pico-quorum

The emulator on the web, signing with your laptop’s passkey

Design notes (2026-10-04), being built (2026-10-05). Built so far:

The goal is a demo: open a link, the console runs in the page, and Touch ID or Windows Hello is the chip.

Short answers

The framing: one console, bring your own key

It’s the same console. The emulator runs firmware/*.py byte for byte. It’s not a port and not a lookalike. The screen is the frame the firmware sent over SPI. So the browser console is the firmware, in a different body:

  Pico console Browser console
the code firmware/*.py, release 2026.10.n the same files, the same release
what it computes the safeTxHash from the fields on screen, the verify code, the blockie the same
the screen its own LCD a canvas in a page on your computer
the buttons its own, wired to the Pico your keyboard
the key a plug-in chip (ATECC608, Trust M) your own passkey (Touch ID, Windows Hello, a phone, a YubiKey)
key cannot be copied out yes yes, except synced passkeys, which go to your Apple or Google account
the screen can’t be faked by the computer yes no

So “bring your own passkey” is the right name for it. Someone in the multisig who has no chip still gets the same clear-signing screens and checks, with a key they already own. Later they can buy a key, and the Safe swaps that owner for the chip’s.

The one thing it doesn’t carry over is the last row. The JavaScript sandbox protects your computer from the page. It does nothing to protect the page from your computer. Malware, or a browser extension allowed on the site, can redraw the canvas, or hand the passkey a different challenge than the hash on screen. The Touch ID prompt says “sign in to picoquorum.app”; it never shows the transaction. So a passkey owner is safe from key theft but not from a compromised computer. Two things limit the damage:

Where the code comes from

The page is static, so what you run is whatever the server sent on this visit. GitHub Pages serves it from the repo over HTTPS. Without more, trusting the page means trusting the GitHub account. These steps narrow that, roughly from cheapest to strongest:

  1. Reproducible build and BUILD.json, as the app does now. Anyone can rebuild the release’s commit and compare hashes.
  2. The firmware’s identity on screen. The Device screen already shows 2026.10.n · <commit>. The web build puts the firmware files’ SHA-256 (and its blockie) there too, and so does tools/fw build for the Pico. The same release gives the same picture on both. Owners can compare across the room, or with RELEASES.md.
  3. Signed releases. Sign the tag fw-<version>, and have a GitHub Actions build attest the built files (build provenance). A checker page, or tools/quorum, verifies them.
  4. Check the live site from outside. A page can’t vouch for itself. A service worker can’t pin one either: the browser fetches the worker script from the site again, at least once a day, so whoever controls the site can replace it, and with it everything it pins.

    Instead, tools/release check-site fetches what Pages serves and compares every byte with docs/ at the commit Pages deployed. .github/workflows/site-check.yml runs it after every deploy and every six hours, and opens an issue on any difference. The app’s Releases page says which release the page is, and whether the project Safe approved it. That line is information for the visitor; the outside check is what proves it.

  5. Later, the quorum picks the code. Put the build on IPFS, and have an ENS name the Safe owns point to it. Changing the web console then takes a Safe transaction that the owners approve on their consoles. Note: a passkey is tied to its domain, so moving there means new passkeys.

The same manifest hash ties the web build to the Pico. A Pico running 2026.10.n and a browser running 2026.10.n show the same firmware identity, because they are the same files.

How the emulator shows it

What was checked

tools/vectors/passkey.mjs: Chromium makes a passkey on a virtual platform authenticator with user verification (the code path Touch ID takes, without the finger). It signs a real safeTxHash from test-vectors/safe_tx.json. The signature is packed exactly as webauthn.encode packs the chip’s. Then the script asks the deployed factory on each chain, read only:

clientDataJSON    {"type":"webauthn.get","challenge":"h1ky9BUZ…-G3IM","origin":"http://localhost:46539","crossOrigin":false}
clientDataFields  "origin":"http://localhost:46539","crossOrigin":false
flags 0x05: UP yes, UV yes (the contract needs UV)
pass  Base Sepolia  owner 0x94b5…3BC9  low s 0x1626ba7e  high s 0x1626ba7e
pass  Base          owner 0x94b5…3BC9  low s 0x1626ba7e  high s 0x1626ba7e
pass  Ethereum      owner 0x94b5…3BC9  low s 0x1626ba7e  high s 0x1626ba7e

0x1626ba7e is EIP-1271’s “valid signature”. The script also called the precompile at 0x100 directly on Ethereum and Base, and it answered 1 on both. What this settles:

Run it: cd tools/vectors && npm ci && npm run passkey (PQ_CHROMIUM= if playwright-core has no Chromium of its own; --chain eth for one chain).

Why not a “virtual Trust M” backed by the passkey

It’s tempting to keep trustm_sim.py and route its CalcSign to the laptop’s key. That can’t work. CalcSign signs any 32-byte digest it is handed, and a passkey never does. A passkey signs only sha256(authenticatorData || sha256(clientDataJSON)), and it writes both of those itself, putting the real origin and its own flags and counter in them.

The console asks the chip for exactly that digest, built from authenticatorData and clientDataJSON that the console writes itself. So the passkey has to come in one layer up: at “make an assertion over this safeTxHash”, not at “sign this digest”. It is a third key technology next to the ATECC608 and the Trust M, which suits KICKOFF’s “no single technology holds more than two keys” well.

The firmware seam

Today quorum.sign_it does:

ad, fields, digest = webauthn.assertion(e["mine"])
r, s = sig.sign(digest)

Add one method to the signer interface (signer.py), with a default for the chips:

def assertion(self, challenge):          # -> (authenticatorData, clientDataFields, r, s)
    ad, fields, digest = webauthn.assertion(challenge)
    r, s = self.sign(digest)
    return ad, fields, r, s

quorum.sign_it calls sig.assertion(e["mine"]) and nothing else changes. The challenge is still the safeTxHash the console computed from the fields on screen, so it is still no blind signature. webauthn.contract_signature and owner_address work on any (ad, fields, r, s) and (qx, qy).

A new PasskeySigner (only present in the emulator) maps the rest of the interface:

Signer interface Passkey
pubkey() from getPublicKey() at creation, kept in the virtual chip’s state
assertion(c) navigator.credentials.get({challenge: c, userVerification: "required"}), DER to (r, s), low s
sign(digest) refused: “a passkey signs only assertions”
genkey() / needs_ceremony() navigator.credentials.create: the key ceremony is one Touch ID prompt
serial() a short hash of the credential id
use_counter() the authenticator’s signCount (a YubiKey counts; iCloud and Google passkeys report 0)
profile() passkey; name says which kind if the browser tells us
lock / slots one slot, nothing to lock

The bridge mirrors what worker.js does for HTTP and keys. WebAuthn isn’t available in a worker, so the _emu.passkey_get call posts to the page and blocks on Atomics.wait. The page runs the WebAuthn call, writes the result into a SharedArrayBuffer and wakes the worker. To the console it’s a slow chip. The screen stays on “Signing · The chip signs the hash computed here” while the OS prompt is up. Cancelling the prompt becomes “Not signed”.

If there’s no platform authenticator, the page can fall back to a soft chip: a non-extractable WebCrypto P-256 key in IndexedDB. ECDSA-SHA256 over ad || sha256(clientDataJSON) is exactly the digest the console wants, so the console writes the assertion as it does for the chips. There is no prompt and no hardware. It is useful for CI and Firefox, and it is labelled as software.

Hosting on Pages: what changes

Now (2026-10-06): only the console page is on Pages (docs/console/, emu/README.md). It is one folder: the console image as plain files, only the shims it uses, its own small worker. Its CSP allows the folder and Safe’s service and nothing else, and sw.js sends that as a header with COOP/COEP, so it binds the worker too. The emulator’s page below, with the editor and the 3D case, stays local (tools/emu).

Built into docs/emu/ by an emu/build.mjs, in the style of web/build.mjs: pinned dependencies, SRI, and a BUILD.json of hashes. It is served under the same origin as the app, at /emu/.

Today (emu/server.mjs) On Pages
COOP/COEP headers coi-serviceworker.js, vendored (no CDN, keeping the strict CSP)
/vendor/* from node_modules copied at build time: MicroPython wasm (0.5 MB), CodeMirror, three.js
GET /ctl/boot (firmware + shims) boot.json written at build time from workspace.mjs; about 0.9 MB of firmware
/ctl/chip (chip state) IndexedDB in the browser, per visitor
POST /ctl/file (editor saves to disk) saved in the browser, with a “download” button
/ctl/events, /ctl/reply (the CLI drives the page) not offered; tools/emu stays the local path
/ctl/devices, ship, flash (USB) not offered at first. Later, Web Serial could put a sketch on a real Pico from Chrome.
/app proxy (a laptop service, such as tools/quorum’s mock) not needed by the quorum console
case STLs (9.5 MB) gone: the case left the repository on 2026-10-06, and every view is flat

From the browser the console talks straight to Safe’s transaction service. The requests shim already does that with sync XHR, and CORS fetches are fine under COEP. Safe’s API key comes from the same localStorage the app uses for its setting (same origin), so it is set once for both.

The demo

  1. Open picoquorum.app/console/. The screen asks you to make a passkey; the OS asks for Touch ID. With a Safe saved in the app, the console boots and shows its owner address and blockie, computed on the console by webauthn.owner_address. On your other device (the passkey synced there), choose I already have one: it signs twice and the page works out the same key.
  2. In the app, Keys → Your passkey (#/passkey): deploy its signer on Base Sepolia, then Add to your test Safe, which opens Add owner with its address filled in.
  3. Propose a send from the app. The emulated console picks it up, decodes it, and shows the verify code and hash blockie, the same screens as the hardware.
  4. Hold A. Touch ID prompts. The confirmation goes to Safe’s service, and the app shows it checked, then executes.

A phone works too: Chrome and Safari offer “use a phone or tablet” (hybrid, by QR code). A YubiKey works as well (ES256).

Limits to say out loud

Steps

  1. signer.assertion() plus the quorum.sign_it change, with test vectors: no behaviour change for the chips. The signing line and Device screen take their wording from the signer. The firmware files’ hash goes on the Device screen, on the Pico too.
  2. emu/build.mjs writing docs/emu/ with boot.json and the service worker; the emulator boots from Pages with the soft chip.
  3. PasskeySigner and the worker/page bridge; the ceremony and signing screens on a real Mac.
  4. App: show passkey owners as such; offer “deploy signer proxy” for them.
  5. Signed tags plus build provenance for releases, and a check of the live site from outside it.
  6. Custom domain, before anything but test Safes.