Ferry
2026Open source · Apache-2.0
An open-source peer-to-peer tool for developer teams, Apache-2.0. Every machine runs the same daemon — client and server at once — and a CLI, a VS Code extension and a Tauri desktop app all talk to it over a local socket. Seven Rust crates in one Cargo workspace, with the dependency direction enforced by the crate graph, so breaking it is a build error rather than something a reviewer has to catch. Two daemons run the full pipeline end to end: discovery, pairing, signed rosters, encrypted transfer, delivery.
Role — Sole author — architecture, pre-implementation spec, full Rust implementation
- Rust crates
- 7
- Tests
- 350+ Rust
- Surfaces
- CLI · VS Code · Desktop
- Generated bindings
- 37 types
- Crypto primitives
- 4 audited
- Docker pulls
- 546
- GitHub release downloads
- 9
The constraint that shaped it
There is no trusted third party. Every rule has to be enforced by the machine that cares about it, against cryptographic evidence rather than a server's word. Most architecture work assumes the opposite: a server that enforces a rule once, for everyone.
The architecture
What it looks like
3 screens · click to enlargeThe constraint that shapes everything
There's no trusted third party. A few things follow from that:
An unverified peer can do nothing. Deny-by-default, enforced at the Noise handshake before any frame is parsed as a command — never in the UI. Discovery isn't trust; a hostname on a network is an unauthenticated claim. Pairing needs an out-of-band short code and a verification phrase a human confirms, and it can't be skipped.
Sending is a local write, not a network call. The outbox row is written in the same transaction as the state change, and a dispatcher drains it when the peer is reachable. It's a familiar pattern used differently: usually an outbox exists so a failed side effect can't roll back a write, but here the peer being offline is the normal case, so it's the core abstraction rather than a safety net.
Expiry runs on the receiver's monotonic clock, starting at delivery. Two laptops on a hotspot disagree about the time, and the sender's clock is attacker-controlled. A ten-minute secret that sat queued for twenty minutes still gets its ten readable minutes once it lands.
Crypto is assembled from audited primitives, never designed. age, Noise XK, SPAKE2, ed25519. No novel constructions and no hand-rolled key derivation.
The compiler owns the layering
Dependency direction: daemon/cli → core → net/store/crypto → proto. Never the reverse. core can't import the daemon, an IPC type or anything UI-related, because it has to be drivable from the daemon, the CLI or a test harness with a fake network without changing.
The networking crate can't import the crypto crate, so the roster check arrives as an injected predicate over raw key bytes. Networking enforces it mechanically and never enters transport mode without a pass; the domain layer supplies the actual roster-backed decision. Neither layer can accidentally be changed into trusting the other.
proto holds types, enums and error codes only — no logic. The 37 TypeScript binding types are generated from it with ts-rs rather than hand-transcribed, and CI fails the build on any diff.
Three surfaces, one local daemon
The daemon is the only thing that speaks the peer-to-peer protocol. Everything a human touches — the CLI, a VS Code extension, and a Tauri desktop shell over a 22-component React UI — talks to it over a length-framed local socket, and never to another machine directly.
That's why pairing lives in the CLI rather than the daemon. It needs a human to read a verification phrase aloud, and the daemon is headless with no guaranteed terminal. A step that requires a person can't live in a process that can't reach one.
The test bar is a docker compose harness that runs two daemons on a network and drives a genuine send, disconnect and reconnect between them. An in-memory two-node test tells me about the state machine; this tells me about a hotspot with a sleeping laptop on it.
How it was built
24 August 2026
Ferry — the core lands
Written against a specification finished before any code. The workspace, the protocol types, crypto, networking, storage, the transfer engine and outbox, and the daemon skeleton.
27 August 2026
Ferry speaks to other machines
A real listener accepting Noise XK connections authorised against the roster, mDNS discovery, and SPAKE2 pairing with human confirmation.
Problems that came out of this project9
- A VS Code panel that was empty, and a test that passed anyway2026-09-15The first real install of the packaged extension opened a blank webview. The existing end-to-end test had been green the entire time, because it only checked that a panel named Ferry existed — not that anything inside it rendered.
- An audit log gap by omission, not by bug2026-09-08After a full pair, send, deliver and burn, the audit log showed only two of six events. Pairing, roster import, delivery and open/burn — the security-relevant ones — wrote no row, because nobody had written the code to write them.
- Three bugs a mock network couldn't see2026-09-06The two-node in-memory harness had been green for weeks. The first real two-daemon run found three bugs in one pass — none of them state-machine bugs. They were all bugs in the boundary the mock replaces.
- A chunk constant that could never have workeddistributed & realtimenet::chunk::DEFAULT_CHUNK_BYTES was 64KiB. A real bincode-encoded chunk at that size, plus envelope overhead — item id string, sequence number, length prefix, enum tag — plus the Noise AEAD tag, exceeds the 65535-byte max frame size. Every
- A protocol message that was structurally dead codedistributed & realtimeThe protocol says an Opened receipt is sent only if the sender asked for it. There was no field anywhere to express that request, so nothing could ever ask, so the message could never be sent. Added notify_on_open to Offer itself rather tha
- An inbox default left over from an earlier designdata integrityferry_store::inbox::insert defaulted new rows to Queued. But core's own create_inbound, and every fake implementing that port, creates inbound rows as Offered — an inbound item's first observed moment is always "an Offer arrived." There's n
- bincode can't deserialize a tagged enumdata integrityThe wire envelope used serde's tag/content representation and encoded with bincode. That combination cannot work: internal tagging needs a self-describing format so the deserializer can look ahead at a string tag, and bincode's binary forma
- Holding a database lock across a network readdistributed & realtimeThe P2P listener and the discovery loop each got their own dedicated SQLite connection rather than sharing one store behind a mutex with the IPC thread. Sharing would have meant potentially holding a lock across a blocking read from a peer,
- Pairing can't live in a headless processdistributed & realtimeferry pair runs the SPAKE2 exchange inside the CLI over a raw TCP socket, not in the daemon. The daemon is headless and can't guarantee a TTY, and the pairing flow requires a human to read and confirm a verification phrase. Both sides excha