skip to content
naft.pink

in an encrypted app, the wire schema is the threat model

I'm restarting my self-hosted chat app in 2026 rather than picking it up where it stopped. It ran through 2025, worked, and never went public.

The interesting part isn't that I'm rewriting it. It's that the thing forcing the rewrite isn't the encryption, the realtime layer, or the mobile client. It's that a message never had a written-down shape.

the tool that was actually a bug report#

At some point I built a logger for my own WebSocket traffic, because I couldn't tell what was going over the wire. This is the line that matters:

type: data.type ?? "unknown",

I wrote that, shipped it, used it for months, and read it as tooling. It isn't. It's a defect report about the system it's pointed at.

That ?? exists because the logger cannot know which shapes are legal. It's reaching for a field that might not be there, on an object it parsed out of a string that might not have been JSON. Every hedge in that expression is describing something the protocol declined to guarantee.

Here's the test I wish I'd applied: if you need to build an instrument to discover the shape of your own messages, the shape doesn't exist. Not "isn't documented" — doesn't exist. There was no artefact anywhere in that codebase that could answer "what is a legal message", because the answer lived distributed across every handler that had ever been written, in the heads of nobody but me, and only for as long as I remembered.

Building the instrument felt like rigour. It was avoidance with a nice console prefix.

why this is worse than untidy when it's encrypted#

If it were an ordinary chat app, no schema is a maintenance problem. Handlers drift, a field gets renamed in three places and not the fourth, you fix bugs for a while. Unpleasant, survivable, fixable incrementally.

End-to-end encryption changes the category, because every message is now a negotiation about what the server is allowed to know.

Some of a message has to be readable by the server — you cannot route what you cannot read. The recipient, the channel, roughly when: that's metadata, and it's load-bearing infrastructure. The rest is ciphertext the server must never be able to open. The line between those two sets is not an implementation detail. It is the security model. It's the entire answer to "what does someone who seizes this box learn?"

Now: where was that line written down in my system?

Nowhere. It was wherever a given handler happened to put a field. Adding a feature meant adding a property to an object, and nothing anywhere would tell me whether I'd just quietly moved something from the encrypted side to the plaintext side. No type error. No failing test. No review comment, because it was only me. The system would keep working perfectly while its security properties changed underneath it.

You cannot audit a boundary that was never drawn. That's the sentence the whole project turns on, and I didn't have it at the time.

Yunoa dodges this by being single-user — one reader, one writer, same person, so almost everything can be ciphertext and the question barely comes up. Put a second participant in the room and the question is the entire design.

this is also why it's a rewrite#

The usual advice is that rewrites are a trap, and it's usually right. I'd defend this one on a narrow ground: a schema is not a layer you can add later.

Most things are addable. You can bolt on logging, caching, rate limiting, even authentication, and the existing code mostly doesn't need to know. A schema isn't like that, because every handler, every stored message and every encryption decision was already written against its absence. Introducing one means touching all of them simultaneously, and there is no intermediate state where half the system has a contract and the other half doesn't — that state is strictly worse than either end, because now you have a guarantee that's only sometimes true, which is the same as no guarantee plus false confidence.

"Retrofit the schema" and "rewrite it" describe the same amount of work. One of them is honest about it.

what I'm doing differently#

Schema first, before any transport code exists. Every message type declared in one place, with the encrypted fields and the routable fields separated in the declaration itself, so that moving something across that line is a visible change to a file whose only job is to describe the boundary.

Then the protocol on top of the socket that I skipped last time — message IDs, acknowledgements, an explicit reconnect handshake — instead of calling send and hoping. And a client that cannot send a message the schema doesn't describe, which is the property that makes all of the above stay true rather than being true on the day I wrote it.

still unsolved#

  • Multi-device history. A new device has to read messages it wasn't present for, and every clean answer to that is some flavour of "trust something new".
  • Group membership changes. What someone who joins today can read of yesterday, and what someone removed can still read tomorrow.
  • Notifications that are useful without leaking who a message is from.

None of these are new problems and none of them are things I'll solve better than the people who already have. The difference is that this time I'll be answering them against a schema that says what a message is — rather than discovering the answers I'd accidentally already committed to by reading my own logs.

all notes