m2m_keygen 0.4.9 → 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 -1
  7. data/CHANGELOG.md +60 -1
  8. data/Gemfile +2 -2
  9. data/Gemfile.lock +135 -158
  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 -17
  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 +105 -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
data/README.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  This gem exists for simplifying Machine to Machine signature generation and verification in a secure way.
4
4
 
5
+ > If you are coming from a `0.4.x` version: the signature scheme changed in `0.5.0` and is not compatible anymore. See [docs/MIGRATING.md](docs/MIGRATING.md).
6
+
5
7
  ## Installation
6
8
 
7
9
  Install the gem and add to the application's Gemfile by executing:
@@ -14,132 +16,116 @@ If bundler is not being used to manage dependencies, install the gem by executin
14
16
 
15
17
  ## Usage
16
18
 
17
- ### Signature
18
-
19
- This gem provides a module for signing and checking signature for HTTP requests
19
+ The 2 servers share the same secret key. The sender will sign the request it is about to send and put the signature (with an expiry and a nonce) in headers. The receiver will generate the same signature from the request it received and compare them.
20
20
 
21
- #### Initialization
21
+ ### Signing a request
22
22
 
23
- You should initialize the `Signature` once (in an initializer for example) with your secret key and eventually an encryption algorithm.
23
+ You should use the `RequestSigner` on the sender side. It will build the query to send and the headers to add, and generate a nonce and an expiry for you.
24
24
 
25
- ```ruby
26
- AuthSignature = M2mKeygen::Signature.new("my_secret_key", algorithm: "sha256")
27
-
28
- AuthSignature = M2mKeygen::Signature.new("my_secret_key") # => Will default algorithm to sha512
29
- ```
30
-
31
- #### Signing
32
-
33
- Use the `sign` method to generate a new signature.
34
-
35
- - `params` is a params hash as used in Rack. The order of keys isn't important as the gem will reformat them.
36
25
  - `verb` is the http verb
37
26
  - `path` is the path for the request
38
-
39
- ```ruby
40
- AuthSignature.sign(
41
- params: {
42
- "a" => "test",
43
- :b => 1,
44
- "d" => %w[a b],
45
- "c" => {
46
- "e" => 45
47
- }
48
- },
49
- verb: "get",
50
- path: "/path"
51
- ) # => "a52168521868ebb37a38f90ec943163d9acb6ceb982206f437e1feb9ca32e7c1a8edef68f0ff4e195aeca1da93ae9afc8da214cb51a812fc6cc3730fdc7613fa"
52
- ```
53
-
54
- After generating the signature send it alongside your request for verification on the receiver side.
55
-
56
- #### Verifying
57
-
58
- Use the `validate` method to verify that a received signature correspond to the HTTP request.
59
-
60
27
  - `params` is a params hash as used in Rack. The order of keys isn't important as the gem will reformat them.
61
- - `verb` is the http verb
62
- - `path` is the path for the request
63
- - `signature` is the received signature
28
+ - `body` is optional, the raw body you will send
64
29
 
65
30
  ```ruby
66
- AuthSignature.validate(
67
- params: {
68
- "a" => "test",
69
- :b => 1,
70
- "d" => %w[a b],
71
- "c" => {
72
- "e" => 45
31
+ Signer = M2mKeygen::RequestSigner.new("my_secret_key") # eventually algorithm: "sha256", defaults to sha512
32
+
33
+ signed =
34
+ Signer.sign_request(
35
+ verb: "get",
36
+ path: "/orders",
37
+ params: {
38
+ "since" => "2026-01-01",
39
+ "limit" => 50
73
40
  }
74
- },
75
- verb: "get",
76
- path: "/path",
77
- signature:
78
- "a52168521868ebb37a38f90ec943163d9acb6ceb982206f437e1feb9ca32e7c1a8edef68f0ff4e195aeca1da93ae9afc8da214cb51a812fc6cc3730fdc7613fa"
79
- ) #=> true
41
+ )
42
+
43
+ signed.query # => the query string to send
44
+ signed.headers # => the X-Signature, X-M2M-Expiry and X-M2M-Nonce headers to add
80
45
  ```
81
46
 
82
- If the validation is true, the request was signed with the same algorithm and same secret key.
47
+ After signing, send `signed.query` and `signed.headers` alongside your request.
83
48
 
84
- ### RackValidator
49
+ ### Validating a request
85
50
 
86
- This module is here for directly validate Rack requests.
51
+ You should initialize the `RackValidator` once (in an initializer for example) with your secret key, eventually an encryption algorithm, a header name for the signature, and a nonce store (see below).
87
52
 
88
53
  It will validate :
89
54
 
90
55
  - Signature matching
91
- - That the `expiry` parameter is present and between now and in 2 minutes.
92
-
93
- #### Initialization
94
-
95
- You should initialize the `RackValidator` once (in an initializer for example) with your secret key, eventually an encryption algorithm and a header name for the signature.
56
+ - That the `expiry` (in the `X-M2M-Expiry` header) is present and between now and in 2 minutes.
57
+ - That the `nonce` (in the `X-M2M-Nonce` header) has never been seen before, so the request can't be replayed.
96
58
 
97
59
  ```ruby
98
60
  RackSignatureValidator =
99
61
  M2mKeygen::RackValidator.new(
100
- "secret",
62
+ "my_secret_key",
63
+ nonce_store: M2mKeygen::NonceStore::Memory.new,
101
64
  algorithm: "sha512", # Default value
102
- header_name: "X-Signature" # Default value
65
+ header_name: "X-Signature", # Default value
66
+ window: 120 # Default value, in seconds
103
67
  )
104
68
  ```
105
69
 
106
- #### Validation
107
-
108
70
  You can then validate a Rack::Request or a Rails Request directly:
109
71
 
110
72
  ```ruby
111
73
  RackSignatureValidator.validate(request) # => true or false
112
74
  ```
113
75
 
114
- ## How does it works
76
+ ### Choosing a nonce store
77
+
78
+ The nonce is what really stops replay: a captured request can't be replayed because its nonce is remembered until it expires. You have to choose a store, there is no default value on purpose, so you don't end up without replay protection without knowing it.
79
+
80
+ - `M2mKeygen::NonceStore::Memory` for a single process app or in development. Careful : with several workers or several hosts, each process has its own memory, so the replay protection is only partial. Use a shared store in production.
81
+ - A shared store (Redis, Postgres, ...) for production. Ready to copy implementations are in [`examples/nonce_store/`](examples/nonce_store/).
82
+ - `M2mKeygen::NonceStore::Disabled` if you explicitly don't want the replay protection (expiry only).
83
+
84
+ ### Signing without headers
85
+
86
+ If you don't go through HTTP headers, `Signature` is the low level tool both helpers are built on.
87
+
88
+ ```ruby
89
+ Signature = M2mKeygen::Signature.new("my_secret_key")
90
+
91
+ hex =
92
+ Signature.sign(
93
+ verb: "get",
94
+ path: "/orders",
95
+ expiry: 1_700_000_000,
96
+ nonce: "a-nonce",
97
+ query: "a=1"
98
+ )
99
+ ```
100
+
101
+ ## How does it work
102
+
103
+ This is intended for a secure discussion between 2 servers and not something in a browser as the secret key must be stored and used on both sides (and you don't want to send the secret key in the browser).
115
104
 
116
- This is intended for a secure discussion between 2 servers and not something in a browser as the secret key must be stored and used both side (and you don't want to send the secret key in the browser).
105
+ Both servers will have the same secret key. The sender will generate a signature matching the HTTP request it will be sending (the verb, the path, the query and the body, with an expiry and a nonce) and add it to the request in designated headers. The receiver will generate the same signature from the HTTP request it has received and will compare it with the signature in the header.
117
106
 
118
- Both server will have the same secret key.
119
- The sender will generate a signature matching the HTTP request it will be sending and add it to the request in a designated header.
120
- The receiver will generate the same signature from the HTTP request it has received and will compare it with the signature in the header.
107
+ The comparison will be done in constant time (i.e. secure) because both strings will be hexdigests from a HMAC with the same algorithm.
121
108
 
122
- The comparison will be done in constant time (i.e. secure) because both string will be hexdigest from a HMAC with the same algorithm.
109
+ The exact byte format is described in [docs/SPEC.md](docs/SPEC.md), with golden vectors. This is the contract another implementation (the TypeScript one for example) will have to reproduce exactly.
123
110
 
124
111
  ## Development
125
112
 
126
113
  After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
127
114
 
128
- To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
115
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb` and the CHANGELOG, then push to `main` : the gem is published to [rubygems.org](https://rubygems.org) through trusted publishing.
129
116
 
130
117
  Every commit/push is checked by overcommit. You should (must) activate overcommit by using `overcommit -i` post installation.
131
118
 
132
- Tool used in dev:
119
+ Tools used in dev:
133
120
 
134
121
  - Rubocop
135
- - Prettier
136
- - Yard
122
+ - Syntax Tree
137
123
  - Sorbet
138
124
  - RSpec
139
125
 
140
126
  ## Contributing
141
127
 
142
- Bug reports and pull requests are welcome on GitHub at https://github.com/zaratan/m2m_keygen. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/zaratan/m2m_keygen/blob/main/CODE_OF_CONDUCT.md).
128
+ Bug reports and pull requests are welcome on GitHub at https://github.com/zaratan/m2m_keygen_ruby. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/zaratan/m2m_keygen_ruby/blob/main/CODE_OF_CONDUCT.md).
143
129
 
144
130
  ## License
145
131
 
@@ -147,4 +133,4 @@ The gem is available as open source under the terms of the [MIT License](https:/
147
133
 
148
134
  ## Code of Conduct
149
135
 
150
- Everyone interacting in the M2mKeygen project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/zaratan/m2m_keygen/blob/main/CODE_OF_CONDUCT.md).
136
+ Everyone interacting in the M2mKeygen project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/zaratan/m2m_keygen_ruby/blob/main/CODE_OF_CONDUCT.md).
data/docs/MIGRATING.md ADDED
@@ -0,0 +1,69 @@
1
+ # Migrating from v1 to v2 (`m2m-keygen/2`)
2
+
3
+ v2 replaces the signature scheme. A v1 signature does not validate under v2 and
4
+ vice-versa, so **the sender and the receiver must be upgraded together** — plan a
5
+ coordinated deploy (both ends share the secret; upgrade them in lockstep). If a
6
+ TypeScript counterpart signs or verifies these requests, it must move to v2 too,
7
+ following [SPEC.md](SPEC.md).
8
+
9
+ ## What changed and why
10
+
11
+ v1 signed `"#{VERB}#{path}#{params_as_k=v}"`, which collided on unescaped
12
+ delimiters, dropped empty values, confused types, and had no replay protection.
13
+ v2 signs the request's **wire bytes** (method, path, byte-order-sorted query, raw
14
+ body) length-prefixed, plus a **signed expiry and nonce**. See [SPEC.md](SPEC.md)
15
+ for the full format.
16
+
17
+ ## API changes
18
+
19
+ | v1 | v2 |
20
+ |----|----|
21
+ | `Signature#sign(params:, verb:, path:)` | `Signature#sign(verb:, path:, expiry:, nonce:, query: '', body: '')` |
22
+ | `Signature#validate(params:, verb:, path:, signature:)` | `Signature#validate(signature:, verb:, path:, expiry:, nonce:, query: '', body: '')` |
23
+ | `ParamsEncoder` | removed (internal `Canonicalizer`) |
24
+ | `RackValidator.new(secret, algorithm:, header_name:)` | `RackValidator.new(secret, nonce_store:, algorithm:, header_name:, window:, expiry_header:, nonce_header:)` |
25
+ | expiry passed as a request param | expiry sent in the `X-M2M-Expiry` header (signed) |
26
+ | — | nonce sent in the `X-M2M-Nonce` header (signed) |
27
+ | — | `RequestSigner` client helper |
28
+
29
+ ## On the sender
30
+
31
+ Replace hand-built signatures with `RequestSigner`, which generates the nonce and
32
+ expiry and returns the headers to send:
33
+
34
+ ```ruby
35
+ signer = M2mKeygen::RequestSigner.new(secret)
36
+ signed = signer.sign_request(verb: "GET", path: "/orders", params: { "since" => "2026-01-01" })
37
+
38
+ # signed.query => the query string to send
39
+ # signed.headers => { "X-Signature" => ..., "X-M2M-Expiry" => ..., "X-M2M-Nonce" => ... }
40
+ ```
41
+
42
+ For a body request, pass `body:` (the exact bytes you will send).
43
+
44
+ ## On the receiver
45
+
46
+ `RackValidator` now **requires** a nonce store — pick one explicitly:
47
+
48
+ ```ruby
49
+ # Single-process / development:
50
+ M2mKeygen::RackValidator.new(secret, nonce_store: M2mKeygen::NonceStore::Memory.new)
51
+
52
+ # Multi-worker / multi-host production: use a shared store
53
+ # (see examples/nonce_store/redis.rb and postgres.rb):
54
+ M2mKeygen::RackValidator.new(secret, nonce_store: RedisNonceStore.new(Redis.new))
55
+
56
+ # Expiry-only, no replay protection — a deliberate, visible opt-out:
57
+ M2mKeygen::RackValidator.new(secret, nonce_store: M2mKeygen::NonceStore::Disabled.new)
58
+ ```
59
+
60
+ `validate(request)` still returns `true`/`false`; it now also rejects a replayed
61
+ or missing nonce (unless the store is `Disabled`).
62
+
63
+ ## Checklist
64
+
65
+ 1. Upgrade the gem (and the TS lib, if any) on **both** ends.
66
+ 2. Sender: switch to `RequestSigner`; send the three headers and the returned query.
67
+ 3. Receiver: pass a `nonce_store:`; expect expiry/nonce in headers.
68
+ 4. Deploy sender and receiver together (a v1 sender against a v2 receiver, or the
69
+ reverse, will fail every signature).
data/docs/SPEC.md ADDED
@@ -0,0 +1,245 @@
1
+ # m2m-keygen/2 — signature scheme specification
2
+
3
+ This is the language-agnostic contract for the `m2m-keygen/2` request signing
4
+ scheme. Any implementation (this Ruby gem, its TypeScript sibling, or any other)
5
+ that produces or verifies signatures MUST follow it byte-for-byte. The
6
+ [golden vectors](#golden-vectors) at the end are the conformance test set.
7
+
8
+ ## Overview
9
+
10
+ A sender signs an HTTP request with an HMAC keyed by a shared secret and sends
11
+ the signature (plus an expiry and a nonce) in headers. The receiver rebuilds the
12
+ same signing input from the request it received and recomputes the HMAC. If they
13
+ match (constant-time compare) and the expiry/nonce checks pass, the request is
14
+ authentic and fresh.
15
+
16
+ The signature commits to the request's **wire bytes** (method, path, the query
17
+ string, and the raw body) rather than to a re-parsed data structure. This is
18
+ what makes the scheme unambiguous and portable: there is no cross-language
19
+ serialization to agree on — each side signs the bytes as they appear on the
20
+ wire.
21
+
22
+ ## Canonical string
23
+
24
+ The signed message is a single byte string built by concatenating these seven
25
+ components, in this exact order:
26
+
27
+ | # | Component | Value |
28
+ |---|------------------|--------------------------------------------------------------|
29
+ | 1 | scheme | the literal ASCII string `m2m-keygen/2` |
30
+ | 2 | verb | the HTTP method, uppercased (ASCII) |
31
+ | 3 | path | the request path exactly as it appears on the wire — **percent-encoded, not decoded** (as `Rack::Request#path` returns the raw `PATH_INFO`, and `new URL(...).pathname` returns it in JS). Signer and verifier must see the same bytes. |
32
+ | 4 | expiry | the expiry, an integer Unix timestamp (seconds), as its decimal string |
33
+ | 5 | nonce | the nonce string |
34
+ | 6 | canonical_query | the canonicalized query string (see below) |
35
+ | 7 | body | the raw request body bytes, exactly as sent |
36
+
37
+ Each component is **length-prefixed** with its **UTF-8 byte length** and a colon,
38
+ then all seven are concatenated with no separator:
39
+
40
+ ```
41
+ canonical = ""
42
+ for component in components:
43
+ bytes = utf8_bytes(component) # for the body: the raw bytes as-is
44
+ canonical += ascii(bytes.length) + ":" + bytes
45
+ signature = hex( HMAC(algorithm, secret, canonical) )
46
+ ```
47
+
48
+ Length-prefixing makes every field boundary unambiguous: no `&`, `=`, `/` or any
49
+ other byte in a value can be mistaken for a delimiter, and no field can "borrow"
50
+ bytes from its neighbour.
51
+
52
+ - `bytes.length` is the number of **bytes** (UTF-8 for text; raw for the body),
53
+ not the number of characters. In Node: `Buffer.byteLength(s, 'utf8')`.
54
+ - The default `algorithm` is `sha512`. `hex` is lowercase hexadecimal.
55
+ - The whole canonical string is assembled as raw bytes, so a binary body is
56
+ handled with no special casing.
57
+ - The scheme string (component 1) carries the version. There is no version
58
+ negotiation: a verifier accepts exactly `m2m-keygen/2`, so senders and verifiers
59
+ move to a new version together (see [MIGRATING.md](MIGRATING.md)).
60
+ - Verification compares the recomputed hex digest to the received one in
61
+ **constant time**; a signature of unexpected length is rejected (`false`)
62
+ without a length-dependent early branch.
63
+
64
+ ## canonical_query
65
+
66
+ The query string is canonicalized by splitting it into its raw `key=value` pairs
67
+ on `&`, sorting those pairs in **byte order**, and re-joining them with `&`:
68
+
69
+ ```
70
+ canonical_query = query.split("&").sortByBytes().join("&") # "" if query is empty
71
+ ```
72
+
73
+ - The pairs are kept **exactly as they appear on the wire** (percent-encoded).
74
+ Values are never decoded, re-encoded, or type-converted for signing.
75
+ - "Byte order" means comparing the UTF-8 byte sequences of the pairs. Ruby's
76
+ `Array#sort` on strings already does this. In JavaScript, the default
77
+ `Array.prototype.sort` compares UTF-16 code units, which agrees with byte
78
+ order for the Basic Multilingual Plane but **diverges for astral characters**
79
+ (> U+FFFF); a conformant implementation MUST sort by encoded bytes, e.g. by
80
+ comparing `Buffer`/`Uint8Array` representations, not raw JS string `<`/`>`.
81
+ - Sorting means the sender may emit pairs in any order; both sides re-sort before
82
+ signing, so pair order in transit is irrelevant.
83
+
84
+ ## Value stringification (senders building a query from a map)
85
+
86
+ When a helper builds the query string from a key/value map (the ergonomic
87
+ sender path), values become strings as follows before percent-encoding:
88
+
89
+ - **String** → itself.
90
+ - **Integer** → its decimal string. Big integers are exact (no float rounding).
91
+ - **Boolean** → `true` / `false`.
92
+ - **Symbol** (Ruby) → its string form.
93
+ - **Float** → its native string form (Ruby `Float#to_s`). Finite floats are
94
+ allowed but their textual form is language-specific (Ruby `1.0` → `"1.0"`,
95
+ JS `String(1.0)` → `"1"`). If you need byte-identical signatures from senders
96
+ in different languages, use strings or integers for numeric values. **NaN and
97
+ ±Infinity are rejected** (they have no stable representation).
98
+ - **Array** → a repeated key (`k=v1&k=v2`), elements stringified as above.
99
+ - **Hash / nested object** → not supported. v2 query params are flat `k=v`
100
+ wire pairs; there is no nested or JSON encoding in the signing path. (A
101
+ structured payload travels in the body, which is signed as raw bytes.)
102
+
103
+ None of this applies to the receiver: it never rebuilds values, it signs the
104
+ `query`/`body` bytes it received.
105
+
106
+ ### Query encoding is not part of the contract
107
+
108
+ The signature covers the **query bytes on the wire**, not the map a sender started
109
+ from. How a sender percent-encodes a map into a query string (space as `+` vs
110
+ `%20`, which characters are escaped, …) is **not** specified here and may differ
111
+ between languages — that is fine, because the receiver re-signs the raw query it
112
+ received. What is guaranteed: a sender signs exactly the bytes it sends.
113
+
114
+ Two consequences:
115
+
116
+ - **Send the signed query (and path) verbatim.** Do not let your HTTP client
117
+ re-encode or reorder the query after signing, or the bytes sent will differ from
118
+ the bytes signed and validation will fail.
119
+ - Two senders in different languages will not necessarily produce byte-identical
120
+ signatures for the same map. If you need that (e.g. shared idempotency keys),
121
+ agree on strings/integers and on the exact query form.
122
+
123
+ ## Transport
124
+
125
+ Three headers carry the protocol metadata (names are configurable; defaults):
126
+
127
+ | Header | Contents |
128
+ |-----------------|-------------------------------------------------|
129
+ | `X-Signature` | the hex HMAC signature |
130
+ | `X-M2M-Expiry` | the expiry (integer Unix seconds), as a string |
131
+ | `X-M2M-Nonce` | the nonce |
132
+
133
+ The expiry and nonce are **signed** (components 4 and 5), so they cannot be
134
+ tampered with; the headers are just how they travel.
135
+
136
+ ## Expiry
137
+
138
+ The expiry is a Unix timestamp (seconds) chosen by the sender. The receiver
139
+ accepts the request only if it is strictly inside the window:
140
+
141
+ ```
142
+ now < expiry < now + window # window default: 120 seconds
143
+ ```
144
+
145
+ Both bounds are strict (`expiry == now` and `expiry == now + window` are
146
+ rejected). This bounds how long a captured request could be replayed; clocks on
147
+ both ends are assumed roughly synchronized (NTP).
148
+
149
+ ## Nonce and anti-replay
150
+
151
+ The expiry alone only *bounds* replay; a captured request can still be replayed
152
+ until it expires. The nonce closes that window:
153
+
154
+ - The sender generates a **unique, unpredictable** nonce per request (e.g.
155
+ `SecureRandom.hex(16)`, ≥ 128 bits) and signs it.
156
+ - The receiver records nonces in a store and **rejects any nonce it has already
157
+ seen**. The store operation MUST be a single **atomic** check-and-set
158
+ (`add(nonce, ttl) -> was_new`); a separate "seen?" then "record" is a TOCTOU
159
+ race that lets two concurrent replays through.
160
+ - The nonce's TTL in the store must cover at least the request's remaining
161
+ acceptance window (`expiry - now`, plus a small clock-skew margin).
162
+ - **Fail closed**: if replay protection is enabled, a request with a missing or
163
+ empty nonce is rejected.
164
+ - Nonce length should be bounded by the receiver to prevent store-memory abuse.
165
+
166
+ An implementation MAY offer an explicit opt-out (expiry-only, no nonce store),
167
+ but it must be a deliberate, visible choice — never a silent default.
168
+
169
+ ## Reproducing a signature with OpenSSL
170
+
171
+ Build the canonical string and pipe it to `openssl`. For the first golden vector
172
+ below (`secret = golden-vector-secret`):
173
+
174
+ ```sh
175
+ printf '%s' \
176
+ '12:m2m-keygen/23:GET6:/users10:170000000016:nonce-simple-get7:a=1&b=20:' \
177
+ | openssl dgst -sha256 -hmac 'golden-vector-secret'
178
+ # => 178ef6ed485b438422fa634c11e2a9497176d69abe98175ee9492503bed345ab
179
+ ```
180
+
181
+ The canonical string reads: `12:` + `m2m-keygen/2`, `3:` + `GET`, `6:` + `/users`,
182
+ `10:` + `1700000000`, `16:` + `nonce-simple-get`, `7:` + `a=1&b=2`, `0:` + `` (empty body).
183
+
184
+ ## Golden vectors
185
+
186
+ Secret: `golden-vector-secret`. Each row lists the inputs and the expected
187
+ lowercase-hex HMAC for both `sha256` and `sha512`. A conformant implementation
188
+ must reproduce every digest. (These are kept in sync with
189
+ `spec/support/golden_vectors.rb`.)
190
+
191
+ ### 1. Simple GET with a sorted query
192
+
193
+ - verb `GET`, path `/users`, expiry `1700000000`, nonce `nonce-simple-get`, query `a=1&b=2`, body *(empty)*
194
+ - sha256: `178ef6ed485b438422fa634c11e2a9497176d69abe98175ee9492503bed345ab`
195
+ - sha512: `88fd787042faffd81584b534a9fb1f634907fc8ae742ff14b96da4b2f98dbddc25492776bbab30e37c59bb63a4ff84f1e22a359d015d463c1bbaa71f08319ae3`
196
+
197
+ ### 2. POST with a JSON body
198
+
199
+ - verb `POST`, path `/users`, expiry `1700000100`, nonce `nonce-simple-post`, query *(empty)*, body `{"name":"Ada"}`
200
+ - sha256: `d1e464edbae8637f7db51c0f858a9a64db2c93208381c8055c2e4787b3fc5589`
201
+ - sha512: `c1f52dcfa6f880d1e601ff6db4889e64678e5057872e1a8e1831232e49ac2487a9c30981cbb67eff9e2dc3e7f54d4e05966bcd4d5bcb9b7f19a77204bd37ad6e`
202
+
203
+ ### 3. Percent-encoded (wire-form) path, with non-ASCII query and body
204
+
205
+ The path is the wire form `/r%C3%A9sum%C3%A9` (the request-target for `/résumé`), **not** the decoded `/résumé`.
206
+
207
+ - verb `POST`, path `/r%C3%A9sum%C3%A9`, expiry `1700000200`, nonce `nonce-non-ascii`, query `name=%C3%A9l%C3%A8ve`, body `café ☃ 日本語`
208
+ - sha256: `4d521773b4b15bff07e73c4a4a8db483314b62725b42b653e2d887efdca78404`
209
+ - sha512: `ec42e774af9730d59456fcf2909d7606a10d61d4187badf2b0f7b4b62047495daa4d9f5d286bdb1e4b9a61b8b23e5801e13b563177c3e7f3d6f553f6dad6f7f1`
210
+
211
+ ### 4. Body with control characters (tab, newline, NUL)
212
+
213
+ - verb `POST`, path `/notes`, expiry `1700000300`, nonce `nonce-control-char`, query *(empty)*, body `control\tchars\n\x00end-of-line`
214
+ - sha256: `841172d2cea40129d864b5f1ef078329d33b4221c2227ed05058716104e75ad7`
215
+ - sha512: `49f58297da8599bc0d325d22cb1ed90ee7d5f67b13a9b13ca430820b8a09e08aff945129f6876bc0186002412eeae20b27dec1317c3f77f3eccb635899536b22`
216
+
217
+ ### 5. Big-integer amount in query and body
218
+
219
+ - verb `POST`, path `/ledger`, expiry `1700000400`, nonce `nonce-bignum`, query `amount=123456789012345678901234567890`, body `{"amount":123456789012345678901234567890}`
220
+ - sha256: `b07e7563ce1f7c4819f3052ba3628a1a5d155b77600a5765c40ca610784e7808`
221
+ - sha512: `32078f4c96255ef99ca9e80ecb4ec800d4056ef136dbd42dc4c3bbfc33b153c3e2ef23a1a09b574d4ffd558ede3d30bcfc1c3cc041deaf5c76c70307e18a3c5d`
222
+
223
+ ### 6. Out-of-order multi-pair query (proves the pair sort)
224
+
225
+ The query arrives as `b=2&a=10&a=1`; `canonical_query` sorts the pairs by bytes to `a=1&a=10&b=2`. An implementation that forgets to sort produces a different digest and fails here.
226
+
227
+ - verb `GET`, path `/list`, expiry `1700000500`, nonce `nonce-sort`, query `b=2&a=10&a=1`, body *(empty)*
228
+ - sha256: `f3da6c2db729b59fdbaa301eb1bebea55253afdcd6039e055849a6404ffdd845`
229
+ - sha512: `db925510d0621073b4a37441f3f0df8697b08d204feae25f663a284840cbaceced9cd28acad09121f7d64749d4c6ab690c279dcc6b14b6a5ad81c0dce86d3cc2`
230
+
231
+ ### 7. Astral character in the query (proves byte-order, not UTF-16, sorting)
232
+
233
+ Two pairs whose sort order differs by encoding: U+FFFD starts with byte `0xEF`, U+1F600 (😀) with `0xF0`, so byte order gives `�=bmp` first — canonical query `�=bmp&😀=astral`. A JS `.sort()` (UTF-16 code units: the 😀 surrogate `0xD83D` sorts before `0xFFFD`) would order them the other way and produce a different digest. The raw UTF-8 bytes of the two pairs are in the query.
234
+
235
+ - verb `GET`, path `/emoji`, expiry `1700000600`, nonce `nonce-astral`, query `😀=astral&�=bmp` (raw UTF-8), body *(empty)*
236
+ - sha256: `32f0e93a4f926293d232a0a81cebbe5206a9f7619413dd126bc7b9dd027bbb69`
237
+ - sha512: `4109fcd615c1a6231d4d8dac08416d28e0bf324bc7b65f3b8193651e7eed8c6a072c08df2e6c3c16daf8a921e6b2a8102e04e726521ac6d98e9c6128a6e44a69`
238
+
239
+ ## Out of scope (threat model)
240
+
241
+ The scheme protects request **integrity** and **freshness** (replay). It does
242
+ **not** provide confidentiality (use TLS), does not bind the host/scheme (a
243
+ signed request is valid against any deployment sharing the secret — use
244
+ per-environment secrets), and assumes roughly synchronized clocks. Secret
245
+ rotation and host binding are possible extensions, not part of v2.
@@ -0,0 +1,40 @@
1
+ # typed: false
2
+ # frozen_string_literal: true
3
+
4
+ # Reference M2mKeygen::NonceStore backed by PostgreSQL. Requires the `pg` gem
5
+ # and this table:
6
+ #
7
+ # CREATE TABLE m2m_nonces (
8
+ # nonce text PRIMARY KEY,
9
+ # expires_at timestamptz NOT NULL
10
+ # );
11
+ # CREATE INDEX m2m_nonces_expires_at_idx ON m2m_nonces (expires_at);
12
+ #
13
+ # store = PostgresNonceStore.new(PG.connect(ENV['DATABASE_URL']))
14
+ # RackValidator.new(secret, nonce_store: store)
15
+ #
16
+ # Like Redis, this store is shared across processes. `INSERT ... ON CONFLICT
17
+ # DO NOTHING RETURNING` is the atomic check-and-set: a row is returned only when
18
+ # this call actually inserted the nonce. Expired rows are inert (a fresh request
19
+ # always carries a fresh random nonce), but should be reclaimed periodically —
20
+ # call `#purge_expired` from a cron or background job.
21
+ class PostgresNonceStore
22
+ def initialize(connection)
23
+ @connection = connection
24
+ end
25
+
26
+ def add(nonce, ttl:)
27
+ result =
28
+ @connection.exec_params(
29
+ 'INSERT INTO m2m_nonces (nonce, expires_at) ' \
30
+ 'VALUES ($1, now() + make_interval(secs => $2::int)) ' \
31
+ 'ON CONFLICT (nonce) DO NOTHING RETURNING nonce',
32
+ [nonce, ttl],
33
+ )
34
+ result.ntuples.positive?
35
+ end
36
+
37
+ def purge_expired
38
+ @connection.exec('DELETE FROM m2m_nonces WHERE expires_at < now()')
39
+ end
40
+ end
@@ -0,0 +1,23 @@
1
+ # typed: false
2
+ # frozen_string_literal: true
3
+
4
+ # Reference M2mKeygen::NonceStore backed by Redis. Requires the `redis` gem.
5
+ #
6
+ # store = RedisNonceStore.new(Redis.new)
7
+ # RackValidator.new(secret, nonce_store: store)
8
+ #
9
+ # Redis is a good fit for multi-worker/multi-host production: the store is
10
+ # shared across all processes, and `SET ... NX PX` gives the atomic
11
+ # check-and-set the NonceStore contract requires, with the TTL handled natively
12
+ # (no purge to run).
13
+ class RedisNonceStore
14
+ def initialize(redis, prefix: 'm2m:nonce:')
15
+ @redis = redis
16
+ @prefix = prefix
17
+ end
18
+
19
+ # Returns true only if the key did not exist (nonce is new); false on replay.
20
+ def add(nonce, ttl:)
21
+ @redis.set("#{@prefix}#{nonce}", '1', nx: true, px: ttl * 1000) == true
22
+ end
23
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ eval_gemfile File.expand_path('../Gemfile', __dir__)
4
+
5
+ gem 'rack', '~> 2.2'
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ eval_gemfile File.expand_path('../Gemfile', __dir__)
4
+
5
+ gem 'rack', '~> 3.0'
@@ -0,0 +1,57 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module M2mKeygen
5
+ class Canonicalizer
6
+ extend T::Sig
7
+
8
+ SCHEME = 'm2m-keygen/2'
9
+
10
+ class << self
11
+ extend T::Sig
12
+
13
+ sig do
14
+ params(
15
+ verb: String,
16
+ path: String,
17
+ expiry: Integer,
18
+ nonce: String,
19
+ query: String,
20
+ body: String,
21
+ ).returns(String)
22
+ end
23
+ def canonical(verb:, path:, expiry:, nonce:, query: '', body: '')
24
+ join_length_prefixed(
25
+ [
26
+ SCHEME,
27
+ verb.upcase,
28
+ path,
29
+ expiry.to_s,
30
+ nonce,
31
+ canonical_query(query),
32
+ body,
33
+ ],
34
+ )
35
+ end
36
+
37
+ sig { params(query: String).returns(String) }
38
+ def canonical_query(query)
39
+ return '' if query.empty?
40
+
41
+ query.split('&').sort_by(&:b).join('&')
42
+ end
43
+
44
+ private
45
+
46
+ sig { params(components: T::Array[String]).returns(String) }
47
+ def join_length_prefixed(components)
48
+ buffer = String.new(encoding: Encoding::BINARY)
49
+ components.each do |component|
50
+ bytes = component.b
51
+ buffer << bytes.bytesize.to_s << ':' << bytes
52
+ end
53
+ buffer
54
+ end
55
+ end
56
+ end
57
+ end