fix: reject line breaks in frontmatter header values; note reference-impl limits
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
864daa0bad
commit
43e4be2b8a
3 changed files with 27 additions and 1 deletions
11
README.md
11
README.md
|
|
@ -33,6 +33,10 @@ The short form is typeable. The long form carries the key itself, so an address
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
|
## Reference code
|
||||||
|
|
||||||
|
The four Python files are a reference, not production software. They were written entirely by an AI agent from [SPEC.md](SPEC.md) alone: if one agent can implement both ends of the protocol, across two transports, from the document and nothing else, the specification is coherent and complete enough to build from. The code favours clarity over hardening and carries simplifications a deployed server would not — see [Audit](#audit). Treat it as an executable illustration of the spec.
|
||||||
|
|
||||||
## Reference server
|
## Reference server
|
||||||
|
|
||||||
[smolmaild.py](smolmaild.py) is a complete server in one file. It declares its own dependencies inline, so there is nothing to install:
|
[smolmaild.py](smolmaild.py) is a complete server in one file. It declares its own dependencies inline, so there is nothing to install:
|
||||||
|
|
@ -69,6 +73,13 @@ Sent mail carries the sender's full `smol://` address in a signed `Reply-To` fie
|
||||||
|
|
||||||
Mail is stored sealed and opened on demand, so the local database holds no plaintext.
|
Mail is stored sealed and opened on demand, so the local database holds no plaintext.
|
||||||
|
|
||||||
|
## Audit
|
||||||
|
|
||||||
|
A security review of the protocol and these reference implementations found no issues in the protocol itself: the trust model, sealing, key rotation, accept tokens and the two transports' authentication binding all hold up as specified. Two findings were implementation-level and specific to the reference code:
|
||||||
|
|
||||||
|
- The server's rate limiter uses a fixed window, which admits up to twice the configured rate across a window boundary. A production limiter should use a sliding window or token bucket.
|
||||||
|
- The TCP server runs one unbounded thread per connection, so a distributed connection flood can exhaust threads. A production server should cap concurrency, as the Reticulum server already does (SPEC.md §13.8).
|
||||||
|
|
||||||
## Specification
|
## Specification
|
||||||
|
|
||||||
[SPEC.md](SPEC.md) defines the wire format, the operations and the trust model.
|
[SPEC.md](SPEC.md) defines the wire format, the operations and the trust model.
|
||||||
|
|
|
||||||
|
|
@ -880,6 +880,11 @@ 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()))
|
||||||
|
# A frontmatter value is a single line (§5.5); a line break in a user-supplied
|
||||||
|
# value would smuggle extra fields into the sealed, signed payload.
|
||||||
|
for key, value in fields:
|
||||||
|
if "\n" in value or "\r" in value:
|
||||||
|
raise SmolError(f"{key} value must not contain a line break")
|
||||||
# §5.7: a signed reply address lets a first-time recipient answer us.
|
# §5.7: a signed reply address lets a first-time recipient answer us.
|
||||||
if (account_addr := store.account()) is not None and not args.anonymous:
|
if (account_addr := store.account()) is not None and not args.anonymous:
|
||||||
fields.append(("Reply-To", account_addr.uri(me.pk)))
|
fields.append(("Reply-To", account_addr.uri(me.pk)))
|
||||||
|
|
|
||||||
12
smolmaild.py
12
smolmaild.py
|
|
@ -411,7 +411,13 @@ class Store:
|
||||||
|
|
||||||
|
|
||||||
class RateLimiter:
|
class RateLimiter:
|
||||||
"""Fixed-window counter, keyed by IP address or by accept token (§10)."""
|
"""Fixed-window counter, keyed by IP address or by accept token (§10).
|
||||||
|
|
||||||
|
A fixed window admits up to 2x the limit across a window boundary (a burst
|
||||||
|
at the end of one window plus a burst at the start of the next). Acceptable
|
||||||
|
for a reference server; a production limiter should use a sliding window or
|
||||||
|
token bucket.
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(self, limit: int, window: float = 60.0) -> None:
|
def __init__(self, limit: int, window: float = 60.0) -> None:
|
||||||
self.limit = limit
|
self.limit = limit
|
||||||
|
|
@ -776,6 +782,10 @@ def static_public(static_key: bytes) -> bytes:
|
||||||
|
|
||||||
|
|
||||||
class MailServer(socketserver.ThreadingTCPServer):
|
class MailServer(socketserver.ThreadingTCPServer):
|
||||||
|
# One thread per connection with no concurrency cap: only conn_limiter (per
|
||||||
|
# IP) bounds it, so a distributed connection flood can exhaust threads. The
|
||||||
|
# Reticulum server caps links (§13.8); a production TCP server should bound
|
||||||
|
# workers likewise, e.g. a thread pool or a max-connections gate.
|
||||||
allow_reuse_address = True
|
allow_reuse_address = True
|
||||||
daemon_threads = True
|
daemon_threads = True
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue