fumi/core/src/client.rs
randogoth 16dafcd388 fix: deliver an accept token once, not on every message
send attached the token to every message addressed to an accepted
correspondent, so a secret rode in the body of ordinary mail indefinitely.
sec 5.8 says the owner delivers each token to its correspondent; it does not
say every message carries one.

Harmless between people, and fatal for any recipient that republishes what it
receives: a published record is verified by rebuilding the exact bytes the
author signed, and those cannot include a token without disclosing it. An
author who accepted such a service could never post to it again — and
accepting a correspondent is the behaviour the rest of the system teaches.

Schema 3 adds `delivered`, set only once the recipient's server has taken the
message, so a failed send leaves the token still owing. Not cleared by block:
the token is a pure function of the master and the identity frozen at
acceptance, so they keep the value either way and re-accepting hands back the
same one.

Existing rows migrate as still owing rather than delivered. One more message
carries the token, which is what every message did before, so nothing that
works today can break; assuming delivery would instead strand anyone accepted
but never written to.
2026-10-14 22:32:21 +03:00

1008 lines
35 KiB
Rust

//! 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). 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;
#[cfg(feature = "store")]
use crate::address::{Address, Scheme};
#[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::{
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()
}
pub fn close(&mut self) {
self.transport.close();
}
}
/// The account address with the host's dial hint applied, when the caller
/// resolved DNS itself: account-bound operations re-read the account, so
/// the hint rides along this way rather than in the stored row.
#[cfg(feature = "store")]
fn account_with_dial(store: &Store, dial: Option<&str>) -> Result<Address, Error> {
let addr = store.account()?.ok_or(Error::NotRegistered)?;
Ok(match dial {
Some(dial) => addr.with_dial(dial),
None => addr,
})
}
/// 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,
require_pin: bool,
timeout: u64,
) -> Result<Session, Error> {
match addr.scheme {
Scheme::Tcp => {
let pinned = store.server_pin(&addr.host, addr.port)?;
if pinned.is_none() && require_pin {
return Err(Error::NotPinned {
host: addr.host.clone(),
port: addr.port,
});
}
// The dial hint lets a host that resolves DNS itself (an
// Android app) reach the server by IP while `host` keeps its
// identity roles.
let transport = TcpTransport::connect(
addr.dial.as_deref().unwrap_or(&addr.host),
addr.port,
pinned,
timeout,
)?;
// Pin enforcement lives here, one layer above the transport: the
// identity host names the trust object even when a dial hint
// routed the packets elsewhere (sec 4).
if let Some(pinned) = pinned {
if !ct_eq(&pinned, transport.server_static()) {
return Err(Error::PinMismatch {
host: addr.host.clone(),
port: addr.port,
pinned,
presented: *transport.server_static(),
});
}
}
let unpinned_static = if pinned.is_none() {
Some(*transport.server_static())
} else {
None
};
Ok(Session {
transport: Box::new(transport),
unpinned_static,
})
}
Scheme::Rns => {
// There is nothing to pin on this carrier (upstream spec 13.4):
// the destination hash is the pin, so `require_pin` decides
// nothing here and every session is as authenticated as a
// pinned TCP one.
#[cfg(feature = "rns")]
{
let _ = require_pin;
let destination = addr.destination()?;
let transport = crate::rns::transport::RnsTransport::connect(
&destination,
std::time::Duration::from_secs(timeout),
)?;
Ok(Session {
transport: Box::new(transport),
unpinned_static: None,
})
}
#[cfg(not(feature = "rns"))]
{
let _ = (addr, require_pin);
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<(), Error> {
if text.len() > 255 {
return Err(Error::Other("field longer than one length byte".into()));
}
out.push(text.len() as u8);
out.extend_from_slice(text);
Ok(())
}
/// RESOLVE: the username's current key and its rotation chain (sec 6.1).
pub fn resolve(
transport: &mut dyn Transport,
username: &str,
) -> 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)?;
expect_ok(response.status, &format!("resolving {username}"))?;
let mut r = Reader::new(&response.body);
let identity: [u8; KEY_LEN] = r.take(KEY_LEN)?.try_into().unwrap();
let chain_len = r.u8()? as usize;
let mut chain = Vec::with_capacity(chain_len);
for _ in 0..chain_len {
chain.push(r.take(CERT_LEN)?.try_into().unwrap());
}
r.done()?;
Ok((identity, chain))
}
/// Accepts a key change only when a signed chain leads from the key we hold
/// to the one the server now returns (sec 7). Both keys must sign each link;
/// a link predating the key we hold is skipped, a gap is not.
pub fn walk_chain(
username: &str,
pinned: &[u8; KEY_LEN],
current: &[u8; KEY_LEN],
chain: &[[u8; CERT_LEN]],
) -> bool {
if ct_eq(pinned, current) {
return true;
}
if chain.is_empty() || chain.len() > MAX_CHAIN {
return false;
}
let mut key: Vec<u8> = pinned.to_vec();
let mut started = false;
for cert_bytes in chain {
let cert = match RotationCert::from_bytes(cert_bytes) {
Ok(cert) => cert,
Err(_) => return false,
};
if !started {
if key != cert.old_pub {
continue; // a link predating the key we hold
}
started = true;
} else if key != cert.old_pub {
return false; // the chain is not continuous
}
if !cert.verify(username) {
return false;
}
key = cert.new_pub.to_vec();
}
started && ct_eq(&key, current)
}
/// How `trust_key` left the contact.
#[derive(PartialEq, Eq, Debug)]
pub enum TrustChange {
/// Trust on first use; `unverified` marks a session with no pin (sec 8).
New { unverified: bool },
/// A signed chain confirmed a key change (sec 7).
Rotated,
/// The key matches what we already hold.
None,
}
/// 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,
addr: &Address,
) -> Result<([u8; KEY_LEN], TrustChange), Error> {
let (identity, chain) = resolve(transport, &addr.user)?;
let known = store.contact(&addr.short())?;
match known {
None => {
store.save_contact(&addr.short(), &identity, false)?;
Ok((
identity,
TrustChange::New {
unverified: !transport.pinned(),
},
))
}
Some((old, _)) if ct_eq(&old, &identity) => Ok((identity, TrustChange::None)),
Some((old, verified)) => {
if walk_chain(&addr.user, &old, &identity, &chain) {
// The displaced key is the only local record that this
// contact rotated, so it is kept (sec 8) before the current
// one moves on. `until` is milliseconds, the unit the export
// format carries.
store.save_history(&addr.short(), &old, crate::store::now() * 1000)?;
store.save_contact(&addr.short(), &identity, verified)?;
Ok((identity, TrustChange::Rotated))
} else {
Err(Error::KeyChanged {
address: addr.short(),
known: old,
offered: identity,
})
}
}
}
}
/// AUTH (sec 4): sign the handshake hash and push the accept-token set. The
/// 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,
account: &Account,
store: &Store,
) -> Result<u16, Error> {
let (sync, tokens) = store.token_set(account)?;
let mut body = Vec::new();
string_field(&mut body, username.as_bytes())?;
body.extend_from_slice(&account.me().pk());
body.extend_from_slice(
&account
.me()
.sign(&[LABEL_AUTH, &transport.bind().h[..]].concat()),
);
body.push(sync);
body.extend_from_slice(&(tokens.len() as u16).to_be_bytes());
for token in &tokens {
body.extend_from_slice(token);
}
let response = transport.request(OP_AUTH, &body)?;
expect_ok(response.status, "authentication")?;
let held = if response.body.len() >= 2 {
u16::from_be_bytes(response.body[..2].try_into().unwrap())
} else {
0
};
Ok(held)
}
/// 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,
/// The server now holds this many tokens.
Held(u16),
}
/// 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,
dial: Option<&str>,
timeout: u64,
) -> Result<Pushed, Error> {
let addr = match account_with_dial(store, dial) {
Ok(addr) => addr,
Err(Error::NotRegistered) => return Ok(Pushed::NotRegistered),
Err(e) => return Err(e),
};
let mut session = connect(store, &addr, true, timeout)?;
let held = authenticate(session.transport(), &addr.user, account, store)?;
session.close();
Ok(Pushed::Held(held))
}
/// 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,
account: &Account,
invite: Option<&str>,
timeout: u64,
) -> Result<(), Error> {
let mut session = connect(store, addr, true, timeout)?;
let transport = session.transport();
let me = account.me();
let mut body = Vec::new();
string_field(&mut body, addr.user.as_bytes())?;
body.extend_from_slice(&me.pk());
body.extend_from_slice(
&me.sign(
&[
LABEL_REGISTER,
&transport.bind().server_static[..],
addr.user.as_bytes(),
&me.pk()[..],
]
.concat(),
),
);
let token = invite.unwrap_or("").as_bytes();
string_field(&mut body, token)?;
body.push(0); // no rotation certificate on a plain registration
let response = transport.request(OP_REGISTER, &body)?;
expect_ok(response.status, &format!("registering {}", addr.short()))?;
session.close();
store.set_account(addr)?;
Ok(())
}
/// 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],
}
/// 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,
dial: Option<&str>,
timeout: u64,
) -> Result<Rotated, Error> {
let addr = account_with_dial(store, dial)?;
if account.index() as usize >= MAX_CHAIN {
return Err(Error::ChainLimit {
index: account.index() + 1,
});
}
let old = account.me();
let new = Identity::from_seed(crate::account::identity_seed(
account.master(),
account.index() + 1,
));
let cert = RotationCert::build(old, &new, &addr.user, now());
let mut session = connect(store, &addr, true, timeout)?;
let transport = session.transport();
let mut body = Vec::new();
string_field(&mut body, addr.user.as_bytes())?;
body.extend_from_slice(&new.pk());
body.extend_from_slice(
&new.sign(
&[
LABEL_REGISTER,
&transport.bind().server_static[..],
addr.user.as_bytes(),
&new.pk()[..],
]
.concat(),
),
);
body.push(0); // no invite token on a rotation
body.push(CERT_LEN as u8);
body.extend_from_slice(&cert);
let response = transport.request(OP_REGISTER, &body)?;
expect_ok(response.status, "rotating")?;
session.close();
store.set_rotations(account.index() + 1)?;
Ok(Rotated {
new_pk: new.pk(),
cert,
})
}
/// 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).
/// 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)?;
session.close();
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()
)));
};
store.set_account(addr)?;
store.set_cursor(0, &[0u8; ID_LEN])?;
store.set_rotations(index)?;
store.set_sync_ok(false)?;
Ok(index)
}
/// One message to send, as the CLI assembled it.
#[cfg(feature = "store")]
pub struct SendDraft<'a> {
pub address: &'a Address,
pub text: String,
pub subject: Option<&'a str>,
/// An id this message replies to, as 64 hex characters.
pub reply_to: Option<&'a str>,
/// Extra `Key: value` frontmatter fields.
pub headers: &'a [(&'a str, &'a str)],
/// Omit the Reply-To field carrying this address (sec 5.7).
pub anonymous: bool,
pub no_pad: bool,
}
/// What `send` did, for the caller to report.
#[cfg(feature = "store")]
pub struct Sent {
pub id: [u8; ID_LEN],
pub bytes: usize,
/// An accept token for their mailbox was attached (sec 5.8).
pub token_used: bool,
/// How the recipient key was obtained, if it was learned now.
pub change: TrustChange,
/// A non-fatal oddity worth showing the user.
pub warning: Option<String>,
}
/// 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,
draft: &SendDraft<'_>,
timeout: u64,
) -> Result<Sent, Error> {
let addr = draft.address;
let me = account.me();
let (recipient, change) = if let Some(key) = addr.identity {
store.save_contact(&addr.short(), &key, true)?;
(key, TrustChange::None)
} else if let Some((known, _)) = store.contact(&addr.short())? {
(known, TrustChange::None)
} else {
let mut session = connect(store, addr, false, timeout)?;
let (key, change) = trust_key(store, session.transport(), addr)?;
session.close();
(key, change)
};
// Frontmatter, in the order the reference client emits it.
let mut fields: Vec<(String, String)> = Vec::new();
if let Some(subject) = draft.subject {
fields.push(("Subject".into(), subject.to_string()));
}
if let Some(reply_to) = draft.reply_to {
fields.push(("In-Reply-To".into(), reply_to.to_string()));
}
for (key, value) in draft.headers {
fields.push(((*key).to_string(), (*value).to_string()));
}
// sec 5.7: a signed reply address lets a first-time recipient answer us.
if let Some(home) = store.account()? {
if !draft.anonymous {
fields.push(("Reply-To".into(), home.uri(&me.pk())));
}
}
// 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
// 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))));
}
let borrowed: Vec<(&str, &str)> = fields
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
let body = build_frontmatter(&borrowed, &draft.text).into_bytes();
let envelope = seal(me, &recipient, &body, now(), !draft.no_pad)?;
let mid = message_id(&envelope);
// sec 5.6: the sent copy is sealed to ourselves, not to the recipient —
// the recipient's envelope uses an ephemeral key that is gone, so the
// only envelope its sender can ever open again is one sealed to their
// own long-term key. The stored id stays the delivered message's.
let self_copy = seal(me, &me.pk(), &body, now(), !draft.no_pad)?;
// sec 5.8: our token for their mailbox, if they have given us one.
let mac = match store.token_of(&addr.short())? {
Some(token) => hmac_sha256(&token, &[LABEL_MAC, &mid[..]].concat()).to_vec(),
None => Vec::new(),
};
let mut session = connect(store, addr, false, timeout)?;
let mut wire = vec![mac.len() as u8];
wire.extend_from_slice(&mac);
wire.extend_from_slice(&envelope);
let response = session.transport().request(OP_SEND, &wire)?;
expect_ok(response.status, &format!("sending to {}", addr.short()))?;
session.close();
// The server echoes the id; it is derived, so ours is authoritative.
let warning = if !response.body.is_empty() && response.body != mid.to_vec() {
Some("server returned an id we did not derive; it is not authoritative".to_string())
} else {
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.
store.store_sent(&mid, &addr.short(), &self_copy, now())?;
Ok(Sent {
id: mid,
bytes: envelope.len(),
token_used: !mac.is_empty(),
change,
warning,
})
}
/// 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,
pub rejected: Vec<Rejected>,
/// 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 envelope the summary counted is accounted for locally, 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.
#[cfg(feature = "store")]
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,
/// Dial this address instead of the account host, for hosts that
/// resolve DNS themselves; the host keeps its identity roles.
pub dial: Option<&'a str>,
/// Checked between pages and before each envelope; when raised,
/// `fetch_with` stops and returns the partial summary with `cancelled`
/// set. What was processed is accounted for locally — cursor moved or
/// messages deleted — so fetching again continues rather than repeats.
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 {
FetchOptions {
keep,
reset,
timeout,
dial: None,
cancel: None,
}
}
}
/// Retrieves, verifies and stores mail (sec 6.1), paging forward by
/// `(received_at, id)` until a response is empty. Acknowledging deletes what
/// 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,
keep: bool,
reset: bool,
timeout: u64,
) -> Result<Fetched, Error> {
fetch_with(store, account, FetchOptions::new(keep, reset, timeout))
}
/// `fetch` with the full option set, for hosts that need cancellation.
#[cfg(feature = "store")]
pub fn fetch_with(
store: &Store,
account: &Account,
opts: FetchOptions<'_>,
) -> Result<Fetched, Error> {
let addr = account_with_dial(store, opts.dial)?;
if opts.reset {
store.set_cursor(0, &[0u8; ID_LEN])?;
}
let (mut after_time, mut after_id) = if opts.keep {
store.cursor()?
} else {
(0, [0u8; ID_LEN])
};
let mut summary = Fetched {
total: 0,
stored: 0,
rejected: Vec::new(),
acknowledged: !opts.keep,
cancelled: false,
};
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);
let response = transport.request(OP_FETCH, &body)?;
expect_ok(response.status, "fetching")?;
let mut r = Reader::new(&response.body);
let count = r.u16()? as usize;
if count == 0 {
break;
}
let mut acked: Vec<[u8; ID_LEN]> = Vec::new();
let mut page_cancelled = false;
for _ in 0..count {
// Checked before each envelope as well as between pages, so a
// cancel lands within one envelope; the cursor and acks below
// then cover exactly the envelopes that were processed.
if cancelled() {
page_cancelled = true;
summary.cancelled = true;
break;
}
let mid: [u8; ID_LEN] = r.take(ID_LEN)?.try_into().unwrap();
let received_at = r.i64()?;
let flags = r.u8()?;
let envelope_len = r.u32()? as usize;
let envelope = r.take(envelope_len)?.to_vec();
after_time = received_at;
after_id = mid;
summary.total += 1;
let opened = match verify_and_open(account, &mid, &envelope) {
Ok(opened) => opened,
Err(reason) => {
summary.rejected.push(Rejected {
id: mid,
reason: reason.to_string(),
});
continue;
}
};
// A message we have already had once is not stored again, even
// if we deleted it locally in the meantime (sec 10).
if !store.seen(&mid)? {
let requests_tier = flags & FLAG_REQUESTS != 0;
store.store_inbox(&mid, &envelope, received_at, requests_tier, opts.keep)?;
learn_token(store, &opened)?;
summary.stored += 1;
}
acked.push(mid);
}
// A cancelled page is not fully parsed, so trailing bytes are
// expected and not an error; everything processed is accounted for.
if !page_cancelled {
r.done()?;
}
if opts.keep {
store.set_cursor(after_time, &after_id)?;
} else if !acked.is_empty() {
delete_ids(transport, &acked)?;
}
if page_cancelled {
break;
}
}
session.close();
if !opts.keep {
store.set_cursor(0, &[0u8; ID_LEN])?;
}
Ok(summary)
}
#[cfg(feature = "store")]
fn verify_and_open(
account: &Account,
mid: &[u8; ID_LEN],
envelope: &[u8],
) -> Result<Opened, Error> {
if message_id(envelope) != *mid {
return Err(Error::Other("id does not match the envelope".into()));
}
unseal(account.keys(), envelope, now())
}
/// 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);
let Some(token) = accept_field(&fields) else {
return Ok(());
};
let reply_to = fields.get("reply-to").map(String::as_str);
match store.address_of(&opened.sender, reply_to)? {
// No address to send to, so no use for a token.
None => Ok(()),
Some(address) => store.save_token(&address, &token),
}
}
#[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());
for id in ids {
body.extend_from_slice(id);
}
let response = transport.request(OP_DELETE, &body)?;
expect_ok(response.status, "acknowledging")?;
let removed = if response.body.len() >= 2 {
u16::from_be_bytes(response.body[..2].try_into().unwrap())
} else {
0
};
Ok(removed)
}
/// 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,
ids: &[[u8; ID_LEN]],
dial: Option<&str>,
timeout: u64,
) -> Result<u16, Error> {
let addr = account_with_dial(store, dial)?;
let mut session = connect(store, &addr, true, timeout)?;
let transport = session.transport();
authenticate(transport, &addr.user, account, store)?;
let removed = delete_ids(transport, ids)?;
session.close();
Ok(removed)
}
/// An opened, described message for listing and reading.
#[cfg(feature = "store")]
pub struct Described {
pub sender: [u8; KEY_LEN],
pub time: i64,
pub fields: BTreeMap<String, String>,
pub text: String,
/// The contact address we know the signer by, or a fingerprint.
pub from: String,
pub subject: String,
}
#[cfg(feature = "store")]
impl Described {
/// Whether this payload carried its signer's accept token (sec 5.8):
/// machinery, not content.
pub fn carries_token(&self) -> bool {
self.fields.contains_key("accept")
}
}
/// 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();
let (fields, body_text) = parse_frontmatter(&text);
let from = store
.address_of(&opened.sender, fields.get("reply-to").map(String::as_str))?
.unwrap_or_else(|| format!("<{}…>", &b32(&opened.sender)[..20]));
Ok(Described {
sender: opened.sender,
time: opened.time,
subject: fields.get("subject").cloned().unwrap_or_default(),
fields,
text: body_text,
from,
})
}
/// The accept MAC a sender attaches to SEND (sec 5.8), exposed for tests.
pub fn accept_mac(token: &[u8; TOKEN_LEN], mid: &[u8; ID_LEN]) -> [u8; 32] {
hmac_sha256(token, &[LABEL_MAC, mid].concat())
}
/// Proof of possession over a server's static key (sec 6.1), for tests.
#[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()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::account::{identity_seed, verify_sig, Account, Identity, RotationCert};
use crate::transport::{TransportBindValues, SIG_LEN};
fn master() -> [u8; 32] {
core::array::from_fn(|i| i as u8)
}
fn identity(n: u32) -> Identity {
Identity::from_seed(identity_seed(&master(), n))
}
#[test]
fn walk_chain_accepts_signed_progressions() {
let old = identity(0);
let new = identity(1);
let cert = RotationCert::build(&old, &new, "alice", 1700000001);
assert!(walk_chain("alice", &old.pk(), &old.pk(), &[]));
assert!(walk_chain("alice", &old.pk(), &new.pk(), &[cert]));
// The username is covered, so the same cert proves nothing for bob.
assert!(!walk_chain("bob", &old.pk(), &new.pk(), &[cert]));
// No chain, no acceptance.
assert!(!walk_chain("alice", &old.pk(), &new.pk(), &[]));
}
#[test]
fn walk_chain_requires_continuity_and_terminates_at_current() {
let keys: Vec<Identity> = (0..4).map(identity).collect();
let cert01 = RotationCert::build(&keys[0], &keys[1], "alice", 1);
let cert12 = RotationCert::build(&keys[1], &keys[2], "alice", 2);
let cert23 = RotationCert::build(&keys[2], &keys[3], "alice", 3);
// A full chain from 0 to 3.
assert!(walk_chain(
"alice",
&keys[0].pk(),
&keys[3].pk(),
&[cert01, cert12, cert23]
));
// Holding key 1: the earlier link is skipped, the rest must continue.
assert!(walk_chain(
"alice",
&keys[1].pk(),
&keys[3].pk(),
&[cert01, cert12, cert23]
));
// A gap between the held key and the chain never validates.
assert!(!walk_chain(
"alice",
&keys[0].pk(),
&keys[3].pk(),
&[cert12, cert23]
));
// The chain must end at the key RESOLVE returned, not merely agree
// partway.
assert!(!walk_chain(
"alice",
&keys[0].pk(),
&keys[2].pk(),
&[cert01, cert12, cert23]
));
}
#[test]
fn walk_chain_enforces_the_length_limit() {
let keys: Vec<Identity> = (0..=16).map(identity).collect();
let certs: Vec<[u8; CERT_LEN]> = (0..16)
.map(|n| RotationCert::build(&keys[n], &keys[n + 1], "alice", n as i64))
.collect();
// Sixteen links walk the whole chain from the first key.
assert!(walk_chain("alice", &keys[0].pk(), &keys[16].pk(), &certs));
// Seventeen links is over-long and needs user confirmation, whatever
// key we hold (sec 7).
let mut over = certs.clone();
let extra = identity(17);
over.push(RotationCert::build(&keys[16], &extra, "alice", 17));
assert!(!walk_chain("alice", &keys[0].pk(), &extra.pk(), &over));
assert!(!walk_chain("alice", &keys[1].pk(), &extra.pk(), &over));
}
#[test]
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(&v.accept_mac.message_id_b32)
.unwrap()
.try_into()
.unwrap();
assert_eq!(
data_encoding::HEXLOWER.encode(&accept_mac(&token, &mid)),
v.accept_mac.mac_hex
);
}
#[cfg(feature = "store")]
#[test]
fn bind_values_feed_the_auth_and_register_signatures() {
let bind = TransportBindValues::tcp(&[2u8; 32], &[7u8; 32]).unwrap();
let account = Account::new(master(), 0).unwrap();
let me = account.me();
let auth = me.sign(&[LABEL_AUTH, &bind.h[..]].concat());
let register = me.sign(&register_signed(&bind.server_static, "alice", &me.pk()));
assert_eq!(auth.len(), SIG_LEN);
assert!(verify_sig(
&me.pk(),
&auth,
&[LABEL_AUTH, &bind.h[..]].concat()
));
assert!(verify_sig(
&me.pk(),
&register,
&register_signed(&bind.server_static, "alice", &me.pk())
));
}
}