Files
PassKeeper/tests/test_registration_privacy.py
T
nngo b84a6d9245
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
Aug 26 - Enhance security 4
2026-08-26 14:19:25 -04:00

112 lines
4.6 KiB
Python

"""
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'