add password hashing and json web tokens - #630
Merged
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.passwordhashes 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_rehashis 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.jwtreads 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 nosign_rs256to 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
algheader 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: noneis refused in the header parse with no opt-in. on top of that acritheader is rejected per rfc 7515, signatures are compared throughstd.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.pithand entries in the lowering tables. the bootstrap seed is regenerated.std/crypto.pithwas 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 testandPITH_GREEN=0 make testboth clean,make bootstrap-verifyclean after refreshing the seed, both regression corpora at 299 cases each,make run-examples,make docsite-check,make memcheckandmake leak-checkall clean.the interop tests are the ones worth naming.
std/crypto/jwt.pithverifies the rfc 7515 appendix a.1 HS256 token and the appendix a.3 ES256 token against the exact serializations the rfc prints, andtests/cases/test_jwt_asymmetric.pithverifies 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: nonetoken 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_rehashin 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
ByteBufferand walked away from the handle, about 80 bytes a call, whilefrom_hexright beside them already had thedefer 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_scopedrops 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_es256re-wraps it. the appendix a.3 vector is what proves that conversion.