refactor: split the library into fumi-core and make it embeddable

This commit is contained in:
randogoth 2026-09-28 23:21:08 +03:00
parent d3cd0afb4f
commit 8fb5661b53
28 changed files with 975 additions and 724 deletions

286
core/src/account.rs Normal file
View file

@ -0,0 +1,286 @@
//! The master secret of SPEC.md sec 2 and everything derived from it: rotation
//! indices, accept tokens and rotation certificates.
use crate::crypto::{hkdf_sha256, hmac_sha256, KEY_LEN};
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;
/// An Ed25519 identity keypair plus the X25519 keypair derived from it (sec 2).
#[derive(Clone)]
pub struct Identity {
signing: SigningKey,
x: StaticSecret,
}
impl Identity {
pub fn from_seed(seed: [u8; KEY_LEN]) -> Identity {
let signing = SigningKey::from_bytes(&seed);
// SHA-512(seed)[0..32], clamped as libsodium's sk_to_curve25519
// does (SPEC.md sec 2).
let x = StaticSecret::from(crate::crypto::clamp_scalar(signing.to_scalar_bytes()));
Identity { signing, x }
}
pub fn pk(&self) -> [u8; KEY_LEN] {
*self.signing.verifying_key().as_bytes()
}
pub fn sign(&self, message: &[u8]) -> [u8; 64] {
self.signing.sign(message).to_bytes()
}
/// The X25519 half used for sealing agreement, never for signatures.
pub fn x_priv(&self) -> &StaticSecret {
&self.x
}
}
/// The Ed25519 seed at a rotation index (sec 2). HKDF is one-way, so a
/// compromised seed exposes neither the master nor any other index.
pub fn identity_seed(master: &[u8; KEY_LEN], index: u32) -> [u8; KEY_LEN] {
let info = [LABEL_IDENTITY, &index.to_be_bytes()[..]].concat();
hkdf_sha256(master, b"", &info, KEY_LEN).try_into().unwrap()
}
/// The accept key (sec 2), independent of the rotation index so accept tokens
/// survive every rotation.
pub fn accept_key(master: &[u8; KEY_LEN]) -> [u8; KEY_LEN] {
hkdf_sha256(master, b"", LABEL_ACCEPT, KEY_LEN)
.try_into()
.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).
pub struct Account {
master: [u8; KEY_LEN],
index: u32,
keys: Vec<Identity>,
}
impl Account {
pub fn new(master: [u8; KEY_LEN], index: u32) -> Result<Account, Error> {
if index as usize > MAX_CHAIN {
return Err(Error::ChainLimit { index });
}
let keys = (0..=index)
.map(|n| Identity::from_seed(identity_seed(&master, n)))
.collect();
Ok(Account {
master,
index,
keys,
})
}
pub fn index(&self) -> u32 {
self.index
}
pub fn master(&self) -> &[u8; KEY_LEN] {
&self.master
}
/// The identity at the current rotation index.
pub fn me(&self) -> &Identity {
self.keys.last().expect("at least one key")
}
/// Every key this account has held, current first is not guaranteed; the
/// index order is 0..=index.
pub fn keys(&self) -> &[Identity] {
&self.keys
}
/// The accept token this account issues to one correspondent (sec 5.8).
pub fn token_for(&self, identity: &[u8; KEY_LEN]) -> [u8; 32] {
hmac_sha256(&accept_key(&self.master), identity)
}
}
/// A double-signed rotation certificate (sec 7): fixed-size at 200 bytes, with
/// the username covered but not carried, so it cannot be replayed against a
/// different username bound to the same key.
pub struct RotationCert {
pub old_pub: [u8; KEY_LEN],
pub new_pub: [u8; KEY_LEN],
pub when: [u8; 8],
sig_old: [u8; 64],
sig_new: [u8; 64],
}
impl RotationCert {
pub fn from_bytes(bytes: &[u8]) -> Result<RotationCert, Error> {
if bytes.len() != CERT_LEN {
return Err(Error::Other(format!(
"rotation certificate is {} bytes, expected {CERT_LEN}",
bytes.len()
)));
}
Ok(RotationCert {
old_pub: bytes[..32].try_into().unwrap(),
new_pub: bytes[32..64].try_into().unwrap(),
when: bytes[64..72].try_into().unwrap(),
sig_old: bytes[72..136].try_into().unwrap(),
sig_new: bytes[136..200].try_into().unwrap(),
})
}
fn signed_message(&self, username: &str) -> Vec<u8> {
rotate_message(username, &self.old_pub, &self.new_pub, &self.when)
}
/// Both signatures must verify: the old key alone could otherwise hand a
/// username to a key nobody controls (sec 7).
pub fn verify(&self, username: &str) -> bool {
let message = self.signed_message(username);
verify_sig(&self.old_pub, &self.sig_old, &message)
&& verify_sig(&self.new_pub, &self.sig_new, &message)
}
/// Builds the certificate for a rotation from `old` to `new`.
pub fn build(old: &Identity, new: &Identity, username: &str, when: i64) -> [u8; CERT_LEN] {
let old_pub = old.pk();
let new_pub = new.pk();
let message = rotate_message(username, &old_pub, &new_pub, &when.to_be_bytes());
let mut cert = [0u8; CERT_LEN];
cert[..32].copy_from_slice(&old_pub);
cert[32..64].copy_from_slice(&new_pub);
cert[64..72].copy_from_slice(&when.to_be_bytes());
cert[72..136].copy_from_slice(&old.sign(&message));
cert[136..200].copy_from_slice(&new.sign(&message));
cert
}
}
/// The double-signed rotation message (sec 7): the username is covered but
/// not carried, keeping the certificate fixed-size and non-portable.
fn rotate_message(username: &str, old_pub: &[u8], new_pub: &[u8], when: &[u8; 8]) -> Vec<u8> {
let mut message = Vec::with_capacity(LABEL_ROTATE.len() + username.len() + 72);
message.extend_from_slice(LABEL_ROTATE);
message.extend_from_slice(username.as_bytes());
message.extend_from_slice(old_pub);
message.extend_from_slice(new_pub);
message.extend_from_slice(when);
message
}
/// Strict Ed25519 verification; malformed keys and signatures are simply not
/// valid.
pub fn verify_sig(identity: &[u8], signature: &[u8], message: &[u8]) -> bool {
let (Ok(identity), Ok(signature)) = (
<[u8; KEY_LEN]>::try_from(identity),
<[u8; 64]>::try_from(signature),
) else {
return false;
};
let Ok(verifying_key) = VerifyingKey::from_bytes(&identity) else {
return false;
};
verifying_key
.verify_strict(message, &Signature::from_bytes(&signature))
.is_ok()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::crypto::{b32, unb32};
use crate::vectors;
fn master() -> [u8; 32] {
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), v.seed_b32(n));
let identity = Identity::from_seed(seed);
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()), v.xpriv_b32(n));
}
}
#[test]
fn accept_key_and_token_match_the_reference_client() {
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(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));
}
#[test]
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()), v.pk_b32(n as u32));
}
assert_eq!(account.index(), 2);
}
#[test]
fn account_rejects_an_over_long_chain() {
assert!(Account::new(master(), 16).is_ok());
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 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(&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(&rc.username));
assert!(RotationCert::from_bytes(&cert[..199]).is_err());
}
#[test]
fn verify_sig_rejects_garbage() {
assert!(!verify_sig(&[0u8; 32], &[0u8; 64], b"msg"));
assert!(!verify_sig(&[0u8; 5], &[0u8; 64], b"msg"));
}
}

276
core/src/address.rs Normal file
View file

@ -0,0 +1,276 @@
//! The four address forms (SPEC.md sec 3, RNS.md sec 13.2) and the username
//! grammar they share.
use crate::crypto::{b32, unb32, KEY_LEN};
use crate::error::Error;
pub const DEFAULT_PORT: u16 = 1961;
/// A Reticulum destination hash, as printed by every RNS tool: 32 lowercase
/// hexadecimal characters (RNS.md sec 13.2).
pub const RNS_HASH_HEX: usize = 32;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Scheme {
Tcp,
Rns,
}
impl Scheme {
pub fn prefix(&self) -> &'static str {
match self {
Scheme::Tcp => "smol://",
Scheme::Rns => "smol+rns://",
}
}
}
/// `user@host[:port]` over TCP, or `user@<destination-hash>` over Reticulum,
/// either optionally self-certifying with a 52-character base32 identity key.
#[derive(Clone, Debug)]
pub struct Address {
pub user: String,
/// TCP hostname, or the destination hash as 32 lowercase hex characters.
pub host: String,
/// TCP only; there is no port on the RNS carrier, which stores 0 here.
pub port: u16,
pub scheme: Scheme,
pub identity: Option<[u8; KEY_LEN]>,
}
impl Address {
/// The form commands and the contact store are keyed by.
pub fn short(&self) -> String {
let port = match (self.scheme, self.port) {
(Scheme::Tcp, DEFAULT_PORT) | (Scheme::Rns, 0) => String::new(),
(Scheme::Tcp, p) => format!(":{p}"),
(Scheme::Rns, _) => unreachable!("no port is stored on the rns scheme"),
};
format!("{}@{}{}", self.user, self.host, port)
}
/// The self-certifying form used for QR codes, contact files and links.
pub fn uri(&self, identity: &[u8; KEY_LEN]) -> String {
format!("{}{}/{}", self.scheme.prefix(), self.short(), b32(identity))
}
/// The Reticulum destination hash, for the `rns` carrier only.
pub fn destination(&self) -> Result<[u8; 16], Error> {
if self.scheme != Scheme::Rns {
return Err(Error::Other("not an smol+rns:// address".into()));
}
data_encoding::HEXLOWER
.decode(self.host.as_bytes())
.map_err(|_| Error::Other("destination hash is not hexadecimal".into()))
.and_then(|bytes| {
bytes
.try_into()
.map_err(|_| Error::Other("destination hash is not 16 bytes".into()))
})
}
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)
} else if let Some(rest) = text.strip_prefix("smol://") {
(Scheme::Tcp, rest)
} else {
(Scheme::Tcp, text)
};
// The path component is the 52-character base32 identity key. On TCP
// it is required; the RNS form also has a bare short form (RNS.md sec
// 13.2).
let (rest, identity) = match rest.rsplit_once('/') {
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(Error::Other(format!(
"{text}: smol:// address carries no key"
)))
}
None => (rest, None),
};
let (user, host_part) = rest
.split_once('@')
.ok_or_else(|| Error::Other(format!("{text:?} is not a valid address")))?;
if user.is_empty() || host_part.is_empty() {
return Err(Error::Other(format!("{text:?} is not a valid address")));
}
valid_username(user)?;
Ok(match scheme {
Scheme::Tcp => {
let (host, port) = split_host_port(text, host_part)?;
Address {
user: user.to_string(),
host,
port,
scheme,
identity,
}
}
Scheme::Rns => {
// 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(Error::Other(format!(
"{text}: the :port suffix must not appear on smol+rns://"
)));
}
if !is_destination_hash(host_part) {
return Err(Error::Other(format!(
"{text}: host must be exactly {RNS_HASH_HEX} lowercase hexadecimal \
characters, a Reticulum destination hash"
)));
}
Address {
user: user.to_string(),
host: host_part.to_string(),
port: 0,
scheme,
identity,
}
}
})
}
}
fn decode_key(text: &str, key: &str) -> Result<[u8; KEY_LEN], Error> {
let identity =
unb32(key).map_err(|e| Error::Other(format!("{text}: undecodable key: {e}")))?;
identity.try_into().map_err(|v: Vec<u8>| {
Error::Other(format!(
"{text}: key is {} bytes, expected {KEY_LEN}",
v.len()
))
})
}
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(|_| Error::Other(format!("{text}: invalid port")));
}
}
if host_part.contains(':') || host_part.contains('/') {
return Err(Error::Other(format!("{text:?} is not a valid address")));
}
Ok((host_part.to_string(), DEFAULT_PORT))
}
fn is_destination_hash(host: &str) -> bool {
host.len() == RNS_HASH_HEX
&& host
.bytes()
.all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c))
}
/// 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<(), Error> {
const SEPARATORS: &[u8] = b"._-";
let bytes = name.as_bytes();
let ok = !bytes.is_empty()
&& bytes.len() <= 63
&& bytes
.iter()
.all(|&c| c.is_ascii_digit() || c.is_ascii_lowercase() || SEPARATORS.contains(&c))
&& !SEPARATORS.contains(&bytes[0])
&& !SEPARATORS.contains(&bytes[bytes.len() - 1])
&& !bytes
.windows(2)
.any(|pair| SEPARATORS.contains(&pair[0]) && SEPARATORS.contains(&pair[1]));
if ok {
Ok(())
} else {
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"
)))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::crypto::b32;
#[test]
fn short_form_and_default_port() {
let a = Address::parse("alice@example.org").unwrap();
assert_eq!(a.user, "alice");
assert_eq!(a.host, "example.org");
assert_eq!(a.port, DEFAULT_PORT);
assert_eq!(a.short(), "alice@example.org");
assert!(a.identity.is_none());
let a = Address::parse("alice@example.org:1962").unwrap();
assert_eq!(a.port, 1962);
assert_eq!(a.short(), "alice@example.org:1962");
}
#[test]
fn self_certifying_form_carries_its_key() {
let key = [9u8; 32];
let a = Address::parse(&format!("smol://alice@example.org/{}", b32(&key))).unwrap();
assert_eq!(a.identity, Some(key));
assert_eq!(a.short(), "alice@example.org");
assert_eq!(
a.uri(&key),
format!("smol://alice@example.org/{}", b32(&key))
);
}
#[test]
fn rns_forms_and_port_rejection() {
let key = [9u8; 32];
let hash = "8f2c1d0a7b6e5f4c3d2a1b0e9f8c7d6a";
let a = Address::parse(&format!("smol+rns://alice@{hash}")).unwrap();
assert_eq!(a.scheme, Scheme::Rns);
assert_eq!(a.destination().unwrap()[..4], [0x8f, 0x2c, 0x1d, 0x0a]);
assert_eq!(a.short(), format!("alice@{hash}"));
let b = Address::parse(&format!("smol+rns://alice@{hash}/{}", b32(&key))).unwrap();
assert_eq!(b.identity, Some(key));
assert_eq!(
b.uri(&key),
format!("smol+rns://alice@{hash}/{}", b32(&key))
);
// No port on this transport (RNS.md sec 13.2).
assert!(Address::parse(&format!("smol+rns://alice@{hash}:1961")).is_err());
// The authority must be exactly 32 lowercase hex characters.
assert!(Address::parse("smol+rns://alice@deadbeef").is_err());
assert!(Address::parse(&format!("smol+rns://alice@{}", hash.to_uppercase())).is_err());
}
#[test]
fn username_grammar() {
assert!(Address::parse("a.b_c-9@example.org").is_ok());
assert!(Address::parse("Alice@example.org").is_err());
assert!(Address::parse(".alice@example.org").is_err());
assert!(Address::parse("alice.@example.org").is_err());
assert!(Address::parse("al..ice@example.org").is_err());
assert!(Address::parse("a-_b@example.org").is_err());
assert!(Address::parse(&format!("{}@example.org", "a".repeat(64))).is_err());
assert!(Address::parse(&format!("{}@example.org", "a".repeat(63))).is_ok());
assert!(Address::parse("al ice@example.org").is_err());
assert!(Address::parse("@example.org").is_err());
assert!(Address::parse("alice@").is_err());
}
#[test]
fn bad_keys_are_rejected() {
assert!(Address::parse("smol://alice@example.org/notbase32!!").is_err());
assert!(Address::parse("smol://alice@example.org/nbswy3dp").is_err());
assert!(Address::parse("smol://example.org").is_err());
// A bare short form has no key and needs none.
assert!(Address::parse("alice@example.org").is_ok());
}
}

872
core/src/client.rs Normal file
View file

@ -0,0 +1,872 @@
//! 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).
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, Error};
use crate::message::{
accept_field, build_frontmatter, message_id, parse_frontmatter, seal, unseal, Opened,
};
use crate::store::{now, Store, Stored};
use crate::tcp::TcpTransport;
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,
};
/// 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.
pub struct Session {
transport: Box<dyn Transport>,
pub unpinned_static: Option<[u8; KEY_LEN]>,
}
impl Session {
pub fn transport(&mut self) -> &mut dyn Transport {
self.transport.as_mut()
}
pub fn close(&mut self) {
self.transport.close();
}
}
/// 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).
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)?;
if pinned.is_none() && require_pin {
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() {
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.
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) {
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.
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).
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.
pub fn push_tokens(store: &Store, account: &Account, timeout: u64) -> Result<Pushed, Error> {
let Some(addr) = store.account()? else {
return Ok(Pushed::NotRegistered);
};
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.
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).
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).
pub fn rotate(store: &Store, account: &Account, timeout: u64) -> Result<Rotated, Error> {
let Some(addr) = store.account()? else {
return Err(Error::NotRegistered);
};
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.
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.
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.
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.
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.
if let Some(identity) = store.accepted_identity(&addr.short())? {
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.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
};
// sec 5.6: the ephemeral is gone, so keep a copy sealed to ourselves.
store.store_sent(&mid, &addr.short(), &envelope, 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.
pub struct Rejected {
pub id: [u8; ID_LEN],
pub reason: String,
}
/// What `fetch` did, for the caller to report.
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 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
/// `(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.
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.
pub fn fetch_with(
store: &Store,
account: &Account,
opts: FetchOptions<'_>,
) -> Result<Fetched, Error> {
let Some(addr) = store.account()? else {
return Err(Error::NotRegistered);
};
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();
for _ in 0..count {
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)?;
learn_token(store, &opened)?;
summary.stored += 1;
}
acked.push(mid);
}
r.done()?;
if opts.keep {
store.set_cursor(after_time, &after_id)?;
} else if !acked.is_empty() {
delete_ids(transport, &acked)?;
}
}
session.close();
if !opts.keep {
store.set_cursor(0, &[0u8; ID_LEN])?;
}
Ok(summary)
}
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.
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),
}
}
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.
pub fn delete(
store: &Store,
account: &Account,
ids: &[[u8; ID_LEN]],
timeout: u64,
) -> Result<u16, Error> {
let Some(addr) = store.account()? else {
return Err(Error::NotRegistered);
};
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.
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,
}
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.
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(test)]
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, 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
);
}
#[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())
));
}
}

192
core/src/crypto.rs Normal file
View file

@ -0,0 +1,192 @@
//! Encoding and cryptographic helpers: base32, HKDF, HMAC, SHA-256 and the
//! Ed25519 -> X25519 key map with the checks SPEC.md sec 2 requires.
use data_encoding::{Encoding, Specification};
use ed25519_dalek::VerifyingKey;
use hkdf::Hkdf;
use hmac::{Hmac, Mac};
use sha2::{Digest, Sha256};
use std::sync::LazyLock;
use x25519_dalek::{PublicKey, StaticSecret};
use crate::error::Error;
pub const KEY_LEN: usize = 32;
/// X25519 scalar clamping (RFC 7748): libsodium's crypto_sign_ed25519_sk_to_
/// curve25519 output is the clamped scalar, and SPEC.md sec 2 spells the map
/// the same way, so the private half is clamped when constructed rather than
/// only inside key agreement.
pub fn clamp_scalar(mut bytes: [u8; KEY_LEN]) -> [u8; KEY_LEN] {
bytes[0] &= 248;
bytes[31] &= 127;
bytes[31] |= 64;
bytes
}
static BASE32_LOWER_UNPADDED: LazyLock<Encoding> = LazyLock::new(|| {
let mut spec = Specification::new();
spec.symbols.push_str("abcdefghijklmnopqrstuvwxyz234567");
spec.encoding().unwrap()
});
/// RFC 4648 base32, lowercase and unpadded (SPEC.md sec 3).
pub fn b32(raw: &[u8]) -> String {
BASE32_LOWER_UNPADDED.encode(raw)
}
/// Inverse of `b32`; accepts uppercase and stray padding for pasted input.
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| Error::Other(format!("undecodable base32: {e}")))
}
/// First 20 characters of the base32 identity, in groups of four (SPEC.md
/// sec 3): a truncation of the identity, not a separate encoding.
pub fn fingerprint(identity: &[u8; KEY_LEN]) -> String {
let s: String = b32(identity).chars().take(20).collect();
s.as_bytes()
.chunks(4)
.map(|c| std::str::from_utf8(c).unwrap())
.collect::<Vec<_>>()
.join(" ")
}
/// Multi-part SHA-256, the shape of every derived value in the protocol.
pub fn sha256(parts: &[&[u8]]) -> [u8; 32] {
let mut hasher = Sha256::new();
for part in parts {
hasher.update(part);
}
hasher.finalize().into()
}
/// HKDF-SHA256 (RFC 5869), as used by the identity derivations of sec 2 and
/// the sealing key of sec 5.2.
pub fn hkdf_sha256(ikm: &[u8], salt: &[u8], info: &[u8], len: usize) -> Vec<u8> {
let mut okm = vec![0u8; len];
let _ = Hkdf::<Sha256>::new(Some(salt), ikm).expand(info, &mut okm);
okm
}
/// HMAC-SHA256 (RFC 2104), used for accept tokens and their MACs (sec 5.8).
pub fn hmac_sha256(key: &[u8], message: &[u8]) -> [u8; 32] {
let mut mac = Hmac::<Sha256>::new_from_slice(key).expect("HMAC accepts any key length");
mac.update(message);
mac.finalize().into_bytes().into()
}
/// Constant-time equality, so a pinned-key or MAC comparison cannot leak
/// timing information.
pub fn ct_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
let mut diff = 0u8;
for (x, y) in a.iter().zip(b.iter()) {
diff |= x ^ y;
}
diff == 0
}
/// 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], Error> {
VerifyingKey::from_bytes(identity)
.map(|key| *key.to_montgomery().as_bytes())
.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], Error> {
let shared = secret.diffie_hellman(&PublicKey::from(*peer));
if shared.as_bytes().iter().all(|&b| b == 0) {
return Err(Error::Other("rejected all-zero key agreement output".into()));
}
Ok(*shared.as_bytes())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn base32_matches_rfc4648_lowercase_unpadded() {
assert_eq!(b32(b"hello"), "nbswy3dp");
assert_eq!(
b32(&[0u8; 32]),
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
);
// 52 characters for a 32-byte key, the form the smol:// path uses.
assert_eq!(b32(&[1u8; 32]).len(), 52);
}
#[test]
fn unb32_round_trips_and_accepts_padded_uppercase() {
assert_eq!(unb32("nbswy3dp").unwrap(), b"hello");
assert_eq!(unb32("NBSWY3DP==").unwrap(), b"hello");
assert!(unb32("nbsw!3dp").is_err());
}
#[test]
fn fingerprint_is_20_chars_in_groups_of_four() {
let fp = fingerprint(&[0u8; 32]);
assert_eq!(fp, "aaaa aaaa aaaa aaaa aaaa");
}
#[test]
fn hkdf_matches_rfc5869_case_1() {
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 v = crate::vectors::load();
let hm = &v.hmac_rfc4231_case1;
assert_eq!(
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() {
// 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()), 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.
let secret = StaticSecret::from([7u8; 32]);
assert!(agree(&secret, &ed25519_to_x25519(&[0u8; 32]).unwrap()).is_err());
}
#[test]
fn agree_rejects_all_zero_output() {
// A low-order base point produces the all-zero agreement output that
// sec 2 rejects.
let secret = StaticSecret::from([7u8; 32]);
let low_order = PublicKey::from([0u8; 32]);
assert!(agree(&secret, low_order.as_bytes()).is_err());
}
#[test]
fn ct_eq_compares_content() {
assert!(ct_eq(b"abc", b"abc"));
assert!(!ct_eq(b"abc", b"abd"));
assert!(!ct_eq(b"abc", b"ab"));
}
}

193
core/src/error.rs Normal file
View 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),
})
}

22
core/src/lib.rs Normal file
View file

@ -0,0 +1,22 @@
//! `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). The
//! `fumi` crate wraps this library as a command-line client; GUI hosts embed
//! this one directly.
pub mod account;
pub mod address;
pub mod client;
pub mod crypto;
pub mod error;
pub mod message;
#[cfg(feature = "rns")]
pub mod rns;
pub mod store;
pub mod tcp;
pub mod transport;
#[cfg(test)]
mod vectors;

426
core/src/message.rs Normal file
View file

@ -0,0 +1,426 @@
//! Envelopes (sec 5.1-5.4) and body frontmatter (sec 5.5).
use std::collections::BTreeMap;
use chacha20poly1305::aead::{Aead, KeyInit, Payload};
use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce};
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::Error;
use crate::transport::{
ENVELOPE_HEADER, ENVELOPE_MAGIC, ENVELOPE_MIN, ENVELOPE_VERSION, LABEL_ID, LABEL_MSG,
LABEL_SEAL, PAYLOAD_HEADER, SIG_LEN,
};
/// Senders MAY pad to a multiple of this to blur message length (sec 5.3).
pub const PAD_TO: usize = 1024;
/// A payload dated further ahead of our clock than this is rejected (sec 5.3).
pub const MAX_SKEW: i64 = 86400;
/// Frontmatter limits (sec 5.5).
pub const FRONTMATTER_MAX: usize = 4096;
pub const FRONTMATTER_KEYS: usize = 64;
/// An opened, verified payload. The message id is derived from the envelope,
/// not carried in it.
pub struct Opened {
pub sender: [u8; KEY_LEN],
pub time: i64,
pub body: Vec<u8>,
}
/// The derived message identifier (sec 5.4): a sender cannot choose it, and a
/// resend is a no-op rather than a new message.
pub fn message_id(envelope: &[u8]) -> [u8; KEY_LEN] {
sha256(&[LABEL_ID, envelope])
}
/// Seals `body` from `sender` to `recipient` (sec 5.2, 5.3). A fresh ephemeral
/// X25519 keypair per message makes the all-zero nonce correct: the derived
/// key is used exactly once, and the sender's long-term key never takes part
/// in key agreement.
pub fn seal(
sender: &Identity,
recipient: &[u8; KEY_LEN],
body: &[u8],
when: i64,
pad: bool,
) -> 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
// without a zeroizing type.
seal_with_esk(sender, recipient, body, when, pad, esk)
}
fn seal_with_esk(
sender: &Identity,
recipient: &[u8; KEY_LEN],
body: &[u8],
when: i64,
pad: bool,
esk: [u8; KEY_LEN],
) -> 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);
// Ephemeral-static against the recipient's converted key (sec 5.2);
// converting also validates the recipient identity as a curve point.
let shared = agree(&eph, &x_recipient)?;
let mut salt = Vec::with_capacity(2 * KEY_LEN);
salt.extend_from_slice(epk.as_bytes());
salt.extend_from_slice(recipient);
let key = hkdf_sha256(&shared, &salt, LABEL_SEAL, 32);
let mut header = Vec::with_capacity(PAYLOAD_HEADER);
header.push(ENVELOPE_VERSION);
header.extend_from_slice(&sender.pk());
header.extend_from_slice(&when.to_be_bytes());
header.extend_from_slice(&(body.len() as u32).to_be_bytes());
let mut signed = Vec::with_capacity(LABEL_MSG.len() + 2 * KEY_LEN + header.len() + body.len());
signed.extend_from_slice(LABEL_MSG);
signed.extend_from_slice(recipient);
signed.extend_from_slice(epk.as_bytes());
signed.extend_from_slice(&header);
signed.extend_from_slice(body);
let mut plaintext = header;
plaintext.extend_from_slice(body);
plaintext.extend_from_slice(&sender.sign(&signed));
if pad {
let pad = (PAD_TO - plaintext.len() % PAD_TO) % PAD_TO;
plaintext.resize(plaintext.len() + pad, 0);
}
let mut aad = Vec::with_capacity(ENVELOPE_HEADER);
aad.extend_from_slice(ENVELOPE_MAGIC);
aad.push(ENVELOPE_VERSION);
aad.extend_from_slice(recipient);
aad.extend_from_slice(epk.as_bytes());
let cipher = ChaCha20Poly1305::new(Key::from_slice(&key));
let sealed = cipher
.encrypt(
&Nonce::from([0u8; 12]),
Payload {
msg: &plaintext,
aad: &aad,
},
)
.map_err(|_| Error::Other("encryption failed".into()))?;
let mut envelope = aad;
envelope.extend_from_slice(&sealed);
Ok(envelope)
}
/// Inverse of `seal`, performing every check a receiver MUST make (sec 5.3):
/// the envelope is addressed to one of our keys, the signature verifies
/// 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, Error> {
if envelope.len() < ENVELOPE_MIN {
return Err(Error::Other("envelope too short".into()));
}
if &envelope[..4] != ENVELOPE_MAGIC {
return Err(Error::Other("not a Smol Mail envelope".into()));
}
if envelope[4] != ENVELOPE_VERSION {
return Err(Error::Other(format!(
"unsupported envelope version {}",
envelope[4]
)));
}
let to: [u8; KEY_LEN] = envelope[5..37].try_into().unwrap();
let epk: [u8; KEY_LEN] = envelope[37..69].try_into().unwrap();
let sealed = &envelope[ENVELOPE_HEADER..];
let me = identities
.iter()
.find(|i| ct_eq(&i.pk(), &to))
.ok_or_else(|| {
Error::Other(format!(
"addressed to {}…, not one of our keys",
&b32(&to)[..16]
))
})?;
let shared = agree(me.x_priv(), &epk)?;
let mut salt = Vec::with_capacity(2 * KEY_LEN);
salt.extend_from_slice(&epk);
salt.extend_from_slice(&to);
let key = hkdf_sha256(&shared, &salt, LABEL_SEAL, 32);
let cipher = ChaCha20Poly1305::new(Key::from_slice(&key));
let plaintext = cipher
.decrypt(
&Nonce::from([0u8; 12]),
Payload {
msg: sealed,
aad: &envelope[..ENVELOPE_HEADER],
},
)
.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(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(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];
let mut signed = Vec::with_capacity(LABEL_MSG.len() + 2 * KEY_LEN + header.len() + body.len());
signed.extend_from_slice(LABEL_MSG);
signed.extend_from_slice(&to);
signed.extend_from_slice(&epk);
signed.extend_from_slice(header);
signed.extend_from_slice(body);
if !verify_sig(&sender, signature, &signed) {
return Err(Error::Other("signature does not verify".into()));
}
if when > now + MAX_SKEW {
return Err(Error::Other("payload is dated in the future".into()));
}
Ok(Opened {
sender,
time: when,
body: body.to_vec(),
})
}
/// Splits a body into its frontmatter fields and text (sec 5.5). Keys are
/// returned lowercased; a malformed block falls back to the whole body as
/// plain text, failing closed toward display.
pub fn parse_frontmatter(body: &str) -> (BTreeMap<String, String>, String) {
let whole = body.to_string();
if !body.starts_with("---\n") {
return (BTreeMap::new(), whole);
}
let lines: Vec<&str> = body.split('\n').collect();
// The block runs to the next line that is exactly `---`.
let Some(close) = lines
.iter()
.skip(1)
.position(|&l| l == "---")
.map(|p| p + 1)
else {
return (BTreeMap::new(), whole);
};
let block = &lines[1..close];
let rest = lines[close + 1..].join("\n");
if block.len() > FRONTMATTER_KEYS {
return (BTreeMap::new(), whole);
}
if block.iter().map(|l| l.len() + 1).sum::<usize>() > FRONTMATTER_MAX {
return (BTreeMap::new(), whole);
}
let mut fields = BTreeMap::new();
for line in block {
let Some((key, value)) = line.split_once(':') else {
return (BTreeMap::new(), whole);
};
if !valid_key(key) {
return (BTreeMap::new(), whole);
}
// First occurrence wins; keys are compared case-insensitively.
fields
.entry(key.to_ascii_lowercase())
.or_insert_with(|| value.trim().to_string());
}
(fields, rest)
}
/// Emits a block only when needed, including to escape a body that genuinely
/// begins with `---` (sec 5.5).
pub fn build_frontmatter(fields: &[(&str, &str)], body: &str) -> String {
if fields.is_empty() && !body.starts_with("---\n") {
return body.to_string();
}
let mut out = String::from("---\n");
for (key, value) in fields {
out.push_str(&format!("{key}: {value}\n"));
}
out.push_str("---\n");
out.push_str(body);
out
}
/// 1-64 bytes of [A-Za-z0-9-] (sec 5.5).
fn valid_key(key: &str) -> bool {
!key.is_empty()
&& key.len() <= 64
&& key.bytes().all(|c| c.is_ascii_alphanumeric() || c == b'-')
}
/// The accept token carried in a reserved `Accept` field, if any. It is
/// meaningful only inside a sealed, signed payload, which the caller has
/// already verified (sec 5.8).
pub fn accept_field(fields: &BTreeMap<String, String>) -> Option<[u8; 32]> {
let raw = fields.get("accept")?;
unb32(raw).ok()?.try_into().ok()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::account::identity_seed;
use crate::crypto::b32;
use std::collections::BTreeMap;
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 message_id_matches_the_reference_client() {
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)),
v.envelope.message_id_hex
);
}
#[test]
fn unseals_an_envelope_from_the_reference_client() {
let v = crate::vectors::load();
let envelope = unb32(&v.envelope.padded_b32).unwrap();
let opened = unseal(
&[identity(0), identity(1), identity(2)],
&envelope,
v.envelope.when + 100,
)
.expect("reference envelope must open");
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 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, e.body.as_bytes(), e.when, false, esk).unwrap();
assert_eq!(plain.len(), 226);
assert_eq!(plain, unb32(&e.unpadded_b32).unwrap());
}
#[test]
fn seal_open_round_trip_and_rejections() {
let sender = identity(2);
let recipient = identity(0);
let envelope = seal(&sender, &recipient.pk(), b"body text", 1700000000, false).unwrap();
let opened = unseal(std::slice::from_ref(&recipient), &envelope, 1700000100).unwrap();
assert_eq!(opened.sender, sender.pk());
assert_eq!(opened.body, b"body text");
// Not addressed to us.
assert!(unseal(&[identity(1)], &envelope, 1700000100).is_err());
// Tampered ciphertext fails the tag.
let mut broken = envelope.clone();
broken[80] ^= 1;
assert!(unseal(std::slice::from_ref(&recipient), &broken, 1700000100).is_err());
// Superseded keys still open their mail (sec 7).
assert!(unseal(&[identity(1), recipient.clone()], &envelope, 1700000100).is_ok());
// A payload dated far in the future is rejected (sec 5.3).
let future = seal(
&sender,
&recipient.pk(),
b"x",
1700000000 + MAX_SKEW + 1,
false,
)
.unwrap();
assert!(unseal(std::slice::from_ref(&recipient), &future, 1700000000).is_err());
// Not an envelope at all.
assert!(unseal(&[recipient], b"SMOL", 0).is_err());
}
#[test]
fn padding_is_ignored_and_length_blurred() {
let sender = identity(2);
let recipient = identity(0);
let padded = seal(&sender, &recipient.pk(), b"smol", 1700000000, true).unwrap();
let opened = unseal(&[recipient], &padded, 1700000000).unwrap();
assert_eq!(opened.body, b"smol");
// 45-byte payload header + 4-byte body + 64-byte signature = 113
// padded to the next 1024 multiple, plus the 69-byte envelope header
// and the 16-byte tag.
assert_eq!(padded.len(), 69 + 1024 + 16);
}
#[test]
fn frontmatter_rules() {
let (fields, text) = parse_frontmatter(
"---\nSubject: hi there\nX-Mood: ok\nSubJect: second loses\n---\nBody here.\n",
);
let mut expected = BTreeMap::new();
expected.insert("subject".to_string(), "hi there".to_string());
expected.insert("x-mood".to_string(), "ok".to_string());
assert_eq!(fields, expected);
assert_eq!(text, "Body here.\n");
// A malformed line invalidates the whole block: display, not discard.
let (fields, text) = parse_frontmatter("---\nSubject: good\nnot a valid line\n---\ntext\n");
assert!(fields.is_empty());
assert!(text.starts_with("---\n"));
// No closing fence, oversized key: no block.
assert!(parse_frontmatter("---\nSubject: hi\n").0.is_empty());
assert!(
parse_frontmatter(&format!("---\n{}: hi\n---\nt\n", "x".repeat(65)))
.0
.is_empty()
);
assert!(parse_frontmatter("plain text").1 == "plain text");
// Values are trimmed; a value may itself contain a colon.
let (fields, _) = parse_frontmatter("---\nX-Url: http://a:b/c\n---\n");
assert_eq!(fields.get("x-url").unwrap(), "http://a:b/c");
}
#[test]
fn frontmatter_building_escapes_leading_dashes() {
let built = build_frontmatter(&[("Subject", "hi"), ("X-Mood", "ok")], "plain body\n");
assert_eq!(built, "---\nSubject: hi\nX-Mood: ok\n---\nplain body\n");
// A body genuinely beginning with --- is escaped by an empty block.
let escaped = build_frontmatter(&[], "---\nstarts with dashes\n");
assert_eq!(escaped, "---\n---\n---\nstarts with dashes\n");
// Nothing to say means no block at all.
assert_eq!(build_frontmatter(&[], "body\n"), "body\n");
// Round trip through the parser.
let (fields, text) = parse_frontmatter(&built);
assert_eq!(fields.get("subject").unwrap(), "hi");
assert_eq!(text, "plain body\n");
}
#[test]
fn accept_field_decodes_base32_or_is_absent() {
let mut fields = BTreeMap::new();
assert!(accept_field(&fields).is_none());
fields.insert("accept".to_string(), "nbswy3dp".to_string());
assert!(accept_field(&fields).is_none()); // 5 bytes, not 32
fields.insert("accept".to_string(), b32(&[1u8; 32]));
assert_eq!(accept_field(&fields), Some([1u8; 32]));
}
}

155
core/src/rns/ffi.rs Normal file
View file

@ -0,0 +1,155 @@
//! `extern "C"` declarations for `shim/smolmail_rns.h` and the safe wrapper.
//!
//! 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
//! `Error` message and never into a `Response`.
use std::ffi::CString;
use std::time::Duration;
use crate::crypto::KEY_LEN;
use crate::error::Error;
use crate::rns::RnsConfig;
use crate::transport::MAX_FRAME;
mod inner {
use std::os::raw::{c_char, c_int};
extern "C" {
// shim/smolmail_rns.cpp
pub fn smolmail_rns_start(
storage_dir: *const c_char,
udp_listen_host: *const c_char,
udp_listen_port: u16,
udp_forward_host: *const c_char,
udp_forward_port: u16,
) -> c_int;
pub fn smolmail_rns_connect(
destination_hash: *const u8,
timeout_ms: u32,
link_id_out: *mut u8,
) -> c_int;
pub fn smolmail_rns_request(
request: *const u8,
request_len: usize,
timeout_ms: u32,
out: *mut u8,
cap: usize,
out_len: *mut usize,
) -> c_int;
pub fn smolmail_rns_close();
}
}
/// Starts the Reticulum stack: storage path, UDP interface, loop thread.
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 =
CString::new(config.udp_listen_host.as_str()).expect("listen host has no interior NUL");
let forward_host = config
.udp_forward_host
.as_ref()
.map(|h| CString::new(h.as_str()).expect("forward host has no interior NUL"));
let rc = unsafe {
inner::smolmail_rns_start(
storage_dir.as_ptr(),
listen_host.as_ptr(),
config.udp_listen_port,
forward_host
.as_ref()
.map(|h| h.as_ptr())
.unwrap_or(std::ptr::null()),
config.udp_forward_port,
)
};
match rc {
0 => Ok(()),
_ => Err(Error::Other(format!(
"RNS shim failed to start (code {rc}); is the UDP port free?"
))),
}
}
/// 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], Error> {
let hex = data_encoding::HEXLOWER.encode(destination);
let mut link_id = [0u8; 16];
let rc = unsafe {
inner::smolmail_rns_connect(
destination.as_ptr(),
timeout.as_millis().min(u32::MAX as u128) as u32,
link_id.as_mut_ptr(),
)
};
match rc {
0 => Ok(link_id),
-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(Error::Other(format!(
"path known but identity not yet learned for {hex}; try again shortly"
))),
-3 => Err(Error::Other(format!(
"could not establish a link to {hex}"
))),
_ => Err(Error::Other(format!(
"RNS connect to {hex} failed (code {rc})"
))),
}
}
/// Sends one `op u8 || body` request and returns the `status u8 || payload`
/// response.
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
// full application frame ceiling.
let cap = MAX_FRAME + 16;
let mut out = vec![0u8; cap];
let mut out_len: usize = 0;
let rc = unsafe {
inner::smolmail_rns_request(
request.as_ptr(),
request.len(),
timeout.as_millis().min(u32::MAX as u128) as u32,
out.as_mut_ptr(),
cap,
&mut out_len,
)
};
match rc {
0 => {
out.truncate(out_len);
Ok(out)
}
-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)".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})"))),
}
}
/// Tears the link down rather than leave it on keepalives (upstream spec sec
/// 13.10).
pub fn close() {
unsafe { inner::smolmail_rns_close() };
}
/// The destination hash length, for callers checking address shapes.
pub const DEST_LEN: usize = 16;
const _: () = assert!(DEST_LEN == 16 && KEY_LEN == 32);

71
core/src/rns/mod.rs Normal file
View file

@ -0,0 +1,71 @@
//! The RNS carrier (Smol Mail 1.2, upstream RNS.md): microReticulum behind
//! the C shim, dispatched through the same operations as TCP.
//!
//! The carrier starts lazily, on the first `smol+rns://` dial, so local-only
//! commands stay usable offline and start instantly. A client creates no
//! Reticulum identity (upstream spec sec 13.8): the shim never calls
//! `Link::identify`, and the flags below configure only the storage path and
//! the UDP interface.
pub mod ffi;
pub mod transport;
use std::path::Path;
use std::sync::{Mutex, OnceLock};
use crate::error::Error;
/// Carrier configuration, set once by the CLI and consumed on first use.
#[derive(Clone, Debug)]
pub struct RnsConfig {
/// microReticulum storage directory; created before the carrier starts.
pub storage_dir: String,
pub udp_listen_host: String,
pub udp_listen_port: u16,
/// Effectively required for a client (upstream spec sec 13.2 and the
/// plan): a client speaks first, so with no forward target the interface
/// has nowhere to send its path request.
pub udp_forward_host: Option<String>,
pub udp_forward_port: u16,
}
impl Default for RnsConfig {
fn default() -> Self {
RnsConfig {
storage_dir: "fumi.rns".to_string(),
// An ephemeral listen port avoids colliding with a local rnsd on
// 4242; replies come back to the source address.
udp_listen_host: "127.0.0.1".to_string(),
udp_listen_port: 0,
udp_forward_host: None,
udp_forward_port: 4242,
}
}
}
static CONFIG: OnceLock<RnsConfig> = OnceLock::new();
static STARTED: OnceLock<()> = OnceLock::new();
static START_MUTEX: Mutex<()> = Mutex::new(());
/// Records the carrier configuration; the first dial wins, as in a CLI the
/// flags are constant for the run.
pub fn configure(config: RnsConfig) {
let _ = CONFIG.set(config);
}
/// Starts the Reticulum stack at most once, on the first RNS dial.
pub fn ensure_started() -> Result<(), Error> {
if STARTED.get().is_some() {
return Ok(());
}
let _guard = START_MUTEX.lock().unwrap();
if STARTED.get().is_some() {
return Ok(());
}
let config = CONFIG.get().cloned().unwrap_or_default();
std::fs::create_dir_all(Path::new(&config.storage_dir))
.map_err(|e| Error::Other(format!("cannot create {}: {e}", config.storage_dir)))?;
ffi::start(&config)?;
let _ = STARTED.set(());
Ok(())
}

83
core/src/rns/transport.rs Normal file
View file

@ -0,0 +1,83 @@
//! `impl Transport` for the RNS carrier: one Reticulum link to a
//! `smolmail.server` destination, one request in flight at a time.
//!
//! There is nothing to pin here (upstream spec sec 13.4): a destination hash
//! is a 128-bit truncated hash over the server identity's public keys, and
//! Reticulum proves the link against exactly that identity. The address is
//! the pin, so `pinned()` is always true and RESOLVE results are as good as
//! the channel the address arrived on.
use std::time::Duration;
use crate::error::Error;
use crate::rns::{ensure_started, ffi};
use crate::transport::{Response, Transport, TransportBindValues};
pub struct RnsTransport {
/// The 16-byte destination hash the address carried.
destination: [u8; 16],
/// What AUTH and REGISTER signatures bind to (upstream spec sec 13.6):
/// derived from destination || link_id, replacing the Noise handshake
/// hash and the server's static key.
bind: TransportBindValues,
/// The per-request timeout a client MUST set itself (upstream spec sec
/// 13.5): Reticulum's default is derived from the round trip time and
/// covers a packet, not a FETCH page.
timeout: Duration,
}
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, Error> {
ensure_started()?;
let link_id = ffi::connect(destination, timeout)?;
Ok(RnsTransport {
destination: *destination,
bind: TransportBindValues::rns(destination, &link_id),
timeout,
})
}
/// The destination hash, as the `smol+rns://` address host part.
pub fn destination_hex(&self) -> String {
data_encoding::HEXLOWER.encode(&self.destination)
}
}
impl Transport for RnsTransport {
/// Sends `op u8 || body` and returns the `status u8 || payload` response
/// (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, Error> {
let mut wire = Vec::with_capacity(1 + body.len());
wire.push(op);
wire.extend_from_slice(body);
let response = ffi::request(&wire, self.timeout)?;
// A server MUST answer every registered request with a status, so an
// empty response is a peer misbehaving, not a local error.
let status = response
.first()
.copied()
.ok_or_else(|| Error::Other("empty response from server".into()))?;
Ok(Response {
status,
body: response[1..].to_vec(),
})
}
fn bind(&self) -> &TransportBindValues {
&self.bind
}
fn pinned(&self) -> bool {
// The destination hash is the pin (upstream spec sec 13.4).
true
}
fn close(&mut self) {
ffi::close();
}
}

724
core/src/store.rs Normal file
View file

@ -0,0 +1,724 @@
//! 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};
use crate::account::Account;
use crate::address::Address;
use crate::crypto::ct_eq;
use crate::error::Error;
use crate::transport::{ID_LEN, KEY_LEN, TIER_MAIN, TIER_REQUESTS};
pub fn now() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock before 1970")
.as_secs() as i64
}
pub const SCHEMA: &str = "
-- One row, always present, so every update is a plain UPDATE.
CREATE TABLE IF NOT EXISTS state (
id INTEGER PRIMARY KEY CHECK (id = 1),
username TEXT, host TEXT, port INTEGER, scheme TEXT,
rotations INTEGER NOT NULL DEFAULT 0, -- sec 2 rotation index
after_time INTEGER NOT NULL DEFAULT 0, -- sec 6.1 FETCH cursor
after_id BLOB NOT NULL DEFAULT x'',
sync_ok INTEGER NOT NULL DEFAULT 1); -- may we replace the server's set?
INSERT OR IGNORE INTO state (id) VALUES (1);
CREATE TABLE IF NOT EXISTS servers (
host TEXT PRIMARY KEY, static BLOB NOT NULL, pinned_at INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS contacts (
address TEXT PRIMARY KEY, identity BLOB NOT NULL,
verified INTEGER NOT NULL, -- 1 when the key came from a self-certifying URI
seen_at INTEGER NOT NULL);
-- 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
-- contact's later rotation must not change the token they already hold.
CREATE TABLE IF NOT EXISTS accepted (
address TEXT PRIMARY KEY, identity BLOB NOT NULL,
active INTEGER NOT NULL, added_at INTEGER NOT NULL);
-- Accept tokens received from correspondents, filed under the address that
-- issued them: an address outlives the keys behind it, so a token keeps
-- working across the issuer's rotations (sec 5.8).
CREATE TABLE IF NOT EXISTS tokens (
address TEXT PRIMARY KEY, token BLOB NOT NULL, seen_at INTEGER NOT NULL);
-- Every id ever fetched, so an envelope resent after we deleted it locally
-- is not stored again (sec 10).
CREATE TABLE IF NOT EXISTS seen (id BLOB PRIMARY KEY, at INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS inbox (
id BLOB PRIMARY KEY, envelope BLOB NOT NULL,
received_at INTEGER NOT NULL, tier INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS sent (
id BLOB PRIMARY KEY, recipient TEXT NOT NULL,
envelope BLOB NOT NULL, sent_at INTEGER NOT NULL);
";
/// One sealed message in a folder, still encrypted.
pub struct Stored {
pub id: [u8; ID_LEN],
pub envelope: Vec<u8>,
pub at: i64,
/// Only sent copies carry a recipient.
pub recipient: Option<String>,
}
pub struct Store {
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, Error> {
let db = Connection::open(path)?;
// 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),
})
}
/// 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)) {
Ok(value) => Some(value),
Err(rusqlite::Error::QueryReturnedNoRows) => None,
Err(e) => return Err(e.into()),
},
)
}
/// The home address, when this identity has been registered or restored.
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| {
Ok((
row.get::<_, Option<String>>(0)?,
row.get::<_, Option<String>>(1)?,
row.get::<_, Option<i64>>(2)?,
row.get::<_, Option<String>>(3)?,
))
},
);
let (username, host, port, scheme) = match row {
Ok(tuple) => tuple,
Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None),
Err(e) => return Err(e.into()),
};
let (Some(username), Some(host)) = (username, host) else {
return Ok(None);
};
let scheme = scheme.unwrap_or_else(|| "tcp".to_string());
let port = port.unwrap_or(crate::address::DEFAULT_PORT as i64) as u16;
let text = if scheme == "rns" {
format!("smol+rns://{username}@{host}")
} else {
format!("{username}@{host}")
};
let mut addr = Address::parse(&text)
.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<(), Error> {
let scheme = match addr.scheme {
crate::address::Scheme::Tcp => "tcp",
crate::address::Scheme::Rns => "rns",
};
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],
)
.map(|_| ())?;
Ok(())
}
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<(), Error> {
self.db()
.execute(
"UPDATE state SET rotations = ?1 WHERE id = 1",
params![index],
)
.map(|_| ())?;
Ok(())
}
pub fn cursor(&self) -> Result<(i64, [u8; ID_LEN]), Error> {
let (after_time, after_id): (i64, Vec<u8>) = self
.db()
.query_row(
"SELECT after_time, after_id FROM state WHERE id = 1",
[],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.map_err(Error::from)?;
let after_id: [u8; ID_LEN] = if after_id.len() == ID_LEN {
after_id.try_into().unwrap()
} else {
[0u8; ID_LEN]
};
Ok((after_time, after_id))
}
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],
)
.map(|_| ())?;
Ok(())
}
/// 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, 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<(), Error> {
self.db()
.execute(
"UPDATE state SET sync_ok = ?1 WHERE id = 1",
params![ok as i64],
)
.map(|_| ())?;
Ok(())
}
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<(), 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",
params![host, key, now()],
)
.map(|_| ())?;
Ok(())
}
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)?)),
);
match row {
Ok((identity, verified)) => Ok(Some((
identity
.try_into()
.map_err(|_| Error::Other("stored contact identity is not 32 bytes".into()))?,
verified != 0,
))),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(e.into()),
}
}
pub fn save_contact(
&self,
address: &str,
identity: &[u8; KEY_LEN],
verified: bool,
) -> 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",
params![address, identity, verified as i64, now()],
)
.map(|_| ())?;
Ok(())
}
/// Every contact with its acceptance state, for `fumi contacts`.
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",
)?;
let rows = stmt
.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, Vec<u8>>(1)?,
row.get::<_, i64>(2)?,
row.get::<_, Option<i64>>(3)?,
))
})?
.collect::<Result<Vec<_>, _>>()?;
Ok(rows
.into_iter()
.filter_map(|(address, identity, verified, active)| {
Some((address, identity.try_into().ok()?, verified != 0, active))
})
.collect())
}
/// 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]>), Error> {
if !self.sync_ok()? {
return Ok((0, Vec::new()));
}
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<_>, _>>()?;
Ok((
1,
tokens
.iter()
.filter_map(|identity| {
let identity: [u8; KEY_LEN] = identity.as_slice().try_into().ok()?;
Some(account.token_for(&identity))
})
.collect(),
))
}
/// The address we know a signer by: a contact, or the Reply-To it signed
/// for itself. Naming a mailbox is not trusting a key, so nothing is
/// pinned here (sec 5.7, sec 8).
pub fn address_of(
&self,
sender: &[u8],
reply_to: Option<&str>,
) -> 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)?))
})?
.collect::<Result<Vec<_>, _>>()?;
for (address, identity) in rows {
if ct_eq(&identity, sender) {
return Ok(Some(address));
}
}
if let Some(uri) = reply_to {
if let Ok(claimed) = Address::parse(uri) {
if claimed.identity.is_some_and(|key| ct_eq(&key, sender)) {
return Ok(Some(claimed.short()));
}
}
}
Ok(None)
}
/// 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<(), 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",
params![address, identity, now()],
)
.map(|_| ())?;
self.set_sync_ok(true)?;
Ok(())
}
pub fn block(&self, address: &str) -> Result<bool, Error> {
let changed = self.db().execute(
"UPDATE accepted SET active = 0 WHERE address = ?1",
params![address],
)?;
Ok(changed > 0)
}
/// 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]>, Error> {
let identity: Option<Vec<u8>> = self.one(
"SELECT identity FROM accepted WHERE address = ?1 AND active = 1",
&[&address],
)?;
Ok(identity.and_then(|identity| identity.try_into().ok()))
}
/// 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> {
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<(), 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",
params![address, token, now()],
)
.map(|_| ())?;
Ok(())
}
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())
}
pub fn store_inbox(
&self,
id: &[u8; ID_LEN],
envelope: &[u8],
received_at: i64,
requests_tier: bool,
) -> Result<(), Error> {
let tier = if requests_tier {
TIER_REQUESTS
} else {
TIER_MAIN
};
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()
.execute(
"INSERT OR IGNORE INTO seen (id, at) VALUES (?1, ?2)",
params![id, received_at],
)
.map(|_| ())?;
Ok(())
}
pub fn store_sent(
&self,
id: &[u8; ID_LEN],
recipient: &str,
envelope: &[u8],
sent_at: i64,
) -> 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],
)
.map(|_| ())?;
Ok(())
}
/// One folder, ordered by arrival or sending time. `folder` is "inbox",
/// "requests", "sent" or "all".
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",
None,
),
"all" => (
"SELECT id, envelope, received_at, NULL FROM inbox ORDER BY received_at, id",
None,
),
"requests" => (
"SELECT id, envelope, received_at, NULL FROM inbox \
WHERE tier = ?1 ORDER BY received_at, id",
Some(TIER_REQUESTS as i8),
),
_ => (
"SELECT id, envelope, received_at, NULL FROM inbox \
WHERE tier = ?1 ORDER BY received_at, id",
Some(TIER_MAIN as i8),
),
};
let db = self.db();
let mut stmt = db.prepare(sql)?;
let rows = match tier {
Some(t) => stmt
.query_map(params![t], row_stored)?
.collect::<Result<Vec<_>, _>>()?,
None => stmt
.query_map([], row_stored)?
.collect::<Result<Vec<_>, _>>()?,
};
Ok(rows)
}
}
fn row_stored(row: &rusqlite::Row<'_>) -> rusqlite::Result<Stored> {
Ok(Stored {
id: row.get::<_, Vec<u8>>(0)?.try_into().unwrap(),
envelope: row.get(1)?,
at: row.get(2)?,
recipient: row.get(3)?,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::account::{identity_seed, Account};
fn master() -> [u8; 32] {
core::array::from_fn(|i| i as u8)
}
fn temp_store(tag: &str) -> Store {
let path = std::env::temp_dir().join(format!(
"fumi-store-{tag}-{}-{}.db",
std::process::id(),
now()
));
Store::open(&path).expect("open store")
}
#[test]
fn account_state_round_trip() {
let store = temp_store("account");
assert!(store.account().unwrap().is_none());
let addr = Address::parse("alice@example.org:1962").unwrap();
store.set_account(&addr).unwrap();
let back = store.account().unwrap().unwrap();
assert_eq!(back.short(), "alice@example.org:1962");
assert_eq!(back.port, 1962);
let rns = Address::parse("smol+rns://alice@8f2c1d0a7b6e5f4c3d2a1b0e9f8c7d6a").unwrap();
store.set_account(&rns).unwrap();
let back = store.account().unwrap().unwrap();
assert_eq!(back.scheme, crate::address::Scheme::Rns);
assert_eq!(back.short(), "alice@8f2c1d0a7b6e5f4c3d2a1b0e9f8c7d6a");
}
#[test]
fn rotation_index_and_cursor_persist() {
let store = temp_store("state");
assert_eq!(store.rotations().unwrap(), 0);
store.set_rotations(3).unwrap();
assert_eq!(store.rotations().unwrap(), 3);
assert_eq!(store.cursor().unwrap(), (0, [0u8; 32]));
store.set_cursor(17, &[9u8; 32]).unwrap();
assert_eq!(store.cursor().unwrap(), (17, [9u8; 32]));
// A short cursor blob (a fresh store's x'') reads as the start.
}
#[test]
fn sync_ok_gates_the_token_set() {
let store = temp_store("sync");
let account = Account::new(master(), 0).unwrap();
let key = Account::new(master(), 1).unwrap().me().pk();
store.accept("bob@example.org", &key).unwrap();
// Accepting turns sync on and pushes exactly one derived token.
assert!(store.sync_ok().unwrap());
let (sync, tokens) = store.token_set(&account).unwrap();
assert_eq!(sync, 1);
assert_eq!(tokens, vec![account.token_for(&key)]);
// A restored client must not erase the server's set (sec 4).
store.set_sync_ok(false).unwrap();
let (sync, tokens) = store.token_set(&account).unwrap();
assert_eq!(sync, 0);
assert!(tokens.is_empty());
}
#[test]
fn accept_freezes_identity_across_conflicts() {
let store = temp_store("accept");
let old = Account::new(master(), 1).unwrap().me().pk();
let new = Account::new(master(), 2).unwrap().me().pk();
store.accept("bob@example.org", &old).unwrap();
store.accept("bob@example.org", &new).unwrap();
assert_eq!(
store.accepted_identity("bob@example.org").unwrap(),
Some(old)
);
assert!(store.block("bob@example.org").unwrap());
assert_eq!(store.accepted_identity("bob@example.org").unwrap(), None);
// Blocking again is a harmless no-op; only a never-accepted address
// is an error, which the caller reports.
assert!(store.block("bob@example.org").unwrap());
assert!(!store.block("nobody@example.org").unwrap());
}
#[test]
fn address_of_matches_contacts_then_reply_to() {
let store = temp_store("address-of");
let bob = Account::new(master(), 1).unwrap().me().pk();
store.save_contact("bob@example.org", &bob, true).unwrap();
assert_eq!(
store.address_of(&bob, None).unwrap().as_deref(),
Some("bob@example.org")
);
// A Reply-To only names a signer whose key it carries (sec 5.7).
let carol = Account::new(master(), 2).unwrap().me().pk();
let uri = format!("smol://carol@example.org/{}", crate::crypto::b32(&carol));
assert_eq!(
store.address_of(&carol, Some(&uri)).unwrap().as_deref(),
Some("carol@example.org")
);
// Key mismatch: ordinary text, no address learned.
assert_eq!(
store
.address_of(
&carol,
Some(&format!(
"smol://mallory@example.org/{}",
crate::crypto::b32(&bob)
))
)
.unwrap(),
None
);
}
#[test]
fn inbox_and_sent_store_sealed_and_deduplicate() {
let store = temp_store("mail");
let id = [3u8; 32];
store.store_inbox(&id, b"envelope", 100, false).unwrap();
// A replayed id is not re-stored (sec 10).
store.store_inbox(&id, b"envelope2", 100, true).unwrap();
assert!(store.seen(&id).unwrap());
let inbox = store.mail("inbox").unwrap();
assert_eq!(inbox.len(), 1);
assert_eq!(inbox[0].envelope, b"envelope");
assert_eq!(inbox[0].at, 100);
store
.store_sent(&id, "bob@example.org", b"sent-copy", 200)
.unwrap();
let sent = store.mail("sent").unwrap();
assert_eq!(sent.len(), 1);
assert_eq!(sent[0].recipient.as_deref(), Some("bob@example.org"));
}
#[test]
fn pins_and_contacts_round_trip() {
let store = temp_store("pins");
assert!(store.server_pin("example.org").unwrap().is_none());
store.pin_server("example.org", &[5u8; 32]).unwrap();
assert_eq!(store.server_pin("example.org").unwrap(), Some([5u8; 32]));
store.pin_server("example.org", &[6u8; 32]).unwrap();
assert_eq!(store.server_pin("example.org").unwrap(), Some([6u8; 32]));
let key = Account::new(master(), 1).unwrap().me().pk();
assert!(store.contact("bob@example.org").unwrap().is_none());
store.save_contact("bob@example.org", &key, false).unwrap();
assert_eq!(
store.contact("bob@example.org").unwrap(),
Some((key, false))
);
store.save_contact("bob@example.org", &key, true).unwrap();
assert_eq!(store.contact("bob@example.org").unwrap(), Some((key, true)));
}
#[test]
fn identity_seed_is_derived_not_stored() {
// A client must be able to re-derive every superseded key (sec 7).
let account = Account::new(master(), 4).unwrap();
assert_eq!(account.keys().len(), 5);
for (n, key) in account.keys().iter().enumerate() {
assert_eq!(
key.pk(),
Account::new(master(), n as u32).unwrap().me().pk()
);
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);
}
}

179
core/src/tcp.rs Normal file
View file

@ -0,0 +1,179 @@
//! The TCP carrier: a Noise_NX initiator session with server-key pinning
//! layered on top (SPEC.md sec 4), and the framed transport over it.
use std::io::{Read, Write};
use std::net::TcpStream;
use std::time::Duration;
use snow::{Builder, TransportState};
use crate::crypto::{ct_eq, KEY_LEN};
use crate::error::Error;
use crate::transport::{
Response, Transport, TransportBindValues, MAX_FRAME, NOISE_PARAMS, NOISE_PAYLOAD, PROLOGUE,
};
/// A pinned-key mismatch is a hard abort: the discrepancy is surfaced, never
/// clicked through, because the pin is the entire trust model.
pub struct TcpTransport {
stream: TcpStream,
noise: TransportState,
buf: Vec<u8>,
bind: TransportBindValues,
pinned: bool,
host: String,
}
impl TcpTransport {
/// Runs the Noise_NX handshake as the initiator. The NX pattern has the
/// server transmit its static key during the handshake, so pinning is a
/// single code path: an existing pin must match, an absent one is
/// accepted but reported as unpinned by `pinned()`.
pub fn connect(
host: &str,
port: u16,
pinned: Option<[u8; KEY_LEN]>,
timeout: u64,
) -> 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;
let params: snow::params::NoiseParams = NOISE_PARAMS.parse()?;
let mut noise = Builder::new(params).prologue(PROLOGUE).build_initiator()?;
let mut buf = [0u8; 65535];
let len = noise.write_message(&[], &mut buf)?;
write_u16_len(&mut stream, &buf[..len])?;
let len = read_u16_len(&mut stream)?;
let mut message = vec![0u8; len];
stream.read_exact(&mut message)?;
noise.read_message(&message, &mut buf)?;
if !noise.is_handshake_finished() {
return Err(Error::Other("handshake did not complete".into()));
}
let server_static: [u8; KEY_LEN] = noise
.get_remote_static()
.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(Error::PinMismatch {
host: host.to_string(),
pinned,
presented: server_static,
});
}
}
Ok(TcpTransport {
stream,
noise: transport,
buf: Vec::new(),
bind,
pinned: pinned.is_some(),
host: host.to_string(),
})
}
/// The server's Noise static key, what REGISTER binds its proof of
/// possession to and what `trust` pins.
pub fn server_static(&self) -> &[u8; KEY_LEN] {
&self.bind.server_static
}
pub fn host(&self) -> &str {
&self.host
}
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)?;
let mut plaintext = vec![0u8; len];
let n = self.noise.read_message(&ciphertext, &mut plaintext)?;
plaintext.truncate(n);
Ok(plaintext)
}
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);
write_u16_len(&mut self.stream, &packet)?;
Ok(())
}
}
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, Error> {
let length = 1 + body.len();
if length > MAX_FRAME {
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());
frame.push(op);
frame.extend_from_slice(body);
for chunk in frame.chunks(NOISE_PAYLOAD) {
self.write_noise(chunk)?;
}
while self.buf.len() < 5 {
let chunk = self.read_noise()?;
self.buf.extend_from_slice(&chunk);
}
let length = u32::from_be_bytes(self.buf[..4].try_into().unwrap()) as usize;
if length == 0 || length > MAX_FRAME {
return Err(Error::Other(format!(
"server sent a frame of length {length}"
)));
}
while self.buf.len() < 4 + length {
let chunk = self.read_noise()?;
self.buf.extend_from_slice(&chunk);
}
// Frame layout: u32 length || op (echoed) || status || payload.
let status = self.buf[5];
let body: Vec<u8> = self.buf.drain(..4 + length).skip(6).collect();
Ok(Response { status, body })
}
fn bind(&self) -> &TransportBindValues {
&self.bind
}
fn pinned(&self) -> bool {
self.pinned
}
fn close(&mut self) {
let _ = self.stream.shutdown(std::net::Shutdown::Both);
}
}
fn read_u16_len(stream: &mut TcpStream) -> std::io::Result<usize> {
let mut len_buf = [0u8; 2];
stream.read_exact(&mut len_buf)?;
Ok(u16::from_be_bytes(len_buf) as usize)
}
fn write_u16_len(stream: &mut TcpStream, packet: &[u8]) -> std::io::Result<()> {
stream.write_all(&(packet.len() as u16).to_be_bytes())?;
stream.write_all(packet)
}

211
core/src/transport.rs Normal file
View file

@ -0,0 +1,211 @@
//! The transport boundary: operation and status constants, the bind values
//! AUTH and REGISTER signatures depend on, and the trait both carriers
//! implement. Only the bind values and the framing differ between them; every
//! operation body is byte-identical (RNS.md sec 13.5, sec 15).
// Used only by the RNS bind values and their tests.
#[cfg(any(test, feature = "rns"))]
use crate::crypto::sha256;
use crate::error::Error;
pub const NOISE_PARAMS: &str = "Noise_NX_25519_ChaChaPoly_SHA256";
pub const PROLOGUE: &[u8] = b"smolmail/1";
pub const LABEL_AUTH: &[u8] = b"smolmail/1 auth";
pub const LABEL_ID: &[u8] = b"smolmail/1 id";
pub const LABEL_SEAL: &[u8] = b"smolmail/1 seal";
pub const LABEL_MSG: &[u8] = b"smolmail/1 msg";
pub const LABEL_MAC: &[u8] = b"smolmail/1 mac";
pub const LABEL_ROTATE: &[u8] = b"smolmail/1 rotate";
pub const LABEL_REGISTER: &[u8] = b"smolmail/1 register";
pub const LABEL_IDENTITY: &[u8] = b"smolmail/1 identity";
pub const LABEL_ACCEPT: &[u8] = b"smolmail/1 accept";
/// RNS.md sec 14: added in 1.2 for the link binding of sec 13.6.
#[cfg(any(test, feature = "rns"))]
pub const LABEL_BIND: &[u8] = b"smolmail/1 bind";
pub const OP_AUTH: u8 = 0x00;
pub const OP_RESOLVE: u8 = 0x01;
pub const OP_SEND: u8 = 0x02;
pub const OP_FETCH: u8 = 0x03;
pub const OP_DELETE: u8 = 0x04;
pub const OP_REGISTER: u8 = 0x05;
pub const ENVELOPE_MAGIC: &[u8; 4] = b"SMOL";
pub const ENVELOPE_VERSION: u8 = 1;
pub const ENVELOPE_HEADER: usize = 69; // magic 4 + version 1 + to 32 + epk 32
pub const ENVELOPE_MIN: usize = ENVELOPE_HEADER + 16; // + Poly1305 tag
pub const PAYLOAD_HEADER: usize = 45; // version 1 + sender 32 + time 8 + body_len 4
pub const KEY_LEN: usize = 32;
pub const ID_LEN: usize = 32;
pub const SIG_LEN: usize = 64;
pub const CERT_LEN: usize = 200; // old_pub 32 + new_pub 32 + time 8 + sig_old 64 + sig_new 64
pub const TOKEN_LEN: usize = 32;
pub const MAX_CHAIN: usize = 16;
pub const MAX_FRAME: usize = 1 << 20; // application frame ceiling (sec 4)
pub const NOISE_PAYLOAD: usize = 65535 - 16; // Noise message ceiling minus the tag
/// Tiers a fetched message can land in (sec 5.8).
pub const TIER_MAIN: u8 = 0;
pub const TIER_REQUESTS: u8 = 1;
/// flags bit 0: the message arrived without a matching accept token.
pub const FLAG_REQUESTS: u8 = 0x01;
/// A parsed response frame: the status byte and everything after it.
pub struct Response {
pub status: u8,
pub body: Vec<u8>,
}
/// Fail-closed reader over a response body. Every parse path errors rather
/// than reading past the end, so a truncated response can never be mistaken
/// for a short but valid one.
pub struct Reader<'a> {
buf: &'a [u8],
pos: usize,
}
impl<'a> Reader<'a> {
pub fn new(buf: &'a [u8]) -> Self {
Reader { buf, pos: 0 }
}
pub fn take(&mut self, n: usize) -> Result<&'a [u8], Error> {
if self.pos + n > self.buf.len() {
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, Error> {
Ok(self.take(1)?[0])
}
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, Error> {
let b = self.take(4)?;
Ok(u32::from_be_bytes(b.try_into().unwrap()))
}
pub fn i64(&mut self) -> Result<i64, Error> {
let b = self.take(8)?;
Ok(i64::from_be_bytes(b.try_into().unwrap()))
}
pub fn rest(&mut self) -> &'a [u8] {
let out = &self.buf[self.pos..];
self.pos = self.buf.len();
out
}
pub fn done(&self) -> Result<(), Error> {
if self.pos != self.buf.len() {
return Err(Error::Other("trailing bytes in response".into()));
}
Ok(())
}
}
/// Transport-supplied values that AUTH and REGISTER signatures bind to. Both
/// carriers prove the same thing — that the peer holds the identity key and
/// is talking to this server, not a replayed capture of another — but the
/// inputs differ (RNS.md sec 13.6), so the session layer consumes this
/// struct instead of a Noise handshake hash.
pub struct TransportBindValues {
/// What AUTH signs: the Noise handshake hash, or its RNS substitute.
pub h: [u8; 32],
/// What REGISTER signs: the server's static key, or its RNS substitute.
pub server_static: [u8; 32],
}
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]) -> Result<Self, Error> {
Ok(Self {
h: handshake_hash
.try_into()
.map_err(|_| Error::Other("handshake hash not 32 bytes".into()))?,
server_static: *server_static,
})
}
/// RNS has no static key of ours on the wire, so both values are derived
/// from the destination; link_id keeps one link's AUTH from replaying on
/// another (RNS.md sec 13.6). Neither input is length-prefixed, and both
/// are fixed-width, so concatenation stays unambiguous.
#[cfg(any(test, feature = "rns"))]
pub fn rns(destination: &[u8; 16], link_id: &[u8; 16]) -> Self {
Self {
h: sha256(&[LABEL_BIND, &destination[..], &link_id[..]]),
server_static: sha256(&[LABEL_BIND, &destination[..]]),
}
}
}
/// One request/response exchange per carrier. `request` sends one application
/// frame and returns the peer's answer; `bind` supplies the values the
/// operation layer signs over; `pinned` reports whether this session's
/// 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, Error>;
fn bind(&self) -> &TransportBindValues;
fn pinned(&self) -> bool;
fn close(&mut self);
}
#[cfg(test)]
mod tests {
use super::*;
const DEST: [u8; 16] = [
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e,
0x0f,
];
const LINK: [u8; 16] = [
0xa0, 0xa1, 0xa2, 0xa3, 0xa4, 0xa5, 0xa6, 0xa7, 0xa8, 0xa9, 0xaa, 0xab, 0xac, 0xad, 0xae,
0xaf,
];
#[test]
fn rns_matches_reference_vectors() {
// 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),
rb.server_static_hex
);
}
#[test]
fn rns_h_differs_per_link_but_server_static_does_not() {
let other = [0xc0u8; 16];
let a = TransportBindValues::rns(&DEST, &LINK);
let b = TransportBindValues::rns(&DEST, &other);
assert_ne!(a.h, b.h);
assert_eq!(a.server_static, b.server_static);
}
#[test]
fn tcp_rejects_short_handshake_hash() {
let static_key = [7u8; KEY_LEN];
assert!(TransportBindValues::tcp(&[1u8; 31], &static_key).is_err());
let bind = TransportBindValues::tcp(&[2u8; 32], &static_key).unwrap();
assert_eq!(bind.h, [2u8; 32]);
assert_eq!(bind.server_static, static_key);
}
}

119
core/src/vectors.rs Normal file
View 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")
}
}