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