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);
}