Skip to main content

zcash_keys/keys/
zcashd.rs

1//! Types and functions for interacting with legacy zcashd key material.
2
3use alloc::string::{String, ToString};
4use bip0039::{English, Mnemonic};
5use regex::Regex;
6use secrecy::{ExposeSecret, SecretVec};
7use zcash_protocol::consensus::NetworkConstants;
8use zip32::{AccountId, ChildIndex};
9
10// Derives a mnemonic phrase, using the pre-BIP-39 seed as entropy.
11//
12// zcashd produced a mnemonic from the pre-BIP-39 seed by incrementing the first byte of the
13// pre-BIP-39 seed until we found a value that was usable as valid entropy for seed phrase
14// generation.
15pub fn derive_mnemonic(legacy_seed: &SecretVec<u8>) -> Option<Mnemonic> {
16    if legacy_seed.expose_secret().len() != 32 {
17        return None;
18    }
19
20    let mut offset = 0u8;
21    loop {
22        let mut entropy = legacy_seed.expose_secret().clone();
23        entropy[0] += offset;
24        match Mnemonic::<English>::from_entropy(entropy) {
25            Ok(m) => {
26                return Some(m);
27            }
28            Err(_) => {
29                if offset == 0xFF {
30                    return None;
31                } else {
32                    offset += 1;
33                }
34            }
35        }
36    }
37}
38
39/// A type-safe wrapper for account identifiers.
40///
41/// Accounts are 31-bit unsigned integers, and are always treated as hardened in
42/// derivation paths.
43#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
44pub struct LegacyAddressIndex(u32);
45
46impl TryFrom<u32> for LegacyAddressIndex {
47    type Error = ();
48
49    fn try_from(id: u32) -> Result<Self, Self::Error> {
50        // Account IDs are always hardened in derivation paths, so they are effectively at
51        // most 31 bits.
52        if id < (1 << 31) {
53            Ok(Self(id))
54        } else {
55            Err(())
56        }
57    }
58}
59
60impl From<LegacyAddressIndex> for u32 {
61    fn from(id: LegacyAddressIndex) -> Self {
62        id.0
63    }
64}
65
66impl From<LegacyAddressIndex> for ChildIndex {
67    fn from(id: LegacyAddressIndex) -> Self {
68        // Account IDs are always hardened in derivation paths.
69        ChildIndex::hardened(id.0)
70    }
71}
72
73/// An enumeration of the address derivation algorithm variants used by `zcashd`.
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
75pub enum ZcashdHdDerivation {
76    /// A unified account derived according to ZIP 32.
77    Zip32 { account_id: AccountId },
78    /// The address was derived under a nonstandard path of the form:
79    ///
80    /// `m/32'/<coin_type>'/0x7FFFFFFF'/<address_index>'
81    Post470LegacySapling { address_index: LegacyAddressIndex },
82}
83
84/// Errors that can occur in parsing the string representation of an HD key path.
85#[derive(Debug)]
86pub enum PathParseError {
87    /// The string did not match the format of the subset of HD derivation paths that are produced
88    /// by zcashd.
89    PathInvalid,
90    /// The coin type component of the HD derivation path did match a supported zcashd coin type.
91    CoinTypeInvalid(String),
92    /// The coin type component of the HD derivation did not match the coin type for the specified
93    /// network.
94    CoinTypeMismatch { expected: u32, actual: u32 },
95    /// The account index component of the HD derivation path could not be parsed as a value in the
96    /// range of a `u32`.
97    AccountIndexInvalid(String),
98    /// The account index component of the HD derivation path is not in the range of valid ZIP 32
99    /// account indices.
100    AccountIdInvalid(u32),
101    /// The derivation path did not contain an address index component.
102    AddressIndexMissing,
103    /// The address index component of the HD derivation path could not be parsed as a value in the
104    /// range of valid ZIP 32 address indices.
105    AddressIndexInvalid(String),
106}
107
108impl ZcashdHdDerivation {
109    const ZCASHD_LEGACY_ACCOUNT_INDEX: u32 = 0x7FFFFFFF;
110
111    // Parses a HD key path to obtain the coin type and account ID or legacy Sapling address index.
112    //
113    // Historically, each key generated via the `z_getnewaddress` RPC method was treated as a separate
114    // pool of funds, much like a ZIP 32 account. After the introduction of BIP 39 mnemonic seed
115    // phrases in Zcashd v4.7.0, we wanted to retain these semantics but did not want `z_getnewaddress`
116    // to implicitly create a new ZIP 32 account. Instead, we used a fixed keypath scheme of
117    //
118    // `m/32'/coin_type'/0x7FFFFFFF'/addressIndex'`
119    //
120    // for each new address. This is not a "standard" path, but instead is a predictable location for
121    // legacy zcashd-derived keys that is minimally different from the UA account path, while unlikely
122    // to collide with normal UA account usage.
123    //
124    // For post v4.7.0 Zcashd accounts, we use standard ZIP 32 HD derivation from the master key to
125    // obtain the unified spending key.
126    pub fn parse_hd_path<C: NetworkConstants>(
127        network: &C,
128        path: &str,
129    ) -> Result<Self, PathParseError> {
130        // ASCII digit classes are used deliberately. An HD keypath's digits
131        // are ASCII by definition, and `\d` requires the regex crate's
132        // `unicode-perl` feature, which this crate does not declare. In a
133        // dependency graph that enables that feature nowhere else, a `\d`
134        // pattern fails to compile at runtime.
135        let re =
136            Regex::new(r"^m/32'/([0-9]+)'/([0-9]+)'(?:/([0-9]+)')?$").expect("checked to be valid");
137        let parts = re.captures(path).ok_or(PathParseError::PathInvalid)?;
138        assert!(parts.len() <= 4);
139
140        let coin_type = parts
141            .get(1)
142            .map_or(Err(PathParseError::PathInvalid), |part| {
143                let part_str = part.as_str();
144                part_str
145                    .parse::<u32>()
146                    .map_err(|_| PathParseError::CoinTypeInvalid(part_str.to_string()))
147            })?;
148        if coin_type != network.coin_type() {
149            return Err(PathParseError::CoinTypeMismatch {
150                expected: network.coin_type(),
151                actual: coin_type,
152            });
153        }
154
155        let account_index = parts
156            .get(2)
157            .map_or(Err(PathParseError::PathInvalid), |part| {
158                let part_str = part.as_str();
159                part_str
160                    .parse::<u32>()
161                    .map_err(|_| PathParseError::AccountIndexInvalid(part_str.to_string()))
162            })?;
163
164        if account_index == Self::ZCASHD_LEGACY_ACCOUNT_INDEX {
165            let address_index =
166                parts
167                    .get(3)
168                    .map_or(Err(PathParseError::AddressIndexMissing), |part| {
169                        let part_str = part.as_str();
170
171                        match part_str.parse::<u32>() {
172                            Ok(v) if v < (1 << 31) => Ok(LegacyAddressIndex(v)),
173                            _ => Err(PathParseError::AddressIndexInvalid(part_str.to_string())),
174                        }
175                    })?;
176
177            Ok(ZcashdHdDerivation::Post470LegacySapling { address_index })
178        } else {
179            if parts.get(3).is_some() {
180                return Err(PathParseError::PathInvalid);
181            }
182            let account_id = AccountId::try_from(account_index)
183                .map_err(|_| PathParseError::AccountIdInvalid(account_index))?;
184            Ok(ZcashdHdDerivation::Zip32 { account_id })
185        }
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use assert_matches::assert_matches;
192
193    use zcash_protocol::consensus::{NetworkConstants, NetworkType};
194    use zip32::AccountId;
195
196    use super::{PathParseError, ZcashdHdDerivation};
197
198    #[test]
199    fn parse_zcashd_hd_path() {
200        let zip32_bip39_path = format!("m/32'/{}'/0'", NetworkType::Main.coin_type());
201        assert_matches!(
202            ZcashdHdDerivation::parse_hd_path(&NetworkType::Main, &zip32_bip39_path),
203            Ok(ZcashdHdDerivation::Zip32 {
204                account_id: id
205            })
206            if id == AccountId::ZERO
207        );
208
209        let zip32_bip39_path_coin_invalid = format!("m/32'/{}'/0'", u64::MAX);
210        assert_matches!(
211            ZcashdHdDerivation::parse_hd_path(&NetworkType::Main, &zip32_bip39_path_coin_invalid),
212            Err(PathParseError::CoinTypeInvalid(_))
213        );
214
215        let zip32_bip39_path_coin_mismatch = format!("m/32'/{}'/0'", NetworkType::Test.coin_type());
216        assert_matches!(
217            ZcashdHdDerivation::parse_hd_path(&NetworkType::Main, &zip32_bip39_path_coin_mismatch),
218            Err(PathParseError::CoinTypeMismatch { .. })
219        );
220
221        let zip32_bip39_account_idx_invalid =
222            format!("m/32'/{}'/{}'/0'", NetworkType::Main.coin_type(), u64::MAX);
223        assert_matches!(
224            ZcashdHdDerivation::parse_hd_path(&NetworkType::Main, &zip32_bip39_account_idx_invalid),
225            Err(PathParseError::AccountIndexInvalid(_))
226        );
227
228        let zip32_bip39_account_id_invalid = format!(
229            "m/32'/{}'/{}'",
230            NetworkType::Main.coin_type(),
231            ZcashdHdDerivation::ZCASHD_LEGACY_ACCOUNT_INDEX + 1
232        );
233        assert_matches!(
234            ZcashdHdDerivation::parse_hd_path(&NetworkType::Main, &zip32_bip39_account_id_invalid),
235            Err(PathParseError::AccountIdInvalid(_))
236        );
237
238        let zcashd_legacy_path = format!(
239            "m/32'/{}'/{}'/0'",
240            NetworkType::Main.coin_type(),
241            ZcashdHdDerivation::ZCASHD_LEGACY_ACCOUNT_INDEX
242        );
243        assert_matches!(
244            ZcashdHdDerivation::parse_hd_path(&NetworkType::Main, &zcashd_legacy_path),
245            Ok(ZcashdHdDerivation::Post470LegacySapling {
246                address_index
247            })
248            if address_index.0 == 0
249        );
250
251        let too_long_path = format!("m/32'/{}'/1'/0'", NetworkType::Main.coin_type(),);
252        assert_matches!(
253            ZcashdHdDerivation::parse_hd_path(&NetworkType::Main, &too_long_path),
254            Err(PathParseError::PathInvalid)
255        );
256    }
257}