Streaming API
August 3, 2026 · View on GitHub
Every OT-extension and sVOLE-extension instance is a streaming producer. Callers pick one of three access styles:
- a fixed-size batch at a time, owning the session (
begin→next*→end); - an arbitrary-size incremental draw within a session they own
(
begin→next_n*→end) — refills a chunk internally; - hand the whole thing to the library as a one-shot (
run(data, num)), which opens and closes a session per call.
This doc covers the lifecycle, the leftover buffer, next_n, the
OT-extension check() boundary, auto-rollover, and the Fiat-Shamir hooks.
The base lives in
emp-ot/common/streaming_extension.h
as StreamingExtension<Element>.
The contract
Four pure virtuals each subclass implements:
virtual int64_t chunk_size() const = 0;
virtual void begin() = 0;
virtual void next(Element* out) = 0;
virtual void end() = 0;
The base also provides two non-virtual draw entry points:
void run (Element* data, int64_t num); // one-shot: opens+closes a session per call
void next_n(Element* dst, int64_t n); // buffered draw within a caller-owned session
OTExtension additionally exposes virtual void begin(int64_t n_ots). Its
default calls the backend's ordinary virtual begin() and records n_ots as
an upper bound across subsequent next_n() calls. Concrete OT backends retain
the overload with using OTExtension::begin. It is virtual because
SilentFerret keeps its stronger existing meaning: prepay enough complete
Ferret rounds before returning. That override preserves its prior
prepayment-hint contract and may roll live if a caller continues beyond
n_ots. sVOLE exposes only the ordinary streaming lifecycle.
The lifecycle is
begin() ─► next()/next_n() ─► check() ─► next()/next_n() ─► end()
▲ │ │
└──────────────────┘ ▼
session remains open (closed)
check() is an OTExtension API, not a fifth
StreamingExtension<Element> virtual: OT extensions have a public
mid-session certification use case, while the shared sVOLE lifecycle remains
begin/next/end. The concrete OT backends keep implementing the same four
streaming virtuals listed above; OTExtension::check() composes their existing
virtual end() and begin() semantics while the streaming base preserves the
buffer cursor across that internal boundary.
chunk_size() is the unit each next() emits — one cGGM tree's
leaves for Ferret, one batch for IKNP/SoftSpoken, one tree's
LPN-folded outputs for Svole. Constant per instance after setup.
Session tripwire: a single bool on the base. Subclass overrides
call protected helpers from inside begin/next/end:
enter_session_()at the top ofbegin()— uses the always-onemp::expectingcontract to reject a prior session left open, then flips the flag.expect_in_session_()insidenext()and at the top ofend()— an always-on check before the override produces a chunk or performs any protocol I/O.exit_session_()at the bottom ofend()— repeats the always-on active-session check, then clears the flag.
Each helper is one line and the subclass is free to do its real work
around them. A single set of virtuals (begin/next/end) is both
the public API and the override point, and the tripwire is opt-in via
the helpers rather than enforced by the base.
Public boundary operations use emp::expecting, so their contracts
remain active in release builds: double begin, end without begin,
run during an open session, and next_n without an open session are
all rejected. A direct out-of-session call to virtual next() is also
rejected in release builds.
Lazy setup
StreamingExtension doesn't run any setup in its constructor — the
ctor just stores party / malicious / setup_done = false. The first
begin() call is responsible for performing the protocol's bootstrap
and flipping setup_done = true. Every later begin sees
setup_done == true and skips the bootstrap.
This lets outer protocols configure the instance (e.g.
set_delta / set_choice_seed) between construction and the
first session. Preconditions on these configuration APIs use
emp::expecting, so misuse is rejected in release builds too:
- OT-extension
set_deltais sender-only, requires a non-null bit buffer with bit 0 set, and must run before bootstrap. set_choice_seedis receiver-only and must run before bootstrap.- Ferret's
set_lpn_matrix_seedis role-symmetric and must run before bootstrap. It leaves the transcript sid independent while explicitly sharing the public LPN matrix where an outer protocol requires matching choice patterns across instances. - OT-extension and sVOLE
set_sidmust run before bootstrap. - sVOLE
set_deltais Δ-holder-only and pre-bootstrap; readingdelta()is Δ-holder-only. The F2k carrier additionally requires Δ's least-significant bit to be one. Fp instead requires a canonical nonzero field element and samples one automatically when not overridden.
The leftover buffer (run(data, num))
void run(Element *data, int64_t num) {
expecting(!in_session_,
"StreamingExtension::run: active streaming session");
expecting(num >= 0, "StreamingExtension::run: negative element count");
expecting(num <= max_element_count_(),
"StreamingExtension::run: element byte count overflow");
expecting(num == 0 || data != nullptr,
"StreamingExtension::run: null output for nonzero count");
if (num == 0) return;
const int64_t chunk = chunk_size();
validate_chunk_size_(chunk); // positive; byte count fits
int64_t produced = drain_leftover(data, num);
if (produced == num) return;
begin();
while (chunk <= num - produced) { // subtraction avoids overflow
next(data + produced);
produced += chunk;
}
if (produced < num) {
// Tail handling: write one more chunk into a per-instance
// scratch, copy out the prefix the user asked for, save
// the suffix for the next call.
if (leftover_.size() < static_cast<size_t>(chunk))
leftover_.resize(static_cast<size_t>(chunk));
next(leftover_.data());
int64_t take = num - produced;
std::memcpy(data + produced, leftover_.data(),
static_cast<size_t>(take) * sizeof(Element));
leftover_pos_ = take;
leftover_count_ = chunk - take;
}
end();
}
The point: callers can request arbitrary num, not necessarily a
multiple of chunk_size(). The base class buffers the suffix
internally, so the next call to run() first drains that suffix
and then either returns or continues with begin/next loop.
In other words, repeated run(...) calls on the same instance with
num != k·chunk_size() don't pay a fresh chunk per call.
The boundary checks happen before touching the leftover buffer or
starting protocol work. With no session active, a zero count is a true
no-op: data may be null, no leftover is consumed, and chunk_size /
begin / next / end are not called. Negative counts, a null output
for a nonzero count, counts whose num * sizeof(Element) cannot fit in
int64_t, and nonpositive or byte-overflowing chunk sizes are rejected
in every build flavor. run rejects being called during a
caller-owned streaming session even when num == 0.
Incremental draw (next_n(dst, n))
run() is convenient but opens and closes a session per call. For a
backend that consumes the stream a little at a time (emp-zk draws one
COT per AND gate, a few per multiplication), that means paying the
per-round end-work — run_refill_'s refill trees plus the
malicious chi-fold check — every chunk_size() elements, amortized
over a single produced tree instead of a whole round (~hundreds of
trees). That was a ~20× slowdown in emp-zk before this API.
next_n instead draws from one long-lived session the caller owns. It
fills whole chunks straight into dst (no intermediate copy) and only
buffers a sub-chunk remainder, so the per-call copy is bounded by one
chunk regardless of n — same structure as run(), minus begin/end:
void next_n(Element *dst, int64_t n) {
expecting(n >= 0,
"StreamingExtension::next_n: negative element count");
expecting(n <= max_element_count_(),
"StreamingExtension::next_n: element byte count overflow");
expecting(n == 0 || dst != nullptr,
"StreamingExtension::next_n: null output for nonzero count");
expecting(in_session_, "StreamingExtension::next_n: call begin first");
if (n == 0) return;
const int64_t chunk = chunk_size();
validate_chunk_size_(chunk);
int64_t got = drain_leftover(dst, n); // 1. prior partial tail (stream order)
while (chunk <= n - got) { // 2. whole chunks: no copy
next(dst + got);
got += chunk;
}
if (got < n) { // 3. sub-chunk remainder via leftover_
if (leftover_.size() < static_cast<size_t>(chunk))
leftover_.resize(static_cast<size_t>(chunk));
next(leftover_.data());
int64_t take = n - got;
std::memcpy(dst + got, leftover_.data(),
static_cast<size_t>(take) * sizeof(Element));
leftover_pos_ = take; leftover_count_ = chunk - take;
}
}
next_n applies the same signed-count, byte-overflow, null-buffer,
and chunk-size checks as run. It additionally requires an active
caller-owned session even when n == 0; once that requirement is met,
a zero count is a no-op and dst may be null. Each concrete next()
also applies the always-on session expectation when producing a chunk.
Writing whole chunks directly into dst is safe at the (possibly
non-chunk-aligned) offset dst + got because Element-pointer
arithmetic preserves each element's alignment: AuthValueFerret is a
block, and AuthValueFp is alignas(16) (its arithmetic does SIMD
block loads), so any element offset lands on a suitably aligned
element — the same direct-next contract run() relies on after its
own drain_leftover.
Usage — the caller owns the session (typically begin in its ctor, end in its dtor):
ote.begin();
ote.next_n(&one, 1); // any count; refills a chunk internally
ote.next_n(batch.data(), k); // round-end work amortizes over the whole session
ote.end();
When the total demand is known, pass it to begin:
ote.begin(total);
ote.next_n(first.data(), first.size());
ote.check();
ote.next_n(rest.data(), rest.size());
ote.end();
The declared count is an upper bound over all next_n() calls in that
caller-owned session; exceeding it is rejected before producing output, while
ending early is allowed. Both peers must declare the same total: a backend's
sized final unit changes the actual message length, so mismatched declarations
desynchronize the stream rather than acting as independent local hints. Once
the entire remaining declaration fits in one chunk, the base offers it to a
protected sized-tail hook. Existing OT backends' default hook still calls one
full next(), so IKNP, Ferret, and every ordinary no-argument begin() retain
their previous behavior. SoftSpoken
overrides the hook using its existing arbitrary-bs pipeline and rounds only
to its native 128-COT unit: a declared suffix of r COTs produces
128 * ceil(r / 128), not a full public chunk.
The plan counts caller-visible outputs, including values drained from the
leftover buffer, and survives both OTExtension::check()'s internal restart
and Ferret's transparent round rollover. check() also preserves a rounded
SoftSpoken suffix already in the buffer. Direct next() is the explicit
whole-chunk API and is not charged against this next_n() plan.
Notes:
- It shares the same
leftover_buffer asrun(), so the two are mutually exclusive on one instance — pick streaming (next_n/begin/next/end) or one-shot (run/rcot), not both. In particular,run()rejects a call made while the streaming session is open. enter_session_()resetsleftover_count_ = 0and any prior count plan, so a fresh ordinarybegin()never serves a stale tail from a previous session. Internal certification/rollover helpers save and restore both pieces of stream state.- The name is
next_n, not an overload ofnext, on purpose: a subclass'snext(Element*)override would otherwise hide a basenext(Element*, int64_t)overload (C++ name hiding), forcing ausingin every subclass. A distinct name avoids that.
Mid-session certification (OTExtension::check())
Malicious OT-extension outputs are provisional until the backend's consistency check succeeds. A long-lived incremental consumer can establish a safe boundary without discarding its chunk tail:
ote.begin();
ote.next_n(cots.data(), cots.size());
ote.check(); // cots (and the buffered chunk suffix) certified
reveal_or_commit(cots);
ote.next_n(more.data(), more.size()); // first drains that certified suffix
ote.check();
reveal_or_commit(more);
ote.end();
The contract is:
check()requires an active caller-owned session and leaves it active.- It completes every backend consistency check covering chunks generated
since
begin()or the preceding successfulcheck(). On the first call it also completes a deferred CSW base-OT extraction check, even if no user chunk was requested yet. - A sub-chunk
next_n()request generated and folded one whole backend chunk. The unused suffix is therefore covered by the same successful check and stays buffered. It is served before any post-check chunk is generated. - Consuming only this already-certified suffix creates no new unchecked work;
another
check()is wire-free. Tracking changes once per generated backend chunk, never per returned COT or downstream gate. end()immediately aftercheck()(or after consuming only its certified suffix) closes without an empty sacrificial exchange. Ferret restores the empty continuation's buffer swap so a later ordinarybegin()still advances from the correct round base.- An ordinary caller-written
end(); begin();remains a fresh session and discards an oldnext_n()tail. Tail retention is specific tocheck().
The default implementation finishes the concrete backend's current logical
session and starts its next logical session internally. IKNP keeps its row PRG
streams advancing and resets only the malicious fold. SoftSpoken likewise
resets its fold and selects a fresh per-session key. Ferret finishes the
current refill/check and swaps to the certified next-round base. Because these
are the same virtual end()/begin() bodies used by the ordinary lifecycle,
the security check itself is not duplicated in a generic wrapper.
SilentFerret is the deliberate exception in timing, not in security:
begin()/begin(n_ots) authenticates the whole prepared range before any
output is visible. Its consume paths never mark a chunk provisional, so the
inherited check() is an active-session-checked no-op throughout that prepared
range.
The polymorphic entry on OTExtension
StreamingExtension is single-role: each instance, given its
party, runs one side of the protocol. OTExtension inherits the
streaming lifecycle (begin/next/end/run/chunk_size), adds the
mid-session check() boundary described above, and implements
RandomCOT::rcot — the polymorphic one-shot
entry that callers holding a RandomCOT* (chosen-correlation
auto-wrapper inside COT::send_cot/recv_cot, generic-OT consumers in
emp-zk / emp-sh2pc) use without knowing the instance's party:
// begin/next/end / run / chunk_size (StreamingExtension contract — inherited)
// check (OTExtension certification boundary)
// rcot(data, num) (RandomCOT abstract; final on OTExtension)
OTExtension::rcot is a one-line wrapper over the inherited run()
— same leftover-buffer service, just exposed under the
polymorphic-RandomCOT name. Streaming-savvy callers use
begin/next/check/end (or run for one-shot) directly; the role is
implicit in party and no party-assertion is needed.
The dispatch tree inside OTExtension:
rcot ─► run(data, num) ─► begin / loop next / end (leftover-buffer drain)
begin ─┐
next ├─► subclass override (no NVI hook layer)
end │
│ Concrete subclasses (IKNP, SoftSpoken, Ferret) override
│ begin / next / end directly:
│ - IKNP / SoftSpoken: inline party-test → private
│ send_{begin,next,end}_ / recv_{begin,next,end}_ helpers.
│ - Ferret: one unified body per stage (party-dispatch
│ happens inside the per-tree private helpers).
Auto-rollover inside next
The base declares next() as a pure virtual and does no rollover;
the subclass's next() override checks the session with the
always-on expect_in_session_() and implements the rollover. The check
runs once per produced chunk, not once per OT; normal chunks contain
thousands of OTs, so the expected branch is amortized over the batch. The
protocol-specific auto-rollover (calling end+begin transparently when
the round's user-visible budget is full) lives inside the subclass's
next:
// Ferret::next (simplified)
void Ferret::next(block* out) {
expect_in_session_();
const int64_t user_budget_trees = param.t - param.refill_trees;
if (tree_idx_ == user_budget_trees) {
end(); // exit_session_ flips the tripwire
begin(); // enter_session_ flips it back
}
process_one_tree_(reinterpret_cast<AuthValueFerret*>(out));
}
// Svole::next (simplified)
void Svole::next(AuthValue* out) {
expect_in_session_();
const int64_t user_budget_trees = param.t - param.refill_trees;
if (tree_idx_ == user_budget_trees) { end(); begin(); }
process_one_tree_(out);
}
Because end → exit_session_ flips the tripwire false and the
following begin → enter_session_ flips it back true, the rollover
sequence passes the protected helpers cleanly while still landing in
a valid session for the caller's next() that follows.
IKNP and SoftSpoken don't need rollover — they have no notion of
"refill the next round's seed material from this round's tail",
so each next() is just one chunk and end() is explicit.
Fiat-Shamir hooks
IOChannel (in emp-tool) carries two optional per-direction
transcripts — a running send and recv digest held in a single fs_
variant, SHA-256 by default (the hash backend is selected at
enable_fs time) — that absorb every byte sent / received
once enable_fs(send_first) is called. Both must be called by
exactly one party with send_first=true (the other passes false).
Streaming extensions use FS in two ways:
-
Per-protocol chi seeds. Inside a malicious-mode
next/end, the gadget snapshotsio->get_digest()to derive chi vectors. The digest is computed deterministically from the wire bytes both parties have observed, so both parties get the same chi vector without an extra round of communication. -
Diagnostic per-direction digests.
io->get_send_digest()andio->get_recv_digest()return the running digest (SHA-256 by default) of each direction's transcript. Thetrace_hashtest uses these to verify wire-byte equivalence across refactors. Seewire-trace-hashes.md.
Subclasses that need FS for chi seeds enable it lazily — e.g.
Ferret's bootstrap_() does if (!io->fs_enabled()) io->enable_fs(is_ot_sender());.
A protocol may also have FS pre-enabled by an outer harness; the
if (!fs_enabled()) guard makes that a no-op.
Destruction in-session
~StreamingExtension() {
expecting(!in_session_,
"~StreamingExtension: destructed without calling end()");
}
A protocol object destructed in the middle of a session is a bug —
the leftover buffer may hold un-consumed bytes; the peer is waiting
for more. The base catches this with emp::expecting in all build
flavors (not just debug).
This is also the tripwire for a next_n caller that forgets to
end(): since next_n keeps the session open, the owner must close it
(e.g. in its destructor) before the extension is destroyed — end()
must run while the concrete subclass is still alive, so it can't be
called from ~StreamingExtension itself (the override would already be
gone).
What about setup_done?
setup_done is a public-on-the-base bool. Subclasses set it true
inside their lazy bootstrap. Setters that must fire pre-bootstrap
(set_delta, set_choice_seed, Ferret's set_lpn_matrix_seed, and set_sid) enforce
!setup_done with always-on emp::expecting checks. The base does not
check setup_done itself — it remains a contract implemented by each
subclass's bootstrap and configuration surface.
See also
class-organization.md— who inherits from whom; where each carrier and gadget lives.wire-trace-hashes.md— using the FS digests to verify refactors don't change wire bytes.