From 373ff53d5c39a3c8a845c704928b5004eff8ee3f Mon Sep 17 00:00:00 2001 From: Eric Wendland Date: Sat, 16 May 2026 14:37:16 +0200 Subject: [PATCH] Add canonical signed operation envelopes --- AGENTS.md | 2 + Cargo.lock | 3 + README.md | 6 +- crates/geth-auth/Cargo.toml | 1 + crates/geth-auth/src/lib.rs | 43 ++++++++++++++ crates/geth-codec/src/lib.rs | 99 ++++++++++++++++++++++++++++++++- crates/geth-crypto/Cargo.toml | 1 + crates/geth-crypto/src/lib.rs | 59 ++++++++++++++++++++ crates/geth-keychain/Cargo.toml | 1 + crates/geth-keychain/src/lib.rs | 44 +++++++++++++-- docs/architecture.md | 5 ++ docs/roadmap.md | 2 +- 12 files changed, 255 insertions(+), 11 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3bb5ed3..cac55b4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -107,6 +107,8 @@ Roadmap items should be actionable and checkable: pinned `geth-iroh` endpoint wrapper with protocol-router scaffold, peer-card types, untrusted discovery-backend trait, custom relay-map config, and Iroh local-network discovery toggle exist. +- Canonical signed-operation envelopes exist for keychain/auth signature + payloads. Reducers and enforcement remain separate roadmap work. - Signed peer-card LAN discovery payloads, peer auth over Iroh, cr-sqlite, iroh-docs, iroh-gossip, iroh-blobs, Automerge sync, real auth enforcement, OpenSSH KRL generation, and Keyhive/BeeKEM-style authorization are future diff --git a/Cargo.lock b/Cargo.lock index 17cf2af..bd2262c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1056,6 +1056,7 @@ dependencies = [ name = "geth-auth" version = "0.1.0" dependencies = [ + "geth-codec", "geth-types", "serde", "serde_json", @@ -1128,6 +1129,7 @@ version = "0.1.0" dependencies = [ "blake3", "ed25519-dalek", + "geth-codec", "geth-types", "hex", "rand_core 0.6.4", @@ -1178,6 +1180,7 @@ dependencies = [ name = "geth-keychain" version = "0.1.0" dependencies = [ + "geth-codec", "geth-types", "serde", "serde_json", diff --git a/README.md b/README.md index 8dda686..5df1c42 100644 --- a/README.md +++ b/README.md @@ -51,9 +51,9 @@ discovery is enabled by default with `[iroh].local_discovery = true`. SSH keys are used as admin trust anchors and ecosystem integration points. OpenSSH, FIDO, and YubiKey-backed keys can sign geth trust objects through -explicit namespaces such as `geth.keychain.v1@geth.local`. Future SSH proxying -may carry SSH protocol bytes over authorized Iroh streams, but the geth transport -remains Iroh. +canonical geth envelopes with explicit namespaces such as +`geth.keychain.v1@geth.local`. Future SSH proxying may carry SSH protocol bytes +over authorized Iroh streams, but the geth transport remains Iroh. SSH certificate request and renewal flows are managed as geth metadata. A node can create a certificate request, another machine can approve it and receive an diff --git a/crates/geth-auth/Cargo.toml b/crates/geth-auth/Cargo.toml index cd6fd3b..87fdf38 100644 --- a/crates/geth-auth/Cargo.toml +++ b/crates/geth-auth/Cargo.toml @@ -8,6 +8,7 @@ license.workspace = true [dependencies] serde.workspace = true thiserror.workspace = true +geth-codec = { path = "../geth-codec" } geth-types = { path = "../geth-types" } [dev-dependencies] diff --git a/crates/geth-auth/src/lib.rs b/crates/geth-auth/src/lib.rs index a5606cc..8ff27ac 100644 --- a/crates/geth-auth/src/lib.rs +++ b/crates/geth-auth/src/lib.rs @@ -5,6 +5,23 @@ pub const AUTH_SIGNATURE_NAMESPACE: &str = "geth.auth-op.v1@geth.local"; pub const RESOURCE_GRANT_SIGNATURE_NAMESPACE: &str = "geth.resource-grant.v1@geth.local"; pub const REVOCATION_SIGNATURE_NAMESPACE: &str = "geth.revocation.v1@geth.local"; +pub type SignedAuthOp = geth_codec::SignedEnvelope; + +pub fn auth_signing_payload(op: &AuthOp) -> Result, geth_codec::CodecError> { + geth_codec::signing_payload(AUTH_SIGNATURE_NAMESPACE, op) +} + +pub fn auth_signing_payload_hash( + op: &AuthOp, +) -> Result { + geth_codec::signing_payload_hash(AUTH_SIGNATURE_NAMESPACE, op) +} + +#[must_use] +pub fn signed_auth_op(op: AuthOp, signer: PrincipalId, signature: Vec) -> SignedAuthOp { + geth_codec::SignedEnvelope::new(AUTH_SIGNATURE_NAMESPACE, op, signer, signature) +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct AuthOp { pub id: AuthOpId, @@ -105,4 +122,30 @@ mod tests { let decoded: AuthOp = serde_json::from_str(&json).expect("decode"); assert_eq!(decoded, op); } + + #[test] + fn auth_signing_payload_is_canonical_and_namespaced() { + let op = AuthOp { + id: "op:auth:1".into(), + resource: "resource:notes".into(), + created_at: UnixMillis(10), + kind: AuthOpKind::GrantCreate { + grant_id: "grant:1".to_owned(), + principal: "node:laptop".into(), + capabilities: vec!["kv.read".into(), "kv.write_prefix:apps/foo/".into()], + }, + }; + assert_eq!( + auth_signing_payload(&op).expect("payload"), + auth_signing_payload(&op).expect("payload again") + ); + assert_ne!( + auth_signing_payload_hash(&op).expect("hash"), + geth_codec::hash_canonical(&op).expect("raw op hash") + ); + + let signed = signed_auth_op(op.clone(), "node:laptop".into(), vec![1, 2, 3]); + assert_eq!(signed.namespace(), AUTH_SIGNATURE_NAMESPACE); + assert_eq!(signed.payload(), &op); + } } diff --git a/crates/geth-codec/src/lib.rs b/crates/geth-codec/src/lib.rs index fe3c565..801f9a1 100644 --- a/crates/geth-codec/src/lib.rs +++ b/crates/geth-codec/src/lib.rs @@ -1,4 +1,6 @@ -use serde::{Serialize, de::DeserializeOwned}; +use serde::{Deserialize, Serialize, de::DeserializeOwned}; + +pub const CANONICAL_ENVELOPE_VERSION: u16 = 1; #[derive(Debug, thiserror::Error)] pub enum CodecError { @@ -6,6 +8,59 @@ pub enum CodecError { Encode(#[from] postcard::Error), } +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CanonicalEnvelope { + pub version: u16, + pub namespace: String, + pub payload: T, +} + +impl CanonicalEnvelope { + #[must_use] + pub fn new(namespace: impl Into, payload: T) -> Self { + Self { + version: CANONICAL_ENVELOPE_VERSION, + namespace: namespace.into(), + payload, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SignedEnvelope { + pub envelope: CanonicalEnvelope, + pub signer: S, + pub signature: Vec, +} + +impl SignedEnvelope { + #[must_use] + pub fn new(namespace: impl Into, payload: T, signer: S, signature: Vec) -> Self { + Self { + envelope: CanonicalEnvelope::new(namespace, payload), + signer, + signature, + } + } + + #[must_use] + pub fn namespace(&self) -> &str { + &self.envelope.namespace + } + + #[must_use] + pub fn payload(&self) -> &T { + &self.envelope.payload + } +} + +#[derive(Serialize)] +struct CanonicalEnvelopeRef<'a, T: ?Sized> { + version: u16, + namespace: &'a str, + payload: &'a T, +} + pub fn encode_canonical(value: &T) -> Result, CodecError> { postcard::to_allocvec(value).map_err(CodecError::from) } @@ -26,6 +81,24 @@ pub fn blake3_hash_bytes(bytes: &[u8]) -> geth_types::BlobHash { geth_types::BlobHash::new(blake3::hash(bytes).to_hex().to_string()) } +pub fn signing_payload( + namespace: &str, + payload: &T, +) -> Result, CodecError> { + encode_canonical(&CanonicalEnvelopeRef { + version: CANONICAL_ENVELOPE_VERSION, + namespace, + payload, + }) +} + +pub fn signing_payload_hash( + namespace: &str, + payload: &T, +) -> Result { + Ok(blake3_hash_bytes(&signing_payload(namespace, payload)?)) +} + #[cfg(test)] mod tests { use super::*; @@ -59,4 +132,28 @@ mod tests { sample ); } + + #[test] + fn signing_payload_includes_namespace_and_version() { + let sample = Sample { + version: 1, + name: "geth".to_owned(), + values: vec![1, 2, 3], + }; + let keychain_payload = + signing_payload("geth.keychain.v1@geth.local", &sample).expect("encode"); + let auth_payload = signing_payload("geth.auth-op.v1@geth.local", &sample).expect("encode"); + + assert_eq!( + keychain_payload, + signing_payload("geth.keychain.v1@geth.local", &sample).expect("encode again") + ); + assert_ne!(keychain_payload, auth_payload); + + let decoded: CanonicalEnvelope = + decode_canonical(&keychain_payload).expect("decode envelope"); + assert_eq!(decoded.version, CANONICAL_ENVELOPE_VERSION); + assert_eq!(decoded.namespace, "geth.keychain.v1@geth.local"); + assert_eq!(decoded.payload, sample); + } } diff --git a/crates/geth-crypto/Cargo.toml b/crates/geth-crypto/Cargo.toml index 4fdcd06..f8dc529 100644 --- a/crates/geth-crypto/Cargo.toml +++ b/crates/geth-crypto/Cargo.toml @@ -12,6 +12,7 @@ hex.workspace = true rand_core.workspace = true serde.workspace = true thiserror.workspace = true +geth-codec = { path = "../geth-codec" } geth-types = { path = "../geth-types" } [dev-dependencies] diff --git a/crates/geth-crypto/src/lib.rs b/crates/geth-crypto/src/lib.rs index 59e3e75..4d2b80b 100644 --- a/crates/geth-crypto/src/lib.rs +++ b/crates/geth-crypto/src/lib.rs @@ -1,5 +1,6 @@ use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; use rand_core::OsRng; +use serde::Serialize; use std::path::Path; #[derive(Debug, thiserror::Error)] @@ -12,6 +13,8 @@ pub enum CryptoError { InvalidKey, #[error("signature verification failed")] Verify, + #[error("canonical encoding failed: {0}")] + Codec(#[from] geth_codec::CodecError), } pub struct AgentKey { @@ -73,6 +76,14 @@ impl AgentKey { pub fn sign(&self, bytes: &[u8]) -> Vec { self.signing_key.sign(bytes).to_bytes().to_vec() } + + pub fn sign_canonical( + &self, + namespace: &str, + payload: &T, + ) -> Result, CryptoError> { + Ok(self.sign(&geth_codec::signing_payload(namespace, payload)?)) + } } pub fn verify(public_key: &[u8], message: &[u8], signature: &[u8]) -> Result<(), CryptoError> { @@ -85,6 +96,19 @@ pub fn verify(public_key: &[u8], message: &[u8], signature: &[u8]) -> Result<(), .map_err(|_| CryptoError::Verify) } +pub fn verify_canonical( + public_key: &[u8], + namespace: &str, + payload: &T, + signature: &[u8], +) -> Result<(), CryptoError> { + verify( + public_key, + &geth_codec::signing_payload(namespace, payload)?, + signature, + ) +} + #[must_use] pub fn blake3_hex(bytes: &[u8]) -> String { blake3::hash(bytes).to_hex().to_string() @@ -115,4 +139,39 @@ mod tests { "3a4aa805ade0d4694a1bb69ad5b9a2f1dffcd4de2136df9a465023722d26e325" ); } + + #[derive(Clone, serde::Serialize)] + struct CanonicalSample { + version: u8, + name: String, + } + + #[test] + fn canonical_signature_verifies_only_for_matching_namespace() { + let key = AgentKey::generate(); + let sample = CanonicalSample { + version: 1, + name: "geth".to_owned(), + }; + let signature = key + .sign_canonical("geth.keychain.v1@geth.local", &sample) + .expect("sign"); + + verify_canonical( + &key.verifying_key().to_bytes(), + "geth.keychain.v1@geth.local", + &sample, + &signature, + ) + .expect("verify matching namespace"); + assert!(matches!( + verify_canonical( + &key.verifying_key().to_bytes(), + "geth.auth-op.v1@geth.local", + &sample, + &signature, + ), + Err(CryptoError::Verify) + )); + } } diff --git a/crates/geth-keychain/Cargo.toml b/crates/geth-keychain/Cargo.toml index 6783bb5..1aa7c50 100644 --- a/crates/geth-keychain/Cargo.toml +++ b/crates/geth-keychain/Cargo.toml @@ -8,6 +8,7 @@ license.workspace = true [dependencies] serde.workspace = true thiserror.workspace = true +geth-codec = { path = "../geth-codec" } geth-types = { path = "../geth-types" } [dev-dependencies] diff --git a/crates/geth-keychain/src/lib.rs b/crates/geth-keychain/src/lib.rs index b54ad46..38033d8 100644 --- a/crates/geth-keychain/src/lib.rs +++ b/crates/geth-keychain/src/lib.rs @@ -3,12 +3,21 @@ use serde::{Deserialize, Serialize}; pub const KEYCHAIN_SIGNATURE_NAMESPACE: &str = "geth.keychain.v1@geth.local"; -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct SignedKeychainOp { - pub op: KeychainOp, - pub signer: KeyId, - pub signature_namespace: String, - pub signature: Vec, +pub type SignedKeychainOp = geth_codec::SignedEnvelope; + +pub fn keychain_signing_payload(op: &KeychainOp) -> Result, geth_codec::CodecError> { + geth_codec::signing_payload(KEYCHAIN_SIGNATURE_NAMESPACE, op) +} + +pub fn keychain_signing_payload_hash( + op: &KeychainOp, +) -> Result { + geth_codec::signing_payload_hash(KEYCHAIN_SIGNATURE_NAMESPACE, op) +} + +#[must_use] +pub fn signed_keychain_op(op: KeychainOp, signer: KeyId, signature: Vec) -> SignedKeychainOp { + geth_codec::SignedEnvelope::new(KEYCHAIN_SIGNATURE_NAMESPACE, op, signer, signature) } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -134,4 +143,27 @@ mod tests { let decoded: KeychainOp = serde_json::from_str(&json).expect("decode"); assert_eq!(decoded, op); } + + #[test] + fn keychain_signing_payload_is_canonical_and_namespaced() { + let op = KeychainOp { + id: "op:1".into(), + created_at: UnixMillis(1), + kind: KeychainOpKind::AdminKeyAdd { + key: "key:admin".into(), + }, + }; + assert_eq!( + keychain_signing_payload(&op).expect("payload"), + keychain_signing_payload(&op).expect("payload again") + ); + assert_ne!( + keychain_signing_payload_hash(&op).expect("hash"), + geth_codec::hash_canonical(&op).expect("raw op hash") + ); + + let signed = signed_keychain_op(op.clone(), "key:admin".into(), vec![1, 2, 3]); + assert_eq!(signed.namespace(), KEYCHAIN_SIGNATURE_NAMESPACE); + assert_eq!(signed.payload(), &op); + } } diff --git a/docs/architecture.md b/docs/architecture.md index 0b9fc3a..4f38a91 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -108,6 +108,11 @@ identity. The authorization plane is `geth-auth`: resource-local signed operation logs, grants, revocations, groups, and `auth explain`. +Both keychain and auth operations use `geth-codec` canonical envelopes for +signature payloads. The envelope includes a version, an explicit signature +namespace, and the operation payload encoded with postcard. JSON remains useful +for CLI/control output, but it is not the signed representation. + The payload access plane is `geth-secrets`: resource master secrets, epochs, key envelopes, bearer secrets, and rotation. Revocation for private data is modeled initially as secret epoch rotation. diff --git a/docs/roadmap.md b/docs/roadmap.md index 5ab437b..adba058 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -132,7 +132,7 @@ geth-to-geth connections without granting trust from discovery alone. Goal: replace stubs with signed, reducible keychain/auth operation logs and resource-scoped capability decisions. -- `[ ]` Canonical signed operation envelope. +- `[x]` Canonical signed operation envelope. Acceptance criteria: - Keychain and auth ops use deterministic canonical encoding for signatures. - JSON is not used as the signed representation.