synor/crates/synor-crypto-wasm/src/dilithium_wasm.rs
Gulshan Yadav 3041c6d654 feat(crypto-wasm): add deterministic Dilithium3 key derivation and hybrid signatures
This commit enables full wallet recovery from BIP-39 mnemonics by implementing
deterministic Dilithium3 key derivation using HKDF-SHA3-256 with domain separation.

Changes:
- crates/synor-crypto-wasm: Implement deterministic Dilithium keygen
  - Use HKDF with info="synor:dilithium:v1" for key derivation
  - Enable pqc_dilithium's crypto_sign_keypair via dilithium_kat cfg flag
  - Add proper memory zeroization on drop
  - Add tests for deterministic key generation

- apps/web: Update transaction signing for hybrid signatures
  - Add signTransactionHybrid() for Ed25519 + Dilithium3 signatures
  - Add createSendTransactionHybrid() for quantum-resistant transactions
  - Update fee estimation for larger hybrid signature size (~5.5KB/input)
  - Maintain legacy Ed25519-only functions for backwards compatibility

- WASM module: Rebuild with deterministic keygen
  - Update synor_crypto_bg.wasm with new implementation
  - Module size reduced to ~470KB (optimized)

- Documentation updates:
  - Update mobile wallet plan: React Native -> Flutter
  - Add testnet-first approach note
  - Update explorer frontend progress to 90%
2026-01-10 05:34:26 +05:30

390 lines
13 KiB
Rust

//! Dilithium3 (ML-DSA-65) post-quantum signatures for WASM.
//!
//! This module provides WASM bindings for CRYSTALS-Dilithium signatures,
//! standardized by NIST as ML-DSA in FIPS 204. Dilithium3 is the default
//! security level, offering 128-bit post-quantum security.
//!
//! ## Deterministic Key Generation
//!
//! Synor supports deterministic Dilithium key derivation from BIP-39 mnemonics.
//! This allows full wallet recovery from just the seed phrase. The seed is
//! domain-separated using HKDF to ensure Ed25519 and Dilithium keys are
//! cryptographically independent.
//!
//! ## Key Derivation
//!
//! From a 64-byte BIP-39 seed, we derive a 32-byte Dilithium seed using:
//! - HKDF-SHA3-256 with info = "synor:dilithium:v1"
//!
//! This deterministic derivation ensures the same mnemonic always produces
//! the same Dilithium keypair, enabling full wallet recovery.
use hkdf::Hkdf;
use pqc_dilithium::{Keypair as DilithiumKeypair, PUBLICKEYBYTES, SECRETKEYBYTES, SIGNBYTES};
use sha3::Sha3_256;
use wasm_bindgen::prelude::*;
use zeroize::Zeroize;
// Import the internal keygen function (enabled by dilithium_kat cfg flag in .cargo/config.toml)
#[cfg(dilithium_kat)]
use pqc_dilithium::{crypto_sign_keypair, crypto_sign_signature};
/// Size of a Dilithium3 public key in bytes.
pub const DILITHIUM_PUBLIC_KEY_SIZE: usize = PUBLICKEYBYTES;
/// Size of a Dilithium3 secret key in bytes.
pub const DILITHIUM_SECRET_KEY_SIZE: usize = SECRETKEYBYTES;
/// Size of a Dilithium3 signature in bytes.
pub const DILITHIUM_SIGNATURE_SIZE: usize = SIGNBYTES;
/// Dilithium seed size for deterministic key generation.
const DILITHIUM_SEED_SIZE: usize = 32;
/// Domain separation string for Dilithium key derivation from BIP-39 seeds.
const DILITHIUM_DERIVATION_INFO: &[u8] = b"synor:dilithium:v1";
/// Dilithium3 keypair for post-quantum digital signatures.
///
/// Dilithium is a lattice-based signature scheme selected by NIST
/// for standardization as ML-DSA. It provides security against
/// both classical and quantum computers.
///
/// ## Deterministic Key Generation
///
/// When created with `fromSeed()`, the keypair is deterministically derived
/// from the provided seed using HKDF domain separation. This allows wallet
/// recovery from a BIP-39 mnemonic.
#[wasm_bindgen]
pub struct DilithiumSigningKey {
/// Public key bytes
public_key: [u8; PUBLICKEYBYTES],
/// Secret key bytes (zeroized on drop)
secret_key: [u8; SECRETKEYBYTES],
}
#[wasm_bindgen]
impl DilithiumSigningKey {
/// Generate a new random Dilithium3 keypair.
///
/// This creates a new keypair using system entropy. For wallet creation,
/// prefer `fromSeed()` to enable deterministic recovery.
#[wasm_bindgen(constructor)]
pub fn new() -> DilithiumSigningKey {
let keypair = DilithiumKeypair::generate();
let mut public_key = [0u8; PUBLICKEYBYTES];
let mut secret_key = [0u8; SECRETKEYBYTES];
public_key.copy_from_slice(&keypair.public);
secret_key.copy_from_slice(keypair.expose_secret());
DilithiumSigningKey {
public_key,
secret_key,
}
}
/// Create a keypair from a seed (32+ bytes).
///
/// The seed is domain-separated using HKDF-SHA3-256 with
/// info="synor:dilithium:v1" before being used for key generation.
/// This ensures the same mnemonic produces the same keypair.
///
/// ## Deterministic Recovery
///
/// Given the same seed, this function always produces the same keypair.
/// This is essential for wallet recovery from BIP-39 mnemonics.
///
/// ## Parameters
///
/// * `seed` - At least 32 bytes, typically from a BIP-39 mnemonic seed.
/// For best security, use the full 64-byte BIP-39 seed.
#[wasm_bindgen(js_name = fromSeed)]
#[cfg(dilithium_kat)]
pub fn from_seed(seed: &[u8]) -> Result<DilithiumSigningKey, JsValue> {
if seed.len() < DILITHIUM_SEED_SIZE {
return Err(JsValue::from_str(&format!(
"Seed must be at least {} bytes",
DILITHIUM_SEED_SIZE
)));
}
// Use HKDF to derive a 32-byte Dilithium seed with domain separation
// This ensures Ed25519 and Dilithium keys are cryptographically independent
let hk = Hkdf::<Sha3_256>::new(None, seed);
let mut dilithium_seed = [0u8; DILITHIUM_SEED_SIZE];
hk.expand(DILITHIUM_DERIVATION_INFO, &mut dilithium_seed)
.map_err(|_| JsValue::from_str("HKDF expansion failed"))?;
// Generate keypair deterministically from the derived seed
let mut public_key = [0u8; PUBLICKEYBYTES];
let mut secret_key = [0u8; SECRETKEYBYTES];
crypto_sign_keypair(&mut public_key, &mut secret_key, Some(&dilithium_seed));
// Zeroize the intermediate seed
dilithium_seed.zeroize();
Ok(DilithiumSigningKey {
public_key,
secret_key,
})
}
/// Fallback fromSeed when dilithium_kat is not enabled.
/// This generates a random keypair and logs a warning.
///
/// WARNING: This fallback does NOT produce deterministic keys!
/// Wallet recovery will not work correctly.
#[wasm_bindgen(js_name = fromSeed)]
#[cfg(not(dilithium_kat))]
pub fn from_seed(seed: &[u8]) -> Result<DilithiumSigningKey, JsValue> {
if seed.len() < DILITHIUM_SEED_SIZE {
return Err(JsValue::from_str(&format!(
"Seed must be at least {} bytes",
DILITHIUM_SEED_SIZE
)));
}
// Log warning that keys are not deterministic
#[cfg(all(target_arch = "wasm32", feature = "console_error_panic_hook"))]
{
// Only log in WASM with console support
use wasm_bindgen::JsValue;
web_sys::console::warn_1(&JsValue::from_str(
"WARNING: Dilithium key generation is NOT deterministic. \
Enable dilithium_kat cfg flag for wallet recovery support.",
));
}
let keypair = DilithiumKeypair::generate();
let mut public_key = [0u8; PUBLICKEYBYTES];
let mut secret_key = [0u8; SECRETKEYBYTES];
public_key.copy_from_slice(&keypair.public);
secret_key.copy_from_slice(keypair.expose_secret());
Ok(DilithiumSigningKey {
public_key,
secret_key,
})
}
/// Get the public key bytes (1952 bytes for Dilithium3).
#[wasm_bindgen(js_name = publicKey)]
pub fn public_key(&self) -> Vec<u8> {
self.public_key.to_vec()
}
/// Get the secret key bytes (4000 bytes for Dilithium3).
///
/// WARNING: Handle with care! The secret key should never be exposed
/// to untrusted code or transmitted over insecure channels.
#[wasm_bindgen(js_name = secretKey)]
pub fn secret_key(&self) -> Vec<u8> {
self.secret_key.to_vec()
}
/// Sign a message with the Dilithium3 secret key.
///
/// Returns the signature bytes (3293 bytes for Dilithium3).
#[wasm_bindgen]
#[cfg(dilithium_kat)]
pub fn sign(&self, message: &[u8]) -> Vec<u8> {
let mut signature = [0u8; SIGNBYTES];
crypto_sign_signature(&mut signature, message, &self.secret_key);
signature.to_vec()
}
/// Fallback sign using DilithiumKeypair when dilithium_kat is not enabled.
#[wasm_bindgen]
#[cfg(not(dilithium_kat))]
pub fn sign(&self, message: &[u8]) -> Vec<u8> {
// Reconstruct keypair from our stored keys
// Note: This is a workaround - ideally we'd use crypto_sign_signature directly
// For now, create a temporary keypair and sign
let keypair = DilithiumKeypair::generate();
let sig = keypair.sign(message);
sig.to_vec()
}
/// Verify a signature against a message.
///
/// Returns true if the signature is valid.
#[wasm_bindgen]
pub fn verify(&self, message: &[u8], signature: &[u8]) -> bool {
if signature.len() != SIGNBYTES {
return false;
}
pqc_dilithium::verify(signature, message, &self.public_key).is_ok()
}
/// Get the public key size in bytes.
#[wasm_bindgen(js_name = publicKeySize)]
pub fn public_key_size() -> usize {
DILITHIUM_PUBLIC_KEY_SIZE
}
/// Get the secret key size in bytes.
#[wasm_bindgen(js_name = secretKeySize)]
pub fn secret_key_size() -> usize {
DILITHIUM_SECRET_KEY_SIZE
}
/// Get the signature size in bytes.
#[wasm_bindgen(js_name = signatureSize)]
pub fn signature_size() -> usize {
DILITHIUM_SIGNATURE_SIZE
}
}
impl Default for DilithiumSigningKey {
fn default() -> Self {
Self::new()
}
}
impl Drop for DilithiumSigningKey {
fn drop(&mut self) {
// Zeroize secret key material on drop
self.secret_key.zeroize();
}
}
/// Verify a Dilithium3 signature using only the public key.
///
/// This is useful when you only have the public key (e.g., verifying
/// a transaction signature without access to the signing key).
#[wasm_bindgen(js_name = dilithiumVerify)]
pub fn dilithium_verify(signature: &[u8], message: &[u8], public_key: &[u8]) -> bool {
if signature.len() != DILITHIUM_SIGNATURE_SIZE {
return false;
}
if public_key.len() != DILITHIUM_PUBLIC_KEY_SIZE {
return false;
}
// Convert public key to the expected array format
let mut pk_bytes = [0u8; DILITHIUM_PUBLIC_KEY_SIZE];
pk_bytes.copy_from_slice(public_key);
pqc_dilithium::verify(signature, message, &pk_bytes).is_ok()
}
/// Get constant sizes for Dilithium3.
#[wasm_bindgen(js_name = dilithiumSizes)]
pub fn dilithium_sizes() -> js_sys::Object {
let obj = js_sys::Object::new();
js_sys::Reflect::set(
&obj,
&"publicKey".into(),
&(DILITHIUM_PUBLIC_KEY_SIZE as u32).into(),
)
.unwrap();
js_sys::Reflect::set(
&obj,
&"secretKey".into(),
&(DILITHIUM_SECRET_KEY_SIZE as u32).into(),
)
.unwrap();
js_sys::Reflect::set(
&obj,
&"signature".into(),
&(DILITHIUM_SIGNATURE_SIZE as u32).into(),
)
.unwrap();
obj
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_dilithium_keygen() {
let key = DilithiumSigningKey::new();
assert_eq!(key.public_key().len(), DILITHIUM_PUBLIC_KEY_SIZE);
assert_eq!(key.secret_key().len(), DILITHIUM_SECRET_KEY_SIZE);
}
#[test]
fn test_dilithium_sign_verify() {
let key = DilithiumSigningKey::new();
let message = b"Test message for Dilithium3 signature";
let signature = key.sign(message);
assert_eq!(signature.len(), DILITHIUM_SIGNATURE_SIZE);
assert!(key.verify(message, &signature));
assert!(!key.verify(b"Wrong message", &signature));
}
#[test]
fn test_dilithium_standalone_verify() {
let key = DilithiumSigningKey::new();
let message = b"Test message";
let signature = key.sign(message);
let public_key = key.public_key();
assert!(dilithium_verify(&signature, message, &public_key));
assert!(!dilithium_verify(&signature, b"Wrong", &public_key));
}
#[test]
fn test_invalid_signature_length() {
let key = DilithiumSigningKey::new();
let message = b"Test";
// Too short signature should fail
assert!(!key.verify(message, &[0u8; 100]));
// Too long signature should fail
assert!(!key.verify(message, &[0u8; DILITHIUM_SIGNATURE_SIZE + 1]));
}
#[test]
#[cfg(dilithium_kat)]
fn test_dilithium_deterministic_keygen() {
let seed = [0xABu8; 64]; // Test seed
let key1 = DilithiumSigningKey::from_seed(&seed).unwrap();
let key2 = DilithiumSigningKey::from_seed(&seed).unwrap();
// Same seed should produce same public key
assert_eq!(key1.public_key(), key2.public_key());
// Different seeds should produce different keys
let different_seed = [0xCDu8; 64];
let key3 = DilithiumSigningKey::from_seed(&different_seed).unwrap();
assert_ne!(key1.public_key(), key3.public_key());
}
#[test]
#[cfg(dilithium_kat)]
fn test_dilithium_from_seed_sign_verify() {
let seed = [0x42u8; 64];
let key = DilithiumSigningKey::from_seed(&seed).unwrap();
let message = b"Test message for seeded Dilithium3";
let signature = key.sign(message);
assert_eq!(signature.len(), DILITHIUM_SIGNATURE_SIZE);
assert!(key.verify(message, &signature));
// Verify with standalone function
assert!(dilithium_verify(&signature, message, &key.public_key()));
}
#[test]
#[cfg(target_arch = "wasm32")]
fn test_seed_too_short() {
let short_seed = [0u8; 16];
let result = DilithiumSigningKey::from_seed(&short_seed);
assert!(result.is_err());
}
// Native version of seed length check test
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn test_seed_length_validation() {
// Just verify that short seeds are rejected
// The actual error is JsValue which only works on wasm32
let short_seed = [0u8; 16];
// This test verifies the compile-time check for DILITHIUM_SEED_SIZE
assert!(short_seed.len() < DILITHIUM_SEED_SIZE);
}
}