Skip to content
Open
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
28 changes: 28 additions & 0 deletions src/utils/credentials.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,3 +252,31 @@ test("saving partial credentials does not wipe out omitted keys", async () => {
after(() => {
lockCredentials();
});

test("unlock of an initialized-but-empty vault persists a verification token (#837)", async () => {
// Vault salt exists (initialized) but no credentials: the old code accepted ANY
// passphrase here and cached the wrong derived key, locking the user out later.
const { local } = setupChromeStorage(
{ openai_api_key: "existing-openai" },
{
credential_encryption_salt: "c2FsdHktcGxhY2Vob2xkZXI=",
openai_api_key: "existing-openai",
},
);

const result = await unlockCredentials("chosen-passphrase");
assert.equal(result, true);

// A verification token must now exist so future unlocks can be validated.
assert.ok(typeof local.credential_vault_verification === "string");
assert.ok((local.credential_vault_verification as string).length > 0);

// Re-unlock with the SAME passphrase succeeds, and with a DIFFERENT passphrase
// is rejected — proving the key is now verifiable, not any-phrase.
lockCredentials();
assert.equal(isUnlocked(), false);
assert.equal(await unlockCredentials("chosen-passphrase"), true);

lockCredentials();
assert.equal(await unlockCredentials("some-other-passphrase"), false);
});
47 changes: 43 additions & 4 deletions src/utils/credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ const IV_LENGTH = 12;
const PBKDF2_ITERATIONS = 100_000;
const SALT_LENGTH = 16;
const SALT_STORAGE_KEY = "credential_encryption_salt";
const VAULT_VERIFICATION_KEY = "credential_vault_verification";

let derivedKey: CryptoKey | null = null;

Expand Down Expand Up @@ -138,19 +139,57 @@ export async function unlockCredentials(passphrase: string): Promise<boolean> {
const key = await deriveKeyFromPassphrase(passphrase, base64ToArrayBuffer(storedSalt));
const encryptedLocal = await chrome.storage.local.get(CREDENTIAL_KEYS);
const encryptedCreds = unmarkEncrypted(encryptedLocal);
if (Object.keys(encryptedCreds).length > 0) {
try {
const stored = await chrome.storage.local.get([VAULT_VERIFICATION_KEY]);
const verificationToken = stored[VAULT_VERIFICATION_KEY];
let verified = false;
try {
if (verificationToken && typeof verificationToken === "string") {
// Preferred path: a verification token is the canonical secret used to
// validate the passphrase for both populated and empty vaults.
const combined = base64ToArrayBuffer(verificationToken);
const iv = new Uint8Array(combined.slice(0, IV_LENGTH));
const ciphertext = combined.slice(IV_LENGTH);
await crypto.subtle.decrypt({ name: AES_ALGORITHM, iv }, key, ciphertext);
verified = true;
} else if (Object.keys(encryptedCreds).length > 0) {
// Fallback for vaults created before the verification token existed:
// validate against a sample credential instead.
const sampleKey = CREDENTIAL_KEYS.find((k) => encryptedCreds[k]);
if (sampleKey && encryptedCreds[sampleKey]) {
const combined = base64ToArrayBuffer(encryptedCreds[sampleKey]);
const iv = new Uint8Array(combined.slice(0, IV_LENGTH));
const ciphertext = combined.slice(IV_LENGTH);
await crypto.subtle.decrypt({ name: AES_ALGORITHM, iv }, key, ciphertext);
verified = true;
}
} catch {
return false;
} else {
// The vault salt exists but the vault is empty: no verification token and
// no stored credentials. The original code accepted *any* passphrase here
// and cached its derived key, so a subsequent save encrypted with the wrong
// key and permanently locked the user out (#837). Treat this as an
// incomplete first-time setup and persist a verification token derived from
// this passphrase so future unlocks can be validated against it.
const verificationIv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
const verificationPlaintext = new TextEncoder().encode("vault-verification-token");
const verificationCiphertext = await crypto.subtle.encrypt(
{ name: AES_ALGORITHM, iv: verificationIv },
key,
verificationPlaintext,
);
const verificationCombined = new Uint8Array(
verificationIv.length + verificationCiphertext.byteLength,
);
verificationCombined.set(verificationIv);
verificationCombined.set(new Uint8Array(verificationCiphertext), verificationIv.length);
await chrome.storage.local.set({
[VAULT_VERIFICATION_KEY]: arrayBufferToBase64(verificationCombined.buffer),
});
verified = true;
}
} catch {
return false;
}
if (!verified) return false;
derivedKey = key;
resetAutoLockTimer();
return true;
Expand Down