undertow is the protocol underneath driftwood: the shape of a message, how identity
works, how two phones reconcile what they hold, and what it does and doesn't defend
against. This is an overview, not a byte-exact spec — a few encodings (timestamp,
the listen set) aren't fully detailed here yet. Field names and frame types below are
drawn directly from the implementation.
An identity is an Ed25519 keypair generated on-device. The public key is the identity — there is no handle registry and no global namespace. A name is only ever what someone is called, never what identifies them.
Two different kinds of name exist, and the app never lets them look alike:
| Nickname | Claimed username | |
|---|---|---|
| Set by | you | its owner |
| Travels | never | yes, signed by its owner |
| Trust | trustworthy — you bound that name to that key yourself | a claim, nothing more |
| Lives in | contacts |
directory |
A nickname is plain text — you confirmed that key belongs to that person, in person, so it's the only kind of name that can be trusted. A claimed username sits on a colour derived from the key, with a short fingerprint beside it: "I vouched for this person" and "this person says so" must never look the same on screen.
Only the claimed username actually travels the wire, as a signed profile record (the
PROFILE frame — §7 below). Nicknames,
the contacts/directory
split, and the colour/fingerprint display convention are this client's own local
choices, not part of the protocol contract.
Every message is content-addressed and independently signed. Parent links are context and structure only — never validity or trust; a missing parent costs context, never integrity. There's no recipient field, either — a message can't be addressed to one person; it only ever moves the way the listen/context/gossip tiers below describe, through whoever you sync with next.
| field | notes |
|---|---|
| v | Format version — 1 for now. First field, so a decoder can branch before parsing the rest. |
| id | Content hash of the message. Not part of its own preimage. |
| author | Public key of the author (32 bytes, Ed25519). |
| root | Empty on a root message — a root is defined as a message with an empty root, and its own id then serves as the thread's root id everywhere else. |
| parent | Id of the specific message being replied to. Optional — enrichment only. |
| timestamp | Author-claimed time, Unix ms UTC. Untrusted. |
| text | UTF-8, max 320 characters, NFC-normalized then counted. |
| sig | Ed25519 signature over the canonical preimage. Also not part of its own preimage. |
root = id can never be made true by hashing — changing
root changes id — so
"empty means root" keeps every field honest and the preimage a single pass:
Fixed-width prefixes, no length header, trivially separable. A receiver hashes the preimage bytes exactly as received — never decoded and re-serialized, which would silently repair a hostile or buggy encoding.
A thread is every message whose root
equals a given id — the root message itself (if held) plus every reply carrying that
root. You can reply to a thread using only its root id;
you don't need to hold the root message, or anything else in the thread, to do it.
Root ids outlive root content. A root id is a permanent, cheap identifier — the root
message is prunable like anything else, so
a long-running thread whose original root nobody still holds is an expected, normal
end-state — the same shape a long-running conversation naturally takes once its
opening no longer needs to be kept. It renders calmly: the tree is shown wherever a
parent is held, and attached to the root
with a quiet marker wherever it isn't.
Every message a device holds falls into exactly one tier, re-evaluated at sync time — not continuously — whenever the device gains or loses a listened author.
The author is on your listen list — a message from someone you actually follow.
Not someone you follow, but the message shares a thread with content from someone you do. Kept so the conversation stays whole — context, not a subscription.
Everything else — incidental content carried along for discovery.
Precedence when a message could qualify for more
than one: listen > context > gossip.
A message's effective_time — the value
actually checked against the window — is
min(claimed timestamp, first received time). That
defends against forward-dating, but deliberately does not rescue backdated or
slow-travelling content: a message that takes 90 days to travel three hops cannot
travel a fourth — reach is bounded by authorship time, not arrival time at each hop.
default window — user-configurable
default storage split — listen / context / gossip
Within an over-quota partition, each distinct author gets an equal share of that
partition's budget, and eviction is oldest-first by effective_time
among authors over their share. Favourited messages are exempt — never evicted, never
counted against anyone's share. Pruning only runs during a sync (after merging), never
merely from adding a listen or a favourite — except blocking, which drops content
immediately.
Two devices run the exact same state machine — one side is "initiator" purely to decide who speaks first. Every exchange is a single offer, answered once, never a negotiation that converges — no back-and-forth haggling over what to send, just one offer, one answer.
Each phase is applied on completion, not buffered until the session ends. If the connection dies during the gossip phase, the priority phase's results are already persisted — the priority phase is independently valid by design. A sync session is brief and in person, and can end at any moment, so a session that's 90% done is still worth something. If the bus arrives, what you already exchanged is yours.
Identity travels in HELLO — both people
get a chance to confirm who they're actually connected to (a name, for a contact; a
fingerprint, for a stranger) before anything private moves. Declining aborts the
session before SCOPE is ever sent.
Every record on the stream shares one frame shape. The length is checked against both
an upper bound (4 MiB) and a lower bound — a naive upper-bound-only check can be
fooled: 0xFFFFFFFF, read as a signed 32-bit value,
arrives as -1 — which is numerically less than 4 MiB,
so it slips straight past a check that only tests the ceiling, then asks for a
negative-size allocation.
| type | value | payload |
|---|---|---|
| HELLO | 0x01 | protocol version (u8), author id (32 bytes) |
| SCOPE | 0x02 | listen set, window cutoff, wants |
| HASHLIST | 0x03 | ids held for own scope |
| MESSAGE | 0x04 | one message, wire form, opaque |
| PROFILE | 0x05 | one profile claim, wire form, opaque |
| PHASE_DONE | 0x06 | — |
| GOSSIP_OFFER | 0x07 | ids offered |
| GOSSIP_REQUEST | 0x08 | ids wanted from that offer |
| SESSION_DONE | 0x09 | — |
| ABORT | 0x0A | reason code (u8) |
MESSAGE and
PROFILE payloads are carried opaquely —
never decoded and re-encoded in transit. Re-serializing would silently repair a hostile
encoding, normalizing away the very tampering the hash exists to catch.
Because messages are self-verifying, a peer's identity has no bearing on whether their content is trustworthy — a stranger relaying content can only choose what to offer, never forge it.
| behaviour | response |
|---|---|
| A message with a flipped bit | Rejected — the hash doesn't match the id. Never stored, never relayed. |
| A message signed by the wrong key | Rejected — bad signature. |
| A profile renamed in transit | Rejected — the claim is signed by the key it names, so a relay can withhold a name but never edit one. |
| Too many rejections from one peer | Session aborted. Content already merged stays — each piece was individually valid. |
| A flood of unverifiable garbage | Nothing that fails verification consumes storage. |
| Offering a huge batch of gossip ids | Bounded by the receiver's gossip budget, regardless of what's offered. |
What none of this defends against: someone standing in front of you claiming to be someone else. Identities are free, so an attacker doesn't break a fingerprint, they grind keys until the displayed part matches — a few minutes on a GPU for an eight-character fingerprint (32 bits), a few dozen attempts for a colour (4–5 bits). The only real answer is a nickname — a name you assigned to a key you confirmed in person.
The blocklist is local and private — never declared to a peer, never part of any exchange. Alongside it sits a blocked-roots list: the root ids of threads a blocked author started, kept separately so replies into that thread keep getting dropped even after the root itself is gone. Both are enforced on-device only: a blocked author's content — and anything under a blocked root — is excluded from what you deliver to others, and dropped at pruning. Neither list ever travels, because announcing either would tell the blocked person they'd been blocked, and push one person's judgement onto everyone downstream.
Syncing with someone shows them something about you. Your listen list is declared
during SCOPE so the other side knows
what to send — sync with a stranger and they learn who you follow. That's a deliberate
trade, not an oversight.
| PROTOCOL_VERSION | 1 |
| MSG_FORMAT_VERSION | 1 |
| MSG_MAX_CHARS | 320 |
| USERNAME_MAX_CHARS | 32 |
| WINDOW_DEFAULT | 90 days |
| CONTEXT_SEND_CAP | 1000 per session |
| GOSSIP_INTAKE_CAP | 1000 per session |
| VERIFY_FAIL_CUTOFF | 20 rejections before abort |
| MAX_FRAME_BYTES | 4 MiB |
| WANT_TTL | 10 fruitless syncs |