carlos / contra Public

shared/1 โ€” the encodings DESIGN.md leaves open, pinned Question open

๐Ÿค– Filed from shared/golden-vectors (discussion #6). Not a relitigation of anything in AGENTS.md โ€” these are places DESIGN.md names a primitive without pinning its wire format. Vectors cannot be written without an answer, and three languages cannot agree without one, so I have chosen and written them down here rather than leaving them implicit in whichever implementation lands first.

All of these are reversible right now and expensive to reverse later. If any is wrong, say so on this thread before web/3 builds against it.

1. HKDF is RFC 5869 Extract-then-Expand, everywhere

DESIGN.md says "HKDF-Expand into two subkeys" in one place and salt=account_key in another; those imply different modes. Chosen: full Extract-then-Expand for every derivation, one mode, no exceptions. Where no salt is named the salt is zero-length (RFC 5869 ยง2.2 then substitutes HashLen zero bytes). info is the label's ASCII bytes with no NUL terminator. L = 32 unless stated.

Rejected: Expand-only with PRK = the input. It is marginally cheaper and it would mean two modes in one protocol, which is how the labels eventually get used under the wrong one.

2. P-256 from a seed: the rejection loop

DESIGN.md requires rejection sampling mod n and does not say over what. Chosen:

candidatei = HKDF-SHA-256(IKM = seed, salt = "", info = "contra/v1/ec-p256" || byte(i)), 32 bytes di = OS2IP(candidatei) // big-endian accept if 1 <= di <= n-1, else i += 1

i starts at 0 and is a single byte. Public key is SEC1 uncompressed, 0x04 || X || Y. A rejection has probability ~2^-32, so the branch is never hit by accident โ€” the vector file pins a seed found by search that does reject at i=0, exactly as #6 asks.

3. Crockford Base32 is byte-oriented and left-aligned

Crockford's own spec encodes a number. Chosen instead: RFC 4648-style MSB-first packing over the 16 raw bytes, left-aligned, the final symbol's 2 spare low bits zero โ€” 26 symbols for 128 bits.

Why: no bignum. A big-integer encoding needs one in JS, and the JS modules are capped at 300 lines by test. Byte packing is the same short loop in Go, JS and Swift.

Decoding is strict: a non-zero pad is an error, so every value has exactly one encoding. Without that, two strings decode to one key and the checksum stops meaning what it says.

U is not in the alphabet and is an error, not a repair candidate.

4. The full printed form is 32 characters

tag(2) || data(26) || checksum(4)

A1 for the Account Key card, C1 for the Recovery sheet, printed C1-XXXXX-XXXXX-XXXXX-XXXXX-XXXXX-XXXXX. Normalisation strips hyphens and whitespace, uppercases, and maps Oโ†’0, Iโ†’1, Lโ†’1 โ€” the tag survives it unchanged, so normalise first and match the tag after.

Checksum: the first 20 bits, MSB-first, of SHA-256(payload_bytes) over the 16 raw bytes โ€” not over the string โ€” as 4 Crockford symbols.

Repair on failure tries every single-character substitution over the 30 payload positions only (the tag is a literal; a mistyped tag is a different error), and returns every candidate passing the checksum, in position-then-alphabet order, for the caller to try against the KCV. It does not pick one: 20 bits collides about once in 1100 attempts and silently choosing would be worse than handing back two.

5. The AEAD AAD needs a canonical encoding

DESIGN.md binds (account_id, object_type, object_id, version) and does not say how they are laid out. Concatenation without lengths is ambiguous โ€” different tuples produce identical AAD, which is the whole thing the binding exists to prevent. Chosen:

AAD = "contra/v1/aad" || 0x00 || u32be(len(accountid)) || accountid // 16 raw UUID bytes || u32be(len(objecttype)) || objecttype // ASCII, e.g. "item" || u32be(len(objectid)) || objectid // 16 raw UUID bytes || u64be(version)

Length-prefixed so it is injective; domain-tagged so an AAD from this protocol can never be replayed into another one.

6. Go module path

github.com/carlosframework/contra, following platform and rastrillo.

How the vectors are produced, and why that matters

#6's trap is generating vectors from an implementation and then testing that implementation against them. So the generator is C against OpenSSL 3.5, in vectors/gen/, and the Go code is only ever the thing under test โ€” two independent implementations, neither derived from the other. OpenSSL's Argon2id was first checked against the published PHC reference vector (password/somesalt, t=2, m=65536, p=1 โ†’ 09316115โ€ฆ13cf7) and its HKDF against RFC 5869 test case 1, so the generator itself is anchored outside this repo before it generates anything.

Every input in the file is generated. Nothing in it is a real secret.

๐Ÿค– 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.

๐Ÿค– One more, from web/4 (#10).

10. The kit carries the account id, not the blind index

DESIGN.md says both documents carry "the account's blind-index handle". They cannot, and the reason is a decision this thread already records rather than a gap: the blind index is an HMAC under a pepper the server holds and the browser deliberately never sees. The kit is generated in the browser and must never touch the server, so the value simply is not available where the document is made.

The server could send it โ€” the blind index is not secret โ€” but that is a round trip and a new endpoint to serve something that identifies the account no better than what the browser already has.

Chosen: the account id's first eight hex characters, formatted XXXX-XXXX. It is already a random UUID that identifies nothing by itself, it is on both documents, and it answers the only question the handle exists to answer: which account is this sheet for? โ€” for somebody holding two kits from two accounts, years later.

If the blind index specifically is wanted on the paper, say so and it becomes a field on /auth/salt or /api/bootstrap. I did not add one for a cosmetic difference.

11. The printed documents are ASCII, and the writer enforces it

Not a style preference. The kit embeds no fonts โ€” it uses the base-14 fonts every reader already has, which is what keeps the PDF writer small enough to read โ€” and those have no glyph for a curly quote or an em dash in this encoding. A document that renders "don't" as "donOt" is not one to hand somebody at the worst moment of their year.

So pdf.js refuses a non-ASCII character rather than drawing the wrong one, and the kit copy uses - and '. Worth knowing before anybody edits that copy and wonders why it throws: the constraint is real, and the alternative is embedding a font subset, which is a much larger piece of code in the one place we are trying to keep readable.

๐Ÿค– One divergence from DESIGN.md, found by using the thing.

12. The kit is saved by two buttons, not one

DESIGN.md: "Signup generates both and offers a single print both action, so it stays one gesture, and does not let the user past until both are confirmed."

The single action does not work. Browsers refuse a second programmatic download from one user gesture โ€” Safari refuses it silently โ€” so the button downloaded the Account Key card, did not download the Recovery sheet, and reported success. Paul hit it on the first real run: "'Save both documents' only appears to have downloaded one file."

That is the worst possible failure for this particular screen. The recovery code is shown once and stored by nobody, so a save that silently half-works loses the only copy of the only thing that can open the vault, at the exact moment the interface says everything is fine.

Changed to one button per document, each marking itself saved once clicked. The "does not let the user past until both are confirmed" half is unchanged.

Why this is also the better shape

The single gesture existed to keep the ceremony feeling like one act. But the whole argument for two documents is that they belong in two different places โ€” the card stays reachable, the sheet gets archived โ€” and an interface that hands them over as one action quietly argues the opposite. Two buttons make the split visible at the moment it matters.

So the constraint and the design point the same way, which is lucky rather than clever: if browsers had allowed the double download I would have shipped the weaker interface.

Two smaller ones from the same run

The kit is its own screen. It was rendering into the passphrase form's status box, so the fields you had just typed sat above the recovery code you were meant to be reading. The passphrase step, a working screen, and the kit are now three screens with one visible at a time.

They cannot be three URLs, and the reason is the same reason the kit is fragile: it exists only in memory. A redirect between choosing a passphrase and seeing the kit would be a redirect that throws it away.

There is a working screen. Creating a vault is two seconds of deliberate Argon2id plus two round trips, and the only sign of life was a greyed-out button. It now names each task as it starts, driven by the crypto module's own progress messages rather than by a timer โ€” a progress list that is really a timer is a lie with a spinner on it.