Skip to content

refactor(kos)!: alternative derivation, API and test cleanup for the instance salt - #1

Merged
xgreenx merged 1 commit into
libid/kos-domain-separation-alpha6from
libid/kos-instance-streams
Sep 14, 2026
Merged

refactor(kos)!: alternative derivation, API and test cleanup for the instance salt#1
xgreenx merged 1 commit into
libid/kos-domain-separation-alpha6from
libid/kos-instance-streams

Conversation

@xgreenx

@xgreenx xgreenx commented Sep 9, 2026

Copy link
Copy Markdown

Summary

An alternative implementation of the per-instance domain separation added in c0379ef (the
mirror 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 is
blocked by it — I verified that against c0379ef itself, unmodified. This PR does not fix a
security hole in c0379ef; it swaps the derivation for one that needs a weaker assumption and
preserves 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 TLSNotary
fork currently patch to.


Why the mitigation is needed

Worth writing down, because the PoC that motivated c0379ef does not establish it — that one
computes a.sender_keys[j] ^ b.sender_keys[j], a function purely of the honest sender's private
output, 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 SharedRCOT barrier deadlock (reproduces in ~4% of
MPC 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 the
AES-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 delta bits from pairs the
counterparty supplies (crates/ot/src/kos/sender.rs:122-130). A malicious chou_orlandi base-OT
sender computes both branch plaintexts (crates/ot-core/src/chou_orlandi/sender.rs:126-131), so
setting m0 == m1 == s_j delivers s_j whatever the honest party's delta bit is — the attacker
never needs delta. Replaying those seeds into a second instance is free; nothing binds a base-OT
transfer 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' MaskedCorrelation vectors:

masked_A[i] + masked_B[i] = (zero_A - one_A + a_A) + (zero_B - one_B + a_B)
                          = a_A + a_B          for every i

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 k0 and k1 of one pair" objection does not save it. That
objection 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/ReceiverRandomizeRCOTSender
AnySender<Gf2_128>OLEShare::new_ole_sender), against c0379ef unmodified:

instance_id indices leaking a_A + a_B
shared (Block::ZERO, Block::ZERO) 128 / 128
distinct (id(3), id(4), as in deps/prover.rs) 0 / 128

The second row is the point: c0379ef's salt already blocks this. The first row is what the
mitigation 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 are
AES-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-core using only public mpz APIs.
Happy to land it here as a regression test.


Implementation issues in c0379ef

Hygiene and robustness, not soundness of the mechanism.

1. It does not compile. The [[bench]] ot target kept the old arity:

$ cargo check -p mpz-ot-core --all-targets
error[E0061]: this function takes 3 arguments but 2 arguments were supplied
  --> crates/ot-core/benches/ot.rs:62:30

.github/workflows/rust.yml and pre-commit-check.sh both run
cargo clippy --workspace --all-targets --all-features -- -D warnings, which covers benches.
cargo test does not build a harness = false bench, which is why it slipped through.

2. Nothing exercises the parameter. All twelve test call sites pass Block::ZERO; no test
constructs 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::ZERO is the id every test uses and the id both TLSNotary Proxy paths hardcode. Those Proxy
paths 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::Receiver still derives Default — an unguarded second constructor that picks id zero
and skips new's contract. It also builds a receiver with alloc == 0, on which check()
underflows computing unchecked_ts[0].len() - 1.

5. new(config, delta: Block, instance_id: Block) places the secret correlation and the public
separator 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 independent
re-verification — an earlier revision of this section overstated it, and the revision after that
over-retracted it:

  • Against the demonstrated attack (forced identical seeds): no advantage. tccr(i, s) = π(π(s) ⊕ i) ⊕ π(s); for a fixed seed π(s) is fixed, so distinct ids give distinct π-inputs and
    distinct outputs — a permutation argument, probability 1. Verified: 56000 fixed-seed id pairs,
    0 collisions. Both derivations block this attack completely.
  • Against an attacker who chooses different seeds per instance to engineer a collision: a real
    but expensive advantage.
    For tccr, tccr(i₁,s₁) = tccr(i₂,s₂) reduces to D(u) ⊕ D(v) = i₁ ⊕ i₂
    with D(x) = π(x) ⊕ x a public fixed-key function, and s = π⁻¹(u ⊕ i) recovers the seed — a
    generic 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.
  • Wire compatibility for single-instance callers. set_stream_id(0) is a no-op, so
    InstanceId::SOLO reproduces stock KOS byte for byte (verified: 12305 rows, identical keys and
    messages). The salt perturbs every derivation, so it breaks interop even for callers with no
    shared delta to defend — including both TLSNotary Proxy paths, which build exactly one KOS
    instance per party. This is the concrete, cheap-to-value difference.
  • A weaker assumption. Only the PRP assumption the PRG already rests on, rather than reading
    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's tccr unchanged. Say the word and I will do that instead.

Plus: InstanceId newtype (no Default, so two instances cannot both default into collision) and
an InstanceIds allocator; Default dropped from kos::Receiver; the bench fixed; a CHANGELOG
entry for the breaking change.


How to use it

One instance per delta — the stock case, byte-identical to pre-c0379ef KOS:

let sender = kos::Sender::new(cfg, delta, InstanceId::SOLO, base_ot);
let receiver = kos::Receiver::new(cfg, InstanceId::SOLO, base_ot);

Several instances sharing one delta — every instance needs a distinct id, and the two
parties of one instance need the same id. Ids match by value, not construction order, so a fixed
wiring is usually clearest:

// prover                                   // verifier
new_send(InstanceId::new(0));  // garbler   new_recv(InstanceId::new(0));
new_recv(InstanceId::new(1));  // zk        new_send(InstanceId::new(1));
new_send(InstanceId::new(2));  // ke        new_recv(InstanceId::new(2));

Where the wiring is dynamic, allocate — both sides must request in the same order:

let mut ids = InstanceIds::new();
let a = ids.allocate();   // InstanceId::SOLO
let b = ids.allocate();   // distinct, by construction

Rules. Distinct id per instance sharing a delta; same id on both sides of one instance;
SOLO only when that delta drives exactly one instance. There is deliberately no Default — two
instances that both defaulted is precisely the failure the type prevents.


Verification

  • cargo test -p mpz-ot-core --all-targets — 32 passed
  • cargo test -p mpz-ot --all-targets — 12 passed (benches/ferret_receiver.rs panics with
    "single replay mux only supports root ID"; pre-existing, reproduced on an untouched
    c0379ef checkout)
  • cargo clippy -p mpz-ot-core -p mpz-ot --all-targets --all-features -- -D warnings — clean
  • cargo +nightly fmt --all -- --check — clean for every file this PR touches
    (crates/fields/src/gf2_*/wasm.rs carry pre-existing drift)

New tests: the leak reproduced against undifferentiated instances, distinct ids defeating it across
every pair, SOLO pinned to the stock derivation, and separation holding even when the base OT
hands both instances identical seeds.


Upstream

The two derivations are wire-incompatible: a peer on tccr and a peer on stream ids will not
interoperate. 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/mpz and the repeated
24-crate [patch] block in the notary.

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>
@xgreenx xgreenx changed the title refactor(kos)!: separate instances by PRG stream, not by salted seed refactor(kos)!: alternative derivation, API and test cleanup for the instance salt Sep 10, 2026
@xgreenx xgreenx self-assigned this Sep 14, 2026
@xgreenx
xgreenx merged commit 4db9454 into libid/kos-domain-separation-alpha6 Sep 14, 2026
1 check passed
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>
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.

1 participant