diff --git a/Cargo.lock b/Cargo.lock index 4059476..11e9184 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -49,15 +49,6 @@ dependencies = [ "zerocopy", ] -[[package]] -name = "aho-corasick" -version = "1.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" -dependencies = [ - "memchr", -] - [[package]] name = "anstream" version = "1.0.0" @@ -120,12 +111,6 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" -[[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - [[package]] name = "bitflags" version = "2.13.2" @@ -315,37 +300,6 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" -[[package]] -name = "defmt" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" -dependencies = [ - "bitflags 1.3.2", - "defmt-macros", -] - -[[package]] -name = "defmt-macros" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" -dependencies = [ - "defmt-parser", - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "defmt-parser" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" -dependencies = [ - "thiserror", -] - [[package]] name = "der" version = "0.7.10" @@ -392,29 +346,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "env_filter" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217" -dependencies = [ - "log", - "regex", -] - -[[package]] -name = "env_logger" -version = "0.11.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6" -dependencies = [ - "anstream", - "anstyle", - "env_filter", - "jiff", - "log", -] - [[package]] name = "fallible-iterator" version = "0.3.0" @@ -444,17 +375,26 @@ name = "fumi" version = "0.1.0" dependencies = [ "anyhow", - "cc", - "chacha20poly1305", "clap", "data-encoding", + "fumi-core", + "rand_core", +] + +[[package]] +name = "fumi-core" +version = "0.1.0" +dependencies = [ + "cc", + "chacha20poly1305", + "data-encoding", "ed25519-dalek", - "env_logger", "hkdf", "hmac", - "log", "rand_core", "rusqlite", + "serde", + "serde_json", "sha2", "snow", "x25519-dalek", @@ -549,41 +489,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] -name = "jiff" -version = "0.2.37" +name = "itoa" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ab1baf72f08796de0260609515130699b890ac25f30e610ad894bc5856cafdb" -dependencies = [ - "defmt", - "jiff-core", - "jiff-static", - "log", - "portable-atomic", - "portable-atomic-util", - "serde_core", -] - -[[package]] -name = "jiff-core" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c" -dependencies = [ - "defmt", - "log", -] - -[[package]] -name = "jiff-static" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "378268a1116ad67ae6228701118ac9f491d78fda38a40a1f1a9e1348de6f7212" -dependencies = [ - "jiff-core", - "proc-macro2", - "quote", - "syn 2.0.119", -] +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "libc" @@ -602,12 +511,6 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "log" -version = "0.4.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" - [[package]] name = "memchr" version = "2.8.3" @@ -671,21 +574,6 @@ dependencies = [ "universal-hash", ] -[[package]] -name = "portable-atomic" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" - -[[package]] -name = "portable-atomic-util" -version = "0.2.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" -dependencies = [ - "portable-atomic", -] - [[package]] name = "proc-macro2" version = "1.0.107" @@ -713,42 +601,13 @@ dependencies = [ "getrandom", ] -[[package]] -name = "regex" -version = "1.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "regex-automata" -version = "0.4.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.8.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" - [[package]] name = "rusqlite" version = "0.32.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" dependencies = [ - "bitflags 2.13.2", + "bitflags", "fallible-iterator", "fallible-streaming-iterator", "hashlink", @@ -801,6 +660,19 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + [[package]] name = "sha2" version = "0.10.9" @@ -893,26 +765,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "thiserror" -version = "2.0.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" -dependencies = [ - "thiserror-impl", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.6", -] - [[package]] name = "typenum" version = "1.20.1" @@ -1025,3 +877,9 @@ dependencies = [ "quote", "syn 2.0.119", ] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index e0542bb..0630452 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,34 +1,4 @@ -[package] -name = "fumi" -version = "0.1.0" -edition = "2021" -description = "Smol Mail client" -license = "Apache-2.0" - -[[bin]] -name = "fumi" -path = "src/main.rs" - -[features] -# RNS carrier over microReticulum (RNS.md); needs MICRORETICULUM_SOURCE_DIR -# at build time, provided by the flake. -rns = ["dep:cc"] - -[dependencies] -snow = "0.9" -chacha20poly1305 = "0.10" -ed25519-dalek = { version = "2", features = ["rand_core"] } -x25519-dalek = { version = "2", features = ["static_secrets"] } -sha2 = "0.10" -hmac = "0.12" -hkdf = "0.12" -rand_core = { version = "0.6", features = ["getrandom"] } -rusqlite = { version = "0.32", features = ["bundled"] } -data-encoding = "2" -clap = { version = "4", features = ["derive"] } -log = "0.4" -env_logger = "0.11" -anyhow = "1" - -[build-dependencies] -cc = { version = "1", optional = true } +[workspace] +members = ["core", "cli"] +default-members = ["cli"] +resolver = "2" diff --git a/README.md b/README.md index c68a2f8..5d18732 100644 --- a/README.md +++ b/README.md @@ -61,26 +61,45 @@ Mail is stored sealed and opened on demand; there is no plaintext at rest. A fir ``` nix build # or: nix develop -c cargo build -nix develop -c cargo test +nix develop -c cargo test --workspace ``` +The crate is a workspace: `core/` is the `fumi-core` library — everything but the command line — and `cli/` is the `fumi` binary. GUI hosts depend on `fumi-core` alone; it pulls no argument parsing, no logging globals, and no keyfile conventions. `bundled-sqlite` (default) vendors libsqlite3; embedders that link their own SQLite, as Android's NDK does, build with `default-features = false`. + `bunshin` and the reference clients make a complete test rig: register against a local server, exchange mail in both directions, then rotate and fetch the mail that the superseded key still receives. +## Embedding + +`fumi-core` is the protocol core of a GUI client, not just this CLI's engine. The contract an embedder can rely on: + +- **Secrets stay bytes in the API, files stay in the CLI.** `Account::new(master, index)` takes the master as bytes and `Store::open(path)` takes only mail and state; `identity.key` next to `fumi.db` is a CLI convention. A GUI keeps the master wherever its platform wants it (Android Keystore, OS keychain) and hands it over per operation. +- **One shareable store handle.** `Store` is `Send + Sync`: every operation locks an inner mutex, and the database runs in WAL mode, so a host holds one `Store` behind an executor and reads it while a fetch writes. Each envelope is committed as it is verified — a concurrent reader listing the inbox sees fetch progress without any callback. +- **Decisions, not prose.** `error::Error` is an enum: `NotPinned`, `PinMismatch`, `KeyChanged { address, known, offered }`, `NotRegistered`, `AuthFailed`, `RateLimited`, `QuotaExceeded`, `SchemaVersion`, ... — the distinctions a UI routes on. `Display` still produces the message the CLI prints. +- **Long operations can stop.** `fetch_with` takes a `FetchOptions` with a cancellation token checked between pages; the cursor is saved per page, so a cancelled fetch resumes rather than repeats. +- **Composable flows.** `restore` remains the one-call convenience, but its pieces are public: `connect` + `resolve`, `account::find_rotation_index`, then the `Store` setters — a wizard can hold the master and the address before it has connectivity, and show each step. Trust outcomes arrive as values: `TrustChange` from `trust_key` and `send`, `Session::unpinned_static` for the sec 4 warning. +- **A versioned store.** The schema carries a `user_version`; a store written by a newer build is refused (`Error::SchemaVersion`) rather than misread, and within a version the schema is stable. The host, not the library, decides when to upgrade the binary. +- **Binding from other languages.** The API is `Send`-friendly and free of CLI globals, so plain Rust bindings (flutter_rust_bridge, uniffi) work against it. `core/vectors.json` holds the reference vectors the unit tests pin — derivations, seals, a full envelope — so a port or FFI binding can verify itself end to end without the reference stack. + +The intended call graph: `Store::open` once, `Account::new(master, rotations)` per session, then `client::*` operations taking `&Store` and `&Account`. The store outlives accounts; nothing in the library caches between calls. + ## Modules | File | Contents | |---|---| -| `src/crypto.rs` | base32, HKDF, HMAC, SHA-256, the Ed25519→X25519 map with SPEC.md §2's checks | -| `src/address.rs` | parsing for all four address forms, username validation, fingerprints | -| `src/account.rs` | master, `seed_n` derivation, accept-key and tokens, rotation certificates | -| `src/message.rs` | envelope seal/open (§5.1–§5.3), message id (§5.4), frontmatter (§5.5) | -| `src/transport.rs` | `Transport` trait, bind values, operation and status constants | -| `src/tcp.rs` | Noise_NX initiator, `len u32 \|\| op u8 \|\| body` framing, static-key pinning | -| `src/rns/` | the Reticulum carrier behind the `rns` feature: FFI over `shim/`, path discovery, one link per session | -| `src/client.rs` | the six operations, AUTH, chain walking, token sync, fetch pipeline | -| `src/store.rs` | SQLite: state, contacts, accepted, tokens, seen ids, inbox, sent | +| `core/src/crypto.rs` | base32, HKDF, HMAC, SHA-256, the Ed25519→X25519 map with SPEC.md §2's checks | +| `core/src/address.rs` | parsing for all four address forms, username validation, fingerprints | +| `core/src/account.rs` | master, `seed_n` derivation, accept-key and tokens, rotation certificates, `find_rotation_index` | +| `core/src/message.rs` | envelope seal/open (§5.1–§5.3), message id (§5.4), frontmatter (§5.5) | +| `core/src/transport.rs` | `Transport` trait, bind values, operation and status constants | +| `core/src/tcp.rs` | Noise_NX initiator, `len u32 \|\| op u8 \|\| body` framing, static-key pinning | +| `core/src/rns/` | the Reticulum carrier behind the `rns` feature: FFI over `core/shim/`, path discovery, one link per session | +| `core/src/client.rs` | the six operations, AUTH, chain walking, token sync, fetch pipeline with cancellation | +| `core/src/store.rs` | SQLite: state, contacts, accepted, tokens, seen ids, inbox, sent; `Send + Sync`, WAL, versioned schema | +| `core/src/error.rs` | the `Error` enum embedders match on, the status-code mapping | +| `core/vectors.json` | the committed reference vectors, the tests' source of truth | +| `cli/src/main.rs` | the command line: keyfile and `--db` conventions, hints, output | -Unit tests pin every derivation against vectors generated from `smolmail.py`, including a full envelope reproduced byte for byte. +Unit tests pin every derivation against `vectors.json`, whose values were generated from `smolmail.py`, including a full envelope reproduced byte for byte. ## References diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 301ee5c..11299f5 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -19,7 +19,6 @@ The libraries marked "vendored source" are pinned nix flake inputs; each is iden - anstyle-wincon 3.0.11 - Copyright (c) Individual contributors - anyhow 1.0.104 - David Tolnay - base64ct 1.8.3 - Copyright (c) 2014 Steve "Sc00bz" Thomas (steve at tobtu dot com) Copyright (c) 2021-2025 The RustCrypto Project Developers -- bitflags 1.3.2 - Copyright (c) 2014 The Rust Project Developers - bitflags 2.13.2 - Copyright (c) 2014 The Rust Project Developers - blake2 0.10.6 - Copyright (c) 2015-2016 The blake2-rfc Developers, Cesar Barros Copyright (c) 2017 Artyom Pavlov - block-buffer 0.10.4 - Copyright (c) 2018-2019 The RustCrypto Project Developers @@ -38,14 +37,9 @@ The libraries marked "vendored source" are pinned nix flake inputs; each is iden - crypto-common 0.1.7 - Copyright (c) 2021 RustCrypto Developers - ctr 0.9.2 - Copyright (c) 2018-2022 RustCrypto Developers Copyright (c) 2018 Artyom Pavlov - curve25519-dalek-derive 0.1.1 - The curve25519-dalek-derive developers -- defmt 1.1.1 - Copyright (c) Ferrous Systems -- defmt-macros 1.1.1 - Copyright (c) Ferrous Systems -- defmt-parser 1.0.0 - The Knurling-rs developers - der 0.7.10 - Copyright (c) 2020-2023 The RustCrypto Project Developers - digest 0.10.7 - Copyright (c) 2017 Artyom Pavlov - ed25519 2.2.3 - Copyright 2018-2022 RustCrypto Developers -- env_filter 2.0.0 - Copyright (c) Individual contributors -- env_logger 0.11.11 - Copyright (c) Individual contributors - fallible-iterator 0.3.0 - Copyright (c) 2015 The rust-openssl-verify Developers - fallible-streaming-iterator 0.1.9 - Copyright (c) 2016 The fallible-streaming-iterator Developers - fiat-crypto 0.2.9 - Copyright 2015-2020 the fiat-crypto authors (see the AUTHORS file) @@ -59,8 +53,8 @@ The libraries marked "vendored source" are pinned nix flake inputs; each is iden - hmac 0.12.1 - Copyright (c) 2017 Artyom Pavlov - inout 0.1.4 - Copyright (c) 2022 The RustCrypto Project Developers Copyright (c) 2022 Artyom Pavlov - is_terminal_polyfill 1.70.2 - Copyright (c) Individual contributors +- itoa 1.0.18 - David Tolnay - 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 - once_cell_polyfill 1.70.2 - Copyright (c) Individual contributors - opaque-debug 0.3.1 - Copyright (c) 2018-2024 The RustCrypto Project Developers @@ -68,19 +62,15 @@ The libraries marked "vendored source" are pinned nix flake inputs; each is iden - pkg-config 0.3.34 - Copyright (c) 2014 Alex Crichton - poly1305 0.8.0 - Copyright (c) 2015-2019 RustCrypto Developers - polyval 0.6.2 - Copyright (c) 2019-2023 RustCrypto Developers -- portable-atomic 1.15.0 - The portable-atomic developers -- portable-atomic-util 0.2.8 - The portable-atomic-util developers - proc-macro2 1.0.107 - David Tolnay , Alex Crichton - quote 1.0.47 - David Tolnay - rand_core 0.6.4 - Copyright 2018 Developers of the Rand project Copyright (c) 2014 The Rust Project Developers -- regex 1.13.1 - Copyright (c) 2014 The Rust Project Developers -- regex-automata 0.4.18 - Copyright (c) 2014 The Rust Project Developers -- regex-syntax 0.8.11 - Copyright (c) 2014 The Rust Project Developers - rustc_version 0.4.1 - Copyright (c) 2016 The Rust Project Developers - semver 1.0.28 - David Tolnay - serde 1.0.229 - Erick Tryzelaar , David Tolnay - serde_core 1.0.229 - Erick Tryzelaar , David Tolnay - serde_derive 1.0.229 - Erick Tryzelaar , David Tolnay +- serde_json 1.0.151 - Erick Tryzelaar , David Tolnay - sha2 0.10.9 - Copyright (c) 2006-2009 Graydon Hoare Copyright (c) 2009-2013 Mozilla Foundation Copyright (c) 2016 Artyom Pavlov - shlex 2.0.1 - Copyright 2015 Nicholas Allegra (comex). - signature 2.2.0 - Copyright (c) 2018-2023 RustCrypto Developers @@ -89,8 +79,6 @@ The libraries marked "vendored source" are pinned nix flake inputs; each is iden - spki 0.7.3 - Copyright (c) 2021-2023 The RustCrypto Project Developers - syn 2.0.119 - David Tolnay - syn 3.0.6 - David Tolnay -- thiserror 2.0.21 - David Tolnay -- thiserror-impl 2.0.21 - David Tolnay - typenum 1.20.1 - Copyright 2014 Paho Lurie-Gregg - unicode-ident 1.0.26 - David Tolnay - universal-hash 0.5.1 - Copyright (c) 2019-2020 RustCrypto Developers @@ -317,7 +305,6 @@ Apache License - aes 0.8.4 - Copyright (c) 2018 Artyom Pavlov - aes-gcm 0.10.3 - Copyright (c) 2019 The RustCrypto Project Developers - ahash 0.8.12 - Copyright (c) 2018 Tom Kaitchuck -- aho-corasick 1.1.5 - Copyright (c) 2015 Andrew Gallant - anstream 1.0.0 - Copyright (c) Individual contributors - anstyle 1.0.14 - Copyright (c) Individual contributors - anstyle-parse 1.0.0 - Copyright (c) Individual contributors @@ -325,7 +312,6 @@ Apache License - anstyle-wincon 3.0.11 - Copyright (c) Individual contributors - anyhow 1.0.104 - David Tolnay - base64ct 1.8.3 - Copyright (c) 2014 Steve "Sc00bz" Thomas (steve at tobtu dot com) Copyright (c) 2021-2025 The RustCrypto Project Developers -- bitflags 1.3.2 - Copyright (c) 2014 The Rust Project Developers - bitflags 2.13.2 - Copyright (c) 2014 The Rust Project Developers - blake2 0.10.6 - Copyright (c) 2015-2016 The blake2-rfc Developers, Cesar Barros Copyright (c) 2017 Artyom Pavlov - block-buffer 0.10.4 - Copyright (c) 2018-2019 The RustCrypto Project Developers @@ -345,14 +331,9 @@ Apache License - ctr 0.9.2 - Copyright (c) 2018-2022 RustCrypto Developers Copyright (c) 2018 Artyom Pavlov - curve25519-dalek-derive 0.1.1 - The curve25519-dalek-derive developers - data-encoding 2.11.1 - Copyright (c) 2015-2020 Julien Cretin Copyright (c) 2017-2020 Google Inc. -- defmt 1.1.1 - Copyright (c) Ferrous Systems -- defmt-macros 1.1.1 - Copyright (c) Ferrous Systems -- defmt-parser 1.0.0 - The Knurling-rs developers - der 0.7.10 - Copyright (c) 2020-2023 The RustCrypto Project Developers - digest 0.10.7 - Copyright (c) 2017 Artyom Pavlov - ed25519 2.2.3 - Copyright (c) 2018-2023 RustCrypto Developers -- env_filter 2.0.0 - Copyright (c) Individual contributors -- env_logger 0.11.11 - Copyright (c) Individual contributors - fallible-iterator 0.3.0 - Copyright (c) 2015 The rust-openssl-verify Developers - fallible-streaming-iterator 0.1.9 - Copyright (c) 2016 The fallible-streaming-iterator Developers - fiat-crypto 0.2.9 - Copyright (c) 2015-2020 the fiat-crypto authors (see the AUTHORS file). @@ -367,12 +348,9 @@ Apache License - hmac 0.12.1 - Copyright (c) 2017 Artyom Pavlov - inout 0.1.4 - Copyright (c) 2022 The RustCrypto Project Developers Copyright (c) 2022 Artyom Pavlov - is_terminal_polyfill 1.70.2 - Copyright (c) Individual contributors -- jiff 0.2.37 - Copyright (c) 2015 Andrew Gallant -- jiff-core 0.1.1 - Copyright (c) 2015 Andrew Gallant -- jiff-static 0.2.37 - Copyright (c) 2015 Andrew Gallant +- itoa 1.0.18 - David Tolnay - libc 0.2.189 - Copyright (c) The Rust Project Developers - libsqlite3-sys 0.30.1 - Copyright (c) 2014-2021 The rusqlite developers -- log 0.4.34 - Copyright (c) 2014 The Rust Project Developers - memchr 2.8.3 - Copyright (c) 2015 Andrew Gallant - once_cell 1.21.4 - Aleksey Kladov - once_cell_polyfill 1.70.2 - Copyright (c) Individual contributors @@ -381,20 +359,16 @@ Apache License - pkg-config 0.3.34 - Copyright (c) 2014 Alex Crichton - poly1305 0.8.0 - Copyright (c) 2015-2019 RustCrypto Developers - polyval 0.6.2 - Copyright (c) 2019-2023 RustCrypto Developers -- portable-atomic 1.15.0 - The portable-atomic developers -- portable-atomic-util 0.2.8 - The portable-atomic-util developers - proc-macro2 1.0.107 - David Tolnay , Alex Crichton - quote 1.0.47 - David Tolnay - rand_core 0.6.4 - Copyright 2018 Developers of the Rand project Copyright (c) 2014 The Rust Project Developers -- regex 1.13.1 - Copyright (c) 2014 The Rust Project Developers -- regex-automata 0.4.18 - Copyright (c) 2014 The Rust Project Developers -- regex-syntax 0.8.11 - Copyright (c) 2014 The Rust Project Developers - rusqlite 0.32.1 - Copyright (c) 2014-2021 The rusqlite developers - rustc_version 0.4.1 - Copyright (c) 2016 The Rust Project Developers - semver 1.0.28 - David Tolnay - serde 1.0.229 - Erick Tryzelaar , David Tolnay - serde_core 1.0.229 - Erick Tryzelaar , David Tolnay - serde_derive 1.0.229 - Erick Tryzelaar , David Tolnay +- serde_json 1.0.151 - Erick Tryzelaar , David Tolnay - sha2 0.10.9 - Copyright (c) 2006-2009 Graydon Hoare Copyright (c) 2009-2013 Mozilla Foundation Copyright (c) 2016 Artyom Pavlov - shlex 2.0.1 - Copyright (c) 2015 Nicholas Allegra (comex). - signature 2.2.0 - Copyright (c) 2018-2023 RustCrypto Developers @@ -404,8 +378,6 @@ Apache License - strsim 0.11.1 - Copyright (c) 2015 Danny Guo Copyright (c) 2016 Titus Wormer Copyright (c) 2018 Akash Kurdekar - syn 2.0.119 - David Tolnay - syn 3.0.6 - David Tolnay -- thiserror 2.0.21 - David Tolnay -- thiserror-impl 2.0.21 - David Tolnay - typenum 1.20.1 - Copyright (c) 2014 Paho Lurie-Gregg - unicode-ident 1.0.26 - David Tolnay - universal-hash 0.5.1 - Copyright (c) 2019-2020 RustCrypto Developers @@ -419,12 +391,13 @@ Apache License - zerocopy-derive 0.8.59 - Copyright 2023 The Fuchsia Authors - zeroize 1.9.0 - Copyright (c) 2018-2026 The RustCrypto Project Developers - zeroize_derive 1.5.0 - Copyright (c) 2019-2026 The RustCrypto Project Developers +- zmij 1.0.23 - David Tolnay - ArduinoJson (vendored source, https://github.com/bblanchon/ArduinoJson, rev ed69feadb951) - MsgPack (vendored source, https://github.com/hideakitai/MsgPack, rev 1f552c31b940) - Copyright (c) 2020 Hideaki Tai - ArxContainer (vendored source, https://github.com/hideakitai/ArxContainer, rev d6affcd0bc83) - Copyright (c) 2019 Hideaki Tai - ArxTypeTraits (vendored source, https://github.com/hideakitai/ArxTypeTraits, rev 702de9cc59c7) - Copyright (c) 2020 Hideaki Tai - DebugLog (vendored source, https://github.com/hideakitai/DebugLog, rev b581f7dde6c2) - Copyright (c) 2019 Hideaki Tai -- Crypto (vendored source, https://github.com/attermann/Crypto, rev 984dc8913309) - Copyright (c) 2024 Chad Attermann +- Crypto (attermann) (vendored source, https://github.com/attermann/Crypto, rev 984dc8913309) - Copyright (c) 2024 Chad Attermann ``` The MIT License (MIT) @@ -570,10 +543,6 @@ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ## The Unlicense -- aho-corasick 1.1.5 - Andrew Gallant -- jiff 0.2.37 - Andrew Gallant -- jiff-core 0.1.1 - Andrew Gallant -- jiff-static 0.2.37 - Andrew Gallant - memchr 2.8.3 - Andrew Gallant , bluss ``` diff --git a/cli/Cargo.toml b/cli/Cargo.toml new file mode 100644 index 0000000..b1d2796 --- /dev/null +++ b/cli/Cargo.toml @@ -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" diff --git a/src/main.rs b/cli/src/main.rs similarity index 92% rename from src/main.rs rename to cli/src/main.rs index ba94655..fe3c456 100644 --- a/src/main.rs +++ b/cli/src/main.rs @@ -4,14 +4,16 @@ use std::io::{IsTerminal, Read, Write}; use std::path::PathBuf; -use anyhow::{Context, Result}; +use anyhow::{anyhow, Context, Result}; use clap::{Parser, Subcommand}; +use fumi_core as fumi; + use fumi::account::{identity_seed, Account}; use fumi::address::Address; use fumi::client::{self, Pushed, SendDraft, TrustChange}; use fumi::crypto::{b32, fingerprint, unb32, KEY_LEN}; -use fumi::error::SmolError; + use fumi::store::Store; use fumi::transport::ID_LEN; @@ -146,10 +148,30 @@ fn main() { let cli = Cli::parse(); if let Err(e) = run(cli) { eprintln!("error: {e}"); + hint(e.downcast_ref::()); 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} " + ), + 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 ") + } + _ => {} + } +} + fn run(cli: Cli) -> Result<()> { let store = Store::open(&cli.db)?; #[cfg(feature = "rns")] @@ -186,18 +208,17 @@ fn warn(message: &str) { fn load_master(path: &PathBuf) -> Result<[u8; KEY_LEN]> { let bytes = std::fs::read(path).map_err(|_| { - SmolError::new(format!( + anyhow!(format!( "no identity at {}; run: fumi keygen", path.display() )) })?; bytes.try_into().map_err(|v: Vec| { - SmolError::new(format!( + anyhow!(format!( "master secret at {} is {} bytes, expected {KEY_LEN}", path.display(), v.len() )) - .into() }) } @@ -232,7 +253,7 @@ fn write_secret(path: &PathBuf, secret: &[u8]) -> Result<()> { fn keygen(path: &PathBuf, force: bool) -> Result<()> { if path.exists() && !force { - return Err(SmolError::new(format!( + return Err(anyhow!(format!( "{} exists; refusing to overwrite (use --force)", path.display() )) @@ -315,7 +336,7 @@ fn rotate(key: &PathBuf, store: &Store, timeout: u64) -> Result<()> { fn trust(store: &Store, host: &str, key_b32: &str, force: bool) -> Result<()> { let key: [u8; KEY_LEN] = unb32(key_b32)?.try_into().map_err(|v: Vec| { - SmolError::new(format!( + anyhow!(format!( "server key is {} bytes, expected {KEY_LEN}", v.len() )) @@ -325,7 +346,7 @@ fn trust(store: &Store, host: &str, key_b32: &str, force: bool) -> Result<()> { let host = host.split(':').next().unwrap_or(host); match store.server_pin(host)? { Some(old) if old != key && !force => { - return Err(SmolError::new(format!( + return Err(anyhow!(format!( "{host} is already pinned to {}; use --force to replace", b32(&old) )) @@ -357,7 +378,7 @@ fn resolve(store: &Store, address: &str, timeout: u64) -> Result<()> { let addr = Address::parse(address)?; if addr.identity.is_some() { return Err( - SmolError::new("that address already carries a key; use `import` instead").into(), + anyhow!("that address already carries a key; use `import` instead").into(), ); } let mut session = client::connect(store, &addr, false, timeout)?; @@ -398,7 +419,7 @@ fn import(store: &Store, uri: &str) -> Result<()> { let addr = Address::parse(uri)?; let key = addr .identity - .ok_or_else(|| SmolError::new("import needs a self-certifying address carrying a key"))?; + .ok_or_else(|| anyhow!("import needs a self-certifying address carrying a key"))?; store.save_contact(&addr.short(), &key, true)?; println!("imported {} {} (verified)", addr.short(), b32(&key)); println!("fingerprint: {}", fingerprint(&key)); @@ -435,7 +456,7 @@ fn target_contact(store: &Store, text: &str) -> Result<(String, [u8; KEY_LEN])> } match store.contact(&addr.short())? { Some((key, _)) => Ok((addr.short(), key)), - None => Err(SmolError::new(format!( + None => Err(anyhow!(format!( "no key for {}; run `resolve` or `import` first", addr.short() )) @@ -467,7 +488,7 @@ fn block(key: &PathBuf, store: &Store, address: &str, timeout: u64) -> Result<() let account = load_account(key, store)?; let (address, _) = target_contact(store, address)?; if !store.block(&address)? { - return Err(SmolError::new(format!("{address} was never accepted")).into()); + return Err(anyhow!(format!("{address} was never accepted"))); } println!("blocked {address}; their mail lands in requests from their next message on"); match client::push_tokens(store, &account, timeout)? { @@ -503,13 +524,13 @@ fn send(cli: &Cli, store: &Store) -> Result<()> { } (None, None) if !std::io::stdin().is_terminal() => read_stdin()?, (None, None) => { - return Err(SmolError::new("no message body; pass --body or pipe it on stdin").into()) + return Err(anyhow!("no message body; pass --body or pipe it on stdin").into()) } }; if let Some(reply) = reply_to { if reply.len() != 64 || !reply.bytes().all(|c| c.is_ascii_hexdigit()) { return Err( - SmolError::new("--reply-to must be a message id: 64 hex characters").into(), + anyhow!("--reply-to must be a message id: 64 hex characters"), ); } } @@ -519,12 +540,12 @@ fn send(cli: &Cli, store: &Store) -> Result<()> { for raw in headers { let Some((k, v)) = raw.split_once(':') else { return Err( - SmolError::new(format!("{raw:?} is not a valid `Key: value` header")).into(), + anyhow!(format!("{raw:?} is not a valid `Key: value` header")), ); }; if !valid_frontmatter_key(k.trim()) { return Err( - SmolError::new(format!("{raw:?} is not a valid `Key: value` header")).into(), + anyhow!(format!("{raw:?} is not a valid `Key: value` header")), ); } extra.push((k.trim().to_string(), v.trim().to_string())); @@ -603,7 +624,9 @@ fn fetch(key: &PathBuf, store: &Store, keep: bool, reset: bool, timeout: u64) -> summary.stored, summary.rejected.len() ); - if keep && summary.total > 0 { + if summary.cancelled { + line.push_str("; cancelled, fetching again continues"); + } else if keep && summary.total > 0 { line.push_str(" (left on the server)"); } println!("{line}"); @@ -614,7 +637,7 @@ fn fetch(key: &PathBuf, store: &Store, keep: bool, reset: bool, timeout: u64) -> /// ids, ready for the wire. fn resolve_id_prefixes(store: &Store, ids: &[String]) -> Result> { if ids.is_empty() { - return Err(SmolError::new("no message ids given").into()); + return Err(anyhow!("no message ids given")); } let stored = store .mail("all")? @@ -631,10 +654,10 @@ fn resolve_id_prefixes(store: &Store, ids: &[String]) -> Result return Err(SmolError::new(format!("no message matching {id:?}")).into()), + 0 => return Err(anyhow!(format!("no message matching {id:?}"))), 1 => full.push(matches[0]), _ => { - return Err(SmolError::new(format!( + return Err(anyhow!(format!( "{id:?} matches {} messages; be more specific", matches.len() )) @@ -705,11 +728,10 @@ fn read(key: &PathBuf, store: &Store, id: &str, sent: bool) -> Result<()> { .filter(|m| hex(&m.id).starts_with(&prefix)) .collect(); match matches.len() { - 0 => Err(SmolError::new(format!( + 0 => Err(anyhow!(format!( "no {}message matching {id:?}", if sent { "sent " } else { "" } - )) - .into()), + ))), 1 => { let row = &matches[0]; let described = client::describe(store, &account, row)?; @@ -736,7 +758,7 @@ fn read(key: &PathBuf, store: &Store, id: &str, sent: bool) -> Result<()> { } Ok(()) } - _ => Err(SmolError::new(format!( + _ => Err(anyhow!(format!( "{id:?} matches {} messages; be more specific", matches.len() )) diff --git a/core/Cargo.toml b/core/Cargo.toml new file mode 100644 index 0000000..9c23d4f --- /dev/null +++ b/core/Cargo.toml @@ -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 } diff --git a/build.rs b/core/build.rs similarity index 100% rename from build.rs rename to core/build.rs diff --git a/shim/smolmail_rns.cpp b/core/shim/smolmail_rns.cpp similarity index 100% rename from shim/smolmail_rns.cpp rename to core/shim/smolmail_rns.cpp diff --git a/shim/smolmail_rns.h b/core/shim/smolmail_rns.h similarity index 100% rename from shim/smolmail_rns.h rename to core/shim/smolmail_rns.h diff --git a/shim/udp_interface.cpp b/core/shim/udp_interface.cpp similarity index 100% rename from shim/udp_interface.cpp rename to core/shim/udp_interface.cpp diff --git a/shim/udp_interface.h b/core/shim/udp_interface.h similarity index 100% rename from shim/udp_interface.h rename to core/shim/udp_interface.h diff --git a/src/account.rs b/core/src/account.rs similarity index 72% rename from src/account.rs rename to core/src/account.rs index 1882c9e..9b04d9a 100644 --- a/src/account.rs +++ b/core/src/account.rs @@ -2,7 +2,7 @@ //! indices, accept tokens and rotation certificates. use crate::crypto::{hkdf_sha256, hmac_sha256, KEY_LEN}; -use crate::error::SmolError; +use crate::error::Error; use crate::transport::{CERT_LEN, LABEL_ACCEPT, LABEL_IDENTITY, LABEL_ROTATE, MAX_CHAIN}; use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey}; use x25519_dalek::StaticSecret; @@ -52,6 +52,15 @@ pub fn accept_key(master: &[u8; KEY_LEN]) -> [u8; KEY_LEN] { .unwrap() } +/// The rotation index whose identity is `identity`, if the key is derived +/// from this master at all (sec 2): what `restore` recovers the index with. +/// A GUI holding the master and the address but no connectivity can call +/// this itself once it has a RESOLVE result. +pub fn find_rotation_index(master: &[u8; KEY_LEN], identity: &[u8; KEY_LEN]) -> Option { + (0..=(MAX_CHAIN as u32)) + .find(|&n| Identity::from_seed(identity_seed(master, n)).pk() == *identity) +} + /// A master plus every identity up to the current rotation index. Superseded /// signing keys stay derivable because mail addressed to them is readable /// with nothing else (sec 7). @@ -62,11 +71,9 @@ pub struct Account { } impl Account { - pub fn new(master: [u8; KEY_LEN], index: u32) -> Result { + pub fn new(master: [u8; KEY_LEN], index: u32) -> Result { if index as usize > MAX_CHAIN { - return Err(SmolError::new(format!( - "rotation index {index} exceeds the chain limit of {MAX_CHAIN}" - ))); + return Err(Error::ChainLimit { index }); } let keys = (0..=index) .map(|n| Identity::from_seed(identity_seed(&master, n))) @@ -115,9 +122,9 @@ pub struct RotationCert { } impl RotationCert { - pub fn from_bytes(bytes: &[u8]) -> Result { + pub fn from_bytes(bytes: &[u8]) -> Result { if bytes.len() != CERT_LEN { - return Err(SmolError::new(format!( + return Err(Error::Other(format!( "rotation certificate is {} bytes, expected {CERT_LEN}", bytes.len() ))); @@ -191,67 +198,32 @@ pub fn verify_sig(identity: &[u8], signature: &[u8], message: &[u8]) -> bool { mod tests { use super::*; use crate::crypto::{b32, unb32}; + use crate::vectors; - // Vectors generated from the reference client's own derivations over - // master = bytes(range(32)). fn master() -> [u8; 32] { - core::array::from_fn(|i| i as u8) - } - - fn seed_b32(n: u32) -> &'static str { - match n { - 0 => "6l6wdqp3uwi2a3vn6jnlgjjmvimilnqv6np6t2vytw7wj3jdg6ea", - 1 => "ag6k4r74bnc3tt6y623my7wasslq4ulxcxm4anzvizdpg76yfxva", - 2 => "r35p2a4e5ffhz6aq5urhv27ch22a34r6i6adnqfwwcnte5te5tua", - 16 => "rf6tdr6doeaymope2uj66au542kcmvfnaqc3cmy5jkulacdbnvaq", - _ => unreachable!(), - } - } - - fn pk_b32(n: u32) -> &'static str { - match n { - 0 => "3jxsxqtdvxwobge7uxefpbordkulv6bc6ao3h4iusqlov4kgkkja", - 1 => "zoieneun2k7n5b65yxfeen4pxvxzq7sclm2kfaa7os3wrwrz4rta", - 2 => "2ptpqdy2d4zklwr2xzizhiza7b7jsf6qr2rayzrr2mk2h73ifbrq", - 16 => "r6yhcbo733vka7dlbknnlly7aqpoflej4lumeuxq7gxhzzm3juta", - _ => unreachable!(), - } - } - - fn xpriv_b32(n: u32) -> &'static str { - match n { - 0 => "ebppfk5yqmf7v5a4seh6f6rkm7z3pnoh7b6hiaa3vs7n5if6hj5a", - 1 => "rbhdttkcycpfqfyfsy2mstbykw4t5pngd6dezskrytlore4j5jvq", - 2 => "xb4jcmjyappxo7zibvbalaepmvy74jilom43fxvfaj7ev4bpxzqq", - 16 => "ucdl3wcmwjlso6um6ipequ3qq6lkx47u4rnwwwy7jo76c2ktwjoq", - _ => unreachable!(), - } + vectors::load().master() } #[test] fn identity_derivation_matches_the_reference_client() { + let v = vectors::load(); for n in [0u32, 1, 2, 16] { let seed = identity_seed(&master(), n); - assert_eq!(b32(&seed), seed_b32(n)); + assert_eq!(b32(&seed), v.seed_b32(n)); let identity = Identity::from_seed(seed); - assert_eq!(b32(&identity.pk()), pk_b32(n)); + assert_eq!(b32(&identity.pk()), v.pk_b32(n)); // The X25519 half must be libsodium's sk_to_curve25519 output. - assert_eq!(b32(identity.x_priv().as_bytes()), xpriv_b32(n)); + assert_eq!(b32(identity.x_priv().as_bytes()), v.xpriv_b32(n)); } } #[test] fn accept_key_and_token_match_the_reference_client() { - assert_eq!( - b32(&accept_key(&master())), - "fqmnb3vktmrth47m2qjzcey6ue7rbkrvsazytwdayjjgkvcqe2xa" - ); + let v = vectors::load(); + assert_eq!(b32(&accept_key(&master())), v.accept_key_b32); let account = Account::new(master(), 0).unwrap(); - let pk1: [u8; 32] = unb32(pk_b32(1)).unwrap().try_into().unwrap(); - assert_eq!( - b32(&account.token_for(&pk1)), - "bgrtewwtanbfmp5aoxq6rqckn4xcufr3tp4wa67mwsumyj7yjwia" - ); + let pk1: [u8; 32] = unb32(v.pk_b32(1)).unwrap().try_into().unwrap(); + assert_eq!(b32(&account.token_for(&pk1)), v.accept_token_for_pk1_b32); // Independent of the rotation index (sec 2). let rotated = Account::new(master(), 3).unwrap(); assert_eq!(account.token_for(&pk1), rotated.token_for(&pk1)); @@ -261,8 +233,9 @@ mod tests { fn account_holds_every_superseded_key() { let account = Account::new(master(), 2).unwrap(); assert_eq!(account.keys().len(), 3); + let v = vectors::load(); for (n, key) in account.keys().iter().enumerate() { - assert_eq!(b32(&key.pk()), pk_b32(n as u32)); + assert_eq!(b32(&key.pk()), v.pk_b32(n as u32)); } assert_eq!(account.index(), 2); } @@ -273,29 +246,35 @@ mod tests { assert!(Account::new(master(), 17).is_err()); } + #[test] + fn find_rotation_index_recovers_every_derived_key() { + let v = vectors::load(); + for n in [0u32, 1, 2, 16] { + let identity = Identity::from_seed(identity_seed(&v.master(), n)); + assert_eq!(find_rotation_index(&v.master(), &identity.pk()), Some(n)); + } + // A key derived from no index of this master derives from none. + assert_eq!(find_rotation_index(&v.master(), &[7u8; 32]), None); + } + #[test] fn rotation_certificates_match_the_reference_client() { - let old = Identity::from_seed(identity_seed(&master(), 0)); - let new = Identity::from_seed(identity_seed(&master(), 1)); - let cert = RotationCert::build(&old, &new, "alice", 1700000001); - // Vector produced by the reference client over the same inputs. - let expected = "da6f2bc263adece0989fa5c85785d11aa8baf822f01db3f1149416eaf1465292\ -cb9046928dd2bede87ddc5ca42378fbd6f987e425b34a2801f74b768da39e466000000006553f101\ -0c6171708ec40a9c68b456547a78fe0a512f8e45a0e4a9c094737ef4f84072a997a152e0f16e94bd\ -bd93502521ba812de62fe4c6d19092f0c424bba69337390401b2995c670f1d5720188956b8a1b00d\ -2ac9e5c1665e862e411c5ddeaea43c0721f450527b98f5dd3af29350a314514898a272bc3f78a0b9a\ -db826b46733c605"; - assert_eq!(data_encoding::HEXLOWER.encode(&cert), expected); + let v = vectors::load(); + let rc = &v.rotation_cert; + let old = Identity::from_seed(identity_seed(&master(), rc.old_index)); + let new = Identity::from_seed(identity_seed(&master(), rc.new_index)); + let cert = RotationCert::build(&old, &new, &rc.username, rc.when); + assert_eq!(data_encoding::HEXLOWER.encode(&cert), rc.cert_hex); assert_eq!(cert.len(), CERT_LEN); let parsed = RotationCert::from_bytes(&cert).unwrap(); - assert!(parsed.verify("alice")); + assert!(parsed.verify(&rc.username)); // The username is covered (sec 7), so another username fails. assert!(!parsed.verify("bob")); // Both halves must sign: flipping a signature byte breaks it. let mut broken = cert; broken[72] ^= 1; - assert!(!RotationCert::from_bytes(&broken).unwrap().verify("alice")); + assert!(!RotationCert::from_bytes(&broken).unwrap().verify(&rc.username)); assert!(RotationCert::from_bytes(&cert[..199]).is_err()); } diff --git a/src/address.rs b/core/src/address.rs similarity index 88% rename from src/address.rs rename to core/src/address.rs index fbfb46f..f073f5a 100644 --- a/src/address.rs +++ b/core/src/address.rs @@ -2,7 +2,7 @@ //! grammar they share. use crate::crypto::{b32, unb32, KEY_LEN}; -use crate::error::SmolError; +use crate::error::Error; pub const DEFAULT_PORT: u16 = 1961; /// A Reticulum destination hash, as printed by every RNS tool: 32 lowercase @@ -54,21 +54,21 @@ impl Address { } /// The Reticulum destination hash, for the `rns` carrier only. - pub fn destination(&self) -> Result<[u8; 16], SmolError> { + pub fn destination(&self) -> Result<[u8; 16], Error> { if self.scheme != Scheme::Rns { - return Err(SmolError::new("not an smol+rns:// address")); + return Err(Error::Other("not an smol+rns:// address".into())); } data_encoding::HEXLOWER .decode(self.host.as_bytes()) - .map_err(|_| SmolError::new("destination hash is not hexadecimal")) + .map_err(|_| Error::Other("destination hash is not hexadecimal".into())) .and_then(|bytes| { bytes .try_into() - .map_err(|_| SmolError::new("destination hash is not 16 bytes")) + .map_err(|_| Error::Other("destination hash is not 16 bytes".into())) }) } - pub fn parse(text: &str) -> Result { + pub fn parse(text: &str) -> Result { let text = text.trim(); let (scheme, rest) = if let Some(rest) = text.strip_prefix("smol+rns://") { (Scheme::Rns, rest) @@ -85,7 +85,7 @@ impl Address { Some((head, key)) => (head, Some(decode_key(text, key)?)), None if scheme == Scheme::Tcp && !text.starts_with("smol://") => (rest, None), None if scheme == Scheme::Tcp => { - return Err(SmolError::new(format!( + return Err(Error::Other(format!( "{text}: smol:// address carries no key" ))) } @@ -94,9 +94,9 @@ impl Address { let (user, host_part) = rest .split_once('@') - .ok_or_else(|| SmolError::new(format!("{text:?} is not a valid address")))?; + .ok_or_else(|| Error::Other(format!("{text:?} is not a valid address")))?; if user.is_empty() || host_part.is_empty() { - return Err(SmolError::new(format!("{text:?} is not a valid address"))); + return Err(Error::Other(format!("{text:?} is not a valid address"))); } valid_username(user)?; @@ -115,12 +115,12 @@ impl Address { // No port and no bare user@host form; the authority is the // destination hash, compared in full (RNS.md sec 13.2). if host_part.contains(':') { - return Err(SmolError::new(format!( + return Err(Error::Other(format!( "{text}: the :port suffix must not appear on smol+rns://" ))); } if !is_destination_hash(host_part) { - return Err(SmolError::new(format!( + return Err(Error::Other(format!( "{text}: host must be exactly {RNS_HASH_HEX} lowercase hexadecimal \ characters, a Reticulum destination hash" ))); @@ -137,28 +137,28 @@ impl Address { } } -fn decode_key(text: &str, key: &str) -> Result<[u8; KEY_LEN], SmolError> { +fn decode_key(text: &str, key: &str) -> Result<[u8; KEY_LEN], Error> { let identity = - unb32(key).map_err(|e| SmolError::new(format!("{text}: undecodable key: {e}")))?; + unb32(key).map_err(|e| Error::Other(format!("{text}: undecodable key: {e}")))?; identity.try_into().map_err(|v: Vec| { - SmolError::new(format!( + Error::Other(format!( "{text}: key is {} bytes, expected {KEY_LEN}", v.len() )) }) } -fn split_host_port(text: &str, host_part: &str) -> Result<(String, u16), SmolError> { +fn split_host_port(text: &str, host_part: &str) -> Result<(String, u16), Error> { if let Some((host, port)) = host_part.rsplit_once(':') { if !host.is_empty() && port.bytes().all(|c| c.is_ascii_digit()) && !port.is_empty() { return port .parse::() .map(|p| (host.to_string(), p)) - .map_err(|_| SmolError::new(format!("{text}: invalid port"))); + .map_err(|_| Error::Other(format!("{text}: invalid port"))); } } if host_part.contains(':') || host_part.contains('/') { - return Err(SmolError::new(format!("{text:?} is not a valid address"))); + return Err(Error::Other(format!("{text:?} is not a valid address"))); } Ok((host_part.to_string(), DEFAULT_PORT)) } @@ -173,7 +173,7 @@ fn is_destination_hash(host: &str) -> bool { /// SPEC.md sec 3: 1-63 bytes of `[a-z0-9._-]`, alphanumeric at both ends, and /// never two separators in a row. Non-lowercase input is a parse error, as in /// the reference client, which normalises before sending instead. -fn valid_username(name: &str) -> Result<(), SmolError> { +fn valid_username(name: &str) -> Result<(), Error> { const SEPARATORS: &[u8] = b"._-"; let bytes = name.as_bytes(); let ok = !bytes.is_empty() @@ -189,7 +189,7 @@ fn valid_username(name: &str) -> Result<(), SmolError> { if ok { Ok(()) } else { - Err(SmolError::new(format!( + Err(Error::Other(format!( "{name:?} must be 1-63 bytes of [a-z0-9._-], begin and end with a letter or digit, \ and contain no two separators in a row" ))) diff --git a/src/client.rs b/core/src/client.rs similarity index 87% rename from src/client.rs rename to core/src/client.rs index c2cde2c..737e3c6 100644 --- a/src/client.rs +++ b/core/src/client.rs @@ -8,7 +8,7 @@ use std::collections::BTreeMap; use crate::account::{Account, Identity, RotationCert}; use crate::address::{Address, Scheme}; use crate::crypto::{b32, ct_eq, hmac_sha256, KEY_LEN}; -use crate::error::{expect_ok, SmolError}; +use crate::error::{expect_ok, Error}; use crate::message::{ accept_field, build_frontmatter, message_id, parse_frontmatter, seal, unseal, Opened, }; @@ -44,16 +44,14 @@ pub fn connect( addr: &Address, require_pin: bool, timeout: u64, -) -> Result { +) -> Result { match addr.scheme { Scheme::Tcp => { let pinned = store.server_pin(&addr.host)?; if pinned.is_none() && require_pin { - return Err(SmolError::new(format!( - "no pinned key for {}.\nObtain it from the operator through a trusted \ - channel, then:\n fumi trust {} ", - addr.host, addr.host - ))); + return Err(Error::NotPinned { + host: addr.host.clone(), + }); } let transport = TcpTransport::connect(&addr.host, addr.port, pinned, timeout)?; let unpinned_static = if pinned.is_none() { @@ -87,17 +85,17 @@ pub fn connect( #[cfg(not(feature = "rns"))] { let _ = (addr, require_pin); - Err(SmolError::new( - "smol+rns:// addresses need the rns feature; rebuild with --features rns", - )) + Err(Error::Other( + "smol+rns:// addresses need the rns feature; rebuild with --features rns".into(), +)) } } } } -fn string_field(out: &mut Vec, text: &[u8]) -> Result<(), SmolError> { +fn string_field(out: &mut Vec, text: &[u8]) -> Result<(), Error> { if text.len() > 255 { - return Err(SmolError::new("field longer than one length byte")); + return Err(Error::Other("field longer than one length byte".into())); } out.push(text.len() as u8); out.extend_from_slice(text); @@ -108,7 +106,7 @@ fn string_field(out: &mut Vec, text: &[u8]) -> Result<(), SmolError> { pub fn resolve( transport: &mut dyn Transport, username: &str, -) -> Result<([u8; KEY_LEN], Vec<[u8; CERT_LEN]>), SmolError> { +) -> Result<([u8; KEY_LEN], Vec<[u8; CERT_LEN]>), Error> { let mut body = Vec::new(); string_field(&mut body, username.as_bytes())?; let response = transport.request(OP_RESOLVE, &body)?; @@ -180,7 +178,7 @@ pub fn trust_key( store: &Store, transport: &mut dyn Transport, addr: &Address, -) -> Result<([u8; KEY_LEN], TrustChange), SmolError> { +) -> Result<([u8; KEY_LEN], TrustChange), Error> { let (identity, chain) = resolve(transport, &addr.user)?; let known = store.contact(&addr.short())?; match known { @@ -199,14 +197,11 @@ pub fn trust_key( store.save_contact(&addr.short(), &identity, verified)?; Ok((identity, TrustChange::Rotated)) } else { - Err(SmolError::new(format!( - "{} presents a different key with no valid rotation chain.\n known: {}\n \ - offered: {}\nVerify out of band, then: fumi import {}", - addr.short(), - b32(&old), - b32(&identity), - addr.uri(&identity) - ))) + Err(Error::KeyChanged { + address: addr.short(), + known: old, + offered: identity, + }) } } } @@ -221,7 +216,7 @@ pub fn authenticate( username: &str, account: &Account, store: &Store, -) -> Result { +) -> Result { let (sync, tokens) = store.token_set(account)?; let mut body = Vec::new(); string_field(&mut body, username.as_bytes())?; @@ -256,7 +251,7 @@ pub enum Pushed { /// An accept or a block only takes effect once the server holds the changed /// set, so it is pushed now rather than at the next fetch. -pub fn push_tokens(store: &Store, account: &Account, timeout: u64) -> Result { +pub fn push_tokens(store: &Store, account: &Account, timeout: u64) -> Result { let Some(addr) = store.account()? else { return Ok(Pushed::NotRegistered); }; @@ -275,7 +270,7 @@ pub fn register( account: &Account, invite: Option<&str>, timeout: u64, -) -> Result<(), SmolError> { +) -> Result<(), Error> { let mut session = connect(store, addr, true, timeout)?; let transport = session.transport(); let me = account.me(); @@ -313,16 +308,14 @@ pub struct Rotated { /// Advances the rotation index and pushes the certificate (sec 7). The master /// is untouched; only the index moves, and the superseded key stays /// derivable from it (sec 2). -pub fn rotate(store: &Store, account: &Account, timeout: u64) -> Result { +pub fn rotate(store: &Store, account: &Account, timeout: u64) -> Result { let Some(addr) = store.account()? else { - return Err(SmolError::new( - "not registered; run: fumi register ", - )); + return Err(Error::NotRegistered); }; if account.index() as usize >= MAX_CHAIN { - return Err(SmolError::new(format!( - "the rotation chain is full at {MAX_CHAIN} links" - ))); + return Err(Error::ChainLimit { + index: account.index() + 1, + }); } let old = account.me(); let new = Identity::from_seed(crate::account::identity_seed( @@ -364,25 +357,16 @@ pub fn rotate(store: &Store, account: &Account, timeout: u64) -> Result Result { +/// The pieces are public for hosts that want intermediate UI states: +/// `connect` + `resolve`, `find_rotation_index`, then the four `set_*` +/// calls below. +pub fn restore(store: &Store, master: &[u8; KEY_LEN], addr: &Address, timeout: u64) -> Result { let mut session = connect(store, addr, false, timeout)?; let (identity, _) = resolve(session.transport(), &addr.user)?; session.close(); - let mut found = None; - for index in 0..=(MAX_CHAIN as u32) { - if Identity::from_seed(crate::account::identity_seed(master, index)).pk() == identity { - found = Some(index); - break; - } - } - let Some(index) = found else { - return Err(SmolError::new(format!( + let Some(index) = crate::account::find_rotation_index(master, &identity) else { + return Err(Error::Other(format!( "the key bound to {} is not derived from this master within {MAX_CHAIN} rotations", addr.short() ))); @@ -428,7 +412,7 @@ pub fn send( account: &Account, draft: &SendDraft<'_>, timeout: u64, -) -> Result { +) -> Result { let addr = draft.address; let me = account.me(); @@ -519,6 +503,37 @@ pub struct Fetched { /// Messages were deleted from the server rather than kept behind a /// cursor (the default). pub acknowledged: bool, + /// `cancel` was raised mid-fetch; the summary covers what completed. + /// Every finished page is accounted for locally — cursor moved or + /// messages deleted — so fetching again continues rather than repeats. + pub cancelled: bool, +} + +/// The knobs a host with a UI needs on `fetch_with`. Progress needs no +/// callback: each envelope is committed to the `Store` as it is verified, so +/// a concurrent reader (the store is `Send + Sync`) can list incrementally. +pub struct FetchOptions<'a> { + /// Remember the cursor instead of acknowledging with DELETE. + pub keep: bool, + /// Forget the cursor and page again. + pub reset: bool, + /// Network timeout in seconds. + pub timeout: u64, + /// Checked between pages; when raised, `fetch_with` stops and returns + /// the partial summary with `cancelled` set. + pub cancel: Option<&'a std::sync::atomic::AtomicBool>, +} + +impl FetchOptions<'_> { + /// The defaults a plain fetch uses. + fn new(keep: bool, reset: bool, timeout: u64) -> Self { + FetchOptions { + keep, + reset, + timeout, + cancel: None, + } + } } /// Retrieves, verifies and stores mail (sec 6.1), paging forward by @@ -532,16 +547,23 @@ pub fn fetch( keep: bool, reset: bool, timeout: u64, -) -> Result { +) -> Result { + 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 { let Some(addr) = store.account()? else { - return Err(SmolError::new( - "not registered; run: fumi register ", - )); + return Err(Error::NotRegistered); }; - if reset { + if opts.reset { store.set_cursor(0, &[0u8; ID_LEN])?; } - let (mut after_time, mut after_id) = if keep { + let (mut after_time, mut after_id) = if opts.keep { store.cursor()? } else { (0, [0u8; ID_LEN]) @@ -551,13 +573,19 @@ pub fn fetch( total: 0, stored: 0, rejected: Vec::new(), - acknowledged: !keep, + acknowledged: !opts.keep, + cancelled: false, }; - let mut session = connect(store, &addr, true, timeout)?; + let cancelled = || opts.cancel.is_some_and(|c| c.load(std::sync::atomic::Ordering::Relaxed)); + let mut session = connect(store, &addr, true, opts.timeout)?; let transport = session.transport(); authenticate(transport, &addr.user, account, store)?; loop { + if cancelled() { + summary.cancelled = true; + break; + } let mut body = Vec::with_capacity(8 + ID_LEN); body.extend_from_slice(&after_time.to_be_bytes()); body.extend_from_slice(&after_id); @@ -600,14 +628,14 @@ pub fn fetch( acked.push(mid); } r.done()?; - if keep { + if opts.keep { store.set_cursor(after_time, &after_id)?; } else if !acked.is_empty() { delete_ids(transport, &acked)?; } } session.close(); - if !keep { + if !opts.keep { store.set_cursor(0, &[0u8; ID_LEN])?; } Ok(summary) @@ -617,9 +645,9 @@ fn verify_and_open( account: &Account, mid: &[u8; ID_LEN], envelope: &[u8], -) -> Result { +) -> Result { if message_id(envelope) != *mid { - return Err(SmolError::new("id does not match the envelope")); + return Err(Error::Other("id does not match the envelope".into())); } unseal(account.keys(), envelope, now()) } @@ -627,7 +655,7 @@ fn verify_and_open( /// Files the accept token a verified payload carried, under the address that /// signed it (sec 5.8). The signature has already been checked by `unseal`, /// so the attribution is the signer's own claim. -fn learn_token(store: &Store, opened: &Opened) -> Result<(), SmolError> { +fn learn_token(store: &Store, opened: &Opened) -> Result<(), Error> { let text = String::from_utf8_lossy(&opened.body).into_owned(); let (fields, _) = parse_frontmatter(&text); let Some(token) = accept_field(&fields) else { @@ -641,7 +669,7 @@ fn learn_token(store: &Store, opened: &Opened) -> Result<(), SmolError> { } } -fn delete_ids(transport: &mut dyn Transport, ids: &[[u8; ID_LEN]]) -> Result { +fn delete_ids(transport: &mut dyn Transport, ids: &[[u8; ID_LEN]]) -> Result { let mut body = Vec::with_capacity(2 + ids.len() * ID_LEN); body.extend_from_slice(&(ids.len() as u16).to_be_bytes()); for id in ids { @@ -664,11 +692,9 @@ pub fn delete( account: &Account, ids: &[[u8; ID_LEN]], timeout: u64, -) -> Result { +) -> Result { let Some(addr) = store.account()? else { - return Err(SmolError::new( - "not registered; run: fumi register ", - )); + return Err(Error::NotRegistered); }; let mut session = connect(store, &addr, true, timeout)?; let transport = session.transport(); @@ -698,7 +724,7 @@ impl Described { } /// Opens one sealed message and splits its body into frontmatter and text. -pub fn describe(store: &Store, account: &Account, stored: &Stored) -> Result { +pub fn describe(store: &Store, account: &Account, stored: &Stored) -> Result { let opened = unseal(account.keys(), &stored.envelope, now())?; let text = String::from_utf8_lossy(&opened.body).into_owned(); let (fields, body_text) = parse_frontmatter(&text); @@ -811,16 +837,16 @@ mod tests { fn accept_mac_matches_the_reference_vector() { // The reference client's own vector: token_for(pk_1) over the id of // the reference envelope (see the message module's vectors). + let v = crate::vectors::load(); let account = Account::new(master(), 0).unwrap(); let token = account.token_for(&identity(1).pk()); - let mid: [u8; 32] = - crate::crypto::unb32("7tttsuaq4fqwbi5hvp6tigzwk5g73upgqpfxvk26aumwak2zefiq") - .unwrap() - .try_into() - .unwrap(); + let mid: [u8; 32] = crate::crypto::unb32(&v.accept_mac.message_id_b32) + .unwrap() + .try_into() + .unwrap(); assert_eq!( data_encoding::HEXLOWER.encode(&accept_mac(&token, &mid)), - "f709facbe5e032f4c2667c7899e5194397437ed001f2b2be33b82f1cc8d7c93e" + v.accept_mac.mac_hex ); } diff --git a/src/crypto.rs b/core/src/crypto.rs similarity index 79% rename from src/crypto.rs rename to core/src/crypto.rs index 7ef3a7a..977d15e 100644 --- a/src/crypto.rs +++ b/core/src/crypto.rs @@ -9,7 +9,7 @@ use sha2::{Digest, Sha256}; use std::sync::LazyLock; use x25519_dalek::{PublicKey, StaticSecret}; -use crate::error::SmolError; +use crate::error::Error; pub const KEY_LEN: usize = 32; @@ -36,12 +36,12 @@ pub fn b32(raw: &[u8]) -> String { } /// Inverse of `b32`; accepts uppercase and stray padding for pasted input. -pub fn unb32(text: &str) -> Result, SmolError> { +pub fn unb32(text: &str) -> Result, Error> { let lower = text.trim().to_ascii_lowercase(); let trimmed = lower.trim_end_matches('='); BASE32_LOWER_UNPADDED .decode(trimmed.as_bytes()) - .map_err(|e| SmolError::new(format!("undecodable base32: {e}"))) + .map_err(|e| Error::Other(format!("undecodable base32: {e}"))) } /// First 20 characters of the base32 identity, in groups of four (SPEC.md @@ -95,18 +95,18 @@ pub fn ct_eq(a: &[u8], b: &[u8]) -> bool { /// The X25519 public key of an Ed25519 identity: libsodium's /// `crypto_sign_ed25519_pk_to_curve25519` (SPEC.md sec 2). Malformed points /// are rejected rather than mapped, matching the reference client. -pub fn ed25519_to_x25519(identity: &[u8; KEY_LEN]) -> Result<[u8; KEY_LEN], SmolError> { +pub fn ed25519_to_x25519(identity: &[u8; KEY_LEN]) -> Result<[u8; KEY_LEN], Error> { VerifyingKey::from_bytes(identity) .map(|key| *key.to_montgomery().as_bytes()) - .map_err(|_| SmolError::new("not a valid Ed25519 public key")) + .map_err(|_| Error::Other("not a valid Ed25519 public key".into())) } /// X25519 key agreement with sec 2's checks. An all-zero output covers a /// low-order received ephemeral as well, so both are refused here. -pub fn agree(secret: &StaticSecret, peer: &[u8; KEY_LEN]) -> Result<[u8; KEY_LEN], SmolError> { +pub fn agree(secret: &StaticSecret, peer: &[u8; KEY_LEN]) -> Result<[u8; KEY_LEN], Error> { let shared = secret.diffie_hellman(&PublicKey::from(*peer)); if shared.as_bytes().iter().all(|&b| b == 0) { - return Err(SmolError::new("rejected all-zero key agreement output")); + return Err(Error::Other("rejected all-zero key agreement output".into())); } Ok(*shared.as_bytes()) } @@ -141,41 +141,32 @@ mod tests { #[test] fn hkdf_matches_rfc5869_case_1() { - let ikm = [0x0bu8; 22]; - let salt = [0u8; 13]; - let info = [0xf0u8, 0xf1, 0xf2, 0xf3, 0xf4, 0xf5, 0xf6, 0xf7, 0xf8, 0xf9]; - let okm = hkdf_sha256(&ikm, &salt, &info, 42); - // First 32 bytes of RFC 5869 test case 1's 42-byte OKM. - let expected = "abbafb13f5c1bc489d4203135817956dd521b39e3bd61d1cc85cef884d1f8e2e"; - assert_eq!(data_encoding::HEXLOWER.encode(&okm[..32]), expected); + let v = crate::vectors::load(); + let hk = &v.hkdf_rfc5869_case1; + let okm = hkdf_sha256(&v.hex(&hk.ikm_hex), &v.hex(&hk.salt_hex), &v.hex(&hk.info_hex), 42); + assert_eq!(data_encoding::HEXLOWER.encode(&okm[..32]), hk.okm_hex_32); } #[test] fn hmac_matches_rfc4231_case_1() { - let key = [0x0bu8; 20]; - let data = b"Hi There"; - let expected = "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7"; + let v = crate::vectors::load(); + let hm = &v.hmac_rfc4231_case1; assert_eq!( - data_encoding::HEXLOWER.encode(&hmac_sha256(&key, data)), - expected + data_encoding::HEXLOWER.encode(&hmac_sha256(&v.hex(&hm.key_hex), hm.data.as_bytes())), + hm.mac_hex ); } #[test] fn ed25519_to_x25519_rejects_garbage_and_maps_like_libsodium() { - // Vectors generated with libsodium's pk_to_curve25519 over the - // identities the reference client derives at seed_0 and seed_1. - let seed0: [u8; 32] = unb32("6l6wdqp3uwi2a3vn6jnlgjjmvimilnqv6np6t2vytw7wj3jdg6ea") - .unwrap() - .try_into() - .unwrap(); + // libsodium's pk_to_curve25519 over the identity the reference + // client derives at seed_0. + let v = crate::vectors::load(); + let seed0: [u8; 32] = unb32(v.seed_b32(0)).unwrap().try_into().unwrap(); let pk0 = *ed25519_dalek::SigningKey::from_bytes(&seed0) .verifying_key() .as_bytes(); - assert_eq!( - b32(&ed25519_to_x25519(&pk0).unwrap()), - "bqw73wfgphe4ubojbbsrhfvtnxknuhhwvq74lmadpppi2p7lkbvq" - ); + assert_eq!(b32(&ed25519_to_x25519(&pk0).unwrap()), v.ed25519_to_x25519_pk0_b32); // 32 zero bytes do decode to a curve point, but a low-order one; // sec 2's rejection happens at agreement time, where the all-zero // output surfaces. diff --git a/core/src/error.rs b/core/src/error.rs new file mode 100644 index 0000000..b535235 --- /dev/null +++ b/core/src/error.rs @@ -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 for Error { + fn from(e: rusqlite::Error) -> Self { + Error::Storage(e) + } +} + +impl From for Error { + fn from(e: std::io::Error) -> Self { + Error::Io(e) + } +} + +impl From 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), + }) +} diff --git a/src/lib.rs b/core/src/lib.rs similarity index 66% rename from src/lib.rs rename to core/src/lib.rs index c738484..9ec0c0e 100644 --- a/src/lib.rs +++ b/core/src/lib.rs @@ -1,8 +1,10 @@ -//! `fumi` — the Smol Mail client (SPEC.md 1.2). +//! `fumi-core` — the Smol Mail client library (SPEC.md 1.2). //! //! Counterpart to bunshin, the server: deriving identities from one master //! secret, sealing and opening mail, and talking to a mailbox over a Noise_NX -//! TCP connection (or the Reticulum carrier behind the `rns` feature). +//! TCP connection (or the Reticulum carrier behind the `rns` feature). The +//! `fumi` crate wraps this library as a command-line client; GUI hosts embed +//! this one directly. pub mod account; pub mod address; @@ -15,3 +17,6 @@ pub mod rns; pub mod store; pub mod tcp; pub mod transport; +#[cfg(test)] +mod vectors; + diff --git a/src/message.rs b/core/src/message.rs similarity index 76% rename from src/message.rs rename to core/src/message.rs index aa621d0..ce8fb92 100644 --- a/src/message.rs +++ b/core/src/message.rs @@ -8,7 +8,7 @@ use rand_core::{OsRng, RngCore}; use crate::account::{verify_sig, Identity}; use crate::crypto::{agree, b32, ct_eq, ed25519_to_x25519, hkdf_sha256, sha256, unb32, KEY_LEN}; -use crate::error::SmolError; +use crate::error::Error; use crate::transport::{ ENVELOPE_HEADER, ENVELOPE_MAGIC, ENVELOPE_MIN, ENVELOPE_VERSION, LABEL_ID, LABEL_MSG, LABEL_SEAL, PAYLOAD_HEADER, SIG_LEN, @@ -46,7 +46,7 @@ pub fn seal( body: &[u8], when: i64, pad: bool, -) -> Result, SmolError> { +) -> Result, Error> { let mut esk = [0u8; KEY_LEN]; OsRng.fill_bytes(&mut esk); // esk is dropped at the end of the frame; nothing more can be promised @@ -61,7 +61,7 @@ fn seal_with_esk( when: i64, pad: bool, esk: [u8; KEY_LEN], -) -> Result, SmolError> { +) -> Result, Error> { let x_recipient = ed25519_to_x25519(recipient)?; let eph = x25519_dalek::StaticSecret::from(esk); let epk = x25519_dalek::PublicKey::from(&eph); @@ -107,7 +107,7 @@ fn seal_with_esk( aad: &aad, }, ) - .map_err(|_| SmolError::new("encryption failed"))?; + .map_err(|_| Error::Other("encryption failed".into()))?; let mut envelope = aad; envelope.extend_from_slice(&sealed); Ok(envelope) @@ -118,15 +118,15 @@ fn seal_with_esk( /// against the sender the payload carries, and the payload is not dated more /// than `MAX_SKEW` ahead of `now`. Trailing bytes past `body_len + 64` are /// ignored as padding. -pub fn unseal(identities: &[Identity], envelope: &[u8], now: i64) -> Result { +pub fn unseal(identities: &[Identity], envelope: &[u8], now: i64) -> Result { if envelope.len() < ENVELOPE_MIN { - return Err(SmolError::new("envelope too short")); + return Err(Error::Other("envelope too short".into())); } if &envelope[..4] != ENVELOPE_MAGIC { - return Err(SmolError::new("not a Smol Mail envelope")); + return Err(Error::Other("not a Smol Mail envelope".into())); } if envelope[4] != ENVELOPE_VERSION { - return Err(SmolError::new(format!( + return Err(Error::Other(format!( "unsupported envelope version {}", envelope[4] ))); @@ -139,7 +139,7 @@ pub fn unseal(identities: &[Identity], envelope: &[u8], now: i64) -> Result Result plaintext.len() { - return Err(SmolError::new("payload body length exceeds the payload")); + return Err(Error::Other("payload body length exceeds the payload".into())); } let body = &plaintext[PAYLOAD_HEADER..PAYLOAD_HEADER + body_len]; let signature = &plaintext[PAYLOAD_HEADER + body_len..PAYLOAD_HEADER + body_len + SIG_LEN]; @@ -181,10 +181,10 @@ pub fn unseal(identities: &[Identity], envelope: &[u8], now: i64) -> Result now + MAX_SKEW { - return Err(SmolError::new("payload is dated in the future")); + return Err(Error::Other("payload is dated in the future".into())); } Ok(Opened { sender, @@ -276,11 +276,6 @@ mod tests { core::array::from_fn(|i| i as u8) } - // A full envelope produced by the reference client: fixed ephemeral - // [7;32], when = 1700000000, sender = seed_3, recipient = pk_1. - const REF_ENVELOPE_B32: &str = "kngu6tabzoieneun2k7n5b65yxfeen4pxvxzq7sclm2kfaa7os3wrwrz4rtbhpsp5lvpebgh7uzvr7e4abzbraoroqtyckbcp3dhj437p7uxw3lqwcki4wgjyisjpfr77awo2sz7p3uuke337wdlyubbzvdixnvauhf2q2nihc4cerlf62uyhdnvwnrajxxqo56hht5qwijh3uxbrons62te3xwwpm4yppbiab63nw57rj3yjarzwq2tfz57cphnxnacdbm5xdl66mdygni5js7pcftrmhitpcqjibjdmanyjtl75xtsjkm6dxzcwldqaxngqnfgg37yatihmh5ba76e6ykfiymqcb5nguhws2cviykpwlkuwmxygtgqxpethdvvlhnurf6g4i2u3la2nd3rhgjlr4t227xerr652osqltqzpcjf4m2sidnhcvu5252nijsvxxmgi343ojiffqodjr4fjuglzdwovufeyirkcjywvjdlslcdmjsxg5wiadnvectykxlq3c6qxxqex66z5vjcklzoldtrh3h36l35vqtjtn5rf4yggz2wtbrrdteym2k46obuoxiolj2cnbmj7qihdabg6wdvohl2ctpoc57huengekrprkuyzvogidgwrnqbjhxjgz7mqq6tri24rkts3pqaza6jol6w72zltvxa5itkarkmu6e2vjytpsxbxulnursf52m4abd3w7vxyw6rlxogivj2wafqpylngomzgghlcyoto65ac2n3oavt53xbcdwmgstrdkhlzd2j2wihp2xscizr7pa3n7d3rzuocdc46cwishrvejl3cihfdfnzjhosij5jkr3driehzjdgrx7rl4q43gyjdlvl5zuvvihw2ka75do7vakdghfz4d2jmh7an2bkchnyv2bnwydwei6wsbfc55g7mthvqqwm5ojaza4f37ha4cps37zrbw4fg5q3xpxupfotnvts5ki7zcpvkpdwn4p4voqlpve6czprwfhv7pmmipevr76trg44lkxemhpweqqxtzr4bdsuwt5hroelg2pyv2e3qmq4ibanrqx6ygr4k7b4znwqmtx5urn3wfu2do45bivw6qd55vaaqzeu27shn5xj4mmpfy46iuj2a3vdkwuevlix26uz27ryuovrgnaurhkuowuyme2u2nxwr5z5xoclwy42dw5mg3tynplzz2espoq5ok5amuacjxcy3dm37d547mljiz3ist25pyv7zn33onzovucntivlvrqul23obeb44lqybqnu4m6nbbdpcuniyxyribqq3ukpq2zhvcesdzqqqzqfv2gcwtkzjuj6cbs25finxerdi726ac73hjlodjgwq7wdeqjohirl54rzmgerhqnlr6mummauqad6ehubeuw6sdj2e4sbi4v4obgp7xv7ci3yeqroowi4ygh674lwtlpweaat7xbtvhzltexcknt6bu3abgmmpabcvlrdszpp33w44pw6wogrhl5p5dozx3of3keefu4bxhypg3euh4mzawrxdnjflee3um6y5kmfw5iexaoscy5lu6jupc5sisvvgtxibnjzxuwaifui22b3ng3coacszqxyy7boll4k4qdaehombacj36mbqk3kjmhxpx5etgqr5krbac2z3xufosnwtfln2pauhe2i3kujty2a3r3yn7umiqvomkeg6zyvla4kltclot55c6vpmp4mlisvzngayds67f45hnz7hpgtygjcz2ozzhqk2tq"; - const REF_MESSAGE_ID: &str = "fce7395010e16160a3a7abfd341b36574dfdd1e683cb7aab5e0519602b592151"; - const REF_ENVELOPE_NOPAD_B32: &str = "kngu6tabzoieneun2k7n5b65yxfeen4pxvxzq7sclm2kfaa7os3wrwrz4rtbhpsp5lvpebgh7uzvr7e4abzbraoroqtyckbcp3dhj437p7uxw3lqwcki4wgjyisjpfr77awo2sz7p3uuke337wdlyubbzvdixnvauhf2q2nihc4cerlf62uyhdnvwnrajxxqo56hht5qwijh3uxbrons62te3xwwpm4yppbiab63nw57rj3yjarzwq2tfz57cphnxnacdbm5xdl66mdygni5js7pcftrmhitpcqjibjdmanyjtl75xtsjkm6dxzcwldqaxngqnfgg37yatihkho2c5e2w27hqbhphfhpeusvhu"; fn identity(n: u32) -> Identity { Identity::from_seed(identity_seed(&master(), n)) @@ -288,58 +283,46 @@ mod tests { #[test] fn message_id_matches_the_reference_client() { - let envelope = unb32(REF_ENVELOPE_B32).unwrap(); + let v = crate::vectors::load(); + let envelope = unb32(&v.envelope.padded_b32).unwrap(); assert_eq!(envelope.len(), 1109); assert_eq!( data_encoding::HEXLOWER.encode(&message_id(&envelope)), - REF_MESSAGE_ID + v.envelope.message_id_hex ); } #[test] fn unseals_an_envelope_from_the_reference_client() { - let envelope = unb32(REF_ENVELOPE_B32).unwrap(); + let v = crate::vectors::load(); + let envelope = unb32(&v.envelope.padded_b32).unwrap(); let opened = unseal( &[identity(0), identity(1), identity(2)], &envelope, - 1700000100, + v.envelope.when + 100, ) .expect("reference envelope must open"); - assert_eq!( - b32(&opened.sender), - "qn6h4zx62pnqxsteog6kcykfef33k2t5yrai7ei3eq33hres6wga" - ); - assert_eq!(opened.time, 1700000000); - assert_eq!(opened.body, b"hello from the reference client\n"); + assert_eq!(b32(&opened.sender), v.envelope.sender_pk_b32); + assert_eq!(opened.time, v.envelope.when); + assert_eq!(opened.body, v.envelope.body.as_bytes()); } #[test] fn seal_matches_the_reference_client_byte_for_byte() { - let sender = identity(3); - let recipient = identity(1).pk(); - let envelope = seal_with_esk( - &sender, - &recipient, - b"hello from the reference client\n", - 1700000000, - true, - [7u8; 32], - ) - .unwrap(); - assert_eq!(envelope, unb32(REF_ENVELOPE_B32).unwrap()); + let v = crate::vectors::load(); + let e = &v.envelope; + let sender = identity(e.sender_index); + let recipient = identity(e.recipient_index).pk(); + let esk: [u8; 32] = v.hex(&e.ephemeral_hex).try_into().unwrap(); + let envelope = + seal_with_esk(&sender, &recipient, e.body.as_bytes(), e.when, true, esk).unwrap(); + assert_eq!(envelope, unb32(&e.padded_b32).unwrap()); // And unpadded, which is the other sender choice sec 5.3 allows. - let plain = seal_with_esk( - &sender, - &recipient, - b"hello from the reference client\n", - 1700000000, - false, - [7u8; 32], - ) - .unwrap(); + let plain = + seal_with_esk(&sender, &recipient, e.body.as_bytes(), e.when, false, esk).unwrap(); assert_eq!(plain.len(), 226); - assert_eq!(plain, unb32(REF_ENVELOPE_NOPAD_B32).unwrap()); + assert_eq!(plain, unb32(&e.unpadded_b32).unwrap()); } #[test] diff --git a/src/rns/ffi.rs b/core/src/rns/ffi.rs similarity index 83% rename from src/rns/ffi.rs rename to core/src/rns/ffi.rs index 1854210..e06f367 100644 --- a/src/rns/ffi.rs +++ b/core/src/rns/ffi.rs @@ -3,13 +3,13 @@ //! Every failure here is a local error — no path, a dead link, a rejected //! resource, a timeout — and MUST NOT be reported as a status code (upstream //! spec sec 13.5), so the wrapper turns every shim return code into an -//! `SmolError` message and never into a `Response`. +//! `Error` message and never into a `Response`. use std::ffi::CString; use std::time::Duration; use crate::crypto::KEY_LEN; -use crate::error::SmolError; +use crate::error::Error; use crate::rns::RnsConfig; use crate::transport::MAX_FRAME; @@ -46,7 +46,7 @@ mod inner { } /// Starts the Reticulum stack: storage path, UDP interface, loop thread. -pub fn start(config: &RnsConfig) -> Result<(), SmolError> { +pub fn start(config: &RnsConfig) -> Result<(), Error> { let storage_dir = CString::new(config.storage_dir.as_str()).expect("storage path has no interior NUL"); let listen_host = @@ -69,7 +69,7 @@ pub fn start(config: &RnsConfig) -> Result<(), SmolError> { }; match rc { 0 => Ok(()), - _ => Err(SmolError::new(format!( + _ => Err(Error::Other(format!( "RNS shim failed to start (code {rc}); is the UDP port free?" ))), } @@ -77,7 +77,7 @@ pub fn start(config: &RnsConfig) -> Result<(), SmolError> { /// Requests a path, recalls the identity, opens a link and waits for ACTIVE, /// returning the 16-byte link id the bind values derive from. -pub fn connect(destination: &[u8; 16], timeout: Duration) -> Result<[u8; 16], SmolError> { +pub fn connect(destination: &[u8; 16], timeout: Duration) -> Result<[u8; 16], Error> { let hex = data_encoding::HEXLOWER.encode(destination); let mut link_id = [0u8; 16]; let rc = unsafe { @@ -89,17 +89,17 @@ pub fn connect(destination: &[u8; 16], timeout: Duration) -> Result<[u8; 16], Sm }; match rc { 0 => Ok(link_id), - -1 => Err(SmolError::new(format!( + -1 => Err(Error::Other(format!( "no path to {hex}; it may be unreachable or has never announced \ (upstream spec 13.1, 13.3)" ))), - -2 => Err(SmolError::new(format!( + -2 => Err(Error::Other(format!( "path known but identity not yet learned for {hex}; try again shortly" ))), - -3 => Err(SmolError::new(format!( + -3 => Err(Error::Other(format!( "could not establish a link to {hex}" ))), - _ => Err(SmolError::new(format!( + _ => Err(Error::Other(format!( "RNS connect to {hex} failed (code {rc})" ))), } @@ -107,7 +107,7 @@ pub fn connect(destination: &[u8; 16], timeout: Duration) -> Result<[u8; 16], Sm /// Sends one `op u8 || body` request and returns the `status u8 || payload` /// response. -pub fn request(request: &[u8], timeout: Duration) -> Result, SmolError> { +pub fn request(request: &[u8], timeout: Duration) -> Result, Error> { // A record is returned whole even when it alone exceeds the server's // fetch budget (upstream spec sec 6.1), and a mailbox shared with TCP // can hold a 768 KiB envelope (sec 13.7), so the response buffer is the @@ -130,17 +130,17 @@ pub fn request(request: &[u8], timeout: Duration) -> Result, SmolError> out.truncate(out_len); Ok(out) } - -1 => Err(SmolError::new("no RNS link is open")), - -2 => Err(SmolError::new("failed to send the request over the link")), - -3 | -4 => Err(SmolError::new( + -1 => Err(Error::Other("no RNS link is open".into())), + -2 => Err(Error::Other("failed to send the request over the link".into())), + -3 | -4 => Err(Error::Other( "request failed: no response (closed link, rejected transfer, or timeout \ - -- not a status code, upstream spec 13.5)", - )), - -5 => Err(SmolError::new("malformed response from server")), - -6 => Err(SmolError::new( - "response larger than one application frame; refusing to truncate it", - )), - _ => Err(SmolError::new(format!("RNS request failed (code {rc})"))), + -- not a status code, upstream spec 13.5)".into(), +)), + -5 => Err(Error::Other("malformed response from server".into())), + -6 => Err(Error::Other( + "response larger than one application frame; refusing to truncate it".into(), +)), + _ => Err(Error::Other(format!("RNS request failed (code {rc})"))), } } diff --git a/src/rns/mod.rs b/core/src/rns/mod.rs similarity index 93% rename from src/rns/mod.rs rename to core/src/rns/mod.rs index 1345db9..e183f07 100644 --- a/src/rns/mod.rs +++ b/core/src/rns/mod.rs @@ -13,7 +13,7 @@ pub mod transport; use std::path::Path; use std::sync::{Mutex, OnceLock}; -use crate::error::SmolError; +use crate::error::Error; /// Carrier configuration, set once by the CLI and consumed on first use. #[derive(Clone, Debug)] @@ -54,7 +54,7 @@ pub fn configure(config: RnsConfig) { } /// Starts the Reticulum stack at most once, on the first RNS dial. -pub fn ensure_started() -> Result<(), SmolError> { +pub fn ensure_started() -> Result<(), Error> { if STARTED.get().is_some() { return Ok(()); } @@ -64,7 +64,7 @@ pub fn ensure_started() -> Result<(), SmolError> { } let config = CONFIG.get().cloned().unwrap_or_default(); std::fs::create_dir_all(Path::new(&config.storage_dir)) - .map_err(|e| SmolError::new(format!("cannot create {}: {e}", config.storage_dir)))?; + .map_err(|e| Error::Other(format!("cannot create {}: {e}", config.storage_dir)))?; ffi::start(&config)?; let _ = STARTED.set(()); Ok(()) diff --git a/src/rns/transport.rs b/core/src/rns/transport.rs similarity index 95% rename from src/rns/transport.rs rename to core/src/rns/transport.rs index 659484f..d98e397 100644 --- a/src/rns/transport.rs +++ b/core/src/rns/transport.rs @@ -9,7 +9,7 @@ use std::time::Duration; -use crate::error::SmolError; +use crate::error::Error; use crate::rns::{ensure_started, ffi}; use crate::transport::{Response, Transport, TransportBindValues}; @@ -30,7 +30,7 @@ impl RnsTransport { /// Starts the carrier if needed, requests a path, and opens a fresh /// link — never reused across authentications, since link_id is what /// stops an AUTH replaying on another link (upstream spec sec 13.6). - pub fn connect(destination: &[u8; 16], timeout: Duration) -> Result { + pub fn connect(destination: &[u8; 16], timeout: Duration) -> Result { ensure_started()?; let link_id = ffi::connect(destination, timeout)?; Ok(RnsTransport { @@ -51,7 +51,7 @@ impl Transport for RnsTransport { /// (upstream spec sec 13.5): every operation body and response payload /// is reused byte for byte from SPEC.md sec 6.1, with only the `length /// u32` gone, because Reticulum delimits messages itself. - fn request(&mut self, op: u8, body: &[u8]) -> Result { + fn request(&mut self, op: u8, body: &[u8]) -> Result { let mut wire = Vec::with_capacity(1 + body.len()); wire.push(op); wire.extend_from_slice(body); @@ -61,7 +61,7 @@ impl Transport for RnsTransport { let status = response .first() .copied() - .ok_or_else(|| SmolError::new("empty response from server"))?; + .ok_or_else(|| Error::Other("empty response from server".into()))?; Ok(Response { status, body: response[1..].to_vec(), diff --git a/src/store.rs b/core/src/store.rs similarity index 81% rename from src/store.rs rename to core/src/store.rs index 43d5f3f..838cfdf 100644 --- a/src/store.rs +++ b/core/src/store.rs @@ -1,7 +1,11 @@ //! Local state: one SQLite file, shared by both carriers (RNS.md sec 15). //! Envelopes are stored sealed and opened on demand; no plaintext at rest. +//! The handle is `Send + Sync`: every operation locks an inner mutex, so a +//! GUI can hold one `Store` behind an executor and read it (for incremental +//! fetch progress) while a fetch runs on another thread. use std::path::Path; +use std::sync::{Mutex, MutexGuard}; use std::time::{SystemTime, UNIX_EPOCH}; use rusqlite::{params, Connection}; @@ -9,7 +13,7 @@ use rusqlite::{params, Connection}; use crate::account::Account; use crate::address::Address; use crate::crypto::ct_eq; -use crate::error::SmolError; +use crate::error::Error; use crate::transport::{ID_LEN, KEY_LEN, TIER_MAIN, TIER_REQUESTS}; pub fn now() -> i64 { @@ -67,26 +71,52 @@ pub struct Stored { } pub struct Store { - db: Connection, + db: Mutex, } /// A contact row: address, identity, verified, and its acceptance state /// (None when never accepted) for `fumi contacts`. pub type ContactRow = (String, [u8; KEY_LEN], bool, Option); +/// The store's schema version, in SQLite's `user_version`. A store written +/// by a newer fumi is refused rather than misread; within a version the +/// schema is stable, and a bump comes with a documented migration. +pub const SCHEMA_VERSION: i32 = 1; + impl Store { - pub fn open(path: &Path) -> Result { + pub fn open(path: &Path) -> Result { let db = Connection::open(path)?; - db.execute_batch(SCHEMA)?; - Ok(Store { db }) + // WAL keeps concurrent readers cheap while a writer commits, and a + // short busy timeout absorbs the contention the mutex cannot (a + // second connection in the same process, or a CLI run alongside). + db.pragma_update(None, "journal_mode", "WAL")?; + db.busy_timeout(std::time::Duration::from_secs(5))?; + let version: i32 = db.query_row("PRAGMA user_version", [], |row| row.get(0))?; + if version == 0 { + db.execute_batch(SCHEMA)?; + db.pragma_update(None, "user_version", SCHEMA_VERSION)?; + } else if version != SCHEMA_VERSION { + return Err(Error::SchemaVersion { + found: version, + expected: SCHEMA_VERSION, + }); + } + Ok(Store { + db: Mutex::new(db), + }) } - fn one(&self, sql: &str, params: &[&dyn rusqlite::ToSql]) -> Result, 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(&self, sql: &str, params: &[&dyn rusqlite::ToSql]) -> Result, Error> where T: rusqlite::types::FromSql, { Ok( - match self.db.query_row(sql, params, |row| row.get::<_, T>(0)) { + match self.db().query_row(sql, params, |row| row.get::<_, T>(0)) { Ok(value) => Some(value), Err(rusqlite::Error::QueryReturnedNoRows) => None, Err(e) => return Err(e.into()), @@ -95,8 +125,8 @@ impl Store { } /// The home address, when this identity has been registered or restored. - pub fn account(&self) -> Result, SmolError> { - let row = self.db.query_row( + pub fn account(&self) -> Result, Error> { + let row = self.db().query_row( "SELECT username, host, port, scheme FROM state WHERE id = 1", [], |row| { @@ -124,19 +154,19 @@ impl Store { format!("{username}@{host}") }; let mut addr = Address::parse(&text) - .map_err(|e| SmolError(format!("stored account is not parseable: {e}")))?; + .map_err(|e| Error::Other(format!("stored account is not parseable: {e}")))?; if addr.scheme == crate::address::Scheme::Tcp { addr.port = port; } Ok(Some(addr)) } - pub fn set_account(&self, addr: &Address) -> Result<(), SmolError> { + pub fn set_account(&self, addr: &Address) -> Result<(), Error> { let scheme = match addr.scheme { crate::address::Scheme::Tcp => "tcp", crate::address::Scheme::Rns => "rns", }; - self.db + self.db() .execute( "UPDATE state SET username = ?1, host = ?2, port = ?3, scheme = ?4 WHERE id = 1", params![addr.user, addr.host, addr.port, scheme], @@ -145,14 +175,14 @@ impl Store { Ok(()) } - pub fn rotations(&self) -> Result { + pub fn rotations(&self) -> Result { Ok(self .one::("SELECT rotations FROM state WHERE id = 1", &[])? .unwrap_or(0) as u32) } - pub fn set_rotations(&self, index: u32) -> Result<(), SmolError> { - self.db + pub fn set_rotations(&self, index: u32) -> Result<(), Error> { + self.db() .execute( "UPDATE state SET rotations = ?1 WHERE id = 1", params![index], @@ -161,15 +191,15 @@ impl Store { Ok(()) } - pub fn cursor(&self) -> Result<(i64, [u8; ID_LEN]), SmolError> { + pub fn cursor(&self) -> Result<(i64, [u8; ID_LEN]), Error> { let (after_time, after_id): (i64, Vec) = self - .db + .db() .query_row( "SELECT after_time, after_id FROM state WHERE id = 1", [], |row| Ok((row.get(0)?, row.get(1)?)), ) - .map_err(SmolError::from)?; + .map_err(Error::from)?; let after_id: [u8; ID_LEN] = if after_id.len() == ID_LEN { after_id.try_into().unwrap() } else { @@ -178,8 +208,8 @@ impl Store { Ok((after_time, after_id)) } - pub fn set_cursor(&self, after_time: i64, after_id: &[u8; ID_LEN]) -> Result<(), SmolError> { - self.db + pub fn set_cursor(&self, after_time: i64, after_id: &[u8; ID_LEN]) -> Result<(), Error> { + self.db() .execute( "UPDATE state SET after_time = ?1, after_id = ?2 WHERE id = 1", params![after_time, after_id], @@ -190,15 +220,15 @@ impl Store { /// Whether the local accept-token set may replace the server's: a client /// restored from the master alone must not erase it (sec 4). - pub fn sync_ok(&self) -> Result { + pub fn sync_ok(&self) -> Result { Ok(self .one::("SELECT sync_ok FROM state WHERE id = 1", &[])? .unwrap_or(1) != 0) } - pub fn set_sync_ok(&self, ok: bool) -> Result<(), SmolError> { - self.db + pub fn set_sync_ok(&self, ok: bool) -> Result<(), Error> { + self.db() .execute( "UPDATE state SET sync_ok = ?1 WHERE id = 1", params![ok as i64], @@ -207,15 +237,15 @@ impl Store { Ok(()) } - pub fn server_pin(&self, host: &str) -> Result, SmolError> { + pub fn server_pin(&self, host: &str) -> Result, Error> { Ok(self .one::>>("SELECT static FROM servers WHERE host = ?1", &[&host])? .flatten() .and_then(|k| k.try_into().ok())) } - pub fn pin_server(&self, host: &str, key: &[u8; KEY_LEN]) -> Result<(), SmolError> { - self.db + pub fn pin_server(&self, host: &str, key: &[u8; KEY_LEN]) -> Result<(), Error> { + self.db() .execute( "INSERT INTO servers (host, static, pinned_at) VALUES (?1, ?2, ?3) \ ON CONFLICT (host) DO UPDATE SET static = ?2, pinned_at = ?3", @@ -225,8 +255,8 @@ impl Store { Ok(()) } - pub fn contact(&self, address: &str) -> Result, SmolError> { - let row = self.db.query_row( + pub fn contact(&self, address: &str) -> Result, Error> { + let row = self.db().query_row( "SELECT identity, verified FROM contacts WHERE address = ?1", [&address], |row| Ok((row.get::<_, Vec>(0)?, row.get::<_, i64>(1)?)), @@ -235,7 +265,7 @@ impl Store { Ok((identity, verified)) => Ok(Some(( identity .try_into() - .map_err(|_| SmolError::new("stored contact identity is not 32 bytes"))?, + .map_err(|_| Error::Other("stored contact identity is not 32 bytes".into()))?, verified != 0, ))), Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), @@ -248,8 +278,8 @@ impl Store { address: &str, identity: &[u8; KEY_LEN], verified: bool, - ) -> Result<(), SmolError> { - self.db + ) -> Result<(), Error> { + self.db() .execute( "INSERT INTO contacts (address, identity, verified, seen_at) VALUES (?1, ?2, ?3, ?4) \ ON CONFLICT (address) DO UPDATE SET identity = ?2, verified = ?3, seen_at = ?4", @@ -260,8 +290,9 @@ impl Store { } /// Every contact with its acceptance state, for `fumi contacts`. - pub fn contact_rows(&self) -> Result, SmolError> { - let mut stmt = self.db.prepare( + pub fn contact_rows(&self) -> Result, Error> { + let db = self.db(); + let mut stmt = db.prepare( "SELECT c.address, c.identity, c.verified, a.active FROM contacts c \ LEFT JOIN accepted a ON a.address = c.address ORDER BY c.address", )?; @@ -284,13 +315,13 @@ impl Store { } /// The accept tokens to push with AUTH, and whether to push at all (sec 4). - pub fn token_set(&self, account: &Account) -> Result<(u8, Vec<[u8; 32]>), SmolError> { + pub fn token_set(&self, account: &Account) -> Result<(u8, Vec<[u8; 32]>), Error> { if !self.sync_ok()? { return Ok((0, Vec::new())); } - let mut stmt = self - .db - .prepare("SELECT identity FROM accepted WHERE active = 1 ORDER BY added_at")?; + let db = self.db(); + let mut stmt = + db.prepare("SELECT identity FROM accepted WHERE active = 1 ORDER BY added_at")?; let tokens = stmt .query_map([], |row| row.get::<_, Vec>(0))? .collect::, _>>()?; @@ -313,8 +344,9 @@ impl Store { &self, sender: &[u8], reply_to: Option<&str>, - ) -> Result, SmolError> { - let mut stmt = self.db.prepare("SELECT address, identity FROM contacts")?; + ) -> Result, Error> { + let db = self.db(); + let mut stmt = db.prepare("SELECT address, identity FROM contacts")?; let rows = stmt .query_map([], |row| { Ok((row.get::<_, String>(0)?, row.get::<_, Vec>(1)?)) @@ -338,8 +370,8 @@ impl Store { /// Admits a correspondent to the main tier, freezing the identity the /// token is derived from (sec 5.8): on conflict the identity is left /// alone, so the token stays the one they already hold. - pub fn accept(&self, address: &str, identity: &[u8; KEY_LEN]) -> Result<(), SmolError> { - self.db + pub fn accept(&self, address: &str, identity: &[u8; KEY_LEN]) -> Result<(), Error> { + self.db() .execute( "INSERT INTO accepted (address, identity, active, added_at) VALUES (?1, ?2, 1, ?3) \ ON CONFLICT (address) DO UPDATE SET active = 1", @@ -350,8 +382,8 @@ impl Store { Ok(()) } - pub fn block(&self, address: &str) -> Result { - let changed = self.db.execute( + pub fn block(&self, address: &str) -> Result { + let changed = self.db().execute( "UPDATE accepted SET active = 0 WHERE address = ?1", params![address], )?; @@ -360,7 +392,7 @@ impl Store { /// The identity frozen at acceptance, if this address is currently /// accepted; the token for our own mailbox travels in the next message. - pub fn accepted_identity(&self, address: &str) -> Result, SmolError> { + pub fn accepted_identity(&self, address: &str) -> Result, Error> { let identity: Option> = self.one( "SELECT identity FROM accepted WHERE address = ?1 AND active = 1", &[&address], @@ -369,14 +401,14 @@ impl Store { } /// A token a correspondent issued us, filed under their address (sec 5.8). - pub fn token_of(&self, address: &str) -> Result, SmolError> { + pub fn token_of(&self, address: &str) -> Result, Error> { let token: Option> = self.one("SELECT token FROM tokens WHERE address = ?1", &[&address])?; Ok(token.and_then(|token| token.try_into().ok())) } - pub fn save_token(&self, address: &str, token: &[u8; 32]) -> Result<(), SmolError> { - self.db + pub fn save_token(&self, address: &str, token: &[u8; 32]) -> Result<(), Error> { + self.db() .execute( "INSERT INTO tokens (address, token, seen_at) VALUES (?1, ?2, ?3) \ ON CONFLICT (address) DO UPDATE SET token = ?2, seen_at = ?3", @@ -386,7 +418,7 @@ impl Store { Ok(()) } - pub fn seen(&self, id: &[u8; ID_LEN]) -> Result { + pub fn seen(&self, id: &[u8; ID_LEN]) -> Result { Ok(self .one::("SELECT 1 FROM seen WHERE id = ?1", &[id])? .is_some()) @@ -398,19 +430,19 @@ impl Store { envelope: &[u8], received_at: i64, requests_tier: bool, - ) -> Result<(), SmolError> { + ) -> Result<(), Error> { let tier = if requests_tier { TIER_REQUESTS } else { TIER_MAIN }; - self.db + self.db() .execute( "INSERT OR IGNORE INTO inbox (id, envelope, received_at, tier) VALUES (?1, ?2, ?3, ?4)", params![id, envelope, received_at, tier], ) .map(|_| ())?; - self.db + self.db() .execute( "INSERT OR IGNORE INTO seen (id, at) VALUES (?1, ?2)", params![id, received_at], @@ -425,8 +457,8 @@ impl Store { recipient: &str, envelope: &[u8], sent_at: i64, - ) -> Result<(), SmolError> { - self.db + ) -> Result<(), Error> { + self.db() .execute( "INSERT OR IGNORE INTO sent (id, recipient, envelope, sent_at) VALUES (?1, ?2, ?3, ?4)", params![id, recipient, envelope, sent_at], @@ -437,7 +469,7 @@ impl Store { /// One folder, ordered by arrival or sending time. `folder` is "inbox", /// "requests", "sent" or "all". - pub fn mail(&self, folder: &str) -> Result, SmolError> { + pub fn mail(&self, folder: &str) -> Result, Error> { let (sql, tier): (&str, Option) = match folder { "sent" => ( "SELECT id, envelope, sent_at, recipient FROM sent ORDER BY sent_at, id", @@ -458,7 +490,8 @@ impl Store { Some(TIER_MAIN as i8), ), }; - let mut stmt = self.db.prepare(sql)?; + let db = self.db(); + let mut stmt = db.prepare(sql)?; let rows = match tier { Some(t) => stmt .query_map(params![t], row_stored)? @@ -650,4 +683,42 @@ mod tests { let _ = identity_seed(&master(), n as u32); } } + + #[test] + fn schema_is_versioned_and_refuses_a_newer_store() { + let path = std::env::temp_dir().join(format!( + "fumi-store-schema-{}-{}.db", + std::process::id(), + now() + )); + drop(Store::open(&path).expect("open store")); + let db = Connection::open(&path).unwrap(); + let version: i32 = db.query_row("PRAGMA user_version", [], |row| row.get(0)).unwrap(); + assert_eq!(version, SCHEMA_VERSION); + // A store from the future is refused, not misread. + db.pragma_update(None, "user_version", SCHEMA_VERSION + 1).unwrap(); + drop(db); + match Store::open(&path) { + Err(Error::SchemaVersion { found, expected }) => { + assert_eq!((found, expected), (SCHEMA_VERSION + 1, SCHEMA_VERSION)); + } + _ => panic!("a newer schema must be refused"), + } + } + + #[test] + fn store_is_shareable_across_threads() { + fn assert_send_sync() {} + assert_send_sync::(); + 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); + } } diff --git a/src/tcp.rs b/core/src/tcp.rs similarity index 83% rename from src/tcp.rs rename to core/src/tcp.rs index 54c305e..18c5483 100644 --- a/src/tcp.rs +++ b/core/src/tcp.rs @@ -7,8 +7,8 @@ use std::time::Duration; use snow::{Builder, TransportState}; -use crate::crypto::{b32, ct_eq, KEY_LEN}; -use crate::error::SmolError; +use crate::crypto::{ct_eq, KEY_LEN}; +use crate::error::Error; use crate::transport::{ Response, Transport, TransportBindValues, MAX_FRAME, NOISE_PARAMS, NOISE_PAYLOAD, PROLOGUE, }; @@ -34,10 +34,12 @@ impl TcpTransport { port: u16, pinned: Option<[u8; KEY_LEN]>, timeout: u64, - ) -> anyhow::Result { - let addr = (host, port); - let stream = TcpStream::connect(addr) - .map_err(|e| anyhow::anyhow!("cannot reach {host}:{port}: {e}"))?; + ) -> Result { + let stream = TcpStream::connect((host, port)).map_err(|source| Error::Unreachable { + host: host.to_string(), + port, + source, + })?; stream.set_read_timeout(Some(Duration::from_secs(timeout)))?; stream.set_write_timeout(Some(Duration::from_secs(timeout)))?; let mut stream = stream; @@ -54,25 +56,25 @@ impl TcpTransport { stream.read_exact(&mut message)?; noise.read_message(&message, &mut buf)?; if !noise.is_handshake_finished() { - anyhow::bail!("handshake did not complete"); + return Err(Error::Other("handshake did not complete".into())); } let server_static: [u8; KEY_LEN] = noise .get_remote_static() - .ok_or_else(|| anyhow::anyhow!("server sent no static key"))? - .try_into()?; + .ok_or_else(|| Error::Other("server sent no static key".into()))? + .try_into() + .map_err(|_| Error::Other("server static key is not 32 bytes".into()))?; let handshake_hash = noise.get_handshake_hash().to_vec(); let bind = TransportBindValues::tcp(&handshake_hash, &server_static)?; let transport = noise.into_transport_mode()?; if let Some(pinned) = pinned { if !ct_eq(&pinned, &server_static) { - return Err(anyhow::anyhow!( - "{host} presented a different key than the one pinned\n pinned: {}\n \ - presented: {}", - b32(&pinned), - b32(&server_static) - )); + return Err(Error::PinMismatch { + host: host.to_string(), + pinned, + presented: server_static, + }); } } @@ -96,7 +98,7 @@ impl TcpTransport { &self.host } - fn read_noise(&mut self) -> Result, SmolError> { + fn read_noise(&mut self) -> Result, Error> { let len = read_u16_len(&mut self.stream)?; let mut ciphertext = vec![0u8; len]; self.stream.read_exact(&mut ciphertext)?; @@ -106,7 +108,7 @@ impl TcpTransport { Ok(plaintext) } - fn write_noise(&mut self, payload: &[u8]) -> Result<(), SmolError> { + fn write_noise(&mut self, payload: &[u8]) -> Result<(), Error> { let mut packet = vec![0u8; payload.len() + 16]; let n = self.noise.write_message(payload, &mut packet)?; packet.truncate(n); @@ -119,10 +121,10 @@ impl Transport for TcpTransport { /// Sends one application frame (u32 length || op || body, split across /// as many Noise messages as it needs) and reads the response frame, /// returning its status byte and body (sec 4, sec 6.1). - fn request(&mut self, op: u8, body: &[u8]) -> Result { + fn request(&mut self, op: u8, body: &[u8]) -> Result { let length = 1 + body.len(); if length > MAX_FRAME { - return Err(SmolError::new("request exceeds the maximum frame size")); + return Err(Error::Other("request exceeds the maximum frame size".into())); } let mut frame = Vec::with_capacity(4 + length); frame.extend_from_slice(&(length as u32).to_be_bytes()); @@ -138,7 +140,7 @@ impl Transport for TcpTransport { } let length = u32::from_be_bytes(self.buf[..4].try_into().unwrap()) as usize; if length == 0 || length > MAX_FRAME { - return Err(SmolError::new(format!( + return Err(Error::Other(format!( "server sent a frame of length {length}" ))); } diff --git a/src/transport.rs b/core/src/transport.rs similarity index 84% rename from src/transport.rs rename to core/src/transport.rs index 850b45c..9b20e4e 100644 --- a/src/transport.rs +++ b/core/src/transport.rs @@ -6,7 +6,7 @@ // Used only by the RNS bind values and their tests. #[cfg(any(test, feature = "rns"))] use crate::crypto::sha256; -use crate::error::SmolError; +use crate::error::Error; pub const NOISE_PARAMS: &str = "Noise_NX_25519_ChaChaPoly_SHA256"; pub const PROLOGUE: &[u8] = b"smolmail/1"; @@ -71,30 +71,30 @@ impl<'a> Reader<'a> { Reader { buf, pos: 0 } } - pub fn take(&mut self, n: usize) -> Result<&'a [u8], SmolError> { + pub fn take(&mut self, n: usize) -> Result<&'a [u8], Error> { if self.pos + n > self.buf.len() { - return Err(SmolError::new("truncated response from server")); + return Err(Error::Other("truncated response from server".into())); } let out = &self.buf[self.pos..self.pos + n]; self.pos += n; Ok(out) } - pub fn u8(&mut self) -> Result { + pub fn u8(&mut self) -> Result { Ok(self.take(1)?[0]) } - pub fn u16(&mut self) -> Result { + pub fn u16(&mut self) -> Result { let b = self.take(2)?; Ok(u16::from_be_bytes([b[0], b[1]])) } - pub fn u32(&mut self) -> Result { + pub fn u32(&mut self) -> Result { let b = self.take(4)?; Ok(u32::from_be_bytes(b.try_into().unwrap())) } - pub fn i64(&mut self) -> Result { + pub fn i64(&mut self) -> Result { let b = self.take(8)?; Ok(i64::from_be_bytes(b.try_into().unwrap())) } @@ -105,9 +105,9 @@ impl<'a> Reader<'a> { out } - pub fn done(&self) -> Result<(), SmolError> { + pub fn done(&self) -> Result<(), Error> { if self.pos != self.buf.len() { - return Err(SmolError::new("trailing bytes in response")); + return Err(Error::Other("trailing bytes in response".into())); } Ok(()) } @@ -127,11 +127,11 @@ pub struct TransportBindValues { impl TransportBindValues { /// Noise binds to the handshake hash and the server's real static key. - pub fn tcp(handshake_hash: &[u8], server_static: &[u8; KEY_LEN]) -> anyhow::Result { + pub fn tcp(handshake_hash: &[u8], server_static: &[u8; KEY_LEN]) -> Result { Ok(Self { h: handshake_hash .try_into() - .map_err(|_| anyhow::anyhow!("handshake hash not 32 bytes"))?, + .map_err(|_| Error::Other("handshake hash not 32 bytes".into()))?, server_static: *server_static, }) } @@ -155,7 +155,7 @@ impl TransportBindValues { /// server identity came from a trusted channel, which decides whether /// RESOLVE results may be marked verified (sec 4, RNS.md sec 13.4). pub trait Transport { - fn request(&mut self, op: u8, body: &[u8]) -> Result; + fn request(&mut self, op: u8, body: &[u8]) -> Result; fn bind(&self) -> &TransportBindValues; fn pinned(&self) -> bool; fn close(&mut self); @@ -165,9 +165,6 @@ pub trait Transport { mod tests { use super::*; - // Vectors computed independently over the RNS.md sec 13.6 formula - // SHA-256("smolmail/1 bind" || destination || link_id); shared with - // bunshin, whose server verifies what this client produces. const DEST: [u8; 16] = [ 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, @@ -176,17 +173,21 @@ mod tests { 0xa0, 0xa1, 0xa2, 0xa3, 0xa4, 0xa5, 0xa6, 0xa7, 0xa8, 0xa9, 0xaa, 0xab, 0xac, 0xad, 0xae, 0xaf, ]; - const H_HEX: &str = "06d6437dc63250ff51d609e12b488ff22b4fa02620f35000b3b2ed1c8789d45c"; - const SERVER_STATIC_HEX: &str = - "da6fe109dc4da2878fa4810ffa0e08cef6b68a8b524126aa05bb14181b20d25a"; #[test] fn rns_matches_reference_vectors() { - let bind = TransportBindValues::rns(&DEST, &LINK); - assert_eq!(data_encoding::HEXLOWER.encode(&bind.h), H_HEX); + // The SHA-256("smolmail/1 bind" || destination || link_id) formula + // of RNS.md sec 13.6, shared with bunshin, whose server verifies + // what this client produces. + let v = crate::vectors::load(); + let rb = &v.rns_bind; + let destination: [u8; 16] = v.hex(&rb.destination_hex).try_into().unwrap(); + let link_id: [u8; 16] = v.hex(&rb.link_id_hex).try_into().unwrap(); + let bind = TransportBindValues::rns(&destination, &link_id); + assert_eq!(data_encoding::HEXLOWER.encode(&bind.h), rb.h_hex); assert_eq!( data_encoding::HEXLOWER.encode(&bind.server_static), - SERVER_STATIC_HEX + rb.server_static_hex ); } diff --git a/core/src/vectors.rs b/core/src/vectors.rs new file mode 100644 index 0000000..23499a8 --- /dev/null +++ b/core/src/vectors.rs @@ -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, + /// `Identity::from_seed(seed_n).pk()` per rotation index, base32. + pub identity_pks_b32: std::collections::BTreeMap, + /// The X25519 half per rotation index, base32. + pub identity_xprivs_b32: std::collections::BTreeMap, + 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, 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 { + data_encoding::HEXLOWER.decode(hex.as_bytes()).expect("hex decodes") + } +} diff --git a/core/vectors.json b/core/vectors.json new file mode 100644 index 0000000..387caa3 --- /dev/null +++ b/core/vectors.json @@ -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" + } +} diff --git a/src/error.rs b/src/error.rs deleted file mode 100644 index b17554f..0000000 --- a/src/error.rs +++ /dev/null @@ -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) -> 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 for SmolError { - fn from(e: anyhow::Error) -> Self { - SmolError(e.to_string()) - } -} - -impl From for SmolError { - fn from(e: rusqlite::Error) -> Self { - SmolError(format!("storage error: {e}")) - } -} - -impl From for SmolError { - fn from(e: std::io::Error) -> Self { - SmolError(e.to_string()) - } -} - -impl From 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})"))) -}