Skip to content

feat: the ceremony wire constructions — digest, PKCE, attested data, reveal layouts - #2

Merged
xgreenx merged 78 commits into
mainfrom
feat/ceremony-constructions
Sep 9, 2026
Merged

xgreenx merged 78 commits into
mainfrom
feat/ceremony-constructions

Conversation

@SupremaLex

@SupremaLex SupremaLex commented Aug 21, 2026

Copy link
Copy Markdown
Member

What this adds

The Rust side of the ceremony: what a prover reveals and commits, what the notary
signs, and the bytes the contracts decode. It absorbs #5 through #17.

libid-ceremony (new, published) holds what a notary needs to sign a
ceremony attestation and nothing else. attestation is the attested-data format
the launch profiles pin: big-endian fixed-width, encode only, and the keccak256
over it that is the only preimage a notary signs. The format is the profile's,
not the specification's (REQ-COMMON-18 leaves it to the profile author), so its
rules are stated in the module in full, and the encoding is pinned by a test
against the Solidity decoder's own fixture rather than against itself.
token_exchange is the GitHub Token Service request and response with the
bounds a served call must satisfy: a signed attestation, an exact 16-byte
opening, a nonempty printable token. It replaces libid-attestations.

libid-transcript::ceremony chooses what a notarized session reveals: one
Layout per direction of each session, built from the profile table
libid-contracts generates (libid-profiles 0.9, re-exported as
ceremony::profiles). Commitments are the complement of the reveals, so the two
tile the signed length by construction, which is what the Platform Verifier
checks. The token request is one revealed range (GitHub's client_secret
committed as the suffix); the identity request reveals everything but the bearer
value; responses reveal each field with its full delimiter. ranges now matches
exact JSON templates, refuses a snippet that spans chunk framing, and scans bare
integers the way the contract's tryJsonInteger does.

libid-tlsn: prover_generic(socket, request, select_layout, on_progress)
takes the layouts from a closure over both transcripts and hands back one
CommitmentOpening per commitment; attest::AttestedData::from_observed builds
the record from what the verifier observed. Range commitments are pinned to
SHA-256 (REQ-COMMON-38; tlsn defaults to BLAKE3, which the circuit cannot open).
The request goes on the wire in origin-form. A commitment past the session's
bytes is refused before accept walks it. A mux driver that finishes after the
session ran is the peer closing, not a fault, so a session that succeeds no
longer fails at its last step.

Removed: libid-attestations, libid-transcript::types, the fixed-shape
prover() and UserInfoParams, handshake extraction, and the Merkle and
recovery helpers in libid-crypto nothing called.

Breaking

prover_generic changed signature; libid-attestations is gone. The notary
(cc40b0c) still calls the removed attest::attested_data and moves to
from_observed after this merges.

Verification

cargo +nightly fmt --all -- --check
cargo clippy --workspace --all-targets --all-features -- -D warnings
cargo test --workspace

113 tests pass; clippy and fmt are clean. Records built from these layouts
verified end to end against the released XPlatformVerifier and
GitHubPlatformVerifier of libid-contracts v0.9.0. SupremaLex ran the prover and
verifier as two real processes against GitHub with a fresh authorization code.

What waits on this

A libid-rs release, then the notary migration. The browser draft still plans one
revealed range per form field and must follow the one-range layout the spec now
states (libid#31); the record merges adjacent ranges before signing, so the
per-field plan cannot survive.

🤖 Generated with Claude Code

@xgreenx xgreenx left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The notary must be agnostic to the ceremony, notary only participates in the communication and returns an attestation over the data that was revealed by the client)client decides what to reveal. We should just return information about host, path, and commitment

Comment thread crates/libid-ceremony/src/launch.rs Outdated
Comment thread crates/libid-ceremony/src/profile.rs Outdated
Comment thread crates/libid-ceremony/src/profile.rs Outdated
@Wondertan

Copy link
Copy Markdown
Member

Needs rebase

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 <georglutsenko@gmail.com>
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 <georglutsenko@gmail.com>
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 <georglutsenko@gmail.com>
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 <georglutsenko@gmail.com>
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 <georglutsenko@gmail.com>
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 <georglutsenko@gmail.com>
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 <georglutsenko@gmail.com>
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 <georglutsenko@gmail.com>
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 <georglutsenko@gmail.com>
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 <georglutsenko@gmail.com>
`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 <georglutsenko@gmail.com>
`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 <georglutsenko@gmail.com>
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<u8>` 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 <georglutsenko@gmail.com>
…tten

`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 <georglutsenko@gmail.com>
`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 <georglutsenko@gmail.com>
`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 <georglutsenko@gmail.com>
…s 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 <georglutsenko@gmail.com>
`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 <georglutsenko@gmail.com>
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 <georglutsenko@gmail.com>
`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 <georglutsenko@gmail.com>
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 <georglutsenko@gmail.com>
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":"<digits>"` and `"username":"<value>"` 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 <georglutsenko@gmail.com>
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 <georglutsenko@gmail.com>
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 <georglutsenko@gmail.com>
@SupremaLex
SupremaLex force-pushed the feat/ceremony-constructions branch from 351b144 to 239a4bb Compare August 25, 2026 19:49
Comment thread crates/libid-ceremony/src/token_exchange.rs Outdated
Comment thread crates/libid-ceremony/src/token_exchange.rs Outdated
`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 <xgreenx9999@gmail.com>
Assisted-by: Claude Opus 5
…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 <xgreenx9999@gmail.com>
Assisted-by: Claude Opus 5
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 <xgreenx9999@gmail.com>
Assisted-by: Claude Opus 5
…t 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 <xgreenx9999@gmail.com>
Assisted-by: Claude Opus 5
xgreenx and others added 2 commits September 8, 2026 23:53
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 <xgreenx9999@gmail.com>
fix: commit under SHA-256, and drop the claims that no longer hold
xgreenx and others added 4 commits September 8, 2026 23:56
feat(transcript): take the ceremony profiles from the chain's own table
…o refactor/remove-the-dyaka-remnants

# Conflicts:
#	crates/libid-crypto/src/lib.rs
#	crates/libid-tlsn/src/session.rs
refactor!: remove what the dyaka product left behind
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 <xgreenx9999@gmail.com>
xgreenx and others added 4 commits September 9, 2026 00:53
chore: drop the workspace dependencies nothing claims
`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 <xgreenx9999@gmail.com>
`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 <xgreenx9999@gmail.com>
`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 <xgreenx9999@gmail.com>
xgreenx and others added 2 commits September 9, 2026 01:57
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 <xgreenx9999@gmail.com>
fix: restore a lost rename, and a fixture that revealed the credential it hid
@SupremaLex

Copy link
Copy Markdown
Member Author

Ran this branch's libid-tlsn between two real processes — the libID OAuth
Bridge as prover, the notary as verifier — with a real GitHub OAuth app and a
real authorization code. Found a bug that stops every successful session, and
confirmed one of your fixes here is needed.

verifier() aborts every session that succeeds

The select! at the end of verifier() treats its mux driver finishing as a
dead connection:

driver_res = driver_task.handle_mut() => {
    return Err(driver_finished_early(driver_res));
}

That is right during setup — a health probe connects and closes, and a request
already submitted would never resolve. It is wrong after run(). There the
prover has finished and closed the mux as its last act (prover.close() then
handle.close()), which is normal, and the record is exchanged on the raw
socket afterwards.

So the verifier fails at the last moment, handle_verified_session never runs,
nothing is signed, and the prover reads EOF:

notary:  finished MPC-TLS
         ERROR driver task finished before the session completed

bridge:  MPC-TLS proof complete
         Attestation request built
         ERROR the notary sent no record for the session it ran: io: early eof

Still present at 25d182d.

Fix

A flag set after run(), checked when the driver finishes:

let established = AtomicBool::new(false);
// after verifier.run():
established.store(true, Ordering::Release);

driver_res = driver_task.handle_mut() => {
    if !established.load(Ordering::Acquire) {
        return Err(driver_finished_early(driver_res));
    }
    finished_driver = Some(driver_res);
    (&mut setup).await?
}

Keep the driver's result and let setup finish — awaiting the handle again
below would poll a finished task.

One thing to watch: this cannot be a select! precondition
(, if !established.load(..)). Tokio evaluates those once, when the select is
entered, and nothing is established then. My first attempt did that, built
fine, and failed exactly as before.

The same guard is in prover_generic at the other call site. I did not need to
touch it, but it has the same shape.

Your SHA-256 change is needed

With the fix above the notary got to signing and refused there:

a commitment uses HashAlgId(2), but REQ-COMMON-38 pins SHA-256 for launch profiles

prover_generic builds TranscriptCommitConfig without naming an algorithm,
so it gets the library default of BLAKE3, while attest.rs in the same crate
accepts only SHA-256. This branch already fixes it —
default_kind(Hash { alg: SHA256 }) — and our pin cafd9a0b predates it, so I
backported it to reproduce. Confirming it is load-bearing, not cosmetic.

After both

notary:  Verified server: github.com
         Verification complete: 425 sent, 4829 recv bytes
         Ceremony attestation sent to prover

bridge:  Attestation request built
         (no error)

Twice, each with a fresh authorization code.

Note for whoever picks this up: we cannot move our pin to this branch's head
yet. libid-attestations is gone here, folded into libid-transcript, and
notary@86c179d still depends on it.

@SupremaLex

SupremaLex commented Sep 9, 2026

Copy link
Copy Markdown
Member Author

Follow-up. With the guard fixed the ceremony got two steps further and hit a
third bug in the same file, which I think is the worst of the three because
nothing fails when it happens.

prover_generic sends the request line in absolute form

The wire carried this, read out of the attested transcript:

POST https://github.com/login/oauth/access_token HTTP/1.1\r\nhost: ...

RFC 9112 3.2.2 reserves the absolute form for a request to a proxy. Origin form
is what a direct request carries:

POST /login/oauth/access_token HTTP/1.1

github.com answers the absolute form anyway. So the exchange succeeds, the
response is a real bearer, the layouts tile, and the notary signs it. The fault
surfaces only in the browser, where the Platform Verifier compares the revealed
request path with its profile constant and finds an absolute URL:
invalid GitHub token payload: request line.

send_request hands the caller's Request to hyper, which writes the request
line from request.uri() — and the caller has to pass an absolute URI, because
session.rs:402 takes the SNI host and the TCP peer from it. I tried fixing it
on our side first and got exactly what that code says:
request URI carries no host.

So it belongs here, after the host has been taken:

let mut request = request;
*request.uri_mut() = request
    .uri()
    .path_and_query()
    .map(|p| p.as_str())
    .unwrap_or("/")
    .parse()
    .map_err(|e| Error::MpcTlsFailed { detail: format!("request target: {e}") })?;

Worth noting a caller cannot test for this. Ours asserts req.uri() == TOKEN_URL, which is the absolute URL and correct as an input, and its fixture
separately states POST /login/oauth/access_token HTTP/1.1 without ever
comparing it with the wire. Both are consistent with the bug. The wire form is
this function's decision, so the test belongs here too.

With all three, the ceremony completes

notary:  Ceremony attestation sent to prover
         ProxyMode verified: 192 sent, 3055 recv bytes for api.github.com
         ProxyMode: ceremony attestation ready for api.github.com
app:     Ceremony finished. Proof received.

Both notarized sessions, real GitHub OAuth app, real authorization code.

One caveat so the result is not read as more than it is: to get there I also
relaxed the browser's request-layout check, and that one is NOT a fix. The
browser is right — platform-ceremonies.md 6.4 lists seven revealed ranges for
the GitHub exchange and puts headers under everything else | no, and
REQ-PLAT-43D forbids revealing anything outside them. ceremony::token_request
reveals one span covering the headers, here and on 25d182d. So the run above
proves the transport, the hand-back and the request line; it does not prove the
disclosure layout.

Correction, and the reason that layout change cannot work on its own

Since writing the above I ran the X ceremony, which uses no Rust prover at all —
browser ProxyMode against the notary — and it fails in the same family:

invalid notarization: sent revealed range count changed

The record cannot carry adjacent revealed ranges. The browser plans one
revealed span per form field, so a verifier can bind each field on its own, and
in a form body those spans are adjacent by construction —
x/1/transcript.ts:104 ends each span just past its & and starts the next
exactly there. The notary writes the record from a RangeSet
(attest.rs:105, authed = partial.sent_authed(), which is
&RangeSet<usize>), and rangeset-0.4.1 states the invariant plainly:

The ranges are non-adjacent.

enforced in the constructor and after every operation, with sort_merge
commented "Merge the ranges if they are adjacent or overlap". The adjacent
field spans become one before anything is signed.

Measured on a live X ceremony, with the counts instrumented in:

sent revealed range count changed:
  planned 6  [0..31, 165..195, 195..240, 240..337, 337..399, 399..456]
  signed  2  [0..31, 165..456]

0..31 is the request line and survives, because 31..165 — the committed
headers — keeps it apart. The five field spans each begin exactly where the last
ended and collapse into 165..456. The merge boundary is precisely where
adjacency starts.

Reproduced identically with a second, independently registered X application —
same six planned spans, same two signed, same transcript sizes. It does not
depend on the client.

Same encoder for both platforms: notary/src/server.rs:642 (ProxyMode, X) and
:725 (MPC-TLS, GitHub) both call sign_ceremony_attestation ->
libid_tlsn::attest::attested_data.

So my sentence above — that the disclosure layout is this repo's to fix — is
right about the specification and wrong about feasibility. Moving
token_request from one revealed span to the five 6.4 requires would not
survive signing: four of the five are adjacent form fields and the RangeSet
flattens them back. No layout change alone can produce what the browser
verifier requires.

The ordered list is not lost in the layout — Layout.reveal is already a
Vec<Range<usize>> in the order the profile states, and it is converted to a
RangeSet on the way into to_partial. Carrying that ordered partition into
the record, rather than a normalised set, is what the disclosure change needs
underneath it. That is a record-format question, so it probably wants a decision
before code.

Unrelated, but it cost the most time

session.worker.ts:267 catches the failure as .catch(async () => ...) — the
error is not bound to a variable at all — and session.ts:71 then discards
event.data and raises a fixed "Notarization failed". Two lines of local
instrumentation turned that into the exact check, file and range counts above.
It is on the critical path of every platform whose token exchange the browser
performs itself, which is all of them except GitHub.

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 <xgreenx9999@gmail.com>
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 <xgreenx9999@gmail.com>
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 <xgreenx9999@gmail.com>
…losing

`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 <xgreenx9999@gmail.com>
@xgreenx

xgreenx commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Thanks for running it for real. Status of everything in both comments, at 8954d84:

The three bugs

  1. The verifier() race is fixed in 8954d84, as you wrote it: a flag set after run(), and when the driver finishes after that, setup is let finish and the driver's result kept rather than polling the handle again. Same guard at the prover_generic site. Not a select! precondition, for the reason you found.
  2. SHA-256 was already in the branch; the description now says so.
  3. Origin-form is fix(tlsn): send the request-target in origin-form #5's origin_form, merged here. The wire-level test you asked for is not in this PR; it goes in its own PR against main once this merges, driving hyper's encoder over a duplex and reading the request line off the wire.

The layout, and the decision you asked for

Made, and it is not a record-format change. The token request is one revealed range: the request line, every header and the body, with GitHub's client_secret committed as the suffix REQ-COMMON-22 orders last. The on-chain verifier (libid-contracts v0.9.0, _tokenSession / _checkTokenHead) reads the fields out of that one range and checks the header set in any order; ceremony::token_request here already produces it; the specification says it in libid-org/libID#31 (§5.2, §6.4, REQ-PLAT-56A/B/C). So nothing plans adjacent ranges any more and the RangeSet merge has nothing to flatten. What has to follow is the browser draft: selectTokenReveals must plan one sent range and read the fields inside it. Filed as a libid task.

The one adjacency that remains is GitHub's "id":N, directly before "login":"…" in the identity response. It does merge into one range; the verifier reads both members from it and pins no count in that direction, so it verifies. Left as is.

Openings. commitment_openings no longer promises an order. tlsn builds the commitments from a set, so the order varies per process; matching on the ranges, as your bearer_blinder does, is the right reading and now the documented one.

The worker's .catch(async () => …). Agreed it is the expensive one. Filed as a libid task alongside the layout change.

Description. Rewritten to the branch as it is: no RevealMode, no pkce or profile modules, the requirement numbers the code actually cites.

After the merge: a libid-rs release, then the notary moves from attested_data to AttestedData::from_observed.

@xgreenx
xgreenx merged commit 501f094 into main Sep 9, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants