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", "zerocopy",
] ]
[[package]]
name = "aho-corasick"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "anstream" name = "anstream"
version = "1.0.0" version = "1.0.0"
@ -120,12 +111,6 @@ version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "bitflags"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "2.13.2" version = "2.13.2"
@ -315,37 +300,6 @@ version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" 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]] [[package]]
name = "der" name = "der"
version = "0.7.10" version = "0.7.10"
@ -392,29 +346,6 @@ dependencies = [
"zeroize", "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]] [[package]]
name = "fallible-iterator" name = "fallible-iterator"
version = "0.3.0" version = "0.3.0"
@ -444,17 +375,26 @@ name = "fumi"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"cc",
"chacha20poly1305",
"clap", "clap",
"data-encoding", "data-encoding",
"fumi-core",
"rand_core",
]
[[package]]
name = "fumi-core"
version = "0.1.0"
dependencies = [
"cc",
"chacha20poly1305",
"data-encoding",
"ed25519-dalek", "ed25519-dalek",
"env_logger",
"hkdf", "hkdf",
"hmac", "hmac",
"log",
"rand_core", "rand_core",
"rusqlite", "rusqlite",
"serde",
"serde_json",
"sha2", "sha2",
"snow", "snow",
"x25519-dalek", "x25519-dalek",
@ -549,41 +489,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]] [[package]]
name = "jiff" name = "itoa"
version = "0.2.37" version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ab1baf72f08796de0260609515130699b890ac25f30e610ad894bc5856cafdb" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
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",
]
[[package]] [[package]]
name = "libc" name = "libc"
@ -602,12 +511,6 @@ dependencies = [
"vcpkg", "vcpkg",
] ]
[[package]]
name = "log"
version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]] [[package]]
name = "memchr" name = "memchr"
version = "2.8.3" version = "2.8.3"
@ -671,21 +574,6 @@ dependencies = [
"universal-hash", "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]] [[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.107" version = "1.0.107"
@ -713,42 +601,13 @@ dependencies = [
"getrandom", "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]] [[package]]
name = "rusqlite" name = "rusqlite"
version = "0.32.1" version = "0.32.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e"
dependencies = [ dependencies = [
"bitflags 2.13.2", "bitflags",
"fallible-iterator", "fallible-iterator",
"fallible-streaming-iterator", "fallible-streaming-iterator",
"hashlink", "hashlink",
@ -801,6 +660,19 @@ dependencies = [
"syn 3.0.6", "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]] [[package]]
name = "sha2" name = "sha2"
version = "0.10.9" version = "0.10.9"
@ -893,26 +765,6 @@ dependencies = [
"unicode-ident", "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]] [[package]]
name = "typenum" name = "typenum"
version = "1.20.1" version = "1.20.1"
@ -1025,3 +877,9 @@ dependencies = [
"quote", "quote",
"syn 2.0.119", "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] [workspace]
name = "fumi" members = ["core", "cli"]
version = "0.1.0" default-members = ["cli"]
edition = "2021" resolver = "2"
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 }

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 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. `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 ## Modules
| File | Contents | | File | Contents |
|---|---| |---|---|
| `src/crypto.rs` | base32, HKDF, HMAC, SHA-256, the Ed25519→X25519 map with SPEC.md §2's checks | | `core/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 | | `core/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 | | `core/src/account.rs` | master, `seed_n` derivation, accept-key and tokens, rotation certificates, `find_rotation_index` |
| `src/message.rs` | envelope seal/open (§5.1–§5.3), message id (§5.4), frontmatter (§5.5) | | `core/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 | | `core/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 | | `core/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 | | `core/src/rns/` | the Reticulum carrier behind the `rns` feature: FFI over `core/shim/`, path discovery, one link per session |
| `src/client.rs` | the six operations, AUTH, chain walking, token sync, fetch pipeline | | `core/src/client.rs` | the six operations, AUTH, chain walking, token sync, fetch pipeline with cancellation |
| `src/store.rs` | SQLite: state, contacts, accepted, tokens, seen ids, inbox, sent | | `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 ## 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 - anstyle-wincon 3.0.11 - Copyright (c) Individual contributors
- anyhow 1.0.104 - David Tolnay <dtolnay@gmail.com> - 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 - 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 - 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 - 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 - 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 - 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 - 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 - 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 - der 0.7.10 - Copyright (c) 2020-2023 The RustCrypto Project Developers
- digest 0.10.7 - Copyright (c) 2017 Artyom Pavlov - digest 0.10.7 - Copyright (c) 2017 Artyom Pavlov
- ed25519 2.2.3 - Copyright 2018-2022 RustCrypto Developers - 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-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 - 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) - 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 - 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 - 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 - 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 - 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 1.21.4 - Aleksey Kladov <aleksey.kladov@gmail.com>
- once_cell_polyfill 1.70.2 - Copyright (c) Individual contributors - once_cell_polyfill 1.70.2 - Copyright (c) Individual contributors
- opaque-debug 0.3.1 - Copyright (c) 2018-2024 The RustCrypto Project Developers - 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 - pkg-config 0.3.34 - Copyright (c) 2014 Alex Crichton
- poly1305 0.8.0 - Copyright (c) 2015-2019 RustCrypto Developers - poly1305 0.8.0 - Copyright (c) 2015-2019 RustCrypto Developers
- polyval 0.6.2 - Copyright (c) 2019-2023 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> - proc-macro2 1.0.107 - David Tolnay <dtolnay@gmail.com>, Alex Crichton <alex@alexcrichton.com>
- quote 1.0.47 - David Tolnay <dtolnay@gmail.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 - 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 - rustc_version 0.4.1 - Copyright (c) 2016 The Rust Project Developers
- semver 1.0.28 - David Tolnay <dtolnay@gmail.com> - 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 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_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_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 - 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). - shlex 2.0.1 - Copyright 2015 Nicholas Allegra (comex).
- signature 2.2.0 - Copyright (c) 2018-2023 RustCrypto Developers - 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 - spki 0.7.3 - Copyright (c) 2021-2023 The RustCrypto Project Developers
- syn 2.0.119 - David Tolnay <dtolnay@gmail.com> - syn 2.0.119 - David Tolnay <dtolnay@gmail.com>
- syn 3.0.6 - 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 - typenum 1.20.1 - Copyright 2014 Paho Lurie-Gregg
- unicode-ident 1.0.26 - David Tolnay <dtolnay@gmail.com> - unicode-ident 1.0.26 - David Tolnay <dtolnay@gmail.com>
- universal-hash 0.5.1 - Copyright (c) 2019-2020 RustCrypto Developers - 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 0.8.4 - Copyright (c) 2018 Artyom Pavlov
- aes-gcm 0.10.3 - Copyright (c) 2019 The RustCrypto Project Developers - aes-gcm 0.10.3 - Copyright (c) 2019 The RustCrypto Project Developers
- ahash 0.8.12 - Copyright (c) 2018 Tom Kaitchuck - 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 - anstream 1.0.0 - Copyright (c) Individual contributors
- anstyle 1.0.14 - Copyright (c) Individual contributors - anstyle 1.0.14 - Copyright (c) Individual contributors
- anstyle-parse 1.0.0 - 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 - anstyle-wincon 3.0.11 - Copyright (c) Individual contributors
- anyhow 1.0.104 - David Tolnay <dtolnay@gmail.com> - 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 - 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 - 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 - 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 - 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 - 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 - 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. - 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 - der 0.7.10 - Copyright (c) 2020-2023 The RustCrypto Project Developers
- digest 0.10.7 - Copyright (c) 2017 Artyom Pavlov - digest 0.10.7 - Copyright (c) 2017 Artyom Pavlov
- ed25519 2.2.3 - Copyright (c) 2018-2023 RustCrypto Developers - 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-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 - 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). - 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 - 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 - 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 - is_terminal_polyfill 1.70.2 - Copyright (c) Individual contributors
- jiff 0.2.37 - Copyright (c) 2015 Andrew Gallant - itoa 1.0.18 - David Tolnay <dtolnay@gmail.com>
- jiff-core 0.1.1 - Copyright (c) 2015 Andrew Gallant
- jiff-static 0.2.37 - Copyright (c) 2015 Andrew Gallant
- libc 0.2.189 - Copyright (c) The Rust Project Developers - libc 0.2.189 - Copyright (c) The Rust Project Developers
- libsqlite3-sys 0.30.1 - Copyright (c) 2014-2021 The rusqlite 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 - memchr 2.8.3 - Copyright (c) 2015 Andrew Gallant
- once_cell 1.21.4 - Aleksey Kladov <aleksey.kladov@gmail.com> - once_cell 1.21.4 - Aleksey Kladov <aleksey.kladov@gmail.com>
- once_cell_polyfill 1.70.2 - Copyright (c) Individual contributors - 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 - pkg-config 0.3.34 - Copyright (c) 2014 Alex Crichton
- poly1305 0.8.0 - Copyright (c) 2015-2019 RustCrypto Developers - poly1305 0.8.0 - Copyright (c) 2015-2019 RustCrypto Developers
- polyval 0.6.2 - Copyright (c) 2019-2023 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> - proc-macro2 1.0.107 - David Tolnay <dtolnay@gmail.com>, Alex Crichton <alex@alexcrichton.com>
- quote 1.0.47 - David Tolnay <dtolnay@gmail.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 - 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 - rusqlite 0.32.1 - Copyright (c) 2014-2021 The rusqlite developers
- rustc_version 0.4.1 - Copyright (c) 2016 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> - 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 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_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_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 - 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). - shlex 2.0.1 - Copyright (c) 2015 Nicholas Allegra (comex).
- signature 2.2.0 - Copyright (c) 2018-2023 RustCrypto Developers - 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 - 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 2.0.119 - David Tolnay <dtolnay@gmail.com>
- syn 3.0.6 - 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 - typenum 1.20.1 - Copyright (c) 2014 Paho Lurie-Gregg
- unicode-ident 1.0.26 - David Tolnay <dtolnay@gmail.com> - unicode-ident 1.0.26 - David Tolnay <dtolnay@gmail.com>
- universal-hash 0.5.1 - Copyright (c) 2019-2020 RustCrypto Developers - 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 - zerocopy-derive 0.8.59 - Copyright 2023 The Fuchsia Authors
- zeroize 1.9.0 - Copyright (c) 2018-2026 The RustCrypto Project Developers - 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 - 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) - 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 - 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 - 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 - 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 - 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) The MIT License (MIT)
@ -570,10 +543,6 @@ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
## The Unlicense ## 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 - 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::io::{IsTerminal, Read, Write};
use std::path::PathBuf; use std::path::PathBuf;
use anyhow::{Context, Result}; use anyhow::{anyhow, Context, Result};
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use fumi_core as fumi;
use fumi::account::{identity_seed, Account}; use fumi::account::{identity_seed, Account};
use fumi::address::Address; use fumi::address::Address;
use fumi::client::{self, Pushed, SendDraft, TrustChange}; use fumi::client::{self, Pushed, SendDraft, TrustChange};
use fumi::crypto::{b32, fingerprint, unb32, KEY_LEN}; use fumi::crypto::{b32, fingerprint, unb32, KEY_LEN};
use fumi::error::SmolError;
use fumi::store::Store; use fumi::store::Store;
use fumi::transport::ID_LEN; use fumi::transport::ID_LEN;
@ -146,10 +148,30 @@ fn main() {
let cli = Cli::parse(); let cli = Cli::parse();
if let Err(e) = run(cli) { if let Err(e) = run(cli) {
eprintln!("error: {e}"); eprintln!("error: {e}");
hint(e.downcast_ref::<fumi::error::Error>());
std::process::exit(1); 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<()> { fn run(cli: Cli) -> Result<()> {
let store = Store::open(&cli.db)?; let store = Store::open(&cli.db)?;
#[cfg(feature = "rns")] #[cfg(feature = "rns")]
@ -186,18 +208,17 @@ fn warn(message: &str) {
fn load_master(path: &PathBuf) -> Result<[u8; KEY_LEN]> { fn load_master(path: &PathBuf) -> Result<[u8; KEY_LEN]> {
let bytes = std::fs::read(path).map_err(|_| { let bytes = std::fs::read(path).map_err(|_| {
SmolError::new(format!( anyhow!(format!(
"no identity at {}; run: fumi keygen", "no identity at {}; run: fumi keygen",
path.display() path.display()
)) ))
})?; })?;
bytes.try_into().map_err(|v: Vec<u8>| { bytes.try_into().map_err(|v: Vec<u8>| {
SmolError::new(format!( anyhow!(format!(
"master secret at {} is {} bytes, expected {KEY_LEN}", "master secret at {} is {} bytes, expected {KEY_LEN}",
path.display(), path.display(),
v.len() v.len()
)) ))
.into()
}) })
} }
@ -232,7 +253,7 @@ fn write_secret(path: &PathBuf, secret: &[u8]) -> Result<()> {
fn keygen(path: &PathBuf, force: bool) -> Result<()> { fn keygen(path: &PathBuf, force: bool) -> Result<()> {
if path.exists() && !force { if path.exists() && !force {
return Err(SmolError::new(format!( return Err(anyhow!(format!(
"{} exists; refusing to overwrite (use --force)", "{} exists; refusing to overwrite (use --force)",
path.display() 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<()> { 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>| { 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}", "server key is {} bytes, expected {KEY_LEN}",
v.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); let host = host.split(':').next().unwrap_or(host);
match store.server_pin(host)? { match store.server_pin(host)? {
Some(old) if old != key && !force => { Some(old) if old != key && !force => {
return Err(SmolError::new(format!( return Err(anyhow!(format!(
"{host} is already pinned to {}; use --force to replace", "{host} is already pinned to {}; use --force to replace",
b32(&old) b32(&old)
)) ))
@ -357,7 +378,7 @@ fn resolve(store: &Store, address: &str, timeout: u64) -> Result<()> {
let addr = Address::parse(address)?; let addr = Address::parse(address)?;
if addr.identity.is_some() { if addr.identity.is_some() {
return Err( 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)?; 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 addr = Address::parse(uri)?;
let key = addr let key = addr
.identity .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)?; store.save_contact(&addr.short(), &key, true)?;
println!("imported {} {} (verified)", addr.short(), b32(&key)); println!("imported {} {} (verified)", addr.short(), b32(&key));
println!("fingerprint: {}", fingerprint(&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())? { match store.contact(&addr.short())? {
Some((key, _)) => Ok((addr.short(), key)), Some((key, _)) => Ok((addr.short(), key)),
None => Err(SmolError::new(format!( None => Err(anyhow!(format!(
"no key for {}; run `resolve` or `import` first", "no key for {}; run `resolve` or `import` first",
addr.short() addr.short()
)) ))
@ -467,7 +488,7 @@ fn block(key: &PathBuf, store: &Store, address: &str, timeout: u64) -> Result<()
let account = load_account(key, store)?; let account = load_account(key, store)?;
let (address, _) = target_contact(store, address)?; let (address, _) = target_contact(store, address)?;
if !store.block(&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"); println!("blocked {address}; their mail lands in requests from their next message on");
match client::push_tokens(store, &account, timeout)? { 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) if !std::io::stdin().is_terminal() => read_stdin()?,
(None, None) => { (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 let Some(reply) = reply_to {
if reply.len() != 64 || !reply.bytes().all(|c| c.is_ascii_hexdigit()) { if reply.len() != 64 || !reply.bytes().all(|c| c.is_ascii_hexdigit()) {
return Err( 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 { for raw in headers {
let Some((k, v)) = raw.split_once(':') else { let Some((k, v)) = raw.split_once(':') else {
return Err( 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()) { if !valid_frontmatter_key(k.trim()) {
return Err( 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())); 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.stored,
summary.rejected.len() 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)"); line.push_str(" (left on the server)");
} }
println!("{line}"); println!("{line}");
@ -614,7 +637,7 @@ fn fetch(key: &PathBuf, store: &Store, keep: bool, reset: bool, timeout: u64) ->
/// ids, ready for the wire. /// ids, ready for the wire.
fn resolve_id_prefixes(store: &Store, ids: &[String]) -> Result<Vec<[u8; ID_LEN]>> { fn resolve_id_prefixes(store: &Store, ids: &[String]) -> Result<Vec<[u8; ID_LEN]>> {
if ids.is_empty() { if ids.is_empty() {
return Err(SmolError::new("no message ids given").into()); return Err(anyhow!("no message ids given"));
} }
let stored = store let stored = store
.mail("all")? .mail("all")?
@ -631,10 +654,10 @@ fn resolve_id_prefixes(store: &Store, ids: &[String]) -> Result<Vec<[u8; ID_LEN]
.copied() .copied()
.collect(); .collect();
match matches.len() { 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]), 1 => full.push(matches[0]),
_ => { _ => {
return Err(SmolError::new(format!( return Err(anyhow!(format!(
"{id:?} matches {} messages; be more specific", "{id:?} matches {} messages; be more specific",
matches.len() 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)) .filter(|m| hex(&m.id).starts_with(&prefix))
.collect(); .collect();
match matches.len() { match matches.len() {
0 => Err(SmolError::new(format!( 0 => Err(anyhow!(format!(
"no {}message matching {id:?}", "no {}message matching {id:?}",
if sent { "sent " } else { "" } if sent { "sent " } else { "" }
)) ))),
.into()),
1 => { 1 => {
let row = &matches[0]; let row = &matches[0];
let described = client::describe(store, &account, row)?; let described = client::describe(store, &account, row)?;
@ -736,7 +758,7 @@ fn read(key: &PathBuf, store: &Store, id: &str, sent: bool) -> Result<()> {
} }
Ok(()) Ok(())
} }
_ => Err(SmolError::new(format!( _ => Err(anyhow!(format!(
"{id:?} matches {} messages; be more specific", "{id:?} matches {} messages; be more specific",
matches.len() 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. //! indices, accept tokens and rotation certificates.
use crate::crypto::{hkdf_sha256, hmac_sha256, KEY_LEN}; 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 crate::transport::{CERT_LEN, LABEL_ACCEPT, LABEL_IDENTITY, LABEL_ROTATE, MAX_CHAIN};
use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey}; use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey};
use x25519_dalek::StaticSecret; use x25519_dalek::StaticSecret;
@ -52,6 +52,15 @@ pub fn accept_key(master: &[u8; KEY_LEN]) -> [u8; KEY_LEN] {
.unwrap() .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 /// A master plus every identity up to the current rotation index. Superseded
/// signing keys stay derivable because mail addressed to them is readable /// signing keys stay derivable because mail addressed to them is readable
/// with nothing else (sec 7). /// with nothing else (sec 7).
@ -62,11 +71,9 @@ pub struct Account {
} }
impl 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 { if index as usize > MAX_CHAIN {
return Err(SmolError::new(format!( return Err(Error::ChainLimit { index });
"rotation index {index} exceeds the chain limit of {MAX_CHAIN}"
)));
} }
let keys = (0..=index) let keys = (0..=index)
.map(|n| Identity::from_seed(identity_seed(&master, n))) .map(|n| Identity::from_seed(identity_seed(&master, n)))
@ -115,9 +122,9 @@ pub struct RotationCert {
} }
impl 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 { if bytes.len() != CERT_LEN {
return Err(SmolError::new(format!( return Err(Error::Other(format!(
"rotation certificate is {} bytes, expected {CERT_LEN}", "rotation certificate is {} bytes, expected {CERT_LEN}",
bytes.len() bytes.len()
))); )));
@ -191,67 +198,32 @@ pub fn verify_sig(identity: &[u8], signature: &[u8], message: &[u8]) -> bool {
mod tests { mod tests {
use super::*; use super::*;
use crate::crypto::{b32, unb32}; 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] { fn master() -> [u8; 32] {
core::array::from_fn(|i| i as u8) vectors::load().master()
}
fn seed_b32(n: u32) -> &'static str {
match n {
0 => "6l6wdqp3uwi2a3vn6jnlgjjmvimilnqv6np6t2vytw7wj3jdg6ea",
1 => "ag6k4r74bnc3tt6y623my7wasslq4ulxcxm4anzvizdpg76yfxva",
2 => "r35p2a4e5ffhz6aq5urhv27ch22a34r6i6adnqfwwcnte5te5tua",
16 => "rf6tdr6doeaymope2uj66au542kcmvfnaqc3cmy5jkulacdbnvaq",
_ => unreachable!(),
}
}
fn pk_b32(n: u32) -> &'static str {
match n {
0 => "3jxsxqtdvxwobge7uxefpbordkulv6bc6ao3h4iusqlov4kgkkja",
1 => "zoieneun2k7n5b65yxfeen4pxvxzq7sclm2kfaa7os3wrwrz4rta",
2 => "2ptpqdy2d4zklwr2xzizhiza7b7jsf6qr2rayzrr2mk2h73ifbrq",
16 => "r6yhcbo733vka7dlbknnlly7aqpoflej4lumeuxq7gxhzzm3juta",
_ => unreachable!(),
}
}
fn xpriv_b32(n: u32) -> &'static str {
match n {
0 => "ebppfk5yqmf7v5a4seh6f6rkm7z3pnoh7b6hiaa3vs7n5if6hj5a",
1 => "rbhdttkcycpfqfyfsy2mstbykw4t5pngd6dezskrytlore4j5jvq",
2 => "xb4jcmjyappxo7zibvbalaepmvy74jilom43fxvfaj7ev4bpxzqq",
16 => "ucdl3wcmwjlso6um6ipequ3qq6lkx47u4rnwwwy7jo76c2ktwjoq",
_ => unreachable!(),
}
} }
#[test] #[test]
fn identity_derivation_matches_the_reference_client() { fn identity_derivation_matches_the_reference_client() {
let v = vectors::load();
for n in [0u32, 1, 2, 16] { for n in [0u32, 1, 2, 16] {
let seed = identity_seed(&master(), n); 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); 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. // 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] #[test]
fn accept_key_and_token_match_the_reference_client() { fn accept_key_and_token_match_the_reference_client() {
assert_eq!( let v = vectors::load();
b32(&accept_key(&master())), assert_eq!(b32(&accept_key(&master())), v.accept_key_b32);
"fqmnb3vktmrth47m2qjzcey6ue7rbkrvsazytwdayjjgkvcqe2xa"
);
let account = Account::new(master(), 0).unwrap(); let account = Account::new(master(), 0).unwrap();
let pk1: [u8; 32] = unb32(pk_b32(1)).unwrap().try_into().unwrap(); let pk1: [u8; 32] = unb32(v.pk_b32(1)).unwrap().try_into().unwrap();
assert_eq!( assert_eq!(b32(&account.token_for(&pk1)), v.accept_token_for_pk1_b32);
b32(&account.token_for(&pk1)),
"bgrtewwtanbfmp5aoxq6rqckn4xcufr3tp4wa67mwsumyj7yjwia"
);
// Independent of the rotation index (sec 2). // Independent of the rotation index (sec 2).
let rotated = Account::new(master(), 3).unwrap(); let rotated = Account::new(master(), 3).unwrap();
assert_eq!(account.token_for(&pk1), rotated.token_for(&pk1)); assert_eq!(account.token_for(&pk1), rotated.token_for(&pk1));
@ -261,8 +233,9 @@ mod tests {
fn account_holds_every_superseded_key() { fn account_holds_every_superseded_key() {
let account = Account::new(master(), 2).unwrap(); let account = Account::new(master(), 2).unwrap();
assert_eq!(account.keys().len(), 3); assert_eq!(account.keys().len(), 3);
let v = vectors::load();
for (n, key) in account.keys().iter().enumerate() { 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); assert_eq!(account.index(), 2);
} }
@ -273,29 +246,35 @@ mod tests {
assert!(Account::new(master(), 17).is_err()); 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] #[test]
fn rotation_certificates_match_the_reference_client() { fn rotation_certificates_match_the_reference_client() {
let old = Identity::from_seed(identity_seed(&master(), 0)); let v = vectors::load();
let new = Identity::from_seed(identity_seed(&master(), 1)); let rc = &v.rotation_cert;
let cert = RotationCert::build(&old, &new, "alice", 1700000001); let old = Identity::from_seed(identity_seed(&master(), rc.old_index));
// Vector produced by the reference client over the same inputs. let new = Identity::from_seed(identity_seed(&master(), rc.new_index));
let expected = "da6f2bc263adece0989fa5c85785d11aa8baf822f01db3f1149416eaf1465292\ let cert = RotationCert::build(&old, &new, &rc.username, rc.when);
cb9046928dd2bede87ddc5ca42378fbd6f987e425b34a2801f74b768da39e466000000006553f101\ assert_eq!(data_encoding::HEXLOWER.encode(&cert), rc.cert_hex);
0c6171708ec40a9c68b456547a78fe0a512f8e45a0e4a9c094737ef4f84072a997a152e0f16e94bd\
bd93502521ba812de62fe4c6d19092f0c424bba69337390401b2995c670f1d5720188956b8a1b00d\
2ac9e5c1665e862e411c5ddeaea43c0721f450527b98f5dd3af29350a314514898a272bc3f78a0b9a\
db826b46733c605";
assert_eq!(data_encoding::HEXLOWER.encode(&cert), expected);
assert_eq!(cert.len(), CERT_LEN); assert_eq!(cert.len(), CERT_LEN);
let parsed = RotationCert::from_bytes(&cert).unwrap(); 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. // The username is covered (sec 7), so another username fails.
assert!(!parsed.verify("bob")); assert!(!parsed.verify("bob"));
// Both halves must sign: flipping a signature byte breaks it. // Both halves must sign: flipping a signature byte breaks it.
let mut broken = cert; let mut broken = cert;
broken[72] ^= 1; 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()); assert!(RotationCert::from_bytes(&cert[..199]).is_err());
} }

View file

@ -2,7 +2,7 @@
//! grammar they share. //! grammar they share.
use crate::crypto::{b32, unb32, KEY_LEN}; use crate::crypto::{b32, unb32, KEY_LEN};
use crate::error::SmolError; use crate::error::Error;
pub const DEFAULT_PORT: u16 = 1961; pub const DEFAULT_PORT: u16 = 1961;
/// A Reticulum destination hash, as printed by every RNS tool: 32 lowercase /// 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. /// 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 { 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 data_encoding::HEXLOWER
.decode(self.host.as_bytes()) .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| { .and_then(|bytes| {
bytes bytes
.try_into() .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 text = text.trim();
let (scheme, rest) = if let Some(rest) = text.strip_prefix("smol+rns://") { let (scheme, rest) = if let Some(rest) = text.strip_prefix("smol+rns://") {
(Scheme::Rns, rest) (Scheme::Rns, rest)
@ -85,7 +85,7 @@ impl Address {
Some((head, key)) => (head, Some(decode_key(text, key)?)), 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 && !text.starts_with("smol://") => (rest, None),
None if scheme == Scheme::Tcp => { None if scheme == Scheme::Tcp => {
return Err(SmolError::new(format!( return Err(Error::Other(format!(
"{text}: smol:// address carries no key" "{text}: smol:// address carries no key"
))) )))
} }
@ -94,9 +94,9 @@ impl Address {
let (user, host_part) = rest let (user, host_part) = rest
.split_once('@') .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() { 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)?; valid_username(user)?;
@ -115,12 +115,12 @@ impl Address {
// No port and no bare user@host form; the authority is the // No port and no bare user@host form; the authority is the
// destination hash, compared in full (RNS.md sec 13.2). // destination hash, compared in full (RNS.md sec 13.2).
if host_part.contains(':') { if host_part.contains(':') {
return Err(SmolError::new(format!( return Err(Error::Other(format!(
"{text}: the :port suffix must not appear on smol+rns://" "{text}: the :port suffix must not appear on smol+rns://"
))); )));
} }
if !is_destination_hash(host_part) { 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 \ "{text}: host must be exactly {RNS_HASH_HEX} lowercase hexadecimal \
characters, a Reticulum destination hash" 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 = 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>| { identity.try_into().map_err(|v: Vec<u8>| {
SmolError::new(format!( Error::Other(format!(
"{text}: key is {} bytes, expected {KEY_LEN}", "{text}: key is {} bytes, expected {KEY_LEN}",
v.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 let Some((host, port)) = host_part.rsplit_once(':') {
if !host.is_empty() && port.bytes().all(|c| c.is_ascii_digit()) && !port.is_empty() { if !host.is_empty() && port.bytes().all(|c| c.is_ascii_digit()) && !port.is_empty() {
return port return port
.parse::<u16>() .parse::<u16>()
.map(|p| (host.to_string(), p)) .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('/') { 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)) 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 /// 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 /// never two separators in a row. Non-lowercase input is a parse error, as in
/// the reference client, which normalises before sending instead. /// 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"._-"; const SEPARATORS: &[u8] = b"._-";
let bytes = name.as_bytes(); let bytes = name.as_bytes();
let ok = !bytes.is_empty() let ok = !bytes.is_empty()
@ -189,7 +189,7 @@ fn valid_username(name: &str) -> Result<(), SmolError> {
if ok { if ok {
Ok(()) Ok(())
} else { } 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, \ "{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" 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::account::{Account, Identity, RotationCert};
use crate::address::{Address, Scheme}; use crate::address::{Address, Scheme};
use crate::crypto::{b32, ct_eq, hmac_sha256, KEY_LEN}; 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::{ use crate::message::{
accept_field, build_frontmatter, message_id, parse_frontmatter, seal, unseal, Opened, accept_field, build_frontmatter, message_id, parse_frontmatter, seal, unseal, Opened,
}; };
@ -44,16 +44,14 @@ pub fn connect(
addr: &Address, addr: &Address,
require_pin: bool, require_pin: bool,
timeout: u64, timeout: u64,
) -> Result<Session, SmolError> { ) -> Result<Session, Error> {
match addr.scheme { match addr.scheme {
Scheme::Tcp => { Scheme::Tcp => {
let pinned = store.server_pin(&addr.host)?; let pinned = store.server_pin(&addr.host)?;
if pinned.is_none() && require_pin { if pinned.is_none() && require_pin {
return Err(SmolError::new(format!( return Err(Error::NotPinned {
"no pinned key for {}.\nObtain it from the operator through a trusted \ host: addr.host.clone(),
channel, then:\n fumi trust {} <key>", });
addr.host, addr.host
)));
} }
let transport = TcpTransport::connect(&addr.host, addr.port, pinned, timeout)?; let transport = TcpTransport::connect(&addr.host, addr.port, pinned, timeout)?;
let unpinned_static = if pinned.is_none() { let unpinned_static = if pinned.is_none() {
@ -87,17 +85,17 @@ pub fn connect(
#[cfg(not(feature = "rns"))] #[cfg(not(feature = "rns"))]
{ {
let _ = (addr, require_pin); let _ = (addr, require_pin);
Err(SmolError::new( Err(Error::Other(
"smol+rns:// addresses need the rns feature; rebuild with --features rns", "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 { 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.push(text.len() as u8);
out.extend_from_slice(text); out.extend_from_slice(text);
@ -108,7 +106,7 @@ fn string_field(out: &mut Vec<u8>, text: &[u8]) -> Result<(), SmolError> {
pub fn resolve( pub fn resolve(
transport: &mut dyn Transport, transport: &mut dyn Transport,
username: &str, 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(); let mut body = Vec::new();
string_field(&mut body, username.as_bytes())?; string_field(&mut body, username.as_bytes())?;
let response = transport.request(OP_RESOLVE, &body)?; let response = transport.request(OP_RESOLVE, &body)?;
@ -180,7 +178,7 @@ pub fn trust_key(
store: &Store, store: &Store,
transport: &mut dyn Transport, transport: &mut dyn Transport,
addr: &Address, addr: &Address,
) -> Result<([u8; KEY_LEN], TrustChange), SmolError> { ) -> Result<([u8; KEY_LEN], TrustChange), Error> {
let (identity, chain) = resolve(transport, &addr.user)?; let (identity, chain) = resolve(transport, &addr.user)?;
let known = store.contact(&addr.short())?; let known = store.contact(&addr.short())?;
match known { match known {
@ -199,14 +197,11 @@ pub fn trust_key(
store.save_contact(&addr.short(), &identity, verified)?; store.save_contact(&addr.short(), &identity, verified)?;
Ok((identity, TrustChange::Rotated)) Ok((identity, TrustChange::Rotated))
} else { } else {
Err(SmolError::new(format!( Err(Error::KeyChanged {
"{} presents a different key with no valid rotation chain.\n known: {}\n \ address: addr.short(),
offered: {}\nVerify out of band, then: fumi import {}", known: old,
addr.short(), offered: identity,
b32(&old), })
b32(&identity),
addr.uri(&identity)
)))
} }
} }
} }
@ -221,7 +216,7 @@ pub fn authenticate(
username: &str, username: &str,
account: &Account, account: &Account,
store: &Store, store: &Store,
) -> Result<u16, SmolError> { ) -> Result<u16, Error> {
let (sync, tokens) = store.token_set(account)?; let (sync, tokens) = store.token_set(account)?;
let mut body = Vec::new(); let mut body = Vec::new();
string_field(&mut body, username.as_bytes())?; 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 /// 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. /// 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 { let Some(addr) = store.account()? else {
return Ok(Pushed::NotRegistered); return Ok(Pushed::NotRegistered);
}; };
@ -275,7 +270,7 @@ pub fn register(
account: &Account, account: &Account,
invite: Option<&str>, invite: Option<&str>,
timeout: u64, timeout: u64,
) -> Result<(), SmolError> { ) -> Result<(), Error> {
let mut session = connect(store, addr, true, timeout)?; let mut session = connect(store, addr, true, timeout)?;
let transport = session.transport(); let transport = session.transport();
let me = account.me(); let me = account.me();
@ -313,16 +308,14 @@ pub struct Rotated {
/// Advances the rotation index and pushes the certificate (sec 7). The master /// Advances the rotation index and pushes the certificate (sec 7). The master
/// is untouched; only the index moves, and the superseded key stays /// is untouched; only the index moves, and the superseded key stays
/// derivable from it (sec 2). /// 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 { let Some(addr) = store.account()? else {
return Err(SmolError::new( return Err(Error::NotRegistered);
"not registered; run: fumi register <user@host>",
));
}; };
if account.index() as usize >= MAX_CHAIN { if account.index() as usize >= MAX_CHAIN {
return Err(SmolError::new(format!( return Err(Error::ChainLimit {
"the rotation chain is full at {MAX_CHAIN} links" index: account.index() + 1,
))); });
} }
let old = account.me(); let old = account.me();
let new = Identity::from_seed(crate::account::identity_seed( 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 /// 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 /// 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). /// the user rebuilds it: an empty set must not replace the server's (sec 4).
pub fn restore( /// The pieces are public for hosts that want intermediate UI states:
store: &Store, /// `connect` + `resolve`, `find_rotation_index`, then the four `set_*`
master: &[u8; KEY_LEN], /// calls below.
addr: &Address, pub fn restore(store: &Store, master: &[u8; KEY_LEN], addr: &Address, timeout: u64) -> Result<u32, Error> {
timeout: u64,
) -> Result<u32, SmolError> {
let mut session = connect(store, addr, false, timeout)?; let mut session = connect(store, addr, false, timeout)?;
let (identity, _) = resolve(session.transport(), &addr.user)?; let (identity, _) = resolve(session.transport(), &addr.user)?;
session.close(); session.close();
let mut found = None; let Some(index) = crate::account::find_rotation_index(master, &identity) else {
for index in 0..=(MAX_CHAIN as u32) { return Err(Error::Other(format!(
if Identity::from_seed(crate::account::identity_seed(master, index)).pk() == identity {
found = Some(index);
break;
}
}
let Some(index) = found else {
return Err(SmolError::new(format!(
"the key bound to {} is not derived from this master within {MAX_CHAIN} rotations", "the key bound to {} is not derived from this master within {MAX_CHAIN} rotations",
addr.short() addr.short()
))); )));
@ -428,7 +412,7 @@ pub fn send(
account: &Account, account: &Account,
draft: &SendDraft<'_>, draft: &SendDraft<'_>,
timeout: u64, timeout: u64,
) -> Result<Sent, SmolError> { ) -> Result<Sent, Error> {
let addr = draft.address; let addr = draft.address;
let me = account.me(); let me = account.me();
@ -519,6 +503,37 @@ pub struct Fetched {
/// Messages were deleted from the server rather than kept behind a /// Messages were deleted from the server rather than kept behind a
/// cursor (the default). /// cursor (the default).
pub acknowledged: bool, 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 /// Retrieves, verifies and stores mail (sec 6.1), paging forward by
@ -532,16 +547,23 @@ pub fn fetch(
keep: bool, keep: bool,
reset: bool, reset: bool,
timeout: u64, 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 { let Some(addr) = store.account()? else {
return Err(SmolError::new( return Err(Error::NotRegistered);
"not registered; run: fumi register <user@host>",
));
}; };
if reset { if opts.reset {
store.set_cursor(0, &[0u8; ID_LEN])?; 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()? store.cursor()?
} else { } else {
(0, [0u8; ID_LEN]) (0, [0u8; ID_LEN])
@ -551,13 +573,19 @@ pub fn fetch(
total: 0, total: 0,
stored: 0, stored: 0,
rejected: Vec::new(), 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(); let transport = session.transport();
authenticate(transport, &addr.user, account, store)?; authenticate(transport, &addr.user, account, store)?;
loop { loop {
if cancelled() {
summary.cancelled = true;
break;
}
let mut body = Vec::with_capacity(8 + ID_LEN); let mut body = Vec::with_capacity(8 + ID_LEN);
body.extend_from_slice(&after_time.to_be_bytes()); body.extend_from_slice(&after_time.to_be_bytes());
body.extend_from_slice(&after_id); body.extend_from_slice(&after_id);
@ -600,14 +628,14 @@ pub fn fetch(
acked.push(mid); acked.push(mid);
} }
r.done()?; r.done()?;
if keep { if opts.keep {
store.set_cursor(after_time, &after_id)?; store.set_cursor(after_time, &after_id)?;
} else if !acked.is_empty() { } else if !acked.is_empty() {
delete_ids(transport, &acked)?; delete_ids(transport, &acked)?;
} }
} }
session.close(); session.close();
if !keep { if !opts.keep {
store.set_cursor(0, &[0u8; ID_LEN])?; store.set_cursor(0, &[0u8; ID_LEN])?;
} }
Ok(summary) Ok(summary)
@ -617,9 +645,9 @@ fn verify_and_open(
account: &Account, account: &Account,
mid: &[u8; ID_LEN], mid: &[u8; ID_LEN],
envelope: &[u8], envelope: &[u8],
) -> Result<Opened, SmolError> { ) -> Result<Opened, Error> {
if message_id(envelope) != *mid { 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()) 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 /// 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`, /// signed it (sec 5.8). The signature has already been checked by `unseal`,
/// so the attribution is the signer's own claim. /// 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 text = String::from_utf8_lossy(&opened.body).into_owned();
let (fields, _) = parse_frontmatter(&text); let (fields, _) = parse_frontmatter(&text);
let Some(token) = accept_field(&fields) else { 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); let mut body = Vec::with_capacity(2 + ids.len() * ID_LEN);
body.extend_from_slice(&(ids.len() as u16).to_be_bytes()); body.extend_from_slice(&(ids.len() as u16).to_be_bytes());
for id in ids { for id in ids {
@ -664,11 +692,9 @@ pub fn delete(
account: &Account, account: &Account,
ids: &[[u8; ID_LEN]], ids: &[[u8; ID_LEN]],
timeout: u64, timeout: u64,
) -> Result<u16, SmolError> { ) -> Result<u16, Error> {
let Some(addr) = store.account()? else { let Some(addr) = store.account()? else {
return Err(SmolError::new( return Err(Error::NotRegistered);
"not registered; run: fumi register <user@host>",
));
}; };
let mut session = connect(store, &addr, true, timeout)?; let mut session = connect(store, &addr, true, timeout)?;
let transport = session.transport(); let transport = session.transport();
@ -698,7 +724,7 @@ impl Described {
} }
/// Opens one sealed message and splits its body into frontmatter and text. /// 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 opened = unseal(account.keys(), &stored.envelope, now())?;
let text = String::from_utf8_lossy(&opened.body).into_owned(); let text = String::from_utf8_lossy(&opened.body).into_owned();
let (fields, body_text) = parse_frontmatter(&text); let (fields, body_text) = parse_frontmatter(&text);
@ -811,16 +837,16 @@ mod tests {
fn accept_mac_matches_the_reference_vector() { fn accept_mac_matches_the_reference_vector() {
// The reference client's own vector: token_for(pk_1) over the id of // The reference client's own vector: token_for(pk_1) over the id of
// the reference envelope (see the message module's vectors). // the reference envelope (see the message module's vectors).
let v = crate::vectors::load();
let account = Account::new(master(), 0).unwrap(); let account = Account::new(master(), 0).unwrap();
let token = account.token_for(&identity(1).pk()); let token = account.token_for(&identity(1).pk());
let mid: [u8; 32] = let mid: [u8; 32] = crate::crypto::unb32(&v.accept_mac.message_id_b32)
crate::crypto::unb32("7tttsuaq4fqwbi5hvp6tigzwk5g73upgqpfxvk26aumwak2zefiq")
.unwrap() .unwrap()
.try_into() .try_into()
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
data_encoding::HEXLOWER.encode(&accept_mac(&token, &mid)), 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 std::sync::LazyLock;
use x25519_dalek::{PublicKey, StaticSecret}; use x25519_dalek::{PublicKey, StaticSecret};
use crate::error::SmolError; use crate::error::Error;
pub const KEY_LEN: usize = 32; 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. /// 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 lower = text.trim().to_ascii_lowercase();
let trimmed = lower.trim_end_matches('='); let trimmed = lower.trim_end_matches('=');
BASE32_LOWER_UNPADDED BASE32_LOWER_UNPADDED
.decode(trimmed.as_bytes()) .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 /// 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 /// The X25519 public key of an Ed25519 identity: libsodium's
/// `crypto_sign_ed25519_pk_to_curve25519` (SPEC.md sec 2). Malformed points /// `crypto_sign_ed25519_pk_to_curve25519` (SPEC.md sec 2). Malformed points
/// are rejected rather than mapped, matching the reference client. /// 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) VerifyingKey::from_bytes(identity)
.map(|key| *key.to_montgomery().as_bytes()) .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 /// 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. /// 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)); let shared = secret.diffie_hellman(&PublicKey::from(*peer));
if shared.as_bytes().iter().all(|&b| b == 0) { 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()) Ok(*shared.as_bytes())
} }
@ -141,41 +141,32 @@ mod tests {
#[test] #[test]
fn hkdf_matches_rfc5869_case_1() { fn hkdf_matches_rfc5869_case_1() {
let ikm = [0x0bu8; 22]; let v = crate::vectors::load();
let salt = [0u8; 13]; let hk = &v.hkdf_rfc5869_case1;
let info = [0xf0u8, 0xf1, 0xf2, 0xf3, 0xf4, 0xf5, 0xf6, 0xf7, 0xf8, 0xf9]; let okm = hkdf_sha256(&v.hex(&hk.ikm_hex), &v.hex(&hk.salt_hex), &v.hex(&hk.info_hex), 42);
let okm = hkdf_sha256(&ikm, &salt, &info, 42); assert_eq!(data_encoding::HEXLOWER.encode(&okm[..32]), hk.okm_hex_32);
// First 32 bytes of RFC 5869 test case 1's 42-byte OKM.
let expected = "abbafb13f5c1bc489d4203135817956dd521b39e3bd61d1cc85cef884d1f8e2e";
assert_eq!(data_encoding::HEXLOWER.encode(&okm[..32]), expected);
} }
#[test] #[test]
fn hmac_matches_rfc4231_case_1() { fn hmac_matches_rfc4231_case_1() {
let key = [0x0bu8; 20]; let v = crate::vectors::load();
let data = b"Hi There"; let hm = &v.hmac_rfc4231_case1;
let expected = "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7";
assert_eq!( assert_eq!(
data_encoding::HEXLOWER.encode(&hmac_sha256(&key, data)), data_encoding::HEXLOWER.encode(&hmac_sha256(&v.hex(&hm.key_hex), hm.data.as_bytes())),
expected hm.mac_hex
); );
} }
#[test] #[test]
fn ed25519_to_x25519_rejects_garbage_and_maps_like_libsodium() { fn ed25519_to_x25519_rejects_garbage_and_maps_like_libsodium() {
// Vectors generated with libsodium's pk_to_curve25519 over the // libsodium's pk_to_curve25519 over the identity the reference
// identities the reference client derives at seed_0 and seed_1. // client derives at seed_0.
let seed0: [u8; 32] = unb32("6l6wdqp3uwi2a3vn6jnlgjjmvimilnqv6np6t2vytw7wj3jdg6ea") let v = crate::vectors::load();
.unwrap() let seed0: [u8; 32] = unb32(v.seed_b32(0)).unwrap().try_into().unwrap();
.try_into()
.unwrap();
let pk0 = *ed25519_dalek::SigningKey::from_bytes(&seed0) let pk0 = *ed25519_dalek::SigningKey::from_bytes(&seed0)
.verifying_key() .verifying_key()
.as_bytes(); .as_bytes();
assert_eq!( assert_eq!(b32(&ed25519_to_x25519(&pk0).unwrap()), v.ed25519_to_x25519_pk0_b32);
b32(&ed25519_to_x25519(&pk0).unwrap()),
"bqw73wfgphe4ubojbbsrhfvtnxknuhhwvq74lmadpppi2p7lkbvq"
);
// 32 zero bytes do decode to a curve point, but a low-order one; // 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 // sec 2's rejection happens at agreement time, where the all-zero
// output surfaces. // 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 //! Counterpart to bunshin, the server: deriving identities from one master
//! secret, sealing and opening mail, and talking to a mailbox over a Noise_NX //! 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 account;
pub mod address; pub mod address;
@ -15,3 +17,6 @@ pub mod rns;
pub mod store; pub mod store;
pub mod tcp; pub mod tcp;
pub mod transport; 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::account::{verify_sig, Identity};
use crate::crypto::{agree, b32, ct_eq, ed25519_to_x25519, hkdf_sha256, sha256, unb32, KEY_LEN}; 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::{ use crate::transport::{
ENVELOPE_HEADER, ENVELOPE_MAGIC, ENVELOPE_MIN, ENVELOPE_VERSION, LABEL_ID, LABEL_MSG, ENVELOPE_HEADER, ENVELOPE_MAGIC, ENVELOPE_MIN, ENVELOPE_VERSION, LABEL_ID, LABEL_MSG,
LABEL_SEAL, PAYLOAD_HEADER, SIG_LEN, LABEL_SEAL, PAYLOAD_HEADER, SIG_LEN,
@ -46,7 +46,7 @@ pub fn seal(
body: &[u8], body: &[u8],
when: i64, when: i64,
pad: bool, pad: bool,
) -> Result<Vec<u8>, SmolError> { ) -> Result<Vec<u8>, Error> {
let mut esk = [0u8; KEY_LEN]; let mut esk = [0u8; KEY_LEN];
OsRng.fill_bytes(&mut esk); OsRng.fill_bytes(&mut esk);
// esk is dropped at the end of the frame; nothing more can be promised // esk is dropped at the end of the frame; nothing more can be promised
@ -61,7 +61,7 @@ fn seal_with_esk(
when: i64, when: i64,
pad: bool, pad: bool,
esk: [u8; KEY_LEN], esk: [u8; KEY_LEN],
) -> Result<Vec<u8>, SmolError> { ) -> Result<Vec<u8>, Error> {
let x_recipient = ed25519_to_x25519(recipient)?; let x_recipient = ed25519_to_x25519(recipient)?;
let eph = x25519_dalek::StaticSecret::from(esk); let eph = x25519_dalek::StaticSecret::from(esk);
let epk = x25519_dalek::PublicKey::from(&eph); let epk = x25519_dalek::PublicKey::from(&eph);
@ -107,7 +107,7 @@ fn seal_with_esk(
aad: &aad, aad: &aad,
}, },
) )
.map_err(|_| SmolError::new("encryption failed"))?; .map_err(|_| Error::Other("encryption failed".into()))?;
let mut envelope = aad; let mut envelope = aad;
envelope.extend_from_slice(&sealed); envelope.extend_from_slice(&sealed);
Ok(envelope) Ok(envelope)
@ -118,15 +118,15 @@ fn seal_with_esk(
/// against the sender the payload carries, and the payload is not dated more /// 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 /// than `MAX_SKEW` ahead of `now`. Trailing bytes past `body_len + 64` are
/// ignored as padding. /// 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 { 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 { 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 { if envelope[4] != ENVELOPE_VERSION {
return Err(SmolError::new(format!( return Err(Error::Other(format!(
"unsupported envelope version {}", "unsupported envelope version {}",
envelope[4] envelope[4]
))); )));
@ -139,7 +139,7 @@ pub fn unseal(identities: &[Identity], envelope: &[u8], now: i64) -> Result<Open
.iter() .iter()
.find(|i| ct_eq(&i.pk(), &to)) .find(|i| ct_eq(&i.pk(), &to))
.ok_or_else(|| { .ok_or_else(|| {
SmolError::new(format!( Error::Other(format!(
"addressed to {}…, not one of our keys", "addressed to {}…, not one of our keys",
&b32(&to)[..16] &b32(&to)[..16]
)) ))
@ -159,17 +159,17 @@ pub fn unseal(identities: &[Identity], envelope: &[u8], now: i64) -> Result<Open
aad: &envelope[..ENVELOPE_HEADER], 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 { 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 header = &plaintext[..PAYLOAD_HEADER];
let sender: [u8; KEY_LEN] = plaintext[1..33].try_into().unwrap(); let sender: [u8; KEY_LEN] = plaintext[1..33].try_into().unwrap();
let when = i64::from_be_bytes(plaintext[33..41].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; let body_len = u32::from_be_bytes(plaintext[41..45].try_into().unwrap()) as usize;
if PAYLOAD_HEADER + body_len + SIG_LEN > plaintext.len() { 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 body = &plaintext[PAYLOAD_HEADER..PAYLOAD_HEADER + body_len];
let signature = &plaintext[PAYLOAD_HEADER + body_len..PAYLOAD_HEADER + body_len + SIG_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(header);
signed.extend_from_slice(body); signed.extend_from_slice(body);
if !verify_sig(&sender, signature, &signed) { 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 { 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 { Ok(Opened {
sender, sender,
@ -276,11 +276,6 @@ mod tests {
core::array::from_fn(|i| i as u8) 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 { fn identity(n: u32) -> Identity {
Identity::from_seed(identity_seed(&master(), n)) Identity::from_seed(identity_seed(&master(), n))
@ -288,58 +283,46 @@ mod tests {
#[test] #[test]
fn message_id_matches_the_reference_client() { 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!(envelope.len(), 1109);
assert_eq!( assert_eq!(
data_encoding::HEXLOWER.encode(&message_id(&envelope)), data_encoding::HEXLOWER.encode(&message_id(&envelope)),
REF_MESSAGE_ID v.envelope.message_id_hex
); );
} }
#[test] #[test]
fn unseals_an_envelope_from_the_reference_client() { 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( let opened = unseal(
&[identity(0), identity(1), identity(2)], &[identity(0), identity(1), identity(2)],
&envelope, &envelope,
1700000100, v.envelope.when + 100,
) )
.expect("reference envelope must open"); .expect("reference envelope must open");
assert_eq!( assert_eq!(b32(&opened.sender), v.envelope.sender_pk_b32);
b32(&opened.sender), assert_eq!(opened.time, v.envelope.when);
"qn6h4zx62pnqxsteog6kcykfef33k2t5yrai7ei3eq33hres6wga" assert_eq!(opened.body, v.envelope.body.as_bytes());
);
assert_eq!(opened.time, 1700000000);
assert_eq!(opened.body, b"hello from the reference client\n");
} }
#[test] #[test]
fn seal_matches_the_reference_client_byte_for_byte() { fn seal_matches_the_reference_client_byte_for_byte() {
let sender = identity(3); let v = crate::vectors::load();
let recipient = identity(1).pk(); let e = &v.envelope;
let envelope = seal_with_esk( let sender = identity(e.sender_index);
&sender, let recipient = identity(e.recipient_index).pk();
&recipient, let esk: [u8; 32] = v.hex(&e.ephemeral_hex).try_into().unwrap();
b"hello from the reference client\n", let envelope =
1700000000, seal_with_esk(&sender, &recipient, e.body.as_bytes(), e.when, true, esk).unwrap();
true, assert_eq!(envelope, unb32(&e.padded_b32).unwrap());
[7u8; 32],
)
.unwrap();
assert_eq!(envelope, unb32(REF_ENVELOPE_B32).unwrap());
// And unpadded, which is the other sender choice sec 5.3 allows. // And unpadded, which is the other sender choice sec 5.3 allows.
let plain = seal_with_esk( let plain =
&sender, seal_with_esk(&sender, &recipient, e.body.as_bytes(), e.when, false, esk).unwrap();
&recipient,
b"hello from the reference client\n",
1700000000,
false,
[7u8; 32],
)
.unwrap();
assert_eq!(plain.len(), 226); assert_eq!(plain.len(), 226);
assert_eq!(plain, unb32(REF_ENVELOPE_NOPAD_B32).unwrap()); assert_eq!(plain, unb32(&e.unpadded_b32).unwrap());
} }
#[test] #[test]

View file

@ -3,13 +3,13 @@
//! Every failure here is a local error — no path, a dead link, a rejected //! 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 //! 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 //! 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::ffi::CString;
use std::time::Duration; use std::time::Duration;
use crate::crypto::KEY_LEN; use crate::crypto::KEY_LEN;
use crate::error::SmolError; use crate::error::Error;
use crate::rns::RnsConfig; use crate::rns::RnsConfig;
use crate::transport::MAX_FRAME; use crate::transport::MAX_FRAME;
@ -46,7 +46,7 @@ mod inner {
} }
/// Starts the Reticulum stack: storage path, UDP interface, loop thread. /// 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 = let storage_dir =
CString::new(config.storage_dir.as_str()).expect("storage path has no interior NUL"); CString::new(config.storage_dir.as_str()).expect("storage path has no interior NUL");
let listen_host = let listen_host =
@ -69,7 +69,7 @@ pub fn start(config: &RnsConfig) -> Result<(), SmolError> {
}; };
match rc { match rc {
0 => Ok(()), 0 => Ok(()),
_ => Err(SmolError::new(format!( _ => Err(Error::Other(format!(
"RNS shim failed to start (code {rc}); is the UDP port free?" "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, /// Requests a path, recalls the identity, opens a link and waits for ACTIVE,
/// returning the 16-byte link id the bind values derive from. /// 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 hex = data_encoding::HEXLOWER.encode(destination);
let mut link_id = [0u8; 16]; let mut link_id = [0u8; 16];
let rc = unsafe { let rc = unsafe {
@ -89,17 +89,17 @@ pub fn connect(destination: &[u8; 16], timeout: Duration) -> Result<[u8; 16], Sm
}; };
match rc { match rc {
0 => Ok(link_id), 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 \ "no path to {hex}; it may be unreachable or has never announced \
(upstream spec 13.1, 13.3)" (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" "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}" "could not establish a link to {hex}"
))), ))),
_ => Err(SmolError::new(format!( _ => Err(Error::Other(format!(
"RNS connect to {hex} failed (code {rc})" "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` /// Sends one `op u8 || body` request and returns the `status u8 || payload`
/// response. /// 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 // 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 // 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 // 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); out.truncate(out_len);
Ok(out) Ok(out)
} }
-1 => Err(SmolError::new("no RNS link is open")), -1 => Err(Error::Other("no RNS link is open".into())),
-2 => Err(SmolError::new("failed to send the request over the link")), -2 => Err(Error::Other("failed to send the request over the link".into())),
-3 | -4 => Err(SmolError::new( -3 | -4 => Err(Error::Other(
"request failed: no response (closed link, rejected transfer, or timeout \ "request failed: no response (closed link, rejected transfer, or timeout \
-- not a status code, upstream spec 13.5)", -- not a status code, upstream spec 13.5)".into(),
)), )),
-5 => Err(SmolError::new("malformed response from server")), -5 => Err(Error::Other("malformed response from server".into())),
-6 => Err(SmolError::new( -6 => Err(Error::Other(
"response larger than one application frame; refusing to truncate it", "response larger than one application frame; refusing to truncate it".into(),
)), )),
_ => Err(SmolError::new(format!("RNS request failed (code {rc})"))), _ => Err(Error::Other(format!("RNS request failed (code {rc})"))),
} }
} }

View file

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

View file

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

View file

@ -1,7 +1,11 @@
//! Local state: one SQLite file, shared by both carriers (RNS.md sec 15). //! 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. //! 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::path::Path;
use std::sync::{Mutex, MutexGuard};
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
use rusqlite::{params, Connection}; use rusqlite::{params, Connection};
@ -9,7 +13,7 @@ use rusqlite::{params, Connection};
use crate::account::Account; use crate::account::Account;
use crate::address::Address; use crate::address::Address;
use crate::crypto::ct_eq; use crate::crypto::ct_eq;
use crate::error::SmolError; use crate::error::Error;
use crate::transport::{ID_LEN, KEY_LEN, TIER_MAIN, TIER_REQUESTS}; use crate::transport::{ID_LEN, KEY_LEN, TIER_MAIN, TIER_REQUESTS};
pub fn now() -> i64 { pub fn now() -> i64 {
@ -67,26 +71,52 @@ pub struct Stored {
} }
pub struct Store { pub struct Store {
db: Connection, db: Mutex<Connection>,
} }
/// A contact row: address, identity, verified, and its acceptance state /// A contact row: address, identity, verified, and its acceptance state
/// (None when never accepted) for `fumi contacts`. /// (None when never accepted) for `fumi contacts`.
pub type ContactRow = (String, [u8; KEY_LEN], bool, Option<i64>); 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 { impl Store {
pub fn open(path: &Path) -> Result<Store, SmolError> { pub fn open(path: &Path) -> Result<Store, Error> {
let db = Connection::open(path)?; let db = Connection::open(path)?;
// 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.execute_batch(SCHEMA)?;
Ok(Store { db }) 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 where
T: rusqlite::types::FromSql, T: rusqlite::types::FromSql,
{ {
Ok( 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), Ok(value) => Some(value),
Err(rusqlite::Error::QueryReturnedNoRows) => None, Err(rusqlite::Error::QueryReturnedNoRows) => None,
Err(e) => return Err(e.into()), Err(e) => return Err(e.into()),
@ -95,8 +125,8 @@ impl Store {
} }
/// The home address, when this identity has been registered or restored. /// The home address, when this identity has been registered or restored.
pub fn account(&self) -> Result<Option<Address>, SmolError> { pub fn account(&self) -> Result<Option<Address>, Error> {
let row = self.db.query_row( let row = self.db().query_row(
"SELECT username, host, port, scheme FROM state WHERE id = 1", "SELECT username, host, port, scheme FROM state WHERE id = 1",
[], [],
|row| { |row| {
@ -124,19 +154,19 @@ impl Store {
format!("{username}@{host}") format!("{username}@{host}")
}; };
let mut addr = Address::parse(&text) 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 { if addr.scheme == crate::address::Scheme::Tcp {
addr.port = port; addr.port = port;
} }
Ok(Some(addr)) 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 { let scheme = match addr.scheme {
crate::address::Scheme::Tcp => "tcp", crate::address::Scheme::Tcp => "tcp",
crate::address::Scheme::Rns => "rns", crate::address::Scheme::Rns => "rns",
}; };
self.db self.db()
.execute( .execute(
"UPDATE state SET username = ?1, host = ?2, port = ?3, scheme = ?4 WHERE id = 1", "UPDATE state SET username = ?1, host = ?2, port = ?3, scheme = ?4 WHERE id = 1",
params![addr.user, addr.host, addr.port, scheme], params![addr.user, addr.host, addr.port, scheme],
@ -145,14 +175,14 @@ impl Store {
Ok(()) Ok(())
} }
pub fn rotations(&self) -> Result<u32, SmolError> { pub fn rotations(&self) -> Result<u32, Error> {
Ok(self Ok(self
.one::<i64>("SELECT rotations FROM state WHERE id = 1", &[])? .one::<i64>("SELECT rotations FROM state WHERE id = 1", &[])?
.unwrap_or(0) as u32) .unwrap_or(0) as u32)
} }
pub fn set_rotations(&self, index: u32) -> Result<(), SmolError> { pub fn set_rotations(&self, index: u32) -> Result<(), Error> {
self.db self.db()
.execute( .execute(
"UPDATE state SET rotations = ?1 WHERE id = 1", "UPDATE state SET rotations = ?1 WHERE id = 1",
params![index], params![index],
@ -161,15 +191,15 @@ impl Store {
Ok(()) 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 let (after_time, after_id): (i64, Vec<u8>) = self
.db .db()
.query_row( .query_row(
"SELECT after_time, after_id FROM state WHERE id = 1", "SELECT after_time, after_id FROM state WHERE id = 1",
[], [],
|row| Ok((row.get(0)?, row.get(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 { let after_id: [u8; ID_LEN] = if after_id.len() == ID_LEN {
after_id.try_into().unwrap() after_id.try_into().unwrap()
} else { } else {
@ -178,8 +208,8 @@ impl Store {
Ok((after_time, after_id)) Ok((after_time, after_id))
} }
pub fn set_cursor(&self, after_time: i64, after_id: &[u8; ID_LEN]) -> Result<(), SmolError> { pub fn set_cursor(&self, after_time: i64, after_id: &[u8; ID_LEN]) -> Result<(), Error> {
self.db self.db()
.execute( .execute(
"UPDATE state SET after_time = ?1, after_id = ?2 WHERE id = 1", "UPDATE state SET after_time = ?1, after_id = ?2 WHERE id = 1",
params![after_time, after_id], params![after_time, after_id],
@ -190,15 +220,15 @@ impl Store {
/// Whether the local accept-token set may replace the server's: a client /// Whether the local accept-token set may replace the server's: a client
/// restored from the master alone must not erase it (sec 4). /// 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 Ok(self
.one::<i64>("SELECT sync_ok FROM state WHERE id = 1", &[])? .one::<i64>("SELECT sync_ok FROM state WHERE id = 1", &[])?
.unwrap_or(1) .unwrap_or(1)
!= 0) != 0)
} }
pub fn set_sync_ok(&self, ok: bool) -> Result<(), SmolError> { pub fn set_sync_ok(&self, ok: bool) -> Result<(), Error> {
self.db self.db()
.execute( .execute(
"UPDATE state SET sync_ok = ?1 WHERE id = 1", "UPDATE state SET sync_ok = ?1 WHERE id = 1",
params![ok as i64], params![ok as i64],
@ -207,15 +237,15 @@ impl Store {
Ok(()) 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 Ok(self
.one::<Option<Vec<u8>>>("SELECT static FROM servers WHERE host = ?1", &[&host])? .one::<Option<Vec<u8>>>("SELECT static FROM servers WHERE host = ?1", &[&host])?
.flatten() .flatten()
.and_then(|k| k.try_into().ok())) .and_then(|k| k.try_into().ok()))
} }
pub fn pin_server(&self, host: &str, key: &[u8; KEY_LEN]) -> Result<(), SmolError> { pub fn pin_server(&self, host: &str, key: &[u8; KEY_LEN]) -> Result<(), Error> {
self.db self.db()
.execute( .execute(
"INSERT INTO servers (host, static, pinned_at) VALUES (?1, ?2, ?3) \ "INSERT INTO servers (host, static, pinned_at) VALUES (?1, ?2, ?3) \
ON CONFLICT (host) DO UPDATE SET static = ?2, pinned_at = ?3", ON CONFLICT (host) DO UPDATE SET static = ?2, pinned_at = ?3",
@ -225,8 +255,8 @@ impl Store {
Ok(()) Ok(())
} }
pub fn contact(&self, address: &str) -> Result<Option<([u8; KEY_LEN], bool)>, SmolError> { pub fn contact(&self, address: &str) -> Result<Option<([u8; KEY_LEN], bool)>, Error> {
let row = self.db.query_row( let row = self.db().query_row(
"SELECT identity, verified FROM contacts WHERE address = ?1", "SELECT identity, verified FROM contacts WHERE address = ?1",
[&address], [&address],
|row| Ok((row.get::<_, Vec<u8>>(0)?, row.get::<_, i64>(1)?)), |row| Ok((row.get::<_, Vec<u8>>(0)?, row.get::<_, i64>(1)?)),
@ -235,7 +265,7 @@ impl Store {
Ok((identity, verified)) => Ok(Some(( Ok((identity, verified)) => Ok(Some((
identity identity
.try_into() .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, verified != 0,
))), ))),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
@ -248,8 +278,8 @@ impl Store {
address: &str, address: &str,
identity: &[u8; KEY_LEN], identity: &[u8; KEY_LEN],
verified: bool, verified: bool,
) -> Result<(), SmolError> { ) -> Result<(), Error> {
self.db self.db()
.execute( .execute(
"INSERT INTO contacts (address, identity, verified, seen_at) VALUES (?1, ?2, ?3, ?4) \ "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", 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`. /// Every contact with its acceptance state, for `fumi contacts`.
pub fn contact_rows(&self) -> Result<Vec<ContactRow>, SmolError> { pub fn contact_rows(&self) -> Result<Vec<ContactRow>, Error> {
let mut stmt = self.db.prepare( let db = self.db();
let mut stmt = db.prepare(
"SELECT c.address, c.identity, c.verified, a.active FROM contacts c \ "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", 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). /// 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()? { if !self.sync_ok()? {
return Ok((0, Vec::new())); return Ok((0, Vec::new()));
} }
let mut stmt = self let db = self.db();
.db let mut stmt =
.prepare("SELECT identity FROM accepted WHERE active = 1 ORDER BY added_at")?; db.prepare("SELECT identity FROM accepted WHERE active = 1 ORDER BY added_at")?;
let tokens = stmt let tokens = stmt
.query_map([], |row| row.get::<_, Vec<u8>>(0))? .query_map([], |row| row.get::<_, Vec<u8>>(0))?
.collect::<Result<Vec<_>, _>>()?; .collect::<Result<Vec<_>, _>>()?;
@ -313,8 +344,9 @@ impl Store {
&self, &self,
sender: &[u8], sender: &[u8],
reply_to: Option<&str>, reply_to: Option<&str>,
) -> Result<Option<String>, SmolError> { ) -> Result<Option<String>, Error> {
let mut stmt = self.db.prepare("SELECT address, identity FROM contacts")?; let db = self.db();
let mut stmt = db.prepare("SELECT address, identity FROM contacts")?;
let rows = stmt let rows = stmt
.query_map([], |row| { .query_map([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, Vec<u8>>(1)?)) 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 /// Admits a correspondent to the main tier, freezing the identity the
/// token is derived from (sec 5.8): on conflict the identity is left /// token is derived from (sec 5.8): on conflict the identity is left
/// alone, so the token stays the one they already hold. /// alone, so the token stays the one they already hold.
pub fn accept(&self, address: &str, identity: &[u8; KEY_LEN]) -> Result<(), SmolError> { pub fn accept(&self, address: &str, identity: &[u8; KEY_LEN]) -> Result<(), Error> {
self.db self.db()
.execute( .execute(
"INSERT INTO accepted (address, identity, active, added_at) VALUES (?1, ?2, 1, ?3) \ "INSERT INTO accepted (address, identity, active, added_at) VALUES (?1, ?2, 1, ?3) \
ON CONFLICT (address) DO UPDATE SET active = 1", ON CONFLICT (address) DO UPDATE SET active = 1",
@ -350,8 +382,8 @@ impl Store {
Ok(()) Ok(())
} }
pub fn block(&self, address: &str) -> Result<bool, SmolError> { pub fn block(&self, address: &str) -> Result<bool, Error> {
let changed = self.db.execute( let changed = self.db().execute(
"UPDATE accepted SET active = 0 WHERE address = ?1", "UPDATE accepted SET active = 0 WHERE address = ?1",
params![address], params![address],
)?; )?;
@ -360,7 +392,7 @@ impl Store {
/// The identity frozen at acceptance, if this address is currently /// The identity frozen at acceptance, if this address is currently
/// accepted; the token for our own mailbox travels in the next message. /// 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( let identity: Option<Vec<u8>> = self.one(
"SELECT identity FROM accepted WHERE address = ?1 AND active = 1", "SELECT identity FROM accepted WHERE address = ?1 AND active = 1",
&[&address], &[&address],
@ -369,14 +401,14 @@ impl Store {
} }
/// A token a correspondent issued us, filed under their address (sec 5.8). /// 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>> = let token: Option<Vec<u8>> =
self.one("SELECT token FROM tokens WHERE address = ?1", &[&address])?; self.one("SELECT token FROM tokens WHERE address = ?1", &[&address])?;
Ok(token.and_then(|token| token.try_into().ok())) Ok(token.and_then(|token| token.try_into().ok()))
} }
pub fn save_token(&self, address: &str, token: &[u8; 32]) -> Result<(), SmolError> { pub fn save_token(&self, address: &str, token: &[u8; 32]) -> Result<(), Error> {
self.db self.db()
.execute( .execute(
"INSERT INTO tokens (address, token, seen_at) VALUES (?1, ?2, ?3) \ "INSERT INTO tokens (address, token, seen_at) VALUES (?1, ?2, ?3) \
ON CONFLICT (address) DO UPDATE SET token = ?2, seen_at = ?3", ON CONFLICT (address) DO UPDATE SET token = ?2, seen_at = ?3",
@ -386,7 +418,7 @@ impl Store {
Ok(()) 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 Ok(self
.one::<i64>("SELECT 1 FROM seen WHERE id = ?1", &[id])? .one::<i64>("SELECT 1 FROM seen WHERE id = ?1", &[id])?
.is_some()) .is_some())
@ -398,19 +430,19 @@ impl Store {
envelope: &[u8], envelope: &[u8],
received_at: i64, received_at: i64,
requests_tier: bool, requests_tier: bool,
) -> Result<(), SmolError> { ) -> Result<(), Error> {
let tier = if requests_tier { let tier = if requests_tier {
TIER_REQUESTS TIER_REQUESTS
} else { } else {
TIER_MAIN TIER_MAIN
}; };
self.db self.db()
.execute( .execute(
"INSERT OR IGNORE INTO inbox (id, envelope, received_at, tier) VALUES (?1, ?2, ?3, ?4)", "INSERT OR IGNORE INTO inbox (id, envelope, received_at, tier) VALUES (?1, ?2, ?3, ?4)",
params![id, envelope, received_at, tier], params![id, envelope, received_at, tier],
) )
.map(|_| ())?; .map(|_| ())?;
self.db self.db()
.execute( .execute(
"INSERT OR IGNORE INTO seen (id, at) VALUES (?1, ?2)", "INSERT OR IGNORE INTO seen (id, at) VALUES (?1, ?2)",
params![id, received_at], params![id, received_at],
@ -425,8 +457,8 @@ impl Store {
recipient: &str, recipient: &str,
envelope: &[u8], envelope: &[u8],
sent_at: i64, sent_at: i64,
) -> Result<(), SmolError> { ) -> Result<(), Error> {
self.db self.db()
.execute( .execute(
"INSERT OR IGNORE INTO sent (id, recipient, envelope, sent_at) VALUES (?1, ?2, ?3, ?4)", "INSERT OR IGNORE INTO sent (id, recipient, envelope, sent_at) VALUES (?1, ?2, ?3, ?4)",
params![id, recipient, envelope, sent_at], params![id, recipient, envelope, sent_at],
@ -437,7 +469,7 @@ impl Store {
/// One folder, ordered by arrival or sending time. `folder` is "inbox", /// One folder, ordered by arrival or sending time. `folder` is "inbox",
/// "requests", "sent" or "all". /// "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 { let (sql, tier): (&str, Option<i8>) = match folder {
"sent" => ( "sent" => (
"SELECT id, envelope, sent_at, recipient FROM sent ORDER BY sent_at, id", "SELECT id, envelope, sent_at, recipient FROM sent ORDER BY sent_at, id",
@ -458,7 +490,8 @@ impl Store {
Some(TIER_MAIN as i8), 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 { let rows = match tier {
Some(t) => stmt Some(t) => stmt
.query_map(params![t], row_stored)? .query_map(params![t], row_stored)?
@ -650,4 +683,42 @@ mod tests {
let _ = identity_seed(&master(), n as u32); 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 snow::{Builder, TransportState};
use crate::crypto::{b32, ct_eq, KEY_LEN}; use crate::crypto::{ct_eq, KEY_LEN};
use crate::error::SmolError; use crate::error::Error;
use crate::transport::{ use crate::transport::{
Response, Transport, TransportBindValues, MAX_FRAME, NOISE_PARAMS, NOISE_PAYLOAD, PROLOGUE, Response, Transport, TransportBindValues, MAX_FRAME, NOISE_PARAMS, NOISE_PAYLOAD, PROLOGUE,
}; };
@ -34,10 +34,12 @@ impl TcpTransport {
port: u16, port: u16,
pinned: Option<[u8; KEY_LEN]>, pinned: Option<[u8; KEY_LEN]>,
timeout: u64, timeout: u64,
) -> anyhow::Result<TcpTransport> { ) -> Result<TcpTransport, Error> {
let addr = (host, port); let stream = TcpStream::connect((host, port)).map_err(|source| Error::Unreachable {
let stream = TcpStream::connect(addr) host: host.to_string(),
.map_err(|e| anyhow::anyhow!("cannot reach {host}:{port}: {e}"))?; port,
source,
})?;
stream.set_read_timeout(Some(Duration::from_secs(timeout)))?; stream.set_read_timeout(Some(Duration::from_secs(timeout)))?;
stream.set_write_timeout(Some(Duration::from_secs(timeout)))?; stream.set_write_timeout(Some(Duration::from_secs(timeout)))?;
let mut stream = stream; let mut stream = stream;
@ -54,25 +56,25 @@ impl TcpTransport {
stream.read_exact(&mut message)?; stream.read_exact(&mut message)?;
noise.read_message(&message, &mut buf)?; noise.read_message(&message, &mut buf)?;
if !noise.is_handshake_finished() { 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 let server_static: [u8; KEY_LEN] = noise
.get_remote_static() .get_remote_static()
.ok_or_else(|| anyhow::anyhow!("server sent no static key"))? .ok_or_else(|| Error::Other("server sent no static key".into()))?
.try_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 handshake_hash = noise.get_handshake_hash().to_vec();
let bind = TransportBindValues::tcp(&handshake_hash, &server_static)?; let bind = TransportBindValues::tcp(&handshake_hash, &server_static)?;
let transport = noise.into_transport_mode()?; let transport = noise.into_transport_mode()?;
if let Some(pinned) = pinned { if let Some(pinned) = pinned {
if !ct_eq(&pinned, &server_static) { if !ct_eq(&pinned, &server_static) {
return Err(anyhow::anyhow!( return Err(Error::PinMismatch {
"{host} presented a different key than the one pinned\n pinned: {}\n \ host: host.to_string(),
presented: {}", pinned,
b32(&pinned), presented: server_static,
b32(&server_static) });
));
} }
} }
@ -96,7 +98,7 @@ impl TcpTransport {
&self.host &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 len = read_u16_len(&mut self.stream)?;
let mut ciphertext = vec![0u8; len]; let mut ciphertext = vec![0u8; len];
self.stream.read_exact(&mut ciphertext)?; self.stream.read_exact(&mut ciphertext)?;
@ -106,7 +108,7 @@ impl TcpTransport {
Ok(plaintext) 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 mut packet = vec![0u8; payload.len() + 16];
let n = self.noise.write_message(payload, &mut packet)?; let n = self.noise.write_message(payload, &mut packet)?;
packet.truncate(n); packet.truncate(n);
@ -119,10 +121,10 @@ impl Transport for TcpTransport {
/// Sends one application frame (u32 length || op || body, split across /// Sends one application frame (u32 length || op || body, split across
/// as many Noise messages as it needs) and reads the response frame, /// as many Noise messages as it needs) and reads the response frame,
/// returning its status byte and body (sec 4, sec 6.1). /// 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(); let length = 1 + body.len();
if length > MAX_FRAME { 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); let mut frame = Vec::with_capacity(4 + length);
frame.extend_from_slice(&(length as u32).to_be_bytes()); 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; let length = u32::from_be_bytes(self.buf[..4].try_into().unwrap()) as usize;
if length == 0 || length > MAX_FRAME { if length == 0 || length > MAX_FRAME {
return Err(SmolError::new(format!( return Err(Error::Other(format!(
"server sent a frame of length {length}" "server sent a frame of length {length}"
))); )));
} }

View file

@ -6,7 +6,7 @@
// Used only by the RNS bind values and their tests. // Used only by the RNS bind values and their tests.
#[cfg(any(test, feature = "rns"))] #[cfg(any(test, feature = "rns"))]
use crate::crypto::sha256; use crate::crypto::sha256;
use crate::error::SmolError; use crate::error::Error;
pub const NOISE_PARAMS: &str = "Noise_NX_25519_ChaChaPoly_SHA256"; pub const NOISE_PARAMS: &str = "Noise_NX_25519_ChaChaPoly_SHA256";
pub const PROLOGUE: &[u8] = b"smolmail/1"; pub const PROLOGUE: &[u8] = b"smolmail/1";
@ -71,30 +71,30 @@ impl<'a> Reader<'a> {
Reader { buf, pos: 0 } 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() { 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]; let out = &self.buf[self.pos..self.pos + n];
self.pos += n; self.pos += n;
Ok(out) Ok(out)
} }
pub fn u8(&mut self) -> Result<u8, SmolError> { pub fn u8(&mut self) -> Result<u8, Error> {
Ok(self.take(1)?[0]) 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)?; let b = self.take(2)?;
Ok(u16::from_be_bytes([b[0], b[1]])) 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)?; let b = self.take(4)?;
Ok(u32::from_be_bytes(b.try_into().unwrap())) 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)?; let b = self.take(8)?;
Ok(i64::from_be_bytes(b.try_into().unwrap())) Ok(i64::from_be_bytes(b.try_into().unwrap()))
} }
@ -105,9 +105,9 @@ impl<'a> Reader<'a> {
out out
} }
pub fn done(&self) -> Result<(), SmolError> { pub fn done(&self) -> Result<(), Error> {
if self.pos != self.buf.len() { if self.pos != self.buf.len() {
return Err(SmolError::new("trailing bytes in response")); return Err(Error::Other("trailing bytes in response".into()));
} }
Ok(()) Ok(())
} }
@ -127,11 +127,11 @@ pub struct TransportBindValues {
impl TransportBindValues { impl TransportBindValues {
/// Noise binds to the handshake hash and the server's real static key. /// 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 { Ok(Self {
h: handshake_hash h: handshake_hash
.try_into() .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, server_static: *server_static,
}) })
} }
@ -155,7 +155,7 @@ impl TransportBindValues {
/// server identity came from a trusted channel, which decides whether /// server identity came from a trusted channel, which decides whether
/// RESOLVE results may be marked verified (sec 4, RNS.md sec 13.4). /// RESOLVE results may be marked verified (sec 4, RNS.md sec 13.4).
pub trait Transport { 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 bind(&self) -> &TransportBindValues;
fn pinned(&self) -> bool; fn pinned(&self) -> bool;
fn close(&mut self); fn close(&mut self);
@ -165,9 +165,6 @@ pub trait Transport {
mod tests { mod tests {
use super::*; 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] = [ const DEST: [u8; 16] = [
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e,
0x0f, 0x0f,
@ -176,17 +173,21 @@ mod tests {
0xa0, 0xa1, 0xa2, 0xa3, 0xa4, 0xa5, 0xa6, 0xa7, 0xa8, 0xa9, 0xaa, 0xab, 0xac, 0xad, 0xae, 0xa0, 0xa1, 0xa2, 0xa3, 0xa4, 0xa5, 0xa6, 0xa7, 0xa8, 0xa9, 0xaa, 0xab, 0xac, 0xad, 0xae,
0xaf, 0xaf,
]; ];
const H_HEX: &str = "06d6437dc63250ff51d609e12b488ff22b4fa02620f35000b3b2ed1c8789d45c";
const SERVER_STATIC_HEX: &str =
"da6fe109dc4da2878fa4810ffa0e08cef6b68a8b524126aa05bb14181b20d25a";
#[test] #[test]
fn rns_matches_reference_vectors() { fn rns_matches_reference_vectors() {
let bind = TransportBindValues::rns(&DEST, &LINK); // The SHA-256("smolmail/1 bind" || destination || link_id) formula
assert_eq!(data_encoding::HEXLOWER.encode(&bind.h), H_HEX); // 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!( assert_eq!(
data_encoding::HEXLOWER.encode(&bind.server_static), 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})")))
}