Compare commits

...

2 commits

3 changed files with 17 additions and 1 deletions

View file

@ -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. 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 ## 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. 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 <id> uv run smolmail.py read <id>
``` ```
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. `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. 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.

View file

@ -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. 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. **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. 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 ## 6. Operations
| Type | Op | Auth | Request → response | | 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 | | Home server key from a trusted channel | Authenticated, pinned, mismatch detected |
| Contact key from `smol://` or a QR code | Strong; requires no server trust | | 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 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 | | `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 | | Key change with a valid chain | Accepted, surfaced in the interface |

View file

@ -680,6 +680,9 @@ def cmd_send(args: argparse.Namespace, store: Store) -> int:
if not sep or not FM_KEY.match(key.strip()): if not sep or not FM_KEY.match(key.strip()):
raise SmolError(f"{raw!r} is not a valid `Key: value` header") raise SmolError(f"{raw!r} is not a valid `Key: value` header")
fields.append((key.strip(), value.strip())) 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() body = build_frontmatter(fields, text).encode()
envelope = seal(me, recipient, body, pad=not args.no_pad) envelope = seal(me, recipient, body, pad=not args.no_pad)
@ -828,6 +831,8 @@ COMMANDS = [
(("--body",), {"help": "message text; read from stdin when omitted"}), (("--body",), {"help": "message text; read from stdin when omitted"}),
(("--header",), {"action": "append", "metavar": "KEY:VALUE", (("--header",), {"action": "append", "metavar": "KEY:VALUE",
"help": "extra frontmatter field; repeatable"}), "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"})]), (("--no-pad",), {"action": "store_true", "help": "do not pad to 1 KiB"})]),
("fetch", cmd_fetch, "retrieve, verify and acknowledge mail", ("fetch", cmd_fetch, "retrieve, verify and acknowledge mail",
[(("--keep",), {"action": "store_true", "help": "do not delete from the server"})]), [(("--keep",), {"action": "store_true", "help": "do not delete from the server"})]),