emp-ot

August 3, 2026 · View on GitHub

build CodeQL

Which version do I want?

  • Existing projects pinned to a published release: stay on 0.3.0 — tag 0.3.0 or branch v0.3.x. Bug fixes and security patches will be backported to v0.3.x.
  • New projects, or willing to migrate: use v1.0.0-alpha.1 — tag v1.0.0-alpha.1, the first alpha of the 1.0 line. New SoftSpoken butterfly kernel with rounds 0-2 fused into AES generation at k=8 (eight leaves' Davies-Meyer outputs folded through three halving levels in-register; cross-platform via emp-tool's AesLane abstraction over VAES-512 / VAES-256 / AES-NI / NEON), post-quantum BMM base OT, reorganized base OTs and extensions, the wire-equivalence framework from emp-tool. The API may change between alpha tags and before the final 1.0.0, so pin to a tag. Pairs with emp-tool v1.0.0-alpha.1 (the CMake package floor is 1.0; see the Security section below for scope and limitations).

State-of-the-art OT implementations on top of emp-tool: four ordinary base OTs (CO, PVW, CSW, BMM), IKNP and SoftSpoken OT extensions (semi-honest + malicious), the Ferret and SilentFerret silent COT extensions, and the F2kVOLE / FpVOLE subfield-VOLE generators built on top of Ferret. The chosen-input COT/ROT/OT conversion wrappers hash with MITCCRH for optimal concrete security; base OTs derive keys from a random oracle (RO), BMM additionally uses SHAKE-256 in its ML-KEM core, and the Fiat-Shamir transcript defaults to SHA-256.

Requirements

  • CMake ≥ 3.25 (emp-tool's floor; emp-ot alone configures with ≥ 3.21)
  • A C++20 compiler (GCC ≥ 11, Clang ≥ 14, AppleClang 14+)
  • emp-tool ≥ 1.0
  • OpenSSL ≥ 3.0. The lattice OT ML-KEM SHAKE shim uses EVP_DigestSqueeze on ≥ 3.3 (one-shot squeeze) and falls back to a re-init / re-absorb loop on 3.0.x — same answers, slightly slower CRS expansion. No build-time configuration needed; the version is detected from OPENSSL_VERSION_NUMBER.
  • pthreads

emp-ot builds a small static library (emp-ot::emp-ot) that compiles the IKNP, SoftSpoken, Ferret, and SilentFerret bodies plus the BMM lattice base-OT body (with its vendored ML-KEM/Kyber sources); the header-only base OTs (CO, PVW, CSW) and the sVOLE layer are consumed inline.

Build and install

emp-ot consumes emp-tool through its installed CMake package. Install emp-tool first, then build emp-ot the same way:

# emp-tool
git clone https://github.com/emp-toolkit/emp-tool.git
cmake -S emp-tool -B emp-tool/build -DCMAKE_BUILD_TYPE=Release
cmake --build emp-tool/build -j
cmake --install emp-tool/build       # respects CMAKE_INSTALL_PREFIX

# emp-ot
git clone https://github.com/emp-toolkit/emp-ot.git
cmake -S emp-ot -B emp-ot/build -DCMAKE_BUILD_TYPE=Release
cmake --build emp-ot/build -j
cmake --install emp-ot/build

If you don't want to install emp-tool, point emp-ot directly at its build tree:

cmake -S emp-ot -B emp-ot/build \
    -DCMAKE_BUILD_TYPE=Release \
    -Demp-tool_DIR=/abs/path/to/emp-tool/build

Omitting -DCMAKE_BUILD_TYPE defaults to Release.

Release builds self-tune: the first top-level Release build in a build directory runs a one-time sweep of machine-local scheduling knobs (AES tile widths, LPN batching — never anything protocol- or security-relevant) and compiles with the winners; every later build reuses the recorded result and is deterministic. The result lives in the build directory (<build>/tuning-include/emp-ot/tuning_local.h, provenance-stamped); install ships the file the library was compiled with. Tuning changes neither outputs nor wire bytes (enforced by test_tuning_invariance and the trace_hash_baseline CI gate below), so differently-tuned parties interoperate freely. -DEMP_OT_AUTO_TUNE=OFF disables the automatic sweep; the tune target sweeps explicitly and tune-clean restores the shipped defaults, per build directory. Design and methodology: docs/performance-tuning.md.

CMake options

OptionDefaultEffect
EMP_OT_BUILD_TESTSON when top-levelBuild the test suite under test/.
EMP_OT_INSTALLON when top-levelGenerate install + export rules.
EMP_OT_AUTO_TUNEON for top-level native ReleaseOne-time per-build-directory tuning sweep before the first build (see above).

Consuming from another CMake project

find_package(emp-ot CONFIG REQUIRED)
target_link_libraries(my-app PRIVATE emp-ot::emp-ot)

emp-ot::emp-ot is a static library target that pulls in emp-tool::emp-tool transitively, so consumers don't need to find emp-tool separately.

Tests

cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build -j
ctest --test-dir build --output-on-failure

The two-party benches (bench_base_ot, bench_iknp, bench_softspoken, bench_ferret, bench_silentferret, bench_silentferret_sink, bench_ot_extension, bench_f2k_vole, bench_fp_vole) launch ALICE/BOB on localhost via the run script. bench_lpn and bench_cggm are single-process benchmarks of internal kernels.

Wire-trace hashes

test/trace_hash.cpp runs the covered protocols under a sibling channel off one anchor NetIO with Fiat-Shamir enabled and prints one SHA-256 digest per direction per protocol. Each protocol is measured from a reset deterministic seed state, so the digests are order-independent: adding or reordering a protocol changes only its own row. Refactors that leave the wire bytes untouched leave the hashes untouched; refactors that do change the wire are visible as a single-line diff. The trace_hash_baseline ctest (Release) runs this deterministically (EMP_TEST_MODE=1) and diffs the checked-in test/trace_hash.baseline, so a wire-format change fails CI. See docs/wire-trace-hashes.md for the full workflow.

$ EMP_TEST_MODE=1 ./run ./build/trace_hash | grep 'send='

Profiling the SoftSpoken pipeline (off by default)

The SoftSpoken chunk pipeline has optional per-phase wall-clock timers (butterfly / io / derand / transpose / combine; per role — derand exists only on the OT-sender side, so the receiver line reports it as zero). They are disabled by default — a normal build compiles them to nothing and carries no timing code. To enable, build into a separate tree with the flag and run any SoftSpoken driver; a ns/OT breakdown prints to stderr at every session end:

cmake -S . -B build-phases -DCMAKE_BUILD_TYPE=Release \
    -DCMAKE_CXX_FLAGS=-DEMP_BENCH_PHASES
cmake --build build-phases -j
./run ./build-phases/bench_softspoken 24
# stderr: [phases send] ots=...  butterfly=... io=... derand=... transpose=... combine=...  (ns/OT; sum=...)

The instrumented build is wire-identical to the default build (pinned by the trace-hash baseline above); only stderr output differs. See emp-ot/ot_extension/softspoken/ss_bench_phases.h.

Current baseline (ALICE's view; first 16 hex of each direction's SHA-256). A change to any of these hashes means the corresponding protocol's wire format changed — fine if intentional, but flag it clearly in the commit message and update this table. (The fingerprints are Fiat–Shamir transcript digests under the build's FS hash; this table assumes the default sha256 — a stack built with emp-tool's EMP_FS_HASH=blake3 produces a different, internally consistent set.)

Ferret(b11) and SilentFerret(b11) are run over a whole number of SilentFerret rounds, where the two are wire-identical by design (SilentFerret only changes when the correction traffic is sent, not the bytes). Their rows must stay equal in both modes; a divergence means a refactor broke the equivalence, not just one protocol's format.

CO                     send=d698630b93938c23 recv=d9c1fafc2be169a6
CSW                    send=0c2796685cfc7b91 recv=51c6e689429102d6
PVW                    send=49680c637b810195 recv=c5631a7f473a0998
BMM                    send=8355635dc112e02f recv=cfefe9ecf658a4cf
IKNP semi              send=ac441b93d05b591e recv=c861d33efcc306b2
SoftSpoken<2> semi     send=c09818e2edddfcd6 recv=53f9e440f7df41f1
SoftSpoken<8> semi     send=c09818e2edddfcd6 recv=6bda77ca7c0f9037
Ferret(b11) semi       send=1f2d87e222bc509a recv=31f4d61323bea622
SilentFerret(b11) semi send=1f2d87e222bc509a recv=31f4d61323bea622
F2kVOLE semi           send=b3f5ce46fb1ab13d recv=0a7d75d69804b3ca
FpVOLE semi            send=5bdf7f4cab96398a recv=30fa025221220ae8
IKNP mali              send=ac441b93d05b591e recv=98125593b26d3b02
SoftSpoken<2> mali     send=c09818e2edddfcd6 recv=a549bba02e32f2a1
SoftSpoken<8> mali     send=c09818e2edddfcd6 recv=b8690d0258534cc0
Ferret(b11) mali       send=8f7e58077aa1d35f recv=74b34e5043665231
SilentFerret(b11) mali send=8f7e58077aa1d35f recv=74b34e5043665231
F2kVOLE mali           send=639b25364bcf3e8d recv=881af7fc8a3ad07c
FpVOLE mali            send=efdc33076a565743 recv=dc3bd67a79aeeeae

Usage

#include <emp-tool/emp-tool.h>     // NetIO etc.
#include <emp-ot/emp-ot.h>         // OTs
using namespace emp;

int party = parse_party(argv);   // argv[1]; port/IP come from $EMP_PORT / $EMP_PEER_IP
auto io = (party == ALICE) ? NetIO::listen(peer_port())
                           : NetIO::connect(peer_ip(), peer_port());

Interfaces

All OTs in emp-ot derive from a four-layer hierarchy in emp-ot/ot.h and emp-ot/ot_extension/ot_extension.h. Each layer adds methods on top of the previous one; you can always call a lower-level method on a higher-level object. See docs/class-organization.md for the full class layout.

InterfaceNew methodsSemantics added
OTsend(m0, m1, n) / recv(mc, c, n)chosen-input 1-out-of-2: sender supplies both messages, receiver picks one
COT : OTsend_cot(m0, n) / recv_cot(mc, c, n); free send_rot/recv_rotchosen-correlation: sender's two messages always differ by a public Delta
RandomCOT : COTrcot(data, n) (role implicit in instance party)random correlation: no choice-bit input; receiver's choice ends up in getLSB(data[i]), and Delta's LSB is forced to 1 so the correlation survives that one bit
OTExtension : RandomCOTset_delta(const bool* delta_bool) (sender-only Δ override), chunk_size(), begin([n_ots])/next/check/end, next_n(data, n), run(data, num)streaming RCOT (one fixed-size chunk per next); counted begin bounds total demand and lets SoftSpoken shorten its terminal unit; check certifies a mid-session prefix without dropping the buffered tail; base-OT bootstrap fires lazily on the first begin

Concrete classes attach as follows:

  • Base OTs (OT only): CO, PVW, CSW, BMM.
  • OT extensions (OTExtension, so all of the above): IKNP, SoftSpoken<k>, and Ferret.

The chosen-input / chosen-correlation / random conversions are implemented once in the base classes (one MITCCRH pass per OT for the chosen-message wrapper, one bit per OT for the random → chosen correction), so picking a backend never forces you to also pick a flavor.

Base OTs

The four ordinary base OTs (CO, PVW, CSW, BMM) implement only the OT interface — chosen-input 1-out-of-2.

block m0[length], m1[length];   // sender's two messages
block mc[length];               // receiver's chosen message
bool  c [length];               // receiver's choice bits

PVW ot(io.get());
if (party == ALICE) ot.send(m0, m1, length);
else                ot.recv(mc, c, length);    // mc[i] = m_{c[i]}

All four implement the same OT interface. CO is semi-honest; CSW, PVW, BMM are malicious-secure.

OT extensions

IKNP, SoftSpoken<k>, and Ferret all derive from RandomCOT, take the same constructor shape, and the same object exposes all four flavors:

IKNP ote(party, io.get());
// SoftSpoken<2> ote(party, io.get()); Ferret ote(party, io.get()); ... all the same below.
block buf[length];

// rcot() is single-method and role-implicit — the instance's party
// determines whether it acts as sender or receiver internally.
// Each side fills its own `buf`; sender's buf[i] ^ receiver's buf[i] = c[i] · Δ.
ote.rcot(buf, length);

// COT / ROT / OT flavors are role-explicit (different signatures by side):
if (party == ALICE) {
    ote.send_cot(m0, length);                     // COT:  fills m0; m1[i] = m0[i]^Δ
    ote.send_rot(m0, m1, length);                 // ROT:  fills m0, m1 (random)
    ote.send(m0, m1, length);                     // OT:   sends caller-chosen m0, m1
} else {
    ote.recv_cot(mc, c, length);                  // COT:  mc[i] = m0[i] ^ c[i]*Δ
    ote.recv_rot(mc, c, length);                  // ROT:  mc[i] = c[i] ? m1[i] : m0[i]
    ote.recv(mc, c, length);                      // OT:   mc[i] = c[i] ? m1[i] : m0[i]
}

The constructor allocates per-instance state and (on the sender side) samples a random Δ with LSB(Δ) = 1 pinned. No network I/O runs in the ctor. The base-OT bootstrap runs on the first begin() (or the first rcot() one-shot call, which delegates to run()begin() internally).

Δ is readable as ote.Delta immediately after construction. The receiver has no Δ. To override the ctor-sampled Δ (e.g. when an outer protocol like emp-zk or emp-sh2pc supplies its own correlation), call set_delta before the first rcot() call:

IKNP ote(party, io.get());
if (party == ALICE) {
    bool delta_bool[128]; /* fill from outer protocol; delta_bool[0] = true */
    ote.set_delta(delta_bool);
}
// ote.rcot(buf, length); // bootstrap fires here, on first streaming begin

set_delta must fire before the first rcot/begin/run call (rejected otherwise, in every build flavor).

Each extension can be parameterized to bootstrap from a non-default base OT (default is CSW); pair an extension's malicious mode with a malicious-secure base — IKNP / SoftSpoken / Ferret check this at construction time and abort otherwise.

IKNP            ote1(party, io.get(), /*malicious=*/true,
                     std::make_unique<CSW>(io.get()));
SoftSpoken<4>   ote2(party, io.get(), /*malicious=*/true,
                     std::make_unique<CSW>(io.get()));      // k=4
Ferret          ote3(party, io.get(), /*malicious=*/true,
                     std::make_unique<CSW>(io.get()),
                     tuning::ferret_b13);

Ferret's final LPN parameter is optional and defaults to tuning::ferret_b13. By default its public LPN matrix is derived from the transcript session id. An outer protocol that needs identical receiver-choice patterns across several separately domain-separated Ferret instances may call set_lpn_matrix_seed(root) on both roles before bootstrap; the transcript ids remain distinct, and Ferret domain-separates the root by its LPN parameters.

Streaming RCOT

The OTExtension base also exposes a streaming API for callers that want to overlap RCOT production with downstream work (one fixed chunk_size()-sized batch per next() call, no internal buffering):

const int64_t chunk = ote.chunk_size();
BlockVec buf(chunk);

ote.begin();
for (int i = 0; i < n_chunks; ++i) {
    ote.next(buf.data());
    consume_chunk(buf.data(), chunk);  // role implicit in party
}
ote.end();

In malicious mode, ordinary streaming output is provisional until check() or end() completes the covering consistency checks. Do not expose it or make irreversible decisions from it before one of those calls succeeds; the one-shot rcot() path performs that check before returning, and SilentFerret front-loads its checks into begin(n_ots).

For a long-lived session that must reveal or commit intermediate results, use check() as the certification boundary:

ote.begin();
ote.next_n(batch.data(), batch.size());
ote.check();                         // session stays open; buffered tail stays available
reveal_or_commit(batch);
ote.next_n(more.data(), more.size());
ote.check();
reveal_or_commit(more);
ote.end();                           // no empty check exchange if nothing new was generated

The whole backend chunk underlying a partial next_n() draw is included in the check, so its unused suffix is already certified. It is drained after the boundary before the extension generates another chunk. Calling check() after consuming only that suffix is wire-free. The first check also completes CSW's deferred base-OT extraction check. SilentFerret inherits the API, but its consume-time call is intentionally a no-op because begin() already certified the complete prepared range.

The one-shot rcot(data, num) is implemented in terms of this streaming API plus a small leftover buffer for tails that aren't a multiple of chunk_size().

For callers that consume the stream incrementally — even one COT at a time — draw from a single long-lived session with next_n(dst, n) instead of calling rcot repeatedly:

ote.begin();                       // open one session (e.g. in your ctor)
ote.next_n(&one, 1);               // draw any count; refills a chunk internally
ote.next_n(batch.data(), k);       // …amortizes the per-round end-work
ote.end();                         // close it (e.g. in your dtor)

This matters because rcot(data, num) opens and closes a session per call, so the per-round end-work (refill trees + the malicious chi-fold check) is paid every chunk_size() COTs. next_n keeps one session open, amortizing that over the whole stream. In a malicious b13 Ferret benchmark matching emp-zk's one-COT-per-AND-gate consumption shape, 1,048,576 calls were 36.9× faster (slower-party median of 5) and used 45.2× less wire this way than with rcot(_, 1) in a loop. next_n is mutually exclusive with run()/rcot() on the same instance (both touch the same leftover buffer).

See docs/streaming-api.md for the full lifecycle contract.

SilentFerret

SilentFerret is a drop-in Ferret subclass that can front-load all of its wire traffic. Where Ferret interleaves the MPCOT/LPN correction messages (and, in malicious mode, the chi-fold check) with production, SilentFerret::begin(n_ots) concentrates every byte — and every malicious check — into the initial call, leaving next() / next_n() completely wire-free for both roles. The cGGM correction stream is identical to Ferret's. The no-argument path prepays one round at a time and, over whole rounds, remains byte-identical overall, as pinned by the trace_hash baseline above. For a multi-round malicious begin(n_ots), SilentFerret folds the per-round consistency checks into one batch check, so its total transcript is slightly smaller even though the corrections are unchanged.

Prepay a known number of COTs up front, then draw them with no communication during the online phase:

SilentFerret ote(party, io.get(), /*malicious=*/true);  // default param; n_threads = 1
ote.begin(n_ots);                  // ALL correction traffic + checks happen here
BlockVec buf(ote.chunk_size());
for (int i = 0; i < n_chunks; ++i)
    ote.next(buf.data());          // wire-free: no bytes either way
ote.end();

The no-arg begin() prepays a single round (K = 1); rollover past it is live. The constructor's last argument n_threads sizes the begin()-time expansion pool (<= 1 runs serially), parallelizing the bursty setup. The trade is a heavier, bursty begin() for a silent online phase — useful when the interactive section of a protocol should move no OT bytes.

Consumers that need only the XOR of a current-round tree range can avoid a full output buffer. Both variants use one chunk_size() scratch buffer and leave the stream cursor unchanged; concurrent ranges require one scratch buffer per caller:

BlockVec scratch(ote.chunk_size());
block sum = ote.produce_range_fused_xor(scratch.data(), tree_begin, n_trees);

produce_range_scratch_xor() materializes and folds one tree at a time; produce_range_fused_xor() sends each finalized LPN value directly to the XOR accumulator and skips the final store. Both reproduce the XOR of the corresponding produce_range() output and remain wire-free after begin().

Performance

Two AWS m8a.8xlarge (AMD EPYC 9R45, Zen 5) in a cluster placement group, us-east-1, Ubuntu 22.04, GCC 11.4, OpenSSL 3.0.2, -march=native. The two parties run on separate instances over the AWS private network; the link measured 9.50–9.53 Gbps single-flow / 0.10–0.11 ms RTT (iperf3 and ping archived with the results). Release build with one final-source auto-tuner result frozen across both hosts; bench output buffers are pre-faulted.

Base OTs

One batch of 128 base OTs (slower party's wall-clock, median of 3 runs; send/recv bytes are deterministic).

ProtocolTimeSend BRecv BSecurity
CO10 ms4,1658,832semi-honest
CSW9.4 ms6,2298,864malicious-secure (CDH + RO)
PVW40 ms39,42417,664malicious-secure (DDH messy mode)
BMM11 ms196,608106,496malicious-secure, post-quantum (over ML-KEM-512 internals)

OT extensions (RCOT throughput)

Length 30,015,488 OTs (exactly two b13 rounds; ~30M), single-threaded (one thread per party; SilentFerret's begin() expansion pool disabled). MOT/s is the median of 9 runs, the slower of the two parties per run; bits/RCOT (total wire bytes, both directions — deterministic) includes the one-time base-OT bootstrap amortised over the length. Observed: the IKNP and SoftSpoken<2> rows, plus semi-honest SoftSpoken<4>, ran at 91–98% of the measured link rate; the remaining rows were CPU-bound. Analysis of what binds each configuration is in docs/performance-tuning.md.

ProtocolModebits/RCOTMOT/s
IKNPsemi12773
IKNPmalicious12773
SoftSpoken<2>semi63144
SoftSpoken<2>malicious63137
SoftSpoken<4>semi31280
SoftSpoken<4>malicious31195
SoftSpoken<8>semi1575
SoftSpoken<8>malicious1570
Ferretsemi0.28124
Ferretmalicious0.28111
SilentFerretsemi0.28116
SilentFerretmalicious0.28103

IKNP and SoftSpoken<k> traffic is one-direction (127 and 128/k − 1 bits/RCOT). The Ferret and SilentFerret rows use the default ferret_b13 parameter set and are measured over two complete rounds. Their correction traffic is identical, and their semi-honest total traffic matches exactly. Malicious SilentFerret batches the two per-round checks into one, saving 48 bytes total (about 0.000013 bits/RCOT), so both still round to 0.28 bits/RCOT. The no-argument, one-round-at-a-time wire equivalence remains pinned by the trace table above. ferret_b11 measured 124 MOT/s at 0.81 bits/RCOT on the same setup. The measured SilentFerret rows' next() calls are wire-free (all traffic is in begin(n_ots)).

COT, ROT, OT flavors layer one MITCCRH pass (and, for chosen-input OT, one block per OT on the wire) on top of RCOT.

Security

  • Research software. This line has been reviewed by its maintainer and by automated tooling (ASan/UBSan CI legs, CodeQL, always-on expecting contract checks, the trace_hash_baseline wire-format gate, and the test suite); it has not had an independent security audit.
  • FpVOLE malicious mode does not provide 128-bit soundness. The field is p = 2⁶¹ − 1, so single-field consistency checks are bounded near 2⁻⁶¹ and the bootstrap's polynomial check near 2⁻⁴⁵ at the shipped size; the 61-bit Δ caps key-guessing near 2⁻⁶¹. F2kVOLE works over GF(2¹²⁸).
  • The malicious-mode OT extensions realize selective-abort-style functionalities: an adversary may make selective guesses that the consistency checks catch with overwhelming probability, and streaming outputs are provisional until check() or end() completes the covering checks (see the Streaming RCOT section).
  • BMM is a custom OT construction using ML-KEM-512 (Kyber) parameters and arithmetic (vendored reference sources); it is not standardized FIPS-203 ML-KEM used as a black box, and "post-quantum" refers to the Module-LWE hardness of that construction, not an end-to-end PQ composition claim.
  • No systematic constant-time guarantee: base-OT elliptic-curve ops call OpenSSL and the symmetric kernels are written for throughput; secret-dependent timing has not been audited across the tree.
  • Report vulnerabilities to wangxiao1254@gmail.com.

Acknowledgement, Reference, and Questions

License

Licensed under the Apache License, Version 2.0 — see LICENSE. Vendored third-party code (pq-crystals/Kyber) is noted in THIRD_PARTY_NOTICES.md.