From c4d3f7a696ea26b19f6f2c54bb4bfad4fdec3d22 Mon Sep 17 00:00:00 2001 From: Soam Desai Date: Thu, 17 Sep 2026 14:49:48 -0700 Subject: [PATCH 1/3] feat: support Flamingo matching in browser WASM --- .gitattributes | 2 + .github/workflows/ci.yml | 23 +- .gitignore | 1 + Cargo.lock | 28 ++- Cargo.toml | 2 + crates/walletkit-core/Cargo.toml | 8 +- crates/walletkit-core/src/flamingo.rs | 8 +- crates/walletkit-core/src/lib.rs | 1 - crates/walletkit-web/Cargo.toml | 27 +++ crates/walletkit-web/src/lib.rs | 232 +++++++++++++++++++ web/.gitignore | 3 + web/bootstrap-deps.sh | 33 +++ web/build.sh | 6 + web/cargo.sh | 21 ++ web/client.js | 50 +++++ web/example.js | 35 +++ web/index.html | 31 +++ web/package-lock.json | 58 +++++ web/package.json | 13 ++ web/patches/flamingo-wasm.patch | 309 ++++++++++++++++++++++++++ web/patches/pontifex-wasm.patch | 136 ++++++++++++ web/playwright.config.js | 15 ++ web/server.mjs | 33 +++ web/test-rust-browser.sh | 21 ++ web/tests/client.spec.js | 80 +++++++ web/worker.js | 55 +++++ 26 files changed, 1220 insertions(+), 11 deletions(-) create mode 100644 .gitattributes create mode 100644 crates/walletkit-web/Cargo.toml create mode 100644 crates/walletkit-web/src/lib.rs create mode 100644 web/.gitignore create mode 100644 web/bootstrap-deps.sh create mode 100644 web/build.sh create mode 100644 web/cargo.sh create mode 100644 web/client.js create mode 100644 web/example.js create mode 100644 web/index.html create mode 100644 web/package-lock.json create mode 100644 web/package.json create mode 100644 web/patches/flamingo-wasm.patch create mode 100644 web/patches/pontifex-wasm.patch create mode 100644 web/playwright.config.js create mode 100644 web/server.mjs create mode 100644 web/test-rust-browser.sh create mode 100644 web/tests/client.spec.js create mode 100644 web/worker.js diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..44345f13c --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Unified diffs intentionally contain a leading context marker on blank/tabbed lines. +web/patches/*.patch -whitespace diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d7cf9c25c..5d9cde1a4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -110,7 +110,9 @@ jobs: llvm-ar-18 --version | head -n1 - name: Run WASM compile check - run: cargo check -p walletkit --features embed-zkeys --target wasm32-unknown-unknown + run: | + bash web/bootstrap-deps.sh + bash web/cargo.sh check -p walletkit --features embed-zkeys --target wasm32-unknown-unknown # Keep this version aligned with the wasm-bindgen version in Cargo.lock. - name: Install WASM browser test runner @@ -119,7 +121,24 @@ jobs: - name: Test walletkit-sqlite in a browser runner run: | CHROMEDRIVER="$CHROMEWEBDRIVER/chromedriver" \ - cargo test -p walletkit-sqlite --target wasm32-unknown-unknown + bash web/cargo.sh test -p walletkit-sqlite --target wasm32-unknown-unknown + + - name: Test Flamingo and Pontifex in a browser worker + env: + CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUNNER: wasm-bindgen-test-runner + run: | + export CHROMEDRIVER="$CHROMEWEBDRIVER/chromedriver" + bash web/test-rust-browser.sh + + - name: Build JavaScript and WASM package + run: bash web/build.sh + + - name: Test browser package in Chromium and WebKit + working-directory: web + run: | + npm ci + npx playwright install --with-deps chromium webkit + npm test swift-build: name: Build Swift diff --git a/.gitignore b/.gitignore index 03b2dd1e5..5a1cf8f5c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ target/ +/target-web/ .DS_Store **/ios_build .swiftpm/ diff --git a/Cargo.lock b/Cargo.lock index 25fba76f5..14cd8c9e7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7573,6 +7573,17 @@ dependencies = [ "serde", ] +[[package]] +name = "serde-wasm-bindgen" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + [[package]] name = "serde_bytes" version = "0.11.19" @@ -8372,7 +8383,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix", "windows-sys 0.61.2", @@ -9415,6 +9426,21 @@ dependencies = [ "world-id-core", ] +[[package]] +name = "walletkit-web" +version = "0.23.0" +dependencies = [ + "hex", + "js-sys", + "serde", + "serde-wasm-bindgen", + "serde_bytes", + "walletkit-core", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-bindgen-test", +] + [[package]] name = "want" version = "0.3.1" diff --git a/Cargo.toml b/Cargo.toml index 1abec1a96..a0001ead2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ members = [ "crates/uniffi-bindgen", "crates/walletkit-core", "crates/walletkit", + "crates/walletkit-web", "crates/walletkit-db", "crates/walletkit-sqlite", "crates/walletkit-cli", @@ -10,6 +11,7 @@ members = [ "xtask", ] resolver = "2" +exclude = ["target/web-deps/flamingo", "target/web-deps/pontifex"] [workspace.package] version = "0.23.0" diff --git a/crates/walletkit-core/Cargo.toml b/crates/walletkit-core/Cargo.toml index 074830429..e83ada6a5 100644 --- a/crates/walletkit-core/Cargo.toml +++ b/crates/walletkit-core/Cargo.toml @@ -21,11 +21,15 @@ crate-type = ["lib", "staticlib", "cdylib"] name = "walletkit_core" [dependencies] +async-trait = { workspace = true } alloy-core = { workspace = true } backon = { workspace = true } base64 = { workspace = true } ciborium = { workspace = true } hex = { workspace = true } +flamingo-verifier-client = { workspace = true } +flamingo-verifier-protocol = { workspace = true } +flamingo-verifier-sealed-types = { workspace = true } hkdf = { workspace = true } log = { workspace = true } rand = { workspace = true } @@ -57,11 +61,7 @@ getrandom = { workspace = true, features = ["wasm_js"] } # Native-only dependencies (not available on wasm32) [target.'cfg(not(target_arch = "wasm32"))'.dependencies] -async-trait = { workspace = true } ctor = { workspace = true } -flamingo-verifier-client = { workspace = true } -flamingo-verifier-protocol = { workspace = true } -flamingo-verifier-sealed-types = { workspace = true } reqwest = { workspace = true, features = ["brotli", "rustls-tls"] } rustls = { workspace = true, features = ["ring"] } diff --git a/crates/walletkit-core/src/flamingo.rs b/crates/walletkit-core/src/flamingo.rs index 458acdfb2..28784f532 100644 --- a/crates/walletkit-core/src/flamingo.rs +++ b/crates/walletkit-core/src/flamingo.rs @@ -104,7 +104,8 @@ pub enum FlamingoError { Verifier(String), } -#[async_trait] +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] trait MatchClient: Sync { type Assignment: Send + Sync; @@ -297,7 +298,8 @@ impl From for FlamingoMatchRejection { } } -#[async_trait] +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] impl MatchClient for FlamingoVerifierClient { type Assignment = VerifiedAssignment; @@ -404,7 +406,7 @@ fn verifier_error(error: &ClientError) -> FlamingoError { FlamingoError::Verifier(error.to_string()) } -#[cfg(test)] +#[cfg(all(test, not(target_arch = "wasm32")))] mod tests { use std::{ collections::{HashMap, VecDeque}, diff --git a/crates/walletkit-core/src/lib.rs b/crates/walletkit-core/src/lib.rs index ea9d39986..40adf05c9 100644 --- a/crates/walletkit-core/src/lib.rs +++ b/crates/walletkit-core/src/lib.rs @@ -103,7 +103,6 @@ pub enum Region { } /// Attested Flamingo matching in preparation for zero-knowledge proof generation. -#[cfg(not(target_arch = "wasm32"))] pub mod flamingo; /// Contains error outputs from `WalletKit` diff --git a/crates/walletkit-web/Cargo.toml b/crates/walletkit-web/Cargo.toml new file mode 100644 index 000000000..cc8e6ae87 --- /dev/null +++ b/crates/walletkit-web/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "walletkit-web" +description = "Browser bindings for WalletKit's attested Flamingo client." +publish = false +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +walletkit-core = { workspace = true, default-features = false } +hex = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_bytes = "0.11" +serde-wasm-bindgen = "0.6" +wasm-bindgen = "=0.2.126" +wasm-bindgen-futures = "=0.4.76" +js-sys = "=0.3.103" + +[target.'cfg(target_arch = "wasm32")'.dev-dependencies] +wasm-bindgen-test = { workspace = true } + +[lints] +workspace = true diff --git a/crates/walletkit-web/src/lib.rs b/crates/walletkit-web/src/lib.rs new file mode 100644 index 000000000..8d23ebba0 --- /dev/null +++ b/crates/walletkit-web/src/lib.rs @@ -0,0 +1,232 @@ +//! Browser bindings for attested Flamingo matching. +//! +//! Attestation verification, encryption, and result verification stay in Rust. +//! These bindings do not implement camera capture, enrollment, or World ID proving. + +use std::{collections::HashMap, sync::Arc}; + +use serde::Deserialize; +use walletkit_core::flamingo::{ + FlamingoError, FlamingoMatchOutcome, FlamingoMatchRejection, FlamingoMatchRequest, + FlamingoMatcher, VerifiedMatchToken, +}; +use wasm_bindgen::prelude::*; + +#[wasm_bindgen(typescript_custom_section)] +const TYPES: &str = r#" +export interface FlamingoConfig { + hostUrl: string; + /** Approved, nonzero 48-byte measurements encoded as hex. PCR0, 1 and 2 are required. */ + measurements: Record; + /** Service authorization headers, never the user's identity or backup secrets. */ + headers?: Record; +} +export interface FlamingoMatchInput { + liveImage: Uint8Array; + credentialImage: Uint8Array; + /** Exact PCP archive bytes; do not parse and reserialize. */ + hashesJson: Uint8Array; + challengeImage: Uint8Array; + matchThreshold: number; +} +export type FlamingoErrorCode = "invalid_input" | "configuration" | "verifier"; +export interface FlamingoClientError extends Error { code: FlamingoErrorCode; } +"#; + +#[wasm_bindgen] +extern "C" { + /// Browser client configuration. + #[wasm_bindgen(typescript_type = "FlamingoConfig")] + pub type FlamingoConfig; + + /// Inputs for the existing three-way match operation. + #[wasm_bindgen(typescript_type = "FlamingoMatchInput")] + pub type FlamingoMatchInput; +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Config { + host_url: String, + measurements: HashMap, + #[serde(default)] + headers: HashMap, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Input { + #[serde(with = "serde_bytes")] + live_image: Vec, + #[serde(with = "serde_bytes")] + credential_image: Vec, + #[serde(with = "serde_bytes")] + hashes_json: Vec, + #[serde(with = "serde_bytes")] + challenge_image: Vec, + match_threshold: f32, +} + +/// Browser client using `WalletKit`'s measurement-pinned Flamingo matcher. +#[wasm_bindgen] +pub struct FlamingoClient { + inner: FlamingoMatcher, +} + +#[wasm_bindgen] +impl FlamingoClient { + /// Creates a client. No network request is made until `performMatch`. + /// + /// # Errors + /// Rejects malformed configuration and missing, zero, or invalid measurements. + #[wasm_bindgen(constructor)] + pub fn new(config: FlamingoConfig) -> Result { + let config: Config = serde_wasm_bindgen::from_value(config.into()) + .map_err(|_| error("configuration", "Invalid Flamingo configuration"))?; + let measurements = config + .measurements + .into_iter() + .map(|(index, value)| { + let index = index.parse::().map_err(|_| { + error("configuration", "Measurement keys must be PCR indices") + })?; + hex::decode(value.strip_prefix("0x").unwrap_or(&value)) + .map(|bytes| (index, bytes)) + .map_err(|_| { + error("configuration", "Measurements must be hex encoded") + }) + }) + .collect::, _>>()?; + let inner = FlamingoMatcher::new(&config.host_url) + .and_then(|client| client.with_measurements(measurements)) + .and_then(|client| client.with_headers(config.headers)) + .map_err(client_error)?; + Ok(Self { inner }) + } + + /// Performs an attested, encrypted three-way match. + /// + /// # Errors + /// Rejects invalid inputs, untrusted attestation, transport failures, or unverifiable results. + #[wasm_bindgen(js_name = performMatch)] + #[cfg_attr( + target_arch = "wasm32", + expect( + clippy::future_not_send, + reason = "browser Fetch futures stay on the originating JavaScript worker" + ) + )] + pub async fn perform_match( + &self, + input: FlamingoMatchInput, + ) -> Result { + let input: Input = serde_wasm_bindgen::from_value(input.into()) + .map_err(|_| error("invalid_input", "Invalid Flamingo match input"))?; + let inner = self + .inner + .perform_match(FlamingoMatchRequest { + live_image: input.live_image, + credential_image: input.credential_image, + hashes_json: input.hashes_json, + challenge_image: input.challenge_image, + light_guard_image: None, + match_threshold: input.match_threshold, + }) + .await + .map_err(client_error)?; + Ok(MatchOutcome { inner }) + } +} + +/// Verified success or a typed, unsigned rejection. Neither is a World ID proof. +#[wasm_bindgen] +pub struct MatchOutcome { + inner: FlamingoMatchOutcome, +} + +#[wasm_bindgen] +impl MatchOutcome { + /// Whether a signed match token was verified against its attested signing key. + #[wasm_bindgen(getter)] + #[must_use] + #[expect( + clippy::missing_const_for_fn, + reason = "wasm-bindgen cannot export const functions" + )] + pub fn matched(&self) -> bool { + matches!(self.inner, FlamingoMatchOutcome::Matched(_)) + } + + /// The rejection code, or `undefined` for success. Rejections are not signed evidence. + #[wasm_bindgen(getter)] + #[must_use] + pub fn rejection(&self) -> Option { + let FlamingoMatchOutcome::Rejected(reason) = &self.inner else { + return None; + }; + Some( + match reason { + FlamingoMatchRejection::MalformedInputs => "malformed_inputs", + FlamingoMatchRejection::InvalidHashesJson => "invalid_hashes_json", + FlamingoMatchRejection::ThumbnailHashMismatch => { + "thumbnail_hash_mismatch" + } + FlamingoMatchRejection::MatchBelowThreshold => "match_below_threshold", + FlamingoMatchRejection::ImageAnalysisFailed => "image_analysis_failed", + } + .to_owned(), + ) + } + + /// An opaque verified token handle for later Rust proof integration. + #[wasm_bindgen(getter)] + #[must_use] + pub fn verified(&self) -> Option { + match &self.inner { + FlamingoMatchOutcome::Matched(token) => Some(VerifiedMatch { + inner: Arc::clone(token), + }), + FlamingoMatchOutcome::Rejected(_) => None, + } + } +} + +/// Opaque match evidence, constructible only after verification succeeds. +/// The signed biometric commitments are not exported as a JavaScript byte buffer. +#[wasm_bindgen] +pub struct VerifiedMatch { + inner: Arc, +} + +#[wasm_bindgen] +impl VerifiedMatch { + /// Signing-key attestation to accompany a future proof; not a proof of matching itself. + #[wasm_bindgen(js_name = signingKeyAttestation)] + #[must_use] + pub fn signing_key_attestation(&self) -> Vec { + self.inner.signing_key_attestation().to_vec() + } +} + +fn client_error(value: FlamingoError) -> JsValue { + match value { + FlamingoError::InvalidInput { attribute, reason } => { + error("invalid_input", &format!("Invalid {attribute}: {reason}")) + } + FlamingoError::Configuration(_) => { + error("configuration", "Invalid Flamingo configuration") + } + // Underlying HTTP errors can contain URLs or service response data. Keep the public error + // stable and avoid forwarding those details to analytics or a parent page. + FlamingoError::Verifier(_) => { + error("verifier", "Flamingo request or verification failed") + } + } +} + +fn error(code: &str, message: &str) -> JsValue { + let value = js_sys::Error::new(message); + value.set_name("FlamingoError"); + let _ = js_sys::Reflect::set(&value, &"code".into(), &code.into()); + value.into() +} diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 000000000..14395cdaf --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,3 @@ +/pkg/ +/node_modules/ +/test-results/ diff --git a/web/bootstrap-deps.sh b/web/bootstrap-deps.sh new file mode 100644 index 000000000..c050db86d --- /dev/null +++ b/web/bootstrap-deps.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Reproducible temporary dependency patches, pending upstream releases. +set -euo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")/.." +root="$PWD" + +prepare() { + local name="$1" revision="$2" + local destination="$root/target/web-deps/$name" + local patch="$root/web/patches/$name-wasm.patch" + if [[ ! -d "$destination/.git" ]]; then + if [[ -e "$destination" ]]; then + echo "Refusing to overwrite existing directory: $destination" >&2 + exit 1 + fi + git init -q "$destination" + git -C "$destination" remote add origin "https://github.com/worldcoin/$name.git" + git -C "$destination" fetch -q --depth 1 origin "$revision" + git -C "$destination" switch -q --detach FETCH_HEAD + fi + if [[ "$(git -C "$destination" rev-parse HEAD)" != "$revision" ]]; then + echo "Unexpected revision in $destination; preserving the checkout." >&2 + exit 1 + fi + if git -C "$destination" apply --reverse --check "$patch" 2>/dev/null; then + return + fi + git -C "$destination" apply --check "$patch" + git -C "$destination" apply "$patch" +} + +prepare pontifex 19fc7eccb8a46babaf688b76fe6a0021d712cbdc +prepare flamingo 3484db339622dd3431e5ed1f3286df5fe7e94ea0 diff --git a/web/build.sh b/web/build.sh new file mode 100644 index 000000000..4e7618037 --- /dev/null +++ b/web/build.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")/.." +bash web/cargo.sh build -p walletkit-web --release --target wasm32-unknown-unknown +wasm-bindgen "${CARGO_TARGET_DIR:-target}/wasm32-unknown-unknown/release/walletkit_web.wasm" \ + --target web --out-dir web/pkg --out-name walletkit_web diff --git a/web/cargo.sh b/web/cargo.sh new file mode 100644 index 000000000..89ecd0249 --- /dev/null +++ b/web/cargo.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Develop the coordinated WalletKit / Flamingo / Pontifex changes before their releases. +set -euo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")/.." +flamingo_dir="${FLAMINGO_DIR:-$PWD/target/web-deps/flamingo}" +pontifex_dir="${PONTIFEX_DIR:-$PWD/target/web-deps/pontifex}" +for dep_dir in "$flamingo_dir" "$pontifex_dir"; do + if [[ ! -f "$dep_dir/Cargo.toml" ]]; then + echo "Missing dependency checkout: $dep_dir. Run bash web/bootstrap-deps.sh first." >&2 + exit 1 + fi +done +command="$1" +shift +exec cargo "$command" \ + --config "patch.crates-io.pontifex.path=\"$pontifex_dir\"" \ + --config "patch.crates-io.flamingo-verifier-client.path=\"$flamingo_dir/verifier/client\"" \ + --config "patch.crates-io.flamingo-verifier-api-types.path=\"$flamingo_dir/verifier/api-types\"" \ + --config "patch.crates-io.flamingo-verifier-protocol.path=\"$flamingo_dir/verifier/protocol\"" \ + --config "patch.crates-io.flamingo-verifier-sealed-types.path=\"$flamingo_dir/verifier/sealed-types\"" \ + "$@" diff --git a/web/client.js b/web/client.js new file mode 100644 index 000000000..a8bfdcb41 --- /dev/null +++ b/web/client.js @@ -0,0 +1,50 @@ +/** A dedicated-worker client. No keys or match-token bytes are returned to the parent page. */ +export class FlamingoWorker { + #worker; + #nextId = 0; + #pending = new Map(); + #closed = false; + + constructor() { + this.#worker = new Worker(new URL('./worker.js', import.meta.url), { type: 'module' }); + this.#worker.onmessage = ({ data }) => { + const pending = this.#pending.get(data.id); + if (!pending) return; + this.#pending.delete(data.id); + if (data.error) { + pending.reject(Object.assign(new Error(data.error.message), { code: data.error.code })); + } else { + pending.resolve(data.result); + } + }; + this.#worker.onerror = () => this.close('Browser worker failed to initialize'); + this.#worker.onmessageerror = () => this.close('Browser worker message failed'); + } + + initialize(config) { return this.#request('initialize', config); } + match(input) { return this.#request('match', input); } + release(verifiedHandle) { return this.#request('release', verifiedHandle); } + + #request(operation, payload) { + if (this.#closed) return Promise.reject(new Error('Client is closed')); + if (this.#pending.size) return Promise.reject(new Error('An operation is already in progress')); + const id = ++this.#nextId; + return new Promise((resolve, reject) => { + this.#pending.set(id, { resolve, reject }); + try { + this.#worker.postMessage({ id, operation, payload }); + } catch (error) { + this.#pending.delete(id); + reject(error); + } + }); + } + + /** Cancels pending work and disposes all Rust handles by terminating the worker. */ + close(message = 'Client closed') { + this.#closed = true; + this.#worker.terminate(); + for (const pending of this.#pending.values()) pending.reject(new Error(message)); + this.#pending.clear(); + } +} diff --git a/web/example.js b/web/example.js new file mode 100644 index 000000000..61f7814a9 --- /dev/null +++ b/web/example.js @@ -0,0 +1,35 @@ +import { FlamingoWorker } from './client.js'; + +const form = document.querySelector('#match'); +const status = document.querySelector('#status'); +const submit = form.querySelector('[type=submit]'); +let active; +document.querySelector('#cancel').onclick = () => active?.close('Cancelled'); +form.onsubmit = async event => { + event.preventDefault(); + const client = new FlamingoWorker(); + active = client; + submit.disabled = true; + status.textContent = 'Loading and verifying…'; + try { + const data = new FormData(form); + const input = { matchThreshold: Number(data.get('matchThreshold')) }; + for (const name of ['liveImage', 'credentialImage', 'hashesJson', 'challengeImage']) { + input[name] = new Uint8Array(await data.get(name).arrayBuffer()); + } + await client.initialize({ + hostUrl: data.get('hostUrl'), + measurements: { 0: data.get('pcr0'), 1: data.get('pcr1'), 2: data.get('pcr2') }, + }); + const result = await client.match(input); + status.textContent = result.matched + ? 'Signed match verified. World ID proof generation is a separate integration.' + : `Match rejected: ${result.rejection} (unsigned rejection).`; + } catch (error) { + status.textContent = error.message; + } finally { + client.close(); + active = undefined; + submit.disabled = false; + } +}; diff --git a/web/index.html b/web/index.html new file mode 100644 index 000000000..c99571d4f --- /dev/null +++ b/web/index.html @@ -0,0 +1,31 @@ + + + + +WalletKit browser match + +

WalletKit browser match

+

Existing three-way Flamingo flow. Use an approved enclave configuration and test PCP. +This example does not enroll a selfie credential or generate a World ID proof.

+
+ + + + + + + + + + + +
+Ready. + + diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 000000000..5858b01b1 --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,58 @@ +{ + "name": "walletkit-browser-example", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "walletkit-browser-example", + "devDependencies": { + "@playwright/test": "1.63.0" + } + }, + "node_modules/@playwright/test": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", + "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 000000000..4ed825951 --- /dev/null +++ b/web/package.json @@ -0,0 +1,13 @@ +{ + "name": "walletkit-browser-example", + "private": true, + "type": "module", + "scripts": { + "build": "bash build.sh", + "serve": "node server.mjs", + "test": "playwright test" + }, + "devDependencies": { + "@playwright/test": "1.63.0" + } +} diff --git a/web/patches/flamingo-wasm.patch b/web/patches/flamingo-wasm.patch new file mode 100644 index 000000000..9394d5fe6 --- /dev/null +++ b/web/patches/flamingo-wasm.patch @@ -0,0 +1,309 @@ +diff --git a/verifier/client/Cargo.toml b/verifier/client/Cargo.toml +index 27fcf97..c0fb11f 100644 +--- a/verifier/client/Cargo.toml ++++ b/verifier/client/Cargo.toml +@@ -23,8 +23,18 @@ thiserror.workspace = true + url.workspace = true + + [dev-dependencies] +-axum.workspace = true + flamingo-verifier-protocol.workspace = true + flamingo-verifier-sealed-types.workspace = true + hex-literal.workspace = true ++ ++[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies] ++axum.workspace = true + tokio.workspace = true ++ ++[target.'cfg(target_arch = "wasm32")'.dependencies] ++getrandom = { version = "0.2", features = ["js"] } ++ ++[target.'cfg(target_arch = "wasm32")'.dev-dependencies] ++wasm-bindgen-test = "=0.3.76" ++wasm-bindgen = "=0.2.126" ++js-sys = "=0.3.103" +diff --git a/verifier/client/src/browser_tests.rs b/verifier/client/src/browser_tests.rs +new file mode 100644 +index 0000000..0584539 +--- /dev/null ++++ b/verifier/client/src/browser_tests.rs +@@ -0,0 +1,178 @@ ++//! Browser runtime checks for the HTTP/encrypted-channel boundary. ++use super::*; ++use flamingo_verifier_sealed_types::{AttestedStatement, FailureReason}; ++use pontifex::ChannelEnclave; ++use wasm_bindgen::prelude::*; ++use wasm_bindgen_test::wasm_bindgen_test; ++ ++wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_dedicated_worker); ++ ++#[wasm_bindgen(inline_js = r#" ++let originalFetch; ++let last; ++export function installFetch(body, status, hang) { ++ originalFetch = globalThis.fetch; ++ globalThis.fetch = async request => { ++ last = { credentials: request.credentials, cache: request.cache, body: await request.text() }; ++ if (hang) return await new Promise((_, reject) => { ++ const abort = () => reject(request.signal.reason); ++ if (request.signal.aborted) abort(); ++ else request.signal.addEventListener('abort', abort, { once: true }); ++ }); ++ const response = new Response(body, { status, headers: { 'Content-Type': 'application/json' } }); ++ Object.defineProperty(response, 'url', { value: request.url }); ++ return response; ++ }; ++} ++export function restoreFetch() { globalThis.fetch = originalFetch; } ++export function lastRequest() { return JSON.stringify(last); } ++"#)] ++extern "C" { ++ fn installFetch(body: &str, status: u16, hang: bool); ++ fn restoreFetch(); ++ fn lastRequest() -> String; ++} ++ ++struct FetchGuard; ++impl Drop for FetchGuard { ++ fn drop(&mut self) { ++ restoreFetch(); ++ } ++} ++ ++fn stub(body: &str, status: u16, hang: bool) -> FetchGuard { ++ installFetch(body, status, hang); ++ FetchGuard ++} ++ ++fn client() -> FlamingoVerifierClient { ++ // No network-accessible verifier bypass: test code lives in this private module. ++ let config = Config::from_json( ++ &serde_json::json!({ ++ "host_url": "https://flamingo.invalid", ++ "allowed_pcr_configs": [[{"index": 0, "value": "01".repeat(48)}]], ++ "request_timeout_millis": 20 ++ }) ++ .to_string(), ++ ) ++ .unwrap(); ++ FlamingoVerifierClient::new(config).unwrap() ++} ++ ++fn exchange( ++ answer: &MatchResult, ++ foreign_reply: bool, ++) -> (String, String, pontifex::ResponseOpener) { ++ let enclave = ChannelEnclave::generate(ChannelDomain::new(MATCH_CHANNEL_DOMAIN)).unwrap(); ++ let consumer = ChannelConsumer::from_unverified_public_key( ++ ChannelDomain::new(MATCH_CHANNEL_DOMAIN), ++ &enclave.public_key(), ++ ) ++ .unwrap(); ++ let (sealed, opener) = consumer.seal_to_enclave(b"private-image-marker").unwrap(); ++ let (plaintext, sealer) = enclave.open(&sealed).unwrap(); ++ assert_eq!(&*plaintext, b"private-image-marker"); ++ let sealer = if foreign_reply { ++ let (other, _) = consumer.seal_to_enclave(b"another request").unwrap(); ++ enclave.open(&other).unwrap().1 ++ } else { ++ sealer ++ }; ++ let response = sealer.seal(&answer.to_padded_cbor().unwrap()).unwrap(); ++ ( ++ STANDARD.encode(sealed), ++ serde_json::json!({"response_ciphertext": STANDARD.encode(response)}).to_string(), ++ opener, ++ ) ++} ++ ++#[wasm_bindgen_test] ++async fn encrypted_response_and_browser_request_policy() { ++ let answer = MatchResult::Failed(FailureReason::MatchBelowThreshold); ++ let (ciphertext, response, opener) = exchange(&answer, false); ++ let _guard = stub(&response, 200, false); ++ let client = client(); ++ let request = client ++ .configure_request(client.http.post("https://flamingo.invalid/v1/matches")) ++ .json(&MatchRequestBody { ciphertext }); ++ assert_eq!( ++ client.request_match_with(request, opener).await.unwrap(), ++ answer ++ ); ++ let observed: serde_json::Value = serde_json::from_str(&lastRequest()).unwrap(); ++ assert_eq!(observed["credentials"], "include"); ++ assert_eq!(observed["cache"], "no-store"); ++ assert!( ++ !observed["body"] ++ .as_str() ++ .unwrap() ++ .contains("private-image-marker") ++ ); ++ let body: serde_json::Value = serde_json::from_str(observed["body"].as_str().unwrap()).unwrap(); ++ assert_eq!(body.as_object().unwrap().len(), 1); ++} ++ ++#[wasm_bindgen_test] ++async fn unrelated_response_is_rejected() { ++ let (ciphertext, response, opener) = ++ exchange(&MatchResult::Failed(FailureReason::MalformedInputs), true); ++ let _guard = stub(&response, 200, false); ++ let client = client(); ++ let request = client ++ .configure_request(client.http.post("https://flamingo.invalid/v1/matches")) ++ .json(&MatchRequestBody { ciphertext }); ++ assert!(matches!( ++ client.request_match_with(request, opener).await, ++ Err(Error::Channel(_)) ++ )); ++} ++ ++#[wasm_bindgen_test] ++async fn invalid_signing_attestation_is_rejected() { ++ let answer = MatchResult::Success(AttestedStatement { ++ token: match_token::MatchToken::from_bytes(vec![1, 2, 3]), ++ signing_key_attestation: vec![0; 8], ++ }); ++ let (ciphertext, response, opener) = exchange(&answer, false); ++ let _guard = stub(&response, 200, false); ++ let client = client(); ++ let request = client ++ .configure_request(client.http.post("https://flamingo.invalid/v1/matches")) ++ .json(&MatchRequestBody { ciphertext }); ++ assert!(matches!( ++ client.request_match_with(request, opener).await, ++ Err(Error::Attestation(_)) ++ )); ++} ++ ++#[wasm_bindgen_test] ++async fn untrusted_assignment_and_stale_routing_fail_closed() { ++ let _guard = stub(r#"{"attestation":"AA==","public_key":"AA=="}"#, 200, false); ++ assert!(matches!( ++ client().request_assignment().await, ++ Err(Error::Channel(_)) ++ )); ++ drop(_guard); ++ let _guard = stub( ++ r#"{"allowRetry":true,"error":{"code":"reassign_required","message":"stale"}}"#, ++ 409, ++ false, ++ ); ++ let (ciphertext, _, opener) = ++ exchange(&MatchResult::Failed(FailureReason::MalformedInputs), false); ++ let client = client(); ++ let request = client ++ .configure_request(client.http.post("https://flamingo.invalid/v1/matches")) ++ .json(&MatchRequestBody { ciphertext }); ++ assert!(matches!( ++ client.request_match_with(request, opener).await, ++ Err(Error::ReassignRequired) ++ )); ++} ++ ++#[wasm_bindgen_test] ++async fn browser_fetch_is_aborted_at_the_deadline() { ++ let _guard = stub("", 200, true); ++ let error = client().request_assignment().await.unwrap_err(); ++ assert!(matches!(error, Error::Request(error) if error.is_timeout())); ++} +diff --git a/verifier/client/src/client.rs b/verifier/client/src/client.rs +index 9811f93..99f80f6 100644 +--- a/verifier/client/src/client.rs ++++ b/verifier/client/src/client.rs +@@ -49,6 +49,13 @@ pub struct FlamingoVerifierClient { + verifier: Verifier, + } + ++#[cfg_attr( ++ target_arch = "wasm32", ++ expect( ++ clippy::future_not_send, ++ reason = "Fetch and JavaScript futures stay on their originating browser worker" ++ ) ++)] + impl FlamingoVerifierClient { + /// Builds a client from `config`. + /// +@@ -61,8 +68,8 @@ impl FlamingoVerifierClient { + + /// Builds a client using an externally configured HTTP client builder. + /// +- /// The configured cookie store, connection timeout, and request timeout are applied to the +- /// supplied builder. ++ /// Native clients use a cookie store and connection/request timeouts. Browser clients ++ /// use Fetch credentials and a per-request deadline; the browser manages connections. + /// + /// # Errors + /// +@@ -71,13 +78,13 @@ impl FlamingoVerifierClient { + config: Config, + http: reqwest::ClientBuilder, + ) -> Result { ++ #[cfg(not(target_arch = "wasm32"))] + let http = http + // Replays the ALB's affinity cookie, so the match reaches the enclave that was assigned. + .cookie_store(true) + .connect_timeout(config.connect_timeout()) +- .timeout(config.request_timeout()) +- .build() +- .map_err(Error::Transport)?; ++ .timeout(config.request_timeout()); ++ let http = http.build().map_err(Error::Transport)?; + + Ok(Self { + verifier: config.verifier()?, +@@ -90,12 +97,13 @@ impl FlamingoVerifierClient { + /// + /// Callers may customize the returned builder before passing it to + /// [`Self::request_assignment_with`]. ++ #[must_use] + pub fn build_assignment_request(&self) -> reqwest::RequestBuilder { + let url = format!( + "{}/v1/enclave-assignment", + self.config.host_url().as_str().trim_end_matches('/') + ); +- self.http.post(url) ++ self.configure_request(self.http.post(url)) + } + + /// Requests an assignment and returns it only if its attestation verifies. +@@ -174,9 +182,11 @@ impl FlamingoVerifierClient { + "{}/v1/matches", + self.config.host_url().as_str().trim_end_matches('/') + ); +- let request = self.http.post(url).json(&MatchRequestBody { +- ciphertext: STANDARD.encode(sealed), +- }); ++ let request = self ++ .configure_request(self.http.post(url)) ++ .json(&MatchRequestBody { ++ ciphertext: STANDARD.encode(sealed), ++ }); + + Ok((request, opener)) + } +@@ -275,9 +285,15 @@ impl FlamingoVerifierClient { + allow_retry: envelope.allow_retry, + } + } ++ ++ fn configure_request(&self, request: reqwest::RequestBuilder) -> reqwest::RequestBuilder { ++ #[cfg(target_arch = "wasm32")] ++ let request = request.fetch_credentials_include().fetch_cache_no_store(); ++ request.timeout(self.config.request_timeout()) ++ } + } + +-#[cfg(test)] ++#[cfg(all(test, not(target_arch = "wasm32")))] + mod tests { + use std::net::{Ipv4Addr, SocketAddr}; + use std::sync::{Arc, Mutex}; +@@ -581,3 +597,7 @@ mod tests { + } + } + } ++ ++#[cfg(all(test, target_arch = "wasm32"))] ++#[path = "browser_tests.rs"] ++mod browser_tests; diff --git a/web/patches/pontifex-wasm.patch b/web/patches/pontifex-wasm.patch new file mode 100644 index 000000000..80b2bc186 --- /dev/null +++ b/web/patches/pontifex-wasm.patch @@ -0,0 +1,136 @@ +diff --git a/Cargo.toml b/Cargo.toml +index 76f0593..ac71509 100644 +--- a/Cargo.toml ++++ b/Cargo.toml +@@ -48,6 +48,8 @@ attestation = [ + "dep:p384", + "dep:webpki", + "dep:x509-cert", ++ "dep:web-time", ++ "dep:getrandom", + ] + + # Internal: The HTTPS-over-vsock transport, shared by `http` and `kms`. +@@ -114,7 +116,17 @@ p384 = { version = "0.13", default-features = false, features = ["ecdsa", "sha38 + [dev-dependencies] + aws-nitro-enclaves-nsm-api = { version = "0.4", default-features = false } + base64 = "0.22" ++hex-literal = "1" ++ ++[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies] + tokio-test = "0.4" + tokio = { version = "1", features = ["macros", "rt", "test-util", "time"] } + aws-smithy-runtime-api = { version = "1.9", features = ["client", "http-1x", "test-util"] } +-hex-literal = "1" ++ ++[target.'cfg(target_arch = "wasm32")'.dev-dependencies] ++wasm-bindgen-test = "=0.3.76" ++ ++[target.'cfg(target_arch = "wasm32")'.dependencies] ++getrandom = { version = "0.2", features = ["js"], optional = true } ++quantum-box = { version = "0.1", features = ["wasm_js"], optional = true } ++web-time = { version = "1", optional = true } +diff --git a/src/attestation.rs b/src/attestation.rs +index 2439343..1d48b62 100644 +--- a/src/attestation.rs ++++ b/src/attestation.rs +@@ -1,6 +1,10 @@ + //! Verification of AWS Nitro Enclave attestation documents. + +-use std::time::{Duration, SystemTime, UNIX_EPOCH}; ++use std::time::Duration; ++#[cfg(not(target_arch = "wasm32"))] ++use std::time::{SystemTime, UNIX_EPOCH}; ++#[cfg(target_arch = "wasm32")] ++use web_time::{SystemTime, UNIX_EPOCH}; + + use coset::{Algorithm, CoseSign1, iana}; + use p384::ecdsa::{Signature, VerifyingKey, signature::Verifier as _}; +@@ -449,7 +453,7 @@ impl Verifier { + } + } + +-#[cfg(test)] ++#[cfg(all(test, not(target_arch = "wasm32")))] + mod tests { + use std::{ + collections::HashMap, +@@ -830,3 +834,65 @@ mod tests { + ); + } + } ++ ++#[cfg(all(test, target_arch = "wasm32"))] ++mod browser_tests { ++ use super::*; ++ use crate::test_fixtures::{ ++ TEN_YEARS, pcr0_only, real_attestation_bytes, real_attestation_verifier, ++ }; ++ use wasm_bindgen_test::wasm_bindgen_test; ++ ++ wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_dedicated_worker); ++ ++ #[wasm_bindgen_test] ++ fn expired_certificate_is_rejected_using_the_browser_clock() { ++ let verifier = Verifier::new(vec![pcr0_only()], TEN_YEARS); ++ assert!(matches!( ++ verifier.verify_attestation_document(&real_attestation_bytes()), ++ Err(Error::ChainInvalid(_)) ++ )); ++ } ++ ++ #[wasm_bindgen_test] ++ fn real_signature_measurements_and_freshness_are_checked_in_a_worker() { ++ // Only the expired fixture's certificate time is skipped, using an existing cfg(test) ++ // hook. Production verification always checks certificate time and document freshness. ++ let bytes = real_attestation_bytes(); ++ let verifier = real_attestation_verifier(); ++ let verified = verifier ++ .verify_attestation_document(&bytes) ++ .expect("valid signed fixture"); ++ assert_eq!( ++ verified.document().public_key.as_ref().unwrap().as_slice(), ++ crate::test_fixtures::ATTESTED_PUBLIC_KEY.as_slice() ++ ); ++ ++ let mut tampered = bytes.clone(); ++ *tampered.last_mut().unwrap() ^= 1; ++ assert!(matches!( ++ verifier.verify_attestation_document(&tampered), ++ Err(Error::SignatureInvalid(_)) ++ )); ++ ++ let wrong_pcr = Verifier::new(vec![PcrConfig::new([1; 48])], TEN_YEARS) ++ .with_skipped_certificate_time_check(); ++ assert!(matches!( ++ wrong_pcr.verify_attestation_document(&bytes), ++ Err(Error::CodeUntrusted { .. }) ++ )); ++ ++ let stale = ++ Verifier::new(vec![pcr0_only()], Duration::ZERO).with_skipped_certificate_time_check(); ++ assert!(matches!( ++ stale.verify_attestation_document(&bytes), ++ Err(Error::Stale { .. }) ++ )); ++ ++ let wrong_root = verifier.with_root_certificate(vec![0; 32]); ++ assert!(matches!( ++ wrong_root.verify_attestation_document(&bytes), ++ Err(Error::ChainInvalid(_)) ++ )); ++ } ++} +diff --git a/src/channel.rs b/src/channel.rs +index d403b50..ec1389b 100644 +--- a/src/channel.rs ++++ b/src/channel.rs +@@ -341,7 +341,7 @@ impl ResponseOpener { + } + } + +-#[cfg(test)] ++#[cfg(all(test, not(target_arch = "wasm32")))] + mod tests { + use super::{ + ChannelConsumer, ChannelDomain, ChannelEnclave, ChannelError, REQUEST, RESPONSE, diff --git a/web/playwright.config.js b/web/playwright.config.js new file mode 100644 index 000000000..14703ef12 --- /dev/null +++ b/web/playwright.config.js @@ -0,0 +1,15 @@ +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests', + workers: 1, + use: { baseURL: 'http://127.0.0.1:4173' }, + projects: [{ name: 'chromium', use: { browserName: 'chromium' } }, + { name: 'webkit', use: { browserName: 'webkit' } }], + webServer: { + command: 'node server.mjs', + url: 'http://127.0.0.1:4173', + env: { WALLETKIT_BROWSER_TEST: '1' }, + reuseExistingServer: false, + }, +}); diff --git a/web/server.mjs b/web/server.mjs new file mode 100644 index 000000000..ecc0eb897 --- /dev/null +++ b/web/server.mjs @@ -0,0 +1,33 @@ +// Local example/test server. It does not implement or proxy biometric verification. +import http from 'node:http'; +import { readFile } from 'node:fs/promises'; +import { resolve, extname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = fileURLToPath(new URL('.', import.meta.url)); +const types = { '.html': 'text/html', '.js': 'text/javascript', '.wasm': 'application/wasm' }; +const server = http.createServer(async (request, response) => { + const pathname = new URL(request.url, 'http://localhost').pathname; + // Tests reach the real browser Fetch path. This deliberately invalid attestation must fail. + if (process.env.WALLETKIT_BROWSER_TEST === '1' && pathname === '/v1/enclave-assignment') { + response.writeHead(200, { + 'Content-Type': 'application/json', 'Cache-Control': 'no-store', + 'Set-Cookie': 'flamingo_test=assigned; Path=/; HttpOnly; SameSite=Lax', + }); + response.end(JSON.stringify({ attestation: 'AA==', public_key: 'AA==' })); + return; + } + const file = resolve(root, `.${pathname === '/' ? '/index.html' : pathname}`); + if (!file.startsWith(root) || !['GET', 'HEAD'].includes(request.method)) { + response.writeHead(404).end(); + return; + } + try { + const content = await readFile(file); + response.writeHead(200, { 'Content-Type': types[extname(file)] ?? 'application/octet-stream', 'Cache-Control': 'no-store' }); + response.end(request.method === 'HEAD' ? undefined : content); + } catch { + response.writeHead(404).end(); + } +}); +server.listen(Number(process.env.PORT ?? 4173), '127.0.0.1'); diff --git a/web/test-rust-browser.sh b/web/test-rust-browser.sh new file mode 100644 index 000000000..147267409 --- /dev/null +++ b/web/test-rust-browser.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")/.." +root="$PWD" +flamingo_dir="${FLAMINGO_DIR:-$root/target/web-deps/flamingo}" +pontifex_dir="${PONTIFEX_DIR:-$root/target/web-deps/pontifex}" +export CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUNNER=wasm-bindgen-test-runner +export CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-$root/target}" +if [[ "$CARGO_TARGET_DIR" != /* ]]; then + export CARGO_TARGET_DIR="$root/$CARGO_TARGET_DIR" +fi +( + cd "$pontifex_dir" + cargo +1.98.1 test --no-default-features --features channel,attestation \ + --target wasm32-unknown-unknown --lib +) +( + cd "$flamingo_dir" + cargo test -p flamingo-verifier-client --target wasm32-unknown-unknown --lib \ + --config "patch.crates-io.pontifex.path=\"$pontifex_dir\"" +) diff --git a/web/tests/client.spec.js b/web/tests/client.spec.js new file mode 100644 index 000000000..29d282985 --- /dev/null +++ b/web/tests/client.spec.js @@ -0,0 +1,80 @@ +import { test, expect } from '@playwright/test'; + +test.beforeEach(async ({ page }) => { await page.goto('/'); }); + +test('worker initializes with approved-shaped pins and rejects invalid input before upload', async ({ page }) => { + const uploads = []; + page.on('request', request => { if (request.method() === 'POST') uploads.push(request.url()); }); + const result = await page.evaluate(async () => { + const { FlamingoWorker } = await import('/client.js'); + const client = new FlamingoWorker(); + try { + const initialized = await client.initialize({ hostUrl: location.origin, + measurements: { 0: '01'.repeat(48), 1: '02'.repeat(48), 2: '03'.repeat(48) } }); + try { + await client.match({ liveImage: new Uint8Array(), credentialImage: new Uint8Array([1]), + hashesJson: new Uint8Array([2]), challengeImage: new Uint8Array([3]), matchThreshold: .5 }); + } catch (error) { return { initialized, code: error.code }; } + } finally { client.close(); } + }); + expect(result).toEqual({ initialized: { ready: true }, code: 'invalid_input' }); + expect(uploads).toEqual([]); +}); + +test('invalid pins and caller cookies fail configuration', async ({ page }) => { + const codes = await page.evaluate(async () => { + const { FlamingoWorker } = await import('/client.js'); + const client = new FlamingoWorker(); + const codes = []; + try { + for (const config of [ + { measurements: {} }, + { measurements: { 0: '00'.repeat(48), 1: '02'.repeat(48), 2: '03'.repeat(48) } }, + { measurements: { 0: '01'.repeat(48), 1: '02'.repeat(48), 2: '03'.repeat(48) }, headers: { Cookie: 'forbidden' } }, + ]) { + try { await client.initialize({ hostUrl: location.origin, ...config }); } + catch (error) { codes.push(error.code); } + } + } finally { client.close(); } + return codes; + }); + expect(codes).toEqual(['configuration', 'configuration', 'configuration']); +}); + +test('real Fetch rejects an untrusted assignment before any biometric upload', async ({ page, context }) => { + const posts = []; + page.on('request', request => { if (request.method() === 'POST') posts.push({ url: request.url(), body: request.postData() }); }); + const result = await page.evaluate(async () => { + const { FlamingoWorker } = await import('/client.js'); + const client = new FlamingoWorker(); + try { + await client.initialize({ hostUrl: location.origin, + measurements: { 0: '01'.repeat(48), 1: '02'.repeat(48), 2: '03'.repeat(48) } }); + try { + await client.match({ liveImage: new Uint8Array([1]), credentialImage: new Uint8Array([2]), + hashesJson: new Uint8Array([3]), challengeImage: new Uint8Array([4]), matchThreshold: .5 }); + } catch (error) { return { code: error.code, message: error.message }; } + } finally { client.close(); } + }); + expect(result.code).toBe('verifier'); + expect(result.message).not.toContain('http'); + expect(posts).toHaveLength(1); + expect(posts[0].url).toContain('/v1/enclave-assignment'); + expect(posts[0].body).toBeNull(); + expect((await context.cookies()).some(cookie => cookie.name === 'flamingo_test')).toBe(true); +}); + +test('closing the worker settles pending initialization and rejects later requests', async ({ page }) => { + const messages = await page.evaluate(async () => { + const { FlamingoWorker } = await import('/client.js'); + const client = new FlamingoWorker(); + const pending = client.initialize({}); + client.close(); + const result = []; + for (const operation of [pending, client.match({})]) { + try { await operation; } catch (error) { result.push(error.message); } + } + return result; + }); + expect(messages).toEqual(['Client closed', 'Client is closed']); +}); diff --git a/web/worker.js b/web/worker.js new file mode 100644 index 000000000..b588d0e24 --- /dev/null +++ b/web/worker.js @@ -0,0 +1,55 @@ +import init, { FlamingoClient } from './pkg/walletkit_web.js'; + +const ready = init(); +let client; +let nextHandle = 0; +const verified = new Map(); + +function dispose() { + client?.free(); + client = undefined; + for (const token of verified.values()) token.free(); + verified.clear(); +} + +self.onmessage = async ({ data: { id, operation, payload } }) => { + try { + await ready; + let result; + switch (operation) { + case 'initialize': { + const replacement = new FlamingoClient(payload); + dispose(); + client = replacement; + result = { ready: true }; + break; + } + case 'match': { + if (!client) throw Object.assign(new Error('Initialize the client first'), { code: 'configuration' }); + const outcome = await client.performMatch(payload); + try { + const token = outcome.verified; + const handle = token ? ++nextHandle : undefined; + if (token) verified.set(handle, token); + result = { matched: outcome.matched, rejection: outcome.rejection, verifiedHandle: handle }; + } finally { + outcome.free(); + } + break; + } + case 'release': + verified.get(payload)?.free(); + verified.delete(payload); + result = { released: true }; + break; + default: + throw Object.assign(new Error('Unknown operation'), { code: 'invalid_input' }); + } + self.postMessage({ id, result }); + } catch (error) { + self.postMessage({ id, error: { + code: error?.code ?? 'worker', + message: error?.code ? error.message : 'Browser operation failed', + } }); + } +}; From a98f5152951d0375669179d337b294bee409d641 Mon Sep 17 00:00:00 2001 From: Soam Desai Date: Thu, 17 Sep 2026 14:55:44 -0700 Subject: [PATCH 2/3] test: remove browser demo and keep minimal fixture --- web/example.js | 35 ----------------------------------- web/index.html | 31 ------------------------------- web/package-lock.json | 4 ++-- web/package.json | 3 +-- web/playwright.config.js | 3 +-- web/{ => tests}/server.mjs | 13 +++++++++---- 6 files changed, 13 insertions(+), 76 deletions(-) delete mode 100644 web/example.js delete mode 100644 web/index.html rename web/{ => tests}/server.mjs (73%) diff --git a/web/example.js b/web/example.js deleted file mode 100644 index 61f7814a9..000000000 --- a/web/example.js +++ /dev/null @@ -1,35 +0,0 @@ -import { FlamingoWorker } from './client.js'; - -const form = document.querySelector('#match'); -const status = document.querySelector('#status'); -const submit = form.querySelector('[type=submit]'); -let active; -document.querySelector('#cancel').onclick = () => active?.close('Cancelled'); -form.onsubmit = async event => { - event.preventDefault(); - const client = new FlamingoWorker(); - active = client; - submit.disabled = true; - status.textContent = 'Loading and verifying…'; - try { - const data = new FormData(form); - const input = { matchThreshold: Number(data.get('matchThreshold')) }; - for (const name of ['liveImage', 'credentialImage', 'hashesJson', 'challengeImage']) { - input[name] = new Uint8Array(await data.get(name).arrayBuffer()); - } - await client.initialize({ - hostUrl: data.get('hostUrl'), - measurements: { 0: data.get('pcr0'), 1: data.get('pcr1'), 2: data.get('pcr2') }, - }); - const result = await client.match(input); - status.textContent = result.matched - ? 'Signed match verified. World ID proof generation is a separate integration.' - : `Match rejected: ${result.rejection} (unsigned rejection).`; - } catch (error) { - status.textContent = error.message; - } finally { - client.close(); - active = undefined; - submit.disabled = false; - } -}; diff --git a/web/index.html b/web/index.html deleted file mode 100644 index c99571d4f..000000000 --- a/web/index.html +++ /dev/null @@ -1,31 +0,0 @@ - - - - -WalletKit browser match - -

WalletKit browser match

-

Existing three-way Flamingo flow. Use an approved enclave configuration and test PCP. -This example does not enroll a selfie credential or generate a World ID proof.

-
- - - - - - - - - - - -
-Ready. - - diff --git a/web/package-lock.json b/web/package-lock.json index 5858b01b1..8c010cb28 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,10 +1,10 @@ { - "name": "walletkit-browser-example", + "name": "walletkit-browser-tests", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "walletkit-browser-example", + "name": "walletkit-browser-tests", "devDependencies": { "@playwright/test": "1.63.0" } diff --git a/web/package.json b/web/package.json index 4ed825951..1931cdccd 100644 --- a/web/package.json +++ b/web/package.json @@ -1,10 +1,9 @@ { - "name": "walletkit-browser-example", + "name": "walletkit-browser-tests", "private": true, "type": "module", "scripts": { "build": "bash build.sh", - "serve": "node server.mjs", "test": "playwright test" }, "devDependencies": { diff --git a/web/playwright.config.js b/web/playwright.config.js index 14703ef12..2be23de6e 100644 --- a/web/playwright.config.js +++ b/web/playwright.config.js @@ -7,9 +7,8 @@ export default defineConfig({ projects: [{ name: 'chromium', use: { browserName: 'chromium' } }, { name: 'webkit', use: { browserName: 'webkit' } }], webServer: { - command: 'node server.mjs', + command: 'node tests/server.mjs', url: 'http://127.0.0.1:4173', - env: { WALLETKIT_BROWSER_TEST: '1' }, reuseExistingServer: false, }, }); diff --git a/web/server.mjs b/web/tests/server.mjs similarity index 73% rename from web/server.mjs rename to web/tests/server.mjs index ecc0eb897..b082e81d2 100644 --- a/web/server.mjs +++ b/web/tests/server.mjs @@ -1,15 +1,20 @@ -// Local example/test server. It does not implement or proxy biometric verification. +// Browser-test fixture: a blank document, static WASM assets, and invalid attestation. import http from 'node:http'; import { readFile } from 'node:fs/promises'; import { resolve, extname } from 'node:path'; import { fileURLToPath } from 'node:url'; -const root = fileURLToPath(new URL('.', import.meta.url)); +const root = fileURLToPath(new URL('../', import.meta.url)); const types = { '.html': 'text/html', '.js': 'text/javascript', '.wasm': 'application/wasm' }; const server = http.createServer(async (request, response) => { const pathname = new URL(request.url, 'http://localhost').pathname; + if (pathname === '/') { + response.writeHead(200, { 'Content-Type': 'text/html', 'Cache-Control': 'no-store' }); + response.end('WalletKit browser tests'); + return; + } // Tests reach the real browser Fetch path. This deliberately invalid attestation must fail. - if (process.env.WALLETKIT_BROWSER_TEST === '1' && pathname === '/v1/enclave-assignment') { + if (pathname === '/v1/enclave-assignment') { response.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', 'Set-Cookie': 'flamingo_test=assigned; Path=/; HttpOnly; SameSite=Lax', @@ -17,7 +22,7 @@ const server = http.createServer(async (request, response) => { response.end(JSON.stringify({ attestation: 'AA==', public_key: 'AA==' })); return; } - const file = resolve(root, `.${pathname === '/' ? '/index.html' : pathname}`); + const file = resolve(root, `.${pathname}`); if (!file.startsWith(root) || !['GET', 'HEAD'].includes(request.method)) { response.writeHead(404).end(); return; From 2a4b2b4ac5fa4d9d193133b73def1ef763162cc0 Mon Sep 17 00:00:00 2001 From: Soam Desai Date: Thu, 17 Sep 2026 15:17:35 -0700 Subject: [PATCH 3/3] fix: use browser-compatible dependencies in standard WASM builds --- .gitattributes | 2 - .github/workflows/ci.yml | 13 +- Cargo.lock | 18 +- Cargo.toml | 7 +- deny.toml | 4 + web/bootstrap-deps.sh | 33 ---- web/build.sh | 2 +- web/cargo.sh | 21 --- web/patches/flamingo-wasm.patch | 309 -------------------------------- web/patches/pontifex-wasm.patch | 136 -------------- web/test-rust-browser.sh | 21 --- 11 files changed, 19 insertions(+), 547 deletions(-) delete mode 100644 .gitattributes delete mode 100644 web/bootstrap-deps.sh delete mode 100644 web/cargo.sh delete mode 100644 web/patches/flamingo-wasm.patch delete mode 100644 web/patches/pontifex-wasm.patch delete mode 100644 web/test-rust-browser.sh diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index 44345f13c..000000000 --- a/.gitattributes +++ /dev/null @@ -1,2 +0,0 @@ -# Unified diffs intentionally contain a leading context marker on blank/tabbed lines. -web/patches/*.patch -whitespace diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5d9cde1a4..3d381c4b9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -111,8 +111,8 @@ jobs: - name: Run WASM compile check run: | - bash web/bootstrap-deps.sh - bash web/cargo.sh check -p walletkit --features embed-zkeys --target wasm32-unknown-unknown + cargo check -p walletkit --features embed-zkeys --locked --target wasm32-unknown-unknown + git diff --exit-code -- Cargo.lock # Keep this version aligned with the wasm-bindgen version in Cargo.lock. - name: Install WASM browser test runner @@ -121,14 +121,7 @@ jobs: - name: Test walletkit-sqlite in a browser runner run: | CHROMEDRIVER="$CHROMEWEBDRIVER/chromedriver" \ - bash web/cargo.sh test -p walletkit-sqlite --target wasm32-unknown-unknown - - - name: Test Flamingo and Pontifex in a browser worker - env: - CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUNNER: wasm-bindgen-test-runner - run: | - export CHROMEDRIVER="$CHROMEWEBDRIVER/chromedriver" - bash web/test-rust-browser.sh + cargo test -p walletkit-sqlite --locked --target wasm32-unknown-unknown - name: Build JavaScript and WASM package run: bash web/build.sh diff --git a/Cargo.lock b/Cargo.lock index 14cd8c9e7..1f8d8aa06 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3287,8 +3287,7 @@ checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe" [[package]] name = "flamingo-verifier-api-types" version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcd7aeda4fe87369c7302be8efc8f8b7061373a281ee2164ad29d008b45de9d2" +source = "git+https://github.com/worldcoin/flamingo?rev=3fc9028493359476aea6a871a95cd05951bc6513#3fc9028493359476aea6a871a95cd05951bc6513" dependencies = [ "serde", ] @@ -3296,13 +3295,13 @@ dependencies = [ [[package]] name = "flamingo-verifier-client" version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d16ff16567769109a6b945844ed02bc49df4946406cca0cc9c0342e2ecf15859" +source = "git+https://github.com/worldcoin/flamingo?rev=3fc9028493359476aea6a871a95cd05951bc6513#3fc9028493359476aea6a871a95cd05951bc6513" dependencies = [ "base64 0.22.1", "flamingo-verifier-api-types", "flamingo-verifier-protocol", "flamingo-verifier-sealed-types", + "getrandom 0.2.17", "hex", "pontifex", "reqwest 0.12.28", @@ -3315,8 +3314,7 @@ dependencies = [ [[package]] name = "flamingo-verifier-protocol" version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13864bdee45533d6c83acbcbf1df5faadd36d451a1e42562e1bfc90257213699" +source = "git+https://github.com/worldcoin/flamingo?rev=3fc9028493359476aea6a871a95cd05951bc6513#3fc9028493359476aea6a871a95cd05951bc6513" dependencies = [ "ark-ff 0.5.0", "coset", @@ -3330,8 +3328,7 @@ dependencies = [ [[package]] name = "flamingo-verifier-sealed-types" version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08d5fadf79c8263ee4faae1f024a2d333e973ba5c25462efa898d03e31e19ad2" +source = "git+https://github.com/worldcoin/flamingo?rev=3fc9028493359476aea6a871a95cd05951bc6513#3fc9028493359476aea6a871a95cd05951bc6513" dependencies = [ "ciborium", "flamingo-verifier-protocol", @@ -5529,12 +5526,12 @@ dependencies = [ [[package]] name = "pontifex" version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f05f626f4b534d7891a41b0a0ffc47e8e7eafa40c63f4dbacfa9b2828527195" +source = "git+https://github.com/worldcoin/pontifex?rev=89d79f45d592a5b7446f106e60372fe357c76fa8#89d79f45d592a5b7446f106e60372fe357c76fa8" dependencies = [ "ciborium", "const-fnv1a-hash", "coset", + "getrandom 0.2.17", "p384", "quantum-box", "serde", @@ -5543,6 +5540,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tracing", + "web-time", "webpki", "x509-cert", "zeroize", diff --git a/Cargo.toml b/Cargo.toml index a0001ead2..f01092f4e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,6 @@ members = [ "xtask", ] resolver = "2" -exclude = ["target/web-deps/flamingo", "target/web-deps/pontifex"] [workspace.package] version = "0.23.0" @@ -43,9 +42,9 @@ clap = "4" ctor = "0.2" dirs = "6" dotenvy = "0.15.7" -flamingo-verifier-client = "0.3.0" -flamingo-verifier-protocol = "0.3.0" -flamingo-verifier-sealed-types = "0.3.0" +flamingo-verifier-client = { version = "0.3.0", git = "https://github.com/worldcoin/flamingo", rev = "3fc9028493359476aea6a871a95cd05951bc6513" } +flamingo-verifier-protocol = { version = "0.3.0", git = "https://github.com/worldcoin/flamingo", rev = "3fc9028493359476aea6a871a95cd05951bc6513" } +flamingo-verifier-sealed-types = { version = "0.3.0", git = "https://github.com/worldcoin/flamingo", rev = "3fc9028493359476aea6a871a95cd05951bc6513" } eyre = "0.6" getrandom = "0.3" hex = "0.4" diff --git a/deny.toml b/deny.toml index a5a02e7bb..7766ec8b1 100644 --- a/deny.toml +++ b/deny.toml @@ -4,6 +4,10 @@ all-features = true [sources] unknown-registry = "deny" +allow-git = [ + "https://github.com/worldcoin/flamingo", + "https://github.com/worldcoin/pontifex", +] [bans] deny = [ diff --git a/web/bootstrap-deps.sh b/web/bootstrap-deps.sh deleted file mode 100644 index c050db86d..000000000 --- a/web/bootstrap-deps.sh +++ /dev/null @@ -1,33 +0,0 @@ -#!/usr/bin/env bash -# Reproducible temporary dependency patches, pending upstream releases. -set -euo pipefail -cd "$(dirname "${BASH_SOURCE[0]}")/.." -root="$PWD" - -prepare() { - local name="$1" revision="$2" - local destination="$root/target/web-deps/$name" - local patch="$root/web/patches/$name-wasm.patch" - if [[ ! -d "$destination/.git" ]]; then - if [[ -e "$destination" ]]; then - echo "Refusing to overwrite existing directory: $destination" >&2 - exit 1 - fi - git init -q "$destination" - git -C "$destination" remote add origin "https://github.com/worldcoin/$name.git" - git -C "$destination" fetch -q --depth 1 origin "$revision" - git -C "$destination" switch -q --detach FETCH_HEAD - fi - if [[ "$(git -C "$destination" rev-parse HEAD)" != "$revision" ]]; then - echo "Unexpected revision in $destination; preserving the checkout." >&2 - exit 1 - fi - if git -C "$destination" apply --reverse --check "$patch" 2>/dev/null; then - return - fi - git -C "$destination" apply --check "$patch" - git -C "$destination" apply "$patch" -} - -prepare pontifex 19fc7eccb8a46babaf688b76fe6a0021d712cbdc -prepare flamingo 3484db339622dd3431e5ed1f3286df5fe7e94ea0 diff --git a/web/build.sh b/web/build.sh index 4e7618037..5d506b0a5 100644 --- a/web/build.sh +++ b/web/build.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash set -euo pipefail cd "$(dirname "${BASH_SOURCE[0]}")/.." -bash web/cargo.sh build -p walletkit-web --release --target wasm32-unknown-unknown +cargo build -p walletkit-web --release --locked --target wasm32-unknown-unknown wasm-bindgen "${CARGO_TARGET_DIR:-target}/wasm32-unknown-unknown/release/walletkit_web.wasm" \ --target web --out-dir web/pkg --out-name walletkit_web diff --git a/web/cargo.sh b/web/cargo.sh deleted file mode 100644 index 89ecd0249..000000000 --- a/web/cargo.sh +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env bash -# Develop the coordinated WalletKit / Flamingo / Pontifex changes before their releases. -set -euo pipefail -cd "$(dirname "${BASH_SOURCE[0]}")/.." -flamingo_dir="${FLAMINGO_DIR:-$PWD/target/web-deps/flamingo}" -pontifex_dir="${PONTIFEX_DIR:-$PWD/target/web-deps/pontifex}" -for dep_dir in "$flamingo_dir" "$pontifex_dir"; do - if [[ ! -f "$dep_dir/Cargo.toml" ]]; then - echo "Missing dependency checkout: $dep_dir. Run bash web/bootstrap-deps.sh first." >&2 - exit 1 - fi -done -command="$1" -shift -exec cargo "$command" \ - --config "patch.crates-io.pontifex.path=\"$pontifex_dir\"" \ - --config "patch.crates-io.flamingo-verifier-client.path=\"$flamingo_dir/verifier/client\"" \ - --config "patch.crates-io.flamingo-verifier-api-types.path=\"$flamingo_dir/verifier/api-types\"" \ - --config "patch.crates-io.flamingo-verifier-protocol.path=\"$flamingo_dir/verifier/protocol\"" \ - --config "patch.crates-io.flamingo-verifier-sealed-types.path=\"$flamingo_dir/verifier/sealed-types\"" \ - "$@" diff --git a/web/patches/flamingo-wasm.patch b/web/patches/flamingo-wasm.patch deleted file mode 100644 index 9394d5fe6..000000000 --- a/web/patches/flamingo-wasm.patch +++ /dev/null @@ -1,309 +0,0 @@ -diff --git a/verifier/client/Cargo.toml b/verifier/client/Cargo.toml -index 27fcf97..c0fb11f 100644 ---- a/verifier/client/Cargo.toml -+++ b/verifier/client/Cargo.toml -@@ -23,8 +23,18 @@ thiserror.workspace = true - url.workspace = true - - [dev-dependencies] --axum.workspace = true - flamingo-verifier-protocol.workspace = true - flamingo-verifier-sealed-types.workspace = true - hex-literal.workspace = true -+ -+[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies] -+axum.workspace = true - tokio.workspace = true -+ -+[target.'cfg(target_arch = "wasm32")'.dependencies] -+getrandom = { version = "0.2", features = ["js"] } -+ -+[target.'cfg(target_arch = "wasm32")'.dev-dependencies] -+wasm-bindgen-test = "=0.3.76" -+wasm-bindgen = "=0.2.126" -+js-sys = "=0.3.103" -diff --git a/verifier/client/src/browser_tests.rs b/verifier/client/src/browser_tests.rs -new file mode 100644 -index 0000000..0584539 ---- /dev/null -+++ b/verifier/client/src/browser_tests.rs -@@ -0,0 +1,178 @@ -+//! Browser runtime checks for the HTTP/encrypted-channel boundary. -+use super::*; -+use flamingo_verifier_sealed_types::{AttestedStatement, FailureReason}; -+use pontifex::ChannelEnclave; -+use wasm_bindgen::prelude::*; -+use wasm_bindgen_test::wasm_bindgen_test; -+ -+wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_dedicated_worker); -+ -+#[wasm_bindgen(inline_js = r#" -+let originalFetch; -+let last; -+export function installFetch(body, status, hang) { -+ originalFetch = globalThis.fetch; -+ globalThis.fetch = async request => { -+ last = { credentials: request.credentials, cache: request.cache, body: await request.text() }; -+ if (hang) return await new Promise((_, reject) => { -+ const abort = () => reject(request.signal.reason); -+ if (request.signal.aborted) abort(); -+ else request.signal.addEventListener('abort', abort, { once: true }); -+ }); -+ const response = new Response(body, { status, headers: { 'Content-Type': 'application/json' } }); -+ Object.defineProperty(response, 'url', { value: request.url }); -+ return response; -+ }; -+} -+export function restoreFetch() { globalThis.fetch = originalFetch; } -+export function lastRequest() { return JSON.stringify(last); } -+"#)] -+extern "C" { -+ fn installFetch(body: &str, status: u16, hang: bool); -+ fn restoreFetch(); -+ fn lastRequest() -> String; -+} -+ -+struct FetchGuard; -+impl Drop for FetchGuard { -+ fn drop(&mut self) { -+ restoreFetch(); -+ } -+} -+ -+fn stub(body: &str, status: u16, hang: bool) -> FetchGuard { -+ installFetch(body, status, hang); -+ FetchGuard -+} -+ -+fn client() -> FlamingoVerifierClient { -+ // No network-accessible verifier bypass: test code lives in this private module. -+ let config = Config::from_json( -+ &serde_json::json!({ -+ "host_url": "https://flamingo.invalid", -+ "allowed_pcr_configs": [[{"index": 0, "value": "01".repeat(48)}]], -+ "request_timeout_millis": 20 -+ }) -+ .to_string(), -+ ) -+ .unwrap(); -+ FlamingoVerifierClient::new(config).unwrap() -+} -+ -+fn exchange( -+ answer: &MatchResult, -+ foreign_reply: bool, -+) -> (String, String, pontifex::ResponseOpener) { -+ let enclave = ChannelEnclave::generate(ChannelDomain::new(MATCH_CHANNEL_DOMAIN)).unwrap(); -+ let consumer = ChannelConsumer::from_unverified_public_key( -+ ChannelDomain::new(MATCH_CHANNEL_DOMAIN), -+ &enclave.public_key(), -+ ) -+ .unwrap(); -+ let (sealed, opener) = consumer.seal_to_enclave(b"private-image-marker").unwrap(); -+ let (plaintext, sealer) = enclave.open(&sealed).unwrap(); -+ assert_eq!(&*plaintext, b"private-image-marker"); -+ let sealer = if foreign_reply { -+ let (other, _) = consumer.seal_to_enclave(b"another request").unwrap(); -+ enclave.open(&other).unwrap().1 -+ } else { -+ sealer -+ }; -+ let response = sealer.seal(&answer.to_padded_cbor().unwrap()).unwrap(); -+ ( -+ STANDARD.encode(sealed), -+ serde_json::json!({"response_ciphertext": STANDARD.encode(response)}).to_string(), -+ opener, -+ ) -+} -+ -+#[wasm_bindgen_test] -+async fn encrypted_response_and_browser_request_policy() { -+ let answer = MatchResult::Failed(FailureReason::MatchBelowThreshold); -+ let (ciphertext, response, opener) = exchange(&answer, false); -+ let _guard = stub(&response, 200, false); -+ let client = client(); -+ let request = client -+ .configure_request(client.http.post("https://flamingo.invalid/v1/matches")) -+ .json(&MatchRequestBody { ciphertext }); -+ assert_eq!( -+ client.request_match_with(request, opener).await.unwrap(), -+ answer -+ ); -+ let observed: serde_json::Value = serde_json::from_str(&lastRequest()).unwrap(); -+ assert_eq!(observed["credentials"], "include"); -+ assert_eq!(observed["cache"], "no-store"); -+ assert!( -+ !observed["body"] -+ .as_str() -+ .unwrap() -+ .contains("private-image-marker") -+ ); -+ let body: serde_json::Value = serde_json::from_str(observed["body"].as_str().unwrap()).unwrap(); -+ assert_eq!(body.as_object().unwrap().len(), 1); -+} -+ -+#[wasm_bindgen_test] -+async fn unrelated_response_is_rejected() { -+ let (ciphertext, response, opener) = -+ exchange(&MatchResult::Failed(FailureReason::MalformedInputs), true); -+ let _guard = stub(&response, 200, false); -+ let client = client(); -+ let request = client -+ .configure_request(client.http.post("https://flamingo.invalid/v1/matches")) -+ .json(&MatchRequestBody { ciphertext }); -+ assert!(matches!( -+ client.request_match_with(request, opener).await, -+ Err(Error::Channel(_)) -+ )); -+} -+ -+#[wasm_bindgen_test] -+async fn invalid_signing_attestation_is_rejected() { -+ let answer = MatchResult::Success(AttestedStatement { -+ token: match_token::MatchToken::from_bytes(vec![1, 2, 3]), -+ signing_key_attestation: vec![0; 8], -+ }); -+ let (ciphertext, response, opener) = exchange(&answer, false); -+ let _guard = stub(&response, 200, false); -+ let client = client(); -+ let request = client -+ .configure_request(client.http.post("https://flamingo.invalid/v1/matches")) -+ .json(&MatchRequestBody { ciphertext }); -+ assert!(matches!( -+ client.request_match_with(request, opener).await, -+ Err(Error::Attestation(_)) -+ )); -+} -+ -+#[wasm_bindgen_test] -+async fn untrusted_assignment_and_stale_routing_fail_closed() { -+ let _guard = stub(r#"{"attestation":"AA==","public_key":"AA=="}"#, 200, false); -+ assert!(matches!( -+ client().request_assignment().await, -+ Err(Error::Channel(_)) -+ )); -+ drop(_guard); -+ let _guard = stub( -+ r#"{"allowRetry":true,"error":{"code":"reassign_required","message":"stale"}}"#, -+ 409, -+ false, -+ ); -+ let (ciphertext, _, opener) = -+ exchange(&MatchResult::Failed(FailureReason::MalformedInputs), false); -+ let client = client(); -+ let request = client -+ .configure_request(client.http.post("https://flamingo.invalid/v1/matches")) -+ .json(&MatchRequestBody { ciphertext }); -+ assert!(matches!( -+ client.request_match_with(request, opener).await, -+ Err(Error::ReassignRequired) -+ )); -+} -+ -+#[wasm_bindgen_test] -+async fn browser_fetch_is_aborted_at_the_deadline() { -+ let _guard = stub("", 200, true); -+ let error = client().request_assignment().await.unwrap_err(); -+ assert!(matches!(error, Error::Request(error) if error.is_timeout())); -+} -diff --git a/verifier/client/src/client.rs b/verifier/client/src/client.rs -index 9811f93..99f80f6 100644 ---- a/verifier/client/src/client.rs -+++ b/verifier/client/src/client.rs -@@ -49,6 +49,13 @@ pub struct FlamingoVerifierClient { - verifier: Verifier, - } - -+#[cfg_attr( -+ target_arch = "wasm32", -+ expect( -+ clippy::future_not_send, -+ reason = "Fetch and JavaScript futures stay on their originating browser worker" -+ ) -+)] - impl FlamingoVerifierClient { - /// Builds a client from `config`. - /// -@@ -61,8 +68,8 @@ impl FlamingoVerifierClient { - - /// Builds a client using an externally configured HTTP client builder. - /// -- /// The configured cookie store, connection timeout, and request timeout are applied to the -- /// supplied builder. -+ /// Native clients use a cookie store and connection/request timeouts. Browser clients -+ /// use Fetch credentials and a per-request deadline; the browser manages connections. - /// - /// # Errors - /// -@@ -71,13 +78,13 @@ impl FlamingoVerifierClient { - config: Config, - http: reqwest::ClientBuilder, - ) -> Result { -+ #[cfg(not(target_arch = "wasm32"))] - let http = http - // Replays the ALB's affinity cookie, so the match reaches the enclave that was assigned. - .cookie_store(true) - .connect_timeout(config.connect_timeout()) -- .timeout(config.request_timeout()) -- .build() -- .map_err(Error::Transport)?; -+ .timeout(config.request_timeout()); -+ let http = http.build().map_err(Error::Transport)?; - - Ok(Self { - verifier: config.verifier()?, -@@ -90,12 +97,13 @@ impl FlamingoVerifierClient { - /// - /// Callers may customize the returned builder before passing it to - /// [`Self::request_assignment_with`]. -+ #[must_use] - pub fn build_assignment_request(&self) -> reqwest::RequestBuilder { - let url = format!( - "{}/v1/enclave-assignment", - self.config.host_url().as_str().trim_end_matches('/') - ); -- self.http.post(url) -+ self.configure_request(self.http.post(url)) - } - - /// Requests an assignment and returns it only if its attestation verifies. -@@ -174,9 +182,11 @@ impl FlamingoVerifierClient { - "{}/v1/matches", - self.config.host_url().as_str().trim_end_matches('/') - ); -- let request = self.http.post(url).json(&MatchRequestBody { -- ciphertext: STANDARD.encode(sealed), -- }); -+ let request = self -+ .configure_request(self.http.post(url)) -+ .json(&MatchRequestBody { -+ ciphertext: STANDARD.encode(sealed), -+ }); - - Ok((request, opener)) - } -@@ -275,9 +285,15 @@ impl FlamingoVerifierClient { - allow_retry: envelope.allow_retry, - } - } -+ -+ fn configure_request(&self, request: reqwest::RequestBuilder) -> reqwest::RequestBuilder { -+ #[cfg(target_arch = "wasm32")] -+ let request = request.fetch_credentials_include().fetch_cache_no_store(); -+ request.timeout(self.config.request_timeout()) -+ } - } - --#[cfg(test)] -+#[cfg(all(test, not(target_arch = "wasm32")))] - mod tests { - use std::net::{Ipv4Addr, SocketAddr}; - use std::sync::{Arc, Mutex}; -@@ -581,3 +597,7 @@ mod tests { - } - } - } -+ -+#[cfg(all(test, target_arch = "wasm32"))] -+#[path = "browser_tests.rs"] -+mod browser_tests; diff --git a/web/patches/pontifex-wasm.patch b/web/patches/pontifex-wasm.patch deleted file mode 100644 index 80b2bc186..000000000 --- a/web/patches/pontifex-wasm.patch +++ /dev/null @@ -1,136 +0,0 @@ -diff --git a/Cargo.toml b/Cargo.toml -index 76f0593..ac71509 100644 ---- a/Cargo.toml -+++ b/Cargo.toml -@@ -48,6 +48,8 @@ attestation = [ - "dep:p384", - "dep:webpki", - "dep:x509-cert", -+ "dep:web-time", -+ "dep:getrandom", - ] - - # Internal: The HTTPS-over-vsock transport, shared by `http` and `kms`. -@@ -114,7 +116,17 @@ p384 = { version = "0.13", default-features = false, features = ["ecdsa", "sha38 - [dev-dependencies] - aws-nitro-enclaves-nsm-api = { version = "0.4", default-features = false } - base64 = "0.22" -+hex-literal = "1" -+ -+[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies] - tokio-test = "0.4" - tokio = { version = "1", features = ["macros", "rt", "test-util", "time"] } - aws-smithy-runtime-api = { version = "1.9", features = ["client", "http-1x", "test-util"] } --hex-literal = "1" -+ -+[target.'cfg(target_arch = "wasm32")'.dev-dependencies] -+wasm-bindgen-test = "=0.3.76" -+ -+[target.'cfg(target_arch = "wasm32")'.dependencies] -+getrandom = { version = "0.2", features = ["js"], optional = true } -+quantum-box = { version = "0.1", features = ["wasm_js"], optional = true } -+web-time = { version = "1", optional = true } -diff --git a/src/attestation.rs b/src/attestation.rs -index 2439343..1d48b62 100644 ---- a/src/attestation.rs -+++ b/src/attestation.rs -@@ -1,6 +1,10 @@ - //! Verification of AWS Nitro Enclave attestation documents. - --use std::time::{Duration, SystemTime, UNIX_EPOCH}; -+use std::time::Duration; -+#[cfg(not(target_arch = "wasm32"))] -+use std::time::{SystemTime, UNIX_EPOCH}; -+#[cfg(target_arch = "wasm32")] -+use web_time::{SystemTime, UNIX_EPOCH}; - - use coset::{Algorithm, CoseSign1, iana}; - use p384::ecdsa::{Signature, VerifyingKey, signature::Verifier as _}; -@@ -449,7 +453,7 @@ impl Verifier { - } - } - --#[cfg(test)] -+#[cfg(all(test, not(target_arch = "wasm32")))] - mod tests { - use std::{ - collections::HashMap, -@@ -830,3 +834,65 @@ mod tests { - ); - } - } -+ -+#[cfg(all(test, target_arch = "wasm32"))] -+mod browser_tests { -+ use super::*; -+ use crate::test_fixtures::{ -+ TEN_YEARS, pcr0_only, real_attestation_bytes, real_attestation_verifier, -+ }; -+ use wasm_bindgen_test::wasm_bindgen_test; -+ -+ wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_dedicated_worker); -+ -+ #[wasm_bindgen_test] -+ fn expired_certificate_is_rejected_using_the_browser_clock() { -+ let verifier = Verifier::new(vec![pcr0_only()], TEN_YEARS); -+ assert!(matches!( -+ verifier.verify_attestation_document(&real_attestation_bytes()), -+ Err(Error::ChainInvalid(_)) -+ )); -+ } -+ -+ #[wasm_bindgen_test] -+ fn real_signature_measurements_and_freshness_are_checked_in_a_worker() { -+ // Only the expired fixture's certificate time is skipped, using an existing cfg(test) -+ // hook. Production verification always checks certificate time and document freshness. -+ let bytes = real_attestation_bytes(); -+ let verifier = real_attestation_verifier(); -+ let verified = verifier -+ .verify_attestation_document(&bytes) -+ .expect("valid signed fixture"); -+ assert_eq!( -+ verified.document().public_key.as_ref().unwrap().as_slice(), -+ crate::test_fixtures::ATTESTED_PUBLIC_KEY.as_slice() -+ ); -+ -+ let mut tampered = bytes.clone(); -+ *tampered.last_mut().unwrap() ^= 1; -+ assert!(matches!( -+ verifier.verify_attestation_document(&tampered), -+ Err(Error::SignatureInvalid(_)) -+ )); -+ -+ let wrong_pcr = Verifier::new(vec![PcrConfig::new([1; 48])], TEN_YEARS) -+ .with_skipped_certificate_time_check(); -+ assert!(matches!( -+ wrong_pcr.verify_attestation_document(&bytes), -+ Err(Error::CodeUntrusted { .. }) -+ )); -+ -+ let stale = -+ Verifier::new(vec![pcr0_only()], Duration::ZERO).with_skipped_certificate_time_check(); -+ assert!(matches!( -+ stale.verify_attestation_document(&bytes), -+ Err(Error::Stale { .. }) -+ )); -+ -+ let wrong_root = verifier.with_root_certificate(vec![0; 32]); -+ assert!(matches!( -+ wrong_root.verify_attestation_document(&bytes), -+ Err(Error::ChainInvalid(_)) -+ )); -+ } -+} -diff --git a/src/channel.rs b/src/channel.rs -index d403b50..ec1389b 100644 ---- a/src/channel.rs -+++ b/src/channel.rs -@@ -341,7 +341,7 @@ impl ResponseOpener { - } - } - --#[cfg(test)] -+#[cfg(all(test, not(target_arch = "wasm32")))] - mod tests { - use super::{ - ChannelConsumer, ChannelDomain, ChannelEnclave, ChannelError, REQUEST, RESPONSE, diff --git a/web/test-rust-browser.sh b/web/test-rust-browser.sh deleted file mode 100644 index 147267409..000000000 --- a/web/test-rust-browser.sh +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -cd "$(dirname "${BASH_SOURCE[0]}")/.." -root="$PWD" -flamingo_dir="${FLAMINGO_DIR:-$root/target/web-deps/flamingo}" -pontifex_dir="${PONTIFEX_DIR:-$root/target/web-deps/pontifex}" -export CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUNNER=wasm-bindgen-test-runner -export CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-$root/target}" -if [[ "$CARGO_TARGET_DIR" != /* ]]; then - export CARGO_TARGET_DIR="$root/$CARGO_TARGET_DIR" -fi -( - cd "$pontifex_dir" - cargo +1.98.1 test --no-default-features --features channel,attestation \ - --target wasm32-unknown-unknown --lib -) -( - cd "$flamingo_dir" - cargo test -p flamingo-verifier-client --target wasm32-unknown-unknown --lib \ - --config "patch.crates-io.pontifex.path=\"$pontifex_dir\"" -)