driftwood ← Back

undertow

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.

1. Identity

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.

2. Message format

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
vFormat version — 1 for now. First field, so a decoder can branch before parsing the rest.
idContent hash of the message. Not part of its own preimage.
authorPublic key of the author (32 bytes, Ed25519).
rootEmpty 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.
parentId of the specific message being replied to. Optional — enrichment only.
timestampAuthor-claimed time, Unix ms UTC. Untrusted.
textUTF-8, max 320 characters, NFC-normalized then counted.
sigEd25519 signature over the canonical preimage. Also not part of its own preimage.

Construction order

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:

assemble fields id = sha256(preimage) sig = sign(preimage)

Wire form

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.

id
32 bytes
sig
64 bytes
canonical preimage
variable — v, author, root, parent, timestamp, text

3. Threads

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.

4. Listen scope & tiers

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.

highest priority

Listen

The author is on your listen list — a message from someone you actually follow.

middle priority

Context

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.

lowest priority

Gossip

Everything else — incidental content carried along for discovery.

Precedence when a message could qualify for more than one: listen > context > gossip.

5. Storage & pruning

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.

90 days

default window — user-configurable

50 / 20 / 30

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.

6. The sync protocol

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.

A B PRIORITY PHASE — still worth something if interrupted HELLO HELLO SCOPE (listen, cutoff, wants) SCOPE (listen, cutoff, wants) HASHLIST (own scope) HASHLIST (own scope) MESSAGE × n, PROFILE × m PHASE_DONE — apply now GOSSIP PHASE — best-effort, skippable GOSSIP_OFFER (newest ids) GOSSIP_REQUEST (subset) MESSAGE × j SESSION_DONE

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.

7. Wire framing

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
u8
length
u32, big-endian
payload
length bytes
type value payload
HELLO0x01protocol version (u8), author id (32 bytes)
SCOPE0x02listen set, window cutoff, wants
HASHLIST0x03ids held for own scope
MESSAGE0x04one message, wire form, opaque
PROFILE0x05one profile claim, wire form, opaque
PHASE_DONE0x06
GOSSIP_OFFER0x07ids offered
GOSSIP_REQUEST0x08ids wanted from that offer
SESSION_DONE0x09
ABORT0x0Areason 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.

8. Trust & security

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 bitRejected — the hash doesn't match the id. Never stored, never relayed.
A message signed by the wrong keyRejected — bad signature.
A profile renamed in transitRejected — 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 peerSession aborted. Content already merged stays — each piece was individually valid.
A flood of unverifiable garbageNothing that fails verification consumes storage.
Offering a huge batch of gossip idsBounded 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.

9. Reference: constants

PROTOCOL_VERSION1
MSG_FORMAT_VERSION1
MSG_MAX_CHARS320
USERNAME_MAX_CHARS32
WINDOW_DEFAULT90 days
CONTEXT_SEND_CAP1000 per session
GOSSIP_INTAKE_CAP1000 per session
VERIFY_FAIL_CUTOFF20 rejections before abort
MAX_FRAME_BYTES4 MiB
WANT_TTL10 fruitless syncs