feat: implement Phase 1 TCP client
This commit is contained in:
parent
1690aed3dc
commit
37df976fde
19 changed files with 5631 additions and 377 deletions
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
/target
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
identity.key
|
||||
*.key
|
||||
result
|
||||
result-*
|
||||
51
AGENTS.md
Normal file
51
AGENTS.md
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
# General Working Rules
|
||||
|
||||
## Coding
|
||||
|
||||
- **Simple and idiomatic.** Write readable, conventional code. Prefer composition, immutability, explicit dependencies, and a single source of truth.
|
||||
- **Small and focused.** Keep functions and modules single-purpose. Separate concerns and avoid unnecessary nesting, abstraction, and global mutable state.
|
||||
- **Reuse existing solutions.** Follow project conventions and prefer existing tools and dependencies. Introduce new ones only when justified.
|
||||
- **Minimal changes.** Implement only what is requested. Prefer deletion over rewriting, and avoid unrelated refactoring, formatting, or speculative features.
|
||||
|
||||
## Comments
|
||||
|
||||
- **Self-contained.** Explain essential context inline without requiring external documents or conversations. References to nearby source code are acceptable.
|
||||
- **Concise.** Usually one sentence. Put longer explanations in brief module headers or documentation.
|
||||
- **Why, not what.** Comment only on non-obvious decisions, constraints, invariants, and surprising behavior. Do not restate obvious code.
|
||||
- **No unnecessary history.** Include implementation history only when essential to understanding current behavior. Remove obsolete comments.
|
||||
|
||||
## Documentation
|
||||
|
||||
- Keep documentation concise, accurate, and synchronized with the implementation.
|
||||
- Distinguish implemented behavior from proposals and historical plans. Remove outdated information and shorten resolved findings.
|
||||
- Update specifications alongside changes to interfaces, formats, and contracts. Record important decisions where they are enforced.
|
||||
- **Natural reflow.** Write paragraphs as continuous lines without hard wrapping. Use blank lines between paragraphs and explicit line breaks only where structurally necessary.
|
||||
|
||||
## Working Style
|
||||
|
||||
- **Inspect first.** Examine existing code, configuration, architecture, and conventions rather than assuming how things work.
|
||||
- **Respect scope.** Follow the latest explicit instructions. Do not make unrelated changes or resume superseded tasks.
|
||||
- **Work silently.** No progress narration or request recaps. Interrupt only for genuine blockers or discoveries that materially change the approach.
|
||||
- **Resolve uncertainty.** Investigate missing information before asking. Never invent requirements or assume unverified behavior.
|
||||
- **Respect user decisions.** Identify significant design problems and propose concrete alternatives with trade-offs, but follow the user's choice.
|
||||
- **Respect authorization.** Observe approval requirements for changes, tests, commits, deployments, and destructive operations.
|
||||
- **Commit messages.** Use concise, single-line, tag-prefixed messages (e.g., fix:, feat:, refactor:, docs:). Describe what changed using the imperative mood.
|
||||
- **No credit notes.** Omit attribution, co-author trailers, AI-generated notices, and other credit statements from commits and generated documentation.
|
||||
|
||||
## Verification
|
||||
|
||||
- Test affected behavior using existing project tools. Add regression tests where appropriate.
|
||||
- Preserve safeguards for security, external input, data integrity, and concurrency.
|
||||
- Measure consequential assumptions before making architectural decisions.
|
||||
- Perform repository-wide cleanup or extensive refactoring only when explicitly requested.
|
||||
- Format and validate affected code without altering unrelated files.
|
||||
- Report what passed, failed, or could not be verified. Never claim untested behavior works.
|
||||
|
||||
## Completion
|
||||
|
||||
Summarize changes, verification results, important decisions, and unresolved issues in a few sentences. Avoid lengthy reports unless requested.
|
||||
|
||||
## Project Preferences
|
||||
|
||||
- use `git` colocated `jj` for version management
|
||||
- use `nix flake` to manage dependencies and scripting
|
||||
1027
Cargo.lock
generated
Normal file
1027
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
33
Cargo.toml
Normal file
33
Cargo.toml
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
[package]
|
||||
name = "fumi"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Smol Mail client"
|
||||
|
||||
[[bin]]
|
||||
name = "fumi"
|
||||
path = "src/main.rs"
|
||||
|
||||
[features]
|
||||
# RNS carrier over microReticulum (RNS.md); needs MICRORETICULUM_SOURCE_DIR
|
||||
# at build time, provided by the flake.
|
||||
rns = ["dep:cc"]
|
||||
|
||||
[dependencies]
|
||||
snow = "0.9"
|
||||
chacha20poly1305 = "0.10"
|
||||
ed25519-dalek = { version = "2", features = ["rand_core"] }
|
||||
x25519-dalek = { version = "2", features = ["static_secrets"] }
|
||||
sha2 = "0.10"
|
||||
hmac = "0.12"
|
||||
hkdf = "0.12"
|
||||
rand_core = { version = "0.6", features = ["getrandom"] }
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
data-encoding = "2"
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
log = "0.4"
|
||||
env_logger = "0.11"
|
||||
anyhow = "1"
|
||||
|
||||
[build-dependencies]
|
||||
cc = { version = "1", optional = true }
|
||||
408
PLAN.md
Normal file
408
PLAN.md
Normal file
|
|
@ -0,0 +1,408 @@
|
|||
# 文 fumi
|
||||
|
||||
A Rust implementation of the [Smol Mail](https://code.randogoth.com/randogoth/smolmail) client: a minimalist, end-to-end encrypted mail protocol over a Noise-secured TCP connection, with an optional Reticulum (RNS) mesh carrier behind the `rns` build feature. `fumi` implements the client side only — deriving identities, sealing and opening mail, and talking to a mailbox — and is the counterpart to [bunshin](https://code.randogoth.com/randogoth/bunshin), the server.
|
||||
|
||||
**Status: plan only.** Nothing in `src/` exists yet. Everything below is the intended design; the coverage tables are targets, not claims.
|
||||
|
||||
## Scope
|
||||
|
||||
| | v1.1 (TCP) | v1.2 (RNS) |
|
||||
|---|---|---|
|
||||
| `AUTH`, `RESOLVE`, `SEND`, `FETCH`, `DELETE`, `REGISTER` | planned | planned, `rns` feature |
|
||||
| Envelope sealing and opening (§5.1–5.4) | planned | identical, transport does not touch it |
|
||||
| Frontmatter (§5.5), sent copies (§5.6), `Reply-To` (§5.7) | planned | identical |
|
||||
| Accept tokens (§5.8) | planned | identical |
|
||||
| Key rotation and chain walking (§7) | planned | identical |
|
||||
| Server pinning | Noise static key | none — the destination hash is the pin (§13.4) |
|
||||
|
||||
Section numbers are [SPEC.md](https://code.randogoth.com/randogoth/smolmail/src/branch/main/SPEC.md) throughout; §13 onwards is [RNS.md](https://code.randogoth.com/randogoth/smolmail/src/branch/main/RNS.md).
|
||||
|
||||
## Addressing
|
||||
|
||||
```
|
||||
alice@example.org[:1961] short form, resolved via the server
|
||||
smol://alice@example.org/mfrggzdfzt... self-certifying, 52-char base32 identity
|
||||
smol+rns://alice@8f2c1d0a7b6e5f4c3d2a1b0e9f8c7d6a short form over Reticulum
|
||||
smol+rns://alice@8f2c1d0a7b6e5f4c3d2a1b0e9f8c7d6a/mfrggzdfzt... self-certifying over Reticulum
|
||||
```
|
||||
|
||||
The path component is the 32-byte identity key in unpadded lowercase base32 — **52 characters** (§3), not 32. The RNS authority is the server's 16-byte destination hash as exactly 32 lowercase hex characters (§13.2), compared in full, with no port and no bare `user@host` form. The scheme selects the transport; there is nothing else to configure and nothing to negotiate.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ main.rs — CLI │
|
||||
│ keygen whoami restore rotate trust register resolve │
|
||||
│ import contacts accept block send fetch delete list read │
|
||||
└──────────────────────────────┬──────────────────────────────┘
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ client.rs — the six operations, AUTH session setup, chain │
|
||||
│ walking, seal/open pipelines, token sync, outbound queue │
|
||||
└───┬──────────────┬──────────────┬──────────────┬────────────┘
|
||||
▼ ▼ ▼ ▼
|
||||
┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────────┐
|
||||
│ account.rs │ │ message.rs │ │ store.rs │ │ address.rs │
|
||||
│ master │ │ envelope │ │ SQLite │ │ the four forms │
|
||||
│ rotation │ │ payload │ │ contacts │ │ usernames │
|
||||
│ tokens │ │ frontmatter│ │ tokens │ │ URIs │
|
||||
│ certs │ │ message id │ │ seen, mail │ │ fingerprints │
|
||||
└──────┬─────┘ └─────┬──────┘ └────────────┘ └────────────────┘
|
||||
▼ ▼
|
||||
┌───────────────────────────────┐
|
||||
│ crypto.rs │
|
||||
│ hkdf, hmac, sha256, base32 │
|
||||
│ Ed25519/X25519 map, §2 checks │
|
||||
└───────────────────────────────┘
|
||||
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ transport.rs — trait Transport { request, bind, close }, │
|
||||
│ TransportBindValues, operation and status constants │
|
||||
└───────────────────▲─────────────────────▲───────────────────┘
|
||||
implements │ │ implements
|
||||
┌────────┴────────┐ ┌─────────┴──────────────┐
|
||||
│ tcp.rs │ │ rns/ (feature "rns") │
|
||||
│ Noise_NX │ │ shim FFI, links │
|
||||
│ u32 framing │ │ path discovery │
|
||||
│ static-key pin │ │ │
|
||||
└─────────────────┘ └────────────────────────┘
|
||||
```
|
||||
|
||||
`client.rs` depends on the `Transport` trait; the two carriers implement it. Only `transport.rs`'s bind values and the framing differ between them — every operation body, every signature and the whole envelope format are byte-identical (§13.5, §15).
|
||||
|
||||
## Modules
|
||||
|
||||
| File | Contents |
|
||||
|---|---|
|
||||
| `crypto.rs` | base32 (unpadded lowercase, as `bunshin/src/crypto.rs`), HKDF-SHA256, HMAC-SHA256, SHA-256, the Ed25519→X25519 map with §2's checks |
|
||||
| `address.rs` | `Address` parsing for all four forms, username validation (§3), self-certifying URI rendering, fingerprints |
|
||||
| `account.rs` | master secret, `seed_n` derivation, the current rotation index, accept-key and per-correspondent token derivation, rotation certificates |
|
||||
| `message.rs` | envelope seal/open (§5.1–5.3), message id (§5.4), frontmatter parse/build (§5.5) |
|
||||
| `transport.rs` | `Transport` trait, `TransportBindValues`, operation and status constants |
|
||||
| `tcp.rs` | Noise_NX initiator, `len u32 \|\| op u8 \|\| body` framing, static-key pinning |
|
||||
| `rns/` | `mod.rs`, `ffi.rs`, `transport.rs` — behind `#[cfg(feature = "rns")]` |
|
||||
| `client.rs` | the six operations, AUTH session setup, chain walking, token sync, send/fetch pipelines |
|
||||
| `store.rs` | SQLite: state, contacts, accepted, tokens, seen ids, inbox, sent |
|
||||
| `error.rs` | `SmolError` and the status-code mapping |
|
||||
| `lib.rs`, `main.rs` | library surface and CLI |
|
||||
|
||||
## Dependencies
|
||||
|
||||
```toml
|
||||
[package]
|
||||
name = "fumi"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Smol Mail client"
|
||||
|
||||
[features]
|
||||
# RNS carrier over microReticulum (§13); needs MICRORETICULUM_SOURCE_DIR at
|
||||
# build time, provided by the flake. Same shape as bunshin's.
|
||||
rns = ["dep:cc"]
|
||||
|
||||
[dependencies]
|
||||
snow = "0.9" # Noise_NX, TCP carrier only
|
||||
chacha20poly1305 = "0.10" # envelope AEAD (§5.1) — snow does not expose one
|
||||
ed25519-dalek = { version = "2", features = ["rand_core"] }
|
||||
x25519-dalek = { version = "2", features = ["static_secrets"] }
|
||||
sha2 = "0.10"
|
||||
hmac = "0.12" # accept tokens and their MACs (§5.8)
|
||||
hkdf = "0.12"
|
||||
rand_core = { version = "0.6", features = ["getrandom"] }
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
data-encoding = "2" # base32 and HEXLOWER; no separate hex crate
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
log = "0.4"
|
||||
env_logger = "0.11"
|
||||
anyhow = "1"
|
||||
|
||||
[build-dependencies]
|
||||
cc = { version = "1", optional = true } # compiles the RNS shim, see Phase 2
|
||||
```
|
||||
|
||||
`chacha20poly1305` is the dependency `bunshin` does not have and `fumi` cannot do without: the server stores envelopes without opening them, the client opens them. No `regex` — §3's username grammar is a dozen lines of `matches!`, the way `bunshin/src/proto.rs` does it. No `hex` — `data_encoding::HEXLOWER` already covers RNS destination hashes.
|
||||
|
||||
The Ed25519→X25519 map (§2) uses `ed25519_dalek::VerifyingKey::to_montgomery()` for the public half and `SigningKey::to_scalar_bytes()` fed to `x25519_dalek::StaticSecret` for the private half, which is exactly what libsodium's `crypto_sign_ed25519_{pk,sk}_to_curve25519` produce. §2 requires rejecting an all-zero agreement output and a low-order received ephemeral; `x25519-dalek` signals neither, so `crypto.rs` checks the output for all-zero and refuses, which covers both.
|
||||
|
||||
---
|
||||
|
||||
## Cryptographic detail
|
||||
|
||||
The one place a client cannot be approximately right. Taken from §5.2–§5.4; `smolmail.py`'s `seal`/`unseal` are the executable reference.
|
||||
|
||||
### Sealing
|
||||
|
||||
```
|
||||
esk, epk = X25519_keygen()
|
||||
shared = X25519(esk, to_x25519(recipient_identity)) # reject all-zero
|
||||
key = HKDF-SHA256(shared, salt = epk || recipient_identity,
|
||||
info = "smolmail/1 seal", len = 32) # one key, not two
|
||||
wipe(esk)
|
||||
|
||||
header = version u8 || sender 32 || time i64 || body_len u32
|
||||
signature = Ed25519(sender, "smolmail/1 msg" || to || epk || header || body)
|
||||
plaintext = header || body || signature || padding # padding to 1024, MAY, default on
|
||||
aad = "SMOL" || version u8 || to 32 || epk 32 # 69 bytes, the envelope header
|
||||
envelope = aad || ChaCha20-Poly1305(key, nonce = 0^12, aad, plaintext)
|
||||
id = SHA-256("smolmail/1 id" || envelope)
|
||||
```
|
||||
|
||||
- `"smolmail/1 seal"` is the HKDF info; `"smolmail/1 msg"` is the signature label. They are different labels for different jobs (§11).
|
||||
- The salt is `epk || recipient_identity`, not empty. It binds the key to this exact sealing.
|
||||
- **The sender is inside the ciphertext.** Nothing outside the AEAD names a sender — that is the whole of §5.2's unlinkability and §9's privacy claim. Frontmatter lives in `body`, so it is encrypted too.
|
||||
- The nonce is all-zero and that is correct: `key` is used exactly once because `esk` is fresh per message.
|
||||
- `body_len` makes the padding unambiguous and the tag protects it.
|
||||
|
||||
### Opening
|
||||
|
||||
Mirror image, plus the checks a receiver **MUST** perform (§5.3): the envelope's `to` is one of our own keys (including superseded ones, §7), the signature verifies against the `sender` it carries, `time` is not more than 86400 s ahead of our clock, and trailing bytes past `body_len + 64` are ignored as padding. A larger backwards skew MAY be surfaced.
|
||||
|
||||
### Accept tokens (§5.8)
|
||||
|
||||
```
|
||||
accept = HKDF-SHA256(master, salt = "", info = "smolmail/1 accept", len = 32)
|
||||
t = HMAC-SHA256(accept, correspondent_identity)
|
||||
mac = HMAC-SHA256(t, "smolmail/1 mac" || id)
|
||||
```
|
||||
|
||||
`accept` does not depend on the rotation index, so tokens survive our rotation; `t` is frozen at the identity the correspondent had when accepted, so it survives theirs. The correspondent's copy of `t` travels as a base32 `Accept` frontmatter field inside a sealed, signed payload, and is filed under the address that signed it — never under the key, which rotates.
|
||||
|
||||
---
|
||||
|
||||
## Client obligations
|
||||
|
||||
The requirements that have no natural home in an operation handler and are therefore the ones an implementation forgets. Each is owned by exactly one module.
|
||||
|
||||
| Requirement | Section | Owner |
|
||||
|---|---|---|
|
||||
| Reject a username that is not 1–63 of `[a-z0-9._-]`, does not start and end alphanumeric, or has adjacent separators; normalise to lowercase | §3 | `address.rs` |
|
||||
| Abort on a pinned-key mismatch and surface it; mark `RESOLVE` from an unpinned session unverified | §4 | `tcp.rs`, `client.rs` |
|
||||
| Refuse `REGISTER`, `FETCH`, `DELETE` against a server whose key did not come from a trusted channel | §4 | `client.rs` |
|
||||
| `AUTH` with `sync = 0` unless the local token set is known complete — a master-restored client must not erase the server's set with an empty one | §4 | `account.rs`, `store.rs` (`sync_ok` flag) |
|
||||
| Reject all-zero X25519 output and low-order ephemerals | §2 | `crypto.rs` |
|
||||
| Verify the payload signature, check `to` is ours, enforce the 86400 s forward skew | §5.3 | `message.rs` |
|
||||
| Keep a sent copy sealed to our own key | §5.6 | `client.rs` |
|
||||
| `Reply-To`: act on it only when the URI's key equals `sender`, never let it replace a bound key | §5.7 | `client.rs` |
|
||||
| Frontmatter: 4 KiB, 64 keys, first occurrence wins, case-insensitive keys, malformed block falls back to plain text, no YAML parser | §5.5 | `message.rs` |
|
||||
| Walk the rotation chain from the pinned key, verify both signatures per link, max 16 links; a broken, absent or over-long chain needs user confirmation; surface every rotation | §7 | `client.rs` |
|
||||
| Be able to re-derive every superseded private key and accept mail addressed to it | §7 | `account.rs` |
|
||||
| Keep a seen-id set so a replayed envelope is not re-stored after deletion | §10 | `store.rs` |
|
||||
| Page `FETCH` forward by `(received_at, id)` until a response is empty; deletion is a separate decision | §6.1 | `client.rs` |
|
||||
| Treat an unknown status code as failure, never retry automatically, surface the number, never parse the reason string | §12 | `error.rs` |
|
||||
| Own the outbound queue and retry; resending is safe because ids are derived | §6, §13.10 | `store.rs`, `client.rs` |
|
||||
|
||||
## Local store
|
||||
|
||||
One SQLite file, shared by both carriers (§15). Following the reference client's schema:
|
||||
|
||||
```
|
||||
state one row: username, host, port, scheme, rotations, cursor (after_time, after_id), sync_ok
|
||||
servers host -> pinned Noise static key, pinned_at (TCP only; RNS has nothing to pin)
|
||||
contacts address -> identity, verified, seen_at (verified = key came from a smol:// URI)
|
||||
accepted address -> identity frozen at acceptance, active (our tokens, issued to others)
|
||||
tokens address -> token (their tokens, issued to us)
|
||||
seen id -> at (§10 replay guard)
|
||||
inbox id -> envelope, received_at, tier (sealed at rest, opened on demand)
|
||||
sent id -> recipient, envelope, sent_at (§5.6 copies)
|
||||
```
|
||||
|
||||
Envelopes are stored sealed; no plaintext at rest.
|
||||
|
||||
---
|
||||
|
||||
## CLI
|
||||
|
||||
One master per store, selected by the global `--key` and `--db` flags — a second identity is a second pair of files. This drops the multi-account juggling an earlier draft had and with it the ambiguity of which account a bare `fetch <address>` meant: the home address lives in `state`, so only the commands that address someone else take an address.
|
||||
|
||||
```
|
||||
fumi [--key identity.key] [--db fumi.db] [--timeout 30] <command>
|
||||
|
||||
# identity
|
||||
fumi keygen [--force] create a master secret
|
||||
fumi whoami address, public key, fingerprint, rotation index
|
||||
fumi restore <address> recover local state from the master alone
|
||||
fumi rotate advance the rotation index, sign and push the certificate
|
||||
|
||||
# servers and contacts
|
||||
fumi trust <host> <key-b32> [--force] pin a server's Noise static key (TCP only)
|
||||
fumi register <address> [--invite TOKEN] bind this identity to a username
|
||||
fumi resolve <address> look up a contact's key, walk the chain, pin it
|
||||
fumi import <smol-uri> add a contact from a self-certifying address
|
||||
fumi contacts known keys and how each was learned
|
||||
|
||||
# accept tokens
|
||||
fumi accept <address> issue a token, admit to the main tier, sync the set
|
||||
fumi block <address> withdraw it, sync the set
|
||||
|
||||
# mail
|
||||
fumi send <address> [--subject S] [--body TEXT | --file F | -] [--header K:V]
|
||||
[--reply-to ID] [--anonymous] [--no-pad]
|
||||
fumi fetch [--keep] [--reset] retrieve, verify, store, acknowledge
|
||||
fumi delete <id>... remove from the server explicitly
|
||||
fumi list [--sent] [--requests]
|
||||
fumi read <id> [--sent]
|
||||
|
||||
# RNS carrier (feature "rns"); addresses select it by scheme
|
||||
fumi --rns-udp <host:port> --rns-udp-forward <host:port> [--rns-storage DIR] <command>
|
||||
```
|
||||
|
||||
`trust` takes a host rather than a full address because a pin is per server, not per user. `register <address>` binds the username the address already carries, so there is no separate `--username`. Without the `rns` feature, a `smol+rns://` address fails with a message naming the feature rather than a parse error.
|
||||
|
||||
## Key differences from bunshin
|
||||
|
||||
| Aspect | bunshin (server) | fumi (client) |
|
||||
|---|---|---|
|
||||
| Noise role | responder, holds the static key | initiator, has no static key (NX) |
|
||||
| Session | accepts connections | initiates, one per operation batch |
|
||||
| Trust | publishes its static key | pins it and aborts on mismatch |
|
||||
| Signatures | verifies AUTH and REGISTER | produces them over the bind values |
|
||||
| Envelopes | stores them sealed, never opens one | seals and opens; needs the AEAD directly |
|
||||
| Rate limits | enforces | respects, and never retries automatically |
|
||||
| RNS role | `IN`/`SINGLE` destination, announces, handles requests | path discovery, `OUT`/`SINGLE`, opens links, sends requests |
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: TCP client
|
||||
|
||||
| # | Module | Notes |
|
||||
|---|---|---|
|
||||
| 1 | `crypto.rs` | base32, HKDF, HMAC, the X25519 map and §2's checks |
|
||||
| 2 | `address.rs` | all four address forms, username grammar |
|
||||
| 3 | `account.rs` | master, `seed_n`, accept key, tokens, rotation certificates |
|
||||
| 4 | `message.rs` | seal, open, message id, frontmatter |
|
||||
| 5 | `transport.rs` | trait, `TransportBindValues::tcp`, constants |
|
||||
| 6 | `tcp.rs` | Noise_NX initiator, framing, pinning |
|
||||
| 7 | `store.rs` | schema above |
|
||||
| 8 | `client.rs` | six operations, chain walking, token sync, fetch pipeline |
|
||||
| 9 | `main.rs` | CLI |
|
||||
|
||||
Modules 1–4 are pure and fully unit-testable against the reference client's vectors before a socket is opened.
|
||||
|
||||
## Phase 2: RNS carrier
|
||||
|
||||
Built the way [bunshin's RNS carrier](https://code.randogoth.com/randogoth/bunshin/src/branch/main/RNS.md) was, and for the same reasons: [microReticulum](https://github.com/attermann/microReticulum) (C++17, Apache-2.0, on upstream's community-implementations list) behind a thin C ABI shim, pinned at the commit bunshin uses — `40fa628`, 2026-07-20. The Python-sidecar and native-Rust options are not chosen: the first doubles the session rules across two implementations, the second has no listed-upstream candidate. Revisit only if a Rust stack gets listed.
|
||||
|
||||
No `libffi`: it builds calls at runtime, which is not what linking a C++ library needs. `build.rs` compiles the shim with `cc` and the library with cmake, exactly as bunshin does.
|
||||
|
||||
| # | Work | Notes |
|
||||
|---|---|---|
|
||||
| 1 | `shim/` | the C ABI below, plus `udp_interface.{h,cpp}` from bunshin |
|
||||
| 2 | `build.rs` | cmake over the vendored checkout, then `cc` for the shim |
|
||||
| 3 | `rns/ffi.rs` | `extern "C"` declarations and the safe wrapper |
|
||||
| 4 | `rns/transport.rs` | `impl Transport`, `TransportBindValues::rns`, timeouts, teardown |
|
||||
| 5 | `address.rs`, `main.rs` | `smol+rns://` dispatch and the `--rns-*` flags |
|
||||
| 6 | `flake.nix` | pin microReticulum and every FetchContent dependency |
|
||||
| 7 | tests | bind vectors, then end-to-end against `smolmaild_rns.py` |
|
||||
|
||||
`transport.rs` itself does not change: the trait already carries the bind values, which is the only thing the two carriers disagree about.
|
||||
|
||||
### Client-side surface
|
||||
|
||||
microReticulum's client API is present and mirrors the Python reference's flow:
|
||||
|
||||
| Need | API | Header |
|
||||
|---|---|---|
|
||||
| Path discovery | `Transport::has_path`, `Transport::request_path` | `Transport.h` |
|
||||
| Server identity | `Identity::recall(destination_hash)` | `Identity.h` |
|
||||
| Destination | `Destination(identity, OUT, SINGLE, "smolmail", "server")` | `Destination.h` |
|
||||
| Link | `Link(destination, established_cb, closed_cb)`, `Link::link_id()`, `teardown()` | `Link.h` |
|
||||
| Request | `Link::request(path, data, response_cb, failed_cb, progress_cb, timeout)` | `Link.h:194` |
|
||||
| Response | `RequestReceipt::get_response()`, `get_status()` | `Link.h:96` |
|
||||
|
||||
Every callback is a bare function pointer with no userdata, the same constraint bunshin hit. A CLI has one request in flight at a time, so the shim keeps a single response slot plus a condvar rather than a map.
|
||||
|
||||
### Shim ABI
|
||||
|
||||
```c
|
||||
/* All calls block the caller; the shim owns the Reticulum loop thread and
|
||||
every callback fires there, as in bunshin's shim. */
|
||||
int smolmail_rns_start(const char *storage_dir,
|
||||
const char *udp_listen_host, uint16_t udp_listen_port,
|
||||
const char *udp_forward_host, uint16_t udp_forward_port);
|
||||
|
||||
/* Requests a path if none is known and waits for it, recalls the identity,
|
||||
builds the OUT/SINGLE destination, opens the link and waits for ACTIVE.
|
||||
Returns the 16-byte link id, which the bind values are derived from. */
|
||||
int smolmail_rns_connect(const uint8_t *destination_hash, uint32_t timeout_ms,
|
||||
uint8_t *link_id_out);
|
||||
|
||||
/* request is `op u8 || body`, response is `status u8 || payload`. */
|
||||
int smolmail_rns_request(const uint8_t *request, size_t request_len,
|
||||
uint32_t timeout_ms, uint8_t *out, size_t cap, size_t *out_len);
|
||||
|
||||
void smolmail_rns_close(void);
|
||||
```
|
||||
|
||||
No client Reticulum identity is created or loaded, and the shim never calls `Link::identify`: a server MUST NOT require identification (§13.8), and a durable client handle is exactly what the wire format is built to withhold.
|
||||
|
||||
### Client requirements specific to this carrier
|
||||
|
||||
- Request a path and **wait** for it before constructing the link. Creating one first makes RNS assume the maximum hop count and fail after minutes rather than promptly (§13.3). `Identity::recall` can still return nothing immediately after a path appears; that is a retry, not an error.
|
||||
- Set an explicit request timeout. Reticulum's default is derived from the round-trip time and covers a packet, not a `FETCH` page (§13.5). Default 30 s, as the reference client.
|
||||
- One fresh link per session, never reused across authentications — `link_id` is what stops an `AUTH` replaying on another link (§13.6).
|
||||
- No path, a dead link, a rejected resource and a timeout are local errors. They MUST NOT be reported as status codes (§13.5).
|
||||
- Tear the link down when done rather than leave it on keepalives (§13.10).
|
||||
- Remember a size cap learned from status 6 or 7 instead of discovering it twice (§13.7). Servers run ~32 KiB envelope and fetch budgets on mesh.
|
||||
- Retrying a `SEND` of unknown outcome is safe and expected here: ids are derived and a resend returns the same id with no new message (§10, §13.10).
|
||||
|
||||
### Bind values
|
||||
|
||||
`TransportBindValues` is lifted from `bunshin/src/bind.rs` unchanged, including its test vectors — the client produces the signatures the server verifies, so sharing the construction is the point.
|
||||
|
||||
```
|
||||
h = SHA-256("smolmail/1 bind" || destination || link_id) # replaces the Noise handshake hash
|
||||
server_static = SHA-256("smolmail/1 bind" || destination) # replaces the Noise static key
|
||||
```
|
||||
|
||||
### Build and interfaces
|
||||
|
||||
`build.rs` copies the microReticulum checkout from `MICRORETICULUM_SOURCE_DIR` into `OUT_DIR`, applies the one-line `#include <cstdint>` patch upstream master needs under current libstdc++, configures cmake with every `RNS_<DEP>_SOURCE_DIR` override the flake supplies, then compiles the shim with `cc` and links both archives. The flake vendors each FetchContent dependency as a pinned `flake = false` input so the sandboxed build never fetches.
|
||||
|
||||
microReticulum ships no interfaces, only examples, so `shim/udp_interface.{h,cpp}` comes over from bunshin verbatim (Apache-2.0). One asymmetry: bunshin can answer to the source of the last datagram it received, but a client speaks first, so `--rns-udp-forward` is effectively required and should point at an `rnsd` or at the server's UDP interface.
|
||||
|
||||
Two upstream divergences bunshin already found and that apply here too: `Curve25519::eval` does not clamp the scalar, so a destination hash must never be re-derived in Rust — but a client only ever consumes a hash from an address, so this stays a non-issue as long as nothing tries to verify one locally. And microReticulum splices request and response payloads into msgpack envelopes verbatim; bunshin's shim packs and unpacks on the server side, and the client shim should expect the same in both directions. Verify against `smolmaild_rns.py` before trusting the symmetry.
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit
|
||||
Address parsing and rejection (52-char base32, 32-char hex, port rejection on `smol+rns://`, bad usernames); identity derivation and rotation against vectors from `smolmail.py`; seal/open round trip; `unseal` of an envelope produced by the reference client, and the reverse; frontmatter parser against §5.5's rules including the malformed-block fallback; token and MAC derivation; `TransportBindValues` against bunshin's vectors; chain walking, including the broken, over-long and absent cases.
|
||||
|
||||
### Integration
|
||||
Against a local `bunshin`: register, resolve, send, fetch, delete, accept-token sync with both `sync` values, key rotation followed by a fetch of mail addressed to the superseded key, quota and rate-limit responses, and an unknown status code.
|
||||
|
||||
### Interoperability
|
||||
Both directions against `smolmail.py`/`smolmaild.py` on TCP and `smolmail_rns.py`/`smolmaild_rns.py` on RNS, plus the cross-transport case §15 promises: send over TCP, fetch over RNS, one identity and one store.
|
||||
|
||||
---
|
||||
|
||||
## Project structure
|
||||
|
||||
```
|
||||
fumi/
|
||||
├── Cargo.toml
|
||||
├── build.rs # rns feature only
|
||||
├── flake.nix # vendors microReticulum and its deps
|
||||
├── shim/
|
||||
│ ├── smolmail_rns.{h,cpp} # C ABI over microReticulum, client side
|
||||
│ └── udp_interface.{h,cpp} # from bunshin, Apache-2.0
|
||||
├── src/
|
||||
│ ├── main.rs lib.rs error.rs
|
||||
│ ├── client.rs account.rs address.rs message.rs store.rs crypto.rs
|
||||
│ ├── transport.rs tcp.rs
|
||||
│ └── rns/ mod.rs ffi.rs transport.rs
|
||||
├── tests/
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- [SPEC.md](https://code.randogoth.com/randogoth/smolmail/src/branch/main/SPEC.md) — protocol 1.1
|
||||
- [RNS.md](https://code.randogoth.com/randogoth/smolmail/src/branch/main/RNS.md) — the 1.2 addendum
|
||||
- [smolmail.py](https://code.randogoth.com/randogoth/smolmail/src/branch/main/smolmail.py), [smolmail_rns.py](https://code.randogoth.com/randogoth/smolmail/src/branch/main/smolmail_rns.py) — reference clients
|
||||
- [bunshin](https://code.randogoth.com/randogoth/bunshin) and its [RNS.md](https://code.randogoth.com/randogoth/bunshin/src/branch/main/RNS.md) — the server, and the carrier this plan follows
|
||||
- [microReticulum](https://github.com/attermann/microReticulum) — C++ Reticulum, pinned at `40fa628`
|
||||
435
README.md
435
README.md
|
|
@ -1,399 +1,80 @@
|
|||
# Fumi - Smol Mail Client (Rust)
|
||||
# fumi
|
||||
|
||||
A Rust implementation of the Smol Mail client protocol, compatible with version 1.2 (including RNS transport). Fumi is the client counterpart to [bunshin](../bunshin), the Smol Mail server implementation.
|
||||
A Rust client for [Smol Mail](https://code.randogoth.com/randogoth/smolmail): a minimalist, end-to-end encrypted mail protocol over a Noise-secured TCP connection. `fumi` derives an identity from one 32-byte master secret, seals and opens mail, and talks to a mailbox; it is the counterpart to [bunshin](https://code.randogoth.com/randogoth/bunshin), the server.
|
||||
|
||||
## Overview
|
||||
## Status
|
||||
|
||||
Smol Mail is a minimalist, end-to-end encrypted mail protocol. This client implementation supports:
|
||||
Phase 1, the TCP carrier (SPEC.md 1.1), is implemented and interoperates in both directions with the reference client `smolmail.py` and the reference server `smolmaild.py`, and with `bunshin`. The Reticulum carrier of RNS.md (1.2) is not built yet: `smol+rns://` addresses parse and fail with a message naming the `rns` feature, and the flake does not yet vendor microReticulum.
|
||||
|
||||
- **Full protocol v1.1**: All operations over TCP with Noise_NX_25519_ChaChaPoly_SHA256
|
||||
- **Full protocol v1.2**: Reticulum Network Stack (RNS) transport for mesh networking
|
||||
- **Shared codebase**: Single binary supports both transports (via feature flags)
|
||||
- **Interoperability**: Works with reference Python implementations and bunshin server
|
||||
|
||||
## Protocol Compatibility
|
||||
|
||||
| Feature | v1.1 (TCP) | v1.2 (RNS) |
|
||||
|---------|------------|------------|
|
||||
| AUTH | ✅ | ✅ |
|
||||
| RESOLVE | ✅ | ✅ |
|
||||
| SEND | ✅ | ✅ |
|
||||
| FETCH | ✅ | ✅ |
|
||||
| DELETE | ✅ | ✅ |
|
||||
| REGISTER | ✅ | ✅ |
|
||||
| Accept Tokens | ✅ | ✅ |
|
||||
| Key Rotation | ✅ | ✅ |
|
||||
| Message Encryption | ✅ | ✅ |
|
||||
|
||||
## Address Formats
|
||||
|
||||
### TCP Transport (v1.1)
|
||||
```
|
||||
smol://alice@example.com[:1961]
|
||||
smol://alice@example.com/abcdefghijklmnopqrstuvwxyz234567 # self-certifying
|
||||
```
|
||||
|
||||
### RNS Transport (v1.2)
|
||||
```
|
||||
smol+rns://alice@8f2c1d0a7b6e5f4c3d2a1b0e9f8c7d6a
|
||||
smol+rns://alice@8f2c1d0a7b6e5f4c3d2a1b0e9f8c7d6a/abcdefghijklmnopqrstuvwxyz234567
|
||||
```
|
||||
|
||||
The RNS address uses the server's 32-character hex destination hash as the host.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
## Addressing
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ fumi client │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||
│ │ CLI (main.rs) │ │
|
||||
│ │ keygen, import, trust, register, send, │ │
|
||||
│ │ fetch, delete, resolve, show │ │
|
||||
│ └──────────────────────────────┬──────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────────────────────────▼──────────────────────────┐ │
|
||||
│ │ Client Core (client.rs) │ │
|
||||
│ │ - Session management with servers │ │
|
||||
│ │ - Message composition and parsing │ │
|
||||
│ │ - Envelope sealing/unsealing │ │
|
||||
│ └──────────────────────────────┬──────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────────────────────────▼──────────────────────────┐ │
|
||||
│ │ Account (account.rs) │ │
|
||||
│ │ - Master secret management │ │
|
||||
│ │ - Identity derivation (rotation chains) │ │
|
||||
│ │ - Token generation │ │
|
||||
│ └──────────────────────────────┬──────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌─────────────────┐ ┌─────────────────────────────┐ │ │
|
||||
│ │ TCP Transport │ │ RNS Transport │ │ │
|
||||
│ │ (tcp_transport) │ │ (rns_transport + FFI) │ │ │
|
||||
│ └────────┬────────┘ └──────────┬───────────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ ▼ ▼ │
|
||||
│ ┌────────────────────────────────────────────────────────┐ │
|
||||
│ │ Transport Trait (transport.rs) │ │
|
||||
│ │ - connect(address) : Establish session │ │
|
||||
│ │ - request(op, body) : Send frame, get response │ │
|
||||
│ │ - bind_values() : Get auth binding values │ │
|
||||
│ │ - close() : Clean up connection │ │
|
||||
│ └────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||
│ │ Store (store.rs) │ │
|
||||
│ │ - SQLite local mailbox │ │
|
||||
│ │ - Accounts, identities, tokens, messages │ │
|
||||
│ └─────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
alice@example.org[:1961] short form, resolved via the server
|
||||
smol://alice@example.org/mfrggzdfzt... self-certifying, 52-char base32 identity
|
||||
smol+rns://alice@8f2c1d0a7b6e5f4c3d2a1b0e9f8c7d6a short form over Reticulum (rns feature)
|
||||
smol+rns://alice@8f2c1d0a7b6e5f4c3d2a1b0e9f8c7d6a/mfrggzdfzt... self-certifying over Reticulum
|
||||
```
|
||||
|
||||
---
|
||||
## Usage
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: Core Client (TCP Only)
|
||||
|
||||
#### Dependencies
|
||||
|
||||
```toml
|
||||
[package]
|
||||
name = "fumi"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Smol Mail client"
|
||||
|
||||
[dependencies]
|
||||
snow = "0.9" # Noise protocol
|
||||
ed25519-dalek = "2" # Ed25519 signatures
|
||||
x25519-dalek = "2" # X25519 key agreement
|
||||
sha2 = "0.10" # SHA-256
|
||||
rand_core = "0.6" # Randomness
|
||||
hmac = "0.12" # HMAC-SHA256
|
||||
clap = "4" # CLI parsing
|
||||
log = "0.4" # Logging
|
||||
env_logger = "0.11" # Logger setup
|
||||
anyhow = "1" # Error handling
|
||||
rusqlite = "0.32" # SQLite local store
|
||||
hex = "0.4" # Hex encoding
|
||||
data-encoding = "2" # Base32 encoding
|
||||
```
|
||||
|
||||
#### Modules
|
||||
|
||||
1. **crypto.rs** - Cryptographic primitives
|
||||
- Base32 encoding/decoding
|
||||
- HKDF-SHA256
|
||||
- HMAC-SHA256
|
||||
- Identity derivation
|
||||
- Accept token generation
|
||||
- Message ID computation
|
||||
|
||||
2. **address.rs** - Address parsing
|
||||
- Parse TCP addresses: `smol://user@host[:port][/identity]`
|
||||
- Parse RNS addresses: `smol+rns://user@desthash[/identity]`
|
||||
- Validate usernames
|
||||
- Generate self-certifying URIs
|
||||
|
||||
3. **account.rs** - Account management
|
||||
- Master secret storage
|
||||
- Identity key derivation with rotation
|
||||
- Current identity tracking
|
||||
- Token generation for correspondents
|
||||
|
||||
4. **transport.rs** - Transport trait
|
||||
- Define `Transport` trait with connect/request/close
|
||||
- Define `TransportBindValues` for auth bindings
|
||||
- Define operation and status constants
|
||||
|
||||
5. **tcp_transport.rs** - TCP transport
|
||||
- Noise_NX handshake
|
||||
- Frame reading/writing
|
||||
- Server key pinning (optional)
|
||||
|
||||
6. **client.rs** - Core client logic
|
||||
- Session management
|
||||
- REGISTER operation
|
||||
- AUTH operation
|
||||
- RESOLVE operation
|
||||
- SEND operation (envelope creation)
|
||||
- FETCH operation
|
||||
- DELETE operation
|
||||
- Message sealing/unsealing
|
||||
|
||||
7. **store.rs** - Local SQLite store
|
||||
- Account storage
|
||||
- Trusted server keys
|
||||
- Accept tokens
|
||||
- Outbound message queue
|
||||
- Inbound message storage
|
||||
|
||||
8. **main.rs** - CLI
|
||||
- keygen: Generate new identity
|
||||
- import: Import existing identity
|
||||
- accounts: List accounts
|
||||
- trust: Trust server key (TCP)
|
||||
- register: Register username
|
||||
- send: Send message
|
||||
- fetch: Fetch messages
|
||||
- delete: Delete messages
|
||||
- resolve: Resolve username
|
||||
- show: Display message info
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: RNS Transport
|
||||
|
||||
Same approach as bunshin's RNS integration:
|
||||
|
||||
1. **FFI with microReticulum** (recommended for production)
|
||||
- Use C++ microReticulum via FFI bindings
|
||||
- Official implementation, full compliance
|
||||
- Complex but most reliable
|
||||
|
||||
2. **Python IPC** (quick validation)
|
||||
- Call `smolmail_rns.py` as subprocess
|
||||
- Share data via files or pipes
|
||||
- Fast to implement
|
||||
|
||||
3. **Native Rust** (future)
|
||||
- Wait for mature Rust RNS crate
|
||||
- Or implement minimal subset
|
||||
|
||||
---
|
||||
|
||||
## Key Differences from Server (bunshin)
|
||||
|
||||
| Aspect | bunshin (server) | fumi (client) |
|
||||
|--------|------------------|---------------|
|
||||
| Noise role | Responder (has static key) | Initiator (no static key) |
|
||||
| Session | Accepts connections | Initiates connections |
|
||||
| Trust | Server key pinned by client | Client pins server key |
|
||||
| Auth | Verifies client signatures | Signs requests |
|
||||
| Rate limiting | Enforces limits | Respects limits |
|
||||
|
||||
---
|
||||
|
||||
## CLI Commands
|
||||
|
||||
```bash
|
||||
# Identity management
|
||||
fumi keygen # Generate new identity
|
||||
fumi keygen --name alice # Generate with name
|
||||
fumi import --name alice -m ABCD... # Import master secret
|
||||
fumi accounts # List accounts
|
||||
|
||||
# Server trust (TCP only)
|
||||
fumi trust smol://alice@example.com ABCD... # Trust server key
|
||||
|
||||
# Account registration
|
||||
fumi register smol://alice@example.com --username alice
|
||||
fumi register smol://alice@example.com --username alice --invite TOK...
|
||||
|
||||
# Messaging
|
||||
fumi send smol://bob@example.com --subject "Hello" --body "World"
|
||||
fumi send smol://bob@example.com --file message.txt
|
||||
fumi fetch smol://alice@example.com
|
||||
fumi delete smol://alice@example.com --ids ABCD...
|
||||
fumi resolve smol://alice@example.com --username bob
|
||||
fumi show ABCD... # Show message details
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
One master per store, selected by the global `--key` and `--db` flags; a second identity is a second pair of files.
|
||||
|
||||
```
|
||||
fumi/
|
||||
├── Cargo.toml # Project configuration
|
||||
├── Cargo.lock
|
||||
├── src/
|
||||
│ ├── main.rs # CLI entry point
|
||||
│ ├── lib.rs # Library exports
|
||||
│ ├── client.rs # Core client logic
|
||||
│ ├── account.rs # Account/identity management
|
||||
│ ├── address.rs # Address parsing
|
||||
│ ├── store.rs # Local SQLite store
|
||||
│ ├── crypto.rs # Cryptographic primitives
|
||||
│ ├── transport.rs # Transport trait
|
||||
│ ├── tcp_transport.rs # TCP transport implementation
|
||||
│ └── error.rs # Error types
|
||||
├── tests/
|
||||
│ └── integration/ # Integration tests
|
||||
├── flake.nix # Nix flake (optional)
|
||||
└── README.md # This file
|
||||
fumi [--key identity.key] [--db fumi.db] [--timeout 30] <command>
|
||||
|
||||
# identity
|
||||
fumi keygen [--force] create a master secret
|
||||
fumi whoami address, public key, fingerprint, rotation index
|
||||
fumi restore <address> recover local state from the master alone
|
||||
fumi rotate advance the rotation index, sign and push the certificate
|
||||
|
||||
# servers and contacts
|
||||
fumi trust <host> <key-b32> [--force] pin a server's Noise static key
|
||||
fumi register <address> [--invite TOKEN] bind this identity to a username
|
||||
fumi resolve <address> look up a contact's key, walk the chain, pin it
|
||||
fumi import <smol-uri> add a contact from a self-certifying address
|
||||
fumi contacts known keys and how each was learned
|
||||
|
||||
# accept tokens
|
||||
fumi accept <address> issue a token, admit to the main tier, sync the set
|
||||
fumi block <address> withdraw it, sync the set
|
||||
|
||||
# mail
|
||||
fumi send <address> [--subject S] [--body TEXT | --file F | -] [--header K:V] [--reply-to ID] [--anonymous] [--no-pad]
|
||||
fumi fetch [--keep] [--reset] retrieve, verify, store, acknowledge
|
||||
fumi delete <id>... remove from the server explicitly
|
||||
fumi list [--sent] [--requests]
|
||||
fumi read <id> [--sent]
|
||||
```
|
||||
|
||||
---
|
||||
Mail is stored sealed and opened on demand; there is no plaintext at rest. A first contact is learned by trust on first use and marked unverified when the server's key was not pinned; a key change is accepted only when a signed rotation chain leads from the key held to the one offered, and is surfaced rather than applied silently.
|
||||
|
||||
## Implementation Priority
|
||||
## Build
|
||||
|
||||
### Phase 1: TCP Client (High Priority)
|
||||
```
|
||||
nix build # or: nix develop -c cargo build
|
||||
nix develop -c cargo test
|
||||
```
|
||||
|
||||
| # | Module | Description | Dependencies |
|
||||
|---|--------|-------------|--------------|
|
||||
| 1 | crypto.rs | Base32, HKDF, HMAC, identity derivation | sha2, hmac, data-encoding |
|
||||
| 2 | address.rs | Address parsing and validation | regex, anyhow |
|
||||
| 3 | account.rs | Account and identity management | ed25519-dalek, rand_core |
|
||||
| 4 | transport.rs | Transport trait and types | None |
|
||||
| 5 | tcp_transport.rs | TCP/Noise transport | snow, std::net |
|
||||
| 6 | client.rs | Core operations (REGISTER, AUTH, etc.) | All above |
|
||||
| 7 | store.rs | SQLite local storage | rusqlite |
|
||||
| 8 | main.rs | CLI | clap, all above |
|
||||
| 9 | Tests | Unit and integration tests | All above |
|
||||
`bunshin` and the reference clients make a complete test rig: register against a local server, exchange mail in both directions, then rotate and fetch the mail that the superseded key still receives.
|
||||
|
||||
### Phase 2: RNS Support (Medium Priority)
|
||||
## Modules
|
||||
|
||||
| # | Module | Description | Dependencies |
|
||||
|---|--------|-------------|--------------|
|
||||
| 1 | rns/ffi.rs | FFI bindings to microReticulum | libffi |
|
||||
| 2 | rns/transport.rs | RNS transport implementation | ffi.rs |
|
||||
| 3 | rns/mod.rs | RNS module exports | transport.rs |
|
||||
| 4 | Update transport.rs | Add RNS feature flag | None |
|
||||
| 5 | Update client.rs | Handle RNS-specific logic | rns module |
|
||||
| 6 | Update main.rs | Add RNS CLI commands | rns module |
|
||||
| 7 | Tests | RNS integration tests | All above |
|
||||
| File | Contents |
|
||||
|---|---|
|
||||
| `src/crypto.rs` | base32, HKDF, HMAC, SHA-256, the Ed25519→X25519 map with SPEC.md §2's checks |
|
||||
| `src/address.rs` | parsing for all four address forms, username validation, fingerprints |
|
||||
| `src/account.rs` | master, `seed_n` derivation, accept-key and tokens, rotation certificates |
|
||||
| `src/message.rs` | envelope seal/open (§5.1–§5.3), message id (§5.4), frontmatter (§5.5) |
|
||||
| `src/transport.rs` | `Transport` trait, bind values, operation and status constants |
|
||||
| `src/tcp.rs` | Noise_NX initiator, `len u32 \|\| op u8 \|\| body` framing, static-key pinning |
|
||||
| `src/client.rs` | the six operations, AUTH, chain walking, token sync, fetch pipeline |
|
||||
| `src/store.rs` | SQLite: state, contacts, accepted, tokens, seen ids, inbox, sent |
|
||||
|
||||
---
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests
|
||||
- Address parsing (TCP and RNS)
|
||||
- Identity generation and rotation
|
||||
- Message sealing and unsealing
|
||||
- Envelope format validation
|
||||
- Token generation and verification
|
||||
|
||||
### Integration Tests
|
||||
- Register with bunshin server (TCP)
|
||||
- Send and receive messages (TCP)
|
||||
- Key rotation interoperability
|
||||
- Accept token usage
|
||||
- Error response handling
|
||||
|
||||
### Interoperability Tests
|
||||
- Against `smolmail.py` reference client
|
||||
- Against `smolmaild.py` reference server
|
||||
- Against `smolmail_rns.py` reference client
|
||||
- Against `smolmaild_rns.py` reference server
|
||||
|
||||
---
|
||||
|
||||
## Compatibility Matrix
|
||||
|
||||
| Client \ Server | bunshin (TCP) | bunshin (RNS) | smolmaild.py | smolmaild_rns.py |
|
||||
|------------------|---------------|---------------|---------------|------------------|
|
||||
| fumi (TCP) | ✅ Full | ❌ No | ✅ Full | ❌ No |
|
||||
| fumi (RNS) | ❌ No | ✅ Full | ❌ No | ✅ Full |
|
||||
|
||||
Both transports share the same account and store, so a user can use both transports with the same identity.
|
||||
|
||||
---
|
||||
|
||||
## Key Implementation Notes
|
||||
|
||||
### Message Sealing (SPEC.md S5)
|
||||
|
||||
1. Generate ephemeral X25519 keypair
|
||||
2. Convert recipient Ed25519 public key to X25519 (per SPEC.md S2)
|
||||
3. Perform X25519 key agreement
|
||||
4. Derive two 32-byte keys using HKDF-SHA256:
|
||||
- `k1 = HKDF(shared, "", "smolmail/1 msg", 64)[0:32]`
|
||||
- `k2 = HKDF(shared, "", "smolmail/1 msg", 64)[32:64]`
|
||||
5. Build payload with frontmatter (unencrypted): version, sender, timestamp
|
||||
6. Build body with: version, timestamp, body_length, body (padded to 1024-byte boundary)
|
||||
7. Encrypt body with ChaCha20-Poly1305 using k1
|
||||
8. Build envelope: magic ("SMOL"), version, recipient, ephemeral_pub, payload
|
||||
|
||||
### Transport Differences
|
||||
|
||||
#### TCP (v1.1)
|
||||
- Noise_NX handshake (client is initiator, server has static key)
|
||||
- Frames: u32 length || u8 op || body (split across Noise messages)
|
||||
- Binding values: Noise handshake hash and server static public key
|
||||
- Server pinning: Client verifies server's Noise static key
|
||||
|
||||
#### RNS (v1.2)
|
||||
- Reticulum link establishment
|
||||
- Frames: u8 op || body (RNS delimits messages)
|
||||
- Binding values: SHA-256("smolmail/1 bind" || dest_hash || link_id) and SHA-256("smolmail/1 bind" || dest_hash)
|
||||
- Server pinning: Destination hash IS the server's identity (no separate pinning)
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Implement Phase 1 (TCP Client)**
|
||||
- Start with crypto, address, and account modules
|
||||
- Then transport and tcp_transport
|
||||
- Then client core operations
|
||||
- Finally store and CLI
|
||||
- Test against bunshin server
|
||||
|
||||
2. **Implement Phase 2 (RNS Support)**
|
||||
- Research microReticulum C API
|
||||
- Create FFI bindings
|
||||
- Implement RNS transport
|
||||
- Add tests with reference implementations
|
||||
|
||||
3. **Polish and Package**
|
||||
- Documentation
|
||||
- Nix flake integration (optional)
|
||||
- Performance optimization
|
||||
- Error handling improvements
|
||||
|
||||
---
|
||||
Unit tests pin every derivation against vectors generated from `smolmail.py`, including a full envelope reproduced byte for byte.
|
||||
|
||||
## References
|
||||
|
||||
- [Smol Mail SPEC.md](https://code.randogoth.com/randogoth/smolmail/src/branch/main/SPEC.md)
|
||||
- [Smol Mail RNS.md (v1.2)](https://code.randogoth.com/randogoth/smolmail/src/branch/main/RNS.md)
|
||||
- [smolmail.py - Reference TCP client](https://code.randogoth.com/randogoth/smolmail/src/branch/main/smolmail.py)
|
||||
- [smolmail_rns.py - Reference RNS client](https://code.randogoth.com/randogoth/smolmail/src/branch/main/smolmail_rns.py)
|
||||
- [bunshin - Rust server implementation](../bunshin)
|
||||
- [microReticulum - C++ RNS implementation](https://github.com/attermann/microReticulum)
|
||||
- [SPEC.md](https://code.randogoth.com/randogoth/smolmail/src/branch/main/SPEC.md) — protocol 1.1
|
||||
- [RNS.md](https://code.randogoth.com/randogoth/smolmail/src/branch/main/RNS.md) — the 1.2 Reticulum addendum
|
||||
- [bunshin](https://code.randogoth.com/randogoth/bunshin) — the server
|
||||
|
|
|
|||
61
flake.lock
generated
Normal file
61
flake.lock
generated
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
{
|
||||
"nodes": {
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1731533236,
|
||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1790578696,
|
||||
"narHash": "sha256-ZoxIApko70jCdbH3l20HWXOBaT2HZd87orzd2yJ9dVE=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "7a0f122f5090cf4c2ade2a13a0e229d4e19ba71f",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
37
flake.nix
Normal file
37
flake.nix
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
{
|
||||
description = "fumi - Smol Mail client (Rust)";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, flake-utils }:
|
||||
flake-utils.lib.eachDefaultSystem (system:
|
||||
let
|
||||
pkgs = import nixpkgs { inherit system; };
|
||||
fumi = { rns ? false }: pkgs.rustPlatform.buildRustPackage {
|
||||
pname = "fumi";
|
||||
version = "0.1.0";
|
||||
src = ./.;
|
||||
cargoLock.lockFile = ./Cargo.lock;
|
||||
buildFeatures = pkgs.lib.optionals rns [ "rns" ];
|
||||
# The rns feature vendors microReticulum behind the shim and needs
|
||||
# MICRORETICULUM_SOURCE_DIR; it is added with the RNS carrier.
|
||||
doCheck = !rns;
|
||||
};
|
||||
in
|
||||
{
|
||||
packages.default = fumi { };
|
||||
packages.rns = fumi { rns = true; };
|
||||
|
||||
apps.default = flake-utils.lib.mkApp {
|
||||
drv = fumi { };
|
||||
name = "fumi";
|
||||
};
|
||||
|
||||
devShells.default = pkgs.mkShell {
|
||||
packages = with pkgs; [ cargo rustc rustfmt clippy sqlite ];
|
||||
};
|
||||
});
|
||||
}
|
||||
307
src/account.rs
Normal file
307
src/account.rs
Normal file
|
|
@ -0,0 +1,307 @@
|
|||
//! The master secret of SPEC.md sec 2 and everything derived from it: rotation
|
||||
//! indices, accept tokens and rotation certificates.
|
||||
|
||||
use crate::crypto::{hkdf_sha256, hmac_sha256, KEY_LEN};
|
||||
use crate::error::SmolError;
|
||||
use crate::transport::{CERT_LEN, LABEL_ACCEPT, LABEL_IDENTITY, LABEL_ROTATE, MAX_CHAIN};
|
||||
use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey};
|
||||
use x25519_dalek::StaticSecret;
|
||||
|
||||
/// An Ed25519 identity keypair plus the X25519 keypair derived from it (sec 2).
|
||||
#[derive(Clone)]
|
||||
pub struct Identity {
|
||||
signing: SigningKey,
|
||||
x: StaticSecret,
|
||||
}
|
||||
|
||||
impl Identity {
|
||||
pub fn from_seed(seed: [u8; KEY_LEN]) -> Identity {
|
||||
let signing = SigningKey::from_bytes(&seed);
|
||||
// SHA-512(seed)[0..32], clamped as libsodium's sk_to_curve25519
|
||||
// does (SPEC.md sec 2).
|
||||
let x = StaticSecret::from(crate::crypto::clamp_scalar(signing.to_scalar_bytes()));
|
||||
Identity { signing, x }
|
||||
}
|
||||
|
||||
pub fn pk(&self) -> [u8; KEY_LEN] {
|
||||
*self.signing.verifying_key().as_bytes()
|
||||
}
|
||||
|
||||
pub fn sign(&self, message: &[u8]) -> [u8; 64] {
|
||||
self.signing.sign(message).to_bytes()
|
||||
}
|
||||
|
||||
/// The X25519 half used for sealing agreement, never for signatures.
|
||||
pub fn x_priv(&self) -> &StaticSecret {
|
||||
&self.x
|
||||
}
|
||||
}
|
||||
|
||||
/// The Ed25519 seed at a rotation index (sec 2). HKDF is one-way, so a
|
||||
/// compromised seed exposes neither the master nor any other index.
|
||||
pub fn identity_seed(master: &[u8; KEY_LEN], index: u32) -> [u8; KEY_LEN] {
|
||||
let info = [LABEL_IDENTITY, &index.to_be_bytes()[..]].concat();
|
||||
hkdf_sha256(master, b"", &info, KEY_LEN).try_into().unwrap()
|
||||
}
|
||||
|
||||
/// The accept key (sec 2), independent of the rotation index so accept tokens
|
||||
/// survive every rotation.
|
||||
pub fn accept_key(master: &[u8; KEY_LEN]) -> [u8; KEY_LEN] {
|
||||
hkdf_sha256(master, b"", LABEL_ACCEPT, KEY_LEN)
|
||||
.try_into()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// A master plus every identity up to the current rotation index. Superseded
|
||||
/// signing keys stay derivable because mail addressed to them is readable
|
||||
/// with nothing else (sec 7).
|
||||
pub struct Account {
|
||||
master: [u8; KEY_LEN],
|
||||
index: u32,
|
||||
keys: Vec<Identity>,
|
||||
}
|
||||
|
||||
impl Account {
|
||||
pub fn new(master: [u8; KEY_LEN], index: u32) -> Result<Account, SmolError> {
|
||||
if index as usize > MAX_CHAIN {
|
||||
return Err(SmolError::new(format!(
|
||||
"rotation index {index} exceeds the chain limit of {MAX_CHAIN}"
|
||||
)));
|
||||
}
|
||||
let keys = (0..=index)
|
||||
.map(|n| Identity::from_seed(identity_seed(&master, n)))
|
||||
.collect();
|
||||
Ok(Account {
|
||||
master,
|
||||
index,
|
||||
keys,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn index(&self) -> u32 {
|
||||
self.index
|
||||
}
|
||||
|
||||
pub fn master(&self) -> &[u8; KEY_LEN] {
|
||||
&self.master
|
||||
}
|
||||
|
||||
/// The identity at the current rotation index.
|
||||
pub fn me(&self) -> &Identity {
|
||||
self.keys.last().expect("at least one key")
|
||||
}
|
||||
|
||||
/// Every key this account has held, current first is not guaranteed; the
|
||||
/// index order is 0..=index.
|
||||
pub fn keys(&self) -> &[Identity] {
|
||||
&self.keys
|
||||
}
|
||||
|
||||
/// The accept token this account issues to one correspondent (sec 5.8).
|
||||
pub fn token_for(&self, identity: &[u8; KEY_LEN]) -> [u8; 32] {
|
||||
hmac_sha256(&accept_key(&self.master), identity)
|
||||
}
|
||||
}
|
||||
|
||||
/// A double-signed rotation certificate (sec 7): fixed-size at 200 bytes, with
|
||||
/// the username covered but not carried, so it cannot be replayed against a
|
||||
/// different username bound to the same key.
|
||||
pub struct RotationCert {
|
||||
pub old_pub: [u8; KEY_LEN],
|
||||
pub new_pub: [u8; KEY_LEN],
|
||||
pub when: [u8; 8],
|
||||
sig_old: [u8; 64],
|
||||
sig_new: [u8; 64],
|
||||
}
|
||||
|
||||
impl RotationCert {
|
||||
pub fn from_bytes(bytes: &[u8]) -> Result<RotationCert, SmolError> {
|
||||
if bytes.len() != CERT_LEN {
|
||||
return Err(SmolError::new(format!(
|
||||
"rotation certificate is {} bytes, expected {CERT_LEN}",
|
||||
bytes.len()
|
||||
)));
|
||||
}
|
||||
Ok(RotationCert {
|
||||
old_pub: bytes[..32].try_into().unwrap(),
|
||||
new_pub: bytes[32..64].try_into().unwrap(),
|
||||
when: bytes[64..72].try_into().unwrap(),
|
||||
sig_old: bytes[72..136].try_into().unwrap(),
|
||||
sig_new: bytes[136..200].try_into().unwrap(),
|
||||
})
|
||||
}
|
||||
|
||||
fn signed_message(&self, username: &str) -> Vec<u8> {
|
||||
rotate_message(username, &self.old_pub, &self.new_pub, &self.when)
|
||||
}
|
||||
|
||||
/// Both signatures must verify: the old key alone could otherwise hand a
|
||||
/// username to a key nobody controls (sec 7).
|
||||
pub fn verify(&self, username: &str) -> bool {
|
||||
let message = self.signed_message(username);
|
||||
verify_sig(&self.old_pub, &self.sig_old, &message)
|
||||
&& verify_sig(&self.new_pub, &self.sig_new, &message)
|
||||
}
|
||||
|
||||
/// Builds the certificate for a rotation from `old` to `new`.
|
||||
pub fn build(old: &Identity, new: &Identity, username: &str, when: i64) -> [u8; CERT_LEN] {
|
||||
let old_pub = old.pk();
|
||||
let new_pub = new.pk();
|
||||
let message = rotate_message(username, &old_pub, &new_pub, &when.to_be_bytes());
|
||||
let mut cert = [0u8; CERT_LEN];
|
||||
cert[..32].copy_from_slice(&old_pub);
|
||||
cert[32..64].copy_from_slice(&new_pub);
|
||||
cert[64..72].copy_from_slice(&when.to_be_bytes());
|
||||
cert[72..136].copy_from_slice(&old.sign(&message));
|
||||
cert[136..200].copy_from_slice(&new.sign(&message));
|
||||
cert
|
||||
}
|
||||
}
|
||||
|
||||
/// The double-signed rotation message (sec 7): the username is covered but
|
||||
/// not carried, keeping the certificate fixed-size and non-portable.
|
||||
fn rotate_message(username: &str, old_pub: &[u8], new_pub: &[u8], when: &[u8; 8]) -> Vec<u8> {
|
||||
let mut message = Vec::with_capacity(LABEL_ROTATE.len() + username.len() + 72);
|
||||
message.extend_from_slice(LABEL_ROTATE);
|
||||
message.extend_from_slice(username.as_bytes());
|
||||
message.extend_from_slice(old_pub);
|
||||
message.extend_from_slice(new_pub);
|
||||
message.extend_from_slice(when);
|
||||
message
|
||||
}
|
||||
|
||||
/// Strict Ed25519 verification; malformed keys and signatures are simply not
|
||||
/// valid.
|
||||
pub fn verify_sig(identity: &[u8], signature: &[u8], message: &[u8]) -> bool {
|
||||
let (Ok(identity), Ok(signature)) = (
|
||||
<[u8; KEY_LEN]>::try_from(identity),
|
||||
<[u8; 64]>::try_from(signature),
|
||||
) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(verifying_key) = VerifyingKey::from_bytes(&identity) else {
|
||||
return false;
|
||||
};
|
||||
verifying_key
|
||||
.verify_strict(message, &Signature::from_bytes(&signature))
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::crypto::{b32, unb32};
|
||||
|
||||
// Vectors generated from the reference client's own derivations over
|
||||
// master = bytes(range(32)).
|
||||
fn master() -> [u8; 32] {
|
||||
core::array::from_fn(|i| i as u8)
|
||||
}
|
||||
|
||||
fn seed_b32(n: u32) -> &'static str {
|
||||
match n {
|
||||
0 => "6l6wdqp3uwi2a3vn6jnlgjjmvimilnqv6np6t2vytw7wj3jdg6ea",
|
||||
1 => "ag6k4r74bnc3tt6y623my7wasslq4ulxcxm4anzvizdpg76yfxva",
|
||||
2 => "r35p2a4e5ffhz6aq5urhv27ch22a34r6i6adnqfwwcnte5te5tua",
|
||||
16 => "rf6tdr6doeaymope2uj66au542kcmvfnaqc3cmy5jkulacdbnvaq",
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
fn pk_b32(n: u32) -> &'static str {
|
||||
match n {
|
||||
0 => "3jxsxqtdvxwobge7uxefpbordkulv6bc6ao3h4iusqlov4kgkkja",
|
||||
1 => "zoieneun2k7n5b65yxfeen4pxvxzq7sclm2kfaa7os3wrwrz4rta",
|
||||
2 => "2ptpqdy2d4zklwr2xzizhiza7b7jsf6qr2rayzrr2mk2h73ifbrq",
|
||||
16 => "r6yhcbo733vka7dlbknnlly7aqpoflej4lumeuxq7gxhzzm3juta",
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
fn xpriv_b32(n: u32) -> &'static str {
|
||||
match n {
|
||||
0 => "ebppfk5yqmf7v5a4seh6f6rkm7z3pnoh7b6hiaa3vs7n5if6hj5a",
|
||||
1 => "rbhdttkcycpfqfyfsy2mstbykw4t5pngd6dezskrytlore4j5jvq",
|
||||
2 => "xb4jcmjyappxo7zibvbalaepmvy74jilom43fxvfaj7ev4bpxzqq",
|
||||
16 => "ucdl3wcmwjlso6um6ipequ3qq6lkx47u4rnwwwy7jo76c2ktwjoq",
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_derivation_matches_the_reference_client() {
|
||||
for n in [0u32, 1, 2, 16] {
|
||||
let seed = identity_seed(&master(), n);
|
||||
assert_eq!(b32(&seed), seed_b32(n));
|
||||
let identity = Identity::from_seed(seed);
|
||||
assert_eq!(b32(&identity.pk()), pk_b32(n));
|
||||
// The X25519 half must be libsodium's sk_to_curve25519 output.
|
||||
assert_eq!(b32(identity.x_priv().as_bytes()), xpriv_b32(n));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accept_key_and_token_match_the_reference_client() {
|
||||
assert_eq!(
|
||||
b32(&accept_key(&master())),
|
||||
"fqmnb3vktmrth47m2qjzcey6ue7rbkrvsazytwdayjjgkvcqe2xa"
|
||||
);
|
||||
let account = Account::new(master(), 0).unwrap();
|
||||
let pk1: [u8; 32] = unb32(pk_b32(1)).unwrap().try_into().unwrap();
|
||||
assert_eq!(
|
||||
b32(&account.token_for(&pk1)),
|
||||
"bgrtewwtanbfmp5aoxq6rqckn4xcufr3tp4wa67mwsumyj7yjwia"
|
||||
);
|
||||
// Independent of the rotation index (sec 2).
|
||||
let rotated = Account::new(master(), 3).unwrap();
|
||||
assert_eq!(account.token_for(&pk1), rotated.token_for(&pk1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_holds_every_superseded_key() {
|
||||
let account = Account::new(master(), 2).unwrap();
|
||||
assert_eq!(account.keys().len(), 3);
|
||||
for (n, key) in account.keys().iter().enumerate() {
|
||||
assert_eq!(b32(&key.pk()), pk_b32(n as u32));
|
||||
}
|
||||
assert_eq!(account.index(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_rejects_an_over_long_chain() {
|
||||
assert!(Account::new(master(), 16).is_ok());
|
||||
assert!(Account::new(master(), 17).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotation_certificates_match_the_reference_client() {
|
||||
let old = Identity::from_seed(identity_seed(&master(), 0));
|
||||
let new = Identity::from_seed(identity_seed(&master(), 1));
|
||||
let cert = RotationCert::build(&old, &new, "alice", 1700000001);
|
||||
// Vector produced by the reference client over the same inputs.
|
||||
let expected = "da6f2bc263adece0989fa5c85785d11aa8baf822f01db3f1149416eaf1465292\
|
||||
cb9046928dd2bede87ddc5ca42378fbd6f987e425b34a2801f74b768da39e466000000006553f101\
|
||||
0c6171708ec40a9c68b456547a78fe0a512f8e45a0e4a9c094737ef4f84072a997a152e0f16e94bd\
|
||||
bd93502521ba812de62fe4c6d19092f0c424bba69337390401b2995c670f1d5720188956b8a1b00d\
|
||||
2ac9e5c1665e862e411c5ddeaea43c0721f450527b98f5dd3af29350a314514898a272bc3f78a0b9a\
|
||||
db826b46733c605";
|
||||
assert_eq!(data_encoding::HEXLOWER.encode(&cert), expected);
|
||||
assert_eq!(cert.len(), CERT_LEN);
|
||||
|
||||
let parsed = RotationCert::from_bytes(&cert).unwrap();
|
||||
assert!(parsed.verify("alice"));
|
||||
// The username is covered (sec 7), so another username fails.
|
||||
assert!(!parsed.verify("bob"));
|
||||
// Both halves must sign: flipping a signature byte breaks it.
|
||||
let mut broken = cert;
|
||||
broken[72] ^= 1;
|
||||
assert!(!RotationCert::from_bytes(&broken).unwrap().verify("alice"));
|
||||
assert!(RotationCert::from_bytes(&cert[..199]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_sig_rejects_garbage() {
|
||||
assert!(!verify_sig(&[0u8; 32], &[0u8; 64], b"msg"));
|
||||
assert!(!verify_sig(&[0u8; 5], &[0u8; 64], b"msg"));
|
||||
}
|
||||
}
|
||||
276
src/address.rs
Normal file
276
src/address.rs
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
//! The four address forms (SPEC.md sec 3, RNS.md sec 13.2) and the username
|
||||
//! grammar they share.
|
||||
|
||||
use crate::crypto::{b32, unb32, KEY_LEN};
|
||||
use crate::error::SmolError;
|
||||
|
||||
pub const DEFAULT_PORT: u16 = 1961;
|
||||
/// A Reticulum destination hash, as printed by every RNS tool: 32 lowercase
|
||||
/// hexadecimal characters (RNS.md sec 13.2).
|
||||
pub const RNS_HASH_HEX: usize = 32;
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum Scheme {
|
||||
Tcp,
|
||||
Rns,
|
||||
}
|
||||
|
||||
impl Scheme {
|
||||
pub fn prefix(&self) -> &'static str {
|
||||
match self {
|
||||
Scheme::Tcp => "smol://",
|
||||
Scheme::Rns => "smol+rns://",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `user@host[:port]` over TCP, or `user@<destination-hash>` over Reticulum,
|
||||
/// either optionally self-certifying with a 52-character base32 identity key.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Address {
|
||||
pub user: String,
|
||||
/// TCP hostname, or the destination hash as 32 lowercase hex characters.
|
||||
pub host: String,
|
||||
/// TCP only; there is no port on the RNS carrier, which stores 0 here.
|
||||
pub port: u16,
|
||||
pub scheme: Scheme,
|
||||
pub identity: Option<[u8; KEY_LEN]>,
|
||||
}
|
||||
|
||||
impl Address {
|
||||
/// The form commands and the contact store are keyed by.
|
||||
pub fn short(&self) -> String {
|
||||
let port = match (self.scheme, self.port) {
|
||||
(Scheme::Tcp, DEFAULT_PORT) | (Scheme::Rns, 0) => String::new(),
|
||||
(Scheme::Tcp, p) => format!(":{p}"),
|
||||
(Scheme::Rns, _) => unreachable!("no port is stored on the rns scheme"),
|
||||
};
|
||||
format!("{}@{}{}", self.user, self.host, port)
|
||||
}
|
||||
|
||||
/// The self-certifying form used for QR codes, contact files and links.
|
||||
pub fn uri(&self, identity: &[u8; KEY_LEN]) -> String {
|
||||
format!("{}{}/{}", self.scheme.prefix(), self.short(), b32(identity))
|
||||
}
|
||||
|
||||
/// The Reticulum destination hash, for the `rns` carrier only.
|
||||
pub fn destination(&self) -> Result<[u8; 16], SmolError> {
|
||||
if self.scheme != Scheme::Rns {
|
||||
return Err(SmolError::new("not an smol+rns:// address"));
|
||||
}
|
||||
data_encoding::HEXLOWER
|
||||
.decode(self.host.as_bytes())
|
||||
.map_err(|_| SmolError::new("destination hash is not hexadecimal"))
|
||||
.and_then(|bytes| {
|
||||
bytes
|
||||
.try_into()
|
||||
.map_err(|_| SmolError::new("destination hash is not 16 bytes"))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn parse(text: &str) -> Result<Address, SmolError> {
|
||||
let text = text.trim();
|
||||
let (scheme, rest) = if let Some(rest) = text.strip_prefix("smol+rns://") {
|
||||
(Scheme::Rns, rest)
|
||||
} else if let Some(rest) = text.strip_prefix("smol://") {
|
||||
(Scheme::Tcp, rest)
|
||||
} else {
|
||||
(Scheme::Tcp, text)
|
||||
};
|
||||
|
||||
// The path component is the 52-character base32 identity key. On TCP
|
||||
// it is required; the RNS form also has a bare short form (RNS.md sec
|
||||
// 13.2).
|
||||
let (rest, identity) = match rest.rsplit_once('/') {
|
||||
Some((head, key)) => (head, Some(decode_key(text, key)?)),
|
||||
None if scheme == Scheme::Tcp && !text.starts_with("smol://") => (rest, None),
|
||||
None if scheme == Scheme::Tcp => {
|
||||
return Err(SmolError::new(format!(
|
||||
"{text}: smol:// address carries no key"
|
||||
)))
|
||||
}
|
||||
None => (rest, None),
|
||||
};
|
||||
|
||||
let (user, host_part) = rest
|
||||
.split_once('@')
|
||||
.ok_or_else(|| SmolError::new(format!("{text:?} is not a valid address")))?;
|
||||
if user.is_empty() || host_part.is_empty() {
|
||||
return Err(SmolError::new(format!("{text:?} is not a valid address")));
|
||||
}
|
||||
valid_username(user)?;
|
||||
|
||||
Ok(match scheme {
|
||||
Scheme::Tcp => {
|
||||
let (host, port) = split_host_port(text, host_part)?;
|
||||
Address {
|
||||
user: user.to_string(),
|
||||
host,
|
||||
port,
|
||||
scheme,
|
||||
identity,
|
||||
}
|
||||
}
|
||||
Scheme::Rns => {
|
||||
// No port and no bare user@host form; the authority is the
|
||||
// destination hash, compared in full (RNS.md sec 13.2).
|
||||
if host_part.contains(':') {
|
||||
return Err(SmolError::new(format!(
|
||||
"{text}: the :port suffix must not appear on smol+rns://"
|
||||
)));
|
||||
}
|
||||
if !is_destination_hash(host_part) {
|
||||
return Err(SmolError::new(format!(
|
||||
"{text}: host must be exactly {RNS_HASH_HEX} lowercase hexadecimal \
|
||||
characters, a Reticulum destination hash"
|
||||
)));
|
||||
}
|
||||
Address {
|
||||
user: user.to_string(),
|
||||
host: host_part.to_string(),
|
||||
port: 0,
|
||||
scheme,
|
||||
identity,
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_key(text: &str, key: &str) -> Result<[u8; KEY_LEN], SmolError> {
|
||||
let identity =
|
||||
unb32(key).map_err(|e| SmolError::new(format!("{text}: undecodable key: {e}")))?;
|
||||
identity.try_into().map_err(|v: Vec<u8>| {
|
||||
SmolError::new(format!(
|
||||
"{text}: key is {} bytes, expected {KEY_LEN}",
|
||||
v.len()
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn split_host_port(text: &str, host_part: &str) -> Result<(String, u16), SmolError> {
|
||||
if let Some((host, port)) = host_part.rsplit_once(':') {
|
||||
if !host.is_empty() && port.bytes().all(|c| c.is_ascii_digit()) && !port.is_empty() {
|
||||
return port
|
||||
.parse::<u16>()
|
||||
.map(|p| (host.to_string(), p))
|
||||
.map_err(|_| SmolError::new(format!("{text}: invalid port")));
|
||||
}
|
||||
}
|
||||
if host_part.contains(':') || host_part.contains('/') {
|
||||
return Err(SmolError::new(format!("{text:?} is not a valid address")));
|
||||
}
|
||||
Ok((host_part.to_string(), DEFAULT_PORT))
|
||||
}
|
||||
|
||||
fn is_destination_hash(host: &str) -> bool {
|
||||
host.len() == RNS_HASH_HEX
|
||||
&& host
|
||||
.bytes()
|
||||
.all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c))
|
||||
}
|
||||
|
||||
/// SPEC.md sec 3: 1-63 bytes of `[a-z0-9._-]`, alphanumeric at both ends, and
|
||||
/// never two separators in a row. Non-lowercase input is a parse error, as in
|
||||
/// the reference client, which normalises before sending instead.
|
||||
fn valid_username(name: &str) -> Result<(), SmolError> {
|
||||
const SEPARATORS: &[u8] = b"._-";
|
||||
let bytes = name.as_bytes();
|
||||
let ok = !bytes.is_empty()
|
||||
&& bytes.len() <= 63
|
||||
&& bytes
|
||||
.iter()
|
||||
.all(|&c| c.is_ascii_digit() || c.is_ascii_lowercase() || SEPARATORS.contains(&c))
|
||||
&& !SEPARATORS.contains(&bytes[0])
|
||||
&& !SEPARATORS.contains(&bytes[bytes.len() - 1])
|
||||
&& !bytes
|
||||
.windows(2)
|
||||
.any(|pair| SEPARATORS.contains(&pair[0]) && SEPARATORS.contains(&pair[1]));
|
||||
if ok {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(SmolError::new(format!(
|
||||
"{name:?} must be 1-63 bytes of [a-z0-9._-], begin and end with a letter or digit, \
|
||||
and contain no two separators in a row"
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::crypto::b32;
|
||||
|
||||
#[test]
|
||||
fn short_form_and_default_port() {
|
||||
let a = Address::parse("alice@example.org").unwrap();
|
||||
assert_eq!(a.user, "alice");
|
||||
assert_eq!(a.host, "example.org");
|
||||
assert_eq!(a.port, DEFAULT_PORT);
|
||||
assert_eq!(a.short(), "alice@example.org");
|
||||
assert!(a.identity.is_none());
|
||||
|
||||
let a = Address::parse("alice@example.org:1962").unwrap();
|
||||
assert_eq!(a.port, 1962);
|
||||
assert_eq!(a.short(), "alice@example.org:1962");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn self_certifying_form_carries_its_key() {
|
||||
let key = [9u8; 32];
|
||||
let a = Address::parse(&format!("smol://alice@example.org/{}", b32(&key))).unwrap();
|
||||
assert_eq!(a.identity, Some(key));
|
||||
assert_eq!(a.short(), "alice@example.org");
|
||||
assert_eq!(
|
||||
a.uri(&key),
|
||||
format!("smol://alice@example.org/{}", b32(&key))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rns_forms_and_port_rejection() {
|
||||
let key = [9u8; 32];
|
||||
let hash = "8f2c1d0a7b6e5f4c3d2a1b0e9f8c7d6a";
|
||||
let a = Address::parse(&format!("smol+rns://alice@{hash}")).unwrap();
|
||||
assert_eq!(a.scheme, Scheme::Rns);
|
||||
assert_eq!(a.destination().unwrap()[..4], [0x8f, 0x2c, 0x1d, 0x0a]);
|
||||
assert_eq!(a.short(), format!("alice@{hash}"));
|
||||
|
||||
let b = Address::parse(&format!("smol+rns://alice@{hash}/{}", b32(&key))).unwrap();
|
||||
assert_eq!(b.identity, Some(key));
|
||||
assert_eq!(
|
||||
b.uri(&key),
|
||||
format!("smol+rns://alice@{hash}/{}", b32(&key))
|
||||
);
|
||||
|
||||
// No port on this transport (RNS.md sec 13.2).
|
||||
assert!(Address::parse(&format!("smol+rns://alice@{hash}:1961")).is_err());
|
||||
// The authority must be exactly 32 lowercase hex characters.
|
||||
assert!(Address::parse("smol+rns://alice@deadbeef").is_err());
|
||||
assert!(Address::parse(&format!("smol+rns://alice@{}", hash.to_uppercase())).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn username_grammar() {
|
||||
assert!(Address::parse("a.b_c-9@example.org").is_ok());
|
||||
assert!(Address::parse("Alice@example.org").is_err());
|
||||
assert!(Address::parse(".alice@example.org").is_err());
|
||||
assert!(Address::parse("alice.@example.org").is_err());
|
||||
assert!(Address::parse("al..ice@example.org").is_err());
|
||||
assert!(Address::parse("a-_b@example.org").is_err());
|
||||
assert!(Address::parse(&format!("{}@example.org", "a".repeat(64))).is_err());
|
||||
assert!(Address::parse(&format!("{}@example.org", "a".repeat(63))).is_ok());
|
||||
assert!(Address::parse("al ice@example.org").is_err());
|
||||
assert!(Address::parse("@example.org").is_err());
|
||||
assert!(Address::parse("alice@").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_keys_are_rejected() {
|
||||
assert!(Address::parse("smol://alice@example.org/notbase32!!").is_err());
|
||||
assert!(Address::parse("smol://alice@example.org/nbswy3dp").is_err());
|
||||
assert!(Address::parse("smol://example.org").is_err());
|
||||
// A bare short form has no key and needs none.
|
||||
assert!(Address::parse("alice@example.org").is_ok());
|
||||
}
|
||||
}
|
||||
823
src/client.rs
Normal file
823
src/client.rs
Normal file
|
|
@ -0,0 +1,823 @@
|
|||
//! The six operations, AUTH session setup, chain walking, token sync and the
|
||||
//! send/fetch pipelines. Everything here is carrier-neutral: it speaks
|
||||
//! through the `Transport` trait and the bodies are byte-identical on both
|
||||
//! carriers (RNS.md sec 13.5, sec 15).
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::account::{Account, Identity, RotationCert};
|
||||
use crate::address::{Address, Scheme};
|
||||
use crate::crypto::{b32, ct_eq, hmac_sha256, KEY_LEN};
|
||||
use crate::error::{expect_ok, SmolError};
|
||||
use crate::message::{
|
||||
accept_field, build_frontmatter, message_id, parse_frontmatter, seal, unseal, Opened,
|
||||
};
|
||||
use crate::store::{now, Store, Stored};
|
||||
use crate::tcp::TcpTransport;
|
||||
use crate::transport::{
|
||||
Reader, Transport, CERT_LEN, FLAG_REQUESTS, ID_LEN, LABEL_AUTH, LABEL_MAC, LABEL_REGISTER,
|
||||
MAX_CHAIN, OP_AUTH, OP_DELETE, OP_FETCH, OP_REGISTER, OP_RESOLVE, OP_SEND, TOKEN_LEN,
|
||||
};
|
||||
|
||||
/// A connection plus what the caller needs to know about how it was made:
|
||||
/// the server's static key when no pin was checked, for the sec 4 warning.
|
||||
pub struct Session {
|
||||
transport: Box<dyn Transport>,
|
||||
pub unpinned_static: Option<[u8; KEY_LEN]>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
pub fn transport(&mut self) -> &mut dyn Transport {
|
||||
self.transport.as_mut()
|
||||
}
|
||||
|
||||
pub fn close(&mut self) {
|
||||
self.transport.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens a session, enforcing sec 4's rule about unpinned servers. TCP pins
|
||||
/// the server's Noise static key; on RNS the destination hash is the pin
|
||||
/// (RNS.md sec 13.4).
|
||||
pub fn connect(
|
||||
store: &Store,
|
||||
addr: &Address,
|
||||
require_pin: bool,
|
||||
timeout: u64,
|
||||
) -> Result<Session, SmolError> {
|
||||
match addr.scheme {
|
||||
Scheme::Tcp => {
|
||||
let pinned = store.server_pin(&addr.host)?;
|
||||
if pinned.is_none() && require_pin {
|
||||
return Err(SmolError::new(format!(
|
||||
"no pinned key for {}.\nObtain it from the operator through a trusted \
|
||||
channel, then:\n fumi trust {} <key>",
|
||||
addr.host, addr.host
|
||||
)));
|
||||
}
|
||||
let transport = TcpTransport::connect(&addr.host, addr.port, pinned, timeout)?;
|
||||
let unpinned_static = if pinned.is_none() {
|
||||
Some(*transport.server_static())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(Session {
|
||||
transport: Box::new(transport),
|
||||
unpinned_static,
|
||||
})
|
||||
}
|
||||
Scheme::Rns => Err(SmolError::new(
|
||||
"smol+rns:// addresses need the rns feature; rebuild with --features rns",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn string_field(out: &mut Vec<u8>, text: &[u8]) -> Result<(), SmolError> {
|
||||
if text.len() > 255 {
|
||||
return Err(SmolError::new("field longer than one length byte"));
|
||||
}
|
||||
out.push(text.len() as u8);
|
||||
out.extend_from_slice(text);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// RESOLVE: the username's current key and its rotation chain (sec 6.1).
|
||||
pub fn resolve(
|
||||
transport: &mut dyn Transport,
|
||||
username: &str,
|
||||
) -> Result<([u8; KEY_LEN], Vec<[u8; CERT_LEN]>), SmolError> {
|
||||
let mut body = Vec::new();
|
||||
string_field(&mut body, username.as_bytes())?;
|
||||
let response = transport.request(OP_RESOLVE, &body)?;
|
||||
expect_ok(response.status, &format!("resolving {username}"))?;
|
||||
let mut r = Reader::new(&response.body);
|
||||
let identity: [u8; KEY_LEN] = r.take(KEY_LEN)?.try_into().unwrap();
|
||||
let chain_len = r.u8()? as usize;
|
||||
let mut chain = Vec::with_capacity(chain_len);
|
||||
for _ in 0..chain_len {
|
||||
chain.push(r.take(CERT_LEN)?.try_into().unwrap());
|
||||
}
|
||||
r.done()?;
|
||||
Ok((identity, chain))
|
||||
}
|
||||
|
||||
/// Accepts a key change only when a signed chain leads from the key we hold
|
||||
/// to the one the server now returns (sec 7). Both keys must sign each link;
|
||||
/// a link predating the key we hold is skipped, a gap is not.
|
||||
pub fn walk_chain(
|
||||
username: &str,
|
||||
pinned: &[u8; KEY_LEN],
|
||||
current: &[u8; KEY_LEN],
|
||||
chain: &[[u8; CERT_LEN]],
|
||||
) -> bool {
|
||||
if ct_eq(pinned, current) {
|
||||
return true;
|
||||
}
|
||||
if chain.is_empty() || chain.len() > MAX_CHAIN {
|
||||
return false;
|
||||
}
|
||||
let mut key: Vec<u8> = pinned.to_vec();
|
||||
let mut started = false;
|
||||
for cert_bytes in chain {
|
||||
let cert = match RotationCert::from_bytes(cert_bytes) {
|
||||
Ok(cert) => cert,
|
||||
Err(_) => return false,
|
||||
};
|
||||
if !started {
|
||||
if key != cert.old_pub {
|
||||
continue; // a link predating the key we hold
|
||||
}
|
||||
started = true;
|
||||
} else if key != cert.old_pub {
|
||||
return false; // the chain is not continuous
|
||||
}
|
||||
if !cert.verify(username) {
|
||||
return false;
|
||||
}
|
||||
key = cert.new_pub.to_vec();
|
||||
}
|
||||
started && ct_eq(&key, current)
|
||||
}
|
||||
|
||||
/// How `trust_key` left the contact.
|
||||
#[derive(PartialEq, Eq, Debug)]
|
||||
pub enum TrustChange {
|
||||
/// Trust on first use; `unverified` marks a session with no pin (sec 8).
|
||||
New { unverified: bool },
|
||||
/// A signed chain confirmed a key change (sec 7).
|
||||
Rotated,
|
||||
/// The key matches what we already hold.
|
||||
None,
|
||||
}
|
||||
|
||||
/// Resolves a contact and applies sec 8's trust rules. A key change without
|
||||
/// a valid chain is an error here: the user confirms out of band and
|
||||
/// imports, rather than clicking through.
|
||||
pub fn trust_key(
|
||||
store: &Store,
|
||||
transport: &mut dyn Transport,
|
||||
addr: &Address,
|
||||
) -> Result<([u8; KEY_LEN], TrustChange), SmolError> {
|
||||
let (identity, chain) = resolve(transport, &addr.user)?;
|
||||
let known = store.contact(&addr.short())?;
|
||||
match known {
|
||||
None => {
|
||||
store.save_contact(&addr.short(), &identity, false)?;
|
||||
Ok((
|
||||
identity,
|
||||
TrustChange::New {
|
||||
unverified: !transport.pinned(),
|
||||
},
|
||||
))
|
||||
}
|
||||
Some((old, _)) if ct_eq(&old, &identity) => Ok((identity, TrustChange::None)),
|
||||
Some((old, verified)) => {
|
||||
if walk_chain(&addr.user, &old, &identity, &chain) {
|
||||
store.save_contact(&addr.short(), &identity, verified)?;
|
||||
Ok((identity, TrustChange::Rotated))
|
||||
} else {
|
||||
Err(SmolError::new(format!(
|
||||
"{} presents a different key with no valid rotation chain.\n known: {}\n \
|
||||
offered: {}\nVerify out of band, then: fumi import {}",
|
||||
addr.short(),
|
||||
b32(&old),
|
||||
b32(&identity),
|
||||
addr.uri(&identity)
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// AUTH (sec 4): sign the handshake hash and push the accept-token set. The
|
||||
/// trailing block carries the mailbox's tokens (sec 5.8): `sync = 0` leaves
|
||||
/// the stored set untouched, `sync = 1` replaces it with exactly what
|
||||
/// follows.
|
||||
pub fn authenticate(
|
||||
transport: &mut dyn Transport,
|
||||
username: &str,
|
||||
account: &Account,
|
||||
store: &Store,
|
||||
) -> Result<u16, SmolError> {
|
||||
let (sync, tokens) = store.token_set(account)?;
|
||||
let mut body = Vec::new();
|
||||
string_field(&mut body, username.as_bytes())?;
|
||||
body.extend_from_slice(&account.me().pk());
|
||||
body.extend_from_slice(
|
||||
&account
|
||||
.me()
|
||||
.sign(&[LABEL_AUTH, &transport.bind().h[..]].concat()),
|
||||
);
|
||||
body.push(sync);
|
||||
body.extend_from_slice(&(tokens.len() as u16).to_be_bytes());
|
||||
for token in &tokens {
|
||||
body.extend_from_slice(token);
|
||||
}
|
||||
let response = transport.request(OP_AUTH, &body)?;
|
||||
expect_ok(response.status, "authentication")?;
|
||||
let held = if response.body.len() >= 2 {
|
||||
u16::from_be_bytes(response.body[..2].try_into().unwrap())
|
||||
} else {
|
||||
0
|
||||
};
|
||||
Ok(held)
|
||||
}
|
||||
|
||||
/// What pushing the accept-token set achieved (sec 5.8).
|
||||
pub enum Pushed {
|
||||
/// Not registered yet; the set travels with the first fetch instead.
|
||||
NotRegistered,
|
||||
/// The server now holds this many tokens.
|
||||
Held(u16),
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub fn push_tokens(store: &Store, account: &Account, timeout: u64) -> Result<Pushed, SmolError> {
|
||||
let Some(addr) = store.account()? else {
|
||||
return Ok(Pushed::NotRegistered);
|
||||
};
|
||||
let mut session = connect(store, &addr, true, timeout)?;
|
||||
let held = authenticate(session.transport(), &addr.user, account, store)?;
|
||||
session.close();
|
||||
Ok(Pushed::Held(held))
|
||||
}
|
||||
|
||||
/// REGISTER (sec 6.1): binds this identity to a username, with proof of
|
||||
/// possession over the server's static key so the attestation cannot be
|
||||
/// replayed to another server.
|
||||
pub fn register(
|
||||
store: &Store,
|
||||
addr: &Address,
|
||||
account: &Account,
|
||||
invite: Option<&str>,
|
||||
timeout: u64,
|
||||
) -> Result<(), SmolError> {
|
||||
let mut session = connect(store, addr, true, timeout)?;
|
||||
let transport = session.transport();
|
||||
let me = account.me();
|
||||
let mut body = Vec::new();
|
||||
string_field(&mut body, addr.user.as_bytes())?;
|
||||
body.extend_from_slice(&me.pk());
|
||||
body.extend_from_slice(
|
||||
&me.sign(
|
||||
&[
|
||||
LABEL_REGISTER,
|
||||
&transport.bind().server_static[..],
|
||||
addr.user.as_bytes(),
|
||||
&me.pk()[..],
|
||||
]
|
||||
.concat(),
|
||||
),
|
||||
);
|
||||
let token = invite.unwrap_or("").as_bytes();
|
||||
string_field(&mut body, token)?;
|
||||
body.push(0); // no rotation certificate on a plain registration
|
||||
let response = transport.request(OP_REGISTER, &body)?;
|
||||
expect_ok(response.status, &format!("registering {}", addr.short()))?;
|
||||
session.close();
|
||||
store.set_account(addr)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A completed rotation: the new key and the certificate the server now
|
||||
/// holds, ready to be pushed to contacts as an ordinary message (sec 7).
|
||||
pub struct Rotated {
|
||||
pub new_pk: [u8; KEY_LEN],
|
||||
pub cert: [u8; CERT_LEN],
|
||||
}
|
||||
|
||||
/// Advances the rotation index and pushes the certificate (sec 7). The master
|
||||
/// is untouched; only the index moves, and the superseded key stays
|
||||
/// derivable from it (sec 2).
|
||||
pub fn rotate(store: &Store, account: &Account, timeout: u64) -> Result<Rotated, SmolError> {
|
||||
let Some(addr) = store.account()? else {
|
||||
return Err(SmolError::new(
|
||||
"not registered; run: fumi register <user@host>",
|
||||
));
|
||||
};
|
||||
if account.index() as usize >= MAX_CHAIN {
|
||||
return Err(SmolError::new(format!(
|
||||
"the rotation chain is full at {MAX_CHAIN} links"
|
||||
)));
|
||||
}
|
||||
let old = account.me();
|
||||
let new = Identity::from_seed(crate::account::identity_seed(
|
||||
account.master(),
|
||||
account.index() + 1,
|
||||
));
|
||||
let cert = RotationCert::build(old, &new, &addr.user, now());
|
||||
|
||||
let mut session = connect(store, &addr, true, timeout)?;
|
||||
let transport = session.transport();
|
||||
let mut body = Vec::new();
|
||||
string_field(&mut body, addr.user.as_bytes())?;
|
||||
body.extend_from_slice(&new.pk());
|
||||
body.extend_from_slice(
|
||||
&new.sign(
|
||||
&[
|
||||
LABEL_REGISTER,
|
||||
&transport.bind().server_static[..],
|
||||
addr.user.as_bytes(),
|
||||
&new.pk()[..],
|
||||
]
|
||||
.concat(),
|
||||
),
|
||||
);
|
||||
body.push(0); // no invite token on a rotation
|
||||
body.push(CERT_LEN as u8);
|
||||
body.extend_from_slice(&cert);
|
||||
let response = transport.request(OP_REGISTER, &body)?;
|
||||
expect_ok(response.status, "rotating")?;
|
||||
session.close();
|
||||
|
||||
store.set_rotations(account.index() + 1)?;
|
||||
Ok(Rotated {
|
||||
new_pk: new.pk(),
|
||||
cert,
|
||||
})
|
||||
}
|
||||
|
||||
/// Recovers the rotation index, and with it every superseded key, from the
|
||||
/// master alone (sec 2). The accepted set is gone, so sync is disabled until
|
||||
/// the user rebuilds it: an empty set must not replace the server's (sec 4).
|
||||
pub fn restore(
|
||||
store: &Store,
|
||||
master: &[u8; KEY_LEN],
|
||||
addr: &Address,
|
||||
timeout: u64,
|
||||
) -> Result<u32, SmolError> {
|
||||
let mut session = connect(store, addr, false, timeout)?;
|
||||
let (identity, _) = resolve(session.transport(), &addr.user)?;
|
||||
session.close();
|
||||
|
||||
let mut found = None;
|
||||
for index in 0..=(MAX_CHAIN as u32) {
|
||||
if Identity::from_seed(crate::account::identity_seed(master, index)).pk() == identity {
|
||||
found = Some(index);
|
||||
break;
|
||||
}
|
||||
}
|
||||
let Some(index) = found else {
|
||||
return Err(SmolError::new(format!(
|
||||
"the key bound to {} is not derived from this master within {MAX_CHAIN} rotations",
|
||||
addr.short()
|
||||
)));
|
||||
};
|
||||
store.set_account(addr)?;
|
||||
store.set_cursor(0, &[0u8; ID_LEN])?;
|
||||
store.set_rotations(index)?;
|
||||
store.set_sync_ok(false)?;
|
||||
Ok(index)
|
||||
}
|
||||
|
||||
/// One message to send, as the CLI assembled it.
|
||||
pub struct SendDraft<'a> {
|
||||
pub address: &'a Address,
|
||||
pub text: String,
|
||||
pub subject: Option<&'a str>,
|
||||
/// An id this message replies to, as 64 hex characters.
|
||||
pub reply_to: Option<&'a str>,
|
||||
/// Extra `Key: value` frontmatter fields.
|
||||
pub headers: &'a [(&'a str, &'a str)],
|
||||
/// Omit the Reply-To field carrying this address (sec 5.7).
|
||||
pub anonymous: bool,
|
||||
pub no_pad: bool,
|
||||
}
|
||||
|
||||
/// What `send` did, for the caller to report.
|
||||
pub struct Sent {
|
||||
pub id: [u8; ID_LEN],
|
||||
pub bytes: usize,
|
||||
/// An accept token for their mailbox was attached (sec 5.8).
|
||||
pub token_used: bool,
|
||||
/// How the recipient key was obtained, if it was learned now.
|
||||
pub change: TrustChange,
|
||||
/// A non-fatal oddity worth showing the user.
|
||||
pub warning: Option<String>,
|
||||
}
|
||||
|
||||
/// Seals and delivers one message (sec 5, sec 6.1). Recipient selection
|
||||
/// prefers a key we already trust: a self-certifying address, then a stored
|
||||
/// contact, and only then RESOLVE with trust on first use.
|
||||
pub fn send(
|
||||
store: &Store,
|
||||
account: &Account,
|
||||
draft: &SendDraft<'_>,
|
||||
timeout: u64,
|
||||
) -> Result<Sent, SmolError> {
|
||||
let addr = draft.address;
|
||||
let me = account.me();
|
||||
|
||||
let (recipient, change) = if let Some(key) = addr.identity {
|
||||
store.save_contact(&addr.short(), &key, true)?;
|
||||
(key, TrustChange::None)
|
||||
} else if let Some((known, _)) = store.contact(&addr.short())? {
|
||||
(known, TrustChange::None)
|
||||
} else {
|
||||
let mut session = connect(store, addr, false, timeout)?;
|
||||
let (key, change) = trust_key(store, session.transport(), addr)?;
|
||||
session.close();
|
||||
(key, change)
|
||||
};
|
||||
|
||||
// Frontmatter, in the order the reference client emits it.
|
||||
let mut fields: Vec<(String, String)> = Vec::new();
|
||||
if let Some(subject) = draft.subject {
|
||||
fields.push(("Subject".into(), subject.to_string()));
|
||||
}
|
||||
if let Some(reply_to) = draft.reply_to {
|
||||
fields.push(("In-Reply-To".into(), reply_to.to_string()));
|
||||
}
|
||||
for (key, value) in draft.headers {
|
||||
fields.push(((*key).to_string(), (*value).to_string()));
|
||||
}
|
||||
// sec 5.7: a signed reply address lets a first-time recipient answer us.
|
||||
if let Some(home) = store.account()? {
|
||||
if !draft.anonymous {
|
||||
fields.push(("Reply-To".into(), home.uri(&me.pk())));
|
||||
}
|
||||
}
|
||||
// sec 5.8: hand an accepted correspondent the token for our own mailbox.
|
||||
if let Some(identity) = store.accepted_identity(&addr.short())? {
|
||||
fields.push(("Accept".into(), b32(&account.token_for(&identity))));
|
||||
}
|
||||
let borrowed: Vec<(&str, &str)> = fields
|
||||
.iter()
|
||||
.map(|(k, v)| (k.as_str(), v.as_str()))
|
||||
.collect();
|
||||
let body = build_frontmatter(&borrowed, &draft.text).into_bytes();
|
||||
|
||||
let envelope = seal(me, &recipient, &body, now(), !draft.no_pad)?;
|
||||
let mid = message_id(&envelope);
|
||||
|
||||
// sec 5.8: our token for their mailbox, if they have given us one.
|
||||
let mac = match store.token_of(&addr.short())? {
|
||||
Some(token) => hmac_sha256(&token, &[LABEL_MAC, &mid[..]].concat()).to_vec(),
|
||||
None => Vec::new(),
|
||||
};
|
||||
|
||||
let mut session = connect(store, addr, false, timeout)?;
|
||||
let mut wire = vec![mac.len() as u8];
|
||||
wire.extend_from_slice(&mac);
|
||||
wire.extend_from_slice(&envelope);
|
||||
let response = session.transport().request(OP_SEND, &wire)?;
|
||||
expect_ok(response.status, &format!("sending to {}", addr.short()))?;
|
||||
session.close();
|
||||
// The server echoes the id; it is derived, so ours is authoritative.
|
||||
let warning = if !response.body.is_empty() && response.body != mid.to_vec() {
|
||||
Some("server returned an id we did not derive; it is not authoritative".to_string())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// sec 5.6: the ephemeral is gone, so keep a copy sealed to ourselves.
|
||||
store.store_sent(&mid, &addr.short(), &envelope, now())?;
|
||||
Ok(Sent {
|
||||
id: mid,
|
||||
bytes: envelope.len(),
|
||||
token_used: !mac.is_empty(),
|
||||
change,
|
||||
warning,
|
||||
})
|
||||
}
|
||||
|
||||
/// One rejected fetch record: the id and why it did not enter the inbox.
|
||||
pub struct Rejected {
|
||||
pub id: [u8; ID_LEN],
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
/// What `fetch` did, for the caller to report.
|
||||
pub struct Fetched {
|
||||
pub total: u32,
|
||||
pub stored: u32,
|
||||
pub rejected: Vec<Rejected>,
|
||||
/// Messages were deleted from the server rather than kept behind a
|
||||
/// cursor (the default).
|
||||
pub acknowledged: bool,
|
||||
}
|
||||
|
||||
/// Retrieves, verifies and stores mail (sec 6.1), paging forward by
|
||||
/// `(received_at, id)` until a response is empty. Acknowledging deletes what
|
||||
/// it takes, so it always pages from the start; `keep` instead remembers the
|
||||
/// cursor. A message that fails verification is left on the server, so a
|
||||
/// client-side bug cannot lose mail.
|
||||
pub fn fetch(
|
||||
store: &Store,
|
||||
account: &Account,
|
||||
keep: bool,
|
||||
reset: bool,
|
||||
timeout: u64,
|
||||
) -> Result<Fetched, SmolError> {
|
||||
let Some(addr) = store.account()? else {
|
||||
return Err(SmolError::new(
|
||||
"not registered; run: fumi register <user@host>",
|
||||
));
|
||||
};
|
||||
if reset {
|
||||
store.set_cursor(0, &[0u8; ID_LEN])?;
|
||||
}
|
||||
let (mut after_time, mut after_id) = if keep {
|
||||
store.cursor()?
|
||||
} else {
|
||||
(0, [0u8; ID_LEN])
|
||||
};
|
||||
|
||||
let mut summary = Fetched {
|
||||
total: 0,
|
||||
stored: 0,
|
||||
rejected: Vec::new(),
|
||||
acknowledged: !keep,
|
||||
};
|
||||
let mut session = connect(store, &addr, true, timeout)?;
|
||||
let transport = session.transport();
|
||||
authenticate(transport, &addr.user, account, store)?;
|
||||
|
||||
loop {
|
||||
let mut body = Vec::with_capacity(8 + ID_LEN);
|
||||
body.extend_from_slice(&after_time.to_be_bytes());
|
||||
body.extend_from_slice(&after_id);
|
||||
let response = transport.request(OP_FETCH, &body)?;
|
||||
expect_ok(response.status, "fetching")?;
|
||||
let mut r = Reader::new(&response.body);
|
||||
let count = r.u16()? as usize;
|
||||
if count == 0 {
|
||||
break;
|
||||
}
|
||||
let mut acked: Vec<[u8; ID_LEN]> = Vec::new();
|
||||
for _ in 0..count {
|
||||
let mid: [u8; ID_LEN] = r.take(ID_LEN)?.try_into().unwrap();
|
||||
let received_at = r.i64()?;
|
||||
let flags = r.u8()?;
|
||||
let envelope_len = r.u32()? as usize;
|
||||
let envelope = r.take(envelope_len)?.to_vec();
|
||||
after_time = received_at;
|
||||
after_id = mid;
|
||||
summary.total += 1;
|
||||
|
||||
let opened = match verify_and_open(account, &mid, &envelope) {
|
||||
Ok(opened) => opened,
|
||||
Err(reason) => {
|
||||
summary.rejected.push(Rejected {
|
||||
id: mid,
|
||||
reason: reason.to_string(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
};
|
||||
// A message we have already had once is not stored again, even
|
||||
// if we deleted it locally in the meantime (sec 10).
|
||||
if !store.seen(&mid)? {
|
||||
let requests_tier = flags & FLAG_REQUESTS != 0;
|
||||
store.store_inbox(&mid, &envelope, received_at, requests_tier)?;
|
||||
learn_token(store, &opened)?;
|
||||
summary.stored += 1;
|
||||
}
|
||||
acked.push(mid);
|
||||
}
|
||||
r.done()?;
|
||||
if keep {
|
||||
store.set_cursor(after_time, &after_id)?;
|
||||
} else if !acked.is_empty() {
|
||||
delete_ids(transport, &acked)?;
|
||||
}
|
||||
}
|
||||
session.close();
|
||||
if !keep {
|
||||
store.set_cursor(0, &[0u8; ID_LEN])?;
|
||||
}
|
||||
Ok(summary)
|
||||
}
|
||||
|
||||
fn verify_and_open(
|
||||
account: &Account,
|
||||
mid: &[u8; ID_LEN],
|
||||
envelope: &[u8],
|
||||
) -> Result<Opened, SmolError> {
|
||||
if message_id(envelope) != *mid {
|
||||
return Err(SmolError::new("id does not match the envelope"));
|
||||
}
|
||||
unseal(account.keys(), envelope, now())
|
||||
}
|
||||
|
||||
/// Files the accept token a verified payload carried, under the address that
|
||||
/// signed it (sec 5.8). The signature has already been checked by `unseal`,
|
||||
/// so the attribution is the signer's own claim.
|
||||
fn learn_token(store: &Store, opened: &Opened) -> Result<(), SmolError> {
|
||||
let text = String::from_utf8_lossy(&opened.body).into_owned();
|
||||
let (fields, _) = parse_frontmatter(&text);
|
||||
let Some(token) = accept_field(&fields) else {
|
||||
return Ok(());
|
||||
};
|
||||
let reply_to = fields.get("reply-to").map(String::as_str);
|
||||
match store.address_of(&opened.sender, reply_to)? {
|
||||
// No address to send to, so no use for a token.
|
||||
None => Ok(()),
|
||||
Some(address) => store.save_token(&address, &token),
|
||||
}
|
||||
}
|
||||
|
||||
fn delete_ids(transport: &mut dyn Transport, ids: &[[u8; ID_LEN]]) -> Result<u16, SmolError> {
|
||||
let mut body = Vec::with_capacity(2 + ids.len() * ID_LEN);
|
||||
body.extend_from_slice(&(ids.len() as u16).to_be_bytes());
|
||||
for id in ids {
|
||||
body.extend_from_slice(id);
|
||||
}
|
||||
let response = transport.request(OP_DELETE, &body)?;
|
||||
expect_ok(response.status, "acknowledging")?;
|
||||
let removed = if response.body.len() >= 2 {
|
||||
u16::from_be_bytes(response.body[..2].try_into().unwrap())
|
||||
} else {
|
||||
0
|
||||
};
|
||||
Ok(removed)
|
||||
}
|
||||
|
||||
/// DELETE (sec 6.1) over an authenticated session: remove ids from the
|
||||
/// server explicitly. Unknown ids are not an error.
|
||||
pub fn delete(
|
||||
store: &Store,
|
||||
account: &Account,
|
||||
ids: &[[u8; ID_LEN]],
|
||||
timeout: u64,
|
||||
) -> Result<u16, SmolError> {
|
||||
let Some(addr) = store.account()? else {
|
||||
return Err(SmolError::new(
|
||||
"not registered; run: fumi register <user@host>",
|
||||
));
|
||||
};
|
||||
let mut session = connect(store, &addr, true, timeout)?;
|
||||
let transport = session.transport();
|
||||
authenticate(transport, &addr.user, account, store)?;
|
||||
let removed = delete_ids(transport, ids)?;
|
||||
session.close();
|
||||
Ok(removed)
|
||||
}
|
||||
|
||||
/// An opened, described message for listing and reading.
|
||||
pub struct Described {
|
||||
pub sender: [u8; KEY_LEN],
|
||||
pub time: i64,
|
||||
pub fields: BTreeMap<String, String>,
|
||||
pub text: String,
|
||||
/// The contact address we know the signer by, or a fingerprint.
|
||||
pub from: String,
|
||||
pub subject: String,
|
||||
}
|
||||
|
||||
impl Described {
|
||||
/// Whether this payload carried its signer's accept token (sec 5.8):
|
||||
/// machinery, not content.
|
||||
pub fn carries_token(&self) -> bool {
|
||||
self.fields.contains_key("accept")
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens one sealed message and splits its body into frontmatter and text.
|
||||
pub fn describe(store: &Store, account: &Account, stored: &Stored) -> Result<Described, SmolError> {
|
||||
let opened = unseal(account.keys(), &stored.envelope, now())?;
|
||||
let text = String::from_utf8_lossy(&opened.body).into_owned();
|
||||
let (fields, body_text) = parse_frontmatter(&text);
|
||||
let from = store
|
||||
.address_of(&opened.sender, fields.get("reply-to").map(String::as_str))?
|
||||
.unwrap_or_else(|| format!("<{}…>", &b32(&opened.sender)[..20]));
|
||||
Ok(Described {
|
||||
sender: opened.sender,
|
||||
time: opened.time,
|
||||
subject: fields.get("subject").cloned().unwrap_or_default(),
|
||||
fields,
|
||||
text: body_text,
|
||||
from,
|
||||
})
|
||||
}
|
||||
|
||||
/// The accept MAC a sender attaches to SEND (sec 5.8), exposed for tests.
|
||||
pub fn accept_mac(token: &[u8; TOKEN_LEN], mid: &[u8; ID_LEN]) -> [u8; 32] {
|
||||
hmac_sha256(token, &[LABEL_MAC, mid].concat())
|
||||
}
|
||||
|
||||
/// Proof of possession over a server's static key (sec 6.1), for tests.
|
||||
#[cfg(test)]
|
||||
fn register_signed(server_static: &[u8], username: &str, identity: &[u8]) -> Vec<u8> {
|
||||
[LABEL_REGISTER, server_static, username.as_bytes(), identity].concat()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::account::{identity_seed, verify_sig, Identity, RotationCert};
|
||||
use crate::transport::{TransportBindValues, SIG_LEN};
|
||||
|
||||
fn master() -> [u8; 32] {
|
||||
core::array::from_fn(|i| i as u8)
|
||||
}
|
||||
|
||||
fn identity(n: u32) -> Identity {
|
||||
Identity::from_seed(identity_seed(&master(), n))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn walk_chain_accepts_signed_progressions() {
|
||||
let old = identity(0);
|
||||
let new = identity(1);
|
||||
let cert = RotationCert::build(&old, &new, "alice", 1700000001);
|
||||
assert!(walk_chain("alice", &old.pk(), &old.pk(), &[]));
|
||||
assert!(walk_chain("alice", &old.pk(), &new.pk(), &[cert]));
|
||||
// The username is covered, so the same cert proves nothing for bob.
|
||||
assert!(!walk_chain("bob", &old.pk(), &new.pk(), &[cert]));
|
||||
// No chain, no acceptance.
|
||||
assert!(!walk_chain("alice", &old.pk(), &new.pk(), &[]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn walk_chain_requires_continuity_and_terminates_at_current() {
|
||||
let keys: Vec<Identity> = (0..4).map(identity).collect();
|
||||
let cert01 = RotationCert::build(&keys[0], &keys[1], "alice", 1);
|
||||
let cert12 = RotationCert::build(&keys[1], &keys[2], "alice", 2);
|
||||
let cert23 = RotationCert::build(&keys[2], &keys[3], "alice", 3);
|
||||
// A full chain from 0 to 3.
|
||||
assert!(walk_chain(
|
||||
"alice",
|
||||
&keys[0].pk(),
|
||||
&keys[3].pk(),
|
||||
&[cert01, cert12, cert23]
|
||||
));
|
||||
// Holding key 1: the earlier link is skipped, the rest must continue.
|
||||
assert!(walk_chain(
|
||||
"alice",
|
||||
&keys[1].pk(),
|
||||
&keys[3].pk(),
|
||||
&[cert01, cert12, cert23]
|
||||
));
|
||||
// A gap between the held key and the chain never validates.
|
||||
assert!(!walk_chain(
|
||||
"alice",
|
||||
&keys[0].pk(),
|
||||
&keys[3].pk(),
|
||||
&[cert12, cert23]
|
||||
));
|
||||
// The chain must end at the key RESOLVE returned, not merely agree
|
||||
// partway.
|
||||
assert!(!walk_chain(
|
||||
"alice",
|
||||
&keys[0].pk(),
|
||||
&keys[2].pk(),
|
||||
&[cert01, cert12, cert23]
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn walk_chain_enforces_the_length_limit() {
|
||||
let keys: Vec<Identity> = (0..=16).map(identity).collect();
|
||||
let certs: Vec<[u8; CERT_LEN]> = (0..16)
|
||||
.map(|n| RotationCert::build(&keys[n], &keys[n + 1], "alice", n as i64))
|
||||
.collect();
|
||||
// Sixteen links walk the whole chain from the first key.
|
||||
assert!(walk_chain("alice", &keys[0].pk(), &keys[16].pk(), &certs));
|
||||
// Seventeen links is over-long and needs user confirmation, whatever
|
||||
// key we hold (sec 7).
|
||||
let mut over = certs.clone();
|
||||
let extra = identity(17);
|
||||
over.push(RotationCert::build(&keys[16], &extra, "alice", 17));
|
||||
assert!(!walk_chain("alice", &keys[0].pk(), &extra.pk(), &over));
|
||||
assert!(!walk_chain("alice", &keys[1].pk(), &extra.pk(), &over));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accept_mac_matches_the_reference_vector() {
|
||||
// The reference client's own vector: token_for(pk_1) over the id of
|
||||
// the reference envelope (see the message module's vectors).
|
||||
let account = Account::new(master(), 0).unwrap();
|
||||
let token = account.token_for(&identity(1).pk());
|
||||
let mid: [u8; 32] =
|
||||
crate::crypto::unb32("7tttsuaq4fqwbi5hvp6tigzwk5g73upgqpfxvk26aumwak2zefiq")
|
||||
.unwrap()
|
||||
.try_into()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
data_encoding::HEXLOWER.encode(&accept_mac(&token, &mid)),
|
||||
"f709facbe5e032f4c2667c7899e5194397437ed001f2b2be33b82f1cc8d7c93e"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bind_values_feed_the_auth_and_register_signatures() {
|
||||
let bind = TransportBindValues::tcp(&[2u8; 32], &[7u8; 32]).unwrap();
|
||||
let account = Account::new(master(), 0).unwrap();
|
||||
let me = account.me();
|
||||
let auth = me.sign(&[LABEL_AUTH, &bind.h[..]].concat());
|
||||
let register = me.sign(®ister_signed(&bind.server_static, "alice", &me.pk()));
|
||||
assert_eq!(auth.len(), SIG_LEN);
|
||||
assert!(verify_sig(
|
||||
&me.pk(),
|
||||
&auth,
|
||||
&[LABEL_AUTH, &bind.h[..]].concat()
|
||||
));
|
||||
assert!(verify_sig(
|
||||
&me.pk(),
|
||||
®ister,
|
||||
®ister_signed(&bind.server_static, "alice", &me.pk())
|
||||
));
|
||||
}
|
||||
}
|
||||
201
src/crypto.rs
Normal file
201
src/crypto.rs
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
//! Encoding and cryptographic helpers: base32, HKDF, HMAC, SHA-256 and the
|
||||
//! Ed25519 -> X25519 key map with the checks SPEC.md sec 2 requires.
|
||||
|
||||
use data_encoding::{Encoding, Specification};
|
||||
use ed25519_dalek::VerifyingKey;
|
||||
use hkdf::Hkdf;
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::sync::LazyLock;
|
||||
use x25519_dalek::{PublicKey, StaticSecret};
|
||||
|
||||
use crate::error::SmolError;
|
||||
|
||||
pub const KEY_LEN: usize = 32;
|
||||
|
||||
/// X25519 scalar clamping (RFC 7748): libsodium's crypto_sign_ed25519_sk_to_
|
||||
/// curve25519 output is the clamped scalar, and SPEC.md sec 2 spells the map
|
||||
/// the same way, so the private half is clamped when constructed rather than
|
||||
/// only inside key agreement.
|
||||
pub fn clamp_scalar(mut bytes: [u8; KEY_LEN]) -> [u8; KEY_LEN] {
|
||||
bytes[0] &= 248;
|
||||
bytes[31] &= 127;
|
||||
bytes[31] |= 64;
|
||||
bytes
|
||||
}
|
||||
|
||||
static BASE32_LOWER_UNPADDED: LazyLock<Encoding> = LazyLock::new(|| {
|
||||
let mut spec = Specification::new();
|
||||
spec.symbols.push_str("abcdefghijklmnopqrstuvwxyz234567");
|
||||
spec.encoding().unwrap()
|
||||
});
|
||||
|
||||
/// RFC 4648 base32, lowercase and unpadded (SPEC.md sec 3).
|
||||
pub fn b32(raw: &[u8]) -> String {
|
||||
BASE32_LOWER_UNPADDED.encode(raw)
|
||||
}
|
||||
|
||||
/// Inverse of `b32`; accepts uppercase and stray padding for pasted input.
|
||||
pub fn unb32(text: &str) -> Result<Vec<u8>, SmolError> {
|
||||
let lower = text.trim().to_ascii_lowercase();
|
||||
let trimmed = lower.trim_end_matches('=');
|
||||
BASE32_LOWER_UNPADDED
|
||||
.decode(trimmed.as_bytes())
|
||||
.map_err(|e| SmolError::new(format!("undecodable base32: {e}")))
|
||||
}
|
||||
|
||||
/// First 20 characters of the base32 identity, in groups of four (SPEC.md
|
||||
/// sec 3): a truncation of the identity, not a separate encoding.
|
||||
pub fn fingerprint(identity: &[u8; KEY_LEN]) -> String {
|
||||
let s: String = b32(identity).chars().take(20).collect();
|
||||
s.as_bytes()
|
||||
.chunks(4)
|
||||
.map(|c| std::str::from_utf8(c).unwrap())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
/// Multi-part SHA-256, the shape of every derived value in the protocol.
|
||||
pub fn sha256(parts: &[&[u8]]) -> [u8; 32] {
|
||||
let mut hasher = Sha256::new();
|
||||
for part in parts {
|
||||
hasher.update(part);
|
||||
}
|
||||
hasher.finalize().into()
|
||||
}
|
||||
|
||||
/// HKDF-SHA256 (RFC 5869), as used by the identity derivations of sec 2 and
|
||||
/// the sealing key of sec 5.2.
|
||||
pub fn hkdf_sha256(ikm: &[u8], salt: &[u8], info: &[u8], len: usize) -> Vec<u8> {
|
||||
let mut okm = vec![0u8; len];
|
||||
let _ = Hkdf::<Sha256>::new(Some(salt), ikm).expand(info, &mut okm);
|
||||
okm
|
||||
}
|
||||
|
||||
/// HMAC-SHA256 (RFC 2104), used for accept tokens and their MACs (sec 5.8).
|
||||
pub fn hmac_sha256(key: &[u8], message: &[u8]) -> [u8; 32] {
|
||||
let mut mac = Hmac::<Sha256>::new_from_slice(key).expect("HMAC accepts any key length");
|
||||
mac.update(message);
|
||||
mac.finalize().into_bytes().into()
|
||||
}
|
||||
|
||||
/// Constant-time equality, so a pinned-key or MAC comparison cannot leak
|
||||
/// timing information.
|
||||
pub fn ct_eq(a: &[u8], b: &[u8]) -> bool {
|
||||
if a.len() != b.len() {
|
||||
return false;
|
||||
}
|
||||
let mut diff = 0u8;
|
||||
for (x, y) in a.iter().zip(b.iter()) {
|
||||
diff |= x ^ y;
|
||||
}
|
||||
diff == 0
|
||||
}
|
||||
|
||||
/// The X25519 public key of an Ed25519 identity: libsodium's
|
||||
/// `crypto_sign_ed25519_pk_to_curve25519` (SPEC.md sec 2). Malformed points
|
||||
/// are rejected rather than mapped, matching the reference client.
|
||||
pub fn ed25519_to_x25519(identity: &[u8; KEY_LEN]) -> Result<[u8; KEY_LEN], SmolError> {
|
||||
VerifyingKey::from_bytes(identity)
|
||||
.map(|key| *key.to_montgomery().as_bytes())
|
||||
.map_err(|_| SmolError::new("not a valid Ed25519 public key"))
|
||||
}
|
||||
|
||||
/// X25519 key agreement with sec 2's checks. An all-zero output covers a
|
||||
/// low-order received ephemeral as well, so both are refused here.
|
||||
pub fn agree(secret: &StaticSecret, peer: &[u8; KEY_LEN]) -> Result<[u8; KEY_LEN], SmolError> {
|
||||
let shared = secret.diffie_hellman(&PublicKey::from(*peer));
|
||||
if shared.as_bytes().iter().all(|&b| b == 0) {
|
||||
return Err(SmolError::new("rejected all-zero key agreement output"));
|
||||
}
|
||||
Ok(*shared.as_bytes())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn base32_matches_rfc4648_lowercase_unpadded() {
|
||||
assert_eq!(b32(b"hello"), "nbswy3dp");
|
||||
assert_eq!(
|
||||
b32(&[0u8; 32]),
|
||||
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
);
|
||||
// 52 characters for a 32-byte key, the form the smol:// path uses.
|
||||
assert_eq!(b32(&[1u8; 32]).len(), 52);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unb32_round_trips_and_accepts_padded_uppercase() {
|
||||
assert_eq!(unb32("nbswy3dp").unwrap(), b"hello");
|
||||
assert_eq!(unb32("NBSWY3DP==").unwrap(), b"hello");
|
||||
assert!(unb32("nbsw!3dp").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fingerprint_is_20_chars_in_groups_of_four() {
|
||||
let fp = fingerprint(&[0u8; 32]);
|
||||
assert_eq!(fp, "aaaa aaaa aaaa aaaa aaaa");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hkdf_matches_rfc5869_case_1() {
|
||||
let ikm = [0x0bu8; 22];
|
||||
let salt = [0u8; 13];
|
||||
let info = [0xf0u8, 0xf1, 0xf2, 0xf3, 0xf4, 0xf5, 0xf6, 0xf7, 0xf8, 0xf9];
|
||||
let okm = hkdf_sha256(&ikm, &salt, &info, 42);
|
||||
// First 32 bytes of RFC 5869 test case 1's 42-byte OKM.
|
||||
let expected = "abbafb13f5c1bc489d4203135817956dd521b39e3bd61d1cc85cef884d1f8e2e";
|
||||
assert_eq!(data_encoding::HEXLOWER.encode(&okm[..32]), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hmac_matches_rfc4231_case_1() {
|
||||
let key = [0x0bu8; 20];
|
||||
let data = b"Hi There";
|
||||
let expected = "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7";
|
||||
assert_eq!(
|
||||
data_encoding::HEXLOWER.encode(&hmac_sha256(&key, data)),
|
||||
expected
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ed25519_to_x25519_rejects_garbage_and_maps_like_libsodium() {
|
||||
// Vectors generated with libsodium's pk_to_curve25519 over the
|
||||
// identities the reference client derives at seed_0 and seed_1.
|
||||
let seed0: [u8; 32] = unb32("6l6wdqp3uwi2a3vn6jnlgjjmvimilnqv6np6t2vytw7wj3jdg6ea")
|
||||
.unwrap()
|
||||
.try_into()
|
||||
.unwrap();
|
||||
let pk0 = *ed25519_dalek::SigningKey::from_bytes(&seed0)
|
||||
.verifying_key()
|
||||
.as_bytes();
|
||||
assert_eq!(
|
||||
b32(&ed25519_to_x25519(&pk0).unwrap()),
|
||||
"bqw73wfgphe4ubojbbsrhfvtnxknuhhwvq74lmadpppi2p7lkbvq"
|
||||
);
|
||||
// 32 zero bytes do decode to a curve point, but a low-order one;
|
||||
// sec 2's rejection happens at agreement time, where the all-zero
|
||||
// output surfaces.
|
||||
let secret = StaticSecret::from([7u8; 32]);
|
||||
assert!(agree(&secret, &ed25519_to_x25519(&[0u8; 32]).unwrap()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agree_rejects_all_zero_output() {
|
||||
// A low-order base point produces the all-zero agreement output that
|
||||
// sec 2 rejects.
|
||||
let secret = StaticSecret::from([7u8; 32]);
|
||||
let low_order = PublicKey::from([0u8; 32]);
|
||||
assert!(agree(&secret, low_order.as_bytes()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ct_eq_compares_content() {
|
||||
assert!(ct_eq(b"abc", b"abc"));
|
||||
assert!(!ct_eq(b"abc", b"abd"));
|
||||
assert!(!ct_eq(b"abc", b"ab"));
|
||||
}
|
||||
}
|
||||
77
src/error.rs
Normal file
77
src/error.rs
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
//! The error the CLI surfaces as a message, and the status-code mapping.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
/// Anything the user should see as a message rather than a traceback.
|
||||
#[derive(Debug)]
|
||||
pub struct SmolError(pub String);
|
||||
|
||||
impl SmolError {
|
||||
pub fn new(msg: impl Into<String>) -> Self {
|
||||
SmolError(msg.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for SmolError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for SmolError {}
|
||||
|
||||
impl From<anyhow::Error> for SmolError {
|
||||
fn from(e: anyhow::Error) -> Self {
|
||||
SmolError(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<rusqlite::Error> for SmolError {
|
||||
fn from(e: rusqlite::Error) -> Self {
|
||||
SmolError(format!("storage error: {e}"))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for SmolError {
|
||||
fn from(e: std::io::Error) -> Self {
|
||||
SmolError(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<snow::Error> for SmolError {
|
||||
fn from(e: snow::Error) -> Self {
|
||||
SmolError(format!("noise error: {e}"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Status codes from SPEC.md sec 12. Unassigned numbers map to None, so a
|
||||
/// caller cannot accidentally name one that does not exist.
|
||||
pub fn status_name(status: u8) -> Option<&'static str> {
|
||||
Some(match status {
|
||||
0 => "ok",
|
||||
1 => "malformed",
|
||||
2 => "bad version",
|
||||
3 => "unknown user",
|
||||
4 => "auth required",
|
||||
5 => "auth failed",
|
||||
6 => "quota exceeded",
|
||||
7 => "too large",
|
||||
8 => "rate limited",
|
||||
9 => "not permitted",
|
||||
10 => "internal error",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Turns a non-OK response into an error. The optional reason string in the
|
||||
/// response body is never parsed (SPEC.md sec 12); an unassigned code is
|
||||
/// surfaced by number and treated as a plain failure.
|
||||
pub fn expect_ok(status: u8, what: &str) -> Result<(), SmolError> {
|
||||
if status == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
let named = status_name(status)
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| format!("unknown status {status}"));
|
||||
Err(SmolError::new(format!("{what} failed: {named} ({status})")))
|
||||
}
|
||||
15
src/lib.rs
Normal file
15
src/lib.rs
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
//! `fumi` — the Smol Mail client (SPEC.md 1.2).
|
||||
//!
|
||||
//! Counterpart to bunshin, the server: deriving identities from one master
|
||||
//! secret, sealing and opening mail, and talking to a mailbox over a Noise_NX
|
||||
//! TCP connection (or the Reticulum carrier behind the `rns` feature).
|
||||
|
||||
pub mod account;
|
||||
pub mod address;
|
||||
pub mod client;
|
||||
pub mod crypto;
|
||||
pub mod error;
|
||||
pub mod message;
|
||||
pub mod store;
|
||||
pub mod tcp;
|
||||
pub mod transport;
|
||||
766
src/main.rs
Normal file
766
src/main.rs
Normal file
|
|
@ -0,0 +1,766 @@
|
|||
//! The command line: one master per store, selected by `--key` and `--db`.
|
||||
//! A second identity is a second pair of files.
|
||||
|
||||
use std::io::{IsTerminal, Read, Write};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use clap::{Parser, Subcommand};
|
||||
|
||||
use fumi::account::{identity_seed, Account};
|
||||
use fumi::address::Address;
|
||||
use fumi::client::{self, Pushed, SendDraft, TrustChange};
|
||||
use fumi::crypto::{b32, fingerprint, unb32, KEY_LEN};
|
||||
use fumi::error::SmolError;
|
||||
use fumi::store::Store;
|
||||
use fumi::transport::ID_LEN;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(about = "Smol Mail client")]
|
||||
struct Cli {
|
||||
/// Master secret file
|
||||
#[arg(long, global = true, default_value = "identity.key")]
|
||||
key: PathBuf,
|
||||
/// Local state and mail
|
||||
#[arg(long, global = true, default_value = "fumi.db")]
|
||||
db: PathBuf,
|
||||
/// Network timeout in seconds
|
||||
#[arg(long, global = true, default_value_t = 30)]
|
||||
timeout: u64,
|
||||
#[command(subcommand)]
|
||||
command: Command,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Command {
|
||||
/// Create a master secret
|
||||
Keygen {
|
||||
#[arg(long)]
|
||||
force: bool,
|
||||
},
|
||||
/// Show this identity
|
||||
Whoami,
|
||||
/// Recover local state from the master alone
|
||||
Restore { address: String },
|
||||
/// Advance to the next identity, signing the change
|
||||
Rotate,
|
||||
/// Pin a server's Noise static key (TCP only)
|
||||
Trust {
|
||||
host: String,
|
||||
key_b32: String,
|
||||
#[arg(long)]
|
||||
force: bool,
|
||||
},
|
||||
/// Bind this identity to a username
|
||||
Register {
|
||||
address: String,
|
||||
/// Invite token, if the server requires one
|
||||
#[arg(long)]
|
||||
invite: Option<String>,
|
||||
},
|
||||
/// Look up a contact's key, walk the chain, pin it
|
||||
Resolve { address: String },
|
||||
/// Add a contact from a self-certifying address
|
||||
Import { uri: String },
|
||||
/// List known keys and how each was learned
|
||||
Contacts,
|
||||
/// Issue an accept token, admit to the main tier, sync the set
|
||||
Accept { address: String },
|
||||
/// Withdraw a contact's accept token, sync the set
|
||||
Block { address: String },
|
||||
/// Seal and deliver a message
|
||||
Send {
|
||||
address: String,
|
||||
#[arg(long)]
|
||||
subject: Option<String>,
|
||||
/// Message text; stdin is read when neither --body nor --file is given
|
||||
#[arg(long)]
|
||||
body: Option<String>,
|
||||
/// Read the message text from this file ("-" for stdin)
|
||||
#[arg(long)]
|
||||
file: Option<String>,
|
||||
/// Extra `Key: value` frontmatter field; repeatable
|
||||
#[arg(long = "header", value_name = "KEY:VALUE")]
|
||||
headers: Vec<String>,
|
||||
/// The id this message replies to, 64 hex characters
|
||||
#[arg(long = "reply-to", value_name = "ID")]
|
||||
reply_to: Option<String>,
|
||||
/// Omit the Reply-To field carrying this address
|
||||
#[arg(long)]
|
||||
anonymous: bool,
|
||||
/// Do not pad the payload to 1 KiB
|
||||
#[arg(long = "no-pad")]
|
||||
no_pad: bool,
|
||||
},
|
||||
/// Retrieve, verify, store and acknowledge mail
|
||||
Fetch {
|
||||
/// Do not delete from the server; remember the cursor instead
|
||||
#[arg(long)]
|
||||
keep: bool,
|
||||
/// Forget the cursor and page again
|
||||
#[arg(long)]
|
||||
reset: bool,
|
||||
},
|
||||
/// Remove messages from the server explicitly
|
||||
Delete {
|
||||
/// Message ids, or unambiguous prefixes
|
||||
ids: Vec<String>,
|
||||
},
|
||||
/// List stored mail
|
||||
List {
|
||||
#[arg(long)]
|
||||
sent: bool,
|
||||
/// Mail that arrived without an accept token
|
||||
#[arg(long)]
|
||||
requests: bool,
|
||||
},
|
||||
/// Show one message
|
||||
Read {
|
||||
id: String,
|
||||
#[arg(long)]
|
||||
sent: bool,
|
||||
},
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let cli = Cli::parse();
|
||||
if let Err(e) = run(cli) {
|
||||
eprintln!("error: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
fn run(cli: Cli) -> Result<()> {
|
||||
let store = Store::open(&cli.db)?;
|
||||
match &cli.command {
|
||||
Command::Keygen { force } => keygen(&cli.key, *force),
|
||||
Command::Whoami => whoami(&cli.key, &store),
|
||||
Command::Restore { address } => restore(&cli.key, &store, address, cli.timeout),
|
||||
Command::Rotate => rotate(&cli.key, &store, cli.timeout),
|
||||
Command::Trust {
|
||||
host,
|
||||
key_b32,
|
||||
force,
|
||||
} => trust(&store, host, key_b32, *force),
|
||||
Command::Register { address, invite } => {
|
||||
register(&cli.key, &store, address, invite.as_deref(), cli.timeout)
|
||||
}
|
||||
Command::Resolve { address } => resolve(&store, address, cli.timeout),
|
||||
Command::Import { uri } => import(&store, uri),
|
||||
Command::Contacts => contacts(&store),
|
||||
Command::Accept { address } => accept(&cli.key, &store, address, cli.timeout),
|
||||
Command::Block { address } => block(&cli.key, &store, address, cli.timeout),
|
||||
Command::Send { .. } => send(&cli, &store),
|
||||
Command::Fetch { keep, reset } => fetch(&cli.key, &store, *keep, *reset, cli.timeout),
|
||||
Command::Delete { ids } => delete(&cli.key, &store, ids, cli.timeout),
|
||||
Command::List { sent, requests } => list(&cli.key, &store, *sent, *requests),
|
||||
Command::Read { id, sent } => read(&cli.key, &store, id, *sent),
|
||||
}
|
||||
}
|
||||
|
||||
fn warn(message: &str) {
|
||||
eprintln!("warning: {message}");
|
||||
}
|
||||
|
||||
fn load_master(path: &PathBuf) -> Result<[u8; KEY_LEN]> {
|
||||
let bytes = std::fs::read(path).map_err(|_| {
|
||||
SmolError::new(format!(
|
||||
"no identity at {}; run: fumi keygen",
|
||||
path.display()
|
||||
))
|
||||
})?;
|
||||
bytes.try_into().map_err(|v: Vec<u8>| {
|
||||
SmolError::new(format!(
|
||||
"master secret at {} is {} bytes, expected {KEY_LEN}",
|
||||
path.display(),
|
||||
v.len()
|
||||
))
|
||||
.into()
|
||||
})
|
||||
}
|
||||
|
||||
/// The master from disk plus the rotation index from local state.
|
||||
fn load_account(path: &PathBuf, store: &Store) -> Result<Account> {
|
||||
Ok(Account::new(load_master(path)?, store.rotations()?)?)
|
||||
}
|
||||
|
||||
/// Writes a secret with mode 0600 before any bytes land, so it is never
|
||||
/// briefly world-readable.
|
||||
fn write_secret(path: &PathBuf, secret: &[u8]) -> Result<()> {
|
||||
#[cfg(unix)]
|
||||
let file = {
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.mode(0o600)
|
||||
.open(path)
|
||||
};
|
||||
#[cfg(not(unix))]
|
||||
let file = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.open(path);
|
||||
let mut file = file.with_context(|| format!("cannot write {}", path.display()))?;
|
||||
file.write_all(secret)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn keygen(path: &PathBuf, force: bool) -> Result<()> {
|
||||
if path.exists() && !force {
|
||||
return Err(SmolError::new(format!(
|
||||
"{} exists; refusing to overwrite (use --force)",
|
||||
path.display()
|
||||
))
|
||||
.into());
|
||||
}
|
||||
let mut master = [0u8; KEY_LEN];
|
||||
use rand_core::RngCore;
|
||||
rand_core::OsRng.fill_bytes(&mut master);
|
||||
write_secret(path, &master)?;
|
||||
let account = Account::new(master, 0)?;
|
||||
println!(
|
||||
"master: {} (back this up; it is the only secret)",
|
||||
path.display()
|
||||
);
|
||||
println!("public key: {}", b32(&account.me().pk()));
|
||||
println!("fingerprint: {}", fingerprint(&account.me().pk()));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn whoami(key: &PathBuf, store: &Store) -> Result<()> {
|
||||
let account = load_account(key, store)?;
|
||||
println!("public key: {}", b32(&account.me().pk()));
|
||||
println!("fingerprint: {}", fingerprint(&account.me().pk()));
|
||||
match store.account()? {
|
||||
Some(addr) => {
|
||||
println!("address: {}", addr.short());
|
||||
println!("uri: {}", addr.uri(&account.me().pk()));
|
||||
}
|
||||
None => println!("address: (not registered)"),
|
||||
}
|
||||
if account.index() > 0 {
|
||||
println!(
|
||||
"rotations: {} (earlier keys derived on demand)",
|
||||
account.index()
|
||||
);
|
||||
}
|
||||
let live = store
|
||||
.contact_rows()?
|
||||
.iter()
|
||||
.filter(|(_, _, _, active)| *active == Some(1))
|
||||
.count();
|
||||
let sync = store.sync_ok()?;
|
||||
println!(
|
||||
"accepted: {live} correspondent(s){}",
|
||||
if sync {
|
||||
String::new()
|
||||
} else {
|
||||
", not pushed to the server until rebuilt".to_string()
|
||||
}
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn restore(key: &PathBuf, store: &Store, address: &str, timeout: u64) -> Result<()> {
|
||||
let master = load_master(key)?;
|
||||
let addr = Address::parse(address)?;
|
||||
let index = client::restore(store, &master, &addr, timeout)?;
|
||||
let identity = fumi::account::Identity::from_seed(identity_seed(&master, index));
|
||||
println!("restored {} at rotation {index}", addr.short());
|
||||
println!("public key: {}", b32(&identity.pk()));
|
||||
println!(
|
||||
"Your accepted correspondents are still live on the server; `accept` them again only \
|
||||
when you are ready to replace that set."
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn rotate(key: &PathBuf, store: &Store, timeout: u64) -> Result<()> {
|
||||
let account = load_account(key, store)?;
|
||||
let rotated = client::rotate(store, &account, timeout)?;
|
||||
let addr = store.account()?.expect("rotate checked for an account");
|
||||
println!("rotated {}", addr.short());
|
||||
println!("new key: {}", b32(&rotated.new_pk));
|
||||
println!("fingerprint: {}", fingerprint(&rotated.new_pk));
|
||||
println!("share: {}", addr.uri(&rotated.new_pk));
|
||||
println!();
|
||||
println!("Tell your contacts; they will accept the change from the signed chain.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn trust(store: &Store, host: &str, key_b32: &str, force: bool) -> Result<()> {
|
||||
let key: [u8; KEY_LEN] = unb32(key_b32)?.try_into().map_err(|v: Vec<u8>| {
|
||||
SmolError::new(format!(
|
||||
"server key is {} bytes, expected {KEY_LEN}",
|
||||
v.len()
|
||||
))
|
||||
})?;
|
||||
// A pin is per server, and the reference client strips any :port the
|
||||
// same way its address grammar does: at the first colon.
|
||||
let host = host.split(':').next().unwrap_or(host);
|
||||
match store.server_pin(host)? {
|
||||
Some(old) if old != key && !force => {
|
||||
return Err(SmolError::new(format!(
|
||||
"{host} is already pinned to {}; use --force to replace",
|
||||
b32(&old)
|
||||
))
|
||||
.into());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
store.pin_server(host, &key)?;
|
||||
println!("pinned {host} {}", b32(&key));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn register(
|
||||
key: &PathBuf,
|
||||
store: &Store,
|
||||
address: &str,
|
||||
invite: Option<&str>,
|
||||
timeout: u64,
|
||||
) -> Result<()> {
|
||||
let account = load_account(key, store)?;
|
||||
let addr = Address::parse(address)?;
|
||||
client::register(store, &addr, &account, invite, timeout)?;
|
||||
println!("registered {}", addr.short());
|
||||
println!("share: {}", addr.uri(&account.me().pk()));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn resolve(store: &Store, address: &str, timeout: u64) -> Result<()> {
|
||||
let addr = Address::parse(address)?;
|
||||
if addr.identity.is_some() {
|
||||
return Err(
|
||||
SmolError::new("that address already carries a key; use `import` instead").into(),
|
||||
);
|
||||
}
|
||||
let mut session = client::connect(store, &addr, false, timeout)?;
|
||||
if let Some(key) = session.unpinned_static {
|
||||
warn(&format!(
|
||||
"{} is not pinned; its key is {}",
|
||||
addr.host,
|
||||
b32(&key)
|
||||
));
|
||||
}
|
||||
let transport = session.transport();
|
||||
let (identity, change) = client::trust_key(store, transport, &addr)?;
|
||||
session.close();
|
||||
match change {
|
||||
TrustChange::New { unverified } => println!(
|
||||
"{} {}\n pinned (trust on first use{})",
|
||||
addr.short(),
|
||||
b32(&identity),
|
||||
if unverified {
|
||||
", UNVERIFIED server"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
),
|
||||
TrustChange::Rotated => {
|
||||
warn(&format!(
|
||||
"{} rotated its key; a signed chain confirms it",
|
||||
addr.short()
|
||||
));
|
||||
println!(" now {}", b32(&identity));
|
||||
}
|
||||
TrustChange::None => println!("{} {}", addr.short(), b32(&identity)),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn import(store: &Store, uri: &str) -> Result<()> {
|
||||
let addr = Address::parse(uri)?;
|
||||
let key = addr
|
||||
.identity
|
||||
.ok_or_else(|| SmolError::new("import needs a self-certifying address carrying a key"))?;
|
||||
store.save_contact(&addr.short(), &key, true)?;
|
||||
println!("imported {} {} (verified)", addr.short(), b32(&key));
|
||||
println!("fingerprint: {}", fingerprint(&key));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn contacts(store: &Store) -> Result<()> {
|
||||
let rows = store.contact_rows()?;
|
||||
if rows.is_empty() {
|
||||
println!("no contacts");
|
||||
return Ok(());
|
||||
}
|
||||
let width = rows.iter().map(|(a, _, _, _)| a.len()).max().unwrap_or(0);
|
||||
for (address, identity, verified, active) in rows {
|
||||
let state = match active {
|
||||
Some(1) => "accepted",
|
||||
Some(_) => "blocked",
|
||||
None => "",
|
||||
};
|
||||
println!(
|
||||
"{address:<width$} {} {:<8} {state}",
|
||||
b32(&identity),
|
||||
if verified { "verified" } else { "tofu" }
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// An address plus the key we hold for it, for commands naming a contact.
|
||||
fn target_contact(store: &Store, text: &str) -> Result<(String, [u8; KEY_LEN])> {
|
||||
let addr = Address::parse(text)?;
|
||||
if let Some(key) = addr.identity {
|
||||
return Ok((addr.short(), key));
|
||||
}
|
||||
match store.contact(&addr.short())? {
|
||||
Some((key, _)) => Ok((addr.short(), key)),
|
||||
None => Err(SmolError::new(format!(
|
||||
"no key for {}; run `resolve` or `import` first",
|
||||
addr.short()
|
||||
))
|
||||
.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn accept(key: &PathBuf, store: &Store, address: &str, timeout: u64) -> Result<()> {
|
||||
let account = load_account(key, store)?;
|
||||
let (address, identity) = target_contact(store, address)?;
|
||||
if !store.sync_ok()? {
|
||||
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",
|
||||
);
|
||||
}
|
||||
store.accept(&address, &identity)?;
|
||||
println!("accepted {address}; its token travels in your next message to them");
|
||||
match client::push_tokens(store, &account, timeout)? {
|
||||
Pushed::NotRegistered => {
|
||||
warn("not registered; the set will be pushed with your first fetch")
|
||||
}
|
||||
Pushed::Held(held) => println!("server now holds {held} accept token(s)"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn block(key: &PathBuf, store: &Store, address: &str, timeout: u64) -> Result<()> {
|
||||
let account = load_account(key, store)?;
|
||||
let (address, _) = target_contact(store, address)?;
|
||||
if !store.block(&address)? {
|
||||
return Err(SmolError::new(format!("{address} was never accepted")).into());
|
||||
}
|
||||
println!("blocked {address}; their mail lands in requests from their next message on");
|
||||
match client::push_tokens(store, &account, timeout)? {
|
||||
Pushed::NotRegistered => {
|
||||
warn("not registered; the set will be pushed with your first fetch")
|
||||
}
|
||||
Pushed::Held(held) => println!("server now holds {held} accept token(s)"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn send(cli: &Cli, store: &Store) -> Result<()> {
|
||||
let Command::Send {
|
||||
address,
|
||||
subject,
|
||||
body,
|
||||
file,
|
||||
headers,
|
||||
reply_to,
|
||||
anonymous,
|
||||
no_pad,
|
||||
} = &cli.command
|
||||
else {
|
||||
unreachable!("send dispatches only from the Send command")
|
||||
};
|
||||
let account = load_account(&cli.key, store)?;
|
||||
let addr = Address::parse(address)?;
|
||||
let text = match (body, file) {
|
||||
(Some(text), _) => text.clone(),
|
||||
(None, Some(path)) if path == "-" => read_stdin()?,
|
||||
(None, Some(path)) => {
|
||||
std::fs::read_to_string(path).with_context(|| format!("cannot read {path}"))?
|
||||
}
|
||||
(None, None) if !std::io::stdin().is_terminal() => read_stdin()?,
|
||||
(None, None) => {
|
||||
return Err(SmolError::new("no message body; pass --body or pipe it on stdin").into())
|
||||
}
|
||||
};
|
||||
if let Some(reply) = reply_to {
|
||||
if reply.len() != 64 || !reply.bytes().all(|c| c.is_ascii_hexdigit()) {
|
||||
return Err(
|
||||
SmolError::new("--reply-to must be a message id: 64 hex characters").into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Extra headers must be valid `Key: value` fields (sec 5.5).
|
||||
let mut extra: Vec<(String, String)> = Vec::new();
|
||||
for raw in headers {
|
||||
let Some((k, v)) = raw.split_once(':') else {
|
||||
return Err(
|
||||
SmolError::new(format!("{raw:?} is not a valid `Key: value` header")).into(),
|
||||
);
|
||||
};
|
||||
if !valid_frontmatter_key(k.trim()) {
|
||||
return Err(
|
||||
SmolError::new(format!("{raw:?} is not a valid `Key: value` header")).into(),
|
||||
);
|
||||
}
|
||||
extra.push((k.trim().to_string(), v.trim().to_string()));
|
||||
}
|
||||
let borrowed: Vec<(&str, &str)> = extra
|
||||
.iter()
|
||||
.map(|(k, v)| (k.as_str(), v.as_str()))
|
||||
.collect();
|
||||
|
||||
let draft = SendDraft {
|
||||
address: &addr,
|
||||
text,
|
||||
subject: subject.as_deref(),
|
||||
reply_to: reply_to.as_deref(),
|
||||
headers: &borrowed,
|
||||
anonymous: *anonymous,
|
||||
no_pad: *no_pad,
|
||||
};
|
||||
let sent = client::send(store, &account, &draft, cli.timeout)?;
|
||||
if let Some(warning) = &sent.warning {
|
||||
warn(warning);
|
||||
}
|
||||
match sent.change {
|
||||
TrustChange::New { unverified } => warn(&format!(
|
||||
"{} is new; its key was learned by trust on first use{}",
|
||||
addr.short(),
|
||||
if unverified {
|
||||
" over an UNVERIFIED server"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
)),
|
||||
TrustChange::Rotated => warn(&format!(
|
||||
"{} rotated its key; a signed chain confirms it",
|
||||
addr.short()
|
||||
)),
|
||||
TrustChange::None => {}
|
||||
}
|
||||
println!(
|
||||
"sent {} to {} ({} bytes{})",
|
||||
&hex(&sent.id)[..16],
|
||||
addr.short(),
|
||||
sent.bytes,
|
||||
if sent.token_used { ", accepted" } else { "" }
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_stdin() -> Result<String> {
|
||||
let mut text = String::new();
|
||||
std::io::stdin()
|
||||
.read_to_string(&mut text)
|
||||
.context("cannot read stdin")?;
|
||||
Ok(text)
|
||||
}
|
||||
|
||||
fn valid_frontmatter_key(key: &str) -> bool {
|
||||
!key.is_empty()
|
||||
&& key.len() <= 64
|
||||
&& key.bytes().all(|c| c.is_ascii_alphanumeric() || c == b'-')
|
||||
}
|
||||
|
||||
fn fetch(key: &PathBuf, store: &Store, keep: bool, reset: bool, timeout: u64) -> Result<()> {
|
||||
let account = load_account(key, store)?;
|
||||
let summary = client::fetch(store, &account, keep, reset, timeout)?;
|
||||
for rejected in &summary.rejected {
|
||||
warn(&format!(
|
||||
"{}: {}; left on server",
|
||||
&hex(&rejected.id)[..16],
|
||||
rejected.reason
|
||||
));
|
||||
}
|
||||
let mut line = format!(
|
||||
"{} message(s): {} new, {} rejected",
|
||||
summary.total,
|
||||
summary.stored,
|
||||
summary.rejected.len()
|
||||
);
|
||||
if keep && summary.total > 0 {
|
||||
line.push_str(" (left on the server)");
|
||||
}
|
||||
println!("{line}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolves an unambiguous id prefix against stored mail and returns the full
|
||||
/// ids, ready for the wire.
|
||||
fn resolve_id_prefixes(store: &Store, ids: &[String]) -> Result<Vec<[u8; ID_LEN]>> {
|
||||
if ids.is_empty() {
|
||||
return Err(SmolError::new("no message ids given").into());
|
||||
}
|
||||
let stored = store
|
||||
.mail("all")?
|
||||
.into_iter()
|
||||
.chain(store.mail("sent")?)
|
||||
.map(|m| m.id)
|
||||
.collect::<Vec<_>>();
|
||||
let mut full = Vec::with_capacity(ids.len());
|
||||
for id in ids {
|
||||
let prefix = id.to_ascii_lowercase();
|
||||
let matches: Vec<[u8; ID_LEN]> = stored
|
||||
.iter()
|
||||
.filter(|m| hex(m).starts_with(&prefix))
|
||||
.copied()
|
||||
.collect();
|
||||
match matches.len() {
|
||||
0 => return Err(SmolError::new(format!("no message matching {id:?}")).into()),
|
||||
1 => full.push(matches[0]),
|
||||
_ => {
|
||||
return Err(SmolError::new(format!(
|
||||
"{id:?} matches {} messages; be more specific",
|
||||
matches.len()
|
||||
))
|
||||
.into())
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(full)
|
||||
}
|
||||
|
||||
fn delete(key: &PathBuf, store: &Store, ids: &[String], timeout: u64) -> Result<()> {
|
||||
let account = load_account(key, store)?;
|
||||
let full = resolve_id_prefixes(store, ids)?;
|
||||
let removed = client::delete(store, &account, &full, timeout)?;
|
||||
println!("removed {removed} message(s) from the server");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn list(key: &PathBuf, store: &Store, sent: bool, requests: bool) -> Result<()> {
|
||||
let account = load_account(key, store)?;
|
||||
let folder = if sent {
|
||||
"sent"
|
||||
} else if requests {
|
||||
"requests"
|
||||
} else {
|
||||
"inbox"
|
||||
};
|
||||
let rows = store.mail(folder)?;
|
||||
if rows.is_empty() {
|
||||
println!("{folder} is empty");
|
||||
return Ok(());
|
||||
}
|
||||
for row in rows {
|
||||
let who = row.recipient.clone();
|
||||
match client::describe(store, &account, &row) {
|
||||
Ok(described) => {
|
||||
let who = who.unwrap_or(described.from);
|
||||
let subject = if described.subject.is_empty() {
|
||||
"(no subject)".to_string()
|
||||
} else {
|
||||
described.subject
|
||||
};
|
||||
println!(
|
||||
"{} {} {:<28.28} {subject}",
|
||||
&hex(&row.id)[..8],
|
||||
format_utc(row.at),
|
||||
who
|
||||
);
|
||||
}
|
||||
Err(e) => println!(
|
||||
"{} {} {:<28.28} <unreadable: {e}>",
|
||||
&hex(&row.id)[..8],
|
||||
format_utc(row.at),
|
||||
"?"
|
||||
),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read(key: &PathBuf, store: &Store, id: &str, sent: bool) -> Result<()> {
|
||||
let account = load_account(key, store)?;
|
||||
let folder = if sent { "sent" } else { "all" };
|
||||
let prefix = id.to_ascii_lowercase();
|
||||
let matches: Vec<_> = store
|
||||
.mail(folder)?
|
||||
.into_iter()
|
||||
.filter(|m| hex(&m.id).starts_with(&prefix))
|
||||
.collect();
|
||||
match matches.len() {
|
||||
0 => Err(SmolError::new(format!(
|
||||
"no {}message matching {id:?}",
|
||||
if sent { "sent " } else { "" }
|
||||
))
|
||||
.into()),
|
||||
1 => {
|
||||
let row = &matches[0];
|
||||
let described = client::describe(store, &account, row)?;
|
||||
fn field(key: &str, value: &str) {
|
||||
println!("{:<12} {value}", format!("{key}:"));
|
||||
}
|
||||
field("id", &hex(&row.id));
|
||||
if let Some(recipient) = &row.recipient {
|
||||
field("to", recipient);
|
||||
}
|
||||
field("from", &described.from);
|
||||
field("key", &b32(&described.sender));
|
||||
field("date", &format_utc(described.time));
|
||||
for (k, v) in &described.fields {
|
||||
if k != "accept" {
|
||||
field(k, v);
|
||||
}
|
||||
}
|
||||
println!("signature verified");
|
||||
println!();
|
||||
print!("{}", described.text);
|
||||
if !described.text.ends_with('\n') {
|
||||
println!();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(SmolError::new(format!(
|
||||
"{id:?} matches {} messages; be more specific",
|
||||
matches.len()
|
||||
))
|
||||
.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn hex(id: &[u8; ID_LEN]) -> String {
|
||||
data_encoding::HEXLOWER.encode(id)
|
||||
}
|
||||
|
||||
/// UTC date and time, so no time-zone dependency is needed for a mail listing.
|
||||
fn format_utc(secs: i64) -> String {
|
||||
let days = secs.div_euclid(86_400);
|
||||
let time_of_day = secs.rem_euclid(86_400);
|
||||
let (year, month, day) = civil_from_days(days);
|
||||
format!(
|
||||
"{year:04}-{month:02}-{day:02} {:02}:{:02}",
|
||||
time_of_day / 3600,
|
||||
time_of_day % 3600 / 60
|
||||
)
|
||||
}
|
||||
|
||||
/// Days since the Unix epoch to a civil date (Howard Hinnant's algorithm).
|
||||
fn civil_from_days(z: i64) -> (i64, u32, u32) {
|
||||
let z = z + 719_468;
|
||||
let era = z.div_euclid(146_097);
|
||||
let doe = z.rem_euclid(146_097);
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let y = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
(if m <= 2 { y + 1 } else { y }, m as u32, d as u32)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn utc_formatting_matches_known_dates() {
|
||||
assert_eq!(format_utc(0), "1970-01-01 00:00");
|
||||
assert_eq!(format_utc(1_700_000_000), "2023-11-14 22:13");
|
||||
assert_eq!(format_utc(951_782_400), "2000-02-29 00:00"); // leap day
|
||||
assert_eq!(format_utc(-1), "1969-12-31 23:59");
|
||||
}
|
||||
}
|
||||
443
src/message.rs
Normal file
443
src/message.rs
Normal file
|
|
@ -0,0 +1,443 @@
|
|||
//! Envelopes (sec 5.1-5.4) and body frontmatter (sec 5.5).
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use chacha20poly1305::aead::{Aead, KeyInit, Payload};
|
||||
use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce};
|
||||
use rand_core::{OsRng, RngCore};
|
||||
|
||||
use crate::account::{verify_sig, Identity};
|
||||
use crate::crypto::{agree, b32, ct_eq, ed25519_to_x25519, hkdf_sha256, sha256, unb32, KEY_LEN};
|
||||
use crate::error::SmolError;
|
||||
use crate::transport::{
|
||||
ENVELOPE_HEADER, ENVELOPE_MAGIC, ENVELOPE_MIN, ENVELOPE_VERSION, LABEL_ID, LABEL_MSG,
|
||||
LABEL_SEAL, PAYLOAD_HEADER, SIG_LEN,
|
||||
};
|
||||
|
||||
/// Senders MAY pad to a multiple of this to blur message length (sec 5.3).
|
||||
pub const PAD_TO: usize = 1024;
|
||||
/// A payload dated further ahead of our clock than this is rejected (sec 5.3).
|
||||
pub const MAX_SKEW: i64 = 86400;
|
||||
/// Frontmatter limits (sec 5.5).
|
||||
pub const FRONTMATTER_MAX: usize = 4096;
|
||||
pub const FRONTMATTER_KEYS: usize = 64;
|
||||
|
||||
/// An opened, verified payload. The message id is derived from the envelope,
|
||||
/// not carried in it.
|
||||
pub struct Opened {
|
||||
pub sender: [u8; KEY_LEN],
|
||||
pub time: i64,
|
||||
pub body: Vec<u8>,
|
||||
}
|
||||
|
||||
/// The derived message identifier (sec 5.4): a sender cannot choose it, and a
|
||||
/// resend is a no-op rather than a new message.
|
||||
pub fn message_id(envelope: &[u8]) -> [u8; KEY_LEN] {
|
||||
sha256(&[LABEL_ID, envelope])
|
||||
}
|
||||
|
||||
/// Seals `body` from `sender` to `recipient` (sec 5.2, 5.3). A fresh ephemeral
|
||||
/// X25519 keypair per message makes the all-zero nonce correct: the derived
|
||||
/// key is used exactly once, and the sender's long-term key never takes part
|
||||
/// in key agreement.
|
||||
pub fn seal(
|
||||
sender: &Identity,
|
||||
recipient: &[u8; KEY_LEN],
|
||||
body: &[u8],
|
||||
when: i64,
|
||||
pad: bool,
|
||||
) -> Result<Vec<u8>, SmolError> {
|
||||
let mut esk = [0u8; KEY_LEN];
|
||||
OsRng.fill_bytes(&mut esk);
|
||||
// esk is dropped at the end of the frame; nothing more can be promised
|
||||
// without a zeroizing type.
|
||||
seal_with_esk(sender, recipient, body, when, pad, esk)
|
||||
}
|
||||
|
||||
fn seal_with_esk(
|
||||
sender: &Identity,
|
||||
recipient: &[u8; KEY_LEN],
|
||||
body: &[u8],
|
||||
when: i64,
|
||||
pad: bool,
|
||||
esk: [u8; KEY_LEN],
|
||||
) -> Result<Vec<u8>, SmolError> {
|
||||
let x_recipient = ed25519_to_x25519(recipient)?;
|
||||
let eph = x25519_dalek::StaticSecret::from(esk);
|
||||
let epk = x25519_dalek::PublicKey::from(&eph);
|
||||
// Ephemeral-static against the recipient's converted key (sec 5.2);
|
||||
// converting also validates the recipient identity as a curve point.
|
||||
let shared = agree(&eph, &x_recipient)?;
|
||||
let mut salt = Vec::with_capacity(2 * KEY_LEN);
|
||||
salt.extend_from_slice(epk.as_bytes());
|
||||
salt.extend_from_slice(recipient);
|
||||
let key = hkdf_sha256(&shared, &salt, LABEL_SEAL, 32);
|
||||
|
||||
let mut header = Vec::with_capacity(PAYLOAD_HEADER);
|
||||
header.push(ENVELOPE_VERSION);
|
||||
header.extend_from_slice(&sender.pk());
|
||||
header.extend_from_slice(&when.to_be_bytes());
|
||||
header.extend_from_slice(&(body.len() as u32).to_be_bytes());
|
||||
|
||||
let mut signed = Vec::with_capacity(LABEL_MSG.len() + 2 * KEY_LEN + header.len() + body.len());
|
||||
signed.extend_from_slice(LABEL_MSG);
|
||||
signed.extend_from_slice(recipient);
|
||||
signed.extend_from_slice(epk.as_bytes());
|
||||
signed.extend_from_slice(&header);
|
||||
signed.extend_from_slice(body);
|
||||
let mut plaintext = header;
|
||||
plaintext.extend_from_slice(body);
|
||||
plaintext.extend_from_slice(&sender.sign(&signed));
|
||||
if pad {
|
||||
let pad = (PAD_TO - plaintext.len() % PAD_TO) % PAD_TO;
|
||||
plaintext.resize(plaintext.len() + pad, 0);
|
||||
}
|
||||
|
||||
let mut aad = Vec::with_capacity(ENVELOPE_HEADER);
|
||||
aad.extend_from_slice(ENVELOPE_MAGIC);
|
||||
aad.push(ENVELOPE_VERSION);
|
||||
aad.extend_from_slice(recipient);
|
||||
aad.extend_from_slice(epk.as_bytes());
|
||||
let cipher = ChaCha20Poly1305::new(Key::from_slice(&key));
|
||||
let sealed = cipher
|
||||
.encrypt(
|
||||
&Nonce::from([0u8; 12]),
|
||||
Payload {
|
||||
msg: &plaintext,
|
||||
aad: &aad,
|
||||
},
|
||||
)
|
||||
.map_err(|_| SmolError::new("encryption failed"))?;
|
||||
let mut envelope = aad;
|
||||
envelope.extend_from_slice(&sealed);
|
||||
Ok(envelope)
|
||||
}
|
||||
|
||||
/// Inverse of `seal`, performing every check a receiver MUST make (sec 5.3):
|
||||
/// the envelope is addressed to one of our keys, the signature verifies
|
||||
/// against the sender the payload carries, and the payload is not dated more
|
||||
/// than `MAX_SKEW` ahead of `now`. Trailing bytes past `body_len + 64` are
|
||||
/// ignored as padding.
|
||||
pub fn unseal(identities: &[Identity], envelope: &[u8], now: i64) -> Result<Opened, SmolError> {
|
||||
if envelope.len() < ENVELOPE_MIN {
|
||||
return Err(SmolError::new("envelope too short"));
|
||||
}
|
||||
if &envelope[..4] != ENVELOPE_MAGIC {
|
||||
return Err(SmolError::new("not a Smol Mail envelope"));
|
||||
}
|
||||
if envelope[4] != ENVELOPE_VERSION {
|
||||
return Err(SmolError::new(format!(
|
||||
"unsupported envelope version {}",
|
||||
envelope[4]
|
||||
)));
|
||||
}
|
||||
let to: [u8; KEY_LEN] = envelope[5..37].try_into().unwrap();
|
||||
let epk: [u8; KEY_LEN] = envelope[37..69].try_into().unwrap();
|
||||
let sealed = &envelope[ENVELOPE_HEADER..];
|
||||
|
||||
let me = identities
|
||||
.iter()
|
||||
.find(|i| ct_eq(&i.pk(), &to))
|
||||
.ok_or_else(|| {
|
||||
SmolError::new(format!(
|
||||
"addressed to {}…, not one of our keys",
|
||||
&b32(&to)[..16]
|
||||
))
|
||||
})?;
|
||||
let shared = agree(me.x_priv(), &epk)?;
|
||||
let mut salt = Vec::with_capacity(2 * KEY_LEN);
|
||||
salt.extend_from_slice(&epk);
|
||||
salt.extend_from_slice(&to);
|
||||
let key = hkdf_sha256(&shared, &salt, LABEL_SEAL, 32);
|
||||
|
||||
let cipher = ChaCha20Poly1305::new(Key::from_slice(&key));
|
||||
let plaintext = cipher
|
||||
.decrypt(
|
||||
&Nonce::from([0u8; 12]),
|
||||
Payload {
|
||||
msg: sealed,
|
||||
aad: &envelope[..ENVELOPE_HEADER],
|
||||
},
|
||||
)
|
||||
.map_err(|_| SmolError::new("decryption failed: wrong key or corrupt envelope"))?;
|
||||
|
||||
if plaintext.len() < PAYLOAD_HEADER + SIG_LEN || plaintext[0] != ENVELOPE_VERSION {
|
||||
return Err(SmolError::new("unsupported payload version"));
|
||||
}
|
||||
let header = &plaintext[..PAYLOAD_HEADER];
|
||||
let sender: [u8; KEY_LEN] = plaintext[1..33].try_into().unwrap();
|
||||
let when = i64::from_be_bytes(plaintext[33..41].try_into().unwrap());
|
||||
let body_len = u32::from_be_bytes(plaintext[41..45].try_into().unwrap()) as usize;
|
||||
if PAYLOAD_HEADER + body_len + SIG_LEN > plaintext.len() {
|
||||
return Err(SmolError::new("payload body length exceeds the payload"));
|
||||
}
|
||||
let body = &plaintext[PAYLOAD_HEADER..PAYLOAD_HEADER + body_len];
|
||||
let signature = &plaintext[PAYLOAD_HEADER + body_len..PAYLOAD_HEADER + body_len + SIG_LEN];
|
||||
|
||||
let mut signed = Vec::with_capacity(LABEL_MSG.len() + 2 * KEY_LEN + header.len() + body.len());
|
||||
signed.extend_from_slice(LABEL_MSG);
|
||||
signed.extend_from_slice(&to);
|
||||
signed.extend_from_slice(&epk);
|
||||
signed.extend_from_slice(header);
|
||||
signed.extend_from_slice(body);
|
||||
if !verify_sig(&sender, signature, &signed) {
|
||||
return Err(SmolError::new("signature does not verify"));
|
||||
}
|
||||
if when > now + MAX_SKEW {
|
||||
return Err(SmolError::new("payload is dated in the future"));
|
||||
}
|
||||
Ok(Opened {
|
||||
sender,
|
||||
time: when,
|
||||
body: body.to_vec(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Splits a body into its frontmatter fields and text (sec 5.5). Keys are
|
||||
/// returned lowercased; a malformed block falls back to the whole body as
|
||||
/// plain text, failing closed toward display.
|
||||
pub fn parse_frontmatter(body: &str) -> (BTreeMap<String, String>, String) {
|
||||
let whole = body.to_string();
|
||||
if !body.starts_with("---\n") {
|
||||
return (BTreeMap::new(), whole);
|
||||
}
|
||||
let lines: Vec<&str> = body.split('\n').collect();
|
||||
// The block runs to the next line that is exactly `---`.
|
||||
let Some(close) = lines
|
||||
.iter()
|
||||
.skip(1)
|
||||
.position(|&l| l == "---")
|
||||
.map(|p| p + 1)
|
||||
else {
|
||||
return (BTreeMap::new(), whole);
|
||||
};
|
||||
let block = &lines[1..close];
|
||||
let rest = lines[close + 1..].join("\n");
|
||||
if block.len() > FRONTMATTER_KEYS {
|
||||
return (BTreeMap::new(), whole);
|
||||
}
|
||||
if block.iter().map(|l| l.len() + 1).sum::<usize>() > FRONTMATTER_MAX {
|
||||
return (BTreeMap::new(), whole);
|
||||
}
|
||||
let mut fields = BTreeMap::new();
|
||||
for line in block {
|
||||
let Some((key, value)) = line.split_once(':') else {
|
||||
return (BTreeMap::new(), whole);
|
||||
};
|
||||
if !valid_key(key) {
|
||||
return (BTreeMap::new(), whole);
|
||||
}
|
||||
// First occurrence wins; keys are compared case-insensitively.
|
||||
fields
|
||||
.entry(key.to_ascii_lowercase())
|
||||
.or_insert_with(|| value.trim().to_string());
|
||||
}
|
||||
(fields, rest)
|
||||
}
|
||||
|
||||
/// Emits a block only when needed, including to escape a body that genuinely
|
||||
/// begins with `---` (sec 5.5).
|
||||
pub fn build_frontmatter(fields: &[(&str, &str)], body: &str) -> String {
|
||||
if fields.is_empty() && !body.starts_with("---\n") {
|
||||
return body.to_string();
|
||||
}
|
||||
let mut out = String::from("---\n");
|
||||
for (key, value) in fields {
|
||||
out.push_str(&format!("{key}: {value}\n"));
|
||||
}
|
||||
out.push_str("---\n");
|
||||
out.push_str(body);
|
||||
out
|
||||
}
|
||||
|
||||
/// 1-64 bytes of [A-Za-z0-9-] (sec 5.5).
|
||||
fn valid_key(key: &str) -> bool {
|
||||
!key.is_empty()
|
||||
&& key.len() <= 64
|
||||
&& key.bytes().all(|c| c.is_ascii_alphanumeric() || c == b'-')
|
||||
}
|
||||
|
||||
/// The accept token carried in a reserved `Accept` field, if any. It is
|
||||
/// meaningful only inside a sealed, signed payload, which the caller has
|
||||
/// already verified (sec 5.8).
|
||||
pub fn accept_field(fields: &BTreeMap<String, String>) -> Option<[u8; 32]> {
|
||||
let raw = fields.get("accept")?;
|
||||
unb32(raw).ok()?.try_into().ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::account::identity_seed;
|
||||
use crate::crypto::b32;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
fn master() -> [u8; 32] {
|
||||
core::array::from_fn(|i| i as u8)
|
||||
}
|
||||
|
||||
// A full envelope produced by the reference client: fixed ephemeral
|
||||
// [7;32], when = 1700000000, sender = seed_3, recipient = pk_1.
|
||||
const REF_ENVELOPE_B32: &str = "kngu6tabzoieneun2k7n5b65yxfeen4pxvxzq7sclm2kfaa7os3wrwrz4rtbhpsp5lvpebgh7uzvr7e4abzbraoroqtyckbcp3dhj437p7uxw3lqwcki4wgjyisjpfr77awo2sz7p3uuke337wdlyubbzvdixnvauhf2q2nihc4cerlf62uyhdnvwnrajxxqo56hht5qwijh3uxbrons62te3xwwpm4yppbiab63nw57rj3yjarzwq2tfz57cphnxnacdbm5xdl66mdygni5js7pcftrmhitpcqjibjdmanyjtl75xtsjkm6dxzcwldqaxngqnfgg37yatihmh5ba76e6ykfiymqcb5nguhws2cviykpwlkuwmxygtgqxpethdvvlhnurf6g4i2u3la2nd3rhgjlr4t227xerr652osqltqzpcjf4m2sidnhcvu5252nijsvxxmgi343ojiffqodjr4fjuglzdwovufeyirkcjywvjdlslcdmjsxg5wiadnvectykxlq3c6qxxqex66z5vjcklzoldtrh3h36l35vqtjtn5rf4yggz2wtbrrdteym2k46obuoxiolj2cnbmj7qihdabg6wdvohl2ctpoc57huengekrprkuyzvogidgwrnqbjhxjgz7mqq6tri24rkts3pqaza6jol6w72zltvxa5itkarkmu6e2vjytpsxbxulnursf52m4abd3w7vxyw6rlxogivj2wafqpylngomzgghlcyoto65ac2n3oavt53xbcdwmgstrdkhlzd2j2wihp2xscizr7pa3n7d3rzuocdc46cwishrvejl3cihfdfnzjhosij5jkr3driehzjdgrx7rl4q43gyjdlvl5zuvvihw2ka75do7vakdghfz4d2jmh7an2bkchnyv2bnwydwei6wsbfc55g7mthvqqwm5ojaza4f37ha4cps37zrbw4fg5q3xpxupfotnvts5ki7zcpvkpdwn4p4voqlpve6czprwfhv7pmmipevr76trg44lkxemhpweqqxtzr4bdsuwt5hroelg2pyv2e3qmq4ibanrqx6ygr4k7b4znwqmtx5urn3wfu2do45bivw6qd55vaaqzeu27shn5xj4mmpfy46iuj2a3vdkwuevlix26uz27ryuovrgnaurhkuowuyme2u2nxwr5z5xoclwy42dw5mg3tynplzz2espoq5ok5amuacjxcy3dm37d547mljiz3ist25pyv7zn33onzovucntivlvrqul23obeb44lqybqnu4m6nbbdpcuniyxyribqq3ukpq2zhvcesdzqqqzqfv2gcwtkzjuj6cbs25finxerdi726ac73hjlodjgwq7wdeqjohirl54rzmgerhqnlr6mummauqad6ehubeuw6sdj2e4sbi4v4obgp7xv7ci3yeqroowi4ygh674lwtlpweaat7xbtvhzltexcknt6bu3abgmmpabcvlrdszpp33w44pw6wogrhl5p5dozx3of3keefu4bxhypg3euh4mzawrxdnjflee3um6y5kmfw5iexaoscy5lu6jupc5sisvvgtxibnjzxuwaifui22b3ng3coacszqxyy7boll4k4qdaehombacj36mbqk3kjmhxpx5etgqr5krbac2z3xufosnwtfln2pauhe2i3kujty2a3r3yn7umiqvomkeg6zyvla4kltclot55c6vpmp4mlisvzngayds67f45hnz7hpgtygjcz2ozzhqk2tq";
|
||||
const REF_MESSAGE_ID: &str = "fce7395010e16160a3a7abfd341b36574dfdd1e683cb7aab5e0519602b592151";
|
||||
const REF_ENVELOPE_NOPAD_B32: &str = "kngu6tabzoieneun2k7n5b65yxfeen4pxvxzq7sclm2kfaa7os3wrwrz4rtbhpsp5lvpebgh7uzvr7e4abzbraoroqtyckbcp3dhj437p7uxw3lqwcki4wgjyisjpfr77awo2sz7p3uuke337wdlyubbzvdixnvauhf2q2nihc4cerlf62uyhdnvwnrajxxqo56hht5qwijh3uxbrons62te3xwwpm4yppbiab63nw57rj3yjarzwq2tfz57cphnxnacdbm5xdl66mdygni5js7pcftrmhitpcqjibjdmanyjtl75xtsjkm6dxzcwldqaxngqnfgg37yatihkho2c5e2w27hqbhphfhpeusvhu";
|
||||
|
||||
fn identity(n: u32) -> Identity {
|
||||
Identity::from_seed(identity_seed(&master(), n))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn message_id_matches_the_reference_client() {
|
||||
let envelope = unb32(REF_ENVELOPE_B32).unwrap();
|
||||
assert_eq!(envelope.len(), 1109);
|
||||
assert_eq!(
|
||||
data_encoding::HEXLOWER.encode(&message_id(&envelope)),
|
||||
REF_MESSAGE_ID
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unseals_an_envelope_from_the_reference_client() {
|
||||
let envelope = unb32(REF_ENVELOPE_B32).unwrap();
|
||||
let opened = unseal(
|
||||
&[identity(0), identity(1), identity(2)],
|
||||
&envelope,
|
||||
1700000100,
|
||||
)
|
||||
.expect("reference envelope must open");
|
||||
assert_eq!(
|
||||
b32(&opened.sender),
|
||||
"qn6h4zx62pnqxsteog6kcykfef33k2t5yrai7ei3eq33hres6wga"
|
||||
);
|
||||
assert_eq!(opened.time, 1700000000);
|
||||
assert_eq!(opened.body, b"hello from the reference client\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seal_matches_the_reference_client_byte_for_byte() {
|
||||
let sender = identity(3);
|
||||
let recipient = identity(1).pk();
|
||||
let envelope = seal_with_esk(
|
||||
&sender,
|
||||
&recipient,
|
||||
b"hello from the reference client\n",
|
||||
1700000000,
|
||||
true,
|
||||
[7u8; 32],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(envelope, unb32(REF_ENVELOPE_B32).unwrap());
|
||||
|
||||
// And unpadded, which is the other sender choice sec 5.3 allows.
|
||||
let plain = seal_with_esk(
|
||||
&sender,
|
||||
&recipient,
|
||||
b"hello from the reference client\n",
|
||||
1700000000,
|
||||
false,
|
||||
[7u8; 32],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(plain.len(), 226);
|
||||
assert_eq!(plain, unb32(REF_ENVELOPE_NOPAD_B32).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seal_open_round_trip_and_rejections() {
|
||||
let sender = identity(2);
|
||||
let recipient = identity(0);
|
||||
let envelope = seal(&sender, &recipient.pk(), b"body text", 1700000000, false).unwrap();
|
||||
let opened = unseal(std::slice::from_ref(&recipient), &envelope, 1700000100).unwrap();
|
||||
assert_eq!(opened.sender, sender.pk());
|
||||
assert_eq!(opened.body, b"body text");
|
||||
|
||||
// Not addressed to us.
|
||||
assert!(unseal(&[identity(1)], &envelope, 1700000100).is_err());
|
||||
// Tampered ciphertext fails the tag.
|
||||
let mut broken = envelope.clone();
|
||||
broken[80] ^= 1;
|
||||
assert!(unseal(std::slice::from_ref(&recipient), &broken, 1700000100).is_err());
|
||||
// Superseded keys still open their mail (sec 7).
|
||||
assert!(unseal(&[identity(1), recipient.clone()], &envelope, 1700000100).is_ok());
|
||||
// A payload dated far in the future is rejected (sec 5.3).
|
||||
let future = seal(
|
||||
&sender,
|
||||
&recipient.pk(),
|
||||
b"x",
|
||||
1700000000 + MAX_SKEW + 1,
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(unseal(std::slice::from_ref(&recipient), &future, 1700000000).is_err());
|
||||
// Not an envelope at all.
|
||||
assert!(unseal(&[recipient], b"SMOL", 0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn padding_is_ignored_and_length_blurred() {
|
||||
let sender = identity(2);
|
||||
let recipient = identity(0);
|
||||
let padded = seal(&sender, &recipient.pk(), b"smol", 1700000000, true).unwrap();
|
||||
let opened = unseal(&[recipient], &padded, 1700000000).unwrap();
|
||||
assert_eq!(opened.body, b"smol");
|
||||
// 45-byte payload header + 4-byte body + 64-byte signature = 113
|
||||
// padded to the next 1024 multiple, plus the 69-byte envelope header
|
||||
// and the 16-byte tag.
|
||||
assert_eq!(padded.len(), 69 + 1024 + 16);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frontmatter_rules() {
|
||||
let (fields, text) = parse_frontmatter(
|
||||
"---\nSubject: hi there\nX-Mood: ok\nSubJect: second loses\n---\nBody here.\n",
|
||||
);
|
||||
let mut expected = BTreeMap::new();
|
||||
expected.insert("subject".to_string(), "hi there".to_string());
|
||||
expected.insert("x-mood".to_string(), "ok".to_string());
|
||||
assert_eq!(fields, expected);
|
||||
assert_eq!(text, "Body here.\n");
|
||||
|
||||
// A malformed line invalidates the whole block: display, not discard.
|
||||
let (fields, text) = parse_frontmatter("---\nSubject: good\nnot a valid line\n---\ntext\n");
|
||||
assert!(fields.is_empty());
|
||||
assert!(text.starts_with("---\n"));
|
||||
|
||||
// No closing fence, oversized key: no block.
|
||||
assert!(parse_frontmatter("---\nSubject: hi\n").0.is_empty());
|
||||
assert!(
|
||||
parse_frontmatter(&format!("---\n{}: hi\n---\nt\n", "x".repeat(65)))
|
||||
.0
|
||||
.is_empty()
|
||||
);
|
||||
assert!(parse_frontmatter("plain text").1 == "plain text");
|
||||
|
||||
// Values are trimmed; a value may itself contain a colon.
|
||||
let (fields, _) = parse_frontmatter("---\nX-Url: http://a:b/c\n---\n");
|
||||
assert_eq!(fields.get("x-url").unwrap(), "http://a:b/c");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frontmatter_building_escapes_leading_dashes() {
|
||||
let built = build_frontmatter(&[("Subject", "hi"), ("X-Mood", "ok")], "plain body\n");
|
||||
assert_eq!(built, "---\nSubject: hi\nX-Mood: ok\n---\nplain body\n");
|
||||
// A body genuinely beginning with --- is escaped by an empty block.
|
||||
let escaped = build_frontmatter(&[], "---\nstarts with dashes\n");
|
||||
assert_eq!(escaped, "---\n---\n---\nstarts with dashes\n");
|
||||
// Nothing to say means no block at all.
|
||||
assert_eq!(build_frontmatter(&[], "body\n"), "body\n");
|
||||
// Round trip through the parser.
|
||||
let (fields, text) = parse_frontmatter(&built);
|
||||
assert_eq!(fields.get("subject").unwrap(), "hi");
|
||||
assert_eq!(text, "plain body\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accept_field_decodes_base32_or_is_absent() {
|
||||
let mut fields = BTreeMap::new();
|
||||
assert!(accept_field(&fields).is_none());
|
||||
fields.insert("accept".to_string(), "nbswy3dp".to_string());
|
||||
assert!(accept_field(&fields).is_none()); // 5 bytes, not 32
|
||||
fields.insert("accept".to_string(), b32(&[1u8; 32]));
|
||||
assert_eq!(accept_field(&fields), Some([1u8; 32]));
|
||||
}
|
||||
}
|
||||
653
src/store.rs
Normal file
653
src/store.rs
Normal file
|
|
@ -0,0 +1,653 @@
|
|||
//! Local state: one SQLite file, shared by both carriers (RNS.md sec 15).
|
||||
//! Envelopes are stored sealed and opened on demand; no plaintext at rest.
|
||||
|
||||
use std::path::Path;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use rusqlite::{params, Connection};
|
||||
|
||||
use crate::account::Account;
|
||||
use crate::address::Address;
|
||||
use crate::crypto::ct_eq;
|
||||
use crate::error::SmolError;
|
||||
use crate::transport::{ID_LEN, KEY_LEN, TIER_MAIN, TIER_REQUESTS};
|
||||
|
||||
pub fn now() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("clock before 1970")
|
||||
.as_secs() as i64
|
||||
}
|
||||
|
||||
pub const SCHEMA: &str = "
|
||||
-- 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, scheme TEXT,
|
||||
rotations INTEGER NOT NULL DEFAULT 0, -- sec 2 rotation index
|
||||
after_time INTEGER NOT NULL DEFAULT 0, -- sec 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 self-certifying URI
|
||||
seen_at INTEGER NOT NULL);
|
||||
-- Correspondents admitted to this mailbox's main tier (sec 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 (sec 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 (sec 10).
|
||||
CREATE TABLE IF NOT EXISTS seen (id BLOB PRIMARY KEY, at INTEGER NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS inbox (
|
||||
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);
|
||||
";
|
||||
|
||||
/// One sealed message in a folder, still encrypted.
|
||||
pub struct Stored {
|
||||
pub id: [u8; ID_LEN],
|
||||
pub envelope: Vec<u8>,
|
||||
pub at: i64,
|
||||
/// Only sent copies carry a recipient.
|
||||
pub recipient: Option<String>,
|
||||
}
|
||||
|
||||
pub struct Store {
|
||||
db: Connection,
|
||||
}
|
||||
|
||||
/// A contact row: address, identity, verified, and its acceptance state
|
||||
/// (None when never accepted) for `fumi contacts`.
|
||||
pub type ContactRow = (String, [u8; KEY_LEN], bool, Option<i64>);
|
||||
|
||||
impl Store {
|
||||
pub fn open(path: &Path) -> Result<Store, SmolError> {
|
||||
let db = Connection::open(path)?;
|
||||
db.execute_batch(SCHEMA)?;
|
||||
Ok(Store { db })
|
||||
}
|
||||
|
||||
fn one<T>(&self, sql: &str, params: &[&dyn rusqlite::ToSql]) -> Result<Option<T>, SmolError>
|
||||
where
|
||||
T: rusqlite::types::FromSql,
|
||||
{
|
||||
Ok(
|
||||
match self.db.query_row(sql, params, |row| row.get::<_, T>(0)) {
|
||||
Ok(value) => Some(value),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => None,
|
||||
Err(e) => return Err(e.into()),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// The home address, when this identity has been registered or restored.
|
||||
pub fn account(&self) -> Result<Option<Address>, SmolError> {
|
||||
let row = self.db.query_row(
|
||||
"SELECT username, host, port, scheme FROM state WHERE id = 1",
|
||||
[],
|
||||
|row| {
|
||||
Ok((
|
||||
row.get::<_, Option<String>>(0)?,
|
||||
row.get::<_, Option<String>>(1)?,
|
||||
row.get::<_, Option<i64>>(2)?,
|
||||
row.get::<_, Option<String>>(3)?,
|
||||
))
|
||||
},
|
||||
);
|
||||
let (username, host, port, scheme) = match row {
|
||||
Ok(tuple) => tuple,
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None),
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
let (Some(username), Some(host)) = (username, host) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let scheme = scheme.unwrap_or_else(|| "tcp".to_string());
|
||||
let port = port.unwrap_or(crate::address::DEFAULT_PORT as i64) as u16;
|
||||
let text = if scheme == "rns" {
|
||||
format!("smol+rns://{username}@{host}")
|
||||
} else {
|
||||
format!("{username}@{host}")
|
||||
};
|
||||
let mut addr = Address::parse(&text)
|
||||
.map_err(|e| SmolError(format!("stored account is not parseable: {e}")))?;
|
||||
if addr.scheme == crate::address::Scheme::Tcp {
|
||||
addr.port = port;
|
||||
}
|
||||
Ok(Some(addr))
|
||||
}
|
||||
|
||||
pub fn set_account(&self, addr: &Address) -> Result<(), SmolError> {
|
||||
let scheme = match addr.scheme {
|
||||
crate::address::Scheme::Tcp => "tcp",
|
||||
crate::address::Scheme::Rns => "rns",
|
||||
};
|
||||
self.db
|
||||
.execute(
|
||||
"UPDATE state SET username = ?1, host = ?2, port = ?3, scheme = ?4 WHERE id = 1",
|
||||
params![addr.user, addr.host, addr.port, scheme],
|
||||
)
|
||||
.map(|_| ())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn rotations(&self) -> Result<u32, SmolError> {
|
||||
Ok(self
|
||||
.one::<i64>("SELECT rotations FROM state WHERE id = 1", &[])?
|
||||
.unwrap_or(0) as u32)
|
||||
}
|
||||
|
||||
pub fn set_rotations(&self, index: u32) -> Result<(), SmolError> {
|
||||
self.db
|
||||
.execute(
|
||||
"UPDATE state SET rotations = ?1 WHERE id = 1",
|
||||
params![index],
|
||||
)
|
||||
.map(|_| ())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn cursor(&self) -> Result<(i64, [u8; ID_LEN]), SmolError> {
|
||||
let (after_time, after_id): (i64, Vec<u8>) = self
|
||||
.db
|
||||
.query_row(
|
||||
"SELECT after_time, after_id FROM state WHERE id = 1",
|
||||
[],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)
|
||||
.map_err(SmolError::from)?;
|
||||
let after_id: [u8; ID_LEN] = if after_id.len() == ID_LEN {
|
||||
after_id.try_into().unwrap()
|
||||
} else {
|
||||
[0u8; ID_LEN]
|
||||
};
|
||||
Ok((after_time, after_id))
|
||||
}
|
||||
|
||||
pub fn set_cursor(&self, after_time: i64, after_id: &[u8; ID_LEN]) -> Result<(), SmolError> {
|
||||
self.db
|
||||
.execute(
|
||||
"UPDATE state SET after_time = ?1, after_id = ?2 WHERE id = 1",
|
||||
params![after_time, after_id],
|
||||
)
|
||||
.map(|_| ())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether the local accept-token set may replace the server's: a client
|
||||
/// restored from the master alone must not erase it (sec 4).
|
||||
pub fn sync_ok(&self) -> Result<bool, SmolError> {
|
||||
Ok(self
|
||||
.one::<i64>("SELECT sync_ok FROM state WHERE id = 1", &[])?
|
||||
.unwrap_or(1)
|
||||
!= 0)
|
||||
}
|
||||
|
||||
pub fn set_sync_ok(&self, ok: bool) -> Result<(), SmolError> {
|
||||
self.db
|
||||
.execute(
|
||||
"UPDATE state SET sync_ok = ?1 WHERE id = 1",
|
||||
params![ok as i64],
|
||||
)
|
||||
.map(|_| ())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn server_pin(&self, host: &str) -> Result<Option<[u8; KEY_LEN]>, SmolError> {
|
||||
Ok(self
|
||||
.one::<Option<Vec<u8>>>("SELECT static FROM servers WHERE host = ?1", &[&host])?
|
||||
.flatten()
|
||||
.and_then(|k| k.try_into().ok()))
|
||||
}
|
||||
|
||||
pub fn pin_server(&self, host: &str, key: &[u8; KEY_LEN]) -> Result<(), SmolError> {
|
||||
self.db
|
||||
.execute(
|
||||
"INSERT INTO servers (host, static, pinned_at) VALUES (?1, ?2, ?3) \
|
||||
ON CONFLICT (host) DO UPDATE SET static = ?2, pinned_at = ?3",
|
||||
params![host, key, now()],
|
||||
)
|
||||
.map(|_| ())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn contact(&self, address: &str) -> Result<Option<([u8; KEY_LEN], bool)>, SmolError> {
|
||||
let row = self.db.query_row(
|
||||
"SELECT identity, verified FROM contacts WHERE address = ?1",
|
||||
[&address],
|
||||
|row| Ok((row.get::<_, Vec<u8>>(0)?, row.get::<_, i64>(1)?)),
|
||||
);
|
||||
match row {
|
||||
Ok((identity, verified)) => Ok(Some((
|
||||
identity
|
||||
.try_into()
|
||||
.map_err(|_| SmolError::new("stored contact identity is not 32 bytes"))?,
|
||||
verified != 0,
|
||||
))),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save_contact(
|
||||
&self,
|
||||
address: &str,
|
||||
identity: &[u8; KEY_LEN],
|
||||
verified: bool,
|
||||
) -> Result<(), SmolError> {
|
||||
self.db
|
||||
.execute(
|
||||
"INSERT INTO contacts (address, identity, verified, seen_at) VALUES (?1, ?2, ?3, ?4) \
|
||||
ON CONFLICT (address) DO UPDATE SET identity = ?2, verified = ?3, seen_at = ?4",
|
||||
params![address, identity, verified as i64, now()],
|
||||
)
|
||||
.map(|_| ())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Every contact with its acceptance state, for `fumi contacts`.
|
||||
pub fn contact_rows(&self) -> Result<Vec<ContactRow>, SmolError> {
|
||||
let mut stmt = self.db.prepare(
|
||||
"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",
|
||||
)?;
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, Vec<u8>>(1)?,
|
||||
row.get::<_, i64>(2)?,
|
||||
row.get::<_, Option<i64>>(3)?,
|
||||
))
|
||||
})?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.filter_map(|(address, identity, verified, active)| {
|
||||
Some((address, identity.try_into().ok()?, verified != 0, active))
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// The accept tokens to push with AUTH, and whether to push at all (sec 4).
|
||||
pub fn token_set(&self, account: &Account) -> Result<(u8, Vec<[u8; 32]>), SmolError> {
|
||||
if !self.sync_ok()? {
|
||||
return Ok((0, Vec::new()));
|
||||
}
|
||||
let mut stmt = self
|
||||
.db
|
||||
.prepare("SELECT identity FROM accepted WHERE active = 1 ORDER BY added_at")?;
|
||||
let tokens = stmt
|
||||
.query_map([], |row| row.get::<_, Vec<u8>>(0))?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok((
|
||||
1,
|
||||
tokens
|
||||
.iter()
|
||||
.filter_map(|identity| {
|
||||
let identity: [u8; KEY_LEN] = identity.as_slice().try_into().ok()?;
|
||||
Some(account.token_for(&identity))
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
/// 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 (sec 5.7, sec 8).
|
||||
pub fn address_of(
|
||||
&self,
|
||||
sender: &[u8],
|
||||
reply_to: Option<&str>,
|
||||
) -> Result<Option<String>, SmolError> {
|
||||
let mut stmt = self.db.prepare("SELECT address, identity FROM contacts")?;
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
Ok((row.get::<_, String>(0)?, row.get::<_, Vec<u8>>(1)?))
|
||||
})?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
for (address, identity) in rows {
|
||||
if ct_eq(&identity, sender) {
|
||||
return Ok(Some(address));
|
||||
}
|
||||
}
|
||||
if let Some(uri) = reply_to {
|
||||
if let Ok(claimed) = Address::parse(uri) {
|
||||
if claimed.identity.is_some_and(|key| ct_eq(&key, sender)) {
|
||||
return Ok(Some(claimed.short()));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Admits a correspondent to the main tier, freezing the identity the
|
||||
/// token is derived from (sec 5.8): on conflict the identity is left
|
||||
/// alone, so the token stays the one they already hold.
|
||||
pub fn accept(&self, address: &str, identity: &[u8; KEY_LEN]) -> Result<(), SmolError> {
|
||||
self.db
|
||||
.execute(
|
||||
"INSERT INTO accepted (address, identity, active, added_at) VALUES (?1, ?2, 1, ?3) \
|
||||
ON CONFLICT (address) DO UPDATE SET active = 1",
|
||||
params![address, identity, now()],
|
||||
)
|
||||
.map(|_| ())?;
|
||||
self.set_sync_ok(true)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn block(&self, address: &str) -> Result<bool, SmolError> {
|
||||
let changed = self.db.execute(
|
||||
"UPDATE accepted SET active = 0 WHERE address = ?1",
|
||||
params![address],
|
||||
)?;
|
||||
Ok(changed > 0)
|
||||
}
|
||||
|
||||
/// The identity frozen at acceptance, if this address is currently
|
||||
/// accepted; the token for our own mailbox travels in the next message.
|
||||
pub fn accepted_identity(&self, address: &str) -> Result<Option<[u8; KEY_LEN]>, SmolError> {
|
||||
let identity: Option<Vec<u8>> = self.one(
|
||||
"SELECT identity FROM accepted WHERE address = ?1 AND active = 1",
|
||||
&[&address],
|
||||
)?;
|
||||
Ok(identity.and_then(|identity| identity.try_into().ok()))
|
||||
}
|
||||
|
||||
/// A token a correspondent issued us, filed under their address (sec 5.8).
|
||||
pub fn token_of(&self, address: &str) -> Result<Option<[u8; 32]>, SmolError> {
|
||||
let token: Option<Vec<u8>> =
|
||||
self.one("SELECT token FROM tokens WHERE address = ?1", &[&address])?;
|
||||
Ok(token.and_then(|token| token.try_into().ok()))
|
||||
}
|
||||
|
||||
pub fn save_token(&self, address: &str, token: &[u8; 32]) -> Result<(), SmolError> {
|
||||
self.db
|
||||
.execute(
|
||||
"INSERT INTO tokens (address, token, seen_at) VALUES (?1, ?2, ?3) \
|
||||
ON CONFLICT (address) DO UPDATE SET token = ?2, seen_at = ?3",
|
||||
params![address, token, now()],
|
||||
)
|
||||
.map(|_| ())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn seen(&self, id: &[u8; ID_LEN]) -> Result<bool, SmolError> {
|
||||
Ok(self
|
||||
.one::<i64>("SELECT 1 FROM seen WHERE id = ?1", &[id])?
|
||||
.is_some())
|
||||
}
|
||||
|
||||
pub fn store_inbox(
|
||||
&self,
|
||||
id: &[u8; ID_LEN],
|
||||
envelope: &[u8],
|
||||
received_at: i64,
|
||||
requests_tier: bool,
|
||||
) -> Result<(), SmolError> {
|
||||
let tier = if requests_tier {
|
||||
TIER_REQUESTS
|
||||
} else {
|
||||
TIER_MAIN
|
||||
};
|
||||
self.db
|
||||
.execute(
|
||||
"INSERT OR IGNORE INTO inbox (id, envelope, received_at, tier) VALUES (?1, ?2, ?3, ?4)",
|
||||
params![id, envelope, received_at, tier],
|
||||
)
|
||||
.map(|_| ())?;
|
||||
self.db
|
||||
.execute(
|
||||
"INSERT OR IGNORE INTO seen (id, at) VALUES (?1, ?2)",
|
||||
params![id, received_at],
|
||||
)
|
||||
.map(|_| ())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn store_sent(
|
||||
&self,
|
||||
id: &[u8; ID_LEN],
|
||||
recipient: &str,
|
||||
envelope: &[u8],
|
||||
sent_at: i64,
|
||||
) -> Result<(), SmolError> {
|
||||
self.db
|
||||
.execute(
|
||||
"INSERT OR IGNORE INTO sent (id, recipient, envelope, sent_at) VALUES (?1, ?2, ?3, ?4)",
|
||||
params![id, recipient, envelope, sent_at],
|
||||
)
|
||||
.map(|_| ())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// One folder, ordered by arrival or sending time. `folder` is "inbox",
|
||||
/// "requests", "sent" or "all".
|
||||
pub fn mail(&self, folder: &str) -> Result<Vec<Stored>, SmolError> {
|
||||
let (sql, tier): (&str, Option<i8>) = match folder {
|
||||
"sent" => (
|
||||
"SELECT id, envelope, sent_at, recipient FROM sent ORDER BY sent_at, id",
|
||||
None,
|
||||
),
|
||||
"all" => (
|
||||
"SELECT id, envelope, received_at, NULL FROM inbox ORDER BY received_at, id",
|
||||
None,
|
||||
),
|
||||
"requests" => (
|
||||
"SELECT id, envelope, received_at, NULL FROM inbox \
|
||||
WHERE tier = ?1 ORDER BY received_at, id",
|
||||
Some(TIER_REQUESTS as i8),
|
||||
),
|
||||
_ => (
|
||||
"SELECT id, envelope, received_at, NULL FROM inbox \
|
||||
WHERE tier = ?1 ORDER BY received_at, id",
|
||||
Some(TIER_MAIN as i8),
|
||||
),
|
||||
};
|
||||
let mut stmt = self.db.prepare(sql)?;
|
||||
let rows = match tier {
|
||||
Some(t) => stmt
|
||||
.query_map(params![t], row_stored)?
|
||||
.collect::<Result<Vec<_>, _>>()?,
|
||||
None => stmt
|
||||
.query_map([], row_stored)?
|
||||
.collect::<Result<Vec<_>, _>>()?,
|
||||
};
|
||||
Ok(rows)
|
||||
}
|
||||
}
|
||||
|
||||
fn row_stored(row: &rusqlite::Row<'_>) -> rusqlite::Result<Stored> {
|
||||
Ok(Stored {
|
||||
id: row.get::<_, Vec<u8>>(0)?.try_into().unwrap(),
|
||||
envelope: row.get(1)?,
|
||||
at: row.get(2)?,
|
||||
recipient: row.get(3)?,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::account::{identity_seed, Account};
|
||||
|
||||
fn master() -> [u8; 32] {
|
||||
core::array::from_fn(|i| i as u8)
|
||||
}
|
||||
|
||||
fn temp_store(tag: &str) -> Store {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"fumi-store-{tag}-{}-{}.db",
|
||||
std::process::id(),
|
||||
now()
|
||||
));
|
||||
Store::open(&path).expect("open store")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_state_round_trip() {
|
||||
let store = temp_store("account");
|
||||
assert!(store.account().unwrap().is_none());
|
||||
let addr = Address::parse("alice@example.org:1962").unwrap();
|
||||
store.set_account(&addr).unwrap();
|
||||
let back = store.account().unwrap().unwrap();
|
||||
assert_eq!(back.short(), "alice@example.org:1962");
|
||||
assert_eq!(back.port, 1962);
|
||||
|
||||
let rns = Address::parse("smol+rns://alice@8f2c1d0a7b6e5f4c3d2a1b0e9f8c7d6a").unwrap();
|
||||
store.set_account(&rns).unwrap();
|
||||
let back = store.account().unwrap().unwrap();
|
||||
assert_eq!(back.scheme, crate::address::Scheme::Rns);
|
||||
assert_eq!(back.short(), "alice@8f2c1d0a7b6e5f4c3d2a1b0e9f8c7d6a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotation_index_and_cursor_persist() {
|
||||
let store = temp_store("state");
|
||||
assert_eq!(store.rotations().unwrap(), 0);
|
||||
store.set_rotations(3).unwrap();
|
||||
assert_eq!(store.rotations().unwrap(), 3);
|
||||
assert_eq!(store.cursor().unwrap(), (0, [0u8; 32]));
|
||||
store.set_cursor(17, &[9u8; 32]).unwrap();
|
||||
assert_eq!(store.cursor().unwrap(), (17, [9u8; 32]));
|
||||
// A short cursor blob (a fresh store's x'') reads as the start.
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_ok_gates_the_token_set() {
|
||||
let store = temp_store("sync");
|
||||
let account = Account::new(master(), 0).unwrap();
|
||||
let key = Account::new(master(), 1).unwrap().me().pk();
|
||||
store.accept("bob@example.org", &key).unwrap();
|
||||
// Accepting turns sync on and pushes exactly one derived token.
|
||||
assert!(store.sync_ok().unwrap());
|
||||
let (sync, tokens) = store.token_set(&account).unwrap();
|
||||
assert_eq!(sync, 1);
|
||||
assert_eq!(tokens, vec![account.token_for(&key)]);
|
||||
|
||||
// A restored client must not erase the server's set (sec 4).
|
||||
store.set_sync_ok(false).unwrap();
|
||||
let (sync, tokens) = store.token_set(&account).unwrap();
|
||||
assert_eq!(sync, 0);
|
||||
assert!(tokens.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accept_freezes_identity_across_conflicts() {
|
||||
let store = temp_store("accept");
|
||||
let old = Account::new(master(), 1).unwrap().me().pk();
|
||||
let new = Account::new(master(), 2).unwrap().me().pk();
|
||||
store.accept("bob@example.org", &old).unwrap();
|
||||
store.accept("bob@example.org", &new).unwrap();
|
||||
assert_eq!(
|
||||
store.accepted_identity("bob@example.org").unwrap(),
|
||||
Some(old)
|
||||
);
|
||||
assert!(store.block("bob@example.org").unwrap());
|
||||
assert_eq!(store.accepted_identity("bob@example.org").unwrap(), None);
|
||||
// Blocking again is a harmless no-op; only a never-accepted address
|
||||
// is an error, which the caller reports.
|
||||
assert!(store.block("bob@example.org").unwrap());
|
||||
assert!(!store.block("nobody@example.org").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn address_of_matches_contacts_then_reply_to() {
|
||||
let store = temp_store("address-of");
|
||||
let bob = Account::new(master(), 1).unwrap().me().pk();
|
||||
store.save_contact("bob@example.org", &bob, true).unwrap();
|
||||
assert_eq!(
|
||||
store.address_of(&bob, None).unwrap().as_deref(),
|
||||
Some("bob@example.org")
|
||||
);
|
||||
// A Reply-To only names a signer whose key it carries (sec 5.7).
|
||||
let carol = Account::new(master(), 2).unwrap().me().pk();
|
||||
let uri = format!("smol://carol@example.org/{}", crate::crypto::b32(&carol));
|
||||
assert_eq!(
|
||||
store.address_of(&carol, Some(&uri)).unwrap().as_deref(),
|
||||
Some("carol@example.org")
|
||||
);
|
||||
// Key mismatch: ordinary text, no address learned.
|
||||
assert_eq!(
|
||||
store
|
||||
.address_of(
|
||||
&carol,
|
||||
Some(&format!(
|
||||
"smol://mallory@example.org/{}",
|
||||
crate::crypto::b32(&bob)
|
||||
))
|
||||
)
|
||||
.unwrap(),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inbox_and_sent_store_sealed_and_deduplicate() {
|
||||
let store = temp_store("mail");
|
||||
let id = [3u8; 32];
|
||||
store.store_inbox(&id, b"envelope", 100, false).unwrap();
|
||||
// A replayed id is not re-stored (sec 10).
|
||||
store.store_inbox(&id, b"envelope2", 100, true).unwrap();
|
||||
assert!(store.seen(&id).unwrap());
|
||||
let inbox = store.mail("inbox").unwrap();
|
||||
assert_eq!(inbox.len(), 1);
|
||||
assert_eq!(inbox[0].envelope, b"envelope");
|
||||
assert_eq!(inbox[0].at, 100);
|
||||
|
||||
store
|
||||
.store_sent(&id, "bob@example.org", b"sent-copy", 200)
|
||||
.unwrap();
|
||||
let sent = store.mail("sent").unwrap();
|
||||
assert_eq!(sent.len(), 1);
|
||||
assert_eq!(sent[0].recipient.as_deref(), Some("bob@example.org"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pins_and_contacts_round_trip() {
|
||||
let store = temp_store("pins");
|
||||
assert!(store.server_pin("example.org").unwrap().is_none());
|
||||
store.pin_server("example.org", &[5u8; 32]).unwrap();
|
||||
assert_eq!(store.server_pin("example.org").unwrap(), Some([5u8; 32]));
|
||||
store.pin_server("example.org", &[6u8; 32]).unwrap();
|
||||
assert_eq!(store.server_pin("example.org").unwrap(), Some([6u8; 32]));
|
||||
|
||||
let key = Account::new(master(), 1).unwrap().me().pk();
|
||||
assert!(store.contact("bob@example.org").unwrap().is_none());
|
||||
store.save_contact("bob@example.org", &key, false).unwrap();
|
||||
assert_eq!(
|
||||
store.contact("bob@example.org").unwrap(),
|
||||
Some((key, false))
|
||||
);
|
||||
store.save_contact("bob@example.org", &key, true).unwrap();
|
||||
assert_eq!(store.contact("bob@example.org").unwrap(), Some((key, true)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_seed_is_derived_not_stored() {
|
||||
// A client must be able to re-derive every superseded key (sec 7).
|
||||
let account = Account::new(master(), 4).unwrap();
|
||||
assert_eq!(account.keys().len(), 5);
|
||||
for (n, key) in account.keys().iter().enumerate() {
|
||||
assert_eq!(
|
||||
key.pk(),
|
||||
Account::new(master(), n as u32).unwrap().me().pk()
|
||||
);
|
||||
let _ = identity_seed(&master(), n as u32);
|
||||
}
|
||||
}
|
||||
}
|
||||
177
src/tcp.rs
Normal file
177
src/tcp.rs
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
//! The TCP carrier: a Noise_NX initiator session with server-key pinning
|
||||
//! layered on top (SPEC.md sec 4), and the framed transport over it.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpStream;
|
||||
use std::time::Duration;
|
||||
|
||||
use snow::{Builder, TransportState};
|
||||
|
||||
use crate::crypto::{b32, ct_eq, KEY_LEN};
|
||||
use crate::error::SmolError;
|
||||
use crate::transport::{
|
||||
Response, Transport, TransportBindValues, MAX_FRAME, NOISE_PARAMS, NOISE_PAYLOAD, PROLOGUE,
|
||||
};
|
||||
|
||||
/// A pinned-key mismatch is a hard abort: the discrepancy is surfaced, never
|
||||
/// clicked through, because the pin is the entire trust model.
|
||||
pub struct TcpTransport {
|
||||
stream: TcpStream,
|
||||
noise: TransportState,
|
||||
buf: Vec<u8>,
|
||||
bind: TransportBindValues,
|
||||
pinned: bool,
|
||||
host: String,
|
||||
}
|
||||
|
||||
impl TcpTransport {
|
||||
/// Runs the Noise_NX handshake as the initiator. The NX pattern has the
|
||||
/// server transmit its static key during the handshake, so pinning is a
|
||||
/// single code path: an existing pin must match, an absent one is
|
||||
/// accepted but reported as unpinned by `pinned()`.
|
||||
pub fn connect(
|
||||
host: &str,
|
||||
port: u16,
|
||||
pinned: Option<[u8; KEY_LEN]>,
|
||||
timeout: u64,
|
||||
) -> anyhow::Result<TcpTransport> {
|
||||
let addr = (host, port);
|
||||
let stream = TcpStream::connect(addr)
|
||||
.map_err(|e| anyhow::anyhow!("cannot reach {host}:{port}: {e}"))?;
|
||||
stream.set_read_timeout(Some(Duration::from_secs(timeout)))?;
|
||||
stream.set_write_timeout(Some(Duration::from_secs(timeout)))?;
|
||||
let mut stream = stream;
|
||||
|
||||
let params: snow::params::NoiseParams = NOISE_PARAMS.parse()?;
|
||||
let mut noise = Builder::new(params).prologue(PROLOGUE).build_initiator()?;
|
||||
|
||||
let mut buf = [0u8; 65535];
|
||||
let len = noise.write_message(&[], &mut buf)?;
|
||||
write_u16_len(&mut stream, &buf[..len])?;
|
||||
|
||||
let len = read_u16_len(&mut stream)?;
|
||||
let mut message = vec![0u8; len];
|
||||
stream.read_exact(&mut message)?;
|
||||
noise.read_message(&message, &mut buf)?;
|
||||
if !noise.is_handshake_finished() {
|
||||
anyhow::bail!("handshake did not complete");
|
||||
}
|
||||
|
||||
let server_static: [u8; KEY_LEN] = noise
|
||||
.get_remote_static()
|
||||
.ok_or_else(|| anyhow::anyhow!("server sent no static key"))?
|
||||
.try_into()?;
|
||||
let handshake_hash = noise.get_handshake_hash().to_vec();
|
||||
let bind = TransportBindValues::tcp(&handshake_hash, &server_static)?;
|
||||
let transport = noise.into_transport_mode()?;
|
||||
|
||||
if let Some(pinned) = pinned {
|
||||
if !ct_eq(&pinned, &server_static) {
|
||||
return Err(anyhow::anyhow!(
|
||||
"{host} presented a different key than the one pinned\n pinned: {}\n \
|
||||
presented: {}",
|
||||
b32(&pinned),
|
||||
b32(&server_static)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(TcpTransport {
|
||||
stream,
|
||||
noise: transport,
|
||||
buf: Vec::new(),
|
||||
bind,
|
||||
pinned: pinned.is_some(),
|
||||
host: host.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// The server's Noise static key, what REGISTER binds its proof of
|
||||
/// possession to and what `trust` pins.
|
||||
pub fn server_static(&self) -> &[u8; KEY_LEN] {
|
||||
&self.bind.server_static
|
||||
}
|
||||
|
||||
pub fn host(&self) -> &str {
|
||||
&self.host
|
||||
}
|
||||
|
||||
fn read_noise(&mut self) -> Result<Vec<u8>, SmolError> {
|
||||
let len = read_u16_len(&mut self.stream)?;
|
||||
let mut ciphertext = vec![0u8; len];
|
||||
self.stream.read_exact(&mut ciphertext)?;
|
||||
let mut plaintext = vec![0u8; len];
|
||||
let n = self.noise.read_message(&ciphertext, &mut plaintext)?;
|
||||
plaintext.truncate(n);
|
||||
Ok(plaintext)
|
||||
}
|
||||
|
||||
fn write_noise(&mut self, payload: &[u8]) -> Result<(), SmolError> {
|
||||
let mut packet = vec![0u8; payload.len() + 16];
|
||||
let n = self.noise.write_message(payload, &mut packet)?;
|
||||
packet.truncate(n);
|
||||
write_u16_len(&mut self.stream, &packet)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Transport for TcpTransport {
|
||||
/// Sends one application frame (u32 length || op || body, split across
|
||||
/// as many Noise messages as it needs) and reads the response frame,
|
||||
/// returning its status byte and body (sec 4, sec 6.1).
|
||||
fn request(&mut self, op: u8, body: &[u8]) -> Result<Response, SmolError> {
|
||||
let length = 1 + body.len();
|
||||
if length > MAX_FRAME {
|
||||
return Err(SmolError::new("request exceeds the maximum frame size"));
|
||||
}
|
||||
let mut frame = Vec::with_capacity(4 + length);
|
||||
frame.extend_from_slice(&(length as u32).to_be_bytes());
|
||||
frame.push(op);
|
||||
frame.extend_from_slice(body);
|
||||
for chunk in frame.chunks(NOISE_PAYLOAD) {
|
||||
self.write_noise(chunk)?;
|
||||
}
|
||||
|
||||
while self.buf.len() < 5 {
|
||||
let chunk = self.read_noise()?;
|
||||
self.buf.extend_from_slice(&chunk);
|
||||
}
|
||||
let length = u32::from_be_bytes(self.buf[..4].try_into().unwrap()) as usize;
|
||||
if length == 0 || length > MAX_FRAME {
|
||||
return Err(SmolError::new(format!(
|
||||
"server sent a frame of length {length}"
|
||||
)));
|
||||
}
|
||||
while self.buf.len() < 4 + length {
|
||||
let chunk = self.read_noise()?;
|
||||
self.buf.extend_from_slice(&chunk);
|
||||
}
|
||||
// Frame layout: u32 length || op (echoed) || status || payload.
|
||||
let status = self.buf[5];
|
||||
let body: Vec<u8> = self.buf.drain(..4 + length).skip(6).collect();
|
||||
Ok(Response { status, body })
|
||||
}
|
||||
|
||||
fn bind(&self) -> &TransportBindValues {
|
||||
&self.bind
|
||||
}
|
||||
|
||||
fn pinned(&self) -> bool {
|
||||
self.pinned
|
||||
}
|
||||
|
||||
fn close(&mut self) {
|
||||
let _ = self.stream.shutdown(std::net::Shutdown::Both);
|
||||
}
|
||||
}
|
||||
|
||||
fn read_u16_len(stream: &mut TcpStream) -> std::io::Result<usize> {
|
||||
let mut len_buf = [0u8; 2];
|
||||
stream.read_exact(&mut len_buf)?;
|
||||
Ok(u16::from_be_bytes(len_buf) as usize)
|
||||
}
|
||||
|
||||
fn write_u16_len(stream: &mut TcpStream, packet: &[u8]) -> std::io::Result<()> {
|
||||
stream.write_all(&(packet.len() as u16).to_be_bytes())?;
|
||||
stream.write_all(packet)
|
||||
}
|
||||
210
src/transport.rs
Normal file
210
src/transport.rs
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
//! The transport boundary: operation and status constants, the bind values
|
||||
//! AUTH and REGISTER signatures depend on, and the trait both carriers
|
||||
//! implement. Only the bind values and the framing differ between them; every
|
||||
//! operation body is byte-identical (RNS.md sec 13.5, sec 15).
|
||||
|
||||
// Used only by the RNS bind values and their tests.
|
||||
#[cfg(any(test, feature = "rns"))]
|
||||
use crate::crypto::sha256;
|
||||
use crate::error::SmolError;
|
||||
|
||||
pub const NOISE_PARAMS: &str = "Noise_NX_25519_ChaChaPoly_SHA256";
|
||||
pub const PROLOGUE: &[u8] = b"smolmail/1";
|
||||
|
||||
pub const LABEL_AUTH: &[u8] = b"smolmail/1 auth";
|
||||
pub const LABEL_ID: &[u8] = b"smolmail/1 id";
|
||||
pub const LABEL_SEAL: &[u8] = b"smolmail/1 seal";
|
||||
pub const LABEL_MSG: &[u8] = b"smolmail/1 msg";
|
||||
pub const LABEL_MAC: &[u8] = b"smolmail/1 mac";
|
||||
pub const LABEL_ROTATE: &[u8] = b"smolmail/1 rotate";
|
||||
pub const LABEL_REGISTER: &[u8] = b"smolmail/1 register";
|
||||
pub const LABEL_IDENTITY: &[u8] = b"smolmail/1 identity";
|
||||
pub const LABEL_ACCEPT: &[u8] = b"smolmail/1 accept";
|
||||
/// RNS.md sec 14: added in 1.2 for the link binding of sec 13.6.
|
||||
#[cfg(any(test, feature = "rns"))]
|
||||
pub const LABEL_BIND: &[u8] = b"smolmail/1 bind";
|
||||
|
||||
pub const OP_AUTH: u8 = 0x00;
|
||||
pub const OP_RESOLVE: u8 = 0x01;
|
||||
pub const OP_SEND: u8 = 0x02;
|
||||
pub const OP_FETCH: u8 = 0x03;
|
||||
pub const OP_DELETE: u8 = 0x04;
|
||||
pub const OP_REGISTER: u8 = 0x05;
|
||||
|
||||
pub const ENVELOPE_MAGIC: &[u8; 4] = b"SMOL";
|
||||
pub const ENVELOPE_VERSION: u8 = 1;
|
||||
pub const ENVELOPE_HEADER: usize = 69; // magic 4 + version 1 + to 32 + epk 32
|
||||
pub const ENVELOPE_MIN: usize = ENVELOPE_HEADER + 16; // + Poly1305 tag
|
||||
pub const PAYLOAD_HEADER: usize = 45; // version 1 + sender 32 + time 8 + body_len 4
|
||||
pub const KEY_LEN: usize = 32;
|
||||
pub const ID_LEN: usize = 32;
|
||||
pub const SIG_LEN: usize = 64;
|
||||
pub const CERT_LEN: usize = 200; // old_pub 32 + new_pub 32 + time 8 + sig_old 64 + sig_new 64
|
||||
pub const TOKEN_LEN: usize = 32;
|
||||
pub const MAX_CHAIN: usize = 16;
|
||||
|
||||
pub const MAX_FRAME: usize = 1 << 20; // application frame ceiling (sec 4)
|
||||
pub const NOISE_PAYLOAD: usize = 65535 - 16; // Noise message ceiling minus the tag
|
||||
|
||||
/// Tiers a fetched message can land in (sec 5.8).
|
||||
pub const TIER_MAIN: u8 = 0;
|
||||
pub const TIER_REQUESTS: u8 = 1;
|
||||
/// flags bit 0: the message arrived without a matching accept token.
|
||||
pub const FLAG_REQUESTS: u8 = 0x01;
|
||||
|
||||
/// A parsed response frame: the status byte and everything after it.
|
||||
pub struct Response {
|
||||
pub status: u8,
|
||||
pub body: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Fail-closed reader over a response body. Every parse path errors rather
|
||||
/// than reading past the end, so a truncated response can never be mistaken
|
||||
/// for a short but valid one.
|
||||
pub struct Reader<'a> {
|
||||
buf: &'a [u8],
|
||||
pos: usize,
|
||||
}
|
||||
|
||||
impl<'a> Reader<'a> {
|
||||
pub fn new(buf: &'a [u8]) -> Self {
|
||||
Reader { buf, pos: 0 }
|
||||
}
|
||||
|
||||
pub fn take(&mut self, n: usize) -> Result<&'a [u8], SmolError> {
|
||||
if self.pos + n > self.buf.len() {
|
||||
return Err(SmolError::new("truncated response from server"));
|
||||
}
|
||||
let out = &self.buf[self.pos..self.pos + n];
|
||||
self.pos += n;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn u8(&mut self) -> Result<u8, SmolError> {
|
||||
Ok(self.take(1)?[0])
|
||||
}
|
||||
|
||||
pub fn u16(&mut self) -> Result<u16, SmolError> {
|
||||
let b = self.take(2)?;
|
||||
Ok(u16::from_be_bytes([b[0], b[1]]))
|
||||
}
|
||||
|
||||
pub fn u32(&mut self) -> Result<u32, SmolError> {
|
||||
let b = self.take(4)?;
|
||||
Ok(u32::from_be_bytes(b.try_into().unwrap()))
|
||||
}
|
||||
|
||||
pub fn i64(&mut self) -> Result<i64, SmolError> {
|
||||
let b = self.take(8)?;
|
||||
Ok(i64::from_be_bytes(b.try_into().unwrap()))
|
||||
}
|
||||
|
||||
pub fn rest(&mut self) -> &'a [u8] {
|
||||
let out = &self.buf[self.pos..];
|
||||
self.pos = self.buf.len();
|
||||
out
|
||||
}
|
||||
|
||||
pub fn done(&self) -> Result<(), SmolError> {
|
||||
if self.pos != self.buf.len() {
|
||||
return Err(SmolError::new("trailing bytes in response"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Transport-supplied values that AUTH and REGISTER signatures bind to. Both
|
||||
/// carriers prove the same thing — that the peer holds the identity key and
|
||||
/// is talking to this server, not a replayed capture of another — but the
|
||||
/// inputs differ (RNS.md sec 13.6), so the session layer consumes this
|
||||
/// struct instead of a Noise handshake hash.
|
||||
pub struct TransportBindValues {
|
||||
/// What AUTH signs: the Noise handshake hash, or its RNS substitute.
|
||||
pub h: [u8; 32],
|
||||
/// What REGISTER signs: the server's static key, or its RNS substitute.
|
||||
pub server_static: [u8; 32],
|
||||
}
|
||||
|
||||
impl TransportBindValues {
|
||||
/// Noise binds to the handshake hash and the server's real static key.
|
||||
pub fn tcp(handshake_hash: &[u8], server_static: &[u8; KEY_LEN]) -> anyhow::Result<Self> {
|
||||
Ok(Self {
|
||||
h: handshake_hash
|
||||
.try_into()
|
||||
.map_err(|_| anyhow::anyhow!("handshake hash not 32 bytes"))?,
|
||||
server_static: *server_static,
|
||||
})
|
||||
}
|
||||
|
||||
/// RNS has no static key of ours on the wire, so both values are derived
|
||||
/// from the destination; link_id keeps one link's AUTH from replaying on
|
||||
/// another (RNS.md sec 13.6). Neither input is length-prefixed, and both
|
||||
/// are fixed-width, so concatenation stays unambiguous.
|
||||
#[cfg(any(test, feature = "rns"))]
|
||||
pub fn rns(destination: &[u8; 16], link_id: &[u8; 16]) -> Self {
|
||||
Self {
|
||||
h: sha256(&[LABEL_BIND, &destination[..], &link_id[..]]),
|
||||
server_static: sha256(&[LABEL_BIND, &destination[..]]),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One request/response exchange per carrier. `request` sends one application
|
||||
/// frame and returns the peer's answer; `bind` supplies the values the
|
||||
/// operation layer signs over; `pinned` reports whether this session's
|
||||
/// server identity came from a trusted channel, which decides whether
|
||||
/// RESOLVE results may be marked verified (sec 4, RNS.md sec 13.4).
|
||||
pub trait Transport {
|
||||
fn request(&mut self, op: u8, body: &[u8]) -> Result<Response, SmolError>;
|
||||
fn bind(&self) -> &TransportBindValues;
|
||||
fn pinned(&self) -> bool;
|
||||
fn close(&mut self);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// Vectors computed independently over the RNS.md sec 13.6 formula
|
||||
// SHA-256("smolmail/1 bind" || destination || link_id); shared with
|
||||
// bunshin, whose server verifies what this client produces.
|
||||
const DEST: [u8; 16] = [
|
||||
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e,
|
||||
0x0f,
|
||||
];
|
||||
const LINK: [u8; 16] = [
|
||||
0xa0, 0xa1, 0xa2, 0xa3, 0xa4, 0xa5, 0xa6, 0xa7, 0xa8, 0xa9, 0xaa, 0xab, 0xac, 0xad, 0xae,
|
||||
0xaf,
|
||||
];
|
||||
const H_HEX: &str = "06d6437dc63250ff51d609e12b488ff22b4fa02620f35000b3b2ed1c8789d45c";
|
||||
const SERVER_STATIC_HEX: &str =
|
||||
"da6fe109dc4da2878fa4810ffa0e08cef6b68a8b524126aa05bb14181b20d25a";
|
||||
|
||||
#[test]
|
||||
fn rns_matches_reference_vectors() {
|
||||
let bind = TransportBindValues::rns(&DEST, &LINK);
|
||||
assert_eq!(data_encoding::HEXLOWER.encode(&bind.h), H_HEX);
|
||||
assert_eq!(
|
||||
data_encoding::HEXLOWER.encode(&bind.server_static),
|
||||
SERVER_STATIC_HEX
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rns_h_differs_per_link_but_server_static_does_not() {
|
||||
let other = [0xc0u8; 16];
|
||||
let a = TransportBindValues::rns(&DEST, &LINK);
|
||||
let b = TransportBindValues::rns(&DEST, &other);
|
||||
assert_ne!(a.h, b.h);
|
||||
assert_eq!(a.server_static, b.server_static);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tcp_rejects_short_handshake_hash() {
|
||||
let static_key = [7u8; KEY_LEN];
|
||||
assert!(TransportBindValues::tcp(&[1u8; 31], &static_key).is_err());
|
||||
let bind = TransportBindValues::tcp(&[2u8; 32], &static_key).unwrap();
|
||||
assert_eq!(bind.h, [2u8; 32]);
|
||||
assert_eq!(bind.server_static, static_key);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue