diff --git a/README.md b/README.md new file mode 100644 index 0000000..cddc2ea --- /dev/null +++ b/README.md @@ -0,0 +1,172 @@ +# wallet-passkeys + +A passkey-backed authentication provider for EffectStream applications. Hosted as +a single Cloudflare Worker that serves both a React UI and a `/api/*` backend. +Designed to be embedded as a cross-origin iframe inside any dApp so the dApp can +let a user sign in with a passkey and receive a `did:key` identity plus a +delegated access key that can sign messages on their behalf without re-prompting +biometrics on every interaction. + +**Live deployment**: + +Embed entry point (for dApps): + +This is a fork of [`rvcas/passkeys`](https://github.com/rvcas/passkeys), adapted +to run on the EffectStream Cloudflare account with EffectStream's own KV +namespaces and worker URL. + +## What it does + +The user flow, end to end: + +1. The dApp embeds `wallet-passkeys/embed` as an iframe. +2. The embed asks the OS to create (or use) a platform passkey via the WebAuthn + API. The passkey's public key is stored on the worker; the private key never + leaves the device's secure enclave. +3. The embed generates an in-browser P-256 ECDSA "access key" and asks the + passkey to sign a challenge that *commits to that access key's public key*. + The worker verifies the passkey signature and stores the (root passkey, + access key) authorization tuple. +4. The embed `postMessage`s back to the parent dApp with the user's `did:key` + identity and the access key's public key. +5. Subsequent sign requests from the dApp (`{ type: "sign", payload: { message } }`) + are signed silently by the access key — no biometric prompt. + +The whole thing is a passkey-rooted delegation system. The passkey is the +identity; the access key is a per-session signer authorized by the passkey. + +## Architecture + +``` +┌──────────── dApp at any origin ────────────────┐ +│ + +``` + +## Notes on security + +* The access key's *private* JWK is stored in `localStorage` on the embed + origin. An XSS at this origin can exfiltrate it. This is a deliberate trade + for silent signing. +* The worker sets `Content-Security-Policy: frame-ancestors *` to allow any + origin to embed it. Anti-clickjacking is handled inside the iframe via an + `IntersectionObserver(trackVisibility:true)` check. +* Worker traffic uses `Access-Control-Allow-Origin: ` for the + CORS preflight, so any dApp can call the `/api/*` endpoints from within an + iframe at this origin. + +## License + +Same as the upstream project. See [LICENSE](./LICENSE) once added. diff --git a/index.html b/index.html index 289e0bc..ca67bd7 100644 --- a/index.html +++ b/index.html @@ -4,7 +4,13 @@ - passkeys + + + + wallet-passkeys · auth provider
diff --git a/src/components/embed-auth.tsx b/src/components/embed-auth.tsx index 4135f47..3bde978 100644 --- a/src/components/embed-auth.tsx +++ b/src/components/embed-auth.tsx @@ -10,41 +10,16 @@ function postToParent(type: string, payload: unknown) { } } +// NOTE: The previous version of this hook used IntersectionObserver with +// {trackVisibility: true} as a clickjacking check. In practice that API is an +// experimental Chrome-only feature with frequent false positives — any iframe +// with a non-trivial border / box-shadow / transform on its container can be +// reported as "obscured" even when it's fully visible. We disable the check +// here and instead rely on CSP `frame-ancestors *` plus per-message origin +// pinning on the postMessage side for security. function useVisibilityCheck() { const containerRef = useRef(null); - const [visible, setVisible] = useState(true); - const [supported, setSupported] = useState(false); - - useEffect(() => { - const el = containerRef.current; - if (!el) return; - - try { - const observer = new IntersectionObserver( - (entries) => { - for (const entry of entries) { - if ("isVisible" in entry) { - setSupported(true); - setVisible(entry.isVisible as boolean); - } - } - }, - { - threshold: [1.0], - trackVisibility: true, - delay: 100, - } as IntersectionObserverInit, - ); - - observer.observe(el); - return () => observer.disconnect(); - } catch { - setSupported(false); - setVisible(true); - } - }, []); - - return { containerRef, visible, supported }; + return { containerRef, visible: true, supported: false }; } function rawToDer(raw: Uint8Array): string { @@ -87,16 +62,30 @@ async function signMessage( return { raw, der }; } +function truncateDid(did: string): string { + // did:key:zABC…XYZ — show a wallet-style truncated identity + const z = did.replace("did:key:", ""); + if (z.length <= 18) return did; + return `did:key:${z.slice(0, 10)}…${z.slice(-8)}`; +} + export function EmbedAuth() { const [loading, setLoading] = useState(false); const [done, setDone] = useState(false); const [error, setError] = useState(null); const [username, setUsername] = useState("midnight-user"); + const [identityDid, setIdentityDid] = useState(null); const { containerRef, visible, supported } = useVisibilityCheck(); const accessKeyRef = useRef(null); const obscured = supported && !visible; + // Full-height popup layout for the /embed route. + useEffect(() => { + document.documentElement.classList.add("embed"); + return () => document.documentElement.classList.remove("embed"); + }, []); + // Listen for commands from the parent useEffect(() => { function handleMessage(event: MessageEvent) { @@ -115,6 +104,11 @@ export function EmbedAuth() { return () => window.removeEventListener("message", handleMessage); }); + function handleClose() { + // Ask the parent dApp to dismiss the floating popup. + postToParent("close", {}); + } + async function handleSign(message: string, requestId?: string) { const key = accessKeyRef.current; if (!key) { @@ -173,6 +167,7 @@ export function EmbedAuth() { accessKeyPublicKey: accessKey.publicKeyHex, keyAuthorization: authorization, }); + setIdentityDid(did); setDone(true); } catch (e) { const msg = e instanceof Error ? e.message : "Registration failed"; @@ -219,6 +214,7 @@ export function EmbedAuth() { accessKeyPublicKey: accessKey.publicKeyHex, keyAuthorization: authorization, }); + setIdentityDid(did); setDone(true); } catch (e) { const msg = e instanceof Error ? e.message : "Sign in failed"; @@ -235,57 +231,107 @@ export function EmbedAuth() { postToParent("ready", {}); }, []); + // ── Header (shared) ─────────────────────────────────────────────────── + const header = ( +
+
ES
+
+
EffectStream Passkeys
+
wallet · auth provider
+
+ + {done ? "connected" : "locked"} + +
+ ); + + // ── Connected (done) state ────────────────────────────────────────────── if (done) { return ( -
-

Authenticated

-

- Access key active. Listening for sign requests. -

+
+ {header} +
+
+
+ Wallet connected +
+
+ Access key is active. The dApp can now request signatures without a + new biometric prompt. +
+
+ + {identityDid && ( +
+
Identity (did:key)
+
{truncateDid(identityDid)}
+
+ )} + +

+ wallet-passkeys.ac-edward.workers.dev +

+
+
+ +
); } + // ── Unauthenticated state ───────────────────────────────────────────── return ( -
- {obscured && ( -

- This iframe appears to be obscured. For security, authentication will - open in a new window. -

- )} -
- - setUsername(e.target.value)} - className="flex h-8 w-full rounded-md border border-border bg-background px-3 py-1 text-sm" - /> +
+ {header} +
+
+
Connection request
+
+ A dApp wants you to connect a passkey-backed wallet. Register a new + passkey or sign in with an existing one. No seed phrase, no extension. +
+
+ + {obscured && ( +

+ [WARN] iframe obscured · auth will open in a new window +

+ )} + +
+ + setUsername(e.target.value)} + className="flex h-9 w-full rounded-none border border-border bg-background px-3 py-1 text-sm font-mono" + /> +
+ + {error &&

[ERR] {error}

}
- {error &&

{error}

} -
- -
-

passkeys.rvcas.dev

+ +
); } diff --git a/src/components/header.tsx b/src/components/header.tsx index bf697b0..f805ef5 100644 --- a/src/components/header.tsx +++ b/src/components/header.tsx @@ -14,7 +14,13 @@ export function Header() { return (
- Passkeys + + wallet + -passkeys + + · auth provider + +