Skip to content

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.

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.

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.

From the root seed:

LabelBytesWhat it becomes
account-id16hex, prefixed acct_. The only identifier the server learns.
auth32the old proof of possession. Its SHA-256 is the authVerifier sent to POST /accounts.
auth-key32an X25519 private key. Only its public half is ever uploaded.
vault64encrypts mail, labels and device names. Never transmitted.

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.

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 data
v = 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.

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 public
encryption = 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.

The same construction in a fixed-width layout, because object storage holds bytes:

"MCA1" ‖ ephemeral public key(32) ‖ nonce(12) ‖ tag(32) ‖ ciphertext

The magic is MCA2 for a version 2 body, and is the only thing that says which framing the tag used.

A ciphertext is bound to where it lives, so moving it is not a way to read it.

WhatAdditional data
An address labelmailcycle/v1/inbox:<address id>
A device profilemailcycle/v1/worker:<device id>
An API key namemailcycle/v1/api-key:<key id>
A webhook namemailcycle/v1/webhook:<webhook id>
A device group namemailcycle/v1/group:<group id>
A team member’s pinned keymailcycle/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 deliveredthe message id

Records sealed before the kind was added to the label carry the bare id, and still open, so a reader tries both.

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.

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.

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.