๐ค Two more wire formats, pinned while building web/3 (#9). Same terms as the rest of this thread: cheap to reverse now, expensive once a second client exists.
7. What a client signs to prove possession
DESIGN.md says sign-in is a signed challenge and does not say over what. Signing the raw challenge bytes would leave the signature replayable into any other ceremony that ever uses ECDSA over the same key. Chosen:
signed = "contra/v1/login-challenge" || 0x00 || branch || 0x00 || challenge
ECDSA-SHA-256 over that, r || s raw (64 bytes), not the ASN.1 wrapper โ WebCrypto emits raw, and asking the browser to re-encode would be inventing work for the one client that has the least room for it.
The branch is bound in, not merely checked alongside. A challenge issued for the recovery credential must not be spendable on the passphrase one, and a check beside the signature is one refactor away from being dropped while the signature still verifies.
The challenge itself is 32 random bytes, single-use, and spent whether or not the signature was good โ a challenge that survives a failed attempt is a challenge an attacker can grind against.
8. The client mints the account id and every wrap id
Not a preference. It is forced, and it was forced by a bug.
The client seals each wrap under an AAD binding (account_id, object_type, wrap_id, version) โ before the server has seen any of it. When the server minted those ids itself, every wrap it stored was bound to identifiers the client had never seen: a row that decrypts for nobody, ever, discovered only when somebody eventually tries to unlock.
So both ids come from the client, like every other id in this protocol. Collisions are refused by the primary key, and a client choosing an existing account's id gets a conflict rather than an overwrite.
This also retires a placeholder: signup had been sealing against sixteen zero bytes because the account id did not exist yet. That was a weaker binding recorded as a known gap, and it is now closed rather than deferred.
How it was found is the part worth keeping. The Go tests did not catch it, because they built the request struct by hand and so agreed with themselves. It surfaced the first time the real browser module talked to the real deployment. Neither implementation was wrong on its own; they disagreed about who owned an identifier, and only running one against the other could say so. That is an argument for driving the real client against the canary before calling any item done โ a golden-vector suite cannot see this class of bug, because it is not a derivation, it is a contract.
9. Argon2id in the browser is written, not vendored
m=64MiB, t=3, p=1 is the one derivation WebCrypto cannot do. The choice was vendoring a .wasm or writing Argon2id out. Chosen: written, ~290 lines against RFC 9106, because a third-party binary blob in a password manager is unreadable in review and "no build step" is a stated constraint that vendoring quietly breaks.
Verified against OpenSSL 3.5 on nine parameter sets and against the published PHC reference vector. 64 MiB at t=3 takes ~1.7s in node and ~2.0s in the signup ceremony on the canary.
Rejected: p > 1, permanently. Argon2id's output depends on p, WASM without SharedArrayBuffer cannot parallelise, and any other value would break byte-identity with Go and Swift. Both the client floor and the Argon2id entry point refuse it.