1164 lines
50 KiB
Python
1164 lines
50 KiB
Python
|
|
#!/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<user>[a-z0-9._-]{1,63})@(?P<host>[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 <user@destination>")
|
||
|
|
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"<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:
|
||
|
|
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 <user@destination>")
|
||
|
|
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())
|