Compare commits
No commits in common. "687ff8b521e4eac1004752ea327e1f328be563e4" and "8872ec6c98c072ffd16b94b4f72ad501b7b56d16" have entirely different histories.
687ff8b521
...
8872ec6c98
5 changed files with 8 additions and 125 deletions
4
Cargo.lock
generated
4
Cargo.lock
generated
|
|
@ -421,7 +421,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fumi"
|
name = "fumi"
|
||||||
version = "0.1.1"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap",
|
"clap",
|
||||||
|
|
@ -433,7 +433,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fumi-core"
|
name = "fumi-core"
|
||||||
version = "0.1.1"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cc",
|
"cc",
|
||||||
"chacha20poly1305",
|
"chacha20poly1305",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "fumi"
|
name = "fumi"
|
||||||
version = "0.1.1"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Smol Mail client"
|
description = "Smol Mail client"
|
||||||
license = "Apache-2.0"
|
license = "Apache-2.0"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "fumi-core"
|
name = "fumi-core"
|
||||||
version = "0.1.1"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Smol Mail client library: identities, sealing, mailbox operations"
|
description = "Smol Mail client library: identities, sealing, mailbox operations"
|
||||||
license = "Apache-2.0"
|
license = "Apache-2.0"
|
||||||
|
|
|
||||||
|
|
@ -529,13 +529,8 @@ pub fn send(
|
||||||
fields.push(("Reply-To".into(), home.uri(&me.pk())));
|
fields.push(("Reply-To".into(), home.uri(&me.pk())));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// sec 5.8: hand an accepted correspondent the token for our own mailbox —
|
// sec 5.8: hand an accepted correspondent the token for our own mailbox.
|
||||||
// once. Re-attaching it to every later message put a secret in the body of
|
if let Some(identity) = store.accepted_identity(&addr.short())? {
|
||||||
// ordinary mail, and a recipient that republishes what it receives cannot
|
|
||||||
// carry one: a published record is verified by rebuilding the exact signed
|
|
||||||
// bytes, and the token must not be among them.
|
|
||||||
let deliver_to = store.token_to_deliver(&addr.short())?;
|
|
||||||
if let Some(identity) = deliver_to {
|
|
||||||
fields.push(("Accept".into(), b32(&account.token_for(&identity))));
|
fields.push(("Accept".into(), b32(&account.token_for(&identity))));
|
||||||
}
|
}
|
||||||
let borrowed: Vec<(&str, &str)> = fields
|
let borrowed: Vec<(&str, &str)> = fields
|
||||||
|
|
@ -572,12 +567,6 @@ pub fn send(
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
// Only now, with the message accepted by their server: a send that failed
|
|
||||||
// earlier leaves the token still owing, so the next attempt carries it.
|
|
||||||
if deliver_to.is_some() {
|
|
||||||
store.mark_delivered(&addr.short())?;
|
|
||||||
}
|
|
||||||
|
|
||||||
// sec 5.6: the ephemeral is gone, so keep a copy sealed to ourselves.
|
// sec 5.6: the ephemeral is gone, so keep a copy sealed to ourselves.
|
||||||
store.store_sent(&mid, &addr.short(), &self_copy, now())?;
|
store.store_sent(&mid, &addr.short(), &self_copy, now())?;
|
||||||
Ok(Sent {
|
Ok(Sent {
|
||||||
|
|
|
||||||
|
|
@ -42,16 +42,9 @@ CREATE TABLE IF NOT EXISTS contacts (
|
||||||
-- Correspondents admitted to this mailbox's main tier (sec 5.8). The
|
-- Correspondents admitted to this mailbox's main tier (sec 5.8). The
|
||||||
-- identity is frozen at acceptance because the token is derived from it: a
|
-- identity is frozen at acceptance because the token is derived from it: a
|
||||||
-- contact's later rotation must not change the token they already hold.
|
-- contact's later rotation must not change the token they already hold.
|
||||||
-- `delivered` records that this correspondent has been handed the token, so
|
|
||||||
-- it is not re-attached to every later message (sec 5.8 says the owner
|
|
||||||
-- *delivers* each token, not that every message carries one). The token is a
|
|
||||||
-- pure function of the master and the identity frozen here, so delivery is
|
|
||||||
-- once and for all: blocking and re-accepting hands back the same value they
|
|
||||||
-- already hold.
|
|
||||||
CREATE TABLE IF NOT EXISTS accepted (
|
CREATE TABLE IF NOT EXISTS accepted (
|
||||||
address TEXT PRIMARY KEY, identity BLOB NOT NULL,
|
address TEXT PRIMARY KEY, identity BLOB NOT NULL,
|
||||||
active INTEGER NOT NULL, added_at INTEGER NOT NULL,
|
active INTEGER NOT NULL, added_at INTEGER NOT NULL);
|
||||||
delivered INTEGER NOT NULL DEFAULT 0);
|
|
||||||
-- Accept tokens received from correspondents, filed under the address that
|
-- Accept tokens received from correspondents, filed under the address that
|
||||||
-- issued them: an address outlives the keys behind it, so a token keeps
|
-- issued them: an address outlives the keys behind it, so a token keeps
|
||||||
-- working across the issuer's rotations (sec 5.8).
|
-- working across the issuer's rotations (sec 5.8).
|
||||||
|
|
@ -97,7 +90,7 @@ pub type ContactRow = (String, [u8; KEY_LEN], bool, Option<i64>);
|
||||||
/// The store's schema version, in SQLite's `user_version`. A store written
|
/// 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
|
/// by a newer fumi is refused rather than misread; within a version the
|
||||||
/// schema is stable, and a bump comes with a documented migration.
|
/// schema is stable, and a bump comes with a documented migration.
|
||||||
pub const SCHEMA_VERSION: i32 = 3;
|
pub const SCHEMA_VERSION: i32 = 2;
|
||||||
|
|
||||||
/// The migration from schema version 1: both changes are additive, so an
|
/// The migration from schema version 1: both changes are additive, so an
|
||||||
/// existing store is upgraded in place at open; only stores newer than this
|
/// existing store is upgraded in place at open; only stores newer than this
|
||||||
|
|
@ -109,17 +102,6 @@ CREATE TABLE IF NOT EXISTS history (
|
||||||
PRIMARY KEY (address, identity));
|
PRIMARY KEY (address, identity));
|
||||||
";
|
";
|
||||||
|
|
||||||
/// The migration from schema version 2, additive like the last one.
|
|
||||||
///
|
|
||||||
/// Existing rows default to *not* delivered, so the next message to each
|
|
||||||
/// accepted correspondent carries the token exactly once more — which is what
|
|
||||||
/// every message did before this change, and therefore cannot break anything
|
|
||||||
/// that works today. Marking them delivered instead would be wrong for anyone
|
|
||||||
/// accepted but never written to, who would then never receive a token at all.
|
|
||||||
const MIGRATE_FROM_2: &str = "
|
|
||||||
ALTER TABLE accepted ADD COLUMN delivered INTEGER NOT NULL DEFAULT 0;
|
|
||||||
";
|
|
||||||
|
|
||||||
impl Store {
|
impl Store {
|
||||||
/// Opens the store at `path`. SQLite's own `:memory:` path works too;
|
/// Opens the store at `path`. SQLite's own `:memory:` path works too;
|
||||||
/// `open_in_memory` spells that case without the magic string.
|
/// `open_in_memory` spells that case without the magic string.
|
||||||
|
|
@ -151,10 +133,6 @@ impl Store {
|
||||||
db.pragma_update(None, "user_version", SCHEMA_VERSION)?;
|
db.pragma_update(None, "user_version", SCHEMA_VERSION)?;
|
||||||
} else if version == 1 {
|
} else if version == 1 {
|
||||||
db.execute_batch(MIGRATE_FROM_1)?;
|
db.execute_batch(MIGRATE_FROM_1)?;
|
||||||
db.execute_batch(MIGRATE_FROM_2)?;
|
|
||||||
db.pragma_update(None, "user_version", SCHEMA_VERSION)?;
|
|
||||||
} else if version == 2 {
|
|
||||||
db.execute_batch(MIGRATE_FROM_2)?;
|
|
||||||
db.pragma_update(None, "user_version", SCHEMA_VERSION)?;
|
db.pragma_update(None, "user_version", SCHEMA_VERSION)?;
|
||||||
} else if version != SCHEMA_VERSION {
|
} else if version != SCHEMA_VERSION {
|
||||||
return Err(Error::SchemaVersion {
|
return Err(Error::SchemaVersion {
|
||||||
|
|
@ -502,30 +480,6 @@ impl Store {
|
||||||
Ok(identity.and_then(|identity| identity.try_into().ok()))
|
Ok(identity.and_then(|identity| identity.try_into().ok()))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The identity to derive a token for, but only while that token still
|
|
||||||
/// needs delivering. `None` once they have it.
|
|
||||||
pub fn token_to_deliver(&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 AND delivered = 0",
|
|
||||||
&[&address],
|
|
||||||
)?;
|
|
||||||
Ok(identity.and_then(|identity| identity.try_into().ok()))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Records that this correspondent now holds our token, so later messages
|
|
||||||
/// carry no secret. Deliberately not undone by `block`: they keep the
|
|
||||||
/// value either way, and re-accepting restores the same one.
|
|
||||||
pub fn mark_delivered(&self, address: &str) -> Result<(), Error> {
|
|
||||||
self.db()
|
|
||||||
.execute(
|
|
||||||
"UPDATE accepted SET delivered = 1 WHERE address = ?1",
|
|
||||||
params![address],
|
|
||||||
)
|
|
||||||
.map(|_| ())?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A token a correspondent issued us, filed under their address (sec 5.8).
|
/// A token a correspondent issued us, filed under their address (sec 5.8).
|
||||||
pub fn token_of(&self, address: &str) -> Result<Option<[u8; 32]>, Error> {
|
pub fn token_of(&self, address: &str) -> Result<Option<[u8; 32]>, Error> {
|
||||||
let token: Option<Vec<u8>> =
|
let token: Option<Vec<u8>> =
|
||||||
|
|
@ -980,66 +934,6 @@ mod tests {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_token_is_owed_until_delivered_then_never_again() {
|
|
||||||
let store = Store::open_in_memory().unwrap();
|
|
||||||
let id = [7u8; KEY_LEN];
|
|
||||||
store.accept("alice@example.org", &id).unwrap();
|
|
||||||
|
|
||||||
// Owed on acceptance.
|
|
||||||
assert_eq!(store.token_to_deliver("alice@example.org").unwrap(), Some(id));
|
|
||||||
store.mark_delivered("alice@example.org").unwrap();
|
|
||||||
assert_eq!(store.token_to_deliver("alice@example.org").unwrap(), None);
|
|
||||||
|
|
||||||
// Blocking does not take the token back — they hold it either way —
|
|
||||||
// and re-accepting hands back the same value, so nothing is owed.
|
|
||||||
assert!(store.block("alice@example.org").unwrap());
|
|
||||||
assert_eq!(store.token_to_deliver("alice@example.org").unwrap(), None);
|
|
||||||
store.accept("alice@example.org", &id).unwrap();
|
|
||||||
assert_eq!(store.token_to_deliver("alice@example.org").unwrap(), None);
|
|
||||||
// Still accepted, so ordinary sending is unaffected.
|
|
||||||
assert_eq!(store.accepted_identity("alice@example.org").unwrap(), Some(id));
|
|
||||||
|
|
||||||
// A blocked correspondent is owed nothing either, delivered or not.
|
|
||||||
store.accept("bob@example.org", &[9u8; KEY_LEN]).unwrap();
|
|
||||||
store.block("bob@example.org").unwrap();
|
|
||||||
assert_eq!(store.token_to_deliver("bob@example.org").unwrap(), None);
|
|
||||||
|
|
||||||
// And someone never accepted is owed nothing.
|
|
||||||
assert_eq!(store.token_to_deliver("nobody@example.org").unwrap(), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A store written before `delivered` existed must open, and must treat
|
|
||||||
/// its rows as still owing: every message used to carry the token, so one
|
|
||||||
/// more cannot break anything, whereas assuming delivery would strand a
|
|
||||||
/// contact who was accepted but never written to.
|
|
||||||
#[test]
|
|
||||||
fn a_version_2_store_upgrades_and_owes_its_tokens() {
|
|
||||||
let db = rusqlite::Connection::open_in_memory().unwrap();
|
|
||||||
db.execute_batch(
|
|
||||||
"CREATE TABLE IF NOT EXISTS accepted (
|
|
||||||
address TEXT PRIMARY KEY, identity BLOB NOT NULL,
|
|
||||||
active INTEGER NOT NULL, added_at INTEGER NOT NULL);",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
db.execute(
|
|
||||||
"INSERT INTO accepted (address, identity, active, added_at) VALUES (?1, ?2, 1, 0)",
|
|
||||||
rusqlite::params!["old@example.org", &[3u8; KEY_LEN][..]],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
db.pragma_update(None, "user_version", 2).unwrap();
|
|
||||||
db.execute_batch(MIGRATE_FROM_2).unwrap();
|
|
||||||
|
|
||||||
let owed: Option<Vec<u8>> = db
|
|
||||||
.query_row(
|
|
||||||
"SELECT identity FROM accepted WHERE delivered = 0",
|
|
||||||
[],
|
|
||||||
|row| row.get(0),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(owed, Some([3u8; KEY_LEN].to_vec()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn accepted_state_reads_the_row_not_a_wish() {
|
fn accepted_state_reads_the_row_not_a_wish() {
|
||||||
let store = temp_store("accepted-state");
|
let store = temp_store("accepted-state");
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue