Noizu.MCP.Auth.Server.Secret (Noizu MCP v0.1.6)

Copy Markdown View Source

Secret generation, hashing and constant-time comparison for the authorization-server facade.

Nothing secret is ever stored in plaintext. Two shapes are used, for two different reasons:

  • Client secretshash/2 / verify/3, PBKDF2-HMAC-SHA256 with a per-secret random salt. A client secret is chosen once and checked often; the salt and the iteration count are what make a stolen table useless.
  • Codes and tokenstoken_hash/1, plain SHA-256. Authorization codes and refresh tokens are 256 bits of :crypto.strong_rand_bytes/1 with a few minutes' or days' life, so there is nothing to brute-force and no dictionary to salt against; PBKDF2 on the token path would only add latency to every refresh.

Every comparison goes through :crypto.hash_equals/2 on equal-length digests, so neither the length of a candidate nor the position of its first wrong byte is observable.

Overriding the hasher

A host with an existing password-hashing dependency (Argon2, bcrypt) can pass secret_hasher: {MyApp.Hash, :hash} — a 1-arity function returning a deterministic digest for a given secret. verify/3 then re-hashes the candidate and compares. Deterministic is a real constraint: a randomly-salted hasher like Argon2.hash_pwd_salt/1 will never compare equal here. Wrap such a library's own verify_pass/2 instead of using this module.

Summary

Functions

Constant-time equality for two binaries of any length.

A URL-safe random secret. bytes is entropy, not output length (32 bytes → 43 characters).

Hash a client secret for storage.

SHA-256 of a code or token, lowercase hex — what an authorization code or refresh token is stored as.

Verify a candidate secret against a stored hash, in constant time.

Functions

equal?(a, b)

@spec equal?(binary() | nil, binary() | nil) :: boolean()

Constant-time equality for two binaries of any length.

:crypto.hash_equals/2 requires equal sizes (and raises otherwise), so both sides are digested first — which also removes length from the comparison.

generate(bytes \\ 32)

@spec generate(pos_integer()) :: String.t()

A URL-safe random secret. bytes is entropy, not output length (32 bytes → 43 characters).

hash(secret, opts \\ [])

@spec hash(
  String.t(),
  keyword()
) :: String.t()

Hash a client secret for storage.

Returns pbkdf2-sha256$<iterations>$<salt>$<derived-key> — self-describing, so the iteration count can be raised later without invalidating old rows.

Options: :iterations (default 100000), :secret_hasher.

token_hash(token)

@spec token_hash(String.t()) :: String.t()

SHA-256 of a code or token, lowercase hex — what an authorization code or refresh token is stored as.

verify(candidate, stored, opts \\ [])

@spec verify(String.t() | nil, String.t() | nil, keyword()) :: boolean()

Verify a candidate secret against a stored hash, in constant time.

A malformed or nil stored hash answers false — after doing the same work, so an unknown client_id and a wrong secret take the same time.