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
84 lines
3.4 KiB
Python
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']
|