Module: NebulaToken

Defined in:
lib/nebula_token.rb

Overview

NEBULA — Opaque Rotating Refresh Tokens. Ruby implementation of SPECIFICATION.md (spec version 1).

Standard library only: openssl and securerandom, both still default gems. Deliberately NOT base64: it was un-defaulted in Ruby 3.4, so requiring it from a gem that does not declare the dependency raises LoadError under Bundler on a current Ruby — the library would fail to load at all. Base64url is done with pack/unpack below, which is stdlib-free and allocation-cheap.

Ruby >= 3.3. Requirement identifiers in comments ([N-*]) refer to SPECIFICATION.md.

Defined Under Namespace

Modules: ErrorCode, RefreshTokenStore Classes: ConfigError, Engine, IssueResult, MemoryRefreshTokenStore, ParsedToken, RefreshResult, RevokeResult, TokenRecord

Constant Summary collapse

SPEC_VERSION =

Version of SPECIFICATION.md this package implements ([N-52]).

1
PREFIX =
'nbl'
SELECTOR_BYTES =
16
VERIFIER_BYTES =
32
SELECTOR_CHARS =
22
VERIFIER_CHARS =
43
MAX_KID_LENGTH =
64
MAX_TOKEN_LENGTH =
512
MIN_PEPPER_LENGTH =
32
DEFAULT_ABSOLUTE_TTL =
60 * 60 * 24 * 30
DEFAULT_IDLE_TTL =
60 * 60 * 24 * 7
DEFAULT_REUSE_GRACE =
0
HASH_HEX_CHARS =

HMAC-SHA-256 output, in lowercase hex characters.

64
B64URL_RE =

Anchors are \A/\z, never ^/$: in Ruby those two are LINE anchors, so /^[A-Za-z0-9_-]+$/ happily matches "…verifier\n" and would accept a token with a trailing newline as well formed ([N-6] rule 4, vector p-24).

/\A[A-Za-z0-9_-]+\z/
KID_RE =
/\A[A-Za-z0-9_-]{1,#{MAX_KID_LENGTH}}\z/
SELECTOR_RE =
/\A[A-Za-z0-9_-]{#{SELECTOR_CHARS}}\z/
VERIFIER_RE =
/\A[A-Za-z0-9_-]{#{VERIFIER_CHARS}}\z/
HEX64_RE =
/\A[0-9a-f]{#{HASH_HEX_CHARS}}\z/
STATUS_ACTIVE =

Record status ([N-10]). Symbols internally; strings are accepted at the store boundary and normalised, see .normalize_status.

:active
STATUS_ROTATED =
:rotated
STATUS_REVOKED =
:revoked
STATUSES =
[STATUS_ACTIVE, STATUS_ROTATED, STATUS_REVOKED].freeze
STATUS_BY_NAME =
{
  'active' => STATUS_ACTIVE, 'rotated' => STATUS_ROTATED, 'revoked' => STATUS_REVOKED
}.freeze
DEVICE_PREFIX =
'device:'.b.freeze

Class Method Summary collapse

Class Method Details

.b64url_decode(str) ⇒ Object

Returns the decoded bytes, or nil for anything that is not unpadded base64url. Never raises ([N-8]).



120
121
122
123
124
125
126
127
128
129
130
# File 'lib/nebula_token.rb', line 120

def b64url_decode(str)
  return nil unless str.is_a?(String) && str.encoding.ascii_compatible?
  return nil unless B64URL_RE.match?(str.b)

  standard = str.b.tr('-_', '+/')
  standard << ('=' * ((4 - (standard.bytesize % 4)) % 4))
  standard.unpack1('m0')
rescue ArgumentError
  # `m0` is the strict decoder: it rejects lengths that cannot be base64.
  nil
end

.b64url_encode(bytes) ⇒ Object

── Base64url, RFC 4648 §5, unpadded ───────────────────────────────────────



114
115
116
# File 'lib/nebula_token.rb', line 114

def b64url_encode(bytes)
  [bytes].pack('m0').tr('+/', '-_').delete('=')
end

.cas_applied?(value) ⇒ Boolean

Did a compare-and-set apply? ([N-17], [N-18])

The store contract returns a boolean, but docs/STORE.md §4 also tells an adapter to derive it from the affected-row count its driver reports — cmd_tuples on a PG::Result, affected_rows on Mysql2 — and in Ruby 0 is truthy. A bare unless @store.mark_rotated(...) therefore reads a LOST compare-and-set as applied: two concurrent refreshes each mint a successor, the family forks into two independently valid lineages, and no later presentation of either is a replay. Reuse detection is not weakened for that family, it is switched off — which is the entire failure [N-17] exists to close. Ruby is the only one of the ten ports where a count can read as success, so this is a Ruby-side guard, not a change of contract.

Anything outside the contract fails closed as "not applied": a spurious CONFLICT revokes nothing and is retryable ([N-35]), a spurious success is unrecoverable.

Returns:

  • (Boolean)


309
310
311
312
313
314
315
# File 'lib/nebula_token.rb', line 309

def cas_applied?(value)
  case value
  when true, false then value
  when Integer then value.positive?
  else false
  end
end

.constant_time_equal_hex(a_hex, b_hex) ⇒ Object

Constant-time comparison of two hex digests ([N-31]).

Operands that are not exactly 64 LOWERCASE hex characters compare unequal. The guard is the point: a lenient hex decode stops at the first invalid character and silently compares decoded prefixes, so a stored hash that a CHAR(64) column space-padded, an ETL job upper-cased, or a truncating migration cut short would keep on verifying instead of failing closed. Never raises, whatever it is handed.



267
268
269
270
271
272
273
274
275
276
277
# File 'lib/nebula_token.rb', line 267

def constant_time_equal_hex(a_hex, b_hex)
  return false unless a_hex.is_a?(String) && b_hex.is_a?(String)

  # Matched through a binary view: a String whose bytes are invalid in its own
  # encoding raises ArgumentError on Regexp#match?, and [N-31] forbids raising.
  return false unless HEX64_RE.match?(a_hex.b) && HEX64_RE.match?(b_hex.b)

  # Both operands are known to be exactly 64 bytes here, so the fixed-length
  # comparison (no hashing round, no early exit) is the right primitive.
  OpenSSL.fixed_length_secure_compare(a_hex.b, b_hex.b)
end

.hash_device_id(pepper, device_id) ⇒ Object

device_id_hash = lowercase hex HMAC-SHA-256(pepper, UTF-8 of "device:" + device_id) ([N-11]). No normalisation, trimming or case folding is applied to either input.

Raises ConfigError for a device id that has no UTF-8 encoding; callers on the attacker-reachable path must pre-check with .utf8_bytes ([N-12]).

Raises:



243
244
245
246
247
248
# File 'lib/nebula_token.rb', line 243

def hash_device_id(pepper, device_id)
  message = utf8_bytes(device_id)
  raise ConfigError, 'device_id is not valid Unicode (unpaired surrogate)' if message.nil?

  OpenSSL::HMAC.hexdigest('SHA256', hmac_key(pepper), DEVICE_PREFIX + message)
end

.hash_verifier(pepper, verifier) ⇒ Object

verifier_hash = lowercase hex HMAC-SHA-256(pepper, verifier bytes) ([N-11], [N-13]).



234
235
236
# File 'lib/nebula_token.rb', line 234

def hash_verifier(pepper, verifier)
  OpenSSL::HMAC.hexdigest('SHA256', hmac_key(pepper), verifier)
end

.hmac_key(pepper) ⇒ Object

Raises:



252
253
254
255
256
257
# File 'lib/nebula_token.rb', line 252

def hmac_key(pepper)
  key = pepper_bytes(pepper)
  raise ConfigError, 'pepper must be a String with a UTF-8 encoding' if key.nil?

  key
end

.kid?(value) ⇒ Boolean

True iff the value is a well-formed kid per the §2 ABNF ([N-5]).

Returns:

  • (Boolean)


227
228
229
# File 'lib/nebula_token.rb', line 227

def kid?(value)
  value.is_a?(String) && value.encoding.ascii_compatible? && KID_RE.match?(value.b)
end

.normalize_status(value) ⇒ Object

Normalise whatever a store returned for status ([N-10]).

Every mainstream Ruby store hands back the database String — ActiveRecord, Sequel, pg, Redis and JSON all do — while the engine compares Symbols. A positive match on :active with rotation as the fall-through would then rotate revoked and already-rotated tokens, silently disabling reuse detection. So the mapping is explicit and anything unrecognised (nil, "Active", 5, a column default) becomes :revoked: unknown state must refuse, not rotate.



287
288
289
290
291
# File 'lib/nebula_token.rb', line 287

def normalize_status(value)
  return value if STATUSES.include?(value)

  STATUS_BY_NAME.fetch(value.to_s, STATUS_REVOKED)
end

.parse_token(token) ⇒ Object

Parse a wire token ([N-5]..[N-9]). Total: returns nil for every rejection, and raises for nothing — not for nil, not for a non-string, not for bytes that are invalid in the string's own encoding.



188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
# File 'lib/nebula_token.rb', line 188

def parse_token(token)
  return nil unless token.is_a?(String)

  # [N-6] rule 1, in BYTES and before any other parsing work.
  return nil if token.bytesize > MAX_TOKEN_LENGTH

  # A cookie value handed over by Rack can carry arbitrary bytes tagged UTF-8.
  # String#split, Regexp#match? and friends raise ArgumentError ("invalid byte
  # sequence in UTF-8") on such a string, which would turn a one-byte request
  # into an unauthenticated 500 on the refresh endpoint instead of MALFORMED
  # ([N-8]). Non-ASCII-compatible encodings (UTF-16/32) cannot hold a token
  # either, and would raise Encoding::CompatibilityError on the split below.
  return nil unless token.valid_encoding? && token.encoding.ascii_compatible?

  parts = token.split('.', -1)
  return nil unless parts.length == 4 # [N-6] rule 2

  prefix, kid, selector, verifier_b64 = parts
  return nil unless prefix == PREFIX # [N-6] rule 3, case-sensitive

  # [N-6] rules 2/4/5/6 in one pass each. The alphabet is ASCII-only, so the
  # character counts in these patterns are also byte counts ([N-1]); the
  # patterns reject padding, whitespace, '+', '/' and every non-ASCII byte.
  return nil unless KID_RE.match?(kid)
  return nil unless SELECTOR_RE.match?(selector)
  return nil unless VERIFIER_RE.match?(verifier_b64)

  verifier = b64url_decode(verifier_b64)
  return nil if verifier.nil? || verifier.bytesize != VERIFIER_BYTES # [N-6] rule 7

  # [N-7] canonical encoding: a 32-byte value has four distinct 43-character
  # spellings, because the last character carries four significant bits and
  # two unused ones. Only the one that re-encodes to itself is a token.
  return nil unless b64url_encode(verifier) == verifier_b64

  ParsedToken.new(kid, selector, verifier)
end

.pepper_bytes(value) ⇒ Object

The HMAC key bytes of a pepper, or nil when the pepper has no UTF-8 encoding ([N-11]).

Decided on the BYTES, not on the String's encoding tag. The other nine ports see a byte string and nothing else, and the same secret reaches Ruby tagged UTF-8 from a JSON file and ASCII-8BIT from File.binread, an ENV var or a KMS client — refusing the second, or transcoding it, would key the HMAC differently in Ruby than everywhere else for one configured value. Bytes that are not valid UTF-8 — "\xED\xA0\x80", the unpaired surrogate a lenient decoder emits — have no UTF-8 encoding at all, so they are refused rather than encoded by substitution (§5, [N-24]).



176
177
178
179
180
181
# File 'lib/nebula_token.rb', line 176

def pepper_bytes(value)
  return nil unless value.is_a?(String)
  return nil unless value.b.force_encoding(Encoding::UTF_8).valid_encoding?

  value.b
end

.utf8_bytes(value) ⇒ Object

UTF-8 bytes of a string, or nil when the value has no UTF-8 encoding.

A Ruby String is bytes plus an encoding tag, so it can hold something that is not valid Unicode: "\xED\xA0\x80" tagged UTF-8 is the unpaired surrogate that arrives trivially through JSON. [N-11] cannot define a hash for such a value, so this returns nil and the caller decides — a binding failure on the attacker-reachable path, a caller error at issue ([N-12]). Deriving a hash from a replacement character instead would make the same identifier hash differently across languages. A binary-tagged String is bytes, not text, and is decided on the BYTES for the same reason pepper_bytes is: the identical identifier reaches Ruby tagged UTF-8 from JSON and ASCII-8BIT from String#b, File.binread or a socket read, and ASCII-8BIT -> UTF-8 transcoding raises for every byte above 0x7F. Transcoding it would refuse an identifier the other nine ports accept — and in refresh that refusal is a sender-binding failure, so it would revoke the whole family where the other nine rotate normally. A String that carries a real text encoding still transcodes, so its UTF-8 encoding is the one [N-11] names.



150
151
152
153
154
155
156
157
158
159
160
161
162
163
# File 'lib/nebula_token.rb', line 150

def utf8_bytes(value)
  return nil unless value.is_a?(String)

  utf8 =
    if value.encoding == Encoding::UTF_8 || value.encoding == Encoding::BINARY
      value
    else
      value.encode(Encoding::UTF_8)
    end
  bytes = utf8.b
  bytes.dup.force_encoding(Encoding::UTF_8).valid_encoding? ? bytes : nil
rescue EncodingError
  nil
end