feat: Initial implementation of post quantum crypto + account_manager changes#668
feat: Initial implementation of post quantum crypto + account_manager changes#668ch4r10t33r wants to merge 11 commits intoReamLabs:masterfrom
Conversation
… to account_manager
There was a problem hiding this comment.
Few cosmetics reviews. I will dive deeper into the actual implementation. I think I need to spend a couple of hours of hash-sig library to fully understand what this PR deals with. (and luckily the documentation of hash-sig is good!, I guess they recently took an effort regarding it)
Also could you elaborate more about this PR in the description, so that others can quickly grasp the content?
| } | ||
| } | ||
|
|
||
| #[allow(deprecated)] |
There was a problem hiding this comment.
It seems those kind of changes is not needed.
There was a problem hiding this comment.
It seems like there are bunch of minor version updates which are not that related with this PR. I couldn't find any issues rn. But personally if we want to bump the dependency versions, I think this PR is not a right place to do so as it brings lots of dependency changes itself.
| pub struct SignedBlock { | ||
| pub data: Block, | ||
| pub signature: PQSignature, | ||
| // pub signature: PQSignature, |
There was a problem hiding this comment.
actually PQSignature is a stub struct that should be fixed after we finalized how we put signature in SignedBlock. I would suggest not changing consensus/lean crate, and remain the stub struct as well.
There was a problem hiding this comment.
I had to comment it because I ran into issues while testing. I no longer see the issue, I'll revert my changes.
There was a problem hiding this comment.
use pub _signature:PQSignature then or remove it, but commenting this out doesn't make sense
There was a problem hiding this comment.
This file isn't necessary anymore, seems like you have been working on the outdated codebase. So please remove it.
There was a problem hiding this comment.
This comment was marked as resolved, but the problem wasn't resolved
syjn99
left a comment
There was a problem hiding this comment.
Only checked the pqc crate! Left lots of discussion points.
| aes-gcm.workspace = true | ||
| anyhow.workspace = true | ||
| argon2.workspace = true | ||
| base64.workspace = true | ||
| hashsig.workspace = true | ||
| hex.workspace = true | ||
| hmac.workspace = true | ||
| rand.workspace = true | ||
| rand_chacha.workspace = true | ||
| ream-pqc = { path = "../crypto/pqc" } | ||
| serde = { version = "1.0", features = ["derive"] } | ||
| serde_json.workspace = true | ||
| sha2.workspace = true | ||
| thiserror = "2.0" | ||
| tracing.workspace = true |
There was a problem hiding this comment.
[dependencies]
aes-gcm.workspace = true
anyhow.workspace = true
argon2.workspace = true
base64.workspace = true
hashsig.workspace = true
hex.workspace = true
hmac.workspace = true
rand.workspace = true
serde.workspace = true
serde_json.workspace = true
sha2.workspace = true
thiserror.workspace = true
tracing.workspace = true
# ream dependencies
ream-pqc.workspace = trueI think above code is more consistent with our current codebase.
There was a problem hiding this comment.
It seems the filename keystore.rs isn't intuitive, as it mostly handles the signature scheme itself.
| #[derive(Serialize, Deserialize, Clone)] | ||
| pub struct KeyPair { | ||
| pub public_key: Vec<u8>, | ||
| pub secret_key: Vec<u8>, | ||
| pub lifetime: u32, | ||
| } |
There was a problem hiding this comment.
What's the benefit to define as a byte array for both public_key and secret_key, provided GeneralizedXMSSPublicKey and GeneralizedXMSSSecretKey are already serializable? Same comment for XmssSignature.signature.
There was a problem hiding this comment.
We don't have multiple backends for the hash-based signature (at this point), so I don't think this is justified.
| pub signature_index: u32, // TODO: Implement proper signature index tracking | ||
| } | ||
|
|
||
| pub struct XmssWrapper; |
There was a problem hiding this comment.
I think this struct can be improved much better. Here's what I experimented with my local environment:
Matching by height isn't scalable, and also can't handle different signature schemes. There are so many schemes in hash-sig with different 1) incomparable encodings (e.g., Winternitz, Target Sum, Top-level), 2) chunk size and 3) lifetime.
Fortunately, I found SignatureScheme trait in hash-sig crate, which will make our lives easier. Let me call the new struct XmssWrapper2:
pub struct XmssWrapper2<T: SignatureScheme> {
_phantom: std::marker::PhantomData<T>,
}Then we can implement generate_keys with this trait, and note that if we don't use a byte array for PublicKey, most of the code below can also be removed. (Please refer to the previous comment that I left)
impl<T> XmssWrapper2<T>
where
T: SignatureScheme,
T::PublicKey: Serialize + for<'de> Deserialize<'de>,
T::SecretKey: Serialize + for<'de> Deserialize<'de>,
T::Signature: Serialize + for<'de> Deserialize<'de>,
{
/// Generate XMSS key pair (serialized)
pub fn generate_keys<R: Rng>(rng: &mut R) -> Result<KeyPair, XmssWrapperError> {
let (public_key, secret_key) = T::key_gen(rng, 0, T::LIFETIME as usize);
let public_key_bytes = serde_json::to_vec(&public_key).map_err(|e| {
XmssWrapperError::Serialization(format!("Failed to serialize public key: {:?}", e))
})?;
let secret_key_bytes = serde_json::to_vec(&secret_key).map_err(|e| {
XmssWrapperError::Serialization(format!("Failed to serialize secret key: {:?}", e))
})?;
Ok(KeyPair {
public_key: public_key_bytes,
secret_key: secret_key_bytes,
lifetime: (T::LIFETIME as f64).log2() as u32,
})
}
}And this can be tested like:
#[test]
fn test_key_generation2() {
let mut rng = rng();
type XmssWrapper8 = XmssWrapper2<custom_lifetime_8::CustomSIGWinternitzLifetime8W8>;
let result = XmssWrapper8::generate_keys(&mut rng);
assert!(result.is_ok());
let key_pair = result.unwrap();
assert_eq!(key_pair.lifetime, 8);
assert!(!key_pair.public_key.is_empty());
assert!(!key_pair.secret_key.is_empty());
}From this approach, I expect we can shrink the LOC of this file upto 40%.
There was a problem hiding this comment.
Also in my knowledge, we won't use Winternitz as our primary incomparable encoding scheme.
Reference: https://eprint.iacr.org/2025/1332.pdf
| // Convert message to 32-byte array as required by SignatureScheme | ||
| let mut message_array = [0u8; 32]; | ||
| let message_len = message.len().min(32); | ||
| message_array[..message_len].copy_from_slice(&message[..message_len]); |
There was a problem hiding this comment.
We shouldn't sign only part of the given message. Is this code intended to get hash value of the message, as an argument?
There was a problem hiding this comment.
message: &[u8; MESSAGE_LENGTH]
^ This can be much better for parameter definition.
|
|
||
| #[allow(clippy::unnecessary_mut_passed)] | ||
| let signature = | ||
| SIGWinternitzLifetime18W8::sign(rng, &mut secret_key, epoch, &message_array) |
There was a problem hiding this comment.
| SIGWinternitzLifetime18W8::sign(rng, &mut secret_key, epoch, &message_array) | |
| SIGWinternitzLifetime18W8::sign(rng, &secret_key, epoch, &message_array) |
It is enough to pass only reference, not mutable reference. It also means that secret_key_bytes is never updated.
| assert!(!key_pair.public_key.is_empty()); | ||
| assert!(!key_pair.secret_key.is_empty()); | ||
|
|
||
| // Test with tree height 8 (256 signatures) |
There was a problem hiding this comment.
Why do we have copy-pasted assertion for each tests?
| ssz_types = { git = "https://github.com/ReamLabs/ssz_types", branch = "removable-variable-list" } | ||
| tempdir = "0.3.7" | ||
| tempfile = "3.19" | ||
| tempfile = "=3.19" |
There was a problem hiding this comment.
just for my own learning, what required this pinning?
There was a problem hiding this comment.
The latest version of tempfile has deprecated several functions, which is causing our make pr command to fail.
| // Save keys | ||
| save_secret_key(&key_pair, password)?; | ||
|
|
||
| println!("Poseidon2-XMSS key pair generated and saved successfully!"); |
| let encrypted_json = serde_json::to_string_pretty(&encrypted_secret) | ||
| .map_err(|e| AccountManagerError::Serialization(e.to_string()))?; | ||
|
|
||
| let mut secret_file = File::create("xmss_secret_key.json")?; |
There was a problem hiding this comment.
Can we pass in the path as a function argument rather than hardcoding the file name here? Also applies to public key
There was a problem hiding this comment.
Yes, this entire crate has to be refactored. I'll be addressing all of them in the new PR. The recent discussions in post-quantum group will require further changes in our code base.
| fn save_secret_key(key_pair: &KeyPair, password: &str) -> Result<(), AccountManagerError> { | ||
| // Save public key file (unencrypted) | ||
| let public_key_data = serde_json::json!({ | ||
| "public_key": hex::encode(&key_pair.public_key), | ||
| "tree_height": key_pair.lifetime, | ||
| "max_signatures": 1u64 << key_pair.lifetime, | ||
| "key_type": "XMSS_PUBLIC", | ||
| "hash_function": "Poseidon2" | ||
| }); | ||
|
|
||
| let public_json_string = serde_json::to_string_pretty(&public_key_data) | ||
| .map_err(|e| AccountManagerError::Serialization(e.to_string()))?; | ||
|
|
||
| let mut public_file = File::create("xmss_public_key.json")?; | ||
| public_file.write_all(public_json_string.as_bytes())?; | ||
|
|
||
| // Encrypt and save secret key file | ||
| save_encrypted_secret_key(key_pair, password)?; | ||
|
|
||
| Ok(()) | ||
| } |
There was a problem hiding this comment.
Instead of having save_secret_key() that saves the public key and calls save_encrypted_secret_key(), can we instead put the public key saving into save_public_key().
Then call save_public_key() and save_encrypted_secret_key() from generate_and_save_keys()?
There was a problem hiding this comment.
Yes, I had them as separate functions for me to test out the serialisation. I'll be moving this over to generate_and_save_keys() with a pre-agreed path.
| secret_key_path: &Path, | ||
| password: &str, |
There was a problem hiding this comment.
I think it'll be quite impractical to recover the secret key from file each time we want to sign a message.
Can we wrap this into, say, an AccountManager struct that can load a secret key from file and keep the decrypted key in memory in the struct? then we call account_manager.sign_message(...) to sign a message?
There was a problem hiding this comment.
Overall this code feels unstructured. I agree most of these functions should be in structures organized by what makes sense.
|
|
||
| // Sign the message | ||
| let (signature, updated_secret_key_bytes) = | ||
| XmssWrapper::sign_message(message, &secret_key_bytes, tree_height, &mut rng, 0) |
There was a problem hiding this comment.
Umm don't we need to keep the epoch argument (i.e. fixed to 0 here) inputtable?
There was a problem hiding this comment.
Please ignore the acocunt_manager, this code is incomplete.
| .map_err(|e| AccountManagerError::Serialization(e.to_string()))?; | ||
|
|
||
| // Update the secret key file with the new state | ||
| let updated_keypair = KeyPair { |
There was a problem hiding this comment.
Does the keypair change after each signing? I thought the secret_key and lifetime doesn't change per signing.
Also by setting public_key: vec![] this is a destructive operation that removes public key from the keystore file.
| let public_key_bytes = load_public_key(public_key_path)?; | ||
|
|
||
| // Verify the signature | ||
| let is_valid = XmssWrapper::verify_signature(message, signature, &public_key_bytes, 0) |
There was a problem hiding this comment.
Similar comment to signing, doesn't the epoch need to be an input?
| fn get_tree_height_from_file<P: AsRef<Path>>(path: P) -> Result<u32, AccountManagerError> { | ||
| let mut file = File::open(path)?; | ||
| let mut content = String::new(); | ||
| file.read_to_string(&mut content)?; | ||
|
|
||
| let encrypted_secret: EncryptedSecretKey = serde_json::from_str(&content) | ||
| .map_err(|e| AccountManagerError::Serialization(e.to_string()))?; | ||
|
|
||
| Ok(encrypted_secret.tree_height) |
There was a problem hiding this comment.
Do we need to load the file separately to get the tree height? Can we do this as part of load_secret_key()?
| } | ||
|
|
||
| /// Generate and save XMSS key pair with encryption | ||
| pub fn generate_and_save_keys(height: u32, password: &str) -> Result<(), AccountManagerError> { |
There was a problem hiding this comment.
@unnawut Yes, that would be better in terms of consistency
There was a problem hiding this comment.
He isn't using anyhow here.
We can do 2 things for errors
- anyhow
- this_error
context decides which we opt for
There was a problem hiding this comment.
I am concerned by the uses of #[allow and I think a lot of functions should in structures, unless they are general utility functions which doesn't seem to be the case for a lot of these.
I left a few comments which should apply to multiple parts of the PR
| hmac.workspace = true | ||
| rand.workspace = true | ||
| rand_chacha.workspace = true | ||
| ream-pqc = { path = "../crypto/pqc" } |
There was a problem hiding this comment.
defining path's should only be defined in the main Cargo.toml
| let phrase = mnemonic.phrase().to_string(); | ||
| // Generate a random 12-word mnemonic using the correct bip39 v2.0 API | ||
| let entropy = rand::random::<[u8; 16]>(); // 128 bits for 12 words | ||
| let mnemonic = Mnemonic::from_entropy_in(bip39::Language::English, &entropy).unwrap(); |
There was a problem hiding this comment.
unwrap shouldn't be used in production code, can this fail? under what conditions? depending on the case we should pass up an error or use expect instead of unwrap, it is probably safer to just pass up an error
|
|
||
| // Generate keys using the pqc crate | ||
| let key_pair = XmssWrapper::generate_keys(height, &mut rng) | ||
| .map_err(|e| AccountManagerError::Serialization(e.to_string()))?; |
There was a problem hiding this comment.
| .map_err(|e| AccountManagerError::Serialization(e.to_string()))?; | |
| .map_err(|err| AccountManagerError::Serialization(err.to_string()))?; | |
| ```} | |
| please make this change in the 22 other spots in the PR |
| } | ||
|
|
||
| /// Generate and save XMSS key pair with encryption | ||
| pub fn generate_and_save_keys(height: u32, password: &str) -> Result<(), AccountManagerError> { |
There was a problem hiding this comment.
He isn't using anyhow here.
We can do 2 things for errors
- anyhow
- this_error
context decides which we opt for
| height, | ||
| height, |
There was a problem hiding this comment.
these can be inlined, and as @unnawut state println!() shouldn't be used in production code
| yamux, | ||
| }; | ||
| use libp2p_identity::{Keypair, PeerId, secp256k1::PublicKey as Secp256k1PublicKey}; | ||
| #[allow(deprecated)] |
There was a problem hiding this comment.
We shouldn't be using #[allow(deprecated)]
Either we update the crate in a separate PR or fix it here in this PR
| secret_key_path: &Path, | ||
| password: &str, | ||
| ) -> Result<XmssSignature, AccountManagerError> { | ||
| use ream_pqc::keystore::XmssWrapper; |
| secret_key_path: &Path, | ||
| password: &str, |
There was a problem hiding this comment.
Overall this code feels unstructured. I agree most of these functions should be in structures organized by what makes sense.
| #[allow(unused_imports)] | ||
| use custom_lifetime_8::CustomSIGWinternitzLifetime8W8; |
There was a problem hiding this comment.
if this is unused, why is it here?
| pub public_key: Vec<u8>, | ||
| pub secret_key: Vec<u8>, |
There was a problem hiding this comment.
aren't these defined lengths? we shouldn't use vector's if the lengths are defined (are they?)
|
The work will be split into multiple tasks which will be shared by @shariqnaiyer and myself. Hence, closing this pull request. |
Initial implementation of post quantum crypto + account_manager changes