smolmail/vectors.py

712 lines
34 KiB
Python
Raw Normal View History

#!/usr/bin/env -S uv run --quiet --script
# /// script
# requires-python = ">=3.11"
# dependencies = ["noiseprotocol>=0.3.1", "pynacl>=1.5"]
# ///
# Copyright 2026 randogoth
# SPDX-License-Identifier: Apache-2.0
"""Test vectors for SPEC.md 1.2, generated from the reference implementation.
Every value is produced by calling into smolmail.py rather than restated here,
so the vectors cannot drift from the code they document. Each one is also
verified from the opposite side before it is emitted: signatures are checked,
envelopes are unsealed, and the rejection cases are confirmed to actually
raise. `--check` repeats the generation and compares it against the committed
files, which is what makes them a regression test rather than a snapshot.
uv run vectors.py # write vectors.json and VECTORS.md
uv run vectors.py --check # verify the committed files still reproduce
Determinism: seal() draws its ephemeral from os.urandom and its timestamp from
the clock (§5.2), so both are pinned here. Nothing else in the protocol is
randomised.
"""
from __future__ import annotations
import calendar
import contextlib
import difflib
import hashlib
import hmac
import json
import os
import re
import struct
import sys
import nacl.bindings as sodium
import smolmail as sm
# Obvious test data: two masters that no one could mistake for a real secret.
ALICE_MASTER = bytes(range(0x00, 0x20))
BOB_MASTER = bytes(range(0x20, 0x40))
SERVER_STATIC = bytes(range(0x60, 0x80))
HANDSHAKE_HASH = bytes(range(0x80, 0xA0))
WHEN = calendar.timegm((2026, 1, 1, 0, 0, 0))
FUTURE = calendar.timegm((2100, 1, 1, 0, 0, 0)) # beyond §5.3's 86400s skew
HOST = "example.org"
# §4's low-order blacklist. Every one of these must be refused as an ephemeral
# public key; the generator asserts that the reference actually refuses them.
LOW_ORDER = [
"0000000000000000000000000000000000000000000000000000000000000000",
"0100000000000000000000000000000000000000000000000000000000000000",
"e0eb7a7c3b41b8ae1656e3faf19fc46ada098deb9c32b1fd866205165f49b800",
"5f9c95bca3508c24b1d0b1559c83ef5b04445cc4581c8e86d8224eddd09f1157",
"ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f",
"edffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f",
"eeffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f",
]
def h(raw: bytes) -> str:
return raw.hex()
@contextlib.contextmanager
def ephemeral(esk: bytes):
"""Pin seal()'s per-message ephemeral so an envelope reproduces byte for byte."""
real = os.urandom
sm.os.urandom = lambda n: esk if n == sm.KEY_LEN else real(n)
try:
yield sodium.crypto_scalarmult_base(esk)
finally:
sm.os.urandom = real
def plaintext_of(sender: sm.Identity, to: bytes, body: bytes, when: int,
epk: bytes, pad: bool) -> tuple[bytes, bytes, bytes]:
"""§5.3's payload, returned with the header and the signed preimage.
seal() builds this internally and keeps none of it, but an implementer
debugging a signature needs the exact bytes that went under the key. The
emitted envelope is asserted equal to seal()'s, which keeps this honest.
"""
header = bytes([sm.VERSION]) + sender.pk + struct.pack(">qI", when, len(body))
signed = sm.LABEL_MSG + to + epk + header + body
payload = header + body + sender.sign(signed)
if pad:
payload += bytes(-len(payload) % sm.PAD_TO)
return header, signed, payload
def encrypt(payload: bytes, to: bytes, esk: bytes) -> bytes:
"""§5.2's sealing step alone, so a vector can carry a deliberately broken
payload that still passes the AEAD."""
epk = sodium.crypto_scalarmult_base(esk)
key = sm.hkdf_sha256(sm.agree(esk, sm.ed_to_x25519_pub(to)), epk + to, sm.LABEL_SEAL)
aad = sm.MAGIC + bytes([sm.VERSION]) + to + epk
return aad + sodium.crypto_aead_chacha20poly1305_ietf_encrypt(payload, aad, bytes(12), key)
def raises(fn) -> str:
"""Run a case that must fail and return the reference's own message."""
try:
fn()
except (sm.SmolError, Exception) as exc: # noqa: BLE001 - any refusal counts
return f"{type(exc).__name__}: {exc}"
raise AssertionError("expected a rejection, got none")
# --- §2 identity ------------------------------------------------------------
def identity_vectors() -> list[dict]:
out = []
for name, master, index in [("alice", ALICE_MASTER, 0), ("alice", ALICE_MASTER, 1),
("bob", BOB_MASTER, 0)]:
seed = sm.identity_seed(master, index)
who = sm.Identity(seed)
out.append({
"name": name,
"master": h(master),
"rotation_index": index,
"seed": h(seed),
"identity_key": h(who.pk),
"x25519_private": h(who.x_priv),
"x25519_public": h(sm.ed_to_x25519_pub(who.pk)),
"base32": sm.b32(who.pk),
"fingerprint": sm.fingerprint(who.pk),
"uri": f"smol://{name}@{HOST}/{sm.b32(who.pk)}",
})
# The X25519 keypair must agree with itself, or the §2 map is wrong.
assert sodium.crypto_scalarmult_base(who.x_priv) == sm.ed_to_x25519_pub(who.pk)
return out
def accept_vectors(alice: sm.Account, bob: sm.Account) -> dict:
token = alice.token_for(bob.me.pk)
assert token == hmac.new(sm.accept_key(ALICE_MASTER), bob.me.pk, hashlib.sha256).digest()
return {
"note": "alice's accept key, and the token she issues to bob. The accept "
"key is derived without the rotation index, so it and every token "
"from it survive rotation.",
"alice_accept_key": h(sm.accept_key(ALICE_MASTER)),
"bob_accept_key": h(sm.accept_key(BOB_MASTER)),
"alice_token_for_bob": h(token),
"alice_token_for_bob_base32": sm.b32(token),
"bob_token_for_alice_0": h(bob.token_for(alice.keys[0].pk)),
"alice_token_for_bob_rotated": h(alice.token_for(
sm.Identity(sm.identity_seed(BOB_MASTER, 1)).pk)),
}
# --- §5 messages ------------------------------------------------------------
def envelope_vectors(alice: sm.Account, bob: sm.Account) -> list[dict]:
reply_to = f"smol://alice@{HOST}/{sm.b32(alice.keys[0].pk)}"
accept = sm.b32(alice.token_for(bob.me.pk))
cases = [
{"name": "empty-body", "from": ("bob", 0), "to": ("alice", 0), "pad": False,
"body": b"",
"note": "body_len 0. The payload is 109 bytes and the envelope 194."},
{"name": "unpadded", "from": ("bob", 0), "to": ("alice", 0), "pad": False,
"body": b"hello alice",
"note": "The minimal form: 69 + 45 + 11 + 64 + 16 bytes. Start here."},
{"name": "padded", "from": ("bob", 0), "to": ("alice", 0), "pad": True,
"body": b"hello alice",
"note": "The same message with §5.3 padding to a 1024-byte multiple. A "
"receiver must ignore the trailing zeros and trust body_len."},
{"name": "utf8", "from": ("bob", 0), "to": ("alice", 0), "pad": False,
"body": "Grüße aus dem Mesh — 🛰\n".encode(),
"note": "body_len counts bytes, not characters: 29 bytes, 22 characters."},
{"name": "frontmatter", "from": ("alice", 0), "to": ("bob", 0), "pad": False,
"body": sm.build_frontmatter(
[("Subject", "the thing"), ("Reply-To", reply_to), ("Accept", accept)],
"Here is a token so your mail skips my requests tier.\n").encode(),
"note": "§5.5 frontmatter carrying the reserved Reply-To (§5.7) and Accept "
"(§5.8) fields. Both are only meaningful because the signature "
"covers them."},
{"name": "superseded-key", "from": ("bob", 0), "to": ("alice", 0), "pad": False,
"body": b"sent before you rotated\n",
"note": "Sealed to alice's index-0 key while she is at index 1 (§7). She "
"opens it with the retained key, which is re-derivable from the "
"master alone."},
{"name": "rotated-key", "from": ("bob", 0), "to": ("alice", 1), "pad": False,
"body": b"sent after you rotated\n",
"note": "The same correspondents after alice's rotation. Only `to` changes; "
"her accept token does not."},
]
accounts = {"alice": alice, "bob": bob}
out = []
for n, case in enumerate(cases):
esk = bytes([0x40 + n]) * 32
sender = accounts[case["from"][0]].keys[case["from"][1]]
recipient = accounts[case["to"][0]].keys[case["to"][1]].pk
body = case["body"]
with ephemeral(esk) as epk:
env = sm.seal(sender, recipient, body, when=WHEN, pad=case["pad"])
header, signed, payload = plaintext_of(sender, recipient, body, WHEN, epk, case["pad"])
# plaintext_of/encrypt duplicate seal()'s layout; pin them to it.
assert encrypt(payload, recipient, esk) == env, case["name"]
opened = sm.unseal(accounts[case["to"][0]].keys, env)
assert opened["body"] == body and opened["sender"] == sender.pk
assert opened["time"] == WHEN and opened["id"] == sm.message_id(env)
shared = sm.agree(esk, sm.ed_to_x25519_pub(recipient))
token = accounts[case["to"][0]].token_for(sender.pk)
out.append({
"name": case["name"],
"note": case["note"],
"sender": f"{case['from'][0]}[{case['from'][1]}]",
"recipient": f"{case['to'][0]}[{case['to'][1]}]",
"body": h(body),
"body_len": len(body),
"time": WHEN,
"padded": case["pad"],
"esk": h(esk),
"epk": h(epk),
"x25519_shared": h(shared),
"seal_key": h(sm.hkdf_sha256(shared, epk + recipient, sm.LABEL_SEAL)),
"aad": h(sm.MAGIC + bytes([sm.VERSION]) + recipient + epk),
"payload_header": h(header),
"signed_preimage": h(signed),
"signature": h(payload[sm.PAYLOAD_HEADER + len(body):][:sm.SIG_LEN]),
"payload": h(payload),
"payload_len": len(payload),
"envelope": h(env),
"envelope_len": len(env),
"envelope_sha256": h(hashlib.sha256(env).digest()),
"message_id": h(sm.message_id(env)),
"accept_mac": h(sm.accept_mac(token, sm.message_id(env))),
"send_body": h(bytes([sm.TOKEN_LEN]) + sm.accept_mac(token, sm.message_id(env)) + env),
})
return out
# --- §4, §6.1 and §7 wire bytes ---------------------------------------------
def session_vectors(alice: sm.Account) -> dict:
me = alice.keys[0]
name = b"alice"
auth_sig = me.sign(sm.LABEL_AUTH + HANDSHAKE_HASH)
assert sm.verify_sig(me.pk, auth_sig, sm.LABEL_AUTH + HANDSHAKE_HASH)
token = alice.token_for(sm.Identity(sm.identity_seed(BOB_MASTER, 0)).pk)
auth_frame = (bytes([len(name)]) + name + me.pk + auth_sig
+ bytes([1]) + struct.pack(">H", 1) + token)
reg_preimage = sm.register_signed(SERVER_STATIC, "alice", me.pk)
reg_sig = me.sign(reg_preimage)
assert sm.verify_sig(me.pk, reg_sig, reg_preimage)
reg_frame = (bytes([len(name)]) + name + me.pk + reg_sig + b"\0" + b"\0")
return {
"note": "The AUTH signature covers the Noise handshake hash, so it cannot "
"be replayed to another session or server. The REGISTER signature "
"covers the server's static key for the same reason: without it, "
"any party could bind any public key to any username.",
"handshake_hash": h(HANDSHAKE_HASH),
"server_static": h(SERVER_STATIC),
"auth_signed_preimage": h(sm.LABEL_AUTH + HANDSHAKE_HASH),
"auth_signature": h(auth_sig),
"auth_frame": h(auth_frame),
"auth_frame_note": "username_len=5 || 'alice' || identity || signature || "
"sync=1 || count=1 || alice's token for bob",
"register_signed_preimage": h(reg_preimage),
"register_signature": h(reg_sig),
"register_frame": h(reg_frame),
"register_frame_note": "token_len=0 and cert_len=0: a first registration, "
"no invite token and no rotation certificate",
}
def rotation_vectors(alice: sm.Account) -> dict:
old, new = alice.keys[0], alice.keys[1]
when = struct.pack(">q", WHEN)
signed = sm.LABEL_ROTATE + b"alice" + old.pk + new.pk + when
cert = old.pk + new.pk + when + old.sign(signed) + new.sign(signed)
assert len(cert) == sm.CERT_LEN
# A chain that leads from the pinned key to the offered one is accepted; the
# same certificate checked under another username is not (§7).
assert sm.walk_chain("alice", old.pk, new.pk, [cert])
assert not sm.walk_chain("mallory", old.pk, new.pk, [cert])
assert not sm.walk_chain("alice", old.pk, new.pk, [])
forged = old.pk + new.pk + when + old.sign(signed) + bytes(sm.SIG_LEN)
assert not sm.walk_chain("alice", old.pk, new.pk, [forged])
return {
"note": "A certificate is 200 bytes and carries no username: the name is "
"covered by both signatures but not transported, so a certificate "
"cannot be replayed against another username bound to the same key.",
"username": "alice",
"old_key": h(old.pk),
"new_key": h(new.pk),
"time": WHEN,
"signed_preimage": h(signed),
"sig_old": h(old.sign(signed)),
"sig_new": h(new.sign(signed)),
"certificate": h(cert),
"resolve_body": h(new.pk + bytes([1]) + cert),
"walk_chain": [
{"case": "pinned=old, offered=new, chain=[cert], username=alice",
"accepted": True},
{"case": "same certificate checked as username=mallory",
"accepted": False,
"why": "both signatures cover the username"},
{"case": "pinned=old, offered=new, empty chain", "accepted": False,
"why": "a key change needs a chain; absent means ask the user"},
{"case": "sig_new zeroed", "accepted": False,
"why": "the new key must sign too, or the old key alone could hand "
"the username to a key nobody controls"},
],
}
# --- §3 addressing ----------------------------------------------------------
def addressing_vectors(alice: sm.Account) -> dict:
def username_ok(name: str) -> bool:
"""§3 through the real parser: a username is valid when `name@host` parses
and gives that exact username back."""
try:
return sm.Address.parse(f"{name}@{HOST}").user == name
except sm.SmolError:
return False
names = ["a", "a0", "alice", "al-ice", "a.b-c_d", "x" * 63,
"", "-alice", "alice-", ".alice", "al..ice", "a._b", "a-_b",
"Alice", "x" * 64, "al ice", "alice@", "ali/ce"]
usernames = [{"username": n, "valid": username_ok(n)} for n in names]
assert [u["valid"] for u in usernames] == [True] * 6 + [False] * 12
addresses = []
for text in [f"alice@{HOST}", f"alice@{HOST}:1961", f"alice@{HOST}:2000",
f"smol://alice@{HOST}/{sm.b32(alice.keys[0].pk)}"]:
a = sm.Address.parse(text)
addresses.append({"input": text, "user": a.user, "host": a.host,
"port": a.port, "short": a.short,
"identity": h(a.identity) if a.identity else None})
bad = {text: raises(lambda t=text: sm.Address.parse(t))
for text in [f"smol://alice@{HOST}", f"smol://alice@{HOST}/{sm.b32(bytes(31))}",
f"smol://alice@{HOST}/not-base32!", f"-alice@{HOST}"]}
return {
"note": "The short form is resolved by the server; the smol:// form carries "
"the key and needs no server to be trusted. 1961 is the default "
"port and is omitted from the short form.",
"usernames": usernames,
"addresses": addresses,
"rejected": bad,
"base32": {"identity": h(alice.keys[0].pk), "encoded": sm.b32(alice.keys[0].pk),
"fingerprint": sm.fingerprint(alice.keys[0].pk),
"fingerprint_note": "the first 20 base32 characters in groups of "
"four: 100 bits, so a collision costs ~2^100"},
}
# --- §5.5 frontmatter -------------------------------------------------------
def frontmatter_vectors() -> list[dict]:
cases = [
("no-block", "hello\n", "no leading '---\\n', so the whole body is text"),
("simple", "---\nSubject: hi\n---\nbody\n", "one field, then the text"),
("empty-block", "---\n---\n---\nliteral dashes\n",
"an empty block escapes a body that genuinely starts with '---'"),
("case-and-duplicates", "---\nSubject: first\nsubject: second\nX-Mood: calm\n---\nb",
"keys are lowercased for comparison and the first occurrence wins"),
("value-trimmed", "---\nSubject: spaced out \n---\n",
"the value is the rest of the line, trimmed"),
("colon-in-value", "---\nSubject: re: the thing\n---\n",
"only the first colon separates"),
("no-colon", "---\nSubject hi\n---\nbody\n",
"a malformed line invalidates the whole block, which becomes text"),
("bad-key", "---\nSub ject: hi\n---\nbody\n", "key charset is [A-Za-z0-9-]"),
("unterminated", "---\nSubject: hi\nbody\n", "no closing '---'"),
("too-many-keys", "---\n" + "".join(f"K{i}: v\n" for i in range(65)) + "---\nb",
"over 64 keys"),
("too-large", "---\nK: " + "v" * 4100 + "\n---\nb", "over 4 KiB"),
("injected-field", "---\nSubject: hi\nAccept: " + "a" * 8 + "\n---\nb",
"what a line break in a sender-supplied value produces: a second field, "
"signed and indistinguishable from an intended one. A client building "
"frontmatter MUST refuse a value containing a line break."),
]
out = []
for name, body, note in cases:
fields, rest = sm.parse_frontmatter(body)
out.append({"name": name, "note": note, "body": body,
"fields": fields, "text": rest,
"block_accepted": bool(fields) or rest != body})
assert out[2]["text"] == "---\nliteral dashes\n"
assert out[3]["fields"]["subject"] == "first"
assert all(not o["block_accepted"] for o in out if o["name"] in
{"no-colon", "bad-key", "unterminated", "too-many-keys", "too-large"})
return out
# --- rejections -------------------------------------------------------------
def reject_vectors(alice: sm.Account, bob: sm.Account) -> dict:
"""Inputs an implementation MUST refuse. These are the vectors that catch a
client which decrypts without verifying, or trusts `to` without checking it."""
to = alice.keys[0].pk
esk = bytes([0xA1]) * 32
with ephemeral(esk) as epk:
valid = sm.seal(bob.me, to, b"valid\n", when=WHEN, pad=False)
_, _, payload = plaintext_of(bob.me, to, b"valid\n", WHEN, epk, False)
assert sm.unseal(alice.keys, valid)["body"] == b"valid\n"
def mutate(env: bytes, at: int, raw: bytes) -> bytes:
return env[:at] + raw + env[at + len(raw):]
def repack(new_payload: bytes) -> bytes:
"""Re-seal a tampered payload so it passes the AEAD and fails deeper in."""
return encrypt(new_payload, to, esk)
bad_sig = repack(payload[:-sm.SIG_LEN] + bytes(sm.SIG_LEN))
wrong_to = repack(plaintext_of(bob.me, bob.me.pk, b"valid\n", WHEN, epk, False)[2])
long_body = repack(payload[:sm.PAYLOAD_HEADER - 4] + struct.pack(">I", 1 << 20)
+ payload[sm.PAYLOAD_HEADER:])
_, _, future = plaintext_of(bob.me, to, b"later\n", FUTURE, epk, False)
envelopes = [
("bad-magic", mutate(valid, 0, b"XMOL"), "magic is not 'SMOL' (§5.1)"),
("bad-envelope-version", mutate(valid, 4, b"\x02"),
"unknown envelope version; the sealed-box construction may differ"),
("truncated", valid[:sm.ENVELOPE_HEADER],
"shorter than the 69-byte header plus a 16-byte tag"),
("tag-flipped", valid[:-1] + bytes([valid[-1] ^ 0x01]),
"the Poly1305 tag no longer authenticates"),
("aad-tampered", mutate(valid, 40, bytes([valid[40] ^ 0x01])),
"epk is covered by the AAD, so altering it breaks the tag rather than "
"silently re-addressing the message"),
("not-for-us", repack(payload)[:5] + bob.me.pk + valid[37:],
"`to` names a key we do not hold; a client MUST check it against its own"),
("bad-signature", bad_sig,
"decrypts cleanly and the signature is zeroed. An implementation that "
"skips §5.3 verification will accept forged mail here."),
("signature-bound-to-other-recipient", wrong_to,
"a payload signed for bob, resealed to alice. The signature covers `to`, "
"so re-addressing a recorded message must not verify."),
("body-len-overflows", long_body, "body_len exceeds the payload (§5.3)"),
("payload-version", repack(b"\x02" + payload[1:]), "unknown payload version"),
("dated-in-the-future", repack(future),
f"time is {FUTURE}, more than 86400s ahead of any clock before 2100 (§5.3)"),
("low-order-epk", mutate(valid, 37, bytes.fromhex(LOW_ORDER[2])),
"a low-order ephemeral key, which §2 requires be refused"),
]
out = []
for name, env, why in envelopes:
out.append({"name": name, "why": why, "envelope": h(env),
"envelope_len": len(env),
"rejected_with": raises(lambda e=env: sm.unseal(alice.keys, e))})
low = [{"x25519_public": p,
"rejected_with": raises(lambda p=p: sm.agree(alice.keys[0].x_priv,
bytes.fromhex(p)))}
for p in LOW_ORDER]
keys = [{"identity_key": k,
"rejected_with": raises(lambda k=k: sm.ed_to_x25519_pub(bytes.fromhex(k)))}
for k in ["0000000000000000000000000000000000000000000000000000000000000000",
"ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
"0100000000000000000000000000000000000000000000000000000000000000"]]
return {
"note": "Inputs a conformant implementation refuses. The envelope cases are "
"mutations of one valid message, listed with the reference's own "
"error, so a client can be tested against its own failure path.",
"valid_baseline": h(valid),
"envelopes": out,
"low_order_x25519": low,
"low_order_note": "§2 and §4: a received ephemeral public key that is a "
"low-order point MUST be refused, and an all-zero X25519 "
"output MUST be refused. libsodium reports the first as "
"the second, since the agreement output is zero.",
"invalid_identity_keys": keys,
"invalid_identity_note": "Keys that do not decode as Ed25519 points, or are "
"of small order, so §2's map to X25519 has no "
"valid result.",
}
# --- assembly ---------------------------------------------------------------
def build() -> dict:
alice = sm.Account(ALICE_MASTER, 1) # rotated once, so §7 has both keys
bob = sm.Account(BOB_MASTER, 0)
return {
"spec": "smolmail/1.2",
"generated_by": "vectors.py, from smolmail.py",
"note": "All values are hex unless the field name says otherwise. `alice[n]` "
"and `bob[n]` name a master and a rotation index from `identity`. "
"Masters are test data: never use them.",
"labels": {name: getattr(sm, name).decode() for name in
["LABEL_IDENTITY", "LABEL_ACCEPT", "LABEL_SEAL", "LABEL_MSG",
"LABEL_ID", "LABEL_MAC", "LABEL_AUTH", "LABEL_ROTATE",
"LABEL_REGISTER", "PROLOGUE"]},
"constants": {"default_port": sm.DEFAULT_PORT, "magic": sm.MAGIC.decode(),
"version": sm.VERSION, "envelope_header": sm.ENVELOPE_HEADER,
"payload_header": sm.PAYLOAD_HEADER, "pad_to": sm.PAD_TO,
"max_chain": sm.MAX_CHAIN, "max_skew": sm.MAX_SKEW,
"noise_protocol": sm.NOISE_PROTOCOL.decode()},
"identity": identity_vectors(),
"accept": accept_vectors(alice, bob),
"addressing": addressing_vectors(alice),
"envelopes": envelope_vectors(alice, bob),
"session": session_vectors(alice),
"rotation": rotation_vectors(alice),
"frontmatter": frontmatter_vectors(),
"rejects": reject_vectors(alice, bob),
}
def wrap(value: str, width: int = 76) -> str:
return "\n".join(value[i:i + width] for i in range(0, len(value), width))
def field(key: str, value, limit: int = 160) -> str:
"""Long hex goes in a fenced block; everything short stays on one line."""
if isinstance(value, str) and len(value) > limit:
return f"\n`{key}`\n\n```\n{wrap(value)}\n```\n"
if isinstance(value, bool):
value = "true" if value else "false"
return f"- `{key}`: `{value}`\n" if value != "" else f"- `{key}`: (empty)\n"
def markdown(v: dict) -> str:
L = v["labels"]
md = [
"<!-- SPDX-License-Identifier: CC-BY-SA-4.0 -->",
"",
f"# smolmail Test Vectors, version {v['spec'].split('/')[1]}",
"",
"Known-answer tests for [SPEC.md](SPEC.md), generated from the reference "
"implementation by [vectors.py](vectors.py). [vectors.json](vectors.json) "
"carries the same values in a form a test harness can load; this file is "
"for reading alongside the specification.",
"",
"Work through them in order. Derivation first (§2), because every later "
"vector depends on it, then an envelope you can seal and unseal (§5), then "
"the wire frames (§4, §6.1, §7). Finish with the rejections: they are the "
"ones that catch an implementation which decrypts without verifying.",
"",
"Hex throughout, unless a field name says otherwise. The two masters are "
"test data and the private keys here are public. Never use them for "
"anything.",
"",
"## Labels (§11)",
"",
"Every hash, signature and MAC is domain-separated by one of these, "
"prefixed to the input as raw bytes with no separator or length:",
"",
]
md += [f"- `{k}` = `{val}`" for k, val in L.items()]
md += ["", "## §2 Identity", "",
"`seed_n = HKDF-SHA256(master, salt = \"\", info = "
f"\"{L['LABEL_IDENTITY']}\" || n)`, with `n` a big-endian u32. The "
"Ed25519 public key of that seed *is* the identity. The X25519 keypair "
"comes from the same key via libsodium's conversions, and the accept key "
"is derived without the index so it survives rotation.", ""]
for who in v["identity"]:
md.append(f"### {who['name']}, rotation index {who['rotation_index']}")
md.append("")
for key in ["master", "rotation_index", "seed", "identity_key",
"x25519_private", "x25519_public", "base32", "fingerprint", "uri"]:
md.append(field(key, who[key]).rstrip("\n"))
md.append("")
md += ["### Accept keys and tokens (§5.8)", "",
v["accept"]["note"], ""]
md += [field(k, val).rstrip("\n") for k, val in v["accept"].items() if k != "note"]
md += ["",
"`alice_token_for_bob_rotated` is the token alice would issue to bob's "
"index-1 key. It differs from the one bob already holds, which is why "
"§5.8 says a token is derived from the correspondent's identity *as it "
"stood when they were accepted*: bob's rotation does not invalidate the "
"token he was given.", ""]
a = v["addressing"]
md += ["## §3 Addressing", "", a["note"], "", "### Usernames", "",
"| username | valid |", "| --- | --- |"]
md += [f"| `{u['username'] or '(empty)'}` | {'yes' if u['valid'] else 'no'} |"
for u in a["usernames"]]
md += ["", "### Addresses", "",
"| input | user | host | port | identity |", "| --- | --- | --- | --- | --- |"]
for ad in a["addresses"]:
ident = f"`{ad['identity'][:16]}…`" if ad["identity"] else "resolved"
md.append(f"| `{ad['input']}` | `{ad['user']}` | `{ad['host']}` | "
f"{ad['port']} | {ident} |")
md += ["", "Refused:", ""]
md += [f"- `{text}` — {why}" for text, why in a["rejected"].items()]
md += ["", "### Fingerprint", ""]
md += [field(k, val).rstrip("\n") for k, val in a["base32"].items()]
md += ["", "## §5 Messages", "",
"Each vector pins the ephemeral `esk` that §5.2 would draw at random and "
"the `time` that §5.3 would take from the clock. Nothing else in the "
"protocol is randomised, so with those two fixed an envelope reproduces "
"byte for byte.", "",
"Build one in this order: agree on `x25519_shared`, derive `seal_key`, "
"assemble `payload_header`, sign `signed_preimage`, concatenate the "
"payload, then encrypt it under `aad` with an all-zero nonce. If your "
"`seal_key` matches and your envelope does not, the fault is in the "
"payload layout; if `x25519_shared` already differs, it is in §2's map.",
"",
"`accept_mac` is what a sender holding the recipient's token attaches to "
"`SEND` (§5.8); `message_id` is what the server returns. The full payload "
"plaintext and the whole `SEND` request body are in vectors.json as "
"`payload` and `send_body`.", ""]
for e in v["envelopes"]:
md += [f"### {e['name']}", "", e["note"], ""]
for key in ["sender", "recipient", "body", "body_len", "time", "padded",
"esk", "epk", "x25519_shared", "seal_key", "aad",
"payload_header", "signed_preimage", "signature",
"payload_len", "envelope_len", "envelope", "envelope_sha256",
"message_id", "accept_mac"]:
md.append(field(key, e[key]).rstrip("\n"))
md.append("")
s = v["session"]
md += ["## §4 and §6.1 Session frames", "", s["note"], ""]
for key in ["handshake_hash", "server_static", "auth_signed_preimage",
"auth_signature", "auth_frame"]:
md.append(field(key, s[key]).rstrip("\n"))
md += ["", s["auth_frame_note"], ""]
for key in ["register_signed_preimage", "register_signature", "register_frame"]:
md.append(field(key, s[key]).rstrip("\n"))
md += ["", s["register_frame_note"], ""]
r = v["rotation"]
md += ["## §7 Key rotation", "", r["note"], ""]
for key in ["username", "old_key", "new_key", "time", "signed_preimage",
"sig_old", "sig_new", "certificate", "resolve_body"]:
md.append(field(key, r[key]).rstrip("\n"))
md += ["", "Chain walking:", ""]
md += [f"- {c['case']} → **{'accept' if c['accepted'] else 'refuse'}**"
+ (f" ({c['why']})" if c.get("why") else "") for c in r["walk_chain"]]
md += ["", "## §5.5 Frontmatter", "",
"A flat `Key: value` block, not YAML. A malformed line invalidates the "
"whole block, which is then shown as ordinary body text: frontmatter "
"fails closed toward display, never toward silent discard.", "",
"| case | input | fields | text | note |", "| --- | --- | --- | --- | --- |"]
def clip(text: str, keep: int = 48) -> str:
return repr(text) if len(text) <= keep else repr(text[:keep]) + "…"
for f in v["frontmatter"]:
md.append(f"| `{f['name']}` | `{clip(f['body'])}` | `{f['fields']}` | "
f"`{clip(f['text'])}` | {f['note']} |")
md += ["", "Long inputs are clipped here; vectors.json has them whole."]
rj = v["rejects"]
md += ["", "## Rejections", "", rj["note"], "",
"Each envelope is a mutation of this valid one:", "",
field("valid_baseline", rj["valid_baseline"]).rstrip("\n"), ""]
for case in rj["envelopes"]:
md += [f"### {case['name']}", "", case["why"], "",
field("envelope", case["envelope"]).rstrip("\n"),
field("rejected_with", case["rejected_with"]).rstrip("\n"), ""]
md += ["### Low-order X25519 keys", "", rj["low_order_note"], ""]
md += [f"- `{p['x25519_public']}`" for p in rj["low_order_x25519"]]
md += ["", "### Invalid identity keys", "", rj["invalid_identity_note"], ""]
md += [f"- `{k['identity_key']}` — {k['rejected_with']}"
for k in rj["invalid_identity_keys"]]
md += ["", "## License", "",
"These vectors are licensed under "
"[CC BY-SA 4.0](LICENSES/CC-BY-SA-4.0.txt), with the specification.", ""]
return re.sub(r"\n{3,}", "\n\n", "\n".join(md))
def main(argv: list[str]) -> int:
check = "--check" in argv
built = build()
rendered = markdown(built)
here = os.path.dirname(os.path.abspath(__file__))
files = {"vectors.json": json.dumps(built, indent=2) + "\n",
"VECTORS.md": rendered}
if not check:
for name, text in files.items():
with open(os.path.join(here, name), "w", encoding="utf-8") as fh:
fh.write(text)
print(f"wrote {name} ({len(text)} bytes)")
counts = {k: len(built[k]) for k in ["identity", "envelopes", "frontmatter"]}
print(f"{counts['identity']} identities, {counts['envelopes']} envelopes, "
f"{len(built['rejects']['envelopes'])} rejections, "
f"{counts['frontmatter']} frontmatter cases: all verified")
return 0
failed = False
for name, text in files.items():
path = os.path.join(here, name)
try:
with open(path, encoding="utf-8") as fh:
have = fh.read()
except FileNotFoundError:
print(f"{name}: missing")
failed = True
continue
if have == text:
print(f"{name}: ok")
continue
failed = True
print(f"{name}: differs from what smolmail.py now produces")
for line in list(difflib.unified_diff(
have.splitlines(), text.splitlines(),
fromfile=f"{name} (committed)", tofile=f"{name} (regenerated)",
lineterm=""))[:40]:
print(f" {line}")
return 1 if failed else 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))