Add canonical signed operation envelopes

This commit is contained in:
Eric Wendland 2026-05-16 14:37:16 +02:00
commit 373ff53d5c
12 changed files with 255 additions and 11 deletions

View file

@ -107,6 +107,8 @@ Roadmap items should be actionable and checkable:
pinned `geth-iroh` endpoint wrapper with protocol-router scaffold, peer-card pinned `geth-iroh` endpoint wrapper with protocol-router scaffold, peer-card
types, untrusted discovery-backend trait, custom relay-map config, and Iroh types, untrusted discovery-backend trait, custom relay-map config, and Iroh
local-network discovery toggle exist. 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, - Signed peer-card LAN discovery payloads, peer auth over Iroh, cr-sqlite,
iroh-docs, iroh-gossip, iroh-blobs, Automerge sync, real auth enforcement, iroh-docs, iroh-gossip, iroh-blobs, Automerge sync, real auth enforcement,
OpenSSH KRL generation, and Keyhive/BeeKEM-style authorization are future OpenSSH KRL generation, and Keyhive/BeeKEM-style authorization are future

3
Cargo.lock generated
View file

@ -1056,6 +1056,7 @@ dependencies = [
name = "geth-auth" name = "geth-auth"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"geth-codec",
"geth-types", "geth-types",
"serde", "serde",
"serde_json", "serde_json",
@ -1128,6 +1129,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"blake3", "blake3",
"ed25519-dalek", "ed25519-dalek",
"geth-codec",
"geth-types", "geth-types",
"hex", "hex",
"rand_core 0.6.4", "rand_core 0.6.4",
@ -1178,6 +1180,7 @@ dependencies = [
name = "geth-keychain" name = "geth-keychain"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"geth-codec",
"geth-types", "geth-types",
"serde", "serde",
"serde_json", "serde_json",

View file

@ -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. SSH keys are used as admin trust anchors and ecosystem integration points.
OpenSSH, FIDO, and YubiKey-backed keys can sign geth trust objects through OpenSSH, FIDO, and YubiKey-backed keys can sign geth trust objects through
explicit namespaces such as `geth.keychain.v1@geth.local`. Future SSH proxying canonical geth envelopes with explicit namespaces such as
may carry SSH protocol bytes over authorized Iroh streams, but the geth transport `geth.keychain.v1@geth.local`. Future SSH proxying may carry SSH protocol bytes
remains Iroh. over authorized Iroh streams, but the geth transport remains Iroh.
SSH certificate request and renewal flows are managed as geth metadata. A node 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 can create a certificate request, another machine can approve it and receive an

View file

@ -8,6 +8,7 @@ license.workspace = true
[dependencies] [dependencies]
serde.workspace = true serde.workspace = true
thiserror.workspace = true thiserror.workspace = true
geth-codec = { path = "../geth-codec" }
geth-types = { path = "../geth-types" } geth-types = { path = "../geth-types" }
[dev-dependencies] [dev-dependencies]

View file

@ -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 RESOURCE_GRANT_SIGNATURE_NAMESPACE: &str = "geth.resource-grant.v1@geth.local";
pub const REVOCATION_SIGNATURE_NAMESPACE: &str = "geth.revocation.v1@geth.local"; pub const REVOCATION_SIGNATURE_NAMESPACE: &str = "geth.revocation.v1@geth.local";
pub type SignedAuthOp = geth_codec::SignedEnvelope<AuthOp, PrincipalId>;
pub fn auth_signing_payload(op: &AuthOp) -> Result<Vec<u8>, geth_codec::CodecError> {
geth_codec::signing_payload(AUTH_SIGNATURE_NAMESPACE, op)
}
pub fn auth_signing_payload_hash(
op: &AuthOp,
) -> Result<geth_types::BlobHash, geth_codec::CodecError> {
geth_codec::signing_payload_hash(AUTH_SIGNATURE_NAMESPACE, op)
}
#[must_use]
pub fn signed_auth_op(op: AuthOp, signer: PrincipalId, signature: Vec<u8>) -> SignedAuthOp {
geth_codec::SignedEnvelope::new(AUTH_SIGNATURE_NAMESPACE, op, signer, signature)
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuthOp { pub struct AuthOp {
pub id: AuthOpId, pub id: AuthOpId,
@ -105,4 +122,30 @@ mod tests {
let decoded: AuthOp = serde_json::from_str(&json).expect("decode"); let decoded: AuthOp = serde_json::from_str(&json).expect("decode");
assert_eq!(decoded, op); 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);
}
} }

View file

@ -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)] #[derive(Debug, thiserror::Error)]
pub enum CodecError { pub enum CodecError {
@ -6,6 +8,59 @@ pub enum CodecError {
Encode(#[from] postcard::Error), Encode(#[from] postcard::Error),
} }
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CanonicalEnvelope<T> {
pub version: u16,
pub namespace: String,
pub payload: T,
}
impl<T> CanonicalEnvelope<T> {
#[must_use]
pub fn new(namespace: impl Into<String>, payload: T) -> Self {
Self {
version: CANONICAL_ENVELOPE_VERSION,
namespace: namespace.into(),
payload,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SignedEnvelope<T, S> {
pub envelope: CanonicalEnvelope<T>,
pub signer: S,
pub signature: Vec<u8>,
}
impl<T, S> SignedEnvelope<T, S> {
#[must_use]
pub fn new(namespace: impl Into<String>, payload: T, signer: S, signature: Vec<u8>) -> 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<T: Serialize + ?Sized>(value: &T) -> Result<Vec<u8>, CodecError> { pub fn encode_canonical<T: Serialize + ?Sized>(value: &T) -> Result<Vec<u8>, CodecError> {
postcard::to_allocvec(value).map_err(CodecError::from) 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()) geth_types::BlobHash::new(blake3::hash(bytes).to_hex().to_string())
} }
pub fn signing_payload<T: Serialize + ?Sized>(
namespace: &str,
payload: &T,
) -> Result<Vec<u8>, CodecError> {
encode_canonical(&CanonicalEnvelopeRef {
version: CANONICAL_ENVELOPE_VERSION,
namespace,
payload,
})
}
pub fn signing_payload_hash<T: Serialize + ?Sized>(
namespace: &str,
payload: &T,
) -> Result<geth_types::BlobHash, CodecError> {
Ok(blake3_hash_bytes(&signing_payload(namespace, payload)?))
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -59,4 +132,28 @@ mod tests {
sample 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<Sample> =
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);
}
} }

View file

@ -12,6 +12,7 @@ hex.workspace = true
rand_core.workspace = true rand_core.workspace = true
serde.workspace = true serde.workspace = true
thiserror.workspace = true thiserror.workspace = true
geth-codec = { path = "../geth-codec" }
geth-types = { path = "../geth-types" } geth-types = { path = "../geth-types" }
[dev-dependencies] [dev-dependencies]

View file

@ -1,5 +1,6 @@
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
use rand_core::OsRng; use rand_core::OsRng;
use serde::Serialize;
use std::path::Path; use std::path::Path;
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
@ -12,6 +13,8 @@ pub enum CryptoError {
InvalidKey, InvalidKey,
#[error("signature verification failed")] #[error("signature verification failed")]
Verify, Verify,
#[error("canonical encoding failed: {0}")]
Codec(#[from] geth_codec::CodecError),
} }
pub struct AgentKey { pub struct AgentKey {
@ -73,6 +76,14 @@ impl AgentKey {
pub fn sign(&self, bytes: &[u8]) -> Vec<u8> { pub fn sign(&self, bytes: &[u8]) -> Vec<u8> {
self.signing_key.sign(bytes).to_bytes().to_vec() self.signing_key.sign(bytes).to_bytes().to_vec()
} }
pub fn sign_canonical<T: Serialize + ?Sized>(
&self,
namespace: &str,
payload: &T,
) -> Result<Vec<u8>, CryptoError> {
Ok(self.sign(&geth_codec::signing_payload(namespace, payload)?))
}
} }
pub fn verify(public_key: &[u8], message: &[u8], signature: &[u8]) -> Result<(), CryptoError> { 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) .map_err(|_| CryptoError::Verify)
} }
pub fn verify_canonical<T: Serialize + ?Sized>(
public_key: &[u8],
namespace: &str,
payload: &T,
signature: &[u8],
) -> Result<(), CryptoError> {
verify(
public_key,
&geth_codec::signing_payload(namespace, payload)?,
signature,
)
}
#[must_use] #[must_use]
pub fn blake3_hex(bytes: &[u8]) -> String { pub fn blake3_hex(bytes: &[u8]) -> String {
blake3::hash(bytes).to_hex().to_string() blake3::hash(bytes).to_hex().to_string()
@ -115,4 +139,39 @@ mod tests {
"3a4aa805ade0d4694a1bb69ad5b9a2f1dffcd4de2136df9a465023722d26e325" "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)
));
}
} }

View file

@ -8,6 +8,7 @@ license.workspace = true
[dependencies] [dependencies]
serde.workspace = true serde.workspace = true
thiserror.workspace = true thiserror.workspace = true
geth-codec = { path = "../geth-codec" }
geth-types = { path = "../geth-types" } geth-types = { path = "../geth-types" }
[dev-dependencies] [dev-dependencies]

View file

@ -3,12 +3,21 @@ use serde::{Deserialize, Serialize};
pub const KEYCHAIN_SIGNATURE_NAMESPACE: &str = "geth.keychain.v1@geth.local"; pub const KEYCHAIN_SIGNATURE_NAMESPACE: &str = "geth.keychain.v1@geth.local";
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub type SignedKeychainOp = geth_codec::SignedEnvelope<KeychainOp, KeyId>;
pub struct SignedKeychainOp {
pub op: KeychainOp, pub fn keychain_signing_payload(op: &KeychainOp) -> Result<Vec<u8>, geth_codec::CodecError> {
pub signer: KeyId, geth_codec::signing_payload(KEYCHAIN_SIGNATURE_NAMESPACE, op)
pub signature_namespace: String, }
pub signature: Vec<u8>,
pub fn keychain_signing_payload_hash(
op: &KeychainOp,
) -> Result<geth_types::BlobHash, geth_codec::CodecError> {
geth_codec::signing_payload_hash(KEYCHAIN_SIGNATURE_NAMESPACE, op)
}
#[must_use]
pub fn signed_keychain_op(op: KeychainOp, signer: KeyId, signature: Vec<u8>) -> SignedKeychainOp {
geth_codec::SignedEnvelope::new(KEYCHAIN_SIGNATURE_NAMESPACE, op, signer, signature)
} }
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
@ -134,4 +143,27 @@ mod tests {
let decoded: KeychainOp = serde_json::from_str(&json).expect("decode"); let decoded: KeychainOp = serde_json::from_str(&json).expect("decode");
assert_eq!(decoded, op); 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);
}
} }

View file

@ -108,6 +108,11 @@ identity.
The authorization plane is `geth-auth`: resource-local signed operation logs, The authorization plane is `geth-auth`: resource-local signed operation logs,
grants, revocations, groups, and `auth explain`. 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, The payload access plane is `geth-secrets`: resource master secrets, epochs,
key envelopes, bearer secrets, and rotation. Revocation for private data is key envelopes, bearer secrets, and rotation. Revocation for private data is
modeled initially as secret epoch rotation. modeled initially as secret epoch rotation.

View file

@ -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 Goal: replace stubs with signed, reducible keychain/auth operation logs and
resource-scoped capability decisions. resource-scoped capability decisions.
- `[ ]` Canonical signed operation envelope. - `[x]` Canonical signed operation envelope.
Acceptance criteria: Acceptance criteria:
- Keychain and auth ops use deterministic canonical encoding for signatures. - Keychain and auth ops use deterministic canonical encoding for signatures.
- JSON is not used as the signed representation. - JSON is not used as the signed representation.