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

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)