From 5570ef220aac2aaf191cddd7a4b641164f9d2110 Mon Sep 17 00:00:00 2001 From: Giuseppe Imperato Date: Tue, 9 Jun 2026 22:48:29 +0200 Subject: [PATCH] fix(security): harden browser IPC + extension consent/UX [v2.0] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cluster 6 — closes the 3 IPC vulnerabilities (external audit) and bundles the browser-extension enhancements so the extension is re-published only once. Security (SEC-01/02/03) — BrowserIpcService + IpcModels + 6x resw + extensions: - SEC-01: mandatory ECDH, no plaintext fallback (error 'ecdh-session-required'). - SEC-02: session bound to the handshake clientId (no cross-client reuse). - SEC-03: explicit user consent on password retrieval ("remember for this session", reset on vault lock); consent + ECDH sessions cleared on lock. - Hardening: unlock-vault rate-limit/lockout + cap on concurrent ECDH sessions. Extension (Chrome + Firefox, 6 languages): - T6.2 autofill success toast; T6.3 visible connection-status indicator. - Consent moved off popup-open (get-all-credentials no longer prompts); per-action request timeouts (consent/unlock) avoid false "not running". - Fixed an IPC deadlock: the consent dialog read a WinUI element off the UI thread (RPC_E_WRONG_THREAD) -> server hung; now captured via events. Interim: SEC-03 consent is the stopgap; SEC-05 (process attestation) will replace it frictionlessly (planned, Cluster 6.5). Adds scripts/ipc-security-test.ps1 (named-pipe harness, checklist Fase A/E). Build 0/0, tests 222/222, security harness all SECURE on a live build. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 23 ++ extensions/chrome/background.js | 16 +- extensions/chrome/lib/i18n.js | 6 + extensions/chrome/popup/popup.css | 25 ++ extensions/chrome/popup/popup.html | 6 +- extensions/chrome/popup/popup.js | 43 ++- extensions/firefox/background.js | 16 +- extensions/firefox/lib/i18n.js | 6 + extensions/firefox/popup/popup.css | 25 ++ extensions/firefox/popup/popup.html | 6 +- extensions/firefox/popup/popup.js | 43 ++- scripts/ipc-security-test.ps1 | 348 ++++++++++++++++++ .../Services/BrowserIpcService.cs | 215 +++++++++-- src/PassKey.Desktop/Services/IpcModels.cs | 7 + .../Strings/de-DE/Resources.resw | 18 + .../Strings/en-GB/Resources.resw | 18 + .../Strings/es-ES/Resources.resw | 18 + .../Strings/fr-FR/Resources.resw | 18 + .../Strings/it-IT/Resources.resw | 18 + .../Strings/pt-PT/Resources.resw | 18 + 20 files changed, 843 insertions(+), 50 deletions(-) create mode 100644 scripts/ipc-security-test.ps1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 516fc58..666a029 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Security +- **Browser IPC hardening (SEC-01/02/03)** on the Named Pipe channel used by the browser + extension: + - **SEC-01** — removed the plaintext password fallback: an ECDH session is now mandatory and + the password is always AES-GCM encrypted; requests without a valid session are rejected with + `ecdh-session-required`. + - **SEC-02** — sessions are bound to the exact extension `clientId` that performed the ECDH + handshake, so a session cannot be reused by a different client even if its id leaks. + - **SEC-03** — sensitive password retrieval requires explicit user consent via a native dialog + ("Remember for this session", until the vault is locked). Consent and all ECDH sessions are + cleared on vault lock. + - Hardening: `unlock-vault` attempts over IPC are rate-limited (lockout after repeated + failures) and the number of concurrent ECDH sessions is capped. + +### Added +- **Browser extension**: autofill success toast ("Credentials filled on {site}") and a visible + connection-status indicator ("Connected"/"Locked") in the popup header (6 languages). + +### Fixed +- **Browser extension**: the popup no longer prompts for consent when it opens (consent applies + only to actual password retrieval); per-action request timeouts prevent spurious "PassKey is + not running" during slow operations such as consent or unlock. + ## [2.0.0] - 2026-06-04 ### Added diff --git a/extensions/chrome/background.js b/extensions/chrome/background.js index bb01206..facde3b 100644 --- a/extensions/chrome/background.js +++ b/extensions/chrome/background.js @@ -16,6 +16,16 @@ importScripts('lib/messages.js', 'lib/crypto.js'); const NATIVE_HOST = 'com.passkey.host'; const REQUEST_TIMEOUT_MS = 5000; +// Some actions wait on a human (SEC-03 consent dialog) or a slow KDF (unlock) on the Desktop +// side, so they need a longer timeout than the default request. +const TIMEOUT_OVERRIDES = { + 'get-credential-password': 120000, // may wait for the user's consent dialog + 'unlock-vault': 30000, // Argon2id KDF on the Desktop +}; +function timeoutForAction(action) { + return TIMEOUT_OVERRIDES[action] ?? REQUEST_TIMEOUT_MS; +} + // ─── State (lost on service worker restart — re-established automatically) ──── let port = null; @@ -90,7 +100,7 @@ function sendNativeMessage(message) { const timeoutId = setTimeout(() => { pendingRequests.delete(message.requestId); reject(new Error('timeout')); - }, REQUEST_TIMEOUT_MS); + }, timeoutForAction(message.action)); pendingRequests.set(message.requestId, { resolve, reject, timeoutId }); @@ -278,7 +288,7 @@ async function handleCopyCredential(credentialId) { // Desktop always returns an AES-GCM encrypted password tied to the ECDH session. // A missing nonce means no valid session — never accept a plaintext fallback. if (!resp.payload?.nonce || resp.payload.nonce.length === 0) { - return { success: false, error: 'no-session' }; + return { success: false, error: 'ecdh-session-required' }; } const password = await decryptPassword(sessionKey, resp.payload.nonce, resp.payload.encryptedPassword); return { success: true, password }; @@ -380,7 +390,7 @@ async function handleFillCredential(credentialId, username, tabId) { // Desktop always returns an AES-GCM encrypted password tied to the ECDH session. // A missing nonce means no valid session — never accept a plaintext fallback. if (!resp.payload?.nonce || resp.payload.nonce.length === 0) { - return { success: false, error: 'no-session' }; + return { success: false, error: 'ecdh-session-required' }; } const password = await decryptPassword( sessionKey, diff --git a/extensions/chrome/lib/i18n.js b/extensions/chrome/lib/i18n.js index cf492b6..74c62e1 100644 --- a/extensions/chrome/lib/i18n.js +++ b/extensions/chrome/lib/i18n.js @@ -54,6 +54,7 @@ const STRINGS = { // Feedback copied: 'Copiato!', copyError: 'Errore', + filledIn: (site) => `Credenziali inserite in ${site}`, // Footer openApp: 'Apri PassKey', }, @@ -86,6 +87,7 @@ const STRINGS = { hasPassword: 'Password saved', copied: 'Copied!', copyError: 'Error', + filledIn: (site) => `Credentials filled on ${site}`, openApp: 'Open PassKey', }, fr: { @@ -117,6 +119,7 @@ const STRINGS = { hasPassword: 'Mot de passe enregistré', copied: 'Copié !', copyError: 'Erreur', + filledIn: (site) => `Identifiants saisis sur ${site}`, openApp: 'Ouvrir PassKey', }, de: { @@ -148,6 +151,7 @@ const STRINGS = { hasPassword: 'Passwort gespeichert', copied: 'Kopiert!', copyError: 'Fehler', + filledIn: (site) => `Anmeldedaten in ${site} eingetragen`, openApp: 'PassKey öffnen', }, es: { @@ -179,6 +183,7 @@ const STRINGS = { hasPassword: 'Contraseña guardada', copied: '¡Copiado!', copyError: 'Error', + filledIn: (site) => `Credenciales rellenadas en ${site}`, openApp: 'Abrir PassKey', }, pt: { @@ -210,6 +215,7 @@ const STRINGS = { hasPassword: 'Senha guardada', copied: 'Copiado!', copyError: 'Erro', + filledIn: (site) => `Credenciais preenchidas em ${site}`, openApp: 'Abrir PassKey', }, }; diff --git a/extensions/chrome/popup/popup.css b/extensions/chrome/popup/popup.css index a74b94c..bc3c3c0 100644 --- a/extensions/chrome/popup/popup.css +++ b/extensions/chrome/popup/popup.css @@ -110,6 +110,13 @@ body { .pk-status.locked { background: var(--pk-warning); } .pk-status.disconnected { background: var(--pk-error); } +/* Status text (T6.3) — visible connection status next to the dot */ +.pk-status-text { + font-size: 11px; + color: var(--pk-text-2); + white-space: nowrap; +} + /* Domain badge */ .pk-domain { display: flex; @@ -460,3 +467,21 @@ body { to { transform: rotate(360deg); } } .pk-spin { animation: pk-spin 0.7s linear infinite; display: block; } + +/* ── Autofill success toast (T6.2) ──────────────────────────────────────── */ +.pk-toast { + position: fixed; + left: 50%; + bottom: 52px; + transform: translateX(-50%); + max-width: 320px; + padding: 8px 14px; + border-radius: 6px; + background: var(--pk-success); + color: #fff; + font-size: 12px; + font-weight: 500; + text-align: center; + box-shadow: 0 2px 8px rgba(0,0,0,0.25); + z-index: 10; +} diff --git a/extensions/chrome/popup/popup.html b/extensions/chrome/popup/popup.html index b85d287..47d1490 100644 --- a/extensions/chrome/popup/popup.html +++ b/extensions/chrome/popup/popup.html @@ -12,7 +12,8 @@
PassKey -
+ +
@@ -124,6 +125,9 @@

+ + +