diff --git a/README.md b/README.md index fd051e8a6..a781ede05 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,11 @@ -# homeui +# Wiren Board HomeUI with WebAuthn -Wiren Board web interface. +An independent extension of the official [Wiren Board HomeUI](https://github.com/wirenboard/homeui) +with passkey authentication. It supports Touch ID, Windows Hello, Android screen lock, and +hardware security keys while retaining password login as a recovery method. + +This repository is not an official Wiren Board release. See the +[WebAuthn deployment guide](docs/webauthn.md) before installing it on a controller. ## MQTT naming conventions @@ -87,3 +92,5 @@ Fonts are stored in `/var/lib/wb-homeui/fonts/` and persisted across firmware up - [JSON Schema editor](frontend/src/components/json-schema-editor/README.md) — homeui's own schema-driven form editor (React + MobX), successor to the legacy forked `@wirenboard/json-editor`. +- [WebAuthn/passkey configuration](docs/webauthn.md) — HTTPS requirements, backend options, + enrollment, recovery, and reverse-proxy notes. diff --git a/backend/configs/usr/share/wb-mqtt-homeui/nginx/default.conf b/backend/configs/usr/share/wb-mqtt-homeui/nginx/default.conf index f7402bbfd..93534ffde 100644 --- a/backend/configs/usr/share/wb-mqtt-homeui/nginx/default.conf +++ b/backend/configs/usr/share/wb-mqtt-homeui/nginx/default.conf @@ -266,6 +266,16 @@ server { proxy_pass http://wb-homeui-back/auth/who_am_i; } + location /auth/webauthn/ { + client_max_body_size 1M; + limit_except GET POST DELETE { + deny all; + } + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_pass http://wb-homeui-back; + } + location /device/info { limit_except GET { deny all; diff --git a/backend/requirements.txt b/backend/requirements.txt index 4ee735eb0..ec1055130 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,6 +1,7 @@ # Runtime dependencies for wb.homeui_backend (third-party only; stdlib omitted). bcrypt cryptography +fido2==1.2.0 requests websockets diff --git a/backend/tests/cert_test.py b/backend/tests/cert_test.py index d415652dc..fb9c3fec2 100644 --- a/backend/tests/cert_test.py +++ b/backend/tests/cert_test.py @@ -12,6 +12,7 @@ from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import rsa from cryptography.x509.oid import NameOID + from wb.homeui_backend.cert import ( CertificateCheckingThread, CertificateState, diff --git a/backend/tests/rate_limiter_test.py b/backend/tests/rate_limiter_test.py index 141c6badc..21f953a70 100644 --- a/backend/tests/rate_limiter_test.py +++ b/backend/tests/rate_limiter_test.py @@ -1,6 +1,7 @@ from datetime import datetime, timedelta import pytest + from wb.homeui_backend.rate_limiter import MAX_TRACKED_KEYS, RateLimiter diff --git a/backend/tests/webauthn_test.py b/backend/tests/webauthn_test.py new file mode 100644 index 000000000..a204861d6 --- /dev/null +++ b/backend/tests/webauthn_test.py @@ -0,0 +1,231 @@ +import hashlib +import sqlite3 +from datetime import datetime, timedelta, timezone + +import pytest +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.asymmetric import ec +from fido2.cose import ES256 +from fido2.utils import websafe_encode +from fido2.webauthn import ( + Aaguid, + AttestationObject, + AttestedCredentialData, + AuthenticatorData, + CollectedClientData, +) + +from wb.homeui_backend.db import create_tables, migration_3 +from wb.homeui_backend.users_storage import User, UserType +from wb.homeui_backend.webauthn import ( + WebAuthnChallengeStore, + WebAuthnChallengeType, + WebAuthnService, + json_dumps, +) +from wb.homeui_backend.webauthn_storage import WebAuthnCredentialsStorage + + +def make_credential(credential_id: bytes = b"credential-id") -> AttestedCredentialData: + private_key = ec.generate_private_key(ec.SECP256R1()) + return AttestedCredentialData.create( + Aaguid.NONE, + credential_id, + ES256.from_cryptography_key(private_key.public_key()), + ) + + +@pytest.fixture(name="storage") +def storage_fixture(): + connection = sqlite3.connect(":memory:") + create_tables(connection) + return WebAuthnCredentialsStorage(connection) + + +def test_credentials_storage_lifecycle(storage): + credential_data = make_credential() + + added = storage.add_credential("user-id", "MacBook Touch ID", credential_data, 1) + loaded = storage.get_credentials_by_user("user-id") + + assert len(loaded) == 1 + assert loaded[0].credential_id == added.credential_id + assert loaded[0].credential_data == credential_data + assert loaded[0].name == "MacBook Touch ID" + assert loaded[0].sign_count == 1 + assert loaded[0].last_used_at is None + + storage.update_last_use(added.credential_id, 2) + updated = storage.get_credentials_by_user("user-id")[0] + assert updated.sign_count == 2 + assert updated.last_used_at is not None + + assert storage.delete_credential("other-user", added.credential_id) is False + assert storage.delete_credential("user-id", added.credential_id) is True + assert storage.get_credentials_by_user("user-id") == [] + + +def test_credentials_storage_deletes_all_credentials_for_user(storage): + storage.add_credential("user-id", "First", make_credential(b"first"), 0) + storage.add_credential("user-id", "Second", make_credential(b"second"), 0) + + storage.delete_credentials_by_user("user-id") + + assert storage.get_credentials_by_user("user-id") == [] + + +def test_migration_3_creates_credentials_table(): + connection = sqlite3.connect(":memory:") + + migration_3(connection) + + version = connection.execute("PRAGMA user_version").fetchone()[0] + table = connection.execute( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'webauthn_credentials'" + ).fetchone() + assert version == 3 + assert table == ("webauthn_credentials",) + + +def test_challenge_is_one_time(): + challenge_store = WebAuthnChallengeStore() + challenge_id = challenge_store.add(WebAuthnChallengeType.REGISTRATION, "user-id", {"a": 1}) + + challenge = challenge_store.consume(challenge_id, WebAuthnChallengeType.REGISTRATION) + + assert challenge is not None + assert challenge.user_id == "user-id" + assert challenge_store.consume(challenge_id, WebAuthnChallengeType.REGISTRATION) is None + + +def test_expired_challenge_is_rejected(): + challenge_store = WebAuthnChallengeStore() + challenge_id = challenge_store.add(WebAuthnChallengeType.AUTHENTICATION, "user-id", {}) + challenge = challenge_store.challenges[challenge_id] + challenge_store.challenges[challenge_id] = challenge.__class__( + challenge.challenge_type, + challenge.user_id, + challenge.state, + datetime.now(timezone.utc) - timedelta(seconds=1), + ) + + assert challenge_store.consume(challenge_id, WebAuthnChallengeType.AUTHENTICATION) is None + + +def test_registration_options_are_json_serializable(storage): + service = WebAuthnService("wb.example.com", "https://wb.example.com", storage) + user = User("user-id", "admin", "hash", UserType.ADMIN, False) + + ceremony = service.begin_registration(user) + + assert ceremony["challenge_id"] + assert "publicKey" in ceremony["options"] + assert json_dumps(ceremony) + + +def test_authentication_requires_registered_credential(storage): + service = WebAuthnService("wb.example.com", "https://wb.example.com", storage) + user = User("user-id", "admin", "hash", UserType.ADMIN, False) + + with pytest.raises(ValueError, match="No credentials configured"): + service.begin_authentication(user) + + +@pytest.mark.parametrize( + "rp_id,origin", + [ + ("https://wb.example.com", "https://wb.example.com"), + ("wb.example.com", "http://wb.example.com"), + ("wb.example.com", "https://other.example.com"), + ("wb.example.com", "https://wb.example.com/path"), + ], +) +def test_invalid_relying_party_configuration_is_rejected(storage, rp_id, origin): + with pytest.raises(ValueError): + WebAuthnService(rp_id, origin, storage) + + +def complete_registration(service, user, credential_data, origin, rp_id): + registration = service.begin_registration(user) + client_data = CollectedClientData.create( + CollectedClientData.TYPE.CREATE, + registration["options"]["publicKey"]["challenge"], + origin, + ) + registration_auth_data = AuthenticatorData.create( + hashlib.sha256(rp_id.encode()).digest(), + AuthenticatorData.FLAG.UP | AuthenticatorData.FLAG.UV | AuthenticatorData.FLAG.AT, + 0, + credential_data, + ) + attestation = AttestationObject.create("none", registration_auth_data, {}) + return service.complete_registration( + user, + registration["challenge_id"], + "MacBook Touch ID", + { + "id": websafe_encode(credential_data.credential_id), + "rawId": websafe_encode(credential_data.credential_id), + "type": "public-key", + "response": { + "clientDataJSON": websafe_encode(bytes(client_data)), + "attestationObject": websafe_encode(bytes(attestation)), + }, + }, + ) + + +def complete_authentication(service, user, credential_data, private_key, relying_party): + rp_id, origin = relying_party + authentication = service.begin_authentication(user) + client_data = CollectedClientData.create( + CollectedClientData.TYPE.GET, + authentication["options"]["publicKey"]["challenge"], + origin, + ) + authentication_auth_data = AuthenticatorData.create( + hashlib.sha256(rp_id.encode()).digest(), + AuthenticatorData.FLAG.UP | AuthenticatorData.FLAG.UV, + 1, + ) + signature = private_key.sign( + bytes(authentication_auth_data) + client_data.hash, + ec.ECDSA(hashes.SHA256()), + ) + return service.complete_authentication( + authentication["challenge_id"], + { + "id": websafe_encode(credential_data.credential_id), + "rawId": websafe_encode(credential_data.credential_id), + "type": "public-key", + "response": { + "clientDataJSON": websafe_encode(bytes(client_data)), + "authenticatorData": websafe_encode(bytes(authentication_auth_data)), + "signature": websafe_encode(signature), + "userHandle": websafe_encode(user.user_id.encode()), + }, + }, + ) + + +def test_registration_and_authentication_ceremonies(storage): + rp_id = "wb.example.com" + origin = "https://wb.example.com" + service = WebAuthnService(rp_id, origin, storage) + user = User("user-id", "admin", "hash", UserType.ADMIN, False) + private_key = ec.generate_private_key(ec.SECP256R1()) + credential_data = AttestedCredentialData.create( + Aaguid.NONE, + b"credential-id", + ES256.from_cryptography_key(private_key.public_key()), + ) + + registered = complete_registration(service, user, credential_data, origin, rp_id) + authenticated_user_id, authenticated_credential = complete_authentication( + service, user, credential_data, private_key, (rp_id, origin) + ) + + assert registered.credential_id == credential_data.credential_id + assert authenticated_user_id == user.user_id + assert authenticated_credential.credential_id == credential_data.credential_id + assert storage.get_credentials_by_user(user.user_id)[0].sign_count == 1 diff --git a/backend/wb/homeui_backend/db.py b/backend/wb/homeui_backend/db.py index 90f018640..c2d2c1ffa 100644 --- a/backend/wb/homeui_backend/db.py +++ b/backend/wb/homeui_backend/db.py @@ -2,7 +2,24 @@ import os import sqlite3 -DB_SCHEMA_VERSION = 2 +DB_SCHEMA_VERSION = 3 + + +def create_webauthn_credentials_table(con: sqlite3.Connection) -> None: + cursor = con.cursor() + cursor.execute( + ( + "CREATE TABLE IF NOT EXISTS webauthn_credentials (" + "credential_id BLOB PRIMARY KEY NOT NULL, " + "user_id TEXT NOT NULL, " + "name TEXT NOT NULL, " + "credential_data BLOB NOT NULL, " + "sign_count INTEGER NOT NULL DEFAULT 0, " + "created_at INTEGER NOT NULL, " + "last_used_at INTEGER)" + ) + ) + con.commit() def create_tables(con: sqlite3.Connection): @@ -29,6 +46,16 @@ def create_tables(con: sqlite3.Connection): ) con.commit() + create_webauthn_credentials_table(con) + + +def migration_3(con: sqlite3.Connection) -> None: + logging.info("Migrating database to version 3") + create_webauthn_credentials_table(con) + cursor = con.cursor() + cursor.execute("PRAGMA user_version = 3") + con.commit() + def migration_2(con: sqlite3.Connection) -> None: logging.info("Migrating database to version 2") @@ -60,7 +87,7 @@ def migration_1(con: sqlite3.Connection) -> None: def update_db(con: sqlite3.Connection, version: int) -> None: - migrations = [migration_1, migration_2] + migrations = [migration_1, migration_2, migration_3] for migration_fn in migrations[version:]: migration_fn(con) @@ -70,7 +97,7 @@ def create_db(db_file: str) -> sqlite3.Connection: con = sqlite3.connect(db_file) create_tables(con) cur = con.cursor() - cur.execute("PRAGMA user_version = 2") + cur.execute("PRAGMA user_version = 3") return con diff --git a/backend/wb/homeui_backend/main.py b/backend/wb/homeui_backend/main.py index 74acc4796..82dff0014 100644 --- a/backend/wb/homeui_backend/main.py +++ b/backend/wb/homeui_backend/main.py @@ -13,7 +13,7 @@ from http import cookies from http.server import BaseHTTPRequestHandler from sys import argv -from typing import Any, Callable, Optional +from typing import Any, Callable, ClassVar, Optional from urllib.parse import unquote, urlparse import bcrypt @@ -51,6 +51,8 @@ from .security import SecurityCheckingThread from .sessions_storage import Session, SessionsStorage from .users_storage import User, UsersStorage, UserType +from .webauthn import WebAuthnService, json_dumps +from .webauthn_storage import WebAuthnCredentialsStorage DEFAULT_SOCKET_FILE = "/tmp/wb-homeui.socket" DEFAULT_DB_FILE = "/var/lib/wb-homeui/users.db" @@ -76,12 +78,14 @@ def check_password(password: str, password_hash: str) -> bool: return bcrypt.checkpw(password.encode("utf-8"), password_hash.encode("utf-8")) -def make_id_cookie(session: Session) -> cookies.SimpleCookie: +def make_id_cookie(session: Session, secure: bool = False) -> cookies.SimpleCookie: cookie = cookies.SimpleCookie() cookie["id"] = session.id cookie["id"]["path"] = "/" cookie["id"]["httponly"] = True cookie["id"]["samesite"] = "Lax" + if secure: + cookie["id"]["secure"] = True expires = session.start_date + DEFAULT_COOKIE_LIFETIME cookie["id"]["expires"] = expires.strftime("%a, %d %b %Y %H:%M:%S GMT") return cookie @@ -184,6 +188,7 @@ def validate_update_user_request(request: dict) -> None: @dataclass class WebRequestHandlerContext: # pylint: disable=too-many-instance-attributes + webauthn_service: ClassVar[Optional[WebAuthnService]] = None sn: str users_storage: UsersStorage sessions_storage: SessionsStorage @@ -300,6 +305,139 @@ def auth_who_am_i_handler( return response_401() +def read_json_request(request: BaseHTTPRequestHandler) -> dict: + length = int(request.headers.get("Content-Length", 0)) + form = json.loads(request.rfile.read(length).decode("utf-8")) + if not isinstance(form, dict): + raise TypeError("JSON object expected") + return form + + +def webauthn_config_handler( + _request: BaseHTTPRequestHandler, context: WebRequestHandlerContext +) -> HttpResponse: + res = {"enabled": context.webauthn_service is not None} + if context.webauthn_service is not None: + res["rp_id"] = context.webauthn_service.rp_id + return response_200([["Content-type", "application/json"]], json.dumps(res)) + + +def webauthn_registration_options_handler( + _request: BaseHTTPRequestHandler, context: WebRequestHandlerContext +) -> HttpResponse: + if context.webauthn_service is None: + return response_404() + if context.session is None: + return response_401() + return response_200( + [["Content-type", "application/json"]], + json_dumps(context.webauthn_service.begin_registration(context.session.user)), + ) + + +def webauthn_registration_complete_handler( + request: BaseHTTPRequestHandler, context: WebRequestHandlerContext +) -> HttpResponse: + if context.webauthn_service is None: + return response_404() + if context.session is None: + return response_401() + try: + form = read_json_request(request) + credential = context.webauthn_service.complete_registration( + context.session.user, + form.get("challenge_id", ""), + form.get("name", ""), + form.get("response", {}), + ) + except (KeyError, TypeError, ValueError) as e: + logging.warning("WebAuthn registration failed for user=%r: %s", context.session.user.login, e) + return response_400("WebAuthn registration failed") + return response_201( + [["Content-type", "application/json"]], + json.dumps(context.webauthn_service.credential_to_dict(credential)), + ) + + +def webauthn_authentication_options_handler( + request: BaseHTTPRequestHandler, context: WebRequestHandlerContext +) -> HttpResponse: + if context.webauthn_service is None: + return response_404() + try: + form = read_json_request(request) + user = context.users_storage.get_user_by_login(form.get("login")) + if user is None: + return response_401() + res = context.webauthn_service.begin_authentication(user) + except (KeyError, TypeError, ValueError): + return response_401() + return response_200([["Content-type", "application/json"]], json_dumps(res)) + + +def webauthn_authentication_complete_handler( + request: BaseHTTPRequestHandler, context: WebRequestHandlerContext +) -> HttpResponse: + if context.webauthn_service is None: + return response_404() + try: + form = read_json_request(request) + user_id, _credential = context.webauthn_service.complete_authentication( + form.get("challenge_id", ""), form.get("response", {}) + ) + user = context.users_storage.get_user_by_id(user_id) + if user is None: + return response_401() + except (KeyError, StopIteration, TypeError, ValueError) as e: + logging.warning("WebAuthn authentication failed: %s", e) + return response_401() + + logging.info("WebAuthn login successful: user=%r type=%s", user.login, user.type.value) + session = context.sessions_storage.add_session(user) + res = {"user_type": user.type.value, "user_id": user.user_id} + return response_200( + headers=[ + make_set_cookie_header(make_id_cookie(session, secure=True)), + ["Content-type", "application/json"], + ], + body=json.dumps(res), + ) + + +def webauthn_credentials_handler( + _request: BaseHTTPRequestHandler, context: WebRequestHandlerContext +) -> HttpResponse: + if context.webauthn_service is None: + return response_404() + if context.session is None: + return response_401() + credentials = context.webauthn_service.credentials_storage.get_credentials_by_user( + context.session.user.user_id + ) + return response_200( + [["Content-type", "application/json"]], + json.dumps([context.webauthn_service.credential_to_dict(item) for item in credentials]), + ) + + +def webauthn_delete_credential_handler( + request: BaseHTTPRequestHandler, context: WebRequestHandlerContext +) -> HttpResponse: + if context.webauthn_service is None: + return response_404() + if context.session is None: + return response_401() + credential_id = urlparse(request.path).path.rsplit("/", 1)[-1] + try: + deleted = context.webauthn_service.credentials_storage.delete_credential( + context.session.user.user_id, + context.webauthn_service.decode_credential_id(credential_id), + ) + except ValueError: + return response_400("Invalid credential id") + return response_204() if deleted else response_404() + + def add_user_handler(request: BaseHTTPRequestHandler, context: WebRequestHandlerContext) -> HttpResponse: try: length = int(request.headers.get("Content-Length", 0)) @@ -392,6 +530,8 @@ def delete_user_handler(request: BaseHTTPRequestHandler, context: WebRequestHand if user.type == UserType.ADMIN and context.users_storage.count_users_by_type(UserType.ADMIN) == 1: return response_400("Can't delete the last admin") context.sessions_storage.delete_sessions_by_user(user) + if context.webauthn_service is not None: + context.webauthn_service.credentials_storage.delete_credentials_by_user(user_id) context.users_storage.delete_user(user_id) return response_204() @@ -824,6 +964,7 @@ class WebRequestHandler(BaseHTTPRequestHandler): config: Config dashboards_store: DashboardsStore fonts_store: FontsStore + webauthn_service: Optional[WebAuthnService] = None def process_response(self, response: HttpResponse) -> None: if 200 <= response.status < 300 or response.status == 304: @@ -886,6 +1027,8 @@ def do_GET(self) -> None: # pylint: disable=invalid-name fn=auth_check_handler, rate_per_minute_limit=1000, rate_limit_per_client=True ), "/auth/who_am_i": RequestHandler(fn=auth_who_am_i_handler), + "/auth/webauthn/config": RequestHandler(fn=webauthn_config_handler), + "/auth/webauthn/credentials": RequestHandler(fn=webauthn_credentials_handler), "/users": RequestHandler(fn=get_users_handler), "/device/info": RequestHandler(fn=device_info_handler), "/api/check": RequestHandler(fn=security_check_handler, rate_per_minute_limit=3), @@ -903,6 +1046,22 @@ def do_POST(self) -> None: # pylint: disable=invalid-name "/users": RequestHandler(fn=add_user_handler), "/auth/login": RequestHandler(fn=auth_login_handler, rate_per_minute_limit=30), "/auth/logout": RequestHandler(fn=auth_logout_handler), + "/auth/webauthn/register/options": RequestHandler( + fn=webauthn_registration_options_handler, rate_per_minute_limit=10 + ), + "/auth/webauthn/register/complete": RequestHandler( + fn=webauthn_registration_complete_handler, rate_per_minute_limit=10 + ), + "/auth/webauthn/login/options": RequestHandler( + fn=webauthn_authentication_options_handler, + rate_per_minute_limit=30, + rate_limit_per_client=True, + ), + "/auth/webauthn/login/complete": RequestHandler( + fn=webauthn_authentication_complete_handler, + rate_per_minute_limit=30, + rate_limit_per_client=True, + ), "/api/https/request_cert": RequestHandler(fn=https_request_cert_handler), "/api/fonts": RequestHandler(fn=upload_font_handler), } @@ -929,6 +1088,7 @@ def do_DELETE(self) -> None: # pylint: disable=invalid-name self.process_request( { "/users/*": RequestHandler(fn=delete_user_handler), + "/auth/webauthn/credentials/*": RequestHandler(fn=webauthn_delete_credential_handler), "/api/dashboards/*": RequestHandler(fn=delete_dashboard_handler), "/api/fonts/*": RequestHandler(fn=delete_font_handler), } @@ -968,8 +1128,13 @@ def main(): parser.add_argument("--debug", action="store_true", help="Enable debug mode") parser.add_argument("--socket-file", default=DEFAULT_SOCKET_FILE, help="Socket file") parser.add_argument("--db-file", default=DEFAULT_DB_FILE, help="Database file path") + parser.add_argument("--webauthn-rp-id", help="WebAuthn relying party domain") + parser.add_argument("--webauthn-origin", help="Allowed WebAuthn HTTPS origin") args = parser.parse_args() + if bool(args.webauthn_rp_id) != bool(args.webauthn_origin): + parser.error("--webauthn-rp-id and --webauthn-origin must be used together") + logging.basicConfig( level=logging.DEBUG if args.debug else logging.INFO, format="%(levelname)s:%(message)s", @@ -981,6 +1146,13 @@ def main(): WebRequestHandler.users_storage = UsersStorage(con) WebRequestHandler.sessions_storage = SessionsStorage(con) + if args.webauthn_rp_id: + WebRequestHandler.webauthn_service = WebAuthnService( + args.webauthn_rp_id, + args.webauthn_origin, + WebAuthnCredentialsStorage(con), + ) + WebRequestHandlerContext.webauthn_service = WebRequestHandler.webauthn_service WebRequestHandler.enable_debug = args.debug WebRequestHandler.sn = sn WebRequestHandler.config = Config(WebRequestHandler.users_storage) diff --git a/backend/wb/homeui_backend/webauthn.py b/backend/wb/homeui_backend/webauthn.py new file mode 100644 index 000000000..c1bbd7d83 --- /dev/null +++ b/backend/wb/homeui_backend/webauthn.py @@ -0,0 +1,195 @@ +#!/usr/bin/env python3 + +import json +import secrets +import threading +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from enum import Enum +from typing import Any, Optional +from urllib.parse import urlparse + +import fido2.features +from fido2.server import Fido2Server +from fido2.utils import websafe_decode, websafe_encode +from fido2.webauthn import ( + AuthenticationResponse, + PublicKeyCredentialRpEntity, + ResidentKeyRequirement, + UserVerificationRequirement, +) + +from .users_storage import User +from .webauthn_storage import WebAuthnCredential, WebAuthnCredentialsStorage + +CHALLENGE_LIFETIME = timedelta(minutes=5) +MAX_CREDENTIAL_NAME_LENGTH = 80 + +# Browser responses use the WebAuthn JSON representation (base64url for binary fields). +# python-fido2 1.2 keeps this parser behind a compatibility feature flag. +fido2.features.webauthn_json_mapping.enabled = True + + +class WebAuthnChallengeType(Enum): + REGISTRATION = "registration" + AUTHENTICATION = "authentication" + + +@dataclass(frozen=True) +class WebAuthnChallenge: + challenge_type: WebAuthnChallengeType + user_id: str + state: dict[str, Any] + expires_at: datetime + + +class WebAuthnChallengeStore: + def __init__(self): + self.challenges: dict[str, WebAuthnChallenge] = {} + self.lock = threading.Lock() + + def add(self, challenge_type: WebAuthnChallengeType, user_id: str, state: dict[str, Any]) -> str: + challenge_id = secrets.token_urlsafe(32) + with self.lock: + self._delete_expired() + self.challenges[challenge_id] = WebAuthnChallenge( + challenge_type, + user_id, + state, + datetime.now(timezone.utc) + CHALLENGE_LIFETIME, + ) + return challenge_id + + def consume( + self, challenge_id: str, challenge_type: WebAuthnChallengeType + ) -> Optional[WebAuthnChallenge]: + with self.lock: + self._delete_expired() + challenge = self.challenges.pop(challenge_id, None) + if challenge is None or challenge.challenge_type != challenge_type: + return None + return challenge + + def _delete_expired(self) -> None: + now = datetime.now(timezone.utc) + self.challenges = { + challenge_id: challenge + for challenge_id, challenge in self.challenges.items() + if challenge.expires_at > now + } + + +class WebAuthnService: + def __init__(self, rp_id: str, origin: str, credentials_storage: WebAuthnCredentialsStorage): + normalized_rp_id = rp_id.strip().lower().rstrip(".") + normalized_origin = origin.strip().rstrip("/") + parsed_origin = urlparse(normalized_origin) + origin_host = (parsed_origin.hostname or "").lower().rstrip(".") + if not normalized_rp_id or "/" in normalized_rp_id or ":" in normalized_rp_id: + raise ValueError("Invalid WebAuthn relying party id") + if parsed_origin.scheme != "https" or not origin_host: + raise ValueError("WebAuthn origin must be an HTTPS origin") + if origin_host != normalized_rp_id and not origin_host.endswith(f".{normalized_rp_id}"): + raise ValueError("WebAuthn origin is outside the relying party domain") + if parsed_origin.path or parsed_origin.params or parsed_origin.query or parsed_origin.fragment: + raise ValueError("WebAuthn origin must not contain a path, query, or fragment") + self.rp_id = normalized_rp_id + self.origin = normalized_origin + self.credentials_storage = credentials_storage + self.challenges = WebAuthnChallengeStore() + self.server = Fido2Server( + PublicKeyCredentialRpEntity(id=rp_id, name="Wiren Board"), + verify_origin=lambda request_origin: request_origin == self.origin, + ) + + def begin_registration(self, user: User) -> dict[str, Any]: + credentials = self.credentials_storage.get_credentials_by_user(user.user_id) + options, state = self.server.register_begin( + { + "id": user.user_id.encode("utf-8"), + "name": user.login, + "displayName": user.login, + }, + [credential.credential_data for credential in credentials], + resident_key_requirement=ResidentKeyRequirement.PREFERRED, + user_verification=UserVerificationRequirement.REQUIRED, + ) + return { + "challenge_id": self.challenges.add(WebAuthnChallengeType.REGISTRATION, user.user_id, state), + "options": dict(options), + } + + def complete_registration( + self, user: User, challenge_id: str, name: str, response: dict[str, Any] + ) -> WebAuthnCredential: + challenge = self.challenges.consume(challenge_id, WebAuthnChallengeType.REGISTRATION) + if challenge is None or challenge.user_id != user.user_id: + raise ValueError("Invalid or expired registration challenge") + normalized_name = name.strip() + if not normalized_name or len(normalized_name) > MAX_CREDENTIAL_NAME_LENGTH: + raise ValueError("Invalid credential name") + auth_data = self.server.register_complete(challenge.state, response) + return self.credentials_storage.add_credential( + user.user_id, + normalized_name, + auth_data.credential_data, + auth_data.counter, + ) + + def begin_authentication(self, user: User) -> dict[str, Any]: + credentials = self.credentials_storage.get_credentials_by_user(user.user_id) + if not credentials: + raise ValueError("No credentials configured") + options, state = self.server.authenticate_begin( + [credential.credential_data for credential in credentials], + user_verification=UserVerificationRequirement.REQUIRED, + ) + return { + "challenge_id": self.challenges.add(WebAuthnChallengeType.AUTHENTICATION, user.user_id, state), + "options": dict(options), + } + + def complete_authentication( + self, challenge_id: str, response: dict[str, Any] + ) -> tuple[str, WebAuthnCredential]: + challenge = self.challenges.consume(challenge_id, WebAuthnChallengeType.AUTHENTICATION) + if challenge is None: + raise ValueError("Invalid or expired authentication challenge") + credentials = self.credentials_storage.get_credentials_by_user(challenge.user_id) + credential_data = self.server.authenticate_complete( + challenge.state, + [credential.credential_data for credential in credentials], + response, + ) + credential = next(item for item in credentials if item.credential_id == credential_data.credential_id) + authentication = AuthenticationResponse.from_dict(response) + new_sign_count = authentication.response.authenticator_data.counter + if credential.sign_count > 0 and new_sign_count > 0: + if new_sign_count <= credential.sign_count: + raise ValueError("Credential sign counter did not increase") + self.credentials_storage.update_last_use(credential.credential_id, new_sign_count) + return challenge.user_id, credential + + @staticmethod + def credential_to_dict(credential: WebAuthnCredential) -> dict[str, Any]: + return { + "id": websafe_encode(credential.credential_id), + "name": credential.name, + "created_at": credential.created_at.isoformat(), + "last_used_at": credential.last_used_at.isoformat() if credential.last_used_at else None, + } + + @staticmethod + def decode_credential_id(credential_id: str) -> bytes: + return websafe_decode(credential_id) + + +def json_dumps(value: Any) -> str: + def default(item: Any): + if isinstance(item, bytes): + return websafe_encode(item) + if isinstance(item, Enum): + return item.value + return dict(item) + + return json.dumps(value, default=default) diff --git a/backend/wb/homeui_backend/webauthn_storage.py b/backend/wb/homeui_backend/webauthn_storage.py new file mode 100644 index 000000000..6e7df4237 --- /dev/null +++ b/backend/wb/homeui_backend/webauthn_storage.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 + +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Optional + +from fido2.webauthn import AttestedCredentialData + + +@dataclass(frozen=True) +class WebAuthnCredential: + credential_id: bytes + user_id: str + name: str + credential_data: AttestedCredentialData + sign_count: int + created_at: datetime + last_used_at: Optional[datetime] + + +class WebAuthnCredentialsStorage: + def __init__(self, db_connection): + self.db_connection = db_connection + + def add_credential( + self, + user_id: str, + name: str, + credential_data: AttestedCredentialData, + sign_count: int, + ) -> WebAuthnCredential: + now = datetime.now(timezone.utc) + cursor = self.db_connection.cursor() + cursor.execute( + ( + "INSERT INTO webauthn_credentials " + "(credential_id, user_id, name, credential_data, sign_count, created_at) " + "VALUES (?, ?, ?, ?, ?, ?)" + ), + ( + credential_data.credential_id, + user_id, + name, + bytes(credential_data), + sign_count, + int(now.timestamp()), + ), + ) + self.db_connection.commit() + return WebAuthnCredential( + credential_data.credential_id, + user_id, + name, + credential_data, + sign_count, + now, + None, + ) + + def get_credentials_by_user(self, user_id: str) -> list[WebAuthnCredential]: + cursor = self.db_connection.cursor() + cursor.execute( + ( + "SELECT credential_id, name, credential_data, sign_count, created_at, last_used_at " + "FROM webauthn_credentials WHERE user_id = ? ORDER BY created_at" + ), + (user_id,), + ) + return [self._from_row(user_id, row) for row in cursor.fetchall()] + + def update_last_use(self, credential_id: bytes, sign_count: int) -> None: + cursor = self.db_connection.cursor() + cursor.execute( + "UPDATE webauthn_credentials SET sign_count = ?, last_used_at = ? WHERE credential_id = ?", + (sign_count, int(datetime.now(timezone.utc).timestamp()), credential_id), + ) + self.db_connection.commit() + + def delete_credential(self, user_id: str, credential_id: bytes) -> bool: + cursor = self.db_connection.cursor() + cursor.execute( + "DELETE FROM webauthn_credentials WHERE user_id = ? AND credential_id = ?", + (user_id, credential_id), + ) + self.db_connection.commit() + return cursor.rowcount == 1 + + def delete_credentials_by_user(self, user_id: str) -> None: + cursor = self.db_connection.cursor() + cursor.execute("DELETE FROM webauthn_credentials WHERE user_id = ?", (user_id,)) + self.db_connection.commit() + + @staticmethod + def _from_row(user_id: str, row) -> WebAuthnCredential: + last_used_at = None + if row[5] is not None: + last_used_at = datetime.fromtimestamp(row[5], tz=timezone.utc) + return WebAuthnCredential( + bytes(row[0]), + user_id, + row[1], + AttestedCredentialData(bytes(row[2])), + row[3], + datetime.fromtimestamp(row[4], tz=timezone.utc), + last_used_at, + ) diff --git a/debian/changelog b/debian/changelog index 9cb098df0..71453286c 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,9 @@ +wb-mqtt-homeui (2.250.0) stable; urgency=medium + + * Add optional WebAuthn passkey authentication + + -- Anton Akinin Wed, 26 Aug 2026 21:36:45 +0300 + wb-mqtt-homeui (2.249.0) stable; urgency=medium * Show the device template parameter variant matching the device firmware diff --git a/debian/control b/debian/control index 74aebaf58..6b722a63a 100644 --- a/debian/control +++ b/debian/control @@ -14,6 +14,7 @@ Build-Depends: debhelper-compat (= 13), python3-all, python3-bcrypt, python3-cryptography, + python3-fido2, python3-legacy-cgi, python3-pytest, python3-requests, @@ -58,6 +59,7 @@ Depends: ${shlibs:Depends}, wb-utils (>= 4.24.0~~), python3-bcrypt, python3-cryptography, + python3-fido2, python3-legacy-cgi, python3-requests, python3-requests-unixsocket, diff --git a/deployment/wb-homeui-backend.example b/deployment/wb-homeui-backend.example new file mode 100644 index 000000000..773fc4530 --- /dev/null +++ b/deployment/wb-homeui-backend.example @@ -0,0 +1,2 @@ +# Add these options to any existing backend options and replace the example domain. +WB_HOMEUI_BACKEND_OPTIONS="--webauthn-rp-id wb.example.com --webauthn-origin https://wb.example.com" diff --git a/docs/webauthn.md b/docs/webauthn.md new file mode 100644 index 000000000..5cf9b524d --- /dev/null +++ b/docs/webauthn.md @@ -0,0 +1,68 @@ +# WebAuthn and passkeys + +HomeUI can use a platform authenticator such as Touch ID, Windows Hello, Android screen lock, +or a hardware security key as a second login method. Password login remains available for +recovery. + +## Requirements + +- A stable DNS name for the controller, for example `wb.example.com`. +- HTTPS with a certificate trusted by the browser. +- The browser must open HomeUI through that DNS name. WebAuthn intentionally does not work on + the controller's plain-HTTP IP address. +- `python3-fido2` must be installed. The Debian package declares this dependency. + +Embedded browsers may restrict WebAuthn. If an embedded browser reports `NotAllowedError`, open +the same HTTPS address in Safari, Chrome, Edge, or Firefox. This does not affect password login. + +## Backend configuration + +Create or edit `/etc/default/wb-homeui-backend` and append the two options to any existing value: + +```sh +WB_HOMEUI_BACKEND_OPTIONS="--webauthn-rp-id wb.example.com --webauthn-origin https://wb.example.com" +``` + +The relying-party ID is a domain name without a scheme, path, or port. The origin is the exact +external HTTPS origin used by the browser, including a non-default port when applicable. The +origin's host must equal the relying-party ID or be its subdomain. + +Apply the change: + +```sh +systemctl restart wb-homeui-backend +systemctl reload nginx +``` + +Verify the public endpoint: + +```sh +curl -fsS https://wb.example.com/auth/webauthn/config +``` + +The response should contain `"enabled": true` and the configured `rp_id`. + +## Registering and using a passkey + +1. Sign in with the existing password through the HTTPS DNS name. +2. Open **Settings → Users** and edit your own user account. +3. Under **Passkeys**, enter a descriptive device label such as `MacBook Touch ID`. This field is + only a recognizable label; no key material is pasted into HomeUI. +4. Select **Register passkey**. +5. Complete the browser, operating-system, or security-key prompt. +6. Sign out. Enter the same username and select **Sign in with passkey**. + +Each passkey belongs to the currently signed-in user. A user may register several passkeys and +remove them independently. Deleting a user also deletes that user's passkeys. + +## Recovery and operational notes + +- Keep password login enabled and store the administrator password securely. +- Register at least two authenticators before relying on passkeys for routine access. +- Passkeys are bound to the relying-party ID. Changing the DNS name requires registering new + passkeys under the new name. +- Reverse proxies must preserve the public HTTPS origin. TLS may terminate at the proxy, but the + browser URL must still match `--webauthn-origin` exactly. +- Challenges are one-time and expire after five minutes. User verification is required. +- Credential private keys never leave the authenticator. HomeUI stores only the public key, + credential identifier, signature counter, name, and timestamps. diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 56eb0994c..e76c8bbc9 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -838,6 +838,15 @@ "https": "HTTPS", "enable-https-warning": " You are creating a first user. To protect the credentials, it is recommended to use a secure HTTPS connection. It can be automatically configured. You will be redirected to {{domain}} and the controller will request a certificate using Wiren Board infrastructure. The controller must have Internet access.", "empty-list": "No users have been created. Click \"Add\" to create the first user.", + "passkeys": "Passkeys", + "passkey-name": "Device name", + "passkey-name-hint": "Enter a recognizable label such as “MacBook Touch ID”. The passkey itself will be registered in a system dialog after you press the button.", + "passkey-name-placeholder": "For example, MacBook Touch ID", + "passkey-created": "Created", + "passkey-last-used": "Last used", + "passkey-never-used": "Never", + "passkeys-empty": "No passkeys have been registered for the current user.", + "passkeys-unavailable": "Passkeys require a supported browser and a secure HTTPS connection.", "role-user-desc": "view dashboards, history and MQTT channels", "role-operator-desc": "all User permissions + create and edit dashboards", "role-admin-desc": "full access: settings, rules, user management, firmware updates, archive downloads" @@ -848,7 +857,9 @@ "edit": "Edit", "delete": "Delete", "enable-https": "Enable HTTPS", - "use-http": "Stay on HTTP" + "use-http": "Stay on HTTP", + "add-passkey": "Register passkey", + "delete-passkey": "Delete passkey" } }, "login": { @@ -860,7 +871,8 @@ "login": "Sign in", "auto-login": "Sign in without password", "forgot-password": "Forgot password?", - "choose-language": "Choose language" + "choose-language": "Choose language", + "passkey": "Sign in with passkey" }, "labels": { "authorization-title": "Logging in to the controller interface", diff --git a/frontend/src/i18n/locales/ru.json b/frontend/src/i18n/locales/ru.json index cef983769..ade97b49c 100644 --- a/frontend/src/i18n/locales/ru.json +++ b/frontend/src/i18n/locales/ru.json @@ -867,6 +867,15 @@ "https": "HTTPS", "enable-https-warning": " Вы создаете первого пользователя. Для защиты учетных данных рекомендуется использовать HTTPS-соединение. Его можно настроить автоматически. Вы будете перенаправлены на {{domain}}, и контроллер запросит сертификат с использованием инфраструктуры Wiren Board. Контроллер должен иметь доступ в Интернет.", "empty-list": "Список пользователей пока пуст. Нажмите «Добавить», чтобы создать первого пользователя.", + "passkeys": "Ключи доступа", + "passkey-name": "Название устройства", + "passkey-name-hint": "Укажите только понятное название, например «MacBook Touch ID». Сам ключ будет зарегистрирован через системное окно после нажатия кнопки.", + "passkey-name-placeholder": "Например, MacBook Touch ID", + "passkey-created": "Создан", + "passkey-last-used": "Последнее использование", + "passkey-never-used": "Не использовался", + "passkeys-empty": "Для текущего пользователя ещё нет ключей доступа.", + "passkeys-unavailable": "Ключи доступа требуют поддерживаемый браузер и безопасное HTTPS-соединение.", "role-user-desc": "просмотр панелей, истории и каналов MQTT", "role-operator-desc": "все права пользователя + создание и редактирование панелей", "role-admin-desc": "полный доступ: настройки, правила, управление пользователями, обновление прошивки, скачивание архивов" @@ -877,7 +886,9 @@ "edit": "Редактировать", "delete": "Удалить", "enable-https": "Настроить HTTPS", - "use-http": "Остаться на HTTP" + "use-http": "Остаться на HTTP", + "add-passkey": "Зарегистрировать ключ", + "delete-passkey": "Удалить ключ" } }, "login": { @@ -889,7 +900,8 @@ "login": "Войти", "auto-login": "Войти без пароля", "forgot-password": "Забыли пароль?", - "choose-language": "Выбор языка" + "choose-language": "Выбор языка", + "passkey": "Войти с ключом доступа" }, "labels": { "authorization-title": "Авторизация в интерфейсе контроллера", diff --git a/frontend/src/pages/login/login.tsx b/frontend/src/pages/login/login.tsx index 3b20e9b0f..cd763ad1d 100644 --- a/frontend/src/pages/login/login.tsx +++ b/frontend/src/pages/login/login.tsx @@ -1,5 +1,5 @@ import { observer } from 'mobx-react-lite'; -import { useState, type SubmitEvent } from 'react'; +import { useEffect, useState, type SubmitEvent } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate, useSearchParams } from 'react-router-dom'; import LocaleIcon from '@/assets/icons/locale.svg'; @@ -11,6 +11,7 @@ import { Button, ButtonLink } from '@/components/button'; import { Dropdown, type Option } from '@/components/dropdown'; import { Input } from '@/components/input'; import { Password } from '@/components/password'; +import { canUseWebAuthn, getWebAuthnConfig } from '@/services/webauthn'; import { authStore } from '@/stores/auth'; import './styles.css'; @@ -41,6 +42,16 @@ const LoginPage = observer(() => { const [isLoading, setIsLoading] = useState(false); const [password, setPassword] = useState(''); const [language, setLanguage] = useState(localStorage.getItem('language') || 'en'); + const [isWebAuthnEnabled, setIsWebAuthnEnabled] = useState(false); + + useEffect(() => { + if (!canUseWebAuthn()) { + return; + } + getWebAuthnConfig() + .then(({ enabled }) => setIsWebAuthnEnabled(enabled)) + .catch(() => setIsWebAuthnEnabled(false)); + }, []); const onSubmit = async (ev: SubmitEvent) => { ev.preventDefault(); @@ -67,6 +78,24 @@ const LoginPage = observer(() => { setLanguage(lang); }; + const onPasskeyLogin = async () => { + try { + setIsShowError(false); + setIsLoading(true); + await authStore.loginWithPasskey(login); + const externalReturn = getSafeExternalReturn(); + if (externalReturn) { + window.location.assign(externalReturn); + return; + } + navigate(searchParams.get('returnState') ?? '/', { replace: true }); + } catch { + setIsShowError(true); + } finally { + setIsLoading(false); + } + }; + const languageOptions: Option[] = [ { label: 'English', value: 'en' }, { label: 'Русский', value: 'ru' }, @@ -143,6 +172,16 @@ const LoginPage = observer(() => { icon={isLoading && } label={t('login.buttons.login')} /> + {isWebAuthnEnabled && ( +