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/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); +} 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 67% rename from circuits/jwt_email/src/main.nr rename to circuits/oidc-google/src/main.nr index 983d760..65e80d4 100644 --- a/circuits/jwt_email/src/main.nr +++ b/circuits/oidc-google/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,17 @@ 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":"` +// 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 `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] { + BASE64_URL_ENCODER::encode(d) +} + // 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 +60,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 +67,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 +133,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 +246,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 +262,109 @@ 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); +} + +// --- 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"); +} - // Pass-through: bound by the proof, checked on-chain (not constrained here). - let _ = chain_id; - let _ = registry_addr; +#[test] +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] == counting_expected[i], "counting digest encoded wrong"); + } } 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