diff --git a/Cargo.lock b/Cargo.lock index 83fe8f6ffc..909047c71d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14,6 +14,7 @@ dependencies = [ "buzz-audio-client", "buzz-credential-store", "buzz-mesh-compute", + "buzz-nostr-verification", "buzz-nsec-backup", "buzz-pairing", "buzzodz-plugins", @@ -1133,6 +1134,20 @@ dependencies = [ "uuid", ] +[[package]] +name = "buzz-nostr-verification" +version = "0.0.0" +dependencies = [ + "hex", + "k256", + "nostr", + "rand_chacha 0.3.1", + "rand_core 0.6.4", + "secp256k1 0.31.1", + "serde", + "sha2 0.10.9", +] + [[package]] name = "buzz-nsec-backup" version = "0.1.0" @@ -1880,6 +1895,21 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-bigint" +version = "0.7.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "914c195c4d7fbc5ed7f88d14cbf85914d5c664967658d43404b263913a477e82" +dependencies = [ + "cpubits", + "ctutils", + "hybrid-array", + "num-traits", + "rand_core 0.10.1", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -2000,6 +2030,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "261af4dc7a06ea8a527a8717d8cbba3417530d6ea14e1edb3de6b67806be474f" dependencies = [ "cmov", + "subtle", ] [[package]] @@ -2436,6 +2467,19 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "ecdsa" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0681a4fc24c767085329728d8dfba959af91228aa4610cca4f8ce317ba46ae0" +dependencies = [ + "der 0.8.3", + "elliptic-curve", + "signature 3.0.0", + "spki 0.8.1", + "zeroize", +] + [[package]] name = "ed25519" version = "2.2.3" @@ -2493,6 +2537,25 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" +[[package]] +name = "elliptic-curve" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "900f37e7e18d74a2347389af6cd69e3a30d5cc9e697f40004865679aa75e9c7c" +dependencies = [ + "base16ct", + "crypto-bigint", + "crypto-common 0.2.2", + "ff", + "group", + "hybrid-array", + "pkcs8 0.11.0", + "rand_core 0.10.1", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "embed-resource" version = "3.0.11" @@ -2694,6 +2757,16 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "ff" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1f686ab92a9fb0eaf188f6c6c87b89490baa6fdb0db4544ba4dc47f7942489f" +dependencies = [ + "rand_core 0.10.1", + "subtle", +] + [[package]] name = "fiat-crypto" version = "0.2.9" @@ -3334,6 +3407,17 @@ dependencies = [ "system-deps", ] +[[package]] +name = "group" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4" +dependencies = [ + "ff", + "rand_core 0.10.1", + "subtle", +] + [[package]] name = "gtk" version = "0.18.2" @@ -3665,7 +3749,9 @@ version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" dependencies = [ + "subtle", "typenum", + "zeroize", ] [[package]] @@ -4471,6 +4557,19 @@ dependencies = [ "serde_json", ] +[[package]] +name = "k256" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93f50113171a713f4a4231ef82eb26703607139b35dcb56241f0ceab2ae1f7d8" +dependencies = [ + "cpubits", + "ecdsa", + "elliptic-curve", + "primeorder", + "wnaf", +] + [[package]] name = "kasuari" version = "0.4.13" @@ -7603,6 +7702,32 @@ dependencies = [ "syn 3.0.5", ] +[[package]] +name = "primefield" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c555a6e4eb7d4e158fcb028c835c3b8642206ddc279b5c6b202ef9a8bdb592f4" +dependencies = [ + "crypto-bigint", + "crypto-common 0.2.2", + "ff", + "rand_core 0.10.1", + "subtle", + "zeroize", +] + +[[package]] +name = "primeorder" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c9f42978c78a00e3d68f69fc03e57a234debae69da4020a4fb588fcdcd07b06" +dependencies = [ + "elliptic-curve", + "primefield", + "serdect", + "wnaf", +] + [[package]] name = "proc-macro-crate" version = "1.3.1" @@ -8725,6 +8850,20 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sec1" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d" +dependencies = [ + "base16ct", + "ctutils", + "der 0.8.3", + "hybrid-array", + "subtle", + "zeroize", +] + [[package]] name = "secp256k1" version = "0.30.0" @@ -12447,6 +12586,18 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "wnaf" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "795ca18b3fdb5e62bf982199278341ddcf7ebf7d32e25e212ad05d496e95f6fa" +dependencies = [ + "ff", + "group", + "hybrid-array", + "primefield", +] + [[package]] name = "writeable" version = "0.6.4" diff --git a/Cargo.toml b/Cargo.toml index adf1563e57..1424f5762e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,4 +1,4 @@ [workspace] -members = ["crates/agent-command", "crates/pairing", "crates/plugin-manager", "crates/agent-controller", "crates/credential-store", "crates/dev-credential-helper", "src/bundled/mesh-compute/compute", "crates/ifc-core", "crates/buzz-ifc", "crates/nsec-backup", "src-tauri"] +members = ["crates/agent-command", "crates/pairing", "crates/plugin-manager", "crates/agent-controller", "crates/credential-store", "crates/dev-credential-helper", "src/bundled/mesh-compute/compute", "crates/ifc-core", "crates/buzz-ifc", "crates/nsec-backup", "crates/nostr-verification", "src-tauri"] default-members = ["crates/plugin-manager"] resolver = "2" diff --git a/NOTICE.md b/NOTICE.md index e669b967e6..94f9982c46 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -1,5 +1,15 @@ # Source attribution +Batch equation accumulation in `crates/nostr-verification/src/lib.rs` is adapted +from Zcash Foundation FROST's `frost-core/src/batch.rs`, revision +[`2016e44ba4a4757a996300350063b937a2ad33e8`](https://github.com/ZcashFoundation/frost/blob/2016e44ba4a4757a996300350063b937a2ad33e8/frost-core/src/batch.rs). +Copyright 2019-2021 Zcash Foundation. Used under its Apache-2.0 license option; +the full license is preserved in [LICENSE](LICENSE). Changes specialize the +verifier for Nostr's BIP-340 encoding and k256 0.14, derive nonzero coefficients +from the complete batch transcript, combine repeated-author terms, and retain +individual libsecp256k1 fallback. No FROST dependency or threshold-signing code +is included. + The agent-card foil texture in `src/features/agents/assets/card-foil.webp`, portrait palette sampling, and reveal/hover treatment are adapted from [Block Buzz PR #5790](https://github.com/block/buzz/pull/5790), revision diff --git a/crates/nostr-verification/Cargo.toml b/crates/nostr-verification/Cargo.toml new file mode 100644 index 0000000000..19cfad45ed --- /dev/null +++ b/crates/nostr-verification/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "buzz-nostr-verification" +version = "0.0.0" +edition = "2021" +license = "Apache-2.0" +description = "Batched verification of incoming Buzz Nostr signatures" + +[dependencies] +hex = "0.4" +k256 = { version = "=0.14.0", default-features = false, features = ["arithmetic", "alloc"] } +rand_chacha = "0.3" +rand_core = "0.6" +secp256k1 = { version = "0.31", default-features = false, features = ["std"] } +serde = { version = "1", features = ["derive"] } +sha2 = "0.10" + +[dev-dependencies] +# Match the desktop's previous event-ID parser in differential tests. +nostr = { version = "=0.45.5", default-features = false, features = ["std"] } diff --git a/crates/nostr-verification/README.md b/crates/nostr-verification/README.md new file mode 100644 index 0000000000..a44b65ebe0 --- /dev/null +++ b/crates/nostr-verification/README.md @@ -0,0 +1,66 @@ +# Native Nostr signature verification + +The desktop's `relay_verify_signatures` command calls this crate on its existing +blocking worker. The input and result remain one signature claim and one boolean +per event, in order. JavaScript still validates each event and recomputes its +NIP-01 content hash before sending a claim; this crate cannot establish that an +event ID matches an event body. + +## Batch selection and correctness + +Chunks contain at most 512 claims. A chunk uses cryptographic batch verification +when it contains at least 12 claims and no more than one distinct author per +eight claims. Otherwise it retains individual libsecp256k1 checks. These are +performance thresholds, not security assumptions: both paths must reject invalid +claims. The threshold comes from local Apple Silicon measurements and should be +remeasured on other supported architectures before claiming a speedup there. + +The batch equation accumulation is adapted from Zcash Foundation FROST's +`frost-core/src/batch.rs`; [NOTICE.md](../../NOTICE.md) pins the source and license. +Signature preparation is separate from weighted equation accumulation, as in +FROST. The local adaptation combines repeated-author terms and uses +[BIP-340's batch verification coefficients](https://github.com/bitcoin/bips/blob/master/bip-0340.mediawiki#batch-verification) +instead of an external random source. It adds no FROST dependency. +It hashes every public key, message (the 32-byte event ID), and signature into a +ChaCha20 seed. The first coefficient is one; later coefficients are sampled by +rejection from the nonzero scalars below the curve order. This binds the weights +to the entire batch, including its signatures. Fixed weights would let crafted +invalid signatures cancel each other's errors. + +RustCrypto `k256` 0.14.0 performs point/scalar decoding and the variable-time +multi-scalar multiplication. Each nonce point and public key is lifted to the +unique even-Y curve point, and each signature scalar must be below the group +order. Terms for the same public key are combined. All arithmetic inputs are +public; no signing or secret-key operation uses this path. This small batching +layer and k256's newer variable-time multiplication are not claimed to be covered +by k256's historical audit. + +A successful batch accepts its signatures with BIP-340's negligible probability +of accepting an invalid batch. If decoding or the batch equation fails, every +claim in that chunk is checked individually with libsecp256k1. This preserves +valid neighbors and the existing rejection behavior. There is no recursive retry, +new queue, timer, or persistent key/proof cache. Invalid batches cost more work +than the original loop; the chunk cap bounds each fallback's work and scratch +space, not the size of the existing IPC request. + +## Correctness checks + +From the repository root, with Hermit activated: + +```sh +cargo test -p buzz-nostr-verification --locked +``` + +Tests cover the official 15 BIP-340 vectors with 32-byte messages, native-verifier +equivalence with seeded mutations and permutations, mixed authors and vector +batches, every invalid position in a batch, malformed input, +duplicates, chunk boundaries, and cancelling signature errors. The Tauri command +test exercises both a successful batch and failed-batch fallback through the real +async command. Existing frontend tests own event-body hashing, proof-cache +validation, and delivery ordering. + +The CSV fixture is the upstream `bitcoin/bips` file +`bip-0340/test-vectors.csv`, retrieved November 11, 2026, under its CC0-1.0 option. +Upstream SHA-256: `34c9d1d9c3a88d524bc80778540dc43f8306ec249a7485293063c376db851c2d`. +The vendored copy normalizes line endings to LF. +Its four variable-message-length vectors are rejected by the Nostr-only API. diff --git a/crates/nostr-verification/src/lib.rs b/crates/nostr-verification/src/lib.rs new file mode 100644 index 0000000000..84e9babe88 --- /dev/null +++ b/crates/nostr-verification/src/lib.rs @@ -0,0 +1,202 @@ +//! Incoming BIP-340 signature checks. Callers must separately validate each +//! event's shape and compare its NIP-01 content hash with its claimed ID. +//! Batching uses the BIP-340 equation over RustCrypto's k256 arithmetic; small +//! or diverse batches and failed batches use individual libsecp256k1 checks. + +// Batch accumulation adapted from Zcash Foundation's frost-core/src/batch.rs, +// revision 2016e44ba4a4757a996300350063b937a2ad33e8. +// Copyright 2019-2021 Zcash Foundation. SPDX-License-Identifier: Apache-2.0 +// Modified for BIP-340 transcript coefficients, repeated authors, and k256 0.14. +// See the root NOTICE.md for the upstream source and license. + +use k256::{ + elliptic_curve::{ + ops::{LinearCombination, Reduce}, + point::DecompactPoint, + PrimeField, + }, + AffinePoint, FieldBytes, ProjectivePoint, Scalar, +}; +use rand_chacha::ChaCha20Rng; +use rand_core::{RngCore, SeedableRng}; +use secp256k1::{schnorr::Signature, Secp256k1, VerifyOnly, XOnlyPublicKey}; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, BTreeSet}; + +/// A signature over an event ID, not evidence that the ID hashes the event. +#[derive(Clone, serde::Deserialize)] +pub struct SignatureClaim { + /// Hex-encoded 32-byte NIP-01 event ID. + pub id: String, + /// Hex-encoded 32-byte BIP-340 x-only public key. + pub pubkey: String, + /// Hex-encoded 64-byte BIP-340 signature. + pub sig: String, +} + +// Bound MSM scratch space and failed-batch work without changing IPC limits. +const MAX_BATCH: usize = 512; +const MIN_BATCH: usize = 12; + +/// Return one verdict per claim, preserving input order, including malformed +/// inputs. A successful batch has BIP-340's negligible false-accept probability; +/// a failed batch falls back once, without recursively retrying sub-batches. +pub fn verify(claims: &[SignatureClaim]) -> Vec { + let secp = Secp256k1::verification_only(); + let mut results = Vec::with_capacity(claims.len()); + for chunk in claims.chunks(MAX_BATCH) { + let valid_batch = should_batch(chunk) + && chunk + .iter() + .map(Decoded::from_wire) + .collect::>>() + .is_some_and(|decoded| batch_valid(&decoded)); + if valid_batch { + results.extend(std::iter::repeat_n(true, chunk.len())); + } else { + results.extend(chunk.iter().map(|claim| verify_one(&secp, claim))); + } + } + results +} + +fn should_batch(claims: &[SignatureClaim]) -> bool { + if claims.len() < MIN_BATCH { + return false; + } + // Native libsecp is faster for distinct authors. Only batch when merging + // repeated-author terms removes most public-key multiplications. + let mut authors = BTreeSet::new(); + for claim in claims { + authors.insert(claim.pubkey.as_str()); + if authors.len() > claims.len() / 8 { + return false; + } + } + true +} + +fn verify_one(secp: &Secp256k1, claim: &SignatureClaim) -> bool { + let (Some(id), Ok(pubkey), Ok(sig)) = ( + decode::<32>(&claim.id), + claim.pubkey.parse::(), + claim.sig.parse::(), + ) else { + return false; + }; + secp.verify_schnorr(&sig, &id, &pubkey).is_ok() +} + +fn decode(text: &str) -> Option<[u8; N]> { + let mut bytes = [0; N]; + hex::decode_to_slice(text, &mut bytes).ok()?; + Some(bytes) +} + +struct Decoded { + id: [u8; 32], + pubkey: [u8; 32], + sig: [u8; 64], +} + +impl Decoded { + fn from_wire(claim: &SignatureClaim) -> Option { + Some(Self { + id: decode(&claim.id)?, + pubkey: decode(&claim.pubkey)?, + sig: decode(&claim.sig)?, + }) + } + + // Like FROST's Item::new, prepare the signature and challenge before + // accumulating its weighted equation. Nostr uses x-only, even-Y points. + fn prepare(&self, challenge: &Sha256) -> Option { + let (r, s) = self.sig.split_at(32); + let nonce = AffinePoint::decompact(&FieldBytes::try_from(r).ok()?).into_option()?; + let response = Scalar::from_repr(FieldBytes::try_from(s).ok()?).into_option()?; + let mut hash = challenge.clone(); + hash.update(r); + hash.update(self.pubkey); + hash.update(self.id); + let challenge = FieldBytes::from(<[u8; 32]>::from(hash.finalize())); + Some(BatchItem { + nonce: nonce.into(), + response, + challenge: >::reduce(&challenge), + }) + } +} + +struct BatchItem { + nonce: ProjectivePoint, + response: Scalar, + challenge: Scalar, +} + +fn batch_valid(claims: &[Decoded]) -> bool { + let mut rng = batch_rng(claims); + let tag = Sha256::digest(b"BIP0340/challenge"); + let mut challenge = Sha256::new(); + challenge.update(tag); + challenge.update(tag); + let mut terms = Vec::with_capacity(claims.len() * 2 + 1); + let mut authors = BTreeMap::<[u8; 32], Scalar>::new(); + let mut generator_coefficient = Scalar::ZERO; + // FROST's weighted equation: -sum(z*s)G + sum(z*R) + sum(z*c*P) = 0. + // secp256k1 has cofactor 1. Combine P terms belonging to the same author. + for (index, claim) in claims.iter().enumerate() { + let Some(item) = claim.prepare(&challenge) else { + return false; + }; + let blind = if index == 0 { + Scalar::ONE + } else { + coefficient(&mut rng) + }; + generator_coefficient -= blind * item.response; + terms.push((item.nonce, blind)); + *authors.entry(claim.pubkey).or_insert(Scalar::ZERO) += blind * item.challenge; + } + for (pubkey, coefficient) in authors { + // Validate the key even when its combined coefficient is zero. + let Some(point) = AffinePoint::decompact(&FieldBytes::from(pubkey)).into_option() else { + return false; + }; + terms.push((ProjectivePoint::from(point), coefficient)); + } + terms.push((ProjectivePoint::GENERATOR, generator_coefficient)); + // Variable-time arithmetic only processes public signature material. + ProjectivePoint::lincomb_vartime(terms.as_slice()) == ProjectivePoint::IDENTITY +} + +fn batch_rng(claims: &[Decoded]) -> ChaCha20Rng { + // BIP-340 permits a CSPRNG seeded by a hash of ALL batch inputs instead of + // FROST's external RNG. Fixed-length fields make the transcript unambiguous. + // Signatures must be bound too, so forged errors cannot cancel known weights. + let mut transcript = Sha256::new(); + for claim in claims { + transcript.update(claim.pubkey); + } + for claim in claims { + transcript.update(claim.id); + } + for claim in claims { + transcript.update(claim.sig); + } + ChaCha20Rng::from_seed(transcript.finalize().into()) +} + +fn coefficient(rng: &mut ChaCha20Rng) -> Scalar { + loop { + let mut bytes = [0; 32]; + rng.fill_bytes(&mut bytes); + if let Some(scalar) = Scalar::from_repr(bytes.into()).into_option() { + if scalar != Scalar::ZERO { + return scalar; + } + } + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/nostr-verification/src/tests.rs b/crates/nostr-verification/src/tests.rs new file mode 100644 index 0000000000..e300654af8 --- /dev/null +++ b/crates/nostr-verification/src/tests.rs @@ -0,0 +1,262 @@ +use super::*; + +fn corpus(count: usize, authors: usize) -> Vec { + let secp = Secp256k1::signing_only(); + (0..count) + .map(|index| { + let secret = Sha256::digest((index % authors).to_le_bytes()); + let key = secp256k1::Keypair::from_secret_key( + &secp, + &secp256k1::SecretKey::from_byte_array(secret.into()).unwrap(), + ); + let id: [u8; 32] = Sha256::digest(index.to_le_bytes()).into(); + SignatureClaim { + id: hex::encode(id), + pubkey: key.x_only_public_key().0.to_string(), + sig: secp.sign_schnorr_no_aux_rand(&id, &key).to_string(), + } + }) + .collect() +} + +fn native(claims: &[SignatureClaim]) -> Vec { + let secp = Secp256k1::verification_only(); + claims + .iter() + .map(|claim| { + let (Ok(id), Ok(pubkey), Ok(sig)) = ( + nostr::event::EventId::from_hex(&claim.id), + claim.pubkey.parse::(), + claim.sig.parse::(), + ) else { + return false; + }; + secp.verify_schnorr(&sig, id.as_bytes(), &pubkey).is_ok() + }) + .collect() +} + +fn batch(claims: &[SignatureClaim]) -> bool { + let decoded = claims + .iter() + .map(Decoded::from_wire) + .collect::>>() + .unwrap(); + batch_valid(&decoded) +} + +#[test] +fn bip340_vectors_match_both_verifiers() { + // Official bitcoin/bips bip-0340/test-vectors.csv, retrieved 2026-11-11. + // BIP-340 code/vectors are BSD-2-Clause OR MIT OR CC0-1.0. Nostr signs + // 32-byte IDs; valid signatures over other message lengths must be rejected. + let mut tested = 0; + let neighbors = corpus(24, 1); + for row in include_str!("../tests/bip340.csv").lines().skip(1) { + let fields: Vec<_> = row.splitn(8, ',').collect(); + let expected = fields[4].len() == 64 && fields[6] == "TRUE"; + let claim = SignatureClaim { + id: fields[4].into(), + pubkey: fields[2].into(), + sig: fields[5].into(), + }; + let claims = vec![claim.clone(); 12]; + assert!(should_batch(&claims)); + if fields[4].len() == 64 { + assert_eq!(batch(&claims), expected, "batch vector {}", fields[0]); + } + assert_eq!( + native(&claims), + vec![expected; 12], + "native vector {}", + fields[0] + ); + assert_eq!(verify(&claims), vec![expected; 12], "vector {}", fields[0]); + let mut mixed = neighbors.clone(); + mixed[7] = claim; + let mut verdicts = vec![true; mixed.len()]; + verdicts[7] = expected; + assert!(should_batch(&mixed)); + assert_eq!(verify(&mixed), verdicts, "mixed vector {}", fields[0]); + tested += 1; + } + assert_eq!(tested, 19); +} + +#[test] +fn seeded_mutations_and_permutations_match_individual_verification() { + let mut rng = ChaCha20Rng::seed_from_u64(0xb1_0340); + let secp = Secp256k1::signing_only(); + // Exercise both sides of batch selection and the 512-claim chunk boundary. + for (count, authors) in [ + (11, 1), + (12, 1), + (12, 2), + (31, 3), + (32, 4), + (32, 5), + (64, 8), + (511, 4), + (512, 4), + (513, 4), + ] { + let keys: Vec<_> = (0..authors) + .map(|_| { + let mut secret = [0; 32]; + rng.fill_bytes(&mut secret); + secp256k1::Keypair::from_secret_key( + &secp, + &secp256k1::SecretKey::from_byte_array(secret).unwrap(), + ) + }) + .collect(); + let good: Vec<_> = (0..count) + .map(|_| { + let key = &keys[rng.next_u32() as usize % keys.len()]; + let mut id = [0; 32]; + rng.fill_bytes(&mut id); + SignatureClaim { + id: hex::encode(id), + pubkey: key.x_only_public_key().0.to_string(), + sig: secp.sign_schnorr_no_aux_rand(&id, key).to_string(), + } + }) + .collect(); + assert_eq!(verify(&good), vec![true; count]); + for field in 0..4 { + let mut claims = good.clone(); + let index = rng.next_u32() as usize % count; + let target = match field { + 0 => &mut claims[index].id, + 1 => &mut claims[index].pubkey, + _ => &mut claims[index].sig, + }; + if field == 3 { + target.push('é'); + } else { + let mut bytes = hex::decode(&*target).unwrap(); + let byte = rng.next_u32() as usize % bytes.len(); + bytes[byte] ^= 1 << (rng.next_u32() % 8); + *target = hex::encode(bytes); + } + for i in (1..count).rev() { + claims.swap(i, rng.next_u32() as usize % (i + 1)); + } + let expected = native(&claims); + assert_eq!(expected.iter().filter(|valid| !**valid).count(), 1); + assert_eq!( + verify(&claims), + expected, + "count={count}, authors={authors}, field={field}" + ); + } + } +} + +#[test] +fn valid_batches_match_native_across_authors_and_chunk_boundaries() { + assert!(verify(&[]).is_empty()); + for count in [1, 11, 12, 32, 128, 512, 513, 1031] { + for authors in [1, 4, count] { + let claims = corpus(count, authors); + assert!(batch(&claims), "count={count}, authors={authors}"); + assert_eq!(verify(&claims), native(&claims)); + assert!(verify(&claims).iter().all(|valid| *valid)); + } + } +} + +#[test] +fn bad_signature_at_every_position_preserves_individual_results() { + let good = corpus(32, 1); + for index in 0..good.len() { + let mut claims = good.clone(); + claims[index].id = "01".repeat(32); + let mut expected = vec![true; good.len()]; + expected[index] = false; + assert!(!batch(&claims)); + assert_eq!(native(&claims), expected); + assert_eq!(verify(&claims), expected, "position {index}"); + } +} + +#[test] +fn malformed_claims_and_invalid_chunk_do_not_poison_neighbors() { + let mut claims = corpus(1031, 1); + claims[0].id = "zz".repeat(32); + claims[20].pubkey = "é".repeat(32); + claims[511].sig.pop(); + claims[512].id = "00".repeat(32); + claims[1000].sig = "ff".repeat(64); + claims[1030].pubkey.clear(); + let mut expected = vec![true; claims.len()]; + for index in [0, 20, 511, 512, 1000, 1030] { + expected[index] = false; + } + assert_eq!(verify(&claims), expected); + assert_eq!(native(&claims), expected); +} + +#[test] +fn weighted_batch_rejects_cancelling_signature_errors() { + let mut claims = corpus(12, 1); + // Keep R, message, and key unchanged, but offset two s values by +1/-1. + // Their errors cancel under an unsafe unweighted aggregate equation. + for (index, offset) in [(0, Scalar::ONE), (11, -Scalar::ONE)] { + let mut sig = decode::<64>(&claims[index].sig).unwrap(); + let s = FieldBytes::try_from(&sig[32..]).unwrap(); + let changed = Scalar::from_repr(s).into_option().unwrap() + offset; + sig[32..].copy_from_slice(&changed.to_bytes()); + claims[index].sig = hex::encode(sig); + } + assert!(!batch(&claims)); + let expected: Vec<_> = (0..12).map(|i| i != 0 && i != 11).collect(); + assert_eq!(native(&claims), expected); + assert_eq!(verify(&claims), expected); +} + +#[test] +fn signatures_must_be_bound_into_the_coefficient_seed() { + let mut claims = corpus(12, 1); + let decoded: Vec<_> = claims + .iter() + .map(|c| Decoded::from_wire(c).unwrap()) + .collect(); + // Model an unsafe verifier whose weights commit only to keys and messages. + // An attacker can learn its last weight before choosing the forged scalars. + let mut transcript = Sha256::new(); + for claim in &decoded { + transcript.update(claim.pubkey); + } + for claim in &decoded { + transcript.update(claim.id); + } + let mut rng = ChaCha20Rng::from_seed(transcript.finalize().into()); + let mut last = Scalar::ONE; + for _ in 1..claims.len() { + last = coefficient(&mut rng); + } + // Its weighted errors would cancel: 1 * last + last * (-1) == 0. + for (index, offset) in [(0, last), (11, -Scalar::ONE)] { + let mut sig = decode::<64>(&claims[index].sig).unwrap(); + let s = Scalar::from_repr(FieldBytes::try_from(&sig[32..]).unwrap()) + .into_option() + .unwrap(); + sig[32..].copy_from_slice(&(s + offset).to_bytes()); + claims[index].sig = hex::encode(sig); + } + assert!(!batch(&claims)); + let expected: Vec<_> = (0..12).map(|i| i != 0 && i != 11).collect(); + assert_eq!(native(&claims), expected); + assert_eq!(verify(&claims), expected); +} + +#[test] +fn duplicates_and_different_signatures_over_the_same_id_are_checked() { + let mut claims = vec![corpus(1, 1).remove(0); 12]; + assert!(batch(&claims)); + assert_eq!(verify(&claims), vec![true; 12]); + claims[5].sig = "00".repeat(64); + let expected: Vec<_> = (0..12).map(|i| i != 5).collect(); + assert_eq!(verify(&claims), expected); +} diff --git a/crates/nostr-verification/tests/bip340.csv b/crates/nostr-verification/tests/bip340.csv new file mode 100644 index 0000000000..aa317a3b3d --- /dev/null +++ b/crates/nostr-verification/tests/bip340.csv @@ -0,0 +1,20 @@ +index,secret key,public key,aux_rand,message,signature,verification result,comment +0,0000000000000000000000000000000000000000000000000000000000000003,F9308A019258C31049344F85F89D5229B531C845836F99B08601F113BCE036F9,0000000000000000000000000000000000000000000000000000000000000000,0000000000000000000000000000000000000000000000000000000000000000,E907831F80848D1069A5371B402410364BDF1C5F8307B0084C55F1CE2DCA821525F66A4A85EA8B71E482A74F382D2CE5EBEEE8FDB2172F477DF4900D310536C0,TRUE, +1,B7E151628AED2A6ABF7158809CF4F3C762E7160F38B4DA56A784D9045190CFEF,DFF1D77F2A671C5F36183726DB2341BE58FEAE1DA2DECED843240F7B502BA659,0000000000000000000000000000000000000000000000000000000000000001,243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89,6896BD60EEAE296DB48A229FF71DFE071BDE413E6D43F917DC8DCF8C78DE33418906D11AC976ABCCB20B091292BFF4EA897EFCB639EA871CFA95F6DE339E4B0A,TRUE, +2,C90FDAA22168C234C4C6628B80DC1CD129024E088A67CC74020BBEA63B14E5C9,DD308AFEC5777E13121FA72B9CC1B7CC0139715309B086C960E18FD969774EB8,C87AA53824B4D7AE2EB035A2B5BBBCCC080E76CDC6D1692C4B0B62D798E6D906,7E2D58D8B3BCDF1ABADEC7829054F90DDA9805AAB56C77333024B9D0A508B75C,5831AAEED7B44BB74E5EAB94BA9D4294C49BCF2A60728D8B4C200F50DD313C1BAB745879A5AD954A72C45A91C3A51D3C7ADEA98D82F8481E0E1E03674A6F3FB7,TRUE, +3,0B432B2677937381AEF05BB02A66ECD012773062CF3FA2549E44F58ED2401710,25D1DFF95105F5253C4022F628A996AD3A0D95FBF21D468A1B33F8C160D8F517,FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF,FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF,7EB0509757E246F19449885651611CB965ECC1A187DD51B64FDA1EDC9637D5EC97582B9CB13DB3933705B32BA982AF5AF25FD78881EBB32771FC5922EFC66EA3,TRUE,test fails if msg is reduced modulo p or n +4,,D69C3509BB99E412E68B0FE8544E72837DFA30746D8BE2AA65975F29D22DC7B9,,4DF3C3F68FCC83B27E9D42C90431A72499F17875C81A599B566C9889B9696703,00000000000000000000003B78CE563F89A0ED9414F5AA28AD0D96D6795F9C6376AFB1548AF603B3EB45C9F8207DEE1060CB71C04E80F593060B07D28308D7F4,TRUE, +5,,EEFDEA4CDB677750A420FEE807EACF21EB9898AE79B9768766E4FAA04A2D4A34,,243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89,6CFF5C3BA86C69EA4B7376F31A9BCB4F74C1976089B2D9963DA2E5543E17776969E89B4C5564D00349106B8497785DD7D1D713A8AE82B32FA79D5F7FC407D39B,FALSE,public key not on the curve +6,,DFF1D77F2A671C5F36183726DB2341BE58FEAE1DA2DECED843240F7B502BA659,,243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89,FFF97BD5755EEEA420453A14355235D382F6472F8568A18B2F057A14602975563CC27944640AC607CD107AE10923D9EF7A73C643E166BE5EBEAFA34B1AC553E2,FALSE,has_even_y(R) is false +7,,DFF1D77F2A671C5F36183726DB2341BE58FEAE1DA2DECED843240F7B502BA659,,243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89,1FA62E331EDBC21C394792D2AB1100A7B432B013DF3F6FF4F99FCB33E0E1515F28890B3EDB6E7189B630448B515CE4F8622A954CFE545735AAEA5134FCCDB2BD,FALSE,negated message +8,,DFF1D77F2A671C5F36183726DB2341BE58FEAE1DA2DECED843240F7B502BA659,,243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89,6CFF5C3BA86C69EA4B7376F31A9BCB4F74C1976089B2D9963DA2E5543E177769961764B3AA9B2FFCB6EF947B6887A226E8D7C93E00C5ED0C1834FF0D0C2E6DA6,FALSE,negated s value +9,,DFF1D77F2A671C5F36183726DB2341BE58FEAE1DA2DECED843240F7B502BA659,,243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89,0000000000000000000000000000000000000000000000000000000000000000123DDA8328AF9C23A94C1FEECFD123BA4FB73476F0D594DCB65C6425BD186051,FALSE,sG - eP is infinite. Test fails in single verification if has_even_y(inf) is defined as true and x(inf) as 0 +10,,DFF1D77F2A671C5F36183726DB2341BE58FEAE1DA2DECED843240F7B502BA659,,243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89,00000000000000000000000000000000000000000000000000000000000000017615FBAF5AE28864013C099742DEADB4DBA87F11AC6754F93780D5A1837CF197,FALSE,sG - eP is infinite. Test fails in single verification if has_even_y(inf) is defined as true and x(inf) as 1 +11,,DFF1D77F2A671C5F36183726DB2341BE58FEAE1DA2DECED843240F7B502BA659,,243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89,4A298DACAE57395A15D0795DDBFD1DCB564DA82B0F269BC70A74F8220429BA1D69E89B4C5564D00349106B8497785DD7D1D713A8AE82B32FA79D5F7FC407D39B,FALSE,sig[0:32] is not an X coordinate on the curve +12,,DFF1D77F2A671C5F36183726DB2341BE58FEAE1DA2DECED843240F7B502BA659,,243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89,FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F69E89B4C5564D00349106B8497785DD7D1D713A8AE82B32FA79D5F7FC407D39B,FALSE,sig[0:32] is equal to field size +13,,DFF1D77F2A671C5F36183726DB2341BE58FEAE1DA2DECED843240F7B502BA659,,243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89,6CFF5C3BA86C69EA4B7376F31A9BCB4F74C1976089B2D9963DA2E5543E177769FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141,FALSE,sig[32:64] is equal to curve order +14,,FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC30,,243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89,6CFF5C3BA86C69EA4B7376F31A9BCB4F74C1976089B2D9963DA2E5543E17776969E89B4C5564D00349106B8497785DD7D1D713A8AE82B32FA79D5F7FC407D39B,FALSE,public key is not a valid X coordinate because it exceeds the field size +15,0340034003400340034003400340034003400340034003400340034003400340,778CAA53B4393AC467774D09497A87224BF9FAB6F6E68B23086497324D6FD117,0000000000000000000000000000000000000000000000000000000000000000,,71535DB165ECD9FBBC046E5FFAEA61186BB6AD436732FCCC25291A55895464CF6069CE26BF03466228F19A3A62DB8A649F2D560FAC652827D1AF0574E427AB63,TRUE,message of size 0 (added 2022-12) +16,0340034003400340034003400340034003400340034003400340034003400340,778CAA53B4393AC467774D09497A87224BF9FAB6F6E68B23086497324D6FD117,0000000000000000000000000000000000000000000000000000000000000000,11,08A20A0AFEF64124649232E0693C583AB1B9934AE63B4C3511F3AE1134C6A303EA3173BFEA6683BD101FA5AA5DBC1996FE7CACFC5A577D33EC14564CEC2BACBF,TRUE,message of size 1 (added 2022-12) +17,0340034003400340034003400340034003400340034003400340034003400340,778CAA53B4393AC467774D09497A87224BF9FAB6F6E68B23086497324D6FD117,0000000000000000000000000000000000000000000000000000000000000000,0102030405060708090A0B0C0D0E0F1011,5130F39A4059B43BC7CAC09A19ECE52B5D8699D1A71E3C52DA9AFDB6B50AC370C4A482B77BF960F8681540E25B6771ECE1E5A37FD80E5A51897C5566A97EA5A5,TRUE,message of size 17 (added 2022-12) +18,0340034003400340034003400340034003400340034003400340034003400340,778CAA53B4393AC467774D09497A87224BF9FAB6F6E68B23086497324D6FD117,0000000000000000000000000000000000000000000000000000000000000000,99999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999,403B12B0D8555A344175EA7EC746566303321E5DBFA8BE6F091635163ECA79A8585ED3E3170807E7C03B720FC54C7B23897FCBA0E9D0B4A06894CFD249F22367,TRUE,message of size 100 (added 2022-12) diff --git a/docs/relay-queries.md b/docs/relay-queries.md index 92c17ea6c8..d7689c94a4 100644 --- a/docs/relay-queries.md +++ b/docs/relay-queries.md @@ -841,9 +841,11 @@ a fresh hash of signed fields and the exact previously verified signature. It still returns newly owned frozen data and performs every HTTP read and access check. It is not an event/query cache or authorization proof. Live subscriptions retain up to 16,384 proofs across reconnects; direct `eventDto` calls are uncached. -Desktop finite reads and live events send signature batches to the existing Rust -libsecp256k1 verifier off the renderer thread. JavaScript still checks wire shape -and content hashes. Live EVENT/EOSE/CLOSED delivery stays ordered, with bounded +Desktop finite reads and live events send signature batches to Rust off the +renderer thread. The [native verifier](../crates/nostr-verification/README.md) +uses BIP-340 batch verification for groups with enough repeated authors, and +individual libsecp256k1 checks otherwise or when a batch fails. JavaScript still +checks wire shape and content hashes. Live EVENT/EOSE/CLOSED delivery stays ordered, with bounded pending work; overflow or host failure reconnects from before unprocessed frames. Browser and broker callers retain JavaScript signature verification. `event-proof.test.ts` covers tampering, diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ee45a13bf2..6f5df03d56 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -26,6 +26,7 @@ url = "2" tauri-build = { version = "2.7.2", features = [] } [dependencies] +buzz-nostr-verification = { path = "../crates/nostr-verification" } buzz-mesh-compute = { path = "../src/bundled/mesh-compute/compute" } buzz-nsec-backup = { path = "../crates/nsec-backup" } buzz-pairing = { path = "../crates/pairing" } diff --git a/src-tauri/src/relay.rs b/src-tauri/src/relay.rs index 1ac49d1b0c..a71317acf4 100644 --- a/src-tauri/src/relay.rs +++ b/src-tauri/src/relay.rs @@ -11,6 +11,7 @@ pub(crate) use agent::{ relay_agent_resolve, }; use base64::{engine::general_purpose::STANDARD, Engine}; +pub(crate) use buzz_nostr_verification::SignatureClaim; pub(crate) use huddle::{huddle_close, huddle_open, huddle_pcm, huddle_touch, Huddles}; use serde::Serialize; use sha2::{Digest, Sha256}; @@ -714,35 +715,12 @@ async fn admit_app_data( Ok(()) } -/// An incoming event's signature claim: the webview checks shape and that `id` -/// hashes the event, then asks libsecp256k1 whether `sig` signs `id` for `pubkey`. -#[derive(serde::Deserialize)] -pub(crate) struct SignatureClaim { - id: String, - pubkey: String, - sig: String, -} - /// BIP-340 checks off the webview's main thread; one result per claim, in order. #[tauri::command] pub(crate) async fn relay_verify_signatures(claims: Vec) -> Result> { - tauri::async_runtime::spawn_blocking(move || { - let secp = secp256k1::Secp256k1::verification_only(); - claims.iter().map(|claim| signs(&secp, claim)).collect() - }) - .await - .map_err(|_| "Signature verification could not complete".into()) -} - -fn signs(secp: &secp256k1::Secp256k1, claim: &SignatureClaim) -> bool { - let (Ok(id), Ok(pubkey), Ok(sig)) = ( - nostr::event::EventId::from_hex(&claim.id), - claim.pubkey.parse::(), - claim.sig.parse::(), - ) else { - return false; - }; - secp.verify_schnorr(&sig, id.as_bytes(), &pubkey).is_ok() + tauri::async_runtime::spawn_blocking(move || buzz_nostr_verification::verify(&claims)) + .await + .map_err(|_| "Signature verification could not complete".into()) } fn verify_signature(event: &serde_json::Value) -> Result<()> { diff --git a/src-tauri/src/relay/tests.rs b/src-tauri/src/relay/tests.rs index 3d344cf422..1f1757cb3c 100644 --- a/src-tauri/src/relay/tests.rs +++ b/src-tauri/src/relay/tests.rs @@ -4460,6 +4460,19 @@ async fn native_signature_checks_reject_tampered_and_malformed_claims() { sig: sig.into(), }; let sig = secp.sign_schnorr_no_aux_rand(&digest, &keys).to_string(); + // Exercise the repeated-author batch through the production async command. + assert_eq!( + relay_verify_signatures((0..12).map(|_| claim(digest, &sig)).collect()) + .await + .unwrap(), + [true; 12] + ); + let mut batch: Vec<_> = (0..12).map(|_| claim(digest, &sig)).collect(); + batch[6] = claim([8; 32], &sig); + assert_eq!( + relay_verify_signatures(batch).await.unwrap(), + (0..12).map(|index| index != 6).collect::>() + ); let checked = relay_verify_signatures(vec![ claim(digest, &sig), claim([8; 32], &sig), diff --git a/src/features/relay/transport.ts b/src/features/relay/transport.ts index f7558cf6e4..49f2e16217 100644 --- a/src/features/relay/transport.ts +++ b/src/features/relay/transport.ts @@ -268,7 +268,7 @@ export interface Signer { upload?(file: File, signal: AbortSignal): Promise; /** Native hosts serve relay `/media/` URLs through an authenticated proxy. */ media?(url: string): string; - /** Native hosts check incoming signatures with libsecp256k1, off the main thread. */ + /** Native hosts check incoming BIP-340 signatures off the main thread. */ verifySignatures?: SignatureVerifier; }