From 5c3c9a77aab1f006cceaa6f6d935d6a8aa21e8d8 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Thu, 20 Aug 2026 13:59:50 +0300 Subject: [PATCH 1/4] feat: link two attestations with one hidden bearer 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 --- circuits/bearer-link/Nargo.toml | 8 + circuits/bearer-link/src/main.nr | 317 +++++++++++++++++++++++++++++++ 2 files changed, 325 insertions(+) create mode 100644 circuits/bearer-link/Nargo.toml create mode 100644 circuits/bearer-link/src/main.nr diff --git a/circuits/bearer-link/Nargo.toml b/circuits/bearer-link/Nargo.toml new file mode 100644 index 0000000..04d8cb0 --- /dev/null +++ b/circuits/bearer-link/Nargo.toml @@ -0,0 +1,8 @@ +[package] +name = "bearer_link" +type = "bin" +authors = ["libID"] +compiler_version = ">=1.0.0" + +[dependencies] +sha256 = { tag = "v0.3.0", git = "https://github.com/noir-lang/sha256" } diff --git a/circuits/bearer-link/src/main.nr b/circuits/bearer-link/src/main.nr new file mode 100644 index 0000000..c4076b6 --- /dev/null +++ b/circuits/bearer-link/src/main.nr @@ -0,0 +1,317 @@ +// libID bearer-link circuit -- X and GitHub. +// +// The ceremony notarizes two TLS sessions: a token session (X +// `/2/oauth2/token`, GitHub's token exchange) and an identity session (X +// `/2/users/me`, GitHub `/user`). Each session commits the same OAuth bearer +// behind its OWN blinder, so the two commitment values differ and nothing on +// chain can tell they open to one credential. +// +// This circuit proves exactly that and nothing else: one hidden bearer opens +// both commitments (REQ-PLAT-32 for X, REQ-PLAT-52 for GitHub). +// +// Everything else the ceremony needs is checked where it can be seen. The +// Platform Verifier binds the Authorization Digest by recomputing the PKCE +// verifier, and reads the client identifier, evidence time, method, path and +// identity fields out of revealed attestation bytes. A fact that can be +// checked in the open does not belong in a proof, so the circuit carries no +// copy of any of them (REQ-PLAT-32B, REQ-PLAT-52A). +// +// X and GitHub state the same relation, so one circuit serves both. The +// Verifier Governance Process registers it separately per profile, which is +// what REQ-PLAT-01A asks for -- an exact artifact per profile, not a +// different one. + +use sha256::sha256_var; + +/// Blinder width of a tlsn hash commitment. The notary secret-shares this +/// value and appends it to the committed range before SHA-256. +global BLINDER_LEN: u32 = 16; + +/// Bearer hard cap. +/// +/// REQ-PLAT-30 and REQ-PLAT-36 permit up to 4096 bytes. This circuit pins +/// 128, which is what the deployed X circuit has always enforced and what +/// live X ceremonies produce; GitHub `gho_` tokens are 40 bytes. The bound is +/// the circuit's whole cost driver -- measured 42,008 gates here against +/// 690,362 at 4096 -- so it is set to the smallest value the platforms are +/// known to fit rather than to the specification ceiling. Raising it is one +/// constant plus a verifier regeneration. +global MAX_BEARER_LEN: u32 = 128; + +/// `MAX_BEARER_LEN + BLINDER_LEN`, spelled out because Noir array types +/// cannot express the sum. +global COMMIT_INPUT_LEN: u32 = 144; + +/// Verify a tlsn hash commitment: assert +/// `SHA256(plaintext[0..plaintext_len] || blinder) == expected`. +/// +/// Generic over `MAX_PLAIN` and `TOTAL` (the caller's +/// `MAX_PLAIN + BLINDER_LEN`). The first assertion catches a wrong `TOTAL` +/// when the witness is solved rather than silently hashing padding. +fn verify_hash_commit( + plaintext_padded: [u8; MAX_PLAIN], + plaintext_len: u32, + blinder: [u8; BLINDER_LEN], + expected: [u8; 32], +) { + assert(TOTAL == MAX_PLAIN + BLINDER_LEN, "TOTAL must equal MAX_PLAIN + BLINDER_LEN"); + + let mut input: [u8; TOTAL] = [0; TOTAL]; + for i in 0..MAX_PLAIN { + if i < plaintext_len { + input[i] = plaintext_padded[i]; + } + } + for i in 0..BLINDER_LEN { + input[plaintext_len + i] = blinder[i]; + } + let computed = sha256_var(input, plaintext_len + BLINDER_LEN); + assert(computed == expected, "hash commit mismatch"); +} + +/// Constrain the opened bearer range: nonempty, printable ASCII, zero-padded +/// tail (REQ-PLAT-30, REQ-PLAT-36, REQ-COMMON-20). +/// +/// The permitted set `0x20`-`0x7e` excludes carriage return and line feed, so +/// REQ-COMMON-37 holds by construction -- the identity session sends this range +/// inside an HTTP header, where a CRLF would carry a second header with it. +/// +/// The specification says nonempty and no more. The deployed circuit also +/// demanded 22 bytes, which is a bound no requirement states; a stricter +/// circuit rejects evidence the chain would accept, so it is not carried over. +fn constrain_bearer(bearer: [u8; MAX_BEARER_LEN], bearer_len: u32) { + assert(bearer_len != 0, "bearer must not be empty"); + assert(bearer_len <= MAX_BEARER_LEN, "bearer too long"); + + for i in 0..MAX_BEARER_LEN { + let b = bearer[i]; + if i < bearer_len { + assert(b >= 0x20, "bearer byte below printable ASCII"); + assert(b <= 0x7e, "bearer byte above printable ASCII"); + } else { + assert(b == 0, "bearer tail must be zero-padded"); + } + } +} + +fn main( + // --- Private --- + bearer: [u8; MAX_BEARER_LEN], + bearer_len: u32, + blinder_token: [u8; BLINDER_LEN], + blinder_identity: [u8; BLINDER_LEN], + // --- Public --- + // Matched by the Platform Verifier against the verified token (X) or + // token-exchange (GitHub) attestation. + token_commitment: pub [u8; 32], + // Matched by the Platform Verifier against the verified `/2/users/me` (X) + // or `/user` (GitHub) attestation. + identity_commitment: pub [u8; 32], +) { + constrain_bearer(bearer, bearer_len); + + // One bearer, two independent blinders, two commitments. REQ-COMMON-44 + // draws the blinders per session, so these two values differ even though + // the credential is the same -- which is the reason this circuit exists. + verify_hash_commit::( + bearer, + bearer_len, + blinder_token, + token_commitment, + ); + verify_hash_commit::( + bearer, + bearer_len, + blinder_identity, + identity_commitment, + ); +} + +// --- Tests ---------------------------------------------------------------- +// +// Every expected hash below comes from an independent Python computation, not +// from this circuit, so a wrong preimage layout is caught rather than mirrored. + +/// A 100-byte bearer, the shape the on-chain X fixtures model. +fn sample_bearer() -> ([u8; MAX_BEARER_LEN], u32) { + let mut bearer: [u8; MAX_BEARER_LEN] = [0; MAX_BEARER_LEN]; + for i in 0..4 { + bearer[i] = 0x41; // 'A' + } + for i in 4..100 { + bearer[i] = 0x78; // 'x' + } + (bearer, 100) +} + +fn blinder_token() -> [u8; BLINDER_LEN] { + let mut b: [u8; BLINDER_LEN] = [0; BLINDER_LEN]; + for i in 0..BLINDER_LEN { + b[i] = i as u8; + } + b +} + +fn blinder_identity() -> [u8; BLINDER_LEN] { + let mut b: [u8; BLINDER_LEN] = [0; BLINDER_LEN]; + for i in 0..BLINDER_LEN { + b[i] = (i + 16) as u8; + } + b +} + +// python3 -c "import hashlib; print(hashlib.sha256(b'AAAA'+b'x'*96+bytes(range(16))).hexdigest())" +fn token_commitment() -> [u8; 32] { + [ + 0x70, 0x5e, 0x0e, 0x2b, 0x21, 0x19, 0x19, 0x6a, 0xea, 0x72, 0x72, 0xae, 0xba, 0x3b, 0xea, + 0xae, 0x86, 0x34, 0xc7, 0xd3, 0x8e, 0x62, 0x47, 0x04, 0x56, 0x44, 0xe8, 0xff, 0xa6, 0x8f, + 0x99, 0x1f, + ] +} + +// python3 -c "import hashlib; print(hashlib.sha256(b'AAAA'+b'x'*96+bytes(range(16,32))).hexdigest())" +fn identity_commitment() -> [u8; 32] { + [ + 0xa1, 0x3d, 0xbd, 0xf1, 0xde, 0xf6, 0xd4, 0xb7, 0xa5, 0xe5, 0xa3, 0x49, 0xf6, 0x13, 0x7f, + 0xb8, 0xa8, 0x1d, 0x8d, 0x48, 0xdc, 0xb1, 0xc4, 0x66, 0x3e, 0x5e, 0x8d, 0x28, 0x2d, 0x72, + 0xf7, 0x9f, + ] +} + +#[test] +fn one_bearer_opens_both_commitments() { + let (bearer, len) = sample_bearer(); + main( + bearer, + len, + blinder_token(), + blinder_identity(), + token_commitment(), + identity_commitment(), + ); +} + +#[test] +fn independent_blinders_give_different_commitments() { + // The whole premise: the same credential commits to two different values, + // so nothing outside a proof can link the two sessions (REQ-COMMON-44). + let t = token_commitment(); + let i = identity_commitment(); + let mut differs = false; + for k in 0..32 { + if t[k] != i[k] { + differs = true; + } + } + assert(differs, "blinders did not separate the commitments"); +} + +#[test(should_fail_with = "hash commit mismatch")] +fn rejects_a_wrong_token_commitment() { + let (bearer, len) = sample_bearer(); + main( + bearer, + len, + blinder_token(), + blinder_identity(), + [0xff; 32], + identity_commitment(), + ); +} + +#[test(should_fail_with = "hash commit mismatch")] +fn rejects_a_wrong_identity_commitment() { + let (bearer, len) = sample_bearer(); + main( + bearer, + len, + blinder_token(), + blinder_identity(), + token_commitment(), + [0xff; 32], + ); +} + +#[test(should_fail_with = "hash commit mismatch")] +fn rejects_a_second_bearer_for_the_identity_session() { + // A prover holding two different credentials cannot link them: the single + // private `bearer` has to open both commitments. + let (mut bearer, len) = sample_bearer(); + bearer[0] = 0x42; // 'B' -- one byte away from the committed value + main( + bearer, + len, + blinder_token(), + blinder_identity(), + token_commitment(), + identity_commitment(), + ); +} + +#[test(should_fail_with = "bearer must not be empty")] +fn rejects_an_empty_bearer() { + let bearer: [u8; MAX_BEARER_LEN] = [0; MAX_BEARER_LEN]; + constrain_bearer(bearer, 0); +} + +#[test(should_fail_with = "bearer byte below printable ASCII")] +fn rejects_a_carriage_return() { + // REQ-COMMON-37: the identity session sends this range inside a header, + // so a CR would smuggle a second header line into it. + let mut bearer: [u8; MAX_BEARER_LEN] = [0; MAX_BEARER_LEN]; + bearer[0] = 0x61; + bearer[1] = 0x0d; + bearer[2] = 0x61; + constrain_bearer(bearer, 3); +} + +#[test(should_fail_with = "bearer byte below printable ASCII")] +fn rejects_a_line_feed() { + let mut bearer: [u8; MAX_BEARER_LEN] = [0; MAX_BEARER_LEN]; + bearer[0] = 0x61; + bearer[1] = 0x0a; + bearer[2] = 0x61; + constrain_bearer(bearer, 3); +} + +#[test(should_fail_with = "bearer byte above printable ASCII")] +fn rejects_a_byte_above_ascii() { + let mut bearer: [u8; MAX_BEARER_LEN] = [0; MAX_BEARER_LEN]; + bearer[0] = 0xc3; + constrain_bearer(bearer, 1); +} + +#[test(should_fail_with = "bearer tail must be zero-padded")] +fn rejects_a_dirty_tail() { + // Padding a prover controls is padding a prover can hide bytes in. + let mut bearer: [u8; MAX_BEARER_LEN] = [0; MAX_BEARER_LEN]; + bearer[0] = 0x61; + bearer[5] = 0x62; + constrain_bearer(bearer, 1); +} + +#[test] +fn accepts_the_shortest_and_longest_bearers() { + let mut shortest: [u8; MAX_BEARER_LEN] = [0; MAX_BEARER_LEN]; + shortest[0] = 0x20; // the low edge of the permitted set + constrain_bearer(shortest, 1); + + let mut longest: [u8; MAX_BEARER_LEN] = [0; MAX_BEARER_LEN]; + for i in 0..MAX_BEARER_LEN { + longest[i] = 0x7e; // the high edge + } + constrain_bearer(longest, MAX_BEARER_LEN); +} + +#[test] +fn verify_hash_commit_matches_an_independent_vector() { + // python3 -c "import hashlib; print(hashlib.sha256(b'abcd' + b'\x42'*16).hexdigest())" + let plaintext: [u8; 4] = [0x61, 0x62, 0x63, 0x64]; + let blinder: [u8; BLINDER_LEN] = [0x42; BLINDER_LEN]; + let expected: [u8; 32] = [ + 0x00, 0x7d, 0x4d, 0x44, 0x55, 0xa6, 0x83, 0x32, 0x06, 0xac, 0x7c, 0x94, 0x59, 0xd5, 0x6b, + 0x96, 0x6d, 0x79, 0x72, 0x44, 0x47, 0x6b, 0xb3, 0xb5, 0x4e, 0xba, 0xcd, 0xef, 0x26, 0x7e, + 0xc1, 0x3d, + ]; + verify_hash_commit::<4, 20>(plaintext, 4, blinder, expected); +} From 1e5eafc4983253fb98cc625f6810fe60d3414223 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Thu, 20 Aug 2026 14:00:05 +0300 Subject: [PATCH 2/4] feat!: bind the Google proof to the authorization digest 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 --- circuits/jwt_email/src/main.nr | 151 +++++++++++++++++++++++++-------- 1 file changed, 114 insertions(+), 37 deletions(-) diff --git a/circuits/jwt_email/src/main.nr b/circuits/jwt_email/src/main.nr index 983d760..8e64cea 100644 --- a/circuits/jwt_email/src/main.nr +++ b/circuits/jwt_email/src/main.nr @@ -9,7 +9,8 @@ global SIGNING_INPUT_MAX: u32 = 1280; global PAYLOAD_JSON_MAX: u32 = 768; // must be divisible by 3 (encoder output size formula). global PAYLOAD_B64_MAX: u32 = 1024; // = 4 * (PAYLOAD_JSON_MAX / 3) when divisible by 3 global EMAIL_MAX: u32 = 62; // 2 packed Fields x 31 bytes -global NONCE_MAX: u32 = 62; +global DIGEST_LEN: u32 = 32; // Authorization Digest, common REQ-COMMON-01 +global NONCE_B64_LEN: u32 = 43; // BASE64URL_NOPAD(32 bytes) global SUB_MAX: u32 = 31; // 1 packed Field x 31 bytes (Google `sub` is ~21 digits) global AUDIENCE_MAX: u32 = 128; global NUM_LIMBS: u32 = 18; @@ -23,6 +24,39 @@ global MAX_EXP_DIGITS: u32 = 12; // unix timestamp fits in 10 digits until year global ISS_PATTERN_LEN: u32 = 35; // length of `"iss":"https://accounts.google.com"` global AUD_PREFIX_LEN: u32 = 7; // length of `"aud":"` +global B64URL_ALPHABET: [u8; 64] = [ + 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, + 89, 90, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, + 115, 116, 117, 118, 119, 120, 121, 122, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 45, 95, +]; + +// Encode the 32-byte Authorization Digest the way REQ-PLAT-10 requires the +// runtime to place it in the OIDC `nonce`: base64url, no padding, 43 chars. +// +// The circuit encodes rather than decodes. Both bind the same pair, and +// encoding is the cheaper direction: 32 bytes are 10 whole three-byte groups +// plus a two-byte tail, so the output is fixed at 43 characters with no +// padding branch to constrain. +fn b64url_encode_digest(d: [u8; DIGEST_LEN]) -> [u8; NONCE_B64_LEN] { + let mut out: [u8; NONCE_B64_LEN] = [0; NONCE_B64_LEN]; + for g in 0..10 { + let b0 = d[3 * g] as u32; + let b1 = d[3 * g + 1] as u32; + let b2 = d[3 * g + 2] as u32; + out[4 * g] = B64URL_ALPHABET[b0 >> 2]; + out[4 * g + 1] = B64URL_ALPHABET[((b0 & 3) << 4) | (b1 >> 4)]; + out[4 * g + 2] = B64URL_ALPHABET[((b1 & 15) << 2) | (b2 >> 6)]; + out[4 * g + 3] = B64URL_ALPHABET[b2 & 63]; + } + // Tail: the last two bytes make three characters, with no padding byte. + let t0 = d[30] as u32; + let t1 = d[31] as u32; + out[40] = B64URL_ALPHABET[t0 >> 2]; + out[41] = B64URL_ALPHABET[((t0 & 3) << 4) | (t1 >> 4)]; + out[42] = B64URL_ALPHABET[(t1 & 15) << 2]; + out +} + // Length of base64url-encoded output (no padding) for `input_len` bytes of input. fn b64_encoded_len(input_len: u32) -> u32 { let chunks_full = input_len / 3; @@ -48,8 +82,6 @@ fn main( aud_offset: u32, email_bytes: [u8; EMAIL_MAX], email_len: u32, - nonce_bytes: [u8; NONCE_MAX], - nonce_len: u32, sub_bytes: [u8; SUB_MAX], sub_len: u32, audience_bytes: [u8; AUDIENCE_MAX], @@ -57,19 +89,16 @@ fn main( signature: [u128; NUM_LIMBS], redc: [u128; NUM_LIMBS], // ---- public inputs ---- - modulus: pub [u128; NUM_LIMBS], - email_packed: pub [Field; 2], - nonce_packed: pub [Field; 2], - sub_packed: pub [Field; 1], - exp: pub u64, + // Exactly the public inputs REQ-PLAT-16B fixes, in the order it lists. + // The Authorization Digest binds the operation, chain and verifier + // version, which is why no chain id or registry address appears here. + authorization_digest: pub [u8; DIGEST_LEN], // Full SHA-256(client_id), packed as two big-endian 16-byte Fields. audience_hash: pub [Field; 2], - // Deployment binding (pass-through pub inputs, like the X circuit's - // wallet_address). The verifier asserts chain_id == block.chainid and - // registry_addr == msg.sender, scoping a proof to one (chain, Registry) - // so it can't be replayed against another deployment sharing the VK. - chain_id: pub Field, - registry_addr: pub Field, + sub_packed: pub [Field; 1], + email_packed: pub [Field; 2], + exp: pub u64, + modulus: pub [u128; NUM_LIMBS], ) { // 1. Hash the JWT signing input (header_b64 . payload_b64). let hash: [u8; 32] = sha256_var(signing_input, signing_input_len); @@ -126,17 +155,19 @@ fn main( for i in 0..NONCE_PREFIX_LEN { assert(payload_json[nonce_offset + i] == nonce_prefix[i]); } - for i in 0..NONCE_MAX { - if i < nonce_len { - assert(payload_json[nonce_offset + NONCE_PREFIX_LEN + i] == nonce_bytes[i]); - // Same quote-exclusion as email: no interior `"` in the nonce. - assert(nonce_bytes[i] != 34); - } else { - // Same as email: zero the padding so nonce_packed is canonical. - assert(nonce_bytes[i] == 0); - } + // REQ-PLAT-18: the signed nonce IS the Authorization Digest. The digest + // is a public input and the nonce is its base64url encoding, so the two + // are bound by re-encoding the digest and comparing byte for byte. The + // length is fixed at 43, which leaves the prover no room to witness a + // longer nonce and hide bytes in it. + let expected_nonce = b64url_encode_digest(authorization_digest); + for i in 0..NONCE_B64_LEN { + assert( + payload_json[nonce_offset + NONCE_PREFIX_LEN + i] == expected_nonce[i], + "nonce does not encode the authorization digest", + ); } - assert(payload_json[nonce_offset + NONCE_PREFIX_LEN + nonce_len] == 34); // closing `"` + assert(payload_json[nonce_offset + NONCE_PREFIX_LEN + NONCE_B64_LEN] == 34); // closing `"` // 5b. `sub` substring at sub_offset in payload_json - the immutable // Google account id, revealed as a public input (mirrors email/nonce). @@ -237,7 +268,7 @@ fn main( // payload_json_len itself is bound by the base64 length check above, // so it cannot be inflated. assert(email_offset + EMAIL_PREFIX_LEN + email_len + 1 <= payload_json_len); - assert(nonce_offset + NONCE_PREFIX_LEN + nonce_len + 1 <= payload_json_len); + assert(nonce_offset + NONCE_PREFIX_LEN + NONCE_B64_LEN + 1 <= payload_json_len); assert(sub_offset + SUB_PREFIX_LEN + sub_len + 1 <= payload_json_len); assert(email_verified_offset + EMAIL_VERIFIED_LEN + 1 <= payload_json_len); assert(exp_offset + EXP_PREFIX_LEN + exp_len + 1 <= payload_json_len); @@ -253,39 +284,85 @@ fn main( assert(iss_offset >= 1); assert(aud_offset >= 1); - // 9. Pack email_bytes / nonce_bytes (62 bytes each, zero-padded past the - // real length) into [Field; 2] each, big-endian, 31 bytes per Field. + // 9. Pack email_bytes (62 bytes, zero-padded past the real length) into + // [Field; 2], big-endian, 31 bytes per Field, and sub_bytes into one. // Assert the public-input packed values match. The contract reverses // this: takes the user-supplied plaintext, zero-pads to 62, packs the // same way, compares. let mut email_f0: Field = 0; let mut email_f1: Field = 0; - let mut nonce_f0: Field = 0; - let mut nonce_f1: Field = 0; let mut sub_f0: Field = 0; for i in 0..31 { email_f0 = email_f0 * 256 + email_bytes[i] as Field; email_f1 = email_f1 * 256 + email_bytes[i + 31] as Field; - nonce_f0 = nonce_f0 * 256 + nonce_bytes[i] as Field; - nonce_f1 = nonce_f1 * 256 + nonce_bytes[i + 31] as Field; sub_f0 = sub_f0 * 256 + sub_bytes[i] as Field; } assert(email_f0 == email_packed[0]); assert(email_f1 == email_packed[1]); - assert(nonce_f0 == nonce_packed[0]); - assert(nonce_f1 == nonce_packed[1]); assert(sub_f0 == sub_packed[0]); // 10. Type bounds. assert(signing_input_len <= SIGNING_INPUT_MAX); assert(payload_json_len <= PAYLOAD_JSON_MAX); assert(email_len <= EMAIL_MAX); - assert(nonce_len <= NONCE_MAX); assert(sub_len <= SUB_MAX); assert(audience_len > 0); assert(audience_len <= AUDIENCE_MAX); +} - // Pass-through: bound by the proof, checked on-chain (not constrained here). - let _ = chain_id; - let _ = registry_addr; +// --- Tests ---------------------------------------------------------------- + +#[test] +fn nonce_encoding_matches_the_published_vector() { + // The Authorization Digest and Google nonce published together in + // platform-ceremonies.md section 3.1, which is itself the digest of the + // conformance vector in ceremony-common.md section 5. Getting both from + // the specification rather than from this circuit is what makes the test + // a check instead of a mirror. + let digest: [u8; DIGEST_LEN] = [ + 0xb3, 0x18, 0xfb, 0x55, 0x9e, 0x16, 0xa1, 0x79, 0xb8, 0x53, 0xed, 0x28, 0x53, 0x57, 0x6c, + 0xda, 0x16, 0x03, 0x2d, 0x93, 0xb0, 0x83, 0x9b, 0xb8, 0x1a, 0x55, 0x13, 0x5d, 0x33, 0x4c, + 0x0a, 0xf5, + ]; + // "sxj7VZ4WoXm4U-0oU1ds2hYDLZOwg5u4GlUTXTNMCvU" + let expected: [u8; NONCE_B64_LEN] = [ + 115, 120, 106, 55, 86, 90, 52, 87, 111, 88, 109, 52, 85, 45, 48, 111, 85, 49, 100, 115, 50, + 104, 89, 68, 76, 90, 79, 119, 103, 53, 117, 52, 71, 108, 85, 84, 88, 84, 78, 77, 67, 118, + 85, + ]; + let got = b64url_encode_digest(digest); + for i in 0..NONCE_B64_LEN { + assert(got[i] == expected[i], "nonce encoding disagrees with the published vector"); + } +} + +#[test] +fn nonce_encoding_separates_distinct_digests() { + let a: [u8; DIGEST_LEN] = [0; DIGEST_LEN]; + let mut b: [u8; DIGEST_LEN] = [0; DIGEST_LEN]; + // Differ in the final byte, which only the tail characters cover -- the + // case a group-only encoder would miss. + b[31] = 1; + let ea = b64url_encode_digest(a); + let eb = b64url_encode_digest(b); + let mut differs = false; + for i in 0..NONCE_B64_LEN { + if ea[i] != eb[i] { + differs = true; + } + } + assert(differs, "two digests encoded to one nonce"); +} + +#[test] +fn nonce_encoding_uses_the_url_alphabet() { + // An all-ones digest exercises the two characters that separate base64url + // from base64: `-` and `_` stand where `+` and `/` would. + let d: [u8; DIGEST_LEN] = [0xff; DIGEST_LEN]; + let got = b64url_encode_digest(d); + for i in 0..NONCE_B64_LEN { + assert(got[i] != 43, "encoder emitted `+`, not base64url"); + assert(got[i] != 47, "encoder emitted `/`, not base64url"); + assert(got[i] != 61, "encoder emitted padding"); + } } From 9e33682fc8cb9992767dc8fbf0eedbc7fd848ffd Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Thu, 20 Aug 2026 16:51:27 +0300 Subject: [PATCH 3/4] refactor: encode the nonce with noir_base64 rather than by hand 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 --- circuits/jwt_email/src/main.nr | 68 +++++++++++++++++----------------- 1 file changed, 35 insertions(+), 33 deletions(-) diff --git a/circuits/jwt_email/src/main.nr b/circuits/jwt_email/src/main.nr index 8e64cea..65e80d4 100644 --- a/circuits/jwt_email/src/main.nr +++ b/circuits/jwt_email/src/main.nr @@ -24,37 +24,15 @@ global MAX_EXP_DIGITS: u32 = 12; // unix timestamp fits in 10 digits until year global ISS_PATTERN_LEN: u32 = 35; // length of `"iss":"https://accounts.google.com"` global AUD_PREFIX_LEN: u32 = 7; // length of `"aud":"` -global B64URL_ALPHABET: [u8; 64] = [ - 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, - 89, 90, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, - 115, 116, 117, 118, 119, 120, 121, 122, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 45, 95, -]; - // Encode the 32-byte Authorization Digest the way REQ-PLAT-10 requires the // runtime to place it in the OIDC `nonce`: base64url, no padding, 43 chars. // // The circuit encodes rather than decodes. Both bind the same pair, and // encoding is the cheaper direction: 32 bytes are 10 whole three-byte groups -// plus a two-byte tail, so the output is fixed at 43 characters with no -// padding branch to constrain. +// plus a two-byte tail, so `noir_base64`'s length formula fixes the output at +// 43 characters with no padding branch to constrain. fn b64url_encode_digest(d: [u8; DIGEST_LEN]) -> [u8; NONCE_B64_LEN] { - let mut out: [u8; NONCE_B64_LEN] = [0; NONCE_B64_LEN]; - for g in 0..10 { - let b0 = d[3 * g] as u32; - let b1 = d[3 * g + 1] as u32; - let b2 = d[3 * g + 2] as u32; - out[4 * g] = B64URL_ALPHABET[b0 >> 2]; - out[4 * g + 1] = B64URL_ALPHABET[((b0 & 3) << 4) | (b1 >> 4)]; - out[4 * g + 2] = B64URL_ALPHABET[((b1 & 15) << 2) | (b2 >> 6)]; - out[4 * g + 3] = B64URL_ALPHABET[b2 & 63]; - } - // Tail: the last two bytes make three characters, with no padding byte. - let t0 = d[30] as u32; - let t1 = d[31] as u32; - out[40] = B64URL_ALPHABET[t0 >> 2]; - out[41] = B64URL_ALPHABET[((t0 & 3) << 4) | (t1 >> 4)]; - out[42] = B64URL_ALPHABET[(t1 & 15) << 2]; - out + BASE64_URL_ENCODER::encode(d) } // Length of base64url-encoded output (no padding) for `input_len` bytes of input. @@ -355,14 +333,38 @@ fn nonce_encoding_separates_distinct_digests() { } #[test] -fn nonce_encoding_uses_the_url_alphabet() { - // An all-ones digest exercises the two characters that separate base64url - // from base64: `-` and `_` stand where `+` and `/` would. - let d: [u8; DIGEST_LEN] = [0xff; DIGEST_LEN]; - let got = b64url_encode_digest(d); +fn nonce_encoding_matches_external_vectors() { + // Computed with Python `base64.urlsafe_b64encode(...).rstrip(b"=")`, so + // this checks `noir_base64` against an oracle outside the Noir ecosystem + // rather than against itself. The all-ones case is the sharp one: it ends + // in `8`, not `_`, because a two-byte tail forces the final character's low + // two bits to zero. An encoder that emitted a fourth tail character, or + // padding, would fail here. + let zeros: [u8; DIGEST_LEN] = [0; DIGEST_LEN]; + let zeros_expected: [u8; NONCE_B64_LEN] = [65; NONCE_B64_LEN]; // "AAA..." + let got = b64url_encode_digest(zeros); + for i in 0..NONCE_B64_LEN { + assert(got[i] == zeros_expected[i], "zero digest encoded wrong"); + } + + let ones: [u8; DIGEST_LEN] = [255; DIGEST_LEN]; + let got = b64url_encode_digest(ones); + for i in 0..42 { + assert(got[i] == 95, "expected `_` across the body of the all-ones digest"); + } + assert(got[42] == 56, "two-byte tail must end in `8`, not `_`"); + + let mut counting: [u8; DIGEST_LEN] = [0; DIGEST_LEN]; + for i in 0..DIGEST_LEN { + counting[i] = i as u8; + } + // "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8" + let counting_expected: [u8; NONCE_B64_LEN] = [ + 65, 65, 69, 67, 65, 119, 81, 70, 66, 103, 99, 73, 67, 81, 111, 76, 68, 65, 48, 79, 68, 120, + 65, 82, 69, 104, 77, 85, 70, 82, 89, 88, 71, 66, 107, 97, 71, 120, 119, 100, 72, 104, 56, + ]; + let got = b64url_encode_digest(counting); for i in 0..NONCE_B64_LEN { - assert(got[i] != 43, "encoder emitted `+`, not base64url"); - assert(got[i] != 47, "encoder emitted `/`, not base64url"); - assert(got[i] != 61, "encoder emitted padding"); + assert(got[i] == counting_expected[i], "counting digest encoded wrong"); } } From 92f49816d54bb8c213e032a742cf5c9a270ec46e Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Mon, 24 Aug 2026 12:20:49 +0300 Subject: [PATCH 4/4] refactor: rename jwt_email to oidc-google `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--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 --- .github/workflows/ci.yml | 6 +++--- README.md | 7 ++++--- circuits/{jwt_email => oidc-google}/Nargo.toml | 2 +- circuits/{jwt_email => oidc-google}/src/main.nr | 0 scripts/gen-verifier.sh | 2 +- 5 files changed, 9 insertions(+), 8 deletions(-) rename circuits/{jwt_email => oidc-google}/Nargo.toml (94%) rename circuits/{jwt_email => oidc-google}/src/main.nr (100%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7b5916b..c56938b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,7 +1,7 @@ name: CI # Two jobs. `circuits` proves that the committed sources build under the -# pinned toolchain: tests pass, both circuits compile, and every vk +# pinned toolchain: tests pass, every circuit compiles, and every vk # generates. Nothing built here is kept — artifacts are never committed and # ship exclusively as release assets, rebuilt from source by release.yml. # `dco` checks the Signed-off-by trailer CONTRIBUTING.md promises. @@ -101,7 +101,7 @@ jobs: - name: shellcheck run: shellcheck scripts/*.sh - # jwt_email has no tests and passes vacuously; x_token has 9. + # bearer_link has 12, x_token 9, oidc_google 3. - name: nargo test run: | set -euo pipefail @@ -110,7 +110,7 @@ jobs: (cd "$dir" && nargo test) done - # Full build: proves both circuits compile and every vk generates with + # Full build: proves every circuit compiles and every vk generates with # the pinned toolchain — the exact path release.yml runs to produce the # release assets. The output is discarded; artifacts only ever ship # from a release build. diff --git a/README.md b/README.md index 70af975..3491023 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,8 @@ release workflow under the pinned toolchain. | Circuit | Package | Proves | |---|---|---| -| `circuits/jwt_email` | `jwt_email` | Possession of a Google OIDC JWT: verifies the RSA signature over the JWT and exposes the claims the login registry needs, without revealing the token. Source of the `HonkVerifier` in libid-contracts `solidity/contracts/login/oidc/Verifier.sol`. | +| `circuits/bearer-link` | `bearer_link` | One hidden OAuth bearer opens both of a ceremony's blinded commitments — the token session's and the identity session's. Exactly two public inputs and nothing else: the credential never leaves the circuit, and the two sessions are tied together without publishing anything that identifies them. Serves X and GitHub, whose statements are byte-identical. | +| `circuits/oidc-google` | `oidc_google` | Possession of a Google OIDC JWT: verifies the RSASSA-PKCS1-v1_5 signature over `header.payload` and exposes the Authorization Digest carried in `nonce`, `SHA256(aud)`, `sub`, the raw `email` bytes, `exp`, and the modulus that verified. The Platform Verifier alone decides whether that modulus is trusted. | | `circuits/x-token` | `x_token` | An X (Twitter) OAuth bearer token binds two TLSN hash commitments: the same private bearer SHA-256-hashes to both notary commitments (`/token` and `/me`), plus a blinder-independent keccak nullifier for one-shot on-chain dedup per real bearer. Source of the `XHonkVerifier` in libid-contracts `solidity/contracts/login/zk/XHonkVerifier.sol`. | Sources were extracted byte-verbatim from the original monorepo and then @@ -81,7 +82,7 @@ local build from the same sources. ## Generating a Solidity verifier ```sh -scripts/gen-verifier.sh jwt_email Verifier.sol +scripts/gen-verifier.sh oidc-google Verifier.sol scripts/gen-verifier.sh x-token XHonkVerifier.sol --contract-name XHonkVerifier ``` @@ -116,4 +117,4 @@ memory-safe rewrite + `XHonkVerifier` rename), runs `forge fmt` over them, and byte-compares against its committed `Verifier.sol` and `XHonkVerifier.sol`. Reproducibility verified 2026-08-12: with the pinned toolchain, both committed verifiers reproduce byte-identically from these -sources (jwt_email vk_hash `0x1a1fad94…d7d6ba08`). +sources (oidc-google vk_hash `0x1a1fad94…d7d6ba08`). diff --git a/circuits/jwt_email/Nargo.toml b/circuits/oidc-google/Nargo.toml similarity index 94% rename from circuits/jwt_email/Nargo.toml rename to circuits/oidc-google/Nargo.toml index f923c73..ea6df5b 100644 --- a/circuits/jwt_email/Nargo.toml +++ b/circuits/oidc-google/Nargo.toml @@ -1,5 +1,5 @@ [package] -name = "jwt_email" +name = "oidc_google" type = "bin" authors = [""] compiler_version = ">=1.0.0" diff --git a/circuits/jwt_email/src/main.nr b/circuits/oidc-google/src/main.nr similarity index 100% rename from circuits/jwt_email/src/main.nr rename to circuits/oidc-google/src/main.nr diff --git a/scripts/gen-verifier.sh b/scripts/gen-verifier.sh index e9356a6..71285af 100755 --- a/scripts/gen-verifier.sh +++ b/scripts/gen-verifier.sh @@ -4,7 +4,7 @@ # # scripts/gen-verifier.sh [--contract-name X] # -# directory name under artifacts/ (e.g. jwt_email, +# directory name under artifacts/ (e.g. oidc-google, # x-token) # output path for the Solidity source # --contract-name X rename the concrete verifier contract from bb's fixed