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:
2026-04-10 23:12:27 -04:00
commit 5f35195e72
10 changed files with 1404 additions and 0 deletions

13
.gitignore vendored Normal file
View File

@@ -0,0 +1,13 @@
.venv/
__pycache__/
*.pyc
.pytest_cache/
# self-signed dev TLS material — never commit
certs/
# editor / tooling
.DS_Store
.claude/
.idea/
.vscode/

219
README.md Normal file
View 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.

View File

@@ -0,0 +1,5 @@
"""Passkey-anchored Nostr key derivation prototype."""
from .derivation import NostrIdentity, derive_nostr_key
__all__ = ["NostrIdentity", "derive_nostr_key"]

204
nostr_passkey/app.py Normal file
View File

@@ -0,0 +1,204 @@
"""FastAPI surface for the passkey -> Nostr prototype.
Single-process, in-memory. Restarting the server wipes registrations
and pending challenges, which is the right default for a
proof-of-concept: the only value worth persisting is the user's
optional salt string.
Endpoints:
POST /register/start -> WebAuthn creation options (with PRF ext)
POST /register/finish -> verify attestation, remember credential
POST /auth/start -> WebAuthn request options (PRF eval)
POST /auth/finish -> verify assertion, derive + return Nostr key
Run locally:
uvicorn nostr_passkey.app:app --reload --port 8000
"""
from __future__ import annotations
import logging
import secrets
from pathlib import Path
from typing import Any
from fastapi import FastAPI, HTTPException, Request
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
# Verbose logging so every ceremony step shows up in the uvicorn output.
# ``force=True`` makes this win over uvicorn's default logging config.
logging.basicConfig(
level=logging.DEBUG,
format="%(asctime)s %(levelname)-5s %(name)s · %(message)s",
force=True,
)
log = logging.getLogger("nostr_passkey")
from .derivation import derive_nostr_key
from .webauthn_flow import (
PendingChallenge,
StoredCredential,
extract_prf_output,
new_authentication_options,
new_registration_options,
verify_authentication,
verify_registration,
)
app = FastAPI(title="Nostr Passkey Prototype")
@app.middleware("http")
async def _trace(request: Request, call_next):
"""Log every request with method, path, and response status."""
log.info("%s %s", request.method, request.url.path)
response = await call_next(request)
log.info("%s %s %s", request.method, request.url.path, response.status_code)
return response
# In-memory stores. NOT for production.
_pending: dict[str, PendingChallenge] = {}
_credentials: dict[str, StoredCredential] = {} # username -> credential
_salts: dict[str, str] = {} # username -> last-used salt (optional)
# Fixed PRF eval input. Any stable bytes work — the authenticator hashes
# this with the "WebAuthn PRF\x00" context before HMAC, and keeping it
# constant across assertions is what makes the PRF output stable for a
# given credential. Changing this constant rotates every derived key.
PRF_EVAL_FIRST = b"nostr-passkey/v1/prf-eval"
class RegisterStartRequest(BaseModel):
username: str
class RegisterFinishRequest(BaseModel):
username: str
session_id: str
credential: dict[str, Any]
class AuthStartRequest(BaseModel):
username: str
class AuthFinishRequest(BaseModel):
username: str
session_id: str
credential: dict[str, Any]
salt: str | None = None
class NostrIdentityResponse(BaseModel):
npub: str
nsec: str
privkey_hex: str
pubkey_hex: str
@app.post("/register/start")
def register_start(req: RegisterStartRequest) -> dict[str, Any]:
log.debug("register/start username=%r", req.username)
options, pending = new_registration_options(
username=req.username,
prf_eval_first=PRF_EVAL_FIRST,
)
session_id = secrets.token_urlsafe(16)
_pending[session_id] = pending
log.debug(
"register/start issued session=%s rp.id=%s challenge_len=%s ext=%s",
session_id,
options.get("rp", {}).get("id"),
len(options.get("challenge", "")),
list(options.get("extensions", {}).keys()),
)
return {"session_id": session_id, "options": options}
@app.post("/register/finish")
def register_finish(req: RegisterFinishRequest) -> dict[str, str]:
log.debug("register/finish username=%r session=%s", req.username, req.session_id)
pending = _pending.pop(req.session_id, None)
if pending is None:
log.warning("register/finish unknown session %s", req.session_id)
raise HTTPException(status_code=400, detail="unknown session")
try:
stored = verify_registration(req.credential, pending)
except Exception as exc:
log.exception("register/finish verification failed: %s", exc)
raise HTTPException(status_code=400, detail=f"verify failed: {exc}") from exc
_credentials[req.username] = stored
log.debug("register/finish ok cred_id=%s", stored.credential_id.hex()[:16])
return {"status": "registered", "username": req.username}
@app.post("/auth/start")
def auth_start(req: AuthStartRequest) -> dict[str, Any]:
log.debug("auth/start username=%r", req.username)
stored = _credentials.get(req.username)
if stored is None:
log.warning("auth/start unknown user %r", req.username)
raise HTTPException(status_code=404, detail="unknown user")
options, pending = new_authentication_options(stored, PRF_EVAL_FIRST)
session_id = secrets.token_urlsafe(16)
_pending[session_id] = pending
log.debug(
"auth/start issued session=%s rpId=%s allowCreds=%s",
session_id,
options.get("rpId"),
len(options.get("allowCredentials", []) or []),
)
return {"session_id": session_id, "options": options}
@app.post("/auth/finish", response_model=NostrIdentityResponse)
def auth_finish(req: AuthFinishRequest) -> NostrIdentityResponse:
log.debug("auth/finish username=%r session=%s salt=%r", req.username, req.session_id, req.salt)
pending = _pending.pop(req.session_id, None)
if pending is None:
log.warning("auth/finish unknown session %s", req.session_id)
raise HTTPException(status_code=400, detail="unknown session")
stored = _credentials.get(req.username)
if stored is None:
log.warning("auth/finish unknown user %r", req.username)
raise HTTPException(status_code=404, detail="unknown user")
try:
new_count = verify_authentication(req.credential, pending, stored)
except Exception as exc:
log.exception("auth/finish assertion verification failed: %s", exc)
raise HTTPException(status_code=400, detail=f"verify failed: {exc}") from exc
stored.sign_count = new_count
log.debug("auth/finish signature ok new_sign_count=%s", new_count)
try:
prf_output = extract_prf_output(req.credential)
except KeyError as exc:
log.warning("auth/finish missing PRF output: %s", exc)
raise HTTPException(status_code=400, detail=str(exc)) from exc
log.debug("auth/finish prf_output=%s bytes", len(prf_output))
# Prefer an explicitly-supplied salt; fall back to whatever the user
# last used. Remember the choice so future calls without a salt
# stay consistent. This is the *only* state we persist beyond the
# current ceremony.
salt = req.salt if req.salt is not None else _salts.get(req.username)
if salt is not None:
_salts[req.username] = salt
identity = derive_nostr_key(prf_output, salt=salt)
log.info("auth/finish derived npub=%s", identity.npub)
return NostrIdentityResponse(
npub=identity.npub,
nsec=identity.nsec,
privkey_hex=identity.privkey_hex,
pubkey_hex=identity.pubkey_hex,
)
# Static frontend. Mounted last so the POST API routes defined above take
# precedence — StaticFiles only serves GET, so there is no collision.
_WEB_DIR = Path(__file__).resolve().parent.parent / "web"
if _WEB_DIR.is_dir():
app.mount("/", StaticFiles(directory=_WEB_DIR, html=True), name="web")

112
nostr_passkey/derivation.py Normal file
View File

@@ -0,0 +1,112 @@
"""Passkey PRF output -> HKDF -> Nostr private key.
The cryptographic flow this module implements:
1. A WebAuthn assertion with the PRF extension returns 32 bytes of
deterministic entropy from the authenticator. Those bytes are bound
to the specific passkey credential and to the PRF eval input the
relying party sent with the assertion.
2. Those 32 bytes become the input keying material (IKM) for
HKDF-SHA256, stretched with an optional user-chosen salt and a
fixed ``info`` context string for domain separation.
3. The 32-byte HKDF output is interpreted as a secp256k1 scalar and
wrapped in a Nostr ``PrivateKey``, producing nsec/npub on demand.
The backend stores nothing derived here — not the PRF output, not the
private key. The only value worth persisting across sessions is the
user's optional salt string (so the same passkey can anchor multiple
independent Nostr identities).
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 -> HKDF -> symmetric key derivation pattern.
https://www.corbado.com/blog/passkeys-prf-webauthn
"""
from __future__ import annotations
from dataclasses import dataclass
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from pynostr.key import PrivateKey
# secp256k1 group order. Valid private keys are integers in [1, n-1].
_SECP256K1_N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
PRF_OUTPUT_BYTES = 32
NOSTR_PRIVKEY_BYTES = 32
# Fixed domain-separation string for this application's HKDF derivation.
# Changing this value yields a different Nostr key even for the same PRF
# output and salt, so it must remain stable once users have derived keys.
HKDF_INFO = b"nostr-passkey/v1/nostr-secp256k1-privkey"
@dataclass(frozen=True)
class NostrIdentity:
"""The derived Nostr keypair in every encoding a caller might want."""
privkey_hex: str
nsec: str
pubkey_hex: str
npub: str
def derive_nostr_key(
prf_output: bytes,
salt: str | None = None,
info: bytes = HKDF_INFO,
) -> NostrIdentity:
"""Derive a Nostr keypair from WebAuthn PRF output via HKDF-SHA256.
Args:
prf_output: The 32 bytes returned by the authenticator under
``clientExtensionResults.prf.results.first``.
salt: Optional user-chosen salt string. Used as the HKDF salt so
that the same passkey can anchor multiple independent
identities (e.g. "work", "personal"). ``None`` or empty
string means no salt (HKDF then uses the all-zero default
per RFC 5869).
info: HKDF info / context string. Defaults to this app's
domain-separation constant; callers should almost never
override it.
Returns:
A ``NostrIdentity`` with the private key in hex and bech32
(nsec), and the x-only public key in hex and bech32 (npub).
Raises:
ValueError: If ``prf_output`` is not exactly 32 bytes, or if the
derived scalar happens to fall outside [1, n-1] on secp256k1
(probability ~2^-128, surfaced rather than silently
re-derived so the caller can decide how to recover).
"""
if len(prf_output) != PRF_OUTPUT_BYTES:
raise ValueError(
f"prf_output must be {PRF_OUTPUT_BYTES} bytes, got {len(prf_output)}"
)
hkdf_salt = salt.encode("utf-8") if salt else None
okm = HKDF(
algorithm=hashes.SHA256(),
length=NOSTR_PRIVKEY_BYTES,
salt=hkdf_salt,
info=info,
).derive(prf_output)
scalar = int.from_bytes(okm, "big")
if scalar == 0 or scalar >= _SECP256K1_N:
raise ValueError("HKDF output is not a valid secp256k1 scalar")
priv = PrivateKey(raw_secret=okm)
return NostrIdentity(
privkey_hex=okm.hex(),
nsec=priv.bech32(),
pubkey_hex=priv.public_key.hex(),
npub=priv.public_key.bech32(),
)

View File

@@ -0,0 +1,204 @@
"""WebAuthn registration + assertion with the PRF extension.
This module wraps ``py_webauthn`` for ceremony bookkeeping (challenges,
signature verification, sign-count tracking) and layers on the bits the
library does not model directly: the PRF extension advertised at
registration and evaluated at assertion, and the extraction of the PRF
output from ``clientExtensionResults`` on the authentication response.
The PRF output itself is opaque bytes as far as this module is concerned.
It is handed off to :func:`nostr_passkey.derivation.derive_nostr_key` by
the caller (the FastAPI app).
Notes on trust model: the backend cannot cryptographically verify that
the PRF output the browser hands back is genuinely the authenticator's
HMAC-secret output — it is reported via ``clientExtensionResults`` and
is not covered by the assertion signature. In a production deployment
the derivation should run entirely in the browser so the PRF output
never leaves the client. This prototype derives server-side on purpose,
to exercise the full pipeline end-to-end in Python.
"""
from __future__ import annotations
import base64
import json
import secrets
from dataclasses import dataclass
from typing import Any
from webauthn import (
generate_authentication_options,
generate_registration_options,
options_to_json,
verify_authentication_response,
verify_registration_response,
)
from webauthn.helpers.structs import (
AuthenticatorSelectionCriteria,
PublicKeyCredentialDescriptor,
ResidentKeyRequirement,
UserVerificationRequirement,
)
# Static RP config for the prototype. A real deployment would pull these
# from application config; for a local proof-of-concept, constants are fine.
RP_ID = "localhost"
RP_NAME = "Nostr Passkey Prototype"
ORIGIN = "https://localhost:8000"
def _b64url_decode(data: str) -> bytes:
padding = "=" * (-len(data) % 4)
return base64.urlsafe_b64decode(data + padding)
def _b64url_encode(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
@dataclass
class StoredCredential:
"""Post-registration state we need to verify future assertions.
The spec goal is "store nothing except optionally a salt string",
but a WebAuthn relying party still needs the credential id and the
credential's public key to verify signatures — neither is a secret.
The PRF-derived entropy and the resulting Nostr private key are
never stored.
"""
credential_id: bytes
public_key: bytes
sign_count: int
user_handle: bytes
@dataclass
class PendingChallenge:
"""A challenge issued for an in-flight ceremony, awaiting the client."""
challenge: bytes
user_handle: bytes | None = None
def new_registration_options(
username: str,
prf_eval_first: bytes | None = None,
) -> tuple[dict[str, Any], PendingChallenge]:
"""Build WebAuthn registration options with PRF extension enabled.
The authenticator needs PRF requested at registration time in order
to report ``prf.enabled=true`` in the registration response; passing
``prf_eval_first`` additionally asks the authenticator to evaluate
the PRF during create(), which some platforms support and some
defer to the next assertion.
"""
user_handle = secrets.token_bytes(16)
options = generate_registration_options(
rp_id=RP_ID,
rp_name=RP_NAME,
user_id=user_handle,
user_name=username,
authenticator_selection=AuthenticatorSelectionCriteria(
resident_key=ResidentKeyRequirement.PREFERRED,
user_verification=UserVerificationRequirement.REQUIRED,
),
)
options_dict = json.loads(options_to_json(options))
# py_webauthn does not model the PRF extension directly as of this
# writing, so we inject it into the serialized options dict before
# it goes to the browser.
prf_ext: dict[str, Any] = {}
if prf_eval_first is not None:
prf_ext["eval"] = {"first": _b64url_encode(prf_eval_first)}
options_dict["extensions"] = {"prf": prf_ext}
return options_dict, PendingChallenge(
challenge=options.challenge,
user_handle=user_handle,
)
def verify_registration(
credential_json: dict[str, Any],
pending: PendingChallenge,
) -> StoredCredential:
"""Verify a registration response and return the stored credential."""
verified = verify_registration_response(
credential=credential_json,
expected_challenge=pending.challenge,
expected_rp_id=RP_ID,
expected_origin=ORIGIN,
)
return StoredCredential(
credential_id=verified.credential_id,
public_key=verified.credential_public_key,
sign_count=verified.sign_count,
user_handle=pending.user_handle or b"",
)
def new_authentication_options(
credential: StoredCredential,
prf_eval_first: bytes,
) -> tuple[dict[str, Any], PendingChallenge]:
"""Build assertion options that request a PRF evaluation.
``prf_eval_first`` is the RP-chosen PRF input. The authenticator
hashes it with the WebAuthn PRF context string (``"WebAuthn PRF\\x00"``)
before running HMAC-secret, so any stable bytes are fine — but the
RP must use the *same* value across assertions to get a stable
output for the same credential.
"""
options = generate_authentication_options(
rp_id=RP_ID,
allow_credentials=[
PublicKeyCredentialDescriptor(id=credential.credential_id)
],
user_verification=UserVerificationRequirement.REQUIRED,
)
options_dict = json.loads(options_to_json(options))
options_dict["extensions"] = {
"prf": {"eval": {"first": _b64url_encode(prf_eval_first)}}
}
return options_dict, PendingChallenge(challenge=options.challenge)
def verify_authentication(
credential_json: dict[str, Any],
pending: PendingChallenge,
stored: StoredCredential,
) -> int:
"""Verify the assertion signature and return the new sign count."""
verified = verify_authentication_response(
credential=credential_json,
expected_challenge=pending.challenge,
expected_rp_id=RP_ID,
expected_origin=ORIGIN,
credential_public_key=stored.public_key,
credential_current_sign_count=stored.sign_count,
)
return verified.new_sign_count
def extract_prf_output(credential_json: dict[str, Any]) -> bytes:
"""Pull the PRF ``first`` output from a client assertion response.
The browser returns it under
``clientExtensionResults.prf.results.first`` as a base64url string.
Raises ``KeyError`` if the extension was not returned — which means
the authenticator does not support PRF, or UV was not satisfied, or
the RP did not ask for evaluation.
"""
ext = credential_json.get("clientExtensionResults") or {}
prf = ext.get("prf") or {}
results = prf.get("results") or {}
first = results.get("first")
if first is None:
raise KeyError(
"clientExtensionResults.prf.results.first missing — "
"authenticator did not return PRF output"
)
return _b64url_decode(first)

7
requirements.txt Normal file
View File

@@ -0,0 +1,7 @@
webauthn>=2.0.0
cryptography>=42.0.0
pynostr>=0.6.2
fastapi>=0.110.0
uvicorn[standard]>=0.29.0
pydantic>=2.6.0
pytest>=8.0.0

54
scripts/demo.py Normal file
View File

@@ -0,0 +1,54 @@
"""End-to-end simulation of the passkey -> Nostr key derivation flow.
Without a real WebAuthn authenticator and browser handy, this script
stubs the 32-byte PRF output with a deterministic hash so you can watch
the rest of the pipeline work: HKDF stretches the entropy, the result
becomes a Nostr private key, and nsec/npub fall out the other side.
Run it with:
python -m scripts.demo
"""
from __future__ import annotations
import hashlib
from nostr_passkey.derivation import derive_nostr_key
def fake_prf_output(seed: str) -> bytes:
"""Stand-in for ``clientExtensionResults.prf.results.first``."""
return hashlib.sha256(seed.encode("utf-8")).digest()
def main() -> None:
prf = fake_prf_output("pretend-passkey-credential")
print(f"Simulated PRF output (hex): {prf.hex()}")
ident_none = derive_nostr_key(prf)
print("\n-- No salt --")
print(f" privkey_hex: {ident_none.privkey_hex}")
print(f" nsec: {ident_none.nsec}")
print(f" npub: {ident_none.npub}")
ident_work = derive_nostr_key(prf, salt="work")
print("\n-- salt='work' --")
print(f" nsec: {ident_work.nsec}")
print(f" npub: {ident_work.npub}")
ident_personal = derive_nostr_key(prf, salt="personal")
print("\n-- salt='personal' --")
print(f" nsec: {ident_personal.nsec}")
print(f" npub: {ident_personal.npub}")
# Invariants the whole design rests on:
ident_work_again = derive_nostr_key(prf, salt="work")
assert ident_work.nsec == ident_work_again.nsec, "derivation must be deterministic"
assert ident_work.nsec != ident_none.nsec, "salt must change the output"
assert ident_work.nsec != ident_personal.nsec, "different salts must diverge"
print("\nDeterminism and salt separation verified.")
if __name__ == "__main__":
main()

50
tests/test_derivation.py Normal file
View File

@@ -0,0 +1,50 @@
"""Tests for the PRF -> HKDF -> Nostr key derivation."""
from __future__ import annotations
import pytest
from nostr_passkey.derivation import PRF_OUTPUT_BYTES, derive_nostr_key
PRF_A = bytes.fromhex("11" * 32)
PRF_B = bytes.fromhex("22" * 32)
def test_deterministic_for_same_inputs() -> None:
a = derive_nostr_key(PRF_A, salt="alpha")
b = derive_nostr_key(PRF_A, salt="alpha")
assert a == b
def test_different_salts_produce_different_keys() -> None:
a = derive_nostr_key(PRF_A, salt="alpha")
b = derive_nostr_key(PRF_A, salt="beta")
assert a.privkey_hex != b.privkey_hex
assert a.npub != b.npub
def test_different_prf_outputs_produce_different_keys() -> None:
a = derive_nostr_key(PRF_A, salt="alpha")
b = derive_nostr_key(PRF_B, salt="alpha")
assert a.privkey_hex != b.privkey_hex
def test_no_salt_differs_from_nonempty_salt() -> None:
no_salt = derive_nostr_key(PRF_A)
salted = derive_nostr_key(PRF_A, salt="x")
assert no_salt.privkey_hex != salted.privkey_hex
def test_rejects_wrong_prf_length() -> None:
with pytest.raises(ValueError):
derive_nostr_key(b"too-short")
with pytest.raises(ValueError):
derive_nostr_key(bytes(PRF_OUTPUT_BYTES + 1))
def test_output_shapes() -> None:
ident = derive_nostr_key(PRF_A)
assert len(ident.privkey_hex) == 64 # 32 bytes hex
assert len(ident.pubkey_hex) == 64 # x-only, 32 bytes hex
assert ident.nsec.startswith("nsec1")
assert ident.npub.startswith("npub1")

536
web/index.html Normal file
View File

@@ -0,0 +1,536 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>nostr-passkey</title>
<style>
:root {
--fg: #000;
--bg: #fff;
--muted: #6b6b6b;
--rule: #000;
--soft: #f4f4f4;
}
* { box-sizing: border-box; }
html, body {
margin: 0; padding: 0;
background: var(--bg); color: var(--fg);
font-family: ui-monospace, "JetBrains Mono", "IBM Plex Mono", Menlo, Consolas, monospace;
font-size: 14px; line-height: 1.55;
-webkit-font-smoothing: antialiased;
}
::selection { background: #000; color: #fff; }
main {
max-width: 620px;
margin: 72px auto 96px;
padding: 0 28px;
}
header {
border-bottom: 1px solid var(--rule);
padding-bottom: 18px;
margin-bottom: 36px;
}
header .title {
display: flex;
align-items: baseline;
gap: 14px;
}
h1 {
font-size: 13px;
font-weight: 700;
letter-spacing: 0.28em;
text-transform: uppercase;
margin: 0;
}
.ver {
font-size: 10px;
color: var(--muted);
letter-spacing: 0.2em;
text-transform: uppercase;
}
.sub {
font-size: 11px;
color: var(--muted);
margin-top: 8px;
letter-spacing: 0.06em;
}
.sub b { color: var(--fg); font-weight: 600; }
section {
border: 1px solid var(--rule);
padding: 22px 26px;
margin-bottom: 22px;
}
section h2 {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.3em;
text-transform: uppercase;
margin: 0 0 18px;
display: flex;
align-items: center;
gap: 12px;
}
section h2::before {
content: "";
display: inline-block;
width: 10px; height: 1px;
background: #000;
}
label {
display: block;
font-size: 9px;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--muted);
margin-bottom: 5px;
}
input[type=text] {
width: 100%;
padding: 11px 13px;
border: 1px solid var(--rule);
background: var(--bg);
color: var(--fg);
font-family: inherit;
font-size: 14px;
margin-bottom: 16px;
border-radius: 0;
outline: none;
transition: background 0.08s, color 0.08s;
}
input[type=text]:focus {
background: #000; color: #fff;
}
input[type=text]::placeholder { color: var(--muted); }
input[type=text]:focus::placeholder { color: #888; }
button {
display: block;
width: 100%;
padding: 13px 16px;
border: 1px solid var(--rule);
background: var(--bg);
color: var(--fg);
font-family: inherit;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.28em;
text-transform: uppercase;
cursor: pointer;
border-radius: 0;
transition: background 0.08s, color 0.08s;
}
button:hover:not(:disabled) { background: #000; color: #fff; }
button:active:not(:disabled) { background: #222; }
button:disabled {
color: var(--muted);
cursor: not-allowed;
border-color: var(--muted);
}
.row { margin-bottom: 14px; }
.row:last-child { margin-bottom: 0; }
pre {
margin: 0;
padding: 12px 14px;
border: 1px solid var(--rule);
background: var(--soft);
font-family: inherit;
font-size: 12px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-all;
}
#log {
margin-top: 36px;
font-size: 11px;
color: var(--muted);
border-top: 1px solid var(--rule);
padding-top: 18px;
min-height: 48px;
white-space: pre-wrap;
}
#log .line::before { content: "> "; color: var(--fg); }
#log .line.err { color: #000; font-weight: 600; }
#log .line.err::before { content: "! "; }
footer {
margin-top: 48px;
padding-top: 16px;
border-top: 1px solid var(--rule);
font-size: 10px;
color: var(--muted);
letter-spacing: 0.08em;
display: flex;
justify-content: space-between;
}
footer a { color: var(--muted); text-decoration: none; border-bottom: 1px dotted var(--muted); }
footer a:hover { color: #000; border-bottom-color: #000; }
.diag {
border: 1px solid var(--rule);
padding: 14px 18px;
margin-bottom: 22px;
font-size: 11px;
}
.diag h2 {
font-size: 9px;
font-weight: 700;
letter-spacing: 0.3em;
text-transform: uppercase;
margin: 0 0 10px;
color: var(--muted);
}
.diag .kv { display: grid; grid-template-columns: 140px 1fr; gap: 4px 12px; }
.diag .k { color: var(--muted); text-transform: uppercase; letter-spacing: 0.1em; font-size: 10px; }
.diag .v { word-break: break-all; }
.diag .v.ok { color: #000; }
.diag .v.bad { color: #000; font-weight: 700; }
.diag .v.bad::before { content: "✗ "; }
.diag .v.ok::before { content: "✓ "; }
</style>
</head>
<body>
<main>
<header>
<div class="title">
<h1>nostr-passkey</h1>
<span class="ver">v0.1 · prototype</span>
</div>
<div class="sub"><b>passkey</b><b>prf</b><b>hkdf</b><b>nostr key</b></div>
</header>
<div class="diag" id="diag">
<h2>00 · environment</h2>
<div class="kv" id="diag-kv"></div>
</div>
<section>
<h2>01 · register</h2>
<label for="reg-user">username</label>
<input id="reg-user" type="text" value="alice" autocomplete="off" spellcheck="false">
<button id="btn-register">create passkey</button>
</section>
<section>
<h2>02 · derive</h2>
<label for="auth-user">username</label>
<input id="auth-user" type="text" value="alice" autocomplete="off" spellcheck="false">
<label for="auth-salt">salt &nbsp;·&nbsp; optional</label>
<input id="auth-salt" type="text" placeholder="e.g. work" autocomplete="off" spellcheck="false">
<button id="btn-derive">authenticate &amp; derive</button>
</section>
<section id="result-section" style="display:none;">
<h2>03 · derived identity</h2>
<div class="row"><label>npub</label><pre id="out-npub"></pre></div>
<div class="row"><label>nsec</label><pre id="out-nsec"></pre></div>
<div class="row"><label>pubkey hex</label><pre id="out-pubhex"></pre></div>
<div class="row"><label>privkey hex</label><pre id="out-privhex"></pre></div>
</section>
<div id="log"></div>
<footer>
<span>stateless · prf-anchored · hkdf-sha256</span>
<a href="/docs" target="_blank" rel="noopener">openapi</a>
</footer>
</main>
<script>
// ---------- base64url helpers ----------
function b64uToBuf(s) {
const pad = "=".repeat((4 - s.length % 4) % 4);
const b64 = (s + pad).replace(/-/g, "+").replace(/_/g, "/");
const raw = atob(b64);
const buf = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i++) buf[i] = raw.charCodeAt(i);
return buf.buffer;
}
function bufToB64u(buf) {
const bytes = new Uint8Array(buf);
let s = "";
for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
// ---------- log (mirrors to browser console for full stacks) ----------
const logEl = document.getElementById("log");
function log(msg, kind) {
const line = document.createElement("div");
line.className = "line" + (kind === "err" ? " err" : "");
line.textContent = msg;
logEl.appendChild(line);
if (kind === "err") console.error("[nostr-passkey]", msg);
else console.log("[nostr-passkey]", msg);
}
function clearLog() { logEl.innerHTML = ""; }
// Expand a DOMException / Error into every field we can get at. Some
// of these properties only exist on specific browsers — read them
// defensively so we don't throw while reporting an error.
function describeError(e) {
if (!e) return "(null error)";
const parts = [];
const name = e.name || e.constructor?.name || "Error";
parts.push(name);
if (e.code !== undefined) parts.push(`code=${e.code}`);
if (e.message) parts.push(e.message);
const seen = new Set();
for (const k of Object.getOwnPropertyNames(e)) {
if (seen.has(k) || ["name", "message", "stack", "code"].includes(k)) continue;
seen.add(k);
try {
const v = e[k];
if (v !== undefined && typeof v !== "function") parts.push(`${k}=${v}`);
} catch (_) { /* ignore */ }
}
return parts.join(" · ");
}
// ---------- environment diagnostics ----------
async function paintDiagnostics() {
const kv = document.getElementById("diag-kv");
const rows = [];
function row(key, val, ok) {
rows.push({ key, val: String(val), ok });
}
row("origin", window.location.origin, true);
row("isSecureContext", window.isSecureContext, !!window.isSecureContext);
row("crypto.subtle", !!(window.crypto && window.crypto.subtle), !!(window.crypto && window.crypto.subtle));
row("PublicKeyCredential", !!window.PublicKeyCredential, !!window.PublicKeyCredential);
let uvpa = "n/a";
if (window.PublicKeyCredential && PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable) {
try {
uvpa = await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
} catch (e) { uvpa = "error: " + describeError(e); }
}
row("platformAuth", uvpa, uvpa === true);
let condMed = "n/a";
if (window.PublicKeyCredential && PublicKeyCredential.isConditionalMediationAvailable) {
try { condMed = await PublicKeyCredential.isConditionalMediationAvailable(); }
catch (e) { condMed = "error: " + describeError(e); }
}
row("condMediation", condMed, condMed === true);
row("userAgent", navigator.userAgent, true);
kv.innerHTML = "";
for (const r of rows) {
const k = document.createElement("div"); k.className = "k"; k.textContent = r.key;
const v = document.createElement("div"); v.className = "v " + (r.ok ? "ok" : "bad"); v.textContent = r.val;
kv.appendChild(k); kv.appendChild(v);
}
// Also dump everything to the console once, in structured form.
console.log("[nostr-passkey] env", {
origin: window.location.origin,
isSecureContext: window.isSecureContext,
hasPKC: !!window.PublicKeyCredential,
platformAuth: uvpa,
condMediation: condMed,
userAgent: navigator.userAgent,
});
}
paintDiagnostics();
// ---------- api ----------
async function api(path, body) {
const res = await fetch(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`${path} ${res.status}: ${text}`);
}
return res.json();
}
// ---------- options decoding ----------
// Server sends all BufferSource fields as base64url strings; the
// WebAuthn API needs them as ArrayBuffers.
function decodeCreationOptions(opts) {
const o = structuredClone(opts);
o.challenge = b64uToBuf(o.challenge);
o.user.id = b64uToBuf(o.user.id);
if (Array.isArray(o.excludeCredentials)) {
o.excludeCredentials = o.excludeCredentials.map(c => ({ ...c, id: b64uToBuf(c.id) }));
}
const prf = o.extensions && o.extensions.prf && o.extensions.prf.eval;
if (prf) {
if (prf.first) prf.first = b64uToBuf(prf.first);
if (prf.second) prf.second = b64uToBuf(prf.second);
}
return o;
}
function decodeRequestOptions(opts) {
const o = structuredClone(opts);
o.challenge = b64uToBuf(o.challenge);
if (Array.isArray(o.allowCredentials)) {
o.allowCredentials = o.allowCredentials.map(c => ({ ...c, id: b64uToBuf(c.id) }));
}
const prf = o.extensions && o.extensions.prf && o.extensions.prf.eval;
if (prf) {
if (prf.first) prf.first = b64uToBuf(prf.first);
if (prf.second) prf.second = b64uToBuf(prf.second);
}
return o;
}
// ---------- credential encoding ----------
function encodeRegistrationCredential(cred) {
return {
id: cred.id,
rawId: bufToB64u(cred.rawId),
type: cred.type,
response: {
clientDataJSON: bufToB64u(cred.response.clientDataJSON),
attestationObject: bufToB64u(cred.response.attestationObject),
},
clientExtensionResults: cred.getClientExtensionResults(),
};
}
function encodeAssertionCredential(cred) {
const ext = cred.getClientExtensionResults();
const extOut = {};
if (ext.prf && ext.prf.results) {
extOut.prf = { results: {} };
if (ext.prf.results.first) extOut.prf.results.first = bufToB64u(ext.prf.results.first);
if (ext.prf.results.second) extOut.prf.results.second = bufToB64u(ext.prf.results.second);
}
return {
id: cred.id,
rawId: bufToB64u(cred.rawId),
type: cred.type,
response: {
clientDataJSON: bufToB64u(cred.response.clientDataJSON),
authenticatorData: bufToB64u(cred.response.authenticatorData),
signature: bufToB64u(cred.response.signature),
userHandle: cred.response.userHandle ? bufToB64u(cred.response.userHandle) : null,
},
clientExtensionResults: extOut,
};
}
// ---------- flows ----------
async function doRegister() {
const btn = document.getElementById("btn-register");
btn.disabled = true;
clearLog();
try {
const username = document.getElementById("reg-user").value.trim();
if (!username) throw new Error("username required");
log(`register/start user=${username}`);
const { session_id, options } = await api("/register/start", { username });
log(`options received · rp.id=${options.rp?.id} challenge_len=${options.challenge?.length}`);
console.log("[nostr-passkey] raw options", options);
const publicKey = decodeCreationOptions(options);
console.log("[nostr-passkey] decoded publicKey", publicKey);
log(`invoking navigator.credentials.create({publicKey})`);
let cred;
try {
cred = await navigator.credentials.create({ publicKey });
} catch (e) {
// Surface every field the browser gives us. "The operation is insecure"
// in Firefox, "NotAllowedError" in Chrome, etc. Each browser puts the
// interesting detail in a different field.
log(`create() threw · ${describeError(e)}`, "err");
console.error("[nostr-passkey] create() error object", e);
throw e;
}
log(`credential created · id=${cred.id?.slice(0, 16)}… type=${cred.type} attachment=${cred.authenticatorAttachment || "?"}`);
const regExt = cred.getClientExtensionResults();
console.log("[nostr-passkey] registration clientExtensionResults", regExt);
const prfEnabled = regExt && regExt.prf && regExt.prf.enabled;
if (prfEnabled === true) {
log("prf extension · enabled on this credential ✓");
} else if (prfEnabled === false) {
log("prf extension · NOT enabled (authenticator refused)", "err");
} else {
log("prf extension · status unknown (no prf field in clientExtensionResults)", "err");
}
await api("/register/finish", {
username,
session_id,
credential: encodeRegistrationCredential(cred),
});
log(`ok · passkey registered for ${username}`);
} catch (e) {
if (!logEl.querySelector(".err")) log(describeError(e), "err");
} finally {
btn.disabled = false;
}
}
async function doDerive() {
const btn = document.getElementById("btn-derive");
btn.disabled = true;
clearLog();
document.getElementById("result-section").style.display = "none";
try {
const username = document.getElementById("auth-user").value.trim();
const salt = document.getElementById("auth-salt").value;
if (!username) throw new Error("username required");
log(`auth/start user=${username}${salt ? ` salt=${salt}` : ""}`);
const { session_id, options } = await api("/auth/start", { username });
log(`options received · rpId=${options.rpId} allowCreds=${(options.allowCredentials||[]).length}`);
console.log("[nostr-passkey] raw options", options);
const publicKey = decodeRequestOptions(options);
console.log("[nostr-passkey] decoded publicKey", publicKey);
log(`invoking navigator.credentials.get({publicKey})`);
let cred;
try {
cred = await navigator.credentials.get({ publicKey });
} catch (e) {
log(`get() threw · ${describeError(e)}`, "err");
console.error("[nostr-passkey] get() error object", e);
throw e;
}
const ext = cred.getClientExtensionResults();
console.log("[nostr-passkey] clientExtensionResults", ext);
if (!ext.prf || !ext.prf.results || !ext.prf.results.first) {
throw new Error("authenticator did not return prf output (needs prf + uv)");
}
log(`prf output · ${ext.prf.results.first.byteLength} bytes`);
log("deriving nostr key via hkdf-sha256");
const identity = await api("/auth/finish", {
username,
session_id,
credential: encodeAssertionCredential(cred),
salt: salt || null,
});
document.getElementById("out-npub").textContent = identity.npub;
document.getElementById("out-nsec").textContent = identity.nsec;
document.getElementById("out-pubhex").textContent = identity.pubkey_hex;
document.getElementById("out-privhex").textContent = identity.privkey_hex;
document.getElementById("result-section").style.display = "block";
log("ok · identity derived");
} catch (e) {
if (!logEl.querySelector(".err")) log(describeError(e), "err");
} finally {
btn.disabled = false;
}
}
document.getElementById("btn-register").addEventListener("click", doRegister);
document.getElementById("btn-derive").addEventListener("click", doDerive);
</script>
</body>
</html>