Skip to main content

klieo_ops/
signer.rs

1//! Pluggable signing for handoff envelopes.
2//!
3//! `SignerProvider` abstracts the act of producing an Ed25519 signature
4//! over a canonical-JSON payload. The default `SoftwareSigner` wraps
5//! `ed25519_dalek::SigningKey` directly. HSM- / KMS-backed adapters
6//! implement the trait against their respective vendor SDK without
7//! modifying klieo-ops.
8//!
9//! Closes spec § 5.4 OQ-3.
10
11use async_trait::async_trait;
12use ed25519_dalek::{Signer as EdSigner, SigningKey, Verifier as EdVerifier, VerifyingKey};
13use klieo_provenance::{BundleSigner, ProvenanceError, SignatureBytes};
14use rand::rngs::OsRng;
15use thiserror::Error;
16
17/// Algorithm tag emitted by [`SoftwareSigner`]'s [`BundleSigner`] impl and
18/// matched against the verifier allowlist at verify time.
19const ED25519_ALGORITHM: &str = "ed25519";
20
21/// Number of leading hex chars of the verifying key used to form a stable,
22/// human-readable `key_id` for resolver lookup.
23const KEY_ID_PREFIX_LEN: usize = 8;
24
25/// Output of a sign call: the raw 64-byte Ed25519 signature plus the
26/// 32-byte verifying key that should be embedded in the envelope so the
27/// receiver can resolve it against its trust registry.
28#[derive(Clone, Debug)]
29#[non_exhaustive]
30pub struct SignedPayload {
31    /// 64-byte Ed25519 signature.
32    pub signature: [u8; 64],
33    /// 32-byte Ed25519 verifying key.
34    pub verifying_key: [u8; 32],
35}
36
37impl SignedPayload {
38    /// Construct a `SignedPayload`. Prefer this over struct-literal syntax
39    /// — `#[non_exhaustive]` blocks cross-crate struct literals.
40    #[must_use]
41    pub fn new(signature: [u8; 64], verifying_key: [u8; 32]) -> Self {
42        Self {
43            signature,
44            verifying_key,
45        }
46    }
47}
48
49/// Errors raised by `SignerProvider` impls.
50#[derive(Debug, Error)]
51#[non_exhaustive]
52pub enum SignerError {
53    /// Underlying HSM / KMS rejected the request.
54    #[error("signer backend: {0}")]
55    Backend(String),
56    /// Other internal error.
57    #[error("internal: {0}")]
58    Internal(String),
59}
60
61/// Pluggable Ed25519 signer.
62#[async_trait]
63pub trait SignerProvider: Send + Sync {
64    /// Sign `payload` and return both the signature and the verifying key.
65    async fn sign_handoff(&self, payload: &[u8]) -> Result<SignedPayload, SignerError>;
66}
67
68/// Default impl backed by an in-process `ed25519_dalek::SigningKey`.
69///
70/// Suitable for dev and non-regulated deployments. Production tenants
71/// requiring HSM-backed signing implement `SignerProvider` against
72/// their vendor SDK.
73pub struct SoftwareSigner {
74    key: SigningKey,
75}
76
77impl SoftwareSigner {
78    /// Build from an existing `SigningKey`.
79    #[must_use]
80    pub fn new(key: SigningKey) -> Self {
81        Self { key }
82    }
83
84    /// Build deterministically from 32 bytes — convenience for tests.
85    #[must_use]
86    pub fn from_bytes(seed: [u8; 32]) -> Self {
87        Self::new(SigningKey::from_bytes(&seed))
88    }
89
90    /// Generate a fresh keypair off the OS CSPRNG.
91    ///
92    /// **Dev / test only.** The private key lives in process memory and is
93    /// lost on exit, so signatures are not reproducible and the key cannot
94    /// be rotated, escrowed, or HSM-protected. For production use
95    /// [`Self::from_bytes`] / [`Self::new`] with managed key material, or
96    /// implement [`SignerProvider`] against an HSM / KMS adapter.
97    #[must_use]
98    pub fn generate() -> Self {
99        Self::new(SigningKey::generate(&mut OsRng))
100    }
101
102    /// Lowercase hex of the 32-byte Ed25519 verifying (public) key — always
103    /// 64 hex chars. Informational only at verify time (see the `BundleSigner`
104    /// impl's CWE-347 note); the trusted key is resolved via `KeyResolver`.
105    #[must_use]
106    pub fn public_key_hex(&self) -> String {
107        hex::encode(self.key.verifying_key().to_bytes())
108    }
109
110    /// Stable key identifier derived from the verifying-key fingerprint.
111    /// Used as [`SignatureBytes::key_id`] so a verifier's `KeyResolver`
112    /// can resolve the trusted public key.
113    #[must_use]
114    pub fn key_id(&self) -> String {
115        let pub_hex = self.public_key_hex();
116        format!("klieo-ops-{}", &pub_hex[..KEY_ID_PREFIX_LEN])
117    }
118
119    /// Verify that `signature_hex` is a valid Ed25519 signature over
120    /// `payload` under this signer's keypair.
121    ///
122    /// Returns `false` on malformed hex, wrong-length signature bytes, or
123    /// a cryptographic mismatch — never panics. This is a convenience for
124    /// callers that hold the signer; bundle verification proper resolves
125    /// the trusted key via a `KeyResolver` (see [`crate::SignerProvider`]
126    /// docs and `klieo_provenance::EvidenceBundle::verify`).
127    #[must_use]
128    pub fn verify(&self, payload: &[u8], signature_hex: &str) -> bool {
129        let Ok(sig_bytes) = hex::decode(signature_hex) else {
130            return false;
131        };
132        let Ok(sig) = ed25519_dalek::Signature::from_slice(&sig_bytes) else {
133            return false;
134        };
135        self.verifying_key().verify(payload, &sig).is_ok()
136    }
137
138    fn verifying_key(&self) -> VerifyingKey {
139        self.key.verifying_key()
140    }
141}
142
143// CWE-347 (improper verification of cryptographic signature) defence:
144// `SignatureBytes::public_key_hex` produced here is INFORMATIONAL ONLY.
145// A verifier MUST resolve the trusted key from a `KeyResolver` keyed on
146// `key_id` and ignore the inline public key — a malicious signer can ship
147// any public key alongside a valid signature against it (the JWT
148// `alg=none` analogue). See `klieo_provenance::SignatureBytes` docs.
149#[async_trait]
150impl BundleSigner for SoftwareSigner {
151    async fn sign(&self, payload: &[u8]) -> Result<SignatureBytes, ProvenanceError> {
152        let signature = self.key.sign(payload);
153        Ok(SignatureBytes {
154            algorithm: ED25519_ALGORITHM.to_string(),
155            signature_hex: hex::encode(signature.to_bytes()),
156            public_key_hex: self.public_key_hex(),
157            key_id: Some(self.key_id()),
158        })
159    }
160}
161
162#[async_trait]
163impl SignerProvider for SoftwareSigner {
164    async fn sign_handoff(&self, payload: &[u8]) -> Result<SignedPayload, SignerError> {
165        let sig = self.key.sign(payload);
166        Ok(SignedPayload {
167            signature: sig.to_bytes(),
168            verifying_key: self.key.verifying_key().to_bytes(),
169        })
170    }
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176    use ed25519_dalek::{Signature, Verifier, VerifyingKey};
177    use klieo_provenance::BundleSigner;
178
179    #[tokio::test]
180    async fn software_signer_produces_verifiable_signature() {
181        let signer = SoftwareSigner::from_bytes([7u8; 32]);
182        let payload = b"hello";
183        let signed = signer.sign_handoff(payload).await.expect("sign");
184        let vk = VerifyingKey::from_bytes(&signed.verifying_key).expect("vk");
185        let sig = Signature::from_bytes(&signed.signature);
186        vk.verify(payload, &sig).expect("verify");
187    }
188
189    #[tokio::test]
190    async fn bundle_signer_emits_ed25519_signature_bytes() {
191        let signer = SoftwareSigner::from_bytes([7u8; 32]);
192        let payload = b"evidence-bundle-payload";
193        let sig = BundleSigner::sign(&signer, payload).await.expect("sign");
194        assert_eq!(sig.algorithm, "ed25519");
195        assert_eq!(sig.public_key_hex, signer.public_key_hex());
196
197        let key_id = signer.key_id();
198        assert_eq!(
199            sig.key_id.as_deref(),
200            Some(key_id.as_str()),
201            "bundle key_id must match the signer's stable key_id"
202        );
203        const KEY_ID_PREFIX: &str = "klieo-ops-";
204        assert!(
205            key_id.starts_with(KEY_ID_PREFIX),
206            "key_id must use the documented prefix"
207        );
208        assert_eq!(
209            key_id.len(),
210            KEY_ID_PREFIX.len() + KEY_ID_PREFIX_LEN,
211            "key_id is the prefix plus {KEY_ID_PREFIX_LEN} hex chars of the verifying key"
212        );
213    }
214
215    #[tokio::test]
216    async fn bundle_signature_round_trips_against_public_key() {
217        let signer = SoftwareSigner::from_bytes([7u8; 32]);
218        let payload = b"evidence-bundle-payload";
219        let sig = BundleSigner::sign(&signer, payload).await.expect("sign");
220        assert!(
221            signer.verify(payload, &sig.signature_hex),
222            "real payload must verify"
223        );
224        assert!(
225            !signer.verify(b"tampered-payload", &sig.signature_hex),
226            "tampered payload must be rejected"
227        );
228        assert!(
229            !signer.verify(payload, "deadbeef"),
230            "malformed signature hex must be rejected"
231        );
232    }
233
234    #[tokio::test]
235    async fn generated_signer_round_trips() {
236        let signer = SoftwareSigner::generate();
237        let payload = b"fresh-keypair-payload";
238        let sig = BundleSigner::sign(&signer, payload).await.expect("sign");
239        assert!(signer.verify(payload, &sig.signature_hex));
240        assert!(!signer.verify(b"other", &sig.signature_hex));
241    }
242}