diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index be60f51d..4054fb93 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -191,7 +191,7 @@ jobs: cargo publish --dry-run -p libid-crypto -p libid-transcript - -p libid-attestations + -p libid-ceremony -p libid-signer # --------------------------------------------------------------------------- diff --git a/.github/workflows/scripts/publish-crates.sh b/.github/workflows/scripts/publish-crates.sh index 42acc4ab..a22145b4 100755 --- a/.github/workflows/scripts/publish-crates.sh +++ b/.github/workflows/scripts/publish-crates.sh @@ -21,8 +21,8 @@ version="${1:?usage: publish-crates.sh }" : "${CARGO_REGISTRY_TOKEN:?CARGO_REGISTRY_TOKEN must be set}" # Dependency order: crypto has no intra-workspace deps; transcript is -# standalone; attestations depends on crypto; signer dev-depends on crypto. -CRATES=(libid-crypto libid-transcript libid-attestations libid-signer) +# standalone; ceremony depends on crypto; signer dev-depends on crypto. +CRATES=(libid-crypto libid-transcript libid-ceremony libid-signer) # Sparse-index path for a crate name (all our names are >= 4 chars). index_path() { diff --git a/Cargo.lock b/Cargo.lock index c14efce7..7c2f0dcb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1412,6 +1412,26 @@ dependencies = [ "serde", ] +[[package]] +name = "bincode" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36eaf5d7b090263e8150820482d5d93cd964a81e4019913c972f4edcc6edb740" +dependencies = [ + "bincode_derive", + "serde", + "unty", +] + +[[package]] +name = "bincode_derive" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf95709a440f45e986983918d0e8a1f30a9b1df04918fc828670606804ac3c09" +dependencies = [ + "virtue", +] + [[package]] name = "bitcoin-consensus-encoding" version = "1.1.0" @@ -2959,7 +2979,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.5", "system-configuration", "tokio", "tower-layer", @@ -3340,12 +3360,6 @@ version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" - [[package]] name = "libc" version = "0.2.189" @@ -3353,13 +3367,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] -name = "libid-attestations" +name = "libid-ceremony" version = "0.3.0" dependencies = [ - "alloy-primitives", - "alloy-sol-types", + "bincode 2.0.1", "hex", "libid-crypto", + "thiserror 2.0.20", ] [[package]] @@ -3373,6 +3387,18 @@ dependencies = [ "tiny-keccak", ] +[[package]] +name = "libid-identity" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe0baf23cfda461fc82227695334e27f45a2feff53870a4612a1d01cafd7aed4" + +[[package]] +name = "libid-profiles" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dab7ecd48e0d3c233192f235e4cc6f90dc6b62fe6ffb9903d99120e6f70c895" + [[package]] name = "libid-signer" version = "0.3.0" @@ -3393,7 +3419,10 @@ dependencies = [ "http-body-util", "hyper 1.11.0", "hyper-util", + "libid-ceremony", "libid-transcript", + "rangeset", + "serde_json", "thiserror 2.0.20", "tlsn", "tokio", @@ -3406,11 +3435,13 @@ dependencies = [ name = "libid-transcript" version = "0.3.0" dependencies = [ + "httparse", + "libid-identity", + "libid-profiles", "serde", "serde_json", "thiserror 2.0.20", "tokio", - "ts-rs", ] [[package]] @@ -3490,7 +3521,7 @@ name = "mpz-circuits-core" version = "0.1.0-alpha.6" source = "git+https://github.com/privacy-ethereum/mpz?rev=v0.1.0-alpha.6#6ebfe619490c3155a589fc6a3be83b0976de19dc" dependencies = [ - "bincode", + "bincode 1.3.3", "itybity 0.3.3", "once_cell", "rand 0.9.5", @@ -3505,7 +3536,7 @@ name = "mpz-circuits-data" version = "0.1.0-alpha.6" source = "git+https://github.com/privacy-ethereum/mpz?rev=v0.1.0-alpha.6#6ebfe619490c3155a589fc6a3be83b0976de19dc" dependencies = [ - "bincode", + "bincode 1.3.3", "mpz-circuits-core", "once_cell", ] @@ -4917,7 +4948,7 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96005f01d74e0866a7c27bc47e6051e286339df96c5ab10dd0ee833043a0fbd5" dependencies = [ - "bincode", + "bincode 1.3.3", "bytes", "futures-channel", "futures-core", @@ -5176,15 +5207,6 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" -[[package]] -name = "termcolor" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" -dependencies = [ - "winapi-util", -] - [[package]] name = "thiserror" version = "1.0.69" @@ -5713,29 +5735,6 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" -[[package]] -name = "ts-rs" -version = "10.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e640d9b0964e9d39df633548591090ab92f7a4567bc31d3891af23471a3365c6" -dependencies = [ - "lazy_static", - "thiserror 2.0.20", - "ts-rs-macros", -] - -[[package]] -name = "ts-rs-macros" -version = "10.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e9d8656589772eeec2cf7a8264d9cda40fb28b9bc53118ceb9e8c07f8f38730" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", - "termcolor", -] - [[package]] name = "typenum" version = "1.20.1" @@ -5810,6 +5809,12 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "unty" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae" + [[package]] name = "url" version = "2.5.8" @@ -5856,6 +5861,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "virtue" +version = "0.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "051eb1abcf10076295e815102942cc58f9d5e3b4560e46e53c21e8ff6f3af7b1" + [[package]] name = "vsimd" version = "0.8.0" @@ -5969,15 +5980,6 @@ dependencies = [ "rustls-pki-types", ] -[[package]] -name = "winapi-util" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "windows-core" version = "0.62.2" diff --git a/Cargo.toml b/Cargo.toml index 9f9e0996..69c2649b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,18 +23,29 @@ repository = "https://github.com/libid-org/libid-rs" libid-crypto = { path = "crates/libid-crypto", version = "0.3.0" } libid-signer = { path = "crates/libid-signer", version = "0.3.0" } libid-transcript = { path = "crates/libid-transcript", version = "0.3.0" } -libid-attestations = { path = "crates/libid-attestations", version = "0.3.0" } +libid-ceremony = { path = "crates/libid-ceremony", version = "0.3.0" } alloy = { version = "1", default-features = false } -alloy-primitives = { version = "1", features = ["serde"] } -alloy-sol-types = "1" # KMS-backed signing. alloy's `signer-aws` feature re-exports # `alloy::signers::aws::AwsSigner`, but constructing one needs an # `aws_sdk_kms::Client`, so the SDK is a direct dependency too. Both are # pinned to the 1.x line so cargo unifies them with whatever alloy resolves. aws-config = "1" aws-sdk-kms = "1" +# Pinned exactly: the encoded bytes are a signed preimage, so a layout change +# in a patch release would change what every notary signs. +bincode = { version = "=2.0.1", features = ["derive"] } hex = "0.4" +# The ceremony profiles, generated in libid-contracts from the same +# `profiles.json` its verifiers read. Zero dependencies of its own. +libid-profiles = "0.9" +# The generated handle table, for the test that keeps the platform names of +# the ceremony profiles and the identity system from drifting apart. A +# dev-dependency only: nothing published carries it. +libid-identity = "0.9" +# Chunk-size parsing. Hand-rolling it is how a malformed chunk becomes a short +# body instead of an error. +httparse = "1" http-body-util = "0.1" hyper = { version = "1.1", features = ["client", "http1"] } hyper-util = { version = "0.1", features = ["full"] } @@ -51,6 +62,4 @@ tlsn = { git = "https://github.com/tlsnotary/tlsn", tag = "v0.1.0-alpha.15" } tokio = { version = "1", features = ["rt", "macros", "net", "io-util", "sync"] } tokio-util = { version = "0.7", features = ["compat"] } tracing = "0.1" -# TS bindings codegen — opt-in via the `ts` feature of libid-transcript. -ts-rs = { version = "10", features = ["serde-compat"] } webpki-root-certs = "1.0" diff --git a/README.md b/README.md index 6aec69c8..61b7da74 100644 --- a/README.md +++ b/README.md @@ -2,18 +2,18 @@ Shared Rust crates for MPC-TLS / zkTLS infrastructure: run TLSNotary-style notarization sessions, carve selective-disclosure ranges out of TLS -transcripts, build the Merkle/EIP-191 proof material, and produce the exact -digests the libID on-chain verifiers check. +transcripts, sign the EIP-191 material, and produce the exact attested-data +record the libID on-chain verifiers check. ## Crates | Crate | crates.io | What it is | | --- | --- | --- | -| `libid-crypto` | yes | Contract-agnostic primitives: keccak256, EIP-191 sign/recover (27/28 `v`, low-s), OpenZeppelin-compatible sorted-pair keccak Merkle tree (root, inclusion proofs, verify, double-hashed prefixed leaves), Ethereum address and hex-key helpers. Minimal deps: `k256`, `tiny-keccak`, `hex`. | -| `libid-transcript` | yes | The tlsn-free half of the MPC-TLS toolkit. HTTP/JSON transcript range math for selective disclosure (header/body/chunked decoding, JSON field and `"key":"value"` snippet ranges, bare-number id snippets, anchored lookups, notary reveal ranges); the length-prefixed JSON wire protocol notary and prover speak after MPC-TLS closes; the `EvmProof` / `NotaryResponse` / `TlsHandshakeData` types. | -| `libid-attestations` | yes | Contract-ABI-shaped digest builders, byte-pinned against the Solidity verifiers: chain-bound notary digest, JWKS-rotation notary digest (legacy 6-slot), backend digest, identity hash, and the XZkVerifier token/me attestation digests with their op-tags. | +| `libid-crypto` | yes | Contract-agnostic primitives: keccak256, EIP-191 sign/recover (27/28 `v`, low-s) — the pair a notary signature is made and checked with — plus address derivation and hex-key parsing. Minimal deps: `k256`, `tiny-keccak`, `hex`. | +| `libid-transcript` | yes | The tlsn-free half of the MPC-TLS toolkit. HTTP/JSON transcript range math for selective disclosure (header/body/chunked decoding, `"key":"value"` and bare-number member ranges); the per-session ceremony reveal layouts, built from the profile table generated in libid-contracts; the length-prefixed JSON wire protocol notary and prover speak after MPC-TLS closes; the `AttestationWire` type. | +| `libid-ceremony` | yes | The attested-data record a notary signs: the types a Platform Profile pins, their big-endian fixed-width encoder, and the keccak256 over it that is the only preimage a notary signs. Also the GitHub Token Service request and response records with the bounds a served call must satisfy. | | `libid-signer` | yes | `ManagedSigner` — one signing identity over a local hex key or an AWS KMS key: EIP-191 claim signing (byte-compatible with `libid_crypto::sign_eth_claim`), bare prehash signing (the tlsn `Secp256k1Eth` format), alloy transaction wallets, public-key accessors, and `SignerSource::from_spec` shape-classified key-spec parsing (64-hex → local key, anything else → KMS). | -| `libid-tlsn` | **no — git only** | The MPC-TLS session driver over the upstream `tlsn` crate: `prover` / `prover_generic` / `verifier` over any async socket, TLS 1.2 handshake-data extraction, WebPKI root store. | +| `libid-tlsn` | **no — git only** | The MPC-TLS session driver over the upstream `tlsn` crate: `prover_generic` and `verifier` over any async socket, the attested-data record built from what a session was observed to be, WebPKI root store. | ## The tlsn git-dep caveat @@ -27,16 +27,9 @@ libid-tlsn = { git = "https://github.com/libid-org/libid-rs", tag = "v0.3.0" } ``` The crate split exists precisely so this caveat stays contained: everything -that does not need `tlsn` types — range math, wire protocol, proof types, -digests, signing — is published normally and never drags the git pin into -your lockfile. - -## Feature flags - -* `libid-transcript/ts` — derives `ts_rs::TS` on `EvmProof` and - `NotaryResponse` for TypeScript bindings generation. Off by default so - production builds don't carry `ts-rs`. -* `libid-tlsn` and everything else: no features. +that does not need `tlsn` types — range math, reveal layouts, the attested-data +record, wire protocol, signing — is published normally and never drags the git +pin into your lockfile. ## Usage sketch @@ -45,34 +38,50 @@ answers over the same socket: ```rust,ignore let result = libid_tlsn::verifier(socket).await?; -// inspect result.partial_transcript / result.tls_transcript, build an -// EvmProof with libid_crypto merkle + libid_attestations digests, sign it -// with libid_signer::ManagedSigner, then: +// describe the session as a libid_tlsn::attest::ObservedSession and build +// the record with AttestedData::from_observed, +// sign its digest with libid_signer::ManagedSigner, then: libid_transcript::write_msg(&mut result.recovered_io, &response).await?; ``` -A prover connects to a notary and fetches an authenticated endpoint, -revealing only the chosen JSON snippets: +A prover connects to a notary, sends one request inside MPC-TLS, and decides +what of the exchange is revealed and what is committed. For a launch profile +that decision is `libid_transcript::ceremony`'s, built from the profile table +`libid-contracts` generates, so the prover and the on-chain verifier read one +definition: ```rust,ignore -let out = libid_tlsn::prover( +use libid_tlsn::{Bytes, HttpBody, HttpRequest}; +use libid_transcript::ceremony::{profiles, Layout}; + +let x = profiles::X.identity.expect("x notarizes an identity session"); +let request = HttpRequest::builder() + .method(x.session.method) + .uri(format!("https://{}{}", x.session.authority, x.session.path)) + .header("authorization", format!("Bearer {access_token}")) + .header("accept", "application/json") + .header("host", x.session.authority) + .header("connection", "close") + .body(HttpBody::new(Bytes::new()))?; + +let out = libid_tlsn::prover_generic( socket, - access_token, - &libid_tlsn::UserInfoParams { - api_host: "api.x.com", - user_info_path: "/2/users/me", - username_field: "username", - id_field: Some(("id", true)), - user_agent: "my-prover/1.0", + request, + |sent, recv| { + let layouts = Layout::identity_request(sent) + .and_then(|s| Layout::identity_response(recv, &x).map(|r| (s, r))); + layouts.map_err(|e| libid_tlsn::Error::MpcTlsFailed { detail: e.to_string() }) }, |step| tracing::info!(?step), ) .await?; +// out.response_body, out.secrets, out.commitment_openings, out.recovered_io ``` -Unauthenticated full-reveal flows (e.g. notarizing a JWKS endpoint) use -`prover_generic` with `bearer_token: None` and a closure returning -`vec![0..recv.len()]`. +The URI is absolute because the host names the server; the wire carries the +origin-form request line the verifiers pin. A session that reads a public +document and reveals all of it -- notarizing a JWKS endpoint -- +states its own layouts, revealing the whole of each direction. ## Versioning and releases diff --git a/crates/libid-attestations/Cargo.toml b/crates/libid-attestations/Cargo.toml deleted file mode 100644 index 25b3390b..00000000 --- a/crates/libid-attestations/Cargo.toml +++ /dev/null @@ -1,18 +0,0 @@ -[package] -name = "libid-attestations" -version.workspace = true -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true -description = "Contract-ABI-shaped digest builders for the libID verifiers: notary, backend, token/me attestation and JWKS-rotation digests, byte-pinned against the Solidity implementations." -keywords = ["ethereum", "attestation", "digest", "abi", "notary"] -categories = ["cryptography::cryptocurrencies"] - -[dependencies] -alloy-primitives.workspace = true -alloy-sol-types.workspace = true -libid-crypto.workspace = true - -[dev-dependencies] -hex.workspace = true diff --git a/crates/libid-attestations/src/lib.rs b/crates/libid-attestations/src/lib.rs deleted file mode 100644 index 7cb9024c..00000000 --- a/crates/libid-attestations/src/lib.rs +++ /dev/null @@ -1,479 +0,0 @@ -//! Contract-ABI-shaped digest builders for the libID verifiers. -//! -//! Every function here mirrors a specific Solidity verification routine and -//! is pinned to it by known-vector tests. The generic primitives (keccak, -//! EIP-191, Merkle) live in `libid-crypto`; this crate is where the contract -//! ABI shapes are allowed to leak in. -//! -//! Digest inventory: -//! -//! * [`compute_notary_digest`] — `_verifyNotarySignature` (8-slot, chain- and -//! deployment-bound). -//! * [`compute_jwks_notary_digest`] — `JwksOracle._notaryDigest` (6-slot -//! legacy form, no chain binding). -//! * [`compute_backend_digest`] — `_verifyBackendSignature` (4-slot). -//! * [`compute_identity_hash`] — `Registry.sol` identity hash. -//! * [`compute_token_attest_digest`] / [`compute_me_attest_digest`] — -//! `XZkVerifier._verifyTokenSig` / `_verifyMeSig`. - -use libid_crypto::keccak256; - -/// Must match Solidity `_verifyNotarySignature` (8-slot abi.encode with -/// `(chainId, verifyingContract)` domain separator + 6 legacy fields). -/// A notary signature on chain A is not replayable against a sibling -/// deployment on chain B (or against a redeployed proxy on the same -/// chain). -#[allow(clippy::too_many_arguments)] -pub fn compute_notary_digest( - chain_id: u64, - verifying_contract: &[u8; 20], - domain: &str, - client_random: &[u8; 32], - server_random: &[u8; 32], - server_ephemeral_key: &[u8], - transcript_root: &[u8; 32], - timestamp: u64, -) -> [u8; 32] { - let domain_hash = keccak256(domain.as_bytes()); - let mut encoded = Vec::with_capacity(8 * 32); - extend_u256(&mut encoded, chain_id as u128); - extend_address(&mut encoded, verifying_contract); - // 6 legacy fields - encoded.extend_from_slice(&domain_hash); - encoded.extend_from_slice(client_random); - encoded.extend_from_slice(server_random); - encoded.extend_from_slice(&keccak256(server_ephemeral_key)); - encoded.extend_from_slice(transcript_root); - extend_u256(&mut encoded, timestamp as u128); - keccak256(&encoded) -} - -/// Compute the digest `JwksOracle._notaryDigest` verifies: the 6-slot legacy -/// form `keccak256(abi.encode(domainHash, clientRandom, serverRandom, -/// keccak(serverEphemeralKey), transcriptRoot, timestamp))` — no chain -/// binding. -pub fn compute_jwks_notary_digest( - domain_hash: [u8; 32], - client_random: [u8; 32], - server_random: [u8; 32], - server_ephemeral_key: &[u8], - transcript_root: [u8; 32], - timestamp: u64, -) -> [u8; 32] { - use alloy_sol_types::SolValue; - let server_eph_hash = keccak256(server_ephemeral_key); - let encoded = ( - alloy_primitives::B256::from(domain_hash), - alloy_primitives::B256::from(client_random), - alloy_primitives::B256::from(server_random), - alloy_primitives::B256::from(server_eph_hash), - alloy_primitives::B256::from(transcript_root), - alloy_primitives::U256::from(timestamp), - ) - .abi_encode_params(); - keccak256(&encoded) -} - -/// Compute the identity hash: `keccak256(abi.encode(domain, username))`. -/// -/// This matches the Solidity `Registry.sol` computation exactly. -/// `abi.encode` for two dynamic `string` arguments produces: -/// - 2 × 32-byte offsets (pointing to each string's length slot) -/// - For each string: 32-byte length + data padded to 32-byte boundary -#[allow(clippy::arithmetic_side_effects)] -pub fn compute_identity_hash(domain: &str, username: &str) -> [u8; 32] { - fn pad32(len: usize) -> usize { - (len + 31) & !31 - } - - let domain_bytes = domain.as_bytes(); - let username_bytes = username.as_bytes(); - - let domain_padded = pad32(domain_bytes.len()); - let username_padded = pad32(username_bytes.len()); - - // Total: 2 offsets (64) + domain length (32) + domain data (padded) - // + username length (32) + username data (padded) - let total = 64 + 32 + domain_padded + 32 + username_padded; - let mut encoded = vec![0u8; total]; - - // Offset of first string data = 64 (0x40) - encoded[31] = 0x40; - // Offset of second string data = 64 + 32 + domain_padded - let second_offset = 64u64 + 32 + domain_padded as u64; - encoded[32..64].copy_from_slice(&{ - let mut buf = [0u8; 32]; - buf[24..].copy_from_slice(&second_offset.to_be_bytes()); - buf - }); - - // Domain: length + data - let base = 64; - encoded[base + 24..base + 32] - .copy_from_slice(&(domain_bytes.len() as u64).to_be_bytes()); - encoded[base + 32..base + 32 + domain_bytes.len()].copy_from_slice(domain_bytes); - - // Username: length + data - let base2 = 64 + 32 + domain_padded; - encoded[base2 + 24..base2 + 32] - .copy_from_slice(&(username_bytes.len() as u64).to_be_bytes()); - encoded[base2 + 32..base2 + 32 + username_bytes.len()] - .copy_from_slice(username_bytes); - - keccak256(&encoded) -} - -/// Op-tag for `XZkVerifier._verifyTokenSig`: `keccak256("XZkVerifier.token.v1")`. -pub fn op_token_attest_tag() -> [u8; 32] { - keccak256(b"XZkVerifier.token.v1") -} - -/// Op-tag for `XZkVerifier._verifyMeSig`: `keccak256("XZkVerifier.me.v1")`. -pub fn op_me_attest_tag() -> [u8; 32] { - keccak256(b"XZkVerifier.me.v1") -} - -/// Input for the token-attestation EIP-191 digest. -pub struct TokenAttestInput<'a> { - /// EVM chain ID (domain separator). - pub chain_id: u64, - /// XZkVerifier contract address (binds attestation to one deployment). - pub verifying_contract: &'a [u8; 20], - /// SNI / platform name (e.g. "api.x.com"). - pub platform_name: &'a str, - /// SHA256(bearer || blinder) — TLSN hash-commit, bearer in RECV. - pub bearer_hash: &'a [u8; 32], - /// Start offset of the bearer range in the recv transcript. - pub bearer_range_start: u32, - /// End offset (exclusive) of the bearer range. - pub bearer_range_end: u32, - /// SENT request body — must contain `client_id=`. - pub sent_revealed: &'a [u8], - /// Unix timestamp (seconds) of notarization. - pub timestamp: u64, -} - -/// Mirrors `XZkVerifier._verifyTokenSig`: -/// `keccak256(abi.encode(chainid, verifier, keccak(platformName), OP_TOKEN_ATTEST, -/// bearerHash, bearerRangeStart, bearerRangeEnd, keccak(sentRevealed), ts))`. -pub fn compute_token_attest_digest(input: &TokenAttestInput<'_>) -> [u8; 32] { - let platform_hash = keccak256(input.platform_name.as_bytes()); - let sent_hash = keccak256(input.sent_revealed); - let op = op_token_attest_tag(); - - let mut buf = Vec::with_capacity(9 * 32); - extend_u256(&mut buf, input.chain_id as u128); - extend_address(&mut buf, input.verifying_contract); - buf.extend_from_slice(&platform_hash); - buf.extend_from_slice(&op); - buf.extend_from_slice(input.bearer_hash); - extend_u256(&mut buf, input.bearer_range_start as u128); - extend_u256(&mut buf, input.bearer_range_end as u128); - buf.extend_from_slice(&sent_hash); - extend_u256(&mut buf, input.timestamp as u128); - keccak256(&buf) -} - -/// Input for the me-attestation EIP-191 digest. -pub struct MeAttestInput<'a> { - /// EVM chain ID (domain separator). - pub chain_id: u64, - /// XZkVerifier contract address. - pub verifying_contract: &'a [u8; 20], - /// SNI / platform name. - pub platform_name: &'a str, - /// SHA256(bearer || blinder) — must equal `tokenAttest.bearerHash`. - pub bearer_hash: &'a [u8; 32], - /// Start offset of the bearer range in the sent transcript. - pub bearer_range_start: u32, - /// End offset (exclusive) of the bearer range. - pub bearer_range_end: u32, - /// SENT-side revealed bytes: concat of `[0, bearer_range_start)` (request - /// prefix ending in `authorization: Bearer `) and - /// `[bearer_range_end, bearer_range_end + 2)` (CRLF after bearer). - pub sent_revealed: &'a [u8], - /// End of the first revealed range. Must equal `bearer_range_start`. - pub sent_prefix_end: u32, - /// End of the second revealed range. Must equal `bearer_range_end + 2`. - /// H1 bearer-end anchor: the two bytes between `bearer_range_end` and - /// `sent_suffix_end` are CRLF, canonicalizing `bearer_len`. - pub sent_suffix_end: u32, - /// RECV-side revealed bytes (chunk containing handle JSON). - pub recv_revealed: &'a [u8], - /// Claimed handle; must appear as `""` in `recv_revealed`. - pub handle: &'a str, - /// Immutable platform user-id; must appear as `"id":""` in - /// `recv_revealed` ("" when not revealed → handle-key fallback). - pub user_id: &'a str, - /// Session key the wallet will register against (signed by notary here). - pub session_addr: &'a [u8; 20], - /// Unix timestamp (seconds) of notarization. - pub timestamp: u64, -} - -/// Mirrors `XZkVerifier._verifyMeSig`. -pub fn compute_me_attest_digest(input: &MeAttestInput<'_>) -> [u8; 32] { - let platform_hash = keccak256(input.platform_name.as_bytes()); - let sent_hash = keccak256(input.sent_revealed); - let recv_hash = keccak256(input.recv_revealed); - let handle_hash = keccak256(input.handle.as_bytes()); - let user_id_hash = keccak256(input.user_id.as_bytes()); - let op = op_me_attest_tag(); - - let mut buf = Vec::with_capacity(15 * 32); - extend_u256(&mut buf, input.chain_id as u128); - extend_address(&mut buf, input.verifying_contract); - buf.extend_from_slice(&platform_hash); - buf.extend_from_slice(&op); - buf.extend_from_slice(input.bearer_hash); - extend_u256(&mut buf, input.bearer_range_start as u128); - extend_u256(&mut buf, input.bearer_range_end as u128); - buf.extend_from_slice(&sent_hash); - extend_u256(&mut buf, input.sent_prefix_end as u128); - extend_u256(&mut buf, input.sent_suffix_end as u128); - buf.extend_from_slice(&recv_hash); - buf.extend_from_slice(&handle_hash); - buf.extend_from_slice(&user_id_hash); - extend_address(&mut buf, input.session_addr); - extend_u256(&mut buf, input.timestamp as u128); - keccak256(&buf) -} - -fn extend_u256(buf: &mut Vec, v: u128) { - buf.extend_from_slice(&[0u8; 16]); - buf.extend_from_slice(&v.to_be_bytes()); -} - -fn extend_address(buf: &mut Vec, addr: &[u8; 20]) { - buf.extend_from_slice(&[0u8; 12]); - buf.extend_from_slice(addr); -} - -/// Compute the backend digest: -/// `keccak256(abi.encode(userAddress, walletAddress, transcriptRoot, timestamp))`. -/// `wallet_address` is `[0u8; 20]` for `register_session` and the target -/// wallet for `linkIdentity` — bound to the signature so a leaked proof -/// cannot be replayed from a different `msg.sender`. -pub fn compute_backend_digest( - user_address: &[u8; 20], - wallet_address: &[u8; 20], - transcript_root: &[u8; 32], - timestamp: u64, -) -> [u8; 32] { - let mut encoded = Vec::with_capacity(4 * 32); - extend_address(&mut encoded, user_address); - extend_address(&mut encoded, wallet_address); - encoded.extend_from_slice(transcript_root); - extend_u256(&mut encoded, timestamp as u128); - keccak256(&encoded) -} - -#[cfg(test)] -mod tests { - use super::*; - - /// Regression: backend digest = abi.encode(userAddr, walletAddr, root, ts). - #[test] - fn backend_digest_known_vector() { - let user_address = [0xABu8; 20]; - let wallet_address = [0xCDu8; 20]; - let transcript_root = [0x01u8; 32]; - let timestamp: u64 = 1000; - - let digest = compute_backend_digest( - &user_address, - &wallet_address, - &transcript_root, - timestamp, - ); - let digest2 = compute_backend_digest( - &user_address, - &wallet_address, - &transcript_root, - timestamp, - ); - assert_eq!(digest, digest2); - assert_ne!(digest, [0u8; 32]); - - // Different walletAddress must produce a different digest. - let other = compute_backend_digest( - &user_address, - &[0u8; 20], - &transcript_root, - timestamp, - ); - assert_ne!(digest, other); - } - - /// Identity hash must match Solidity: keccak256(abi.encode("api.x.com", "alice")). - /// The expected hash is pinned in test/Registry.t.sol::test_identityHash_knownVector. - #[test] - fn identity_hash_matches_solidity() { - let hash = compute_identity_hash("api.x.com", "alice"); - // Computed from Solidity: keccak256(abi.encode("api.x.com", "alice")) - // This value must be kept in sync with the Solidity test. - let expected = hex::decode( - "c9c0cd07ff8cc2f66b83dc7343b0040bc55eb4b7705829cf17f45aba75a2ecf3", - ) - .unwrap(); - assert_eq!( - hash, - expected.as_slice(), - "identity hash Rust/Solidity mismatch" - ); - } - - /// The hand-rolled identity-hash abi.encode must agree with alloy's - /// encoder for dynamic strings. - #[test] - fn identity_hash_matches_alloy_encoder() { - use alloy_sol_types::SolValue; - for (domain, username) in [ - ("api.x.com", "alice"), - ("api.github.com", "a-much-longer-username-past-32-bytes!!"), - ("", ""), - ] { - let encoded = (domain.to_string(), username.to_string()).abi_encode_params(); - assert_eq!( - compute_identity_hash(domain, username), - keccak256(&encoded), - "{domain}/{username}" - ); - } - } - - /// Token-attest digest must match Solidity XZkVerifier._verifyTokenSig. - /// keccak256(abi.encode(chainid, verifier, keccak(platform), OP_TOKEN_ATTEST, - /// bearerHash, start, end, keccak(sentRevealed), ts)). - #[test] - fn token_attest_digest_matches_solidity() { - let verifier = [0u8; 20]; - let mut v = verifier; - v[19] = 1; - let bearer_hash = [0x11u8; 32]; - let digest = compute_token_attest_digest(&TokenAttestInput { - chain_id: 1, - verifying_contract: &v, - platform_name: "api.x.com", - bearer_hash: &bearer_hash, - bearer_range_start: 5, - bearer_range_end: 50, - sent_revealed: b"GET /token", - timestamp: 1000, - }); - let expected = hex::decode( - "4221c29b0c1346afc0eaabad4bdf16803b1329ae9408f05dade9a675faea62ed", - ) - .unwrap(); - assert_eq!( - digest, - expected.as_slice(), - "token-attest digest Rust/Solidity mismatch" - ); - } - - /// Me-attest digest must match Solidity XZkVerifier._verifyMeSig. - /// keccak256(abi.encode(chainid, verifier, keccak(platform), OP_ME_ATTEST, - /// bearerHash, start, end, keccak(sent), prefixEnd, suffixEnd, keccak(recv), - /// keccak(handle), keccak(userId), sessionAddr, ts)). - #[test] - fn me_attest_digest_matches_solidity() { - let mut v = [0u8; 20]; - v[19] = 1; - let mut session = [0u8; 20]; - session[19] = 2; - let bearer_hash = [0x11u8; 32]; - let digest = compute_me_attest_digest(&MeAttestInput { - chain_id: 1, - verifying_contract: &v, - platform_name: "api.x.com", - bearer_hash: &bearer_hash, - bearer_range_start: 5, - bearer_range_end: 50, - sent_revealed: b"GET /me", - sent_prefix_end: 5, - sent_suffix_end: 52, - recv_revealed: br#"{"id":"123","username":"alice"}"#, - handle: "alice", - user_id: "123", - session_addr: &session, - timestamp: 1000, - }); - let expected = hex::decode( - "fbde91b37cbfc819cabfdc43bd9a2ae09a344f7b135ead145d3f5b27ae1b9903", - ) - .unwrap(); - assert_eq!( - digest, - expected.as_slice(), - "me-attest digest Rust/Solidity mismatch" - ); - } - - /// The chain-bound notary digest is the jwks legacy digest with the - /// `(chainId, verifyingContract)` prefix — verify the hand-rolled - /// encoding against alloy's for the shared 6-field tail. - #[test] - fn notary_digest_matches_alloy_encoding() { - use alloy_sol_types::SolValue; - let chain_id = 11155111u64; - let contract = [0x42u8; 20]; - let domain = "api.x.com"; - let client_random = [0xAAu8; 32]; - let server_random = [0xBBu8; 32]; - let eph = vec![0x04u8; 65]; - let root = [0xCCu8; 32]; - let ts = 1_700_000_000u64; - - let digest = compute_notary_digest( - chain_id, - &contract, - domain, - &client_random, - &server_random, - &eph, - &root, - ts, - ); - - let encoded = ( - alloy_primitives::U256::from(chain_id), - alloy_primitives::Address::from(contract), - alloy_primitives::B256::from(keccak256(domain.as_bytes())), - alloy_primitives::B256::from(client_random), - alloy_primitives::B256::from(server_random), - alloy_primitives::B256::from(keccak256(&eph)), - alloy_primitives::B256::from(root), - alloy_primitives::U256::from(ts), - ) - .abi_encode_params(); - assert_eq!(digest, keccak256(&encoded)); - } - - /// The 6-slot jwks digest differs from the chain-bound one precisely by - /// the missing (chainId, verifyingContract) prefix. - #[test] - fn jwks_notary_digest_is_the_unbound_tail() { - let domain_hash = keccak256(b"www.googleapis.com"); - let eph = vec![0u8; 65]; - let digest = compute_jwks_notary_digest( - domain_hash, - [1u8; 32], - [2u8; 32], - &eph, - [3u8; 32], - 1000, - ); - - let mut encoded = Vec::with_capacity(6 * 32); - encoded.extend_from_slice(&domain_hash); - encoded.extend_from_slice(&[1u8; 32]); - encoded.extend_from_slice(&[2u8; 32]); - encoded.extend_from_slice(&keccak256(&eph)); - encoded.extend_from_slice(&[3u8; 32]); - let mut ts = [0u8; 32]; - ts[24..].copy_from_slice(&1000u64.to_be_bytes()); - encoded.extend_from_slice(&ts); - assert_eq!(digest, keccak256(&encoded)); - } -} diff --git a/crates/libid-ceremony/Cargo.toml b/crates/libid-ceremony/Cargo.toml new file mode 100644 index 00000000..0e059b7e --- /dev/null +++ b/crates/libid-ceremony/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "libid-ceremony" +description = "The attestation record a libID notary signs: the attested-data types a Platform Profile pins, and their encoder." +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +bincode.workspace = true +libid-crypto.workspace = true +thiserror.workspace = true + +[dev-dependencies] +hex.workspace = true diff --git a/crates/libid-ceremony/src/attestation.rs b/crates/libid-ceremony/src/attestation.rs new file mode 100644 index 00000000..472e5822 --- /dev/null +++ b/crates/libid-ceremony/src/attestation.rs @@ -0,0 +1,288 @@ +//! The attested-data format the launch profiles pin. +//! +//! THE LAYOUT IS THE PROFILE'S, NOT THE SPECIFICATION'S. `REQ-COMMON-18` has a +//! Platform Profile fix the attestation format it accepts and leaves the format +//! itself to the profile author. So this module is the definition, not a +//! reading of one, and every rule it keeps is stated here in full. +//! +//! An attestation is a byte string and a signature over it. The Notary Service +//! signs off chain, where it holds the transcript, and verifies on chain, where +//! it holds none. The verifying side therefore rebuilds these exact bytes from +//! what it was handed and derives the signing key from them: a field reordered, +//! omitted, or encoded differently on either side derives a key nobody trusts, +//! and `REQ-COMMON-33` leaves it nothing else to check the signature against. +//! +//! Every boundary is derivable from bytes that precede it, so decoding is one +//! forward pass and two different attestations cannot share one preimage by +//! shifting a boundary. +//! +//! Four components must agree on these bytes: this crate, the Solidity +//! decoder, the TypeScript mirror, and the notary that signs them. A +//! divergence is silent -- the signature derives a key nobody trusts and every +//! genuine attestation is rejected with no error saying why. + +use libid_crypto::keccak256; + +/// Offsets are zero-based into that direction's complete transcript, `start` +/// inclusive and `end` exclusive. +#[derive(Clone, Debug, PartialEq, Eq, bincode::Encode)] +pub struct RevealedRange { + pub start: u32, + /// The range's plaintext. Its length IS the range's length -- there is no + /// `end`, because two ways to say the same thing is one way to disagree. + /// The decoder computes `end = start + bytes.len()`. + pub bytes: Vec, +} + +/// A hidden range, carried as its offsets and a blinded commitment. The +/// plaintext of a committed range never appears in the attested data. +#[derive(Clone, Debug, PartialEq, Eq, bincode::Encode)] +pub struct RangeCommitment { + pub start: u32, + pub end: u32, + pub commitment: [u8; 32], +} + +/// One direction of the session. +#[derive(Clone, Debug, Default, PartialEq, Eq, bincode::Encode)] +pub struct DirectionBlock { + pub revealed: Vec, + pub commitments: Vec, +} + +/// The signed bytes. +/// +/// The attested data describes the observed session and says nothing about +/// where the evidence will be spent: no chain, no verifier identity. The +/// Authorization Digest already commits the chain, and binding an attestation +/// to one verifier would stop a newly registered version checking attestations +/// made before it existed. +#[derive(Clone, Debug, PartialEq, Eq, bincode::Encode)] +pub struct AttestedData { + /// The TLS server name the notary authenticated, hashed. + /// + /// This is the only identity in the record, and the notary observed it + /// rather than being told it. Which platform that host belongs to, and + /// which session of a ceremony this is, are read from the revealed request + /// line by the party that pins those constants. + pub authority_id: [u8; 32], + pub created_at: u64, + pub sent_transcript_length: u32, + pub recv_transcript_length: u32, + pub sent: DirectionBlock, + pub received: DirectionBlock, +} + +/// Bytes before the first direction block: the authority, `createdAt`, and the +/// two transcript lengths. +pub const HEADER_LEN: usize = 32 + 8 + 4 + 4; + +/// Big-endian, fixed-width, no varints: the decoder is Solidity, which has no +/// use for a compact integer that costs a branch to read. +/// +/// Pinned to one exact bincode version in `Cargo.toml`. These bytes are a +/// signed preimage, so a layout change in a patch release would silently +/// change what every notary signs -- and the cross-language fixture below is +/// what would catch it. +const WIRE: bincode::config::Configuration< + bincode::config::BigEndian, + bincode::config::Fixint, +> = bincode::config::standard() + .with_big_endian() + .with_fixed_int_encoding(); + +impl AttestedData { + /// The `authority_id` of a record covering a session with `server_name`: + /// keccak256 over the canonical authority bytes (REQ-COMMON-21, + /// REQ-COMMON-21A). + /// + /// An associated function on the record rather than a free `tag`, because + /// the free form said nothing about what may be hashed. The one input this + /// field accepts is the TLS server name the notary AUTHENTICATED. A `Host` + /// header the prover composed hashes just as well and yields a record + /// naming an authority nobody observed -- and that substitution is one the + /// transcript cannot rule out, since the request carries the authority only + /// where the prover wrote it. Naming the record puts the rule beside the + /// field. + /// + /// The canonical form is ASCII lowercase, and it is OURS to fix, the way + /// the byte layout above is. REQ-COMMON-21A has the Platform Verifier + /// compare the authenticated authority byte for byte against the constants + /// its profile pins; which bytes those are is the profile author's + /// decision, and this is where this implementation makes it. The generated + /// table in `libid-contracts` makes the same one and refuses any authority + /// that is not lowercase and free of a trailing dot, so the two agree at + /// the source rather than by coincidence. + /// + /// It happens HERE rather than at each caller. The id is compared on chain against a constant a profile + /// pins, and ASCII case is the one difference a TLS stack hands back + /// without anyone noticing: `API.x.com` authenticates the same server and + /// hashes to a different id. Left to the call site it is a step every + /// future caller has to remember, and the one that forgets produces + /// attestations that are signed, well formed, and refused by every verifier + /// with nothing pointing at the capital letter. It sat at the one caller in + /// `libid-tlsn` while the other passed a string that was already lowercase + /// -- a rule kept by accident. + /// + /// The same sentence of section 9 that gives the lowercase form also says + /// no trailing dot, and this does NOT strip one. A dotted name therefore + /// hashes to an id no profile matches, and the session is refused -- which + /// is the safe direction, but it is a refusal rather than a repair. Fixing + /// it needs a caller that can produce the FQDN form, and a test; it is not + /// a rename's business to change what a signed field hashes. + /// + /// A string rather than a server-name type: this crate carries three + /// dependencies and no TLS library at all, so it knows nothing about how + /// one models a name -- which is also what keeps this mapping testable + /// without a session. + /// + /// The record's one remaining 32-byte tag. It used to serve three more -- + /// format, platform and session -- and those went with the fields the + /// notary was handed rather than saw. + pub fn authority_id_of(server_name: &str) -> [u8; 32] { + keccak256(server_name.to_ascii_lowercase().as_bytes()) + } + + /// Lay the record out. This does NOT judge it: a malformed record is the + /// prover's problem, the Platform Verifier's decision, and the client's to + /// catch in a dry run. Refusing to sign here would only withhold a session + /// the notary really did observe. + /// + /// The layout is the struct above, in declaration order. Nothing here + /// restates it, so nothing here can drift from it. + pub fn encode(&self) -> Result, bincode::error::EncodeError> { + bincode::encode_to_vec(self, WIRE) + } + + /// What the notary signs, and the only preimage it ever signs + /// (REQ-COMMON-33). + pub fn digest(&self) -> Result<[u8; 32], bincode::error::EncodeError> { + Ok(keccak256(&self.encode()?)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample() -> AttestedData { + // Shaped like the X identity session: the request reveals everything + // but the bearer, which is committed and framed by the header bytes. + AttestedData { + authority_id: AttestedData::authority_id_of("api.x.com"), + created_at: 1_770_000_000, + sent_transcript_length: 60, + recv_transcript_length: 40, + sent: DirectionBlock { + revealed: vec![ + RevealedRange { + start: 0, + bytes: vec![b'a'; 20], + }, + RevealedRange { + start: 40, + bytes: vec![b'b'; 20], + }, + ], + commitments: vec![RangeCommitment { + start: 20, + end: 40, + commitment: [7u8; 32], + }], + }, + received: DirectionBlock { + revealed: vec![RevealedRange { + start: 0, + bytes: vec![b'c'; 10], + }], + commitments: vec![RangeCommitment { + start: 10, + end: 40, + commitment: [9u8; 32], + }], + }, + } + } + + /// The exact bytes `solidity/contracts/ceremony/test/CeremonyAttestation.t.sol` + /// decodes. Both sides carry this fixture, so a change to either encoder + /// breaks loudly here rather than diverging quietly and rejecting every + /// genuine attestation on chain. + const CROSS_LANGUAGE_FIXTURE: &str = "4930142f5283d4a8eab0d24c588f00b21213ae2a47e7ed6c1dc6a57044f1655d0000000069800e800000003c00000028000000000000000200000000000000000000001461616161616161616161616161616161616161610000002800000000000000146262626262626262626262626262626262626262000000000000000100000014000000280707070707070707070707070707070707070707070707070707070707070707000000000000000100000000000000000000000a6363636363636363636300000000000000010000000a000000280909090909090909090909090909090909090909090909090909090909090909"; + + const CROSS_LANGUAGE_DIGEST: &str = + "48162f05bdb27b19b3544bf2aae608745861bf357bb31e07f536b6fb50e95936"; + + #[test] + fn agrees_with_the_solidity_decoder() { + let encoded = sample().encode().unwrap(); + assert_eq!(hex::encode(&encoded), CROSS_LANGUAGE_FIXTURE); + assert_eq!( + hex::encode(sample().digest().unwrap()), + CROSS_LANGUAGE_DIGEST + ); + } + + #[test] + fn the_authority_is_hashed_in_one_canonical_spelling() { + // REQ-COMMON-21A fixes the preimage as the lowercase ASCII server name. + // A profile pins this id as a constant, so a differently cased spelling + // of the same authenticated host would hash to an id no profile matches + // -- every genuine attestation for that host refused, with nothing + // pointing at the capital letter. + assert_eq!( + AttestedData::authority_id_of("API.X.com"), + AttestedData::authority_id_of("api.x.com") + ); + } + + #[test] + fn header_is_one_hundred_and_forty_four_bytes() { + let mut data = sample(); + data.sent = DirectionBlock::default(); + data.received = DirectionBlock::default(); + data.sent_transcript_length = 0; + data.recv_transcript_length = 0; + // Header plus two empty counts per direction. + // Four counts, one per direction per list, eight bytes each. + assert_eq!(data.encode().unwrap().len(), HEADER_LEN + 4 * 8); + assert_eq!(HEADER_LEN, 48); + } + + #[test] + fn every_header_field_changes_the_digest() { + let base = sample().digest().unwrap(); + for mutate in [ + (|d: &mut AttestedData| d.authority_id[0] ^= 1) as fn(&mut AttestedData), + |d| d.created_at += 1, + ] { + let mut data = sample(); + mutate(&mut data); + assert_ne!(data.digest().unwrap(), base); + } + } + + #[test] + fn two_sessions_of_one_ceremony_are_not_interchangeable() { + // Nothing in the record labels which session it covers, and nothing + // needs to: the sessions differ in what the notary OBSERVED. The + // request line is a revealed range, and the verifier compares it + // against the path its profile pins, so a token attestation offered in + // the identity slot fails on bytes the notary actually saw rather than + // on a label it was handed. + let mut token = sample(); + token.sent.revealed[0].bytes = b"POST /2/oauth2/token ".to_vec(); + assert_ne!(token.digest().unwrap(), sample().digest().unwrap()); + } + + #[test] + fn a_shifted_boundary_cannot_produce_one_preimage() { + // Moving a byte from a revealed range into the next one changes the + // encoding: the range's length is its bytes, and the following span + // starts one earlier. + let mut moved = sample(); + moved.sent.revealed[0].bytes.pop(); + moved.sent.commitments[0].start = 19; + assert_ne!(moved.digest().unwrap(), sample().digest().unwrap()); + } +} diff --git a/crates/libid-ceremony/src/lib.rs b/crates/libid-ceremony/src/lib.rs new file mode 100644 index 00000000..798768b1 --- /dev/null +++ b/crates/libid-ceremony/src/lib.rs @@ -0,0 +1,56 @@ +//! What the notary needs to sign a ceremony attestation, and nothing else. +//! +//! # Why this crate is small +//! +//! The notary's whole job is to record what it observed and sign it. It does +//! not judge that record: whether the ranges tile the transcript, whether a +//! request carries exactly one authorization header, whether the framing bytes +//! are right -- every one of those is the Platform Verifier's decision, and the +//! Platform Verifier is Solidity. +//! +//! A copy of those checks here would be a second opinion nobody asked for. Its +//! only power would be to refuse to sign a session the notary really did +//! observe, which is a denial of service by a party with no standing to judge. +//! REQ-COMMON-33 says it more plainly: the notary decides nothing +//! profile-specific. +//! +//! Where a check IS wanted before spending gas, it belongs in the client as a +//! dry run: REQ-PLAT-44 has the Canonical Runtime check an attestation before +//! it spends a second session on one. This crate is still not where that runs +//! -- the dry run reads the rules a verifier applies, and those are the +//! chain's. +//! +//! An earlier version of this paragraph named four `@libid/contracts` +//! TypeScript exports as the place it already lived. They do not exist: +//! v0.8.0's ceremony package is the generated profile table and its index. +//! Naming a client-side checker that has not been written invites the reader +//! to skip writing one. +//! +//! The same reasoning removed the last labels. The notary used to stamp a +//! format tag, a platform id and a session tag; it observed none of them. The +//! format is fixed by the notary key a profile pins alongside it +//! (REQ-COMMON-18); the platform is the host it connected to; and which session +//! this is, is the request line it recorded. All three were a party naming +//! things it was told rather than things it saw. +//! +//! So this crate holds one direction of one thing: +//! +//! * [`attestation`] -- the attested-data types and the encoder that lays them +//! out. No decoder: whoever decodes also checks, and +//! that is the chain and the client. +//! * [`token_exchange`] -- the GitHub Token Service's own request and response +//! records. Its validation stays, because REQ-PLAT-37 and REQ-PLAT-38 put +//! that service's input validation on that service; no contract sees it. The +//! route it is served on does not: section 6.3 leaves endpoint naming and +//! parsing bounds to the deployment, so the implementation states the route +//! and this crate states the records. + +pub mod attestation; +pub mod token_exchange; + +pub use attestation::{ + AttestedData, + DirectionBlock, + RangeCommitment, + RevealedRange, +}; diff --git a/crates/libid-ceremony/src/token_exchange.rs b/crates/libid-ceremony/src/token_exchange.rs new file mode 100644 index 00000000..b4d40941 --- /dev/null +++ b/crates/libid-ceremony/src/token_exchange.rs @@ -0,0 +1,346 @@ +//! The GitHub Token Service contract of platform-ceremonies section 6.3. +//! +//! GitHub uses a confidential client, so the exchange cannot run in the +//! browser: the client secret would have to go there. The deployment runs it +//! instead, inside a notarized TLS session, and returns the attestation. The +//! secret stays behind a range commitment and never reaches the browser. +//! +//! The service is stateless by requirement, not by preference. It holds +//! ceremony credentials, so retention would create a compromise target with no +//! protocol purpose (REQ-PLAT-42). +//! +//! # What this module is, and is not +//! +//! Section 6.3 names protocol values, not serialized field names: "the browser +//! and deployment specifications own endpoint naming, transport framing, +//! serialization, parsing bounds, caller authentication, and cache policy". +//! So the route does not live here -- the deployment picks it, and the +//! implementation that serves it states it. +//! +//! What lives here is the record pair and the bounds a served request and +//! response must satisfy before the service acts on either. The semantics come +//! from REQ-PLAT-37, -38, -41, -54 and -55; the byte bounds come from the +//! GitHub token endpoint of the ceremony server contract, which is the +//! deployment specification that owns them. + +pub const MAX_CODE_BYTES: usize = 1024; +pub const CODE_VERIFIER_LEN: usize = 43; +pub const MAX_ACCESS_TOKEN_BYTES: usize = 4096; +/// The bearer commitment's blinder is fixed-width prover material, not a +/// bounded string: the circuit opens exactly this many bytes. +pub const BEARER_OPENING_LEN: usize = 16; +pub const MAX_ATTESTED_DATA_BYTES: usize = 2 * 1024 * 1024; +/// A recoverable secp256k1 signature: `r || s || v`. +pub const SIGNATURE_LEN: usize = 65; +pub const MAX_RESPONSE_BYTES: usize = 3 * 1024 * 1024; + +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum TokenExchangeError { + #[error("code is empty")] + EmptyCode, + #[error("code is {0} bytes, over the {MAX_CODE_BYTES}-byte bound")] + CodeTooLong(usize), + #[error("code carries a byte outside printable ASCII at index {0}")] + CodeNotPrintable(usize), + #[error("codeVerifier must match [A-Za-z0-9_-]{{43}}")] + MalformedCodeVerifier, + #[error("accessToken is empty")] + EmptyAccessToken, + #[error("accessToken is {0} bytes, over the {MAX_ACCESS_TOKEN_BYTES}-byte bound")] + AccessTokenTooLong(usize), + #[error("accessToken carries a byte outside printable ASCII at index {0}")] + AccessTokenNotPrintable(usize), + #[error("attestedData is empty")] + EmptyAttestedData, + #[error("attestedData is {0} bytes, over the {MAX_ATTESTED_DATA_BYTES}-byte bound")] + AttestedDataTooLong(usize), + #[error("signature is {0} bytes, not the {SIGNATURE_LEN} a notary signature is")] + SignatureWrongLength(usize), + #[error( + "bearerOpening is {0} bytes, not the {BEARER_OPENING_LEN} the circuit opens" + )] + BearerOpeningWrongLength(usize), +} + +/// The index of the first byte outside printable ASCII, which excludes +/// whitespace and control characters. Both credentials carried here are held to +/// it: the code because it is echoed into a platform request, the bearer +/// because it is echoed into an `Authorization` header. +fn first_unprintable(s: &str) -> Option { + s.bytes().position(|b| !(0x21..=0x7e).contains(&b)) +} + +/// What the Canonical Runtime sends. Nothing else: the service uses only its +/// compiled client identifier, secret, redirect URI, token endpoint and notary +/// configuration, and accepts no caller-selected action, client, redirect, +/// endpoint or return URL (REQ-PLAT-41). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TokenRequest { + pub code: String, + pub code_verifier: String, +} + +/// The signed attestation of the notarized exchange. +/// +/// The bytes alone are not the attestation. REQ-PLAT-38 has the service return +/// the attestation, and an attestation is a byte string together with the +/// notary signature over it -- a record carrying only the bytes leaves the +/// browser holding something no verifier can check, and no field to put the +/// signature in. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TokenAttestation { + /// The byte-exact attested data of the notarized exchange, preserved as + /// the notary produced it. + pub attested_data: Vec, + /// The notary signature authenticating those exact bytes. + pub signature: Vec, +} + +/// What comes back. `access_token` and `bearer_opening` both stay inside the +/// browser: the opening is private witness material for the Proving Circuit, +/// and publishing it beside the commitment would publish the credential the +/// commitment exists to hide (REQ-PLAT-55). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TokenResponse { + pub access_token: String, + pub token_attestation: TokenAttestation, + /// The blinder that opens the committed bearer range of that attestation. + /// + /// Without it the browser holds the attestation and the bearer but cannot + /// build the proof: the blinder is prover-private material generated inside + /// a session only this service ran (REQ-PLAT-54). + pub bearer_opening: Vec, +} + +impl TokenRequest { + /// Bounded parsing, per REQ-PLAT-37 and the request bounds of the server + /// contract. + pub fn validate(&self) -> Result<(), TokenExchangeError> { + if self.code.is_empty() { + return Err(TokenExchangeError::EmptyCode); + } + if self.code.len() > MAX_CODE_BYTES { + return Err(TokenExchangeError::CodeTooLong(self.code.len())); + } + if let Some(i) = first_unprintable(&self.code) { + return Err(TokenExchangeError::CodeNotPrintable(i)); + } + if self.code_verifier.len() != CODE_VERIFIER_LEN + || !self + .code_verifier + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-') + { + return Err(TokenExchangeError::MalformedCodeVerifier); + } + Ok(()) + } +} + +impl TokenResponse { + /// Bounded parsing, per REQ-PLAT-38 and the response bounds of the server + /// contract. + /// + /// The three values are one result and the bounds say so: a bearer the + /// header cannot carry, an opening the circuit cannot use, or a signature + /// no recovery accepts each make the other two worthless, so each is exact + /// rather than merely capped. + pub fn validate(&self) -> Result<(), TokenExchangeError> { + if self.access_token.is_empty() { + return Err(TokenExchangeError::EmptyAccessToken); + } + if self.access_token.len() > MAX_ACCESS_TOKEN_BYTES { + return Err(TokenExchangeError::AccessTokenTooLong( + self.access_token.len(), + )); + } + if let Some(i) = first_unprintable(&self.access_token) { + return Err(TokenExchangeError::AccessTokenNotPrintable(i)); + } + if self.token_attestation.attested_data.is_empty() { + return Err(TokenExchangeError::EmptyAttestedData); + } + if self.token_attestation.attested_data.len() > MAX_ATTESTED_DATA_BYTES { + return Err(TokenExchangeError::AttestedDataTooLong( + self.token_attestation.attested_data.len(), + )); + } + if self.token_attestation.signature.len() != SIGNATURE_LEN { + return Err(TokenExchangeError::SignatureWrongLength( + self.token_attestation.signature.len(), + )); + } + if self.bearer_opening.len() != BEARER_OPENING_LEN { + return Err(TokenExchangeError::BearerOpeningWrongLength( + self.bearer_opening.len(), + )); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn request() -> TokenRequest { + TokenRequest { + code: "abc123".into(), + // The section 7 conformance vector of ceremony-common, transcribed: + // this crate derives nothing, so the value can only be copied. + code_verifier: "5teBDl6cz4U77aFweV5PbMhBJ_lEFv6LLNKzqnDI5lo".into(), + } + } + + fn response() -> TokenResponse { + TokenResponse { + access_token: "gho_abc123".into(), + token_attestation: TokenAttestation { + attested_data: vec![0; 10], + signature: vec![0; SIGNATURE_LEN], + }, + bearer_opening: vec![0; BEARER_OPENING_LEN], + } + } + + #[test] + fn accepts_a_well_formed_request() { + request().validate().unwrap(); + } + + #[test] + fn accepts_a_well_formed_response() { + response().validate().unwrap(); + } + + #[test] + fn the_published_verifier_is_the_right_shape() { + // The section 7 conformance vector must satisfy the request bounds, or + // the service would refuse a verifier the specification itself + // produces. + assert_eq!(request().code_verifier.len(), CODE_VERIFIER_LEN); + request().validate().unwrap(); + } + + #[test] + fn refuses_an_empty_or_over_long_code() { + let mut r = request(); + r.code = String::new(); + assert_eq!(r.validate(), Err(TokenExchangeError::EmptyCode)); + r.code = "a".repeat(MAX_CODE_BYTES + 1); + assert_eq!( + r.validate(), + Err(TokenExchangeError::CodeTooLong(MAX_CODE_BYTES + 1)) + ); + } + + #[test] + fn refuses_whitespace_and_control_bytes_in_a_code() { + for bad in ["ab cd", "ab\tcd", "ab\ncd", "ab\0cd"] { + let r = TokenRequest { + code: bad.into(), + ..request() + }; + assert!( + matches!(r.validate(), Err(TokenExchangeError::CodeNotPrintable(_))), + "accepted {bad:?}" + ); + } + } + + #[test] + fn refuses_a_verifier_of_the_wrong_length_or_charset() { + for bad in [ + "short", + "5teBDl6cz4U77aFweV5PbMhBJ_lEFv6LLNKzqnDI5l", // 42 + "5teBDl6cz4U77aFweV5PbMhBJ_lEFv6LLNKzqnDI5loo", // 44 + "5teBDl6cz4U77aFweV5PbMhBJ+lEFv6LLNKzqnDI5lo", // base64, not base64url + "5teBDl6cz4U77aFweV5PbMhBJ/lEFv6LLNKzqnDI5lo", + ] { + let r = TokenRequest { + code_verifier: bad.into(), + ..request() + }; + assert_eq!( + r.validate(), + Err(TokenExchangeError::MalformedCodeVerifier), + "accepted {bad:?}" + ); + } + } + + #[test] + fn refuses_an_empty_or_over_long_access_token() { + let mut r = response(); + r.access_token = String::new(); + assert_eq!(r.validate(), Err(TokenExchangeError::EmptyAccessToken)); + r.access_token = "t".repeat(MAX_ACCESS_TOKEN_BYTES + 1); + assert_eq!( + r.validate(), + Err(TokenExchangeError::AccessTokenTooLong( + MAX_ACCESS_TOKEN_BYTES + 1 + )) + ); + } + + #[test] + fn refuses_whitespace_and_control_bytes_in_an_access_token() { + // The bearer is echoed into an `Authorization` header; a control byte + // there is a header the platform never sees as one. + for bad in ["gho_ab cd", "gho_ab\tcd", "gho_ab\r\ncd", "gho_ab\0cd"] { + let r = TokenResponse { + access_token: bad.into(), + ..response() + }; + assert!( + matches!( + r.validate(), + Err(TokenExchangeError::AccessTokenNotPrintable(_)) + ), + "accepted {bad:?}" + ); + } + } + + #[test] + fn refuses_an_empty_or_over_long_attested_data() { + let mut r = response(); + r.token_attestation.attested_data = Vec::new(); + assert_eq!(r.validate(), Err(TokenExchangeError::EmptyAttestedData)); + + let mut r = response(); + r.token_attestation.attested_data = vec![0; MAX_ATTESTED_DATA_BYTES + 1]; + assert!(matches!( + r.validate(), + Err(TokenExchangeError::AttestedDataTooLong(_)) + )); + } + + #[test] + fn refuses_a_signature_that_is_not_exactly_recoverable_length() { + for len in [0, SIGNATURE_LEN - 1, SIGNATURE_LEN + 1] { + let mut r = response(); + r.token_attestation.signature = vec![0; len]; + assert_eq!( + r.validate(), + Err(TokenExchangeError::SignatureWrongLength(len)), + "accepted a {len}-byte signature" + ); + } + } + + #[test] + fn refuses_an_opening_that_is_not_exactly_what_the_circuit_opens() { + // A near miss is the dangerous one: a bounded check accepted both of + // these, and the circuit accepts neither. + for len in [0, BEARER_OPENING_LEN - 1, BEARER_OPENING_LEN + 1, 256] { + let mut r = response(); + r.bearer_opening = vec![0; len]; + assert_eq!( + r.validate(), + Err(TokenExchangeError::BearerOpeningWrongLength(len)), + "accepted a {len}-byte opening" + ); + } + } +} diff --git a/crates/libid-crypto/Cargo.toml b/crates/libid-crypto/Cargo.toml index 05069f8d..0d6cb33e 100644 --- a/crates/libid-crypto/Cargo.toml +++ b/crates/libid-crypto/Cargo.toml @@ -5,8 +5,8 @@ edition.workspace = true rust-version.workspace = true license.workspace = true repository.workspace = true -description = "Contract-agnostic crypto primitives for the libID stack: keccak256, EIP-191 sign/recover, sorted-pair keccak Merkle trees (OpenZeppelin-compatible), and Ethereum address helpers." -keywords = ["ethereum", "keccak", "merkle", "eip-191", "secp256k1"] +description = "Contract-agnostic crypto primitives for the libID stack: keccak256, EIP-191 sign/recover, and Ethereum address helpers." +keywords = ["ethereum", "keccak", "eip-191", "secp256k1"] categories = ["cryptography::cryptocurrencies"] [dependencies] diff --git a/crates/libid-crypto/src/lib.rs b/crates/libid-crypto/src/lib.rs index 1cb56109..67c8ff2f 100644 --- a/crates/libid-crypto/src/lib.rs +++ b/crates/libid-crypto/src/lib.rs @@ -1,10 +1,10 @@ //! Contract-agnostic crypto primitives shared across the libID stack. //! //! Everything here is generic Ethereum-flavoured cryptography: keccak256, -//! EIP-191 signing/recovery, a sorted-pair keccak Merkle tree byte-compatible -//! with OpenZeppelin's `MerkleProof`, and address helpers. Nothing in this -//! crate knows about any specific contract ABI — the contract-shaped digest -//! builders live in `libid-attestations`. +//! EIP-191 signing and recovery -- the pair a notary signature is made and +//! checked with -- and address helpers. Nothing in this +//! crate knows about any specific contract ABI — the byte layouts a Solidity +//! decoder has to agree with live in `libid-ceremony`. use k256::ecdsa::{ signature::hazmat::PrehashSigner, @@ -51,50 +51,6 @@ pub fn keccak256(data: &[u8]) -> [u8; 32] { output } -/// Sign a message with a secp256k1 private key (Ethereum-style: keccak256 -/// prehash). Returns a 65-byte signature: r (32) || s (32) || v (1), with the -/// raw 0/1 recovery byte (no EVM offset — see [`sign_eth_claim`] for the -/// 27/28 convention). -pub fn sign_message(key: &SigningKey, message: &[u8]) -> Result> { - let digest = keccak256(message); - let (sig, recid) = key.sign_prehash(&digest).map_err(|e| Error::CryptoFailed { - op: "sign".into(), - detail: format!("{e}"), - })?; - let sig: Signature = sig; - let mut out = Vec::with_capacity(65); - out.extend_from_slice(&sig.to_bytes()); - out.push(recid.to_byte()); - Ok(out) -} - -/// Recover the public key from a 65-byte signature and the original message. -pub fn recover_public_key(signature: &[u8], message: &[u8]) -> Result { - if signature.len() != 65 { - return Err(Error::CryptoFailed { - op: "verify signature".into(), - detail: "signature must be 65 bytes".into(), - }); - } - let sig = - Signature::from_slice(&signature[..64]).map_err(|e| Error::CryptoFailed { - op: "parse signature".into(), - detail: format!("{e}"), - })?; - let recid = - RecoveryId::from_byte(signature[64]).ok_or_else(|| Error::CryptoFailed { - op: "parse recovery id".into(), - detail: "invalid recovery id".into(), - })?; - let digest = keccak256(message); - VerifyingKey::recover_from_prehash(&digest, &sig, recid).map_err(|e| { - Error::CryptoFailed { - op: "recover public key".into(), - detail: format!("{e}"), - } - }) -} - /// Convert a public key to an Ethereum address (last 20 bytes of keccak256 of /// the uncompressed point). pub fn pubkey_to_eth_address(key: &VerifyingKey) -> [u8; 20] { @@ -170,113 +126,6 @@ pub fn recover_eth_claim(signature: &[u8], digest: &[u8; 32]) -> Result [u8; 32] { - if leaves.is_empty() { - return [0u8; 32]; - } - if leaves.len() == 1 { - return leaves[0]; - } - let mut layer: Vec<[u8; 32]> = leaves.to_vec(); - while layer.len() > 1 { - let mut next = Vec::with_capacity(layer.len().div_ceil(2)); - for chunk in layer.chunks(2) { - if chunk.len() == 2 { - next.push(hash_pair(chunk[0], chunk[1])); - } else { - next.push(chunk[0]); - } - } - layer = next; - } - layer[0] -} - -/// Generate a Merkle inclusion proof for the leaf at `index`. -/// -/// # Panics -/// -/// Panics if `index >= leaves.len()`. -#[allow(clippy::arithmetic_side_effects)] // Index arithmetic is bounded by layer.len() -pub fn merkle_proof(leaves: &[[u8; 32]], index: usize) -> Vec<[u8; 32]> { - assert!(index < leaves.len(), "index out of range"); - let mut proof = Vec::new(); - let mut layer: Vec<[u8; 32]> = leaves.to_vec(); - let mut idx = index; - while layer.len() > 1 { - if idx.is_multiple_of(2) { - if idx + 1 < layer.len() { - proof.push(layer[idx + 1]); - } - } else { - proof.push(layer[idx - 1]); - } - let mut next = Vec::with_capacity(layer.len().div_ceil(2)); - for chunk in layer.chunks(2) { - next.push(if chunk.len() == 2 { - hash_pair(chunk[0], chunk[1]) - } else { - chunk[0] - }); - } - layer = next; - idx /= 2; - } - proof -} - -/// Verify a Merkle inclusion proof produced by [`merkle_proof`] against a -/// root produced by [`build_merkle_tree`]. Byte-compatible with OpenZeppelin's -/// `MerkleProof.verify`. -pub fn merkle_verify(proof: &[[u8; 32]], root: [u8; 32], leaf: [u8; 32]) -> bool { - let mut cur = leaf; - for sibling in proof { - cur = hash_pair(cur, *sibling); - } - cur == root -} - -/// Sorted-pair keccak hash — the OpenZeppelin node combine step. -pub fn hash_pair(a: [u8; 32], b: [u8; 32]) -> [u8; 32] { - if a < b { - keccak256(&[a.as_slice(), b.as_slice()].concat()) - } else { - keccak256(&[b.as_slice(), a.as_slice()].concat()) - } -} - -/// Double-hashed Merkle leaf (OpenZeppelin style, prevents second-preimage): -/// `keccak256(keccak256(prefix || value))`. -/// -/// `prefix` accepts both `&str` (`"recv:"`) and `&[u8]` (`b"recv:"`) tags. -pub fn double_hash_leaf(prefix: impl AsRef<[u8]>, value: &[u8]) -> [u8; 32] { - let mut inner = Vec::with_capacity(prefix.as_ref().len().saturating_add(value.len())); - inner.extend_from_slice(prefix.as_ref()); - inner.extend_from_slice(value); - let inner_hash = keccak256(&inner); - keccak256(&inner_hash) -} - -/// Parse a hex string (with or without a "0x" prefix) into a 20-byte -/// Ethereum address. -pub fn hex_to_address(hex_str: &str) -> Result<[u8; 20]> { - let hex_str = hex_str.strip_prefix("0x").unwrap_or(hex_str); - let bytes = hex::decode(hex_str).map_err(|e| Error::CryptoFailed { - op: "parse address hex".into(), - detail: format!("{e}"), - })?; - if bytes.len() != 20 { - return Err(Error::CryptoFailed { - op: "parse address".into(), - detail: "address must be 20 bytes".into(), - }); - } - let mut addr = [0u8; 20]; - addr.copy_from_slice(&bytes); - Ok(addr) -} - /// Parse a hex string (with or without a "0x" prefix) into a secp256k1 /// signing key. pub fn hex_to_signing_key(hex_str: &str) -> Result { @@ -301,12 +150,61 @@ mod tests { const ANVIL_ADDR: &str = "f39fd6e51aad88f6f4ce6ab8827279cfffb92266"; #[test] - fn roundtrip_sign_recover() { + fn a_recovered_key_is_not_the_signer_of_another_digest() { + // Recovery ALWAYS produces a key for a well-formed signature -- it + // cannot fail its way to safety. What makes it a check is comparing + // the result against a key the caller already trusts, which is what + // `NotaryService` does on chain with its trusted set. This is the case + // that comparison exists for. let (sk, vk) = generate_keypair(); - let msg = b"hello world"; - let sig = sign_message(&sk, msg).unwrap(); - let recovered = recover_public_key(&sig, msg).unwrap(); - assert_eq!(vk, recovered); + let signed = keccak256(b"the record the notary saw"); + let sig = sign_eth_claim(&sk, &signed).unwrap(); + + let other = recover_eth_claim(&sig, &keccak256(b"some other record")).unwrap(); + assert_ne!(vk, other, "another digest must not recover the signer"); + // And the signer does come back for the digest it signed, so the + // assertion above is about the digest and not about recovery failing. + assert_eq!(vk, recover_eth_claim(&sig, &signed).unwrap()); + } + + #[test] + fn the_eth_claim_recovery_refuses_what_it_cannot_read() { + let (sk, _) = generate_keypair(); + let digest = keccak256(b"a claim"); + let sig = sign_eth_claim(&sk, &digest).unwrap(); + + assert!(recover_eth_claim(&sig[..64], &digest).is_err()); + let mut bad_v = sig.clone(); + // Neither convention: 26 is below the EVM offset and above 0..=3. + bad_v[64] = 26; + assert!(recover_eth_claim(&bad_v, &digest).is_err()); + + // A readable `v` over sixty-four bytes that are not a signature: `s` + // must be non-zero and in the lower half of the order. + let mut zeros = [0u8; 65]; + zeros[64] = 27; + assert!(recover_eth_claim(&zeros, &digest).is_err()); + } + + #[test] + fn a_public_key_hexes_as_its_thirty_three_compressed_bytes() { + let (_, vk) = generate_keypair(); + let hex = pubkey_to_hex(&vk); + assert_eq!(hex.len(), 66, "33 bytes, two characters each"); + assert!(hex.chars().all(|c| c.is_ascii_hexdigit())); + // The compressed SEC1 form starts 02 or 03, never 04 -- that prefix is + // the uncompressed point, which is what the address derivation hashes + // and is a different encoding entirely. + assert!(hex.starts_with("02") || hex.starts_with("03"), "{hex}"); + } + + #[test] + fn a_signing_key_refuses_hex_that_is_not_a_key() { + assert!(hex_to_signing_key("not hex at all").is_err()); + // Well-formed hex of the wrong width. + assert!(hex_to_signing_key("0xdeadbeef").is_err()); + // Zero is not a valid secp256k1 scalar. + assert!(hex_to_signing_key(&"00".repeat(32)).is_err()); } #[test] @@ -363,78 +261,6 @@ mod tests { assert_eq!(recover_eth_claim(&sig, &digest).unwrap(), vk); } - #[test] - fn merkle_tree_empty_and_single_leaf() { - assert_eq!(build_merkle_tree(&[]), [0u8; 32]); - let leaf = keccak256(b"leaf"); - assert_eq!(build_merkle_tree(&[leaf]), leaf); - } - - #[test] - fn merkle_tree_two_leaves() { - let a = keccak256(b"a"); - let b = keccak256(b"b"); - let root = build_merkle_tree(&[a, b]); - let expected = hash_pair(a, b); - assert_eq!(root, expected); - } - - #[test] - fn merkle_proof_roundtrip() { - // Odd count exercises the promoted-node path. - let leaves: Vec<[u8; 32]> = (0..5u8).map(|i| keccak256(&[i])).collect(); - let root = build_merkle_tree(&leaves); - - for i in 0..leaves.len() { - let proof = merkle_proof(&leaves, i); - assert!(merkle_verify(&proof, root, leaves[i]), "leaf {}", i); - } - } - - #[test] - fn merkle_proof_roundtrip_double_hashed_leaves() { - // The shape the notaries use: double-hashed prefixed leaves. - let leaves: Vec<[u8; 32]> = ["a", "b", "c", "d"] - .iter() - .map(|s| double_hash_leaf("recv:", s.as_bytes())) - .collect(); - let root = build_merkle_tree(&leaves); - for i in 0..leaves.len() { - let proof = merkle_proof(&leaves, i); - assert!(merkle_verify(&proof, root, leaves[i]), "leaf {i}"); - } - } - - #[test] - fn merkle_proof_rejects_wrong_leaf() { - let leaves: Vec<[u8; 32]> = (0..4u8).map(|i| keccak256(&[i])).collect(); - let root = build_merkle_tree(&leaves); - let proof = merkle_proof(&leaves, 0); - assert!(!merkle_verify(&proof, root, keccak256(b"forged"))); - } - - #[test] - fn double_hash_leaf_matches_solidity() { - let value = keccak256(b"x123"); - let leaf = double_hash_leaf("identity", &value); - let mut inner = Vec::new(); - inner.extend_from_slice(b"identity"); - inner.extend_from_slice(&value); - let expected = keccak256(&keccak256(&inner)); - assert_eq!(leaf, expected); - } - - #[test] - fn double_hash_leaf_str_and_bytes_prefixes_agree() { - // The original call sites pass `"recv:"`, the jwks prover passed - // `b"recv:"` — both must hash identically now that they share one - // implementation. - assert_eq!( - double_hash_leaf("recv:", b"payload"), - double_hash_leaf(b"recv:".as_slice(), b"payload"), - ); - } - /// Regression: comment-uid encoding must match Solidity /// abi.encodePacked(platform, ":", resourceType, ":", resourceId). The /// expected hash is pinned by the Solidity known-vector test. diff --git a/crates/libid-tlsn/Cargo.toml b/crates/libid-tlsn/Cargo.toml index 3879cb86..4f203bef 100644 --- a/crates/libid-tlsn/Cargo.toml +++ b/crates/libid-tlsn/Cargo.toml @@ -26,6 +26,7 @@ publish = false http-body-util.workspace = true hyper.workspace = true hyper-util.workspace = true +libid-ceremony.workspace = true libid-transcript.workspace = true thiserror.workspace = true tlsn.workspace = true @@ -39,3 +40,6 @@ webpki-root-certs.workspace = true # The driver-task leak regression test builds its own runtime so # `Runtime::metrics().num_alive_tasks()` counts only tasks the crate spawned. tokio = { workspace = true, features = ["rt-multi-thread", "time", "macros"] } +# The attested-data tests build tlsn types directly. +rangeset = "0.4" +serde_json.workspace = true diff --git a/crates/libid-tlsn/src/attest.rs b/crates/libid-tlsn/src/attest.rs new file mode 100644 index 00000000..59651005 --- /dev/null +++ b/crates/libid-tlsn/src/attest.rs @@ -0,0 +1,666 @@ +//! Turn what a notarized session produced into the attested data a launch +//! profile pins. +//! +//! This is the only place tlsn's view of a transcript meets libID's. The +//! layering is deliberate: `libid-ceremony` owns the bytes and is publishable, +//! this crate owns the translation and is git-only because tlsn is. Nothing +//! above needs to know that a `RangeSet` exists. +//! +//! The byte layout itself is the profile's rather than the specification's; +//! `libid_ceremony::attestation` says under which requirement, and states each +//! rule it keeps in full. + +use libid_ceremony::attestation::{ + AttestedData, + DirectionBlock, + RangeCommitment, + RevealedRange, +}; +use tlsn::{ + hash::HashAlgId, + transcript::{ + Direction, + PartialTranscript, + TranscriptCommitment, + }, +}; + +/// One notarized session, as the notary observed it. +/// +/// Every field here is something the notary SAW: the transcript it helped +/// decrypt, the server name it authenticated against WebPKI, the commitments +/// the prover made inside the session, and the moment its own clock said the +/// session closed. That is the line REQ-COMMON-33 draws between what a notary +/// may sign and what it may not, and a type is how the line is kept -- a value +/// the notary was merely TOLD has no field to arrive in, so it cannot reach the +/// signed bytes by being appended to an argument list. +/// +/// It borrows rather than owns. The party that ran the session already holds +/// every one of these, and copying a whole transcript across in order to +/// describe it would double the peak memory of a notarization to say nothing +/// new. `Copy` for the same reason: handing the same view to two calls should +/// not mean restating it. +/// +/// Deliberately not `Debug`. Printing one prints the revealed transcript -- +/// the prover's request with its credential framing around it -- into whatever +/// log line was being written at the time. +#[derive(Clone, Copy)] +pub struct ObservedSession<'a> { + /// The transcript with the prover's revealed ranges opened and the rest + /// still closed. Both directions and both signed lengths are read from this + /// one value, so there is no pair of lengths that can disagree with the + /// ranges they bound. + pub transcript: &'a PartialTranscript, + /// The DNS name the notary authenticated, which the caller takes from + /// `ServerName::Dns`. + /// + /// It arrives as a string rather than as tlsn's name type so this mapping + /// stays testable and so nothing here depends on how upstream models a + /// server name. It reaches the record as a signed field rather than as a + /// transcript range because the transcript carries the authority only in a + /// prover-composed `Host` header, which says nothing about which server + /// answered (REQ-COMMON-21, REQ-COMMON-21A). + pub authority: &'a str, + /// Every commitment the session produced, both directions together, in + /// whatever order the prover made them. + /// [`AttestedData::from_observed`] splits them by direction and sorts + /// them by offset, so a caller passes on + /// what it was handed rather than pre-sorting a list the format reorders + /// anyway. + pub commitments: &'a [TranscriptCommitment], + /// The notary's OWN clock reading when the session completed. + /// + /// Never the prover's, never a response header, never any other party's: + /// the verifier's freshness window is measured from this, so a reading the + /// observed party could choose would be a window it could choose. It is a + /// field rather than a call to the clock here so a test can pin it; the + /// caller must pass its own. + pub created_at: u64, +} + +#[derive(Debug, thiserror::Error)] +pub enum AttestError { + #[error( + "a commitment uses {0:?}, but REQ-COMMON-38 pins SHA-256 for launch profiles" + )] + WrongCommitmentAlgorithm(HashAlgId), + #[error("a commitment covers {0} disjoint ranges; the format carries one range per commitment")] + DisjointCommitment(usize), + #[error("a commitment hash is {0} bytes, not 32")] + BadCommitmentLength(usize), + #[error("transcript offset {0} does not fit the format's 32-bit field")] + OffsetTooLarge(usize), +} + +fn u32_of(value: usize) -> Result { + u32::try_from(value).map_err(|_| AttestError::OffsetTooLarge(value)) +} + +/// One direction of an [`ObservedSession`], which is what a [`DirectionBlock`] +/// is built from. +/// +/// A pair rather than two arguments, for the reason the session is a struct +/// rather than four: a transcript and a commitment list must come from the SAME +/// session or the block describes bytes nobody observed together. Carrying the +/// whole session makes that pairing unspellable-wrong rather than merely +/// uncommon, and leaves the direction as the only thing a caller chooses. +#[derive(Clone, Copy)] +pub struct ObservedDirection<'a> { + /// The session both directions are read from. + pub session: ObservedSession<'a>, + /// Which of its two directions this block covers. + pub direction: Direction, +} + +/// Building a `libid-ceremony` record out of what this crate observed. +/// +/// A trait, because both records belong to `libid-ceremony`, which is on the +/// release job's publish list and so must never name a tlsn type -- `tlsn` is +/// an unpublished git dependency, and a crate that names it cannot go to +/// crates.io at all. That is what keeps an inherent `impl` for either record +/// out of this crate. A LOCAL trait can, and may be +/// implemented for any type at all, so each constructor lands on the type it +/// constructs and every call site names what is being built before it names +/// what it is built from. +/// +/// Two implementors, and the pair is the layering: an [`AttestedData`] is a +/// header plus two [`DirectionBlock`]s, and its impl below is written that way +/// rather than inlining the direction walk twice. +/// +/// It is not an abstraction over records and no third implementor is expected. +/// It is the way to put a constructor where coherence would otherwise refuse +/// one. Bring it into scope to use it, as any extension trait is brought in. +pub trait FromObserved: Sized { + /// The record `source` describes, or the reason the format cannot describe + /// it. + fn from_observed(source: Source) -> Result; +} + +impl FromObserved> for AttestedData { + /// The record of a session, in the layout the launch profiles pin. + /// + /// Section 9.1 of ceremony-common is attestation verification and its fee; + /// it fixes no byte of this. REQ-COMMON-18 leaves the format to the profile + /// author, which is why `libid_ceremony::attestation` is the definition + /// rather than a reading of one. + /// + /// The four values this reads were never four unrelated things: they are + /// four readings of ONE session, which a caller previously had to keep in + /// step by hand across an argument list. + /// + /// The notary places nothing here that it derived by applying a profile + /// rule -- no handle, no account identifier, no client identifier, no chain + /// address (REQ-COMMON-33). Every such value is already derivable from the + /// revealed ranges, a second signed copy can disagree with the bytes it + /// came from, and producing one would make the Notary Service decide + /// something profile-specific. What is signed is what [`ObservedSession`] + /// holds, in the order the record declares it. + /// + /// This fails only where the session cannot be described by the format at + /// all: an offset past its 32-bit field, a commitment under the wrong hash, + /// a commitment over disjoint ranges, a commitment hash that is not 32 + /// bytes. It judges nothing else. Whether the + /// ranges tile, whether the request carries exactly one credential header + /// -- those are the Platform Verifier's decision and the client's dry run, + /// and refusing here would only withhold a session the notary really did + /// observe. + fn from_observed(session: ObservedSession<'_>) -> Result { + let of = |direction| ObservedDirection { session, direction }; + Ok(AttestedData { + authority_id: AttestedData::authority_id_of(session.authority), + created_at: session.created_at, + sent_transcript_length: u32_of(session.transcript.len_sent())?, + recv_transcript_length: u32_of(session.transcript.len_received())?, + sent: DirectionBlock::from_observed(of(Direction::Sent))?, + received: DirectionBlock::from_observed(of(Direction::Received))?, + }) + } +} + +impl FromObserved> for DirectionBlock { + /// One direction's revealed runs and its commitments, both in ascending + /// start order. + /// + /// Written once and asked twice rather than written twice and compared: the + /// two directions differ only in which pair of accessors they read, and a + /// second copy of this loop is a second place for the offset arithmetic to + /// drift. + fn from_observed(source: ObservedDirection<'_>) -> Result { + let (authed, data) = match source.direction { + Direction::Sent => ( + source.session.transcript.sent_authed(), + source.session.transcript.sent_unsafe(), + ), + Direction::Received => ( + source.session.transcript.received_authed(), + source.session.transcript.received_unsafe(), + ), + }; + + // One entry per revealed range, in ascending start order, each carrying + // where it sat and what it held. Revealed bytes signed without their + // offsets say that some bytes were disclosed but not where they sat, which + // is not enough to tile a transcript. The end is the bytes' own length, so + // it is not written down twice. + let mut revealed = Vec::new(); + for range in authed.iter() { + // Still checked, even though only `start` is encoded: a range whose end + // does not fit is a transcript this record cannot describe. + u32_of(range.end)?; + revealed.push(RevealedRange { + start: u32_of(range.start)?, + bytes: data[range.clone()].to_vec(), + }); + } + + let mut out = Vec::new(); + for commitment in source.session.commitments { + // The enum is non-exhaustive upstream, so an unknown commitment kind + // is skipped rather than assumed to be a hash. + let TranscriptCommitment::Hash(hash) = commitment else { + continue; + }; + if hash.direction != source.direction { + continue; + } + // The notarization library defaults to BLAKE3 while the Proving Circuit + // computes SHA-256, so a prover left on library defaults produces + // commitments the circuit cannot open (REQ-COMMON-38). + if hash.hash.alg != HashAlgId::SHA256 { + return Err(AttestError::WrongCommitmentAlgorithm(hash.hash.alg)); + } + + // A `RangeSet` may be disjoint, but the format pairs one commitment + // value with one offset pair. A hash over a union cannot be split + // between two entries without inventing a value for each. + let ranges: Vec<_> = hash.idx.iter().collect(); + let [range] = ranges.as_slice() else { + return Err(AttestError::DisjointCommitment(ranges.len())); + }; + + let value = hash.hash.value.as_bytes(); + let value: [u8; 32] = value + .try_into() + .map_err(|_| AttestError::BadCommitmentLength(value.len()))?; + + out.push(RangeCommitment { + start: u32_of(range.start)?, + end: u32_of(range.end)?, + commitment: value, + }); + } + out.sort_by_key(|c| c.start); + + Ok(DirectionBlock { + revealed, + commitments: out, + }) + } +} + +#[cfg(test)] +mod tests { + /// The Platform Verifier requires the revealed ranges and the commitments + /// to account for the signed length exactly. That is its rule to enforce, + /// not ours -- but a layout that cannot satisfy it produces attestations no + /// verifier accepts, so it is worth asserting here on the way out. + fn assert_tiles(block: &libid_ceremony::DirectionBlock, length: u32) { + let mut spans: Vec<(u32, u32)> = block + .revealed + .iter() + .map(|r| (r.start, r.start + r.bytes.len() as u32)) + .chain(block.commitments.iter().map(|c| (c.start, c.end))) + .collect(); + spans.sort_unstable(); + let mut at = 0u32; + for (start, end) in spans { + assert_eq!(start, at, "gap or overlap before {start}"); + at = end; + } + assert_eq!(at, length, "the spans do not reach the signed length"); + } + + use super::*; + use libid_transcript::ceremony::{ + profiles, + Layout, + }; + use rangeset::set::RangeSet; + use tlsn::{ + hash::TypedHash, + transcript::{ + hash::PlaintextHash, + Transcript, + TranscriptCommitment, + }, + }; + + const SENT: &[u8] = b"GET /2/users/me HTTP/1.1\r\nauthorization: Bearer TOK\r\n\r\n"; + const RECV: &[u8] = b"HTTP/1.1 200 OK\r\n\r\n{\"id\":\"7\"}"; + + /// The session as the notary saw it, for the tests that vary only the + /// transcript and the commitments over it. + fn observed<'a>( + transcript: &'a PartialTranscript, + commitments: &'a [TranscriptCommitment], + ) -> ObservedSession<'a> { + observed_at(transcript, commitments, "api.x.com") + } + + /// The same, for the one test that varies the authority. + fn observed_at<'a>( + transcript: &'a PartialTranscript, + commitments: &'a [TranscriptCommitment], + authority: &'a str, + ) -> ObservedSession<'a> { + ObservedSession { + transcript, + authority, + commitments, + created_at: 1_770_000_000, + } + } + + /// `Hash` has no public constructor, so build it the way upstream + /// deserializes it: a sequence of bytes. + fn hash32(byte: u8) -> TypedHash { + TypedHash { + alg: HashAlgId::SHA256, + value: serde_json::from_value(serde_json::json!(vec![byte; 32])).unwrap(), + } + } + + /// Reveal everything except the bearer, and commit the bearer -- the shape + /// an identity session actually produces. + fn session() -> (PartialTranscript, Vec) { + let bearer = 48..51; // "TOK" + let transcript = Transcript::new(SENT, RECV); + let sent_revealed = RangeSet::from(vec![0..bearer.start, bearer.end..SENT.len()]); + let partial = transcript.to_partial(sent_revealed, RangeSet::from(0..RECV.len())); + let commitments = vec![TranscriptCommitment::Hash(PlaintextHash { + direction: Direction::Sent, + idx: RangeSet::from(bearer), + hash: hash32(7), + })]; + (partial, commitments) + } + + #[test] + fn carries_the_signed_transcript_lengths() { + // These appear nowhere in any signed field today, and REQ-COMMON-36 + // makes them the only source of the length the coverage check uses. + let (partial, commitments) = session(); + let data = AttestedData::from_observed(observed(&partial, &commitments)).unwrap(); + assert_eq!(data.sent_transcript_length, SENT.len() as u32); + assert_eq!(data.recv_transcript_length, RECV.len() as u32); + } + + #[test] + fn encodes_to_the_length_its_own_fields_imply() { + let (partial, commitments) = session(); + let data = AttestedData::from_observed(observed(&partial, &commitments)).unwrap(); + let encoded = data.encode().unwrap(); + + // No decoder here to round-trip against: decoding is the chain's and + // the client's. What stays checkable on this side is that every byte + // the fields describe is present, which is the property the layout + // gives the forward-parsing decoder something to walk. + let mut want = libid_ceremony::attestation::HEADER_LEN; + for d in [&data.sent, &data.received] { + want += 8 + 8; // one eight-byte count per list + for r in &d.revealed { + want += 4 + 8 + r.bytes.len(); // start, byte length, bytes + } + want += d.commitments.len() * (4 + 4 + 32); // start, end, commitment + } + assert_eq!(encoded.len(), want); + } + + #[test] + fn tiles_the_request_exactly() { + // The whole point: what the notary emits must satisfy the coverage + // check the Platform Verifier runs, or no genuine session ever passes. + let (partial, commitments) = session(); + let data = AttestedData::from_observed(observed(&partial, &commitments)).unwrap(); + assert_tiles(&data.sent, data.sent_transcript_length); + } + + /// The fixture's own offsets, which nothing else here reads. + /// + /// Every other test built on `session()` asserts tiling, the signed + /// lengths, the authority or the clock -- all of which hold just as well + /// when the committed range is the wrong three bytes. They WERE the wrong + /// three bytes: `45..48` is `er `, the tail of the header name, so the + /// fixture committed part of `authorization: Bearer` and revealed `TOK`, + /// while its comment claimed the opposite. A fixture that reveals the + /// credential is not the shape an identity session produces, and the + /// tests that lean on it were describing a session no prover should run. + #[test] + fn the_fixture_commits_the_credential_and_reveals_none_of_it() { + let (partial, commitments) = session(); + let data = AttestedData::from_observed(observed(&partial, &commitments)).unwrap(); + + let token = SENT + .windows(3) + .position(|w| w == b"TOK") + .expect("the fixture request carries a bearer"); + + let [committed] = data.sent.commitments.as_slice() else { + panic!("an identity request commits exactly one range, the credential") + }; + assert_eq!( + (committed.start as usize, committed.end as usize), + (token, token + 3), + "the committed range must be the credential, not the bytes beside it" + ); + + for range in &data.sent.revealed { + let start = range.start as usize; + assert!( + start + range.bytes.len() <= token || start >= token + 3, + "a revealed range covers the credential this session is meant to hide" + ); + } + } + + #[test] + fn authority_is_the_authenticated_server_name() { + let (partial, commitments) = session(); + let data = AttestedData::from_observed(observed(&partial, &commitments)).unwrap(); + assert_eq!( + data.authority_id, + AttestedData::authority_id_of("api.x.com") + ); + // And it is NOT taken from a Host header the prover composed. + assert_ne!( + data.authority_id, + AttestedData::authority_id_of("evil.example") + ); + } + + #[test] + fn the_authority_is_canonicalized_on_the_way_into_the_record() { + // The rule used to be kept here, by this call site remembering to + // lowercase. It now belongs to the constructor, so what this asserts is + // that the record still comes out canonical when the caller does not. + let (partial, commitments) = session(); + let data = + AttestedData::from_observed(observed_at(&partial, &commitments, "API.X.com")) + .unwrap(); + assert_eq!( + data.authority_id, + AttestedData::authority_id_of("api.x.com") + ); + } + + #[test] + fn the_record_names_the_authority_this_session_carried() { + // Every other test here observes `api.x.com`, so a record that ignored + // the session and hardcoded that host would satisfy all of them -- + // including the two beside this one, whose names promise otherwise. + // This observes a different host, so only a record that reads the + // session can pass. + let (partial, commitments) = session(); + let data = AttestedData::from_observed(observed_at( + &partial, + &commitments, + "api.github.com", + )) + .unwrap(); + assert_eq!( + data.authority_id, + AttestedData::authority_id_of("api.github.com") + ); + assert_ne!( + data.authority_id, + AttestedData::authority_id_of("api.x.com") + ); + } + + #[test] + fn the_notarys_clock_reading_reaches_the_record() { + // The verifier's freshness window is measured from this field, so a + // record that dropped it would be judged on a time nobody observed. + // Nothing asserted it: `created_at: 0` passed the entire suite. + let (partial, commitments) = session(); + let mut session_view = observed(&partial, &commitments); + session_view.created_at = 1_800_000_123; + let data = AttestedData::from_observed(session_view).unwrap(); + assert_eq!(data.created_at, 1_800_000_123); + } + + #[test] + fn refuses_a_blake3_commitment() { + // The notarization library's default. The circuit computes SHA-256, so + // a prover left on defaults produces commitments it cannot open. + let (partial, mut commitments) = session(); + let TranscriptCommitment::Hash(ref mut h) = commitments[0] else { + unreachable!() + }; + h.hash.alg = HashAlgId::BLAKE3; + assert!(matches!( + AttestedData::from_observed(observed(&partial, &commitments)), + Err(AttestError::WrongCommitmentAlgorithm(_)) + )); + } + + #[test] + fn refuses_a_commitment_over_disjoint_ranges() { + // The format pairs one commitment value with one offset pair; a hash + // over a union cannot be split without inventing a value for each. + let (partial, _) = session(); + let commitments = vec![TranscriptCommitment::Hash(PlaintextHash { + direction: Direction::Sent, + idx: RangeSet::from(vec![10..12, 20..22]), + hash: hash32(7), + })]; + assert!(matches!( + AttestedData::from_observed(observed(&partial, &commitments)), + Err(AttestError::DisjointCommitment(2)) + )); + } + + #[test] + fn places_no_profile_derived_value_in_the_signed_bytes() { + // REQ-COMMON-33: the Notary Service decides nothing profile-specific, + // so the notary places no value it obtained by applying a profile rule + // -- no handle, no account identifier, no client identifier, no chain + // address. Every one is already derivable + // from the revealed ranges, and a second signed representation can + // disagree with the bytes it was taken from. + // + // Tested structurally: two sessions whose responses name different + // accounts must produce IDENTICAL header bytes. If any identity field + // were signed into the header, it would differ here. + let other_recv: &[u8] = b"HTTP/1.1 200 OK\r\n\r\n{\"id\":\"9\"}"; + assert_eq!( + other_recv.len(), + RECV.len(), + "the two responses must be the same length" + ); + + let bearer = 48..51; + let sent_revealed = RangeSet::from(vec![0..bearer.start, bearer.end..SENT.len()]); + let commitments = vec![TranscriptCommitment::Hash(PlaintextHash { + direction: Direction::Sent, + idx: RangeSet::from(bearer), + hash: hash32(7), + })]; + + let mut headers = Vec::new(); + for recv in [RECV, other_recv] { + let partial = Transcript::new(SENT, recv) + .to_partial(sent_revealed.clone(), RangeSet::from(0..recv.len())); + let data = + AttestedData::from_observed(observed(&partial, &commitments)).unwrap(); + headers.push( + data.encode().unwrap()[..libid_ceremony::attestation::HEADER_LEN] + .to_vec(), + ); + } + assert_eq!( + headers[0], headers[1], + "an identity field leaked into the signed header" + ); + + // And the accounts really are different, so the test is not vacuous. + let a = Transcript::new(SENT, RECV) + .to_partial(sent_revealed.clone(), RangeSet::from(0..RECV.len())); + let b = Transcript::new(SENT, other_recv) + .to_partial(sent_revealed, RangeSet::from(0..other_recv.len())); + assert_ne!( + AttestedData::from_observed(observed(&a, &commitments)) + .unwrap() + .encode() + .unwrap(), + AttestedData::from_observed(observed(&b, &commitments)) + .unwrap() + .encode() + .unwrap(), + "the two sessions must differ somewhere -- in the revealed range" + ); + } + + // --- The layouts the prover selects must satisfy the verifier ---------- + + /// Build a session from a real transcript plus the layout the ceremony + /// selects for it, and check the attested data it produces TILES. + /// + /// This is the property no unit test on either side reaches on its own. The + /// verifier demands exact coverage; the prover chooses the ranges. If they + /// disagree, every check passes in isolation and no honest ceremony + /// verifies -- a liveness failure that only shows up in an end-to-end run. + fn round_trip( + sent: &[u8], + recv: &[u8], + sent_layout: &Layout, + recv_layout: &Layout, + ) -> AttestedData { + let transcript = Transcript::new(sent, recv); + let partial = transcript.to_partial( + RangeSet::from(sent_layout.reveal.clone()), + RangeSet::from(recv_layout.reveal.clone()), + ); + let mut commitments = Vec::new(); + for (direction, l) in [ + (Direction::Sent, sent_layout), + (Direction::Received, recv_layout), + ] { + for range in &l.commit { + commitments.push(TranscriptCommitment::Hash(PlaintextHash { + direction, + idx: RangeSet::from(range.clone()), + hash: hash32(1), + })); + } + } + AttestedData::from_observed(observed(&partial, &commitments)).unwrap() + } + + #[test] + fn the_identity_session_layout_tiles_both_directions() { + let sent: &[u8] = b"GET /2/users/me HTTP/1.1\r\nhost: api.x.com\r\nauthorization: Bearer TOKENVALUE\r\nconnection: close\r\n\r\n"; + let recv: &[u8] = b"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\n\r\n{\"data\":{\"id\":\"2244994945\",\"name\":\"Al\",\"username\":\"alice\"}}"; + + let s = Layout::identity_request(sent).unwrap(); + let r = Layout::identity_response(recv, &profiles::X.identity.unwrap()).unwrap(); + let data = round_trip(sent, recv, &s, &r); + assert_tiles(&data.sent, data.sent_transcript_length); + assert_tiles(&data.received, data.recv_transcript_length); + + // And exactly one credential is hidden in the request, which is what + // ties the framed range to the one the circuit opens. + assert_eq!(data.sent.commitments.len(), 1); + } + + #[test] + fn the_x_token_session_layout_tiles() { + let sent: &[u8] = b"POST /2/oauth2/token HTTP/1.1\r\nhost: api.x.com\r\n\r\ngrant_type=authorization_code&client_id=abc&code_verifier=xyz"; + let recv: &[u8] = b"HTTP/1.1 200 OK\r\n\r\n{\"access_token\":\"SECRETBEARER\"}"; + + let s = Layout::token_request(sent, &profiles::X.token.unwrap()).unwrap(); + let r = Layout::token_response(recv).unwrap(); + let data = round_trip(sent, recv, &s, &r); + assert_tiles(&data.sent, data.sent_transcript_length); + // X reveals its token request whole, so the verifier can see the head + // boundary and locate the body by the framing the server parsed. + assert!(data.sent.commitments.is_empty()); + assert_eq!(data.sent.revealed.len(), 1); + assert_eq!(data.sent.revealed[0].start, 0); + } + + #[test] + fn the_github_exchange_layout_commits_a_suffix() { + let sent: &[u8] = b"POST /login/oauth/access_token HTTP/1.1\r\nhost: github.com\r\n\r\nclient_id=Iv1.x&code=abc&code_verifier=xyz&client_secret=deadbeef"; + let recv: &[u8] = b"HTTP/1.1 200 OK\r\n\r\n{\"access_token\":\"gho_SECRET\"}"; + + let s = Layout::token_request(sent, &profiles::GITHUB.token.unwrap()).unwrap(); + let r = Layout::token_response(recv).unwrap(); + let data = round_trip(sent, recv, &s, &r); + assert_tiles(&data.sent, data.sent_transcript_length); + assert_eq!(data.sent.revealed.len(), 1); + assert_eq!(data.sent.commitments.len(), 1); + // Ordered last, so the commitment reaches the transcript end. + assert_eq!(data.sent.commitments[0].end, data.sent_transcript_length); + } +} diff --git a/crates/libid-tlsn/src/lib.rs b/crates/libid-tlsn/src/lib.rs index 1fb25ee1..5cf0d43d 100644 --- a/crates/libid-tlsn/src/lib.rs +++ b/crates/libid-tlsn/src/lib.rs @@ -60,16 +60,24 @@ mod session; +pub use http_body_util::Full as HttpBody; +/// The request `prover_generic` sends, and the pieces to build one. +/// +/// Re-exported so a caller states its own headers without taking a direct +/// dependency on the HTTP crates this uses. A notarized request is bytes a +/// verifier compares against a profile, so the party that knows the profile +/// writes them -- this library injects none. +pub use hyper::{ + body::Bytes, + Request as HttpRequest, +}; pub use session::{ - extract_handshake_data, - prover, prover_generic, root_store, verifier, - HttpRequestSpec, + CommitmentOpening, ProverResult, ProverStep, - UserInfoParams, VerifierResult, MAX_RECV_DATA, MAX_SENT_DATA, @@ -100,3 +108,15 @@ pub enum Error { /// Result alias for this crate. pub type Result = std::result::Result; + +/// Which direction of a transcript a commitment covers. +/// +/// Re-exported because [`CommitmentOpening`] carries one, and a caller sorting +/// its openings would otherwise have to depend on tlsn directly -- on an alpha +/// tag, for one enum. With this, every field a caller READS off a +/// `ProverResult` is nameable without tlsn -- `handshake` already was, from +/// libid-transcript. Only `secrets` still hands back a tlsn type, and anything +/// doing its own proof construction with it depends on tlsn regardless. +pub use tlsn::transcript::Direction; + +pub mod attest; diff --git a/crates/libid-tlsn/src/session.rs b/crates/libid-tlsn/src/session.rs index 5c50d130..93f52b54 100644 --- a/crates/libid-tlsn/src/session.rs +++ b/crates/libid-tlsn/src/session.rs @@ -6,7 +6,14 @@ use hyper::{ StatusCode, }; use hyper_util::rt::TokioIo; -use std::future::IntoFuture; +use libid_transcript::ceremony::Layout; +use std::{ + future::IntoFuture, + sync::atomic::{ + AtomicBool, + Ordering, + }, +}; use tlsn::{ attestation::{ request::{ @@ -25,18 +32,22 @@ use tlsn::{ verifier::VerifierConfig, }, connection::{ - CertBinding, - CertBindingV1_2, HandshakeData, ServerName, }, hash::HashAlgId, prover::ProverOutput, transcript::{ + ContentType, + Direction, PartialTranscript, + Record, TlsTranscript, + Transcript, TranscriptCommitConfig, TranscriptCommitment, + TranscriptCommitmentKind, + TranscriptSecret, }, verifier::{ VerifierCommitStart, @@ -68,19 +79,11 @@ use tracing::{ instrument, }; -use libid_transcript::{ - find_notary_reveal_ranges, - find_presentation_commit_ranges, - TlsHandshakeData, -}; - use crate::{ Error, Result, }; -use std::ops::Range; - /// Maximum bytes the prover may send in the MPC-TLS session (4 KB). The /// verifier rejects sessions configured above this. pub const MAX_SENT_DATA: usize = 1 << 12; @@ -140,19 +143,6 @@ fn driver_finished_early( Error::MpcTlsFailed { detail } } -/// Sub-steps within the MPC-TLS prover phase, reported via callback. -#[derive(Debug, Clone, Copy)] -pub enum ProverStep { - /// MPC-TLS session established with notary. - MpcSetupComplete, - /// TLS handshake completed via MPC. - TlsHandshakeComplete, - /// Platform user data fetched over MPC-TLS. - PlatformDataFetched, - /// MPC proof finalized. - MpcProofFinalized, -} - /// The WebPKI root store both sides validate server certificates against. pub fn root_store() -> RootCertStore { RootCertStore { @@ -163,42 +153,156 @@ pub fn root_store() -> RootCertStore { } } -/// Extract TLS handshake data from a TLS transcript. -pub fn extract_handshake_data( - tls_transcript: &TlsTranscript, -) -> Result { - let CertBinding::V1_2(CertBindingV1_2 { - client_random, - server_random, - server_ephemeral_key, - }) = tls_transcript.certificate_binding() - else { - return Err(Error::UnsupportedTlsVersion { - detail: "expected TLS 1.2".into(), - }); - }; +/// The application data one direction of a finished session actually carried. +/// +/// The same sum the verifier makes to decide a transcript's true length, taken +/// here so a commitment can be measured against it before anything allocates +/// over it. +fn application_data_len(records: &[Record]) -> usize { + records + .iter() + .filter(|record| record.typ == ContentType::ApplicationData) + .map(|record| record.ciphertext.len()) + .sum() +} - Ok(TlsHandshakeData { - client_random: *client_random, - server_random: *server_random, - server_ephemeral_key: server_ephemeral_key.key.clone(), +/// The first committed range that runs past the direction it names, if any. +/// +/// A prover states its commitments as bare offsets, and NOTHING upstream +/// bounds them against the session: `TranscriptCommitConfigBuilder` refuses an +/// out-of-range commitment, but `ProveRequest` derives its deserializer with no +/// validation, so a prover that writes its own wire bytes never runs that +/// check. On this side each committed range is allocated over and then used to +/// index the transcript's plaintext, so an oversized range is an allocation the +/// session never justified and an out-of-range one indexes past the end. +/// +/// Separate from the session so it can be tested without one: the shapes worth +/// testing are all a prover's arithmetic, not a notarization. +/// Each commitment is given as its direction and the end of the range it +/// covers, which is the only part of it that can run past the session; an +/// empty range set has no end and cannot. +fn commitment_past_the_session( + commitments: impl IntoIterator)>, + sent_len: usize, + recv_len: usize, +) -> Option<(Direction, usize, usize)> { + commitments.into_iter().find_map(|(direction, end)| { + let len = match direction { + Direction::Sent => sent_len, + Direction::Received => recv_len, + }; + end.filter(|end| *end > len) + .map(|end| (direction, end, len)) + }) +} + +/// What this session commits to, and under which hash. +/// +/// Split out of `prover_generic` so the algorithm is assertable without an +/// MPC session: the config is the only place the choice is made, and it is +/// made here rather than by a caller, because `select_layout` hands back +/// ranges and no algorithm. +fn transcript_commit_config( + transcript: &Transcript, + sent: &[std::ops::Range], + recv: &[std::ops::Range], +) -> Result { + let mut builder = TranscriptCommitConfig::builder(transcript); + // REQ-COMMON-38. The notarization library defaults to BLAKE3 and the + // Proving Circuit computes SHA-256, so a prover left on that default + // produces commitments the circuit cannot open -- and which + // `AttestedData::from_observed` refuses, after a whole MPC-TLS session has + // been paid for. It is set here because `select_layout` hands back ranges + // and no algorithm, so no caller can correct it. + builder.default_kind(TranscriptCommitmentKind::Hash { + alg: HashAlgId::SHA256, + }); + for range in sent { + builder + .commit_sent(range) + .map_err(|e| Error::MpcTlsFailed { + detail: format!("commit sent: {e}"), + })?; + } + for range in recv { + builder + .commit_recv(range) + .map_err(|e| Error::MpcTlsFailed { + detail: format!("commit recv: {e}"), + })?; + } + builder.build().map_err(|e| Error::MpcTlsFailed { + detail: format!("transcript commit config: {e}"), }) } +/// A phase boundary of a prover session, in the order they occur. +/// +/// Reported through `on_progress` so a caller can drive something typed off +/// them -- a progress indicator for a browser waiting out a server-side +/// exchange, which takes seconds. The same four boundaries are `tracing` +/// events for operators; this is the interface, because log text is not one. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum ProverStep { + /// MPC-TLS session established with the notary. + MpcSetupComplete, + /// TLS handshake completed through it. + TlsHandshakeComplete, + /// The platform answered. + PlatformDataFetched, + /// The proof is finalised and the session can be closed. + MpcProofFinalized, +} + +impl ProverStep { + /// How far through the session this boundary is, in `(0, 1]`. + /// + /// The phases are not equal in wall-clock time -- setup and proving + /// dominate -- so this is a position, not an estimate of remaining time. + pub fn fraction(self) -> f32 { + match self { + Self::MpcSetupComplete => 0.25, + Self::TlsHandshakeComplete => 0.5, + Self::PlatformDataFetched => 0.75, + Self::MpcProofFinalized => 1.0, + } + } +} + +/// The blinder that opens one commitment this session made. +/// +/// A committed range is a hash of the plaintext and this value, so the party +/// that later proves something about those bytes needs both. The prover is the +/// only party that ever holds it: the notary sees the commitment, never the +/// opening, which is the whole point of committing rather than revealing. +/// +/// It is surfaced because a caller that commits a credential must hand the +/// opening on to whoever proves over it — the browser, for a bearer this +/// service exchanged. Without it the caller holds an attestation nobody can +/// build a proof against. +#[derive(Clone)] +pub struct CommitmentOpening { + /// Which direction of the transcript the committed range belongs to. + pub direction: Direction, + /// The committed ranges, in the same shape the layout stated them, so a + /// caller can match an opening against the range it asked to commit + /// without converting anything. + pub ranges: Vec>, + /// The blinder itself. Sixteen bytes, as the commitment scheme fixes. + pub blinder: Vec, +} + /// Result from the MPC-TLS prover. pub struct ProverResult { /// The HTTP response body from the platform API (decoded, headers stripped). pub response_body: Vec, - /// Revealed recv segments — the exact bytes the prover disclosed to the notary. - /// The notary hashes each segment as `double_hash_leaf("recv:", segment)` to - /// build the `recv:` Merkle leaves of the EvmProof. One entry per revealed range. - pub recv_segments: Vec>, - /// The attestation request to send to the notary. - pub request: Request, /// The TLS secrets for proof construction. pub secrets: Secrets, - /// Extracted TLS handshake data. - pub handshake: TlsHandshakeData, + /// One opening per commitment this session made, in no particular order: + /// tlsn hands the commitments back from a set, so a caller finds its + /// opening by the `ranges` it covers rather than by position. Empty when + /// the session committed nothing. + pub commitment_openings: Vec, /// The recovered I/O stream after MPC-TLS completes. pub recovered_io: T, } @@ -217,119 +321,93 @@ pub struct VerifierResult { pub recovered_io: T, } -/// The HTTPS request the prover performs over MPC-TLS. -#[derive(Debug, Clone, Copy)] -pub struct HttpRequestSpec<'a> { - /// API host (SNI and Host header), e.g. `"api.x.com"`. - pub api_host: &'a str, - /// Request path, e.g. `"/2/users/me"`. - pub path: &'a str, - /// HTTP method, e.g. `"GET"`. - pub method: &'a str, - /// Optional request body; when set, `Content-Type: application/json` is - /// added. - pub body: Option<&'a str>, - /// Optional bearer token, sent as `Authorization: Bearer `. `None` - /// for unauthenticated endpoints (e.g. a public JWKS fetch). - pub bearer_token: Option<&'a str>, - /// User-Agent header value. - pub user_agent: &'a str, -} - -/// Parameters for the user-info prover flow ([`prover`]). -#[derive(Debug, Clone, Copy)] -pub struct UserInfoParams<'a> { - /// API host (SNI and Host header). - pub api_host: &'a str, - /// Path of the user-info endpoint, e.g. `"/2/users/me"`. - pub user_info_path: &'a str, - /// JSON field holding the handle; its `"field":"value"` snippet is - /// revealed. - pub username_field: &'a str, - /// Optional immutable-id field: `(field_name, quoted)`. Quoted (X): - /// `"id":""`; bare (GitHub): `"id":,`. Revealed when present so - /// the backend can build idPath. - pub id_field: Option<(&'a str, bool)>, - /// User-Agent header value. - pub user_agent: &'a str, -} - -/// Run the MPC-TLS prover to fetch user data from a platform API, revealing -/// the username snippet (and the id snippet when configured). -#[instrument(skip_all, fields(api_host = params.api_host))] -pub async fn prover( - socket: T, - access_token: &str, - params: &UserInfoParams<'_>, - on_progress: F, -) -> Result> -where - T: AsyncWrite + AsyncRead + Send + Unpin + 'static, - F: Fn(ProverStep), -{ - let username_field = params.username_field; - let id_field = params.id_field; - prover_generic( - socket, - &HttpRequestSpec { - api_host: params.api_host, - path: params.user_info_path, - method: "GET", - body: None, - bearer_token: Some(access_token), - user_agent: params.user_agent, - }, - |recv| { - let mut ranges = - vec![ - libid_transcript::compute_field_snippet_range(recv, username_field) - .ok_or_else(|| Error::MpcTlsFailed { - detail: format!( - "username field '{}' not found in response body", - username_field - ), - })?, - ]; - // Also reveal the immutable id snippet so the backend can build idPath. - // Quoted (X): `"id":""`; bare (GitHub): `"id":,`. - if let Some((id_field, quoted)) = id_field { - if let Some(range) = - libid_transcript::compute_id_snippet_range(recv, id_field, quoted) - { - ranges.push(range); - } - } - Ok(ranges) - }, - on_progress, - ) - .await +/// Rewrite the request's URI to origin-form before it goes on the wire. +/// +/// `hyper::client::conn::http1` writes the request-target exactly as the +/// `Uri` displays (`Client::encode` in `proto/h1/role.rs`); only hyper-util's +/// pooled client rewrites it, and [`prover_generic`] drives a raw connection. +/// A caller hands us an absolute URI because that is where the host comes +/// from, so left alone the request line would read +/// `GET https://www.googleapis.com/oauth2/v3/certs HTTP/1.1` -- valid HTTP, +/// but not the origin-form line the Platform Verifiers and `GoogleJwtRoots` +/// pin, so the session would be refused on chain. +/// +/// Only the URI changes: the `Host` header the caller set stays as it is. +fn origin_form(request: &mut hyper::Request) -> Result<()> { + let target = match request.uri().path_and_query() { + Some(path) => { + let mut parts = hyper::http::uri::Parts::default(); + parts.path_and_query = Some(path.clone()); + hyper::Uri::from_parts(parts).map_err(|e| Error::MpcTlsFailed { + detail: format!("origin-form request-target: {e}"), + })? + } + None => hyper::Uri::default(), + }; + *request.uri_mut() = target; + Ok(()) } /// Run the MPC-TLS prover with arbitrary API parameters. /// -/// The `compute_reveal_ranges` closure receives the full `recv` transcript -/// data after the HTTP exchange completes and must return the byte ranges -/// within `recv` to selectively disclose. Each range becomes a separate -/// Merkle leaf in the notary's transcript tree. To reveal the entire -/// received transcript (as a JWKS-style notary requires), return -/// `vec![0..recv.len()]`. +/// `select_layout` receives both complete transcripts once the HTTP exchange +/// finishes and returns, for each direction, what to reveal and what to commit. +/// The prover chooses that -- it is the party holding the session keys, and +/// nobody above it can decide on its behalf. +/// +/// A caller producing a ceremony attestation calls +/// `libid_transcript::ceremony` here and returns what it gives back: those +/// layouts derive each direction's commitments as the complement of its +/// reveals, so the direction tiles by construction, which is what the Platform +/// Verifier's coverage check demands. A caller doing something else -- the +/// JWKS session reads a public document and reveals all of it -- states its +/// own. +/// +/// Each revealed range is carried in the attested record with the offsets it +/// sat at, and each hidden run as one commitment; `libid_transcript::ceremony` +/// is what chooses them for a launch profile. +/// +/// # Following a session /// -/// Use [`libid_transcript::compute_field_reveal_range`] and friends inside -/// the closure to locate JSON field values in the response body. -#[instrument(skip_all, fields(api_host = request.api_host))] -pub async fn prover_generic( +/// This is slow -- setup and proving dominate -- so every phase boundary is +/// reported twice, to two different audiences. A `tracing` event inside this +/// function's span, for whoever reads the logs; and [`ProverStep`] through +/// `on_progress`, for a caller driving something typed off it. +/// +/// The browser has its own progress from the tlsn wasm prover and never +/// reaches this function. The caller this exists for is a server that +/// notarizes on someone's behalf -- the GitHub Token-Exchange Service, whose +/// HTTP caller waits out the whole session -- and which cannot report phases +/// by parsing log lines. +/// +/// The request's URI must be absolute -- the host names the server -- but the +/// wire carries the request-target in origin-form (`GET /path?query HTTP/1.1`), +/// which is the line every verifier pins. See [`origin_form`]. +#[instrument(skip_all)] +pub async fn prover_generic( socket: T, - request: &HttpRequestSpec<'_>, - compute_reveal_ranges: R, + mut request: hyper::Request>, + select_layout: S, on_progress: F, ) -> Result> where T: AsyncWrite + AsyncRead + Send + Unpin + 'static, + S: FnOnce(&[u8], &[u8]) -> Result<(Layout, Layout)>, F: Fn(ProverStep), - R: FnOnce(&[u8]) -> Result>>, { - let api_host = request.api_host; + // SNI and the TCP peer come from the request's own authority. A caller + // that set no host has not said which server it means to reach. + let api_host = request + .uri() + .host() + .ok_or_else(|| Error::MpcTlsFailed { + detail: "request URI carries no host".into(), + })? + .to_string(); + let api_host = api_host.as_str(); + let method = request.method().clone(); + let path = request.uri().path().to_string(); + origin_form(&mut request)?; let session = Session::new(socket.compat()); let (driver, mut handle) = session.split(); @@ -337,6 +415,11 @@ where // dropping this future — aborts the driver instead of detaching it. let mut driver_task = AbortOnDrop::new(tokio::spawn(driver)); + // Set once the session has run. Before that, the driver finishing means the + // connection died under the session; after, it means the peer closed the + // mux, which is how a session ends. + let established = AtomicBool::new(false); + let established = &established; let setup = async { info!("Setting up MPC-TLS"); let prover = handle @@ -361,6 +444,7 @@ where .map_err(|e| Error::MpcTlsFailed { detail: format!("commit: {e}"), })?; + info!("MPC-TLS setup complete"); on_progress(ProverStep::MpcSetupComplete); info!("Connecting to {} API", api_host); @@ -383,6 +467,7 @@ where .map_err(|e| Error::MpcTlsFailed { detail: format!("connect: {e}"), })?; + info!("TLS handshake complete"); on_progress(ProverStep::TlsHandshakeComplete); let prover_task = AbortOnDrop::new(tokio::spawn(prover.into_future())); @@ -396,41 +481,10 @@ where // exchange; the guard reaps it if the session bails out first. let _conn_task = AbortOnDrop::new(tokio::spawn(conn)); - let http_request = { - let mut builder = hyper::Request::builder() - .method(request.method) - .uri(request.path) - .header("Host", api_host) - .header("Connection", "close") - .header("Accept", "application/json") - .header("User-Agent", request.user_agent); - if let Some(token) = request.bearer_token { - builder = builder.header("Authorization", format!("Bearer {}", token)); - } - if request.body.is_some() { - builder = builder.header("Content-Type", "application/json"); - } - if let Some(post_body) = request.body { - builder - .body(http_body_util::Full::new(Bytes::from( - post_body.to_string(), - ))) - .map_err(|e| Error::MpcTlsFailed { - detail: format!("request build: {e}"), - })? - } else { - builder - .body(http_body_util::Full::new(Bytes::new())) - .map_err(|e| Error::MpcTlsFailed { - detail: format!("request build: {e}"), - })? - } - }; - - info!("Sending {} {}", request.method, request.path); + info!("Sending {method} {path}"); let response = sender - .send_request(http_request) + .send_request(request) .await .map_err(|e| Error::MpcTlsFailed { detail: format!("send request: {e}"), @@ -468,34 +522,24 @@ where let transcript = prover.transcript().clone(); let sent = transcript.sent(); let recv = transcript.received(); - let reveal_recv_ranges = compute_reveal_ranges(recv)?; + // The ceremony layouts derive their commitments as the complement of + // the reveals, so each direction tiles by construction -- which is what + // the Platform Verifier's coverage check demands. + // The prover chooses what it reveals -- that is what a prover IS. One + // parameter says so, and there is no second mechanism to disagree with + // it. A caller wanting the specification's layouts calls + // `libid_transcript::ceremony` here and returns what it gives back. + let (sent_layout, recv_layout) = select_layout(sent, recv)?; - // Save the revealed recv segments BEFORE the transcript is moved. - // The notary hashes exactly these bytes into the `recv:` Merkle leaves, so - // saving them here lets the ZK prover verify the full chain. - let recv_segments: Vec> = reveal_recv_ranges - .iter() - .map(|r| recv[r.clone()].to_vec()) - .collect(); + let reveal_recv_ranges = recv_layout.reveal.clone(); - let notary_sent_ranges = find_notary_reveal_ranges(sent); + let notary_sent_ranges = sent_layout.reveal.clone(); - let mut tc_builder = TranscriptCommitConfig::builder(&transcript); - for range in find_presentation_commit_ranges(sent) { - tc_builder - .commit_sent(&range) - .map_err(|e| Error::MpcTlsFailed { - detail: format!("commit sent: {e}"), - })?; - } - tc_builder - .commit_recv(&(0..recv.len())) - .map_err(|e| Error::MpcTlsFailed { - detail: format!("commit recv: {e}"), - })?; - let transcript_commit = tc_builder.build().map_err(|e| Error::MpcTlsFailed { - detail: format!("transcript commit config: {e}"), - })?; + let transcript_commit = transcript_commit_config( + &transcript, + &sent_layout.commit, + &recv_layout.commit, + )?; let mut prove_config = ProveConfig::builder(&transcript); prove_config.server_identity(); @@ -524,10 +568,10 @@ where detail: format!("prove: {e}"), })?; info!("MPC-TLS proof complete"); + established.store(true, Ordering::Release); on_progress(ProverStep::MpcProofFinalized); let tls_transcript = prover.tls_transcript().clone(); - let handshake = extract_handshake_data(&tls_transcript)?; let mut req_config = RequestConfig::builder(); req_config @@ -566,10 +610,37 @@ where }) .transcript(transcript) .transcript_commitments( - prover_output.transcript_secrets, + prover_output.transcript_secrets.clone(), prover_output.transcript_commitments, ); - let (att_request, secrets) = req_builder + // Taken before the secrets move into the request: the builder consumes + // them and `Secrets` exposes no accessor, so this is the only point at + // which a caller can still be handed what opens its own commitments. + // + // A secret of a kind this cannot open is refused rather than skipped: + // dropping one would hand the caller fewer openings than it made + // commitments, and it would find that out later, somewhere the reason + // is no longer visible. + let commitment_openings: Vec = prover_output + .transcript_secrets + .into_iter() + .map(|secret| match secret { + TranscriptSecret::Hash(hash) => Ok(CommitmentOpening { + direction: hash.direction, + ranges: hash.idx.into_inner(), + blinder: hash.blinder.as_bytes().to_vec(), + }), + other => Err(Error::MpcTlsFailed { + detail: format!( + "commitment secret of a kind this build cannot open: {other:?}" + ), + }), + }) + .collect::>()?; + // The request itself goes nowhere: the notary answers a session with the + // attested-data record and reads no attestation request. `build` is still + // what produces `secrets`, so it stays. + let (_att_request, secrets) = req_builder .build(&CryptoProvider::default()) .map_err(|e| Error::MpcTlsFailed { detail: format!("attestation request: {e}"), @@ -581,7 +652,7 @@ where })?; handle.close(); - Ok((body, recv_segments, att_request, secrets, handshake)) + Ok((body, secrets, commitment_openings)) }; tokio::pin!(setup); @@ -589,17 +660,28 @@ where // connection to the verifier died under the session — a protocol request // already submitted to it may then never resolve, so fail instead of // pending forever. - let (body, recv_segments, att_request, secrets, handshake) = tokio::select! { + let mut finished_driver = None; + let (body, secrets, commitment_openings) = tokio::select! { biased; res = &mut setup => res?, driver_res = driver_task.handle_mut() => { - return Err(driver_finished_early(driver_res)); + if !established.load(Ordering::Acquire) { + return Err(driver_finished_early(driver_res)); + } + // The peer closed the mux as its last act while this side was + // still finishing. Let setup complete and keep the driver's + // result: a finished handle cannot be polled a second time. Not a + // `select!` precondition, which is evaluated once, on entry. + finished_driver = Some(driver_res); + (&mut setup).await? } }; - let recovered_compat: Compat = driver_task - .into_inner() - .await + let driver_res = match finished_driver { + Some(res) => res, + None => driver_task.into_inner().await, + }; + let recovered_compat: Compat = driver_res .map_err(|e| Error::MpcTlsFailed { detail: format!("driver task join: {e}"), })? @@ -610,10 +692,8 @@ where Ok(ProverResult { response_body: body.to_vec(), - recv_segments, - request: att_request, secrets, - handshake, + commitment_openings, recovered_io, }) } @@ -629,6 +709,11 @@ pub async fn verifier // dropping this future — aborts the driver instead of detaching it. let mut driver_task = AbortOnDrop::new(tokio::spawn(driver)); + // Set once the session has run. Before that, the driver finishing means the + // connection died under the session; after, it means the peer closed the + // mux, which is how a session ends. + let established = AtomicBool::new(false); + let established = &established; let setup = async { let verifier = handle .new_verifier( @@ -676,6 +761,7 @@ pub async fn verifier let verifier = verifier.run().await.map_err(|e| Error::MpcTlsFailed { detail: format!("run: {e}"), })?; + established.store(true, Ordering::Release); let tls_transcript = verifier.tls_transcript().clone(); @@ -694,6 +780,36 @@ pub async fn verifier }); } + // Refuse a commitment this session cannot contain, BEFORE `accept` + // walks it. `accept` allocates in proportion to every committed range + // and then indexes the plaintext with it, so a range the prover made + // up is either an allocation nothing bounds or an index past the end. + // Checked here rather than upstream because this is the last point + // that holds both the request and the transcript it describes. + let overrun = verifier.request().transcript_commit().and_then(|commit| { + commitment_past_the_session( + commit + .iter_hash() + .map(|(direction, idx, _)| (*direction, idx.end())), + application_data_len(tls_transcript.sent()), + application_data_len(tls_transcript.recv()), + ) + }); + if let Some((direction, end, len)) = overrun { + verifier + .reject(Some("commitment range out of bounds")) + .await + .map_err(|e| Error::MpcTlsFailed { + detail: format!("reject: {e}"), + })?; + return Err(Error::MpcTlsFailed { + detail: format!( + "a {direction} commitment ends at {end}, past the {len} bytes \ + this session carried" + ), + }); + } + let (output, verifier) = verifier.accept().await.map_err(|e| Error::MpcTlsFailed { detail: format!("accept verify: {e}"), @@ -732,17 +848,28 @@ pub async fn verifier // connection died under the session (e.g. a health probe that connected // and immediately closed) — a protocol request already submitted to it // may then never resolve, so fail instead of pending forever. + let mut finished_driver = None; let (server_name, transcript, tls_transcript, transcript_commitments) = tokio::select! { biased; res = &mut setup => res?, driver_res = driver_task.handle_mut() => { - return Err(driver_finished_early(driver_res)); + if !established.load(Ordering::Acquire) { + return Err(driver_finished_early(driver_res)); + } + // The peer closed the mux as its last act while this side was + // still finishing. Let setup complete and keep the driver's + // result: a finished handle cannot be polled a second time. Not a + // `select!` precondition, which is evaluated once, on entry. + finished_driver = Some(driver_res); + (&mut setup).await? } }; - let recovered_compat: Compat = driver_task - .into_inner() - .await + let driver_res = match finished_driver { + Some(res) => res, + None => driver_task.into_inner().await, + }; + let recovered_compat: Compat = driver_res .map_err(|e| Error::MpcTlsFailed { detail: format!("driver task join: {e}"), })? @@ -759,3 +886,136 @@ pub async fn verifier recovered_io, }) } + +#[cfg(test)] +mod tests { + use super::*; + + fn request(uri: &str) -> hyper::Request<()> { + hyper::Request::builder() + .uri(uri) + .header("Host", "www.googleapis.com") + .body(()) + .expect("valid request") + } + + /// REQ-COMMON-38: launch profiles pin SHA-256, because the Proving + /// Circuit computes SHA-256 and cannot open a commitment made under + /// anything else. + /// + /// This is asserted on the CONFIG rather than on a notarized session, + /// because the algorithm is chosen here and nowhere else -- `select_layout` + /// hands back ranges, so no caller can correct it. The unit tests that + /// cover the record synthesize their own commitments and hard-code + /// SHA-256, so they assert on an algorithm no code path in this crate + /// produces; this is the gap that leaves. + #[test] + fn every_commitment_this_prover_configures_is_sha256() { + let transcript = + Transcript::new(b"GET / HTTP/1.1\r\n\r\n", b"HTTP/1.1 200 OK\r\n\r\nx"); + // Two ranges per direction: the algorithm is per commitment, so one + // range could not tell a default applied once from one applied to each. + let config = + transcript_commit_config(&transcript, &[0..4, 6..10], &[0..4, 6..10]) + .expect("the ranges are inside the transcript"); + + let algs: Vec<_> = config.iter_hash().map(|(_, alg)| *alg).collect(); + assert_eq!(algs.len(), 4, "two commitments per direction"); + for alg in algs { + assert_eq!( + alg, + HashAlgId::SHA256, + "a commitment under {alg:?} is one the circuit cannot open, and \ + one `AttestedData::from_observed` refuses (REQ-COMMON-38)" + ); + } + } + + /// A record as a finished session holds it, for the length sum below. + fn record(typ: ContentType, len: usize) -> Record { + Record { + seq: 0, + typ, + plaintext: None, + explicit_nonce: Vec::new(), + ciphertext: vec![0; len], + tag: None, + } + } + + #[test] + fn the_session_length_counts_only_its_application_data() { + // A transcript's offsets are into its application data. Handshake and + // alert records ride the same wire and belong to no direction's + // offsets, so counting them would leave room for a commitment the + // transcript has no bytes for. + let records = [ + record(ContentType::Handshake, 100), + record(ContentType::ApplicationData, 40), + record(ContentType::Alert, 7), + record(ContentType::ApplicationData, 2), + ]; + assert_eq!(application_data_len(&records), 42); + } + + #[test] + fn a_commitment_past_the_session_is_refused() { + // The shape a prover writes by hand. `TranscriptCommitConfigBuilder` + // refuses it, and a prover composing its own wire bytes never calls + // that builder -- `ProveRequest` deserializes with no validation of + // its own, so this is the only place the offsets are met. + assert_eq!( + commitment_past_the_session([(Direction::Sent, Some(1 << 40))], 4096, 4096), + Some((Direction::Sent, 1 << 40, 4096)), + "an enormous range is an allocation the session never justified" + ); + assert_eq!( + commitment_past_the_session([(Direction::Received, Some(4097))], 4096, 4096), + Some((Direction::Received, 4097, 4096)), + "one byte past the end still indexes past the plaintext" + ); + } + + #[test] + fn a_commitment_the_session_carried_is_allowed() { + // Each direction is measured against its OWN length, so a range that + // would overrun the other one is still one this session can open. + assert_eq!( + commitment_past_the_session( + [ + (Direction::Sent, Some(4096)), + (Direction::Received, Some(30_000)), + (Direction::Sent, None), + ], + 4096, + 32_768, + ), + None + ); + } + + #[test] + fn origin_form_keeps_path_and_query() { + let mut request = request("https://www.googleapis.com/p?q=1"); + origin_form(&mut request).expect("origin-form"); + assert_eq!(request.uri().to_string(), "/p?q=1"); + } + + #[test] + fn origin_form_keeps_a_bare_path() { + let mut request = request("https://www.googleapis.com/p"); + origin_form(&mut request).expect("origin-form"); + assert_eq!(request.uri().to_string(), "/p"); + } + + #[test] + fn origin_form_leaves_the_host_header_alone() { + let mut request = request("https://www.googleapis.com/oauth2/v3/certs"); + origin_form(&mut request).expect("origin-form"); + assert_eq!(request.uri().host(), None); + assert_eq!( + request.headers().get("Host").map(|v| v.as_bytes()), + Some(&b"www.googleapis.com"[..]) + ); + } +} diff --git a/crates/libid-tlsn/tests/ceremony_end_to_end.rs b/crates/libid-tlsn/tests/ceremony_end_to_end.rs new file mode 100644 index 00000000..6e05d5b4 --- /dev/null +++ b/crates/libid-tlsn/tests/ceremony_end_to_end.rs @@ -0,0 +1,295 @@ +//! The stitch between choosing a layout and what the verifier demands of it. +//! +//! Every piece of the ceremony has its own tests. What had none is the JOIN: +//! `libid_transcript::ceremony` picks the ranges, `libid_tlsn::attest` turns a +//! session into the attested-data record, and a Platform Verifier on chain then +//! applies rules neither of them states. A layout can be internally consistent, +//! encode cleanly, and still be refused. +//! +//! So this drives all three for both X sessions and asserts, on the decoded +//! record, the rules the Solidity side enforces. It is not a network test — +//! there is no TLS here — but it is the only place the two halves meet before +//! a deployment does. +//! +//! Each assertion below names the check it mirrors, so a rule that changes on +//! chain has one place to change here. + +use libid_ceremony::attestation::{ + AttestedData, + DirectionBlock, +}; +use libid_tlsn::attest::{ + FromObserved, + ObservedSession, +}; +use libid_transcript::ceremony::{ + profiles, + Layout, +}; +use rangeset::set::RangeSet; +use tlsn::{ + hash::{ + HashAlgId, + TypedHash, + }, + transcript::{ + hash::PlaintextHash, + Direction, + Transcript, + TranscriptCommitment, + }, +}; + +const TOKEN_SENT: &[u8] = b"POST /2/oauth2/token HTTP/1.1\r\nhost: api.x.com\r\n\r\ngrant_type=authorization_code&client_id=abc&code_verifier=5teBDl6cz4U77aFweV5PbMhBJ_lEFv6LLNKzqnDI5lo"; +const TOKEN_RECV: &[u8] = + b"HTTP/1.1 200 OK\r\n\r\n{\"token_type\":\"bearer\",\"access_token\":\"SECRETBEARER\"}"; +const ID_SENT: &[u8] = b"GET /2/users/me HTTP/1.1\r\nhost: api.x.com\r\nauthorization: Bearer SECRETBEARER\r\nconnection: close\r\n\r\n"; +const ID_RECV: &[u8] = b"HTTP/1.1 200 OK\r\n\r\n{\"data\":{\"id\":\"2244994945\",\"name\":\"Al\",\"username\":\"alice\"}}"; + +fn hash32(byte: u8) -> TypedHash { + TypedHash { + alg: HashAlgId::SHA256, + value: serde_json::from_value(serde_json::json!(vec![byte; 32])).unwrap(), + } +} + +/// Turn a pair of layouts into the [`ObservedSession`] a notary's verifier +/// holds. +/// +/// This is the step a real session performs inside MPC: the prover states what +/// it reveals, and the verifier ends up holding the revealed transcript and a +/// commitment per hidden run. Reproducing it here is what makes the record +/// below the one a real session would produce. +fn record( + sent: &[u8], + recv: &[u8], + sl: &Layout, + rl: &Layout, + created_at: u64, +) -> AttestedData { + let transcript = Transcript::new(sent, recv); + let partial = transcript.to_partial( + RangeSet::from(sl.reveal.clone()), + RangeSet::from(rl.reveal.clone()), + ); + + let mut commitments = Vec::new(); + for (i, c) in sl.commit.iter().enumerate() { + commitments.push(TranscriptCommitment::Hash(PlaintextHash { + direction: Direction::Sent, + idx: RangeSet::from(c.clone()), + hash: hash32(i as u8 + 1), + })); + } + for (i, c) in rl.commit.iter().enumerate() { + commitments.push(TranscriptCommitment::Hash(PlaintextHash { + direction: Direction::Received, + idx: RangeSet::from(c.clone()), + hash: hash32(i as u8 + 100), + })); + } + + AttestedData::from_observed(ObservedSession { + transcript: &partial, + authority: "api.x.com", + commitments: &commitments, + created_at, + }) + .expect("the layouts produce an attestable session") +} + +/// `CeremonyAttestation.requireExactCoverage`: revealed ranges and commitments +/// account for `[0, length)` with no gap and no overlap. +fn assert_tiles(block: &DirectionBlock, length: u32, what: &str) { + let mut spans: Vec<(u32, u32)> = block + .revealed + .iter() + .map(|r| (r.start, r.start + r.bytes.len() as u32)) + .chain(block.commitments.iter().map(|c| (c.start, c.end))) + .collect(); + spans.sort_by_key(|s| s.0); + let mut at = 0u32; + for (start, end) in spans { + assert_eq!(start, at, "{what}: gap or overlap at {at}"); + assert!(end > start, "{what}: empty span at {start}"); + at = end; + } + assert_eq!( + at, length, + "{what}: coverage stops short of the signed length" + ); +} + +/// The revealed bytes of one direction, joined in offset order — what the +/// verifier's cross-range delimiter count reads. +fn joined(block: &DirectionBlock) -> Vec { + let mut ranges: Vec<_> = block.revealed.iter().collect(); + ranges.sort_by_key(|r| r.start); + ranges.iter().flat_map(|r| r.bytes.clone()).collect() +} + +fn count(haystack: &[u8], needle: &[u8]) -> usize { + haystack + .windows(needle.len()) + .filter(|w| *w == needle) + .count() +} + +#[test] +fn the_token_session_produces_a_record_the_verifier_accepts() { + let sl = Layout::token_request(TOKEN_SENT, &profiles::X.token.unwrap()).unwrap(); + let rl = Layout::token_response(TOKEN_RECV).unwrap(); + let data = record(TOKEN_SENT, TOKEN_RECV, &sl, &rl, 1_770_000_000); + + assert_tiles(&data.sent, data.sent_transcript_length, "token request"); + assert_tiles( + &data.received, + data.recv_transcript_length, + "token response", + ); + + // `_tokenBody`: ONE revealed sent range, anchored at the origin. X carries + // no secret, so the request is revealed entire. + assert_eq!(data.sent.revealed.len(), 1); + assert_eq!(data.sent.revealed[0].start, 0); + assert!(data.sent.commitments.is_empty()); + assert!(data.sent.revealed[0] + .bytes + .starts_with(b"POST /2/oauth2/token ")); + + // `_tokenBody` again: exactly one head boundary, or the body is ambiguous. + assert_eq!(count(&data.sent.revealed[0].bytes, b"\r\n\r\n"), 1); + + // REQ-COMMON-15A: the digest binding is the revealed `code_verifier`. + assert_eq!(count(&data.sent.revealed[0].bytes, b"code_verifier="), 1); + + // `requireFramedCommitment`: one commitment carries the framing, and the + // bearer is not readable anywhere. + let framed: Vec<_> = data + .received + .commitments + .iter() + .filter(|c| { + data.received.revealed.iter().any(|r| { + r.start + r.bytes.len() as u32 == c.start + && r.bytes.ends_with(b"\"access_token\":\"") + }) + }) + .collect(); + assert_eq!( + framed.len(), + 1, + "exactly one commitment is framed as the bearer" + ); + assert_eq!(count(&joined(&data.received), b"SECRETBEARER"), 0); +} + +#[test] +fn the_identity_session_produces_a_record_the_verifier_accepts() { + let sl = Layout::identity_request(ID_SENT).unwrap(); + let rl = Layout::identity_response(ID_RECV, &profiles::X.identity.unwrap()).unwrap(); + let data = record(ID_SENT, ID_RECV, &sl, &rl, 1_770_000_000); + + assert_tiles(&data.sent, data.sent_transcript_length, "identity request"); + assert_tiles( + &data.received, + data.recv_transcript_length, + "identity response", + ); + + // `_identitySession`: the request line sits at offset 0. + let first = data.sent.revealed.iter().min_by_key(|r| r.start).unwrap(); + assert_eq!(first.start, 0); + assert!(first.bytes.starts_with(b"GET /2/users/me ")); + + // `requireBearerHeaderRequest`: exactly one commitment, framed by the + // header bytes REQ-COMMON-40 names. + assert_eq!(data.sent.commitments.len(), 1); + let bearer = &data.sent.commitments[0]; + let before = data + .sent + .revealed + .iter() + .find(|r| r.start + r.bytes.len() as u32 == bearer.start) + .expect("a revealed range ends where the commitment begins"); + assert!(before.bytes.ends_with(b"\r\nauthorization: Bearer ")); + let after = data + .sent + .revealed + .iter() + .find(|r| r.start == bearer.end) + .expect("a revealed range begins where the commitment ends"); + assert!(after.bytes.starts_with(b"\r\n")); + + // REQ-COMMON-39, counted over the CONCATENATION: one authorization header. + let mut normalized = joined(&data.sent).to_ascii_lowercase(); + normalized.retain(|&b| b != b' ' && b != b'\t'); + assert_eq!(count(&normalized, b"\r\nauthorization:bearer"), 1); + + // `requireExactCoverage`: the response is tiled, and what it does not + // reveal it commits -- so the account metadata beside the two members never + // reaches the chain. + assert!( + !data.received.commitments.is_empty(), + "the rest of the response must be committed, not published" + ); + + // What the verifier can read is exactly the two members, each once. A + // duplicate reaching these bytes is still caught on chain; one behind a + // commitment is not, and ASM-PROV-06 is what stands in for that. + let body = joined(&data.received); + assert_eq!(count(&body, b"\"id\":\""), 1); + assert_eq!(count(&body, b"\"username\":\""), 1); + assert!( + !body.windows(2).any(|w| w == b"Al"), + "the display name must stay behind a commitment" + ); +} + +/// The record has to survive the wire, not merely exist: the encoding is what +/// the notary signs and what the Solidity decoder reads. +#[test] +fn both_sessions_encode_and_carry_their_own_lengths() { + for (sent, recv, sl, rl) in [ + ( + TOKEN_SENT, + TOKEN_RECV, + Layout::token_request(TOKEN_SENT, &profiles::X.token.unwrap()).unwrap(), + Layout::token_response(TOKEN_RECV).unwrap(), + ), + ( + ID_SENT, + ID_RECV, + Layout::identity_request(ID_SENT).unwrap(), + Layout::identity_response(ID_RECV, &profiles::X.identity.unwrap()).unwrap(), + ), + ] { + let data = record(sent, recv, &sl, &rl, 1_770_000_000); + assert_eq!(data.sent_transcript_length as usize, sent.len()); + assert_eq!(data.recv_transcript_length as usize, recv.len()); + let encoded = data.encode().expect("encodes"); + assert!(encoded.len() > 48, "at least the header"); + assert_ne!(data.digest().unwrap(), [0u8; 32]); + } +} + +/// The GitHub token exchange is the one session whose REQUEST hides something, +/// and the shape it must take is a prefix: the commitment reaches the +/// transcript end, so the revealed run has no hole in it. +#[test] +fn the_github_exchange_commits_a_suffix_and_nothing_else() { + const SENT: &[u8] = b"POST /login/oauth/access_token HTTP/1.1\r\nhost: github.com\r\n\r\nclient_id=Iv1.x&code=abc&code_verifier=xyz&client_secret=deadbeef"; + const RECV: &[u8] = + b"HTTP/1.1 200 OK\r\n\r\n{\"token_type\":\"bearer\",\"access_token\":\"SECRETBEARER\"}"; + + let sl = Layout::token_request(SENT, &profiles::GITHUB.token.unwrap()).unwrap(); + let rl = Layout::token_response(RECV).unwrap(); + let data = record(SENT, RECV, &sl, &rl, 1_770_000_000); + + assert_tiles(&data.sent, data.sent_transcript_length, "github exchange"); + assert_eq!(data.sent.revealed.len(), 1); + assert_eq!(data.sent.revealed[0].start, 0); + assert_eq!(data.sent.commitments.len(), 1); + assert_eq!(data.sent.commitments[0].end, SENT.len() as u32); + assert_eq!(count(&joined(&data.sent), b"deadbeef"), 0); +} diff --git a/crates/libid-transcript/Cargo.toml b/crates/libid-transcript/Cargo.toml index b7697c41..350b79ba 100644 --- a/crates/libid-transcript/Cargo.toml +++ b/crates/libid-transcript/Cargo.toml @@ -5,23 +5,19 @@ edition.workspace = true rust-version.workspace = true license.workspace = true repository.workspace = true -description = "tlsn-free half of the libID MPC-TLS toolkit: HTTP/JSON transcript range math for selective disclosure, the length-prefixed JSON notary wire protocol, and the EvmProof/NotaryResponse proof types." +description = "tlsn-free half of the libID MPC-TLS toolkit: HTTP/JSON transcript range math for selective disclosure, the ceremony reveal layouts, and the length-prefixed JSON protocol a notary and a prover speak once MPC-TLS closes." keywords = ["tls", "notary", "zktls", "transcript", "mpc"] categories = ["cryptography", "parser-implementations"] -[features] -default = [] -# Derives `ts_rs::TS` on the proof types so TypeScript bindings can be -# generated (`cargo test --features ts` writes them via ts-rs export tests -# in the consumer). -ts = ["dep:ts-rs"] - [dependencies] +httparse.workspace = true +libid-profiles.workspace = true serde.workspace = true serde_json.workspace = true thiserror.workspace = true tokio = { workspace = true, features = ["io-util"] } -ts-rs = { workspace = true, optional = true } [dev-dependencies] +# The generated platform names, to check the profile table against. +libid-identity.workspace = true tokio = { workspace = true, features = ["rt", "macros", "io-util"] } diff --git a/crates/libid-transcript/src/ceremony.rs b/crates/libid-transcript/src/ceremony.rs new file mode 100644 index 00000000..b0dfadfa --- /dev/null +++ b/crates/libid-transcript/src/ceremony.rs @@ -0,0 +1,664 @@ +//! Choosing what a notarized session reveals. +//! +//! The Platform Verifier checks that the revealed ranges and the commitments +//! TILE the transcript: every byte accounted for, no gap and no overlap. A gap +//! is where a prover hides bytes, so a session that leaves one is refused -- +//! which means the selection here is not a disclosure preference, it is a +//! correctness requirement. Choose the wrong ranges and no honest ceremony +//! verifies at all. +//! +//! Every layout below therefore names only what it REVEALS, and the commitments +//! are derived as the complement. Tiling then holds by construction rather than +//! by inspection. +//! +//! Nothing here is applied on anyone's behalf. A prover notarizing a ceremony +//! session calls these and hands the result to `prover_generic`; a prover doing +//! something else states its own. In Rust that prover will be the GitHub +//! Token-Exchange Service, for the token session. The other three sessions are +//! the browser's. + +use std::ops::Range; + +use crate::ranges::{ + compute_field_snippet_range, + compute_id_snippet_range, + JsonMember, +}; + +/// What one direction of one session discloses. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Layout { + /// Ascending, non-overlapping. + pub reveal: Vec>, + /// The complement of `reveal` over the whole direction. + pub commit: Vec>, +} + +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum LayoutError { + #[error("the request has no `{0}` header, so the layout has nothing to anchor on")] + MissingHeader(&'static str), + #[error("the response carries no `{0}` field where the profile expects one")] + MissingField(String), + #[error("the transcript has no head boundary, so its body cannot be located")] + NoHeadBoundary, + #[error("the credential to commit was not found in the request body")] + MissingCredential, +} + +/// The bytes of `[0, len)` that `reveal` does not cover. +/// +/// Deriving the commitments this way is what makes every layout tile. The +/// alternative -- listing both and hoping they agree -- is the mistake the +/// verifier exists to catch. +fn complement(reveal: &[Range], len: usize) -> Vec> { + let mut out = Vec::new(); + let mut at = 0usize; + for r in reveal { + if r.start > at { + out.push(at..r.start); + } + at = r.end; + } + if at < len { + out.push(at..len); + } + out +} + +/// The ceremony profiles, and the types a layout is built from. +/// +/// Re-exported rather than restated. `libid-profiles` is generated in +/// libid-contracts from `solidity/contracts/ceremony/profiles.json` -- the same +/// file `CeremonyProfile.sol` is generated from -- so what a prover reveals and +/// what a Platform Verifier compares it against come from one place. A table +/// written again here would be a second copy of values whose whole problem is +/// that copies drift in silence. +/// +/// [`IdShape`] used to be declared here. It said the same thing the generated +/// one says, and two spellings of one profile fact is the drift this crate now +/// takes the table to avoid. +pub use libid_profiles::{ + self as profiles, + IdShape, + IdentitySession, + Profile, + TokenSession, +}; + +impl Layout { + /// A layout that reveals these ranges of a direction `len` bytes long, and + /// commits everything they leave. + /// + /// The one door into a tiling `Layout`, and the reason every constructor below + /// tiles by construction rather than by inspection: no constructor states + /// `commit`, so no constructor's list can disagree with the reveals it was + /// supposed to complement. `layout` -- a lowercase homonym of the type it + /// built, in a module about layouts -- said none of that. + /// + /// `complement` walks the reveals once, taking each as starting where the last + /// one ended, so unsorted input reads as overlap and yields a complement that + /// tiles nothing -- which the Platform Verifier rejects and nothing here would + /// catch. Sorting is done once, here, so no caller has to remember: the + /// layouts that build in order are unaffected, and + /// [`Layout::identity_response`], whose two members arrive in whatever order + /// the platform serialized them, carries no sort of its own. Overlapping input + /// is a caller bug that sorting cannot repair, and the debug assertion is what + /// says so. + /// + /// Private, and `Layout`'s fields stay public beside it. A prover outside + /// this module may state a layout this module does not know, so tiling is a + /// property of these constructors and not of the type. A public constructor + /// advertising a guarantee the type does not enforce would be worse than no + /// public constructor at all. + /// + /// It takes an iterator rather than a `Vec` so that a one-range layout is + /// spelled `core::iter::once(a..b)`. Both `vec![a..b]` and `[a..b]` trip + /// `clippy::single_range_in_vec_init`, a lint that exists to catch `vec![0; n]` + /// typos and cannot tell this apart from one; the iterator form says what is + /// meant without a named helper standing in for it. + fn revealing(reveal: impl IntoIterator>, len: usize) -> Self { + let mut reveal: Vec> = reveal.into_iter().collect(); + reveal.sort_by_key(|r| r.start); + debug_assert!( + reveal.windows(2).all(|pair| pair[0].end <= pair[1].start), + "reveal ranges overlap: {reveal:?}" + ); + let commit = complement(&reveal, len); + Layout { reveal, commit } + } + + /// The token request of `x/v1`, or the token exchange of `github/v1`. + /// + /// X reveals the request whole: it authenticates with a public client, so the + /// request carries nothing secret and the head boundary stays visible, which is + /// how the verifier locates the body at all. GitHub commits its `client_secret` + /// alone -- ordered last in the body, so the revealed run is a prefix and the + /// commitment reaches the transcript end. + pub fn token_request( + sent: &[u8], + session: &TokenSession, + ) -> Result { + let Some(field) = session.secret_field else { + return Ok(Self::revealing(core::iter::once(0..sent.len()), sent.len())); + }; + + // `&client_secret=` begins the committed tail. The profile orders it last + // under REQ-COMMON-22 precisely so this is a suffix and not a hole. + let needle = format!("&{field}="); + let start = sent + .windows(needle.len()) + .position(|w| w == needle.as_bytes()) + .ok_or(LayoutError::MissingCredential)?; + Ok(Self::revealing(core::iter::once(0..start), sent.len())) + } + + /// The token response: the `"access_token":"` delimiter and its closing quote + /// are revealed, and everything else -- the bearer included -- is committed. + /// + /// Those two anchors are what identify the committed bearer. Without them the + /// committed range is indistinguishable from a `refresh_token` value, or any + /// other substring the prover chose to commit (REQ-PLAT-57, REQ-PLAT-58). + pub fn token_response(recv: &[u8]) -> Result { + // Named once, and a constant rather than a parameter. `access_token` is + // RFC 6749 section 5.1, not a platform's choice -- which is why the + // contract pins `ACCESS_TOKEN_PREFIX` on `TlsNotaryVerifierBase`, shared by + // every profile, while the things that ARE platform choices are per-profile + // virtuals there and parameters here: the committed body credential of + // `Layout::token_request`, the field names of + // `Layout::identity_response`. + const FIELD: &str = "access_token"; + let missing = || LayoutError::MissingField(FIELD.into()); + + // Through the shared reader rather than a scan of its own. That one locates + // the response BODY, so a header carrying this delimiter cannot answer + // first, and it refuses a member that chunk framing runs through -- which + // this direction cares about most, because the framing would land inside + // the committed bearer and the circuit would open a value the token service + // never returned. + let found = JsonMember::in_response(recv, FIELD).ok_or_else(missing)?; + + // Reveal the two delimiters and let the complement commit the bearer + // between them. Both boundaries come from the scan that found the member, + // so nothing here restates `"access_token":"` to recompute one. + // + // An empty bearer is refused: it would leave the two reveals adjacent and + // commit nothing, and a response direction with no commitment is one the + // framing check on chain finds no bearer in. + if found.value.is_empty() { + return Err(missing()); + } + + Ok(Self::revealing( + [ + found.member.start..found.value.start, + found.value.end..found.member.end, + ], + recv.len(), + )) + } + + /// The identity request: every byte revealed except the bearer value, which is + /// committed. + /// + /// The two revealed runs plus the committed one account for the request exactly, + /// which is what REQ-COMMON-35 demands and what leaves the committed range as + /// the only region the verifier cannot read. + pub fn identity_request(sent: &[u8]) -> Result { + const PREFIX: &[u8] = b"\r\nauthorization: Bearer "; + let prefix_at = sent + .windows(PREFIX.len()) + .position(|w| w == PREFIX) + .ok_or(LayoutError::MissingHeader("authorization"))?; + let value_start = prefix_at + PREFIX.len(); + let value_end = value_start + + sent[value_start..] + .windows(2) + .position(|w| w == b"\r\n") + .ok_or(LayoutError::MissingHeader("authorization"))?; + + Ok(Self::revealing( + [0..value_start, value_end..sent.len()], + sent.len(), + )) + } + + /// The identity response: the two identity members with their full delimiters, + /// and nothing else. + /// + /// Each member is revealed whole -- delimiter, value and closing byte -- so the + /// verifier reads that field's value rather than a substring of a neighbouring + /// one, and so the match sits inside a single revealed run rather than being + /// spliced out of several. Everything between and around them is committed. + /// + /// # What committing the rest costs, and why it is taken + /// + /// Every reader on the verifying side scans revealed bytes: the per-range field + /// read and the cross-range delimiter count alike. A commitment is invisible to + /// all of them. So a response that genuinely names an authoritative field twice + /// lets a prover commit the real member and reveal the one it composed, and + /// both checks then see exactly one. Uniqueness is a property of the document, + /// and this establishes it over a part. + /// + /// Reaching that needs the PLATFORM to emit the duplicate. ASM-PROV-06 assumes + /// it does not, and JSON escaping keeps a `","field":"` delimiter out of any + /// value the account controls -- a quote inside a string is written `\"`, which + /// does not match the template. A duplicate that reaches the REVEALED bytes is + /// still caught on chain, in either range layout. + /// + /// What the commitments buy is that the rest of the response never reaches the + /// chain. `GET /user` under an OAuth client holding a `user`-family scope + /// returns the account's plan, private-repository counts, disk usage and + /// two-factor state; revealing the response whole would publish all of it, + /// permanently, for every bind. + /// + /// `id_field` and `handle_field` are both bare `&str`, and a call that + /// transposes them still finds both members, still reveals both, and still + /// tiles -- it just names the handle as the account's immutable identifier. + /// Nothing downstream sees the swap: the layout is well formed and the + /// verifier reads what it was given, so the mistake surfaces on chain as an + /// offset rather than as a name. `IdShape` sitting between the two is luck, + /// not protection. Both names come from one profile, so keep them together at + /// the call site. + /// + /// The arguments are still taken and still checked. A response missing either + /// member is a failure now rather than at the verifier, where the reason would + /// be an offset rather than a name. + pub fn identity_response( + recv: &[u8], + session: &IdentitySession, + ) -> Result { + let (id_field, handle_field) = (session.id_field, session.handle_field); + + // The bare-integer form takes its structural terminator with it, which is + // what proves the revealed digits are the whole number. + let quoted = session.id_shape == IdShape::JsonString; + let id = compute_id_snippet_range(recv, id_field, quoted) + .ok_or_else(|| LayoutError::MissingField(id_field.into()))?; + let handle = compute_field_snippet_range(recv, handle_field) + .ok_or_else(|| LayoutError::MissingField(handle_field.into()))?; + + // JSON member order is not fixed; `Self::revealing` sorts, so this does + // not assume one. + Ok(Self::revealing([id, handle], recv.len())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The property every layout must have, checked directly rather than + /// inferred from the ranges looking plausible. + fn tiles(l: &Layout, len: usize) -> bool { + let mut spans: Vec> = + l.reveal.iter().chain(l.commit.iter()).cloned().collect(); + spans.sort_by_key(|r| r.start); + let mut at = 0usize; + for s in spans { + if s.start != at || s.end <= s.start { + return false; + } + at = s.end; + } + at == len + } + + /// The launch profiles these layouts are built for. Taking the arguments + /// from the table rather than restating them is what makes these tests + /// exercise the values a Platform Verifier actually compares against. + fn x_token() -> TokenSession { + libid_profiles::X + .token + .expect("x notarizes a token session") + } + + fn x_identity() -> IdentitySession { + libid_profiles::X + .identity + .expect("x notarizes an identity session") + } + + fn github_token() -> TokenSession { + libid_profiles::GITHUB + .token + .expect("github notarizes a token session") + } + + fn github_identity() -> IdentitySession { + libid_profiles::GITHUB + .identity + .expect("github notarizes an identity session") + } + + const X_TOKEN_REQ: &[u8] = + b"POST /2/oauth2/token HTTP/1.1\r\nhost: api.x.com\r\n\r\ngrant_type=authorization_code&client_id=abc&code_verifier=xyz"; + + #[test] + fn a_bearer_split_by_chunk_framing_is_refused() { + // The session Rust actually runs. Framing inside the committed range + // means the circuit opens bytes the token service never returned, and + // the on-chain framing check passes anyway because it reads the + // delimiters either side of the commitment, not its contents. + let mut recv = b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n".to_vec(); + for part in [ + r#"{"access_token":"ghu_AA"#, + r#"BB","token_type":"bearer"}"#, + ] { + recv.extend_from_slice(format!("{:x}\r\n", part.len()).as_bytes()); + recv.extend_from_slice(part.as_bytes()); + recv.extend_from_slice(b"\r\n"); + } + recv.extend_from_slice(b"0\r\n\r\n"); + assert!(Layout::token_response(&recv).is_err()); + } + + #[test] + fn an_empty_bearer_is_refused() { + // The two reveals would be adjacent, the complement would commit + // nothing, and `requireFramedCommitment` would find no bearer in a + // direction that carries no commitment at all. + let recv: &[u8] = br#"HTTP/1.1 200 OK"#; + let recv = [recv, b"\r\n\r\n", br#"{"access_token":""}"#].concat(); + assert!(Layout::token_response(&recv).is_err()); + } + + #[test] + fn a_bearer_carrying_structural_bytes_is_committed_whole() { + // Only `"` closes the value. A scan stopping at `:` or `,` would + // commit a prefix and REVEAL the rest of the bearer. + let recv = [ + b"HTTP/1.1 200 OK\r\n\r\n".as_slice(), + br#"{"access_token":"gh:u,A}BC","token_type":"bearer"}"#, + ] + .concat(); + let l = Layout::token_response(&recv).unwrap(); + assert!(tiles(&l, recv.len())); + assert!(l.commit.iter().any(|c| recv[c.clone()] == *b"gh:u,A}BC")); + // And no revealed run holds any part of it. + for r in &l.reveal { + assert!( + !recv[r.clone()].windows(3).any(|w| w == b"gh:"), + "the bearer must not appear in a revealed range" + ); + } + } + + #[test] + fn a_header_cannot_answer_for_the_body() { + // The old scan started at byte zero, so a response header carrying the + // delimiter was matched before the body's own member. + let recv: &[u8] = concat!( + "HTTP/1.1 200 OK\r\n", + r#"x-echo: "access_token":"decoy""#, + "\r\n\r\n", + r#"{"access_token":"real"}"#, + ) + .as_bytes(); + let l = Layout::token_response(recv).unwrap(); + let revealed: Vec = l + .reveal + .iter() + .flat_map(|r| recv[r.clone()].to_vec()) + .collect(); + assert_eq!(revealed, br#""access_token":"""#.to_vec()); + // The committed run is the bearer in the BODY, not the decoy. + let committed = l.commit.iter().find(|r| r.len() == 4).unwrap(); + assert_eq!(&recv[committed.clone()], b"real"); + } + + #[test] + fn the_x_token_request_is_revealed_whole() { + let l = Layout::token_request(X_TOKEN_REQ, &x_token()).unwrap(); + assert_eq!(l.reveal, vec![0..X_TOKEN_REQ.len()]); + assert!(l.commit.is_empty(), "X hides nothing in its token request"); + assert!(tiles(&l, X_TOKEN_REQ.len())); + } + + #[test] + fn the_github_exchange_commits_only_its_secret() { + let req: &[u8] = b"POST /login/oauth/access_token HTTP/1.1\r\nhost: github.com\r\n\r\nclient_id=Iv1.x&code=abc&code_verifier=xyz&client_secret=deadbeef"; + let l = Layout::token_request(req, &github_token()).unwrap(); + assert_eq!(l.reveal.len(), 1); + assert_eq!(l.commit.len(), 1); + // The commitment is a suffix, which is why ordering it last matters. + assert_eq!(l.commit[0].end, req.len()); + assert!(tiles(&l, req.len())); + // The secret's bytes are inside the commitment, not the reveal. + let revealed = &req[l.reveal[0].clone()]; + assert!(!revealed.windows(8).any(|w| w == b"deadbeef")); + } + + #[test] + fn a_missing_secret_is_an_error_not_a_silent_reveal() { + assert_eq!( + Layout::token_request(X_TOKEN_REQ, &github_token()), + Err(LayoutError::MissingCredential) + ); + } + + #[test] + fn the_token_response_reveals_only_the_two_anchors() { + let recv: &[u8] = + b"HTTP/1.1 200 OK\r\n\r\n{\"token_type\":\"bearer\",\"access_token\":\"SECRETBEARER\"}"; + let l = Layout::token_response(recv).unwrap(); + assert!(tiles(&l, recv.len())); + assert_eq!( + recv[l.reveal[0].clone()].to_vec(), + b"\"access_token\":\"".to_vec() + ); + assert_eq!(recv[l.reveal[1].clone()].to_vec(), b"\"".to_vec()); + // The bearer is committed, between the two anchors. + assert!(l.commit.iter().any(|c| recv[c.clone()] == *b"SECRETBEARER")); + } + + #[test] + fn the_identity_request_commits_only_the_bearer() { + let sent: &[u8] = b"GET /2/users/me HTTP/1.1\r\nhost: api.x.com\r\nauthorization: Bearer TOKENVALUE\r\nconnection: close\r\n\r\n"; + let l = Layout::identity_request(sent).unwrap(); + assert!(tiles(&l, sent.len())); + assert_eq!(l.commit.len(), 1, "exactly one credential is hidden"); + assert_eq!(sent[l.commit[0].clone()].to_vec(), b"TOKENVALUE".to_vec()); + // And the framing bytes the verifier compares are revealed. + let before = &sent[..l.commit[0].start]; + assert!(before.ends_with(b"\r\nauthorization: Bearer ")); + assert!(sent[l.commit[0].end..].starts_with(b"\r\n")); + } + + #[test] + fn a_request_without_the_credential_header_is_an_error() { + assert_eq!( + Layout::identity_request( + b"GET /2/users/me HTTP/1.1\r\nhost: api.x.com\r\n\r\n" + ), + Err(LayoutError::MissingHeader("authorization")) + ); + } + + #[test] + fn the_identity_response_reveals_both_members_whole() { + let recv: &[u8] = b"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\n\r\n{\"data\":{\"id\":\"2244994945\",\"name\":\"Al\",\"username\":\"alice\"}}"; + let l = Layout::identity_response(recv, &x_identity()).unwrap(); + assert!(tiles(&l, recv.len())); + assert_eq!(l.reveal.len(), 2); + // Whole members, delimiters included -- so the verifier reads the + // field's value and not a substring of the display name beside it. + assert_eq!( + recv[l.reveal[0].clone()].to_vec(), + b"\"id\":\"2244994945\"".to_vec() + ); + assert_eq!( + recv[l.reveal[1].clone()].to_vec(), + b"\"username\":\"alice\"".to_vec() + ); + } + + #[test] + fn the_github_identity_response_reveals_the_id_with_its_terminator() { + // GitHub's id is a BARE integer, so the two members are not the same + // shape: `login` closes on a quote, `id` closes on the structural byte + // after the digits. That byte is revealed WITH them, because it is what + // proves they are the whole number and not a prefix of a longer one -- + // `CeremonyFields.tryJsonInteger` pins it to `,` or `}` and no other. + let recv: &[u8] = b"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\n\r\n{\"login\":\"octocat\",\"id\":583231,\"node_id\":\"MDQ=\"}"; + let l = Layout::identity_response(recv, &github_identity()).unwrap(); + assert!(tiles(&l, recv.len())); + assert_eq!(l.reveal.len(), 2); + assert_eq!( + recv[l.reveal[0].clone()].to_vec(), + b"\"login\":\"octocat\"".to_vec() + ); + assert_eq!( + recv[l.reveal[1].clone()].to_vec(), + b"\"id\":583231,".to_vec() + ); + } + + #[test] + fn a_github_id_closed_by_a_brace_is_revealed_the_same_way() { + // JSON member order is not the platform's promise, so the id can be + // last -- and then `}` closes it instead of `,`. The profile fixes both + // as acceptable; a layout that only ever produced one would refuse + // half of GitHub's honest responses. + let recv: &[u8] = b"HTTP/1.1 200 OK\r\n\r\n{\"login\":\"octocat\",\"id\":583231}"; + let l = Layout::identity_response(recv, &github_identity()).unwrap(); + assert!(tiles(&l, recv.len())); + assert_eq!( + recv[l.reveal[1].clone()].to_vec(), + b"\"id\":583231}".to_vec() + ); + } + + #[test] + fn the_two_profiles_do_not_read_each_other_s_responses() { + // The point of taking the three arguments as one profile: crossed, they + // describe a session nobody ran, and that used to be four arguments + // away. Each direction is refused by the id, where the shapes differ. + let github: &[u8] = + b"HTTP/1.1 200 OK\r\n\r\n{\"login\":\"octocat\",\"id\":583231}"; + let x: &[u8] = b"HTTP/1.1 200 OK\r\n\r\n{\"id\":\"7\",\"username\":\"alice\"}"; + + // X's shape wants `"id":"`, and GitHub's id is bare: it fails on the id. + assert_eq!( + Layout::identity_response(github, &x_identity()), + Err(LayoutError::MissingField("id".into())) + ); + + // And GitHub's shape wants digits where X puts a quoted string, so it + // fails on the id as well rather than reaching the handle. It did not + // always: the bare reader used to stop at the first `,`, which returned + // `"id":"7",` -- a quoted value read as though it were a number -- and + // left the mismatch to be caught by the handle name instead. + assert_eq!( + Layout::identity_response(x, &github_identity()), + Err(LayoutError::MissingField("id".into())) + ); + } + + #[test] + fn a_response_that_names_the_handle_first_still_reveals_in_offset_order() { + // JSON member order is not the platform's promise, and the arguments + // are given id-first regardless. `Layout::revealing` is what reconciles + // the two: `complement` walks the reveals taking each as starting where + // the last one ended, so an unsorted pair reads as overlap and yields a + // complement that tiles nothing -- a layout the Platform Verifier + // refuses, with no honest ceremony able to produce an accepted one. + // + // Every other fixture here happens to serialize `id` first, so this is + // the one that exercises the sort. + let recv: &[u8] = b"HTTP/1.1 200 OK\r\n\r\n{\"username\":\"alice\",\"id\":\"7\"}"; + let l = Layout::identity_response(recv, &x_identity()).unwrap(); + assert!(tiles(&l, recv.len())); + assert_eq!(l.reveal.len(), 2); + // Offset order, which here is the OPPOSITE of the argument order. + assert_eq!( + recv[l.reveal[0].clone()].to_vec(), + b"\"username\":\"alice\"".to_vec() + ); + assert_eq!(recv[l.reveal[1].clone()].to_vec(), b"\"id\":\"7\"".to_vec()); + } + + #[test] + fn the_display_name_beside_a_member_stays_committed() { + // The point of committing the rest: nothing but the two members and + // their delimiters reaches the chain. + let recv: &[u8] = b"HTTP/1.1 200 OK\r\n\r\n{\"id\":\"7\",\"name\":\"Al\",\"username\":\"alice\"}"; + let l = Layout::identity_response(recv, &x_identity()).unwrap(); + assert!(tiles(&l, recv.len())); + assert!(!l.commit.is_empty(), "the rest of the response is hidden"); + for r in &l.reveal { + assert!( + !recv[r.clone()].windows(2).any(|w| w == b"Al"), + "the display name is inside a revealed range" + ); + } + } + + /// The one duplicate this layout cannot defend against, recorded so the + /// assumption is visible on the prover side too. + /// + /// A response naming `username` twice lets the revealed range carry one + /// member while the other stays committed, invisible to every reader on + /// chain. Reaching it needs the platform to emit that document: ASM-PROV-06 + /// assumes it does not, and JSON escaping keeps the delimiter out of any + /// value the account controls. The layout picks the first match and does + /// not detect the second -- stated here rather than left to be discovered. + #[test] + fn a_response_naming_a_member_twice_reveals_only_one() { + let recv: &[u8] = b"HTTP/1.1 200 OK\r\n\r\n{\"id\":\"7\",\"username\":\"victim\",\"username\":\"alice\"}"; + let l = Layout::identity_response(recv, &x_identity()).unwrap(); + assert!(tiles(&l, recv.len())); + let revealed: usize = l + .reveal + .iter() + .map(|r| { + recv[r.clone()] + .windows(11) + .filter(|w| *w == b"\"username\":") + .count() + }) + .sum(); + assert_eq!(revealed, 1, "the second member is committed, not revealed"); + } + + #[test] + fn a_missing_member_is_an_error() { + let recv: &[u8] = b"HTTP/1.1 200 OK\r\n\r\n{\"data\":{\"id\":\"7\"}}"; + assert!(matches!( + Layout::identity_response(recv, &x_identity()), + Err(LayoutError::MissingField(_)) + )); + } +} + +#[cfg(test)] +mod tables { + use super::profiles; + + /// The ceremony profiles and the identity system name the same platforms. + /// + /// Two generated tables, deliberately: one says how a handle normalizes, + /// the other says what a session notarizes, and neither belongs inside the + /// other. libid-contracts keeps them apart the same way and asserts they + /// agree (`PlatformIdentity.t.sol::test_theTwoTablesAgree`), because a name + /// bound through one path and read through the other is two keyspaces for + /// one platform with nothing to make the divergence loud. + /// + /// Both crates are generated from libid-contracts, so this is not checking + /// our transcription -- it is checking that a consumer holding BOTH at + /// versions it chose independently holds one keyspace. They are separate + /// crates with separate version requirements, and a lockfile can pin a pair + /// that never shipped together. + #[test] + fn the_two_tables_name_the_same_platforms() { + use libid_identity::handle_vectors::{ + PLATFORM_GITHUB_DOMAIN, + PLATFORM_GOOGLE_DOMAIN, + PLATFORM_X_DOMAIN, + }; + + assert_eq!(profiles::X.platform, PLATFORM_X_DOMAIN); + assert_eq!(profiles::GITHUB.platform, PLATFORM_GITHUB_DOMAIN); + assert_eq!(profiles::GOOGLE.platform, PLATFORM_GOOGLE_DOMAIN); + } +} diff --git a/crates/libid-transcript/src/lib.rs b/crates/libid-transcript/src/lib.rs index 33fc6f3f..9a88dcf1 100644 --- a/crates/libid-transcript/src/lib.rs +++ b/crates/libid-transcript/src/lib.rs @@ -8,40 +8,30 @@ //! * [`ranges`] — HTTP/JSON byte-range math for selective disclosure: locate //! headers, response bodies (chunked or not), and JSON field/snippet ranges //! in a raw TLS transcript, and map them back to absolute transcript -//! offsets that become Merkle leaves. +//! offsets, which are what a reveal range and a commitment are stated in. //! * [`wire`] — the length-prefixed JSON protocol the notary and prover speak //! over the recovered socket after MPC-TLS closes. -//! * [`types`] — [`EvmProof`], [`NotaryResponse`] and [`TlsHandshakeData`], -//! the notary's output as consumed by backends and on-chain verifiers. +pub mod ceremony; pub mod ranges; -pub mod types; pub mod wire; pub use ranges::{ - compute_field_reveal_range, compute_field_snippet_range, compute_id_snippet_range, - compute_id_snippet_range_after, extract_header, extract_response_body, find_header_range, find_json_bare_snippet_range, - find_json_field_range, find_json_snippet_range, - find_notary_reveal_ranges, - find_presentation_commit_ranges, find_request_line_range, find_response_body_range, -}; -pub use types::{ - EvmProof, - NotaryResponse, - TlsHandshakeData, + JsonMember, }; pub use wire::{ read_msg, write_msg, + AttestationWire, }; /// Errors from transcript parsing and the wire protocol. diff --git a/crates/libid-transcript/src/ranges.rs b/crates/libid-transcript/src/ranges.rs index 3c820c1d..07fb1896 100644 --- a/crates/libid-transcript/src/ranges.rs +++ b/crates/libid-transcript/src/ranges.rs @@ -1,11 +1,12 @@ //! TLS transcript parsing and byte-range helpers for selective disclosure. //! //! All functions operate on raw transcript bytes (`sent` / `recv`) and return -//! `Range` offsets into them. The revealed slices become Merkle leaves -//! that on-chain verifiers check, so every helper here fails closed: a range -//! that cannot be located contiguously in the RAW transcript (e.g. a JSON -//! snippet split across a chunk boundary) yields `None` rather than a -//! mis-resolved leaf. +//! `Range` offsets into them. The attested record carries the revealed +//! slices at those offsets and a commitment over each hidden run, and a +//! Platform Verifier reads the values out of them -- so every helper here +//! fails closed: a range that cannot be located contiguously in the RAW +//! transcript, such as a member split across a chunk boundary, yields `None` +//! rather than a range pointing at bytes nobody sent. use std::ops::Range; @@ -65,167 +66,183 @@ pub fn extract_response_body(recv: &[u8]) -> Result> { if let Some(te) = extract_header(recv, "Transfer-Encoding") { if te.contains("chunked") { - return Ok(decode_chunked_body(raw_body)); + return decode_chunked_body(raw_body); } } Ok(raw_body.to_vec()) } -fn decode_chunked_body(raw: &[u8]) -> Vec { - let mut result = Vec::new(); - let mut pos = 0; - while pos < raw.len() { - let size_end = match raw - .get(pos..) - .and_then(|s| s.windows(2).position(|w| w == b"\r\n")) - { - Some(p) => match pos.checked_add(p) { - Some(v) => v, - None => break, - }, - None => break, +/// Join a chunked body's chunks. +/// +/// Every malformed input is an error rather than a shorter body. The reveal +/// ranges are computed over what this returns, so a silent truncation would +/// have the prover select ranges over bytes the server never sent -- and the +/// notary would sign that selection without anyone noticing. +fn decode_chunked_body(raw: &[u8]) -> Result> { + let bad = |detail: &str| Error::Transcript { + detail: format!("chunked body: {detail}"), + }; + + let mut out = Vec::new(); + let mut rest = raw; + loop { + let (header_len, size) = match httparse::parse_chunk_size(rest) { + Ok(httparse::Status::Complete(v)) => v, + Ok(httparse::Status::Partial) => { + return Err(bad("ends inside a chunk header")) + } + Err(_) => return Err(bad("chunk size is not hexadecimal")), }; - let size_str = std::str::from_utf8(raw.get(pos..size_end).unwrap_or_default()) - .unwrap_or("0"); - let chunk_size = usize::from_str_radix(size_str.trim(), 16).unwrap_or(0); - if chunk_size == 0 { - break; + if size == 0 { + return Ok(out); } - let data_start = match size_end.checked_add(2) { - Some(v) => v, - None => break, - }; - let data_end = match data_start.checked_add(chunk_size) { - Some(v) => v, - None => break, - }; - if data_end > raw.len() { - break; + let size = + usize::try_from(size).map_err(|_| bad("chunk larger than this machine"))?; + let body_end = header_len + .checked_add(size) + .ok_or_else(|| bad("chunk length overflows"))?; + let chunk = rest + .get(header_len..body_end) + .ok_or_else(|| bad("chunk is shorter than its declared size"))?; + out.extend_from_slice(chunk); + + // The CRLF that closes a chunk. Its absence means the framing is not + // what it claims, and the next size would be read from the wrong place. + let after = rest + .get(body_end..body_end + 2) + .ok_or_else(|| bad("ends before a chunk terminator"))?; + if after != b"\r\n" { + return Err(bad("chunk is not terminated by CRLF")); } - result.extend_from_slice(&raw[data_start..data_end]); - pos = match data_end.checked_add(2) { - Some(v) => v, - None => break, - }; + rest = &rest[body_end + 2..]; } - result } -/// Find the byte range of a JSON string field value. -pub fn find_json_field_range(body: &[u8], field: &str) -> Option> { - let needle = format!("\"{}\"", field); - let pos = body - .windows(needle.len()) - .position(|w| w == needle.as_bytes())?; - let after_key = pos.checked_add(needle.len())?; - let colon = body - .get(after_key..)? - .iter() - .position(|&b| b == b':')? - .checked_add(after_key)?; - let after_colon = colon.checked_add(1)?; - let open_quote = body - .get(after_colon..)? - .iter() - .position(|&b| b == b'"')? - .checked_add(after_colon)?; - let after_open = open_quote.checked_add(1)?; - let close_quote = body - .get(after_open..)? - .iter() - .position(|&b| b == b'"')? - .checked_add(after_open)?; - Some(after_open..close_quote) +/// The `"key":"value"` member, from the key's opening quote through the +/// value's closing quote. +/// +/// # The template is the reader's +/// +/// `CeremonyFields.tryJsonString` matches the literal `"":"`, so this +/// matches the same bytes. Anything looser picks a range the reader cannot +/// read: a body written `"login" : "octocat"` would be revealed here and then +/// met with `FieldNotFound` on chain, which is the same refusal reported where +/// nobody can see why. Failing here fails it where the reason is visible. +/// +/// Uniqueness is NOT checked here, and that is deliberate. The reader refuses +/// a delimiter matching twice in the bytes it was shown (REQ-COMMON-19A), and +/// which bytes those are is exactly what a layout decides -- so +/// `identity_response` reveals one member and commits the other, and the +/// reader sees one. Refusing a second occurrence here would only stop an +/// honest prover from building that layout; a dishonest one does not run this +/// code at all. +pub fn find_json_snippet_range(body: &[u8], field: &str) -> Option> { + JsonMember::in_body(body, field).map(|member| member.member) +} + +/// A `"field":"value"` member, and the value inside it. +/// +/// Two ranges rather than one because a caller that reveals the delimiters and +/// commits the value needs both boundaries, and deriving the inner one from the +/// outer one means restating the template -- which is a second place to change +/// the field name and one place to forget. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct JsonMember { + /// The whole member, both delimiters included. + pub member: Range, + /// The value alone, between the quotes. Empty when the value is `""`. + pub value: Range, } -/// Find the byte ranges that should be revealed to the notary: the request -/// line and the Host header. -pub fn find_notary_reveal_ranges(sent: &[u8]) -> Vec> { - let mut ranges = Vec::new(); - - let req_line = find_request_line_range(sent); - let req_end = req_line.end.saturating_add(2).min(sent.len()); - ranges.push(0..req_end); - - if let Some(range) = find_header_range(sent, "Host") { - let needle = "\r\nHost: "; - let prefix_start = sent - .windows(needle.len()) - .position(|w| w.eq_ignore_ascii_case(needle.as_bytes())); - if let Some(start) = prefix_start { - let header_end = range.end.saturating_add(2).min(sent.len()); - ranges.push(start..header_end); - } +impl JsonMember { + /// The member named `field` in `body`, with offsets INTO `body`. + /// + /// Raw bytes in, raw offsets out: this scans whatever it is handed, so a + /// caller passing a whole HTTP response gets whichever match comes first -- + /// a header's, if a header carries the delimiter. [`JsonMember::in_response`] + /// is the one that locates the body first, and is what a caller building a + /// reveal layout wants. + /// + /// A constructor on the type it produces: `json_member_in` restated the type + /// in the function name, stranded a preposition on the end of it, and left + /// the coordinate system -- the thing this module gets wrong most + /// expensively -- unsaid. + /// + /// The template it matches, and why that template is exactly the reader's, + /// is argued on [`find_json_snippet_range`], which is the public face of + /// this scan. + fn in_body(body: &[u8], field: &str) -> Option { + let needle = format!("\"{field}\":\""); + let start = find_first(body, needle.as_bytes())?; + let value = start.checked_add(needle.len())?; + let close = body + .get(value..)? + .iter() + .position(|&b| b == b'"')? + .checked_add(value)?; + Some(Self { + // From the opening `"` of the key through the closing `"` of the value. + member: start..close.checked_add(1)?, + value: value..close, + }) } - ranges + /// The member named `field_name` in an HTTP response, with offsets into the + /// RAW `recv` transcript. + /// + /// For a caller that reveals a member's delimiters and commits what sits + /// between them: both boundaries come from the scan that found them, so no + /// caller restates the template to recover one. + /// + /// The offsets are the whole difference from `in_body`, and the reason the + /// two are named apart rather than left to a `find_`/`compute_` prefix + /// nobody can decode. A reveal layout selects ranges of the TRANSCRIPT, so a + /// body-relative range handed to one selects bytes somewhere up in the + /// response headers -- a range that is well formed, signed, and pointing at + /// the wrong thing. + pub fn in_response(recv: &[u8], field_name: &str) -> Option { + let body_range = find_response_body_range(recv)?; + let raw_body = &recv[body_range.clone()]; + let decoded_body = extract_response_body(recv).ok()?; + + // Found in both: the decoded body says the member exists, the raw body says + // where it sits, and the two must hold the same bytes. + let decoded = Self::in_body(&decoded_body, field_name)?; + let raw = Self::in_body(raw_body, field_name)?; + require_contiguous( + raw_body.get(raw.member.clone())?, + decoded_body.get(decoded.member)?, + )?; + + let at = |offset: usize| body_range.start.checked_add(offset); + Some(Self { + member: at(raw.member.start)?..at(raw.member.end)?, + value: at(raw.value.start)?..at(raw.value.end)?, + }) + } } -/// Find the byte ranges to commit to in the TLSNotary presentation. -pub fn find_presentation_commit_ranges(sent: &[u8]) -> Vec> { - find_notary_reveal_ranges(sent) +/// The first occurrence of `needle`, or nothing. +fn find_first(haystack: &[u8], needle: &[u8]) -> Option { + haystack.windows(needle.len()).position(|w| w == needle) } -/// Compute the absolute recv-transcript byte range for a JSON field value. -/// -/// Given the full `recv` transcript data and a JSON field name, this function: -/// 1. Finds the HTTP response body range in `recv` -/// 2. Decodes the body (handling chunked transfer encoding) -/// 3. Finds the field value range in the decoded body -/// 4. Maps it back to an absolute range in the raw `recv` data +/// The raw bytes are the member, and not the member with framing through it. /// -/// Returns the absolute byte range within `recv` that contains just the -/// field's string value (without quotes). -pub fn compute_field_reveal_range(recv: &[u8], field_name: &str) -> Option> { - let body_range = find_response_body_range(recv)?; - let raw_body = &recv[body_range.clone()]; - let decoded_body = extract_response_body(recv).ok()?; - - // Find field in decoded body to validate it exists - let _decoded_field_range = find_json_field_range(&decoded_body, field_name)?; - - // For the actual byte range, search in the raw body (which may include - // chunk framing). The field bytes are the same in both representations. - let raw_field_range = find_json_field_range(raw_body, field_name)?; - - let start = body_range.start.checked_add(raw_field_range.start)?; - let end = body_range.start.checked_add(raw_field_range.end)?; - Some(start..end) -} - -/// Find the byte range of a full JSON key-value snippet: `"key":"value"`. +/// A chunked body carries `\r\n\r\n` between chunks, and that framing +/// holds no quote, comma or brace -- so a member split across a boundary is +/// found in the decoded body AND in the raw one, and the raw range silently +/// spans the framing. What that range selects is not the member: revealed, it +/// puts framing inside the handle a verifier reads; committed, it puts framing +/// inside the bearer a circuit opens against the clean value the caller was +/// handed. Re-framing cannot repair it, because a commitment covers one +/// contiguous run and this member is two. /// -/// Unlike [`find_json_field_range`] which returns only the value bytes, -/// this returns the range from the opening `"` of the key to the closing -/// `"` of the value (inclusive). -pub fn find_json_snippet_range(body: &[u8], field: &str) -> Option> { - let needle = format!("\"{}\"", field); - let pos = body - .windows(needle.len()) - .position(|w| w == needle.as_bytes())?; - // pos points to the opening `"` of the key. - // Now find the closing `"` of the value (same logic as find_json_field_range). - let after_key = pos.checked_add(needle.len())?; - let colon = body - .get(after_key..)? - .iter() - .position(|&b| b == b':')? - .checked_add(after_key)?; - let after_colon = colon.checked_add(1)?; - let open_quote = body - .get(after_colon..)? - .iter() - .position(|&b| b == b'"')? - .checked_add(after_colon)?; - let after_open = open_quote.checked_add(1)?; - let close_quote = body - .get(after_open..)? - .iter() - .position(|&b| b == b'"')? - .checked_add(after_open)?; - // Range: from opening `"` of key to after the closing `"` of value. - Some(pos..close_quote.checked_add(1)?) +/// So the session is refused here, where the reason is a decodable body rather +/// than an unopenable commitment three components later. +fn require_contiguous(raw: &[u8], decoded: &[u8]) -> Option<()> { + (raw == decoded).then_some(()) } /// Find the byte range of a bare (unquoted) JSON number snippet: @@ -233,109 +250,56 @@ pub fn find_json_snippet_range(body: &[u8], field: &str) -> Option> /// trailing `,` that follows the number (matching the on-chain `idSuffix=,`). /// /// Returns `None` only when neither a `,` nor a `}` terminator follows the -/// number; both terminators are included in the range (on-chain `_extractId` -/// scans digits and stops at either). +/// number; both terminators are included in the range (on-chain +/// `tryJsonInteger` scans digits and stops at either). pub fn find_json_bare_snippet_range(body: &[u8], field: &str) -> Option> { - let needle = format!("\"{}\"", field); - let pos = body - .windows(needle.len()) - .position(|w| w == needle.as_bytes())?; - // pos points to the opening `"` of the key. - let after_key = pos.checked_add(needle.len())?; - let colon = body - .get(after_key..)? - .iter() - .position(|&b| b == b':')? - .checked_add(after_key)?; - let after_colon = colon.checked_add(1)?; - // Bound the number by the first `,` or `}` after the colon. - let term = body - .get(after_colon..)? - .iter() - .position(|&b| b == b',' || b == b'}')? - .checked_add(after_colon)?; - // Include trailing terminator (`,` or `}`); on-chain _extractId stops at either. - Some(pos..term.checked_add(1)?) + let needle = format!("\"{field}\":"); + let start = find_first(body, needle.as_bytes())?; + let from = start.checked_add(needle.len())?; + + // Digits, then the byte that closes them -- the order `tryJsonInteger` + // reads in. Scanning instead to the first `,` or `}` would accept + // `"id":"7",`, a quoted value returned as though it were a number: the + // chain then refuses it as noncanonical, which is the same answer given + // where nobody can see the reason. + let rest = body.get(from..)?; + let width = rest.iter().take_while(|b| b.is_ascii_digit()).count(); + if width == 0 { + return None; + } + // A leading zero is noncanonical, and `0` alone is not a leading zero. + if width > 1 && rest[0] == b'0' { + return None; + } + + // The terminator is revealed with the digits: it is what proves they are + // the whole number rather than a prefix of a longer one, and the profile + // fixes it as `,` or `}` and no other byte (REQ-PLAT-51). + let term = from.checked_add(width)?; + match body.get(term) { + Some(b',') | Some(b'}') => Some(start..term.checked_add(1)?), + _ => None, + } } -/// Like [`compute_field_reveal_range`] but returns the range covering the +/// Like [`compute_field_snippet_range`] but returns the range covering the /// full JSON snippet `"key":"value"` instead of just the value. /// -/// The revealed bytes become a Merkle leaf that the contract can verify -/// against the expected `abi.encodePacked(handlePrefix, username, '"')`. +/// The revealed bytes are what `CeremonyFields` reads the value out of on +/// chain, which is why the range covers the delimiters and not just the +/// value. pub fn compute_field_snippet_range( recv: &[u8], field_name: &str, ) -> Option> { - let body_range = find_response_body_range(recv)?; - let raw_body = &recv[body_range.clone()]; - let decoded_body = extract_response_body(recv).ok()?; - - // Validate field exists in decoded body - let _decoded = find_json_snippet_range(&decoded_body, field_name)?; - - // Find in raw body (may include chunk framing) - let raw_snippet_range = find_json_snippet_range(raw_body, field_name)?; - - let start = body_range.start.checked_add(raw_snippet_range.start)?; - let end = body_range.start.checked_add(raw_snippet_range.end)?; - Some(start..end) -} - -/// Like [`compute_id_snippet_range`] but only matches `field_name` after the -/// first occurrence of `anchor_field` (disambiguates a non-unique id field). -pub fn compute_id_snippet_range_after( - recv: &[u8], - field_name: &str, - quoted: bool, - anchor_field: &str, -) -> Option> { - let body_range = find_response_body_range(recv)?; - let raw_body = &recv[body_range.clone()]; - - // Include the `:` so we match the JSON KEY `"user":` — not a substring of a - // user-controlled body field (whose quotes are JSON-escaped) nor a sibling - // key like `"user_view_type"`. - let anchor_needle = format!("\"{}\":", anchor_field); - - // Validate the anchored id against the DECODED body (chunk-framing stripped), - // so a body that is chunked or contains decoy bytes can't drive the result. - { - let decoded = extract_response_body(recv).ok()?; - let danchor = decoded - .windows(anchor_needle.len()) - .position(|w| w == anchor_needle.as_bytes())?; - let dsub = decoded.get(danchor.checked_add(anchor_needle.len())?..)?; - if quoted { - find_json_snippet_range(dsub, field_name)?; - } else { - find_json_bare_snippet_range(dsub, field_name)?; - } - } - - // The Merkle leaf is over the RAW transcript, so map the range there. (A - // snippet split across a chunk boundary won't be found contiguously here and - // fails closed — never mis-resolves.) - let anchor_pos = raw_body - .windows(anchor_needle.len()) - .position(|w| w == anchor_needle.as_bytes())?; - let search_from = anchor_pos.checked_add(anchor_needle.len())?; - let sub = raw_body.get(search_from..)?; - - let rel = if quoted { - find_json_snippet_range(sub, field_name)? - } else { - find_json_bare_snippet_range(sub, field_name)? - }; - let base = body_range.start.checked_add(search_from)?; - Some(base.checked_add(rel.start)?..base.checked_add(rel.end)?) + JsonMember::in_response(recv, field_name).map(|found| found.member) } /// Compute the absolute recv-transcript range for an id snippet, dispatching on /// quotedness: `quoted` → `"id":""`, otherwise the bare `"id":[,}]` form. /// /// Returns `None` if the field is absent. Both `,`- and `}`-terminated bare -/// numbers are matched (on-chain `_extractId` scans digits past either). +/// numbers are matched (on-chain `tryJsonInteger` scans digits past either). pub fn compute_id_snippet_range( recv: &[u8], field_name: &str, @@ -348,9 +312,12 @@ pub fn compute_id_snippet_range( let raw_body = &recv[body_range.clone()]; let decoded_body = extract_response_body(recv).ok()?; - // Validate the snippet exists in the decoded body. - let _decoded = find_json_bare_snippet_range(&decoded_body, field_name)?; + let decoded_range = find_json_bare_snippet_range(&decoded_body, field_name)?; let raw_snippet_range = find_json_bare_snippet_range(raw_body, field_name)?; + require_contiguous( + raw_body.get(raw_snippet_range.clone())?, + decoded_body.get(decoded_range)?, + )?; let start = body_range.start.checked_add(raw_snippet_range.start)?; let end = body_range.start.checked_add(raw_snippet_range.end)?; @@ -359,83 +326,112 @@ pub fn compute_id_snippet_range( #[cfg(test)] mod tests { - use super::*; - + /// A chunk header that is not a hex size used to end the body silently: + /// the size parsed as `unwrap_or(0)`, the loop hit `break`, and the caller + /// got a short body with no error. The reveal ranges are computed from + /// that body, so the prover would select them over bytes the server never + /// sent -- and never learn. #[test] - fn find_json_field_range_simple() { - let body = br#"{"login":"octocat","id":123}"#; - let range = find_json_field_range(body, "login").unwrap(); - assert_eq!(&body[range], b"octocat"); + fn a_malformed_chunk_size_is_an_error_not_a_short_body() { + let recv = b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n\ +5\r\nhello\r\nzz\r\nworld\r\n0\r\n\r\n"; + assert!(super::extract_response_body(recv).is_err()); } #[test] - fn find_json_field_range_nested() { - let body = br#"{"user":{"login":"octocat"},"body":"hello"}"#; - let range = find_json_field_range(body, "login").unwrap(); - assert_eq!(&body[range], b"octocat"); - - let range = find_json_field_range(body, "body").unwrap(); - assert_eq!(&body[range], b"hello"); + fn a_truncated_chunk_is_an_error_too() { + // The size says 20 bytes and 5 follow. + let recv = b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n\ +14\r\nhello"; + assert!(super::extract_response_body(recv).is_err()); } + use super::*; + #[test] - fn find_json_field_range_x_tweet() { - let body = br#"{"data":[{"text":"@libid greet @bob with 1 TST","id":"123"}],"includes":{"users":[{"username":"alice"}]}}"#; - let range = find_json_field_range(body, "text").unwrap(); - assert_eq!(&body[range], b"@libid greet @bob with 1 TST"); + fn extract_response_body_decodes_chunked() { + let recv = b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n7\r\n{\"a\":1,\r\n8\r\n\"b\":\"x\"}\r\n0\r\n\r\n"; + let body = extract_response_body(recv).unwrap(); + assert_eq!(body, br#"{"a":1,"b":"x"}"#); + } - let range = find_json_field_range(body, "username").unwrap(); - assert_eq!(&body[range], b"alice"); + #[test] + fn find_json_snippet_range_simple() { + let body = br#"{"login":"octocat","id":123}"#; + let range = find_json_snippet_range(body, "login").unwrap(); + assert_eq!(&body[range], br#""login":"octocat""#); } #[test] - fn compute_field_reveal_range_from_http() { - // Simulate a minimal HTTP response with a JSON body - let recv = b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\r\n{\"body\":\"hello world\",\"user\":{\"login\":\"alice\"}}"; + fn find_json_snippet_range_nested() { + let body = br#"{"user":{"login":"octocat"},"body":"hello"}"#; + let range = find_json_snippet_range(body, "login").unwrap(); + assert_eq!(&body[range], br#""login":"octocat""#); + } - let range = compute_field_reveal_range(recv, "body").unwrap(); - assert_eq!(&recv[range], b"hello world"); + #[test] + fn a_second_member_is_left_for_the_layout_to_commit() { + // Not refused here: the reader's uniqueness rule is over the bytes it + // was shown, and the layout is what decides those. `identity_response` + // reveals this one and commits the rest, so the reader sees one. + let body = br#"{"login":"octocat","user":{"login":"impostor"}}"#; + let range = find_json_snippet_range(body, "login").unwrap(); + assert_eq!(&body[range], br#""login":"octocat""#); - let range = compute_field_reveal_range(recv, "login").unwrap(); - assert_eq!(&recv[range], b"alice"); + let bare = br#"{"id":1,"user":{"id":2}}"#; + let range = find_json_bare_snippet_range(bare, "id").unwrap(); + assert_eq!(&bare[range], br#""id":1,"#); } #[test] - fn compute_field_reveal_range_missing_field() { - let recv = - b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\r\n{\"foo\":\"bar\"}"; - assert!(compute_field_reveal_range(recv, "missing").is_none()); + fn a_spaced_member_is_refused_because_the_reader_refuses_it() { + // The on-chain needle is the literal `"login":"`. Selecting a range + // here that the reader cannot read only moves the same refusal to + // where its reason is invisible. + let body = br#"{"login" : "octocat"}"#; + assert!(find_json_snippet_range(body, "login").is_none()); + + let bare = br#"{"id" : 123}"#; + assert!(find_json_bare_snippet_range(bare, "id").is_none()); } #[test] - fn extract_response_body_decodes_chunked() { - let recv = b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n7\r\n{\"a\":1,\r\n8\r\n\"b\":\"x\"}\r\n0\r\n\r\n"; - let body = extract_response_body(recv).unwrap(); - assert_eq!(body, br#"{"a":1,"b":"x"}"#); + fn a_quoted_value_is_not_a_bare_number() { + // `tryJsonInteger` scans DIGITS and then demands the terminator. A scan + // that instead ran to the first `,` would return `"id":"7",` here, and + // the chain would refuse it as noncanonical -- the same answer, given + // where the reason is not visible. + let body = br#"{"login":"octocat","id":"7","x":1}"#; + assert!(find_json_bare_snippet_range(body, "id").is_none()); } #[test] - fn find_notary_reveal_ranges_covers_request_line_and_host() { - let sent = b"GET /2/users/me HTTP/1.1\r\nHost: api.x.com\r\nAccept: application/json\r\n\r\n"; - let ranges = find_notary_reveal_ranges(sent); - assert_eq!(ranges.len(), 2); - assert_eq!(&sent[ranges[0].clone()], b"GET /2/users/me HTTP/1.1\r\n"); - assert_eq!(&sent[ranges[1].clone()], b"\r\nHost: api.x.com\r\n"); - assert_eq!(find_presentation_commit_ranges(sent), ranges); + fn a_leading_zero_is_refused_but_zero_itself_is_not() { + // `end - at > 1 && data[at] == "0"` on chain: `0123` is noncanonical, + // `0` is just zero. + assert!(find_json_bare_snippet_range(br#"{"id":0123,"x":1}"#, "id").is_none()); + let zero = br#"{"id":0,"x":1}"#; + let range = find_json_bare_snippet_range(zero, "id").unwrap(); + assert_eq!(&zero[range], br#""id":0,"#); } #[test] - fn find_json_snippet_range_simple() { - let body = br#"{"login":"octocat","id":123}"#; - let range = find_json_snippet_range(body, "login").unwrap(); - assert_eq!(&body[range], br#""login":"octocat""#); + fn a_terminator_the_profile_does_not_fix_is_refused() { + // Only `,` and `}` close the digits. A `]` means the id sat in an array + // the profile never described. + assert!(find_json_bare_snippet_range(br#"{"a":[1,"id":7]}"#, "id").is_none()); + // And digits running to the end of the range have no terminator at all, + // which is `Found.None` on chain rather than a value. + assert!(find_json_bare_snippet_range(br#"{"id":7"#, "id").is_none()); } #[test] - fn find_json_snippet_range_nested() { - let body = br#"{"user":{"login":"octocat"},"body":"hello"}"#; - let range = find_json_snippet_range(body, "login").unwrap(); - assert_eq!(&body[range], br#""login":"octocat""#); + fn a_lookalike_key_does_not_match() { + // `"node_id":` contains `id":` but not `"id":` -- the full delimiter is + // what keeps a neighbouring member out, on both sides. + let body = br#"{"node_id":"MDQ=","id":123}"#; + let range = find_json_bare_snippet_range(body, "id").unwrap(); + assert_eq!(&body[range], br#""id":123}"#); } #[test] @@ -456,6 +452,106 @@ mod tests { assert_eq!(&recv[range], br#""body":"hello world""#); } + /// A chunked response whose `field` value is cut in half by a chunk + /// boundary. The framing carries no quote, comma or brace, so every scan + /// here runs straight through it. + fn straddling(head: &str, tail: &str) -> Vec { + let mut out = b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n".to_vec(); + for part in [head, tail] { + out.extend_from_slice(format!("{:x}\r\n", part.len()).as_bytes()); + out.extend_from_slice(part.as_bytes()); + out.extend_from_slice(b"\r\n"); + } + out.extend_from_slice(b"0\r\n\r\n"); + out + } + + /// The property every caller of `JsonMember::in_response` depends on: the + /// value sits inside the member, and what the member holds either side of + /// it is exactly the two delimiters. A boundary that drifts breaks this + /// before it reaches a layout, where the symptom is a committed bearer with + /// a quote in it. + fn assert_brackets(recv: &[u8], found: &JsonMember, field: &str, value: &[u8]) { + assert!( + found.member.start <= found.value.start + && found.value.end <= found.member.end, + "the value must sit inside the member" + ); + assert_eq!(&recv[found.value.clone()], value, "value bytes"); + assert_eq!( + &recv[found.member.start..found.value.start], + format!("\"{field}\":\"").as_bytes(), + "opening delimiter" + ); + assert_eq!( + &recv[found.value.end..found.member.end], + b"\"", + "closing quote" + ); + } + + #[test] + fn the_member_brackets_its_value_with_the_two_delimiters() { + let recv = b"HTTP/1.1 200 OK\r\n\r\n{\"access_token\":\"ghu_ABC\",\"x\":1}"; + let found = JsonMember::in_response(recv, "access_token").unwrap(); + assert_brackets(recv, &found, "access_token", b"ghu_ABC"); + } + + #[test] + fn a_value_carrying_structural_bytes_still_ends_at_its_quote() { + // Only `"` closes a JSON string, so a value holding `:`, `,` or `}` + // must not shorten the member -- a scan that stopped at one would + // commit a prefix of the bearer and reveal the rest of it. + let recv = b"HTTP/1.1 200 OK\r\n\r\n{\"access_token\":\"a:b,c}d\",\"x\":1}"; + let found = JsonMember::in_response(recv, "access_token").unwrap(); + assert_brackets(recv, &found, "access_token", b"a:b,c}d"); + } + + #[test] + fn an_empty_value_is_found_with_an_empty_range() { + // Found, not refused: whether an empty value is usable is the caller's + // rule, and `token_response` has its own reason to refuse one. + let recv = b"HTTP/1.1 200 OK\r\n\r\n{\"access_token\":\"\"}"; + let found = JsonMember::in_response(recv, "access_token").unwrap(); + assert!(found.value.is_empty()); + assert_eq!(&recv[found.member.clone()], b"\"access_token\":\"\""); + } + + #[test] + fn the_member_range_is_the_snippet_range() { + // `compute_field_snippet_range` is this with the value dropped, and the + // two must not drift apart. + let recv = b"HTTP/1.1 200 OK\r\n\r\n{\"login\":\"octocat\",\"id\":1}"; + assert_eq!( + JsonMember::in_response(recv, "login").unwrap().member, + compute_field_snippet_range(recv, "login").unwrap() + ); + } + + #[test] + fn a_member_split_by_chunk_framing_is_refused() { + // Found in both bodies, and the raw range spans `\r\n\r\n` in the + // middle of the value. Revealed it would put framing inside the handle + // a verifier reads; committed, inside the bearer a circuit opens. + let recv = straddling(r#"{"login":"oct"#, r#"ocat","id":1}"#); + assert!(compute_field_snippet_range(&recv, "login").is_none()); + } + + #[test] + fn a_bare_id_split_by_chunk_framing_is_refused() { + let recv = straddling(r#"{"login":"octocat","id":12"#, r#"34,"x":1}"#); + assert!(compute_id_snippet_range(&recv, "id", false).is_none()); + } + + #[test] + fn a_chunked_member_inside_one_chunk_still_resolves() { + // The point is contiguity, not chunking: a body that happens to be + // chunked is fine as long as the member sits in one piece. + let recv = straddling(r#"{"login":"octocat","#, r#""id":1}"#); + let range = compute_field_snippet_range(&recv, "login").unwrap(); + assert_eq!(&recv[range], br#""login":"octocat""#); + } + #[test] fn compute_field_snippet_range_missing_field() { let recv = @@ -476,7 +572,7 @@ mod tests { #[test] fn find_json_bare_snippet_range_brace_terminated() { // id is the last field — terminated by `}`. The snippet includes the - // `}`; on-chain _extractId scans digits and stops at it. + // `}`; `CeremonyFields.tryJsonInteger` scans digits and stops at it. let body = br#"{"login":"octocat","id":123}"#; let range = find_json_bare_snippet_range(body, "id").unwrap(); assert_eq!(&body[range], br#""id":123}"#); @@ -503,13 +599,4 @@ mod tests { let range = compute_id_snippet_range(recv, "id", true).unwrap(); assert_eq!(&recv[range], br#""id":"123""#); } - - #[test] - fn compute_id_snippet_range_after_anchor() { - // The id under `"user":` is the one that must resolve, not the decoy - // earlier in the body. - let recv = b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\r\n{\"id\":999,\"user\":{\"login\":\"octocat\",\"id\":123,\"x\":1}}"; - let range = compute_id_snippet_range_after(recv, "id", false, "user").unwrap(); - assert_eq!(&recv[range], br#""id":123,"#); - } } diff --git a/crates/libid-transcript/src/types.rs b/crates/libid-transcript/src/types.rs deleted file mode 100644 index 3a31d398..00000000 --- a/crates/libid-transcript/src/types.rs +++ /dev/null @@ -1,149 +0,0 @@ -//! Proof-related types: EVM proofs and notary responses. -//! -//! These are the notary's outputs as consumed by backends, ZK provers and -//! on-chain verifiers. With the `ts` feature enabled they additionally derive -//! `ts_rs::TS` so TypeScript bindings can be generated. - -use serde::{ - Deserialize, - Serialize, -}; - -/// TLS handshake data for EVM proof construction. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct TlsHandshakeData { - /// TLS client random (32 bytes). - pub client_random: [u8; 32], - /// TLS server random (32 bytes). - pub server_random: [u8; 32], - /// Server ephemeral public key (uncompressed, 65 bytes). - pub server_ephemeral_key: Vec, -} - -/// EVM-compatible proof for on-chain verification. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -pub struct EvmProof { - /// The domain being attested, taken directly from TLS SNI (authenticated - /// by the CA hierarchy during the MPC-TLS handshake). - pub domain: String, - /// The endpoint (method + path), extracted from the revealed HTTP request - /// line in the transcript (e.g., "GET /2/users/me"). - pub endpoint: String, - /// TLS client random (32 bytes). - pub client_random: [u8; 32], - /// TLS server random (32 bytes). - pub server_random: [u8; 32], - /// Server ephemeral public key. - pub server_ephemeral_key: Vec, - /// Merkle root over `[domain_leaf, endpoint_leaf, recv_seg_0, ...]`. - pub transcript_root: [u8; 32], - /// Merkle leaves (domain, endpoint, and recv segment hashes). - pub leaves: Vec<[u8; 32]>, - /// Unix timestamp of proof generation. - pub timestamp: u64, - /// Notary signature over the proof digest. - pub notary_signature: Vec, - /// Raw revealed recv segments (plaintext bytes) — used as ZK circuit - /// private input. Each element corresponds to a Merkle leaf in - /// `leaves[2..]`. - #[serde(default)] - pub recv_segments: Vec>, - /// Explicit nonce (8 bytes) from the first AppData TLS record. - /// Only set in ZK proxy path. Combined with server_write_iv to form the - /// 12-byte GCM nonce. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub explicit_nonce: Vec, - /// First 160 bytes of the first AppData TLS record ciphertext. - /// Only set in ZK proxy path. Circuit input for AES-128-CTR decryption. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub app_ciphertext: Vec, -} - -/// Response from the notary server containing attestation and proof. -/// -/// The notary is fully platform-agnostic: it never imports platform -/// definitions, never parses the API response, and never validates domains -/// against a whitelist. It attests to the domain (from TLS SNI), the endpoint -/// (from the revealed HTTP request line), and returns the raw response body -/// for the backend to parse. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -pub struct NotaryResponse { - /// TLSNotary attestation bytes. - pub attestation: Vec, - /// EVM-compatible proof. - /// - /// - `domain`: from TLS SNI, authenticated by the CA hierarchy. - /// - `endpoint`: from the revealed HTTP request line in the transcript. - /// - `transcript_root`: Merkle root over - /// `[domain_leaf, endpoint_leaf, recv_seg_0, recv_seg_1, ...]`. - pub evm_proof: EvmProof, -} - -#[cfg(test)] -mod tests { - use super::*; - - fn sample_proof() -> EvmProof { - EvmProof { - domain: "api.x.com".into(), - endpoint: "GET /2/users/me".into(), - client_random: [1u8; 32], - server_random: [2u8; 32], - server_ephemeral_key: vec![4u8; 65], - transcript_root: [3u8; 32], - leaves: vec![[5u8; 32], [6u8; 32]], - timestamp: 1_700_000_000, - notary_signature: vec![7u8; 65], - recv_segments: vec![br#""username":"alice""#.to_vec()], - explicit_nonce: Vec::new(), - app_ciphertext: Vec::new(), - } - } - - #[test] - fn evm_proof_serde_round_trip() { - let proof = sample_proof(); - let json = serde_json::to_string(&proof).unwrap(); - let back: EvmProof = serde_json::from_str(&json).unwrap(); - assert_eq!(back.domain, proof.domain); - assert_eq!(back.transcript_root, proof.transcript_root); - assert_eq!(back.recv_segments, proof.recv_segments); - // Empty ZK-proxy fields are skipped on the wire… - assert!(!json.contains("explicit_nonce")); - // …and default back to empty on read. - assert!(back.explicit_nonce.is_empty()); - } - - #[test] - fn evm_proof_reads_legacy_payload_without_optional_fields() { - // Payloads produced before recv_segments/nonce/ciphertext existed - // must still parse (serde defaults). - let json = r#"{ - "domain": "api.x.com", - "endpoint": "GET /2/users/me", - "client_random": [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0], - "server_random": [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0], - "server_ephemeral_key": [], - "transcript_root": [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0], - "leaves": [], - "timestamp": 0, - "notary_signature": [] - }"#; - let proof: EvmProof = serde_json::from_str(json).unwrap(); - assert!(proof.recv_segments.is_empty()); - } - - #[test] - fn notary_response_serde_round_trip() { - let resp = NotaryResponse { - attestation: vec![9u8; 16], - evm_proof: sample_proof(), - }; - let json = serde_json::to_vec(&resp).unwrap(); - let back: NotaryResponse = serde_json::from_slice(&json).unwrap(); - assert_eq!(back.attestation, resp.attestation); - assert_eq!(back.evm_proof.endpoint, resp.evm_proof.endpoint); - } -} diff --git a/crates/libid-transcript/src/wire.rs b/crates/libid-transcript/src/wire.rs index fe274255..c6fad795 100644 --- a/crates/libid-transcript/src/wire.rs +++ b/crates/libid-transcript/src/wire.rs @@ -6,6 +6,7 @@ use serde::{ de::DeserializeOwned, + Deserialize, Serialize, }; use tokio::io::{ @@ -23,6 +24,32 @@ use crate::{ /// Maximum allowed message size (10 MB). const MAX_MSG_SIZE: usize = 10 * 1024 * 1024; +/// The notary's answer to a completed session: the attested-data record its +/// profile pins, and the signature over it. +/// +/// It lives here rather than in either party because both speak it. The notary +/// writes it -- onto the recovered socket for an MPC prover, and as the body of +/// its ProxyMode attestation endpoint for a browser -- and a prover reads it +/// back. Held privately on one side and mirrored on the other, a renamed field +/// fails at parse time with an error that says nothing about which side moved. +/// +/// The notary places nothing here that it derived by applying a profile rule: +/// no handle, no account identifier, no client identifier, no chain address +/// (REQ-COMMON-33). Every one is derivable from the revealed ranges, and a +/// second signed representation can disagree with the bytes it came from. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AttestationWire { + /// The exact bytes of the pinned attested-data format, as the notary encoded + /// them. Carried whole rather than re-encoded from a decoded form: the + /// signature is over these bytes, and a field reordered on the way through + /// derives a key nobody trusts. + pub attested_data: Vec, + /// EIP-191 over `keccak256(attested_data)`. The verifying side derives the + /// key from this pair alone and accepts no caller-supplied digest + /// (REQ-COMMON-33). + pub notary_signature: Vec, +} + /// Write a message with length prefix. /// /// The message is serialized as JSON, then prefixed with a 4-byte @@ -87,6 +114,37 @@ mod tests { assert_eq!(got, msg); } + /// The one message this protocol carries in production, written the way the + /// notary writes it and read the way a prover reads it. Held privately on + /// each side, this is exactly the round trip nothing would have checked. + #[tokio::test] + async fn the_notary_record_survives_the_wire() { + let (mut notary, mut prover) = tokio::io::duplex(64 * 1024); + let sent = AttestationWire { + attested_data: vec![0xde, 0xad, 0xbe, 0xef], + notary_signature: vec![7u8; 65], + }; + write_msg(&mut notary, &sent).await.unwrap(); + let got: AttestationWire = read_msg(&mut prover).await.unwrap(); + assert_eq!(got, sent); + } + + /// The shape a browser receives. The notary serves this same struct as the + /// body of its ProxyMode attestation endpoint, so its JSON is a public + /// contract -- and a Rust-to-Rust round trip would not notice it changing, + /// because both ends would change together. + #[test] + fn the_record_serialises_to_the_shape_its_readers_expect() { + let record = AttestationWire { + attested_data: vec![0xde, 0xad, 0xbe, 0xef], + notary_signature: vec![1, 2, 3], + }; + assert_eq!( + serde_json::to_string(&record).unwrap(), + r#"{"attested_data":[222,173,190,239],"notary_signature":[1,2,3]}"# + ); + } + #[tokio::test] async fn round_trip_sequence_preserves_framing() { let (mut a, mut b) = tokio::io::duplex(64 * 1024);