Skip to content

feat: the ceremony circuits — two-commitment bearer link, digest-bound Google - #1

Merged
SupremaLex merged 4 commits into
mainfrom
feat/ceremony-circuits
Aug 24, 2026
Merged

SupremaLex merged 4 commits into
mainfrom
feat/ceremony-circuits

Conversation

@SupremaLex

Copy link
Copy Markdown
Member

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 GitHub

REQ-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, 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 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.

gates
the X circuit this replaces 76,618
bearer-link 42,008

It 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_LEN gates native prove
128 42,008 0.9 s
512 104,752
2048 355,728
4096 690,362 11.0 s

4096 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 one
constant 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 Digest

Public inputs become exactly the six of REQ-PLAT-16B, in its order. The
62-byte free-text nonce becomes the 32-byte digest; the chain_id and
registry_addr pass-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:

b318fb55…4c0af5  →  sxj7VZ4WoXm4U-0oU1ds2hYDLZOwg5u4GlUTXTNMCvU   (§3.1)

178,899 gates, against 179,189 before.

The nonce encoder is noir_base64, not hand-rolled

The library was already a dependency here, and BASE64_URL_ENCODER is the
URL-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 low
two 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
:

verifier runtime bytes vs EIP-170
BearerLinkHonkVerifier (42k gates, 2 inputs) 27,699 −3,123
HonkVerifier (Google, reworked) 27,697 −3,121
XHonkVerifier (old, 76k gates, 5 inputs) 27,698 −3,122

The Honk verifier is fixed-size; only N and LOG_N change. So the size work
and 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-token is left in place and still building. bearer-link
    replaces it, but the live X demo still uses it; it should go at cutover.
  • Gate counts were recorded for the first time doing this — no repo carried one
    before.
  • jwt_email's vk_hash changes, so its Solidity verifier regenerates with it.

Verification

nargo fmt --check, nargo test and scripts/build.sh all pass for every
circuit: 12 tests for bearer-link, 3 for jwt_email, 9 unchanged for
dyaka-noir-token.

bearer-link  vk_hash 02bbc194f5160b0918f408d0f67445b8882e86d76e58b3465637cb6bcb26818e
jwt_email    vk_hash 24db903f725957f760b865b2c6f010da37d7c9397b800c21113fbd0388a5a69f

@SupremaLex
SupremaLex force-pushed the feat/ceremony-circuits branch from b8e6983 to 5b1a0bd Compare August 21, 2026 06:13
@SupremaLex
SupremaLex requested a review from xgreenx August 21, 2026 13:40

@xgreenx xgreenx left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. Missing trailing-structural-byte check on the five string claimscircuits/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 only email_verified and exp do. Without it, a claim-shaped substring inside another signed string value would match — JSON escaping of attacker-typed content is what currently prevents that.

  2. exp accepts leading zeroscircuits/jwt_email/src/main.nr:189-203. REQ-COMMON-19D demands canonical 0|[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).

xgreenx added a commit that referenced this pull request Aug 21, 2026
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>
@xgreenx
xgreenx force-pushed the feat/ceremony-circuits branch from effeb69 to 5b1a0bd Compare August 21, 2026 23:50
Wondertan pushed a commit that referenced this pull request Aug 22, 2026
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>
xgreenx added a commit that referenced this pull request Aug 24, 2026
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>
@SupremaLex
SupremaLex force-pushed the feat/ceremony-circuits branch from 27f7f84 to 9e33682 Compare August 24, 2026 09:18
`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>
@SupremaLex
SupremaLex merged commit 50d20f5 into main Aug 24, 2026
3 checks passed
SupremaLex added a commit that referenced this pull request Aug 24, 2026
)

* 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>
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.

2 participants