authentication in pith is three modules. std.crypto.password handles the
password at rest, std.crypto.jwt handles the session that follows, and
std.crypto.jwks handles the tokens somebody else signed.
examples/auth.pith runs the first two end to end,
examples/web_auth.pith wires them into a std.web server — a login route
that issues tokens and a middleware that guards everything else — and
examples/jwks_verify.pith verifies against a key set an issuer publishes.
for a browser rather than an api, the session is the better carrier: a token
in a cookie cannot be revoked before it expires, and a server-side session
can. std.web.session, std.web.csrf and std.web.auth are that path —
docs/web.md covers them, and examples/web_login.pith runs a password
login through all three. this file stays about the two crypto modules
underneath.
hash takes a password and returns a phc string. verify takes the password
and the stored string and says whether they match.
import std.crypto.password as password
stored := password.hash("correct horse battery staple")!
# $argon2id$v=19$m=19456,t=2,p=1$hMDYzMFA5NHRoZQ$Zk5MPFY7...
password.verify("correct horse battery staple", stored) # true
password.verify("something else", stored) # false
the salt is fresh os randomness on every call, so hashing the same password
twice gives two different strings. the comparison at the end of verify goes
through std.crypto.subtle, so it looks at every byte whether or not the first
one matched — a comparison that returns early leaks how much of the tag was
right, one request at a time.
verify returns a Bool rather than a result. a hash it cannot parse is a
failed verification, which is the only safe reading: an api that returned an
error there would sooner or later be called by something that treated
"unparseable" as "fine".
the defaults are owasp's argon2id parameters: 19 mib of memory, two passes, one lane, a 16 byte salt and a 32 byte tag. that measures around 30 ms per hash on a modest core.
| memory | passes | lanes | measured | |
|---|---|---|---|---|
| the default | 19 mib | 2 | 1 | ~30 ms |
| rfc 9106 first option | 2 gib | 1 | 4 | over the runtime's 1 gib cap |
| rfc 9106 second option | 64 mib | 3 | 4 | ~230 ms |
30 ms is a deliberate middle. it is expensive enough that an attacker who
steals the database is buying time by the cpu-year, and cheap enough that a
login endpoint under load is not attacking itself — an auth path that spends a
quarter of a second per attempt hands anyone with a script a way to saturate
it. the runtime derives single-threaded, so raising lanes costs wall clock
rather than saving it.
to move off the defaults, build a Params and use hash_with:
strict := password.params(65536, 3, 1) # memory kib, passes, lanes
stored := password.hash_with(secret, strict)!
params_with_lengths also sets the salt and tag sizes. everything is range
checked before any derivation happens: at least 8 kib of memory per lane, a
salt of at least 8 bytes, a tag of at least 16, and no more than 1 gib of
memory, which is the cap the runtime enforces.
the cost parameters are written into the phc string, so a hash stored under old
parameters stays verifiable after you raise them. needs_rehash is what tells
you which ones to replace:
wanted := password.params(65536, 3, 1)
if password.verify(attempt, stored):
if password.needs_rehash(stored, wanted):
stored = password.hash_with(attempt, wanted)!
save(user, stored)
the rehash happens inside the successful login, because that is the only moment the plaintext password is in hand. a hash that cannot be parsed at all also reports as needing a rehash, which quietly migrates anything left over from an older scheme the first time its owner signs in.
std.crypto.jwt reads and writes the jws compact serialization. every
algorithm both signs and verifies: HS256, HS384 and HS512 take a shared
secret, and PS256, RS256, ES256 and EdDSA take a pkcs#8 private key — the der
inside what openssl genpkey writes, once the pem armor is stripped.
which one to pick: HS* when a single service both issues and checks its own tokens; EdDSA or PS256 when other parties need to verify without being able to forge; RS256 when the other end demands it, which hosted issuers commonly do. ES256 is there for the ecosystems that standardized on p-256.
import std.bytes as bytes
import std.crypto.jwt as jwt
secret := bytes.from_string_utf8(env("SESSION_SECRET"))
token := jwt.claims().issuer("chat").subject("u-1024").expires_in(3600).sign_hs256(secret)!
session := jwt.verify_hs256(token, secret, jwt.default_options())!
print(session.subject())
the builder writes the json, escapes the values, and stamps iat and exp
at signing time — expires_in counts seconds from now, so no epoch
arithmetic reaches your code. custom claims go on with .claim("role", "admin") and .claim_int("level", 9); a custom claim that spells a
registered name (iss, exp, ...) is refused rather than silently
overriding the setters. the sign_* methods cover every algorithm the
module signs with.
verify_* hands back a Verified. read the registered subject with
.subject() and anything else with .claim(name) / .claim_int(name);
for a struct of your own, the claims json is still there —
json.decode_text[T] on .claims fills it. raw claims json can also be
signed directly with jwt.sign_hs256(text, secret) when it comes from
somewhere else; a pith string literal writes json with doubled braces,
since a single { starts an interpolation.
every verifier names the algorithm it accepts. verify_hs256 verifies HS256
and nothing else; the token's own alg header is only ever compared against
the name the caller passed, and no code path selects a verifier from it.
the attack this closes is the classic one. take a token meant for an rsa
verifier, re-sign it as HS256 using the rsa public key as the hmac secret,
and hand it to a verifier that trusts the header. against verify_rs256 that
token is refused on its alg before its signature is looked at.
refused outright, with no option to turn it back on, spelled in any case, with or without a signature field bolted on the end. an unsecured jws is a valid thing for the spec to describe and never a valid thing for a verifier to accept.
a crit header parameter is rejected per rfc 7515 section 4.1.11, since a
verifier that ignores an extension the issuer marked critical is ignoring the
thing the issuer said not to ignore. signature comparison goes through
std.crypto.subtle. the token length, header parameter count, claim count and
audience list are all bounded.
Options decides which registered claims get checked. the defaults check
exp, nbf and iat with a minute of clock skew, and compare nothing the
caller has not named:
expected := jwt.with_audience(jwt.with_issuer(jwt.default_options(), "chat"), "web")
session := jwt.verify_hs256(token, secret, expected)!
the builders compose, each returning a new Options:
with_issuer,with_audience,with_subject— compareiss,sub, andaud, which may be a string or an array of themwith_leeway— how much clock skew the time comparisons toleraterequiring_exp— refuse a token that carries noexpat allat_time— validate against a fixed unix timestamp rather than the clockwithout_time_checks— turn offexp,nbfandiat, for replaying a fixed vector and not for traffic
jwt.verify_* wants the key in hand. an identity provider does not hand you
one: it publishes a json web key set at a url and rotates what is in it when
it likes. std.crypto.jwks is that side of the story.
import std.crypto.jwks as jwks
keys := jwks.cache("https://issuer.example.com/.well-known/jwks.json")
expected := jwt.with_issuer(jwt.default_options(), "https://issuer.example.com")
session := keys.verify(token, expected)!
build the cache once at startup and share it. it fetches on first use and
again when its ttl has passed (.ttl(seconds), an hour by default), so an
issuer is asked for its keys on a timer rather than on a request. the fetch
has a timeout (.timeout_ms) and a response size cap (.max_bytes), and the
lock is never held across it.
with a document you already have — vendored, or read from disk at startup — skip the cache:
set := jwks.parse(document)!
session := set.verify(token, expected)!
set.keys is the parsed list. each Key carries its kid, the algorithm it
verifies, and the material in the form the matching jwt.verify_* takes:
the pkcs#1 rsapublickey der for RS256 and PS256, the uncompressed point for
ES256, the raw 32 bytes for EdDSA. so a caller who wants to do the
verification itself can, without redoing the jwk conversion.
which algorithm a key verifies is a property of the key: an RSA key verifies
RS256 (or PS256 if it says so), a P-256 key verifies ES256, an Ed25519 key
verifies EdDSA. jwks reads that off the key material — the jwk's own alg
when it states one, kty and crv otherwise — and hands the answer to the
matching jwt.verify_*, which then compares the token's alg header against
that name and refuses the token if they differ.
so the token's header selects nothing. its kid picks which key to try, and
that is all it does. this is the same shape std.crypto.jwt already has,
extended one step out, so a rotated key set cannot introduce an algorithm
either.
a symmetric key in a key set (kty: oct) is refused outright, and the whole
parse fails rather than skipping it. a key set is a public document; a shared
secret in one hands every reader the ability to sign. a jwk that claims an
algorithm its key type cannot verify — kty: RSA with alg: ES256 — is
refused for the same reason: it is describing something that does not exist,
and the interesting question is who wrote it.
keys of a type this module does not implement, and keys marked use: enc,
are skipped rather than fatal: an issuer publishing one key you can use and
one you cannot is publishing a set you can still use.
a token naming a kid the cached set does not hold is what a rotation looks
like from here, so it is worth one refetch — but no more than one per
cooldown (.refresh_cooldown(seconds), a minute by default), or a stream of
invented kids becomes a stream of requests at the issuer. refresh()
fetches now, whatever the ttl says, for when something outside told you the
keys moved.
a token with no kid is tried against every key in the set, which is what a
single-key issuer needs; a set with exactly one key reports that key's own
refusal rather than a generic one, so an expired token still says "expired".
the key set is the root of trust for every token you accept. over plain http
anyone on the path replaces it with keys of their own and mints whatever they
like. jwks.cache allows an http url for a key set served by a sidecar on
loopback, and refuses any other scheme; everything else should be https.
examples/jwks_verify.pith runs the whole thing against an issuer in the
same process: the published set, a token it signed, and the forgeries that
get turned away.
the tests verify the rfc 7515 appendix a.1 HS256 token and the appendix a.3
ES256 token against the exact serializations the rfc prints, and
tests/cases/test_jwt_asymmetric.pith verifies an RS256 and an EdDSA token
signed with openssl. a token that only round trips against the module that made
it says nothing about talking to anyone else.
the signing half is held to the same standard. rsa pkcs#1 v1.5 and ed25519 are
deterministic, so for a fixed key and claims there is exactly one valid token —
and the tests check that sign_rs256 and sign_eddsa produce, byte for byte,
the token openssl produces. ES256 cannot be pinned like that (every signature
uses a fresh nonce, on purpose — a repeated ecdsa nonce forfeits the private
key), so it is tested by round trip and by checking two signatures over the
same input differ.
ES256 needs a small translation on the way in: jws carries the ecdsa signature
as r and s glued together at a fixed width, and the runtime's verifier reads
the asn.1 der encoding, so verify_es256 re-wraps it. signing has no
translation — the runtime signs in the fixed-width form directly.
key formats, which are the usual place to get stuck:
verify_eddsatakes the raw 32 byte ed25519 public keyverify_es256takes the uncompressed p-256 point:0x04, then x, then yverify_rs256andverify_ps256take the pkcs#1 rsapublickey der, which is whatstd.crypto.x509returns as a certificate'ssubject_public_key- every
sign_*takes a pkcs#8 private key, which is whatencoding.pem_decodegives you from aBEGIN PRIVATE KEYfile. an rsa key in the olderBEGIN RSA PRIVATE KEYform needs one pass throughopenssl pkcs8 -topk8first