feat: revise to protocol 1.1 with accept tokens, fetch cursors and 32-byte ids
This commit is contained in:
parent
56a6ed1186
commit
71f5f628be
4 changed files with 712 additions and 220 deletions
477
smolmail.py
477
smolmail.py
|
|
@ -5,9 +5,9 @@
|
|||
# ///
|
||||
"""Smol Mail reference client.
|
||||
|
||||
Implements SPEC.md version 1 from both ends: one Ed25519 identity, sealed and
|
||||
Implements SPEC.md version 1.1 from both ends: one master secret, sealed and
|
||||
signed messages, server key pinning, trust on first use with rotation chains,
|
||||
and the five operations.
|
||||
accept tokens, 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.
|
||||
|
|
@ -42,6 +42,8 @@ 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"
|
||||
LABEL_IDENTITY, LABEL_ACCEPT = b"smolmail/1 identity", b"smolmail/1 accept"
|
||||
LABEL_MAC, LABEL_REGISTER = b"smolmail/1 mac", b"smolmail/1 register"
|
||||
|
||||
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",
|
||||
|
|
@ -49,12 +51,15 @@ STATUS = {0: "ok", 1: "malformed", 2: "bad version", 3: "unknown user",
|
|||
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
|
||||
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
|
||||
DEFAULT_PORT, MAX_FRAME, NOISE_PAYLOAD = 1961, 1 << 20, 65535 - 16
|
||||
FRONTMATTER_MAX, FRONTMATTER_KEYS = 4096, 64
|
||||
SEPARATORS = "._-"
|
||||
MAX_SKEW = 86400 # §5.3, how far ahead of our clock a payload may be dated
|
||||
TIER_MAIN, TIER_REQUESTS, FLAG_REQUESTS = 0, 1, 0x01
|
||||
|
||||
|
||||
class SmolError(Exception):
|
||||
|
|
@ -111,6 +116,13 @@ class Reader:
|
|||
ADDRESS = re.compile(r"^(?P<user>[a-z0-9._-]{1,63})@(?P<host>[^/:]+)(?::(?P<port>\d+))?$")
|
||||
|
||||
|
||||
def valid_username(name: str) -> bool:
|
||||
"""§3: 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:]))
|
||||
|
||||
|
||||
class Address:
|
||||
"""A short `user@host` address, or a self-certifying `smol://` one (§3)."""
|
||||
|
||||
|
|
@ -135,8 +147,9 @@ class Address:
|
|||
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")
|
||||
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")
|
||||
return cls(m["user"], m["host"], int(m["port"] or DEFAULT_PORT), identity)
|
||||
|
||||
@property
|
||||
|
|
@ -160,6 +173,21 @@ def hkdf_sha256(ikm: bytes, salt: bytes, info: bytes, length: int = 32) -> bytes
|
|||
return out[:length]
|
||||
|
||||
|
||||
def identity_seed(master: bytes, index: int) -> bytes:
|
||||
"""§2. The Ed25519 seed at a rotation index."""
|
||||
return hkdf_sha256(master, b"", LABEL_IDENTITY + struct.pack(">I", index))
|
||||
|
||||
|
||||
def accept_key(master: bytes) -> bytes:
|
||||
"""§2. 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:
|
||||
"""§5.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:
|
||||
"""§2, via libsodium's own conversion as the spec prefers."""
|
||||
try:
|
||||
|
|
@ -184,6 +212,26 @@ class Identity:
|
|||
return self._signing.sign(message).signature
|
||||
|
||||
|
||||
class Account:
|
||||
"""The master secret of §2 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 (§7).
|
||||
Rotation advances the index, so none of them 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:
|
||||
"""§5.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)
|
||||
|
|
@ -193,7 +241,7 @@ def verify_sig(identity: bytes, signature: bytes, message: bytes) -> bool:
|
|||
|
||||
|
||||
def agree(x_priv: bytes, x_pub: bytes) -> bytes:
|
||||
"""X25519 with §3.2's checks; libsodium rejects low-order points itself."""
|
||||
"""X25519 with §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:
|
||||
|
|
@ -205,7 +253,7 @@ def agree(x_priv: bytes, x_pub: bytes) -> bytes:
|
|||
|
||||
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]
|
||||
return hashlib.sha256(LABEL_ID + envelope).digest()
|
||||
|
||||
|
||||
def seal(sender: Identity, recipient: bytes, body: bytes, when: int | None = None,
|
||||
|
|
@ -228,11 +276,7 @@ def seal(sender: Identity, recipient: bytes, body: bytes, when: int | None = Non
|
|||
|
||||
|
||||
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).
|
||||
"""
|
||||
"""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:
|
||||
|
|
@ -261,6 +305,8 @@ def unseal(identities: list[Identity], envelope: bytes) -> dict:
|
|||
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)}
|
||||
|
||||
|
||||
|
|
@ -273,7 +319,8 @@ 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.
|
||||
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
|
||||
|
|
@ -292,7 +339,7 @@ def parse_frontmatter(body: str) -> tuple[dict[str, str], str]:
|
|||
key, sep, value = line.partition(":")
|
||||
if not sep or not FM_KEY.match(key):
|
||||
return {}, body
|
||||
fields.setdefault(key, value.strip()) # first occurrence wins
|
||||
fields.setdefault(key.lower(), value.strip()) # first occurrence wins
|
||||
return fields, rest
|
||||
|
||||
|
||||
|
|
@ -307,21 +354,39 @@ def build_frontmatter(fields: list[tuple[str, str]], body: str) -> str:
|
|||
# --- local state ------------------------------------------------------------
|
||||
|
||||
SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS account (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1), username TEXT, host TEXT, port INTEGER);
|
||||
-- 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, port INTEGER,
|
||||
rotations INTEGER NOT NULL DEFAULT 0, -- §2 rotation index
|
||||
after_time INTEGER NOT NULL DEFAULT 0, -- §6.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 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);
|
||||
-- Correspondents admitted to this mailbox's main tier (§5.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 (§5.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 (§10).
|
||||
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);
|
||||
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);
|
||||
|
|
@ -345,14 +410,24 @@ class Store:
|
|||
return self.db.execute(sql, args)
|
||||
|
||||
def account(self) -> Address | None:
|
||||
row = self.one("SELECT username, host, port FROM account WHERE id = 1")
|
||||
row = self.one("SELECT username, host, port FROM state 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",
|
||||
self.run("UPDATE state SET username = ?, host = ?, port = ? WHERE id = 1",
|
||||
addr.user, addr.host, addr.port)
|
||||
|
||||
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
|
||||
|
|
@ -367,17 +442,63 @@ class Store:
|
|||
"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 token_set(self, account: "Account") -> tuple[int, list[bytes]]:
|
||||
"""§4. 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 (§5.7, §8)."""
|
||||
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:
|
||||
"""§5.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 "
|
||||
"ORDER BY received_at, id")
|
||||
"WHERE tier = ? ORDER BY received_at, id", tier)
|
||||
|
||||
|
||||
# --- transport (§4) ---------------------------------------------------------
|
||||
|
|
@ -486,23 +607,25 @@ def do_resolve(conn: Conn, user: str) -> tuple[bytes, list[bytes]]:
|
|||
return identity, [r.take(CERT_LEN) for _ in range(r.u8())]
|
||||
|
||||
|
||||
def walk_chain(pinned: bytes, current: bytes, chain: list[bytes]) -> bool:
|
||||
def walk_chain(username: str, 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."""
|
||||
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, sig = cert[:32], cert[32:64], cert[64:72], cert[72:]
|
||||
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
|
||||
if not verify_sig(old, sig, LABEL_ROTATE + old + new + when):
|
||||
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)
|
||||
|
|
@ -520,7 +643,7 @@ def trust_key(store: Store, conn: Conn, addr: Address) -> bytes:
|
|||
old, verified = known
|
||||
if hmac.compare_digest(old, identity):
|
||||
return identity
|
||||
if walk_chain(old, identity, chain):
|
||||
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)}")
|
||||
|
|
@ -530,12 +653,33 @@ def trust_key(store: Store, conn: Conn, addr: Address) -> bytes:
|
|||
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."""
|
||||
def register_signed(server_static: bytes, username: str, identity: bytes) -> bytes:
|
||||
"""§6.1 proof of possession, bound to the server that will store the binding."""
|
||||
return LABEL_REGISTER + server_static + username.encode() + identity
|
||||
|
||||
|
||||
def authenticate(conn: Conn, addr: Address, account: Account, store: Store) -> int:
|
||||
"""§4 session authentication: sign the handshake hash and push the token set."""
|
||||
name = addr.user.encode()
|
||||
status, _ = conn.call(OP_AUTH, bytes([len(name)]) + name + me.pk
|
||||
+ me.sign(LABEL_AUTH + conn.handshake_hash))
|
||||
sync, tokens = store.token_set(account)
|
||||
body = (bytes([len(name)]) + name + account.me.pk
|
||||
+ account.me.sign(LABEL_AUTH + conn.handshake_hash)
|
||||
+ 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) -> None:
|
||||
"""§5.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(store, addr, require_pin=True) as conn:
|
||||
held = authenticate(conn, addr, account, store)
|
||||
print(f"server now holds {held} accept token(s)")
|
||||
|
||||
|
||||
# --- helpers ----------------------------------------------------------------
|
||||
|
|
@ -545,19 +689,24 @@ def warn(message: str) -> None:
|
|||
print(f"warning: {message}", file=sys.stderr)
|
||||
|
||||
|
||||
def load_identity(args: argparse.Namespace) -> Identity:
|
||||
def load_master(args: argparse.Namespace) -> bytes:
|
||||
try:
|
||||
with open(args.key, "rb") as fh:
|
||||
return Identity(fh.read())
|
||||
return 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."""
|
||||
def load_account(args: argparse.Namespace, store: Store) -> Account:
|
||||
"""§2. 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(seed)
|
||||
fh.write(secret)
|
||||
|
||||
|
||||
def describe(store: Store, envelope: bytes, identities: list[Identity]) -> dict:
|
||||
|
|
@ -567,33 +716,48 @@ def describe(store: Store, envelope: bytes, identities: list[Identity]) -> dict:
|
|||
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", ""),
|
||||
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)")
|
||||
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)}")
|
||||
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:
|
||||
me = load_identity(args)
|
||||
print(f"public key: {b32(me.pk)}\nfingerprint: {fingerprint(me.pk)}")
|
||||
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(me.pk)}" if addr
|
||||
print(f"address: {addr.short}\nuri: {addr.uri(account.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")
|
||||
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
|
||||
|
||||
|
||||
|
|
@ -614,10 +778,13 @@ def cmd_trust(args: argparse.Namespace, store: Store) -> int:
|
|||
|
||||
|
||||
def cmd_register(args: argparse.Namespace, store: Store) -> int:
|
||||
me, addr = load_identity(args), Address.parse(args.address)
|
||||
account, addr = load_account(args, store), Address.parse(args.address)
|
||||
me = account.me
|
||||
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:
|
||||
body = (bytes([len(name)]) + name + me.pk
|
||||
+ me.sign(register_signed(conn.server_static, 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)
|
||||
|
|
@ -645,18 +812,49 @@ def cmd_import(args: argparse.Namespace, store: Store) -> int:
|
|||
|
||||
|
||||
def cmd_contacts(args: argparse.Namespace, store: Store) -> int:
|
||||
rows = store.all("SELECT address, identity, verified FROM contacts ORDER BY address")
|
||||
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 in rows:
|
||||
print(f"{address:<{width}} {b32(identity)} {'verified' if verified else 'tofu'}")
|
||||
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 (§5.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)
|
||||
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)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_send(args: argparse.Namespace, store: Store) -> int:
|
||||
me, addr = load_identity(args), Address.parse(args.address)
|
||||
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():
|
||||
|
|
@ -681,36 +879,48 @@ def cmd_send(args: argparse.Namespace, store: Store) -> int:
|
|||
raise SmolError(f"{raw!r} is not a valid `Key: value` header")
|
||||
fields.append((key.strip(), value.strip()))
|
||||
# §5.7: a signed reply address lets a first-time recipient answer us.
|
||||
if (account := store.account()) is not None and not args.anonymous:
|
||||
fields.append(("Reply-To", account.uri(me.pk)))
|
||||
if (account_addr := store.account()) is not None and not args.anonymous:
|
||||
fields.append(("Reply-To", account_addr.uri(me.pk)))
|
||||
# §5.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)
|
||||
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)
|
||||
# §5.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(store, addr, require_pin=False) 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")
|
||||
# §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)")
|
||||
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:
|
||||
me = load_identity(args)
|
||||
account = load_account(args, store)
|
||||
addr = store.account()
|
||||
if addr is None:
|
||||
raise SmolError("not registered; run: smolmail.py register <user@host>")
|
||||
identities = store.identities(me)
|
||||
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(store, addr, require_pin=True) as conn:
|
||||
authenticate(conn, addr, me)
|
||||
authenticate(conn, addr, account, store)
|
||||
while True:
|
||||
status, body = conn.call(OP_FETCH)
|
||||
status, body = conn.call(OP_FETCH, struct.pack(">q", after_time) + after_id)
|
||||
expect_ok(status, "fetching")
|
||||
r = Reader(body)
|
||||
count = r.u16()
|
||||
|
|
@ -719,42 +929,55 @@ def cmd_fetch(args: argparse.Namespace, store: Store) -> int:
|
|||
acked = []
|
||||
for _ in range(count):
|
||||
total += 1
|
||||
mid, received_at = r.take(ID_LEN), r.i64()
|
||||
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")
|
||||
unseal(identities, 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()}: {exc}; left on server")
|
||||
warn(f"{mid.hex()[:16]}: {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:
|
||||
# A message we have already had once is not stored again, even
|
||||
# if we deleted it locally in the meantime (§10).
|
||||
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 not acked or args.keep:
|
||||
break
|
||||
status, _ = conn.call(OP_DELETE, struct.pack(">H", len(acked)) + b"".join(acked))
|
||||
expect_ok(status, "acknowledging")
|
||||
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:
|
||||
me = load_identity(args)
|
||||
identities, box = store.identities(me), "sent" if args.sent else "inbox"
|
||||
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, identities)
|
||||
opened = describe(store, envelope, account.keys)
|
||||
who = recipient if box == "sent" else opened["from"]
|
||||
subject = opened["subject"] or "(no subject)"
|
||||
except SmolError as exc:
|
||||
|
|
@ -765,56 +988,91 @@ def cmd_list(args: argparse.Namespace, store: Store) -> int:
|
|||
|
||||
|
||||
def cmd_read(args: argparse.Namespace, store: Store) -> int:
|
||||
me = load_identity(args)
|
||||
identities, box = store.identities(me), "sent" if args.sent else "inbox"
|
||||
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 message in {box} matching {args.id!r}")
|
||||
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, identities)
|
||||
print(f"id: {mid.hex()}")
|
||||
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":
|
||||
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"])))
|
||||
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():
|
||||
print(f"{key.lower() + ':':<9}{value}")
|
||||
if key != "accept": # machinery, not content (§5.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:
|
||||
old = load_identity(args)
|
||||
account = load_account(args, store)
|
||||
addr = store.account()
|
||||
if addr is None:
|
||||
raise SmolError("not registered; run: smolmail.py register <user@host>")
|
||||
new = Identity(os.urandom(KEY_LEN))
|
||||
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()))
|
||||
cert = old.pk + new.pk + when + old.sign(LABEL_ROTATE + old.pk + new.pk + when)
|
||||
# §7: 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()
|
||||
body = bytes([len(name)]) + name + new.pk + b"\0" + bytes([len(cert)]) + cert
|
||||
with connect(store, addr, require_pin=True) as conn:
|
||||
body = (bytes([len(name)]) + name + new.pk
|
||||
+ new.sign(register_signed(conn.server_static, addr.user, new.pk))
|
||||
+ b"\0" + bytes([len(cert)]) + cert)
|
||||
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)
|
||||
# The master is untouched; only the index moves, and the superseded key stays
|
||||
# derivable from it (§2).
|
||||
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:
|
||||
"""§2. 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(store, addr, require_pin=False) 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 (§4).
|
||||
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 an identity",
|
||||
("keygen", cmd_keygen, "create a master secret",
|
||||
[(("--force",), {"action": "store_true"})]),
|
||||
("whoami", cmd_whoami, "show this identity", []),
|
||||
("trust", cmd_trust, "pin a server's static key",
|
||||
|
|
@ -822,9 +1080,13 @@ COMMANDS = [
|
|||
(("--force",), {"action": "store_true"})]),
|
||||
("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:// 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"}),
|
||||
|
|
@ -835,17 +1097,22 @@ COMMANDS = [
|
|||
"help": "omit the Reply-To field carrying this address (SPEC.md §5.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"})]),
|
||||
("list", cmd_list, "list stored mail", [(("--sent",), {"action": "store_true"})]),
|
||||
[(("--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, "replace this identity, signing the change", []),
|
||||
("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="identity seed file")
|
||||
parser.add_argument("--key", default="identity.key", help="master secret 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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue