From 5a45f54c93d16de3c9331b5024150d316b47f9a2 Mon Sep 17 00:00:00 2001 From: Steve Brown Date: Sat, 24 Oct 2026 21:04:44 -0400 Subject: [PATCH 1/3] feat(attest)!: sign quipu-write-v2, bound to the receiving store A quipu-write-v1 signature names no store, so a signed request is accepted by every store that trusts the key. sd now signs quipu-write-v2 by default: it reads the server's store id from GET /stats once, before anything is signed, pins it for the connection, and puts it in the signed message (the audience line last) and the envelope. The store then refuses a request signed for any other store. - The audience is never a caller choice. A /stats that fails or names no usable store_id refuses the connection; sd never falls back to v1. - `[quipu] signing_store_id` optionally pins the store: a server reporting another id is refused before anything is signed. - `[quipu] signing_version = "v1-legacy"` keeps the old format for a server older than write-v2. It cannot be combined with a pin. Both keys are user-level only, like the other signing_* keys. The bytes reproduce quipu's published tests/vectors/write-attestation-v2.json (copied unchanged), and the v1 vector still reproduces. BREAKING CHANGE: signed requests default to quipu-write-v2, so a quipu server older than write-v2 refuses them until `signing_version = "v1-legacy"` is set. `Signer::canonical_message`, `Signer::header` and `Signer::header_now` take the audience as a new last argument. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BzgSY2LpdGhBhb7D1nHbiF --- docs/book/src/config.md | 22 +- .../pr-demos/actor-principal/reproduce.py | 2 + src/native/attest.rs | 199 ++++++++++++++++-- src/native/config.rs | 82 ++++++++ src/native/mod.rs | 10 +- src/native/remote.rs | 79 ++++++- src/native/remote/gateway_tests.rs | 105 ++++++++- tests/actor_principal.rs | 8 + tests/vectors/write-attestation-v2.json | 42 ++++ 9 files changed, 517 insertions(+), 32 deletions(-) create mode 100644 tests/vectors/write-attestation-v2.json diff --git a/docs/book/src/config.md b/docs/book/src/config.md index 3c5c7ed..9e91d2a 100644 --- a/docs/book/src/config.md +++ b/docs/book/src/config.md @@ -18,6 +18,8 @@ store = ".seeds/seeds.db" # a local quipu store file (the default) # signing_key_file = "~/.config/seeds/keys/.key" # signed writes: USER config only # signing_session = "" # signing_introducer = "" +# signing_version = "v2" # default; "v1-legacy" only for a pre-write-v2 server +# signing_store_id = "urn:uuid:..." # optional pin: refuse any other store (GET /stats) [project] prefix = "sd" # id prefix for new seeds (default sd) @@ -134,10 +136,22 @@ signing_introducer = "" ``` - A signed write never also sends the bearer. -- A key is used only for a server you would send your token to: a URL from a - project file needs its host in `trusted_hosts`. The signature does not name - the server, so a server a cloned project chose could otherwise pass your - signed write on to yours. +- Every signed request is `quipu-write-v2`: the signature names the receiving + store, so another store that trusts the same key refuses it. sd reads that + id from the server's `GET /stats` once, before it signs anything, and refuses + to connect when `/stats` fails or names no usable `store_id`. It never falls + back to the audience-free v1 on its own. +- `signing_store_id` pins the store: when it is set, sd refuses a server whose + `/stats` reports any other id, so a server answering at that URL cannot get + your key to sign for itself. Leave it unset if one key signs for several + stores. +- `signing_version = "v1-legacy"` signs the old `quipu-write-v1`, which names no + server, for a quipu server older than write-v2. It cannot be combined with + `signing_store_id`. An operator can refuse a session's v1 writes with + `quipu attest require-v2 `. +- A key is still used only for a server you would send your token to: a URL + from a project file needs its host in `trusted_hosts`, and a project file + cannot set any `signing_*` key. - `/graph/create` is signed too. seeds creates a graph only when the server does not already list it (`GET /graphs`). A server too old to accept a signed route refuses; the client does not retry that operation with a bearer. diff --git a/docs/probes/pr-demos/actor-principal/reproduce.py b/docs/probes/pr-demos/actor-principal/reproduce.py index 991b8c4..f5d970d 100644 --- a/docs/probes/pr-demos/actor-principal/reproduce.py +++ b/docs/probes/pr-demos/actor-principal/reproduce.py @@ -30,6 +30,8 @@ def reply(self, value): def do_GET(self): if self.path == "/graphs": return self.reply({"graphs": []}) + if self.path == "/stats": # the store id a write-v2 signature names + return self.reply({"store_id": "urn:uuid:demo-fixture-store"}) assert self.path == "/health" self.reply({"status": "ok"}) diff --git a/src/native/attest.rs b/src/native/attest.rs index ea44f59..26d7334 100644 --- a/src/native/attest.rs +++ b/src/native/attest.rs @@ -3,9 +3,13 @@ //! instead of a bearer token. Nothing reusable crosses the wire: each header //! binds one request (method, path, content type, body hash) and one nonce. //! -//! The byte format is quipu's `quipu-write-v1`, pinned by the test vector quipu -//! publishes (`tests/vectors/write-attestation-v1.json`, copied here). seeds does -//! not depend on quipu for this; the vector is the contract. +//! The byte format is quipu's `quipu-write-v2` (aegis-2idcev): v1 plus the +//! receiving store's id as the audience, so a signed request is accepted only by +//! the store it was signed for. `quipu-write-v1` names no store and remains only +//! behind the explicit `signing_version = "v1-legacy"` setting. Both are pinned +//! by the test vectors quipu publishes (`tests/vectors/write-attestation-v*.json`, +//! copied here). seeds does not depend on quipu for this; the vectors are the +//! contract. use std::path::{Path, PathBuf}; @@ -16,18 +20,43 @@ use sha2::{Digest, Sha256}; use crate::error::{ErrorKind, Result, SdError}; pub const HEADER: &str = "x-quipu-attestation"; -pub const VERSION: &str = "quipu-write-v1"; +/// The audience-free format, kept only for `signing_version = "v1-legacy"`. +pub const VERSION_V1: &str = "quipu-write-v1"; +/// The default: the signature also binds the receiving store's id. +pub const VERSION_V2: &str = "quipu-write-v2"; + +/// The longest store id sd will sign for. quipu's are `urn:uuid:` ids; the +/// bound only stops a hostile `/stats` from growing every signed request. +const MAX_AUDIENCE: usize = 256; + +/// Whether `audience` is a store id sd will put in a signed message. +pub fn audience_ok(audience: &str) -> bool { + !audience.is_empty() && audience.len() <= MAX_AUDIENCE && clean(audience) +} /// Audited mutation paths and the identity read. Signed general reads are /// dispatched separately; unsupported signed mutations refuse without fallback. pub const SIGNED_PATHS: [&str; 5] = ["/knot", "/update", "/episode", "/graph/create", "/identity"]; +/// Which format a [`Signer`] signs, and against which store (aegis-2idcev). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Audience { + /// `quipu-write-v2` (the default): the store id is read from the server's + /// `/stats` at connect and pinned for the connection; when `pin` is set the + /// server must report exactly that id or nothing is signed. + Store { pin: Option }, + /// `quipu-write-v1`, which names no store. Only by explicit + /// `signing_version = "v1-legacy"`; never a fallback. + LegacyV1, +} + /// A registered signing identity: the private key and the binding it was /// registered under (`quipu attest register --session S --introducer I`). pub struct Signer { key: SigningKey, pub session: String, pub introducer: String, + pub audience: Audience, } /// Lowercase hex of `bytes`. @@ -77,9 +106,17 @@ impl Signer { key: SigningKey::from_bytes(&seed), session: session.to_string(), introducer: introducer.to_string(), + audience: Audience::Store { pin: None }, }) } + /// The same identity under another [`Audience`] policy. + #[must_use] + pub fn with_audience(mut self, audience: Audience) -> Self { + self.audience = audience; + self + } + /// Load the key file: 64 hex characters (the Ed25519 seed), readable by the /// owner only. pub fn load(path: &Path, session: &str, introducer: &str) -> Result { @@ -110,7 +147,9 @@ impl Signer { format!("sha256:{}", sha256_hex(self.key.verifying_key().as_bytes())) } - /// The exact bytes signed for one write. + /// The exact bytes signed for one write: v2 when `audience` names the + /// receiving store (its line comes LAST), v1 when it is `None`. + #[allow(clippy::too_many_arguments)] pub fn canonical_message( &self, issued_at: u64, @@ -119,17 +158,29 @@ impl Signer { path: &str, content_type: &str, body_sha256: &str, + audience: Option<&str>, ) -> String { - format!( - "{VERSION}\nkey_id={}\nsession={}\nintroducer={}\nissued_at={issued_at}\nnonce={nonce}\n\ + let version = if audience.is_some() { + VERSION_V2 + } else { + VERSION_V1 + }; + let mut message = format!( + "{version}\nkey_id={}\nsession={}\nintroducer={}\nissued_at={issued_at}\nnonce={nonce}\n\ method={method}\npath={path}\ncontent_type={content_type}\nbody_sha256={body_sha256}\n", self.key_id(), self.session, self.introducer, - ) + ); + if let Some(audience) = audience { + message.push_str(&format!("audience={audience}\n")); + } + message } /// The header value for one write: base64url (unpadded) of the envelope JSON. + /// `audience` is the receiving store's id (v2), or `None` for legacy v1. + #[allow(clippy::too_many_arguments)] pub fn header( &self, issued_at: u64, @@ -138,6 +189,7 @@ impl Signer { path: &str, content_type: &str, body: &[u8], + audience: Option<&str>, ) -> Result { if path.contains('?') || ![method, path, content_type].iter().all(|f| clean(f)) { return Err(SdError::failed(format!( @@ -145,6 +197,12 @@ impl Signer { characters" ))); } + if audience.is_some_and(|a| !audience_ok(a)) { + return Err(SdError::failed(format!( + "cannot sign {method} {path}: the store id is empty, longer than \ + {MAX_AUDIENCE} bytes, or has a control character" + ))); + } let message = self.canonical_message( issued_at, nonce, @@ -152,14 +210,20 @@ impl Signer { path, content_type, &sha256_hex(body), + audience, ); let signature = hex(&self.key.sign(message.as_bytes()).to_bytes()); // Field order matches quipu's envelope; the server parses JSON, so order - // is not load-bearing, but it keeps the vector byte-identical. + // is not load-bearing, but it keeps the vectors byte-identical. A v1 + // envelope must carry NO audience field; a v2 one carries it last. + let (version, audience_field) = match audience { + Some(a) => (VERSION_V2, format!(",\"audience\":{}", json_str(a))), + None => (VERSION_V1, String::new()), + }; let envelope = format!( "{{\"version\":{},\"key_id\":{},\"session\":{},\"introducer\":{},\ - \"issued_at_epoch\":{issued_at},\"nonce\":{},\"signature\":{}}}", - json_str(VERSION), + \"issued_at_epoch\":{issued_at},\"nonce\":{},\"signature\":{}{audience_field}}}", + json_str(version), json_str(&self.key_id()), json_str(&self.session), json_str(&self.introducer), @@ -176,12 +240,21 @@ impl Signer { path: &str, content_type: &str, body: &[u8], + audience: Option<&str>, ) -> Result { let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map_err(|e| SdError::failed(format!("system clock before 1970: {e}")))? .as_secs(); - self.header(now, &fresh_nonce()?, method, path, content_type, body) + self.header( + now, + &fresh_nonce()?, + method, + path, + content_type, + body, + audience, + ) } /// The command the introducer runs, once, on the quipu host. Someone else @@ -311,6 +384,84 @@ mod tests { use super::*; const VECTOR: &str = include_str!("../../tests/vectors/write-attestation-v1.json"); + const VECTOR_V2: &str = include_str!("../../tests/vectors/write-attestation-v2.json"); + + /// quipu's published v2 vector, reproduced byte for byte: the audience line + /// LAST in the signed message, the audience field LAST in the envelope. + #[test] + fn reproduces_quipus_published_v2_vector_byte_for_byte() { + let v: serde_json::Value = serde_json::from_str(VECTOR_V2).unwrap(); + let (i, d) = (&v["inputs"], &v["derived"]); + let s = |x: &serde_json::Value| x.as_str().unwrap().to_string(); + let seed: [u8; 32] = unhex(&s(&i["ed25519_seed_hex"])) + .unwrap() + .try_into() + .unwrap(); + let signer = Signer::from_seed(seed, &s(&i["session"]), &s(&i["introducer"])).unwrap(); + let body = s(&i["body"]); + let issued = i["issued_at_epoch"].as_u64().unwrap(); + let audience = s(&i["audience"]); + let (method, path, ct, nonce) = ( + s(&i["method"]), + s(&i["path"]), + s(&i["content_type"]), + s(&i["nonce"]), + ); + assert_eq!(signer.key_id(), s(&d["key_id"])); + let message = signer.canonical_message( + issued, + &nonce, + &method, + &path, + &ct, + &s(&d["body_sha256"]), + Some(&audience), + ); + assert_eq!(message, s(&d["canonical_message"])); + assert_eq!( + hex(&signer.key.sign(message.as_bytes()).to_bytes()), + s(&d["signature_hex"]) + ); + let header = signer + .header( + issued, + &nonce, + &method, + &path, + &ct, + body.as_bytes(), + Some(&audience), + ) + .unwrap(); + assert_eq!(header, s(&d["header_value"])); + let envelope = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(&header) + .unwrap(); + assert_eq!(String::from_utf8(envelope).unwrap(), s(&d["envelope_json"])); + } + + #[test] + fn a_store_id_that_could_break_the_message_is_refused() { + let signer = Signer::from_seed([7; 32], "s", "i").unwrap(); + let long = "u".repeat(257); + for bad in [ + "", + "urn:uuid:a\naudience=urn:uuid:b", + "urn:uuid:\t", + long.as_str(), + ] { + assert!( + signer + .header(1, "00", "POST", "/update", "a/b", b"", Some(bad)) + .is_err(), + "{bad:?} accepted" + ); + } + let ok = "u".repeat(256); + assert!(signer + .header(1, "00", "POST", "/update", "a/b", b"", Some(&ok)) + .is_ok()); + } #[test] fn reproduces_quipus_published_vector_byte_for_byte() { @@ -335,11 +486,19 @@ mod tests { assert_eq!(signer.key_id(), s(&d["key_id"])); assert_eq!(sha256_hex(body.as_bytes()), s(&d["body_sha256"])); assert_eq!( - signer.canonical_message(issued, &nonce, &method, &path, &ct, &s(&d["body_sha256"])), + signer.canonical_message( + issued, + &nonce, + &method, + &path, + &ct, + &s(&d["body_sha256"]), + None + ), s(&d["canonical_message"]) ); let header = signer - .header(issued, &nonce, &method, &path, &ct, body.as_bytes()) + .header(issued, &nonce, &method, &path, &ct, body.as_bytes(), None) .unwrap(); assert_eq!(header, s(&d["header_value"])); let envelope = base64::engine::general_purpose::URL_SAFE_NO_PAD @@ -352,10 +511,10 @@ mod tests { fn a_query_string_or_control_character_is_refused() { let signer = Signer::from_seed([7; 32], "s", "i").unwrap(); assert!(signer - .header(1, "00", "POST", "/update?x=1", "a/b", b"") + .header(1, "00", "POST", "/update?x=1", "a/b", b"", None) .is_err()); assert!(signer - .header(1, "00", "POST", "/update", "a/b\n", b"") + .header(1, "00", "POST", "/update", "a/b\n", b"", None) .is_err()); assert!(Signer::from_seed([7; 32], "s\nintroducer=x", "i").is_err()); } @@ -415,8 +574,12 @@ mod tests { #[test] fn fresh_headers_never_reuse_a_nonce() { let signer = Signer::from_seed([7; 32], "s", "i").unwrap(); - let a = signer.header_now("POST", "/update", "a/b", b"x").unwrap(); - let b = signer.header_now("POST", "/update", "a/b", b"x").unwrap(); + let a = signer + .header_now("POST", "/update", "a/b", b"x", Some("urn:uuid:s")) + .unwrap(); + let b = signer + .header_now("POST", "/update", "a/b", b"x", Some("urn:uuid:s")) + .unwrap(); assert_ne!(a, b); } diff --git a/src/native/config.rs b/src/native/config.rs index 6d49177..b66c971 100644 --- a/src/native/config.rs +++ b/src/native/config.rs @@ -139,6 +139,12 @@ pub struct QuipuSection { pub signing_session: Option, /// The introducer that registered it (`quipu attest register --introducer`). pub signing_introducer: Option, + /// `"v2"` (the default) signs `quipu-write-v2`, bound to the receiving + /// store's id; `"v1-legacy"` signs the audience-free v1 (aegis-2idcev). + pub signing_version: Option, + /// The store id writes must be signed for. When set, sd refuses to sign + /// for a server whose `/stats` reports any other id. + pub signing_store_id: Option, /// User-level strict policy: refuse writes without a verified remote key identity. pub require_identity: Option, /// User/host policy for a declared actor differing from the verified key principal. @@ -235,6 +241,33 @@ pub struct Signing { pub key_file: PathBuf, pub session: String, pub introducer: String, + pub audience: super::attest::Audience, +} + +/// The signing format from `signing_version` and `signing_store_id`. A pin +/// with v1 is refused: v1 names no store, so the pin could never be checked. +fn signing_audience(q: &QuipuSection) -> Result { + use super::attest::{audience_ok, Audience}; + let pin = q.signing_store_id.clone(); + if pin.as_deref().is_some_and(|p| !audience_ok(p)) { + return Err(config_error( + "[quipu] signing_store_id must be a non-empty store id (GET /stats store_id) of \ + at most 256 bytes with no control characters" + .into(), + )); + } + match q.signing_version.as_deref() { + None | Some("v2") => Ok(Audience::Store { pin }), + Some("v1-legacy") if pin.is_some() => Err(config_error( + "[quipu] signing_store_id needs signing_version = \"v2\": a v1 signature names no \ + store, so the pin could never be checked" + .into(), + )), + Some("v1-legacy") => Ok(Audience::LegacyV1), + Some(other) => Err(config_error(format!( + "[quipu] signing_version {other:?} is not \"v2\" or \"v1-legacy\"" + ))), + } } /// Everything resolution reads, passed in so tests control all of it. @@ -499,6 +532,8 @@ pub fn resolve(inputs: &Inputs) -> Result { || p.quipu.signing_key_file.is_some() || p.quipu.signing_session.is_some() || p.quipu.signing_introducer.is_some() + || p.quipu.signing_version.is_some() + || p.quipu.signing_store_id.is_some() || p.quipu.require_identity.is_some() || p.quipu.actor_principal_mode.is_some() { @@ -578,6 +613,7 @@ pub fn resolve(inputs: &Inputs) -> Result { key_file: expand(f, &user_base, home), session: s.clone(), introducer: i.clone(), + audience: signing_audience(q)?, }), _ => { return Err(config_error( @@ -943,6 +979,52 @@ mod tests { } } + /// aegis-2idcev: signing is write-v2 unless the user explicitly chooses the + /// legacy v1; a pin needs v2; bad values and a project file are refused. + #[test] + fn the_signing_version_defaults_to_v2_and_v1_needs_the_explicit_legacy_word() { + use crate::native::attest::Audience; + let sb = Sandbox::new("signing-version"); + let identity = + "[quipu]\nsigning_key_file=\"k\"\nsigning_session=\"s\"\nsigning_introducer=\"i\"\n"; + let audience = |extra: &str| { + sb.user_toml(&format!("{identity}{extra}")); + resolve(&sb.inputs()).map(|r| r.signing.unwrap().audience) + }; + assert_eq!(audience("").unwrap(), Audience::Store { pin: None }); + assert_eq!( + audience("signing_version=\"v2\"\n").unwrap(), + Audience::Store { pin: None } + ); + assert_eq!( + audience("signing_store_id=\"urn:uuid:pinned\"\n").unwrap(), + Audience::Store { + pin: Some("urn:uuid:pinned".into()) + } + ); + assert_eq!( + audience("signing_version=\"v1-legacy\"\n").unwrap(), + Audience::LegacyV1 + ); + for bad in [ + "signing_version=\"v1\"\n", + "signing_version=\"V2\"\n", + "signing_version=\"v1-legacy\"\nsigning_store_id=\"urn:uuid:p\"\n", + "signing_store_id=\"\"\n", + ] { + assert!(audience(bad).is_err(), "{bad:?} accepted"); + } + // A cloned project must not choose how (or for whom) your key signs. + sb.user_toml(identity); + for key in [ + "signing_version=\"v1-legacy\"", + "signing_store_id=\"urn:uuid:x\"", + ] { + sb.project_toml(&format!("[quipu]\n{key}\n")); + assert!(resolve(&sb.inputs()).is_err(), "project {key} accepted"); + } + } + #[test] fn with_no_config_at_all_the_default_is_a_local_store_in_cwd() { let sb = Sandbox::new("default"); diff --git a/src/native/mod.rs b/src/native/mod.rs index 0025753..f401026 100644 --- a/src/native/mod.rs +++ b/src/native/mod.rs @@ -1462,9 +1462,9 @@ fn default_session() -> Result { } /// The signing identity for writes to `url`, under the same trust gate as the -/// token: a signed write names nothing about the server it was meant for, so a -/// server a cloned project chose could relay it to yours within the clock -/// window. Sign only for servers you would send your token to. +/// token. A write-v2 signature names the receiving store, but the store id is +/// whatever that server's `/stats` reports, and the legacy v1 names none: sign +/// only for servers you would send your token to. fn signer(cfg: &Resolved, url: &str, from_project: bool) -> Result> { let Some(s) = &cfg.signing else { return Ok(None); @@ -1476,7 +1476,8 @@ fn signer(cfg: &Resolved, url: &str, from_project: bool) -> Result) -> Outcome { @@ -4067,6 +4068,7 @@ mod selected_credential_tests { key_file: "unread-signing-key".into(), session: "fixture".into(), introducer: "fixture".into(), + audience: attest::Audience::Store { pin: None }, }); assert!(token(&cfg, "https://example.org", false) .unwrap_err() diff --git a/src/native/remote.rs b/src/native/remote.rs index 1c6d9f9..c1e2de3 100644 --- a/src/native/remote.rs +++ b/src/native/remote.rs @@ -201,6 +201,10 @@ pub struct RemoteBackend { /// Signs writes on the paths quipu accepts signed (aegis-bys8d1); those /// then carry no bearer. signer: Option, + /// The store id every signed request names (`quipu-write-v2`), read from + /// the server's `/stats` once at connect and pinned for this connection. + /// `None` with a signer only under the explicit `v1-legacy` setting. + audience: Option, agent: ureq::Agent, graph_registered: bool, ephemeral_registered: bool, @@ -365,11 +369,12 @@ impl RemoteBackend { .timeout_connect(Duration::from_secs(5)) .timeout(Duration::from_secs(120)) .build(); - let b = Self { + let mut b = Self { base: base.trim_end_matches('/').to_string(), graph: graph.to_string(), token, signer, + audience: None, agent, graph_registered: false, ephemeral_registered: false, @@ -380,6 +385,7 @@ impl RemoteBackend { max_write_clauses: super::config::DEFAULT_MAX_WRITE_CLAUSES, subject_page: SUBJECT_PAGE, }; + b.audience = b.resolve_audience()?; let health = if b.signer.is_some() { b.signed_request("GET", "/health", "", b"")?.call() } else { @@ -401,6 +407,67 @@ impl RemoteBackend { } } + /// The store id to sign for (aegis-2idcev): this server's own `store_id` + /// from its unauthenticated `/stats`, before anything is signed. Never a + /// caller choice, and never a silent v1: a missing, unreadable or + /// unpinned-mismatching id refuses the connection. + fn resolve_audience(&self) -> Result> { + use super::attest::{audience_ok, Audience}; + let Some(signer) = &self.signer else { + return Ok(None); + }; + let pin = match &signer.audience { + Audience::LegacyV1 => return Ok(None), + Audience::Store { pin } => pin, + }; + let refuse = |why: String| { + SdError::refused(format!( + "not signing for {}: {why}. Signed writes are bound to the receiving store \ + (quipu-write-v2); sd does not fall back to the audience-free v1. A server \ + older than write-v2 needs signing_version = \"v1-legacy\" in \ + ~/.config/seeds/config.toml.", + self.base + )) + }; + let response = retry_read(|| { + quipu_read_request(self.agent.get(&format!("{}/stats", self.base)), "stats") + .call() + .map_err(Box::new) + }) + .map_err(|e| *e); + let body = match response { + Ok(r) => r + .into_string() + .map_err(|e| refuse(format!("reading /stats: {e}")))?, + Err(ureq::Error::Transport(t)) => return Err(transport(&self.base, &t)), + Err(ureq::Error::Status(code, _)) => { + return Err(refuse(format!("GET /stats answered {code}"))); + } + }; + let store_id = serde_json::from_str::(&body) + .ok() + .and_then(|v| v["store_id"].as_str().map(str::to_owned)) + .ok_or_else(|| refuse("its /stats names no store_id".into()))?; + if !audience_ok(&store_id) { + return Err(refuse( + "its /stats store_id is not a usable store id".into(), + )); + } + if let Some(pin) = pin { + if *pin != store_id { + return Err(refuse(format!( + "it reports store {store_id}, but signing_store_id pins {pin}" + ))); + } + } + Ok(Some(store_id)) + } + + /// The store id signed requests on this connection name, if any. + pub fn signing_audience(&self) -> Option<&str> { + self.audience.as_deref() + } + fn signed_request( &self, method: &str, @@ -414,7 +481,7 @@ impl RemoteBackend { if let Some(signer) = &self.signer { req = req.set( super::attest::HEADER, - &signer.header_now(method, path, content_type, body)?, + &signer.header_now(method, path, content_type, body, self.audience.as_deref())?, ); } Ok(req) @@ -504,7 +571,13 @@ impl RemoteBackend { // A signed write carries the attestation and never the bearer. Some(s) if super::attest::SIGNED_PATHS.contains(&path) => { let header = s - .header_now("POST", path, content_type, body.as_bytes()) + .header_now( + "POST", + path, + content_type, + body.as_bytes(), + self.audience.as_deref(), + ) .map_err(|e| (false, e))?; req = req.set(super::attest::HEADER, &header); } diff --git a/src/native/remote/gateway_tests.rs b/src/native/remote/gateway_tests.rs index 3a61924..e801f11 100644 --- a/src/native/remote/gateway_tests.rs +++ b/src/native/remote/gateway_tests.rs @@ -323,7 +323,10 @@ fn signed_identity_checks_binding_and_never_sends_declared_actor() { let base = format!("http://{}", listener.local_addr().unwrap()); let worker = std::thread::spawn(move || { let mut captured = Vec::new(); - for body in ["ok".to_string(), value.to_string()] { + // /stats first (the v2 audience, read unsigned), then the signed + // /health and /identity. + let stats = r#"{"store_id":"urn:uuid:gateway-fixture"}"#.to_string(); + for body in [stats, "ok".to_string(), value.to_string()] { let (stream, _) = listener.accept().unwrap(); let mut reader = BufReader::new(stream); let mut headers = String::new(); @@ -356,8 +359,13 @@ fn signed_identity_checks_binding_and_never_sends_declared_actor() { let result = backend.authenticated_identity(); assert_eq!(result.is_ok(), mutation == "valid", "{mutation}"); let requests = worker.join().unwrap(); - assert!(requests[1].starts_with("post /identity ")); - for request in requests { + assert!(requests[0].starts_with("get /stats "), "{}", requests[0]); + assert!( + !requests[0].contains("x-quipu-attestation:"), + "the audience is read before anything is signed" + ); + assert!(requests[2].starts_with("post /identity ")); + for request in requests.into_iter().skip(1) { assert!(request.contains("x-quipu-attestation:")); assert!(!request.contains("x-quipu-agent:")); assert!(!request.contains("x-quipu-session:")); @@ -365,3 +373,94 @@ fn signed_identity_checks_binding_and_never_sends_declared_actor() { } } } + +/// aegis-2idcev: with a signer, an unusable `/stats` (an error status, no +/// `store_id`, or a store id that could break the signed message) refuses the +/// connection with NOTHING signed. A silent fall back to the audience-free v1 +/// would turn every one of these into a v1 request. +#[test] +fn an_unusable_stats_refuses_before_anything_is_signed() { + let served = r#"{"store_id":"urn:uuid:served"}"#; + for (name, status, body, pin) in [ + ( + "error status", + "500 Internal Server Error", + "{}".to_string(), + None, + ), + ("not found", "404 Not Found", "{}".to_string(), None), + ("no store_id", "200 OK", r#"{"facts":1}"#.to_string(), None), + ( + "control char", + "200 OK", + r#"{"store_id":"urn:uuid:a\naudience=b"}"#.to_string(), + None, + ), + // A usable id that is not the one the user pinned. + ( + "pin mismatch", + "200 OK", + served.to_string(), + Some("urn:uuid:pinned"), + ), + ] { + let signer = crate::native::attest::Signer::from_seed([47; 32], "s", "i") + .unwrap() + .with_audience(crate::native::attest::Audience::Store { + pin: pin.map(str::to_owned), + }); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let worker_stop = stop.clone(); + let worker = std::thread::spawn(move || { + let mut captured = Vec::new(); + while !worker_stop.load(std::sync::atomic::Ordering::Relaxed) { + let Ok((stream, _)) = listener.accept() else { + std::thread::sleep(std::time::Duration::from_millis(5)); + continue; + }; + stream.set_nonblocking(false).unwrap(); + let mut reader = BufReader::new(stream); + let mut headers = String::new(); + loop { + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + if line == "\r\n" || line.is_empty() { + break; + } + headers.push_str(&line); + } + captured.push(headers.to_ascii_lowercase()); + write!( + reader.get_mut(), + "HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + .unwrap(); + } + captured + }); + let result = RemoteBackend::connect(&base, "urn:fixture", None, Some(signer), &[]); + stop.store(true, std::sync::atomic::Ordering::Relaxed); + let requests = worker.join().unwrap(); + let Err(error) = result else { + panic!("{name}: connected without a usable store id"); + }; + assert!( + error.to_string().contains("not signing for") + || error.to_string().contains("cannot reach"), + "{name}: {error}" + ); + assert!(!requests.is_empty(), "{name}: control: /stats was asked"); + assert!( + requests.iter().all(|r| r.starts_with("get /stats ")), + "{name}: only /stats may be sent: {requests:?}" + ); + assert!( + requests.iter().all(|r| !r.contains("x-quipu-attestation:")), + "{name}: something was signed" + ); + } +} diff --git a/tests/actor_principal.rs b/tests/actor_principal.rs index 03d92e8..1432a55 100644 --- a/tests/actor_principal.rs +++ b/tests/actor_principal.rs @@ -84,6 +84,8 @@ fn shared_host_key_observes_each_actor_without_replacing_it_and_enforce_refuses( let query = query["query"].as_str().unwrap_or(""); let body = match path { "/health" => json!({"ok":true}), + // The store id a v2 signature names (aegis-2idcev). + "/stats" => json!({"store_id":"urn:uuid:actor-principal-fixture"}), "/identity" if served_identity.load(Ordering::Relaxed) => identity.clone(), "/identity" => json!({"authenticated":false}), "/graphs" => json!({"graphs":[]}), @@ -359,6 +361,12 @@ fn shared_host_key_observes_each_actor_without_replacing_it_and_enforce_refuses( signer.key_id(), "all actors use the SAME host key" ); + // Default signing is write-v2, bound to the store /stats named. + assert_eq!(envelope["version"], "quipu-write-v2", "{envelope}"); + assert_eq!( + envelope["audience"], "urn:uuid:actor-principal-fixture", + "{envelope}" + ); } std::fs::remove_dir_all(root).unwrap(); } diff --git a/tests/vectors/write-attestation-v2.json b/tests/vectors/write-attestation-v2.json new file mode 100644 index 0000000..2e4edf0 --- /dev/null +++ b/tests/vectors/write-attestation-v2.json @@ -0,0 +1,42 @@ +{ + "derived": { + "body_sha256": "c895460491f7d423c1706d1813394dc1b9d0cd64cbbc9de835c170c2582ea818", + "canonical_message": "quipu-write-v2\nkey_id=sha256:8b81c862681b712b3541770fcae748a931d6ef3c3b817fe6f71b8dad20e96d07\nsession=example-session\nintroducer=urn:example:introducer\nissued_at=1800000100\nnonce=00112233445566778899aabbccddeeff\nmethod=POST\npath=/knot\ncontent_type=application/json\nbody_sha256=c895460491f7d423c1706d1813394dc1b9d0cd64cbbc9de835c170c2582ea818\naudience=urn:uuid:0f1e2d3c-4b5a-4697-8877-665544332211\n", + "envelope_json": "{\"version\":\"quipu-write-v2\",\"key_id\":\"sha256:8b81c862681b712b3541770fcae748a931d6ef3c3b817fe6f71b8dad20e96d07\",\"session\":\"example-session\",\"introducer\":\"urn:example:introducer\",\"issued_at_epoch\":1800000100,\"nonce\":\"00112233445566778899aabbccddeeff\",\"signature\":\"53d3e244e535e6ed1117dcafa4662c6d1c77ee5b8dbbfd02261942ee58c6174329acc5da7ad7c6fa893759236617cdaa17cbaff6a3f9f56013f9248ec3fe2e08\",\"audience\":\"urn:uuid:0f1e2d3c-4b5a-4697-8877-665544332211\"}", + "header_name": "x-quipu-attestation", + "header_value": "eyJ2ZXJzaW9uIjoicXVpcHUtd3JpdGUtdjIiLCJrZXlfaWQiOiJzaGEyNTY6OGI4MWM4NjI2ODFiNzEyYjM1NDE3NzBmY2FlNzQ4YTkzMWQ2ZWYzYzNiODE3ZmU2ZjcxYjhkYWQyMGU5NmQwNyIsInNlc3Npb24iOiJleGFtcGxlLXNlc3Npb24iLCJpbnRyb2R1Y2VyIjoidXJuOmV4YW1wbGU6aW50cm9kdWNlciIsImlzc3VlZF9hdF9lcG9jaCI6MTgwMDAwMDEwMCwibm9uY2UiOiIwMDExMjIzMzQ0NTU2Njc3ODg5OWFhYmJjY2RkZWVmZiIsInNpZ25hdHVyZSI6IjUzZDNlMjQ0ZTUzNWU2ZWQxMTE3ZGNhZmE0NjYyYzZkMWM3N2VlNWI4ZGJiZmQwMjI2MTk0MmVlNThjNjE3NDMyOWFjYzVkYTdhZDdjNmZhODkzNzU5MjM2NjE3Y2RhYTE3Y2JhZmY2YTNmOWY1NjAxM2Y5MjQ4ZWMzZmUyZTA4IiwiYXVkaWVuY2UiOiJ1cm46dXVpZDowZjFlMmQzYy00YjVhLTQ2OTctODg3Ny02NjU1NDQzMzIyMTEifQ", + "key_id": "sha256:8b81c862681b712b3541770fcae748a931d6ef3c3b817fe6f71b8dad20e96d07", + "public_key_hex": "8146640f02493af4fbc54fe33388e75dc2c937ae0b7727cc2b2afb1b75199a3e", + "signature_hex": "53d3e244e535e6ed1117dcafa4662c6d1c77ee5b8dbbfd02261942ee58c6174329acc5da7ad7c6fa893759236617cdaa17cbaff6a3f9f56013f9248ec3fe2e08" + }, + "description": "Signed HTTP write, quipu-write-v2: v1 plus the audience, the receiving store's id. Ed25519 (RFC 8032, deterministic). Every value below is derived by quipu's own functions from the inputs; a client must reproduce each one byte for byte.", + "inputs": { + "agent": "urn:example:agent", + "audience": "urn:uuid:0f1e2d3c-4b5a-4697-8877-665544332211", + "body": "{\"turtle\":\" \\\"o\\\" .\"}", + "content_type": "application/json", + "ed25519_seed_hex": "5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e", + "introducer": "urn:example:introducer", + "issued_at_epoch": 1800000100, + "method": "POST", + "nonce": "00112233445566778899aabbccddeeff", + "path": "/knot", + "session": "example-session" + }, + "rules": [ + "body_sha256 is lowercase hex of the raw body bytes, 64 chars, no prefix", + "key_id is \"sha256:\" + lowercase hex SHA-256 of the RAW 32-byte public key: it HAS a prefix", + "body_sha256 has NO prefix; key_id HAS one; do not normalise either", + "canonical_message is exactly the bytes signed; every line ends in \\n", + "no field may contain a control character (newline, CR, tab, ...)", + "path is the request path exactly as sent; a signed write carries no query string", + "content_type is the Content-Type header byte for byte", + "nonce is 32 lowercase hex chars, single use; issued_at within 300 s of server time", + "header_value is base64url WITHOUT padding of envelope_json", + "accepted on POST /knot, /update, /episode; an invalid attestation is 401 and never falls back to a bearer", + "audience is the receiving store's store_id exactly as GET /stats reports it", + "canonical_message is the v1 lines under the v2 tag, then audience=\\n LAST", + "envelope_json carries the same audience; a store with another id refuses it as invalid", + "a v1 envelope must carry NO audience field; a v2 envelope must carry one" + ] +} From bb50ca23e99e3405bfbe912810daee4d3cf2ea88 Mon Sep 17 00:00:00 2001 From: Steve Brown Date: Sat, 24 Oct 2026 21:09:23 -0400 Subject: [PATCH 2/3] docs(config): state the minimum quipu for write-v2 signing (aegis-2idcev) quipu-ai-v0.12.0 is the first quipu release that verifies quipu-write-v2 and reports store_id in GET /stats (checked: v0.11.1 has neither). Older servers refuse every signed request until signing_version = "v1-legacy". Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BzgSY2LpdGhBhb7D1nHbiF --- docs/book/src/config.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/book/src/config.md b/docs/book/src/config.md index 9e91d2a..127aa29 100644 --- a/docs/book/src/config.md +++ b/docs/book/src/config.md @@ -137,7 +137,10 @@ signing_introducer = "" - A signed write never also sends the bearer. - Every signed request is `quipu-write-v2`: the signature names the receiving - store, so another store that trusts the same key refuses it. sd reads that + store, so another store that trusts the same key refuses it. **This needs + quipu `quipu-ai-v0.12.0` or later**, the first release that verifies + write-v2 and reports `store_id` in `GET /stats`. Against an older quipu every + signed request is refused until you set `signing_version = "v1-legacy"`. sd reads that id from the server's `GET /stats` once, before it signs anything, and refuses to connect when `/stats` fails or names no usable `store_id`. It never falls back to the audience-free v1 on its own. From 5d4109c992769f6902b73cf9baa4bfbea03f08e9 Mon Sep 17 00:00:00 2001 From: Steve Brown Date: Sat, 24 Oct 2026 21:32:26 -0400 Subject: [PATCH 3/3] fix(knowledge): re-witness src/native/config.rs after the signing keys (aegis-2idcev) The config.rs record claims storage routing resolves flags, environment, project then user config and refuses a conflict. The signing_version and signing_store_id keys leave that claim true; only the source hash moved. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BzgSY2LpdGhBhb7D1nHbiF --- docs/knowledge/repository-knowledge.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/knowledge/repository-knowledge.json b/docs/knowledge/repository-knowledge.json index 74a856a..f7a8def 100644 --- a/docs/knowledge/repository-knowledge.json +++ b/docs/knowledge/repository-knowledge.json @@ -38,7 +38,7 @@ "source": "src/native/config.rs", "subject": "http://aegis.gastown.local/ontology/code/seeds/src%2Fnative%2Fconfig.rs", "summary": "Storage configuration resolves explicit flags before environment, project configuration and user configuration. Conflicting or unreadable routing refuses rather than choosing a different store.", - "source_sha256": "2f7703e3b1564800b616af1c907528e549ac4a38dce322370ae3d4dd6b1a301e", + "source_sha256": "20e3f5aa530fbf5bb6eb450b800ef02e6b697acb3bbcb12bb0866147574ce8c3", "properties": { "interfaceKind": "config", "publicName": "--store, --quipu, --graph; SEEDS_QUIPU_STORE, SEEDS_QUIPU_URL, SEEDS_GRAPH; .seeds/config.toml"