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 ───────────────────────────────────────.
-
.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 |
.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]).
228 229 230 231 232 233 |
# File 'lib/nebula_token.rb', line 228 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]).
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
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]).
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 |