diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d7cf9c25..3d381c4b 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: | + 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 @@ -119,7 +121,17 @@ jobs: - name: Test walletkit-sqlite in a browser runner run: | CHROMEDRIVER="$CHROMEWEBDRIVER/chromedriver" \ - cargo test -p walletkit-sqlite --target wasm32-unknown-unknown + cargo test -p walletkit-sqlite --locked --target wasm32-unknown-unknown + + - 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 03b2dd1e..5a1cf8f5 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 25fba76f..1f8d8aa0 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", @@ -7573,6 +7571,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 +8381,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 +9424,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 1abec1a9..f01092f4 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", @@ -41,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/crates/walletkit-core/Cargo.toml b/crates/walletkit-core/Cargo.toml index 07483042..e83ada6a 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 458acdfb..28784f53 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 ea9d3998..40adf05c 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 00000000..cc8e6ae8 --- /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 00000000..8d23ebba --- /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/deny.toml b/deny.toml index a5a02e7b..7766ec8b 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/.gitignore b/web/.gitignore new file mode 100644 index 00000000..14395cda --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,3 @@ +/pkg/ +/node_modules/ +/test-results/ diff --git a/web/build.sh b/web/build.sh new file mode 100644 index 00000000..5d506b0a --- /dev/null +++ b/web/build.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")/.." +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/client.js b/web/client.js new file mode 100644 index 00000000..a8bfdcb4 --- /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/package-lock.json b/web/package-lock.json new file mode 100644 index 00000000..8c010cb2 --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,58 @@ +{ + "name": "walletkit-browser-tests", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "walletkit-browser-tests", + "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 00000000..1931cdcc --- /dev/null +++ b/web/package.json @@ -0,0 +1,12 @@ +{ + "name": "walletkit-browser-tests", + "private": true, + "type": "module", + "scripts": { + "build": "bash build.sh", + "test": "playwright test" + }, + "devDependencies": { + "@playwright/test": "1.63.0" + } +} diff --git a/web/playwright.config.js b/web/playwright.config.js new file mode 100644 index 00000000..2be23de6 --- /dev/null +++ b/web/playwright.config.js @@ -0,0 +1,14 @@ +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 tests/server.mjs', + url: 'http://127.0.0.1:4173', + reuseExistingServer: false, + }, +}); diff --git a/web/tests/client.spec.js b/web/tests/client.spec.js new file mode 100644 index 00000000..29d28298 --- /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/tests/server.mjs b/web/tests/server.mjs new file mode 100644 index 00000000..b082e81d --- /dev/null +++ b/web/tests/server.mjs @@ -0,0 +1,38 @@ +// 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 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 (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}`); + 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/worker.js b/web/worker.js new file mode 100644 index 00000000..b588d0e2 --- /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', + } }); + } +};