Skip to content

feat!: return ceremony attestations on the notarization WebSocket - #6

Open
Wondertan wants to merge 19 commits into
feat/ceremony-attestationfrom
feat/ws-attestations
Open

feat!: return ceremony attestations on the notarization WebSocket#6
Wondertan wants to merge 19 commits into
feat/ceremony-attestationfrom
feat/ws-attestations

Conversation

@Wondertan

@Wondertan Wondertan commented Aug 26, 2026

Copy link
Copy Markdown
Member

Summary

  • stack on refactor!: sign the ceremony attested data, and nothing else #3 and carry browser WASM prerelease packaging forward without generated-source embedding
  • publish the matched TLSNotary wrapper, WASM, and generated worker module under their native relative paths for CCDP
  • pin the temporary TLSN and MPZ patches to libID-owned forks without changing the qualified code
  • allow version-matched RC releases without moving the final Docker latest alias
  • sign the canonical ceremony §9.1 attested data for both notary transports
  • remove browser session IDs, retained results, and HTTP polling
  • reclaim the TLSNotary channel and return one dedicated WebSocket binary message containing the length-prefixed {attested_data, notary_signature} record, then close
  • preserve the equivalent MPC behavior on the recovered TCP socket
  • add local end-to-end smoke tests for both ProxyMode and MPC, including signature recovery, framing, and EOF

This follows the later browser transport contract in libid-org/libid#13: the notary sends the canonical libID record directly after TLSNotary completes and reads no application-level request.

Browser WASM artifact

The release archive preserves exactly the generated runtime tree: tlsn_wasm.js, tlsn_wasm_bg.wasm, and snippets/web-spawn-<digest>/js/spawn.js. The build verifies the wrapper's generated import and stages the worker at that path unchanged. CCDP can therefore select those three resources, mount the tree beneath an immutable asset prefix, and apply worker-specific response headers only to spawn.js; the notary no longer needs to flatten, rewrite, or embed generated modules.

Browser handoff race

The previous fixes changed verifier/driver shutdown ordering, but the invalid-message failure remained because the handoff itself had no boundary. The WASM adapter reads and buffers a whole WebSocket message; finish() then discards that adapter. If the final TLSNotary bytes and attestation were emitted by one duplex-pump read, they became one WebSocket message and the buffered attestation was lost before the original browser IO tried to read it.

The notary now drains and closes the TLSNotary side before sending the attestation as its own WebSocket binary message. WebSocket ordering provides a deterministic ownership boundary without another request or acknowledgement. The regression test asserts that the final WebSocket message is exactly one valid length-prefixed attestation. This is a framing implementation bug, not a fundamental limitation of same-WebSocket retrieval.

MPC regression and fix

Before #3's fix, the new ceremony attestation was retained only in the browser session map for later HTTP polling. MPC enters through the raw TCP listener and has no sessionId, so that storage branch was unreachable: an MPC prover could complete TLS verification but could not retrieve the ceremony attestation.

#3 fixes this by writing the single canonical attestation frame directly to TLSNotary's recovered MPC socket. No follow-up request or HTTP retrieval is involved. Because this PR is stacked on #3, it includes that fix and adds an end-to-end MPC regression test covering the real MPC exchange and final response.

Closes #5.

Dependencies

Upstream TLSNotary main has had no commits for over a month. If #1178, #1179, or #1173 and its MPZ dependency are not merged and released in a timely manner, we may retain the libid-org/tlsn and libid-org/mpz forks instead of blocking this work.

The server and SDK test clients are pinned to libid-org/tlsn@94aaaf33f3361d1218f9abb4c82b5c58a9199460, with MPZ resolved from libid-org/mpz@1dd2349d52aeea038d77fb0816f781c6b714fe77. The browser WASM build reads the same TLSN revision from Cargo.lock; that TLSN workspace carries the corresponding MPZ patch. The deadlock fix and its domain-separation follow-up must ship together, with matched server and browser artifacts.

Before merge

  • settle the TLSNotary source: upstream releases containing #1178, #1179, and #1173 (including MPZ #446), or maintained fork pins containing all of those changes
  • land the browser contract and selective CCDP resource distribution in libid#13
  • load v0.3.0-rc.3 through CCDP in a real browser, then run one real X/GitHub ceremony

Verification

  • TLSN_WASM_FORCE=1 ./scripts/build-tlsn-wasm.sh --out <fresh-dir> built libid-org/tlsn@94aaaf33 and staged only the wrapper, WASM, and snippets/web-spawn-05868593a72e2d44/js/spawn.js; its archive retained the same tree and no root spawn.js
  • cargo +nightly-2026-08-10 fmt --all -- --check
  • cargo +1.97.1 clippy --all-targets --all-features -- -D warnings
  • cargo +1.97.1 test --all passed all 15 tests, including real local Proxy-WebSocket and MPC recovered-socket protocol checks
  • bash -n scripts/build-tlsn-wasm.sh
  • git diff --check
  • v0.3.0-rc.3 release workflow passed; the uploaded WASM archive has SHA-256 1cd11e4397dc2d3a9749f16ae901c8d8e2ea97b539e80629d40cdb996551a7a8 and the verified native tree above

@Wondertan
Wondertan changed the base branch from main to feat/ceremony-attestation August 26, 2026 15:19
@Wondertan
Wondertan force-pushed the feat/ws-attestations branch from bae5f1e to d449da1 Compare August 26, 2026 15:23
@Wondertan Wondertan closed this Aug 26, 2026
@Wondertan Wondertan reopened this Aug 26, 2026
@Wondertan
Wondertan force-pushed the feat/ws-attestations branch 3 times, most recently from 271d318 to d4c4d87 Compare August 26, 2026 23:36
@Wondertan
Wondertan requested review from SupremaLex and xgreenx and removed request for SupremaLex August 27, 2026 11:42
@Wondertan
Wondertan force-pushed the feat/ws-attestations branch 3 times, most recently from cf8b26c to f65795f Compare August 28, 2026 03:20

@SupremaLex SupremaLex left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Read server.rs end to end. The core move is right, there is one design problem I'd like changed, and two things I checked rather than assumed.

The core move is right

This deletes a stateful subsystem and replaces it with nothing: SessionMap, SessionEntry, SessionAttestation, SESSION_TTL, SESSION_SWEEP_INTERVAL, sweep_stale_sessions, admit_session, the background sweep task, the ready Notify, POST /session, GET /attestation/{id}, NotarizeQuery — for a Semaphore and a write back on the socket.

That removes a memory-DoS surface, a background task, a monotonic-vs-wall-clock hazard, a produced-but-never-fetched lifecycle, and this race, which silently discarded a completed proof:

"session {sid} gone before raw attestation could be stored (evicted/abandoned) — proof dropped"

It also converges with what libid-server-rs already assumes — "the notary answers a completed session on the socket the session ran over. It reads no attestation request." Two sides of one contract that previously disagreed.

The WebSocket rework is the strongest part. Splitting the pump into independent inbound/outbound tasks with a oneshot handoff, and the two comments justifying it — cancellation independence, and "this message boundary is the handoff: TLSNotary may read ahead within one WebSocket message, but it cannot consume this later one" — are load-bearing and correctly reasoned. try_acquire_owned before on_upgrade, so an over-capacity client gets a real 503 rather than a socket that just closes, and reject(Some("UPSTREAM_CONNECT_FAILED")) so a prover learns why, are both right.

Two things I checked rather than assumed

The deleted driver-death guard is not a regression. driver_finished_early, handle_mut() and the select! race are gone, and silent_connection_hits_the_deadline asserts a silent client takes the full 300s — which reads at first like trading a prompt failure for a five-minute hang, and with a semaphore that would be a cheap way to exhaust permits. But tests/driver_task_leak.rs covers the scenario the guard actually existed for: 20 connect-then-drop cycles, a 2s settle, num_alive_tasks() == 0. A handler wedged for 300s would show as 20 live tasks and fail it. Replaced, not dropped — worth saying out loud since the comment explaining the original hazard went with it.

CI: I have not run the suite on this branch; the above is from reading.

Inline comments below on the #[cfg(test)] seam (the one I'd like changed), the nested Result, the permit, and one question about a deleted test.

Comment thread src/server.rs Outdated
Comment thread src/server.rs Outdated

let setup = async {
#[cfg(test)]
let root_store = state

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Trust-anchor selection is the first of the two cfg forks. Under cfg(test) this reads proxy_test; shipped, it is unconditionally libid_tlsn::root_store(). Whichever branch is wrong, no test can reach it. See the note on the proxy_test field — an ordinary root_store field on NotaryState removes the fork.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Addressed in bebcdaf: the verifier now always reads state.proxy_root_store through one production code path.

Comment thread src/server.rs
Comment thread src/server.rs
handle.close();

Ok((server_name, transcript, transcript_commitments))
Ok::<_, Error>(Ok((server_name, transcript, transcript_commitments)))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Ok::<_, Error>(Ok(...)), then let setup_outcome = setup.await? and a match on the inner — the nesting encodes a real distinction (an error that must join the driver first, versus one that can propagate immediately) but nothing names it, so a reader has to reverse-engineer why there are two levels.

A two-variant enum would say it — SetupOutcome::Ready(..) | SetupOutcome::Rejected(Error) — or, if the shape stays, one sentence on setup stating what each level means. It is the sum-type-written-as-a-product smell: two different failures wearing the same Result.

Comment thread src/server.rs
Comment thread src/server.rs
@SupremaLex

Copy link
Copy Markdown
Member

Ran this branch (86c179d) against a Rust prover — the libID OAuth Bridge —
over the TCP listener, with a real GitHub OAuth app and a real authorization
code. The MPC-TLS session and the GitHub exchange both work. The attestation
step does not. Two separate problems.

1. No record comes back on a session that fully succeeded

Prover side, one session:

Setting up MPC-TLS
MPC-TLS setup complete
Connecting to github.com API
TLS handshake complete
Sending POST /login/oauth/access_token
Response: 255 bytes            <- GitHub returned a real bearer
MPC-TLS proof complete
Attestation request built
ERROR  MPC-TLS failed: the notary sent no record for the session it ran:
       io: early eof

Notary side, same session:

starting MPC-TLS
finished MPC-TLS
ERROR  TCP handler error: MPC-TLS failed: driver task finished before the
       session completed

Verification complete never appears, so libid_tlsn::verifier() returned the
driver-finished-early error and handle_verified_session — which signs and
writes the record — never ran. The prover then reads EOF.

Reproduced twice on separate runs, each with a fresh authorization code.

Guess, not verified: prover_generic calls prover.close() and handle.close()
before the caller reads the attestation off the socket. If that closes the mux,
the verifier's driver finishes and the notary can never write the record.

2. Panic when the platform returns non-2xx

When the platform answers non-2xx, prover_generic returns early and a tokio
worker panics:

thread 'tokio-rt-worker' panicked at async-task-4.7.1/src/task.rs:452:45:
Task polled after completion

Backtrace, trimmed:

async_task::task::Task<T,M>::poll                     <- panic
futures_util  TryJoin / TryMaybeDone / Either
mpz_garble::store::garbler::GarblerStore<COT>::flush  (garble/src/store/garbler.rs:124)
mpz_vm_core::Execute::execute_all
tlsn::prover::client::mpc::InnerState::finalize
tlsn::prover::client::mpc::MpcTlsClient::poll  (x3 — normal tail recursion,
                                                not the bug)
tlsn::prover::Prover<Connected<S>>::poll
tlsn::prover::future::ProverFuture::poll

GarblerStore::flush loops on wants_flush() and each pass builds a
ctx.try_join(..) whose branches are spawned as async_task::Tasks. One of
those is polled after it already returned Ready.

MpcTlsClient does guard against re-polling — State::Finished returns
"mpc tls client polled again in finished state" — but the finished future here
is inside finalize, below that guard.

It kills one worker task. The process keeps running and keeps serving.

The two are separate: changing only the OAuth clientId so that GitHub answers
200 instead of 404 gives the missing record with zero panics. So the missing
record is not a side effect of the panic.

@Wondertan

Copy link
Copy Markdown
Member Author

@SupremaLex, the second one I will have to look at and it could be real, as I didnt tested the failure cases coming from platforms. The first however works well with wasm client and I am able to get attestations. Please investigate it.

SupremaLex added a commit to libid-org/libid-server-rs that referenced this pull request Sep 9, 2026
Five scratch files landed here that should not have: two unapplied
patches against other repositories, and three write-ups of a debugging
session. The patches target libid-rs and the TypeScript ceremony
package, so nothing in this build consumes them and they would drift
from what they patch with no way for a reader to tell. The write-ups
belong where the work does.

The findings themselves are on libid-rs#2 and libid-org/notary#6, which
is where anyone acting on them is looking.

Assisted-by: Claude Opus 5
Signed-off-by: SupremaLex <georglutsenko@gmail.com>
@Wondertan

Wondertan commented Sep 10, 2026

Copy link
Copy Markdown
Member Author

Point 2 is fixed in libid-org/mpz#2 and pinned here by 231ef89.

The non-2xx return drops the TLSN session handle while its detached prover still owns an MPZ context. In alpha.6, dropping the executor cancelled queued tasks, so a still-awaited async-task::Task could be polled after cancellation. The backport makes each context retain the shared executor handle, and shutdown now runs only when its final guard drops. The focused regression drops the executor before using its context.

This is the minimal alpha.6-compatible lifetime part of upstream MPZ #403; it does not pull in that PR's global pool/API refactor. The notary suite passes all 15 tests, including the real ProxyMode and MPC return paths, and full clippy passes with warnings denied.

Point 1 was a separate Rust-prover integration issue and is fixed by libid-rs@8954d84. This MPZ dependency fix does not change notary framing or close behavior. PR #6 still inherits #3's older libid-rs pin; updating that pin remains with #3's owner.

Assisted-by: GPT-5
Signed-off-by: Wondertan <hlibwondertan@gmail.com>
Remove session creation and polling. Reclaim the ProxyMode channel, send one length-prefixed ceremony attestation, then close it. Pin the browser bundle to TLSNotary #1178 for prover-side finish().

Assisted-by: GPT-5
Signed-off-by: Wondertan <hlibwondertan@gmail.com>
Exercise a real local ProxyMode session through the WebSocket route, reclaim the prover channel, validate the signed ceremony attestation, and require EOF without any polling endpoint.

Assisted-by: GPT-5
Signed-off-by: Wondertan <hlibwondertan@gmail.com>
Assisted-by: GPT-5
Signed-off-by: Wondertan <hlibwondertan@gmail.com>
Assisted-by: GPT-5
Signed-off-by: Wondertan <hlibwondertan@gmail.com>
Signed-off-by: Wondertan <hlibwondertan@gmail.com>
Assisted-by: GPT-5
Signed-off-by: Wondertan <hlibwondertan@gmail.com>
Assisted-by: GPT-5
Signed-off-by: Wondertan <hlibwondertan@gmail.com>
Assisted-by: GPT-5
Signed-off-by: Wondertan <hlibwondertan@gmail.com>
Assisted-by: GPT-5
Signed-off-by: Wondertan <hlibwondertan@gmail.com>
Signed-off-by: Wondertan <hlibwondertan@gmail.com>
Assisted-by: GPT-5
Signed-off-by: Wondertan <hlibwondertan@gmail.com>
Assisted-by: GPT-5
Signed-off-by: Wondertan <hlibwondertan@gmail.com>
Pin the notary and release WASM build to the libid TLSN integration revision containing upstream PRs #1178 and #1179.

Assisted-by: GPT-5
Signed-off-by: Wondertan <hlibwondertan@gmail.com>
Promote the PoC-qualified TLSN pin with per-consumer RCOT and KOS domain separation. Repeat its MPZ workspace patches so the notary and SDK test clients resolve the same dependency graph as the browser WASM build.

Depends on tlsnotary/tlsn#1173 and ethereum/mpz#446, alongside the existing #1178 and #1179 fork changes.

Assisted-by: GPT-5
Signed-off-by: Wondertan <hlibwondertan@gmail.com>
Assisted-by: GPT-5
Signed-off-by: Wondertan <hlibwondertan@gmail.com>
Preserve the generated wrapper, WASM, and hashed web-spawn module paths in the release archive so CCDP can deploy them without rewriting generated source.

Assisted-by: GPT-5
Signed-off-by: Wondertan <hlibwondertan@gmail.com>
Repin TLSN to its ownership-only integration commit and resolve the KOS domain-separation patch from libid-org/mpz at the same qualified commit.

Assisted-by: GPT-5
Signed-off-by: Wondertan <hlibwondertan@gmail.com>
Pin the minimal alpha.6-compatible executor lifetime backport so early HTTP failures do not cancel active TLSN protocol tasks.

Assisted-by: GPT-5
Signed-off-by: Wondertan <hlibwondertan@gmail.com>
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.

Return attestations on the originating notarization connection

2 participants