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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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, "&")
|
||||
|
||||
@@ -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')
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user