863 lines
35 KiB
Python
Executable file
863 lines
35 KiB
Python
Executable file
#!/usr/bin/env -S uv run --quiet --script
|
|
# /// script
|
|
# requires-python = ">=3.11"
|
|
# dependencies = ["noiseprotocol>=0.3.1", "pynacl>=1.5"]
|
|
# ///
|
|
"""Smol Mail reference client.
|
|
|
|
Implements SPEC.md version 1 from both ends: one Ed25519 identity, sealed and
|
|
signed messages, server key pinning, trust on first use with rotation chains,
|
|
and the five operations.
|
|
|
|
Mail is stored sealed and opened on demand, so a stolen database is no more
|
|
readable than a stolen mailbox on a server.
|
|
|
|
uv run smolmail.py keygen
|
|
uv run smolmail.py trust example.org <server-key>
|
|
uv run smolmail.py register alice@example.org
|
|
uv run smolmail.py send bob@example.org --subject Hello
|
|
uv run smolmail.py fetch
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import base64
|
|
import hashlib
|
|
import hmac
|
|
import os
|
|
import re
|
|
import socket
|
|
import sqlite3
|
|
import struct
|
|
import sys
|
|
import time
|
|
|
|
import nacl.bindings as sodium
|
|
import nacl.exceptions
|
|
from nacl.signing import SigningKey, VerifyKey
|
|
from noise.connection import NoiseConnection
|
|
|
|
NOISE_PROTOCOL = b"Noise_NX_25519_ChaChaPoly_SHA256"
|
|
PROLOGUE = b"smolmail/1"
|
|
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"
|
|
|
|
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 = 32, 16, 64, 136
|
|
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
|
|
DEFAULT_PORT, MAX_FRAME, NOISE_PAYLOAD = 1961, 1 << 20, 65535 - 16
|
|
FRONTMATTER_MAX, FRONTMATTER_KEYS = 4096, 64
|
|
|
|
|
|
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 (§3)."""
|
|
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 (§3)."""
|
|
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
|
|
|
|
|
|
ADDRESS = re.compile(r"^(?P<user>[a-z0-9._-]{1,63})@(?P<host>[^/:]+)(?::(?P<port>\d+))?$")
|
|
|
|
|
|
class Address:
|
|
"""A short `user@host` address, or a self-certifying `smol://` one (§3)."""
|
|
|
|
def __init__(self, user: str, host: str, port: int, identity: bytes | None = None) -> None:
|
|
self.user, self.host, self.port, self.identity = user, host, port, identity
|
|
|
|
@classmethod
|
|
def parse(cls, text: str) -> "Address":
|
|
text, identity = text.strip(), None
|
|
if text.startswith("smol://"):
|
|
rest = text[len("smol://") :]
|
|
if "/" not in rest:
|
|
raise SmolError(f"{text}: smol:// address carries no key")
|
|
rest, key = rest.rsplit("/", 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}")
|
|
text = rest
|
|
m = ADDRESS.match(text)
|
|
if not m:
|
|
raise SmolError(f"{text!r} is not a valid address")
|
|
if m["user"][0] in "._-" or m["user"][-1] in "._-":
|
|
raise SmolError(f"{m['user']!r} may not begin or end with a separator")
|
|
return cls(m["user"], m["host"], int(m["port"] or DEFAULT_PORT), identity)
|
|
|
|
@property
|
|
def short(self) -> str:
|
|
return f"{self.user}@{self.host}" + ("" if self.port == DEFAULT_PORT else f":{self.port}")
|
|
|
|
def uri(self, identity: bytes) -> str:
|
|
return f"smol://{self.short}/{b32(identity)}"
|
|
|
|
|
|
# --- identity and message cryptography (§2, §5) -----------------------------
|
|
|
|
|
|
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 ed_to_x25519_pub(identity: bytes) -> bytes:
|
|
"""§2, 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
|
|
|
|
|
|
class Identity:
|
|
"""An Ed25519 keypair plus the X25519 keypair derived from it (§2)."""
|
|
|
|
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
|
|
|
|
|
|
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 §3.2'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:
|
|
"""§5.4, derived from the envelope so no sender can choose it."""
|
|
return hashlib.sha256(LABEL_ID + envelope).digest()[:ID_LEN]
|
|
|
|
|
|
def seal(sender: Identity, recipient: bytes, body: bytes, when: int | None = None,
|
|
pad: bool = True) -> bytes:
|
|
"""§5.2 and §5.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.
|
|
|
|
`identities` may include retired keys: sealing is to a specific key, so mail
|
|
addressed to a superseded one needs that key (§7).
|
|
"""
|
|
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")
|
|
return {"sender": sender, "time": when, "body": body, "id": message_id(envelope)}
|
|
|
|
|
|
# --- body frontmatter (§5.5) ------------------------------------------------
|
|
|
|
FM_KEY = re.compile(r"^[A-Za-z0-9-]{1,64}$")
|
|
|
|
|
|
def parse_frontmatter(body: str) -> tuple[dict[str, str], str]:
|
|
"""§5.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.
|
|
"""
|
|
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, 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 `---` (§5.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 ------------------------------------------------------------
|
|
|
|
SCHEMA = """
|
|
CREATE TABLE IF NOT EXISTS account (
|
|
id INTEGER PRIMARY KEY CHECK (id = 1), username TEXT, host TEXT, port INTEGER);
|
|
CREATE TABLE IF NOT EXISTS servers (
|
|
host TEXT PRIMARY KEY, static BLOB NOT NULL, pinned_at INTEGER NOT NULL);
|
|
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:// address
|
|
seen_at INTEGER NOT NULL);
|
|
-- Private keys rotated away from. Mail sealed to a superseded key is readable
|
|
-- only with that key, so these can never be discarded (§7).
|
|
CREATE TABLE IF NOT EXISTS retired (
|
|
identity BLOB PRIMARY KEY, seed BLOB NOT NULL, retired_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);
|
|
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, port FROM account WHERE id = 1")
|
|
return Address(row[0], row[1], row[2]) if row and row[0] else None
|
|
|
|
def set_account(self, addr: Address) -> None:
|
|
self.run("INSERT INTO account (id, username, host, port) VALUES (1, ?1, ?2, ?3) "
|
|
"ON CONFLICT (id) DO UPDATE SET username = ?1, host = ?2, port = ?3",
|
|
addr.user, addr.host, addr.port)
|
|
|
|
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 pin_server(self, host: str, static: bytes) -> None:
|
|
self.run("INSERT INTO servers (host, static, pinned_at) VALUES (?,?,?) "
|
|
"ON CONFLICT (host) DO UPDATE SET static = ?2, pinned_at = ?3",
|
|
host, static, int(time.time()))
|
|
|
|
def identities(self, me: Identity) -> list[Identity]:
|
|
"""The current key plus every key rotated away from."""
|
|
return [me] + [Identity(row[0]) for row in
|
|
self.all("SELECT seed FROM retired ORDER BY retired_at")]
|
|
|
|
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")
|
|
return self.all("SELECT id, envelope, received_at, NULL FROM inbox "
|
|
"ORDER BY received_at, id")
|
|
|
|
|
|
# --- transport (§4) ---------------------------------------------------------
|
|
|
|
|
|
class Conn:
|
|
"""A Noise_NX initiator session with pinning layered on top (§4)."""
|
|
|
|
def __init__(self, host: str, port: int, pinned: bytes | None) -> None:
|
|
try:
|
|
self.sock = socket.create_connection((host, port), timeout=30)
|
|
except OSError as exc:
|
|
raise SmolError(f"cannot reach {host}:{port}: {exc}") from None
|
|
noise = NoiseConnection.from_name(NOISE_PROTOCOL)
|
|
noise.set_as_initiator()
|
|
noise.set_prologue(PROLOGUE)
|
|
noise.start_handshake()
|
|
first = noise.write_message()
|
|
self.sock.sendall(struct.pack(">H", len(first)) + first)
|
|
# The library drops the peer's static key once the handshake ends, so
|
|
# keep the handshake state to read it afterwards.
|
|
state = noise.noise_protocol.handshake_state
|
|
noise.read_message(self._recv(struct.unpack(">H", self._recv(2))[0]))
|
|
if not noise.handshake_finished:
|
|
raise SmolError("handshake did not complete")
|
|
|
|
self.server_static = bytes(state.rs.public_bytes)
|
|
if pinned is not None and not hmac.compare_digest(pinned, self.server_static):
|
|
self.close()
|
|
raise SmolError(f"{host} presented a different key than the one pinned\n"
|
|
f" pinned: {b32(pinned)}\n"
|
|
f" presented: {b32(self.server_static)}")
|
|
self.pinned = pinned is not None
|
|
self.noise, self.buf = noise, bytearray()
|
|
self.handshake_hash: bytes = noise.get_handshake_hash()
|
|
|
|
def _recv(self, n: int) -> bytes:
|
|
out = b""
|
|
while len(out) < n:
|
|
chunk = self.sock.recv(n - len(out))
|
|
if not chunk:
|
|
raise SmolError("server closed the connection")
|
|
out += chunk
|
|
return out
|
|
|
|
def call(self, op: int, body: bytes = b"") -> tuple[int, bytes]:
|
|
frame = struct.pack(">I", 1 + len(body)) + bytes([op]) + body
|
|
if len(frame) > MAX_FRAME + 4:
|
|
raise SmolError("request exceeds the maximum frame size")
|
|
for off in range(0, len(frame), NOISE_PAYLOAD):
|
|
packet = self.noise.encrypt(frame[off : off + NOISE_PAYLOAD])
|
|
self.sock.sendall(struct.pack(">H", len(packet)) + packet)
|
|
while len(self.buf) < 5:
|
|
self.buf += self.noise.decrypt(self._recv(struct.unpack(">H", self._recv(2))[0]))
|
|
(length,) = struct.unpack(">I", self.buf[:4])
|
|
if not 1 <= length <= MAX_FRAME:
|
|
raise SmolError(f"server sent a frame of length {length}")
|
|
while len(self.buf) < 4 + length:
|
|
self.buf += self.noise.decrypt(self._recv(struct.unpack(">H", self._recv(2))[0]))
|
|
payload = bytes(self.buf[4 : 4 + length])
|
|
del self.buf[: 4 + length]
|
|
return payload[1], payload[2:] # status, body (§6.1)
|
|
|
|
def close(self) -> None:
|
|
try:
|
|
self.sock.close()
|
|
except OSError:
|
|
pass
|
|
|
|
def __enter__(self) -> "Conn":
|
|
return self
|
|
|
|
def __exit__(self, *exc) -> None:
|
|
self.close()
|
|
|
|
|
|
def connect(store: Store, addr: Address, require_pin: bool) -> Conn:
|
|
"""Open a session, enforcing §4's rule about unpinned servers."""
|
|
row = store.one("SELECT static FROM servers WHERE host = ?", addr.host)
|
|
pinned = row[0] if row else None
|
|
if pinned is None and require_pin:
|
|
raise SmolError(f"no pinned key for {addr.host}.\nObtain it from the operator "
|
|
f"through a trusted channel, then:\n"
|
|
f" smolmail.py trust {addr.host} <key>")
|
|
conn = Conn(addr.host, addr.port, pinned)
|
|
if pinned is None:
|
|
warn(f"{addr.host} is not pinned; its key is {b32(conn.server_static)}")
|
|
return conn
|
|
|
|
|
|
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 (§6.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(pinned: bytes, current: bytes, chain: list[bytes]) -> bool:
|
|
"""§7. Accept a key change only when a signed chain leads from the key we
|
|
hold to the one the server now returns."""
|
|
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, sig = cert[:32], cert[32:64], cert[64:72], cert[72:]
|
|
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
|
|
if not verify_sig(old, sig, LABEL_ROTATE + old + new + when):
|
|
return False
|
|
key = new
|
|
return started and hmac.compare_digest(key, current)
|
|
|
|
|
|
def trust_key(store: Store, conn: Conn, addr: Address) -> bytes:
|
|
"""Resolve a contact and apply §8's trust rules."""
|
|
identity, chain = do_resolve(conn, addr.user)
|
|
known = store.contact(addr.short)
|
|
if known is None:
|
|
store.save_contact(addr.short, identity, verified=False)
|
|
qualifier = "" if conn.pinned else ", UNVERIFIED server"
|
|
print(f"{addr.short} {b32(identity)}\n pinned (trust on first use{qualifier})")
|
|
return identity
|
|
old, verified = known
|
|
if hmac.compare_digest(old, identity):
|
|
return identity
|
|
if walk_chain(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.py import {addr.uri(identity)}")
|
|
|
|
|
|
def authenticate(conn: Conn, addr: Address, me: Identity) -> None:
|
|
"""§4 session authentication: sign the handshake hash."""
|
|
name = addr.user.encode()
|
|
status, _ = conn.call(OP_AUTH, bytes([len(name)]) + name + me.pk
|
|
+ me.sign(LABEL_AUTH + conn.handshake_hash))
|
|
expect_ok(status, "authentication")
|
|
|
|
|
|
# --- helpers ----------------------------------------------------------------
|
|
|
|
|
|
def warn(message: str) -> None:
|
|
print(f"warning: {message}", file=sys.stderr)
|
|
|
|
|
|
def load_identity(args: argparse.Namespace) -> Identity:
|
|
try:
|
|
with open(args.key, "rb") as fh:
|
|
return Identity(fh.read())
|
|
except FileNotFoundError:
|
|
raise SmolError(f"no identity at {args.key}; run: smolmail.py keygen") from None
|
|
|
|
|
|
def write_seed(path: str, seed: bytes) -> None:
|
|
"""0600 before any bytes land, so the seed 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(seed)
|
|
|
|
|
|
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
|
|
|
|
|
|
# --- 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)")
|
|
me = Identity(os.urandom(KEY_LEN))
|
|
write_seed(args.key, me.seed)
|
|
print(f"identity: {args.key} (back this up; it is the only secret)")
|
|
print(f"public key: {b32(me.pk)}\nfingerprint: {fingerprint(me.pk)}")
|
|
return 0
|
|
|
|
|
|
def cmd_whoami(args: argparse.Namespace, store: Store) -> int:
|
|
me = load_identity(args)
|
|
print(f"public key: {b32(me.pk)}\nfingerprint: {fingerprint(me.pk)}")
|
|
addr = store.account()
|
|
print(f"address: {addr.short}\nuri: {addr.uri(me.pk)}" if addr
|
|
else "address: (not registered)")
|
|
retired = len(store.all("SELECT 1 FROM retired"))
|
|
if retired:
|
|
print(f"retired: {retired} superseded key(s), kept to read old mail")
|
|
return 0
|
|
|
|
|
|
def cmd_trust(args: argparse.Namespace, store: Store) -> int:
|
|
try:
|
|
key = unb32(args.key_b32)
|
|
except Exception as exc:
|
|
raise SmolError(f"undecodable key: {exc}") from None
|
|
if len(key) != KEY_LEN:
|
|
raise SmolError(f"server key is {len(key)} bytes, expected {KEY_LEN}")
|
|
host = args.host.split(":")[0]
|
|
row = store.one("SELECT static FROM servers WHERE host = ?", host)
|
|
if row and row[0] != key and not args.force:
|
|
raise SmolError(f"{host} is already pinned to {b32(row[0])}; use --force to replace")
|
|
store.pin_server(host, key)
|
|
print(f"pinned {host} {b32(key)}")
|
|
return 0
|
|
|
|
|
|
def cmd_register(args: argparse.Namespace, store: Store) -> int:
|
|
me, addr = load_identity(args), Address.parse(args.address)
|
|
name, token = addr.user.encode(), (args.token or "").encode()
|
|
body = (bytes([len(name)]) + name + me.pk + bytes([len(token)]) + token + b"\0")
|
|
with connect(store, addr, require_pin=True) as conn:
|
|
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(store, addr, require_pin=False) as conn:
|
|
trust_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:// 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 address, identity, verified FROM contacts ORDER BY address")
|
|
if not rows:
|
|
print("no contacts")
|
|
return 0
|
|
width = max(len(a) for a, _, _ in rows)
|
|
for address, identity, verified in rows:
|
|
print(f"{address:<{width}} {b32(identity)} {'verified' if verified else 'tofu'}")
|
|
return 0
|
|
|
|
|
|
def cmd_send(args: argparse.Namespace, store: Store) -> int:
|
|
me, addr = load_identity(args), Address.parse(args.address)
|
|
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(store, addr, require_pin=False) as conn:
|
|
recipient = trust_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()))
|
|
|
|
body = build_frontmatter(fields, text).encode()
|
|
envelope = seal(me, recipient, body, pad=not args.no_pad)
|
|
with connect(store, addr, require_pin=False) as conn:
|
|
status, payload = conn.call(OP_SEND, envelope)
|
|
expect_ok(status, f"sending to {addr.short}")
|
|
mid = message_id(envelope)
|
|
if payload and payload != mid:
|
|
warn("server returned an id we did not derive; it is not authoritative")
|
|
# §5.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()} to {addr.short} ({len(envelope)} bytes)")
|
|
return 0
|
|
|
|
|
|
def cmd_fetch(args: argparse.Namespace, store: Store) -> int:
|
|
me = load_identity(args)
|
|
addr = store.account()
|
|
if addr is None:
|
|
raise SmolError("not registered; run: smolmail.py register <user@host>")
|
|
identities = store.identities(me)
|
|
stored = rejected = total = 0
|
|
|
|
with connect(store, addr, require_pin=True) as conn:
|
|
authenticate(conn, addr, me)
|
|
while True:
|
|
status, body = conn.call(OP_FETCH)
|
|
expect_ok(status, "fetching")
|
|
r = Reader(body)
|
|
count = r.u16()
|
|
if count == 0:
|
|
break
|
|
acked = []
|
|
for _ in range(count):
|
|
total += 1
|
|
mid, received_at = r.take(ID_LEN), r.i64()
|
|
envelope = r.take(r.u32())
|
|
try:
|
|
if message_id(envelope) != mid:
|
|
raise SmolError("id does not match the envelope")
|
|
unseal(identities, envelope)
|
|
except SmolError as exc:
|
|
# Left on the server rather than destroyed, so a client-side
|
|
# bug cannot lose mail.
|
|
warn(f"{mid.hex()}: {exc}; left on server")
|
|
rejected += 1
|
|
continue
|
|
if store.run("INSERT OR IGNORE INTO inbox (id, envelope, received_at) "
|
|
"VALUES (?,?,?)", mid, envelope, received_at).rowcount:
|
|
stored += 1
|
|
acked.append(mid)
|
|
if not acked or args.keep:
|
|
break
|
|
status, _ = conn.call(OP_DELETE, struct.pack(">H", len(acked)) + b"".join(acked))
|
|
expect_ok(status, "acknowledging")
|
|
|
|
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:
|
|
me = load_identity(args)
|
|
identities, box = store.identities(me), "sent" if args.sent 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, identities)
|
|
who = recipient if box == "sent" else opened["from"]
|
|
subject = opened["subject"] or "(no subject)"
|
|
except SmolError as exc:
|
|
who, subject = "?", f"<unreadable: {exc}>"
|
|
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:
|
|
me = load_identity(args)
|
|
identities, box = store.identities(me), "sent" if args.sent else "inbox"
|
|
matches = [r for r in store.mail(box) if r[0].hex().startswith(args.id.lower())]
|
|
if not matches:
|
|
raise SmolError(f"no message in {box} 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, identities)
|
|
print(f"id: {mid.hex()}")
|
|
if box == "sent":
|
|
print(f"to: {recipient}")
|
|
print(f"from: {opened['from']}\nkey: {b32(opened['sender'])}")
|
|
print("date: " + time.strftime("%Y-%m-%d %H:%M:%S %z", time.localtime(opened["time"])))
|
|
for key, value in opened["fields"].items():
|
|
print(f"{key.lower() + ':':<9}{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:
|
|
old = load_identity(args)
|
|
addr = store.account()
|
|
if addr is None:
|
|
raise SmolError("not registered; run: smolmail.py register <user@host>")
|
|
new = Identity(os.urandom(KEY_LEN))
|
|
when = struct.pack(">q", int(time.time()))
|
|
cert = old.pk + new.pk + when + old.sign(LABEL_ROTATE + old.pk + new.pk + when)
|
|
|
|
name = addr.user.encode()
|
|
body = bytes([len(name)]) + name + new.pk + b"\0" + bytes([len(cert)]) + cert
|
|
with connect(store, addr, require_pin=True) as conn:
|
|
status, _ = conn.call(OP_REGISTER, body)
|
|
expect_ok(status, "rotating")
|
|
|
|
# Retain the old seed first: mail already sealed to it is otherwise unreadable.
|
|
store.run("INSERT OR IGNORE INTO retired (identity, seed, retired_at) VALUES (?,?,?)",
|
|
old.pk, old.seed, int(time.time()))
|
|
write_seed(args.key, new.seed)
|
|
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
|
|
|
|
|
|
# --- CLI --------------------------------------------------------------------
|
|
|
|
COMMANDS = [
|
|
("keygen", cmd_keygen, "create an identity",
|
|
[(("--force",), {"action": "store_true"})]),
|
|
("whoami", cmd_whoami, "show this identity", []),
|
|
("trust", cmd_trust, "pin a server's static key",
|
|
[(("host",), {}), (("key_b32",), {"metavar": "key"}),
|
|
(("--force",), {"action": "store_true"})]),
|
|
("register", cmd_register, "bind this identity to a username",
|
|
[(("address",), {}), (("--token",), {"help": "invite token, if required"})]),
|
|
("resolve", cmd_resolve, "look up and pin a contact's key", [(("address",), {})]),
|
|
("import", cmd_import, "add a contact from a smol:// address", [(("uri",), {})]),
|
|
("contacts", cmd_contacts, "list known keys", []),
|
|
("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"}),
|
|
(("--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"})]),
|
|
("list", cmd_list, "list stored mail", [(("--sent",), {"action": "store_true"})]),
|
|
("read", cmd_read, "show one message",
|
|
[(("id",), {}), (("--sent",), {"action": "store_true"})]),
|
|
("rotate", cmd_rotate, "replace this 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="identity seed file")
|
|
parser.add_argument("--db", default="smolmail.db", help="local state and mail")
|
|
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)
|
|
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())
|