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}