smolmail/SPEC.md

229 lines
14 KiB
Markdown
Raw Normal View 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 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.
## 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.
**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 response MAY carry a UTF-8 reason string. Clients MUST NOT parse it.
## 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.