From 5f35195e72816e0b5d70a58fc84bcede55b9fda2 Mon Sep 17 00:00:00 2001 From: Michael Schapira - krilin Date: Fri, 10 Apr 2026 23:12:27 -0400 Subject: [PATCH] =?UTF-8?q?Initial=20prototype:=20passkey=20PRF=20?= =?UTF-8?q?=E2=86=92=20HKDF=20=E2=86=92=20Nostr=20key?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 13 + README.md | 219 ++++++++++++++ nostr_passkey/__init__.py | 5 + nostr_passkey/app.py | 204 +++++++++++++ nostr_passkey/derivation.py | 112 +++++++ nostr_passkey/webauthn_flow.py | 204 +++++++++++++ requirements.txt | 7 + scripts/demo.py | 54 ++++ tests/test_derivation.py | 50 +++ web/index.html | 536 +++++++++++++++++++++++++++++++++ 10 files changed, 1404 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 nostr_passkey/__init__.py create mode 100644 nostr_passkey/app.py create mode 100644 nostr_passkey/derivation.py create mode 100644 nostr_passkey/webauthn_flow.py create mode 100644 requirements.txt create mode 100644 scripts/demo.py create mode 100644 tests/test_derivation.py create mode 100644 web/index.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e8ff068 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +.venv/ +__pycache__/ +*.pyc +.pytest_cache/ + +# self-signed dev TLS material — never commit +certs/ + +# editor / tooling +.DS_Store +.claude/ +.idea/ +.vscode/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..1314b35 --- /dev/null +++ b/README.md @@ -0,0 +1,219 @@ +# nostr-passkey + +A prototype that derives a stable Nostr keypair from a WebAuthn passkey +using the PRF extension and HKDF. The server stores nothing except, +optionally, a salt string. + +``` +passkey ──▶ prf ──▶ hkdf-sha256 ──▶ nostr secp256k1 privkey + │ + └── optional salt (enables multiple identities per passkey) +``` + +Status: working end-to-end against Firefox 149 + macOS Touch ID. Not +production code — see [Trust model](#trust-model) and [Limitations](#limitations). + +## Why + +A passkey is a hardware-rooted secret the user already has, already +trusts, and cannot lose to a phishing site. If the authenticator supports +the WebAuthn [PRF extension](https://www.w3.org/TR/webauthn-3/#prf-extension), +it can produce 32 bytes of deterministic entropy bound to that specific +credential. Those bytes make an excellent root secret: the user doesn't +manage a seed phrase, and the same passkey reproduces the same Nostr +identity on any device that can touch it. + +This prototype proves the full pipeline — passkey assertion, PRF eval, +HKDF stretch, secp256k1 scalar, bech32 encoding — runs cleanly in +Python. + +## Cryptographic flow + +1. **WebAuthn PRF assertion.** The browser calls + `navigator.credentials.get()` with `extensions.prf.eval.first` set to + a fixed RP-chosen input. The authenticator computes HMAC-SHA256 over + `"WebAuthn PRF" || 0x00 || input` using its internal credential + secret, returning 32 bytes under + `clientExtensionResults.prf.results.first`. Same credential + same + input ⇒ same output, forever. + +2. **HKDF-SHA256 derivation.** Those 32 bytes become the IKM. The + application feeds them through HKDF with: + - `salt` = the user's optional salt string (UTF-8), or none + - `info` = `b"nostr-passkey/v1/nostr-secp256k1-privkey"` (fixed for + domain separation; changing it rotates every derived key) + - `length` = 32 bytes + +3. **secp256k1 scalar → Nostr key.** The output is validated as a scalar + in `[1, n-1]` and wrapped in a `pynostr.PrivateKey`, which exposes + `nsec` / `npub` bech32 encodings. + +Nothing derived here is persisted. The only value worth storing across +sessions is the user's salt string, so the same passkey can anchor +multiple independent identities ("work", "personal", etc.). + +### 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 → KDF → symmetric key pattern: + https://www.corbado.com/blog/passkeys-prf-webauthn + +## Layout + +``` +nostr-passkey/ +├── nostr_passkey/ +│ ├── __init__.py +│ ├── derivation.py # PRF → HKDF-SHA256 → Nostr key (pure, tested) +│ ├── webauthn_flow.py # py_webauthn + PRF extension glue +│ └── app.py # FastAPI endpoints + static mount +├── web/ +│ └── index.html # single-page WebAuthn client +├── scripts/ +│ └── demo.py # end-to-end simulation with mock PRF output +├── tests/ +│ └── test_derivation.py # determinism, salt separation, validation +├── requirements.txt +└── README.md +``` + +## Running it + +### 1. Install dependencies + +```bash +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +``` + +### 2. Generate a self-signed TLS cert + +WebAuthn requires a secure context. Self-signed is fine on loopback. + +```bash +mkdir -p certs/dev +openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 \ + -sha256 -days 365 -nodes \ + -keyout certs/dev/key.pem -out certs/dev/cert.pem \ + -subj "/CN=localhost" \ + -addext "subjectAltName=DNS:localhost,DNS:*.localhost,IP:127.0.0.1,IP:::1" \ + -addext "keyUsage=digitalSignature,keyEncipherment" \ + -addext "extendedKeyUsage=serverAuth" +chmod 600 certs/dev/key.pem +``` + +### 3. Trust the cert in your browser + +- **Chrome / Safari (macOS):** add to the login keychain: + ```bash + security add-trusted-cert -r trustRoot -p ssl \ + -k ~/Library/Keychains/login.keychain-db certs/dev/cert.pem + ``` + Restart the browser so it picks up the new trust entry. +- **Firefox:** Firefox uses its own NSS store, *not* the keychain. + `about:preferences#privacy` → *Certificates* → *View Certificates* → + *Authorities* → *Import*, select `certs/dev/cert.pem`, check + "Trust this CA to identify websites". Restart Firefox. + +### 4. Run the server + +```bash +uvicorn nostr_passkey.app:app \ + --host 127.0.0.1 --port 8000 \ + --ssl-keyfile certs/dev/key.pem \ + --ssl-certfile certs/dev/cert.pem +``` + +### 5. Open the UI + +**Navigate to `https://localhost:8000/`** — not `127.0.0.1`. WebAuthn +forbids IP addresses as RP IDs, so the origin hostname must be +`localhost`. + +Click `create passkey`, pick the platform authenticator (Touch ID) at +the OS prompt, then `authenticate & derive`. You should see `npub` / +`nsec` / hex keys populate in the result card. Type a salt and +re-authenticate to see the same passkey anchor a different identity. + +## Running tests + +```bash +pytest tests/ -v +python -m scripts.demo # end-to-end simulation without real WebAuthn +``` + +The derivation module is pure and has full unit coverage: determinism, +salt separation, PRF separation, input validation, output shape. + +## API surface + +All endpoints are `POST` with JSON bodies; the single static route `/` +serves the web UI. + +| endpoint | purpose | +|----------------------|--------------------------------------------------------------------| +| `/register/start` | issue WebAuthn creation options (PRF extension advertised) | +| `/register/finish` | verify attestation, remember credential in memory | +| `/auth/start` | issue assertion options requesting PRF eval | +| `/auth/finish` | verify assertion, extract PRF, run HKDF, return derived identity | + +Interactive docs at `/docs` while the server is running. + +## Trust model + +**This prototype derives the Nostr key server-side from a PRF output the +client hands over the wire.** That is fine for a proof-of-concept; it +is wrong for real use. + +- `clientExtensionResults.prf.results.first` is **not covered by the + assertion signature**. A malicious or buggy client can send any bytes + it likes in that field, and the server cannot cryptographically + distinguish the authenticator's real PRF output from a fabrication. + The server trusts the client here because the client is deriving its + *own* identity. +- In any production system the PRF output should never leave the + browser. The HKDF step and the secp256k1 wrap should run entirely + client-side (WebCrypto HKDF + a small secp256k1 library), and the + server — if there is one — should only ever see the resulting `npub`, + used for account lookup, never the private key material. + +Porting `derivation.py` to a tiny vanilla-JS module is the obvious +next step once the backend proof is settled. + +## Limitations + +- **In-memory state only.** `_credentials`, `_pending`, `_salts` live + in Python dicts. Restarting the server wipes everything except the + TLS cert. +- **Single-origin static config.** `RP_ID`, `ORIGIN`, and the PRF eval + input are constants in `webauthn_flow.py` and `app.py`. A real + deployment would read them from env / config. +- **PRF requires a recent authenticator.** Confirmed working: Touch ID + on macOS in Firefox 149. Confirmed *not* working: at least one + cross-platform authenticator in this repo's test runs (likely a + YubiKey with firmware older than 5.7). YubiKey PRF support requires + firmware ≥ 5.7; Windows Hello and iCloud Keychain also work on + recent browsers. +- **Verbose logging is on by default.** Both the backend (`logging.DEBUG`) + and the frontend (raw options, credential objects, PRF byte counts) + emit diagnostic output useful for bring-up but noisy for anything + else. Gate it behind a debug flag before sharing. +- **`authorized: false` on the threat model for the derived key.** + See [Trust model](#trust-model). Don't rely on server-returned nsec + values for anything real. + +## Out of scope (deliberately) + +- BIP-39 mnemonic derivation, BIP-32 hierarchies, and multi-chain key + trees. The Breez spec layers those on top of a PRF root; this repo + cuts straight from PRF to a single Nostr key via HKDF, because the + goal was proving the pipeline, not building a wallet. +- Salt publication to Nostr relays for recovery. The Breez spec suggests + labels as Nostr events; that would be a natural follow-up once the + derivation is client-side. +- Credential persistence, user accounts, rate limiting, CSRF tokens, + any kind of production hardening. diff --git a/nostr_passkey/__init__.py b/nostr_passkey/__init__.py new file mode 100644 index 0000000..b5417dd --- /dev/null +++ b/nostr_passkey/__init__.py @@ -0,0 +1,5 @@ +"""Passkey-anchored Nostr key derivation prototype.""" + +from .derivation import NostrIdentity, derive_nostr_key + +__all__ = ["NostrIdentity", "derive_nostr_key"] diff --git a/nostr_passkey/app.py b/nostr_passkey/app.py new file mode 100644 index 0000000..8a32541 --- /dev/null +++ b/nostr_passkey/app.py @@ -0,0 +1,204 @@ +"""FastAPI surface for the passkey -> Nostr prototype. + +Single-process, in-memory. Restarting the server wipes registrations +and pending challenges, which is the right default for a +proof-of-concept: the only value worth persisting is the user's +optional salt string. + +Endpoints: + POST /register/start -> WebAuthn creation options (with PRF ext) + POST /register/finish -> verify attestation, remember credential + POST /auth/start -> WebAuthn request options (PRF eval) + POST /auth/finish -> verify assertion, derive + return Nostr key + +Run locally: + uvicorn nostr_passkey.app:app --reload --port 8000 +""" + +from __future__ import annotations + +import logging +import secrets +from pathlib import Path +from typing import Any + +from fastapi import FastAPI, HTTPException, Request +from fastapi.staticfiles import StaticFiles +from pydantic import BaseModel + +# Verbose logging so every ceremony step shows up in the uvicorn output. +# ``force=True`` makes this win over uvicorn's default logging config. +logging.basicConfig( + level=logging.DEBUG, + format="%(asctime)s %(levelname)-5s %(name)s · %(message)s", + force=True, +) +log = logging.getLogger("nostr_passkey") + +from .derivation import derive_nostr_key +from .webauthn_flow import ( + PendingChallenge, + StoredCredential, + extract_prf_output, + new_authentication_options, + new_registration_options, + verify_authentication, + verify_registration, +) + +app = FastAPI(title="Nostr Passkey Prototype") + + +@app.middleware("http") +async def _trace(request: Request, call_next): + """Log every request with method, path, and response status.""" + log.info("→ %s %s", request.method, request.url.path) + response = await call_next(request) + log.info("← %s %s %s", request.method, request.url.path, response.status_code) + return response + +# In-memory stores. NOT for production. +_pending: dict[str, PendingChallenge] = {} +_credentials: dict[str, StoredCredential] = {} # username -> credential +_salts: dict[str, str] = {} # username -> last-used salt (optional) + +# Fixed PRF eval input. Any stable bytes work — the authenticator hashes +# this with the "WebAuthn PRF\x00" context before HMAC, and keeping it +# constant across assertions is what makes the PRF output stable for a +# given credential. Changing this constant rotates every derived key. +PRF_EVAL_FIRST = b"nostr-passkey/v1/prf-eval" + + +class RegisterStartRequest(BaseModel): + username: str + + +class RegisterFinishRequest(BaseModel): + username: str + session_id: str + credential: dict[str, Any] + + +class AuthStartRequest(BaseModel): + username: str + + +class AuthFinishRequest(BaseModel): + username: str + session_id: str + credential: dict[str, Any] + salt: str | None = None + + +class NostrIdentityResponse(BaseModel): + npub: str + nsec: str + privkey_hex: str + pubkey_hex: str + + +@app.post("/register/start") +def register_start(req: RegisterStartRequest) -> dict[str, Any]: + log.debug("register/start username=%r", req.username) + options, pending = new_registration_options( + username=req.username, + prf_eval_first=PRF_EVAL_FIRST, + ) + session_id = secrets.token_urlsafe(16) + _pending[session_id] = pending + log.debug( + "register/start issued session=%s rp.id=%s challenge_len=%s ext=%s", + session_id, + options.get("rp", {}).get("id"), + len(options.get("challenge", "")), + list(options.get("extensions", {}).keys()), + ) + return {"session_id": session_id, "options": options} + + +@app.post("/register/finish") +def register_finish(req: RegisterFinishRequest) -> dict[str, str]: + log.debug("register/finish username=%r session=%s", req.username, req.session_id) + pending = _pending.pop(req.session_id, None) + if pending is None: + log.warning("register/finish unknown session %s", req.session_id) + raise HTTPException(status_code=400, detail="unknown session") + try: + stored = verify_registration(req.credential, pending) + except Exception as exc: + log.exception("register/finish verification failed: %s", exc) + raise HTTPException(status_code=400, detail=f"verify failed: {exc}") from exc + _credentials[req.username] = stored + log.debug("register/finish ok cred_id=%s", stored.credential_id.hex()[:16]) + return {"status": "registered", "username": req.username} + + +@app.post("/auth/start") +def auth_start(req: AuthStartRequest) -> dict[str, Any]: + log.debug("auth/start username=%r", req.username) + stored = _credentials.get(req.username) + if stored is None: + log.warning("auth/start unknown user %r", req.username) + raise HTTPException(status_code=404, detail="unknown user") + options, pending = new_authentication_options(stored, PRF_EVAL_FIRST) + session_id = secrets.token_urlsafe(16) + _pending[session_id] = pending + log.debug( + "auth/start issued session=%s rpId=%s allowCreds=%s", + session_id, + options.get("rpId"), + len(options.get("allowCredentials", []) or []), + ) + return {"session_id": session_id, "options": options} + + +@app.post("/auth/finish", response_model=NostrIdentityResponse) +def auth_finish(req: AuthFinishRequest) -> NostrIdentityResponse: + log.debug("auth/finish username=%r session=%s salt=%r", req.username, req.session_id, req.salt) + pending = _pending.pop(req.session_id, None) + if pending is None: + log.warning("auth/finish unknown session %s", req.session_id) + raise HTTPException(status_code=400, detail="unknown session") + stored = _credentials.get(req.username) + if stored is None: + log.warning("auth/finish unknown user %r", req.username) + raise HTTPException(status_code=404, detail="unknown user") + + try: + new_count = verify_authentication(req.credential, pending, stored) + except Exception as exc: + log.exception("auth/finish assertion verification failed: %s", exc) + raise HTTPException(status_code=400, detail=f"verify failed: {exc}") from exc + stored.sign_count = new_count + log.debug("auth/finish signature ok new_sign_count=%s", new_count) + + try: + prf_output = extract_prf_output(req.credential) + except KeyError as exc: + log.warning("auth/finish missing PRF output: %s", exc) + raise HTTPException(status_code=400, detail=str(exc)) from exc + log.debug("auth/finish prf_output=%s bytes", len(prf_output)) + + # Prefer an explicitly-supplied salt; fall back to whatever the user + # last used. Remember the choice so future calls without a salt + # stay consistent. This is the *only* state we persist beyond the + # current ceremony. + salt = req.salt if req.salt is not None else _salts.get(req.username) + if salt is not None: + _salts[req.username] = salt + + identity = derive_nostr_key(prf_output, salt=salt) + log.info("auth/finish derived npub=%s", identity.npub) + return NostrIdentityResponse( + npub=identity.npub, + nsec=identity.nsec, + privkey_hex=identity.privkey_hex, + pubkey_hex=identity.pubkey_hex, + ) + + +# Static frontend. Mounted last so the POST API routes defined above take +# precedence — StaticFiles only serves GET, so there is no collision. +_WEB_DIR = Path(__file__).resolve().parent.parent / "web" +if _WEB_DIR.is_dir(): + app.mount("/", StaticFiles(directory=_WEB_DIR, html=True), name="web") diff --git a/nostr_passkey/derivation.py b/nostr_passkey/derivation.py new file mode 100644 index 0000000..db4acc6 --- /dev/null +++ b/nostr_passkey/derivation.py @@ -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(), + ) diff --git a/nostr_passkey/webauthn_flow.py b/nostr_passkey/webauthn_flow.py new file mode 100644 index 0000000..681c41a --- /dev/null +++ b/nostr_passkey/webauthn_flow.py @@ -0,0 +1,204 @@ +"""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) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..2e25afb --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +webauthn>=2.0.0 +cryptography>=42.0.0 +pynostr>=0.6.2 +fastapi>=0.110.0 +uvicorn[standard]>=0.29.0 +pydantic>=2.6.0 +pytest>=8.0.0 diff --git a/scripts/demo.py b/scripts/demo.py new file mode 100644 index 0000000..d0163bd --- /dev/null +++ b/scripts/demo.py @@ -0,0 +1,54 @@ +"""End-to-end simulation of the passkey -> Nostr key derivation flow. + +Without a real WebAuthn authenticator and browser handy, this script +stubs the 32-byte PRF output with a deterministic hash so you can watch +the rest of the pipeline work: HKDF stretches the entropy, the result +becomes a Nostr private key, and nsec/npub fall out the other side. + +Run it with: + python -m scripts.demo +""" + +from __future__ import annotations + +import hashlib + +from nostr_passkey.derivation import derive_nostr_key + + +def fake_prf_output(seed: str) -> bytes: + """Stand-in for ``clientExtensionResults.prf.results.first``.""" + return hashlib.sha256(seed.encode("utf-8")).digest() + + +def main() -> None: + prf = fake_prf_output("pretend-passkey-credential") + print(f"Simulated PRF output (hex): {prf.hex()}") + + ident_none = derive_nostr_key(prf) + print("\n-- No salt --") + print(f" privkey_hex: {ident_none.privkey_hex}") + print(f" nsec: {ident_none.nsec}") + print(f" npub: {ident_none.npub}") + + ident_work = derive_nostr_key(prf, salt="work") + print("\n-- salt='work' --") + print(f" nsec: {ident_work.nsec}") + print(f" npub: {ident_work.npub}") + + ident_personal = derive_nostr_key(prf, salt="personal") + print("\n-- salt='personal' --") + print(f" nsec: {ident_personal.nsec}") + print(f" npub: {ident_personal.npub}") + + # Invariants the whole design rests on: + ident_work_again = derive_nostr_key(prf, salt="work") + assert ident_work.nsec == ident_work_again.nsec, "derivation must be deterministic" + assert ident_work.nsec != ident_none.nsec, "salt must change the output" + assert ident_work.nsec != ident_personal.nsec, "different salts must diverge" + + print("\nDeterminism and salt separation verified.") + + +if __name__ == "__main__": + main() diff --git a/tests/test_derivation.py b/tests/test_derivation.py new file mode 100644 index 0000000..48e037c --- /dev/null +++ b/tests/test_derivation.py @@ -0,0 +1,50 @@ +"""Tests for the PRF -> HKDF -> Nostr key derivation.""" + +from __future__ import annotations + +import pytest + +from nostr_passkey.derivation import PRF_OUTPUT_BYTES, derive_nostr_key + +PRF_A = bytes.fromhex("11" * 32) +PRF_B = bytes.fromhex("22" * 32) + + +def test_deterministic_for_same_inputs() -> None: + a = derive_nostr_key(PRF_A, salt="alpha") + b = derive_nostr_key(PRF_A, salt="alpha") + assert a == b + + +def test_different_salts_produce_different_keys() -> None: + a = derive_nostr_key(PRF_A, salt="alpha") + b = derive_nostr_key(PRF_A, salt="beta") + assert a.privkey_hex != b.privkey_hex + assert a.npub != b.npub + + +def test_different_prf_outputs_produce_different_keys() -> None: + a = derive_nostr_key(PRF_A, salt="alpha") + b = derive_nostr_key(PRF_B, salt="alpha") + assert a.privkey_hex != b.privkey_hex + + +def test_no_salt_differs_from_nonempty_salt() -> None: + no_salt = derive_nostr_key(PRF_A) + salted = derive_nostr_key(PRF_A, salt="x") + assert no_salt.privkey_hex != salted.privkey_hex + + +def test_rejects_wrong_prf_length() -> None: + with pytest.raises(ValueError): + derive_nostr_key(b"too-short") + with pytest.raises(ValueError): + derive_nostr_key(bytes(PRF_OUTPUT_BYTES + 1)) + + +def test_output_shapes() -> None: + ident = derive_nostr_key(PRF_A) + assert len(ident.privkey_hex) == 64 # 32 bytes hex + assert len(ident.pubkey_hex) == 64 # x-only, 32 bytes hex + assert ident.nsec.startswith("nsec1") + assert ident.npub.startswith("npub1") diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..4b126db --- /dev/null +++ b/web/index.html @@ -0,0 +1,536 @@ + + + + + +nostr-passkey + + + +
+
+
+

nostr-passkey

+ v0.1 · prototype +
+
passkeyprfhkdfnostr key
+
+ +
+

00 · environment

+
+
+ +
+

01 · register

+ + + +
+ +
+

02 · derive

+ + + + + +
+ + + +
+ +
+ stateless · prf-anchored · hkdf-sha256 + openapi +
+
+ + + +