Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 32 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
```

Expand All @@ -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
Expand Down Expand Up @@ -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.

Expand Down
45 changes: 45 additions & 0 deletions USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`
Expand Down
4 changes: 4 additions & 0 deletions popup/popup.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions popup/popup.html
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,8 @@ <h1>Podkey</h1>
<span class="sep">•</span>
<a href="#" id="exportBtn">Export key</a>
<span class="sep">•</span>
<a href="#" id="resetKeyBtn" class="danger">Start over</a>
<span class="sep">•</span>
<a href="https://github.com/JavaScriptSolidServer/podkey" target="_blank" rel="noopener">GitHub</a>
</div>
</div>
Expand Down
4 changes: 4 additions & 0 deletions popup/popup.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
66 changes: 51 additions & 15 deletions src/injected.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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);
}
});

Expand Down
14 changes: 14 additions & 0 deletions src/nip98-interceptor.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
})();
82 changes: 64 additions & 18 deletions src/passkey.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Loading