Skip to content

The shared FFI core (postponed)

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.json pins 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.

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.py by 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.

The exact signatures are for the implementation to fix; this is the shape.

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 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.

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 }

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

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.