chore: monorepo inicial con arje + minga + yahweh absorbidos
Workspace en 4 ejes (core/modules/apps/shared):
- core/: 24 crates de arje (Init systemd-compatible: ente-card, ente-zero,
ente-kernel, ente-bus, ente-cas, ente-soma, ente-wasm, ente-snapshot,
ente-brain, ente-echo, ente-policy-provider, + 12 crates *-compat)
- modules/semantic_dht/: 5 crates de minga (minga-core con AST/CAS/MST,
minga-p2p con libp2p Kad, minga-store, minga-vfs, minga-cli)
- modules/ui_engine/: 11 crates de yahweh (libs/{core,theme,bus,providers},
widgets/{tree,splitter,tabs,tiled,container_core,text_input})
- apps/: 5 crates de yahweh (file_explorer, database_explorer, text_viewer,
image_viewer, yahweh-shell)
- shared_wit/protocol.wit: handshake/lifecycle inicial
Cargo.toml unificado: thiserror bumped a 2 (transparente para arje), tokio
"full", paths intra-workspace de yahweh redirigidos a su nueva ubicación.
cargo check --workspace: 0 errores, 17 warnings (dead code preexistente).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,798 @@
|
||||
//! Invariantes del protocolo de sincronización recursivo.
|
||||
//!
|
||||
//! Tres familias de tests:
|
||||
//! - **Convergencia funcional**: tras `run_sync`, ambos peers tienen
|
||||
//! el mismo `root_hash`, `MemStore` equivalente, y reconstruyen los
|
||||
//! árboles bit a bit.
|
||||
//! - **Eficiencia estructural**: el short-circuit por hash de subárbol
|
||||
//! reduce probes y delivers cuando los repos comparten ramas.
|
||||
//! - **Seguridad**: el receptor verifica `hash_stored(stored) == hash`
|
||||
//! y rechaza nodos manipulados.
|
||||
|
||||
use minga_core::{
|
||||
cas::hash_components, hash_node, hash_stored, parse, ContentHash, Keypair, MemStore, Mst,
|
||||
NodeStore, Signature, StoredNode,
|
||||
};
|
||||
use minga_p2p::{run_sync, Message, SyncSession};
|
||||
|
||||
fn kp(seed: u8) -> Keypair {
|
||||
Keypair::from_seed(&[seed; 32])
|
||||
}
|
||||
|
||||
/// Helper que replica la construcción del payload firmado del `Hello`
|
||||
/// dentro del protocolo Minga. Usado por los tests que inyectan
|
||||
/// mensajes manualmente.
|
||||
fn hello_payload(nonce: &[u8; 32], did: &minga_core::Did, root: &ContentHash) -> [u8; 96] {
|
||||
let mut p = [0u8; 96];
|
||||
p[..32].copy_from_slice(nonce);
|
||||
p[32..64].copy_from_slice(&did.0);
|
||||
p[64..96].copy_from_slice(&root.0);
|
||||
p
|
||||
}
|
||||
|
||||
fn build_repo(sources: &[&str]) -> (Mst, MemStore, Vec<ContentHash>) {
|
||||
let mut mst = Mst::new();
|
||||
let mut store = MemStore::new();
|
||||
let mut roots = Vec::new();
|
||||
for src in sources {
|
||||
let n = parse::rust(src).unwrap();
|
||||
let h = store.put(&n);
|
||||
mst.insert(h);
|
||||
roots.push(h);
|
||||
}
|
||||
(mst, store, roots)
|
||||
}
|
||||
|
||||
// ─── Convergencia funcional ────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn sync_identical_is_noop() {
|
||||
let sources = &[
|
||||
"fn add(x: i32, y: i32) -> i32 { x + y }",
|
||||
"fn neg(x: i32) -> i32 { -x }",
|
||||
];
|
||||
let (mst_a, store_a, _) = build_repo(sources);
|
||||
let (mst_b, store_b, _) = build_repo(sources);
|
||||
|
||||
let mut a = SyncSession::without_attestations(mst_a, store_a, kp(1));
|
||||
let mut b = SyncSession::without_attestations(mst_b, store_b, kp(2));
|
||||
let stats = run_sync(&mut a, &mut b);
|
||||
|
||||
// Mismas raíces de MST: el short-circuit en Hello evita cualquier
|
||||
// probe o transferencia. Solo cruzan los 2 Hellos y los 2 Dones.
|
||||
assert_eq!(stats.hellos, 2);
|
||||
assert_eq!(stats.probe_reqs, 0);
|
||||
assert_eq!(stats.probe_ress, 0);
|
||||
assert_eq!(stats.fetches, 0);
|
||||
assert_eq!(stats.delivers, 0);
|
||||
assert_eq!(stats.dones, 2);
|
||||
|
||||
assert_eq!(a.mst().root_hash(), b.mst().root_hash());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_one_empty_pulls_everything() {
|
||||
let sources = &["fn f(x: i32) -> i32 { x * 2 }"];
|
||||
let (mst_a, store_a, _) = build_repo(sources);
|
||||
let (mst_b, store_b, _) = build_repo(&[]);
|
||||
|
||||
let mut a = SyncSession::without_attestations(mst_a, store_a, kp(1));
|
||||
let mut b = SyncSession::without_attestations(mst_b, store_b, kp(2));
|
||||
run_sync(&mut a, &mut b);
|
||||
|
||||
assert_eq!(a.mst().root_hash(), b.mst().root_hash());
|
||||
assert_eq!(a.store().len(), b.store().len());
|
||||
|
||||
for h in a.mst().iter() {
|
||||
assert!(b.store().contains(h));
|
||||
let a_tree = a.store().reconstruct(h).unwrap();
|
||||
let b_tree = b.store().reconstruct(h).unwrap();
|
||||
assert_eq!(a_tree, b_tree);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_disjoint_sets_merge() {
|
||||
let only_a = &[
|
||||
"fn alpha() -> i32 { 1 }",
|
||||
"fn beta(x: i32) -> i32 { x + 1 }",
|
||||
];
|
||||
let only_b = &[
|
||||
"fn gamma(y: i32) -> bool { y > 0 }",
|
||||
"fn delta() -> &'static str { \"hello\" }",
|
||||
];
|
||||
|
||||
let (mst_a, store_a, _) = build_repo(only_a);
|
||||
let (mst_b, store_b, _) = build_repo(only_b);
|
||||
|
||||
let mut a = SyncSession::without_attestations(mst_a, store_a, kp(1));
|
||||
let mut b = SyncSession::without_attestations(mst_b, store_b, kp(2));
|
||||
run_sync(&mut a, &mut b);
|
||||
|
||||
assert_eq!(a.mst().root_hash(), b.mst().root_hash());
|
||||
assert_eq!(a.mst().len(), 4);
|
||||
assert_eq!(b.mst().len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_partial_overlap_converges() {
|
||||
let common = &[
|
||||
"fn shared_one() -> i32 { 42 }",
|
||||
"fn shared_two(n: i32) -> i32 { n + 1 }",
|
||||
];
|
||||
let extra_a = &["fn only_in_a() -> bool { true }"];
|
||||
let extra_b = &["fn only_in_b(s: &str) -> usize { s.len() }"];
|
||||
|
||||
let mut sources_a: Vec<&str> = common.to_vec();
|
||||
sources_a.extend_from_slice(extra_a);
|
||||
let mut sources_b: Vec<&str> = common.to_vec();
|
||||
sources_b.extend_from_slice(extra_b);
|
||||
|
||||
let (mst_a, store_a, _) = build_repo(&sources_a);
|
||||
let (mst_b, store_b, _) = build_repo(&sources_b);
|
||||
|
||||
let mut a = SyncSession::without_attestations(mst_a, store_a, kp(1));
|
||||
let mut b = SyncSession::without_attestations(mst_b, store_b, kp(2));
|
||||
run_sync(&mut a, &mut b);
|
||||
|
||||
assert_eq!(a.mst().root_hash(), b.mst().root_hash());
|
||||
assert_eq!(a.mst().len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_transitive_children_pulled() {
|
||||
let big_src = r#"
|
||||
fn complicated(x: i32, y: i32) -> i32 {
|
||||
let a = x + y;
|
||||
let b = a * 2;
|
||||
match b {
|
||||
n if n > 100 => n - 50,
|
||||
n if n < 0 => -n,
|
||||
_ => b,
|
||||
}
|
||||
}
|
||||
"#;
|
||||
let (mst_a, store_a, roots) = build_repo(&[big_src]);
|
||||
let store_a_size = store_a.len();
|
||||
let root_hash = roots[0];
|
||||
|
||||
let (mst_b, store_b, _) = build_repo(&[]);
|
||||
|
||||
let mut a = SyncSession::without_attestations(mst_a, store_a, kp(1));
|
||||
let mut b = SyncSession::without_attestations(mst_b, store_b, kp(2));
|
||||
run_sync(&mut a, &mut b);
|
||||
|
||||
assert!(b.store().contains(&root_hash));
|
||||
assert_eq!(b.store().len(), store_a_size);
|
||||
|
||||
let a_tree = a.store().reconstruct(&root_hash).unwrap();
|
||||
let b_tree = b.store().reconstruct(&root_hash).unwrap();
|
||||
assert_eq!(a_tree, b_tree);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_idempotent_after_convergence() {
|
||||
let sources = &["fn p() -> i32 { 1 }", "fn q(x: i32) -> i32 { x + 1 }"];
|
||||
let (mst_a, store_a, _) = build_repo(sources);
|
||||
let (mst_b, store_b, _) = build_repo(&["fn r(y: i32) -> i32 { y - 1 }"]);
|
||||
|
||||
let mut a = SyncSession::without_attestations(mst_a, store_a, kp(1));
|
||||
let mut b = SyncSession::without_attestations(mst_b, store_b, kp(2));
|
||||
run_sync(&mut a, &mut b);
|
||||
|
||||
let (mst_a, store_a, _) = a.into_parts();
|
||||
let (mst_b, store_b, _) = b.into_parts();
|
||||
let mut a2 = SyncSession::without_attestations(mst_a, store_a, kp(1));
|
||||
let mut b2 = SyncSession::without_attestations(mst_b, store_b, kp(2));
|
||||
let stats = run_sync(&mut a2, &mut b2);
|
||||
|
||||
// Tras converger, la segunda corrida es 2 Hellos + 2 Dones, nada
|
||||
// estructural ni transferencias.
|
||||
assert_eq!(stats.probe_reqs, 0);
|
||||
assert_eq!(stats.probe_ress, 0);
|
||||
assert_eq!(stats.fetches, 0);
|
||||
assert_eq!(stats.delivers, 0);
|
||||
assert_eq!(stats.hellos, 2);
|
||||
assert_eq!(stats.dones, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_both_empty_terminates() {
|
||||
let mut a = SyncSession::without_attestations(Mst::new(), MemStore::new(), kp(1));
|
||||
let mut b = SyncSession::without_attestations(Mst::new(), MemStore::new(), kp(2));
|
||||
let stats = run_sync(&mut a, &mut b);
|
||||
assert_eq!(stats.hellos, 2);
|
||||
assert_eq!(stats.probe_reqs, 0);
|
||||
assert_eq!(stats.dones, 2);
|
||||
assert!(a.mst().is_empty());
|
||||
assert!(b.mst().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_three_way_via_pairwise_runs() {
|
||||
let sources_a = &["fn a1() -> i32 { 1 }", "fn shared() -> i32 { 0 }"];
|
||||
let sources_b = &["fn b1(x: i32) -> i32 { x }", "fn shared() -> i32 { 0 }"];
|
||||
let sources_c = &["fn c1() -> bool { true }"];
|
||||
|
||||
let (mst_a, store_a, _) = build_repo(sources_a);
|
||||
let (mst_b, store_b, _) = build_repo(sources_b);
|
||||
let (mst_c, store_c, _) = build_repo(sources_c);
|
||||
|
||||
let mut a = SyncSession::without_attestations(mst_a, store_a, kp(1));
|
||||
let mut b = SyncSession::without_attestations(mst_b, store_b, kp(2));
|
||||
run_sync(&mut a, &mut b);
|
||||
let (mst_a, store_a, _) = a.into_parts();
|
||||
let (mst_b, store_b, _) = b.into_parts();
|
||||
|
||||
let mut b = SyncSession::without_attestations(mst_b, store_b, kp(2));
|
||||
let mut c = SyncSession::without_attestations(mst_c, store_c, kp(3));
|
||||
run_sync(&mut b, &mut c);
|
||||
let (mst_b, _, _) = b.into_parts();
|
||||
let (mst_c, store_c, _) = c.into_parts();
|
||||
|
||||
let mut c = SyncSession::without_attestations(mst_c, store_c, kp(3));
|
||||
let mut a = SyncSession::without_attestations(mst_a, store_a, kp(1));
|
||||
run_sync(&mut c, &mut a);
|
||||
let (mst_c, _, _) = c.into_parts();
|
||||
let (mst_a, _, _) = a.into_parts();
|
||||
|
||||
assert_eq!(mst_a.root_hash(), mst_b.root_hash());
|
||||
assert_eq!(mst_b.root_hash(), mst_c.root_hash());
|
||||
assert_eq!(mst_a.len(), 4);
|
||||
}
|
||||
|
||||
// ─── Eficiencia estructural ────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn sync_subtree_short_circuit_skips_shared_branches() {
|
||||
// Construimos dos repos que comparten muchos nodos pero difieren en
|
||||
// uno. El short-circuit por hash de subárbol debería podar las
|
||||
// ramas compartidas: el número de probes y delivers debe estar
|
||||
// dominado por la divergencia, no por el tamaño total.
|
||||
let common: Vec<String> = (0..50)
|
||||
.map(|i| format!("fn shared_{}() -> i32 {{ {} }}", i, i))
|
||||
.collect();
|
||||
let common_refs: Vec<&str> = common.iter().map(|s| s.as_str()).collect();
|
||||
|
||||
let extra_a = "fn only_a() -> bool { true }".to_string();
|
||||
let mut sources_a: Vec<&str> = common_refs.clone();
|
||||
sources_a.push(&extra_a);
|
||||
|
||||
let extra_b = "fn only_b() -> bool { false }".to_string();
|
||||
let mut sources_b: Vec<&str> = common_refs.clone();
|
||||
sources_b.push(&extra_b);
|
||||
|
||||
let (mst_a, store_a, _) = build_repo(&sources_a);
|
||||
let (mst_b, store_b, _) = build_repo(&sources_b);
|
||||
let store_a_size = store_a.len();
|
||||
|
||||
let mut a = SyncSession::without_attestations(mst_a, store_a, kp(1));
|
||||
let mut b = SyncSession::without_attestations(mst_b, store_b, kp(2));
|
||||
let stats = run_sync(&mut a, &mut b);
|
||||
|
||||
assert_eq!(a.mst().root_hash(), b.mst().root_hash());
|
||||
|
||||
// Cota de eficiencia: cada peer debe pedir como máximo lo que
|
||||
// realmente le falta. En este escenario, cada peer ignora una sola
|
||||
// función nueva (~docena de StoredNodes). Si el short-circuit
|
||||
// estuviera roto, transferiríamos cerca del store entero (~varios
|
||||
// cientos). La cota es laxa pero detectaría esa regresión.
|
||||
assert!(
|
||||
stats.delivers < store_a_size / 2,
|
||||
"demasiados delivers ({}); esperaba << {}",
|
||||
stats.delivers,
|
||||
store_a_size,
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Seguridad: verificación criptográfica ─────────────────────────
|
||||
|
||||
#[test]
|
||||
fn cas_hash_node_equals_hash_stored() {
|
||||
// El invariante fundacional para verificación: hashear el árbol
|
||||
// como `SemanticNode` y como `StoredNode` produce idéntico hash.
|
||||
// Sin esto, el receptor no podría confiar en lo que recibe.
|
||||
let node = parse::rust("fn add(x: i32, y: i32) -> i32 { x + y }").unwrap();
|
||||
let direct = hash_node(&node);
|
||||
|
||||
let mut store = MemStore::new();
|
||||
let via_store = store.put(&node);
|
||||
assert_eq!(direct, via_store);
|
||||
|
||||
let stored = store.get(&direct).unwrap();
|
||||
let recomputed = hash_stored(stored);
|
||||
assert_eq!(direct, recomputed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_rejects_tampered_deliver() {
|
||||
// Construimos un mensaje Deliver donde `hash` y `stored` no son
|
||||
// consistentes — simulando un peer malicioso o un bit flip en el
|
||||
// transporte. La sesión debe rechazarlo y no contaminar su estado.
|
||||
let (mst_a, store_a, _) = build_repo(&[]);
|
||||
let mut a = SyncSession::without_attestations(mst_a, store_a, kp(1));
|
||||
let initial_store_size = a.store().len();
|
||||
let initial_mst_size = a.mst().len();
|
||||
|
||||
// Forjamos un StoredNode con identidad falsa: anunciamos un hash
|
||||
// arbitrario pero adjuntamos contenido distinto.
|
||||
let fake_stored = StoredNode {
|
||||
kind: "function_item".to_string(),
|
||||
field_name: None,
|
||||
leaf_text: None,
|
||||
children: Vec::new(),
|
||||
};
|
||||
// El hash real de fake_stored es x; anunciamos como otra cosa.
|
||||
let real_hash = hash_components("function_item", None, None, &[]);
|
||||
let bogus_hash = ContentHash([0xAB; 32]);
|
||||
assert_ne!(real_hash, bogus_hash);
|
||||
|
||||
// Inyectamos como si viniera del peer (sesión recibe Hello primero
|
||||
// para que received_hello sea true; luego le metemos el Deliver
|
||||
// tóxico). El Hello se firma con la llave del peer simulado.
|
||||
let peer_kp = kp(99);
|
||||
let peer_root = minga_core::empty_subtree_hash();
|
||||
let peer_sig = peer_kp.sign(peer_root.as_bytes());
|
||||
a.handle(Message::Hello {
|
||||
peer_did: peer_kp.did(),
|
||||
root_subtree_hash: peer_root,
|
||||
signature: peer_sig,
|
||||
});
|
||||
let _ = a.handle(Message::Deliver {
|
||||
hash: bogus_hash,
|
||||
stored: fake_stored,
|
||||
});
|
||||
|
||||
// El store y MST no deben cambiar; el contador de rechazos sí.
|
||||
assert_eq!(a.store().len(), initial_store_size);
|
||||
assert_eq!(a.mst().len(), initial_mst_size);
|
||||
assert_eq!(a.rejected_delivers(), 1);
|
||||
assert!(!a.store().contains(&bogus_hash));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_accepts_well_formed_deliver() {
|
||||
// Contraprueba del anterior: un Deliver con hash válido sí se
|
||||
// acepta. Verifica que el rechazo es selectivo, no global.
|
||||
let (mst_a, store_a, _) = build_repo(&[]);
|
||||
let mut a = SyncSession::without_attestations(mst_a, store_a, kp(1));
|
||||
|
||||
let stored = StoredNode {
|
||||
kind: "integer_literal".to_string(),
|
||||
field_name: None,
|
||||
leaf_text: Some(b"42".to_vec()),
|
||||
children: Vec::new(),
|
||||
};
|
||||
let real_hash = hash_stored(&stored);
|
||||
|
||||
let peer_kp = kp(99);
|
||||
let peer_root = minga_core::empty_subtree_hash();
|
||||
let peer_sig = peer_kp.sign(peer_root.as_bytes());
|
||||
a.handle(Message::Hello {
|
||||
peer_did: peer_kp.did(),
|
||||
root_subtree_hash: peer_root,
|
||||
signature: peer_sig,
|
||||
});
|
||||
a.handle(Message::Deliver {
|
||||
hash: real_hash,
|
||||
stored,
|
||||
});
|
||||
|
||||
// No estaba en awaiting_root (no llegó por probe), así que no
|
||||
// entra al MST — pero sí al store.
|
||||
assert!(a.store().contains(&real_hash));
|
||||
assert_eq!(a.rejected_delivers(), 0);
|
||||
}
|
||||
|
||||
// ─── Identidad y autenticación ─────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn sync_captures_peer_did_after_valid_hello() {
|
||||
// Tras un sync exitoso, cada sesión conoce el DID del otro peer
|
||||
// — la primera afirmación criptográficamente verificable de la
|
||||
// identidad del interlocutor.
|
||||
let sources = &["fn f() -> i32 { 1 }"];
|
||||
let (mst_a, store_a, _) = build_repo(sources);
|
||||
let (mst_b, store_b, _) = build_repo(sources);
|
||||
|
||||
let kp_a = kp(10);
|
||||
let kp_b = kp(20);
|
||||
let did_a = kp_a.did();
|
||||
let did_b = kp_b.did();
|
||||
|
||||
let mut a = SyncSession::without_attestations(mst_a, store_a, kp_a);
|
||||
let mut b = SyncSession::without_attestations(mst_b, store_b, kp_b);
|
||||
|
||||
assert_eq!(a.peer_did(), None);
|
||||
assert_eq!(b.peer_did(), None);
|
||||
|
||||
run_sync(&mut a, &mut b);
|
||||
|
||||
// Cada peer ahora tiene la identidad verificada del otro.
|
||||
assert_eq!(a.peer_did(), Some(did_b));
|
||||
assert_eq!(b.peer_did(), Some(did_a));
|
||||
assert_eq!(a.local_did(), did_a);
|
||||
assert_eq!(b.local_did(), did_b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_rejects_hello_with_tampered_signature() {
|
||||
// Un atacante que captura un Hello legítimo pero modifica un byte
|
||||
// de la firma debe ser rechazado. La sesión no marca
|
||||
// received_hello, no procesa el root, no emite ProbeReq — el
|
||||
// contador de rechazos se incrementa en su lugar.
|
||||
let (mst_a, store_a, _) = build_repo(&[]);
|
||||
let mut a = SyncSession::without_attestations(mst_a, store_a, kp(1));
|
||||
|
||||
let attacker = kp(2);
|
||||
let root = minga_core::empty_subtree_hash();
|
||||
let mut sig = attacker.sign(root.as_bytes());
|
||||
sig.0[5] ^= 0xFF;
|
||||
|
||||
let out = a.handle(Message::Hello {
|
||||
peer_did: attacker.did(),
|
||||
root_subtree_hash: root,
|
||||
signature: sig,
|
||||
});
|
||||
|
||||
assert!(out.is_empty(), "Hello con firma rota no debe producir respuesta");
|
||||
assert_eq!(a.rejected_hellos(), 1);
|
||||
assert_eq!(a.peer_did(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_rejects_hello_with_swapped_did() {
|
||||
// Otro vector: la firma es válida bajo el DID original, pero el
|
||||
// atacante reemplaza el campo `peer_did` por uno distinto. La
|
||||
// verificación falla porque la firma no fue producida por la
|
||||
// llave privada correspondiente al DID anunciado.
|
||||
let (mst_a, store_a, _) = build_repo(&[]);
|
||||
let mut a = SyncSession::without_attestations(mst_a, store_a, kp(1));
|
||||
|
||||
let real_signer = kp(50);
|
||||
let imposter = kp(51);
|
||||
let root = minga_core::empty_subtree_hash();
|
||||
let sig = real_signer.sign(root.as_bytes());
|
||||
|
||||
a.handle(Message::Hello {
|
||||
peer_did: imposter.did(), // dice ser imposter pero la firma es de real_signer
|
||||
root_subtree_hash: root,
|
||||
signature: sig,
|
||||
});
|
||||
|
||||
assert_eq!(a.rejected_hellos(), 1);
|
||||
assert_eq!(a.peer_did(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_rejects_hello_signed_over_different_root() {
|
||||
// El atacante firma un root diferente al que anuncia. La firma es
|
||||
// válida sobre `wrong_root`, pero el mensaje dice `claimed_root`.
|
||||
let (mst_a, store_a, _) = build_repo(&[]);
|
||||
let mut a = SyncSession::without_attestations(mst_a, store_a, kp(1));
|
||||
|
||||
let signer = kp(60);
|
||||
let claimed_root = ContentHash([0xAA; 32]);
|
||||
let wrong_root = ContentHash([0xBB; 32]);
|
||||
let sig_over_wrong = signer.sign(wrong_root.as_bytes());
|
||||
|
||||
a.handle(Message::Hello {
|
||||
peer_did: signer.did(),
|
||||
root_subtree_hash: claimed_root,
|
||||
signature: sig_over_wrong,
|
||||
});
|
||||
|
||||
assert_eq!(a.rejected_hellos(), 1);
|
||||
assert_eq!(a.peer_did(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_rejects_replay_of_hello_from_different_session() {
|
||||
// El test del bloque CRÍTICO: anti-replay anti-replay.
|
||||
//
|
||||
// Sesión 1: el peer "alice" responde a un Challenge de A1
|
||||
// firmando un Hello con el nonce de A1.
|
||||
//
|
||||
// Sesión 2: la misma A vuelve a abrir sesión (A2). A2 genera un
|
||||
// nonce nuevo. Un atacante intenta replicar el Hello capturado de
|
||||
// la sesión 1. Como el nonce es distinto, la firma no verifica.
|
||||
let alice = kp(50);
|
||||
let alice_root = ContentHash([0xAA; 32]);
|
||||
|
||||
// Sesión 1.
|
||||
let mut a1 = SyncSession::without_attestations(Mst::new(), MemStore::new(), kp(1));
|
||||
let nonce_a1 = a1.self_nonce();
|
||||
|
||||
// Alice firma su Hello sobre el nonce que A1 emitió.
|
||||
let payload_1 = hello_payload(&nonce_a1, &alice.did(), &alice_root);
|
||||
let sig_1 = alice.sign(&payload_1);
|
||||
let captured_hello = Message::Hello {
|
||||
peer_did: alice.did(),
|
||||
root_subtree_hash: alice_root,
|
||||
signature: sig_1,
|
||||
};
|
||||
|
||||
// En sesión 1, el Hello se acepta limpiamente.
|
||||
a1.handle(captured_hello.clone());
|
||||
assert_eq!(a1.peer_did(), Some(alice.did()));
|
||||
assert_eq!(a1.rejected_hellos(), 0);
|
||||
|
||||
// Sesión 2: A2 con nonce nuevo. El atacante replica `captured_hello`.
|
||||
let mut a2 = SyncSession::without_attestations(Mst::new(), MemStore::new(), kp(2));
|
||||
assert_ne!(a2.self_nonce(), nonce_a1, "los nonces son distintos por sesión");
|
||||
|
||||
a2.handle(captured_hello);
|
||||
|
||||
// Replay rechazado: la firma estaba sobre nonce_a1, A2 verifica
|
||||
// contra su propio nonce, mismatch criptográfico.
|
||||
assert_eq!(a2.rejected_hellos(), 1);
|
||||
assert_eq!(a2.peer_did(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_proceeds_after_valid_hello_following_rejection() {
|
||||
// Si llega un Hello inválido seguido de uno válido, la sesión se
|
||||
// recupera: acepta el válido y captura ese DID. No hay
|
||||
// "envenenamiento" persistente del estado.
|
||||
let (mst_a, store_a, _) = build_repo(&[]);
|
||||
let mut a = SyncSession::without_attestations(mst_a, store_a, kp(1));
|
||||
|
||||
let bad_signer = kp(70);
|
||||
let mut bad_sig = bad_signer.sign(b"otro mensaje");
|
||||
bad_sig.0[0] ^= 0xFF;
|
||||
let root = minga_core::empty_subtree_hash();
|
||||
a.handle(Message::Hello {
|
||||
peer_did: bad_signer.did(),
|
||||
root_subtree_hash: root,
|
||||
signature: bad_sig,
|
||||
});
|
||||
assert_eq!(a.rejected_hellos(), 1);
|
||||
assert_eq!(a.peer_did(), None);
|
||||
|
||||
let good_signer = kp(71);
|
||||
let nonce = a.self_nonce();
|
||||
let good_payload = hello_payload(&nonce, &good_signer.did(), &root);
|
||||
let good_sig = good_signer.sign(&good_payload);
|
||||
a.handle(Message::Hello {
|
||||
peer_did: good_signer.did(),
|
||||
root_subtree_hash: root,
|
||||
signature: good_sig,
|
||||
});
|
||||
assert_eq!(a.rejected_hellos(), 1);
|
||||
assert_eq!(a.peer_did(), Some(good_signer.did()));
|
||||
}
|
||||
|
||||
// Aux: dejamos `Signature` importado para que el bloque arriba siga
|
||||
// compilando en futuras refactorizaciones que lo necesiten.
|
||||
#[allow(dead_code)]
|
||||
fn _signature_marker(_: Signature) {}
|
||||
|
||||
// ─── Propagación de atestaciones ───────────────────────────────────
|
||||
|
||||
use minga_core::{Attestation, AttestationStore, Did};
|
||||
|
||||
fn build_repo_with_attests(
|
||||
sources: &[&str],
|
||||
signers: &[&Keypair],
|
||||
) -> (Mst, MemStore, AttestationStore, Vec<ContentHash>) {
|
||||
let mut mst = Mst::new();
|
||||
let mut store = MemStore::new();
|
||||
let mut attests = AttestationStore::new();
|
||||
let mut roots = Vec::new();
|
||||
for src in sources {
|
||||
let n = parse::rust(src).unwrap();
|
||||
let h = store.put(&n);
|
||||
mst.insert(h);
|
||||
for kp in signers {
|
||||
attests.add(Attestation::create(kp, h)).unwrap();
|
||||
}
|
||||
roots.push(h);
|
||||
}
|
||||
(mst, store, attests, roots)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_propagates_attestations_for_owned_content() {
|
||||
// Cada peer tiene su propio contenido y firma sus propias claves.
|
||||
// Tras sync, ambos peers conocen ambas atestaciones.
|
||||
let kp_a = kp(10);
|
||||
let kp_b = kp(20);
|
||||
|
||||
let (mst_a, store_a, atts_a, roots_a) =
|
||||
build_repo_with_attests(&["fn from_a() -> i32 { 1 }"], &[&kp_a]);
|
||||
let (mst_b, store_b, atts_b, roots_b) =
|
||||
build_repo_with_attests(&["fn from_b() -> i32 { 2 }"], &[&kp_b]);
|
||||
|
||||
let mut a = SyncSession::new(mst_a, store_a, atts_a, kp_a.clone());
|
||||
let mut b = SyncSession::new(mst_b, store_b, atts_b, kp_b.clone());
|
||||
run_sync(&mut a, &mut b);
|
||||
|
||||
// A debe ahora conocer la atestación de B sobre roots_b[0], y
|
||||
// viceversa. Ambas verificables criptográficamente.
|
||||
let h_a = roots_a[0];
|
||||
let h_b = roots_b[0];
|
||||
|
||||
let a_authors_for_a: Vec<Did> = a.attestations().authors_of(&h_a);
|
||||
let a_authors_for_b: Vec<Did> = a.attestations().authors_of(&h_b);
|
||||
assert_eq!(a_authors_for_a, vec![kp_a.did()]);
|
||||
assert_eq!(a_authors_for_b, vec![kp_b.did()]);
|
||||
|
||||
let b_authors_for_a: Vec<Did> = b.attestations().authors_of(&h_a);
|
||||
let b_authors_for_b: Vec<Did> = b.attestations().authors_of(&h_b);
|
||||
assert_eq!(b_authors_for_a, vec![kp_a.did()]);
|
||||
assert_eq!(b_authors_for_b, vec![kp_b.did()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_merges_multiple_authors_for_shared_content() {
|
||||
// Ambos peers tienen el MISMO contenido (mismo hash) pero
|
||||
// atestaciones de autores DISTINTOS. Tras sync, cada peer ve el
|
||||
// conjunto completo de autores que han respaldado ese contenido.
|
||||
let kp_a = kp(30);
|
||||
let kp_b = kp(40);
|
||||
let kp_c = kp(50);
|
||||
let kp_d = kp(60);
|
||||
|
||||
let src = "fn shared() -> i32 { 99 }";
|
||||
|
||||
// A tiene firmas de A y C sobre el contenido.
|
||||
let (mst_a, store_a, atts_a, _) = build_repo_with_attests(&[src], &[&kp_a, &kp_c]);
|
||||
// B tiene firmas de B y D sobre el MISMO contenido.
|
||||
let (mst_b, store_b, atts_b, roots_b) = build_repo_with_attests(&[src], &[&kp_b, &kp_d]);
|
||||
let h = roots_b[0];
|
||||
|
||||
let mut a = SyncSession::new(mst_a, store_a, atts_a, kp_a.clone());
|
||||
let mut b = SyncSession::new(mst_b, store_b, atts_b, kp_b.clone());
|
||||
run_sync(&mut a, &mut b);
|
||||
|
||||
// Ambos peers ven los cuatro autores.
|
||||
let mut a_authors = a.attestations().authors_of(&h);
|
||||
let mut b_authors = b.attestations().authors_of(&h);
|
||||
a_authors.sort_by_key(|d| d.0);
|
||||
b_authors.sort_by_key(|d| d.0);
|
||||
assert_eq!(a_authors, b_authors);
|
||||
assert_eq!(a_authors.len(), 4);
|
||||
assert!(a_authors.contains(&kp_a.did()));
|
||||
assert!(a_authors.contains(&kp_b.did()));
|
||||
assert!(a_authors.contains(&kp_c.did()));
|
||||
assert!(a_authors.contains(&kp_d.did()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_attestations_are_verified_at_receiver() {
|
||||
// Inyectamos manualmente un AttestPush con una firma corrupta
|
||||
// entre las legítimas. La sesión solo acepta las legítimas e
|
||||
// incrementa rejected_attests.
|
||||
let mut a = SyncSession::without_attestations(Mst::new(), MemStore::new(), kp(1));
|
||||
|
||||
// Hello válido del peer simulado, para que received_hello sea true.
|
||||
let peer_kp = kp(80);
|
||||
let peer_root = minga_core::empty_subtree_hash();
|
||||
let nonce = a.self_nonce();
|
||||
let peer_payload = hello_payload(&nonce, &peer_kp.did(), &peer_root);
|
||||
let peer_sig = peer_kp.sign(&peer_payload);
|
||||
a.handle(Message::Hello {
|
||||
peer_did: peer_kp.did(),
|
||||
root_subtree_hash: peer_root,
|
||||
signature: peer_sig,
|
||||
});
|
||||
|
||||
// Tres atestaciones: dos legítimas y una con firma rota.
|
||||
let alice = kp(81);
|
||||
let bob = kp(82);
|
||||
let h1 = ContentHash([1u8; 32]);
|
||||
let h2 = ContentHash([2u8; 32]);
|
||||
let h3 = ContentHash([3u8; 32]);
|
||||
|
||||
let valid1 = Attestation::create(&alice, h1);
|
||||
let valid2 = Attestation::create(&bob, h2);
|
||||
let mut tampered = Attestation::create(&alice, h3);
|
||||
tampered.signature.0[10] ^= 0xFF;
|
||||
|
||||
a.handle(Message::AttestPush {
|
||||
attestations: vec![valid1.clone(), tampered, valid2.clone()],
|
||||
});
|
||||
|
||||
// Las dos válidas se mergean; la corrupta se rechaza.
|
||||
assert_eq!(a.attestations().len(), 2);
|
||||
assert_eq!(a.rejected_attests(), 1);
|
||||
assert_eq!(a.attestations().authors_of(&h1), vec![alice.did()]);
|
||||
assert_eq!(a.attestations().authors_of(&h2), vec![bob.did()]);
|
||||
assert!(a.attestations().get(&h3).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_attest_push_before_hello_is_rejected() {
|
||||
// Una atestación que llega antes del Hello autenticado se descarta
|
||||
// — no podemos confiar en lo que dice el remitente hasta saber
|
||||
// quién es.
|
||||
let mut a = SyncSession::without_attestations(Mst::new(), MemStore::new(), kp(1));
|
||||
|
||||
let alice = kp(90);
|
||||
let h = ContentHash([7u8; 32]);
|
||||
let att = Attestation::create(&alice, h);
|
||||
|
||||
let out = a.handle(Message::AttestPush {
|
||||
attestations: vec![att],
|
||||
});
|
||||
assert!(out.is_empty());
|
||||
assert_eq!(a.rejected_attests(), 1);
|
||||
assert_eq!(a.attestations().len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_attestations_are_idempotent_across_runs() {
|
||||
// Re-correr el sync no duplica atestaciones (gracias a la
|
||||
// idempotencia de AttestationStore::add por (autor, contenido)).
|
||||
let kp_a = kp(100);
|
||||
let kp_b = kp(101);
|
||||
|
||||
let (mst_a, store_a, atts_a, _) =
|
||||
build_repo_with_attests(&["fn run_one() -> i32 { 1 }"], &[&kp_a]);
|
||||
let (mst_b, store_b, atts_b, _) =
|
||||
build_repo_with_attests(&["fn run_two() -> i32 { 2 }"], &[&kp_b]);
|
||||
|
||||
let mut a = SyncSession::new(mst_a, store_a, atts_a, kp_a.clone());
|
||||
let mut b = SyncSession::new(mst_b, store_b, atts_b, kp_b.clone());
|
||||
run_sync(&mut a, &mut b);
|
||||
let after_first_a = a.attestations().len();
|
||||
let after_first_b = b.attestations().len();
|
||||
assert_eq!(after_first_a, 2);
|
||||
assert_eq!(after_first_b, 2);
|
||||
|
||||
let (mst_a, store_a, atts_a) = a.into_parts();
|
||||
let (mst_b, store_b, atts_b) = b.into_parts();
|
||||
let mut a2 = SyncSession::new(mst_a, store_a, atts_a, kp_a);
|
||||
let mut b2 = SyncSession::new(mst_b, store_b, atts_b, kp_b);
|
||||
run_sync(&mut a2, &mut b2);
|
||||
|
||||
assert_eq!(a2.attestations().len(), after_first_a);
|
||||
assert_eq!(b2.attestations().len(), after_first_b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_attestations_about_remote_content() {
|
||||
// Caso interesante: A tiene una atestación sobre contenido que
|
||||
// **NO** posee (lo recibió por gossip de un tercero). Tras sync
|
||||
// con B, B aprende esa atestación aunque A nunca tuvo el contenido
|
||||
// en su store.
|
||||
let kp_a = kp(110);
|
||||
let kp_third_party = kp(111);
|
||||
|
||||
// A no tiene contenido propio pero sí una atestación de
|
||||
// `kp_third_party` sobre un hash arbitrario.
|
||||
let phantom_hash = ContentHash([0xCD; 32]);
|
||||
let mut atts_a = AttestationStore::new();
|
||||
atts_a
|
||||
.add(Attestation::create(&kp_third_party, phantom_hash))
|
||||
.unwrap();
|
||||
|
||||
let kp_b = kp(112);
|
||||
let mut a = SyncSession::new(Mst::new(), MemStore::new(), atts_a, kp_a);
|
||||
let mut b = SyncSession::without_attestations(Mst::new(), MemStore::new(), kp_b);
|
||||
run_sync(&mut a, &mut b);
|
||||
|
||||
// B ahora conoce la atestación, aunque ni A ni B tienen el
|
||||
// contenido en su store.
|
||||
assert_eq!(b.attestations().len(), 1);
|
||||
assert_eq!(b.attestations().authors_of(&phantom_hash), vec![kp_third_party.did()]);
|
||||
assert!(!b.store().contains(&phantom_hash));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_attest_push_count_in_stats() {
|
||||
// Cuando ambos peers tienen atestaciones, el harness registra dos
|
||||
// AttestPushes (uno por dirección).
|
||||
let kp_a = kp(120);
|
||||
let kp_b = kp(121);
|
||||
let (mst_a, store_a, atts_a, _) =
|
||||
build_repo_with_attests(&["fn ax() -> i32 { 0 }"], &[&kp_a]);
|
||||
let (mst_b, store_b, atts_b, _) =
|
||||
build_repo_with_attests(&["fn bx() -> i32 { 0 }"], &[&kp_b]);
|
||||
|
||||
let mut a = SyncSession::new(mst_a, store_a, atts_a, kp_a);
|
||||
let mut b = SyncSession::new(mst_b, store_b, atts_b, kp_b);
|
||||
let stats = run_sync(&mut a, &mut b);
|
||||
|
||||
assert_eq!(stats.attest_pushes, 2);
|
||||
}
|
||||
Reference in New Issue
Block a user