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>
113 lines
4.1 KiB
Python
113 lines
4.1 KiB
Python
"""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(),
|
|
)
|