From 078b7353fe0e152261496dc1a53d097040511609 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Thu, 13 Aug 2026 17:21:43 +0000 Subject: [PATCH 1/3] fix(content+popup): survive orphaned extension context; add reset-to-init MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two robustness fixes surfaced while live-testing the passkey identity flow. Content-script resilience (injected.js, nip98-interceptor.js): When the extension is reloaded/updated while a consuming page stays open, the content script is orphaned and every chrome.runtime call throws "Extension context invalidated." High-frequency callers (e.g. Proton's event-manager poll) flooded the console with an identical stack forever. Now the bridge latches the dead context on first sight, warns once, tells the page interceptor to restore native fetch/XHR, and answers all later requests with a silent null. A tab reload re-injects fresh scripts. Reset-to-init (popup): The wipe-and-return-to-setup action (handleForgetKey) was only reachable from the unlock screen, so an unlocked user had no way back to the setup screen — the sole entry point for passkey-derived identity creation. Add a "Start over" footer link on the main screen wired to the same handler, so existing users can reset to init (and reach passkey creation) without locking first. Styled with the existing --danger token. Co-Authored-By: jjohare --- popup/popup.css | 4 +++ popup/popup.html | 2 ++ popup/popup.js | 4 +++ src/injected.js | 66 +++++++++++++++++++++++++++++++--------- src/nip98-interceptor.js | 14 +++++++++ 5 files changed, 75 insertions(+), 15 deletions(-) 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'); })(); From 0c95d78af29e00e58f9fd1ccb8d98f1d51d86709 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Thu, 13 Aug 2026 18:23:19 +0000 Subject: [PATCH 2/3] fix(passkey): don't request resident credential; clearer PRF/ceremony errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Requesting residentKey:'preferred' asks the authenticator to create a discoverable credential, which Podkey never uses — it stores the credentialId and always passes it via allowCredentials at unlock. On some TPM/security-key authenticators (e.g. tpm-fido) the resident-credential makeCredential path fails after a successful fingerprint/UV, surfacing as a generic NotAllowedError ("timed out or not allowed"). Switch to residentKey:'discouraged'; hmac-secret/PRF works fine on non-resident creds. Also surface actionable errors instead of the raw NotAllowedError: - After create(), check getClientExtensionResults().prf.enabled — the definitive signal that the authenticator provisioned hmac-secret — and fail early with a clear "no PRF" message rather than persisting a credential that can never unlock. - Translate NotAllowedError/AbortError from either ceremony into a message that names the likely causes (cancel/timeout/focus) and the two-prompt shape. Co-Authored-By: jjohare --- src/passkey.js | 82 +++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 64 insertions(+), 18 deletions(-) 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); } From 785627d8a55278beb3d19d45e02b01cc525fcb21 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Thu, 13 Aug 2026 19:16:23 +0000 Subject: [PATCH 3/3] docs: document passkey identity, PRF requirement, reset-to-init Bring the user-facing docs up to date with the FIDO2 passkey feature and the PR #26 fixes. - CHANGELOG: populate [Unreleased] with the passkey master identity (derived + wrapped, PRF requirement, nsec backup), "Start over" reset, the invalidated- context resilience fix, and the passkey ceremony compatibility/error fixes. - USAGE: add a "Create a passkey-derived identity (advanced)" walkthrough (two prompts, PRF-capable authenticator, nsec backup), "Passkey unlock" for an existing key, "Start over (reset to setup)", and a passkey troubleshooting entry (NotAllowedError / PRF / fingerprint verify-no-match). - README: add a "Passkey identity (advanced)" section linking the specs, add passkey.js/keyformat.js/auth-header-utils.js to the source tree, correct the test count to 169, and add a passkey troubleshooting note. Co-Authored-By: jjohare --- CHANGELOG.md | 33 +++++++++++++++++++++++++++++++++ README.md | 33 ++++++++++++++++++++++++++++++++- USAGE.md | 45 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 110 insertions(+), 1 deletion(-) 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`