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
+75
View File
@@ -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']
+109
View File
@@ -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'
)
+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'
+143
View File
@@ -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}'
+148
View File
@@ -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
+83
View File
@@ -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']