Skip to content

Comments

feat: Initial implementation of post quantum crypto + account_manager changes#668

Closed
ch4r10t33r wants to merge 11 commits intoReamLabs:masterfrom
ch4r10t33r:pq-devnet-0
Closed

feat: Initial implementation of post quantum crypto + account_manager changes#668
ch4r10t33r wants to merge 11 commits intoReamLabs:masterfrom
ch4r10t33r:pq-devnet-0

Conversation

@ch4r10t33r
Copy link
Contributor

@ch4r10t33r ch4r10t33r commented Jul 28, 2025

Initial implementation of post quantum crypto + account_manager changes

@ch4r10t33r ch4r10t33r marked this pull request as draft July 28, 2025 20:59
@ch4r10t33r ch4r10t33r linked an issue Jul 28, 2025 that may be closed by this pull request
@ch4r10t33r ch4r10t33r removed this from Ream Client Jul 28, 2025
@ch4r10t33r ch4r10t33r self-assigned this Jul 28, 2025
@ch4r10t33r ch4r10t33r marked this pull request as ready for review August 20, 2025 14:25
Copy link
Contributor

@syjn99 syjn99 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems those kind of changes is not needed.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had to comment it because I ran into issues while testing. I no longer see the issue, I'll revert my changes.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

use pub _signature:PQSignature then or remove it, but commenting this out doesn't make sense

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file isn't necessary anymore, seems like you have been working on the outdated codebase. So please remove it.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment was marked as resolved, but the problem wasn't resolved

Copy link
Contributor

@syjn99 syjn99 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only checked the pqc crate! Left lots of discussion points.

Comment on lines +13 to 26
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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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 = true

I think above code is more consistent with our current codebase.

Copy link
Contributor

@syjn99 syjn99 Aug 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems the filename keystore.rs isn't intuitive, as it mostly handles the signature scheme itself.

Comment on lines +86 to +91
#[derive(Serialize, Deserialize, Clone)]
pub struct KeyPair {
pub public_key: Vec<u8>,
pub secret_key: Vec<u8>,
pub lifetime: u32,
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;
Copy link
Contributor

@syjn99 syjn99 Aug 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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%.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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]);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We shouldn't sign only part of the given message. Is this code intended to get hash value of the message, as an argument?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just for my own learning, what required this pinning?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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!");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

use tracing's info! here

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")?;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we pass in the path as a function argument rather than hardcoding the file name here? Also applies to public key

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +78 to +98
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(())
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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()?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +296 to +297
secret_key_path: &Path,
password: &str,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Umm don't we need to keep the epoch argument (i.e. fixed to 0 here) inputtable?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 {
Copy link
Contributor

@unnawut unnawut Aug 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar comment to signing, doesn't the epoch need to be an input?

Comment on lines +345 to +353
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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Super nitpick and super unsure. I think we use anyhow::Result everywhere else instead of import it for disambiguity.

@KolbyML @syjn99 Can you help confirm?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@unnawut Yes, that would be better in terms of consistency

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

He isn't using anyhow here.

We can do 2 things for errors

  • anyhow
  • this_error

context decides which we opt for

Copy link
Contributor

@KolbyML KolbyML left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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" }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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()))?;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
.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> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

He isn't using anyhow here.

We can do 2 things for errors

  • anyhow
  • this_error

context decides which we opt for

Comment on lines +69 to +70
height,
height,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

import etc

Comment on lines +296 to +297
secret_key_path: &Path,
password: &str,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall this code feels unstructured. I agree most of these functions should be in structures organized by what makes sense.

Comment on lines +60 to +61
#[allow(unused_imports)]
use custom_lifetime_8::CustomSIGWinternitzLifetime8W8;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if this is unused, why is it here?

Comment on lines +88 to +89
pub public_key: Vec<u8>,
pub secret_key: Vec<u8>,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

aren't these defined lengths? we shouldn't use vector's if the lengths are defined (are they?)

@ch4r10t33r
Copy link
Contributor Author

The work will be split into multiple tasks which will be shared by @shariqnaiyer and myself. Hence, closing this pull request.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants