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

220 lines
8.6 KiB
Markdown

# 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.