Skip to content

[Critical] unlockCredentials() accepts any passphrase when vault is initialized but empty — subsequent saves encrypt with wrong key, permanently locking the user out #837

Description

@711nishtha

Describe the bug

src/utils/credentials.ts unlockCredentials() has two distinct branches:

  1. Salt missing → first-time setup, creates salt, derives key, returns true
  2. Salt present → existing vault, derives key, tries to decrypt a sample credential to verify the passphrase

The verification step is guarded by this check on line 141:

const encryptedCreds = unmarkEncrypted(encryptedLocal);
if (Object.keys(encryptedCreds).length > 0) {
  // Passphrase verification via decryption
  try {
    ...
    await crypto.subtle.decrypt(...);
    ...
  } catch {
    return false;
  }
}

// ← If no encrypted creds exist, falls through and accepts ANY passphrase
derivedKey = key;
return true;

When encryptedCreds is empty — i.e. the vault has been initialized (salt is stored) but the user hasn't saved any API keys yet — the verification block is skipped entirely. Any passphrase is accepted and used to derive derivedKey.

This creates a silent, irreversible lock-out:

1. User sets up vault with passphrase "correct-horse"
   → Salt written to chrome.storage.local
   → No API keys saved yet (user closes popup before saving)

2. Next session: user misremembers, types "correct-horse-battery"
   → storedSalt found → no encryptedCreds → verification skipped
   → derivedKey = PBKDF2("correct-horse-battery", salt)
   → returns true  ← vault "unlocks" silently with WRONG passphrase

3. User saves their OpenAI key
   → encrypted with wrong-passphrase-derived key
   → stored to chrome.storage.local

4. Next session: user types "correct-horse" (actual passphrase)
   → storedSalt found → encryptedCreds found → verification runs
   → decrypt("correct-horse", ciphertext) fails — was encrypted with "correct-horse-battery"
   → returns false  ← locked out permanently

5. User tries both passphrases, neither works. Data is unrecoverable.

There is no recovery path. The only escape is clearing all extension storage, losing any saved credentials.

Steps to reproduce

// 1. Initialize vault (first-time setup)
await unlockCredentials("correct-passphrase");   // true — salt created
await lockCredentials();

// 2. Unlock with WRONG passphrase before saving any credentials
const result = await unlockCredentials("WRONG-passphrase");
console.log(result);   // true  ← BUG: should be false or undefined

// 3. Save a credential (now encrypted with wrong key)
await saveApiCredentials({ openai_api_key: "sk-abc123" });
await lockCredentials();

// 4. Try to unlock with correct passphrase
const result2 = await unlockCredentials("correct-passphrase");
console.log(result2);  // false — locked out permanently

Expected behavior

unlockCredentials() must be able to verify the passphrase even when no API credentials are stored yet.

The standard fix is a vault sentinel — a small known-plaintext value encrypted with the derived key during first-time setup, stored separately from API credentials, and always used for passphrase verification:

const VAULT_SENTINEL_KEY = "vault_sentinel";
const VAULT_SENTINEL_PLAINTEXT = "late-meet-vault-v1";

// During first-time setup (no storedSalt):
const sentinelCiphertext = await encrypt(VAULT_SENTINEL_PLAINTEXT); // uses newly derived key
await chrome.storage.local.set({
  [VAULT_SENTINEL_KEY]: sentinelCiphertext,
});

// During subsequent unlocks (storedSalt present):
const { [VAULT_SENTINEL_KEY]: sentinel } =
  await chrome.storage.local.get([VAULT_SENTINEL_KEY]);

if (sentinel) {
  try {
    const plaintext = await decrypt(sentinel); // uses newly derived key
    if (plaintext !== VAULT_SENTINEL_PLAINTEXT) return false;
  } catch {
    return false; // Wrong passphrase
  }
}

// Sentinel verified → safe to set derivedKey
derivedKey = key;
return true;

This guarantees passphrase verification regardless of whether API credentials have been saved, and costs only one small extra storage write during vault initialization.

Additional context

  • The window where this bug is exploitable is any time between vault initialization and the first saveApiCredentials() call — which is exactly the new-user onboarding flow.
  • The bug is silent — no error is thrown and true is returned, giving the user no indication anything went wrong.
  • Recovery requires manually clearing chrome.storage.local via DevTools, which is not documented anywhere in the extension's UI.

I'd like to fix this under GSSoC 2026 — please assign it to me if triaged.

Metadata

Metadata

Assignees

Labels

gssocOfficial GSSoC contribution issuegssoc:approvedGSSoC: PR approved and scoredsecurity-reviewRequires security review before mergetype:codeType: Code changetype:testType: Test files

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions