diff --git a/README.md b/README.md index 5d95c46..4b9a7c7 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,8 @@ The short form is typeable. The long form carries the key itself, so an address Keys learned from a server are pinned on first use. Later changes need a rotation certificate signed by the previous key, or explicit confirmation. +Recipients learn only the sender's key, never a name: a first contact arrives as a bare fingerprint until its address is known. A client may carry its full `smol://` address in a signed `Reply-To` field so first contacts can be answered; receivers bind it only when its key matches the message's signer (SPEC.md §5.7). + ## Cryptography Ed25519 · X25519 · ChaCha20-Poly1305 · SHA-256. Four established primitives, no novel cryptography, nothing else anywhere in the protocol. Transport is TCP with a Noise handshake — no certificates, no CA, no expiry. @@ -68,6 +70,8 @@ uv run smolmail.py list uv run smolmail.py read ``` +Sent mail carries the sender's full `smol://` address in a signed `Reply-To` field (SPEC.md §5.7), so a first-time recipient can answer without an out-of-band channel; pass `--anonymous` to omit it. + `import` adds a contact from a `smol://` address without trusting any server, `contacts` lists known keys and how each was learned, and `rotate` replaces the identity with a signed certificate your contacts accept automatically. Mail is stored sealed and opened on demand, so the local database holds no plaintext. Superseded keys are retained after a rotation, because mail already addressed to them is readable with nothing else. diff --git a/SPEC.md b/SPEC.md index 41ae165..aa9dc48 100644 --- a/SPEC.md +++ b/SPEC.md @@ -135,7 +135,7 @@ Body text starts here. One `Key: value` per line. Key is 1–64 bytes of `[A-Za-z0-9-]`; value is the remainder of the line after `:`, trimmed of surrounding spaces. No nesting, arrays, multi-line values, quoting, comments or type coercion. Where a key repeats, the first occurrence wins. Maximum 4 KiB and 64 keys. A malformed line invalidates the whole block, which is then displayed as ordinary body text — frontmatter fails closed toward display, never toward silent discard. -`Subject` and `In-Reply-To` (a message id as 32 lowercase hex characters) are reserved. Unknown keys MUST be preserved, MAY be displayed, and MUST NOT alter client behaviour. A body whose text genuinely begins with `---` is escaped by emitting an empty block first. +`Subject`, `In-Reply-To` (a message id as 32 lowercase hex characters) and `Reply-To` (§5.7) are reserved. Unknown keys MUST be preserved, MAY be displayed, and MUST NOT alter client behaviour. A body whose text genuinely begins with `---` is escaped by emitting an empty block first. **This grammar is not YAML; do not use a YAML parser.** It is flat so that a correct parser is twenty dependency-free lines, in a code path that handles attacker-controlled input. @@ -143,6 +143,12 @@ One `Key: value` per line. Key is 1–64 bytes of `[A-Za-z0-9-]`; value is the r Because `esk` is wiped, a sender cannot decrypt what they sent. To keep a Sent folder, a client seals a second copy of the payload to the sender's own identity key with a fresh ephemeral and stores it locally. This is a client convention and involves no server. +### 5.7 Sender addresses + +Nothing on the wire names a sender. A receiver learns only the signing key of §5.3, so a first contact is identified by nothing but its fingerprint, and replying needs an address the sender supplied somewhere: out of band, or in the message itself. + +Like the sent copies of §5.6 this is a client convention and involves no server: a sender MAY include a `Reply-To` field holding its own full `smol://` address. The value sits inside the sealed, signed payload, so the signature binds the claim. A receiver that acts on it MUST check that the key carried in the URI equals the message's `sender`, MUST treat anything else as ordinary text, and MUST NOT let it replace a key already bound to that address (§8) — it is a first-contact aid, not a trust upgrade. + ## 6. Operations | Type | Op | Auth | Request → response | @@ -202,6 +208,7 @@ A client MUST likewise retain every private key it has rotated away from. Sealin | --- | --- | | Home server key from a trusted channel | Authenticated, pinned, mismatch detected | | Contact key from `smol://` or a QR code | Strong; requires no server trust | +| Sender's `Reply-To` field, key equal to the signer (§5.7) | Strong; the sender's own signed claim, but never overrides a pinned key | | `RESOLVE` over a pinned session | Trust on first use, as good as that server | | `RESOLVE` over an unpinned session | Trust on first use, interceptable at first contact; MUST be shown as unverified | | Key change with a valid chain | Accepted, surfaced in the interface | diff --git a/smolmail.py b/smolmail.py index 83de2eb..a7dce6f 100755 --- a/smolmail.py +++ b/smolmail.py @@ -680,6 +680,9 @@ def cmd_send(args: argparse.Namespace, store: Store) -> int: 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())) + # §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))) body = build_frontmatter(fields, text).encode() envelope = seal(me, recipient, body, pad=not args.no_pad) @@ -828,6 +831,8 @@ COMMANDS = [ (("--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 §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"})]),