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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +28 -0
- data/LICENSE +202 -0
- data/README.md +66 -0
- data/lib/nebula-token.rb +7 -0
- data/lib/nebula_token.rb +916 -0
- data/skills/nebula-token-ruby/SKILL.md +85 -0
- metadata +53 -0
data/lib/nebula_token.rb
ADDED
|
@@ -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
|