mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-07-20 22:51:07 +00:00
4d90577e25
* stage-4171: [security] require auth for passkey enrollment, rebased onto master * Release PC: [security] require auth for passkey enrollment (#4171) Self-rebased Hinotoi-agent's #4171 onto v0.51.441; Nathan-approved security posture. Gates the first-passkey bootstrap like first-password onboarding (loopback/private or explicit opt-in; remote rejected; socket-based locality, no header spoof). Codex SAFE + Opus SHIP + 17 passkey tests green. Co-authored-by: Hinotoi-agent <Hinotoi-agent@users.noreply.github.com> --------- Co-authored-by: nesquena-hermes <agent@nesquena-hermes> Co-authored-by: Hinotoi-agent <Hinotoi-agent@users.noreply.github.com>
398 lines
16 KiB
Python
398 lines
16 KiB
Python
import base64
|
|
import io
|
|
import json
|
|
import hashlib
|
|
from types import SimpleNamespace
|
|
|
|
from cryptography.hazmat.primitives import hashes
|
|
|
|
from cryptography.hazmat.primitives.asymmetric import ec
|
|
|
|
|
|
def b64u(data: bytes) -> str:
|
|
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
|
|
|
|
|
def cbor(obj):
|
|
if isinstance(obj, int):
|
|
if obj >= 0:
|
|
return _cbor_int(0, obj)
|
|
return _cbor_int(1, -1 - obj)
|
|
if isinstance(obj, bytes):
|
|
return _cbor_int(2, len(obj)) + obj
|
|
if isinstance(obj, str):
|
|
raw = obj.encode()
|
|
return _cbor_int(3, len(raw)) + raw
|
|
if isinstance(obj, list):
|
|
return _cbor_int(4, len(obj)) + b"".join(cbor(x) for x in obj)
|
|
if isinstance(obj, dict):
|
|
return _cbor_int(5, len(obj)) + b"".join(cbor(k) + cbor(v) for k, v in obj.items())
|
|
if obj is None:
|
|
return b"\xf6"
|
|
if obj is False:
|
|
return b"\xf4"
|
|
if obj is True:
|
|
return b"\xf5"
|
|
raise TypeError(obj)
|
|
|
|
|
|
def _cbor_int(major, value):
|
|
prefix = major << 5
|
|
if value < 24:
|
|
return bytes([prefix | value])
|
|
if value < 256:
|
|
return bytes([prefix | 24, value])
|
|
if value < 65536:
|
|
return bytes([prefix | 25]) + value.to_bytes(2, "big")
|
|
return bytes([prefix | 26]) + value.to_bytes(4, "big")
|
|
|
|
|
|
class FakeHeaders(dict):
|
|
def get(self, key, default=None):
|
|
return super().get(key, default)
|
|
|
|
|
|
class FakeHandler:
|
|
headers = FakeHeaders({"Host": "localhost:8787"})
|
|
|
|
|
|
def _set_paths(monkeypatch, tmp_path):
|
|
import api.passkeys as passkeys
|
|
monkeypatch.setattr(passkeys, "_CREDENTIALS_FILE", tmp_path / "passkeys.json")
|
|
monkeypatch.setattr(passkeys, "_CHALLENGES_FILE", tmp_path / ".passkey_challenges.json")
|
|
return passkeys
|
|
|
|
|
|
def _client_data(kind, challenge, origin="http://localhost:8787"):
|
|
raw = json.dumps({"type": kind, "challenge": challenge, "origin": origin}).encode()
|
|
return raw, b64u(raw)
|
|
|
|
|
|
def test_passkey_registration_stores_public_credential_metadata(monkeypatch, tmp_path):
|
|
passkeys = _set_paths(monkeypatch, tmp_path)
|
|
key = ec.generate_private_key(ec.SECP256R1())
|
|
pub = key.public_key().public_numbers()
|
|
credential_id = b"credential-1"
|
|
cose_key = {1: 2, 3: -7, -1: 1, -2: pub.x.to_bytes(32, "big"), -3: pub.y.to_bytes(32, "big")}
|
|
opts = passkeys.registration_options(FakeHandler())
|
|
client_raw, client_b64 = _client_data("webauthn.create", opts["challenge"])
|
|
auth_data = (
|
|
hashlib.sha256(opts["rp"]["id"].encode()).digest()
|
|
+ bytes([0x41])
|
|
+ (1).to_bytes(4, "big")
|
|
+ (b"\0" * 16)
|
|
+ len(credential_id).to_bytes(2, "big")
|
|
+ credential_id
|
|
+ cbor(cose_key)
|
|
)
|
|
attestation = cbor({"fmt": "none", "authData": auth_data, "attStmt": {}})
|
|
|
|
result = passkeys.finish_registration({
|
|
"label": "MacBook Touch ID",
|
|
"response": {"clientDataJSON": client_b64, "attestationObject": b64u(attestation)},
|
|
}, FakeHandler())
|
|
|
|
assert result["ok"] is True
|
|
creds = passkeys.registered_credentials()
|
|
assert creds == [{
|
|
"id": b64u(credential_id),
|
|
"label": "MacBook Touch ID",
|
|
"created_at": creds[0]["created_at"],
|
|
"last_used_at": None,
|
|
"sign_count": 1,
|
|
}]
|
|
assert "public_key_pem" not in creds[0]
|
|
|
|
|
|
def test_passkey_login_verifies_signature_and_updates_usage(monkeypatch, tmp_path):
|
|
passkeys = _set_paths(monkeypatch, tmp_path)
|
|
key = ec.generate_private_key(ec.SECP256R1())
|
|
pub = key.public_key().public_numbers()
|
|
credential_id = b"credential-2"
|
|
cose_key = {1: 2, 3: -7, -1: 1, -2: pub.x.to_bytes(32, "big"), -3: pub.y.to_bytes(32, "big")}
|
|
|
|
reg_opts = passkeys.registration_options(FakeHandler())
|
|
_raw, client_b64 = _client_data("webauthn.create", reg_opts["challenge"])
|
|
reg_auth_data = hashlib.sha256(reg_opts["rp"]["id"].encode()).digest() + bytes([0x41]) + (1).to_bytes(4, "big") + (b"\0" * 16) + len(credential_id).to_bytes(2, "big") + credential_id + cbor(cose_key)
|
|
passkeys.finish_registration({"response": {"clientDataJSON": client_b64, "attestationObject": b64u(cbor({"fmt": "none", "authData": reg_auth_data, "attStmt": {}}))}}, FakeHandler())
|
|
|
|
login_opts = passkeys.authentication_options(FakeHandler())
|
|
client_raw, login_client_b64 = _client_data("webauthn.get", login_opts["challenge"])
|
|
login_auth_data = hashlib.sha256(login_opts["rpId"].encode()).digest() + bytes([0x01]) + (2).to_bytes(4, "big")
|
|
signature = key.sign(login_auth_data + hashlib.sha256(client_raw).digest(), ec.ECDSA(hashes.SHA256()))
|
|
|
|
result = passkeys.finish_login({
|
|
"id": b64u(credential_id),
|
|
"response": {
|
|
"clientDataJSON": login_client_b64,
|
|
"authenticatorData": b64u(login_auth_data),
|
|
"signature": b64u(signature),
|
|
},
|
|
}, FakeHandler())
|
|
|
|
assert result == {"ok": True, "credential_id": b64u(credential_id)}
|
|
[cred] = passkeys.registered_credentials()
|
|
assert cred["sign_count"] == 2
|
|
assert cred["last_used_at"] is not None
|
|
|
|
|
|
def test_passkey_options_evict_oldest_challenges_per_context(monkeypatch, tmp_path):
|
|
passkeys = _set_paths(monkeypatch, tmp_path)
|
|
passkeys._save_credentials([{"id": b64u(b"credential-3"), "label": "This device"}])
|
|
monkeypatch.setattr(passkeys, "_MAX_CHALLENGES_PER_CONTEXT", 2)
|
|
|
|
first = passkeys.authentication_options(FakeHandler())
|
|
second = passkeys.authentication_options(FakeHandler())
|
|
third = passkeys.authentication_options(FakeHandler())
|
|
|
|
pending = json.loads((tmp_path / ".passkey_challenges.json").read_text(encoding="utf-8"))
|
|
assert set(pending) == {second["challenge"], third["challenge"]}
|
|
assert first["challenge"] not in pending
|
|
|
|
|
|
def test_passkey_options_evict_oldest_challenges_globally(monkeypatch, tmp_path):
|
|
passkeys = _set_paths(monkeypatch, tmp_path)
|
|
passkeys._save_credentials([{"id": b64u(b"credential-4"), "label": "This device"}])
|
|
monkeypatch.setattr(passkeys, "_MAX_CHALLENGES", 2)
|
|
monkeypatch.setattr(passkeys, "_MAX_CHALLENGES_PER_CONTEXT", 10)
|
|
|
|
first = passkeys.authentication_options(FakeHandler())
|
|
second = passkeys.authentication_options(FakeHandler())
|
|
third = passkeys.authentication_options(FakeHandler())
|
|
|
|
pending = json.loads((tmp_path / ".passkey_challenges.json").read_text(encoding="utf-8"))
|
|
assert set(pending) == {second["challenge"], third["challenge"]}
|
|
assert first["challenge"] not in pending
|
|
|
|
|
|
class RouteFakeHandler:
|
|
def __init__(self):
|
|
self.headers = FakeHeaders({"Host": "localhost:8787", "Content-Length": "0"})
|
|
self.rfile = io.BytesIO(b"")
|
|
self.wfile = io.BytesIO()
|
|
self.status = None
|
|
self.sent_headers = []
|
|
self.client_address: tuple[str, int] = ("127.0.0.1", 12345)
|
|
|
|
def send_response(self, status):
|
|
self.status = status
|
|
|
|
def send_header(self, key, value):
|
|
self.sent_headers.append((key, value))
|
|
|
|
def end_headers(self):
|
|
pass
|
|
|
|
|
|
def test_passkey_options_rate_limit_errors_return_429(monkeypatch):
|
|
import api.auth as auth
|
|
import api.passkeys as passkeys
|
|
import api.routes as routes
|
|
|
|
monkeypatch.setattr(routes, "_check_csrf", lambda handler: True)
|
|
monkeypatch.setattr(auth, "_passkey_feature_flag_enabled", lambda: True)
|
|
monkeypatch.setattr(auth, "is_auth_enabled", lambda: True)
|
|
|
|
def raise_rate_limit(_handler):
|
|
raise passkeys.PasskeyRateLimitError("too many")
|
|
|
|
monkeypatch.setattr(passkeys, "authentication_options", raise_rate_limit)
|
|
handler = RouteFakeHandler()
|
|
|
|
routes.handle_post(handler, SimpleNamespace(path="/api/auth/passkey/options"))
|
|
|
|
assert handler.status == 429
|
|
assert json.loads(handler.wfile.getvalue())["error"] == "too many"
|
|
|
|
|
|
def test_passkey_register_options_handles_base_passkey_errors(monkeypatch):
|
|
import api.auth as auth
|
|
import api.passkeys as passkeys
|
|
import api.routes as routes
|
|
|
|
monkeypatch.setattr(routes, "_check_csrf", lambda handler: True)
|
|
monkeypatch.setattr(auth, "_passkey_feature_flag_enabled", lambda: True)
|
|
monkeypatch.setattr(auth, "is_auth_enabled", lambda: True)
|
|
monkeypatch.setattr(auth, "parse_cookie", lambda handler: "session")
|
|
monkeypatch.setattr(auth, "verify_session", lambda cookie: True)
|
|
|
|
def raise_passkey_error(_handler):
|
|
raise passkeys.PasskeyError("plain passkey error")
|
|
|
|
monkeypatch.setattr(passkeys, "registration_options", raise_passkey_error)
|
|
handler = RouteFakeHandler()
|
|
|
|
routes.handle_post(handler, SimpleNamespace(path="/api/auth/passkey/register/options"))
|
|
|
|
assert handler.status == 400
|
|
assert json.loads(handler.wfile.getvalue())["error"] == "plain passkey error"
|
|
|
|
def test_first_passkey_registration_options_rejects_remote_bootstrap(monkeypatch, tmp_path):
|
|
import api.auth as auth
|
|
import api.routes as routes
|
|
|
|
_set_paths(monkeypatch, tmp_path)
|
|
monkeypatch.setenv("HERMES_WEBUI_PASSKEY", "1")
|
|
monkeypatch.setattr(routes, "_check_csrf", lambda handler: True)
|
|
monkeypatch.setattr(auth, "get_password_hash", lambda: None)
|
|
|
|
handler = RouteFakeHandler()
|
|
handler.client_address = ("8.8.8.8", 12345)
|
|
routes.handle_post(handler, SimpleNamespace(path="/api/auth/passkey/register/options"))
|
|
|
|
assert handler.status == 401
|
|
assert json.loads(handler.wfile.getvalue())["error"] == "Authentication required"
|
|
|
|
|
|
def test_first_passkey_registration_rejects_remote_bootstrap(monkeypatch, tmp_path):
|
|
import api.auth as auth
|
|
import api.routes as routes
|
|
|
|
_set_paths(monkeypatch, tmp_path)
|
|
monkeypatch.setenv("HERMES_WEBUI_PASSKEY", "1")
|
|
monkeypatch.setattr(routes, "_check_csrf", lambda handler: True)
|
|
monkeypatch.setattr(auth, "get_password_hash", lambda: None)
|
|
|
|
handler = RouteFakeHandler()
|
|
handler.client_address = ("8.8.8.8", 12345)
|
|
routes.handle_post(handler, SimpleNamespace(path="/api/auth/passkey/register"))
|
|
|
|
assert handler.status == 401
|
|
assert json.loads(handler.wfile.getvalue())["error"] == "Authentication required"
|
|
|
|
|
|
def test_first_passkey_registration_options_allows_local_bootstrap(monkeypatch, tmp_path):
|
|
import api.auth as auth
|
|
import api.passkeys as passkeys
|
|
import api.routes as routes
|
|
|
|
_set_paths(monkeypatch, tmp_path)
|
|
monkeypatch.setenv("HERMES_WEBUI_PASSKEY", "1")
|
|
monkeypatch.setattr(routes, "_check_csrf", lambda handler: True)
|
|
monkeypatch.setattr(auth, "get_password_hash", lambda: None)
|
|
monkeypatch.setattr(passkeys, "registration_options", lambda handler: {"challenge": "local-bootstrap"})
|
|
|
|
handler = RouteFakeHandler()
|
|
routes.handle_post(handler, SimpleNamespace(path="/api/auth/passkey/register/options"))
|
|
|
|
assert handler.status == 200
|
|
assert json.loads(handler.wfile.getvalue())["publicKey"] == {"challenge": "local-bootstrap"}
|
|
|
|
|
|
def test_first_passkey_registration_allows_local_bootstrap(monkeypatch, tmp_path):
|
|
import api.auth as auth
|
|
import api.passkeys as passkeys
|
|
import api.routes as routes
|
|
|
|
_set_paths(monkeypatch, tmp_path)
|
|
monkeypatch.setenv("HERMES_WEBUI_PASSKEY", "1")
|
|
monkeypatch.setattr(routes, "_check_csrf", lambda handler: True)
|
|
monkeypatch.setattr(auth, "get_password_hash", lambda: None)
|
|
monkeypatch.setattr(passkeys, "finish_registration", lambda body, handler: {"ok": True})
|
|
monkeypatch.setattr(passkeys, "registered_credentials", lambda: [{"id": "local-bootstrap"}])
|
|
|
|
handler = RouteFakeHandler()
|
|
routes.handle_post(handler, SimpleNamespace(path="/api/auth/passkey/register"))
|
|
|
|
assert handler.status == 200
|
|
assert json.loads(handler.wfile.getvalue()) == {"ok": True, "credentials": [{"id": "local-bootstrap"}]}
|
|
|
|
|
|
def test_passkey_registration_requires_valid_session_when_auth_is_enabled(monkeypatch):
|
|
import api.auth as auth
|
|
import api.passkeys as passkeys
|
|
import api.routes as routes
|
|
|
|
monkeypatch.setattr(routes, "_check_csrf", lambda handler: True)
|
|
monkeypatch.setattr(auth, "_passkey_feature_flag_enabled", lambda: True)
|
|
monkeypatch.setattr(auth, "is_auth_enabled", lambda: True)
|
|
monkeypatch.setattr(auth, "parse_cookie", lambda handler: None)
|
|
monkeypatch.setattr(
|
|
auth,
|
|
"verify_session",
|
|
lambda cookie: (_ for _ in ()).throw(AssertionError("verify_session should not be called without a cookie")),
|
|
)
|
|
monkeypatch.setattr(
|
|
passkeys,
|
|
"registration_options",
|
|
lambda handler: (_ for _ in ()).throw(AssertionError("registration_options should not be reached")),
|
|
)
|
|
monkeypatch.setattr(
|
|
passkeys,
|
|
"finish_registration",
|
|
lambda body, handler: (_ for _ in ()).throw(AssertionError("finish_registration should not be reached")),
|
|
)
|
|
|
|
for path in ("/api/auth/passkey/register/options", "/api/auth/passkey/register"):
|
|
handler = RouteFakeHandler()
|
|
routes.handle_post(handler, SimpleNamespace(path=path))
|
|
|
|
assert handler.status == 401
|
|
assert json.loads(handler.wfile.getvalue())["error"] == "Authentication required"
|
|
|
|
|
|
def test_auth_status_reports_passkey_availability_source_contract():
|
|
src = open("api/routes.py", encoding="utf-8").read()
|
|
assert '"passkeys_enabled"' in src
|
|
assert '"passkeys_count"' in src
|
|
assert '"password_auth_enabled"' in src
|
|
assert '"passwordless_enabled"' in src
|
|
assert 'registered_credentials()' in src
|
|
|
|
|
|
def test_login_page_has_default_hidden_passkey_button_and_script_wiring():
|
|
routes = open("api/routes.py", encoding="utf-8").read()
|
|
login_js = open("static/login.js", encoding="utf-8").read()
|
|
assert 'id="passkey-login"' in routes
|
|
assert 'style="display:none"' in routes
|
|
assert "api/auth/passkey/options" in login_js
|
|
assert "navigator.credentials.get" in login_js
|
|
|
|
|
|
def test_passwordless_mode_keeps_auth_enabled_with_passkeys(monkeypatch, tmp_path):
|
|
import api.auth as auth
|
|
# Stage-batch14: passkey support is opt-in default-off behind HERMES_WEBUI_PASSKEY=1
|
|
monkeypatch.setenv("HERMES_WEBUI_PASSKEY", "1")
|
|
passkeys = _set_paths(monkeypatch, tmp_path)
|
|
passkeys._save_credentials([{"id": "cred-1", "label": "This device"}])
|
|
monkeypatch.setattr(auth, "get_password_hash", lambda: None)
|
|
|
|
assert auth.are_passkeys_enabled() is True
|
|
assert auth.is_auth_enabled() is True
|
|
|
|
|
|
def test_passkey_feature_flag_off_disables_passkeys_even_with_credentials(monkeypatch, tmp_path):
|
|
"""When HERMES_WEBUI_PASSKEY is unset/0, are_passkeys_enabled() returns False."""
|
|
import api.auth as auth
|
|
passkeys = _set_paths(monkeypatch, tmp_path)
|
|
passkeys._save_credentials([{"id": "cred-1", "label": "This device"}])
|
|
monkeypatch.delenv("HERMES_WEBUI_PASSKEY", raising=False)
|
|
monkeypatch.setattr(auth, "get_config", lambda: {}, raising=False)
|
|
assert auth.are_passkeys_enabled() is False
|
|
|
|
|
|
def test_passkey_feature_flag_via_config(monkeypatch, tmp_path):
|
|
"""webui_passkey_enabled: true in config also enables the surface."""
|
|
import api.auth as auth
|
|
passkeys = _set_paths(monkeypatch, tmp_path)
|
|
passkeys._save_credentials([{"id": "cred-1", "label": "This device"}])
|
|
monkeypatch.delenv("HERMES_WEBUI_PASSKEY", raising=False)
|
|
# Patch the config import inside _passkey_feature_flag_enabled
|
|
import api.config
|
|
monkeypatch.setattr(api.config, "get_config", lambda: {"webui_passkey_enabled": True})
|
|
assert auth.are_passkeys_enabled() is True
|
|
|
|
|
|
def test_passwordless_settings_and_last_passkey_guard_are_wired():
|
|
routes = open("api/routes.py", encoding="utf-8").read()
|
|
panels = open("static/panels.js", encoding="utf-8").read()
|
|
index = open("static/index.html", encoding="utf-8").read()
|
|
|
|
assert "_passwordless" in routes
|
|
assert "Register a passkey before going passwordless." in routes
|
|
assert "Set a password or disable auth before removing the last passkey." in routes
|
|
assert "clear_credentials()" in routes
|
|
assert "id=\"btnGoPasswordless\"" in index
|
|
assert "async function goPasswordless" in panels
|
|
assert "prompt(" not in panels
|