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::Argon2;
28use chacha20poly1305::aead::{Aead, Generate};
29use chacha20poly1305::{ChaCha20Poly1305, Key, KeyInit, Nonce};
30use std::fmt::{self, Display, Formatter};
31
32/// Argon2id (default params) over the passphrase and raw salt bytes, producing the 32-byte
33/// ChaCha20Poly1305 key used for key persistence.  Identical output to the argon2 0.5
34/// `hash_password(passphrase, SaltString::encode_b64(salt))` path it replaced (that path
35/// decoded the salt back to raw bytes and used the default 32-byte output length) — pinned by
36/// `keys_from_persistence_regression_vector`.
37fn derive_persistence_key(passphrase: &str, salt: &[u8]) -> anyhow::Result<Key> {
38    let mut key = Key::default();
39    Argon2::default().hash_password_into(passphrase.as_bytes(), salt, &mut key).map_err(|e| anyhow!("error hashing passphrase: {}", e))?;
40    Ok(key)
41}
42
43#[derive(Clone)]
44pub struct Keys {
45    pub signature_key: SignatureKey,
46    pub verification_key: VerificationKey,
47    pub verification_key_bytes: VerificationKeyBytes,
48    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/
49}
50
51impl Display for Keys {
52    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
53        write!(f, "[ verification_key_bytes:{} pq_commitment:{} ]", hex::encode(self.verification_key.as_ref()), hex::encode(self.pq_commitment_bytes.as_ref()))
54    }
55}
56
57impl Keys {
58    pub fn from_rnd(skip_pq_commitment_bytes: bool) -> anyhow::Result<Keys> {
59        let mut seed = [0u8; 32];
60        tools::random_fill_bytes(&mut seed);
61        Self::from_seed(&seed, skip_pq_commitment_bytes)
62    }
63
64    pub fn from_phrase(phrase: &str) -> anyhow::Result<Keys> {
65        let mut seed = [0u8; 32];
66        Argon2::default().hash_password_into(phrase.as_bytes(), b"hashiverse-global-salt", &mut seed).map_err(|e| anyhow!("error hashing phrase: {}", e))?;
67
68        Self::from_seed(&seed, false)
69    }
70
71    pub fn from_seed(seed: &[u8; 32], skip_pq_commitment_bytes: bool) -> anyhow::Result<Keys> {
72        let signature_key = {
73            let ed25519_seed = blake3::derive_key("hashiverse-pk-ed25519", seed);
74            SignatureKey::from_bytes(&ed25519_seed)?
75        };
76
77        let verification_key = signature_key.verification_key();
78        let verification_key_bytes = verification_key.to_verification_key_bytes();
79        let pq_commitment_bytes = match skip_pq_commitment_bytes {
80            false => pq_commitment_bytes_from_seed(seed),
81            true => Ok(PQCommitmentBytes::zero()),
82        }?;
83
84        Ok(Keys {
85            signature_key,
86            verification_key,
87            verification_key_bytes,
88            pq_commitment_bytes,
89        })
90    }
91
92    pub fn to_persistence(&self, passphrase: &String) -> anyhow::Result<String> {
93        // Concatenate key bytes into a buffer
94        let mut buf = Vec::with_capacity(32 + 32 + 32);
95        buf.extend_from_slice(self.signature_key.as_ref());
96        buf.extend_from_slice(self.verification_key.as_ref());
97        buf.extend_from_slice(self.pq_commitment_bytes.as_ref());
98
99        // Derive encryption key from passphrase and random salt
100        let mut salt = vec![0u8; 16];
101        tools::random_fill_bytes(&mut salt);
102        let key = derive_persistence_key(passphrase, &salt)?;
103
104        // Encrypt the buffer
105        let nonce = Nonce::generate();
106
107        let cipher = ChaCha20Poly1305::new(&key);
108        let ciphertext = cipher.encrypt(&nonce, buf.as_ref()).map_err(|e| anyhow!("error encrypting buffer: {}", e))?.to_vec();
109
110        // Store lengths for salt and nonce (as u8)
111        let salt_len = salt.len();
112        let nonce_len = nonce.len();
113
114        if salt_len > u8::MAX as usize || nonce_len > u8::MAX as usize {
115            return Err(anyhow!("Salt or nonce too large"));
116        }
117
118        // Prepare output: [salt_len (1)][salt bytes][nonce_len (1)][nonce bytes][ciphertext]
119        let mut out = Vec::with_capacity(1 + salt_len + 1 + nonce_len + ciphertext.len());
120        out.push(salt_len as u8);
121        out.extend_from_slice(&salt);
122        out.push(nonce_len as u8);
123        out.extend_from_slice(&nonce);
124        out.extend_from_slice(&ciphertext);
125
126        Ok(tools::encode_base64(&out))
127    }
128
129    pub fn from_persistence(passphrase: &String, persistence: &str) -> anyhow::Result<Keys> {
130        let decoded = tools::decode_base64(persistence)?;
131
132        if decoded.len() < 2 {
133            return Err(anyhow!("Input too short for salt/nonce lengths"));
134        }
135
136        // Get salt length and slice
137        let salt_len = decoded[0] as usize;
138        if decoded.len() < 1 + salt_len + 1 {
139            return Err(anyhow!("Input too short for salt data"));
140        }
141        let salt_start = 1;
142        let salt_end = salt_start + salt_len;
143        let salt = &decoded[salt_start..salt_end];
144
145        // Get nonce length and slice
146        let nonce_len = decoded[salt_end] as usize;
147        let nonce_start = salt_end + 1;
148        let nonce_end = nonce_start + nonce_len;
149        if decoded.len() < nonce_end {
150            return Err(anyhow!("Input too short for nonce data"));
151        }
152        let nonce = &decoded[nonce_start..nonce_end];
153        let ciphertext = &decoded[nonce_end..];
154
155        // Derive encryption key from passphrase and salt
156        let key = derive_persistence_key(passphrase, salt)?;
157
158        // Decrypt the buffer
159        let nonce = <&Nonce>::try_from(nonce).map_err(|_| anyhow!("Invalid nonce length: {}", nonce.len()))?;
160        let cipher = ChaCha20Poly1305::new(&key);
161        let buf = cipher.decrypt(nonce, ciphertext).map_err(|e| anyhow!("Decryption failed: {}", e))?;
162
163        // Split the buffer back into the key materials
164        if buf.len() != 32 * 3 {
165            return Err(anyhow!("Decrypted keys len mismatch"));
166        }
167        let signature_key_bytes = <&[u8; 32]>::try_from(&buf[0..32])?;
168        let verification_key_bytes = <&[u8; 32]>::try_from(&buf[32..64])?;
169        let pq_commitment_bytes = <&[u8; 32]>::try_from(&buf[64..96])?;
170
171        let signature_key = SignatureKey::from_bytes(signature_key_bytes)?;
172        let verification_key = VerificationKey::from_bytes_raw(verification_key_bytes)?;
173        let verification_key_bytes = verification_key.to_verification_key_bytes();
174        let pq_commitment_bytes = PQCommitmentBytes::from_slice(pq_commitment_bytes)?;
175
176        Ok(Keys {
177            signature_key,
178            verification_key,
179            verification_key_bytes,
180            pq_commitment_bytes,
181        })
182    }
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188    use ml_dsa::signature::Keypair;
189    use std::string::ToString;
190    use ml_dsa::SigningKey;
191    use uuid::Uuid;
192
193    #[tokio::test]
194    async fn test_keys_to_and_from_persistence_roundtrip() -> anyhow::Result<()> {
195        for _ in 0..8 {
196            let passphrase = Uuid::new_v4().to_string();
197
198            // Generate random keys (could also use from_seed/phrase if you want determinism)
199            let keys = Keys::from_rnd(false)?;
200            let keys_persisted = keys.to_persistence(&passphrase)?;
201            let keys_unpersisted = Keys::from_persistence(&passphrase, &keys_persisted)?;
202
203            assert_eq!(keys.signature_key, keys_unpersisted.signature_key);
204            assert_eq!(keys.verification_key, keys_unpersisted.verification_key);
205            assert_eq!(keys.pq_commitment_bytes, keys_unpersisted.pq_commitment_bytes);
206
207            // Since the keys are complex objects, let's compare their byte representations
208            assert_eq!(keys.signature_key.as_ref(), keys_unpersisted.signature_key.as_ref());
209            assert_eq!(keys.verification_key.as_ref(), keys_unpersisted.verification_key.as_ref());
210            assert_eq!(keys.pq_commitment_bytes.as_ref(), keys_unpersisted.pq_commitment_bytes.as_ref());
211        }
212        Ok(())
213    }
214
215    #[tokio::test]
216    async fn test_pq_keys_are_deterministic_from_seed() {
217        let mut seed = [0u8; 32];
218        tools::random_fill_bytes(&mut seed);
219
220        let keys1 = Keys::from_seed(&seed, false).unwrap();
221        let keys2 = Keys::from_seed(&seed, false).unwrap();
222
223        assert_eq!(keys1.pq_commitment_bytes.as_ref(), keys2.pq_commitment_bytes.as_ref(), "PQ key commitments must be deterministic from the same seed");
224    }
225
226    #[tokio::test]
227    async fn test_falcon_sign_and_verify() -> anyhow::Result<()> {
228        use falcon_rust::falcon512;
229
230        let mut seed = [0u8; 32];
231        tools::random_fill_bytes(&mut seed);
232
233        let falcon_seed: [u8; 32] = blake3::derive_key("hashiverse-pk-falcon", &seed);
234        let (sk, pk) = falcon512::keygen(falcon_seed);
235
236        let msg = b"hello hashiverse";
237        let sig = falcon512::sign(msg, &sk);
238        assert!(falcon512::verify(msg, &sig, &pk), "Falcon signature should verify");
239
240        // Rehydrated verifying key
241        let pk_rehydrated = falcon512::PublicKey::from_bytes(&pk.to_bytes()).map_err(|e| anyhow::anyhow!("Failed to decode Falcon public key: {:?}", e))?;
242        assert!(falcon512::verify(msg, &sig, &pk_rehydrated), "Rehydrated Falcon verifying key should verify");
243
244        // Rehydrated signing key: re-serialised, signs a new message
245        let sk_rehydrated = falcon512::SecretKey::from_bytes(&sk.to_bytes()).map_err(|e| anyhow::anyhow!("Failed to decode Falcon secret key: {:?}", e))?;
246        let msg2 = b"second message";
247        let sig2 = falcon512::sign(msg2, &sk_rehydrated);
248        assert!(falcon512::verify(msg2, &sig2, &pk), "Rehydrated Falcon signing key should produce valid signatures");
249
250        // Wrong message should not verify
251        assert!(!falcon512::verify(b"wrong message", &sig, &pk), "Falcon should reject wrong message");
252
253        Ok(())
254    }
255
256    #[tokio::test]
257    async fn test_dilithium_sign_and_verify() -> anyhow::Result<()> {
258        use ml_dsa::MlDsa44;
259        use ml_dsa::signature::{Signer, Verifier};
260
261        let mut seed = [0u8; 32];
262        tools::random_fill_bytes(&mut seed);
263        let dilithium_seed = blake3::derive_key("hashiverse-pk-dilithium", &seed);
264
265        // Generate key pair — same derivation as Keys::from_seed
266        let signing_key = SigningKey::<MlDsa44>::from_seed(&dilithium_seed.into());
267
268        let msg = b"hello hashiverse";
269        let sig = signing_key.sign(msg);
270
271        // Verify with original verifying key
272        assert!(signing_key.verifying_key().verify(msg, &sig).is_ok(), "Dilithium signature should verify");
273
274        // Rehydrated: regenerate from the same seed (the seed is the Dilithium private key)
275        let kp_rehydrated = SigningKey::<MlDsa44>::from_seed(&dilithium_seed.into());
276        let vk_encoded = signing_key.verifying_key().encode();
277        let vk_rehydrated_encoded = kp_rehydrated.verifying_key().encode();
278        assert_eq!(vk_encoded, vk_rehydrated_encoded, "Dilithium keys must be identical for the same seed");
279        assert!(kp_rehydrated.verifying_key().verify(msg, &sig).is_ok(), "Rehydrated Dilithium verifying key should verify the same signature");
280
281        // Wrong message should fail
282        assert!(signing_key.verifying_key().verify(b"wrong message", &sig).is_err(), "Dilithium should reject wrong message");
283
284        Ok(())
285    }
286
287    #[tokio::test]
288    async fn test_pq_commitment_matches_key() -> anyhow::Result<()> {
289        use falcon_rust::falcon512;
290        use ml_dsa::MlDsa44;
291
292        let seed = [123u8; 32];
293        let keys = Keys::from_seed(&seed, false)?;
294
295        // Independently compute expected Falcon commitment
296        let expected_falcon: [u8; 16] = {
297            let falcon_seed: [u8; 32] = blake3::derive_key("hashiverse-pk-falcon", &seed);
298            let (_, pk) = falcon512::keygen(falcon_seed);
299            let vrfy_key = pk.to_bytes();
300            let hash = blake3::hash(&vrfy_key);
301            hash.as_bytes()[..16].try_into()?
302        };
303
304        // Independently compute expected Dilithium commitment
305        let expected_dilithium: [u8; 16] = {
306            let dilithium_seed = blake3::derive_key("hashiverse-pk-dilithium", &seed);
307            let kp = SigningKey::<MlDsa44>::from_seed(&dilithium_seed.into());
308            let vk_bytes = kp.verifying_key().encode();
309            let hash = blake3::hash(vk_bytes.as_ref());
310            hash.as_bytes()[..16].try_into()?
311        };
312
313        let expected: Vec<u8> = [expected_falcon, expected_dilithium].concat();
314        assert_eq!(keys.pq_commitment_bytes.as_ref(), expected.as_slice(), "pq_commitment_bytes must match independently computed PQ commitments");
315
316        Ok(())
317    }
318
319    // ------------------------------------------------------------------------------------------
320    // Regression vectors: guard against a dependency bump (ed25519-dalek, argon2, chacha20poly1305,
321    // falcon-rust, ml-dsa, blake3) silently changing derived identities or breaking persisted keys.
322    // Captured against the crate versions in Cargo.lock at the time of writing.  If an *intentional*
323    // change alters them, that is a protocol-level identity break — do not just regenerate.
324    // ------------------------------------------------------------------------------------------
325
326    const REGRESSION_SEED: [u8; 32] = [7u8; 32];
327    const REGRESSION_SIGNATURE_KEY_HEX: &str = "5e2620e5ef4b81d5f32d34482c7ba0bdd2168aa19927f1faa4962eeff361b99c";
328    const REGRESSION_VERIFICATION_KEY_HEX: &str = "277931d3eee9562cb808fe237a07e189fcda5eb05aeadf52e26533558339b8ba";
329    const REGRESSION_PQ_COMMITMENT_HEX: &str = "25b30ae387a506732efab27ee3845d92904145fc863edb7dc34be7bf13c801a3";
330    const REGRESSION_MESSAGE: &[u8] = b"hashiverse regression message";
331
332    #[test]
333    fn keys_from_seed_regression_vector() -> anyhow::Result<()> {
334        let keys = Keys::from_seed(&REGRESSION_SEED, false)?;
335        assert_eq!(hex::encode(keys.signature_key.as_ref()), REGRESSION_SIGNATURE_KEY_HEX, "ed25519 signature key drift");
336        assert_eq!(hex::encode(keys.verification_key.as_ref()), REGRESSION_VERIFICATION_KEY_HEX, "ed25519 verification key drift");
337        assert_eq!(hex::encode(keys.pq_commitment_bytes.as_ref()), REGRESSION_PQ_COMMITMENT_HEX, "pq commitment drift (falcon-rust or ml-dsa keygen output changed)");
338        Ok(())
339    }
340
341    #[test]
342    fn keys_from_phrase_regression_vector() -> anyhow::Result<()> {
343        let keys = Keys::from_phrase("correct horse battery staple")?;
344        assert_eq!(hex::encode(keys.verification_key.as_ref()), "86a7b26ecabf7db65b8f3fa2be529a7eb45985a8a2fc3d035b0e08f797253257", "phrase-derived identity drift (argon2 default params or output changed)");
345        Ok(())
346    }
347
348    /// A key blob persisted by the current code must remain loadable after dependency bumps.
349    #[test]
350    fn keys_from_persistence_regression_vector() -> anyhow::Result<()> {
351        let persistence = "ELTv4mnbtmhDRvUfZLqKhtUMvWMijc86P8PZ214rrdjOfxd2NBZMd4Ee9c53wJ3efxW4TE9jn8NSEj9fQR/xInRsSPThPUYkv7X6a5gjmFmvaOqVqA/O/hEoMJ4rwkPA26QXFDpIT2tCic37y7M9QHMLVTQMlpDJ7T2eiSIzA/wsDvoO0bQdtb/UBFICsA==";
352        let keys = Keys::from_persistence(&"hashiverse-passphrase".to_string(), persistence)?;
353        assert_eq!(hex::encode(keys.signature_key.as_ref()), REGRESSION_SIGNATURE_KEY_HEX);
354        assert_eq!(hex::encode(keys.verification_key.as_ref()), REGRESSION_VERIFICATION_KEY_HEX);
355        assert_eq!(hex::encode(keys.pq_commitment_bytes.as_ref()), REGRESSION_PQ_COMMITMENT_HEX);
356        assert!(Keys::from_persistence(&"wrong-passphrase".to_string(), persistence).is_err());
357        Ok(())
358    }
359
360    #[test]
361    fn falcon_regression_vector() -> anyhow::Result<()> {
362        use falcon_rust::falcon512;
363
364        let falcon_seed: [u8; 32] = blake3::derive_key("hashiverse-pk-falcon", &REGRESSION_SEED);
365        let (falcon_secret_key, falcon_public_key) = falcon512::keygen(falcon_seed);
366        assert_eq!(blake3::hash(&falcon_public_key.to_bytes()).to_hex().as_str(), "25b30ae387a506732efab27ee3845d9268c45aae814162e5d8d4f944dce9bbda", "falcon public key drift");
367        assert_eq!(blake3::hash(&falcon_secret_key.to_bytes()).to_hex().as_str(), "a68398a1b0ddf21ed4272859c890cc06fc6d5e67754c70ab730ef9ac70d1cb3a", "falcon secret key drift");
368
369        // Falcon signing is randomised, so check that a previously produced signature still decodes and verifies.
370        let falcon_signature_hex = "596518c44e776396daf86f2e3813a17a683f47063329881cf3a22e1bfc0408b5b8e4d7abe8cdc1ccb1b875e718a97d79b0f4f7a8ab432d35cc413ead54fb1ac83fe49b248cc6b295969f1a9128889328b26e2507bf9470db69bab79e390d36f99a90ecf8caa284f33b12882fe730923588b243dcf854275932c3be4d6bdaaa857ae5209ceff5d4c87b51b8560d121dc6d2e575feb890ca44bade76c790a7f39aec0cf4e75ea71885a992169dff1da94f50c5e4f645e1cbff6369c0a9be8a176b9599e8534cbd466bdfcdcdf1f9161cb2e9e81e2436a18f32c33ada213f7e4caa3530dbba90ca4d09a0d0d079bb290f7290a9f4d0a140c52768230f1b8638e9633b499c0689109f71d118831b0e686cba8adb3fce8dbb1bbd2d43386138d0f86b9580438b8d55a949bafb677facc0d83a9bfbe775f3491d4a849a88f274e99606f32d6f6f2c6a4bdc575fb6c5cbc2a8f5c65d558c0cf0e6562aebb4b4cb64c9a6a1bd38ec0b399e310bb1e381145c920d8e89ceea7a48b43853d7e678ace8e8c8b68193c6ccd5847610631d8bd1559c77154c5f7d2c723031b335c63dbc097a2abfd81ab3209ecd3489fb89b0da61d0b5bb20cee8b3f981d9e2df3fdaba217742325047891089df18e414841edd6afab8dcf5aa32637c78206e0b6ac7b577f8bec9e94fee50646bcf7fe211cfe21906509c9c4c64fbb1a985a3f501e93668a73316d4acf40a77aa58ff876f20581ecb72069c3edbb94300c9de792d86beafade5d01df8be362e84c73508d0f04a38d38cba98f63a6d299235d7ac464cb8ecde8e276ac92f9441b4e81b30e523c997d08acf5658de688734310184ec7ed3f5b2f5134db04427a66c4d496cdacbd07f3ff397b8c8410f9ecd53f34869aba22334ea5b4870e2ba341fc5997bf6f60864000000000000000000000000";
371        let falcon_signature = falcon512::Signature::from_bytes(&hex::decode(falcon_signature_hex)?).map_err(|e| anyhow::anyhow!("failed to decode falcon signature: {:?}", e))?;
372        assert!(falcon512::verify(REGRESSION_MESSAGE, &falcon_signature, &falcon_public_key), "stored falcon signature no longer verifies");
373        assert!(!falcon512::verify(b"wrong message", &falcon_signature, &falcon_public_key));
374        Ok(())
375    }
376
377    #[test]
378    fn dilithium_regression_vector() -> anyhow::Result<()> {
379        use ml_dsa::MlDsa44;
380        use ml_dsa::signature::{Signer, Verifier};
381
382        let dilithium_seed = blake3::derive_key("hashiverse-pk-dilithium", &REGRESSION_SEED);
383        let dilithium_signing_key = SigningKey::<MlDsa44>::from_seed(&dilithium_seed.into());
384        let dilithium_verifying_key = dilithium_signing_key.verifying_key();
385        assert_eq!(blake3::hash(dilithium_verifying_key.encode().as_ref()).to_hex().as_str(), "904145fc863edb7dc34be7bf13c801a3076c0304f90c36a2c309d531d915a759", "ml-dsa verifying key drift");
386
387        // ml-dsa's `Signer::sign` is the deterministic variant, so the signature itself is stable.
388        let dilithium_signature = dilithium_signing_key.sign(REGRESSION_MESSAGE);
389        assert_eq!(blake3::hash(dilithium_signature.encode().as_ref()).to_hex().as_str(), "23e107d5dc11d8b202108ff08673e175bb18aa0318d91701ca5ca66ccdef6e3e", "ml-dsa signature drift");
390        assert!(dilithium_verifying_key.verify(REGRESSION_MESSAGE, &dilithium_signature).is_ok());
391        Ok(())
392    }
393}