refactor: split the library into fumi-core and make it embeddable

This commit is contained in:
randogoth 2026-09-28 23:21:08 +03:00
parent d3cd0afb4f
commit 8fb5661b53
28 changed files with 975 additions and 724 deletions

214
Cargo.lock generated
View file

@ -49,15 +49,6 @@ dependencies = [
"zerocopy",
]
[[package]]
name = "aho-corasick"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
dependencies = [
"memchr",
]
[[package]]
name = "anstream"
version = "1.0.0"
@ -120,12 +111,6 @@ version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "bitflags"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bitflags"
version = "2.13.2"
@ -315,37 +300,6 @@ version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
[[package]]
name = "defmt"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1"
dependencies = [
"bitflags 1.3.2",
"defmt-macros",
]
[[package]]
name = "defmt-macros"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8"
dependencies = [
"defmt-parser",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "defmt-parser"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e"
dependencies = [
"thiserror",
]
[[package]]
name = "der"
version = "0.7.10"
@ -392,29 +346,6 @@ dependencies = [
"zeroize",
]
[[package]]
name = "env_filter"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217"
dependencies = [
"log",
"regex",
]
[[package]]
name = "env_logger"
version = "0.11.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6"
dependencies = [
"anstream",
"anstyle",
"env_filter",
"jiff",
"log",
]
[[package]]
name = "fallible-iterator"
version = "0.3.0"
@ -444,17 +375,26 @@ name = "fumi"
version = "0.1.0"
dependencies = [
"anyhow",
"cc",
"chacha20poly1305",
"clap",
"data-encoding",
"fumi-core",
"rand_core",
]
[[package]]
name = "fumi-core"
version = "0.1.0"
dependencies = [
"cc",
"chacha20poly1305",
"data-encoding",
"ed25519-dalek",
"env_logger",
"hkdf",
"hmac",
"log",
"rand_core",
"rusqlite",
"serde",
"serde_json",
"sha2",
"snow",
"x25519-dalek",
@ -549,41 +489,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "jiff"
version = "0.2.37"
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ab1baf72f08796de0260609515130699b890ac25f30e610ad894bc5856cafdb"
dependencies = [
"defmt",
"jiff-core",
"jiff-static",
"log",
"portable-atomic",
"portable-atomic-util",
"serde_core",
]
[[package]]
name = "jiff-core"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c"
dependencies = [
"defmt",
"log",
]
[[package]]
name = "jiff-static"
version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "378268a1116ad67ae6228701118ac9f491d78fda38a40a1f1a9e1348de6f7212"
dependencies = [
"jiff-core",
"proc-macro2",
"quote",
"syn 2.0.119",
]
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "libc"
@ -602,12 +511,6 @@ dependencies = [
"vcpkg",
]
[[package]]
name = "log"
version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]]
name = "memchr"
version = "2.8.3"
@ -671,21 +574,6 @@ dependencies = [
"universal-hash",
]
[[package]]
name = "portable-atomic"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
[[package]]
name = "portable-atomic-util"
version = "0.2.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715"
dependencies = [
"portable-atomic",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
@ -713,42 +601,13 @@ dependencies = [
"getrandom",
]
[[package]]
name = "regex"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "rusqlite"
version = "0.32.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e"
dependencies = [
"bitflags 2.13.2",
"bitflags",
"fallible-iterator",
"fallible-streaming-iterator",
"hashlink",
@ -801,6 +660,19 @@ dependencies = [
"syn 3.0.6",
]
[[package]]
name = "serde_json"
version = "1.0.151"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "sha2"
version = "0.10.9"
@ -893,26 +765,6 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "thiserror"
version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "typenum"
version = "1.20.1"
@ -1025,3 +877,9 @@ dependencies = [
"quote",
"syn 2.0.119",
]
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"

View file

@ -1,34 +1,4 @@
[package]
name = "fumi"
version = "0.1.0"
edition = "2021"
description = "Smol Mail client"
license = "Apache-2.0"
[[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 }
[workspace]
members = ["core", "cli"]
default-members = ["cli"]
resolver = "2"

View file

@ -61,26 +61,45 @@ Mail is stored sealed and opened on demand; there is no plaintext at rest. A fir
```
nix build # or: nix develop -c cargo build
nix develop -c cargo test
nix develop -c cargo test --workspace
```
The crate is a workspace: `core/` is the `fumi-core` library — everything but the command line — and `cli/` is the `fumi` binary. GUI hosts depend on `fumi-core` alone; it pulls no argument parsing, no logging globals, and no keyfile conventions. `bundled-sqlite` (default) vendors libsqlite3; embedders that link their own SQLite, as Android's NDK does, build with `default-features = false`.
`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.
## Embedding
`fumi-core` is the protocol core of a GUI client, not just this CLI's engine. The contract an embedder can rely on:
- **Secrets stay bytes in the API, files stay in the CLI.** `Account::new(master, index)` takes the master as bytes and `Store::open(path)` takes only mail and state; `identity.key` next to `fumi.db` is a CLI convention. A GUI keeps the master wherever its platform wants it (Android Keystore, OS keychain) and hands it over per operation.
- **One shareable store handle.** `Store` is `Send + Sync`: every operation locks an inner mutex, and the database runs in WAL mode, so a host holds one `Store` behind an executor and reads it while a fetch writes. Each envelope is committed as it is verified — a concurrent reader listing the inbox sees fetch progress without any callback.
- **Decisions, not prose.** `error::Error` is an enum: `NotPinned`, `PinMismatch`, `KeyChanged { address, known, offered }`, `NotRegistered`, `AuthFailed`, `RateLimited`, `QuotaExceeded`, `SchemaVersion`, ... — the distinctions a UI routes on. `Display` still produces the message the CLI prints.
- **Long operations can stop.** `fetch_with` takes a `FetchOptions` with a cancellation token checked between pages; the cursor is saved per page, so a cancelled fetch resumes rather than repeats.
- **Composable flows.** `restore` remains the one-call convenience, but its pieces are public: `connect` + `resolve`, `account::find_rotation_index`, then the `Store` setters — a wizard can hold the master and the address before it has connectivity, and show each step. Trust outcomes arrive as values: `TrustChange` from `trust_key` and `send`, `Session::unpinned_static` for the sec 4 warning.
- **A versioned store.** The schema carries a `user_version`; a store written by a newer build is refused (`Error::SchemaVersion`) rather than misread, and within a version the schema is stable. The host, not the library, decides when to upgrade the binary.
- **Binding from other languages.** The API is `Send`-friendly and free of CLI globals, so plain Rust bindings (flutter_rust_bridge, uniffi) work against it. `core/vectors.json` holds the reference vectors the unit tests pin — derivations, seals, a full envelope — so a port or FFI binding can verify itself end to end without the reference stack.
The intended call graph: `Store::open` once, `Account::new(master, rotations)` per session, then `client::*` operations taking `&Store` and `&Account`. The store outlives accounts; nothing in the library caches between calls.
## Modules
| 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/rns/` | the Reticulum carrier behind the `rns` feature: FFI over `shim/`, path discovery, one link per session |
| `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 |
| `core/src/crypto.rs` | base32, HKDF, HMAC, SHA-256, the Ed25519→X25519 map with SPEC.md §2's checks |
| `core/src/address.rs` | parsing for all four address forms, username validation, fingerprints |
| `core/src/account.rs` | master, `seed_n` derivation, accept-key and tokens, rotation certificates, `find_rotation_index` |
| `core/src/message.rs` | envelope seal/open (§5.1–§5.3), message id (§5.4), frontmatter (§5.5) |
| `core/src/transport.rs` | `Transport` trait, bind values, operation and status constants |
| `core/src/tcp.rs` | Noise_NX initiator, `len u32 \|\| op u8 \|\| body` framing, static-key pinning |
| `core/src/rns/` | the Reticulum carrier behind the `rns` feature: FFI over `core/shim/`, path discovery, one link per session |
| `core/src/client.rs` | the six operations, AUTH, chain walking, token sync, fetch pipeline with cancellation |
| `core/src/store.rs` | SQLite: state, contacts, accepted, tokens, seen ids, inbox, sent; `Send + Sync`, WAL, versioned schema |
| `core/src/error.rs` | the `Error` enum embedders match on, the status-code mapping |
| `core/vectors.json` | the committed reference vectors, the tests' source of truth |
| `cli/src/main.rs` | the command line: keyfile and `--db` conventions, hints, output |
Unit tests pin every derivation against vectors generated from `smolmail.py`, including a full envelope reproduced byte for byte.
Unit tests pin every derivation against `vectors.json`, whose values were generated from `smolmail.py`, including a full envelope reproduced byte for byte.
## References

View file

@ -19,7 +19,6 @@ The libraries marked "vendored source" are pinned nix flake inputs; each is iden
- anstyle-wincon 3.0.11 - Copyright (c) Individual contributors
- anyhow 1.0.104 - David Tolnay <dtolnay@gmail.com>
- base64ct 1.8.3 - Copyright (c) 2014 Steve "Sc00bz" Thomas (steve at tobtu dot com) Copyright (c) 2021-2025 The RustCrypto Project Developers
- bitflags 1.3.2 - Copyright (c) 2014 The Rust Project Developers
- bitflags 2.13.2 - Copyright (c) 2014 The Rust Project Developers
- blake2 0.10.6 - Copyright (c) 2015-2016 The blake2-rfc Developers, Cesar Barros Copyright (c) 2017 Artyom Pavlov
- block-buffer 0.10.4 - Copyright (c) 2018-2019 The RustCrypto Project Developers
@ -38,14 +37,9 @@ The libraries marked "vendored source" are pinned nix flake inputs; each is iden
- crypto-common 0.1.7 - Copyright (c) 2021 RustCrypto Developers
- ctr 0.9.2 - Copyright (c) 2018-2022 RustCrypto Developers Copyright (c) 2018 Artyom Pavlov
- curve25519-dalek-derive 0.1.1 - The curve25519-dalek-derive developers
- defmt 1.1.1 - Copyright (c) Ferrous Systems
- defmt-macros 1.1.1 - Copyright (c) Ferrous Systems
- defmt-parser 1.0.0 - The Knurling-rs developers
- der 0.7.10 - Copyright (c) 2020-2023 The RustCrypto Project Developers
- digest 0.10.7 - Copyright (c) 2017 Artyom Pavlov
- ed25519 2.2.3 - Copyright 2018-2022 RustCrypto Developers
- env_filter 2.0.0 - Copyright (c) Individual contributors
- env_logger 0.11.11 - Copyright (c) Individual contributors
- fallible-iterator 0.3.0 - Copyright (c) 2015 The rust-openssl-verify Developers
- fallible-streaming-iterator 0.1.9 - Copyright (c) 2016 The fallible-streaming-iterator Developers
- fiat-crypto 0.2.9 - Copyright 2015-2020 the fiat-crypto authors (see the AUTHORS file)
@ -59,8 +53,8 @@ The libraries marked "vendored source" are pinned nix flake inputs; each is iden
- hmac 0.12.1 - Copyright (c) 2017 Artyom Pavlov
- inout 0.1.4 - Copyright (c) 2022 The RustCrypto Project Developers Copyright (c) 2022 Artyom Pavlov
- is_terminal_polyfill 1.70.2 - Copyright (c) Individual contributors
- itoa 1.0.18 - David Tolnay <dtolnay@gmail.com>
- libc 0.2.189 - Copyright (c) The Rust Project Developers
- log 0.4.34 - Copyright (c) 2014 The Rust Project Developers
- once_cell 1.21.4 - Aleksey Kladov <aleksey.kladov@gmail.com>
- once_cell_polyfill 1.70.2 - Copyright (c) Individual contributors
- opaque-debug 0.3.1 - Copyright (c) 2018-2024 The RustCrypto Project Developers
@ -68,19 +62,15 @@ The libraries marked "vendored source" are pinned nix flake inputs; each is iden
- pkg-config 0.3.34 - Copyright (c) 2014 Alex Crichton
- poly1305 0.8.0 - Copyright (c) 2015-2019 RustCrypto Developers
- polyval 0.6.2 - Copyright (c) 2019-2023 RustCrypto Developers
- portable-atomic 1.15.0 - The portable-atomic developers
- portable-atomic-util 0.2.8 - The portable-atomic-util developers
- proc-macro2 1.0.107 - David Tolnay <dtolnay@gmail.com>, Alex Crichton <alex@alexcrichton.com>
- quote 1.0.47 - David Tolnay <dtolnay@gmail.com>
- rand_core 0.6.4 - Copyright 2018 Developers of the Rand project Copyright (c) 2014 The Rust Project Developers
- regex 1.13.1 - Copyright (c) 2014 The Rust Project Developers
- regex-automata 0.4.18 - Copyright (c) 2014 The Rust Project Developers
- regex-syntax 0.8.11 - Copyright (c) 2014 The Rust Project Developers
- rustc_version 0.4.1 - Copyright (c) 2016 The Rust Project Developers
- semver 1.0.28 - David Tolnay <dtolnay@gmail.com>
- serde 1.0.229 - Erick Tryzelaar <erick.tryzelaar@gmail.com>, David Tolnay <dtolnay@gmail.com>
- serde_core 1.0.229 - Erick Tryzelaar <erick.tryzelaar@gmail.com>, David Tolnay <dtolnay@gmail.com>
- serde_derive 1.0.229 - Erick Tryzelaar <erick.tryzelaar@gmail.com>, David Tolnay <dtolnay@gmail.com>
- serde_json 1.0.151 - Erick Tryzelaar <erick.tryzelaar@gmail.com>, David Tolnay <dtolnay@gmail.com>
- sha2 0.10.9 - Copyright (c) 2006-2009 Graydon Hoare Copyright (c) 2009-2013 Mozilla Foundation Copyright (c) 2016 Artyom Pavlov
- shlex 2.0.1 - Copyright 2015 Nicholas Allegra (comex).
- signature 2.2.0 - Copyright (c) 2018-2023 RustCrypto Developers
@ -89,8 +79,6 @@ The libraries marked "vendored source" are pinned nix flake inputs; each is iden
- spki 0.7.3 - Copyright (c) 2021-2023 The RustCrypto Project Developers
- syn 2.0.119 - David Tolnay <dtolnay@gmail.com>
- syn 3.0.6 - David Tolnay <dtolnay@gmail.com>
- thiserror 2.0.21 - David Tolnay <dtolnay@gmail.com>
- thiserror-impl 2.0.21 - David Tolnay <dtolnay@gmail.com>
- typenum 1.20.1 - Copyright 2014 Paho Lurie-Gregg
- unicode-ident 1.0.26 - David Tolnay <dtolnay@gmail.com>
- universal-hash 0.5.1 - Copyright (c) 2019-2020 RustCrypto Developers
@ -317,7 +305,6 @@ Apache License
- aes 0.8.4 - Copyright (c) 2018 Artyom Pavlov
- aes-gcm 0.10.3 - Copyright (c) 2019 The RustCrypto Project Developers
- ahash 0.8.12 - Copyright (c) 2018 Tom Kaitchuck
- aho-corasick 1.1.5 - Copyright (c) 2015 Andrew Gallant
- anstream 1.0.0 - Copyright (c) Individual contributors
- anstyle 1.0.14 - Copyright (c) Individual contributors
- anstyle-parse 1.0.0 - Copyright (c) Individual contributors
@ -325,7 +312,6 @@ Apache License
- anstyle-wincon 3.0.11 - Copyright (c) Individual contributors
- anyhow 1.0.104 - David Tolnay <dtolnay@gmail.com>
- base64ct 1.8.3 - Copyright (c) 2014 Steve "Sc00bz" Thomas (steve at tobtu dot com) Copyright (c) 2021-2025 The RustCrypto Project Developers
- bitflags 1.3.2 - Copyright (c) 2014 The Rust Project Developers
- bitflags 2.13.2 - Copyright (c) 2014 The Rust Project Developers
- blake2 0.10.6 - Copyright (c) 2015-2016 The blake2-rfc Developers, Cesar Barros Copyright (c) 2017 Artyom Pavlov
- block-buffer 0.10.4 - Copyright (c) 2018-2019 The RustCrypto Project Developers
@ -345,14 +331,9 @@ Apache License
- ctr 0.9.2 - Copyright (c) 2018-2022 RustCrypto Developers Copyright (c) 2018 Artyom Pavlov
- curve25519-dalek-derive 0.1.1 - The curve25519-dalek-derive developers
- data-encoding 2.11.1 - Copyright (c) 2015-2020 Julien Cretin Copyright (c) 2017-2020 Google Inc.
- defmt 1.1.1 - Copyright (c) Ferrous Systems
- defmt-macros 1.1.1 - Copyright (c) Ferrous Systems
- defmt-parser 1.0.0 - The Knurling-rs developers
- der 0.7.10 - Copyright (c) 2020-2023 The RustCrypto Project Developers
- digest 0.10.7 - Copyright (c) 2017 Artyom Pavlov
- ed25519 2.2.3 - Copyright (c) 2018-2023 RustCrypto Developers
- env_filter 2.0.0 - Copyright (c) Individual contributors
- env_logger 0.11.11 - Copyright (c) Individual contributors
- fallible-iterator 0.3.0 - Copyright (c) 2015 The rust-openssl-verify Developers
- fallible-streaming-iterator 0.1.9 - Copyright (c) 2016 The fallible-streaming-iterator Developers
- fiat-crypto 0.2.9 - Copyright (c) 2015-2020 the fiat-crypto authors (see the AUTHORS file).
@ -367,12 +348,9 @@ Apache License
- hmac 0.12.1 - Copyright (c) 2017 Artyom Pavlov
- inout 0.1.4 - Copyright (c) 2022 The RustCrypto Project Developers Copyright (c) 2022 Artyom Pavlov
- is_terminal_polyfill 1.70.2 - Copyright (c) Individual contributors
- jiff 0.2.37 - Copyright (c) 2015 Andrew Gallant
- jiff-core 0.1.1 - Copyright (c) 2015 Andrew Gallant
- jiff-static 0.2.37 - Copyright (c) 2015 Andrew Gallant
- itoa 1.0.18 - David Tolnay <dtolnay@gmail.com>
- libc 0.2.189 - Copyright (c) The Rust Project Developers
- libsqlite3-sys 0.30.1 - Copyright (c) 2014-2021 The rusqlite developers
- log 0.4.34 - Copyright (c) 2014 The Rust Project Developers
- memchr 2.8.3 - Copyright (c) 2015 Andrew Gallant
- once_cell 1.21.4 - Aleksey Kladov <aleksey.kladov@gmail.com>
- once_cell_polyfill 1.70.2 - Copyright (c) Individual contributors
@ -381,20 +359,16 @@ Apache License
- pkg-config 0.3.34 - Copyright (c) 2014 Alex Crichton
- poly1305 0.8.0 - Copyright (c) 2015-2019 RustCrypto Developers
- polyval 0.6.2 - Copyright (c) 2019-2023 RustCrypto Developers
- portable-atomic 1.15.0 - The portable-atomic developers
- portable-atomic-util 0.2.8 - The portable-atomic-util developers
- proc-macro2 1.0.107 - David Tolnay <dtolnay@gmail.com>, Alex Crichton <alex@alexcrichton.com>
- quote 1.0.47 - David Tolnay <dtolnay@gmail.com>
- rand_core 0.6.4 - Copyright 2018 Developers of the Rand project Copyright (c) 2014 The Rust Project Developers
- regex 1.13.1 - Copyright (c) 2014 The Rust Project Developers
- regex-automata 0.4.18 - Copyright (c) 2014 The Rust Project Developers
- regex-syntax 0.8.11 - Copyright (c) 2014 The Rust Project Developers
- rusqlite 0.32.1 - Copyright (c) 2014-2021 The rusqlite developers
- rustc_version 0.4.1 - Copyright (c) 2016 The Rust Project Developers
- semver 1.0.28 - David Tolnay <dtolnay@gmail.com>
- serde 1.0.229 - Erick Tryzelaar <erick.tryzelaar@gmail.com>, David Tolnay <dtolnay@gmail.com>
- serde_core 1.0.229 - Erick Tryzelaar <erick.tryzelaar@gmail.com>, David Tolnay <dtolnay@gmail.com>
- serde_derive 1.0.229 - Erick Tryzelaar <erick.tryzelaar@gmail.com>, David Tolnay <dtolnay@gmail.com>
- serde_json 1.0.151 - Erick Tryzelaar <erick.tryzelaar@gmail.com>, David Tolnay <dtolnay@gmail.com>
- sha2 0.10.9 - Copyright (c) 2006-2009 Graydon Hoare Copyright (c) 2009-2013 Mozilla Foundation Copyright (c) 2016 Artyom Pavlov
- shlex 2.0.1 - Copyright (c) 2015 Nicholas Allegra (comex).
- signature 2.2.0 - Copyright (c) 2018-2023 RustCrypto Developers
@ -404,8 +378,6 @@ Apache License
- strsim 0.11.1 - Copyright (c) 2015 Danny Guo Copyright (c) 2016 Titus Wormer <tituswormer@gmail.com> Copyright (c) 2018 Akash Kurdekar
- syn 2.0.119 - David Tolnay <dtolnay@gmail.com>
- syn 3.0.6 - David Tolnay <dtolnay@gmail.com>
- thiserror 2.0.21 - David Tolnay <dtolnay@gmail.com>
- thiserror-impl 2.0.21 - David Tolnay <dtolnay@gmail.com>
- typenum 1.20.1 - Copyright (c) 2014 Paho Lurie-Gregg
- unicode-ident 1.0.26 - David Tolnay <dtolnay@gmail.com>
- universal-hash 0.5.1 - Copyright (c) 2019-2020 RustCrypto Developers
@ -419,12 +391,13 @@ Apache License
- zerocopy-derive 0.8.59 - Copyright 2023 The Fuchsia Authors
- zeroize 1.9.0 - Copyright (c) 2018-2026 The RustCrypto Project Developers
- zeroize_derive 1.5.0 - Copyright (c) 2019-2026 The RustCrypto Project Developers
- zmij 1.0.23 - David Tolnay <dtolnay@gmail.com>
- ArduinoJson (vendored source, https://github.com/bblanchon/ArduinoJson, rev ed69feadb951)
- MsgPack (vendored source, https://github.com/hideakitai/MsgPack, rev 1f552c31b940) - Copyright (c) 2020 Hideaki Tai
- ArxContainer (vendored source, https://github.com/hideakitai/ArxContainer, rev d6affcd0bc83) - Copyright (c) 2019 Hideaki Tai
- ArxTypeTraits (vendored source, https://github.com/hideakitai/ArxTypeTraits, rev 702de9cc59c7) - Copyright (c) 2020 Hideaki Tai
- DebugLog (vendored source, https://github.com/hideakitai/DebugLog, rev b581f7dde6c2) - Copyright (c) 2019 Hideaki Tai
- Crypto (vendored source, https://github.com/attermann/Crypto, rev 984dc8913309) - Copyright (c) 2024 Chad Attermann
- Crypto (attermann) (vendored source, https://github.com/attermann/Crypto, rev 984dc8913309) - Copyright (c) 2024 Chad Attermann
```
The MIT License (MIT)
@ -570,10 +543,6 @@ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
## The Unlicense
- aho-corasick 1.1.5 - Andrew Gallant <jamslam@gmail.com>
- jiff 0.2.37 - Andrew Gallant <jamslam@gmail.com>
- jiff-core 0.1.1 - Andrew Gallant <jamslam@gmail.com>
- jiff-static 0.2.37 - Andrew Gallant <jamslam@gmail.com>
- memchr 2.8.3 - Andrew Gallant <jamslam@gmail.com>, bluss
```

22
cli/Cargo.toml Normal file
View file

@ -0,0 +1,22 @@
[package]
name = "fumi"
version = "0.1.0"
edition = "2021"
description = "Smol Mail client"
license = "Apache-2.0"
[[bin]]
name = "fumi"
path = "src/main.rs"
[features]
# Passes the core's carrier feature through; addresses select the carrier by
# scheme, so enabling it only affects smol+rns:// dials.
rns = ["fumi-core/rns"]
[dependencies]
fumi-core = { path = "../core" }
clap = { version = "4", features = ["derive"] }
anyhow = "1"
rand_core = { version = "0.6", features = ["getrandom"] }
data-encoding = "2"

View file

@ -4,14 +4,16 @@
use std::io::{IsTerminal, Read, Write};
use std::path::PathBuf;
use anyhow::{Context, Result};
use anyhow::{anyhow, Context, Result};
use clap::{Parser, Subcommand};
use fumi_core as fumi;
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;
@ -146,10 +148,30 @@ fn main() {
let cli = Cli::parse();
if let Err(e) = run(cli) {
eprintln!("error: {e}");
hint(e.downcast_ref::<fumi::error::Error>());
std::process::exit(1);
}
}
/// The remedy for the errors whose remedy is a command to run. The library
/// reports the distinction; only the CLI knows the command.
fn hint(error: Option<&fumi::error::Error>) {
match error {
Some(fumi::error::Error::NotPinned { host }) => eprintln!(
"obtain the key from the operator through a trusted channel, then:\n fumi trust {host} <key>"
),
Some(fumi::error::Error::KeyChanged { address, offered, .. }) => {
if let Ok(addr) = Address::parse(address) {
eprintln!("verify out of band, then:\n fumi import {}", addr.uri(offered));
}
}
Some(fumi::error::Error::NotRegistered) => {
eprintln!("run: fumi register <user@host>")
}
_ => {}
}
}
fn run(cli: Cli) -> Result<()> {
let store = Store::open(&cli.db)?;
#[cfg(feature = "rns")]
@ -186,18 +208,17 @@ fn warn(message: &str) {
fn load_master(path: &PathBuf) -> Result<[u8; KEY_LEN]> {
let bytes = std::fs::read(path).map_err(|_| {
SmolError::new(format!(
anyhow!(format!(
"no identity at {}; run: fumi keygen",
path.display()
))
})?;
bytes.try_into().map_err(|v: Vec<u8>| {
SmolError::new(format!(
anyhow!(format!(
"master secret at {} is {} bytes, expected {KEY_LEN}",
path.display(),
v.len()
))
.into()
})
}
@ -232,7 +253,7 @@ fn write_secret(path: &PathBuf, secret: &[u8]) -> Result<()> {
fn keygen(path: &PathBuf, force: bool) -> Result<()> {
if path.exists() && !force {
return Err(SmolError::new(format!(
return Err(anyhow!(format!(
"{} exists; refusing to overwrite (use --force)",
path.display()
))
@ -315,7 +336,7 @@ fn rotate(key: &PathBuf, store: &Store, timeout: u64) -> Result<()> {
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!(
anyhow!(format!(
"server key is {} bytes, expected {KEY_LEN}",
v.len()
))
@ -325,7 +346,7 @@ fn trust(store: &Store, host: &str, key_b32: &str, force: bool) -> Result<()> {
let host = host.split(':').next().unwrap_or(host);
match store.server_pin(host)? {
Some(old) if old != key && !force => {
return Err(SmolError::new(format!(
return Err(anyhow!(format!(
"{host} is already pinned to {}; use --force to replace",
b32(&old)
))
@ -357,7 +378,7 @@ 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(),
anyhow!("that address already carries a key; use `import` instead").into(),
);
}
let mut session = client::connect(store, &addr, false, timeout)?;
@ -398,7 +419,7 @@ 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"))?;
.ok_or_else(|| anyhow!("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));
@ -435,7 +456,7 @@ fn target_contact(store: &Store, text: &str) -> Result<(String, [u8; KEY_LEN])>
}
match store.contact(&addr.short())? {
Some((key, _)) => Ok((addr.short(), key)),
None => Err(SmolError::new(format!(
None => Err(anyhow!(format!(
"no key for {}; run `resolve` or `import` first",
addr.short()
))
@ -467,7 +488,7 @@ 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());
return Err(anyhow!(format!("{address} was never accepted")));
}
println!("blocked {address}; their mail lands in requests from their next message on");
match client::push_tokens(store, &account, timeout)? {
@ -503,13 +524,13 @@ fn send(cli: &Cli, store: &Store) -> Result<()> {
}
(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())
return Err(anyhow!("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(),
anyhow!("--reply-to must be a message id: 64 hex characters"),
);
}
}
@ -519,12 +540,12 @@ fn send(cli: &Cli, store: &Store) -> Result<()> {
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(),
anyhow!(format!("{raw:?} is not a valid `Key: value` header")),
);
};
if !valid_frontmatter_key(k.trim()) {
return Err(
SmolError::new(format!("{raw:?} is not a valid `Key: value` header")).into(),
anyhow!(format!("{raw:?} is not a valid `Key: value` header")),
);
}
extra.push((k.trim().to_string(), v.trim().to_string()));
@ -603,7 +624,9 @@ fn fetch(key: &PathBuf, store: &Store, keep: bool, reset: bool, timeout: u64) ->
summary.stored,
summary.rejected.len()
);
if keep && summary.total > 0 {
if summary.cancelled {
line.push_str("; cancelled, fetching again continues");
} else if keep && summary.total > 0 {
line.push_str(" (left on the server)");
}
println!("{line}");
@ -614,7 +637,7 @@ fn fetch(key: &PathBuf, store: &Store, keep: bool, reset: bool, timeout: u64) ->
/// 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());
return Err(anyhow!("no message ids given"));
}
let stored = store
.mail("all")?
@ -631,10 +654,10 @@ fn resolve_id_prefixes(store: &Store, ids: &[String]) -> Result<Vec<[u8; ID_LEN]
.copied()
.collect();
match matches.len() {
0 => return Err(SmolError::new(format!("no message matching {id:?}")).into()),
0 => return Err(anyhow!(format!("no message matching {id:?}"))),
1 => full.push(matches[0]),
_ => {
return Err(SmolError::new(format!(
return Err(anyhow!(format!(
"{id:?} matches {} messages; be more specific",
matches.len()
))
@ -705,11 +728,10 @@ fn read(key: &PathBuf, store: &Store, id: &str, sent: bool) -> Result<()> {
.filter(|m| hex(&m.id).starts_with(&prefix))
.collect();
match matches.len() {
0 => Err(SmolError::new(format!(
0 => Err(anyhow!(format!(
"no {}message matching {id:?}",
if sent { "sent " } else { "" }
))
.into()),
))),
1 => {
let row = &matches[0];
let described = client::describe(store, &account, row)?;
@ -736,7 +758,7 @@ fn read(key: &PathBuf, store: &Store, id: &str, sent: bool) -> Result<()> {
}
Ok(())
}
_ => Err(SmolError::new(format!(
_ => Err(anyhow!(format!(
"{id:?} matches {} messages; be more specific",
matches.len()
))

34
core/Cargo.toml Normal file
View file

@ -0,0 +1,34 @@
[package]
name = "fumi-core"
version = "0.1.0"
edition = "2021"
description = "Smol Mail client library: identities, sealing, mailbox operations"
license = "Apache-2.0"
[features]
default = ["bundled-sqlite"]
# Bundle libsqlite3 so the store needs no system SQLite; embedders with their
# own SQLite (Android's NDK case) build with default-features = false.
bundled-sqlite = ["rusqlite/bundled"]
# 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 = "0.32"
data-encoding = "2"
[dev-dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
[build-dependencies]
cc = { version = "1", optional = true }

View file

@ -2,7 +2,7 @@
//! indices, accept tokens and rotation certificates.
use crate::crypto::{hkdf_sha256, hmac_sha256, KEY_LEN};
use crate::error::SmolError;
use crate::error::Error;
use crate::transport::{CERT_LEN, LABEL_ACCEPT, LABEL_IDENTITY, LABEL_ROTATE, MAX_CHAIN};
use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey};
use x25519_dalek::StaticSecret;
@ -52,6 +52,15 @@ pub fn accept_key(master: &[u8; KEY_LEN]) -> [u8; KEY_LEN] {
.unwrap()
}
/// The rotation index whose identity is `identity`, if the key is derived
/// from this master at all (sec 2): what `restore` recovers the index with.
/// A GUI holding the master and the address but no connectivity can call
/// this itself once it has a RESOLVE result.
pub fn find_rotation_index(master: &[u8; KEY_LEN], identity: &[u8; KEY_LEN]) -> Option<u32> {
(0..=(MAX_CHAIN as u32))
.find(|&n| Identity::from_seed(identity_seed(master, n)).pk() == *identity)
}
/// 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).
@ -62,11 +71,9 @@ pub struct Account {
}
impl Account {
pub fn new(master: [u8; KEY_LEN], index: u32) -> Result<Account, SmolError> {
pub fn new(master: [u8; KEY_LEN], index: u32) -> Result<Account, Error> {
if index as usize > MAX_CHAIN {
return Err(SmolError::new(format!(
"rotation index {index} exceeds the chain limit of {MAX_CHAIN}"
)));
return Err(Error::ChainLimit { index });
}
let keys = (0..=index)
.map(|n| Identity::from_seed(identity_seed(&master, n)))
@ -115,9 +122,9 @@ pub struct RotationCert {
}
impl RotationCert {
pub fn from_bytes(bytes: &[u8]) -> Result<RotationCert, SmolError> {
pub fn from_bytes(bytes: &[u8]) -> Result<RotationCert, Error> {
if bytes.len() != CERT_LEN {
return Err(SmolError::new(format!(
return Err(Error::Other(format!(
"rotation certificate is {} bytes, expected {CERT_LEN}",
bytes.len()
)));
@ -191,67 +198,32 @@ pub fn verify_sig(identity: &[u8], signature: &[u8], message: &[u8]) -> bool {
mod tests {
use super::*;
use crate::crypto::{b32, unb32};
use crate::vectors;
// 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!(),
}
vectors::load().master()
}
#[test]
fn identity_derivation_matches_the_reference_client() {
let v = vectors::load();
for n in [0u32, 1, 2, 16] {
let seed = identity_seed(&master(), n);
assert_eq!(b32(&seed), seed_b32(n));
assert_eq!(b32(&seed), v.seed_b32(n));
let identity = Identity::from_seed(seed);
assert_eq!(b32(&identity.pk()), pk_b32(n));
assert_eq!(b32(&identity.pk()), v.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));
assert_eq!(b32(identity.x_priv().as_bytes()), v.xpriv_b32(n));
}
}
#[test]
fn accept_key_and_token_match_the_reference_client() {
assert_eq!(
b32(&accept_key(&master())),
"fqmnb3vktmrth47m2qjzcey6ue7rbkrvsazytwdayjjgkvcqe2xa"
);
let v = vectors::load();
assert_eq!(b32(&accept_key(&master())), v.accept_key_b32);
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"
);
let pk1: [u8; 32] = unb32(v.pk_b32(1)).unwrap().try_into().unwrap();
assert_eq!(b32(&account.token_for(&pk1)), v.accept_token_for_pk1_b32);
// Independent of the rotation index (sec 2).
let rotated = Account::new(master(), 3).unwrap();
assert_eq!(account.token_for(&pk1), rotated.token_for(&pk1));
@ -261,8 +233,9 @@ mod tests {
fn account_holds_every_superseded_key() {
let account = Account::new(master(), 2).unwrap();
assert_eq!(account.keys().len(), 3);
let v = vectors::load();
for (n, key) in account.keys().iter().enumerate() {
assert_eq!(b32(&key.pk()), pk_b32(n as u32));
assert_eq!(b32(&key.pk()), v.pk_b32(n as u32));
}
assert_eq!(account.index(), 2);
}
@ -273,29 +246,35 @@ mod tests {
assert!(Account::new(master(), 17).is_err());
}
#[test]
fn find_rotation_index_recovers_every_derived_key() {
let v = vectors::load();
for n in [0u32, 1, 2, 16] {
let identity = Identity::from_seed(identity_seed(&v.master(), n));
assert_eq!(find_rotation_index(&v.master(), &identity.pk()), Some(n));
}
// A key derived from no index of this master derives from none.
assert_eq!(find_rotation_index(&v.master(), &[7u8; 32]), None);
}
#[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);
let v = vectors::load();
let rc = &v.rotation_cert;
let old = Identity::from_seed(identity_seed(&master(), rc.old_index));
let new = Identity::from_seed(identity_seed(&master(), rc.new_index));
let cert = RotationCert::build(&old, &new, &rc.username, rc.when);
assert_eq!(data_encoding::HEXLOWER.encode(&cert), rc.cert_hex);
assert_eq!(cert.len(), CERT_LEN);
let parsed = RotationCert::from_bytes(&cert).unwrap();
assert!(parsed.verify("alice"));
assert!(parsed.verify(&rc.username));
// 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(&broken).unwrap().verify(&rc.username));
assert!(RotationCert::from_bytes(&cert[..199]).is_err());
}

View file

@ -2,7 +2,7 @@
//! grammar they share.
use crate::crypto::{b32, unb32, KEY_LEN};
use crate::error::SmolError;
use crate::error::Error;
pub const DEFAULT_PORT: u16 = 1961;
/// A Reticulum destination hash, as printed by every RNS tool: 32 lowercase
@ -54,21 +54,21 @@ impl Address {
}
/// The Reticulum destination hash, for the `rns` carrier only.
pub fn destination(&self) -> Result<[u8; 16], SmolError> {
pub fn destination(&self) -> Result<[u8; 16], Error> {
if self.scheme != Scheme::Rns {
return Err(SmolError::new("not an smol+rns:// address"));
return Err(Error::Other("not an smol+rns:// address".into()));
}
data_encoding::HEXLOWER
.decode(self.host.as_bytes())
.map_err(|_| SmolError::new("destination hash is not hexadecimal"))
.map_err(|_| Error::Other("destination hash is not hexadecimal".into()))
.and_then(|bytes| {
bytes
.try_into()
.map_err(|_| SmolError::new("destination hash is not 16 bytes"))
.map_err(|_| Error::Other("destination hash is not 16 bytes".into()))
})
}
pub fn parse(text: &str) -> Result<Address, SmolError> {
pub fn parse(text: &str) -> Result<Address, Error> {
let text = text.trim();
let (scheme, rest) = if let Some(rest) = text.strip_prefix("smol+rns://") {
(Scheme::Rns, rest)
@ -85,7 +85,7 @@ impl Address {
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!(
return Err(Error::Other(format!(
"{text}: smol:// address carries no key"
)))
}
@ -94,9 +94,9 @@ impl Address {
let (user, host_part) = rest
.split_once('@')
.ok_or_else(|| SmolError::new(format!("{text:?} is not a valid address")))?;
.ok_or_else(|| Error::Other(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")));
return Err(Error::Other(format!("{text:?} is not a valid address")));
}
valid_username(user)?;
@ -115,12 +115,12 @@ impl Address {
// 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!(
return Err(Error::Other(format!(
"{text}: the :port suffix must not appear on smol+rns://"
)));
}
if !is_destination_hash(host_part) {
return Err(SmolError::new(format!(
return Err(Error::Other(format!(
"{text}: host must be exactly {RNS_HASH_HEX} lowercase hexadecimal \
characters, a Reticulum destination hash"
)));
@ -137,28 +137,28 @@ impl Address {
}
}
fn decode_key(text: &str, key: &str) -> Result<[u8; KEY_LEN], SmolError> {
fn decode_key(text: &str, key: &str) -> Result<[u8; KEY_LEN], Error> {
let identity =
unb32(key).map_err(|e| SmolError::new(format!("{text}: undecodable key: {e}")))?;
unb32(key).map_err(|e| Error::Other(format!("{text}: undecodable key: {e}")))?;
identity.try_into().map_err(|v: Vec<u8>| {
SmolError::new(format!(
Error::Other(format!(
"{text}: key is {} bytes, expected {KEY_LEN}",
v.len()
))
})
}
fn split_host_port(text: &str, host_part: &str) -> Result<(String, u16), SmolError> {
fn split_host_port(text: &str, host_part: &str) -> Result<(String, u16), Error> {
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")));
.map_err(|_| Error::Other(format!("{text}: invalid port")));
}
}
if host_part.contains(':') || host_part.contains('/') {
return Err(SmolError::new(format!("{text:?} is not a valid address")));
return Err(Error::Other(format!("{text:?} is not a valid address")));
}
Ok((host_part.to_string(), DEFAULT_PORT))
}
@ -173,7 +173,7 @@ fn is_destination_hash(host: &str) -> bool {
/// 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> {
fn valid_username(name: &str) -> Result<(), Error> {
const SEPARATORS: &[u8] = b"._-";
let bytes = name.as_bytes();
let ok = !bytes.is_empty()
@ -189,7 +189,7 @@ fn valid_username(name: &str) -> Result<(), SmolError> {
if ok {
Ok(())
} else {
Err(SmolError::new(format!(
Err(Error::Other(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"
)))

View file

@ -8,7 +8,7 @@ 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::error::{expect_ok, Error};
use crate::message::{
accept_field, build_frontmatter, message_id, parse_frontmatter, seal, unseal, Opened,
};
@ -44,16 +44,14 @@ pub fn connect(
addr: &Address,
require_pin: bool,
timeout: u64,
) -> Result<Session, SmolError> {
) -> Result<Session, Error> {
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
)));
return Err(Error::NotPinned {
host: addr.host.clone(),
});
}
let transport = TcpTransport::connect(&addr.host, addr.port, pinned, timeout)?;
let unpinned_static = if pinned.is_none() {
@ -87,17 +85,17 @@ pub fn connect(
#[cfg(not(feature = "rns"))]
{
let _ = (addr, require_pin);
Err(SmolError::new(
"smol+rns:// addresses need the rns feature; rebuild with --features rns",
))
Err(Error::Other(
"smol+rns:// addresses need the rns feature; rebuild with --features rns".into(),
))
}
}
}
}
fn string_field(out: &mut Vec<u8>, text: &[u8]) -> Result<(), SmolError> {
fn string_field(out: &mut Vec<u8>, text: &[u8]) -> Result<(), Error> {
if text.len() > 255 {
return Err(SmolError::new("field longer than one length byte"));
return Err(Error::Other("field longer than one length byte".into()));
}
out.push(text.len() as u8);
out.extend_from_slice(text);
@ -108,7 +106,7 @@ fn string_field(out: &mut Vec<u8>, text: &[u8]) -> Result<(), SmolError> {
pub fn resolve(
transport: &mut dyn Transport,
username: &str,
) -> Result<([u8; KEY_LEN], Vec<[u8; CERT_LEN]>), SmolError> {
) -> Result<([u8; KEY_LEN], Vec<[u8; CERT_LEN]>), Error> {
let mut body = Vec::new();
string_field(&mut body, username.as_bytes())?;
let response = transport.request(OP_RESOLVE, &body)?;
@ -180,7 +178,7 @@ pub fn trust_key(
store: &Store,
transport: &mut dyn Transport,
addr: &Address,
) -> Result<([u8; KEY_LEN], TrustChange), SmolError> {
) -> Result<([u8; KEY_LEN], TrustChange), Error> {
let (identity, chain) = resolve(transport, &addr.user)?;
let known = store.contact(&addr.short())?;
match known {
@ -199,14 +197,11 @@ pub fn trust_key(
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)
)))
Err(Error::KeyChanged {
address: addr.short(),
known: old,
offered: identity,
})
}
}
}
@ -221,7 +216,7 @@ pub fn authenticate(
username: &str,
account: &Account,
store: &Store,
) -> Result<u16, SmolError> {
) -> Result<u16, Error> {
let (sync, tokens) = store.token_set(account)?;
let mut body = Vec::new();
string_field(&mut body, username.as_bytes())?;
@ -256,7 +251,7 @@ pub enum Pushed {
/// 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> {
pub fn push_tokens(store: &Store, account: &Account, timeout: u64) -> Result<Pushed, Error> {
let Some(addr) = store.account()? else {
return Ok(Pushed::NotRegistered);
};
@ -275,7 +270,7 @@ pub fn register(
account: &Account,
invite: Option<&str>,
timeout: u64,
) -> Result<(), SmolError> {
) -> Result<(), Error> {
let mut session = connect(store, addr, true, timeout)?;
let transport = session.transport();
let me = account.me();
@ -313,16 +308,14 @@ pub struct Rotated {
/// 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> {
pub fn rotate(store: &Store, account: &Account, timeout: u64) -> Result<Rotated, Error> {
let Some(addr) = store.account()? else {
return Err(SmolError::new(
"not registered; run: fumi register <user@host>",
));
return Err(Error::NotRegistered);
};
if account.index() as usize >= MAX_CHAIN {
return Err(SmolError::new(format!(
"the rotation chain is full at {MAX_CHAIN} links"
)));
return Err(Error::ChainLimit {
index: account.index() + 1,
});
}
let old = account.me();
let new = Identity::from_seed(crate::account::identity_seed(
@ -364,25 +357,16 @@ pub fn rotate(store: &Store, account: &Account, timeout: u64) -> Result<Rotated,
/// 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> {
/// The pieces are public for hosts that want intermediate UI states:
/// `connect` + `resolve`, `find_rotation_index`, then the four `set_*`
/// calls below.
pub fn restore(store: &Store, master: &[u8; KEY_LEN], addr: &Address, timeout: u64) -> Result<u32, Error> {
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!(
let Some(index) = crate::account::find_rotation_index(master, &identity) else {
return Err(Error::Other(format!(
"the key bound to {} is not derived from this master within {MAX_CHAIN} rotations",
addr.short()
)));
@ -428,7 +412,7 @@ pub fn send(
account: &Account,
draft: &SendDraft<'_>,
timeout: u64,
) -> Result<Sent, SmolError> {
) -> Result<Sent, Error> {
let addr = draft.address;
let me = account.me();
@ -519,6 +503,37 @@ pub struct Fetched {
/// Messages were deleted from the server rather than kept behind a
/// cursor (the default).
pub acknowledged: bool,
/// `cancel` was raised mid-fetch; the summary covers what completed.
/// Every finished page is accounted for locally — cursor moved or
/// messages deleted — so fetching again continues rather than repeats.
pub cancelled: bool,
}
/// The knobs a host with a UI needs on `fetch_with`. Progress needs no
/// callback: each envelope is committed to the `Store` as it is verified, so
/// a concurrent reader (the store is `Send + Sync`) can list incrementally.
pub struct FetchOptions<'a> {
/// Remember the cursor instead of acknowledging with DELETE.
pub keep: bool,
/// Forget the cursor and page again.
pub reset: bool,
/// Network timeout in seconds.
pub timeout: u64,
/// Checked between pages; when raised, `fetch_with` stops and returns
/// the partial summary with `cancelled` set.
pub cancel: Option<&'a std::sync::atomic::AtomicBool>,
}
impl FetchOptions<'_> {
/// The defaults a plain fetch uses.
fn new(keep: bool, reset: bool, timeout: u64) -> Self {
FetchOptions {
keep,
reset,
timeout,
cancel: None,
}
}
}
/// Retrieves, verifies and stores mail (sec 6.1), paging forward by
@ -532,16 +547,23 @@ pub fn fetch(
keep: bool,
reset: bool,
timeout: u64,
) -> Result<Fetched, SmolError> {
) -> Result<Fetched, Error> {
fetch_with(store, account, FetchOptions::new(keep, reset, timeout))
}
/// `fetch` with the full option set, for hosts that need cancellation.
pub fn fetch_with(
store: &Store,
account: &Account,
opts: FetchOptions<'_>,
) -> Result<Fetched, Error> {
let Some(addr) = store.account()? else {
return Err(SmolError::new(
"not registered; run: fumi register <user@host>",
));
return Err(Error::NotRegistered);
};
if reset {
if opts.reset {
store.set_cursor(0, &[0u8; ID_LEN])?;
}
let (mut after_time, mut after_id) = if keep {
let (mut after_time, mut after_id) = if opts.keep {
store.cursor()?
} else {
(0, [0u8; ID_LEN])
@ -551,13 +573,19 @@ pub fn fetch(
total: 0,
stored: 0,
rejected: Vec::new(),
acknowledged: !keep,
acknowledged: !opts.keep,
cancelled: false,
};
let mut session = connect(store, &addr, true, timeout)?;
let cancelled = || opts.cancel.is_some_and(|c| c.load(std::sync::atomic::Ordering::Relaxed));
let mut session = connect(store, &addr, true, opts.timeout)?;
let transport = session.transport();
authenticate(transport, &addr.user, account, store)?;
loop {
if cancelled() {
summary.cancelled = true;
break;
}
let mut body = Vec::with_capacity(8 + ID_LEN);
body.extend_from_slice(&after_time.to_be_bytes());
body.extend_from_slice(&after_id);
@ -600,14 +628,14 @@ pub fn fetch(
acked.push(mid);
}
r.done()?;
if keep {
if opts.keep {
store.set_cursor(after_time, &after_id)?;
} else if !acked.is_empty() {
delete_ids(transport, &acked)?;
}
}
session.close();
if !keep {
if !opts.keep {
store.set_cursor(0, &[0u8; ID_LEN])?;
}
Ok(summary)
@ -617,9 +645,9 @@ fn verify_and_open(
account: &Account,
mid: &[u8; ID_LEN],
envelope: &[u8],
) -> Result<Opened, SmolError> {
) -> Result<Opened, Error> {
if message_id(envelope) != *mid {
return Err(SmolError::new("id does not match the envelope"));
return Err(Error::Other("id does not match the envelope".into()));
}
unseal(account.keys(), envelope, now())
}
@ -627,7 +655,7 @@ fn verify_and_open(
/// 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> {
fn learn_token(store: &Store, opened: &Opened) -> Result<(), Error> {
let text = String::from_utf8_lossy(&opened.body).into_owned();
let (fields, _) = parse_frontmatter(&text);
let Some(token) = accept_field(&fields) else {
@ -641,7 +669,7 @@ fn learn_token(store: &Store, opened: &Opened) -> Result<(), SmolError> {
}
}
fn delete_ids(transport: &mut dyn Transport, ids: &[[u8; ID_LEN]]) -> Result<u16, SmolError> {
fn delete_ids(transport: &mut dyn Transport, ids: &[[u8; ID_LEN]]) -> Result<u16, Error> {
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 {
@ -664,11 +692,9 @@ pub fn delete(
account: &Account,
ids: &[[u8; ID_LEN]],
timeout: u64,
) -> Result<u16, SmolError> {
) -> Result<u16, Error> {
let Some(addr) = store.account()? else {
return Err(SmolError::new(
"not registered; run: fumi register <user@host>",
));
return Err(Error::NotRegistered);
};
let mut session = connect(store, &addr, true, timeout)?;
let transport = session.transport();
@ -698,7 +724,7 @@ impl Described {
}
/// Opens one sealed message and splits its body into frontmatter and text.
pub fn describe(store: &Store, account: &Account, stored: &Stored) -> Result<Described, SmolError> {
pub fn describe(store: &Store, account: &Account, stored: &Stored) -> Result<Described, Error> {
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);
@ -811,16 +837,16 @@ mod tests {
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 v = crate::vectors::load();
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();
let mid: [u8; 32] = crate::crypto::unb32(&v.accept_mac.message_id_b32)
.unwrap()
.try_into()
.unwrap();
assert_eq!(
data_encoding::HEXLOWER.encode(&accept_mac(&token, &mid)),
"f709facbe5e032f4c2667c7899e5194397437ed001f2b2be33b82f1cc8d7c93e"
v.accept_mac.mac_hex
);
}

View file

@ -9,7 +9,7 @@ use sha2::{Digest, Sha256};
use std::sync::LazyLock;
use x25519_dalek::{PublicKey, StaticSecret};
use crate::error::SmolError;
use crate::error::Error;
pub const KEY_LEN: usize = 32;
@ -36,12 +36,12 @@ pub fn b32(raw: &[u8]) -> String {
}
/// Inverse of `b32`; accepts uppercase and stray padding for pasted input.
pub fn unb32(text: &str) -> Result<Vec<u8>, SmolError> {
pub fn unb32(text: &str) -> Result<Vec<u8>, Error> {
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}")))
.map_err(|e| Error::Other(format!("undecodable base32: {e}")))
}
/// First 20 characters of the base32 identity, in groups of four (SPEC.md
@ -95,18 +95,18 @@ pub fn ct_eq(a: &[u8], b: &[u8]) -> bool {
/// 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> {
pub fn ed25519_to_x25519(identity: &[u8; KEY_LEN]) -> Result<[u8; KEY_LEN], Error> {
VerifyingKey::from_bytes(identity)
.map(|key| *key.to_montgomery().as_bytes())
.map_err(|_| SmolError::new("not a valid Ed25519 public key"))
.map_err(|_| Error::Other("not a valid Ed25519 public key".into()))
}
/// 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> {
pub fn agree(secret: &StaticSecret, peer: &[u8; KEY_LEN]) -> Result<[u8; KEY_LEN], Error> {
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"));
return Err(Error::Other("rejected all-zero key agreement output".into()));
}
Ok(*shared.as_bytes())
}
@ -141,41 +141,32 @@ mod tests {
#[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);
let v = crate::vectors::load();
let hk = &v.hkdf_rfc5869_case1;
let okm = hkdf_sha256(&v.hex(&hk.ikm_hex), &v.hex(&hk.salt_hex), &v.hex(&hk.info_hex), 42);
assert_eq!(data_encoding::HEXLOWER.encode(&okm[..32]), hk.okm_hex_32);
}
#[test]
fn hmac_matches_rfc4231_case_1() {
let key = [0x0bu8; 20];
let data = b"Hi There";
let expected = "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7";
let v = crate::vectors::load();
let hm = &v.hmac_rfc4231_case1;
assert_eq!(
data_encoding::HEXLOWER.encode(&hmac_sha256(&key, data)),
expected
data_encoding::HEXLOWER.encode(&hmac_sha256(&v.hex(&hm.key_hex), hm.data.as_bytes())),
hm.mac_hex
);
}
#[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();
// libsodium's pk_to_curve25519 over the identity the reference
// client derives at seed_0.
let v = crate::vectors::load();
let seed0: [u8; 32] = unb32(v.seed_b32(0)).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"
);
assert_eq!(b32(&ed25519_to_x25519(&pk0).unwrap()), v.ed25519_to_x25519_pk0_b32);
// 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.

193
core/src/error.rs Normal file
View file

@ -0,0 +1,193 @@
//! The failure type embedders match on, and the status-code mapping it is
//! built from.
use std::fmt;
use crate::crypto::{b32, KEY_LEN};
use crate::transport::MAX_CHAIN;
/// Every failure the library reports, distinguished the way a caller needs
/// to decide what to do — no prose parsing. Each variant documents the
/// decision it supports; `Display` still produces a user-readable message.
#[derive(Debug)]
pub enum Error {
/// The wire status codes of SPEC.md sec 12, one variant each, carrying
/// what was being attempted. `UnknownStatus` is an unassigned code.
Malformed(String),
BadVersion(String),
UnknownUser(String),
AuthRequired(String),
AuthFailed(String),
QuotaExceeded(String),
TooLarge(String),
RateLimited(String),
NotPermitted(String),
InternalError(String),
UnknownStatus(u8, String),
/// The host has no pinned server key, so the session cannot be
/// authenticated (sec 4). A GUI routes this to pinning; an auth failure
/// is terminal.
NotPinned { host: String },
/// A pinned server presented a different key (sec 4): a hard abort, the
/// pin is the entire trust model.
PinMismatch {
host: String,
pinned: [u8; KEY_LEN],
presented: [u8; KEY_LEN],
},
/// A contact's key changed with no valid rotation chain (sec 7): the
/// user confirms out of band and imports, rather than clicking through.
KeyChanged {
address: String,
known: [u8; KEY_LEN],
offered: [u8; KEY_LEN],
},
/// The store has no account yet; registering or restoring creates one.
NotRegistered,
/// The rotation index would exceed the chain limit (sec 2).
ChainLimit { index: u32 },
/// The store was written by a schema this build cannot read; a host
/// decides when to migrate, not the library.
SchemaVersion { found: i32, expected: i32 },
/// The server could not be reached at all.
Unreachable {
host: String,
port: u16,
source: std::io::Error,
},
Storage(rusqlite::Error),
Noise(snow::Error),
Io(std::io::Error),
/// Validation prose without a decision-relevant variant of its own.
Other(String),
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Malformed(what) => write!(f, "{what} failed: malformed (1)"),
Self::BadVersion(what) => write!(f, "{what} failed: bad version (2)"),
Self::UnknownUser(what) => write!(f, "{what} failed: unknown user (3)"),
Self::AuthRequired(what) => write!(f, "{what} failed: auth required (4)"),
Self::AuthFailed(what) => write!(f, "{what} failed: auth failed (5)"),
Self::QuotaExceeded(what) => write!(f, "{what} failed: quota exceeded (6)"),
Self::TooLarge(what) => write!(f, "{what} failed: too large (7)"),
Self::RateLimited(what) => write!(f, "{what} failed: rate limited (8)"),
Self::NotPermitted(what) => write!(f, "{what} failed: not permitted (9)"),
Self::InternalError(what) => write!(f, "{what} failed: internal error (10)"),
Self::UnknownStatus(status, what) => {
write!(f, "{what} failed: unknown status {status}")
}
Self::NotPinned { host } => write!(f, "no pinned key for {host}"),
Self::PinMismatch {
host,
pinned,
presented,
} => write!(
f,
"{host} presented a different key than the one pinned\n pinned: {}\n presented: {}",
b32(pinned),
b32(presented)
),
Self::KeyChanged {
address,
known,
offered,
} => write!(
f,
"{address} presents a different key with no valid rotation chain.\n known: {}\n offered: {}",
b32(known),
b32(offered)
),
Self::NotRegistered => write!(f, "not registered"),
Self::ChainLimit { index } => write!(
f,
"rotation index {index} exceeds the chain limit of {MAX_CHAIN}"
),
Self::SchemaVersion { found, expected } => write!(
f,
"store schema version {found} is not this build's {expected}"
),
Self::Unreachable { host, port, source } => {
write!(f, "cannot reach {host}:{port}: {source}")
}
Self::Storage(e) => write!(f, "storage error: {e}"),
Self::Noise(e) => write!(f, "noise error: {e}"),
Self::Io(e) => write!(f, "{e}"),
Self::Other(msg) => write!(f, "{msg}"),
}
}
}
impl std::error::Error for Error {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Unreachable { source, .. } => Some(source),
Self::Storage(e) => Some(e),
Self::Noise(e) => Some(e),
Self::Io(e) => Some(e),
_ => None,
}
}
}
impl From<rusqlite::Error> for Error {
fn from(e: rusqlite::Error) -> Self {
Error::Storage(e)
}
}
impl From<std::io::Error> for Error {
fn from(e: std::io::Error) -> Self {
Error::Io(e)
}
}
impl From<snow::Error> for Error {
fn from(e: snow::Error) -> Self {
Error::Noise(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 carrying the status code. 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<(), Error> {
if status == 0 {
return Ok(());
}
let what = what.to_string();
Err(match status {
1 => Error::Malformed(what),
2 => Error::BadVersion(what),
3 => Error::UnknownUser(what),
4 => Error::AuthRequired(what),
5 => Error::AuthFailed(what),
6 => Error::QuotaExceeded(what),
7 => Error::TooLarge(what),
8 => Error::RateLimited(what),
9 => Error::NotPermitted(what),
10 => Error::InternalError(what),
status => Error::UnknownStatus(status, what),
})
}

View file

@ -1,8 +1,10 @@
//! `fumi` — the Smol Mail client (SPEC.md 1.2).
//! `fumi-core` — the Smol Mail client library (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).
//! TCP connection (or the Reticulum carrier behind the `rns` feature). The
//! `fumi` crate wraps this library as a command-line client; GUI hosts embed
//! this one directly.
pub mod account;
pub mod address;
@ -15,3 +17,6 @@ pub mod rns;
pub mod store;
pub mod tcp;
pub mod transport;
#[cfg(test)]
mod vectors;

View file

@ -8,7 +8,7 @@ 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::error::Error;
use crate::transport::{
ENVELOPE_HEADER, ENVELOPE_MAGIC, ENVELOPE_MIN, ENVELOPE_VERSION, LABEL_ID, LABEL_MSG,
LABEL_SEAL, PAYLOAD_HEADER, SIG_LEN,
@ -46,7 +46,7 @@ pub fn seal(
body: &[u8],
when: i64,
pad: bool,
) -> Result<Vec<u8>, SmolError> {
) -> Result<Vec<u8>, Error> {
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
@ -61,7 +61,7 @@ fn seal_with_esk(
when: i64,
pad: bool,
esk: [u8; KEY_LEN],
) -> Result<Vec<u8>, SmolError> {
) -> Result<Vec<u8>, Error> {
let x_recipient = ed25519_to_x25519(recipient)?;
let eph = x25519_dalek::StaticSecret::from(esk);
let epk = x25519_dalek::PublicKey::from(&eph);
@ -107,7 +107,7 @@ fn seal_with_esk(
aad: &aad,
},
)
.map_err(|_| SmolError::new("encryption failed"))?;
.map_err(|_| Error::Other("encryption failed".into()))?;
let mut envelope = aad;
envelope.extend_from_slice(&sealed);
Ok(envelope)
@ -118,15 +118,15 @@ fn seal_with_esk(
/// 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> {
pub fn unseal(identities: &[Identity], envelope: &[u8], now: i64) -> Result<Opened, Error> {
if envelope.len() < ENVELOPE_MIN {
return Err(SmolError::new("envelope too short"));
return Err(Error::Other("envelope too short".into()));
}
if &envelope[..4] != ENVELOPE_MAGIC {
return Err(SmolError::new("not a Smol Mail envelope"));
return Err(Error::Other("not a Smol Mail envelope".into()));
}
if envelope[4] != ENVELOPE_VERSION {
return Err(SmolError::new(format!(
return Err(Error::Other(format!(
"unsupported envelope version {}",
envelope[4]
)));
@ -139,7 +139,7 @@ pub fn unseal(identities: &[Identity], envelope: &[u8], now: i64) -> Result<Open
.iter()
.find(|i| ct_eq(&i.pk(), &to))
.ok_or_else(|| {
SmolError::new(format!(
Error::Other(format!(
"addressed to {}…, not one of our keys",
&b32(&to)[..16]
))
@ -159,17 +159,17 @@ pub fn unseal(identities: &[Identity], envelope: &[u8], now: i64) -> Result<Open
aad: &envelope[..ENVELOPE_HEADER],
},
)
.map_err(|_| SmolError::new("decryption failed: wrong key or corrupt envelope"))?;
.map_err(|_| Error::Other("decryption failed: wrong key or corrupt envelope".into()))?;
if plaintext.len() < PAYLOAD_HEADER + SIG_LEN || plaintext[0] != ENVELOPE_VERSION {
return Err(SmolError::new("unsupported payload version"));
return Err(Error::Other("unsupported payload version".into()));
}
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"));
return Err(Error::Other("payload body length exceeds the payload".into()));
}
let body = &plaintext[PAYLOAD_HEADER..PAYLOAD_HEADER + body_len];
let signature = &plaintext[PAYLOAD_HEADER + body_len..PAYLOAD_HEADER + body_len + SIG_LEN];
@ -181,10 +181,10 @@ pub fn unseal(identities: &[Identity], envelope: &[u8], now: i64) -> Result<Open
signed.extend_from_slice(header);
signed.extend_from_slice(body);
if !verify_sig(&sender, signature, &signed) {
return Err(SmolError::new("signature does not verify"));
return Err(Error::Other("signature does not verify".into()));
}
if when > now + MAX_SKEW {
return Err(SmolError::new("payload is dated in the future"));
return Err(Error::Other("payload is dated in the future".into()));
}
Ok(Opened {
sender,
@ -276,11 +276,6 @@ mod tests {
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))
@ -288,58 +283,46 @@ mod tests {
#[test]
fn message_id_matches_the_reference_client() {
let envelope = unb32(REF_ENVELOPE_B32).unwrap();
let v = crate::vectors::load();
let envelope = unb32(&v.envelope.padded_b32).unwrap();
assert_eq!(envelope.len(), 1109);
assert_eq!(
data_encoding::HEXLOWER.encode(&message_id(&envelope)),
REF_MESSAGE_ID
v.envelope.message_id_hex
);
}
#[test]
fn unseals_an_envelope_from_the_reference_client() {
let envelope = unb32(REF_ENVELOPE_B32).unwrap();
let v = crate::vectors::load();
let envelope = unb32(&v.envelope.padded_b32).unwrap();
let opened = unseal(
&[identity(0), identity(1), identity(2)],
&envelope,
1700000100,
v.envelope.when + 100,
)
.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");
assert_eq!(b32(&opened.sender), v.envelope.sender_pk_b32);
assert_eq!(opened.time, v.envelope.when);
assert_eq!(opened.body, v.envelope.body.as_bytes());
}
#[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());
let v = crate::vectors::load();
let e = &v.envelope;
let sender = identity(e.sender_index);
let recipient = identity(e.recipient_index).pk();
let esk: [u8; 32] = v.hex(&e.ephemeral_hex).try_into().unwrap();
let envelope =
seal_with_esk(&sender, &recipient, e.body.as_bytes(), e.when, true, esk).unwrap();
assert_eq!(envelope, unb32(&e.padded_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();
let plain =
seal_with_esk(&sender, &recipient, e.body.as_bytes(), e.when, false, esk).unwrap();
assert_eq!(plain.len(), 226);
assert_eq!(plain, unb32(REF_ENVELOPE_NOPAD_B32).unwrap());
assert_eq!(plain, unb32(&e.unpadded_b32).unwrap());
}
#[test]

View file

@ -3,13 +3,13 @@
//! Every failure here is a local error — no path, a dead link, a rejected
//! resource, a timeout — and MUST NOT be reported as a status code (upstream
//! spec sec 13.5), so the wrapper turns every shim return code into an
//! `SmolError` message and never into a `Response`.
//! `Error` message and never into a `Response`.
use std::ffi::CString;
use std::time::Duration;
use crate::crypto::KEY_LEN;
use crate::error::SmolError;
use crate::error::Error;
use crate::rns::RnsConfig;
use crate::transport::MAX_FRAME;
@ -46,7 +46,7 @@ mod inner {
}
/// Starts the Reticulum stack: storage path, UDP interface, loop thread.
pub fn start(config: &RnsConfig) -> Result<(), SmolError> {
pub fn start(config: &RnsConfig) -> Result<(), Error> {
let storage_dir =
CString::new(config.storage_dir.as_str()).expect("storage path has no interior NUL");
let listen_host =
@ -69,7 +69,7 @@ pub fn start(config: &RnsConfig) -> Result<(), SmolError> {
};
match rc {
0 => Ok(()),
_ => Err(SmolError::new(format!(
_ => Err(Error::Other(format!(
"RNS shim failed to start (code {rc}); is the UDP port free?"
))),
}
@ -77,7 +77,7 @@ pub fn start(config: &RnsConfig) -> Result<(), SmolError> {
/// Requests a path, recalls the identity, opens a link and waits for ACTIVE,
/// returning the 16-byte link id the bind values derive from.
pub fn connect(destination: &[u8; 16], timeout: Duration) -> Result<[u8; 16], SmolError> {
pub fn connect(destination: &[u8; 16], timeout: Duration) -> Result<[u8; 16], Error> {
let hex = data_encoding::HEXLOWER.encode(destination);
let mut link_id = [0u8; 16];
let rc = unsafe {
@ -89,17 +89,17 @@ pub fn connect(destination: &[u8; 16], timeout: Duration) -> Result<[u8; 16], Sm
};
match rc {
0 => Ok(link_id),
-1 => Err(SmolError::new(format!(
-1 => Err(Error::Other(format!(
"no path to {hex}; it may be unreachable or has never announced \
(upstream spec 13.1, 13.3)"
))),
-2 => Err(SmolError::new(format!(
-2 => Err(Error::Other(format!(
"path known but identity not yet learned for {hex}; try again shortly"
))),
-3 => Err(SmolError::new(format!(
-3 => Err(Error::Other(format!(
"could not establish a link to {hex}"
))),
_ => Err(SmolError::new(format!(
_ => Err(Error::Other(format!(
"RNS connect to {hex} failed (code {rc})"
))),
}
@ -107,7 +107,7 @@ pub fn connect(destination: &[u8; 16], timeout: Duration) -> Result<[u8; 16], Sm
/// Sends one `op u8 || body` request and returns the `status u8 || payload`
/// response.
pub fn request(request: &[u8], timeout: Duration) -> Result<Vec<u8>, SmolError> {
pub fn request(request: &[u8], timeout: Duration) -> Result<Vec<u8>, Error> {
// A record is returned whole even when it alone exceeds the server's
// fetch budget (upstream spec sec 6.1), and a mailbox shared with TCP
// can hold a 768 KiB envelope (sec 13.7), so the response buffer is the
@ -130,17 +130,17 @@ pub fn request(request: &[u8], timeout: Duration) -> Result<Vec<u8>, SmolError>
out.truncate(out_len);
Ok(out)
}
-1 => Err(SmolError::new("no RNS link is open")),
-2 => Err(SmolError::new("failed to send the request over the link")),
-3 | -4 => Err(SmolError::new(
-1 => Err(Error::Other("no RNS link is open".into())),
-2 => Err(Error::Other("failed to send the request over the link".into())),
-3 | -4 => Err(Error::Other(
"request failed: no response (closed link, rejected transfer, or timeout \
-- not a status code, upstream spec 13.5)",
)),
-5 => Err(SmolError::new("malformed response from server")),
-6 => Err(SmolError::new(
"response larger than one application frame; refusing to truncate it",
)),
_ => Err(SmolError::new(format!("RNS request failed (code {rc})"))),
-- not a status code, upstream spec 13.5)".into(),
)),
-5 => Err(Error::Other("malformed response from server".into())),
-6 => Err(Error::Other(
"response larger than one application frame; refusing to truncate it".into(),
)),
_ => Err(Error::Other(format!("RNS request failed (code {rc})"))),
}
}

View file

@ -13,7 +13,7 @@ pub mod transport;
use std::path::Path;
use std::sync::{Mutex, OnceLock};
use crate::error::SmolError;
use crate::error::Error;
/// Carrier configuration, set once by the CLI and consumed on first use.
#[derive(Clone, Debug)]
@ -54,7 +54,7 @@ pub fn configure(config: RnsConfig) {
}
/// Starts the Reticulum stack at most once, on the first RNS dial.
pub fn ensure_started() -> Result<(), SmolError> {
pub fn ensure_started() -> Result<(), Error> {
if STARTED.get().is_some() {
return Ok(());
}
@ -64,7 +64,7 @@ pub fn ensure_started() -> Result<(), SmolError> {
}
let config = CONFIG.get().cloned().unwrap_or_default();
std::fs::create_dir_all(Path::new(&config.storage_dir))
.map_err(|e| SmolError::new(format!("cannot create {}: {e}", config.storage_dir)))?;
.map_err(|e| Error::Other(format!("cannot create {}: {e}", config.storage_dir)))?;
ffi::start(&config)?;
let _ = STARTED.set(());
Ok(())

View file

@ -9,7 +9,7 @@
use std::time::Duration;
use crate::error::SmolError;
use crate::error::Error;
use crate::rns::{ensure_started, ffi};
use crate::transport::{Response, Transport, TransportBindValues};
@ -30,7 +30,7 @@ impl RnsTransport {
/// Starts the carrier if needed, requests a path, and opens a fresh
/// link — never reused across authentications, since link_id is what
/// stops an AUTH replaying on another link (upstream spec sec 13.6).
pub fn connect(destination: &[u8; 16], timeout: Duration) -> Result<RnsTransport, SmolError> {
pub fn connect(destination: &[u8; 16], timeout: Duration) -> Result<RnsTransport, Error> {
ensure_started()?;
let link_id = ffi::connect(destination, timeout)?;
Ok(RnsTransport {
@ -51,7 +51,7 @@ impl Transport for RnsTransport {
/// (upstream spec sec 13.5): every operation body and response payload
/// is reused byte for byte from SPEC.md sec 6.1, with only the `length
/// u32` gone, because Reticulum delimits messages itself.
fn request(&mut self, op: u8, body: &[u8]) -> Result<Response, SmolError> {
fn request(&mut self, op: u8, body: &[u8]) -> Result<Response, Error> {
let mut wire = Vec::with_capacity(1 + body.len());
wire.push(op);
wire.extend_from_slice(body);
@ -61,7 +61,7 @@ impl Transport for RnsTransport {
let status = response
.first()
.copied()
.ok_or_else(|| SmolError::new("empty response from server"))?;
.ok_or_else(|| Error::Other("empty response from server".into()))?;
Ok(Response {
status,
body: response[1..].to_vec(),

View file

@ -1,7 +1,11 @@
//! 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.
//! The handle is `Send + Sync`: every operation locks an inner mutex, so a
//! GUI can hold one `Store` behind an executor and read it (for incremental
//! fetch progress) while a fetch runs on another thread.
use std::path::Path;
use std::sync::{Mutex, MutexGuard};
use std::time::{SystemTime, UNIX_EPOCH};
use rusqlite::{params, Connection};
@ -9,7 +13,7 @@ use rusqlite::{params, Connection};
use crate::account::Account;
use crate::address::Address;
use crate::crypto::ct_eq;
use crate::error::SmolError;
use crate::error::Error;
use crate::transport::{ID_LEN, KEY_LEN, TIER_MAIN, TIER_REQUESTS};
pub fn now() -> i64 {
@ -67,26 +71,52 @@ pub struct Stored {
}
pub struct Store {
db: Connection,
db: Mutex<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>);
/// The store's schema version, in SQLite's `user_version`. A store written
/// by a newer fumi is refused rather than misread; within a version the
/// schema is stable, and a bump comes with a documented migration.
pub const SCHEMA_VERSION: i32 = 1;
impl Store {
pub fn open(path: &Path) -> Result<Store, SmolError> {
pub fn open(path: &Path) -> Result<Store, Error> {
let db = Connection::open(path)?;
db.execute_batch(SCHEMA)?;
Ok(Store { db })
// WAL keeps concurrent readers cheap while a writer commits, and a
// short busy timeout absorbs the contention the mutex cannot (a
// second connection in the same process, or a CLI run alongside).
db.pragma_update(None, "journal_mode", "WAL")?;
db.busy_timeout(std::time::Duration::from_secs(5))?;
let version: i32 = db.query_row("PRAGMA user_version", [], |row| row.get(0))?;
if version == 0 {
db.execute_batch(SCHEMA)?;
db.pragma_update(None, "user_version", SCHEMA_VERSION)?;
} else if version != SCHEMA_VERSION {
return Err(Error::SchemaVersion {
found: version,
expected: SCHEMA_VERSION,
});
}
Ok(Store {
db: Mutex::new(db),
})
}
fn one<T>(&self, sql: &str, params: &[&dyn rusqlite::ToSql]) -> Result<Option<T>, SmolError>
/// One lock acquisition; the guard's lifetime is the operation's.
fn db(&self) -> MutexGuard<'_, Connection> {
self.db.lock().expect("store mutex poisoned")
}
fn one<T>(&self, sql: &str, params: &[&dyn rusqlite::ToSql]) -> Result<Option<T>, Error>
where
T: rusqlite::types::FromSql,
{
Ok(
match self.db.query_row(sql, params, |row| row.get::<_, T>(0)) {
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()),
@ -95,8 +125,8 @@ impl Store {
}
/// 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(
pub fn account(&self) -> Result<Option<Address>, Error> {
let row = self.db().query_row(
"SELECT username, host, port, scheme FROM state WHERE id = 1",
[],
|row| {
@ -124,19 +154,19 @@ impl Store {
format!("{username}@{host}")
};
let mut addr = Address::parse(&text)
.map_err(|e| SmolError(format!("stored account is not parseable: {e}")))?;
.map_err(|e| Error::Other(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> {
pub fn set_account(&self, addr: &Address) -> Result<(), Error> {
let scheme = match addr.scheme {
crate::address::Scheme::Tcp => "tcp",
crate::address::Scheme::Rns => "rns",
};
self.db
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],
@ -145,14 +175,14 @@ impl Store {
Ok(())
}
pub fn rotations(&self) -> Result<u32, SmolError> {
pub fn rotations(&self) -> Result<u32, Error> {
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
pub fn set_rotations(&self, index: u32) -> Result<(), Error> {
self.db()
.execute(
"UPDATE state SET rotations = ?1 WHERE id = 1",
params![index],
@ -161,15 +191,15 @@ impl Store {
Ok(())
}
pub fn cursor(&self) -> Result<(i64, [u8; ID_LEN]), SmolError> {
pub fn cursor(&self) -> Result<(i64, [u8; ID_LEN]), Error> {
let (after_time, after_id): (i64, Vec<u8>) = self
.db
.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)?;
.map_err(Error::from)?;
let after_id: [u8; ID_LEN] = if after_id.len() == ID_LEN {
after_id.try_into().unwrap()
} else {
@ -178,8 +208,8 @@ impl Store {
Ok((after_time, after_id))
}
pub fn set_cursor(&self, after_time: i64, after_id: &[u8; ID_LEN]) -> Result<(), SmolError> {
self.db
pub fn set_cursor(&self, after_time: i64, after_id: &[u8; ID_LEN]) -> Result<(), Error> {
self.db()
.execute(
"UPDATE state SET after_time = ?1, after_id = ?2 WHERE id = 1",
params![after_time, after_id],
@ -190,15 +220,15 @@ impl Store {
/// 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> {
pub fn sync_ok(&self) -> Result<bool, Error> {
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
pub fn set_sync_ok(&self, ok: bool) -> Result<(), Error> {
self.db()
.execute(
"UPDATE state SET sync_ok = ?1 WHERE id = 1",
params![ok as i64],
@ -207,15 +237,15 @@ impl Store {
Ok(())
}
pub fn server_pin(&self, host: &str) -> Result<Option<[u8; KEY_LEN]>, SmolError> {
pub fn server_pin(&self, host: &str) -> Result<Option<[u8; KEY_LEN]>, Error> {
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
pub fn pin_server(&self, host: &str, key: &[u8; KEY_LEN]) -> Result<(), Error> {
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",
@ -225,8 +255,8 @@ impl Store {
Ok(())
}
pub fn contact(&self, address: &str) -> Result<Option<([u8; KEY_LEN], bool)>, SmolError> {
let row = self.db.query_row(
pub fn contact(&self, address: &str) -> Result<Option<([u8; KEY_LEN], bool)>, Error> {
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)?)),
@ -235,7 +265,7 @@ impl Store {
Ok((identity, verified)) => Ok(Some((
identity
.try_into()
.map_err(|_| SmolError::new("stored contact identity is not 32 bytes"))?,
.map_err(|_| Error::Other("stored contact identity is not 32 bytes".into()))?,
verified != 0,
))),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
@ -248,8 +278,8 @@ impl Store {
address: &str,
identity: &[u8; KEY_LEN],
verified: bool,
) -> Result<(), SmolError> {
self.db
) -> Result<(), Error> {
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",
@ -260,8 +290,9 @@ impl Store {
}
/// Every contact with its acceptance state, for `fumi contacts`.
pub fn contact_rows(&self) -> Result<Vec<ContactRow>, SmolError> {
let mut stmt = self.db.prepare(
pub fn contact_rows(&self) -> Result<Vec<ContactRow>, Error> {
let db = self.db();
let mut stmt = 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",
)?;
@ -284,13 +315,13 @@ impl Store {
}
/// 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> {
pub fn token_set(&self, account: &Account) -> Result<(u8, Vec<[u8; 32]>), Error> {
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 db = self.db();
let mut stmt =
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<_>, _>>()?;
@ -313,8 +344,9 @@ impl Store {
&self,
sender: &[u8],
reply_to: Option<&str>,
) -> Result<Option<String>, SmolError> {
let mut stmt = self.db.prepare("SELECT address, identity FROM contacts")?;
) -> Result<Option<String>, Error> {
let db = self.db();
let mut stmt = db.prepare("SELECT address, identity FROM contacts")?;
let rows = stmt
.query_map([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, Vec<u8>>(1)?))
@ -338,8 +370,8 @@ impl Store {
/// 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
pub fn accept(&self, address: &str, identity: &[u8; KEY_LEN]) -> Result<(), Error> {
self.db()
.execute(
"INSERT INTO accepted (address, identity, active, added_at) VALUES (?1, ?2, 1, ?3) \
ON CONFLICT (address) DO UPDATE SET active = 1",
@ -350,8 +382,8 @@ impl Store {
Ok(())
}
pub fn block(&self, address: &str) -> Result<bool, SmolError> {
let changed = self.db.execute(
pub fn block(&self, address: &str) -> Result<bool, Error> {
let changed = self.db().execute(
"UPDATE accepted SET active = 0 WHERE address = ?1",
params![address],
)?;
@ -360,7 +392,7 @@ impl Store {
/// 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> {
pub fn accepted_identity(&self, address: &str) -> Result<Option<[u8; KEY_LEN]>, Error> {
let identity: Option<Vec<u8>> = self.one(
"SELECT identity FROM accepted WHERE address = ?1 AND active = 1",
&[&address],
@ -369,14 +401,14 @@ impl Store {
}
/// 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> {
pub fn token_of(&self, address: &str) -> Result<Option<[u8; 32]>, Error> {
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
pub fn save_token(&self, address: &str, token: &[u8; 32]) -> Result<(), Error> {
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",
@ -386,7 +418,7 @@ impl Store {
Ok(())
}
pub fn seen(&self, id: &[u8; ID_LEN]) -> Result<bool, SmolError> {
pub fn seen(&self, id: &[u8; ID_LEN]) -> Result<bool, Error> {
Ok(self
.one::<i64>("SELECT 1 FROM seen WHERE id = ?1", &[id])?
.is_some())
@ -398,19 +430,19 @@ impl Store {
envelope: &[u8],
received_at: i64,
requests_tier: bool,
) -> Result<(), SmolError> {
) -> Result<(), Error> {
let tier = if requests_tier {
TIER_REQUESTS
} else {
TIER_MAIN
};
self.db
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
self.db()
.execute(
"INSERT OR IGNORE INTO seen (id, at) VALUES (?1, ?2)",
params![id, received_at],
@ -425,8 +457,8 @@ impl Store {
recipient: &str,
envelope: &[u8],
sent_at: i64,
) -> Result<(), SmolError> {
self.db
) -> Result<(), Error> {
self.db()
.execute(
"INSERT OR IGNORE INTO sent (id, recipient, envelope, sent_at) VALUES (?1, ?2, ?3, ?4)",
params![id, recipient, envelope, sent_at],
@ -437,7 +469,7 @@ impl Store {
/// 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> {
pub fn mail(&self, folder: &str) -> Result<Vec<Stored>, Error> {
let (sql, tier): (&str, Option<i8>) = match folder {
"sent" => (
"SELECT id, envelope, sent_at, recipient FROM sent ORDER BY sent_at, id",
@ -458,7 +490,8 @@ impl Store {
Some(TIER_MAIN as i8),
),
};
let mut stmt = self.db.prepare(sql)?;
let db = self.db();
let mut stmt = db.prepare(sql)?;
let rows = match tier {
Some(t) => stmt
.query_map(params![t], row_stored)?
@ -650,4 +683,42 @@ mod tests {
let _ = identity_seed(&master(), n as u32);
}
}
#[test]
fn schema_is_versioned_and_refuses_a_newer_store() {
let path = std::env::temp_dir().join(format!(
"fumi-store-schema-{}-{}.db",
std::process::id(),
now()
));
drop(Store::open(&path).expect("open store"));
let db = Connection::open(&path).unwrap();
let version: i32 = db.query_row("PRAGMA user_version", [], |row| row.get(0)).unwrap();
assert_eq!(version, SCHEMA_VERSION);
// A store from the future is refused, not misread.
db.pragma_update(None, "user_version", SCHEMA_VERSION + 1).unwrap();
drop(db);
match Store::open(&path) {
Err(Error::SchemaVersion { found, expected }) => {
assert_eq!((found, expected), (SCHEMA_VERSION + 1, SCHEMA_VERSION));
}
_ => panic!("a newer schema must be refused"),
}
}
#[test]
fn store_is_shareable_across_threads() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<Store>();
let store = temp_store("threads");
std::thread::scope(|scope| {
for _ in 0..4 {
scope.spawn(|| {
store.set_rotations(7).unwrap();
store.rotations().unwrap();
});
}
});
assert_eq!(store.rotations().unwrap(), 7);
}
}

View file

@ -7,8 +7,8 @@ use std::time::Duration;
use snow::{Builder, TransportState};
use crate::crypto::{b32, ct_eq, KEY_LEN};
use crate::error::SmolError;
use crate::crypto::{ct_eq, KEY_LEN};
use crate::error::Error;
use crate::transport::{
Response, Transport, TransportBindValues, MAX_FRAME, NOISE_PARAMS, NOISE_PAYLOAD, PROLOGUE,
};
@ -34,10 +34,12 @@ impl TcpTransport {
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}"))?;
) -> Result<TcpTransport, Error> {
let stream = TcpStream::connect((host, port)).map_err(|source| Error::Unreachable {
host: host.to_string(),
port,
source,
})?;
stream.set_read_timeout(Some(Duration::from_secs(timeout)))?;
stream.set_write_timeout(Some(Duration::from_secs(timeout)))?;
let mut stream = stream;
@ -54,25 +56,25 @@ impl TcpTransport {
stream.read_exact(&mut message)?;
noise.read_message(&message, &mut buf)?;
if !noise.is_handshake_finished() {
anyhow::bail!("handshake did not complete");
return Err(Error::Other("handshake did not complete".into()));
}
let server_static: [u8; KEY_LEN] = noise
.get_remote_static()
.ok_or_else(|| anyhow::anyhow!("server sent no static key"))?
.try_into()?;
.ok_or_else(|| Error::Other("server sent no static key".into()))?
.try_into()
.map_err(|_| Error::Other("server static key is not 32 bytes".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)
));
return Err(Error::PinMismatch {
host: host.to_string(),
pinned,
presented: server_static,
});
}
}
@ -96,7 +98,7 @@ impl TcpTransport {
&self.host
}
fn read_noise(&mut self) -> Result<Vec<u8>, SmolError> {
fn read_noise(&mut self) -> Result<Vec<u8>, Error> {
let len = read_u16_len(&mut self.stream)?;
let mut ciphertext = vec![0u8; len];
self.stream.read_exact(&mut ciphertext)?;
@ -106,7 +108,7 @@ impl TcpTransport {
Ok(plaintext)
}
fn write_noise(&mut self, payload: &[u8]) -> Result<(), SmolError> {
fn write_noise(&mut self, payload: &[u8]) -> Result<(), Error> {
let mut packet = vec![0u8; payload.len() + 16];
let n = self.noise.write_message(payload, &mut packet)?;
packet.truncate(n);
@ -119,10 +121,10 @@ 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> {
fn request(&mut self, op: u8, body: &[u8]) -> Result<Response, Error> {
let length = 1 + body.len();
if length > MAX_FRAME {
return Err(SmolError::new("request exceeds the maximum frame size"));
return Err(Error::Other("request exceeds the maximum frame size".into()));
}
let mut frame = Vec::with_capacity(4 + length);
frame.extend_from_slice(&(length as u32).to_be_bytes());
@ -138,7 +140,7 @@ impl Transport for TcpTransport {
}
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!(
return Err(Error::Other(format!(
"server sent a frame of length {length}"
)));
}

View file

@ -6,7 +6,7 @@
// Used only by the RNS bind values and their tests.
#[cfg(any(test, feature = "rns"))]
use crate::crypto::sha256;
use crate::error::SmolError;
use crate::error::Error;
pub const NOISE_PARAMS: &str = "Noise_NX_25519_ChaChaPoly_SHA256";
pub const PROLOGUE: &[u8] = b"smolmail/1";
@ -71,30 +71,30 @@ impl<'a> Reader<'a> {
Reader { buf, pos: 0 }
}
pub fn take(&mut self, n: usize) -> Result<&'a [u8], SmolError> {
pub fn take(&mut self, n: usize) -> Result<&'a [u8], Error> {
if self.pos + n > self.buf.len() {
return Err(SmolError::new("truncated response from server"));
return Err(Error::Other("truncated response from server".into()));
}
let out = &self.buf[self.pos..self.pos + n];
self.pos += n;
Ok(out)
}
pub fn u8(&mut self) -> Result<u8, SmolError> {
pub fn u8(&mut self) -> Result<u8, Error> {
Ok(self.take(1)?[0])
}
pub fn u16(&mut self) -> Result<u16, SmolError> {
pub fn u16(&mut self) -> Result<u16, Error> {
let b = self.take(2)?;
Ok(u16::from_be_bytes([b[0], b[1]]))
}
pub fn u32(&mut self) -> Result<u32, SmolError> {
pub fn u32(&mut self) -> Result<u32, Error> {
let b = self.take(4)?;
Ok(u32::from_be_bytes(b.try_into().unwrap()))
}
pub fn i64(&mut self) -> Result<i64, SmolError> {
pub fn i64(&mut self) -> Result<i64, Error> {
let b = self.take(8)?;
Ok(i64::from_be_bytes(b.try_into().unwrap()))
}
@ -105,9 +105,9 @@ impl<'a> Reader<'a> {
out
}
pub fn done(&self) -> Result<(), SmolError> {
pub fn done(&self) -> Result<(), Error> {
if self.pos != self.buf.len() {
return Err(SmolError::new("trailing bytes in response"));
return Err(Error::Other("trailing bytes in response".into()));
}
Ok(())
}
@ -127,11 +127,11 @@ pub struct TransportBindValues {
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> {
pub fn tcp(handshake_hash: &[u8], server_static: &[u8; KEY_LEN]) -> Result<Self, Error> {
Ok(Self {
h: handshake_hash
.try_into()
.map_err(|_| anyhow::anyhow!("handshake hash not 32 bytes"))?,
.map_err(|_| Error::Other("handshake hash not 32 bytes".into()))?,
server_static: *server_static,
})
}
@ -155,7 +155,7 @@ impl TransportBindValues {
/// 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 request(&mut self, op: u8, body: &[u8]) -> Result<Response, Error>;
fn bind(&self) -> &TransportBindValues;
fn pinned(&self) -> bool;
fn close(&mut self);
@ -165,9 +165,6 @@ pub trait Transport {
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,
@ -176,17 +173,21 @@ mod tests {
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);
// The SHA-256("smolmail/1 bind" || destination || link_id) formula
// of RNS.md sec 13.6, shared with bunshin, whose server verifies
// what this client produces.
let v = crate::vectors::load();
let rb = &v.rns_bind;
let destination: [u8; 16] = v.hex(&rb.destination_hex).try_into().unwrap();
let link_id: [u8; 16] = v.hex(&rb.link_id_hex).try_into().unwrap();
let bind = TransportBindValues::rns(&destination, &link_id);
assert_eq!(data_encoding::HEXLOWER.encode(&bind.h), rb.h_hex);
assert_eq!(
data_encoding::HEXLOWER.encode(&bind.server_static),
SERVER_STATIC_HEX
rb.server_static_hex
);
}

119
core/src/vectors.rs Normal file
View file

@ -0,0 +1,119 @@
//! The committed reference vectors (`vectors.json`) as the tests' source of
//! truth, so the file and the derivations cannot drift apart. Language ports
//! and FFI bindings pin the same operations against the same file without
//! regenerating anything from the reference stack.
use serde::Deserialize;
use crate::crypto::KEY_LEN;
#[derive(Deserialize)]
pub struct Vectors {
/// The master every derivation below starts from, hex-encoded.
pub master_hex: String,
/// `identity_seed(master, n)` per rotation index, base32.
pub identity_seeds_b32: std::collections::BTreeMap<String, String>,
/// `Identity::from_seed(seed_n).pk()` per rotation index, base32.
pub identity_pks_b32: std::collections::BTreeMap<String, String>,
/// The X25519 half per rotation index, base32.
pub identity_xprivs_b32: std::collections::BTreeMap<String, String>,
pub accept_key_b32: String,
/// `Account::new(master, 0).token_for(pk_1)`, base32.
pub accept_token_for_pk1_b32: String,
pub rotation_cert: RotationCertVector,
pub accept_mac: AcceptMacVector,
pub envelope: EnvelopeVector,
pub hkdf_rfc5869_case1: HkdfVector,
pub hmac_rfc4231_case1: HmacVector,
/// `ed25519_to_x25519(pk_0)`, base32 — libsodium's pk_to_curve25519.
pub ed25519_to_x25519_pk0_b32: String,
pub rns_bind: RnsBindVector,
}
#[derive(Deserialize)]
pub struct RotationCertVector {
pub username: String,
pub old_index: u32,
pub new_index: u32,
pub when: i64,
pub cert_hex: String,
}
#[derive(Deserialize)]
pub struct AcceptMacVector {
/// The token is `token_for(pk_1)` over this message id, base32.
pub message_id_b32: String,
pub mac_hex: String,
}
#[derive(Deserialize)]
pub struct EnvelopeVector {
pub sender_index: u32,
pub recipient_index: u32,
pub when: i64,
/// The fixed ephemeral scalar the reference client used, hex.
pub ephemeral_hex: String,
pub padded_b32: String,
pub unpadded_b32: String,
pub message_id_hex: String,
pub body: String,
pub sender_pk_b32: String,
}
#[derive(Deserialize)]
pub struct HkdfVector {
pub ikm_hex: String,
pub salt_hex: String,
pub info_hex: String,
/// The first 32 bytes of the 42-byte OKM.
pub okm_hex_32: String,
}
#[derive(Deserialize)]
pub struct HmacVector {
pub key_hex: String,
pub data: String,
pub mac_hex: String,
}
#[derive(Deserialize)]
pub struct RnsBindVector {
pub destination_hex: String,
pub link_id_hex: String,
pub h_hex: String,
pub server_static_hex: String,
}
pub fn load() -> Vectors {
serde_json::from_str(include_str!("../vectors.json")).expect("vectors.json parses")
}
fn b32_at(map: &std::collections::BTreeMap<String, String>, n: u32) -> &str {
map.get(&n.to_string()).expect("vectors.json has the index")
}
impl Vectors {
pub fn master(&self) -> [u8; KEY_LEN] {
data_encoding::HEXLOWER
.decode(self.master_hex.as_bytes())
.expect("master_hex decodes")
.try_into()
.expect("master is 32 bytes")
}
pub fn seed_b32(&self, n: u32) -> &str {
b32_at(&self.identity_seeds_b32, n)
}
pub fn pk_b32(&self, n: u32) -> &str {
b32_at(&self.identity_pks_b32, n)
}
pub fn xpriv_b32(&self, n: u32) -> &str {
b32_at(&self.identity_xprivs_b32, n)
}
pub fn hex(&self, hex: &str) -> Vec<u8> {
data_encoding::HEXLOWER.decode(hex.as_bytes()).expect("hex decodes")
}
}

64
core/vectors.json Normal file
View file

@ -0,0 +1,64 @@
{
"comment": "Reference vectors for Smol Mail derivations, generated from the reference stack (smolmail.py) over master = bytes(range(32)). Unit tests in fumi-core pin the same values from this file; language ports and FFI bindings can verify end to end against it. base32 is RFC 4648 lowercase unpadded.",
"master_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f",
"identity_seeds_b32": {
"0": "6l6wdqp3uwi2a3vn6jnlgjjmvimilnqv6np6t2vytw7wj3jdg6ea",
"1": "ag6k4r74bnc3tt6y623my7wasslq4ulxcxm4anzvizdpg76yfxva",
"2": "r35p2a4e5ffhz6aq5urhv27ch22a34r6i6adnqfwwcnte5te5tua",
"16": "rf6tdr6doeaymope2uj66au542kcmvfnaqc3cmy5jkulacdbnvaq"
},
"identity_pks_b32": {
"0": "3jxsxqtdvxwobge7uxefpbordkulv6bc6ao3h4iusqlov4kgkkja",
"1": "zoieneun2k7n5b65yxfeen4pxvxzq7sclm2kfaa7os3wrwrz4rta",
"2": "2ptpqdy2d4zklwr2xzizhiza7b7jsf6qr2rayzrr2mk2h73ifbrq",
"16": "r6yhcbo733vka7dlbknnlly7aqpoflej4lumeuxq7gxhzzm3juta"
},
"identity_xprivs_b32": {
"0": "ebppfk5yqmf7v5a4seh6f6rkm7z3pnoh7b6hiaa3vs7n5if6hj5a",
"1": "rbhdttkcycpfqfyfsy2mstbykw4t5pngd6dezskrytlore4j5jvq",
"2": "xb4jcmjyappxo7zibvbalaepmvy74jilom43fxvfaj7ev4bpxzqq",
"16": "ucdl3wcmwjlso6um6ipequ3qq6lkx47u4rnwwwy7jo76c2ktwjoq"
},
"accept_key_b32": "fqmnb3vktmrth47m2qjzcey6ue7rbkrvsazytwdayjjgkvcqe2xa",
"accept_token_for_pk1_b32": "bgrtewwtanbfmp5aoxq6rqckn4xcufr3tp4wa67mwsumyj7yjwia",
"rotation_cert": {
"username": "alice",
"old_index": 0,
"new_index": 1,
"when": 1700000001,
"cert_hex": "da6f2bc263adece0989fa5c85785d11aa8baf822f01db3f1149416eaf1465292cb9046928dd2bede87ddc5ca42378fbd6f987e425b34a2801f74b768da39e466000000006553f1010c6171708ec40a9c68b456547a78fe0a512f8e45a0e4a9c094737ef4f84072a997a152e0f16e94bdbd93502521ba812de62fe4c6d19092f0c424bba69337390401b2995c670f1d5720188956b8a1b00d2ac9e5c1665e862e411c5ddeaea43c0721f450527b98f5dd3af29350a314514898a272bc3f78a0b9adb826b46733c605"
},
"accept_mac": {
"message_id_b32": "7tttsuaq4fqwbi5hvp6tigzwk5g73upgqpfxvk26aumwak2zefiq",
"mac_hex": "f709facbe5e032f4c2667c7899e5194397437ed001f2b2be33b82f1cc8d7c93e"
},
"envelope": {
"sender_index": 3,
"recipient_index": 1,
"when": 1700000000,
"ephemeral_hex": "0707070707070707070707070707070707070707070707070707070707070707",
"padded_b32": "kngu6tabzoieneun2k7n5b65yxfeen4pxvxzq7sclm2kfaa7os3wrwrz4rtbhpsp5lvpebgh7uzvr7e4abzbraoroqtyckbcp3dhj437p7uxw3lqwcki4wgjyisjpfr77awo2sz7p3uuke337wdlyubbzvdixnvauhf2q2nihc4cerlf62uyhdnvwnrajxxqo56hht5qwijh3uxbrons62te3xwwpm4yppbiab63nw57rj3yjarzwq2tfz57cphnxnacdbm5xdl66mdygni5js7pcftrmhitpcqjibjdmanyjtl75xtsjkm6dxzcwldqaxngqnfgg37yatihmh5ba76e6ykfiymqcb5nguhws2cviykpwlkuwmxygtgqxpethdvvlhnurf6g4i2u3la2nd3rhgjlr4t227xerr652osqltqzpcjf4m2sidnhcvu5252nijsvxxmgi343ojiffqodjr4fjuglzdwovufeyirkcjywvjdlslcdmjsxg5wiadnvectykxlq3c6qxxqex66z5vjcklzoldtrh3h36l35vqtjtn5rf4yggz2wtbrrdteym2k46obuoxiolj2cnbmj7qihdabg6wdvohl2ctpoc57huengekrprkuyzvogidgwrnqbjhxjgz7mqq6tri24rkts3pqaza6jol6w72zltvxa5itkarkmu6e2vjytpsxbxulnursf52m4abd3w7vxyw6rlxogivj2wafqpylngomzgghlcyoto65ac2n3oavt53xbcdwmgstrdkhlzd2j2wihp2xscizr7pa3n7d3rzuocdc46cwishrvejl3cihfdfnzjhosij5jkr3driehzjdgrx7rl4q43gyjdlvl5zuvvihw2ka75do7vakdghfz4d2jmh7an2bkchnyv2bnwydwei6wsbfc55g7mthvqqwm5ojaza4f37ha4cps37zrbw4fg5q3xpxupfotnvts5ki7zcpvkpdwn4p4voqlpve6czprwfhv7pmmipevr76trg44lkxemhpweqqxtzr4bdsuwt5hroelg2pyv2e3qmq4ibanrqx6ygr4k7b4znwqmtx5urn3wfu2do45bivw6qd55vaaqzeu27shn5xj4mmpfy46iuj2a3vdkwuevlix26uz27ryuovrgnaurhkuowuyme2u2nxwr5z5xoclwy42dw5mg3tynplzz2espoq5ok5amuacjxcy3dm37d547mljiz3ist25pyv7zn33onzovucntivlvrqul23obeb44lqybqnu4m6nbbdpcuniyxyribqq3ukpq2zhvcesdzqqqzqfv2gcwtkzjuj6cbs25finxerdi726ac73hjlodjgwq7wdeqjohirl54rzmgerhqnlr6mummauqad6ehubeuw6sdj2e4sbi4v4obgp7xv7ci3yeqroowi4ygh674lwtlpweaat7xbtvhzltexcknt6bu3abgmmpabcvlrdszpp33w44pw6wogrhl5p5dozx3of3keefu4bxhypg3euh4mzawrxdnjflee3um6y5kmfw5iexaoscy5lu6jupc5sisvvgtxibnjzxuwaifui22b3ng3coacszqxyy7boll4k4qdaehombacj36mbqk3kjmhxpx5etgqr5krbac2z3xufosnwtfln2pauhe2i3kujty2a3r3yn7umiqvomkeg6zyvla4kltclot55c6vpmp4mlisvzngayds67f45hnz7hpgtygjcz2ozzhqk2tq",
"unpadded_b32": "kngu6tabzoieneun2k7n5b65yxfeen4pxvxzq7sclm2kfaa7os3wrwrz4rtbhpsp5lvpebgh7uzvr7e4abzbraoroqtyckbcp3dhj437p7uxw3lqwcki4wgjyisjpfr77awo2sz7p3uuke337wdlyubbzvdixnvauhf2q2nihc4cerlf62uyhdnvwnrajxxqo56hht5qwijh3uxbrons62te3xwwpm4yppbiab63nw57rj3yjarzwq2tfz57cphnxnacdbm5xdl66mdygni5js7pcftrmhitpcqjibjdmanyjtl75xtsjkm6dxzcwldqaxngqnfgg37yatihkho2c5e2w27hqbhphfhpeusvhu",
"message_id_hex": "fce7395010e16160a3a7abfd341b36574dfdd1e683cb7aab5e0519602b592151",
"body": "hello from the reference client\n",
"sender_pk_b32": "qn6h4zx62pnqxsteog6kcykfef33k2t5yrai7ei3eq33hres6wga"
},
"hkdf_rfc5869_case1": {
"ikm_hex": "0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b",
"salt_hex": "00000000000000000000000000",
"info_hex": "f0f1f2f3f4f5f6f7f8f9",
"okm_hex_32": "abbafb13f5c1bc489d4203135817956dd521b39e3bd61d1cc85cef884d1f8e2e"
},
"hmac_rfc4231_case1": {
"key_hex": "0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b",
"data": "Hi There",
"mac_hex": "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7"
},
"ed25519_to_x25519_pk0_b32": "bqw73wfgphe4ubojbbsrhfvtnxknuhhwvq74lmadpppi2p7lkbvq",
"rns_bind": {
"destination_hex": "000102030405060708090a0b0c0d0e0f",
"link_id_hex": "a0a1a2a3a4a5a6a7a8a9aaabacadaeaf",
"h_hex": "06d6437dc63250ff51d609e12b488ff22b4fa02620f35000b3b2ed1c8789d45c",
"server_static_hex": "da6fe109dc4da2878fa4810ffa0e08cef6b68a8b524126aa05bb14181b20d25a"
}
}

View file

@ -1,77 +0,0 @@
//! 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})")))
}