Skip to content
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/web.yml
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ jobs:
done

- name: Upload package
if: steps.version.outputs.version
if: steps.version.outputs.version || github.event_name == 'workflow_dispatch'
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: walletkit-web-package
Expand Down
71 changes: 71 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions crates/walletkit-web/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ wasm-bindgen = "=0.2.126"
wasm-bindgen-futures = "0.4.76"
walletkit-core = { workspace = true, features = ["embed-zkeys"] }
zeroize = { workspace = true }
selfie-enrollment-client = { git = "https://github.com/worldcoin/biometrics-tee", rev = "04481c5c9136ed2205e0d80d26ab495be6a2dd98", default-features = false }
selfie-enrollment-sealed-types = { git = "https://github.com/worldcoin/biometrics-tee", rev = "04481c5c9136ed2205e0d80d26ab495be6a2dd98" }
serde_json.workspace = true
web-sys = { version = "0.3.103", features = ["WorkerGlobalScope", "WorkerLocation", "Request", "RequestInit", "RequestMode", "RequestCredentials", "Headers", "Response", "ReadableStream", "ReadableStreamDefaultReader", "AbortController", "AbortSignal"] }

[lints]
workspace = true
191 changes: 191 additions & 0 deletions crates/walletkit-web/src/enrollment.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
//! Embedding enrollment over the dedicated browser-compatible TEE channel.
use js_sys::{Promise, Reflect, Uint8Array};
use selfie_enrollment_client::{Config, Error};
use selfie_enrollment_sealed_types::{EmbeddingResult, Failure};
use wasm_bindgen::{prelude::*, JsCast};
use wasm_bindgen_futures::JsFuture;
use zeroize::Zeroizing;

use crate::{
error::{invalid_argument, to_js},
js,
};

#[wasm_bindgen(typescript_custom_section)]
const TS_TYPES: &str = r#"
export type SelfieEmbeddingResult =
| { status: "success"; embedding: SelfieEmbedding }
| { status: "failed"; reason: "invalid_image" | "quality_rejected" | "extraction_failed" | "busy" | "invalid_request" };

export interface SelfieEmbedding {
vector: string;
embeddingType: string;
version: string;
inferenceBackend: string;
worker: { profile: string; executableSha384: string };
}
"#;

/// Extracts an embedding after verifying Nitro measurements and the worker identity.
///
/// `configJson` is the trusted release policy from the enrollment deployment.
/// `admissionPath` is a same-origin absolute path whose POST handler returns a
/// P256 admission ticket. Only the public server challenge is sent to that handler.
/// The handler must authorize the caller before signing; its key stays server-side.
/// The image is sealed inside this worker before upload and is never persisted.
///
/// Calls have a bounded deadline. Terminate the `WalletKit` instance to cancel an
/// in-flight enrollment immediately. This operation does not issue a credential.
///
/// # Errors
/// Throws a `TypeError` for an invalid admission path or browser context, and a
/// `SelfieEnrollmentError` for invalid policy, rejected attestation/admission, or
/// transport failures. Image/quality rejections return a typed failed result.
#[wasm_bindgen(js_name = extractSelfieEmbedding, unchecked_return_type = "Promise<SelfieEmbeddingResult>")]
pub fn extract_selfie_embedding(
config_json: String,
image: Vec<u8>,
admission_path: String,
) -> Promise {
let mut image = Zeroizing::new(image);
js::promise(async move {
if !admission_path.starts_with('/')
|| admission_path.starts_with("//")
|| admission_path.contains(['\\', '#', '\r', '\n', '\t'])
{
return Err(invalid_argument(
"admissionPath must be an absolute same-origin path",
));
}
let scope = js_sys::global()
.dyn_into::<web_sys::WorkerGlobalScope>()
.map_err(|_| {
invalid_argument("Enrollment requires the WalletKit worker")
})?;
let issuer_url = format!("{}{}", scope.location().origin(), admission_path);
let config: Config =
serde_json::from_str(&config_json).map_err(|_| to_js(Error::Config))?;
let result = selfie_enrollment_client::browser::extract(
&config,
std::mem::take(&mut *image),
move |challenge| async move {
let abort = AdmissionFetch(
web_sys::AbortController::new().map_err(|_| Error::Admission)?,
);
let options = web_sys::RequestInit::new();
options.set_method("POST");
options.set_credentials(web_sys::RequestCredentials::SameOrigin);
options.set_mode(web_sys::RequestMode::SameOrigin);
options.set_signal(Some(&abort.0.signal()));
options.set_body(
&serde_json::to_string(&challenge)
.map_err(|_| Error::Admission)?
.into(),
);
let request =
web_sys::Request::new_with_str_and_init(&issuer_url, &options)
.map_err(|_| Error::Admission)?;
request
.headers()
.set("Content-Type", "application/json")
.map_err(|_| Error::Admission)?;
let response = JsFuture::from(scope.fetch_with_request(&request))
.await
.map_err(|_| Error::Admission)?
.dyn_into::<web_sys::Response>()
.map_err(|_| Error::Admission)?;
let body = read_admission(response).await?;
serde_json::from_slice(&body).map_err(|_| Error::Admission)
},
None,
)
.await
.map_err(to_js)?;
result_to_js(&result)
})
}

// Aborting on drop also stops the fetch when the enclosing enrollment future times out.
struct AdmissionFetch(web_sys::AbortController);
impl Drop for AdmissionFetch {
fn drop(&mut self) {
self.0.abort();
}
}

#[allow(
clippy::future_not_send,
reason = "Browser streams stay on the WalletKit worker"
)]
async fn read_admission(response: web_sys::Response) -> Result<Vec<u8>, Error> {
if !response.ok() {
return Err(Error::Admission);
}
let reader = response
.body()
.ok_or(Error::Admission)?
.get_reader()
.dyn_into::<web_sys::ReadableStreamDefaultReader>()
.map_err(|_| Error::Admission)?;
let mut body = Vec::with_capacity(2048);
loop {
let chunk = JsFuture::from(reader.read())
.await
.map_err(|_| Error::Admission)?;
let done = Reflect::get(&chunk, &"done".into())
.map_err(|_| Error::Admission)?
.as_bool()
.ok_or(Error::Admission)?;
if done {
return Ok(body);
}
let bytes = Reflect::get(&chunk, &"value".into())
.map_err(|_| Error::Admission)?
.dyn_into::<Uint8Array>()
.map_err(|_| Error::Admission)?;
if bytes.length() as usize > 2048 - body.len() {
return Err(Error::Admission);
}
body.extend_from_slice(&bytes.to_vec());
}
}

fn result_to_js(result: &EmbeddingResult) -> Result<JsValue, JsValue> {
match result {
EmbeddingResult::Success { embedding } => js::object(&[
("status", "success".into()),
(
"embedding",
js::object(&[
Comment thread
Copilot marked this conversation as resolved.
("vector", embedding.vector.as_str().into()),
("embeddingType", embedding.embedding_type.as_str().into()),
("version", embedding.version.as_str().into()),
(
"inferenceBackend",
embedding.inference_backend.as_str().into(),
),
(
"worker",
js::object(&[
("profile", embedding.worker.profile.as_str().into()),
(
"executableSha384",
embedding.worker.executable_sha384.as_str().into(),
),
])?,
),
])?,
),
]),
EmbeddingResult::Failed { reason } => {
let reason = match reason {
Failure::InvalidImage => "invalid_image",
Failure::QualityRejected => "quality_rejected",
Failure::ExtractionFailed => "extraction_failed",
Failure::Busy => "busy",
Failure::InvalidRequest => "invalid_request",
};
js::object(&[("status", "failed".into()), ("reason", reason.into())])
}
}
}
4 changes: 4 additions & 0 deletions crates/walletkit-web/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ impl JsReportable for WalletKitError {
const NAME: &'static str = "WalletKitError";
}

impl JsReportable for selfie_enrollment_client::Error {
const NAME: &'static str = "SelfieEnrollmentError";
}

impl JsReportable for StorageError {
const NAME: &'static str = "StorageError";
}
Expand Down
1 change: 1 addition & 0 deletions crates/walletkit-web/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
#![cfg(all(target_arch = "wasm32", target_os = "unknown"))]

mod authenticator;
mod enrollment;
mod error;
mod js;
mod storage;
Expand Down
34 changes: 34 additions & 0 deletions web/walletkit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,40 @@ The browser API differs from the Swift and Kotlin bindings in these ways:
- Objects stay in the worker until you call `free()` or they are garbage
collected.

## Extract a Selfie Check embedding

```ts
const result = await wallet.extractSelfieEmbedding(
JSON.stringify(trustedEnrollmentRelease),
imageBytes,
"/api/enrollment-admission",
);
if (result.status === "success") {
// Sensitive biometric data: keep it out of logs and persistent storage.
const embedding = result.embedding;
}
```

Use the trusted `client-stage.json` generated by the enrollment release in
`worldcoin/tee-apps`. It pins the endpoint, audience, Nitro PCR0/1/2 and worker
executable digest. The client verifies these before sealing the image. A failed
attestation never causes an image upload, and no bypass option is exposed.

Your same-origin admission endpoint receives `{ audience, nonce }` in a JSON POST
with same-origin credentials. Authorize the caller and return a short-lived P256
`{ expires_at, signature }` ticket using the biometrics-tee admission protocol.
Keep its signing key on the server. The admission endpoint does not receive the
image. `admissionPath` must be an absolute same-origin path such as the one above;
callbacks and `AbortSignal` objects cannot cross the package's worker boundary.
Terminate the WalletKit instance to cancel immediately; enrollment also has a
bounded deadline.

The response is `SelfieEmbeddingResult`: a success with the worker's original
vector encoding and metadata, or a typed image/quality failure. This operation
extracts an embedding and does not issue a credential or prove camera liveness.
The initial service uses test admission while production enrollment authorization
is designed separately.

## Handle errors

WalletKit reports failures in these ways:
Expand Down
2 changes: 2 additions & 0 deletions web/walletkit/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ export type {
RecoveryUpdateSignature,
Region,
RegistrationStatus,
SelfieEmbedding,
SelfieEmbeddingResult,
} from "./generated/walletkit.js";

export interface InitializeOptions {
Expand Down
Loading
Loading