# nostr-passkey A prototype that derives a stable Nostr keypair from a WebAuthn passkey using the PRF extension and HKDF. The server stores nothing except, optionally, a salt string. ``` passkey ──▶ prf ──▶ hkdf-sha256 ──▶ nostr secp256k1 privkey │ └── optional salt (enables multiple identities per passkey) ``` Status: working end-to-end against Firefox 149 + macOS Touch ID. Not production code — see [Trust model](#trust-model) and [Limitations](#limitations). ## Why A passkey is a hardware-rooted secret the user already has, already trusts, and cannot lose to a phishing site. If the authenticator supports the WebAuthn [PRF extension](https://www.w3.org/TR/webauthn-3/#prf-extension), it can produce 32 bytes of deterministic entropy bound to that specific credential. Those bytes make an excellent root secret: the user doesn't manage a seed phrase, and the same passkey reproduces the same Nostr identity on any device that can touch it. This prototype proves the full pipeline — passkey assertion, PRF eval, HKDF stretch, secp256k1 scalar, bech32 encoding — runs cleanly in Python. ## Cryptographic flow 1. **WebAuthn PRF assertion.** The browser calls `navigator.credentials.get()` with `extensions.prf.eval.first` set to a fixed RP-chosen input. The authenticator computes HMAC-SHA256 over `"WebAuthn PRF" || 0x00 || input` using its internal credential secret, returning 32 bytes under `clientExtensionResults.prf.results.first`. Same credential + same input ⇒ same output, forever. 2. **HKDF-SHA256 derivation.** Those 32 bytes become the IKM. The application feeds them through HKDF with: - `salt` = the user's optional salt string (UTF-8), or none - `info` = `b"nostr-passkey/v1/nostr-secp256k1-privkey"` (fixed for domain separation; changing it rotates every derived key) - `length` = 32 bytes 3. **secp256k1 scalar → Nostr key.** The output is validated as a scalar in `[1, n-1]` and wrapped in a `pynostr.PrivateKey`, which exposes `nsec` / `npub` bech32 encodings. Nothing derived here is persisted. The only value worth storing across sessions is the user's salt string, so the same passkey can anchor multiple independent identities ("work", "personal", etc.). ### References - Breez `passkey-login` spec — PRF as root-key anchor, salts as meaningful strings, determinism as a design requirement: https://github.com/breez/passkey-login/blob/main/spec.md - Corbado, *Passkeys & WebAuthn PRF for End-to-End Encryption* — PRF output → KDF → symmetric key pattern: https://www.corbado.com/blog/passkeys-prf-webauthn ## Layout ``` nostr-passkey/ ├── nostr_passkey/ │ ├── __init__.py │ ├── derivation.py # PRF → HKDF-SHA256 → Nostr key (pure, tested) │ ├── webauthn_flow.py # py_webauthn + PRF extension glue │ └── app.py # FastAPI endpoints + static mount ├── web/ │ └── index.html # single-page WebAuthn client ├── scripts/ │ └── demo.py # end-to-end simulation with mock PRF output ├── tests/ │ └── test_derivation.py # determinism, salt separation, validation ├── requirements.txt └── README.md ``` ## Running it ### 1. Install dependencies ```bash python3 -m venv .venv source .venv/bin/activate pip install -r requirements.txt ``` ### 2. Generate a self-signed TLS cert WebAuthn requires a secure context. Self-signed is fine on loopback. ```bash mkdir -p certs/dev openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 \ -sha256 -days 365 -nodes \ -keyout certs/dev/key.pem -out certs/dev/cert.pem \ -subj "/CN=localhost" \ -addext "subjectAltName=DNS:localhost,DNS:*.localhost,IP:127.0.0.1,IP:::1" \ -addext "keyUsage=digitalSignature,keyEncipherment" \ -addext "extendedKeyUsage=serverAuth" chmod 600 certs/dev/key.pem ``` ### 3. Trust the cert in your browser - **Chrome / Safari (macOS):** add to the login keychain: ```bash security add-trusted-cert -r trustRoot -p ssl \ -k ~/Library/Keychains/login.keychain-db certs/dev/cert.pem ``` Restart the browser so it picks up the new trust entry. - **Firefox:** Firefox uses its own NSS store, *not* the keychain. `about:preferences#privacy` → *Certificates* → *View Certificates* → *Authorities* → *Import*, select `certs/dev/cert.pem`, check "Trust this CA to identify websites". Restart Firefox. ### 4. Run the server ```bash uvicorn nostr_passkey.app:app \ --host 127.0.0.1 --port 8000 \ --ssl-keyfile certs/dev/key.pem \ --ssl-certfile certs/dev/cert.pem ``` ### 5. Open the UI **Navigate to `https://localhost:8000/`** — not `127.0.0.1`. WebAuthn forbids IP addresses as RP IDs, so the origin hostname must be `localhost`. Click `create passkey`, pick the platform authenticator (Touch ID) at the OS prompt, then `authenticate & derive`. You should see `npub` / `nsec` / hex keys populate in the result card. Type a salt and re-authenticate to see the same passkey anchor a different identity. ## Running tests ```bash pytest tests/ -v python -m scripts.demo # end-to-end simulation without real WebAuthn ``` The derivation module is pure and has full unit coverage: determinism, salt separation, PRF separation, input validation, output shape. ## API surface All endpoints are `POST` with JSON bodies; the single static route `/` serves the web UI. | endpoint | purpose | |----------------------|--------------------------------------------------------------------| | `/register/start` | issue WebAuthn creation options (PRF extension advertised) | | `/register/finish` | verify attestation, remember credential in memory | | `/auth/start` | issue assertion options requesting PRF eval | | `/auth/finish` | verify assertion, extract PRF, run HKDF, return derived identity | Interactive docs at `/docs` while the server is running. ## Trust model **This prototype derives the Nostr key server-side from a PRF output the client hands over the wire.** That is fine for a proof-of-concept; it is wrong for real use. - `clientExtensionResults.prf.results.first` is **not covered by the assertion signature**. A malicious or buggy client can send any bytes it likes in that field, and the server cannot cryptographically distinguish the authenticator's real PRF output from a fabrication. The server trusts the client here because the client is deriving its *own* identity. - In any production system the PRF output should never leave the browser. The HKDF step and the secp256k1 wrap should run entirely client-side (WebCrypto HKDF + a small secp256k1 library), and the server — if there is one — should only ever see the resulting `npub`, used for account lookup, never the private key material. Porting `derivation.py` to a tiny vanilla-JS module is the obvious next step once the backend proof is settled. ## Limitations - **In-memory state only.** `_credentials`, `_pending`, `_salts` live in Python dicts. Restarting the server wipes everything except the TLS cert. - **Single-origin static config.** `RP_ID`, `ORIGIN`, and the PRF eval input are constants in `webauthn_flow.py` and `app.py`. A real deployment would read them from env / config. - **PRF requires a recent authenticator.** Confirmed working: Touch ID on macOS in Firefox 149. Confirmed *not* working: at least one cross-platform authenticator in this repo's test runs (likely a YubiKey with firmware older than 5.7). YubiKey PRF support requires firmware ≥ 5.7; Windows Hello and iCloud Keychain also work on recent browsers. - **Verbose logging is on by default.** Both the backend (`logging.DEBUG`) and the frontend (raw options, credential objects, PRF byte counts) emit diagnostic output useful for bring-up but noisy for anything else. Gate it behind a debug flag before sharing. - **`authorized: false` on the threat model for the derived key.** See [Trust model](#trust-model). Don't rely on server-returned nsec values for anything real. ## Out of scope (deliberately) - BIP-39 mnemonic derivation, BIP-32 hierarchies, and multi-chain key trees. The Breez spec layers those on top of a PRF root; this repo cuts straight from PRF to a single Nostr key via HKDF, because the goal was proving the pipeline, not building a wallet. - Salt publication to Nostr relays for recovery. The Breez spec suggests labels as Nostr events; that would be a natural follow-up once the derivation is client-side. - Credential persistence, user accounts, rate limiting, CSRF tokens, any kind of production hardening.