From fe710cca6f3eb203dd9f42e7aa69822790fb89f2 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Thu, 24 Sep 2026 16:25:07 +0000 Subject: [PATCH 1/5] =?UTF-8?q?fix(passkey):=20hardware=20keys=20=E2=80=94?= =?UTF-8?q?=20transport=20hints,=20clearer=20errors,=20testable=20ceremony?= =?UTF-8?q?=20options?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Store the transports a credential reports and pass them in allowCredentials, so unlock goes straight to the security key instead of the generic chooser. Name the failed step and what to do (PIN, touch, keep the window in front); own messages for InvalidStateError, NotSupportedError and SecurityError. Three-minute timeouts; EdDSA and RS256 offered after ES256. UV stays 'required' on both ceremonies (hmac-secret differs with and without UV). creationOptions/assertionOptions/cleanTransports exported and tested. Co-Authored-By: jjohare --- src/passkey.js | 129 ++++++++++++++++++++++++++++++------------- test/passkey.test.js | 76 +++++++++++++++++++++++++ 2 files changed, 167 insertions(+), 38 deletions(-) diff --git a/src/passkey.js b/src/passkey.js index 3ec05dc..9706037 100644 --- a/src/passkey.js +++ b/src/passkey.js @@ -89,46 +89,103 @@ const PRF_UNSUPPORTED_MESSAGE = 'Try a phone passkey or a modern security key that supports PRF, or create a ' + 'passphrase-based key instead.'; +// Transports an authenticator can report (WebAuthn AuthenticatorTransport). +// Passing the ones a credential reported back in allowCredentials lets the +// browser go straight to "insert and touch your security key" (or NFC) +// instead of opening its generic chooser, which on desktop leads with the +// phone/QR option — where hardware-key users got lost. +export const KNOWN_TRANSPORTS = ['usb', 'nfc', 'ble', 'hybrid', 'internal', 'smart-card']; + +export function cleanTransports (transports) { + if (!Array.isArray(transports)) return []; + return [...new Set(transports.filter(t => KNOWN_TRANSPORTS.includes(t)))]; +} + // 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')) { +// authenticator, a wrong PIN, and the document not having focus. Name the +// likely causes for the step that failed without over-claiming which one +// occurred; pass any other error through unchanged. +export function translateCeremonyError (err, step = 'unlock') { + const name = err?.name; + if (name === 'NotAllowedError' || name === 'AbortError') { + const what = step === 'register' + ? 'Registering the passkey was cancelled or timed out.' + : 'The passkey step was cancelled or timed out.'; 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.' + `${what} With a security key: insert it, enter its PIN if asked, and touch it when it flashes. ` + + 'Keep this window in front while you do, then try again.' ); } + if (name === 'InvalidStateError') { + return new Error('This authenticator already holds a Podkey passkey. Use it to unlock, or choose a different key.'); + } + if (name === 'NotSupportedError') { + return new Error('This authenticator cannot make the kind of passkey Podkey needs. Try a phone passkey or a newer security key.'); + } + if (name === 'SecurityError') { + return new Error('The browser refused the passkey request for this extension. Reload Podkey and try again.'); + } return err instanceof Error ? err : new Error(String(err?.message || err)); } -export async function createPasskey (prfSalt, label = 'Podkey identity') { - if (!window.PublicKeyCredential || !navigator.credentials) { +// userVerification is 'required' on both ceremonies, and must stay equal +// between them: CTAP2 hmac-secret derives from a different per-credential +// secret with and without user verification, so a key registered with UV but +// asserted without it (or the reverse) returns a different PRF output — a +// different Nostr identity, or an unwrap failure. For a security key, UV is +// its PIN; the browser asks the person to set one if the key has none. +export function creationOptions (prfSalt, label = 'Podkey identity') { + return { + challenge: randomBytes(32), + user: { id: randomBytes(32), name: 'podkey', displayName: label }, + rp: { name: 'Podkey' }, + // ES256 first (every FIDO2 key), then EdDSA and RS256 so an authenticator + // that offers only those (some Windows Hello TPMs: RS256) is not refused. + pubKeyCredParams: [ + { type: 'public-key', alg: -7 }, + { type: 'public-key', alg: -8 }, + { type: 'public-key', alg: -257 } + ], + // 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, uses one of a security key's few + // resident slots 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', requireResidentKey: false, userVerification: 'required' }, + // Three minutes: long enough to find a key, set a first PIN and touch it. + timeout: 180000, + attestation: 'none', + extensions: { prf: { eval: { first: prfSalt } } } + }; +} + +export function assertionOptions (credentialId, prfSalt, transports = []) { + const id = typeof credentialId === 'string' ? fromBase64Url(credentialId) : credentialId; + const hint = cleanTransports(transports); + return { + challenge: randomBytes(32), + allowCredentials: [{ type: 'public-key', id, ...(hint.length ? { transports: hint } : {}) }], + userVerification: 'required', + timeout: 180000, + extensions: { prf: { eval: { first: prfSalt } } } + }; +} + +function requireWebAuthn () { + if (!globalThis.PublicKeyCredential || !globalThis.navigator?.credentials) { throw new Error('Passkeys are not supported by this browser'); } +} + +export async function createPasskey (prfSalt, label = 'Podkey identity') { + requireWebAuthn(); 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 } } } - } }); + credential = await navigator.credentials.create({ publicKey: creationOptions(prfSalt, label) }); } catch (err) { - throw translateCeremonyError(err); + throw translateCeremonyError(err, 'register'); } if (!credential) throw new Error('Passkey creation was cancelled'); // Definitive PRF-support signal: with prf requested at creation, the client @@ -139,22 +196,18 @@ export async function createPasskey (prfSalt, label = 'Podkey identity') { if (!prf || prf.enabled !== true) { throw new Error(PRF_UNSUPPORTED_MESSAGE); } - return { credentialId: toBase64Url(new Uint8Array(credential.rawId)) }; + let transports = []; + try { transports = cleanTransports(credential.response?.getTransports?.()); } catch { /* only a hint */ } + return { credentialId: toBase64Url(new Uint8Array(credential.rawId)), transports }; } -export async function getPasskeyPrf (credentialId, prfSalt) { - const id = typeof credentialId === 'string' ? fromBase64Url(credentialId) : credentialId; +export async function getPasskeyPrf (credentialId, prfSalt, transports = []) { + requireWebAuthn(); 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 } } } - } }); + assertion = await navigator.credentials.get({ publicKey: assertionOptions(credentialId, prfSalt, transports) }); } catch (err) { - throw translateCeremonyError(err); + throw translateCeremonyError(err, 'unlock'); } const output = assertion?.getClientExtensionResults().prf?.results?.first; if (!output) throw new Error(PRF_UNSUPPORTED_MESSAGE); diff --git a/test/passkey.test.js b/test/passkey.test.js index 460ae9a..3d315e5 100644 --- a/test/passkey.test.js +++ b/test/passkey.test.js @@ -3,9 +3,13 @@ import assert from 'node:assert/strict'; import { webcrypto } from 'node:crypto'; import { getPublicKey } from '../src/crypto.js'; import { + assertionOptions, + cleanTransports, + creationOptions, deriveNostrKey, fromBase64Url, toBase64Url, + translateCeremonyError, unwrapPrivateKey, wrapPrivateKey } from '../src/passkey.js'; @@ -79,3 +83,75 @@ describe('wrap freshness and domain separation', () => { assert.notEqual(derived, Buffer.from(salt).toString('hex')); }); }); + +describe('ceremony options (hardware security keys)', () => { + const salt = new Uint8Array(32).fill(4); + + it('requires user verification on both ceremonies, so hmac-secret output is stable', () => { + // CTAP2 hmac-secret uses a different secret with and without UV: the two + // ceremonies must agree or unlock yields a different key. + assert.equal(creationOptions(salt).authenticatorSelection.userVerification, 'required'); + assert.equal(assertionOptions('AAAA', salt).userVerification, 'required'); + }); + + it('asks for a non-resident credential with PRF', () => { + const o = creationOptions(salt, 'Podkey unlock'); + assert.equal(o.authenticatorSelection.residentKey, 'discouraged'); + assert.equal(o.authenticatorSelection.requireResidentKey, false); + assert.deepEqual(o.extensions.prf.eval.first, salt); + assert.equal(o.user.displayName, 'Podkey unlock'); + assert.equal(o.rp.id, undefined, 'rp.id stays unset so the credential binds to the extension origin'); + }); + + it('offers ES256 first, then EdDSA and RS256', () => { + assert.deepEqual(creationOptions(salt).pubKeyCredParams.map(p => p.alg), [-7, -8, -257]); + }); + + it('gives a security key at least three minutes', () => { + assert.ok(creationOptions(salt).timeout >= 180000); + assert.ok(assertionOptions('AAAA', salt).timeout >= 180000); + }); + + it('passes the stored transports so the browser goes straight to the key', () => { + const o = assertionOptions(toBase64Url(Uint8Array.from([1, 2, 3])), salt, ['usb', 'nfc']); + assert.deepEqual(o.allowCredentials[0].transports, ['usb', 'nfc']); + assert.deepEqual(o.allowCredentials[0].id, Uint8Array.from([1, 2, 3])); + assert.deepEqual(o.extensions.prf.eval.first, salt); + }); + + it('omits transports for a passkey set up before they were stored', () => { + assert.equal('transports' in assertionOptions('AAAA', salt).allowCredentials[0], false); + assert.equal('transports' in assertionOptions('AAAA', salt, undefined).allowCredentials[0], false); + }); + + it('keeps only known transports, once each', () => { + assert.deepEqual(cleanTransports(['usb', 'usb', 'bogus', 'nfc', 7]), ['usb', 'nfc']); + assert.deepEqual(cleanTransports('usb'), []); + }); +}); + +describe('ceremony errors', () => { + const named = (name) => Object.assign(new Error('x'), { name }); + + it('tells a security-key user what to do after a cancel or timeout', () => { + const e = translateCeremonyError(named('NotAllowedError'), 'unlock'); + assert.match(e.message, /PIN/); + assert.match(e.message, /touch it/); + assert.doesNotMatch(e.message, /twice/, 'an unlock prompts once'); + }); + + it('names the registration step when that is what failed', () => { + assert.match(translateCeremonyError(named('AbortError'), 'register').message, /Registering/); + }); + + it('explains InvalidStateError and NotSupportedError', () => { + assert.match(translateCeremonyError(named('InvalidStateError')).message, /already holds/); + assert.match(translateCeremonyError(named('NotSupportedError')).message, /cannot make/); + }); + + it('passes other errors through', () => { + const original = new Error('boom'); + assert.equal(translateCeremonyError(original), original); + assert.equal(translateCeremonyError('plain').message, 'plain'); + }); +}); From 08869a95816b596fb03725f65e3e65e01f2f81aa Mon Sep 17 00:00:00 2001 From: Claude Code Date: Thu, 24 Sep 2026 16:25:14 +0000 Subject: [PATCH 2/5] fix(popup): passkey windows wait for focus; inline status and retry; look and feel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - a ?flow= window waits for focus before its ceremony: WebAuthn refused the unfocused new window with NotAllowedError, the main security-key snag - inline passkey status (step 1 of 2 / 2 of 2, errors with Try again) instead of alert(); transports stored in the passkey config - a window opened only to unlock closes once unlocked - [hidden] restored over .btn: 'Unlock with passkey' showed with no passkey - Set up button no longer stuck on 'Waiting…' after a failure - approve prompt says approving also trusts the site; export shows nsec - plainer UK copy; text contrast and a dark-mode primary fill that pass; focus rings on every control; popup debug logging removed Co-Authored-By: jjohare --- CHANGELOG.md | 33 +++++++++++ popup/approve.css | 2 +- popup/approve.html | 2 +- popup/popup.css | 75 ++++++++++++++++++++++--- popup/popup.html | 28 ++++++---- popup/popup.js | 135 +++++++++++++++++++++++++++++++-------------- 6 files changed, 213 insertions(+), 62 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c7a8acd..7dc9cdf 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] +### Fixed + +- **Security keys: passkey windows no longer fail straight away.** A passkey + window started its WebAuthn ceremony on `DOMContentLoaded`, often before the + new window had focus, and WebAuthn refuses an unfocused document with the same + `NotAllowedError` as a cancel. Ceremonies now wait for focus. +- **Security keys: the browser goes straight to the key.** The transports a + credential reports (`usb`, `nfc`, …) are stored and passed in + `allowCredentials`, so unlock asks to insert and touch the key instead of + opening the generic chooser that leads with a phone/QR option. Passkeys set up + before this keep working without the hint. +- **"Unlock with passkey" showed with no passkey set up.** `.btn` overrode the + `hidden` attribute; a global `[hidden]` rule restores it. +- Ceremony errors name the step and say what to do (PIN, touch, keep the window + in front); `InvalidStateError`, `NotSupportedError` and `SecurityError` get + their own messages. Failures show inline with **Try again** instead of an + `alert()` over a dead window, and set-up says which of its two touches is next. +- Ceremony timeouts are three minutes (time to find a key and set a first PIN); + EdDSA and RS256 are offered after ES256 for authenticators that lack ES256. +- A window opened only to unlock closes itself once unlocked, handing focus back + to the site that asked. +- The **Set up** passkey button no longer stays on "Waiting…" after a failure. + +### Changed + +- The first-approval prompt says that approving also trusts the site from then + on (it always did; the prompt did not say so). +- Export shows the key as `nsec`, the form other apps and Podkey's import take. +- Plainer copy on the welcome, import and passkey screens; import says it takes + `nsec` or hex. Stronger text contrast in light and dark, a primary-button fill + that passes contrast in dark mode, and keyboard focus rings on every control. + Debug logging removed from the popup. + ### Added - **FIDO2 / WebAuthn passkey master identity (advanced).** Create a Nostr diff --git a/popup/approve.css b/popup/approve.css index 3f042c9..e0e0992 100644 --- a/popup/approve.css +++ b/popup/approve.css @@ -220,7 +220,7 @@ body { justify-content: center; gap: 6px; font-size: 11px; - color: var(--text-faint); + color: var(--text-muted); margin-bottom: 7px; } diff --git a/popup/approve.html b/popup/approve.html index 29b0099..0136260 100644 --- a/popup/approve.html +++ b/popup/approve.html @@ -46,7 +46,7 @@ - Approving lets this site act with your key as if it were you. + Approving also trusts this site: from now on it can sign and read messages as you without asking. You can remove it under Trusted sites in Podkey.
diff --git a/popup/popup.css b/popup/popup.css index 89bab18..299d032 100644 --- a/popup/popup.css +++ b/popup/popup.css @@ -14,8 +14,9 @@ /* Text */ --text: #1e293b; - --text-muted: #64748b; - --text-faint: #94a3b8; + --text-muted: #5b6b80; + /* decorative text only (placeholders, separators); never body copy */ + --text-faint: #8391a5; /* Borders */ --border: #e2e8f0; @@ -26,6 +27,9 @@ --accent-hover: #1d4ed8; --accent-soft: #eff6ff; --accent-ring: rgba(37, 99, 235, 0.25); + /* fill behind white text: dark mode's lighter --accent fails contrast there */ + --accent-solid: #2563eb; + --accent-solid-hover: #1d4ed8; /* Semantic */ --safe: #16a34a; @@ -64,8 +68,8 @@ --surface-inset: #0f151d; --text: #e6eaf0; - --text-muted: #9aa6b6; - --text-faint: #6b7888; + --text-muted: #a3afbf; + --text-faint: #7b8898; --border: #2a3340; --border-strong: #3a4452; @@ -74,6 +78,8 @@ --accent-hover: #74a0f4; --accent-soft: #16223a; --accent-ring: rgba(91, 141, 239, 0.35); + --accent-solid: #2f63d4; + --accent-solid-hover: #3a6fe0; --safe: #4ade80; --safe-hover: #6ee79a; @@ -246,13 +252,13 @@ h2 { } .btn-primary { - background: var(--accent); + background: var(--accent-solid); color: #fff; box-shadow: var(--shadow-sm); } .btn-primary:hover { - background: var(--accent-hover); + background: var(--accent-solid-hover); } .btn-secondary { @@ -279,13 +285,66 @@ h2 { .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 { display: flex; align-items: center; gap: 8px; margin: 12px 0; color: var(--text-muted); 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; } +/* Keyboard focus for every control that had none (small buttons, the + passkey summary, footer links, toggles). */ +.btn-small:focus-visible, +.text-action:focus-visible, +.btn-copy:focus-visible, +.btn-remove:focus-visible, +.advanced-choice summary:focus-visible, +.footer a:focus-visible, +.toggle input:focus-visible + .slider { + outline: none; + box-shadow: 0 0 0 3px var(--accent-ring); + border-radius: var(--radius-sm); +} + +.btn-small:hover, +.text-action:hover { + background: var(--surface-2); +} + +.btn-small[disabled], +.text-action[disabled] { + opacity: 0.55; + cursor: progress; +} + +/* ---- Passkey status line ---- */ +.flow-status { + margin: 12px 18px 0; + padding: 10px 12px; + border: 1px solid var(--accent-ring); + border-radius: var(--radius-sm); + background: var(--accent-soft); + color: var(--text); + font-size: 12.5px; + line-height: 1.45; +} + +.flow-status.error { + border-color: var(--danger-border); + background: var(--danger-soft); + color: var(--danger); +} + +.flow-status .btn { + margin: 10px 0 0; + padding: 8px 12px; + font-size: 13px; +} + +[hidden] { + display: none !important; +} + /* ---- Identity ---- */ .identity-section .section-title { margin-bottom: 10px; @@ -584,7 +643,7 @@ input:focus-visible + .slider { .empty-state { text-align: center; padding: 22px 16px; - color: var(--text-faint); + color: var(--text-muted); font-size: 12.5px; } diff --git a/popup/popup.html b/popup/popup.html index b816efe..11da8f4 100644 --- a/popup/popup.html +++ b/popup/popup.html @@ -7,6 +7,13 @@ + + + @@ -43,19 +50,18 @@

Welcome to Podkey

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.

+

For managing agents or working under compliance rules: tie your key to a passkey — a security key such as a YubiKey, or your phone or computer's own passkey.

-

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

+

No passphrase: your passkey recreates the same key each time. You'll touch a security key twice while setting up, and it needs a PIN (your browser helps you set one). Save the backup Podkey shows you, in case the passkey is lost.

What is Podkey?

- A browser extension for did:nostr and - Solid authentication. It holds your cryptographic - identity and signs in to Solid pods using your did:nostr identity — - your keys never leave this device. + Podkey holds your Nostr key and signs for you when a + site asks — posts, messages, and sign-in to Solid + pods. You approve each new site. Your key never leaves this device.

@@ -134,15 +140,15 @@

Unlock Podkey

Import private key

-

Enter your 64-character hex private key

+

Paste your nsec or 64-character hex key

- +