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:
5
nostr_passkey/__init__.py
Normal file
5
nostr_passkey/__init__.py
Normal 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
204
nostr_passkey/app.py
Normal 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
112
nostr_passkey/derivation.py
Normal 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(),
|
||||
)
|
||||
204
nostr_passkey/webauthn_flow.py
Normal file
204
nostr_passkey/webauthn_flow.py
Normal 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)
|
||||
Reference in New Issue
Block a user