Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 115 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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**: <https://wallet-passkeys-app.ac-edward.workers.dev>

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 <https://wallet-passkeys.ac-edward.workers.dev/embed> 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 <https://wallet-passkeys.ac-edward.workers.dev>.

## 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
<https://wallet-passkeys-app.ac-edward.workers.dev>.

## How to test

1. Open <https://wallet-passkeys-app.ac-edward.workers.dev/> 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.
8 changes: 7 additions & 1 deletion index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,13 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>fake app</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
<title>EffectStream · Demo App</title>
</head>
<body>
<div id="app"></div>
Expand Down
93 changes: 54 additions & 39 deletions src/components/fake-dapp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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<AuthResult | null>(null);
const [error, setError] = useState<string | null>(null);
const [messageToSign, setMessageToSign] = useState("Hello from fake-app!");
const [messageToSign, setMessageToSign] = useState("Hello from the Demo App!");
const [signResult, setSignResult] = useState<SignResult | null>(null);
const [signing, setSigning] = useState(false);
const iframeRef = useRef<HTMLIFrameElement>(null);
Expand All @@ -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") {
Expand All @@ -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);
}
Expand Down Expand Up @@ -117,14 +136,19 @@ export function FakeDapp() {
}, [messageToSign]);

return (
<div className="mx-auto max-w-lg space-y-4 p-4">
<div className="flex items-center justify-between">
<div className="space-y-1">
<h1 className="font-heading text-xl font-semibold">Fake dApp</h1>
<p className="text-sm text-muted-foreground">
<div className="mx-auto max-w-2xl space-y-6 p-6 pt-12">
{/* Editorial wordmark header */}
<div className="flex items-end justify-between gap-6">
<div className="space-y-3">
<span className="es-chip">Demo dApp · Issue 01</span>
<h1 className="es-wordmark">
EFFECT<span className="accent">STREAM</span>
<br />DEMO<span className="accent">.</span>
</h1>
<p className="text-sm text-muted-foreground max-w-md">
{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."}
</p>
</div>
{authResult && (
Expand All @@ -133,14 +157,15 @@ export function FakeDapp() {
</Button>
)}
</div>
<div className="es-rule" />

{/* Connect button — shown before iframe is mounted */}
{!iframeMounted && !authResult && (
<Card>
<CardHeader>
<CardTitle>Connect Wallet</CardTitle>
<CardDescription>
Sign in with your midnightOS passkey — no extensions, no seed phrases
Sign in with your EffectStream passkey — no extensions, no seed phrases
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
Expand All @@ -150,36 +175,26 @@ export function FakeDapp() {
</Card>
)}

{/* Auth iframe — visible during auth, hidden after */}
{error && !authResult && (
<p className="text-xs text-destructive">{error}</p>
)}

{/* 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 && (
<Card className={authResult ? "hidden" : ""}>
<CardHeader>
<CardTitle>midnightOS Wallet</CardTitle>
<CardDescription>Authenticating via passkeys.rvcas.dev</CardDescription>
</CardHeader>
<CardContent>
{error && !authResult && (
<p className="text-xs text-destructive mb-3">{error}</p>
)}
<iframe
ref={iframeRef}
src={EMBED_URL}
allow="publickey-credentials-create; publickey-credentials-get"
className="w-full rounded-md border border-border"
style={{ height: 240 }}
title="midnightOS Passkey Authentication"
/>
{!authResult && (
<Button
variant="outline"
className="mt-3"
onClick={() => setIframeMounted(false)}
>
Cancel
</Button>
)}
</CardContent>
</Card>
<div
className="wallet-dock"
style={{ display: popupVisible ? "block" : "none" }}
>
<iframe
ref={iframeRef}
src={EMBED_URL}
allow="publickey-credentials-create; publickey-credentials-get"
title="EffectStream Passkeys wallet"
/>
</div>
)}

{/* Identity card — shown after auth */}
Expand Down
Loading