""" Regression tests for registration account-existence disclosure (finding #8). /register answered 409 "Email already registered", which let anyone probe whether a given address has a PassKeeper account — a ready-made target list for phishing, and precisely what /login goes out of its way not to reveal. Both branches must now be indistinguishable in status, body, and timing. """ import time from app import db from app.models.audit_log import AuditLog from app.models.user import User from tests.conftest import login, register def test_duplicate_registration_is_indistinguishable(client, app): first = register(client, 'user@example.com', 'HASH-1', 'SALT-1') second = register(client, 'user@example.com', 'HASH-2', 'SALT-2') assert first.status_code == second.status_code == 202 assert first.get_json() == second.get_json(), ( 'the response differs for an existing address, so it can be probed' ) def test_duplicate_registration_does_not_touch_the_existing_account(client, app): """The generic response must not come at the cost of overwriting credentials.""" register(client, 'user@example.com', 'HASH-1', 'SALT-1') original = User.query.filter_by(email='user@example.com').first() original_hash, original_salt = original.master_hash, original.enc_key_salt register(client, 'user@example.com', 'ATTACKER-HASH', 'ATTACKER-SALT') user = User.query.filter_by(email='user@example.com').first() assert user.master_hash == original_hash, 'existing credentials overwritten' assert user.enc_key_salt == original_salt assert User.query.filter_by(email='user@example.com').count() == 1 # The original password must still be the one that works. assert login(client, 'user@example.com', 'HASH-1').status_code == 200 assert login(client, 'user@example.com', 'ATTACKER-HASH').status_code == 401 def test_registration_response_does_not_name_the_cause(client, app): register(client, 'user@example.com') body = register(client, 'user@example.com').get_json() text = ' '.join(str(v) for v in body.values()).lower() for leak in ('already', 'exists', 'taken', 'registered account', 'duplicate'): assert leak not in text, f'response body leaks existence via {leak!r}: {body}' def test_timing_does_not_disclose_existence(client, app): """ Creating an account runs Argon2id, which is deliberately slow. If the duplicate branch returned early it would be measurably faster and the oracle would survive in the timing even though the body is identical. Uses a loose bound: this asserts the expensive work happens on both paths, not that timing is cryptographically uniform. """ register(client, 'taken@example.com') def elapsed(email): start = time.perf_counter() register(client, email) return time.perf_counter() - start new_times = [elapsed(f'fresh{i}@example.com') for i in range(3)] dup_times = [elapsed('taken@example.com') for _ in range(3)] new_avg = sum(new_times) / len(new_times) dup_avg = sum(dup_times) / len(dup_times) slower, faster = max(new_avg, dup_avg), min(new_avg, dup_avg) assert slower < faster * 4, ( f'timing distinguishes the branches: new={new_avg:.4f}s dup={dup_avg:.4f}s' ) def test_duplicate_attempt_is_audited(client, app): """Invisible to the prober, but the operator should still see the attempts.""" register(client, 'user@example.com') register(client, 'user@example.com') entry = (AuditLog.query.filter_by(action='auth.register_duplicate') .order_by(AuditLog.id.desc()).first()) assert entry is not None, 'duplicate registration attempt was not audited' assert 'user@example.com' not in (entry.detail or ''), ( 'audit detail should not need the probed address to be useful' ) def test_validation_errors_still_reported(client, app): """Input validation does not reveal existence, so it stays specific.""" assert register(client, 'not-an-email').status_code == 400 assert client.post('/api/auth/register', json={'email': 'a@b.co'}).status_code == 400 assert client.post('/api/auth/register', json={'email': 'a@b.co', 'auth_hash': 'h'}).status_code == 400 def test_new_registration_still_creates_a_usable_account(client, app): """The privacy fix must not break the happy path.""" assert register(client, 'fresh@example.com', 'HASH', 'SALT').status_code == 202 assert User.query.filter_by(email='fresh@example.com').first() is not None res = login(client, 'fresh@example.com', 'HASH') assert res.status_code == 200 assert res.get_json()['enc_key_salt'] == 'SALT'