The shared FFI core (postponed)
Why a shared core would exist
Section titled “Why a shared core would exist”The client protocol is distributed, stateful and cryptographic, and it states the core danger plainly: “a broken client looks exactly like no matches, which is also the correct output most of the time.” Divergence between clients is the most expensive class of bug in the system, and it is silent.
Today two things guard against it:
vectors/protocol_vectors.jsonpins the four crypto primitives to byte-identical output across languages.- The spec itself describes the state machine every client must reproduce.
That is enough while there is one real client. It stops being enough when there are several hand-written state machines, because the vectors guard only the crypto — not the publish decision (§5.3), set construction (§4.1), the sync command sequence (§5.4), single-flight (§6.3) or backoff (§6.2), which is exactly where the spec says the expensive bugs live.
A shared core removes the possibility of divergence for the parts that matter,
by making every platform run the same compiled bytes. This is the
established pattern for distributed cryptographic protocols with many clients —
Signal’s libsignal and Mozilla’s application-services are both a Rust core
exposed to Swift, Kotlin, TypeScript and others through generated bindings.
What goes in the core, and what does not
Section titled “What goes in the core, and what does not”The design is sans-io / functional-core–imperative-shell. The core is a pure, deterministic engine; every side effect is delegated back to the host.
┌─────────────────────────────── host (per platform) ───────────────────────────────┐ │ secure store · OS contact book · HTTP transport · timers · UI │ └───────────▲───────────────────────────┬─────────────────────────────────────────────┘ │ events (incl. I/O results) │ commands (effects to run) ┌───────────┴───────────────────────────▼─────────────────────────────────────────────┐ │ Rust core (shared, compiled per target) │ │ • crypto primitives (normalize, hash_to_point, generate_key, apply_key) │ │ • set construction (§4.1: normalize, drop own, dedup, sort, cap, digest) │ │ • protocol engine (§5–§7 as a pure state machine: state × event → state, cmds) │ └───────────────────────────────────────────────────────────────────────────────────────┘In the core — everything deterministic and divergence-sensitive:
- The four cryptographic operations, backed by libsodium linked into the core
so the bytes are identical to
protocol.pyby construction, not by re-implementation. - Canonical set construction and the publish digest (§4.1, §5.3).
- The full state machine: publish-or-not decisions, the sync algorithm and its single follow-up fetch, key–set binding checks, index resolution, backoff and single-flight bookkeeping, and the §8 error-to-behaviour mapping.
Not in the core — everything platform-specific or I/O-bound. The core never performs I/O; it only asks the host to, via commands:
- Secure storage of
device_key(Keychain / Keystore / OS credential store). - Reading the OS contact book.
- HTTP transport and auth header injection.
- Wall-clock time and scheduling wake-ups.
- Persisting the client state record.
This split is what makes the core testable against golden traces and identical everywhere: the impure, un-testable, per-platform parts are pushed to the edge.
Interface sketch
Section titled “Interface sketch”The exact signatures are for the implementation to fix; this is the shape.
Crypto surface (stateless, pure)
Section titled “Crypto surface (stateless, pure)”Mirrors §2 of the client protocol exactly. Byte-for-byte equal to
server/src/vouch/protocol.py and client/src/crypto.ts, guarded by the shared
vectors.
| Function | Signature (conceptual) | Notes |
|---|---|---|
normalize |
(raw: string) -> Result<E164, Error> |
strip separators, trim, assert E.164 |
hash_to_point |
(e164: E164) -> Point |
ristretto255_from_hash(SHA-512("vouch/v1" ‖ e164)) |
generate_key |
() -> Scalar |
CSPRNG ristretto255 scalar |
apply_key |
(p: Point, k: Scalar) -> Point |
crypto_scalarmult_ristretto255 |
The protocol engine (pure state machine)
Section titled “The protocol engine (pure state machine)”The engine holds the client state and is driven exclusively by events. It never blocks and never does I/O; it returns a list of commands the host must perform, then feeds the results back as further events.
step(state, event) -> (state', [command])Events the host feeds in (illustrative, not exhaustive):
| Event | Raised when |
|---|---|
Started / SessionRestored |
app launch (trigger 1, §6) |
Foregrounded |
app enters foreground (trigger 2) |
ContactsChanged(list) |
OS contact-book change or rescan |
Tick(now) |
scheduler fired |
SyncNowRequested |
explicit user action |
NetworkRegained |
connectivity restored |
HttpCompleted(id, result) |
a PerformHttp command finished |
StatePersisted(id) / SecretStored(id) |
a storage command finished |
Commands the host must execute and report back:
| Command | Host action |
|---|---|
PerformHttp(request) |
issue the request with the bearer token, return status + body |
PersistState(record) |
atomically write fields 5–8 of §3 |
StoreSecret(device_key) / LoadSecret |
secure-store the key |
ReadContacts |
read the OS contact book |
ScheduleWake(at) |
arm a timer honouring the §6 intervals/backoff/jitter |
EmitMatches(list) |
hand resolved matches to the UI |
SurfaceError(kind) |
user-visible protocol errors (username taken, truncation, dropped contact) |
Because scheduling, single-flight, debounce/floor and backoff are all decided
inside the engine and expressed as ScheduleWake commands, the §6 timing
constants live in the core and cannot be tuned per client — which is precisely
what §6 demands.
Host capability traits
Section titled “Host capability traits”Each platform implements a small set of interfaces; the generated bindings turn these into idiomatic protocols/interfaces in the host language:
trait SecureStore { store(key) ; load() -> Option<key> ; clear() }trait Contacts { read() -> [ {display_name, e164} ] }trait HttpTransport { send(request) -> (status, body) }trait Clock { now() -> Timestamp }Binding strategy per target
Section titled “Binding strategy per target”One Rust crate, three binding generators — no per-platform reimplementation of the protocol:
| Target | Mechanism | Notes |
|---|---|---|
| Swift (iOS/macOS) | UniFFI | generates a Swift package from the Rust interface |
| Kotlin (Android) | UniFFI | same interface definition, JNI under the hood |
| Dart (Flutter) | flutter_rust_bridge | async-friendly Dart bindings |
| Web (JS/TS) | wasm-bindgen / wasm-pack | WASM module; watch bundle size |
| Python (server/tests) | UniFFI or PyO3 | optional — lets the server reuse the same core in tests |
Conformance stays vector-driven
Section titled “Conformance stays vector-driven”A shared core does not replace the conformance vectors — it consumes them. The
crypto primitives keep running protocol_vectors.json. The state machine runs
the golden traces (input state + event → expected commands) that the
Flutter-era work introduces. The difference is that with a shared core there is
one engine passing those traces instead of N, so a passing trace means every
platform passes.
That is also why the postponement is safe: the golden-trace vectors are worth building now regardless, and they are exactly the harness a future core would be validated against. Nothing done for the near-term Flutter client is wasted if this core is later built — the pure-engine structuring of the Dart client (events → commands) makes extraction into Rust mechanical rather than a rewrite.
Related
Section titled “Related”- Client protocol specification — the normative behaviour a core would encode.
- The matching protocol — why the cryptography works.
- Architecture — the current crypto-blind system.