kirakira/native/src/lib.rs

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"),
}
}
}