altcha 2.0.1 → 3.0.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +44 -0
- data/Gemfile.lock +5 -1
- data/README.md +20 -13
- data/altcha.gemspec +2 -1
- data/lib/altcha/v2.rb +292 -96
- data/lib/altcha/version.rb +1 -1
- metadata +18 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f3f8c54a53e1c95984a1f46cc9420708fce3a3564b727ac3439c12da1dc922f3
|
|
4
|
+
data.tar.gz: f036c9affe9baebe5d23a2b9146ac967bb1482b604bc6db0d71c95b7b07b0605
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 34e5b230a973ad0b4e90da5389b45bd1ef383f8329ad38747e961a98b49f525c4262a9d51f13be7026afc74ce6340188d47f6b2f94233bca751116cbab900ad3
|
|
7
|
+
data.tar.gz: 25c5a676ab422049e48185cb12ada7332194bbbe13dd2688a77b54cbfbc0be65a7b0afdf258af0aa2a8e91aeec5da97b3ebfe9515a1a199a3d365ff54d1f53b5
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented in this file.
|
|
4
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
5
|
+
|
|
6
|
+
## [3.0.0] - 2026-10-04
|
|
7
|
+
|
|
8
|
+
Brings `Altcha::V2` in line with the reference JS implementation: challenges, solutions and signatures now interoperate in both directions.
|
|
9
|
+
|
|
10
|
+
### Breaking changes
|
|
11
|
+
|
|
12
|
+
- Ruby 3.0 or newer is required. v2 already needed it: `OpenSSL.fixed_length_secure_compare` does not exist on Ruby 2.7.
|
|
13
|
+
- `ARGON2ID` uses `memory_cost` exactly, in KiB; it was rounded to a power of two before. `memory_cost` is now required and has no 64 MiB default. ARGON2ID challenges created earlier with a memory cost that is not a power of two no longer verify.
|
|
14
|
+
- `hmac_algorithm` accepts only `'SHA-256'`, `'SHA-384'` and `'SHA-512'`, matched exactly. Anything else, including `'SHA-1'`, lowercase names and `nil`, raises `ArgumentError`. Before, unknown values silently used SHA-256.
|
|
15
|
+
- `verify_fields_hash` raises `ArgumentError` for any algorithm other than `'SHA-256'`, `'SHA-384'` or `'SHA-512'`. Before, unknown values silently used SHA-256.
|
|
16
|
+
- `verify_solution` raises `ArgumentError` when `hmac_signature_secret` is `nil` or `''`.
|
|
17
|
+
- `create_challenge` treats `''` secrets as unset, as JS does. An empty `hmac_signature_secret` returns an unsigned challenge, and an empty `hmac_key_signature_secret` adds no `keySignature`.
|
|
18
|
+
- `solve_challenge` gives up after 90 seconds by default and returns `nil`. Pass `timeout: nil` to keep the old unlimited behaviour.
|
|
19
|
+
- `expires_at` is compared with sub-second precision, so there is no longer up to 1 second of extra validity. `expires_at: 0` means "no expiry" instead of "expired".
|
|
20
|
+
- In `uint32` counter mode, `verify_solution` accepts only whole-number counters from 0 to 2^32−1. Values that wrap to a valid counter, such as `c + 2^32`, are rejected as `invalid_solution`. JS accepts them, so this is stricter.
|
|
21
|
+
- The signed JSON now includes unknown and `null` challenge parameters exactly as received, so adding a field to a challenge invalidates its signature. A challenge without `keyLength` or `keyPrefix` no longer has the defaults added to its signed JSON.
|
|
22
|
+
- `canonical_json` output changed for floats, integer-like keys, keys sorted by UTF-16 code unit, and objects inside arrays (see below). Challenges that were signed before upgrading and contain such `data` fail verification once.
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
|
|
26
|
+
- `counter_mode` option (`'uint32'`, the default, or `'string'`) for `create_challenge`, `solve_challenge` and `verify_solution`, matching JS `counterMode`. Unknown values raise `ArgumentError`.
|
|
27
|
+
- `hmac_algorithm` option for `create_challenge`. It is used for both the challenge signature and `keySignature`.
|
|
28
|
+
- `timeout` option for `solve_challenge`, in milliseconds (default `90_000`).
|
|
29
|
+
- `ChallengeParameters#extra`, which holds parameter keys without a matching attribute so they are signed exactly as received.
|
|
30
|
+
|
|
31
|
+
### Fixed
|
|
32
|
+
|
|
33
|
+
- `canonical_json` writes numbers like JS `JSON.stringify` (`1.0` → `1`, `1e-7` → `1e-7`, integers above 2^53 rounded to a double) and orders keys like JS: integer-like keys first in numeric order, other keys by UTF-16 code unit, objects inside arrays unsorted.
|
|
34
|
+
- `key_prefix` is lowercased in `create_challenge`, and `solve_challenge` and `verify_solution` compare it case-insensitively. Before, a signed uppercase prefix never verified and made `solve_challenge` loop forever.
|
|
35
|
+
- On the `keySignature` fast path, a `derivedKey` that is not an even-length hex string is rejected. Before, odd-length or non-hex strings could decode to the real key and verify.
|
|
36
|
+
- `verify_solution` returns a result instead of raising for any malformed client input:
|
|
37
|
+
- a non-numeric `counter`;
|
|
38
|
+
- a `derivedKey` that is not a string or not valid UTF-8;
|
|
39
|
+
- a non-numeric `expiresAt`;
|
|
40
|
+
- a challenge `signature` that is not a string;
|
|
41
|
+
- challenge parameters containing invalid UTF-8.
|
|
42
|
+
- `verify_server_signature` returns a result for an unsupported `algorithm` or a non-string `verificationData`. Before, unknown algorithms fell back to SHA-256. A non-numeric or zero `expire` never expires, as in JS.
|
|
43
|
+
- `verify_fields_hash` hashes falsy values (`nil`, `false`, `0`, `''`) as empty strings, as JS `String(value || '')` does.
|
|
44
|
+
- A `keySignature` or secret that is `''` (or another falsy value) skips the fast path and re-derives the key, as in JS.
|
data/Gemfile.lock
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: .
|
|
3
3
|
specs:
|
|
4
|
-
altcha (
|
|
4
|
+
altcha (3.0.0)
|
|
5
5
|
base64
|
|
6
6
|
|
|
7
7
|
GEM
|
|
8
8
|
remote: https://rubygems.org/
|
|
9
9
|
specs:
|
|
10
|
+
argon2-kdf (1.0.0)
|
|
11
|
+
fiddle
|
|
10
12
|
base64 (0.3.0)
|
|
11
13
|
diff-lcs (1.6.2)
|
|
14
|
+
fiddle (1.1.8)
|
|
12
15
|
rake (13.4.2)
|
|
13
16
|
rspec (3.13.2)
|
|
14
17
|
rspec-core (~> 3.13.0)
|
|
@@ -30,6 +33,7 @@ PLATFORMS
|
|
|
30
33
|
|
|
31
34
|
DEPENDENCIES
|
|
32
35
|
altcha!
|
|
36
|
+
argon2-kdf (>= 0.3)
|
|
33
37
|
bundler (~> 4.0)
|
|
34
38
|
rake (~> 13.3)
|
|
35
39
|
rspec (~> 3.13)
|
data/README.md
CHANGED
|
@@ -4,7 +4,7 @@ The ALTCHA Ruby Library is a lightweight, zero-dependency library designed for c
|
|
|
4
4
|
|
|
5
5
|
## Compatibility
|
|
6
6
|
|
|
7
|
-
- Ruby
|
|
7
|
+
- Ruby 3.0+
|
|
8
8
|
|
|
9
9
|
## Examples
|
|
10
10
|
|
|
@@ -122,14 +122,16 @@ Creates a new v2 challenge.
|
|
|
122
122
|
| `algorithm` | `String` | — | Key derivation algorithm: `SHA-256`, `SHA-384`, `SHA-512`, `PBKDF2/SHA-256`, `PBKDF2/SHA-384`, `PBKDF2/SHA-512`, `SCRYPT`, `ARGON2ID`. |
|
|
123
123
|
| `cost` | `Integer` | — | Algorithm cost (iterations for PBKDF2/SHA, N for SCRYPT). |
|
|
124
124
|
| `counter` | `Integer` | `nil` | Pre-compute a deterministic key prefix from this counter value. |
|
|
125
|
+
| `counter_mode` | `String` | `'uint32'` | How the counter is appended to the nonce: `'uint32'` (4-byte big-endian) or `'string'` (decimal text, as JS `counterMode: 'string'`); anything else raises `ArgumentError`. Pass the same value to `solve_challenge` and `verify_solution`. |
|
|
125
126
|
| `data` | `Hash` | `nil` | Arbitrary metadata to embed in the challenge. |
|
|
126
|
-
| `expires_at` | `Integer, Time` | `nil` | Expiration timestamp (Unix seconds or `Time`). |
|
|
127
|
-
| `
|
|
128
|
-
| `
|
|
127
|
+
| `expires_at` | `Integer, Time` | `nil` | Expiration timestamp (Unix seconds or `Time`). Expired once the current time passes it, compared with sub-second precision; `0` means no expiry. |
|
|
128
|
+
| `hmac_algorithm` | `String` | `'SHA-256'` | HMAC algorithm for the challenge signature and key signature: `'SHA-256'`, `'SHA-384'` or `'SHA-512'` (exact, as in altcha-lib JS); anything else raises `ArgumentError`. Pass the same value to `verify_solution`. |
|
|
129
|
+
| `hmac_signature_secret` | `String` | `nil` | Signs the challenge parameters. Required for `verify_solution`. `nil` or `''` returns an unsigned challenge (no `key_signature` either). |
|
|
130
|
+
| `hmac_key_signature_secret` | `String` | `nil` | Signs the derived key (fast-path verification). Requires `counter`. `nil` or `''` adds no `key_signature`. |
|
|
129
131
|
| `key_length` | `Integer` | `32` | Derived key length in bytes. |
|
|
130
|
-
| `key_prefix` | `String` | `'00'` | Hex prefix the derived key must start with. |
|
|
132
|
+
| `key_prefix` | `String` | `'00'` | Hex prefix the derived key must start with. Lowercased; solve and verify also match prefixes case-insensitively. |
|
|
131
133
|
| `key_prefix_length` | `Integer` | `key_length / 2` | Bytes of the derived key used as prefix in deterministic mode. |
|
|
132
|
-
| `memory_cost` | `Integer` | `nil` | Memory cost in KiB (`ARGON2ID
|
|
134
|
+
| `memory_cost` | `Integer` | `nil` | Memory cost in KiB (`ARGON2ID`, required; used exactly, not rounded), or block size `r` (`SCRYPT`). |
|
|
133
135
|
| `parallelism` | `Integer` | `nil` | Parallelism factor (`ARGON2ID`, `SCRYPT`). |
|
|
134
136
|
|
|
135
137
|
---
|
|
@@ -144,6 +146,8 @@ Solves a challenge by iterating counter values until the derived key starts with
|
|
|
144
146
|
| `max_counter` | `Integer, nil` | `nil` | Safety cap on the counter. Returns `nil` if exceeded. |
|
|
145
147
|
| `counter_start` | `Integer` | `0` | Starting counter value. |
|
|
146
148
|
| `counter_step` | `Integer` | `1` | Counter increment per iteration. |
|
|
149
|
+
| `counter_mode` | `String` | `'uint32'` | `'uint32'` or `'string'`; must match the challenge's `counter_mode`. |
|
|
150
|
+
| `timeout` | `Numeric, nil` | `90_000` | Milliseconds before giving up and returning `nil` (JS default). `nil` or `0` disables it. |
|
|
147
151
|
|
|
148
152
|
---
|
|
149
153
|
|
|
@@ -155,9 +159,10 @@ Verifies a submitted solution.
|
|
|
155
159
|
|---|---|---|
|
|
156
160
|
| `challenge` | `Challenge` | The original challenge. |
|
|
157
161
|
| `solution` | `Solution` | The submitted solution. |
|
|
158
|
-
| `hmac_signature_secret:` | `String` | Must match the secret used in `create_challenge`. |
|
|
159
|
-
| `hmac_key_signature_secret:` | `String, nil` |
|
|
160
|
-
| `hmac_algorithm:` | `String` | HMAC digest algorithm (`SHA-256`, `SHA-384`, `SHA-512`)
|
|
162
|
+
| `hmac_signature_secret:` | `String` | Must match the secret used in `create_challenge`. `nil` or `''` raises `ArgumentError`. |
|
|
163
|
+
| `hmac_key_signature_secret:` | `String, nil` | Enables the fast path when `key_signature` is present. `nil` or `''` re-derives the key instead. |
|
|
164
|
+
| `hmac_algorithm:` | `String` | HMAC digest algorithm (`SHA-256`, `SHA-384`, `SHA-512`); anything else raises `ArgumentError`. Default: `SHA-256`. |
|
|
165
|
+
| `counter_mode:` | `String` | `'uint32'` or `'string'`; must match the challenge's `counter_mode`. Default: `uint32`. |
|
|
161
166
|
|
|
162
167
|
**`VerifySolutionResult`**
|
|
163
168
|
|
|
@@ -166,14 +171,14 @@ Verifies a submitted solution.
|
|
|
166
171
|
| `verified` | `Boolean` | `true` if the solution is valid. |
|
|
167
172
|
| `expired` | `Boolean` | `true` if the challenge has expired. |
|
|
168
173
|
| `invalid_signature` | `Boolean, nil` | `true` if the challenge signature is missing or wrong. |
|
|
169
|
-
| `invalid_solution` | `Boolean, nil` | `true` if the derived key does not match. |
|
|
174
|
+
| `invalid_solution` | `Boolean, nil` | `true` if the derived key does not match, or the solution is malformed (`counter` not a number, or in `uint32` mode not a whole number in `0..2^32-1`; `derived_key` not an even-length hex string). |
|
|
170
175
|
| `time` | `Integer` | Verification time in milliseconds. |
|
|
171
176
|
|
|
172
177
|
---
|
|
173
178
|
|
|
174
179
|
### `Altcha::V2.verify_server_signature(payload:, hmac_secret:)` → `VerifyServerSignatureResult`
|
|
175
180
|
|
|
176
|
-
Verifies a server signature payload issued by the ALTCHA backend.
|
|
181
|
+
Verifies a server signature payload issued by the ALTCHA backend. A payload `algorithm` other than `SHA-256`, `SHA-384` or `SHA-512` gives `invalid_signature: true`.
|
|
177
182
|
|
|
178
183
|
| Parameter | Type | Description |
|
|
179
184
|
|---|---|---|
|
|
@@ -195,7 +200,7 @@ Verifies a server signature payload issued by the ALTCHA backend.
|
|
|
195
200
|
|
|
196
201
|
### `Altcha::V2.verify_fields_hash(form_data:, fields:, fields_hash:, algorithm: 'SHA-256')` → `Boolean`
|
|
197
202
|
|
|
198
|
-
Verifies the SHA digest of selected form fields, matching the `fieldsHash` included in `verification_data`.
|
|
203
|
+
Verifies the SHA digest of selected form fields, matching the `fieldsHash` included in `verification_data`. Falsy values (`nil`, `false`, `0`, `''`, as in JS) hash as empty strings. `algorithm` must be `'SHA-256'`, `'SHA-384'` or `'SHA-512'`; anything else raises `ArgumentError`.
|
|
199
204
|
|
|
200
205
|
---
|
|
201
206
|
|
|
@@ -207,7 +212,7 @@ Parses a URL-encoded `verificationData` string into a typed Hash. Booleans, inte
|
|
|
207
212
|
|
|
208
213
|
### `Altcha::V2.canonical_json(obj)` → `String`
|
|
209
214
|
|
|
210
|
-
Produces a canonical (
|
|
215
|
+
Produces a canonical compact JSON string, byte-identical to JS `JSON.stringify(sortKeys(obj))`. Object keys are sorted, except that integer-like keys (`"2"`, `"10"`) come first in numeric order as in JS, and objects inside arrays keep their key order. Numbers are formatted like JS `JSON.stringify` (`1.0` → `1`, `1e-7` → `1e-7`, integers beyond 2^53 rounded to the nearest double). Used internally for signing.
|
|
211
216
|
|
|
212
217
|
---
|
|
213
218
|
|
|
@@ -219,6 +224,8 @@ Produces a canonical (alphabetically sorted keys, compact) JSON string. Used int
|
|
|
219
224
|
- `.from_json(string)` / `#to_json`
|
|
220
225
|
|
|
221
226
|
**`Altcha::V2::ChallengeParameters`** — all parameters embedded in a challenge.
|
|
227
|
+
- `extra` — `Hash` of parsed parameter keys without a non-nil attribute (unknown fields, explicit `null`s). Serialized and signed verbatim, so challenges from other implementations verify; injected fields fail the signature check.
|
|
228
|
+
- `key_length` / `key_prefix` — default to `32` / `'00'` when absent from a parsed challenge, but absent keys are not added to the signed JSON.
|
|
222
229
|
|
|
223
230
|
**`Altcha::V2::Solution`** — solution returned by `solve_challenge`.
|
|
224
231
|
- `counter` — `Integer`
|
data/altcha.gemspec
CHANGED
|
@@ -12,7 +12,7 @@ Gem::Specification.new do |spec|
|
|
|
12
12
|
spec.description = "A lightweight library for creating and verifying ALTCHA challenges."
|
|
13
13
|
spec.homepage = "https://altcha.org"
|
|
14
14
|
spec.license = "MIT"
|
|
15
|
-
spec.required_ruby_version = ">=
|
|
15
|
+
spec.required_ruby_version = ">= 3.0"
|
|
16
16
|
|
|
17
17
|
# Specify which files should be added to the gem when it is released.
|
|
18
18
|
# The `git ls-files -z` loads the files in the RubyGem that have been added into git.
|
|
@@ -25,6 +25,7 @@ Gem::Specification.new do |spec|
|
|
|
25
25
|
|
|
26
26
|
spec.add_dependency "base64"
|
|
27
27
|
|
|
28
|
+
spec.add_development_dependency "argon2-kdf", ">= 0.3"
|
|
28
29
|
spec.add_development_dependency "bundler", "~> 4.0"
|
|
29
30
|
spec.add_development_dependency "rake", "~> 13.3"
|
|
30
31
|
spec.add_development_dependency "rspec", "~> 3.13"
|
data/lib/altcha/v2.rb
CHANGED
|
@@ -12,15 +12,27 @@ module Altcha
|
|
|
12
12
|
module V2
|
|
13
13
|
DEFAULT_KEY_LENGTH = 32
|
|
14
14
|
DEFAULT_KEY_PREFIX = '00'
|
|
15
|
+
# solve_challenge timeout in milliseconds (JS solveChallenge default).
|
|
16
|
+
DEFAULT_SOLVE_TIMEOUT = 90_000
|
|
17
|
+
# Even-length hex string (case-insensitive, like JS parseInt).
|
|
18
|
+
HEX_PATTERN = /\A(?:[0-9a-fA-F]{2})*\z/.freeze
|
|
15
19
|
|
|
16
20
|
# All parameters embedded in a v2 challenge.
|
|
17
21
|
class ChallengeParameters
|
|
18
|
-
|
|
19
|
-
|
|
22
|
+
# camelCase keys backed by attributes; any other key goes to #extra.
|
|
23
|
+
KEYS = %w[algorithm cost data expiresAt keyLength keyPrefix keySignature
|
|
24
|
+
memoryCost nonce parallelism salt].freeze
|
|
20
25
|
|
|
26
|
+
attr_accessor :algorithm, :nonce, :salt, :cost, :key_signature, :memory_cost,
|
|
27
|
+
:parallelism, :expires_at, :data, :extra
|
|
28
|
+
attr_writer :key_length, :key_prefix
|
|
29
|
+
|
|
30
|
+
# extra: keys from a parsed challenge with no non-nil attribute (unknown
|
|
31
|
+
# fields, explicit nulls). Serialized verbatim so the signed canonical JSON
|
|
32
|
+
# matches what the issuer signed, as JS canonicalJSON does.
|
|
21
33
|
def initialize(algorithm:, nonce:, salt:, cost:, key_length: DEFAULT_KEY_LENGTH,
|
|
22
34
|
key_prefix: DEFAULT_KEY_PREFIX, key_signature: nil,
|
|
23
|
-
memory_cost: nil, parallelism: nil, expires_at: nil, data: nil)
|
|
35
|
+
memory_cost: nil, parallelism: nil, expires_at: nil, data: nil, extra: {})
|
|
24
36
|
@algorithm = algorithm
|
|
25
37
|
@nonce = nonce
|
|
26
38
|
@salt = salt
|
|
@@ -32,24 +44,36 @@ module Altcha
|
|
|
32
44
|
@parallelism = parallelism
|
|
33
45
|
@expires_at = expires_at
|
|
34
46
|
@data = data
|
|
47
|
+
@extra = extra
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# A parsed challenge may omit keyLength/keyPrefix: use the defaults for
|
|
51
|
+
# solving and verifying, but keep them out of the signed JSON.
|
|
52
|
+
def key_length
|
|
53
|
+
@key_length.nil? ? DEFAULT_KEY_LENGTH : @key_length
|
|
35
54
|
end
|
|
36
55
|
|
|
37
|
-
|
|
38
|
-
|
|
56
|
+
def key_prefix
|
|
57
|
+
@key_prefix.nil? ? DEFAULT_KEY_PREFIX : @key_prefix
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Serializes to a plain Hash with camelCase keys: non-nil attributes plus
|
|
61
|
+
# #extra. Must reproduce the issuer's parameters exactly for HMAC signing.
|
|
39
62
|
def to_h
|
|
40
63
|
h = {
|
|
41
|
-
'algorithm'
|
|
42
|
-
'cost'
|
|
43
|
-
'
|
|
44
|
-
'
|
|
45
|
-
'
|
|
46
|
-
'
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
64
|
+
'algorithm' => algorithm,
|
|
65
|
+
'cost' => cost,
|
|
66
|
+
'data' => data,
|
|
67
|
+
'expiresAt' => expires_at,
|
|
68
|
+
'keyLength' => @key_length,
|
|
69
|
+
'keyPrefix' => @key_prefix,
|
|
70
|
+
'keySignature' => key_signature,
|
|
71
|
+
'memoryCost' => memory_cost,
|
|
72
|
+
'nonce' => nonce,
|
|
73
|
+
'parallelism' => parallelism,
|
|
74
|
+
'salt' => salt
|
|
75
|
+
}.compact
|
|
76
|
+
extra.each { |key, value| h[key] = value unless h.key?(key) }
|
|
53
77
|
h
|
|
54
78
|
end
|
|
55
79
|
|
|
@@ -85,13 +109,14 @@ module Altcha
|
|
|
85
109
|
nonce: p['nonce'],
|
|
86
110
|
salt: p['salt'],
|
|
87
111
|
cost: p['cost'],
|
|
88
|
-
key_length: p
|
|
89
|
-
key_prefix: p
|
|
112
|
+
key_length: p['keyLength'],
|
|
113
|
+
key_prefix: p['keyPrefix'],
|
|
90
114
|
key_signature: p['keySignature'],
|
|
91
115
|
memory_cost: p['memoryCost'],
|
|
92
116
|
parallelism: p['parallelism'],
|
|
93
117
|
expires_at: p['expiresAt'],
|
|
94
|
-
data: p['data']
|
|
118
|
+
data: p['data'],
|
|
119
|
+
extra: p.reject { |key, value| !value.nil? && ChallengeParameters::KEYS.include?(key) }
|
|
95
120
|
),
|
|
96
121
|
signature: data['signature']
|
|
97
122
|
)
|
|
@@ -222,20 +247,22 @@ module Altcha
|
|
|
222
247
|
|
|
223
248
|
# Options for V2.create_challenge.
|
|
224
249
|
class CreateChallengeOptions
|
|
225
|
-
attr_accessor :algorithm, :cost, :counter, :data, :expires_at,
|
|
226
|
-
:hmac_signature_secret, :hmac_key_signature_secret,
|
|
250
|
+
attr_accessor :algorithm, :cost, :counter, :counter_mode, :data, :expires_at,
|
|
251
|
+
:hmac_algorithm, :hmac_signature_secret, :hmac_key_signature_secret,
|
|
227
252
|
:key_length, :key_prefix, :key_prefix_length,
|
|
228
253
|
:memory_cost, :parallelism
|
|
229
254
|
|
|
230
|
-
def initialize(algorithm:, cost:, counter: nil, data: nil,
|
|
231
|
-
expires_at: nil, hmac_signature_secret: nil,
|
|
255
|
+
def initialize(algorithm:, cost:, counter: nil, counter_mode: 'uint32', data: nil,
|
|
256
|
+
expires_at: nil, hmac_algorithm: 'SHA-256', hmac_signature_secret: nil,
|
|
232
257
|
hmac_key_signature_secret: nil, key_length: nil, key_prefix: nil,
|
|
233
258
|
key_prefix_length: nil, memory_cost: nil, parallelism: nil)
|
|
234
259
|
@algorithm = algorithm
|
|
235
260
|
@cost = cost
|
|
236
261
|
@counter = counter
|
|
262
|
+
@counter_mode = counter_mode
|
|
237
263
|
@data = data
|
|
238
264
|
@expires_at = expires_at
|
|
265
|
+
@hmac_algorithm = hmac_algorithm
|
|
239
266
|
@hmac_signature_secret = hmac_signature_secret
|
|
240
267
|
@hmac_key_signature_secret = hmac_key_signature_secret
|
|
241
268
|
@key_length = key_length
|
|
@@ -250,25 +277,111 @@ module Altcha
|
|
|
250
277
|
# Module-level functions
|
|
251
278
|
# -------------------------------------------------------------------------
|
|
252
279
|
|
|
253
|
-
#
|
|
280
|
+
# Largest integer a JS number holds exactly (Number.MAX_SAFE_INTEGER).
|
|
281
|
+
MAX_SAFE_INTEGER = (2**53) - 1
|
|
282
|
+
|
|
283
|
+
# Largest array index; JSON.stringify emits keys "0".."4294967294" first.
|
|
284
|
+
MAX_ARRAY_INDEX = (2**32) - 2
|
|
285
|
+
|
|
286
|
+
# Produces a canonical (sorted-key, compact) JSON string, byte-identical to
|
|
287
|
+
# JS JSON.stringify(sortKeys(obj)): keys and numbers are ordered/formatted
|
|
288
|
+
# like JS so signatures match across implementations and survive a JS
|
|
289
|
+
# parse/stringify round-trip.
|
|
254
290
|
def self.canonical_json(obj)
|
|
291
|
+
js_json(obj, true)
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# sort_keys mirrors JS sortKeys, which recurses into objects but returns
|
|
295
|
+
# arrays (and any objects inside them) untouched.
|
|
296
|
+
def self.js_json(obj, sort_keys)
|
|
255
297
|
case obj
|
|
256
298
|
when Hash
|
|
257
|
-
pairs = obj.
|
|
258
|
-
.map { |k, v| "#{k.to_s.to_json}:#{canonical_json(v)}" }
|
|
299
|
+
pairs = js_key_order(obj, sort_keys).map { |k, v| "#{k.to_json}:#{js_json(v, sort_keys)}" }
|
|
259
300
|
"{#{pairs.join(',')}}"
|
|
260
301
|
when Array
|
|
261
|
-
"[#{obj.map { |v|
|
|
302
|
+
"[#{obj.map { |v| js_json(v, false) }.join(',')}]"
|
|
303
|
+
when Integer, Float
|
|
304
|
+
js_number(obj)
|
|
262
305
|
else
|
|
263
306
|
obj.to_json
|
|
264
307
|
end
|
|
265
308
|
end
|
|
309
|
+
private_class_method :js_json
|
|
310
|
+
|
|
311
|
+
# JS object key order: array-index keys ascending numerically, then the
|
|
312
|
+
# remaining keys in insertion order. sortKeys inserts them sorted with
|
|
313
|
+
# Array#sort, which compares UTF-16 code units.
|
|
314
|
+
def self.js_key_order(hash, sort_keys)
|
|
315
|
+
pairs = hash.map { |k, v| [k.to_s, v] }
|
|
316
|
+
index_pairs, named_pairs = pairs.partition { |k, _| array_index_key?(k) }
|
|
317
|
+
named_pairs = named_pairs.sort_by { |k, _| k.encode(Encoding::UTF_16BE) } if sort_keys
|
|
318
|
+
index_pairs.sort_by { |k, _| k.to_i } + named_pairs
|
|
319
|
+
end
|
|
320
|
+
private_class_method :js_key_order
|
|
321
|
+
|
|
322
|
+
# Invalid-UTF-8 keys (e.g. JSON-parsed lone surrogates) are never indices;
|
|
323
|
+
# checking first keeps the regex from raising on them.
|
|
324
|
+
def self.array_index_key?(key)
|
|
325
|
+
key.valid_encoding? && /\A(?:0|[1-9][0-9]{0,9})\z/.match?(key) && key.to_i <= MAX_ARRAY_INDEX
|
|
326
|
+
end
|
|
327
|
+
private_class_method :array_index_key?
|
|
328
|
+
|
|
329
|
+
# ECMAScript Number::toString as used by JSON.stringify; non-finite → null.
|
|
330
|
+
# Float#to_s yields the shortest round-trip digits, same as JS.
|
|
331
|
+
def self.js_number_to_s(float)
|
|
332
|
+
return 'null' unless float.finite?
|
|
333
|
+
return '0' if float.zero?
|
|
334
|
+
|
|
335
|
+
mantissa, exponent = float.abs.to_s.split('e')
|
|
336
|
+
int_part, frac_part = mantissa.split('.')
|
|
337
|
+
all_digits = int_part + frac_part.to_s
|
|
338
|
+
digits = all_digits.sub(/\A0+/, '')
|
|
339
|
+
# n: position of the decimal point relative to the first significant digit.
|
|
340
|
+
n = int_part.length + exponent.to_i - (all_digits.length - digits.length)
|
|
341
|
+
digits = digits.sub(/0+\z/, '')
|
|
342
|
+
k = digits.length
|
|
343
|
+
|
|
344
|
+
s = if k <= n && n <= 21
|
|
345
|
+
digits + ('0' * (n - k))
|
|
346
|
+
elsif n.positive? && n <= 21
|
|
347
|
+
"#{digits[0, n]}.#{digits[n..]}"
|
|
348
|
+
elsif n > -6 && n <= 0
|
|
349
|
+
"0.#{'0' * -n}#{digits}"
|
|
350
|
+
else
|
|
351
|
+
e = n - 1
|
|
352
|
+
exp = e.negative? ? "e-#{-e}" : "e+#{e}"
|
|
353
|
+
k == 1 ? "#{digits}#{exp}" : "#{digits[0]}.#{digits[1..]}#{exp}"
|
|
354
|
+
end
|
|
355
|
+
float.negative? ? "-#{s}" : s
|
|
356
|
+
end
|
|
357
|
+
private_class_method :js_number_to_s
|
|
358
|
+
|
|
359
|
+
# JS Number#toString for a JSON-number value (Integer or finite Float).
|
|
360
|
+
def self.js_number(num)
|
|
361
|
+
num.is_a?(Integer) && num.abs <= MAX_SAFE_INTEGER ? num.to_s : js_number_to_s(num.to_f)
|
|
362
|
+
end
|
|
363
|
+
private_class_method :js_number
|
|
364
|
+
|
|
365
|
+
# Counter encodings supported by altcha-lib (JS PasswordBuffer).
|
|
366
|
+
COUNTER_MODES = %w[uint32 string].freeze
|
|
367
|
+
|
|
368
|
+
# Largest counter in uint32 mode (4-byte big-endian).
|
|
369
|
+
MAX_UINT32 = (2**32) - 1
|
|
266
370
|
|
|
267
371
|
# Builds the password buffer (nonce bytes + counter) used for key derivation.
|
|
268
|
-
#
|
|
269
|
-
|
|
270
|
-
|
|
372
|
+
# 'uint32': 4-byte big-endian unsigned integer (wraps mod 2^32 like JS setUint32).
|
|
373
|
+
# 'string': the counter's decimal string (JS n.toString()), UTF-8 encoded.
|
|
374
|
+
def self.make_password(nonce_bytes, counter, counter_mode = 'uint32')
|
|
375
|
+
validate_counter_mode(counter_mode)
|
|
376
|
+
nonce_bytes + (counter_mode == 'string' ? js_number(counter) : [counter].pack('N'))
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def self.validate_counter_mode(counter_mode)
|
|
380
|
+
return if COUNTER_MODES.include?(counter_mode)
|
|
381
|
+
|
|
382
|
+
raise ArgumentError, "Unsupported counter mode: #{counter_mode.inspect} (expected #{COUNTER_MODES.join(', ')})"
|
|
271
383
|
end
|
|
384
|
+
private_class_method :validate_counter_mode
|
|
272
385
|
|
|
273
386
|
# Derives a key from the given parameters, salt, and password bytes.
|
|
274
387
|
def self.derive_key(parameters, salt_bytes, password_bytes)
|
|
@@ -282,16 +395,22 @@ module Altcha
|
|
|
282
395
|
rescue LoadError
|
|
283
396
|
raise LoadError, "Add 'argon2-kdf' to your Gemfile to use the ARGON2ID algorithm"
|
|
284
397
|
end
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
398
|
+
memory_cost = parameters.memory_cost
|
|
399
|
+
raise ArgumentError, 'ARGON2ID requires memory_cost (KiB)' if memory_cost.nil?
|
|
400
|
+
|
|
401
|
+
# argon2-kdf's public API takes log2(memory), which cannot express
|
|
402
|
+
# non-power-of-two memoryCost values, so call its libargon2 binding
|
|
403
|
+
# directly with the exact KiB value (as node crypto.argon2 does).
|
|
404
|
+
hash = Fiddle::Pointer.malloc(key_len, Fiddle::RUBY_FREE)
|
|
405
|
+
status = Argon2::KDF::FFI.argon2id_hash_raw(
|
|
406
|
+
parameters.cost, memory_cost, parameters.parallelism || 1,
|
|
407
|
+
Fiddle::Pointer[password_bytes], password_bytes.bytesize,
|
|
408
|
+
Fiddle::Pointer[salt_bytes], salt_bytes.bytesize,
|
|
409
|
+
hash, key_len
|
|
294
410
|
)
|
|
411
|
+
raise Argon2::KDF::Error, Argon2::KDF::FFI.argon2_error_message(status).to_s unless status.zero?
|
|
412
|
+
|
|
413
|
+
hash[0, key_len]
|
|
295
414
|
when /\APBKDF2\//
|
|
296
415
|
digest = case alg
|
|
297
416
|
when 'PBKDF2/SHA-512' then 'SHA512'
|
|
@@ -331,16 +450,22 @@ module Altcha
|
|
|
331
450
|
end
|
|
332
451
|
end
|
|
333
452
|
|
|
453
|
+
# SHA algorithms supported for HMAC (JS HmacAlgorithm) and plain hashes → OpenSSL digest.
|
|
454
|
+
SHA_DIGESTS = { 'SHA-256' => 'SHA256', 'SHA-384' => 'SHA384', 'SHA-512' => 'SHA512' }.freeze
|
|
455
|
+
|
|
334
456
|
# Computes an HMAC hex digest using the specified algorithm ('SHA-256' etc.).
|
|
457
|
+
# Raises ArgumentError for any algorithm outside SHA_DIGESTS.
|
|
335
458
|
def self.hmac_hex(data, key, algorithm = 'SHA-256')
|
|
336
|
-
|
|
337
|
-
when 'SHA-384' then 'SHA384'
|
|
338
|
-
when 'SHA-512' then 'SHA512'
|
|
339
|
-
else 'SHA256'
|
|
340
|
-
end
|
|
341
|
-
OpenSSL::HMAC.hexdigest(digest, key, data)
|
|
459
|
+
OpenSSL::HMAC.hexdigest(sha_digest(algorithm), key, data)
|
|
342
460
|
end
|
|
343
461
|
|
|
462
|
+
def self.sha_digest(algorithm)
|
|
463
|
+
SHA_DIGESTS.fetch(algorithm) do
|
|
464
|
+
raise ArgumentError, "Unsupported algorithm: #{algorithm.inspect} (expected #{SHA_DIGESTS.keys.join(', ')})"
|
|
465
|
+
end
|
|
466
|
+
end
|
|
467
|
+
private_class_method :sha_digest
|
|
468
|
+
|
|
344
469
|
# Constant-time string comparison.
|
|
345
470
|
def self.constant_time_equal?(a, b)
|
|
346
471
|
return false if a.bytesize != b.bytesize
|
|
@@ -354,8 +479,10 @@ module Altcha
|
|
|
354
479
|
# @param options [CreateChallengeOptions]
|
|
355
480
|
# @return [Challenge]
|
|
356
481
|
def self.create_challenge(options)
|
|
482
|
+
sha_digest(options.hmac_algorithm) # raise early, even for unsigned challenges
|
|
483
|
+
validate_counter_mode(options.counter_mode)
|
|
357
484
|
key_length = options.key_length || DEFAULT_KEY_LENGTH
|
|
358
|
-
key_prefix = options.key_prefix
|
|
485
|
+
key_prefix = (options.key_prefix || DEFAULT_KEY_PREFIX).downcase
|
|
359
486
|
key_prefix_length = options.key_prefix_length || (key_length / 2)
|
|
360
487
|
expires_at = options.expires_at.is_a?(Time) ? options.expires_at.to_i : options.expires_at
|
|
361
488
|
|
|
@@ -377,21 +504,23 @@ module Altcha
|
|
|
377
504
|
if options.counter
|
|
378
505
|
nonce_bytes = [parameters.nonce].pack('H*')
|
|
379
506
|
salt_bytes = [parameters.salt].pack('H*')
|
|
380
|
-
password_bytes = make_password(nonce_bytes, options.counter)
|
|
507
|
+
password_bytes = make_password(nonce_bytes, options.counter, options.counter_mode)
|
|
381
508
|
derived_key_bytes = derive_key(parameters, salt_bytes, password_bytes)
|
|
382
509
|
parameters.key_prefix = derived_key_bytes[0, key_prefix_length].unpack1('H*')
|
|
383
510
|
end
|
|
384
511
|
|
|
385
|
-
if options.hmac_signature_secret
|
|
386
|
-
if derived_key_bytes && options.hmac_key_signature_secret
|
|
512
|
+
if js_truthy?(options.hmac_signature_secret)
|
|
513
|
+
if derived_key_bytes && js_truthy?(options.hmac_key_signature_secret)
|
|
387
514
|
parameters.key_signature = hmac_hex(
|
|
388
515
|
derived_key_bytes,
|
|
389
|
-
options.hmac_key_signature_secret
|
|
516
|
+
options.hmac_key_signature_secret,
|
|
517
|
+
options.hmac_algorithm
|
|
390
518
|
)
|
|
391
519
|
end
|
|
392
520
|
signature = hmac_hex(
|
|
393
521
|
canonical_json(parameters.to_h),
|
|
394
|
-
options.hmac_signature_secret
|
|
522
|
+
options.hmac_signature_secret,
|
|
523
|
+
options.hmac_algorithm
|
|
395
524
|
)
|
|
396
525
|
Challenge.new(parameters: parameters, signature: signature)
|
|
397
526
|
else
|
|
@@ -404,19 +533,27 @@ module Altcha
|
|
|
404
533
|
# @param max_counter [Integer, nil] Safety cap; nil means no limit.
|
|
405
534
|
# @param counter_start [Integer]
|
|
406
535
|
# @param counter_step [Integer]
|
|
407
|
-
# @
|
|
408
|
-
|
|
536
|
+
# @param counter_mode [String] 'uint32' (default) or 'string'; must match create_challenge.
|
|
537
|
+
# @param timeout [Numeric, nil] Milliseconds before giving up (default 90 000, as in JS);
|
|
538
|
+
# nil or 0 disables it.
|
|
539
|
+
# @return [Solution, nil] nil when max_counter or the timeout is reached.
|
|
540
|
+
def self.solve_challenge(challenge, max_counter: nil, counter_start: 0, counter_step: 1,
|
|
541
|
+
counter_mode: 'uint32', timeout: DEFAULT_SOLVE_TIMEOUT)
|
|
542
|
+
validate_counter_mode(counter_mode)
|
|
409
543
|
parameters = challenge.parameters
|
|
410
544
|
nonce_bytes = [parameters.nonce].pack('H*')
|
|
411
545
|
salt_bytes = [parameters.salt].pack('H*')
|
|
412
|
-
|
|
413
|
-
|
|
546
|
+
# Derived keys are lowercase hex; match prefixes case-insensitively.
|
|
547
|
+
key_prefix = parameters.key_prefix.downcase
|
|
548
|
+
start_ms = monotonic_ms
|
|
414
549
|
counter = counter_start
|
|
415
550
|
|
|
416
551
|
loop do
|
|
417
552
|
return nil if max_counter && counter > max_counter
|
|
553
|
+
# Checked every iteration (JS: every 10) since one KDF call can be slow.
|
|
554
|
+
return nil if timeout && timeout.positive? && monotonic_ms - start_ms > timeout
|
|
418
555
|
|
|
419
|
-
password_bytes = make_password(nonce_bytes, counter)
|
|
556
|
+
password_bytes = make_password(nonce_bytes, counter, counter_mode)
|
|
420
557
|
derived_key_bytes = derive_key(parameters, salt_bytes, password_bytes)
|
|
421
558
|
derived_key_hex = derived_key_bytes.unpack1('H*')
|
|
422
559
|
|
|
@@ -424,7 +561,7 @@ module Altcha
|
|
|
424
561
|
return Solution.new(
|
|
425
562
|
counter: counter,
|
|
426
563
|
derived_key: derived_key_hex,
|
|
427
|
-
time: (
|
|
564
|
+
time: (monotonic_ms - start_ms).round
|
|
428
565
|
)
|
|
429
566
|
end
|
|
430
567
|
|
|
@@ -432,40 +569,62 @@ module Altcha
|
|
|
432
569
|
end
|
|
433
570
|
end
|
|
434
571
|
|
|
572
|
+
def self.monotonic_ms
|
|
573
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond)
|
|
574
|
+
end
|
|
575
|
+
private_class_method :monotonic_ms
|
|
576
|
+
|
|
435
577
|
# Verifies a v2 solution against its challenge.
|
|
436
578
|
# @param challenge [Challenge]
|
|
437
579
|
# @param solution [Solution]
|
|
438
580
|
# @param hmac_signature_secret [String] Must match what was used in create_challenge.
|
|
439
581
|
# @param hmac_key_signature_secret [String, nil] Required when keySignature is present.
|
|
440
582
|
# @param hmac_algorithm [String] Defaults to 'SHA-256'.
|
|
583
|
+
# @param counter_mode [String] 'uint32' (default) or 'string'; must match create_challenge.
|
|
441
584
|
# @return [VerifySolutionResult]
|
|
442
585
|
def self.verify_solution(challenge, solution, hmac_signature_secret:,
|
|
443
586
|
hmac_key_signature_secret: nil,
|
|
444
|
-
hmac_algorithm: 'SHA-256')
|
|
587
|
+
hmac_algorithm: 'SHA-256', counter_mode: 'uint32')
|
|
445
588
|
start_time = Time.now
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
589
|
+
sha_digest(hmac_algorithm) # raise on misconfiguration, before any early return
|
|
590
|
+
validate_counter_mode(counter_mode)
|
|
591
|
+
# An empty secret makes signatures forgeable (JS WebCrypto rejects it too).
|
|
592
|
+
raise ArgumentError, 'hmac_signature_secret must be a non-empty String' unless js_truthy?(hmac_signature_secret)
|
|
593
|
+
|
|
594
|
+
# 1. Expiration check. Runs before the signature check, so expires_at may
|
|
595
|
+
# be tampered: only numbers are compared; anything else falls through and
|
|
596
|
+
# fails the signature check. Like JS `expiresAt && expiresAt < now`:
|
|
597
|
+
# 0 means no expiry, and now keeps fractional seconds (no 1 s grace).
|
|
598
|
+
expires_at = challenge.parameters.expires_at
|
|
599
|
+
if (expires_at.is_a?(Integer) || expires_at.is_a?(Float)) &&
|
|
600
|
+
!expires_at.zero? && expires_at < Time.now.to_f
|
|
449
601
|
return VerifySolutionResult.new(
|
|
450
602
|
expired: true, invalid_signature: nil, invalid_solution: nil,
|
|
451
603
|
time: elapsed_ms(start_time), verified: false
|
|
452
604
|
)
|
|
453
605
|
end
|
|
454
606
|
|
|
455
|
-
# 2. Signature presence check.
|
|
456
|
-
|
|
607
|
+
# 2. Signature presence check. The signature is client input: anything
|
|
608
|
+
# but a String (e.g. 123, an Array) is invalid instead of raising.
|
|
609
|
+
unless challenge.signature.is_a?(String)
|
|
457
610
|
return VerifySolutionResult.new(
|
|
458
611
|
expired: false, invalid_signature: true, invalid_solution: nil,
|
|
459
612
|
time: elapsed_ms(start_time), verified: false
|
|
460
613
|
)
|
|
461
614
|
end
|
|
462
615
|
|
|
463
|
-
# 3. Verify challenge signature (tamper detection).
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
616
|
+
# 3. Verify challenge signature (tamper detection). The parameters are
|
|
617
|
+
# client input: strings that cannot be serialized (invalid UTF-8, e.g. a
|
|
618
|
+
# JSON-parsed lone surrogate) cannot match any signature we issued.
|
|
619
|
+
begin
|
|
620
|
+
signed_json = canonical_json(challenge.parameters.to_h)
|
|
621
|
+
rescue JSON::GeneratorError, EncodingError
|
|
622
|
+
return VerifySolutionResult.new(
|
|
623
|
+
expired: false, invalid_signature: true, invalid_solution: nil,
|
|
624
|
+
time: elapsed_ms(start_time), verified: false
|
|
625
|
+
)
|
|
626
|
+
end
|
|
627
|
+
expected_sig = hmac_hex(signed_json, hmac_signature_secret, hmac_algorithm)
|
|
469
628
|
unless constant_time_equal?(challenge.signature, expected_sig)
|
|
470
629
|
return VerifySolutionResult.new(
|
|
471
630
|
expired: false, invalid_signature: true, invalid_solution: nil,
|
|
@@ -473,11 +632,25 @@ module Altcha
|
|
|
473
632
|
)
|
|
474
633
|
end
|
|
475
634
|
|
|
635
|
+
# The solution is unsigned client input: reject malformed fields instead
|
|
636
|
+
# of raising. Counter must be a JSON number (in uint32 mode a whole number
|
|
637
|
+
# in 0..2^32-1, so each solution has one counter); derived_key must be a string.
|
|
638
|
+
unless valid_solution_fields?(solution, counter_mode)
|
|
639
|
+
return VerifySolutionResult.new(
|
|
640
|
+
expired: false, invalid_signature: false, invalid_solution: true,
|
|
641
|
+
time: elapsed_ms(start_time), verified: false
|
|
642
|
+
)
|
|
643
|
+
end
|
|
644
|
+
|
|
476
645
|
# 4a. Fast path: verify via key signature when available.
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
valid =
|
|
646
|
+
# pack('H*') never fails: it pads odd lengths and maps non-hex characters
|
|
647
|
+
# to nibbles, so only well-formed hex is decoded.
|
|
648
|
+
if js_truthy?(challenge.parameters.key_signature) && js_truthy?(hmac_key_signature_secret)
|
|
649
|
+
valid = HEX_PATTERN.match?(solution.derived_key) &&
|
|
650
|
+
constant_time_equal?(
|
|
651
|
+
challenge.parameters.key_signature,
|
|
652
|
+
hmac_hex([solution.derived_key].pack('H*'), hmac_key_signature_secret, hmac_algorithm)
|
|
653
|
+
)
|
|
481
654
|
return VerifySolutionResult.new(
|
|
482
655
|
expired: false, invalid_signature: false, invalid_solution: !valid,
|
|
483
656
|
time: elapsed_ms(start_time), verified: valid
|
|
@@ -488,11 +661,11 @@ module Altcha
|
|
|
488
661
|
# and require it to satisfy the signed key prefix.
|
|
489
662
|
nonce_bytes = [challenge.parameters.nonce].pack('H*')
|
|
490
663
|
salt_bytes = [challenge.parameters.salt].pack('H*')
|
|
491
|
-
password_bytes = make_password(nonce_bytes, solution.counter)
|
|
664
|
+
password_bytes = make_password(nonce_bytes, solution.counter, counter_mode)
|
|
492
665
|
derived_key_bytes = derive_key(challenge.parameters, salt_bytes, password_bytes)
|
|
493
666
|
derived_key_hex = derived_key_bytes.unpack1('H*')
|
|
494
667
|
key_matches = constant_time_equal?(derived_key_hex, solution.derived_key)
|
|
495
|
-
prefix_matches = derived_key_hex.start_with?(challenge.parameters.key_prefix)
|
|
668
|
+
prefix_matches = derived_key_hex.start_with?(challenge.parameters.key_prefix.downcase)
|
|
496
669
|
invalid = !(key_matches && prefix_matches)
|
|
497
670
|
|
|
498
671
|
VerifySolutionResult.new(
|
|
@@ -526,19 +699,17 @@ module Altcha
|
|
|
526
699
|
nil
|
|
527
700
|
end
|
|
528
701
|
|
|
529
|
-
# Verifies the SHA hash of selected form fields.
|
|
702
|
+
# Verifies the SHA hash of selected form fields. Like JS
|
|
703
|
+
# `String(data[field] || '')`, falsy values (nil, false, 0, '') hash as ''.
|
|
530
704
|
# @param form_data [Hash]
|
|
531
705
|
# @param fields [Array<String>]
|
|
532
706
|
# @param fields_hash [String] Expected hex digest.
|
|
533
|
-
# @param algorithm [String]
|
|
707
|
+
# @param algorithm [String] 'SHA-256' (default), 'SHA-384' or 'SHA-512';
|
|
708
|
+
# anything else raises ArgumentError (JS crypto.subtle.digest throws).
|
|
534
709
|
# @return [Boolean]
|
|
535
710
|
def self.verify_fields_hash(form_data:, fields:, fields_hash:, algorithm: 'SHA-256')
|
|
536
|
-
digest =
|
|
537
|
-
|
|
538
|
-
when 'SHA-384' then 'SHA384'
|
|
539
|
-
else 'SHA256'
|
|
540
|
-
end
|
|
541
|
-
lines = fields.map { |f| form_data[f].to_s }
|
|
711
|
+
digest = sha_digest(algorithm)
|
|
712
|
+
lines = fields.map { |f| js_truthy?(form_data[f]) ? form_data[f].to_s : '' }
|
|
542
713
|
OpenSSL::Digest.hexdigest(digest, lines.join("\n")) == fields_hash
|
|
543
714
|
end
|
|
544
715
|
|
|
@@ -549,21 +720,19 @@ module Altcha
|
|
|
549
720
|
def self.verify_server_signature(payload:, hmac_secret:)
|
|
550
721
|
start_time = Time.now
|
|
551
722
|
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
hash_bytes = OpenSSL::Digest.digest(digest, payload.verification_data)
|
|
559
|
-
expected_sig = hmac_hex(hash_bytes, hmac_secret, payload.algorithm)
|
|
723
|
+
# The payload is client input: an unsupported algorithm or non-String
|
|
724
|
+
# verification_data fails the signature check instead of raising.
|
|
725
|
+
digest = SHA_DIGESTS[payload.algorithm] if payload.verification_data.is_a?(String)
|
|
726
|
+
expected_sig = if digest
|
|
727
|
+
hmac_hex(OpenSSL::Digest.digest(digest, payload.verification_data), hmac_secret, payload.algorithm)
|
|
728
|
+
end
|
|
560
729
|
verification_data = parse_verification_data(payload.verification_data)
|
|
561
730
|
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
731
|
+
# Like JS `!!expire && expire < now`: non-numeric or 0 never expires.
|
|
732
|
+
expire = verification_data && verification_data['expire']
|
|
733
|
+
expired = (expire.is_a?(Integer) || expire.is_a?(Float)) && !expire.zero? && expire < Time.now.to_i
|
|
565
734
|
|
|
566
|
-
invalid_signature = !constant_time_equal?(payload.signature.to_s, expected_sig)
|
|
735
|
+
invalid_signature = expected_sig.nil? || !constant_time_equal?(payload.signature.to_s, expected_sig)
|
|
567
736
|
|
|
568
737
|
invalid_solution = verification_data.nil? ||
|
|
569
738
|
verification_data['verified'] != true ||
|
|
@@ -584,6 +753,33 @@ module Altcha
|
|
|
584
753
|
def self.elapsed_ms(start_time)
|
|
585
754
|
((Time.now - start_time) * 1000).round
|
|
586
755
|
end
|
|
756
|
+
|
|
757
|
+
# derived_key must be a validly encoded String: the 4a hex regex raises on
|
|
758
|
+
# invalid UTF-8, and such a key can never match a hex key in 4b.
|
|
759
|
+
# In uint32 mode the counter must be a whole number in 0..MAX_UINT32: JS
|
|
760
|
+
# wraps larger values (and rounds them above 2^53), so they would only be
|
|
761
|
+
# aliases of a smaller counter. Stricter than JS, which accepts c + 2^32.
|
|
762
|
+
def self.valid_solution_fields?(solution, counter_mode)
|
|
763
|
+
counter = solution.counter
|
|
764
|
+
derived_key = solution.derived_key
|
|
765
|
+
valid_counter = if counter_mode == 'uint32'
|
|
766
|
+
(counter.is_a?(Integer) || (counter.is_a?(Float) && counter.finite? && counter % 1 == 0)) &&
|
|
767
|
+
counter >= 0 && counter <= MAX_UINT32
|
|
768
|
+
else
|
|
769
|
+
counter.is_a?(Integer) || (counter.is_a?(Float) && counter.finite?)
|
|
770
|
+
end
|
|
771
|
+
valid_counter && derived_key.is_a?(String) && derived_key.valid_encoding?
|
|
772
|
+
end
|
|
773
|
+
private_class_method :valid_solution_fields?
|
|
774
|
+
|
|
775
|
+
# JS truthiness: nil, false, '', 0, -0.0 and NaN are falsy.
|
|
776
|
+
def self.js_truthy?(value)
|
|
777
|
+
return false if value.nil? || value == false || value == ''
|
|
778
|
+
return !(value.zero? || (value.is_a?(Float) && value.nan?)) if value.is_a?(Numeric)
|
|
779
|
+
|
|
780
|
+
true
|
|
781
|
+
end
|
|
782
|
+
private_class_method :js_truthy?
|
|
587
783
|
private_class_method :elapsed_ms
|
|
588
784
|
end
|
|
589
785
|
end
|
data/lib/altcha/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: altcha
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version:
|
|
4
|
+
version: 3.0.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Daniel Regeci
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-10-04 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: base64
|
|
@@ -24,6 +24,20 @@ dependencies:
|
|
|
24
24
|
- - ">="
|
|
25
25
|
- !ruby/object:Gem::Version
|
|
26
26
|
version: '0'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: argon2-kdf
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - ">="
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '0.3'
|
|
34
|
+
type: :development
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - ">="
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '0.3'
|
|
27
41
|
- !ruby/object:Gem::Dependency
|
|
28
42
|
name: bundler
|
|
29
43
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -77,6 +91,7 @@ files:
|
|
|
77
91
|
- ".github/workflows/publish.yml"
|
|
78
92
|
- ".gitignore"
|
|
79
93
|
- ".rspec"
|
|
94
|
+
- CHANGELOG.md
|
|
80
95
|
- CODE_OF_CONDUCT.md
|
|
81
96
|
- CONTRIBUTING.md
|
|
82
97
|
- Gemfile
|
|
@@ -105,7 +120,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
105
120
|
requirements:
|
|
106
121
|
- - ">="
|
|
107
122
|
- !ruby/object:Gem::Version
|
|
108
|
-
version: '
|
|
123
|
+
version: '3.0'
|
|
109
124
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
110
125
|
requirements:
|
|
111
126
|
- - ">="
|