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