Skip to main content

kobe_casper/
deriver.rs

1//! Casper account derivation from a unified wallet seed.
2
3use alloc::string::String;
4use core::ops::Deref;
5
6use kobe_primitives::{
7    DerivationStyle as _, Derive, DeriveError, DerivedAccount, DerivedPublicKey, Wallet,
8};
9
10use crate::address::{
11    ED25519_TAG, SECP256K1_TAG, account_hash_ed25519, account_hash_secp256k1, format_account_hash,
12    tagged_public_key_hex,
13};
14use crate::key_algo::KeyAlgo;
15
16/// A Casper-specific derived account.
17///
18/// Wraps the unified [`DerivedAccount`] (`address` = `account-hash-…`) and
19/// adds the signature algorithm plus the Casper **tagged** public-key hex
20/// (`01…` / `02…`) used in serialization contexts.
21///
22/// Implements `Deref<Target = DerivedAccount>` so shared accessors
23/// (`address()`, `public_key_bytes()`, `private_key_hex()`, untagged
24/// [`DerivedAccount::public_key_hex`], …) work without name shadowing.
25#[derive(Clone)]
26pub struct CasperAccount {
27    inner: DerivedAccount,
28    algo: KeyAlgo,
29    /// Lowercase hex of tag ‖ raw public key (no `0x` prefix).
30    tagged_public_key_hex: String,
31}
32
33impl core::fmt::Debug for CasperAccount {
34    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
35        f.debug_struct("CasperAccount")
36            .field("inner", &self.inner)
37            .field("algo", &self.algo)
38            .field("tagged_public_key_hex", &self.tagged_public_key_hex)
39            .finish()
40    }
41}
42
43impl CasperAccount {
44    /// Signature algorithm used for this derivation.
45    #[inline]
46    #[must_use]
47    pub const fn algo(&self) -> KeyAlgo {
48        self.algo
49    }
50
51    /// Casper tagged public-key hex (`01 ‖ ed25519` or `02 ‖ secp compressed`).
52    ///
53    /// No `0x` prefix; lowercase. Distinct from untagged
54    /// [`DerivedAccount::public_key_hex`] (available via `Deref`).
55    #[inline]
56    #[must_use]
57    pub fn tagged_public_key_hex(&self) -> &str {
58        &self.tagged_public_key_hex
59    }
60
61    /// Formatted `AccountHash` (`account-hash-` + 64 hex). Alias for
62    /// [`DerivedAccount::address`].
63    #[inline]
64    #[must_use]
65    pub fn account_hash(&self) -> &str {
66        self.inner.address()
67    }
68
69    /// The underlying unified [`DerivedAccount`].
70    #[inline]
71    #[must_use]
72    pub const fn as_derived_account(&self) -> &DerivedAccount {
73        &self.inner
74    }
75
76    /// Consume and yield the underlying [`DerivedAccount`].
77    #[inline]
78    #[must_use]
79    pub fn into_derived_account(self) -> DerivedAccount {
80        self.inner
81    }
82}
83
84impl Deref for CasperAccount {
85    type Target = DerivedAccount;
86
87    #[inline]
88    fn deref(&self) -> &Self::Target {
89        &self.inner
90    }
91}
92
93impl AsRef<DerivedAccount> for CasperAccount {
94    #[inline]
95    fn as_ref(&self) -> &DerivedAccount {
96        &self.inner
97    }
98}
99
100impl From<CasperAccount> for DerivedAccount {
101    #[inline]
102    fn from(account: CasperAccount) -> Self {
103        account.inner
104    }
105}
106
107/// Casper address deriver from a unified wallet seed.
108///
109/// Default algorithm is [`KeyAlgo::Secp256k1`] (Ledger path). Switch with
110/// [`with_algo`](Self::with_algo) or per-call [`derive_with`](Self::derive_with).
111#[derive(Debug)]
112pub struct Deriver<'a> {
113    wallet: &'a Wallet,
114    algo: KeyAlgo,
115}
116
117impl<'a> Deriver<'a> {
118    /// Create a deriver with the default algorithm ([`KeyAlgo::Secp256k1`]).
119    #[inline]
120    #[must_use]
121    pub const fn new(wallet: &'a Wallet) -> Self {
122        Self {
123            wallet,
124            algo: KeyAlgo::Secp256k1,
125        }
126    }
127
128    /// Create a deriver locked to `algo` for [`Derive::derive`] /
129    /// [`Derive::derive_path`].
130    #[inline]
131    #[must_use]
132    pub const fn with_algo(wallet: &'a Wallet, algo: KeyAlgo) -> Self {
133        Self { wallet, algo }
134    }
135
136    /// Algorithm used by [`Derive::derive`] and [`Derive::derive_path`].
137    #[inline]
138    #[must_use]
139    pub const fn algo(&self) -> KeyAlgo {
140        self.algo
141    }
142
143    /// Derive at the default path for the deriver's algorithm.
144    ///
145    /// # Errors
146    ///
147    /// Returns an error if key derivation or `AccountHash` hashing fails.
148    #[inline]
149    pub fn derive(&self, index: u32) -> Result<CasperAccount, DeriveError> {
150        self.derive_with(self.algo, index)
151    }
152
153    /// Derive at the default path for an explicit algorithm.
154    ///
155    /// # Errors
156    ///
157    /// Returns an error if key derivation or `AccountHash` hashing fails.
158    pub fn derive_with(&self, algo: KeyAlgo, index: u32) -> Result<CasperAccount, DeriveError> {
159        self.derive_at_with(&algo.path(index), algo)
160    }
161
162    /// Derive at an arbitrary path using the deriver's stored algorithm for
163    /// tagging / `AccountHash`.
164    ///
165    /// Prefer [`derive_at_with`](Self::derive_at_with) when the path and
166    /// algorithm must be specified together.
167    ///
168    /// # Errors
169    ///
170    /// Returns an error if key derivation or `AccountHash` hashing fails.
171    #[inline]
172    pub fn derive_at(&self, path: &str) -> Result<CasperAccount, DeriveError> {
173        self.derive_at_with(path, self.algo)
174    }
175
176    /// Derive at an arbitrary path with an explicit algorithm (encoding).
177    ///
178    /// The path must be valid for the curve of `algo` (BIP-32 for secp,
179    /// fully hardened SLIP-10 for Ed25519).
180    ///
181    /// # Errors
182    ///
183    /// Returns an error if key derivation or `AccountHash` hashing fails.
184    pub fn derive_at_with(&self, path: &str, algo: KeyAlgo) -> Result<CasperAccount, DeriveError> {
185        match algo {
186            KeyAlgo::Secp256k1 => self.derive_secp(path),
187            KeyAlgo::Ed25519 => self.derive_ed25519(path),
188        }
189    }
190
191    fn derive_secp(&self, path: &str) -> Result<CasperAccount, DeriveError> {
192        let key = self.wallet.derive_secp256k1(path)?;
193        let compressed = key.compressed_pubkey();
194        let digest = account_hash_secp256k1(&compressed)?;
195        let address = format_account_hash(&digest);
196        let tagged = tagged_public_key_hex(SECP256K1_TAG, &compressed);
197        let sk = key.private_key_bytes();
198
199        let inner = DerivedAccount::new(
200            String::from(path),
201            sk,
202            DerivedPublicKey::Secp256k1Compressed(compressed),
203            address,
204        );
205
206        Ok(CasperAccount {
207            inner,
208            algo: KeyAlgo::Secp256k1,
209            tagged_public_key_hex: tagged,
210        })
211    }
212
213    fn derive_ed25519(&self, path: &str) -> Result<CasperAccount, DeriveError> {
214        let derived = self.wallet.derive_ed25519(path)?;
215        let pubkey_bytes = derived.public_key_bytes();
216        let digest = account_hash_ed25519(&pubkey_bytes)?;
217        let address = format_account_hash(&digest);
218        let tagged = tagged_public_key_hex(ED25519_TAG, &pubkey_bytes);
219        let sk_bytes = derived.private_key_bytes();
220
221        let inner = DerivedAccount::new(
222            String::from(path),
223            sk_bytes,
224            DerivedPublicKey::Ed25519(pubkey_bytes),
225            address,
226        );
227
228        Ok(CasperAccount {
229            inner,
230            algo: KeyAlgo::Ed25519,
231            tagged_public_key_hex: tagged,
232        })
233    }
234}
235
236impl Derive for Deriver<'_> {
237    type Account = CasperAccount;
238    type Error = DeriveError;
239
240    fn derive(&self, index: u32) -> Result<CasperAccount, DeriveError> {
241        Deriver::derive(self, index)
242    }
243
244    fn derive_path(&self, path: &str) -> Result<CasperAccount, DeriveError> {
245        self.derive_at(path)
246    }
247}
248
249#[cfg(test)]
250#[allow(
251    clippy::unwrap_used,
252    clippy::expect_used,
253    clippy::indexing_slicing,
254    clippy::panic,
255    reason = "unit tests"
256)]
257mod tests {
258    use alloc::format;
259    use alloc::vec::Vec;
260
261    use kobe_primitives::DeriveExt;
262
263    use super::*;
264    use crate::address::{account_hash_ed25519, account_hash_secp256k1, format_account_hash};
265
266    /// Canonical BIP-39 test mnemonic (12 × `abandon` + `about`).
267    const TEST_MNEMONIC: &str = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
268
269    /// Locked HD KAT — abandon @ secp `m/44'/506'/0'/0/0`.
270    ///
271    /// Private key from workspace BIP-32 (`kobe-primitives`). `AccountHash` via
272    /// `casper-types` preimage (`b"secp256k1" || 0x00 || compressed_pk`),
273    /// independently re-checked with Python `hashlib.blake2b` over the
274    /// public key in `SECP0_TAGGED` (strip leading `02` tag byte).
275    const SECP0_PRIV: &str = "9c72144893c3ca5fa7299e65a7d7d6c41ab6a7add5f9860618324854d3c369d1";
276    const SECP0_ADDR: &str =
277        "account-hash-e699fcd4904aa6617b2930c6d8995a6f301708b6a64621820a5896d92e2457b3";
278    const SECP0_TAGGED: &str =
279        "020357f9e27d8125932c5e6fd52babb1a114bc89363f2f56c7860bb594f74523342b";
280
281    /// Locked HD KAT — abandon @ ed25519 `m/44'/506'/0'/0'/0'`.
282    ///
283    /// Private key matches independent Python SLIP-10 (`ed25519 seed` + path).
284    /// `AccountHash` re-checked with Python `hashlib.blake2b` over the untagged
285    /// public key (`ED0_TAGGED` without leading `01`).
286    const ED0_PRIV: &str = "619386127005778f66a68fa91518c0841f59495790bb796fc781ecdd54fe329a";
287    const ED0_ADDR: &str =
288        "account-hash-356106f683840956a5bff75d011b236068ceccdf09d5c1a6a748c9355b635e08";
289    const ED0_TAGGED: &str = "016a1585d8197fc14b1d8cc05d5351e5ba04810d466158a050494c799b776ff819";
290
291    fn test_wallet() -> Wallet {
292        Wallet::from_mnemonic(TEST_MNEMONIC, None).unwrap()
293    }
294
295    /// Default deriver uses secp path and `AccountHash` address.
296    #[test]
297    fn default_algo_is_secp_path() {
298        let a = Deriver::new(&test_wallet()).derive(0).unwrap();
299        assert_eq!(a.algo(), KeyAlgo::Secp256k1);
300        assert_eq!(a.path(), "m/44'/506'/0'/0/0");
301        assert!(a.address().starts_with("account-hash-"));
302        assert_eq!(a.address().len(), "account-hash-".len() + 64);
303        assert!(a.tagged_public_key_hex().starts_with("02"));
304        assert_eq!(a.tagged_public_key_hex().len(), 2 + 66); // tag + 33-byte key hex
305        // Untagged raw key via Deref must not equal tagged form.
306        assert_ne!(a.public_key_hex(), a.tagged_public_key_hex());
307        assert_eq!(a.public_key_hex().len(), 66);
308    }
309
310    /// `AccountHash` recomputed from public key bytes must match `address()`.
311    #[test]
312    fn account_hash_matches_pubkey_encoding_secp() {
313        let a = Deriver::new(&test_wallet()).derive(0).unwrap();
314        let pk = match a.public_key() {
315            DerivedPublicKey::Secp256k1Compressed(b) => b,
316            other => panic!("expected compressed secp, got {other:?}"),
317        };
318        let digest = account_hash_secp256k1(pk).unwrap();
319        assert_eq!(a.address(), format_account_hash(&digest));
320        assert_eq!(a.tagged_public_key_hex(), format!("02{}", hex::encode(pk)));
321    }
322
323    #[test]
324    fn account_hash_matches_pubkey_encoding_ed25519() {
325        let a = Deriver::with_algo(&test_wallet(), KeyAlgo::Ed25519)
326            .derive(0)
327            .unwrap();
328        assert_eq!(a.path(), "m/44'/506'/0'/0'/0'");
329        let pk = match a.public_key() {
330            DerivedPublicKey::Ed25519(b) => b,
331            other => panic!("expected ed25519, got {other:?}"),
332        };
333        let digest = account_hash_ed25519(pk).unwrap();
334        assert_eq!(a.address(), format_account_hash(&digest));
335        assert!(a.tagged_public_key_hex().starts_with("01"));
336        assert_eq!(a.tagged_public_key_hex().len(), 2 + 64);
337    }
338
339    #[test]
340    fn kat_secp_abandon_index0() {
341        let a = Deriver::new(&test_wallet()).derive(0).unwrap();
342        assert_eq!(a.path(), "m/44'/506'/0'/0/0");
343        let sk = a.private_key_hex();
344        assert_eq!(sk.as_str(), SECP0_PRIV);
345        assert_eq!(a.address(), SECP0_ADDR);
346        assert_eq!(a.tagged_public_key_hex(), SECP0_TAGGED);
347    }
348
349    #[test]
350    fn kat_ed25519_abandon_index0() {
351        let a = Deriver::with_algo(&test_wallet(), KeyAlgo::Ed25519)
352            .derive(0)
353            .unwrap();
354        assert_eq!(a.path(), "m/44'/506'/0'/0'/0'");
355        let sk = a.private_key_hex();
356        assert_eq!(sk.as_str(), ED0_PRIV);
357        assert_eq!(a.address(), ED0_ADDR);
358        assert_eq!(a.tagged_public_key_hex(), ED0_TAGGED);
359    }
360
361    #[test]
362    fn debug_redacts_private_key() {
363        let a = Deriver::new(&test_wallet()).derive(0).unwrap();
364        let dbg = format!("{a:?}");
365        assert!(
366            dbg.contains("[REDACTED]"),
367            "Debug must redact private key: {dbg}"
368        );
369        assert!(
370            !dbg.contains(SECP0_PRIV),
371            "Debug must not leak private key hex: {dbg}"
372        );
373        assert!(dbg.contains("tagged_public_key_hex"));
374    }
375
376    #[test]
377    fn kat_secp_abandon_index1_differs() {
378        let w = test_wallet();
379        let d = Deriver::new(&w);
380        let a0 = d.derive(0).unwrap();
381        let a1 = d.derive(1).unwrap();
382        assert_ne!(a0.address(), a1.address());
383        assert_eq!(a1.path(), "m/44'/506'/0'/0/1");
384    }
385
386    #[test]
387    fn derive_many_matches_individual() {
388        let w = test_wallet();
389        let d = Deriver::new(&w);
390        let batch = d.derive_many(0, 3).unwrap();
391        let single: Vec<_> = (0..3).map(|i| d.derive(i).unwrap()).collect();
392        for (b, s) in batch.iter().zip(single.iter()) {
393            assert_eq!(b.address(), s.address());
394            assert_eq!(b.path(), s.path());
395            assert_eq!(b.tagged_public_key_hex(), s.tagged_public_key_hex());
396        }
397    }
398
399    #[test]
400    fn passphrase_changes_derivation() {
401        let w = Wallet::from_mnemonic(TEST_MNEMONIC, Some("TREZOR")).unwrap();
402        assert_ne!(
403            Deriver::new(&test_wallet()).derive(0).unwrap().address(),
404            Deriver::new(&w).derive(0).unwrap().address(),
405        );
406    }
407
408    #[test]
409    fn ed_and_secp_addresses_differ() {
410        let w = test_wallet();
411        let secp = Deriver::new(&w).derive(0).unwrap();
412        let ed = Deriver::with_algo(&w, KeyAlgo::Ed25519).derive(0).unwrap();
413        assert_ne!(secp.address(), ed.address());
414    }
415}