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

112
nostr_passkey/derivation.py Normal file
View 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(),
)