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
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
|
-
|
|
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
|
-
|
|
21
|
+
### Signing a request
|
|
22
22
|
|
|
23
|
-
You should
|
|
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
|
-
- `
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
"
|
|
71
|
-
"
|
|
72
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
47
|
+
After signing, send `signed.query` and `signed.headers` alongside your request.
|
|
83
48
|
|
|
84
|
-
###
|
|
49
|
+
### Validating a request
|
|
85
50
|
|
|
86
|
-
|
|
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`
|
|
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
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
119
|
+
Tools used in dev:
|
|
133
120
|
|
|
134
121
|
- Rubocop
|
|
135
|
-
-
|
|
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/
|
|
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/
|
|
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,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
|