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,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)