epds-wallet-mvp · tee-wallet-e2e
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.
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.
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.

/api/oauth/login, which runs PAR + PKCE + DPoP against the PDS.
login_hint — 8-digit OTP form, code delivered via mailpit.

picker-with-random mode). Chose a custom handle.
@atproto/oauth-provider consent screen for the confidential client.
0xDe73…bea0 and Solana 31GF…WHUo addresses from GET /wallet/info. Pill shows not enrolled.
POST /wallet/enroll (TOFU). Pill flips to enrolled.
signatureHex 6348…af37, recovery 1.
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.
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 ✓ matchFrom 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.
curl https://epds-wallet-mvp.vercel.app/api/wallet/info without a session cookie → instant 401 {"error":"not logged in"} (verify-unauth-401.json).
res.body.cancel() hangs under Next.js’s instrumented fetchThe 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).
mode: dev) on this deploymentThe 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.
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.
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.
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