Skip to main content

av_receipts/
keys.rs

1//! Signing keys: the `Signer` trait (in-process Ed25519 now, KMS post-MVP) and
2//! an offline `Keyring` for verification with key rotation via key ids.
3
4use ed25519_dalek::{Signature, Signer as DalekSigner, SigningKey, VerifyingKey};
5use std::collections::HashMap;
6
7/// Abstract signer — the KMS integration point (brief Module G, post-MVP).
8pub trait Signer: Send + Sync {
9    /// Stable identifier for the signing key (embedded in receipts).
10    fn key_id(&self) -> &str;
11    /// Sign `msg`, returning the 64-byte Ed25519 signature.
12    fn sign(&self, msg: &[u8]) -> [u8; 64];
13    /// The corresponding public key (32 bytes).
14    fn public_key_bytes(&self) -> [u8; 32];
15}
16
17/// In-process Ed25519 signer.
18pub struct Ed25519Signer {
19    key_id: String,
20    key: SigningKey,
21}
22
23impl std::fmt::Debug for Ed25519Signer {
24    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
25        // Never print secret key material.
26        f.debug_struct("Ed25519Signer")
27            .field("key_id", &self.key_id)
28            .finish_non_exhaustive()
29    }
30}
31
32impl Ed25519Signer {
33    /// Generate a fresh keypair. The key id is derived from the public key
34    /// (first 32 hex chars of its SHA-256) so ids are collision-resistant and
35    /// never chosen by an attacker.
36    ///
37    /// Defense-in-depth against a compromised
38    /// `getrandom` (VM without entropy, cloud image with a broken
39    /// `/dev/urandom`) — regenerate rather than accept an
40    /// all-zero or all-0xFF seed. Reader-side already refuses
41    /// these known-weak seeds; this closes the gap
42    /// for generator-side. Astronomically unlikely from a healthy
43    /// OsRng, but the failure mode is silent installation of a
44    /// globally-predictable keypair — cheap to guard.
45    pub fn generate() -> Self {
46        use rand::TryRng;
47        loop {
48            // rand 0.9+ removed the infallible `OsRng` that
49            // `SigningKey::generate` (rand_core 0.6) accepted; draw the
50            // seed through the fallible `SysRng` interface instead and
51            // build the key from bytes.
52            //
53            // Wrap the raw seed buffer in `Zeroizing` so the
54            // stack slot zeroes on scope exit. A bare `[u8; 32]` has no
55            // Drop, so it lingers in freed stack memory (recoverable from
56            // a core dump).
57            let mut bytes = zeroize::Zeroizing::new([0u8; 32]);
58            // An OS entropy failure is unrecoverable for key generation;
59            // the pre-0.9 `OsRng` path aborted here too (it panicked
60            // inside `getrandom`). Panicking keeps the infallible API.
61            #[allow(clippy::expect_used)]
62            rand::rngs::SysRng
63                .try_fill_bytes(&mut *bytes)
64                .expect("OS random source unavailable");
65            if *bytes == [0u8; 32] || *bytes == [0xFFu8; 32] {
66                continue;
67            }
68            let key = SigningKey::from_bytes(&bytes);
69            let key_id = derive_key_id(&key.verifying_key());
70            return Self { key_id, key };
71        }
72    }
73
74    /// Load from a 32-byte secret seed.
75    ///
76    /// Takes `&[u8; 32]` (borrow), not by value. Passing
77    /// the seed by value materializes a caller-owned temp slot on
78    /// the stack that Rust does not guarantee to zeroize on drop —
79    /// a `Zeroizing<[u8; 32]>` wrapper only zeroizes
80    /// the slot IT owns, not the copy the callee received. By taking
81    /// a reference we let the caller keep the seed inside a
82    /// `Zeroizing` and never lose control of the memory.
83    ///
84    /// # Caveat: caller must reject known-weak seeds
85    ///
86    /// This constructor does NOT reject the all-zero or all-0xFF seeds
87    /// (both produce globally-predictable keypairs). The production
88    /// seed-loading path in `av-harness::main::read_signer` refuses
89    /// both before reaching this function (see the `[0u8; 32]` /
90    /// `[0xFFu8; 32]` guard in `av-harness/src/main.rs`); every OTHER
91    /// caller (tests, `avctl keygen`) must apply the same policy or
92    /// use [`Ed25519Signer::generate`] which loops until a non-weak
93    /// seed is drawn. This contract is documented in-signature
94    /// so a future refactor cannot silently drop the pre-validation.
95    pub fn from_seed(seed: &[u8; 32]) -> Self {
96        let key = SigningKey::from_bytes(seed);
97        let key_id = derive_key_id(&key.verifying_key());
98        Self { key_id, key }
99    }
100
101    /// Export the 32-byte secret seed (for `avctl keygen` persistence).
102    ///
103    /// Returns `Zeroizing<[u8; 32]>` so the caller's
104    /// receiving slot zeroes on drop — historically the bare
105    /// `[u8; 32]` return let a copy linger in freed stack/heap.
106    pub fn seed(&self) -> zeroize::Zeroizing<[u8; 32]> {
107        zeroize::Zeroizing::new(self.key.to_bytes())
108    }
109}
110
111/// `sha256(pubkey)[..16]` hex — 32 chars (a 128-bit id; ~2^64 birthday bound).
112///
113/// 64 bits would put birthday attacks at ~2^32, cheap on modern hardware;
114/// 128 bits pushes the birthday bound to ~2^64, comfortably infeasible.
115fn derive_key_id(vk: &VerifyingKey) -> String {
116    let digest = av_core::digest::sha256_hex(vk.as_bytes());
117    digest.chars().take(32).collect()
118}
119
120impl Signer for Ed25519Signer {
121    fn key_id(&self) -> &str {
122        &self.key_id
123    }
124
125    fn sign(&self, msg: &[u8]) -> [u8; 64] {
126        self.key.sign(msg).to_bytes()
127    }
128
129    fn public_key_bytes(&self) -> [u8; 32] {
130        self.key.verifying_key().to_bytes()
131    }
132}
133
134/// Verification keyring: key id → public key. Old receipts stay verifiable
135/// after rotation as long as their key remains in the ring.
136#[derive(Debug, Default, Clone)]
137pub struct Keyring {
138    keys: HashMap<String, VerifyingKey>,
139}
140
141/// Keyring / verification errors.
142#[derive(Debug, thiserror::Error)]
143#[non_exhaustive]
144pub enum KeyError {
145    /// Public key bytes malformed.
146    #[error("invalid public key: {0}")]
147    InvalidKey(String),
148    /// Public key is a small-order Curve25519 point (identity or a torsion
149    /// element). Such keys let any 64-byte value verify as a signature over
150    /// almost every message — always refused at add time so a compromised
151    /// keyring cannot be constructed.
152    #[error("public key is small-order (weak); refused to add to keyring")]
153    WeakKey,
154    /// Signature bytes malformed.
155    #[error("invalid signature encoding")]
156    InvalidSignature,
157    /// No key with this id.
158    #[error("unknown key id {0:?}")]
159    UnknownKeyId(String),
160    /// Signature did not verify.
161    #[error("signature verification failed for key id {0:?}")]
162    BadSignature(String),
163    /// Two distinct public keys derived the same key id.
164    #[error("key id {0:?} is already registered to a different public key")]
165    KeyMismatch(String),
166}
167
168impl Keyring {
169    /// Empty ring.
170    pub fn new() -> Self {
171        Self::default()
172    }
173
174    /// Add a key by raw public bytes; returns its derived key id.
175    ///
176    /// If the derived id already exists with a different public key, refuses
177    /// silently overwriting it and returns `KeyMismatch` — otherwise an
178    /// attacker who found a 128-bit collision could substitute their key for
179    /// an honest signer's.
180    ///
181    /// Refuses small-order (weak) public keys — the identity point and the
182    /// order-2/order-4 torsion elements. `Keyring::verify` already routes
183    /// through `verify_strict`, which denies them at verification time; this
184    /// is defense-in-depth so a poisoned ring cannot even be constructed
185    /// and so no other consumer of the ring (e.g. `Receipt::verify_embedded`)
186    /// can be tricked by relying on a non-strict verify. See ed25519-dalek
187    /// 3.0 `VerifyingKey::is_weak` / `verify_strict`.
188    pub fn add_key_bytes(&mut self, bytes: &[u8; 32]) -> Result<String, KeyError> {
189        let vk = VerifyingKey::from_bytes(bytes).map_err(|e| KeyError::InvalidKey(e.to_string()))?;
190        if vk.is_weak() {
191            return Err(KeyError::WeakKey);
192        }
193        let id = derive_key_id(&vk);
194        if let Some(existing) = self.keys.get(&id) {
195            if existing.as_bytes() != vk.as_bytes() {
196                return Err(KeyError::KeyMismatch(id));
197            }
198            return Ok(id);
199        }
200        self.keys.insert(id.clone(), vk);
201        Ok(id)
202    }
203
204    /// Add the public half of a signer.
205    pub fn add_signer(&mut self, signer: &dyn Signer) -> Result<String, KeyError> {
206        self.add_key_bytes(&signer.public_key_bytes())
207    }
208
209    /// Verify `sig` over `msg` with the key identified by `key_id`.
210    ///
211    /// Uses `verify_strict` (ed25519-dalek), NOT `verify`:
212    /// non-strict `verify` accepts signatures whose R component or the
213    /// verifying key itself is a small-order Curve25519 point. In that
214    /// regime a single signature can validate against multiple distinct
215    /// messages, so a mutated receipt body whose signature is untouched
216    /// can still verify. `verify_strict` rejects small-order R AND
217    /// small-order pubkeys (dalek verifying.rs:367-390 in 3.0.0) and is
218    /// the documented recommendation for new protocols.
219    pub fn verify(&self, key_id: &str, msg: &[u8], sig: &[u8]) -> Result<(), KeyError> {
220        let vk = self
221            .keys
222            .get(key_id)
223            .ok_or_else(|| KeyError::UnknownKeyId(key_id.to_owned()))?;
224        let sig_bytes: [u8; 64] = sig.try_into().map_err(|_| KeyError::InvalidSignature)?;
225        let signature = Signature::from_bytes(&sig_bytes);
226        vk.verify_strict(msg, &signature)
227            .map_err(|_| KeyError::BadSignature(key_id.to_owned()))
228    }
229
230    /// Number of keys in the ring.
231    pub fn len(&self) -> usize {
232        self.keys.len()
233    }
234
235    /// True if the ring holds no keys.
236    pub fn is_empty(&self) -> bool {
237        self.keys.is_empty()
238    }
239}
240
241#[cfg(test)]
242mod tests {
243    #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
244
245    use super::*;
246
247    #[test]
248    fn sign_verify_roundtrip() {
249        let signer = Ed25519Signer::generate();
250        let mut ring = Keyring::new();
251        let id = ring.add_signer(&signer).unwrap();
252        assert_eq!(id, signer.key_id());
253        let sig = signer.sign(b"hello");
254        ring.verify(&id, b"hello", &sig).unwrap();
255    }
256
257    #[test]
258    fn wrong_message_fails() {
259        let signer = Ed25519Signer::generate();
260        let mut ring = Keyring::new();
261        let id = ring.add_signer(&signer).unwrap();
262        let sig = signer.sign(b"hello");
263        assert!(matches!(
264            ring.verify(&id, b"HELLO", &sig),
265            Err(KeyError::BadSignature(_))
266        ));
267    }
268
269    #[test]
270    fn wrong_key_fails() {
271        let a = Ed25519Signer::generate();
272        let b = Ed25519Signer::generate();
273        let mut ring = Keyring::new();
274        ring.add_signer(&b).unwrap();
275        let sig = a.sign(b"msg");
276        // b's ring doesn't know a's key id.
277        assert!(matches!(
278            ring.verify(a.key_id(), b"msg", &sig),
279            Err(KeyError::UnknownKeyId(_))
280        ));
281    }
282
283    #[test]
284    fn truncated_signature_rejected() {
285        let signer = Ed25519Signer::generate();
286        let mut ring = Keyring::new();
287        let id = ring.add_signer(&signer).unwrap();
288        let sig = signer.sign(b"msg");
289        assert!(matches!(
290            ring.verify(&id, b"msg", &sig[..63]),
291            Err(KeyError::InvalidSignature)
292        ));
293    }
294
295    #[test]
296    fn seed_roundtrip_preserves_identity() {
297        let a = Ed25519Signer::generate();
298        let b = Ed25519Signer::from_seed(&a.seed());
299        assert_eq!(a.key_id(), b.key_id());
300        assert_eq!(a.public_key_bytes(), b.public_key_bytes());
301    }
302
303    /// Pins the key_id derivation algorithm to the documented recipe:
304    /// the first 32 hex characters of the SHA-256 digest of the raw
305    /// 32-byte public key (VERIFYING-A-RECEIPT.md §4, LIMITS.md).
306    /// External verifiers recompute this fingerprint independently, so
307    /// silently changing the hash or the truncation would break every
308    /// out-of-tree audit pipeline. Fixed vector: seed = [0x01; 32].
309    #[test]
310    fn key_id_is_truncated_sha256_of_public_key() {
311        let signer = Ed25519Signer::from_seed(&[0x01; 32]);
312        assert_eq!(
313            hex::encode(signer.public_key_bytes()),
314            "8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c"
315        );
316        assert_eq!(signer.key_id(), "34750f98bd59fcfc946da45aaabe933b");
317        let recomputed: String = av_core::digest::sha256_hex(&signer.public_key_bytes())
318            .chars()
319            .take(32)
320            .collect();
321        assert_eq!(signer.key_id(), recomputed);
322    }
323
324    #[test]
325    fn rotation_keeps_old_receipts_verifiable() {
326        let old = Ed25519Signer::generate();
327        let new = Ed25519Signer::generate();
328        let mut ring = Keyring::new();
329        ring.add_signer(&old).unwrap();
330        ring.add_signer(&new).unwrap();
331        let old_sig = old.sign(b"old receipt");
332        let new_sig = new.sign(b"new receipt");
333        ring.verify(old.key_id(), b"old receipt", &old_sig).unwrap();
334        ring.verify(new.key_id(), b"new receipt", &new_sig).unwrap();
335        assert_eq!(ring.len(), 2);
336    }
337
338    #[test]
339    fn debug_never_leaks_secret() {
340        let signer = Ed25519Signer::generate();
341        let dbg = format!("{signer:?}");
342        let seed_hex = hex::encode(signer.seed());
343        assert!(!dbg.contains(&seed_hex), "Debug output leaked the seed");
344        // Debug must contain something identifying (the key_id label).
345        assert!(dbg.contains(signer.key_id()));
346    }
347
348    #[test]
349    fn keyring_is_empty_reflects_the_ring_state() {
350        let mut ring = Keyring::new();
351        assert!(ring.is_empty(), "fresh ring must be empty");
352        assert_eq!(ring.len(), 0);
353        let s = Ed25519Signer::generate();
354        ring.add_signer(&s).unwrap();
355        assert!(!ring.is_empty(), "ring with one key must not be empty");
356        assert_eq!(ring.len(), 1);
357    }
358
359    #[test]
360    fn key_id_is_128_bits_wide() {
361        let signer = Ed25519Signer::generate();
362        assert_eq!(
363            signer.key_id().len(),
364            32,
365            "key_id must be 32 hex chars (128 bits) so birthday collisions cost ~2^64"
366        );
367    }
368
369    #[test]
370    fn keyring_refuses_to_overwrite_colliding_id_with_different_key() {
371        let mut ring = Keyring::new();
372        let honest = Ed25519Signer::generate();
373        let id = ring.add_signer(&honest).unwrap();
374        let attacker = Ed25519Signer::generate();
375        assert_ne!(honest.public_key_bytes(), attacker.public_key_bytes());
376        // We can't manufacture a real SHA-256 collision, so simulate one at
377        // the low-level map: a ring already holding the ATTACKER's key under
378        // the honest key's id. Adding the honest key (whose derived id now
379        // collides with different bytes) must be refused, not overwritten.
380        let attacker_vk = VerifyingKey::from_bytes(&attacker.public_key_bytes()).unwrap();
381        let mut fresh = Keyring::new();
382        fresh.keys.insert(id.clone(), attacker_vk);
383        let err = fresh
384            .add_key_bytes(&honest.public_key_bytes())
385            .expect_err("must refuse overwrite of a colliding id");
386        assert!(matches!(err, KeyError::KeyMismatch(_)), "got {err:?}");
387    }
388
389    #[test]
390    fn keyring_add_is_idempotent_for_the_same_key() {
391        let mut ring = Keyring::new();
392        let signer = Ed25519Signer::generate();
393        let id1 = ring.add_signer(&signer).unwrap();
394        let id2 = ring.add_signer(&signer).unwrap();
395        assert_eq!(id1, id2);
396        assert_eq!(ring.len(), 1);
397    }
398}