aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authortv <tv@krebsco.de>2026-11-08 14:18:33 +0000
committertv <tv@krebsco.de>2026-11-08 15:32:31 +0100
commitd56b3b069782585a6edcc9c608236361c2644a51 (patch)
tree22839d0582b3b5a841465b23bfa13957fc6e84ea
parentfac4b143587188d283578bc51e13ca44bcfc6382 (diff)
envelope: a signed value names its swarm
Review 3.1, part of finding 12: a signed value's payload bound path, user, time and prev, but not the swarm. A user with the same name and key in two swarms could have an envelope replayed from one into the other, wherever the path holds no envelope yet (prev ""). The payload now names the swarm (`swarm`), and a node takes an envelope only for its own. /auth/whoami says the swarm, and continuum-signed and the example in api.md sign it. Wire version 44: nodes before this refuse such envelopes, and those after refuse the old ones.
-rw-r--r--docs/access.md3
-rw-r--r--docs/api.md10
-rw-r--r--docs/openapi.yaml4
-rwxr-xr-xscripts/continuum-signed6
-rw-r--r--src/access/envelope.rs25
-rw-r--r--src/access/rules.rs1
-rw-r--r--src/access/screen.rs1
-rw-r--r--src/access/sealed.rs3
-rw-r--r--src/api/login.rs2
-rw-r--r--src/api/store.rs2
-rw-r--r--src/document/store.rs3
-rw-r--r--src/simtest/policy.rs6
-rw-r--r--src/transport/wire.rs2
13 files changed, 52 insertions, 16 deletions
diff --git a/docs/access.md b/docs/access.md
index a85dfa4..e8108e2 100644
--- a/docs/access.md
+++ b/docs/access.md
@@ -139,7 +139,8 @@ forgets it. The login page
token pasted, from a login by key, say; the dashboard renews it while open
([`dashboard.md`](dashboard.md)).
-`GET /auth/whoami` says who a request's user is, how it proved it, and what it may do.
+`GET /auth/whoami` says the swarm, who a request's user is, how it proved it, and what
+it may do.
Pages of other origins, the dashboards of other members, may call a node with a token:
its own links (`/admin/dashboard/links/own`, and its events) and messages
diff --git a/docs/api.md b/docs/api.md
index 019f9cf..cf05046 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -281,18 +281,20 @@ their users), a user writes one by signing it with its SSH key: the payload, one
of JSON, then the signature, as `application/continuum-signed`.
```sh
-# The envelope there, if any, and what replaces it names: its SHA-256 (none: "").
+# The swarm it is for; the envelope there, if any, and what replaces it names: its
+# SHA-256 (none: "").
+swarm=$(curl -sf -u alice localhost:3000/auth/whoami | jq -r .swarm)
read=$(curl -sf -u alice -H 'Accept: text/plain' 'localhost:3000/store/config?raw=true')
prev=${read:+$(printf %s "$read" | sha256sum | cut -d' ' -f1)}
-printf '{"path":["config"],"value":{"mode":"fast"},"user":"alice","at":%s,"prev":"%s"}' \
- "$(date +%s000)" "$prev" > payload
+printf '{"swarm":"%s","path":["config"],"value":{"mode":"fast"},"user":"alice","at":%s,"prev":"%s"}' \
+ "$swarm" "$(date +%s000)" "$prev" > payload
ssh-keygen -Y sign -f ~/.ssh/id_ed25519 -n continuum-value payload
{ cat payload; echo; cat payload.sig; } | curl -X PUT localhost:3000/store/config \
-u alice -H 'Content-Type: application/continuum-signed' --data-binary @-
```
The node writes it as its envelope if the authenticated user signed it (`403` if not),
-for this path, with one of its keys, may write there (`403`), and it replaces what is
+for this swarm and this path, with one of its keys, may write there (`403`), and it replaces what is
there (`prev`, the SHA-256 of the envelope it replaces; `409` if it names another:
read again and sign anew), and only where values are signed (`409` elsewhere). It
answers with the value, as a `PUT` does; a signed `null` deletes, with `204`. Under
diff --git a/docs/openapi.yaml b/docs/openapi.yaml
index f0d54a9..13d8a65 100644
--- a/docs/openapi.yaml
+++ b/docs/openapi.yaml
@@ -192,7 +192,7 @@ paths:
schema:
type: string
example: |
- {"path":["config"],"value":{"mode":"fast"},"user":"alice","at":1762400000000,"prev":""}
+ {"swarm":"6f9a…","path":["config"],"value":{"mode":"fast"},"user":"alice","at":1762400000000,"prev":""}
-----BEGIN SSH SIGNATURE-----
U1NIU0lH…
-----END SSH SIGNATURE-----
@@ -981,7 +981,7 @@ paths:
get:
tags: [Auth]
summary: Who you are here
- description: The user, how it proved it (`htpasswd`, `header`, `token`), its registry entry's capabilities, and its token's claims if it came with one.
+ description: The swarm, the user, how it proved it (`htpasswd`, `header`, `token`), its registry entry's capabilities, and its token's claims if it came with one.
responses:
'200':
description: Who.
diff --git a/scripts/continuum-signed b/scripts/continuum-signed
index dc5bd75..6dd1f4c 100755
--- a/scripts/continuum-signed
+++ b/scripts/continuum-signed
@@ -96,10 +96,12 @@ write() {
cv1.*) prev=$(printf %s "$current" | sha256sum | cut -d' ' -f1) ;;
*) prev= ;;
esac
+ swarm=$(curl -sf -H "Authorization: Bearer $CONTINUUM_TOKEN" "$url/auth/whoami" |
+ jq -r .swarm) || die "no swarm from $url"
# The exact bytes signed and sent: one line of JSON, no newline.
- jq -cnj --arg path "$1" --argjson value "$2" --arg user "$user" \
+ jq -cnj --arg swarm "$swarm" --arg path "$1" --argjson value "$2" --arg user "$user" \
--argjson at "$(date +%s)000" --arg prev "$prev" \
- '{path: ($path | split("/")), value: $value, user: $user, at: $at, prev: $prev}' \
+ '{swarm: $swarm, path: ($path | split("/")), value: $value, user: $user, at: $at, prev: $prev}' \
>"$work/payload" || die "not JSON: $2"
sign "$work/payload" continuum-value
{ cat "$work/payload"; echo; cat "$work/payload.sig"; } |
diff --git a/src/access/envelope.rs b/src/access/envelope.rs
index 1804b98..e0c8909 100644
--- a/src/access/envelope.rs
+++ b/src/access/envelope.rs
@@ -108,6 +108,9 @@ fn agree(pattern: &[String], path: &[String]) -> bool {
/// A signed value's payload.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub(crate) struct Payload {
+ /// The swarm it is for: one user, with the same name and key in two swarms, signs
+ /// for one only.
+ pub(crate) swarm: String,
pub(crate) path: Vec<String>,
pub(crate) value: JsonValue,
pub(crate) user: String,
@@ -170,7 +173,7 @@ pub(crate) fn read(envelope: &str) -> Result<Payload, String> {
/// Checks `envelope` as the value at `path`, replacing `previous` (the envelope there
/// before, if any), against the user registry `doc` holds (as the writer saw it): for
-/// that path, naming what it replaces, signed by one of its user's keys, a user who may
+/// its swarm, that path, naming what it replaces, signed by one of its user's keys, a user who may
/// write there.
pub(crate) fn check(
doc: &impl ReadDoc,
@@ -179,6 +182,15 @@ pub(crate) fn check(
previous: Option<&str>,
) -> Result<Signed, String> {
let (bytes, payload, signature) = open(envelope)?;
+ let swarm = store::read_path(
+ doc,
+ &crate::document::meta::path(&[crate::document::meta::SWARM]),
+ )
+ .ok()
+ .flatten();
+ if swarm.as_ref().and_then(JsonValue::as_str) != Some(payload.swarm.as_str()) {
+ return Err(format!("signed for swarm {}, not this one", payload.swarm));
+ }
if payload.path != path {
return Err(format!(
"signed for /{}, not /{}",
@@ -223,6 +235,7 @@ mod tests {
let line = sshsig::PublicKey(key.verifying_key().to_bytes()).line();
let mut doc = Document::new();
change(&mut doc, |doc| {
+ store::write_path(doc, &path("_continuum/swarm"), &json!("s")).unwrap();
let mut alice = registry::entry("root", 1, &[(path("config"), &[Right::Write][..])]);
alice["keys"] = json!({ "laptop": line });
store::write_path(doc, &registry::user_path("alice"), &alice).unwrap();
@@ -240,6 +253,7 @@ mod tests {
fn payload(user: &str, at: &str, value: JsonValue, prev: &str) -> Payload {
Payload {
+ swarm: "s".to_owned(),
path: path(at),
value,
user: user.to_owned(),
@@ -282,6 +296,15 @@ mod tests {
let tampered = first.replacen("cv1.", "cv1.e30", 1);
assert!(check(&doc, &tampered, &path("config"), None).is_err());
assert!(check(&doc, "plain", &path("config"), None).is_err());
+ // For another swarm.
+ let elsewhere = signed(
+ &key,
+ &Payload {
+ swarm: "t".to_owned(),
+ ..payload("alice", "config", json!({"mode": "fast"}), "")
+ },
+ );
+ assert!(check(&doc, &elsewhere, &path("config"), None).is_err());
}
#[test]
diff --git a/src/access/rules.rs b/src/access/rules.rs
index a56e5ae..3eca1c9 100644
--- a/src/access/rules.rs
+++ b/src/access/rules.rs
@@ -888,6 +888,7 @@ mod tests {
);
let sealed = |at: &str, prev: &str| {
let payload = envelope::Payload {
+ swarm: "s".to_owned(),
path: path(at),
value: json!({"mode": "fast"}),
user: "alice".to_owned(),
diff --git a/src/access/screen.rs b/src/access/screen.rs
index 4c640b6..aa16b42 100644
--- a/src/access/screen.rs
+++ b/src/access/screen.rs
@@ -761,6 +761,7 @@ mod tests {
write(&mut theirs, "a", None, "_continuum/members/a", json!({}));
let sealed = |prev: &str, value: serde_json::Value| {
let payload = envelope::Payload {
+ swarm: "s".to_owned(),
path: path("config"),
value,
user: "alice".to_owned(),
diff --git a/src/access/sealed.rs b/src/access/sealed.rs
index 7a8896f..ed5024c 100644
--- a/src/access/sealed.rs
+++ b/src/access/sealed.rs
@@ -347,8 +347,7 @@ mod tests {
assert!(check(&doc, notes, &json!("plain")).is_err());
// Signed: holding a sealed value, or null.
let signed = |value: JsonValue| {
- let payload =
- json!({"path": ["notes"], "value": value, "user": "alice", "at": 1, "prev": ""});
+ let payload = json!({"swarm": "s", "path": ["notes"], "value": value, "user": "alice", "at": 1, "prev": ""});
format!("cv1.{}.c2ln", B64URL.encode(payload.to_string()))
};
assert!(check(&doc, notes, &json!(signed(json!(sealed_for(&[key(9)]))))).is_ok());
diff --git a/src/api/login.rs b/src/api/login.rs
index 31a5d7f..1faadf7 100644
--- a/src/api/login.rs
+++ b/src/api/login.rs
@@ -502,6 +502,7 @@ pub(crate) async fn whoami(
Extension(via): Extension<Via>,
token: Option<Extension<Token>>,
) -> Json<JsonValue> {
+ let swarm = state.swarm_id().await;
let replica = state.doc.read().await;
let entry = registry::user(&replica.doc, &user.0);
let token = token.map(|Extension(token)| {
@@ -516,6 +517,7 @@ pub(crate) async fn whoami(
})
});
Json(json!({
+ "swarm": swarm,
"user": user.0,
"via": via.0,
"registry": registry::exists(&replica.doc),
diff --git a/src/api/store.rs b/src/api/store.rs
index 101957a..81d0046 100644
--- a/src/api/store.rs
+++ b/src/api/store.rs
@@ -1315,7 +1315,7 @@ mod tests {
fn envelope_of(value: JsonValue) -> String {
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
- let payload = json!({"path": ["x"], "value": value, "user": "alice", "at": 1, "prev": ""});
+ let payload = json!({"swarm": "s", "path": ["x"], "value": value, "user": "alice", "at": 1, "prev": ""});
format!("cv1.{}.c2ln", URL_SAFE_NO_PAD.encode(payload.to_string()))
}
diff --git a/src/document/store.rs b/src/document/store.rs
index d323928..e04ef41 100644
--- a/src/document/store.rs
+++ b/src/document/store.rs
@@ -1707,8 +1707,7 @@ mod tests {
fn plain_reads_open_envelopes() {
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
let sealed = |value: JsonValue| {
- let payload =
- json!({"path": ["a"], "value": value, "user": "alice", "at": 1, "prev": ""});
+ let payload = json!({"swarm": "s", "path": ["a"], "value": value, "user": "alice", "at": 1, "prev": ""});
format!("cv1.{}.c2ln", URL_SAFE_NO_PAD.encode(payload.to_string()))
};
let mut doc = crate::document::Document::new();
diff --git a/src/simtest/policy.rs b/src/simtest/policy.rs
index 4873b38..ba32615 100644
--- a/src/simtest/policy.rs
+++ b/src/simtest/policy.rs
@@ -217,7 +217,13 @@ async fn write_signed(
Ok(Some(serde_json::Value::String(text))) if text.starts_with("cv1.") => Some(text),
_ => None,
};
+ let swarm = store::read_path(&node.doc.read().await.doc, &path("_continuum/swarm"))
+ .ok()
+ .flatten()
+ .and_then(|swarm| swarm.as_str().map(str::to_owned))
+ .unwrap_or_default();
let payload = envelope::Payload {
+ swarm,
path: at.clone(),
value,
user: user.to_owned(),
diff --git a/src/transport/wire.rs b/src/transport/wire.rs
index e7bec62..2ab39e5 100644
--- a/src/transport/wire.rs
+++ b/src/transport/wire.rs
@@ -14,7 +14,7 @@ use super::{EncodedMessage, MessageKind, ParsedMessage};
/// The wire format's version, the first byte of every payload. Bump it with every change
/// to a message type or a codec (`golden_bytes` fails then).
-pub(crate) const VERSION: u8 = 43;
+pub(crate) const VERSION: u8 = 44;
/// What the second byte of a payload says.
fn tag(kind: MessageKind) -> u8 {