sirna: Destroy the Key, Not the Copy
The successor to otm. otm keeps a master key on the server, so the server can decrypt everything it holds — not patchable, because it is the shape of the design. sirna never lets the key reach the server at all.
sirna — Indonesian for to vanish without leaving a trace.
This exists because of a limit in otm that no patch could reach. otm keeps a master SECRET_KEY on the server and uses it to encrypt every message key. That is fine for what otm is — a working demonstration of applied cryptography, which it remains — but it means the server can decrypt everything it holds. For a tool whose entire pitch is the word secret, that is not a bug to fix. It is the shape of the design.
So the promise changed.
The promise is “destroy the key”, not “destroy the copy”
You cannot delete data off someone else’s device. Once a recipient has bytes, those bytes are theirs — saved, screenshotted, backed up to a cloud you have never heard of.
What you can delete is a key. Destroy it, and every copy of the ciphertext dies at the same instant, including the copies you do not control and never knew existed.
That is a narrower promise than “the message is gone”, and it is the only one that can actually be kept.
Two channels, and neither is enough on its own
The envelope travels however you like — WhatsApp, email, an S3 bucket. The key travels separately: 24 words, or a QR code, shown exactly once.
The key never touches the server, and it never rides in the URL. Leaking one channel is not enough.
What the server holds is the envelope alone. crates/server links crates/core for the header parser only, so “the server cannot decrypt” is a property of the build rather than a sentence in a README.
What a stranger learns from the envelope
$ sirna inspect report.pdf.sirna
format version : 1
chunk size : 65536 bytes
kind : file
envelope size : 104883299 bytes
That is all of it. Filename, MIME type, true length, expiry, and any note live inside the encrypted metadata block.
Each chunk’s additional authenticated data folds in a BLAKE3 hash of the header, which authenticates the header without a separate MAC. Chunk boundaries come from the authenticated plaintext length rather than from bytes remaining — which is why a truncated download reports truncated instead of wrong key.
crates/core takes the clock and the RNG as parameters. That is not fastidiousness: wasm32 has no SystemTime, and byte-exact test vectors are impossible without a seedable RNG. Those 22 vectors are the only thing keeping four clients — CLI, browser, Android, server — from silently drifting apart.
One read means one read, and the cost is written down
The first version handed the claim back when delivery failed and allowed three attempts. The reasoning was kind: a reader on a flaky connection should not be left with nothing.
It was the wrong trade. It meant a blob could be served more than once, and once is the entire promise. A guarantee that holds usually is not a guarantee anyone can reason about, and it is worse than a hard rule with a known cost.
The retry path is gone. A blob moves straight from live to consumed inside the claiming UPDATE, with no intermediate state and no way back. It is spent the moment it is claimed — regardless of a storage error, a dropped connection, or a closed tab. Nobody gets a second attempt, including a reader who never received it.
The cost is real, so it is stated rather than smoothed over: a failed transfer destroys the message and the sender has to send a new one. The runbook says to expect “it errored and now it is gone” and that this is correct. The read page says the message is used up the moment Open is pressed. Nobody should have to learn that from a support thread.
Dropping the intermediate state also removed the reaper branch for blobs stranded mid-download, the attempts counter, and two methods. The state machine is two states and one transition — small enough to audit at a glance, which is the right size for the one rule the product is named after.
A correct key reported as a typo
Pasting the 24 words failed with “that key is not quite right — check for a typo”. That is the worst possible thing to tell someone holding a key that is, in fact, perfect.
The client was guessing which mode an envelope was in by inspecting what the user typed: a space meant a phrase, no space meant a passphrase. Copying the words out of the numbered list on the sealed screen separates them with newlines, so the guess came out wrong and the passphrase path rejected a phrase that was never a passphrase.
The envelope states its own mode, and inspect already reported it. The answer was being thrown away in favour of a guess. Nothing guesses now.
The mnemonic parser also required single spaces. Words arrive separated by newlines from a list, by double spaces through a chat client, and with tabs from a terminal — all the same phrase to the person holding it. Whitespace is collapsed before parsing, with a test for each shape including CRLF.
This is the argument for the error taxonomy being worth the trouble. The report was “check for a typo”, and because that maps to a distinct code rather than a generic failure, it pointed straight at key parsing instead of at decryption, where the bug was not.
wrong key, or the envelope has been altered | 5 |
envelope is incomplete — data is missing from the end | 6 |
this message has expired | 9 |
key checksum does not match — likely a typo | 12 |
The codes are identical across every client. Someone with a corrupt download is not sent hunting for their key.
The fallback that hid a broken page
Opening a message link showed the header, the footer, and nothing else — inert, unstyled, and looking like a deploy that had not finished.
The markup referenced ./app.js and ./style.css. The app serves from two URL shapes, / and /m/<id>, so those resolve to /app.js on one and /m/<id>/app.js on the other. The deep route — the one every recipient actually opens — asked for files that were not there.
The reason it shipped is the more instructive half. The fallback answered every unknown path with index.html and a 200, so a missing asset came back as a perfectly successful page of HTML. The browser only noticed when that HTML failed to parse as JavaScript. A broken reference was indistinguishable from a working one at the HTTP level, which is exactly how it survived my own checks: I verified that / and /m/<id> return 200, never that the assets those pages name are reachable from where the browser asks for them.
Anything that looks like a file is a 404 now, and only extensionless paths fall through to the app.
Custody mode, and five digits read aloud
Handing over words means the sender cannot take the key back. Custody mode keeps the key on the owner’s device and releases it to one specific reader at read time, after which the owner destroys it. That is what makes revocation real rather than rhetorical.
The release derives a wrapping key with BLAKE3 over the shared secret together with both public keys and the blob id. Binding both identities and the message into the derivation means a relay that swaps either side produces something that does not decrypt, rather than something that quietly works.
The short authentication string is five digits over both public keys and the blob id. Five is a deliberate compromise: one in a hundred thousand against an attacker who gets a single attempt, and short enough that a human will actually read it aloud. Longer would improve the arithmetic and worsen the outcome, because people skip what is tedious.
The release tests are written from the relay’s point of view rather than the happy path, because the relay is the adversary the design assumes. It sees every frame. It can substitute the reader — and that is precisely what changes the digits on both screens. It cannot read the key, replay a grant onto another message, alter a byte undetected, or forge a receipt.
Where it stands
Live at sirna.arisjirat.com. The spec and its 22 vectors, core, the CLI, the blob server over Garage, the browser client, and the Kotlin bindings are all done — 22/22 vectors passing on the JVM too. The Android app with Keystore-backed shredding is not built yet.
62 tests. core is #![forbid(unsafe_code)].