From feee199412ffc74a3ad647503e3400947bd5b931 Mon Sep 17 00:00:00 2001 From: thesimplekid Date: Wed, 20 May 2026 17:16:27 +0200 Subject: [PATCH 1/7] feat: bls --- Cargo.lock | 59 +++ Cargo.toml | 5 + crates/cashu/Cargo.toml | 5 + crates/cashu/src/dhke.rs | 349 +++++++++++++++++- crates/cashu/src/nuts/auth/nut22.rs | 7 +- crates/cashu/src/nuts/mod.rs | 2 +- crates/cashu/src/nuts/nut00/mod.rs | 83 ++++- crates/cashu/src/nuts/nut00/token.rs | 31 +- crates/cashu/src/nuts/nut01/bls.rs | 269 ++++++++++++++ crates/cashu/src/nuts/nut01/mod.rs | 23 ++ crates/cashu/src/nuts/nut01/public_key.rs | 222 ++++++----- crates/cashu/src/nuts/nut01/secret_key.rs | 176 ++++++--- crates/cashu/src/nuts/nut02.rs | 106 +++++- crates/cashu/src/nuts/nut11/mod.rs | 2 +- crates/cashu/src/nuts/nut12.rs | 46 ++- crates/cashu/src/nuts/nut13.rs | 73 +++- crates/cashu/src/nuts/nut28/mod.rs | 4 +- .../cdk-common/src/database/mint/test/mod.rs | 28 +- .../src/database/mint/test/proofs.rs | 50 ++- crates/cdk-common/src/error.rs | 4 +- crates/cdk-common/src/wallet/mod.rs | 12 +- crates/cdk-ffi/src/wallet.rs | 16 +- crates/cdk-ffi/src/wallet_trait.rs | 8 +- .../subcommands/rotate_next_keyset.rs | 8 +- .../cdk-mint-rpc/src/proto/cdk-mint-rpc.proto | 2 + crates/cdk-mint-rpc/src/proto/server.rs | 21 +- crates/cdk-mintd/README.md | 13 +- crates/cdk-mintd/example.config.toml | 6 +- crates/cdk-mintd/src/config.rs | 4 +- crates/cdk-mintd/src/env_vars/fake_wallet.rs | 2 +- crates/cdk-mintd/src/lib.rs | 17 +- crates/cdk-signatory/src/db_signatory.rs | 15 +- crates/cdk/src/mint/builder.rs | 44 ++- crates/cdk/src/mint/keysets/mod.rs | 26 +- crates/cdk/src/wallet/auth/auth_wallet.rs | 29 +- crates/cdk/src/wallet/blind_signature.rs | 28 +- crates/cdk/src/wallet/issue/saga/mod.rs | 14 +- crates/cdk/src/wallet/mod.rs | 32 +- crates/cdk/src/wallet/receive/saga/mod.rs | 20 +- crates/cdk/src/wallet/swap/saga/resume.rs | 7 +- crates/cdk/src/wallet/wallet_trait.rs | 9 +- 41 files changed, 1588 insertions(+), 289 deletions(-) create mode 100644 crates/cashu/src/nuts/nut01/bls.rs diff --git a/Cargo.lock b/Cargo.lock index e3c93b1cbb..f8bd2c205c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1002,6 +1002,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "block-buffer" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4" +dependencies = [ + "generic-array", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -1029,6 +1038,20 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bls12_381" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7bc6d6292be3a19e6379786dac800f551e5865a5bb51ebbe3064ab80433f403" +dependencies = [ + "digest 0.9.0", + "ff", + "group", + "pairing", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "bounded-vec-deque" version = "0.1.1" @@ -1153,8 +1176,11 @@ version = "0.17.0" dependencies = [ "bip39", "bitcoin 0.32.100", + "bls12_381", "cbor-diag", "ciborium", + "ff", + "group", "lightning", "lightning-invoice", "nostr-sdk", @@ -1162,6 +1188,8 @@ dependencies = [ "serde", "serde_json", "serde_with", + "sha2 0.10.9", + "sha2 0.9.9", "strum 0.27.2", "strum_macros 0.27.2", "thiserror 2.0.18", @@ -2661,6 +2689,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "digest" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" +dependencies = [ + "generic-array", +] + [[package]] name = "digest" version = "0.10.7" @@ -5192,6 +5229,15 @@ dependencies = [ "sha2 0.10.9", ] +[[package]] +name = "pairing" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81fec4625e73cf41ef4bb6846cafa6d44736525f442ba45e407c4a000a13996f" +dependencies = [ + "group", +] + [[package]] name = "parking" version = "2.2.1" @@ -6982,6 +7028,19 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "sha2" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800" +dependencies = [ + "block-buffer 0.9.0", + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.9.0", + "opaque-debug", +] + [[package]] name = "sha2" version = "0.10.9" diff --git a/Cargo.toml b/Cargo.toml index c8cc320f8a..2cc5104f6b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,6 +48,7 @@ anyhow = "1" async-trait = "0.1" axum = { version = "0.8.1", features = ["ws"] } bitcoin = { version = "0.32.2", features = ["base64", "serde", "rand", "rand-std"] } +bls12_381 = { version = "0.8", default-features = false, features = ["experimental", "pairings"] } bip39 = { version = "2.0", features = ["rand"] } jsonwebtoken = "9.2.0" cashu = { path = "./crates/cashu", default-features = false, version = "=0.17.0" } @@ -79,11 +80,15 @@ cbor-diag = "0.1.12" config = { version = "0.15.11", features = ["toml"] } criterion = "0.6.0" futures = { version = "0.3.28", default-features = false, features = ["async-await"] } +ff = { version = "0.13", default-features = false } +group = { version = "0.13", default-features = false } lightning-invoice = { version = "0.34.0", features = ["serde", "std"] } lightning = { version = "0.2.0", default-features = false, features = ["std"]} ldk-node = "0.7.0" serde = { version = "1", features = ["derive", "rc"] } serde_json = "1" +sha2 = { version = "0.10", default-features = false } +sha2_09 = { package = "sha2", version = "0.9", default-features = false } thiserror = { version = "2" } tokio = { version = "1", default-features = false, features = ["rt", "macros", "test-util", "sync", "time"] } tokio-util = { version = "0.7.11", default-features = false } diff --git a/crates/cashu/Cargo.toml b/crates/cashu/Cargo.toml index cd6766d1dc..9150c4b9ed 100644 --- a/crates/cashu/Cargo.toml +++ b/crates/cashu/Cargo.toml @@ -20,8 +20,11 @@ bench = [] [dependencies] uuid.workspace = true bitcoin.workspace = true +bls12_381.workspace = true cbor-diag.workspace = true ciborium.workspace = true +ff.workspace = true +group.workspace = true once_cell.workspace = true serde.workspace = true lightning-invoice.workspace = true @@ -31,6 +34,8 @@ tracing.workspace = true url.workspace = true serde_json.workspace = true serde_with.workspace = true +sha2.workspace = true +sha2_09.workspace = true strum.workspace = true strum_macros.workspace = true nostr-sdk = { workspace = true, optional = true } diff --git a/crates/cashu/src/dhke.rs b/crates/cashu/src/dhke.rs index 69f78cd853..e56c14be16 100644 --- a/crates/cashu/src/dhke.rs +++ b/crates/cashu/src/dhke.rs @@ -1,7 +1,5 @@ //! Diffie-Hellmann key exchange -use std::ops::Deref; - use bitcoin::hashes::sha256::Hash as Sha256Hash; use bitcoin::hashes::Hash; use bitcoin::secp256k1::{ @@ -9,7 +7,8 @@ use bitcoin::secp256k1::{ }; use thiserror::Error; -use crate::nuts::nut01::{PublicKey, SecretKey}; +use crate::nuts::nut01::{bls, PublicKey, SecretKey}; +use crate::nuts::nut02::KeySetVersion; use crate::nuts::nut12::ProofDleq; use crate::nuts::{BlindSignature, Keys, Proof, Proofs}; use crate::secret::Secret; @@ -30,6 +29,9 @@ pub enum Error { /// Secp256k1 error #[error(transparent)] Secp256k1(#[from] bitcoin::secp256k1::Error), + /// Key error + #[error(transparent)] + Key(#[from] crate::nuts::nut01::Error), // TODO: Remove use anyhow /// Custom Error #[error("`{0}`")] @@ -66,6 +68,17 @@ pub fn hash_to_curve(message: &[u8]) -> Result { Err(Error::NoValidPoint) } +/// Hash a proof secret to the curve used by a keyset version. +pub fn hash_to_curve_for_version( + message: &[u8], + version: KeySetVersion, +) -> Result { + match version { + KeySetVersion::Version00 | KeySetVersion::Version01 => hash_to_curve(message), + KeySetVersion::Version02 => Ok(bls::BlsG1PublicKey::hash_to_curve(message).into()), + } +} + /// Convert iterator of [`PublicKey`] to byte array pub fn hash_e(public_keys: I) -> [u8; 32] where @@ -88,9 +101,30 @@ pub fn blind_message( secret: &[u8], blinding_factor: Option, ) -> Result<(PublicKey, SecretKey), Error> { - let y: PublicKey = hash_to_curve(secret)?; - let r: SecretKey = blinding_factor.unwrap_or_else(SecretKey::generate); - Ok((y.combine(&r.public_key())?.into(), r)) + blind_message_for_version(secret, blinding_factor, KeySetVersion::Version00) +} + +/// Blind Message for a specific keyset version. +pub fn blind_message_for_version( + secret: &[u8], + blinding_factor: Option, + version: KeySetVersion, +) -> Result<(PublicKey, SecretKey), Error> { + match version { + KeySetVersion::Version00 | KeySetVersion::Version01 => { + let y: PublicKey = hash_to_curve(secret)?; + let r: SecretKey = blinding_factor.unwrap_or_else(SecretKey::generate); + Ok((y.combine(&r.public_key())?, r)) + } + KeySetVersion::Version02 => { + let r = match blinding_factor { + Some(r) => r, + None => SecretKey::generate_bls(), + }; + let b = bls::BlsG1PublicKey::hash_to_curve(secret).mul(r.as_bls()?); + Ok((b.into(), r)) + } + } } /// Unblind Message @@ -103,7 +137,7 @@ pub fn unblind_message( // K mint_pubkey: &PublicKey, ) -> Result { - let r: Scalar = Scalar::from(r.deref().to_owned()); + let r: Scalar = Scalar::from(*r.as_secp256k1()?); // a = r * K let a: PublicKey = mint_pubkey.mul_tweak(&SECP256K1, &r)?.into(); @@ -138,9 +172,27 @@ pub fn construct_proofs( .amount_key(blinded_signature.amount) .ok_or(Error::Custom("Could not get proofs".to_string()))?; - let unblinded_signature: PublicKey = unblind_message(&blinded_c, &r, &a)?; + let unblinded_signature: PublicKey = match blinded_signature.keyset_id.get_version() { + KeySetVersion::Version00 | KeySetVersion::Version01 => { + unblind_message(&blinded_c, &r, &a)? + } + KeySetVersion::Version02 => { + let c = blinded_c.as_bls_g1()?.mul(&r.as_bls()?.invert()?); + c.into() + } + }; - let dleq = blinded_signature.dleq.map(|d| ProofDleq::new(d.e, d.s, r)); + let dleq = match blinded_signature.keyset_id.get_version() { + KeySetVersion::Version00 | KeySetVersion::Version01 => { + blinded_signature.dleq.map(|d| ProofDleq::new(d.e, d.s, r)) + } + KeySetVersion::Version02 => { + if blinded_signature.dleq.is_some() { + return Err(Error::TokenNotVerified); + } + None + } + }; let proof = Proof { amount: blinded_signature.amount, @@ -165,8 +217,13 @@ pub fn construct_proofs( /// * `B_` is the blinded message #[inline] pub fn sign_message(k: &SecretKey, blinded_message: &PublicKey) -> Result { - let k: Scalar = Scalar::from(k.deref().to_owned()); - Ok(blinded_message.mul_tweak(&SECP256K1, &k)?.into()) + match k { + SecretKey::Secp256k1(inner) => { + let k: Scalar = Scalar::from(*inner); + Ok(blinded_message.mul_tweak(&SECP256K1, &k)?) + } + SecretKey::Bls(inner) => Ok(blinded_message.as_bls_g1()?.mul(inner).into()), + } } /// Verify Message @@ -179,9 +236,8 @@ pub fn verify_message( let y: PublicKey = hash_to_curve(msg)?; // Compute the expected unblinded message - let expected_unblinded_message: PublicKey = y - .mul_tweak(&Secp256k1::new(), &Scalar::from(*a.deref()))? - .into(); + let expected_unblinded_message: PublicKey = + y.mul_tweak(&Secp256k1::new(), &Scalar::from(*a.as_secp256k1()?))?; // Compare the unblinded_message with the expected value if unblinded_message == expected_unblinded_message { @@ -191,6 +247,61 @@ pub fn verify_message( Err(Error::TokenNotVerified) } +/// Verify BLS proof using pairings. +pub fn verify_bls_message( + mint_pubkey: PublicKey, + unblinded_message: PublicKey, + msg: &[u8], +) -> Result<(), Error> { + if bls::verify_pairing( + &unblinded_message.as_bls_g1()?, + msg, + &mint_pubkey.as_bls_g2()?, + ) { + return Ok(()); + } + + Err(Error::TokenNotVerified) +} + +/// Verify BLS proof using the mint secret key. +pub fn verify_bls_message_keyed( + mint_secretkey: &SecretKey, + unblinded_message: PublicKey, + msg: &[u8], +) -> Result<(), Error> { + let y = bls::BlsG1PublicKey::hash_to_curve(msg); + let expected = y.mul(mint_secretkey.as_bls()?); + + if unblinded_message.as_bls_g1()? == expected { + return Ok(()); + } + + Err(Error::TokenNotVerified) +} + +/// Batch verify BLS proofs using pairings. +pub fn batch_verify_bls_messages( + mint_pubkeys: &[PublicKey], + unblinded_messages: &[PublicKey], + messages: &[&[u8]], +) -> Result<(), Error> { + let mint_pubkeys = mint_pubkeys + .iter() + .map(PublicKey::as_bls_g2) + .collect::, _>>()?; + let unblinded_messages = unblinded_messages + .iter() + .map(PublicKey::as_bls_g1) + .collect::, _>>()?; + + if bls::batch_verify_pairing(&mint_pubkeys, &unblinded_messages, messages) { + return Ok(()); + } + + Err(Error::TokenNotVerified) +} + #[cfg(test)] mod tests { use std::str::FromStr; @@ -390,6 +501,216 @@ mod tests { assert!(verify_message(&bob_sec, unblinded, &message).is_ok()); } + #[test] + fn test_bls_full_dhke() { + use std::collections::BTreeMap; + + use crate::nuts::nut02::{Id, KeySetVersion}; + use crate::Amount; + + let message = b"test message"; + let r = SecretKey::bls_from_reduced_bytes(&[42u8; 32]); + let mint_secret = SecretKey::bls_from_reduced_bytes(&[7u8; 32]); + + let keyset_id = + Id::from_bytes(&[vec![KeySetVersion::Version02.to_byte()], vec![1; 32]].concat()) + .expect("valid v3 id"); + let (blinded, returned_r) = + blind_message_for_version(message, Some(r.clone()), KeySetVersion::Version02) + .expect("blind"); + assert_eq!(returned_r, r); + + let signed = sign_message(&mint_secret, &blinded).expect("sign"); + let mut keys = BTreeMap::new(); + keys.insert(Amount::from(1), mint_secret.public_key()); + let keys = Keys::new(keys); + let proof = construct_proofs( + vec![BlindSignature { + amount: Amount::from(1), + keyset_id, + c: signed, + dleq: None, + }], + vec![r], + vec![Secret::from_str("test message").expect("secret")], + &keys, + ) + .expect("proof") + .pop() + .expect("one proof"); + + verify_bls_message(mint_secret.public_key(), proof.c, proof.secret.as_bytes()) + .expect("valid pairing"); + assert!(verify_bls_message( + SecretKey::bls_from_reduced_bytes(&[8u8; 32]).public_key(), + proof.c, + proof.secret.as_bytes() + ) + .is_err()); + } + + #[test] + fn test_construct_proofs_rejects_v3_dleq() { + use std::collections::BTreeMap; + + use crate::nuts::nut02::{Id, KeySetVersion}; + use crate::nuts::nut12::BlindSignatureDleq; + use crate::Amount; + + let message = b"test message"; + let r = SecretKey::bls_from_reduced_bytes(&[42u8; 32]); + let mint_secret = SecretKey::bls_from_reduced_bytes(&[7u8; 32]); + let keyset_id = + Id::from_bytes(&[vec![KeySetVersion::Version02.to_byte()], vec![1; 32]].concat()) + .expect("valid v3 id"); + let (blinded, _) = + blind_message_for_version(message, Some(r.clone()), KeySetVersion::Version02) + .expect("blind"); + let signed = sign_message(&mint_secret, &blinded).expect("sign"); + + let mut keys = BTreeMap::new(); + keys.insert(Amount::from(1), mint_secret.public_key()); + let keys = Keys::new(keys); + + let result = construct_proofs( + vec![BlindSignature { + amount: Amount::from(1), + keyset_id, + c: signed, + dleq: Some(BlindSignatureDleq { + e: SecretKey::generate(), + s: SecretKey::generate(), + }), + }], + vec![r], + vec![Secret::from_str("test message").expect("secret")], + &keys, + ); + + assert!(matches!(result, Err(Error::TokenNotVerified))); + } + + #[test] + fn test_bls_hash_to_curve_nutshell_vectors() { + let y = bls::BlsG1PublicKey::hash_to_curve( + &hex::decode("0000000000000000000000000000000000000000000000000000000000000000") + .expect("hex"), + ); + assert_eq!( + PublicKey::from(y).to_hex(), + "a0687086dadc17db3c73fc63d58d61569ca32752a9b92c4e543692bc6b87b293fdcb4e9c870ab6e6d08127deb9382fb9" + ); + + let y = bls::BlsG1PublicKey::hash_to_curve( + &hex::decode("0000000000000000000000000000000000000000000000000000000000000001") + .expect("hex"), + ); + assert_eq!( + PublicKey::from(y).to_hex(), + "8dbdd24f1bc6f485fda14721cb1f15ba72ba34c05f89b5ca38c2a222c07158f471011d50a371cdb365da6bc7ef4139f4" + ); + } + + #[test] + fn test_bls_dhke_nutshell_steps() { + let secret_msg = b"test_message"; + let (blinded, r) = + blind_message_for_version(secret_msg, None, KeySetVersion::Version02).expect("blind"); + + let mint_secret = SecretKey::generate_bls(); + let blinded_signature = sign_message(&mint_secret, &blinded).expect("sign"); + let unblinded_signature = blinded_signature + .as_bls_g1() + .expect("bls g1") + .mul(&r.as_bls().expect("bls scalar").invert().expect("inverse")) + .into(); + + verify_bls_message_keyed(&mint_secret, unblinded_signature, secret_msg) + .expect("keyed verification"); + verify_bls_message(mint_secret.public_key(), unblinded_signature, secret_msg) + .expect("pairing verification"); + } + + #[test] + fn test_bls_batch_pairing_verification_nutshell() { + let secrets = [b"msg1".as_slice(), b"msg2".as_slice(), b"msg3".as_slice()]; + let mint_secret_1 = SecretKey::generate_bls(); + let mint_secret_2 = SecretKey::generate_bls(); + + let mut mint_pubkeys = Vec::new(); + let mut signatures = Vec::new(); + + for (secret, mint_secret) in [ + (secrets[0], &mint_secret_1), + (secrets[1], &mint_secret_1), + (secrets[2], &mint_secret_2), + ] { + let (blinded, r) = + blind_message_for_version(secret, None, KeySetVersion::Version02).expect("blind"); + let blinded_signature = sign_message(mint_secret, &blinded).expect("sign"); + let signature = blinded_signature + .as_bls_g1() + .expect("bls g1") + .mul(&r.as_bls().expect("bls scalar").invert().expect("inverse")) + .into(); + mint_pubkeys.push(mint_secret.public_key()); + signatures.push(signature); + } + + batch_verify_bls_messages(&mint_pubkeys, &signatures, &secrets) + .expect("valid batch pairing"); + + signatures[0] = signatures[1]; + assert!(batch_verify_bls_messages(&mint_pubkeys, &signatures, &secrets).is_err()); + } + + #[test] + fn test_deterministic_bls_steps_nutshell_vectors() { + let secret_msg = b"test_message"; + let r = SecretKey::bls_from_reduced_bytes( + &hex::decode("0000000000000000000000000000000000000000000000000000000000000003") + .expect("hex") + .try_into() + .expect("32 bytes"), + ); + let mint_secret = SecretKey::bls_from_reduced_bytes( + &hex::decode("0000000000000000000000000000000000000000000000000000000000000002") + .expect("hex") + .try_into() + .expect("32 bytes"), + ); + + let (blinded, returned_r) = + blind_message_for_version(secret_msg, Some(r.clone()), KeySetVersion::Version02) + .expect("blind"); + assert_eq!(returned_r.to_secret_hex(), r.to_secret_hex()); + + let blinded_signature = sign_message(&mint_secret, &blinded).expect("sign"); + let unblinded_signature = blinded_signature + .as_bls_g1() + .expect("bls g1") + .mul(&r.as_bls().expect("bls scalar").invert().expect("inverse")) + .into(); + + verify_bls_message_keyed(&mint_secret, unblinded_signature, secret_msg) + .expect("keyed verification"); + verify_bls_message(mint_secret.public_key(), unblinded_signature, secret_msg) + .expect("pairing verification"); + + assert_eq!( + blinded.to_hex(), + "8e88c5f6a93f653784a66b033a00e52128499e18b095c2a56f080d1c2a937ffc9ef4600804a48d087bbd1f662f6b068f" + ); + assert_eq!( + blinded_signature.to_hex(), + "8d52d7a6cbe5e99858d5c15c092d11a0c387c78917471211082a6e5afc2a79680dfa188fafe5d4a51c5398ce160e7a16" + ); + assert_eq!( + unblinded_signature.to_hex(), + "b7a4881059133fd91a8753600d9a5e524c65d6224f6fe2d5aef9e59f1507fdad90b3b4d48ee46da5c8dfaa0b88e28b69" + ); + } + /// Tests that `verify_message` correctly rejects verification when using an incorrect key. /// /// This test ensures that the verification process fails when attempting to verify diff --git a/crates/cashu/src/nuts/auth/nut22.rs b/crates/cashu/src/nuts/auth/nut22.rs index abbbdfa6ab..2ba7dafbac 100644 --- a/crates/cashu/src/nuts/auth/nut22.rs +++ b/crates/cashu/src/nuts/auth/nut22.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize}; use thiserror::Error; use super::nut21::ProtectedEndpoint; -use crate::dhke::hash_to_curve; +use crate::dhke::hash_to_curve_for_version; use crate::secret::Secret; use crate::util::hex; use crate::{BlindedMessage, Id, Proof, ProofDleq, PublicKey}; @@ -164,7 +164,10 @@ pub struct AuthProof { impl AuthProof { /// Y of AuthProof pub fn y(&self) -> Result { - Ok(hash_to_curve(self.secret.as_bytes())?) + Ok(hash_to_curve_for_version( + self.secret.as_bytes(), + self.keyset_id.get_version(), + )?) } } diff --git a/crates/cashu/src/nuts/mod.rs b/crates/cashu/src/nuts/mod.rs index 54a185ac03..0d02f6a97f 100644 --- a/crates/cashu/src/nuts/mod.rs +++ b/crates/cashu/src/nuts/mod.rs @@ -48,7 +48,7 @@ pub use nut00::{PreMint, PreMintSecrets}; pub use nut01::{Keys, KeysResponse, PublicKey, SecretKey}; #[cfg(feature = "mint")] pub use nut02::MintKeySet; -pub use nut02::{Id, KeySet, KeySetInfo, KeysetResponse}; +pub use nut02::{Id, KeySet, KeySetInfo, KeySetVersion, KeysetResponse}; #[cfg(feature = "wallet")] pub use nut03::PreSwap; pub use nut03::{SwapRequest, SwapResponse}; diff --git a/crates/cashu/src/nuts/nut00/mod.rs b/crates/cashu/src/nuts/nut00/mod.rs index f7cf45480f..f79e395519 100644 --- a/crates/cashu/src/nuts/nut00/mod.rs +++ b/crates/cashu/src/nuts/nut00/mod.rs @@ -23,8 +23,8 @@ use crate::amount::FeeAndAmounts; #[cfg(feature = "wallet")] use crate::amount::SplitTarget; #[cfg(feature = "wallet")] -use crate::dhke::blind_message; -use crate::dhke::hash_to_curve; +use crate::dhke::blind_message_for_version; +use crate::dhke::hash_to_curve_for_version; use crate::nuts::nut01::PublicKey; #[cfg(feature = "wallet")] use crate::nuts::nut01::SecretKey; @@ -407,9 +407,13 @@ impl Proof { /// Get y from proof /// - /// Where y is `hash_to_curve(secret)` + /// Where y is `hash_to_curve(secret)` for secp256k1 keysets and BLS G1 + /// hash-to-curve for v3 keysets. pub fn y(&self) -> Result { - Ok(hash_to_curve(self.secret.as_bytes())?) + Ok(hash_to_curve_for_version( + self.secret.as_bytes(), + self.keyset_id.get_version(), + )?) } } @@ -450,7 +454,7 @@ pub struct ProofV4 { #[serde(default, skip_serializing_if = "Option::is_none")] pub witness: Option, /// DLEQ Proof - #[serde(rename = "d")] + #[serde(rename = "d", default, skip_serializing_if = "Option::is_none")] pub dleq: Option, /// P2BK Ephemeral Public Key (NUT-28) #[serde(rename = "pe", default, skip_serializing_if = "Option::is_none")] @@ -592,6 +596,60 @@ where PublicKey::from_slice(&bytes).map_err(serde::de::Error::custom) } +#[cfg(test)] +mod proof_y_tests { + use super::*; + use crate::nuts::nut02::KeySetVersion; + + fn keyset_id(version: KeySetVersion) -> Id { + match version { + KeySetVersion::Version00 => { + Id::from_bytes(&[vec![version.to_byte()], vec![1; 7]].concat()) + } + KeySetVersion::Version01 | KeySetVersion::Version02 => { + Id::from_bytes(&[vec![version.to_byte()], vec![1; 32]].concat()) + } + } + .expect("valid keyset id") + } + + #[test] + fn proof_y_matches_keyset_version() { + let secret = Secret::from_str("test proof secret").expect("secret"); + let secp_c = crate::nuts::SecretKey::generate().public_key(); + let bls_c = crate::nuts::nut01::BlsG1PublicKey::hash_to_curve(b"signature").into(); + + let v0_y = Proof::new( + Amount::from(1), + keyset_id(KeySetVersion::Version00), + secret.clone(), + secp_c, + ) + .y() + .expect("v0 y"); + let v1_y = Proof::new( + Amount::from(1), + keyset_id(KeySetVersion::Version01), + secret.clone(), + secp_c, + ) + .y() + .expect("v1 y"); + let v2_y = Proof::new( + Amount::from(1), + keyset_id(KeySetVersion::Version02), + secret, + bls_c, + ) + .y() + .expect("v2 y"); + + assert!(matches!(v0_y, PublicKey::Secp256k1(_))); + assert!(matches!(v1_y, PublicKey::Secp256k1(_))); + assert!(matches!(v2_y, PublicKey::BlsG1(_))); + } +} + /// Currency Unit #[non_exhaustive] #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Default)] @@ -968,7 +1026,8 @@ impl PreMintSecrets { for amount in amount_split { let secret = Secret::generate(); - let (blinded, r) = blind_message(&secret.to_bytes(), None)?; + let (blinded, r) = + blind_message_for_version(&secret.to_bytes(), None, keyset_id.get_version())?; let blinded_message = BlindedMessage::new(amount, keyset_id, blinded); @@ -995,7 +1054,8 @@ impl PreMintSecrets { let mut output = Vec::with_capacity(secrets.len()); for (secret, amount) in secrets.into_iter().zip(amounts) { - let (blinded, r) = blind_message(&secret.to_bytes(), None)?; + let (blinded, r) = + blind_message_for_version(&secret.to_bytes(), None, keyset_id.get_version())?; let blinded_message = BlindedMessage::new(amount, keyset_id, blinded); @@ -1021,7 +1081,8 @@ impl PreMintSecrets { for _i in 0..count { let secret = Secret::generate(); - let (blinded, r) = blind_message(&secret.to_bytes(), None)?; + let (blinded, r) = + blind_message_for_version(&secret.to_bytes(), None, keyset_id.get_version())?; let blinded_message = BlindedMessage::new(Amount::ZERO, keyset_id, blinded); @@ -1106,7 +1167,8 @@ impl PreMintSecrets { let secret: crate::nuts::nut10::Secret = p2pk_conditions.into(); let secret: Secret = secret.try_into()?; - let (blinded, rs) = blind_message(&secret.to_bytes(), None)?; + let (blinded, rs) = + blind_message_for_version(&secret.to_bytes(), None, keyset_id.get_version())?; let blinded_message = BlindedMessage::new(amount, keyset_id, blinded); @@ -1140,7 +1202,8 @@ impl PreMintSecrets { let secret: nut10::Secret = conditions.clone().into(); let secret: Secret = secret.try_into()?; - let (blinded, r) = blind_message(&secret.to_bytes(), None)?; + let (blinded, r) = + blind_message_for_version(&secret.to_bytes(), None, keyset_id.get_version())?; let blinded_message = BlindedMessage::new(amount, keyset_id, blinded); diff --git a/crates/cashu/src/nuts/nut00/token.rs b/crates/cashu/src/nuts/nut00/token.rs index a449c94f2b..75f003dcec 100644 --- a/crates/cashu/src/nuts/nut00/token.rs +++ b/crates/cashu/src/nuts/nut00/token.rs @@ -631,6 +631,8 @@ mod tests { use super::*; use crate::dhke::hash_to_curve; use crate::mint_url::MintUrl; + use crate::nuts::nut01::BlsG1PublicKey; + use crate::nuts::nut02::KeySetVersion; use crate::nuts::nut10::{Conditions, SpendingConditions}; use crate::nuts::nut11::SigFlag; use crate::secret::Secret; @@ -708,10 +710,37 @@ mod tests { let token_v3 = TokenV3::from_str(token_v3_str).expect("TokenV3 should be created from string"); let token_v4 = TokenV4::try_from(token_v3).expect("TokenV3 should be converted to TokenV4"); - let token_v4_expected = "cashuBpGFtd2h0dHBzOi8vODMzMy5zcGFjZTozMzM4YXVjc2F0YWRqVGhhbmsgeW91LmF0gaJhaUgAmh8pMlPkHmFwgqRhYQJhc3hANDA3OTE1YmMyMTJiZTYxYTc3ZTNlNmQyYWViNGM3Mjc5ODBiZGE1MWNkMDZhNmFmYzI5ZTI4NjE3NjhhNzgzN2FjWCECvJCXmX2Br7LMc0a15DRak0a9KlBut5WFmKcvDPhRY-phZPakYWEIYXN4QGZlMTUxMDkzMTRlNjFkNzc1NmIwZjhlZTBmMjNhNjI0YWNhYTNmNGUwNDJmNjE0MzNjNzI4YzcwNTdiOTMxYmVhY1ghAp6OUFC4kKfWwJaNsWvB1dX6BA6h3ihPbsadYSmfZxBZYWT2"; + let token_v4_expected = "cashuBpGFtd2h0dHBzOi8vODMzMy5zcGFjZTozMzM4YXVjc2F0YWRqVGhhbmsgeW91LmF0gaJhaUgAmh8pMlPkHmFwgqNhYQJhc3hANDA3OTE1YmMyMTJiZTYxYTc3ZTNlNmQyYWViNGM3Mjc5ODBiZGE1MWNkMDZhNmFmYzI5ZTI4NjE3NjhhNzgzN2FjWCECvJCXmX2Br7LMc0a15DRak0a9KlBut5WFmKcvDPhRY-qjYWEIYXN4QGZlMTUxMDkzMTRlNjFkNzc1NmIwZjhlZTBmMjNhNjI0YWNhYTNmNGUwNDJmNjE0MzNjNzI4YzcwNTdiOTMxYmVhY1ghAp6OUFC4kKfWwJaNsWvB1dX6BA6h3ihPbsadYSmfZxBZ"; assert_eq!(token_v4.to_string(), token_v4_expected); } + #[test] + fn test_token_v4_omits_empty_dleq() { + let mint_url = MintUrl::from_str("https://example.com").unwrap(); + let keyset_id = + Id::from_bytes(&[vec![KeySetVersion::Version02.to_byte()], vec![1; 32]].concat()) + .expect("valid keyset id"); + let proof = Proof { + amount: Amount::from(1), + keyset_id, + secret: Secret::generate(), + c: BlsG1PublicKey::hash_to_curve(b"signature").into(), + witness: None, + dleq: None, + p2pk_e: None, + }; + + let token = Token::new( + mint_url, + vec![proof].into_iter().collect(), + None, + CurrencyUnit::Sat, + ); + let raw = token.to_raw_bytes().expect("token serializes"); + + assert!(!raw.windows(3).any(|window| window == [0x61, 0x64, 0xf6])); + } + #[test] fn test_token_str_round_trip() { let token_str = "cashuAeyJ0b2tlbiI6W3sibWludCI6Imh0dHBzOi8vODMzMy5zcGFjZTozMzM4IiwicHJvb2ZzIjpbeyJhbW91bnQiOjIsImlkIjoiMDA5YTFmMjkzMjUzZTQxZSIsInNlY3JldCI6IjQwNzkxNWJjMjEyYmU2MWE3N2UzZTZkMmFlYjRjNzI3OTgwYmRhNTFjZDA2YTZhZmMyOWUyODYxNzY4YTc4MzciLCJDIjoiMDJiYzkwOTc5OTdkODFhZmIyY2M3MzQ2YjVlNDM0NWE5MzQ2YmQyYTUwNmViNzk1ODU5OGE3MmYwY2Y4NTE2M2VhIn0seyJhbW91bnQiOjgsImlkIjoiMDA5YTFmMjkzMjUzZTQxZSIsInNlY3JldCI6ImZlMTUxMDkzMTRlNjFkNzc1NmIwZjhlZTBmMjNhNjI0YWNhYTNmNGUwNDJmNjE0MzNjNzI4YzcwNTdiOTMxYmUiLCJDIjoiMDI5ZThlNTA1MGI4OTBhN2Q2YzA5NjhkYjE2YmMxZDVkNWZhMDQwZWExZGUyODRmNmVjNjlkNjEyOTlmNjcxMDU5In1dfV0sInVuaXQiOiJzYXQiLCJtZW1vIjoiVGhhbmsgeW91LiJ9"; diff --git a/crates/cashu/src/nuts/nut01/bls.rs b/crates/cashu/src/nuts/nut01/bls.rs new file mode 100644 index 0000000000..c028618694 --- /dev/null +++ b/crates/cashu/src/nuts/nut01/bls.rs @@ -0,0 +1,269 @@ +use core::fmt; +use std::collections::BTreeMap; + +use bitcoin::hashes::sha256::Hash as Sha256Hash; +use bitcoin::hashes::Hash; +use bls12_381::hash_to_curve::{ExpandMsgXmd, HashToCurve}; +use bls12_381::{pairing, G1Affine, G1Projective, G2Affine, G2Projective, Gt, Scalar}; +use group::Curve; +use sha2_09::Sha256; + +use super::Error; + +const BLS_DST: &[u8] = b"CASHU_BLS12_381_G1_XMD:SHA-256_SSWU_RO_"; +const BLS_BATCH_DST: &[u8] = b"Cashu_BLS_Batch_v1"; + +/// BLS12-381 scalar/private key. +#[derive(Clone, PartialEq, Eq)] +pub struct BlsSecretKey { + scalar: Scalar, +} + +impl fmt::Debug for BlsSecretKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("BlsSecretKey(..)") + } +} + +impl BlsSecretKey { + /// Derive a scalar by reducing 32-byte input modulo the BLS12-381 scalar field. + pub fn from_reduced_bytes(bytes: &[u8; 32]) -> Self { + let mut wide = [0u8; 64]; + for (dst, src) in wide.iter_mut().zip(bytes.iter().rev()) { + *dst = *src; + } + Self { + scalar: Scalar::from_bytes_wide(&wide), + } + } + + /// Parse a canonical scalar from 32 big-endian bytes. + pub fn from_bytes(bytes: &[u8]) -> Result { + let mut bytes: [u8; 32] = bytes.try_into().map_err(|_| Error::InvalidSecretKeySize { + expected: 32, + found: bytes.len(), + })?; + bytes.reverse(); + let scalar = + Option::::from(Scalar::from_bytes(&bytes)).ok_or(Error::InvalidSecretKey)?; + Ok(Self { scalar }) + } + + /// Return canonical scalar bytes in big-endian order. + pub fn to_bytes(&self) -> [u8; 32] { + let mut bytes = self.scalar.to_bytes(); + bytes.reverse(); + bytes + } + + /// Return the scalar. + pub fn scalar(&self) -> Scalar { + self.scalar + } + + /// Return the multiplicative inverse. + pub fn invert(&self) -> Result { + let scalar = Option::::from(self.scalar.invert()).ok_or(Error::InvalidSecretKey)?; + Ok(Self { scalar }) + } + + /// Derive the mint public key in G2. + pub fn public_key_g2(&self) -> BlsG2PublicKey { + BlsG2PublicKey { + point: (G2Projective::generator() * self.scalar).to_affine(), + } + } +} + +impl Drop for BlsSecretKey { + fn drop(&mut self) { + self.scalar = Scalar::zero(); + } +} + +/// BLS12-381 G1 public key, used for blinded messages and signatures. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct BlsG1PublicKey { + point: G1Affine, +} + +impl BlsG1PublicKey { + /// Hash arbitrary bytes to G1 with the Cashu BLS DST. + pub fn hash_to_curve(message: &[u8]) -> Self { + Self { + point: >>::hash_to_curve( + message, BLS_DST, + ) + .to_affine(), + } + } + + /// Parse compressed G1 bytes. + pub fn from_bytes(bytes: &[u8]) -> Result { + let bytes: [u8; 48] = bytes.try_into().map_err(|_| Error::InvalidPublicKeySize { + expected: 48, + found: bytes.len(), + })?; + let point = Option::::from(G1Affine::from_compressed(&bytes)) + .ok_or(Error::InvalidPublicKey)?; + if bool::from(point.is_identity()) { + return Err(Error::InvalidPublicKey); + } + Ok(Self { point }) + } + + /// Return compressed G1 bytes. + pub fn to_bytes(&self) -> [u8; 48] { + self.point.to_compressed() + } + + /// Multiply this G1 point by a scalar. + pub fn mul(&self, scalar: &BlsSecretKey) -> Self { + Self { + point: (G1Projective::from(self.point) * scalar.scalar()).to_affine(), + } + } + + /// Return the affine point. + pub fn point(&self) -> G1Affine { + self.point + } +} + +/// BLS12-381 G2 public key, used for mint public keys. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct BlsG2PublicKey { + point: G2Affine, +} + +impl BlsG2PublicKey { + /// Parse compressed G2 bytes. + pub fn from_bytes(bytes: &[u8]) -> Result { + let bytes: [u8; 96] = bytes.try_into().map_err(|_| Error::InvalidPublicKeySize { + expected: 96, + found: bytes.len(), + })?; + let point = Option::::from(G2Affine::from_compressed(&bytes)) + .ok_or(Error::InvalidPublicKey)?; + if bool::from(point.is_identity()) { + return Err(Error::InvalidPublicKey); + } + Ok(Self { point }) + } + + /// Return compressed G2 bytes. + pub fn to_bytes(&self) -> [u8; 96] { + self.point.to_compressed() + } + + /// Return the affine point. + pub fn point(&self) -> G2Affine { + self.point + } +} + +/// Verify `e(signature, G2) == e(hash_to_curve(secret), mint_pubkey)`. +pub(crate) fn verify_pairing( + signature: &BlsG1PublicKey, + secret: &[u8], + mint_pubkey: &BlsG2PublicKey, +) -> bool { + let y = BlsG1PublicKey::hash_to_curve(secret); + pairing(&signature.point(), &G2Affine::generator()) == pairing(&y.point(), &mint_pubkey.point()) +} + +fn derive_batch_weights( + mint_pubkeys: &[BlsG2PublicKey], + signatures: &[BlsG1PublicKey], + messages: &[&[u8]], +) -> Vec { + let mut transcript = Vec::new(); + transcript.extend_from_slice(BLS_BATCH_DST); + for ((mint_pubkey, signature), message) in mint_pubkeys.iter().zip(signatures).zip(messages) { + transcript.extend_from_slice(&signature.to_bytes()); + transcript.extend_from_slice(&mint_pubkey.to_bytes()); + transcript.extend_from_slice(&(message.len() as u32).to_be_bytes()); + transcript.extend_from_slice(message); + } + + let challenge = Sha256Hash::hash(&transcript).to_byte_array(); + (0..mint_pubkeys.len()) + .map(|i| { + let mut counter = 0u8; + loop { + let mut weight_material = Vec::with_capacity(37); + weight_material.extend_from_slice(&challenge); + weight_material.extend_from_slice(&(i as u32).to_be_bytes()); + weight_material.push(counter); + let weight = Sha256Hash::hash(&weight_material).to_byte_array(); + let scalar = BlsSecretKey::from_reduced_bytes(&weight); + if scalar.scalar() != Scalar::zero() { + return scalar; + } + counter = counter + .checked_add(1) + .expect("BLS batch weight derivation failed"); + } + }) + .collect() +} + +/// Batch verify BLS proofs using deterministic transcript-derived weights. +pub(crate) fn batch_verify_pairing( + mint_pubkeys: &[BlsG2PublicKey], + signatures: &[BlsG1PublicKey], + messages: &[&[u8]], +) -> bool { + if mint_pubkeys.len() != signatures.len() || signatures.len() != messages.len() { + return false; + } + if signatures.is_empty() { + return true; + } + + let weights = derive_batch_weights(mint_pubkeys, signatures, messages); + let mut weighted_signatures = G1Projective::identity(); + let mut weighted_messages = BTreeMap::<[u8; 96], (BlsG2PublicKey, G1Projective)>::new(); + + for (((mint_pubkey, signature), message), weight) in mint_pubkeys + .iter() + .zip(signatures) + .zip(messages) + .zip(&weights) + { + weighted_signatures += G1Projective::from(signature.point()) * weight.scalar(); + let weighted_message = + G1Projective::from(BlsG1PublicKey::hash_to_curve(message).point()) * weight.scalar(); + weighted_messages + .entry(mint_pubkey.to_bytes()) + .and_modify(|(_, sum)| *sum += weighted_message) + .or_insert((*mint_pubkey, weighted_message)); + } + + let left = pairing(&weighted_signatures.to_affine(), &G2Affine::generator()); + let right = weighted_messages.into_values().fold( + Gt::identity(), + |acc, (mint_pubkey, weighted_message)| { + &acc + &pairing(&weighted_message.to_affine(), &mint_pubkey.point()) + }, + ); + + left == right +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_reject_g1_identity_point() { + let identity = G1Projective::identity().to_affine().to_compressed(); + assert!(BlsG1PublicKey::from_bytes(&identity).is_err()); + } + + #[test] + fn test_reject_g2_identity_point() { + let identity = G2Projective::identity().to_affine().to_compressed(); + assert!(BlsG2PublicKey::from_bytes(&identity).is_err()); + } +} diff --git a/crates/cashu/src/nuts/nut01/mod.rs b/crates/cashu/src/nuts/nut01/mod.rs index 7d5b0e3c58..6b8b64d73d 100644 --- a/crates/cashu/src/nuts/nut01/mod.rs +++ b/crates/cashu/src/nuts/nut01/mod.rs @@ -15,11 +15,14 @@ use thiserror::Error; mod public_key; mod secret_key; +pub use self::bls::{BlsG1PublicKey, BlsG2PublicKey, BlsSecretKey}; pub use self::public_key::PublicKey; pub use self::secret_key::SecretKey; use super::nut02::KeySet; use crate::amount::Amount; +pub(crate) mod bls; + /// Nut01 Error #[derive(Debug, Error)] pub enum Error { @@ -29,6 +32,9 @@ pub enum Error { /// Json Error #[error(transparent)] Json(#[from] serde_json::Error), + /// Hex Error + #[error(transparent)] + Hex(#[from] crate::util::hex::Error), /// Invalid Pubkey size #[error("Invalid public key size: expected={expected}, found={found}")] InvalidPublicKeySize { @@ -37,6 +43,23 @@ pub enum Error { /// Actual size found: usize, }, + /// Invalid secret key size + #[error("Invalid secret key size: expected={expected}, found={found}")] + InvalidSecretKeySize { + /// Expected size + expected: usize, + /// Actual size + found: usize, + }, + /// Invalid public key + #[error("Invalid public key")] + InvalidPublicKey, + /// Invalid secret key + #[error("Invalid secret key")] + InvalidSecretKey, + /// Wrong key kind for operation + #[error("Wrong key kind for operation")] + WrongKeyKind, } /// Mint public keys per amount. diff --git a/crates/cashu/src/nuts/nut01/public_key.rs b/crates/cashu/src/nuts/nut01/public_key.rs index 21a657b6c8..737c85afbb 100644 --- a/crates/cashu/src/nuts/nut01/public_key.rs +++ b/crates/cashu/src/nuts/nut01/public_key.rs @@ -1,20 +1,43 @@ use core::fmt; -use core::ops::Deref; +use core::hash::{Hash, Hasher}; use core::str::FromStr; use bitcoin::hashes::sha256::Hash as Sha256Hash; -use bitcoin::hashes::Hash; +use bitcoin::hashes::Hash as BitcoinHash; use bitcoin::secp256k1::schnorr::Signature; -use bitcoin::secp256k1::{self, Message, XOnlyPublicKey}; +use bitcoin::secp256k1::{self, Message, Scalar, Secp256k1, XOnlyPublicKey}; use serde::{Deserialize, Deserializer, Serialize}; -use super::Error; +use super::{BlsG1PublicKey, BlsG2PublicKey, Error}; use crate::SECP256K1; -/// PublicKey -#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct PublicKey { - inner: secp256k1::PublicKey, +/// Protocol public key or point. +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum PublicKey { + /// Secp256k1 compressed public key. + Secp256k1(secp256k1::PublicKey), + /// BLS12-381 G1 point, used for blinded messages and signatures. + BlsG1(BlsG1PublicKey), + /// BLS12-381 G2 point, used for mint public keys. + BlsG2(BlsG2PublicKey), +} + +impl Hash for PublicKey { + fn hash(&self, state: &mut H) { + self.to_bytes().hash(state); + } +} + +impl PartialOrd for PublicKey { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for PublicKey { + fn cmp(&self, other: &Self) -> core::cmp::Ordering { + self.to_bytes().cmp(&other.to_bytes()) + } } impl fmt::Debug for PublicKey { @@ -23,81 +46,152 @@ impl fmt::Debug for PublicKey { } } -impl Deref for PublicKey { - type Target = secp256k1::PublicKey; +impl From for PublicKey { + fn from(inner: secp256k1::PublicKey) -> Self { + Self::Secp256k1(inner) + } +} - fn deref(&self) -> &Self::Target { - &self.inner +impl From for PublicKey { + fn from(inner: BlsG1PublicKey) -> Self { + Self::BlsG1(inner) } } -impl From for PublicKey { - fn from(inner: secp256k1::PublicKey) -> Self { - Self { inner } +impl From for PublicKey { + fn from(inner: BlsG2PublicKey) -> Self { + Self::BlsG2(inner) } } impl PublicKey { - /// Parse from `bytes` + /// Parse from compressed bytes. #[inline] pub fn from_slice(slice: &[u8]) -> Result { - Ok(Self { - inner: secp256k1::PublicKey::from_slice(slice)?, - }) + match slice.len() { + 33 => Ok(Self::Secp256k1(secp256k1::PublicKey::from_slice(slice)?)), + 48 => Ok(Self::BlsG1(BlsG1PublicKey::from_bytes(slice)?)), + 96 => Ok(Self::BlsG2(BlsG2PublicKey::from_bytes(slice)?)), + found => Err(Error::InvalidPublicKeySize { + expected: 33, + found, + }), + } } - /// Parse from `hex` string + /// Parse from hex string. #[inline] pub fn from_hex(hex: S) -> Result where S: AsRef, { - let hex: &str = hex.as_ref(); - - // Check size - if hex.len() != 33 * 2 { - return Err(Error::InvalidPublicKeySize { - expected: 33, - found: hex.len() / 2, - }); - } - - Ok(Self { - inner: secp256k1::PublicKey::from_str(hex)?, - }) + let bytes = crate::util::hex::decode(hex.as_ref())?; + Self::from_slice(&bytes) } - /// [`PublicKey`] to bytes + /// Return compressed bytes. #[inline] - pub fn to_bytes(&self) -> [u8; 33] { - self.inner.serialize() + pub fn to_bytes(&self) -> Vec { + match self { + Self::Secp256k1(inner) => inner.serialize().to_vec(), + Self::BlsG1(inner) => inner.to_bytes().to_vec(), + Self::BlsG2(inner) => inner.to_bytes().to_vec(), + } } - /// To uncompressed bytes + /// Return uncompressed secp256k1 bytes. #[inline] pub fn to_uncompressed_bytes(&self) -> [u8; 65] { - self.inner.serialize_uncompressed() + match self { + Self::Secp256k1(inner) => inner.serialize_uncompressed(), + Self::BlsG1(_) | Self::BlsG2(_) => panic!("BLS keys do not have secp bytes"), + } } - /// To [`XOnlyPublicKey`] + /// Return secp256k1 x-only public key. #[inline] pub fn x_only_public_key(&self) -> XOnlyPublicKey { - self.inner.x_only_public_key().0 + match self { + Self::Secp256k1(inner) => inner.x_only_public_key().0, + Self::BlsG1(_) | Self::BlsG2(_) => panic!("BLS keys do not have x-only form"), + } } - /// Get public key as `hex` string + /// Return secp256k1 x-only public key and parity. + #[inline] + pub fn x_only_public_key_with_parity(&self) -> (XOnlyPublicKey, secp256k1::Parity) { + match self { + Self::Secp256k1(inner) => inner.x_only_public_key(), + Self::BlsG1(_) | Self::BlsG2(_) => panic!("BLS keys do not have x-only form"), + } + } + + /// Get public key as hex string. #[inline] pub fn to_hex(&self) -> String { - self.inner.to_string() + crate::util::hex::encode(self.to_bytes()) } - /// Verify schnorr signature + /// Verify schnorr signature. pub fn verify(&self, msg: &[u8], sig: &Signature) -> Result<(), Error> { - let hash: Sha256Hash = Sha256Hash::hash(msg); + let Self::Secp256k1(inner) = self else { + return Err(Error::WrongKeyKind); + }; + let hash: Sha256Hash = BitcoinHash::hash(msg); let msg = Message::from_digest_slice(hash.as_ref())?; - SECP256K1.verify_schnorr(sig, &msg, &self.inner.x_only_public_key().0)?; + SECP256K1.verify_schnorr(sig, &msg, &inner.x_only_public_key().0)?; Ok(()) } + + /// Add two secp256k1 public keys. + pub fn combine(&self, other: &Self) -> Result { + match (self, other) { + (Self::Secp256k1(a), Self::Secp256k1(b)) => Ok(a.combine(b)?.into()), + _ => Err(secp256k1::Error::InvalidPublicKey), + } + } + + /// Tweak-multiply a secp256k1 public key. + pub fn mul_tweak( + &self, + secp: &Secp256k1, + tweak: &Scalar, + ) -> Result + where + C: secp256k1::Verification, + { + match self { + Self::Secp256k1(inner) => Ok(inner.mul_tweak(secp, tweak)?.into()), + Self::BlsG1(_) | Self::BlsG2(_) => Err(secp256k1::Error::InvalidPublicKey), + } + } + + /// Negate a secp256k1 public key. + pub fn negate(&self, secp: &Secp256k1) -> Self + where + C: secp256k1::Verification, + { + match self { + Self::Secp256k1(inner) => inner.negate(secp).into(), + Self::BlsG1(_) | Self::BlsG2(_) => panic!("cannot negate BLS point with secp context"), + } + } + + /// Return the BLS G1 point. + pub fn as_bls_g1(&self) -> Result { + match self { + Self::BlsG1(point) => Ok(*point), + Self::Secp256k1(_) | Self::BlsG2(_) => Err(Error::WrongKeyKind), + } + } + + /// Return the BLS G2 point. + pub fn as_bls_g2(&self) -> Result { + match self { + Self::BlsG2(point) => Ok(*point), + Self::Secp256k1(_) | Self::BlsG1(_) => Err(Error::WrongKeyKind), + } + } } impl FromStr for PublicKey { @@ -132,41 +226,3 @@ impl<'de> Deserialize<'de> for PublicKey { Self::from_hex(public_key).map_err(serde::de::Error::custom) } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_public_key_from_hex() { - // Compressed - assert!(PublicKey::from_hex( - "02194603ffa36356f4a56b7df9371fc3192472351453ec7398b8da8117e7c3e104" - ) - .is_ok()); - } - - #[test] - fn test_invalid_public_key_from_hex() { - // Uncompressed (is valid but is cashu must be compressed?) - assert!(PublicKey::from_hex("04fd4ce5a16b65576145949e6f99f445f8249fee17c606b688b504a849cdc452de3625246cb2c27dac965cb7200a5986467eee92eb7d496bbf1453b074e223e481") - .is_err()) - } -} - -#[cfg(all(feature = "bench", test))] -mod benches { - extern crate test; - use test::{black_box, Bencher}; - - use super::*; - - const HEX: &str = "02194603ffa36356f4a56b7df9371fc3192472351453ec7398b8da8117e7c3e104"; - - #[bench] - pub fn public_key_from_hex(bh: &mut Bencher) { - bh.iter(|| { - black_box(PublicKey::from_hex(HEX)).unwrap(); - }); - } -} diff --git a/crates/cashu/src/nuts/nut01/secret_key.rs b/crates/cashu/src/nuts/nut01/secret_key.rs index 995e1e9f1d..cbc6020509 100644 --- a/crates/cashu/src/nuts/nut01/secret_key.rs +++ b/crates/cashu/src/nuts/nut01/secret_key.rs @@ -1,36 +1,37 @@ use core::fmt; -use core::ops::Deref; use core::str::FromStr; use bitcoin::hashes::sha256::Hash as Sha256Hash; use bitcoin::hashes::Hash; use bitcoin::secp256k1; use bitcoin::secp256k1::rand::rngs::OsRng; +use bitcoin::secp256k1::rand::RngCore; use bitcoin::secp256k1::schnorr::Signature; -use bitcoin::secp256k1::{Keypair, Message, Scalar}; +use bitcoin::secp256k1::{Keypair, Message, Scalar, Secp256k1, XOnlyPublicKey}; use serde::de::Visitor; use serde::{Deserialize, Deserializer, Serialize}; -use super::{Error, PublicKey}; +use super::{BlsSecretKey, Error, PublicKey}; use crate::SECP256K1; -/// SecretKey +/// Protocol secret key/scalar. #[derive(Debug, Clone, PartialEq, Eq)] -pub struct SecretKey { - inner: secp256k1::SecretKey, +pub enum SecretKey { + /// Secp256k1 secret key. + Secp256k1(secp256k1::SecretKey), + /// BLS12-381 scalar. + Bls(BlsSecretKey), } -impl Deref for SecretKey { - type Target = secp256k1::SecretKey; - - fn deref(&self) -> &Self::Target { - &self.inner +impl From for SecretKey { + fn from(inner: secp256k1::SecretKey) -> Self { + Self::Secp256k1(inner) } } -impl From for SecretKey { - fn from(inner: secp256k1::SecretKey) -> Self { - Self { inner } +impl From for SecretKey { + fn from(inner: BlsSecretKey) -> Self { + Self::Bls(inner) } } @@ -41,73 +42,158 @@ impl fmt::Display for SecretKey { } impl SecretKey { - /// Parse from `bytes` + /// Parse secp256k1 secret from `bytes`. pub fn from_slice(slice: &[u8]) -> Result { - Ok(Self { - inner: secp256k1::SecretKey::from_slice(slice)?, - }) + Ok(Self::Secp256k1(secp256k1::SecretKey::from_slice(slice)?)) } - /// Parse from `hex` string + /// Parse secp256k1 secret from `hex` string. pub fn from_hex(hex: S) -> Result where S: AsRef, { - Ok(Self { - inner: secp256k1::SecretKey::from_str(hex.as_ref())?, - }) + Ok(Self::Secp256k1(secp256k1::SecretKey::from_str( + hex.as_ref(), + )?)) } - /// Generate random secret key + /// Derive a BLS scalar by reducing 32-byte input. + pub fn bls_from_reduced_bytes(bytes: &[u8; 32]) -> Self { + Self::Bls(BlsSecretKey::from_reduced_bytes(bytes)) + } + + /// Parse BLS scalar from canonical bytes. + pub fn bls_from_slice(slice: &[u8]) -> Result { + Ok(Self::Bls(BlsSecretKey::from_bytes(slice)?)) + } + + /// Generate random secp256k1 secret key. pub fn generate() -> Self { let (secret_key, _) = SECP256K1.generate_keypair(&mut OsRng); - Self { inner: secret_key } + Self::Secp256k1(secret_key) } - /// Get secret key as `hex` string + /// Generate random BLS scalar. + pub fn generate_bls() -> Self { + let mut bytes = [0u8; 32]; + OsRng.fill_bytes(&mut bytes); + Self::bls_from_reduced_bytes(&bytes) + } + + /// Get secret key as `hex` string. pub fn to_secret_hex(&self) -> String { - self.inner.display_secret().to_string() + crate::util::hex::encode(self.to_secret_bytes()) } - /// Get secret key as `bytes` - pub fn as_secret_bytes(&self) -> &[u8] { - self.inner.as_ref() + /// Get secret key as `bytes`. + pub fn as_secret_bytes(&self) -> Vec { + self.to_secret_bytes().to_vec() } - /// Get secret key as `bytes` + /// Get secret key as `bytes`. pub fn to_secret_bytes(&self) -> [u8; 32] { - self.inner.secret_bytes() + match self { + Self::Secp256k1(inner) => inner.secret_bytes(), + Self::Bls(inner) => inner.to_bytes(), + } } - /// Schnorr Signature on Message + /// Alias for compatibility with `bitcoin::secp256k1::SecretKey`. + pub fn secret_bytes(&self) -> [u8; 32] { + self.to_secret_bytes() + } + + /// Schnorr Signature on Message. pub fn sign(&self, msg: &[u8]) -> Result { + let Self::Secp256k1(inner) = self else { + return Err(Error::WrongKeyKind); + }; let hash: Sha256Hash = Sha256Hash::hash(msg); let msg = Message::from_digest_slice(hash.as_ref())?; - Ok(SECP256K1.sign_schnorr(&msg, &Keypair::from_secret_key(&SECP256K1, &self.inner))) + Ok(SECP256K1.sign_schnorr(&msg, &Keypair::from_secret_key(&SECP256K1, inner))) } - /// Get public key + /// Get public key. pub fn public_key(&self) -> PublicKey { - self.inner.public_key(&SECP256K1).into() + match self { + Self::Secp256k1(inner) => inner.public_key(&SECP256K1).into(), + Self::Bls(inner) => inner.public_key_g2().into(), + } + } + + /// Return secp256k1 x-only public key and parity. + pub fn x_only_public_key(&self, secp: &Secp256k1) -> (XOnlyPublicKey, secp256k1::Parity) + where + C: secp256k1::Signing, + { + match self { + Self::Secp256k1(inner) => inner.x_only_public_key(secp), + Self::Bls(_) => panic!("BLS scalar is not a secp256k1 key"), + } } - /// [`SecretKey`] to [`Scalar`] + /// [`SecretKey`] to secp256k1 [`Scalar`]. #[inline] pub fn to_scalar(self) -> Scalar { - Scalar::from(self.inner) + match self { + Self::Secp256k1(inner) => Scalar::from(inner), + Self::Bls(_) => panic!("BLS scalar is not a secp256k1 scalar"), + } } - /// [`SecretKey`] as [`Scalar`] + /// [`SecretKey`] as secp256k1 [`Scalar`]. #[inline] pub fn as_scalar(&self) -> Scalar { - Scalar::from(self.inner) + match self { + Self::Secp256k1(inner) => Scalar::from(*inner), + Self::Bls(_) => panic!("BLS scalar is not a secp256k1 scalar"), + } + } + + /// Return the secp256k1 secret key. + pub fn as_secp256k1(&self) -> Result<&secp256k1::SecretKey, Error> { + match self { + Self::Secp256k1(inner) => Ok(inner), + Self::Bls(_) => Err(Error::WrongKeyKind), + } + } + + /// Return the BLS scalar. + pub fn as_bls(&self) -> Result<&BlsSecretKey, Error> { + match self { + Self::Bls(inner) => Ok(inner), + Self::Secp256k1(_) => Err(Error::WrongKeyKind), + } + } + + /// Tweak-multiply a secp256k1 secret key. + pub fn mul_tweak(&self, tweak: &Scalar) -> Result { + match self { + Self::Secp256k1(inner) => Ok(inner.mul_tweak(tweak)?.into()), + Self::Bls(_) => Err(secp256k1::Error::InvalidSecretKey), + } + } + + /// Tweak-add a secp256k1 secret key. + pub fn add_tweak(&self, tweak: &Scalar) -> Result { + match self { + Self::Secp256k1(inner) => Ok(inner.add_tweak(tweak)?.into()), + Self::Bls(_) => Err(secp256k1::Error::InvalidSecretKey), + } + } + + /// Negate a secp256k1 secret key. + pub fn negate(&self) -> Self { + match self { + Self::Secp256k1(inner) => inner.negate().into(), + Self::Bls(_) => panic!("cannot negate BLS scalar as a secp256k1 key"), + } } } impl FromStr for SecretKey { type Err = Error; - /// Try to parse [SecretKey] from `hex` or `bech32` fn from_str(secret_key: &str) -> Result { Self::from_hex(secret_key) } @@ -116,10 +202,8 @@ impl FromStr for SecretKey { impl Serialize for SecretKey { fn serialize(&self, serializer: S) -> Result { match serializer.is_human_readable() { - // For human-readable formats like JSON, serialize as hex string true => serializer.serialize_str(&self.to_secret_hex()), - // For binary formats like CBOR, use the bytes serialization - false => serializer.serialize_bytes(self.as_secret_bytes()), + false => serializer.serialize_bytes(&self.to_secret_bytes()), } } } @@ -127,12 +211,10 @@ impl Serialize for SecretKey { impl<'de> Deserialize<'de> for SecretKey { fn deserialize>(deserializer: D) -> Result { match deserializer.is_human_readable() { - // For human-readable formats like JSON, deserialize from hex string true => { let secret_key: String = String::deserialize(deserializer)?; SecretKey::from_hex(secret_key).map_err(serde::de::Error::custom) } - // For binary formats like CBOR, use the bytes deserialization false => { struct SecretKeyVisitor; @@ -159,7 +241,9 @@ impl<'de> Deserialize<'de> for SecretKey { impl Drop for SecretKey { fn drop(&mut self) { - self.inner.non_secure_erase(); + if let Self::Secp256k1(inner) = self { + inner.non_secure_erase(); + } tracing::trace!("Secret Key dropped."); } } diff --git a/crates/cashu/src/nuts/nut02.rs b/crates/cashu/src/nuts/nut02.rs index 57622b0f89..154f74be9b 100644 --- a/crates/cashu/src/nuts/nut02.rs +++ b/crates/cashu/src/nuts/nut02.rs @@ -22,7 +22,7 @@ use thiserror::Error; use super::nut01::Keys; #[cfg(feature = "mint")] -use super::nut01::{MintKeyPair, MintKeys}; +use super::nut01::{MintKeyPair, MintKeys, SecretKey}; use crate::nuts::nut00::CurrencyUnit; use crate::util::hex; use crate::{ensure_cdk, Amount}; @@ -34,7 +34,7 @@ pub enum Error { #[error(transparent)] HexError(#[from] hex::Error), /// Keyset length error - #[error("NUT02: ID length invalid, expected 8 bytes (short/v1) or 33 bytes (v2)")] + #[error("NUT02: ID length invalid, expected 8 bytes (short/v1) or 33 bytes (v2/v3)")] Length, /// Unknown version #[error("NUT02: Unknown Version")] @@ -60,6 +60,8 @@ pub enum KeySetVersion { Version00, /// Version 01 Version01, + /// Version 02 + Version02, } impl KeySetVersion { @@ -68,6 +70,7 @@ impl KeySetVersion { match self { Self::Version00 => 0, Self::Version01 => 1, + Self::Version02 => 2, } } @@ -76,6 +79,7 @@ impl KeySetVersion { match byte { 0 => Ok(Self::Version00), 1 => Ok(Self::Version01), + 2 => Ok(Self::Version02), _ => Err(Error::UnknownVersion), } } @@ -85,6 +89,7 @@ impl KeySetVersion { match value { 1 => Ok(Self::Version00), 2 => Ok(Self::Version01), + 3 => Ok(Self::Version02), _ => Err(Error::UnknownVersion), } } @@ -94,6 +99,7 @@ impl KeySetVersion { match self { Self::Version00 => 1, Self::Version01 => 2, + Self::Version02 => 3, } } } @@ -103,6 +109,7 @@ impl fmt::Display for KeySetVersion { match self { KeySetVersion::Version00 => f.write_str("00"), KeySetVersion::Version01 => f.write_str("01"), + KeySetVersion::Version02 => f.write_str("02"), } } } @@ -157,7 +164,9 @@ impl Id { let version = KeySetVersion::from_byte(&bytes[0])?; let id = match version { KeySetVersion::Version00 => IdBytes::V1(bytes[1..].try_into()?), - KeySetVersion::Version01 => IdBytes::V2(bytes[1..].try_into()?), + KeySetVersion::Version01 | KeySetVersion::Version02 => { + IdBytes::V2(bytes[1..].try_into()?) + } }; Ok(Self { version, id }) } @@ -222,6 +231,19 @@ impl Id { } } + /// *** V3 KEYSET *** + /// Create [`Id`] v3 using the v2 data string shape over BLS G2 compressed keys. + pub fn v3_from_data( + map: &Keys, + unit: &CurrencyUnit, + input_fee_ppk: u64, + expiry: Option, + ) -> Self { + let mut id = Self::v2_from_data(map, unit, input_fee_ppk, expiry); + id.version = KeySetVersion::Version02; + id + } + /// *** V1 VERSION *** /// As per NUT-02: /// 1. sort public keys by their amount in ascending order @@ -241,7 +263,7 @@ impl Id { let pubkeys_concat: Vec = keys .iter() .map(|(_, pubkey)| pubkey.to_bytes()) - .collect::>() + .collect::>>() .concat(); let hash = Sha256::hash(&pubkeys_concat); @@ -278,10 +300,10 @@ impl Id { id: IdBytes::V1(idbytes), }) } - KeySetVersion::Version01 => { + KeySetVersion::Version01 | KeySetVersion::Version02 => { // We return the first match or error for keyset_info in keysets_info.iter() { - if keyset_info.id.version == KeySetVersion::Version01 + if keyset_info.id.version == short_id.version && keyset_info.id.id.to_vec().starts_with(&short_id.prefix) { return Ok(keyset_info.id); @@ -358,6 +380,11 @@ impl TryFrom for Id { .try_into() .map_err(|_| Error::Length)?, ), + KeySetVersion::Version02 => IdBytes::V2( + hex::decode(&s[2..])? + .try_into() + .map_err(|_| Error::Length)?, + ), }; Ok(Self { version, id }) @@ -415,7 +442,7 @@ impl From for ShortKeysetId { IdBytes::V1(idbytes) => Vec::from(&idbytes), _ => panic!("Unexpected IdBytes length"), }, - KeySetVersion::Version01 => match id.id { + KeySetVersion::Version01 | KeySetVersion::Version02 => match id.id { IdBytes::V2(idbytes) => Vec::from(&idbytes[..7]), _ => panic!("Unexpected IdBytes length"), }, @@ -514,6 +541,12 @@ impl KeySet { self.input_fee_ppk, self.final_expiry, ), + KeySetVersion::Version02 => Id::v3_from_data( + &self.keys, + &self.unit, + self.input_fee_ppk, + self.final_expiry, + ), }; ensure_cdk!( @@ -628,13 +661,17 @@ impl MintKeySet { .expect("RNG busted") .private_key; let public_key = secret_key.public_key(secp); - map.insert( - amount.into(), - MintKeyPair { + let mint_key_pair = match version { + KeySetVersion::Version00 | KeySetVersion::Version01 => MintKeyPair { secret_key: secret_key.into(), public_key: public_key.into(), }, - ); + KeySetVersion::Version02 => { + let digest = Sha256::hash(&secret_key.secret_bytes()).to_byte_array(); + MintKeyPair::from_secret_key(SecretKey::bls_from_reduced_bytes(&digest)) + } + }; + map.insert(amount.into(), mint_key_pair); } let keys = MintKeys::new(map); @@ -643,6 +680,9 @@ impl MintKeySet { KeySetVersion::Version01 => { Id::v2_from_data(&keys.clone().into(), &unit, input_fee_ppk, final_expiry) } + KeySetVersion::Version02 => { + Id::v3_from_data(&keys.clone().into(), &unit, input_fee_ppk, final_expiry) + } }; Self { id, @@ -727,6 +767,12 @@ impl From for Id { keyset.input_fee_ppk, keyset.final_expiry, ), + KeySetVersion::Version02 => Id::v3_from_data( + &keys, + &keyset.unit, + keyset.input_fee_ppk, + keyset.final_expiry, + ), } } } @@ -832,6 +878,12 @@ mod test { } "#; + const BLS_V3_KEYSET_VECTOR_KEY: &str = "93e02b6052719f607dacd3a088274f65596bd0d09920b61ab5da61bbdc7f5049334cf11213945d57e5ac7d055d042b7e024aa2b2f08f0a91260805272dc51051c6e47ad4fa403b02b4510b647ae3d1770bac0326a805bbefd48056c8c121bdb8"; + const BLS_V3_KEYSET_VECTOR_1_ID: &str = + "02ce4c47836fd0e64f37a08254777b7fd0dedb95fc1ddd0acadf5600674c743c5d"; + const BLS_V3_KEYSET_VECTOR_2_ID: &str = + "02b532391cadf8c5d98bf0ff05b85e3cfb76a8175d71822140df3396c20cf40588"; + #[test] fn test_deserialization_and_id_generation() { let _id = Id::from_str("009a1f293253e41e").unwrap(); @@ -876,6 +928,34 @@ mod test { assert_eq!(id, id_from_str); } + #[test] + fn test_v3_deserialization_and_id_generation() { + let unit = CurrencyUnit::Sat; + let vector_1 = format!( + r#"{{ + "1":"{key}", + "2":"{key}" + }}"#, + key = BLS_V3_KEYSET_VECTOR_KEY + ); + let keys: Keys = serde_json::from_str(&vector_1).unwrap(); + let id = Id::v3_from_data(&keys, &unit, 0, None); + assert_eq!(id, Id::from_str(BLS_V3_KEYSET_VECTOR_1_ID).unwrap()); + + let vector_2 = format!( + r#"{{ + "1":"{key}", + "2":"{key}", + "4":"{key}", + "8":"{key}" + }}"#, + key = BLS_V3_KEYSET_VECTOR_KEY + ); + let keys: Keys = serde_json::from_str(&vector_2).unwrap(); + let id = Id::v3_from_data(&keys, &unit, 100, Some(2_000_000_000)); + assert_eq!(id, Id::from_str(BLS_V3_KEYSET_VECTOR_2_ID).unwrap()); + } + #[test] fn test_deserialization_keyset_info() { let h = r#"{"id":"009a1f293253e41e","unit":"sat","active":true}"#; @@ -978,8 +1058,8 @@ mod test { panic!("Failed to create Id from {}: {e}", hex::encode(rand_bytes)) }) } - KeySetVersion::Version01 => { - let mut rand_bytes = vec![1u8; 33]; + KeySetVersion::Version01 | KeySetVersion::Version02 => { + let mut rand_bytes = vec![version.to_byte(); 33]; rand::thread_rng().fill_bytes(&mut rand_bytes[1..]); Id::from_bytes(&rand_bytes).unwrap_or_else(|e| { panic!("Failed to create Id from {}: {e}", hex::encode(rand_bytes)) diff --git a/crates/cashu/src/nuts/nut11/mod.rs b/crates/cashu/src/nuts/nut11/mod.rs index 149075bbeb..222d60d797 100644 --- a/crates/cashu/src/nuts/nut11/mod.rs +++ b/crates/cashu/src/nuts/nut11/mod.rs @@ -361,7 +361,7 @@ pub(crate) fn valid_signatures( impl BlindedMessage { /// Sign [BlindedMessage] pub fn sign_p2pk(&mut self, secret_key: SecretKey) -> Result<(), Error> { - let msg: [u8; 33] = self.blinded_secret.to_bytes(); + let msg = self.blinded_secret.to_bytes(); let signature: Signature = secret_key.sign(&msg)?; let signatures = vec![signature.to_string()]; diff --git a/crates/cashu/src/nuts/nut12.rs b/crates/cashu/src/nuts/nut12.rs index 59e9a379a2..c9886f8ea6 100644 --- a/crates/cashu/src/nuts/nut12.rs +++ b/crates/cashu/src/nuts/nut12.rs @@ -2,15 +2,13 @@ //! //! -use core::ops::Deref; - use bitcoin::secp256k1::{self, Scalar}; use serde::{Deserialize, Serialize}; use thiserror::Error; use super::nut00::{BlindSignature, Proof}; use super::nut01::{PublicKey, SecretKey}; -use super::nut02::Id; +use super::nut02::{Id, KeySetVersion}; use crate::dhke::{hash_e, hash_to_curve}; use crate::{Amount, SECP256K1}; @@ -87,7 +85,7 @@ fn verify_dleq( let r1: PublicKey = s.public_key().combine(&a)?.into(); // s*G + (-a) // b = s*B' - let s: Scalar = Scalar::from(s.deref().to_owned()); + let s: Scalar = Scalar::from(*s.as_secp256k1()?); let b: PublicKey = blinded_message.mul_tweak(&SECP256K1, &s)?.into(); // c = e*C' @@ -173,15 +171,20 @@ impl BlindSignature { blinded_message: &PublicKey, mint_secretkey: SecretKey, ) -> Result { - Ok(Self { - amount, - keyset_id, - c: blinded_signature, - dleq: Some(calculate_dleq( + let dleq = match keyset_id.get_version() { + KeySetVersion::Version00 | KeySetVersion::Version01 => Some(calculate_dleq( blinded_signature, blinded_message, &mint_secretkey, )?), + KeySetVersion::Version02 => None, + }; + + Ok(Self { + amount, + keyset_id, + c: blinded_signature, + dleq, }) } @@ -411,4 +414,29 @@ mod tests { .verify_dleq(secret_key.public_key(), blinded_message) .is_ok()); } + + #[test] + fn test_v3_blind_signature_omits_dleq() { + use crate::nuts::nut02::{Id, KeySetVersion}; + + let keyset_id = + Id::from_bytes(&[vec![KeySetVersion::Version02.to_byte()], vec![1; 32]].concat()) + .expect("valid v3 keyset id"); + let blinded_message = + crate::nuts::nut01::BlsG1PublicKey::hash_to_curve(b"blinded message").into(); + let mint_secretkey = SecretKey::bls_from_reduced_bytes(&[7u8; 32]); + let blinded_signature = crate::dhke::sign_message(&mint_secretkey, &blinded_message) + .expect("sign blinded message"); + + let blind_sig = BlindSignature::new( + Amount::from(1), + blinded_signature, + keyset_id, + &blinded_message, + mint_secretkey, + ) + .expect("blind signature"); + + assert!(blind_sig.dleq.is_none()); + } } diff --git a/crates/cashu/src/nuts/nut13.rs b/crates/cashu/src/nuts/nut13.rs index 7fa8cf8db1..2e4800dfa0 100644 --- a/crates/cashu/src/nuts/nut13.rs +++ b/crates/cashu/src/nuts/nut13.rs @@ -10,9 +10,9 @@ use tracing::instrument; use super::nut00::{BlindedMessage, PreMint, PreMintSecrets}; use super::nut01::SecretKey; -use super::nut02::Id; +use super::nut02::{Id, KeySetVersion}; use crate::amount::{FeeAndAmounts, SplitTarget}; -use crate::dhke::blind_message; +use crate::dhke::blind_message_for_version; use crate::secret::Secret; use crate::util::hex; use crate::{Amount, SECP256K1}; @@ -47,8 +47,10 @@ impl Secret { /// Create new [`Secret`] from seed pub fn from_seed(seed: &[u8; 64], keyset_id: Id, counter: u32) -> Result { match keyset_id.get_version() { - super::nut02::KeySetVersion::Version00 => Self::legacy_derive(seed, keyset_id, counter), - super::nut02::KeySetVersion::Version01 => Self::derive(seed, keyset_id, counter), + KeySetVersion::Version00 => Self::legacy_derive(seed, keyset_id, counter), + KeySetVersion::Version01 | KeySetVersion::Version02 => { + Self::derive(seed, keyset_id, counter) + } } } @@ -64,7 +66,7 @@ impl Secret { ))) } - fn derive(seed: &[u8; 64], keyset_id: Id, counter: u32) -> Result { + fn derive(seed: &[u8], keyset_id: Id, counter: u32) -> Result { let mut message = Vec::new(); message.extend_from_slice(b"Cashu_KDF_HMAC_SHA256"); message.extend_from_slice(&keyset_id.to_bytes()); @@ -84,8 +86,9 @@ impl SecretKey { /// Create new [`SecretKey`] from seed pub fn from_seed(seed: &[u8; 64], keyset_id: Id, counter: u32) -> Result { match keyset_id.get_version() { - super::nut02::KeySetVersion::Version00 => Self::legacy_derive(seed, keyset_id, counter), - super::nut02::KeySetVersion::Version01 => Self::derive(seed, keyset_id, counter), + KeySetVersion::Version00 => Self::legacy_derive(seed, keyset_id, counter), + KeySetVersion::Version01 => Self::derive(seed, keyset_id, counter), + KeySetVersion::Version02 => Self::derive_bls(seed, keyset_id, counter), } } @@ -99,7 +102,7 @@ impl SecretKey { Ok(Self::from(derived_xpriv.private_key)) } - fn derive(seed: &[u8; 64], keyset_id: Id, counter: u32) -> Result { + fn derive(seed: &[u8], keyset_id: Id, counter: u32) -> Result { let mut message = Vec::new(); message.extend_from_slice(b"Cashu_KDF_HMAC_SHA256"); message.extend_from_slice(&keyset_id.to_bytes()); @@ -115,6 +118,21 @@ impl SecretKey { &result_bytes[..32], )?)) } + + fn derive_bls(seed: &[u8], keyset_id: Id, counter: u32) -> Result { + let mut message = Vec::new(); + message.extend_from_slice(b"Cashu_KDF_HMAC_SHA256"); + message.extend_from_slice(&keyset_id.to_bytes()); + message.extend_from_slice(&(counter as u64).to_be_bytes()); + message.extend_from_slice(b"\x01"); + + let mut engine = HmacEngine::::new(seed); + engine.input(&message); + let hmac_result = hmac::Hmac::::from_engine(engine); + let result_bytes = hmac_result.to_byte_array(); + + Ok(Self::bls_from_reduced_bytes(&result_bytes)) + } } impl PreMintSecrets { @@ -137,7 +155,11 @@ impl PreMintSecrets { let secret = Secret::from_seed(seed, keyset_id, counter)?; let blinding_factor = SecretKey::from_seed(seed, keyset_id, counter)?; - let (blinded, r) = blind_message(&secret.to_bytes(), Some(blinding_factor))?; + let (blinded, r) = blind_message_for_version( + &secret.to_bytes(), + Some(blinding_factor), + keyset_id.get_version(), + )?; let blinded_message = BlindedMessage::new(amount, keyset_id, blinded); @@ -171,7 +193,11 @@ impl PreMintSecrets { let secret = Secret::from_seed(seed, keyset_id, counter)?; let blinding_factor = SecretKey::from_seed(seed, keyset_id, counter)?; - let (blinded, r) = blind_message(&secret.to_bytes(), Some(blinding_factor))?; + let (blinded, r) = blind_message_for_version( + &secret.to_bytes(), + Some(blinding_factor), + keyset_id.get_version(), + )?; let amount = Amount::ZERO; @@ -204,7 +230,11 @@ impl PreMintSecrets { let secret = Secret::from_seed(seed, keyset_id, i)?; let blinding_factor = SecretKey::from_seed(seed, keyset_id, i)?; - let (blinded, r) = blind_message(&secret.to_bytes(), Some(blinding_factor))?; + let (blinded, r) = blind_message_for_version( + &secret.to_bytes(), + Some(blinding_factor), + keyset_id.get_version(), + )?; let blinded_message = BlindedMessage::new(Amount::ZERO, keyset_id, blinded); @@ -471,6 +501,27 @@ mod tests { assert_eq!(secret_key.secret_bytes().len(), 32); } + #[test] + fn test_v3_secret_derivation_vector() { + let seed = b"test seed v3 reduction"; + let keyset_id = + Id::from_str("02ce4c47836fd0e64f37a08254777b7fd0dedb95fc1ddd0acadf5600674c743c5d") + .unwrap(); + let counter = 2; + + let secret = Secret::derive(seed, keyset_id, counter).unwrap(); + let blinding_factor = SecretKey::derive_bls(seed, keyset_id, counter).unwrap(); + + assert_eq!( + secret.to_string(), + "4729fe85ab3886ce03259ac658735ff534c9cd41b2b364d202ff497e4ee48809" + ); + assert_eq!( + blinding_factor.to_secret_hex(), + "08bb237d625b73022cd50f6fedfb660c6125b676a4819474241c264903259d2f" + ); + } + #[test] fn test_pre_mint_secrets_with_v2_keyset() { let seed = diff --git a/crates/cashu/src/nuts/nut28/mod.rs b/crates/cashu/src/nuts/nut28/mod.rs index fb3377857e..9e4edc421d 100644 --- a/crates/cashu/src/nuts/nut28/mod.rs +++ b/crates/cashu/src/nuts/nut28/mod.rs @@ -102,7 +102,7 @@ pub fn ecdh_kdf( let shared = pubkey.mul_tweak(&SECP, &secret_key.as_scalar())?; // SharedSecret exposes 32 bytes (x-coordinate) - let z_x: [u8; 32] = shared.x_only_public_key().0.serialize(); + let z_x: [u8; 32] = shared.x_only_public_key().serialize(); // Build KDF input per NUT-28 spec: domain tag || x-only(Z) || canonical_slot (1 byte) let mut engine = Sha256::engine(); @@ -178,7 +178,7 @@ pub fn derive_signing_key_bip340( let privkey_pubkey = privkey.public_key(); // Verify the x-coordinates match - let (unblinded_x_only, unblinded_parity) = unblinded_pubkey.x_only_public_key(); + let (unblinded_x_only, unblinded_parity) = unblinded_pubkey.x_only_public_key_with_parity(); let privkey_x_only = privkey_pubkey.x_only_public_key(); // Compute parity from the compressed public key bytes diff --git a/crates/cdk-common/src/database/mint/test/mod.rs b/crates/cdk-common/src/database/mint/test/mod.rs index 1e6c8da7e8..eb09a91615 100644 --- a/crates/cdk-common/src/database/mint/test/mod.rs +++ b/crates/cdk-common/src/database/mint/test/mod.rs @@ -8,7 +8,7 @@ use std::sync::atomic::{AtomicU64, Ordering}; // For derivation path parsing use bitcoin::bip32::DerivationPath; -use cashu::CurrencyUnit; +use cashu::{CurrencyUnit, KeySetVersion}; use web_time::{SystemTime, UNIX_EPOCH}; use super::*; @@ -58,6 +58,31 @@ where keyset_id } +#[inline] +async fn setup_bls_keyset(db: &DB) -> Id +where + DB: KeysDatabase, +{ + let keyset_id = + Id::from_bytes(&[vec![KeySetVersion::Version02.to_byte()], vec![2; 32]].concat()).unwrap(); + let keyset_info = MintKeySetInfo { + id: keyset_id, + unit: CurrencyUnit::Sat, + active: true, + valid_from: 0, + final_expiry: None, + derivation_path: DerivationPath::from_str("m/0'/0'/1'").unwrap(), + derivation_path_index: Some(1), + input_fee_ppk: 0, + amounts: standard_keyset_amounts(32), + issuer_version: IssuerVersion::from_str("cdk/0.1.0").ok(), + }; + let mut writer = db.begin_transaction().await.expect("db.begin()"); + writer.add_keyset_info(keyset_info).await.unwrap(); + writer.commit().await.expect("commit()"); + keyset_id +} + /// Test KV store functionality including write, read, list, update, and remove operations pub async fn kvstore_functionality(db: DB) where @@ -191,6 +216,7 @@ macro_rules! mint_db_test { $make_db_fn, add_and_find_proofs, add_duplicate_proofs, + bls_g1_proofs_can_be_stored_queried_and_spent, kvstore_functionality, add_mint_quote, add_mint_quote_only_once, diff --git a/crates/cdk-common/src/database/mint/test/proofs.rs b/crates/cdk-common/src/database/mint/test/proofs.rs index 78b8f71b28..5d6d471f42 100644 --- a/crates/cdk-common/src/database/mint/test/proofs.rs +++ b/crates/cdk-common/src/database/mint/test/proofs.rs @@ -2,10 +2,11 @@ use std::str::FromStr; +use cashu::nuts::nut01::BlsG1PublicKey; use cashu::secret::Secret; -use cashu::{Amount, Id, SecretKey}; +use cashu::{Amount, Id, PublicKey, SecretKey, State}; -use crate::database::mint::test::setup_keyset; +use crate::database::mint::test::{setup_bls_keyset, setup_keyset}; use crate::database::mint::{Database, Error, KeysDatabase, Proof, QuoteId}; use crate::mint::Operation; use crate::state::check_state_transition; @@ -174,13 +175,54 @@ where ); } +/// Test that v3 proofs are indexed by BLS G1 Y and can be queried and spent. +pub async fn bls_g1_proofs_can_be_stored_queried_and_spent(db: DB) +where + DB: Database + KeysDatabase, +{ + let keyset_id = setup_bls_keyset(&db).await; + let proof = Proof { + amount: Amount::from(100), + keyset_id, + secret: Secret::from_str("bls proof secret").unwrap(), + c: BlsG1PublicKey::hash_to_curve(b"bls proof signature").into(), + witness: None, + dleq: None, + p2pk_e: None, + }; + let y = proof.y().unwrap(); + + assert!(matches!(y, PublicKey::BlsG1(_))); + + let mut tx = Database::begin_transaction(&db).await.unwrap(); + tx.add_proofs( + vec![proof.clone()], + None, + &Operation::new_swap(Amount::ZERO, Amount::ZERO, Amount::ZERO), + ) + .await + .unwrap(); + tx.commit().await.unwrap(); + + let retrieved = db.get_proofs_by_ys(&[y]).await.unwrap(); + assert_eq!(retrieved, vec![Some(proof.clone())]); + + let mut tx = Database::begin_transaction(&db).await.unwrap(); + let mut acquired = tx.get_proofs(&[y]).await.unwrap(); + tx.update_proofs_state(&mut acquired, State::Spent) + .await + .unwrap(); + tx.commit().await.unwrap(); + + let states = db.get_proofs_states(&[y]).await.unwrap(); + assert_eq!(states, vec![Some(State::Spent)]); +} + /// Test updating proofs states pub async fn update_proofs_states(db: DB) where DB: Database + KeysDatabase, { - use cashu::State; - let keyset_id = setup_keyset(&db).await; let quote_id = QuoteId::new(); diff --git a/crates/cdk-common/src/error.rs b/crates/cdk-common/src/error.rs index bdcbfec6e3..093a60f2fa 100644 --- a/crates/cdk-common/src/error.rs +++ b/crates/cdk-common/src/error.rs @@ -383,8 +383,8 @@ pub enum Error { /// Unknown error response #[error("Unknown error response: `{0}`")] UnknownErrorResponse(String), - /// Invalid DLEQ proof - #[error("Could not verify DLEQ proof")] + /// Invalid proof signature + #[error("Could not verify proof signature")] CouldNotVerifyDleq, /// Dleq Proof not provided for signature #[error("Dleq proof not provided for signature")] diff --git a/crates/cdk-common/src/wallet/mod.rs b/crates/cdk-common/src/wallet/mod.rs index 33a0b2ea83..22112b923e 100644 --- a/crates/cdk-common/src/wallet/mod.rs +++ b/crates/cdk-common/src/wallet/mod.rs @@ -1047,8 +1047,16 @@ pub trait Wallet: Send + Sync { /// Restore wallet from seed with custom [`NUT13Options`] async fn restore_with_opts(&self, opts: NUT13Options) -> Result; - /// Verify DLEQ proofs in a token - async fn verify_token_dleq(&self, token_str: &str) -> Result<(), Self::Error>; + /// Verify token proof signatures against the mint keys. + /// + /// This verifies DLEQ proofs for v1/v2 keysets and BLS pairings for v3 keysets. + async fn verify_token_signatures(&self, token_str: &str) -> Result<(), Self::Error>; + + /// Verify DLEQ proofs in a token. + #[deprecated(note = "use verify_token_signatures instead")] + async fn verify_token_dleq(&self, token_str: &str) -> Result<(), Self::Error> { + self.verify_token_signatures(token_str).await + } /// Pay a NUT-18 payment request async fn pay_request( diff --git a/crates/cdk-ffi/src/wallet.rs b/crates/cdk-ffi/src/wallet.rs index f13a646e23..c90ddbf9bb 100644 --- a/crates/cdk-ffi/src/wallet.rs +++ b/crates/cdk-ffi/src/wallet.rs @@ -161,10 +161,22 @@ impl Wallet { Ok(restored.into()) } - /// Verify token DLEQ proofs + /// Verify token proof signatures + pub async fn verify_token_signatures( + &self, + token: std::sync::Arc, + ) -> Result<(), FfiError> { + let cdk_token = token.inner.clone(); + self.inner.verify_token_signatures(&cdk_token).await?; + Ok(()) + } + + /// Verify token DLEQ proofs. + /// + /// Deprecated: use `verify_token_signatures` instead. pub async fn verify_token_dleq(&self, token: std::sync::Arc) -> Result<(), FfiError> { let cdk_token = token.inner.clone(); - self.inner.verify_token_dleq(&cdk_token).await?; + self.inner.verify_token_signatures(&cdk_token).await?; Ok(()) } diff --git a/crates/cdk-ffi/src/wallet_trait.rs b/crates/cdk-ffi/src/wallet_trait.rs index a2a6afe076..d7d6953bd8 100644 --- a/crates/cdk-ffi/src/wallet_trait.rs +++ b/crates/cdk-ffi/src/wallet_trait.rs @@ -416,8 +416,14 @@ impl WalletTraitDef for Wallet { Ok(restored) } + async fn verify_token_signatures(&self, token_str: &str) -> Result<(), Self::Error> { + WalletTraitDef::verify_token_signatures(self.inner().as_ref(), token_str).await?; + Ok(()) + } + + #[allow(deprecated)] async fn verify_token_dleq(&self, token_str: &str) -> Result<(), Self::Error> { - WalletTraitDef::verify_token_dleq(self.inner().as_ref(), token_str).await?; + WalletTraitDef::verify_token_signatures(self.inner().as_ref(), token_str).await?; Ok(()) } diff --git a/crates/cdk-mint-rpc/src/mint_rpc_cli/subcommands/rotate_next_keyset.rs b/crates/cdk-mint-rpc/src/mint_rpc_cli/subcommands/rotate_next_keyset.rs index ca81d27ad4..40a7938738 100644 --- a/crates/cdk-mint-rpc/src/mint_rpc_cli/subcommands/rotate_next_keyset.rs +++ b/crates/cdk-mint-rpc/src/mint_rpc_cli/subcommands/rotate_next_keyset.rs @@ -20,9 +20,14 @@ pub struct RotateNextKeysetCommand { /// The input fee in parts per thousand to apply when minting with this keyset #[arg(short, long)] input_fee_ppk: Option, - /// Use keyset v2 + /// Use keyset v2. + /// + /// Deprecated: use --keyset-version instead. #[arg(long)] use_keyset_v2: Option, + /// Keyset version to create: v1, v2, or v3 + #[arg(long)] + keyset_version: Option, /// Final expiry unix timestamp for the keyset #[arg(long)] final_expiry: Option, @@ -56,6 +61,7 @@ pub async fn rotate_next_keyset( input_fee_ppk: sub_command_args.input_fee_ppk, use_keyset_v2: sub_command_args.use_keyset_v2, final_expiry: sub_command_args.final_expiry, + keyset_version: sub_command_args.keyset_version.clone(), })) .await?; diff --git a/crates/cdk-mint-rpc/src/proto/cdk-mint-rpc.proto b/crates/cdk-mint-rpc/src/proto/cdk-mint-rpc.proto index 697664e8fd..d4a98c89ca 100644 --- a/crates/cdk-mint-rpc/src/proto/cdk-mint-rpc.proto +++ b/crates/cdk-mint-rpc/src/proto/cdk-mint-rpc.proto @@ -130,8 +130,10 @@ message RotateNextKeysetRequest { string unit = 1; repeated uint64 amounts = 2; optional uint64 input_fee_ppk = 3; + // Deprecated: use keyset_version instead. optional bool use_keyset_v2 = 4; optional uint64 final_expiry = 5; + optional string keyset_version = 6; } diff --git a/crates/cdk-mint-rpc/src/proto/server.rs b/crates/cdk-mint-rpc/src/proto/server.rs index f99bf87526..ff75e0a41d 100644 --- a/crates/cdk-mint-rpc/src/proto/server.rs +++ b/crates/cdk-mint-rpc/src/proto/server.rs @@ -6,7 +6,7 @@ use std::sync::Arc; use cdk::mint::{Mint, MintQuote}; use cdk::nuts::nut04::MintMethodSettings; use cdk::nuts::nut05::MeltMethodSettings; -use cdk::nuts::{CurrencyUnit, MintQuoteState, PaymentMethod}; +use cdk::nuts::{CurrencyUnit, KeySetVersion, MintQuoteState, PaymentMethod}; use cdk::types::QuoteTTL; use cdk::Amount; use cdk_common::grpc::create_version_check_interceptor; @@ -792,14 +792,29 @@ impl CdkMint for MintRPCServer { .map_err(|_| Status::invalid_argument("Invalid unit".to_string()))?; let amounts = request.amounts; + let keyset_version = match request.keyset_version.as_deref() { + Some("v1") | Some("00") => KeySetVersion::Version00, + Some("v2") | Some("01") => KeySetVersion::Version01, + Some("v3") | Some("02") => KeySetVersion::Version02, + Some(version) => { + return Err(Status::invalid_argument(format!( + "Invalid keyset version: {version}" + ))); + } + None => match request.use_keyset_v2 { + Some(true) => KeySetVersion::Version01, + Some(false) => KeySetVersion::Version00, + None => KeySetVersion::Version02, + }, + }; let keyset_info = self .mint - .rotate_keyset( + .rotate_keyset_by_version( unit, amounts, request.input_fee_ppk.unwrap_or(0), - request.use_keyset_v2.unwrap_or(true), + keyset_version, request.final_expiry, ) .await diff --git a/crates/cdk-mintd/README.md b/crates/cdk-mintd/README.md index 5b052685a3..cf553206e5 100644 --- a/crates/cdk-mintd/README.md +++ b/crates/cdk-mintd/README.md @@ -167,21 +167,22 @@ custom_payment_methods = [] ### Keyset Version Management -The mint supports rotating keysets to newer versions (e.g., migrating from V1 to V2). +The mint supports rotating keysets to newer versions (e.g., migrating from V1/V2 to V3). **Policy Configuration:** -By default, the mint will use V2 (Version01) for *new* keysets but will preserve existing V1 (Version00) keysets to avoid unnecessary rotation. You can force a specific policy using `config.toml` or environment variables: +By default, the mint will use V3 (Version02) for *new* keysets but will preserve existing active keysets to avoid unnecessary rotation. You can force a legacy policy using `config.toml` or environment variables: - `use_keyset_v2 = true` (or `CDK_MINTD_USE_KEYSET_V2=true`): Forces V2. If the current active keyset is V1, it will be rotated to V2 on startup. -- `use_keyset_v2 = false` (or `CDK_MINTD_USE_KEYSET_V2=false`): Forces V1. If the current active keyset is V2, it will be rotated to V1 on startup. -- **Unset (Default)**: Preserves the current keyset version. If no keyset exists, V2 is created. +- `use_keyset_v2 = false` (or `CDK_MINTD_USE_KEYSET_V2=false`): Forces V1. If the current active keyset is V2 or V3, it will be rotated to V1 on startup. +- **Unset (Default)**: Preserves the current keyset version. If no keyset exists, V3 is created. **Manual Rotation:** You can manually trigger a rotation to a specific version using the CLI: ```bash -mint-cli rotate-next-keyset --use-keyset-v2 # Rotate to V2 -mint-cli rotate-next-keyset --use-keyset-v2=false # Rotate to V1 +mint-cli rotate-next-keyset --keyset-version v3 # Rotate to V3 +mint-cli rotate-next-keyset --keyset-version v2 # Rotate to V2 +mint-cli rotate-next-keyset --keyset-version v1 # Rotate to V1 ``` ## Production Examples diff --git a/crates/cdk-mintd/example.config.toml b/crates/cdk-mintd/example.config.toml index 538534d029..52f2ba5cf2 100644 --- a/crates/cdk-mintd/example.config.toml +++ b/crates/cdk-mintd/example.config.toml @@ -10,7 +10,7 @@ mnemonic = "" # Set keyset version preference. # true = Force upgrade to V2 (Version01). # false = Force downgrade to V1 (Version00). -# If unset (default), existing keysets are preserved, but new ones use V2. +# If unset (default), existing keysets are preserved, but new ones use V3 (Version02). # use_keyset_v2 = true [info.quote_ttl] @@ -300,13 +300,13 @@ max_delay_time = 3 # Optional keyset rotations to create inactive/expired test keysets # Each rotation creates a keyset that gets rotated out during mint build # unit: currency unit (e.g. "sat", "usd") -# version: "v1" (Version00) or "v2" (Version01) +# version: "v1" (Version00), "v2" (Version01), or "v3" (Version02, default) # input_fee_ppk: input fee in parts per thousand (default: 0) # expired: if true, keyset is created with a past expiry timestamp (default: false) # # [[fake_wallet.keyset_rotations]] # unit = "sat" -# version = "v1" +# version = "v3" # input_fee_ppk = 0 # expired = true # diff --git a/crates/cdk-mintd/src/config.rs b/crates/cdk-mintd/src/config.rs index 310fc52570..ee187b781b 100644 --- a/crates/cdk-mintd/src/config.rs +++ b/crates/cdk-mintd/src/config.rs @@ -729,7 +729,7 @@ pub struct FakeWalletKeysetRotation { /// Input fee in parts per thousand #[serde(default)] pub input_fee_ppk: u64, - /// Keyset version: "v1" (Version00) or "v2" (Version01) + /// Keyset version: "v1" (Version00), "v2" (Version01), or "v3" (Version02) #[serde(default = "default_keyset_version")] pub version: String, /// If true, the keyset will be created with a past expiry (expired) @@ -739,7 +739,7 @@ pub struct FakeWalletKeysetRotation { #[cfg(feature = "fakewallet")] fn default_keyset_version() -> String { - "v1".to_string() + "v3".to_string() } #[cfg(feature = "fakewallet")] diff --git a/crates/cdk-mintd/src/env_vars/fake_wallet.rs b/crates/cdk-mintd/src/env_vars/fake_wallet.rs index 7c83bf7e3d..7b99ddff52 100644 --- a/crates/cdk-mintd/src/env_vars/fake_wallet.rs +++ b/crates/cdk-mintd/src/env_vars/fake_wallet.rs @@ -15,7 +15,7 @@ pub const ENV_FAKE_WALLET_CUSTOM_PAYMENT_METHODS: &str = pub const ENV_FAKE_WALLET_MIN_DELAY: &str = "CDK_MINTD_FAKE_WALLET_MIN_DELAY"; pub const ENV_FAKE_WALLET_MAX_DELAY: &str = "CDK_MINTD_FAKE_WALLET_MAX_DELAY"; /// JSON array of keyset rotations, e.g.: -/// `[{"unit":"sat","version":"v1","input_fee_ppk":0,"expired":true}]` +/// `[{"unit":"sat","version":"v3","input_fee_ppk":0,"expired":true}]` pub const ENV_FAKE_WALLET_KEYSET_ROTATIONS: &str = "CDK_MINTD_FAKE_WALLET_KEYSET_ROTATIONS"; impl FakeWallet { diff --git a/crates/cdk-mintd/src/lib.rs b/crates/cdk-mintd/src/lib.rs index 087542cede..98514dfa2d 100644 --- a/crates/cdk-mintd/src/lib.rs +++ b/crates/cdk-mintd/src/lib.rs @@ -760,7 +760,7 @@ async fn configure_lightning_backend( let fake_wallet = settings.fake_wallet.as_ref().ok_or_else(|| { anyhow!("Fake wallet backend selected but [fake_wallet] config section is missing") })?; - mint_builder = configure_fake_wallet_keyset_rotations_once(mint_builder, fake_wallet); + mint_builder = configure_fake_wallet_keyset_rotations_once(mint_builder, fake_wallet)?; } Ok(mint_builder) @@ -770,9 +770,10 @@ async fn configure_lightning_backend( fn configure_fake_wallet_keyset_rotations_once( mut mint_builder: MintBuilder, fake_wallet: &config::FakeWallet, -) -> MintBuilder { +) -> Result { for rotation_cfg in &fake_wallet.keyset_rotations { use cdk::mint::KeysetRotation; + use cdk_common::nut02::KeySetVersion; let amounts = cdk::mint::UnitConfig::default().amounts; let final_expiry = if rotation_cfg.expired { @@ -780,17 +781,25 @@ fn configure_fake_wallet_keyset_rotations_once( } else { None }; + let keyset_id_type = match rotation_cfg.version.as_str() { + "v1" => KeySetVersion::Version00, + "v2" => KeySetVersion::Version01, + "v3" => KeySetVersion::Version02, + version => { + bail!("Unsupported fake wallet keyset rotation version: {version}"); + } + }; mint_builder = mint_builder.with_keyset_rotation(KeysetRotation { unit: rotation_cfg.unit.clone(), amounts, input_fee_ppk: rotation_cfg.input_fee_ppk, - use_keyset_v2: rotation_cfg.version == "v2", + keyset_id_type, final_expiry, }); } - mint_builder + Ok(mint_builder) } /// Configures Onchain backend based on the specified backend type diff --git a/crates/cdk-signatory/src/db_signatory.rs b/crates/cdk-signatory/src/db_signatory.rs index f2afe48715..c2b426484b 100644 --- a/crates/cdk-signatory/src/db_signatory.rs +++ b/crates/cdk-signatory/src/db_signatory.rs @@ -6,8 +6,9 @@ use std::sync::Arc; use bitcoin::bip32::{DerivationPath, Xpriv}; use bitcoin::secp256k1::{self, Secp256k1}; -use cdk_common::dhke::{sign_message, verify_message}; +use cdk_common::dhke::{sign_message, verify_bls_message, verify_message}; use cdk_common::mint::MintKeySetInfo; +use cdk_common::nut02::KeySetVersion; use cdk_common::nuts::{BlindSignature, BlindedMessage, CurrencyUnit, Id, MintKeySet, Proof}; use cdk_common::{database, Error, PublicKey}; use tokio::sync::RwLock; @@ -165,7 +166,17 @@ impl Signatory for DbSignatory { proofs.into_iter().try_for_each(|proof| { let (_, key) = keysets.get(&proof.keyset_id).ok_or(Error::UnknownKeySet)?; let key_pair = key.keys.get(&proof.amount).ok_or(Error::UnknownKeySet)?; - verify_message(&key_pair.secret_key, proof.c, proof.secret.as_bytes())?; + match proof.keyset_id.get_version() { + KeySetVersion::Version00 | KeySetVersion::Version01 => { + verify_message(&key_pair.secret_key, proof.c, proof.secret.as_bytes())?; + } + KeySetVersion::Version02 => { + if proof.dleq.is_some() { + return Err(Error::DHKE(cdk_common::dhke::Error::TokenNotVerified)); + } + verify_bls_message(key_pair.public_key, proof.c, proof.secret.as_bytes())?; + } + } Ok(()) }) } diff --git a/crates/cdk/src/mint/builder.rs b/crates/cdk/src/mint/builder.rs index 7760b9553b..e2b1f22a57 100644 --- a/crates/cdk/src/mint/builder.rs +++ b/crates/cdk/src/mint/builder.rs @@ -7,6 +7,7 @@ use bitcoin::bip32::DerivationPath; use cdk_common::database::{DynMintAuthDatabase, DynMintDatabase, MintKeysDatabase}; use cdk_common::error::Error; use cdk_common::nut00::KnownMethod; +use cdk_common::nut02::KeySetVersion; use cdk_common::nut04::MintMethodOptions; use cdk_common::nut05::MeltMethodOptions; use cdk_common::payment::DynMintPayment; @@ -53,8 +54,8 @@ pub struct KeysetRotation { pub amounts: Vec, /// Input fee pub input_fee_ppk: u64, - /// Whether to use keyset V2 (Version01) or V1 (Version00) - pub use_keyset_v2: bool, + /// Keyset version to create. + pub keyset_id_type: KeySetVersion, /// Optional expiry timestamp (unix seconds) pub final_expiry: Option, } @@ -127,6 +128,14 @@ impl MintBuilder { self } + fn preferred_keyset_version(&self) -> KeySetVersion { + match self.use_keyset_v2 { + Some(false) => KeySetVersion::Version00, + Some(true) => KeySetVersion::Version01, + None => KeySetVersion::Version02, + } + } + /// Add a keyset rotation to execute during build. /// Used to create inactive/expired keysets for testing. pub fn with_keyset_rotation(mut self, rotation: KeysetRotation) -> Self { @@ -620,13 +629,18 @@ impl MintBuilder { // Check if version matches explicit preference if let Some(want_v2) = self.use_keyset_v2 { - let is_v2 = - keyset.id.get_version() == cdk_common::nut02::KeySetVersion::Version01; - if want_v2 && !is_v2 { - tracing::info!("Rotating keyset for unit {} due to explicit V2 preference (current is V1)", unit); - rotate = true; - } else if !want_v2 && is_v2 { - tracing::info!("Rotating keyset for unit {} due to explicit V1 preference (current is V2)", unit); + let desired_version = if want_v2 { + KeySetVersion::Version01 + } else { + KeySetVersion::Version00 + }; + if keyset.id.get_version() != desired_version { + tracing::info!( + "Rotating keyset for unit {} due to explicit {} preference (current is {})", + unit, + desired_version, + keyset.id.get_version() + ); rotate = true; } } @@ -642,11 +656,7 @@ impl MintBuilder { unit: unit.clone(), amounts: amounts.clone(), input_fee_ppk: *fee, - keyset_id_type: if self.use_keyset_v2.unwrap_or(true) { - cdk_common::nut02::KeySetVersion::Version01 - } else { - cdk_common::nut02::KeySetVersion::Version00 - }, + keyset_id_type: self.preferred_keyset_version(), final_expiry: None, }) .await?; @@ -660,11 +670,7 @@ impl MintBuilder { unit: rotation.unit.clone(), amounts: rotation.amounts.clone(), input_fee_ppk: rotation.input_fee_ppk, - keyset_id_type: if rotation.use_keyset_v2 { - cdk_common::nut02::KeySetVersion::Version01 - } else { - cdk_common::nut02::KeySetVersion::Version00 - }, + keyset_id_type: rotation.keyset_id_type, final_expiry: rotation.final_expiry, }) .await?; diff --git a/crates/cdk/src/mint/keysets/mod.rs b/crates/cdk/src/mint/keysets/mod.rs index abbd390ada..837dcdf2e1 100644 --- a/crates/cdk/src/mint/keysets/mod.rs +++ b/crates/cdk/src/mint/keysets/mod.rs @@ -78,6 +78,26 @@ impl Mint { input_fee_ppk: u64, use_keyset_v2: bool, final_expiry: Option, + ) -> Result { + let keyset_id_type = if use_keyset_v2 { + cdk_common::nut02::KeySetVersion::Version01 + } else { + cdk_common::nut02::KeySetVersion::Version00 + }; + self.rotate_keyset_by_version(unit, amounts, input_fee_ppk, keyset_id_type, final_expiry) + .await + } + + /// Add current keyset to inactive keysets and generate a new keyset + /// with the specified keyset version. + #[instrument(skip(self))] + pub async fn rotate_keyset_by_version( + &self, + unit: CurrencyUnit, + amounts: Vec, + input_fee_ppk: u64, + keyset_id_type: cdk_common::nut02::KeySetVersion, + final_expiry: Option, ) -> Result { let result = self .signatory @@ -85,11 +105,7 @@ impl Mint { unit, amounts, input_fee_ppk, - keyset_id_type: if use_keyset_v2 { - cdk_common::nut02::KeySetVersion::Version01 - } else { - cdk_common::nut02::KeySetVersion::Version00 - }, + keyset_id_type, final_expiry, }) .await?; diff --git a/crates/cdk/src/wallet/auth/auth_wallet.rs b/crates/cdk/src/wallet/auth/auth_wallet.rs index d1877eafce..c92de135f2 100644 --- a/crates/cdk/src/wallet/auth/auth_wallet.rs +++ b/crates/cdk/src/wallet/auth/auth_wallet.rs @@ -13,11 +13,11 @@ use tracing::instrument; use super::AuthMintConnector; use crate::amount::SplitTarget; -use crate::dhke::construct_proofs; +use crate::dhke::{construct_proofs, verify_bls_message}; use crate::nuts::nut22::MintAuthRequest; use crate::nuts::{ - nut12, AuthRequired, AuthToken, BlindAuthToken, CurrencyUnit, KeySetInfo, PreMintSecrets, - Proofs, ProtectedEndpoint, State, + nut12, AuthRequired, AuthToken, BlindAuthToken, CurrencyUnit, KeySetInfo, KeySetVersion, + PreMintSecrets, Proofs, ProtectedEndpoint, State, }; use crate::wallet::mint_connector::AuthHttpClient; use crate::wallet::mint_metadata_cache::MintMetadataCache; @@ -464,13 +464,24 @@ impl AuthWallet { let keys = self.load_keyset_keys(sig.keyset_id).await?; let key = keys.amount_key(sig.amount).ok_or(Error::AmountKey)?; - match sig.verify_dleq(key, premint.blinded_message.blinded_secret) { - Ok(_) => (), - Err(nut12::Error::MissingDleqProof) => { - tracing::warn!("Signature for bat returned without dleq proof."); - return Err(Error::DleqProofNotProvided); + match sig.keyset_id.get_version() { + KeySetVersion::Version00 | KeySetVersion::Version01 => { + match sig.verify_dleq(key, premint.blinded_message.blinded_secret) { + Ok(_) => (), + Err(nut12::Error::MissingDleqProof) => { + tracing::warn!("Signature for bat returned without dleq proof."); + return Err(Error::DleqProofNotProvided); + } + Err(_) => return Err(Error::CouldNotVerifyDleq), + } + } + KeySetVersion::Version02 => { + if sig.dleq.is_some() { + return Err(Error::CouldNotVerifyDleq); + } + verify_bls_message(key, sig.c, premint.secret.as_bytes()) + .map_err(|_| Error::CouldNotVerifyDleq)?; } - Err(_) => return Err(Error::CouldNotVerifyDleq), } } } diff --git a/crates/cdk/src/wallet/blind_signature.rs b/crates/cdk/src/wallet/blind_signature.rs index 73838b8e0a..13d7cfddaa 100644 --- a/crates/cdk/src/wallet/blind_signature.rs +++ b/crates/cdk/src/wallet/blind_signature.rs @@ -1,4 +1,4 @@ -use crate::nuts::{nut12, BlindSignature, BlindedMessage}; +use crate::nuts::{nut12, BlindSignature, BlindedMessage, KeySetVersion}; use crate::wallet::Wallet; use crate::{Amount, Error}; @@ -21,9 +21,10 @@ pub(crate) enum SignatureAmountValidation { /// wallet requested a specific denomination. Use /// [`SignatureAmountValidation::AllowZeroAmountPlaceholder`] for NUT-08/NUT-09 /// style outputs where the wallet sends amount `0` and the mint fills in the -/// actual change or restored amount. DLEQ proofs are optional for compatibility, -/// but when present they are verified after the signature metadata has been -/// cross-checked. +/// actual change or restored amount. DLEQ proofs are optional for v0/v1 +/// compatibility, but when present they are verified after the signature +/// metadata has been cross-checked. V2/BLS signatures must not include DLEQ +/// proof data. pub(crate) async fn validate_mint_response_signatures<'a>( wallet: &Wallet, signatures: &[BlindSignature], @@ -62,11 +63,20 @@ pub(crate) async fn validate_mint_response_signatures<'a>( ))); } - let keys = wallet.load_keyset_keys(sig.keyset_id).await?; - let key = keys.amount_key(sig.amount).ok_or(Error::AmountKey)?; - match sig.verify_dleq(key, blinded_message.blinded_secret) { - Ok(_) | Err(nut12::Error::MissingDleqProof) => (), - Err(_) => return Err(Error::CouldNotVerifyDleq), + match sig.keyset_id.get_version() { + KeySetVersion::Version00 | KeySetVersion::Version01 => { + let keys = wallet.load_keyset_keys(sig.keyset_id).await?; + let key = keys.amount_key(sig.amount).ok_or(Error::AmountKey)?; + match sig.verify_dleq(key, blinded_message.blinded_secret) { + Ok(_) | Err(nut12::Error::MissingDleqProof) => (), + Err(_) => return Err(Error::CouldNotVerifyDleq), + } + } + KeySetVersion::Version02 => { + if sig.dleq.is_some() { + return Err(Error::CouldNotVerifyDleq); + } + } } } diff --git a/crates/cdk/src/wallet/issue/saga/mod.rs b/crates/cdk/src/wallet/issue/saga/mod.rs index 96d5e259da..2242b7abec 100644 --- a/crates/cdk/src/wallet/issue/saga/mod.rs +++ b/crates/cdk/src/wallet/issue/saga/mod.rs @@ -45,9 +45,9 @@ use tracing::instrument; use self::compensation::{MintCompensation, ReleaseMintQuote}; use self::state::{Finalized, Initial, Prepared, PreparedMintRequest}; use crate::amount::SplitTarget; -use crate::dhke::construct_proofs; +use crate::dhke::{construct_proofs, verify_bls_message}; use crate::nuts::nut00::ProofsMethods; -use crate::nuts::{MintRequest, PreMintSecrets, Proofs, SpendingConditions, State}; +use crate::nuts::{KeySetVersion, MintRequest, PreMintSecrets, Proofs, SpendingConditions, State}; use crate::util::unix_time; use crate::wallet::blind_signature::{ validate_mint_response_signatures, SignatureAmountValidation, @@ -606,7 +606,7 @@ impl<'a> MintSaga<'a, Initial> { impl<'a> MintSaga<'a, Prepared> { /// Execute the mint operation. /// - /// Posts mint request, verifies DLEQ proofs, constructs and stores proofs, + /// Posts mint request, verifies mint signatures, constructs and stores proofs, /// updates quote state, and records transaction. On success, compensations /// are cleared. #[instrument(skip_all)] @@ -709,6 +709,14 @@ impl<'a> MintSaga<'a, Prepared> { &keys, )?; + for proof in &proofs { + if proof.keyset_id.get_version() == KeySetVersion::Version02 { + let key = keys.amount_key(proof.amount).ok_or(Error::AmountKey)?; + verify_bls_message(key, proof.c, proof.secret.as_bytes()) + .map_err(|_| Error::CouldNotVerifyDleq)?; + } + } + let minted_amount = proofs.total_amount()?; // Extract first quote info before consuming quote_infos diff --git a/crates/cdk/src/wallet/mod.rs b/crates/cdk/src/wallet/mod.rs index 49f85c4942..2af10a851d 100644 --- a/crates/cdk/src/wallet/mod.rs +++ b/crates/cdk/src/wallet/mod.rs @@ -24,14 +24,14 @@ use tracing::instrument; use zeroize::Zeroize; use crate::amount::SplitTarget; -use crate::dhke::construct_proofs; +use crate::dhke::{construct_proofs, verify_bls_message}; use crate::error::Error; use crate::fees::calculate_fee; use crate::mint_url::MintUrl; use crate::nuts::nut00::token::Token; use crate::nuts::nut17::Kind; use crate::nuts::{ - nut10, CurrencyUnit, Id, Keys, MintInfo, MintQuoteState, PreMintSecrets, Proofs, + nut10, CurrencyUnit, Id, KeySetVersion, Keys, MintInfo, MintQuoteState, PreMintSecrets, Proofs, RestoreRequest, SpendingConditions, State, }; use crate::wallet::mint_metadata_cache::MintMetadataCache; @@ -864,9 +864,11 @@ impl Wallet { Ok(()) } - /// Verify all proofs in token have a valid DLEQ proof + /// Verify all proofs in token have a valid mint signature. + /// + /// This verifies DLEQ proofs for v1/v2 keysets and BLS pairings for v3 keysets. #[instrument(skip(self, token))] - pub async fn verify_token_dleq(&self, token: &Token) -> Result<(), Error> { + pub async fn verify_token_signatures(&self, token: &Token) -> Result<(), Error> { let token_mint_url = token.mint_url()?; if token_mint_url != self.mint_url { return Err(Error::IncorrectWallet(format!( @@ -893,14 +895,30 @@ impl Wallet { } .ok_or(Error::AmountKey)?; - proof - .verify_dleq(mint_pubkey) - .map_err(|_| Error::CouldNotVerifyDleq)?; + match proof.keyset_id.get_version() { + KeySetVersion::Version00 | KeySetVersion::Version01 => proof + .verify_dleq(mint_pubkey) + .map_err(|_| Error::CouldNotVerifyDleq)?, + KeySetVersion::Version02 => { + if proof.dleq.is_some() { + return Err(Error::CouldNotVerifyDleq); + } + verify_bls_message(mint_pubkey, proof.c, proof.secret.as_bytes()) + .map_err(|_| Error::CouldNotVerifyDleq)?; + } + } } Ok(()) } + /// Verify all proofs in token have a valid DLEQ proof. + #[deprecated(note = "use verify_token_signatures instead")] + #[instrument(skip(self, token))] + pub async fn verify_token_dleq(&self, token: &Token) -> Result<(), Error> { + self.verify_token_signatures(token).await + } + /// Set the client (MintConnector) for this wallet /// /// This allows updating the connector without recreating the wallet. diff --git a/crates/cdk/src/wallet/receive/saga/mod.rs b/crates/cdk/src/wallet/receive/saga/mod.rs index 0b9de92bff..ccc6a71cab 100644 --- a/crates/cdk/src/wallet/receive/saga/mod.rs +++ b/crates/cdk/src/wallet/receive/saga/mod.rs @@ -46,10 +46,10 @@ use tracing::instrument; use self::compensation::RemovePendingProofs; use self::state::{Finalized, Initial, Prepared}; use super::ReceiveOptions; -use crate::dhke::construct_proofs; +use crate::dhke::{construct_proofs, verify_bls_message}; use crate::nuts::nut00::ProofsMethods; use crate::nuts::nut10::Kind; -use crate::nuts::{Conditions, Proofs, PublicKey, SecretKey, SigFlag, State}; +use crate::nuts::{Conditions, KeySetVersion, Proofs, PublicKey, SecretKey, SigFlag, State}; use crate::util::hex; use crate::wallet::saga::{ add_compensation, clear_compensations, execute_compensations, new_compensations, Compensations, @@ -89,7 +89,7 @@ impl<'a> ReceiveSaga<'a, Initial> { /// Prepare proofs for receiving. /// - /// Verifies DLEQ proofs, signs P2PK proofs if keys provided, and adds HTLC preimages. + /// Verifies proof signatures, signs P2PK proofs if keys provided, and adds HTLC preimages. /// No database changes are made in this step. #[instrument(skip_all)] pub async fn prepare( @@ -130,10 +130,18 @@ impl<'a> ReceiveSaga<'a, Initial> { .map(|s| (s.x_only_public_key(&SECP256K1).0, s.clone())) .collect(); - // Process each proof: verify DLEQ, handle P2PK/HTLC + // Process each proof: verify mint signature, handle P2PK/HTLC for proof in &mut proofs { - // Verify that proof DLEQ is valid - if proof.dleq.is_some() { + // Verify that the proof was signed by the mint. + if proof.keyset_id.get_version() == KeySetVersion::Version02 { + if proof.dleq.is_some() { + return Err(Error::CouldNotVerifyDleq); + } + let keys = self.wallet.load_keyset_keys(proof.keyset_id).await?; + let key = keys.amount_key(proof.amount).ok_or(Error::AmountKey)?; + verify_bls_message(key, proof.c, proof.secret.as_bytes()) + .map_err(|_| Error::CouldNotVerifyDleq)?; + } else if proof.dleq.is_some() { let keys = self.wallet.load_keyset_keys(proof.keyset_id).await?; let key = keys.amount_key(proof.amount).ok_or(Error::AmountKey)?; proof.verify_dleq(key)?; diff --git a/crates/cdk/src/wallet/swap/saga/resume.rs b/crates/cdk/src/wallet/swap/saga/resume.rs index b4bf739e89..50bfda3fdc 100644 --- a/crates/cdk/src/wallet/swap/saga/resume.rs +++ b/crates/cdk/src/wallet/swap/saga/resume.rs @@ -14,7 +14,7 @@ use cdk_common::wallet::{OperationData, SwapOperationData, SwapSagaState, WalletSaga}; use tracing::instrument; -use crate::dhke::hash_to_curve; +use crate::dhke::hash_to_curve_for_version; use crate::nuts::{PreMintSecrets, State}; use crate::wallet::recovery::{RecoveryAction, RecoveryHelpers}; use crate::wallet::saga::{CompensatingAction, RevertProofReservation}; @@ -195,11 +195,12 @@ impl Wallet { if let Ok(premint_secrets) = PreMintSecrets::restore_batch(keyset_id, &self.seed, start, end) { - // Derive Ys from secrets let ys_result: Result, _> = premint_secrets .secrets .iter() - .map(|p| hash_to_curve(&p.secret.to_bytes())) + .map(|p| { + hash_to_curve_for_version(&p.secret.to_bytes(), keyset_id.get_version()) + }) .collect(); if let Ok(ys) = ys_result { diff --git a/crates/cdk/src/wallet/wallet_trait.rs b/crates/cdk/src/wallet/wallet_trait.rs index 840d60ea2c..a86b35e016 100644 --- a/crates/cdk/src/wallet/wallet_trait.rs +++ b/crates/cdk/src/wallet/wallet_trait.rs @@ -351,10 +351,17 @@ impl WalletTrait for super::Wallet { self.restore_with_opts(opts).await } + #[instrument(skip(self, token_str))] + async fn verify_token_signatures(&self, token_str: &str) -> Result<(), Self::Error> { + let token = cdk_common::nuts::nut00::token::Token::from_str(token_str)?; + self.verify_token_signatures(&token).await + } + + #[allow(deprecated)] #[instrument(skip(self, token_str))] async fn verify_token_dleq(&self, token_str: &str) -> Result<(), Self::Error> { let token = cdk_common::nuts::nut00::token::Token::from_str(token_str)?; - self.verify_token_dleq(&token).await + self.verify_token_signatures(&token).await } #[instrument(skip(self, request))] From c51066de9f53734cb26fe914c2d018e025677118 Mon Sep 17 00:00:00 2001 From: thesimplekid Date: Mon, 8 Jun 2026 10:26:52 +0100 Subject: [PATCH 2/7] fix: remove extra hash step --- crates/cashu/src/nuts/nut02.rs | 47 +++++++++++++++++++++++++++++++--- 1 file changed, 43 insertions(+), 4 deletions(-) diff --git a/crates/cashu/src/nuts/nut02.rs b/crates/cashu/src/nuts/nut02.rs index 154f74be9b..5aab1c63fb 100644 --- a/crates/cashu/src/nuts/nut02.rs +++ b/crates/cashu/src/nuts/nut02.rs @@ -666,10 +666,9 @@ impl MintKeySet { secret_key: secret_key.into(), public_key: public_key.into(), }, - KeySetVersion::Version02 => { - let digest = Sha256::hash(&secret_key.secret_bytes()).to_byte_array(); - MintKeyPair::from_secret_key(SecretKey::bls_from_reduced_bytes(&digest)) - } + KeySetVersion::Version02 => MintKeyPair::from_secret_key( + SecretKey::bls_from_reduced_bytes(&secret_key.secret_bytes()), + ), }; map.insert(amount.into(), mint_key_pair); } @@ -956,6 +955,46 @@ mod test { assert_eq!(id, Id::from_str(BLS_V3_KEYSET_VECTOR_2_ID).unwrap()); } + #[cfg(feature = "mint")] + #[test] + fn test_v3_mint_key_derivation_uses_bip32_child_bytes() { + use bitcoin::bip32::{ChildNumber, DerivationPath, Xpriv}; + use bitcoin::hashes::sha256::Hash as Sha256; + use bitcoin::hashes::Hash; + + use crate::nuts::nut01::SecretKey; + use crate::SECP256K1; + + let seed = [1u8; 64]; + let derivation_path = DerivationPath::from_str("m/0'/0'/0'").unwrap(); + let keyset = super::MintKeySet::generate_from_seed( + &SECP256K1, + &seed, + &[1], + CurrencyUnit::Sat, + derivation_path.clone(), + 0, + None, + KeySetVersion::Version02, + ); + + let xpriv = Xpriv::new_master(bitcoin::Network::Bitcoin, &seed) + .unwrap() + .derive_priv(&SECP256K1, &derivation_path) + .unwrap(); + let child = xpriv + .derive_priv(&SECP256K1, &[ChildNumber::from_hardened_idx(0).unwrap()]) + .unwrap() + .private_key; + let expected_secret_key = SecretKey::bls_from_reduced_bytes(&child.secret_bytes()); + let old_hashed_secret_key = + SecretKey::bls_from_reduced_bytes(&Sha256::hash(&child.secret_bytes()).to_byte_array()); + + let key_pair = keyset.keys.get(&1.into()).unwrap(); + assert_eq!(key_pair.secret_key, expected_secret_key); + assert_ne!(key_pair.secret_key, old_hashed_secret_key); + } + #[test] fn test_deserialization_keyset_info() { let h = r#"{"id":"009a1f293253e41e","unit":"sat","active":true}"#; From bd395ef6e4d7f5bc6a2e951f5036527fc9fd2709 Mon Sep 17 00:00:00 2001 From: thesimplekid Date: Mon, 8 Jun 2026 11:04:40 +0100 Subject: [PATCH 3/7] fix: use rejection sampling --- crates/cashu/src/nuts/nut02.rs | 97 ++++++++++++++++++++++++++-------- crates/cashu/src/nuts/nut13.rs | 48 +++++++++++++---- 2 files changed, 111 insertions(+), 34 deletions(-) diff --git a/crates/cashu/src/nuts/nut02.rs b/crates/cashu/src/nuts/nut02.rs index 5aab1c63fb..3a8ad593de 100644 --- a/crates/cashu/src/nuts/nut02.rs +++ b/crates/cashu/src/nuts/nut02.rs @@ -653,22 +653,51 @@ impl MintKeySet { ) -> Self { let mut map = BTreeMap::new(); for (i, amount) in amounts.iter().enumerate() { - let secret_key = xpriv - .derive_priv( - secp, - &[ChildNumber::from_hardened_idx(i as u32).expect("order is valid index")], - ) - .expect("RNG busted") - .private_key; - let public_key = secret_key.public_key(secp); let mint_key_pair = match version { - KeySetVersion::Version00 | KeySetVersion::Version01 => MintKeyPair { - secret_key: secret_key.into(), - public_key: public_key.into(), - }, - KeySetVersion::Version02 => MintKeyPair::from_secret_key( - SecretKey::bls_from_reduced_bytes(&secret_key.secret_bytes()), - ), + KeySetVersion::Version00 | KeySetVersion::Version01 => { + let secret_key = xpriv + .derive_priv( + secp, + &[ChildNumber::from_hardened_idx(i as u32) + .expect("order is valid index")], + ) + .expect("RNG busted") + .private_key; + let public_key = secret_key.public_key(secp); + MintKeyPair { + secret_key: secret_key.into(), + public_key: public_key.into(), + } + } + KeySetVersion::Version02 => { + let mut attempt = 0; + loop { + let secret_bytes = xpriv + .derive_priv( + secp, + &[ + ChildNumber::from_hardened_idx(i as u32) + .expect("order is valid index"), + ChildNumber::from_hardened_idx(attempt) + .expect("attempt is valid index"), + ], + ) + .expect("RNG busted") + .private_key + .secret_bytes(); + + if secret_bytes.iter().all(|byte| *byte == 0) { + attempt = attempt.checked_add(1).expect("attempt space exhausted"); + continue; + } + + if let Ok(secret_key) = SecretKey::bls_from_slice(&secret_bytes) { + break MintKeyPair::from_secret_key(secret_key); + } + + attempt = attempt.checked_add(1).expect("attempt space exhausted"); + } + } }; map.insert(amount.into(), mint_key_pair); } @@ -957,10 +986,8 @@ mod test { #[cfg(feature = "mint")] #[test] - fn test_v3_mint_key_derivation_uses_bip32_child_bytes() { + fn test_v3_mint_key_derivation_uses_rejection_sampled_bip32_child_bytes() { use bitcoin::bip32::{ChildNumber, DerivationPath, Xpriv}; - use bitcoin::hashes::sha256::Hash as Sha256; - use bitcoin::hashes::Hash; use crate::nuts::nut01::SecretKey; use crate::SECP256K1; @@ -982,17 +1009,41 @@ mod test { .unwrap() .derive_priv(&SECP256K1, &derivation_path) .unwrap(); - let child = xpriv + let old_one_level_child = xpriv .derive_priv(&SECP256K1, &[ChildNumber::from_hardened_idx(0).unwrap()]) .unwrap() .private_key; - let expected_secret_key = SecretKey::bls_from_reduced_bytes(&child.secret_bytes()); - let old_hashed_secret_key = - SecretKey::bls_from_reduced_bytes(&Sha256::hash(&child.secret_bytes()).to_byte_array()); + let old_reduced_secret_key = + SecretKey::bls_from_reduced_bytes(&old_one_level_child.secret_bytes()); + + let mut attempt = 0; + let expected_secret_key = loop { + let child = xpriv + .derive_priv( + &SECP256K1, + &[ + ChildNumber::from_hardened_idx(0).unwrap(), + ChildNumber::from_hardened_idx(attempt).unwrap(), + ], + ) + .unwrap() + .private_key; + let secret_bytes = child.secret_bytes(); + + if secret_bytes.iter().all(|byte| *byte == 0) { + attempt += 1; + continue; + } + + match SecretKey::bls_from_slice(&secret_bytes) { + Ok(secret_key) => break secret_key, + Err(_) => attempt += 1, + } + }; let key_pair = keyset.keys.get(&1.into()).unwrap(); assert_eq!(key_pair.secret_key, expected_secret_key); - assert_ne!(key_pair.secret_key, old_hashed_secret_key); + assert_ne!(key_pair.secret_key, old_reduced_secret_key); } #[test] diff --git a/crates/cashu/src/nuts/nut13.rs b/crates/cashu/src/nuts/nut13.rs index 2e4800dfa0..2ef18145c0 100644 --- a/crates/cashu/src/nuts/nut13.rs +++ b/crates/cashu/src/nuts/nut13.rs @@ -41,6 +41,9 @@ pub enum Error { /// SecretKey Error #[error(transparent)] SecpError(#[from] bitcoin::secp256k1::Error), + /// BLS blinding factor derivation failed + #[error("BLS blinding factor derivation exhausted the attempt space")] + BlsDerivationExhausted, } impl Secret { @@ -120,18 +123,39 @@ impl SecretKey { } fn derive_bls(seed: &[u8], keyset_id: Id, counter: u32) -> Result { + Self::derive_bls_with_attempt(seed, keyset_id, counter).map(|(secret_key, _)| secret_key) + } + + fn derive_bls_with_attempt( + seed: &[u8], + keyset_id: Id, + counter: u32, + ) -> Result<(Self, u32), Error> { let mut message = Vec::new(); message.extend_from_slice(b"Cashu_KDF_HMAC_SHA256"); message.extend_from_slice(&keyset_id.to_bytes()); message.extend_from_slice(&(counter as u64).to_be_bytes()); message.extend_from_slice(b"\x01"); - let mut engine = HmacEngine::::new(seed); - engine.input(&message); - let hmac_result = hmac::Hmac::::from_engine(engine); - let result_bytes = hmac_result.to_byte_array(); + for attempt in 0..=u32::MAX { + let mut attempt_message = message.clone(); + attempt_message.extend_from_slice(&attempt.to_be_bytes()); + + let mut engine = HmacEngine::::new(seed); + engine.input(&attempt_message); + let hmac_result = hmac::Hmac::::from_engine(engine); + let result_bytes = hmac_result.to_byte_array(); + + if result_bytes.iter().all(|byte| *byte == 0) { + continue; + } + + if let Ok(secret_key) = Self::bls_from_slice(&result_bytes) { + return Ok((secret_key, attempt)); + } + } - Ok(Self::bls_from_reduced_bytes(&result_bytes)) + Err(Error::BlsDerivationExhausted) } } @@ -503,23 +527,25 @@ mod tests { #[test] fn test_v3_secret_derivation_vector() { - let seed = b"test seed v3 reduction"; + let seed = b"nut13 v3 test seed"; let keyset_id = - Id::from_str("02ce4c47836fd0e64f37a08254777b7fd0dedb95fc1ddd0acadf5600674c743c5d") + Id::from_str("02abd02ebc1ff44652153375162407deaf0b30e590844cca0b6e4894a08a8828dd") .unwrap(); - let counter = 2; + let counter = 3; let secret = Secret::derive(seed, keyset_id, counter).unwrap(); - let blinding_factor = SecretKey::derive_bls(seed, keyset_id, counter).unwrap(); + let (blinding_factor, accepted_attempt) = + SecretKey::derive_bls_with_attempt(seed, keyset_id, counter).unwrap(); assert_eq!( secret.to_string(), - "4729fe85ab3886ce03259ac658735ff534c9cd41b2b364d202ff497e4ee48809" + "7a45e04943504b25273e9569ab7019ab62f814dade23998c12f5f4cb1bb7978a" ); assert_eq!( blinding_factor.to_secret_hex(), - "08bb237d625b73022cd50f6fedfb660c6125b676a4819474241c264903259d2f" + "236dbcb12fc064ceeae6c5e2de7f79258374dccbf23ac0afdf72cf9eb53540c9" ); + assert_eq!(accepted_attempt, 1); } #[test] From 48fbad37d65898885300aa7200d61e61ee2084bd Mon Sep 17 00:00:00 2001 From: thesimplekid Date: Wed, 10 Jun 2026 09:15:37 +0100 Subject: [PATCH 4/7] fix: reject non-secp256k1 pubkeys in P2PK/HTLC to prevent panic --- crates/cashu/src/nuts/nut10/mod.rs | 6 ++++++ crates/cashu/src/nuts/nut11/mod.rs | 33 ++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/crates/cashu/src/nuts/nut10/mod.rs b/crates/cashu/src/nuts/nut10/mod.rs index 4cf37a6049..1b05904c4e 100644 --- a/crates/cashu/src/nuts/nut10/mod.rs +++ b/crates/cashu/src/nuts/nut10/mod.rs @@ -103,6 +103,12 @@ impl SecretData { fn check_duplicate_pubkeys(pubkeys: &[PublicKey]) -> Result<(), Error> { let mut x_coords = std::collections::HashSet::with_capacity(pubkeys.len()); for pk in pubkeys { + // P2PK/HTLC spending conditions are secp256k1-only. A non-secp key (e.g. a + // BLS point parsed from an attacker-controlled NUT-10 secret) can never + // satisfy a signature and would panic in `x_only_public_key`, so reject it. + if !matches!(pk, PublicKey::Secp256k1(_)) { + return Err(Error::NUT11(crate::nuts::nut11::Error::NonSecp256k1Pubkey)); + } if !x_coords.insert(pk.x_only_public_key().serialize()) { return Err(Error::NUT11(crate::nuts::nut11::Error::DuplicatePubkey)); } diff --git a/crates/cashu/src/nuts/nut11/mod.rs b/crates/cashu/src/nuts/nut11/mod.rs index 222d60d797..646a91c498 100644 --- a/crates/cashu/src/nuts/nut11/mod.rs +++ b/crates/cashu/src/nuts/nut11/mod.rs @@ -58,6 +58,9 @@ pub enum Error { /// Duplicate public key in multisig (same x-coordinate) #[error("Duplicate public key in multisig (same x-coordinate)")] DuplicatePubkey, + /// P2PK/HTLC spending conditions require secp256k1 public keys + #[error("P2PK/HTLC public keys must be secp256k1")] + NonSecp256k1Pubkey, /// Impossible multisig configuration: num_sigs exceeds available pubkeys #[error( "Impossible multisig: required {required} signatures but only {available} keys available" @@ -687,6 +690,36 @@ mod tests { assert!(invalid_proof.verify_p2pk().is_err()); } + #[test] + fn test_verify_p2pk_with_bls_pubkey_data_is_rejected_not_panic() { + use crate::nuts::nut01::BlsG1PublicKey; + + // An attacker-crafted P2PK secret whose `data` is a valid compressed BLS G1 + // point (48 bytes / 96 hex chars) instead of a secp256k1 key. Before the fix + // this panicked in `PublicKey::x_only_public_key`; now it must return an error. + let bls_data = PublicKey::from(BlsG1PublicKey::hash_to_curve(b"attacker point")).to_hex(); + assert_eq!(bls_data.len(), 96); + + let secret_data = crate::nuts::nut10::SecretData::new(bls_data, None::>>); + let nut10_secret = crate::nuts::nut10::Secret::new(Kind::P2PK, secret_data); + let secret: Secret = nut10_secret.try_into().unwrap(); + + let proof = Proof { + keyset_id: Id::from_str("009a1f293253e41e").unwrap(), + amount: Amount::ZERO, + secret, + c: PublicKey::from_str( + "02698c4e2b5f9534cd0687d87513c759790cf829aa5739184a3e3735471fbda904", + ) + .unwrap(), + witness: Some(Witness::P2PKWitness(P2PKWitness { signatures: vec![] })), + dleq: None, + p2pk_e: None, + }; + + assert!(proof.verify_p2pk().is_err()); + } + #[test] fn verify_multi_sig() { // Proof with 2 valid signatures to satifiy the condition From 63e0f21f9d1ae69c543e1520b4ab4d2489c118bb Mon Sep 17 00:00:00 2001 From: thesimplekid Date: Wed, 10 Jun 2026 09:41:48 +0100 Subject: [PATCH 5/7] fix: use rejection sampling for BLS batch weights per NUT-00 derive_batch_weights used modular reduction and a single-byte counter, diverging from the spec (rejection sampling against BLS_FR_ORDER with a u32_BE counter) and failing the published NUT-00 batch test vector. Use canonical BlsSecretKey::from_bytes (rejects x >= order) plus a non-zero check, and encode the counter as u32_BE. Adds the NUT-00 batch vector as a conformance test. --- crates/cashu/src/nuts/nut01/bls.rs | 59 +++++++++++++++++++++++++++--- 1 file changed, 53 insertions(+), 6 deletions(-) diff --git a/crates/cashu/src/nuts/nut01/bls.rs b/crates/cashu/src/nuts/nut01/bls.rs index c028618694..ab1e4652cc 100644 --- a/crates/cashu/src/nuts/nut01/bls.rs +++ b/crates/cashu/src/nuts/nut01/bls.rs @@ -189,16 +189,24 @@ fn derive_batch_weights( let challenge = Sha256Hash::hash(&transcript).to_byte_array(); (0..mint_pubkeys.len()) .map(|i| { - let mut counter = 0u8; + // Per NUT-00, derive each weight by rejection sampling in `Fr*`: + // h = SHA256(challenge || u32_BE(i) || u32_BE(ctr)) + // x = OS2IP(h); reject if x == 0 or x >= BLS_FR_ORDER. + // `BlsSecretKey::from_bytes` interprets `h` big-endian and only succeeds + // when x < BLS_FR_ORDER (canonical), so it rejects the out-of-range case; + // we additionally reject the zero scalar. Plain modular reduction would + // bias the distribution and diverge from the spec's deterministic weights. + let mut counter = 0u32; loop { - let mut weight_material = Vec::with_capacity(37); + let mut weight_material = Vec::with_capacity(40); weight_material.extend_from_slice(&challenge); weight_material.extend_from_slice(&(i as u32).to_be_bytes()); - weight_material.push(counter); + weight_material.extend_from_slice(&counter.to_be_bytes()); let weight = Sha256Hash::hash(&weight_material).to_byte_array(); - let scalar = BlsSecretKey::from_reduced_bytes(&weight); - if scalar.scalar() != Scalar::zero() { - return scalar; + if let Ok(scalar) = BlsSecretKey::from_bytes(&weight) { + if scalar.scalar() != Scalar::zero() { + return scalar; + } } counter = counter .checked_add(1) @@ -255,6 +263,45 @@ pub(crate) fn batch_verify_pairing( mod tests { use super::*; + fn hex_to_g1(hex: &str) -> BlsG1PublicKey { + BlsG1PublicKey::from_bytes(&crate::util::hex::decode(hex).expect("hex")).expect("g1") + } + + fn hex_to_g2(hex: &str) -> BlsG2PublicKey { + BlsG2PublicKey::from_bytes(&crate::util::hex::decode(hex).expect("hex")).expect("g2") + } + + /// NUT-00 batch verification test vector: two proofs under the same mint key + /// `K = 2·G2`. Exercises both rejection-sampling code paths: `weight_1` is + /// accepted at `ctr = 4` and `weight_2` at `ctr = 0`. + #[test] + fn test_batch_weight_derivation_nut00_vector() { + let k = hex_to_g2( + "aa4edef9c1ed7f729f520e47730a124fd70662a904ba1074728114d1031e1572c6c886f6b57ec72a6178288c47c335771638533957d540a9d2370f17cc7ed5863bc0b995b8825e0ee1ea1e1e4d00dbae81f14b0bf3611b78c952aacab827a053", + ); + let c1 = hex_to_g1( + "acebf797506a7031cef3189904715cb22792528f1ea0e6ab25341401d245539438ed97122f00e38ee6185cc20b09ba11", + ); + let c2 = hex_to_g1( + "9776497ad47a00f8a56233fb88f939b0572cf174a4c6d2446c0b1060434e305fae6845fd1f68b70376ba53ffe67f0414", + ); + let mint_pubkeys = [k, k]; + let signatures = [c1, c2]; + let messages: [&[u8]; 2] = [b"batch_proof_1", b"batch_proof_2"]; + + let weights = derive_batch_weights(&mint_pubkeys, &signatures, &messages); + + assert_eq!( + crate::util::hex::encode(weights[0].to_bytes()), + "0e7ff8be2ccb756d4ef390991bdd77eb65e8db624a2729fa1657c3cf8d7d4b55" + ); + assert_eq!( + crate::util::hex::encode(weights[1].to_bytes()), + "6d026a181a6215b233e73b121d01908a1a1eb6911955bea5130bbf2f2966554d" + ); + assert!(batch_verify_pairing(&mint_pubkeys, &signatures, &messages)); + } + #[test] fn test_reject_g1_identity_point() { let identity = G1Projective::identity().to_affine().to_compressed(); From e2e51382617231d4767436f9beea4f2a06e5161b Mon Sep 17 00:00:00 2001 From: thesimplekid Date: Wed, 10 Jun 2026 09:57:32 +0100 Subject: [PATCH 6/7] fix: rejection-sample BLS blinding factors in generate_bls generate_bls reduced 32 random bytes modulo the field order, biasing the distribution and admitting the forbidden zero scalar. Rejection-sample a canonical non-zero scalar in Fr* instead. --- crates/cashu/src/nuts/nut01/secret_key.rs | 37 +++++++++++++++++++++-- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/crates/cashu/src/nuts/nut01/secret_key.rs b/crates/cashu/src/nuts/nut01/secret_key.rs index cbc6020509..c3a40c8b63 100644 --- a/crates/cashu/src/nuts/nut01/secret_key.rs +++ b/crates/cashu/src/nuts/nut01/secret_key.rs @@ -74,10 +74,23 @@ impl SecretKey { } /// Generate random BLS scalar. + /// + /// Uses rejection sampling so the scalar is uniform over `Fr*`: a canonical + /// non-zero value strictly below the field order. Modular reduction of raw + /// bytes would bias the distribution and could yield the forbidden zero + /// blinding factor. pub fn generate_bls() -> Self { - let mut bytes = [0u8; 32]; - OsRng.fill_bytes(&mut bytes); - Self::bls_from_reduced_bytes(&bytes) + loop { + let mut bytes = [0u8; 32]; + OsRng.fill_bytes(&mut bytes); + // `bls_from_slice` only succeeds when the value is canonical (< order); + // reject the all-zero scalar so `r` is always in `Fr*`. + if bytes != [0u8; 32] { + if let Ok(secret_key) = Self::bls_from_slice(&bytes) { + return secret_key; + } + } + } } /// Get secret key as `hex` string. @@ -247,3 +260,21 @@ impl Drop for SecretKey { tracing::trace!("Secret Key dropped."); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_generate_bls_is_canonical_and_non_zero() { + for _ in 0..256 { + let key = SecretKey::generate_bls(); + let bytes = key.to_secret_bytes(); + // Non-zero. + assert_ne!(bytes, [0u8; 32]); + // Canonical: re-parsing as a canonical BLS scalar must succeed and round-trip. + let reparsed = SecretKey::bls_from_slice(&bytes).expect("canonical scalar"); + assert_eq!(reparsed.to_secret_bytes(), bytes); + } + } +} From ed4b5bec94d8a3af283bf7a0ce8c041fbecbc67c Mon Sep 17 00:00:00 2001 From: thesimplekid Date: Wed, 10 Jun 2026 09:57:32 +0100 Subject: [PATCH 7/7] test(fuzz): add BLS12-381 (v3) fuzz targets Adds two libFuzzer targets for the new pairing-based protocol: - fuzz_bls_pubkey_in_conditions: injects *valid* compressed BLS G1/G2 points into the secp256k1-only P2PK/HTLC pubkey positions (data field, pubkeys, refund_keys) and runs verify_p2pk/verify_htlc, asserting they return an error rather than panic. Random hex strings essentially never decode to a subgroup-correct BLS point, so this reaches the check_duplicate_pubkeys panic that the existing fuzz_p2pk_verify could not. Would have caught the parsing-panic DoS. - fuzz_bls_dhke: stresses panic-safety of BLS point/scalar parsing and the blind/sign/unblind/verify and batch-verify paths (including the rejection-sampling weight loop), and checks soundness invariants: honest v3 proofs verify, honest batches batch-verify, tampering fails. Adds bls_g1_pubkey_from/bls_g2_pubkey_from helpers to the fuzz lib. --- fuzz/Cargo.lock | 163 +++++++++++++++ fuzz/Cargo.toml | 17 ++ fuzz/fuzz_targets/fuzz_bls_dhke.rs | 143 +++++++++++++ .../fuzz_bls_pubkey_in_conditions.rs | 196 ++++++++++++++++++ fuzz/src/lib.rs | 22 ++ 5 files changed, 541 insertions(+) create mode 100644 fuzz/fuzz_targets/fuzz_bls_dhke.rs create mode 100644 fuzz/fuzz_targets/fuzz_bls_pubkey_in_conditions.rs diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index f849949cf6..e0a57c1de1 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -115,6 +115,38 @@ dependencies = [ "serde", ] +[[package]] +name = "block-buffer" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bls12_381" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7bc6d6292be3a19e6379786dac800f551e5865a5bb51ebbe3064ab80433f403" +dependencies = [ + "digest 0.9.0", + "ff", + "group", + "pairing", + "rand_core", + "subtle", +] + [[package]] name = "bs58" version = "0.5.1" @@ -135,14 +167,19 @@ name = "cashu" version = "0.17.0" dependencies = [ "bitcoin", + "bls12_381", "cbor-diag", "ciborium", + "ff", + "group", "lightning", "lightning-invoice", "once_cell", "serde", "serde_json", "serde_with", + "sha2 0.10.9", + "sha2 0.9.9", "strum", "strum_macros", "thiserror", @@ -247,12 +284,31 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "crunchy" version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "darling" version = "0.21.3" @@ -315,6 +371,25 @@ dependencies = [ "syn", ] +[[package]] +name = "digest" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" +dependencies = [ + "generic-array", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -344,6 +419,16 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core", + "subtle", +] + [[package]] name = "find-msvc-tools" version = "0.1.6" @@ -365,6 +450,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.16" @@ -388,6 +483,17 @@ dependencies = [ "wasip2", ] +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core", + "subtle", +] + [[package]] name = "half" version = "2.7.1" @@ -781,6 +887,21 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "pairing" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81fec4625e73cf41ef4bb6846cafa6d44736525f442ba45e407c4a000a13996f" +dependencies = [ + "group", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1031,6 +1152,30 @@ dependencies = [ "syn", ] +[[package]] +name = "sha2" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800" +dependencies = [ + "block-buffer 0.9.0", + "cfg-if", + "cpufeatures", + "digest 0.9.0", + "opaque-debug", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest 0.10.7", +] + [[package]] name = "shlex" version = "1.3.0" @@ -1073,6 +1218,12 @@ dependencies = [ "syn", ] +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.113" @@ -1200,6 +1351,12 @@ version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "unicode-ident" version = "1.0.22" @@ -1245,6 +1402,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index 91c4867f3a..d9ba4b8d5a 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -203,6 +203,23 @@ test = false doc = false bench = false +# BLS12-381 (v3) bins: cover the pairing-based protocol added for `02` +# keysets and the BLS points that can reach secp256k1-only code paths. + +[[bin]] +name = "fuzz_bls_pubkey_in_conditions" +path = "fuzz_targets/fuzz_bls_pubkey_in_conditions.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "fuzz_bls_dhke" +path = "fuzz_targets/fuzz_bls_dhke.rs" +test = false +doc = false +bench = false + # Structured bins: sibling bins that consume `arbitrary_ext` # wrappers directly. They complement -- they do not replace -- the # raw-bytes parsing bins above. diff --git a/fuzz/fuzz_targets/fuzz_bls_dhke.rs b/fuzz/fuzz_targets/fuzz_bls_dhke.rs new file mode 100644 index 0000000000..5df2199f93 --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_bls_dhke.rs @@ -0,0 +1,143 @@ +#![no_main] + +//! Robustness + soundness fuzzing for the new BLS12-381 (v3) BDHKE surface. +//! +//! The pairing-based protocol added for `02` keysets is entirely new code with +//! its own point parsing, scalar handling, and (deterministic) batch-weight +//! derivation loop. This target stresses two things: +//! +//! 1. Panic-safety of parsing arbitrary bytes as BLS G1/G2 points and of the +//! blind/sign/unblind/verify and batch-verify code paths. +//! 2. Soundness invariants: an honestly produced v3 proof must verify, and a +//! batch of honest proofs must batch-verify; tampering must not verify. +//! +//! The batch path exercises `derive_batch_weights` (rejection-sampling loop) so +//! a regression that panics or loops forever there surfaces as a crash/timeout. + +use cashu::dhke::{ + batch_verify_bls_messages, blind_message_for_version, sign_message, verify_bls_message, +}; +use cashu::nuts::nut01::BlsG1PublicKey; +use cashu::nuts::nut02::KeySetVersion; +use cashu::{PublicKey, SecretKey}; +use libfuzzer_sys::arbitrary::{Arbitrary, Unstructured}; +use libfuzzer_sys::fuzz_target; + +#[derive(Debug)] +struct Proofish { + secret: Vec, + r_bytes: [u8; 32], + mint_bytes: [u8; 32], + // Index of which mint key to use, to exercise the "group by key" batch path. + mint_idx: u8, +} + +#[derive(Debug)] +struct Input { + proofs: Vec, + // Arbitrary bytes thrown directly at the point/scalar parsers. + raw_g1: Vec, + raw_g2: Vec, + raw_scalar: [u8; 32], +} + +impl<'a> Arbitrary<'a> for Input { + fn arbitrary(u: &mut Unstructured<'a>) -> libfuzzer_sys::arbitrary::Result { + let n = u.int_in_range(0..=6)?; + let proofs = (0..n) + .map(|_| { + Ok(Proofish { + secret: Vec::::arbitrary(u)?, + r_bytes: u.arbitrary()?, + mint_bytes: u.arbitrary()?, + mint_idx: u.arbitrary()?, + }) + }) + .collect::, _>>()?; + Ok(Self { + proofs, + raw_g1: Vec::::arbitrary(u)?, + raw_g2: Vec::::arbitrary(u)?, + raw_scalar: u.arbitrary()?, + }) + } +} + +/// A valid, non-zero BLS scalar from fuzz bytes (falls back to a fixed scalar). +fn bls_scalar(bytes: &[u8; 32]) -> SecretKey { + SecretKey::bls_from_slice(bytes) + .ok() + .filter(|_| bytes != &[0u8; 32]) + .unwrap_or_else(|| SecretKey::bls_from_slice(&[1u8; 32]).expect("valid scalar")) +} + +fuzz_target!(|input: Input| { + // --- Parsers must never panic on arbitrary bytes --- + let _ = PublicKey::from_slice(&input.raw_g1); + let _ = PublicKey::from_slice(&input.raw_g2); + let _ = SecretKey::bls_from_slice(&input.raw_scalar); + // hash_to_curve must accept any byte slice. + let _ = BlsG1PublicKey::hash_to_curve(&input.raw_g1); + + if input.proofs.is_empty() { + return; + } + + // Pool of mint keys; `mint_idx` selects from it so several proofs can share + // a key, exercising the batch "group by mint key" path. + let key_pool: Vec = input.proofs.iter().map(|p| bls_scalar(&p.mint_bytes)).collect(); + + let mut mint_pubkeys: Vec = Vec::new(); + let mut signatures: Vec = Vec::new(); + let mut messages: Vec> = Vec::new(); + + for p in &input.proofs { + let r = bls_scalar(&p.r_bytes); + let mint_secret = key_pool[p.mint_idx as usize % key_pool.len()].clone(); + + // Honest v3 BDHKE round-trip: B_ = r·Y, C_ = a·B_, C = r^-1·C_. + let (blinded, returned_r) = + match blind_message_for_version(&p.secret, Some(r.clone()), KeySetVersion::Version02) { + Ok(v) => v, + Err(_) => return, + }; + // Blinding with a provided factor must return it unchanged. + assert_eq!(returned_r.to_secret_bytes(), r.to_secret_bytes()); + + let blind_sig = match sign_message(&mint_secret, &blinded) { + Ok(v) => v, + Err(_) => return, + }; + let c: PublicKey = match (|| { + let inv = r.as_bls().ok()?.invert().ok()?; + Some(blind_sig.as_bls_g1().ok()?.mul(&inv).into()) + })() { + Some(c) => c, + None => return, + }; + + // Single-proof pairing verification must accept the honest proof... + verify_bls_message(mint_secret.public_key(), c, &p.secret) + .expect("honest v3 proof must verify"); + // ...and reject a different message. + let mut wrong = p.secret.clone(); + wrong.push(0xff); + assert!(verify_bls_message(mint_secret.public_key(), c, &wrong).is_err()); + + mint_pubkeys.push(mint_secret.public_key()); + signatures.push(c); + messages.push(p.secret.clone()); + } + + // --- Batch verification: honest batch must verify --- + let msg_refs: Vec<&[u8]> = messages.iter().map(|m| m.as_slice()).collect(); + batch_verify_bls_messages(&mint_pubkeys, &signatures, &msg_refs) + .expect("honest batch must verify"); + + // --- Tampering must break the batch (swap two distinct signatures) --- + if signatures.len() >= 2 && signatures[0] != signatures[1] { + let mut tampered = signatures.clone(); + tampered.swap(0, 1); + assert!(batch_verify_bls_messages(&mint_pubkeys, &tampered, &msg_refs).is_err()); + } +}); diff --git a/fuzz/fuzz_targets/fuzz_bls_pubkey_in_conditions.rs b/fuzz/fuzz_targets/fuzz_bls_pubkey_in_conditions.rs new file mode 100644 index 0000000000..9609a87f89 --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_bls_pubkey_in_conditions.rs @@ -0,0 +1,196 @@ +#![no_main] + +//! Robustness fuzzing for BLS points smuggled into secp256k1-only positions. +//! +//! NUT-11 (P2PK) and NUT-14 (HTLC) spending conditions are secp256k1-only, but +//! `PublicKey::from_slice` accepts 48-byte (G1) and 96-byte (G2) BLS encodings. +//! A NUT-10 secret is fully attacker-controlled, so the `data` field and the +//! `pubkeys` / `refund_keys` tags can carry a *valid* compressed BLS point. +//! +//! Several `PublicKey` accessors (`x_only_public_key`, `negate`, +//! `to_uncompressed_bytes`) `panic!` on BLS variants. This target deliberately +//! injects valid BLS G1/G2 points into every pubkey-bearing position and then +//! runs the verification pipelines, asserting they return an error rather than +//! panic (libFuzzer treats any panic as a crash). +//! +//! This would have caught the `check_duplicate_pubkeys` panic: random hex +//! strings essentially never decode to a valid subgroup-correct BLS point, so +//! the existing `fuzz_p2pk_verify` `data_override` path could not reach it. + +use std::str::FromStr; + +use cashu::nuts::nut00::Witness; +use cashu::nuts::nut10::{Kind, SecretData}; +use cashu::nuts::nut11::{P2PKWitness, SigFlag}; +use cashu::nuts::Conditions; +use cashu::secret::Secret as SecretString; +use cashu::{Amount, BlindedMessage, Id, Nut10Secret, Proof, PublicKey}; +use cdk_fuzz::{bls_g1_pubkey_from, bls_g2_pubkey_from, pubkey_from}; +use libfuzzer_sys::arbitrary::{Arbitrary, Unstructured}; +use libfuzzer_sys::fuzz_target; + +/// How to encode a fuzz-chosen pubkey: as secp256k1, BLS G1, or BLS G2. +#[derive(Debug)] +enum KeyKind { + Secp, + BlsG1, + BlsG2, +} + +impl KeyKind { + fn from_byte(b: u8) -> Self { + match b % 3 { + 0 => KeyKind::Secp, + 1 => KeyKind::BlsG1, + _ => KeyKind::BlsG2, + } + } + + fn pubkey(&self, seed: [u8; 32]) -> PublicKey { + match self { + KeyKind::Secp => pubkey_from(seed), + KeyKind::BlsG1 => bls_g1_pubkey_from(&seed), + KeyKind::BlsG2 => bls_g2_pubkey_from(seed), + } + } +} + +#[derive(Debug)] +struct Input { + // Kind tag + seed for each pubkey position. At least one will usually be BLS. + data_kind: u8, + data_seed: [u8; 32], + extra: Vec<(u8, [u8; 32])>, + refund: Vec<(u8, [u8; 32])>, + is_htlc: bool, + locktime: Option, + num_sigs: Option, + num_sigs_refund: Option, + sig_flag_odd: bool, + witness_sigs: Vec, + amount: u64, + keyset_id_bytes: [u8; 8], + required_sigs: u64, +} + +impl<'a> Arbitrary<'a> for Input { + fn arbitrary(u: &mut Unstructured<'a>) -> libfuzzer_sys::arbitrary::Result { + let take_keys = |u: &mut Unstructured<'a>, + max: usize| + -> libfuzzer_sys::arbitrary::Result> { + let n = u.int_in_range(0..=max)?; + (0..n) + .map(|_| Ok((u.arbitrary::()?, u.arbitrary::<[u8; 32]>()?))) + .collect() + }; + Ok(Self { + data_kind: u.arbitrary()?, + data_seed: u.arbitrary()?, + extra: take_keys(u, 4)?, + refund: take_keys(u, 3)?, + is_htlc: u.arbitrary()?, + locktime: Option::::arbitrary(u)?, + num_sigs: Option::::arbitrary(u)?, + num_sigs_refund: Option::::arbitrary(u)?, + sig_flag_odd: u.arbitrary()?, + witness_sigs: { + let n = u.int_in_range(0..=4)?; + (0..n).map(|_| String::arbitrary(u)).collect::>()? + }, + amount: u.arbitrary()?, + keyset_id_bytes: u.arbitrary()?, + required_sigs: u.arbitrary()?, + }) + } +} + +fuzz_target!(|input: Input| { + let data_pubkey = KeyKind::from_byte(input.data_kind).pubkey(input.data_seed); + let extra_pubkeys: Vec = input + .extra + .iter() + .map(|(k, s)| KeyKind::from_byte(*k).pubkey(*s)) + .collect(); + let refund_keys: Vec = input + .refund + .iter() + .map(|(k, s)| KeyKind::from_byte(*k).pubkey(*s)) + .collect(); + + let conditions = Conditions { + locktime: input.locktime, + pubkeys: if extra_pubkeys.is_empty() { + None + } else { + Some(extra_pubkeys.clone()) + }, + refund_keys: if refund_keys.is_empty() { + None + } else { + Some(refund_keys.clone()) + }, + num_sigs: input.num_sigs, + sig_flag: if input.sig_flag_odd { + SigFlag::SigAll + } else { + SigFlag::SigInputs + }, + num_sigs_refund: input.num_sigs_refund, + }; + + let kind = if input.is_htlc { Kind::HTLC } else { Kind::P2PK }; + + // Build the data field: a HTLC needs a sha256-shaped hash, but we want to + // drive a (possibly BLS) pubkey into the P2PK `data` slot. For HTLC, put a + // valid hash; for P2PK, put the (possibly BLS) data pubkey hex. + let data_string = if input.is_htlc { + // 32-byte zero hash hex; pubkeys live in the tags for HTLC. + "00".repeat(32) + } else { + data_pubkey.to_hex() + }; + + let tags: Vec> = conditions.into(); + let secret_data = SecretData::new(data_string, Some(tags)); + let nut10 = Nut10Secret::new(kind, secret_data); + + let secret: SecretString = match nut10.try_into() { + Ok(s) => s, + Err(_) => return, + }; + + let keyset_id = Id::from_bytes(&input.keyset_id_bytes) + .unwrap_or_else(|_| Id::from_str("00deadbeef123456").expect("valid id")); + + // `c` just needs to be a well-formed point; reuse a secp key. + let c_point = pubkey_from([2u8; 32]); + + let witness = if input.is_htlc { + Witness::HTLCWitness(cashu::nuts::nut14::HTLCWitness { + preimage: "00".repeat(32), + signatures: Some(input.witness_sigs.clone()), + }) + } else { + Witness::P2PKWitness(P2PKWitness { + signatures: input.witness_sigs.clone(), + }) + }; + + let proof = Proof { + amount: Amount::from(input.amount), + keyset_id, + secret, + c: c_point, + witness: Some(witness), + dleq: None, + p2pk_e: None, + }; + + // Primary targets: the full verification pipelines must not panic. + let _ = proof.verify_p2pk(); + let _ = proof.verify_htlc(); + + // Secondary: BlindedMessage::verify_p2pk with a (possibly BLS) pubkey list. + let blinded = BlindedMessage::new(Amount::from(input.amount), keyset_id, c_point); + let _ = blinded.verify_p2pk(&extra_pubkeys, input.required_sigs); +}); diff --git a/fuzz/src/lib.rs b/fuzz/src/lib.rs index a8dea15b4a..cfb0a46dd5 100644 --- a/fuzz/src/lib.rs +++ b/fuzz/src/lib.rs @@ -8,6 +8,7 @@ pub mod arbitrary_ext; +use cashu::nuts::nut01::BlsG1PublicKey; use cashu::{PublicKey, SecretKey}; /// Deterministic `SecretKey` from 32 fuzz bytes. @@ -24,3 +25,24 @@ pub fn secret_key_from(bytes: [u8; 32]) -> SecretKey { pub fn pubkey_from(bytes: [u8; 32]) -> PublicKey { secret_key_from(bytes).public_key() } + +/// A *valid* BLS12-381 G1 `PublicKey` (compressed 48 bytes) from fuzz bytes. +/// +/// `hash_to_curve` always yields a canonical, subgroup-correct, non-identity +/// point, so this deterministically produces a parseable BLS G1 key. Used to +/// inject BLS points into positions that the protocol expects to be +/// secp256k1 (e.g. P2PK/HTLC pubkeys) — the case random hex strings would +/// effectively never hit. +pub fn bls_g1_pubkey_from(bytes: &[u8]) -> PublicKey { + BlsG1PublicKey::hash_to_curve(bytes).into() +} + +/// A *valid* BLS12-381 G2 `PublicKey` (compressed 96 bytes) from 32 fuzz bytes. +/// +/// Falls back to a fixed valid scalar when the bytes are non-canonical so the +/// result is always a parseable G2 key. +pub fn bls_g2_pubkey_from(bytes: [u8; 32]) -> PublicKey { + SecretKey::bls_from_slice(&bytes) + .unwrap_or_else(|_| SecretKey::bls_from_slice(&[1u8; 32]).expect("valid bls scalar")) + .public_key() +}