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