diff options
| author | tv <tv@krebsco.de> | 2026-11-08 09:44:28 +0000 |
|---|---|---|
| committer | tv <tv@krebsco.de> | 2026-11-08 14:29:01 +0100 |
| commit | e63984e99bef66cb05d7bcf781812ea5e6276fa8 (patch) | |
| tree | aad499e46a8b63ebc2c329e5984ef48e45dc6a0a /src/simtest | |
| parent | 4ecb4ee5c0a49fc6d78ae06e21f6bb1571e290cb (diff) | |
access: a node judges its own changes before they leave
The API checks the path a request asks for; the rules judge everything
the change does. Where they disagreed, the honest node's change was
refused by every other node, and with signatures required the node was
refused as a whole. The plan had both gates run the same judgement.
Under a policy, each change a node makes (its own writes and those
through the API) is now judged by rules::judge as receivers will judge
it, before it is signed or sent; one every node would refuse is rolled
back (the document forked at the heads before it, same actor: the
change never left) and the request refused with 403 (StoreError::
Refused). rules::judge_held, judging a change the document holds,
moves from swarm::refusals to access::rules for both.
The simulations that play a member past its own checks write with a
helper that bypasses them (forge).
Review finding 3 (the same judgement at both gates).
Diffstat (limited to 'src/simtest')
| -rw-r--r-- | src/simtest/policy.rs | 94 |
1 files changed, 88 insertions, 6 deletions
diff --git a/src/simtest/policy.rs b/src/simtest/policy.rs index da046bb..8057660 100644 --- a/src/simtest/policy.rs +++ b/src/simtest/policy.rs @@ -33,6 +33,36 @@ async fn write_as(node: &AppState, user: &str, at: &str, value: serde_json::Valu .expect("write"); } +/// Writes `value` at `at` on `node` as `user`, past the checks a node makes of its own +/// writes (the API's, and judging the change as every node will): as a member that +/// bypasses them would. Signed by the node, as its own. +async fn forge(node: &AppState, user: &str, at: &str, value: serde_json::Value) { + let at = path(at); + let user = user.to_owned(); + let state = node.clone(); + tokio::task::spawn_blocking(move || { + let mut replica = state.doc.blocking_write(); + let writer = crate::document::replica::Writer { + node: state.local_id(), + user: Some(&user), + }; + replica + .transact(state.clock.now_millis(), &writer, |doc| { + store::write_path(doc, &at, &value) + }) + .expect("forged write"); + let heads = replica.doc.get_heads(); + if let (Some(key), [hash]) = (&state.node_key, &heads[..]) { + let replica = &mut *replica; + replica.signatures.sign(&replica.doc, *hash, key); + } + replica.refresh_heads(); + }) + .await + .expect("forged write"); + node.publish_change(); +} + async fn holds(node: &AppState, at: &str) -> bool { let at = path(at); let replica = node.doc.read().await; @@ -83,7 +113,7 @@ async fn a_swarm_judging_every_change() { // A write c lets through for a user the registry doesn't have: refused by the // others, and so is what c builds on it. - write_as(&c, "mallory", "notes/forged", json!(4)).await; + forge(&c, "mallory", "notes/forged", json!(4)).await; for node in [&a, &b, &d] { sim.wait_until("the forged write is refused", SOON, move || async move { refused(node).await > 0 @@ -305,8 +335,8 @@ async fn a_strict_swarm() { // c writes the registry plain, as a user who may manage users: refused by the // others, and so is a plain write over the signed value. let mallory = crate::access::registry::entry("alice", 1, &crate::access::registry::all_caps()); - write_as(&c, "alice", "_continuum/users/mallory", mallory).await; - write_as(&c, "alice", "config", json!({"mode": "forged"})).await; + forge(&c, "alice", "_continuum/users/mallory", mallory).await; + forge(&c, "alice", "config", json!({"mode": "forged"})).await; for node in [&a, &b] { sim.wait_until("the plain writes are refused", SOON, move || async move { refused(node).await > 0 @@ -382,8 +412,8 @@ async fn sealed_values() { // c writes one sealed for a stranger too: refused by the others, and so is what it // builds on it. let stray = sealed_for(&[alice_key, PublicKey([22; 32])]); - write_as(&c, "alice", "secrets/web", json!(stray)).await; - write_as(&c, "alice", "secrets/api", json!("hunter2")).await; + forge(&c, "alice", "secrets/web", json!(stray)).await; + forge(&c, "alice", "secrets/api", json!("hunter2")).await; for node in [&a, &b] { sim.wait_until( "the badly sealed writes are refused", @@ -514,7 +544,7 @@ async fn a_member_writing_against_the_rules() { .await; sim.wait_converged(&[&a, &b, &c], SOON).await; - write_as(&c, "mallory", "notes/forged", json!(1)).await; + forge(&c, "mallory", "notes/forged", json!(1)).await; let rogue = c.local_id().to_owned(); // By whichever node judges it first: the other may still be waiting for its @@ -613,3 +643,55 @@ async fn a_refusal_naming_the_root() { assert!(holds(&b, "notes/x").await, "b dropped its document"); assert_eq!(b.remake.list_quarantine(|entry| entry.id.clone()).len(), 0); } + +/// A write the API would let through but every node's rules refuse (here: past the +/// API, to a path its user may not write): judged before it leaves, refused, rolled +/// back; what the node writes next goes everywhere. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn a_write_every_node_would_refuse() { + let sim = Sim::with_identity(79); + let a = sim.start_with("a", &[], Start::Genesis, |config| { + config.first_users = [("alice".to_owned(), Default::default())].into(); + }); + let b = sim.start("b", &["a"], Start::Join); + sim.wait_ready(&[&a, &b], SOON).await; + let bob = crate::access::registry::entry( + "alice", + 1, + &[(path("notes"), &[crate::access::Right::Write][..])], + ); + write_as(&a, "alice", "_continuum/users/bob", bob).await; + write_as( + &a, + "alice", + "_continuum/access/policy", + json!({"version": 1}), + ) + .await; + sim.wait_converged(&[&a, &b], SOON).await; + + let heads = b.doc.read().await.doc.get_heads(); + let at = path("secrets/x"); + let refused_write = b + .mutate_doc_for( + Some("bob".to_owned()), + Sign::Write { + path: at.clone(), + asked: false, + }, + move |doc| store::write_path(doc, &at, &json!(1)), + ) + .await; + assert!( + matches!(refused_write, Err(store::StoreError::Refused(_))), + "a write every node would refuse" + ); + assert_eq!(b.doc.read().await.doc.get_heads(), heads, "rolled back"); + write_as(&b, "bob", "notes/x", json!(2)).await; + sim.wait_until("bob's next write reaches a", SOON, || async { + holds(&a, "notes/x").await + }) + .await; + assert!(!holds(&a, "secrets/x").await); + assert_eq!(refused(&a).await, 0); +} |
