From 84ac3bf3bbfcc72b544414c778a003cd099f5321 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Mon, 27 Apr 2026 14:06:58 +0200 Subject: [PATCH] Add WASM Chrome extension PoC --- Cargo.toml | 17 +- README.md | 38 ++ benches/benchmark_poseidon.rs | 76 +++ benches/demo_perf.rs | 123 +++++ crates/leansig-wasm/Cargo.toml | 20 + crates/leansig-wasm/src/lib.rs | 202 +++++++ examples/chrome-extension-poc/.gitignore | 1 + examples/chrome-extension-poc/README.md | 100 ++++ examples/chrome-extension-poc/background.js | 5 + examples/chrome-extension-poc/build.sh | 34 ++ examples/chrome-extension-poc/manifest.json | 16 + examples/chrome-extension-poc/wallet.css | 220 ++++++++ examples/chrome-extension-poc/wallet.html | 129 +++++ examples/chrome-extension-poc/wallet.js | 506 ++++++++++++++++++ scripts/collect_demo_perf.sh | 39 ++ src/lib.rs | 1 + src/parallel.rs | 96 ++++ src/signature/generalized_xmss.rs | 43 +- .../instantiations_poseidon.rs | 74 +++ src/symmetric/tweak_hash.rs | 24 +- src/symmetric/tweak_hash/poseidon.rs | 29 +- 21 files changed, 1741 insertions(+), 52 deletions(-) create mode 100644 benches/demo_perf.rs create mode 100644 crates/leansig-wasm/Cargo.toml create mode 100644 crates/leansig-wasm/src/lib.rs create mode 100644 examples/chrome-extension-poc/.gitignore create mode 100644 examples/chrome-extension-poc/README.md create mode 100644 examples/chrome-extension-poc/background.js create mode 100755 examples/chrome-extension-poc/build.sh create mode 100644 examples/chrome-extension-poc/manifest.json create mode 100644 examples/chrome-extension-poc/wallet.css create mode 100644 examples/chrome-extension-poc/wallet.html create mode 100644 examples/chrome-extension-poc/wallet.js create mode 100755 scripts/collect_demo_perf.sh create mode 100644 src/parallel.rs diff --git a/Cargo.toml b/Cargo.toml index bdaff83..2932b9f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,10 @@ version = "0.1.0" edition = "2024" rust-version = "1.87" +[workspace] +members = ["crates/leansig-wasm"] +default-members = ["."] + [lints.clippy] # all lints that are on by default (correctness, suspicious, style, complexity, perf) all = { level = "warn", priority = -1 } @@ -33,7 +37,7 @@ upper_case_acronyms = "allow" rand = "0.10" sha3 = "0.10.8" num-bigint = "0.4.6" -rayon = "1.10.0" +rayon = { version = "1.10.0", optional = true } num-traits = "0.2.19" dashmap = "6.1.0" serde = { version = "1.0", features = ["derive", "alloc"] } @@ -46,12 +50,17 @@ p3-baby-bear = { git = "https://github.com/Plonky3/Plonky3.git", rev = "b4dcde46 p3-koala-bear = { git = "https://github.com/Plonky3/Plonky3.git", rev = "b4dcde46" } p3-symmetric = { git = "https://github.com/Plonky3/Plonky3.git", rev = "b4dcde46" } +[target.'cfg(target_arch = "wasm32")'.dependencies] +getrandom = { version = "0.4.2", features = ["wasm_js"] } + [dev-dependencies] criterion = "0.7" proptest = "1.7" bincode = { version = "2.0.1", features = ["serde"] } [features] +default = ["parallel-rayon"] +parallel-rayon = ["dep:rayon"] slow-tests = [] with-gen-benches-sha = [] with-gen-benches-poseidon = [] @@ -60,6 +69,10 @@ with-gen-benches-poseidon = [] name = "benchmark" harness = false +[[bench]] +name = "demo_perf" +harness = false + [profile.profiling] inherits = "release" -debug = true \ No newline at end of file +debug = true diff --git a/README.md b/README.md index 309cc13..f9ae292 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,44 @@ Confidence intervals can also be shown via python3 benchmark-mean.py target --intervals ``` +For a focused comparison of the portable demo scheme with and without `rayon`, use + +```bash +./scripts/collect_demo_perf.sh +``` + +## Web And Extension PoCs + +The repo now includes: + +- a minimal MV3 Chrome extension proof of concept in `examples/chrome-extension-poc/` +- a `wasm-bindgen` wrapper crate in `crates/leansig-wasm/` + +The WASM wrapper exposes a small wallet-like API for: + +- key generation +- secret/public key serialization +- signing a 32-byte message for a chosen epoch +- detached verification from only public key bytes, epoch, message bytes, and signature bytes +- prepared-interval inspection and manual preparation advance + +The Chrome extension can be built in two modes: + +```bash +# Fast interactive demo parameters: lifetime 2^10, w=2. +./examples/chrome-extension-poc/build.sh + +# Larger benchmark-oriented parameters: lifetime 2^18, w=2. +./examples/chrome-extension-poc/build.sh production +``` + +After building, open `chrome://extensions`, enable Developer mode, click `Load unpacked`, +and select `examples/chrome-extension-poc/`. The extension page has buttons to generate a +keypair, sign, verify, advance preparation, and run an in-Chrome performance sample. + +The extension packaging flow is documented in +[examples/chrome-extension-poc/README.md](examples/chrome-extension-poc/README.md). + ## License Apache Version 2.0. diff --git a/benches/benchmark_poseidon.rs b/benches/benchmark_poseidon.rs index da21476..afef5d3 100644 --- a/benches/benchmark_poseidon.rs +++ b/benches/benchmark_poseidon.rs @@ -1,4 +1,5 @@ use std::hint::black_box; +use std::time::Instant; use criterion::{Criterion, SamplingMode}; use rand::RngExt; @@ -8,6 +9,9 @@ use leansig::{ signature::{ SignatureScheme, SignatureSchemeSecretKey, generalized_xmss::instantiations_poseidon::{ + lifetime_2_to_the_10::target_sum::{ + SIGTargetSumLifetime10W2NoOff, SIGTargetSumLifetime10W2Off10, + }, lifetime_2_to_the_18::target_sum::{ SIGTargetSumLifetime18W1NoOff, SIGTargetSumLifetime18W1Off10, SIGTargetSumLifetime18W2NoOff, SIGTargetSumLifetime18W2Off10, @@ -96,6 +100,75 @@ pub fn benchmark_signature_scheme(c: &mut Criterion, descrip group.finish(); } +/// Benchmark the cost of shifting the secret key's prepared interval forward. +/// +/// This benchmark intentionally excludes key generation from the timed region by +/// pre-generating as many keys as Criterion asks us to consume. +pub fn benchmark_preparation_scheme(c: &mut Criterion, description: &str) { + let mut group = c.benchmark_group(format!("Poseidon preparation: {description}")); + group.sampling_mode(SamplingMode::Flat); + group.sample_size(30); + + let log_lifetime = S::LIFETIME.ilog2() as usize; + let max_advances_per_key = (1usize << (log_lifetime / 2)).saturating_sub(2); + assert!( + max_advances_per_key > 0, + "advance_preparation benchmark requires at least one possible advance" + ); + + group.bench_function("- advance_preparation", |b| { + b.iter_custom(|iters| { + let mut rng = rand::rng(); + let keys_needed = (iters as usize).div_ceil(max_advances_per_key); + let mut secret_keys: Vec = (0..keys_needed) + .map(|_| S::key_gen(&mut rng, 0, S::LIFETIME as usize).1) + .collect(); + + let start = Instant::now(); + let mut remaining = iters as usize; + + for secret_key in &mut secret_keys { + if remaining == 0 { + break; + } + + let advances_for_key = remaining.min(max_advances_per_key); + for _ in 0..advances_for_key { + secret_key.advance_preparation(); + } + + black_box(secret_key.get_prepared_interval()); + remaining -= advances_for_key; + } + + start.elapsed() + }); + }); + + group.finish(); +} + +/// Benchmarking demo-friendly Lifetime 2^10 for Target Sum Encoding +fn bench_lifetime10_target_sum(c: &mut Criterion) { + benchmark_signature_scheme::( + c, + "Target Sum, Lifetime 2^10, w = 2, no offset", + ); + benchmark_signature_scheme::( + c, + "Target Sum, Lifetime 2^10, w = 2, 10% offset", + ); + + benchmark_preparation_scheme::( + c, + "Target Sum, Lifetime 2^10, w = 2, no offset", + ); + benchmark_preparation_scheme::( + c, + "Target Sum, Lifetime 2^10, w = 2, 10% offset", + ); +} + /// Benchmarking Lifetime 2^18 for Target Sum Encoding fn bench_lifetime18_target_sum(c: &mut Criterion) { benchmark_signature_scheme::( @@ -175,6 +248,9 @@ fn bench_lifetime20_target_sum(c: &mut Criterion) { } pub fn bench_function_poseidon(c: &mut Criterion) { + // benchmarking demo lifetime 2^10 - Target Sum + bench_lifetime10_target_sum(c); + // benchmarking lifetime 2^18 - Target Sum bench_lifetime18_target_sum(c); diff --git a/benches/demo_perf.rs b/benches/demo_perf.rs new file mode 100644 index 0000000..ffa1a35 --- /dev/null +++ b/benches/demo_perf.rs @@ -0,0 +1,123 @@ +use std::hint::black_box; +use std::time::Instant; + +use criterion::{BenchmarkId, Criterion, SamplingMode, criterion_group, criterion_main}; +use rand::RngExt; + +use leansig::{ + MESSAGE_LENGTH, + signature::{ + SignatureScheme, SignatureSchemeSecretKey, + generalized_xmss::instantiations_poseidon::lifetime_2_to_the_10::target_sum::SIGTargetSumLifetime10W2NoOff, + }, +}; + +type DemoScheme = SIGTargetSumLifetime10W2NoOff; + +fn bench_demo_scheme(c: &mut Criterion) { + let mut group = c.benchmark_group("demo/perf"); + group.sampling_mode(SamplingMode::Flat); + + let mut rng = rand::rng(); + + group.sample_size(10); + group.bench_function(BenchmarkId::new("keygen", "lifetime_2^10_w2"), |b| { + b.iter(|| { + let _ = DemoScheme::key_gen(black_box(&mut rng), 0, DemoScheme::LIFETIME as usize); + }); + }); + + let (public_key, secret_key) = DemoScheme::key_gen(&mut rng, 0, DemoScheme::LIFETIME as usize); + let prepared_interval = secret_key.get_prepared_interval(); + let message: [u8; MESSAGE_LENGTH] = rng.random(); + + group.sample_size(60); + group.bench_function(BenchmarkId::new("sign", "lifetime_2^10_w2"), |b| { + let mut next_epoch = prepared_interval.start as u32; + b.iter(|| { + let epoch = next_epoch; + next_epoch += 1; + if next_epoch >= prepared_interval.end as u32 { + next_epoch = prepared_interval.start as u32; + } + + let _ = DemoScheme::sign( + black_box(&secret_key), + black_box(epoch), + black_box(&message), + ) + .expect("signing should succeed for prepared demo epochs"); + }); + }); + + let signatures: Vec<( + u32, + [u8; MESSAGE_LENGTH], + ::Signature, + )> = (0..64) + .map(|offset| { + let epoch = prepared_interval.start as u32 + offset; + let signature = + DemoScheme::sign(&secret_key, epoch, &message).expect("precomputing signature"); + (epoch, message, signature) + }) + .collect(); + + group.bench_function(BenchmarkId::new("verify", "lifetime_2^10_w2"), |b| { + let mut index = 0usize; + b.iter(|| { + let (epoch, benchmark_message, signature) = &signatures[index]; + index = (index + 1) % signatures.len(); + + let _ = DemoScheme::verify( + black_box(&public_key), + *epoch, + black_box(benchmark_message), + black_box(signature), + ); + }); + }); + + group.sample_size(30); + let log_lifetime = DemoScheme::LIFETIME.ilog2() as usize; + let max_advances_per_key = (1usize << (log_lifetime / 2)).saturating_sub(2); + + group.bench_function( + BenchmarkId::new("advance_preparation", "lifetime_2^10_w2"), + |b| { + b.iter_custom(|iters| { + let keys_needed = (iters as usize).div_ceil(max_advances_per_key); + let mut benchmark_rng = rand::rng(); + let mut secret_keys: Vec<_> = (0..keys_needed) + .map(|_| { + DemoScheme::key_gen(&mut benchmark_rng, 0, DemoScheme::LIFETIME as usize).1 + }) + .collect(); + + let start = Instant::now(); + let mut remaining = iters as usize; + + for secret_key in &mut secret_keys { + if remaining == 0 { + break; + } + + let advances_for_key = remaining.min(max_advances_per_key); + for _ in 0..advances_for_key { + secret_key.advance_preparation(); + } + + black_box(secret_key.get_prepared_interval()); + remaining -= advances_for_key; + } + + start.elapsed() + }); + }, + ); + + group.finish(); +} + +criterion_group!(demo_perf, bench_demo_scheme); +criterion_main!(demo_perf); diff --git a/crates/leansig-wasm/Cargo.toml b/crates/leansig-wasm/Cargo.toml new file mode 100644 index 0000000..147cd11 --- /dev/null +++ b/crates/leansig-wasm/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "leansig-wasm" +version = "0.1.0" +edition = "2024" +rust-version = "1.87" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +leansig = { path = "../..", default-features = false } +rand = "0.10" +wasm-bindgen = "0.2" + +[target.'cfg(target_arch = "wasm32")'.dependencies] +getrandom = { version = "0.4.2", features = ["wasm_js"] } + +[features] +default = [] +production-lifetime-18-w2 = [] diff --git a/crates/leansig-wasm/src/lib.rs b/crates/leansig-wasm/src/lib.rs new file mode 100644 index 0000000..dffeb1c --- /dev/null +++ b/crates/leansig-wasm/src/lib.rs @@ -0,0 +1,202 @@ +use leansig::{ + MESSAGE_LENGTH, + serialization::Serializable, + signature::{SignatureScheme, SignatureSchemeSecretKey}, +}; +#[cfg(not(feature = "production-lifetime-18-w2"))] +use leansig::signature::generalized_xmss::instantiations_poseidon::lifetime_2_to_the_10::target_sum::SIGTargetSumLifetime10W2NoOff as SelectedScheme; +#[cfg(feature = "production-lifetime-18-w2")] +use leansig::signature::generalized_xmss::instantiations_poseidon::lifetime_2_to_the_18::target_sum::SIGTargetSumLifetime18W2NoOff as SelectedScheme; +use rand::rng; +use wasm_bindgen::prelude::*; + +type DemoScheme = SelectedScheme; +type DemoPublicKey = ::PublicKey; +type DemoSecretKey = ::SecretKey; +type DemoSignature = ::Signature; + +fn js_error(message: impl Into) -> JsValue { + JsValue::from_str(&message.into()) +} + +fn decode_message(message: &[u8]) -> Result<[u8; MESSAGE_LENGTH], JsValue> { + message.try_into().map_err(|_| { + js_error(format!( + "expected {MESSAGE_LENGTH} message bytes, got {}", + message.len() + )) + }) +} + +fn decode_public_key(bytes: &[u8]) -> Result { + DemoPublicKey::from_bytes(bytes) + .map_err(|err| js_error(format!("invalid public key bytes: {err:?}"))) +} + +fn decode_signature(bytes: &[u8]) -> Result { + DemoSignature::from_bytes(bytes) + .map_err(|err| js_error(format!("invalid signature bytes: {err:?}"))) +} + +#[wasm_bindgen] +pub fn demo_message_length() -> usize { + MESSAGE_LENGTH +} + +#[wasm_bindgen] +pub fn demo_lifetime() -> u32 { + DemoScheme::LIFETIME as u32 +} + +#[wasm_bindgen] +pub fn demo_scheme_name() -> String { + #[cfg(feature = "production-lifetime-18-w2")] + { + "Poseidon target-sum, lifetime 2^18, w=2, no offset".to_string() + } + + #[cfg(not(feature = "production-lifetime-18-w2"))] + { + "Poseidon target-sum, lifetime 2^10, w=2, no offset".to_string() + } +} + +#[wasm_bindgen] +pub fn verify_demo_signature( + public_key_bytes: &[u8], + epoch: u32, + message: &[u8], + signature_bytes: &[u8], +) -> Result { + let public_key = decode_public_key(public_key_bytes)?; + let signature = decode_signature(signature_bytes)?; + let message = decode_message(message)?; + + Ok(DemoScheme::verify(&public_key, epoch, &message, &signature)) +} + +#[wasm_bindgen] +pub struct DemoKeypair { + secret_key: DemoSecretKey, + public_key: DemoPublicKey, +} + +#[wasm_bindgen] +impl DemoKeypair { + #[wasm_bindgen(constructor)] + pub fn new(secret_key_bytes: &[u8]) -> Result { + let secret_key = DemoSecretKey::from_bytes(secret_key_bytes) + .map_err(|err| js_error(format!("invalid secret key bytes: {err:?}")))?; + let public_key = DemoScheme::get_public_key(&secret_key); + + Ok(Self { + secret_key, + public_key, + }) + } + + #[wasm_bindgen(js_name = generate)] + pub fn generate() -> DemoKeypair { + let mut rng = rng(); + let (public_key, secret_key) = + DemoScheme::key_gen(&mut rng, 0, DemoScheme::LIFETIME as usize); + + Self { + secret_key, + public_key, + } + } + + #[wasm_bindgen(js_name = publicKeyBytes)] + pub fn public_key_bytes(&self) -> Vec { + self.public_key.to_bytes() + } + + #[wasm_bindgen(js_name = secretKeyBytes)] + pub fn secret_key_bytes(&self) -> Vec { + self.secret_key.to_bytes() + } + + #[wasm_bindgen(js_name = activationIntervalStart)] + pub fn activation_interval_start(&self) -> u32 { + self.secret_key.get_activation_interval().start as u32 + } + + #[wasm_bindgen(js_name = activationIntervalEnd)] + pub fn activation_interval_end(&self) -> u32 { + self.secret_key.get_activation_interval().end as u32 + } + + #[wasm_bindgen(js_name = preparedIntervalStart)] + pub fn prepared_interval_start(&self) -> u32 { + self.secret_key.get_prepared_interval().start as u32 + } + + #[wasm_bindgen(js_name = preparedIntervalEnd)] + pub fn prepared_interval_end(&self) -> u32 { + self.secret_key.get_prepared_interval().end as u32 + } + + #[wasm_bindgen(js_name = advancePreparation)] + pub fn advance_preparation(&mut self) { + self.secret_key.advance_preparation(); + } + + pub fn sign(&self, epoch: u32, message: &[u8]) -> Result, JsValue> { + let message = decode_message(message)?; + let signature = DemoScheme::sign(&self.secret_key, epoch, &message) + .map_err(|err| js_error(format!("signing failed: {err}")))?; + + Ok(signature.to_bytes()) + } + + pub fn verify( + &self, + epoch: u32, + message: &[u8], + signature_bytes: &[u8], + ) -> Result { + let message = decode_message(message)?; + let signature = decode_signature(signature_bytes)?; + + Ok(DemoScheme::verify( + &self.public_key, + epoch, + &message, + &signature, + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn demo_keypair_roundtrip_sign_verify() { + let mut keypair = DemoKeypair::generate(); + let message = [7u8; MESSAGE_LENGTH]; + let epoch = keypair.prepared_interval_start(); + + let signature_bytes = keypair.sign(epoch, &message).unwrap(); + assert!(keypair.verify(epoch, &message, &signature_bytes).unwrap()); + assert!( + verify_demo_signature( + &keypair.public_key_bytes(), + epoch, + &message, + &signature_bytes, + ) + .unwrap() + ); + + let secret_key_bytes = keypair.secret_key_bytes(); + let imported = DemoKeypair::new(&secret_key_bytes).unwrap(); + assert_eq!(imported.public_key_bytes(), keypair.public_key_bytes()); + assert!(imported.verify(epoch, &message, &signature_bytes).unwrap()); + + let prepared_before = keypair.prepared_interval_start(); + keypair.advance_preparation(); + assert!(keypair.prepared_interval_start() >= prepared_before); + } +} diff --git a/examples/chrome-extension-poc/.gitignore b/examples/chrome-extension-poc/.gitignore new file mode 100644 index 0000000..cace5e8 --- /dev/null +++ b/examples/chrome-extension-poc/.gitignore @@ -0,0 +1 @@ +/pkg/ diff --git a/examples/chrome-extension-poc/README.md b/examples/chrome-extension-poc/README.md new file mode 100644 index 0000000..d770e24 --- /dev/null +++ b/examples/chrome-extension-poc/README.md @@ -0,0 +1,100 @@ +# LeanSig Chrome Extension PoC + +This is a minimal MV3 Chrome extension proof of concept around `crates/leansig-wasm`. +It packages LeanXMSS-style signing into an extension page and loads the crypto through +`wasm-bindgen`-generated WASM assets. + +By default it keeps the same small demo scheme as the browser harness: +- Lifetime: `2^10` +- Encoding: target sum +- Chunk size: `w = 2` + +It can also be built against a larger benchmark-oriented scheme: +- Lifetime: `2^18` +- Encoding: target sum +- Chunk size: `w = 2` + +## Build + +From the repo root: + +```bash +./examples/chrome-extension-poc/build.sh +``` + +This will: +1. build `leansig-wasm` for `wasm32-unknown-unknown` +2. run `wasm-bindgen --target web` +3. emit the packaged extension assets into `examples/chrome-extension-poc/pkg/` + +To build the same extension UI against the larger benchmark-oriented `2^18 / w=2` +scheme, run: + +```bash +./examples/chrome-extension-poc/build.sh production +``` + +The production build intentionally uses fewer in-page performance samples because +key generation can take minutes in single-threaded WASM. + +Re-run the build command whenever you switch between demo and production mode, then reload +the unpacked extension in Chrome. + +## Load In Chrome + +1. Open `chrome://extensions` +2. Enable Developer mode +3. Click `Load unpacked` +4. Select `examples/chrome-extension-poc/` +5. Click the extension action to open the wallet page in a new tab + +After the page opens: + +1. Click `Generate Demo Key`. +2. Check that the runtime panel says `Module: Ready`. +3. Click `Sign`. +4. Click `Verify`. +5. Check the log for `local=true, detached=true`. +6. Click `Run Perf Sample` to measure the active build inside the Chrome extension runtime. + +For the production build, the perf sample intentionally runs only one key-generation sample. +Expect the page to be busy while that sample runs. + +## What The PoC Covers + +- generate a demo keypair inside an MV3 extension page +- persist the secret key in `chrome.storage.local` +- export and import secret key bytes +- inspect activation and prepared intervals +- sign a 32-byte message for a chosen epoch +- verify through both the keypair instance and the detached public-key API +- advance the prepared interval manually +- run an extension-side performance sample using `performance.now()` + +`local=true` means the signature verified through the loaded keypair object. +`detached=true` means verification succeeded using only public key bytes, epoch, message bytes, +and signature bytes. The detached path is the closest browser-side analogue to a wallet producing +a signature and another component verifying it without access to the secret key. + +The verifier here is a WASM/JS-side detached verifier, not an on-chain verifier. + +## Self-Test + +For automated validation, open: + +```text +chrome-extension:///wallet.html?self-test=1 +``` + +This runs an in-page smoke test after WASM initialization: + +- generate a temporary keypair +- sign and verify the default 32-byte message +- advance preparation once +- publish the result into the DOM via `data-self-test-status` on `` and the + `#self-test-output` JSON block + +## Important Limitation + +This is a stateful signature scheme demo, not a production wallet flow. +Reusing epochs or mishandling secret-key state can break the security model. diff --git a/examples/chrome-extension-poc/background.js b/examples/chrome-extension-poc/background.js new file mode 100644 index 0000000..32ad61e --- /dev/null +++ b/examples/chrome-extension-poc/background.js @@ -0,0 +1,5 @@ +const WALLET_URL = chrome.runtime.getURL("wallet.html"); + +chrome.action.onClicked.addListener(async () => { + await chrome.tabs.create({ url: WALLET_URL }); +}); diff --git a/examples/chrome-extension-poc/build.sh b/examples/chrome-extension-poc/build.sh new file mode 100755 index 0000000..3c7b292 --- /dev/null +++ b/examples/chrome-extension-poc/build.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +OUT_DIR="$ROOT_DIR/examples/chrome-extension-poc/pkg" +TARGET_DIR="$ROOT_DIR/target/wasm32-unknown-unknown/release" +WASM_CRATE="leansig_wasm" +SCHEME="${1:-demo}" +FEATURE_ARGS=() + +cd "$ROOT_DIR" + +case "$SCHEME" in + demo) + ;; + production|prod|lifetime18-w2) + FEATURE_ARGS=(--features production-lifetime-18-w2) + ;; + *) + printf 'Unknown scheme "%s". Use "demo" or "production".\n' "$SCHEME" >&2 + exit 2 + ;; +esac + +cargo build -p leansig-wasm --target wasm32-unknown-unknown --release "${FEATURE_ARGS[@]}" +mkdir -p "$OUT_DIR" + +wasm-bindgen \ + "$TARGET_DIR/${WASM_CRATE}.wasm" \ + --out-dir "$OUT_DIR" \ + --target web \ + --out-name "leansig_wasm" + +printf 'Built Chrome extension assets into %s using scheme %s\n' "$OUT_DIR" "$SCHEME" diff --git a/examples/chrome-extension-poc/manifest.json b/examples/chrome-extension-poc/manifest.json new file mode 100644 index 0000000..f116c0e --- /dev/null +++ b/examples/chrome-extension-poc/manifest.json @@ -0,0 +1,16 @@ +{ + "manifest_version": 3, + "name": "LeanSig Wallet PoC", + "version": "0.1.0", + "description": "Proof of concept Chrome extension for a stateful LeanXMSS-style signing flow.", + "permissions": ["storage"], + "action": { + "default_title": "Open LeanSig Wallet PoC" + }, + "background": { + "service_worker": "background.js" + }, + "content_security_policy": { + "extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self';" + } +} diff --git a/examples/chrome-extension-poc/wallet.css b/examples/chrome-extension-poc/wallet.css new file mode 100644 index 0000000..38efa0d --- /dev/null +++ b/examples/chrome-extension-poc/wallet.css @@ -0,0 +1,220 @@ +:root { + --ink: #f6f1e8; + --muted: #d8cdb9; + --accent: #ff8f3f; + --accent-soft: rgba(255, 143, 63, 0.18); + --panel: rgba(18, 24, 33, 0.82); + --panel-strong: rgba(26, 35, 47, 0.95); + --line: rgba(255, 255, 255, 0.09); + --bg-a: #0d1722; + --bg-b: #18283a; + --bg-c: #5f2e16; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + font-family: + "IBM Plex Sans", + "Segoe UI", + sans-serif; + color: var(--ink); + background: + radial-gradient(circle at top left, rgba(255, 143, 63, 0.28), transparent 28%), + radial-gradient(circle at 85% 15%, rgba(86, 152, 255, 0.18), transparent 25%), + linear-gradient(140deg, var(--bg-a), var(--bg-b) 58%, var(--bg-c)); +} + +.shell { + width: min(1180px, calc(100vw - 32px)); + margin: 0 auto; + padding: 40px 0 56px; +} + +.hero { + margin-bottom: 28px; +} + +.eyebrow { + margin: 0 0 8px; + font-size: 0.85rem; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--accent); +} + +.hero h1 { + margin: 0; + max-width: 12ch; + font-size: clamp(2.5rem, 7vw, 5.2rem); + line-height: 0.92; +} + +.lede { + max-width: 68ch; + color: var(--muted); +} + +.lede-compact { + margin-top: 0; + margin-bottom: 0; +} + +.grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 18px; +} + +.panel { + padding: 20px; + border: 1px solid var(--line); + border-radius: 18px; + background: var(--panel); + backdrop-filter: blur(12px); + box-shadow: 0 18px 54px rgba(0, 0, 0, 0.24); +} + +.panel-primary { + background: linear-gradient(160deg, var(--panel-strong), rgba(49, 34, 21, 0.9)); +} + +.panel-wide { + grid-column: 1 / -1; +} + +.panel h2 { + margin-top: 0; + margin-bottom: 16px; + font-size: 1.15rem; +} + +.status-line, +.intervals { + display: grid; + gap: 10px; +} + +.status-line { + grid-template-columns: 1fr auto; + padding: 10px 0; + border-bottom: 1px solid var(--line); +} + +.intervals { + grid-template-columns: repeat(2, minmax(0, 1fr)); + margin-bottom: 16px; +} + +.label { + display: block; + margin-bottom: 4px; + font-size: 0.8rem; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.form-grid { + display: grid; + gap: 10px; +} + +label { + font-size: 0.9rem; + color: var(--muted); +} + +textarea, +input { + width: 100%; + padding: 12px 14px; + border: 1px solid rgba(255, 255, 255, 0.13); + border-radius: 14px; + color: var(--ink); + background: rgba(8, 14, 20, 0.56); + font: inherit; +} + +textarea { + min-height: 120px; + resize: vertical; + font-family: + "IBM Plex Mono", + "SFMono-Regular", + monospace; + font-size: 0.92rem; +} + +button { + padding: 12px 16px; + border: 0; + border-radius: 999px; + color: #20150d; + background: linear-gradient(135deg, #ffd57a, var(--accent)); + font: inherit; + font-weight: 700; + cursor: pointer; +} + +button:hover { + transform: translateY(-1px); +} + +.button-ghost { + color: var(--ink); + background: var(--accent-soft); + border: 1px solid rgba(255, 143, 63, 0.26); +} + +.button-row { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 14px; +} + +#log-output, +#perf-output { + min-height: 220px; + margin: 0; + padding: 16px; + overflow: auto; + border-radius: 14px; + background: rgba(8, 14, 20, 0.62); + color: #d4f0d0; + font-family: + "IBM Plex Mono", + "SFMono-Regular", + monospace; + font-size: 0.88rem; + line-height: 1.45; +} + +#perf-output { + min-height: 150px; +} + +code { + padding: 0.15em 0.4em; + border-radius: 0.4em; + background: rgba(255, 255, 255, 0.1); +} + +@media (max-width: 900px) { + .grid { + grid-template-columns: 1fr; + } + + .panel-wide { + grid-column: auto; + } + + .intervals { + grid-template-columns: 1fr; + } +} diff --git a/examples/chrome-extension-poc/wallet.html b/examples/chrome-extension-poc/wallet.html new file mode 100644 index 0000000..8e9b591 --- /dev/null +++ b/examples/chrome-extension-poc/wallet.html @@ -0,0 +1,129 @@ + + + + + + LeanSig Wallet PoC + + + +
+
+

LeanSig MV3 PoC

+

Stateful signatures as an extension wallet.

+

+ This proof of concept packages a LeanXMSS-style scheme into a Chrome extension page. + The signing flow remains explicitly stateful: epochs are single-use, and the prepared + interval must keep moving forward. +

+
+ +
+
+

Runtime

+
+ Module + Loading… +
+
+ Message length + - +
+
+ Lifetime + - +
+
+ Context + Checking… +
+
+ Storage backend + Checking… +
+
+ Stored key + Checking… +
+
+ + +
+
+ +
+

Key State

+
+
+ Activation interval + - +
+
+ Prepared interval + - +
+
+ + +
+ + +
+
+ +
+

Signing

+
+ + + + + + + + +
+
+ + + +
+
+ +
+

Public Key

+ + +
+ +
+

Log

+

+        
+ +
+

Performance Sample

+

+ This runs the same in-page timing sample as the browser harness, but inside the MV3 + extension page runtime. +

+
+ +
+
No sample collected yet.
+
+ + +
+
+ + + + diff --git a/examples/chrome-extension-poc/wallet.js b/examples/chrome-extension-poc/wallet.js new file mode 100644 index 0000000..5078f70 --- /dev/null +++ b/examples/chrome-extension-poc/wallet.js @@ -0,0 +1,506 @@ +import init, { + DemoKeypair, + demo_lifetime, + demo_message_length, + demo_scheme_name, + verify_demo_signature, +} from "./pkg/leansig_wasm.js"; + +const STORAGE_KEY_PREFIX = "leansig-extension-secret-key"; +const DEFAULT_MESSAGE_HEX = + "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"; +const selfTestEnabled = new URLSearchParams(location.search).get("self-test") === "1"; + +const els = { + moduleStatus: document.getElementById("module-status"), + messageLength: document.getElementById("message-length"), + lifetime: document.getElementById("lifetime"), + runtimeContext: document.getElementById("runtime-context"), + storageBackend: document.getElementById("storage-backend"), + storageStatus: document.getElementById("storage-status"), + activationInterval: document.getElementById("activation-interval"), + preparedInterval: document.getElementById("prepared-interval"), + secretKeyHex: document.getElementById("secret-key-hex"), + publicKeyHex: document.getElementById("public-key-hex"), + epochInput: document.getElementById("epoch-input"), + messageHex: document.getElementById("message-hex"), + signatureHex: document.getElementById("signature-hex"), + logOutput: document.getElementById("log-output"), + generateKey: document.getElementById("generate-key"), + loadKey: document.getElementById("load-key"), + importKey: document.getElementById("import-key"), + exportKey: document.getElementById("export-key"), + signMessage: document.getElementById("sign-message"), + verifyMessage: document.getElementById("verify-message"), + advancePreparation: document.getElementById("advance-preparation"), + runPerfSample: document.getElementById("run-perf-sample"), + perfOutput: document.getElementById("perf-output"), + selfTestPanel: document.getElementById("self-test-panel"), + selfTestStatus: document.getElementById("self-test-status"), + selfTestOutput: document.getElementById("self-test-output"), +}; + +let moduleReady = false; +let keypair = null; + +function log(message) { + const timestamp = new Date().toLocaleTimeString(); + els.logOutput.textContent = `[${timestamp}] ${message}\n${els.logOutput.textContent}`.trim(); +} + +function bytesToHex(bytes) { + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +function hexToBytes(hex) { + const normalized = hex.trim().replace(/\s+/g, "").toLowerCase(); + if (normalized.length === 0) { + return new Uint8Array(); + } + if (normalized.length % 2 !== 0) { + throw new Error("hex input must have an even number of characters"); + } + + const bytes = new Uint8Array(normalized.length / 2); + for (let index = 0; index < normalized.length; index += 2) { + const value = Number.parseInt(normalized.slice(index, index + 2), 16); + if (Number.isNaN(value)) { + throw new Error(`invalid hex at position ${index}`); + } + bytes[index / 2] = value; + } + return bytes; +} + +function formatDuration(milliseconds) { + if (milliseconds < 1) { + return `${(milliseconds * 1000).toFixed(2)} µs`; + } + if (milliseconds < 1000) { + return `${milliseconds.toFixed(2)} ms`; + } + return `${(milliseconds / 1000).toFixed(2)} s`; +} + +function summarizeSamples(label, samples) { + const sorted = [...samples].sort((left, right) => left - right); + const total = sorted.reduce((sum, sample) => sum + sample, 0); + const mean = total / sorted.length; + const median = sorted[Math.floor(sorted.length / 2)]; + return `${label}: mean ${formatDuration(mean)}, median ${formatDuration( + median, + )}, min ${formatDuration(sorted[0])}, max ${formatDuration(sorted[sorted.length - 1])}`; +} + +function measureSync(operation) { + const start = performance.now(); + const result = operation(); + return { duration: performance.now() - start, result }; +} + +async function yieldToUi() { + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +async function getStoredSecretKey() { + const key = storageKey(); + const values = await chrome.storage.local.get(key); + return values[key] ?? null; +} + +function storageKey() { + if (!moduleReady) { + return `${STORAGE_KEY_PREFIX}-pending`; + } + + return `${STORAGE_KEY_PREFIX}-lifetime-${demo_lifetime()}`; +} + +function setSelfTestResult(status, details) { + document.body.dataset.selfTestStatus = status; + if (!els.selfTestPanel || !els.selfTestStatus || !els.selfTestOutput) { + return; + } + + els.selfTestPanel.hidden = false; + els.selfTestStatus.textContent = status; + els.selfTestOutput.textContent = JSON.stringify(details, null, 2); + window.__leansigSelfTestResult = { status, ...details }; +} + +async function setStoredSecretKey(secretKeyHex) { + await chrome.storage.local.set({ [storageKey()]: secretKeyHex }); +} + +async function updateStorageStatus() { + const stored = await getStoredSecretKey(); + els.storageStatus.textContent = stored ? "Present" : "Empty"; +} + +async function persistSecretKey() { + if (!keypair) { + return; + } + + await setStoredSecretKey(bytesToHex(keypair.secretKeyBytes())); + await updateStorageStatus(); +} + +function syncKeyFields() { + if (!keypair) { + els.activationInterval.textContent = "-"; + els.preparedInterval.textContent = "-"; + els.publicKeyHex.value = ""; + els.secretKeyHex.value = ""; + return; + } + + const activationStart = keypair.activationIntervalStart(); + const activationEnd = keypair.activationIntervalEnd(); + const preparedStart = keypair.preparedIntervalStart(); + const preparedEnd = keypair.preparedIntervalEnd(); + + els.activationInterval.textContent = `[${activationStart}, ${activationEnd})`; + els.preparedInterval.textContent = `[${preparedStart}, ${preparedEnd})`; + els.publicKeyHex.value = bytesToHex(keypair.publicKeyBytes()); + els.secretKeyHex.value = bytesToHex(keypair.secretKeyBytes()); + + if (!els.epochInput.value) { + els.epochInput.value = String(preparedStart); + } +} + +function requireReady() { + if (!moduleReady) { + throw new Error("WASM module is not ready yet"); + } +} + +function requireKeypair() { + requireReady(); + if (!keypair) { + throw new Error("No keypair loaded"); + } +} + +function readEpoch() { + const epoch = Number.parseInt(els.epochInput.value, 10); + if (!Number.isFinite(epoch) || epoch < 0) { + throw new Error("epoch must be a non-negative integer"); + } + return epoch; +} + +function readMessageBytes() { + const messageBytes = hexToBytes(els.messageHex.value); + const expectedLength = Number(demo_message_length()); + if (messageBytes.length !== expectedLength) { + throw new Error(`message must be exactly ${expectedLength} bytes`); + } + return messageBytes; +} + +function perfSamplePlan() { + if (demo_lifetime() >= 1 << 18) { + return { + advanceSamples: 1, + keygenSamples: 1, + signSamples: 3, + }; + } + + return { + advanceSamples: 10, + keygenSamples: 5, + signSamples: 24, + }; +} + +async function runSelfTest() { + setSelfTestResult("running", { + runtimeContext: els.runtimeContext.textContent, + storageBackend: els.storageBackend.textContent, + }); + + try { + requireReady(); + + const messageBytes = hexToBytes(DEFAULT_MESSAGE_HEX); + const testKeypair = DemoKeypair.generate(); + const epoch = testKeypair.preparedIntervalStart(); + const signatureBytes = testKeypair.sign(epoch, messageBytes); + const localVerify = testKeypair.verify(epoch, messageBytes, signatureBytes); + const detachedVerify = verify_demo_signature( + testKeypair.publicKeyBytes(), + epoch, + messageBytes, + signatureBytes, + ); + + if (!localVerify || !detachedVerify) { + throw new Error( + `self-test verification failed: local=${localVerify}, detached=${detachedVerify}`, + ); + } + + const preparedBefore = testKeypair.preparedIntervalStart(); + testKeypair.advancePreparation(); + const preparedAfter = testKeypair.preparedIntervalStart(); + + setSelfTestResult("passed", { + moduleStatus: els.moduleStatus.textContent, + runtimeContext: els.runtimeContext.textContent, + storageBackend: els.storageBackend.textContent, + messageLength: demo_message_length(), + lifetime: demo_lifetime(), + epoch, + publicKeyLength: testKeypair.publicKeyBytes().length, + signatureLength: signatureBytes.length, + preparedBefore, + preparedAfter, + localVerify, + detachedVerify, + }); + log("Self-test passed."); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + setSelfTestResult("failed", { + moduleStatus: els.moduleStatus.textContent, + runtimeContext: els.runtimeContext.textContent, + storageBackend: els.storageBackend.textContent, + error: message, + }); + log(`Self-test failed: ${message}`); + } +} + +function installHandlers() { + els.generateKey.addEventListener("click", async () => { + try { + requireReady(); + keypair = DemoKeypair.generate(); + await persistSecretKey(); + syncKeyFields(); + els.signatureHex.value = ""; + log("Generated a fresh demo keypair."); + } catch (error) { + log(`Generate failed: ${error instanceof Error ? error.message : String(error)}`); + } + }); + + els.loadKey.addEventListener("click", async () => { + try { + requireReady(); + const stored = await getStoredSecretKey(); + if (!stored) { + throw new Error("no stored secret key"); + } + keypair = new DemoKeypair(hexToBytes(stored)); + syncKeyFields(); + log("Loaded keypair from chrome.storage.local."); + } catch (error) { + log(`Load failed: ${error instanceof Error ? error.message : String(error)}`); + } + }); + + els.importKey.addEventListener("click", async () => { + try { + requireReady(); + keypair = new DemoKeypair(hexToBytes(els.secretKeyHex.value)); + await persistSecretKey(); + syncKeyFields(); + log("Imported secret key bytes."); + } catch (error) { + log(`Import failed: ${error instanceof Error ? error.message : String(error)}`); + } + }); + + els.exportKey.addEventListener("click", () => { + try { + requireKeypair(); + syncKeyFields(); + log("Refreshed exported public and secret key bytes."); + } catch (error) { + log(`Export failed: ${error instanceof Error ? error.message : String(error)}`); + } + }); + + els.signMessage.addEventListener("click", async () => { + try { + requireKeypair(); + const epoch = readEpoch(); + const messageBytes = readMessageBytes(); + const signatureBytes = keypair.sign(epoch, messageBytes); + els.signatureHex.value = bytesToHex(signatureBytes); + await persistSecretKey(); + syncKeyFields(); + log(`Signed message for epoch ${epoch}.`); + } catch (error) { + log(`Sign failed: ${error instanceof Error ? error.message : String(error)}`); + } + }); + + els.verifyMessage.addEventListener("click", () => { + try { + requireKeypair(); + const epoch = readEpoch(); + const messageBytes = readMessageBytes(); + const signatureBytes = hexToBytes(els.signatureHex.value); + const localResult = keypair.verify(epoch, messageBytes, signatureBytes); + const detachedResult = verify_demo_signature( + keypair.publicKeyBytes(), + epoch, + messageBytes, + signatureBytes, + ); + log( + `Verify result for epoch ${epoch}: local=${localResult}, detached=${detachedResult}.`, + ); + } catch (error) { + log(`Verify failed: ${error instanceof Error ? error.message : String(error)}`); + } + }); + + els.advancePreparation.addEventListener("click", async () => { + try { + requireKeypair(); + keypair.advancePreparation(); + await persistSecretKey(); + syncKeyFields(); + log("Advanced the prepared interval."); + } catch (error) { + log(`Advance failed: ${error instanceof Error ? error.message : String(error)}`); + } + }); + + els.runPerfSample.addEventListener("click", async () => { + try { + requireReady(); + const messageBytes = readMessageBytes(); + const keygenSamples = []; + const signSamples = []; + const verifySamples = []; + const advanceSamples = []; + const plan = perfSamplePlan(); + + let perfKeypair = null; + for (let iteration = 0; iteration < plan.keygenSamples; iteration += 1) { + const sample = measureSync(() => DemoKeypair.generate()); + keygenSamples.push(sample.duration); + perfKeypair = sample.result; + await yieldToUi(); + } + + if (!perfKeypair) { + throw new Error("perf key generation did not produce a keypair"); + } + + const perfPublicKey = perfKeypair.publicKeyBytes(); + let nextEpoch = perfKeypair.preparedIntervalStart(); + const preparedEnd = perfKeypair.preparedIntervalEnd(); + + for ( + let iteration = 0; + iteration < plan.signSamples && nextEpoch < preparedEnd; + iteration += 1 + ) { + const signSample = measureSync(() => perfKeypair.sign(nextEpoch, messageBytes)); + signSamples.push(signSample.duration); + + const verifySample = measureSync(() => + verify_demo_signature(perfPublicKey, nextEpoch, messageBytes, signSample.result), + ); + if (!verifySample.result) { + throw new Error(`detached verification failed for perf sample epoch ${nextEpoch}`); + } + verifySamples.push(verifySample.duration); + nextEpoch += 1; + await yieldToUi(); + } + + const preparationKey = + demo_lifetime() >= 1 << 18 ? perfKeypair : DemoKeypair.generate(); + for (let iteration = 0; iteration < plan.advanceSamples; iteration += 1) { + const before = preparationKey.preparedIntervalStart(); + const sample = measureSync(() => preparationKey.advancePreparation()); + advanceSamples.push(sample.duration); + if (preparationKey.preparedIntervalStart() === before) { + break; + } + await yieldToUi(); + } + + const lines = [ + `Runtime: Chrome extension (${chrome.runtime.id})`, + `Scheme: ${demo_scheme_name()}`, + "Storage: chrome.storage.local", + summarizeSamples(`keygen (${keygenSamples.length} samples)`, keygenSamples), + summarizeSamples(`sign (${signSamples.length} samples)`, signSamples), + summarizeSamples(`verify (${verifySamples.length} samples)`, verifySamples), + summarizeSamples( + `advance_preparation (${advanceSamples.length} samples)`, + advanceSamples, + ), + ]; + + els.perfOutput.textContent = lines.join("\n"); + log("Collected a local performance sample."); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + els.perfOutput.textContent = `Perf sample failed: ${message}`; + log(`Perf sample failed: ${message}`); + } + }); +} + +async function boot() { + installHandlers(); + els.runtimeContext.textContent = `Chrome extension (${chrome.runtime.id})`; + els.storageBackend.textContent = "chrome.storage.local"; + if (selfTestEnabled && els.selfTestPanel) { + els.selfTestPanel.hidden = false; + setSelfTestResult("armed", { + runtimeContext: els.runtimeContext.textContent, + storageBackend: els.storageBackend.textContent, + }); + } + if (!els.messageHex.value) { + els.messageHex.value = DEFAULT_MESSAGE_HEX; + } + await updateStorageStatus(); + + try { + await init(); + moduleReady = true; + els.moduleStatus.textContent = "Ready"; + els.messageLength.textContent = `${demo_message_length()} bytes`; + els.lifetime.textContent = `${demo_lifetime()} epochs (${demo_scheme_name()})`; + log("WASM module initialized."); + await updateStorageStatus(); + + const stored = await getStoredSecretKey(); + if (stored) { + keypair = new DemoKeypair(hexToBytes(stored)); + syncKeyFields(); + log("Recovered stored keypair."); + } + + if (selfTestEnabled) { + await runSelfTest(); + } + } catch (error) { + els.moduleStatus.textContent = "Failed"; + const message = error instanceof Error ? error.message : String(error); + if (selfTestEnabled) { + setSelfTestResult("failed", { + moduleStatus: els.moduleStatus.textContent, + runtimeContext: els.runtimeContext.textContent, + storageBackend: els.storageBackend.textContent, + error: message, + }); + } + log(`Module init failed: ${message}`); + } +} + +boot(); diff --git a/scripts/collect_demo_perf.sh b/scripts/collect_demo_perf.sh new file mode 100755 index 0000000..b0061d0 --- /dev/null +++ b/scripts/collect_demo_perf.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +OUT_BASE="${1:-$ROOT_DIR/target/demo-perf}" +THREAD_DIR="$OUT_BASE/threaded" +SCALAR_DIR="$OUT_BASE/scalar" + +run_bench() { + local label="$1" + local target_dir="$2" + shift 2 + + printf '\n[%s]\n' "$label" + printf 'target dir: %s\n' "$target_dir" + mkdir -p "$target_dir" + + ( + cd "$ROOT_DIR" + CARGO_TARGET_DIR="$target_dir" cargo bench "$@" --bench demo_perf -- --noplot + ) +} + +run_bench "native threaded (default features)" "$THREAD_DIR" +run_bench "native scalar (no rayon)" "$SCALAR_DIR" --no-default-features + +printf '\nCriterion outputs were written to:\n' +printf ' %s\n' "$THREAD_DIR" +printf ' %s\n' "$SCALAR_DIR" + +if python3 -c 'import tabulate' >/dev/null 2>&1; then + printf '\nThreaded summary:\n' + python3 "$ROOT_DIR/benchmark-mean.py" "$THREAD_DIR" + printf '\nScalar summary:\n' + python3 "$ROOT_DIR/benchmark-mean.py" "$SCALAR_DIR" +else + printf '\nInstall python tabulate to render summaries with benchmark-mean.py:\n' + printf ' python3 -m pip install tabulate\n' +fi diff --git a/src/lib.rs b/src/lib.rs index 121c46b..583a61c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -16,6 +16,7 @@ pub(crate) type PackedF = ::Packing; pub mod array; pub mod inc_encoding; +pub(crate) mod parallel; pub mod serialization; pub mod signature; pub(crate) mod simd_utils; diff --git a/src/parallel.rs b/src/parallel.rs new file mode 100644 index 0000000..3901581 --- /dev/null +++ b/src/parallel.rs @@ -0,0 +1,96 @@ +#[cfg(feature = "parallel-rayon")] +use rayon::prelude::*; + +pub(crate) fn map_range(range: std::ops::Range, f: F) -> Vec +where + T: Send, + F: Fn(usize) -> T + Sync + Send, +{ + #[cfg(feature = "parallel-rayon")] + { + range.into_par_iter().map(f).collect() + } + + #[cfg(not(feature = "parallel-rayon"))] + { + range.map(f).collect() + } +} + +pub(crate) fn map_chunks_exact(items: &[T], chunk_size: usize, f: F) -> Vec +where + T: Sync, + U: Send, + F: Fn(usize, &[T]) -> U + Sync + Send, +{ + assert!(chunk_size > 0, "chunk_size must be non-zero"); + assert!( + items.len().is_multiple_of(chunk_size), + "items length must be divisible by chunk_size" + ); + + #[cfg(feature = "parallel-rayon")] + { + items + .par_chunks_exact(chunk_size) + .enumerate() + .map(|(index, chunk)| f(index, chunk)) + .collect() + } + + #[cfg(not(feature = "parallel-rayon"))] + { + items + .chunks_exact(chunk_size) + .enumerate() + .map(|(index, chunk)| f(index, chunk)) + .collect() + } +} + +pub(crate) fn for_each_zipped_chunks_exact_mut( + left: &mut [A], + left_chunk_size: usize, + right: &[B], + right_chunk_size: usize, + f: F, +) where + A: Send, + B: Sync, + F: Fn(usize, &mut [A], &[B]) + Sync + Send, +{ + assert!(left_chunk_size > 0, "left_chunk_size must be non-zero"); + assert!(right_chunk_size > 0, "right_chunk_size must be non-zero"); + assert!( + left.len().is_multiple_of(left_chunk_size), + "left length must be divisible by left_chunk_size" + ); + assert!( + right.len().is_multiple_of(right_chunk_size), + "right length must be divisible by right_chunk_size" + ); + assert_eq!( + left.len() / left_chunk_size, + right.len() / right_chunk_size, + "left/right chunk counts must match" + ); + + #[cfg(feature = "parallel-rayon")] + { + left.par_chunks_exact_mut(left_chunk_size) + .zip(right.par_chunks_exact(right_chunk_size)) + .enumerate() + .for_each(|(index, (left_chunk, right_chunk))| f(index, left_chunk, right_chunk)); + } + + #[cfg(not(feature = "parallel-rayon"))] + { + for (index, (left_chunk, right_chunk)) in left + .chunks_exact_mut(left_chunk_size) + .zip(right.chunks_exact(right_chunk_size)) + .enumerate() + { + f(index, left_chunk, right_chunk); + } + } +} diff --git a/src/signature/generalized_xmss.rs b/src/signature/generalized_xmss.rs index 74ec20b..8218d26 100644 --- a/src/signature/generalized_xmss.rs +++ b/src/signature/generalized_xmss.rs @@ -1,12 +1,12 @@ use std::marker::PhantomData; use rand::RngExt; -use rayon::prelude::*; use serde::{Deserialize, Serialize}; use crate::{ MESSAGE_LENGTH, inc_encoding::IncomparableEncoding, + parallel::map_range, serialization::Serializable, signature::SignatureSchemeSecretKey, symmetric::{ @@ -803,19 +803,17 @@ where roots_of_bottom_trees.push(right_bottom_tree.root()); // the rest of the bottom trees in parallel - roots_of_bottom_trees.extend( - (start_bottom_tree_index + 2..end_bottom_tree_index) - .into_par_iter() - .map(|bottom_tree_index| { - let bottom_tree = bottom_tree_from_prf_key::( - &prf_key, - bottom_tree_index as u64, - ¶meter, - ); - bottom_tree.root() - }) - .collect::>(), // note: roots are in the correct order. - ); + roots_of_bottom_trees.extend(map_range( + start_bottom_tree_index + 2..end_bottom_tree_index, + |bottom_tree_index| { + let bottom_tree = bottom_tree_from_prf_key::( + &prf_key, + bottom_tree_index as u64, + ¶meter, + ); + bottom_tree.root() + }, + )); // note: roots are in the correct order. // second, we build the top tree, which has the roots of our bottom trees // as leafs. the root of it will be our public key. @@ -919,16 +917,13 @@ where ); // In parallel, compute the hash values for each chain based on the codeword `x`. - let hashes = (0..num_chains) - .into_par_iter() - .map(|chain_index| { - // get back to the start of the chain from the PRF - let start = PRF::get_domain_element(&sk.prf_key, epoch, chain_index as u64).into(); - // now walk the chain for a number of steps determined by the current chunk of x - let steps = x[chain_index] as usize; - chain::(&sk.parameter, epoch, chain_index as u8, 0, steps, &start) - }) - .collect(); + let hashes = map_range(0..num_chains, |chain_index| { + // get back to the start of the chain from the PRF + let start = PRF::get_domain_element(&sk.prf_key, epoch, chain_index as u64).into(); + // now walk the chain for a number of steps determined by the current chunk of x + let steps = x[chain_index] as usize; + chain::(&sk.parameter, epoch, chain_index as u8, 0, steps, &start) + }); // assemble the signature: Merkle path, randomness, chain elements Ok(GeneralizedXMSSSignature { path, rho, hashes }) diff --git a/src/signature/generalized_xmss/instantiations_poseidon.rs b/src/signature/generalized_xmss/instantiations_poseidon.rs index 2f6d860..417407f 100644 --- a/src/signature/generalized_xmss/instantiations_poseidon.rs +++ b/src/signature/generalized_xmss/instantiations_poseidon.rs @@ -1,3 +1,77 @@ +/// Demo-oriented instantiations with Lifetime 2^10 +pub mod lifetime_2_to_the_10 { + /// Instantiations based on the target sum encoding + pub mod target_sum { + use crate::{ + inc_encoding::target_sum::TargetSumEncoding, + signature::generalized_xmss::GeneralizedXMSSSignatureScheme, + symmetric::{ + message_hash::poseidon::PoseidonMessageHash, prf::shake_to_field::ShakePRFtoF, + tweak_hash::poseidon::PoseidonTweakHash, + }, + }; + + const LOG_LIFETIME: usize = 10; + const PARAMETER_LEN: usize = 5; + const MSG_HASH_LEN_FE: usize = 5; + const HASH_LEN_FE: usize = 7; + const MSG_LEN_FE: usize = 9; + const TWEAK_LEN_FE: usize = 2; + const RAND_LEN: usize = 6; + const CAPACITY: usize = 9; + + const BASE_W2: usize = 4; + const NUM_CHUNKS_W2: usize = 78; + type MHw2 = PoseidonMessageHash< + PARAMETER_LEN, + RAND_LEN, + MSG_HASH_LEN_FE, + NUM_CHUNKS_W2, + BASE_W2, + TWEAK_LEN_FE, + MSG_LEN_FE, + >; + type THw2 = + PoseidonTweakHash; + type PRFw2 = ShakePRFtoF; + type IEw2 = TargetSumEncoding; + + /// Demo instantiation with Lifetime 2^10, Target Sum encoding, and chunk size w = 2. + /// + /// This is intentionally much smaller than the paper-oriented benchmark schemes. + /// It exists to make browser and extension proof-of-concept work interactive. + pub type SIGTargetSumLifetime10W2NoOff = + GeneralizedXMSSSignatureScheme, THw2, LOG_LIFETIME>; + + /// Demo instantiation with Lifetime 2^10, Target Sum encoding, and chunk size w = 2, + /// with a 10% target-sum offset. + pub type SIGTargetSumLifetime10W2Off10 = + GeneralizedXMSSSignatureScheme, THw2, LOG_LIFETIME>; + + #[cfg(test)] + mod test { + use crate::signature::SignatureScheme; + use crate::signature::test_templates::test_signature_scheme_correctness; + + use super::{SIGTargetSumLifetime10W2NoOff, SIGTargetSumLifetime10W2Off10}; + + #[test] + fn test_w2_correctness_demo() { + test_signature_scheme_correctness::( + 21, + 0, + SIGTargetSumLifetime10W2NoOff::LIFETIME as usize, + ); + test_signature_scheme_correctness::( + 512, + 0, + SIGTargetSumLifetime10W2Off10::LIFETIME as usize, + ); + } + } + } +} + /// Instantiations with Lifetime 2^18 pub mod lifetime_2_to_the_18 { /// Instantiations based on the target sum encoding diff --git a/src/symmetric/tweak_hash.rs b/src/symmetric/tweak_hash.rs index 952b1f7..d94c3f9 100644 --- a/src/symmetric/tweak_hash.rs +++ b/src/symmetric/tweak_hash.rs @@ -1,7 +1,6 @@ use rand::RngExt; -use rayon::prelude::*; - +use crate::parallel::map_chunks_exact; use crate::serialization::Serializable; use crate::symmetric::prf::Pseudorandom; @@ -66,8 +65,9 @@ pub trait TweakableHash { /// # Returns /// A vector of parent nodes with length `children.len() / 2`. /// - /// This default implementation processes pairs in parallel using Rayon. - /// The Poseidon implementation overrides this with a SIMD-accelerated variant. + /// This default implementation can process pairs using the crate's optional + /// parallel backend. The Poseidon implementation overrides this with a + /// SIMD-accelerated variant. fn compute_tree_layer( parameter: &Self::Parameter, level: u8, @@ -75,16 +75,12 @@ pub trait TweakableHash { children: &[Self::Domain], ) -> Vec { // default implementation is scalar. tweak_tree/poseidon.rs provides a SIMD variant - children - .par_chunks_exact(2) - .enumerate() - .map(|(i, children)| { - // Parent index in this layer - let parent_pos = (parent_start + i) as u32; - // Hash children into their parent using the tweak - Self::apply(parameter, &Self::tree_tweak(level, parent_pos), children) - }) - .collect() + map_chunks_exact(children, 2, |i, children| { + // Parent index in this layer + let parent_pos = (parent_start + i) as u32; + // Hash children into their parent using the tweak + Self::apply(parameter, &Self::tree_tweak(level, parent_pos), children) + }) } /// Computes bottom tree leaves by walking hash chains for multiple epochs. diff --git a/src/symmetric/tweak_hash/poseidon.rs b/src/symmetric/tweak_hash/poseidon.rs index cb35819..35600ab 100644 --- a/src/symmetric/tweak_hash/poseidon.rs +++ b/src/symmetric/tweak_hash/poseidon.rs @@ -2,11 +2,11 @@ use core::array; use p3_field::{Algebra, PackedValue, PrimeCharacteristicRing, PrimeField64}; use p3_symmetric::CryptographicPermutation; -use rayon::prelude::*; use crate::TWEAK_SEPARATOR_FOR_CHAIN_HASH; use crate::TWEAK_SEPARATOR_FOR_TREE_HASH; use crate::array::FieldArray; +use crate::parallel::for_each_zipped_chunks_exact_mut; use crate::poseidon1_16; use crate::poseidon1_24; use crate::simd_utils::{pack_array, pack_even_into, pack_fn_into, pack_odd_into}; @@ -464,11 +464,12 @@ impl< let right_offset = PARAMETER_LEN + TWEAK_LEN + HASH_LEN; // Process SIMD batches with in-place mutation - parents - .par_chunks_exact_mut(WIDTH) - .zip(children.par_chunks_exact(2 * WIDTH)) - .enumerate() - .for_each(|(chunk_idx, (parents_chunk, children_chunk))| { + for_each_zipped_chunks_exact_mut( + &mut parents, + WIDTH, + children, + 2 * WIDTH, + |chunk_idx, parents_chunk, children_chunk| { let parent_pos = (parent_start + chunk_idx * WIDTH) as u32; // Assemble packed input directly: [parameter | tweak | left | right] @@ -498,7 +499,8 @@ impl< // Unpack directly to output slice PackedF::unpack_into(&packed_parents, FieldArray::as_raw_slice_mut(parents_chunk)); - }); + }, + ); // Handle remainder (elements that don't fill a complete SIMD batch) let remainder_start = (children.len() / (2 * WIDTH)) * WIDTH; @@ -587,10 +589,12 @@ impl< // Process epochs in batches of size `width`. // Each batch is handled by one thread. // Within each batch, SIMD processes `width` epochs simultaneously. - epochs - .par_chunks_exact(width) - .zip(leaves.par_chunks_exact_mut(width)) - .for_each(|(epoch_chunk, leaves_chunk)| { + for_each_zipped_chunks_exact_mut( + &mut leaves, + width, + epochs, + width, + |_chunk_idx, leaves_chunk, epoch_chunk| { // STEP 1: GENERATE AND PACK CHAIN STARTING POINTS // // For each chain, generate starting points for all epochs in the chunk. @@ -715,7 +719,8 @@ impl< // Convert from vertical packing back to scalar layout. // Each lane becomes one leaf in the output slice. PackedF::unpack_into(&packed_leaves, FieldArray::as_raw_slice_mut(leaves_chunk)); - }); + }, + ); // HANDLE REMAINDER EPOCHS //