Full pipeline proving a WebAuthn PRF assertion can anchor a stable Nostr keypair. Core derivation in nostr_passkey/derivation.py (pure, unit-tested), WebAuthn ceremony glue in webauthn_flow.py, FastAPI surface in app.py, single-page WebAuthn client in web/index.html, and an end-to-end simulation in scripts/demo.py for running without a real authenticator. Verified working against Firefox 149 + macOS Touch ID over HTTPS on https://localhost:8000 with a self-signed loopback cert. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
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 and 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, 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
-
WebAuthn PRF assertion. The browser calls
navigator.credentials.get()withextensions.prf.eval.firstset to a fixed RP-chosen input. The authenticator computes HMAC-SHA256 over"WebAuthn PRF" || 0x00 || inputusing its internal credential secret, returning 32 bytes underclientExtensionResults.prf.results.first. Same credential + same input ⇒ same output, forever. -
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 noneinfo=b"nostr-passkey/v1/nostr-secp256k1-privkey"(fixed for domain separation; changing it rotates every derived key)length= 32 bytes
-
secp256k1 scalar → Nostr key. The output is validated as a scalar in
[1, n-1]and wrapped in apynostr.PrivateKey, which exposesnsec/npubbech32 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-loginspec — 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
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.
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:
Restart the browser so it picks up the new trust entry.
security add-trusted-cert -r trustRoot -p ssl \ -k ~/Library/Keychains/login.keychain-db certs/dev/cert.pem - Firefox: Firefox uses its own NSS store, not the keychain.
about:preferences#privacy→ Certificates → View Certificates → Authorities → Import, selectcerts/dev/cert.pem, check "Trust this CA to identify websites". Restart Firefox.
4. Run the server
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
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.firstis 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,_saltslive 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 inwebauthn_flow.pyandapp.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: falseon the threat model for the derived key. See 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.