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