refactor(kos)!: alternative derivation, API and test cleanup for the instance salt - #1
Merged
xgreenx merged 1 commit intoSep 14, 2026
Conversation
c0379ef gave `kos::Sender`/`Receiver` an `instance_id: Block` and mixed it into the base-OT-derived PRG seeds with `FIXED_KEY_AES.tccr(instance_id, seed)`. The goal is right -- KOS15 is analysed as one extension per global `delta`, and tlsnotary/tlsn#1173 now runs six under one -- but the derivation, the type and the coverage are not. Separate by PRG stream instead. `Prg` already documents this exact capability ("a stream ID, which can be used to generate distinct streams using the same seed"): the stream id enters the AES-CTR input block, so distinct instances address disjoint counter blocks and their columns cannot coincide, whatever the base OT produced. Three consequences: * Separation is structural rather than probabilistic. Salting the seed leaves a ~2^64 birthday bound on collapsing two instances onto one stream -- and the receiver chooses the base-OT seeds, so it is the party that would search for that collision. Disjoint counter blocks admit no collision at all. * It needs only the PRP assumption the PRG already rests on, instead of reading fixed-key AES as a tweakable correlation-robust hash under a public, low-entropy, adversary-known tweak -- outside the notion 2019/074 proves. * `set_stream_id(0)` is a no-op, so `InstanceId::SOLO` reproduces stock KOS byte for byte. The salt perturbed every derivation, breaking the wire for single-instance callers that have no shared `delta` to defend. Make the id a type rather than a second `Block`. `new(config, delta: Block, instance_id: Block)` let a caller transpose the secret correlation and the public separator with no diagnostic. `InstanceId` has no `Default`, because two instances that both defaulted are precisely the failure it prevents; `InstanceIds` hands out distinct ids where a fixed wiring is not clearer. Drop `Default` from `kos::Receiver` for the same reason: it was an unguarded second constructor that silently chose id zero and skipped `new`'s contract. (It also built a receiver with `alloc == 0`, on which `check()` underflows.) Cover the invariant. c0379ef passes `Block::ZERO` at all twelve test sites, so nothing exercised what the parameter is for. These tests reproduce the leak against undifferentiated instances, show distinct ids defeat it across every pair, and pin `SOLO` to the stock derivation. Fix the `ot` bench, left at the old arity, which broke `cargo clippy --all-targets` -- the repo's own CI and pre-commit command. Assisted-by: Claude Opus 5 Signed-off-by: xgreenx <xgreenx9999@gmail.com>
This was referenced Sep 14, 2026
xgreenx
added a commit
to libid-org/notary
that referenced
this pull request
Sep 14, 2026
libid-org/mpz#1 merged into `libid/kos-domain-separation-alpha6` (4db9454): a typed KOS `InstanceId` in place of a bare `Block`, instances derived by PRG stream id so a solo instance is byte-identical to stock KOS, `Default` removed from `kos::Receiver`, and the bench, tests and CHANGELOG entry mpz#446 shipped without. libid-org/tlsn#3 (dc31702) ports the TLSNotary fork to that API on top of `integration/notary-alpha15`. Move both pins together: the TLSN patch and the three SDK test-client dev-dependencies to dc31702, and all 24 MPZ patch entries from 1dd2349 (mpz#2 only) to 4db9454 (mpz#2 + mpz#1). Cargo.lock resolves exactly one TLSN source, which is what scripts/build-tlsn-wasm.sh requires, so the browser prover is rebuilt from the same revision. Wire note: c0379ef's zero-id salt perturbed the Proxy derivation; the stream-id rework does not, so a browser WASM published from 94aaaf3 will not interoperate with a notary on this pin. Server and browser artifacts ship together. Verified: `cargo +nightly fmt --all -- --check`; `cargo clippy --all-targets --all-features -- -D warnings`; `cargo test --all` (15 passed, including the Proxy-WebSocket and MPC recovered-socket end-to-end checks); `bash -n scripts/build-tlsn-wasm.sh`. Assisted-by: Claude Fable 5.1 Signed-off-by: xgreenx <xgreenx9999@gmail.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
An alternative implementation of the per-instance domain separation added in
c0379ef(themirror of privacy-ethereum/mpz#446), plus
API and test cleanup for it, plus a stronger explanation of the vulnerability it mitigates.
To be clear about what this is not:
c0379ef's mitigation works. The attack described below isblocked by it — I verified that against
c0379efitself, unmodified. This PR does not fix asecurity hole in
c0379ef; it swaps the derivation for one that needs a weaker assumption andpreserves wire compatibility for single-instance callers, fixes a build break and the absent test
coverage, and makes the invariant harder to violate by accident.
Base:
libid/kos-domain-separation-alpha6(c0379ef), the revision the notary and the TLSNotaryfork currently patch to.
Why the mitigation is needed
Worth writing down, because the PoC that motivated
c0379efdoes not establish it — that onecomputes
a.sender_keys[j] ^ b.sender_keys[j], a function purely of the honest sender's privateoutput, which no adversary holds. Its author said as much on tlsnotary/tlsn#1173: "I've been
trying to see how a malicious party actually accesses the data it needs, and I can't." The
maintainer's scepticism there is reasonable on that evidence.
Here is a version using only what a malicious counterparty legitimately holds.
Setting. tlsnotary/tlsn#1173 fixes a real
SharedRCOTbarrier deadlock (reproduces in ~4% ofMPC runs) by giving each consumer its own KOS instance. The prover then runs four KOS senders
under one global garbling
delta(crates/tlsn/src/deps/prover.rs), of which id(3) is theAES-GCM encrypter and id(4) the decrypter, each wrapped
RandomizeRCOTSender → AnySender<Gf2_128> → OLESender(crates/mpc-tls/src/leader.rs:85,92).Step 1 — force the collapse. A KOS extension sender contributes no entropy to its own PRG
seeds: it acts as base-OT receiver, selecting per column by its
deltabits from pairs thecounterparty supplies (
crates/ot/src/kos/sender.rs:122-130). A malicious chou_orlandi base-OTsender computes both branch plaintexts (
crates/ot-core/src/chou_orlandi/sender.rs:126-131), sosetting
m0 == m1 == s_jdeliverss_jwhatever the honest party's delta bit is — the attackernever needs
delta. Replaying those seeds into a second instance is free; nothing binds a base-OTtransfer to a KOS instance.
Undifferentiated, both instances then derive identical extension columns, and the KOS consistency
check still passes — undetected, and after the base OT entirely passive.
Step 2 — cancel. At every index either the two instances' choice bits are equal (the
(k0,k1)pairs are identical) or opposite (the pairs are swapped). Both branches cancel in GF(2¹²⁸), so
for the two OLE senders'
MaskedCorrelationvectors:The counterparty XORs two wire messages position-wise and recovers the XOR of the prover's two
private OLE inputs. No
delta, and no second message of any key pair — which is why the natural"a COT receiver can never hold both
k0andk1of one pair" objection does not save it. Thatobjection is true, and it is what makes the collapse look harmless on first analysis; holding both
was never required.
Measured, driving the real chain end to end (
kos::Sender/Receiver→RandomizeRCOTSender→AnySender<Gf2_128>→OLEShare::new_ole_sender), againstc0379efunmodified:instance_ida_A + a_BBlock::ZERO,Block::ZERO)id(3),id(4), as indeps/prover.rs)The second row is the point:
c0379ef's salt already blocks this. The first row is what themitigation is for, and what happens if a caller passes the same id twice.
Those inputs feed the encrypter's and decrypter's share conversion inside
MpcAesGcm, so they areAES-GCM key material. I have not traced precisely which value, so treat the blast radius as
unestablished — but a relation between two private inputs leaking to the counterparty is
unambiguously a break of the intended security.
Reproduction available on request; ~130 lines against
mpz-ole-coreusing only public mpz APIs.Happy to land it here as a regression test.
Implementation issues in
c0379efHygiene and robustness, not soundness of the mechanism.
1. It does not compile. The
[[bench]] ottarget kept the old arity:.github/workflows/rust.ymlandpre-commit-check.shboth runcargo clippy --workspace --all-targets --all-features -- -D warnings, which covers benches.cargo testdoes not build aharness = falsebench, which is why it slipped through.2. Nothing exercises the parameter. All twelve test call sites pass
Block::ZERO; no testconstructs two instances with different ids, so the suite never demonstrates the mitigation works.
3. Violating the invariant is silent — and reproduces the leak in full (row 1 above).
Block::ZEROis the id every test uses and the id both TLSNotary Proxy paths hardcode. Those Proxypaths are correct, since each builds exactly one KOS instance per party, but the value is doing
double duty as "instance 0" and "don't care".
4.
kos::Receiverstill derivesDefault— an unguarded second constructor that picks id zeroand skips
new's contract. It also builds a receiver withalloc == 0, on whichcheck()underflows computing
unchecked_ts[0].len() - 1.5.
new(config, delta: Block, instance_id: Block)places the secret correlation and the publicseparator adjacent with the same type. Transposing them compiles silently.
The alternative derivation, and why
Precisely what the stream-id derivation buys over
tccr(instance_id, seed), after independentre-verification — an earlier revision of this section overstated it, and the revision after that
over-retracted it:
tccr(i, s) = π(π(s) ⊕ i) ⊕ π(s); for a fixed seedπ(s)is fixed, so distinct ids give distinct π-inputs anddistinct outputs — a permutation argument, probability 1. Verified: 56000 fixed-seed id pairs,
0 collisions. Both derivations block this attack completely.
but expensive advantage. For tccr,
tccr(i₁,s₁) = tccr(i₂,s₂)reduces toD(u) ⊕ D(v) = i₁ ⊕ i₂with
D(x) = π(x) ⊕ xa public fixed-key function, ands = π⁻¹(u ⊕ i)recovers the seed — ageneric offline birthday search (reproduced on a 36-bit window at the expected ~2¹⁷ rate, no
structural obstruction), so ~2⁶⁴ per column, ~2⁶⁸ with a shared table across 128 columns × 2
seeds. One full derived-seed collision keys both PRGs identically and collapses the whole column.
With stream ids, different seeds are different AES keys: a block-0 birthday hit is equally cheap
but does not extend to block 1, so collapsing an L-block column needs a joint collision on 128·L
bits — ≥ 2¹²⁸ for L ≥ 2.
set_stream_id(0)is a no-op, soInstanceId::SOLOreproduces stock KOS byte for byte (verified: 12305 rows, identical keys andmessages). The salt perturbs every derivation, so it breaks interop even for callers with no
shared
deltato defend — including both TLSNotary Proxy paths, which build exactly one KOSinstance per party. This is the concrete, cheap-to-value difference.
fixed-key AES as a tweakable correlation-robust hash under a public, low-entropy,
adversary-known tweak — outside the notion eprint 2019/074 §7.4 proves.
So: a margin improvement (2⁶⁴–2⁶⁸ → ≥2¹²⁸ on one attacker capability) plus solo wire compatibility.
Not a fix for the mitigation, which works. Not verified by anyone: whether a partial column
coincidence (single block or known offset) is exploitable by the delta-leak composition; both
designs and this analysis assume the attack needs full column equality.
If that does not seem worth a divergent derivation, the API and test fixes below stand on their own
and could be rebased onto
c0379ef'stccrunchanged. Say the word and I will do that instead.Plus:
InstanceIdnewtype (noDefault, so two instances cannot both default into collision) andan
InstanceIdsallocator;Defaultdropped fromkos::Receiver; the bench fixed; a CHANGELOGentry for the breaking change.
How to use it
One instance per
delta— the stock case, byte-identical to pre-c0379efKOS:Several instances sharing one
delta— every instance needs a distinct id, and the twoparties of one instance need the same id. Ids match by value, not construction order, so a fixed
wiring is usually clearest:
Where the wiring is dynamic, allocate — both sides must request in the same order:
Rules. Distinct id per instance sharing a
delta; same id on both sides of one instance;SOLOonly when thatdeltadrives exactly one instance. There is deliberately noDefault— twoinstances that both defaulted is precisely the failure the type prevents.
Verification
cargo test -p mpz-ot-core --all-targets— 32 passedcargo test -p mpz-ot --all-targets— 12 passed (benches/ferret_receiver.rspanics with"single replay mux only supports root ID"; pre-existing, reproduced on an untouchedc0379efcheckout)cargo clippy -p mpz-ot-core -p mpz-ot --all-targets --all-features -- -D warnings— cleancargo +nightly fmt --all -- --check— clean for every file this PR touches(
crates/fields/src/gf2_*/wasm.rscarry pre-existing drift)New tests: the leak reproduced against undifferentiated instances, distinct ids defeating it across
every pair,
SOLOpinned to the stock derivation, and separation holding even when the base OThands both instances identical seeds.
Upstream
The two derivations are wire-incompatible: a peer on
tccrand a peer on stream ids will notinteroperate. Both the notary and the browser build from the same pin, so that is controlled today,
but it argues for taking this derivation to ethereum#446 rather than carrying a divergent fork — so the
fork converges on upstream instead of drifting.
Independently of which derivation wins, the attack above is the missing justification for ethereum#446, and
a ~4% deadlock reproduction is available for tlsnotary/tlsn#1173, which has been asked for since
2026-09-01. Merging both upstream is what removes the need for
libid-org/mpzand the repeated24-crate
[patch]block in the notary.