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
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:
@@ -0,0 +1,75 @@
|
||||
"""
|
||||
Shared pytest fixtures.
|
||||
|
||||
Runs the real app factory against in-memory SQLite. The server treats all
|
||||
client-side crypto as opaque strings (auth_hash, enc_data, iv, enc_name), so
|
||||
these tests can pass arbitrary values for them — no Web Crypto needed. The one
|
||||
place real crypto matters is the recovery proof, which is plain HMAC-SHA256 and
|
||||
is computed here exactly as recover.js does.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from app import create_app, db as _db # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
application = create_app('testing')
|
||||
with application.app_context():
|
||||
_db.create_all()
|
||||
yield application
|
||||
_db.session.remove()
|
||||
_db.drop_all()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(app):
|
||||
return app.test_client()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db(app):
|
||||
return _db
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def register(client, email='user@example.com', auth_hash='AUTH-HASH-V1',
|
||||
enc_key_salt='SALT-V1'):
|
||||
return client.post('/api/auth/register', json={
|
||||
'email': email, 'auth_hash': auth_hash, 'enc_key_salt': enc_key_salt,
|
||||
})
|
||||
|
||||
|
||||
def login(client, email='user@example.com', auth_hash='AUTH-HASH-V1'):
|
||||
return client.post('/api/auth/login', json={'email': email, 'auth_hash': auth_hash})
|
||||
|
||||
|
||||
def auth_headers(token):
|
||||
return {'Authorization': f'Bearer {token}'}
|
||||
|
||||
|
||||
def make_user(client, email='user@example.com', auth_hash='AUTH-HASH-V1',
|
||||
enc_key_salt='SALT-V1'):
|
||||
"""Register + log in. Returns (access_token, refresh_token)."""
|
||||
assert register(client, email, auth_hash, enc_key_salt).status_code == 201
|
||||
res = login(client, email, auth_hash)
|
||||
assert res.status_code == 200, res.get_json()
|
||||
body = res.get_json()
|
||||
return body['access_token'], body['refresh_token']
|
||||
|
||||
|
||||
def add_item(client, token, name='password', enc_data='CT', iv='IV'):
|
||||
"""Create a vault item. `name` is the server-side type label."""
|
||||
res = client.post('/api/vault', headers=auth_headers(token), json={
|
||||
'name': name, 'item_type': 'password',
|
||||
'enc_data': enc_data, 'iv': iv,
|
||||
'enc_name': 'ENCNAME', 'iv_name': 'IVNAME',
|
||||
})
|
||||
assert res.status_code == 201, res.get_json()
|
||||
return res.get_json()['id']
|
||||
@@ -0,0 +1,109 @@
|
||||
"""
|
||||
Guards on deployment configuration that only bites in production.
|
||||
|
||||
These are the settings whose failure mode is an intermittent 502 rather than a
|
||||
stack trace, so nothing else catches them drifting apart.
|
||||
"""
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
NGINX = (ROOT / 'scripts' / 'passkeeper-nginx.conf').read_text(encoding='utf-8')
|
||||
UNIT = (ROOT / 'scripts' / 'passkeeper.service').read_text(encoding='utf-8')
|
||||
|
||||
|
||||
@pytest.fixture(scope='module')
|
||||
def gunicorn_conf():
|
||||
ns = {}
|
||||
exec(compile((ROOT / 'gunicorn.conf.py').read_text(encoding='utf-8'),
|
||||
'gunicorn.conf.py', 'exec'), ns)
|
||||
return ns
|
||||
|
||||
|
||||
def _nginx_seconds(directive):
|
||||
m = re.search(rf'^\s*{directive}\s+(\d+)s;', NGINX, re.M)
|
||||
assert m, f'{directive} not found in passkeeper-nginx.conf'
|
||||
return int(m.group(1))
|
||||
|
||||
|
||||
def test_nginx_gives_up_before_gunicorn_kills_the_worker(gunicorn_conf):
|
||||
"""
|
||||
The 502 invariant. If Gunicorn's timeout fires first the connection is
|
||||
severed mid-response and Nginx reports 502; if Nginx times out first the
|
||||
client gets a clean 504 instead.
|
||||
"""
|
||||
assert _nginx_seconds('proxy_read_timeout') < gunicorn_conf['timeout']
|
||||
|
||||
|
||||
def test_gunicorn_holds_keepalive_longer_than_nginx(gunicorn_conf):
|
||||
"""
|
||||
Nginx must be the side that closes an idle upstream connection. If Gunicorn
|
||||
closes one as Nginx is reusing it, that request fails as a 502.
|
||||
"""
|
||||
assert gunicorn_conf['keepalive'] > _nginx_seconds('keepalive_timeout')
|
||||
|
||||
|
||||
def test_preload_app_is_disabled(gunicorn_conf):
|
||||
"""
|
||||
create_app() starts an APScheduler thread, and threads do not survive
|
||||
fork(). Under preload_app the scheduler would exist only in the arbiter,
|
||||
which serves no requests, so the cleanup job would silently never run.
|
||||
"""
|
||||
assert gunicorn_conf['preload_app'] is False
|
||||
|
||||
|
||||
def test_static_location_repeats_every_security_header():
|
||||
"""
|
||||
Nginx drops ALL inherited add_header directives in any location that
|
||||
declares one of its own. /static/ sets Cache-Control, so without explicit
|
||||
copies every JS and CSS asset ships with no CSP, HSTS or X-Frame-Options.
|
||||
"""
|
||||
static = re.search(r'location /static/ \{(.*?)\n \}', NGINX, re.S)
|
||||
assert static, 'no /static/ location block found'
|
||||
body = static.group(1)
|
||||
|
||||
for header in ('Strict-Transport-Security', 'X-Frame-Options',
|
||||
'X-Content-Type-Options', 'Referrer-Policy',
|
||||
'Permissions-Policy', 'Content-Security-Policy'):
|
||||
assert header in body, f'/static/ is missing {header}'
|
||||
|
||||
|
||||
def test_hibp_origin_is_allowed_in_every_csp():
|
||||
"""The security dashboard's breach check needs this origin in connect-src."""
|
||||
policies = re.findall(r'connect-src[^;"]*', NGINX)
|
||||
assert policies, 'no connect-src directive found'
|
||||
for p in policies:
|
||||
assert 'https://api.pwnedpasswords.com' in p, p
|
||||
|
||||
|
||||
def test_unit_has_no_watchdog():
|
||||
"""
|
||||
WatchdogSec without Type=notify meant systemd never received a keepalive,
|
||||
declared the service hung, and SIGKILLed it on a loop — a repeating window
|
||||
of 502s. Re-enabling it requires Type=notify AND NotifyAccess=main.
|
||||
"""
|
||||
active = [ln for ln in UNIT.splitlines()
|
||||
if ln.strip().startswith('WatchdogSec')]
|
||||
if active:
|
||||
assert 'Type=notify' in UNIT and 'NotifyAccess=main' in UNIT, (
|
||||
'WatchdogSec requires Type=notify + NotifyAccess=main or systemd '
|
||||
'will kill the service on a loop'
|
||||
)
|
||||
|
||||
|
||||
def test_unit_reload_does_not_use_usr2():
|
||||
"""
|
||||
USR2 forks a second master without retiring the first, leaving systemd's
|
||||
$MAINPID tracking a stale process.
|
||||
"""
|
||||
reload_line = next((ln for ln in UNIT.splitlines()
|
||||
if ln.strip().startswith('ExecReload=')), '')
|
||||
assert 'USR2' not in reload_line, reload_line
|
||||
|
||||
|
||||
def test_unit_loads_the_gunicorn_config_file():
|
||||
assert 'gunicorn.conf.py' in UNIT, (
|
||||
'the unit no longer references gunicorn.conf.py, so its tuning is dead code'
|
||||
)
|
||||
@@ -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'
|
||||
@@ -0,0 +1,143 @@
|
||||
"""
|
||||
Regression tests for the MFA bypass (review finding #1).
|
||||
|
||||
The bug had two halves:
|
||||
a) /login returned enc_key_salt in the MFA-pending response, before the second
|
||||
factor was verified.
|
||||
b) The recovery challenge was keyed on enc_key_salt, so anyone holding it
|
||||
could forge a proof and pull the whole encrypted vault from the
|
||||
unauthenticated /recovery/items — bypassing MFA entirely.
|
||||
|
||||
Chained, an attacker with only the master password could exfiltrate or take over
|
||||
the account. These tests pin both halves shut.
|
||||
"""
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
|
||||
import pyotp
|
||||
|
||||
from app import db
|
||||
from app.models.user import User
|
||||
from app.services.auth_service import encrypt_totp_secret
|
||||
from tests.conftest import add_item, auth_headers, login, make_user
|
||||
|
||||
|
||||
def _enable_mfa(email='user@example.com'):
|
||||
"""Turn on TOTP directly in the DB and return the plaintext secret."""
|
||||
user = User.query.filter_by(email=email).first()
|
||||
secret = pyotp.random_base32()
|
||||
enc, iv = encrypt_totp_secret(secret)
|
||||
user.totp_secret, user.totp_iv, user.totp_enabled = enc, iv, True
|
||||
db.session.commit()
|
||||
return secret
|
||||
|
||||
|
||||
def test_login_withholds_enc_key_salt_until_mfa_is_verified(client, app):
|
||||
make_user(client)
|
||||
secret = _enable_mfa()
|
||||
|
||||
res = login(client)
|
||||
body = res.get_json()
|
||||
|
||||
assert res.status_code == 200
|
||||
assert body['mfa_required'] is True
|
||||
# The heart of finding #1a.
|
||||
assert 'enc_key_salt' not in body, (
|
||||
'enc_key_salt leaked before the second factor was verified'
|
||||
)
|
||||
|
||||
res = client.post('/api/auth/mfa/verify', json={
|
||||
'mfa_token': body['mfa_token'], 'totp_code': pyotp.TOTP(secret).now(),
|
||||
})
|
||||
verified = res.get_json()
|
||||
assert res.status_code == 200
|
||||
# Released only now, once both factors are proven.
|
||||
assert verified['enc_key_salt'] == 'SALT-V1'
|
||||
|
||||
|
||||
def test_login_without_mfa_still_returns_enc_key_salt(client):
|
||||
"""The withholding must apply only to the MFA-pending path."""
|
||||
make_user(client)
|
||||
body = login(client).get_json()
|
||||
assert 'mfa_required' not in body
|
||||
assert body['enc_key_salt'] == 'SALT-V1'
|
||||
|
||||
|
||||
# ── Finding #1b: the recovery proof must not be forgeable from enc_key_salt ──
|
||||
|
||||
def _setup_recovery(client, token, verifier):
|
||||
res = client.post('/api/auth/recovery/setup', headers=auth_headers(token), json={
|
||||
'recovery_enc_salt': 'RECOVERY-BLOB', 'recovery_iv': 'RECOVERY-IV',
|
||||
'recovery_verifier': verifier,
|
||||
})
|
||||
assert res.status_code == 200, res.get_json()
|
||||
|
||||
|
||||
def test_recovery_proof_cannot_be_forged_from_enc_key_salt(client, app):
|
||||
"""
|
||||
The attack: password known, second factor not. Previously the attacker could
|
||||
key the HMAC with enc_key_salt and walk away with every encrypted item.
|
||||
"""
|
||||
token, _ = make_user(client)
|
||||
add_item(client, token)
|
||||
verifier = 'a' * 64
|
||||
_setup_recovery(client, token, verifier)
|
||||
|
||||
nonce = client.get('/api/auth/recovery/data?email=user@example.com').get_json()['nonce']
|
||||
|
||||
forged = hmac.new(b'SALT-V1', nonce.encode(), hashlib.sha256).hexdigest()
|
||||
res = client.get('/api/auth/recovery/items?email=user@example.com',
|
||||
headers={'X-Recovery-Proof': forged})
|
||||
assert res.status_code == 401, 'enc_key_salt still forges a valid recovery proof'
|
||||
|
||||
|
||||
def test_recovery_proof_from_verifier_is_accepted(client, app):
|
||||
"""The legitimate holder of the recovery code must still get through."""
|
||||
token, _ = make_user(client)
|
||||
add_item(client, token)
|
||||
verifier = 'b' * 64
|
||||
_setup_recovery(client, token, verifier)
|
||||
|
||||
data = client.get('/api/auth/recovery/data?email=user@example.com').get_json()
|
||||
assert data['proof_scheme'] == 'verifier'
|
||||
|
||||
proof = hmac.new(verifier.encode(), data['nonce'].encode(), hashlib.sha256).hexdigest()
|
||||
res = client.get('/api/auth/recovery/items?email=user@example.com',
|
||||
headers={'X-Recovery-Proof': proof})
|
||||
assert res.status_code == 200, res.get_json()
|
||||
assert len(res.get_json()['items']) == 1
|
||||
|
||||
|
||||
def test_legacy_account_falls_back_to_enc_key_salt_proof(client, app):
|
||||
"""
|
||||
Recovery codes created before recovery_verifier must keep working, and be
|
||||
reported as legacy so the UI can prompt a regeneration.
|
||||
"""
|
||||
token, _ = make_user(client)
|
||||
add_item(client, token)
|
||||
user = User.query.filter_by(email='user@example.com').first()
|
||||
user.recovery_enc_salt, user.recovery_iv = 'BLOB', 'IV'
|
||||
user.recovery_verifier = None # pre-migration state
|
||||
db.session.commit()
|
||||
|
||||
status = client.get('/api/auth/recovery/status', headers=auth_headers(token)).get_json()
|
||||
assert status['recovery_configured'] is True
|
||||
assert status['recovery_is_legacy'] is True
|
||||
|
||||
data = client.get('/api/auth/recovery/data?email=user@example.com').get_json()
|
||||
assert data['proof_scheme'] == 'legacy'
|
||||
|
||||
proof = hmac.new(b'SALT-V1', data['nonce'].encode(), hashlib.sha256).hexdigest()
|
||||
res = client.get('/api/auth/recovery/items?email=user@example.com',
|
||||
headers={'X-Recovery-Proof': proof})
|
||||
assert res.status_code == 200
|
||||
|
||||
|
||||
def test_recovery_setup_rejects_malformed_verifier(client):
|
||||
token, _ = make_user(client)
|
||||
for bad in ('', 'short', 'g' * 64, 'A' * 63):
|
||||
res = client.post('/api/auth/recovery/setup', headers=auth_headers(token), json={
|
||||
'recovery_enc_salt': 'B', 'recovery_iv': 'IV', 'recovery_verifier': bad,
|
||||
})
|
||||
assert res.status_code == 400, f'accepted malformed verifier {bad!r}'
|
||||
@@ -0,0 +1,148 @@
|
||||
"""
|
||||
Regression tests for session revocation (review finding #3).
|
||||
|
||||
Changing the master password used to leave every outstanding access and refresh
|
||||
token valid. The response said "Please log in again" but nothing enforced it, so
|
||||
a stolen refresh token kept working for its full 7-day lifetime after the victim
|
||||
changed the password it was obtained under.
|
||||
|
||||
Every JWT now carries an `epoch` claim checked against users.token_epoch.
|
||||
|
||||
Also covers the sub-finding that require_jwt never confirmed the user still
|
||||
existed: a valid token for a deleted account dereferenced None and returned 500.
|
||||
"""
|
||||
import jwt as pyjwt
|
||||
|
||||
from app import db
|
||||
from app.models.user import User
|
||||
from tests.conftest import add_item, auth_headers, login, make_user
|
||||
|
||||
|
||||
def _rotate_password(client, token, ids):
|
||||
"""Complete a full, valid password change."""
|
||||
res = client.post('/api/auth/change-password', headers=auth_headers(token), json={
|
||||
'current_auth_hash': 'AUTH-HASH-V1',
|
||||
'new_auth_hash': 'AUTH-HASH-V2',
|
||||
'new_enc_key_salt': 'SALT-V2',
|
||||
'items': [{'id': i, 'enc_data': f'NEW-{i}', 'iv': f'IV-{i}'} for i in ids],
|
||||
})
|
||||
assert res.status_code == 200, res.get_json()
|
||||
return res
|
||||
|
||||
|
||||
def test_access_token_is_revoked_by_password_change(client, app):
|
||||
token, _ = make_user(client)
|
||||
ids = [add_item(client, token)]
|
||||
|
||||
assert client.get('/api/vault', headers=auth_headers(token)).status_code == 200
|
||||
_rotate_password(client, token, ids)
|
||||
|
||||
res = client.get('/api/vault', headers=auth_headers(token))
|
||||
assert res.status_code == 401, 'access token survived the password change'
|
||||
|
||||
|
||||
def test_refresh_token_is_revoked_by_password_change(client, app):
|
||||
"""
|
||||
The more damaging half: a refresh token is valid for 7 days and can mint
|
||||
fresh access tokens indefinitely.
|
||||
"""
|
||||
token, refresh = make_user(client)
|
||||
ids = [add_item(client, token)]
|
||||
_rotate_password(client, token, ids)
|
||||
|
||||
res = client.post('/api/auth/refresh', json={'refresh_token': refresh})
|
||||
assert res.status_code == 401, 'refresh token survived the password change'
|
||||
|
||||
|
||||
def test_epoch_increments_and_new_login_works(client, app):
|
||||
token, _ = make_user(client)
|
||||
ids = [add_item(client, token)]
|
||||
_rotate_password(client, token, ids)
|
||||
|
||||
user = User.query.filter_by(email='user@example.com').first()
|
||||
assert user.token_epoch == 1
|
||||
|
||||
res = login(client, auth_hash='AUTH-HASH-V2')
|
||||
assert res.status_code == 200
|
||||
new_token = res.get_json()['access_token']
|
||||
assert client.get('/api/vault', headers=auth_headers(new_token)).status_code == 200
|
||||
|
||||
|
||||
def test_recovery_also_revokes_prior_sessions(client, app):
|
||||
"""An attacker holding a token must not survive the victim recovering."""
|
||||
import hashlib
|
||||
import hmac
|
||||
|
||||
verifier = 'd' * 64
|
||||
token, _ = make_user(client)
|
||||
ids = [add_item(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
|
||||
|
||||
nonce = client.get('/api/auth/recovery/data?email=user@example.com').get_json()['nonce']
|
||||
proof = hmac.new(verifier.encode(), nonce.encode(), hashlib.sha256).hexdigest()
|
||||
client.get('/api/auth/recovery/items?email=user@example.com',
|
||||
headers={'X-Recovery-Proof': 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': [{'id': i, 'enc_data': f'NEW-{i}', 'iv': f'IV-{i}'} for i in ids],
|
||||
})
|
||||
assert res.status_code == 200, res.get_json()
|
||||
|
||||
assert client.get('/api/vault', headers=auth_headers(token)).status_code == 401
|
||||
# The tokens handed back by /recover must carry the NEW epoch and work.
|
||||
fresh = res.get_json()['access_token']
|
||||
assert client.get('/api/vault', headers=auth_headers(fresh)).status_code == 200
|
||||
|
||||
|
||||
def test_token_for_deleted_account_is_401_not_500(client, app):
|
||||
"""Previously this dereferenced None inside the handler and returned 500."""
|
||||
token, _ = make_user(client)
|
||||
user = User.query.filter_by(email='user@example.com').first()
|
||||
db.session.delete(user)
|
||||
db.session.commit()
|
||||
|
||||
for path in ('/api/vault', '/api/auth/me', '/api/sharing/keys', '/api/emergency'):
|
||||
res = client.get(path, headers=auth_headers(token))
|
||||
assert res.status_code == 401, f'{path} returned {res.status_code}'
|
||||
|
||||
|
||||
def test_forged_epoch_claim_is_rejected(client, app):
|
||||
"""
|
||||
The epoch is inside the signed payload, so tampering invalidates the
|
||||
signature. Re-signing with the wrong key must also fail.
|
||||
"""
|
||||
token, _ = make_user(client)
|
||||
payload = pyjwt.decode(token, options={'verify_signature': False})
|
||||
payload['epoch'] = 99
|
||||
forged = pyjwt.encode(payload, 'not-the-real-signing-key', algorithm='HS256')
|
||||
|
||||
assert client.get('/api/vault', headers=auth_headers(forged)).status_code == 401
|
||||
|
||||
|
||||
def test_tokens_predating_the_epoch_claim_still_work(client, app):
|
||||
"""
|
||||
Deploying this must not sign existing sessions out: tokens minted before the
|
||||
claim existed decode with epoch 0, matching the column default.
|
||||
"""
|
||||
token, _ = make_user(client)
|
||||
payload = pyjwt.decode(token, options={'verify_signature': False})
|
||||
del payload['epoch'] # simulate a pre-upgrade token
|
||||
legacy = pyjwt.encode(payload, app.config['JWT_SECRET_KEY'], algorithm='HS256')
|
||||
|
||||
assert client.get('/api/vault', headers=auth_headers(legacy)).status_code == 200
|
||||
|
||||
|
||||
def test_logout_still_revokes_via_blacklist(client, app):
|
||||
"""Epoch checking must not have displaced the existing jti blacklist."""
|
||||
token, refresh = make_user(client)
|
||||
assert client.post('/api/auth/logout', headers=auth_headers(token),
|
||||
json={'refresh_token': refresh}).status_code == 200
|
||||
|
||||
assert client.get('/api/vault', headers=auth_headers(token)).status_code == 401
|
||||
assert client.post('/api/auth/refresh',
|
||||
json={'refresh_token': refresh}).status_code == 401
|
||||
@@ -0,0 +1,83 @@
|
||||
"""
|
||||
Regression tests for passkey user verification (review finding #4).
|
||||
|
||||
Both ceremonies used UserVerificationRequirement.PREFERRED with
|
||||
require_user_verification=False, so an authenticator was free to skip the
|
||||
biometric/PIN check. Since a passkey assertion here replaces BOTH the password
|
||||
and the TOTP second factor, that reduced a full login to possession of an
|
||||
unlocked device — enough to enumerate and delete vault items.
|
||||
|
||||
A full ceremony needs a real authenticator, so these tests pin the negotiated
|
||||
options (what the server asks the browser for) and the rejection path. The
|
||||
enforcement half — require_user_verification=True passed to py-webauthn — is
|
||||
asserted directly against the source.
|
||||
"""
|
||||
import inspect
|
||||
import re
|
||||
|
||||
from tests.conftest import auth_headers, make_user
|
||||
|
||||
import app.routes.webauthn as webauthn_routes
|
||||
|
||||
|
||||
def test_registration_options_require_user_verification(client, app):
|
||||
token, _ = make_user(client)
|
||||
res = client.post('/api/webauthn/register/begin',
|
||||
headers=auth_headers(token), json={})
|
||||
assert res.status_code == 200, res.get_json()
|
||||
body = res.get_json()
|
||||
assert body['authenticatorSelection']['userVerification'] == 'required'
|
||||
|
||||
|
||||
def test_authentication_options_require_user_verification(client, app):
|
||||
make_user(client)
|
||||
res = client.post('/api/webauthn/authenticate/begin',
|
||||
json={'email': 'user@example.com'})
|
||||
assert res.status_code == 200, res.get_json()
|
||||
assert res.get_json()['userVerification'] == 'required'
|
||||
|
||||
|
||||
def test_verification_calls_enforce_user_verification():
|
||||
"""
|
||||
Negotiating 'required' is only a request to the browser. The server must
|
||||
also refuse an assertion that comes back without the UV flag set, or the
|
||||
hint is decorative.
|
||||
"""
|
||||
src = inspect.getsource(webauthn_routes)
|
||||
calls = re.findall(r'require_user_verification=(\w+)', src)
|
||||
assert calls, 'no require_user_verification argument found'
|
||||
assert all(v == 'True' for v in calls), (
|
||||
f'require_user_verification must be True everywhere, found: {calls}'
|
||||
)
|
||||
|
||||
|
||||
def test_unknown_credential_is_rejected(client, app):
|
||||
make_user(client)
|
||||
client.post('/api/webauthn/authenticate/begin', json={'email': 'user@example.com'})
|
||||
res = client.post('/api/webauthn/authenticate/complete',
|
||||
json={'id': 'bm9wZQ', 'rawId': 'bm9wZQ'})
|
||||
assert res.status_code == 401
|
||||
assert 'not recognised' in res.get_json()['error']
|
||||
|
||||
|
||||
def test_registration_failure_does_not_leak_exception_text(client, app):
|
||||
"""
|
||||
CLAUDE.md forbids returning str(e) to clients; this handler used to embed
|
||||
the raw py-webauthn message, which quotes attestation internals.
|
||||
"""
|
||||
token, _ = make_user(client)
|
||||
client.post('/api/webauthn/register/begin', headers=auth_headers(token), json={})
|
||||
|
||||
res = client.post('/api/webauthn/register/complete',
|
||||
headers=auth_headers(token), json={'id': 'garbage'})
|
||||
assert res.status_code == 400
|
||||
error = res.get_json()['error']
|
||||
assert error == 'Could not verify this passkey. Please try again.', error
|
||||
|
||||
|
||||
def test_register_complete_requires_a_pending_challenge(client, app):
|
||||
token, _ = make_user(client)
|
||||
res = client.post('/api/webauthn/register/complete',
|
||||
headers=auth_headers(token), json={'id': 'x'})
|
||||
assert res.status_code == 400
|
||||
assert 'No pending registration challenge' in res.get_json()['error']
|
||||
Reference in New Issue
Block a user