fix: read back sent copies after the sec 5.6 upstream fix, pinned at 2d65d66
This commit is contained in:
parent
52947e153d
commit
d6f4c434fa
36 changed files with 4223 additions and 3118 deletions
450
native/src/lib.rs
Normal file
450
native/src/lib.rs
Normal file
|
|
@ -0,0 +1,450 @@
|
|||
// 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(
|
||||
"g5ttsssqjq6j37vso3h7otn33zrk6o6r7c6jjm7guxbbijoa5nqq",
|
||||
)
|
||||
.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", &server).unwrap();
|
||||
bob_store.pin_server("127.0.0.1", &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(
|
||||
"g5ttsssqjq6j37vso3h7otn33zrk6o6r7c6jjm7guxbbijoa5nqq",
|
||||
)
|
||||
.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", &server).unwrap();
|
||||
bob_store.pin_server("127.0.0.1", &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(
|
||||
"g5ttsssqjq6j37vso3h7otn33zrk6o6r7c6jjm7guxbbijoa5nqq",
|
||||
)
|
||||
.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, &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 }) => assert_eq!(host, "127.0.0.1"),
|
||||
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", &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(
|
||||
"g5ttsssqjq6j37vso3h7otn33zrk6o6r7c6jjm7guxbbijoa5nqq",
|
||||
)
|
||||
.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", &server).unwrap();
|
||||
alice_store.pin_server("127.0.0.1", &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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue