use super::*;
use crate::artifact_store::ArtifactStore;
use crate::chain::EVENT_KIND_ARTIFACT_REVOKE;
use crate::mesh_artifact::ExchangeConfig;
use crate::mesh_artifact_pkg::ExchangedPackage;
use crate::mesh_artifact_tests::{Node, anchors_for, events, key, node_with, signed_package};
use crate::workload_pkg::codec::hex_encode;
struct Peer {
id: String,
node: Node,
rt: Arc<MeshRuntime>,
rev: Arc<RevocationExchange>,
list: Arc<RevocationList>,
_tmp: tempfile::TempDir,
}
fn peer(id: &str, pkg_dir: &std::path::Path, anchors: &TrustAnchors) -> (Peer, ExchangedPackage) {
let cfg = ExchangeConfig {
piece_size: 1024 * 1024,
block_size: 256 * 1024,
..Default::default()
};
let node = node_with(id, ArtifactStore::new_memory(), cfg);
let pkg = node.ex.seed_package_dir(pkg_dir, anchors).unwrap();
let rt = Arc::new(MeshRuntime::new(id.to_string()));
let tmp = tempfile::tempdir().unwrap();
let list = Arc::new(RevocationList::new(tmp.path().join("revoked.json")));
let rev = RevocationExchange::start(node.ex.clone(), list.clone(), anchors.clone(), rt.clone());
(
Peer {
id: id.to_string(),
node,
rt,
rev,
list,
_tmp: tmp,
},
pkg,
)
}
fn link(a: &Peer, b: &Peer) {
let (tx_ab, mut rx_ab) = tokio::sync::mpsc::channel::<Vec<u8>>(64);
let (tx_ba, mut rx_ba) = tokio::sync::mpsc::channel::<Vec<u8>>(64);
let (b_rt, back) = (b.rt.clone(), tx_ba.clone());
tokio::spawn(async move {
while let Some(bytes) = rx_ab.recv().await {
let _ = b_rt.handle_incoming_peer(&bytes, back.clone(), None).await;
}
});
let (a_rt, back) = (a.rt.clone(), tx_ab.clone());
tokio::spawn(async move {
while let Some(bytes) = rx_ba.recv().await {
let _ = a_rt.handle_incoming_peer(&bytes, back.clone(), None).await;
}
});
a.rt.add_peer(b.id.clone(), tx_ab);
b.rt.add_peer(a.id.clone(), tx_ba);
}
async fn wait_for(what: &str, mut ok: impl FnMut() -> bool) {
for _ in 0..400 {
if ok() {
return;
}
tokio::time::sleep(Duration::from_millis(5)).await;
}
panic!("timed out waiting for {what}");
}
use std::time::Duration;
struct Line {
a: Peer,
b: Peer,
c: Peer,
pkg: ExchangedPackage,
_tmp: tempfile::TempDir,
}
fn line() -> Line {
let tmp = tempfile::tempdir().unwrap();
let k = key(1);
let dir = signed_package(tmp.path(), 2 * 1024 * 1024, &k);
let anchors = anchors_for(&k);
let (a, pkg) = peer("node-a", &dir, &anchors);
let (b, _) = peer("node-b", &dir, &anchors);
let (c, _) = peer("node-c", &dir, &anchors);
link(&a, &b);
link(&b, &c); Line {
a,
b,
c,
pkg,
_tmp: tmp,
}
}
fn notice(l: &Line, k: &SigningKey) -> SignedRevocation {
sign_revocation(RevocationKind::Package, &l.pkg.package_id, "compromised", 1, k).unwrap()
}
#[tokio::test]
async fn a_revocation_reaches_every_node_and_evicts_the_package() {
let l = line();
for p in [&l.a, &l.b, &l.c] {
assert_eq!(p.node.ex.servable_artifacts().len(), 3);
}
assert!(l.a.rev.issue(notice(&l, &key(1))).await.unwrap());
wait_for("C, two hops away, to evict", || l.c.node.ex.store().count() == 0).await;
for p in [&l.a, &l.b, &l.c] {
assert!(p.list.is_subject_revoked(RevocationKind::Package, &l.pkg.package_id));
assert!(p.node.ex.servable_artifacts().is_empty(), "{} still seeds", p.id);
assert_eq!(p.node.ex.store().count(), 0, "{} still holds bytes", p.id);
assert_eq!(events(&p.node.chain, EVENT_KIND_ARTIFACT_REVOKE).len(), 3, "{}", p.id);
}
}
#[tokio::test]
async fn a_known_notice_is_not_applied_or_forwarded_again() {
let l = line();
let n = notice(&l, &key(1));
assert!(l.a.rev.issue(n.clone()).await.unwrap());
wait_for("C", || l.c.node.ex.store().count() == 0).await;
assert!(!l.a.rev.issue(n.clone()).await.unwrap());
assert!(!l.b.rev.accept(&n).unwrap());
tokio::time::sleep(Duration::from_millis(100)).await;
for p in [&l.a, &l.b, &l.c] {
assert_eq!(events(&p.node.chain, EVENT_KIND_ARTIFACT_REVOKE).len(), 3, "{}", p.id);
}
}
#[tokio::test]
async fn only_pinned_operator_keys_can_revoke() {
let l = line();
let stranger = notice(&l, &key(9));
assert_eq!(l.a.rev.accept(&stranger), Err(NoticeError::UnauthorizedSigner));
let mut tampered = notice(&l, &key(1));
tampered.payload = tampered.payload.replace("compromised", "harmless");
assert_eq!(l.a.rev.accept(&tampered), Err(NoticeError::BadSignature));
let mut anchors = TrustAnchors::default();
let pk = key(5).verifying_key().to_bytes();
anchors
.push_signer("cog", &hex_encode(&pk), KeyOrigin::CognitumRelease)
.unwrap();
assert_eq!(
verify_revocation(¬ice(&l, &key(5)), &anchors),
Err(NoticeError::UnauthorizedSigner)
);
let forged = serde_json::to_value(&stranger).unwrap();
let ctx = PeerCtx::unauthenticated("node-b");
assert!(l.a.rev.on_peer_control(&ctx, 0, &forged).is_empty());
assert!(l.a.rev.on_peer_control(&ctx, 0, &serde_json::json!({"nope": 1})).is_empty());
tokio::time::sleep(Duration::from_millis(50)).await;
for p in [&l.a, &l.b, &l.c] {
assert!(p.list.list_subjects(None).is_empty());
assert_eq!(p.node.ex.servable_artifacts().len(), 3);
}
}
#[tokio::test]
async fn a_revoked_signer_key_can_no_longer_revoke() {
let l = line();
let own = hex_encode(&key(1).verifying_key().to_bytes());
l.a.list.revoke_subject(RevocationKind::SignerKey, &own, "key leaked").unwrap();
assert_eq!(l.a.rev.accept(¬ice(&l, &key(1))), Err(NoticeError::SignerRevoked));
assert!(l.a.list.is_subject_revoked(RevocationKind::SignerKey, &own));
}
#[tokio::test]
async fn notices_are_rate_limited_per_node() {
let l = line();
let mut refused = 0;
for i in 0..30 {
let n = sign_revocation(RevocationKind::ArtifactHash, &hex_encode(&[i as u8; 32]), "x", 1, &key(1)).unwrap();
if l.a.rev.accept(&n) == Err(NoticeError::RateLimited) {
refused += 1;
}
}
assert!(refused >= 15, "burst of {NOTICE_BURST} then ~{NOTICES_PER_SEC}/s: {refused} refused");
}
#[tokio::test]
async fn a_duplicate_notice_is_not_swept_again() {
let l = line();
let n = notice(&l, &key(1));
assert!(l.a.rev.accept(&n).unwrap());
let before = events(&l.a.node.chain, EVENT_KIND_ARTIFACT_REVOKE).len();
assert!(!l.a.rev.accept(&n).unwrap());
assert_eq!(events(&l.a.node.chain, EVENT_KIND_ARTIFACT_REVOKE).len(), before);
}
#[test]
fn a_malleated_signature_is_refused_by_strict_verification() {
let k = key(1);
let anchors = anchors_for(&k);
let good = sign_revocation(RevocationKind::ArtifactHash, &hex_encode(&[7; 32]), "x", 1, &k).unwrap();
assert!(verify_revocation(&good, &anchors).is_ok());
let mut bad = good.clone();
bad.signature[63] |= 0xf0; assert!(verify_revocation(&bad, &anchors).is_err());
}
#[tokio::test]
async fn junk_on_one_connection_blocks_neither_another_connection_nor_issue() {
let l = line();
let ctx = PeerCtx::unauthenticated("node-b");
for i in 0..60u8 {
let mut junk = sign_revocation(RevocationKind::ArtifactHash, &hex_encode(&[i; 32]), "x", 1, &key(1)).unwrap();
junk.payload = junk.payload.replace("\"x\"", "\"y\"");
let v = serde_json::to_value(&junk).unwrap();
assert!(l.a.rev.on_peer_control(&ctx, 1, &v).is_empty());
}
let genuine = notice(&l, &key(1));
assert_eq!(l.a.rev.accept_from(&genuine, Some(1)), Err(NoticeError::RateLimited));
assert_eq!(l.a.rev.accept_from(&genuine, Some(2)), Ok(true));
let own = sign_revocation(RevocationKind::ArtifactHash, &hex_encode(&[77; 32]), "own", 2, &key(1)).unwrap();
assert_eq!(l.a.rev.issue(own).await, Ok(true));
}
#[tokio::test]
async fn unpinned_signers_cost_no_budget() {
let l = line();
for i in 0..100u8 {
let n = sign_revocation(RevocationKind::ArtifactHash, &hex_encode(&[i; 32]), "x", 1, &key(9)).unwrap();
assert_eq!(l.a.rev.accept_from(&n, Some(5)), Err(NoticeError::UnauthorizedSigner));
}
assert_eq!(l.a.rev.accept_from(¬ice(&l, &key(1)), Some(5)), Ok(true));
}
#[tokio::test]
async fn an_applied_notice_is_chained_and_runs_the_hook_once() {
use std::sync::atomic::{AtomicUsize, Ordering};
let l = line();
let hits = Arc::new(AtomicUsize::new(0));
let h = hits.clone();
let pkg_id = l.pkg.package_id.clone();
assert!(l.b.rev.set_on_applied(Arc::new(move |n| {
assert_eq!(n.kind, RevocationKind::Package);
assert_eq!(n.id, pkg_id);
h.fetch_add(1, Ordering::SeqCst);
})));
assert!(!l.b.rev.set_on_applied(Arc::new(|_| {})), "first hook wins");
let n = notice(&l, &key(1));
assert!(l.a.rev.issue(n.clone()).await.unwrap());
wait_for("B's hook", || hits.load(Ordering::SeqCst) == 1).await;
assert!(!l.b.rev.accept(&n).unwrap());
tokio::time::sleep(Duration::from_millis(100)).await;
assert_eq!(hits.load(Ordering::SeqCst), 1);
for p in [&l.a, &l.b, &l.c] {
let ev = events(&p.node.chain, crate::chain::EVENT_KIND_WORKLOAD_REVOKE);
assert_eq!(ev.len(), 1, "{}", p.id);
assert_eq!(ev[0]["subject_id"], l.pkg.package_id.as_str());
assert!(ev[0]["revoked_by"].as_str().unwrap().starts_with("mesh:"), "{}", ev[0]);
assert_eq!(ev[0]["persisted"], true);
}
}
#[tokio::test]
async fn a_notice_whose_write_fails_is_still_applied_chained_and_forwarded() {
use std::sync::atomic::{AtomicUsize, Ordering};
let l = line();
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("blocker"), "x").unwrap();
let broken = Arc::new(RevocationList::new(tmp.path().join("blocker").join("r.json")));
let anchors = anchors_for(&key(1));
let rt = Arc::new(MeshRuntime::new("node-d".into()));
let node = node_with("node-d", ArtifactStore::new_memory(), ExchangeConfig::default());
let rev = RevocationExchange::start(node.ex.clone(), broken.clone(), anchors, rt);
let hits = Arc::new(AtomicUsize::new(0));
let h = hits.clone();
rev.set_on_applied(Arc::new(move |_| {
h.fetch_add(1, Ordering::SeqCst);
}));
assert!(rev.accept(¬ice(&l, &key(1))).unwrap(), "in force, so new");
assert!(broken.is_subject_revoked(RevocationKind::Package, &l.pkg.package_id));
assert_eq!(hits.load(Ordering::SeqCst), 1, "the hook still runs");
let ev = events(&node.chain, crate::chain::EVENT_KIND_WORKLOAD_REVOKE);
assert_eq!(ev.len(), 1);
assert_eq!(ev[0]["persisted"], false);
}
#[test]
fn the_replay_log_is_bounded_and_evicts_the_oldest() {
let tmp = tempfile::tempdir().unwrap();
let k = key(1);
let (p, _) = peer("node-a", &signed_package(tmp.path(), 1024, &k), &anchors_for(&k));
let notice = |i: usize| {
let id = hex_encode(&blake3::hash(&i.to_le_bytes()).as_bytes()[..]);
sign_revocation(RevocationKind::ArtifactHash, &id, "t", 1, &k).unwrap()
};
let first = notice(0);
p.rev.log_notice(&first);
for i in 1..MAX_LOGGED + 5 {
p.rev.log_notice(¬ice(i));
}
let log = p.rev.log.lock().unwrap();
assert_eq!(log.entries.len(), MAX_LOGGED);
assert!(!log.entries.contains(&first), "the oldest went first");
assert!(log.entries.contains(¬ice(MAX_LOGGED + 4)), "the newest is kept");
}