Skip to main content

klieo_provenance/
verify.rs

1//! Verifier port + concrete `Ed25519Verifier` and `StaticKeyResolver`.
2//!
3//! The `klieo-provenance` crate ships a [`Signer`](crate::Signer) port for
4//! producing signed [`EvidenceBundle`](crate::EvidenceBundle)s, but until
5//! 0.1.3 the verification half was missing — every downstream consumer
6//! re-implemented canonical payload reconstruction, algorithm dispatch,
7//! and chain Merkle verification. Worse, the bundle's
8//! [`SignatureBytes::public_key_hex`](crate::SignatureBytes) was the only
9//! key reference available, inviting a JWT `alg=none`-style forgery (a
10//! malicious signer ships a bundle with their OWN public key and a valid
11//! signature against it).
12//!
13//! This module closes both gaps:
14//!
15//! - [`Verifier`] decouples algorithm from key material. Implementations
16//!   advertise the algorithm tag they support and verify a single
17//!   `(public_key, payload, signature)` triple.
18//! - [`KeyResolver`] maps a `key_id` to a trusted public key. The
19//!   verifier NEVER reads `public_key_hex` from the bundle — trust comes
20//!   exclusively from the resolver.
21//! - [`EvidenceBundle::verify`](crate::EvidenceBundle::verify) composes
22//!   them: rebuild the canonical payload, look up the trusted key,
23//!   delegate the algorithm-specific check to the verifier, then re-walk
24//!   the Merkle chain.
25//!
26//! [`Ed25519Verifier`] + [`StaticKeyResolver`] cover the common case:
27//! ed25519-dalek-backed signatures, key material loaded from a config
28//! file at boot.
29
30use std::collections::HashMap;
31
32use ed25519_dalek::{Signature, VerifyingKey};
33use thiserror::Error;
34
35/// Failure cases for evidence-bundle verification.
36#[non_exhaustive]
37#[derive(Debug, Error)]
38pub enum VerifyError {
39    /// The bundle's `algorithm` tag is not in the verifier's allowlist.
40    /// CWE-327 / CWE-347 defence: rejects bundles claiming `"none"` or
41    /// an unknown algorithm before any cryptographic work happens.
42    #[error("algorithm {0:?} not in verifier allowlist")]
43    UnsupportedAlgorithm(String),
44    /// The key_id named in the bundle is not in the trust store.
45    #[error("unknown key_id: {0}")]
46    UnknownKeyId(String),
47    /// The signature did not validate against the trusted public key.
48    #[error("signature mismatch")]
49    SignatureMismatch,
50    /// The chain failed Merkle re-verification (a tampered or reordered
51    /// entry).
52    #[error("merkle chain broken: {0}")]
53    Chain(String),
54    /// Payload reconstruction or hex decoding failed.
55    #[error("payload reconstruction failed: {0}")]
56    Payload(String),
57    /// The public key returned by the resolver is malformed (wrong
58    /// length, bad encoding, etc.).
59    #[error("invalid trusted key: {0}")]
60    InvalidKey(String),
61}
62
63/// Trust store: given a bundle's `key_id`, returns the trusted public
64/// key bytes. The verifier NEVER reads `public_key_hex` from the bundle;
65/// trust derives exclusively from this resolver.
66pub trait KeyResolver: Send + Sync {
67    /// Return the trusted public key (raw bytes) registered for
68    /// `key_id`, or `None` if the key is unknown.
69    fn resolve(&self, key_id: &str) -> Option<Vec<u8>>;
70}
71
72/// Algorithm-specific signature checker.
73///
74/// One implementation per signature algorithm the deployment trusts.
75/// Wire `Ed25519Verifier` for production; bring your own for HSM /
76/// FIPS-mode / multi-algorithm setups.
77pub trait Verifier: Send + Sync {
78    /// Algorithm tag this verifier accepts. Bundles whose
79    /// `signature.algorithm` does not match this string are rejected
80    /// before any cryptographic work runs. Common values:
81    /// `"ed25519"`.
82    fn algorithm(&self) -> &str;
83
84    /// Verify `signature` against `payload` using `public_key`.
85    /// Implementations must use constant-time comparison and reject
86    /// malformed key / signature lengths with `VerifyError::InvalidKey`
87    /// or `VerifyError::SignatureMismatch` respectively.
88    fn verify(
89        &self,
90        public_key: &[u8],
91        payload: &[u8],
92        signature: &[u8],
93    ) -> Result<(), VerifyError>;
94}
95
96/// Ed25519 implementation backed by `ed25519-dalek`. Algorithm tag is
97/// the literal string `"ed25519"`.
98pub struct Ed25519Verifier;
99
100impl Ed25519Verifier {
101    /// Construct a new ed25519 verifier.
102    pub fn new() -> Self {
103        Self
104    }
105}
106
107impl Default for Ed25519Verifier {
108    fn default() -> Self {
109        Self::new()
110    }
111}
112
113impl Verifier for Ed25519Verifier {
114    fn algorithm(&self) -> &str {
115        "ed25519"
116    }
117
118    fn verify(
119        &self,
120        public_key: &[u8],
121        payload: &[u8],
122        signature: &[u8],
123    ) -> Result<(), VerifyError> {
124        let key_bytes: [u8; 32] = public_key.try_into().map_err(|_| {
125            VerifyError::InvalidKey(format!(
126                "ed25519 public key must be 32 bytes, got {}",
127                public_key.len()
128            ))
129        })?;
130        let verifying = VerifyingKey::from_bytes(&key_bytes)
131            .map_err(|e| VerifyError::InvalidKey(e.to_string()))?;
132        let sig_bytes: [u8; 64] = signature
133            .try_into()
134            .map_err(|_| VerifyError::SignatureMismatch)?;
135        let sig = Signature::from_bytes(&sig_bytes);
136        verifying
137            .verify_strict(payload, &sig)
138            .map_err(|_| VerifyError::SignatureMismatch)
139    }
140}
141
142/// ECDSA-P256 implementation backed by the `p256` crate. Algorithm
143/// tag is the literal string `"ecdsa-p256"`. Compatible with the
144/// SignatureBytes shape emitted by KMS-rooted signers
145/// (`examples/aws-kms-signer/`, see ADR-009).
146///
147/// The verifier expects:
148/// - `public_key` as an SPKI-encoded DER blob (this is what AWS KMS
149///   `GetPublicKey` returns natively).
150/// - `signature` as the DER-encoded ECDSA signature pair (what AWS
151///   KMS `Sign` returns natively when `SigningAlgorithm =
152///   ECDSA_SHA_256`).
153///
154/// Gated by the `ecdsa-p256` cargo feature.
155///
156/// ## CWE-347 — malleability note
157///
158/// Standard ECDSA accepts both halves of the `(r, s)` / `(r, n-s)`
159/// malleability pair. AWS KMS emits either form depending on the
160/// internal nonce, so this verifier accepts both. Consumers MUST
161/// NOT key bundle deduplication, replay-defence, or any uniqueness
162/// invariant on `SignatureBytes::signature_hex` — two distinct hex
163/// strings can validly authenticate the same payload+key. Use the
164/// canonical payload hash or the bundle id as the dedup key
165/// instead.
166#[cfg(feature = "ecdsa-p256")]
167pub struct EcdsaP256Verifier;
168
169#[cfg(feature = "ecdsa-p256")]
170impl EcdsaP256Verifier {
171    /// Construct a new ECDSA-P256 verifier.
172    pub fn new() -> Self {
173        Self
174    }
175}
176
177#[cfg(feature = "ecdsa-p256")]
178impl Default for EcdsaP256Verifier {
179    fn default() -> Self {
180        Self::new()
181    }
182}
183
184#[cfg(feature = "ecdsa-p256")]
185impl Verifier for EcdsaP256Verifier {
186    fn algorithm(&self) -> &str {
187        "ecdsa-p256"
188    }
189
190    fn verify(
191        &self,
192        public_key: &[u8],
193        payload: &[u8],
194        signature: &[u8],
195    ) -> Result<(), VerifyError> {
196        use p256::ecdsa::signature::Verifier as _;
197        use p256::ecdsa::{DerSignature, VerifyingKey};
198        use p256::pkcs8::DecodePublicKey;
199
200        let verifying = VerifyingKey::from_public_key_der(public_key)
201            .map_err(|e| VerifyError::InvalidKey(format!("p256 SPKI decode: {e}")))?;
202        // Malformed signature bytes are a SignatureMismatch per the
203        // Verifier trait contract (sibling Ed25519Verifier does the
204        // same on wrong-length sigs); InvalidKey is reserved for the
205        // trust-store key-bytes branch above.
206        let sig =
207            DerSignature::from_bytes(signature).map_err(|_| VerifyError::SignatureMismatch)?;
208        verifying
209            .verify(payload, &sig)
210            .map_err(|_| VerifyError::SignatureMismatch)
211    }
212}
213
214/// In-process key resolver backed by a static `HashMap`.
215///
216/// Load trusted keys at boot from a config file / KMS / environment
217/// once; then look them up by `key_id` at verify time. For rotation,
218/// hold multiple `(key_id, key_bytes)` pairs simultaneously and remove
219/// retired ones on a schedule that exceeds the longest-lived bundle
220/// retention period.
221#[derive(Default)]
222pub struct StaticKeyResolver {
223    keys: HashMap<String, Vec<u8>>,
224}
225
226impl StaticKeyResolver {
227    /// Build an empty resolver.
228    pub fn new() -> Self {
229        Self::default()
230    }
231
232    /// Add a `(key_id, public_key_bytes)` pair to the trust store.
233    /// Returns `self` for chained construction.
234    pub fn with(mut self, key_id: impl Into<String>, public_key: Vec<u8>) -> Self {
235        self.keys.insert(key_id.into(), public_key);
236        self
237    }
238
239    /// Add a key after construction.
240    pub fn insert(&mut self, key_id: impl Into<String>, public_key: Vec<u8>) {
241        self.keys.insert(key_id.into(), public_key);
242    }
243}
244
245impl KeyResolver for StaticKeyResolver {
246    fn resolve(&self, key_id: &str) -> Option<Vec<u8>> {
247        self.keys.get(key_id).cloned()
248    }
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254    use ed25519_dalek::{Signer as DalekSigner, SigningKey};
255    use rand::rngs::OsRng;
256
257    #[cfg(feature = "ecdsa-p256")]
258    mod ecdsa_p256 {
259        use super::super::{EcdsaP256Verifier, Verifier, VerifyError};
260        use p256::ecdsa::signature::Signer as _;
261        use p256::ecdsa::{DerSignature, SigningKey};
262        use p256::pkcs8::EncodePublicKey;
263
264        fn fresh_signer_and_pub_der() -> (SigningKey, Vec<u8>) {
265            let signing = SigningKey::random(&mut rand::rngs::OsRng);
266            let pub_der = signing
267                .verifying_key()
268                .to_public_key_der()
269                .unwrap()
270                .as_bytes()
271                .to_vec();
272            (signing, pub_der)
273        }
274
275        #[test]
276        fn ecdsa_verifier_advertises_algorithm_tag() {
277            assert_eq!(EcdsaP256Verifier::new().algorithm(), "ecdsa-p256");
278        }
279
280        #[test]
281        fn ecdsa_verifier_round_trips_a_real_signature() {
282            let (signing, pub_der) = fresh_signer_and_pub_der();
283            let payload = b"hello provenance";
284            let sig: DerSignature = signing.sign(payload);
285            EcdsaP256Verifier::new()
286                .verify(&pub_der, payload, sig.as_bytes())
287                .expect("valid ECDSA-P256 signature must verify");
288        }
289
290        #[test]
291        fn ecdsa_verifier_rejects_tampered_payload() {
292            let (signing, pub_der) = fresh_signer_and_pub_der();
293            let sig: DerSignature = signing.sign(b"original");
294            let err = EcdsaP256Verifier::new()
295                .verify(&pub_der, b"tampered", sig.as_bytes())
296                .unwrap_err();
297            assert!(matches!(err, VerifyError::SignatureMismatch));
298        }
299
300        #[test]
301        fn ecdsa_verifier_rejects_malformed_public_key() {
302            let err = EcdsaP256Verifier::new()
303                .verify(&[0u8; 8], b"payload", &[0u8; 70])
304                .unwrap_err();
305            assert!(matches!(err, VerifyError::InvalidKey(_)));
306        }
307
308        #[test]
309        fn ecdsa_verifier_rejects_malformed_signature() {
310            let (_, pub_der) = fresh_signer_and_pub_der();
311            // Malformed signature bytes must be SignatureMismatch (per
312            // Verifier trait contract); InvalidKey is reserved for the
313            // trust-store key-bytes branch.
314            let err = EcdsaP256Verifier::new()
315                .verify(&pub_der, b"payload", &[0u8; 4])
316                .unwrap_err();
317            assert!(
318                matches!(err, VerifyError::SignatureMismatch),
319                "expected SignatureMismatch, got {err:?}"
320            );
321        }
322
323        /// CWE-347 awareness — the verifier accepts BOTH halves of
324        /// the ECDSA malleability pair because AWS KMS emits either
325        /// form. Both must verify under the standard ECDSA equation.
326        /// The mitigation against malleable-sig replay/dedup is
327        /// documented at the type level: consumers MUST key
328        /// uniqueness on the canonical payload hash, not on
329        /// `signature_hex`.
330        #[test]
331        fn ecdsa_verifier_accepts_both_low_s_and_high_s_signatures() {
332            use p256::ecdsa::{DerSignature, Signature};
333            let (signing, pub_der) = fresh_signer_and_pub_der();
334            let payload = b"malleability-acceptance";
335
336            let sig_low: DerSignature = signing.sign(payload);
337            let sig_low_decoded: Signature = sig_low.clone().try_into().expect("decode");
338            let (r, s) = sig_low_decoded.split_scalars();
339            let high_s = -*s.as_ref();
340            let high_sig = Signature::from_scalars(*r.as_ref(), high_s).expect("high-S construct");
341            let high_der: DerSignature = high_sig.into();
342
343            EcdsaP256Verifier::new()
344                .verify(&pub_der, payload, sig_low.as_bytes())
345                .expect("low-S form must verify");
346            EcdsaP256Verifier::new()
347                .verify(&pub_der, payload, high_der.as_bytes())
348                .expect("high-S form must verify (documented CWE-347 acceptance)");
349        }
350    }
351
352    #[test]
353    fn ed25519_verifier_round_trips_a_real_signature() {
354        let mut csprng = OsRng;
355        let signing = SigningKey::generate(&mut csprng);
356        let verifying = signing.verifying_key();
357        let payload = b"hello provenance";
358        let sig = signing.sign(payload);
359
360        let verifier = Ed25519Verifier::new();
361        verifier
362            .verify(verifying.as_bytes(), payload, &sig.to_bytes())
363            .expect("valid signature must verify");
364    }
365
366    #[test]
367    fn ed25519_verifier_rejects_tampered_payload() {
368        let mut csprng = OsRng;
369        let signing = SigningKey::generate(&mut csprng);
370        let verifying = signing.verifying_key();
371        let payload = b"original";
372        let sig = signing.sign(payload);
373
374        let verifier = Ed25519Verifier::new();
375        let err = verifier
376            .verify(verifying.as_bytes(), b"tampered", &sig.to_bytes())
377            .unwrap_err();
378        assert!(matches!(err, VerifyError::SignatureMismatch));
379    }
380
381    #[test]
382    fn ed25519_verifier_rejects_wrong_key_length() {
383        let verifier = Ed25519Verifier::new();
384        let err = verifier
385            .verify(&[0u8; 16], b"payload", &[0u8; 64])
386            .unwrap_err();
387        assert!(matches!(err, VerifyError::InvalidKey(_)));
388    }
389
390    #[test]
391    fn ed25519_verifier_rejects_wrong_signature_length() {
392        let mut csprng = OsRng;
393        let key = SigningKey::generate(&mut csprng).verifying_key();
394        let verifier = Ed25519Verifier::new();
395        let err = verifier
396            .verify(key.as_bytes(), b"payload", &[0u8; 32])
397            .unwrap_err();
398        assert!(matches!(err, VerifyError::SignatureMismatch));
399    }
400
401    #[test]
402    fn static_key_resolver_returns_inserted_key() {
403        let r = StaticKeyResolver::new().with("k1", vec![1, 2, 3]);
404        assert_eq!(r.resolve("k1"), Some(vec![1, 2, 3]));
405        assert!(r.resolve("unknown").is_none());
406    }
407
408    #[test]
409    fn ed25519_verifier_advertises_algorithm_tag() {
410        assert_eq!(Ed25519Verifier::new().algorithm(), "ed25519");
411    }
412}