Add canonical signed operation envelopes
This commit is contained in:
parent
bfc5df698c
commit
373ff53d5c
12 changed files with 255 additions and 11 deletions
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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<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)]
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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> {
|
||||
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<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)]
|
||||
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<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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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<u8> {
|
||||
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> {
|
||||
|
|
@ -85,6 +96,19 @@ pub fn verify(public_key: &[u8], message: &[u8], signature: &[u8]) -> Result<(),
|
|||
.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]
|
||||
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)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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<u8>,
|
||||
pub type SignedKeychainOp = geth_codec::SignedEnvelope<KeychainOp, KeyId>;
|
||||
|
||||
pub fn keychain_signing_payload(op: &KeychainOp) -> Result<Vec<u8>, geth_codec::CodecError> {
|
||||
geth_codec::signing_payload(KEYCHAIN_SIGNATURE_NAMESPACE, op)
|
||||
}
|
||||
|
||||
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)]
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue