# Smol Mail Protocol, version 1.1 A minimalist, decentral, end-to-end encrypted mail protocol. Online correspondence reduced to an address, a key and a message. - Five operations, four primitives, zero extensibility mechanisms. - One 32bit master key as the only identity. - No certificates, no CAs, no expiry. One round trip and the server's key pin are the entire trust model. - A fresh key seals every message: untraceable, unforgeable, and safe by construction. - Anti-spam without censorship: quotas and tokens do the filtering; servers never see a word. - Clients talk directly to the recipient's server - Registration is bound to one server by proof-of-possession. - Couble-signed certificate chains move a username to a new key, and old keys keep receiving until every contact catches up. ## 1. Primitives - Ed25519 (RFC 8032) - X25519 (RFC 7748) - ChaCha20-Poly1305 (RFC 8439) - SHA-256 / HMAC-SHA256 (RFC 2104) anHKDF-SHA256 (RFC 5869) - Plus a Noise pattern (§4) as a construction over these four. - Integers are big-endian. Text is UTF-8. - Every hash, signature and MAC is domain-separated by a label from §11. ## 2. Identity - The only secret a user holds is a **32-byte master**. - It is the only thing to back up, and it never changes. - Everything else is derived from it: ``` seed_n = HKDF-SHA256(master, salt = "", info = "smolmail/1 identity" || n, len = 32) # n: u32 accept = HKDF-SHA256(master, salt = "", info = "smolmail/1 accept", len = 32) ``` `n` is the **rotation index**, counting from 0. `seed_n` is an Ed25519 seed, and its 32-byte public key *is* the identity at that index. Rotation (§7) advances `n`; the accept key of §5.8 does not depend on it, so it survives every rotation. HKDF is one-way, so a compromised `seed_n` does not expose the master or any other index. A client restoring from the master alone recovers the rest: it resolves its own username, derives `seed_0`, `seed_1`, ... and stops at the index whose public key the server has bound, within the chain limit of §7. Every superseded signing key is therefore re-derivable and none has to be archived. X25519 keys for key agreement come from the same keypair: ``` x_priv = clamp(SHA-512(seed_n)[0..32]) # [0] &= 248; [31] &= 127; [31] |= 64 x_pub = (1 + y) / (1 - y) mod 2^255-19 # y from the Edwards point; sign bit discarded ``` These are libsodium's `crypto_sign_ed25519_sk_to_curve25519` and `..._pk_to_curve25519`. Implementations SHOULD call those rather than reimplement the map. Using only one key in both a signature scheme and a key-agreement scheme is a deliberate trade, mitigated by domain-separating every derivation (§11) and explicitly made to keep an identity at 32 bytes: that's one QR code, one spoken fingerprint. - Implementations MUST reject an all-zero X25519 output and MUST reject a received ephemeral public key that is a low-order point. ## 3. Addressing `alice@example.org[:1961]` -- short form, resolved via the server `smol://alice@example.org/mfrggzdfzt...` -- self-certifying, carries its own key - Usernames are 1-63 bytes of `[a-z0-9._-]`. - A username MUST begin and end with `[a-z0-9]`. - A username MUST NOT contain two adjacent separators. - Usernames are compared case-insensitively. - Clients normalise to lowercase before sending. - The default TCP port is **1961**. The `smol://` path component is the 32-byte identity key in RFC 4648 base32, lowercase and unpadded (52 characters). Because it carries the key, an address shared this way requires no trust in any server. It is the form used for QR codes, contact files and links. Fingerprints for spoken or visual verification are the first 20 characters of that base32 string in groups of four, a truncation of the identity, not a separate encoding. Twenty characters carry 100 bits, so producing a second key with a given fingerprint costs about 2^100 trials. ## 4. Transport TCP with a **Noise_NX_25519_ChaChaPoly_SHA256** handshake, prologue `smolmail/1`. One round trip. No certificates, no CA, no expiry. The NX pattern has the server transmit its static public key during the handshake, so pinning is a single code path: - If a pinned key exists for this host, it MUST match the key received. - On mismatch the client MUST abort and surface the discrepancy. - If no pinned key exists, the client MAY accept and pin it, but MUST treat `RESOLVE` results from that session as unverified and mark them as such. - A client MUST NOT `REGISTER`, `FETCH` or `DELETE` against a server whose static key it did not obtain from a trusted channel. - An unpinned server is acceptable for `SEND` when the sender already holds the recipient's identity key: the payload is sealed end-to-end, so the channel is providing only confidentiality against a passive observer. ### Framing - Each Noise message carries a 2-byte length prefix and is at most 65535 bytes. - Application frames are `length (u32) || type (u8) || body`, split across Noise messages, with a default maximum of 1 MiB. - Every field is length-prefixed; nothing on the wire requires text parsing or delimiter scanning. ### Session authentication - `FETCH` and `DELETE` require one `AUTH` frame: an application frame of type `0x00` -- after the handshake: ``` username_len u8 || username || identity 32 || signature 64 || sync u8 || count u16 || token 32 × count signature = Ed25519 over "smolmail/1 auth" || h # h = Noise handshake hash ``` The server verifies the signature and that `identity` is the key bound to `username`. Because `h` incorporates the server's fresh ephemeral, this requires no challenge round trip and cannot be replayed across sessions or servers. The trailing block carries the mailbox's accept tokens (§5.8). `sync = 0` leaves the stored set untouched and `count` MUST then be 0; `sync = 1` replaces the stored set with exactly the tokens that follow, which is how a token is both added and removed. A client MUST send `sync = 0` unless it knows its set is complete -- in particular a client restored from the master alone, whose empty set would otherwise erase the server's. A set larger than the server's limit is refused with status 7 and leaves the session unauthenticated. ## 5. Message format ### 5.1 Envelope: what the server stores cleartext ``` magic 4 "SMOL" version 1 0x01 to 32 recipient Ed25519 identity key epk 32 sender's ephemeral X25519 public key ciphertext n tag 16 Poly1305 ``` ChaCha20-Poly1305 with `nonce = 0^12` and `aad = magic || version || to || epk`. The all-zero nonce is correct because the key is used exactly once (§5.2). There is no nonce field, so nonce reuse is not a failure mode an implementation can have. The envelope is also the on-disk and export format, hence the magic and version. - An envelope is 69 bytes of header plus ciphertext plus a 16-byte tag. - It is at most 768 KiB (§10). - The payload it seals is 109 bytes plus the body. - A message identifier is 32 bytes. - A rotation certificate is 200. - An accept token is 32 and its MAC 32. ### 5.2 Sealing The sender generates a throwaway X25519 keypair per message: ``` esk, epk = X25519_keygen() shared = X25519(esk, to_x25519(recipient_identity)) key = HKDF-SHA256(shared, salt = epk || recipient_identity, info = "smolmail/1 seal", len = 32) wipe(esk) ``` The recipient recovers the same key with `X25519(own_x_priv, epk)`. - The sender's long-term key signs only and never participates in key agreement. - Therefore the sealing key is unique per message. - Compromising a sender's identity key reveals nothing about messages already sent. - The sender does not appear in the envelope, so a server cannot observe who corresponds with whom. - An envelope has exactly one recipient. - Addressing one message to several people means sealing one envelope per recipient, with no shared state between them. (A seized *recipient* key still decrypts ciphertext stored while it was valid.) ### 5.3 Sealed payload ``` version 1 0x01 sender 32 Ed25519 identity key time 8 int64, Unix seconds, UTC body_len 4 u32 body n UTF-8 signature 64 Ed25519 by sender padding 0..n ignored ``` The signature covers `"smolmail/1 msg" || to || epk || version || sender || time || body_len || body`. Covering `to` prevents a recorded message being re-addressed to a third party and still verifying; covering `epk` binds the signature to this exact sealing. A receiver MUST verify the signature against `sender` and MUST check that `to` equals its own identity key. - A receiver MUST reject a payload whose `time` is more than 86400 seconds ahead of its own clock, and MAY mark a larger backwards skew. - This `version` is not the envelope's: the envelope version governs the sealed-box construction of §5.1 and §5.2, this one governs the plaintext layout, and either can change while the other does not. - Senders MAY pad the plaintext to a multiple of 1024 bytes to blur message length. - Receivers MUST ignore trailing bytes. `body_len` makes padding unambiguous, and the AEAD tag protects it from alteration. ### 5.4 Message identifier ``` id = SHA-256("smolmail/1 id" || envelope) ``` - The identifier is derived: a sender cannot choose it, and both servers and clients can deduplicate without trusting anyone. - It is used whole, nothing truncates it. ### 5.5 Body frontmatter If the body begins with the exact bytes `---\n`, an optional frontmatter block runs to the next line consisting of exactly `---`. Everything after that line is the message text. ``` --- Subject: Re: the thing In-Reply-To: 4f2a1c9e8b7d6a5f3e2d1c0b9a8f7e6d5c4b3a291807f6e5d4c3b2a1908f7e6d5 X-Mood: cautiously optimistic --- Body text starts here. ``` - One `Key: value` per line. - Key is 1-64 bytes of `[A-Za-z0-9-]`. - Value is the remainder of the line after `:`, trimmed of surrounding spaces. - No nesting, arrays, multi-line values, quoting, comments or type coercion. - Keys are compared case-insensitively, so `Subject` and `subject` are the same key - Where a key repeats, the first occurrence wins. - Maximum 4 KiB and 64 keys. - A malformed line invalidates the whole block, which is then displayed as ordinary body text. - Frontmatter fails closed toward display, never toward silent discard. - `Subject`, `In-Reply-To` (a message id as 64 lowercase hex characters), `Reply-To` (§5.7) and `Accept` (§5.8) are reserved. - Unknown keys MUST be preserved, MAY be displayed, and MUST NOT alter client behaviour. - A body whose text genuinely begins with `---` is escaped by emitting an empty block first. - This grammar is not YAML: do not use a YAML parser. - It is flat so that a correct parser is twenty dependency-free lines, in a code path that handles attacker-controlled input. ### 5.6 Sent copies Because `esk` is wiped, a sender cannot decrypt what they sent. To keep a Sent folder, a client seals a second copy of the payload to the sender's own identity key with a fresh ephemeral and stores it locally. This is a client convention and involves no server. ### 5.7 Sender addresses Nothing on the wire names a sender. A receiver learns only the signing key of §5.3, so a first contact is identified by nothing but its fingerprint, and replying needs an address the sender supplied somewhere: out of band, or in the message itself. Like the sent copies of §5.6 this is a client convention and involves no server: - a sender MAY include a `Reply-To` field holding its own full `smol://` address. - The value sits inside the sealed, signed payload, so the signature binds the claim. - A receiver that acts on it MUST check that the key carried in the URI equals the message's `sender`, MUST treat anything else as ordinary text, and MUST NOT let it replace a key already bound to that address (§8). - It is a first-contact aid, not a trust upgrade. ### 5.8 Accept tokens - A server cannot see senders. - It cannot tell wanted mail from a flood. - An **accept token** is the recipient's own 32-byte secret, given to one correspondent and to her server. - It lets the server separate the two without learning who anyone is. ``` t = HMAC-SHA256(accept, correspondent_identity) # accept: §2 mac = HMAC-SHA256(t, "smolmail/1 mac" || id) # id: §5.4 ``` - The accept key never leaves the owner's device. - `t` is derived rather than random so that it is a function of the master and the correspondent's identity alone, and a MUST. - One distinct token per correspondent, or they can neither be told apart nor removed individually. - The owner uploads her tokens with `AUTH` (§4) and delivers each one to its correspondent as a reserved `Accept` frontmatter field, base32, inside a sealed and signed payload. - A receiver MUST ignore an `Accept` value unless the payload's signature verifies, and MUST attribute the token to the signer. - Because a token is issued by a mailbox rather than by one of its keys, a receiver SHOULD file it under the address it knows that signer by: one already bound to the key (§8), or the `Reply-To` the same payload signs for itself (§5.7), so that the token keeps working across the issuer's rotations and a rotation it never observed cannot orphan it. A sender holding a token attaches `mac` to `SEND` (§6.1). The server recomputes `HMAC(t_i, ...)` for each token `t_i` stored for the recipient's mailbox and compares in constant time: - A token matches: the envelope enters the mailbox's main quota and retention. - No token matches, the MAC is malformed, or none is attached: the envelope enters the **requests** tier, a much smaller quota with a short retention (§10). - `FETCH` marks which tier a message arrived in, so a client can present unsolicited mail separately from correspondence. - A failed match is never reported to the sender: `SEND` succeeds either way, and cannot be used to test whether a token is still accepted. - Removing a token from the set, an `AUTH` with `sync = 1` that omits it, demotes that correspondent to the requests tier from the next message on. - The tier is merely a quota boundary: refusing someone's mail outright remains the client's decision, after decryption. - Because the accept key is independent of the rotation index (§2), tokens survive the owner's key rotation. - A token is likewise derived from the correspondent's identity as it stood when they were accepted, so their later rotation does not invalidate the token they hold. ## 6. Operations | Type | Op | Auth | Request → response | | --- | --- | --- | --- | | 0x01 | `RESOLVE` | no | username → identity key + rotation chain, possibly empty | | 0x02 | `SEND` | no | one envelope, optionally with an accept MAC → id. Redelivery is a no-op | | 0x03 | `FETCH` | yes | cursor → count, then `id \|\| received_at \|\| flags \|\| envelope` records, oldest first, up to a server byte budget | | 0x04 | `DELETE` | yes | count + 32-byte ids → number removed. Unknown ids are not an error | | 0x05 | `REGISTER` | no | username + identity key + proof of possession + optional invite token + optional rotation certificate | `AUTH` (§4) is session setup, not an operation. `SEND` requires no account on the recipient's server. Clients connect directly to the server named in the address; there is no relaying and no federation, so an outbound queue lives in the sending client. `FETCH` carries a cursor and has no flags or server-side read state; a client pages forward until a response is empty, and deleting is a separate decision. `REGISTER` without a certificate binds a free username, or returns code 9; with one, it rebinds an existing username when the chain validates against the currently bound key. ### 6.1 Request and response bodies - A request body is the operation's payload. - A response body begins with `status (u8)` from §12. - On `0` the operation's payload follows, on anything else an optional UTF-8 reason string. - A response reuses the request's type byte. - `AUTH` is type `0x00` and its response carries a status and, on success, the number of accept tokens the server now holds. ``` AUTH → username_len u8 || username || identity 32 || signature 64 || sync u8 || count u16 || token 32 × count ← accepted u16 RESOLVE → username_len u8 || username ← identity 32 || chain_len u8 || cert 200 × chain_len SEND → mac_len u8 || mac || envelope ← id 32 FETCH → after_received_at i64 || after_id 32 ← count u16 || ( id 32 || received_at i64 || flags u8 || env_len u32 || envelope ) × count DELETE → count u16 || id 32 × count ← removed u16 REGISTER → username_len u8 || username || identity 32 || signature 64 || token_len u8 || token || cert_len u8 || cert ← (empty) ``` A zero length means the field is absent. `SEND`'s `mac_len` is 0 or 32 (§5.8). `REGISTER`'s signature is proof of possession, required on every registration and rotation: ``` signature = Ed25519 by identity over "smolmail/1 register" || server_static || username || identity ``` `server_static` is the key the server sent during the handshake, so the attestation cannot be replayed to another server. Without it any party could bind any public key to any username. `FETCH` returns records ordered by `(received_at, id)`, strictly greater than the cursor; an all-zero cursor starts at the beginning, and a client's next cursor is the last record it received. A server fills a response up to a byte budget which MUST be smaller than the maximum application frame -- 512 KiB against the default 1 MiB frame is a reasonable choice -- and MUST return at least one record even when it alone exceeds the budget, so no message can wedge a mailbox shut. In `flags`, bit 0 is set when the message arrived without a matching accept token (§5.8); the remaining bits are zero. ## 7. Key rotation ``` old_pub 32 || new_pub 32 || time 8 || sig_old 64 || sig_new 64 both signatures over "smolmail/1 rotate" || username || old_pub || new_pub || time ``` `sig_old` is by `old_pub` and `sig_new` by `new_pub`. Both are required: the old key alone could otherwise hand a username to a key nobody controls, or to someone else's. The username is covered but not carried, which keeps the certificate fixed-size and stops it being a portable object replayable against another username bound to the same key; a verifier always knows which username it is checking. - A server keeps each username's ordered chain of certificates and returns it with every `RESOLVE`. - A client holding a stale pinned key walks the chain forward from that key, verifying both signatures on each link, and accepts the new key silently if the chain terminates at the key `RESOLVE` returned. - A broken, absent or over-long chain, maximum 16 links, requires explicit user confirmation. - Senders SHOULD also push their certificate to contacts as an ordinary message, so rotation propagates without depending on the old server. - A server MUST retain every key ever bound to a username, MUST accept `SEND` addressed to any of them, and MUST return messages addressed to any of them on `FETCH`. - Without this, mail from a contact who has not yet seen a rotation is addressed to a superseded key and becomes unreachable. - A client MUST likewise be able to produce every private key it has rotated away from, since sealing is to a specific key (§5.2) and mail addressed to a superseded key is readable with nothing else. - Advancing the rotation index rather than generating an unrelated key makes those keys a derivation from the master (§2) rather than an archive. - Rotation is not revocation: An attacker holding a stolen identity key can rotate to their own key and the chain will validate. - Clients MUST surface rotations in the interface rather than applying them invisibly. - Out-of-band re-verification is the only defence against a compromised identity key. - There is no revocation mechanism. ## 8. Trust model | Key source | Guarantee | | --- | --- | | Home server key from a trusted channel | Authenticated, pinned, mismatch detected | | Contact key from `smol://` or a QR code | Strong; requires no server trust | | Sender's `Reply-To` field, key equal to the signer (§5.7) | Strong; the sender's own signed claim, but never overrides a pinned key | | `RESOLVE` over a pinned session | Trust on first use, as good as that server | | `RESOLVE` over an unpinned session | Trust on first use, interceptable at first contact; MUST be shown as unverified | | Key change with a valid chain | Accepted, surfaced in the interface | | Key change without a chain | Rejected until the user confirms | ## 9. Privacy A server learns which mailbox each envelope is for, its size, and when it arrived, was fetched and was deleted, plus the connecting IP address. It does not learn the sender, the subject, the content, or any relationship between messages. Accept tokens (§5.8) add to that: the server learns how many correspondents a user has accepted, whether a given message came from one, and which token it matched. A token is therefore a stable pseudonym, and the server can group a correspondent's messages under it without learning the identity behind it. That is the price of a mailbox that cannot be filled by anyone who knows an address, and an unfillable mailbox is a precondition for the rest of this document mattering. A network observer learns that a client contacted a host on this port, and the volume and timing of traffic. Everything after the first round trip is encrypted, including usernames, recipient keys and tokens. Not protected: traffic analysis, delivery timing correlation, mailbox size, and whether a given user has an account on a given server. Operators wanting metadata resistance should run the server as a Tor onion service; the transport is plain TCP and needs no changes. There is no recipient-side forward secrecy. Messages are signed, which provides non-repudiation rather than deniability: a recipient can prove to a third party who wrote a message. ## 10. Servers - Independent store-and-forward mailboxes that never communicate with one another. - Each holds its own username bindings, rotation chains, accept tokens and envelope queues. - Envelopes persist until deleted or expired. Every mailbox has two tiers (§5.8). Defaults, all configurable: ``` 768 KiB per envelope 2 MiB requests-tier quota 64 MiB main-tier quota 7 days requests-tier retention 30 days main-tier retention 1024 accept tokens per mailbox ``` A server also keeps the identifier of every envelope it has accepted for the length of its retention window, and answers a resend with the same id and no new message, so an envelope captured and replayed after deletion does not reappear. Clients keep their own seen-id set for the same reason. Abuse control is quotas, size caps and rate limits: per-IP connection and `SEND` limits, and a `SEND` limit per accept token, so one accepted correspondent cannot fill the main tier either. The accept-token set is an allow-list the mailbox owner controls and the server cannot interpret; there are no server-side denials, because a server still cannot see senders, and content filtering belongs in the client, after decryption. Operators SHOULD NOT log recipient keys alongside IP addresses and timestamps, which would reconstruct much of the metadata the message format withholds. ## 11. Domain separation labels ``` "smolmail/1" Noise prologue "smolmail/1 msg" message signature "smolmail/1 identity" signing seed "smolmail/1 id" message identifier "smolmail/1 accept" accept key "smolmail/1 mac" accept token MAC "smolmail/1 auth" session auth "smolmail/1 rotate" rotation certificate "smolmail/1 seal" HKDF info "smolmail/1 register" proof of possession ``` ## 12. Status codes ``` 0 ok 3 unknown user 6 quota exceeded 9 not permitted 1 malformed 4 auth required 7 too large 10 internal error 2 bad version 5 auth failed 8 rate limited ``` A status is the first byte of every response body (§6.1), which MAY be followed by a UTF-8 reason string. Clients MUST NOT parse the reason. Codes not listed are unassigned: a client MUST treat one it does not know as a failure of the operation, MUST NOT retry automatically, and SHOULD surface the number.