feat: add Reticulum transport reference implementation, bump protocol to 1.2

This commit is contained in:
randogoth 2026-09-27 18:41:42 +03:00
parent 83563a71dd
commit f3d54f9f98
4 changed files with 2021 additions and 2 deletions

View file

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

View file

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

1163
smolmail_rns.py Normal file

File diff suppressed because it is too large Load diff

850
smolmaild_rns.py Normal file
View file

@ -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://<username>@{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://<username>@%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())