From 6e605605b8965ce22081ba381d8e212f547fd46b Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Thu, 20 Aug 2026 14:16:05 +0300 Subject: [PATCH 01/65] feat: add the ceremony wire constructions One implementation of each construction the specification fixes, so the notary, the backend and the conformance suite cannot disagree about bytes. The Authorization Digest of ceremony-common section 5 binds one authorization to the transaction that will consume it. Only the transaction data varies in length, so every other field sits at a fixed offset and no boundary can be shifted to reinterpret one preimage as another. The chain id is the keccak256 of whatever bytes a chain's identifier contributes, never the identifier itself, because chains name themselves incompatibly and some too wide for 64 bits. The PKCE construction of section 7 is how X and GitHub carry that digest through an OAuth authorization, since neither can hold it the way Google holds it in an OIDC nonce. The verifier is revealed in the notarized token request and the Platform Verifier recomputes it, so retargeting an attestation to another digest would take a second preimage. The attested-data layout of section 9.1 is what the notary signs off chain and what the Platform Verifier rebuilds on chain, where it holds no transcript. Every boundary is derivable from bytes that precede it, so decoding is one forward pass; the decoder refuses trailing bytes, a count that outruns its buffer, and any truncation, and both directions reject ranges that are empty, out of order, overlapping, or past the signed transcript length. The three published conformance vectors are the tests, transcribed from the specification and reproduced independently with cast keccak before use: authorization digest b318fb55...4c0af5 with its full 102-byte preimage, and the PKCE triple ending in code_verifier iMSTNh6gQkRnBGlY1c0MUOsD7MCO4G8C7ph1_gIZs5I. Assisted-by: Claude Opus 5 Signed-off-by: SupremaLex --- Cargo.lock | 11 + Cargo.toml | 3 + crates/libid-ceremony/Cargo.toml | 17 + crates/libid-ceremony/src/attestation.rs | 578 +++++++++++++++++++++ crates/libid-ceremony/src/authorization.rs | 171 ++++++ crates/libid-ceremony/src/lib.rs | 35 ++ crates/libid-ceremony/src/pkce.rs | 146 ++++++ 7 files changed, 961 insertions(+) create mode 100644 crates/libid-ceremony/Cargo.toml create mode 100644 crates/libid-ceremony/src/attestation.rs create mode 100644 crates/libid-ceremony/src/authorization.rs create mode 100644 crates/libid-ceremony/src/lib.rs create mode 100644 crates/libid-ceremony/src/pkce.rs diff --git a/Cargo.lock b/Cargo.lock index c14efce7..542d0a63 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3362,6 +3362,17 @@ dependencies = [ "libid-crypto", ] +[[package]] +name = "libid-ceremony" +version = "0.1.0" +dependencies = [ + "base64 0.22.1", + "hex", + "libid-crypto", + "sha2 0.10.9", + "thiserror 2.0.20", +] + [[package]] name = "libid-crypto" version = "0.3.0" diff --git a/Cargo.toml b/Cargo.toml index 9f9e0996..5a80569a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,7 @@ 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"] } @@ -34,6 +35,7 @@ alloy-sol-types = "1" # pinned to the 1.x line so cargo unifies them with whatever alloy resolves. aws-config = "1" aws-sdk-kms = "1" +base64 = "0.22" hex = "0.4" http-body-util = "0.1" hyper = { version = "1.1", features = ["client", "http1"] } @@ -42,6 +44,7 @@ k256 = { version = "0.13", features = ["ecdsa", "sha256"] } rand = "0.8" serde = { version = "1", features = ["derive"] } serde_json = "1.0" +sha2 = "0.10" thiserror = "2" tiny-keccak = { version = "2.0", features = ["keccak"] } # Upstream TLSNotary, same pin as the original monorepo. A GIT dependency: any crate that diff --git a/crates/libid-ceremony/Cargo.toml b/crates/libid-ceremony/Cargo.toml new file mode 100644 index 00000000..bef1c1a2 --- /dev/null +++ b/crates/libid-ceremony/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "libid-ceremony" +description = "Wire constructions of the libID identity ceremony: authorization digest, PKCE binding, and attestation format." +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +base64.workspace = true +libid-crypto.workspace = true +sha2.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..0865ee0a --- /dev/null +++ b/crates/libid-ceremony/src/attestation.rs @@ -0,0 +1,578 @@ +//! The attestation format of ceremony-common section 9.1. +//! +//! 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 +//! (REQ-COMMON-47). +//! +//! 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 (REQ-COMMON-48). + +use libid_crypto::keccak256; + +/// Offsets are zero-based into that direction's complete transcript, `start` +/// inclusive and `end` exclusive. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RevealedRange { + pub start: u32, + pub end: u32, + 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 +/// (REQ-COMMON-60). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RangeCommitment { + pub start: u32, + pub end: u32, + pub commitment: [u8; 32], +} + +/// One direction of the session. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +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)] +pub struct AttestedData { + pub format_tag: [u8; 32], + pub platform_id: [u8; 32], + pub operation_tag: [u8; 32], + 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: four 32-byte tags, `createdAt`, and +/// the two transcript lengths. +pub const HEADER_LEN: usize = 32 * 4 + 8 + 4 + 4; + +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum AttestationError { + #[error("attested data ends inside the {field} field")] + Truncated { field: &'static str }, + #[error("{0} bytes remain after the received direction block")] + TrailingBytes(usize), + #[error("a direction holds {0} entries, which does not fit the two-byte count")] + CountTooLarge(usize), + #[error("revealed range {index} of the {direction} direction carries {carried} bytes for offsets {start}..{end}")] + RangeLengthMismatch { + direction: &'static str, + index: usize, + start: u32, + end: u32, + carried: usize, + }, + #[error("{kind} {index} of the {direction} direction is empty at offset {start}")] + EmptyRange { + direction: &'static str, + kind: &'static str, + index: usize, + start: u32, + }, + #[error("{kind} {index} of the {direction} direction starts at {start}, behind the previous end {previous_end}")] + OutOfOrder { + direction: &'static str, + kind: &'static str, + index: usize, + start: u32, + previous_end: u32, + }, + #[error("{kind} {index} of the {direction} direction ends at {end}, past the signed transcript length {length}")] + PastTranscriptEnd { + direction: &'static str, + kind: &'static str, + index: usize, + end: u32, + length: u32, + }, + #[error("a commitment of the {direction} direction overlaps a revealed range at {start}..{end}")] + CommitmentOverlapsRevealed { + direction: &'static str, + start: u32, + end: u32, + }, +} + +/// Derive a 32-byte tag from a libID-namespaced ASCII string. +/// +/// Used for `formatTag` (REQ-COMMON-53), `platformId` and `operationTag` +/// (REQ-COMMON-55), and `authorityId` over the canonical authority bytes +/// (REQ-COMMON-56). +pub fn tag(namespaced: &str) -> [u8; 32] { + keccak256(namespaced.as_bytes()) +} + +impl DirectionBlock { + fn validate( + &self, + direction: &'static str, + length: u32, + ) -> Result<(), AttestationError> { + if self.revealed.len() > u16::MAX as usize { + return Err(AttestationError::CountTooLarge(self.revealed.len())); + } + if self.commitments.len() > u16::MAX as usize { + return Err(AttestationError::CountTooLarge(self.commitments.len())); + } + + let mut previous_end = 0u32; + for (index, range) in self.revealed.iter().enumerate() { + check_span( + direction, + "revealed range", + index, + range.start, + range.end, + length, + &mut previous_end, + )?; + let want = (range.end - range.start) as usize; + if range.bytes.len() != want { + return Err(AttestationError::RangeLengthMismatch { + direction, + index, + start: range.start, + end: range.end, + carried: range.bytes.len(), + }); + } + } + + let mut previous_end = 0u32; + for (index, commitment) in self.commitments.iter().enumerate() { + check_span( + direction, + "commitment", + index, + commitment.start, + commitment.end, + length, + &mut previous_end, + )?; + // A committed range that overlaps a revealed one would let the + // same bytes be both read and hidden. + for revealed in &self.revealed { + if commitment.start < revealed.end && revealed.start < commitment.end { + return Err(AttestationError::CommitmentOverlapsRevealed { + direction, + start: commitment.start, + end: commitment.end, + }); + } + } + } + Ok(()) + } + + fn encode_into(&self, out: &mut Vec) { + out.extend_from_slice(&(self.revealed.len() as u16).to_be_bytes()); + for range in &self.revealed { + out.extend_from_slice(&range.start.to_be_bytes()); + out.extend_from_slice(&range.end.to_be_bytes()); + out.extend_from_slice(&range.bytes); + } + out.extend_from_slice(&(self.commitments.len() as u16).to_be_bytes()); + for commitment in &self.commitments { + out.extend_from_slice(&commitment.start.to_be_bytes()); + out.extend_from_slice(&commitment.end.to_be_bytes()); + out.extend_from_slice(&commitment.commitment); + } + } +} + +fn check_span( + direction: &'static str, + kind: &'static str, + index: usize, + start: u32, + end: u32, + length: u32, + previous_end: &mut u32, +) -> Result<(), AttestationError> { + if end <= start { + return Err(AttestationError::EmptyRange { + direction, + kind, + index, + start, + }); + } + if start < *previous_end { + return Err(AttestationError::OutOfOrder { + direction, + kind, + index, + start, + previous_end: *previous_end, + }); + } + if end > length { + return Err(AttestationError::PastTranscriptEnd { + direction, + kind, + index, + end, + length, + }); + } + *previous_end = end; + Ok(()) +} + +/// A forward cursor that never panics on a short buffer. +struct Reader<'a> { + bytes: &'a [u8], + at: usize, +} + +impl<'a> Reader<'a> { + fn take( + &mut self, + n: usize, + field: &'static str, + ) -> Result<&'a [u8], AttestationError> { + let end = self + .at + .checked_add(n) + .ok_or(AttestationError::Truncated { field })?; + let slice = self + .bytes + .get(self.at..end) + .ok_or(AttestationError::Truncated { field })?; + self.at = end; + Ok(slice) + } + + fn take32(&mut self, field: &'static str) -> Result<[u8; 32], AttestationError> { + Ok(self.take(32, field)?.try_into().expect("checked length")) + } + + fn u16(&mut self, field: &'static str) -> Result { + Ok(u16::from_be_bytes( + self.take(2, field)?.try_into().expect("checked length"), + )) + } + + fn u32(&mut self, field: &'static str) -> Result { + Ok(u32::from_be_bytes( + self.take(4, field)?.try_into().expect("checked length"), + )) + } + + fn u64(&mut self, field: &'static str) -> Result { + Ok(u64::from_be_bytes( + self.take(8, field)?.try_into().expect("checked length"), + )) + } + + fn direction(&mut self) -> Result { + let revealed_count = self.u16("revealed range count")? as usize; + let mut revealed = Vec::with_capacity(revealed_count); + for _ in 0..revealed_count { + let start = self.u32("revealed range start")?; + let end = self.u32("revealed range end")?; + // A start past its end would wrap; the validate pass rejects the + // shape, so read nothing here rather than compute a huge length. + let len = end.saturating_sub(start) as usize; + revealed.push(RevealedRange { + start, + end, + bytes: self.take(len, "revealed range bytes")?.to_vec(), + }); + } + + let commitment_count = self.u16("commitment count")? as usize; + let mut commitments = Vec::with_capacity(commitment_count); + for _ in 0..commitment_count { + commitments.push(RangeCommitment { + start: self.u32("commitment start")?, + end: self.u32("commitment end")?, + commitment: self.take32("commitment value")?, + }); + } + + Ok(DirectionBlock { + revealed, + commitments, + }) + } +} + +impl AttestedData { + /// Reject a shape the Platform Verifier must refuse: ranges out of order, + /// overlapping, empty, or ending past the signed transcript length + /// (REQ-COMMON-59, REQ-COMMON-60). + pub fn validate(&self) -> Result<(), AttestationError> { + self.sent.validate("sent", self.sent_transcript_length)?; + self.received + .validate("received", self.recv_transcript_length) + } + + /// Serialize exactly the byte concatenation of section 9.1. + pub fn encode(&self) -> Result, AttestationError> { + self.validate()?; + let mut out = Vec::with_capacity(HEADER_LEN); + out.extend_from_slice(&self.format_tag); + out.extend_from_slice(&self.platform_id); + out.extend_from_slice(&self.operation_tag); + out.extend_from_slice(&self.authority_id); + out.extend_from_slice(&self.created_at.to_be_bytes()); + out.extend_from_slice(&self.sent_transcript_length.to_be_bytes()); + out.extend_from_slice(&self.recv_transcript_length.to_be_bytes()); + self.sent.encode_into(&mut out); + self.received.encode_into(&mut out); + Ok(out) + } + + /// Parse and validate. Trailing bytes are refused: the layout accounts for + /// every byte, so a suffix is a second message hiding behind the first. + pub fn decode(bytes: &[u8]) -> Result { + let mut reader = Reader { bytes, at: 0 }; + let decoded = AttestedData { + format_tag: reader.take32("formatTag")?, + platform_id: reader.take32("platformId")?, + operation_tag: reader.take32("operationTag")?, + authority_id: reader.take32("authorityId")?, + created_at: reader.u64("createdAt")?, + sent_transcript_length: reader.u32("sentTranscriptLength")?, + recv_transcript_length: reader.u32("recvTranscriptLength")?, + sent: reader.direction()?, + received: reader.direction()?, + }; + if reader.at != bytes.len() { + return Err(AttestationError::TrailingBytes(bytes.len() - reader.at)); + } + decoded.validate()?; + Ok(decoded) + } + + /// `keccak256(attestedData)` -- the only preimage the notary signs + /// (REQ-COMMON-47). + pub fn digest(&self) -> Result<[u8; 32], AttestationError> { + 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 { + format_tag: tag("libid.attestation.v1"), + platform_id: tag("x"), + operation_tag: tag("libid.ceremony.session.identity.v1"), + authority_id: tag("api.x.com"), + created_at: 1_770_000_000, + sent_transcript_length: 60, + recv_transcript_length: 40, + sent: DirectionBlock { + revealed: vec![ + RevealedRange { + start: 0, + end: 20, + bytes: vec![b'a'; 20], + }, + RevealedRange { + start: 40, + end: 60, + bytes: vec![b'b'; 20], + }, + ], + commitments: vec![RangeCommitment { + start: 20, + end: 40, + commitment: [7u8; 32], + }], + }, + received: DirectionBlock { + revealed: vec![RevealedRange { + start: 0, + end: 10, + bytes: vec![b'c'; 10], + }], + commitments: vec![RangeCommitment { + start: 10, + end: 40, + commitment: [9u8; 32], + }], + }, + } + } + + #[test] + fn round_trips() { + let data = sample(); + assert_eq!(AttestedData::decode(&data.encode().unwrap()).unwrap(), data); + } + + #[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. + assert_eq!(data.encode().unwrap().len(), HEADER_LEN + 4 + 4); + assert_eq!(HEADER_LEN, 144); + } + + #[test] + fn every_header_field_changes_the_digest() { + let base = sample().digest().unwrap(); + for mutate in [ + (|d: &mut AttestedData| d.format_tag[0] ^= 1) as fn(&mut AttestedData), + |d| d.platform_id[0] ^= 1, + |d| d.operation_tag[0] ^= 1, + |d| d.authority_id[0] ^= 1, + |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() { + // REQ-COMMON-55: without operationTag the token and identity + // attestations of one ceremony would differ in nothing a verifier reads. + let mut token = sample(); + token.operation_tag = tag("libid.ceremony.session.token.v1"); + assert_ne!(token.digest().unwrap(), sample().digest().unwrap()); + } + + #[test] + fn rejects_out_of_order_revealed_ranges() { + let mut data = sample(); + data.sent.revealed.swap(0, 1); + assert!(matches!( + data.encode(), + Err(AttestationError::OutOfOrder { + direction: "sent", + .. + }) + )); + } + + #[test] + fn rejects_overlapping_revealed_ranges() { + let mut data = sample(); + data.sent.revealed[1].start = 10; + data.sent.revealed[1].bytes = vec![b'b'; 50]; + assert!(matches!( + data.encode(), + Err(AttestationError::OutOfOrder { .. }) + )); + } + + #[test] + fn rejects_an_empty_range() { + let mut data = sample(); + data.sent.revealed[0].end = 0; + data.sent.revealed[0].bytes.clear(); + assert!(matches!( + data.encode(), + Err(AttestationError::EmptyRange { .. }) + )); + } + + #[test] + fn rejects_a_range_past_the_signed_transcript_length() { + // The signed length is what makes bytes past the last revealed range + // visible at all (REQ-COMMON-36). + let mut data = sample(); + data.sent_transcript_length = 50; + assert!(matches!( + data.encode(), + Err(AttestationError::PastTranscriptEnd { + end: 60, + length: 50, + .. + }) + )); + } + + #[test] + fn rejects_a_commitment_overlapping_a_revealed_range() { + let mut data = sample(); + data.sent.commitments[0].start = 10; + assert!(matches!( + data.encode(), + Err(AttestationError::CommitmentOverlapsRevealed { .. }) + )); + } + + #[test] + fn rejects_a_range_whose_bytes_disagree_with_its_offsets() { + let mut data = sample(); + data.sent.revealed[0].bytes.push(b'a'); + assert!(matches!( + data.encode(), + Err(AttestationError::RangeLengthMismatch { carried: 21, .. }) + )); + } + + #[test] + fn rejects_trailing_bytes() { + let mut encoded = sample().encode().unwrap(); + encoded.push(0); + assert_eq!( + AttestedData::decode(&encoded), + Err(AttestationError::TrailingBytes(1)) + ); + } + + #[test] + fn rejects_truncation_at_every_length() { + // Never panics, whatever a caller hands it. + let encoded = sample().encode().unwrap(); + for cut in 0..encoded.len() { + assert!( + AttestedData::decode(&encoded[..cut]).is_err(), + "accepted a {cut}-byte prefix" + ); + } + } + + #[test] + fn rejects_a_count_that_outruns_the_buffer() { + // A declared count of 0xffff with no entries behind it must not + // allocate its way to a panic. + let mut encoded = sample().encode().unwrap(); + encoded[HEADER_LEN] = 0xff; + encoded[HEADER_LEN + 1] = 0xff; + assert!(AttestedData::decode(&encoded).is_err()); + } + + #[test] + fn a_shifted_boundary_cannot_produce_one_preimage() { + // Moving a byte from a revealed range into the next one changes the + // encoding, because both offsets and both lengths are written down. + let mut moved = sample(); + moved.sent.revealed[0].end = 19; + 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/authorization.rs b/crates/libid-ceremony/src/authorization.rs new file mode 100644 index 00000000..ee5dc5ee --- /dev/null +++ b/crates/libid-ceremony/src/authorization.rs @@ -0,0 +1,171 @@ +//! The Authorization Digest of ceremony-common section 5. +//! +//! One value binds one authorization to the transaction that will consume it. +//! The Canonical Runtime builds it before the ceremony starts; the Proof +//! Verifier rebuilds it from the submission and its own observed chain id, and +//! the two must agree or nothing verifies. + +use libid_crypto::keccak256; + +/// Fixed-width part of the preimage: 32 + 2 + 32 + 32 + 4. +pub const PREIMAGE_FIXED_LEN: usize = 102; + +/// The Authorized Transaction Data length is carried in four bytes, so this is +/// the longest payload the layout can describe. +pub const MAX_TRANSACTION_DATA_LEN: usize = u32::MAX as usize; + +/// Everything the digest commits to. +/// +/// `chain_id` is the keccak256 of whatever bytes a chain's own identifier +/// contributes, never the raw identifier: chains name themselves +/// incompatibly, and some too wide for 64 bits (REQ-COMMON-01C). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AuthorizationPreimage { + pub operation_domain: [u8; 32], + pub platform_verifier_version: u16, + pub chain_id: [u8; 32], + pub authorization_nonce: [u8; 32], + pub transaction_data: Vec, +} + +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum AuthorizationError { + #[error( + "transaction data is {0} bytes, which does not fit the four-byte length field" + )] + TransactionDataTooLong(usize), +} + +/// Derive an operation domain from its string. +/// +/// The Consumer fixes one libID-namespaced ASCII string per transaction kind. +/// A new operation, or a change to one operation's transaction-data meaning, +/// takes a new string rather than another digest field (REQ-COMMON-01A). +pub fn operation_domain(domain_string: &str) -> [u8; 32] { + keccak256(domain_string.as_bytes()) +} + +/// Derive a chain id from the exact bytes its Chain Profile fixes. +pub fn chain_id(identifier_bytes: &[u8]) -> [u8; 32] { + keccak256(identifier_bytes) +} + +impl AuthorizationPreimage { + /// Serialize exactly the byte concatenation of section 5. + /// + /// Only the transaction data varies in length, so every other field sits + /// at a fixed offset and no boundary can be shifted to reinterpret the + /// preimage as a different authorization (REQ-COMMON-01). + pub fn encode(&self) -> Result, AuthorizationError> { + let len = self.transaction_data.len(); + if len > MAX_TRANSACTION_DATA_LEN { + return Err(AuthorizationError::TransactionDataTooLong(len)); + } + + let mut out = Vec::with_capacity(PREIMAGE_FIXED_LEN + len); + out.extend_from_slice(&self.operation_domain); + out.extend_from_slice(&self.platform_verifier_version.to_be_bytes()); + out.extend_from_slice(&self.chain_id); + out.extend_from_slice(&self.authorization_nonce); + out.extend_from_slice(&(len as u32).to_be_bytes()); + out.extend_from_slice(&self.transaction_data); + Ok(out) + } + + /// `keccak256` of the encoded preimage. + pub fn digest(&self) -> Result<[u8; 32], AuthorizationError> { + Ok(keccak256(&self.encode()?)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The conformance vector of ceremony-common section 5, transcribed from + /// the specification and reproduced independently with `cast keccak`. + fn spec_vector() -> AuthorizationPreimage { + AuthorizationPreimage { + operation_domain: operation_domain("libid.claim-identity"), + platform_verifier_version: 1, + chain_id: chain_id(b"example:1"), + authorization_nonce: [0x55; 32], + transaction_data: vec![0x00, 0x01, 0x02, 0x03], + } + } + + #[test] + fn derived_constants_match_the_specification() { + assert_eq!( + hex::encode(operation_domain("libid.claim-identity")), + "cb29bed0428519ef88a3d670e8203db76e06f41aca3e684e2c63b516c9b93e1b" + ); + assert_eq!( + hex::encode(chain_id(b"example:1")), + "38064d82f31db40935cc75f2a0d07dcfb448d7c08e7484fc30f5de95484a4066" + ); + } + + #[test] + fn preimage_matches_the_specification_vector() { + let expected = "cb29bed0428519ef88a3d670e8203db76e06f41aca3e684e2c63b516c9b93e1b\ + 0001\ + 38064d82f31db40935cc75f2a0d07dcfb448d7c08e7484fc30f5de95484a4066\ + 5555555555555555555555555555555555555555555555555555555555555555\ + 00000004\ + 00010203"; + assert_eq!(hex::encode(spec_vector().encode().unwrap()), expected); + } + + #[test] + fn digest_matches_the_specification_vector() { + assert_eq!( + hex::encode(spec_vector().digest().unwrap()), + "b318fb559e16a179b853ed2853576cda16032d93b0839bb81a55135d334c0af5" + ); + } + + #[test] + fn fixed_part_is_one_hundred_and_two_bytes() { + let mut p = spec_vector(); + p.transaction_data.clear(); + assert_eq!(p.encode().unwrap().len(), PREIMAGE_FIXED_LEN); + } + + #[test] + fn every_field_changes_the_digest() { + let base = spec_vector().digest().unwrap(); + + let mut p = spec_vector(); + p.operation_domain[0] ^= 1; + assert_ne!(p.digest().unwrap(), base, "operation domain does not bind"); + + let mut p = spec_vector(); + p.platform_verifier_version = 2; + assert_ne!(p.digest().unwrap(), base, "verifier version does not bind"); + + let mut p = spec_vector(); + p.chain_id[31] ^= 1; + assert_ne!(p.digest().unwrap(), base, "chain id does not bind"); + + let mut p = spec_vector(); + p.authorization_nonce[0] ^= 1; + assert_ne!(p.digest().unwrap(), base, "nonce does not bind"); + + let mut p = spec_vector(); + p.transaction_data.push(0x04); + assert_ne!(p.digest().unwrap(), base, "transaction data does not bind"); + } + + #[test] + fn length_prefix_separates_a_shifted_boundary() { + // Without the explicit length, transaction data whose leading bytes + // could be read as part of the nonce would collide. The prefix is what + // makes every boundary derivable. + let mut a = spec_vector(); + a.transaction_data = vec![0x00, 0x01]; + let mut b = spec_vector(); + b.transaction_data = vec![0x00, 0x01, 0x00, 0x00]; + assert_ne!(a.digest().unwrap(), b.digest().unwrap()); + } +} diff --git a/crates/libid-ceremony/src/lib.rs b/crates/libid-ceremony/src/lib.rs new file mode 100644 index 00000000..295644be --- /dev/null +++ b/crates/libid-ceremony/src/lib.rs @@ -0,0 +1,35 @@ +//! Wire constructions of the libID identity ceremony. +//! +//! One implementation of each construction the specification fixes, so the +//! notary, the backend and the conformance suite cannot disagree about bytes: +//! +//! * [`authorization`] -- the Authorization Digest of ceremony-common +//! section 5, which binds one authorization to the transaction that will +//! consume it. +//! * [`pkce`] -- the derived `code_verifier` of section 7, which is how X and +//! GitHub carry that digest through an OAuth authorization. +//! * [`attestation`] -- the attested-data byte layout of section 9.1, which is +//! what the Notary Service signs and what the Platform Verifier rebuilds. +//! +//! Each module's tests pin it to the conformance vectors the specification +//! publishes, taken from the specification rather than from this code. + +pub mod attestation; +pub mod authorization; +pub mod pkce; + +pub use attestation::{ + AttestationError, + AttestedData, + DirectionBlock, + RangeCommitment, + RevealedRange, +}; +pub use authorization::{ + AuthorizationError, + AuthorizationPreimage, +}; +pub use pkce::{ + code_challenge, + code_verifier, +}; diff --git a/crates/libid-ceremony/src/pkce.rs b/crates/libid-ceremony/src/pkce.rs new file mode 100644 index 00000000..9f2d3767 --- /dev/null +++ b/crates/libid-ceremony/src/pkce.rs @@ -0,0 +1,146 @@ +//! The PKCE construction of ceremony-common section 7. +//! +//! X and GitHub cannot carry the Authorization Digest in their authorization +//! request the way Google carries it in an OIDC `nonce`, so they carry it +//! through the PKCE verifier instead. The verifier is revealed in the +//! notarized token request, and the Platform Verifier recomputes it from the +//! digest and the submitted nonce. Retargeting an attestation to another +//! digest would take a second preimage of that verifier. + +use base64::{ + engine::general_purpose::URL_SAFE_NO_PAD, + Engine as _, +}; +use libid_crypto::keccak256; +use sha2::{ + Digest, + Sha256, +}; + +/// Domain separator, carrying no version of its own: the Authorization Digest +/// already binds `platformVerifierVersion`, and a change to this construction +/// changes the proof statement, which bumps that version (REQ-COMMON-12). +pub const PKCE_DOMAIN_STRING: &str = "libid.identity.pkce"; + +/// Both the verifier and the challenge are exactly this many unpadded +/// base64url characters. +pub const PKCE_LEN: usize = 43; + +/// `keccak256(PKCE_DOMAIN_STRING)`. +pub fn pkce_domain() -> [u8; 32] { + keccak256(PKCE_DOMAIN_STRING.as_bytes()) +} + +/// `SHA256(PKCE_DOMAIN || authorizationDigest || pkceNonce)`. +pub fn verifier_hash(authorization_digest: &[u8; 32], pkce_nonce: &[u8; 32]) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(pkce_domain()); + hasher.update(authorization_digest); + hasher.update(pkce_nonce); + hasher.finalize().into() +} + +/// `BASE64URL_NOPAD(verifierHash)` -- the `code_verifier` the token request +/// carries and the Platform Verifier recomputes. +/// +/// `pkce_nonce` is drawn freshly per authorization attempt: the nonce becomes +/// public at submission, so reusing one across attempts of a single digest +/// publishes the verifier of an earlier attempt whose code may still be live +/// (REQ-COMMON-13). +pub fn code_verifier(authorization_digest: &[u8; 32], pkce_nonce: &[u8; 32]) -> String { + URL_SAFE_NO_PAD.encode(verifier_hash(authorization_digest, pkce_nonce)) +} + +/// `BASE64URL_NOPAD(SHA256(ASCII(code_verifier)))` -- the S256 challenge sent +/// in the authorization request. +pub fn code_challenge(code_verifier: &str) -> String { + URL_SAFE_NO_PAD.encode(Sha256::digest(code_verifier.as_bytes())) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The Authorization Digest of the section 5 vector. + const DIGEST: [u8; 32] = [ + 0xb3, 0x18, 0xfb, 0x55, 0x9e, 0x16, 0xa1, 0x79, 0xb8, 0x53, 0xed, 0x28, 0x53, + 0x57, 0x6c, 0xda, 0x16, 0x03, 0x2d, 0x93, 0xb0, 0x83, 0x9b, 0xb8, 0x1a, 0x55, + 0x13, 0x5d, 0x33, 0x4c, 0x0a, 0xf5, + ]; + const NONCE: [u8; 32] = [0x44; 32]; + + #[test] + fn matches_the_specification_vector() { + assert_eq!( + hex::encode(pkce_domain()), + "3961dfe56cd0f2d94e72a15b96df889fbb46968cdb37518830fc0077b0730a01" + ); + assert_eq!( + hex::encode(verifier_hash(&DIGEST, &NONCE)), + "88c493361ea0424467046958d5cd0c50eb03ecc08ee06f02ee9875fe0219b392" + ); + let verifier = code_verifier(&DIGEST, &NONCE); + assert_eq!(verifier, "iMSTNh6gQkRnBGlY1c0MUOsD7MCO4G8C7ph1_gIZs5I"); + assert_eq!( + code_challenge(&verifier), + "BhFqYIY1YnHafYOrrblUswFnjxFF97UvGjSgqugPQvA" + ); + } + + #[test] + fn both_values_are_forty_three_unpadded_characters() { + let verifier = code_verifier(&DIGEST, &NONCE); + let challenge = code_challenge(&verifier); + assert_eq!(verifier.len(), PKCE_LEN); + assert_eq!(challenge.len(), PKCE_LEN); + for value in [&verifier, &challenge] { + assert!(!value.contains('='), "padding leaked into {value}"); + assert!(!value.contains('+'), "not base64url: {value}"); + assert!(!value.contains('/'), "not base64url: {value}"); + } + } + + #[test] + fn the_verifier_is_a_pkce_charset_string() { + // RFC 7636 unreserved set, which base64url is a subset of. + let verifier = code_verifier(&DIGEST, &NONCE); + assert!(verifier + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')); + } + + #[test] + fn a_different_digest_gives_a_different_verifier() { + // This is the binding. If it failed, one notarized token request would + // serve any transaction. + let mut other = DIGEST; + other[0] ^= 1; + assert_ne!( + code_verifier(&DIGEST, &NONCE), + code_verifier(&other, &NONCE) + ); + } + + #[test] + fn a_different_nonce_gives_a_different_verifier() { + // Which is what makes a retry of one digest unpredictable to anyone + // holding only the public digest. + let mut other = NONCE; + other[31] ^= 1; + assert_ne!( + code_verifier(&DIGEST, &NONCE), + code_verifier(&DIGEST, &other) + ); + } + + #[test] + fn the_domain_separates_this_hash_from_a_bare_one() { + let bare: [u8; 32] = { + let mut h = Sha256::new(); + h.update(DIGEST); + h.update(NONCE); + h.finalize().into() + }; + assert_ne!(verifier_hash(&DIGEST, &NONCE), bare); + } +} From 02b6c09896a2a60cfe61cddfe2c4d934eba1a21d Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Thu, 20 Aug 2026 14:21:20 +0300 Subject: [PATCH 02/65] test: pin the attestation encoding against the Solidity decoder The notary writes these bytes in Rust and the chain reads them in Solidity. A divergence does not fail loudly on its own: the notary would sign a preimage the Platform Verifier rebuilds differently, deriving a key nobody trusts and rejecting every genuine attestation. Both sides now carry the same fixture, so a change to either encoder breaks a test instead. Verified by reordering two header fields, which the specification forbids for exactly this reason: the assertion failed, and passed again on revert. Assisted-by: Claude Opus 5 Signed-off-by: SupremaLex --- crates/libid-ceremony/src/attestation.rs | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/crates/libid-ceremony/src/attestation.rs b/crates/libid-ceremony/src/attestation.rs index 0865ee0a..f732a457 100644 --- a/crates/libid-ceremony/src/attestation.rs +++ b/crates/libid-ceremony/src/attestation.rs @@ -419,6 +419,30 @@ mod tests { } } + /// 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 = "f1b67c286f7f90224eb4661a5922406b5092042b9515e4e9e448ec1d4f55b352\ +7521d1cadbcfa91eec65aa16715b94ffc1c9654ba57ea2ef1a2127bca1127a83\ +e7b961087ec316778e6885d11145cc06f1d75360430f461d0322fb7f105899dd\ +4930142f5283d4a8eab0d24c588f00b21213ae2a47e7ed6c1dc6a57044f1655d\ +0000000069800e800000003c00000028000200000000000000146161616161616161616161616161616161616161\ +000000280000003c62626262626262626262626262626262626262620001000000140000002807070707070707070707070707070707070707070707070707070707070707070001000000000000000a6363636363636363636300010000000a000000280909090909090909090909090909090909090909090909090909090909090909"; + + const CROSS_LANGUAGE_DIGEST: &str = + "511d91f8a3c13c1824fd1d3e7c011caf09f2f0763f1ede5c786839592ae8d252"; + + #[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 round_trips() { let data = sample(); From 71937a6ea1bd3cf533c2507be653f915f008d26c Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Thu, 20 Aug 2026 15:10:02 +0300 Subject: [PATCH 03/65] feat: require exact transcript coverage where a profile demands it A security review found that shape validation accepts a transcript byte covered by neither a revealed range nor a commitment. Confirmed: moving the sample's commitment one byte forward leaves byte 20 covered by nothing and validation still passes. That is correct for validation and wrong to leave unavailable. REQ-COMMON-35 is conditional -- it governs an identity-session request committing a credential in an Authorization header, and REQ-COMMON-43 withholds it from a credential committed in a request body, which is GitHub's client_secret. A codec that tiled unconditionally would reject every valid GitHub token exchange. So coverage becomes something a caller asks for where its profile fixes it, rather than something nobody offers. A gap is where a prover hides bytes: exact coverage leaves the committed range as the only region the verifier cannot read, and makes its offset and length follow from the ranges around it. Assisted-by: Claude Opus 5 Signed-off-by: SupremaLex --- crates/libid-ceremony/src/attestation.rs | 94 ++++++++++++++++++++++++ 1 file changed, 94 insertions(+) diff --git a/crates/libid-ceremony/src/attestation.rs b/crates/libid-ceremony/src/attestation.rs index f732a457..f2d36a3d 100644 --- a/crates/libid-ceremony/src/attestation.rs +++ b/crates/libid-ceremony/src/attestation.rs @@ -108,6 +108,12 @@ pub enum AttestationError { start: u32, end: u32, }, + #[error("transcript bytes {from}..{to} of the {direction} direction are covered by nothing")] + CoverageGap { + direction: &'static str, + from: u32, + to: u32, + }, } /// Derive a 32-byte tag from a libID-namespaced ASCII string. @@ -315,6 +321,53 @@ impl<'a> Reader<'a> { } } +impl DirectionBlock { + /// Require the revealed ranges and commitments to tile `[0, length)` + /// exactly, with no gap and no overlap (REQ-COMMON-35). + /// + /// Only for a direction whose profile demands exact coverage. The rule is + /// conditional: REQ-COMMON-43 withholds it from a credential committed in + /// a request body, which is GitHub's `client_secret`, so [`validate`] does + /// not apply it and a caller asks for it where the profile does. + /// + /// A gap is where a prover hides bytes. Exact coverage leaves the committed + /// range as the only region the verifier cannot read and makes its offset + /// and length follow from the ranges around it. + /// + /// [`validate`]: AttestedData::validate + pub fn require_exact_coverage( + &self, + direction: &'static str, + length: u32, + ) -> Result<(), AttestationError> { + let mut spans: Vec<(u32, u32)> = + Vec::with_capacity(self.revealed.len() + self.commitments.len()); + spans.extend(self.revealed.iter().map(|r| (r.start, r.end))); + spans.extend(self.commitments.iter().map(|c| (c.start, c.end))); + spans.sort_unstable(); + + let mut at = 0u32; + for (start, end) in spans { + if start != at { + return Err(AttestationError::CoverageGap { + direction, + from: at, + to: start, + }); + } + at = end; + } + if at != length { + return Err(AttestationError::CoverageGap { + direction, + from: at, + to: length, + }); + } + Ok(()) + } +} + impl AttestedData { /// Reject a shape the Platform Verifier must refuse: ranges out of order, /// overlapping, empty, or ending past the signed transcript length @@ -443,6 +496,47 @@ e7b961087ec316778e6885d11145cc06f1d75360430f461d0322fb7f105899dd\ ); } + #[test] + fn coverage_accepts_an_exact_tiling() { + let data = sample(); + data.sent + .require_exact_coverage("sent", data.sent_transcript_length) + .expect("the sample tiles 0..20, 20..40, 40..60"); + } + + #[test] + fn coverage_rejects_a_gap() { + // `validate` accepts this on purpose: coverage is conditional under + // REQ-COMMON-43. The identity-session verifier is what must refuse it. + let mut data = sample(); + data.sent.commitments[0].start = 21; + data.validate().expect("shape alone still passes"); + assert_eq!( + data.sent + .require_exact_coverage("sent", data.sent_transcript_length), + Err(AttestationError::CoverageGap { + direction: "sent", + from: 20, + to: 21 + }) + ); + } + + #[test] + fn coverage_rejects_a_trailing_gap() { + // Bytes past the last range are invisible without the signed length to + // close them (REQ-COMMON-36). + let data = sample(); + assert_eq!( + data.sent.require_exact_coverage("sent", 80), + Err(AttestationError::CoverageGap { + direction: "sent", + from: 60, + to: 80 + }) + ); + } + #[test] fn round_trips() { let data = sample(); From 0b2f454bfc77308dc31acd8b4a7fd1bc35475af5 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Thu, 20 Aug 2026 15:40:33 +0300 Subject: [PATCH 04/65] feat: add the ceremony profile constants and token-exchange contract A Platform Profile fixes what a Platform Verifier pins: the tags it compares an attestation against, the authority that must have answered, the method and path it expects, how the Authorization Digest reaches it, and the handle parameters. These now live in one place, so the notary that writes them and the verifier that compares them read one definition. Two things the profiles make explicit. The attestation count is derived from the session list rather than stated beside it, so the two cannot disagree. GitHub notarizes two different authorities -- github.com serves the exchange and api.github.com serves the identity read -- so one pinned authority per profile would be wrong. The namespaced strings are ours, not the specification's. ceremony-common fixes exactly one literal, libid.identity.pkce. formatTag, operationTag and the platform name are all required to exist and required to be pinned, with their bytes left to the profile author. That makes them a cross-implementation agreement: a notary emitting one string and a verifier pinning another derives a key nobody trusts and rejects every genuine attestation, with no error that says why. The module header says so. The GitHub Token-Exchange Service contract of section 6.3 joins them, with the bounded parsing its requirements fix. Its bearerOpening is the blinder that opens the committed bearer range: without it a browser holding the attestation and the bearer still cannot build the proof, because the blinder is prover-private material from a session only that service ran. Assisted-by: Claude Opus 5 Signed-off-by: SupremaLex --- crates/libid-ceremony/src/lib.rs | 2 + crates/libid-ceremony/src/profile.rs | 333 ++++++++++++++++++++ crates/libid-ceremony/src/token_exchange.rs | 211 +++++++++++++ 3 files changed, 546 insertions(+) create mode 100644 crates/libid-ceremony/src/profile.rs create mode 100644 crates/libid-ceremony/src/token_exchange.rs diff --git a/crates/libid-ceremony/src/lib.rs b/crates/libid-ceremony/src/lib.rs index 295644be..92aaa7b0 100644 --- a/crates/libid-ceremony/src/lib.rs +++ b/crates/libid-ceremony/src/lib.rs @@ -17,6 +17,8 @@ pub mod attestation; pub mod authorization; pub mod pkce; +pub mod profile; +pub mod token_exchange; pub use attestation::{ AttestationError, diff --git a/crates/libid-ceremony/src/profile.rs b/crates/libid-ceremony/src/profile.rs new file mode 100644 index 00000000..0df7e447 --- /dev/null +++ b/crates/libid-ceremony/src/profile.rs @@ -0,0 +1,333 @@ +//! Ceremony profile constants, and the protocol parameters governance owns. +//! +//! A Platform Profile fixes what a Platform Verifier pins: the tags it compares +//! an attestation against, the authority that must have answered, the method +//! and path it expects, how many attestations it verifies, and how the +//! Authorization Digest reaches it. This module is the single place those live, +//! so the notary that writes them and the verifier that compares them read one +//! definition. +//! +//! # The namespaced strings are ours, not the specification's +//! +//! The specification fixes exactly one literal: `libid.identity.pkce`, in +//! ceremony-common section 7. Every other libID-namespaced string -- +//! `formatTag` (REQ-COMMON-53), `operationTag` and the platform name +//! (REQ-COMMON-55), and each Consumer's operation domain (REQ-COMMON-01A) -- +//! is required to exist and required to be pinned, but its bytes are left to +//! the profile author. +//! +//! That makes these constants a cross-implementation agreement rather than a +//! reading of the specification. A notary emitting `libid.attestation.v1` and a +//! verifier pinning anything else derives a key nobody trusts and rejects every +//! genuine attestation, with no error that says why. They must be agreed before +//! either side ships. + +/// Names this attestation byte layout and its version (REQ-COMMON-53). +/// +/// A change to the field list, to a field's width, or to a field's meaning +/// takes a new version string rather than another field. +pub const FORMAT_TAG: &str = "libid.attestation.v1"; + +/// Which session of the ceremony an attestation covers (REQ-COMMON-55). +/// +/// One ceremony notarizes more than one session, and two attestations that +/// differ only in which session they came from would otherwise be +/// interchangeable. +pub const TOKEN_SESSION_TAG: &str = "libid.ceremony.session.token.v1"; +pub const IDENTITY_SESSION_TAG: &str = "libid.ceremony.session.identity.v1"; + +/// How a profile binds the Authorization Digest to its evidence +/// (REQ-COMMON-02C). Exactly one of the two, never both and never neither. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DigestBinding { + /// Google: the digest is a public proof input the verifier compares. + PublicProofInput, + /// X and GitHub: the verifier recomputes the revealed `code_verifier`. + RevealedCodeVerifier, +} + +/// One notarized session of a ceremony. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct SessionProfile { + /// The `operationTag` this session's attestation must carry. + pub operation_tag: &'static str, + /// The TLS server name the notary must have authenticated, in the + /// canonical form of section 9: lowercase ASCII, no trailing dot. It + /// reaches the verifier as `authorityId`, never as a transcript range, + /// because the transcript holds it only in a prover-composed `Host` header. + pub authority: &'static str, + /// Exact uppercase HTTP method, compared against a revealed range. + pub method: &'static str, + /// Origin-form path, no query, compared against a revealed range. + pub path: &'static str, +} + +/// The five parameters of platform-ceremonies section 2.1a. +/// +/// `is_email` supersedes the two booleans below it (REQ-PLAT-67); they are +/// stated because REQ-PLAT-72 requires a profile to fix all five. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct HandleRules { + pub max_length: u16, + pub strip_leading_at: bool, + pub is_email: bool, + pub allow_underscore: bool, + pub allow_hyphen: bool, +} + +/// An immutable, independently versioned ceremony profile. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PlatformProfile { + /// Preimage of `platformId` (REQ-COMMON-55). + pub name: &'static str, + /// Carried in the Authorization Digest and the submission. Launch profiles + /// use 1 (REQ-PLAT-01). + pub platform_verifier_version: u16, + pub digest_binding: DigestBinding, + /// The token or token-exchange session, where the profile has one. + pub token_session: Option, + /// The identity session, where the profile has one. + pub identity_session: Option, + pub handle: HandleRules, +} + +impl PlatformProfile { + /// Derived, never stated beside the session list: the profile fixes the + /// list and the count is its size (REQ-COMMON-41). + pub fn attestation_count(&self) -> u8 { + self.token_session.is_some() as u8 + self.identity_session.is_some() as u8 + } + + /// A profile verifying no attestation reaches no Notary Service and pays + /// nothing (REQ-COMMON-05D, REQ-COMMON-06E). + pub fn verifies_attestations(&self) -> bool { + self.attestation_count() > 0 + } +} + +/// `google/v1` -- authentication-only OIDC. No token exchange, no client +/// secret, no PKCE, no notarized session, and therefore no Notary Fee. +pub const GOOGLE_V1: PlatformProfile = PlatformProfile { + name: "google", + platform_verifier_version: 1, + digest_binding: DigestBinding::PublicProofInput, + token_session: None, + identity_session: None, + handle: HandleRules { + max_length: 62, + strip_leading_at: false, + is_email: true, + allow_underscore: false, + allow_hyphen: false, + }, +}; + +/// `x/v1` -- a public client with S256 PKCE and two browser-owned sessions. +pub const X_V1: PlatformProfile = PlatformProfile { + name: "x", + platform_verifier_version: 1, + digest_binding: DigestBinding::RevealedCodeVerifier, + token_session: Some(SessionProfile { + operation_tag: TOKEN_SESSION_TAG, + authority: "api.x.com", + method: "POST", + path: "/2/oauth2/token", + }), + identity_session: Some(SessionProfile { + operation_tag: IDENTITY_SESSION_TAG, + authority: "api.x.com", + method: "GET", + path: "/2/users/me", + }), + handle: HandleRules { + max_length: 15, + strip_leading_at: true, + is_email: false, + allow_underscore: true, + allow_hyphen: false, + }, +}; + +/// `github/v1` -- a confidential client, so the exchange runs in the +/// deployment's Token-Exchange Service and that service is the notarized party +/// for the token session. Note the two authorities differ. +pub const GITHUB_V1: PlatformProfile = PlatformProfile { + name: "github", + platform_verifier_version: 1, + digest_binding: DigestBinding::RevealedCodeVerifier, + token_session: Some(SessionProfile { + operation_tag: TOKEN_SESSION_TAG, + authority: "github.com", + method: "POST", + path: "/login/oauth/access_token", + }), + identity_session: Some(SessionProfile { + operation_tag: IDENTITY_SESSION_TAG, + authority: "api.github.com", + method: "GET", + path: "/user", + }), + handle: HandleRules { + max_length: 39, + strip_leading_at: true, + is_email: false, + allow_underscore: false, + allow_hyphen: true, + }, +}; + +pub const LAUNCH_PROFILES: [PlatformProfile; 3] = [GOOGLE_V1, X_V1, GITHUB_V1]; + +/// Governance-owned unsigned 64-bit values in seconds, read where they are +/// enforced (libid.md protocol parameters). +/// +/// The Platform Verifier reads the current value when it verifies; a browser +/// read is advisory. Lowering one may reject an outstanding proof and raising +/// one may extend an outstanding X or GitHub proof, so they are authority +/// rather than configuration (REQ-PARAM-01, REQ-PARAM-02). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ProtocolParameters { + /// Maximum age of the X token attestation. + pub proof_lifetime_x: u64, + /// Maximum age of the GitHub token-exchange attestation. + pub proof_lifetime_github: u64, + /// Maximum X or GitHub attestation lead over chain time. + pub max_future_attestation_skew: u64, +} + +/// The launch values. +pub const LAUNCH_PARAMETERS: ProtocolParameters = ProtocolParameters { + proof_lifetime_x: 3600, + proof_lifetime_github: 3600, + max_future_attestation_skew: 300, +}; + +#[cfg(test)] +mod tests { + use super::*; + use crate::attestation::tag; + + #[test] + fn attestation_counts_match_the_profiles() { + // Google verifies none and pays nothing; X and GitHub verify two each, + // so one submission on either path pays two Notary Fees. + assert_eq!(GOOGLE_V1.attestation_count(), 0); + assert!(!GOOGLE_V1.verifies_attestations()); + assert_eq!(X_V1.attestation_count(), 2); + assert_eq!(GITHUB_V1.attestation_count(), 2); + } + + #[test] + fn digest_binding_is_one_method_per_profile() { + // REQ-COMMON-02C: never both, never neither. Google carries no + // code_verifier; X and GitHub expose no digest public input. + assert_eq!(GOOGLE_V1.digest_binding, DigestBinding::PublicProofInput); + assert_eq!(X_V1.digest_binding, DigestBinding::RevealedCodeVerifier); + assert_eq!( + GITHUB_V1.digest_binding, + DigestBinding::RevealedCodeVerifier + ); + } + + #[test] + fn the_two_sessions_of_a_profile_carry_different_tags() { + // Otherwise a token attestation and an identity attestation of one + // ceremony would be interchangeable (REQ-COMMON-55). + for profile in [X_V1, GITHUB_V1] { + let token = profile.token_session.unwrap(); + let identity = profile.identity_session.unwrap(); + assert_ne!(tag(token.operation_tag), tag(identity.operation_tag)); + } + } + + #[test] + fn github_notarizes_two_different_authorities() { + // The exchange is served by github.com and the identity read by + // api.github.com, so one pinned authority per profile would be wrong. + let profile = GITHUB_V1; + assert_eq!(profile.token_session.unwrap().authority, "github.com"); + assert_eq!( + profile.identity_session.unwrap().authority, + "api.github.com" + ); + assert_ne!( + tag(profile.token_session.unwrap().authority), + tag(profile.identity_session.unwrap().authority) + ); + } + + #[test] + fn platform_ids_are_distinct() { + let ids: Vec<_> = LAUNCH_PROFILES.iter().map(|p| tag(p.name)).collect(); + assert_ne!(ids[0], ids[1]); + assert_ne!(ids[1], ids[2]); + assert_ne!(ids[0], ids[2]); + } + + #[test] + fn authorities_are_canonical() { + // Section 9: lowercase ASCII DNS name, no trailing dot, no scheme, + // no port. + for profile in LAUNCH_PROFILES { + for session in [profile.token_session, profile.identity_session] + .into_iter() + .flatten() + { + let a = session.authority; + assert_eq!(a, a.to_ascii_lowercase(), "authority not lowercase: {a}"); + assert!(!a.ends_with('.'), "trailing dot: {a}"); + assert!( + !a.contains('/') && !a.contains(':'), + "not a bare DNS name: {a}" + ); + assert!( + session.path.starts_with('/'), + "path not origin-form: {}", + session.path + ); + assert!( + !session.path.contains('?'), + "path carries a query: {}", + session.path + ); + assert_eq!(session.method, session.method.to_ascii_uppercase()); + } + } + } + + #[test] + fn handle_rules_match_the_published_parameter_table() { + // platform-ceremonies section 2.1a. + assert_eq!( + ( + GOOGLE_V1.handle.max_length, + GOOGLE_V1.handle.strip_leading_at, + GOOGLE_V1.handle.is_email + ), + (62, false, true) + ); + assert_eq!( + ( + X_V1.handle.max_length, + X_V1.handle.strip_leading_at, + X_V1.handle.allow_underscore + ), + (15, true, true) + ); + assert_eq!( + ( + GITHUB_V1.handle.max_length, + GITHUB_V1.handle.strip_leading_at, + GITHUB_V1.handle.allow_hyphen + ), + (39, true, true) + ); + } + + #[test] + fn launch_parameters_match_the_published_values() { + assert_eq!(LAUNCH_PARAMETERS.proof_lifetime_x, 3600); + assert_eq!(LAUNCH_PARAMETERS.proof_lifetime_github, 3600); + assert_eq!(LAUNCH_PARAMETERS.max_future_attestation_skew, 300); + } +} diff --git a/crates/libid-ceremony/src/token_exchange.rs b/crates/libid-ceremony/src/token_exchange.rs new file mode 100644 index 00000000..e4ba6e31 --- /dev/null +++ b/crates/libid-ceremony/src/token_exchange.rs @@ -0,0 +1,211 @@ +//! The GitHub Token-Exchange 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). + +/// Fixed route on the redirect origin. +pub const ROUTE: &str = "/oauth/github/token-exchange"; + +pub const MAX_CODE_BYTES: usize = 1024; +pub const CODE_VERIFIER_LEN: usize = 43; +pub const MAX_ACCESS_TOKEN_BYTES: usize = 4096; +pub const MAX_BEARER_OPENING_BYTES: usize = 256; +pub const MAX_TOKEN_ATTESTATION_BYTES: usize = 2 * 1024 * 1024; +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 {0} bytes, over the {MAX_ACCESS_TOKEN_BYTES}-byte bound")] + AccessTokenTooLong(usize), + #[error( + "bearerOpening is {0} bytes, over the {MAX_BEARER_OPENING_BYTES}-byte bound" + )] + BearerOpeningTooLong(usize), + #[error("tokenAttestation is {0} bytes, over the {MAX_TOKEN_ATTESTATION_BYTES}-byte bound")] + AttestationTooLong(usize), +} + +/// 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 TokenExchangeRequestV1 { + pub code: String, + pub code_verifier: String, +} + +/// 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 TokenExchangeResponseV1 { + pub access_token: String, + /// The attested data of the notarized exchange, as bytes. + pub token_attestation: Vec, + /// 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 TokenExchangeRequestV1 { + /// Bounded parsing, per REQ-PLAT-37 and REQ-PLAT-38. + 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())); + } + // Printable ASCII excludes whitespace and control characters, which is + // what REQ-PLAT-37 asks for in one test. + if let Some(i) = self.code.bytes().position(|b| !(0x21..=0x7e).contains(&b)) { + 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 TokenExchangeResponseV1 { + /// Bounded parsing, per REQ-PLAT-39. + pub fn validate(&self) -> Result<(), TokenExchangeError> { + if self.access_token.len() > MAX_ACCESS_TOKEN_BYTES { + return Err(TokenExchangeError::AccessTokenTooLong( + self.access_token.len(), + )); + } + if self.bearer_opening.len() > MAX_BEARER_OPENING_BYTES { + return Err(TokenExchangeError::BearerOpeningTooLong( + self.bearer_opening.len(), + )); + } + if self.token_attestation.len() > MAX_TOKEN_ATTESTATION_BYTES { + return Err(TokenExchangeError::AttestationTooLong( + self.token_attestation.len(), + )); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn request() -> TokenExchangeRequestV1 { + TokenExchangeRequestV1 { + code: "abc123".into(), + code_verifier: "iMSTNh6gQkRnBGlY1c0MUOsD7MCO4G8C7ph1_gIZs5I".into(), + } + } + + #[test] + fn accepts_a_well_formed_request() { + request().validate().unwrap(); + } + + #[test] + fn the_published_verifier_is_the_right_shape() { + // The section 7 conformance vector must satisfy REQ-PLAT-38, 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 = TokenExchangeRequestV1 { + 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", + "iMSTNh6gQkRnBGlY1c0MUOsD7MCO4G8C7ph1_gIZs5", // 42 + "iMSTNh6gQkRnBGlY1c0MUOsD7MCO4G8C7ph1_gIZs5II", // 44 + "iMSTNh6gQkRnBGlY1c0MUOsD7MCO4G8C7ph1+gIZs5I", // base64, not base64url + "iMSTNh6gQkRnBGlY1c0MUOsD7MCO4G8C7ph1/gIZs5I", + ] { + let r = TokenExchangeRequestV1 { + code_verifier: bad.into(), + ..request() + }; + assert_eq!( + r.validate(), + Err(TokenExchangeError::MalformedCodeVerifier), + "accepted {bad:?}" + ); + } + } + + #[test] + fn refuses_an_over_long_response_field() { + let ok = TokenExchangeResponseV1 { + access_token: "t".into(), + token_attestation: vec![0; 10], + bearer_opening: vec![0; 16], + }; + ok.validate().unwrap(); + + let mut r = ok.clone(); + r.bearer_opening = vec![0; MAX_BEARER_OPENING_BYTES + 1]; + assert!(matches!( + r.validate(), + Err(TokenExchangeError::BearerOpeningTooLong(_)) + )); + + let mut r = ok.clone(); + r.access_token = "t".repeat(MAX_ACCESS_TOKEN_BYTES + 1); + assert!(matches!( + r.validate(), + Err(TokenExchangeError::AccessTokenTooLong(_)) + )); + } +} From d9e15fd6539141ba7a58990f286842213dc566b7 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Thu, 20 Aug 2026 17:46:15 +0300 Subject: [PATCH 05/65] feat: make the identity-session request checks one call A security review found the coverage duty easy to miss: nothing pointed the next implementer at it, and the decoder read like a complete check. The fix is not another helper beside the first but one entry point named for the job, so the whole boundary is a single call the caller cannot half-apply. REQ-COMMON-35, -39 and -40 are one property, and two of them are worthless alone. The uniqueness scan counts the authorization needle across REVEALED bytes only, so a byte covered by nothing is a byte it never reads: without coverage a prover hides a second header in a gap, the count stays at one, and the platform honours whichever header it likes. Closes the obsolete-line-fold gap the review found. REQ-COMMON-39 keeps CR and LF so the needle counts header lines, but obs-fold ADDS a CRLF that normalization then preserves: `authorization:\r\n Bearer x` becomes `authorization:\r\nbearer`, the needle does not match, and the header is never counted. A server honouring the fold would authenticate with it. Obs-fold is illegal in HTTP/1.1 anyway, so it is rejected outright. Also requires exactly one commitment in the direction. REQ-COMMON-60 permits several, but REQ-COMMON-35 and -40 both say "the committed range" and the circuit opens one commitment; with several, nothing ties the range that was framed to the range that was proved. Coverage now names an overlap rather than reporting a backwards gap, which was reachable only by hand-building a block and skipping validation. Eight tests, one per attack: a planted second header, a case- and whitespace-evaded one, an obs-folded one, none at all, a gap, a commitment framed by the wrong header, and two commitments. Assisted-by: Claude Opus 5 Signed-off-by: SupremaLex --- crates/libid-ceremony/src/attestation.rs | 370 +++++++++++++++++++++++ 1 file changed, 370 insertions(+) diff --git a/crates/libid-ceremony/src/attestation.rs b/crates/libid-ceremony/src/attestation.rs index f2d36a3d..fa1358f7 100644 --- a/crates/libid-ceremony/src/attestation.rs +++ b/crates/libid-ceremony/src/attestation.rs @@ -114,6 +114,24 @@ pub enum AttestationError { from: u32, to: u32, }, + #[error("spans of the {direction} direction overlap at {at}")] + SpansOverlap { direction: &'static str, at: u32 }, + #[error("the {direction} direction holds {count} commitments, but this request commits exactly one credential")] + NotOneCommitment { + direction: &'static str, + count: usize, + }, + #[error("the revealed {direction} bytes carry an obsolete line fold at {at}")] + ObsoleteLineFold { direction: &'static str, at: usize }, + #[error( + "the revealed {direction} bytes hold {count} authorization header lines, not one" + )] + NotOneAuthorizationHeader { + direction: &'static str, + count: usize, + }, + #[error("the committed range of the {direction} direction is not framed by an authorization header line")] + BadBearerFraming { direction: &'static str }, } /// Derive a 32-byte tag from a libID-namespaced ASCII string. @@ -348,6 +366,14 @@ impl DirectionBlock { let mut at = 0u32; for (start, end) in spans { + // Overlap is `validate`'s job, but a caller may hand-build a block + // and skip it. Naming it here beats reporting a backwards gap. + if start < at { + return Err(AttestationError::SpansOverlap { + direction, + at: start, + }); + } if start != at { return Err(AttestationError::CoverageGap { direction, @@ -368,6 +394,69 @@ impl DirectionBlock { } } +/// `\r\nauthorization: Bearer ` -- the raw bytes REQ-COMMON-40 requires +/// immediately before the committed range. +pub const BEARER_PREFIX: &[u8] = b"\r\nauthorization: Bearer "; +/// The raw bytes REQ-COMMON-40 requires immediately after it. +pub const BEARER_SUFFIX: &[u8] = b"\r\n"; +/// The normalized, line-anchored needle REQ-COMMON-39 counts. +pub const AUTHORIZATION_NEEDLE: &[u8] = b"\r\nauthorization:bearer"; + +/// Lowercase ASCII and drop every space and horizontal tab, keeping CR and LF +/// (REQ-COMMON-39). +/// +/// HTTP field names and the auth-scheme token are case-insensitive and the +/// colon admits optional whitespace, so a literal search over raw bytes is +/// evadable. Removing only bytes absent from the needle can create a spurious +/// match, which over-rejects and is safe, but can never hide a real one. +/// Keeping CR and LF is what makes the needle count header lines rather than +/// any substring. +fn normalize_header_bytes(raw: &[u8]) -> Vec { + raw.iter() + .filter(|b| **b != b' ' && **b != b'\t') + .map(|b| b.to_ascii_lowercase()) + .collect() +} + +/// Read `[from, to)` of the transcript out of the revealed ranges, or `None` +/// if any byte of it is not revealed. +fn revealed_slice(block: &DirectionBlock, from: usize, to: usize) -> Option> { + let mut out = Vec::with_capacity(to.checked_sub(from)?); + let mut at = from; + while at < to { + let range = block + .revealed + .iter() + .find(|r| (r.start as usize) <= at && at < (r.end as usize))?; + let offset = at - range.start as usize; + let take = (range.bytes.len() - offset).min(to - at); + out.extend_from_slice(&range.bytes[offset..offset + take]); + at += take; + } + Some(out) +} + +fn count_needle(haystack: &[u8]) -> usize { + if haystack.len() < AUTHORIZATION_NEEDLE.len() { + return 0; + } + haystack + .windows(AUTHORIZATION_NEEDLE.len()) + .filter(|w| *w == AUTHORIZATION_NEEDLE) + .count() +} + +impl DirectionBlock { + /// Concatenate the revealed bytes in offset order. + fn revealed_bytes(&self) -> Vec { + let mut out = Vec::new(); + for range in &self.revealed { + out.extend_from_slice(&range.bytes); + } + out + } +} + impl AttestedData { /// Reject a shape the Platform Verifier must refuse: ranges out of order, /// overlapping, empty, or ending past the signed transcript length @@ -416,6 +505,93 @@ impl AttestedData { Ok(decoded) } + /// Every check REQ-COMMON-35, -39 and -40 require of an identity-session + /// request that commits a credential in an HTTP `Authorization` header. + /// + /// At launch that is X's `/2/users/me` request and GitHub's `/user` + /// request, and nothing else. A credential committed in a request body is + /// a different case with its own rules, and REQ-COMMON-43 forbids applying + /// these three to it -- GitHub's token exchange commits `client_secret` in + /// a form body, so demanding a CRLF-framed header around that range would + /// reject every valid exchange. + /// + /// The three are one call because they are one property, and because two + /// of them are worthless alone. The uniqueness scan counts the needle + /// across REVEALED bytes only, so a byte covered by nothing is a byte it + /// never reads: without coverage a prover hides a second authorization + /// header in a gap, the count stays at one, and the platform honours + /// whichever header it likes. Coverage is what makes the scan complete. + /// + /// Returns the committed bearer range, which the Platform Verifier then + /// matches against the circuit's identity-bearer public input. + pub fn require_bearer_header_request( + &self, + direction: &'static str, + block: &DirectionBlock, + length: u32, + ) -> Result { + // One committed range, so "the committed range" of REQ-COMMON-35 and + // REQ-COMMON-40 and the commitment the circuit opens are the same + // object. REQ-COMMON-60 permits several per direction, and nothing + // else here would tie the framed range to the proved one. + if block.commitments.len() != 1 { + return Err(AttestationError::NotOneCommitment { + direction, + count: block.commitments.len(), + }); + } + let commitment = block.commitments[0].clone(); + + block.require_exact_coverage(direction, length)?; + + let revealed = block.revealed_bytes(); + + // Obsolete line folding is illegal in HTTP/1.1, and it defeats the + // needle: `authorization:\r\n Bearer x` normalizes to + // `authorization:\r\nbearer`, because normalization strips the space + // but keeps the CRLF that the fold introduced. The header is then not + // counted, and a server that honours the fold authenticates with it. + for i in 0..revealed.len().saturating_sub(2) { + if revealed[i] == b'\r' + && revealed[i + 1] == b'\n' + && (revealed[i + 2] == b' ' || revealed[i + 2] == b'\t') + { + return Err(AttestationError::ObsoleteLineFold { direction, at: i }); + } + } + + // Count over each region separately rather than over a concatenation, + // so joining the two cannot manufacture a match at the seam. + let mut count = 0; + for range in &block.revealed { + count += count_needle(&normalize_header_bytes(&range.bytes)); + } + if count != 1 { + return Err(AttestationError::NotOneAuthorizationHeader { direction, count }); + } + + // Framing, on RAW bytes at known offsets. Two fixed comparisons make + // the committed range one header line's value by construction, so the + // credential cannot be hidden inside another header's value. + let before_end = commitment.start as usize; + let prefix_start = before_end.checked_sub(BEARER_PREFIX.len()); + let ok_prefix = match prefix_start { + Some(from) => { + revealed_slice(block, from, before_end) == Some(BEARER_PREFIX.to_vec()) + } + None => false, + }; + let after_start = commitment.end as usize; + let ok_suffix = + revealed_slice(block, after_start, after_start + BEARER_SUFFIX.len()) + == Some(BEARER_SUFFIX.to_vec()); + if !ok_prefix || !ok_suffix { + return Err(AttestationError::BadBearerFraming { direction }); + } + + Ok(commitment) + } + /// `keccak256(attestedData)` -- the only preimage the notary signs /// (REQ-COMMON-47). pub fn digest(&self) -> Result<[u8; 32], AttestationError> { @@ -693,4 +869,198 @@ e7b961087ec316778e6885d11145cc06f1d75360430f461d0322fb7f105899dd\ moved.sent.commitments[0].start = 19; assert_ne!(moved.digest().unwrap(), sample().digest().unwrap()); } + + // --- The identity-session request checks ------------------------------- + + const BEARER: &[u8] = b"AAAAbbbbCCCCdddd"; + + /// A real `/2/users/me` request: four headers, the bearer committed, every + /// other byte revealed, tiled exactly. + fn identity_request( + extra_header: &str, + bearer_prefix: &str, + ) -> (DirectionBlock, u32) { + let head = format!( + "GET /2/users/me HTTP/1.1\r\naccept: application/json\r\nhost: api.x.com\r\n{extra_header}{bearer_prefix}" + ); + let tail = "\r\nconnection: close\r\n\r\n"; + let start = head.len() as u32; + let end = start + BEARER.len() as u32; + let length = end + tail.len() as u32; + ( + DirectionBlock { + revealed: vec![ + RevealedRange { + start: 0, + end: start, + bytes: head.into_bytes(), + }, + RevealedRange { + start: end, + end: length, + bytes: tail.as_bytes().to_vec(), + }, + ], + commitments: vec![RangeCommitment { + start, + end, + commitment: [5u8; 32], + }], + }, + length, + ) + } + + fn honest() -> (DirectionBlock, u32) { + identity_request("", "\r\nauthorization: Bearer ") + } + + fn check( + block: &DirectionBlock, + length: u32, + ) -> Result { + let mut data = sample(); + data.sent = block.clone(); + data.sent_transcript_length = length; + data.require_bearer_header_request("sent", block, length) + } + + #[test] + fn accepts_an_honest_identity_request() { + let (block, length) = honest(); + let commitment = check(&block, length).expect("an honest request must verify"); + assert_eq!(commitment.commitment, [5u8; 32]); + } + + #[test] + fn rejects_a_second_authorization_header() { + // The plain form: a planted header in revealed bytes. Coverage forces + // it to be revealed, and the scan counts two. + let (block, length) = identity_request( + "authorization: Bearer stolen\r\n", + "\r\nauthorization: Bearer ", + ); + assert!(matches!( + check(&block, length), + Err(AttestationError::NotOneAuthorizationHeader { count: 2, .. }) + )); + } + + #[test] + fn rejects_a_case_and_whitespace_evaded_second_header() { + // Field names and the scheme token are case-insensitive and the colon + // admits whitespace, so a literal search would miss this one. + let (block, length) = identity_request( + "AuThOrIzAtIoN:\tBeArEr stolen\r\n", + "\r\nauthorization: Bearer ", + ); + assert!(matches!( + check(&block, length), + Err(AttestationError::NotOneAuthorizationHeader { count: 2, .. }) + )); + } + + #[test] + fn rejects_an_obsolete_line_fold() { + // The gap a security review found: `authorization:\r\n Bearer x` + // normalizes to `authorization:\r\nbearer`, so the needle does not + // match and the header is never counted. A server honouring the fold + // would authenticate with it. + let (block, length) = identity_request( + "authorization:\r\n Bearer stolen\r\n", + "\r\nauthorization: Bearer ", + ); + assert!(matches!( + check(&block, length), + Err(AttestationError::ObsoleteLineFold { .. }) + )); + } + + #[test] + fn rejects_a_request_with_no_authorization_header_at_all() { + let (block, length) = identity_request("", "\r\nx-other: "); + assert!(matches!( + check(&block, length), + Err(AttestationError::NotOneAuthorizationHeader { count: 0, .. }) + )); + } + + #[test] + fn rejects_a_gap_the_scan_would_never_read() { + // This is why the three are one call. Open a gap and the hidden bytes + // are not scanned at all. + let (mut block, length) = honest(); + block.revealed[0].end -= 1; + block.revealed[0].bytes.pop(); + assert!(matches!( + check(&block, length), + Err(AttestationError::CoverageGap { .. }) + )); + } + + #[test] + fn rejects_a_commitment_that_is_not_the_header_value() { + // Framing on its own. The authorization header here is whole and + // revealed, so the needle counts once and coverage is exact -- but the + // committed range sits in the `host` header instead. Without + // REQ-COMMON-40 nothing would notice that the proved commitment and + // the credential are different objects. + let head = "GET /2/users/me HTTP/1.1\r\naccept: application/json\r\n\ +authorization: Bearer TOKEN123\r\nhost: "; + let committed = "api."; + let tail = "x.com\r\nconnection: close\r\n\r\n"; + let start = head.len() as u32; + let end = start + committed.len() as u32; + let length = end + tail.len() as u32; + let block = DirectionBlock { + revealed: vec![ + RevealedRange { + start: 0, + end: start, + bytes: head.as_bytes().to_vec(), + }, + RevealedRange { + start: end, + end: length, + bytes: tail.as_bytes().to_vec(), + }, + ], + commitments: vec![RangeCommitment { + start, + end, + commitment: [5u8; 32], + }], + }; + assert!(matches!( + check(&block, length), + Err(AttestationError::BadBearerFraming { .. }) + )); + } + + #[test] + fn rejects_more_than_one_commitment() { + // REQ-COMMON-60 permits several per direction, but then nothing ties + // the framed range to the one the circuit opens. + let (mut block, length) = honest(); + let extra = RangeCommitment { + start: 0, + end: 1, + commitment: [6u8; 32], + }; + block.commitments.insert(0, extra); + assert!(matches!( + check(&block, length), + Err(AttestationError::NotOneCommitment { count: 2, .. }) + )); + } + + #[test] + fn coverage_names_an_overlap_rather_than_a_backwards_gap() { + let mut block = honest().0; + block.revealed[1].start -= 2; + assert!(matches!( + block.require_exact_coverage("sent", 0), + Err(AttestationError::SpansOverlap { .. }) + )); + } } From 0a57124c138dba052def3ef56d31ea673887ba6c Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Thu, 20 Aug 2026 17:55:36 +0300 Subject: [PATCH 06/65] feat: build attested data from a notarized session The adapter between what tlsn observed and what ceremony-common section 9.1 signs. It lives here because this crate is already the only one allowed to know tlsn exists: 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 a RangeSet exists. The signed transcript lengths appear in no signed field today, and REQ-COMMON-36 makes them the only source of the length the coverage check uses. tlsn carries them already, as u32, which is the width the format wants. Three refusals, each closing a way a genuine-looking session would produce evidence nothing can verify. A BLAKE3 commitment is refused because that is the notarization library's default while the Proving Circuit computes SHA-256, so a prover left on defaults produces commitments the circuit cannot open. A commitment over disjoint ranges is refused because the format pairs one value with one offset pair, and a hash over a union cannot be split without inventing a value for each. An offset past 32 bits is refused rather than truncated. The authority arrives as a string rather than as tlsn's name type, so the mapping stays testable and nothing here depends on how upstream models a server name. It is a signed field rather than a revealed range because the transcript carries the authority only in a prover-composed Host header. REQ-COMMON-61 is tested structurally: two sessions whose responses name different accounts must produce identical header bytes, and the test also asserts the two sessions differ somewhere, so it cannot pass vacuously. The load-bearing test is that what this emits satisfies the coverage check the Platform Verifier runs. If it did not, no honest session would ever verify. Assisted-by: Claude Opus 5 Signed-off-by: SupremaLex --- Cargo.lock | 3 + crates/libid-tlsn/Cargo.toml | 4 + crates/libid-tlsn/src/attest.rs | 339 ++++++++++++++++++++++++++++++++ crates/libid-tlsn/src/lib.rs | 2 + 4 files changed, 348 insertions(+) create mode 100644 crates/libid-tlsn/src/attest.rs diff --git a/Cargo.lock b/Cargo.lock index 542d0a63..3008624c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3404,7 +3404,10 @@ dependencies = [ "http-body-util", "hyper 1.11.0", "hyper-util", + "libid-ceremony", "libid-transcript", + "rangeset", + "serde_json", "thiserror 2.0.20", "tlsn", "tokio", 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..85ea2b06 --- /dev/null +++ b/crates/libid-tlsn/src/attest.rs @@ -0,0 +1,339 @@ +//! Turn what a notarized session produced into the attested data of +//! ceremony-common section 9.1. +//! +//! 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. + +use libid_ceremony::attestation::{ + tag, + AttestedData, + DirectionBlock, + RangeCommitment, + RevealedRange, +}; +use tlsn::{ + hash::HashAlgId, + transcript::{ + Direction, + PartialTranscript, + TranscriptCommitment, + }, +}; + +/// What the profile pins, and what only the notary knows. +pub struct AttestationInput<'a> { + /// The libID-namespaced string naming this attestation format and version. + pub format_tag: &'a str, + /// The identity-platform name. + pub platform_name: &'a str, + /// The libID-namespaced string naming which session this covers. + pub operation_tag: &'a str, + /// The notary's OWN clock reading when the session completed. + /// + /// REQ-COMMON-57 forbids taking this from the prover, from a response + /// header, or from any other party. It is an argument 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)) +} + +/// Build the attested data for one notarized session. +/// +/// 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-61). 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. +/// `authority` is 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. +pub fn attested_data( + partial: &PartialTranscript, + authority: &str, + commitments: &[TranscriptCommitment], + input: AttestationInput<'_>, +) -> Result { + Ok(AttestedData { + format_tag: tag(input.format_tag), + platform_id: tag(input.platform_name), + operation_tag: tag(input.operation_tag), + // The canonical authority of section 9: the lowercase ASCII TLS server + // name the notary authenticated, with no trailing dot. It is a signed + // field rather than 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-56). + authority_id: tag(&authority.to_ascii_lowercase()), + created_at: input.created_at, + sent_transcript_length: u32_of(partial.len_sent())?, + recv_transcript_length: u32_of(partial.len_received())?, + sent: direction_block(partial, commitments, Direction::Sent)?, + received: direction_block(partial, commitments, Direction::Received)?, + }) +} + +fn direction_block( + partial: &PartialTranscript, + commitments: &[TranscriptCommitment], + direction: Direction, +) -> Result { + let (authed, data) = match direction { + Direction::Sent => (partial.sent_authed(), partial.sent_unsafe()), + Direction::Received => (partial.received_authed(), partial.received_unsafe()), + }; + + // One entry per revealed range, in ascending start order, each carrying its + // offsets and exactly `end - start` bytes. 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 (REQ-COMMON-59). + let mut revealed = Vec::new(); + for range in authed.iter() { + revealed.push(RevealedRange { + start: u32_of(range.start)?, + end: u32_of(range.end)?, + bytes: data[range.clone()].to_vec(), + }); + } + + let mut out = Vec::new(); + for commitment in 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 != 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 { + use super::*; + 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\"}"; + + fn input<'a>() -> AttestationInput<'a> { + AttestationInput { + format_tag: "libid.attestation.v1", + platform_name: "x", + operation_tag: "libid.ceremony.session.identity.v1", + 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 = 45..48; // "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 = attested_data(&partial, "api.x.com", &commitments, input()).unwrap(); + assert_eq!(data.sent_transcript_length, SENT.len() as u32); + assert_eq!(data.recv_transcript_length, RECV.len() as u32); + } + + #[test] + fn produces_an_attestation_the_codec_accepts() { + let (partial, commitments) = session(); + let data = attested_data(&partial, "api.x.com", &commitments, input()).unwrap(); + let encoded = data.encode().expect("the notary must emit a valid shape"); + assert_eq!( + libid_ceremony::AttestedData::decode(&encoded).unwrap(), + data + ); + } + + #[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 = attested_data(&partial, "api.x.com", &commitments, input()).unwrap(); + data.sent + .require_exact_coverage("sent", data.sent_transcript_length) + .expect("an honest identity request tiles exactly"); + } + + #[test] + fn authority_is_the_authenticated_server_name() { + let (partial, commitments) = session(); + let data = attested_data(&partial, "api.x.com", &commitments, input()).unwrap(); + assert_eq!(data.authority_id, tag("api.x.com")); + // And it is NOT taken from a Host header the prover composed. + assert_ne!(data.authority_id, tag("evil.example")); + } + + #[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!( + attested_data(&partial, "api.x.com", &commitments, input()), + 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!( + attested_data(&partial, "api.x.com", &commitments, input()), + Err(AttestError::DisjointCommitment(2)) + )); + } + + #[test] + fn places_no_profile_derived_value_in_the_signed_bytes() { + // REQ-COMMON-61: the notary must place 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 = 45..48; + 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 = + attested_data(&partial, "api.x.com", &commitments, input()).unwrap(); + headers.push(data.encode().unwrap()[..144].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!( + attested_data(&a, "api.x.com", &commitments, input()) + .unwrap() + .encode() + .unwrap(), + attested_data(&b, "api.x.com", &commitments, input()) + .unwrap() + .encode() + .unwrap(), + "the two sessions must differ somewhere -- in the revealed range" + ); + } +} diff --git a/crates/libid-tlsn/src/lib.rs b/crates/libid-tlsn/src/lib.rs index 1fb25ee1..8dd18842 100644 --- a/crates/libid-tlsn/src/lib.rs +++ b/crates/libid-tlsn/src/lib.rs @@ -100,3 +100,5 @@ pub enum Error { /// Result alias for this crate. pub type Result = std::result::Result; + +pub mod attest; From d9194a953af13a2da21d2ea2e50c1f768f66e16e Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Fri, 21 Aug 2026 08:24:56 +0300 Subject: [PATCH 07/65] feat: select the ceremony reveal layouts A security pass found the verifier had moved ahead of the prover: the contracts demand that every notarized direction tiles, and the only reveal-selection code in the tree still chose the pre-ceremony sparse ranges -- the request line and Host revealed, the whole response committed. An honest session would have been rejected. That is as bad as a hole, and no unit test on either side reaches it, because each is correct in isolation. The layouts live in libid-transcript, which is tlsn-free and publishable, and each names only what it REVEALS. The commitments are derived as the complement, so tiling holds by construction rather than by inspection -- listing both and hoping they agree is exactly the mistake the verifier exists to catch. Four layouts. The X token request is revealed whole, because X authenticates with a public client and hides nothing there, and because the verifier needs the head boundary visible to locate the body by the framing the server parsed at all. GitHub commits its client_secret alone, ordered last under REQ-COMMON-22, so the revealed run is a prefix and the commitment reaches the transcript end. The token response reveals the two `"access_token":"` anchors and commits everything between and around them. The identity request reveals every byte but the bearer; the identity response reveals the two identity members whole, delimiters included, so each match sits inside one revealed run rather than being spliced from several. The load-bearing tests are the round-trips in libid-tlsn: they build a real transcript, take the layout the prover would select, encode it through attested_data, and assert require_exact_coverage accepts it. That is the agreement between the two sides, and it is now asserted rather than assumed. The pre-ceremony selection stays behind RevealMode::Legacy, marked as not tiling, until every caller moves. BREAKING CHANGE: prover_generic takes a RevealMode. Existing callers pass RevealMode::Legacy for the behaviour they had. 126 tests, clippy clean. Assisted-by: Claude Opus 5 Signed-off-by: SupremaLex --- Cargo.lock | 2 +- crates/libid-tlsn/src/attest.rs | 104 ++++++++ crates/libid-tlsn/src/session.rs | 112 ++++++++- crates/libid-transcript/src/ceremony.rs | 304 ++++++++++++++++++++++++ crates/libid-transcript/src/lib.rs | 1 + 5 files changed, 514 insertions(+), 9 deletions(-) create mode 100644 crates/libid-transcript/src/ceremony.rs diff --git a/Cargo.lock b/Cargo.lock index 3008624c..a392bb5c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3364,7 +3364,7 @@ dependencies = [ [[package]] name = "libid-ceremony" -version = "0.1.0" +version = "0.2.0" dependencies = [ "base64 0.22.1", "hex", diff --git a/crates/libid-tlsn/src/attest.rs b/crates/libid-tlsn/src/attest.rs index 85ea2b06..275ac229 100644 --- a/crates/libid-tlsn/src/attest.rs +++ b/crates/libid-tlsn/src/attest.rs @@ -162,6 +162,10 @@ fn direction_block( #[cfg(test)] mod tests { use super::*; + use libid_transcript::ceremony::{ + self, + Layout, + }; use rangeset::set::RangeSet; use tlsn::{ hash::TypedHash, @@ -336,4 +340,104 @@ mod tests { "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), + })); + } + } + attested_data(&partial, "api.x.com", &commitments, input()).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 = ceremony::identity_request(sent).unwrap(); + let r = ceremony::identity_response( + recv, + "id", + ceremony::IdShape::JsonString, + "username", + ) + .unwrap(); + let data = round_trip(sent, recv, &s, &r); + + data.sent + .require_exact_coverage("sent", data.sent_transcript_length) + .expect("the identity request must satisfy REQ-COMMON-35"); + data.received + .require_exact_coverage("received", data.recv_transcript_length) + .expect("the identity response must tile too"); + + // 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 = ceremony::token_request(sent, None).unwrap(); + let r = ceremony::token_response(recv).unwrap(); + let data = round_trip(sent, recv, &s, &r); + + data.sent + .require_exact_coverage("sent", data.sent_transcript_length) + .expect("the token request must tile"); + // 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 = ceremony::token_request(sent, Some("client_secret")).unwrap(); + let r = ceremony::token_response(recv).unwrap(); + let data = round_trip(sent, recv, &s, &r); + + data.sent + .require_exact_coverage("sent", data.sent_transcript_length) + .expect("the exchange must tile"); + 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/session.rs b/crates/libid-tlsn/src/session.rs index 5c50d130..ea71be03 100644 --- a/crates/libid-tlsn/src/session.rs +++ b/crates/libid-tlsn/src/session.rs @@ -69,6 +69,10 @@ use tracing::{ }; use libid_transcript::{ + ceremony::{ + self, + IdShape, + }, find_notary_reveal_ranges, find_presentation_commit_ranges, TlsHandshakeData, @@ -279,6 +283,9 @@ where bearer_token: Some(access_token), user_agent: params.user_agent, }, + // This flow predates the ceremony layouts and still selects the old + // sparse ranges. `RevealMode::Ceremony` is what a ceremony session uses. + RevealMode::Legacy, |recv| { let mut ranges = vec![ @@ -308,6 +315,68 @@ where /// Run the MPC-TLS prover with arbitrary API parameters. /// +/// The reveal and commit ranges for one ceremony session, both directions. +fn ceremony_layouts( + sent: &[u8], + recv: &[u8], + session: CeremonySession<'_>, +) -> Result<(ceremony::Layout, ceremony::Layout)> { + let to_err = |e: ceremony::LayoutError| Error::MpcTlsFailed { + detail: format!("ceremony layout: {e}"), + }; + Ok(match session { + CeremonySession::Token { secret_field } => ( + ceremony::token_request(sent, secret_field).map_err(to_err)?, + ceremony::token_response(recv).map_err(to_err)?, + ), + CeremonySession::Identity { + id_field, + id_shape, + handle_field, + } => ( + ceremony::identity_request(sent).map_err(to_err)?, + ceremony::identity_response(recv, id_field, id_shape, handle_field) + .map_err(to_err)?, + ), + }) +} + +/// Which session of a ceremony this is, and therefore what it discloses. +/// +/// The layouts come from `libid_transcript::ceremony`, where the commitments +/// are derived as the complement of the reveals so every direction tiles by +/// construction. A direction that does not tile is refused by the Platform +/// Verifier, so this is a correctness requirement rather than a disclosure +/// preference: choose the wrong ranges and no honest ceremony verifies. +#[derive(Clone, Copy, Debug)] +pub enum CeremonySession<'a> { + /// X's `/2/oauth2/token`, or GitHub's token exchange when `secret_field` + /// names the credential ordered last in its body. + Token { secret_field: Option<&'a str> }, + /// X's `/2/users/me`, or GitHub's `/user`. + Identity { + id_field: &'a str, + id_shape: IdShape, + handle_field: &'a str, + }, +} + +/// How a prover chooses its ranges. +#[derive(Clone, Copy, Debug)] +pub enum RevealMode<'a> { + /// The ceremony layouts of the specification. + Ceremony(CeremonySession<'a>), + /// The pre-ceremony selection: the request line and `Host` revealed and + /// committed, the whole response committed, and the caller's closure + /// choosing what of the response to reveal. + /// + /// Kept only until the ceremony path replaces every caller. It does NOT + /// tile, so an attestation produced this way is rejected by the Platform + /// Verifier. + Legacy, +} + +/// The reveal and commit ranges for one ceremony session, both directions. /// 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 @@ -321,6 +390,7 @@ where pub async fn prover_generic( socket: T, request: &HttpRequestSpec<'_>, + reveal_mode: RevealMode<'_>, compute_reveal_ranges: R, on_progress: F, ) -> Result> @@ -468,7 +538,21 @@ 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. + let (sent_layout, recv_layout) = match reveal_mode { + RevealMode::Ceremony(session) => { + let (s, r) = ceremony_layouts(sent, recv, session)?; + (Some(s), Some(r)) + } + RevealMode::Legacy => (None, None), + }; + + let reveal_recv_ranges = match &recv_layout { + Some(l) => l.reveal.clone(), + None => compute_reveal_ranges(recv)?, + }; // Save the revealed recv segments BEFORE the transcript is moved. // The notary hashes exactly these bytes into the `recv:` Merkle leaves, so @@ -478,21 +562,33 @@ where .map(|r| recv[r.clone()].to_vec()) .collect(); - let notary_sent_ranges = find_notary_reveal_ranges(sent); + let notary_sent_ranges = match &sent_layout { + Some(l) => l.reveal.clone(), + None => find_notary_reveal_ranges(sent), + }; let mut tc_builder = TranscriptCommitConfig::builder(&transcript); - for range in find_presentation_commit_ranges(sent) { + let (sent_commits, recv_commits) = match (&sent_layout, &recv_layout) { + (Some(s), Some(r)) => (s.commit.clone(), r.commit.clone()), + _ => ( + find_presentation_commit_ranges(sent), + core::iter::once(0..recv.len()).collect(), + ), + }; + for range in sent_commits { 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}"), - })?; + for range in recv_commits { + tc_builder + .commit_recv(&range) + .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}"), })?; diff --git a/crates/libid-transcript/src/ceremony.rs b/crates/libid-transcript/src/ceremony.rs new file mode 100644 index 00000000..584e828c --- /dev/null +++ b/crates/libid-transcript/src/ceremony.rs @@ -0,0 +1,304 @@ +//! 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. + +use std::ops::Range; + +use crate::ranges::{ + compute_field_snippet_range, + compute_id_snippet_range, +}; + +/// 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 +} + +/// A one-range reveal list. Spelled this way because a `vec![a..b]` literal +/// trips a lint that exists to catch `vec![0; n]` typos. +fn one(range: Range) -> Vec> { + core::iter::once(range).collect() +} + +fn layout(reveal: Vec>, len: usize) -> Layout { + 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], + secret_field: Option<&str>, +) -> Result { + let Some(field) = secret_field else { + return Ok(layout(one(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(layout(one(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 { + const ANCHOR: &[u8] = b"\"access_token\":\""; + let anchor_start = recv + .windows(ANCHOR.len()) + .position(|w| w == ANCHOR) + .ok_or_else(|| LayoutError::MissingField("access_token".into()))?; + let value_start = anchor_start + ANCHOR.len(); + let value_end = value_start + + recv[value_start..] + .iter() + .position(|&b| b == b'"') + .ok_or_else(|| LayoutError::MissingField("access_token".into()))?; + + Ok(layout( + vec![anchor_start..value_start, value_end..value_end + 1], + 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(layout( + vec![0..value_start, value_end..sent.len()], + sent.len(), + )) +} + +/// Which shape the platform's immutable identifier takes. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum IdShape { + /// X: `"id":"2244994945"`. + JsonString, + /// GitHub: `"id":583231,` -- the terminator is revealed with it, because it + /// is what proves the digits are the whole number rather than a prefix. + JsonInteger, +} + +/// 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. +pub fn identity_response( + recv: &[u8], + id_field: &str, + id_shape: IdShape, + handle_field: &str, +) -> Result { + // The bare-integer form takes its structural terminator with it, which is + // what proves the revealed digits are the whole number. + let id = compute_id_snippet_range(recv, id_field, id_shape == IdShape::JsonString) + .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, so sort rather than assume. + let mut reveal = vec![id, handle]; + reveal.sort_by_key(|r| r.start); + Ok(layout(reveal, 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 + } + + 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 the_x_token_request_is_revealed_whole() { + let l = token_request(X_TOKEN_REQ, None).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 = token_request(req, Some("client_secret")).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!( + token_request(X_TOKEN_REQ, Some("client_secret")), + 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 = 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 = 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!( + 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_x_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 = identity_response(recv, "id", IdShape::JsonString, "username").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() + ); + // The display name stays committed. + assert!(l + .commit + .iter() + .any(|c| recv[c.clone()].windows(4).any(|w| w == b"name"))); + } + + #[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!( + identity_response(recv, "id", IdShape::JsonString, "username"), + Err(LayoutError::MissingField(_)) + )); + } +} diff --git a/crates/libid-transcript/src/lib.rs b/crates/libid-transcript/src/lib.rs index 33fc6f3f..4f02ffe9 100644 --- a/crates/libid-transcript/src/lib.rs +++ b/crates/libid-transcript/src/lib.rs @@ -14,6 +14,7 @@ //! * [`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; From 86705032e6daecebc834e027254b63aa5980dfb3 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Fri, 21 Aug 2026 11:54:53 +0300 Subject: [PATCH 08/65] docs: say where the attestation format's requirement numbers come from REQ-COMMON-47..61 and REQ-PLAT-61..74 are cited throughout this crate and are not in the published specification. They were written in libid PR #12, which defined the attestation byte layout and was closed on 2026-08-20 without merging; PR #15 does not restore it. What survives on main is REQ-COMMON-18, which requires a Platform Profile to PIN the format it accepts and leaves the format itself to the profile author. So this crate is the definition rather than a reading of one, and a reader looking those numbers up will not find them. The numbering is kept because it is the specification's own and the intent is to upstream the layout under it -- the specification follows what the implementation needs. Every rule the numbers name is stated in full beside them. Recorded because the alternative is a reviewer chasing a citation into a closed pull request, and because four components must agree on these bytes: this crate, the Solidity decoder, the TypeScript mirror, and the notary. A divergence between them is silent. Signed-off-by: SupremaLex --- crates/libid-ceremony/src/attestation.rs | 20 ++++++++++++++++++++ crates/libid-ceremony/src/profile.rs | 6 +++++- crates/libid-tlsn/src/attest.rs | 4 ++++ 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/crates/libid-ceremony/src/attestation.rs b/crates/libid-ceremony/src/attestation.rs index fa1358f7..52424080 100644 --- a/crates/libid-ceremony/src/attestation.rs +++ b/crates/libid-ceremony/src/attestation.rs @@ -10,6 +10,26 @@ //! 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 (REQ-COMMON-48). +//! +//! # Where these requirement numbers come from +//! +//! The `REQ-COMMON-47` through `REQ-COMMON-61` cited below are NOT in the +//! published specification. They were written in libid PR #12, which defined +//! this byte layout and was closed on 2026-08-20 without merging; PR #15 does +//! not restore it. What survives on main is `REQ-COMMON-18`, which requires a +//! Platform Profile to PIN 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. The numbering is +//! kept because it is the specification's own, and the intent is to upstream +//! this layout under those identifiers -- the specification follows what the +//! implementation needs. Until it does, a reader looking these up will not +//! find them, and every rule they name is stated in full here. +//! +//! 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; diff --git a/crates/libid-ceremony/src/profile.rs b/crates/libid-ceremony/src/profile.rs index 0df7e447..ce151b14 100644 --- a/crates/libid-ceremony/src/profile.rs +++ b/crates/libid-ceremony/src/profile.rs @@ -17,7 +17,11 @@ //! the profile author. //! //! That makes these constants a cross-implementation agreement rather than a -//! reading of the specification. A notary emitting `libid.attestation.v1` and a +//! reading of the specification. The same goes for the `REQ-COMMON-53`/`-55` +//! and `REQ-PLAT-61`..`-74` numbers cited below: they come from libid PR #12, +//! which was closed without merging, so a reader will not find them on main. +//! They are kept because the intent is to upstream these definitions under the +//! specification's own numbering. A notary emitting `libid.attestation.v1` and a //! verifier pinning anything else derives a key nobody trusts and rejects every //! genuine attestation, with no error that says why. They must be agreed before //! either side ships. diff --git a/crates/libid-tlsn/src/attest.rs b/crates/libid-tlsn/src/attest.rs index 275ac229..2d89d000 100644 --- a/crates/libid-tlsn/src/attest.rs +++ b/crates/libid-tlsn/src/attest.rs @@ -5,6 +5,10 @@ //! 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 `REQ-COMMON-56`/`-57`/`-59`/`-61` cited below are from libid PR #12, +//! which was closed without merging. `libid_ceremony::attestation` carries the +//! provenance note and states each rule in full. use libid_ceremony::attestation::{ tag, From d170d3d9d2a783d72dfc9eb0839570484b102d42 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Mon, 24 Aug 2026 12:54:50 +0300 Subject: [PATCH 09/65] refactor: publish only what Rust reads, gate the rest behind `vectors` Measured, not guessed: every use of this crate outside libid-rs is three string constants, all in one function of the notary. notary/src/server.rs:649 profile::TOKEN_SESSION_TAG notary/src/server.rs:650 profile::IDENTITY_SESSION_TAG notary/src/server.rs:695 profile::FORMAT_TAG libid-server-rs uses none. Inside libid-rs, only `attestation` is referenced. `AuthorizationPreimage`, the PKCE derivation, `PlatformProfile`, `SessionProfile`, `HandleRules`, `DigestBinding`, `LAUNCH_PROFILES` and `LAUNCH_PARAMETERS` have no caller anywhere, and will not get one: nothing in Rust builds a digest, derives a verifier, or reads a profile record. The browser builds the first two and the contracts recompute them; a Platform Verifier pins the third. The notary derives nothing by rule (REQ-COMMON-33). They are worth keeping as a third reading of the specification's published vectors, so the conformance suite can check the Solidity and TypeScript implementations against something written independently. That makes them a test oracle. An oracle compiled into every consumer is code nothing calls, and a mistake in it is invisible -- so `authorization`, `pkce` and the launch records now sit behind a `vectors` feature that is off by default and on under `cfg(test)`. The launch records move out of `profile` into their own module, which leaves `profile` at 38 lines holding exactly the three tags the notary stamps. That is the honest shape of what Rust needs. No behaviour changes. 127 tests pass with the feature off and on; clippy clean both ways. Signed-off-by: SupremaLex --- crates/libid-ceremony/Cargo.toml | 7 + crates/libid-ceremony/src/launch.rs | 317 +++++++++++++++++++++++++ crates/libid-ceremony/src/lib.rs | 40 +++- crates/libid-ceremony/src/profile.rs | 333 ++------------------------- 4 files changed, 370 insertions(+), 327 deletions(-) create mode 100644 crates/libid-ceremony/src/launch.rs diff --git a/crates/libid-ceremony/Cargo.toml b/crates/libid-ceremony/Cargo.toml index bef1c1a2..143a87c0 100644 --- a/crates/libid-ceremony/Cargo.toml +++ b/crates/libid-ceremony/Cargo.toml @@ -7,6 +7,13 @@ rust-version.workspace = true license.workspace = true repository.workspace = true +[features] +# The digest, PKCE and launch-profile records: a third reading of the +# specification's constants, for the conformance suite to check the Solidity and +# TypeScript ones against. No service enables this -- nothing in Rust builds a +# digest, derives a verifier, or reads a profile record. +vectors = [] + [dependencies] base64.workspace = true libid-crypto.workspace = true diff --git a/crates/libid-ceremony/src/launch.rs b/crates/libid-ceremony/src/launch.rs new file mode 100644 index 00000000..8d4b0d48 --- /dev/null +++ b/crates/libid-ceremony/src/launch.rs @@ -0,0 +1,317 @@ +//! The launch platform profiles, and the protocol parameters governance owns. +//! +//! # Nothing in Rust reads these +//! +//! A Platform Profile is what a Platform Verifier pins and what a browser +//! runtime selects. Neither is Rust: the verifier is Solidity and the runtime +//! is TypeScript. The notary, which IS Rust, is forbidden from deciding +//! anything profile-specific (REQ-COMMON-33) -- it stamps the three tags of +//! [`super::profile`] and derives nothing else. +//! +//! These records exist as a third reading of the specification's published +//! constants, so the conformance suite can check the Solidity and TypeScript +//! ones against something written independently. That makes them a test +//! oracle, which is why they are behind a feature no service enables. Compiled +//! into the notary they would be dead weight, and a mistake in them would be +//! invisible. + +use crate::profile::{ + IDENTITY_SESSION_TAG, + TOKEN_SESSION_TAG, +}; + +/// How a profile binds the Authorization Digest to its evidence +/// (REQ-COMMON-02C). Exactly one of the two, never both and never neither. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DigestBinding { + /// Google: the digest is a public proof input the verifier compares. + PublicProofInput, + /// X and GitHub: the verifier recomputes the revealed `code_verifier`. + RevealedCodeVerifier, +} + +/// One notarized session of a ceremony. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct SessionProfile { + /// The `operationTag` this session's attestation must carry. + pub operation_tag: &'static str, + /// The TLS server name the notary must have authenticated, in the + /// canonical form of section 9: lowercase ASCII, no trailing dot. It + /// reaches the verifier as `authorityId`, never as a transcript range, + /// because the transcript holds it only in a prover-composed `Host` header. + pub authority: &'static str, + /// Exact uppercase HTTP method, compared against a revealed range. + pub method: &'static str, + /// Origin-form path, no query, compared against a revealed range. + pub path: &'static str, +} + +/// The five parameters of platform-ceremonies section 2.1a. +/// +/// `is_email` supersedes the two booleans below it (REQ-PLAT-67); they are +/// stated because REQ-PLAT-72 requires a profile to fix all five. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct HandleRules { + pub max_length: u16, + pub strip_leading_at: bool, + pub is_email: bool, + pub allow_underscore: bool, + pub allow_hyphen: bool, +} + +/// An immutable, independently versioned ceremony profile. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PlatformProfile { + /// Preimage of `platformId` (REQ-COMMON-55). + pub name: &'static str, + /// Carried in the Authorization Digest and the submission. Launch profiles + /// use 1 (REQ-PLAT-01). + pub platform_verifier_version: u16, + pub digest_binding: DigestBinding, + /// The token or token-exchange session, where the profile has one. + pub token_session: Option, + /// The identity session, where the profile has one. + pub identity_session: Option, + pub handle: HandleRules, +} + +impl PlatformProfile { + /// Derived, never stated beside the session list: the profile fixes the + /// list and the count is its size (REQ-COMMON-41). + pub fn attestation_count(&self) -> u8 { + self.token_session.is_some() as u8 + self.identity_session.is_some() as u8 + } + + /// A profile verifying no attestation reaches no Notary Service and pays + /// nothing (REQ-COMMON-05D, REQ-COMMON-06E). + pub fn verifies_attestations(&self) -> bool { + self.attestation_count() > 0 + } +} + +/// `google/v1` -- authentication-only OIDC. No token exchange, no client +/// secret, no PKCE, no notarized session, and therefore no Notary Fee. +pub const GOOGLE_V1: PlatformProfile = PlatformProfile { + name: "google", + platform_verifier_version: 1, + digest_binding: DigestBinding::PublicProofInput, + token_session: None, + identity_session: None, + handle: HandleRules { + max_length: 62, + strip_leading_at: false, + is_email: true, + allow_underscore: false, + allow_hyphen: false, + }, +}; + +/// `x/v1` -- a public client with S256 PKCE and two browser-owned sessions. +pub const X_V1: PlatformProfile = PlatformProfile { + name: "x", + platform_verifier_version: 1, + digest_binding: DigestBinding::RevealedCodeVerifier, + token_session: Some(SessionProfile { + operation_tag: TOKEN_SESSION_TAG, + authority: "api.x.com", + method: "POST", + path: "/2/oauth2/token", + }), + identity_session: Some(SessionProfile { + operation_tag: IDENTITY_SESSION_TAG, + authority: "api.x.com", + method: "GET", + path: "/2/users/me", + }), + handle: HandleRules { + max_length: 15, + strip_leading_at: true, + is_email: false, + allow_underscore: true, + allow_hyphen: false, + }, +}; + +/// `github/v1` -- a confidential client, so the exchange runs in the +/// deployment's Token-Exchange Service and that service is the notarized party +/// for the token session. Note the two authorities differ. +pub const GITHUB_V1: PlatformProfile = PlatformProfile { + name: "github", + platform_verifier_version: 1, + digest_binding: DigestBinding::RevealedCodeVerifier, + token_session: Some(SessionProfile { + operation_tag: TOKEN_SESSION_TAG, + authority: "github.com", + method: "POST", + path: "/login/oauth/access_token", + }), + identity_session: Some(SessionProfile { + operation_tag: IDENTITY_SESSION_TAG, + authority: "api.github.com", + method: "GET", + path: "/user", + }), + handle: HandleRules { + max_length: 39, + strip_leading_at: true, + is_email: false, + allow_underscore: false, + allow_hyphen: true, + }, +}; + +pub const LAUNCH_PROFILES: [PlatformProfile; 3] = [GOOGLE_V1, X_V1, GITHUB_V1]; + +/// Governance-owned unsigned 64-bit values in seconds, read where they are +/// enforced (libid.md protocol parameters). +/// +/// The Platform Verifier reads the current value when it verifies; a browser +/// read is advisory. Lowering one may reject an outstanding proof and raising +/// one may extend an outstanding X or GitHub proof, so they are authority +/// rather than configuration (REQ-PARAM-01, REQ-PARAM-02). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ProtocolParameters { + /// Maximum age of the X token attestation. + pub proof_lifetime_x: u64, + /// Maximum age of the GitHub token-exchange attestation. + pub proof_lifetime_github: u64, + /// Maximum X or GitHub attestation lead over chain time. + pub max_future_attestation_skew: u64, +} + +/// The launch values. +pub const LAUNCH_PARAMETERS: ProtocolParameters = ProtocolParameters { + proof_lifetime_x: 3600, + proof_lifetime_github: 3600, + max_future_attestation_skew: 300, +}; + +#[cfg(test)] +mod tests { + use super::*; + use crate::attestation::tag; + + #[test] + fn attestation_counts_match_the_profiles() { + // Google verifies none and pays nothing; X and GitHub verify two each, + // so one submission on either path pays two Notary Fees. + assert_eq!(GOOGLE_V1.attestation_count(), 0); + assert!(!GOOGLE_V1.verifies_attestations()); + assert_eq!(X_V1.attestation_count(), 2); + assert_eq!(GITHUB_V1.attestation_count(), 2); + } + + #[test] + fn digest_binding_is_one_method_per_profile() { + // REQ-COMMON-02C: never both, never neither. Google carries no + // code_verifier; X and GitHub expose no digest public input. + assert_eq!(GOOGLE_V1.digest_binding, DigestBinding::PublicProofInput); + assert_eq!(X_V1.digest_binding, DigestBinding::RevealedCodeVerifier); + assert_eq!( + GITHUB_V1.digest_binding, + DigestBinding::RevealedCodeVerifier + ); + } + + #[test] + fn the_two_sessions_of_a_profile_carry_different_tags() { + // Otherwise a token attestation and an identity attestation of one + // ceremony would be interchangeable (REQ-COMMON-55). + for profile in [X_V1, GITHUB_V1] { + let token = profile.token_session.unwrap(); + let identity = profile.identity_session.unwrap(); + assert_ne!(tag(token.operation_tag), tag(identity.operation_tag)); + } + } + + #[test] + fn github_notarizes_two_different_authorities() { + // The exchange is served by github.com and the identity read by + // api.github.com, so one pinned authority per profile would be wrong. + let profile = GITHUB_V1; + assert_eq!(profile.token_session.unwrap().authority, "github.com"); + assert_eq!( + profile.identity_session.unwrap().authority, + "api.github.com" + ); + assert_ne!( + tag(profile.token_session.unwrap().authority), + tag(profile.identity_session.unwrap().authority) + ); + } + + #[test] + fn platform_ids_are_distinct() { + let ids: Vec<_> = LAUNCH_PROFILES.iter().map(|p| tag(p.name)).collect(); + assert_ne!(ids[0], ids[1]); + assert_ne!(ids[1], ids[2]); + assert_ne!(ids[0], ids[2]); + } + + #[test] + fn authorities_are_canonical() { + // Section 9: lowercase ASCII DNS name, no trailing dot, no scheme, + // no port. + for profile in LAUNCH_PROFILES { + for session in [profile.token_session, profile.identity_session] + .into_iter() + .flatten() + { + let a = session.authority; + assert_eq!(a, a.to_ascii_lowercase(), "authority not lowercase: {a}"); + assert!(!a.ends_with('.'), "trailing dot: {a}"); + assert!( + !a.contains('/') && !a.contains(':'), + "not a bare DNS name: {a}" + ); + assert!( + session.path.starts_with('/'), + "path not origin-form: {}", + session.path + ); + assert!( + !session.path.contains('?'), + "path carries a query: {}", + session.path + ); + assert_eq!(session.method, session.method.to_ascii_uppercase()); + } + } + } + + #[test] + fn handle_rules_match_the_published_parameter_table() { + // platform-ceremonies section 2.1a. + assert_eq!( + ( + GOOGLE_V1.handle.max_length, + GOOGLE_V1.handle.strip_leading_at, + GOOGLE_V1.handle.is_email + ), + (62, false, true) + ); + assert_eq!( + ( + X_V1.handle.max_length, + X_V1.handle.strip_leading_at, + X_V1.handle.allow_underscore + ), + (15, true, true) + ); + assert_eq!( + ( + GITHUB_V1.handle.max_length, + GITHUB_V1.handle.strip_leading_at, + GITHUB_V1.handle.allow_hyphen + ), + (39, true, true) + ); + } + + #[test] + fn launch_parameters_match_the_published_values() { + assert_eq!(LAUNCH_PARAMETERS.proof_lifetime_x, 3600); + assert_eq!(LAUNCH_PARAMETERS.proof_lifetime_github, 3600); + assert_eq!(LAUNCH_PARAMETERS.max_future_attestation_skew, 300); + } +} diff --git a/crates/libid-ceremony/src/lib.rs b/crates/libid-ceremony/src/lib.rs index 92aaa7b0..a42e18b2 100644 --- a/crates/libid-ceremony/src/lib.rs +++ b/crates/libid-ceremony/src/lib.rs @@ -1,25 +1,41 @@ //! Wire constructions of the libID identity ceremony. //! -//! One implementation of each construction the specification fixes, so the -//! notary, the backend and the conformance suite cannot disagree about bytes: +//! # What Rust actually needs, and what is here for the vectors //! -//! * [`authorization`] -- the Authorization Digest of ceremony-common -//! section 5, which binds one authorization to the transaction that will -//! consume it. -//! * [`pkce`] -- the derived `code_verifier` of section 7, which is how X and -//! GitHub carry that digest through an OAuth authorization. -//! * [`attestation`] -- the attested-data byte layout of section 9.1, which is -//! what the Notary Service signs and what the Platform Verifier rebuilds. +//! The notary is the only service that touches these bytes in production, and +//! it needs two things: [`attestation`], to build and sign the attested data of +//! ceremony-common section 9.1, and the three tags of [`profile`] to stamp into +//! it. [`token_exchange`] is the GitHub Token-Exchange Service's request and +//! response records, which that service will encode. +//! +//! Everything else is behind the `vectors` feature, off by default: +//! +//! * [`authorization`] -- the Authorization Digest of section 5. +//! * [`pkce`] -- the derived `code_verifier` of section 7. +//! * [`launch`] -- the launch platform profiles and protocol parameters. +//! +//! Nothing in Rust builds a digest, derives a verifier, or reads a profile +//! record. The browser builds the first two and the contracts recompute them; +//! a Platform Verifier pins the third. These modules exist so the conformance +//! suite can check those two implementations against a third, written +//! independently from the published vectors -- which makes them a test oracle. +//! An oracle compiled into every consumer is code nothing calls, and a mistake +//! in it is invisible. Hence the feature. //! //! Each module's tests pin it to the conformance vectors the specification //! publishes, taken from the specification rather than from this code. pub mod attestation; -pub mod authorization; -pub mod pkce; pub mod profile; pub mod token_exchange; +#[cfg(any(test, feature = "vectors"))] +pub mod authorization; +#[cfg(any(test, feature = "vectors"))] +pub mod launch; +#[cfg(any(test, feature = "vectors"))] +pub mod pkce; + pub use attestation::{ AttestationError, AttestedData, @@ -27,10 +43,12 @@ pub use attestation::{ RangeCommitment, RevealedRange, }; +#[cfg(any(test, feature = "vectors"))] pub use authorization::{ AuthorizationError, AuthorizationPreimage, }; +#[cfg(any(test, feature = "vectors"))] pub use pkce::{ code_challenge, code_verifier, diff --git a/crates/libid-ceremony/src/profile.rs b/crates/libid-ceremony/src/profile.rs index ce151b14..68aa26f6 100644 --- a/crates/libid-ceremony/src/profile.rs +++ b/crates/libid-ceremony/src/profile.rs @@ -1,30 +1,27 @@ -//! Ceremony profile constants, and the protocol parameters governance owns. +//! The three tags the notary stamps into attested data. //! -//! A Platform Profile fixes what a Platform Verifier pins: the tags it compares -//! an attestation against, the authority that must have answered, the method -//! and path it expects, how many attestations it verifies, and how the -//! Authorization Digest reaches it. This module is the single place those live, -//! so the notary that writes them and the verifier that compares them read one -//! definition. +//! This is the whole of what Rust needs from a Platform Profile. The notary +//! decides nothing profile-specific (REQ-COMMON-33): it names the byte layout, +//! says which session an attestation covers, and stops. Which ranges a profile +//! expects and what their bytes must contain belong to the Platform Verifier. //! -//! # The namespaced strings are ours, not the specification's +//! The launch profiles themselves -- endpoints, authorities, handle rules, +//! protocol parameters -- are in [`crate::launch`], behind a feature, because +//! nothing in Rust reads them. +//! +//! # These strings are ours, not the specification's //! //! The specification fixes exactly one literal: `libid.identity.pkce`, in -//! ceremony-common section 7. Every other libID-namespaced string -- -//! `formatTag` (REQ-COMMON-53), `operationTag` and the platform name -//! (REQ-COMMON-55), and each Consumer's operation domain (REQ-COMMON-01A) -- -//! is required to exist and required to be pinned, but its bytes are left to -//! the profile author. +//! ceremony-common section 7. `formatTag` (REQ-COMMON-53) and `operationTag` +//! (REQ-COMMON-55) are required to exist and required to be pinned, but their +//! bytes are left to the profile author. Those requirement numbers come from +//! libid PR #12, which was closed without merging, so a reader will not find +//! them on main. //! //! That makes these constants a cross-implementation agreement rather than a -//! reading of the specification. The same goes for the `REQ-COMMON-53`/`-55` -//! and `REQ-PLAT-61`..`-74` numbers cited below: they come from libid PR #12, -//! which was closed without merging, so a reader will not find them on main. -//! They are kept because the intent is to upstream these definitions under the -//! specification's own numbering. A notary emitting `libid.attestation.v1` and a +//! reading of the specification. A notary emitting `libid.attestation.v1` and a //! verifier pinning anything else derives a key nobody trusts and rejects every -//! genuine attestation, with no error that says why. They must be agreed before -//! either side ships. +//! genuine attestation, with no error that says why. /// Names this attestation byte layout and its version (REQ-COMMON-53). /// @@ -39,299 +36,3 @@ pub const FORMAT_TAG: &str = "libid.attestation.v1"; /// interchangeable. pub const TOKEN_SESSION_TAG: &str = "libid.ceremony.session.token.v1"; pub const IDENTITY_SESSION_TAG: &str = "libid.ceremony.session.identity.v1"; - -/// How a profile binds the Authorization Digest to its evidence -/// (REQ-COMMON-02C). Exactly one of the two, never both and never neither. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum DigestBinding { - /// Google: the digest is a public proof input the verifier compares. - PublicProofInput, - /// X and GitHub: the verifier recomputes the revealed `code_verifier`. - RevealedCodeVerifier, -} - -/// One notarized session of a ceremony. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct SessionProfile { - /// The `operationTag` this session's attestation must carry. - pub operation_tag: &'static str, - /// The TLS server name the notary must have authenticated, in the - /// canonical form of section 9: lowercase ASCII, no trailing dot. It - /// reaches the verifier as `authorityId`, never as a transcript range, - /// because the transcript holds it only in a prover-composed `Host` header. - pub authority: &'static str, - /// Exact uppercase HTTP method, compared against a revealed range. - pub method: &'static str, - /// Origin-form path, no query, compared against a revealed range. - pub path: &'static str, -} - -/// The five parameters of platform-ceremonies section 2.1a. -/// -/// `is_email` supersedes the two booleans below it (REQ-PLAT-67); they are -/// stated because REQ-PLAT-72 requires a profile to fix all five. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct HandleRules { - pub max_length: u16, - pub strip_leading_at: bool, - pub is_email: bool, - pub allow_underscore: bool, - pub allow_hyphen: bool, -} - -/// An immutable, independently versioned ceremony profile. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct PlatformProfile { - /// Preimage of `platformId` (REQ-COMMON-55). - pub name: &'static str, - /// Carried in the Authorization Digest and the submission. Launch profiles - /// use 1 (REQ-PLAT-01). - pub platform_verifier_version: u16, - pub digest_binding: DigestBinding, - /// The token or token-exchange session, where the profile has one. - pub token_session: Option, - /// The identity session, where the profile has one. - pub identity_session: Option, - pub handle: HandleRules, -} - -impl PlatformProfile { - /// Derived, never stated beside the session list: the profile fixes the - /// list and the count is its size (REQ-COMMON-41). - pub fn attestation_count(&self) -> u8 { - self.token_session.is_some() as u8 + self.identity_session.is_some() as u8 - } - - /// A profile verifying no attestation reaches no Notary Service and pays - /// nothing (REQ-COMMON-05D, REQ-COMMON-06E). - pub fn verifies_attestations(&self) -> bool { - self.attestation_count() > 0 - } -} - -/// `google/v1` -- authentication-only OIDC. No token exchange, no client -/// secret, no PKCE, no notarized session, and therefore no Notary Fee. -pub const GOOGLE_V1: PlatformProfile = PlatformProfile { - name: "google", - platform_verifier_version: 1, - digest_binding: DigestBinding::PublicProofInput, - token_session: None, - identity_session: None, - handle: HandleRules { - max_length: 62, - strip_leading_at: false, - is_email: true, - allow_underscore: false, - allow_hyphen: false, - }, -}; - -/// `x/v1` -- a public client with S256 PKCE and two browser-owned sessions. -pub const X_V1: PlatformProfile = PlatformProfile { - name: "x", - platform_verifier_version: 1, - digest_binding: DigestBinding::RevealedCodeVerifier, - token_session: Some(SessionProfile { - operation_tag: TOKEN_SESSION_TAG, - authority: "api.x.com", - method: "POST", - path: "/2/oauth2/token", - }), - identity_session: Some(SessionProfile { - operation_tag: IDENTITY_SESSION_TAG, - authority: "api.x.com", - method: "GET", - path: "/2/users/me", - }), - handle: HandleRules { - max_length: 15, - strip_leading_at: true, - is_email: false, - allow_underscore: true, - allow_hyphen: false, - }, -}; - -/// `github/v1` -- a confidential client, so the exchange runs in the -/// deployment's Token-Exchange Service and that service is the notarized party -/// for the token session. Note the two authorities differ. -pub const GITHUB_V1: PlatformProfile = PlatformProfile { - name: "github", - platform_verifier_version: 1, - digest_binding: DigestBinding::RevealedCodeVerifier, - token_session: Some(SessionProfile { - operation_tag: TOKEN_SESSION_TAG, - authority: "github.com", - method: "POST", - path: "/login/oauth/access_token", - }), - identity_session: Some(SessionProfile { - operation_tag: IDENTITY_SESSION_TAG, - authority: "api.github.com", - method: "GET", - path: "/user", - }), - handle: HandleRules { - max_length: 39, - strip_leading_at: true, - is_email: false, - allow_underscore: false, - allow_hyphen: true, - }, -}; - -pub const LAUNCH_PROFILES: [PlatformProfile; 3] = [GOOGLE_V1, X_V1, GITHUB_V1]; - -/// Governance-owned unsigned 64-bit values in seconds, read where they are -/// enforced (libid.md protocol parameters). -/// -/// The Platform Verifier reads the current value when it verifies; a browser -/// read is advisory. Lowering one may reject an outstanding proof and raising -/// one may extend an outstanding X or GitHub proof, so they are authority -/// rather than configuration (REQ-PARAM-01, REQ-PARAM-02). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct ProtocolParameters { - /// Maximum age of the X token attestation. - pub proof_lifetime_x: u64, - /// Maximum age of the GitHub token-exchange attestation. - pub proof_lifetime_github: u64, - /// Maximum X or GitHub attestation lead over chain time. - pub max_future_attestation_skew: u64, -} - -/// The launch values. -pub const LAUNCH_PARAMETERS: ProtocolParameters = ProtocolParameters { - proof_lifetime_x: 3600, - proof_lifetime_github: 3600, - max_future_attestation_skew: 300, -}; - -#[cfg(test)] -mod tests { - use super::*; - use crate::attestation::tag; - - #[test] - fn attestation_counts_match_the_profiles() { - // Google verifies none and pays nothing; X and GitHub verify two each, - // so one submission on either path pays two Notary Fees. - assert_eq!(GOOGLE_V1.attestation_count(), 0); - assert!(!GOOGLE_V1.verifies_attestations()); - assert_eq!(X_V1.attestation_count(), 2); - assert_eq!(GITHUB_V1.attestation_count(), 2); - } - - #[test] - fn digest_binding_is_one_method_per_profile() { - // REQ-COMMON-02C: never both, never neither. Google carries no - // code_verifier; X and GitHub expose no digest public input. - assert_eq!(GOOGLE_V1.digest_binding, DigestBinding::PublicProofInput); - assert_eq!(X_V1.digest_binding, DigestBinding::RevealedCodeVerifier); - assert_eq!( - GITHUB_V1.digest_binding, - DigestBinding::RevealedCodeVerifier - ); - } - - #[test] - fn the_two_sessions_of_a_profile_carry_different_tags() { - // Otherwise a token attestation and an identity attestation of one - // ceremony would be interchangeable (REQ-COMMON-55). - for profile in [X_V1, GITHUB_V1] { - let token = profile.token_session.unwrap(); - let identity = profile.identity_session.unwrap(); - assert_ne!(tag(token.operation_tag), tag(identity.operation_tag)); - } - } - - #[test] - fn github_notarizes_two_different_authorities() { - // The exchange is served by github.com and the identity read by - // api.github.com, so one pinned authority per profile would be wrong. - let profile = GITHUB_V1; - assert_eq!(profile.token_session.unwrap().authority, "github.com"); - assert_eq!( - profile.identity_session.unwrap().authority, - "api.github.com" - ); - assert_ne!( - tag(profile.token_session.unwrap().authority), - tag(profile.identity_session.unwrap().authority) - ); - } - - #[test] - fn platform_ids_are_distinct() { - let ids: Vec<_> = LAUNCH_PROFILES.iter().map(|p| tag(p.name)).collect(); - assert_ne!(ids[0], ids[1]); - assert_ne!(ids[1], ids[2]); - assert_ne!(ids[0], ids[2]); - } - - #[test] - fn authorities_are_canonical() { - // Section 9: lowercase ASCII DNS name, no trailing dot, no scheme, - // no port. - for profile in LAUNCH_PROFILES { - for session in [profile.token_session, profile.identity_session] - .into_iter() - .flatten() - { - let a = session.authority; - assert_eq!(a, a.to_ascii_lowercase(), "authority not lowercase: {a}"); - assert!(!a.ends_with('.'), "trailing dot: {a}"); - assert!( - !a.contains('/') && !a.contains(':'), - "not a bare DNS name: {a}" - ); - assert!( - session.path.starts_with('/'), - "path not origin-form: {}", - session.path - ); - assert!( - !session.path.contains('?'), - "path carries a query: {}", - session.path - ); - assert_eq!(session.method, session.method.to_ascii_uppercase()); - } - } - } - - #[test] - fn handle_rules_match_the_published_parameter_table() { - // platform-ceremonies section 2.1a. - assert_eq!( - ( - GOOGLE_V1.handle.max_length, - GOOGLE_V1.handle.strip_leading_at, - GOOGLE_V1.handle.is_email - ), - (62, false, true) - ); - assert_eq!( - ( - X_V1.handle.max_length, - X_V1.handle.strip_leading_at, - X_V1.handle.allow_underscore - ), - (15, true, true) - ); - assert_eq!( - ( - GITHUB_V1.handle.max_length, - GITHUB_V1.handle.strip_leading_at, - GITHUB_V1.handle.allow_hyphen - ), - (39, true, true) - ); - } - - #[test] - fn launch_parameters_match_the_published_values() { - assert_eq!(LAUNCH_PARAMETERS.proof_lifetime_x, 3600); - assert_eq!(LAUNCH_PARAMETERS.proof_lifetime_github, 3600); - assert_eq!(LAUNCH_PARAMETERS.max_future_attestation_skew, 300); - } -} From e710f06846fbfffd790c4a6b96e9ac7079bf4064 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Mon, 24 Aug 2026 15:23:36 +0300 Subject: [PATCH 10/65] refactor!: the notary records, it does not judge Everything a contract checks comes out of this crate. What is left is one direction of one thing: the section 9.1 types and the encoder that lays them out. Removed, with the reason each had no business here: validate / check_span the notary judging its own library's output. Its only power was to refuse to sign a session it really did observe. require_exact_coverage tiling is the Platform Verifier's rule require_bearer_header_request the uniqueness scan and framing are too normalize_header_bytes, count_needle, revealed_slice parts of that scan decode, Reader whoever decodes also checks, and that is the chain and the client authorization, pkce, launch the client BUILDS the digest and the verifier, and a Platform Verifier pins the profile. Nothing in Rust ever read them. `encode` no longer calls `validate`. A malformed record is the prover's problem and the verifier's decision; withholding a signature is neither. Where a check IS wanted before spending gas it belongs in the client as a dry run, and it already lives there: `@libid/contracts` exports `decodeAttestedData`, `validate`, `requireExactCoverage` and `requireBearerHeaderRequest` in TypeScript, which is what REQ-PLAT-44 has the Canonical Runtime call before it spends a second session on an attestation. Two implementations of those checks, one of them unreachable from any caller, is worse than one. `token_exchange` stays whole. Its validation is the Token-Exchange Service's own input handling under REQ-PLAT-37 to -40; no contract sees that request. 2024 lines to 622. `base64` and `sha2` drop out of the dependency list with PKCE. The tests that went were testing the code that went; 85 remain, clippy and fmt clean. The one assertion worth keeping moved into `libid-tlsn`'s tests as a local helper: what the notary emits must tile, or no genuine session ever verifies. That is an assertion about our layouts, not a rule we enforce on anyone. Signed-off-by: SupremaLex --- Cargo.lock | 2 - crates/libid-ceremony/Cargo.toml | 11 +- crates/libid-ceremony/src/attestation.rs | 765 +-------------------- crates/libid-ceremony/src/authorization.rs | 171 ----- crates/libid-ceremony/src/launch.rs | 317 --------- crates/libid-ceremony/src/lib.rs | 63 +- crates/libid-ceremony/src/pkce.rs | 146 ---- crates/libid-tlsn/src/attest.rs | 65 +- 8 files changed, 72 insertions(+), 1468 deletions(-) delete mode 100644 crates/libid-ceremony/src/authorization.rs delete mode 100644 crates/libid-ceremony/src/launch.rs delete mode 100644 crates/libid-ceremony/src/pkce.rs diff --git a/Cargo.lock b/Cargo.lock index a392bb5c..b825cdc8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3366,10 +3366,8 @@ dependencies = [ name = "libid-ceremony" version = "0.2.0" dependencies = [ - "base64 0.22.1", "hex", "libid-crypto", - "sha2 0.10.9", "thiserror 2.0.20", ] diff --git a/crates/libid-ceremony/Cargo.toml b/crates/libid-ceremony/Cargo.toml index 143a87c0..9cb7b61c 100644 --- a/crates/libid-ceremony/Cargo.toml +++ b/crates/libid-ceremony/Cargo.toml @@ -1,23 +1,14 @@ [package] name = "libid-ceremony" -description = "Wire constructions of the libID identity ceremony: authorization digest, PKCE binding, and attestation format." +description = "The attestation record a libID notary signs: the section 9.1 types and their encoder." version.workspace = true edition.workspace = true rust-version.workspace = true license.workspace = true repository.workspace = true -[features] -# The digest, PKCE and launch-profile records: a third reading of the -# specification's constants, for the conformance suite to check the Solidity and -# TypeScript ones against. No service enables this -- nothing in Rust builds a -# digest, derives a verifier, or reads a profile record. -vectors = [] - [dependencies] -base64.workspace = true libid-crypto.workspace = true -sha2.workspace = true thiserror.workspace = true [dev-dependencies] diff --git a/crates/libid-ceremony/src/attestation.rs b/crates/libid-ceremony/src/attestation.rs index 52424080..5089b896 100644 --- a/crates/libid-ceremony/src/attestation.rs +++ b/crates/libid-ceremony/src/attestation.rs @@ -164,67 +164,6 @@ pub fn tag(namespaced: &str) -> [u8; 32] { } impl DirectionBlock { - fn validate( - &self, - direction: &'static str, - length: u32, - ) -> Result<(), AttestationError> { - if self.revealed.len() > u16::MAX as usize { - return Err(AttestationError::CountTooLarge(self.revealed.len())); - } - if self.commitments.len() > u16::MAX as usize { - return Err(AttestationError::CountTooLarge(self.commitments.len())); - } - - let mut previous_end = 0u32; - for (index, range) in self.revealed.iter().enumerate() { - check_span( - direction, - "revealed range", - index, - range.start, - range.end, - length, - &mut previous_end, - )?; - let want = (range.end - range.start) as usize; - if range.bytes.len() != want { - return Err(AttestationError::RangeLengthMismatch { - direction, - index, - start: range.start, - end: range.end, - carried: range.bytes.len(), - }); - } - } - - let mut previous_end = 0u32; - for (index, commitment) in self.commitments.iter().enumerate() { - check_span( - direction, - "commitment", - index, - commitment.start, - commitment.end, - length, - &mut previous_end, - )?; - // A committed range that overlaps a revealed one would let the - // same bytes be both read and hidden. - for revealed in &self.revealed { - if commitment.start < revealed.end && revealed.start < commitment.end { - return Err(AttestationError::CommitmentOverlapsRevealed { - direction, - start: commitment.start, - end: commitment.end, - }); - } - } - } - Ok(()) - } - fn encode_into(&self, out: &mut Vec) { out.extend_from_slice(&(self.revealed.len() as u16).to_be_bytes()); for range in &self.revealed { @@ -241,255 +180,12 @@ impl DirectionBlock { } } -fn check_span( - direction: &'static str, - kind: &'static str, - index: usize, - start: u32, - end: u32, - length: u32, - previous_end: &mut u32, -) -> Result<(), AttestationError> { - if end <= start { - return Err(AttestationError::EmptyRange { - direction, - kind, - index, - start, - }); - } - if start < *previous_end { - return Err(AttestationError::OutOfOrder { - direction, - kind, - index, - start, - previous_end: *previous_end, - }); - } - if end > length { - return Err(AttestationError::PastTranscriptEnd { - direction, - kind, - index, - end, - length, - }); - } - *previous_end = end; - Ok(()) -} - -/// A forward cursor that never panics on a short buffer. -struct Reader<'a> { - bytes: &'a [u8], - at: usize, -} - -impl<'a> Reader<'a> { - fn take( - &mut self, - n: usize, - field: &'static str, - ) -> Result<&'a [u8], AttestationError> { - let end = self - .at - .checked_add(n) - .ok_or(AttestationError::Truncated { field })?; - let slice = self - .bytes - .get(self.at..end) - .ok_or(AttestationError::Truncated { field })?; - self.at = end; - Ok(slice) - } - - fn take32(&mut self, field: &'static str) -> Result<[u8; 32], AttestationError> { - Ok(self.take(32, field)?.try_into().expect("checked length")) - } - - fn u16(&mut self, field: &'static str) -> Result { - Ok(u16::from_be_bytes( - self.take(2, field)?.try_into().expect("checked length"), - )) - } - - fn u32(&mut self, field: &'static str) -> Result { - Ok(u32::from_be_bytes( - self.take(4, field)?.try_into().expect("checked length"), - )) - } - - fn u64(&mut self, field: &'static str) -> Result { - Ok(u64::from_be_bytes( - self.take(8, field)?.try_into().expect("checked length"), - )) - } - - fn direction(&mut self) -> Result { - let revealed_count = self.u16("revealed range count")? as usize; - let mut revealed = Vec::with_capacity(revealed_count); - for _ in 0..revealed_count { - let start = self.u32("revealed range start")?; - let end = self.u32("revealed range end")?; - // A start past its end would wrap; the validate pass rejects the - // shape, so read nothing here rather than compute a huge length. - let len = end.saturating_sub(start) as usize; - revealed.push(RevealedRange { - start, - end, - bytes: self.take(len, "revealed range bytes")?.to_vec(), - }); - } - - let commitment_count = self.u16("commitment count")? as usize; - let mut commitments = Vec::with_capacity(commitment_count); - for _ in 0..commitment_count { - commitments.push(RangeCommitment { - start: self.u32("commitment start")?, - end: self.u32("commitment end")?, - commitment: self.take32("commitment value")?, - }); - } - - Ok(DirectionBlock { - revealed, - commitments, - }) - } -} - -impl DirectionBlock { - /// Require the revealed ranges and commitments to tile `[0, length)` - /// exactly, with no gap and no overlap (REQ-COMMON-35). - /// - /// Only for a direction whose profile demands exact coverage. The rule is - /// conditional: REQ-COMMON-43 withholds it from a credential committed in - /// a request body, which is GitHub's `client_secret`, so [`validate`] does - /// not apply it and a caller asks for it where the profile does. - /// - /// A gap is where a prover hides bytes. Exact coverage leaves the committed - /// range as the only region the verifier cannot read and makes its offset - /// and length follow from the ranges around it. - /// - /// [`validate`]: AttestedData::validate - pub fn require_exact_coverage( - &self, - direction: &'static str, - length: u32, - ) -> Result<(), AttestationError> { - let mut spans: Vec<(u32, u32)> = - Vec::with_capacity(self.revealed.len() + self.commitments.len()); - spans.extend(self.revealed.iter().map(|r| (r.start, r.end))); - spans.extend(self.commitments.iter().map(|c| (c.start, c.end))); - spans.sort_unstable(); - - let mut at = 0u32; - for (start, end) in spans { - // Overlap is `validate`'s job, but a caller may hand-build a block - // and skip it. Naming it here beats reporting a backwards gap. - if start < at { - return Err(AttestationError::SpansOverlap { - direction, - at: start, - }); - } - if start != at { - return Err(AttestationError::CoverageGap { - direction, - from: at, - to: start, - }); - } - at = end; - } - if at != length { - return Err(AttestationError::CoverageGap { - direction, - from: at, - to: length, - }); - } - Ok(()) - } -} - -/// `\r\nauthorization: Bearer ` -- the raw bytes REQ-COMMON-40 requires -/// immediately before the committed range. -pub const BEARER_PREFIX: &[u8] = b"\r\nauthorization: Bearer "; -/// The raw bytes REQ-COMMON-40 requires immediately after it. -pub const BEARER_SUFFIX: &[u8] = b"\r\n"; -/// The normalized, line-anchored needle REQ-COMMON-39 counts. -pub const AUTHORIZATION_NEEDLE: &[u8] = b"\r\nauthorization:bearer"; - -/// Lowercase ASCII and drop every space and horizontal tab, keeping CR and LF -/// (REQ-COMMON-39). -/// -/// HTTP field names and the auth-scheme token are case-insensitive and the -/// colon admits optional whitespace, so a literal search over raw bytes is -/// evadable. Removing only bytes absent from the needle can create a spurious -/// match, which over-rejects and is safe, but can never hide a real one. -/// Keeping CR and LF is what makes the needle count header lines rather than -/// any substring. -fn normalize_header_bytes(raw: &[u8]) -> Vec { - raw.iter() - .filter(|b| **b != b' ' && **b != b'\t') - .map(|b| b.to_ascii_lowercase()) - .collect() -} - -/// Read `[from, to)` of the transcript out of the revealed ranges, or `None` -/// if any byte of it is not revealed. -fn revealed_slice(block: &DirectionBlock, from: usize, to: usize) -> Option> { - let mut out = Vec::with_capacity(to.checked_sub(from)?); - let mut at = from; - while at < to { - let range = block - .revealed - .iter() - .find(|r| (r.start as usize) <= at && at < (r.end as usize))?; - let offset = at - range.start as usize; - let take = (range.bytes.len() - offset).min(to - at); - out.extend_from_slice(&range.bytes[offset..offset + take]); - at += take; - } - Some(out) -} - -fn count_needle(haystack: &[u8]) -> usize { - if haystack.len() < AUTHORIZATION_NEEDLE.len() { - return 0; - } - haystack - .windows(AUTHORIZATION_NEEDLE.len()) - .filter(|w| *w == AUTHORIZATION_NEEDLE) - .count() -} - -impl DirectionBlock { - /// Concatenate the revealed bytes in offset order. - fn revealed_bytes(&self) -> Vec { - let mut out = Vec::new(); - for range in &self.revealed { - out.extend_from_slice(&range.bytes); - } - out - } -} - impl AttestedData { - /// Reject a shape the Platform Verifier must refuse: ranges out of order, - /// overlapping, empty, or ending past the signed transcript length - /// (REQ-COMMON-59, REQ-COMMON-60). - pub fn validate(&self) -> Result<(), AttestationError> { - self.sent.validate("sent", self.sent_transcript_length)?; - self.received - .validate("received", self.recv_transcript_length) - } - - /// Serialize exactly the byte concatenation of section 9.1. + /// 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. pub fn encode(&self) -> Result, AttestationError> { - self.validate()?; let mut out = Vec::with_capacity(HEADER_LEN); out.extend_from_slice(&self.format_tag); out.extend_from_slice(&self.platform_id); @@ -505,115 +201,6 @@ impl AttestedData { /// Parse and validate. Trailing bytes are refused: the layout accounts for /// every byte, so a suffix is a second message hiding behind the first. - pub fn decode(bytes: &[u8]) -> Result { - let mut reader = Reader { bytes, at: 0 }; - let decoded = AttestedData { - format_tag: reader.take32("formatTag")?, - platform_id: reader.take32("platformId")?, - operation_tag: reader.take32("operationTag")?, - authority_id: reader.take32("authorityId")?, - created_at: reader.u64("createdAt")?, - sent_transcript_length: reader.u32("sentTranscriptLength")?, - recv_transcript_length: reader.u32("recvTranscriptLength")?, - sent: reader.direction()?, - received: reader.direction()?, - }; - if reader.at != bytes.len() { - return Err(AttestationError::TrailingBytes(bytes.len() - reader.at)); - } - decoded.validate()?; - Ok(decoded) - } - - /// Every check REQ-COMMON-35, -39 and -40 require of an identity-session - /// request that commits a credential in an HTTP `Authorization` header. - /// - /// At launch that is X's `/2/users/me` request and GitHub's `/user` - /// request, and nothing else. A credential committed in a request body is - /// a different case with its own rules, and REQ-COMMON-43 forbids applying - /// these three to it -- GitHub's token exchange commits `client_secret` in - /// a form body, so demanding a CRLF-framed header around that range would - /// reject every valid exchange. - /// - /// The three are one call because they are one property, and because two - /// of them are worthless alone. The uniqueness scan counts the needle - /// across REVEALED bytes only, so a byte covered by nothing is a byte it - /// never reads: without coverage a prover hides a second authorization - /// header in a gap, the count stays at one, and the platform honours - /// whichever header it likes. Coverage is what makes the scan complete. - /// - /// Returns the committed bearer range, which the Platform Verifier then - /// matches against the circuit's identity-bearer public input. - pub fn require_bearer_header_request( - &self, - direction: &'static str, - block: &DirectionBlock, - length: u32, - ) -> Result { - // One committed range, so "the committed range" of REQ-COMMON-35 and - // REQ-COMMON-40 and the commitment the circuit opens are the same - // object. REQ-COMMON-60 permits several per direction, and nothing - // else here would tie the framed range to the proved one. - if block.commitments.len() != 1 { - return Err(AttestationError::NotOneCommitment { - direction, - count: block.commitments.len(), - }); - } - let commitment = block.commitments[0].clone(); - - block.require_exact_coverage(direction, length)?; - - let revealed = block.revealed_bytes(); - - // Obsolete line folding is illegal in HTTP/1.1, and it defeats the - // needle: `authorization:\r\n Bearer x` normalizes to - // `authorization:\r\nbearer`, because normalization strips the space - // but keeps the CRLF that the fold introduced. The header is then not - // counted, and a server that honours the fold authenticates with it. - for i in 0..revealed.len().saturating_sub(2) { - if revealed[i] == b'\r' - && revealed[i + 1] == b'\n' - && (revealed[i + 2] == b' ' || revealed[i + 2] == b'\t') - { - return Err(AttestationError::ObsoleteLineFold { direction, at: i }); - } - } - - // Count over each region separately rather than over a concatenation, - // so joining the two cannot manufacture a match at the seam. - let mut count = 0; - for range in &block.revealed { - count += count_needle(&normalize_header_bytes(&range.bytes)); - } - if count != 1 { - return Err(AttestationError::NotOneAuthorizationHeader { direction, count }); - } - - // Framing, on RAW bytes at known offsets. Two fixed comparisons make - // the committed range one header line's value by construction, so the - // credential cannot be hidden inside another header's value. - let before_end = commitment.start as usize; - let prefix_start = before_end.checked_sub(BEARER_PREFIX.len()); - let ok_prefix = match prefix_start { - Some(from) => { - revealed_slice(block, from, before_end) == Some(BEARER_PREFIX.to_vec()) - } - None => false, - }; - let after_start = commitment.end as usize; - let ok_suffix = - revealed_slice(block, after_start, after_start + BEARER_SUFFIX.len()) - == Some(BEARER_SUFFIX.to_vec()); - if !ok_prefix || !ok_suffix { - return Err(AttestationError::BadBearerFraming { direction }); - } - - Ok(commitment) - } - - /// `keccak256(attestedData)` -- the only preimage the notary signs - /// (REQ-COMMON-47). pub fn digest(&self) -> Result<[u8; 32], AttestationError> { Ok(keccak256(&self.encode()?)) } @@ -692,53 +279,6 @@ e7b961087ec316778e6885d11145cc06f1d75360430f461d0322fb7f105899dd\ ); } - #[test] - fn coverage_accepts_an_exact_tiling() { - let data = sample(); - data.sent - .require_exact_coverage("sent", data.sent_transcript_length) - .expect("the sample tiles 0..20, 20..40, 40..60"); - } - - #[test] - fn coverage_rejects_a_gap() { - // `validate` accepts this on purpose: coverage is conditional under - // REQ-COMMON-43. The identity-session verifier is what must refuse it. - let mut data = sample(); - data.sent.commitments[0].start = 21; - data.validate().expect("shape alone still passes"); - assert_eq!( - data.sent - .require_exact_coverage("sent", data.sent_transcript_length), - Err(AttestationError::CoverageGap { - direction: "sent", - from: 20, - to: 21 - }) - ); - } - - #[test] - fn coverage_rejects_a_trailing_gap() { - // Bytes past the last range are invisible without the signed length to - // close them (REQ-COMMON-36). - let data = sample(); - assert_eq!( - data.sent.require_exact_coverage("sent", 80), - Err(AttestationError::CoverageGap { - direction: "sent", - from: 60, - to: 80 - }) - ); - } - - #[test] - fn round_trips() { - let data = sample(); - assert_eq!(AttestedData::decode(&data.encode().unwrap()).unwrap(), data); - } - #[test] fn header_is_one_hundred_and_forty_four_bytes() { let mut data = sample(); @@ -776,109 +316,6 @@ e7b961087ec316778e6885d11145cc06f1d75360430f461d0322fb7f105899dd\ assert_ne!(token.digest().unwrap(), sample().digest().unwrap()); } - #[test] - fn rejects_out_of_order_revealed_ranges() { - let mut data = sample(); - data.sent.revealed.swap(0, 1); - assert!(matches!( - data.encode(), - Err(AttestationError::OutOfOrder { - direction: "sent", - .. - }) - )); - } - - #[test] - fn rejects_overlapping_revealed_ranges() { - let mut data = sample(); - data.sent.revealed[1].start = 10; - data.sent.revealed[1].bytes = vec![b'b'; 50]; - assert!(matches!( - data.encode(), - Err(AttestationError::OutOfOrder { .. }) - )); - } - - #[test] - fn rejects_an_empty_range() { - let mut data = sample(); - data.sent.revealed[0].end = 0; - data.sent.revealed[0].bytes.clear(); - assert!(matches!( - data.encode(), - Err(AttestationError::EmptyRange { .. }) - )); - } - - #[test] - fn rejects_a_range_past_the_signed_transcript_length() { - // The signed length is what makes bytes past the last revealed range - // visible at all (REQ-COMMON-36). - let mut data = sample(); - data.sent_transcript_length = 50; - assert!(matches!( - data.encode(), - Err(AttestationError::PastTranscriptEnd { - end: 60, - length: 50, - .. - }) - )); - } - - #[test] - fn rejects_a_commitment_overlapping_a_revealed_range() { - let mut data = sample(); - data.sent.commitments[0].start = 10; - assert!(matches!( - data.encode(), - Err(AttestationError::CommitmentOverlapsRevealed { .. }) - )); - } - - #[test] - fn rejects_a_range_whose_bytes_disagree_with_its_offsets() { - let mut data = sample(); - data.sent.revealed[0].bytes.push(b'a'); - assert!(matches!( - data.encode(), - Err(AttestationError::RangeLengthMismatch { carried: 21, .. }) - )); - } - - #[test] - fn rejects_trailing_bytes() { - let mut encoded = sample().encode().unwrap(); - encoded.push(0); - assert_eq!( - AttestedData::decode(&encoded), - Err(AttestationError::TrailingBytes(1)) - ); - } - - #[test] - fn rejects_truncation_at_every_length() { - // Never panics, whatever a caller hands it. - let encoded = sample().encode().unwrap(); - for cut in 0..encoded.len() { - assert!( - AttestedData::decode(&encoded[..cut]).is_err(), - "accepted a {cut}-byte prefix" - ); - } - } - - #[test] - fn rejects_a_count_that_outruns_the_buffer() { - // A declared count of 0xffff with no entries behind it must not - // allocate its way to a panic. - let mut encoded = sample().encode().unwrap(); - encoded[HEADER_LEN] = 0xff; - encoded[HEADER_LEN + 1] = 0xff; - assert!(AttestedData::decode(&encoded).is_err()); - } - #[test] fn a_shifted_boundary_cannot_produce_one_preimage() { // Moving a byte from a revealed range into the next one changes the @@ -889,198 +326,4 @@ e7b961087ec316778e6885d11145cc06f1d75360430f461d0322fb7f105899dd\ moved.sent.commitments[0].start = 19; assert_ne!(moved.digest().unwrap(), sample().digest().unwrap()); } - - // --- The identity-session request checks ------------------------------- - - const BEARER: &[u8] = b"AAAAbbbbCCCCdddd"; - - /// A real `/2/users/me` request: four headers, the bearer committed, every - /// other byte revealed, tiled exactly. - fn identity_request( - extra_header: &str, - bearer_prefix: &str, - ) -> (DirectionBlock, u32) { - let head = format!( - "GET /2/users/me HTTP/1.1\r\naccept: application/json\r\nhost: api.x.com\r\n{extra_header}{bearer_prefix}" - ); - let tail = "\r\nconnection: close\r\n\r\n"; - let start = head.len() as u32; - let end = start + BEARER.len() as u32; - let length = end + tail.len() as u32; - ( - DirectionBlock { - revealed: vec![ - RevealedRange { - start: 0, - end: start, - bytes: head.into_bytes(), - }, - RevealedRange { - start: end, - end: length, - bytes: tail.as_bytes().to_vec(), - }, - ], - commitments: vec![RangeCommitment { - start, - end, - commitment: [5u8; 32], - }], - }, - length, - ) - } - - fn honest() -> (DirectionBlock, u32) { - identity_request("", "\r\nauthorization: Bearer ") - } - - fn check( - block: &DirectionBlock, - length: u32, - ) -> Result { - let mut data = sample(); - data.sent = block.clone(); - data.sent_transcript_length = length; - data.require_bearer_header_request("sent", block, length) - } - - #[test] - fn accepts_an_honest_identity_request() { - let (block, length) = honest(); - let commitment = check(&block, length).expect("an honest request must verify"); - assert_eq!(commitment.commitment, [5u8; 32]); - } - - #[test] - fn rejects_a_second_authorization_header() { - // The plain form: a planted header in revealed bytes. Coverage forces - // it to be revealed, and the scan counts two. - let (block, length) = identity_request( - "authorization: Bearer stolen\r\n", - "\r\nauthorization: Bearer ", - ); - assert!(matches!( - check(&block, length), - Err(AttestationError::NotOneAuthorizationHeader { count: 2, .. }) - )); - } - - #[test] - fn rejects_a_case_and_whitespace_evaded_second_header() { - // Field names and the scheme token are case-insensitive and the colon - // admits whitespace, so a literal search would miss this one. - let (block, length) = identity_request( - "AuThOrIzAtIoN:\tBeArEr stolen\r\n", - "\r\nauthorization: Bearer ", - ); - assert!(matches!( - check(&block, length), - Err(AttestationError::NotOneAuthorizationHeader { count: 2, .. }) - )); - } - - #[test] - fn rejects_an_obsolete_line_fold() { - // The gap a security review found: `authorization:\r\n Bearer x` - // normalizes to `authorization:\r\nbearer`, so the needle does not - // match and the header is never counted. A server honouring the fold - // would authenticate with it. - let (block, length) = identity_request( - "authorization:\r\n Bearer stolen\r\n", - "\r\nauthorization: Bearer ", - ); - assert!(matches!( - check(&block, length), - Err(AttestationError::ObsoleteLineFold { .. }) - )); - } - - #[test] - fn rejects_a_request_with_no_authorization_header_at_all() { - let (block, length) = identity_request("", "\r\nx-other: "); - assert!(matches!( - check(&block, length), - Err(AttestationError::NotOneAuthorizationHeader { count: 0, .. }) - )); - } - - #[test] - fn rejects_a_gap_the_scan_would_never_read() { - // This is why the three are one call. Open a gap and the hidden bytes - // are not scanned at all. - let (mut block, length) = honest(); - block.revealed[0].end -= 1; - block.revealed[0].bytes.pop(); - assert!(matches!( - check(&block, length), - Err(AttestationError::CoverageGap { .. }) - )); - } - - #[test] - fn rejects_a_commitment_that_is_not_the_header_value() { - // Framing on its own. The authorization header here is whole and - // revealed, so the needle counts once and coverage is exact -- but the - // committed range sits in the `host` header instead. Without - // REQ-COMMON-40 nothing would notice that the proved commitment and - // the credential are different objects. - let head = "GET /2/users/me HTTP/1.1\r\naccept: application/json\r\n\ -authorization: Bearer TOKEN123\r\nhost: "; - let committed = "api."; - let tail = "x.com\r\nconnection: close\r\n\r\n"; - let start = head.len() as u32; - let end = start + committed.len() as u32; - let length = end + tail.len() as u32; - let block = DirectionBlock { - revealed: vec![ - RevealedRange { - start: 0, - end: start, - bytes: head.as_bytes().to_vec(), - }, - RevealedRange { - start: end, - end: length, - bytes: tail.as_bytes().to_vec(), - }, - ], - commitments: vec![RangeCommitment { - start, - end, - commitment: [5u8; 32], - }], - }; - assert!(matches!( - check(&block, length), - Err(AttestationError::BadBearerFraming { .. }) - )); - } - - #[test] - fn rejects_more_than_one_commitment() { - // REQ-COMMON-60 permits several per direction, but then nothing ties - // the framed range to the one the circuit opens. - let (mut block, length) = honest(); - let extra = RangeCommitment { - start: 0, - end: 1, - commitment: [6u8; 32], - }; - block.commitments.insert(0, extra); - assert!(matches!( - check(&block, length), - Err(AttestationError::NotOneCommitment { count: 2, .. }) - )); - } - - #[test] - fn coverage_names_an_overlap_rather_than_a_backwards_gap() { - let mut block = honest().0; - block.revealed[1].start -= 2; - assert!(matches!( - block.require_exact_coverage("sent", 0), - Err(AttestationError::SpansOverlap { .. }) - )); - } } diff --git a/crates/libid-ceremony/src/authorization.rs b/crates/libid-ceremony/src/authorization.rs deleted file mode 100644 index ee5dc5ee..00000000 --- a/crates/libid-ceremony/src/authorization.rs +++ /dev/null @@ -1,171 +0,0 @@ -//! The Authorization Digest of ceremony-common section 5. -//! -//! One value binds one authorization to the transaction that will consume it. -//! The Canonical Runtime builds it before the ceremony starts; the Proof -//! Verifier rebuilds it from the submission and its own observed chain id, and -//! the two must agree or nothing verifies. - -use libid_crypto::keccak256; - -/// Fixed-width part of the preimage: 32 + 2 + 32 + 32 + 4. -pub const PREIMAGE_FIXED_LEN: usize = 102; - -/// The Authorized Transaction Data length is carried in four bytes, so this is -/// the longest payload the layout can describe. -pub const MAX_TRANSACTION_DATA_LEN: usize = u32::MAX as usize; - -/// Everything the digest commits to. -/// -/// `chain_id` is the keccak256 of whatever bytes a chain's own identifier -/// contributes, never the raw identifier: chains name themselves -/// incompatibly, and some too wide for 64 bits (REQ-COMMON-01C). -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct AuthorizationPreimage { - pub operation_domain: [u8; 32], - pub platform_verifier_version: u16, - pub chain_id: [u8; 32], - pub authorization_nonce: [u8; 32], - pub transaction_data: Vec, -} - -#[derive(Debug, thiserror::Error, PartialEq, Eq)] -pub enum AuthorizationError { - #[error( - "transaction data is {0} bytes, which does not fit the four-byte length field" - )] - TransactionDataTooLong(usize), -} - -/// Derive an operation domain from its string. -/// -/// The Consumer fixes one libID-namespaced ASCII string per transaction kind. -/// A new operation, or a change to one operation's transaction-data meaning, -/// takes a new string rather than another digest field (REQ-COMMON-01A). -pub fn operation_domain(domain_string: &str) -> [u8; 32] { - keccak256(domain_string.as_bytes()) -} - -/// Derive a chain id from the exact bytes its Chain Profile fixes. -pub fn chain_id(identifier_bytes: &[u8]) -> [u8; 32] { - keccak256(identifier_bytes) -} - -impl AuthorizationPreimage { - /// Serialize exactly the byte concatenation of section 5. - /// - /// Only the transaction data varies in length, so every other field sits - /// at a fixed offset and no boundary can be shifted to reinterpret the - /// preimage as a different authorization (REQ-COMMON-01). - pub fn encode(&self) -> Result, AuthorizationError> { - let len = self.transaction_data.len(); - if len > MAX_TRANSACTION_DATA_LEN { - return Err(AuthorizationError::TransactionDataTooLong(len)); - } - - let mut out = Vec::with_capacity(PREIMAGE_FIXED_LEN + len); - out.extend_from_slice(&self.operation_domain); - out.extend_from_slice(&self.platform_verifier_version.to_be_bytes()); - out.extend_from_slice(&self.chain_id); - out.extend_from_slice(&self.authorization_nonce); - out.extend_from_slice(&(len as u32).to_be_bytes()); - out.extend_from_slice(&self.transaction_data); - Ok(out) - } - - /// `keccak256` of the encoded preimage. - pub fn digest(&self) -> Result<[u8; 32], AuthorizationError> { - Ok(keccak256(&self.encode()?)) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - /// The conformance vector of ceremony-common section 5, transcribed from - /// the specification and reproduced independently with `cast keccak`. - fn spec_vector() -> AuthorizationPreimage { - AuthorizationPreimage { - operation_domain: operation_domain("libid.claim-identity"), - platform_verifier_version: 1, - chain_id: chain_id(b"example:1"), - authorization_nonce: [0x55; 32], - transaction_data: vec![0x00, 0x01, 0x02, 0x03], - } - } - - #[test] - fn derived_constants_match_the_specification() { - assert_eq!( - hex::encode(operation_domain("libid.claim-identity")), - "cb29bed0428519ef88a3d670e8203db76e06f41aca3e684e2c63b516c9b93e1b" - ); - assert_eq!( - hex::encode(chain_id(b"example:1")), - "38064d82f31db40935cc75f2a0d07dcfb448d7c08e7484fc30f5de95484a4066" - ); - } - - #[test] - fn preimage_matches_the_specification_vector() { - let expected = "cb29bed0428519ef88a3d670e8203db76e06f41aca3e684e2c63b516c9b93e1b\ - 0001\ - 38064d82f31db40935cc75f2a0d07dcfb448d7c08e7484fc30f5de95484a4066\ - 5555555555555555555555555555555555555555555555555555555555555555\ - 00000004\ - 00010203"; - assert_eq!(hex::encode(spec_vector().encode().unwrap()), expected); - } - - #[test] - fn digest_matches_the_specification_vector() { - assert_eq!( - hex::encode(spec_vector().digest().unwrap()), - "b318fb559e16a179b853ed2853576cda16032d93b0839bb81a55135d334c0af5" - ); - } - - #[test] - fn fixed_part_is_one_hundred_and_two_bytes() { - let mut p = spec_vector(); - p.transaction_data.clear(); - assert_eq!(p.encode().unwrap().len(), PREIMAGE_FIXED_LEN); - } - - #[test] - fn every_field_changes_the_digest() { - let base = spec_vector().digest().unwrap(); - - let mut p = spec_vector(); - p.operation_domain[0] ^= 1; - assert_ne!(p.digest().unwrap(), base, "operation domain does not bind"); - - let mut p = spec_vector(); - p.platform_verifier_version = 2; - assert_ne!(p.digest().unwrap(), base, "verifier version does not bind"); - - let mut p = spec_vector(); - p.chain_id[31] ^= 1; - assert_ne!(p.digest().unwrap(), base, "chain id does not bind"); - - let mut p = spec_vector(); - p.authorization_nonce[0] ^= 1; - assert_ne!(p.digest().unwrap(), base, "nonce does not bind"); - - let mut p = spec_vector(); - p.transaction_data.push(0x04); - assert_ne!(p.digest().unwrap(), base, "transaction data does not bind"); - } - - #[test] - fn length_prefix_separates_a_shifted_boundary() { - // Without the explicit length, transaction data whose leading bytes - // could be read as part of the nonce would collide. The prefix is what - // makes every boundary derivable. - let mut a = spec_vector(); - a.transaction_data = vec![0x00, 0x01]; - let mut b = spec_vector(); - b.transaction_data = vec![0x00, 0x01, 0x00, 0x00]; - assert_ne!(a.digest().unwrap(), b.digest().unwrap()); - } -} diff --git a/crates/libid-ceremony/src/launch.rs b/crates/libid-ceremony/src/launch.rs deleted file mode 100644 index 8d4b0d48..00000000 --- a/crates/libid-ceremony/src/launch.rs +++ /dev/null @@ -1,317 +0,0 @@ -//! The launch platform profiles, and the protocol parameters governance owns. -//! -//! # Nothing in Rust reads these -//! -//! A Platform Profile is what a Platform Verifier pins and what a browser -//! runtime selects. Neither is Rust: the verifier is Solidity and the runtime -//! is TypeScript. The notary, which IS Rust, is forbidden from deciding -//! anything profile-specific (REQ-COMMON-33) -- it stamps the three tags of -//! [`super::profile`] and derives nothing else. -//! -//! These records exist as a third reading of the specification's published -//! constants, so the conformance suite can check the Solidity and TypeScript -//! ones against something written independently. That makes them a test -//! oracle, which is why they are behind a feature no service enables. Compiled -//! into the notary they would be dead weight, and a mistake in them would be -//! invisible. - -use crate::profile::{ - IDENTITY_SESSION_TAG, - TOKEN_SESSION_TAG, -}; - -/// How a profile binds the Authorization Digest to its evidence -/// (REQ-COMMON-02C). Exactly one of the two, never both and never neither. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum DigestBinding { - /// Google: the digest is a public proof input the verifier compares. - PublicProofInput, - /// X and GitHub: the verifier recomputes the revealed `code_verifier`. - RevealedCodeVerifier, -} - -/// One notarized session of a ceremony. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct SessionProfile { - /// The `operationTag` this session's attestation must carry. - pub operation_tag: &'static str, - /// The TLS server name the notary must have authenticated, in the - /// canonical form of section 9: lowercase ASCII, no trailing dot. It - /// reaches the verifier as `authorityId`, never as a transcript range, - /// because the transcript holds it only in a prover-composed `Host` header. - pub authority: &'static str, - /// Exact uppercase HTTP method, compared against a revealed range. - pub method: &'static str, - /// Origin-form path, no query, compared against a revealed range. - pub path: &'static str, -} - -/// The five parameters of platform-ceremonies section 2.1a. -/// -/// `is_email` supersedes the two booleans below it (REQ-PLAT-67); they are -/// stated because REQ-PLAT-72 requires a profile to fix all five. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct HandleRules { - pub max_length: u16, - pub strip_leading_at: bool, - pub is_email: bool, - pub allow_underscore: bool, - pub allow_hyphen: bool, -} - -/// An immutable, independently versioned ceremony profile. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct PlatformProfile { - /// Preimage of `platformId` (REQ-COMMON-55). - pub name: &'static str, - /// Carried in the Authorization Digest and the submission. Launch profiles - /// use 1 (REQ-PLAT-01). - pub platform_verifier_version: u16, - pub digest_binding: DigestBinding, - /// The token or token-exchange session, where the profile has one. - pub token_session: Option, - /// The identity session, where the profile has one. - pub identity_session: Option, - pub handle: HandleRules, -} - -impl PlatformProfile { - /// Derived, never stated beside the session list: the profile fixes the - /// list and the count is its size (REQ-COMMON-41). - pub fn attestation_count(&self) -> u8 { - self.token_session.is_some() as u8 + self.identity_session.is_some() as u8 - } - - /// A profile verifying no attestation reaches no Notary Service and pays - /// nothing (REQ-COMMON-05D, REQ-COMMON-06E). - pub fn verifies_attestations(&self) -> bool { - self.attestation_count() > 0 - } -} - -/// `google/v1` -- authentication-only OIDC. No token exchange, no client -/// secret, no PKCE, no notarized session, and therefore no Notary Fee. -pub const GOOGLE_V1: PlatformProfile = PlatformProfile { - name: "google", - platform_verifier_version: 1, - digest_binding: DigestBinding::PublicProofInput, - token_session: None, - identity_session: None, - handle: HandleRules { - max_length: 62, - strip_leading_at: false, - is_email: true, - allow_underscore: false, - allow_hyphen: false, - }, -}; - -/// `x/v1` -- a public client with S256 PKCE and two browser-owned sessions. -pub const X_V1: PlatformProfile = PlatformProfile { - name: "x", - platform_verifier_version: 1, - digest_binding: DigestBinding::RevealedCodeVerifier, - token_session: Some(SessionProfile { - operation_tag: TOKEN_SESSION_TAG, - authority: "api.x.com", - method: "POST", - path: "/2/oauth2/token", - }), - identity_session: Some(SessionProfile { - operation_tag: IDENTITY_SESSION_TAG, - authority: "api.x.com", - method: "GET", - path: "/2/users/me", - }), - handle: HandleRules { - max_length: 15, - strip_leading_at: true, - is_email: false, - allow_underscore: true, - allow_hyphen: false, - }, -}; - -/// `github/v1` -- a confidential client, so the exchange runs in the -/// deployment's Token-Exchange Service and that service is the notarized party -/// for the token session. Note the two authorities differ. -pub const GITHUB_V1: PlatformProfile = PlatformProfile { - name: "github", - platform_verifier_version: 1, - digest_binding: DigestBinding::RevealedCodeVerifier, - token_session: Some(SessionProfile { - operation_tag: TOKEN_SESSION_TAG, - authority: "github.com", - method: "POST", - path: "/login/oauth/access_token", - }), - identity_session: Some(SessionProfile { - operation_tag: IDENTITY_SESSION_TAG, - authority: "api.github.com", - method: "GET", - path: "/user", - }), - handle: HandleRules { - max_length: 39, - strip_leading_at: true, - is_email: false, - allow_underscore: false, - allow_hyphen: true, - }, -}; - -pub const LAUNCH_PROFILES: [PlatformProfile; 3] = [GOOGLE_V1, X_V1, GITHUB_V1]; - -/// Governance-owned unsigned 64-bit values in seconds, read where they are -/// enforced (libid.md protocol parameters). -/// -/// The Platform Verifier reads the current value when it verifies; a browser -/// read is advisory. Lowering one may reject an outstanding proof and raising -/// one may extend an outstanding X or GitHub proof, so they are authority -/// rather than configuration (REQ-PARAM-01, REQ-PARAM-02). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct ProtocolParameters { - /// Maximum age of the X token attestation. - pub proof_lifetime_x: u64, - /// Maximum age of the GitHub token-exchange attestation. - pub proof_lifetime_github: u64, - /// Maximum X or GitHub attestation lead over chain time. - pub max_future_attestation_skew: u64, -} - -/// The launch values. -pub const LAUNCH_PARAMETERS: ProtocolParameters = ProtocolParameters { - proof_lifetime_x: 3600, - proof_lifetime_github: 3600, - max_future_attestation_skew: 300, -}; - -#[cfg(test)] -mod tests { - use super::*; - use crate::attestation::tag; - - #[test] - fn attestation_counts_match_the_profiles() { - // Google verifies none and pays nothing; X and GitHub verify two each, - // so one submission on either path pays two Notary Fees. - assert_eq!(GOOGLE_V1.attestation_count(), 0); - assert!(!GOOGLE_V1.verifies_attestations()); - assert_eq!(X_V1.attestation_count(), 2); - assert_eq!(GITHUB_V1.attestation_count(), 2); - } - - #[test] - fn digest_binding_is_one_method_per_profile() { - // REQ-COMMON-02C: never both, never neither. Google carries no - // code_verifier; X and GitHub expose no digest public input. - assert_eq!(GOOGLE_V1.digest_binding, DigestBinding::PublicProofInput); - assert_eq!(X_V1.digest_binding, DigestBinding::RevealedCodeVerifier); - assert_eq!( - GITHUB_V1.digest_binding, - DigestBinding::RevealedCodeVerifier - ); - } - - #[test] - fn the_two_sessions_of_a_profile_carry_different_tags() { - // Otherwise a token attestation and an identity attestation of one - // ceremony would be interchangeable (REQ-COMMON-55). - for profile in [X_V1, GITHUB_V1] { - let token = profile.token_session.unwrap(); - let identity = profile.identity_session.unwrap(); - assert_ne!(tag(token.operation_tag), tag(identity.operation_tag)); - } - } - - #[test] - fn github_notarizes_two_different_authorities() { - // The exchange is served by github.com and the identity read by - // api.github.com, so one pinned authority per profile would be wrong. - let profile = GITHUB_V1; - assert_eq!(profile.token_session.unwrap().authority, "github.com"); - assert_eq!( - profile.identity_session.unwrap().authority, - "api.github.com" - ); - assert_ne!( - tag(profile.token_session.unwrap().authority), - tag(profile.identity_session.unwrap().authority) - ); - } - - #[test] - fn platform_ids_are_distinct() { - let ids: Vec<_> = LAUNCH_PROFILES.iter().map(|p| tag(p.name)).collect(); - assert_ne!(ids[0], ids[1]); - assert_ne!(ids[1], ids[2]); - assert_ne!(ids[0], ids[2]); - } - - #[test] - fn authorities_are_canonical() { - // Section 9: lowercase ASCII DNS name, no trailing dot, no scheme, - // no port. - for profile in LAUNCH_PROFILES { - for session in [profile.token_session, profile.identity_session] - .into_iter() - .flatten() - { - let a = session.authority; - assert_eq!(a, a.to_ascii_lowercase(), "authority not lowercase: {a}"); - assert!(!a.ends_with('.'), "trailing dot: {a}"); - assert!( - !a.contains('/') && !a.contains(':'), - "not a bare DNS name: {a}" - ); - assert!( - session.path.starts_with('/'), - "path not origin-form: {}", - session.path - ); - assert!( - !session.path.contains('?'), - "path carries a query: {}", - session.path - ); - assert_eq!(session.method, session.method.to_ascii_uppercase()); - } - } - } - - #[test] - fn handle_rules_match_the_published_parameter_table() { - // platform-ceremonies section 2.1a. - assert_eq!( - ( - GOOGLE_V1.handle.max_length, - GOOGLE_V1.handle.strip_leading_at, - GOOGLE_V1.handle.is_email - ), - (62, false, true) - ); - assert_eq!( - ( - X_V1.handle.max_length, - X_V1.handle.strip_leading_at, - X_V1.handle.allow_underscore - ), - (15, true, true) - ); - assert_eq!( - ( - GITHUB_V1.handle.max_length, - GITHUB_V1.handle.strip_leading_at, - GITHUB_V1.handle.allow_hyphen - ), - (39, true, true) - ); - } - - #[test] - fn launch_parameters_match_the_published_values() { - assert_eq!(LAUNCH_PARAMETERS.proof_lifetime_x, 3600); - assert_eq!(LAUNCH_PARAMETERS.proof_lifetime_github, 3600); - assert_eq!(LAUNCH_PARAMETERS.max_future_attestation_skew, 300); - } -} diff --git a/crates/libid-ceremony/src/lib.rs b/crates/libid-ceremony/src/lib.rs index a42e18b2..a5aeea0a 100644 --- a/crates/libid-ceremony/src/lib.rs +++ b/crates/libid-ceremony/src/lib.rs @@ -1,41 +1,40 @@ -//! Wire constructions of the libID identity ceremony. +//! What the notary needs to sign a ceremony attestation, and nothing else. //! -//! # What Rust actually needs, and what is here for the vectors +//! # Why this crate is small //! -//! The notary is the only service that touches these bytes in production, and -//! it needs two things: [`attestation`], to build and sign the attested data of -//! ceremony-common section 9.1, and the three tags of [`profile`] to stamp into -//! it. [`token_exchange`] is the GitHub Token-Exchange Service's request and -//! response records, which that service will encode. +//! 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. //! -//! Everything else is behind the `vectors` feature, off by default: +//! 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. //! -//! * [`authorization`] -- the Authorization Digest of section 5. -//! * [`pkce`] -- the derived `code_verifier` of section 7. -//! * [`launch`] -- the launch platform profiles and protocol parameters. +//! Where a check IS wanted before spending gas, it belongs in the client as a +//! dry run -- and it already lives there. `@libid/contracts` exports +//! `decodeAttestedData`, `validate`, `requireExactCoverage` and +//! `requireBearerHeaderRequest` in TypeScript, which is what REQ-PLAT-44 has +//! the Canonical Runtime call before it spends a second session on an +//! attestation. //! -//! Nothing in Rust builds a digest, derives a verifier, or reads a profile -//! record. The browser builds the first two and the contracts recompute them; -//! a Platform Verifier pins the third. These modules exist so the conformance -//! suite can check those two implementations against a third, written -//! independently from the published vectors -- which makes them a test oracle. -//! An oracle compiled into every consumer is code nothing calls, and a mistake -//! in it is invisible. Hence the feature. +//! So this crate holds one direction of one thing: //! -//! Each module's tests pin it to the conformance vectors the specification -//! publishes, taken from the specification rather than from this code. +//! * [`attestation`] -- the types of ceremony-common section 9.1 and the +//! encoder that lays them out. No decoder: whoever decodes also checks, and +//! that is the chain and the client. +//! * [`profile`] -- the tags the notary stamps. +//! * [`token_exchange`] -- the GitHub Token-Exchange Service's own request and +//! response records. Its validation stays, because REQ-PLAT-37 to -40 put +//! that service's input validation on that service; no contract sees it. pub mod attestation; pub mod profile; pub mod token_exchange; -#[cfg(any(test, feature = "vectors"))] -pub mod authorization; -#[cfg(any(test, feature = "vectors"))] -pub mod launch; -#[cfg(any(test, feature = "vectors"))] -pub mod pkce; - pub use attestation::{ AttestationError, AttestedData, @@ -43,13 +42,3 @@ pub use attestation::{ RangeCommitment, RevealedRange, }; -#[cfg(any(test, feature = "vectors"))] -pub use authorization::{ - AuthorizationError, - AuthorizationPreimage, -}; -#[cfg(any(test, feature = "vectors"))] -pub use pkce::{ - code_challenge, - code_verifier, -}; diff --git a/crates/libid-ceremony/src/pkce.rs b/crates/libid-ceremony/src/pkce.rs deleted file mode 100644 index 9f2d3767..00000000 --- a/crates/libid-ceremony/src/pkce.rs +++ /dev/null @@ -1,146 +0,0 @@ -//! The PKCE construction of ceremony-common section 7. -//! -//! X and GitHub cannot carry the Authorization Digest in their authorization -//! request the way Google carries it in an OIDC `nonce`, so they carry it -//! through the PKCE verifier instead. The verifier is revealed in the -//! notarized token request, and the Platform Verifier recomputes it from the -//! digest and the submitted nonce. Retargeting an attestation to another -//! digest would take a second preimage of that verifier. - -use base64::{ - engine::general_purpose::URL_SAFE_NO_PAD, - Engine as _, -}; -use libid_crypto::keccak256; -use sha2::{ - Digest, - Sha256, -}; - -/// Domain separator, carrying no version of its own: the Authorization Digest -/// already binds `platformVerifierVersion`, and a change to this construction -/// changes the proof statement, which bumps that version (REQ-COMMON-12). -pub const PKCE_DOMAIN_STRING: &str = "libid.identity.pkce"; - -/// Both the verifier and the challenge are exactly this many unpadded -/// base64url characters. -pub const PKCE_LEN: usize = 43; - -/// `keccak256(PKCE_DOMAIN_STRING)`. -pub fn pkce_domain() -> [u8; 32] { - keccak256(PKCE_DOMAIN_STRING.as_bytes()) -} - -/// `SHA256(PKCE_DOMAIN || authorizationDigest || pkceNonce)`. -pub fn verifier_hash(authorization_digest: &[u8; 32], pkce_nonce: &[u8; 32]) -> [u8; 32] { - let mut hasher = Sha256::new(); - hasher.update(pkce_domain()); - hasher.update(authorization_digest); - hasher.update(pkce_nonce); - hasher.finalize().into() -} - -/// `BASE64URL_NOPAD(verifierHash)` -- the `code_verifier` the token request -/// carries and the Platform Verifier recomputes. -/// -/// `pkce_nonce` is drawn freshly per authorization attempt: the nonce becomes -/// public at submission, so reusing one across attempts of a single digest -/// publishes the verifier of an earlier attempt whose code may still be live -/// (REQ-COMMON-13). -pub fn code_verifier(authorization_digest: &[u8; 32], pkce_nonce: &[u8; 32]) -> String { - URL_SAFE_NO_PAD.encode(verifier_hash(authorization_digest, pkce_nonce)) -} - -/// `BASE64URL_NOPAD(SHA256(ASCII(code_verifier)))` -- the S256 challenge sent -/// in the authorization request. -pub fn code_challenge(code_verifier: &str) -> String { - URL_SAFE_NO_PAD.encode(Sha256::digest(code_verifier.as_bytes())) -} - -#[cfg(test)] -mod tests { - use super::*; - - /// The Authorization Digest of the section 5 vector. - const DIGEST: [u8; 32] = [ - 0xb3, 0x18, 0xfb, 0x55, 0x9e, 0x16, 0xa1, 0x79, 0xb8, 0x53, 0xed, 0x28, 0x53, - 0x57, 0x6c, 0xda, 0x16, 0x03, 0x2d, 0x93, 0xb0, 0x83, 0x9b, 0xb8, 0x1a, 0x55, - 0x13, 0x5d, 0x33, 0x4c, 0x0a, 0xf5, - ]; - const NONCE: [u8; 32] = [0x44; 32]; - - #[test] - fn matches_the_specification_vector() { - assert_eq!( - hex::encode(pkce_domain()), - "3961dfe56cd0f2d94e72a15b96df889fbb46968cdb37518830fc0077b0730a01" - ); - assert_eq!( - hex::encode(verifier_hash(&DIGEST, &NONCE)), - "88c493361ea0424467046958d5cd0c50eb03ecc08ee06f02ee9875fe0219b392" - ); - let verifier = code_verifier(&DIGEST, &NONCE); - assert_eq!(verifier, "iMSTNh6gQkRnBGlY1c0MUOsD7MCO4G8C7ph1_gIZs5I"); - assert_eq!( - code_challenge(&verifier), - "BhFqYIY1YnHafYOrrblUswFnjxFF97UvGjSgqugPQvA" - ); - } - - #[test] - fn both_values_are_forty_three_unpadded_characters() { - let verifier = code_verifier(&DIGEST, &NONCE); - let challenge = code_challenge(&verifier); - assert_eq!(verifier.len(), PKCE_LEN); - assert_eq!(challenge.len(), PKCE_LEN); - for value in [&verifier, &challenge] { - assert!(!value.contains('='), "padding leaked into {value}"); - assert!(!value.contains('+'), "not base64url: {value}"); - assert!(!value.contains('/'), "not base64url: {value}"); - } - } - - #[test] - fn the_verifier_is_a_pkce_charset_string() { - // RFC 7636 unreserved set, which base64url is a subset of. - let verifier = code_verifier(&DIGEST, &NONCE); - assert!(verifier - .chars() - .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')); - } - - #[test] - fn a_different_digest_gives_a_different_verifier() { - // This is the binding. If it failed, one notarized token request would - // serve any transaction. - let mut other = DIGEST; - other[0] ^= 1; - assert_ne!( - code_verifier(&DIGEST, &NONCE), - code_verifier(&other, &NONCE) - ); - } - - #[test] - fn a_different_nonce_gives_a_different_verifier() { - // Which is what makes a retry of one digest unpredictable to anyone - // holding only the public digest. - let mut other = NONCE; - other[31] ^= 1; - assert_ne!( - code_verifier(&DIGEST, &NONCE), - code_verifier(&DIGEST, &other) - ); - } - - #[test] - fn the_domain_separates_this_hash_from_a_bare_one() { - let bare: [u8; 32] = { - let mut h = Sha256::new(); - h.update(DIGEST); - h.update(NONCE); - h.finalize().into() - }; - assert_ne!(verifier_hash(&DIGEST, &NONCE), bare); - } -} diff --git a/crates/libid-tlsn/src/attest.rs b/crates/libid-tlsn/src/attest.rs index 2d89d000..b1ff2898 100644 --- a/crates/libid-tlsn/src/attest.rs +++ b/crates/libid-tlsn/src/attest.rs @@ -165,6 +165,26 @@ fn direction_block( #[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.end)) + .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::{ self, @@ -227,14 +247,24 @@ mod tests { } #[test] - fn produces_an_attestation_the_codec_accepts() { + fn encodes_to_the_length_its_own_fields_imply() { let (partial, commitments) = session(); let data = attested_data(&partial, "api.x.com", &commitments, input()).unwrap(); - let encoded = data.encode().expect("the notary must emit a valid shape"); - assert_eq!( - libid_ceremony::AttestedData::decode(&encoded).unwrap(), - data - ); + 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 += 2 + 2; + for r in &d.revealed { + want += 8 + r.bytes.len(); + } + want += d.commitments.len() * (8 + 32); + } + assert_eq!(encoded.len(), want); } #[test] @@ -243,9 +273,7 @@ mod tests { // check the Platform Verifier runs, or no genuine session ever passes. let (partial, commitments) = session(); let data = attested_data(&partial, "api.x.com", &commitments, input()).unwrap(); - data.sent - .require_exact_coverage("sent", data.sent_transcript_length) - .expect("an honest identity request tiles exactly"); + assert_tiles(&data.sent, data.sent_transcript_length); } #[test] @@ -395,13 +423,8 @@ mod tests { ) .unwrap(); let data = round_trip(sent, recv, &s, &r); - - data.sent - .require_exact_coverage("sent", data.sent_transcript_length) - .expect("the identity request must satisfy REQ-COMMON-35"); - data.received - .require_exact_coverage("received", data.recv_transcript_length) - .expect("the identity response must tile too"); + 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. @@ -416,10 +439,7 @@ mod tests { let s = ceremony::token_request(sent, None).unwrap(); let r = ceremony::token_response(recv).unwrap(); let data = round_trip(sent, recv, &s, &r); - - data.sent - .require_exact_coverage("sent", data.sent_transcript_length) - .expect("the token request must tile"); + 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()); @@ -435,10 +455,7 @@ mod tests { let s = ceremony::token_request(sent, Some("client_secret")).unwrap(); let r = ceremony::token_response(recv).unwrap(); let data = round_trip(sent, recv, &s, &r); - - data.sent - .require_exact_coverage("sent", data.sent_transcript_length) - .expect("the exchange must tile"); + 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. From 6e90caeff2334d3fef647815f02cdbe1ac58c436 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Mon, 24 Aug 2026 15:45:57 +0300 Subject: [PATCH 11/65] refactor!: drop the three fields the notary was told rather than saw `formatTag`, `platformId` and `operationTag` leave the record, and `profile.rs` goes with them: it held nothing else. Each was a value handed to the notary and written down as though observed. The format is fixed by the notary key a profile pins alongside it (REQ-COMMON-18), so the key already answers that question. The platform is whichever host answered, and `authorityId` is that host, authenticated in the handshake. Which session this is, is the request line the notary recorded as a revealed range -- the verifier already compares it against the path its profile pins, so the label was a second, weaker answer supplied by the party being attested. `AttestationInput` is down to one field, `created_at`, which REQ-COMMON-57 requires the notary to originate. Everything else in the record now comes from the session: the authenticated server name, the transcript lengths, the ranges the client chose to reveal, and the commitments over the rest. HEADER_LEN 144 -> 48 The test that pinned the two sessions apart by their tags now pins them apart by their request lines, which is where the difference actually lives. Also re-exports `RevealMode` and `CeremonySession` from `libid-tlsn`. `prover_generic` has taken a `RevealMode` since this branch began and the type was never exported, so no caller outside this crate could construct one -- the notary could not compile against it. Cross-language fixture and digest move with the layout; the Solidity and TypeScript sides carry the same new values. 85 tests, clippy and fmt clean. Signed-off-by: SupremaLex --- crates/libid-ceremony/src/attestation.rs | 46 +++++++++++------------- crates/libid-ceremony/src/lib.rs | 9 +++-- crates/libid-ceremony/src/profile.rs | 38 -------------------- crates/libid-tlsn/src/attest.rs | 18 ++-------- crates/libid-tlsn/src/lib.rs | 2 ++ 5 files changed, 33 insertions(+), 80 deletions(-) delete mode 100644 crates/libid-ceremony/src/profile.rs diff --git a/crates/libid-ceremony/src/attestation.rs b/crates/libid-ceremony/src/attestation.rs index 5089b896..2d30ee26 100644 --- a/crates/libid-ceremony/src/attestation.rs +++ b/crates/libid-ceremony/src/attestation.rs @@ -68,9 +68,12 @@ pub struct DirectionBlock { /// made before it existed. #[derive(Clone, Debug, PartialEq, Eq)] pub struct AttestedData { - pub format_tag: [u8; 32], - pub platform_id: [u8; 32], - pub operation_tag: [u8; 32], + /// 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, @@ -79,9 +82,9 @@ pub struct AttestedData { pub received: DirectionBlock, } -/// Bytes before the first direction block: four 32-byte tags, `createdAt`, and -/// the two transcript lengths. -pub const HEADER_LEN: usize = 32 * 4 + 8 + 4 + 4; +/// Bytes before the first direction block: the authority, `createdAt`, and the +/// two transcript lengths. +pub const HEADER_LEN: usize = 32 + 8 + 4 + 4; #[derive(Debug, thiserror::Error, PartialEq, Eq)] pub enum AttestationError { @@ -187,9 +190,6 @@ impl AttestedData { /// the notary really did observe. pub fn encode(&self) -> Result, AttestationError> { let mut out = Vec::with_capacity(HEADER_LEN); - out.extend_from_slice(&self.format_tag); - out.extend_from_slice(&self.platform_id); - out.extend_from_slice(&self.operation_tag); out.extend_from_slice(&self.authority_id); out.extend_from_slice(&self.created_at.to_be_bytes()); out.extend_from_slice(&self.sent_transcript_length.to_be_bytes()); @@ -214,9 +214,6 @@ mod tests { // Shaped like the X identity session: the request reveals everything // but the bearer, which is committed and framed by the header bytes. AttestedData { - format_tag: tag("libid.attestation.v1"), - platform_id: tag("x"), - operation_tag: tag("libid.ceremony.session.identity.v1"), authority_id: tag("api.x.com"), created_at: 1_770_000_000, sent_transcript_length: 60, @@ -259,15 +256,12 @@ mod tests { /// 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 = "f1b67c286f7f90224eb4661a5922406b5092042b9515e4e9e448ec1d4f55b352\ -7521d1cadbcfa91eec65aa16715b94ffc1c9654ba57ea2ef1a2127bca1127a83\ -e7b961087ec316778e6885d11145cc06f1d75360430f461d0322fb7f105899dd\ -4930142f5283d4a8eab0d24c588f00b21213ae2a47e7ed6c1dc6a57044f1655d\ + const CROSS_LANGUAGE_FIXTURE: &str = "4930142f5283d4a8eab0d24c588f00b21213ae2a47e7ed6c1dc6a57044f1655d\ 0000000069800e800000003c00000028000200000000000000146161616161616161616161616161616161616161\ 000000280000003c62626262626262626262626262626262626262620001000000140000002807070707070707070707070707070707070707070707070707070707070707070001000000000000000a6363636363636363636300010000000a000000280909090909090909090909090909090909090909090909090909090909090909"; const CROSS_LANGUAGE_DIGEST: &str = - "511d91f8a3c13c1824fd1d3e7c011caf09f2f0763f1ede5c786839592ae8d252"; + "84f7c0aaf996ddc4db3f5a81baa230849bbf0c554a14302cc0946dcde937104b"; #[test] fn agrees_with_the_solidity_decoder() { @@ -288,17 +282,14 @@ e7b961087ec316778e6885d11145cc06f1d75360430f461d0322fb7f105899dd\ data.recv_transcript_length = 0; // Header plus two empty counts per direction. assert_eq!(data.encode().unwrap().len(), HEADER_LEN + 4 + 4); - assert_eq!(HEADER_LEN, 144); + 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.format_tag[0] ^= 1) as fn(&mut AttestedData), - |d| d.platform_id[0] ^= 1, - |d| d.operation_tag[0] ^= 1, - |d| d.authority_id[0] ^= 1, + (|d: &mut AttestedData| d.authority_id[0] ^= 1) as fn(&mut AttestedData), |d| d.created_at += 1, ] { let mut data = sample(); @@ -309,10 +300,15 @@ e7b961087ec316778e6885d11145cc06f1d75360430f461d0322fb7f105899dd\ #[test] fn two_sessions_of_one_ceremony_are_not_interchangeable() { - // REQ-COMMON-55: without operationTag the token and identity - // attestations of one ceremony would differ in nothing a verifier reads. + // 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.operation_tag = tag("libid.ceremony.session.token.v1"); + token.sent.revealed[0].bytes = b"POST /2/oauth2/token ".to_vec(); + token.sent.revealed[0].end = token.sent.revealed[0].start + 21; assert_ne!(token.digest().unwrap(), sample().digest().unwrap()); } diff --git a/crates/libid-ceremony/src/lib.rs b/crates/libid-ceremony/src/lib.rs index a5aeea0a..5663ec4c 100644 --- a/crates/libid-ceremony/src/lib.rs +++ b/crates/libid-ceremony/src/lib.rs @@ -21,18 +21,23 @@ //! the Canonical Runtime call before it spends a second session on an //! attestation. //! +//! 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 types of ceremony-common section 9.1 and the //! encoder that lays them out. No decoder: whoever decodes also checks, and //! that is the chain and the client. -//! * [`profile`] -- the tags the notary stamps. //! * [`token_exchange`] -- the GitHub Token-Exchange Service's own request and //! response records. Its validation stays, because REQ-PLAT-37 to -40 put //! that service's input validation on that service; no contract sees it. pub mod attestation; -pub mod profile; pub mod token_exchange; pub use attestation::{ diff --git a/crates/libid-ceremony/src/profile.rs b/crates/libid-ceremony/src/profile.rs deleted file mode 100644 index 68aa26f6..00000000 --- a/crates/libid-ceremony/src/profile.rs +++ /dev/null @@ -1,38 +0,0 @@ -//! The three tags the notary stamps into attested data. -//! -//! This is the whole of what Rust needs from a Platform Profile. The notary -//! decides nothing profile-specific (REQ-COMMON-33): it names the byte layout, -//! says which session an attestation covers, and stops. Which ranges a profile -//! expects and what their bytes must contain belong to the Platform Verifier. -//! -//! The launch profiles themselves -- endpoints, authorities, handle rules, -//! protocol parameters -- are in [`crate::launch`], behind a feature, because -//! nothing in Rust reads them. -//! -//! # These strings are ours, not the specification's -//! -//! The specification fixes exactly one literal: `libid.identity.pkce`, in -//! ceremony-common section 7. `formatTag` (REQ-COMMON-53) and `operationTag` -//! (REQ-COMMON-55) are required to exist and required to be pinned, but their -//! bytes are left to the profile author. Those requirement numbers come from -//! libid PR #12, which was closed without merging, so a reader will not find -//! them on main. -//! -//! That makes these constants a cross-implementation agreement rather than a -//! reading of the specification. A notary emitting `libid.attestation.v1` and a -//! verifier pinning anything else derives a key nobody trusts and rejects every -//! genuine attestation, with no error that says why. - -/// Names this attestation byte layout and its version (REQ-COMMON-53). -/// -/// A change to the field list, to a field's width, or to a field's meaning -/// takes a new version string rather than another field. -pub const FORMAT_TAG: &str = "libid.attestation.v1"; - -/// Which session of the ceremony an attestation covers (REQ-COMMON-55). -/// -/// One ceremony notarizes more than one session, and two attestations that -/// differ only in which session they came from would otherwise be -/// interchangeable. -pub const TOKEN_SESSION_TAG: &str = "libid.ceremony.session.token.v1"; -pub const IDENTITY_SESSION_TAG: &str = "libid.ceremony.session.identity.v1"; diff --git a/crates/libid-tlsn/src/attest.rs b/crates/libid-tlsn/src/attest.rs index b1ff2898..f5d40bdd 100644 --- a/crates/libid-tlsn/src/attest.rs +++ b/crates/libid-tlsn/src/attest.rs @@ -27,13 +27,7 @@ use tlsn::{ }; /// What the profile pins, and what only the notary knows. -pub struct AttestationInput<'a> { - /// The libID-namespaced string naming this attestation format and version. - pub format_tag: &'a str, - /// The identity-platform name. - pub platform_name: &'a str, - /// The libID-namespaced string naming which session this covers. - pub operation_tag: &'a str, +pub struct AttestationInput { /// The notary's OWN clock reading when the session completed. /// /// REQ-COMMON-57 forbids taking this from the prover, from a response @@ -76,12 +70,9 @@ pub fn attested_data( partial: &PartialTranscript, authority: &str, commitments: &[TranscriptCommitment], - input: AttestationInput<'_>, + input: AttestationInput, ) -> Result { Ok(AttestedData { - format_tag: tag(input.format_tag), - platform_id: tag(input.platform_name), - operation_tag: tag(input.operation_tag), // The canonical authority of section 9: the lowercase ASCII TLS server // name the notary authenticated, with no trailing dot. It is a signed // field rather than a transcript range because the transcript carries @@ -203,11 +194,8 @@ mod tests { 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\"}"; - fn input<'a>() -> AttestationInput<'a> { + fn input() -> AttestationInput { AttestationInput { - format_tag: "libid.attestation.v1", - platform_name: "x", - operation_tag: "libid.ceremony.session.identity.v1", created_at: 1_770_000_000, } } diff --git a/crates/libid-tlsn/src/lib.rs b/crates/libid-tlsn/src/lib.rs index 8dd18842..d9b46364 100644 --- a/crates/libid-tlsn/src/lib.rs +++ b/crates/libid-tlsn/src/lib.rs @@ -66,9 +66,11 @@ pub use session::{ prover_generic, root_store, verifier, + CeremonySession, HttpRequestSpec, ProverResult, ProverStep, + RevealMode, UserInfoParams, VerifierResult, MAX_RECV_DATA, From 42a3dc128d52f4826aee6981c77eb1cff8f05919 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Mon, 24 Aug 2026 15:51:59 +0300 Subject: [PATCH 12/65] refactor!: one error, because there is one way to fail `AttestationError` had ten variants and nine of them became unreachable when the decoder and the checks left. `Truncated` and `TrailingBytes` were the decoder's; `EmptyRange`, `OutOfOrder`, `PastTranscriptEnd`, `CommitmentOverlapsRevealed`, `CoverageGap` and `SpansOverlap` were the validator's and the coverage check's; `RangeLengthMismatch` belonged to both. All of that is the Platform Verifier's now, and the client's to preview. What remains is `CountTooLarge`: a direction holding more entries than the two-byte count can name. That is not a judgement about the session, it is the encoder declining to write down something it cannot represent. Which is also a fix. `encode_into` cast the length with `as u16`, and once `validate` went there was nothing left to catch the overflow -- a direction with more than 65535 entries would have truncated silently and produced a signed record describing a different session from the one observed. `count()` returns the error instead, with a test that fails without it. Two comments went stale in the same cut and are corrected: `digest` still carried the decoder's "parse and validate, trailing bytes are refused", and `encode` now says why it does not judge what it lays out. Signed-off-by: SupremaLex --- crates/libid-ceremony/src/attestation.rs | 124 +++++++++-------------- crates/libid-ceremony/src/lib.rs | 2 +- 2 files changed, 47 insertions(+), 79 deletions(-) diff --git a/crates/libid-ceremony/src/attestation.rs b/crates/libid-ceremony/src/attestation.rs index 2d30ee26..bd3f7fbc 100644 --- a/crates/libid-ceremony/src/attestation.rs +++ b/crates/libid-ceremony/src/attestation.rs @@ -86,76 +86,17 @@ pub struct AttestedData { /// two transcript lengths. pub const HEADER_LEN: usize = 32 + 8 + 4 + 4; +/// The one way encoding can fail: a direction holding more entries than the +/// two-byte count can name. +/// +/// There is nothing else to get wrong here. Whether the ranges tile, whether +/// they are ordered, whether a commitment overlaps a revealed range -- this +/// crate used to answer all of that and no longer does. Those are the Platform +/// Verifier's decisions, and the client's to preview in a dry run. What is left +/// is the encoder declining to write down something it cannot represent. #[derive(Debug, thiserror::Error, PartialEq, Eq)] -pub enum AttestationError { - #[error("attested data ends inside the {field} field")] - Truncated { field: &'static str }, - #[error("{0} bytes remain after the received direction block")] - TrailingBytes(usize), - #[error("a direction holds {0} entries, which does not fit the two-byte count")] - CountTooLarge(usize), - #[error("revealed range {index} of the {direction} direction carries {carried} bytes for offsets {start}..{end}")] - RangeLengthMismatch { - direction: &'static str, - index: usize, - start: u32, - end: u32, - carried: usize, - }, - #[error("{kind} {index} of the {direction} direction is empty at offset {start}")] - EmptyRange { - direction: &'static str, - kind: &'static str, - index: usize, - start: u32, - }, - #[error("{kind} {index} of the {direction} direction starts at {start}, behind the previous end {previous_end}")] - OutOfOrder { - direction: &'static str, - kind: &'static str, - index: usize, - start: u32, - previous_end: u32, - }, - #[error("{kind} {index} of the {direction} direction ends at {end}, past the signed transcript length {length}")] - PastTranscriptEnd { - direction: &'static str, - kind: &'static str, - index: usize, - end: u32, - length: u32, - }, - #[error("a commitment of the {direction} direction overlaps a revealed range at {start}..{end}")] - CommitmentOverlapsRevealed { - direction: &'static str, - start: u32, - end: u32, - }, - #[error("transcript bytes {from}..{to} of the {direction} direction are covered by nothing")] - CoverageGap { - direction: &'static str, - from: u32, - to: u32, - }, - #[error("spans of the {direction} direction overlap at {at}")] - SpansOverlap { direction: &'static str, at: u32 }, - #[error("the {direction} direction holds {count} commitments, but this request commits exactly one credential")] - NotOneCommitment { - direction: &'static str, - count: usize, - }, - #[error("the revealed {direction} bytes carry an obsolete line fold at {at}")] - ObsoleteLineFold { direction: &'static str, at: usize }, - #[error( - "the revealed {direction} bytes hold {count} authorization header lines, not one" - )] - NotOneAuthorizationHeader { - direction: &'static str, - count: usize, - }, - #[error("the committed range of the {direction} direction is not framed by an authorization header line")] - BadBearerFraming { direction: &'static str }, -} +#[error("a direction holds {0} entries, which does not fit the two-byte count")] +pub struct CountTooLarge(pub usize); /// Derive a 32-byte tag from a libID-namespaced ASCII string. /// @@ -167,41 +108,52 @@ pub fn tag(namespaced: &str) -> [u8; 32] { } impl DirectionBlock { - fn encode_into(&self, out: &mut Vec) { - out.extend_from_slice(&(self.revealed.len() as u16).to_be_bytes()); + fn encode_into(&self, out: &mut Vec) -> Result<(), CountTooLarge> { + // Not a rule about what a good attestation looks like -- that belongs + // to the verifier. This is the encoder saying it cannot write down what + // it was handed: a count past `u16` would truncate and produce a record + // describing a different session from the one observed. + out.extend_from_slice(&count(self.revealed.len())?); for range in &self.revealed { out.extend_from_slice(&range.start.to_be_bytes()); out.extend_from_slice(&range.end.to_be_bytes()); out.extend_from_slice(&range.bytes); } - out.extend_from_slice(&(self.commitments.len() as u16).to_be_bytes()); + out.extend_from_slice(&count(self.commitments.len())?); for commitment in &self.commitments { out.extend_from_slice(&commitment.start.to_be_bytes()); out.extend_from_slice(&commitment.end.to_be_bytes()); out.extend_from_slice(&commitment.commitment); } + Ok(()) } } +fn count(entries: usize) -> Result<[u8; 2], CountTooLarge> { + u16::try_from(entries) + .map(u16::to_be_bytes) + .map_err(|_| CountTooLarge(entries)) +} + impl AttestedData { /// 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. - pub fn encode(&self) -> Result, AttestationError> { + pub fn encode(&self) -> Result, CountTooLarge> { let mut out = Vec::with_capacity(HEADER_LEN); out.extend_from_slice(&self.authority_id); out.extend_from_slice(&self.created_at.to_be_bytes()); out.extend_from_slice(&self.sent_transcript_length.to_be_bytes()); out.extend_from_slice(&self.recv_transcript_length.to_be_bytes()); - self.sent.encode_into(&mut out); - self.received.encode_into(&mut out); + self.sent.encode_into(&mut out)?; + self.received.encode_into(&mut out)?; Ok(out) } - /// Parse and validate. Trailing bytes are refused: the layout accounts for - /// every byte, so a suffix is a second message hiding behind the first. - pub fn digest(&self) -> Result<[u8; 32], AttestationError> { + /// What the notary signs, and the only preimage it ever signs + /// (REQ-COMMON-47). + pub fn digest(&self) -> Result<[u8; 32], CountTooLarge> { Ok(keccak256(&self.encode()?)) } } @@ -312,6 +264,22 @@ mod tests { assert_ne!(token.digest().unwrap(), sample().digest().unwrap()); } + #[test] + fn refuses_a_count_it_cannot_write_down() { + // The two-byte count is the format's, not a judgement about the + // session. An encoder that truncated here would sign a record + // describing a different session from the one observed. + let mut data = sample(); + data.sent.revealed = (0..70_000u32) + .map(|i| RevealedRange { + start: i, + end: i + 1, + bytes: vec![0], + }) + .collect(); + assert_eq!(data.encode().unwrap_err(), CountTooLarge(70_000)); + } + #[test] fn a_shifted_boundary_cannot_produce_one_preimage() { // Moving a byte from a revealed range into the next one changes the diff --git a/crates/libid-ceremony/src/lib.rs b/crates/libid-ceremony/src/lib.rs index 5663ec4c..34bd38c0 100644 --- a/crates/libid-ceremony/src/lib.rs +++ b/crates/libid-ceremony/src/lib.rs @@ -41,8 +41,8 @@ pub mod attestation; pub mod token_exchange; pub use attestation::{ - AttestationError, AttestedData, + CountTooLarge, DirectionBlock, RangeCommitment, RevealedRange, From 07bab2e3ad9da349b6472c720d6b21d94d5d7321 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Mon, 24 Aug 2026 16:03:47 +0300 Subject: [PATCH 13/65] refactor!: derive the encoder The layout is the struct, in declaration order, and nothing restates it. What was nine hand-written `extend_from_slice` calls is now `bincode::encode_to_vec(self, WIRE)`. The configuration is what makes this usable by a decoder that is not Rust: bincode::config::standard().with_big_endian().with_fixed_int_encoding() Big-endian because every other number in this system is, and fixed-width because a varint costs the Solidity decoder a branch per integer to save bytes nobody is short of. Fixed arrays encode raw, so the authority is still 32 bytes with no length in front of it. `RevealedRange` loses `end`. Its length is its bytes, and a `Vec` carries that length itself -- writing both invites them to disagree, which is what `RangeLengthMismatch` used to be the error for. The decoder computes `end = start + bytes.len()`, so the disagreement cannot be expressed. `RangeCommitment` keeps both offsets: it has no bytes to derive an end from. Counts widen from two bytes to eight, which is `Vec`'s own length. That costs 24 bytes a record and removes `CountTooLarge`: a count that does not fit is no longer a thing this format can be handed. The bincode version is pinned exactly. 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 is what would catch it. Fixture and digest move with the layout; the Solidity and TypeScript sides carry the same values. 85 tests, clippy and fmt clean. Signed-off-by: SupremaLex --- Cargo.lock | 39 +++++++- Cargo.toml | 3 + crates/libid-ceremony/Cargo.toml | 1 + crates/libid-ceremony/src/attestation.rs | 113 +++++++---------------- crates/libid-ceremony/src/lib.rs | 1 - crates/libid-tlsn/src/attest.rs | 21 +++-- 6 files changed, 84 insertions(+), 94 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b825cdc8..5909379c 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" @@ -3366,6 +3386,7 @@ dependencies = [ name = "libid-ceremony" version = "0.2.0" dependencies = [ + "bincode 2.0.1", "hex", "libid-crypto", "thiserror 2.0.20", @@ -3502,7 +3523,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", @@ -3517,7 +3538,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", ] @@ -4929,7 +4950,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", @@ -5822,6 +5843,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" @@ -5868,6 +5895,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" diff --git a/Cargo.toml b/Cargo.toml index 5a80569a..9f6bc9e6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,9 @@ alloy-sol-types = "1" aws-config = "1" aws-sdk-kms = "1" base64 = "0.22" +# 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" http-body-util = "0.1" hyper = { version = "1.1", features = ["client", "http1"] } diff --git a/crates/libid-ceremony/Cargo.toml b/crates/libid-ceremony/Cargo.toml index 9cb7b61c..e0f733c6 100644 --- a/crates/libid-ceremony/Cargo.toml +++ b/crates/libid-ceremony/Cargo.toml @@ -8,6 +8,7 @@ license.workspace = true repository.workspace = true [dependencies] +bincode.workspace = true libid-crypto.workspace = true thiserror.workspace = true diff --git a/crates/libid-ceremony/src/attestation.rs b/crates/libid-ceremony/src/attestation.rs index bd3f7fbc..2a7b3d88 100644 --- a/crates/libid-ceremony/src/attestation.rs +++ b/crates/libid-ceremony/src/attestation.rs @@ -35,17 +35,19 @@ use libid_crypto::keccak256; /// Offsets are zero-based into that direction's complete transcript, `start` /// inclusive and `end` exclusive. -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, bincode::Encode)] pub struct RevealedRange { pub start: u32, - pub end: 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 /// (REQ-COMMON-60). -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, bincode::Encode)] pub struct RangeCommitment { pub start: u32, pub end: u32, @@ -53,7 +55,7 @@ pub struct RangeCommitment { } /// One direction of the session. -#[derive(Clone, Debug, Default, PartialEq, Eq)] +#[derive(Clone, Debug, Default, PartialEq, Eq, bincode::Encode)] pub struct DirectionBlock { pub revealed: Vec, pub commitments: Vec, @@ -66,7 +68,7 @@ pub struct DirectionBlock { /// 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)] +#[derive(Clone, Debug, PartialEq, Eq, bincode::Encode)] pub struct AttestedData { /// The TLS server name the notary authenticated, hashed. /// @@ -86,18 +88,6 @@ pub struct AttestedData { /// two transcript lengths. pub const HEADER_LEN: usize = 32 + 8 + 4 + 4; -/// The one way encoding can fail: a direction holding more entries than the -/// two-byte count can name. -/// -/// There is nothing else to get wrong here. Whether the ranges tile, whether -/// they are ordered, whether a commitment overlaps a revealed range -- this -/// crate used to answer all of that and no longer does. Those are the Platform -/// Verifier's decisions, and the client's to preview in a dry run. What is left -/// is the encoder declining to write down something it cannot represent. -#[derive(Debug, thiserror::Error, PartialEq, Eq)] -#[error("a direction holds {0} entries, which does not fit the two-byte count")] -pub struct CountTooLarge(pub usize); - /// Derive a 32-byte tag from a libID-namespaced ASCII string. /// /// Used for `formatTag` (REQ-COMMON-53), `platformId` and `operationTag` @@ -107,53 +97,35 @@ pub fn tag(namespaced: &str) -> [u8; 32] { keccak256(namespaced.as_bytes()) } -impl DirectionBlock { - fn encode_into(&self, out: &mut Vec) -> Result<(), CountTooLarge> { - // Not a rule about what a good attestation looks like -- that belongs - // to the verifier. This is the encoder saying it cannot write down what - // it was handed: a count past `u16` would truncate and produce a record - // describing a different session from the one observed. - out.extend_from_slice(&count(self.revealed.len())?); - for range in &self.revealed { - out.extend_from_slice(&range.start.to_be_bytes()); - out.extend_from_slice(&range.end.to_be_bytes()); - out.extend_from_slice(&range.bytes); - } - out.extend_from_slice(&count(self.commitments.len())?); - for commitment in &self.commitments { - out.extend_from_slice(&commitment.start.to_be_bytes()); - out.extend_from_slice(&commitment.end.to_be_bytes()); - out.extend_from_slice(&commitment.commitment); - } - Ok(()) - } -} - -fn count(entries: usize) -> Result<[u8; 2], CountTooLarge> { - u16::try_from(entries) - .map(u16::to_be_bytes) - .map_err(|_| CountTooLarge(entries)) -} +/// 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 { /// 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. - pub fn encode(&self) -> Result, CountTooLarge> { - let mut out = Vec::with_capacity(HEADER_LEN); - out.extend_from_slice(&self.authority_id); - out.extend_from_slice(&self.created_at.to_be_bytes()); - out.extend_from_slice(&self.sent_transcript_length.to_be_bytes()); - out.extend_from_slice(&self.recv_transcript_length.to_be_bytes()); - self.sent.encode_into(&mut out)?; - self.received.encode_into(&mut out)?; - Ok(out) + /// + /// 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-47). - pub fn digest(&self) -> Result<[u8; 32], CountTooLarge> { + pub fn digest(&self) -> Result<[u8; 32], bincode::error::EncodeError> { Ok(keccak256(&self.encode()?)) } } @@ -174,12 +146,10 @@ mod tests { revealed: vec![ RevealedRange { start: 0, - end: 20, bytes: vec![b'a'; 20], }, RevealedRange { start: 40, - end: 60, bytes: vec![b'b'; 20], }, ], @@ -192,7 +162,6 @@ mod tests { received: DirectionBlock { revealed: vec![RevealedRange { start: 0, - end: 10, bytes: vec![b'c'; 10], }], commitments: vec![RangeCommitment { @@ -208,12 +177,10 @@ mod tests { /// 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 = "4930142f5283d4a8eab0d24c588f00b21213ae2a47e7ed6c1dc6a57044f1655d\ -0000000069800e800000003c00000028000200000000000000146161616161616161616161616161616161616161\ -000000280000003c62626262626262626262626262626262626262620001000000140000002807070707070707070707070707070707070707070707070707070707070707070001000000000000000a6363636363636363636300010000000a000000280909090909090909090909090909090909090909090909090909090909090909"; + const CROSS_LANGUAGE_FIXTURE: &str = "4930142f5283d4a8eab0d24c588f00b21213ae2a47e7ed6c1dc6a57044f1655d0000000069800e800000003c00000028000000000000000200000000000000000000001461616161616161616161616161616161616161610000002800000000000000146262626262626262626262626262626262626262000000000000000100000014000000280707070707070707070707070707070707070707070707070707070707070707000000000000000100000000000000000000000a6363636363636363636300000000000000010000000a000000280909090909090909090909090909090909090909090909090909090909090909"; const CROSS_LANGUAGE_DIGEST: &str = - "84f7c0aaf996ddc4db3f5a81baa230849bbf0c554a14302cc0946dcde937104b"; + "48162f05bdb27b19b3544bf2aae608745861bf357bb31e07f536b6fb50e95936"; #[test] fn agrees_with_the_solidity_decoder() { @@ -233,7 +200,8 @@ mod tests { data.sent_transcript_length = 0; data.recv_transcript_length = 0; // Header plus two empty counts per direction. - assert_eq!(data.encode().unwrap().len(), HEADER_LEN + 4 + 4); + // 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); } @@ -260,32 +228,15 @@ mod tests { // on a label it was handed. let mut token = sample(); token.sent.revealed[0].bytes = b"POST /2/oauth2/token ".to_vec(); - token.sent.revealed[0].end = token.sent.revealed[0].start + 21; assert_ne!(token.digest().unwrap(), sample().digest().unwrap()); } - #[test] - fn refuses_a_count_it_cannot_write_down() { - // The two-byte count is the format's, not a judgement about the - // session. An encoder that truncated here would sign a record - // describing a different session from the one observed. - let mut data = sample(); - data.sent.revealed = (0..70_000u32) - .map(|i| RevealedRange { - start: i, - end: i + 1, - bytes: vec![0], - }) - .collect(); - assert_eq!(data.encode().unwrap_err(), CountTooLarge(70_000)); - } - #[test] fn a_shifted_boundary_cannot_produce_one_preimage() { // Moving a byte from a revealed range into the next one changes the - // encoding, because both offsets and both lengths are written down. + // encoding: the range's length is its bytes, and the following span + // starts one earlier. let mut moved = sample(); - moved.sent.revealed[0].end = 19; 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 index 34bd38c0..4df7590f 100644 --- a/crates/libid-ceremony/src/lib.rs +++ b/crates/libid-ceremony/src/lib.rs @@ -42,7 +42,6 @@ pub mod token_exchange; pub use attestation::{ AttestedData, - CountTooLarge, DirectionBlock, RangeCommitment, RevealedRange, diff --git a/crates/libid-tlsn/src/attest.rs b/crates/libid-tlsn/src/attest.rs index f5d40bdd..a6f42735 100644 --- a/crates/libid-tlsn/src/attest.rs +++ b/crates/libid-tlsn/src/attest.rs @@ -97,15 +97,18 @@ fn direction_block( Direction::Received => (partial.received_authed(), partial.received_unsafe()), }; - // One entry per revealed range, in ascending start order, each carrying its - // offsets and exactly `end - start` bytes. 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 (REQ-COMMON-59). + // 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 (REQ-COMMON-59). 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)?, - end: u32_of(range.end)?, bytes: data[range.clone()].to_vec(), }); } @@ -164,7 +167,7 @@ mod tests { let mut spans: Vec<(u32, u32)> = block .revealed .iter() - .map(|r| (r.start, r.end)) + .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(); @@ -246,11 +249,11 @@ mod tests { // gives the forward-parsing decoder something to walk. let mut want = libid_ceremony::attestation::HEADER_LEN; for d in [&data.sent, &data.received] { - want += 2 + 2; + want += 8 + 8; // one eight-byte count per list for r in &d.revealed { - want += 8 + r.bytes.len(); + want += 4 + 8 + r.bytes.len(); // start, byte length, bytes } - want += d.commitments.len() * (8 + 32); + want += d.commitments.len() * (4 + 4 + 32); // start, end, commitment } assert_eq!(encoded.len(), want); } From 64404924443a8e5e48ffd043d3c0f56afe8eacf5 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Mon, 24 Aug 2026 16:28:41 +0300 Subject: [PATCH 14/65] refactor!: name the reveal mode for what it does, not when it was written `RevealMode::Legacy` was one name over two different things, and its doc promised something false about one of them: "kept only until the ceremony path replaces every caller". Two callers pass it. The pre-ceremony X `/me` flow is genuinely waiting to be replaced and goes at cutover. The notary's JWKS session is not a ceremony at all -- it reads a public document, carries no credential and reaches no Platform Verifier -- so nothing will ever replace it, and this variant outlives the legacy flow it was named after. What the variant actually selects is who picks the ranges: the caller, through its own closure, rather than the layouts of the specification. `CallerSelected` says that, and the doc now names both callers and which one is temporary. Signed-off-by: SupremaLex --- crates/libid-tlsn/src/session.rs | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/crates/libid-tlsn/src/session.rs b/crates/libid-tlsn/src/session.rs index ea71be03..58c19106 100644 --- a/crates/libid-tlsn/src/session.rs +++ b/crates/libid-tlsn/src/session.rs @@ -284,8 +284,9 @@ where user_agent: params.user_agent, }, // This flow predates the ceremony layouts and still selects the old - // sparse ranges. `RevealMode::Ceremony` is what a ceremony session uses. - RevealMode::Legacy, + // sparse ranges, which is why it cannot produce a ceremony attestation. + // It goes at cutover; `RevealMode::Ceremony` is what replaces it. + RevealMode::CallerSelected, |recv| { let mut ranges = vec![ @@ -366,14 +367,20 @@ pub enum CeremonySession<'a> { pub enum RevealMode<'a> { /// The ceremony layouts of the specification. Ceremony(CeremonySession<'a>), - /// The pre-ceremony selection: the request line and `Host` revealed and - /// committed, the whole response committed, and the caller's closure - /// choosing what of the response to reveal. + /// The caller selects the ranges itself: the request line and `Host` + /// revealed and committed, the whole response committed, and the caller's + /// closure choosing what of the response to reveal. /// - /// Kept only until the ceremony path replaces every caller. It does NOT - /// tile, so an attestation produced this way is rejected by the Platform - /// Verifier. - Legacy, + /// This does NOT tile, so a ceremony attestation produced this way is + /// rejected by the Platform Verifier. Two callers use it, and only one of + /// them is waiting to be replaced: + /// + /// * the pre-ceremony X `/me` flow in [`prover`], which goes at cutover; + /// * the notary's JWKS session, which is not a ceremony at all -- it reads + /// a public document, carries no credential, and reaches no Platform + /// Verifier. Nothing will replace it, so this variant outlives the + /// legacy flow that first needed it. + CallerSelected, } /// The reveal and commit ranges for one ceremony session, both directions. @@ -546,7 +553,7 @@ where let (s, r) = ceremony_layouts(sent, recv, session)?; (Some(s), Some(r)) } - RevealMode::Legacy => (None, None), + RevealMode::CallerSelected => (None, None), }; let reveal_recv_ranges = match &recv_layout { From 3b182c55ffd112f179a729b8936eb37322320b65 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Mon, 24 Aug 2026 16:41:29 +0300 Subject: [PATCH 15/65] refactor!: the prover chooses what it reveals, and says so once `prover_generic` took two ways to decide the same thing: a `RevealMode` that could supply full layouts for both directions, and a `compute_reveal_ranges` closure that supplied received-direction reveals. Three `match` sites reconciled them, and whichever the caller meant, the other was dead -- `Ceremony` made the closure unreachable, `CallerSelected` made the layouts unreachable. Worse, one of them was never reachable at all: nothing anywhere constructed `RevealMode::Ceremony`. Every caller in the workspace passed the other variant, so `CeremonySession`, `ceremony_layouts` and the whole arm behind them were code no execution reached. That was the wrong shape for a fact that does not vary: the prover chooses what it reveals, because it is the party holding the session keys and nobody above it can decide on its behalf. So there is one parameter now, and it always applies: S: FnOnce(&[u8], &[u8]) -> Result<(Layout, Layout)> A caller producing a ceremony attestation calls `libid_transcript::ceremony` inside it and returns what that gives back. A caller doing something else -- the pre-ceremony X `/me` flow, the JWKS session -- states its own. The ceremony layouts stop being a mode the library selects and become helpers a caller may use, which is what they always were. `RevealMode` and `CeremonySession` are gone with the three reconciling matches and the fallbacks they guarded. Signed-off-by: SupremaLex --- crates/libid-tlsn/src/lib.rs | 2 - crates/libid-tlsn/src/session.rs | 153 +++++++++---------------------- 2 files changed, 41 insertions(+), 114 deletions(-) diff --git a/crates/libid-tlsn/src/lib.rs b/crates/libid-tlsn/src/lib.rs index d9b46364..8dd18842 100644 --- a/crates/libid-tlsn/src/lib.rs +++ b/crates/libid-tlsn/src/lib.rs @@ -66,11 +66,9 @@ pub use session::{ prover_generic, root_store, verifier, - CeremonySession, HttpRequestSpec, ProverResult, ProverStep, - RevealMode, UserInfoParams, VerifierResult, MAX_RECV_DATA, diff --git a/crates/libid-tlsn/src/session.rs b/crates/libid-tlsn/src/session.rs index 58c19106..bb364445 100644 --- a/crates/libid-tlsn/src/session.rs +++ b/crates/libid-tlsn/src/session.rs @@ -6,6 +6,7 @@ use hyper::{ StatusCode, }; use hyper_util::rt::TokioIo; +use libid_transcript::ceremony::Layout; use std::future::IntoFuture; use tlsn::{ attestation::{ @@ -69,10 +70,6 @@ use tracing::{ }; use libid_transcript::{ - ceremony::{ - self, - IdShape, - }, find_notary_reveal_ranges, find_presentation_commit_ranges, TlsHandshakeData, @@ -83,8 +80,6 @@ use crate::{ 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; @@ -284,10 +279,10 @@ where user_agent: params.user_agent, }, // This flow predates the ceremony layouts and still selects the old - // sparse ranges, which is why it cannot produce a ceremony attestation. - // It goes at cutover; `RevealMode::Ceremony` is what replaces it. - RevealMode::CallerSelected, - |recv| { + // sparse ranges: the request line and `Host` revealed, everything else + // of the response committed whole. It does NOT tile, so what it + // produces is not a ceremony attestation. It goes at cutover. + |sent, recv| { let mut ranges = vec![ libid_transcript::compute_field_snippet_range(recv, username_field) @@ -307,7 +302,16 @@ where ranges.push(range); } } - Ok(ranges) + Ok(( + Layout { + reveal: find_notary_reveal_ranges(sent), + commit: find_presentation_commit_ranges(sent), + }, + Layout { + reveal: ranges, + commit: core::iter::once(0..recv.len()).collect(), + }, + )) }, on_progress, ) @@ -316,95 +320,33 @@ where /// Run the MPC-TLS prover with arbitrary API parameters. /// -/// The reveal and commit ranges for one ceremony session, both directions. -fn ceremony_layouts( - sent: &[u8], - recv: &[u8], - session: CeremonySession<'_>, -) -> Result<(ceremony::Layout, ceremony::Layout)> { - let to_err = |e: ceremony::LayoutError| Error::MpcTlsFailed { - detail: format!("ceremony layout: {e}"), - }; - Ok(match session { - CeremonySession::Token { secret_field } => ( - ceremony::token_request(sent, secret_field).map_err(to_err)?, - ceremony::token_response(recv).map_err(to_err)?, - ), - CeremonySession::Identity { - id_field, - id_shape, - handle_field, - } => ( - ceremony::identity_request(sent).map_err(to_err)?, - ceremony::identity_response(recv, id_field, id_shape, handle_field) - .map_err(to_err)?, - ), - }) -} - -/// Which session of a ceremony this is, and therefore what it discloses. +/// `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. /// -/// The layouts come from `libid_transcript::ceremony`, where the commitments -/// are derived as the complement of the reveals so every direction tiles by -/// construction. A direction that does not tile is refused by the Platform -/// Verifier, so this is a correctness requirement rather than a disclosure -/// preference: choose the wrong ranges and no honest ceremony verifies. -#[derive(Clone, Copy, Debug)] -pub enum CeremonySession<'a> { - /// X's `/2/oauth2/token`, or GitHub's token exchange when `secret_field` - /// names the credential ordered last in its body. - Token { secret_field: Option<&'a str> }, - /// X's `/2/users/me`, or GitHub's `/user`. - Identity { - id_field: &'a str, - id_shape: IdShape, - handle_field: &'a str, - }, -} - -/// How a prover chooses its ranges. -#[derive(Clone, Copy, Debug)] -pub enum RevealMode<'a> { - /// The ceremony layouts of the specification. - Ceremony(CeremonySession<'a>), - /// The caller selects the ranges itself: the request line and `Host` - /// revealed and committed, the whole response committed, and the caller's - /// closure choosing what of the response to reveal. - /// - /// This does NOT tile, so a ceremony attestation produced this way is - /// rejected by the Platform Verifier. Two callers use it, and only one of - /// them is waiting to be replaced: - /// - /// * the pre-ceremony X `/me` flow in [`prover`], which goes at cutover; - /// * the notary's JWKS session, which is not a ceremony at all -- it reads - /// a public document, carries no credential, and reaches no Platform - /// Verifier. Nothing will replace it, so this variant outlives the - /// legacy flow that first needed it. - CallerSelected, -} - -/// The reveal and commit ranges for one ceremony session, both directions. -/// 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()]`. +/// 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. /// -/// Use [`libid_transcript::compute_field_reveal_range`] and friends inside -/// the closure to locate JSON field values in the response body. +/// Each revealed range becomes a separate Merkle leaf in the notary's +/// transcript tree. [`libid_transcript::compute_field_reveal_range`] and +/// friends locate JSON field values in a response body. #[instrument(skip_all, fields(api_host = request.api_host))] -pub async fn prover_generic( +pub async fn prover_generic( socket: T, request: &HttpRequestSpec<'_>, - reveal_mode: RevealMode<'_>, - compute_reveal_ranges: R, + select_layout: S, on_progress: F, ) -> Result> where T: AsyncWrite + AsyncRead + Send + Unpin + 'static, F: Fn(ProverStep), - R: FnOnce(&[u8]) -> Result>>, + S: FnOnce(&[u8], &[u8]) -> Result<(Layout, Layout)>, { let api_host = request.api_host; @@ -548,18 +490,13 @@ where // 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. - let (sent_layout, recv_layout) = match reveal_mode { - RevealMode::Ceremony(session) => { - let (s, r) = ceremony_layouts(sent, recv, session)?; - (Some(s), Some(r)) - } - RevealMode::CallerSelected => (None, None), - }; + // 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)?; - let reveal_recv_ranges = match &recv_layout { - Some(l) => l.reveal.clone(), - None => compute_reveal_ranges(recv)?, - }; + let reveal_recv_ranges = recv_layout.reveal.clone(); // Save the revealed recv segments BEFORE the transcript is moved. // The notary hashes exactly these bytes into the `recv:` Merkle leaves, so @@ -569,19 +506,11 @@ where .map(|r| recv[r.clone()].to_vec()) .collect(); - let notary_sent_ranges = match &sent_layout { - Some(l) => l.reveal.clone(), - None => find_notary_reveal_ranges(sent), - }; + let notary_sent_ranges = sent_layout.reveal.clone(); let mut tc_builder = TranscriptCommitConfig::builder(&transcript); - let (sent_commits, recv_commits) = match (&sent_layout, &recv_layout) { - (Some(s), Some(r)) => (s.commit.clone(), r.commit.clone()), - _ => ( - find_presentation_commit_ranges(sent), - core::iter::once(0..recv.len()).collect(), - ), - }; + let (sent_commits, recv_commits) = + (sent_layout.commit.clone(), recv_layout.commit.clone()); for range in sent_commits { tc_builder .commit_sent(&range) From 1b8d4ff307b03f7126808b43c3757f9747332fe3 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Mon, 24 Aug 2026 17:09:22 +0300 Subject: [PATCH 16/65] docs: correct what the churn left behind `tag()` said it derives `formatTag`, `platformId` and `operationTag`. It derives one thing now -- `authorityId` -- because the other three went with the fields the notary was handed rather than saw. `libid-transcript::ceremony` says who calls it, since nothing in this workspace does yet: a prover notarizing a ceremony session, which in Rust will be the GitHub Token-Exchange Service for the token session. The other three sessions are the browser's. Signed-off-by: SupremaLex --- crates/libid-ceremony/src/attestation.rs | 8 ++++---- crates/libid-transcript/src/ceremony.rs | 6 ++++++ 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/crates/libid-ceremony/src/attestation.rs b/crates/libid-ceremony/src/attestation.rs index 2a7b3d88..6c9891b0 100644 --- a/crates/libid-ceremony/src/attestation.rs +++ b/crates/libid-ceremony/src/attestation.rs @@ -88,11 +88,11 @@ pub struct AttestedData { /// two transcript lengths. pub const HEADER_LEN: usize = 32 + 8 + 4 + 4; -/// Derive a 32-byte tag from a libID-namespaced ASCII string. +/// Hash the canonical authority bytes into `authorityId` (REQ-COMMON-56). /// -/// Used for `formatTag` (REQ-COMMON-53), `platformId` and `operationTag` -/// (REQ-COMMON-55), and `authorityId` over the canonical authority bytes -/// (REQ-COMMON-56). +/// 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 tag(namespaced: &str) -> [u8; 32] { keccak256(namespaced.as_bytes()) } diff --git a/crates/libid-transcript/src/ceremony.rs b/crates/libid-transcript/src/ceremony.rs index 584e828c..119fe686 100644 --- a/crates/libid-transcript/src/ceremony.rs +++ b/crates/libid-transcript/src/ceremony.rs @@ -10,6 +10,12 @@ //! 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; From 3aca6de051f25ffd06b35a7ac72d414ebe7694e8 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Mon, 24 Aug 2026 17:34:07 +0300 Subject: [PATCH 17/65] refactor!: let the caller state its own request, and drop the progress callback Two pieces of API that a crate already covered or nobody used. `HttpRequestSpec` was a six-field bag -- host, path, method, body, bearer, user-agent -- translated field by field into `hyper::Request::builder()` twenty-eight lines further down. The crate was already a dependency and already did the work; the struct was a detour with a cost. It injected three headers of its own: .header("Connection", "close") .header("Accept", "application/json") .header("Content-Type", "application/json") // when a body was set A notarized request is bytes a Platform Verifier compares against a profile, and the profile fixes the exact set -- X's identity request "carries exactly four headers, in this order". A library that adds its own cannot produce that. So `prover_generic` takes a finished `hyper::Request` and the party that knows the profile writes the headers. SNI and the TCP peer come from the request's own authority, and a request with no host is refused rather than guessed at. `hyper::Request`, `Bytes` and `http_body_util::Full` are re-exported as `HttpRequest`, `Bytes` and `HttpBody`, so a caller states its headers without taking a direct dependency on the HTTP crates this uses. `ProverStep` and the `F: Fn(ProverStep)` parameter go too. Four call sites fed it inside the library, and both callers in the workspace passed `|_| {}` -- a generic parameter threaded through two public functions to feed nothing. Signed-off-by: SupremaLex --- crates/libid-tlsn/src/lib.rs | 13 +++- crates/libid-tlsn/src/session.rs | 125 ++++++++++--------------------- 2 files changed, 49 insertions(+), 89 deletions(-) diff --git a/crates/libid-tlsn/src/lib.rs b/crates/libid-tlsn/src/lib.rs index 8dd18842..dd961ebb 100644 --- a/crates/libid-tlsn/src/lib.rs +++ b/crates/libid-tlsn/src/lib.rs @@ -60,15 +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, ProverResult, - ProverStep, UserInfoParams, VerifierResult, MAX_RECV_DATA, diff --git a/crates/libid-tlsn/src/session.rs b/crates/libid-tlsn/src/session.rs index bb364445..ac265d51 100644 --- a/crates/libid-tlsn/src/session.rs +++ b/crates/libid-tlsn/src/session.rs @@ -139,19 +139,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 { @@ -216,25 +203,6 @@ 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> { @@ -256,28 +224,38 @@ pub struct UserInfoParams<'a> { /// 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( +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; + // The headers this flow sends, stated here rather than injected by the + // library. A notarized request is bytes a verifier compares against a + // profile, so whoever knows the profile writes them. + let request = hyper::Request::builder() + .method("GET") + .uri(format!( + "https://{}{}", + params.api_host, params.user_info_path + )) + .header("Host", params.api_host) + .header("Connection", "close") + .header("Accept", "application/json") + .header("User-Agent", params.user_agent) + .header("Authorization", format!("Bearer {access_token}")) + .body(http_body_util::Full::new(Bytes::new())) + .map_err(|e| Error::MpcTlsFailed { + detail: format!("request build: {e}"), + })?; + 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, - }, + request, // This flow predates the ceremony layouts and still selects the old // sparse ranges: the request line and `Host` revealed, everything else // of the response committed whole. It does NOT tile, so what it @@ -313,7 +291,6 @@ where }, )) }, - on_progress, ) .await } @@ -336,19 +313,28 @@ where /// Each revealed range becomes a separate Merkle leaf in the notary's /// transcript tree. [`libid_transcript::compute_field_reveal_range`] and /// friends locate JSON field values in a response body. -#[instrument(skip_all, fields(api_host = request.api_host))] -pub async fn prover_generic( +#[instrument(skip_all)] +pub async fn prover_generic( socket: T, - request: &HttpRequestSpec<'_>, + request: hyper::Request>, select_layout: S, - on_progress: F, ) -> Result> where T: AsyncWrite + AsyncRead + Send + Unpin + 'static, - F: Fn(ProverStep), S: FnOnce(&[u8], &[u8]) -> Result<(Layout, Layout)>, { - 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(); let session = Session::new(socket.compat()); let (driver, mut handle) = session.split(); @@ -380,7 +366,6 @@ where .map_err(|e| Error::MpcTlsFailed { detail: format!("commit: {e}"), })?; - on_progress(ProverStep::MpcSetupComplete); info!("Connecting to {} API", api_host); let tcp = tokio::net::TcpStream::connect(format!("{}:443", api_host)).await?; @@ -402,7 +387,6 @@ where .map_err(|e| Error::MpcTlsFailed { detail: format!("connect: {e}"), })?; - on_progress(ProverStep::TlsHandshakeComplete); let prover_task = AbortOnDrop::new(tokio::spawn(prover.into_future())); let (mut sender, conn) = @@ -415,41 +399,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}"), @@ -473,7 +426,6 @@ where }); } info!("Response: {} bytes", body.len()); - on_progress(ProverStep::PlatformDataFetched); let mut prover = prover_task .into_inner() @@ -556,7 +508,6 @@ where detail: format!("prove: {e}"), })?; info!("MPC-TLS proof complete"); - on_progress(ProverStep::MpcProofFinalized); let tls_transcript = prover.tls_transcript().clone(); let handshake = extract_handshake_data(&tls_transcript)?; From 9dd30508e55176173d857fd3fc51c1039fba3164 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Mon, 24 Aug 2026 17:38:49 +0300 Subject: [PATCH 18/65] fix!: a malformed chunked body is an error, not a short one `decode_chunked_body` swallowed everything. A chunk header that was not hexadecimal parsed as `unwrap_or(0)`, which the loop read as the terminating zero chunk and returned what it had. A chunk shorter than its declared size hit `break`. A missing terminator, the same. Every one of those produced a short body and no error. That body is not incidental: `compute_field_snippet_range` and `compute_id_snippet_range` read it, and the ceremony layouts compute their reveal ranges over what they find. A silent truncation therefore has the prover select ranges over bytes the server never sent, and the notary sign that selection, with nobody in a position to notice. `httparse::parse_chunk_size` replaces the hand-rolled size parsing. It has the three answers this needs -- complete, partial, malformed -- where the previous code had one. The rest is the framing around it: a chunk shorter than declared, an absent terminator, and one that is not CRLF are each their own error now. Two tests, both failing before the change: a body whose second chunk header is `zz`, and one whose chunk declares twenty bytes and carries five. Verified the guard is what closes them by putting the swallow back and watching the first fail again. Signed-off-by: SupremaLex --- Cargo.lock | 1 + Cargo.toml | 3 + crates/libid-transcript/Cargo.toml | 1 + crates/libid-transcript/src/ranges.rs | 96 +++++++++++++++++---------- 4 files changed, 66 insertions(+), 35 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5909379c..f1638121 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3439,6 +3439,7 @@ dependencies = [ name = "libid-transcript" version = "0.3.0" dependencies = [ + "httparse", "serde", "serde_json", "thiserror 2.0.20", diff --git a/Cargo.toml b/Cargo.toml index 9f6bc9e6..0936583c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,6 +40,9 @@ base64 = "0.22" # in a patch release would change what every notary signs. bincode = { version = "=2.0.1", features = ["derive"] } hex = "0.4" +# 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"] } diff --git a/crates/libid-transcript/Cargo.toml b/crates/libid-transcript/Cargo.toml index b7697c41..3c2a9f47 100644 --- a/crates/libid-transcript/Cargo.toml +++ b/crates/libid-transcript/Cargo.toml @@ -17,6 +17,7 @@ default = [] ts = ["dep:ts-rs"] [dependencies] +httparse.workspace = true serde.workspace = true serde_json.workspace = true thiserror.workspace = true diff --git a/crates/libid-transcript/src/ranges.rs b/crates/libid-transcript/src/ranges.rs index 3c820c1d..5971a0f6 100644 --- a/crates/libid-transcript/src/ranges.rs +++ b/crates/libid-transcript/src/ranges.rs @@ -65,51 +65,57 @@ 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. @@ -359,6 +365,26 @@ pub fn compute_id_snippet_range( #[cfg(test)] mod tests { + /// 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 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 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] From 91e1a1e4687cba9a19c99615ff539cdf901fcff4 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Mon, 24 Aug 2026 18:11:23 +0300 Subject: [PATCH 19/65] fix: restore the two phase signals the callback removal took with it Dropping `Fn(ProverStep)` was right about the API and careless about what it carried. Two of its four phases were also `info!` lines a line away -- "Response: N bytes" and "MPC-TLS proof complete" -- so removing the callback cost nothing there. Two were not: `MpcSetupComplete` and `TlsHandshakeComplete` had no log of their own, and the nearest ones announce those phases STARTING rather than finishing. Those are the slow parts of an MPC session, so losing them lost the two moments a caller most wants to see. They are `tracing` events now, inside this function's span, alongside the two that were already there. The doc says why the callback went and when it should come back. `tracing` is already a dependency and needs no parameter threaded through the signature, but log text is not an interface: a caller driving something typed off these phases -- a progress bar rather than a log line -- wants the callback, and should have it back rather than parse messages. Signed-off-by: SupremaLex --- crates/libid-tlsn/src/session.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/crates/libid-tlsn/src/session.rs b/crates/libid-tlsn/src/session.rs index ac265d51..5fecb532 100644 --- a/crates/libid-tlsn/src/session.rs +++ b/crates/libid-tlsn/src/session.rs @@ -313,6 +313,20 @@ where /// Each revealed range becomes a separate Merkle leaf in the notary's /// transcript tree. [`libid_transcript::compute_field_reveal_range`] and /// friends locate JSON field values in a response body. +/// +/// # Following a session +/// +/// This is slow and worth watching, so every phase boundary is a `tracing` +/// event inside this function's span: MPC-TLS setup complete, TLS handshake +/// complete, response received, proof complete. A caller that wants to show +/// progress subscribes to them. +/// +/// There used to be a typed `Fn(ProverStep)` callback instead, and it was +/// removed because both callers in the workspace passed `|_| {}`. `tracing` is +/// already a dependency and needs no parameter threaded through the signature. +/// If a caller ever needs to drive something typed off these phases -- a +/// progress bar rather than a log -- the callback is the better answer and +/// should come back; log text is not an interface. #[instrument(skip_all)] pub async fn prover_generic( socket: T, @@ -366,6 +380,7 @@ where .map_err(|e| Error::MpcTlsFailed { detail: format!("commit: {e}"), })?; + info!("MPC-TLS setup complete"); info!("Connecting to {} API", api_host); let tcp = tokio::net::TcpStream::connect(format!("{}:443", api_host)).await?; @@ -387,6 +402,7 @@ where .map_err(|e| Error::MpcTlsFailed { detail: format!("connect: {e}"), })?; + info!("TLS handshake complete"); let prover_task = AbortOnDrop::new(tokio::spawn(prover.into_future())); let (mut sender, conn) = From c7c59d679e3707d061935299f33fe316bd928820 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Mon, 24 Aug 2026 18:15:23 +0300 Subject: [PATCH 20/65] feat: report prover phases again, typed `ProverStep` and `on_progress` come back on `prover_generic`. Removing them was right about what existed and wrong about what is coming. What existed: both callers passed `|_| {}`, and the browser's progress -- the one a user actually sees -- comes from the tlsn wasm prover's own `set_progress_callback`, which never reaches this function. libid-rs compiles to no wasm, so it could not have. What is coming: the GitHub Token-Exchange Service notarizes on the browser's behalf, and its HTTP caller waits out the whole session. Reporting phases to that caller means a typed value, not `tracing` lines it would have to parse. The four boundaries are already known and already placed; rebuilding them later would be rediscovering them. `ProverStep` is ordered and carries `fraction()`, because a caller showing progress wants a position and the wasm prover's own callback already gives one. It is a position and not a time estimate -- setup and proving dominate. Both audiences are served at each boundary now: a `tracing` event for whoever reads logs, the callback for whoever drives something off it. The legacy `/me` wrapper stubs it internally rather than making its callers carry a parameter for a flow that goes at cutover. Signed-off-by: SupremaLex --- crates/libid-tlsn/src/lib.rs | 1 + crates/libid-tlsn/src/session.rs | 62 ++++++++++++++++++++++++++------ 2 files changed, 52 insertions(+), 11 deletions(-) diff --git a/crates/libid-tlsn/src/lib.rs b/crates/libid-tlsn/src/lib.rs index dd961ebb..854d4b53 100644 --- a/crates/libid-tlsn/src/lib.rs +++ b/crates/libid-tlsn/src/lib.rs @@ -78,6 +78,7 @@ pub use session::{ root_store, verifier, ProverResult, + ProverStep, UserInfoParams, VerifierResult, MAX_RECV_DATA, diff --git a/crates/libid-tlsn/src/session.rs b/crates/libid-tlsn/src/session.rs index 5fecb532..004fb9e7 100644 --- a/crates/libid-tlsn/src/session.rs +++ b/crates/libid-tlsn/src/session.rs @@ -171,6 +171,39 @@ pub fn extract_handshake_data( }) } +/// 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, + } + } +} + /// Result from the MPC-TLS prover. pub struct ProverResult { /// The HTTP response body from the platform API (decoded, headers stripped). @@ -291,6 +324,8 @@ where }, )) }, + // This flow goes at cutover and nothing watches it run. + |_| {}, ) .await } @@ -316,26 +351,27 @@ where /// /// # Following a session /// -/// This is slow and worth watching, so every phase boundary is a `tracing` -/// event inside this function's span: MPC-TLS setup complete, TLS handshake -/// complete, response received, proof complete. A caller that wants to show -/// progress subscribes to them. +/// 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. /// -/// There used to be a typed `Fn(ProverStep)` callback instead, and it was -/// removed because both callers in the workspace passed `|_| {}`. `tracing` is -/// already a dependency and needs no parameter threaded through the signature. -/// If a caller ever needs to drive something typed off these phases -- a -/// progress bar rather than a log -- the callback is the better answer and -/// should come back; log text is not an interface. +/// 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. #[instrument(skip_all)] -pub async fn prover_generic( +pub async fn prover_generic( socket: T, 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), { // 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. @@ -381,6 +417,7 @@ where detail: format!("commit: {e}"), })?; info!("MPC-TLS setup complete"); + on_progress(ProverStep::MpcSetupComplete); info!("Connecting to {} API", api_host); let tcp = tokio::net::TcpStream::connect(format!("{}:443", api_host)).await?; @@ -403,6 +440,7 @@ where detail: format!("connect: {e}"), })?; info!("TLS handshake complete"); + on_progress(ProverStep::TlsHandshakeComplete); let prover_task = AbortOnDrop::new(tokio::spawn(prover.into_future())); let (mut sender, conn) = @@ -442,6 +480,7 @@ where }); } info!("Response: {} bytes", body.len()); + on_progress(ProverStep::PlatformDataFetched); let mut prover = prover_task .into_inner() @@ -524,6 +563,7 @@ where detail: format!("prove: {e}"), })?; info!("MPC-TLS proof complete"); + on_progress(ProverStep::MpcProofFinalized); let tls_transcript = prover.tls_transcript().clone(); let handshake = extract_handshake_data(&tls_transcript)?; From 0a1c2edafa620479d190a9f7557de0a91c4a2b15 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Tue, 25 Aug 2026 12:15:56 +0300 Subject: [PATCH 21/65] feat!: reveal the status line in every response layout Neither response layout revealed offset zero, so no verifier could tell a `200` from a `403`. Consent was inferred from the fields the layout looks for happening to be present -- which is an argument about what an error body does not contain, not a check. The circuit sees no HTTP at all, so the layouts are the only place the fact can be made available. Both layouts now reveal the status line, from the origin to its CRLF. Anchored there so the verifier reads a status line rather than bytes that look like one, and a response with no CRLF is refused outright rather than silently omitting it -- an attestation missing the range is one the verifier rejects for a reason the prover cannot see. The layouts still tile: the commitments are the complement, so the CRLF and the headers after it stay committed. Two tests, plus the two existing layout tests updated to the new shape. 89 tests. Signed-off-by: SupremaLex --- crates/libid-transcript/src/ceremony.rs | 77 ++++++++++++++++++++++--- 1 file changed, 70 insertions(+), 7 deletions(-) diff --git a/crates/libid-transcript/src/ceremony.rs b/crates/libid-transcript/src/ceremony.rs index 119fe686..547cf85a 100644 --- a/crates/libid-transcript/src/ceremony.rs +++ b/crates/libid-transcript/src/ceremony.rs @@ -43,6 +43,8 @@ pub enum LayoutError { NoHeadBoundary, #[error("the credential to commit was not found in the request body")] MissingCredential, + #[error("the response has no status line, so nothing says the server agreed")] + NoStatusLine, } /// The bytes of `[0, len)` that `reveal` does not cover. @@ -107,6 +109,23 @@ pub fn token_request( /// 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). +/// The status line, from the origin to its CRLF. +/// +/// Revealed in every response layout so the Platform Verifier can see that the +/// server agreed. Without it, consent is inferred from the wanted fields +/// happening to be present -- which is an argument about what an error body +/// does not contain, not a check. +/// +/// Anchored at offset zero, so the verifier reads it as the status line rather +/// than as bytes that look like one. +fn status_line(recv: &[u8]) -> Result, LayoutError> { + let end = recv + .windows(2) + .position(|w| w == b"\r\n") + .ok_or(LayoutError::NoStatusLine)?; + Ok(0..end) +} + pub fn token_response(recv: &[u8]) -> Result { const ANCHOR: &[u8] = b"\"access_token\":\""; let anchor_start = recv @@ -121,7 +140,11 @@ pub fn token_response(recv: &[u8]) -> Result { .ok_or_else(|| LayoutError::MissingField("access_token".into()))?; Ok(layout( - vec![anchor_start..value_start, value_end..value_end + 1], + vec![ + status_line(recv)?, + anchor_start..value_start, + value_end..value_end + 1, + ], recv.len(), )) } @@ -181,8 +204,9 @@ pub fn identity_response( let handle = compute_field_snippet_range(recv, handle_field) .ok_or_else(|| LayoutError::MissingField(handle_field.into()))?; - // JSON member order is not fixed, so sort rather than assume. - let mut reveal = vec![id, handle]; + // JSON member order is not fixed, so sort rather than assume. The status + // line is first by construction, but sorting covers it too. + let mut reveal = vec![status_line(recv)?, id, handle]; reveal.sort_by_key(|r| r.start); Ok(layout(reveal, recv.len())) } @@ -240,6 +264,37 @@ mod tests { ); } + /// Consent is a fact about the response, not an inference from what an + /// error body happens not to contain. Both layouts reveal it, at offset + /// zero, so the verifier reads a status line rather than bytes that look + /// like one. + #[test] + fn every_response_layout_reveals_the_status_line_at_the_origin() { + let token: &[u8] = b"HTTP/1.1 200 OK\r\n\r\n{\"access_token\":\"X\"}"; + let identity: &[u8] = b"HTTP/1.1 200 OK\r\n\r\n{\"id\":\"1\",\"username\":\"a\"}"; + + for l in [ + token_response(token).unwrap(), + identity_response(identity, "id", IdShape::JsonString, "username").unwrap(), + ] { + assert_eq!(l.reveal[0].start, 0); + assert_eq!(l.reveal[0].end, 15); + } + } + + /// A response with no CRLF has no status line to reveal, and a layout that + /// silently omitted it would produce an attestation the verifier refuses + /// for a reason the prover cannot see. + #[test] + fn a_response_without_a_status_line_is_refused() { + // Carries the field, so it gets past the anchor search and fails on + // the thing under test rather than before it. + assert_eq!( + token_response(b"{\"access_token\":\"X\"}").unwrap_err(), + LayoutError::NoStatusLine + ); + } + #[test] fn the_token_response_reveals_only_the_two_anchors() { let recv: &[u8] = @@ -248,9 +303,13 @@ mod tests { assert!(tiles(&l, recv.len())); assert_eq!( recv[l.reveal[0].clone()].to_vec(), + b"HTTP/1.1 200 OK".to_vec() + ); + assert_eq!( + recv[l.reveal[1].clone()].to_vec(), b"\"access_token\":\"".to_vec() ); - assert_eq!(recv[l.reveal[1].clone()].to_vec(), b"\"".to_vec()); + assert_eq!(recv[l.reveal[2].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")); } @@ -281,15 +340,19 @@ mod tests { 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 = identity_response(recv, "id", IdShape::JsonString, "username").unwrap(); assert!(tiles(&l, recv.len())); - assert_eq!(l.reveal.len(), 2); + assert_eq!(l.reveal.len(), 3); + assert_eq!( + recv[l.reveal[0].clone()].to_vec(), + b"HTTP/1.1 200 OK".to_vec() + ); // 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(), + recv[l.reveal[1].clone()].to_vec(), b"\"id\":\"2244994945\"".to_vec() ); assert_eq!( - recv[l.reveal[1].clone()].to_vec(), + recv[l.reveal[2].clone()].to_vec(), b"\"username\":\"alice\"".to_vec() ); // The display name stays committed. From f137dacb87efbeb73cd4279413c47455c8799a11 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Tue, 25 Aug 2026 12:41:48 +0300 Subject: [PATCH 22/65] revert: do not reveal the status line I added it an hour ago arguing that consent was otherwise inferred from the wanted fields happening to be present. The argument does not survive contact with what an attacker would gain. For an error response to pass, the platform's error body would have to carry `"id":""` and `"username":""` with their full delimiters, each exactly once, at a request whose line is `GET /2/users/me ` to a cert-authenticated `api.x.com`. That is precisely what ASM-PROV-06 assumes away -- the profile chose those delimiters as its anchors -- and an error body carrying them would be a platform defect, not an attack. The token response is narrower still: an error yields no bearer, and the circuit requires the same bearer to open both sessions' commitments, so a refused token exchange cannot reach the chain at all. So it bought nothing, cost fifteen revealed bytes in every session, and diverged from a specification that lists the status line under "everything else | no" in all three reveal tables. The specification following the implementation is the agreed direction, but it should follow something that carries its weight. The tiling half of that work stays: the specification does require it, in the same tables -- "every committed range of this direction is bounded by a revealed delimiter on each side that faces one, and by the signed transcript boundary at the two ends". Signed-off-by: SupremaLex --- crates/libid-transcript/src/ceremony.rs | 77 +++---------------------- 1 file changed, 7 insertions(+), 70 deletions(-) diff --git a/crates/libid-transcript/src/ceremony.rs b/crates/libid-transcript/src/ceremony.rs index 547cf85a..119fe686 100644 --- a/crates/libid-transcript/src/ceremony.rs +++ b/crates/libid-transcript/src/ceremony.rs @@ -43,8 +43,6 @@ pub enum LayoutError { NoHeadBoundary, #[error("the credential to commit was not found in the request body")] MissingCredential, - #[error("the response has no status line, so nothing says the server agreed")] - NoStatusLine, } /// The bytes of `[0, len)` that `reveal` does not cover. @@ -109,23 +107,6 @@ pub fn token_request( /// 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). -/// The status line, from the origin to its CRLF. -/// -/// Revealed in every response layout so the Platform Verifier can see that the -/// server agreed. Without it, consent is inferred from the wanted fields -/// happening to be present -- which is an argument about what an error body -/// does not contain, not a check. -/// -/// Anchored at offset zero, so the verifier reads it as the status line rather -/// than as bytes that look like one. -fn status_line(recv: &[u8]) -> Result, LayoutError> { - let end = recv - .windows(2) - .position(|w| w == b"\r\n") - .ok_or(LayoutError::NoStatusLine)?; - Ok(0..end) -} - pub fn token_response(recv: &[u8]) -> Result { const ANCHOR: &[u8] = b"\"access_token\":\""; let anchor_start = recv @@ -140,11 +121,7 @@ pub fn token_response(recv: &[u8]) -> Result { .ok_or_else(|| LayoutError::MissingField("access_token".into()))?; Ok(layout( - vec![ - status_line(recv)?, - anchor_start..value_start, - value_end..value_end + 1, - ], + vec![anchor_start..value_start, value_end..value_end + 1], recv.len(), )) } @@ -204,9 +181,8 @@ pub fn identity_response( let handle = compute_field_snippet_range(recv, handle_field) .ok_or_else(|| LayoutError::MissingField(handle_field.into()))?; - // JSON member order is not fixed, so sort rather than assume. The status - // line is first by construction, but sorting covers it too. - let mut reveal = vec![status_line(recv)?, id, handle]; + // JSON member order is not fixed, so sort rather than assume. + let mut reveal = vec![id, handle]; reveal.sort_by_key(|r| r.start); Ok(layout(reveal, recv.len())) } @@ -264,37 +240,6 @@ mod tests { ); } - /// Consent is a fact about the response, not an inference from what an - /// error body happens not to contain. Both layouts reveal it, at offset - /// zero, so the verifier reads a status line rather than bytes that look - /// like one. - #[test] - fn every_response_layout_reveals_the_status_line_at_the_origin() { - let token: &[u8] = b"HTTP/1.1 200 OK\r\n\r\n{\"access_token\":\"X\"}"; - let identity: &[u8] = b"HTTP/1.1 200 OK\r\n\r\n{\"id\":\"1\",\"username\":\"a\"}"; - - for l in [ - token_response(token).unwrap(), - identity_response(identity, "id", IdShape::JsonString, "username").unwrap(), - ] { - assert_eq!(l.reveal[0].start, 0); - assert_eq!(l.reveal[0].end, 15); - } - } - - /// A response with no CRLF has no status line to reveal, and a layout that - /// silently omitted it would produce an attestation the verifier refuses - /// for a reason the prover cannot see. - #[test] - fn a_response_without_a_status_line_is_refused() { - // Carries the field, so it gets past the anchor search and fails on - // the thing under test rather than before it. - assert_eq!( - token_response(b"{\"access_token\":\"X\"}").unwrap_err(), - LayoutError::NoStatusLine - ); - } - #[test] fn the_token_response_reveals_only_the_two_anchors() { let recv: &[u8] = @@ -303,13 +248,9 @@ mod tests { assert!(tiles(&l, recv.len())); assert_eq!( recv[l.reveal[0].clone()].to_vec(), - b"HTTP/1.1 200 OK".to_vec() - ); - assert_eq!( - recv[l.reveal[1].clone()].to_vec(), b"\"access_token\":\"".to_vec() ); - assert_eq!(recv[l.reveal[2].clone()].to_vec(), b"\"".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")); } @@ -340,19 +281,15 @@ mod tests { 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 = identity_response(recv, "id", IdShape::JsonString, "username").unwrap(); assert!(tiles(&l, recv.len())); - assert_eq!(l.reveal.len(), 3); - assert_eq!( - recv[l.reveal[0].clone()].to_vec(), - b"HTTP/1.1 200 OK".to_vec() - ); + 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[1].clone()].to_vec(), + recv[l.reveal[0].clone()].to_vec(), b"\"id\":\"2244994945\"".to_vec() ); assert_eq!( - recv[l.reveal[2].clone()].to_vec(), + recv[l.reveal[1].clone()].to_vec(), b"\"username\":\"alice\"".to_vec() ); // The display name stays committed. From f53428f3b563743739fcb80c1f5daa57481144ca Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Tue, 25 Aug 2026 22:07:23 +0300 Subject: [PATCH 23/65] refactor!: drop what the notary stopped reading The notary answers a session with the section 9.1 record and nothing else. It reads no attestation request and builds no Merkle tree, so two fields of `ProverResult` had no reader left: * `request` -- the tlsn attestation request. `build` still runs, because it is also what produces `secrets`, but the request it returns goes nowhere. * `recv_segments` -- saved so a caller could feed the notary's `recv:` Merkle leaves. There are no such leaves. `EvmProof` and `NotaryResponse` go with them. They described the pre-ceremony wire: a Merkle transcript root bound to a chain id and a `verifyingContract`, signed beside tlsn's own attestation. A ceremony attestation binds neither -- it describes an observed session and says nothing about where the evidence is spent -- so the record that carried them has no shape left to hold. `TlsHandshakeData` stays: the randoms and the server's ephemeral key are observations, not derivations. BREAKING for a caller that sends `prover_result.request` and reads a `NotaryResponse` back. `identity-backend` and `libid-server-rs` both do, and both pin `v0.1.0` -- they are unaffected until they move, and moving means adopting the ceremony wire on purpose rather than by a version bump. Signed-off-by: SupremaLex --- crates/libid-tlsn/src/session.rs | 25 ++--- crates/libid-transcript/src/lib.rs | 8 +- crates/libid-transcript/src/types.rs | 141 +-------------------------- 3 files changed, 10 insertions(+), 164 deletions(-) diff --git a/crates/libid-tlsn/src/session.rs b/crates/libid-tlsn/src/session.rs index 004fb9e7..a141af7e 100644 --- a/crates/libid-tlsn/src/session.rs +++ b/crates/libid-tlsn/src/session.rs @@ -208,12 +208,6 @@ impl ProverStep { 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. @@ -505,14 +499,6 @@ where let reveal_recv_ranges = recv_layout.reveal.clone(); - // 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 notary_sent_ranges = sent_layout.reveal.clone(); let mut tc_builder = TranscriptCommitConfig::builder(&transcript); @@ -608,7 +594,10 @@ where prover_output.transcript_secrets, prover_output.transcript_commitments, ); - let (att_request, secrets) = req_builder + // The request itself goes nowhere: the notary answers a session with the + // section 9.1 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}"), @@ -620,7 +609,7 @@ where })?; handle.close(); - Ok((body, recv_segments, att_request, secrets, handshake)) + Ok((body, secrets, handshake)) }; tokio::pin!(setup); @@ -628,7 +617,7 @@ 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 (body, secrets, handshake) = tokio::select! { biased; res = &mut setup => res?, driver_res = driver_task.handle_mut() => { @@ -649,8 +638,6 @@ where Ok(ProverResult { response_body: body.to_vec(), - recv_segments, - request: att_request, secrets, handshake, recovered_io, diff --git a/crates/libid-transcript/src/lib.rs b/crates/libid-transcript/src/lib.rs index 4f02ffe9..f16f784f 100644 --- a/crates/libid-transcript/src/lib.rs +++ b/crates/libid-transcript/src/lib.rs @@ -11,7 +11,7 @@ //! offsets that become Merkle leaves. //! * [`wire`] — the length-prefixed JSON protocol the notary and prover speak //! over the recovered socket after MPC-TLS closes. -//! * [`types`] — [`EvmProof`], [`NotaryResponse`] and [`TlsHandshakeData`], +//! * [`types`] — [`TlsHandshakeData`], //! the notary's output as consumed by backends and on-chain verifiers. pub mod ceremony; @@ -35,11 +35,7 @@ pub use ranges::{ find_request_line_range, find_response_body_range, }; -pub use types::{ - EvmProof, - NotaryResponse, - TlsHandshakeData, -}; +pub use types::TlsHandshakeData; pub use wire::{ read_msg, write_msg, diff --git a/crates/libid-transcript/src/types.rs b/crates/libid-transcript/src/types.rs index 3a31d398..88da0ce5 100644 --- a/crates/libid-transcript/src/types.rs +++ b/crates/libid-transcript/src/types.rs @@ -1,15 +1,6 @@ -//! 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. +//! What a prover reads off a completed TLS session, beyond the transcript. -use serde::{ - Deserialize, - Serialize, -}; - -/// TLS handshake data for EVM proof construction. +/// TLS handshake data: the randoms and the server's ephemeral key. #[derive(Debug, Clone, PartialEq, Eq)] pub struct TlsHandshakeData { /// TLS client random (32 bytes). @@ -19,131 +10,3 @@ pub struct TlsHandshakeData { /// 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); - } -} From 239a4bb426ac72591fe30006f22660e164a98d96 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Tue, 25 Aug 2026 22:26:59 +0300 Subject: [PATCH 24/65] fix!: the identity response reveals everything, and a test says why The layout revealed the two identity members and committed the rest. The verifier requires the opposite -- zero commitments -- and it is right. 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 -- one member echoed out of a profile string the account controls -- lets a prover commit the real member and reveal the one it composed. Both checks then see exactly one, and the handle bound is the prover's rather than the account's. Uniqueness over a document cannot be established from part of it. Nothing is lost by revealing it whole: the response is the account's own public profile, and the credential that fetched it is in the request direction. The arguments stay and stay checked. A response missing either member fails here rather than at the verifier, where the reason would be an offset rather than a name. ### The test that should have caught this `tests/ceremony_end_to_end.rs` drives the join nothing covered: the layouts pick the ranges, `attest::attested_data` turns the session into the section 9.1 record, and the assertions are the rules the Solidity Platform Verifier applies to it -- coverage, the framing bytes, the line-anchored header count over the concatenation, one revealed sent range at the origin, one head boundary, and the response hiding nothing. Every piece here had tests. The JOIN had none, so a layout could be internally consistent, encode cleanly, and still be refused on chain -- which is exactly what happened. Put the old layout back and `the_identity_session_produces_a_record_the_verifier_accepts` fails on the commitment. Four cases: both X sessions, the GitHub exchange whose request commits a suffix, and the encoding both must survive. Signed-off-by: SupremaLex --- .../libid-tlsn/tests/ceremony_end_to_end.rs | 291 ++++++++++++++++++ crates/libid-transcript/src/ceremony.rs | 77 +++-- 2 files changed, 342 insertions(+), 26 deletions(-) create mode 100644 crates/libid-tlsn/tests/ceremony_end_to_end.rs 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..ee8d6f09 --- /dev/null +++ b/crates/libid-tlsn/tests/ceremony_end_to_end.rs @@ -0,0 +1,291 @@ +//! 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 section 9.1 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::{ + attested_data, + AttestationInput, +}; +use libid_transcript::ceremony::{ + self, + IdShape, + 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=iMSTNh6gQkRnBGlY1c0MUOsD7MCO4G8C7ph1_gIZs5I"; +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 what a notary's verifier hands `attested_data`. +/// +/// 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), + })); + } + + attested_data( + &partial, + "api.x.com", + &commitments, + AttestationInput { 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 = ceremony::token_request(TOKEN_SENT, None).unwrap(); + let rl = ceremony::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 = ceremony::identity_request(ID_SENT).unwrap(); + let rl = ceremony::identity_response(ID_RECV, "id", IdShape::JsonString, "username") + .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); + + // `requireFullyRevealed`: the response hides nothing, so the duplicate + // scan below can see the whole document. + assert!( + data.received.commitments.is_empty(), + "a commitment here would hide a duplicate member from every reader" + ); + + // And what that buys: each identity member appears exactly once in bytes + // the verifier can read. + let body = joined(&data.received); + assert_eq!(count(&body, b"\"id\":\""), 1); + assert_eq!(count(&body, b"\"username\":\""), 1); +} + +/// 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, + ceremony::token_request(TOKEN_SENT, None).unwrap(), + ceremony::token_response(TOKEN_RECV).unwrap(), + ), + ( + ID_SENT, + ID_RECV, + ceremony::identity_request(ID_SENT).unwrap(), + ceremony::identity_response(ID_RECV, "id", IdShape::JsonString, "username") + .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 = ceremony::token_request(SENT, Some("client_secret")).unwrap(); + let rl = ceremony::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/src/ceremony.rs b/crates/libid-transcript/src/ceremony.rs index 119fe686..6e3bac42 100644 --- a/crates/libid-transcript/src/ceremony.rs +++ b/crates/libid-transcript/src/ceremony.rs @@ -161,13 +161,28 @@ pub enum IdShape { JsonInteger, } -/// The identity response: the two identity members with their full delimiters, -/// and nothing else. +/// The identity response: revealed WHOLE, hiding nothing. /// -/// 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. +/// This one has no choice to make, and the reason is worth stating because the +/// obvious layout -- reveal the two identity members, commit the rest -- is +/// unsafe. +/// +/// 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 +/// -- one member echoed out of a profile string the account controls -- lets a +/// prover commit the real member and reveal the one it composed. Both checks +/// then see exactly one, and the handle bound is the prover's rather than the +/// account's. +/// +/// Uniqueness over a document cannot be established from part of it. So the +/// verifier requires zero commitments here (`requireFullyRevealed`), and this +/// produces zero. Nothing is lost: the response is the account's own public +/// profile, and the credential that fetched it is in the REQUEST direction. +/// +/// 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], id_field: &str, @@ -176,15 +191,12 @@ pub fn identity_response( ) -> Result { // The bare-integer form takes its structural terminator with it, which is // what proves the revealed digits are the whole number. - let id = compute_id_snippet_range(recv, id_field, id_shape == IdShape::JsonString) + compute_id_snippet_range(recv, id_field, id_shape == IdShape::JsonString) .ok_or_else(|| LayoutError::MissingField(id_field.into()))?; - let handle = compute_field_snippet_range(recv, handle_field) + compute_field_snippet_range(recv, handle_field) .ok_or_else(|| LayoutError::MissingField(handle_field.into()))?; - // JSON member order is not fixed, so sort rather than assume. - let mut reveal = vec![id, handle]; - reveal.sort_by_key(|r| r.start); - Ok(layout(reveal, recv.len())) + Ok(layout(one(0..recv.len()), recv.len())) } #[cfg(test)] @@ -277,26 +289,39 @@ mod tests { } #[test] - fn the_x_identity_response_reveals_both_members_whole() { + fn the_identity_response_hides_nothing() { 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 = identity_response(recv, "id", IdShape::JsonString, "username").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!(l.reveal, vec![0..recv.len()]); + assert!( + l.commit.is_empty(), + "a commitment here hides a duplicate member from every reader" ); + } + + /// The attack the whole-reveal exists for, stated as the layout refusing to + /// produce the shape that admits it. + /// + /// `name` is the account's own display string. Set it to close the JSON + /// member and open another, and the signed response holds two `username` + /// members. Committing the first and revealing the second would pass a + /// per-range read and a cross-range count both. + #[test] + fn a_response_naming_a_member_twice_still_hides_nothing() { + let recv: &[u8] = b"HTTP/1.1 200 OK\r\n\r\n{\"id\":\"7\",\"name\":\"\",\"username\":\"victim\",\"username\":\"alice\"}"; + let l = identity_response(recv, "id", IdShape::JsonString, "username").unwrap(); + assert!(l.commit.is_empty()); + // Both members are in the revealed run, so the verifier's duplicate + // check has something to fire on. + let revealed = &recv[l.reveal[0].clone()]; assert_eq!( - recv[l.reveal[1].clone()].to_vec(), - b"\"username\":\"alice\"".to_vec() + revealed + .windows(11) + .filter(|w| *w == b"\"username\":") + .count(), + 2 ); - // The display name stays committed. - assert!(l - .commit - .iter() - .any(|c| recv[c.clone()].windows(4).any(|w| w == b"name"))); } #[test] From e8b0dd2c2049234f55266ebea861546a3960ab85 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Wed, 26 Aug 2026 13:17:26 +0300 Subject: [PATCH 25/65] fix!: the token service returns an attestation, and the exact bytes around it Three things the response record got wrong, all found in review of PR #2. `tokenAttestation` was `Vec` documented as attested data alone. REQ-PLAT-38 has the service return the attestation, and attestation.rs opens by saying what one is: "a byte string and a signature over it". The record had nowhere to put the signature, so a browser that received it held bytes no verifier could check. It is now `TokenAttestation { attested_data, signature }`. `bearerOpening` was capped at 256 bytes. The opening is not a bounded string -- it is the blinder the Proving Circuit opens, and the circuit opens sixteen bytes. A cap accepts 15, 17 and 256, and the circuit accepts none of them, so the check is now exact. `MAX_BEARER_OPENING_BYTES` becomes `BEARER_OPENING_LEN`. The signature is exact for the same reason: 65 bytes or no recovery. `accessToken` was length-checked and nothing else, so empty, whitespace and control bytes all passed. The bearer is echoed into an `Authorization` header; a control byte there is a header the platform never sees as one. `code` directly above it already had the non-empty printable-ASCII scan for exactly this reason -- the asymmetry was an oversight, and the scan is now one helper both call. ### The route moves out Section 6.3 now says its identifiers "name protocol values, not serialized field names", and hands endpoint naming, transport framing, serialization and parsing bounds to the browser and deployment specifications. `ROUTE` was this crate naming something the deployment picks, so it is gone; the implementation that serves the endpoint states it. The byte bounds stay, because a Rust service still has to enforce them, and they now cite the ceremony server contract that owns them rather than REQ-PLAT-39 and -40, which no longer exist. `V1` goes with it -- the suffix was removed from the specification, and the records are `TokenRequest` and `TokenResponse` there. BREAKING for any caller of `TokenExchangeRequestV1` / `TokenExchangeResponseV1`. Nothing consumes them yet: the module is new in this branch, and no crate in this workspace, notary, libid-server-rs or identity-backend references it. `Cargo.lock` comes along: it still recorded `libid-ceremony 0.2.0` against a workspace that bumped to 0.3.0 several commits ago, so every local cargo run regenerated the same line. Folding it in here rather than leaving it to the next person's dirty tree. Assisted-by: Claude Opus 5 Signed-off-by: SupremaLex --- Cargo.lock | 2 +- crates/libid-ceremony/src/lib.rs | 9 +- crates/libid-ceremony/src/token_exchange.rs | 231 +++++++++++++++----- 3 files changed, 189 insertions(+), 53 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f1638121..d83cbb10 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3384,7 +3384,7 @@ dependencies = [ [[package]] name = "libid-ceremony" -version = "0.2.0" +version = "0.3.0" dependencies = [ "bincode 2.0.1", "hex", diff --git a/crates/libid-ceremony/src/lib.rs b/crates/libid-ceremony/src/lib.rs index 4df7590f..185ee685 100644 --- a/crates/libid-ceremony/src/lib.rs +++ b/crates/libid-ceremony/src/lib.rs @@ -33,9 +33,12 @@ //! * [`attestation`] -- the types of ceremony-common section 9.1 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-Exchange Service's own request and -//! response records. Its validation stays, because REQ-PLAT-37 to -40 put -//! that service's input validation on that service; no contract sees it. +//! * [`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; diff --git a/crates/libid-ceremony/src/token_exchange.rs b/crates/libid-ceremony/src/token_exchange.rs index e4ba6e31..f4e7317a 100644 --- a/crates/libid-ceremony/src/token_exchange.rs +++ b/crates/libid-ceremony/src/token_exchange.rs @@ -1,4 +1,4 @@ -//! The GitHub Token-Exchange Service contract of platform-ceremonies section 6.3. +//! 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 @@ -8,15 +8,30 @@ //! 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). - -/// Fixed route on the redirect origin. -pub const ROUTE: &str = "/oauth/github/token-exchange"; +//! +//! # 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; -pub const MAX_BEARER_OPENING_BYTES: usize = 256; -pub const MAX_TOKEN_ATTESTATION_BYTES: usize = 2 * 1024 * 1024; +/// 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)] @@ -29,14 +44,30 @@ pub enum TokenExchangeError { 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, over the {MAX_BEARER_OPENING_BYTES}-byte bound" + "bearerOpening is {0} bytes, not the {BEARER_OPENING_LEN} the circuit opens" )] - BearerOpeningTooLong(usize), - #[error("tokenAttestation is {0} bytes, over the {MAX_TOKEN_ATTESTATION_BYTES}-byte bound")] - AttestationTooLong(usize), + 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 @@ -44,20 +75,35 @@ pub enum TokenExchangeError { /// configuration, and accepts no caller-selected action, client, redirect, /// endpoint or return URL (REQ-PLAT-41). #[derive(Clone, Debug, PartialEq, Eq)] -pub struct TokenExchangeRequestV1 { +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 TokenExchangeResponseV1 { +pub struct TokenResponse { pub access_token: String, - /// The attested data of the notarized exchange, as bytes. - pub token_attestation: Vec, + 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 @@ -66,8 +112,9 @@ pub struct TokenExchangeResponseV1 { pub bearer_opening: Vec, } -impl TokenExchangeRequestV1 { - /// Bounded parsing, per REQ-PLAT-37 and REQ-PLAT-38. +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); @@ -75,9 +122,7 @@ impl TokenExchangeRequestV1 { if self.code.len() > MAX_CODE_BYTES { return Err(TokenExchangeError::CodeTooLong(self.code.len())); } - // Printable ASCII excludes whitespace and control characters, which is - // what REQ-PLAT-37 asks for in one test. - if let Some(i) = self.code.bytes().position(|b| !(0x21..=0x7e).contains(&b)) { + if let Some(i) = first_unprintable(&self.code) { return Err(TokenExchangeError::CodeNotPrintable(i)); } if self.code_verifier.len() != CODE_VERIFIER_LEN @@ -92,22 +137,42 @@ impl TokenExchangeRequestV1 { } } -impl TokenExchangeResponseV1 { - /// Bounded parsing, per REQ-PLAT-39. +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 self.bearer_opening.len() > MAX_BEARER_OPENING_BYTES { - return Err(TokenExchangeError::BearerOpeningTooLong( - self.bearer_opening.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.len() > MAX_TOKEN_ATTESTATION_BYTES { - return Err(TokenExchangeError::AttestationTooLong( - self.token_attestation.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(()) @@ -118,22 +183,39 @@ impl TokenExchangeResponseV1 { mod tests { use super::*; - fn request() -> TokenExchangeRequestV1 { - TokenExchangeRequestV1 { + fn request() -> TokenRequest { + TokenRequest { code: "abc123".into(), code_verifier: "iMSTNh6gQkRnBGlY1c0MUOsD7MCO4G8C7ph1_gIZs5I".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 REQ-PLAT-38, or the - // service would refuse a verifier the specification itself produces. + // 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(); } @@ -153,7 +235,7 @@ mod tests { #[test] fn refuses_whitespace_and_control_bytes_in_a_code() { for bad in ["ab cd", "ab\tcd", "ab\ncd", "ab\0cd"] { - let r = TokenExchangeRequestV1 { + let r = TokenRequest { code: bad.into(), ..request() }; @@ -173,7 +255,7 @@ mod tests { "iMSTNh6gQkRnBGlY1c0MUOsD7MCO4G8C7ph1+gIZs5I", // base64, not base64url "iMSTNh6gQkRnBGlY1c0MUOsD7MCO4G8C7ph1/gIZs5I", ] { - let r = TokenExchangeRequestV1 { + let r = TokenRequest { code_verifier: bad.into(), ..request() }; @@ -186,26 +268,77 @@ mod tests { } #[test] - fn refuses_an_over_long_response_field() { - let ok = TokenExchangeResponseV1 { - access_token: "t".into(), - token_attestation: vec![0; 10], - bearer_opening: vec![0; 16], - }; - ok.validate().unwrap(); - - let mut r = ok.clone(); - r.bearer_opening = vec![0; MAX_BEARER_OPENING_BYTES + 1]; - assert!(matches!( + 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::BearerOpeningTooLong(_)) - )); + Err(TokenExchangeError::AccessTokenTooLong( + MAX_ACCESS_TOKEN_BYTES + 1 + )) + ); + } - let mut r = ok.clone(); - r.access_token = "t".repeat(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::AccessTokenTooLong(_)) + 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" + ); + } + } } From c490e4eb365595fd9a70d2deec51dabc42956680 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Wed, 26 Aug 2026 18:11:22 +0300 Subject: [PATCH 26/65] revert!: the identity response reveals its two members, and commits the rest This puts back the layout 239a4bb replaced, because the verifier it was written to satisfy no longer demands the other one. libid-contracts now tiles the identity response instead of requiring it whole, and the reason is disclosure: `GET /user` under an OAuth client holding a `user`-family scope returns `plan`, `total_private_repos`, `owned_private_repos`, `private_gists`, `disk_usage`, `collaborators` and `two_factor_authentication` -- measured at 41 fields and 1680 bytes against 34 and 1317 without it. Revealing the response whole puts all of it on chain, permanently, for every bind, to read two members. 239a4bb argued two things. The first stands: uniqueness is a property of a document, and a commitment is invisible to every reader on the verifying side, so a response naming an authoritative field twice lets a prover commit the real member and reveal its own. The second does not: "nothing is lost, the response is the account's own public profile" is false for GitHub, and the numbers above are what it costs. The first argument survives on an assumption now stated rather than enforced. Reaching the attack 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, since a quote inside a string is written `\"` and does not match the template. No reachable construction was found against either launch profile's identity endpoint. The tests say all of it. `the_identity_response_reveals_both_members_whole` comes back; `the_display_name_beside_a_member_stays_committed` asserts what the commitments buy, which is the half that had no test before; and `a_response_naming_a_member_twice_reveals_only_one` records the case this layout cannot defend against, so the assumption is visible on the prover side too rather than living only in a contract comment. `ceremony_end_to_end.rs` moves with it: the received direction must now carry commitments, and the display name must not appear in the joined revealed bytes. BREAKING for a caller pinned to a verifier that still requires zero commitments in this direction -- that is libid-contracts before its matching change. Assisted-by: Claude Opus 5 Signed-off-by: SupremaLex --- .../libid-tlsn/tests/ceremony_end_to_end.rs | 18 ++- crates/libid-transcript/src/ceremony.rs | 116 ++++++++++++------ 2 files changed, 89 insertions(+), 45 deletions(-) diff --git a/crates/libid-tlsn/tests/ceremony_end_to_end.rs b/crates/libid-tlsn/tests/ceremony_end_to_end.rs index ee8d6f09..55ab3426 100644 --- a/crates/libid-tlsn/tests/ceremony_end_to_end.rs +++ b/crates/libid-tlsn/tests/ceremony_end_to_end.rs @@ -227,18 +227,24 @@ fn the_identity_session_produces_a_record_the_verifier_accepts() { normalized.retain(|&b| b != b' ' && b != b'\t'); assert_eq!(count(&normalized, b"\r\nauthorization:bearer"), 1); - // `requireFullyRevealed`: the response hides nothing, so the duplicate - // scan below can see the whole document. + // `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(), - "a commitment here would hide a duplicate member from every reader" + !data.received.commitments.is_empty(), + "the rest of the response must be committed, not published" ); - // And what that buys: each identity member appears exactly once in bytes - // the verifier can read. + // 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 diff --git a/crates/libid-transcript/src/ceremony.rs b/crates/libid-transcript/src/ceremony.rs index 6e3bac42..c8b68fb2 100644 --- a/crates/libid-transcript/src/ceremony.rs +++ b/crates/libid-transcript/src/ceremony.rs @@ -161,24 +161,34 @@ pub enum IdShape { JsonInteger, } -/// The identity response: revealed WHOLE, hiding nothing. +/// The identity response: the two identity members with their full delimiters, +/// and nothing else. /// -/// This one has no choice to make, and the reason is worth stating because the -/// obvious layout -- reveal the two identity members, commit the rest -- is -/// unsafe. +/// 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 -/// -- one member echoed out of a profile string the account controls -- lets a -/// prover commit the real member and reveal the one it composed. Both checks -/// then see exactly one, and the handle bound is the prover's rather than the -/// account's. +/// 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. /// -/// Uniqueness over a document cannot be established from part of it. So the -/// verifier requires zero commitments here (`requireFullyRevealed`), and this -/// produces zero. Nothing is lost: the response is the account's own public -/// profile, and the credential that fetched it is in the REQUEST direction. +/// 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. /// /// 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 @@ -191,12 +201,15 @@ pub fn identity_response( ) -> Result { // The bare-integer form takes its structural terminator with it, which is // what proves the revealed digits are the whole number. - compute_id_snippet_range(recv, id_field, id_shape == IdShape::JsonString) + let id = compute_id_snippet_range(recv, id_field, id_shape == IdShape::JsonString) .ok_or_else(|| LayoutError::MissingField(id_field.into()))?; - compute_field_snippet_range(recv, handle_field) + let handle = compute_field_snippet_range(recv, handle_field) .ok_or_else(|| LayoutError::MissingField(handle_field.into()))?; - Ok(layout(one(0..recv.len()), recv.len())) + // JSON member order is not fixed, so sort rather than assume. + let mut reveal = vec![id, handle]; + reveal.sort_by_key(|r| r.start); + Ok(layout(reveal, recv.len())) } #[cfg(test)] @@ -289,39 +302,64 @@ mod tests { } #[test] - fn the_identity_response_hides_nothing() { + 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 = identity_response(recv, "id", IdShape::JsonString, "username").unwrap(); assert!(tiles(&l, recv.len())); - assert_eq!(l.reveal, vec![0..recv.len()]); - assert!( - l.commit.is_empty(), - "a commitment here hides a duplicate member from every reader" + 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_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 = identity_response(recv, "id", IdShape::JsonString, "username").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 attack the whole-reveal exists for, stated as the layout refusing to - /// produce the shape that admits it. + /// The one duplicate this layout cannot defend against, recorded so the + /// assumption is visible on the prover side too. /// - /// `name` is the account's own display string. Set it to close the JSON - /// member and open another, and the signed response holds two `username` - /// members. Committing the first and revealing the second would pass a - /// per-range read and a cross-range count both. + /// 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_still_hides_nothing() { - let recv: &[u8] = b"HTTP/1.1 200 OK\r\n\r\n{\"id\":\"7\",\"name\":\"\",\"username\":\"victim\",\"username\":\"alice\"}"; + 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 = identity_response(recv, "id", IdShape::JsonString, "username").unwrap(); - assert!(l.commit.is_empty()); - // Both members are in the revealed run, so the verifier's duplicate - // check has something to fire on. - let revealed = &recv[l.reveal[0].clone()]; - assert_eq!( - revealed - .windows(11) - .filter(|w| *w == b"\"username\":") - .count(), - 2 - ); + 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] From dec025b4b62cc19da0807b7edcc1631d08198cdf Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Wed, 2 Sep 2026 14:50:55 +0300 Subject: [PATCH 27/65] feat(tlsn): hand back what opens the commitments a session made MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A committed range is a hash of the plaintext and a blinder. The prover is the only party that ever holds that blinder — the notary sees the commitment and never the opening, which is the whole point of committing rather than revealing. So whoever later proves something about those bytes needs it, and until now nothing could get it: `prove` returns the secrets, the attestation request builder consumes them, and `Secrets` exposes no accessor. That left a caller which commits a credential on someone else's behalf holding an attestation nobody can build a proof against. The GitHub Token-Exchange Service is exactly that caller: it commits the bearer it exchanged and must hand the opening to the browser that proves over it. The openings are taken before the secrets move into the request, which is the only point where they are still reachable. Ranges come back as plain `Range` rather than tlsn's `RangeSet`, which is not exported and would make a caller convert its own layout to compare against what it asked to commit. A secret of a kind this cannot open is refused rather than skipped. `TranscriptSecret` is `non_exhaustive`, and dropping an unrecognised one would return fewer openings than there were commitments — which the caller would discover later, somewhere the reason is no longer visible. Assisted-by: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018RSUubCFFeUqtiC64iRFVy Signed-off-by: SupremaLex --- crates/libid-tlsn/src/session.rs | 59 ++++++++++++++++++++++++++++++-- 1 file changed, 56 insertions(+), 3 deletions(-) diff --git a/crates/libid-tlsn/src/session.rs b/crates/libid-tlsn/src/session.rs index a141af7e..5aafc6f0 100644 --- a/crates/libid-tlsn/src/session.rs +++ b/crates/libid-tlsn/src/session.rs @@ -34,10 +34,12 @@ use tlsn::{ hash::HashAlgId, prover::ProverOutput, transcript::{ + Direction, PartialTranscript, TlsTranscript, TranscriptCommitConfig, TranscriptCommitment, + TranscriptSecret, }, verifier::{ VerifierCommitStart, @@ -204,6 +206,29 @@ impl ProverStep { } } +/// 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). @@ -212,6 +237,9 @@ pub struct ProverResult { pub secrets: Secrets, /// Extracted TLS handshake data. pub handshake: TlsHandshakeData, + /// One opening per commitment this session made, in the order the layouts + /// stated them. Empty when the session committed nothing. + pub commitment_openings: Vec, /// The recovered I/O stream after MPC-TLS completes. pub recovered_io: T, } @@ -591,9 +619,33 @@ where }) .transcript(transcript) .transcript_commitments( - prover_output.transcript_secrets, + prover_output.transcript_secrets.clone(), prover_output.transcript_commitments, ); + // 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 // section 9.1 record and reads no attestation request. `build` is still // what produces `secrets`, so it stays. @@ -609,7 +661,7 @@ where })?; handle.close(); - Ok((body, secrets, handshake)) + Ok((body, secrets, handshake, commitment_openings)) }; tokio::pin!(setup); @@ -617,7 +669,7 @@ 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, secrets, handshake) = tokio::select! { + let (body, secrets, handshake, commitment_openings) = tokio::select! { biased; res = &mut setup => res?, driver_res = driver_task.handle_mut() => { @@ -640,6 +692,7 @@ where response_body: body.to_vec(), secrets, handshake, + commitment_openings, recovered_io, }) } From cafd9a0b3b3c1c4a2e735a279e5904e4d0e19471 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Wed, 2 Sep 2026 15:25:04 +0300 Subject: [PATCH 28/65] refactor(transcript): give the notary's record one definition, not two The notary wrote `AttestationWire` privately and every prover mirrored it privately. Two definitions of one wire message, agreeing today because somebody typed them the same way: a renamed field fails at parse time, with a JSON error that says nothing about which side moved. So it moves beside `read_msg` and `write_msg`, which are the functions that frame it. This crate is where the notary and prover already agree on what they say to each other, and it is the only crate on that path with serde in it -- `libid-ceremony` is deliberately what the notary needs to SIGN a record and nothing more. The shape is carried over exactly as the notary emits it, byte fields included: its ProxyMode attestation endpoint already serves it to browsers, and this is not the commit to change what they receive. Two tests, because there are two contracts. One writes the record the way the notary writes it and reads it the way a prover reads it -- the only message this protocol carries in production, and nothing checked it while each side held its own copy. The other pins the JSON itself, which a Rust-to-Rust round trip cannot: both ends would change together, while the browser reading the ProxyMode endpoint would not. `libid-tlsn` also re-exports `CommitmentOpening` and `Direction`. The first was already public but reachable through no path -- a caller could read the field and not name its type. The second is what that type carries, and without it a caller that only wants to sort its own openings takes a direct dependency on tlsn, on an alpha tag, for one enum. Between them, every field a caller reads off a `ProverResult` is now nameable without tlsn; only `secrets` still hands back one, and constructing a proof from it needs tlsn regardless. Assisted-by: Claude Opus 5 Signed-off-by: SupremaLex --- crates/libid-tlsn/src/lib.rs | 11 ++++++ crates/libid-transcript/src/lib.rs | 1 + crates/libid-transcript/src/wire.rs | 58 +++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+) diff --git a/crates/libid-tlsn/src/lib.rs b/crates/libid-tlsn/src/lib.rs index 854d4b53..c88d0cfc 100644 --- a/crates/libid-tlsn/src/lib.rs +++ b/crates/libid-tlsn/src/lib.rs @@ -77,6 +77,7 @@ pub use session::{ prover_generic, root_store, verifier, + CommitmentOpening, ProverResult, ProverStep, UserInfoParams, @@ -111,4 +112,14 @@ 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-transcript/src/lib.rs b/crates/libid-transcript/src/lib.rs index f16f784f..62c35c15 100644 --- a/crates/libid-transcript/src/lib.rs +++ b/crates/libid-transcript/src/lib.rs @@ -39,6 +39,7 @@ pub use types::TlsHandshakeData; pub use wire::{ read_msg, write_msg, + AttestationWire, }; /// Errors from transcript parsing and the wire protocol. diff --git a/crates/libid-transcript/src/wire.rs b/crates/libid-transcript/src/wire.rs index fe274255..9f57dc3b 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 ceremony-common section 9.1 +/// record, 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-61). 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 ceremony-common section 9.1, 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-49). + 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); From 193bf242bdcbf5918245c4d239b51f0a669f1e91 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Wed, 2 Sep 2026 18:07:27 +0300 Subject: [PATCH 29/65] feat(ceremony): give the attestation the one string the wire has room for The response interface carries `tokenAttestation` as a single canonical unpadded base64url value, and REQ-PLAT-45 requires that value to carry the configured notary's signature. Two fields, one string. How they sit inside it decides whether a browser can read what a server sent. So the layout lives here, beside the type, where both sides already look: the attested data, then the signature. A notary signature is a fixed 65 bytes, so it is the tail and the split needs no length prefix. THE LAYOUT IS NOT IN THE SPECIFICATION -- said in the doc comment too, because it is the kind of gap that closes silently. Every component that shares this type agrees by reading it; one written from the specification alone would have to guess. The response bound moves with it. REQ-PLAT-39 bounds the DECODED `tokenAttestation`, which is the pair together, so `validate` now measures what `encode` produces. Measured on the data alone it admitted a response a signature over the bound -- accepted here, then refused by the browser for a reason nothing on this side had stated. Assisted-by: Claude Opus 5 Signed-off-by: SupremaLex --- crates/libid-ceremony/src/token_exchange.rs | 110 +++++++++++++++++++- 1 file changed, 105 insertions(+), 5 deletions(-) diff --git a/crates/libid-ceremony/src/token_exchange.rs b/crates/libid-ceremony/src/token_exchange.rs index f4e7317a..ed82183c 100644 --- a/crates/libid-ceremony/src/token_exchange.rs +++ b/crates/libid-ceremony/src/token_exchange.rs @@ -52,8 +52,15 @@ pub enum TokenExchangeError { AccessTokenNotPrintable(usize), #[error("attestedData is empty")] EmptyAttestedData, - #[error("attestedData is {0} bytes, over the {MAX_ATTESTED_DATA_BYTES}-byte bound")] + #[error( + "the attestation is {0} bytes, over the {MAX_ATTESTED_DATA_BYTES}-byte bound" + )] AttestedDataTooLong(usize), + #[error( + "the attestation is {0} bytes, too short to carry a {SIGNATURE_LEN}-byte \ + signature and any data" + )] + AttestationTooShort(usize), #[error("signature is {0} bytes, not the {SIGNATURE_LEN} a notary signature is")] SignatureWrongLength(usize), #[error( @@ -96,6 +103,47 @@ pub struct TokenAttestation { pub signature: Vec, } +impl TokenAttestation { + /// The single byte string the wire carries: the attested data, then the + /// signature. + /// + /// One string rather than two fields because that is the room the response + /// interface gives it -- `tokenAttestation` is one canonical unpadded + /// base64url value. A notary signature is a fixed [`SIGNATURE_LEN`] bytes, + /// so it is the tail, and the split needs no length prefix and no framing. + /// + /// THE LAYOUT IS NOT IN THE SPECIFICATION. REQ-PLAT-45 requires the + /// returned attestation to carry the notary's signature and the response + /// interface gives it one string to travel in, but how the two sit inside + /// that string is left to the components sharing it. Every one of them + /// reads this function, so they agree; an implementation written from the + /// specification alone would have to guess, which is worth a sentence + /// there. + pub fn encode(&self) -> Vec { + let mut out = Vec::with_capacity(self.attested_data.len() + self.signature.len()); + out.extend_from_slice(&self.attested_data); + out.extend_from_slice(&self.signature); + out + } + + /// Split one wire string back into the pair. + /// + /// A string too short to hold a signature is refused rather than read as + /// an empty-data attestation: the two would be indistinguishable, and the + /// second is a record no verifier can check. + pub fn decode(bytes: &[u8]) -> Result { + let split = bytes + .len() + .checked_sub(SIGNATURE_LEN) + .filter(|n| *n > 0) + .ok_or(TokenExchangeError::AttestationTooShort(bytes.len()))?; + Ok(Self { + attested_data: bytes[..split].to_vec(), + signature: bytes[split..].to_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 @@ -160,10 +208,13 @@ impl TokenResponse { 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(), - )); + // REQ-PLAT-39 bounds the DECODED `tokenAttestation`, and that is this + // pair together -- so the bound belongs on what `encode` produces. + // Measured on the data alone it would admit a response a signature + // over, which the browser then refuses for a reason nothing here said. + let attestation_len = self.token_attestation.attested_data.len() + SIGNATURE_LEN; + if attestation_len > MAX_ATTESTED_DATA_BYTES { + return Err(TokenExchangeError::AttestedDataTooLong(attestation_len)); } if self.token_attestation.signature.len() != SIGNATURE_LEN { return Err(TokenExchangeError::SignatureWrongLength( @@ -183,6 +234,55 @@ impl TokenResponse { mod tests { use super::*; + /// The wire carries one string, and the pair has to survive the trip: + /// everything downstream reads the attested data by offset and recovers a + /// key from the signature, so a byte moved between them derives a key + /// nobody trusts. + #[test] + fn an_attestation_survives_the_one_string_it_travels_in() { + let attestation = TokenAttestation { + attested_data: (0u8..=200).collect(), + signature: vec![0xab; SIGNATURE_LEN], + }; + let encoded = attestation.encode(); + assert_eq!(encoded.len(), 201 + SIGNATURE_LEN); + assert_eq!(TokenAttestation::decode(&encoded).unwrap(), attestation); + } + + /// A string with room for a signature and nothing else decodes to empty + /// attested data, which is a record no verifier can check. Refused here, + /// where the reason is still legible. + #[test] + fn a_string_too_short_to_hold_both_is_refused() { + for len in [0, 1, SIGNATURE_LEN - 1, SIGNATURE_LEN] { + assert!( + matches!( + TokenAttestation::decode(&vec![0u8; len]), + Err(TokenExchangeError::AttestationTooShort(_)) + ), + "{len} bytes must not decode" + ); + } + assert!(TokenAttestation::decode(&[0u8; SIGNATURE_LEN + 1]).is_ok()); + } + + /// The bound is on what travels, not on half of it. Attested data that + /// exactly fills the bound leaves no room for the signature beside it. + #[test] + fn the_bound_covers_the_signature_travelling_with_the_data() { + let mut response = response(); + response.token_attestation.attested_data = + vec![0u8; MAX_ATTESTED_DATA_BYTES - SIGNATURE_LEN]; + assert!(response.validate().is_ok()); + + response.token_attestation.attested_data = + vec![0u8; MAX_ATTESTED_DATA_BYTES - SIGNATURE_LEN + 1]; + assert!(matches!( + response.validate(), + Err(TokenExchangeError::AttestedDataTooLong(_)) + )); + } + fn request() -> TokenRequest { TokenRequest { code: "abc123".into(), From 391078f2f05d1d3b6bce7c347db3c5f6a0cbe851 Mon Sep 17 00:00:00 2001 From: xgreenx Date: Thu, 3 Sep 2026 02:21:35 +0100 Subject: [PATCH 30/65] fix(tlsn): send the request-target in origin-form `prover_generic` takes an absolute URI because the host is where SNI and the TCP peer come from, and then handed that request to hyper unchanged. `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 this is a raw connection. So every Rust prover session put `GET https://host/path HTTP/1.1` on the wire. That line is valid HTTP, and every verifier refuses it. The Platform Verifiers and `IdentityJwksRoots` pin the origin-form request line (`GET /oauth2/v3/certs HTTP/1.1`), so a session notarized through this path -- the GitHub Token-Exchange Service's token session, the keeper's JWKS rotation -- could not be accepted on chain. The fix is where the host is derived: once `api_host` and `path` are read, the URI shrinks to its path-and-query before `send_request`. The `Host` header the caller set stays as it is -- it was already the header the contracts read, only the request line disagreed with it. Assisted-by: Claude Fable 5.1 Signed-off-by: xgreenx --- crates/libid-tlsn/src/session.rs | 72 +++++++++++++++++++++++++++++++- 1 file changed, 71 insertions(+), 1 deletion(-) diff --git a/crates/libid-tlsn/src/session.rs b/crates/libid-tlsn/src/session.rs index 5aafc6f0..77982056 100644 --- a/crates/libid-tlsn/src/session.rs +++ b/crates/libid-tlsn/src/session.rs @@ -352,6 +352,33 @@ where .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 `IdentityJwksRoots` +/// 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. /// /// `select_layout` receives both complete transcripts once the HTTP exchange @@ -383,10 +410,14 @@ where /// 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: hyper::Request>, + mut request: hyper::Request>, select_layout: S, on_progress: F, ) -> Result> @@ -407,6 +438,7 @@ where 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(); @@ -838,3 +870,41 @@ 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") + } + + #[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"[..]) + ); + } +} From 4e89f55ccc6036b824b4ece3c1ae950d59cece53 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Thu, 3 Sep 2026 15:45:56 +0300 Subject: [PATCH 31/65] Revert "feat(ceremony): give the attestation the one string the wire has room for" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deployment contract does not carry the attestation as one string. `SERVER.md` — the document the protocol specification delegates transport framing and serialization to — gives `TokenResponse.tokenAttestation` two members, `attestedData` and `signature`, and bounds them apart: the record decodes to at most 2 MiB, the signature to exactly the 65 bytes a notary signature is. So there is no single wire string for `encode` to produce or `decode` to split, and the combined bound was measuring something nothing sends. Both go, and `validate` returns to bounding the two members it actually has. The reverted commit read `specs/platform-ceremonies.md`, which describes a `tokenAttestation` string. That document says of itself that it fixes the semantic call and leaves endpoint naming, transport framing and serialization to the browser and deployment specifications; `SERVER.md` is the one it leaves them to. The two disagree on this wire, and the disagreement is worth raising where the specifications live. This reverts commit 193bf242bdcbf5918245c4d239b51f0a669f1e91. Assisted-by: Claude Opus 5 Signed-off-by: SupremaLex --- crates/libid-ceremony/src/token_exchange.rs | 110 +------------------- 1 file changed, 5 insertions(+), 105 deletions(-) diff --git a/crates/libid-ceremony/src/token_exchange.rs b/crates/libid-ceremony/src/token_exchange.rs index ed82183c..f4e7317a 100644 --- a/crates/libid-ceremony/src/token_exchange.rs +++ b/crates/libid-ceremony/src/token_exchange.rs @@ -52,15 +52,8 @@ pub enum TokenExchangeError { AccessTokenNotPrintable(usize), #[error("attestedData is empty")] EmptyAttestedData, - #[error( - "the attestation is {0} bytes, over the {MAX_ATTESTED_DATA_BYTES}-byte bound" - )] + #[error("attestedData is {0} bytes, over the {MAX_ATTESTED_DATA_BYTES}-byte bound")] AttestedDataTooLong(usize), - #[error( - "the attestation is {0} bytes, too short to carry a {SIGNATURE_LEN}-byte \ - signature and any data" - )] - AttestationTooShort(usize), #[error("signature is {0} bytes, not the {SIGNATURE_LEN} a notary signature is")] SignatureWrongLength(usize), #[error( @@ -103,47 +96,6 @@ pub struct TokenAttestation { pub signature: Vec, } -impl TokenAttestation { - /// The single byte string the wire carries: the attested data, then the - /// signature. - /// - /// One string rather than two fields because that is the room the response - /// interface gives it -- `tokenAttestation` is one canonical unpadded - /// base64url value. A notary signature is a fixed [`SIGNATURE_LEN`] bytes, - /// so it is the tail, and the split needs no length prefix and no framing. - /// - /// THE LAYOUT IS NOT IN THE SPECIFICATION. REQ-PLAT-45 requires the - /// returned attestation to carry the notary's signature and the response - /// interface gives it one string to travel in, but how the two sit inside - /// that string is left to the components sharing it. Every one of them - /// reads this function, so they agree; an implementation written from the - /// specification alone would have to guess, which is worth a sentence - /// there. - pub fn encode(&self) -> Vec { - let mut out = Vec::with_capacity(self.attested_data.len() + self.signature.len()); - out.extend_from_slice(&self.attested_data); - out.extend_from_slice(&self.signature); - out - } - - /// Split one wire string back into the pair. - /// - /// A string too short to hold a signature is refused rather than read as - /// an empty-data attestation: the two would be indistinguishable, and the - /// second is a record no verifier can check. - pub fn decode(bytes: &[u8]) -> Result { - let split = bytes - .len() - .checked_sub(SIGNATURE_LEN) - .filter(|n| *n > 0) - .ok_or(TokenExchangeError::AttestationTooShort(bytes.len()))?; - Ok(Self { - attested_data: bytes[..split].to_vec(), - signature: bytes[split..].to_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 @@ -208,13 +160,10 @@ impl TokenResponse { if self.token_attestation.attested_data.is_empty() { return Err(TokenExchangeError::EmptyAttestedData); } - // REQ-PLAT-39 bounds the DECODED `tokenAttestation`, and that is this - // pair together -- so the bound belongs on what `encode` produces. - // Measured on the data alone it would admit a response a signature - // over, which the browser then refuses for a reason nothing here said. - let attestation_len = self.token_attestation.attested_data.len() + SIGNATURE_LEN; - if attestation_len > MAX_ATTESTED_DATA_BYTES { - return Err(TokenExchangeError::AttestedDataTooLong(attestation_len)); + 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( @@ -234,55 +183,6 @@ impl TokenResponse { mod tests { use super::*; - /// The wire carries one string, and the pair has to survive the trip: - /// everything downstream reads the attested data by offset and recovers a - /// key from the signature, so a byte moved between them derives a key - /// nobody trusts. - #[test] - fn an_attestation_survives_the_one_string_it_travels_in() { - let attestation = TokenAttestation { - attested_data: (0u8..=200).collect(), - signature: vec![0xab; SIGNATURE_LEN], - }; - let encoded = attestation.encode(); - assert_eq!(encoded.len(), 201 + SIGNATURE_LEN); - assert_eq!(TokenAttestation::decode(&encoded).unwrap(), attestation); - } - - /// A string with room for a signature and nothing else decodes to empty - /// attested data, which is a record no verifier can check. Refused here, - /// where the reason is still legible. - #[test] - fn a_string_too_short_to_hold_both_is_refused() { - for len in [0, 1, SIGNATURE_LEN - 1, SIGNATURE_LEN] { - assert!( - matches!( - TokenAttestation::decode(&vec![0u8; len]), - Err(TokenExchangeError::AttestationTooShort(_)) - ), - "{len} bytes must not decode" - ); - } - assert!(TokenAttestation::decode(&[0u8; SIGNATURE_LEN + 1]).is_ok()); - } - - /// The bound is on what travels, not on half of it. Attested data that - /// exactly fills the bound leaves no room for the signature beside it. - #[test] - fn the_bound_covers_the_signature_travelling_with_the_data() { - let mut response = response(); - response.token_attestation.attested_data = - vec![0u8; MAX_ATTESTED_DATA_BYTES - SIGNATURE_LEN]; - assert!(response.validate().is_ok()); - - response.token_attestation.attested_data = - vec![0u8; MAX_ATTESTED_DATA_BYTES - SIGNATURE_LEN + 1]; - assert!(matches!( - response.validate(), - Err(TokenExchangeError::AttestedDataTooLong(_)) - )); - } - fn request() -> TokenRequest { TokenRequest { code: "abc123".into(), From 25d7fdaf9a898e9064f7e9aa253ebd6c0ffc8cac Mon Sep 17 00:00:00 2001 From: xgreenx Date: Mon, 7 Sep 2026 22:23:25 +0100 Subject: [PATCH 32/65] chore!: remove libid-attestations, the builders for removed contracts Every digest this crate builds mirrors a Solidity routine that no longer exists. libid-contracts#21 removed the legacy login and transfer stack, so `_verifyNotarySignature`, `JwksOracle`, `XZkVerifier` and the registry identity hash are gone from that repository's main -- and the tests here named `token_attest_digest_matches_solidity` and `me_attest_digest_matches_solidity` have no Solidity left to match. Nothing in this workspace uses it. Its last consumer is the notary's JWKS path, which libid-org/notary#7 drops by making the JWKS reading an ordinary notarized session verified through `NotaryService`. The notary branches that still call it pin a fixed revision of this repository, so they resolve against a tree that still carries the crate; only a pin moved past this commit needs #7 first. The two backends that call `compute_notary_digest` pin the v0.1.0 and v0.2.0 release tags and are untouched. Those published versions stay on crates.io; there is simply no 0.3.0 of this crate. `libid-ceremony` now holds the one byte layout a Solidity decoder has to agree with, and libid-crypto's doc line points there instead. Assisted-by: Claude Opus 5 Signed-off-by: xgreenx --- .github/workflows/ci.yml | 1 - .github/workflows/scripts/publish-crates.sh | 4 +- Cargo.lock | 10 - Cargo.toml | 1 - README.md | 3 +- crates/libid-attestations/Cargo.toml | 18 - crates/libid-attestations/src/lib.rs | 479 -------------------- crates/libid-crypto/src/lib.rs | 4 +- 8 files changed, 5 insertions(+), 515 deletions(-) delete mode 100644 crates/libid-attestations/Cargo.toml delete mode 100644 crates/libid-attestations/src/lib.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index be60f51d..524082b3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -191,7 +191,6 @@ jobs: cargo publish --dry-run -p libid-crypto -p libid-transcript - -p libid-attestations -p libid-signer # --------------------------------------------------------------------------- diff --git a/.github/workflows/scripts/publish-crates.sh b/.github/workflows/scripts/publish-crates.sh index 42acc4ab..cb46ea3c 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; signer dev-depends on crypto. +CRATES=(libid-crypto libid-transcript 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 d83cbb10..075b04b5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3372,16 +3372,6 @@ version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" -[[package]] -name = "libid-attestations" -version = "0.3.0" -dependencies = [ - "alloy-primitives", - "alloy-sol-types", - "hex", - "libid-crypto", -] - [[package]] name = "libid-ceremony" version = "0.3.0" diff --git a/Cargo.toml b/Cargo.toml index 0936583c..c0e77103 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,7 +23,6 @@ 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 } diff --git a/README.md b/README.md index 6aec69c8..8deee981 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,6 @@ digests the libID on-chain verifiers check. | --- | --- | --- | | `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-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. | @@ -46,7 +45,7 @@ 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 +// EvmProof with libid_crypto merkle digests, sign it // with libid_signer::ManagedSigner, then: libid_transcript::write_msg(&mut result.recovered_io, &response).await?; ``` 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-crypto/src/lib.rs b/crates/libid-crypto/src/lib.rs index 1cb56109..3d687633 100644 --- a/crates/libid-crypto/src/lib.rs +++ b/crates/libid-crypto/src/lib.rs @@ -3,8 +3,8 @@ //! 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`. +//! 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, From a53c34164f994934fffeceae2dd21d8293714433 Mon Sep 17 00:00:00 2001 From: xgreenx Date: Mon, 7 Sep 2026 22:30:13 +0100 Subject: [PATCH 33/65] docs: cite requirements that exist `attestation.rs` says it plainly: REQ-COMMON-47 through -61 are not in the published specification. They were written in libid PR #12, which defined this byte layout and closed on 2026-08-20 without merging. The module kept the numbering on the intent of upstreaming the layout under those identifiers. That has not happened, and the contracts are the side that decides: they were the second implementation carrying the same vocabulary, and libid-org/libid-contracts#27 drops it there. This follows, so the two keep naming the same rules by the same names. Where a published requirement says the same thing, the citation moves to it: -47, -49, -61 -> REQ-COMMON-33, which has the Notary Service take the attested data and its signature and return one decision covering that signature over exactly those bytes, and decide nothing profile-specific. -56 -> REQ-COMMON-21 and REQ-COMMON-21A, which have the notary authenticate the TLS server identity and the Platform Verifier compare the authenticated authority byte for byte with its pinned constants. -48, -57, -59 and -60 have no published counterpart. The rules stay, stated in full where they were; only the identifiers go. The one they were standing in for is REQ-COMMON-18, which requires a Platform Profile to fix the attestation format and leaves the format to the profile author -- so the module header now says the layout is the profile's rather than calling itself section 9.1, which is attestation verification and its fee and defines no bytes. Comments only. 100 tests, clippy and nightly fmt unchanged. Assisted-by: Claude Opus 5 Signed-off-by: xgreenx --- crates/libid-ceremony/src/attestation.rs | 36 +++++++++--------------- crates/libid-tlsn/src/attest.rs | 30 +++++++++++--------- crates/libid-transcript/src/wire.rs | 6 ++-- 3 files changed, 33 insertions(+), 39 deletions(-) diff --git a/crates/libid-ceremony/src/attestation.rs b/crates/libid-ceremony/src/attestation.rs index 6c9891b0..57422723 100644 --- a/crates/libid-ceremony/src/attestation.rs +++ b/crates/libid-ceremony/src/attestation.rs @@ -1,30 +1,20 @@ -//! The attestation format of ceremony-common section 9.1. +//! 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 -//! (REQ-COMMON-47). +//! 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 (REQ-COMMON-48). -//! -//! # Where these requirement numbers come from -//! -//! The `REQ-COMMON-47` through `REQ-COMMON-61` cited below are NOT in the -//! published specification. They were written in libid PR #12, which defined -//! this byte layout and was closed on 2026-08-20 without merging; PR #15 does -//! not restore it. What survives on main is `REQ-COMMON-18`, which requires a -//! Platform Profile to PIN 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. The numbering is -//! kept because it is the specification's own, and the intent is to upstream -//! this layout under those identifiers -- the specification follows what the -//! implementation needs. Until it does, a reader looking these up will not -//! find them, and every rule they name is stated in full here. +//! 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 @@ -45,8 +35,7 @@ pub struct RevealedRange { } /// A hidden range, carried as its offsets and a blinded commitment. The -/// plaintext of a committed range never appears in the attested data -/// (REQ-COMMON-60). +/// plaintext of a committed range never appears in the attested data. #[derive(Clone, Debug, PartialEq, Eq, bincode::Encode)] pub struct RangeCommitment { pub start: u32, @@ -88,7 +77,8 @@ pub struct AttestedData { /// two transcript lengths. pub const HEADER_LEN: usize = 32 + 8 + 4 + 4; -/// Hash the canonical authority bytes into `authorityId` (REQ-COMMON-56). +/// Hash the canonical authority bytes into `authorityId` (REQ-COMMON-21, +/// REQ-COMMON-21A). /// /// 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 @@ -124,7 +114,7 @@ impl AttestedData { } /// What the notary signs, and the only preimage it ever signs - /// (REQ-COMMON-47). + /// (REQ-COMMON-33). pub fn digest(&self) -> Result<[u8; 32], bincode::error::EncodeError> { Ok(keccak256(&self.encode()?)) } diff --git a/crates/libid-tlsn/src/attest.rs b/crates/libid-tlsn/src/attest.rs index a6f42735..c03fff45 100644 --- a/crates/libid-tlsn/src/attest.rs +++ b/crates/libid-tlsn/src/attest.rs @@ -6,9 +6,9 @@ //! this crate owns the translation and is git-only because tlsn is. Nothing //! above needs to know that a `RangeSet` exists. //! -//! The `REQ-COMMON-56`/`-57`/`-59`/`-61` cited below are from libid PR #12, -//! which was closed without merging. `libid_ceremony::attestation` carries the -//! provenance note and states each rule in full. +//! 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::{ tag, @@ -30,9 +30,11 @@ use tlsn::{ pub struct AttestationInput { /// The notary's OWN clock reading when the session completed. /// - /// REQ-COMMON-57 forbids taking this from the prover, from a response - /// header, or from any other party. It is an argument rather than a call to - /// the clock here so a test can pin it; the caller must pass its own. + /// 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 an + /// argument rather than a call to the clock here so a test can pin it; the + /// caller must pass its own. pub created_at: u64, } @@ -58,7 +60,7 @@ fn u32_of(value: usize) -> Result { /// /// 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-61). Every such value is already derivable from the revealed +/// (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. @@ -77,7 +79,8 @@ pub fn attested_data( // name the notary authenticated, with no trailing dot. It is a signed // field rather than 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-56). + // nothing about which server answered (REQ-COMMON-21, + // REQ-COMMON-21A). authority_id: tag(&authority.to_ascii_lowercase()), created_at: input.created_at, sent_transcript_length: u32_of(partial.len_sent())?, @@ -100,8 +103,8 @@ fn direction_block( // 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 (REQ-COMMON-59). The end is the - // bytes' own length, so it is not written down twice. + // 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 @@ -309,9 +312,10 @@ mod tests { #[test] fn places_no_profile_derived_value_in_the_signed_bytes() { - // REQ-COMMON-61: the notary must place 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 + // 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. // diff --git a/crates/libid-transcript/src/wire.rs b/crates/libid-transcript/src/wire.rs index 9f57dc3b..f6ffa436 100644 --- a/crates/libid-transcript/src/wire.rs +++ b/crates/libid-transcript/src/wire.rs @@ -35,18 +35,18 @@ const MAX_MSG_SIZE: usize = 10 * 1024 * 1024; /// /// 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-61). Every one is derivable from the revealed ranges, and a +/// (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 ceremony-common section 9.1, as the notary encoded + /// 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-49). + /// (REQ-COMMON-33). pub notary_signature: Vec, } From 0c4eed8ee9c6ddc2d9a681b9a101b88c181c3dea Mon Sep 17 00:00:00 2001 From: xgreenx Date: Mon, 7 Sep 2026 23:22:21 +0100 Subject: [PATCH 34/65] chore: the release surface follows the code Removing a crate is half of it. The manifests, the README and the publish lists still describe a workspace that is two crates and three types out of date, and none of that is checked by anything: * `libid-transcript` keeps a `ts` feature and an optional `ts-rs` dependency. Every `ts_rs::TS` derive went with `EvmProof`, so `--features ts` now compiles ts-rs and derives nothing. No consumer enables it, so this is weight rather than breakage. * Its `description` -- the string crates.io shows -- still advertises "the EvmProof/NotaryResponse proof types". Both were deleted. `TlsHandshakeData` survives and `AttestationWire` is new, so the description names those. * The README table has no `libid-ceremony` row, its feature-flag section documents the derive that no longer exists, and its usage sketch builds an `EvmProof`. * `libid-ceremony` carries no `publish = false`, and was in neither `publish-crates.sh` nor the publish dry-run. A publishable crate that nothing publishes is a v0.3.0 that ships an incomplete workspace. It goes after `libid-crypto`, which it depends on. Its description also called the record "the section 9.1 types". Section 9.1 is attestation verification and its fee; the layout is the profile's under REQ-COMMON-18, as the sibling PR spells out. Verified with the dry-run in the batch form CI uses -- all four crates pack and rebuild from the packaged tarball. Assisted-by: Claude Opus 5 Signed-off-by: xgreenx --- .github/workflows/ci.yml | 1 + .github/workflows/scripts/publish-crates.sh | 4 +- Cargo.lock | 48 --------------------- Cargo.toml | 1 - README.md | 21 +++------ crates/libid-ceremony/Cargo.toml | 2 +- crates/libid-transcript/Cargo.toml | 10 +---- 7 files changed, 12 insertions(+), 75 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 524082b3..4054fb93 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -191,6 +191,7 @@ jobs: cargo publish --dry-run -p libid-crypto -p libid-transcript + -p libid-ceremony -p libid-signer # --------------------------------------------------------------------------- diff --git a/.github/workflows/scripts/publish-crates.sh b/.github/workflows/scripts/publish-crates.sh index cb46ea3c..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; signer dev-depends on crypto. -CRATES=(libid-crypto libid-transcript 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 075b04b5..7e31589f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3360,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" @@ -3434,7 +3428,6 @@ dependencies = [ "serde_json", "thiserror 2.0.20", "tokio", - "ts-rs", ] [[package]] @@ -5200,15 +5193,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" @@ -5737,29 +5721,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" @@ -6005,15 +5966,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 c0e77103..e511eba6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -60,5 +60,4 @@ 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 8deee981..c03982a4 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,8 @@ digests the libID on-chain verifiers check. | 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-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 per-session ceremony reveal layouts; the length-prefixed JSON wire protocol notary and prover speak after MPC-TLS closes; the `AttestationWire` and `TlsHandshakeData` types. | +| `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. | @@ -26,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 @@ -44,9 +38,8 @@ 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 digests, sign it -// with libid_signer::ManagedSigner, then: +// build the attested data from the session with libid_tlsn::attested_data, +// sign its digest with libid_signer::ManagedSigner, then: libid_transcript::write_msg(&mut result.recovered_io, &response).await?; ``` diff --git a/crates/libid-ceremony/Cargo.toml b/crates/libid-ceremony/Cargo.toml index e0f733c6..0e059b7e 100644 --- a/crates/libid-ceremony/Cargo.toml +++ b/crates/libid-ceremony/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "libid-ceremony" -description = "The attestation record a libID notary signs: the section 9.1 types and their encoder." +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 diff --git a/crates/libid-transcript/Cargo.toml b/crates/libid-transcript/Cargo.toml index 3c2a9f47..dfffce4e 100644 --- a/crates/libid-transcript/Cargo.toml +++ b/crates/libid-transcript/Cargo.toml @@ -5,24 +5,16 @@ 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 serde.workspace = true serde_json.workspace = true thiserror.workspace = true tokio = { workspace = true, features = ["io-util"] } -ts-rs = { workspace = true, optional = true } [dev-dependencies] tokio = { workspace = true, features = ["rt", "macros", "io-util"] } From 087b08f32e1c6440bbf016cf83ca2d51188246d3 Mon Sep 17 00:00:00 2001 From: xgreenx Date: Mon, 7 Sep 2026 23:26:23 +0100 Subject: [PATCH 35/65] fix(transcript): match the delimiter the verifier matches `find_json_snippet_range` looked for `"field"`, then scanned forward for a `:`, then for a quote. `CeremonyFields.tryJsonString` matches the literal `"field":"` and nothing else. So a response written `"login" : "octocat"` was selected here and met with `FieldNotFound` on chain -- the same refusal, moved to where nobody can see its reason. The bare-integer finder had the same gap against `tryJsonInteger`. Both now match the reader's template, so a body the reader cannot read fails where the reason is visible. Compact JSON, which is all any launch platform sends, selects the same bytes as before. Uniqueness is deliberately NOT taken from the reader. `CeremonyFields` reverts on a delimiter matching twice in the bytes it was SHOWN, and which bytes those are is what a layout decides: `identity_response` reveals one member and commits the other, so the reader sees one. Refusing a second occurrence here would stop an honest prover building that layout and stop nothing else -- a dishonest prover does not run this code. Both facts are now written down where someone would otherwise re-derive them, with the test named for the behaviour rather than against it. `layout` sorts its reveals. `complement` walks them once and takes each as starting where the last ended, so unsorted input reads as overlap and produces a complement that tiles nothing -- rejected on chain, caught by nothing here. The invariant lived in a doc comment on the struct field and in one caller's memory; it is now done once, in the one place that can, with a debug assertion for the overlap sorting cannot fix. `identity_response` drops its own sort. Two comments cited `_extractId`, which went with the pre-#21 contracts. They name `CeremonyFields.tryJsonInteger`, which is what reads those bytes now. Assisted-by: Claude Opus 5 Signed-off-by: xgreenx --- crates/libid-transcript/src/ceremony.rs | 21 +++- crates/libid-transcript/src/ranges.rs | 123 +++++++++++++++--------- 2 files changed, 94 insertions(+), 50 deletions(-) diff --git a/crates/libid-transcript/src/ceremony.rs b/crates/libid-transcript/src/ceremony.rs index c8b68fb2..6bf09eaa 100644 --- a/crates/libid-transcript/src/ceremony.rs +++ b/crates/libid-transcript/src/ceremony.rs @@ -71,7 +71,19 @@ fn one(range: Range) -> Vec> { core::iter::once(range).collect() } -fn layout(reveal: Vec>, len: usize) -> Layout { +fn layout(mut reveal: Vec>, len: usize) -> Layout { + // `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 + // `identity_response`, whose two members arrive in whatever order the + // platform serialized them, no longer carries a sort of its own. + 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 } } @@ -206,10 +218,9 @@ pub fn identity_response( let handle = compute_field_snippet_range(recv, handle_field) .ok_or_else(|| LayoutError::MissingField(handle_field.into()))?; - // JSON member order is not fixed, so sort rather than assume. - let mut reveal = vec![id, handle]; - reveal.sort_by_key(|r| r.start); - Ok(layout(reveal, recv.len())) + // JSON member order is not fixed; `layout` sorts, so this does not assume + // one. + Ok(layout(vec![id, handle], recv.len())) } #[cfg(test)] diff --git a/crates/libid-transcript/src/ranges.rs b/crates/libid-transcript/src/ranges.rs index 5971a0f6..85151432 100644 --- a/crates/libid-transcript/src/ranges.rs +++ b/crates/libid-transcript/src/ranges.rs @@ -200,38 +200,43 @@ pub fn compute_field_reveal_range(recv: &[u8], field_name: &str) -> Option":"`, 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> { - 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..)? + 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(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)?) + .checked_add(value)?; + // From the opening `"` of the key through the closing `"` of the value. + Some(start..close.checked_add(1)?) +} + +/// The first occurrence of `needle`, or nothing. +fn find_first(haystack: &[u8], needle: &[u8]) -> Option { + haystack.windows(needle.len()).position(|w| w == needle) } /// Find the byte range of a bare (unquoted) JSON number snippet: @@ -242,26 +247,19 @@ pub fn find_json_snippet_range(body: &[u8], field: &str) -> Option> /// number; both terminators are included in the range (on-chain `_extractId` /// 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)?; + let needle = format!("\"{field}\":"); + let start = find_first(body, needle.as_bytes())?; + let digits = start.checked_add(needle.len())?; // Bound the number by the first `,` or `}` after the colon. let term = body - .get(after_colon..)? + .get(digits..)? .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)?) + .checked_add(digits)?; + // 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 + // `CeremonyFields.tryJsonInteger` refuses any other byte there. + Some(start..term.checked_add(1)?) } /// Like [`compute_field_reveal_range`] but returns the range covering the @@ -464,6 +462,41 @@ mod tests { assert_eq!(&body[range], br#""login":"octocat""#); } + #[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 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 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 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] fn find_json_snippet_range_email() { let body = br#"{"email":"alice@example.com","verified":true}"#; @@ -502,7 +535,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}"#); From b4f88e19a8d3db1c4ffa367435458db308bf52e7 Mon Sep 17 00:00:00 2001 From: xgreenx Date: Tue, 8 Sep 2026 18:40:10 +0100 Subject: [PATCH 36/65] fix(transcript): refuse a member that chunk framing runs through A chunked body carries `\r\n\r\n` between chunks, and that framing holds no quote, comma or brace -- so every scan here runs straight through it. A member split across a boundary is therefore found in the decoded body AND in the raw one, and the raw range silently spans the framing: RAW: ...19\r\n{"access_token":"ghu_AAAA\r\n1c\r\nBBBB","token_type":... compute_field_snippet_range -> "access_token":"ghu_AAAA\r\n1c\r\nBBBB" `compute_id_snippet_range_after` claimed the opposite in a comment -- "a snippet split across a chunk boundary won't be found contiguously here and fails closed" -- which holds only when the split lands inside the NEEDLE. A split inside the value is what actually happens, and nothing caught it. What the spanning range selects is not the member. Revealed, it puts framing inside the handle a Platform Verifier reads. Committed, it puts framing inside the bearer a Proving Circuit opens -- against the clean value hyper handed the caller, because `ProverResult::response_body` is decoded and the commitment is over raw transcript bytes. Re-framing cannot repair either: a commitment covers one contiguous run and this member is two. So all three finders now compare the raw bytes against the decoded ones and refuse when they differ, which fails the session where the reason is a decodable body rather than an unopenable commitment three components later. `token_response` goes through that shared reader instead of scanning the raw transcript with a copy of the template. This is the session the deployment itself runs -- the GitHub token exchange, per platform-ceremonies section 6 -- so it is the one that most needed the check, and it had none. The reuse also scopes the search to the response BODY: the old scan started at byte zero, so a response header carrying the delimiter answered before the body's own member. An empty bearer is refused too. It would leave the two revealed delimiters adjacent and commit nothing, and a response direction with no commitment is one `requireFramedCommitment` finds no bearer in. Six tests, including the straddling case for each finder and a chunked body whose member sits in one piece, which still resolves -- the rule is contiguity, not the absence of chunking. Assisted-by: Claude Opus 5 Signed-off-by: xgreenx --- crates/libid-transcript/src/ceremony.rs | 80 +++++++++++++++--- crates/libid-transcript/src/ranges.rs | 104 ++++++++++++++++++++---- 2 files changed, 156 insertions(+), 28 deletions(-) diff --git a/crates/libid-transcript/src/ceremony.rs b/crates/libid-transcript/src/ceremony.rs index 6bf09eaa..b4017873 100644 --- a/crates/libid-transcript/src/ceremony.rs +++ b/crates/libid-transcript/src/ceremony.rs @@ -120,20 +120,36 @@ pub fn token_request( /// 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 { - const ANCHOR: &[u8] = b"\"access_token\":\""; - let anchor_start = recv - .windows(ANCHOR.len()) - .position(|w| w == ANCHOR) + const ANCHOR_LEN: usize = r#""access_token":""#.len(); + + // 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 member = compute_field_snippet_range(recv, "access_token") .ok_or_else(|| LayoutError::MissingField("access_token".into()))?; - let value_start = anchor_start + ANCHOR.len(); - let value_end = value_start - + recv[value_start..] - .iter() - .position(|&b| b == b'"') - .ok_or_else(|| LayoutError::MissingField("access_token".into()))?; + + // The member is `"access_token":""`. Reveal the two delimiters; the + // complement commits the bearer between them. + let value_start = member + .start + .checked_add(ANCHOR_LEN) + .ok_or_else(|| LayoutError::MissingField("access_token".into()))?; + let close = member + .end + .checked_sub(1) + .ok_or_else(|| LayoutError::MissingField("access_token".into()))?; + // An empty bearer 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 close <= value_start { + return Err(LayoutError::MissingField("access_token".into())); + } Ok(layout( - vec![anchor_start..value_start, value_end..value_end + 1], + vec![member.start..value_start, close..member.end], recv.len(), )) } @@ -246,6 +262,48 @@ mod tests { 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!(token_response(&recv).is_err()); + } + + #[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 = 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 = token_request(X_TOKEN_REQ, None).unwrap(); diff --git a/crates/libid-transcript/src/ranges.rs b/crates/libid-transcript/src/ranges.rs index 85151432..98e055b6 100644 --- a/crates/libid-transcript/src/ranges.rs +++ b/crates/libid-transcript/src/ranges.rs @@ -239,6 +239,23 @@ fn find_first(haystack: &[u8], needle: &[u8]) -> Option { haystack.windows(needle.len()).position(|w| w == needle) } +/// The raw bytes are the member, and not the member with framing through it. +/// +/// 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. +/// +/// 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: /// `"key":,`. The range runs from the key's opening `"` through the /// trailing `,` that follows the number (matching the on-chain `idSuffix=,`). @@ -275,11 +292,14 @@ pub fn compute_field_snippet_range( 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) + // 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_range = find_json_snippet_range(&decoded_body, field_name)?; let raw_snippet_range = find_json_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)?; @@ -304,22 +324,23 @@ pub fn compute_id_snippet_range_after( // 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()?; + // The bytes it finds are kept, to be compared with the raw ones below. + let decoded = extract_response_body(recv).ok()?; + let decoded_member = { 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)?; + let from = danchor.checked_add(anchor_needle.len())?; + let dsub = decoded.get(from..)?; + let rel = if quoted { + find_json_snippet_range(dsub, field_name)? } else { - find_json_bare_snippet_range(dsub, field_name)?; - } - } + find_json_bare_snippet_range(dsub, field_name)? + }; + dsub.get(rel)? + }; - // 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.) + // The Merkle leaf is over the RAW transcript, so map the range there. let anchor_pos = raw_body .windows(anchor_needle.len()) .position(|w| w == anchor_needle.as_bytes())?; @@ -331,6 +352,8 @@ pub fn compute_id_snippet_range_after( } else { find_json_bare_snippet_range(sub, field_name)? }; + require_contiguous(sub.get(rel.clone())?, decoded_member)?; + let base = body_range.start.checked_add(search_from)?; Some(base.checked_add(rel.start)?..base.checked_add(rel.end)?) } @@ -352,9 +375,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)?; @@ -515,6 +541,50 @@ 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 + } + + #[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 an_anchored_id_split_by_chunk_framing_is_refused() { + let recv = straddling(r#"{"user":{"id":"12"#, r#"34"}}"#); + assert!(compute_id_snippet_range_after(&recv, "id", true, "user").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 = From b9a939e289854bb65d9ceb8c3bc9e14671492881 Mon Sep 17 00:00:00 2001 From: xgreenx Date: Tue, 8 Sep 2026 18:47:05 +0100 Subject: [PATCH 37/65] refactor(transcript): name the field once, take both boundaries from the scan `token_response` spelled `access_token` twice -- once as the argument to the reader, once inside the literal it measured the delimiter length from. Change one and the other disagrees silently, moving the revealed boundary into the bearer or past it. That is the same class of defect this branch exists to close, so it should not be introduced by the fix for it. `compute_json_member` now returns the member AND its value, both from the scan that located them, and `compute_field_snippet_range` is that with the value dropped. The caller revealing two delimiters and committing what sits between them takes both boundaries from the finder and restates no template. The field stays a constant rather than becoming 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` for every profile instead of on each Platform Verifier. What IS a platform choice is a per-profile virtual there and a parameter here: the committed body credential of `token_request`, the field names of `identity_response`. The comment says so, because the asymmetry looks like an oversight until you know where the contract put it. Assisted-by: Claude Opus 5 Signed-off-by: xgreenx --- crates/libid-transcript/src/ceremony.rs | 44 +++++++++++---------- crates/libid-transcript/src/lib.rs | 2 + crates/libid-transcript/src/ranges.rs | 51 ++++++++++++++++++++----- 3 files changed, 68 insertions(+), 29 deletions(-) diff --git a/crates/libid-transcript/src/ceremony.rs b/crates/libid-transcript/src/ceremony.rs index b4017873..916a61a0 100644 --- a/crates/libid-transcript/src/ceremony.rs +++ b/crates/libid-transcript/src/ceremony.rs @@ -22,6 +22,7 @@ use std::ops::Range; use crate::ranges::{ compute_field_snippet_range, compute_id_snippet_range, + compute_json_member, }; /// What one direction of one session discloses. @@ -120,7 +121,14 @@ pub fn token_request( /// 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 { - const ANCHOR_LEN: usize = r#""access_token":""#.len(); + // 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 + // `token_request`, the field names of `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 @@ -128,28 +136,24 @@ pub fn token_response(recv: &[u8]) -> Result { // 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 member = compute_field_snippet_range(recv, "access_token") - .ok_or_else(|| LayoutError::MissingField("access_token".into()))?; - - // The member is `"access_token":""`. Reveal the two delimiters; the - // complement commits the bearer between them. - let value_start = member - .start - .checked_add(ANCHOR_LEN) - .ok_or_else(|| LayoutError::MissingField("access_token".into()))?; - let close = member - .end - .checked_sub(1) - .ok_or_else(|| LayoutError::MissingField("access_token".into()))?; - // An empty bearer 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 close <= value_start { - return Err(LayoutError::MissingField("access_token".into())); + let found = compute_json_member(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(layout( - vec![member.start..value_start, close..member.end], + vec![ + found.member.start..found.value.start, + found.value.end..found.member.end, + ], recv.len(), )) } diff --git a/crates/libid-transcript/src/lib.rs b/crates/libid-transcript/src/lib.rs index 62c35c15..8de6154b 100644 --- a/crates/libid-transcript/src/lib.rs +++ b/crates/libid-transcript/src/lib.rs @@ -24,6 +24,7 @@ pub use ranges::{ compute_field_snippet_range, compute_id_snippet_range, compute_id_snippet_range_after, + compute_json_member, extract_header, extract_response_body, find_header_range, @@ -34,6 +35,7 @@ pub use ranges::{ find_presentation_commit_ranges, find_request_line_range, find_response_body_range, + JsonMember, }; pub use types::TlsHandshakeData; pub use wire::{ diff --git a/crates/libid-transcript/src/ranges.rs b/crates/libid-transcript/src/ranges.rs index 98e055b6..15748552 100644 --- a/crates/libid-transcript/src/ranges.rs +++ b/crates/libid-transcript/src/ranges.rs @@ -222,6 +222,25 @@ pub fn compute_field_reveal_range(recv: &[u8], field_name: &str) -> Option Option> { + json_member_in(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, +} + +/// Locate the member and its value in one pass over `body`. +fn json_member_in(body: &[u8], field: &str) -> Option { let needle = format!("\"{field}\":\""); let start = find_first(body, needle.as_bytes())?; let value = start.checked_add(needle.len())?; @@ -230,8 +249,11 @@ pub fn find_json_snippet_range(body: &[u8], field: &str) -> Option> .iter() .position(|&b| b == b'"')? .checked_add(value)?; - // From the opening `"` of the key through the closing `"` of the value. - Some(start..close.checked_add(1)?) + Some(JsonMember { + // From the opening `"` of the key through the closing `"` of the value. + member: start..close.checked_add(1)?, + value: value..close, + }) } /// The first occurrence of `needle`, or nothing. @@ -288,22 +310,33 @@ pub fn compute_field_snippet_range( recv: &[u8], field_name: &str, ) -> Option> { + compute_json_member(recv, field_name).map(|found| found.member) +} + +/// [`compute_field_snippet_range`], keeping the value boundary too. +/// +/// For a caller that reveals a member's delimiters and commits what sits +/// between them: the boundaries come from the scan that found them, so no +/// caller restates the template to recover one. +pub fn compute_json_member(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_range = find_json_snippet_range(&decoded_body, field_name)?; - let raw_snippet_range = find_json_snippet_range(raw_body, field_name)?; + let decoded = json_member_in(&decoded_body, field_name)?; + let raw = json_member_in(raw_body, field_name)?; require_contiguous( - raw_body.get(raw_snippet_range.clone())?, - decoded_body.get(decoded_range)?, + raw_body.get(raw.member.clone())?, + decoded_body.get(decoded.member)?, )?; - 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) + let at = |offset: usize| body_range.start.checked_add(offset); + Some(JsonMember { + member: at(raw.member.start)?..at(raw.member.end)?, + value: at(raw.value.start)?..at(raw.value.end)?, + }) } /// Like [`compute_id_snippet_range`] but only matches `field_name` after the From 5bb4fc7786af4417a09a5dbb3ccfc9deb8eed896 Mon Sep 17 00:00:00 2001 From: xgreenx Date: Tue, 8 Sep 2026 18:49:36 +0100 Subject: [PATCH 38/65] test(transcript): pin the member boundaries the layout depends on `compute_json_member` returns two ranges and `token_response` trusts their relationship without restating it, so the relationship is what these check: the value sits inside the member, and what the member holds either side of it is exactly `"field":"` and the closing quote. A boundary that drifts fails here rather than as a committed bearer with a quote in it. Also pinned: a value carrying `:`, `,` or `}` still ends at its quote and is committed whole -- a scan stopping at a structural byte would commit a prefix of the bearer and REVEAL the rest, which is the failure worth a test of its own; an empty value is FOUND with an empty range, because whether that is usable belongs to the caller, and `token_response` refuses it for its own reason; and `compute_field_snippet_range` still equals the member range, so the two cannot drift. Assisted-by: Claude Opus 5 Signed-off-by: xgreenx --- crates/libid-transcript/src/ceremony.rs | 31 +++++++++++++ crates/libid-transcript/src/ranges.rs | 62 +++++++++++++++++++++++++ 2 files changed, 93 insertions(+) diff --git a/crates/libid-transcript/src/ceremony.rs b/crates/libid-transcript/src/ceremony.rs index 916a61a0..9c596c7e 100644 --- a/crates/libid-transcript/src/ceremony.rs +++ b/crates/libid-transcript/src/ceremony.rs @@ -285,6 +285,37 @@ mod tests { assert!(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!(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 = 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 diff --git a/crates/libid-transcript/src/ranges.rs b/crates/libid-transcript/src/ranges.rs index 15748552..ba13ceab 100644 --- a/crates/libid-transcript/src/ranges.rs +++ b/crates/libid-transcript/src/ranges.rs @@ -588,6 +588,68 @@ mod tests { out } + /// The property every caller of `compute_json_member` 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 = compute_json_member(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 = compute_json_member(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 = compute_json_member(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!( + compute_json_member(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 From aff961d68fc3ae9699d2e1d976223e4ddd6aa2ca Mon Sep 17 00:00:00 2001 From: xgreenx Date: Tue, 8 Sep 2026 20:02:14 +0100 Subject: [PATCH 39/65] refactor(transcript): the layouts are constructors on the type they produce `token_request`, `token_response`, `identity_request` and `identity_response` each produce a `Layout` and nothing else, so they are `Layout::token_request` and friends now, and a call site says what it is building before it says what it is building it from. The private `layout` becomes `Layout::revealing`, which is the change that carries the invariant. It was a lowercase homonym of the type it built, sitting in a module about layouts, and it is the ONE door into a tiling `Layout` -- no constructor states `commit`, so no constructor's list can disagree with the reveals it was supposed to complement. Naming it after the type says which door that is. It stays private, and `Layout`'s fields stay public beside it. `libid-tlsn`'s legacy prover builds two layouts whose reveals and commitments deliberately overlap, so tiling is a property of these four constructors rather than of the type, and a public constructor advertising a guarantee the type does not enforce would be worse than no public constructor at all. `revealing` takes an iterator, which retires `one()`. That helper existed because both `vec![a..b]` and `[a..b]` trip `single_range_in_vec_init` -- a lint for `vec![0; n]` typos that cannot tell this apart from one -- and its whole body was `core::iter::once(range).collect()`. The call sites now spell that themselves rather than reaching for a named stand-in for a lint dodge. `IdShape` moves above the block so the four constructors are contiguous; its definition and doc are unchanged. No layout selects a different range than it selected before: the 108 tests that pin the ranges are the check on that, and none of them changed except to say `Layout::` first. Signed-off-by: xgreenx Assisted-by: Claude Opus 5 --- crates/libid-tlsn/src/attest.rs | 21 +- .../libid-tlsn/tests/ceremony_end_to_end.rs | 21 +- crates/libid-transcript/src/ceremony.rs | 395 ++++++++++-------- 3 files changed, 234 insertions(+), 203 deletions(-) diff --git a/crates/libid-tlsn/src/attest.rs b/crates/libid-tlsn/src/attest.rs index c03fff45..f08514c1 100644 --- a/crates/libid-tlsn/src/attest.rs +++ b/crates/libid-tlsn/src/attest.rs @@ -184,7 +184,7 @@ mod tests { use super::*; use libid_transcript::ceremony::{ - self, + IdShape, Layout, }; use rangeset::set::RangeSet; @@ -409,14 +409,9 @@ mod tests { 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 = ceremony::identity_request(sent).unwrap(); - let r = ceremony::identity_response( - recv, - "id", - ceremony::IdShape::JsonString, - "username", - ) - .unwrap(); + let s = Layout::identity_request(sent).unwrap(); + let r = Layout::identity_response(recv, "id", IdShape::JsonString, "username") + .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); @@ -431,8 +426,8 @@ mod tests { 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 = ceremony::token_request(sent, None).unwrap(); - let r = ceremony::token_response(recv).unwrap(); + let s = Layout::token_request(sent, None).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 @@ -447,8 +442,8 @@ mod tests { 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 = ceremony::token_request(sent, Some("client_secret")).unwrap(); - let r = ceremony::token_response(recv).unwrap(); + let s = Layout::token_request(sent, Some("client_secret")).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); diff --git a/crates/libid-tlsn/tests/ceremony_end_to_end.rs b/crates/libid-tlsn/tests/ceremony_end_to_end.rs index 55ab3426..f286f389 100644 --- a/crates/libid-tlsn/tests/ceremony_end_to_end.rs +++ b/crates/libid-tlsn/tests/ceremony_end_to_end.rs @@ -23,7 +23,6 @@ use libid_tlsn::attest::{ AttestationInput, }; use libid_transcript::ceremony::{ - self, IdShape, Layout, }; @@ -137,8 +136,8 @@ fn count(haystack: &[u8], needle: &[u8]) -> usize { #[test] fn the_token_session_produces_a_record_the_verifier_accepts() { - let sl = ceremony::token_request(TOKEN_SENT, None).unwrap(); - let rl = ceremony::token_response(TOKEN_RECV).unwrap(); + let sl = Layout::token_request(TOKEN_SENT, None).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"); @@ -186,8 +185,8 @@ fn the_token_session_produces_a_record_the_verifier_accepts() { #[test] fn the_identity_session_produces_a_record_the_verifier_accepts() { - let sl = ceremony::identity_request(ID_SENT).unwrap(); - let rl = ceremony::identity_response(ID_RECV, "id", IdShape::JsonString, "username") + let sl = Layout::identity_request(ID_SENT).unwrap(); + let rl = Layout::identity_response(ID_RECV, "id", IdShape::JsonString, "username") .unwrap(); let data = record(ID_SENT, ID_RECV, &sl, &rl, 1_770_000_000); @@ -255,14 +254,14 @@ fn both_sessions_encode_and_carry_their_own_lengths() { ( TOKEN_SENT, TOKEN_RECV, - ceremony::token_request(TOKEN_SENT, None).unwrap(), - ceremony::token_response(TOKEN_RECV).unwrap(), + Layout::token_request(TOKEN_SENT, None).unwrap(), + Layout::token_response(TOKEN_RECV).unwrap(), ), ( ID_SENT, ID_RECV, - ceremony::identity_request(ID_SENT).unwrap(), - ceremony::identity_response(ID_RECV, "id", IdShape::JsonString, "username") + Layout::identity_request(ID_SENT).unwrap(), + Layout::identity_response(ID_RECV, "id", IdShape::JsonString, "username") .unwrap(), ), ] { @@ -284,8 +283,8 @@ fn the_github_exchange_commits_a_suffix_and_nothing_else() { const RECV: &[u8] = b"HTTP/1.1 200 OK\r\n\r\n{\"token_type\":\"bearer\",\"access_token\":\"SECRETBEARER\"}"; - let sl = ceremony::token_request(SENT, Some("client_secret")).unwrap(); - let rl = ceremony::token_response(RECV).unwrap(); + let sl = Layout::token_request(SENT, Some("client_secret")).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"); diff --git a/crates/libid-transcript/src/ceremony.rs b/crates/libid-transcript/src/ceremony.rs index 9c596c7e..3a2cba1d 100644 --- a/crates/libid-transcript/src/ceremony.rs +++ b/crates/libid-transcript/src/ceremony.rs @@ -66,123 +66,6 @@ fn complement(reveal: &[Range], len: usize) -> Vec> { out } -/// A one-range reveal list. Spelled this way because a `vec![a..b]` literal -/// trips a lint that exists to catch `vec![0; n]` typos. -fn one(range: Range) -> Vec> { - core::iter::once(range).collect() -} - -fn layout(mut reveal: Vec>, len: usize) -> Layout { - // `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 - // `identity_response`, whose two members arrive in whatever order the - // platform serialized them, no longer carries a sort of its own. - 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], - secret_field: Option<&str>, -) -> Result { - let Some(field) = secret_field else { - return Ok(layout(one(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(layout(one(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 - // `token_request`, the field names of `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 = compute_json_member(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(layout( - vec![ - 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(layout( - vec![0..value_start, value_end..sent.len()], - sent.len(), - )) -} - /// Which shape the platform's immutable identifier takes. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum IdShape { @@ -193,54 +76,203 @@ pub enum IdShape { JsonInteger, } -/// 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. -/// -/// 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], - id_field: &str, - id_shape: IdShape, - handle_field: &str, -) -> Result { - // The bare-integer form takes its structural terminator with it, which is - // what proves the revealed digits are the whole number. - let id = compute_id_snippet_range(recv, id_field, id_shape == IdShape::JsonString) - .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; `layout` sorts, so this does not assume - // one. - Ok(layout(vec![id, handle], recv.len())) +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. Callers outside this + /// module state layouts this module does not know -- `libid-tlsn`'s legacy + /// prover builds two whose ranges deliberately do not tile -- 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], + secret_field: Option<&str>, + ) -> Result { + let Some(field) = 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 = compute_json_member(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], + id_field: &str, + id_shape: IdShape, + handle_field: &str, + ) -> Result { + // The bare-integer form takes its structural terminator with it, which is + // what proves the revealed digits are the whole number. + let id = + compute_id_snippet_range(recv, id_field, id_shape == IdShape::JsonString) + .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)] @@ -282,7 +314,7 @@ mod tests { recv.extend_from_slice(b"\r\n"); } recv.extend_from_slice(b"0\r\n\r\n"); - assert!(token_response(&recv).is_err()); + assert!(Layout::token_response(&recv).is_err()); } #[test] @@ -292,7 +324,7 @@ mod tests { // 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!(token_response(&recv).is_err()); + assert!(Layout::token_response(&recv).is_err()); } #[test] @@ -304,7 +336,7 @@ mod tests { br#"{"access_token":"gh:u,A}BC","token_type":"bearer"}"#, ] .concat(); - let l = token_response(&recv).unwrap(); + 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. @@ -327,7 +359,7 @@ mod tests { r#"{"access_token":"real"}"#, ) .as_bytes(); - let l = token_response(recv).unwrap(); + let l = Layout::token_response(recv).unwrap(); let revealed: Vec = l .reveal .iter() @@ -341,7 +373,7 @@ mod tests { #[test] fn the_x_token_request_is_revealed_whole() { - let l = token_request(X_TOKEN_REQ, None).unwrap(); + let l = Layout::token_request(X_TOKEN_REQ, None).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())); @@ -350,7 +382,7 @@ mod tests { #[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 = token_request(req, Some("client_secret")).unwrap(); + let l = Layout::token_request(req, Some("client_secret")).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. @@ -364,7 +396,7 @@ mod tests { #[test] fn a_missing_secret_is_an_error_not_a_silent_reveal() { assert_eq!( - token_request(X_TOKEN_REQ, Some("client_secret")), + Layout::token_request(X_TOKEN_REQ, Some("client_secret")), Err(LayoutError::MissingCredential) ); } @@ -373,7 +405,7 @@ mod tests { 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 = token_response(recv).unwrap(); + let l = Layout::token_response(recv).unwrap(); assert!(tiles(&l, recv.len())); assert_eq!( recv[l.reveal[0].clone()].to_vec(), @@ -387,7 +419,7 @@ mod tests { #[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 = identity_request(sent).unwrap(); + 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()); @@ -400,7 +432,9 @@ mod tests { #[test] fn a_request_without_the_credential_header_is_an_error() { assert_eq!( - identity_request(b"GET /2/users/me HTTP/1.1\r\nhost: api.x.com\r\n\r\n"), + Layout::identity_request( + b"GET /2/users/me HTTP/1.1\r\nhost: api.x.com\r\n\r\n" + ), Err(LayoutError::MissingHeader("authorization")) ); } @@ -408,7 +442,8 @@ mod tests { #[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 = identity_response(recv, "id", IdShape::JsonString, "username").unwrap(); + let l = Layout::identity_response(recv, "id", IdShape::JsonString, "username") + .unwrap(); assert!(tiles(&l, recv.len())); assert_eq!(l.reveal.len(), 2); // Whole members, delimiters included -- so the verifier reads the @@ -428,7 +463,8 @@ mod tests { // 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 = identity_response(recv, "id", IdShape::JsonString, "username").unwrap(); + let l = Layout::identity_response(recv, "id", IdShape::JsonString, "username") + .unwrap(); assert!(tiles(&l, recv.len())); assert!(!l.commit.is_empty(), "the rest of the response is hidden"); for r in &l.reveal { @@ -451,7 +487,8 @@ mod tests { #[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 = identity_response(recv, "id", IdShape::JsonString, "username").unwrap(); + let l = Layout::identity_response(recv, "id", IdShape::JsonString, "username") + .unwrap(); assert!(tiles(&l, recv.len())); let revealed: usize = l .reveal @@ -470,7 +507,7 @@ mod tests { 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!( - identity_response(recv, "id", IdShape::JsonString, "username"), + Layout::identity_response(recv, "id", IdShape::JsonString, "username"), Err(LayoutError::MissingField(_)) )); } From 073e52f31c684605a2ea2647b8b42005ad5e825f Mon Sep 17 00:00:00 2001 From: xgreenx Date: Tue, 8 Sep 2026 20:04:44 +0100 Subject: [PATCH 40/65] refactor(transcript): the member finders are constructors on JsonMember `json_member_in` and `compute_json_member` both produce a `JsonMember` and differ in exactly one thing: which bytes their offsets are measured against. That is the difference this module gets wrong most expensively, and neither name said it. A `find_`/`compute_` prefix pair is not a coordinate system. So they become `JsonMember::in_body` -- offsets into the bytes handed in -- and `JsonMember::in_response`, which locates the response body first and returns offsets into the RAW `recv` transcript. A reveal layout selects ranges of the transcript, so a body-relative range handed to one selects bytes somewhere up in the response headers: well formed, signed, and pointing at the wrong thing. Now the two are told apart at the call site by the word that names what they differ in. `in_body` is private, because a caller that scans whatever it is handed gets whichever match comes first -- a header's, if a header carries the delimiter -- and that is the bug REQ-COMMON-39 costs a session over. The public door stays `in_response`. `find_json_snippet_range` and `compute_field_snippet_range` keep their names, their signatures and their docs, including the argument for why the template is exactly the reader's. Each changes by one expression, because the helper it delegates to is renamed. `compute_json_member` leaves the crate-root re-export list; it appears in no published release -- neither v0.1.0 nor v0.2.0 carries the symbol -- so nothing outside this repository can be holding it. Signed-off-by: xgreenx Assisted-by: Claude Opus 5 --- crates/libid-transcript/src/ceremony.rs | 4 +- crates/libid-transcript/src/lib.rs | 1 - crates/libid-transcript/src/ranges.rs | 121 ++++++++++++++---------- 3 files changed, 75 insertions(+), 51 deletions(-) diff --git a/crates/libid-transcript/src/ceremony.rs b/crates/libid-transcript/src/ceremony.rs index 3a2cba1d..af7b6936 100644 --- a/crates/libid-transcript/src/ceremony.rs +++ b/crates/libid-transcript/src/ceremony.rs @@ -22,7 +22,7 @@ use std::ops::Range; use crate::ranges::{ compute_field_snippet_range, compute_id_snippet_range, - compute_json_member, + JsonMember, }; /// What one direction of one session discloses. @@ -167,7 +167,7 @@ impl Layout { // 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 = compute_json_member(recv, FIELD).ok_or_else(missing)?; + 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, diff --git a/crates/libid-transcript/src/lib.rs b/crates/libid-transcript/src/lib.rs index 8de6154b..db304f8b 100644 --- a/crates/libid-transcript/src/lib.rs +++ b/crates/libid-transcript/src/lib.rs @@ -24,7 +24,6 @@ pub use ranges::{ compute_field_snippet_range, compute_id_snippet_range, compute_id_snippet_range_after, - compute_json_member, extract_header, extract_response_body, find_header_range, diff --git a/crates/libid-transcript/src/ranges.rs b/crates/libid-transcript/src/ranges.rs index ba13ceab..03af50b4 100644 --- a/crates/libid-transcript/src/ranges.rs +++ b/crates/libid-transcript/src/ranges.rs @@ -222,7 +222,7 @@ pub fn compute_field_reveal_range(recv: &[u8], field_name: &str) -> Option Option> { - json_member_in(body, field).map(|member| member.member) + JsonMember::in_body(body, field).map(|member| member.member) } /// A `"field":"value"` member, and the value inside it. @@ -239,21 +239,72 @@ pub struct JsonMember { pub value: Range, } -/// Locate the member and its value in one pass over `body`. -fn json_member_in(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(JsonMember { - // From the opening `"` of the key through the closing `"` of the value. - member: start..close.checked_add(1)?, - value: value..close, - }) +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, + }) + } + + /// 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)?, + }) + } } /// The first occurrence of `needle`, or nothing. @@ -310,33 +361,7 @@ pub fn compute_field_snippet_range( recv: &[u8], field_name: &str, ) -> Option> { - compute_json_member(recv, field_name).map(|found| found.member) -} - -/// [`compute_field_snippet_range`], keeping the value boundary too. -/// -/// For a caller that reveals a member's delimiters and commits what sits -/// between them: the boundaries come from the scan that found them, so no -/// caller restates the template to recover one. -pub fn compute_json_member(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 = json_member_in(&decoded_body, field_name)?; - let raw = json_member_in(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(JsonMember { - member: at(raw.member.start)?..at(raw.member.end)?, - value: at(raw.value.start)?..at(raw.value.end)?, - }) + JsonMember::in_response(recv, field_name).map(|found| found.member) } /// Like [`compute_id_snippet_range`] but only matches `field_name` after the @@ -588,7 +613,7 @@ mod tests { out } - /// The property every caller of `compute_json_member` depends on: the + /// 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 @@ -615,7 +640,7 @@ mod tests { #[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 = compute_json_member(recv, "access_token").unwrap(); + let found = JsonMember::in_response(recv, "access_token").unwrap(); assert_brackets(recv, &found, "access_token", b"ghu_ABC"); } @@ -625,7 +650,7 @@ mod tests { // 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 = compute_json_member(recv, "access_token").unwrap(); + let found = JsonMember::in_response(recv, "access_token").unwrap(); assert_brackets(recv, &found, "access_token", b"a:b,c}d"); } @@ -634,7 +659,7 @@ mod tests { // 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 = compute_json_member(recv, "access_token").unwrap(); + let found = JsonMember::in_response(recv, "access_token").unwrap(); assert!(found.value.is_empty()); assert_eq!(&recv[found.member.clone()], b"\"access_token\":\"\""); } @@ -645,7 +670,7 @@ mod tests { // two must not drift apart. let recv = b"HTTP/1.1 200 OK\r\n\r\n{\"login\":\"octocat\",\"id\":1}"; assert_eq!( - compute_json_member(recv, "login").unwrap().member, + JsonMember::in_response(recv, "login").unwrap().member, compute_field_snippet_range(recv, "login").unwrap() ); } From 49b0ed693d0d29f60209c30db34001c1845773ac Mon Sep 17 00:00:00 2001 From: xgreenx Date: Tue, 8 Sep 2026 20:06:15 +0100 Subject: [PATCH 41/65] refactor(tlsn): attesting is what a session does, not what a function does `attested_data` took four arguments that were never four unrelated things: a transcript, the authority behind it, the commitments made inside it, and the clock reading that closed it are four readings of ONE session, which every caller had to keep in step by hand. They are now the fields of `ObservedSession`, and `attested_data` is a method on it. The constructor form is not available and the doc says so. `AttestedData` is defined in `libid-ceremony`, which is published to crates.io and must never name a tlsn type, so an inherent impl for it cannot live in this crate. The object that owns the inputs carries the producer instead -- which is also the better half of the trade, because the type is where the rule now lives: every field of `ObservedSession` is something the notary SAW, so a value it was merely told has no field to arrive in and cannot reach the signed bytes by being appended to an argument list. That is REQ-COMMON-33 held by the type rather than by review. `direction_block` becomes a private method and loses two of its three arguments. Those two had to come from the same session or the block describes bytes nobody observed together; on `self` that pairing is unspellable-wrong rather than merely uncommon. `AttestationInput` is retired. It was a struct around one `u64`, and its `created_at` doc -- the notary's own clock, never the prover's, because the freshness window is measured from it -- moves onto the field verbatim. `ObservedSession` borrows and is `Copy`: the caller already holds all four, and copying a transcript to describe it would double the peak memory of a notarization to say nothing new. It is deliberately not `Debug`, because printing one prints the revealed transcript -- the prover's request with its credential framing around it -- into whatever log line was open at the time. No signed byte moves: the record is built from the same values in the same order, and the fixture that pins those bytes against the Solidity decoder is not touched. The README's usage sketch is corrected while it is being rewritten -- it named `libid_tlsn::attested_data`, a path that never existed. Signed-off-by: xgreenx Assisted-by: Claude Opus 5 --- README.md | 3 +- crates/libid-tlsn/src/attest.rs | 295 +++++++++++------- .../libid-tlsn/tests/ceremony_end_to_end.rs | 21 +- 3 files changed, 196 insertions(+), 123 deletions(-) diff --git a/README.md b/README.md index c03982a4..b6224155 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,8 @@ answers over the same socket: ```rust,ignore let result = libid_tlsn::verifier(socket).await?; -// build the attested data from the session with libid_tlsn::attested_data, +// describe the session as a libid_tlsn::attest::ObservedSession and call +// .attested_data() on it, // sign its digest with libid_signer::ManagedSigner, then: libid_transcript::write_msg(&mut result.recovered_io, &response).await?; ``` diff --git a/crates/libid-tlsn/src/attest.rs b/crates/libid-tlsn/src/attest.rs index f08514c1..2d507373 100644 --- a/crates/libid-tlsn/src/attest.rs +++ b/crates/libid-tlsn/src/attest.rs @@ -26,14 +26,54 @@ use tlsn::{ }, }; -/// What the profile pins, and what only the notary knows. -pub struct AttestationInput { +/// 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. [`ObservedSession::attested_data`] + /// 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 an - /// argument rather than a call to the clock here so a test can pin it; 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, } @@ -56,108 +96,132 @@ fn u32_of(value: usize) -> Result { u32::try_from(value).map_err(|_| AttestError::OffsetTooLarge(value)) } -/// Build the attested data for one notarized session. -/// -/// 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. -/// `authority` is 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. -pub fn attested_data( - partial: &PartialTranscript, - authority: &str, - commitments: &[TranscriptCommitment], - input: AttestationInput, -) -> Result { - Ok(AttestedData { - // The canonical authority of section 9: the lowercase ASCII TLS server - // name the notary authenticated, with no trailing dot. It is a signed - // field rather than 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). - authority_id: tag(&authority.to_ascii_lowercase()), - created_at: input.created_at, - sent_transcript_length: u32_of(partial.len_sent())?, - recv_transcript_length: u32_of(partial.len_received())?, - sent: direction_block(partial, commitments, Direction::Sent)?, - received: direction_block(partial, commitments, Direction::Received)?, - }) -} - -fn direction_block( - partial: &PartialTranscript, - commitments: &[TranscriptCommitment], - direction: Direction, -) -> Result { - let (authed, data) = match direction { - Direction::Sent => (partial.sent_authed(), partial.sent_unsafe()), - Direction::Received => (partial.received_authed(), partial.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(), - }); +impl ObservedSession<'_> { + /// Lay this session out as the [`AttestedData`] of ceremony-common + /// section 9.1. + /// + /// A method rather than a constructor because the constructor form is not + /// available here: `AttestedData` is defined in `libid-ceremony`, which is + /// published to crates.io and must never name a tlsn type, so an inherent + /// impl for it cannot live in this crate. The object that owns the inputs + /// carries the producer instead -- and the four arguments this replaces + /// were never four unrelated things. They were four readings of one + /// session, which a caller had to keep in step by hand. + /// + /// 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. 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. + pub fn attested_data(&self) -> Result { + Ok(AttestedData { + // The canonical authority of section 9: the lowercase ASCII TLS + // server name the notary authenticated, with no trailing dot. + authority_id: tag(&self.authority.to_ascii_lowercase()), + created_at: self.created_at, + sent_transcript_length: u32_of(self.transcript.len_sent())?, + recv_transcript_length: u32_of(self.transcript.len_received())?, + sent: self.direction_block(Direction::Sent)?, + received: self.direction_block(Direction::Received)?, + }) } - let mut out = Vec::new(); - for commitment in 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; + /// One direction's revealed runs and its commitments, both in ascending + /// start order. + /// + /// The direction is the only parameter, because everything else it reads + /// belongs to the session -- and the two it used to take apart, the + /// transcript and the commitment list, must come from the SAME session or + /// the block describes bytes nobody observed together. Holding them on + /// `self` makes that pairing unspellable-wrong rather than merely + /// uncommon. + /// + /// Private, and not a constructor: `DirectionBlock` is `libid-ceremony`'s, + /// so a constructor for it cannot live here either, and nothing outside + /// this file has a reason to build one direction alone. + fn direction_block( + &self, + direction: Direction, + ) -> Result { + let (authed, data) = match direction { + Direction::Sent => { + (self.transcript.sent_authed(), self.transcript.sent_unsafe()) + } + Direction::Received => ( + self.transcript.received_authed(), + self.transcript.received_unsafe(), + ), }; - if hash.direction != 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)); + + // 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(), + }); } - // 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 mut out = Vec::new(); + for commitment in self.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 != 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)); + } - let value = hash.hash.value.as_bytes(); - let value: [u8; 32] = value - .try_into() - .map_err(|_| AttestError::BadCommitmentLength(value.len()))?; + // 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); - out.push(RangeCommitment { - start: u32_of(range.start)?, - end: u32_of(range.end)?, - commitment: value, - }); + Ok(DirectionBlock { + revealed, + commitments: out, + }) } - out.sort_by_key(|c| c.start); - - Ok(DirectionBlock { - revealed, - commitments: out, - }) } #[cfg(test)] @@ -200,8 +264,16 @@ mod tests { 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\"}"; - fn input() -> AttestationInput { - AttestationInput { + /// 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> { + ObservedSession { + transcript, + authority: "api.x.com", + commitments, created_at: 1_770_000_000, } } @@ -235,7 +307,7 @@ mod tests { // 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 = attested_data(&partial, "api.x.com", &commitments, input()).unwrap(); + let data = observed(&partial, &commitments).attested_data().unwrap(); assert_eq!(data.sent_transcript_length, SENT.len() as u32); assert_eq!(data.recv_transcript_length, RECV.len() as u32); } @@ -243,7 +315,7 @@ mod tests { #[test] fn encodes_to_the_length_its_own_fields_imply() { let (partial, commitments) = session(); - let data = attested_data(&partial, "api.x.com", &commitments, input()).unwrap(); + let data = observed(&partial, &commitments).attested_data().unwrap(); let encoded = data.encode().unwrap(); // No decoder here to round-trip against: decoding is the chain's and @@ -266,14 +338,14 @@ mod tests { // 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 = attested_data(&partial, "api.x.com", &commitments, input()).unwrap(); + let data = observed(&partial, &commitments).attested_data().unwrap(); assert_tiles(&data.sent, data.sent_transcript_length); } #[test] fn authority_is_the_authenticated_server_name() { let (partial, commitments) = session(); - let data = attested_data(&partial, "api.x.com", &commitments, input()).unwrap(); + let data = observed(&partial, &commitments).attested_data().unwrap(); assert_eq!(data.authority_id, tag("api.x.com")); // And it is NOT taken from a Host header the prover composed. assert_ne!(data.authority_id, tag("evil.example")); @@ -289,7 +361,7 @@ mod tests { }; h.hash.alg = HashAlgId::BLAKE3; assert!(matches!( - attested_data(&partial, "api.x.com", &commitments, input()), + observed(&partial, &commitments).attested_data(), Err(AttestError::WrongCommitmentAlgorithm(_)) )); } @@ -305,7 +377,7 @@ mod tests { hash: hash32(7), })]; assert!(matches!( - attested_data(&partial, "api.x.com", &commitments, input()), + observed(&partial, &commitments).attested_data(), Err(AttestError::DisjointCommitment(2)) )); } @@ -341,8 +413,7 @@ mod tests { for recv in [RECV, other_recv] { let partial = Transcript::new(SENT, recv) .to_partial(sent_revealed.clone(), RangeSet::from(0..recv.len())); - let data = - attested_data(&partial, "api.x.com", &commitments, input()).unwrap(); + let data = observed(&partial, &commitments).attested_data().unwrap(); headers.push(data.encode().unwrap()[..144].to_vec()); } assert_eq!( @@ -356,11 +427,13 @@ mod tests { let b = Transcript::new(SENT, other_recv) .to_partial(sent_revealed, RangeSet::from(0..other_recv.len())); assert_ne!( - attested_data(&a, "api.x.com", &commitments, input()) + observed(&a, &commitments) + .attested_data() .unwrap() .encode() .unwrap(), - attested_data(&b, "api.x.com", &commitments, input()) + observed(&b, &commitments) + .attested_data() .unwrap() .encode() .unwrap(), @@ -401,7 +474,7 @@ mod tests { })); } } - attested_data(&partial, "api.x.com", &commitments, input()).unwrap() + observed(&partial, &commitments).attested_data().unwrap() } #[test] diff --git a/crates/libid-tlsn/tests/ceremony_end_to_end.rs b/crates/libid-tlsn/tests/ceremony_end_to_end.rs index f286f389..b852afb0 100644 --- a/crates/libid-tlsn/tests/ceremony_end_to_end.rs +++ b/crates/libid-tlsn/tests/ceremony_end_to_end.rs @@ -18,10 +18,7 @@ use libid_ceremony::attestation::{ AttestedData, DirectionBlock, }; -use libid_tlsn::attest::{ - attested_data, - AttestationInput, -}; +use libid_tlsn::attest::ObservedSession; use libid_transcript::ceremony::{ IdShape, Layout, @@ -53,7 +50,8 @@ fn hash32(byte: u8) -> TypedHash { } } -/// Turn a pair of layouts into what a notary's verifier hands `attested_data`. +/// 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 @@ -88,12 +86,13 @@ fn record( })); } - attested_data( - &partial, - "api.x.com", - &commitments, - AttestationInput { created_at }, - ) + ObservedSession { + transcript: &partial, + authority: "api.x.com", + commitments: &commitments, + created_at, + } + .attested_data() .expect("the layouts produce an attestable session") } From 1b2a13010c20d95c87591f998d154d6c4696444e Mon Sep 17 00:00:00 2001 From: xgreenx Date: Tue, 8 Sep 2026 20:07:06 +0100 Subject: [PATCH 42/65] refactor(ceremony): the authority id is named for the field it fills `tag` was a free function called `tag` that produced the record's one remaining 32-byte tag, and said nothing about what may be hashed into it. It becomes `AttestedData::authority_id_of`, an associated function on the record whose single field it fills. The name is the rule. 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. That substitution is precisely the one the transcript cannot rule out, because the request carries the authority only where the prover wrote it (REQ-COMMON-21, REQ-COMMON-21A). A free `tag(&str)` invited it; a constructor named after the field does not. Rename only, and the byte-inertness is a fact rather than a claim: the caller still lowercases at the call site, the sample record still builds from the same string, and `CROSS_LANGUAGE_FIXTURE`, `CROSS_LANGUAGE_DIGEST` and `agrees_with_the_solidity_decoder` are not edited in this commit -- that test passing is what proves no signed byte moved. The canonicalization moves in the next commit, where it can be argued and tested on its own. Signed-off-by: xgreenx Assisted-by: Claude Opus 5 --- crates/libid-ceremony/src/attestation.rs | 36 ++++++++++++++++-------- crates/libid-tlsn/src/attest.rs | 15 +++++++--- 2 files changed, 36 insertions(+), 15 deletions(-) diff --git a/crates/libid-ceremony/src/attestation.rs b/crates/libid-ceremony/src/attestation.rs index 57422723..94023a23 100644 --- a/crates/libid-ceremony/src/attestation.rs +++ b/crates/libid-ceremony/src/attestation.rs @@ -77,16 +77,6 @@ pub struct AttestedData { /// two transcript lengths. pub const HEADER_LEN: usize = 32 + 8 + 4 + 4; -/// Hash the canonical authority bytes into `authorityId` (REQ-COMMON-21, -/// REQ-COMMON-21A). -/// -/// 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 tag(namespaced: &str) -> [u8; 32] { - keccak256(namespaced.as_bytes()) -} - /// Big-endian, fixed-width, no varints: the decoder is Solidity, which has no /// use for a compact integer that costs a branch to read. /// @@ -102,6 +92,30 @@ const WIRE: bincode::config::Configuration< .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. + /// + /// A string rather than a server-name type: this crate is published and + /// knows nothing about how a TLS library 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.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 @@ -128,7 +142,7 @@ mod tests { // 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: tag("api.x.com"), + authority_id: AttestedData::authority_id_of("api.x.com"), created_at: 1_770_000_000, sent_transcript_length: 60, recv_transcript_length: 40, diff --git a/crates/libid-tlsn/src/attest.rs b/crates/libid-tlsn/src/attest.rs index 2d507373..5fd3d49c 100644 --- a/crates/libid-tlsn/src/attest.rs +++ b/crates/libid-tlsn/src/attest.rs @@ -11,7 +11,6 @@ //! rule it keeps in full. use libid_ceremony::attestation::{ - tag, AttestedData, DirectionBlock, RangeCommitment, @@ -127,7 +126,9 @@ impl ObservedSession<'_> { Ok(AttestedData { // The canonical authority of section 9: the lowercase ASCII TLS // server name the notary authenticated, with no trailing dot. - authority_id: tag(&self.authority.to_ascii_lowercase()), + authority_id: AttestedData::authority_id_of( + &self.authority.to_ascii_lowercase(), + ), created_at: self.created_at, sent_transcript_length: u32_of(self.transcript.len_sent())?, recv_transcript_length: u32_of(self.transcript.len_received())?, @@ -346,9 +347,15 @@ mod tests { fn authority_is_the_authenticated_server_name() { let (partial, commitments) = session(); let data = observed(&partial, &commitments).attested_data().unwrap(); - assert_eq!(data.authority_id, tag("api.x.com")); + 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, tag("evil.example")); + assert_ne!( + data.authority_id, + AttestedData::authority_id_of("evil.example") + ); } #[test] From a79d322f9bb35e55ae74490a930758d10062a1e4 Mon Sep 17 00:00:00 2001 From: xgreenx Date: Tue, 8 Sep 2026 20:08:55 +0100 Subject: [PATCH 43/65] fix(ceremony): canonicalize the authority where the field is built `AttestedData::authority_id_of` now lowercases its input, and the caller in `libid-tlsn` stops doing it by hand. REQ-COMMON-21A fixes the preimage as the lowercase ASCII server name, 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. The id is compared on chain against a constant a profile pins, so the record that misses the rule is signed, well formed, and refused by every verifier with nothing pointing at the capital letter. Kept at the call site, it was a step each future caller had to remember, and one caller already did not: `libid-tlsn` lowercased while the notary's mock prover passed a literal that happened to already be lowercase. A rule kept by accident at one of two call sites is a rule the next call site breaks. No produced byte changes today -- every input in either repository is already lowercase, which is why this could ride behind the rename rather than in front of it. What changes is that the rule can no longer be skipped. Two tests, and both fail if the `to_ascii_lowercase` is taken back out: one on the constructor, one on a whole session built with a mixed-case authority, which is the call site that used to hold the rule. The trailing dot section 9 also asks for is deliberately still not stripped, and the doc now says so out loud rather than leaving a comment claiming it. Changing what a signed field hashes needs a change that argues for it, with a caller that can produce an FQDN form to test against. Signed-off-by: xgreenx Assisted-by: Claude Opus 5 --- crates/libid-ceremony/src/attestation.rs | 30 +++++++++++++++++++++- crates/libid-tlsn/src/attest.rs | 32 +++++++++++++++++++----- 2 files changed, 55 insertions(+), 7 deletions(-) diff --git a/crates/libid-ceremony/src/attestation.rs b/crates/libid-ceremony/src/attestation.rs index 94023a23..49e79437 100644 --- a/crates/libid-ceremony/src/attestation.rs +++ b/crates/libid-ceremony/src/attestation.rs @@ -105,6 +105,21 @@ impl AttestedData { /// where the prover wrote it. Naming the record puts the rule beside the /// field. /// + /// Canonicalization is ASCII lowercase, and 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. + /// + /// Section 9 also asks for no trailing dot, and this does NOT strip one, + /// exactly as `tag` did not. Changing what a signed field hashes belongs in + /// a change that argues for it and tests it, not in a rename. + /// /// A string rather than a server-name type: this crate is published and /// knows nothing about how a TLS library models a name, which is also what /// keeps this mapping testable without a session. @@ -113,7 +128,7 @@ impl AttestedData { /// 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.as_bytes()) + keccak256(server_name.to_ascii_lowercase().as_bytes()) } /// Lay the record out. This does NOT judge it: a malformed record is the @@ -196,6 +211,19 @@ mod tests { ); } + #[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(); diff --git a/crates/libid-tlsn/src/attest.rs b/crates/libid-tlsn/src/attest.rs index 5fd3d49c..676a0b2b 100644 --- a/crates/libid-tlsn/src/attest.rs +++ b/crates/libid-tlsn/src/attest.rs @@ -124,11 +124,7 @@ impl ObservedSession<'_> { /// observe. pub fn attested_data(&self) -> Result { Ok(AttestedData { - // The canonical authority of section 9: the lowercase ASCII TLS - // server name the notary authenticated, with no trailing dot. - authority_id: AttestedData::authority_id_of( - &self.authority.to_ascii_lowercase(), - ), + authority_id: AttestedData::authority_id_of(self.authority), created_at: self.created_at, sent_transcript_length: u32_of(self.transcript.len_sent())?, recv_transcript_length: u32_of(self.transcript.len_received())?, @@ -270,10 +266,19 @@ mod tests { 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: "api.x.com", + authority, commitments, created_at: 1_770_000_000, } @@ -358,6 +363,21 @@ mod tests { ); } + #[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 = observed_at(&partial, &commitments, "API.X.com") + .attested_data() + .unwrap(); + assert_eq!( + data.authority_id, + AttestedData::authority_id_of("api.x.com") + ); + } + #[test] fn refuses_a_blake3_commitment() { // The notarization library's default. The circuit computes SHA-256, so From 8d234923311199d45a9d2475be6319396c86e0b2 Mon Sep 17 00:00:00 2001 From: xgreenx Date: Tue, 8 Sep 2026 22:42:46 +0100 Subject: [PATCH 44/65] test(transcript): pin the sort every identity response depends on `Layout::revealing` sorts its reveals, and `Layout::identity_response` is the constructor that needs it: JSON member order is not the platform's promise, but the arguments name the id first regardless. When a response serializes the handle first, the two arrive out of order. Nothing here exercised that. Every fixture in this module happens to put `id` before `username`, so the sort was load-bearing and untested -- and the failure it prevents is not a wrong value but a layout that tiles nothing: `complement` walks the reveals taking each as starting where the last one ended, so an unsorted pair reads as overlap, and the Platform Verifier refuses every honest ceremony built on it. One response with the members the other way round. Removing the `sort_by_key` fails it, on the overlap `debug_assert!` -- so this covers that assertion too, which nothing reached before either. Signed-off-by: xgreenx Assisted-by: Claude Opus 5 --- crates/libid-transcript/src/ceremony.rs | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/crates/libid-transcript/src/ceremony.rs b/crates/libid-transcript/src/ceremony.rs index af7b6936..226911a9 100644 --- a/crates/libid-transcript/src/ceremony.rs +++ b/crates/libid-transcript/src/ceremony.rs @@ -458,6 +458,30 @@ mod tests { ); } + #[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, "id", IdShape::JsonString, "username") + .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 From 40bea698b0b7402d1f95ac6bffaeef31de2ac0b4 Mon Sep 17 00:00:00 2001 From: xgreenx Date: Tue, 8 Sep 2026 22:47:16 +0100 Subject: [PATCH 45/65] refactor(tlsn): put the constructor on the record, through a local trait `ObservedSession::attested_data` became `AttestedData::from_session`, so the producer sits on the type it produces after all. The earlier commit claimed the constructor form was unavailable. That was too quick. An INHERENT impl for `AttestedData` cannot live here -- the type is `libid-ceremony`'s, and that crate is published and must never name a tlsn type -- but coherence only constrains foreign trait on foreign type. A LOCAL trait may be implemented for anything, including a foreign type, so the constructor can land where it belongs and the call site names what is being built before it names what it is built from: AttestedData::from_session(ObservedSession { transcript, authority, .. })? `FromObservedSession` has one implementor and will keep one. It is not an abstraction over records; it is the way to put a constructor where coherence would otherwise refuse one, and its doc says exactly that so nobody later reads it as an extension point. The cost is the usual one for an extension trait: it must be in scope at the call site, which rustc's own diagnostic points at. `ObservedSession` is unchanged and keeps `direction_block` as a private method -- that one really is a method, since it reads the session and produces one direction of the record from it. No signed byte moves. The record is built from the same values in the same order; the fields simply arrive through `session.` instead of `self.`, and the cross-language fixture is untouched and still passes. Downstream, the notary gains a trait import beside the call it was already going to rewrite. I rebuilt it against this branch to check: it compiles and its 16 tests pass. Signed-off-by: xgreenx Assisted-by: Claude Opus 5 --- README.md | 4 +- crates/libid-tlsn/src/attest.rs | 82 +++++++++++-------- .../libid-tlsn/tests/ceremony_end_to_end.rs | 10 ++- 3 files changed, 56 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index b6224155..017174d6 100644 --- a/README.md +++ b/README.md @@ -38,8 +38,8 @@ answers over the same socket: ```rust,ignore let result = libid_tlsn::verifier(socket).await?; -// describe the session as a libid_tlsn::attest::ObservedSession and call -// .attested_data() on it, +// describe the session as a libid_tlsn::attest::ObservedSession and build +// the record with AttestedData::from_session, // sign its digest with libid_signer::ManagedSigner, then: libid_transcript::write_msg(&mut result.recovered_io, &response).await?; ``` diff --git a/crates/libid-tlsn/src/attest.rs b/crates/libid-tlsn/src/attest.rs index 676a0b2b..ee60b211 100644 --- a/crates/libid-tlsn/src/attest.rs +++ b/crates/libid-tlsn/src/attest.rs @@ -62,8 +62,9 @@ pub struct ObservedSession<'a> { /// 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. [`ObservedSession::attested_data`] - /// splits them by direction and sorts them by offset, so a caller passes on + /// whatever order the prover made them. + /// [`AttestedData::from_session`] 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], @@ -95,17 +96,25 @@ fn u32_of(value: usize) -> Result { u32::try_from(value).map_err(|_| AttestError::OffsetTooLarge(value)) } -impl ObservedSession<'_> { - /// Lay this session out as the [`AttestedData`] of ceremony-common - /// section 9.1. +/// Building a record of what a session was OBSERVED to be. +/// +/// A trait, because the constructor belongs on `AttestedData` and +/// `AttestedData` is `libid-ceremony`'s: that crate is published to crates.io +/// and must never name a tlsn type, so an inherent `impl` for it cannot live +/// here. A LOCAL trait can, and may be implemented for any type at all -- so +/// the constructor lands on the type it constructs, and the call site names +/// what is being built before it names what it is being built from. +/// +/// One implementor, deliberately. This is not an abstraction over records; it +/// is the way to put a constructor where coherence would otherwise refuse one. +/// Bring it into scope to use it, the way any extension trait is brought in. +pub trait FromObservedSession: Sized { + /// The record of `session`, laid out as ceremony-common section 9.1 fixes + /// it. /// - /// A method rather than a constructor because the constructor form is not - /// available here: `AttestedData` is defined in `libid-ceremony`, which is - /// published to crates.io and must never name a tlsn type, so an inherent - /// impl for it cannot live in this crate. The object that owns the inputs - /// carries the producer instead -- and the four arguments this replaces - /// were never four unrelated things. They were four readings of one - /// session, which a caller had to keep in step by hand. + /// 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 @@ -122,17 +131,23 @@ impl ObservedSession<'_> { /// -- 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. - pub fn attested_data(&self) -> Result { + fn from_session(session: ObservedSession<'_>) -> Result; +} + +impl FromObservedSession for AttestedData { + fn from_session(session: ObservedSession<'_>) -> Result { Ok(AttestedData { - authority_id: AttestedData::authority_id_of(self.authority), - created_at: self.created_at, - sent_transcript_length: u32_of(self.transcript.len_sent())?, - recv_transcript_length: u32_of(self.transcript.len_received())?, - sent: self.direction_block(Direction::Sent)?, - received: self.direction_block(Direction::Received)?, + 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: session.direction_block(Direction::Sent)?, + received: session.direction_block(Direction::Received)?, }) } +} +impl ObservedSession<'_> { /// One direction's revealed runs and its commitments, both in ascending /// start order. /// @@ -313,7 +328,7 @@ mod tests { // 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 = observed(&partial, &commitments).attested_data().unwrap(); + let data = AttestedData::from_session(observed(&partial, &commitments)).unwrap(); assert_eq!(data.sent_transcript_length, SENT.len() as u32); assert_eq!(data.recv_transcript_length, RECV.len() as u32); } @@ -321,7 +336,7 @@ mod tests { #[test] fn encodes_to_the_length_its_own_fields_imply() { let (partial, commitments) = session(); - let data = observed(&partial, &commitments).attested_data().unwrap(); + let data = AttestedData::from_session(observed(&partial, &commitments)).unwrap(); let encoded = data.encode().unwrap(); // No decoder here to round-trip against: decoding is the chain's and @@ -344,14 +359,14 @@ mod tests { // 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 = observed(&partial, &commitments).attested_data().unwrap(); + let data = AttestedData::from_session(observed(&partial, &commitments)).unwrap(); assert_tiles(&data.sent, data.sent_transcript_length); } #[test] fn authority_is_the_authenticated_server_name() { let (partial, commitments) = session(); - let data = observed(&partial, &commitments).attested_data().unwrap(); + let data = AttestedData::from_session(observed(&partial, &commitments)).unwrap(); assert_eq!( data.authority_id, AttestedData::authority_id_of("api.x.com") @@ -369,9 +384,9 @@ mod tests { // 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 = observed_at(&partial, &commitments, "API.X.com") - .attested_data() - .unwrap(); + let data = + AttestedData::from_session(observed_at(&partial, &commitments, "API.X.com")) + .unwrap(); assert_eq!( data.authority_id, AttestedData::authority_id_of("api.x.com") @@ -388,7 +403,7 @@ mod tests { }; h.hash.alg = HashAlgId::BLAKE3; assert!(matches!( - observed(&partial, &commitments).attested_data(), + AttestedData::from_session(observed(&partial, &commitments)), Err(AttestError::WrongCommitmentAlgorithm(_)) )); } @@ -404,7 +419,7 @@ mod tests { hash: hash32(7), })]; assert!(matches!( - observed(&partial, &commitments).attested_data(), + AttestedData::from_session(observed(&partial, &commitments)), Err(AttestError::DisjointCommitment(2)) )); } @@ -440,7 +455,8 @@ mod tests { for recv in [RECV, other_recv] { let partial = Transcript::new(SENT, recv) .to_partial(sent_revealed.clone(), RangeSet::from(0..recv.len())); - let data = observed(&partial, &commitments).attested_data().unwrap(); + let data = + AttestedData::from_session(observed(&partial, &commitments)).unwrap(); headers.push(data.encode().unwrap()[..144].to_vec()); } assert_eq!( @@ -454,13 +470,11 @@ mod tests { let b = Transcript::new(SENT, other_recv) .to_partial(sent_revealed, RangeSet::from(0..other_recv.len())); assert_ne!( - observed(&a, &commitments) - .attested_data() + AttestedData::from_session(observed(&a, &commitments)) .unwrap() .encode() .unwrap(), - observed(&b, &commitments) - .attested_data() + AttestedData::from_session(observed(&b, &commitments)) .unwrap() .encode() .unwrap(), @@ -501,7 +515,7 @@ mod tests { })); } } - observed(&partial, &commitments).attested_data().unwrap() + AttestedData::from_session(observed(&partial, &commitments)).unwrap() } #[test] diff --git a/crates/libid-tlsn/tests/ceremony_end_to_end.rs b/crates/libid-tlsn/tests/ceremony_end_to_end.rs index b852afb0..861d08b0 100644 --- a/crates/libid-tlsn/tests/ceremony_end_to_end.rs +++ b/crates/libid-tlsn/tests/ceremony_end_to_end.rs @@ -18,7 +18,10 @@ use libid_ceremony::attestation::{ AttestedData, DirectionBlock, }; -use libid_tlsn::attest::ObservedSession; +use libid_tlsn::attest::{ + FromObservedSession, + ObservedSession, +}; use libid_transcript::ceremony::{ IdShape, Layout, @@ -86,13 +89,12 @@ fn record( })); } - ObservedSession { + AttestedData::from_session(ObservedSession { transcript: &partial, authority: "api.x.com", commitments: &commitments, created_at, - } - .attested_data() + }) .expect("the layouts produce an attestable session") } From b2e9a227af87561aafdd6c6b59e1eef07217df8b Mon Sep 17 00:00:00 2001 From: xgreenx Date: Tue, 8 Sep 2026 22:52:22 +0100 Subject: [PATCH 46/65] refactor(tlsn): one trait, both records, so the two constructors match `DirectionBlock` was the odd one out. `AttestedData` gained a constructor on the type it constructs, while the block beside it stayed a private method on `ObservedSession` -- two shapes for one job, in one file, for no reason beyond the order the commits landed in. `FromObservedSession` becomes `FromObserved`, and both records implement it: AttestedData::from_observed(ObservedSession { .. })? DirectionBlock::from_observed(ObservedDirection { session, direction })? Both are `libid-ceremony`'s types, so neither can take an inherent impl here and both needed the same answer. One generic trait gives it to them, rather than a trait each. The pair is also the layering, and saying so is what the symmetry buys: an attested record IS a header plus two direction blocks, and its impl now reads that way -- it builds each direction through the other constructor instead of inlining the walk twice. `ObservedDirection` carries the whole session rather than a transcript and a commitment list, for the reason `ObservedSession` is a struct rather than four arguments: those two must come from the SAME session or the block describes bytes nobody observed together. The direction is left as the only thing a caller chooses. No signed byte moves. The direction walk is the same loop over the same accessors, reached through `source.session.` instead of `self.`, and the cross-language fixture is untouched and still passes. Rebuilt the notary against this branch again: it compiles and its 16 tests pass, with the trait import renamed alongside the call. Signed-off-by: xgreenx Assisted-by: Claude Opus 5 --- README.md | 2 +- crates/libid-tlsn/src/attest.rs | 123 ++++++++++-------- .../libid-tlsn/tests/ceremony_end_to_end.rs | 4 +- 3 files changed, 72 insertions(+), 57 deletions(-) diff --git a/README.md b/README.md index 017174d6..28570cc2 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ answers over the same socket: ```rust,ignore let result = libid_tlsn::verifier(socket).await?; // describe the session as a libid_tlsn::attest::ObservedSession and build -// the record with AttestedData::from_session, +// the record with AttestedData::from_observed, // sign its digest with libid_signer::ManagedSigner, then: libid_transcript::write_msg(&mut result.recovered_io, &response).await?; ``` diff --git a/crates/libid-tlsn/src/attest.rs b/crates/libid-tlsn/src/attest.rs index ee60b211..9f778a84 100644 --- a/crates/libid-tlsn/src/attest.rs +++ b/crates/libid-tlsn/src/attest.rs @@ -63,7 +63,7 @@ pub struct ObservedSession<'a> { pub authority: &'a str, /// Every commitment the session produced, both directions together, in /// whatever order the prover made them. - /// [`AttestedData::from_session`] splits them by direction and sorts + /// [`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. @@ -96,20 +96,46 @@ fn u32_of(value: usize) -> Result { u32::try_from(value).map_err(|_| AttestError::OffsetTooLarge(value)) } -/// Building a record of what a session was OBSERVED to be. +/// One direction of an [`ObservedSession`], which is what a [`DirectionBlock`] +/// is built from. /// -/// A trait, because the constructor belongs on `AttestedData` and -/// `AttestedData` is `libid-ceremony`'s: that crate is published to crates.io -/// and must never name a tlsn type, so an inherent `impl` for it cannot live -/// here. A LOCAL trait can, and may be implemented for any type at all -- so -/// the constructor lands on the type it constructs, and the call site names -/// what is being built before it names what it is being 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` and that crate is +/// published to crates.io and must never name a tlsn type -- so an inherent +/// `impl` for either cannot live here. 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. /// -/// One implementor, deliberately. This is not an abstraction over records; it -/// is the way to put a constructor where coherence would otherwise refuse one. -/// Bring it into scope to use it, the way any extension trait is brought in. -pub trait FromObservedSession: Sized { - /// The record of `session`, laid out as ceremony-common section 9.1 fixes +/// 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, laid out as ceremony-common section 9.1 fixes /// it. /// /// The four values this reads were never four unrelated things: they are @@ -131,47 +157,36 @@ pub trait FromObservedSession: Sized { /// -- 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_session(session: ObservedSession<'_>) -> Result; -} - -impl FromObservedSession for AttestedData { - fn from_session(session: ObservedSession<'_>) -> Result { + 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: session.direction_block(Direction::Sent)?, - received: session.direction_block(Direction::Received)?, + sent: DirectionBlock::from_observed(of(Direction::Sent))?, + received: DirectionBlock::from_observed(of(Direction::Received))?, }) } } -impl ObservedSession<'_> { +impl FromObserved> for DirectionBlock { /// One direction's revealed runs and its commitments, both in ascending /// start order. /// - /// The direction is the only parameter, because everything else it reads - /// belongs to the session -- and the two it used to take apart, the - /// transcript and the commitment list, must come from the SAME session or - /// the block describes bytes nobody observed together. Holding them on - /// `self` makes that pairing unspellable-wrong rather than merely - /// uncommon. - /// - /// Private, and not a constructor: `DirectionBlock` is `libid-ceremony`'s, - /// so a constructor for it cannot live here either, and nothing outside - /// this file has a reason to build one direction alone. - fn direction_block( - &self, - direction: Direction, - ) -> Result { - let (authed, data) = match direction { - Direction::Sent => { - (self.transcript.sent_authed(), self.transcript.sent_unsafe()) - } + /// 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 => ( - self.transcript.received_authed(), - self.transcript.received_unsafe(), + source.session.transcript.received_authed(), + source.session.transcript.received_unsafe(), ), }; @@ -192,13 +207,13 @@ impl ObservedSession<'_> { } let mut out = Vec::new(); - for commitment in self.commitments { + 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 != direction { + if hash.direction != source.direction { continue; } // The notarization library defaults to BLAKE3 while the Proving Circuit @@ -328,7 +343,7 @@ mod tests { // 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_session(observed(&partial, &commitments)).unwrap(); + 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); } @@ -336,7 +351,7 @@ mod tests { #[test] fn encodes_to_the_length_its_own_fields_imply() { let (partial, commitments) = session(); - let data = AttestedData::from_session(observed(&partial, &commitments)).unwrap(); + 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 @@ -359,14 +374,14 @@ mod tests { // 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_session(observed(&partial, &commitments)).unwrap(); + let data = AttestedData::from_observed(observed(&partial, &commitments)).unwrap(); assert_tiles(&data.sent, data.sent_transcript_length); } #[test] fn authority_is_the_authenticated_server_name() { let (partial, commitments) = session(); - let data = AttestedData::from_session(observed(&partial, &commitments)).unwrap(); + let data = AttestedData::from_observed(observed(&partial, &commitments)).unwrap(); assert_eq!( data.authority_id, AttestedData::authority_id_of("api.x.com") @@ -385,7 +400,7 @@ mod tests { // that the record still comes out canonical when the caller does not. let (partial, commitments) = session(); let data = - AttestedData::from_session(observed_at(&partial, &commitments, "API.X.com")) + AttestedData::from_observed(observed_at(&partial, &commitments, "API.X.com")) .unwrap(); assert_eq!( data.authority_id, @@ -403,7 +418,7 @@ mod tests { }; h.hash.alg = HashAlgId::BLAKE3; assert!(matches!( - AttestedData::from_session(observed(&partial, &commitments)), + AttestedData::from_observed(observed(&partial, &commitments)), Err(AttestError::WrongCommitmentAlgorithm(_)) )); } @@ -419,7 +434,7 @@ mod tests { hash: hash32(7), })]; assert!(matches!( - AttestedData::from_session(observed(&partial, &commitments)), + AttestedData::from_observed(observed(&partial, &commitments)), Err(AttestError::DisjointCommitment(2)) )); } @@ -456,7 +471,7 @@ mod tests { let partial = Transcript::new(SENT, recv) .to_partial(sent_revealed.clone(), RangeSet::from(0..recv.len())); let data = - AttestedData::from_session(observed(&partial, &commitments)).unwrap(); + AttestedData::from_observed(observed(&partial, &commitments)).unwrap(); headers.push(data.encode().unwrap()[..144].to_vec()); } assert_eq!( @@ -470,11 +485,11 @@ mod tests { let b = Transcript::new(SENT, other_recv) .to_partial(sent_revealed, RangeSet::from(0..other_recv.len())); assert_ne!( - AttestedData::from_session(observed(&a, &commitments)) + AttestedData::from_observed(observed(&a, &commitments)) .unwrap() .encode() .unwrap(), - AttestedData::from_session(observed(&b, &commitments)) + AttestedData::from_observed(observed(&b, &commitments)) .unwrap() .encode() .unwrap(), @@ -515,7 +530,7 @@ mod tests { })); } } - AttestedData::from_session(observed(&partial, &commitments)).unwrap() + AttestedData::from_observed(observed(&partial, &commitments)).unwrap() } #[test] diff --git a/crates/libid-tlsn/tests/ceremony_end_to_end.rs b/crates/libid-tlsn/tests/ceremony_end_to_end.rs index 861d08b0..e25b4640 100644 --- a/crates/libid-tlsn/tests/ceremony_end_to_end.rs +++ b/crates/libid-tlsn/tests/ceremony_end_to_end.rs @@ -19,7 +19,7 @@ use libid_ceremony::attestation::{ DirectionBlock, }; use libid_tlsn::attest::{ - FromObservedSession, + FromObserved, ObservedSession, }; use libid_transcript::ceremony::{ @@ -89,7 +89,7 @@ fn record( })); } - AttestedData::from_session(ObservedSession { + AttestedData::from_observed(ObservedSession { transcript: &partial, authority: "api.x.com", commitments: &commitments, From f96a0f07510715c6cb4ef7d61d24b284e2ede6bc Mon Sep 17 00:00:00 2001 From: xgreenx Date: Tue, 8 Sep 2026 22:57:28 +0100 Subject: [PATCH 47/65] docs(tlsn): say why the crate boundary holds, not that it already shipped `FromObserved`'s doc said `libid-ceremony` "is published to crates.io". It is not: the sparse index answers 404 for it. The crate is new in this stack and has never been released, so a reader checking the claim finds it false and has no reason to trust the rest of the paragraph. What is true is the reason, and it is stronger than the claim: `libid-ceremony` is on the release job's publish list, and `tlsn` is an unpublished git dependency, so a crate naming `tlsn` cannot go to crates.io AT ALL. The boundary holds because of what the crate must be able to become, not because of what it already is -- which is also why it holds today, before any release. `attest.rs`'s own module doc already had the accurate word for this, "publishable". This matches it. Signed-off-by: xgreenx Assisted-by: Claude Opus 5 --- crates/libid-tlsn/src/attest.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/libid-tlsn/src/attest.rs b/crates/libid-tlsn/src/attest.rs index 9f778a84..65e05fbd 100644 --- a/crates/libid-tlsn/src/attest.rs +++ b/crates/libid-tlsn/src/attest.rs @@ -114,9 +114,11 @@ pub struct ObservedDirection<'a> { /// Building a `libid-ceremony` record out of what this crate observed. /// -/// A trait, because both records belong to `libid-ceremony` and that crate is -/// published to crates.io and must never name a tlsn type -- so an inherent -/// `impl` for either cannot live here. A LOCAL trait can, and may be +/// 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. From 02ce72267b8fb7ec489e1b05079c566ac1ee4527 Mon Sep 17 00:00:00 2001 From: xgreenx Date: Tue, 8 Sep 2026 23:14:49 +0100 Subject: [PATCH 48/65] feat(transcript): take the ceremony profiles from the chain's own table A layout is built from values a Platform Verifier compares byte for byte, and this crate had none of them: every `"id"`, `"username"` and `client_secret` in it was a test literal, and every real caller kept private copies. A wrong one failed on chain, where the error names an offset rather than the constant behind it. `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 verifier compares it against now come from one place. Released as 0.8.0, no dependencies of its own, so taking it costs a published consumer nothing. `IdShape` was the duplication that mattered. This crate declared one and the generated table carries another, saying the same thing about the same profile fact, and a caller holding a profile had to translate before it could build a layout. The local one goes; the generated one is re-exported in its place, along with the table as `ceremony::profiles`. The two identity constructors follow #11's own argument to its end. `identity_response` took `id_field`, `id_shape` and `handle_field` as three loose arguments, and `token_request` took a bare `Option<&str>` -- but those are not independent choices. They are readings of ONE profile, and mixing X's handle field with GitHub's id shape describes a session nobody ran. They now take `&IdentitySession` and `&TokenSession`, the way `AttestedData:: from_observed` takes an `ObservedSession` rather than four arguments that must already agree. The tests take their arguments from the table too, so they exercise the values a verifier actually pins rather than restating plausible ones. One test is new and could not have been written before: the ceremony profiles and the identity system must name the same platforms, which libid-contracts asserts across its own two tables in `PlatformIdentity.t.sol`. Both crates are generated there, so this is not checking a transcription -- it is checking that a consumer holding both, at versions it resolved independently, holds one keyspace. They are separate crates with separate requirements, and a lockfile can pin a pair that never shipped together. `libid-identity` is a dev-dependency, so nothing published carries it. Assisted-by: Claude Opus 5 Signed-off-by: xgreenx --- Cargo.lock | 16 ++- Cargo.toml | 7 ++ crates/libid-tlsn/src/attest.rs | 9 +- .../libid-tlsn/tests/ceremony_end_to_end.rs | 14 +-- crates/libid-transcript/Cargo.toml | 3 + crates/libid-transcript/src/ceremony.rs | 117 +++++++++++++----- 6 files changed, 123 insertions(+), 43 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7e31589f..84d8a070 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2979,7 +2979,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.5", "system-configuration", "tokio", "tower-layer", @@ -3387,6 +3387,18 @@ dependencies = [ "tiny-keccak", ] +[[package]] +name = "libid-identity" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2600c89b89d4150001046d84d4e3a6f638aee454fe0c45ffcc54185113df5cb" + +[[package]] +name = "libid-profiles" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7632c18090e9053a04d7cc34e7b2fe3af310b72930593236604bcbe6f0ea80d0" + [[package]] name = "libid-signer" version = "0.3.0" @@ -3424,6 +3436,8 @@ name = "libid-transcript" version = "0.3.0" dependencies = [ "httparse", + "libid-identity", + "libid-profiles", "serde", "serde_json", "thiserror 2.0.20", diff --git a/Cargo.toml b/Cargo.toml index e511eba6..56e40155 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,6 +39,13 @@ base64 = "0.22" # 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.8" +# 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.8" # Chunk-size parsing. Hand-rolling it is how a malformed chunk becomes a short # body instead of an error. httparse = "1" diff --git a/crates/libid-tlsn/src/attest.rs b/crates/libid-tlsn/src/attest.rs index 65e05fbd..7cb078fd 100644 --- a/crates/libid-tlsn/src/attest.rs +++ b/crates/libid-tlsn/src/attest.rs @@ -277,7 +277,7 @@ mod tests { use super::*; use libid_transcript::ceremony::{ - IdShape, + profiles, Layout, }; use rangeset::set::RangeSet; @@ -541,8 +541,7 @@ mod tests { 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, "id", IdShape::JsonString, "username") - .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); @@ -557,7 +556,7 @@ mod tests { 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, None).unwrap(); + 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); @@ -573,7 +572,7 @@ mod tests { 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, Some("client_secret")).unwrap(); + 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); diff --git a/crates/libid-tlsn/tests/ceremony_end_to_end.rs b/crates/libid-tlsn/tests/ceremony_end_to_end.rs index e25b4640..b5cb409b 100644 --- a/crates/libid-tlsn/tests/ceremony_end_to_end.rs +++ b/crates/libid-tlsn/tests/ceremony_end_to_end.rs @@ -23,7 +23,7 @@ use libid_tlsn::attest::{ ObservedSession, }; use libid_transcript::ceremony::{ - IdShape, + profiles, Layout, }; use rangeset::set::RangeSet; @@ -137,7 +137,7 @@ fn count(haystack: &[u8], needle: &[u8]) -> usize { #[test] fn the_token_session_produces_a_record_the_verifier_accepts() { - let sl = Layout::token_request(TOKEN_SENT, None).unwrap(); + 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); @@ -187,8 +187,7 @@ fn the_token_session_produces_a_record_the_verifier_accepts() { #[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, "id", IdShape::JsonString, "username") - .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"); @@ -255,15 +254,14 @@ fn both_sessions_encode_and_carry_their_own_lengths() { ( TOKEN_SENT, TOKEN_RECV, - Layout::token_request(TOKEN_SENT, None).unwrap(), + 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, "id", IdShape::JsonString, "username") - .unwrap(), + Layout::identity_response(ID_RECV, &profiles::X.identity.unwrap()).unwrap(), ), ] { let data = record(sent, recv, &sl, &rl, 1_770_000_000); @@ -284,7 +282,7 @@ fn the_github_exchange_commits_a_suffix_and_nothing_else() { 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, Some("client_secret")).unwrap(); + 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); diff --git a/crates/libid-transcript/Cargo.toml b/crates/libid-transcript/Cargo.toml index dfffce4e..350b79ba 100644 --- a/crates/libid-transcript/Cargo.toml +++ b/crates/libid-transcript/Cargo.toml @@ -11,10 +11,13 @@ categories = ["cryptography", "parser-implementations"] [dependencies] httparse.workspace = true +libid-profiles.workspace = true serde.workspace = true serde_json.workspace = true thiserror.workspace = true tokio = { workspace = true, features = ["io-util"] } [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 index 226911a9..136b38a0 100644 --- a/crates/libid-transcript/src/ceremony.rs +++ b/crates/libid-transcript/src/ceremony.rs @@ -66,15 +66,25 @@ fn complement(reveal: &[Range], len: usize) -> Vec> { out } -/// Which shape the platform's immutable identifier takes. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum IdShape { - /// X: `"id":"2244994945"`. - JsonString, - /// GitHub: `"id":583231,` -- the terminator is revealed with it, because it - /// is what proves the digits are the whole number rather than a prefix. - JsonInteger, -} +/// 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 @@ -128,9 +138,9 @@ impl Layout { /// commitment reaches the transcript end. pub fn token_request( sent: &[u8], - secret_field: Option<&str>, + session: &TokenSession, ) -> Result { - let Some(field) = secret_field else { + let Some(field) = session.secret_field else { return Ok(Self::revealing(core::iter::once(0..sent.len()), sent.len())); }; @@ -257,15 +267,15 @@ impl Layout { /// be an offset rather than a name. pub fn identity_response( recv: &[u8], - id_field: &str, - id_shape: IdShape, - handle_field: &str, + 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 id = - compute_id_snippet_range(recv, id_field, id_shape == IdShape::JsonString) - .ok_or_else(|| LayoutError::MissingField(id_field.into()))?; + 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()))?; @@ -295,6 +305,27 @@ mod tests { 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") + } + 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"; @@ -373,7 +404,7 @@ mod tests { #[test] fn the_x_token_request_is_revealed_whole() { - let l = Layout::token_request(X_TOKEN_REQ, None).unwrap(); + 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())); @@ -382,7 +413,7 @@ mod tests { #[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, Some("client_secret")).unwrap(); + 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. @@ -396,7 +427,7 @@ mod tests { #[test] fn a_missing_secret_is_an_error_not_a_silent_reveal() { assert_eq!( - Layout::token_request(X_TOKEN_REQ, Some("client_secret")), + Layout::token_request(X_TOKEN_REQ, &github_token()), Err(LayoutError::MissingCredential) ); } @@ -442,8 +473,7 @@ mod tests { #[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, "id", IdShape::JsonString, "username") - .unwrap(); + 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 @@ -470,8 +500,7 @@ mod tests { // 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, "id", IdShape::JsonString, "username") - .unwrap(); + 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. @@ -487,8 +516,7 @@ mod tests { // 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, "id", IdShape::JsonString, "username") - .unwrap(); + 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 { @@ -511,8 +539,7 @@ mod tests { #[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, "id", IdShape::JsonString, "username") - .unwrap(); + let l = Layout::identity_response(recv, &x_identity()).unwrap(); assert!(tiles(&l, recv.len())); let revealed: usize = l .reveal @@ -531,8 +558,40 @@ mod tests { 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, "id", IdShape::JsonString, "username"), + 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); + } +} From 2964a705a7f302c05a50e3d9e803f9f03a91f202 Mon Sep 17 00:00:00 2001 From: xgreenx Date: Tue, 8 Sep 2026 23:30:08 +0100 Subject: [PATCH 49/65] test(transcript): drive the identity layout with GitHub's profile too Every `identity_response` case here used X's, so the bare-integer path had no coverage through a layout at all -- only through the range finder underneath it. The two shapes are not the same: `login` closes on a quote, `id` closes on the structural byte after its digits, and that byte is revealed WITH them because it is what proves they are the whole number rather than a prefix (REQ-PLAT-51). Both terminators GitHub can send are covered, `,` and `}`; a layout producing only one would refuse half of its honest responses. `github_identity()` exists now because a test needed it. It was missing for the same reason the coverage was: the helpers were written for the call sites that already existed. The third case crosses the profiles, which is what taking them as one value is for. It fails both ways but NOT symmetrically, and asserting the field each names is the point: X's shape on GitHub's body fails on the id, while GitHub's shape on X's body gets past the id -- the bare reader finds `"id":` and stops at the `,`, returning a quoted value read as though it were a number -- and fails on the handle instead. Assisted-by: Claude Opus 5 Signed-off-by: xgreenx --- crates/libid-transcript/src/ceremony.rs | 70 +++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/crates/libid-transcript/src/ceremony.rs b/crates/libid-transcript/src/ceremony.rs index 136b38a0..d1eec6ed 100644 --- a/crates/libid-transcript/src/ceremony.rs +++ b/crates/libid-transcript/src/ceremony.rs @@ -326,6 +326,12 @@ mod tests { .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"; @@ -488,6 +494,70 @@ mod tests { ); } + #[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. Both directions fail -- but not symmetrically, and the field + // each names says why. + 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())) + ); + + // The other way round does NOT fail on the id. GitHub's bare reader + // finds `"id":` and stops at the `,`, so it happily returns `"id":"7",` + // -- a quoted value read as though it were a number. What refuses the + // session is the handle: X calls it `username` and GitHub `login`. + // Worth knowing, because it says the id reader alone would not have + // caught the mismatch. + assert_eq!( + Layout::identity_response(x, &github_identity()), + Err(LayoutError::MissingField("login".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 From c91b291fcc1f3be09e5c1aafa4a6c82a54ea836c Mon Sep 17 00:00:00 2001 From: xgreenx Date: Tue, 8 Sep 2026 23:33:00 +0100 Subject: [PATCH 50/65] fix(transcript): read a bare id the way the verifier reads it The finder took the digits to be everything up to the first `,` or `}`. `CeremonyFields.tryJsonInteger` does the opposite: it scans DIGITS and then demands one of those two bytes closes them. The difference is not academic -- `"id":"7",` satisfied the old scan, so a quoted value came back as though it were a number, and the chain refused it as noncanonical. Same answer, given three components away from the reason. Now mirrored exactly, including the two rules the scan never had: at least one digit, and no leading zero unless the value is `0` -- `end - at > 1 && data[at] == "0"` on chain. Digits running to the end with nothing after them are `Found.None` there and `None` here. The crossed-profile test found this. It asserted that GitHub's shape on X's body fails on the HANDLE, because the id slipped through; it now fails on the id, where the shapes actually differ. A test written to document behaviour, and then changed by fixing it, is the test earning its place. Assisted-by: Claude Opus 5 Signed-off-by: xgreenx --- crates/libid-transcript/src/ceremony.rs | 16 +++---- crates/libid-transcript/src/ranges.rs | 64 +++++++++++++++++++++---- 2 files changed, 61 insertions(+), 19 deletions(-) diff --git a/crates/libid-transcript/src/ceremony.rs b/crates/libid-transcript/src/ceremony.rs index d1eec6ed..60949444 100644 --- a/crates/libid-transcript/src/ceremony.rs +++ b/crates/libid-transcript/src/ceremony.rs @@ -534,8 +534,7 @@ mod tests { 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. Both directions fail -- but not symmetrically, and the field - // each names says why. + // 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\"}"; @@ -546,15 +545,14 @@ mod tests { Err(LayoutError::MissingField("id".into())) ); - // The other way round does NOT fail on the id. GitHub's bare reader - // finds `"id":` and stops at the `,`, so it happily returns `"id":"7",` - // -- a quoted value read as though it were a number. What refuses the - // session is the handle: X calls it `username` and GitHub `login`. - // Worth knowing, because it says the id reader alone would not have - // caught the mismatch. + // 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("login".into())) + Err(LayoutError::MissingField("id".into())) ); } diff --git a/crates/libid-transcript/src/ranges.rs b/crates/libid-transcript/src/ranges.rs index 03af50b4..2da1fc72 100644 --- a/crates/libid-transcript/src/ranges.rs +++ b/crates/libid-transcript/src/ranges.rs @@ -339,17 +339,31 @@ fn require_contiguous(raw: &[u8], decoded: &[u8]) -> Option<()> { pub fn find_json_bare_snippet_range(body: &[u8], field: &str) -> Option> { let needle = format!("\"{field}\":"); let start = find_first(body, needle.as_bytes())?; - let digits = start.checked_add(needle.len())?; - // Bound the number by the first `,` or `}` after the colon. - let term = body - .get(digits..)? - .iter() - .position(|&b| b == b',' || b == b'}')? - .checked_add(digits)?; + 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 - // `CeremonyFields.tryJsonInteger` refuses any other byte there. - Some(start..term.checked_add(1)?) + // 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 @@ -572,6 +586,36 @@ mod tests { assert!(find_json_bare_snippet_range(bare, "id").is_none()); } + #[test] + 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 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 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 a_lookalike_key_does_not_match() { // `"node_id":` contains `id":` but not `"id":` -- the full delimiter is From 62a268f52e905994fc980d19779f86f088936f20 Mon Sep 17 00:00:00 2001 From: xgreenx Date: Tue, 8 Sep 2026 23:42:06 +0100 Subject: [PATCH 51/65] test(crypto): the rejections, which had no tests at all Measured rather than guessed: `cargo llvm-cov` put this crate at 78.62% lines and 72.73% functions, the lowest of anything that is not the MPC driver. The shape of the gap turned out to be uniform -- every happy path had a test and almost no rejection did. That is the wrong way round for this crate. These functions sit under the notary's signing and under whatever checks a notary signature off chain, so a malformed signature is the ordinary case, not the exotic one. `hex_to_address` had no test in either direction. Added: a signature that is not sixty-five bytes, a recovery id outside the two it can mean, sixty-four bytes that are not a signature, the same three for the EIP-191 path, hex that is not an address in three ways, and hex that is not a key in three more. One is not a rejection and matters more than the rest: recovery ALWAYS returns a key for a well-formed signature -- it cannot fail its way to safety -- so what makes it a check is comparing the result. Nothing tested that comparing was necessary. Now a signature over one message is recovered against another and asserted NOT to be the signer. 78.62% -> 94.79% lines, 72.73% -> 92.45% functions. The four functions still uncovered are error branches on operations that do not fail for well-formed input: `sign_prehash` on a valid key, and `recover_from_prehash` after the signature already parsed. Reaching them needs fault injection, and a test that faked it would cover a line without checking anything. Assisted-by: Claude Opus 5 Signed-off-by: xgreenx --- crates/libid-crypto/src/lib.rs | 108 +++++++++++++++++++++++++++++++++ 1 file changed, 108 insertions(+) diff --git a/crates/libid-crypto/src/lib.rs b/crates/libid-crypto/src/lib.rs index 3d687633..02f8a089 100644 --- a/crates/libid-crypto/src/lib.rs +++ b/crates/libid-crypto/src/lib.rs @@ -309,6 +309,114 @@ mod tests { assert_eq!(vk, recovered); } + /// Every rejection below is a signature or key someone HANDED us. These + /// functions sit under the notary's signing and under whatever checks a + /// notary signature off chain, so malformed input is the ordinary case, + /// not the exotic one -- and each of these paths existed untested while + /// every happy path had a test. + #[test] + fn recovery_refuses_a_signature_that_is_not_sixty_five_bytes() { + let (sk, _) = generate_keypair(); + let msg = b"hello world"; + let sig = sign_message(&sk, msg).unwrap(); + + assert!(recover_public_key(&sig[..64], msg).is_err()); + assert!(recover_public_key(&[], msg).is_err()); + let mut long = sig.clone(); + long.push(0); + assert!(recover_public_key(&long, msg).is_err()); + } + + #[test] + fn recovery_refuses_a_recovery_id_outside_the_two_it_can_mean() { + let (sk, _) = generate_keypair(); + let msg = b"hello world"; + let mut sig = sign_message(&sk, msg).unwrap(); + // `sign_message` writes the raw 0/1 byte, so 27/28 is the EVM + // convention this function does NOT accept -- `recover_eth_claim` is + // the one that strips the offset. + sig[64] = 27; + assert!(recover_public_key(&sig, msg).is_err()); + sig[64] = 4; + assert!(recover_public_key(&sig, msg).is_err()); + } + + #[test] + fn recovery_refuses_sixty_four_bytes_that_are_not_a_signature() { + // All zeros is not a valid (r, s): `s` must be non-zero and in the + // lower half of the order. + let zeros = [0u8; 65]; + assert!(recover_public_key(&zeros, b"hello world").is_err()); + } + + #[test] + fn a_recovered_key_is_not_the_signer_of_other_bytes() { + // 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, and this is the case that comparison exists for. + let (sk, vk) = generate_keypair(); + let sig = sign_message(&sk, b"hello world").unwrap(); + let other = recover_public_key(&sig, b"hello worlt").unwrap(); + assert_ne!(vk, other, "a different message must not recover the signer"); + } + + #[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 an_address_reads_with_or_without_the_prefix_and_refuses_the_rest() { + // This function had no test at all, in either direction. + let expected = [ + 0xf3, 0x9f, 0xd6, 0xe5, 0x1a, 0xad, 0x88, 0xf6, 0xf4, 0xce, 0x6a, 0xb8, 0x82, + 0x72, 0x79, 0xcf, 0xff, 0xb9, 0x22, 0x66, + ]; + let bare = "f39fd6e51aad88f6f4ce6ab8827279cffFb92266"; + assert_eq!(hex_to_address(bare).unwrap(), expected); + assert_eq!(hex_to_address(&format!("0x{bare}")).unwrap(), expected); + + // Nineteen bytes, twenty-one bytes, and something that is not hex. + assert!(hex_to_address("f39fd6e51aad88f6f4ce6ab8827279cffFb922").is_err()); + assert!(hex_to_address("f39fd6e51aad88f6f4ce6ab8827279cffFb9226600").is_err()); + assert!(hex_to_address("0xzz").is_err()); + } + + #[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] fn eth_address_deterministic() { let (_, vk) = generate_keypair(); From 9bc598ccf0f08f96097d50c42499319ccd4c4058 Mon Sep 17 00:00:00 2001 From: xgreenx Date: Tue, 8 Sep 2026 23:48:50 +0100 Subject: [PATCH 52/65] fix(tlsn): commit under SHA-256, the algorithm the circuit opens `prover_generic` built its `TranscriptCommitConfig` and never chose a hash algorithm, so tlsn's default stood: BLAKE3. The Proving Circuit computes SHA-256, and `AttestedData::from_observed` refuses anything else -- so every commitment this prover made was one the circuit could not open and the notary would not sign. No Rust-proved ceremony could be attested at all. The two halves of this repository disagreed with each other, and about a hazard they both already knew: REQ-COMMON-38 names it in prose ("the notarization library's default commit algorithm is BLAKE3 while the Proving Circuit computes SHA-256, so a prover left on library defaults produces commitments the circuit cannot open"), the refusal was implemented on the notary side, and the selection that makes the refusal satisfiable was never made on the prover side. Upstream's own zk example makes the call this omitted. It failed closed, so this is liveness rather than safety -- but it failed late, after a full MPC-TLS session had been paid for, with an opaque "a commitment uses BLAKE3" at the notary. Nothing caught it because nothing could. The tests that cover the record synthesize their own commitments through a helper that hard-codes SHA-256, so they assert on an algorithm no code path in this crate produced. So the builder moves into `transcript_commit_config`, where the choice is visible and assertable without an MPC session, and a test pins every commitment this prover configures to SHA-256. That test fails on the parent commit. The algorithm is set here rather than by a caller because `select_layout` hands back ranges and no algorithm: no caller could have corrected it. Signed-off-by: xgreenx Assisted-by: Claude Opus 5 --- crates/libid-tlsn/src/session.rs | 98 +++++++++++++++++++++++++------- 1 file changed, 77 insertions(+), 21 deletions(-) diff --git a/crates/libid-tlsn/src/session.rs b/crates/libid-tlsn/src/session.rs index 77982056..5b4aa290 100644 --- a/crates/libid-tlsn/src/session.rs +++ b/crates/libid-tlsn/src/session.rs @@ -37,8 +37,10 @@ use tlsn::{ Direction, PartialTranscript, TlsTranscript, + Transcript, TranscriptCommitConfig, TranscriptCommitment, + TranscriptCommitmentKind, TranscriptSecret, }, verifier::{ @@ -151,6 +153,46 @@ pub fn root_store() -> RootCertStore { } } +/// 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}"), + }) +} + /// Extract TLS handshake data from a TLS transcript. pub fn extract_handshake_data( tls_transcript: &TlsTranscript, @@ -561,26 +603,11 @@ where let notary_sent_ranges = sent_layout.reveal.clone(); - let mut tc_builder = TranscriptCommitConfig::builder(&transcript); - let (sent_commits, recv_commits) = - (sent_layout.commit.clone(), recv_layout.commit.clone()); - for range in sent_commits { - tc_builder - .commit_sent(&range) - .map_err(|e| Error::MpcTlsFailed { - detail: format!("commit sent: {e}"), - })?; - } - for range in recv_commits { - tc_builder - .commit_recv(&range) - .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(); @@ -679,7 +706,7 @@ where }) .collect::>()?; // The request itself goes nowhere: the notary answers a session with the - // section 9.1 record and reads no attestation request. `build` is still + // 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()) @@ -883,6 +910,35 @@ mod tests { .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"); + let config = transcript_commit_config(&transcript, &[0..4], &[0..4]) + .expect("the ranges are inside the transcript"); + + let algs: Vec<_> = config.iter_hash().map(|(_, alg)| *alg).collect(); + assert_eq!(algs.len(), 2, "one commitment 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)" + ); + } + } + #[test] fn origin_form_keeps_path_and_query() { let mut request = request("https://www.googleapis.com/p?q=1"); From 23f8352aeafdb868b839634bd56eff109f5b41ac Mon Sep 17 00:00:00 2001 From: xgreenx Date: Tue, 8 Sep 2026 23:49:14 +0100 Subject: [PATCH 53/65] test(tlsn): assert the record carries the authority and the clock it was given Two header fields the notary itself contributes were unasserted. Both mutants passed the entire suite: authority_id: AttestedData::authority_id_of("api.x.com") // ignore the session created_at: 0 // drop the clock The cause is that every test observed `api.x.com`, so nothing ever checked that a different authority produces a different id -- including `authority_is_the_authenticated_server_name`, whose name promises exactly that, and whose `assert_ne!(.., "evil.example")` passed trivially because the record said `api.x.com` either way. These are the two fields worth the assertions. `authority_id` is the only identity in the signed record, and the transcript cannot corroborate it: the request carries the authority only in a header the prover composed (REQ-COMMON-21, REQ-COMMON-21A). `created_at` is the notary's own clock, and the verifier's freshness window is measured from it, so a record judged on a time nobody observed is a window nobody chose. One test observes a different host; one varies the clock. Each kills its mutant. Signed-off-by: xgreenx Assisted-by: Claude Opus 5 --- crates/libid-tlsn/src/attest.rs | 51 +++++++++++++++++++++++++++++---- 1 file changed, 46 insertions(+), 5 deletions(-) diff --git a/crates/libid-tlsn/src/attest.rs b/crates/libid-tlsn/src/attest.rs index 65e05fbd..54471a85 100644 --- a/crates/libid-tlsn/src/attest.rs +++ b/crates/libid-tlsn/src/attest.rs @@ -1,5 +1,5 @@ -//! Turn what a notarized session produced into the attested data of -//! ceremony-common section 9.1. +//! 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, @@ -137,8 +137,12 @@ pub trait FromObserved: Sized { } impl FromObserved> for AttestedData { - /// The record of a session, laid out as ceremony-common section 9.1 fixes - /// it. + /// 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 @@ -154,7 +158,8 @@ impl FromObserved> for AttestedData { /// /// 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. It judges nothing else. Whether the + /// 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 @@ -410,6 +415,42 @@ mod tests { ); } + #[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 From 1772952003c4e8046c1b1cd3dfb176ae25591053 Mon Sep 17 00:00:00 2001 From: xgreenx Date: Tue, 8 Sep 2026 23:49:14 +0100 Subject: [PATCH 54/65] docs: drop the claims that no longer hold Three, found by checking the Rust against libid-contracts v0.8.0 and the published specification rather than against itself. `libid-ceremony`'s module doc said `@libid/contracts` exports `decodeAttestedData`, `validate`, `requireExactCoverage` and `requireBearerHeaderRequest` as the client-side dry run REQ-PLAT-44 asks for. None of them exists: v0.8.0's ceremony package is the generated profile table and its index. Naming a checker that has not been written invites the next reader to skip writing one, so the paragraph now says the dry run is still owed. Six sites called the attested-data record "ceremony-common section 9.1". Section 9.1 is attestation verification and its fee, and fixes no byte of the layout -- which `attestation.rs` already says at the top of its own file, in capitals: the layout is the profile's, not the specification's. A citation that contradicts the file it sits in is worse than none. The "this crate is published" claim survived in a second place. An earlier commit removed it from `attest.rs` on the grounds that a reader who checks it and finds it false discounts the paragraph around it; it was still in `attestation.rs`, where the paragraph it discredits is the argument for the signed field's own parameter type. The honest reason is simpler and true: `libid-ceremony` carries three dependencies and no TLS library, so it knows nothing about how one models a server name. And the canonical authority is stated as what it is: OURS. 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, exactly as the byte layout is under REQ-COMMON-18. The generated table in libid-contracts makes the same decision and refuses any authority that is not lowercase and free of a trailing dot, so the two agree at the source. The trailing dot this still does not strip is now described by what it does -- a dotted name hashes to an id no profile matches, so the session is refused rather than repaired. Also: the failure list on `from_observed` named three of four variants. Signed-off-by: xgreenx Assisted-by: Claude Opus 5 --- crates/libid-ceremony/src/attestation.rs | 28 +++++++++++++------ crates/libid-ceremony/src/lib.rs | 19 ++++++++----- .../libid-tlsn/tests/ceremony_end_to_end.rs | 2 +- crates/libid-transcript/src/wire.rs | 4 +-- 4 files changed, 35 insertions(+), 18 deletions(-) diff --git a/crates/libid-ceremony/src/attestation.rs b/crates/libid-ceremony/src/attestation.rs index 49e79437..472e5822 100644 --- a/crates/libid-ceremony/src/attestation.rs +++ b/crates/libid-ceremony/src/attestation.rs @@ -105,8 +105,16 @@ impl AttestedData { /// where the prover wrote it. Naming the record puts the rule beside the /// field. /// - /// Canonicalization is ASCII lowercase, and it happens HERE rather than at - /// each caller. The id is compared on chain against a constant a profile + /// 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 @@ -116,13 +124,17 @@ impl AttestedData { /// `libid-tlsn` while the other passed a string that was already lowercase /// -- a rule kept by accident. /// - /// Section 9 also asks for no trailing dot, and this does NOT strip one, - /// exactly as `tag` did not. Changing what a signed field hashes belongs in - /// a change that argues for it and tests it, not in a rename. + /// 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 is published and - /// knows nothing about how a TLS library models a name, which is also what - /// keeps this mapping testable without a session. + /// 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 diff --git a/crates/libid-ceremony/src/lib.rs b/crates/libid-ceremony/src/lib.rs index 185ee685..798768b1 100644 --- a/crates/libid-ceremony/src/lib.rs +++ b/crates/libid-ceremony/src/lib.rs @@ -15,11 +15,16 @@ //! profile-specific. //! //! Where a check IS wanted before spending gas, it belongs in the client as a -//! dry run -- and it already lives there. `@libid/contracts` exports -//! `decodeAttestedData`, `validate`, `requireExactCoverage` and -//! `requireBearerHeaderRequest` in TypeScript, which is what REQ-PLAT-44 has -//! the Canonical Runtime call before it spends a second session on an -//! attestation. +//! 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 @@ -30,8 +35,8 @@ //! //! So this crate holds one direction of one thing: //! -//! * [`attestation`] -- the types of ceremony-common section 9.1 and the -//! encoder that lays them out. No decoder: whoever decodes also checks, and +//! * [`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 diff --git a/crates/libid-tlsn/tests/ceremony_end_to_end.rs b/crates/libid-tlsn/tests/ceremony_end_to_end.rs index e25b4640..33aa03b8 100644 --- a/crates/libid-tlsn/tests/ceremony_end_to_end.rs +++ b/crates/libid-tlsn/tests/ceremony_end_to_end.rs @@ -2,7 +2,7 @@ //! //! 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 section 9.1 record, and a Platform Verifier on chain then +//! 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. //! diff --git a/crates/libid-transcript/src/wire.rs b/crates/libid-transcript/src/wire.rs index f6ffa436..c6fad795 100644 --- a/crates/libid-transcript/src/wire.rs +++ b/crates/libid-transcript/src/wire.rs @@ -24,8 +24,8 @@ 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 ceremony-common section 9.1 -/// record, and the signature over it. +/// 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 From 297857c3b726c103d726c54f6f93ec65cb10d71a Mon Sep 17 00:00:00 2001 From: xgreenx Date: Tue, 8 Sep 2026 23:50:29 +0100 Subject: [PATCH 55/65] test(tlsn): commit two ranges per direction, and satisfy the lint that says so `&[0..4]` trips `single_range_in_vec_init` -- the same lint that shaped `Layout::revealing`'s iterator signature -- so the test could not compile under `-D warnings`. Widening it to two ranges per direction is the better fix rather than the quieter one: the hash algorithm is a property of each commitment, not of the config, so a single range could not distinguish a default applied once from one applied to every commitment. Four commitments now, all asserted. Signed-off-by: xgreenx Assisted-by: Claude Opus 5 --- crates/libid-tlsn/src/session.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/libid-tlsn/src/session.rs b/crates/libid-tlsn/src/session.rs index 5b4aa290..d25f01c5 100644 --- a/crates/libid-tlsn/src/session.rs +++ b/crates/libid-tlsn/src/session.rs @@ -924,11 +924,14 @@ mod tests { 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"); - let config = transcript_commit_config(&transcript, &[0..4], &[0..4]) - .expect("the ranges are inside the transcript"); + // 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(), 2, "one commitment per direction"); + assert_eq!(algs.len(), 4, "two commitments per direction"); for alg in algs { assert_eq!( alg, From 05e60a7fd72ae70a7d8a15cd7027d83ee82ffc2a Mon Sep 17 00:00:00 2001 From: xgreenx Date: Tue, 8 Sep 2026 23:53:33 +0100 Subject: [PATCH 56/65] refactor!: remove what the dyaka product left behind PR #2 deleted `EvmProof` and `NotaryResponse`. Everything that FED them stayed, and it is most of what this workspace still carried that the ceremony protocol does not use: * the Merkle suite -- `build_merkle_tree`, `merkle_proof`, `merkle_verify`, `hash_pair`, `double_hash_leaf` -- which built `EvmProof.transcript_root` and its leaves. The attested-data record is bincode and one keccak; it has no tree. * `TlsHandshakeData`, `extract_handshake_data`, and `ProverResult.handshake` -- the client random, server random and server ephemeral key, which were `EvmProof` fields. tlsn's own `HandshakeData` is a different type, still used, and is what binds the session now. * the legacy `prover` flow and `UserInfoParams`, whose own comment said it "does NOT tile, so what it produces is not a ceremony attestation. It goes at cutover." Nothing calls it. * the reveal helpers only that flow used: `find_notary_reveal_ranges`, `find_presentation_commit_ranges`. And three exported finders nothing called at all: `find_json_field_range`, `compute_field_reveal_range`, `compute_id_snippet_range_after`. * `hex_to_address`, which parsed an `EvmProof` address, and `sign_message`/`recover_public_key`, the raw 0/1-recovery pair. A notary signature is EIP-191, made and checked with `sign_eth_claim` and `recover_eth_claim`, and those stay. Nothing removed here is used by anything tracking the current protocol: not by this workspace, not by the notary's ceremony branch, not by the keeper. The two backends do use the Merkle functions and `hex_to_address` -- and they pin the v0.1.0 and v0.2.0 tags, so they resolve against a tree that still has them. That is the same argument that retired `libid-attestations`, and it is why this is a removal rather than a deprecation: the old product has its own releases. 599 lines out, 8 in. 95 tests pass, and the four publishable crates still pack and rebuild from their own tarballs. Assisted-by: Claude Opus 5 Signed-off-by: xgreenx --- README.md | 6 +- crates/libid-crypto/src/lib.rs | 232 ------------------------ crates/libid-tlsn/src/lib.rs | 3 - crates/libid-tlsn/src/session.rs | 132 +------------- crates/libid-transcript/src/ceremony.rs | 5 +- crates/libid-transcript/src/lib.rs | 7 - crates/libid-transcript/src/ranges.rs | 210 +-------------------- crates/libid-transcript/src/types.rs | 12 -- 8 files changed, 8 insertions(+), 599 deletions(-) delete mode 100644 crates/libid-transcript/src/types.rs diff --git a/README.md b/README.md index 28570cc2..27d048c1 100644 --- a/README.md +++ b/README.md @@ -9,11 +9,11 @@ digests the libID on-chain verifiers check. | 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 per-session ceremony reveal layouts; the length-prefixed JSON wire protocol notary and prover speak after MPC-TLS closes; the `AttestationWire` and `TlsHandshakeData` types. | +| `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 diff --git a/crates/libid-crypto/src/lib.rs b/crates/libid-crypto/src/lib.rs index 3d687633..e86a5061 100644 --- a/crates/libid-crypto/src/lib.rs +++ b/crates/libid-crypto/src/lib.rs @@ -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 { @@ -300,15 +149,6 @@ mod tests { "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; const ANVIL_ADDR: &str = "f39fd6e51aad88f6f4ce6ab8827279cfffb92266"; - #[test] - fn roundtrip_sign_recover() { - 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); - } - #[test] fn eth_address_deterministic() { let (_, vk) = generate_keypair(); @@ -363,78 +203,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/src/lib.rs b/crates/libid-tlsn/src/lib.rs index c88d0cfc..5cf0d43d 100644 --- a/crates/libid-tlsn/src/lib.rs +++ b/crates/libid-tlsn/src/lib.rs @@ -72,15 +72,12 @@ pub use hyper::{ Request as HttpRequest, }; pub use session::{ - extract_handshake_data, - prover, prover_generic, root_store, verifier, CommitmentOpening, ProverResult, ProverStep, - UserInfoParams, VerifierResult, MAX_RECV_DATA, MAX_SENT_DATA, diff --git a/crates/libid-tlsn/src/session.rs b/crates/libid-tlsn/src/session.rs index 77982056..73ed9cc5 100644 --- a/crates/libid-tlsn/src/session.rs +++ b/crates/libid-tlsn/src/session.rs @@ -26,8 +26,6 @@ use tlsn::{ verifier::VerifierConfig, }, connection::{ - CertBinding, - CertBindingV1_2, HandshakeData, ServerName, }, @@ -71,12 +69,6 @@ use tracing::{ instrument, }; -use libid_transcript::{ - find_notary_reveal_ranges, - find_presentation_commit_ranges, - TlsHandshakeData, -}; - use crate::{ Error, Result, @@ -151,28 +143,6 @@ 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(), - }); - }; - - Ok(TlsHandshakeData { - client_random: *client_random, - server_random: *server_random, - server_ephemeral_key: server_ephemeral_key.key.clone(), - }) -} - /// A phase boundary of a prover session, in the order they occur. /// /// Reported through `on_progress` so a caller can drive something typed off @@ -235,8 +205,6 @@ pub struct ProverResult { pub response_body: Vec, /// The TLS secrets for proof construction. pub secrets: Secrets, - /// Extracted TLS handshake data. - pub handshake: TlsHandshakeData, /// One opening per commitment this session made, in the order the layouts /// stated them. Empty when the session committed nothing. pub commitment_openings: Vec, @@ -258,100 +226,6 @@ pub struct VerifierResult { pub recovered_io: T, } -/// 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<'_>, -) -> Result> -where - T: AsyncWrite + AsyncRead + Send + Unpin + 'static, -{ - let username_field = params.username_field; - let id_field = params.id_field; - // The headers this flow sends, stated here rather than injected by the - // library. A notarized request is bytes a verifier compares against a - // profile, so whoever knows the profile writes them. - let request = hyper::Request::builder() - .method("GET") - .uri(format!( - "https://{}{}", - params.api_host, params.user_info_path - )) - .header("Host", params.api_host) - .header("Connection", "close") - .header("Accept", "application/json") - .header("User-Agent", params.user_agent) - .header("Authorization", format!("Bearer {access_token}")) - .body(http_body_util::Full::new(Bytes::new())) - .map_err(|e| Error::MpcTlsFailed { - detail: format!("request build: {e}"), - })?; - - prover_generic( - socket, - request, - // This flow predates the ceremony layouts and still selects the old - // sparse ranges: the request line and `Host` revealed, everything else - // of the response committed whole. It does NOT tile, so what it - // produces is not a ceremony attestation. It goes at cutover. - |sent, 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(( - Layout { - reveal: find_notary_reveal_ranges(sent), - commit: find_presentation_commit_ranges(sent), - }, - Layout { - reveal: ranges, - commit: core::iter::once(0..recv.len()).collect(), - }, - )) - }, - // This flow goes at cutover and nothing watches it run. - |_| {}, - ) - .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 @@ -612,7 +486,6 @@ where 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 @@ -693,7 +566,7 @@ where })?; handle.close(); - Ok((body, secrets, handshake, commitment_openings)) + Ok((body, secrets, commitment_openings)) }; tokio::pin!(setup); @@ -701,7 +574,7 @@ 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, secrets, handshake, commitment_openings) = tokio::select! { + let (body, secrets, commitment_openings) = tokio::select! { biased; res = &mut setup => res?, driver_res = driver_task.handle_mut() => { @@ -723,7 +596,6 @@ where Ok(ProverResult { response_body: body.to_vec(), secrets, - handshake, commitment_openings, recovered_io, }) diff --git a/crates/libid-transcript/src/ceremony.rs b/crates/libid-transcript/src/ceremony.rs index 226911a9..93879d24 100644 --- a/crates/libid-transcript/src/ceremony.rs +++ b/crates/libid-transcript/src/ceremony.rs @@ -96,9 +96,8 @@ impl Layout { /// 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. Callers outside this - /// module state layouts this module does not know -- `libid-tlsn`'s legacy - /// prover builds two whose ranges deliberately do not tile -- so tiling is a + /// 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. diff --git a/crates/libid-transcript/src/lib.rs b/crates/libid-transcript/src/lib.rs index db304f8b..7a51d0a4 100644 --- a/crates/libid-transcript/src/lib.rs +++ b/crates/libid-transcript/src/lib.rs @@ -16,27 +16,20 @@ 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, JsonMember, }; -pub use types::TlsHandshakeData; pub use wire::{ read_msg, write_msg, diff --git a/crates/libid-transcript/src/ranges.rs b/crates/libid-transcript/src/ranges.rs index 03af50b4..8409eee5 100644 --- a/crates/libid-transcript/src/ranges.rs +++ b/crates/libid-transcript/src/ranges.rs @@ -118,94 +118,9 @@ fn decode_chunked_body(raw: &[u8]) -> 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) -} - -/// 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); - } - } - - ranges -} - -/// 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) -} - -/// 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 -/// -/// 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) -} - /// The `"key":"value"` member, from the key's opening quote through the /// value's closing quote. /// -/// Unlike [`find_json_field_range`], which returns only the value bytes, this -/// returns the whole member -- the range a reveal layout selects. -/// /// # The template is the reader's /// /// `CeremonyFields.tryJsonString` matches the literal `"":"`, so this @@ -352,7 +267,7 @@ pub fn find_json_bare_snippet_range(body: &[u8], field: &str) -> Option 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. - // The bytes it finds are kept, to be compared with the raw ones below. - let decoded = extract_response_body(recv).ok()?; - let decoded_member = { - let danchor = decoded - .windows(anchor_needle.len()) - .position(|w| w == anchor_needle.as_bytes())?; - let from = danchor.checked_add(anchor_needle.len())?; - let dsub = decoded.get(from..)?; - let rel = if quoted { - find_json_snippet_range(dsub, field_name)? - } else { - find_json_bare_snippet_range(dsub, field_name)? - }; - dsub.get(rel)? - }; - - // The Merkle leaf is over the RAW transcript, so map the range there. - 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)? - }; - require_contiguous(sub.get(rel.clone())?, decoded_member)?; - - let base = body_range.start.checked_add(search_from)?; - Some(base.checked_add(rel.start)?..base.checked_add(rel.end)?) -} - /// Compute the absolute recv-transcript range for an id snippet, dispatching on /// quotedness: `quoted` → `"id":""`, otherwise the bare `"id":[,}]` form. /// @@ -469,52 +332,6 @@ mod tests { use super::*; - #[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"); - } - - #[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"); - } - - #[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"); - - let range = find_json_field_range(body, "username").unwrap(); - assert_eq!(&body[range], b"alice"); - } - - #[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\"}}"; - - let range = compute_field_reveal_range(recv, "body").unwrap(); - assert_eq!(&recv[range], b"hello world"); - - let range = compute_field_reveal_range(recv, "login").unwrap(); - assert_eq!(&recv[range], b"alice"); - } - - #[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()); - } - #[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"; @@ -522,16 +339,6 @@ mod tests { assert_eq!(body, br#"{"a":1,"b":"x"}"#); } - #[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); - } - #[test] fn find_json_snippet_range_simple() { let body = br#"{"login":"octocat","id":123}"#; @@ -690,12 +497,6 @@ mod tests { assert!(compute_id_snippet_range(&recv, "id", false).is_none()); } - #[test] - fn an_anchored_id_split_by_chunk_framing_is_refused() { - let recv = straddling(r#"{"user":{"id":"12"#, r#"34"}}"#); - assert!(compute_id_snippet_range_after(&recv, "id", true, "user").is_none()); - } - #[test] fn a_chunked_member_inside_one_chunk_still_resolves() { // The point is contiguity, not chunking: a body that happens to be @@ -752,13 +553,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 88da0ce5..00000000 --- a/crates/libid-transcript/src/types.rs +++ /dev/null @@ -1,12 +0,0 @@ -//! What a prover reads off a completed TLS session, beyond the transcript. - -/// TLS handshake data: the randoms and the server's ephemeral key. -#[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, -} From 7e5d9e1073b471274912fdbba8b1ff4789f37b01 Mon Sep 17 00:00:00 2001 From: xgreenx Date: Wed, 9 Sep 2026 00:41:41 +0100 Subject: [PATCH 57/65] chore: drop the workspace dependencies nothing claims Four `[workspace.dependencies]` entries survived the crates that used them. `alloy-primitives` and `alloy-sol-types` were `libid-attestations`', and went unnoticed when that crate was removed -- my own miss in #6. `base64` and `sha2` belonged to the proof types deleted with `EvmProof`. No crate names any of the four and no source imports them, so nothing is built differently and the lockfile does not move. What they cost is a reader believing the workspace depends on alloy, and the next crate that needs a base64 reaching for `base64.workspace = true` and inheriting a version nobody chose for it. Assisted-by: Claude Opus 5 Signed-off-by: xgreenx --- Cargo.toml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 56e40155..df82e149 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,15 +26,12 @@ libid-transcript = { path = "crates/libid-transcript", 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" -base64 = "0.22" # 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"] } @@ -56,7 +53,6 @@ k256 = { version = "0.13", features = ["ecdsa", "sha256"] } rand = "0.8" serde = { version = "1", features = ["derive"] } serde_json = "1.0" -sha2 = "0.10" thiserror = "2" tiny-keccak = { version = "2.0", features = ["keccak"] } # Upstream TLSNotary, same pin as the original monorepo. A GIT dependency: any crate that From 59d010e5e5a6057603a917f7814b4f2462f02251 Mon Sep 17 00:00:00 2001 From: xgreenx Date: Wed, 9 Sep 2026 01:49:54 +0100 Subject: [PATCH 58/65] docs(tlsn): name the contract that exists, again `origin_form`'s doc cites the contract that pins the origin-form request line. That contract is `GoogleJwtRoots`. `IdentityJwksRoots` does not exist at libid-contracts v0.8.0 and did not exist when the line was written. This is the second time it has been fixed. The rename landed in 9c7cdb4 on `fix/prover-commits-sha256`; the merge that brought that branch in, 8f994cd, resolved this hunk against the other side and dropped it while keeping the rest of the commit. Nothing failed, because the name lives in a doc comment and no build, test or lint has an opinion about it -- which is exactly why it survived a second time. The argument was made once and has not changed: a reader who greps libid-contracts for `IdentityJwksRoots` finds nothing, and cannot tell from here whether the contract was renamed or the doc was always wrong. Assisted-by: Claude Opus 5 Signed-off-by: xgreenx --- crates/libid-tlsn/src/session.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/libid-tlsn/src/session.rs b/crates/libid-tlsn/src/session.rs index 5a73edd6..739bef8f 100644 --- a/crates/libid-tlsn/src/session.rs +++ b/crates/libid-tlsn/src/session.rs @@ -276,7 +276,7 @@ pub struct VerifierResult { /// 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 `IdentityJwksRoots` +/// 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. From ee03eebfed865421971791a5c44954457c5586d4 Mon Sep 17 00:00:00 2001 From: xgreenx Date: Wed, 9 Sep 2026 01:49:54 +0100 Subject: [PATCH 59/65] test(tlsn): commit the credential the fixture claims to hide `session()` is documented as revealing everything except the bearer and committing the bearer. Its offsets did the opposite. In GET /2/users/me HTTP/1.1\r\nauthorization: Bearer TOK\r\n\r\n the request line ends at 24, its CRLF at 26, and `authorization: Bearer ` is 22 bytes, so `TOK` sits at 48..51. The fixture committed 45..48 -- `er `, the tail of the header name -- and revealed the credential with everything else. Nothing caught it because nothing looked. Every test built on `session()` asserts tiling, the two signed lengths, the authority or the clock, and all of those hold just as well when the committed range is three bytes to the left. So the tests that stand for "the shape an identity session actually produces" stood for a session that publishes its own bearer. The new test is the one that looks: it locates `TOK` in the request, requires the single commitment to be exactly that range, and requires no revealed range to overlap it. Putting the offsets back to 45..48 fails it on the first assertion, which is the check the old fixture never had to pass. Also slices by `HEADER_LEN` rather than by 144. That 144 was the header length before this branch cut three 32-byte tags from the record; the header is now 48. The comparison still passed, because both sides encode the same prefix either way, but 144 named no boundary in the format and for this fixture cut four bytes into the first commitment. Assisted-by: Claude Opus 5 Signed-off-by: xgreenx --- crates/libid-tlsn/src/attest.rs | 47 ++++++++++++++++++++++++++++++--- 1 file changed, 44 insertions(+), 3 deletions(-) diff --git a/crates/libid-tlsn/src/attest.rs b/crates/libid-tlsn/src/attest.rs index 1aa6883a..59651005 100644 --- a/crates/libid-tlsn/src/attest.rs +++ b/crates/libid-tlsn/src/attest.rs @@ -333,7 +333,7 @@ mod tests { /// Reveal everything except the bearer, and commit the bearer -- the shape /// an identity session actually produces. fn session() -> (PartialTranscript, Vec) { - let bearer = 45..48; // "TOK" + 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())); @@ -385,6 +385,44 @@ mod tests { 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(); @@ -501,7 +539,7 @@ mod tests { "the two responses must be the same length" ); - let bearer = 45..48; + 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, @@ -515,7 +553,10 @@ mod tests { .to_partial(sent_revealed.clone(), RangeSet::from(0..recv.len())); let data = AttestedData::from_observed(observed(&partial, &commitments)).unwrap(); - headers.push(data.encode().unwrap()[..144].to_vec()); + headers.push( + data.encode().unwrap()[..libid_ceremony::attestation::HEADER_LEN] + .to_vec(), + ); } assert_eq!( headers[0], headers[1], From af15583cf0059e118ccc5a3459f87fb95d0b3178 Mon Sep 17 00:00:00 2001 From: xgreenx Date: Wed, 9 Sep 2026 01:49:54 +0100 Subject: [PATCH 60/65] chore: drop the comment for a dependency this branch removed `ts-rs` went when the `ts` feature did, and #16 has since taken the last orphaned workspace dependencies with it. The comment describing the TypeScript bindings codegen stayed behind, where it now introduces `webpki-root-certs` -- a crate that has nothing to do with TypeScript. Assisted-by: Claude Opus 5 Signed-off-by: xgreenx --- Cargo.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index df82e149..9cac11be 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -62,5 +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. webpki-root-certs = "1.0" From 84091017fe0dc7552b5aa8478b8df30e8a2bbc88 Mon Sep 17 00:00:00 2001 From: xgreenx Date: Wed, 9 Sep 2026 01:57:22 +0100 Subject: [PATCH 61/65] fix(tlsn): refuse a commitment the session could not have carried A prover states its transcript commitments as bare offsets, and nothing between the wire and the allocation bounds them. `TranscriptCommitConfigBuilder` refuses an out-of-range commitment, but that builder is the honest prover's path: `ProveRequest` derives its deserializer with no validation of its own, so a prover composing its own wire bytes never runs the check. Reproduced with a 72-byte message carrying `0..2^40` against a 4096-byte transcript, which deserializes without complaint. What the verifier then does with those offsets is allocate over every committed range and index the transcript's plaintext with it. So a range no session carried is either an allocation nothing bounds -- the notary's memory, at roughly 128 bytes of key material per claimed byte -- or, where the prover reveals everything, an index straight past the end of the plaintext slice. This is the last point holding both the request and the transcript it describes: `verify()` has returned, so the commitments are readable, and `accept()` has not been called, so nothing has walked them yet. It sits beside the `server_identity` guard, which is the same shape for the same reason. The bound is the application data the session actually carried, summed the way the verifier itself sums it. 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. This does NOT close the neighbouring hole, and cannot from here. A prover also declares its transcript's total length, and that number reaches `vec![0; n]` inside `verify()` -- before this code, or any libid-rs code, holds anything to inspect. Forty-eight bytes on the wire abort the process there, and the bound for it has to live in tlsn, on the fork the notary already patches in. Assisted-by: Claude Opus 5 Signed-off-by: xgreenx --- crates/libid-tlsn/src/session.rs | 138 +++++++++++++++++++++++++++++++ 1 file changed, 138 insertions(+) diff --git a/crates/libid-tlsn/src/session.rs b/crates/libid-tlsn/src/session.rs index 739bef8f..1c2c56e9 100644 --- a/crates/libid-tlsn/src/session.rs +++ b/crates/libid-tlsn/src/session.rs @@ -32,8 +32,10 @@ use tlsn::{ hash::HashAlgId, prover::ProverOutput, transcript::{ + ContentType, Direction, PartialTranscript, + Record, TlsTranscript, Transcript, TranscriptCommitConfig, @@ -145,6 +147,49 @@ pub fn root_store() -> RootCertStore { } } +/// 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() +} + +/// 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 @@ -704,6 +749,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}"), @@ -814,6 +889,69 @@ mod tests { } } + /// 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"); From 48e3859bc4c6f32591a264f7e2a241a78de8458b Mon Sep 17 00:00:00 2001 From: xgreenx Date: Wed, 9 Sep 2026 16:28:39 +0100 Subject: [PATCH 62/65] test(ceremony): the fixture verifier is the current section 7 vector The sample `code_verifier` was the conformance vector of ceremony-common as first merged, under `SHA256(PKCE_DOMAIN || digest || pkceNonce)`. The specification moved to `SHA256(digest || authorizationNonce)` and the contracts followed; this crate did not, because it derives nothing and so had no test that could fail -- both vectors are 43 base64url bytes, which is all `TokenRequest::validate` reads. The value is transcribed again, and the comment now says that it can only be transcribed. `the_published_verifier_is_the_right_shape` claims to hold the specification's own vector to the request bounds; with the old one it held a vector no specification produces. Assisted-by: Claude Opus 5 Signed-off-by: xgreenx --- crates/libid-ceremony/src/token_exchange.rs | 12 +++++++----- crates/libid-tlsn/tests/ceremony_end_to_end.rs | 2 +- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/crates/libid-ceremony/src/token_exchange.rs b/crates/libid-ceremony/src/token_exchange.rs index f4e7317a..b4d40941 100644 --- a/crates/libid-ceremony/src/token_exchange.rs +++ b/crates/libid-ceremony/src/token_exchange.rs @@ -186,7 +186,9 @@ mod tests { fn request() -> TokenRequest { TokenRequest { code: "abc123".into(), - code_verifier: "iMSTNh6gQkRnBGlY1c0MUOsD7MCO4G8C7ph1_gIZs5I".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(), } } @@ -250,10 +252,10 @@ mod tests { fn refuses_a_verifier_of_the_wrong_length_or_charset() { for bad in [ "short", - "iMSTNh6gQkRnBGlY1c0MUOsD7MCO4G8C7ph1_gIZs5", // 42 - "iMSTNh6gQkRnBGlY1c0MUOsD7MCO4G8C7ph1_gIZs5II", // 44 - "iMSTNh6gQkRnBGlY1c0MUOsD7MCO4G8C7ph1+gIZs5I", // base64, not base64url - "iMSTNh6gQkRnBGlY1c0MUOsD7MCO4G8C7ph1/gIZs5I", + "5teBDl6cz4U77aFweV5PbMhBJ_lEFv6LLNKzqnDI5l", // 42 + "5teBDl6cz4U77aFweV5PbMhBJ_lEFv6LLNKzqnDI5loo", // 44 + "5teBDl6cz4U77aFweV5PbMhBJ+lEFv6LLNKzqnDI5lo", // base64, not base64url + "5teBDl6cz4U77aFweV5PbMhBJ/lEFv6LLNKzqnDI5lo", ] { let r = TokenRequest { code_verifier: bad.into(), diff --git a/crates/libid-tlsn/tests/ceremony_end_to_end.rs b/crates/libid-tlsn/tests/ceremony_end_to_end.rs index b96f6162..6e05d5b4 100644 --- a/crates/libid-tlsn/tests/ceremony_end_to_end.rs +++ b/crates/libid-tlsn/tests/ceremony_end_to_end.rs @@ -40,7 +40,7 @@ use tlsn::{ }, }; -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=iMSTNh6gQkRnBGlY1c0MUOsD7MCO4G8C7ph1_gIZs5I"; +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"; From ada633d6e684356790caadfd9155c347ef14a1e7 Mon Sep 17 00:00:00 2001 From: xgreenx Date: Wed, 9 Sep 2026 18:25:52 +0100 Subject: [PATCH 63/65] chore: take libid-profiles and libid-identity 0.9 libid-contracts v0.9.0 makes the token request's headers profile data: each `TokenSession` gains `request_headers`, the lines a builder sets, and `request_header_block`, the same lines joined by CRLF for a verifier to match as a set. Nothing here reads them yet; the point is that the profile re-exported as `ceremony::profiles` is the one the deployed verifiers compare against, so a prover built on this crate cannot hold another copy. `libid-identity` moves with it under the single-version invariant. Assisted-by: Claude Opus 5 Signed-off-by: xgreenx --- Cargo.lock | 8 ++++---- Cargo.toml | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 84d8a070..7c2f0dcb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3389,15 +3389,15 @@ dependencies = [ [[package]] name = "libid-identity" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2600c89b89d4150001046d84d4e3a6f638aee454fe0c45ffcc54185113df5cb" +checksum = "fe0baf23cfda461fc82227695334e27f45a2feff53870a4612a1d01cafd7aed4" [[package]] name = "libid-profiles" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7632c18090e9053a04d7cc34e7b2fe3af310b72930593236604bcbe6f0ea80d0" +checksum = "4dab7ecd48e0d3c233192f235e4cc6f90dc6b62fe6ffb9903d99120e6f70c895" [[package]] name = "libid-signer" diff --git a/Cargo.toml b/Cargo.toml index 9cac11be..69c2649b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,11 +38,11 @@ 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.8" +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.8" +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" From de6a6e161723d4f9b7f63fb63f2ef6397802a1da Mon Sep 17 00:00:00 2001 From: xgreenx Date: Wed, 9 Sep 2026 18:38:12 +0100 Subject: [PATCH 64/65] docs: describe the crates that exist after the dead-code sweep Three descriptions outlived what they described. The README's prover example called `libid_tlsn::prover` with `UserInfoParams`, both removed in #15, and its closing paragraph named a `bearer_token` parameter `prover_generic` never had; it now shows `prover_generic` with the ceremony layouts, which is the call the GitHub Token-Exchange Service makes. `libid-crypto` advertised Merkle trees the sweep deleted, in its description, its keywords and the README's first sentence. Two comments in `ranges.rs` named the contract function `_extractId`, which libid-contracts calls `tryJsonInteger`. Assisted-by: Claude Opus 5 Signed-off-by: xgreenx --- README.md | 46 ++++++++++++++++++--------- crates/libid-crypto/Cargo.toml | 4 +-- crates/libid-transcript/src/ranges.rs | 6 ++-- 3 files changed, 36 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 27d048c1..61b7da74 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,8 @@ 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 @@ -44,28 +44,44 @@ let result = libid_tlsn::verifier(socket).await?; 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-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-transcript/src/ranges.rs b/crates/libid-transcript/src/ranges.rs index 23126669..07fb1896 100644 --- a/crates/libid-transcript/src/ranges.rs +++ b/crates/libid-transcript/src/ranges.rs @@ -250,8 +250,8 @@ fn require_contiguous(raw: &[u8], decoded: &[u8]) -> 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 start = find_first(body, needle.as_bytes())?; @@ -299,7 +299,7 @@ pub fn compute_field_snippet_range( /// 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, From 8954d8480b2f7856aa11c87efd8a35f9fa6880c3 Mon Sep 17 00:00:00 2001 From: xgreenx Date: Wed, 9 Sep 2026 19:03:44 +0100 Subject: [PATCH 65/65] fix(tlsn): a driver that finishes after the session ran is the peer closing `verifier()` raced its setup against the mux driver and treated the driver finishing as a dead connection. That is right before the session runs -- a health probe connects and closes, and a request submitted to the mux would never resolve -- and wrong after: the prover closes the mux as its last act while this side is still verifying what it received, so every session that succeeded failed at the end, nothing was signed, and the prover read EOF. A flag set once the session has run decides which case it is. When the driver finishes after that, setup is let finish and the driver's result kept, since a finished handle cannot be polled again. `prover_generic` has the same shape and gets the same guard. Not a `select!` precondition: those are evaluated once, on entry, when nothing is established yet. Found by SupremaLex running this branch's prover and verifier as two real processes against GitHub; the fix is theirs, confirmed twice with fresh authorization codes. The openings a prover hands back are no longer promised in the layouts' order. tlsn builds the commitments from a set, so the order varies per process; a caller matches on the ranges an opening covers, which is what the one caller already does. Assisted-by: Claude Opus 5 Signed-off-by: xgreenx --- crates/libid-tlsn/src/session.rs | 64 ++++++++++++++++++++++++++------ 1 file changed, 53 insertions(+), 11 deletions(-) diff --git a/crates/libid-tlsn/src/session.rs b/crates/libid-tlsn/src/session.rs index 1c2c56e9..93f52b54 100644 --- a/crates/libid-tlsn/src/session.rs +++ b/crates/libid-tlsn/src/session.rs @@ -7,7 +7,13 @@ use hyper::{ }; use hyper_util::rt::TokioIo; use libid_transcript::ceremony::Layout; -use std::future::IntoFuture; +use std::{ + future::IntoFuture, + sync::atomic::{ + AtomicBool, + Ordering, + }, +}; use tlsn::{ attestation::{ request::{ @@ -292,8 +298,10 @@ pub struct ProverResult { pub response_body: Vec, /// The TLS secrets for proof construction. pub secrets: Secrets, - /// One opening per commitment this session made, in the order the layouts - /// stated them. Empty when the session committed nothing. + /// 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, @@ -407,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 @@ -555,6 +568,7 @@ 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(); @@ -646,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 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}"), })? @@ -684,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( @@ -731,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(); @@ -817,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}"), })?