ADR-0131: VayuTalk — ephemeral, end-to-end-encrypted messaging
- Status: Accepted
- Date: 2026-07-11
- Relates to: ADR-0128 (private-key sync), ADR-0129 (device approval), the VayuPGP engine, the VayuMail credential scope
Context
VayuMail gives a sovereign identity its own mail. The next want is a conversation channel — instant, one-to-one, private — without adding a third-party messenger, a phone number, or a new account. It should reuse the identity a mailbox already has (its address and its PGP keypair) and it must not weaken the product's core promise: the server sees no plaintext and keeps no durable record of what was said.
The design tension is between a real-time transport and the no-storage promise. A messenger normally needs presence, delivery, and read state — all of which tempt a database. VayuTalk resolves this by making ephemerality the design centre: the relay carries opaque ciphertext, holds it in RAM only, and a process restart erases everything.
Decision
SSE + REST over the standard library, not WebSockets
The client needs to receive pushed messages and receipts, and to send
messages, ack them, and fetch a recipient's key. Receiving is the only part
that needs a server→client push. We use Server-Sent Events for that one
stream (GET /stream) and plain REST for everything else
(connect/send/ack/pubkey).
Why not WebSockets:
- SSE is one-directional server→client, which is exactly the shape of the push channel; the client→server direction is ordinary authenticated POSTs.
- SSE rides on
net/httpwithhttp.Flusherandhttp.NewResponseControllerfor per-write deadlines — zero new dependencies, no upgrade handshake, no framing library, no separate proxy configuration. It passes through the same reverse proxies and CSP as the rest of VayuPress. - Auto-reconnect is built into the browser/
EventSourcecontract; our at-least- once store-mode queue makes reconnection safe (see below). - A bidirectional socket would buy nothing here and cost a dependency plus a second, harder-to-reason-about lifecycle.
In-memory only, no SQLite, restart purges everything
The entire VayuTalk state — pending envelopes, live subscribers, bearer tokens
— lives in bounded in-memory structures guarded by a mutex. Nothing touches
the database. An Envelope holds only the opaque ciphertext plus minimal
routing metadata (id, from, to, timestamps, mode); the server never has the
plaintext and never logs message content. A restart drops every envelope and
every token by construction, which is the strongest possible "delete" story:
there is nothing on disk to subpoena, image, or recover.
Two delivery modes:
- live — deliver only if the recipient holds a stream now; if offline, drop. Fire-and-forget, never stored, no receipt.
- store — always placed in the in-memory queue (so an ack can read-destroy it and the purge can expire it), and also pushed live if the recipient is online. An unacknowledged store envelope is re-flushed when the recipient reconnects, giving at-least-once delivery until it is read or it expires.
Read-destruction: ack deletes the envelope immediately and, if the sender is
streaming, emits a receipt{read} to them. Expiry: exactly one purge
goroutine (ticker ~2s, context-stopped with the engine) removes expired
envelopes and emits receipt{expired} to a streaming sender, and prunes
expired tokens in the same pass.
Resource caps bound server load
Because the store is memory, every dimension is capped so a hostile or buggy client cannot exhaust the host:
| Bound | Value | Enforced by |
|---|---|---|
| Decoded ciphertext | ≤ 64 KiB | 413 on send |
| TTL | clamped to [60, 3600] s | silent clamp on send |
| Per-recipient queue | ≤ 50 | 429 on send |
| Global queue | ≤ 5000 | 429 on send |
| Streams per user | ≤ 3 | 429 on stream |
| Global streams | ≤ 500 | 429 on stream |
| Bearer tokens | ≤ 20 000, 12 h TTL | evict-soonest on mint |
Every per-connection goroutine is bounded: the SSE handler is tied to
r.Context() (client disconnect cancels it), carries a per-write deadline, and
runs a ~25 s heartbeat so dead intermediaries are noticed. The single purge
goroutine is the only background loop.
Authentication reuses the mail-sync credential scope
connect authenticates through the same verifyCredential chokepoint the
mail listeners and private-key sync use — so device approval (ADR-0129) is
enforced: a mailbox that requires an approved device credential will not hand
out a VayuTalk token to a raw password. It is wrapped in the shared brute-force
throttle and returns a uniform 401 on any failure (anti-enumeration). A
successful connect mints a random 32-byte base64url bearer token (12 h). All
other endpoints require that bearer. Public keys come from the VayuPGP engine
(GetPublicKey, minting on demand), keeping the package free of cmd/ and DB
imports through injected Verify/PubKey function dependencies.
Threat model
- Server compromise reveals only what is in RAM at that instant: a bounded set of short-lived, opaque ciphertext blobs and their routing metadata. There is no plaintext, no key material for the messages (PGP private keys live in the VayuPGP keystore, encrypted at rest, and are never in the VayuTalk store), and nothing on disk. TTL and read-destruction keep the live window small.
- Forward secrecy today comes from ephemerality plus per-message PGP session keys: each message is encrypted to the recipient under a fresh symmetric session key (standard PGP), and the ciphertext is destroyed on read or expiry, so compromising the relay later yields nothing. This is not the ratcheted forward secrecy of Signal — a compromise of a long-term PGP private key still decrypts any ciphertext an attacker separately captured in flight.
- Metadata minimisation: the server holds sender/recipient/timestamps for routing only, in memory, purged on restart; message content is never logged.
- Transport confidentiality and integrity are TLS's job, as everywhere else in VayuPress.
Future work
A double-ratchet (X3DH + symmetric-key ratchet, Signal-style) would add
per-message forward secrecy and post-compromise security independent of the
long-term PGP key. It is deliberately out of scope for this first cut: it
requires client-side session state and a key-agreement handshake that the
current PGP-only clients do not implement. The wire protocol is frozen at v1;
a ratcheted mode would be negotiated as a new envelope mode/version so the
in-memory relay itself — which only ever moves opaque bytes — need not change.
Web client (VayuOS console)
The mobile app holds the mailbox's PGP private key on the device and does its
own crypto. The VayuOS web client (/os/talk) cannot — a browser
under our strict CSP has no private key and we will not ship a megabyte of
vendored OpenPGP.js to give it one. Instead the web surface reuses the trust
model webmail already relies on: the server decrypts on the user's behalf,
exactly as it does when the reader opens an encrypted mail. This keeps the
browser client tiny (vanilla JS, EventSource + fetch, no crypto library) so
the console stays butter-smooth.
Concretely, two session-authenticated routes bridge the browser to the same in-process relay the app uses:
POST /os/talk/send— the server signs+encrypts the plaintext to the recipient withEncryptAndSignFromEmail(text, to, self)and hands the armored ciphertext to the relay. This is byte-identical to what VayuMail Mobile'skeyring.Encrypt(text, [peer], selfEmail)produces, so a web sender and an app sender are indistinguishable to the relay and to the recipient's signature check.GET /os/talk/stream— a server-side-decrypting SSE bridge:Subscribeto the relay for the signed-in mailbox, decrypt each envelope with that mailbox's key, push plaintext to the browser, thenAck(read-destroy) so a delivered message is a read message.
The relay itself is unchanged and still only ever sees opaque ciphertext; the
network and every intermediary do too. Plaintext exists only for the lifetime of
one request or one SSE write and is never logged or persisted. The browser keeps
conversations in tab memory only — a reload wipes them, matching the ephemeral
promise. Web and app share one relay and interoperate end to end: a message
sent from either surface arrives, decrypts, and read-destroys on the other.
Bidirectional interop is pinned by TestTalkWebToApp / TestTalkAppToWeb.
VayuTalk is a top-level system in the console (its own sidebar entry and
/os/talk route), not a VayuMail sub-tab — it reuses the mailbox identity but is
a distinct product surface. Chat identity is resolved by talkIdentity, not
ownMailbox: the latter re-reads the user from the database and so drops the
in-memory mailbox address a mailbox login attaches to a unified CMS account, and
a pure CMS admin has no assigned-mailbox row at all — which produced a spurious
"no mailbox assigned" dead-end for a signed-in owner. talkIdentity resolves,
most-specific first, (1) the session's bound mailbox, (2) the account's own email
when it is on the mail domain ("chat as who I signed in as"), then (3) for an
admin, the first mailbox on the server (an admin owns them all) or
postmaster@domain. Every branch is an address the caller controls, so nobody
can chat as an identity that isn't theirs; resolution is pinned by
TestTalkIdentityResolution.
Verification parity. The web shows a safety number (the peer's PGP
fingerprint) for out-of-band comparison, formatted by formatSafety with the
same normalisation as VayuMail Mobile's chat.SafetyNumber (colons/spaces
stripped, uppercased, groups of four) so the number on the web panel and the one
on the app's Verify-contact screen match character for character. A read-only
GET /os/talk/peer resolves a recipient's key so the browser can both preflight
reachability (turning a doomed send into an upfront explanation) and render that
number. Verified state is per-tab memory only — fingerprints are public, and
nothing about a verification is persisted.
Chat-as switcher. talkIdentities lists every address a session may chat as
(one for a normal holder; every active mailbox for an admin, who owns them all),
and talkSelf(r, requested) resolves a request's identity, honouring the
switcher's as value only when it is in that entitled set — the authorization
boundary that stops a non-admin from ever sending as another mailbox. The stream
(?as=) and send ("as" body field) both pass through talkSelf, so selection
is validated on every request, not just at page load. Switching identity is a
clean teardown client-side (new inbox, new key, reset conversations) because a
different mailbox is a different key.
Store-and-forward is the default surface. Both clients now default to (the
web exclusively uses) store mode: the relay delivers live if the recipient is
streaming and otherwise queues with TTL for at-least-once delivery on their next
connect. live (fire-and-forget, dropped when the peer is momentarily offline)
was a footgun as a user-facing default — it surfaced as "not delivered" whenever
the two endpoints weren't connected at the same instant — so the web drops the
toggle entirely and VayuMail Mobile defaults to store. For real-time to actually
feel real-time both endpoints must be streaming, so the app keeps its VayuTalk
connection open for the active account the whole time it runs (not only while
the chat screen is foregrounded); because the app acks on reveal, not on
receipt, a reconnect re-flushes anything unread, and the client de-duplicates by
envelope id.
Consequences
- A sovereign identity gains private messaging with no new dependency, no new table, and no new plaintext exposure — on both the app and the web console, over one shared relay.
- Delivery is best-effort by design:
livecan be dropped, and a restart clears thestorequeue. Clients treat VayuTalk as ephemeral chat, not as an archive — mail remains the durable channel. - The relay is trivially horizontal-unfriendly (state is in one process's RAM); that is an accepted trade for the no-storage guarantee on the single-VPS product. Clustering would require a shared ephemeral bus and is out of scope.
Amendment (v3.13.56): burn-after-read self-destruct timers + Live mode
The original design expired every envelope at an absolute CreatedAt + TTL
computed at send. That conflated two different clocks — "how long the server
holds a message the recipient hasn't come to read" and "how long a message
lingers after it IS read". This amendment separates them and adds a Live mode.
Burn-after-read timer. Each envelope now carries burn_seconds (wire field
on the envelope payload; Envelope.BurnSeconds), the self-destruct timer that
starts when the message is read, not when it is sent. User-selectable values
are 5s / 1m / 5m / 15m / 30m / 1h, default 5 minutes (safe but usable).
The clamp widened from [60s, 3600s] to [5s, 3600s] (MinBurnSeconds,
MaxBurnSeconds, DefaultBurnSeconds); the wire field ttl_seconds on /send
is reinterpreted as this burn timer (additively compatible — an older client
that sent a lifetime still gets a sensible burn-after-read).
Unread holding window. Envelope.ExpiresAt now means only the server-side
holding deadline for an unread message — default 24h, operator-tunable via
VAYUOS_TALK_UNREAD_TTL_SECONDS (clamped [5m, 7d], see SetUnreadTTL). This
is decoupled from the burn timer so a "5s after read" message does not vanish
from the queue five seconds after send. Reading still read-destroys the stored
copy on ack (no server storage after read); the countdown is then enforced
client-side on both endpoints (recipient on reveal, sender on the read
receipt), so the message disappears on both devices on the same clock.
Live mode. The live envelope mode — never queued, delivered only if the
recipient is streaming — is resurfaced as a user toggle meaning "nothing is ever
stored on the server, and the message burns the moment it is read". It reuses
the existing read-receipt plumbing; the client applies a short read grace so a
live message is legible before it burns. The web console remains a passive
viewer that never acks (per the v1.6.0 fix that stopped it read-destroying the
app's queued copy); it applies the burn countdown visually on display but leaves
the authoritative read-destroy to the app.
Nothing is persisted; all new state (the burn timer, the unread window) lives in the same bounded in-memory structures and a restart still purges everything.