Skip to main content

darkpool_client/
identity.rs

1//! Cryptographic identity management: BJJ keypairs (note ownership), X25519 keypairs
2//! (mixnet routing), and hierarchical `DarkAccount` key derivation.
3
4use ark_ff::{BigInteger, PrimeField};
5use curve25519_dalek::constants::X25519_BASEPOINT;
6use curve25519_dalek::montgomery::MontgomeryPoint;
7use curve25519_dalek::scalar::Scalar;
8use ethers::types::U256;
9use num_bigint::BigUint;
10use rand::RngCore;
11use serde::{Deserialize, Serialize};
12use sha2::{Digest, Sha256};
13use zeroize::Zeroize;
14
15use darkpool_crypto::Kdf;
16use darkpool_crypto::{
17    CryptoError as BjjCryptoError, PublicKey as BjjPublicKey, SecretKey as BjjSecretKey,
18    SharedSecret, BASE8, SUBGROUP_ORDER,
19};
20
21pub type X25519SecretKey = [u8; 32];
22pub type X25519PublicKey = [u8; 32];
23
24#[allow(clippy::expect_used)]
25static SUBGROUP_ORDER_BIGINT: std::sync::LazyLock<BigUint> = std::sync::LazyLock::new(|| {
26    BigUint::parse_bytes(SUBGROUP_ORDER.as_bytes(), 10).expect("valid constant")
27});
28
29/// BJJ keypair (Baby Jubjub curve). Note: `BjjSecretKey` (`ark_ed_on_bn254::Fr`)
30/// does not implement `Zeroize` -- keypairs are short-lived and never persisted.
31#[derive(Debug, Clone)]
32pub struct BjjKeypair {
33    sk: BjjSecretKey,
34    pk: BjjPublicKey,
35}
36
37impl Serialize for BjjKeypair {
38    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
39    where
40        S: serde::Serializer,
41    {
42        use serde::ser::SerializeStruct;
43        let mut state = serializer.serialize_struct("BjjKeypair", 3)?;
44        state.serialize_field("sk", &self.sk.to_hex())?;
45        state.serialize_field("pk", &self.pk.to_hex())?;
46        state.end()
47    }
48}
49
50impl<'de> Deserialize<'de> for BjjKeypair {
51    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
52    where
53        D: serde::Deserializer<'de>,
54    {
55        #[derive(Deserialize)]
56        struct BjjKeypairData {
57            sk: String,
58            pk: String,
59        }
60
61        let data = BjjKeypairData::deserialize(deserializer)?;
62        let sk = BjjSecretKey::from_hex(&data.sk).map_err(serde::de::Error::custom)?;
63        let pk = BjjPublicKey::from_hex(&data.pk).map_err(serde::de::Error::custom)?;
64
65        Ok(Self { sk, pk })
66    }
67}
68
69impl BjjKeypair {
70    #[must_use]
71    pub fn generate() -> Self {
72        let mut rng = rand::rngs::OsRng;
73        let sk = BjjSecretKey::generate(&mut rng);
74        let pk = sk.public_key().unwrap_or_else(|_| {
75            unreachable!("public key derivation cannot fail for random scalar")
76        });
77        Self { sk, pk }
78    }
79
80    /// Deterministic derivation with `SUBGROUP_ORDER` reduction (matches TS circomlibjs).
81    pub fn from_seed(seed: &[u8]) -> Result<Self, BjjCryptoError> {
82        let mut hasher = Sha256::new();
83        hasher.update(b"hisoka.bjj.keypair");
84        hasher.update(seed);
85        let hash_bytes = hasher.finalize();
86
87        let hash_bigint = BigUint::from_bytes_be(&hash_bytes);
88        let reduced = hash_bigint % &*SUBGROUP_ORDER_BIGINT;
89
90        let mut sk_bytes = reduced.to_bytes_be();
91        while sk_bytes.len() < 32 {
92            sk_bytes.insert(0, 0);
93        }
94
95        let sk = BjjSecretKey::from_hex(&hex::encode(&sk_bytes))?;
96        let pk = sk.public_key()?;
97        Ok(Self { sk, pk })
98    }
99
100    #[allow(clippy::must_use_candidate)]
101    pub fn public_key(&self) -> &BjjPublicKey {
102        &self.pk
103    }
104
105    #[allow(clippy::must_use_candidate)]
106    pub fn pk_x(&self) -> U256 {
107        let bytes = self.pk.x().into_bigint().to_bytes_be();
108        U256::from_big_endian(&bytes)
109    }
110
111    #[allow(clippy::must_use_candidate)]
112    pub fn pk_y(&self) -> U256 {
113        let bytes = self.pk.y().into_bigint().to_bytes_be();
114        U256::from_big_endian(&bytes)
115    }
116
117    #[allow(clippy::must_use_candidate)]
118    pub fn pk_tuple(&self) -> (U256, U256) {
119        (self.pk_x(), self.pk_y())
120    }
121
122    #[allow(clippy::must_use_candidate)]
123    pub fn sk_as_u256(&self) -> U256 {
124        let bytes = self.sk.0.into_bigint().to_bytes_be();
125        U256::from_big_endian(&bytes)
126    }
127
128    pub fn derive_shared_secret(
129        &self,
130        peer_pk: &BjjPublicKey,
131    ) -> Result<SharedSecret, BjjCryptoError> {
132        self.sk.derive_shared_secret(peer_pk)
133    }
134
135    pub fn derive_shared_secret_x(&self, peer_pk: &BjjPublicKey) -> Result<U256, BjjCryptoError> {
136        let ss = self.derive_shared_secret(peer_pk)?;
137        let bytes = ss.x().into_bigint().to_bytes_be();
138        Ok(U256::from_big_endian(&bytes))
139    }
140
141    #[allow(clippy::must_use_candidate)]
142    pub fn sk_hex(&self) -> String {
143        self.sk.to_hex()
144    }
145
146    #[allow(clippy::must_use_candidate)]
147    pub fn pk_hex(&self) -> String {
148        self.pk.to_hex()
149    }
150}
151
152/// X25519 keypair for mixnet routing.
153#[derive(Debug, Clone)]
154pub struct X25519Keypair {
155    pub sk: X25519SecretKey,
156    pub pk: X25519PublicKey,
157}
158
159impl X25519Keypair {
160    #[must_use]
161    pub fn generate() -> Self {
162        let mut rng = rand::rngs::OsRng;
163        let mut sk = [0u8; 32];
164        rng.fill_bytes(&mut sk);
165
166        // Clamp per X25519 spec
167        sk[0] &= 0xF8;
168        sk[31] &= 0x7F;
169        sk[31] |= 0x40;
170
171        let scalar = Scalar::from_bytes_mod_order(sk);
172        let pk_point = X25519_BASEPOINT * scalar;
173
174        Self {
175            sk,
176            pk: pk_point.to_bytes(),
177        }
178    }
179
180    #[must_use]
181    pub fn from_seed(seed: &[u8]) -> Self {
182        let mut hasher = Sha256::new();
183        hasher.update(b"hisoka.x25519.keypair");
184        hasher.update(seed);
185        let mut sk: [u8; 32] = hasher.finalize().into();
186
187        sk[0] &= 0xF8;
188        sk[31] &= 0x7F;
189        sk[31] |= 0x40;
190
191        let scalar = Scalar::from_bytes_mod_order(sk);
192        let pk_point = X25519_BASEPOINT * scalar;
193
194        Self {
195            sk,
196            pk: pk_point.to_bytes(),
197        }
198    }
199
200    #[must_use]
201    pub fn ecdh(&self, other_pk: &X25519PublicKey) -> [u8; 32] {
202        let other_point = MontgomeryPoint(*other_pk);
203        let scalar = Scalar::from_bytes_mod_order(self.sk);
204        (other_point * scalar).to_bytes()
205    }
206}
207
208impl Drop for X25519Keypair {
209    fn drop(&mut self) {
210        self.sk.zeroize();
211    }
212}
213
214#[derive(Debug, Clone)]
215pub struct ClientIdentity {
216    pub bjj: BjjKeypair,
217    pub x25519: X25519Keypair,
218    pub name: String,
219}
220
221impl ClientIdentity {
222    #[must_use]
223    pub fn new(name: &str) -> Self {
224        Self {
225            bjj: BjjKeypair::generate(),
226            x25519: X25519Keypair::generate(),
227            name: name.to_string(),
228        }
229    }
230
231    pub fn from_seed(name: &str, seed: &[u8]) -> Result<Self, BjjCryptoError> {
232        Ok(Self {
233            bjj: BjjKeypair::from_seed(seed)?,
234            x25519: X25519Keypair::from_seed(seed),
235            name: name.to_string(),
236        })
237    }
238
239    pub fn from_signature(name: &str, signature: &[u8]) -> Result<Self, BjjCryptoError> {
240        Self::from_seed(name, signature)
241    }
242}
243
244const BN254_FR_MODULUS: &str =
245    "21888242871839275222246405745257275088548364400416034343698204186575808495617";
246
247#[allow(clippy::expect_used)]
248static BN254_MODULUS_BIGINT: std::sync::LazyLock<BigUint> = std::sync::LazyLock::new(|| {
249    BigUint::parse_bytes(BN254_FR_MODULUS.as_bytes(), 10).expect("valid constant")
250});
251
252/// Hierarchical key derivation (mirrors TypeScript `DarkAccount`):
253///
254/// ```text
255/// sk_root
256///    ├── sk_spend = Kdf("hisoka.spend", sk_root)
257///    └── sk_view  = Kdf("hisoka.view", sk_root)
258///           └── vk_master = Kdf("hisoka.ivkMaster", sk_view)
259///                  ├── ivk_j = vk_master + Kdf("hisoka.ivkTweak", vk_master, j)
260///                  └── esk_j = vk_master + Kdf("hisoka.eskTweak", vk_master, j)
261/// ```
262#[derive(Debug, Clone)]
263pub struct DarkAccount {
264    sk_root: U256,
265    sk_spend: Option<U256>,
266    sk_view: Option<U256>,
267    vk_master: Option<U256>,
268}
269
270/// U256 doesn't implement Zeroize, so we use volatile writes.
271impl Drop for DarkAccount {
272    fn drop(&mut self) {
273        zeroize_u256(&mut self.sk_root);
274        if let Some(ref mut v) = self.sk_spend {
275            zeroize_u256(v);
276        }
277        if let Some(ref mut v) = self.sk_view {
278            zeroize_u256(v);
279        }
280        if let Some(ref mut v) = self.vk_master {
281            zeroize_u256(v);
282        }
283    }
284}
285
286fn zeroize_u256(val: &mut U256) {
287    let ptr = std::ptr::from_mut::<U256>(val);
288    // volatile_write prevents dead-store elimination
289    unsafe { std::ptr::write_volatile(ptr, U256::zero()) };
290}
291
292impl DarkAccount {
293    #[must_use]
294    pub fn new(sk_root: U256) -> Self {
295        Self {
296            sk_root,
297            sk_spend: None,
298            sk_view: None,
299            vk_master: None,
300        }
301    }
302
303    /// Reduce signature mod BN254 Fr, then derive root key via KDF.
304    #[must_use]
305    pub fn from_signature(signature: &[u8]) -> Self {
306        let sig_bigint = BigUint::from_bytes_be(signature);
307        let reduced = sig_bigint % &*BN254_MODULUS_BIGINT;
308
309        let mut sig_bytes = reduced.to_bytes_be();
310        while sig_bytes.len() < 32 {
311            sig_bytes.insert(0, 0);
312        }
313        let sig_fr = U256::from_big_endian(&sig_bytes[..32.min(sig_bytes.len())]);
314
315        #[allow(clippy::expect_used)]
316        let sk_root = Kdf::derive("hisoka.root", sig_fr, None).expect("valid purpose string");
317        Self::new(sk_root)
318    }
319
320    /// Deterministic derivation from seed (for testing; production uses `from_signature`).
321    #[must_use]
322    pub fn from_seed(seed: &[u8]) -> Self {
323        let mut hasher = Sha256::new();
324        hasher.update(b"hisoka.seed");
325        hasher.update(seed);
326        let hash = hasher.finalize();
327
328        let hash_bigint = BigUint::from_bytes_be(&hash);
329        let reduced = hash_bigint % &*BN254_MODULUS_BIGINT;
330
331        let mut bytes = reduced.to_bytes_be();
332        while bytes.len() < 32 {
333            bytes.insert(0, 0);
334        }
335        let seed_fr = U256::from_big_endian(&bytes);
336
337        #[allow(clippy::expect_used)]
338        let sk_root = Kdf::derive("hisoka.root", seed_fr, None).expect("valid purpose string");
339        Self::new(sk_root)
340    }
341
342    #[allow(clippy::must_use_candidate)]
343    pub fn sk_root(&self) -> U256 {
344        self.sk_root
345    }
346
347    #[allow(clippy::expect_used)]
348    pub fn get_spend_key(&mut self) -> U256 {
349        *self.sk_spend.get_or_insert_with(|| {
350            Kdf::derive("hisoka.spend", self.sk_root, None).expect("valid purpose string")
351        })
352    }
353
354    #[allow(clippy::expect_used)]
355    pub fn get_view_key(&mut self) -> U256 {
356        *self.sk_view.get_or_insert_with(|| {
357            Kdf::derive("hisoka.view", self.sk_root, None).expect("valid purpose string")
358        })
359    }
360
361    #[allow(clippy::expect_used)]
362    fn get_vk_master(&mut self) -> U256 {
363        if self.vk_master.is_none() {
364            let sk_view = self.get_view_key();
365            self.vk_master =
366                Some(Kdf::derive("hisoka.ivkMaster", sk_view, None).expect("valid purpose string"));
367        }
368        *self.vk_master.as_ref().unwrap_or_else(|| unreachable!())
369    }
370
371    /// Reduced mod BJJ subgroup order (not BN254 Fr) so the scalar fits in
372    /// Noir's `ScalarField::<63>` (max 2^252).
373    #[allow(clippy::expect_used)]
374    pub fn get_ephemeral_outgoing_key(&mut self, index: u64) -> U256 {
375        let vk_master = self.get_vk_master();
376        let tweak =
377            Kdf::derive_indexed("hisoka.eskTweak", vk_master, index).expect("valid purpose string");
378        Self::add_mod_subgroup_order(vk_master, tweak)
379    }
380
381    #[allow(clippy::expect_used)]
382    pub fn get_incoming_viewing_key(&mut self, index: u64) -> U256 {
383        let vk_master = self.get_vk_master();
384        let tweak =
385            Kdf::derive_indexed("hisoka.ivkTweak", vk_master, index).expect("valid purpose string");
386        Self::add_mod_subgroup_order(vk_master, tweak)
387    }
388
389    pub fn get_public_ephemeral_key(&mut self, index: u64) -> Result<(U256, U256), BjjCryptoError> {
390        let esk = self.get_ephemeral_outgoing_key(index);
391        Self::scalar_mul_base8(esk)
392    }
393
394    pub fn get_public_incoming_key(&mut self, index: u64) -> Result<(U256, U256), BjjCryptoError> {
395        let ivk = self.get_incoming_viewing_key(index);
396        Self::scalar_mul_base8(ivk)
397    }
398
399    /// Uses BJJ subgroup order (~2^251) instead of BN254 Fr (~2^254) to avoid
400    /// overflowing Noir's nibble decomposition in `noir-edwards`.
401    fn add_mod_subgroup_order(a: U256, b: U256) -> U256 {
402        let a_bigint = BigUint::from_bytes_be(&{
403            let mut bytes = [0u8; 32];
404            a.to_big_endian(&mut bytes);
405            bytes
406        });
407        let b_bigint = BigUint::from_bytes_be(&{
408            let mut bytes = [0u8; 32];
409            b.to_big_endian(&mut bytes);
410            bytes
411        });
412
413        let sum = (a_bigint + b_bigint) % &*SUBGROUP_ORDER_BIGINT;
414        let mut sum_bytes = sum.to_bytes_be();
415        while sum_bytes.len() < 32 {
416            sum_bytes.insert(0, 0);
417        }
418        U256::from_big_endian(&sum_bytes)
419    }
420
421    fn scalar_mul_base8(scalar: U256) -> Result<(U256, U256), BjjCryptoError> {
422        use ark_ff::BigInteger;
423
424        let mut scalar_bytes = [0u8; 32];
425        scalar.to_big_endian(&mut scalar_bytes);
426        scalar_bytes.reverse(); // mul_scalar expects little-endian
427
428        let result = BASE8.mul_scalar(&scalar_bytes)?;
429        let x_bytes = result.x().into_bigint().to_bytes_be();
430        let y_bytes = result.y().into_bigint().to_bytes_be();
431
432        Ok((
433            U256::from_big_endian(&x_bytes),
434            U256::from_big_endian(&y_bytes),
435        ))
436    }
437}
438
439#[cfg(test)]
440mod tests {
441    use super::*;
442
443    #[test]
444    fn test_bjj_keypair_generation() {
445        let kp1 = BjjKeypair::generate();
446        let kp2 = BjjKeypair::generate();
447        assert_ne!(kp1.sk_hex(), kp2.sk_hex());
448        assert_ne!(kp1.pk_hex(), kp2.pk_hex());
449    }
450
451    #[test]
452    fn test_bjj_from_seed_deterministic() {
453        let seed = b"alice_secret_seed";
454        let kp1 = BjjKeypair::from_seed(seed).unwrap();
455        let kp2 = BjjKeypair::from_seed(seed).unwrap();
456        assert_eq!(kp1.sk_hex(), kp2.sk_hex());
457        assert_eq!(kp1.pk_hex(), kp2.pk_hex());
458    }
459
460    #[test]
461    fn test_bjj_subgroup_reduction() {
462        let seed = [0xffu8; 64];
463        let kp = BjjKeypair::from_seed(&seed).unwrap();
464        let sk_u256 = kp.sk_as_u256();
465        let subgroup_order = U256::from_dec_str(SUBGROUP_ORDER).unwrap();
466        assert!(sk_u256 < subgroup_order);
467    }
468
469    #[test]
470    fn test_bjj_ecdh() {
471        let alice = BjjKeypair::generate();
472        let bob = BjjKeypair::generate();
473        let ss_alice = alice.derive_shared_secret_x(bob.public_key()).unwrap();
474        let ss_bob = bob.derive_shared_secret_x(alice.public_key()).unwrap();
475        assert_eq!(ss_alice, ss_bob);
476    }
477
478    #[test]
479    fn test_x25519_ecdh() {
480        let alice = X25519Keypair::generate();
481        let bob = X25519Keypair::generate();
482        assert_eq!(alice.ecdh(&bob.pk), bob.ecdh(&alice.pk));
483    }
484
485    #[test]
486    fn test_bjj_serialization_roundtrip() {
487        let kp = BjjKeypair::generate();
488        let json = serde_json::to_string(&kp).unwrap();
489        let kp2: BjjKeypair = serde_json::from_str(&json).unwrap();
490        assert_eq!(kp.sk_hex(), kp2.sk_hex());
491        assert_eq!(kp.pk_hex(), kp2.pk_hex());
492    }
493
494    #[test]
495    fn test_dark_account_from_seed_deterministic() {
496        let seed = b"alice_secret_seed_for_dark_account";
497        let mut account1 = DarkAccount::from_seed(seed);
498        let mut account2 = DarkAccount::from_seed(seed);
499        assert_eq!(account1.sk_root(), account2.sk_root());
500        assert_eq!(account1.get_spend_key(), account2.get_spend_key());
501        assert_eq!(account1.get_view_key(), account2.get_view_key());
502    }
503
504    #[test]
505    fn test_dark_account_key_hierarchy() {
506        let mut account = DarkAccount::from_seed(b"test_hierarchy");
507        let sk_root = account.sk_root();
508        let sk_spend = account.get_spend_key();
509        let sk_view = account.get_view_key();
510
511        assert!(!sk_root.is_zero());
512        assert!(!sk_spend.is_zero());
513        assert!(!sk_view.is_zero());
514        assert_ne!(sk_root, sk_spend);
515        assert_ne!(sk_root, sk_view);
516        assert_ne!(sk_spend, sk_view);
517    }
518
519    #[test]
520    fn test_dark_account_per_index_keys() {
521        let mut account = DarkAccount::from_seed(b"test_per_index");
522
523        let esk_0 = account.get_ephemeral_outgoing_key(0);
524        let esk_1 = account.get_ephemeral_outgoing_key(1);
525        let esk_2 = account.get_ephemeral_outgoing_key(2);
526        assert_ne!(esk_0, esk_1);
527        assert_ne!(esk_1, esk_2);
528
529        let ivk_0 = account.get_incoming_viewing_key(0);
530        let ivk_1 = account.get_incoming_viewing_key(1);
531        assert_ne!(ivk_0, ivk_1);
532        assert_ne!(esk_0, ivk_0);
533    }
534
535    #[test]
536    fn test_dark_account_public_keys() {
537        let mut account = DarkAccount::from_seed(b"test_public_keys");
538
539        let (epk_x, epk_y) = account.get_public_ephemeral_key(0).unwrap();
540        let (ivk_x, ivk_y) = account.get_public_incoming_key(0).unwrap();
541        assert!(!epk_x.is_zero() || !epk_y.is_zero());
542        assert!(!ivk_x.is_zero() || !ivk_y.is_zero());
543
544        let (epk1_x, epk1_y) = account.get_public_ephemeral_key(1).unwrap();
545        assert!(epk_x != epk1_x || epk_y != epk1_y);
546    }
547
548    #[test]
549    fn test_dark_account_from_signature() {
550        let signature = hex::decode(
551            "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\
552             0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef01",
553        )
554        .unwrap();
555
556        let mut account = DarkAccount::from_signature(&signature);
557        assert!(!account.sk_root().is_zero());
558        assert!(!account.get_spend_key().is_zero());
559    }
560
561    #[test]
562    fn test_dark_account_caching() {
563        let mut account = DarkAccount::from_seed(b"test_caching");
564        let sk_spend_1 = account.get_spend_key();
565        let sk_spend_2 = account.get_spend_key();
566        assert_eq!(sk_spend_1, sk_spend_2);
567
568        let sk_view_1 = account.get_view_key();
569        let sk_view_2 = account.get_view_key();
570        assert_eq!(sk_view_1, sk_view_2);
571    }
572}