altcha 2.0.0 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 614821123caf617aa6fa7cb26f4f9accedb5147d7e8f96ad51e6b785ac769029
4
- data.tar.gz: d1fb7cf2d8599ae569c0b15973e0af96315e45863733ad97b7013dd2e982b7ef
3
+ metadata.gz: f3f8c54a53e1c95984a1f46cc9420708fce3a3564b727ac3439c12da1dc922f3
4
+ data.tar.gz: f036c9affe9baebe5d23a2b9146ac967bb1482b604bc6db0d71c95b7b07b0605
5
5
  SHA512:
6
- metadata.gz: b09b0f6917754cfa1a2db31d8bbe35a505cf2e8a81f948079ea7460a90d6cebfc9f16c576ed598fdd8079568821e559c5b91bb479de65f634347bcf39b513a31
7
- data.tar.gz: 8fdc4d5ae63d8f17843f20c55f4343bcf310ef80da3938dc437f9f20d4081b2da8984309e6faf556bb14b66dfca3db667111f19946068c5c493fc881a7bc2c1e
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,15 +1,18 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- altcha (2.0.0)
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)
12
- rake (13.3.1)
14
+ fiddle (1.1.8)
15
+ rake (13.4.2)
13
16
  rspec (3.13.2)
14
17
  rspec-core (~> 3.13.0)
15
18
  rspec-expectations (~> 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 2.7+
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
- | `hmac_signature_secret` | `String` | `nil` | Signs the challenge parameters. Required for `verify_solution`. |
128
- | `hmac_key_signature_secret` | `String` | `nil` | Signs the derived key (fast-path verification). Requires `counter`. |
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`), or block size `r` (`SCRYPT`). |
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` | Required when `key_signature` is present. |
160
- | `hmac_algorithm:` | `String` | HMAC digest algorithm (`SHA-256`, `SHA-384`, `SHA-512`). Default: `SHA-256`. |
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 (alphabetically sorted keys, compact) JSON string. Used internally for signing.
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 = ">= 2.7"
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
- attr_accessor :algorithm, :nonce, :salt, :cost, :key_length, :key_prefix,
19
- :key_signature, :memory_cost, :parallelism, :expires_at, :data
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
- # Serializes to a plain Hash with camelCase keys, omitting nil optional fields.
38
- # The resulting hash must be stable across round-trips for HMAC signing to work.
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' => algorithm,
42
- 'cost' => cost,
43
- 'keyLength' => key_length,
44
- 'keyPrefix' => key_prefix,
45
- 'nonce' => nonce,
46
- 'salt' => salt,
47
- }
48
- h['data'] = data unless data.nil?
49
- h['expiresAt'] = expires_at unless expires_at.nil?
50
- h['keySignature'] = key_signature unless key_signature.nil?
51
- h['memoryCost'] = memory_cost unless memory_cost.nil?
52
- h['parallelism'] = parallelism unless parallelism.nil?
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.fetch('keyLength', DEFAULT_KEY_LENGTH),
89
- key_prefix: p.fetch('keyPrefix', DEFAULT_KEY_PREFIX),
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
- # Produces a canonical (sorted-key, compact) JSON string.
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.sort_by { |k, _| k.to_s }
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| canonical_json(v) }.join(',')}]"
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
- # Counter is encoded as a 4-byte big-endian unsigned integer.
269
- def self.make_password(nonce_bytes, counter)
270
- nonce_bytes + [counter].pack('N')
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
- # argon2-kdf's `m` is log2(memory_cost_in_KiB) — convert from KiB.
286
- m_kib = parameters.memory_cost || 65536
287
- Argon2::KDF.argon2id(
288
- password_bytes,
289
- salt: salt_bytes,
290
- t: parameters.cost,
291
- m: Math.log2(m_kib).round,
292
- p: parameters.parallelism || 1,
293
- length: key_len
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
- digest = case algorithm
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 || DEFAULT_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
- # @return [Solution, nil]
408
- def self.solve_challenge(challenge, max_counter: nil, counter_start: 0, counter_step: 1)
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
- key_prefix = parameters.key_prefix
413
- start_time = Time.now
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: ((Time.now - start_time) * 1000).round
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
- # 1. Expiration check.
448
- if challenge.parameters.expires_at && Time.now.to_i > challenge.parameters.expires_at
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
- unless challenge.signature
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
- expected_sig = hmac_hex(
465
- canonical_json(challenge.parameters.to_h),
466
- hmac_signature_secret,
467
- hmac_algorithm
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,24 +632,41 @@ 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
- if challenge.parameters.key_signature && hmac_key_signature_secret
478
- derived_key_bytes = [solution.derived_key].pack('H*')
479
- expected_key_sig = hmac_hex(derived_key_bytes, hmac_key_signature_secret, hmac_algorithm)
480
- valid = constant_time_equal?(challenge.parameters.key_signature, expected_key_sig)
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
484
657
  )
485
658
  end
486
659
 
487
- # 4b. Slow path: re-derive key from the submitted counter and compare.
660
+ # 4b. Slow path: re-derive key from the submitted counter and compare,
661
+ # and require it to satisfy the signed key prefix.
488
662
  nonce_bytes = [challenge.parameters.nonce].pack('H*')
489
663
  salt_bytes = [challenge.parameters.salt].pack('H*')
490
- password_bytes = make_password(nonce_bytes, solution.counter)
664
+ password_bytes = make_password(nonce_bytes, solution.counter, counter_mode)
491
665
  derived_key_bytes = derive_key(challenge.parameters, salt_bytes, password_bytes)
492
666
  derived_key_hex = derived_key_bytes.unpack1('H*')
493
- invalid = !constant_time_equal?(derived_key_hex, solution.derived_key)
667
+ key_matches = constant_time_equal?(derived_key_hex, solution.derived_key)
668
+ prefix_matches = derived_key_hex.start_with?(challenge.parameters.key_prefix.downcase)
669
+ invalid = !(key_matches && prefix_matches)
494
670
 
495
671
  VerifySolutionResult.new(
496
672
  expired: false, invalid_signature: false, invalid_solution: invalid,
@@ -523,19 +699,17 @@ module Altcha
523
699
  nil
524
700
  end
525
701
 
526
- # 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 ''.
527
704
  # @param form_data [Hash]
528
705
  # @param fields [Array<String>]
529
706
  # @param fields_hash [String] Expected hex digest.
530
- # @param algorithm [String] Defaults to 'SHA-256'.
707
+ # @param algorithm [String] 'SHA-256' (default), 'SHA-384' or 'SHA-512';
708
+ # anything else raises ArgumentError (JS crypto.subtle.digest throws).
531
709
  # @return [Boolean]
532
710
  def self.verify_fields_hash(form_data:, fields:, fields_hash:, algorithm: 'SHA-256')
533
- digest = case algorithm
534
- when 'SHA-512' then 'SHA512'
535
- when 'SHA-384' then 'SHA384'
536
- else 'SHA256'
537
- end
538
- 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 : '' }
539
713
  OpenSSL::Digest.hexdigest(digest, lines.join("\n")) == fields_hash
540
714
  end
541
715
 
@@ -546,21 +720,19 @@ module Altcha
546
720
  def self.verify_server_signature(payload:, hmac_secret:)
547
721
  start_time = Time.now
548
722
 
549
- digest = case payload.algorithm
550
- when 'SHA-512' then 'SHA512'
551
- when 'SHA-384' then 'SHA384'
552
- else 'SHA256'
553
- end
554
-
555
- hash_bytes = OpenSSL::Digest.digest(digest, payload.verification_data)
556
- 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
557
729
  verification_data = parse_verification_data(payload.verification_data)
558
730
 
559
- expired = !!(verification_data &&
560
- verification_data['expire'] &&
561
- verification_data['expire'] < Time.now.to_i)
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
562
734
 
563
- 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)
564
736
 
565
737
  invalid_solution = verification_data.nil? ||
566
738
  verification_data['verified'] != true ||
@@ -581,6 +753,33 @@ module Altcha
581
753
  def self.elapsed_ms(start_time)
582
754
  ((Time.now - start_time) * 1000).round
583
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?
584
783
  private_class_method :elapsed_ms
585
784
  end
586
785
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Altcha
4
- VERSION = '2.0.0'
4
+ VERSION = '3.0.0'
5
5
  end
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: 2.0.0
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-04-07 00:00:00.000000000 Z
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: '2.7'
123
+ version: '3.0'
109
124
  required_rubygems_version: !ruby/object:Gem::Requirement
110
125
  requirements:
111
126
  - - ">="