nebula-token 1.0.1.pre.rc.3

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.
@@ -0,0 +1,916 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openssl'
4
+ require 'securerandom'
5
+
6
+ # NEBULA — Opaque Rotating Refresh Tokens.
7
+ # Ruby implementation of SPECIFICATION.md (spec version 1).
8
+ #
9
+ # Standard library only: `openssl` and `securerandom`, both still default gems.
10
+ # Deliberately NOT `base64`: it was un-defaulted in Ruby 3.4, so requiring it
11
+ # from a gem that does not declare the dependency raises LoadError under Bundler
12
+ # on a current Ruby — the library would fail to load at all. Base64url is done
13
+ # with pack/unpack below, which is stdlib-free and allocation-cheap.
14
+ #
15
+ # Ruby >= 3.3. Requirement identifiers in comments ([N-*]) refer to SPECIFICATION.md.
16
+ module NebulaToken
17
+ # ── Spec constants (§1) ────────────────────────────────────────────────────
18
+ # Every constant of §1 is exposed as a public named value ([N-4]).
19
+
20
+ # Version of SPECIFICATION.md this package implements ([N-52]).
21
+ SPEC_VERSION = 1
22
+ PREFIX = 'nbl'
23
+ SELECTOR_BYTES = 16
24
+ VERIFIER_BYTES = 32
25
+ SELECTOR_CHARS = 22
26
+ VERIFIER_CHARS = 43
27
+ MAX_KID_LENGTH = 64
28
+ MAX_TOKEN_LENGTH = 512
29
+ MIN_PEPPER_LENGTH = 32
30
+ DEFAULT_ABSOLUTE_TTL = 60 * 60 * 24 * 30
31
+ DEFAULT_IDLE_TTL = 60 * 60 * 24 * 7
32
+ DEFAULT_REUSE_GRACE = 0
33
+
34
+ # HMAC-SHA-256 output, in lowercase hex characters.
35
+ HASH_HEX_CHARS = 64
36
+
37
+ # Anchors are `\A`/`\z`, never `^`/`$`: in Ruby those two are LINE anchors, so
38
+ # `/^[A-Za-z0-9_-]+$/` happily matches "…{verifier}\n" and would accept a token
39
+ # with a trailing newline as well formed ([N-6] rule 4, vector p-24).
40
+ B64URL_RE = /\A[A-Za-z0-9_-]+\z/
41
+ KID_RE = /\A[A-Za-z0-9_-]{1,#{MAX_KID_LENGTH}}\z/
42
+ SELECTOR_RE = /\A[A-Za-z0-9_-]{#{SELECTOR_CHARS}}\z/
43
+ VERIFIER_RE = /\A[A-Za-z0-9_-]{#{VERIFIER_CHARS}}\z/
44
+ HEX64_RE = /\A[0-9a-f]{#{HASH_HEX_CHARS}}\z/
45
+
46
+ # Record status ([N-10]). Symbols internally; strings are accepted at the
47
+ # store boundary and normalised, see .normalize_status.
48
+ STATUS_ACTIVE = :active
49
+ STATUS_ROTATED = :rotated
50
+ STATUS_REVOKED = :revoked
51
+ STATUSES = [STATUS_ACTIVE, STATUS_ROTATED, STATUS_REVOKED].freeze
52
+ STATUS_BY_NAME = {
53
+ 'active' => STATUS_ACTIVE, 'rotated' => STATUS_ROTATED, 'revoked' => STATUS_REVOKED
54
+ }.freeze
55
+
56
+ # Protocol outcomes ([N-38]). Plain strings, so they can be logged, compared
57
+ # and serialised without a mapping table.
58
+ #
59
+ # [N-40]: treat this set as OPEN. A future minor version of the specification
60
+ # may add a code; consumers MUST treat an unrecognised code as a refusal
61
+ # rather than assuming their `case` covers everything. Concretely: give every
62
+ # `case result.error` an `else` branch that denies access.
63
+ module ErrorCode
64
+ MALFORMED = 'MALFORMED'
65
+ UNKNOWN_KID = 'UNKNOWN_KID'
66
+ NOT_FOUND = 'NOT_FOUND'
67
+ VERIFIER_MISMATCH = 'VERIFIER_MISMATCH'
68
+ REUSE_DETECTED = 'REUSE_DETECTED'
69
+ REVOKED = 'REVOKED'
70
+ EXPIRED_ABSOLUTE = 'EXPIRED_ABSOLUTE'
71
+ EXPIRED_IDLE = 'EXPIRED_IDLE'
72
+ DEVICE_MISMATCH = 'DEVICE_MISMATCH'
73
+ CONFLICT = 'CONFLICT'
74
+
75
+ # The codes defined by spec version 1. Not a closed set ([N-40]).
76
+ ALL = [
77
+ MALFORMED, UNKNOWN_KID, NOT_FOUND, VERIFIER_MISMATCH, REUSE_DETECTED,
78
+ REVOKED, EXPIRED_ABSOLUTE, EXPIRED_IDLE, DEVICE_MISMATCH, CONFLICT
79
+ ].freeze
80
+ end
81
+
82
+ # Raised by {Engine#initialize} and {Engine#issue} for caller mistakes
83
+ # (§5, [N-12]). Subclasses ArgumentError so that existing `rescue ArgumentError`
84
+ # keeps working. Protocol outcomes are NEVER raised ([N-29]).
85
+ class ConfigError < ArgumentError
86
+ def initialize(message)
87
+ super("[NEBULA] #{message}")
88
+ end
89
+ end
90
+
91
+ # A parsed wire token. `verifier` holds the raw 32 secret bytes.
92
+ class ParsedToken
93
+ attr_reader :kid, :selector, :verifier
94
+
95
+ def initialize(kid, selector, verifier)
96
+ @kid = kid
97
+ @selector = selector
98
+ @verifier = verifier
99
+ freeze
100
+ end
101
+
102
+ # [N-14]: the raw verifier must not reach any debug representation — the
103
+ # default Struct/Object inspect would print the live secret into a log.
104
+ def inspect
105
+ "#<NebulaToken::ParsedToken kid=#{@kid.inspect} selector=#{@selector.inspect} verifier=[REDACTED]>"
106
+ end
107
+ alias to_s inspect
108
+ end
109
+
110
+ module_function
111
+
112
+ # ── Base64url, RFC 4648 §5, unpadded ───────────────────────────────────────
113
+
114
+ def b64url_encode(bytes)
115
+ [bytes].pack('m0').tr('+/', '-_').delete('=')
116
+ end
117
+
118
+ # Returns the decoded bytes, or nil for anything that is not unpadded
119
+ # base64url. Never raises ([N-8]).
120
+ def b64url_decode(str)
121
+ return nil unless str.is_a?(String) && str.encoding.ascii_compatible?
122
+ return nil unless B64URL_RE.match?(str.b)
123
+
124
+ standard = str.b.tr('-_', '+/')
125
+ standard << ('=' * ((4 - (standard.bytesize % 4)) % 4))
126
+ standard.unpack1('m0')
127
+ rescue ArgumentError
128
+ # `m0` is the strict decoder: it rejects lengths that cannot be base64.
129
+ nil
130
+ end
131
+
132
+ # UTF-8 bytes of a string, or nil when the value has no UTF-8 encoding.
133
+ #
134
+ # A Ruby String is bytes plus an encoding tag, so it can hold something that
135
+ # is not valid Unicode: `"\xED\xA0\x80"` tagged UTF-8 is the unpaired
136
+ # surrogate that arrives trivially through JSON. [N-11] cannot define a hash
137
+ # for such a value, so this returns nil and the caller decides — a binding
138
+ # failure on the attacker-reachable path, a caller error at issue ([N-12]).
139
+ # Deriving a hash from a replacement character instead would make the same
140
+ # identifier hash differently across languages.
141
+ def utf8_bytes(value)
142
+ return nil unless value.is_a?(String)
143
+
144
+ utf8 = value.encoding == Encoding::UTF_8 ? value : value.encode(Encoding::UTF_8)
145
+ utf8.valid_encoding? ? utf8.b : nil
146
+ rescue EncodingError
147
+ nil
148
+ end
149
+
150
+ # The HMAC key bytes of a pepper, or nil when the pepper has no UTF-8
151
+ # encoding ([N-11]).
152
+ #
153
+ # Decided on the BYTES, not on the String's encoding tag. The other nine
154
+ # ports see a byte string and nothing else, and the same secret reaches Ruby
155
+ # tagged UTF-8 from a JSON file and ASCII-8BIT from File.binread, an ENV var
156
+ # or a KMS client — refusing the second, or transcoding it, would key the
157
+ # HMAC differently in Ruby than everywhere else for one configured value.
158
+ # Bytes that are not valid UTF-8 — "\xED\xA0\x80", the unpaired surrogate a
159
+ # lenient decoder emits — have no UTF-8 encoding at all, so they are refused
160
+ # rather than encoded by substitution (§5, [N-24]).
161
+ def pepper_bytes(value)
162
+ return nil unless value.is_a?(String)
163
+ return nil unless value.b.force_encoding(Encoding::UTF_8).valid_encoding?
164
+
165
+ value.b
166
+ end
167
+
168
+ # ── Parsing (§2) ───────────────────────────────────────────────────────────
169
+
170
+ # Parse a wire token ([N-5]..[N-9]). Total: returns nil for every rejection,
171
+ # and raises for nothing — not for nil, not for a non-string, not for bytes
172
+ # that are invalid in the string's own encoding.
173
+ def parse_token(token)
174
+ return nil unless token.is_a?(String)
175
+
176
+ # [N-6] rule 1, in BYTES and before any other parsing work.
177
+ return nil if token.bytesize > MAX_TOKEN_LENGTH
178
+
179
+ # A cookie value handed over by Rack can carry arbitrary bytes tagged UTF-8.
180
+ # String#split, Regexp#match? and friends raise ArgumentError ("invalid byte
181
+ # sequence in UTF-8") on such a string, which would turn a one-byte request
182
+ # into an unauthenticated 500 on the refresh endpoint instead of MALFORMED
183
+ # ([N-8]). Non-ASCII-compatible encodings (UTF-16/32) cannot hold a token
184
+ # either, and would raise Encoding::CompatibilityError on the split below.
185
+ return nil unless token.valid_encoding? && token.encoding.ascii_compatible?
186
+
187
+ parts = token.split('.', -1)
188
+ return nil unless parts.length == 4 # [N-6] rule 2
189
+
190
+ prefix, kid, selector, verifier_b64 = parts
191
+ return nil unless prefix == PREFIX # [N-6] rule 3, case-sensitive
192
+
193
+ # [N-6] rules 2/4/5/6 in one pass each. The alphabet is ASCII-only, so the
194
+ # character counts in these patterns are also byte counts ([N-1]); the
195
+ # patterns reject padding, whitespace, '+', '/' and every non-ASCII byte.
196
+ return nil unless KID_RE.match?(kid)
197
+ return nil unless SELECTOR_RE.match?(selector)
198
+ return nil unless VERIFIER_RE.match?(verifier_b64)
199
+
200
+ verifier = b64url_decode(verifier_b64)
201
+ return nil if verifier.nil? || verifier.bytesize != VERIFIER_BYTES # [N-6] rule 7
202
+
203
+ # [N-7] canonical encoding: a 32-byte value has four distinct 43-character
204
+ # spellings, because the last character carries four significant bits and
205
+ # two unused ones. Only the one that re-encodes to itself is a token.
206
+ return nil unless b64url_encode(verifier) == verifier_b64
207
+
208
+ ParsedToken.new(kid, selector, verifier)
209
+ end
210
+
211
+ # True iff the value is a well-formed kid per the §2 ABNF ([N-5]).
212
+ def kid?(value)
213
+ value.is_a?(String) && value.encoding.ascii_compatible? && KID_RE.match?(value.b)
214
+ end
215
+
216
+ # ── Hashing (§3) ───────────────────────────────────────────────────────────
217
+
218
+ # verifier_hash = lowercase hex HMAC-SHA-256(pepper, verifier bytes) ([N-11], [N-13]).
219
+ def hash_verifier(pepper, verifier)
220
+ OpenSSL::HMAC.hexdigest('SHA256', hmac_key(pepper), verifier)
221
+ end
222
+
223
+ # device_id_hash = lowercase hex HMAC-SHA-256(pepper, UTF-8 of "device:" + device_id)
224
+ # ([N-11]). No normalisation, trimming or case folding is applied to either input.
225
+ #
226
+ # Raises ConfigError for a device id that has no UTF-8 encoding; callers on the
227
+ # attacker-reachable path must pre-check with .utf8_bytes ([N-12]).
228
+ def hash_device_id(pepper, device_id)
229
+ message = utf8_bytes(device_id)
230
+ raise ConfigError, 'device_id is not valid Unicode (unpaired surrogate)' if message.nil?
231
+
232
+ OpenSSL::HMAC.hexdigest('SHA256', hmac_key(pepper), DEVICE_PREFIX + message)
233
+ end
234
+
235
+ DEVICE_PREFIX = 'device:'.b.freeze
236
+
237
+ def hmac_key(pepper)
238
+ key = pepper_bytes(pepper)
239
+ raise ConfigError, 'pepper must be a String with a UTF-8 encoding' if key.nil?
240
+
241
+ key
242
+ end
243
+
244
+ # Constant-time comparison of two hex digests ([N-31]).
245
+ #
246
+ # Operands that are not exactly 64 LOWERCASE hex characters compare unequal.
247
+ # The guard is the point: a lenient hex decode stops at the first invalid
248
+ # character and silently compares decoded prefixes, so a stored hash that a
249
+ # CHAR(64) column space-padded, an ETL job upper-cased, or a truncating
250
+ # migration cut short would keep on verifying instead of failing closed.
251
+ # Never raises, whatever it is handed.
252
+ def constant_time_equal_hex(a_hex, b_hex)
253
+ return false unless a_hex.is_a?(String) && b_hex.is_a?(String)
254
+
255
+ # Matched through a binary view: a String whose bytes are invalid in its own
256
+ # encoding raises ArgumentError on Regexp#match?, and [N-31] forbids raising.
257
+ return false unless HEX64_RE.match?(a_hex.b) && HEX64_RE.match?(b_hex.b)
258
+
259
+ # Both operands are known to be exactly 64 bytes here, so the fixed-length
260
+ # comparison (no hashing round, no early exit) is the right primitive.
261
+ OpenSSL.fixed_length_secure_compare(a_hex.b, b_hex.b)
262
+ end
263
+
264
+ # Normalise whatever a store returned for `status` ([N-10]).
265
+ #
266
+ # Every mainstream Ruby store hands back the database String — ActiveRecord,
267
+ # Sequel, pg, Redis and JSON all do — while the engine compares Symbols. A
268
+ # positive match on :active with rotation as the fall-through would then rotate
269
+ # revoked and already-rotated tokens, silently disabling reuse detection. So
270
+ # the mapping is explicit and anything unrecognised (nil, "Active", 5, a
271
+ # column default) becomes :revoked: unknown state must refuse, not rotate.
272
+ def normalize_status(value)
273
+ return value if STATUSES.include?(value)
274
+
275
+ STATUS_BY_NAME.fetch(value.to_s, STATUS_REVOKED)
276
+ end
277
+
278
+ # ── Server-side record (§3) ────────────────────────────────────────────────
279
+
280
+ # One row per issued token ([N-10]).
281
+ class TokenRecord
282
+ attr_reader :selector, :verifier_hash, :kid, :family_id, :generation, :user_id,
283
+ :device_id_hash, :created_at, :family_expires_at, :idle_expires_at,
284
+ :status, :rotated_at, :replaced_by_selector
285
+
286
+ # rubocop:disable Metrics/ParameterLists
287
+ def initialize(selector:, verifier_hash:, kid:, family_id:, generation:, user_id:,
288
+ created_at:, family_expires_at:, idle_expires_at:,
289
+ device_id_hash: nil, status: STATUS_ACTIVE, rotated_at: nil,
290
+ replaced_by_selector: nil)
291
+ @selector = selector
292
+ @verifier_hash = verifier_hash
293
+ @kid = kid
294
+ @family_id = family_id
295
+ @user_id = user_id
296
+ @device_id_hash = device_id_hash
297
+ @replaced_by_selector = replaced_by_selector
298
+ # [N-2]: timestamps and the generation counter are integers. A store that
299
+ # returns them as strings (the pg gem does, by default) must not silently
300
+ # produce `Integer < String` comparisons deep inside the engine.
301
+ @generation = TokenRecord.integer(generation)
302
+ @created_at = TokenRecord.integer(created_at)
303
+ @family_expires_at = TokenRecord.integer(family_expires_at)
304
+ @idle_expires_at = TokenRecord.integer(idle_expires_at)
305
+ @rotated_at = rotated_at.nil? ? nil : TokenRecord.integer(rotated_at)
306
+ self.status = status
307
+ end
308
+ # rubocop:enable Metrics/ParameterLists
309
+
310
+ # Kernel#Integer alone reads "010" as octal and "0x10" as hex; a database
311
+ # column is always base 10.
312
+ def self.integer(value)
313
+ value.is_a?(String) ? Integer(value, 10) : Integer(value)
314
+ end
315
+
316
+ # The three fields a store mutates in place; see the CAS contract in §4.
317
+ def status=(value)
318
+ @status = NebulaToken.normalize_status(value)
319
+ end
320
+
321
+ attr_writer :rotated_at, :replaced_by_selector
322
+
323
+ def active? = @status == STATUS_ACTIVE
324
+ def rotated? = @status == STATUS_ROTATED
325
+ def revoked? = @status == STATUS_REVOKED
326
+
327
+ def to_h
328
+ {
329
+ selector: @selector, verifier_hash: @verifier_hash, kid: @kid,
330
+ family_id: @family_id, generation: @generation, user_id: @user_id,
331
+ device_id_hash: @device_id_hash, created_at: @created_at,
332
+ family_expires_at: @family_expires_at, idle_expires_at: @idle_expires_at,
333
+ status: @status, rotated_at: @rotated_at, replaced_by_selector: @replaced_by_selector
334
+ }
335
+ end
336
+
337
+ def dup_record
338
+ TokenRecord.new(**to_h)
339
+ end
340
+
341
+ # [N-14]/[N-46]: no secret-derived material in a debug representation.
342
+ def inspect
343
+ "#<NebulaToken::TokenRecord selector=#{@selector.inspect} kid=#{@kid.inspect} " \
344
+ "family_id=#{@family_id.inspect} user_id=#{@user_id.inspect} generation=#{@generation} " \
345
+ "status=#{@status.inspect} verifier_hash=[REDACTED] device_id_hash=[REDACTED]>"
346
+ end
347
+ alias to_s inspect
348
+ end
349
+
350
+ # ── Store contract (§4) ────────────────────────────────────────────────────
351
+
352
+ # The six-method storage contract ([N-16]). Ruby stores are duck-typed, so
353
+ # including this module is optional; it documents the contract and turns a
354
+ # forgotten method into a clear NotImplementedError instead of NoMethodError.
355
+ #
356
+ # Two failure channels ([N-20]): protocol outcomes are the return values
357
+ # below; infrastructure failures (connection lost, timeout, constraint
358
+ # violation) MUST be raised. An exception propagates out of the engine and is
359
+ # never converted into a RefreshResult, so the caller always fails closed.
360
+ module RefreshTokenStore
361
+ def find_by_selector(_selector)
362
+ raise NotImplementedError, 'find_by_selector(selector) -> TokenRecord | nil'
363
+ end
364
+
365
+ def insert(_record)
366
+ raise NotImplementedError, 'insert(record) -> void'
367
+ end
368
+
369
+ # Compare-and-set ([N-17]). Apply the rotation write **only if** the stored
370
+ # record's status is still `from_status`, and return whether it was applied:
371
+ #
372
+ # UPDATE … SET status='rotated', rotated_at=$2, replaced_by_selector=$3
373
+ # WHERE selector=$1 AND status=$4 -- and return cmd_tuples == 1
374
+ #
375
+ # Returning true unconditionally is non-conforming: it re-opens the race in
376
+ # which two concurrent refreshes both mint a successor and fork the family.
377
+ def mark_rotated(_selector, _from_status, _rotated_at, _replaced_by_selector)
378
+ raise NotImplementedError, 'mark_rotated(selector, from_status, rotated_at, replaced_by_selector) -> bool'
379
+ end
380
+
381
+ # Compare-and-set ([N-18]): revoke only if still active; return whether it did.
382
+ def revoke_if_active(_selector)
383
+ raise NotImplementedError, 'revoke_if_active(selector) -> bool'
384
+ end
385
+
386
+ # Revoke every record of the family; return how many changed ([N-19]).
387
+ def revoke_family(_family_id)
388
+ raise NotImplementedError, 'revoke_family(family_id) -> Integer'
389
+ end
390
+
391
+ # Revoke every record of the user; return how many changed ([N-19]).
392
+ def revoke_user(_user_id)
393
+ raise NotImplementedError, 'revoke_user(user_id) -> Integer'
394
+ end
395
+ end
396
+
397
+ # Reference store ([N-21]).
398
+ #
399
+ # Guarded by a mutex, so the compare-and-set methods are atomic under the
400
+ # threaded request concurrency of Puma, Falcon or Sidekiq.
401
+ #
402
+ # NOT FOR PRODUCTION: state is per-process and lost on restart, so reuse
403
+ # detection does not survive a deploy and does not work behind more than one
404
+ # instance. Implement the six methods over your database instead — start from
405
+ # examples/pg_store.rb, schema in docs/STORE.md.
406
+ class MemoryRefreshTokenStore
407
+ include RefreshTokenStore
408
+
409
+ def initialize
410
+ @rows = {}
411
+ @mutex = Mutex.new
412
+ end
413
+
414
+ def find_by_selector(selector)
415
+ @mutex.synchronize { @rows[selector]&.dup_record }
416
+ end
417
+
418
+ def insert(record)
419
+ @mutex.synchronize do
420
+ if @rows.key?(record.selector)
421
+ # An infrastructure failure, not a protocol outcome ([N-20]): mirrors
422
+ # the primary-key violation a real store would raise.
423
+ raise "[NEBULA] duplicate selector #{record.selector}"
424
+ end
425
+
426
+ @rows[record.selector] = record.dup_record
427
+ end
428
+ nil
429
+ end
430
+
431
+ def mark_rotated(selector, from_status, rotated_at, replaced_by_selector)
432
+ want = NebulaToken.normalize_status(from_status)
433
+ @mutex.synchronize do
434
+ row = @rows[selector]
435
+ next false if row.nil? || row.status != want
436
+
437
+ row.status = STATUS_ROTATED
438
+ row.rotated_at = rotated_at
439
+ row.replaced_by_selector = replaced_by_selector
440
+ true
441
+ end
442
+ end
443
+
444
+ def revoke_if_active(selector)
445
+ @mutex.synchronize do
446
+ row = @rows[selector]
447
+ next false unless row&.active?
448
+
449
+ row.status = STATUS_REVOKED
450
+ true
451
+ end
452
+ end
453
+
454
+ def revoke_family(family_id)
455
+ revoke_where { |row| row.family_id == family_id }
456
+ end
457
+
458
+ def revoke_user(user_id)
459
+ revoke_where { |row| row.user_id == user_id }
460
+ end
461
+
462
+ # Test helper: every record currently stored. Not part of the store contract.
463
+ def all
464
+ @mutex.synchronize { @rows.values.map(&:dup_record) }
465
+ end
466
+
467
+ # Operational helper: drop records whose family deadline has passed. Nothing
468
+ # may be deleted before it ([N-15]) — rotated rows ARE the reuse detector.
469
+ def delete_expired(now)
470
+ @mutex.synchronize do
471
+ expired = @rows.select { |_, row| now >= row.family_expires_at }
472
+ expired.each_key { |selector| @rows.delete(selector) }
473
+ expired.size
474
+ end
475
+ end
476
+
477
+ private
478
+
479
+ def revoke_where
480
+ @mutex.synchronize do
481
+ @rows.each_value.count do |row|
482
+ next false if row.revoked? || !yield(row)
483
+
484
+ row.status = STATUS_REVOKED
485
+ true
486
+ end
487
+ end
488
+ end
489
+ end
490
+
491
+ # ── Results (§6) ───────────────────────────────────────────────────────────
492
+
493
+ # Result of {Engine#issue}. Every timestamp is integer unix seconds ([N-2]).
494
+ class IssueResult
495
+ attr_reader :token, :user_id, :family_id, :generation, :expires_at, :idle_expires_at
496
+
497
+ def initialize(token:, user_id:, family_id:, generation:, expires_at:, idle_expires_at:)
498
+ @token = token
499
+ @user_id = user_id
500
+ @family_id = family_id
501
+ @generation = generation
502
+ @expires_at = expires_at
503
+ @idle_expires_at = idle_expires_at
504
+ freeze
505
+ end
506
+
507
+ # [N-14]: the token embeds the raw verifier, so it must never reach a debug
508
+ # representation — `p issued` or `"#{issued}"` in a controller would
509
+ # otherwise write a live credential into the log.
510
+ def inspect
511
+ "#<NebulaToken::IssueResult token=[REDACTED] user_id=#{@user_id.inspect} " \
512
+ "family_id=#{@family_id.inspect} generation=#{@generation} " \
513
+ "expires_at=#{@expires_at} idle_expires_at=#{@idle_expires_at}>"
514
+ end
515
+ alias to_s inspect
516
+ end
517
+
518
+ # Result of {Engine#refresh} ([N-29]: outcomes are values, never exceptions).
519
+ #
520
+ # `user_id` and `family_id` are populated whenever the engine resolved a
521
+ # record — every code except MALFORMED, UNKNOWN_KID and NOT_FOUND — so a
522
+ # REUSE_DETECTED or DEVICE_MISMATCH event can be attributed to a session
523
+ # without a second lookup of a token you were told never to log ([N-39]).
524
+ class RefreshResult
525
+ attr_reader :token, :user_id, :family_id, :generation, :expires_at, :idle_expires_at, :error
526
+
527
+ # rubocop:disable Metrics/ParameterLists
528
+ def initialize(ok:, token: nil, user_id: nil, family_id: nil, generation: nil,
529
+ expires_at: nil, idle_expires_at: nil, error: nil)
530
+ @ok = ok
531
+ @token = token
532
+ @user_id = user_id
533
+ @family_id = family_id
534
+ @generation = generation
535
+ @expires_at = expires_at
536
+ @idle_expires_at = idle_expires_at
537
+ @error = error
538
+ freeze
539
+ end
540
+ # rubocop:enable Metrics/ParameterLists
541
+
542
+ def ok? = @ok
543
+
544
+ def self.success(token:, user_id:, family_id:, generation:, expires_at:, idle_expires_at:)
545
+ new(ok: true, token: token, user_id: user_id, family_id: family_id,
546
+ generation: generation, expires_at: expires_at, idle_expires_at: idle_expires_at)
547
+ end
548
+
549
+ # `record` is nil exactly when nothing was resolved ([N-39]).
550
+ def self.failure(error, record = nil)
551
+ new(ok: false, error: error, user_id: record&.user_id, family_id: record&.family_id)
552
+ end
553
+
554
+ # [N-14], as for IssueResult.
555
+ def inspect
556
+ "#<NebulaToken::RefreshResult ok=#{@ok} error=#{@error.inspect} " \
557
+ "user_id=#{@user_id.inspect} family_id=#{@family_id.inspect} " \
558
+ "generation=#{@generation.inspect} token=#{@token.nil? ? 'nil' : '[REDACTED]'}>"
559
+ end
560
+ alias to_s inspect
561
+ end
562
+
563
+ # Result of {Engine#revoke_token} ([N-36]).
564
+ #
565
+ # On a failure, `user_id` and `family_id` are populated whenever the engine
566
+ # resolved a record, exactly as in {RefreshResult} — [N-39] governs every
567
+ # failure result, not `refresh` alone. revoke_token resolves its record
568
+ # before proving the verifier, so a VERIFIER_MISMATCH there is attributable
569
+ # and carries both; MALFORMED, UNKNOWN_KID and NOT_FOUND never do.
570
+ class RevokeResult
571
+ attr_reader :user_id, :family_id, :revoked, :error
572
+
573
+ def initialize(ok:, user_id: nil, family_id: nil, revoked: 0, error: nil)
574
+ @ok = ok
575
+ @user_id = user_id
576
+ @family_id = family_id
577
+ @revoked = revoked
578
+ @error = error
579
+ freeze
580
+ end
581
+
582
+ def ok? = @ok
583
+
584
+ def self.success(user_id:, family_id:, revoked:)
585
+ new(ok: true, user_id: user_id, family_id: family_id, revoked: revoked)
586
+ end
587
+
588
+ # `record` is nil exactly when nothing was resolved ([N-39]).
589
+ def self.failure(error, record = nil)
590
+ new(ok: false, error: error, user_id: record&.user_id, family_id: record&.family_id)
591
+ end
592
+ end
593
+
594
+ # ── Engine (§5, §6) ────────────────────────────────────────────────────────
595
+
596
+ class Engine
597
+ # @param peppers [Hash{String=>String}] kid → secret, each >= MIN_PEPPER_LENGTH
598
+ # BYTES; see [N-23] on entropy — the floor is against misconfiguration, not
599
+ # a sufficient condition for security.
600
+ # @param active_kid [String] kid used for newly minted tokens.
601
+ # @param store [#find_by_selector] the six-method store ([N-16]).
602
+ # @param clock [#call] injectable "now", integer unix seconds ([N-3]).
603
+ # rubocop:disable Metrics/ParameterLists
604
+ def initialize(peppers:, active_kid:, store:,
605
+ absolute_ttl_seconds: DEFAULT_ABSOLUTE_TTL,
606
+ idle_ttl_seconds: DEFAULT_IDLE_TTL,
607
+ reuse_grace_seconds: DEFAULT_REUSE_GRACE,
608
+ clock: nil)
609
+ @peppers = copy_peppers(peppers)
610
+ unless @peppers.key?(active_kid)
611
+ raise ConfigError, "active_kid #{active_kid.inspect} not present in peppers"
612
+ end
613
+
614
+ @active_kid = active_kid
615
+ @store = store
616
+ @absolute_ttl = positive_seconds(absolute_ttl_seconds, 'absolute_ttl_seconds')
617
+ @idle_ttl = positive_seconds(idle_ttl_seconds, 'idle_ttl_seconds')
618
+ @reuse_grace = non_negative_seconds(reuse_grace_seconds, 'reuse_grace_seconds')
619
+ @clock = clock || -> { Time.now.to_i }
620
+ raise ConfigError, 'clock must respond to #call' unless @clock.respond_to?(:call)
621
+ end
622
+ # rubocop:enable Metrics/ParameterLists
623
+
624
+ # [N-46]: a pepper is an HMAC key and MUST NOT be logged. The default
625
+ # Object#inspect prints every instance variable, so `p engine`, `pp engine`,
626
+ # `Rails.logger.debug engine` or an error page that dumps ivars would write
627
+ # the entire pepper map into a log in plain text. The kid names are public
628
+ # ([N-45] treats them as routing metadata), the secrets never are.
629
+ def inspect
630
+ "#<NebulaToken::Engine active_kid=#{@active_kid.inspect} kids=#{@peppers.keys.inspect} " \
631
+ "peppers=[REDACTED] absolute_ttl=#{@absolute_ttl} idle_ttl=#{@idle_ttl} " \
632
+ "reuse_grace=#{@reuse_grace} store=#{@store.class}>"
633
+ end
634
+ alias to_s inspect
635
+
636
+ # Issue the first token of a new family ([N-25]). Call at login.
637
+ #
638
+ # `device_id` is optional sender binding; nil (absent) and "" (a real,
639
+ # bound, empty identifier) are different values and stay different ([N-25]).
640
+ def issue(user_id, device_id = nil)
641
+ # [N-12]: at issue the device id comes from the application, so an
642
+ # unencodable one is a caller error on the native channel — surfacing the
643
+ # defect here rather than minting a binding nothing can ever satisfy.
644
+ if !device_id.nil? && NebulaToken.utf8_bytes(device_id).nil?
645
+ raise ConfigError, 'device_id is not valid Unicode (unpaired surrogate)'
646
+ end
647
+
648
+ now = @clock.call
649
+ family_id = SecureRandom.hex(16)
650
+ family_expires_at = now + @absolute_ttl
651
+ device_hash = device_id.nil? ? nil : NebulaToken.hash_device_id(active_pepper, device_id)
652
+ token, record = mint(user_id, family_id, 0, device_hash, family_expires_at, now)
653
+
654
+ # [N-25] step 3: if the insert fails the exception propagates and no token
655
+ # is returned — never hand back a credential for state that was not written.
656
+ @store.insert(record)
657
+
658
+ IssueResult.new(token: token, user_id: user_id, family_id: family_id, generation: 0,
659
+ expires_at: family_expires_at, idle_expires_at: record.idle_expires_at)
660
+ end
661
+
662
+ # Exchange a refresh token for its successor.
663
+ #
664
+ # The check order of [N-26] is normative and observable — it fixes which
665
+ # error wins when several conditions hold at once — so the numbered steps
666
+ # below must not be reordered.
667
+ def refresh(token, device_id = nil)
668
+ parsed = NebulaToken.parse_token(token) # 1
669
+ return RefreshResult.failure(ErrorCode::MALFORMED) if parsed.nil?
670
+
671
+ return RefreshResult.failure(ErrorCode::UNKNOWN_KID) unless @peppers.key?(parsed.kid) # 2
672
+
673
+ record = @store.find_by_selector(parsed.selector) # 3
674
+ return RefreshResult.failure(ErrorCode::NOT_FOUND) if record.nil?
675
+
676
+ # 4. Verifier proof against the pepper of the RECORD's kid, not the active
677
+ # one; absent means the pepper was retired since the record was written.
678
+ record_pepper = @peppers[record.kid]
679
+ return RefreshResult.failure(ErrorCode::UNKNOWN_KID) if record_pepper.nil? # [N-27]
680
+
681
+ presented = NebulaToken.hash_verifier(record_pepper, parsed.verifier)
682
+ unless NebulaToken.constant_time_equal_hex(presented, record.verifier_hash)
683
+ # [N-28]: no family revocation here. Otherwise knowledge of a selector
684
+ # alone — a value this specification says is safe to index and log —
685
+ # would let anyone destroy a session.
686
+ return RefreshResult.failure(ErrorCode::VERIFIER_MISMATCH, record)
687
+ end
688
+
689
+ now = @clock.call
690
+
691
+ # Normalised again here, not only inside TokenRecord: a custom store may
692
+ # return any duck-typed row object, and only :active may rotate.
693
+ status = NebulaToken.normalize_status(record.status)
694
+
695
+ return handle_reuse(record, record_pepper, device_id, now) if status == STATUS_ROTATED # 5
696
+
697
+ # 6. Fail closed: everything that is not exactly :active refuses. Rotation
698
+ # is never the fall-through branch.
699
+ return RefreshResult.failure(ErrorCode::REVOKED, record) unless status == STATUS_ACTIVE
700
+
701
+ if now >= record.family_expires_at # 7
702
+ @store.revoke_family(record.family_id)
703
+ return RefreshResult.failure(ErrorCode::EXPIRED_ABSOLUTE, record)
704
+ end
705
+
706
+ if now >= record.idle_expires_at # 8
707
+ @store.revoke_family(record.family_id)
708
+ return RefreshResult.failure(ErrorCode::EXPIRED_IDLE, record)
709
+ end
710
+
711
+ if !record.device_id_hash.nil? && !device_matches?(record, record_pepper, device_id) # 9
712
+ @store.revoke_family(record.family_id)
713
+ return RefreshResult.failure(ErrorCode::DEVICE_MISMATCH, record)
714
+ end
715
+
716
+ rotate(record, device_id, now, STATUS_ACTIVE, now) # 10
717
+ end
718
+
719
+ # Revoke the family a token belongs to ([N-36]).
720
+ #
721
+ # Authenticated: steps 1-4 of [N-26] are performed exactly as in #refresh,
722
+ # because the selector is a public lookup key and must not by itself be a
723
+ # capability to terminate a session — that would be an unauthenticated
724
+ # denial of service against an arbitrary user. Succeeds whatever the
725
+ # record's status, so a client can still log out with a token that was
726
+ # already rotated or revoked.
727
+ #
728
+ # It takes no device identifier and performs no sender-binding check
729
+ # ([N-36]): sender binding is not required in order to log out.
730
+ def revoke_token(token)
731
+ parsed = NebulaToken.parse_token(token)
732
+ return RevokeResult.failure(ErrorCode::MALFORMED) if parsed.nil?
733
+ return RevokeResult.failure(ErrorCode::UNKNOWN_KID) unless @peppers.key?(parsed.kid)
734
+
735
+ record = @store.find_by_selector(parsed.selector)
736
+ return RevokeResult.failure(ErrorCode::NOT_FOUND) if record.nil?
737
+
738
+ record_pepper = @peppers[record.kid]
739
+ return RevokeResult.failure(ErrorCode::UNKNOWN_KID) if record_pepper.nil?
740
+
741
+ presented = NebulaToken.hash_verifier(record_pepper, parsed.verifier)
742
+ unless NebulaToken.constant_time_equal_hex(presented, record.verifier_hash)
743
+ # [N-39]: the record was resolved above, so this refusal is attributable.
744
+ # An unauthenticated attempt to terminate somebody's session is exactly
745
+ # the event an operator needs to see, and the selector alone will not
746
+ # identify the victim.
747
+ return RevokeResult.failure(ErrorCode::VERIFIER_MISMATCH, record)
748
+ end
749
+
750
+ RevokeResult.success(user_id: record.user_id, family_id: record.family_id,
751
+ revoked: @store.revoke_family(record.family_id))
752
+ end
753
+
754
+ # Revoke a whole family by its server-side identifier ([N-37]). Requires no
755
+ # token; the caller is responsible for authorising it. Idempotent.
756
+ # @return [Integer] records changed.
757
+ def revoke_family(family_id)
758
+ @store.revoke_family(family_id)
759
+ end
760
+
761
+ # Revoke every session of a user ([N-37]). Password change, "log out all
762
+ # devices", compromise response. Idempotent.
763
+ # @return [Integer] records changed.
764
+ def revoke_all_for_user(user_id)
765
+ @store.revoke_user(user_id)
766
+ end
767
+
768
+ private
769
+
770
+ # §6.3 reuse handling.
771
+ def handle_reuse(record, record_pepper, device_id, now)
772
+ # [N-30], all six preconditions. The sixth (now < family_expires_at) is
773
+ # what stops a grace retry from minting a token past the absolute deadline.
774
+ within_grace = @reuse_grace.positive? &&
775
+ !record.rotated_at.nil? &&
776
+ (now - record.rotated_at) <= @reuse_grace &&
777
+ !record.replaced_by_selector.nil? &&
778
+ now < record.family_expires_at
779
+
780
+ if within_grace
781
+ successor = @store.find_by_selector(record.replaced_by_selector)
782
+ if successor && NebulaToken.normalize_status(successor.status) == STATUS_ACTIVE
783
+ if !record.device_id_hash.nil? && !device_matches?(record, record_pepper, device_id)
784
+ @store.revoke_family(record.family_id)
785
+ return RefreshResult.failure(ErrorCode::DEVICE_MISMATCH, record)
786
+ end
787
+
788
+ # Compare-and-set: exactly one concurrent retry may consume the unused
789
+ # successor. The loser rotates nothing and reports CONFLICT ([N-30] 2).
790
+ unless @store.revoke_if_active(successor.selector)
791
+ return RefreshResult.failure(ErrorCode::CONFLICT, record)
792
+ end
793
+
794
+ # Keep the ORIGINAL rotated_at: the window is anchored to the first
795
+ # rotation and cannot be walked forward by repeated retries ([N-30] 3).
796
+ return rotate(record, device_id, now, STATUS_ROTATED, record.rotated_at)
797
+ end
798
+ end
799
+
800
+ # Otherwise the presentation is a theft signal.
801
+ @store.revoke_family(record.family_id)
802
+ RefreshResult.failure(ErrorCode::REUSE_DETECTED, record)
803
+ end
804
+
805
+ # §6.4 rotation ([N-34]).
806
+ def rotate(record, device_id, now, from_status, rotated_at)
807
+ device_hash = if !record.device_id_hash.nil? && !device_id.nil?
808
+ # Re-hash with the ACTIVE pepper: migrates the binding
809
+ # forward across pepper rotation ([N-33] step 4).
810
+ NebulaToken.hash_device_id(active_pepper, device_id)
811
+ else
812
+ record.device_id_hash
813
+ end
814
+
815
+ token, successor = mint(record.user_id, record.family_id, record.generation + 1,
816
+ device_hash, record.family_expires_at, now)
817
+ @store.insert(successor)
818
+
819
+ unless @store.mark_rotated(record.selector, from_status, rotated_at, successor.selector)
820
+ # [N-34] step 5: a concurrent refresh won the compare-and-set. Clean up
821
+ # the successor we inserted and report a retryable conflict — never a
822
+ # token. Without the CAS both refreshes would mint a successor and the
823
+ # family would fork into two independently valid lineages.
824
+ @store.revoke_if_active(successor.selector)
825
+ return RefreshResult.failure(ErrorCode::CONFLICT, record)
826
+ end
827
+
828
+ RefreshResult.success(token: token, user_id: record.user_id, family_id: record.family_id,
829
+ generation: successor.generation,
830
+ expires_at: successor.family_expires_at,
831
+ idle_expires_at: successor.idle_expires_at)
832
+ end
833
+
834
+ # §6.4 minting ([N-33]).
835
+ # rubocop:disable Metrics/ParameterLists
836
+ def mint(user_id, family_id, generation, device_id_hash, family_expires_at, now)
837
+ selector = NebulaToken.b64url_encode(SecureRandom.bytes(SELECTOR_BYTES))
838
+ verifier = SecureRandom.bytes(VERIFIER_BYTES)
839
+ record = TokenRecord.new(
840
+ selector: selector,
841
+ verifier_hash: NebulaToken.hash_verifier(active_pepper, verifier),
842
+ kid: @active_kid,
843
+ family_id: family_id,
844
+ generation: generation,
845
+ user_id: user_id,
846
+ device_id_hash: device_id_hash,
847
+ created_at: now,
848
+ family_expires_at: family_expires_at,
849
+ idle_expires_at: [now + @idle_ttl, family_expires_at].min,
850
+ status: STATUS_ACTIVE
851
+ )
852
+ ["#{PREFIX}.#{@active_kid}.#{selector}.#{NebulaToken.b64url_encode(verifier)}", record]
853
+ end
854
+ # rubocop:enable Metrics/ParameterLists
855
+
856
+ # [N-32]: hash the presented device id with the RECORD's pepper. A missing
857
+ # identifier where the record is bound fails.
858
+ def device_matches?(record, record_pepper, device_id)
859
+ return false if device_id.nil? || record.device_id_hash.nil?
860
+ # [N-12]: on the attacker-reachable path an unencodable device id is a
861
+ # binding failure, never an exception.
862
+ return false if NebulaToken.utf8_bytes(device_id).nil?
863
+
864
+ NebulaToken.constant_time_equal_hex(
865
+ NebulaToken.hash_device_id(record_pepper, device_id), record.device_id_hash
866
+ )
867
+ end
868
+
869
+ def active_pepper
870
+ @peppers[@active_kid]
871
+ end
872
+
873
+ # [N-24]: copy the configuration. Mutating the caller's Hash afterwards — or
874
+ # the pepper String in place, which Ruby permits and JavaScript does not —
875
+ # must not change engine behaviour.
876
+ def copy_peppers(peppers)
877
+ raise ConfigError, 'peppers must be a Hash of kid => secret' unless peppers.is_a?(Hash)
878
+ raise ConfigError, 'peppers must not be empty' if peppers.empty?
879
+
880
+ peppers.each_with_object({}) do |(kid, secret), copy|
881
+ unless NebulaToken.kid?(kid)
882
+ raise ConfigError, "kid #{kid.inspect} must be 1-#{MAX_KID_LENGTH} bytes from [A-Za-z0-9_-]"
883
+ end
884
+ # [N-11]: the HMAC key is the pepper's UTF-8 encoding, so a pepper with
885
+ # no UTF-8 encoding is not a usable key and MUST fail construction
886
+ # (§5, [N-24]) rather than be encoded by substitution. Decided on the
887
+ # bytes — see .pepper_bytes. The message never quotes the secret ([N-14]).
888
+ key = NebulaToken.pepper_bytes(secret)
889
+ if key.nil?
890
+ raise ConfigError, "pepper for kid #{kid.inspect} must be a String with a UTF-8 encoding"
891
+ end
892
+ # Bytes OF THAT ENCODING, not characters ([N-1]): "日" * 11 is 11
893
+ # characters and 33 bytes.
894
+ if key.bytesize < MIN_PEPPER_LENGTH
895
+ raise ConfigError, "pepper for kid #{kid.inspect} must be at least #{MIN_PEPPER_LENGTH} bytes"
896
+ end
897
+
898
+ # dup.freeze, not String#-@: interning a secret in the fstring table
899
+ # would keep it alive for the life of the process.
900
+ copy[kid.dup.freeze] = secret.dup.freeze
901
+ end.freeze
902
+ end
903
+
904
+ def positive_seconds(value, name)
905
+ raise ConfigError, "#{name} must be a positive Integer" unless value.is_a?(Integer) && value.positive?
906
+
907
+ value
908
+ end
909
+
910
+ def non_negative_seconds(value, name)
911
+ raise ConfigError, "#{name} must be a non-negative Integer" unless value.is_a?(Integer) && !value.negative?
912
+
913
+ value
914
+ end
915
+ end
916
+ end