Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
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 = "e790f5a1d9dbcbcd67ad802aa6e358216bcaa228", default-features = false }
selfie-enrollment-sealed-types = { git = "https://github.com/worldcoin/biometrics-tee", rev = "e790f5a1d9dbcbcd67ad802aa6e358216bcaa228" }
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
187 changes: 187 additions & 0 deletions crates/walletkit-web/src/enrollment.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
//! 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();
}
}

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