Skip to main content

hashiverse_lib/tools/
keys.rs

1//! # Ed25519 keypair bundle with post-quantum commitment
2//!
3//! [`Keys`] packages together everything a client or server needs to sign messages and be
4//! identified on the network:
5//! - the Ed25519 secret half (`signature_key`) used to sign,
6//! - the Ed25519 public half in two forms (`verification_key` for verification, and the
7//!   serialisable `verification_key_bytes` for wire/storage),
8//! - and the 32-byte `pq_commitment_bytes` (Falcon + Dilithium commitments — see
9//!   [`crate::tools::keys_post_quantum`]) that future-proofs the identity.
10//!
11//! Construction paths:
12//! - [`Keys::from_rnd`] — generate a fresh random keypair (optionally skipping the
13//!   slow PQ commitment derivation for test scenarios).
14//! - [`Keys::from_seed`] — deterministic derivation from a 32-byte seed; used internally
15//!   and by tests that need reproducible keys.
16//! - [`Keys::from_phrase`] — Argon2 stretch a user-supplied passphrase into a 32-byte
17//!   seed, then derive keys from it. Passphrase-recoverable accounts.
18//!
19//! Persistence:
20//! - [`Keys::to_persistence`] / [`Keys::from_persistence`] encrypt the full keypair under
21//!   a passphrase using ChaCha20Poly1305 for at-rest storage in the key locker.
22
23use crate::tools::keys_post_quantum::pq_commitment_bytes_from_seed;
24use crate::tools::tools;
25use crate::tools::types::{PQCommitmentBytes, SignatureKey, VerificationKey, VerificationKeyBytes};
26use anyhow::anyhow;
27use argon2::password_hash::rand_core::OsRng;
28use argon2::password_hash::SaltString;
29use argon2::{Argon2, PasswordHasher};
30use chacha20poly1305::aead::Aead;
31use chacha20poly1305::{AeadCore, ChaCha20Poly1305, Key, KeyInit};
32use std::fmt::{self, Display, Formatter};
33
34#[derive(Clone)]
35pub struct Keys {
36    pub signature_key: SignatureKey,
37    pub verification_key: VerificationKey,
38    pub verification_key_bytes: VerificationKeyBytes,
39    pub pq_commitment_bytes: PQCommitmentBytes, // For a critique on this, check out https://www.reddit.com/r/cryptography/comments/1thjnaj/critique_of_a_hybrid_identity_scheme_ed25519/
40}
41
42impl Display for Keys {
43    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
44        write!(f, "[ verification_key_bytes:{} pq_commitment:{} ]", hex::encode(self.verification_key.as_ref()), hex::encode(self.pq_commitment_bytes.as_ref()))
45    }
46}
47
48impl Keys {
49    pub fn from_rnd(skip_pq_commitment_bytes: bool) -> anyhow::Result<Keys> {
50        let mut seed = [0u8; 32];
51        tools::random_fill_bytes(&mut seed);
52        Self::from_seed(&seed, skip_pq_commitment_bytes)
53    }
54
55    pub fn from_phrase(phrase: &str) -> anyhow::Result<Keys> {
56        let mut seed = [0u8; 32];
57        Argon2::default().hash_password_into(phrase.as_bytes(), b"hashiverse-global-salt", &mut seed).map_err(|e| anyhow!("error hashing phrase: {}", e))?;
58
59        Self::from_seed(&seed, false)
60    }
61
62    pub fn from_seed(seed: &[u8; 32], skip_pq_commitment_bytes: bool) -> anyhow::Result<Keys> {
63        let signature_key = {
64            let ed25519_seed = blake3::derive_key("hashiverse-pk-ed25519", seed);
65            SignatureKey::from_bytes(&ed25519_seed)?
66        };
67
68        let verification_key = signature_key.verification_key();
69        let verification_key_bytes = verification_key.to_verification_key_bytes();
70        let pq_commitment_bytes = match skip_pq_commitment_bytes {
71            false => pq_commitment_bytes_from_seed(seed),
72            true => Ok(PQCommitmentBytes::zero()),
73        }?;
74
75        Ok(Keys {
76            signature_key,
77            verification_key,
78            verification_key_bytes,
79            pq_commitment_bytes,
80        })
81    }
82
83    pub fn to_persistence(&self, passphrase: &String) -> anyhow::Result<String> {
84        // Concatenate key bytes into a buffer
85        let mut buf = Vec::with_capacity(32 + 32 + 32);
86        buf.extend_from_slice(self.signature_key.as_ref());
87        buf.extend_from_slice(self.verification_key.as_ref());
88        buf.extend_from_slice(self.pq_commitment_bytes.as_ref());
89
90        // Derive encryption key from passphrase and random salt
91        let mut salt = vec![0u8; 16];
92        tools::random_fill_bytes(&mut salt);
93        let salt_string = SaltString::encode_b64(&salt).map_err(|e| anyhow!("error creating salt: {}", e))?;
94
95        let argon2 = Argon2::default();
96        let hash = argon2
97            .hash_password(passphrase.as_bytes(), &salt_string)
98            .map_err(|e| anyhow!("error hashing passphrase: {}", e))?
99            .hash
100            .ok_or_else(|| anyhow::anyhow!("argon2 failed"))?;
101        let key_bytes = hash.as_bytes();
102        let mut key = Key::default();
103        let copy_len = key_bytes.len().min(key.len());
104        key[..copy_len].copy_from_slice(&key_bytes[..copy_len]);
105
106        // Encrypt the buffer
107        let nonce = ChaCha20Poly1305::generate_nonce(&mut OsRng);
108        let nonce_slice = nonce.as_slice();
109
110        let cipher = ChaCha20Poly1305::new(&key);
111        let ciphertext = cipher.encrypt(nonce_slice.into(), buf.as_ref()).map_err(|e| anyhow!("error encrypting buffer: {}", e))?.to_vec();
112
113        // Store lengths for salt and nonce (as u8)
114        let salt_len = salt.len();
115        let nonce_len = nonce_slice.len();
116
117        if salt_len > u8::MAX as usize || nonce_len > u8::MAX as usize {
118            return Err(anyhow!("Salt or nonce too large"));
119        }
120
121        // Prepare output: [salt_len (1)][salt bytes][nonce_len (1)][nonce bytes][ciphertext]
122        let mut out = Vec::with_capacity(1 + salt_len + 1 + nonce_len + ciphertext.len());
123        out.push(salt_len as u8);
124        out.extend_from_slice(&salt);
125        out.push(nonce_len as u8);
126        out.extend_from_slice(nonce_slice);
127        out.extend_from_slice(&ciphertext);
128
129        Ok(tools::encode_base64(&out))
130    }
131
132    pub fn from_persistence(passphrase: &String, persistence: &str) -> anyhow::Result<Keys> {
133        let decoded = tools::decode_base64(persistence)?;
134
135        if decoded.len() < 2 {
136            return Err(anyhow!("Input too short for salt/nonce lengths"));
137        }
138
139        // Get salt length and slice
140        let salt_len = decoded[0] as usize;
141        if decoded.len() < 1 + salt_len + 1 {
142            return Err(anyhow!("Input too short for salt data"));
143        }
144        let salt_start = 1;
145        let salt_end = salt_start + salt_len;
146        let salt = &decoded[salt_start..salt_end];
147
148        // Get nonce length and slice
149        let nonce_len = decoded[salt_end] as usize;
150        let nonce_start = salt_end + 1;
151        let nonce_end = nonce_start + nonce_len;
152        if decoded.len() < nonce_end {
153            return Err(anyhow!("Input too short for nonce data"));
154        }
155        let nonce = &decoded[nonce_start..nonce_end];
156        let ciphertext = &decoded[nonce_end..];
157
158        // Convert salt to usable type
159        let salt_string = SaltString::encode_b64(salt).map_err(|e| anyhow!("error creating salt: {}", e))?;
160
161        // Derive encryption key from passphrase and salt
162        let argon2 = Argon2::default();
163        let hash = argon2
164            .hash_password(passphrase.as_bytes(), &salt_string)
165            .map_err(|e| anyhow!("error hashing passphrase: {}", e))?
166            .hash
167            .ok_or_else(|| anyhow!("argon2 failed"))?;
168        let key_bytes = hash.as_bytes();
169        let mut key = Key::default();
170        let copy_len = key_bytes.len().min(key.len());
171        key[..copy_len].copy_from_slice(&key_bytes[..copy_len]);
172
173        // Decrypt the buffer
174        let cipher = ChaCha20Poly1305::new(&key);
175        let buf = cipher.decrypt(nonce.into(), ciphertext).map_err(|e| anyhow!("Decryption failed: {}", e))?;
176
177        // Split the buffer back into the key materials
178        if buf.len() != 32 * 3 {
179            return Err(anyhow!("Decrypted keys len mismatch"));
180        }
181        let signature_key_bytes = <&[u8; 32]>::try_from(&buf[0..32])?;
182        let verification_key_bytes = <&[u8; 32]>::try_from(&buf[32..64])?;
183        let pq_commitment_bytes = <&[u8; 32]>::try_from(&buf[64..96])?;
184
185        let signature_key = SignatureKey::from_bytes(signature_key_bytes)?;
186        let verification_key = VerificationKey::from_bytes_raw(verification_key_bytes)?;
187        let verification_key_bytes = verification_key.to_verification_key_bytes();
188        let pq_commitment_bytes = PQCommitmentBytes::from_slice(pq_commitment_bytes)?;
189
190        Ok(Keys {
191            signature_key,
192            verification_key,
193            verification_key_bytes,
194            pq_commitment_bytes,
195        })
196    }
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202    use ml_dsa::signature::Keypair;
203    use std::string::ToString;
204    use ml_dsa::SigningKey;
205    use uuid::Uuid;
206
207    #[tokio::test]
208    async fn test_keys_to_and_from_persistence_roundtrip() -> anyhow::Result<()> {
209        for _ in 0..8 {
210            let passphrase = Uuid::new_v4().to_string();
211
212            // Generate random keys (could also use from_seed/phrase if you want determinism)
213            let keys = Keys::from_rnd(false)?;
214            let keys_persisted = keys.to_persistence(&passphrase)?;
215            let keys_unpersisted = Keys::from_persistence(&passphrase, &keys_persisted)?;
216
217            assert_eq!(keys.signature_key, keys_unpersisted.signature_key);
218            assert_eq!(keys.verification_key, keys_unpersisted.verification_key);
219            assert_eq!(keys.pq_commitment_bytes, keys_unpersisted.pq_commitment_bytes);
220
221            // Since the keys are complex objects, let's compare their byte representations
222            assert_eq!(keys.signature_key.as_ref(), keys_unpersisted.signature_key.as_ref());
223            assert_eq!(keys.verification_key.as_ref(), keys_unpersisted.verification_key.as_ref());
224            assert_eq!(keys.pq_commitment_bytes.as_ref(), keys_unpersisted.pq_commitment_bytes.as_ref());
225        }
226        Ok(())
227    }
228
229    #[tokio::test]
230    async fn test_pq_keys_are_deterministic_from_seed() {
231        let mut seed = [0u8; 32];
232        tools::random_fill_bytes(&mut seed);
233
234        let keys1 = Keys::from_seed(&seed, false).unwrap();
235        let keys2 = Keys::from_seed(&seed, false).unwrap();
236
237        assert_eq!(keys1.pq_commitment_bytes.as_ref(), keys2.pq_commitment_bytes.as_ref(), "PQ key commitments must be deterministic from the same seed");
238    }
239
240    #[tokio::test]
241    async fn test_falcon_sign_and_verify() -> anyhow::Result<()> {
242        use falcon_rust::falcon512;
243
244        let mut seed = [0u8; 32];
245        tools::random_fill_bytes(&mut seed);
246
247        let falcon_seed: [u8; 32] = blake3::derive_key("hashiverse-pk-falcon", &seed);
248        let (sk, pk) = falcon512::keygen(falcon_seed);
249
250        let msg = b"hello hashiverse";
251        let sig = falcon512::sign(msg, &sk);
252        assert!(falcon512::verify(msg, &sig, &pk), "Falcon signature should verify");
253
254        // Rehydrated verifying key
255        let pk_rehydrated = falcon512::PublicKey::from_bytes(&pk.to_bytes()).map_err(|e| anyhow::anyhow!("Failed to decode Falcon public key: {:?}", e))?;
256        assert!(falcon512::verify(msg, &sig, &pk_rehydrated), "Rehydrated Falcon verifying key should verify");
257
258        // Rehydrated signing key: re-serialised, signs a new message
259        let sk_rehydrated = falcon512::SecretKey::from_bytes(&sk.to_bytes()).map_err(|e| anyhow::anyhow!("Failed to decode Falcon secret key: {:?}", e))?;
260        let msg2 = b"second message";
261        let sig2 = falcon512::sign(msg2, &sk_rehydrated);
262        assert!(falcon512::verify(msg2, &sig2, &pk), "Rehydrated Falcon signing key should produce valid signatures");
263
264        // Wrong message should not verify
265        assert!(!falcon512::verify(b"wrong message", &sig, &pk), "Falcon should reject wrong message");
266
267        Ok(())
268    }
269
270    #[tokio::test]
271    async fn test_dilithium_sign_and_verify() -> anyhow::Result<()> {
272        use ml_dsa::MlDsa44;
273        use ml_dsa::signature::{Signer, Verifier};
274
275        let mut seed = [0u8; 32];
276        tools::random_fill_bytes(&mut seed);
277        let dilithium_seed = blake3::derive_key("hashiverse-pk-dilithium", &seed);
278
279        // Generate key pair — same derivation as Keys::from_seed
280        let signing_key = SigningKey::<MlDsa44>::from_seed(&dilithium_seed.into());
281
282        let msg = b"hello hashiverse";
283        let sig = signing_key.sign(msg);
284
285        // Verify with original verifying key
286        assert!(signing_key.verifying_key().verify(msg, &sig).is_ok(), "Dilithium signature should verify");
287
288        // Rehydrated: regenerate from the same seed (the seed is the Dilithium private key)
289        let kp_rehydrated = SigningKey::<MlDsa44>::from_seed(&dilithium_seed.into());
290        let vk_encoded = signing_key.verifying_key().encode();
291        let vk_rehydrated_encoded = kp_rehydrated.verifying_key().encode();
292        assert_eq!(vk_encoded, vk_rehydrated_encoded, "Dilithium keys must be identical for the same seed");
293        assert!(kp_rehydrated.verifying_key().verify(msg, &sig).is_ok(), "Rehydrated Dilithium verifying key should verify the same signature");
294
295        // Wrong message should fail
296        assert!(signing_key.verifying_key().verify(b"wrong message", &sig).is_err(), "Dilithium should reject wrong message");
297
298        Ok(())
299    }
300
301    #[tokio::test]
302    async fn test_pq_commitment_matches_key() -> anyhow::Result<()> {
303        use falcon_rust::falcon512;
304        use ml_dsa::MlDsa44;
305
306        let seed = [123u8; 32];
307        let keys = Keys::from_seed(&seed, false)?;
308
309        // Independently compute expected Falcon commitment
310        let expected_falcon: [u8; 16] = {
311            let falcon_seed: [u8; 32] = blake3::derive_key("hashiverse-pk-falcon", &seed);
312            let (_, pk) = falcon512::keygen(falcon_seed);
313            let vrfy_key = pk.to_bytes();
314            let hash = blake3::hash(&vrfy_key);
315            hash.as_bytes()[..16].try_into()?
316        };
317
318        // Independently compute expected Dilithium commitment
319        let expected_dilithium: [u8; 16] = {
320            let dilithium_seed = blake3::derive_key("hashiverse-pk-dilithium", &seed);
321            let kp = SigningKey::<MlDsa44>::from_seed(&dilithium_seed.into());
322            let vk_bytes = kp.verifying_key().encode();
323            let hash = blake3::hash(vk_bytes.as_ref());
324            hash.as_bytes()[..16].try_into()?
325        };
326
327        let expected: Vec<u8> = [expected_falcon, expected_dilithium].concat();
328        assert_eq!(keys.pq_commitment_bytes.as_ref(), expected.as_slice(), "pq_commitment_bytes must match independently computed PQ commitments");
329
330        Ok(())
331    }
332}