From fb02061ef508bbd23269e1a34b50d9b48c0722ed Mon Sep 17 00:00:00 2001 From: Claude Code Date: Wed, 12 Aug 2026 19:50:10 +0000 Subject: [PATCH 1/2] feat: FIDO2/WebAuthn passkey master identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add optional passkey-based identity to the MV3 Nostr signer, in two modes: - derived: the Nostr secp256k1 key is derived from a WebAuthn PRF output via HKDF-SHA256 (info "podkey/nostr-secret/v1" + counter byte, client-stored 32-byte derivation salt, rejection loop for invalid scalars). - wrapped: an existing key is AES-256-GCM wrapped under HKDF-SHA256 of the PRF output (info "podkey/wrap/v1"), keeping passphrase recovery intact. Security and robustness: - All WebAuthn ceremonies run in a dedicated chrome.windows.create window (?flow=create|enable|unlock), not the toolbar action popup, which Chrome destroys on blur when the authenticator UI takes focus. - Key material is always obtained from a get() assertion (never the creation PRF), so setup and every future unlock derive identically. - Derived-identity creation is gated on an acknowledged nsec backup, and writes config before the session key so an interrupted setup is recoverable, not orphaned; orphan public keys are swept on status. - Privileged background messages (SET_SESSION_KEY, GENERATE/IMPORT_KEYPAIR, UN/LOCK_VAULT, GET_KEYPAIR_STATUS) are rejected unless the sender is one of our own extension pages, keyed on the extension origin (not sender.tab, since the ceremony window is itself a tab). Docs: two draft specs under site/ modelled on the did-nostr method spec — the passkey key-custody contract (with test vectors) and the did:nostr identity / NIP-07 / NIP-98 surface. Verified end-to-end in Chrome via a CDP virtual authenticator: create → backup gate → derived identity → lock → passkey unlock reproduces the same pubkey. 169 unit tests pass; lint and build clean. Co-Authored-By: jjohare --- .gitignore | 1 + README.md | 2 +- popup/popup.css | 11 ++ popup/popup.html | 66 ++++++- popup/popup.js | 228 ++++++++++++++++++++++- scripts/bundle.js | 11 ++ site/did-nostr.html | 274 ++++++++++++++++++++++++++++ site/index.html | 2 + site/passkey-identity.html | 345 +++++++++++++++++++++++++++++++++++ src/background.js | 63 ++++++- src/keyformat.js | 32 ++++ src/passkey.js | 116 ++++++++++++ store/SUBMISSION.md | 2 +- test/keyformat.test.js | 17 +- test/passkey.test.js | 81 ++++++++ test/set-session-key.test.js | 156 ++++++++++++++++ 16 files changed, 1398 insertions(+), 9 deletions(-) create mode 100644 site/did-nostr.html create mode 100644 site/passkey-identity.html create mode 100644 src/passkey.js create mode 100644 test/passkey.test.js create mode 100644 test/set-session-key.test.js diff --git a/.gitignore b/.gitignore index 5c27928..5afa9ee 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ dist/ .vscode/ .idea/ src/background.bundle.js +popup/popup.bundle.js diff --git a/README.md b/README.md index bb1a202..ab8448c 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ stays inside the extension and never reaches the page. git clone https://github.com/JavaScriptSolidServer/podkey.git cd podkey npm install -npm run build # bundles @noble deps into src/background.bundle.js +npm run build # bundles the background worker and passkey-enabled popup ``` Then load the `podkey` directory as an unpacked extension (steps 2–4 above). diff --git a/popup/popup.css b/popup/popup.css index a3daf78..c95c59c 100644 --- a/popup/popup.css +++ b/popup/popup.css @@ -274,6 +274,17 @@ h2 { transform: none; } +.advanced-choice { margin-top: 12px; color: var(--text-muted); font-size: 12px; } +.advanced-choice summary { cursor: pointer; text-align: center; font-weight: 600; color: var(--accent); } +.advanced-choice p { margin-top: 7px; line-height: 1.45; } +.text-action { width: 100%; margin-top: 10px; padding: 9px; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); background: var(--surface); color: var(--text); font: inherit; font-weight: 600; cursor: pointer; } +.divider { display: flex; align-items: center; gap: 8px; margin: 12px 0; color: var(--text-faint); font-size: 10px; text-transform: uppercase; letter-spacing: .06em; } +.divider::before, .divider::after { content: ''; height: 1px; flex: 1; background: var(--border); } +.settings-separated { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border); } +.btn-small { flex: none; padding: 6px 10px; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); background: var(--surface); color: var(--accent); font: inherit; font-size: 11px; font-weight: 700; cursor: pointer; } +.backup-ack { display: flex; align-items: center; gap: 8px; margin: 12px 0 4px; font-size: 12px; color: var(--text); cursor: pointer; } +.backup-ack input { accent-color: var(--accent); width: 15px; height: 15px; flex: none; cursor: pointer; } + /* ---- Identity ---- */ .identity-section .section-title { margin-bottom: 10px; diff --git a/popup/popup.html b/popup/popup.html index e609051..7defad7 100644 --- a/popup/popup.html +++ b/popup/popup.html @@ -41,6 +41,11 @@

Welcome to Podkey

Import existing key +
+ Advanced identity options + +

No passphrase. Your passkey recreates the same Nostr key. Export a backup in case the passkey is lost.

+
@@ -104,6 +109,8 @@

Unlock Podkey

+ + @@ -166,6 +173,56 @@

Import private key

+ + +
@@ -241,6 +305,6 @@

Podkey

- + diff --git a/popup/popup.js b/popup/popup.js index 03d6d1f..4ea7e30 100644 --- a/popup/popup.js +++ b/popup/popup.js @@ -1,6 +1,18 @@ /** * Podkey - Popup UI Logic */ +import { + PASSKEY_CONFIG_KEY, + createPasskey, + deriveNostrKey, + fromBase64Url, + getPasskeyPrf, + newPasskeySalt, + toBase64Url, + unwrapPrivateKey, + wrapPrivateKey +} from '../src/passkey.js'; +import { hexToNsec } from '../src/keyformat.js'; // Set DEBUG=true to log identity material (public key / DID) for local // debugging. Off by default so the popup never prints the user's pubkey. @@ -9,14 +21,53 @@ const DEBUG = false; // UI State let currentScreen = 'setup'; +// A derived identity waiting on the backup acknowledgment. Nothing is +// persisted until the user confirms the backup, so abandoning this screen +// (cancel or closing the window) leaves no partial state anywhere. +let pendingDerivedIdentity = null; + // Initialize document.addEventListener('DOMContentLoaded', async () => { console.log('[Podkey Popup] DOMContentLoaded fired'); await checkKeypairStatus(); setupEventListeners(); console.log('[Podkey Popup] Initialization complete'); + + // When relaunched as a dedicated passkey window (?flow=…), start the + // requested ceremony immediately — see runPasskeyFlow. + const flow = new URLSearchParams(location.search).get('flow'); + if (flow === 'create') handleCreatePasskeyIdentity(); + else if (flow === 'enable') handleEnablePasskeyUnlock(); + else if (flow === 'unlock') handlePasskeyUnlock(); }); +/** + * Run a WebAuthn ceremony in a context that survives losing focus. The + * toolbar action popup is destroyed on blur — and the platform authenticator + * UI takes focus — so a ceremony started there can be killed mid-flight. + * A chrome.windows.create popup (like the background's unlock window) is not, + * so when invoked from the action popup we relaunch this page into one and + * let ?flow= restart the ceremony there. + */ +async function runPasskeyFlow(flow, handler) { + let inOwnWindow = false; + try { + inOwnWindow = (await chrome.windows.getCurrent()).type === 'popup'; + } catch { /* fall through: treat as action popup and relaunch */ } + if (inOwnWindow) { + await handler(); + return; + } + await chrome.windows.create({ + url: chrome.runtime.getURL(`popup/popup.html?flow=${flow}`), + type: 'popup', + width: 400, + height: 560, + focused: true + }); + window.close(); +} + /** * Check if keypair exists and show appropriate screen */ @@ -55,6 +106,11 @@ function showSetupScreen() { console.log('[Podkey Popup] Setup screen display set to block'); } +async function getPasskeyConfig() { + const { [PASSKEY_CONFIG_KEY]: config } = await chrome.storage.local.get([PASSKEY_CONFIG_KEY]); + return config || null; +} + /** * Show generate screen (set an encryption passphrase for a new key) */ @@ -79,7 +135,16 @@ function showUnlockScreen(status) { document.getElementById('unlockPassphrase').value = ''; document.getElementById('unlockScreen').style.display = 'block'; currentScreen = 'unlock'; - document.getElementById('unlockPassphrase').focus(); + getPasskeyConfig().then(config => { + const passkeyBtn = document.getElementById('passkeyUnlockBtn'); + const hasPasskey = !!config; + passkeyBtn.hidden = !hasPasskey; + document.getElementById('unlockDivider').hidden = !hasPasskey || config.mode === 'derived'; + document.querySelector('label[for="unlockPassphrase"]').hidden = config?.mode === 'derived'; + document.getElementById('unlockPassphrase').hidden = config?.mode === 'derived'; + document.getElementById('unlockBtn').hidden = config?.mode === 'derived'; + if (!hasPasskey) document.getElementById('unlockPassphrase').focus(); + }); } /** @@ -110,6 +175,16 @@ async function showMainScreen(status) { // which keeps silent trusted-origin Solid / NIP-98 signing strictly opt-in). const { podkey_auto_sign: autoSign = false } = await chrome.storage.local.get(['podkey_auto_sign']); document.getElementById('autoSignToggle').checked = autoSign; + + const config = await getPasskeyConfig(); + const passkeyBtn = document.getElementById('enablePasskeyBtn'); + passkeyBtn.hidden = config?.mode === 'derived'; + passkeyBtn.textContent = config ? 'Replace' : 'Set up'; + document.getElementById('passkeySettingDesc').textContent = config?.mode === 'derived' + ? 'This identity is derived from your passkey.' + : config + ? 'Enabled. Your passphrase remains available for recovery.' + : 'Use biometrics or a security key instead of typing your passphrase.'; } /** @@ -129,6 +204,7 @@ function setupEventListeners() { // Setup screen document.getElementById('generateBtn').addEventListener('click', () => showGenerateScreen()); document.getElementById('importBtn').addEventListener('click', () => showImportScreen()); + document.getElementById('passkeyDerivedBtn').addEventListener('click', () => runPasskeyFlow('create', handleCreatePasskeyIdentity)); // Generate screen document.getElementById('generateConfirmBtn').addEventListener('click', handleGenerate); @@ -136,6 +212,7 @@ function setupEventListeners() { // Unlock screen document.getElementById('unlockBtn').addEventListener('click', handleUnlock); + document.getElementById('passkeyUnlockBtn').addEventListener('click', () => runPasskeyFlow('unlock', handlePasskeyUnlock)); document.getElementById('unlockPassphrase').addEventListener('keydown', (e) => { if (e.key === 'Enter') handleUnlock(); }); @@ -150,6 +227,153 @@ function setupEventListeners() { document.getElementById('autoSignToggle').addEventListener('change', handleAutoSignToggle); document.getElementById('exportBtn').addEventListener('click', handleExport); document.getElementById('lockBtn').addEventListener('click', handleLock); + document.getElementById('enablePasskeyBtn').addEventListener('click', () => runPasskeyFlow('enable', handleEnablePasskeyUnlock)); + + // Passkey backup screen + document.getElementById('backupAckCheck').addEventListener('change', (e) => { + document.getElementById('backupFinishBtn').disabled = !e.target.checked; + }); + document.getElementById('backupCopyBtn').addEventListener('click', handleBackupCopy); + document.getElementById('backupFinishBtn').addEventListener('click', handleBackupFinish); + document.getElementById('backupCancelBtn').addEventListener('click', () => { + pendingDerivedIdentity = null; + showSetupScreen(); + }); +} + +async function registerPrfPasskey(label) { + const prfSalt = newPasskeySalt(); + const { credentialId } = await createPasskey(prfSalt, label); + // Key material comes from a get() assertion — the operation every future + // unlock performs — so what we derive or wrap now is exactly what the + // passkey will reproduce later. (Second prompt is the cost of that proof.) + const prfOutput = await getPasskeyPrf(credentialId, prfSalt); + return { credentialId, prfOutput, prfSalt }; +} + +async function handleCreatePasskeyIdentity() { + const confirmed = confirm( + 'This advanced mode derives your identity from a passkey. If the passkey is lost or unavailable, the identity cannot be recovered without the private-key backup you will be shown next.\n\nContinue?' + ); + if (!confirmed) return; + const btn = document.getElementById('passkeyDerivedBtn'); + try { + btn.disabled = true; + btn.textContent = 'Creating passkey…'; + const { credentialId, prfOutput, prfSalt } = await registerPrfPasskey('Podkey Nostr identity'); + const derivationSalt = newPasskeySalt(); + const privateKey = await deriveNostrKey(prfOutput, derivationSalt); + // Persist nothing yet: the identity only comes into existence once the + // user has acknowledged the backup on the next screen. + pendingDerivedIdentity = { + privateKey, + config: { + v: 1, mode: 'derived', credentialId, + prfSalt: toBase64Url(prfSalt), derivationSalt: toBase64Url(derivationSalt) + } + }; + showBackupScreen(privateKey); + } catch (error) { + alert(error.message || 'Could not create a passkey identity.'); + } finally { + btn.disabled = false; + btn.textContent = 'Create identity from a passkey'; + } +} + +/** + * Show the backup-acknowledgment gate for a freshly derived identity. + */ +function showBackupScreen(privateKey) { + hideAllScreens(); + document.getElementById('backupNsec').textContent = hexToNsec(privateKey); + document.getElementById('backupAckCheck').checked = false; + document.getElementById('backupFinishBtn').disabled = true; + document.getElementById('passkeyBackupScreen').style.display = 'block'; + currentScreen = 'passkeyBackup'; +} + +async function handleBackupCopy() { + if (!pendingDerivedIdentity) return; + try { + await navigator.clipboard.writeText(hexToNsec(pendingDerivedIdentity.privateKey)); + const label = document.querySelector('#backupCopyBtn .btn-copy-label'); + const original = label.textContent; + label.textContent = 'Copied'; + setTimeout(() => { label.textContent = original; }, 2000); + } catch (error) { + alert('Failed to copy: ' + error.message); + } +} + +async function handleBackupFinish() { + if (!pendingDerivedIdentity) return; + const btn = document.getElementById('backupFinishBtn'); + try { + btn.disabled = true; + btn.textContent = 'Creating…'; + // Config before key: if this is interrupted after the config write, the + // status handler reports a locked passkey identity and the next passkey + // unlock re-derives and stores the key — whereas key-before-config left + // an orphaned public key and no way back to this identity. + await chrome.storage.local.set({ [PASSKEY_CONFIG_KEY]: pendingDerivedIdentity.config }); + const response = await chrome.runtime.sendMessage({ + type: 'SET_SESSION_KEY', privateKey: pendingDerivedIdentity.privateKey + }); + if (response?.error) { + await chrome.storage.local.remove([PASSKEY_CONFIG_KEY]); + throw new Error(response.error); + } + pendingDerivedIdentity = null; + await showMainScreen(response); + } catch (error) { + alert(error.message || 'Could not create a passkey identity.'); + btn.disabled = !document.getElementById('backupAckCheck').checked; + } finally { + btn.textContent = 'Create identity'; + } +} + +async function handleEnablePasskeyUnlock() { + const btn = document.getElementById('enablePasskeyBtn'); + try { + btn.disabled = true; + btn.textContent = 'Waiting…'; + const { podkey_private_key: privateKey } = await chrome.storage.session.get(['podkey_private_key']); + if (!privateKey) throw new Error('Unlock Podkey before setting up passkey unlock'); + const { credentialId, prfOutput, prfSalt } = await registerPrfPasskey('Podkey unlock'); + const wrapped = await wrapPrivateKey(privateKey, prfOutput); + await chrome.storage.local.set({ [PASSKEY_CONFIG_KEY]: { + v: 1, mode: 'wrapped', credentialId, prfSalt: toBase64Url(prfSalt), wrapped + } }); + await showMainScreen(await chrome.runtime.sendMessage({ type: 'GET_KEYPAIR_STATUS' })); + } catch (error) { + alert(error.message || 'Could not enable passkey unlock.'); + } finally { + btn.disabled = false; + } +} + +async function handlePasskeyUnlock() { + const btn = document.getElementById('passkeyUnlockBtn'); + try { + btn.disabled = true; + btn.textContent = 'Waiting for passkey…'; + const config = await getPasskeyConfig(); + if (!config) throw new Error('No passkey is configured'); + const prfOutput = await getPasskeyPrf(config.credentialId, fromBase64Url(config.prfSalt)); + const privateKey = config.mode === 'derived' + ? await deriveNostrKey(prfOutput, fromBase64Url(config.derivationSalt)) + : await unwrapPrivateKey(config.wrapped, prfOutput); + const response = await chrome.runtime.sendMessage({ type: 'SET_SESSION_KEY', privateKey }); + if (response?.error) throw new Error(response.error); + await showMainScreen(response); + } catch (error) { + alert(error.message || 'Passkey unlock failed.'); + } finally { + btn.disabled = false; + btn.textContent = 'Unlock with passkey'; + } } /** @@ -256,7 +480,7 @@ async function handleForgetKey(event) { if (!confirmed) return; await chrome.storage.session.remove(['podkey_private_key']); - await chrome.storage.local.remove(['podkey_vault', 'podkey_public_key']); + await chrome.storage.local.remove(['podkey_vault', 'podkey_public_key', PASSKEY_CONFIG_KEY]); showSetupScreen(); } diff --git a/scripts/bundle.js b/scripts/bundle.js index 2357c14..c2e66e7 100644 --- a/scripts/bundle.js +++ b/scripts/bundle.js @@ -33,6 +33,17 @@ async function bundle () { } }); + await build({ + entryPoints: [join(rootDir, 'popup/popup.js')], + bundle: true, + outfile: join(rootDir, 'popup/popup.bundle.js'), + format: 'iife', + platform: 'browser', + target: 'es2020', + sourcemap: false, + minify: false + }); + console.log('✅ Background service worker bundled successfully!'); console.log(' Output: src/background.bundle.js\n'); diff --git a/site/did-nostr.html b/site/did-nostr.html new file mode 100644 index 0000000..663d51f --- /dev/null +++ b/site/did-nostr.html @@ -0,0 +1,274 @@ + + + + + + Podkey did:nostr Identity Specification + + + + + +
+
+
+ + Podkey +
+ +
+ +

Podkey did:nostr Identity Specification

+

How Podkey creates, presents, and authenticates a did:nostr identity in the browser

+ Draft — version 1 +

+ This document is a work in progress and may be updated, replaced, or obsoleted at any time. + It profiles the Nostr DID Method Specification + for a browser-held identity, following that document's structure and conventions. + The key words must, must not, + should, and may are to be interpreted as in RFC 2119. + Key custody mechanics (encrypted vaults, passkey unlock and derivation) are out of scope here and are + specified in the companion Podkey Passkey Identity Specification. +

+ +

1. Introduction

+

+ did:nostr makes a Nostr public key a W3C decentralized + identifier: no registry, no blockchain, no identity provider — the key is the identity. What the + method specification leaves open is where that key lives and how it is exercised safely from a web page. +

+

+ Podkey answers that as a Manifest V3 browser extension: it holds exactly one secp256k1 keypair per + profile, never releases the private key to any page, and exposes the identity through two narrow, + user-consented surfaces — the NIP-07 capability object for Nostr applications, and a + NIP-98 HTTP authentication profile for servers such as + Solid pods. This document specifies both surfaces and the identity + lifecycle around them, so that servers and applications can interoperate with a Podkey-held + did:nostr identity without depending on extension internals. +

+ +

2. Core concepts

+

2.1 Identifier

+

+ The identity is a BIP-340 x-only secp256k1 public key, encoded as 64 lowercase hex characters, used + directly in the DID scheme of the method specification: +

+
const pubkey = await window.nostr.getPublicKey()
+const did = `did:nostr:${pubkey}`
+// did:nostr:3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d
+

+ Implementations must use the raw hex form in DIDs and protocol messages. + The bech32 forms (npub… for display, nsec… for private-key interchange per + NIP-19) are presentation formats only. +

+ +

2.2 One identity, one key

+

+ Podkey manages a single active identity. Everything the extension emits — a signed event, an + authentication header, an encrypted payload — is attributable to that one did:nostr. + There is no key rotation within an identity: rotating means creating a new identity, as in the + method specification's update semantics. +

+ +

2.3 Custody boundary

+

+ The private key exists in plaintext only inside extension memory for the duration of a browser session. + At rest it is encrypted on-device; unlocking uses a passphrase or a passkey (see the + companion specification). Web pages interact only with the + surfaces below; a page never receives, and cannot request, key material. Every response that crosses + the page boundary is a public key, a signed event, an encrypted/decrypted payload, or an + authentication header. +

+ +

3. Presentation: the NIP-07 capability surface

+

+ Podkey injects a NIP-07 + window.nostr object into every page. The implemented surface: +

+ + + + + +
MethodBehaviour
getPublicKey()Returns the 64-hex public key after per-origin consent; consent establishes revocable origin trust.
signEvent(event)Signs any event kind (BIP-340 Schnorr). Trusted origins sign without a prompt; untrusted origins always prompt.
nip44.encrypt(pubkey, plaintext) / nip44.decrypt(pubkey, ciphertext)NIP-44 v2 payloads, gated by the same origin-trust model.
+

+ getRelays() is intentionally absent: Podkey holds no relay list, and a missing method is the + honest signal to capability-probing clients. Applications must feature-detect + rather than assume the full NIP-07 surface. +

+ +

4. Authentication: the NIP-98 HTTP profile

+

+ Podkey authenticates HTTP requests with + NIP-98: a signed kind-27235 event + carried in the Authorization header. Podkey emits tokens with this exact shape: +

+
{
+  "kind": 27235,
+  "created_at": <unix seconds>,
+  "tags": [
+    ["u", "<exact request URL>"],
+    ["method", "<HTTP method>"],
+    ["nonce", "<16 random bytes, hex>"],
+    ["payload", "<sha256 of the request body, hex>"]   // present iff the request has a body
+  ],
+  "content": "",
+  "pubkey": "<64-hex>",
+  "id": "…", "sig": "…"
+}
+
+Authorization: Nostr <base64(JSON event)>
+
    +
  • The nonce tag supplements NIP-98's 1-second created_at resolution so + verifiers can reject replays exactly; verifiers should enforce + single use per nonce within their freshness window.
  • +
  • The payload tag binds the request body: verifiers must + reject a body-bearing request whose hash does not match.
  • +
  • Verifiers must check the u and method tags + against the actual request, verify the Schnorr signature, and then treat + did:nostr:<pubkey> as the authenticated identity.
  • +
+

+ This is how a Podkey identity authenticates to Solid pods with no OAuth redirect and no identity-provider + account: the pod verifies the token and resolves the DID per the method specification. Automatic + (promptless) NIP-98 signing is opt-in and restricted to exactly-matching trusted hosts; by default every + authentication is user-approved. +

+ +

5. Operations

+

5.1 Create

+

+ Generate a fresh secp256k1 keypair inside the extension, or import an existing key + (64-hex or nsec, normalised at the import boundary). The DID is + did:nostr:<pubkey> from the moment the key exists — no registration step, per the + method specification. +

+

5.2 Read (resolve)

+

+ Podkey does not resolve DIDs; it is the subject, not a resolver. Consumers resolve a Podkey-presented DID + exactly as the method specification describes — minimal resolution derives a valid DID document from the + public key alone, offline, which is sufficient to verify anything Podkey signs. +

+

5.3 Update / Deactivate

+

+ Not applicable at the extension layer. Podkey publishes no events on its own; profile data (kind 0), + relay lists, and social graph are the domain of Nostr applications the user signs into via NIP-07. + Abandoning an identity is done by forgetting the key (after backup) and creating a new one. +

+

5.4 Backup and restore

+

+ The identity survives as an exported private key (nsec or hex). Restoring it into any + NIP-07 signer — Podkey or another — restores the same did:nostr. This portability is a + property of the key itself and is the canonical way an identity moves between devices and + implementations. +

+ +

6. Security considerations

+
    +
  • Key confinement. The private key must not cross the page + boundary in any form; all signing happens inside the extension context.
  • +
  • Origin trust. Consent is per-origin and revocable; a trusted origin gains promptless + signing, so trust grants should be treated by users as the security + decision they are. Lookalike hosts are not matched — trust is exact.
  • +
  • Replay and binding. Verifiers rely on the nonce, URL, method, and payload-hash tags + (§4); accepting a token without checking all of them voids those guarantees.
  • +
  • Single-key blast radius. One key signs for every consuming application. Users who + need compartmentalised identities should use separate browser profiles, + each with its own Podkey identity.
  • +
  • Key-custody threat model (vault encryption, unlock, loss and recovery) is specified in the + companion specification.
  • +
+ +

7. Privacy considerations

+
    +
  • A did:nostr is a stable global identifier by design; every origin the user consents to + learns the same pubkey and can correlate on it. This inherits the method specification's + correlation caveats.
  • +
  • Podkey itself makes no network requests and emits nothing without a page asking; disclosure is + always the result of a user-approved capability call.
  • +
  • NIP-98 tokens contain the exact URL being authenticated; servers holding logs of them hold a + signed browsing trace for that origin. Verifiers should retain tokens no + longer than replay-checking requires.
  • +
+ +

8. Implementations

+
    +
  • Podkey — this + specification's reference implementation (subject side).
  • +
  • JavaScriptSolidServer — + Solid pod server verifying the §4 profile and resolving did:nostr per the method + specification (verifier side).
  • +
  • Any NIP-07 client and any NIP-98 verifier interoperate with the respective surface without + Podkey-specific code.
  • +
+ +

9. Resources

+ + + +
+ + diff --git a/site/index.html b/site/index.html index cdb7304..55fdc83 100644 --- a/site/index.html +++ b/site/index.html @@ -76,6 +76,8 @@ Ecosystem Security Install + DID Spec + Passkey Spec Privacy GitHub diff --git a/site/passkey-identity.html b/site/passkey-identity.html new file mode 100644 index 0000000..63ef75b --- /dev/null +++ b/site/passkey-identity.html @@ -0,0 +1,345 @@ + + + + + + Podkey Passkey Identity Specification + + + + + +
+
+
+ + Podkey +
+ +
+ +

Podkey Passkey Identity Specification

+

Deriving and protecting a Nostr / did:nostr identity with a FIDO2 passkey via the WebAuthn PRF extension

+ Draft — version 1 +

+ This document is a work in progress and may be updated, replaced, or obsoleted at any time. + It follows the structural conventions of the + Nostr DID Method Specification, which defines + the did:nostr identifiers this specification produces keys for. + The key words must, must not, + should, and may are to be interpreted as in RFC 2119. +

+ +

1. Introduction

+

+ A Nostr identity is a secp256k1 keypair; its 64-character lowercase hex x-only public key is also a + did:nostr decentralized identifier. Managing the private half of that keypair is the entire + user-facing problem: passphrases are forgettable and phishable, and pasted nsec strings leak. +

+

+ FIDO2 passkeys solve custody but cannot sign Nostr events — WebAuthn authenticators do not produce + BIP-340 Schnorr signatures. The bridge is the + WebAuthn PRF extension: a per-credential + pseudo-random function whose 32-byte output is only released after user verification. This specification + defines how that PRF output is turned into — or used to protect — a Nostr secret key, deterministically, + so that any conforming implementation holding the same credential and the same salts derives + the same identity. +

+
+ Scope of the contract. WebAuthn credentials are scoped to the relying party that created + them, and PRF outputs are per-credential (§5). Two applications with different + relying-party identifiers therefore cannot re-derive one identity from "the same passkey" — this + specification does not make identities portable across origins. An identity moves between applications as + a NIP-07 signing capability (the extension signs on the site's behalf) or as an exported backup + (§6.4), never by passkey re-derivation. +
+

+ Podkey (a Manifest V3 browser extension) is the primary implementation. Other clients + (for example, forum software offering extension-less login) must implement the + derivation in §3 byte-for-byte to be construction-compatible: identical + config schema, identical audited derivation, validated against the shared test vectors in + §3.1. Identities such clients create are scoped to their own relying party. +

+ +

2. Core concepts

+

2.1 Modes

+

This specification defines two mutually exclusive modes:

+ + + + + + + + + + + + +
ModeRoot of trustDefinition
derivedThe passkeyThe Nostr secret key is a pure function of the PRF output and two stored salts (§3). + The same passkey and salts always recreate the same identity; nothing secret is stored.
wrappedAn existing keyA pre-existing Nostr secret key is encrypted (AES-256-GCM) under a key derived from the PRF output + (§4). The passkey becomes an unlock method; other recovery paths + (e.g. a passphrase vault) remain valid.
+ +

2.2 Salts

+
    +
  • PRF salt (prfSalt) — 32 random bytes, generated client-side at credential + creation, passed as prf.eval.first in every WebAuthn ceremony. Selects the PRF input.
  • +
  • Derivation salt (derivationSalt, derived mode only) — 32 random bytes, + generated client-side, used as the HKDF salt.
  • +
+
+ Salts are not secrets — the PRF output never leaves the authenticator boundary unverified, and + HKDF's security does not rest on salt secrecy. Salts are, however, availability-critical + in derived mode: without them, even the surviving passkey cannot recreate the identity. Implementations + must store them durably and must not treat their + disclosure as key compromise. All implementations must generate salts client-side; + a server must not mint or own them. +
+ +

2.3 Configuration record

+

+ Implementations persist one versioned record per identity. All binary fields are encoded as + base64url without padding (RFC 4648 §5). +

+
// mode "derived"
+{
+  "v": 1,
+  "mode": "derived",
+  "credentialId": "<base64url credential rawId>",
+  "prfSalt": "<base64url 32 bytes>",
+  "derivationSalt": "<base64url 32 bytes>"
+}
+
+// mode "wrapped"
+{
+  "v": 1,
+  "mode": "wrapped",
+  "credentialId": "<base64url credential rawId>",
+  "prfSalt": "<base64url 32 bytes>",
+  "wrapped": {
+    "salt": "<base64url 32 bytes>",   // HKDF salt for the wrapping key
+    "iv":   "<base64url 12 bytes>",   // AES-GCM nonce
+    "ct":   "<base64url ciphertext + 16-byte tag>"
+  }
+}
+ +

3. Key derivation (mode derived) — normative

+

Given the 32-byte PRF output prf and the 32-byte derivationSalt:

+
    +
  1. Set counter = 0.
  2. +
  3. Compute info = "podkey/nostr-secret/v1" || byte(counter) + (the 22 ASCII bytes of the label followed by one counter byte).
  4. +
  5. Compute candidate = HKDF-SHA-256(ikm = prf, salt = derivationSalt, info = info, length = 32).
  6. +
  7. If candidate, read as a big-endian integer, is a valid secp256k1 secret scalar + (nonzero and less than the group order n), it is the Nostr secret key. Stop.
  8. +
  9. Otherwise increment counter and repeat from step 2. + Implementations must bound the loop (256 iterations) and fail if exhausted.
  10. +
+

+ The counter loop makes derivation total and deterministic: the probability that + counter = 0 is invalid is ≈ 2−128, but when it happens every conforming + implementation advances identically, so all derive the same key. The public key is the BIP-340 + x-only form, and did:nostr:<pubkey-hex> is the corresponding DID. +

+ +

3.1 Test vector

+

Implementations must reproduce this vector exactly.

+
prf            = 0707070707070707070707070707070707070707070707070707070707070707
+derivationSalt = 0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b
+secret key     = 35b9688c42b950406cd91257e11a2f8a76c61ef7b59dcdbe85250e06896582b9
+public key     = a71f3a2f075fdfe99d801dc0658a4bcf2acf8fdf832be28ee2c64dada773eda8
+did            = did:nostr:a71f3a2f075fdfe99d801dc0658a4bcf2acf8fdf832be28ee2c64dada773eda8
+nsec           = nsec1xkuk3rzzh9gyqmxezft7zx303fmvv8hhkkwum059y58qdzt9s2usyx2avn
+ +

4. Key wrapping (mode wrapped) — normative

+

To wrap an existing 32-byte Nostr secret key under the PRF output prf:

+
    +
  1. Generate a fresh random 32-byte salt and 12-byte iv + for every wrap operation.
  2. +
  3. Compute wrapKey = HKDF-SHA-256(ikm = prf, salt = salt, info = "podkey/wrap/v1", length = 32).
  4. +
  5. Compute ct = AES-256-GCM(key = wrapKey, nonce = iv, plaintext = secretKeyBytes) + with the default 128-bit tag.
  6. +
  7. Persist {salt, iv, ct} in the configuration record.
  8. +
+

+ Unwrapping reverses the process; any authentication failure must be reported + as a generic unlock failure without distinguishing wrong-passkey from tampered-ciphertext. +

+
+ The derive path (§3) and wrap path (§4) use distinct HKDF info strings + (podkey/nostr-secret/v1 vs podkey/wrap/v1), so the two constructions can never + yield the same bytes from the same inputs. Wrap-key check vector: + HKDF-SHA-256(prf, salt, "podkey/wrap/v1") with prf and salt as in + §3.1 = 20066d41137e1e47957febffb8ba84259e9dc6ba232803e55e70d93a7d15c892. +
+ +

5. WebAuthn ceremony parameters — normative

+ + + + + + + +
ParameterValueRequirement
pubKeyCredParamsES256 (alg: -7)should (the credential's own algorithm does not affect derivation; PRF support does)
userVerificationrequiredmust — the PRF output gates the identity
residentKeypreferredshould
attestationnoneshould — attestation adds nothing here
extensions.prf.eval.firstthe stored prfSaltmust, on both create and get
+

+ Key material must be obtained from an assertion-time PRF evaluation + (navigator.credentials.get() with allowCredentials = [credentialId]) — the same + operation every future unlock performs. A creation-time PRF output must not be + used for key material: some authenticators return a different value at creation than at assertion, which + would bake in an identity the passkey can never reproduce. Implementations + must fail cleanly when the authenticator does not support PRF at all. +

+
+ Credentials are scoped to the relying-party identifier of the creating context, and the PRF is keyed + per-credential — not per passkey account. For a browser extension the relying party is the extension + origin, so the extension ID is effectively part of the identity: a credential created under one install + path (e.g. unpacked development) cannot be resolved under another (e.g. a store build) unless the ID is + pinned via a manifest key. Web implementations are similarly bound to their domain. + Consequently the passkey-plus-salts recovery path (§6.4) only works within the + relying-party scope that created the credential; recovery across an origin or extension-ID change + must go through the exported backup. Cross-device "hybrid" transports may + evaluate the PRF differently per device; implementations should treat a PRF + output that fails to reproduce the expected identity as a wrong-authenticator condition, not corruption. +
+ +

6. Operations

+

6.1 Create (derived identity)

+
    +
  1. Generate prfSalt (32 random bytes); create the credential with the parameters in §5.
  2. +
  3. Obtain the PRF output via an assertion on the new credential (§5 — never from the creation result).
  4. +
  5. Generate derivationSalt; derive the secret key per §3.
  6. +
  7. Backup gate: present the secret key (as nsec) and require explicit user + acknowledgment that it has been stored, before persisting anything. If the flow is abandoned + here, no state may remain.
  8. +
  9. Persist the configuration record, then activate the key for the session.
  10. +
+

6.2 Unlock (re-derive / unwrap)

+
    +
  1. Load the configuration record; run an assertion with allowCredentials = [credentialId] + and prf.eval.first = prfSalt.
  2. +
  3. Derived mode: recompute the key per §3. Wrapped mode: unwrap per §4.
  4. +
  5. Verify the resulting public key equals the stored identity; reject with a + "different identity" error on mismatch.
  6. +
+

6.3 Enable passkey unlock for an existing key (wrapped)

+

Requires the identity to be unlocked; then §6.1 steps 1–2 followed by §4. Existing recovery + methods (e.g. passphrase vault) must remain intact.

+

6.4 Recover

+

From the backup nsec/hex via ordinary key import (which may + then be re-protected with a new passkey), or — in derived mode, and only within the relying-party scope + that created the credential (§5) — from the same passkey plus the stored salts. + The exported backup is the sole recovery path across installations, origins, or extension-ID changes.

+

6.5 Forget

+

Deletes the configuration record and any vault. Implementations must warn that + in derived mode this destroys the salts — the identity then survives only in exported backups — and that + the WebAuthn credential itself remains on the authenticator (WebAuthn offers no programmatic deletion) + but is no longer referenced.

+ +

7. Security considerations

+
    +
  • PRF output is the secret. It must be held only in memory, + zeroed where the platform allows, and never logged or persisted.
  • +
  • User verification is the only authorisation gate on the identity; ceremonies + must not downgrade it.
  • +
  • Focus-loss resilience. Platform authenticator UI steals focus. Ceremonies + must run in a context that survives losing focus (in an extension: a + dedicated window, not a toolbar popup that closes on blur).
  • +
  • Atomicity. Persisting the configuration record before session-key activation makes an + interrupted creation recoverable (a locked identity) rather than orphaned. Implementations + must order writes so no interruption produces unrecoverable partial state.
  • +
  • Privileged surfaces. Messages that install key material into a session + must be rejected when they arrive from web-content contexts.
  • +
  • Loss model honesty. Derived mode has exactly two recovery paths: (passkey + salts) or + the exported backup. UX must not suggest otherwise.
  • +
+ +

8. Privacy considerations

+
    +
  • The configuration record reveals that an identity exists and its credential ID; it does not reveal the + identity itself, but implementations should treat it as fingerprintable local + state.
  • +
  • The derived public key is, by design, a stable global identifier + (see the did:nostr privacy considerations).
  • +
  • No network interaction is required by any operation in this specification; conforming implementations + must not transmit PRF outputs, salts, or secret keys.
  • +
+ +

9. Implementations

+
    +
  • Podkey — primary + implementation (src/passkey.js); both modes.
  • +
  • nostr-rust-forum — planned: extension-less passkey login implementing the §3/§4 + construction for identities scoped to the forum's own origin. Podkey-held identities are used on the + forum via NIP-07 / NIP-98, not passkey re-derivation.
  • +
+ +

10. Resources

+ + + +
+ + diff --git a/src/background.js b/src/background.js index 0f9b70e..bcbcac1 100644 --- a/src/background.js +++ b/src/background.js @@ -76,11 +76,45 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { /** * Handle incoming messages */ +// Key-material operations are only ever initiated by extension UI (the action +// popup, or the dedicated ceremony window opened via chrome.windows.create), +// never on behalf of a page. The content-script whitelist in injected.js +// already keeps pages away from these, but that guarantee lives in one Set in +// another file — so reject them here too unless the sender is one of our own +// extension pages. +// +// The discriminator is the sender's extension origin, NOT sender.tab: the +// ceremony window is a real browser tab, so a sender.tab check would wrongly +// reject the very passkey flow these types exist for. A content script instead +// reports the web page's URL/origin, so an chrome-extension:// prefix +// cleanly separates trusted extension UI from page-injected content. +const EXTENSION_UI_ONLY_TYPES = new Set([ + 'GENERATE_KEYPAIR', + 'IMPORT_KEYPAIR', + 'UNLOCK_VAULT', + 'LOCK_VAULT', + 'SET_SESSION_KEY', + 'GET_KEYPAIR_STATUS' +]); + +function isExtensionUiSender (sender) { + if (!sender || sender.id !== chrome.runtime.id) return false; + const ownOrigin = `chrome-extension://${chrome.runtime.id}`; + // Extension pages report a chrome-extension URL/origin for our own id; a + // content script reports the host page's URL even though sender.id matches. + const from = sender.url || sender.origin || ''; + return from.startsWith(ownOrigin); +} + async function handleMessage (message, sender) { const { type, origin } = message; if (DEBUG) console.log('[Podkey] Message received:', type, 'from', origin || 'popup'); + if (EXTENSION_UI_ONLY_TYPES.has(type) && !isExtensionUiSender(sender)) { + throw new Error(`${type} is not allowed from web content`); + } + switch (type) { case 'GET_PUBLIC_KEY': return await handleGetPublicKey(origin, sender); @@ -103,6 +137,9 @@ async function handleMessage (message, sender) { case 'GET_KEYPAIR_STATUS': return await handleGetKeypairStatus(); + case 'SET_SESSION_KEY': + return await handleSetSessionKey(message.privateKey); + case 'NIP44_ENCRYPT': return await handleNip44Encrypt(message.pubkey, message.plaintext, origin); @@ -185,10 +222,11 @@ function awaitUnlock () { async function ensureUnlocked () { if (await hasKeypair()) return; - if (await hasVault()) { + const { podkey_passkey: passkey } = await chrome.storage.local.get(['podkey_passkey']); + if ((await hasVault()) || passkey) { const unlocked = await awaitUnlock(); if (unlocked && (await hasKeypair())) return; - throw new Error('Podkey is locked. Open Podkey, unlock with your passphrase, and try again.'); + throw new Error('Podkey is locked. Open Podkey, unlock it, and try again.'); } throw new Error('No key in Podkey. Open the extension to generate or import a key first.'); } @@ -442,6 +480,17 @@ async function handleUnlockVault (passphrase) { }; } +async function handleSetSessionKey (privateKey) { + const hexKey = normalizeSecretKeyToHex(privateKey); + const publicKey = getPublicKey(hexKey); + const storedPublicKey = await getStoredPublicKey(); + if (storedPublicKey && storedPublicKey !== publicKey) { + throw new Error('Passkey produced a different identity'); + } + await storeKeypair(hexKey, publicKey); + return { state: 'unlocked', exists: true, publicKey, did: `did:nostr:${publicKey}` }; +} + /** * Lock the vault: drop the in-memory key but keep the encrypted vault on disk. */ @@ -469,7 +518,8 @@ async function handleGetKeypairStatus () { }; } - if (await hasVault()) { + const { podkey_passkey: passkey } = await chrome.storage.local.get(['podkey_passkey']); + if ((await hasVault()) || passkey) { const publicKey = await getStoredPublicKey(); return { state: 'locked', @@ -479,6 +529,13 @@ async function handleGetKeypairStatus () { }; } + // No session key, vault, or passkey config: a stored public key here is an + // orphan from an interrupted setup. Clear it so it cannot later trip the + // SET_SESSION_KEY different-identity guard against a fresh derivation. + if (await getStoredPublicKey()) { + await chrome.storage.local.remove(['podkey_public_key']); + } + return { state: 'none', exists: false }; } diff --git a/src/keyformat.js b/src/keyformat.js index a089606..c37701e 100644 --- a/src/keyformat.js +++ b/src/keyformat.js @@ -130,6 +130,38 @@ export function nsecToHex (nsec) { return bytesToHex(bytes); } +/** + * Encode 5-bit words as a bech32 string with the BIP-173 checksum — the exact + * inverse of bech32Decode above, sharing its polymod/hrpExpand/CHARSET. + * @param {string} hrp + * @param {number[]} words + * @returns {string} + */ +function bech32Encode (hrp, words) { + const values = hrpExpand(hrp).concat(words, [0, 0, 0, 0, 0, 0]); + const mod = polymod(values) ^ BECH32_CONST; + let out = hrp + '1'; + for (const word of words) out += CHARSET[word]; + for (let i = 0; i < 6; i++) out += CHARSET[(mod >>> (5 * (5 - i))) & 31]; + return out; +} + +/** + * Encode a 64-char hex private key as its NIP-19 `nsec1…` form — the standard + * interchange format Nostr apps expect, so an exported backup can be pasted + * anywhere (including back into Podkey, whose import accepts both forms). + * @param {string} hex 64-char hex private key + * @returns {string} nsec1… bech32 string + */ +export function hexToNsec (hex) { + if (typeof hex !== 'string' || !/^[0-9a-fA-F]{64}$/.test(hex)) { + throw new Error('Invalid key'); + } + const bytes = []; + for (let i = 0; i < 64; i += 2) bytes.push(parseInt(hex.slice(i, i + 2), 16)); + return bech32Encode('nsec', convertBits(bytes, 8, 5, true)); +} + /** * Normalise a pasted private key into canonical 64-char lowercase hex, accepting * either raw hex or an `nsec1…` bech32 key. The nsec form is converted inline so diff --git a/src/passkey.js b/src/passkey.js new file mode 100644 index 0000000..bba7414 --- /dev/null +++ b/src/passkey.js @@ -0,0 +1,116 @@ +import { bytesToHex, hexToBytes, randomBytes } from '@noble/hashes/utils'; +import { getPublicKey } from './crypto.js'; + +export const PASSKEY_CONFIG_KEY = 'podkey_passkey'; + +// HKDF domain separation. The derive path (a Nostr identity computed from the +// PRF output — the cross-implementation KDF contract other Podkey-compatible +// clients must match byte-for-byte) and the wrap path (an AES key that merely +// encrypts an existing identity) use distinct info strings so the two can +// never yield the same bytes, whatever the salts. +const DERIVE_INFO = new TextEncoder().encode('podkey/nostr-secret/v1'); +const WRAP_INFO = new TextEncoder().encode('podkey/wrap/v1'); + +export function toBase64Url (bytes) { + let binary = ''; + for (const byte of new Uint8Array(bytes)) binary += String.fromCharCode(byte); + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); +} + +export function fromBase64Url (value) { + const base64 = value.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(value.length / 4) * 4, '='); + const binary = atob(base64); + return Uint8Array.from(binary, char => char.charCodeAt(0)); +} + +async function hkdf (secret, salt, info) { + const key = await crypto.subtle.importKey('raw', secret, 'HKDF', false, ['deriveBits']); + return new Uint8Array(await crypto.subtle.deriveBits( + { name: 'HKDF', hash: 'SHA-256', salt, info }, key, 256 + )); +} + +export async function deriveNostrKey (prfOutput, derivationSalt) { + for (let counter = 0; counter < 256; counter++) { + const info = new Uint8Array(DERIVE_INFO.length + 1); + info.set(DERIVE_INFO); + info[DERIVE_INFO.length] = counter; + const candidate = bytesToHex(await hkdf(prfOutput, derivationSalt, info)); + try { + getPublicKey(candidate); + return candidate; + } catch { + // The negligible invalid-scalar case deterministically advances counter. + } + } + throw new Error('Could not derive a valid Nostr key'); +} + +export async function wrapPrivateKey (privateKeyHex, prfOutput) { + const salt = randomBytes(32); + const iv = randomBytes(12); + const wrappingBytes = await hkdf(prfOutput, salt, WRAP_INFO); + const key = await crypto.subtle.importKey('raw', wrappingBytes, 'AES-GCM', false, ['encrypt']); + const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, hexToBytes(privateKeyHex)); + return { salt: toBase64Url(salt), iv: toBase64Url(iv), ct: toBase64Url(ct) }; +} + +export async function unwrapPrivateKey (wrapped, prfOutput) { + try { + const wrappingBytes = await hkdf(prfOutput, fromBase64Url(wrapped.salt), WRAP_INFO); + const key = await crypto.subtle.importKey('raw', wrappingBytes, 'AES-GCM', false, ['decrypt']); + const plaintext = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv: fromBase64Url(wrapped.iv) }, key, fromBase64Url(wrapped.ct) + ); + return bytesToHex(new Uint8Array(plaintext)); + } catch { + throw new Error('Passkey could not unlock this identity'); + } +} + +export function newPasskeySalt () { + return randomBytes(32); +} + +// rp.id is deliberately omitted below, so Chrome binds the credential to the +// extension origin (chrome-extension://). The extension ID therefore IS +// part of the identity: an unpacked/dev install has a different ID than the +// Web Store build and cannot resolve credentials created under the other. Dev +// installs that need passkey parity must pin the ID via a manifest `key`. +// +// The PRF extension is requested at creation so the authenticator provisions +// it, but the creation-time PRF output is never used for key material: some +// 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. +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 } } } + } }); + if (!credential) throw new Error('Passkey creation was cancelled'); + 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 } } } + } }); + const output = assertion?.getClientExtensionResults().prf?.results?.first; + if (!output) throw new Error('This passkey does not support secure key derivation (PRF)'); + return new Uint8Array(output); +} diff --git a/store/SUBMISSION.md b/store/SUBMISSION.md index fdf6aae..27697d8 100644 --- a/store/SUBMISSION.md +++ b/store/SUBMISSION.md @@ -17,7 +17,7 @@ version 0.0.8. The uploadable ZIP is produced by the build, with test files excluded: ```bash -npm run build # bundles deps into src/background.bundle.js +npm run build # bundles the background worker and popup zip -r podkey-extension.zip manifest.json src popup icons -x '*.test.js' ``` diff --git a/test/keyformat.test.js b/test/keyformat.test.js index 6b36dc0..da5bbb7 100644 --- a/test/keyformat.test.js +++ b/test/keyformat.test.js @@ -1,6 +1,6 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { nsecToHex, normalizeSecretKeyToHex } from '../src/keyformat.js'; +import { hexToNsec, nsecToHex, normalizeSecretKeyToHex } from '../src/keyformat.js'; // Canonical NIP-19 spec test vector. const NSEC = 'nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5'; @@ -54,3 +54,18 @@ test('rejects non-string input', () => { assert.throws(() => normalizeSecretKeyToHex(null), /Invalid key/); assert.throws(() => normalizeSecretKeyToHex(undefined), /Invalid key/); }); + +test('hexToNsec encodes the NIP-19 spec vector', () => { + assert.equal(hexToNsec(HEX), NSEC); +}); + +test('hexToNsec accepts uppercase hex and round-trips through nsecToHex', () => { + assert.equal(nsecToHex(hexToNsec(HEX.toUpperCase())), HEX); +}); + +test('hexToNsec rejects non-hex and wrong-length input', () => { + assert.throws(() => hexToNsec(HEX.slice(0, 63)), /Invalid key/); + assert.throws(() => hexToNsec(HEX + '00'), /Invalid key/); + assert.throws(() => hexToNsec('zz' + HEX.slice(2)), /Invalid key/); + assert.throws(() => hexToNsec(null), /Invalid key/); +}); diff --git a/test/passkey.test.js b/test/passkey.test.js new file mode 100644 index 0000000..460ae9a --- /dev/null +++ b/test/passkey.test.js @@ -0,0 +1,81 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { webcrypto } from 'node:crypto'; +import { getPublicKey } from '../src/crypto.js'; +import { + deriveNostrKey, + fromBase64Url, + toBase64Url, + unwrapPrivateKey, + wrapPrivateKey +} from '../src/passkey.js'; + +globalThis.crypto ??= webcrypto; +globalThis.btoa ??= value => Buffer.from(value, 'binary').toString('base64'); +globalThis.atob ??= value => Buffer.from(value, 'base64').toString('binary'); + +describe('passkey key material', () => { + it('round-trips binary values through base64url', () => { + const bytes = Uint8Array.from([0, 1, 2, 250, 255]); + assert.deepEqual(fromBase64Url(toBase64Url(bytes)), bytes); + }); + + it('derives the same valid Nostr key from the same PRF output and salt', async () => { + const prf = new Uint8Array(32).fill(7); + const salt = new Uint8Array(32).fill(11); + const first = await deriveNostrKey(prf, salt); + const second = await deriveNostrKey(prf, salt); + assert.equal(first, second); + assert.match(first, /^[0-9a-f]{64}$/); + assert.match(getPublicKey(first), /^[0-9a-f]{64}$/); + }); + + it('domain-separates identities with different salts', async () => { + const prf = new Uint8Array(32).fill(7); + assert.notEqual( + await deriveNostrKey(prf, new Uint8Array(32).fill(1)), + await deriveNostrKey(prf, new Uint8Array(32).fill(2)) + ); + }); + + it('wraps and unwraps an existing Nostr key', async () => { + const privateKey = '01'.padStart(64, '0'); + const prf = new Uint8Array(32).fill(9); + const wrapped = await wrapPrivateKey(privateKey, prf); + assert.equal(await unwrapPrivateKey(wrapped, prf), privateKey); + await assert.rejects(() => unwrapPrivateKey(wrapped, new Uint8Array(32).fill(8)), /could not unlock/); + }); + + it('rejects tampered wrapped ciphertext', async () => { + const prf = new Uint8Array(32).fill(9); + const wrapped = await wrapPrivateKey('02'.padStart(64, '0'), prf); + const bytes = fromBase64Url(wrapped.ct); + bytes[0] ^= 1; + await assert.rejects(() => unwrapPrivateKey({ ...wrapped, ct: toBase64Url(bytes) }, prf), /could not unlock/); + }); +}); + +describe('wrap freshness and domain separation', () => { + it('uses a fresh random salt and iv for every wrap', async () => { + const prf = new Uint8Array(32).fill(9); + const privateKey = '03'.padStart(64, '0'); + const a = await wrapPrivateKey(privateKey, prf); + const b = await wrapPrivateKey(privateKey, prf); + assert.notEqual(a.salt, b.salt); + assert.notEqual(a.iv, b.iv); + assert.notEqual(a.ct, b.ct); + assert.equal(await unwrapPrivateKey(a, prf), privateKey); + assert.equal(await unwrapPrivateKey(b, prf), privateKey); + }); + + it('never derives an identity equal to the raw PRF output or wrap key path', async () => { + // Sanity check on domain separation: the derived identity must not be a + // trivial function of the inputs (distinct info strings guarantee the + // derive and wrap HKDF outputs differ even for identical prf and salt). + const prf = new Uint8Array(32).fill(5); + const salt = new Uint8Array(32).fill(6); + const derived = await deriveNostrKey(prf, salt); + assert.notEqual(derived, Buffer.from(prf).toString('hex')); + assert.notEqual(derived, Buffer.from(salt).toString('hex')); + }); +}); diff --git a/test/set-session-key.test.js b/test/set-session-key.test.js new file mode 100644 index 0000000..1d480c7 --- /dev/null +++ b/test/set-session-key.test.js @@ -0,0 +1,156 @@ +/** + * Tests for the SET_SESSION_KEY background contract (passkey unlock/creation) + * and the surrounding state-consistency guarantees: + * + * - extension-UI-only guard: privileged message types are rejected when the + * message arrives via a content script (sender.tab set) — defence-in-depth + * behind the injected.js whitelist + * - first use: a valid key is stored and reported as unlocked with the + * matching pubkey / did + * - identity guard: a key that derives a different pubkey than the stored + * identity is rejected ("different identity") + * - orphan cleanup: a stored public key with no vault, passkey config, or + * session key is removed when status is checked + * + * background.js exports nothing and wires its listener onto chrome.runtime at + * import time, so these tests mock `chrome`, import the module, and drive the + * captured onMessage listener directly (same harness as consent-approval). + */ + +import { describe, it, beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; + +function makeArea (store) { + return { + get: async (keys) => { + const result = {}; + if (Array.isArray(keys)) { + keys.forEach((k) => { result[k] = store[k]; }); + } else if (keys) { + Object.keys(keys).forEach((k) => { result[k] = store[k] ?? keys[k]; }); + } else { + return { ...store }; + } + return result; + }, + set: async (items) => { Object.assign(store, items); }, + remove: async (keys) => { + (Array.isArray(keys) ? keys : [keys]).forEach((k) => delete store[k]); + } + }; +} + +const stores = { local: {}, session: {} }; +const captured = { onMessage: null }; + +const EXT_ID = 'podkeytestextensionid'; +global.chrome = { + runtime: { + id: EXT_ID, + onInstalled: { addListener: () => {} }, + onMessage: { addListener: (fn) => { captured.onMessage = fn; } }, + lastError: null + }, + windows: { + create: () => Promise.resolve({ id: 1 }) + }, + storage: { + local: makeArea(stores.local), + session: makeArea(stores.session) + } +}; + +await import('../src/background.js'); +const { generateKeypair, getPublicKey } = await import('../src/crypto.js'); + +const onMessage = captured.onMessage; + +// Default sender = one of our own extension pages (the action popup or the +// dedicated ceremony window opened via chrome.windows.create — both report a +// chrome-extension URL for our own id). +const EXT_UI_SENDER = { id: EXT_ID, url: `chrome-extension://${EXT_ID}/popup/popup.html` }; + +/** Send a message to the background and resolve with sendResponse's value. */ +function send (message, sender = EXT_UI_SENDER) { + return new Promise((resolve) => { onMessage(message, sender, resolve); }); +} + +describe('SET_SESSION_KEY contract', () => { + beforeEach(() => { + for (const k of Object.keys(stores.local)) delete stores.local[k]; + for (const k of Object.keys(stores.session)) delete stores.session[k]; + }); + + it('rejects privileged types from a content script (own id, but a web-page URL)', async () => { + const { privateKey } = await generateKeypair(); + // A content script runs with our extension id in sender.id, but sender.url + // is the host page — this must NOT be accepted. + const contentScript = { id: EXT_ID, url: 'https://evil.example/app', tab: { id: 1 } }; + for (const type of ['SET_SESSION_KEY', 'UNLOCK_VAULT', 'GENERATE_KEYPAIR', 'IMPORT_KEYPAIR', 'LOCK_VAULT', 'GET_KEYPAIR_STATUS']) { + const response = await send({ type, privateKey, passphrase: 'irrelevant' }, contentScript); + assert.match(response.error, /not allowed from web content/, type); + } + assert.equal(stores.session.podkey_private_key, undefined); + assert.equal(stores.local.podkey_public_key, undefined); + }); + + it('accepts privileged types from the ceremony window (a real tab, own extension URL)', async () => { + const { privateKey, publicKey } = await generateKeypair(); + // chrome.windows.create popups have a sender.tab AND our extension URL — the + // guard must allow these, or every passkey unlock breaks. + const ceremonyWindow = { id: EXT_ID, url: `chrome-extension://${EXT_ID}/popup/popup.html?flow=create`, tab: { id: 9 } }; + const response = await send({ type: 'SET_SESSION_KEY', privateKey }, ceremonyWindow); + assert.equal(response.state, 'unlocked'); + assert.equal(response.publicKey, publicKey); + }); + + it('stores a first-use key and reports it unlocked', async () => { + const { privateKey, publicKey } = await generateKeypair(); + const response = await send({ type: 'SET_SESSION_KEY', privateKey }); + assert.equal(response.state, 'unlocked'); + assert.equal(response.publicKey, publicKey); + assert.equal(response.did, `did:nostr:${publicKey}`); + assert.equal(stores.session.podkey_private_key, privateKey); + assert.equal(stores.local.podkey_public_key, publicKey); + }); + + it('accepts the same identity again (re-unlock) without error', async () => { + const { privateKey } = await generateKeypair(); + await send({ type: 'SET_SESSION_KEY', privateKey }); + delete stores.session.podkey_private_key; // simulate lock / browser restart + const response = await send({ type: 'SET_SESSION_KEY', privateKey }); + assert.equal(response.state, 'unlocked'); + assert.equal(response.publicKey, getPublicKey(privateKey)); + }); + + it('rejects a key that derives a different identity than the stored one', async () => { + const existing = await generateKeypair(); + stores.local.podkey_public_key = existing.publicKey; + const other = await generateKeypair(); + const response = await send({ type: 'SET_SESSION_KEY', privateKey: other.privateKey }); + assert.match(response.error, /different identity/); + assert.equal(stores.session.podkey_private_key, undefined); + }); + + it('rejects malformed key material', async () => { + const response = await send({ type: 'SET_SESSION_KEY', privateKey: 'not-a-key' }); + assert.ok(response.error); + assert.equal(stores.session.podkey_private_key, undefined); + }); + + it('GET_KEYPAIR_STATUS clears an orphaned public key (no vault, passkey, or session)', async () => { + stores.local.podkey_public_key = 'a'.repeat(64); + const response = await send({ type: 'GET_KEYPAIR_STATUS' }); + assert.equal(response.state, 'none'); + assert.equal(stores.local.podkey_public_key, undefined); + }); + + it('GET_KEYPAIR_STATUS keeps the public key for a locked passkey identity', async () => { + stores.local.podkey_public_key = 'b'.repeat(64); + stores.local.podkey_passkey = { v: 1, mode: 'derived', credentialId: 'x', prfSalt: 'y', derivationSalt: 'z' }; + const response = await send({ type: 'GET_KEYPAIR_STATUS' }); + assert.equal(response.state, 'locked'); + assert.equal(response.publicKey, 'b'.repeat(64)); + assert.equal(stores.local.podkey_public_key, 'b'.repeat(64)); + }); +}); From d3b96cd585d1407f5559cffef1173c9160a8c559 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Wed, 12 Aug 2026 20:43:52 +0000 Subject: [PATCH 2/2] feat(popup): frame passkey identity as the advanced tier for agents/regulated use MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Name the audience on the advanced-identity disclosure so the higher-friction FIDO2 path reads as a deliberate best-practice choice (agent management, compliance) rather than an unexplained option — consistent with the framing used on the forum and the dreamlab-ai site. Co-Authored-By: jjohare --- popup/popup.css | 1 + popup/popup.html | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/popup/popup.css b/popup/popup.css index c95c59c..a9bc776 100644 --- a/popup/popup.css +++ b/popup/popup.css @@ -277,6 +277,7 @@ h2 { .advanced-choice { margin-top: 12px; color: var(--text-muted); font-size: 12px; } .advanced-choice summary { cursor: pointer; text-align: center; font-weight: 600; color: var(--accent); } .advanced-choice p { margin-top: 7px; line-height: 1.45; } +.advanced-choice .advanced-audience { color: var(--text-muted); border-left: 2px solid var(--border-strong); padding-left: 9px; } .text-action { width: 100%; margin-top: 10px; padding: 9px; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); background: var(--surface); color: var(--text); font: inherit; font-weight: 600; cursor: pointer; } .divider { display: flex; align-items: center; gap: 8px; margin: 12px 0; color: var(--text-faint); font-size: 10px; text-transform: uppercase; letter-spacing: .06em; } .divider::before, .divider::after { content: ''; height: 1px; flex: 1; background: var(--border); } diff --git a/popup/popup.html b/popup/popup.html index 7defad7..8c47b45 100644 --- a/popup/popup.html +++ b/popup/popup.html @@ -42,7 +42,8 @@

Welcome to Podkey

Import existing key
- Advanced identity options + Advanced: passkey identity +

For managing agents or working under compliance rules: bind your key to a FIDO2 passkey — hardware-backed, phishing-resistant, and unlocked with biometrics or a security key.

No passphrase. Your passkey recreates the same Nostr key. Export a backup in case the passkey is lost.