Encryption format
This is the specification of Mailcycle’s encryption: the derivations, the box formats and what each ciphertext is bound to. It is published so that anyone can write a client, and so that a reviewer can work from the format rather than from our source.
The test vectors are published: vectors.json. Every phrase and key in it was made up for the file and opens nothing else; private keys are printed in full so that an implementation can check it derives exactly them.
Everything is base64url without padding, decoded strictly: unknown characters, padding, a length that cannot come from whole bytes, and non-zero trailing bits are all errors.
1. Phrase to seed
Section titled “1. Phrase to seed”PBKDF2-HMAC-SHA256, 120,000 iterations, 64 bytes out. The password is the twelve words, lowercased and single-spaced; the salt is the fixed string mailcycle-seed.
The wordlist is 2048 words and is not BIP-39’s. Every word is 3 to 8 lowercase ASCII letters and no two share their first four.
2. Subkeys
Section titled “2. Subkeys”subkey(seed, label, length) = HMAC-SHA256(seed, "mailcycle/v1/" + label) ‖ HMAC-SHA256(seed, "mailcycle/v1/" + label + "/2") (only if more than 32 bytes are wanted)The label is concatenated, not length-prefixed. That is why an identifier that contains a separator is refused rather than derived from: for an id X, the MAC half of one key is also the encryption half of the key for X/2.
3. Account identity
Section titled “3. Account identity”From the root seed:
| Label | Bytes | What it becomes |
|---|---|---|
account-id | 16 | hex, prefixed acct_. The only identifier the server learns. |
auth | 32 | the old proof of possession. Its SHA-256 is the authVerifier sent to POST /accounts. |
auth-key | 32 | an X25519 private key. Only its public half is ever uploaded. |
vault | 64 | encrypts mail, labels and device names. Never transmitted. |
4. Per-address keys
Section titled “4. Per-address keys”From the vault key:
inbox/<id>, 64 bytes: the symmetric key, for mail your own devices deliver.inbox-sealing/<id>, 32 bytes: an X25519 private key, which is what lets the mail server seal what arrives from outside without being able to read it.
Two labels rather than one, so a key handed to a paired device cannot also open inbound mail for an address it was never assigned.
5. Symmetric boxes
Section titled “5. Symmetric boxes”ChaCha20 with a 12-byte nonce, then HMAC-SHA256, encrypt-then-MAC. The 64-byte key is split: the first 32 bytes encrypt, the second 32 authenticate. The MAC is checked before anything is decrypted.
What the tag covers depends on the box’s v:
v = 1 nonce ‖ ciphertext ‖ additional datav = 2 nonce ‖ ciphertext ‖ additional data ‖ length of the additional data (8 bytes, big-endian)A box is { "v", "n", "c", "t" }. A reader must open both versions. New boxes are version 2.
Verify version 1 exactly as specified. Do not add the length suffix to it.
A box relabelled as the other version computes a different tag and fails, so a server cannot talk a reader into the weaker framing.
6. Sealed boxes
Section titled “6. Sealed boxes”How the mail server seals to an address it cannot open afterwards. X25519 between a throwaway key and the address’s public key gives shared, which is refused if it is all zeroes. The two keys come from that, with the raw curve output as the HMAC key and the transcript binding both public keys:
transcript = "mailcycle/v1/seal-to-public" ‖ shared ‖ ephemeral public ‖ recipient publicencryption = HMAC-SHA256(shared, transcript ‖ "/enc")mac = HMAC-SHA256(shared, transcript ‖ "/mac")From there it is the ChaCha20 and HMAC of step 5. A box is { "v", "epk", "n", "c", "t" }, where epk is the ephemeral public key.
This is not forward secrecy. The address’s key is derived from the recovery phrase and is permanent, so anyone who ever obtains the phrase can decrypt every message ever sealed to that address. It also does not authenticate the sender: anyone holding the public key can seal to it, which is what an email address is.
7. Attachment bodies
Section titled “7. Attachment bodies”The same construction in a fixed-width layout, because object storage holds bytes:
"MCA1" ‖ ephemeral public key(32) ‖ nonce(12) ‖ tag(32) ‖ ciphertextThe magic is MCA2 for a version 2 body, and is the only thing that says which framing the tag used.
8. What the additional data binds
Section titled “8. What the additional data binds”A ciphertext is bound to where it lives, so moving it is not a way to read it.
| What | Additional data |
|---|---|
| An address label | mailcycle/v1/inbox:<address id> |
| A device profile | mailcycle/v1/worker:<device id> |
| An API key name | mailcycle/v1/api-key:<key id> |
| A webhook name | mailcycle/v1/webhook:<webhook id> |
| A device group name | mailcycle/v1/group:<group id> |
| A team member’s pinned key | mailcycle/v1/team-member:<member account id> |
| A team grant (sealed box to the member’s key) | mailcycle/v1/team-grant:<owner id>:<member id>:<address id>:<epoch> |
| Mail sealed to an address | <address id>:<message id> |
| An attachment body | <address id>:<address id>/<message id>/<index> |
| A message a device delivered | the message id |
Records sealed before the kind was added to the label carry the bare id, and still open, so a reader tries both.
9. The unlock proof
Section titled “9. The unlock proof”X25519 between the account key and the server’s challenge key, then:
HMAC-SHA256(shared, "mailcycle/v1/auth-proof:<nonce>:<account id>")The account id is inside the MAC as well as the nonce, so a proof captured for one account cannot be presented for another. A challenge key of small order is refused: answering it would return a MAC under a secret the server already knows.
10. Webhook signatures
Section titled “10. Webhook signatures”HMAC-SHA256(secret, "<unix seconds>.<body>") in lowercase hex, sent as t=<unix seconds>,v1=<hex> in the Mailcycle-Signature header. Verify against the raw body, before any parsing.
Writing a client
Section titled “Writing a client”The TypeScript, Python, Go, Rust, Swift, PHP, Java, .NET and Ruby SDKs all implement exactly this, and each is checked against the same vectors, including the refusals: the wrong additional data, another address’s key, a box relabelled as the other version, an attachment served under the wrong object key. If you write another one, check it the same way, against vectors.json, rather than testing it against a live account.
© Northlab Studios Ltd, trading as Mailcycle. Registered in England and Wales, company no. 17462734. Registered office: 167-169 Great Portland Street, London W1W 5PF.