Files
signer/tests/pq_conformance.rs
T

105 lines
3.4 KiB
Rust

//! Cross-implementation PQ keygen conformance.
//!
//! Validates the v2 seeded derivation against the pinned vectors from
//! nostr_quantum_preparation (`test/vectors/seed-to-pubkeys.v2.json`):
//! the same fixed mnemonic must produce the same PQ public keys in the
//! web app (noble) and here (RustCrypto crates).
//!
//! The test is skipped (passes with a note) when the vector file does not
//! exist yet — coordinate generation with the web app project
//! (`test/vectors/generate-vectors.mjs`).
use signer::pq_crypto;
const VECTOR_PATH: &str = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../nostr_quantum_preparation/test/vectors/seed-to-pubkeys.v2.json"
);
#[derive(serde::Deserialize)]
struct Vector {
mnemonic: String,
#[serde(rename = "derivedPublicKeys")]
derived_public_keys: DerivedPublicKeys,
}
#[derive(serde::Deserialize)]
struct DerivedPublicKeys {
#[serde(rename = "ml-dsa-65")]
ml_dsa_65: KeyEntry,
#[serde(rename = "slh-dsa-128s")]
slh_dsa_128s: KeyEntry,
#[serde(rename = "ml-kem-768")]
ml_kem_768: KeyEntry,
}
#[derive(serde::Deserialize)]
struct KeyEntry {
#[serde(rename = "derivationPath")]
derivation_path: String,
#[serde(rename = "publicKeyHex")]
public_key_hex: String,
}
/// Extract the base path (strip the trailing leaf index and any sibling
/// notation like "+ m/44'/102004'/0'/0'/1'").
fn base_path(full: &str) -> String {
full.split(" + ").next().unwrap_or(full).to_string()
}
#[test]
fn pq_pubkeys_match_web_app_v2_vectors() {
let data = match std::fs::read_to_string(VECTOR_PATH) {
Ok(d) => d,
Err(_) => {
eprintln!("SKIP: {VECTOR_PATH} not found — generate it in nostr_quantum_preparation");
return;
}
};
let vector: Vector = serde_json::from_str(&data)
.expect("valid v2 vector JSON");
// ML-DSA-65: 32-byte seed from one child.
let seed = pq_crypto::derive_pq_seed_from_path(
&vector.mnemonic,
&base_path(&vector.derived_public_keys.ml_dsa_65.derivation_path),
32,
)
.unwrap();
let seed_arr: [u8; 32] = seed.as_slice().try_into().unwrap();
let (_, pub_key) = pq_crypto::ml_dsa_65_keygen_from_seed(&seed_arr).unwrap();
assert_eq!(
hex::encode(&pub_key),
vector.derived_public_keys.ml_dsa_65.public_key_hex,
"ml-dsa-65 pubkey must match the web app vector"
);
// SLH-DSA-128s: 48-byte seed from two children (first 48 of 64).
let seed = pq_crypto::derive_pq_seed_from_path(
&vector.mnemonic,
&base_path(&vector.derived_public_keys.slh_dsa_128s.derivation_path),
48,
)
.unwrap();
let (_, pub_key) = pq_crypto::slh_dsa_128s_keygen_from_seed(&seed).unwrap();
assert_eq!(
hex::encode(&pub_key),
vector.derived_public_keys.slh_dsa_128s.public_key_hex,
"slh-dsa-128s pubkey must match the web app vector"
);
// ML-KEM-768: 64-byte seed from two children.
let seed = pq_crypto::derive_pq_seed_from_path(
&vector.mnemonic,
&base_path(&vector.derived_public_keys.ml_kem_768.derivation_path),
64,
)
.unwrap();
let (_, pub_key) = pq_crypto::ml_kem_768_keygen_from_seed(&seed).unwrap();
assert_eq!(
hex::encode(&pub_key),
vector.derived_public_keys.ml_kem_768.public_key_hex,
"ml-kem-768 pubkey must match the web app vector"
);
}