Skip to main content

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}