453 lines
18 KiB
Rust
453 lines
18 KiB
Rust
// C ABI over fumi-core for the Dart FFI binding; the app links the cdylib.
|
|
// One smoke function for the spike; the real surface lives in `ffi`.
|
|
|
|
pub mod ffi;
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use fumi_core::{account::Identity, message};
|
|
use rand_core::{OsRng, RngCore};
|
|
use std::time::Instant;
|
|
|
|
#[test]
|
|
fn store_round_trip() {
|
|
assert_eq!(crate::ffi::smol_smoke(), 0);
|
|
}
|
|
|
|
/// Spike benchmark: seal/unseal throughput with a 2 KiB body, to compare
|
|
/// against the Dart core's numbers (test/perf_smoke_test.dart). Print
|
|
/// only; the assertions pin correctness, not speed.
|
|
#[test]
|
|
fn envelope_perf() {
|
|
let mut seed = [0u8; 32];
|
|
OsRng.fill_bytes(&mut seed);
|
|
let sender = Identity::from_seed(seed);
|
|
OsRng.fill_bytes(&mut seed);
|
|
let recipient = Identity::from_seed(seed);
|
|
let body = vec![b'x'; 2048];
|
|
const N: usize = 100;
|
|
|
|
let t0 = Instant::now();
|
|
let envelopes: Vec<Vec<u8>> = (0..N)
|
|
.map(|i| message::seal(&sender, &recipient.pk(), &body, i as i64, true).unwrap())
|
|
.collect();
|
|
let seal_dt = t0.elapsed();
|
|
|
|
let t1 = Instant::now();
|
|
for (i, envelope) in envelopes.iter().enumerate() {
|
|
let opened = message::unseal(&[recipient.clone()], envelope, 0).unwrap();
|
|
assert_eq!(opened.body.len(), 2048);
|
|
assert_eq!(opened.sender, sender.pk());
|
|
assert_eq!(opened.time, i as i64);
|
|
}
|
|
let open_dt = t1.elapsed();
|
|
|
|
println!(
|
|
"rust: seal {N} in {seal_dt:?} ({:.0} us/msg), unseal {N} in {open_dt:?} ({:.0} us/msg)",
|
|
seal_dt.as_micros() as f64 / N as f64,
|
|
open_dt.as_micros() as f64 / N as f64,
|
|
);
|
|
}
|
|
|
|
/// Spike round-trip against a live bunshin on 127.0.0.1:19619; ignored
|
|
/// because it needs the server (bunshin serve --key ... --port 19619).
|
|
/// Covers the embeddable path end to end: pin, REGISTER, SEND, FETCH.
|
|
#[test]
|
|
#[ignore]
|
|
fn bunshin_round_trip() {
|
|
use fumi_core::account::Account;
|
|
use fumi_core::address::Address;
|
|
use fumi_core::client::{fetch, register, send, SendDraft};
|
|
use fumi_core::crypto::unb32;
|
|
use fumi_core::store::Store;
|
|
use fumi_core::transport::KEY_LEN;
|
|
use rand_core::{OsRng, RngCore};
|
|
|
|
let server: [u8; KEY_LEN] = unb32(
|
|
"wukbhdiwboarbg4pujxstbqi3caveunrruaopbwxr226ga3xv5ga",
|
|
)
|
|
.unwrap()
|
|
.try_into()
|
|
.unwrap();
|
|
|
|
let mut master = [0u8; 32];
|
|
let mut identity = || {
|
|
OsRng.fill_bytes(&mut master);
|
|
(Store::open_in_memory().unwrap(), Account::new(master, 0).unwrap())
|
|
};
|
|
let (alice_store, alice) = identity();
|
|
let (bob_store, bob) = identity();
|
|
|
|
// Random usernames: the server persists registrations, so fixed ones
|
|
// would collide on the second run of this test.
|
|
let mut suffix = [0u8; 2];
|
|
OsRng.fill_bytes(&mut suffix);
|
|
let run = fumi_core::crypto::b32(&suffix);
|
|
let alice_addr =
|
|
Address::parse(&format!("a{run}@127.0.0.1:19619")).unwrap();
|
|
let bob_addr = Address::parse(&format!("b{run}@127.0.0.1:19619")).unwrap();
|
|
alice_store.pin_server("127.0.0.1", 19619, &server).unwrap();
|
|
bob_store.pin_server("127.0.0.1", 19619, &server).unwrap();
|
|
|
|
register(&alice_store, &alice_addr, &alice, None, 5).unwrap();
|
|
register(&bob_store, &bob_addr, &bob, None, 5).unwrap();
|
|
|
|
let draft = SendDraft {
|
|
address: &bob_addr,
|
|
text: "spike: hello over fumi".into(),
|
|
subject: Some("spike"),
|
|
reply_to: None,
|
|
headers: &[],
|
|
anonymous: false,
|
|
no_pad: false,
|
|
};
|
|
let sent = send(&alice_store, &alice, &draft, 5).unwrap();
|
|
assert_eq!(sent.warning, None);
|
|
|
|
let fetched = fetch(&bob_store, &bob, false, false, 5).unwrap();
|
|
assert_eq!(fetched.stored, 1);
|
|
// First contact without an accept token lands in the requests tier
|
|
// (sec 5.8), not the inbox.
|
|
let stored = &bob_store.mail("requests").unwrap()[0];
|
|
let opened = fumi_core::client::describe(&bob_store, &bob, stored).unwrap();
|
|
assert_eq!(opened.text, "spike: hello over fumi");
|
|
assert_eq!(opened.subject, "spike");
|
|
assert_eq!(opened.sender, alice.keys()[0].pk());
|
|
assert_eq!(opened.from, alice_addr.short());
|
|
|
|
// sec 5.6, the cross-recipient case self-sends mask: the sent copy
|
|
// is sealed to the sender's own key, so alice can read back what she
|
|
// sent to bob even though bob's envelope used a discarded ephemeral.
|
|
let sent = &alice_store.mail("sent").unwrap()[0];
|
|
let reread =
|
|
fumi_core::client::describe(&alice_store, &alice, sent).unwrap();
|
|
assert_eq!(reread.text, "spike: hello over fumi");
|
|
assert_eq!(reread.subject, "spike");
|
|
}
|
|
|
|
/// Spike: cancellation and resume, against the same live bunshin. Four
|
|
/// ~400 KiB envelopes exceed the server's 512 KiB fetch budget, so each
|
|
/// page carries one message and the between-page cancel check is
|
|
/// reachable. Phase A is deterministic (flag pre-set); phase B cancels
|
|
/// from a watcher thread the moment the first message commits — which is
|
|
/// also the concurrent-reader check, since the store is read while the
|
|
/// fetch holds it.
|
|
#[test]
|
|
#[ignore]
|
|
fn bunshin_cancel_and_resume() {
|
|
use fumi_core::account::Account;
|
|
use fumi_core::address::Address;
|
|
use fumi_core::client::{fetch, fetch_with, register, send, FetchOptions, SendDraft};
|
|
use fumi_core::crypto::unb32;
|
|
use fumi_core::store::Store;
|
|
use fumi_core::transport::KEY_LEN;
|
|
use rand_core::{OsRng, RngCore};
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
use std::sync::Arc;
|
|
|
|
let server: [u8; KEY_LEN] = unb32(
|
|
"wukbhdiwboarbg4pujxstbqi3caveunrruaopbwxr226ga3xv5ga",
|
|
)
|
|
.unwrap()
|
|
.try_into()
|
|
.unwrap();
|
|
|
|
let mut master = [0u8; 32];
|
|
let mut identity = || {
|
|
OsRng.fill_bytes(&mut master);
|
|
(Store::open_in_memory().unwrap(), Account::new(master, 0).unwrap())
|
|
};
|
|
let (alice_store, alice) = identity();
|
|
let bob_store = Arc::new(Store::open_in_memory().unwrap());
|
|
let mut bob_master = [0u8; 32];
|
|
OsRng.fill_bytes(&mut bob_master);
|
|
let bob = Account::new(bob_master, 0).unwrap();
|
|
|
|
let mut suffix = [0u8; 2];
|
|
OsRng.fill_bytes(&mut suffix);
|
|
let run = fumi_core::crypto::b32(&suffix);
|
|
let alice_addr = Address::parse(&format!("a{run}@127.0.0.1:19619")).unwrap();
|
|
let bob_addr = Address::parse(&format!("b{run}@127.0.0.1:19619")).unwrap();
|
|
alice_store.pin_server("127.0.0.1", 19619, &server).unwrap();
|
|
bob_store.pin_server("127.0.0.1", 19619, &server).unwrap();
|
|
register(&alice_store, &alice_addr, &alice, None, 5).unwrap();
|
|
register(&bob_store, &bob_addr, &bob, None, 5).unwrap();
|
|
|
|
let big = "x".repeat(400 * 1024);
|
|
for i in 0..4 {
|
|
let draft = SendDraft {
|
|
address: &bob_addr,
|
|
text: format!("{i}\n{big}"),
|
|
subject: None,
|
|
reply_to: None,
|
|
headers: &[],
|
|
anonymous: false,
|
|
no_pad: false,
|
|
};
|
|
send(&alice_store, &alice, &draft, 5).unwrap();
|
|
}
|
|
|
|
// Phase A: the flag is checked before the first page, so nothing is
|
|
// fetched and nothing is acknowledged.
|
|
let cancel = AtomicBool::new(true);
|
|
let a = fetch_with(
|
|
&bob_store,
|
|
&bob,
|
|
FetchOptions {
|
|
keep: false,
|
|
reset: false,
|
|
dial: None,
|
|
timeout: 5,
|
|
cancel: Some(&cancel),
|
|
},
|
|
)
|
|
.unwrap();
|
|
assert!(a.cancelled);
|
|
assert_eq!(a.stored, 0);
|
|
|
|
// Phase B: a reader thread watches the store fill and raises the
|
|
// flag after the first commit; the fetch stops between pages.
|
|
cancel.store(false, Ordering::Relaxed);
|
|
let flag = Arc::new(AtomicBool::new(false));
|
|
let thread_flag = Arc::clone(&flag);
|
|
let watcher_store = Arc::clone(&bob_store);
|
|
let watcher = std::thread::spawn(move || {
|
|
while watcher_store.mail("all").unwrap().len() < 1 {
|
|
std::thread::sleep(std::time::Duration::from_millis(1));
|
|
}
|
|
thread_flag.store(true, Ordering::Relaxed);
|
|
});
|
|
let b = fetch_with(
|
|
&bob_store,
|
|
&bob,
|
|
FetchOptions {
|
|
keep: false,
|
|
reset: false,
|
|
dial: None,
|
|
timeout: 5,
|
|
cancel: Some(&flag),
|
|
},
|
|
)
|
|
.unwrap();
|
|
assert!(b.cancelled, "watcher should have raised the flag mid-fetch");
|
|
assert!(b.stored >= 1 && b.stored < 4);
|
|
watcher.join().unwrap();
|
|
|
|
// Resume: an uncancelled fetch delivers the rest, and the seen-id
|
|
// set keeps the acknowledged pages from coming back as duplicates.
|
|
let c = fetch(&bob_store, &bob, false, false, 5).unwrap();
|
|
assert_eq!(b.stored + c.stored, 4);
|
|
assert_eq!(bob_store.mail("all").unwrap().len(), 4);
|
|
}
|
|
|
|
/// The Android regression: a hostname the native resolver cannot
|
|
/// resolve (the device's getaddrinfo is dead for app processes), pinned
|
|
/// by name, dialed by the IP the app resolved itself. Registration must
|
|
/// succeed and the account must keep the hostname identity.
|
|
#[test]
|
|
#[ignore]
|
|
fn bunshin_dial_hint_routes_by_ip() {
|
|
use fumi_core::client::register;
|
|
use fumi_core::crypto::unb32;
|
|
use fumi_core::store::Store;
|
|
use fumi_core::transport::KEY_LEN;
|
|
use rand_core::{OsRng, RngCore};
|
|
|
|
let server: [u8; KEY_LEN] = unb32(
|
|
"wukbhdiwboarbg4pujxstbqi3caveunrruaopbwxr226ga3xv5ga",
|
|
)
|
|
.unwrap()
|
|
.try_into()
|
|
.unwrap();
|
|
let mut master = [0u8; 32];
|
|
OsRng.fill_bytes(&mut master);
|
|
let store = Store::open_in_memory().unwrap();
|
|
let account = fumi_core::account::Account::new(master, 0).unwrap();
|
|
|
|
let mut suffix = [0u8; 2];
|
|
OsRng.fill_bytes(&mut suffix);
|
|
let run = fumi_core::crypto::b32(&suffix);
|
|
let host = format!("unresolvable-{run}.invalid");
|
|
let addr =
|
|
fumi_core::address::Address::parse(&format!("u{run}@{host}:19619"))
|
|
.unwrap()
|
|
.with_dial("127.0.0.1");
|
|
store.pin_server(&host, 19619, &server).unwrap();
|
|
register(&store, &addr, &account, None, 5).unwrap();
|
|
assert_eq!(store.account().unwrap().unwrap().short(),
|
|
format!("u{run}@{host}:19619"));
|
|
}
|
|
|
|
/// Spike: the error variants the onboarding routes on. No pin and a
|
|
/// wrong pin are distinct and matchable — the two branches that decide
|
|
/// whether the register step frames itself as "pin the key" (§4) or
|
|
/// aborts. Needs the live bunshin but changes no server state.
|
|
#[test]
|
|
#[ignore]
|
|
fn bunshin_pin_errors() {
|
|
use fumi_core::address::Address;
|
|
use fumi_core::client::connect;
|
|
use fumi_core::error::Error;
|
|
use fumi_core::store::Store;
|
|
use fumi_core::transport::KEY_LEN;
|
|
use rand_core::{OsRng, RngCore};
|
|
|
|
let addr = Address::parse("nobody@127.0.0.1:19619").unwrap();
|
|
|
|
let unpinned = Store::open_in_memory().unwrap();
|
|
match connect(&unpinned, &addr, true, 5) {
|
|
Err(Error::NotPinned { host, port }) => {
|
|
assert_eq!(host, "127.0.0.1");
|
|
assert_eq!(port, 19619);
|
|
}
|
|
Err(err) => panic!("expected NotPinned, got {err}"),
|
|
Ok(_) => panic!("expected NotPinned, connected"),
|
|
}
|
|
|
|
let mut wrong = [0u8; KEY_LEN];
|
|
OsRng.fill_bytes(&mut wrong);
|
|
let mismatched = Store::open_in_memory().unwrap();
|
|
mismatched.pin_server("127.0.0.1", 19619, &wrong).unwrap();
|
|
match connect(&mismatched, &addr, true, 5) {
|
|
Err(Error::PinMismatch { .. }) => {}
|
|
Err(err) => panic!("expected PinMismatch, got {err}"),
|
|
Ok(_) => panic!("expected PinMismatch, connected"),
|
|
}
|
|
}
|
|
|
|
/// Spike, bright path: a real rotation validates through the chain and
|
|
/// surfaces as `TrustChange::Rotated` — a warning, not an error. Carol
|
|
/// registers, Alice learns her key by sending, Carol rotates, and
|
|
/// Alice's next resolve walks the chain the server returns.
|
|
#[test]
|
|
#[ignore]
|
|
fn bunshin_rotation_bright() {
|
|
use fumi_core::account::Account;
|
|
use fumi_core::address::Address;
|
|
use fumi_core::client::{connect, register, rotate, send, trust_key, SendDraft};
|
|
use fumi_core::crypto::unb32;
|
|
use fumi_core::store::Store;
|
|
use fumi_core::transport::KEY_LEN;
|
|
use rand_core::{OsRng, RngCore};
|
|
|
|
let server: [u8; KEY_LEN] = unb32(
|
|
"wukbhdiwboarbg4pujxstbqi3caveunrruaopbwxr226ga3xv5ga",
|
|
)
|
|
.unwrap()
|
|
.try_into()
|
|
.unwrap();
|
|
|
|
let mut carol_master = [0u8; 32];
|
|
OsRng.fill_bytes(&mut carol_master);
|
|
let mut alice_master = [0u8; 32];
|
|
OsRng.fill_bytes(&mut alice_master);
|
|
let carol_store = Store::open_in_memory().unwrap();
|
|
let alice_store = Store::open_in_memory().unwrap();
|
|
let carol = Account::new(carol_master, 0).unwrap();
|
|
let alice = Account::new(alice_master, 0).unwrap();
|
|
|
|
let mut suffix = [0u8; 2];
|
|
OsRng.fill_bytes(&mut suffix);
|
|
let run = fumi_core::crypto::b32(&suffix);
|
|
let carol_addr = Address::parse(&format!("c{run}@127.0.0.1:19619")).unwrap();
|
|
let alice_addr = Address::parse(&format!("d{run}@127.0.0.1:19619")).unwrap();
|
|
carol_store.pin_server("127.0.0.1", 19619, &server).unwrap();
|
|
alice_store.pin_server("127.0.0.1", 19619, &server).unwrap();
|
|
register(&carol_store, &carol_addr, &carol, None, 5).unwrap();
|
|
register(&alice_store, &alice_addr, &alice, None, 5).unwrap();
|
|
|
|
let draft = SendDraft {
|
|
address: &carol_addr,
|
|
text: "before the rotation".into(),
|
|
subject: None,
|
|
reply_to: None,
|
|
headers: &[],
|
|
anonymous: false,
|
|
no_pad: false,
|
|
};
|
|
let sent = send(&alice_store, &alice, &draft, 5).unwrap();
|
|
assert!(matches!(
|
|
sent.change,
|
|
fumi_core::client::TrustChange::New { unverified: false }
|
|
));
|
|
|
|
rotate(&carol_store, &carol, None, 5).unwrap();
|
|
let carol_next = Account::new(carol_master, 1).unwrap();
|
|
|
|
let mut session = connect(&alice_store, &carol_addr, true, 5).unwrap();
|
|
let (pk, change) = {
|
|
let transport = session.transport();
|
|
trust_key(&alice_store, transport, &carol_addr).unwrap()
|
|
};
|
|
session.close();
|
|
assert!(matches!(change, fumi_core::client::TrustChange::Rotated));
|
|
assert_eq!(pk, carol_next.me().pk());
|
|
}
|
|
|
|
/// Spike, dark twin: RESOLVE returns a key with no chain to the one we
|
|
/// hold — the state a hijacked or re-registered username produces, and
|
|
/// the branch the onboarding must treat as terminal until the user
|
|
/// verifies out of band. A mock transport stands in for the server, so
|
|
/// this needs no live bunshin.
|
|
#[test]
|
|
fn keychanged_dark_twin() {
|
|
use fumi_core::address::Address;
|
|
use fumi_core::client::trust_key;
|
|
use fumi_core::error::Error;
|
|
use fumi_core::store::Store;
|
|
use fumi_core::transport::{
|
|
Response, Transport, TransportBindValues, KEY_LEN, OP_RESOLVE,
|
|
};
|
|
use rand_core::{OsRng, RngCore};
|
|
|
|
struct MockResolve {
|
|
offered: [u8; KEY_LEN],
|
|
bind: TransportBindValues,
|
|
}
|
|
impl Transport for MockResolve {
|
|
fn request(&mut self, op: u8, _body: &[u8]) -> Result<Response, Error> {
|
|
assert_eq!(op, OP_RESOLVE);
|
|
let mut body = Vec::with_capacity(KEY_LEN + 1);
|
|
body.extend_from_slice(&self.offered);
|
|
body.push(0); // no chain at all
|
|
Ok(Response { status: 0, body })
|
|
}
|
|
fn bind(&self) -> &TransportBindValues {
|
|
&self.bind
|
|
}
|
|
fn pinned(&self) -> bool {
|
|
true
|
|
}
|
|
fn close(&mut self) {}
|
|
}
|
|
|
|
let addr = Address::parse("dark@127.0.0.1:19619").unwrap();
|
|
let mut known = [0u8; KEY_LEN];
|
|
OsRng.fill_bytes(&mut known);
|
|
let mut offered = [0u8; KEY_LEN];
|
|
OsRng.fill_bytes(&mut offered);
|
|
|
|
let store = Store::open_in_memory().unwrap();
|
|
store.save_contact(&addr.short(), &known, false).unwrap();
|
|
let mut mock = MockResolve {
|
|
offered,
|
|
bind: TransportBindValues {
|
|
h: [0u8; 32],
|
|
server_static: [0u8; 32],
|
|
},
|
|
};
|
|
match trust_key(&store, &mut mock, &addr) {
|
|
Err(Error::KeyChanged {
|
|
address,
|
|
known: k,
|
|
offered: o,
|
|
}) => {
|
|
assert_eq!(address, addr.short());
|
|
assert_eq!(k, known);
|
|
assert_eq!(o, offered);
|
|
}
|
|
Err(err) => panic!("expected KeyChanged, got {err}"),
|
|
Ok(_) => panic!("expected KeyChanged, trusted the new key"),
|
|
}
|
|
}
|
|
}
|