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

This commit is contained in:
2026-08-26 10:37:00 -04:00
parent 0295fac3fa
commit 0304095e53
9 changed files with 502 additions and 112 deletions
+10
View File
@@ -39,6 +39,16 @@ class User(db.Model, UserMixin):
# The server never sees the recovery code — only the ciphertext of enc_key_salt.
recovery_enc_salt = db.Column(db.String(128), nullable=True)
recovery_iv = db.Column(db.String(64), nullable=True)
# recovery_verifier: 64 hex chars (256 bits), derived client-side from the
# recovery code ALONE:
# PBKDF2(recovery_code, "passkeeper-recovery-verifier:" + email, 200k, SHA-256)
# Used only as the HMAC key for the recovery challenge-response proof.
# It is deliberately independent of enc_key_salt: enc_key_salt doubles as the
# vault-key PBKDF2 salt and is handed to the client at login, so keying the
# proof with it let anyone holding the password forge a proof and pull the
# whole encrypted vault from /recovery/items without a second factor.
# NULL = legacy recovery code; the proof falls back to enc_key_salt.
recovery_verifier = db.Column(db.String(64), nullable=True)
# Brute-force lockout — incremented on every failed login attempt,
# reset to 0 on success. locked_until is set to now()+15min after
# MAX_FAILED_LOGINS consecutive failures.
+227 -67
View File
@@ -31,6 +31,103 @@ auth_bp = Blueprint('auth', __name__)
EMAIL_RE = re.compile(r'^[^@\s]+@[^@\s]+\.[^@\s]+$')
# Fixed-length hex validator for the client-supplied recovery verifier.
_HEX64_RE = re.compile(r'^[0-9a-f]{64}$')
def _recovery_proof_key(user) -> bytes:
"""
Return the HMAC key the recovery challenge-response is computed over.
Preferred: user.recovery_verifier — a 256-bit value derived client-side from
the recovery code alone. It is used for nothing else, so learning it grants
no decryption ability, and knowing enc_key_salt does not yield it.
Legacy fallback: user.enc_key_salt, for recovery codes created before the
verifier existed. This is weaker — enc_key_salt is also the vault-key PBKDF2
salt and is disclosed to the client on login, so anyone holding the master
password can forge a proof and pull the vault from /recovery/items without a
second factor. Accounts on this path are flagged via /recovery/status so the
settings UI can prompt the user to regenerate.
"""
if user.recovery_verifier:
return user.recovery_verifier.encode()
return user.enc_key_salt.encode()
class IncompleteReencryption(Exception):
"""
Raised when a key-rotation payload does not cover every vault item the user
owns. Rotating enc_key_salt while some ciphertext is still under the old key
renders those items permanently undecryptable, so the whole transaction is
refused unless the caller explicitly opts into partial coverage.
"""
def __init__(self, expected: int, received: int):
self.expected = expected
self.received = received
super().__init__(f'expected {expected} item(s), received {received}')
def _apply_reencrypted_items(user_id: int, items, allow_partial: bool = False) -> tuple[int, int]:
"""
Apply client-supplied re-encrypted ciphertext to the user's vault items.
Both the password-change and account-recovery flows rotate enc_key_salt,
which invalidates every ciphertext encrypted under the previous vault key.
The client is responsible for re-encrypting each item and sending it back;
any item missing from that payload is silently orphaned by the rotation.
This helper therefore counts what it actually wrote and compares it against
the number of items the user owns. On a shortfall it raises
IncompleteReencryption so the caller can roll back rather than commit a
rotation that destroys data.
allow_partial=True skips the guard. The web client only sets it after
showing the user exactly how many items will be lost and getting explicit
confirmation — it exists so a single corrupt item cannot lock someone out of
recovery entirely.
Returns (updated_count, total_count). Does not commit.
"""
from app.models.vault_item import VaultItem
total = VaultItem.query.filter_by(user_id=user_id).count()
item_ids = [i.get('id') for i in (items or []) if i.get('id')]
existing = {
v.id: v
for v in VaultItem.query.filter(
VaultItem.user_id == user_id,
VaultItem.id.in_(item_ids),
).all()
} if item_ids else {}
updated = 0
for item_data in (items or []):
item_id = item_data.get('id')
enc_data = item_data.get('enc_data', '')
iv = item_data.get('iv', '')
if not item_id or not enc_data or not iv:
continue
vault_item = existing.get(item_id)
if not vault_item:
continue
vault_item.enc_data = enc_data
vault_item.iv = iv
# Re-encrypt the name ciphertext if the client sent updated enc_name/iv_name.
if item_data.get('enc_name'):
vault_item.enc_name = item_data['enc_name']
if item_data.get('iv_name'):
vault_item.iv_name = item_data['iv_name']
updated += 1
if updated != total and not allow_partial:
raise IncompleteReencryption(expected=total, received=updated)
return updated, total
@auth_bp.route('/register', methods=['POST'])
@limiter.limit('10 per minute')
def register():
@@ -175,13 +272,17 @@ def login():
)
db.session.commit()
# MFA gate: if enabled, issue a short-lived mfa_token instead of full tokens
# MFA gate: if enabled, issue a short-lived mfa_token instead of full tokens.
#
# enc_key_salt is deliberately NOT returned here. It is the PBKDF2 salt for
# the vault key, and releasing it to a caller that has only cleared the
# password factor is a partial authentication result. The client receives it
# from /mfa/verify once the second factor is satisfied.
if user.totp_enabled:
mfa_token = generate_mfa_token(user.id)
return jsonify({
'mfa_required': True,
'mfa_token': mfa_token,
'enc_key_salt': user.enc_key_salt,
}), 200
tokens = generate_tokens(user.id)
@@ -437,6 +538,8 @@ def mfa_verify():
return jsonify({
'access_token': tokens['access_token'],
'refresh_token': tokens['refresh_token'],
# Released here rather than at /login — both factors are now proven.
'enc_key_salt': user.enc_key_salt,
}), 200
@@ -581,6 +684,9 @@ def change_password():
items = data.get('items', []) # [{id, enc_data, iv, enc_name?, iv_name?}, ...]
sharing_private_key_enc = data.get('sharing_private_key_enc', '')
sharing_private_key_iv = data.get('sharing_private_key_iv', '')
# Explicit opt-in to rotating the key while some items go un-re-encrypted.
# The client must have confirmed the resulting data loss with the user.
allow_partial = bool(data.get('allow_partial'))
if not current_auth_hash or not new_auth_hash or not new_enc_key_salt:
return jsonify({'error': 'current_auth_hash, new_auth_hash, and new_enc_key_salt are required'}), 400
@@ -600,33 +706,11 @@ def change_password():
return jsonify({'error': 'Current password is incorrect'}), 401
try:
from app.models.vault_item import VaultItem
# Bulk-update all vault item ciphertexts with new vault key encryption
item_ids = [i.get('id') for i in items if i.get('id')]
existing = {
v.id: v
for v in VaultItem.query.filter(
VaultItem.user_id == user.id,
VaultItem.id.in_(item_ids),
).all()
} if item_ids else {}
for item_data in items:
item_id = item_data.get('id')
enc_data = item_data.get('enc_data', '')
iv = item_data.get('iv', '')
if not item_id or not enc_data or not iv:
continue
vault_item = existing.get(item_id)
if vault_item:
vault_item.enc_data = enc_data
vault_item.iv = iv
# Re-encrypt the name ciphertext if the client sent updated enc_name/iv_name.
if item_data.get('enc_name'):
vault_item.enc_name = item_data['enc_name']
if item_data.get('iv_name'):
vault_item.iv_name = item_data['iv_name']
# Refuse the rotation outright unless every item was re-encrypted —
# see _apply_reencrypted_items. Raises IncompleteReencryption otherwise.
updated, total = _apply_reencrypted_items(
user.id, items, allow_partial=allow_partial
)
# Update credentials
user.master_hash = hash_auth_token(new_auth_hash)
@@ -634,6 +718,7 @@ def change_password():
# Clear recovery data — it was encrypted with the old vault key and is now invalid
user.recovery_enc_salt = None
user.recovery_iv = None
user.recovery_verifier = None
# Re-encrypt sharing private key with new vault key if the client sent it.
# Without this update, the old ciphertext would be undecryptable after key rotation.
if sharing_private_key_enc and sharing_private_key_iv:
@@ -645,10 +730,39 @@ def change_password():
action='auth.change_password',
resource_type='user',
resource_id=user.id,
detail=f'Master password changed; {len(existing)} vault item(s) re-encrypted; recovery code cleared',
detail=(
f'Master password changed; {updated}/{total} vault item(s) re-encrypted'
f'{" (PARTIAL — user confirmed data loss)" if updated != total else ""}; '
'recovery code cleared'
),
ip_address=client_ip(),
)
db.session.commit()
except IncompleteReencryption as exc:
db.session.rollback()
AuditLog.log(
user_id=g.current_user_id,
action='auth.change_password_failed',
resource_type='user',
resource_id=g.current_user_id,
detail=(
f'Password change refused — re-encryption payload covered '
f'{exc.received} of {exc.expected} vault item(s)'
),
ip_address=client_ip(),
)
db.session.commit()
return jsonify({
'error': (
f'Password not changed: the re-encryption payload covered only '
f'{exc.received} of your {exc.expected} vault item(s). Completing '
'this would permanently lock the missing items. Reload the vault '
'and try again.'
),
'code': 'incomplete_reencryption',
'expected': exc.expected,
'received': exc.received,
}), 409
except Exception:
db.session.rollback()
_log.exception('change_password failed for user %s', g.current_user_id)
@@ -727,13 +841,20 @@ def recovery_setup():
data = request.get_json(silent=True) or {}
recovery_enc_salt = data.get('recovery_enc_salt', '').strip()
recovery_iv = data.get('recovery_iv', '').strip()
# 64 lowercase hex chars, derived client-side from the recovery code alone.
recovery_verifier = (data.get('recovery_verifier') or '').strip().lower()
if not recovery_enc_salt or not recovery_iv:
return jsonify({'error': 'recovery_enc_salt and recovery_iv are required'}), 400
if not recovery_verifier or not _HEX64_RE.match(recovery_verifier):
return jsonify({
'error': 'recovery_verifier must be 64 hexadecimal characters'
}), 400
user = db.session.get(User, g.current_user_id)
user.recovery_enc_salt = recovery_enc_salt
user.recovery_iv = recovery_iv
user.recovery_verifier = recovery_verifier
AuditLog.log(
user_id=user.id,
@@ -754,7 +875,13 @@ def recovery_setup():
def recovery_status():
"""Return whether the user has a recovery code configured."""
user = db.session.get(User, g.current_user_id)
return jsonify({'recovery_configured': bool(user.recovery_enc_salt)}), 200
return jsonify({
'recovery_configured': bool(user.recovery_enc_salt),
# True when the stored recovery code predates recovery_verifier and so
# still relies on the weaker enc_key_salt-keyed proof. The settings UI
# surfaces this as a prompt to regenerate.
'recovery_is_legacy': bool(user.recovery_enc_salt and not user.recovery_verifier),
}), 200
@auth_bp.route('/recover', methods=['POST'])
@@ -771,7 +898,12 @@ def recover_account():
5. Client POSTs everything here in one atomic payload.
The server validates recovery_proof against the value stored in the DB
during /recovery/data — enc_key_salt is never sent in plaintext.
during /recovery/data — neither the recovery code nor the verifier is ever
sent in plaintext.
The re-encrypted `items` array must cover every vault item the user owns;
otherwise the rotation is refused with 409. Pass allow_partial=true to
override once the user has confirmed the resulting data loss.
The challenge row is consumed (deleted) on first use to prevent replay.
Challenge state is stored in the database, not the Flask session, so the
flow works correctly across all Gunicorn workers.
@@ -783,6 +915,11 @@ def recover_account():
new_enc_key_salt = data.get('new_enc_key_salt', '')
client_proof = data.get('recovery_proof', '')
items = data.get('items', [])
# Explicit opt-in to recovering with some items left un-re-encrypted.
# The client sets this only after telling the user how many items it could
# not decrypt and getting confirmation — without the escape hatch a single
# corrupt row would block recovery entirely.
allow_partial = bool(data.get('allow_partial'))
if not all([email, new_auth_hash, new_enc_key_salt, client_proof]):
return jsonify({'error': 'email, new_auth_hash, new_enc_key_salt, and recovery_proof are required'}), 400
@@ -813,47 +950,59 @@ def recover_account():
try:
from app.models.vault_item import VaultItem
item_ids = [i.get('id') for i in items if i.get('id')]
existing = {
v.id: v
for v in VaultItem.query.filter(
VaultItem.user_id == user.id,
VaultItem.id.in_(item_ids),
).all()
} if item_ids else {}
for item_data in items:
item_id = item_data.get('id')
enc_data = item_data.get('enc_data', '')
iv = item_data.get('iv', '')
if not item_id or not enc_data or not iv:
continue
vault_item = existing.get(item_id)
if vault_item:
vault_item.enc_data = enc_data
vault_item.iv = iv
if item_data.get('enc_name'):
vault_item.enc_name = item_data['enc_name']
if item_data.get('iv_name'):
vault_item.iv_name = item_data['iv_name']
# Refuse to rotate the key while items remain under the old one —
# see _apply_reencrypted_items. Raises IncompleteReencryption otherwise.
updated, total = _apply_reencrypted_items(
user.id, items, allow_partial=allow_partial
)
user.master_hash = hash_auth_token(new_auth_hash)
user.enc_key_salt = new_enc_key_salt
# Recovery code is consumed — clear it so it cannot be reused.
user.recovery_enc_salt = None
user.recovery_iv = None
user.recovery_verifier = None
AuditLog.log(
user_id=user.id,
action='auth.recovery_success',
resource_type='user',
resource_id=user.id,
detail=f'Account recovered; {len(existing)} vault item(s) re-encrypted; recovery code consumed',
detail=(
f'Account recovered; {updated}/{total} vault item(s) re-encrypted'
f'{" (PARTIAL — user confirmed data loss)" if updated != total else ""}; '
'recovery code consumed'
),
ip_address=client_ip(),
)
db.session.commit()
except IncompleteReencryption as exc:
# The challenge was already consumed above, so the client must restart
# from /recovery/data. That is the correct trade-off: better to repeat
# the flow than to commit a rotation that orphans ciphertext.
db.session.rollback()
AuditLog.log(
user_id=user.id,
action='auth.recovery_failed',
resource_type='user',
resource_id=user.id,
detail=(
f'Recovery refused — re-encryption payload covered '
f'{exc.received} of {exc.expected} vault item(s)'
),
ip_address=client_ip(),
)
db.session.commit()
return jsonify({
'error': (
f'Recovery stopped: only {exc.received} of your {exc.expected} '
'vault item(s) could be re-encrypted. Continuing would permanently '
'lock the rest.'
),
'code': 'incomplete_reencryption',
'expected': exc.expected,
'received': exc.received,
}), 409
except Exception:
db.session.rollback()
_log.exception('recover_account failed for user %s', user.id)
@@ -876,11 +1025,15 @@ def recovery_data():
Exposes: enc_key_salt, recovery_enc_salt, recovery_iv, and a one-time nonce.
The nonce is used for the HMAC-SHA256 challenge-response proof:
- Client decrypts recovery_enc_salt → gets enc_key_salt bytes.
- Client computes: proof = HMAC-SHA256(key=enc_key_salt_bytes, msg=nonce)
- Client derives the recovery verifier from the recovery code:
PBKDF2(recovery_code, "passkeeper-recovery-verifier:" + email, 200k)
- Client computes: proof = HMAC-SHA256(key=verifier, msg=nonce)
- Server stores expected proof in the DB (recovery_challenges table),
verifying it on /recover and /recovery/items without ever receiving
enc_key_salt in plaintext.
verifying it on /recover and /recovery/items.
The response's `proof_scheme` field tells the client which key to use.
Accounts whose recovery code predates recovery_verifier get 'legacy' and
key the proof on the enc_key_salt decrypted out of the recovery blob.
Returns 404 if no recovery code is configured (prevents user enumeration).
The challenge is stored in the database (not the Flask session cookie) so
@@ -903,7 +1056,7 @@ def recovery_data():
# enc_key_salt in plaintext.
nonce = generate_recovery_nonce()
expected_proof = _hmac.new(
user.enc_key_salt.encode(),
_recovery_proof_key(user),
nonce.encode(),
hashlib.sha256,
).hexdigest()
@@ -922,6 +1075,10 @@ def recovery_data():
'recovery_enc_salt': user.recovery_enc_salt,
'recovery_iv': user.recovery_iv,
'nonce': nonce,
# Tells the client which value to key the HMAC proof with:
# 'verifier' → PBKDF2(recovery_code, "passkeeper-recovery-verifier:" + email)
# 'legacy' → the enc_key_salt decrypted out of the recovery blob
'proof_scheme': 'verifier' if user.recovery_verifier else 'legacy',
}), 200
@@ -932,11 +1089,14 @@ def recovery_items():
Return encrypted vault items for recovery re-encryption (unauthenticated).
Requires X-Recovery-Proof header containing the HMAC-SHA256 proof:
proof = HMAC-SHA256(key=enc_key_salt_bytes, msg=nonce_from_recovery_data)
proof = HMAC-SHA256(key=recovery_verifier, msg=nonce_from_recovery_data)
The enc_key_salt used as the HMAC key is NOT returned by /recovery/data;
the client must derive it by decrypting the recovery blob with the recovery
code. This ensures only the holder of the recovery code can compute the proof.
The verifier is derived client-side from the recovery code alone and is
never returned by any endpoint, so only the holder of the recovery code can
compute the proof. It is deliberately not enc_key_salt: that value is also
the vault-key PBKDF2 salt and is released to the client on login, so keying
the proof with it allowed anyone holding the master password to forge a
proof and pull the whole vault here — bypassing MFA.
Replay prevention: the challenge is consumed (deleted) on success, then
immediately re-issued with the same expected_proof but a new nonce and a
+5 -3
View File
@@ -220,9 +220,11 @@ def generate_recovery_nonce() -> str:
# NOTE: compute_recovery_proof() is intentionally absent.
# The server cannot decrypt the recovery blob (it was encrypted client-side with
# the user's recovery key). Instead, the expected HMAC is computed inline in
# the /recovery/data route using user.enc_key_salt as the HMAC key, stored in
# flask.session, and compared on submission via verify_recovery_proof() below.
# the user's recovery key). Instead, the expected HMAC is computed inline in the
# /recovery/data route using the key returned by _recovery_proof_key(user) —
# user.recovery_verifier, or user.enc_key_salt for legacy codes — persisted in
# the recovery_challenges table, and compared on submission via
# verify_recovery_proof() below.
def verify_recovery_proof(expected_hmac: str, client_hmac: str) -> bool:
+4 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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, "&amp;")
+3 -10
View File
@@ -321,10 +321,9 @@ async function handleLogin() {
}
if (data.mfa_required) {
// enc_key_salt is withheld by the server until the second factor is
// verified — it now arrives with the /mfa/verify response instead.
_mfaToken = data.mfa_token;
await chrome.storage.session.set({
_pending_enc_key_salt: data.enc_key_salt,
});
handleMfaStage(password);
// Reset MFA view to TOTP mode each time it's shown
$("mfa-totp-section").classList.remove("hidden");
@@ -385,13 +384,7 @@ function handleMfaStage(password) {
return;
}
_mfaToken = null;
const { _pending_enc_key_salt } = await chrome.storage.session.get(
"_pending_enc_key_salt",
);
await completeLogin(
{ ...data, enc_key_salt: _pending_enc_key_salt },
password,
);
await completeLogin(data, password);
} catch (err) {
showError("mfa-error", "Error: " + err.message);
} finally {
@@ -0,0 +1,47 @@
"""add recovery_verifier to users
Revision ID: j0k1l2m3n4o5
Revises: i9j0k1l2m3n4
Create Date: 2026-08-26 00:00:00.000000
Decouples the account-recovery challenge-response proof from enc_key_salt.
Before this change the recovery proof was HMAC-SHA256(key=enc_key_salt, msg=nonce).
Because enc_key_salt is also the PBKDF2 salt for the vault key and is handed to
the client at login, anyone who learned enc_key_salt could forge a recovery proof
and pull the entire encrypted vault from the unauthenticated /recovery/items
endpoint bypassing MFA entirely.
recovery_verifier is an independent 256-bit value derived client-side from the
recovery code alone:
verifier = PBKDF2(recovery_code, "passkeeper-recovery-verifier:" + email,
200_000 iter, SHA-256) 64 hex chars
It is stored server-side purely as the HMAC key for the recovery challenge, and
is never used for any encryption. Knowing enc_key_salt no longer grants the
ability to forge a proof.
NULL = legacy recovery code (created before this migration). Those accounts fall
back to the old enc_key_salt-keyed proof so existing recovery codes keep working;
the settings UI prompts the user to regenerate.
"""
from alembic import op
import sqlalchemy as sa
revision = 'j0k1l2m3n4o5'
down_revision = 'i9j0k1l2m3n4'
branch_labels = None
depends_on = None
def upgrade():
op.add_column(
'users',
sa.Column('recovery_verifier', sa.String(64), nullable=True),
)
def downgrade():
op.drop_column('users', 'recovery_verifier')
+18 -6
View File
@@ -8,7 +8,7 @@
# journalctl -xeu passkeeper.service
#
# Phase 5 additions vs original:
# - WatchdogSec: systemd kills and restarts a hung Gunicorn within 30 s
# - Restart=on-failure: systemd restarts Gunicorn if the master exits non-zero
# - Gunicorn --timeout: workers that don't respond within 25 s are replaced
# - Gunicorn --graceful-timeout: allows in-flight requests to finish on reload
# - PrivateTmp, NoNewPrivileges, ProtectSystem: basic systemd sandboxing
@@ -43,11 +43,23 @@ ExecStart=/home/spuser/.venv/bin/gunicorn \
# Reload (zero-downtime): send USR2 to Gunicorn master
ExecReload=/bin/kill -s USR2 $MAINPID
# Watchdog: systemd sends SIGKILL if Gunicorn doesn't send keepalives within 30 s.
# Requires gunicorn to be started with --preload OR the watchdog plugin; here we
# rely on the worker timeout (25 s) to recycle hung workers before the 30 s
# watchdog fires, which restarts the entire service.
WatchdogSec=30s
# NO WatchdogSec here — deliberately.
#
# WatchdogSec requires the service to send WATCHDOG=1 keepalives over the sd_notify
# socket. Gunicorn only does that when systemd exports NOTIFY_SOCKET, which happens
# only under Type=notify (+ NotifyAccess=main). This unit is Type=simple (the
# default), so no keepalive was ever sent, systemd treated the service as hung, and
# SIGKILLed it every ~30 s. Restart=on-failure then brought it back after RestartSec,
# producing a repeating window of 502s from Nginx.
#
# Hung *workers* are already handled by Gunicorn's own --timeout above; a crashed
# *master* is already handled by Restart=on-failure below. The watchdog added no
# coverage, only outages.
#
# To re-enable it properly (optional), all three lines are required:
# Type=notify
# NotifyAccess=main
# WatchdogSec=30s
Restart=on-failure
RestartSec=5s