m2m_keygen 0.4.7 → 0.5.0

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.
Files changed (174) hide show
  1. checksums.yaml +4 -4
  2. data/.overcommit.yml +4 -7
  3. data/.rubocop.yml +19 -16
  4. data/.ruby-version +1 -1
  5. data/.streerc +1 -0
  6. data/.tool-versions +1 -0
  7. data/CHANGELOG.md +73 -1
  8. data/Gemfile +2 -2
  9. data/Gemfile.lock +138 -160
  10. data/README.md +64 -78
  11. data/docs/MIGRATING.md +69 -0
  12. data/docs/SPEC.md +245 -0
  13. data/examples/nonce_store/postgres.rb +40 -0
  14. data/examples/nonce_store/redis.rb +23 -0
  15. data/gemfiles/rack_2.gemfile +5 -0
  16. data/gemfiles/rack_3.gemfile +5 -0
  17. data/lib/m2m_keygen/canonicalizer.rb +57 -0
  18. data/lib/m2m_keygen/nonce_store/disabled.rb +19 -0
  19. data/lib/m2m_keygen/nonce_store/memory.rb +64 -0
  20. data/lib/m2m_keygen/nonce_store.rb +18 -0
  21. data/lib/m2m_keygen/rack_validator.rb +72 -19
  22. data/lib/m2m_keygen/request_signer/signed_request.rb +10 -0
  23. data/lib/m2m_keygen/request_signer.rb +118 -0
  24. data/lib/m2m_keygen/signature.rb +41 -39
  25. data/lib/m2m_keygen/types/params_type.rb +6 -16
  26. data/lib/m2m_keygen/version.rb +1 -1
  27. data/lib/m2m_keygen.rb +6 -0
  28. data/m2m_keygen.gemspec +12 -14
  29. data/sorbet/config +1 -0
  30. data/sorbet/rbi/gems/.gitattributes +1 -0
  31. data/sorbet/rbi/gems/{ast@2.4.2.rbi → ast@2.4.3.rbi} +45 -79
  32. data/sorbet/rbi/gems/{benchmark@0.2.0.rbi → benchmark@0.5.0.rbi} +91 -58
  33. data/sorbet/rbi/gems/bundler-audit@0.9.3.rbi +397 -0
  34. data/sorbet/rbi/gems/byebug@13.0.0.rbi +3651 -0
  35. data/sorbet/rbi/gems/childprocess@5.1.0.rbi +336 -0
  36. data/sorbet/rbi/gems/concurrent-ruby@1.3.7.rbi +384 -0
  37. data/sorbet/rbi/gems/{diff-lcs@1.5.0.rbi → diff-lcs@1.6.2.rbi} +189 -199
  38. data/sorbet/rbi/gems/{docile@1.4.0.rbi → docile@1.4.1.rbi} +147 -147
  39. data/sorbet/rbi/gems/dotenv@3.2.0.rbi +267 -0
  40. data/sorbet/rbi/gems/erubi@1.13.1.rbi +155 -0
  41. data/sorbet/rbi/gems/{faker@2.22.0.rbi → faker@3.8.0.rbi} +8468 -3829
  42. data/sorbet/rbi/gems/{ffi@1.15.5.rbi → ffi@1.17.4.rbi} +1 -0
  43. data/sorbet/rbi/gems/{formatador@1.1.0.rbi → formatador@1.2.3.rbi} +1 -0
  44. data/sorbet/rbi/gems/{guard@2.18.0.rbi → guard@2.20.1.rbi} +1 -0
  45. data/sorbet/rbi/gems/{i18n@1.12.0.rbi → i18n@1.15.2.rbi} +687 -827
  46. data/sorbet/rbi/gems/io-console@0.8.2.rbi +9 -0
  47. data/sorbet/rbi/gems/{json@2.6.2.rbi → json@2.20.0.rbi} +994 -252
  48. data/sorbet/rbi/gems/language_server-protocol@3.17.0.5.rbi +9 -0
  49. data/sorbet/rbi/gems/lint_roller@1.1.0.rbi +189 -0
  50. data/sorbet/rbi/gems/listen@3.10.0.rbi +9 -0
  51. data/sorbet/rbi/gems/logger@1.7.0.rbi +896 -0
  52. data/sorbet/rbi/gems/{lumberjack@1.2.8.rbi → lumberjack@1.4.2.rbi} +1 -0
  53. data/sorbet/rbi/gems/{method_source@1.0.0.rbi → method_source@1.1.0.rbi} +111 -90
  54. data/sorbet/rbi/gems/{overcommit@0.59.1.rbi → overcommit@0.71.0.rbi} +503 -647
  55. data/sorbet/rbi/gems/parallel@2.1.0.rbi +321 -0
  56. data/sorbet/rbi/gems/parser@3.3.11.1.rbi +5229 -0
  57. data/sorbet/rbi/gems/prettier_print@1.2.1.rbi +878 -0
  58. data/sorbet/rbi/gems/prism@1.9.0.rbi +42224 -0
  59. data/sorbet/rbi/gems/pry-byebug@3.12.0.rbi +481 -0
  60. data/sorbet/rbi/gems/{pry@0.14.1.rbi → pry@0.16.0.rbi} +2567 -3550
  61. data/sorbet/rbi/gems/{racc@1.6.0.rbi → racc@1.8.1.rbi} +54 -40
  62. data/sorbet/rbi/gems/rack@3.2.6.rbi +4653 -0
  63. data/sorbet/rbi/gems/{rake@13.0.6.rbi → rake@13.4.2.rbi} +963 -732
  64. data/sorbet/rbi/gems/{rb-inotify@0.10.1.rbi → rb-inotify@0.11.1.rbi} +1 -0
  65. data/sorbet/rbi/gems/rbi@0.3.14.rbi +5519 -0
  66. data/sorbet/rbi/gems/rbs@4.0.3.rbi +6908 -0
  67. data/sorbet/rbi/gems/regexp_parser@2.12.0.rbi +3398 -0
  68. data/sorbet/rbi/gems/reline@0.6.3.rbi +2446 -0
  69. data/sorbet/rbi/gems/require-hooks@0.4.0.rbi +152 -0
  70. data/sorbet/rbi/gems/{rexml@3.2.5.rbi → rexml@3.4.4.rbi} +1085 -894
  71. data/sorbet/rbi/gems/rspec-core@3.13.6.rbi +9475 -0
  72. data/sorbet/rbi/gems/rspec-expectations@3.13.5.rbi +6108 -0
  73. data/sorbet/rbi/gems/rspec-mocks@3.13.8.rbi +4787 -0
  74. data/sorbet/rbi/gems/rspec-support@3.13.7.rbi +1274 -0
  75. data/sorbet/rbi/gems/rspec@3.13.2.rbi +15 -0
  76. data/sorbet/rbi/gems/rspec_in_context@1.2.2.rbi +294 -0
  77. data/sorbet/rbi/gems/rubocop-ast@1.49.1.rbi +7140 -0
  78. data/sorbet/rbi/gems/rubocop-faker@1.3.0.rbi +88 -0
  79. data/sorbet/rbi/gems/rubocop-performance@1.26.1.rbi +3403 -0
  80. data/sorbet/rbi/gems/rubocop-sorbet@0.12.0.rbi +2448 -0
  81. data/sorbet/rbi/gems/rubocop@1.88.1.rbi +63103 -0
  82. data/sorbet/rbi/gems/ruby-lsp@0.26.9.rbi +28 -0
  83. data/sorbet/rbi/gems/ruby-progressbar@1.13.0.rbi +988 -0
  84. data/sorbet/rbi/gems/rubydex@0.2.7.rbi +836 -0
  85. data/sorbet/rbi/gems/simplecov-html@0.13.2.rbi +90 -0
  86. data/sorbet/rbi/gems/{simplecov@0.21.2.rbi → simplecov@0.22.0.rbi} +438 -578
  87. data/sorbet/rbi/gems/spoom@1.8.2.rbi +6691 -0
  88. data/sorbet/rbi/gems/syntax_tree@6.3.0.rbi +22090 -0
  89. data/sorbet/rbi/gems/tapioca@0.19.2.rbi +3597 -0
  90. data/sorbet/rbi/gems/{thor@1.2.1.rbi → thor@1.5.0.rbi} +1085 -1171
  91. data/sorbet/rbi/gems/tsort@0.2.0.rbi +389 -0
  92. data/sorbet/rbi/gems/unicode-display_width@3.2.0.rbi +130 -0
  93. data/sorbet/rbi/gems/unicode-emoji@4.2.0.rbi +332 -0
  94. data/sorbet/rbi/gems/{yard-sorbet@0.7.0.rbi → yard-sorbet@0.9.0.rbi} +142 -99
  95. data/sorbet/rbi/gems/{yard@0.9.28.rbi → yard@0.9.44.rbi} +5350 -6316
  96. data/sorbet/rbi/gems/zeitwerk@2.8.2.rbi +1178 -0
  97. metadata +106 -111
  98. data/.prettierrc.js +0 -4
  99. data/docs/M2mKeygen/Error.html +0 -135
  100. data/docs/M2mKeygen/ParamsEncoder.html +0 -321
  101. data/docs/M2mKeygen/RackValidator.html +0 -533
  102. data/docs/M2mKeygen/Signature.html +0 -680
  103. data/docs/M2mKeygen/Types.html +0 -147
  104. data/docs/M2mKeygen.html +0 -157
  105. data/docs/_index.html +0 -182
  106. data/docs/class_list.html +0 -51
  107. data/docs/css/common.css +0 -1
  108. data/docs/css/full_list.css +0 -58
  109. data/docs/css/style.css +0 -497
  110. data/docs/file.README.html +0 -230
  111. data/docs/file_list.html +0 -56
  112. data/docs/frames.html +0 -17
  113. data/docs/index.html +0 -230
  114. data/docs/js/app.js +0 -314
  115. data/docs/js/full_list.js +0 -216
  116. data/docs/js/jquery.js +0 -4
  117. data/docs/method_list.html +0 -139
  118. data/docs/top-level-namespace.html +0 -110
  119. data/lib/m2m_keygen/params_encoder.rb +0 -56
  120. data/package.json +0 -12
  121. data/sig/m2m_keygen.rbs +0 -4
  122. data/sorbet/rbi/gems/activesupport@7.0.3.1.rbi +0 -18608
  123. data/sorbet/rbi/gems/backport@1.2.0.rbi +0 -522
  124. data/sorbet/rbi/gems/bundler-audit@0.9.1.rbi +0 -308
  125. data/sorbet/rbi/gems/byebug@11.1.3.rbi +0 -3603
  126. data/sorbet/rbi/gems/childprocess@4.1.0.rbi +0 -401
  127. data/sorbet/rbi/gems/concurrent-ruby@1.1.10.rbi +0 -11581
  128. data/sorbet/rbi/gems/dotenv@2.8.1.rbi +0 -234
  129. data/sorbet/rbi/gems/e2mmap@0.1.0.rbi +0 -8
  130. data/sorbet/rbi/gems/haml@5.2.2.rbi +0 -3190
  131. data/sorbet/rbi/gems/jaro_winkler@1.5.4.rbi +0 -19
  132. data/sorbet/rbi/gems/kramdown-parser-gfm@1.1.0.rbi +0 -133
  133. data/sorbet/rbi/gems/kramdown@2.4.0.rbi +0 -3261
  134. data/sorbet/rbi/gems/listen@3.7.1.rbi +0 -1181
  135. data/sorbet/rbi/gems/minitest@5.16.3.rbi +0 -1459
  136. data/sorbet/rbi/gems/nokogiri@1.13.8.rbi +0 -6514
  137. data/sorbet/rbi/gems/parallel@1.22.1.rbi +0 -277
  138. data/sorbet/rbi/gems/parser@3.1.2.1.rbi +0 -6826
  139. data/sorbet/rbi/gems/prettier@3.2.0.rbi +0 -22
  140. data/sorbet/rbi/gems/prettier_print@0.1.0.rbi +0 -8
  141. data/sorbet/rbi/gems/pry-byebug@3.10.1.rbi +0 -1222
  142. data/sorbet/rbi/gems/rack@2.2.4.rbi +0 -5630
  143. data/sorbet/rbi/gems/rbi@0.0.15.rbi +0 -3007
  144. data/sorbet/rbi/gems/rbs@2.6.0.rbi +0 -8
  145. data/sorbet/rbi/gems/regexp_parser@2.5.0.rbi +0 -3366
  146. data/sorbet/rbi/gems/reverse_markdown@2.1.1.rbi +0 -389
  147. data/sorbet/rbi/gems/rspec-core@3.11.0.rbi +0 -10786
  148. data/sorbet/rbi/gems/rspec-expectations@3.11.0.rbi +0 -8170
  149. data/sorbet/rbi/gems/rspec-mocks@3.11.1.rbi +0 -5385
  150. data/sorbet/rbi/gems/rspec-support@3.11.0.rbi +0 -1746
  151. data/sorbet/rbi/gems/rspec@3.11.0.rbi +0 -187
  152. data/sorbet/rbi/gems/rspec_in_context@1.1.0.3.rbi +0 -1113
  153. data/sorbet/rbi/gems/rubocop-ast@1.21.0.rbi +0 -7044
  154. data/sorbet/rbi/gems/rubocop-faker@1.1.0.rbi +0 -106
  155. data/sorbet/rbi/gems/rubocop-performance@1.14.3.rbi +0 -2982
  156. data/sorbet/rbi/gems/rubocop-sorbet@0.6.11.rbi +0 -990
  157. data/sorbet/rbi/gems/rubocop@1.35.1.rbi +0 -52002
  158. data/sorbet/rbi/gems/ruby-progressbar@1.11.0.rbi +0 -1239
  159. data/sorbet/rbi/gems/simplecov-html@0.12.3.rbi +0 -219
  160. data/sorbet/rbi/gems/solargraph@0.46.0.rbi +0 -9075
  161. data/sorbet/rbi/gems/spoom@1.1.12.rbi +0 -2369
  162. data/sorbet/rbi/gems/syntax_tree-haml@1.3.1.rbi +0 -8
  163. data/sorbet/rbi/gems/syntax_tree-rbs@0.5.0.rbi +0 -8
  164. data/sorbet/rbi/gems/syntax_tree@3.5.0.rbi +0 -8
  165. data/sorbet/rbi/gems/tapioca@0.9.4.rbi +0 -2946
  166. data/sorbet/rbi/gems/temple@0.8.2.rbi +0 -1712
  167. data/sorbet/rbi/gems/tilt@2.0.11.rbi +0 -742
  168. data/sorbet/rbi/gems/tzinfo@2.0.5.rbi +0 -5914
  169. data/sorbet/rbi/gems/unicode-display_width@2.2.0.rbi +0 -48
  170. data/sorbet/rbi/gems/unparser@0.6.5.rbi +0 -4529
  171. data/sorbet/rbi/gems/webrick@1.7.0.rbi +0 -2553
  172. data/sorbet/rbi/gems/zeitwerk@2.6.0.rbi +0 -867
  173. data/sorbet/rbi/manual.rbi +0 -7
  174. 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,93 @@ 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 { params(secret: String, algorithm: String, header_name: String).void }
16
- def initialize(secret, algorithm: 'sha512', header_name: 'X-Signature')
17
- @header_name = T.let("HTTP_#{header_name.tr('-', '_').upcase}", String)
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
- # This will cover the case when Rails is used.
24
- req = Rack::Request.new(req.env)
25
- params = (req.params || {}).merge(parse_json_body(req))
26
- !!@signature.validate(
27
- params: params,
28
- verb: req.request_method || 'get',
29
- path: req.path || '/',
30
- signature: req.env['HTTP_X_SIGNATURE'] || '',
31
- ) && params['expiry'] && params['expiry'].to_i > Time.now.to_i &&
32
- params['expiry'].to_i < Time.now.to_i + 120
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 = request.body.read.to_s
56
+ request.body.rewind
57
+
58
+ valid_signature =
59
+ @signature.validate(
60
+ signature: request.env[@header_name].to_s,
61
+ verb: request.request_method || 'GET',
62
+ path: request.path || '/',
63
+ expiry: expiry,
64
+ nonce: nonce,
65
+ query: request.query_string || '',
66
+ body: body,
67
+ )
68
+
69
+ !!(
70
+ valid_signature && expiry_within_window?(expiry) &&
71
+ @nonce_store.add(nonce, ttl: nonce_ttl(expiry))
72
+ )
33
73
  end
34
74
 
35
75
  private
36
76
 
37
- sig { params(req: T.untyped).returns(T::Hash[T.untyped, T.untyped]) }
38
- def parse_json_body(req)
39
- body = req.body.read
40
- JSON.parse(body || '{}')
41
- rescue JSON::ParserError
42
- {}
77
+ sig { returns(T::Boolean) }
78
+ def nonce_required?
79
+ !@nonce_store.is_a?(NonceStore::Disabled)
80
+ end
81
+
82
+ sig { params(expiry: Integer).returns(T::Boolean) }
83
+ def expiry_within_window?(expiry)
84
+ now = Time.now.to_i
85
+ now < expiry && expiry < now + @window
86
+ end
87
+
88
+ sig { params(expiry: Integer).returns(Integer) }
89
+ def nonce_ttl(expiry)
90
+ expiry - Time.now.to_i + NONCE_TTL_MARGIN_SECONDS
91
+ end
92
+
93
+ sig { params(name: String).returns(String) }
94
+ def env_key_for(name)
95
+ "HTTP_#{name.tr('-', '_').upcase}"
43
96
  end
44
97
  end
45
98
  end
@@ -0,0 +1,10 @@
1
+ # typed: strict
2
+
3
+ module M2mKeygen
4
+ class RequestSigner
5
+ class SignedRequest < T::Struct
6
+ const :headers, T::Hash[String, String]
7
+ const :query, String
8
+ end
9
+ end
10
+ 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
@@ -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
- params: Types::ParamsType,
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(params:, verb:, path:)
38
+ def sign(verb:, path:, expiry:, nonce:, query: '', body: '')
31
39
  OpenSSL::HMAC.hexdigest(
32
40
  @algorithm,
33
41
  @secret,
34
- "#{verb.to_s.upcase}#{path}#{ParamsEncoder.new(params).encode}",
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(params:, verb:, path:, signature:)
47
- if OpenSSL.methods.include?(:fixed_length_secure_compare)
48
- OpenSSL.fixed_length_secure_compare(
49
- sign(params: params, verb: verb, path: path),
50
- signature,
51
- )
52
- else
53
- fallback_fixed_length_secure_compare(
54
- sign(params: params, verb: verb, path: path),
55
- signature,
56
- )
57
- end
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,23 +3,13 @@ module M2mKeygen
3
3
  module Types
4
4
  extend T::Sig
5
5
 
6
- ParamsType =
7
- T.type_alias do
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 do
16
- T.any(
17
- Integer,
18
- String,
19
- Symbol,
20
- T::Array[T.untyped],
21
- T::Hash[T.untyped, T.untyped],
22
- )
23
- 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] }
24
14
  end
25
15
  end
@@ -3,5 +3,5 @@
3
3
 
4
4
  module M2mKeygen
5
5
  # Gem version
6
- VERSION = '0.4.7'
6
+ VERSION = '0.5.0'
7
7
  end
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/Billcorporate/m2m_keygen_ruby'
14
+ spec.homepage = 'https://github.com/zaratan/m2m_keygen_ruby'
15
15
  spec.license = 'MIT'
16
- spec.required_ruby_version = '>= 2.7.0'
16
+ spec.required_ruby_version = '>= 3.3.0'
17
17
 
18
- spec.metadata['homepage_uri'] = spec.homepage
19
- spec.metadata['source_code_uri'] = spec.homepage
20
- spec.metadata[
21
- 'changelog_uri'
22
- ] = 'https://github.com/Billcorporate/m2m_keygen_ruby/blob/main/CHANGELOG.md'
23
- spec.metadata[
24
- 'documentation_uri'
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
@@ -3,3 +3,4 @@
3
3
  --ignore=node_modules
4
4
  --ignore=tmp
5
5
  --ignore=vendor
6
+ --ignore=examples
@@ -0,0 +1 @@
1
+ **/*.rbi linguist-generated=true