Files
nostr-passkey/README.md
Michael Schapira - krilin 5f35195e72 Initial prototype: passkey PRF → HKDF → Nostr key
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>
2026-04-10 23:12:27 -04:00

8.6 KiB

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

  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

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:
    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#privacyCertificatesView CertificatesAuthoritiesImport, select certs/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.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. 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.