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>
220 lines
8.6 KiB
Markdown
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.
|