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
-
.b64url_decode(str) ⇒ Object
Returns the decoded bytes, or nil for anything that is not unpadded base64url.
-
.b64url_encode(bytes) ⇒ Object
── Base64url, RFC 4648 §5, unpadded ───────────────────────────────────────.
-
.cas_applied?(value) ⇒ Boolean
Did a compare-and-set apply? ([N-17], [N-18]).
-
.constant_time_equal_hex(a_hex, b_hex) ⇒ Object
Constant-time comparison of two hex digests ([N-31]).
-
.hash_device_id(pepper, device_id) ⇒ Object
device_id_hash = lowercase hex HMAC-SHA-256(pepper, UTF-8 of "device:" + device_id) ([N-11]).
-
.hash_verifier(pepper, verifier) ⇒ Object
verifier_hash = lowercase hex HMAC-SHA-256(pepper, verifier bytes) ([N-11], [N-13]).
- .hmac_key(pepper) ⇒ Object
-
.kid?(value) ⇒ Boolean
True iff the value is a well-formed kid per the §2 ABNF ([N-5]).
-
.normalize_status(value) ⇒ Object
Normalise whatever a store returned for
status([N-10]). -
.parse_token(token) ⇒ Object
Parse a wire token ([N-5]..[N-9]).
-
.pepper_bytes(value) ⇒ Object
The HMAC key bytes of a pepper, or nil when the pepper has no UTF-8 encoding ([N-11]).
-
.utf8_bytes(value) ⇒ Object
UTF-8 bytes of a string, or nil when the value has no UTF-8 encoding.
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.
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]).
243 244 245 246 247 248 |
# File 'lib/nebula_token.rb', line 243 def hash_device_id(pepper, device_id) = utf8_bytes(device_id) raise ConfigError, 'device_id is not valid Unicode (unpaired surrogate)' if .nil? OpenSSL::HMAC.hexdigest('SHA256', hmac_key(pepper), DEVICE_PREFIX + ) 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
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]).
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 |