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>
This commit is contained in:
219
README.md
Normal file
219
README.md
Normal file
@@ -0,0 +1,219 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user