Reference user-app for ArkLabsHQ/introspector-enclave. Demonstrates end-to-end-encrypted, versioned, per-user key/value storage on top of a Nitro enclave. Plaintext values never reach the server. Storage paths contain only HMAC-derived opaque key IDs.
protocol/ shared TypeScript types + Zod schemas (JSON-RPC 2.0)
server/ Node.js 22+ user-app (hono + @simplewebauthn/server)
client/ Vite SPA — verifies attestation, runs the WebAuthn ceremonies,
encrypts every value before sending it to the server
flake.nix reproducible EIF build (vendored Node.js template — runs npm build)
enclave.yaml deployment config consumed by the framework CLI
- Trusted at PCR0: enclave code (framework supervisor + this app), authenticator at the moment of operation.
- Untrusted: AWS host, network, anyone with read access to S3 / SSM / KMS, and the user-app handler itself for the purpose of value contents.
- Goals:
- Confidentiality of value bytes against the user-app and below.
- Storage-path privacy: per-user key names do not appear in S3 paths.
- Integrity / authenticity of responses (BIP-340 Schnorr signatures from the framework supervisor).
- Replay resistance on writes (
expected_version).
- TLS termination inside the enclave at nitriding.
- BIP-340 Schnorr response signing — every response has
X-Attestation-SignatureandX-Attestation-Pubkeyheaders overSHA256(body). - Nitro attestation document —
GET /enclave/attestation?nonce=…. - KMS-backed AES-256-GCM at-rest storage —
PUT/GET/DELETE/LIST /v1/storage/..., accessed viaAuthorization: Bearer ${ENCLAVE_RUNTIME_TOKEN}.
| Layer | Mechanism |
|---|---|
| End-user auth | WebAuthn passkeys with PRF extension |
| Auth tokens | HS256 JWT, key from KMS-managed secret auth_token_key, jti LRU replay guard |
| Value encryption | AES-256-GCM with key derived client-side from PRF output via HKDF-SHA256 |
| Path privacy | key_id = base32(HMAC-SHA256(path_key, name)).slice(0, 32) |
| KV semantics | Per-user, versioned, optimistic-concurrency kv.put / kv.del |
GET /api/info— public, returns{ pcr0, version, providers, rp }. Schnorr-signed by the framework. Display thepcr0and compare against the published reference for the release you trust.POST /api/rpc— JSON-RPC 2.0. Methods listed inprotocol/src/methods.ts:session.begin,auth.webauthn.{register,assert}.{begin,finish},auth.credentials.{list,delete}kv.{get,put,del,list,batch_get,batch_put}
Auth methods that require an existing session take Authorization: Bearer <auth_token>.
- Registration runs the standard WebAuthn ceremony.
- The first assertion asks the authenticator for
prfResults.firstusing the fixed app saltSHA256("enclave-kv-v1"). - The client derives two keys via HKDF:
value_key(info:"kv-v1-value-key") → AES-256-GCMpath_key(info:"kv-v1-path-key") → HMAC-SHA256
- Per value: random 12-byte nonce, AES-GCM, store
nonce ‖ ct ‖ tagas thevaluefield. The server stores it but cannot decrypt it. - The user-supplied key name is encrypted with
value_keyintoname_ct. The server stores it.kv.listreturnsname_ct; the client decrypts. - The opaque on-the-wire
key_idisbase32(HMAC-SHA256(path_key, name))truncated to 20 bytes.
Synced passkeys (iCloud Keychain, Google Password Manager, 1Password) yield the same PRF output across devices — the user's data is portable. Two distinct passkeys give different PRF outputs (one passkey == one realm in phase 1).
Lose your last passkey ⇒ data is unrecoverable. This is not a bug.
client/src/enclave/attestation.ts ports the Go reference logic in introspector-enclave/client/verify.go:
- Fetch
/enclave/attestation?nonce=<random hex>. - Parse the COSE_Sign1 envelope.
- Build the cert chain and anchor it to the AWS Nitro root cert (embedded in
nitroRoot.ts). - Verify the COSE signature (ECDSA P-384 over SHA-384) via WebCrypto.
- Verify the nonce matches what we sent.
- Verify
SHA256(attestation_pubkey)matches theappKeyHashembedded inuser_databytes 47..79. - Display PCR0 to the user — they compare it out-of-band against the published reference.
After that, every /api/rpc response is checked: rpcClient.ts verifies the BIP-340 Schnorr signature over SHA256(response_body) using the pinned attestation pubkey, and rejects responses signed with any other key.
- Node.js 22+
- For EIF build / deploy: a working installation of the framework CLI (
enclave).
npm install
npm run build # protocol -> client -> assets:copy -> server
npm -w e2ee-kv-server run dev # in one terminal
npm -w e2ee-kv-client run dev # in another (Vite dev server on :5173)The dev server stubs the ENCLAVE_RUNTIME_TOKEN and AUTH_TOKEN_KEY env vars
(see server/src/config.ts). Storage calls go to
http://127.0.0.1:7073 — point that at a mock or run a local nitriding stack.
npm testCovers JWT sign/verify/expired/replay, session nonce single-use, KV version-conflict semantics, AES-GCM round-trip, base32 round-trip, BIP-340 known-answer verification, and JSON-RPC schema completeness.
# 1. Push this repo to GitHub.
# 2. Update enclave.yaml: nix_owner / nix_repo / nix_rev / nix_hash.
# 3. Compute npm vendor hash (CLI helper):
enclave setup
# 4. Build the EIF:
enclave build
# 5. Deploy:
enclave run- Lift
client/src/enclave/into a published@arklabs/enclave-client-tspackage once the API stabilizes. - Cross-key transactional
kv.batch_put. Today batch is per-item atomic. - Multi-passkey shared data realms via a wrapped master key. Today: one passkey == one realm.
- Upstream the framework's Node.js flake template so apps don't need to vendor
flake.nixto runnpm run build.