Skip to main content

treeship_core/attestation/
signer.rs

1use ed25519_dalek::{Signer as DalekSigner, SigningKey, VerifyingKey};
2use rand::rngs::OsRng;
3use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing};
4
5/// `Signer` is the interface for anything that can sign PAE bytes.
6///
7/// The abstraction lets us swap in hardware keys (Secure Enclave, YubiKey),
8/// FROST threshold keys, or test signers without changing the attestation layer.
9///
10/// Implementations must sign the PAE bytes as-is — never hash them again,
11/// never parse them. The PAE construction has already bound the payloadType
12/// and payload into a single unambiguous byte string.
13pub trait Signer: Send + Sync {
14    /// Signs the PAE bytes. Returns raw signature bytes.
15    /// Ed25519 signatures are always 64 bytes.
16    fn sign(&self, pae: &[u8]) -> Result<Vec<u8>, SignerError>;
17
18    /// The stable key identifier. Format: "key_<hex>" from the keystore.
19    fn key_id(&self) -> &str;
20
21    /// The raw public key bytes (32 bytes for Ed25519).
22    /// Used for key registration, Verifier construction, and fingerprinting.
23    fn public_key_bytes(&self) -> Vec<u8>;
24}
25
26/// An error produced by a Signer.
27#[derive(Debug)]
28pub struct SignerError(pub String);
29
30impl std::fmt::Display for SignerError {
31    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32        write!(f, "signer error: {}", self.0)
33    }
34}
35
36impl std::error::Error for SignerError {}
37
38/// The default Ed25519 signer.
39///
40/// Holds an Ed25519 signing key in memory. In production, keys are loaded
41/// from the encrypted keystore — this struct is never constructed with a
42/// plaintext key in application code.
43///
44/// `ed25519-dalek` uses the `subtle` crate throughout for constant-time
45/// scalar operations, providing side-channel resistance.
46///
47/// **Zeroization.** The 32-byte secret scalar is wiped from memory when the
48/// signer is dropped. `ed25519-dalek`'s `SigningKey` implements `Zeroize`,
49/// so the wrapper just delegates. Callers that copy the secret out via
50/// [`Ed25519Signer::secret_bytes`] receive a [`Zeroizing<[u8; 32]>`] so the
51/// caller-side copy is wiped on its own scope exit; pre-v0.10.4 returned a
52/// raw `[u8; 32]` that lingered on the caller's stack.
53///
54/// Note: this guarantees the secret is wiped when the *signer struct itself*
55/// is dropped. If the signer is stored in a long-lived cache (e.g. a service
56/// holding a default signer for the process lifetime), the wipe only fires
57/// when the cache entry is dropped. Hot paths that briefly load a signer
58/// should let it go out of scope as soon as signing is done.
59pub struct Ed25519Signer {
60    key_id: String,
61    signing_key: SigningKey,
62}
63
64impl Zeroize for Ed25519Signer {
65    fn zeroize(&mut self) {
66        // `ed25519-dalek::SigningKey` (with the `zeroize` feature) impls
67        // `ZeroizeOnDrop` via its own `Drop` that zeroizes the secret
68        // scalar. It does NOT expose a public `Zeroize` impl. To wipe the
69        // secret in place without dropping the wrapper, swap in a fresh
70        // throwaway key from a fixed zero seed; the old SigningKey's `Drop`
71        // fires immediately and wipes the real secret. The replacement
72        // is a deterministic non-secret value, immediately discarded.
73        let _ = std::mem::replace(&mut self.signing_key, SigningKey::from_bytes(&[0u8; 32]));
74        // key_id is a stable public identifier ("key_<hex>"); not secret.
75    }
76}
77
78impl Drop for Ed25519Signer {
79    fn drop(&mut self) {
80        // Belt-and-suspenders: `SigningKey`'s own `Drop` wipes its secret
81        // scalar (it impls `ZeroizeOnDrop`). The natural drop of `self`
82        // would already trigger that. Calling `zeroize()` here is a no-op
83        // in practice but makes the intent explicit at the wrapper layer
84        // and survives any future refactor that swaps the inner type.
85        self.zeroize();
86    }
87}
88
89// Marker trait: this wrapper's secret material is wiped on drop.
90impl ZeroizeOnDrop for Ed25519Signer {}
91
92impl Ed25519Signer {
93    /// Constructs an Ed25519Signer from a pre-loaded 64-byte private key.
94    pub fn from_bytes(key_id: impl Into<String>, bytes: &[u8; 32]) -> Result<Self, SignerError> {
95        let key_id = key_id.into();
96        if key_id.is_empty() {
97            return Err(SignerError("key_id must not be empty".into()));
98        }
99        let signing_key = SigningKey::from_bytes(bytes);
100        Ok(Self {
101            key_id,
102            signing_key,
103        })
104    }
105
106    /// Generates a fresh Ed25519 keypair using the OS CSPRNG.
107    ///
108    /// Used by `treeship init` and tests. In production, key generation
109    /// goes through the keystore which handles encrypted storage.
110    pub fn generate(key_id: impl Into<String>) -> Result<Self, SignerError> {
111        let key_id = key_id.into();
112        if key_id.is_empty() {
113            return Err(SignerError("key_id must not be empty".into()));
114        }
115        let signing_key = SigningKey::generate(&mut OsRng);
116        Ok(Self {
117            key_id,
118            signing_key,
119        })
120    }
121
122    /// Returns the `VerifyingKey` (public key) for building a `Verifier`.
123    pub fn verifying_key(&self) -> VerifyingKey {
124        self.signing_key.verifying_key()
125    }
126
127    /// Returns the 32-byte private key scalar wrapped in [`Zeroizing`] so the
128    /// caller-side copy is wiped on scope exit.
129    ///
130    /// Only exposed for keystore serialization — never log or transmit this.
131    /// Pre-v0.10.4 returned a raw `[u8; 32]`, which lingered on the caller's
132    /// stack until the slot was reused. Callers that need the raw array can
133    /// still dereference (`*signer.secret_bytes()`); the deref returns a copy,
134    /// so the new copy is then unguarded — prefer to keep working with the
135    /// `Zeroizing` wrapper.
136    pub fn secret_bytes(&self) -> Zeroizing<[u8; 32]> {
137        Zeroizing::new(self.signing_key.to_bytes())
138    }
139}
140
141impl Signer for Ed25519Signer {
142    fn sign(&self, pae: &[u8]) -> Result<Vec<u8>, SignerError> {
143        // ed25519-dalek's sign() uses the full ExpandedSecretKey internally,
144        // which includes both the scalar and the nonce material. No need for
145        // an external random source — the nonce is deterministic from the key
146        // and message (RFC 8032 §5.1.6).
147        let signature = self.signing_key.sign(pae);
148        Ok(signature.to_bytes().to_vec())
149    }
150
151    fn key_id(&self) -> &str {
152        &self.key_id
153    }
154
155    fn public_key_bytes(&self) -> Vec<u8> {
156        self.signing_key.verifying_key().to_bytes().to_vec()
157    }
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163    use crate::attestation::pae;
164
165    fn test_pae() -> Vec<u8> {
166        pae(
167            "application/vnd.treeship.action.v1+json",
168            b"{\"actor\":\"agent://test\"}",
169        )
170    }
171
172    #[test]
173    fn generate_succeeds() {
174        let s = Ed25519Signer::generate("key_test_01").unwrap();
175        assert_eq!(s.key_id(), "key_test_01");
176        assert_eq!(s.public_key_bytes().len(), 32);
177    }
178
179    #[test]
180    fn empty_key_id_errors() {
181        assert!(Ed25519Signer::generate("").is_err());
182    }
183
184    #[test]
185    fn sign_produces_64_bytes() {
186        let signer = Ed25519Signer::generate("key_test").unwrap();
187        let sig = signer.sign(&test_pae()).unwrap();
188        assert_eq!(sig.len(), 64, "Ed25519 signatures are always 64 bytes");
189    }
190
191    #[test]
192    fn sign_is_deterministic_for_same_key_and_message() {
193        // Ed25519 (RFC 8032) uses deterministic nonce — same key + message
194        // always produces the same signature. This is a security property:
195        // non-deterministic signing would leak key material if the RNG is weak.
196        let signer = Ed25519Signer::generate("key_det").unwrap();
197        let msg = test_pae();
198        let sig1 = signer.sign(&msg).unwrap();
199        let sig2 = signer.sign(&msg).unwrap();
200        assert_eq!(sig1, sig2, "Ed25519 signing must be deterministic");
201    }
202
203    #[test]
204    fn different_keys_produce_different_signatures() {
205        let s1 = Ed25519Signer::generate("key_1").unwrap();
206        let s2 = Ed25519Signer::generate("key_2").unwrap();
207        let msg = test_pae();
208        assert_ne!(
209            s1.sign(&msg).unwrap(),
210            s2.sign(&msg).unwrap(),
211            "Different keys must produce different signatures"
212        );
213    }
214
215    #[test]
216    fn different_messages_produce_different_signatures() {
217        let signer = Ed25519Signer::generate("key_test").unwrap();
218        let pae1 = pae("application/vnd.treeship.action.v1+json", b"{\"a\":1}");
219        let pae2 = pae("application/vnd.treeship.approval.v1+json", b"{\"a\":1}");
220        assert_ne!(signer.sign(&pae1).unwrap(), signer.sign(&pae2).unwrap());
221    }
222
223    #[test]
224    fn roundtrip_from_bytes() {
225        let original = Ed25519Signer::generate("key_rt").unwrap();
226        let secret = original.secret_bytes();
227        // `secret` is Zeroizing<[u8; 32]>; deref to the inner array for
228        // `from_bytes`'s `&[u8; 32]` parameter.
229        let restored = Ed25519Signer::from_bytes("key_rt", &*secret).unwrap();
230
231        assert_eq!(original.public_key_bytes(), restored.public_key_bytes());
232
233        let msg = test_pae();
234        let sig_a = original.sign(&msg).unwrap();
235        let sig_b = restored.sign(&msg).unwrap();
236        assert_eq!(
237            sig_a, sig_b,
238            "Restored key must produce identical signatures"
239        );
240    }
241
242    /// Proves the `Zeroize` impl actually wipes the secret scalar.
243    ///
244    /// We can't test that Drop fires on every path without unsafe memory
245    /// inspection (and even then, the allocator may have reused the slot).
246    /// What we *can* test is that calling `zeroize()` directly zeros the
247    /// underlying secret bytes. That guarantees the trait wiring is correct;
248    /// the Drop impl just calls `zeroize()` and is a one-liner that's a
249    /// code-review concern, not a runtime test concern.
250    #[test]
251    fn signer_zeroize_wipes_secret_scalar() {
252        use zeroize::Zeroize;
253        let mut signer = Ed25519Signer::from_bytes("key_z", &[0xAA; 32]).unwrap();
254        // Sanity: before zeroize, the secret is the non-zero pattern we set.
255        assert_eq!(*signer.secret_bytes(), [0xAA; 32]);
256        signer.zeroize();
257        // After zeroize, the secret scalar must be all zeros.
258        assert_eq!(*signer.secret_bytes(), [0u8; 32]);
259    }
260
261    /// Proves the `Zeroizing<[u8; 32]>` returned by `secret_bytes` wipes its
262    /// inner buffer when it goes out of scope. Probes the byte pattern with
263    /// `Zeroize::zeroize` directly -- no unsafe memory inspection required.
264    #[test]
265    fn secret_bytes_wrapper_wipes_on_drop() {
266        use zeroize::Zeroize;
267        let signer = Ed25519Signer::from_bytes("key_w", &[0x55; 32]).unwrap();
268        let mut copy = *signer.secret_bytes(); // copy out of Zeroizing wrapper
269        assert_eq!(copy, [0x55; 32]);
270        copy.zeroize();
271        assert_eq!(copy, [0u8; 32]);
272    }
273}