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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 13 additions & 3 deletions extensions/chrome/background.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 });

Expand Down Expand Up @@ -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 };
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions extensions/chrome/lib/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ const STRINGS = {
// Feedback
copied: 'Copiato!',
copyError: 'Errore',
filledIn: (site) => `Credenziali inserite in ${site}`,
// Footer
openApp: 'Apri PassKey',
},
Expand Down Expand Up @@ -86,6 +87,7 @@ const STRINGS = {
hasPassword: 'Password saved',
copied: 'Copied!',
copyError: 'Error',
filledIn: (site) => `Credentials filled on ${site}`,
openApp: 'Open PassKey',
},
fr: {
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -148,6 +151,7 @@ const STRINGS = {
hasPassword: 'Passwort gespeichert',
copied: 'Kopiert!',
copyError: 'Fehler',
filledIn: (site) => `Anmeldedaten in ${site} eingetragen`,
openApp: 'PassKey öffnen',
},
es: {
Expand Down Expand Up @@ -179,6 +183,7 @@ const STRINGS = {
hasPassword: 'Contraseña guardada',
copied: '¡Copiado!',
copyError: 'Error',
filledIn: (site) => `Credenciales rellenadas en ${site}`,
openApp: 'Abrir PassKey',
},
pt: {
Expand Down Expand Up @@ -210,6 +215,7 @@ const STRINGS = {
hasPassword: 'Senha guardada',
copied: 'Copiado!',
copyError: 'Erro',
filledIn: (site) => `Credenciais preenchidas em ${site}`,
openApp: 'Abrir PassKey',
},
};
Expand Down
25 changes: 25 additions & 0 deletions extensions/chrome/popup/popup.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
6 changes: 5 additions & 1 deletion extensions/chrome/popup/popup.html
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
<header class="pk-header">
<img src="../icons/icon-32.png" alt="" class="pk-logo" aria-hidden="true">
<span class="pk-title">PassKey</span>
<div id="status-dot" class="pk-status" role="status" aria-live="polite" aria-label=""></div>
<span id="status-text" class="pk-status-text" role="status" aria-live="polite"></span>
<div id="status-dot" class="pk-status" aria-hidden="true"></div>
</header>

<!-- Domain badge (shown when valid HTTP URL) -->
Expand Down Expand Up @@ -124,6 +125,9 @@ <h2 id="empty-title" class="pk-state-title pk-state-title--sm"></h2>
<ul id="credentials-list" class="pk-cred-list" role="list"></ul>
</div>

<!-- Autofill success toast (T6.2) -->
<div id="pk-toast" class="pk-toast" role="status" aria-live="polite" hidden></div>

<!-- Footer -->
<footer class="pk-footer">
<button id="btn-open-app" class="pk-footer-link" type="button"></button>
Expand Down
43 changes: 40 additions & 3 deletions extensions/chrome/popup/popup.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,11 @@ let isHttpUrl = false;
const $ = id => document.getElementById(id);

const statusDot = $('status-dot');
const statusText = $('status-text');
const domainBadge = $('domain-badge');
const domainText = $('domain-text');
const copyFeedback = $('copy-feedback');
const pkToast = $('pk-toast');
const loadingText = $('loading-text');
const disconnectedTitle = $('disconnected-title');
const disconnectedSub = $('disconnected-sub');
Expand Down Expand Up @@ -148,7 +150,10 @@ function setState(state) {
*/
function setStatus(type) {
statusDot.className = `pk-status ${type}`;
statusDot.setAttribute('aria-label', window.t[`status${type.charAt(0).toUpperCase()+type.slice(1)}`] || type);
const cap = type ? type.charAt(0).toUpperCase() + type.slice(1) : 'Connecting';
const label = window.t[`status${cap}`] || '';
statusDot.setAttribute('aria-label', label);
if (statusText) statusText.textContent = label;
}

// ─── Main init ────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -511,19 +516,51 @@ async function onFill(cred, btn) {
setSvgIcon(iconTarget, SPIN_SVG);
btn.disabled = true;
}

let ok = false;
let site = '';
try {
// Re-query active tab at fill time to avoid stale tab ID (user may have
// switched tabs without closing the popup).
const [activeTab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (!activeTab?.id) { window.close(); return; }
await chrome.runtime.sendMessage({
site = domainOf(activeTab.url);
const resp = await chrome.runtime.sendMessage({
type: 'fill-credential',
id: cred.id,
username: cred.username,
tabId: activeTab.id
});
ok = resp?.success === true;
} catch { /* ignore */ }
window.close();

// T6.2: show a success toast briefly, then close. On failure close immediately.
if (ok) {
showToast(window.t.filledIn(site));
setTimeout(() => window.close(), 1200);
} else {
window.close();
}
}

/**
* Extracts the hostname from a URL (strips a leading "www."), or "" for non-URL inputs.
*
* @param {string|undefined} url - The URL to parse.
* @returns {string} The hostname without "www.", or an empty string.
*/
function domainOf(url) {
try { return new URL(url).hostname.replace(/^www\./, ''); } catch { return ''; }
}

/**
* Shows the autofill success toast. The popup closes shortly after, so no auto-hide is needed.
*
* @param {string} msg - Localized message to display.
*/
function showToast(msg) {
pkToast.textContent = msg;
pkToast.hidden = false;
}

/**
Expand Down
16 changes: 13 additions & 3 deletions extensions/firefox/background.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,16 @@
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 event page unload — re-established automatically) ─────────

let port = null;
Expand Down Expand Up @@ -93,7 +103,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 });

Expand Down Expand Up @@ -281,7 +291,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 };
Expand Down Expand Up @@ -383,7 +393,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,
Expand Down
6 changes: 6 additions & 0 deletions extensions/firefox/lib/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ const STRINGS = {
// Feedback
copied: 'Copiato!',
copyError: 'Errore',
filledIn: (site) => `Credenziali inserite in ${site}`,
// Footer
openApp: 'Apri PassKey',
},
Expand Down Expand Up @@ -86,6 +87,7 @@ const STRINGS = {
hasPassword: 'Password saved',
copied: 'Copied!',
copyError: 'Error',
filledIn: (site) => `Credentials filled on ${site}`,
openApp: 'Open PassKey',
},
fr: {
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -148,6 +151,7 @@ const STRINGS = {
hasPassword: 'Passwort gespeichert',
copied: 'Kopiert!',
copyError: 'Fehler',
filledIn: (site) => `Anmeldedaten in ${site} eingetragen`,
openApp: 'PassKey öffnen',
},
es: {
Expand Down Expand Up @@ -179,6 +183,7 @@ const STRINGS = {
hasPassword: 'Contraseña guardada',
copied: '¡Copiado!',
copyError: 'Error',
filledIn: (site) => `Credenciales rellenadas en ${site}`,
openApp: 'Abrir PassKey',
},
pt: {
Expand Down Expand Up @@ -210,6 +215,7 @@ const STRINGS = {
hasPassword: 'Senha guardada',
copied: 'Copiado!',
copyError: 'Erro',
filledIn: (site) => `Credenciais preenchidas em ${site}`,
openApp: 'Abrir PassKey',
},
};
Expand Down
25 changes: 25 additions & 0 deletions extensions/firefox/popup/popup.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Loading
Loading