diff options
| author | tv <tv@krebsco.de> | 2026-11-08 22:43:19 +0000 |
|---|---|---|
| committer | tv <tv@krebsco.de> | 2026-11-08 23:52:59 +0100 |
| commit | 24527adf52ca2d8d3b927feb1b811e10fc55fc5c (patch) | |
| tree | 599616858c8ec8bca78163810e65585f5e0daba4 | |
| parent | 1f50adb6ab723425ce031b45ed0db80d404805f0 (diff) | |
signatures: sign what is sent
A node signed its changes no rule signs a while after them (--sign-every
1s): the change left at once, unsigned, and its signature followed on
its own a second later, to every neighbour, over every link. A few
writes made a cascade of SIGNATURES. Under a policy requiring
signatures it signed every change as it was made instead.
A node now signs its latest change, if no signature vouches for it, as
a sync message takes it (--sign-every sent, the default): the
signature leaves in the same message, and goes where the change goes.
One signature per message, however many changes it carries, each
vouching for those before it: a slow writer's every change is signed,
a fast writer's batches are, at no threshold to pick. A policy
requiring signatures signs what is sent too, even with `none`. `all`
still signs every change as it is made; changes to protocol data,
listed paths and ?signed=true writes are signed as they are made, as
before. The interval, and the task signing on it, go.
| -rw-r--r-- | docs/example.toml | 10 | ||||
| -rw-r--r-- | src/config/mod.rs | 6 | ||||
| -rw-r--r-- | src/document/signatures.rs | 64 | ||||
| -rw-r--r-- | src/node.rs | 3 | ||||
| -rw-r--r-- | src/simtest/signatures.rs | 7 | ||||
| -rw-r--r-- | src/state/mod.rs | 47 | ||||
| -rw-r--r-- | src/swarm/sync.rs | 3 |
7 files changed, 61 insertions, 79 deletions
diff --git a/docs/example.toml b/docs/example.toml index 82ba016..9eeee20 100644 --- a/docs/example.toml +++ b/docs/example.toml @@ -75,10 +75,12 @@ htpasswd = "/etc/continuum/htpasswd" # Which of its changes this node signs with its node key besides those touching # protocol data, paths listed in _continuum/access/signed, or written with -# ?signed=true: "all", "none" (one left unsigned is vouched for by the node's next -# signed change), or its latest change, if unsigned, this often (docs/api.md, Signed -# changes). -# sign-every = "1s" +# ?signed=true: "sent", its latest as a sync message takes it (one signature per +# message, vouching for the changes before it); "all", every change as it is made; or +# "none" (one left unsigned is vouched for by the node's next signed change; as "sent" +# under a policy requiring signatures). docs/api.md, Signed changes, says which to +# choose. +# sign-every = "sent" # How long a token this node issues (POST /auth/login, /auth/refresh) is taken, by every # node of the swarm (docs/access.md, Tokens). Refreshed before it ends, it lasts until diff --git a/src/config/mod.rs b/src/config/mod.rs index 4230b65..7df68e7 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -126,9 +126,9 @@ pub(crate) const OPTIONS: &[OptionSpec] = &[ option( "sign-every", Kind::Text, - "all|none|DURATION", - Some("1s"), - "Which of its changes this node signs with its node key besides those touching protocol data, paths listed in _continuum/access/signed, or written with ?signed=true: all of them; none (one left unsigned is vouched for by the node's next signed change); or its latest change, if unsigned, this often, so every change is vouched for within it (docs/api.md, Signed changes).", + "all|sent|none", + Some("sent"), + "Which of its changes this node signs with its node key besides those touching protocol data, paths listed in _continuum/access/signed, or written with ?signed=true: its latest, as a sync message takes it (sent: one signature per message, vouching for the changes before it); every change as it is made (all); or none (one left unsigned is vouched for by the node's next signed change; as sent under a policy requiring signatures). docs/api.md, Signed changes, says which to choose.", ), option( "token-lifetime", diff --git a/src/document/signatures.rs b/src/document/signatures.rs index 102cad2..f2809fe 100644 --- a/src/document/signatures.rs +++ b/src/document/signatures.rs @@ -12,8 +12,8 @@ //! touches protocol data (`_continuum`) or a path the swarm lists as signed //! (`_continuum/access/signed`, `listed`), or when its write asks for it (`Sign`); a //! change left unsigned is vouched for by the node's next signed one, which, as -//! `--sign-every` says, is its latest change, signed a while after it if nothing signed -//! it before (`SignEvery`), or every change at once. +//! `--sign-every` says (`SignEvery`), is its latest change, signed as it leaves in a +//! sync message (`sent`), or every change at once (`all`). //! //! Signatures travel with sync messages (`SyncEnvelope::signatures`): each actor's //! latest one a node verified, those a peer wasn't sent yet (`newer_than`). A received @@ -86,49 +86,40 @@ pub(crate) fn listed(doc: &Document) -> Vec<Vec<String>> { } /// Which of this node's changes it signs besides those `Sign` says (`--sign-every`). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub(crate) enum SignEvery { /// Every change, as it is made (`all`, `true`). Change, + /// Its latest change, if unsigned, as a sync message takes it (`sent`): one + /// signature per message, however many changes it carries, each vouching for those + /// before it. + #[default] + Sent, /// No others (`none`, `false`): one left unsigned waits for the node's next signed - /// change. + /// change; unless the policy requires signatures, then as `sent`. Never, - /// Its latest change, if unsigned, this often: every change is vouched for within it. - Interval(std::time::Duration), -} - -impl Default for SignEvery { - fn default() -> Self { - SignEvery::Interval(std::time::Duration::from_secs(1)) - } } impl std::fmt::Display for SignEvery { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - SignEvery::Change => f.write_str("all"), - SignEvery::Never => f.write_str("none"), - SignEvery::Interval(every) => f.write_str(&crate::units::format_duration(*every)), - } + f.write_str(match self { + SignEvery::Change => "all", + SignEvery::Sent => "sent", + SignEvery::Never => "none", + }) } } impl std::str::FromStr for SignEvery { type Err = String; - /// `all` or `true`, `none` or `false`, or a duration (`units::parse_duration`; 0 is - /// `none`). + /// `all` or `true`, `sent`, `none` or `false`. fn from_str(text: &str) -> Result<Self, String> { match text.trim() { "all" | "true" => Ok(SignEvery::Change), + "sent" => Ok(SignEvery::Sent), "none" | "false" => Ok(SignEvery::Never), - other => match crate::units::parse_duration(other) { - Ok(every) if every.is_zero() => Ok(SignEvery::Never), - Ok(every) => Ok(SignEvery::Interval(every)), - Err(error) => Err(format!( - "{other:?} is neither all, none nor a duration ({error})" - )), - }, + other => Err(format!("{other:?} is neither all, sent nor none")), } } } @@ -149,11 +140,8 @@ impl<'de> serde::Deserialize<'de> for SignEvery { SignEvery::Never }), serde_json::Value::String(text) => text.parse().map_err(D::Error::custom), - serde_json::Value::Number(number) => { - number.to_string().parse().map_err(D::Error::custom) - } other => Err(D::Error::custom(format!( - "{other} is neither all, none nor a duration" + "{other} is neither all, sent nor none" ))), } } @@ -524,7 +512,8 @@ impl Signatures { self.unsigned = None; } - /// Whether this node's latest change waits for a signature (`sign_latest`). + /// Whether this node's latest change waits for a signature (`sign_latest`, as a sync + /// message takes it). pub(crate) fn pending(&self) -> bool { self.unsigned.is_some() } @@ -625,23 +614,18 @@ mod tests { assert!(check(&named, &hash, &key.sign(&message(&hash))).is_none()); } - /// `--sign-every`: `all`, `none`, a duration; 0 is `none`. + /// `--sign-every`: `all`, `sent`, `none`. #[test] fn sign_every_reads() { - use std::time::Duration; let read = |text: &str| text.parse::<SignEvery>(); assert_eq!(read("all"), Ok(SignEvery::Change)); assert_eq!(read("true"), Ok(SignEvery::Change)); + assert_eq!(read("sent"), Ok(SignEvery::Sent)); assert_eq!(read("none"), Ok(SignEvery::Never)); - assert_eq!(read("0"), Ok(SignEvery::Never)); - assert_eq!( - read("500ms"), - Ok(SignEvery::Interval(Duration::from_millis(500))) - ); - assert!(read("sometimes").is_err()); + assert!(read("1s").is_err()); let toml: SignEvery = serde_json::from_value(json!(false)).unwrap(); assert_eq!(toml, SignEvery::Never); - assert_eq!(SignEvery::default().to_string(), "1s"); + assert_eq!(SignEvery::default().to_string(), "sent"); } /// The latest change left unsigned is signed later, once; a signature since covers it. diff --git a/src/node.rs b/src/node.rs index 314433c..742da09 100644 --- a/src/node.rs +++ b/src/node.rs @@ -275,9 +275,6 @@ impl Node { tokio::spawn(remake::run_worker(state.clone())); tokio::spawn(crate::swarm::signatures::run(state.clone())); - if let crate::document::signatures::SignEvery::Interval(every) = state.sign_every { - tokio::spawn(state.clone().sign_latest_every(every)); - } trace!("Remake worker task spawned"); tokio::spawn(swarm::topology::run_worker(state.clone())); diff --git a/src/simtest/signatures.rs b/src/simtest/signatures.rs index 2425a16..c6232e7 100644 --- a/src/simtest/signatures.rs +++ b/src/simtest/signatures.rs @@ -44,11 +44,10 @@ async fn verified(node: &AppState, actor: &ActorId, seq: u64) -> bool { node.doc.read().await.signatures.verified(actor, seq) } -/// A write no rule signs is signed a while after it (`--sign-every`, 1 s by default), -/// and the signature reaches every node on its own (SIGNATURES): the change went before -/// it was signed. +/// A write no rule signs is signed as a sync message takes it (`--sign-every sent`, the +/// default), and the signature reaches every node with it. #[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn unsigned_writes_are_verified_within_the_interval() { +async fn writes_leave_signed() { let sim = Sim::with_identity(61); let a = sim.start("a", &[], Start::Genesis); let b = sim.start("b", &["a"], Start::Join); diff --git a/src/state/mod.rs b/src/state/mod.rs index e079340..26910b6 100644 --- a/src/state/mod.rs +++ b/src/state/mod.rs @@ -692,8 +692,8 @@ impl AppState { } /// Signs the change just made in `replica`, if any (the one head after `before`, as - /// it builds on every head before), as `sign` says, or as `--sign-every` or the - /// policy has it; or keeps it as left unsigned. + /// it builds on every head before), as `sign` says, or as `--sign-every` has it; or + /// keeps it as left unsigned, for the sync message that takes it (`sign_sent`). fn sign_made( &self, replica: &mut crate::document::replica::Replica, @@ -707,10 +707,7 @@ impl AppState { if after != before && let [hash] = after[..] { - if self.sign_every == SignEvery::Change - || sign.wanted(&replica.doc) - || crate::access::policy::of(&replica.doc).required() - { + if self.sign_every == SignEvery::Change || sign.wanted(&replica.doc) { replica.signatures.sign(&replica.doc, hash, key); } else { replica.signatures.left_unsigned(hash); @@ -718,27 +715,27 @@ impl AppState { } } - /// This node's latest change signed, if no signature vouches for it yet, every - /// `every` (`--sign-every`): every change it makes is vouched for within that. - pub(crate) async fn sign_latest_every(self, every: Duration) { - let Some(key) = self.node_key.clone() else { + /// Before a sync message is made from `doc`: this node's latest change signed, if + /// no signature vouches for it yet and it signs what it sends (`--sign-every sent`, + /// or `none` under a policy requiring signatures), so it leaves with its signature. + /// Looked at first under the read lock: mostly there is nothing to sign. Blocking. + pub(crate) fn sign_sent(&self, doc: &crate::document::lock::DocumentLock) { + let Some(key) = &self.node_key else { return; }; - let mut ticks = tokio::time::interval(every); - ticks.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); - loop { - ticks.tick().await; - let state = self.clone(); - let key = Arc::clone(&key); - let _ = tokio::task::spawn_blocking(move || { - // Looked at first under the read lock: mostly there is nothing to sign. - state.doc.blocking_read().signatures.pending() && { - let mut replica = state.doc.blocking_write(); - let replica = &mut *replica; - replica.signatures.sign_latest(&replica.doc, &key) - } - }) - .await; + if self.sign_every == SignEvery::Change { + return; + } + let due = { + let replica = doc.blocking_read(); + replica.signatures.pending() + && (self.sign_every == SignEvery::Sent + || crate::access::policy::of(&replica.doc).required()) + }; + if due { + let mut replica = doc.blocking_write(); + let replica = &mut *replica; + replica.signatures.sign_latest(&replica.doc, key); } } diff --git a/src/swarm/sync.rs b/src/swarm/sync.rs index e76f5f9..0c49fa3 100644 --- a/src/swarm/sync.rs +++ b/src/swarm/sync.rs @@ -167,9 +167,12 @@ async fn deliver_sync_to_peer(state: &AppState, peer: &str) -> Result<()> { let stats = Arc::clone(&state.stats); let clock = Arc::clone(&state.clock); + let signer = state.clone(); // The session the message comes from, for its send's outcome. let (session, generated) = tokio::task::spawn_blocking( move || -> (u64, Result<Option<transport::EncodedMessage>>) { + // This node's latest change, if this message may take it, leaves signed. + signer.sign_sent(&doc); // Label the message with the epoch of the document it was generated // from, read under the same lock. let (session, epoch, epoch_seq, body, writer, delivered, signatures) = { |
