Skip to content

add password hashing and json web tokens - #630

Merged
kacy merged 7 commits into
mainfrom
auth-password-jwt
Jul 31, 2026
Merged

add password hashing and json web tokens#630
kacy merged 7 commits into
mainfrom
auth-password-jwt

Conversation

@kacy

@kacy kacy commented Jul 31, 2026

Copy link
Copy Markdown
Owner

summary

pith had the aead, hmac, ed25519 and constant-time pieces but nothing for storing a password or checking a token, so anything with a login was going to pull both from outside. this adds the two modules and the wiring they needed.

std.crypto.password hashes with argon2id and writes phc strings, so the cost parameters travel with the hash. the defaults are owasp's (19 mib, two passes, one lane), which measure 30 ms per hash from pith on this box. needs_rehash is the piece that makes the parameters movable: it tells a server which stored hashes are behind, and a hash it cannot parse counts as behind too, which quietly migrates anything older.

std.crypto.jwt reads and writes the jws compact serialization. hmac and rsa-pss are the only signers the runtime has, so pith can issue HS256, HS384, HS512 and PS256 and can verify those plus EdDSA, ES256 and RS256 from an outside issuer. that asymmetry is in the function names rather than in a runtime error — there is no sign_rs256 to find out about later.

the two failure modes jwt libraries are known for are closed structurally. every verifier names the algorithm it accepts and only compares the token's alg header against that name, so nothing dispatches on the header and the re-sign-as-HS256-with-the-public-key attack has nowhere to land. alg: none is refused in the header parse with no opt-in. on top of that a crit header is rejected per rfc 7515, signatures are compared through std.crypto.subtle, and the token length, header parameter count, claim count and audience list are all bounded.

the runtime already had blake2b and argon2id, but a call to a builtin the checker does not know about stops at E201, so both needed typing in checker_builtins.pith and entries in the lowering tables. the bootstrap seed is regenerated.

std/crypto.pith was three lines and a test that asserted true. pith has no re-export, so it cannot hand anyone the submodules, but it is still where you land when you go looking for a mac or a password hash. it is now an index of the whole surface grouped by what you are trying to do, with a test that reads the directory so a new submodule cannot ship unlisted.

what was tested

make test and PITH_GREEN=0 make test both clean, make bootstrap-verify clean after refreshing the seed, both regression corpora at 299 cases each, make run-examples, make docsite-check, make memcheck and make leak-check all clean.

the interop tests are the ones worth naming. std/crypto/jwt.pith verifies 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 security cases cover a tampered signature, a swapped payload, an alg: none token spelled three ways, an algorithm confusion attempt against both rsa verifiers, expired and not-yet-valid tokens, a wrong audience and issuer, malformed base64url, malformed json, and a payload that is valid json but not an object. the password cases cover the round trip, a wrong password, a tampered phc string, the parameters surviving the encoding, needs_rehash in both directions, and eight shapes of malformed input.

notes

profiling the verify path turned up a leak that predates this: base64 decode, base32 decode, base58 decode and varint encode each built a ByteBuffer and walked away from the handle, about 80 bytes a call, while from_hex right beside them already had the defer buf.free() they were missing. it did not matter much when base64 was a setup step and matters per request when verifying a token decodes three fields, so it is fixed here in its own commit. 100k decodes went from growing the peak rss by 8 mb to flat.

one leak is left and is not this branch's: json.open_scope() / parse / close_scope drops around 95 bytes per cycle, which every std.json caller already pays, including the http servers that open a scope per request. verifying a token parses twice, so it shows up here more than most. worth its own look.

ES256 needed a small translation: jws carries the ecdsa signature as r and s at a fixed width and the runtime's verifier reads asn.1 der, so verify_es256 re-wraps it. the appendix a.3 vector is what proves that conversion.

kacy added 7 commits July 31, 2026 06:58
the runtime already has both and runtime_functions.txt already declares them,
but a call to a function the checker does not know about stops at E201. this
types them the way every other crypto builtin is typed and adds them to the
lowering tables: both hand back a bytes handle, or zero when a parameter is out
of range, which is exactly the fallible-bytes shape aead and rsa signing use.

the bootstrap seed is regenerated because ci builds ir_driver from the tracked
ir before it compiles any pith.
argon2id password hashing that writes and reads phc strings, so the cost
parameters travel with the hash and a stored hash stays verifiable after you
raise them. the defaults are owasp's argon2id parameters — 19 mib, two passes,
one lane — which measure around 45 ms per hash here. rfc 9106's second option
costs 700 ms single-threaded, and a login endpoint that spends that per attempt
is its own denial of service.

verify recomputes the tag and compares it with std.crypto.subtle, and treats a
hash it cannot parse as a failed verification rather than an error, so nobody
can mistake "unparseable" for "correct". needs_rehash is the other half: it
tells a server which stored hashes were made with weaker parameters than it
wants now, which is what lets the parameters move at all.
json web tokens over the jws compact serialization. hmac and rsa-pss are the
only signers the runtime has, so pith can issue HS256, HS384, HS512 and PS256
and can verify those plus EdDSA, ES256 and RS256 minted elsewhere. that
asymmetry lives in the function names — there is no sign_rs256 to find out
about at runtime.

every verifier names the algorithm it accepts and only ever compares the
token's alg header against that name. nothing dispatches on the header, which
is what algorithm confusion needs, and alg none is refused outright with no
opt-in. signatures are compared with std.crypto.subtle, a crit header is
rejected per rfc 7515, and the token length, header parameter count, claim
count and audience list are all bounded.

the interop tests verify the rfc 7515 appendix a.1 HS256 and appendix a.3
ES256 vectors, and the asymmetric regression case verifies an RS256 and an
EdDSA token signed with openssl. a token that only round trips against this
module would say nothing about interoperating with an outside issuer.
the root module was three lines listing some of the submodules and a test that
asserted true. pith has no re-export, so this file cannot hand anyone the
submodules, but it is still the first thing you open when you go looking for a
mac or a password hash, and it should answer that.

it now describes the whole surface grouped by what you are trying to do, says
what is deliberately absent, and points at std.hash and std.encoding for the
things people look for here and do not find. the test reads the directory and
compares it against the index, so a new submodule cannot ship unlisted.
docs/auth.md covers both modules in one place: what the argon2id defaults cost
and why they sit where they do, how needs_rehash lets the parameters move, and
the sign/verify table that makes the asymmetry obvious before anyone reaches
for a sign_rs256 that does not exist. it also spells out how algorithm
confusion and alg none are prevented, and the key formats each verifier wants,
which is where people usually get stuck.

examples/auth.pith runs the flow: register, log in, notice the stored hash is
behind, issue a session token, verify it, and watch four kinds of bad token get
turned away.

the timings in the module comment are re-measured from pith rather than from
the rust side: 30 ms at the defaults, 230 ms for rfc 9106's 64 mib option,
which the runtime derives single-threaded.
base64 decode, base32 decode, base58 decode and varint encode each build a
ByteBuffer, copy it out with bytes(), and walk away from the handle. from_hex
right next to them already had the `defer buf.free()` these were missing.

it leaked around 80 bytes per call, which nobody noticed while base64 was a
setup step. it is a per-request cost now: verifying a jwt decodes three
base64url fields, so a service checking tokens on every call was paying for it
on every call. a loop of 100k decodes was growing the peak resident set by 8 mb
and is now flat.
the tampered-hash test mutated the last character of the phc hash field.
a 16-byte hash encodes to 22 base64 characters, and the last one holds two
meaningful bits plus four spare ones, so 'a' and 'b' decode identically and
the tampered string still verified. it failed about one run in four.

tamper the first character of the hash and of the salt instead, where every
bit counts.
@kacy
kacy merged commit b4ab9d3 into main Jul 31, 2026
2 checks passed
@kacy
kacy deleted the auth-password-jwt branch July 31, 2026 10:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant