Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 21 additions & 4 deletions docs/book/src/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ store = ".seeds/seeds.db" # a local quipu store file (the default)
# signing_key_file = "~/.config/seeds/keys/<session>.key" # signed writes: USER config only
# signing_session = "<session>"
# signing_introducer = "<who registered it>"
# 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)
Expand Down Expand Up @@ -134,10 +136,25 @@ signing_introducer = "<lead>"
```

- 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. **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.
- `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 <session>`.
- 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.
Expand Down
2 changes: 1 addition & 1 deletion docs/knowledge/repository-knowledge.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions docs/probes/pr-demos/actor-principal/reproduce.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"})

Expand Down
199 changes: 181 additions & 18 deletions src/native/attest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand All @@ -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<String> },
/// `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`.
Expand Down Expand Up @@ -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<Self> {
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -138,28 +189,41 @@ impl Signer {
path: &str,
content_type: &str,
body: &[u8],
audience: Option<&str>,
) -> Result<String> {
if path.contains('?') || ![method, path, content_type].iter().all(|f| clean(f)) {
return Err(SdError::failed(format!(
"cannot sign {method} {path}: a signed write has no query string or control \
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,
method,
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),
Expand All @@ -176,12 +240,21 @@ impl Signer {
path: &str,
content_type: &str,
body: &[u8],
audience: Option<&str>,
) -> Result<String> {
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
Expand Down Expand Up @@ -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() {
Expand All @@ -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
Expand All @@ -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());
}
Expand Down Expand Up @@ -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);
}

Expand Down
Loading
Loading