diff --git a/README.md b/README.md new file mode 100644 index 0000000..748a08b --- /dev/null +++ b/README.md @@ -0,0 +1,115 @@ +# wallet-passkeys-app + +A demo dApp that authenticates users via the EffectStream +[`wallet-passkeys`](https://github.com/effectstream/wallet-passkeys) worker. +Embeds the wallet's `/embed` route as a cross-origin iframe and uses the +documented `postMessage` RPC to register passkeys, sign in, and request +signatures — without ever holding any private key material itself. + +**Live deployment**: + +This is a fork of [`rvcas/fake-app`](https://github.com/rvcas/fake-app), +adapted to point at the EffectStream-hosted wallet-passkeys worker and to +deploy under the EffectStream Cloudflare account. + +## What it does + +A minimal third-party application that demonstrates how to integrate with +`wallet-passkeys` from a different origin: + +1. Mounts as a same-page + iframe inside a Card component. +2. Listens for `midnightos-passkeys` `postMessage` events from that iframe. +3. Sends `midnightos-dapp` requests back when the user clicks Register, Sign + In, or Sign Message. +4. Displays the returned `did:key` identity, the access key's public key, and + the signed message (raw `r||s` + DER ASN.1). + +The dApp itself stores no key material. Every credential, every signature, and +every byte of private key lives at the wallet-passkeys origin. The dApp only +sees public artifacts: the user's DID, the access key's public key, signatures. + +## Cross-origin postMessage protocol + +The iframe and parent exchange typed messages over `postMessage`: + +| Direction | `type` | Payload | +|---|---|---| +| dApp → wallet | `register` | `{ username }` | +| dApp → wallet | `sign-in` | `{ credentialId? }` | +| dApp → wallet | `sign` | `{ message, requestId }` | +| wallet → dApp | `ready` | `{}` | +| wallet → dApp | `authenticated` | `{ credential, did, didDocument, accessKeyPublicKey, keyAuthorization }` | +| wallet → dApp | `signed` | `{ requestId, message, signature, signatureDer, publicKey }` | +| wallet → dApp | `sign-error` / `error` | `{ requestId?, message }` | + +Each side discriminates messages by a `source` string (`midnightos-dapp` or +`midnightos-passkeys`) plus an origin check so neither will react to traffic +from an unrelated frame. + +## Local development + +```sh +pnpm install +pnpm dev +# Vite+ dev server at http://localhost:5173 (or whatever vp picks) +``` + +To run against a local wallet-passkeys instance instead of the deployed worker, +edit the `PASSKEYS_ORIGIN` constant in +[`src/components/fake-dapp.tsx`](./src/components/fake-dapp.tsx) — by default +it points at . + +## Build + deploy + +```sh +pnpm run build # tsc + vp build → dist/ +pnpm run deploy # builds + wrangler deploy +``` + +The deploy targets the EffectStream Cloudflare account +(`28ea08e36bc67a4f136df373255ce175`) via [wrangler.jsonc](./wrangler.jsonc). +The worker binding name is `wallet-passkeys-app`, so the workers.dev URL is +. + +## How to test + +1. Open in Chrome (or any + browser with platform passkey + WebAuthn support). +2. Click **Connect with Passkey**. The wallet-passkeys iframe appears. +3. Inside the iframe, click **Register**. Your OS shows the passkey-create + prompt (Touch ID, Windows Hello, Android biometric, etc). +4. Approve. The iframe completes the flow and posts your `did:key:z…` identity + back to this dApp; the iframe collapses and the Identity card appears. +5. Type a message into **Sign Message** and click the button. The dApp posts a + `sign` request to the iframe; the access key (held inside wallet-passkeys) + signs the message without re-prompting biometrics. The signed message + raw + and DER signatures appear in the UI. + +End-to-end, the user touched the OS biometric prompt once during Register and +once if they ever call `sign-in` again on a fresh session. Subsequent message +signatures are silent. + +## How this fits in EffectStream + +Together with [`wallet-passkeys`](https://github.com/effectstream/wallet-passkeys) +this pair is a reference implementation of the embed-and-postMessage pattern +EffectStream uses elsewhere — including the +[`effectstream-social-2of3-wallet`](https://github.com/effectstream/effectstream-social-2of3-wallet) +multi-chain wallet, which uses the same cross-origin iframe + postMessage +protocol shape for its EVM / Cardano / Midnight signing surface. + +The passkey design here is complementary, not competing: + +* **wallet-passkeys** — passkey is the *root* signing key, access key is a + delegated session signer. Single-curve (P-256 ECDSA), single identity (`did:key`). +* **effectstream-social-2of3-wallet** — passkey/Drive is the *unlock* for one + Shamir share; the actual signing keys are derived per-chain from a master + entropy. Multi-chain (EVM secp256k1, Cardano ed25519/Icarus, Midnight Zswap + + Dust + Night), one entropy per user. + +You can run them side-by-side or pick whichever model fits your app. + +## License + +Same as the upstream project. See [LICENSE](./LICENSE) once added. diff --git a/index.html b/index.html index 510b35e..43620c4 100644 --- a/index.html +++ b/index.html @@ -4,7 +4,13 @@ - fake app + + + + EffectStream · Demo App
diff --git a/src/components/fake-dapp.tsx b/src/components/fake-dapp.tsx index 0cd7271..8f87b59 100644 --- a/src/components/fake-dapp.tsx +++ b/src/components/fake-dapp.tsx @@ -2,7 +2,9 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -const PASSKEYS_ORIGIN = "https://passkeys.rvcas.dev"; +// Points at the EffectStream-hosted wallet-passkeys worker. Override locally +// by editing this constant if you're running wallet-passkeys on localhost. +const PASSKEYS_ORIGIN = "https://wallet-passkeys.ac-edward.workers.dev"; const EMBED_URL = `${PASSKEYS_ORIGIN}/embed`; type KeyAuthorization = { @@ -28,10 +30,14 @@ type SignResult = { }; export function FakeDapp() { + // `iframeMounted` keeps the iframe in the DOM (its JS context holds the + // access key, so it must survive after auth to handle sign requests). + // `popupVisible` controls whether the floating wallet dock is shown. const [iframeMounted, setIframeMounted] = useState(false); + const [popupVisible, setPopupVisible] = useState(false); const [authResult, setAuthResult] = useState(null); const [error, setError] = useState(null); - const [messageToSign, setMessageToSign] = useState("Hello from fake-app!"); + const [messageToSign, setMessageToSign] = useState("Hello from the Demo App!"); const [signResult, setSignResult] = useState(null); const [signing, setSigning] = useState(false); const iframeRef = useRef(null); @@ -47,6 +53,17 @@ export function FakeDapp() { if (data.type === "authenticated") { setAuthResult(data.payload); setError(null); + // Auth done — collapse the floating popup, but keep the iframe mounted + // (hidden) so its access key can still sign later requests. + setPopupVisible(false); + } else if (data.type === "close") { + // User pressed Cancel / OK inside the wallet popup. If they never + // authenticated, tear the iframe down entirely; otherwise just hide. + setPopupVisible(false); + setAuthResult((prev) => { + if (!prev) setIframeMounted(false); + return prev; + }); } else if (data.type === "error") { setError(data.payload?.message ?? "Authentication failed"); } else if (data.type === "signed") { @@ -66,12 +83,14 @@ export function FakeDapp() { const handleConnect = useCallback(() => { setIframeMounted(true); + setPopupVisible(true); setError(null); }, []); function handleDisconnect() { setAuthResult(null); setIframeMounted(false); + setPopupVisible(false); setSignResult(null); setError(null); } @@ -117,14 +136,19 @@ export function FakeDapp() { }, [messageToSign]); return ( -
-
-
-

Fake dApp

-

+

+ {/* Editorial wordmark header */} +
+
+ Demo dApp · Issue 01 +

+ EFFECTSTREAM +
DEMO. +

+

{authResult - ? "Connected via passkeys.rvcas.dev" - : "A third-party application that authenticates via cross-origin iframe"} + ? "Connected · access key issued by wallet-passkeys" + : "A consumer application that authenticates users through a cross-origin wallet iframe. This page holds no key material."}

{authResult && ( @@ -133,6 +157,7 @@ export function FakeDapp() { )}
+
{/* Connect button — shown before iframe is mounted */} {!iframeMounted && !authResult && ( @@ -140,7 +165,7 @@ export function FakeDapp() { Connect Wallet - Sign in with your midnightOS passkey — no extensions, no seed phrases + Sign in with your EffectStream passkey — no extensions, no seed phrases @@ -150,36 +175,26 @@ export function FakeDapp() { )} - {/* Auth iframe — visible during auth, hidden after */} + {error && !authResult && ( +

{error}

+ )} + + {/* Floating wallet popup — fixed top-right like a browser-extension + wallet. The iframe stays mounted (so its access key survives for + later sign requests) but the dock hides once the popup is dismissed. + The wallet's own header / body / OK-Cancel footer live inside. */} {iframeMounted && ( - - - midnightOS Wallet - Authenticating via passkeys.rvcas.dev - - - {error && !authResult && ( -

{error}

- )} -