Aug 26 - Enhance security 2
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 / Pytest (push) Has been cancelled
CI / Build extension zip (push) Has been cancelled

This commit is contained in:
2026-08-26 12:54:17 -04:00
parent 82dd7c5aef
commit 6c1bef73c8
20 changed files with 1193 additions and 79 deletions
+208
View File
@@ -0,0 +1,208 @@
"""
Regression tests for silent vault destruction (review finding #2).
change_password and /recover both rotate enc_key_salt, which invalidates every
ciphertext under the previous vault key. The client re-encrypts each item and
sends it back — but nothing checked that the payload actually covered every
item. A short payload rotated the key anyway and left the missing items
permanently undecryptable, with no error and a success entry in the audit log.
"""
import hashlib
import hmac
from app import db
from app.models.audit_log import AuditLog
from app.models.user import User
from app.models.vault_item import VaultItem
from tests.conftest import add_item, auth_headers, login, make_user
VERIFIER = 'c' * 64
def _payload(ids):
return [{'id': i, 'enc_data': f'NEW-{i}', 'iv': f'NEWIV-{i}'} for i in ids]
def _change_password(client, token, items, allow_partial=None):
body = {
'current_auth_hash': 'AUTH-HASH-V1',
'new_auth_hash': 'AUTH-HASH-V2',
'new_enc_key_salt': 'SALT-V2',
'items': items,
}
if allow_partial is not None:
body['allow_partial'] = allow_partial
return client.post('/api/auth/change-password', headers=auth_headers(token), json=body)
# -- change_password ---------------------------------------------------------
def test_full_payload_rotates_everything(client, app):
token, _ = make_user(client)
ids = [add_item(client, token) for _ in range(3)]
res = _change_password(client, token, _payload(ids))
assert res.status_code == 200, res.get_json()
for i in ids:
assert db.session.get(VaultItem, i).enc_data == f'NEW-{i}'
user = User.query.filter_by(email='user@example.com').first()
assert user.enc_key_salt == 'SALT-V2'
def test_short_payload_is_refused_and_nothing_changes(client, app):
"""The exact data-loss bug: one item omitted from the re-encryption."""
token, _ = make_user(client)
ids = [add_item(client, token) for _ in range(3)]
res = _change_password(client, token, _payload(ids[:2])) # third omitted
assert res.status_code == 409, res.get_json()
body = res.get_json()
assert body['code'] == 'incomplete_reencryption'
assert (body['expected'], body['received']) == (3, 2)
# Nothing may have been committed: salt unchanged, ciphertext untouched.
user = User.query.filter_by(email='user@example.com').first()
assert user.enc_key_salt == 'SALT-V1', 'key rotated despite refusal'
assert user.token_epoch == 0
for i in ids:
assert db.session.get(VaultItem, i).enc_data == 'CT'
# The old password must still work.
assert login(client).status_code == 200
def test_empty_payload_against_populated_vault_is_refused(client, app):
token, _ = make_user(client)
for _ in range(4):
add_item(client, token)
res = _change_password(client, token, [])
assert res.status_code == 409
assert res.get_json()['received'] == 0
assert User.query.filter_by(email='user@example.com').first().enc_key_salt == 'SALT-V1'
def test_refusal_is_audited(client, app):
token, _ = make_user(client)
ids = [add_item(client, token) for _ in range(2)]
_change_password(client, token, _payload(ids[:1]))
entry = (AuditLog.query
.filter_by(action='auth.change_password_failed')
.order_by(AuditLog.id.desc()).first())
assert entry is not None
assert '1 of 2' in entry.detail
def test_another_users_item_does_not_count_toward_coverage(client, app):
"""A foreign id must not pad the payload up to the expected count."""
token_a, _ = make_user(client, 'a@example.com', 'HASH-A', 'SALT-A')
token_b, _ = make_user(client, 'b@example.com', 'HASH-B', 'SALT-B')
a_ids = [add_item(client, token_a) for _ in range(2)]
b_id = add_item(client, token_b)
res = client.post('/api/auth/change-password', headers=auth_headers(token_a), json={
'current_auth_hash': 'HASH-A', 'new_auth_hash': 'HASH-A2',
'new_enc_key_salt': 'SALT-A2',
'items': _payload([a_ids[0], b_id]), # b_id does not belong to user A
})
assert res.status_code == 409
assert (res.get_json()['expected'], res.get_json()['received']) == (2, 1)
assert db.session.get(VaultItem, b_id).enc_data == 'CT'
# -- /recover ----------------------------------------------------------------
def _setup_recovery(client, token):
assert client.post('/api/auth/recovery/setup', headers=auth_headers(token), json={
'recovery_enc_salt': 'BLOB', 'recovery_iv': 'IV',
'recovery_verifier': VERIFIER,
}).status_code == 200
def _proof(client, email='user@example.com'):
nonce = client.get(f'/api/auth/recovery/data?email={email}').get_json()['nonce']
return hmac.new(VERIFIER.encode(), nonce.encode(), hashlib.sha256).hexdigest()
def _consume_challenge(client, proof):
"""Fetch items exactly as recover.js does, which rotates the challenge."""
return client.get('/api/auth/recovery/items?email=user@example.com',
headers={'X-Recovery-Proof': proof})
def test_recover_refuses_short_payload(client, app):
token, _ = make_user(client)
ids = [add_item(client, token) for _ in range(3)]
_setup_recovery(client, token)
proof = _proof(client)
_consume_challenge(client, proof)
res = client.post('/api/auth/recover', json={
'email': 'user@example.com', 'new_auth_hash': 'AUTH-HASH-V2',
'new_enc_key_salt': 'SALT-V2', 'recovery_proof': proof,
'items': _payload(ids[:2]),
})
assert res.status_code == 409, res.get_json()
user = User.query.filter_by(email='user@example.com').first()
assert user.enc_key_salt == 'SALT-V1'
assert user.recovery_enc_salt == 'BLOB', 'recovery code consumed despite refusal'
def test_recover_allows_partial_when_explicitly_confirmed(client, app):
"""
The escape hatch exists because refusing outright would leave a locked-out
user with no way into their account at all.
"""
token, _ = make_user(client)
ids = [add_item(client, token) for _ in range(3)]
_setup_recovery(client, token)
proof = _proof(client)
_consume_challenge(client, proof)
res = client.post('/api/auth/recover', json={
'email': 'user@example.com', 'new_auth_hash': 'AUTH-HASH-V2',
'new_enc_key_salt': 'SALT-V2', 'recovery_proof': proof,
'items': _payload(ids[:2]), 'allow_partial': True,
})
assert res.status_code == 200, res.get_json()
entry = (AuditLog.query.filter_by(action='auth.recovery_success')
.order_by(AuditLog.id.desc()).first())
assert 'PARTIAL' in entry.detail, 'partial recovery not flagged in the audit log'
def test_recover_full_payload_succeeds_and_consumes_the_code(client, app):
token, _ = make_user(client)
ids = [add_item(client, token) for _ in range(2)]
_setup_recovery(client, token)
proof = _proof(client)
_consume_challenge(client, proof)
res = client.post('/api/auth/recover', json={
'email': 'user@example.com', 'new_auth_hash': 'AUTH-HASH-V2',
'new_enc_key_salt': 'SALT-V2', 'recovery_proof': proof,
'items': _payload(ids),
})
assert res.status_code == 200, res.get_json()
user = User.query.filter_by(email='user@example.com').first()
assert user.enc_key_salt == 'SALT-V2'
assert user.recovery_enc_salt is None
assert user.recovery_verifier is None
assert login(client, auth_hash='AUTH-HASH-V2').status_code == 200
def test_change_password_ignores_allow_partial(client, app):
"""
Recovery has a partial-completion escape hatch; changing the password must
not. The current password keeps working, so there is never a reason to
accept permanent data loss here — the server refuses even if a client asks.
"""
token, _ = make_user(client)
ids = [add_item(client, token) for _ in range(3)]
res = _change_password(client, token, _payload(ids[:1]), allow_partial=True)
assert res.status_code == 409, 'server honoured allow_partial on change-password'
assert User.query.filter_by(email='user@example.com').first().enc_key_salt == 'SALT-V1'