smolmail/SPEC.md
2026-09-26 11:01:41 +03:00

16 KiB
Raw Blame History

Smol Mail Protocol, version 1

A minimalist, decentralized, end-to-end encrypted mail protocol.

Electronic correspondence reduced to an address, a key and a message. One keypair per user, five operations, one binary message format. Anyone can run a server, anyone can write a client, and only the intended recipient can read a message. A server learns the recipient, the size and the arrival time of each message — nothing else.

  • Minimal — five operations, four primitives, no extensibility mechanisms.
  • Private — messages are sealed and signed; senders are absent from the wire format.
  • Decentralized — independent servers, no federation, no directories.
  • Portable — identity is a keypair, not an account.

1. Primitives

Ed25519 (RFC 8032) · X25519 (RFC 7748) · ChaCha20-Poly1305 (RFC 8439) · SHA-256 / HKDF-SHA256 (RFC 5869). No other cryptographic primitive appears anywhere in this protocol.

Integers are big-endian. Text is UTF-8. Every hash and signature is domain-separated by a label from §11.

2. Identity

Identity is one Ed25519 keypair. The 32-byte public key is the identity; the 32-byte seed is the only secret a user must back up. X25519 keys for key agreement are derived from the same keypair:

x_priv = clamp(SHA-512(seed)[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 one key in both a signature scheme and a key-agreement scheme is a deliberate trade, made to keep an identity at 32 bytes — one QR code, one spoken fingerprint — and mitigated by domain-separating every derivation (§11).

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/mfrggzdfmztwq2lknnwg23tpo…   self-certifying, carries its own key

Usernames are 1–63 bytes of [a-z0-9._-], must not begin or end with a separator, and 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.

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:

  • A pinned key exists for this host — it MUST match the key received. On mismatch the client MUST abort and surface the discrepancy.
  • 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
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.

5. Message format

5.1 Envelope — cleartext, what the server stores

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.

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; and the sender does not appear in the envelope, so a server cannot observe who corresponds with whom.

A seized recipient key still decrypts ciphertext stored while it was valid. Closing that requires a ratchet and per-peer session state, which §13 places out of scope.

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.

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)[0..16]

The identifier is derived rather than asserted: a sender cannot choose it, two distinct messages cannot collide on it, and both servers and clients can deduplicate without trusting anyone.

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: 4f2a1c9e8b7d6a5f3e2d1c0b9a8f7e6d
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. 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 and In-Reply-To (a message id as 32 lowercase hex characters) 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.

6. Operations

Type Op Auth Request → response
0x01 RESOLVE no username → identity key + rotation chain, possibly empty
0x02 SEND no one envelope → id. Redelivery is a no-op; the id already exists
0x03 FETCH yes → count, then id || received_at || envelope records, oldest first, up to a server byte budget
0x04 DELETE yes count + 16-byte ids → number removed. Unknown ids are not an error
0x05 REGISTER no username + identity key + 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 has no cursors, flags or server-side read state; a client loops until it returns nothing. 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 no payload.

RESOLVE   → username_len u8 || username
          ← identity 32 || chain_len u8 || cert 136 × chain_len

SEND      → envelope
          ← id 16

FETCH     → (empty)
          ← count u16 || ( id 16 || received_at i64 || env_len u32 || envelope ) × count

DELETE    → count u16 || id 16 × count
          ← removed u16

REGISTER  → username_len u8 || username || identity 32
            || token_len u8 || token || cert_len u8 || cert
          ← (empty)

A zero length means the field is absent. FETCH returns records oldest first, up to a server byte budget which MUST be smaller than the maximum application frame; 512 KiB against the default 1 MiB frame is a reasonable choice.

7. Key rotation

old_pub 32 || new_pub 32 || time 8 || signature 64
signature = Ed25519 by old_pub over "smolmail/1 rotate" || old_pub || new_pub || time

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 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 retain every private key it has rotated away from. Sealing is to a specific key (§5.2), so mail addressed to a superseded key is only ever readable with that key's private half.

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 in version 1.

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
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.

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 and recipient keys.

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 and envelope queues. Envelopes persist until deleted or expired. Defaults, all configurable: 1 MiB per envelope, 64 MiB per mailbox, 30-day retention, and per-IP connection and SEND rate limits.

There are no blocklists or allowlists at any server, because a server cannot see senders. Abuse control is quotas, size caps and rate limits; sender and content filtering belong 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.

A conforming server is a single binary over an embedded key-value store.

11. Domain separation labels

"smolmail/1"         Noise prologue        "smolmail/1 msg"     message signature
"smolmail/1 auth"    session auth          "smolmail/1 id"      message identifier
"smolmail/1 seal"    HKDF info             "smolmail/1 rotate"  rotation certificate

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.

13. Out of scope for version 1

Attachments. Group messaging — multiple recipients means multiple envelopes with no shared state. Federation and relaying. Anonymous routing. Multi-device synchronisation. Recipient-side forward secrecy. Key revocation. Metadata privacy against a network observer. Server push and notification.

14. Conformance

An implementation is conforming when it agrees byte for byte with the published test vectors: a fixed seed, its Ed25519 public key, its derived X25519 pair, an envelope sealed to a known recipient with a fixed ephemeral, and that envelope's message identifier. Those vectors are the interoperability test and SHOULD be the first artifact of any implementation.

Sizes follow from §5: an envelope is 69 bytes of header plus ciphertext plus a 16-byte tag; a payload is 109 bytes plus the body.

Every field in §5 has exactly one stated reason to exist. Any field proposed for a future version should be held to the same test.