feat: the ceremony circuits — two-commitment bearer link, digest-bound Google - #1
Conversation
b8e6983 to
5b1a0bd
Compare
xgreenx
left a comment
There was a problem hiding this comment.
Approving on the strength of an independent verification run under the pinned toolchain (nargo 1.0.0-beta.20, bb 5.0.0-nightly.20260324): all 24 tests pass, both vk_hashes reproduce byte-for-byte (02bbc194…818e, 24db903f…a69f), all three gate counts reproduce exactly, every embedded vector re-derives independently in Python (including the spec §3.1 digest→nonce pair), and adversarial probes against the commitment scheme (preimage re-splitting across the bearer/blinder boundary, length edges at 128/129) all fail the way they must. bearer-link's two-input shape is not just acceptable but mandated — REQ-COMMON-02B forbids a digest input on PKCE profiles.
Two findings worth a follow-up commit — both pre-existing on main (this PR doesn't introduce them), both defense-in-depth misses rather than live attacks, since exploiting either needs Google to emit non-canonical/unescaped JSON:
-
Missing trailing-structural-byte check on the five string claims —
circuits/jwt_email/src/main.nr:128(email),:148(nonce),:167(sub),:217(iss),:232(aud). REQ-COMMON-19 (invoked for these claims by REQ-PLAT-21) requires asserting the closing quote and the following structural byte (,or}); today onlyemail_verifiedandexpdo. Without it, a claim-shaped substring inside another signed string value would match — JSON escaping of attacker-typed content is what currently prevents that. -
expaccepts leading zeros —circuits/jwt_email/src/main.nr:189-203. REQ-COMMON-19D demands canonical0|[1-9][0-9]*and explicitly lists leading-zero among the rejects; the circuit accepts any digit run. Non-exploitable with signature-fixed bytes from an honest issuer, but it's a written MUST.
PS: the PR body's 178,899 gate figure for jwt_email is the pre-noir_base64 number; the branch as it stands measures 179,367 (matching the third commit's message).
Two spec-compliance gaps found in review of #1, both pre-existing on main. REQ-COMMON-19 requires a string claim's match to cover the closing quote AND the following structural byte. Only email_verified and exp asserted their trailing byte; email, nonce, sub, iss and aud stopped at the closing quote, so a claim-shaped substring inside a longer signed string value would have matched -- JSON escaping of attacker-typed content was the only thing in the way. Every claim now ends at `,` or `}` via one shared assert_structural(), and the section-9 bounds grow by one byte so the trailing byte itself must lie inside the authenticated payload length (REQ-COMMON-19B), not in prover-controlled zero padding. REQ-COMMON-19D pins integers to canonical 0|[1-9][0-9]* and lists leading zeros among the rejects; the exp digit-run check accepted them, so a leading-zero rendering was a second signed encoding of the same u64. A multi-digit exp may no longer start with '0'; the lone "0" stays legal. Negative tests cover both: a claim-shaped substring whose apparent closing quote is followed by ordinary string content now fails, as does a leading-zero exp; companion positive tests pin the accepted shapes. The added constraints move jwt_email from 179,367 to 179,413 gates (+46, 0.03 %) and change its vk_hash from 0x24db903f...88a5a69f to 0x1596b429...2af7a27c; the on-chain verifier regenerates with the next release. bearer-link and dyaka-noir-token artifacts are byte-identical. Assisted-by: Claude Fable 5 Signed-off-by: xgreenx <xgreenx9999@gmail.com>
effeb69 to
5b1a0bd
Compare
Two spec-compliance gaps found in review of #1, both pre-existing on main. REQ-COMMON-19 requires a string claim's match to cover the closing quote AND the following structural byte. Only email_verified and exp asserted their trailing byte; email, nonce, sub, iss and aud stopped at the closing quote, so a claim-shaped substring inside a longer signed string value would have matched -- JSON escaping of attacker-typed content was the only thing in the way. Every claim now ends at `,` or `}` via one shared assert_structural(), and the section-9 bounds grow by one byte so the trailing byte itself must lie inside the authenticated payload length (REQ-COMMON-19B), not in prover-controlled zero padding. REQ-COMMON-19D pins integers to canonical 0|[1-9][0-9]* and lists leading zeros among the rejects; the exp digit-run check accepted them, so a leading-zero rendering was a second signed encoding of the same u64. A multi-digit exp may no longer start with '0'; the lone "0" stays legal. Negative tests cover both: a claim-shaped substring whose apparent closing quote is followed by ordinary string content now fails, as does a leading-zero exp; companion positive tests pin the accepted shapes. The added constraints move jwt_email from 179,367 to 179,413 gates (+46, 0.03 %) and change its vk_hash from 0x24db903f...88a5a69f to 0x1596b429...2af7a27c; the on-chain verifier regenerates with the next release. bearer-link and dyaka-noir-token artifacts are byte-identical. Assisted-by: Claude Fable 5 Signed-off-by: xgreenx <xgreenx9999@gmail.com>
Two spec-compliance gaps found in review of #1, both pre-existing on main. REQ-COMMON-19 requires a string claim's match to cover the closing quote AND the following structural byte. Only email_verified and exp asserted their trailing byte; email, nonce, sub, iss and aud stopped at the closing quote, so a claim-shaped substring inside a longer signed string value would have matched -- JSON escaping of attacker-typed content was the only thing in the way. Every claim now ends at `,` or `}` via one shared assert_structural(), and the section-9 bounds grow by one byte so the trailing byte itself must lie inside the authenticated payload length (REQ-COMMON-19B), not in prover-controlled zero padding. REQ-COMMON-19D pins integers to canonical 0|[1-9][0-9]* and lists leading zeros among the rejects; the exp digit-run check accepted them, so a leading-zero rendering was a second signed encoding of the same u64. A multi-digit exp may no longer start with '0'; the lone "0" stays legal. Negative tests cover both: a claim-shaped substring whose apparent closing quote is followed by ordinary string content now fails, as does a leading-zero exp; companion positive tests pin the accepted shapes. The added constraints move jwt_email from 179,367 to 179,413 gates (+46, 0.03 %) and change its vk_hash from 0x24db903f...88a5a69f to 0x1596b429...2af7a27c; the on-chain verifier regenerates with the next release. bearer-link and dyaka-noir-token artifacts are byte-identical. Assisted-by: Claude Fable 5 Signed-off-by: xgreenx <xgreenx9999@gmail.com>
The X and GitHub ceremonies each notarize two TLS sessions, and each session commits the same OAuth bearer behind its own blinder. The two commitment values therefore differ, and nothing on the Consumer Chain can tell they open to one credential. This circuit proves exactly that and nothing else. Both platforms state the identical relation, so one circuit serves both. The Verifier Governance Process registers it once per profile, which is the exact artifact per profile REQ-PLAT-01A asks for -- not a different one. Two public inputs, the token and identity commitments, per REQ-PLAT-32B and REQ-PLAT-52A. No authorization digest, client identifier, timestamp, endpoint, userId or handle: the Platform Verifier reads each of those from revealed attestation bytes or binds it by recomputing the PKCE verifier, and a fact that can be checked in the open does not belong in a proof. The bearer is constrained to nonempty printable ASCII, which excludes CR and LF as REQ-COMMON-37 requires, with a zero-padded tail. The cap stays at 128 bytes rather than the 4096 REQ-PLAT-30 permits: 128 is what live X ceremonies produce and GitHub tokens are 40 bytes, while 4096 measures 690,362 gates against 42,008 here. The bound is one constant and a verifier regeneration. 42,008 gates, against 76,618 for the circuit it replaces. Dropping the keccak nullifier pays for the charset constraint several times over; replay is now the Authorization Digest the Consumer records under REQ-COMMON-03A. Assisted-by: Claude Opus 5 Signed-off-by: SupremaLex <georglutsenko@gmail.com>
The Google circuit carried a 62-byte free-text nonce and two pass-through public inputs naming a chain and a registry. The specification replaces all three with one value: the Authorization Digest of common section 5, which already commits the operation domain, the chain, the verifier version and the transaction data. A proof is scoped by that digest, so nothing else needs to say where it may be spent. Public inputs are now exactly the six REQ-PLAT-16B fixes, in the order it lists them: the digest, SHA-256 of the signed aud, sub, the raw email bytes, exp, and the RSA modulus. The circuit encodes rather than decodes. REQ-PLAT-10 has the runtime place BASE64URL_NOPAD(digest) in the OIDC nonce, so the circuit re-encodes the digest it received and compares it against the signed payload byte for byte. Both directions bind the same pair; encoding is the cheaper one, and the fixed 43-character length leaves no room to witness a longer nonce and hide bytes inside it. Verified against the pair the specification publishes: digest b318fb55...4c0af5 encodes to sxj7VZ4WoXm4U-0oU1ds2hYDLZOwg5u4GlUTXTNMCvU, taken from platform-ceremonies.md section 3.1 rather than from this circuit. 178,899 gates, against 179,189 before. BREAKING CHANGE: the verifying key changes, so the Solidity verifier and every caller building public inputs must be regenerated together with it. Assisted-by: Claude Opus 5 Signed-off-by: SupremaLex <georglutsenko@gmail.com>
The circuit already depends on noir_base64 to bind payload_json to the signing input, and BASE64_URL_ENCODER is the URL-safe unpadded variant whose length formula gives exactly 43 characters for a 32-byte input. Writing that encoder out by hand was unnecessary: it added twenty-five lines of bit manipulation and a 64-byte alphabet table this repository would then own and have to keep correct. The library costs 468 gates more, 179,367 against 178,899. That is 0.26 % of the circuit, and worth paying to not maintain a base64 implementation. The tests now check the library against an oracle outside the Noir ecosystem: three digests encoded with Python, alongside the pair the specification publishes. The all-ones digest is the sharp case, ending in `8` rather than `_`, because a two-byte tail forces the final character's low two bits to zero -- an encoder emitting a fourth tail character or a padding byte fails there. Verified by swapping in the standard alphabet, which turned two tests red, and passing again on revert. Assisted-by: Claude Opus 5 Signed-off-by: SupremaLex <georglutsenko@gmail.com>
27f7f84 to
9e33682
Compare
`jwt_email` named the evidence's shape, not what the circuit is for, and it was the last directory left on the old convention. `x-token` established the one main uses: the directory is hyphenated, the Nargo package underscored. This follows it, so the three circuits read as one set: circuits/bearer-link bearer_link circuits/oidc-google oidc_google circuits/x-token x_token The release workflow takes an asset name from the directory basename, so the published tarball becomes `libid-circuits-<version>-oidc-google.tar.gz`. Anything pinning the old asset name has to move with it, exactly as the `x-token` rename required. Two stale notes fixed while here, both wrong before the rename rather than because of it: the README table had no `bearer-link` row at all and still described `oidc-google` by the public inputs it carried before this branch changed them, and CI claimed the Google circuit "has no tests and passes vacuously" when it has three. No circuit source changes. All three compile, `nargo fmt --check` is clean, and the tests pass: bearer_link 12, x_token 9, oidc_google 3. Signed-off-by: SupremaLex <georglutsenko@gmail.com>
) * fix: restore the REQ-COMMON-19 / REQ-COMMON-19D constraints These were written by xgreenx in review of #1, merged into that branch as #4, and then lost: I rebased the branch onto main and force-pushed, so the merge commit carrying them was discarded and #1 landed without them. Restoring the patch verbatim onto the renamed path. REQ-COMMON-19 requires a string claim's match to cover the closing quote AND the following structural byte. Only `email_verified` and `exp` asserted their trailing byte; `email`, `nonce`, `sub`, `iss` and `aud` stopped at the closing quote, so a claim-shaped substring inside a longer signed string value would have matched -- JSON escaping of attacker-typed content was the only thing in the way. Every claim now ends at `,` or `}` through one shared `assert_structural()`, and the section-9 bounds grow by one byte so the trailing byte itself must lie inside the authenticated payload length (REQ-COMMON-19B) rather than in prover-controlled zero padding. REQ-COMMON-19D pins integers to canonical `0|[1-9][0-9]*` and lists leading zeros among the rejects; the `exp` digit-run check accepted them, so a leading-zero rendering was a second signed encoding of the same u64. A multi-digit `exp` may no longer start with `0`; the lone `0` stays legal. The rebuilt key is `0x1596b4295a9471df3d620bb3a94c970dd695a0617117ed4d0c9190ad2af7a27c`, which is the value xgreenx's original commit predicted -- an independent check that the restore is byte-exact. Tests go 3 to 7; `bearer-link` is unchanged. Still missing from main, and not in this commit: the bb 5.2.0 / nargo 1.0.0-beta.25 toolchain bump, lost the same way. It moves every key again and needs that toolchain installed to verify, so it goes on its own. Co-authored-by: xgreenx <xgreenx9999@gmail.com> Signed-off-by: SupremaLex <georglutsenko@gmail.com> * chore: restore the bb 5.2.0 / nargo 1.0.0-beta.25 toolchain bump Wondertan's, lost in the same force-push as the constraints in the commit before this one. Restoring it here rather than after the release, because it moves every verification key and releasing without it means re-cutting. Rebuilt and verified under the restored pins, with x-token already gone: bearer-link 0x1d161afb536683d31a3e426db0feaa30de8be89cc45510579f361266c20e078f oidc-google 0x1b50bbf6d8ea6efc7ecc2547c25b285511704a10d9247466e84045095d9c3f77 Both keys move: the toolchain is what derives them, which is the whole reason the pins are a single source of truth. Downstream verifiers regenerate from these, so libid-contracts rolls its committed Solidity together with the tag that carries them. bearer_link 12 tests, oidc_google 7, `nargo fmt --check` clean on both under beta.25. Co-authored-by: Wondertan <hlibwondertan@gmail.com> Signed-off-by: SupremaLex <georglutsenko@gmail.com> --------- Signed-off-by: SupremaLex <georglutsenko@gmail.com> Co-authored-by: xgreenx <xgreenx9999@gmail.com> Co-authored-by: Wondertan <hlibwondertan@gmail.com>
Brings the circuits in line with the merged ceremony specification. The
requirements behind every change landed in #10 and #11, so nothing here rests
on an open or closed spec PR.
bearer-link— new, and it serves both X and GitHubREQ-PLAT-32B and REQ-PLAT-52A give X and GitHub byte-identical statements, so
one circuit serves both and the Verifier Governance Process registers it once
per profile. That is the exact artifact per profile REQ-PLAT-01A asks for, not
a different one.
It proves exactly one thing: one hidden bearer opens both attestations'
blinded commitments. Two public inputs, nothing else. No authorization
digest, client identifier, timestamp, endpoint,
userIdor handle — thePlatform Verifier reads each of those from revealed attestation bytes or binds
it by recomputing the PKCE verifier, and a fact checkable in the open does not
belong in a proof.
Why a circuit is needed at all: the two sessions draw independent blinders
(REQ-COMMON-44), so the same bearer commits to two different values and nothing
on chain can tell they open to one credential.
bearer-linkIt is cheaper than what it replaces. Dropping the keccak nullifier more than
pays for the new printable-ASCII charset constraint; replay is now the
Authorization Digest the Consumer records under REQ-COMMON-03A.
The bearer bound is 128, not the 4096 REQ-PLAT-30 permits
Measured before choosing:
MAX_BEARER_LEN4096 pads to 2^20 and uses two thirds of it; browser bb.js runs several times
slower than native, so it is tens of seconds on desktop and a real risk on
mobile. 128 is what the deployed X circuit has always enforced and what live X
ceremonies produce; GitHub
gho_tokens are 40 bytes. The bound is oneconstant and a verifier regeneration, and 256 would cost 62,922 gates — still
below today's baseline — if more headroom is wanted later.
The charset constraint itself is nearly free: about one gate per byte.
jwt_email— Google bound to the Authorization DigestPublic inputs become exactly the six of REQ-PLAT-16B, in its order. The
62-byte free-text
noncebecomes the 32-byte digest; thechain_idandregistry_addrpass-throughs go, because the digest already commits the chain,the operation and the verifier version.
The circuit encodes rather than decodes: it re-encodes the digest to
base64url and compares against the signed payload. Both bind the same pair,
encoding is the cheaper direction, and the fixed 43-character length leaves no
room to witness a longer nonce and hide bytes in it.
It reproduces the pair the specification publishes:
178,899 gates, against 179,189 before.
The nonce encoder is
noir_base64, not hand-rolledThe library was already a dependency here, and
BASE64_URL_ENCODERis theURL-safe unpadded variant whose length formula gives exactly 43 for 32 bytes.
It costs 468 gates more than the hand-written version — 0.26 % — and is worth
it to not own a base64 implementation.
Its tests check it against Python, an oracle outside the Noir ecosystem,
not only against the spec vector. The all-ones digest is the sharp case: it
ends in
8, not_, because a two-byte tail forces the final character's lowtwo bits to zero. An encoder emitting a fourth tail character or padding fails
there. Verified by swapping in the standard alphabet, which turns two tests
red.
Contract sizes are unaffected — worth knowing
A 16× smaller circuit with 1/68th the public inputs produces a verifier one
byte larger:
BearerLinkHonkVerifier(42k gates, 2 inputs)HonkVerifier(Google, reworked)XHonkVerifier(old, 76k gates, 5 inputs)The Honk verifier is fixed-size; only
NandLOG_Nchange. So the size workand the circuit work are independent, and waiting for one before the other buys
nothing. Public inputs do drop 136 → 64, which cuts calldata and verification
gas.
Notes
dyaka-noir-tokenis left in place and still building.bearer-linkreplaces it, but the live X demo still uses it; it should go at cutover.
before.
jwt_email's vk_hash changes, so its Solidity verifier regenerates with it.Verification
nargo fmt --check,nargo testandscripts/build.shall pass for everycircuit: 12 tests for
bearer-link, 3 forjwt_email, 9 unchanged fordyaka-noir-token.