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

.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.



252
253
254
255
256
257
258
259
260
261
262
# File 'lib/nebula_token.rb', line 252

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:



228
229
230
231
232
233
# File 'lib/nebula_token.rb', line 228

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]).



219
220
221
# File 'lib/nebula_token.rb', line 219

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

.hmac_key(pepper) ⇒ Object

Raises:



237
238
239
240
241
242
# File 'lib/nebula_token.rb', line 237

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)


212
213
214
# File 'lib/nebula_token.rb', line 212

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.



272
273
274
275
276
# File 'lib/nebula_token.rb', line 272

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.



173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
# File 'lib/nebula_token.rb', line 173

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]).



161
162
163
164
165
166
# File 'lib/nebula_token.rb', line 161

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.



141
142
143
144
145
146
147
148
# File 'lib/nebula_token.rb', line 141

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

  utf8 = value.encoding == Encoding::UTF_8 ? value : value.encode(Encoding::UTF_8)
  utf8.valid_encoding? ? utf8.b : nil
rescue EncodingError
  nil
end