Assessment: age encryption as an option in ZenMemes / v3
Short version: age fits v3 well in one place, maybe in a second, and badly in the one people would think of first, private messages. I read through the v3 repo (private messaging architecture, key backups, PRINCIPLES.md and the browser crypto code) before writing this. I haven't built or benchmarked anything.
What age is, for anyone who hasn't used it: a small file-encryption format from 2019 by Filippo Valsorda and Ben Cartwright-Cox, now specified at C2SP. Recipients are X25519 keys, SSH keys, or a passphrase (scrypt). The body is ChaCha20-Poly1305 in 64 KiB chunks. It has no signatures, no keyring, no key server and almost no options, on purpose.
What v3 does today: every browser identity is an OpenPGP key generated as ed25519, with a Curve25519 encryption subkey. A private message is one OpenPGP envelope encrypted to every approved key of both usernames plus the sender's local key, and signed. The reader refuses to show text until at least one signature verifies. History sync unwraps an envelope's session key and rewraps it for another of your keys. Key backups are password-encrypted OpenPGP messages, as a text file or a Starfield PNG.
1. Operator backups: yes, and it costs almost nothing.
The private mailbox (state/private/messages.sqlite3), the private image store and a git bundle of the record could be piped through age to one or more steward recipients before they leave the server. Message bodies are already ciphertext, but routing metadata, unread state and the receipt-signing secret are not. This is a runbook change, not app code: age is a single static binary, the backup works with the server gone, and encrypting to several stewards at once fits "a community can take its history with it." No browser work, no new record type.
2. Key backups in age format: possible, modest payoff.
The idea is to offer a backup file that "age -d" opens, with the same armored OpenPGP key inside. That needs scrypt, ChaCha20-Poly1305, HKDF and HMAC in the browser. WebCrypto doesn't do ChaCha20 or scrypt, and v3 serves plain HTTP where there's no SubtleCrypto anyway. The bundled OpenPGP.js 6.3.0 has no ChaCha20-Poly1305 either (I grepped for it). So it means hand-writing those primitives, the same way the repo already hand-wrote SHA-256 in private_message_history_crypto.js, and testing them against the official age test vectors. The other cost is scrypt at age's default work factor in pure JS on an old phone, which I'd expect to take seconds; I haven't measured it.
What you get: a backup that opens with age instead of gpg. Today's backup already opens with "gpg -d" on any machine that has gpg. I'd only do this if someone actually wants to restore without gpg.
3. Private messages as an age option: I'd say no.
The blocker is signatures. age only encrypts, and v3's rule is verify before display. So an age message would still carry an OpenPGP signature inside it, which means OpenPGP stays in every client and the age path adds a second format without removing anything.
Then recipients. Each identity would need a published age key, which means a new signed record tying it to the OpenPGP fingerprint, plus approval. You could in principle derive an age X25519 key from the existing Curve25519 subkey, since it's the same curve, but using one key in two protocols is the kind of shortcut that comes back later.
Then mixed conversations. If one participant has no age key, the sender falls back to OpenPGP, so the reader has to handle both formats in one thread. Under "old records keep working," it has to handle both forever once anyone sends one age message.
History sync could be made to work. An age header wraps a file key per recipient, much like OpenPGP's session key, and since the header MAC is computed from the file key, a client holding that key can add a stanza and recompute the MAC. But no off-the-shelf age library exposes that, so it would be more of our own code.
And it doesn't fix anything on the current limits list. There's still no forward secrecy, and lost keys are still lost.
The one real draw is SSH recipients: messaging someone at the ed25519 key they already have on GitHub. That's a different feature, though, one that brings in identities from outside the approval model, and it should be argued on those terms.
Last, the longevity principle names OpenPGP explicitly. OpenPGP dates to the 1990s; age is seven years old. That isn't disqualifying for an operator-side tool you can swap out, but it counts against putting age into the record format.
If this goes further, my suggestion is to write the age backup runbook now and run anything browser-side through Railspike step 1 first, with "replace OpenPGP signing too" on the list of options. That's the only version where age simplifies the system instead of adding to it.
Sources:
age spec (C2SP): https://github.com/C2SP/C2SP/blob/main/age.md
age reference implementation: https://github.com/FiloSottile/age
v3 private messaging architecture: https://github.com/gulkily/v3/blob/main/docs/architecture/private_messaging.md
v3 key backups: https://github.com/gulkily/v3/blob/main/docs/reference/key_backups.md
v3 principles: https://github.com/gulkily/v3/blob/main/PRINCIPLES.md
#v3 #encryption #age #design
#
What age is, for anyone who hasn't used it: a small file-encryption format from 2019 by Filippo Valsorda and Ben Cartwright-Cox, now specified at C2SP. Recipients are X25519 keys, SSH keys, or a passphrase (scrypt). The body is ChaCha20-Poly1305 in 64 KiB chunks. It has no signatures, no keyring, no key server and almost no options, on purpose.
What v3 does today: every browser identity is an OpenPGP key generated as ed25519, with a Curve25519 encryption subkey. A private message is one OpenPGP envelope encrypted to every approved key of both usernames plus the sender's local key, and signed. The reader refuses to show text until at least one signature verifies. History sync unwraps an envelope's session key and rewraps it for another of your keys. Key backups are password-encrypted OpenPGP messages, as a text file or a Starfield PNG.
1. Operator backups: yes, and it costs almost nothing.
The private mailbox (state/private/messages.sqlite3), the private image store and a git bundle of the record could be piped through age to one or more steward recipients before they leave the server. Message bodies are already ciphertext, but routing metadata, unread state and the receipt-signing secret are not. This is a runbook change, not app code: age is a single static binary, the backup works with the server gone, and encrypting to several stewards at once fits "a community can take its history with it." No browser work, no new record type.
2. Key backups in age format: possible, modest payoff.
The idea is to offer a backup file that "age -d" opens, with the same armored OpenPGP key inside. That needs scrypt, ChaCha20-Poly1305, HKDF and HMAC in the browser. WebCrypto doesn't do ChaCha20 or scrypt, and v3 serves plain HTTP where there's no SubtleCrypto anyway. The bundled OpenPGP.js 6.3.0 has no ChaCha20-Poly1305 either (I grepped for it). So it means hand-writing those primitives, the same way the repo already hand-wrote SHA-256 in private_message_history_crypto.js, and testing them against the official age test vectors. The other cost is scrypt at age's default work factor in pure JS on an old phone, which I'd expect to take seconds; I haven't measured it.
What you get: a backup that opens with age instead of gpg. Today's backup already opens with "gpg -d" on any machine that has gpg. I'd only do this if someone actually wants to restore without gpg.
3. Private messages as an age option: I'd say no.
The blocker is signatures. age only encrypts, and v3's rule is verify before display. So an age message would still carry an OpenPGP signature inside it, which means OpenPGP stays in every client and the age path adds a second format without removing anything.
Then recipients. Each identity would need a published age key, which means a new signed record tying it to the OpenPGP fingerprint, plus approval. You could in principle derive an age X25519 key from the existing Curve25519 subkey, since it's the same curve, but using one key in two protocols is the kind of shortcut that comes back later.
Then mixed conversations. If one participant has no age key, the sender falls back to OpenPGP, so the reader has to handle both formats in one thread. Under "old records keep working," it has to handle both forever once anyone sends one age message.
History sync could be made to work. An age header wraps a file key per recipient, much like OpenPGP's session key, and since the header MAC is computed from the file key, a client holding that key can add a stanza and recompute the MAC. But no off-the-shelf age library exposes that, so it would be more of our own code.
And it doesn't fix anything on the current limits list. There's still no forward secrecy, and lost keys are still lost.
The one real draw is SSH recipients: messaging someone at the ed25519 key they already have on GitHub. That's a different feature, though, one that brings in identities from outside the approval model, and it should be argued on those terms.
Last, the longevity principle names OpenPGP explicitly. OpenPGP dates to the 1990s; age is seven years old. That isn't disqualifying for an operator-side tool you can swap out, but it counts against putting age into the record format.
If this goes further, my suggestion is to write the age backup runbook now and run anything browser-side through Railspike step 1 first, with "replace OpenPGP signing too" on the list of options. That's the only version where age simplifies the system instead of adding to it.
Sources:
age spec (C2SP): https://github.com/C2SP/C2SP/blob/main/age.md
age reference implementation: https://github.com/FiloSottile/age
v3 private messaging architecture: https://github.com/gulkily/v3/blob/main/docs/architecture/private_messaging.md
v3 key backups: https://github.com/gulkily/v3/blob/main/docs/reference/key_backups.md
v3 principles: https://github.com/gulkily/v3/blob/main/PRINCIPLES.md
#v3 #encryption #age #design