Aug 26 - Enhance security
CI / Python lint (flake8) (push) Has been cancelled
CI / Python syntax check (push) Has been cancelled
CI / Alembic migration chain (push) Has been cancelled
CI / JavaScript syntax check (push) Has been cancelled
CI / Build extension zip (push) Has been cancelled
CI / Python lint (flake8) (push) Has been cancelled
CI / Python syntax check (push) Has been cancelled
CI / Alembic migration chain (push) Has been cancelled
CI / JavaScript syntax check (push) Has been cancelled
CI / Build extension zip (push) Has been cancelled
This commit is contained in:
@@ -83,7 +83,6 @@ const Auth = (() => {
|
||||
|
||||
// Temporarily held between Step 1 and Step 2
|
||||
let _pendingMfaToken = null;
|
||||
let _pendingEncKeySalt = null;
|
||||
let _pendingPassword = null;
|
||||
|
||||
async function handleLogin(e) {
|
||||
@@ -114,11 +113,11 @@ const Auth = (() => {
|
||||
}
|
||||
|
||||
if (data.mfa_required) {
|
||||
// Step 2: collect TOTP code
|
||||
// Step 2: collect TOTP code.
|
||||
// enc_key_salt is no longer part of this response — the server withholds
|
||||
// it until the second factor is verified, so it arrives from /mfa/verify.
|
||||
_pendingMfaToken = data.mfa_token;
|
||||
_pendingEncKeySalt = data.enc_key_salt;
|
||||
_pendingPassword = password;
|
||||
sessionStorage.setItem("enc_key_salt", data.enc_key_salt);
|
||||
showMfaStep();
|
||||
return;
|
||||
}
|
||||
@@ -183,7 +182,7 @@ const Auth = (() => {
|
||||
await completeLogin(_pendingPassword, {
|
||||
access_token: data.access_token,
|
||||
refresh_token: data.refresh_token,
|
||||
enc_key_salt: _pendingEncKeySalt,
|
||||
enc_key_salt: data.enc_key_salt,
|
||||
});
|
||||
} catch (err) {
|
||||
errEl.textContent = "An unexpected error occurred. Please try again.";
|
||||
@@ -228,7 +227,6 @@ const Auth = (() => {
|
||||
document.getElementById("mfa-code").value = "";
|
||||
document.getElementById("mfa-error").classList.add("hidden");
|
||||
_pendingMfaToken = null;
|
||||
_pendingEncKeySalt = null;
|
||||
_pendingPassword = null;
|
||||
}
|
||||
|
||||
|
||||
+98
-12
@@ -159,17 +159,54 @@ const Recover = (() => {
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the HMAC-SHA256 recovery proof.
|
||||
* proof = HMAC-SHA256(key=enc_key_salt_bytes, msg=nonce_bytes)
|
||||
* Derive the recovery verifier: 256 bits of PBKDF2 over the recovery code,
|
||||
* salted with a domain-separated label plus the user's email, as 64 hex chars.
|
||||
*
|
||||
* This proves to the server that the client correctly decrypted the recovery
|
||||
* blob (and therefore holds the right recovery code) without transmitting
|
||||
* enc_key_salt in plaintext.
|
||||
* This is the HMAC key for the recovery challenge under the current scheme.
|
||||
* It depends on the recovery code alone and is used for nothing else, so it
|
||||
* cannot be derived from enc_key_salt (which the server hands to any client
|
||||
* that clears the password factor).
|
||||
*
|
||||
* Must stay byte-identical to _deriveRecoveryVerifier() in vault.js.
|
||||
*/
|
||||
async function computeRecoveryProof(encKeySalt, nonce) {
|
||||
async function deriveRecoveryVerifier(recoveryCode, email) {
|
||||
const baseKey = await subtle.importKey(
|
||||
"raw",
|
||||
strToBytes(recoveryCode),
|
||||
"PBKDF2",
|
||||
false,
|
||||
["deriveBits"],
|
||||
);
|
||||
const bits = await subtle.deriveBits(
|
||||
{
|
||||
name: "PBKDF2",
|
||||
salt: strToBytes(
|
||||
"passkeeper-recovery-verifier:" + email.toLowerCase(),
|
||||
),
|
||||
iterations: 200_000,
|
||||
hash: "SHA-256",
|
||||
},
|
||||
baseKey,
|
||||
256,
|
||||
);
|
||||
return Array.from(new Uint8Array(bits))
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the HMAC-SHA256 recovery proof: HMAC(key=proofKey, msg=nonce).
|
||||
*
|
||||
* proofKey is the recovery verifier for codes generated under the current
|
||||
* scheme, or — for codes predating it — the enc_key_salt decrypted out of the
|
||||
* recovery blob. The server tells us which via `proof_scheme` on
|
||||
* /recovery/data. Either way the proof demonstrates possession of the
|
||||
* recovery code without transmitting anything reusable.
|
||||
*/
|
||||
async function computeRecoveryProof(proofKey, nonce) {
|
||||
const keyMaterial = await subtle.importKey(
|
||||
"raw",
|
||||
strToBytes(encKeySalt),
|
||||
strToBytes(proofKey),
|
||||
{ name: "HMAC", hash: "SHA-256" },
|
||||
false,
|
||||
["sign"],
|
||||
@@ -245,9 +282,15 @@ const Recover = (() => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Compute HMAC-SHA256 proof: proves we correctly decrypted the blob
|
||||
// without sending enc_key_salt in plaintext.
|
||||
const proof = await computeRecoveryProof(decryptedEncKeySalt, data.nonce);
|
||||
// Key the proof on the recovery verifier when the account has one.
|
||||
// Legacy accounts (recovery code created before recovery_verifier existed)
|
||||
// still key it on the decrypted enc_key_salt; regenerating the code from
|
||||
// Settings migrates them.
|
||||
const proofKey =
|
||||
data.proof_scheme === "verifier"
|
||||
? await deriveRecoveryVerifier(rawCode, email)
|
||||
: decryptedEncKeySalt;
|
||||
const proof = await computeRecoveryProof(proofKey, data.nonce);
|
||||
|
||||
// Derive the old vault key using the recovery code as master password proxy
|
||||
_oldVaultKey = await Crypto.deriveVaultKey(rawCode, decryptedEncKeySalt);
|
||||
@@ -340,9 +383,20 @@ const Recover = (() => {
|
||||
},
|
||||
);
|
||||
|
||||
if (!itemsRes.ok) {
|
||||
showError(
|
||||
"recover-error-2",
|
||||
"Could not load your vault items. Please restart the recovery process.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let reEncryptedItems = [];
|
||||
if (itemsRes.ok) {
|
||||
let totalItems = 0;
|
||||
const failedItemIds = [];
|
||||
{
|
||||
const itemsData = await itemsRes.json();
|
||||
totalItems = itemsData.items.length;
|
||||
// Re-encrypt each item: old vault key → new vault key
|
||||
for (const item of itemsData.items) {
|
||||
try {
|
||||
@@ -369,12 +423,43 @@ const Recover = (() => {
|
||||
}
|
||||
reEncryptedItems.push({ id: item.id, enc_data, iv, ...encNamePayload });
|
||||
} catch {
|
||||
// Item decryption failed — skip (shouldn't happen if recovery code is correct)
|
||||
// Item decryption failed — record it. The server refuses to rotate
|
||||
// the key unless every item is covered, so we must either resolve
|
||||
// this or have the user explicitly accept losing these items.
|
||||
failedItemIds.push(item.id);
|
||||
console.warn(`Could not re-encrypt item ${item.id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Completing recovery rotates enc_key_salt, which permanently orphans any
|
||||
// item still encrypted under the old key. Unlike the change-password flow
|
||||
// we cannot simply refuse — the user is locked out of their account and
|
||||
// has no other way in — so we surface the exact cost and let them decide.
|
||||
let allowPartial = false;
|
||||
if (reEncryptedItems.length !== totalItems) {
|
||||
const lost = totalItems - reEncryptedItems.length;
|
||||
const proceed = confirm(
|
||||
`${lost} of your ${totalItems} vault item(s) could not be decrypted ` +
|
||||
`with this recovery code and cannot be carried over.
|
||||
|
||||
` +
|
||||
`Continuing will recover your account and the other ` +
|
||||
`${reEncryptedItems.length} item(s), but those ${lost} item(s) will ` +
|
||||
`be permanently unreadable.
|
||||
|
||||
Continue with recovery?`,
|
||||
);
|
||||
if (!proceed) {
|
||||
showError(
|
||||
"recover-error-2",
|
||||
"Recovery cancelled. Your account is unchanged.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
allowPartial = true;
|
||||
}
|
||||
|
||||
// Submit recovery
|
||||
const recoverRes = await fetch("/api/auth/recover", {
|
||||
method: "POST",
|
||||
@@ -385,6 +470,7 @@ const Recover = (() => {
|
||||
new_enc_key_salt: newEncKeySalt,
|
||||
recovery_proof: _recoveryProof,
|
||||
items: reEncryptedItems,
|
||||
allow_partial: allowPartial,
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
+90
-8
@@ -3554,12 +3554,17 @@ const Vault = (() => {
|
||||
btn.textContent = `Re-encrypting ${items.length} item(s)…`;
|
||||
|
||||
const reEncrypted = [];
|
||||
const failedIds = [];
|
||||
for (const item of items) {
|
||||
const plain = await Crypto.decryptItem(
|
||||
vaultKey,
|
||||
item.enc_data,
|
||||
item.iv,
|
||||
);
|
||||
let plain;
|
||||
try {
|
||||
plain = await Crypto.decryptItem(vaultKey, item.enc_data, item.iv);
|
||||
} catch {
|
||||
// Cannot re-encrypt what we cannot read. Collect and abort below —
|
||||
// rotating the key regardless would orphan this item permanently.
|
||||
failedIds.push(item.id);
|
||||
continue;
|
||||
}
|
||||
const { enc_data, iv } = await Crypto.encryptItem(newVaultKey, plain);
|
||||
// Re-encrypt the name if it was previously encrypted.
|
||||
let encNamePayload = {};
|
||||
@@ -3580,6 +3585,19 @@ const Vault = (() => {
|
||||
reEncrypted.push({ id: item.id, enc_data, iv, ...encNamePayload });
|
||||
}
|
||||
|
||||
// Refuse to rotate unless every item was re-encrypted. Unlike recovery
|
||||
// there is no lockout risk in stopping here — the current password keeps
|
||||
// working — so this fails closed with no partial-completion escape hatch.
|
||||
if (failedIds.length || reEncrypted.length !== items.length) {
|
||||
cpError.textContent =
|
||||
`Password not changed: ${failedIds.length || items.length - reEncrypted.length} of ` +
|
||||
`${items.length} item(s) could not be re-encrypted. Continuing would ` +
|
||||
`permanently lock them. Reload the vault and try again — if this ` +
|
||||
`persists, export your vault before retrying.`;
|
||||
cpError.classList.remove("hidden");
|
||||
return;
|
||||
}
|
||||
|
||||
// Re-encrypt sharing private key with new vault key so sharing stays functional.
|
||||
// The private key is stored as AES-GCM ciphertext on the server; rotating the
|
||||
// vault key without re-encrypting it would leave it permanently unreadable.
|
||||
@@ -3644,7 +3662,10 @@ const Vault = (() => {
|
||||
showToast("Password changed. Please log in again.");
|
||||
setTimeout(() => redirectToLogin(), 1500);
|
||||
} catch (err) {
|
||||
cpError.textContent = "An error occurred: " + err.message;
|
||||
// The server runs the same completeness check and answers 409 if the
|
||||
// payload was short; show its message rather than burying it.
|
||||
cpError.textContent =
|
||||
err.status === 409 ? err.message : "An error occurred: " + err.message;
|
||||
cpError.classList.remove("hidden");
|
||||
console.error(err);
|
||||
} finally {
|
||||
@@ -3663,7 +3684,16 @@ const Vault = (() => {
|
||||
const statusEl = document.getElementById("recovery-status-text");
|
||||
const actionsEl = document.getElementById("recovery-actions");
|
||||
|
||||
if (data.recovery_configured) {
|
||||
if (data.recovery_configured && data.recovery_is_legacy) {
|
||||
// Pre-verifier recovery code: its challenge proof is still keyed on
|
||||
// enc_key_salt, which the server discloses at login. Regenerating
|
||||
// rebinds the proof to a value derived from the recovery code alone.
|
||||
statusEl.textContent =
|
||||
"⚠ Your recovery code uses an outdated verification method. " +
|
||||
"Generate a new one to secure it — your current code keeps working until you do.";
|
||||
actionsEl.innerHTML =
|
||||
'<button class="btn-primary" id="btn-regen-recovery">Generate new recovery code</button>';
|
||||
} else if (data.recovery_configured) {
|
||||
statusEl.textContent =
|
||||
"✅ A recovery code is configured for your account.";
|
||||
actionsEl.innerHTML =
|
||||
@@ -3753,10 +3783,23 @@ const Vault = (() => {
|
||||
const recovery_enc_salt = bytesToBase64(ciphertext);
|
||||
const recovery_iv = bytesToBase64(iv);
|
||||
|
||||
// Derive the recovery verifier — the HMAC key the server uses for the
|
||||
// recovery challenge-response. It comes from the recovery code alone and
|
||||
// is used for nothing else, so it never doubles as key material.
|
||||
// Must stay byte-identical to deriveRecoveryVerifier() in recover.js.
|
||||
const recovery_verifier = await _deriveRecoveryVerifier(
|
||||
recoveryCode,
|
||||
userEmail,
|
||||
);
|
||||
|
||||
// Store on server
|
||||
const res = await apiFetch("/api/auth/recovery/setup", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ recovery_enc_salt, recovery_iv }),
|
||||
body: JSON.stringify({
|
||||
recovery_enc_salt,
|
||||
recovery_iv,
|
||||
recovery_verifier,
|
||||
}),
|
||||
});
|
||||
if (!res) return;
|
||||
|
||||
@@ -4532,6 +4575,45 @@ const Vault = (() => {
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the recovery verifier: 256 bits of PBKDF2 over the recovery code,
|
||||
* salted with a domain-separated label plus the user's email, returned as 64
|
||||
* lowercase hex characters.
|
||||
*
|
||||
* The server stores this and keys the recovery challenge HMAC with it. It is
|
||||
* deliberately independent of enc_key_salt — enc_key_salt is the vault-key
|
||||
* PBKDF2 salt and is disclosed to the client at login, so using it as the
|
||||
* proof key let anyone with the master password forge a proof and pull the
|
||||
* whole vault from /recovery/items without a second factor.
|
||||
*
|
||||
* recover.js has a byte-identical copy. Changing the label or iteration count
|
||||
* in one place without the other invalidates every existing recovery code.
|
||||
*/
|
||||
async function _deriveRecoveryVerifier(recoveryCode, email) {
|
||||
const baseKey = await window.crypto.subtle.importKey(
|
||||
"raw",
|
||||
new TextEncoder().encode(recoveryCode),
|
||||
"PBKDF2",
|
||||
false,
|
||||
["deriveBits"],
|
||||
);
|
||||
const bits = await window.crypto.subtle.deriveBits(
|
||||
{
|
||||
name: "PBKDF2",
|
||||
salt: new TextEncoder().encode(
|
||||
"passkeeper-recovery-verifier:" + email.toLowerCase(),
|
||||
),
|
||||
iterations: 200_000,
|
||||
hash: "SHA-256",
|
||||
},
|
||||
baseKey,
|
||||
256,
|
||||
);
|
||||
return Array.from(new Uint8Array(bits))
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
}
|
||||
|
||||
function escHtml(str) {
|
||||
return String(str)
|
||||
.replace(/&/g, "&")
|
||||
|
||||
Reference in New Issue
Block a user