aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--docs/access.md3
-rw-r--r--src/access/strict.rs53
-rw-r--r--src/remake/api.rs15
-rw-r--r--src/remake/vote.rs21
-rw-r--r--src/simtest/policy.rs30
5 files changed, 104 insertions, 18 deletions
diff --git a/docs/access.md b/docs/access.md
index 657ab94..d7e05ab 100644
--- a/docs/access.md
+++ b/docs/access.md
@@ -427,7 +427,8 @@ continuum-signed strict
```
shows the values it would keep (`GET /admin/strict-manifest`: every value at a signed
-place now, by its digest, and the policy with `"values": "signed"`), signs the
+place now, by its digest, and the policy with `"values": "signed"`, for this epoch at
+the heads it is at, so it serves one remake, at those heads, and no other), signs the
manifest once confirmed (`ssh-keygen -Y sign -n continuum-strict`, with a key of
`--strict-keys`), and proposes the remake at the heads it was made at (`{"kind":
"strict", "spec": {"manifest": "<its line>\n<its signature>"}, "baseHeads": […]}`). To
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;