diff options
| author | tv <tv@krebsco.de> | 2026-11-08 18:17:14 +0000 |
|---|---|---|
| committer | tv <tv@krebsco.de> | 2026-11-08 19:29:10 +0100 |
| commit | 96ba15cf1a6f8725f70f429695a5a9661f706eb0 (patch) | |
| tree | 36feecb42a8bfa5317f27a052266f74489312dfc /src | |
| parent | 4ce3bde36bb8283d82b914f0f73e30d89c935113 (diff) | |
strict: a manifest serves one remake, at its base
Review 2, N1: a strict manifest wasn't bound to its base. Any member
could propose an old, legitimately signed one again at the current
heads; nodes would verify it and vote, and every value at a signed
place changed or added since (a ban, a new user, a rotated key, a
changed userSigned or sealed declaration) would be deleted, nothing
after the base being left to replay.
The manifest now names the epoch and the heads it was made at, and a
node takes it (proposing or voting) only for a remake of that epoch
with exactly that base. GET /admin/strict-manifest makes it so;
continuum-signed strict proposes at those heads already.
Diffstat (limited to 'src')
| -rw-r--r-- | src/access/strict.rs | 53 | ||||
| -rw-r--r-- | src/remake/api.rs | 15 | ||||
| -rw-r--r-- | src/remake/vote.rs | 21 | ||||
| -rw-r--r-- | src/simtest/policy.rs | 30 |
4 files changed, 102 insertions, 17 deletions
diff --git a/src/access/strict.rs b/src/access/strict.rs index 0fa6577..0b294f8 100644 --- a/src/access/strict.rs +++ b/src/access/strict.rs @@ -27,6 +27,10 @@ pub(crate) const NAMESPACE: &str = "continuum-strict"; pub(crate) struct Manifest { /// The swarm it is for. pub(crate) swarm: String, + /// The epoch it is for, and the heads it was made at: the base a remake taking it + /// must have, so it serves once, for the values it names as they were then. + pub(crate) epoch: crate::document::replica::EpochRecord, + pub(crate) base: Vec<String>, /// The policy the next epoch has (`"values": "signed"`). pub(crate) policy: JsonValue, /// The values kept at the places values are signed (`envelope::signed_paths`), each @@ -46,6 +50,29 @@ pub(crate) struct Kept { } impl Manifest { + /// Whether it is for a remake of `epoch` at `base` (hex heads): why not, if not. + pub(crate) fn for_base( + &self, + epoch: &crate::document::replica::EpochRecord, + base: Option<&[String]>, + ) -> Result<(), String> { + if &self.epoch != epoch { + return Err(format!( + "made for epoch {}:{}, not this one", + self.epoch.generation, self.epoch.id + )); + } + let sorted = |heads: &[String]| { + let mut heads = heads.to_vec(); + heads.sort(); + heads + }; + match base { + Some(base) if sorted(base) == sorted(&self.base) => Ok(()), + _ => Err("made at other heads than the remake's base".to_owned()), + } + } + /// Whether it keeps `value` at `path`. pub(crate) fn keeps(&self, path: &[String], value: &JsonValue) -> bool { let digest = digest(value); @@ -104,17 +131,24 @@ pub(crate) fn held(doc: &impl ReadDoc) -> Vec<(Vec<String>, JsonValue)> { out } -/// A manifest for `doc` of `swarm`: keeping every value held at a signed place now, the -/// policy `policy`, for `user` to look over and sign at `at`. Its exact line. +/// A manifest for `doc` (at heads `base`, of `epoch` of `swarm`): keeping every value +/// held at a signed place now, the policy `policy`, for `user` to look over and sign at +/// `at`. Its exact line. pub(crate) fn manifest_of( doc: &impl ReadDoc, swarm: &str, + epoch: crate::document::replica::EpochRecord, + base: &[crate::document::ChangeHash], policy: JsonValue, user: &str, at: u64, ) -> String { + let mut base: Vec<String> = base.iter().map(ToString::to_string).collect(); + base.sort(); let manifest = Manifest { swarm: swarm.to_owned(), + epoch, + base, policy, keep: held(doc) .into_iter() @@ -202,7 +236,10 @@ mod tests { store::write_path(doc, &path("notes"), &json!(1)).unwrap(); }); let policy = json!({"version": 1, "values": "signed"}); - let line = manifest_of(&doc, "s", policy, "alice", 1); + let epoch = + crate::document::replica::EpochRecord::from(crate::document::replica::Epoch::GENESIS); + let heads = doc.get_heads(); + let line = manifest_of(&doc, "s", epoch.clone(), &heads, policy, "alice", 1); let key = SigningKey::from_bytes(&[3; 32]); let public = sshsig::PublicKey(key.verifying_key().to_bytes()); let text = format!("{line}\n{}", sshsig::sign(&key, NAMESPACE, line.as_bytes())); @@ -229,5 +266,15 @@ mod tests { assert!(verify(&elsewhere, std::slice::from_ref(&public), "s").is_err()); let tampered = text.replacen("alice", "mallory", 1); assert!(verify(&tampered, std::slice::from_ref(&public), "s").is_err()); + // For its epoch and base only. + let base: Vec<String> = heads.iter().map(ToString::to_string).collect(); + assert!(manifest.for_base(&epoch, Some(&base)).is_ok()); + assert!(manifest.for_base(&epoch, None).is_err()); + assert!(manifest.for_base(&epoch, Some(&[])).is_err()); + let later = crate::document::replica::EpochRecord { + generation: 1, + id: "ab".to_owned(), + }; + assert!(manifest.for_base(&later, Some(&base)).is_err()); } } diff --git a/src/remake/api.rs b/src/remake/api.rs index 79b551f..7aeb675 100644 --- a/src/remake/api.rs +++ b/src/remake/api.rs @@ -393,7 +393,13 @@ async fn start_kind<H: Host>( .unwrap_or_default() }) .await; - if let Err(why) = super::vote::strict_taken(&host, prepared.content.as_deref(), &swarm) { + if let Err(why) = super::vote::strict_taken( + &host, + prepared.content.as_deref(), + &swarm, + &EpochRecord::from(host.current_epoch()), + request.base_heads.as_deref(), + ) { return error_response( StatusCode::FORBIDDEN, format!("this node takes no such manifest: {why}"), @@ -817,6 +823,7 @@ pub(crate) async fn strict_manifest<H: Host>( ) -> Response { let at = host.now_millis().as_u64(); let caller = user.0.clone(); + let epoch = EpochRecord::from(host.current_epoch()); let (manifest, values, heads) = host .read_doc(move |doc| { let read = |path: &[&str]| { @@ -832,12 +839,14 @@ pub(crate) async fn strict_manifest<H: Host>( _ => json!({"version": 1}), }; policy["values"] = json!("signed"); - let manifest = crate::access::strict::manifest_of(doc, &swarm, policy, &caller, at); + let heads = doc.get_heads(); + let manifest = + crate::access::strict::manifest_of(doc, &swarm, epoch, &heads, policy, &caller, at); let values: Vec<JsonValue> = crate::access::strict::held(doc) .into_iter() .map(|(path, value)| json!({ "path": path, "value": value })) .collect(); - let heads = records::encode_heads(&doc.get_heads()); + let heads = records::encode_heads(&heads); (manifest, values, heads) }) .await; diff --git a/src/remake/vote.rs b/src/remake/vote.rs index 073fcda..7e678e6 100644 --- a/src/remake/vote.rs +++ b/src/remake/vote.rs @@ -294,7 +294,7 @@ pub(super) async fn cast<H: Host>(host: &H) { return None; } let content = record.proposal.content.clone(); - Some((id, content, record.proposal.kind.clone())) + Some((id, content, record.proposal.clone())) }); let swarm = store::read_path( doc, @@ -319,7 +319,7 @@ pub(super) async fn cast<H: Host>(host: &H) { } } } - let Some((chosen, content, kind)) = due else { + let Some((chosen, content, proposal)) = due else { return; }; // Only with the content in hand: a committed remake must have it on a majority. @@ -331,8 +331,14 @@ pub(super) async fn cast<H: Host>(host: &H) { None => None, }; // A strict remake: only with its manifest signed by a key this node was given. - if kind == "strict" - && let Err(why) = strict_taken(host, bytes.as_deref().map(Vec::as_slice), &swarm) + if proposal.kind == "strict" + && let Err(why) = strict_taken( + host, + bytes.as_deref().map(Vec::as_slice), + &swarm, + &proposal.from_epoch, + proposal.base_heads.as_deref(), + ) { if host.remake().refuses_strict(&chosen) { warn!(remake = %chosen, why = %why, "Not voting for a strict remake"); @@ -375,11 +381,14 @@ pub(super) async fn cast<H: Host>(host: &H) { } /// Whether this node takes a strict remake's manifest (`content`, for `swarm`): signed -/// by one of its `--strict-keys`. Why not, if not. +/// by one of its `--strict-keys`, for the remake's epoch and base (`epoch`, `base`). Why +/// not, if not. pub(super) fn strict_taken<H: Host>( host: &H, content: Option<&[u8]>, swarm: &str, + epoch: &EpochRecord, + base: Option<&[String]>, ) -> Result<(), String> { let text = content .and_then(|bytes| std::str::from_utf8(bytes).ok()) @@ -387,7 +396,7 @@ pub(super) fn strict_taken<H: Host>( if host.strict_keys().is_empty() { return Err("this node has no --strict-keys".to_owned()); } - crate::access::strict::verify(text, host.strict_keys(), swarm).map(|_| ()) + crate::access::strict::verify(text, host.strict_keys(), swarm)?.for_base(epoch, base) } /// A random duration below `limit`. diff --git a/src/simtest/policy.rs b/src/simtest/policy.rs index 7e881e7..9f38e44 100644 --- a/src/simtest/policy.rs +++ b/src/simtest/policy.rs @@ -958,7 +958,16 @@ async fn a_swarm_turning_strict_through_a_remake() { let swarm = store::read_path(&replica.doc, &path("_continuum/swarm")) .unwrap() .unwrap(); - let made = strict::manifest_of(&replica.doc, swarm.as_str().unwrap(), policy, "alice", 1); + let epoch = crate::document::replica::EpochRecord::from(replica.epoch); + let made = strict::manifest_of( + &replica.doc, + swarm.as_str().unwrap(), + epoch, + &replica.doc.get_heads(), + policy, + "alice", + 1, + ); let mut manifest: strict::Manifest = serde_json::from_str(&made).unwrap(); manifest .keep @@ -1041,19 +1050,30 @@ async fn a_strict_remake_too_few_take() { } }) .await; - let text = { + let (text, heads) = { let replica = a.doc.read().await; let swarm = store::read_path(&replica.doc, &path("_continuum/swarm")) .unwrap() .unwrap(); let policy = json!({"version": 1, "values": "signed"}); - let line = strict::manifest_of(&replica.doc, swarm.as_str().unwrap(), policy, "alice", 1); + let epoch = crate::document::replica::EpochRecord::from(replica.epoch); + let heads = replica.doc.get_heads(); + let line = strict::manifest_of( + &replica.doc, + swarm.as_str().unwrap(), + epoch, + &heads, + policy, + "alice", + 1, + ); let signed = sshsig::sign(&key, strict::NAMESPACE, line.as_bytes()); - format!("{line}\n{signed}") + let heads: Vec<String> = heads.iter().map(ToString::to_string).collect(); + (format!("{line}\n{signed}"), heads) }; let (status, report) = crate::remake::testing::start( a.clone(), - json!({"kind": "strict", "spec": {"manifest": text}, "settle": "3s"}), + json!({"kind": "strict", "spec": {"manifest": text}, "baseHeads": heads, "settle": "3s"}), None, ) .await; |
