A test to determine if the Passport Agent PWA approach works on various platforms
  • TypeScript 92.2%
  • JavaScript 5.3%
  • CSS 2.5%
Find a file
2026-05-11 20:58:32 -04:00
client feat: initial commit 2026-05-11 20:58:32 -04:00
passport-agent feat: initial commit 2026-05-11 20:58:32 -04:00
.gitignore feat: initial commit 2026-05-11 20:58:32 -04:00
README.md feat: initial commit 2026-05-11 20:58:32 -04:00

Sharenet Passport Agent + Client (PoC)

A minimal proof-of-concept for the architecture described in sharenet-passport-agent-client-ios-plan.md. Two separately-deployed Next.js PWAs (client/ and passport-agent/) talk over a cross-origin iframe with postMessage. The Passport Agent holds an Ed25519 keypair in IndexedDB; the Client holds no key material and asks the agent to sign payloads.

This PoC deliberately does not include:

  • The real Sharenet protocol or canonical payload validation (we just sign whatever string the Client sends, with a soft size cap to demonstrate where the validator would live).
  • The @sharenet/passport WASM module.
  • .spf import/export.
  • Passphrase encryption of the stored keypair (we store raw JWKs in IndexedDB).
  • A backend, hubs, or unit tests.

What it does demonstrate:

  • Two separate Next.js apps at two separate origins.
  • The postMessage protocol: ping, getIdentity, capabilities, sign, requestDeauthorization.
  • User-chosen Passport Agent: the Client has a configuration card where the user enters any agent origin they trust and clicks Use this agent. Exactly one agent at a time, persisted to localStorage.
  • Explicit authorization: the Client never talks to the agent until the user clicks Request Authorization; before that, the iframe isn't even embedded.
  • Forget agent: a single Forget agent button clears the Client's local state (origin + want-auth flag) so the user can enter a different agent. It's purely local — no protocol message — because cleaning up the agent's allowlist row is the agent's concern, reachable from the agent's Settings page. (The requestDeauthorization protocol op exists and the iframe handles it; nothing in the Client currently calls it.)
  • Origin-allowlist authorization with the Allow / Deny prompt rendered on the standalone Passport Agent page, not inside the iframe — see "Consent flow" below for why.
  • Ed25519 keypair generation, storage in IndexedDB, and signing via WebCrypto.
  • The §5.1 invariant: the Client never links to the Passport Agent (with a CI-style audit script under client/scripts/no-link-audit.mjs).
  • The §11 Phase-0 storage probe surface — every flow needed to answer the "are subdomain iframes same-site for storage?" question on iOS Safari.

Layout

sharenet_ios_passport_agent_and_client_test/
├── client/                  # Next.js, port 3000 — the embedder
│   ├── app/page.tsx         # main demo UI (config + Request Auth/Deauth)
│   ├── lib/agent-client.ts  # postMessage wrapper
│   ├── lib/agent-config.ts  # localStorage: configured agent + want-auth
│   ├── lib/protocol.ts      # protocol types (mirrored)
│   └── scripts/no-link-audit.mjs
└── passport-agent/          # Next.js, port 3001 — holds the keypair
    ├── app/page.tsx         # standalone (passport + Pending requests)
    ├── app/iframe/page.tsx  # embedded surface (signing + protocol)
    ├── app/settings/page.tsx
    ├── lib/db.ts            # IndexedDB
    ├── lib/consent-channel.ts # BroadcastChannel between iframe and page
    ├── lib/crypto.ts        # WebCrypto Ed25519
    └── lib/protocol.ts

Run it locally (different ports — proves the protocol works)

This exercises the Client/Agent split but does not answer the §3 linchpin question (different ports on localhost aren't even on the same eTLD+1).

# Terminal 1
cd passport-agent && npm install && npm run dev   # http://localhost:3001

# Terminal 2
cd client && npm install && npm run dev           # http://localhost:3000

Step-by-step walkthrough:

  1. Open http://localhost:3001 and click Create passport.
  2. Open http://localhost:3000. The Client shows a Passport Agent configuration card with the input pre-filled to http://localhost:3001.
  3. Click Use this agent. The configuration is stored in localStorage; the iframe is not yet embedded. Status: "not authorized".
  4. Click Request Authorization. The iframe is embedded and sends getIdentity. The agent's iframe writes a pending_consents row and broadcasts; the iframe shows an "Open your Passport Agent yourself" notice (no buttons). Status: "awaiting your approval".
  5. Switch back to the http://localhost:3001 tab. A new Pending authorization requests card lists http://localhost:3000.
  6. Click Allow there. The Client tab updates automatically. Status: "authorized". The Sign button enables.
  7. Type a payload, click Sign. You get an Ed25519 hex signature back.
  8. To stop using this agent or switch to another: click Forget agent. The Client clears its localStorage and unmounts the iframe. No protocol message is sent to the agent — the row stays on the agent's allowlist until you revoke it manually from the agent's Authorized Clients settings.

Run it locally on real subdomains (exercises §3)

To actually test whether subdomain iframes share IndexedDB the way the architecture assumes, you need two origins on the same registrable domain. Two ports on localhost are not enough.

# 1. Add to /etc/hosts
127.0.0.1 app.sharenet.test
127.0.0.1 passport.sharenet.test

# 2. mkcert certs (or use any local TLS solution)
mkcert app.sharenet.test passport.sharenet.test

# 3. Run each app behind a TLS-terminating dev proxy (Caddy, mkcert+local-ssl-proxy,
#    nginx, etc.) so the Client at https://app.sharenet.test embeds the agent
#    at https://passport.sharenet.test/iframe.

# 4. Point the Client at the right agent origin:
NEXT_PUBLIC_AGENT_ORIGIN=https://passport.sharenet.test npm run dev

For iOS device testing where localhost/*.test won't work, follow §9 of the plan: a Cloudflare Tunnel with two subdomains of one tunnel domain, or a staging deploy at app.staging.sharenet.sh + passport.staging.sharenet.sh.

Phase 0 test (the linchpin)

The §11 Phase 0 question — does iOS Safari treat the two subdomain origins as same-site for IndexedDB? — is answerable with this PoC directly:

  1. Visit the Passport Agent standalone, create a passport.
  2. Visit the Client; the iframe loads.
  3. If the iframe shows "consent_required" naming the Client's origin and then, after you Allow, signs successfully, World A holds: the iframe read its first-party IndexedDB and saw the passport written by the standalone PWA.
  4. If the iframe shows "no passport" even though you just created one in the standalone PWA, World B holds: the iframe is in partitioned storage, separate from the standalone DB. The plan's two fallbacks (B-path, B-postmessage) apply.

The original plan §4.5 described the consent prompt as rendered inside the iframe at passport.sharenet.sh/iframe. Functionally that's secure (the iframe is at the agent's own origin and event.origin is browser-stamped), but visually it isn't: an iframe inside the Client's page has no URL bar, no chrome, no visual cue distinguishing "rendered by the agent's origin" from "rendered by the Client's HTML". A user staring at an iframe prompt has nothing on screen to anchor their trust, and a malicious Client could paint a pixel-identical fake.

This PoC moves Allow / Deny to the standalone Passport Agent page where the browser's address bar — the only authoritative trust anchor the user has — is visible at the top of the window. The flow is:

  1. The iframe receives a request from a Client whose origin isn't on the authorized list.
  2. The iframe writes a pending_consents row in IndexedDB and broadcasts a pending event on a BroadcastChannel.
  3. The iframe's UI shows a notice naming the Client's origin and telling the user to open their Passport Agent themselves to approve. The iframe never shows Allow / Deny buttons.
  4. The user navigates to passport.sharenet.sh (or localhost:3001) themselves — bookmark, home-screen icon, or typing the address. They can verify the URL bar.
  5. The standalone home page lists the pending request and offers Allow / Deny. On Allow it writes authorized_origins, deletes the pending row, and broadcasts granted.
  6. The iframe receives granted on its BroadcastChannel listener and tells its embedding Client consent_granted. The Client retries.

This also strengthens compliance with §5.1 ("the user always navigates to their Passport Agent themselves") — the consent decision is now made on a top-level page the user reached through their own entry point, not on a surface the Client put in front of them.

What's interesting in the code

  • passport-agent/app/iframe/page.tsx — the embedded surface. Note how event.origin is captured from the first incoming message (it's browser-stamped; trust it) and how every reply is sent back targeted to exactly that origin, never "*".
  • client/lib/agent-client.ts — every outbound postMessage targets the configured expectedOrigin exactly; every inbound message is dropped unless its event.origin matches that string. Methods include sign and requestDeauthorization.
  • client/lib/agent-config.ts — single-source-of-truth for the user-chosen Passport Agent origin and the "I have clicked Request Authorization" flag, both in localStorage. Exactly one agent at a time; setting a new origin clears the want-auth flag.
  • passport-agent/lib/db.ts — one IndexedDB with three stores: passport, origins, and pending_consents. The authorized-origins list lives next to the passport per §4.2; pending requests sit alongside until the user accepts or denies.
  • passport-agent/lib/consent-channel.ts — the BroadcastChannel that links the iframe and the standalone page. The iframe broadcasts pending; the standalone broadcasts granted / denied.
  • client/scripts/no-link-audit.mjs — coarse but loud. Run with npm run no-link-audit from client/. Wire it into CI to enforce §5.1.

What's not interesting (but the plan wants it)

  • Service workers, offline support, push notifications — out of scope for the PoC. The manifest.json is enough for "Add to Home Screen".
  • navigator.storage.persist() is called on the Passport Agent home page (status shown as a badge), as a stub for §11 Phase 2.

PoC vs. production: Client CSP

The Client's CSP is intentionally permissive (frame-src 'self' http: https:) so the user-configurable agent origin can be any host. In a real product where the Client is paired with a fixed canonical agent (e.g. only passport.sharenet.sh), tighten this to the exact origin in client/next.config.js. Runtime origin checks in client/lib/agent-client.ts already enforce that every outbound postMessage targets the configured agent and every inbound message is dropped unless its event.origin matches — CSP is a defense-in-depth layer on top of that, not the only thing standing between the Client and a malicious frame.