refactor: split the library into fumi-core and make it embeddable
This commit is contained in:
parent
d3cd0afb4f
commit
8fb5661b53
28 changed files with 975 additions and 724 deletions
286
core/src/account.rs
Normal file
286
core/src/account.rs
Normal 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
276
core/src/address.rs
Normal 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
872
core/src/client.rs
Normal 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(®ister_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(),
|
||||
®ister,
|
||||
®ister_signed(&bind.server_static, "alice", &me.pk())
|
||||
));
|
||||
}
|
||||
}
|
||||
192
core/src/crypto.rs
Normal file
192
core/src/crypto.rs
Normal 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
193
core/src/error.rs
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
//! The failure type embedders match on, and the status-code mapping it is
|
||||
//! built from.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use crate::crypto::{b32, KEY_LEN};
|
||||
use crate::transport::MAX_CHAIN;
|
||||
|
||||
/// Every failure the library reports, distinguished the way a caller needs
|
||||
/// to decide what to do — no prose parsing. Each variant documents the
|
||||
/// decision it supports; `Display` still produces a user-readable message.
|
||||
#[derive(Debug)]
|
||||
pub enum Error {
|
||||
/// The wire status codes of SPEC.md sec 12, one variant each, carrying
|
||||
/// what was being attempted. `UnknownStatus` is an unassigned code.
|
||||
Malformed(String),
|
||||
BadVersion(String),
|
||||
UnknownUser(String),
|
||||
AuthRequired(String),
|
||||
AuthFailed(String),
|
||||
QuotaExceeded(String),
|
||||
TooLarge(String),
|
||||
RateLimited(String),
|
||||
NotPermitted(String),
|
||||
InternalError(String),
|
||||
UnknownStatus(u8, String),
|
||||
/// The host has no pinned server key, so the session cannot be
|
||||
/// authenticated (sec 4). A GUI routes this to pinning; an auth failure
|
||||
/// is terminal.
|
||||
NotPinned { host: String },
|
||||
/// A pinned server presented a different key (sec 4): a hard abort, the
|
||||
/// pin is the entire trust model.
|
||||
PinMismatch {
|
||||
host: String,
|
||||
pinned: [u8; KEY_LEN],
|
||||
presented: [u8; KEY_LEN],
|
||||
},
|
||||
/// A contact's key changed with no valid rotation chain (sec 7): the
|
||||
/// user confirms out of band and imports, rather than clicking through.
|
||||
KeyChanged {
|
||||
address: String,
|
||||
known: [u8; KEY_LEN],
|
||||
offered: [u8; KEY_LEN],
|
||||
},
|
||||
/// The store has no account yet; registering or restoring creates one.
|
||||
NotRegistered,
|
||||
/// The rotation index would exceed the chain limit (sec 2).
|
||||
ChainLimit { index: u32 },
|
||||
/// The store was written by a schema this build cannot read; a host
|
||||
/// decides when to migrate, not the library.
|
||||
SchemaVersion { found: i32, expected: i32 },
|
||||
/// The server could not be reached at all.
|
||||
Unreachable {
|
||||
host: String,
|
||||
port: u16,
|
||||
source: std::io::Error,
|
||||
},
|
||||
Storage(rusqlite::Error),
|
||||
Noise(snow::Error),
|
||||
Io(std::io::Error),
|
||||
/// Validation prose without a decision-relevant variant of its own.
|
||||
Other(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for Error {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Malformed(what) => write!(f, "{what} failed: malformed (1)"),
|
||||
Self::BadVersion(what) => write!(f, "{what} failed: bad version (2)"),
|
||||
Self::UnknownUser(what) => write!(f, "{what} failed: unknown user (3)"),
|
||||
Self::AuthRequired(what) => write!(f, "{what} failed: auth required (4)"),
|
||||
Self::AuthFailed(what) => write!(f, "{what} failed: auth failed (5)"),
|
||||
Self::QuotaExceeded(what) => write!(f, "{what} failed: quota exceeded (6)"),
|
||||
Self::TooLarge(what) => write!(f, "{what} failed: too large (7)"),
|
||||
Self::RateLimited(what) => write!(f, "{what} failed: rate limited (8)"),
|
||||
Self::NotPermitted(what) => write!(f, "{what} failed: not permitted (9)"),
|
||||
Self::InternalError(what) => write!(f, "{what} failed: internal error (10)"),
|
||||
Self::UnknownStatus(status, what) => {
|
||||
write!(f, "{what} failed: unknown status {status}")
|
||||
}
|
||||
Self::NotPinned { host } => write!(f, "no pinned key for {host}"),
|
||||
Self::PinMismatch {
|
||||
host,
|
||||
pinned,
|
||||
presented,
|
||||
} => write!(
|
||||
f,
|
||||
"{host} presented a different key than the one pinned\n pinned: {}\n presented: {}",
|
||||
b32(pinned),
|
||||
b32(presented)
|
||||
),
|
||||
Self::KeyChanged {
|
||||
address,
|
||||
known,
|
||||
offered,
|
||||
} => write!(
|
||||
f,
|
||||
"{address} presents a different key with no valid rotation chain.\n known: {}\n offered: {}",
|
||||
b32(known),
|
||||
b32(offered)
|
||||
),
|
||||
Self::NotRegistered => write!(f, "not registered"),
|
||||
Self::ChainLimit { index } => write!(
|
||||
f,
|
||||
"rotation index {index} exceeds the chain limit of {MAX_CHAIN}"
|
||||
),
|
||||
Self::SchemaVersion { found, expected } => write!(
|
||||
f,
|
||||
"store schema version {found} is not this build's {expected}"
|
||||
),
|
||||
Self::Unreachable { host, port, source } => {
|
||||
write!(f, "cannot reach {host}:{port}: {source}")
|
||||
}
|
||||
Self::Storage(e) => write!(f, "storage error: {e}"),
|
||||
Self::Noise(e) => write!(f, "noise error: {e}"),
|
||||
Self::Io(e) => write!(f, "{e}"),
|
||||
Self::Other(msg) => write!(f, "{msg}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Error {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
match self {
|
||||
Self::Unreachable { source, .. } => Some(source),
|
||||
Self::Storage(e) => Some(e),
|
||||
Self::Noise(e) => Some(e),
|
||||
Self::Io(e) => Some(e),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<rusqlite::Error> for Error {
|
||||
fn from(e: rusqlite::Error) -> Self {
|
||||
Error::Storage(e)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for Error {
|
||||
fn from(e: std::io::Error) -> Self {
|
||||
Error::Io(e)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<snow::Error> for Error {
|
||||
fn from(e: snow::Error) -> Self {
|
||||
Error::Noise(e)
|
||||
}
|
||||
}
|
||||
|
||||
/// Status codes from SPEC.md sec 12. Unassigned numbers map to None, so a
|
||||
/// caller cannot accidentally name one that does not exist.
|
||||
pub fn status_name(status: u8) -> Option<&'static str> {
|
||||
Some(match status {
|
||||
0 => "ok",
|
||||
1 => "malformed",
|
||||
2 => "bad version",
|
||||
3 => "unknown user",
|
||||
4 => "auth required",
|
||||
5 => "auth failed",
|
||||
6 => "quota exceeded",
|
||||
7 => "too large",
|
||||
8 => "rate limited",
|
||||
9 => "not permitted",
|
||||
10 => "internal error",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Turns a non-OK response into an error carrying the status code. The
|
||||
/// optional reason string in the response body is never parsed (SPEC.md sec
|
||||
/// 12); an unassigned code is surfaced by number and treated as a plain
|
||||
/// failure.
|
||||
pub fn expect_ok(status: u8, what: &str) -> Result<(), Error> {
|
||||
if status == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
let what = what.to_string();
|
||||
Err(match status {
|
||||
1 => Error::Malformed(what),
|
||||
2 => Error::BadVersion(what),
|
||||
3 => Error::UnknownUser(what),
|
||||
4 => Error::AuthRequired(what),
|
||||
5 => Error::AuthFailed(what),
|
||||
6 => Error::QuotaExceeded(what),
|
||||
7 => Error::TooLarge(what),
|
||||
8 => Error::RateLimited(what),
|
||||
9 => Error::NotPermitted(what),
|
||||
10 => Error::InternalError(what),
|
||||
status => Error::UnknownStatus(status, what),
|
||||
})
|
||||
}
|
||||
22
core/src/lib.rs
Normal file
22
core/src/lib.rs
Normal 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
426
core/src/message.rs
Normal 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
155
core/src/rns/ffi.rs
Normal 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
71
core/src/rns/mod.rs
Normal 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
83
core/src/rns/transport.rs
Normal 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
724
core/src/store.rs
Normal 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
179
core/src/tcp.rs
Normal 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
211
core/src/transport.rs
Normal 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
119
core/src/vectors.rs
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
//! The committed reference vectors (`vectors.json`) as the tests' source of
|
||||
//! truth, so the file and the derivations cannot drift apart. Language ports
|
||||
//! and FFI bindings pin the same operations against the same file without
|
||||
//! regenerating anything from the reference stack.
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::crypto::KEY_LEN;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct Vectors {
|
||||
/// The master every derivation below starts from, hex-encoded.
|
||||
pub master_hex: String,
|
||||
/// `identity_seed(master, n)` per rotation index, base32.
|
||||
pub identity_seeds_b32: std::collections::BTreeMap<String, String>,
|
||||
/// `Identity::from_seed(seed_n).pk()` per rotation index, base32.
|
||||
pub identity_pks_b32: std::collections::BTreeMap<String, String>,
|
||||
/// The X25519 half per rotation index, base32.
|
||||
pub identity_xprivs_b32: std::collections::BTreeMap<String, String>,
|
||||
pub accept_key_b32: String,
|
||||
/// `Account::new(master, 0).token_for(pk_1)`, base32.
|
||||
pub accept_token_for_pk1_b32: String,
|
||||
pub rotation_cert: RotationCertVector,
|
||||
pub accept_mac: AcceptMacVector,
|
||||
pub envelope: EnvelopeVector,
|
||||
pub hkdf_rfc5869_case1: HkdfVector,
|
||||
pub hmac_rfc4231_case1: HmacVector,
|
||||
/// `ed25519_to_x25519(pk_0)`, base32 — libsodium's pk_to_curve25519.
|
||||
pub ed25519_to_x25519_pk0_b32: String,
|
||||
pub rns_bind: RnsBindVector,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct RotationCertVector {
|
||||
pub username: String,
|
||||
pub old_index: u32,
|
||||
pub new_index: u32,
|
||||
pub when: i64,
|
||||
pub cert_hex: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct AcceptMacVector {
|
||||
/// The token is `token_for(pk_1)` over this message id, base32.
|
||||
pub message_id_b32: String,
|
||||
pub mac_hex: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct EnvelopeVector {
|
||||
pub sender_index: u32,
|
||||
pub recipient_index: u32,
|
||||
pub when: i64,
|
||||
/// The fixed ephemeral scalar the reference client used, hex.
|
||||
pub ephemeral_hex: String,
|
||||
pub padded_b32: String,
|
||||
pub unpadded_b32: String,
|
||||
pub message_id_hex: String,
|
||||
pub body: String,
|
||||
pub sender_pk_b32: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct HkdfVector {
|
||||
pub ikm_hex: String,
|
||||
pub salt_hex: String,
|
||||
pub info_hex: String,
|
||||
/// The first 32 bytes of the 42-byte OKM.
|
||||
pub okm_hex_32: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct HmacVector {
|
||||
pub key_hex: String,
|
||||
pub data: String,
|
||||
pub mac_hex: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct RnsBindVector {
|
||||
pub destination_hex: String,
|
||||
pub link_id_hex: String,
|
||||
pub h_hex: String,
|
||||
pub server_static_hex: String,
|
||||
}
|
||||
|
||||
pub fn load() -> Vectors {
|
||||
serde_json::from_str(include_str!("../vectors.json")).expect("vectors.json parses")
|
||||
}
|
||||
|
||||
fn b32_at(map: &std::collections::BTreeMap<String, String>, n: u32) -> &str {
|
||||
map.get(&n.to_string()).expect("vectors.json has the index")
|
||||
}
|
||||
|
||||
impl Vectors {
|
||||
pub fn master(&self) -> [u8; KEY_LEN] {
|
||||
data_encoding::HEXLOWER
|
||||
.decode(self.master_hex.as_bytes())
|
||||
.expect("master_hex decodes")
|
||||
.try_into()
|
||||
.expect("master is 32 bytes")
|
||||
}
|
||||
|
||||
pub fn seed_b32(&self, n: u32) -> &str {
|
||||
b32_at(&self.identity_seeds_b32, n)
|
||||
}
|
||||
|
||||
pub fn pk_b32(&self, n: u32) -> &str {
|
||||
b32_at(&self.identity_pks_b32, n)
|
||||
}
|
||||
|
||||
pub fn xpriv_b32(&self, n: u32) -> &str {
|
||||
b32_at(&self.identity_xprivs_b32, n)
|
||||
}
|
||||
|
||||
pub fn hex(&self, hex: &str) -> Vec<u8> {
|
||||
data_encoding::HEXLOWER.decode(hex.as_bytes()).expect("hex decodes")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue