From 437a94d215e381d5e0c377e045051824291be59c Mon Sep 17 00:00:00 2001 From: Jordan Mecom Date: Wed, 11 Nov 2026 15:36:37 -0800 Subject: [PATCH 1/4] perf(relay): batch repeated-author Nostr signature verification Co-authored-by: Codex Signed-off-by: Jordan Mecom --- Cargo.lock | 151 ++++++++++++++ Cargo.toml | 2 +- crates/nostr-verification/Cargo.toml | 23 +++ crates/nostr-verification/README.md | 112 +++++++++++ crates/nostr-verification/benches/verify.rs | 110 ++++++++++ .../m4-max-2026-11-11-samples.csv | 190 ++++++++++++++++++ .../measurements/m4-max-2026-11-11.csv | 28 +++ crates/nostr-verification/src/lib.rs | 181 +++++++++++++++++ crates/nostr-verification/src/tests.rs | 186 +++++++++++++++++ crates/nostr-verification/tests/bip340.csv | 20 ++ docs/relay-queries.md | 8 +- src-tauri/Cargo.toml | 1 + src-tauri/src/relay.rs | 30 +-- src-tauri/src/relay/tests.rs | 13 ++ src/features/relay/transport.ts | 2 +- 15 files changed, 1026 insertions(+), 31 deletions(-) create mode 100644 crates/nostr-verification/Cargo.toml create mode 100644 crates/nostr-verification/README.md create mode 100644 crates/nostr-verification/benches/verify.rs create mode 100644 crates/nostr-verification/measurements/m4-max-2026-11-11-samples.csv create mode 100644 crates/nostr-verification/measurements/m4-max-2026-11-11.csv create mode 100644 crates/nostr-verification/src/lib.rs create mode 100644 crates/nostr-verification/src/tests.rs create mode 100644 crates/nostr-verification/tests/bip340.csv 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/crates/nostr-verification/Cargo.toml b/crates/nostr-verification/Cargo.toml new file mode 100644 index 0000000000..b7957ed5de --- /dev/null +++ b/crates/nostr-verification/Cargo.toml @@ -0,0 +1,23 @@ +[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/benchmarks. +nostr = { version = "=0.45.5", default-features = false, features = ["std"] } + +[[bench]] +name = "verify" +harness = false diff --git a/crates/nostr-verification/README.md b/crates/nostr-verification/README.md new file mode 100644 index 0000000000..2b8d179ae2 --- /dev/null +++ b/crates/nostr-verification/README.md @@ -0,0 +1,112 @@ +# 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 path follows [BIP-340's batch verification algorithm](https://github.com/bitcoin/bips/blob/master/bip-0340.mediawiki#batch-verification). +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. + +## Validation and benchmark + +From the repository root, with Hermit activated: + +```sh +cargo test -p buzz-nostr-verification --locked +cargo bench -p buzz-nostr-verification --bench verify --locked +``` + +Tests cover the official 15 BIP-340 vectors with 32-byte messages, native-verifier +equivalence, mixed authors, 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 later variable-message-length vectors do not apply to Nostr's 32-byte IDs. + +The benchmark compares the production function with the previous desktop loop, +including its `nostr::EventId` parser, signature/public-key parsing, context +creation, and ordered result allocation. Setup/signing is outside the timer. +Each case warms both paths four times, then alternates their order over seven +rounds, with at least 16 calls and approximately 4,096 signatures per round. +Standard output contains medians in microseconds per call and the old/new time +ratio; standard error records every sample. Ratios above one mean a speedup. + +The cases include isolated events, repeated and distinct authors, both sides of +the selection threshold, and one or many invalid signatures. These measurements +exclude JSON, Tauri IPC, event-body hashing, network latency, and rendering. They +measure native verification, not channel-load or app responsiveness. + +## Local results, November 11, 2026 + +Apple M4 Max, arm64, macOS 26.7.2, Rust 1.98.1, Cargo release defaults. The +measured checkout was based on `b9c2edbb74d5daf2f4642d0b56d975645ed1ac5c`. +No other task-owned tests or builds ran during measurement. Medians per call: + +| Signatures | Authors | Previous loop | Adaptive verifier | Speedup | +| ---: | ---: | ---: | ---: | ---: | +| 12 | 1 | 181.5 µs | 145.7 µs | 1.25× | +| 32 | 4 | 481.5 µs | 381.0 µs | 1.26× | +| 128 | 4 | 1,996.0 µs | 1,423.6 µs | 1.40× | +| 512 | 4 | 8,203.6 µs | 5,847.8 µs | 1.40× | +| 512 | 64 (batch cutoff) | 8,091.5 µs | 6,444.4 µs | 1.26× | +| 512 | 512 (individual path) | 8,129.2 µs | 8,197.5 µs | 0.99× | + +The repeated-author cases save approximately 20–29% of native verification time. +Invalid-signature batches take approximately 1.7–1.8× the previous time because +they perform both the batch attempt and individual fallback. For example, 512 +same-author claims with one invalid signature take 14.03 ms versus 8.10 ms. +Isolated and diverse-author cases remain within about 1% of the original loop. + +The [complete medians](measurements/m4-max-2026-11-11.csv) and +[individual samples](measurements/m4-max-2026-11-11-samples.csv) include threshold +boundaries and invalid traffic. Measured source SHA-256 values: + +- `src/lib.rs`: `cedcd8b7d410f9df6d6b5e7574da1764a63c91ac1ffc8509db0582ff32a2f01a` +- `benches/verify.rs`: `decaf1516daa4f799b14516c5c02fd57e1c41dbf61badb404b30cb13834f9a6c` + +All seven crypto tests, the focused native command test, and 285 frontend +transport tests passed. Native/package Clippy and formatting checks passed. +Negative controls replacing coefficients with ones or omitting signatures from +the coefficient seed each make the corresponding forgery test fail. The full desktop suite did not pass: its +`host_command::tests::stdin_is_exact_data_and_output_is_drained_concurrently` +test failed, and it later received SIGSEGV in the macOS +`window_state::tests::onboarding_snapshot_is_kept_until_next_launch_restore` +test (`MockRuntime` passed a dummy view to `ns_window`). Those tests were not +changed. No live desktop UI timing or other-architecture measurement was run. diff --git a/crates/nostr-verification/benches/verify.rs b/crates/nostr-verification/benches/verify.rs new file mode 100644 index 0000000000..d8799705cb --- /dev/null +++ b/crates/nostr-verification/benches/verify.rs @@ -0,0 +1,110 @@ +//! Compare the production verifier with the previous native loop, including +//! hex decoding, public-key parsing, context creation, and ordered results. +//! Signing/setup, JSON, Tauri IPC, event hashing, and rendering are excluded. +use buzz_nostr_verification::{verify, SignatureClaim}; +use secp256k1::{schnorr::Signature, Keypair, Secp256k1, SecretKey, XOnlyPublicKey}; +use sha2::{Digest, Sha256}; +use std::{hint::black_box, time::Instant}; + +fn original(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 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 = Keypair::from_secret_key( + &secp, + &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 time(f: fn(&[SignatureClaim]) -> Vec, claims: &[SignatureClaim], repeats: usize) -> f64 { + let start = Instant::now(); + for _ in 0..repeats { + black_box(f(black_box(claims))); + } + start.elapsed().as_secs_f64() * 1e6 / repeats as f64 +} + +fn measure(claims: &[SignatureClaim], authors: usize, invalid: usize) { + assert_eq!(verify(claims), original(claims)); + for _ in 0..4 { + black_box(verify(claims)); + black_box(original(claims)); + } + let repeats = (4096 / claims.len()).max(16); + let mut old = Vec::new(); + let mut new = Vec::new(); + for round in 0..7 { + if round % 2 == 0 { + old.push(time(original, claims, repeats)); + new.push(time(verify, claims, repeats)); + } else { + new.push(time(verify, claims, repeats)); + old.push(time(original, claims, repeats)); + } + eprintln!( + "sample,{},{authors},{invalid},{round},{:.3},{:.3}", + claims.len(), + old[round], + new[round] + ); + } + old.sort_by(f64::total_cmp); + new.sort_by(f64::total_cmp); + println!( + "{},{authors},{invalid},{:.3},{:.3},{:.3}", + claims.len(), + old[3], + new[3], + old[3] / new[3] + ); +} + +fn main() { + println!("signatures,authors,invalid,individual_us,adaptive_us,speedup"); + for count in [1, 4, 12, 32, 128, 512] { + let mut author_counts = vec![1, 4, count / 8, count / 8 + 1, count]; + author_counts.sort_unstable(); + author_counts.dedup(); + for authors in author_counts { + if authors == 0 || authors > count { + continue; + } + measure(&corpus(count, authors), authors, 0); + } + } + for count in [12, 128, 512] { + for bad in [1, count / 2] { + let mut claims = corpus(count, 1); + for claim in &mut claims[..bad] { + claim.id = "00".repeat(32); + } + measure(&claims, 1, bad); + } + } +} diff --git a/crates/nostr-verification/measurements/m4-max-2026-11-11-samples.csv b/crates/nostr-verification/measurements/m4-max-2026-11-11-samples.csv new file mode 100644 index 0000000000..0f4670284c --- /dev/null +++ b/crates/nostr-verification/measurements/m4-max-2026-11-11-samples.csv @@ -0,0 +1,190 @@ +kind,signatures,authors,invalid,round,individual_us,adaptive_us +sample,1,1,0,0,21.182,16.130 +sample,1,1,0,1,16.115,16.139 +sample,1,1,0,2,16.091,16.112 +sample,1,1,0,3,16.113,16.145 +sample,1,1,0,4,16.103,16.145 +sample,1,1,0,5,16.122,16.110 +sample,1,1,0,6,16.093,16.134 +sample,4,1,0,0,60.911,60.984 +sample,4,1,0,1,60.761,60.920 +sample,4,1,0,2,60.837,60.897 +sample,4,1,0,3,60.739,60.835 +sample,4,1,0,4,60.763,60.765 +sample,4,1,0,5,60.737,60.844 +sample,4,1,0,6,60.714,62.559 +sample,4,4,0,0,60.448,60.356 +sample,4,4,0,1,60.356,60.516 +sample,4,4,0,2,60.398,60.321 +sample,4,4,0,3,60.280,60.752 +sample,4,4,0,4,60.112,60.474 +sample,4,4,0,5,60.326,60.832 +sample,4,4,0,6,60.145,60.571 +sample,12,1,0,0,179.192,145.698 +sample,12,1,0,1,180.461,144.977 +sample,12,1,0,2,179.441,145.696 +sample,12,1,0,3,185.515,144.877 +sample,12,1,0,4,186.065,149.009 +sample,12,1,0,5,181.577,150.505 +sample,12,1,0,6,181.529,145.838 +sample,12,2,0,0,181.324,180.158 +sample,12,2,0,1,179.222,179.516 +sample,12,2,0,2,179.569,179.802 +sample,12,2,0,3,179.777,179.794 +sample,12,2,0,4,179.836,179.501 +sample,12,2,0,5,180.928,179.181 +sample,12,2,0,6,179.879,179.495 +sample,12,4,0,0,179.430,180.222 +sample,12,4,0,1,179.870,181.309 +sample,12,4,0,2,180.391,180.352 +sample,12,4,0,3,179.620,179.569 +sample,12,4,0,4,180.663,180.538 +sample,12,4,0,5,180.121,179.896 +sample,12,4,0,6,179.488,180.697 +sample,12,12,0,0,178.172,179.158 +sample,12,12,0,1,179.774,178.896 +sample,12,12,0,2,178.384,178.668 +sample,12,12,0,3,177.236,178.800 +sample,12,12,0,4,178.424,179.355 +sample,12,12,0,5,178.353,179.125 +sample,12,12,0,6,178.524,178.697 +sample,32,1,0,0,479.438,351.277 +sample,32,1,0,1,479.727,353.645 +sample,32,1,0,2,478.407,351.188 +sample,32,1,0,3,479.409,349.202 +sample,32,1,0,4,476.965,350.840 +sample,32,1,0,5,477.841,350.112 +sample,32,1,0,6,481.660,356.359 +sample,32,4,0,0,482.306,381.033 +sample,32,4,0,1,482.054,380.423 +sample,32,4,0,2,481.537,381.923 +sample,32,4,0,3,480.698,380.915 +sample,32,4,0,4,481.045,381.872 +sample,32,4,0,5,481.467,379.459 +sample,32,4,0,6,479.410,381.362 +sample,32,5,0,0,477.294,479.250 +sample,32,5,0,1,477.504,478.853 +sample,32,5,0,2,478.148,480.015 +sample,32,5,0,3,481.733,478.387 +sample,32,5,0,4,479.744,480.380 +sample,32,5,0,5,477.846,479.558 +sample,32,5,0,6,477.658,476.665 +sample,32,32,0,0,480.209,478.905 +sample,32,32,0,1,479.010,478.414 +sample,32,32,0,2,479.288,478.652 +sample,32,32,0,3,479.883,480.255 +sample,32,32,0,4,482.061,480.419 +sample,32,32,0,5,478.509,480.548 +sample,32,32,0,6,476.338,480.367 +sample,128,1,0,0,1944.132,1381.384 +sample,128,1,0,1,1959.454,1368.202 +sample,128,1,0,2,1942.940,1381.880 +sample,128,1,0,3,1983.906,1368.917 +sample,128,1,0,4,1946.383,1384.931 +sample,128,1,0,5,1996.451,1371.993 +sample,128,1,0,6,1953.454,1411.935 +sample,128,4,0,0,1978.285,1418.814 +sample,128,4,0,1,2011.288,1408.068 +sample,128,4,0,2,2041.335,1460.983 +sample,128,4,0,3,2006.352,1423.572 +sample,128,4,0,4,1973.784,1436.035 +sample,128,4,0,5,1986.167,1421.220 +sample,128,4,0,6,1996.036,1430.372 +sample,128,16,0,0,2003.281,1551.594 +sample,128,16,0,1,1990.176,1547.620 +sample,128,16,0,2,1957.801,1551.780 +sample,128,16,0,3,1958.799,1530.660 +sample,128,16,0,4,1957.270,1525.978 +sample,128,16,0,5,1986.967,1525.551 +sample,128,16,0,6,1940.388,1527.286 +sample,128,17,0,0,1936.332,1945.224 +sample,128,17,0,1,1951.716,1948.759 +sample,128,17,0,2,1948.682,1943.819 +sample,128,17,0,3,1937.438,1946.697 +sample,128,17,0,4,1956.128,2008.104 +sample,128,17,0,5,1990.844,1974.156 +sample,128,17,0,6,1987.543,1959.082 +sample,128,128,0,0,1942.568,1953.234 +sample,128,128,0,1,1953.292,1949.020 +sample,128,128,0,2,1948.986,1951.734 +sample,128,128,0,3,1962.286,1982.729 +sample,128,128,0,4,2025.021,2031.357 +sample,128,128,0,5,1975.923,2054.206 +sample,128,128,0,6,1950.958,1949.069 +sample,512,1,0,0,8162.477,5885.513 +sample,512,1,0,1,8110.758,5783.156 +sample,512,1,0,2,8077.771,5768.943 +sample,512,1,0,3,8102.727,5710.922 +sample,512,1,0,4,8099.862,5717.185 +sample,512,1,0,5,8134.393,5665.255 +sample,512,1,0,6,8068.635,5777.646 +sample,512,4,0,0,8098.362,5802.880 +sample,512,4,0,1,8215.760,5789.557 +sample,512,4,0,2,8203.596,5924.607 +sample,512,4,0,3,8223.685,5889.201 +sample,512,4,0,4,8239.716,5847.773 +sample,512,4,0,5,8158.482,5857.760 +sample,512,4,0,6,8170.898,5811.568 +sample,512,64,0,0,8090.148,6449.911 +sample,512,64,0,1,8120.190,6461.388 +sample,512,64,0,2,8055.690,6414.250 +sample,512,64,0,3,8091.539,6375.383 +sample,512,64,0,4,8089.050,6444.393 +sample,512,64,0,5,8506.281,6375.294 +sample,512,64,0,6,8213.500,6483.815 +sample,512,65,0,0,8096.125,8214.591 +sample,512,65,0,1,8156.464,8234.849 +sample,512,65,0,2,8152.857,8187.075 +sample,512,65,0,3,8143.125,8186.964 +sample,512,65,0,4,8100.411,8142.289 +sample,512,65,0,5,8128.630,8164.026 +sample,512,65,0,6,8112.159,8171.466 +sample,512,512,0,0,8116.557,8186.570 +sample,512,512,0,1,8169.911,8215.419 +sample,512,512,0,2,8142.047,8226.589 +sample,512,512,0,3,8129.224,8347.820 +sample,512,512,0,4,8114.779,8197.049 +sample,512,512,0,5,8130.398,8173.089 +sample,512,512,0,6,8125.729,8197.451 +sample,12,1,1,0,180.018,323.723 +sample,12,1,1,1,180.069,325.138 +sample,12,1,1,2,179.748,325.159 +sample,12,1,1,3,180.181,325.229 +sample,12,1,1,4,180.025,330.152 +sample,12,1,1,5,184.696,331.190 +sample,12,1,1,6,180.229,325.305 +sample,12,1,6,0,179.169,328.554 +sample,12,1,6,1,180.288,324.964 +sample,12,1,6,2,180.111,326.671 +sample,12,1,6,3,180.552,325.947 +sample,12,1,6,4,179.182,333.077 +sample,12,1,6,5,180.127,337.489 +sample,12,1,6,6,180.129,325.613 +sample,128,1,1,0,1942.184,3375.026 +sample,128,1,1,1,2284.806,3671.582 +sample,128,1,1,2,2045.533,3407.909 +sample,128,1,1,3,1960.104,3408.254 +sample,128,1,1,4,1955.227,3375.967 +sample,128,1,1,5,1960.630,3373.086 +sample,128,1,1,6,1967.133,3386.960 +sample,128,1,64,0,1949.026,3402.682 +sample,128,1,64,1,1974.268,3395.740 +sample,128,1,64,2,1943.294,3377.315 +sample,128,1,64,3,1938.751,3360.775 +sample,128,1,64,4,1942.415,3354.883 +sample,128,1,64,5,1948.275,3370.066 +sample,128,1,64,6,1954.081,3370.732 +sample,512,1,1,0,8086.977,14003.016 +sample,512,1,1,1,8115.021,14003.086 +sample,512,1,1,2,8180.857,14056.646 +sample,512,1,1,3,8112.625,14028.938 +sample,512,1,1,4,8096.417,14071.534 +sample,512,1,1,5,8091.648,14033.175 +sample,512,1,1,6,8086.320,14560.333 +sample,512,1,256,0,8143.081,14032.349 +sample,512,1,256,1,8100.789,14016.297 +sample,512,1,256,2,8116.919,13904.219 +sample,512,1,256,3,8082.828,14023.057 +sample,512,1,256,4,8029.044,14008.617 +sample,512,1,256,5,8100.414,14068.200 +sample,512,1,256,6,8184.203,14014.224 diff --git a/crates/nostr-verification/measurements/m4-max-2026-11-11.csv b/crates/nostr-verification/measurements/m4-max-2026-11-11.csv new file mode 100644 index 0000000000..d7bc4b7db8 --- /dev/null +++ b/crates/nostr-verification/measurements/m4-max-2026-11-11.csv @@ -0,0 +1,28 @@ +signatures,authors,invalid,individual_us,adaptive_us,speedup +1,1,0,16.113,16.134,0.999 +4,1,0,60.761,60.897,0.998 +4,4,0,60.326,60.516,0.997 +12,1,0,181.529,145.698,1.246 +12,2,0,179.836,179.516,1.002 +12,4,0,179.870,180.352,0.997 +12,12,0,178.384,178.896,0.997 +32,1,0,479.409,351.188,1.365 +32,4,0,481.467,381.033,1.264 +32,5,0,477.846,479.250,0.997 +32,32,0,479.288,480.255,0.998 +128,1,0,1953.454,1381.384,1.414 +128,4,0,1996.036,1423.572,1.402 +128,16,0,1958.799,1530.660,1.280 +128,17,0,1951.716,1948.759,1.002 +128,128,0,1953.292,1953.234,1.000 +512,1,0,8102.727,5768.943,1.405 +512,4,0,8203.596,5847.773,1.403 +512,64,0,8091.539,6444.393,1.256 +512,65,0,8128.630,8186.964,0.993 +512,512,0,8129.224,8197.451,0.992 +12,1,1,180.069,325.229,0.554 +12,1,6,180.127,326.671,0.551 +128,1,1,1960.630,3386.960,0.579 +128,1,64,1948.275,3370.732,0.578 +512,1,1,8096.417,14033.175,0.577 +512,1,256,8100.789,14016.297,0.578 diff --git a/crates/nostr-verification/src/lib.rs b/crates/nostr-verification/src/lib.rs new file mode 100644 index 0000000000..1f2865daec --- /dev/null +++ b/crates/nostr-verification/src/lib.rs @@ -0,0 +1,181 @@ +//! 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. + +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. See bench. + 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)?, + }) + } +} + +fn batch_valid(claims: &[Decoded]) -> bool { + // BIP-340 Batch Verification: seed a CSPRNG with a hash of ALL inputs. + // All fields have fixed lengths, so this transcript is unambiguous. Binding + // signatures too prevents adversaries from choosing cancelling errors after + // learning the weights. Never replace these coefficients with fixed 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); + } + let mut rng = ChaCha20Rng::from_seed(transcript.finalize().into()); + 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 sum = Scalar::ZERO; + for (index, claim) in claims.iter().enumerate() { + let mut r = FieldBytes::default(); + r.copy_from_slice(&claim.sig[..32]); + let Some(point) = AffinePoint::decompact(&r).into_option() else { + return false; + }; + let mut s = FieldBytes::default(); + s.copy_from_slice(&claim.sig[32..]); + let Some(s) = Scalar::from_repr(s).into_option() else { + return false; + }; + let mut hash = challenge.clone(); + hash.update(r); + hash.update(claim.pubkey); + hash.update(claim.id); + let e = >::reduce(&FieldBytes::from(<[u8; 32]>::from( + hash.finalize(), + ))); + let a = if index == 0 { + Scalar::ONE + } else { + coefficient(&mut rng) + }; + sum += a * s; + terms.push((ProjectivePoint::from(point), -a)); + *authors.entry(claim.pubkey).or_insert(Scalar::ZERO) -= a * e; + } + 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, sum)); + // Variable-time arithmetic only processes public signature material. + ProjectivePoint::lincomb_vartime(terms.as_slice()) == ProjectivePoint::IDENTITY +} + +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..f991fccdd0 --- /dev/null +++ b/crates/nostr-verification/src/tests.rs @@ -0,0 +1,186 @@ +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; the later variable-message-length vectors are out of scope. + let mut tested = 0; + for row in include_str!("../tests/bip340.csv").lines().skip(1) { + let fields: Vec<_> = row.splitn(8, ',').collect(); + if fields[4].len() != 64 { + continue; + } + let expected = fields[6] == "TRUE"; + let claim = SignatureClaim { + id: fields[4].into(), + pubkey: fields[2].into(), + sig: fields[5].into(), + }; + let claims = vec![claim; 12]; + assert!(should_batch(&claims)); + 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]); + tested += 1; + } + assert_eq!(tested, 15); +} + +#[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; } From 4b97179524692c3a2c1ef5bd715d7af3d92e90bf Mon Sep 17 00:00:00 2001 From: Jordan Mecom Date: Wed, 11 Nov 2026 15:56:51 -0800 Subject: [PATCH 2/4] chore(relay): keep signature benchmarks out of the repository Co-authored-by: Codex Signed-off-by: Jordan Mecom --- crates/nostr-verification/Cargo.toml | 6 +- crates/nostr-verification/README.md | 54 +---- crates/nostr-verification/benches/verify.rs | 110 ---------- .../m4-max-2026-11-11-samples.csv | 190 ------------------ .../measurements/m4-max-2026-11-11.csv | 28 --- crates/nostr-verification/src/lib.rs | 2 +- 6 files changed, 3 insertions(+), 387 deletions(-) delete mode 100644 crates/nostr-verification/benches/verify.rs delete mode 100644 crates/nostr-verification/measurements/m4-max-2026-11-11-samples.csv delete mode 100644 crates/nostr-verification/measurements/m4-max-2026-11-11.csv diff --git a/crates/nostr-verification/Cargo.toml b/crates/nostr-verification/Cargo.toml index b7957ed5de..19cfad45ed 100644 --- a/crates/nostr-verification/Cargo.toml +++ b/crates/nostr-verification/Cargo.toml @@ -15,9 +15,5 @@ serde = { version = "1", features = ["derive"] } sha2 = "0.10" [dev-dependencies] -# Match the desktop's previous event-ID parser in differential tests/benchmarks. +# Match the desktop's previous event-ID parser in differential tests. nostr = { version = "=0.45.5", default-features = false, features = ["std"] } - -[[bench]] -name = "verify" -harness = false diff --git a/crates/nostr-verification/README.md b/crates/nostr-verification/README.md index 2b8d179ae2..8c92c062ab 100644 --- a/crates/nostr-verification/README.md +++ b/crates/nostr-verification/README.md @@ -38,13 +38,12 @@ 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. -## Validation and benchmark +## Correctness checks From the repository root, with Hermit activated: ```sh cargo test -p buzz-nostr-verification --locked -cargo bench -p buzz-nostr-verification --bench verify --locked ``` Tests cover the official 15 BIP-340 vectors with 32-byte messages, native-verifier @@ -59,54 +58,3 @@ The CSV fixture is the upstream `bitcoin/bips` file Upstream SHA-256: `34c9d1d9c3a88d524bc80778540dc43f8306ec249a7485293063c376db851c2d`. The vendored copy normalizes line endings to LF. Its later variable-message-length vectors do not apply to Nostr's 32-byte IDs. - -The benchmark compares the production function with the previous desktop loop, -including its `nostr::EventId` parser, signature/public-key parsing, context -creation, and ordered result allocation. Setup/signing is outside the timer. -Each case warms both paths four times, then alternates their order over seven -rounds, with at least 16 calls and approximately 4,096 signatures per round. -Standard output contains medians in microseconds per call and the old/new time -ratio; standard error records every sample. Ratios above one mean a speedup. - -The cases include isolated events, repeated and distinct authors, both sides of -the selection threshold, and one or many invalid signatures. These measurements -exclude JSON, Tauri IPC, event-body hashing, network latency, and rendering. They -measure native verification, not channel-load or app responsiveness. - -## Local results, November 11, 2026 - -Apple M4 Max, arm64, macOS 26.7.2, Rust 1.98.1, Cargo release defaults. The -measured checkout was based on `b9c2edbb74d5daf2f4642d0b56d975645ed1ac5c`. -No other task-owned tests or builds ran during measurement. Medians per call: - -| Signatures | Authors | Previous loop | Adaptive verifier | Speedup | -| ---: | ---: | ---: | ---: | ---: | -| 12 | 1 | 181.5 µs | 145.7 µs | 1.25× | -| 32 | 4 | 481.5 µs | 381.0 µs | 1.26× | -| 128 | 4 | 1,996.0 µs | 1,423.6 µs | 1.40× | -| 512 | 4 | 8,203.6 µs | 5,847.8 µs | 1.40× | -| 512 | 64 (batch cutoff) | 8,091.5 µs | 6,444.4 µs | 1.26× | -| 512 | 512 (individual path) | 8,129.2 µs | 8,197.5 µs | 0.99× | - -The repeated-author cases save approximately 20–29% of native verification time. -Invalid-signature batches take approximately 1.7–1.8× the previous time because -they perform both the batch attempt and individual fallback. For example, 512 -same-author claims with one invalid signature take 14.03 ms versus 8.10 ms. -Isolated and diverse-author cases remain within about 1% of the original loop. - -The [complete medians](measurements/m4-max-2026-11-11.csv) and -[individual samples](measurements/m4-max-2026-11-11-samples.csv) include threshold -boundaries and invalid traffic. Measured source SHA-256 values: - -- `src/lib.rs`: `cedcd8b7d410f9df6d6b5e7574da1764a63c91ac1ffc8509db0582ff32a2f01a` -- `benches/verify.rs`: `decaf1516daa4f799b14516c5c02fd57e1c41dbf61badb404b30cb13834f9a6c` - -All seven crypto tests, the focused native command test, and 285 frontend -transport tests passed. Native/package Clippy and formatting checks passed. -Negative controls replacing coefficients with ones or omitting signatures from -the coefficient seed each make the corresponding forgery test fail. The full desktop suite did not pass: its -`host_command::tests::stdin_is_exact_data_and_output_is_drained_concurrently` -test failed, and it later received SIGSEGV in the macOS -`window_state::tests::onboarding_snapshot_is_kept_until_next_launch_restore` -test (`MockRuntime` passed a dummy view to `ns_window`). Those tests were not -changed. No live desktop UI timing or other-architecture measurement was run. diff --git a/crates/nostr-verification/benches/verify.rs b/crates/nostr-verification/benches/verify.rs deleted file mode 100644 index d8799705cb..0000000000 --- a/crates/nostr-verification/benches/verify.rs +++ /dev/null @@ -1,110 +0,0 @@ -//! Compare the production verifier with the previous native loop, including -//! hex decoding, public-key parsing, context creation, and ordered results. -//! Signing/setup, JSON, Tauri IPC, event hashing, and rendering are excluded. -use buzz_nostr_verification::{verify, SignatureClaim}; -use secp256k1::{schnorr::Signature, Keypair, Secp256k1, SecretKey, XOnlyPublicKey}; -use sha2::{Digest, Sha256}; -use std::{hint::black_box, time::Instant}; - -fn original(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 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 = Keypair::from_secret_key( - &secp, - &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 time(f: fn(&[SignatureClaim]) -> Vec, claims: &[SignatureClaim], repeats: usize) -> f64 { - let start = Instant::now(); - for _ in 0..repeats { - black_box(f(black_box(claims))); - } - start.elapsed().as_secs_f64() * 1e6 / repeats as f64 -} - -fn measure(claims: &[SignatureClaim], authors: usize, invalid: usize) { - assert_eq!(verify(claims), original(claims)); - for _ in 0..4 { - black_box(verify(claims)); - black_box(original(claims)); - } - let repeats = (4096 / claims.len()).max(16); - let mut old = Vec::new(); - let mut new = Vec::new(); - for round in 0..7 { - if round % 2 == 0 { - old.push(time(original, claims, repeats)); - new.push(time(verify, claims, repeats)); - } else { - new.push(time(verify, claims, repeats)); - old.push(time(original, claims, repeats)); - } - eprintln!( - "sample,{},{authors},{invalid},{round},{:.3},{:.3}", - claims.len(), - old[round], - new[round] - ); - } - old.sort_by(f64::total_cmp); - new.sort_by(f64::total_cmp); - println!( - "{},{authors},{invalid},{:.3},{:.3},{:.3}", - claims.len(), - old[3], - new[3], - old[3] / new[3] - ); -} - -fn main() { - println!("signatures,authors,invalid,individual_us,adaptive_us,speedup"); - for count in [1, 4, 12, 32, 128, 512] { - let mut author_counts = vec![1, 4, count / 8, count / 8 + 1, count]; - author_counts.sort_unstable(); - author_counts.dedup(); - for authors in author_counts { - if authors == 0 || authors > count { - continue; - } - measure(&corpus(count, authors), authors, 0); - } - } - for count in [12, 128, 512] { - for bad in [1, count / 2] { - let mut claims = corpus(count, 1); - for claim in &mut claims[..bad] { - claim.id = "00".repeat(32); - } - measure(&claims, 1, bad); - } - } -} diff --git a/crates/nostr-verification/measurements/m4-max-2026-11-11-samples.csv b/crates/nostr-verification/measurements/m4-max-2026-11-11-samples.csv deleted file mode 100644 index 0f4670284c..0000000000 --- a/crates/nostr-verification/measurements/m4-max-2026-11-11-samples.csv +++ /dev/null @@ -1,190 +0,0 @@ -kind,signatures,authors,invalid,round,individual_us,adaptive_us -sample,1,1,0,0,21.182,16.130 -sample,1,1,0,1,16.115,16.139 -sample,1,1,0,2,16.091,16.112 -sample,1,1,0,3,16.113,16.145 -sample,1,1,0,4,16.103,16.145 -sample,1,1,0,5,16.122,16.110 -sample,1,1,0,6,16.093,16.134 -sample,4,1,0,0,60.911,60.984 -sample,4,1,0,1,60.761,60.920 -sample,4,1,0,2,60.837,60.897 -sample,4,1,0,3,60.739,60.835 -sample,4,1,0,4,60.763,60.765 -sample,4,1,0,5,60.737,60.844 -sample,4,1,0,6,60.714,62.559 -sample,4,4,0,0,60.448,60.356 -sample,4,4,0,1,60.356,60.516 -sample,4,4,0,2,60.398,60.321 -sample,4,4,0,3,60.280,60.752 -sample,4,4,0,4,60.112,60.474 -sample,4,4,0,5,60.326,60.832 -sample,4,4,0,6,60.145,60.571 -sample,12,1,0,0,179.192,145.698 -sample,12,1,0,1,180.461,144.977 -sample,12,1,0,2,179.441,145.696 -sample,12,1,0,3,185.515,144.877 -sample,12,1,0,4,186.065,149.009 -sample,12,1,0,5,181.577,150.505 -sample,12,1,0,6,181.529,145.838 -sample,12,2,0,0,181.324,180.158 -sample,12,2,0,1,179.222,179.516 -sample,12,2,0,2,179.569,179.802 -sample,12,2,0,3,179.777,179.794 -sample,12,2,0,4,179.836,179.501 -sample,12,2,0,5,180.928,179.181 -sample,12,2,0,6,179.879,179.495 -sample,12,4,0,0,179.430,180.222 -sample,12,4,0,1,179.870,181.309 -sample,12,4,0,2,180.391,180.352 -sample,12,4,0,3,179.620,179.569 -sample,12,4,0,4,180.663,180.538 -sample,12,4,0,5,180.121,179.896 -sample,12,4,0,6,179.488,180.697 -sample,12,12,0,0,178.172,179.158 -sample,12,12,0,1,179.774,178.896 -sample,12,12,0,2,178.384,178.668 -sample,12,12,0,3,177.236,178.800 -sample,12,12,0,4,178.424,179.355 -sample,12,12,0,5,178.353,179.125 -sample,12,12,0,6,178.524,178.697 -sample,32,1,0,0,479.438,351.277 -sample,32,1,0,1,479.727,353.645 -sample,32,1,0,2,478.407,351.188 -sample,32,1,0,3,479.409,349.202 -sample,32,1,0,4,476.965,350.840 -sample,32,1,0,5,477.841,350.112 -sample,32,1,0,6,481.660,356.359 -sample,32,4,0,0,482.306,381.033 -sample,32,4,0,1,482.054,380.423 -sample,32,4,0,2,481.537,381.923 -sample,32,4,0,3,480.698,380.915 -sample,32,4,0,4,481.045,381.872 -sample,32,4,0,5,481.467,379.459 -sample,32,4,0,6,479.410,381.362 -sample,32,5,0,0,477.294,479.250 -sample,32,5,0,1,477.504,478.853 -sample,32,5,0,2,478.148,480.015 -sample,32,5,0,3,481.733,478.387 -sample,32,5,0,4,479.744,480.380 -sample,32,5,0,5,477.846,479.558 -sample,32,5,0,6,477.658,476.665 -sample,32,32,0,0,480.209,478.905 -sample,32,32,0,1,479.010,478.414 -sample,32,32,0,2,479.288,478.652 -sample,32,32,0,3,479.883,480.255 -sample,32,32,0,4,482.061,480.419 -sample,32,32,0,5,478.509,480.548 -sample,32,32,0,6,476.338,480.367 -sample,128,1,0,0,1944.132,1381.384 -sample,128,1,0,1,1959.454,1368.202 -sample,128,1,0,2,1942.940,1381.880 -sample,128,1,0,3,1983.906,1368.917 -sample,128,1,0,4,1946.383,1384.931 -sample,128,1,0,5,1996.451,1371.993 -sample,128,1,0,6,1953.454,1411.935 -sample,128,4,0,0,1978.285,1418.814 -sample,128,4,0,1,2011.288,1408.068 -sample,128,4,0,2,2041.335,1460.983 -sample,128,4,0,3,2006.352,1423.572 -sample,128,4,0,4,1973.784,1436.035 -sample,128,4,0,5,1986.167,1421.220 -sample,128,4,0,6,1996.036,1430.372 -sample,128,16,0,0,2003.281,1551.594 -sample,128,16,0,1,1990.176,1547.620 -sample,128,16,0,2,1957.801,1551.780 -sample,128,16,0,3,1958.799,1530.660 -sample,128,16,0,4,1957.270,1525.978 -sample,128,16,0,5,1986.967,1525.551 -sample,128,16,0,6,1940.388,1527.286 -sample,128,17,0,0,1936.332,1945.224 -sample,128,17,0,1,1951.716,1948.759 -sample,128,17,0,2,1948.682,1943.819 -sample,128,17,0,3,1937.438,1946.697 -sample,128,17,0,4,1956.128,2008.104 -sample,128,17,0,5,1990.844,1974.156 -sample,128,17,0,6,1987.543,1959.082 -sample,128,128,0,0,1942.568,1953.234 -sample,128,128,0,1,1953.292,1949.020 -sample,128,128,0,2,1948.986,1951.734 -sample,128,128,0,3,1962.286,1982.729 -sample,128,128,0,4,2025.021,2031.357 -sample,128,128,0,5,1975.923,2054.206 -sample,128,128,0,6,1950.958,1949.069 -sample,512,1,0,0,8162.477,5885.513 -sample,512,1,0,1,8110.758,5783.156 -sample,512,1,0,2,8077.771,5768.943 -sample,512,1,0,3,8102.727,5710.922 -sample,512,1,0,4,8099.862,5717.185 -sample,512,1,0,5,8134.393,5665.255 -sample,512,1,0,6,8068.635,5777.646 -sample,512,4,0,0,8098.362,5802.880 -sample,512,4,0,1,8215.760,5789.557 -sample,512,4,0,2,8203.596,5924.607 -sample,512,4,0,3,8223.685,5889.201 -sample,512,4,0,4,8239.716,5847.773 -sample,512,4,0,5,8158.482,5857.760 -sample,512,4,0,6,8170.898,5811.568 -sample,512,64,0,0,8090.148,6449.911 -sample,512,64,0,1,8120.190,6461.388 -sample,512,64,0,2,8055.690,6414.250 -sample,512,64,0,3,8091.539,6375.383 -sample,512,64,0,4,8089.050,6444.393 -sample,512,64,0,5,8506.281,6375.294 -sample,512,64,0,6,8213.500,6483.815 -sample,512,65,0,0,8096.125,8214.591 -sample,512,65,0,1,8156.464,8234.849 -sample,512,65,0,2,8152.857,8187.075 -sample,512,65,0,3,8143.125,8186.964 -sample,512,65,0,4,8100.411,8142.289 -sample,512,65,0,5,8128.630,8164.026 -sample,512,65,0,6,8112.159,8171.466 -sample,512,512,0,0,8116.557,8186.570 -sample,512,512,0,1,8169.911,8215.419 -sample,512,512,0,2,8142.047,8226.589 -sample,512,512,0,3,8129.224,8347.820 -sample,512,512,0,4,8114.779,8197.049 -sample,512,512,0,5,8130.398,8173.089 -sample,512,512,0,6,8125.729,8197.451 -sample,12,1,1,0,180.018,323.723 -sample,12,1,1,1,180.069,325.138 -sample,12,1,1,2,179.748,325.159 -sample,12,1,1,3,180.181,325.229 -sample,12,1,1,4,180.025,330.152 -sample,12,1,1,5,184.696,331.190 -sample,12,1,1,6,180.229,325.305 -sample,12,1,6,0,179.169,328.554 -sample,12,1,6,1,180.288,324.964 -sample,12,1,6,2,180.111,326.671 -sample,12,1,6,3,180.552,325.947 -sample,12,1,6,4,179.182,333.077 -sample,12,1,6,5,180.127,337.489 -sample,12,1,6,6,180.129,325.613 -sample,128,1,1,0,1942.184,3375.026 -sample,128,1,1,1,2284.806,3671.582 -sample,128,1,1,2,2045.533,3407.909 -sample,128,1,1,3,1960.104,3408.254 -sample,128,1,1,4,1955.227,3375.967 -sample,128,1,1,5,1960.630,3373.086 -sample,128,1,1,6,1967.133,3386.960 -sample,128,1,64,0,1949.026,3402.682 -sample,128,1,64,1,1974.268,3395.740 -sample,128,1,64,2,1943.294,3377.315 -sample,128,1,64,3,1938.751,3360.775 -sample,128,1,64,4,1942.415,3354.883 -sample,128,1,64,5,1948.275,3370.066 -sample,128,1,64,6,1954.081,3370.732 -sample,512,1,1,0,8086.977,14003.016 -sample,512,1,1,1,8115.021,14003.086 -sample,512,1,1,2,8180.857,14056.646 -sample,512,1,1,3,8112.625,14028.938 -sample,512,1,1,4,8096.417,14071.534 -sample,512,1,1,5,8091.648,14033.175 -sample,512,1,1,6,8086.320,14560.333 -sample,512,1,256,0,8143.081,14032.349 -sample,512,1,256,1,8100.789,14016.297 -sample,512,1,256,2,8116.919,13904.219 -sample,512,1,256,3,8082.828,14023.057 -sample,512,1,256,4,8029.044,14008.617 -sample,512,1,256,5,8100.414,14068.200 -sample,512,1,256,6,8184.203,14014.224 diff --git a/crates/nostr-verification/measurements/m4-max-2026-11-11.csv b/crates/nostr-verification/measurements/m4-max-2026-11-11.csv deleted file mode 100644 index d7bc4b7db8..0000000000 --- a/crates/nostr-verification/measurements/m4-max-2026-11-11.csv +++ /dev/null @@ -1,28 +0,0 @@ -signatures,authors,invalid,individual_us,adaptive_us,speedup -1,1,0,16.113,16.134,0.999 -4,1,0,60.761,60.897,0.998 -4,4,0,60.326,60.516,0.997 -12,1,0,181.529,145.698,1.246 -12,2,0,179.836,179.516,1.002 -12,4,0,179.870,180.352,0.997 -12,12,0,178.384,178.896,0.997 -32,1,0,479.409,351.188,1.365 -32,4,0,481.467,381.033,1.264 -32,5,0,477.846,479.250,0.997 -32,32,0,479.288,480.255,0.998 -128,1,0,1953.454,1381.384,1.414 -128,4,0,1996.036,1423.572,1.402 -128,16,0,1958.799,1530.660,1.280 -128,17,0,1951.716,1948.759,1.002 -128,128,0,1953.292,1953.234,1.000 -512,1,0,8102.727,5768.943,1.405 -512,4,0,8203.596,5847.773,1.403 -512,64,0,8091.539,6444.393,1.256 -512,65,0,8128.630,8186.964,0.993 -512,512,0,8129.224,8197.451,0.992 -12,1,1,180.069,325.229,0.554 -12,1,6,180.127,326.671,0.551 -128,1,1,1960.630,3386.960,0.579 -128,1,64,1948.275,3370.732,0.578 -512,1,1,8096.417,14033.175,0.577 -512,1,256,8100.789,14016.297,0.578 diff --git a/crates/nostr-verification/src/lib.rs b/crates/nostr-verification/src/lib.rs index 1f2865daec..31194f4b0d 100644 --- a/crates/nostr-verification/src/lib.rs +++ b/crates/nostr-verification/src/lib.rs @@ -59,7 +59,7 @@ fn should_batch(claims: &[SignatureClaim]) -> bool { return false; } // Native libsecp is faster for distinct authors. Only batch when merging - // repeated-author terms removes most public-key multiplications. See bench. + // repeated-author terms removes most public-key multiplications. let mut authors = BTreeSet::new(); for claim in claims { authors.insert(claim.pubkey.as_str()); From ba2fad14dce07cd88b94f91d95b1858227de3c5f Mon Sep 17 00:00:00 2001 From: Jordan Mecom Date: Wed, 11 Nov 2026 16:12:50 -0800 Subject: [PATCH 3/4] refactor(relay): adapt FROST batch verification locally Co-authored-by: Codex Signed-off-by: Jordan Mecom --- NOTICE.md | 10 +++ crates/nostr-verification/README.md | 7 ++- crates/nostr-verification/src/lib.rs | 93 +++++++++++++++++----------- 3 files changed, 73 insertions(+), 37 deletions(-) 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/README.md b/crates/nostr-verification/README.md index 8c92c062ab..f0cb33a2cc 100644 --- a/crates/nostr-verification/README.md +++ b/crates/nostr-verification/README.md @@ -15,7 +15,12 @@ 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 path follows [BIP-340's batch verification algorithm](https://github.com/bitcoin/bips/blob/master/bip-0340.mediawiki#batch-verification). +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 diff --git a/crates/nostr-verification/src/lib.rs b/crates/nostr-verification/src/lib.rs index 31194f4b0d..84e9babe88 100644 --- a/crates/nostr-verification/src/lib.rs +++ b/crates/nostr-verification/src/lib.rs @@ -3,6 +3,12 @@ //! 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}, @@ -101,57 +107,55 @@ impl Decoded { 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 { - // BIP-340 Batch Verification: seed a CSPRNG with a hash of ALL inputs. - // All fields have fixed lengths, so this transcript is unambiguous. Binding - // signatures too prevents adversaries from choosing cancelling errors after - // learning the weights. Never replace these coefficients with fixed 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); - } - let mut rng = ChaCha20Rng::from_seed(transcript.finalize().into()); + 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 sum = Scalar::ZERO; + 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 mut r = FieldBytes::default(); - r.copy_from_slice(&claim.sig[..32]); - let Some(point) = AffinePoint::decompact(&r).into_option() else { - return false; - }; - let mut s = FieldBytes::default(); - s.copy_from_slice(&claim.sig[32..]); - let Some(s) = Scalar::from_repr(s).into_option() else { + let Some(item) = claim.prepare(&challenge) else { return false; }; - let mut hash = challenge.clone(); - hash.update(r); - hash.update(claim.pubkey); - hash.update(claim.id); - let e = >::reduce(&FieldBytes::from(<[u8; 32]>::from( - hash.finalize(), - ))); - let a = if index == 0 { + let blind = if index == 0 { Scalar::ONE } else { coefficient(&mut rng) }; - sum += a * s; - terms.push((ProjectivePoint::from(point), -a)); - *authors.entry(claim.pubkey).or_insert(Scalar::ZERO) -= a * e; + 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. @@ -160,11 +164,28 @@ fn batch_valid(claims: &[Decoded]) -> bool { }; terms.push((ProjectivePoint::from(point), coefficient)); } - terms.push((ProjectivePoint::GENERATOR, sum)); + 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]; From 7a83520b33b45dc758c71dd848d16da8dfea1549 Mon Sep 17 00:00:00 2001 From: Jordan Mecom Date: Thu, 12 Nov 2026 15:41:12 -0800 Subject: [PATCH 4/4] test(relay): expand batch signature correctness cases Co-authored-by: Codex Signed-off-by: Jordan Mecom --- crates/nostr-verification/README.md | 5 +- crates/nostr-verification/src/tests.rs | 92 +++++++++++++++++++++++--- 2 files changed, 87 insertions(+), 10 deletions(-) diff --git a/crates/nostr-verification/README.md b/crates/nostr-verification/README.md index f0cb33a2cc..a44b65ebe0 100644 --- a/crates/nostr-verification/README.md +++ b/crates/nostr-verification/README.md @@ -52,7 +52,8 @@ cargo test -p buzz-nostr-verification --locked ``` Tests cover the official 15 BIP-340 vectors with 32-byte messages, native-verifier -equivalence, mixed authors, every invalid position in a batch, malformed input, +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 @@ -62,4 +63,4 @@ 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 later variable-message-length vectors do not apply to Nostr's 32-byte IDs. +Its four variable-message-length vectors are rejected by the Nostr-only API. diff --git a/crates/nostr-verification/src/tests.rs b/crates/nostr-verification/src/tests.rs index f991fccdd0..e300654af8 100644 --- a/crates/nostr-verification/src/tests.rs +++ b/crates/nostr-verification/src/tests.rs @@ -49,22 +49,22 @@ fn batch(claims: &[SignatureClaim]) -> bool { 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; the later variable-message-length vectors are out of scope. + // 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(); - if fields[4].len() != 64 { - continue; - } - let expected = fields[6] == "TRUE"; + 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; 12]; + let claims = vec![claim.clone(); 12]; assert!(should_batch(&claims)); - assert_eq!(batch(&claims), expected, "batch vector {}", fields[0]); + if fields[4].len() == 64 { + assert_eq!(batch(&claims), expected, "batch vector {}", fields[0]); + } assert_eq!( native(&claims), vec![expected; 12], @@ -72,9 +72,85 @@ fn bip340_vectors_match_both_verifiers() { 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, 15); + 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]