diff --git a/CHANGELOG.md b/CHANGELOG.md index 18962a2..c7a8acd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **FIDO2 / WebAuthn passkey master identity (advanced).** Create a Nostr + identity whose secret key is derived from a hardware passkey via the WebAuthn + **PRF** extension (HKDF-SHA-256, domain `podkey/nostr-secret/v1`), with no + passphrase — the passkey reproduces the same key at every unlock. A separate + *passkey unlock* mode instead wraps an existing passphrase key with an + AES-256-GCM key derived from the passkey PRF (`podkey/wrap/v1`). Both require a + PRF-capable authenticator (a phone passkey, a modern security key, or a + platform authenticator with hmac-secret). The derived flow shows a one-time + `nsec` backup that must be acknowledged before the identity is persisted. + Framed as an advanced tier for managing agents or working under compliance + rules; the ordinary Generate/Import flows are unchanged. Specs: + `site/passkey-identity.html`, `site/did-nostr.html`. +- **"Start over" on the main screen.** A footer action that wipes the vault, + public key and passkey config and returns to the setup screen, so an existing + user can reset to the initial state (and reach passkey-derived creation) + without locking first. + +### Fixed + +- **Survive an invalidated extension context.** When the extension is reloaded + or updated while a page stays open, the orphaned content script no longer + floods the console with `Extension context invalidated` on every request from + high-frequency callers; it latches the dead context once, restores native + `fetch`/XHR, and answers silently until the tab is reloaded. +- **Passkey ceremony compatibility and errors.** Stop requesting a discoverable + (resident) credential Podkey never uses — it stores the credential id and + unlocks via `allowCredentials` — fixing `makeCredential` failures on some + TPM-backed authenticators. Surface actionable messages for a missing PRF / + hmac-secret extension and for a cancelled or timed-out ceremony, instead of + the raw WebAuthn `NotAllowedError`. + ## [0.0.8] - 2026-07-10 ### Added diff --git a/README.md b/README.md index ab8448c..11d5988 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,26 @@ const did = `did:nostr:${pubkey}` That identifier authenticates you to Solid pods and travels across any NIP-07-aware app. +## Passkey identity (advanced) + +Podkey can bind your Nostr identity to a **FIDO2 / WebAuthn passkey** instead of +a passphrase. Two modes, both requiring an authenticator that supports the +WebAuthn **PRF (hmac-secret)** extension — a phone passkey, a modern security +key, or a platform authenticator: + +- **Derived** — the secret key is computed from the passkey's PRF output via + HKDF-SHA-256 (`podkey/nostr-secret/v1`). No passphrase; the passkey reproduces + the same key at every unlock. A one-time `nsec` backup is shown and must be + acknowledged before the identity is created. +- **Wrapped** — an existing passphrase key is sealed with an AES-256-GCM key + derived from the passkey PRF (`podkey/wrap/v1`), so you can unlock with + biometrics instead of typing the passphrase. + +It is an advanced tier aimed at managing agents or working under compliance +rules; the Generate/Import flows are unchanged. The construction is specified in +[`site/passkey-identity.html`](site/passkey-identity.html), and the DID layer in +[`site/did-nostr.html`](site/did-nostr.html). + ## Where Podkey fits Podkey sits at the join of two mature, independently-built ecosystems and @@ -186,7 +206,7 @@ top. ```bash npm install npm run build # bundle dependencies into the service worker -npm test # node --test, 141 cases (incl. vault crypto) +npm test # node --test, 169 cases (incl. vault & passkey crypto) npm run lint # eslint, no-unused-vars as error ``` @@ -196,8 +216,11 @@ podkey/ ├── src/ │ ├── background.js # service worker: message handling, consent gate │ ├── crypto.js # key generation & Schnorr signing +│ ├── passkey.js # FIDO2/WebAuthn PRF identity derive + wrap +│ ├── keyformat.js # nsec/npub bech32 encode/decode │ ├── nip44.js # NIP-44 v2 encrypt/decrypt │ ├── nip98-interceptor.js # page-context NIP-98 fetch/XHR auth +│ ├── auth-header-utils.js # NIP-98 Authorization header helpers │ ├── vault.js # AES-GCM encrypted-at-rest key vault (scrypt) │ ├── storage.js # session key cache + trusted-origin storage │ ├── injected.js # content-script page bridge @@ -243,6 +266,14 @@ passphrase to unlock for the session. Also check the service worker console (the "service worker" link on `chrome://extensions`) for a blocked consent prompt. +**Passkey identity fails right after the biometric.** The WebAuthn ceremony +reports `NotAllowedError` ("timed out or was not allowed") when a prompt is +cancelled, times out, or the authenticator lacks the **PRF (hmac-secret)** +extension Podkey needs to derive the key. Podkey prompts twice — register, then +derive — so confirm both. Use a phone passkey or a modern security key if your +local authenticator has no PRF. A fingerprint that scans but is rejected +(`verify-no-match`) is an OS enrolment issue, not Podkey. + **Build errors.** Reinstall dependencies (`npm install`) and confirm Node.js 18 or newer. diff --git a/USAGE.md b/USAGE.md index 756cd02..9d0dc3d 100644 --- a/USAGE.md +++ b/USAGE.md @@ -59,6 +59,39 @@ This will: ⚠️ **Warning**: Never share your private key with anyone! +### Create a passkey-derived identity (advanced) + +This binds your Nostr identity to a **FIDO2 passkey** — hardware-backed and +unlocked with biometrics or a security key, with no passphrase. It is aimed at +managing agents or working under compliance rules; most users can skip it. + +**You need a PRF-capable authenticator**: a phone passkey (via the browser's +QR / "use a different device" prompt), a modern security key, or a platform +authenticator that supports the WebAuthn **PRF (hmac-secret)** extension. + +1. Click the **Podkey icon** (🔑), then expand **"Advanced: passkey identity"** +2. Click **"Create identity from a passkey"** and confirm the warning +3. Podkey opens a dedicated window and runs the passkey ceremony. **You are + prompted twice** — once to register the passkey, once to derive the key — so + confirm both prompts (biometric or security-key touch) +4. Save the shown **`nsec` backup** — it is the only way to recover the identity + if the passkey is lost — then tick the box and click **"Create identity"** + +After setup, the Unlock screen offers **"Unlock with passkey"**: one biometric +tap re-derives the same key, no passphrase. + +> **Passkey unlock for a passphrase key.** If you already have a passphrase key, +> the main screen's **Settings → Passkey unlock → Set up** wraps that existing +> key with your passkey instead of deriving a new one. This also needs a +> PRF-capable authenticator. + +### Start over (reset to setup) + +To wipe the current identity and return to the setup screen — for example to +switch to a passkey-derived identity — use **"Start over"** in the main screen +footer. This deletes the encrypted vault, public key and passkey config, so +**export your key first** if you might need it again. + ### Unlocking after a browser restart Your key is encrypted at rest, so when you restart the browser the popup shows @@ -246,6 +279,18 @@ npm run lint - Check for permission prompts that may be blocked - Check the extension console (click "service worker" link in chrome://extensions) +### Passkey identity won't create + +- **"…timed out or was not allowed" right after the biometric.** Your + authenticator likely lacks the WebAuthn **PRF (hmac-secret)** extension, or a + prompt was cancelled. Podkey prompts twice (register, then derive) — confirm + both. Try a phone passkey (QR prompt) or a modern security key. +- **Fingerprint scans but is rejected (`verify-no-match`).** This is your OS + fingerprint stack, not Podkey — re-enrol the finger and confirm it verifies + before retrying. +- The derived and wrapped passkey modes both require PRF; on an authenticator + without it, use the passphrase-based Generate/Import flow instead. + ### Build errors - Make sure all dependencies are installed: `npm install` diff --git a/popup/popup.css b/popup/popup.css index a9bc776..89bab18 100644 --- a/popup/popup.css +++ b/popup/popup.css @@ -634,6 +634,10 @@ input:focus-visible + .slider { text-decoration: underline; } +.footer a.danger { + color: var(--danger); +} + .footer .sep { margin: 0 6px; color: var(--text-faint); diff --git a/popup/popup.html b/popup/popup.html index 8c47b45..b816efe 100644 --- a/popup/popup.html +++ b/popup/popup.html @@ -302,6 +302,8 @@

Podkey

Export key + Start over + GitHub diff --git a/popup/popup.js b/popup/popup.js index 4ea7e30..7f2678d 100644 --- a/popup/popup.js +++ b/popup/popup.js @@ -227,6 +227,10 @@ function setupEventListeners() { document.getElementById('autoSignToggle').addEventListener('change', handleAutoSignToggle); document.getElementById('exportBtn').addEventListener('click', handleExport); document.getElementById('lockBtn').addEventListener('click', handleLock); + // Same wipe-and-return-to-setup action as the unlock screen's link, surfaced + // on the main screen so an existing user can reset to the init state (and + // reach the passkey-derived creation flow) without locking first. + document.getElementById('resetKeyBtn').addEventListener('click', handleForgetKey); document.getElementById('enablePasskeyBtn').addEventListener('click', () => runPasskeyFlow('enable', handleEnablePasskeyUnlock)); // Passkey backup screen diff --git a/src/injected.js b/src/injected.js index 3eba392..d8b0cdc 100644 --- a/src/injected.js +++ b/src/injected.js @@ -19,13 +19,53 @@ interceptorScript.onerror = function () { }; (document.head || document.documentElement).appendChild(interceptorScript); +// When the extension is reloaded, updated, or disabled while this page stays +// open, the content script is orphaned: every chrome.runtime.* call throws +// "Extension context invalidated." High-frequency callers (e.g. Proton's +// event-manager poll fires a fetch per tick) would otherwise flood the console +// with an identical stack forever. Latch the dead context on first sight, tell +// the page-context interceptor to un-patch, and answer all later requests with +// a silent null. A tab reload re-injects fresh scripts against the live context. +let podkeyContextValid = true; + +function respondNip98 (id, result) { + window.dispatchEvent(new CustomEvent('podkey-nip98-response', { + detail: { id, result: result || null } + })); +} + +function isDeadContextError (message) { + return /Extension context invalidated|message port closed|receiving end does not exist/i.test(message || ''); +} + +function disablePodkeyOnDeadContext () { + if (!podkeyContextValid) return; // log + signal exactly once + podkeyContextValid = false; + console.warn( + '[Podkey] Extension context invalidated (extension was reloaded/updated). ' + + 'NIP-98 injection disabled for this page — reload the tab to re-enable.' + ); + // Ask the page-context interceptor to restore native fetch/XHR so it stops + // round-tripping to a dead extension on every request. + window.dispatchEvent(new CustomEvent('podkey-nip98-disable')); +} + // Listen for NIP-98 auth requests from page context. The request body never // crosses this boundary -- the page context computes its SHA-256 (the only // place FormData / URLSearchParams / streamed bodies survive intact) and sends // only the hex digest for the NIP-98 `payload` tag. window.addEventListener('podkey-nip98-request', async (event) => { - const { id, url, method, bodyHash } = event.detail; + const { id } = event.detail; + + // Fast path: context already known dead, or chrome.runtime torn down + // (runtime.id becomes undefined in an orphaned content script). + if (!podkeyContextValid || !chrome.runtime?.id) { + disablePodkeyOnDeadContext(); + respondNip98(id, null); + return; + } + const { url, method, bodyHash } = event.detail; try { const response = await chrome.runtime.sendMessage({ type: 'CREATE_NIP98_AUTH_HEADER', @@ -38,21 +78,17 @@ window.addEventListener('podkey-nip98-request', async (event) => { throw new Error(chrome.runtime.lastError.message); } - // Send response back to page context - window.dispatchEvent(new CustomEvent('podkey-nip98-response', { - detail: { - id, - result: response || null - } - })); + respondNip98(id, response); } catch (error) { - console.error('[Podkey] Error handling NIP-98 request:', error); - window.dispatchEvent(new CustomEvent('podkey-nip98-response', { - detail: { - id, - result: null - } - })); + // The orphaned-context error is expected after an extension reload: latch + // and go quiet instead of logging per request. Anything else is a genuine + // fault worth surfacing. + if (isDeadContextError(error?.message)) { + disablePodkeyOnDeadContext(); + } else { + console.error('[Podkey] Error handling NIP-98 request:', error); + } + respondNip98(id, null); } }); diff --git a/src/nip98-interceptor.js b/src/nip98-interceptor.js index 52bd5b8..6feb576 100644 --- a/src/nip98-interceptor.js +++ b/src/nip98-interceptor.js @@ -251,5 +251,19 @@ return originalXHRSend.apply(this, [body]); }; + // If the content-script bridge reports the extension context is gone (the + // extension was reloaded/updated while this page stayed open), stop + // intercepting and restore the native network APIs. Without this we keep + // round-tripping a CustomEvent per request to a dead extension. A tab reload + // re-injects a fresh interceptor bound to the live extension. + window.addEventListener('podkey-nip98-disable', function restoreNative () { + window.removeEventListener('podkey-nip98-disable', restoreNative); + window.fetch = originalFetch; + XMLHttpRequest.prototype.open = originalXHROpen; + XMLHttpRequest.prototype.send = originalXHRSend; + XMLHttpRequest.prototype.setRequestHeader = originalXHRSetRequestHeader; + if (DEBUG) console.log('[Podkey] Context invalidated — native fetch/XHR restored'); + }); + if (DEBUG) console.log('[Podkey] NIP-98 interceptor injected into page context'); })(); diff --git a/src/passkey.js b/src/passkey.js index bba7414..3ec05dc 100644 --- a/src/passkey.js +++ b/src/passkey.js @@ -83,34 +83,80 @@ export function newPasskeySalt () { // authenticators return a different value at create() than at get(), and // every future unlock uses get(). Callers obtain key material exclusively via // getPasskeyPrf, so a value baked in at setup is always reproducible at unlock. +const PRF_UNSUPPORTED_MESSAGE = + 'This authenticator completed sign-in but did not return a derivation secret ' + + '(the WebAuthn PRF / hmac-secret extension). Podkey needs PRF to derive your key. ' + + 'Try a phone passkey or a modern security key that supports PRF, or create a ' + + 'passphrase-based key instead.'; + +// WebAuthn surfaces almost every ceremony failure as NotAllowedError — a +// deliberately vague catch-all covering user cancel, timeout, no available +// authenticator, and lost window focus. Name the likely causes (including the +// two-prompt shape below) without over-claiming which one occurred; pass any +// other error through unchanged. +function translateCeremonyError (err) { + if (err && (err.name === 'NotAllowedError' || err.name === 'AbortError')) { + return new Error( + 'The passkey step was cancelled, timed out, or could not be completed. ' + + 'Podkey prompts twice — once to register the passkey, once to derive the key — ' + + 'so confirm every prompt. If it keeps failing, try a phone passkey or a ' + + 'different security key.' + ); + } + return err instanceof Error ? err : new Error(String(err?.message || err)); +} + export async function createPasskey (prfSalt, label = 'Podkey identity') { if (!window.PublicKeyCredential || !navigator.credentials) { throw new Error('Passkeys are not supported by this browser'); } - const credential = await navigator.credentials.create({ publicKey: { - challenge: randomBytes(32), - user: { id: randomBytes(32), name: 'podkey', displayName: label }, - rp: { name: 'Podkey' }, - pubKeyCredParams: [{ type: 'public-key', alg: -7 }], - authenticatorSelection: { residentKey: 'preferred', userVerification: 'required' }, - timeout: 120000, - attestation: 'none', - extensions: { prf: { eval: { first: prfSalt } } } - } }); + let credential; + try { + credential = await navigator.credentials.create({ publicKey: { + challenge: randomBytes(32), + user: { id: randomBytes(32), name: 'podkey', displayName: label }, + rp: { name: 'Podkey' }, + pubKeyCredParams: [{ type: 'public-key', alg: -7 }], + // Podkey stores the credentialId itself and always passes it via + // allowCredentials at unlock, so it never needs a discoverable (resident) + // credential. Requesting one adds cost and, on some TPM/security-key + // authenticators (e.g. tpm-fido), a makeCredential failure path — so + // discourage it. hmac-secret/PRF works fine on non-resident credentials. + authenticatorSelection: { residentKey: 'discouraged', userVerification: 'required' }, + timeout: 120000, + attestation: 'none', + extensions: { prf: { eval: { first: prfSalt } } } + } }); + } catch (err) { + throw translateCeremonyError(err); + } if (!credential) throw new Error('Passkey creation was cancelled'); + // Definitive PRF-support signal: with prf requested at creation, the client + // reports whether the authenticator provisioned hmac-secret. If it didn't, + // every unlock's get() would fail to return key material — so stop here with + // an actionable message instead of persisting a credential that can't unlock. + const prf = credential.getClientExtensionResults?.().prf; + if (!prf || prf.enabled !== true) { + throw new Error(PRF_UNSUPPORTED_MESSAGE); + } return { credentialId: toBase64Url(new Uint8Array(credential.rawId)) }; } export async function getPasskeyPrf (credentialId, prfSalt) { const id = typeof credentialId === 'string' ? fromBase64Url(credentialId) : credentialId; - const assertion = await navigator.credentials.get({ publicKey: { - challenge: randomBytes(32), - allowCredentials: [{ type: 'public-key', id }], - userVerification: 'required', - timeout: 120000, - extensions: { prf: { eval: { first: prfSalt } } } - } }); + let assertion; + try { + assertion = await navigator.credentials.get({ publicKey: { + challenge: randomBytes(32), + allowCredentials: [{ type: 'public-key', id }], + userVerification: 'required', + timeout: 120000, + extensions: { prf: { eval: { first: prfSalt } } } + } }); + } catch (err) { + throw translateCeremonyError(err); + } const output = assertion?.getClientExtensionResults().prf?.results?.first; - if (!output) throw new Error('This passkey does not support secure key derivation (PRF)'); + if (!output) throw new Error(PRF_UNSUPPORTED_MESSAGE); return new Uint8Array(output); }