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:
13
.gitignore
vendored
Normal file
13
.gitignore
vendored
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.pytest_cache/
|
||||||
|
|
||||||
|
# self-signed dev TLS material — never commit
|
||||||
|
certs/
|
||||||
|
|
||||||
|
# editor / tooling
|
||||||
|
.DS_Store
|
||||||
|
.claude/
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
219
README.md
Normal file
219
README.md
Normal file
@@ -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.
|
||||||
5
nostr_passkey/__init__.py
Normal file
5
nostr_passkey/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""Passkey-anchored Nostr key derivation prototype."""
|
||||||
|
|
||||||
|
from .derivation import NostrIdentity, derive_nostr_key
|
||||||
|
|
||||||
|
__all__ = ["NostrIdentity", "derive_nostr_key"]
|
||||||
204
nostr_passkey/app.py
Normal file
204
nostr_passkey/app.py
Normal file
@@ -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")
|
||||||
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(),
|
||||||
|
)
|
||||||
204
nostr_passkey/webauthn_flow.py
Normal file
204
nostr_passkey/webauthn_flow.py
Normal file
@@ -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)
|
||||||
7
requirements.txt
Normal file
7
requirements.txt
Normal file
@@ -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
|
||||||
54
scripts/demo.py
Normal file
54
scripts/demo.py
Normal file
@@ -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()
|
||||||
50
tests/test_derivation.py
Normal file
50
tests/test_derivation.py
Normal file
@@ -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")
|
||||||
536
web/index.html
Normal file
536
web/index.html
Normal file
@@ -0,0 +1,536 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>nostr-passkey</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--fg: #000;
|
||||||
|
--bg: #fff;
|
||||||
|
--muted: #6b6b6b;
|
||||||
|
--rule: #000;
|
||||||
|
--soft: #f4f4f4;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body {
|
||||||
|
margin: 0; padding: 0;
|
||||||
|
background: var(--bg); color: var(--fg);
|
||||||
|
font-family: ui-monospace, "JetBrains Mono", "IBM Plex Mono", Menlo, Consolas, monospace;
|
||||||
|
font-size: 14px; line-height: 1.55;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
::selection { background: #000; color: #fff; }
|
||||||
|
main {
|
||||||
|
max-width: 620px;
|
||||||
|
margin: 72px auto 96px;
|
||||||
|
padding: 0 28px;
|
||||||
|
}
|
||||||
|
header {
|
||||||
|
border-bottom: 1px solid var(--rule);
|
||||||
|
padding-bottom: 18px;
|
||||||
|
margin-bottom: 36px;
|
||||||
|
}
|
||||||
|
header .title {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.28em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.ver {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--muted);
|
||||||
|
letter-spacing: 0.2em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.sub {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-top: 8px;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
.sub b { color: var(--fg); font-weight: 600; }
|
||||||
|
|
||||||
|
section {
|
||||||
|
border: 1px solid var(--rule);
|
||||||
|
padding: 22px 26px;
|
||||||
|
margin-bottom: 22px;
|
||||||
|
}
|
||||||
|
section h2 {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.3em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin: 0 0 18px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
section h2::before {
|
||||||
|
content: "";
|
||||||
|
display: inline-block;
|
||||||
|
width: 10px; height: 1px;
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
font-size: 9px;
|
||||||
|
letter-spacing: 0.22em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
input[type=text] {
|
||||||
|
width: 100%;
|
||||||
|
padding: 11px 13px;
|
||||||
|
border: 1px solid var(--rule);
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--fg);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
border-radius: 0;
|
||||||
|
outline: none;
|
||||||
|
transition: background 0.08s, color 0.08s;
|
||||||
|
}
|
||||||
|
input[type=text]:focus {
|
||||||
|
background: #000; color: #fff;
|
||||||
|
}
|
||||||
|
input[type=text]::placeholder { color: var(--muted); }
|
||||||
|
input[type=text]:focus::placeholder { color: #888; }
|
||||||
|
|
||||||
|
button {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 13px 16px;
|
||||||
|
border: 1px solid var(--rule);
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--fg);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.28em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 0;
|
||||||
|
transition: background 0.08s, color 0.08s;
|
||||||
|
}
|
||||||
|
button:hover:not(:disabled) { background: #000; color: #fff; }
|
||||||
|
button:active:not(:disabled) { background: #222; }
|
||||||
|
button:disabled {
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: not-allowed;
|
||||||
|
border-color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row { margin-bottom: 14px; }
|
||||||
|
.row:last-child { margin-bottom: 0; }
|
||||||
|
pre {
|
||||||
|
margin: 0;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid var(--rule);
|
||||||
|
background: var(--soft);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
#log {
|
||||||
|
margin-top: 36px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
border-top: 1px solid var(--rule);
|
||||||
|
padding-top: 18px;
|
||||||
|
min-height: 48px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
#log .line::before { content: "> "; color: var(--fg); }
|
||||||
|
#log .line.err { color: #000; font-weight: 600; }
|
||||||
|
#log .line.err::before { content: "! "; }
|
||||||
|
|
||||||
|
footer {
|
||||||
|
margin-top: 48px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid var(--rule);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--muted);
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
footer a { color: var(--muted); text-decoration: none; border-bottom: 1px dotted var(--muted); }
|
||||||
|
footer a:hover { color: #000; border-bottom-color: #000; }
|
||||||
|
|
||||||
|
.diag {
|
||||||
|
border: 1px solid var(--rule);
|
||||||
|
padding: 14px 18px;
|
||||||
|
margin-bottom: 22px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.diag h2 {
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.3em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin: 0 0 10px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.diag .kv { display: grid; grid-template-columns: 140px 1fr; gap: 4px 12px; }
|
||||||
|
.diag .k { color: var(--muted); text-transform: uppercase; letter-spacing: 0.1em; font-size: 10px; }
|
||||||
|
.diag .v { word-break: break-all; }
|
||||||
|
.diag .v.ok { color: #000; }
|
||||||
|
.diag .v.bad { color: #000; font-weight: 700; }
|
||||||
|
.diag .v.bad::before { content: "✗ "; }
|
||||||
|
.diag .v.ok::before { content: "✓ "; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<header>
|
||||||
|
<div class="title">
|
||||||
|
<h1>nostr-passkey</h1>
|
||||||
|
<span class="ver">v0.1 · prototype</span>
|
||||||
|
</div>
|
||||||
|
<div class="sub"><b>passkey</b> ⟶ <b>prf</b> ⟶ <b>hkdf</b> ⟶ <b>nostr key</b></div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="diag" id="diag">
|
||||||
|
<h2>00 · environment</h2>
|
||||||
|
<div class="kv" id="diag-kv"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>01 · register</h2>
|
||||||
|
<label for="reg-user">username</label>
|
||||||
|
<input id="reg-user" type="text" value="alice" autocomplete="off" spellcheck="false">
|
||||||
|
<button id="btn-register">create passkey</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>02 · derive</h2>
|
||||||
|
<label for="auth-user">username</label>
|
||||||
|
<input id="auth-user" type="text" value="alice" autocomplete="off" spellcheck="false">
|
||||||
|
<label for="auth-salt">salt · optional</label>
|
||||||
|
<input id="auth-salt" type="text" placeholder="e.g. work" autocomplete="off" spellcheck="false">
|
||||||
|
<button id="btn-derive">authenticate & derive</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="result-section" style="display:none;">
|
||||||
|
<h2>03 · derived identity</h2>
|
||||||
|
<div class="row"><label>npub</label><pre id="out-npub"></pre></div>
|
||||||
|
<div class="row"><label>nsec</label><pre id="out-nsec"></pre></div>
|
||||||
|
<div class="row"><label>pubkey hex</label><pre id="out-pubhex"></pre></div>
|
||||||
|
<div class="row"><label>privkey hex</label><pre id="out-privhex"></pre></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div id="log"></div>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<span>stateless · prf-anchored · hkdf-sha256</span>
|
||||||
|
<a href="/docs" target="_blank" rel="noopener">openapi</a>
|
||||||
|
</footer>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// ---------- base64url helpers ----------
|
||||||
|
function b64uToBuf(s) {
|
||||||
|
const pad = "=".repeat((4 - s.length % 4) % 4);
|
||||||
|
const b64 = (s + pad).replace(/-/g, "+").replace(/_/g, "/");
|
||||||
|
const raw = atob(b64);
|
||||||
|
const buf = new Uint8Array(raw.length);
|
||||||
|
for (let i = 0; i < raw.length; i++) buf[i] = raw.charCodeAt(i);
|
||||||
|
return buf.buffer;
|
||||||
|
}
|
||||||
|
function bufToB64u(buf) {
|
||||||
|
const bytes = new Uint8Array(buf);
|
||||||
|
let s = "";
|
||||||
|
for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);
|
||||||
|
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- log (mirrors to browser console for full stacks) ----------
|
||||||
|
const logEl = document.getElementById("log");
|
||||||
|
function log(msg, kind) {
|
||||||
|
const line = document.createElement("div");
|
||||||
|
line.className = "line" + (kind === "err" ? " err" : "");
|
||||||
|
line.textContent = msg;
|
||||||
|
logEl.appendChild(line);
|
||||||
|
if (kind === "err") console.error("[nostr-passkey]", msg);
|
||||||
|
else console.log("[nostr-passkey]", msg);
|
||||||
|
}
|
||||||
|
function clearLog() { logEl.innerHTML = ""; }
|
||||||
|
|
||||||
|
// Expand a DOMException / Error into every field we can get at. Some
|
||||||
|
// of these properties only exist on specific browsers — read them
|
||||||
|
// defensively so we don't throw while reporting an error.
|
||||||
|
function describeError(e) {
|
||||||
|
if (!e) return "(null error)";
|
||||||
|
const parts = [];
|
||||||
|
const name = e.name || e.constructor?.name || "Error";
|
||||||
|
parts.push(name);
|
||||||
|
if (e.code !== undefined) parts.push(`code=${e.code}`);
|
||||||
|
if (e.message) parts.push(e.message);
|
||||||
|
const seen = new Set();
|
||||||
|
for (const k of Object.getOwnPropertyNames(e)) {
|
||||||
|
if (seen.has(k) || ["name", "message", "stack", "code"].includes(k)) continue;
|
||||||
|
seen.add(k);
|
||||||
|
try {
|
||||||
|
const v = e[k];
|
||||||
|
if (v !== undefined && typeof v !== "function") parts.push(`${k}=${v}`);
|
||||||
|
} catch (_) { /* ignore */ }
|
||||||
|
}
|
||||||
|
return parts.join(" · ");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- environment diagnostics ----------
|
||||||
|
async function paintDiagnostics() {
|
||||||
|
const kv = document.getElementById("diag-kv");
|
||||||
|
const rows = [];
|
||||||
|
function row(key, val, ok) {
|
||||||
|
rows.push({ key, val: String(val), ok });
|
||||||
|
}
|
||||||
|
|
||||||
|
row("origin", window.location.origin, true);
|
||||||
|
row("isSecureContext", window.isSecureContext, !!window.isSecureContext);
|
||||||
|
row("crypto.subtle", !!(window.crypto && window.crypto.subtle), !!(window.crypto && window.crypto.subtle));
|
||||||
|
row("PublicKeyCredential", !!window.PublicKeyCredential, !!window.PublicKeyCredential);
|
||||||
|
|
||||||
|
let uvpa = "n/a";
|
||||||
|
if (window.PublicKeyCredential && PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable) {
|
||||||
|
try {
|
||||||
|
uvpa = await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
|
||||||
|
} catch (e) { uvpa = "error: " + describeError(e); }
|
||||||
|
}
|
||||||
|
row("platformAuth", uvpa, uvpa === true);
|
||||||
|
|
||||||
|
let condMed = "n/a";
|
||||||
|
if (window.PublicKeyCredential && PublicKeyCredential.isConditionalMediationAvailable) {
|
||||||
|
try { condMed = await PublicKeyCredential.isConditionalMediationAvailable(); }
|
||||||
|
catch (e) { condMed = "error: " + describeError(e); }
|
||||||
|
}
|
||||||
|
row("condMediation", condMed, condMed === true);
|
||||||
|
|
||||||
|
row("userAgent", navigator.userAgent, true);
|
||||||
|
|
||||||
|
kv.innerHTML = "";
|
||||||
|
for (const r of rows) {
|
||||||
|
const k = document.createElement("div"); k.className = "k"; k.textContent = r.key;
|
||||||
|
const v = document.createElement("div"); v.className = "v " + (r.ok ? "ok" : "bad"); v.textContent = r.val;
|
||||||
|
kv.appendChild(k); kv.appendChild(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also dump everything to the console once, in structured form.
|
||||||
|
console.log("[nostr-passkey] env", {
|
||||||
|
origin: window.location.origin,
|
||||||
|
isSecureContext: window.isSecureContext,
|
||||||
|
hasPKC: !!window.PublicKeyCredential,
|
||||||
|
platformAuth: uvpa,
|
||||||
|
condMediation: condMed,
|
||||||
|
userAgent: navigator.userAgent,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
paintDiagnostics();
|
||||||
|
|
||||||
|
// ---------- api ----------
|
||||||
|
async function api(path, body) {
|
||||||
|
const res = await fetch(path, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
throw new Error(`${path} ${res.status}: ${text}`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- options decoding ----------
|
||||||
|
// Server sends all BufferSource fields as base64url strings; the
|
||||||
|
// WebAuthn API needs them as ArrayBuffers.
|
||||||
|
function decodeCreationOptions(opts) {
|
||||||
|
const o = structuredClone(opts);
|
||||||
|
o.challenge = b64uToBuf(o.challenge);
|
||||||
|
o.user.id = b64uToBuf(o.user.id);
|
||||||
|
if (Array.isArray(o.excludeCredentials)) {
|
||||||
|
o.excludeCredentials = o.excludeCredentials.map(c => ({ ...c, id: b64uToBuf(c.id) }));
|
||||||
|
}
|
||||||
|
const prf = o.extensions && o.extensions.prf && o.extensions.prf.eval;
|
||||||
|
if (prf) {
|
||||||
|
if (prf.first) prf.first = b64uToBuf(prf.first);
|
||||||
|
if (prf.second) prf.second = b64uToBuf(prf.second);
|
||||||
|
}
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
function decodeRequestOptions(opts) {
|
||||||
|
const o = structuredClone(opts);
|
||||||
|
o.challenge = b64uToBuf(o.challenge);
|
||||||
|
if (Array.isArray(o.allowCredentials)) {
|
||||||
|
o.allowCredentials = o.allowCredentials.map(c => ({ ...c, id: b64uToBuf(c.id) }));
|
||||||
|
}
|
||||||
|
const prf = o.extensions && o.extensions.prf && o.extensions.prf.eval;
|
||||||
|
if (prf) {
|
||||||
|
if (prf.first) prf.first = b64uToBuf(prf.first);
|
||||||
|
if (prf.second) prf.second = b64uToBuf(prf.second);
|
||||||
|
}
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- credential encoding ----------
|
||||||
|
function encodeRegistrationCredential(cred) {
|
||||||
|
return {
|
||||||
|
id: cred.id,
|
||||||
|
rawId: bufToB64u(cred.rawId),
|
||||||
|
type: cred.type,
|
||||||
|
response: {
|
||||||
|
clientDataJSON: bufToB64u(cred.response.clientDataJSON),
|
||||||
|
attestationObject: bufToB64u(cred.response.attestationObject),
|
||||||
|
},
|
||||||
|
clientExtensionResults: cred.getClientExtensionResults(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function encodeAssertionCredential(cred) {
|
||||||
|
const ext = cred.getClientExtensionResults();
|
||||||
|
const extOut = {};
|
||||||
|
if (ext.prf && ext.prf.results) {
|
||||||
|
extOut.prf = { results: {} };
|
||||||
|
if (ext.prf.results.first) extOut.prf.results.first = bufToB64u(ext.prf.results.first);
|
||||||
|
if (ext.prf.results.second) extOut.prf.results.second = bufToB64u(ext.prf.results.second);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: cred.id,
|
||||||
|
rawId: bufToB64u(cred.rawId),
|
||||||
|
type: cred.type,
|
||||||
|
response: {
|
||||||
|
clientDataJSON: bufToB64u(cred.response.clientDataJSON),
|
||||||
|
authenticatorData: bufToB64u(cred.response.authenticatorData),
|
||||||
|
signature: bufToB64u(cred.response.signature),
|
||||||
|
userHandle: cred.response.userHandle ? bufToB64u(cred.response.userHandle) : null,
|
||||||
|
},
|
||||||
|
clientExtensionResults: extOut,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- flows ----------
|
||||||
|
async function doRegister() {
|
||||||
|
const btn = document.getElementById("btn-register");
|
||||||
|
btn.disabled = true;
|
||||||
|
clearLog();
|
||||||
|
try {
|
||||||
|
const username = document.getElementById("reg-user").value.trim();
|
||||||
|
if (!username) throw new Error("username required");
|
||||||
|
|
||||||
|
log(`register/start user=${username}`);
|
||||||
|
const { session_id, options } = await api("/register/start", { username });
|
||||||
|
log(`options received · rp.id=${options.rp?.id} challenge_len=${options.challenge?.length}`);
|
||||||
|
console.log("[nostr-passkey] raw options", options);
|
||||||
|
|
||||||
|
const publicKey = decodeCreationOptions(options);
|
||||||
|
console.log("[nostr-passkey] decoded publicKey", publicKey);
|
||||||
|
log(`invoking navigator.credentials.create({publicKey})`);
|
||||||
|
|
||||||
|
let cred;
|
||||||
|
try {
|
||||||
|
cred = await navigator.credentials.create({ publicKey });
|
||||||
|
} catch (e) {
|
||||||
|
// Surface every field the browser gives us. "The operation is insecure"
|
||||||
|
// in Firefox, "NotAllowedError" in Chrome, etc. Each browser puts the
|
||||||
|
// interesting detail in a different field.
|
||||||
|
log(`create() threw · ${describeError(e)}`, "err");
|
||||||
|
console.error("[nostr-passkey] create() error object", e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
log(`credential created · id=${cred.id?.slice(0, 16)}… type=${cred.type} attachment=${cred.authenticatorAttachment || "?"}`);
|
||||||
|
const regExt = cred.getClientExtensionResults();
|
||||||
|
console.log("[nostr-passkey] registration clientExtensionResults", regExt);
|
||||||
|
const prfEnabled = regExt && regExt.prf && regExt.prf.enabled;
|
||||||
|
if (prfEnabled === true) {
|
||||||
|
log("prf extension · enabled on this credential ✓");
|
||||||
|
} else if (prfEnabled === false) {
|
||||||
|
log("prf extension · NOT enabled (authenticator refused)", "err");
|
||||||
|
} else {
|
||||||
|
log("prf extension · status unknown (no prf field in clientExtensionResults)", "err");
|
||||||
|
}
|
||||||
|
|
||||||
|
await api("/register/finish", {
|
||||||
|
username,
|
||||||
|
session_id,
|
||||||
|
credential: encodeRegistrationCredential(cred),
|
||||||
|
});
|
||||||
|
log(`ok · passkey registered for ${username}`);
|
||||||
|
} catch (e) {
|
||||||
|
if (!logEl.querySelector(".err")) log(describeError(e), "err");
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doDerive() {
|
||||||
|
const btn = document.getElementById("btn-derive");
|
||||||
|
btn.disabled = true;
|
||||||
|
clearLog();
|
||||||
|
document.getElementById("result-section").style.display = "none";
|
||||||
|
try {
|
||||||
|
const username = document.getElementById("auth-user").value.trim();
|
||||||
|
const salt = document.getElementById("auth-salt").value;
|
||||||
|
if (!username) throw new Error("username required");
|
||||||
|
|
||||||
|
log(`auth/start user=${username}${salt ? ` salt=${salt}` : ""}`);
|
||||||
|
const { session_id, options } = await api("/auth/start", { username });
|
||||||
|
log(`options received · rpId=${options.rpId} allowCreds=${(options.allowCredentials||[]).length}`);
|
||||||
|
console.log("[nostr-passkey] raw options", options);
|
||||||
|
|
||||||
|
const publicKey = decodeRequestOptions(options);
|
||||||
|
console.log("[nostr-passkey] decoded publicKey", publicKey);
|
||||||
|
log(`invoking navigator.credentials.get({publicKey})`);
|
||||||
|
|
||||||
|
let cred;
|
||||||
|
try {
|
||||||
|
cred = await navigator.credentials.get({ publicKey });
|
||||||
|
} catch (e) {
|
||||||
|
log(`get() threw · ${describeError(e)}`, "err");
|
||||||
|
console.error("[nostr-passkey] get() error object", e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = cred.getClientExtensionResults();
|
||||||
|
console.log("[nostr-passkey] clientExtensionResults", ext);
|
||||||
|
if (!ext.prf || !ext.prf.results || !ext.prf.results.first) {
|
||||||
|
throw new Error("authenticator did not return prf output (needs prf + uv)");
|
||||||
|
}
|
||||||
|
log(`prf output · ${ext.prf.results.first.byteLength} bytes`);
|
||||||
|
log("deriving nostr key via hkdf-sha256");
|
||||||
|
const identity = await api("/auth/finish", {
|
||||||
|
username,
|
||||||
|
session_id,
|
||||||
|
credential: encodeAssertionCredential(cred),
|
||||||
|
salt: salt || null,
|
||||||
|
});
|
||||||
|
document.getElementById("out-npub").textContent = identity.npub;
|
||||||
|
document.getElementById("out-nsec").textContent = identity.nsec;
|
||||||
|
document.getElementById("out-pubhex").textContent = identity.pubkey_hex;
|
||||||
|
document.getElementById("out-privhex").textContent = identity.privkey_hex;
|
||||||
|
document.getElementById("result-section").style.display = "block";
|
||||||
|
log("ok · identity derived");
|
||||||
|
} catch (e) {
|
||||||
|
if (!logEl.querySelector(".err")) log(describeError(e), "err");
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("btn-register").addEventListener("click", doRegister);
|
||||||
|
document.getElementById("btn-derive").addEventListener("click", doDerive);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user