Files
PassKeeper/tests/test_webauthn_uv.py
nngo 6c1bef73c8
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 2
2026-08-26 12:54:17 -04:00

84 lines
3.4 KiB
Python

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