feat: gate the store behind a feature and document the schema as a contract
This commit is contained in:
parent
8fb5661b53
commit
aa3950bc80
6 changed files with 122 additions and 16 deletions
|
|
@ -6,7 +6,12 @@ description = "Smol Mail client library: identities, sealing, mailbox operations
|
|||
license = "Apache-2.0"
|
||||
|
||||
[features]
|
||||
default = ["bundled-sqlite"]
|
||||
default = ["store", "bundled-sqlite"]
|
||||
# Local state in SQLite: the store module and the client operations that take
|
||||
# one. Off, the crate keeps identities, addresses, seal/open and the raw
|
||||
# transport operations for hosts with their own database and thin FFI
|
||||
# consumers that need only the crypto and envelope layer.
|
||||
store = ["dep:rusqlite"]
|
||||
# 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"]
|
||||
|
|
@ -23,7 +28,7 @@ sha2 = "0.10"
|
|||
hmac = "0.12"
|
||||
hkdf = "0.12"
|
||||
rand_core = { version = "0.6", features = ["getrandom"] }
|
||||
rusqlite = "0.32"
|
||||
rusqlite = { version = "0.32", optional = true }
|
||||
data-encoding = "2"
|
||||
|
||||
[dev-dependencies]
|
||||
|
|
|
|||
|
|
@ -1,31 +1,48 @@
|
|||
//! The six operations, AUTH session setup, chain walking, token sync and the
|
||||
//! send/fetch pipelines. Everything here is carrier-neutral: it speaks
|
||||
//! through the `Transport` trait and the bodies are byte-identical on both
|
||||
//! carriers (RNS.md sec 13.5, sec 15).
|
||||
//! carriers (RNS.md sec 13.5, sec 15). The operations that take a `Store`
|
||||
//! need the `store` feature; `resolve`, `walk_chain` and `accept_mac` are
|
||||
//! always built.
|
||||
|
||||
use crate::account::RotationCert;
|
||||
use crate::crypto::{ct_eq, hmac_sha256, KEY_LEN};
|
||||
use crate::error::{expect_ok, Error};
|
||||
use crate::transport::{
|
||||
Reader, Transport, CERT_LEN, ID_LEN, LABEL_MAC, MAX_CHAIN, OP_RESOLVE, TOKEN_LEN,
|
||||
};
|
||||
|
||||
#[cfg(feature = "store")]
|
||||
use crate::account::{Account, Identity};
|
||||
#[cfg(feature = "store")]
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::account::{Account, Identity, RotationCert};
|
||||
#[cfg(feature = "store")]
|
||||
use crate::address::{Address, Scheme};
|
||||
use crate::crypto::{b32, ct_eq, hmac_sha256, KEY_LEN};
|
||||
use crate::error::{expect_ok, Error};
|
||||
#[cfg(feature = "store")]
|
||||
use crate::crypto::b32;
|
||||
#[cfg(feature = "store")]
|
||||
use crate::message::{
|
||||
accept_field, build_frontmatter, message_id, parse_frontmatter, seal, unseal, Opened,
|
||||
};
|
||||
#[cfg(feature = "store")]
|
||||
use crate::store::{now, Store, Stored};
|
||||
#[cfg(feature = "store")]
|
||||
use crate::tcp::TcpTransport;
|
||||
#[cfg(feature = "store")]
|
||||
use crate::transport::{
|
||||
Reader, Transport, CERT_LEN, FLAG_REQUESTS, ID_LEN, LABEL_AUTH, LABEL_MAC, LABEL_REGISTER,
|
||||
MAX_CHAIN, OP_AUTH, OP_DELETE, OP_FETCH, OP_REGISTER, OP_RESOLVE, OP_SEND, TOKEN_LEN,
|
||||
FLAG_REQUESTS, LABEL_AUTH, LABEL_REGISTER, OP_AUTH, OP_DELETE, OP_FETCH, OP_REGISTER, OP_SEND,
|
||||
};
|
||||
|
||||
/// A connection plus what the caller needs to know about how it was made:
|
||||
/// the server's static key when no pin was checked, for the sec 4 warning.
|
||||
#[cfg(feature = "store")]
|
||||
pub struct Session {
|
||||
transport: Box<dyn Transport>,
|
||||
pub unpinned_static: Option<[u8; KEY_LEN]>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "store")]
|
||||
impl Session {
|
||||
pub fn transport(&mut self) -> &mut dyn Transport {
|
||||
self.transport.as_mut()
|
||||
|
|
@ -39,6 +56,7 @@ impl Session {
|
|||
/// Opens a session, enforcing sec 4's rule about unpinned servers. TCP pins
|
||||
/// the server's Noise static key; on RNS the destination hash is the pin
|
||||
/// (RNS.md sec 13.4).
|
||||
#[cfg(feature = "store")]
|
||||
pub fn connect(
|
||||
store: &Store,
|
||||
addr: &Address,
|
||||
|
|
@ -174,6 +192,7 @@ pub enum TrustChange {
|
|||
/// Resolves a contact and applies sec 8's trust rules. A key change without
|
||||
/// a valid chain is an error here: the user confirms out of band and
|
||||
/// imports, rather than clicking through.
|
||||
#[cfg(feature = "store")]
|
||||
pub fn trust_key(
|
||||
store: &Store,
|
||||
transport: &mut dyn Transport,
|
||||
|
|
@ -211,6 +230,7 @@ pub fn trust_key(
|
|||
/// trailing block carries the mailbox's tokens (sec 5.8): `sync = 0` leaves
|
||||
/// the stored set untouched, `sync = 1` replaces it with exactly what
|
||||
/// follows.
|
||||
#[cfg(feature = "store")]
|
||||
pub fn authenticate(
|
||||
transport: &mut dyn Transport,
|
||||
username: &str,
|
||||
|
|
@ -242,6 +262,7 @@ pub fn authenticate(
|
|||
}
|
||||
|
||||
/// What pushing the accept-token set achieved (sec 5.8).
|
||||
#[cfg(feature = "store")]
|
||||
pub enum Pushed {
|
||||
/// Not registered yet; the set travels with the first fetch instead.
|
||||
NotRegistered,
|
||||
|
|
@ -251,6 +272,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.
|
||||
#[cfg(feature = "store")]
|
||||
pub fn push_tokens(store: &Store, account: &Account, timeout: u64) -> Result<Pushed, Error> {
|
||||
let Some(addr) = store.account()? else {
|
||||
return Ok(Pushed::NotRegistered);
|
||||
|
|
@ -264,6 +286,7 @@ pub fn push_tokens(store: &Store, account: &Account, timeout: u64) -> Result<Pus
|
|||
/// REGISTER (sec 6.1): binds this identity to a username, with proof of
|
||||
/// possession over the server's static key so the attestation cannot be
|
||||
/// replayed to another server.
|
||||
#[cfg(feature = "store")]
|
||||
pub fn register(
|
||||
store: &Store,
|
||||
addr: &Address,
|
||||
|
|
@ -300,6 +323,7 @@ pub fn register(
|
|||
|
||||
/// A completed rotation: the new key and the certificate the server now
|
||||
/// holds, ready to be pushed to contacts as an ordinary message (sec 7).
|
||||
#[cfg(feature = "store")]
|
||||
pub struct Rotated {
|
||||
pub new_pk: [u8; KEY_LEN],
|
||||
pub cert: [u8; CERT_LEN],
|
||||
|
|
@ -308,6 +332,7 @@ 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).
|
||||
#[cfg(feature = "store")]
|
||||
pub fn rotate(store: &Store, account: &Account, timeout: u64) -> Result<Rotated, Error> {
|
||||
let Some(addr) = store.account()? else {
|
||||
return Err(Error::NotRegistered);
|
||||
|
|
@ -360,6 +385,7 @@ pub fn rotate(store: &Store, account: &Account, timeout: u64) -> Result<Rotated,
|
|||
/// The pieces are public for hosts that want intermediate UI states:
|
||||
/// `connect` + `resolve`, `find_rotation_index`, then the four `set_*`
|
||||
/// calls below.
|
||||
#[cfg(feature = "store")]
|
||||
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)?;
|
||||
|
|
@ -379,6 +405,7 @@ pub fn restore(store: &Store, master: &[u8; KEY_LEN], addr: &Address, timeout: u
|
|||
}
|
||||
|
||||
/// One message to send, as the CLI assembled it.
|
||||
#[cfg(feature = "store")]
|
||||
pub struct SendDraft<'a> {
|
||||
pub address: &'a Address,
|
||||
pub text: String,
|
||||
|
|
@ -393,6 +420,7 @@ pub struct SendDraft<'a> {
|
|||
}
|
||||
|
||||
/// What `send` did, for the caller to report.
|
||||
#[cfg(feature = "store")]
|
||||
pub struct Sent {
|
||||
pub id: [u8; ID_LEN],
|
||||
pub bytes: usize,
|
||||
|
|
@ -407,6 +435,7 @@ pub struct Sent {
|
|||
/// Seals and delivers one message (sec 5, sec 6.1). Recipient selection
|
||||
/// prefers a key we already trust: a self-certifying address, then a stored
|
||||
/// contact, and only then RESOLVE with trust on first use.
|
||||
#[cfg(feature = "store")]
|
||||
pub fn send(
|
||||
store: &Store,
|
||||
account: &Account,
|
||||
|
|
@ -490,12 +519,14 @@ pub fn send(
|
|||
}
|
||||
|
||||
/// One rejected fetch record: the id and why it did not enter the inbox.
|
||||
#[cfg(feature = "store")]
|
||||
pub struct Rejected {
|
||||
pub id: [u8; ID_LEN],
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
/// What `fetch` did, for the caller to report.
|
||||
#[cfg(feature = "store")]
|
||||
pub struct Fetched {
|
||||
pub total: u32,
|
||||
pub stored: u32,
|
||||
|
|
@ -512,6 +543,7 @@ pub struct Fetched {
|
|||
/// 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.
|
||||
#[cfg(feature = "store")]
|
||||
pub struct FetchOptions<'a> {
|
||||
/// Remember the cursor instead of acknowledging with DELETE.
|
||||
pub keep: bool,
|
||||
|
|
@ -524,6 +556,7 @@ pub struct FetchOptions<'a> {
|
|||
pub cancel: Option<&'a std::sync::atomic::AtomicBool>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "store")]
|
||||
impl FetchOptions<'_> {
|
||||
/// The defaults a plain fetch uses.
|
||||
fn new(keep: bool, reset: bool, timeout: u64) -> Self {
|
||||
|
|
@ -541,6 +574,7 @@ impl FetchOptions<'_> {
|
|||
/// it takes, so it always pages from the start; `keep` instead remembers the
|
||||
/// cursor. A message that fails verification is left on the server, so a
|
||||
/// client-side bug cannot lose mail.
|
||||
#[cfg(feature = "store")]
|
||||
pub fn fetch(
|
||||
store: &Store,
|
||||
account: &Account,
|
||||
|
|
@ -552,6 +586,7 @@ pub fn fetch(
|
|||
}
|
||||
|
||||
/// `fetch` with the full option set, for hosts that need cancellation.
|
||||
#[cfg(feature = "store")]
|
||||
pub fn fetch_with(
|
||||
store: &Store,
|
||||
account: &Account,
|
||||
|
|
@ -641,6 +676,7 @@ pub fn fetch_with(
|
|||
Ok(summary)
|
||||
}
|
||||
|
||||
#[cfg(feature = "store")]
|
||||
fn verify_and_open(
|
||||
account: &Account,
|
||||
mid: &[u8; ID_LEN],
|
||||
|
|
@ -655,6 +691,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.
|
||||
#[cfg(feature = "store")]
|
||||
fn learn_token(store: &Store, opened: &Opened) -> Result<(), Error> {
|
||||
let text = String::from_utf8_lossy(&opened.body).into_owned();
|
||||
let (fields, _) = parse_frontmatter(&text);
|
||||
|
|
@ -669,6 +706,7 @@ fn learn_token(store: &Store, opened: &Opened) -> Result<(), Error> {
|
|||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "store")]
|
||||
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());
|
||||
|
|
@ -687,6 +725,7 @@ fn delete_ids(transport: &mut dyn Transport, ids: &[[u8; ID_LEN]]) -> Result<u16
|
|||
|
||||
/// DELETE (sec 6.1) over an authenticated session: remove ids from the
|
||||
/// server explicitly. Unknown ids are not an error.
|
||||
#[cfg(feature = "store")]
|
||||
pub fn delete(
|
||||
store: &Store,
|
||||
account: &Account,
|
||||
|
|
@ -705,6 +744,7 @@ pub fn delete(
|
|||
}
|
||||
|
||||
/// An opened, described message for listing and reading.
|
||||
#[cfg(feature = "store")]
|
||||
pub struct Described {
|
||||
pub sender: [u8; KEY_LEN],
|
||||
pub time: i64,
|
||||
|
|
@ -715,6 +755,7 @@ pub struct Described {
|
|||
pub subject: String,
|
||||
}
|
||||
|
||||
#[cfg(feature = "store")]
|
||||
impl Described {
|
||||
/// Whether this payload carried its signer's accept token (sec 5.8):
|
||||
/// machinery, not content.
|
||||
|
|
@ -724,6 +765,7 @@ impl Described {
|
|||
}
|
||||
|
||||
/// Opens one sealed message and splits its body into frontmatter and text.
|
||||
#[cfg(feature = "store")]
|
||||
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();
|
||||
|
|
@ -747,7 +789,7 @@ pub fn accept_mac(token: &[u8; TOKEN_LEN], mid: &[u8; ID_LEN]) -> [u8; 32] {
|
|||
}
|
||||
|
||||
/// Proof of possession over a server's static key (sec 6.1), for tests.
|
||||
#[cfg(test)]
|
||||
#[cfg(all(test, feature = "store"))]
|
||||
fn register_signed(server_static: &[u8], username: &str, identity: &[u8]) -> Vec<u8> {
|
||||
[LABEL_REGISTER, server_static, username.as_bytes(), identity].concat()
|
||||
}
|
||||
|
|
@ -755,7 +797,7 @@ fn register_signed(server_static: &[u8], username: &str, identity: &[u8]) -> Vec
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::account::{identity_seed, verify_sig, Identity, RotationCert};
|
||||
use crate::account::{identity_seed, verify_sig, Account, Identity, RotationCert};
|
||||
use crate::transport::{TransportBindValues, SIG_LEN};
|
||||
|
||||
fn master() -> [u8; 32] {
|
||||
|
|
@ -850,6 +892,7 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "store")]
|
||||
#[test]
|
||||
fn bind_values_feed_the_auth_and_register_signatures() {
|
||||
let bind = TransportBindValues::tcp(&[2u8; 32], &[7u8; 32]).unwrap();
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ pub enum Error {
|
|||
port: u16,
|
||||
source: std::io::Error,
|
||||
},
|
||||
#[cfg(feature = "store")]
|
||||
Storage(rusqlite::Error),
|
||||
Noise(snow::Error),
|
||||
Io(std::io::Error),
|
||||
|
|
@ -111,6 +112,7 @@ impl fmt::Display for Error {
|
|||
Self::Unreachable { host, port, source } => {
|
||||
write!(f, "cannot reach {host}:{port}: {source}")
|
||||
}
|
||||
#[cfg(feature = "store")]
|
||||
Self::Storage(e) => write!(f, "storage error: {e}"),
|
||||
Self::Noise(e) => write!(f, "noise error: {e}"),
|
||||
Self::Io(e) => write!(f, "{e}"),
|
||||
|
|
@ -123,6 +125,7 @@ impl std::error::Error for Error {
|
|||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
match self {
|
||||
Self::Unreachable { source, .. } => Some(source),
|
||||
#[cfg(feature = "store")]
|
||||
Self::Storage(e) => Some(e),
|
||||
Self::Noise(e) => Some(e),
|
||||
Self::Io(e) => Some(e),
|
||||
|
|
@ -131,6 +134,7 @@ impl std::error::Error for Error {
|
|||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "store")]
|
||||
impl From<rusqlite::Error> for Error {
|
||||
fn from(e: rusqlite::Error) -> Self {
|
||||
Error::Storage(e)
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ pub mod error;
|
|||
pub mod message;
|
||||
#[cfg(feature = "rns")]
|
||||
pub mod rns;
|
||||
#[cfg(feature = "store")]
|
||||
pub mod store;
|
||||
pub mod tcp;
|
||||
pub mod transport;
|
||||
|
|
|
|||
|
|
@ -84,12 +84,29 @@ pub type ContactRow = (String, [u8; KEY_LEN], bool, Option<i64>);
|
|||
pub const SCHEMA_VERSION: i32 = 1;
|
||||
|
||||
impl Store {
|
||||
/// Opens the store at `path`. SQLite's own `:memory:` path works too;
|
||||
/// `open_in_memory` spells that case without the magic string.
|
||||
pub fn open(path: &Path) -> Result<Store, Error> {
|
||||
let db = Connection::open(path)?;
|
||||
Store::init(Connection::open(path)?)
|
||||
}
|
||||
|
||||
/// An in-memory store: the same schema, version and contract, gone with
|
||||
/// the handle. For tests, foreign importers assembling rows in code,
|
||||
/// and FFI round-trips that never touch disk.
|
||||
pub fn open_in_memory() -> Result<Store, Error> {
|
||||
Store::init(Connection::open_in_memory()?)
|
||||
}
|
||||
|
||||
fn init(db: Connection) -> Result<Store, Error> {
|
||||
// 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")?;
|
||||
// An in-memory database journals in memory, so WAL applies to
|
||||
// files only.
|
||||
let journal: String = db.query_row("PRAGMA journal_mode", [], |row| row.get(0))?;
|
||||
if journal != "memory" {
|
||||
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 {
|
||||
|
|
@ -706,6 +723,22 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn in_memory_store_round_trips() {
|
||||
let store = Store::open_in_memory().expect("memory store opens");
|
||||
let addr = Address::parse("alice@example.org").unwrap();
|
||||
store.set_account(&addr).unwrap();
|
||||
assert_eq!(
|
||||
store.account().unwrap().unwrap().short(),
|
||||
"alice@example.org"
|
||||
);
|
||||
store.store_inbox(&[1u8; 32], b"envelope", 5, false).unwrap();
|
||||
assert_eq!(store.mail("inbox").unwrap().len(), 1);
|
||||
// The same handle through the path spelling.
|
||||
let path = std::path::Path::new(":memory:");
|
||||
assert!(Store::open(path).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn store_is_shareable_across_threads() {
|
||||
fn assert_send_sync<T: Send + Sync>() {}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue