epds-wallet-mvp · tee-wallet-e2e

ePDS TEE wallet MVP — end-to-end smoke test

@daviddao

A fresh Next.js client on Vercel signs a user in to the TDX confidential-VM ePDS via email-OTP OAuth, shows their enclave-derived EVM + Solana addresses, enrolls a browser request key, and gets a wallet signature produced inside the TEE — with the forged-key failure path correctly rejected.

Date: 2026-07-15 · Target: epds-wallet-mvp.vercel.appepds.35-225-123-226.sslip.io (GCP c3-standard-4, Intel TDX) · pass


Headline result

9
Flow steps passed
3
Out-of-band proofs
1
Bug found & fixed mid-run
0
Blockers

The whole self-custody loop works end-to-end. Sign-up via email OTP auto-adopted the new account into TEE repo signing (PLC #atproto key rotated to the enclave key), GET /wallet/info returned enclave-derived EVM + Solana addresses, browser-key enrollment (TOFU) succeeded, and a user-signed envelope came back with an EVM signature produced inside the confidential VM. A forged envelope (wrong browser key) was rejected 403 invalid signature by the signer — the property that makes the wallet self-custodial even against a compromised PDS host.

Walkthrough

Two accounts were used: wallet-tester@example.com (first run, shots 01–05, hit the fetch bug below) and tee-demo@example.com (post-fix re-run, shots 06–09 and the recording). OTP codes were pulled from the deployment’s mailpit API rather than a real inbox.

Landing page with email sign-in form
01Landing page. Email form posts to /api/oauth/login, which runs PAR + PKCE + DPoP against the PDS.
ePDS auth OTP entry page
02Redirected to the ePDS auth service with login_hint — 8-digit OTP form, code delivered via mailpit.
OTP digits filled in
03OTP filled. Verification creates the account server-side (passwordless).
Handle picker
04Handle picker (picker-with-random mode). Chose a custom handle.
OAuth consent screen
05Upstream @atproto/oauth-provider consent screen for the confidential client.
Dashboard with TEE wallet addresses
06Dashboard: DID + handle from the session, and TEE-derived EVM 0xDe73…bea0 and Solana 31GF…WHUo addresses from GET /wallet/info. Pill shows not enrolled.
Wallet enrolled state
07After “Enroll browser key”: WebCrypto P-256 key generated in-browser, compressed pubkey registered via POST /wallet/enroll (TOFU). Pill flips to enrolled.
TEE signature result
08“Sign test envelope”: browser signs a random 32-byte digest envelope; the TEE signer verifies it against the enrolled key and returns signatureHex 6348…af37, recovery 1.
Forged envelope rejected with 403
09 · failure pathlocalStorage key wiped → fresh (unenrolled) key signs the next envelope → signer rejects it: sign failed (403): invalid signature.

End-to-end recording of the post-fix run: landing → OTP → handle → consent → dashboard → enroll → TEE signature → forged-envelope rejection.

Out-of-band verification

PLC directory shows the enclave signing key (adopt-on-signup fired)

The new account’s DID document (verify-plc-diddoc.json) lists exactly the key the PDS logged when adopting the account into TEE signing:

plc.directory/did:plc:y2yj3i4mmtvi3xvktze35ks6
  verificationMethod: zQ3shVfvuhp7AAySwPmyU6yacvjAQa2QRnijMnedcyrZa9VBu

pds-core log: "account adopted into TEE signing"
  keyDid: did:key:zQ3shVfvuhp7AAySwPmyU6yacvjAQa2QRnijMnedcyrZa9VBu  ✓ match

Signer journal confirms every wallet event

From journalctl -u epds-signer on the CVM (verify-signer-logs.txt): wallet enrollment (outcome: created), wallet signature issued (purpose: wallet/evm), and wallet sign rejected (reason: invalid signature) — all for did:plc:y2yj3i4…, timestamps matching the browser session.

Unauthenticated API access denied

curl https://epds-wallet-mvp.vercel.app/api/wallet/info without a session cookie → instant 401 {"error":"not logged in"} (verify-unauth-401.json).

Findings

Fixed mid-run: res.body.cancel() hangs under Next.js’s instrumented fetch

The first run’s dashboard stalled on “Loading wallet info…”. The DPoP nonce-retry helper cancelled the 401 response body before retrying — under Next.js App Router’s instrumented fetch (GET), body.cancel() never resolves and the serverless function hangs until timeout. A standalone Node repro of the identical request chain worked, isolating the bug to the framework layer. Fix: consume the body (await res.arrayBuffer()) instead of cancelling, plus cache: 'no-store'. Deployed and re-verified (0.9 s response).

Signer runs unattested (mode: dev) on this deployment

The plain GCP confidential VM has no dstack guest agent, so GET /v1/attestation returns no hardware quote. TDX memory encryption is active (dmesg: Memory Encryption Features active: Intel TDX), but a verifier can’t remotely prove which code holds the keys. Production needs dstack on bare-metal TDX or GCP Confidential Space. The root seed was also self-generated in-VM (SIGNER_ALLOW_DEV_SEED=1) rather than KMS-provisioned.

Test-bench trade-offs, by design

Browser request key is extractable and lives in localStorage (production: non-extractable WebCrypto or passkey). Enrollment is trust-on-first-use — the documented operator-bootstrap gap applies. Access token + DPoP key ride in an HMAC-signed httpOnly cookie (no server-side store). Hostnames are throwaway sslip.io names on the VM IP.

Everything else worked first try

PAR with private_key_jwt client assertion, DPoP nonce dance on PAR/token/resource calls, OTP delivery + verification, handle provisioning, consent, PLC key rotation on signup, wallet derivation, enrollment, envelope signing, and forged-envelope rejection.

Reproducing

Apphttps://epds-wallet-mvp.vercel.app (source: GainForest/epds-wallet-mvp, private) BackendePDS feat/tee-signer on GCP epds-tee-test (us-central1-a, c3-standard-4, Intel TDX); signer as systemd unit on the CVM host FlowSign in with any email → pull OTP from mailpit (mail.epds.35-225-123-226.sslip.io) → dashboard → Enroll browser key → Sign test envelope Verify PLCcurl https://plc.directory/<did>#atproto key must equal the adoption log’s keyDid Verify signerjournalctl -u epds-signer on the CVM — enrollment / signature / rejection events Videowalkthrough.webm Screenshotsassets/*.png