diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b872182..66f91b32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ and this library adheres to Rust's notion of [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added +- `zip32::registered` module, implementing hardened-only key derivation for + an application protocol specified in a ZIP. + +### Deprecated +- `zip32::arbitrary::SecretKey::into_full_width_key`. This API is + cryptographically unsafe because it depends on a restriction that cannot + be enforced. Use `zip32::registered::SecretKey::full_width_key_from_path` + instead. ## [0.1.3] - 2024-12-13 diff --git a/Cargo.lock b/Cargo.lock index abedeb79..8e2c59d7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -52,8 +52,7 @@ checksum = "81cdd64d312baedb58e21336b31bc043b77e01cc99033ce76ef539f78e965ebc" [[package]] name = "zcash_spec" version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cede95491c2191d3e278cab76e097a44b17fde8d6ca0d4e3a22cf4807b2d857" +source = "git+https://github.com/daira/zcash_spec.git?rev=0d3f5914f192f1352dddd31bf56b1df1860bcfb8#0d3f5914f192f1352dddd31bf56b1df1860bcfb8" dependencies = [ "blake2b_simd", ] diff --git a/Cargo.toml b/Cargo.toml index d7a6c2d7..5ab10d29 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,3 +25,6 @@ assert_matches = "1.5" [features] default = ["std"] std = ["memuse/std"] + +[patch.crates-io] +zcash_spec = { git = "https://github.com/daira/zcash_spec.git", rev = "0d3f5914f192f1352dddd31bf56b1df1860bcfb8" } diff --git a/src/arbitrary.rs b/src/arbitrary.rs index fb58fc34..96d082e4 100644 --- a/src/arbitrary.rs +++ b/src/arbitrary.rs @@ -5,6 +5,10 @@ //! without the need for ecosystem-wide coordination. The following instantiation of the //! [hardened key generation framework] may be used for this purpose. //! +//! The keys derived by the functions in this module will be unrelated to any keys +//! derived by functions in the [`crate::registered`] module, even if the same context +//! string and seed are used. +//! //! Defined in [ZIP32: Arbitrary key derivation][arbkd]. //! //! [hardened key generation framework]: crate::hardened_only @@ -114,7 +118,12 @@ impl SecretKey { /// /// Child keys MUST NOT be derived from any key on which this method is called. For /// the current API, this means that [`SecretKey::from_path`] MUST NOT be called with - /// a `path` for which this key's path is a prefix. + /// a `path` for which this key's path is a prefix. This API is cryptographically + /// unsafe because there is no way to enforce that restriction. + #[deprecated( + since = "0.1.4", + note = "Use [`zip32::registered::SecretKey::full_width_key_from_path`] instead." + )] pub fn into_full_width_key(self) -> [u8; 64] { let (sk, c) = self.inner.into_parts(); // Re-concatenate the key parts. diff --git a/src/hardened_only.rs b/src/hardened_only.rs index f2a3b54f..d4b754a2 100644 --- a/src/hardened_only.rs +++ b/src/hardened_only.rs @@ -28,7 +28,7 @@ pub trait Context { const CKD_DOMAIN: PrfExpand<([u8; 32], [u8; 4])>; } -/// An arbitrary extended secret key. +/// An arbitrary or registered extended secret key. /// /// Defined in [ZIP32: Hardened-only key derivation][hkd]. /// @@ -103,7 +103,34 @@ impl HardenedOnlyKey { &self.sk, &index.index().to_le_bytes(), ); + self.derive_from(&I) + } + + /// Derives a child key from a parent key at a given index and optional tag. + /// + /// Defined in [ZIP32: Hardened-only child key derivation][ckdh]. + /// + /// [ckdh]: https://zips.z.cash/zip-0032#hardened-only-child-key-derivation + pub fn derive_child_with_tag( + &self, + index: ChildIndex, + tag: Option<&[u8]>, + full_width_leaf: bool, + ) -> Self { + let lead = (tag.is_some() || full_width_leaf).then(|| [u8::from(full_width_leaf)]); + + // I := PRF^Expand(c_par, [Context.CKDDomain] || sk_par || I2LEOSP(i) || lead || tag) + let I: [u8; 64] = C::CKD_DOMAIN.with_tag( + self.chain_code.as_bytes(), + &self.sk, + &index.index().to_le_bytes(), + &lead.unwrap_or_default(), + tag.unwrap_or_default(), + ); + self.derive_from(&I) + } + fn derive_from(&self, I: &[u8; 64]) -> Self { let (I_L, I_R) = I.split_at(32); // I_L is used as the child spending key sk_i. diff --git a/src/lib.rs b/src/lib.rs index 584e2bba..483645d0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -21,6 +21,7 @@ use subtle::{Choice, ConditionallySelectable, ConstantTimeEq}; pub mod arbitrary; pub mod fingerprint; pub mod hardened_only; +pub mod registered; /// A type-safe wrapper for account identifiers. /// diff --git a/src/registered.rs b/src/registered.rs new file mode 100644 index 00000000..3dbfc0ed --- /dev/null +++ b/src/registered.rs @@ -0,0 +1,210 @@ +//! Registered key derivation. +//! +//! In some contexts there is a need for deriving a key for a registered application +//! protocol (defined by a ZIP) with a similar derivation path to existing key material +//! (for example, deriving an arbitrary account-level key). The following instantiation +//! of the [hardened key generation framework] may be used for this purpose. +//! +//! The keys derived by the functions in this module will be unrelated to any keys +//! derived by functions in the [`crate::arbitrary`] module, even if the same context +//! string and seed are used. +//! +//! Defined in [ZIP32: Registered key derivation][regkd]. +//! +//! [hardened key generation framework]: crate::hardened_only +//! [regkd]: https://zips.z.cash/zip-0032#specification-registered-key-derivation + +use zcash_spec::PrfExpand; + +use crate::{ + hardened_only::{Context, HardenedOnlyKey}, + ChainCode, ChildIndex, +}; + +struct Registered; + +impl Context for Registered { + const MKG_DOMAIN: [u8; 16] = *b"ZIPRegistered_KD"; + const CKD_DOMAIN: PrfExpand<([u8; 32], [u8; 4])> = PrfExpand::REGISTERED_ZIP32_CHILD; +} + +/// A registered extended secret key. +/// +/// Defined in [ZIP32: Registered key derivation][arbkd]. +/// +/// [regkd]: https://zips.z.cash/zip-0032#specification-registered-key-derivation +pub struct SecretKey { + inner: HardenedOnlyKey, +} + +fn with_ikm(context_string: &[u8], seed: &[u8], f: F) -> T +where + F: FnOnce(&[&[u8]]) -> T, +{ + let context_len = + u8::try_from(context_string.len()).expect("context string should be at most 252 bytes"); + assert!((1..=252).contains(&context_len)); + + let seed_len = u8::try_from(seed.len()).expect("seed should be at most 252 bytes"); + assert!((32..=252).contains(&seed_len)); + + let ikm = &[&[context_len], context_string, &[seed_len], seed]; + + f(ikm) +} + +impl SecretKey { + /// Derives a key for a registered application protocol at the given path from the + /// given seed. Each path element may consist of an index and optional tag. + /// + /// - `zip_number` is the number of the ZIP defining the application protocol. + /// The corresponding hardened index (with no tag) will be prepended to the + /// `path`. + /// - `context_string` is an identifier for the context in which this key will be + /// used. It must be globally unique. + /// + /// # Panics + /// + /// Panics if: + /// - the context string is empty or longer than 252 bytes. + /// - the seed is shorter than 32 bytes or longer than 252 bytes. + pub fn from_path( + zip_number: u16, + context_string: &[u8], + seed: &[u8], + path: &[(ChildIndex, Option<&[u8]>)], + ) -> Self { + let mut xsk = Self::master(context_string, seed).derive_child_with_tag( + ChildIndex::hardened(u32::from(zip_number)), + None, + false, + ); + + for (i, tag) in path { + xsk = xsk.derive_child_with_tag(*i, *tag, false); + } + xsk + } + + /// Derives 64 bytes of key material for a registered application protocol at + /// the given path from the given seed. Each path element may consist of an + /// index and optional tag. + /// + /// - `zip_number` is the number of the ZIP defining the application protocol. + /// The corresponding hardened index (with no tag) will be prepended to the + /// `path`. + /// - `context_string` is an identifier for the context in which this key will be + /// used. It must be globally unique. + /// + /// # Panics + /// + /// Panics if: + /// - the context string is empty or longer than 252 bytes. + /// - the seed is shorter than 32 bytes or longer than 252 bytes. + pub fn full_width_key_from_path( + zip_number: u16, + context_string: &[u8], + seed: &[u8], + path: &[(ChildIndex, Option<&[u8]>)], + ) -> [u8; 64] { + let mut xsk = Self::master(context_string, seed); + + for (j, (i, tag)) in Some((ChildIndex::hardened(u32::from(zip_number)), None)) + .iter() + .chain(path) + .enumerate() + { + xsk = xsk.derive_child_with_tag(*i, *tag, j == path.len()); + } + + // Concatenate the key data and chain code to obtain a full-width key. + // This is safe because we only do it on a child key that has been + // derived with `full_width_leaf: true`, which ensures the same key + // cannot be obtained directly from any public API. + + let (sk, c) = xsk.inner.into_parts(); + // Re-concatenate the key parts. + let mut key = [0; 64]; + key[..32].copy_from_slice(&sk); + key[32..].copy_from_slice(&c.0); + key + } + + /// Generates the master key of a registered extended secret key. + /// + /// Defined in [ZIP32: Registered master key generation][mkgarb]. + /// + /// [regmkg]: https://zips.z.cash/zip-0032#arbitrary-master-key-generation + /// + /// # Panics + /// + /// Panics if: + /// - the context string is empty or longer than 252 bytes. + /// - the seed is shorter than 32 bytes or longer than 252 bytes. + fn master(context_string: &[u8], seed: &[u8]) -> Self { + with_ikm(context_string, seed, |ikm| Self { + inner: HardenedOnlyKey::master(ikm), + }) + } + + /// Derives a child key from a parent key at a given index and optional tag. + /// + /// Defined in [ZIP32: Arbitrary and registered child key derivation][ckdarb]. + /// + /// [ckdarb]: https://zips.z.cash/zip-0032#arbitrary-and-registered-child-key-derivation + fn derive_child_with_tag( + &self, + index: ChildIndex, + tag: Option<&[u8]>, + full_width_leaf: bool, + ) -> Self { + Self { + inner: self + .inner + .derive_child_with_tag(index, tag, full_width_leaf), + } + } + + /// Returns the key material for this arbitrary key. + pub fn data(&self) -> &[u8; 32] { + self.inner.parts().0 + } + + /// Returns the chain code for this arbitrary key. + pub fn chain_code(&self) -> &ChainCode { + self.inner.parts().1 + } +} + +#[cfg(test)] +mod tests { + use super::SecretKey; + use crate::ChildIndex; + + struct TestVector { + context_string: &'static [u8], + seed: [u8; 32], + zip_number: u16, + path: &'static [(u32, Option<&'static [u8]>)], + sk: [u8; 32], + c: [u8; 32], + } + + // From https://github.com/zcash-hackworks/zcash-test-vectors/blob/master/zip_0032_registered.py + const TEST_VECTORS: &'static [TestVector] = &[]; + + #[test] + fn test_vectors() { + for tv in TEST_VECTORS { + let path = tv + .path + .into_iter() + .map(|(i, tag)| (ChildIndex::from_index(*i).expect("hardened"), *tag)) + .collect::>(); + + let sk = SecretKey::from_path(tv.zip_number, tv.context_string, &tv.seed, &path); + assert_eq!(sk.data(), &tv.sk); + assert_eq!(sk.chain_code().as_bytes(), &tv.c); + } + } +}