diff --git a/reticulum.md b/RNS.md similarity index 96% rename from reticulum.md rename to RNS.md index f268837..85ad86b 100644 --- a/reticulum.md +++ b/RNS.md @@ -1,6 +1,6 @@ # Smol Mail Protocol, version 1.2 -- Reticulum transport -**Status: proposal.** Nothing here is implemented. This document is an addendum: version 1.2 is version 1.1 plus these sections, and it edits nothing that came before. §1 to §12 refer to `SPEC.md`, whose sections are unchanged, and the numbering continues from there so this text can fold into that document without renumbering anything. +This document is an addendum: version 1.2 is version 1.1 plus these sections, and it edits nothing that came before. §1 to §12 refer to `SPEC.md`, whose sections are unchanged, and the numbering continues from there so this text can fold into that document without renumbering anything. A mailbox reachable over the Reticulum Network Stack needs no DNS name, no IP address and no fixed topology. It works over LoRa, packet radio, serial links, I2P or TCP, and it keeps working when the internet does not. @@ -10,6 +10,8 @@ A mailbox reachable over the Reticulum Network Stack needs no DNS name, no IP ad RNS facts cited below were read from Reticulum 1.5.4. Constants in that stack are version-dependent and some are undocumented, so an implementation MUST read them from the stack at runtime rather than compile them from this text, and SHOULD state the version it was tested against. A reader who finds a figure here contradicted by the stack SHOULD believe the stack. +[smolmail_rns.py](smolmail_rns.py) and [smolmaild_rns.py](smolmaild_rns.py) are a reference client and server for this transport, tested against Reticulum 1.5.4. + ## 13. Reticulum transport ### 13.1 Server destination diff --git a/SPEC.md b/SPEC.md index 98cecfb..86df93b 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1,4 +1,4 @@ -# Smol Mail Protocol, version 1.1 +# Smol Mail Protocol, version 1.2 A minimalist, decentral, end-to-end encrypted mail protocol. @@ -392,3 +392,7 @@ Abuse control is quotas, size caps and rate limits: per-IP connection and `SEND` ``` 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. + +## 13. Reticulum transport + +See [RNS.md](RNS.md), the addendum that added this transport in version 1.2. It edits nothing above and continues the numbering from here. diff --git a/smolmail_rns.py b/smolmail_rns.py new file mode 100644 index 0000000..6394422 --- /dev/null +++ b/smolmail_rns.py @@ -0,0 +1,1163 @@ +#!/usr/bin/env -S uv run --quiet --script +# /// script +# requires-python = ">=3.11" +# dependencies = ["rns>=0.7.3", "pynacl>=1.5"] +# /// +"""Smol Mail reference client -- Reticulum transport. + +Implements reticulum.md (protocol 1.2), the Reticulum-transport addendum to +SPEC.md's version 1.1. Identities, envelopes, message identifiers, operations, +rotation and accept tokens are exactly smolmail.py's (reticulum.md line 8); +only addressing, discovery and session authentication change. There is +nothing to pin here: a destination hash IS the server's key, so `trust` has +no counterpart and every RESOLVE is trusted exactly as much as the address it +travelled over (S13.4). --key and --db default to the same files smolmail.py +uses, since one master secret and one local store work over either transport +(S15). + + uv run smolmail_rns.py keygen + uv run smolmail_rns.py register alice@8f2c1d0a7b6e5f4c3d2a1b0e9f8c7d6a + uv run smolmail_rns.py send bob@1a2b3c4d5e6f7081920a1b2c3d4e5f60 --subject Hello + uv run smolmail_rns.py fetch +""" + +from __future__ import annotations + +import argparse +import base64 +import hashlib +import hmac +import os +import re +import sqlite3 +import struct +import sys +import threading +import time + +import nacl.bindings as sodium +import nacl.exceptions +import RNS +from nacl.signing import SigningKey, VerifyKey + +LABEL_AUTH, LABEL_SEAL = b"smolmail/1 auth", b"smolmail/1 seal" +LABEL_MSG, LABEL_ID, LABEL_ROTATE = b"smolmail/1 msg", b"smolmail/1 id", b"smolmail/1 rotate" +LABEL_IDENTITY, LABEL_ACCEPT = b"smolmail/1 identity", b"smolmail/1 accept" +LABEL_MAC, LABEL_REGISTER = b"smolmail/1 mac", b"smolmail/1 register" +LABEL_BIND = b"smolmail/1 bind" # S14: link binding, replaces the Noise handshake + +OP_AUTH, OP_RESOLVE, OP_SEND, OP_FETCH, OP_DELETE, OP_REGISTER = range(6) +STATUS = {0: "ok", 1: "malformed", 2: "bad version", 3: "unknown user", + 4: "auth required", 5: "auth failed", 6: "quota exceeded", 7: "too large", + 8: "rate limited", 9: "not permitted", 10: "internal error"} + +MAGIC, VERSION = b"SMOL", 1 +KEY_LEN, ID_LEN, SIG_LEN, CERT_LEN, TOKEN_LEN = 32, 32, 64, 200, 32 +PAYLOAD_HEADER = 45 # version 1 + sender 32 + time 8 + body_len 4 +ENVELOPE_HEADER = 69 # magic 4 + version 1 + to 32 + epk 32 +MAX_CHAIN, PAD_TO = 16, 1024 +FRONTMATTER_MAX, FRONTMATTER_KEYS = 4096, 64 +SEPARATORS = "._-" +MAX_SKEW = 86400 # SPEC.md S5.3, how far ahead of our clock a payload may be dated +TIER_MAIN, TIER_REQUESTS, FLAG_REQUESTS = 0, 1, 0x01 + +APP_NAME, ASPECT = "smolmail", "server" # the RNS.Destination a server announces (S13.1) +PATH = "smolmail/1" # the one request path; the type byte multiplexes operations (S13.5) + + +class SmolError(Exception): + """Anything the user should see as a message rather than a traceback.""" + + +# --- encoding --------------------------------------------------------------- + + +def b32(raw: bytes) -> str: + """RFC 4648 base32, lowercase and unpadded (SPEC.md S3).""" + return base64.b32encode(raw).decode("ascii").rstrip("=").lower() + + +def unb32(text: str) -> bytes: + text = text.strip().upper() + return base64.b32decode(text + "=" * (-len(text) % 8)) + + +def fingerprint(identity: bytes) -> str: + """First 20 characters of the base32 identity, in groups of four (SPEC.md S3).""" + s = b32(identity)[:20] + return " ".join(s[i : i + 4] for i in range(0, 20, 4)) + + +class Reader: + """Fail-closed reader; every parse raises rather than reading past the end.""" + + def __init__(self, buf: bytes) -> None: + self.buf, self.pos = buf, 0 + + def take(self, n: int) -> bytes: + if n < 0 or self.pos + n > len(self.buf): + raise SmolError("truncated response from server") + self.pos += n + return self.buf[self.pos - n : self.pos] + + def u8(self) -> int: + return self.take(1)[0] + + def u16(self) -> int: + return struct.unpack(">H", self.take(2))[0] + + def u32(self) -> int: + return struct.unpack(">I", self.take(4))[0] + + def i64(self) -> int: + return struct.unpack(">q", self.take(8))[0] + + def left(self) -> int: + return len(self.buf) - self.pos + + +# reticulum.md S13.2: scheme required in both forms, host is exactly a +# destination hash, no port. +ADDRESS = re.compile(r"^(?P[a-z0-9._-]{1,63})@(?P[0-9a-fA-F]+)$") + + +def valid_username(name: str) -> bool: + """SPEC.md S3: alphanumeric at both ends, never two separators in a row.""" + if name[0] in SEPARATORS or name[-1] in SEPARATORS: + return False + return not any(a in SEPARATORS and b in SEPARATORS for a, b in zip(name, name[1:])) + + +def dest_hex_len() -> int: + """The stack's own truncated-hash width, read at runtime rather than + assumed (reticulum.md's preamble): 32 hex characters at 128 bits.""" + return (RNS.Reticulum.TRUNCATED_HASHLENGTH // 8) * 2 + + +class Address: + """A `smol+rns://` address: short form resolved via the server, or + self-certifying with an embedded identity key (reticulum.md S13.2).""" + + SCHEME = "smol+rns://" + + def __init__(self, user: str, host: str, identity: bytes | None = None) -> None: + self.user, self.host, self.identity = user, host.lower(), identity + + @property + def host_bytes(self) -> bytes: + return bytes.fromhex(self.host) + + @classmethod + def parse(cls, text: str) -> "Address": + text = text.strip() + if not text.startswith(cls.SCHEME): + raise SmolError(f"{text!r} is not a smol+rns:// address; there is no bare " + f"user@host short form on this transport (reticulum.md S13.2)") + rest, identity = text[len(cls.SCHEME) :], None + if "/" in rest: + rest, key = rest.split("/", 1) + try: + identity = unb32(key) + except Exception as exc: + raise SmolError(f"{text}: undecodable key: {exc}") from None + if len(identity) != KEY_LEN: + raise SmolError(f"{text}: key is {len(identity)} bytes, expected {KEY_LEN}") + m = ADDRESS.match(rest) + if not m: + raise SmolError(f"{text!r} is not a valid address") + if not valid_username(m["user"]): + raise SmolError(f"{m['user']!r} must begin and end with a letter or digit " + f"and may not contain two separators in a row") + host = m["host"] + if len(host) != dest_hex_len(): + raise SmolError(f"{text}: host must be exactly {dest_hex_len()} hexadecimal " + f"characters, a Reticulum destination hash, got {len(host)}") + return cls(m["user"], host, identity) + + @property + def short(self) -> str: + return f"{self.user}@{self.host}" + + def uri(self, identity: bytes) -> str: + return f"{self.SCHEME}{self.short}/{b32(identity)}" + + +# --- identity and message cryptography (SPEC.md S2, S5) --------------------- + + +def hkdf_sha256(ikm: bytes, salt: bytes, info: bytes, length: int = 32) -> bytes: + """RFC 5869, small enough to carry inline rather than add a dependency for.""" + prk = hmac.new(salt, ikm, hashlib.sha256).digest() + out, block, counter = b"", b"", 1 + while len(out) < length: + block = hmac.new(prk, block + info + bytes([counter]), hashlib.sha256).digest() + out, counter = out + block, counter + 1 + return out[:length] + + +def identity_seed(master: bytes, index: int) -> bytes: + """SPEC.md S2. The Ed25519 seed at a rotation index.""" + return hkdf_sha256(master, b"", LABEL_IDENTITY + struct.pack(">I", index)) + + +def accept_key(master: bytes) -> bytes: + """SPEC.md S2. Independent of the rotation index, so tokens survive a rotation.""" + return hkdf_sha256(master, b"", LABEL_ACCEPT) + + +def accept_mac(token: bytes, mid: bytes) -> bytes: + """SPEC.md S5.8. What a sender attaches to SEND to reach the main tier.""" + return hmac.new(token, LABEL_MAC + mid, hashlib.sha256).digest() + + +def ed_to_x25519_pub(identity: bytes) -> bytes: + """SPEC.md S2, via libsodium's own conversion as the spec prefers.""" + try: + return sodium.crypto_sign_ed25519_pk_to_curve25519(identity) + except (nacl.exceptions.CryptoError, RuntimeError, ValueError) as exc: + raise SmolError(f"not a valid Ed25519 public key: {exc}") from None + + +def bind_h(destination_hash: bytes, link_id: bytes) -> bytes: + """reticulum.md S13.6: substitutes the Noise handshake hash in AUTH's signature.""" + return hashlib.sha256(LABEL_BIND + destination_hash + link_id).digest() + + +def bind_server_static(destination_hash: bytes) -> bytes: + """reticulum.md S13.6: substitutes the Noise static key in REGISTER's signature.""" + return hashlib.sha256(LABEL_BIND + destination_hash).digest() + + +class Identity: + """An Ed25519 keypair plus the X25519 keypair derived from it (SPEC.md S2).""" + + def __init__(self, seed: bytes) -> None: + if len(seed) != KEY_LEN: + raise SmolError(f"identity seed must be {KEY_LEN} bytes, got {len(seed)}") + self.seed = seed + self._signing = SigningKey(seed) + self.pk: bytes = bytes(self._signing.verify_key) + self.x_priv: bytes = sodium.crypto_sign_ed25519_sk_to_curve25519( + sodium.crypto_sign_seed_keypair(seed)[1]) + + def sign(self, message: bytes) -> bytes: + return self._signing.sign(message).signature + + +class Account: + """The master secret of SPEC.md S2 and everything derived from it. + + `keys` holds every index up to the current one: sealing is to a specific + key, so mail addressed to a superseded one is readable only with that key + (SPEC.md S7). Rotation advances the index, so none has to be archived. + """ + + def __init__(self, master: bytes, index: int) -> None: + if len(master) != KEY_LEN: + raise SmolError(f"master secret must be {KEY_LEN} bytes, got {len(master)}") + self.master, self.index = master, index + self.keys = [Identity(identity_seed(master, n)) for n in range(index + 1)] + self.me = self.keys[-1] + + def token_for(self, identity: bytes) -> bytes: + """SPEC.md S5.8. The accept token this account issues to one correspondent.""" + return hmac.new(accept_key(self.master), identity, hashlib.sha256).digest() + + +def verify_sig(identity: bytes, signature: bytes, message: bytes) -> bool: + try: + VerifyKey(identity).verify(message, signature) + return True + except (nacl.exceptions.BadSignatureError, ValueError): + return False + + +def agree(x_priv: bytes, x_pub: bytes) -> bytes: + """X25519 with SPEC.md S2's checks; libsodium rejects low-order points itself.""" + try: + shared = sodium.crypto_scalarmult(x_priv, x_pub) + except (RuntimeError, nacl.exceptions.CryptoError) as exc: + raise SmolError(f"rejected key agreement: {exc}") from None + if shared == bytes(KEY_LEN): + raise SmolError("rejected all-zero key agreement output") + return shared + + +def message_id(envelope: bytes) -> bytes: + """SPEC.md S5.4, derived from the envelope so no sender can choose it.""" + return hashlib.sha256(LABEL_ID + envelope).digest() + + +def seal(sender: Identity, recipient: bytes, body: bytes, when: int | None = None, + pad: bool = True) -> bytes: + """SPEC.md S5.2 and S5.3. Ephemeral-static, so the nonce is fixed and the + sender's long-term key never takes part in key agreement.""" + when = int(time.time()) if when is None else when + esk = os.urandom(KEY_LEN) + epk = sodium.crypto_scalarmult_base(esk) + key = hkdf_sha256(agree(esk, ed_to_x25519_pub(recipient)), epk + recipient, LABEL_SEAL) + del esk # best effort; CPython offers no way to wipe bytes + + header = bytes([VERSION]) + sender.pk + struct.pack(">qI", when, len(body)) + plaintext = header + body + sender.sign(LABEL_MSG + recipient + epk + header + body) + if pad: + plaintext += bytes(-len(plaintext) % PAD_TO) + aad = MAGIC + bytes([VERSION]) + recipient + epk + return aad + sodium.crypto_aead_chacha20poly1305_ietf_encrypt( + plaintext, aad, bytes(12), key) + + +def unseal(identities: list[Identity], envelope: bytes) -> dict: + """Inverse of seal(); raises unless signature and recipient both check out.""" + if len(envelope) < ENVELOPE_HEADER + 16: + raise SmolError("envelope too short") + if envelope[:4] != MAGIC: + raise SmolError("not a Smol Mail envelope") + if envelope[4] != VERSION: + raise SmolError(f"unsupported envelope version {envelope[4]}") + to, epk, sealed = envelope[5:37], envelope[37:69], envelope[69:] + + me = next((i for i in identities if i.pk == to), None) + if me is None: + raise SmolError(f"addressed to {b32(to)[:16]}…, not one of our keys") + key = hkdf_sha256(agree(me.x_priv, epk), epk + to, LABEL_SEAL) + try: + plaintext = sodium.crypto_aead_chacha20poly1305_ietf_decrypt( + sealed, envelope[:ENVELOPE_HEADER], bytes(12), key) + except nacl.exceptions.CryptoError: + raise SmolError("decryption failed: wrong key or corrupt envelope") from None + + r = Reader(plaintext) + if r.u8() != VERSION: + raise SmolError("unsupported payload version") + sender, when, body_len = r.take(KEY_LEN), r.i64(), r.u32() + if body_len > r.left(): + raise SmolError("payload body length exceeds the payload") + body, signature = r.take(body_len), r.take(SIG_LEN) # trailing bytes are padding + if not verify_sig(sender, signature, + LABEL_MSG + to + epk + plaintext[:PAYLOAD_HEADER] + body): + raise SmolError("signature does not verify") + if when > int(time.time()) + MAX_SKEW: + raise SmolError("payload is dated in the future") + return {"sender": sender, "time": when, "body": body, "id": message_id(envelope)} + + +# --- body frontmatter (SPEC.md S5.5) ----------------------------------------- + +FM_KEY = re.compile(r"^[A-Za-z0-9-]{1,64}$") + + +def parse_frontmatter(body: str) -> tuple[dict[str, str], str]: + """SPEC.md S5.5. A flat `Key: value` block, deliberately not YAML. + + Any malformed line invalidates the whole block, which is then returned as + ordinary body text: frontmatter fails closed toward display. Keys are + compared case-insensitively, so they are kept lowercased. + """ + if not body.startswith("---\n"): + return {}, body + lines = body.split("\n") + try: + close = lines.index("---", 1) + except ValueError: + return {}, body + block, rest = lines[1:close], "\n".join(lines[close + 1 :]) + if len(block) > FRONTMATTER_KEYS: + return {}, body + if sum(len(line.encode()) + 1 for line in block) > FRONTMATTER_MAX: + return {}, body + fields: dict[str, str] = {} + for line in block: + key, sep, value = line.partition(":") + if not sep or not FM_KEY.match(key): + return {}, body + fields.setdefault(key.lower(), value.strip()) # first occurrence wins + return fields, rest + + +def build_frontmatter(fields: list[tuple[str, str]], body: str) -> str: + """Emit a block only when needed, including to escape a body that + genuinely begins with `---` (SPEC.md S5.5).""" + if not fields and not body.startswith("---\n"): + return body + return "---\n" + "".join(f"{k}: {v}\n" for k, v in fields) + "---\n" + body + + +# --- local state -------------------------------------------------------------- +# Same schema as smolmail.py, minus server pinning: there is nothing to pin +# over Reticulum, the address is the server's key (S13.4). + +SCHEMA = """ +-- One row, always present, so every update is a plain UPDATE. +CREATE TABLE IF NOT EXISTS state ( + id INTEGER PRIMARY KEY CHECK (id = 1), + username TEXT, host TEXT, + rotations INTEGER NOT NULL DEFAULT 0, -- SPEC.md S2 rotation index + after_time INTEGER NOT NULL DEFAULT 0, -- SPEC.md S6.1 FETCH cursor + after_id BLOB NOT NULL DEFAULT x'', + sync_ok INTEGER NOT NULL DEFAULT 1); -- may we replace the server's set? +INSERT OR IGNORE INTO state (id) VALUES (1); +CREATE TABLE IF NOT EXISTS contacts ( + address TEXT PRIMARY KEY, identity BLOB NOT NULL, + verified INTEGER NOT NULL, -- 1 when the key came from a smol+rns:// address + seen_at INTEGER NOT NULL); +-- Correspondents admitted to this mailbox's main tier (SPEC.md S5.8). The +-- identity is frozen at acceptance because the token is derived from it: a +-- contact's later rotation must not change the token they already hold. +CREATE TABLE IF NOT EXISTS accepted ( + address TEXT PRIMARY KEY, identity BLOB NOT NULL, + active INTEGER NOT NULL, added_at INTEGER NOT NULL); +-- Accept tokens received from correspondents, filed under the address that +-- issued them: an address outlives the keys behind it, so a token keeps +-- working across the issuer's rotations (SPEC.md S5.8). +CREATE TABLE IF NOT EXISTS tokens ( + address TEXT PRIMARY KEY, token BLOB NOT NULL, seen_at INTEGER NOT NULL); +-- Every id ever fetched, so an envelope resent after we deleted it locally is +-- not stored again (SPEC.md S10). +CREATE TABLE IF NOT EXISTS seen (id BLOB PRIMARY KEY, at INTEGER NOT NULL); +-- Envelopes are stored sealed and opened on demand; no plaintext at rest. +CREATE TABLE IF NOT EXISTS inbox ( + id BLOB PRIMARY KEY, envelope BLOB NOT NULL, + received_at INTEGER NOT NULL, tier INTEGER NOT NULL); +CREATE TABLE IF NOT EXISTS sent ( + id BLOB PRIMARY KEY, recipient TEXT NOT NULL, + envelope BLOB NOT NULL, sent_at INTEGER NOT NULL); +""" + + +class Store: + def __init__(self, path: str) -> None: + self.db = sqlite3.connect(path) + self.db.executescript(SCHEMA) + self.db.commit() + + def all(self, sql: str, *args) -> list[tuple]: + return self.db.execute(sql, args).fetchall() + + def one(self, sql: str, *args) -> tuple | None: + return self.db.execute(sql, args).fetchone() + + def run(self, sql: str, *args) -> sqlite3.Cursor: + with self.db: + return self.db.execute(sql, args) + + def account(self) -> Address | None: + row = self.one("SELECT username, host FROM state WHERE id = 1") + return Address(row[0], row[1]) if row and row[0] else None + + def set_account(self, addr: Address) -> None: + self.run("UPDATE state SET username = ?, host = ? WHERE id = 1", addr.user, addr.host) + + def rotations(self) -> int: + return self.one("SELECT rotations FROM state WHERE id = 1")[0] + + def cursor(self) -> tuple[int, bytes]: + after_time, after_id = self.one("SELECT after_time, after_id FROM state WHERE id = 1") + return after_time, after_id if len(after_id) == ID_LEN else bytes(ID_LEN) + + def set_cursor(self, after_time: int, after_id: bytes) -> None: + self.run("UPDATE state SET after_time = ?, after_id = ? WHERE id = 1", + after_time, after_id) + + def contact(self, address: str) -> tuple[bytes, bool] | None: + row = self.one("SELECT identity, verified FROM contacts WHERE address = ?", address) + return (row[0], bool(row[1])) if row else None + + def save_contact(self, address: str, identity: bytes, verified: bool) -> None: + self.run("INSERT INTO contacts (address, identity, verified, seen_at) VALUES (?,?,?,?) " + "ON CONFLICT (address) DO UPDATE SET identity = ?2, verified = ?3, seen_at = ?4", + address, identity, int(verified), int(time.time())) + + def token_set(self, account: "Account") -> tuple[int, list[bytes]]: + """S4. The accept tokens to push with AUTH, and whether to push at all. + + A client that cannot vouch for its own set — one restored from the + master alone — must not replace the server's with an incomplete one. + """ + if not self.one("SELECT sync_ok FROM state WHERE id = 1")[0]: + return 0, [] + rows = self.all("SELECT identity FROM accepted WHERE active = 1 ORDER BY added_at") + return 1, [account.token_for(row[0]) for row in rows] + + def address_of(self, sender: bytes, reply_to: str | None) -> str | None: + """The address we know a signer by: a contact, or the Reply-To it + signed for itself. Naming a mailbox is not trusting a key, so nothing + is pinned here (SPEC.md S5.7, S8).""" + for address, identity in self.all("SELECT address, identity FROM contacts"): + if hmac.compare_digest(identity, sender): + return address + if reply_to: + try: + claimed = Address.parse(reply_to) + except SmolError: + return None + if claimed.identity and hmac.compare_digest(claimed.identity, sender): + return claimed.short + return None + + def learn_token(self, opened: dict) -> None: + """SPEC.md S5.8. An Accept field is bound to the signer of the message + that carried it, which unseal() has already verified.""" + fields, _ = parse_frontmatter(opened["body"].decode("utf-8", "replace")) + raw = fields.get("accept") + if not raw: + return + try: + token = unb32(raw) + except Exception: + return + if len(token) != TOKEN_LEN: + return + address = self.address_of(opened["sender"], fields.get("reply-to")) + if address is None: + return # no address to send to, so no use for a token + self.run("INSERT INTO tokens (address, token, seen_at) VALUES (?,?,?) " + "ON CONFLICT (address) DO UPDATE SET token = ?2, seen_at = ?3", + address, token, int(time.time())) + + def mail(self, box: str) -> list[tuple]: + if box == "sent": + return self.all("SELECT id, envelope, sent_at, recipient FROM sent " + "ORDER BY sent_at, id") + if box == "all": + return self.all("SELECT id, envelope, received_at, NULL FROM inbox " + "ORDER BY received_at, id") + tier = TIER_REQUESTS if box == "requests" else TIER_MAIN + return self.all("SELECT id, envelope, received_at, NULL FROM inbox " + "WHERE tier = ? ORDER BY received_at, id", tier) + + +# --- transport (reticulum.md S13.3-S13.6) ----------------------------------- + + +class Conn: + """A Reticulum link to a `smolmail.server` destination. + + reticulum.md S13.4: the destination hash IS the server's key, established + the moment Reticulum proves the link against that identity. There is + nothing left to pin and no separate handshake to verify. + """ + + def __init__(self, destination_hash: bytes, timeout: float) -> None: + self.destination_hash = destination_hash + self.timeout = timeout + if not RNS.Transport.has_path(destination_hash): + RNS.Transport.request_path(destination_hash) + deadline = time.monotonic() + RNS.Transport.PATH_REQUEST_TIMEOUT + while not RNS.Transport.has_path(destination_hash) and time.monotonic() < deadline: + time.sleep(0.1) + if not RNS.Transport.has_path(destination_hash): + raise SmolError(f"no path to {destination_hash.hex()}; it may be unreachable " + f"or has never announced (reticulum.md S13.1, S13.3)") + identity = RNS.Identity.recall(destination_hash) + if identity is None: + raise SmolError(f"path known but identity not yet learned for " + f"{destination_hash.hex()}; try again shortly") + destination = RNS.Destination( + identity, RNS.Destination.OUT, RNS.Destination.SINGLE, APP_NAME, ASPECT) + # S13.3: a link MUST NOT be created before a path is known, which is + # already the case here, so this establishes promptly rather than + # assuming the maximum hop count. + self.link = RNS.Link(destination) + ready, done = threading.Event(), threading.Event() + self.link.set_link_established_callback(lambda l: (ready.set(), done.set())) + self.link.set_link_closed_callback(lambda l: done.set()) + if not done.wait(timeout) or self.link.status != RNS.Link.ACTIVE: + raise SmolError(f"could not establish a link to {destination_hash.hex()}") + + @property + def link_id(self) -> bytes: + return self.link.link_id + + def call(self, op: int, body: bytes = b"") -> tuple[int, bytes]: + done = threading.Event() + outcome: dict = {} + + def on_response(receipt: RNS.RequestReceipt) -> None: + outcome["response"] = receipt.response + done.set() + + def on_failed(_receipt: RNS.RequestReceipt) -> None: + done.set() + + # S13.5: a client MUST set its own request timeout rather than rely on + # Reticulum's RTT-derived default, which covers a packet, not a + # response as large as a FETCH page. + receipt = self.link.request(PATH, bytes([op]) + body, response_callback=on_response, + failed_callback=on_failed, timeout=self.timeout) + if receipt is False: + raise SmolError("failed to send request over the link") + if not done.wait(self.timeout + 1): + raise SmolError("request timed out") + response = outcome.get("response") + # S13.5: no path, a closed link, a rejected resource and a timeout are + # local errors, not status codes, and must not be reported as one. + if response is None: + raise SmolError("request failed: no response (closed link, rejected transfer, " + "or timeout -- not a status code, S13.5)") + if not isinstance(response, (bytes, bytearray)) or len(response) < 1: + raise SmolError("malformed response from server") + return response[0], bytes(response[1:]) + + def close(self) -> None: + # S13.10: tear the link down when done rather than hold it open on + # keepalives, which run for minutes. + try: + self.link.teardown() + except Exception: + pass + + def __enter__(self) -> "Conn": + return self + + def __exit__(self, *exc) -> None: + self.close() + + +def connect(addr: Address, timeout: float) -> Conn: + return Conn(addr.host_bytes, timeout) + + +def expect_ok(status: int, what: str) -> None: + if status != 0: + raise SmolError(f"{what} failed: {STATUS.get(status, status)} ({status})") + + +# --- operations --------------------------------------------------------------- + + +def do_resolve(conn: Conn, user: str) -> tuple[bytes, list[bytes]]: + """RESOLVE, returning the current key and its rotation chain (SPEC.md S6.1).""" + name = user.encode() + status, body = conn.call(OP_RESOLVE, bytes([len(name)]) + name) + expect_ok(status, f"resolving {user}") + r = Reader(body) + identity = r.take(KEY_LEN) + return identity, [r.take(CERT_LEN) for _ in range(r.u8())] + + +def walk_chain(username: str, pinned: bytes, current: bytes, chain: list[bytes]) -> bool: + """SPEC.md S7. Accept a key change only when a signed chain leads from the + key we hold to the one the server now returns. Both keys must sign each + link.""" + if hmac.compare_digest(pinned, current): + return True + if not chain or len(chain) > MAX_CHAIN: + return False + key, started = pinned, False + for cert in chain: + old, new, when = cert[:32], cert[32:64], cert[64:72] + sig_old, sig_new = cert[72:136], cert[136:200] + if not started: + if old != key: + continue # a link predating the key we hold + started = True + elif old != key: + return False # the chain is not continuous + signed = LABEL_ROTATE + username.encode() + old + new + when + if not verify_sig(old, sig_old, signed) or not verify_sig(new, sig_new, signed): + return False + key = new + return started and hmac.compare_digest(key, current) + + +def resolve_key(store: Store, conn: Conn, addr: Address) -> bytes: + """Resolve a contact and apply SPEC.md S8's trust rules. + + Unlike smolmail.py there is no unpinned-session qualifier: reticulum.md + S13.4 makes every session here as authenticated as a pinned TCP one, since + the address dialled IS the server's key. + """ + identity, chain = do_resolve(conn, addr.user) + known = store.contact(addr.short) + if known is None: + store.save_contact(addr.short, identity, verified=False) + print(f"{addr.short} {b32(identity)}\n pinned (trust on first use)") + return identity + old, verified = known + if hmac.compare_digest(old, identity): + return identity + if walk_chain(addr.user, old, identity, chain): + store.save_contact(addr.short, identity, verified) + warn(f"{addr.short} rotated its key; a signed chain confirms it") + print(f" now {b32(identity)}") + return identity + raise SmolError(f"{addr.short} presents a different key with no valid rotation chain.\n" + f" known: {b32(old)}\n offered: {b32(identity)}\n" + f"Verify out of band, then: smolmail_rns.py import {addr.uri(identity)}") + + +def register_signed(destination_hash: bytes, username: str, identity: bytes) -> bytes: + """SPEC.md S6.1 proof of possession, bound (S13.6) to the destination that + will store the binding.""" + return LABEL_REGISTER + bind_server_static(destination_hash) + username.encode() + identity + + +def authenticate(conn: Conn, addr: Address, account: Account, store: Store) -> int: + """S13.6 session authentication: sign the link binding and push the token set.""" + name = addr.user.encode() + sync, tokens = store.token_set(account) + h = bind_h(conn.destination_hash, conn.link_id) + body = (bytes([len(name)]) + name + account.me.pk + + account.me.sign(LABEL_AUTH + h) + + bytes([sync]) + struct.pack(">H", len(tokens)) + b"".join(tokens)) + status, payload = conn.call(OP_AUTH, body) + expect_ok(status, "authentication") + return struct.unpack(">H", payload[:2])[0] if len(payload) >= 2 else 0 + + +def push_tokens(store: Store, account: Account, timeout: float) -> None: + """SPEC.md S5.8. An accept or a block only takes effect once the server + holds the changed set, so it is pushed now rather than at the next fetch.""" + addr = store.account() + if addr is None: + warn("not registered; the set will be pushed with your first fetch") + return + with connect(addr, timeout) as conn: + held = authenticate(conn, addr, account, store) + print(f"server now holds {held} accept token(s)") + + +# --- helpers ------------------------------------------------------------------ + + +def warn(message: str) -> None: + print(f"warning: {message}", file=sys.stderr) + + +def load_master(args: argparse.Namespace) -> bytes: + try: + with open(args.key, "rb") as fh: + return fh.read() + except FileNotFoundError: + raise SmolError(f"no identity at {args.key}; run: smolmail_rns.py keygen") from None + + +def load_account(args: argparse.Namespace, store: Store) -> Account: + """SPEC.md S2. The master from disk plus the rotation index from local state.""" + return Account(load_master(args), store.rotations()) + + +def write_secret(path: str, secret: bytes) -> None: + """0600 before any bytes land, so the secret is never briefly world-readable.""" + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "wb") as fh: + fh.write(secret) + + +def describe(store: Store, envelope: bytes, identities: list[Identity]) -> dict: + """Open an envelope and split its body into frontmatter and text.""" + opened = unseal(identities, envelope) + fields, text = parse_frontmatter(opened["body"].decode("utf-8", "replace")) + sender = opened["sender"] + known = next((a for a, i, _ in store.all( + "SELECT address, identity, verified FROM contacts") if hmac.compare_digest(i, sender)), None) + opened |= {"fields": fields, "text": text, "subject": fields.get("subject", ""), + "from": known or f"<{b32(sender)[:20]}…>"} + return opened + + +def target_contact(store: Store, text: str) -> tuple[str, bytes]: + """An address plus the key we hold for it, for commands naming a contact.""" + addr = Address.parse(text) + if addr.identity is not None: + return addr.short, addr.identity + known = store.contact(addr.short) + if known is None: + raise SmolError(f"no key for {addr.short}; run `resolve` or `import` first") + return addr.short, known[0] + + +# --- commands ------------------------------------------------------------- + + +def cmd_keygen(args: argparse.Namespace, store: Store) -> int: + if os.path.exists(args.key) and not args.force: + raise SmolError(f"{args.key} exists; refusing to overwrite (use --force)") + master = os.urandom(KEY_LEN) + write_secret(args.key, master) + account = Account(master, 0) + print(f"master: {args.key} (back this up; it is the only secret)") + print(f"public key: {b32(account.me.pk)}\nfingerprint: {fingerprint(account.me.pk)}") + return 0 + + +def cmd_whoami(args: argparse.Namespace, store: Store) -> int: + account = load_account(args, store) + print(f"public key: {b32(account.me.pk)}\nfingerprint: {fingerprint(account.me.pk)}") + addr = store.account() + print(f"address: {addr.short}\nuri: {addr.uri(account.me.pk)}" if addr + else "address: (not registered)") + if account.index: + print(f"rotations: {account.index} (earlier keys derived on demand)") + live = store.one("SELECT COUNT(*) FROM accepted WHERE active = 1")[0] + sync = store.one("SELECT sync_ok FROM state WHERE id = 1")[0] + print(f"accepted: {live} correspondent(s)" + + ("" if sync else ", not pushed to the server until rebuilt")) + return 0 + + +def cmd_register(args: argparse.Namespace, store: Store) -> int: + account, addr = load_account(args, store), Address.parse(args.address) + me = account.me + name, token = addr.user.encode(), (args.token or "").encode() + with connect(addr, args.timeout) as conn: + body = (bytes([len(name)]) + name + me.pk + + me.sign(register_signed(conn.destination_hash, addr.user, me.pk)) + + bytes([len(token)]) + token + b"\0") + status, _ = conn.call(OP_REGISTER, body) + expect_ok(status, f"registering {addr.short}") + store.set_account(addr) + print(f"registered {addr.short}\nshare: {addr.uri(me.pk)}") + return 0 + + +def cmd_resolve(args: argparse.Namespace, store: Store) -> int: + addr = Address.parse(args.address) + if addr.identity is not None: + raise SmolError("that address already carries a key; use `import` instead") + with connect(addr, args.timeout) as conn: + resolve_key(store, conn, addr) + return 0 + + +def cmd_import(args: argparse.Namespace, store: Store) -> int: + addr = Address.parse(args.uri) + if addr.identity is None: + raise SmolError("import needs a smol+rns:// address carrying a key") + store.save_contact(addr.short, addr.identity, verified=True) + print(f"imported {addr.short} {b32(addr.identity)} (verified)") + print(f"fingerprint: {fingerprint(addr.identity)}") + return 0 + + +def cmd_contacts(args: argparse.Namespace, store: Store) -> int: + rows = store.all("SELECT c.address, c.identity, c.verified, a.active FROM contacts c " + "LEFT JOIN accepted a ON a.address = c.address ORDER BY c.address") + if not rows: + print("no contacts") + return 0 + width = max(len(a) for a, _, _, _ in rows) + for address, identity, verified, active in rows: + state = "accepted" if active else ("blocked" if active == 0 else "") + print(f"{address:<{width}} {b32(identity)} " + f"{'verified' if verified else 'tofu':<8} {state}".rstrip()) + return 0 + + +def cmd_accept(args: argparse.Namespace, store: Store) -> int: + account = load_account(args, store) + address, identity = target_contact(store, args.address) + if not store.one("SELECT sync_ok FROM state WHERE id = 1")[0]: + warn("this client's accepted set was not restored; from now on it replaces " + "the server's, so re-accept everyone you still correspond with") + # ON CONFLICT leaves `identity` alone: the token stays the one the + # correspondent already holds, even after they rotate (SPEC.md S5.8). + store.run("INSERT INTO accepted (address, identity, active, added_at) VALUES (?,?,1,?) " + "ON CONFLICT (address) DO UPDATE SET active = 1", address, identity, + int(time.time())) + store.run("UPDATE state SET sync_ok = 1 WHERE id = 1") + print(f"accepted {address}; its token travels in your next message to them") + push_tokens(store, account, args.timeout) + return 0 + + +def cmd_block(args: argparse.Namespace, store: Store) -> int: + account = load_account(args, store) + address, _ = target_contact(store, args.address) + if not store.run("UPDATE accepted SET active = 0 WHERE address = ?", address).rowcount: + raise SmolError(f"{address} was never accepted") + print(f"blocked {address}; their mail lands in requests from their next message on") + push_tokens(store, account, args.timeout) + return 0 + + +def cmd_send(args: argparse.Namespace, store: Store) -> int: + account, addr = load_account(args, store), Address.parse(args.address) + me = account.me + if args.body is not None: + text = args.body + elif sys.stdin.isatty(): + raise SmolError("no message body; pass --body or pipe it on stdin") + else: + text = sys.stdin.read() + + # Prefer a key we already trust; fall back to RESOLVE with trust on first use. + if addr.identity is not None: + recipient = addr.identity + store.save_contact(addr.short, recipient, verified=True) + elif (known := store.contact(addr.short)) is not None: + recipient = known[0] + else: + with connect(addr, args.timeout) as conn: + recipient = resolve_key(store, conn, addr) + + fields = [(k, v) for k, v in (("Subject", args.subject), ("In-Reply-To", args.reply_to)) if v] + for raw in args.header or []: + key, sep, value = raw.partition(":") + if not sep or not FM_KEY.match(key.strip()): + raise SmolError(f"{raw!r} is not a valid `Key: value` header") + fields.append((key.strip(), value.strip())) + # SPEC.md S5.7: a signed reply address lets a first-time recipient answer us. + if (account_addr := store.account()) is not None and not args.anonymous: + fields.append(("Reply-To", account_addr.uri(me.pk))) + # SPEC.md S5.8: hand an accepted correspondent the token for our own mailbox. + if (row := store.one("SELECT identity FROM accepted WHERE address = ? AND active = 1", + addr.short)) is not None: + fields.append(("Accept", b32(account.token_for(row[0])))) + + body = build_frontmatter(fields, text).encode() + envelope = seal(me, recipient, body, pad=not args.no_pad) + mid = message_id(envelope) + # SPEC.md S5.8: our token for their mailbox, if they have given us one. + held = store.one("SELECT token FROM tokens WHERE address = ?", addr.short) + mac = accept_mac(held[0], mid) if held else b"" + with connect(addr, args.timeout) as conn: + status, payload = conn.call(OP_SEND, bytes([len(mac)]) + mac + envelope) + expect_ok(status, f"sending to {addr.short}") + if payload and payload != mid: + warn("server returned an id we did not derive; it is not authoritative") + # SPEC.md S5.6: the ephemeral is gone, so keep a copy sealed to ourselves. + store.run("INSERT OR IGNORE INTO sent (id, recipient, envelope, sent_at) VALUES (?,?,?,?)", + mid, addr.short, seal(me, me.pk, body, pad=not args.no_pad), int(time.time())) + print(f"sent {mid.hex()[:16]} to {addr.short} ({len(envelope)} bytes" + + (", accepted" if mac else "") + ")") + return 0 + + +def cmd_fetch(args: argparse.Namespace, store: Store) -> int: + account = load_account(args, store) + addr = store.account() + if addr is None: + raise SmolError("not registered; run: smolmail_rns.py register ") + if args.reset: + store.set_cursor(0, bytes(ID_LEN)) + # Acknowledging deletes what it takes, so it always pages from the start + # and leaves the stored cursor at zero; --keep instead remembers its place. + after_time, after_id = store.cursor() if args.keep else (0, bytes(ID_LEN)) + stored = rejected = total = 0 + + with connect(addr, args.timeout) as conn: + authenticate(conn, addr, account, store) + while True: + status, body = conn.call(OP_FETCH, struct.pack(">q", after_time) + after_id) + expect_ok(status, "fetching") + r = Reader(body) + count = r.u16() + if count == 0: + break + acked = [] + for _ in range(count): + total += 1 + mid, received_at, flags = r.take(ID_LEN), r.i64(), r.u8() + envelope = r.take(r.u32()) + after_time, after_id = received_at, mid + try: + if message_id(envelope) != mid: + raise SmolError("id does not match the envelope") + opened = unseal(account.keys, envelope) + except SmolError as exc: + # Left on the server rather than destroyed, so a + # client-side bug cannot lose mail. + warn(f"{mid.hex()[:16]}: {exc}; left on server") + rejected += 1 + continue + # A message we have already had once is not stored again, even + # if we deleted it locally in the meantime (SPEC.md S10). + if store.one("SELECT 1 FROM seen WHERE id = ?", mid) is None: + tier = TIER_REQUESTS if flags & FLAG_REQUESTS else TIER_MAIN + store.run("INSERT OR IGNORE INTO inbox " + "(id, envelope, received_at, tier) VALUES (?,?,?,?)", + mid, envelope, received_at, tier) + store.run("INSERT OR IGNORE INTO seen (id, at) VALUES (?,?)", + mid, received_at) + store.learn_token(opened) + stored += 1 + acked.append(mid) + if args.keep: + store.set_cursor(after_time, after_id) + elif acked: + status, _ = conn.call(OP_DELETE, + struct.pack(">H", len(acked)) + b"".join(acked)) + expect_ok(status, "acknowledging") + + if not args.keep: + store.set_cursor(0, bytes(ID_LEN)) + print(f"{total} message(s): {stored} new, {rejected} rejected" + + (" (left on the server)" if args.keep and total else "")) + return 0 + + +def cmd_list(args: argparse.Namespace, store: Store) -> int: + account = load_account(args, store) + box = "sent" if args.sent else "requests" if args.requests else "inbox" + rows = store.mail(box) + if not rows: + print(f"{box} is empty") + return 0 + for mid, envelope, when, recipient in rows: + try: + opened = describe(store, envelope, account.keys) + who = recipient if box == "sent" else opened["from"] + subject = opened["subject"] or "(no subject)" + except SmolError as exc: + who, subject = "?", f"" + stamp = time.strftime("%Y-%m-%d %H:%M", time.localtime(when)) + print(f"{mid.hex()[:8]} {stamp} {who:<28.28} {subject}") + return 0 + + +def cmd_read(args: argparse.Namespace, store: Store) -> int: + account = load_account(args, store) + box = "sent" if args.sent else "all" + matches = [r for r in store.mail(box) if r[0].hex().startswith(args.id.lower())] + if not matches: + raise SmolError(f"no {'sent ' if args.sent else ''}message matching {args.id!r}") + if len(matches) > 1: + raise SmolError(f"{args.id!r} matches {len(matches)} messages; be more specific") + mid, envelope, _, recipient = matches[0] + opened = describe(store, envelope, account.keys) + def field(key: str, value: str) -> None: + print(f"{key + ':':<12} {value}") + + field("id", mid.hex()) + if box == "sent": + field("to", recipient) + field("from", opened["from"]) + field("key", b32(opened["sender"])) + field("date", time.strftime("%Y-%m-%d %H:%M:%S %z", time.localtime(opened["time"]))) + for key, value in opened["fields"].items(): + if key != "accept": # machinery, not content (SPEC.md S5.8) + field(key, value) + print("signature verified\n") + print(opened["text"], end="" if opened["text"].endswith("\n") else "\n") + return 0 + + +def cmd_rotate(args: argparse.Namespace, store: Store) -> int: + account = load_account(args, store) + addr = store.account() + if addr is None: + raise SmolError("not registered; run: smolmail_rns.py register ") + if account.index >= MAX_CHAIN: + raise SmolError(f"the rotation chain is full at {MAX_CHAIN} links") + old = account.me + new = Identity(identity_seed(account.master, account.index + 1)) + when = struct.pack(">q", int(time.time())) + # SPEC.md S7: both keys sign, so the old key alone cannot hand the + # username to a key nobody controls. + signed = LABEL_ROTATE + addr.user.encode() + old.pk + new.pk + when + cert = old.pk + new.pk + when + old.sign(signed) + new.sign(signed) + + name = addr.user.encode() + with connect(addr, args.timeout) as conn: + body = (bytes([len(name)]) + name + new.pk + + new.sign(register_signed(conn.destination_hash, addr.user, new.pk)) + + b"\0" + bytes([len(cert)]) + cert) + status, _ = conn.call(OP_REGISTER, body) + expect_ok(status, "rotating") + + # The master is untouched; only the index moves, and the superseded key + # stays derivable from it (SPEC.md S2). + store.run("UPDATE state SET rotations = ? WHERE id = 1", account.index + 1) + print(f"rotated {addr.short}\nnew key: {b32(new.pk)}") + print(f"fingerprint: {fingerprint(new.pk)}\nshare: {addr.uri(new.pk)}") + print("\nTell your contacts; they will accept the change from the signed chain.") + return 0 + + +def cmd_restore(args: argparse.Namespace, store: Store) -> int: + """SPEC.md S2. Recover the rotation index, and with it every superseded + key, from the master alone.""" + master = load_master(args) + addr = Address.parse(args.address) + with connect(addr, args.timeout) as conn: + identity, _ = do_resolve(conn, addr.user) + for index in range(MAX_CHAIN + 1): + if Identity(identity_seed(master, index)).pk == identity: + break + else: + raise SmolError(f"the key bound to {addr.short} is not derived from this master " + f"within {MAX_CHAIN} rotations") + store.set_account(addr) + store.set_cursor(0, bytes(ID_LEN)) + # The accepted set is gone, and an empty one must not replace the + # server's (S4). + store.run("UPDATE state SET rotations = ?, sync_ok = 0 WHERE id = 1", index) + print(f"restored {addr.short} at rotation {index}\npublic key: {b32(identity)}") + print("Your accepted correspondents are still live on the server; `accept` them " + "again only when you are ready to replace that set.") + return 0 + + +# --- CLI -------------------------------------------------------------------- + +COMMANDS = [ + ("keygen", cmd_keygen, "create a master secret", + [(("--force",), {"action": "store_true"})]), + ("whoami", cmd_whoami, "show this identity", []), + ("register", cmd_register, "bind this identity to a username", + [(("address",), {}), (("--token",), {"help": "invite token, if required"})]), + ("restore", cmd_restore, "recover local state from the master", + [(("address",), {})]), + ("resolve", cmd_resolve, "look up and pin a contact's key", [(("address",), {})]), + ("import", cmd_import, "add a contact from a smol+rns:// address", [(("uri",), {})]), + ("contacts", cmd_contacts, "list known keys", []), + ("accept", cmd_accept, "admit a contact to the main tier", [(("address",), {})]), + ("block", cmd_block, "withdraw a contact's accept token", [(("address",), {})]), + ("send", cmd_send, "seal and deliver a message", + [(("address",), {}), (("--subject",), {}), + (("--reply-to",), {"metavar": "ID"}), + (("--body",), {"help": "message text; read from stdin when omitted"}), + (("--header",), {"action": "append", "metavar": "KEY:VALUE", + "help": "extra frontmatter field; repeatable"}), + (("--anonymous",), {"action": "store_true", + "help": "omit the Reply-To field carrying this address (SPEC.md S5.7)"}), + (("--no-pad",), {"action": "store_true", "help": "do not pad to 1 KiB"})]), + ("fetch", cmd_fetch, "retrieve, verify and acknowledge mail", + [(("--keep",), {"action": "store_true", + "help": "do not delete from the server; remember the cursor instead"}), + (("--reset",), {"action": "store_true", "help": "forget the cursor and page again"})]), + ("list", cmd_list, "list stored mail", + [(("--sent",), {"action": "store_true"}), + (("--requests",), {"action": "store_true", + "help": "mail that arrived without an accept token"})]), + ("read", cmd_read, "show one message", + [(("id",), {}), (("--sent",), {"action": "store_true"})]), + ("rotate", cmd_rotate, "advance to the next identity, signing the change", []), +] + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--key", default="identity.key", help="master secret file") + parser.add_argument("--db", default="smolmail.db", help="local state and mail") + parser.add_argument("--config", default=None, metavar="DIR", + help="Reticulum config directory (default: the usual RNS location)") + parser.add_argument("--timeout", type=float, default=30.0, metavar="SECONDS", + help="request timeout; a client MUST set one (reticulum.md S13.5)") + parser.add_argument("-v", "--verbose", action="store_true") + sub = parser.add_subparsers(dest="command", required=True) + for name, func, help_text, arguments in COMMANDS: + child = sub.add_parser(name, help=help_text) + child.set_defaults(func=func) + for flags, options in arguments: + child.add_argument(*flags, **options) + + args = parser.parse_args(argv) + # Only start the Reticulum stack for commands that actually talk to a + # server; local-only commands stay usable offline and start instantly. + NETWORKED = {"register", "resolve", "send", "fetch", "rotate", "restore", "accept", "block"} + if args.command in NETWORKED: + RNS.Reticulum(configdir=args.config, loglevel=RNS.LOG_DEBUG if args.verbose else RNS.LOG_ERROR) + try: + return args.func(args, Store(args.db)) + except SmolError as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + except KeyboardInterrupt: + return 130 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/smolmaild_rns.py b/smolmaild_rns.py new file mode 100644 index 0000000..f45fcf5 --- /dev/null +++ b/smolmaild_rns.py @@ -0,0 +1,850 @@ +#!/usr/bin/env -S uv run --quiet --script +# /// script +# requires-python = ">=3.11" +# dependencies = ["rns>=0.7.3", "cryptography>=42"] +# /// +"""Smol Mail reference server -- Reticulum transport. + +Implements reticulum.md (protocol 1.2), the Reticulum-transport addendum to +SPEC.md's version 1.1. It is the same store-and-forward mailbox as +smolmaild.py -- the same envelopes, operations, quotas and status codes -- +reached over the Reticulum Network Stack instead of TCP and Noise. Only +session authentication changes: AUTH and REGISTER sign a value derived from +the Reticulum link, in place of the Noise handshake hash and static key +(reticulum.md S13.6). A server MAY point --db at the same file a smolmaild.py +instance uses; the two transports share one store (S15). + + uv run smolmaild_rns.py keygen --key server.rns.key + uv run smolmaild_rns.py serve --key server.rns.key --db mail.db +""" + +from __future__ import annotations + +import argparse +import hashlib +import hmac +import logging +import os +import sqlite3 +import struct +import sys +import threading +import time + +import RNS +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey + +log = logging.getLogger("smolmaild-rns") + +# --------------------------------------------------------------------------- +# Protocol constants (SPEC.md S6, S11, S12; reticulum.md S13, S14) +# --------------------------------------------------------------------------- + +APP_NAME, ASPECT = "smolmail", "server" # RNS.Destination(identity, IN, SINGLE, ...) (S13.1) +PATH = "smolmail/1" # the one request path; the type byte multiplexes operations (S13.5) + +LABEL_AUTH = b"smolmail/1 auth" +LABEL_ID = b"smolmail/1 id" +LABEL_MAC = b"smolmail/1 mac" +LABEL_ROTATE = b"smolmail/1 rotate" +LABEL_REGISTER = b"smolmail/1 register" +LABEL_BIND = b"smolmail/1 bind" # S14: link binding, replaces the Noise handshake + +OP_AUTH = 0x00 +OP_RESOLVE = 0x01 +OP_SEND = 0x02 +OP_FETCH = 0x03 +OP_DELETE = 0x04 +OP_REGISTER = 0x05 + +OK = 0 +MALFORMED = 1 +BAD_VERSION = 2 +UNKNOWN_USER = 3 +AUTH_REQUIRED = 4 +AUTH_FAILED = 5 +QUOTA_EXCEEDED = 6 +TOO_LARGE = 7 +RATE_LIMITED = 8 +NOT_PERMITTED = 9 +INTERNAL_ERROR = 10 + +ENVELOPE_MAGIC = b"SMOL" +ENVELOPE_VERSION = 1 +ENVELOPE_HEADER = 69 # magic 4 + version 1 + to 32 + epk 32 +ENVELOPE_MIN = ENVELOPE_HEADER + 16 # + Poly1305 tag +ID_LEN = 32 +KEY_LEN = 32 +SIG_LEN = 64 +TOKEN_LEN = 32 # S5.8 accept token, and the MAC derived from it +CERT_LEN = 200 # old_pub 32 + new_pub 32 + time 8 + two signatures +MAX_CHAIN = 16 +SEPARATORS = "._-" + +TIER_MAIN = 0 +TIER_REQUESTS = 1 +FLAG_REQUESTS = 0x01 # S6.1 record flags + +RECORD_OVERHEAD = ID_LEN + 8 + 1 + 4 # id + received_at + flags + env_len +PURGE_INTERVAL = 60.0 + + +class ProtocolError(Exception): + """A peer sent something unparseable. Always answered with MALFORMED.""" + + +# --------------------------------------------------------------------------- +# Encoding helpers +# --------------------------------------------------------------------------- + + +class Reader: + """Fail-closed reader over a request body. + + Every parse path raises rather than reading past the end, so a truncated + request can never be mistaken for a short but valid one. + """ + + def __init__(self, buf: bytes) -> None: + self.buf = buf + self.pos = 0 + + def take(self, n: int) -> bytes: + if n < 0 or self.pos + n > len(self.buf): + raise ProtocolError(f"short read: want {n}, have {len(self.buf) - self.pos}") + out = self.buf[self.pos : self.pos + n] + self.pos += n + return out + + def u8(self) -> int: + return self.take(1)[0] + + def u16(self) -> int: + return struct.unpack(">H", self.take(2))[0] + + def i64(self) -> int: + return struct.unpack(">q", self.take(8))[0] + + def rest(self) -> bytes: + return self.take(len(self.buf) - self.pos) + + def done(self) -> None: + if self.pos != len(self.buf): + raise ProtocolError(f"{len(self.buf) - self.pos} trailing bytes") + + +def valid_username(name: str) -> bool: + """SPEC.md S3: 1-63 bytes of [a-z0-9._-], alphanumeric ends, no adjacent separators.""" + if not 1 <= len(name) <= 63: + return False + if not all("0" <= c <= "9" or "a" <= c <= "z" or c in SEPARATORS for c in name): + return False + if name[0] in SEPARATORS or name[-1] in SEPARATORS: + return False + return not any(a in SEPARATORS and b in SEPARATORS for a, b in zip(name, name[1:])) + + +def message_id(envelope: bytes) -> bytes: + """SPEC.md S5.4. Derived from the envelope, so a sender cannot choose it.""" + return hashlib.sha256(LABEL_ID + envelope).digest() + + +def verify(pubkey: bytes, signature: bytes, message: bytes) -> bool: + try: + Ed25519PublicKey.from_public_bytes(pubkey).verify(signature, message) + return True + except (InvalidSignature, ValueError): + return False + + +def bind_h(destination_hash: bytes, link_id: bytes) -> bytes: + """reticulum.md S13.6: substitutes the Noise handshake hash in AUTH's signature.""" + return hashlib.sha256(LABEL_BIND + destination_hash + link_id).digest() + + +def bind_server_static(destination_hash: bytes) -> bytes: + """reticulum.md S13.6: substitutes the Noise static key in REGISTER's signature.""" + return hashlib.sha256(LABEL_BIND + destination_hash).digest() + + +def max_request_bytes(max_envelope: int, max_accepted: int) -> int: + """S13.5: the greater of an envelope plus 34 bytes and a full-token AUTH.""" + send_max = 1 + 1 + TOKEN_LEN + max_envelope # type + mac_len + mac + envelope + auth_max = 1 + 1 + 63 + KEY_LEN + SIG_LEN + 1 + 2 + TOKEN_LEN * max_accepted + return max(send_max, auth_max) + + +# --------------------------------------------------------------------------- +# Storage (unchanged from smolmaild.py; a server MAY share this file across +# both transports, S15) +# --------------------------------------------------------------------------- + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS users ( + username TEXT PRIMARY KEY, + identity BLOB NOT NULL +); +-- Every key ever bound to a username, so a superseded key stays addressable +-- across a rotation (SPEC.md S7). +CREATE TABLE IF NOT EXISTS keys ( + identity BLOB PRIMARY KEY, + username TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS rotations ( + username TEXT NOT NULL, + seq INTEGER NOT NULL, + cert BLOB NOT NULL, + PRIMARY KEY (username, seq) +); +-- Accept tokens (SPEC.md S5.8): opaque secrets the mailbox owner uploads with +-- AUTH. The server can match them against a message's MAC and nothing else; +-- it never learns which correspondent a token belongs to. +CREATE TABLE IF NOT EXISTS accepted ( + username TEXT NOT NULL, + token BLOB NOT NULL, + PRIMARY KEY (username, token) +); +CREATE TABLE IF NOT EXISTS messages ( + id BLOB PRIMARY KEY, + recipient BLOB NOT NULL, + received_at INTEGER NOT NULL, + tier INTEGER NOT NULL, + envelope BLOB NOT NULL +); +-- (received_at, id) is FETCH's sort and cursor order (SPEC.md S6.1). +CREATE INDEX IF NOT EXISTS messages_by_recipient + ON messages (recipient, received_at, id); +-- Identifiers of every envelope accepted within the retention window, so an +-- envelope captured and resent after DELETE does not reappear (SPEC.md S10). +CREATE TABLE IF NOT EXISTS seen ( + id BLOB PRIMARY KEY, + at INTEGER NOT NULL +); +""" + + +class Store: + """SQLite behind a connection per thread. + + RNS may call a request handler from more than one internal thread (packet + reception, resource assembly), and sqlite3 connections are not shareable + across threads, so each thread gets its own via thread-local state. + """ + + def __init__(self, path: str) -> None: + self.path = path + self.local = threading.local() + with sqlite3.connect(path) as db: + db.execute("PRAGMA journal_mode=WAL") + db.executescript(SCHEMA) + + @property + def db(self) -> sqlite3.Connection: + conn = getattr(self.local, "conn", None) + if conn is None: + conn = sqlite3.connect(self.path, timeout=10.0) + conn.execute("PRAGMA busy_timeout=10000") + self.local.conn = conn + return conn + + def identity_of(self, username: str) -> bytes | None: + row = self.db.execute( + "SELECT identity FROM users WHERE username = ?", (username,) + ).fetchone() + return row[0] if row else None + + def chain(self, username: str) -> list[bytes]: + return [ + row[0] + for row in self.db.execute( + "SELECT cert FROM rotations WHERE username = ? ORDER BY seq", (username,) + ) + ] + + def keys_of(self, username: str) -> list[bytes]: + return [ + row[0] + for row in self.db.execute( + "SELECT identity FROM keys WHERE username = ?", (username,) + ) + ] + + def username_for_key(self, identity: bytes) -> str | None: + row = self.db.execute( + "SELECT username FROM keys WHERE identity = ?", (identity,) + ).fetchone() + return row[0] if row else None + + def register(self, username: str, identity: bytes) -> bool: + try: + with self.db: + self.db.execute( + "INSERT INTO users (username, identity) VALUES (?, ?)", + (username, identity), + ) + self.db.execute( + "INSERT INTO keys (identity, username) VALUES (?, ?)", + (identity, username), + ) + return True + except sqlite3.IntegrityError: + # Username taken, or this key is already bound to another username. + return False + + def rotate(self, username: str, new_key: bytes, cert: bytes, seq: int) -> bool: + try: + with self.db: + self.db.execute( + "UPDATE users SET identity = ? WHERE username = ?", + (new_key, username), + ) + self.db.execute( + "INSERT INTO keys (identity, username) VALUES (?, ?)", + (new_key, username), + ) + self.db.execute( + "INSERT INTO rotations (username, seq, cert) VALUES (?, ?, ?)", + (username, seq, cert), + ) + return True + except sqlite3.IntegrityError: + return False + + def accepted_tokens(self, username: str) -> list[bytes]: + return [ + row[0] + for row in self.db.execute( + "SELECT token FROM accepted WHERE username = ?", (username,) + ) + ] + + def set_accepted(self, username: str, tokens: list[bytes]) -> int: + """S4: an AUTH with sync = 1 replaces the whole set, which is how a + token is both added and removed.""" + with self.db: + self.db.execute("DELETE FROM accepted WHERE username = ?", (username,)) + self.db.executemany( + "INSERT OR IGNORE INTO accepted (username, token) VALUES (?, ?)", + [(username, token) for token in tokens], + ) + return self.count_accepted(username) + + def count_accepted(self, username: str) -> int: + return self.db.execute( + "SELECT COUNT(*) FROM accepted WHERE username = ?", (username,) + ).fetchone()[0] + + def tombstoned(self, mid: bytes) -> bool: + return ( + self.db.execute("SELECT 1 FROM seen WHERE id = ?", (mid,)).fetchone() + is not None + ) + + def mailbox_bytes(self, keys: list[bytes], tier: int) -> int: + marks = ",".join("?" * len(keys)) + row = self.db.execute( + f"SELECT COALESCE(SUM(LENGTH(envelope)), 0) FROM messages " + f"WHERE tier = ? AND recipient IN ({marks})", + [tier] + keys, + ).fetchone() + return row[0] + + def store_message(self, mid: bytes, recipient: bytes, tier: int, envelope: bytes) -> None: + with self.db: + now = int(time.time()) + self.db.execute( + "INSERT OR IGNORE INTO messages " + "(id, recipient, received_at, tier, envelope) VALUES (?, ?, ?, ?, ?)", + (mid, recipient, now, tier, envelope), + ) + self.db.execute( + "INSERT OR IGNORE INTO seen (id, at) VALUES (?, ?)", (mid, now) + ) + + def pending( + self, keys: list[bytes], after_time: int, after_id: bytes, budget: int + ) -> list[tuple[bytes, int, int, bytes]]: + marks = ",".join("?" * len(keys)) + rows = self.db.execute( + f"SELECT id, received_at, tier, envelope FROM messages " + f"WHERE recipient IN ({marks}) " + f"AND (received_at > ? OR (received_at = ? AND id > ?)) " + f"ORDER BY received_at, id", + keys + [after_time, after_time, after_id], + ) + out: list[tuple[bytes, int, int, bytes]] = [] + used = 0 + for mid, received_at, tier, envelope in rows: + size = RECORD_OVERHEAD + len(envelope) + # Always return at least one record, even if it alone exceeds the + # budget, so an oversized envelope cannot wedge a mailbox shut. + if out and used + size > budget: + break + out.append((mid, received_at, tier, envelope)) + used += size + return out + + def delete(self, keys: list[bytes], ids: list[bytes]) -> int: + kmarks = ",".join("?" * len(keys)) + imarks = ",".join("?" * len(ids)) + with self.db: + cur = self.db.execute( + f"DELETE FROM messages WHERE id IN ({imarks}) " + f"AND recipient IN ({kmarks})", + ids + keys, + ) + return cur.rowcount + + def purge(self, main_cutoff: int, requests_cutoff: int, seen_cutoff: int) -> int: + with self.db: + cur = self.db.execute( + "DELETE FROM messages WHERE (tier = ? AND received_at < ?) " + "OR (tier = ? AND received_at < ?)", + (TIER_MAIN, main_cutoff, TIER_REQUESTS, requests_cutoff), + ) + gone = cur.rowcount + self.db.execute("DELETE FROM seen WHERE at < ?", (seen_cutoff,)) + return gone + + +# --------------------------------------------------------------------------- +# Rate limiting (S13.8: no per-IP handle exists, so limits key on the link and +# on the accept token instead) +# --------------------------------------------------------------------------- + + +class RateLimiter: + """Fixed-window counter, keyed by link id, or by accept token for SEND.""" + + def __init__(self, limit: int, window: float = 60.0) -> None: + self.limit = limit + self.window = window + self.lock = threading.Lock() + self.hits: dict[str, tuple[float, int]] = {} + + def allow(self, key: str, cost: int = 1) -> bool: + if self.limit <= 0: + return True + now = time.monotonic() + with self.lock: + start, count = self.hits.get(key, (now, 0)) + if now - start >= self.window: + start, count = now, 0 + if count + cost > self.limit: + return False + self.hits[key] = (start, count + cost) + if len(self.hits) > 4096: + self._evict(now) + return True + + def _evict(self, now: float) -> None: + stale = [key for key, (start, _) in self.hits.items() if now - start >= self.window] + for key in stale: + del self.hits[key] + + +# --------------------------------------------------------------------------- +# Server (S13) +# --------------------------------------------------------------------------- + + +class MailServer: + """Owns the RNS destination and the per-link session state it requires. + + Session state is a plain dict keyed by link id (S13.6): a link is the + session, and there is no other handle to key it on -- S13.8 is explicit + that a server must not ask for one. + """ + + def __init__(self, identity: RNS.Identity, args: argparse.Namespace) -> None: + self.store = Store(args.db) + self.max_envelope = args.max_envelope + self.fetch_budget = args.fetch_budget + self.quota = args.quota + self.requests_quota = args.requests_quota + self.retention = args.retention_days * 86400 + self.requests_retention = args.requests_retention_days * 86400 + self.max_accepted = args.max_accepted + self.invite_token = args.invite_token.encode() if args.invite_token else None + self.max_links = args.max_links + + self.sessions_lock = threading.Lock() + self.sessions: dict[bytes, str] = {} # link_id -> authenticated username + + self.link_request_limiter = RateLimiter(args.rate_link_requests) + self.link_byte_limiter = RateLimiter(args.rate_link_bytes) + self.send_limiter = RateLimiter(args.rate_sends) # global ceiling, not per-peer + self.token_limiter = RateLimiter(args.rate_token) # per accept token; the main defence + + self.destination = RNS.Destination( + identity, RNS.Destination.IN, RNS.Destination.SINGLE, APP_NAME, ASPECT + ) + self.destination.set_max_request_size(max_request_bytes(self.max_envelope, self.max_accepted)) + self.destination.set_link_established_callback(self.on_link_established) + self.destination.register_request_handler( + PATH, response_generator=self.handle_request, allow=RNS.Destination.ALLOW_ALL + ) + + threading.Thread(target=self._purge_loop, daemon=True).start() + if args.announce_interval > 0: + threading.Thread( + target=self._announce_loop, args=(args.announce_interval,), daemon=True + ).start() + + # -- link lifecycle ------------------------------------------------- + + def on_link_established(self, link: RNS.Link) -> None: + link.set_link_closed_callback(self.on_link_closed) + log.debug("link established: %s", RNS.prettyhexrep(link.link_id)) + if len(self.destination.links) >= self.max_links: + log.info("at the %d concurrent link cap; refusing further links (S13.8)", self.max_links) + self.destination.accept_link_requests = False + + def on_link_closed(self, link: RNS.Link) -> None: + with self.sessions_lock: + self.sessions.pop(link.link_id, None) + if len(self.destination.links) < self.max_links: + self.destination.accept_link_requests = True + + # -- request handling (S13.5) ---------------------------------------- + + def handle_request(self, path, data, request_id, link_id, remote_identity, requested_at): + # S13.8: link_id is an ephemeral handle, nothing more; remote_identity + # would be a durable Reticulum identity, and a server must not use one + # even when a client volunteers it, so it is never read below. + del path, request_id, remote_identity, requested_at + if not self.link_request_limiter.allow(link_id.hex()): + return bytes([RATE_LIMITED]) + if not isinstance(data, (bytes, bytearray)) or len(data) < 1: + return bytes([MALFORMED]) + if not self.link_byte_limiter.allow(link_id.hex(), cost=len(data)): + return bytes([RATE_LIMITED]) + op, body = data[0], bytes(data[1:]) + try: + status, payload = self.dispatch(link_id, op, Reader(body)) + except (ProtocolError, UnicodeDecodeError) as exc: + log.info("bad request on %s: %s", RNS.prettyhexrep(link_id), exc) + return bytes([MALFORMED]) + except Exception: + log.exception("handler error on %s", RNS.prettyhexrep(link_id)) + return bytes([INTERNAL_ERROR]) + # A server MUST answer every registered request, including with an + # error status (S13.5); this function therefore never returns None. + return bytes([status]) + payload + + def dispatch(self, link_id: bytes, op: int, r: Reader) -> tuple[int, bytes]: + handlers = { + OP_AUTH: self.op_auth, + OP_RESOLVE: self.op_resolve, + OP_SEND: self.op_send, + OP_FETCH: self.op_fetch, + OP_DELETE: self.op_delete, + OP_REGISTER: self.op_register, + } + handler = handlers.get(op) + if handler is None: + return MALFORMED, b"" + with self.sessions_lock: + username = self.sessions.get(link_id) + if op in (OP_FETCH, OP_DELETE) and username is None: + return AUTH_REQUIRED, b"" + return handler(link_id, username, r) + + # -- operations (SPEC.md S6, with S13.6's substituted signatures) ---- + + def op_auth(self, link_id: bytes, _username: str | None, r: Reader) -> tuple[int, bytes]: + username = r.take(r.u8()).decode("utf-8", "strict") + identity = r.take(KEY_LEN) + signature = r.take(SIG_LEN) + sync = r.u8() + tokens = [r.take(TOKEN_LEN) for _ in range(r.u16())] + r.done() + bound = self.store.identity_of(username) + # A wrong username and a wrong signature are both AUTH_FAILED: telling + # them apart would turn this into an account-existence oracle. + if bound is None or bound != identity: + return AUTH_FAILED, b"" + h = bind_h(self.destination.hash, link_id) + if not verify(identity, signature, LABEL_AUTH + h): + return AUTH_FAILED, b"" + if sync not in (0, 1) or (sync == 0 and tokens): + return MALFORMED, b"" + # Refused before authenticating, so the client has to trim and retry + # rather than silently running with a truncated set (S4). + if len(tokens) > self.max_accepted: + return TOO_LARGE, b"" + with self.sessions_lock: + self.sessions[link_id] = username + held = self.store.set_accepted(username, tokens) if sync else self.store.count_accepted(username) + return OK, struct.pack(">H", held) + + def op_resolve(self, _link_id: bytes, _username: str | None, r: Reader) -> tuple[int, bytes]: + username = r.take(r.u8()).decode("utf-8", "strict") + r.done() + identity = self.store.identity_of(username) + if identity is None: + return UNKNOWN_USER, b"" + chain = self.store.chain(username) + return OK, identity + bytes([len(chain)]) + b"".join(chain) + + def match_token(self, username: str, mid: bytes, mac: bytes) -> bytes | None: + """S5.8. Trial-match the MAC against the mailbox's tokens. + + Bounded by --max-accepted, so an unmatchable MAC costs a known amount + of HMAC rather than an unbounded scan. + """ + if len(mac) != TOKEN_LEN: + return None + for token in self.store.accepted_tokens(username): + want = hmac.new(token, LABEL_MAC + mid, hashlib.sha256).digest() + if hmac.compare_digest(want, mac): + return token + return None + + def op_send(self, _link_id: bytes, _username: str | None, r: Reader) -> tuple[int, bytes]: + mac = r.take(r.u8()) + envelope = r.rest() + if not self.send_limiter.allow("global"): # S13.8: global ceiling, not per-peer + return RATE_LIMITED, b"" + if len(envelope) > self.max_envelope: + return TOO_LARGE, b"" + if len(envelope) < ENVELOPE_MIN or envelope[:4] != ENVELOPE_MAGIC: + return MALFORMED, b"" + if envelope[4] != ENVELOPE_VERSION: + return BAD_VERSION, b"" + recipient = envelope[5:37] + username = self.store.username_for_key(recipient) + if username is None: + return UNKNOWN_USER, b"" + mid = message_id(envelope) + # A resend is answered with the same id and stores nothing, whether + # the original is still here or was deleted (S10). + if self.store.tombstoned(mid): + return OK, mid + # An unmatched MAC is not an error: SEND must not reveal whether a + # token is still accepted (S5.8). + token = self.match_token(username, mid, mac) + if token is None: + tier, quota = TIER_REQUESTS, self.requests_quota + else: + tier, quota = TIER_MAIN, self.quota + if not self.token_limiter.allow(token.hex()): + return RATE_LIMITED, b"" + keys = self.store.keys_of(username) + if self.store.mailbox_bytes(keys, tier) + len(envelope) > quota: + return QUOTA_EXCEEDED, b"" + # The ciphertext is never inspected; the server cannot read it. + self.store.store_message(mid, recipient, tier, envelope) + return OK, mid + + def op_fetch(self, _link_id: bytes, username: str | None, r: Reader) -> tuple[int, bytes]: + after_time = r.i64() + after_id = r.take(ID_LEN) + r.done() + assert username is not None + keys = self.store.keys_of(username) + records = self.store.pending(keys, after_time, after_id, self.fetch_budget) + out = [struct.pack(">H", len(records))] + for mid, received_at, tier, envelope in records: + flags = FLAG_REQUESTS if tier == TIER_REQUESTS else 0 + out.append(mid + struct.pack(">qBI", received_at, flags, len(envelope)) + envelope) + return OK, b"".join(out) + + def op_delete(self, _link_id: bytes, username: str | None, r: Reader) -> tuple[int, bytes]: + count = r.u16() + ids = [r.take(ID_LEN) for _ in range(count)] + r.done() + assert username is not None + if not ids: + return OK, struct.pack(">H", 0) + # Scoped to the caller's own keys, so ids cannot be used to probe or + # delete another mailbox. + keys = self.store.keys_of(username) + removed = self.store.delete(keys, ids) + return OK, struct.pack(">H", removed) + + def op_register(self, _link_id: bytes, _username: str | None, r: Reader) -> tuple[int, bytes]: + username = r.take(r.u8()).decode("utf-8", "strict") + identity = r.take(KEY_LEN) + signature = r.take(SIG_LEN) + token = r.take(r.u8()) + cert = r.take(r.u8()) + r.done() + + if not valid_username(username): + return MALFORMED, b"" + try: + Ed25519PublicKey.from_public_bytes(identity) + except ValueError: + return MALFORMED, b"" + # S13.6 proof of possession, bound to this server's destination so the + # attestation cannot be replayed elsewhere. + server_static = bind_server_static(self.destination.hash) + if not verify(identity, signature, LABEL_REGISTER + server_static + username.encode() + identity): + return AUTH_FAILED, b"" + expected = self.invite_token + if expected is not None and token != expected: + return NOT_PERMITTED, b"" + + if not cert: + if not self.store.register(username, identity): + return NOT_PERMITTED, b"" + return OK, b"" + + if len(cert) != CERT_LEN: + return MALFORMED, b"" + old_pub, new_pub, when = cert[:32], cert[32:64], cert[64:72] + sig_old, sig_new = cert[72:136], cert[136:200] + if new_pub != identity: + return MALFORMED, b"" + bound = self.store.identity_of(username) + if bound is None: + return UNKNOWN_USER, b"" + if bound != old_pub: + # Only the currently bound key may hand the username on (S7). + return NOT_PERMITTED, b"" + # Both keys sign: the old one alone could otherwise rotate to a key + # nobody controls (S7). + signed = LABEL_ROTATE + username.encode() + old_pub + new_pub + when + if not verify(old_pub, sig_old, signed) or not verify(new_pub, sig_new, signed): + return AUTH_FAILED, b"" + chain = self.store.chain(username) + if len(chain) >= MAX_CHAIN: + return NOT_PERMITTED, b"" + if not self.store.rotate(username, new_pub, cert, len(chain)): + return NOT_PERMITTED, b"" + return OK, b"" + + # -- background loops -------------------------------------------------- + + def _purge_loop(self) -> None: + store = Store(self.store.path) + while True: + time.sleep(PURGE_INTERVAL) + try: + now = int(time.time()) + # Tombstones outlive both tiers, so a replay cannot slip in + # between a message expiring and its id being forgotten. + gone = store.purge( + now - self.retention, + now - self.requests_retention, + now - max(self.retention, self.requests_retention), + ) + if gone: + log.info("expired %d message(s)", gone) + except Exception: + log.exception("purge failed") + + def _announce_loop(self, interval: float) -> None: + # S13.1: interfaces rate-limit a destination to roughly one announce + # an hour; the default keeps well inside that. + while True: + time.sleep(interval) + try: + self.destination.announce() + log.info("announced %s", self.destination.hexhash) + except Exception: + log.exception("announce failed") + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + + +def cmd_keygen(args: argparse.Namespace) -> int: + if os.path.exists(args.key) and not args.force: + print(f"{args.key} exists; refusing to overwrite (use --force)", file=sys.stderr) + return 1 + identity = RNS.Identity() + # RNS writes this private key unencrypted (S13.1); mode 0600 before any + # bytes land, so it is never briefly world-readable. + fd = os.open(args.key, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + os.close(fd) + identity.to_file(args.key) + dest_hash = RNS.Destination.hash(identity, APP_NAME, ASPECT) + print(f"identity: {args.key} (unencrypted; back it up, keep it mode 0600, never transmit it)") + print(f"destination: {dest_hash.hex()}") + print(f"address: smol+rns://@{dest_hash.hex()}") + print() + print("The destination hash is the whole trust model (reticulum.md S13.4): publish it,") + print("and there is nothing else to pin. It is stable for as long as this file survives.") + return 0 + + +def cmd_serve(args: argparse.Namespace) -> int: + identity = RNS.Identity.from_file(args.key) + if identity is None: + print(f"no identity at {args.key}; run: smolmaild_rns.py keygen --key {args.key}", file=sys.stderr) + return 1 + + RNS.Reticulum(configdir=args.config, loglevel=RNS.LOG_DEBUG if args.verbose else RNS.LOG_NOTICE) + server = MailServer(identity, args) + log.info("destination: %s", server.destination.hexhash) + log.info("address: smol+rns://@%s", server.destination.hexhash) + if not args.no_announce: + server.destination.announce() + + try: + while True: + time.sleep(3600) # RNS runs its own threads; this just keeps the process up + except KeyboardInterrupt: + log.info("shutting down") + return 0 + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("-v", "--verbose", action="store_true") + sub = parser.add_subparsers(dest="command", required=True) + + keygen = sub.add_parser("keygen", help="generate the server's Reticulum identity") + keygen.add_argument("--key", default="server.rns.key") + keygen.add_argument("--force", action="store_true") + keygen.set_defaults(func=cmd_keygen) + + serve = sub.add_parser("serve", help="run the mailbox server") + serve.add_argument("--key", default="server.rns.key") + serve.add_argument("--db", default="mail.db") + serve.add_argument("--config", default=None, metavar="DIR", + help="Reticulum config directory (default: the usual RNS location)") + serve.add_argument("--max-envelope", type=int, default=32 << 10, metavar="BYTES", + help="S13.7 recommends a much smaller cap than TCP's 768 KiB") + serve.add_argument("--fetch-budget", type=int, default=32 << 10, metavar="BYTES", + help="S13.7 recommends a much smaller cap than TCP's 512 KiB") + serve.add_argument("--quota", type=int, default=64 << 20, metavar="BYTES") + serve.add_argument("--requests-quota", type=int, default=2 << 20, metavar="BYTES", + help="quota for mail arriving without an accept token") + serve.add_argument("--retention-days", type=int, default=30) + serve.add_argument("--requests-retention-days", type=int, default=7) + serve.add_argument("--max-accepted", type=int, default=1024, metavar="TOKENS", + help="accept tokens a mailbox may hold (SPEC.md S5.8)") + serve.add_argument("--invite-token", default=None, + help="require this token to register; open registration if unset") + serve.add_argument("--max-links", type=int, default=256, + help="cap on concurrent Reticulum links (S13.8)") + serve.add_argument("--rate-link-requests", type=int, default=120, metavar="PER_MIN", + help="requests per minute, per link (S13.8)") + serve.add_argument("--rate-link-bytes", type=int, default=1 << 20, metavar="BYTES_PER_MIN", + help="request bytes per minute, per link (S13.8)") + serve.add_argument("--rate-sends", type=int, default=600, metavar="PER_MIN", + help="global SEND ceiling; S13.8 has no per-peer handle to key one on") + serve.add_argument("--rate-token", type=int, default=120, metavar="PER_MIN", + help="sends per minute per accept token, the main defence (S13.8)") + serve.add_argument("--announce-interval", type=int, default=3 * 3600, metavar="SECONDS", + help="0 disables periodic announcing (S13.1)") + serve.add_argument("--no-announce", action="store_true", + help="do not announce at startup either; rely on path requests alone") + serve.set_defaults(func=cmd_serve) + + args = parser.parse_args(argv) + logging.basicConfig( + level=logging.DEBUG if args.verbose else logging.INFO, + format="%(asctime)s %(levelname)s %(message)s", + ) + return args.func(args) + + +if __name__ == "__main__": + sys.exit(main())