dig_keystore/custody/signer.rs
1//! `SignerHandle` — the unlocked, signing-capable handle returned by `Keystore::unlock`.
2//!
3//! A `SignerHandle<K>` owns a `Zeroizing<Vec<u8>>` copy of the decrypted secret
4//! and the derived public key, and exposes `sign` and `public_key`. Drop
5//! zeroizes the secret.
6//!
7//! # What the handle actually guarantees about the secret
8//!
9//! The secret leaves the handle by exactly one route: [`SignerHandle::expose_secret`],
10//! which is gated behind the non-default `hd-derivation` feature. It returns a
11//! **borrow** of the internal `Zeroizing` buffer — the bytes are never handed
12//! over as an owned value, so they still wipe when the handle drops (unless the
13//! caller copies them out, which its docs warn against). Without that feature
14//! the method does not exist and the only ways to use the secret are `sign` /
15//! `try_sign`.
16//!
17//! This is deliberately weaker than "the secret can never be extracted", which
18//! is what these docs used to claim while `expose_secret` sat a few dozen lines
19//! below. HD wallets and key-derivation libraries genuinely need the seed, so
20//! the honest property is a narrow, named, feature-gated borrow — not an
21//! absence.
22
23use std::marker::PhantomData;
24
25use zeroize::Zeroizing;
26
27use crate::custody::scheme::KeyScheme;
28use crate::error::Result;
29
30/// The unlocked handle. Drop wipes the secret.
31///
32/// Cloning a `SignerHandle` clones the underlying zeroizing buffer — both
33/// copies are independently wiped on drop. This is expensive for high-frequency
34/// signing; prefer sharing an `Arc<SignerHandle<K>>` for that case.
35pub struct SignerHandle<K: KeyScheme> {
36 secret: Zeroizing<Vec<u8>>,
37 public: K::PublicKey,
38 _marker: PhantomData<fn() -> K>,
39}
40
41impl<K: KeyScheme> SignerHandle<K> {
42 pub(crate) fn from_parts(secret: Zeroizing<Vec<u8>>, public: K::PublicKey) -> Self {
43 Self {
44 secret,
45 public,
46 _marker: PhantomData,
47 }
48 }
49
50 /// Borrow the derived public key. Cheap (precomputed at unlock time).
51 pub fn public_key(&self) -> &K::PublicKey {
52 &self.public
53 }
54
55 /// Sign a byte message.
56 pub fn sign(&self, msg: &[u8]) -> K::Signature {
57 // K::sign only errors when the secret length is wrong; we control that.
58 K::sign(&self.secret, msg).expect("signer handle secret length is guaranteed valid")
59 }
60
61 /// Attempt to sign, surfacing any scheme-level errors instead of panicking.
62 pub fn try_sign(&self, msg: &[u8]) -> Result<K::Signature> {
63 K::sign(&self.secret, msg)
64 }
65
66 /// Borrow the raw secret bytes.
67 ///
68 /// # ⚠️ Danger
69 ///
70 /// Prefer [`sign`](Self::sign) whenever possible. This method exists for
71 /// a narrow class of consumers — hierarchical-deterministic (HD) wallets
72 /// and key-derivation libraries — that need the raw seed bytes to
73 /// derive child keys (e.g.
74 /// [`chia_bls::DerivableKey::derive_unhardened`](https://docs.rs/chia-bls)).
75 /// These callers cannot use [`sign`](Self::sign) because they need the
76 /// `SecretKey` itself, not a signature.
77 ///
78 /// The returned slice is borrowed from the handle's internal
79 /// `Zeroizing<Vec<u8>>`, so it wipes automatically when the handle
80 /// drops. **Callers must not copy the bytes into a non-zeroizing
81 /// buffer** without re-wrapping them — doing so would leave the secret
82 /// on the heap past the end of its intended lifetime.
83 ///
84 /// Typical usage:
85 ///
86 /// ```no_run
87 /// # // `cargo` builds doctests with the crate's own enabled features, so
88 /// # // this only compiles (and only exists) under `hd-derivation`.
89 /// # use dig_keystore::{scheme::L1WalletBls, SignerHandle};
90 /// # fn get_signer() -> SignerHandle<L1WalletBls> { unimplemented!() }
91 /// let signer = get_signer();
92 /// let master_sk = chia_bls::SecretKey::from_seed(signer.expose_secret());
93 /// // `master_sk` is now the chia-bls master key; HD-derive as needed.
94 /// ```
95 ///
96 /// If you catch yourself writing `signer.expose_secret().to_vec()` or
97 /// `let copy = signer.expose_secret().to_owned();`, stop and consider
98 /// whether you really need to own the bytes — and if so, wrap the copy
99 /// in `Zeroizing::<Vec<u8>>::from(...)`.
100 ///
101 /// Requires the `hd-derivation` feature. It is split out from `custody`
102 /// because most custody consumers want to sign, not to extract; making
103 /// extraction a second, explicit opt-in keeps it out of their surface.
104 #[cfg(feature = "hd-derivation")]
105 pub fn expose_secret(&self) -> &[u8] {
106 &self.secret
107 }
108}
109
110impl<K: KeyScheme> Clone for SignerHandle<K> {
111 fn clone(&self) -> Self {
112 Self {
113 secret: self.secret.clone(),
114 public: self.public.clone(),
115 _marker: PhantomData,
116 }
117 }
118}
119
120impl<K: KeyScheme> std::fmt::Debug for SignerHandle<K> {
121 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
122 f.debug_struct("SignerHandle")
123 .field("scheme", &K::NAME)
124 .field("public", &self.public)
125 .field(
126 "secret",
127 &format_args!("<{} bytes zeroized>", self.secret.len()),
128 )
129 .finish()
130 }
131}
132
133// Explicitly NOT implementing AsRef<[u8]>, Deref, or into_raw() on SignerHandle.
134// The secret never leaves the handle by default; `expose_secret` (feature
135// `hd-derivation`) is the one deliberate, clearly-named opt-out for HD-wallet
136// consumers, and it borrows rather than yielding ownership.
137
138#[cfg(test)]
139mod tests {
140 use super::*;
141 use crate::custody::scheme::BlsSigning;
142
143 /// **Proves:** the `Debug` impl of `SignerHandle` does not print the raw
144 /// secret bytes. We construct a handle with secret `0xAA` × 32 and
145 /// assert the formatted output contains the placeholder but not `"AA"`.
146 ///
147 /// **Why it matters:** `SignerHandle` is routinely stored inside the
148 /// validator's `Node` struct which gets `tracing::info!(?self, ...)`ed
149 /// at startup. If the Debug impl leaked the secret, validator keys
150 /// would land in log files on every restart. The test pins the no-leak
151 /// property.
152 ///
153 /// **Catches:** accidentally deriving `Debug` on `SignerHandle` (which
154 /// would print the inner `Zeroizing<Vec<u8>>` content), or a future
155 /// `#[derive(Debug)]` addition that bypasses the custom impl.
156 #[test]
157 fn debug_does_not_leak_secret() {
158 let secret = Zeroizing::new(vec![0xAAu8; 32]);
159 let public = BlsSigning::public_key(&secret).unwrap();
160 let handle: SignerHandle<BlsSigning> = SignerHandle::from_parts(secret, public);
161 let s = format!("{:?}", handle);
162 assert!(s.contains("<32 bytes zeroized>"));
163 assert!(!s.contains("AA"));
164 }
165
166 /// **Proves:** the full in-memory signing path works — construct a
167 /// handle, sign, verify with the public key.
168 ///
169 /// **Why it matters:** Exercises `SignerHandle::sign` without going
170 /// through the encrypted backend. If this ever regresses, `Keystore::unlock`
171 /// would return a handle that produces wrong signatures (catastrophic).
172 ///
173 /// **Catches:** a bug in `sign` (e.g. forwarding the wrong secret field,
174 /// signing the wrong bytes, feeding the public key as the secret key).
175 #[test]
176 fn sign_works() {
177 let secret = Zeroizing::new(vec![0x11u8; 32]);
178 let public = BlsSigning::public_key(&secret).unwrap();
179 let handle: SignerHandle<BlsSigning> = SignerHandle::from_parts(secret, public);
180 let sig = handle.sign(b"message");
181 assert!(chia_bls::verify(&sig, &public, b"message"));
182 }
183
184 /// **Proves:** `try_sign` returns `Ok(sig)` for a valid handle and the
185 /// signature it produces verifies under the handle's public key — i.e. the
186 /// fallible signing path is equivalent to the panicking [`sign`](super::SignerHandle::sign).
187 ///
188 /// **Why it matters:** `try_sign` is the non-panicking entry point for
189 /// callers that prefer to surface scheme-level errors as `Result` rather
190 /// than unwind. Because a `SignerHandle` always holds a length-validated
191 /// secret, `try_sign` must succeed here; the test pins that the `Ok` arm is
192 /// reached and yields a real, verifiable signature (not a default/empty one).
193 ///
194 /// **Catches:** a `try_sign` that forwards the wrong secret/message, or that
195 /// erroneously returns `Err` for a well-formed handle.
196 #[test]
197 fn try_sign_succeeds_and_verifies() {
198 let secret = Zeroizing::new(vec![0x33u8; 32]);
199 let public = BlsSigning::public_key(&secret).unwrap();
200 let handle: SignerHandle<BlsSigning> = SignerHandle::from_parts(secret, public);
201 let sig = handle
202 .try_sign(b"payload")
203 .expect("try_sign should succeed");
204 assert!(chia_bls::verify(&sig, &public, b"payload"));
205 // try_sign and sign agree byte-for-byte.
206 assert_eq!(sig.to_bytes(), handle.sign(b"payload").to_bytes());
207 }
208
209 /// **Proves:** cloning a `SignerHandle` yields an independent copy that
210 /// produces the exact same signature as the original.
211 ///
212 /// **Why it matters:** Validators sometimes clone the handle into a
213 /// per-duty context (so a panic in one duty's signing doesn't poison
214 /// the other's). Both copies must produce identical signatures, which
215 /// they will iff the secret is copied (not shared-and-mutably-rotated).
216 ///
217 /// **Catches:** a regression where `Clone` shares the underlying
218 /// storage via `Arc` without `CoW` semantics — a single rotate would
219 /// then silently corrupt one of the copies.
220 #[test]
221 fn clone_preserves_equality() {
222 let secret = Zeroizing::new(vec![0x11u8; 32]);
223 let public = BlsSigning::public_key(&secret).unwrap();
224 let h1: SignerHandle<BlsSigning> = SignerHandle::from_parts(secret, public);
225 let h2 = h1.clone();
226 let s1 = h1.sign(b"x");
227 let s2 = h2.sign(b"x");
228 assert_eq!(s1.to_bytes(), s2.to_bytes());
229 }
230
231 #[cfg(feature = "hd-derivation")]
232 /// **Proves:** `expose_secret` returns the exact seed bytes that were
233 /// used to construct the handle — byte-for-byte equality.
234 ///
235 /// **Why it matters:** HD-wallet consumers (`dig-l1-wallet`, future
236 /// `apps/wallet`) need the raw seed to feed `chia_bls::SecretKey::from_seed`
237 /// and derive child keys. If `expose_secret` ever returned a transformed
238 /// value (e.g., the derived master `SecretKey` serialised), HD derivation
239 /// in dependent crates would silently produce the wrong child addresses.
240 ///
241 /// **Catches:** a regression that hashes / transforms the secret before
242 /// returning; a bug where `expose_secret` borrows the public key
243 /// instead of the private seed.
244 #[test]
245 fn expose_secret_returns_original_bytes() {
246 let bytes = [0x77u8; 32];
247 let secret = Zeroizing::new(bytes.to_vec());
248 let public = BlsSigning::public_key(&secret).unwrap();
249 let handle: SignerHandle<BlsSigning> = SignerHandle::from_parts(secret, public);
250 assert_eq!(handle.expose_secret(), &bytes);
251 }
252
253 #[cfg(feature = "hd-derivation")]
254 /// **Proves:** the byte slice returned by `expose_secret` is tied to the
255 /// handle's lifetime — once the handle is dropped, the borrow must end.
256 ///
257 /// **Why it matters:** This is really a compile-time property of the
258 /// borrow checker rather than a runtime assertion. The test exists to
259 /// document the intended lifetime contract and to break if someone
260 /// "helpfully" changes the return type to `Vec<u8>` (owned).
261 ///
262 /// **Catches:** a signature change to `expose_secret(&self) -> Vec<u8>`
263 /// that would leak the secret on drop of the returned vec.
264 #[test]
265 fn expose_secret_borrow_scoped_to_handle() {
266 let secret = Zeroizing::new(vec![0x22u8; 32]);
267 let public = BlsSigning::public_key(&secret).unwrap();
268 let handle: SignerHandle<BlsSigning> = SignerHandle::from_parts(secret, public);
269 let borrowed: &[u8] = handle.expose_secret();
270 assert_eq!(borrowed.len(), 32);
271 // The borrow would be rejected by the compiler if `handle` were dropped
272 // before `borrowed` — which is the property we want.
273 drop(handle);
274 // borrowed is now invalid and can't be touched; the compiler proves this.
275 }
276}