dig_keystore/scheme/l1_wallet_bls.rs
1//! `L1WalletBls` — the DIG / Chia L1 wallet BLS signing key scheme.
2//!
3//! Chia L1 wallets use BLS12-381 signatures. The stored secret is the
4//! wallet's **master secret key's raw 32-byte scalar**
5//! (`chia_bls::SecretKey::to_bytes()`) — already the result of the
6//! Chia-standard `mnemonic -> mnemonic.to_seed("") -> SecretKey::from_seed(seed)`
7//! derivation performed **once**, upstream, by the wallet layer (e.g.
8//! `dig-l1-wallet::keystore::mnemonic::derive_master_key_from_mnemonic`). HD
9//! account derivation (`m/12381/8444/2/{index}` via `master_to_wallet_unhardened`
10//! / `_hardened`, then `.derive_synthetic()`) also happens in that wallet
11//! layer. This crate only round-trips the already-derived master key and
12//! exposes its public key / signature operations — it performs no further
13//! derivation of its own.
14//!
15//! Reconstruction uses [`chia_bls::SecretKey::from_bytes`], which
16//! deserializes the canonical scalar directly. It must NEVER run
17//! [`chia_bls::SecretKey::from_seed`] on these bytes — that would derive a
18//! **second**, different key from what is already a derived master key, so
19//! the result would not match `dig-l1-wallet` / Sage / the Chia reference
20//! wallet for the same mnemonic. (This was a real bug — see `dig_ecosystem`
21//! issues #64 / #57 — fixed by switching reconstruction from `from_seed` to
22//! `from_bytes`; see the
23//! `public_key_matches_chia_standard_master_key_for_all_zero_mnemonic` test
24//! below for the regression test.)
25//!
26//! On-disk file magic is `DIGLW1`.
27
28use chia_bls::{PublicKey, SecretKey, Signature};
29use rand_core::{CryptoRng, RngCore};
30use zeroize::Zeroizing;
31
32use crate::error::{KeystoreError, Result};
33use crate::scheme::KeyScheme;
34
35/// DIG/Chia L1 wallet master BLS key (the root of wallet HD derivation).
36///
37/// Callers typically unlock this, take the derived [`chia_bls::SecretKey`], and
38/// use `chia_bls::DerivableKey::derive_unhardened` / `derive_hardened` at the
39/// wallet layer. The keystore does not itself perform HD derivation.
40#[derive(Debug, Clone, Copy)]
41pub struct L1WalletBls;
42
43impl KeyScheme for L1WalletBls {
44 type PublicKey = PublicKey;
45 type Signature = Signature;
46
47 const MAGIC: [u8; 6] = *b"DIGLW1";
48 const NAME: &'static str = "L1WalletBls";
49 const SCHEME_ID: u16 = 0x0003;
50 const SECRET_LEN: usize = 32;
51
52 fn generate<R: RngCore + CryptoRng>(rng: &mut R) -> Zeroizing<Vec<u8>> {
53 // Fresh entropy is run through `from_seed` ONCE (the same EIP-2333
54 // keygen the Chia-standard `mnemonic -> master key` step uses) so the
55 // stored bytes are always a canonical in-range BLS12-381 scalar. Raw
56 // random 32 bytes are NOT guaranteed to be < the scalar field order
57 // (roughly half would be rejected by `from_bytes` in
58 // `secret_to_secret_key`), so this derivation step is required, not
59 // cosmetic — it produces the master key whose `to_bytes()` becomes
60 // the stored secret, matching what `secret_to_secret_key` expects.
61 let mut entropy = Zeroizing::new(vec![0u8; Self::SECRET_LEN]);
62 rng.fill_bytes(&mut entropy);
63 let master_sk = SecretKey::from_seed(&entropy);
64 Zeroizing::new(master_sk.to_bytes().to_vec())
65 }
66
67 fn public_key(secret: &[u8]) -> Result<Self::PublicKey> {
68 let sk = secret_to_secret_key(secret)?;
69 Ok(sk.public_key())
70 }
71
72 fn sign(secret: &[u8], msg: &[u8]) -> Result<Self::Signature> {
73 let sk = secret_to_secret_key(secret)?;
74 Ok(chia_bls::sign(&sk, msg))
75 }
76}
77
78/// Deserialize the stored secret as an **already-derived** master key's raw
79/// canonical scalar bytes.
80///
81/// Uses [`SecretKey::from_bytes`], NOT [`SecretKey::from_seed`] — per the
82/// module docs, the stored secret is the final master key, so this function
83/// must not re-derive it. Re-running `from_seed` here was the
84/// double-derivation bug in `dig_ecosystem` issues #64 / #57: it treated an
85/// already-derived scalar as fresh entropy and derived a second, different
86/// key from it.
87fn secret_to_secret_key(secret: &[u8]) -> Result<SecretKey> {
88 if secret.len() != L1WalletBls::SECRET_LEN {
89 return Err(KeystoreError::InvalidPlaintext {
90 expected: L1WalletBls::SECRET_LEN,
91 got: secret.len(),
92 });
93 }
94 let bytes: [u8; 32] = secret
95 .try_into()
96 .expect("length checked above to equal SECRET_LEN (32)");
97 SecretKey::from_bytes(&bytes).map_err(|e| KeystoreError::InvalidSeed(e.to_string()))
98}
99
100#[cfg(test)]
101mod tests {
102 use super::*;
103
104 /// **Proves:** `L1WalletBls::MAGIC` and `L1WalletBls::SCHEME_ID` differ
105 /// from those of [`crate::scheme::BlsSigning`].
106 ///
107 /// **Why it matters:** Type confusion between a validator signing key
108 /// and a wallet master seed is the single most dangerous regression this
109 /// crate could ship. If the two schemes ever shared a magic or a scheme
110 /// id, `Keystore::<BlsSigning>::load` would silently accept a wallet
111 /// file (or vice versa) and the two code paths would use each other's
112 /// keys. This test is the tripwire.
113 ///
114 /// **Catches:** copy-paste of the `MAGIC` / `SCHEME_ID` constants from
115 /// `BlsSigning` without editing them for the new scheme.
116 #[test]
117 fn magic_differs_from_bls_signing() {
118 use crate::scheme::BlsSigning;
119 assert_ne!(L1WalletBls::MAGIC, BlsSigning::MAGIC);
120 assert_ne!(L1WalletBls::SCHEME_ID, BlsSigning::SCHEME_ID);
121 }
122
123 /// **Proves:** the full sign→verify round-trip works for `L1WalletBls` —
124 /// a signature produced by `sign` verifies under the pubkey derived
125 /// from the same seed via the public [`chia_bls::verify`].
126 ///
127 /// **Why it matters:** Mirror of
128 /// [`super::bls_signing::tests::sign_verifies_via_chia_bls`] but for
129 /// the wallet scheme. Cheap sanity check that both schemes share the
130 /// same working `chia-bls` integration.
131 ///
132 /// **Catches:** a scheme-specific bug where e.g. `public_key` derives
133 /// via the Chia wallet's HD path but `sign` uses the raw master key
134 /// (or vice versa) — their outputs would mismatch and `verify` would
135 /// return `false`.
136 #[test]
137 fn roundtrip_sign_verify() {
138 let seed = [42u8; 32];
139 let pk = L1WalletBls::public_key(&seed).unwrap();
140 let sig = L1WalletBls::sign(&seed, b"hi").unwrap();
141 assert!(chia_bls::verify(&sig, &pk, b"hi"));
142 }
143
144 /// **Proves:** both `public_key` and `sign` reject a secret whose length is
145 /// not [`L1WalletBls::SECRET_LEN`], returning
146 /// [`KeystoreError::InvalidPlaintext`] with the expected/got lengths — and
147 /// do so **without panicking** (the [`crate::scheme::KeyScheme`] contract
148 /// forbids panicking on malformed input).
149 ///
150 /// **Why it matters:** A wrong-length seed reaching `SecretKey::from_seed`
151 /// is exactly the kind of malformed input that could come from a corrupt
152 /// keystore file or a truncated import. The scheme must surface it as a typed
153 /// error the keystore layer can report, not crash the validator/wallet
154 /// binary.
155 ///
156 /// **Catches:** removing the length guard in `secret_to_secret_key` (which
157 /// would let `from_seed` panic or silently accept the wrong-length seed),
158 /// or reporting the wrong `expected`/`got` lengths.
159 #[test]
160 fn wrong_length_secret_rejected() {
161 let short = [0u8; 16]; // not SECRET_LEN (32)
162
163 let pk_err = L1WalletBls::public_key(&short).unwrap_err();
164 match pk_err {
165 KeystoreError::InvalidPlaintext { expected, got } => {
166 assert_eq!(expected, L1WalletBls::SECRET_LEN);
167 assert_eq!(got, 16);
168 }
169 other => panic!("expected InvalidPlaintext, got {other:?}"),
170 }
171
172 let sign_err = L1WalletBls::sign(&short, b"x").unwrap_err();
173 assert!(matches!(
174 sign_err,
175 KeystoreError::InvalidPlaintext { got: 16, .. }
176 ));
177 }
178
179 /// **Proves:** `L1WalletBls::public_key`/`sign`, given a wallet's already-derived
180 /// master secret key bytes, reconstruct the SAME key — not a second, different
181 /// key produced by re-deriving (`SecretKey::from_seed`) on bytes that are
182 /// already a derived scalar.
183 ///
184 /// Regression for `dig_ecosystem` issues #64 / #57 ("L1WalletBls scheme
185 /// double-derives"). This test computes the Chia-standard master key
186 /// completely independently of `L1WalletBls` — via the well-known
187 /// all-zero-entropy 24-word BIP-39 mnemonic ("abandon" ×11 + "about") and
188 /// Chia's empty-passphrase convention (`mnemonic.to_seed("")` then
189 /// `SecretKey::from_seed(seed)` **once**) — exactly the chain
190 /// `dig-l1-wallet::keystore::mnemonic::derive_master_key_from_mnemonic` uses.
191 /// It then stores that master key's raw canonical scalar bytes
192 /// (`to_bytes()`) as the `L1WalletBls` secret (`SECRET_LEN` is 32 bytes,
193 /// which only fits an already-derived scalar — the raw 64-byte BIP-39 seed
194 /// cannot fit) and asserts `public_key`/`sign` operate on THAT key.
195 ///
196 /// Before the fix, `secret_to_secret_key` ran `SecretKey::from_seed` on
197 /// these already-derived bytes a SECOND time, producing a different
198 /// pubkey — this assertion fails under the old code and passes under the
199 /// `SecretKey::from_bytes`-based fix.
200 ///
201 /// **Catches:** re-introducing any re-derivation (`from_seed`,
202 /// `derive_hardened`/`derive_unhardened`, HKDF, hashing, …) inside
203 /// `secret_to_secret_key`.
204 #[test]
205 fn public_key_matches_chia_standard_master_key_for_all_zero_mnemonic() {
206 use bip39::{Language, Mnemonic};
207
208 // The canonical all-zero-entropy 24-word BIP-39 mnemonic.
209 let mnemonic = Mnemonic::parse_in_normalized(
210 Language::English,
211 "abandon abandon abandon abandon abandon abandon abandon abandon \
212 abandon abandon abandon about",
213 )
214 .expect("well-known valid BIP-39 test vector");
215
216 // Chia convention: empty passphrase.
217 let bip39_seed = mnemonic.to_seed("");
218
219 // The Chia-standard master key: `from_seed` applied EXACTLY ONCE.
220 let expected_master_sk = SecretKey::from_seed(&bip39_seed);
221 let expected_pk = expected_master_sk.public_key();
222
223 // What an `L1WalletBls` keystore persists is the master key's raw
224 // canonical scalar bytes, not the (64-byte, too-long-to-fit) BIP-39
225 // seed itself — HD derivation already happened once, above.
226 let stored_secret = expected_master_sk.to_bytes();
227
228 let got_pk = L1WalletBls::public_key(&stored_secret).unwrap();
229 assert_eq!(
230 got_pk, expected_pk,
231 "L1WalletBls::public_key must reconstruct the SAME master key \
232 derived via `from_seed` once — not re-derive by hashing its raw \
233 bytes through `from_seed` again"
234 );
235
236 // Golden fixed hex — pins the exact value so a future dependency bump
237 // (bip39/chia-bls) can't silently move both sides of the comparison
238 // above together and mask a regression.
239 assert_eq!(
240 hex::encode(got_pk.to_bytes()),
241 "82ae65efe846b15a92c51b7ad6c32589fd79d38263d3cbefbeeba08be8e90d8bc335a\
242 1e2fcc66a10b8c817c06232285a"
243 );
244
245 let msg = b"dig-keystore l1_wallet_bls golden vector";
246 let sig = L1WalletBls::sign(&stored_secret, msg).unwrap();
247 assert!(
248 chia_bls::verify(&sig, &expected_pk, msg),
249 "signature produced from the stored master-key bytes must verify \
250 under the SAME (non-re-derived) master key's pubkey"
251 );
252 }
253}