m2m_keygen 0.4.9 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.overcommit.yml +4 -7
- data/.rubocop.yml +19 -16
- data/.ruby-version +1 -1
- data/.streerc +1 -0
- data/.tool-versions +1 -1
- data/CHANGELOG.md +70 -1
- data/Gemfile +2 -2
- data/Gemfile.lock +135 -158
- data/README.md +64 -78
- data/docs/MIGRATING.md +69 -0
- data/docs/SPEC.md +245 -0
- data/examples/nonce_store/postgres.rb +40 -0
- data/examples/nonce_store/redis.rb +23 -0
- data/gemfiles/rack_2.gemfile +5 -0
- data/gemfiles/rack_3.gemfile +5 -0
- data/lib/m2m_keygen/canonicalizer.rb +57 -0
- data/lib/m2m_keygen/nonce_store/disabled.rb +19 -0
- data/lib/m2m_keygen/nonce_store/memory.rb +64 -0
- data/lib/m2m_keygen/nonce_store.rb +18 -0
- data/lib/m2m_keygen/rack_validator.rb +86 -19
- data/lib/m2m_keygen/request_signer/signed_request.rb +10 -0
- data/lib/m2m_keygen/request_signer.rb +118 -0
- data/lib/m2m_keygen/signature.rb +41 -39
- data/lib/m2m_keygen/types/params_type.rb +6 -17
- data/lib/m2m_keygen/version.rb +1 -1
- data/lib/m2m_keygen.rb +6 -0
- data/m2m_keygen.gemspec +12 -14
- data/sorbet/config +1 -0
- data/sorbet/rbi/gems/.gitattributes +1 -0
- data/sorbet/rbi/gems/{ast@2.4.2.rbi → ast@2.4.3.rbi} +45 -79
- data/sorbet/rbi/gems/{benchmark@0.2.0.rbi → benchmark@0.5.0.rbi} +91 -58
- data/sorbet/rbi/gems/bundler-audit@0.9.3.rbi +397 -0
- data/sorbet/rbi/gems/byebug@13.0.0.rbi +3651 -0
- data/sorbet/rbi/gems/childprocess@5.1.0.rbi +336 -0
- data/sorbet/rbi/gems/concurrent-ruby@1.3.7.rbi +384 -0
- data/sorbet/rbi/gems/{diff-lcs@1.5.0.rbi → diff-lcs@1.6.2.rbi} +189 -199
- data/sorbet/rbi/gems/{docile@1.4.0.rbi → docile@1.4.1.rbi} +147 -147
- data/sorbet/rbi/gems/dotenv@3.2.0.rbi +267 -0
- data/sorbet/rbi/gems/erubi@1.13.1.rbi +155 -0
- data/sorbet/rbi/gems/{faker@2.22.0.rbi → faker@3.8.0.rbi} +8468 -3829
- data/sorbet/rbi/gems/{ffi@1.15.5.rbi → ffi@1.17.4.rbi} +1 -0
- data/sorbet/rbi/gems/{formatador@1.1.0.rbi → formatador@1.2.3.rbi} +1 -0
- data/sorbet/rbi/gems/{guard@2.18.0.rbi → guard@2.20.1.rbi} +1 -0
- data/sorbet/rbi/gems/{i18n@1.12.0.rbi → i18n@1.15.2.rbi} +687 -827
- data/sorbet/rbi/gems/io-console@0.8.2.rbi +9 -0
- data/sorbet/rbi/gems/{json@2.6.2.rbi → json@2.20.0.rbi} +994 -252
- data/sorbet/rbi/gems/language_server-protocol@3.17.0.5.rbi +9 -0
- data/sorbet/rbi/gems/lint_roller@1.1.0.rbi +189 -0
- data/sorbet/rbi/gems/listen@3.10.0.rbi +9 -0
- data/sorbet/rbi/gems/logger@1.7.0.rbi +896 -0
- data/sorbet/rbi/gems/{lumberjack@1.2.8.rbi → lumberjack@1.4.2.rbi} +1 -0
- data/sorbet/rbi/gems/{method_source@1.0.0.rbi → method_source@1.1.0.rbi} +111 -90
- data/sorbet/rbi/gems/{overcommit@0.59.1.rbi → overcommit@0.71.0.rbi} +503 -647
- data/sorbet/rbi/gems/parallel@2.1.0.rbi +321 -0
- data/sorbet/rbi/gems/parser@3.3.11.1.rbi +5229 -0
- data/sorbet/rbi/gems/prettier_print@1.2.1.rbi +878 -0
- data/sorbet/rbi/gems/prism@1.9.0.rbi +42224 -0
- data/sorbet/rbi/gems/pry-byebug@3.12.0.rbi +481 -0
- data/sorbet/rbi/gems/{pry@0.14.1.rbi → pry@0.16.0.rbi} +2567 -3550
- data/sorbet/rbi/gems/{racc@1.6.0.rbi → racc@1.8.1.rbi} +54 -40
- data/sorbet/rbi/gems/rack@3.2.6.rbi +4653 -0
- data/sorbet/rbi/gems/{rake@13.0.6.rbi → rake@13.4.2.rbi} +963 -732
- data/sorbet/rbi/gems/{rb-inotify@0.10.1.rbi → rb-inotify@0.11.1.rbi} +1 -0
- data/sorbet/rbi/gems/rbi@0.3.14.rbi +5519 -0
- data/sorbet/rbi/gems/rbs@4.0.3.rbi +6908 -0
- data/sorbet/rbi/gems/regexp_parser@2.12.0.rbi +3398 -0
- data/sorbet/rbi/gems/reline@0.6.3.rbi +2446 -0
- data/sorbet/rbi/gems/require-hooks@0.4.0.rbi +152 -0
- data/sorbet/rbi/gems/{rexml@3.2.5.rbi → rexml@3.4.4.rbi} +1085 -894
- data/sorbet/rbi/gems/rspec-core@3.13.6.rbi +9475 -0
- data/sorbet/rbi/gems/rspec-expectations@3.13.5.rbi +6108 -0
- data/sorbet/rbi/gems/rspec-mocks@3.13.8.rbi +4787 -0
- data/sorbet/rbi/gems/rspec-support@3.13.7.rbi +1274 -0
- data/sorbet/rbi/gems/rspec@3.13.2.rbi +15 -0
- data/sorbet/rbi/gems/rspec_in_context@1.2.2.rbi +294 -0
- data/sorbet/rbi/gems/rubocop-ast@1.49.1.rbi +7140 -0
- data/sorbet/rbi/gems/rubocop-faker@1.3.0.rbi +88 -0
- data/sorbet/rbi/gems/rubocop-performance@1.26.1.rbi +3403 -0
- data/sorbet/rbi/gems/rubocop-sorbet@0.12.0.rbi +2448 -0
- data/sorbet/rbi/gems/rubocop@1.88.1.rbi +63103 -0
- data/sorbet/rbi/gems/ruby-lsp@0.26.9.rbi +28 -0
- data/sorbet/rbi/gems/ruby-progressbar@1.13.0.rbi +988 -0
- data/sorbet/rbi/gems/rubydex@0.2.7.rbi +836 -0
- data/sorbet/rbi/gems/simplecov-html@0.13.2.rbi +90 -0
- data/sorbet/rbi/gems/{simplecov@0.21.2.rbi → simplecov@0.22.0.rbi} +438 -578
- data/sorbet/rbi/gems/spoom@1.8.2.rbi +6691 -0
- data/sorbet/rbi/gems/syntax_tree@6.3.0.rbi +22090 -0
- data/sorbet/rbi/gems/tapioca@0.19.2.rbi +3597 -0
- data/sorbet/rbi/gems/{thor@1.2.1.rbi → thor@1.5.0.rbi} +1085 -1171
- data/sorbet/rbi/gems/tsort@0.2.0.rbi +389 -0
- data/sorbet/rbi/gems/unicode-display_width@3.2.0.rbi +130 -0
- data/sorbet/rbi/gems/unicode-emoji@4.2.0.rbi +332 -0
- data/sorbet/rbi/gems/{yard-sorbet@0.7.0.rbi → yard-sorbet@0.9.0.rbi} +142 -99
- data/sorbet/rbi/gems/{yard@0.9.28.rbi → yard@0.9.44.rbi} +5350 -6316
- data/sorbet/rbi/gems/zeitwerk@2.8.2.rbi +1178 -0
- metadata +105 -111
- data/.prettierrc.js +0 -4
- data/docs/M2mKeygen/Error.html +0 -135
- data/docs/M2mKeygen/ParamsEncoder.html +0 -321
- data/docs/M2mKeygen/RackValidator.html +0 -533
- data/docs/M2mKeygen/Signature.html +0 -680
- data/docs/M2mKeygen/Types.html +0 -147
- data/docs/M2mKeygen.html +0 -157
- data/docs/_index.html +0 -182
- data/docs/class_list.html +0 -51
- data/docs/css/common.css +0 -1
- data/docs/css/full_list.css +0 -58
- data/docs/css/style.css +0 -497
- data/docs/file.README.html +0 -230
- data/docs/file_list.html +0 -56
- data/docs/frames.html +0 -17
- data/docs/index.html +0 -230
- data/docs/js/app.js +0 -314
- data/docs/js/full_list.js +0 -216
- data/docs/js/jquery.js +0 -4
- data/docs/method_list.html +0 -139
- data/docs/top-level-namespace.html +0 -110
- data/lib/m2m_keygen/params_encoder.rb +0 -56
- data/package.json +0 -12
- data/sig/m2m_keygen.rbs +0 -4
- data/sorbet/rbi/gems/activesupport@7.0.3.1.rbi +0 -18608
- data/sorbet/rbi/gems/backport@1.2.0.rbi +0 -522
- data/sorbet/rbi/gems/bundler-audit@0.9.1.rbi +0 -308
- data/sorbet/rbi/gems/byebug@11.1.3.rbi +0 -3603
- data/sorbet/rbi/gems/childprocess@4.1.0.rbi +0 -401
- data/sorbet/rbi/gems/concurrent-ruby@1.1.10.rbi +0 -11581
- data/sorbet/rbi/gems/dotenv@2.8.1.rbi +0 -234
- data/sorbet/rbi/gems/e2mmap@0.1.0.rbi +0 -8
- data/sorbet/rbi/gems/haml@5.2.2.rbi +0 -3190
- data/sorbet/rbi/gems/jaro_winkler@1.5.4.rbi +0 -19
- data/sorbet/rbi/gems/kramdown-parser-gfm@1.1.0.rbi +0 -133
- data/sorbet/rbi/gems/kramdown@2.4.0.rbi +0 -3261
- data/sorbet/rbi/gems/listen@3.7.1.rbi +0 -1181
- data/sorbet/rbi/gems/minitest@5.16.3.rbi +0 -1459
- data/sorbet/rbi/gems/nokogiri@1.13.8.rbi +0 -6514
- data/sorbet/rbi/gems/parallel@1.22.1.rbi +0 -277
- data/sorbet/rbi/gems/parser@3.1.2.1.rbi +0 -6826
- data/sorbet/rbi/gems/prettier@3.2.0.rbi +0 -22
- data/sorbet/rbi/gems/prettier_print@0.1.0.rbi +0 -8
- data/sorbet/rbi/gems/pry-byebug@3.10.1.rbi +0 -1222
- data/sorbet/rbi/gems/rack@2.2.4.rbi +0 -5630
- data/sorbet/rbi/gems/rbi@0.0.15.rbi +0 -3007
- data/sorbet/rbi/gems/rbs@2.6.0.rbi +0 -8
- data/sorbet/rbi/gems/regexp_parser@2.5.0.rbi +0 -3366
- data/sorbet/rbi/gems/reverse_markdown@2.1.1.rbi +0 -389
- data/sorbet/rbi/gems/rspec-core@3.11.0.rbi +0 -10786
- data/sorbet/rbi/gems/rspec-expectations@3.11.0.rbi +0 -8170
- data/sorbet/rbi/gems/rspec-mocks@3.11.1.rbi +0 -5385
- data/sorbet/rbi/gems/rspec-support@3.11.0.rbi +0 -1746
- data/sorbet/rbi/gems/rspec@3.11.0.rbi +0 -187
- data/sorbet/rbi/gems/rspec_in_context@1.1.0.3.rbi +0 -1113
- data/sorbet/rbi/gems/rubocop-ast@1.21.0.rbi +0 -7044
- data/sorbet/rbi/gems/rubocop-faker@1.1.0.rbi +0 -106
- data/sorbet/rbi/gems/rubocop-performance@1.14.3.rbi +0 -2982
- data/sorbet/rbi/gems/rubocop-sorbet@0.6.11.rbi +0 -990
- data/sorbet/rbi/gems/rubocop@1.35.1.rbi +0 -52002
- data/sorbet/rbi/gems/ruby-progressbar@1.11.0.rbi +0 -1239
- data/sorbet/rbi/gems/simplecov-html@0.12.3.rbi +0 -219
- data/sorbet/rbi/gems/solargraph@0.46.0.rbi +0 -9075
- data/sorbet/rbi/gems/spoom@1.1.12.rbi +0 -2369
- data/sorbet/rbi/gems/syntax_tree-haml@1.3.1.rbi +0 -8
- data/sorbet/rbi/gems/syntax_tree-rbs@0.5.0.rbi +0 -8
- data/sorbet/rbi/gems/syntax_tree@3.5.0.rbi +0 -8
- data/sorbet/rbi/gems/tapioca@0.9.4.rbi +0 -2946
- data/sorbet/rbi/gems/temple@0.8.2.rbi +0 -1712
- data/sorbet/rbi/gems/tilt@2.0.11.rbi +0 -742
- data/sorbet/rbi/gems/tzinfo@2.0.5.rbi +0 -5914
- data/sorbet/rbi/gems/unicode-display_width@2.2.0.rbi +0 -48
- data/sorbet/rbi/gems/unparser@0.6.5.rbi +0 -4529
- data/sorbet/rbi/gems/webrick@1.7.0.rbi +0 -2553
- data/sorbet/rbi/gems/zeitwerk@2.6.0.rbi +0 -867
- data/sorbet/rbi/manual.rbi +0 -7
- data/yarn.lock +0 -20
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
|
|
3
|
+
module M2mKeygen
|
|
4
|
+
module NonceStore
|
|
5
|
+
# Explicit, greppable opt-out: `add` always succeeds, so replay protection
|
|
6
|
+
# falls back to expiry-only. See docs/ for when that risk is acceptable.
|
|
7
|
+
class Disabled
|
|
8
|
+
include NonceStore
|
|
9
|
+
extend T::Sig
|
|
10
|
+
|
|
11
|
+
# rubocop:disable Lint/UnusedMethodArgument -- interface method, args unused here
|
|
12
|
+
sig { override.params(nonce: String, ttl: Integer).returns(T::Boolean) }
|
|
13
|
+
def add(nonce, ttl:)
|
|
14
|
+
true
|
|
15
|
+
end
|
|
16
|
+
# rubocop:enable Lint/UnusedMethodArgument
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
|
|
3
|
+
module M2mKeygen
|
|
4
|
+
module NonceStore
|
|
5
|
+
# In-process nonce store backed by a Mutex-guarded Hash.
|
|
6
|
+
#
|
|
7
|
+
# ⚠️ SINGLE-PROCESS ONLY. This store lives in this Ruby process' memory.
|
|
8
|
+
# As soon as your service runs more than one process — multiple Puma/Unicorn
|
|
9
|
+
# workers, multiple hosts, a restart between two requests — each process
|
|
10
|
+
# holds its own independent set of seen nonces, so replay protection becomes
|
|
11
|
+
# PARTIAL: a nonce recorded by worker A is invisible to worker B, which will
|
|
12
|
+
# happily accept it again. Use this only for single-process deployments or
|
|
13
|
+
# local development. For multi-worker/multi-host production, back
|
|
14
|
+
# `nonce_store:` with a store shared across processes (e.g. Redis or
|
|
15
|
+
# Postgres) — see the reference implementations under `examples/nonce_store/`.
|
|
16
|
+
class Memory
|
|
17
|
+
include NonceStore
|
|
18
|
+
extend T::Sig
|
|
19
|
+
|
|
20
|
+
DEFAULT_MAX_SIZE = 100_000
|
|
21
|
+
|
|
22
|
+
sig { params(max_size: Integer).void }
|
|
23
|
+
def initialize(max_size: DEFAULT_MAX_SIZE)
|
|
24
|
+
@max_size = T.let(max_size, Integer)
|
|
25
|
+
@mutex = T.let(Mutex.new, Mutex)
|
|
26
|
+
@expirations_by_nonce = T.let({}, T::Hash[String, Float])
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
sig { override.params(nonce: String, ttl: Integer).returns(T::Boolean) }
|
|
30
|
+
def add(nonce, ttl:)
|
|
31
|
+
@mutex.synchronize do
|
|
32
|
+
purge_expired
|
|
33
|
+
next false if @expirations_by_nonce.key?(nonce)
|
|
34
|
+
|
|
35
|
+
evict_soonest_to_expire if at_capacity?
|
|
36
|
+
@expirations_by_nonce[nonce] = Time.now.to_f + ttl
|
|
37
|
+
true
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
sig { void }
|
|
44
|
+
def purge_expired
|
|
45
|
+
now = Time.now.to_f
|
|
46
|
+
@expirations_by_nonce.delete_if do |_nonce, expires_at|
|
|
47
|
+
expires_at <= now
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
sig { returns(T::Boolean) }
|
|
52
|
+
def at_capacity?
|
|
53
|
+
@expirations_by_nonce.size >= @max_size
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
sig { void }
|
|
57
|
+
def evict_soonest_to_expire
|
|
58
|
+
soonest, =
|
|
59
|
+
@expirations_by_nonce.min_by { |_nonce, expires_at| expires_at }
|
|
60
|
+
@expirations_by_nonce.delete(soonest) if soonest
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
|
|
3
|
+
module M2mKeygen
|
|
4
|
+
# `add` MUST be atomic (check-and-set in one step): a separate seen?/record
|
|
5
|
+
# pair would be a TOCTOU race.
|
|
6
|
+
module NonceStore
|
|
7
|
+
extend T::Sig
|
|
8
|
+
extend T::Helpers
|
|
9
|
+
include Kernel
|
|
10
|
+
|
|
11
|
+
interface!
|
|
12
|
+
|
|
13
|
+
# Returns true if newly recorded, false if already seen (replay).
|
|
14
|
+
sig { abstract.params(nonce: String, ttl: Integer).returns(T::Boolean) }
|
|
15
|
+
def add(nonce, ttl:)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -6,40 +6,107 @@ module M2mKeygen
|
|
|
6
6
|
class RackValidator
|
|
7
7
|
extend T::Sig
|
|
8
8
|
|
|
9
|
+
DEFAULT_WINDOW_SECONDS = 120
|
|
10
|
+
NONCE_TTL_MARGIN_SECONDS = 5
|
|
11
|
+
MAX_NONCE_BYTES = 256
|
|
12
|
+
|
|
9
13
|
sig { returns(Signature) }
|
|
10
14
|
attr_reader :signature
|
|
11
15
|
|
|
12
16
|
sig { returns(String) }
|
|
13
17
|
attr_reader :header_name
|
|
14
18
|
|
|
15
|
-
sig
|
|
16
|
-
|
|
17
|
-
|
|
19
|
+
sig do
|
|
20
|
+
params(
|
|
21
|
+
secret: String,
|
|
22
|
+
nonce_store: NonceStore,
|
|
23
|
+
algorithm: String,
|
|
24
|
+
header_name: String,
|
|
25
|
+
window: Integer,
|
|
26
|
+
expiry_header: String,
|
|
27
|
+
nonce_header: String,
|
|
28
|
+
).void
|
|
29
|
+
end
|
|
30
|
+
def initialize(
|
|
31
|
+
secret,
|
|
32
|
+
nonce_store:,
|
|
33
|
+
algorithm: 'sha512',
|
|
34
|
+
header_name: 'X-Signature',
|
|
35
|
+
window: DEFAULT_WINDOW_SECONDS,
|
|
36
|
+
expiry_header: 'X-M2M-Expiry',
|
|
37
|
+
nonce_header: 'X-M2M-Nonce'
|
|
38
|
+
)
|
|
18
39
|
@signature = T.let(Signature.new(secret, algorithm: algorithm), Signature)
|
|
40
|
+
@nonce_store = T.let(nonce_store, NonceStore)
|
|
41
|
+
@window = T.let(window, Integer)
|
|
42
|
+
@header_name = T.let(env_key_for(header_name), String)
|
|
43
|
+
@expiry_header = T.let(env_key_for(expiry_header), String)
|
|
44
|
+
@nonce_header = T.let(env_key_for(nonce_header), String)
|
|
19
45
|
end
|
|
20
46
|
|
|
21
47
|
sig { params(req: T.untyped).returns(T::Boolean) }
|
|
22
48
|
def validate(req)
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
49
|
+
request = Rack::Request.new(req.env)
|
|
50
|
+
nonce = request.env[@nonce_header].to_s
|
|
51
|
+
return false if nonce.empty? && nonce_required?
|
|
52
|
+
return false if nonce.bytesize > MAX_NONCE_BYTES
|
|
53
|
+
|
|
54
|
+
expiry = request.env[@expiry_header].to_i
|
|
55
|
+
body = read_body(request)
|
|
56
|
+
|
|
57
|
+
valid_signature =
|
|
58
|
+
@signature.validate(
|
|
59
|
+
signature: request.env[@header_name].to_s,
|
|
60
|
+
verb: request.request_method || 'GET',
|
|
61
|
+
path: request.path || '/',
|
|
62
|
+
expiry: expiry,
|
|
63
|
+
nonce: nonce,
|
|
64
|
+
query: request.query_string || '',
|
|
65
|
+
body: body,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
!!(
|
|
69
|
+
valid_signature && expiry_within_window?(expiry) &&
|
|
70
|
+
@nonce_store.add(nonce, ttl: nonce_ttl(expiry))
|
|
71
|
+
)
|
|
33
72
|
end
|
|
34
73
|
|
|
35
74
|
private
|
|
36
75
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
76
|
+
# Rack 3 no longer guarantees a rewindable (or even present) input stream:
|
|
77
|
+
# under `Rack::Lint` (rackup's development default) `#rewind` is not
|
|
78
|
+
# exposed, and `rack.input` itself is optional. Read what we can and only
|
|
79
|
+
# rewind when the stream supports it, so downstream readers still work
|
|
80
|
+
# where they used to.
|
|
81
|
+
sig { params(request: Rack::Request).returns(String) }
|
|
82
|
+
def read_body(request)
|
|
83
|
+
input = request.body
|
|
84
|
+
return '' if input.nil?
|
|
85
|
+
|
|
86
|
+
body = input.read.to_s
|
|
87
|
+
input.rewind if input.respond_to?(:rewind)
|
|
88
|
+
body
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
sig { returns(T::Boolean) }
|
|
92
|
+
def nonce_required?
|
|
93
|
+
!@nonce_store.is_a?(NonceStore::Disabled)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
sig { params(expiry: Integer).returns(T::Boolean) }
|
|
97
|
+
def expiry_within_window?(expiry)
|
|
98
|
+
now = Time.now.to_i
|
|
99
|
+
now < expiry && expiry < now + @window
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
sig { params(expiry: Integer).returns(Integer) }
|
|
103
|
+
def nonce_ttl(expiry)
|
|
104
|
+
expiry - Time.now.to_i + NONCE_TTL_MARGIN_SECONDS
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
sig { params(name: String).returns(String) }
|
|
108
|
+
def env_key_for(name)
|
|
109
|
+
"HTTP_#{name.tr('-', '_').upcase}"
|
|
43
110
|
end
|
|
44
111
|
end
|
|
45
112
|
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
|
|
3
|
+
require 'rack'
|
|
4
|
+
require 'securerandom'
|
|
5
|
+
|
|
6
|
+
module M2mKeygen
|
|
7
|
+
class RequestSigner
|
|
8
|
+
extend T::Sig
|
|
9
|
+
|
|
10
|
+
DEFAULT_EXPIRY_TTL_SECONDS = 90
|
|
11
|
+
NONCE_BYTES = 16
|
|
12
|
+
|
|
13
|
+
sig do
|
|
14
|
+
params(
|
|
15
|
+
secret: String,
|
|
16
|
+
algorithm: String,
|
|
17
|
+
header_name: String,
|
|
18
|
+
expiry_header: String,
|
|
19
|
+
nonce_header: String,
|
|
20
|
+
).void
|
|
21
|
+
end
|
|
22
|
+
def initialize(
|
|
23
|
+
secret,
|
|
24
|
+
algorithm: 'sha512',
|
|
25
|
+
header_name: 'X-Signature',
|
|
26
|
+
expiry_header: 'X-M2M-Expiry',
|
|
27
|
+
nonce_header: 'X-M2M-Nonce'
|
|
28
|
+
)
|
|
29
|
+
@signature = T.let(Signature.new(secret, algorithm: algorithm), Signature)
|
|
30
|
+
@header_name = T.let(header_name, String)
|
|
31
|
+
@expiry_header = T.let(expiry_header, String)
|
|
32
|
+
@nonce_header = T.let(nonce_header, String)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
sig do
|
|
36
|
+
params(
|
|
37
|
+
verb: String,
|
|
38
|
+
path: String,
|
|
39
|
+
params: Types::ParamsType,
|
|
40
|
+
body: String,
|
|
41
|
+
expiry: T.nilable(Integer),
|
|
42
|
+
nonce: T.nilable(String),
|
|
43
|
+
).returns(SignedRequest)
|
|
44
|
+
end
|
|
45
|
+
def sign_request(
|
|
46
|
+
verb:,
|
|
47
|
+
path:,
|
|
48
|
+
params: {},
|
|
49
|
+
body: '',
|
|
50
|
+
expiry: nil,
|
|
51
|
+
nonce: nil
|
|
52
|
+
)
|
|
53
|
+
resolved_nonce = nonce || SecureRandom.hex(NONCE_BYTES)
|
|
54
|
+
resolved_expiry = expiry || (Time.now.to_i + DEFAULT_EXPIRY_TTL_SECONDS)
|
|
55
|
+
query = build_query(params)
|
|
56
|
+
|
|
57
|
+
signature =
|
|
58
|
+
@signature.sign(
|
|
59
|
+
verb: verb,
|
|
60
|
+
path: path,
|
|
61
|
+
expiry: resolved_expiry,
|
|
62
|
+
nonce: resolved_nonce,
|
|
63
|
+
query: query,
|
|
64
|
+
body: body,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
SignedRequest.new(
|
|
68
|
+
headers: {
|
|
69
|
+
@header_name => signature,
|
|
70
|
+
@expiry_header => resolved_expiry.to_s,
|
|
71
|
+
@nonce_header => resolved_nonce,
|
|
72
|
+
},
|
|
73
|
+
query: query,
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
sig { params(params: Types::ParamsType).returns(String) }
|
|
80
|
+
def build_query(params)
|
|
81
|
+
return '' if params.empty?
|
|
82
|
+
|
|
83
|
+
Rack::Utils.build_query(stringify_values(params))
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
sig do
|
|
87
|
+
params(params: Types::ParamsType).returns(T::Hash[String, T.untyped])
|
|
88
|
+
end
|
|
89
|
+
def stringify_values(params)
|
|
90
|
+
params
|
|
91
|
+
.transform_keys(&:to_s)
|
|
92
|
+
.transform_values { |value| stringify(value) }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
sig do
|
|
96
|
+
params(value: Types::ParamsValueType).returns(
|
|
97
|
+
T.any(String, T::Array[String]),
|
|
98
|
+
)
|
|
99
|
+
end
|
|
100
|
+
def stringify(value)
|
|
101
|
+
if value.is_a?(Array)
|
|
102
|
+
return value.map { |element| stringify_scalar(element) }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
stringify_scalar(value)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
sig { params(value: Types::ParamsScalarType).returns(String) }
|
|
109
|
+
def stringify_scalar(value)
|
|
110
|
+
if value.is_a?(Float) && !value.finite?
|
|
111
|
+
raise CanonicalizationError,
|
|
112
|
+
"Non-finite float values cannot be signed (NaN/Infinity): #{value.inspect}"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
value.to_s
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
data/lib/m2m_keygen/signature.rb
CHANGED
|
@@ -1,15 +1,11 @@
|
|
|
1
1
|
# typed: strict
|
|
2
2
|
|
|
3
3
|
require 'openssl'
|
|
4
|
-
require 'json'
|
|
5
4
|
|
|
6
5
|
module M2mKeygen
|
|
7
6
|
class Signature
|
|
8
7
|
extend T::Sig
|
|
9
8
|
|
|
10
|
-
sig { returns(String) }
|
|
11
|
-
attr_reader :secret
|
|
12
|
-
|
|
13
9
|
sig { returns(String) }
|
|
14
10
|
attr_reader :algorithm
|
|
15
11
|
|
|
@@ -17,62 +13,68 @@ module M2mKeygen
|
|
|
17
13
|
def initialize(secret, algorithm: 'sha512')
|
|
18
14
|
@secret = T.let(secret, String)
|
|
19
15
|
@algorithm = T.let(algorithm, String)
|
|
16
|
+
# Fail fast on an unsupported algorithm (OpenSSL's error class varies).
|
|
20
17
|
OpenSSL::HMAC.hexdigest(@algorithm, @secret, '')
|
|
18
|
+
rescue StandardError => e
|
|
19
|
+
raise Error,
|
|
20
|
+
"Unsupported HMAC algorithm #{algorithm.inspect}: #{e.message}"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
sig { returns(String) }
|
|
24
|
+
def inspect
|
|
25
|
+
"#<#{self.class.name} algorithm=#{@algorithm.inspect}>"
|
|
21
26
|
end
|
|
22
27
|
|
|
23
28
|
sig do
|
|
24
29
|
params(
|
|
25
|
-
|
|
26
|
-
verb: T.any(String, Symbol),
|
|
30
|
+
verb: String,
|
|
27
31
|
path: String,
|
|
32
|
+
expiry: Integer,
|
|
33
|
+
nonce: String,
|
|
34
|
+
query: String,
|
|
35
|
+
body: String,
|
|
28
36
|
).returns(String)
|
|
29
37
|
end
|
|
30
|
-
def sign(
|
|
38
|
+
def sign(verb:, path:, expiry:, nonce:, query: '', body: '')
|
|
31
39
|
OpenSSL::HMAC.hexdigest(
|
|
32
40
|
@algorithm,
|
|
33
41
|
@secret,
|
|
34
|
-
|
|
42
|
+
Canonicalizer.canonical(
|
|
43
|
+
verb: verb,
|
|
44
|
+
path: path,
|
|
45
|
+
expiry: expiry,
|
|
46
|
+
nonce: nonce,
|
|
47
|
+
query: query,
|
|
48
|
+
body: body,
|
|
49
|
+
),
|
|
35
50
|
)
|
|
36
51
|
end
|
|
37
52
|
|
|
38
53
|
sig do
|
|
39
54
|
params(
|
|
40
|
-
params: Types::ParamsType,
|
|
41
|
-
verb: T.any(String, Symbol),
|
|
42
|
-
path: String,
|
|
43
55
|
signature: String,
|
|
56
|
+
verb: String,
|
|
57
|
+
path: String,
|
|
58
|
+
expiry: Integer,
|
|
59
|
+
nonce: String,
|
|
60
|
+
query: String,
|
|
61
|
+
body: String,
|
|
44
62
|
).returns(T::Boolean)
|
|
45
63
|
end
|
|
46
|
-
def validate(
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
64
|
+
def validate(signature:, verb:, path:, expiry:, nonce:, query: '', body: '')
|
|
65
|
+
OpenSSL.fixed_length_secure_compare(
|
|
66
|
+
sign(
|
|
67
|
+
verb: verb,
|
|
68
|
+
path: path,
|
|
69
|
+
expiry: expiry,
|
|
70
|
+
nonce: nonce,
|
|
71
|
+
query: query,
|
|
72
|
+
body: body,
|
|
73
|
+
),
|
|
74
|
+
signature,
|
|
75
|
+
)
|
|
58
76
|
rescue StandardError
|
|
59
77
|
false
|
|
60
78
|
end
|
|
61
|
-
|
|
62
|
-
private
|
|
63
|
-
|
|
64
|
-
# Ruby 2.7 openssl lib doesn't have fixed_length_secure_compare method
|
|
65
|
-
# File activesupport/lib/active_support/security_utils.rb, line 11
|
|
66
|
-
# With sorbet fix
|
|
67
|
-
sig { params(str_a: String, str_b: String).returns(T::Boolean) }
|
|
68
|
-
def fallback_fixed_length_secure_compare(str_a, str_b)
|
|
69
|
-
return false unless str_a.bytesize == str_b.bytesize
|
|
70
|
-
|
|
71
|
-
l = str_a.unpack "C#{str_a.bytesize}"
|
|
72
|
-
|
|
73
|
-
res = 0
|
|
74
|
-
str_b.each_byte { |byte| res |= byte ^ l.shift.to_i }
|
|
75
|
-
res == 0
|
|
76
|
-
end
|
|
77
79
|
end
|
|
78
80
|
end
|
|
@@ -3,24 +3,13 @@ module M2mKeygen
|
|
|
3
3
|
module Types
|
|
4
4
|
extend T::Sig
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
T.type_alias
|
|
8
|
-
T.nilable(T::Hash[T.any(String, Symbol), T.nilable(ParamsValueType)])
|
|
9
|
-
end
|
|
10
|
-
|
|
11
|
-
ParamsHashNotNilType =
|
|
12
|
-
T.type_alias { T::Hash[T.any(String, Symbol), ParamsValueType] }
|
|
6
|
+
ParamsScalarType =
|
|
7
|
+
T.type_alias { T.any(String, Symbol, Integer, Float, T::Boolean) }
|
|
13
8
|
|
|
14
9
|
ParamsValueType =
|
|
15
|
-
T.type_alias
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
Symbol,
|
|
20
|
-
T::Boolean,
|
|
21
|
-
T::Array[T.untyped],
|
|
22
|
-
T::Hash[T.untyped, T.untyped],
|
|
23
|
-
)
|
|
24
|
-
end
|
|
10
|
+
T.type_alias { T.any(ParamsScalarType, T::Array[ParamsScalarType]) }
|
|
11
|
+
|
|
12
|
+
ParamsType =
|
|
13
|
+
T.type_alias { T::Hash[T.any(String, Symbol), ParamsValueType] }
|
|
25
14
|
end
|
|
26
15
|
end
|
data/lib/m2m_keygen/version.rb
CHANGED
data/lib/m2m_keygen.rb
CHANGED
|
@@ -11,4 +11,10 @@ module M2mKeygen
|
|
|
11
11
|
# Standard error for the gem
|
|
12
12
|
class Error < StandardError
|
|
13
13
|
end
|
|
14
|
+
|
|
15
|
+
# Raised when an input cannot be turned into the canonical signing string
|
|
16
|
+
# (e.g. an unsupported value type). Surfaces loudly on the signer side; the
|
|
17
|
+
# verifier turns it into a `false` result.
|
|
18
|
+
class CanonicalizationError < Error
|
|
19
|
+
end
|
|
14
20
|
end
|
data/m2m_keygen.gemspec
CHANGED
|
@@ -11,19 +11,17 @@ Gem::Specification.new do |spec|
|
|
|
11
11
|
spec.summary = 'Secure M2M key generator'
|
|
12
12
|
spec.description =
|
|
13
13
|
'Secure M2M key generator for Ruby. Generates secure keys for M2M communication in REST APIs.'
|
|
14
|
-
spec.homepage = 'https://github.com/
|
|
14
|
+
spec.homepage = 'https://github.com/zaratan/m2m_keygen_ruby'
|
|
15
15
|
spec.license = 'MIT'
|
|
16
|
-
spec.required_ruby_version = '>=
|
|
16
|
+
spec.required_ruby_version = '>= 3.3.0'
|
|
17
17
|
|
|
18
|
-
spec.metadata
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
'changelog_uri'
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
] = 'https://billcorporate.github.io/m2m_keygen_ruby'
|
|
26
|
-
spec.metadata = { 'rubygems_mfa_required' => 'true' }
|
|
18
|
+
spec.metadata = {
|
|
19
|
+
'homepage_uri' => spec.homepage,
|
|
20
|
+
'source_code_uri' => spec.homepage,
|
|
21
|
+
'changelog_uri' => "#{spec.homepage}/blob/main/CHANGELOG.md",
|
|
22
|
+
'bug_tracker_uri' => "#{spec.homepage}/issues",
|
|
23
|
+
'rubygems_mfa_required' => 'true',
|
|
24
|
+
}
|
|
27
25
|
|
|
28
26
|
# Specify which files should be added to the gem when it is released.
|
|
29
27
|
# The `git ls-files -z` loads the files in the RubyGem that have been added into git.
|
|
@@ -41,7 +39,7 @@ Gem::Specification.new do |spec|
|
|
|
41
39
|
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
|
|
42
40
|
spec.require_paths = ['lib']
|
|
43
41
|
|
|
44
|
-
spec.add_dependency 'rack'
|
|
45
|
-
spec.add_dependency 'sorbet-runtime'
|
|
46
|
-
spec.add_dependency 'zeitwerk', '>= 2.6'
|
|
42
|
+
spec.add_dependency 'rack', '>= 2.2', '< 4.0'
|
|
43
|
+
spec.add_dependency 'sorbet-runtime', '>= 0.5'
|
|
44
|
+
spec.add_dependency 'zeitwerk', '>= 2.6', '< 3.0'
|
|
47
45
|
end
|
data/sorbet/config
CHANGED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
**/*.rbi linguist-generated=true
|