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.
|
# 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_enc_salt = db.Column(db.String(128), nullable=True)
|
||||||
recovery_iv = db.Column(db.String(64), 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,
|
# Brute-force lockout — incremented on every failed login attempt,
|
||||||
# reset to 0 on success. locked_until is set to now()+15min after
|
# reset to 0 on success. locked_until is set to now()+15min after
|
||||||
# MAX_FAILED_LOGINS consecutive failures.
|
# MAX_FAILED_LOGINS consecutive failures.
|
||||||
|
|||||||
+227
-67
@@ -31,6 +31,103 @@ auth_bp = Blueprint('auth', __name__)
|
|||||||
EMAIL_RE = re.compile(r'^[^@\s]+@[^@\s]+\.[^@\s]+$')
|
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'])
|
@auth_bp.route('/register', methods=['POST'])
|
||||||
@limiter.limit('10 per minute')
|
@limiter.limit('10 per minute')
|
||||||
def register():
|
def register():
|
||||||
@@ -175,13 +272,17 @@ def login():
|
|||||||
)
|
)
|
||||||
db.session.commit()
|
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:
|
if user.totp_enabled:
|
||||||
mfa_token = generate_mfa_token(user.id)
|
mfa_token = generate_mfa_token(user.id)
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'mfa_required': True,
|
'mfa_required': True,
|
||||||
'mfa_token': mfa_token,
|
'mfa_token': mfa_token,
|
||||||
'enc_key_salt': user.enc_key_salt,
|
|
||||||
}), 200
|
}), 200
|
||||||
|
|
||||||
tokens = generate_tokens(user.id)
|
tokens = generate_tokens(user.id)
|
||||||
@@ -437,6 +538,8 @@ def mfa_verify():
|
|||||||
return jsonify({
|
return jsonify({
|
||||||
'access_token': tokens['access_token'],
|
'access_token': tokens['access_token'],
|
||||||
'refresh_token': tokens['refresh_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
|
}), 200
|
||||||
|
|
||||||
|
|
||||||
@@ -581,6 +684,9 @@ def change_password():
|
|||||||
items = data.get('items', []) # [{id, enc_data, iv, enc_name?, iv_name?}, ...]
|
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_enc = data.get('sharing_private_key_enc', '')
|
||||||
sharing_private_key_iv = data.get('sharing_private_key_iv', '')
|
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:
|
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
|
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
|
return jsonify({'error': 'Current password is incorrect'}), 401
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from app.models.vault_item import VaultItem
|
# Refuse the rotation outright unless every item was re-encrypted —
|
||||||
|
# see _apply_reencrypted_items. Raises IncompleteReencryption otherwise.
|
||||||
# Bulk-update all vault item ciphertexts with new vault key encryption
|
updated, total = _apply_reencrypted_items(
|
||||||
item_ids = [i.get('id') for i in items if i.get('id')]
|
user.id, items, allow_partial=allow_partial
|
||||||
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']
|
|
||||||
|
|
||||||
# Update credentials
|
# Update credentials
|
||||||
user.master_hash = hash_auth_token(new_auth_hash)
|
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
|
# Clear recovery data — it was encrypted with the old vault key and is now invalid
|
||||||
user.recovery_enc_salt = None
|
user.recovery_enc_salt = None
|
||||||
user.recovery_iv = None
|
user.recovery_iv = None
|
||||||
|
user.recovery_verifier = None
|
||||||
# Re-encrypt sharing private key with new vault key if the client sent it.
|
# 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.
|
# Without this update, the old ciphertext would be undecryptable after key rotation.
|
||||||
if sharing_private_key_enc and sharing_private_key_iv:
|
if sharing_private_key_enc and sharing_private_key_iv:
|
||||||
@@ -645,10 +730,39 @@ def change_password():
|
|||||||
action='auth.change_password',
|
action='auth.change_password',
|
||||||
resource_type='user',
|
resource_type='user',
|
||||||
resource_id=user.id,
|
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(),
|
ip_address=client_ip(),
|
||||||
)
|
)
|
||||||
db.session.commit()
|
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:
|
except Exception:
|
||||||
db.session.rollback()
|
db.session.rollback()
|
||||||
_log.exception('change_password failed for user %s', g.current_user_id)
|
_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 {}
|
data = request.get_json(silent=True) or {}
|
||||||
recovery_enc_salt = data.get('recovery_enc_salt', '').strip()
|
recovery_enc_salt = data.get('recovery_enc_salt', '').strip()
|
||||||
recovery_iv = data.get('recovery_iv', '').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:
|
if not recovery_enc_salt or not recovery_iv:
|
||||||
return jsonify({'error': 'recovery_enc_salt and recovery_iv are required'}), 400
|
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 = db.session.get(User, g.current_user_id)
|
||||||
user.recovery_enc_salt = recovery_enc_salt
|
user.recovery_enc_salt = recovery_enc_salt
|
||||||
user.recovery_iv = recovery_iv
|
user.recovery_iv = recovery_iv
|
||||||
|
user.recovery_verifier = recovery_verifier
|
||||||
|
|
||||||
AuditLog.log(
|
AuditLog.log(
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
@@ -754,7 +875,13 @@ def recovery_setup():
|
|||||||
def recovery_status():
|
def recovery_status():
|
||||||
"""Return whether the user has a recovery code configured."""
|
"""Return whether the user has a recovery code configured."""
|
||||||
user = db.session.get(User, g.current_user_id)
|
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'])
|
@auth_bp.route('/recover', methods=['POST'])
|
||||||
@@ -771,7 +898,12 @@ def recover_account():
|
|||||||
5. Client POSTs everything here in one atomic payload.
|
5. Client POSTs everything here in one atomic payload.
|
||||||
|
|
||||||
The server validates recovery_proof against the value stored in the DB
|
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.
|
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
|
Challenge state is stored in the database, not the Flask session, so the
|
||||||
flow works correctly across all Gunicorn workers.
|
flow works correctly across all Gunicorn workers.
|
||||||
@@ -783,6 +915,11 @@ def recover_account():
|
|||||||
new_enc_key_salt = data.get('new_enc_key_salt', '')
|
new_enc_key_salt = data.get('new_enc_key_salt', '')
|
||||||
client_proof = data.get('recovery_proof', '')
|
client_proof = data.get('recovery_proof', '')
|
||||||
items = data.get('items', [])
|
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]):
|
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
|
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:
|
try:
|
||||||
from app.models.vault_item import VaultItem
|
# Refuse to rotate the key while items remain under the old one —
|
||||||
|
# see _apply_reencrypted_items. Raises IncompleteReencryption otherwise.
|
||||||
item_ids = [i.get('id') for i in items if i.get('id')]
|
updated, total = _apply_reencrypted_items(
|
||||||
existing = {
|
user.id, items, allow_partial=allow_partial
|
||||||
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']
|
|
||||||
|
|
||||||
user.master_hash = hash_auth_token(new_auth_hash)
|
user.master_hash = hash_auth_token(new_auth_hash)
|
||||||
user.enc_key_salt = new_enc_key_salt
|
user.enc_key_salt = new_enc_key_salt
|
||||||
# Recovery code is consumed — clear it so it cannot be reused.
|
# Recovery code is consumed — clear it so it cannot be reused.
|
||||||
user.recovery_enc_salt = None
|
user.recovery_enc_salt = None
|
||||||
user.recovery_iv = None
|
user.recovery_iv = None
|
||||||
|
user.recovery_verifier = None
|
||||||
|
|
||||||
AuditLog.log(
|
AuditLog.log(
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
action='auth.recovery_success',
|
action='auth.recovery_success',
|
||||||
resource_type='user',
|
resource_type='user',
|
||||||
resource_id=user.id,
|
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(),
|
ip_address=client_ip(),
|
||||||
)
|
)
|
||||||
db.session.commit()
|
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:
|
except Exception:
|
||||||
db.session.rollback()
|
db.session.rollback()
|
||||||
_log.exception('recover_account failed for user %s', user.id)
|
_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.
|
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:
|
The nonce is used for the HMAC-SHA256 challenge-response proof:
|
||||||
- Client decrypts recovery_enc_salt → gets enc_key_salt bytes.
|
- Client derives the recovery verifier from the recovery code:
|
||||||
- Client computes: proof = HMAC-SHA256(key=enc_key_salt_bytes, msg=nonce)
|
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),
|
- Server stores expected proof in the DB (recovery_challenges table),
|
||||||
verifying it on /recover and /recovery/items without ever receiving
|
verifying it on /recover and /recovery/items.
|
||||||
enc_key_salt in plaintext.
|
|
||||||
|
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).
|
Returns 404 if no recovery code is configured (prevents user enumeration).
|
||||||
The challenge is stored in the database (not the Flask session cookie) so
|
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.
|
# enc_key_salt in plaintext.
|
||||||
nonce = generate_recovery_nonce()
|
nonce = generate_recovery_nonce()
|
||||||
expected_proof = _hmac.new(
|
expected_proof = _hmac.new(
|
||||||
user.enc_key_salt.encode(),
|
_recovery_proof_key(user),
|
||||||
nonce.encode(),
|
nonce.encode(),
|
||||||
hashlib.sha256,
|
hashlib.sha256,
|
||||||
).hexdigest()
|
).hexdigest()
|
||||||
@@ -922,6 +1075,10 @@ def recovery_data():
|
|||||||
'recovery_enc_salt': user.recovery_enc_salt,
|
'recovery_enc_salt': user.recovery_enc_salt,
|
||||||
'recovery_iv': user.recovery_iv,
|
'recovery_iv': user.recovery_iv,
|
||||||
'nonce': nonce,
|
'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
|
}), 200
|
||||||
|
|
||||||
|
|
||||||
@@ -932,11 +1089,14 @@ def recovery_items():
|
|||||||
Return encrypted vault items for recovery re-encryption (unauthenticated).
|
Return encrypted vault items for recovery re-encryption (unauthenticated).
|
||||||
|
|
||||||
Requires X-Recovery-Proof header containing the HMAC-SHA256 proof:
|
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 verifier is derived client-side from the recovery code alone and is
|
||||||
the client must derive it by decrypting the recovery blob with the recovery
|
never returned by any endpoint, so only the holder of the recovery code can
|
||||||
code. This ensures only the holder of the recovery code can compute the proof.
|
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
|
Replay prevention: the challenge is consumed (deleted) on success, then
|
||||||
immediately re-issued with the same expected_proof but a new nonce and a
|
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.
|
# NOTE: compute_recovery_proof() is intentionally absent.
|
||||||
# The server cannot decrypt the recovery blob (it was encrypted client-side with
|
# 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 user's recovery key). Instead, the expected HMAC is computed inline in the
|
||||||
# the /recovery/data route using user.enc_key_salt as the HMAC key, stored in
|
# /recovery/data route using the key returned by _recovery_proof_key(user) —
|
||||||
# flask.session, and compared on submission via verify_recovery_proof() below.
|
# 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:
|
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
|
// Temporarily held between Step 1 and Step 2
|
||||||
let _pendingMfaToken = null;
|
let _pendingMfaToken = null;
|
||||||
let _pendingEncKeySalt = null;
|
|
||||||
let _pendingPassword = null;
|
let _pendingPassword = null;
|
||||||
|
|
||||||
async function handleLogin(e) {
|
async function handleLogin(e) {
|
||||||
@@ -114,11 +113,11 @@ const Auth = (() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (data.mfa_required) {
|
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;
|
_pendingMfaToken = data.mfa_token;
|
||||||
_pendingEncKeySalt = data.enc_key_salt;
|
|
||||||
_pendingPassword = password;
|
_pendingPassword = password;
|
||||||
sessionStorage.setItem("enc_key_salt", data.enc_key_salt);
|
|
||||||
showMfaStep();
|
showMfaStep();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -183,7 +182,7 @@ const Auth = (() => {
|
|||||||
await completeLogin(_pendingPassword, {
|
await completeLogin(_pendingPassword, {
|
||||||
access_token: data.access_token,
|
access_token: data.access_token,
|
||||||
refresh_token: data.refresh_token,
|
refresh_token: data.refresh_token,
|
||||||
enc_key_salt: _pendingEncKeySalt,
|
enc_key_salt: data.enc_key_salt,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
errEl.textContent = "An unexpected error occurred. Please try again.";
|
errEl.textContent = "An unexpected error occurred. Please try again.";
|
||||||
@@ -228,7 +227,6 @@ const Auth = (() => {
|
|||||||
document.getElementById("mfa-code").value = "";
|
document.getElementById("mfa-code").value = "";
|
||||||
document.getElementById("mfa-error").classList.add("hidden");
|
document.getElementById("mfa-error").classList.add("hidden");
|
||||||
_pendingMfaToken = null;
|
_pendingMfaToken = null;
|
||||||
_pendingEncKeySalt = null;
|
|
||||||
_pendingPassword = null;
|
_pendingPassword = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+98
-12
@@ -159,17 +159,54 @@ const Recover = (() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compute the HMAC-SHA256 recovery proof.
|
* Derive the recovery verifier: 256 bits of PBKDF2 over the recovery code,
|
||||||
* proof = HMAC-SHA256(key=enc_key_salt_bytes, msg=nonce_bytes)
|
* 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
|
* This is the HMAC key for the recovery challenge under the current scheme.
|
||||||
* blob (and therefore holds the right recovery code) without transmitting
|
* It depends on the recovery code alone and is used for nothing else, so it
|
||||||
* enc_key_salt in plaintext.
|
* 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(
|
const keyMaterial = await subtle.importKey(
|
||||||
"raw",
|
"raw",
|
||||||
strToBytes(encKeySalt),
|
strToBytes(proofKey),
|
||||||
{ name: "HMAC", hash: "SHA-256" },
|
{ name: "HMAC", hash: "SHA-256" },
|
||||||
false,
|
false,
|
||||||
["sign"],
|
["sign"],
|
||||||
@@ -245,9 +282,15 @@ const Recover = (() => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compute HMAC-SHA256 proof: proves we correctly decrypted the blob
|
// Key the proof on the recovery verifier when the account has one.
|
||||||
// without sending enc_key_salt in plaintext.
|
// Legacy accounts (recovery code created before recovery_verifier existed)
|
||||||
const proof = await computeRecoveryProof(decryptedEncKeySalt, data.nonce);
|
// 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
|
// Derive the old vault key using the recovery code as master password proxy
|
||||||
_oldVaultKey = await Crypto.deriveVaultKey(rawCode, decryptedEncKeySalt);
|
_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 = [];
|
let reEncryptedItems = [];
|
||||||
if (itemsRes.ok) {
|
let totalItems = 0;
|
||||||
|
const failedItemIds = [];
|
||||||
|
{
|
||||||
const itemsData = await itemsRes.json();
|
const itemsData = await itemsRes.json();
|
||||||
|
totalItems = itemsData.items.length;
|
||||||
// Re-encrypt each item: old vault key → new vault key
|
// Re-encrypt each item: old vault key → new vault key
|
||||||
for (const item of itemsData.items) {
|
for (const item of itemsData.items) {
|
||||||
try {
|
try {
|
||||||
@@ -369,12 +423,43 @@ const Recover = (() => {
|
|||||||
}
|
}
|
||||||
reEncryptedItems.push({ id: item.id, enc_data, iv, ...encNamePayload });
|
reEncryptedItems.push({ id: item.id, enc_data, iv, ...encNamePayload });
|
||||||
} catch {
|
} 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}`);
|
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
|
// Submit recovery
|
||||||
const recoverRes = await fetch("/api/auth/recover", {
|
const recoverRes = await fetch("/api/auth/recover", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -385,6 +470,7 @@ const Recover = (() => {
|
|||||||
new_enc_key_salt: newEncKeySalt,
|
new_enc_key_salt: newEncKeySalt,
|
||||||
recovery_proof: _recoveryProof,
|
recovery_proof: _recoveryProof,
|
||||||
items: reEncryptedItems,
|
items: reEncryptedItems,
|
||||||
|
allow_partial: allowPartial,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+90
-8
@@ -3554,12 +3554,17 @@ const Vault = (() => {
|
|||||||
btn.textContent = `Re-encrypting ${items.length} item(s)…`;
|
btn.textContent = `Re-encrypting ${items.length} item(s)…`;
|
||||||
|
|
||||||
const reEncrypted = [];
|
const reEncrypted = [];
|
||||||
|
const failedIds = [];
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const plain = await Crypto.decryptItem(
|
let plain;
|
||||||
vaultKey,
|
try {
|
||||||
item.enc_data,
|
plain = await Crypto.decryptItem(vaultKey, item.enc_data, item.iv);
|
||||||
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);
|
const { enc_data, iv } = await Crypto.encryptItem(newVaultKey, plain);
|
||||||
// Re-encrypt the name if it was previously encrypted.
|
// Re-encrypt the name if it was previously encrypted.
|
||||||
let encNamePayload = {};
|
let encNamePayload = {};
|
||||||
@@ -3580,6 +3585,19 @@ const Vault = (() => {
|
|||||||
reEncrypted.push({ id: item.id, enc_data, iv, ...encNamePayload });
|
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.
|
// 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
|
// 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.
|
// vault key without re-encrypting it would leave it permanently unreadable.
|
||||||
@@ -3644,7 +3662,10 @@ const Vault = (() => {
|
|||||||
showToast("Password changed. Please log in again.");
|
showToast("Password changed. Please log in again.");
|
||||||
setTimeout(() => redirectToLogin(), 1500);
|
setTimeout(() => redirectToLogin(), 1500);
|
||||||
} catch (err) {
|
} 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");
|
cpError.classList.remove("hidden");
|
||||||
console.error(err);
|
console.error(err);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -3663,7 +3684,16 @@ const Vault = (() => {
|
|||||||
const statusEl = document.getElementById("recovery-status-text");
|
const statusEl = document.getElementById("recovery-status-text");
|
||||||
const actionsEl = document.getElementById("recovery-actions");
|
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 =
|
statusEl.textContent =
|
||||||
"✅ A recovery code is configured for your account.";
|
"✅ A recovery code is configured for your account.";
|
||||||
actionsEl.innerHTML =
|
actionsEl.innerHTML =
|
||||||
@@ -3753,10 +3783,23 @@ const Vault = (() => {
|
|||||||
const recovery_enc_salt = bytesToBase64(ciphertext);
|
const recovery_enc_salt = bytesToBase64(ciphertext);
|
||||||
const recovery_iv = bytesToBase64(iv);
|
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
|
// Store on server
|
||||||
const res = await apiFetch("/api/auth/recovery/setup", {
|
const res = await apiFetch("/api/auth/recovery/setup", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ recovery_enc_salt, recovery_iv }),
|
body: JSON.stringify({
|
||||||
|
recovery_enc_salt,
|
||||||
|
recovery_iv,
|
||||||
|
recovery_verifier,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
if (!res) return;
|
if (!res) return;
|
||||||
|
|
||||||
@@ -4532,6 +4575,45 @@ const Vault = (() => {
|
|||||||
.catch(() => {});
|
.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) {
|
function escHtml(str) {
|
||||||
return String(str)
|
return String(str)
|
||||||
.replace(/&/g, "&")
|
.replace(/&/g, "&")
|
||||||
|
|||||||
@@ -321,10 +321,9 @@ async function handleLogin() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (data.mfa_required) {
|
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;
|
_mfaToken = data.mfa_token;
|
||||||
await chrome.storage.session.set({
|
|
||||||
_pending_enc_key_salt: data.enc_key_salt,
|
|
||||||
});
|
|
||||||
handleMfaStage(password);
|
handleMfaStage(password);
|
||||||
// Reset MFA view to TOTP mode each time it's shown
|
// Reset MFA view to TOTP mode each time it's shown
|
||||||
$("mfa-totp-section").classList.remove("hidden");
|
$("mfa-totp-section").classList.remove("hidden");
|
||||||
@@ -385,13 +384,7 @@ function handleMfaStage(password) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_mfaToken = null;
|
_mfaToken = null;
|
||||||
const { _pending_enc_key_salt } = await chrome.storage.session.get(
|
await completeLogin(data, password);
|
||||||
"_pending_enc_key_salt",
|
|
||||||
);
|
|
||||||
await completeLogin(
|
|
||||||
{ ...data, enc_key_salt: _pending_enc_key_salt },
|
|
||||||
password,
|
|
||||||
);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showError("mfa-error", "Error: " + err.message);
|
showError("mfa-error", "Error: " + err.message);
|
||||||
} finally {
|
} 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
|
# journalctl -xeu passkeeper.service
|
||||||
#
|
#
|
||||||
# Phase 5 additions vs original:
|
# 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 --timeout: workers that don't respond within 25 s are replaced
|
||||||
# - Gunicorn --graceful-timeout: allows in-flight requests to finish on reload
|
# - Gunicorn --graceful-timeout: allows in-flight requests to finish on reload
|
||||||
# - PrivateTmp, NoNewPrivileges, ProtectSystem: basic systemd sandboxing
|
# - PrivateTmp, NoNewPrivileges, ProtectSystem: basic systemd sandboxing
|
||||||
@@ -43,11 +43,23 @@ ExecStart=/home/spuser/.venv/bin/gunicorn \
|
|||||||
# Reload (zero-downtime): send USR2 to Gunicorn master
|
# Reload (zero-downtime): send USR2 to Gunicorn master
|
||||||
ExecReload=/bin/kill -s USR2 $MAINPID
|
ExecReload=/bin/kill -s USR2 $MAINPID
|
||||||
|
|
||||||
# Watchdog: systemd sends SIGKILL if Gunicorn doesn't send keepalives within 30 s.
|
# NO WatchdogSec here — deliberately.
|
||||||
# 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
|
# WatchdogSec requires the service to send WATCHDOG=1 keepalives over the sd_notify
|
||||||
# watchdog fires, which restarts the entire service.
|
# socket. Gunicorn only does that when systemd exports NOTIFY_SOCKET, which happens
|
||||||
WatchdogSec=30s
|
# 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
|
Restart=on-failure
|
||||||
RestartSec=5s
|
RestartSec=5s
|
||||||
|
|||||||
Reference in New Issue
Block a user