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:
112
nostr_passkey/derivation.py
Normal file
112
nostr_passkey/derivation.py
Normal 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(),
|
||||
)
|
||||
Reference in New Issue
Block a user