feat: add auth subsystem with WebAuthn passkeys support

New modules: lib/auth, lib/auth_users, lib/db, lib/db_sqlite, lib/password,
lib/webauthn, daemon/handlers/auth, scripts/bootstrap_auth, tests/test_auth

Frontend: webui/api/auth, hoover/components/auth, pages/login, passkeys, users

Updates: daemon/iface and server, lib/common and nginx, pyproject.toml deps,
install script, server.py, app.js, and websocket/api clients
This commit is contained in:
2026-07-24 01:21:39 +00:00
parent 04417cf05c
commit 56b200d233
28 changed files with 4900 additions and 82 deletions
+358
View File
@@ -0,0 +1,358 @@
"""JWT authentication module for Vacuum Wall.
Handles token creation, validation, refresh, and blacklisting.
Configuration comes from config/auth/config.json.
"""
from __future__ import annotations
import logging
import secrets
import time
import uuid
from pathlib import Path
from typing import Any
import jwt
from lib.common import load_json, save_json
from lib.db import (
Q_DELETE_EXPIRED_BLACKLIST,
Q_DELETE_REFRESH_TOKEN,
Q_INSERT_BLACKLIST,
Q_SELECT_BLACKLIST,
Q_SELECT_REFRESH_TOKEN,
Q_UPSERT_REFRESH_TOKEN,
get_db,
)
logger = logging.getLogger(__name__)
AUTH_CONFIG_PATH = (
Path(__file__).resolve().parent.parent / "config" / "auth" / "config.json"
)
_DEFAULT_JWT_CONFIG = {
"access_token_ttl": 900,
"refresh_token_ttl": 604800,
"algorithm": "HS256",
"secret": "",
}
def _get_jwt_config() -> dict[str, Any]:
"""Load JWT configuration from auth config."""
raw = load_json(AUTH_CONFIG_PATH)
return raw.get("jwt", _DEFAULT_JWT_CONFIG)
def get_secret() -> str | None:
"""Return the JWT signing secret, or ``None`` if not configured."""
secret = _get_jwt_config().get("secret")
return secret if secret else None
def get_access_ttl() -> int:
"""Return access token TTL in seconds."""
return _get_jwt_config().get("access_token_ttl", 900)
def get_refresh_ttl() -> int:
"""Return refresh token TTL in seconds."""
return _get_jwt_config().get("refresh_token_ttl", 604800)
def get_algorithm() -> str:
"""Return the JWT algorithm."""
return _get_jwt_config().get("algorithm", "HS256")
def generate_access_token(username: str, permissions: dict[str, str]) -> str:
"""Generate a new access token for *username*.
Args:
username: The authenticated username.
permissions: Dict mapping subsystem names to permission levels.
Returns:
JWT token string.
Raises:
RuntimeError: If JWT secret is not configured.
"""
secret = get_secret()
if not secret:
raise RuntimeError("JWT secret is not configured")
algorithm = get_algorithm()
now = int(time.time())
payload = {
"sub": username,
"exp": now + get_access_ttl(),
"iat": now,
"jti": str(uuid.uuid4()),
"type": "access",
"permissions": permissions,
}
return jwt.encode(payload, secret, algorithm=algorithm)
def generate_refresh_token(username: str) -> str:
"""Generate a new refresh token for *username*.
Args:
username: The authenticated username.
Returns:
JWT refresh token string.
Raises:
RuntimeError: If JWT secret is not configured.
"""
secret = get_secret()
if not secret:
raise RuntimeError("JWT secret is not configured")
algorithm = get_algorithm()
now = int(time.time())
payload = {
"sub": username,
"exp": now + get_refresh_ttl(),
"iat": now,
"jti": str(uuid.uuid4()),
"type": "refresh",
}
return jwt.encode(payload, secret, algorithm=algorithm)
def generate_tokens(username: str, permissions: dict[str, str]) -> dict[str, str]:
"""Generate both access and refresh tokens.
Args:
username: The authenticated username.
permissions: Dict mapping subsystem names to permission levels.
Returns:
Dict with ``access_token`` and ``refresh_token`` keys.
"""
access_token = generate_access_token(username, permissions)
refresh_token = generate_refresh_token(username)
_persist_refresh_token(username, refresh_token)
return {
"access_token": access_token,
"refresh_token": refresh_token,
}
def _persist_refresh_token(username: str, refresh_token: str) -> None:
"""Persist the active refresh token JTI for *username* in the database.
One row per user — replaces any existing entry on upsert.
Args:
username: The username.
refresh_token: The JWT refresh token string.
"""
payload = decode_token(refresh_token)
if payload is None:
return
jti = payload.get("jti")
if not jti:
return
issued_at = payload.get("iat", int(time.time()))
db = get_db()
db.run(Q_UPSERT_REFRESH_TOKEN, (username, jti, issued_at))
def blacklist_active_refresh_token(username: str) -> None:
"""Blacklist the user's currently active refresh token from the database.
Looks up the stored JTI for *username*, blacklists it, and removes the
database entry. Safe to call when no token is registered — the query
will simply return no rows.
Args:
username: The username.
"""
db = get_db()
rows = db.query(Q_SELECT_REFRESH_TOKEN, (username,))
if rows:
blacklist_token(rows[0]["jti"], token_type="refresh")
db.run(Q_DELETE_REFRESH_TOKEN, (username,))
def clear_active_refresh_token(username: str) -> None:
"""Remove the user's stored refresh token entry without blacklisting.
Used when the refresh token has already been blacklisted (e.g., during
a successful refresh rotation).
Args:
username: The username.
"""
db = get_db()
db.run(Q_DELETE_REFRESH_TOKEN, (username,))
def decode_token(token_string: str) -> dict[str, Any] | None:
"""Decode and validate a JWT token.
Args:
token_string: The JWT token string (without Bearer prefix).
Returns:
Payload dict if valid, None if invalid/expired or secret not configured.
"""
secret = get_secret()
if not secret:
return None
algorithm = get_algorithm()
try:
payload = jwt.decode(token_string, secret, algorithms=[algorithm])
return payload
except (jwt.ExpiredSignatureError, jwt.InvalidTokenError):
return None
def validate_token(
token_string: str, token_type: str = "access"
) -> dict[str, Any] | None:
"""Validate a JWT token and check it against the blacklist.
Args:
token_string: The JWT token string.
token_type: Expected token type ("access" or "refresh").
Returns:
Payload dict including permissions, or None if invalid/blacklisted.
"""
payload = decode_token(token_string)
if payload is None:
return None
if payload.get("type") != token_type:
return None
jti = payload.get("jti")
if jti and is_blacklisted(jti):
return None
return payload
def blacklist_token(jti: str, token_type: str = "access") -> None:
"""Add a JTI to the blacklist.
Args:
jti: Token UUID to blacklist.
token_type: Token type ("access" or "refresh"). Defaults to "access".
The blacklist expiry is set to current time + the token type's TTL.
"""
db = get_db()
ttl = get_refresh_ttl() if token_type == "refresh" else get_access_ttl()
db.run(Q_INSERT_BLACKLIST, (jti, token_type, int(time.time()) + ttl))
def is_blacklisted(jti: str) -> bool:
"""Check if a JTI is blacklisted.
Args:
jti: Token UUID to check.
Returns:
True if the token has been blacklisted.
"""
db = get_db()
rows = db.query(Q_SELECT_BLACKLIST, (jti,))
return len(rows) > 0
def blacklist_expired() -> None:
"""Remove expired entries from the blacklist."""
db = get_db()
now = int(time.time())
db.run(Q_DELETE_EXPIRED_BLACKLIST, (now,))
def rotate_secret() -> None:
"""Rotate the JWT secret, invalidating all existing tokens.
Used when a user's password is changed to ensure all prior sessions
are immediately terminated regardless of token expiration.
"""
raw = load_json(AUTH_CONFIG_PATH)
jwt_config = raw.get("jwt", _DEFAULT_JWT_CONFIG)
jwt_config["secret"] = secrets.token_urlsafe(48)
raw["jwt"] = jwt_config
save_json(AUTH_CONFIG_PATH, raw)
logger.warning("JWT secret rotated — all existing tokens are now invalid")
class RateLimiter:
"""Simple sliding-window rate limiter for login attempts.
Maintains per-key attempt timestamps and rejects requests that exceed
the allowed count within the window.
"""
def __init__(self, max_attempts: int = 5, window_seconds: int = 300) -> None:
self.max_attempts = max_attempts
self.window = window_seconds
self.attempts: dict[str, list[float]] = {}
def is_allowed(self, key: str) -> bool:
"""Check if a request from *key* is allowed.
Args:
key: Identifier for the rate limit bucket (e.g., username or IP).
Returns:
True if the request is allowed, False if rate limited.
"""
now = time.time()
cutoff = now - self.window
timestamps = self.attempts.get(key, [])
# Clean old entries
self.attempts[key] = [t for t in timestamps if t > cutoff]
if len(self.attempts[key]) >= self.max_attempts:
return False
self.attempts[key].append(now)
return True
def cleanup(self) -> None:
"""Remove expired entries from all buckets."""
now = time.time()
cutoff = now - self.window
for key in list(self.attempts):
self.attempts[key] = [t for t in self.attempts[key] if t > cutoff]
if not self.attempts[key]:
del self.attempts[key]
# Global rate limiters
_login_limiter = RateLimiter(max_attempts=10, window_seconds=300)
_webauthn_limiter = RateLimiter(max_attempts=5, window_seconds=600)
def check_login_rate(username: str) -> bool:
"""Check if login is rate-limited for the given username.
Args:
username: The login attempt username.
Returns:
True if the attempt is allowed, False if rate limited.
"""
return _login_limiter.is_allowed(username)
def check_webauthn_rate(username: str) -> bool:
"""Check if WebAuthn authentication is rate-limited for the given username.
Args:
username: The WebAuthn attempt username.
Returns:
True if the attempt is allowed, False if rate limited.
"""
return _webauthn_limiter.is_allowed(username)
+258
View File
@@ -0,0 +1,258 @@
"""User CRUD and permission management for Vacuum Wall authentication.
All operations use DB query IDs through the Database abstract layer.
Password hashing uses Argon2id via lib.password.
"""
from __future__ import annotations
import logging
import re
from typing import Any
from lib.auth import blacklist_active_refresh_token, blacklist_expired
from lib.db import (
Q_DELETE_PERMISSIONS,
Q_DELETE_USER,
Q_INSERT_USER,
Q_SELECT_ALL_USERS,
Q_SELECT_PERMISSIONS,
Q_SELECT_USER_BY_NAME,
Q_UPDATE_PASSWORD,
Q_UPSERT_PERMISSION,
get_db,
)
from lib.password import hash_password, verify_password
logger = logging.getLogger(__name__)
# All subsystem names for default permission assignment
ALL_SUBSYSTEMS = [
"firewall",
"network",
"dhcp",
"proxy",
"certs",
"wireguard",
"logs",
"status",
"auth",
]
_USERNAME_RE = re.compile(r"^[a-zA-Z0-9_-]{3,32}$")
def _get_permissions(username: str) -> dict[str, str]:
"""Load permissions for *username* from the database.
Args:
username: The username to load permissions for.
Returns:
Dict mapping subsystem names to permission levels.
"""
db = get_db()
rows = db.query(Q_SELECT_PERMISSIONS, (username,))
return {row["subsystem"]: row["level"] for row in rows}
def get_user(username: str) -> dict[str, Any] | None:
"""Get a user by username (without password hash).
Args:
username: The username to look up.
Returns:
User dict with id, username, permissions, or None if not found.
"""
user = find_user(username)
if user is None:
return None
return {
"id": user["id"],
"username": user["username"],
"permissions": _get_permissions(username),
}
def find_user(username: str) -> dict[str, Any] | None:
"""Find a user by username, including password hash.
Used for password verification. Not returned through APIs.
Args:
username: The username to look up.
Returns:
User dict from the database row, or None.
"""
db = get_db()
rows = db.query(Q_SELECT_USER_BY_NAME, (username,))
if not rows:
return None
return rows[0]
def verify_user_password(username: str, password: str) -> dict[str, Any] | None:
"""Verify a user's password using Argon2id.
Args:
username: The username to verify.
password: Plain-text password.
Returns:
User dict (without password hash) if the password is correct, None otherwise.
"""
user = find_user(username)
if user is None:
return None
if not verify_password(password, user["password_hash"]):
return None
return {
"id": user["id"],
"username": user["username"],
"permissions": _get_permissions(username),
}
def create_user(
username: str,
password: str,
permissions: dict[str, str] | None = None,
) -> dict[str, Any]:
"""Create a new user with the given password and permissions.
Args:
username: The new username (3-32 alphanumeric chars, dash, underscore).
password: Plain-text password that will be hashed with Argon2id.
permissions: Dict mapping subsystem names to "read" or "rw".
Returns:
The created user dict with id, username, permissions.
Raises:
ValueError: If username is invalid or already exists.
"""
if not _USERNAME_RE.match(username):
raise ValueError(
"Username must be 3-32 characters: letters, digits, dash, underscore"
)
existing = find_user(username)
if existing is not None:
raise ValueError(f"User {username!r} already exists")
password_hash = hash_password(password)
db = get_db()
with db.in_transaction() as tx:
tx.run_one(Q_INSERT_USER, (username, password_hash))
if permissions:
for subsystem, level in permissions.items():
tx.run(Q_UPSERT_PERMISSION, (username, subsystem, level))
return {
"id": db.query(Q_SELECT_USER_BY_NAME, (username,))[0]["id"],
"username": username,
"permissions": _get_permissions(username),
}
def update_password(username: str, old_password: str, new_password: str) -> bool:
"""Update a user's password and invalidate all active refresh tokens.
Old access tokens expire naturally (15 min TTL). The active refresh
token is immediately blacklisted to prevent token reuse.
Args:
username: The username.
old_password: Current password.
new_password: New plain-text password.
Returns:
True if password was updated.
Raises:
ValueError: If old password is incorrect.
"""
if not verify_user_password(username, old_password):
raise ValueError("Current password is incorrect")
blacklist_active_refresh_token(username)
new_hash = hash_password(new_password)
db = get_db()
db.run(Q_UPDATE_PASSWORD, (new_hash, username))
_cleanup_blacklist()
return True
def update_permissions(username: str, permissions: dict[str, str]) -> None:
"""Update a user's permissions.
Replaces all existing permissions with the provided mapping.
Args:
username: The username.
permissions: Dict mapping subsystem names to permission levels.
"""
user = find_user(username)
if user is None:
raise ValueError(f"User {username!r} not found")
db = get_db()
with db.in_transaction() as tx:
tx.run(Q_DELETE_PERMISSIONS, (username,))
for subsystem, level in permissions.items():
tx.run(Q_UPSERT_PERMISSION, (username, subsystem, level))
def list_users() -> list[dict[str, Any]]:
"""List all users with their permissions.
Returns:
List of user summary dicts.
"""
db = get_db()
rows = db.query(Q_SELECT_ALL_USERS, ())
result = []
for row in rows:
username = row["username"]
permissions = _get_permissions(username)
result.append(
{
"id": row["id"],
"username": username,
"permissions": permissions,
"created_at": row["created_at"],
}
)
return result
def delete_user(username: str) -> bool:
"""Delete a user and all their associated data.
Permissions and WebAuthn credentials are CASCADE-deleted by the schema.
Args:
username: The username to delete.
Returns:
True if the user was deleted.
Raises:
ValueError: If the user does not exist.
"""
user = find_user(username)
if user is None:
raise ValueError(f"User {username!r} not found")
db = get_db()
db.run(Q_DELETE_USER, (username,))
return True
def _cleanup_blacklist() -> None:
"""Clean up expired blacklist entries."""
blacklist_expired()
+19
View File
@@ -13,6 +13,8 @@ from copy import deepcopy
from pathlib import Path
from typing import Any
from passlib.hash import sha256_crypt
_APPLY_HASH_KEY = "_last_applied_hash"
@@ -171,6 +173,22 @@ def ensure_dirs(*dirs: Path) -> None:
d.mkdir(parents=True, exist_ok=True)
def _hash_password(password: str) -> str:
"""Hash *password* using SHA-256 crypt (``$5$`` format) via passlib.
Used for nginx htpasswd files. NOT used for auth user passwords —
those use Argon2id via ``lib.password``.
Args:
password: Plain-text password to hash.
Returns:
The hashed password string suitable for ``.htpasswd``
(e.g. ``$5$rounds=…$…``).
"""
return sha256_crypt.hash(password)
def get_interface_ip(iface: str) -> str | None:
"""Return the primary IPv4 address of *iface* (without CIDR), or ``None``.
@@ -192,6 +210,7 @@ def get_interface_ip(iface: str) -> str | None:
__all__ = [
"_APPLY_HASH_KEY",
"_hash_password",
"config_hash",
"deep_merge",
"ensure_dirs",
+280
View File
@@ -0,0 +1,280 @@
"""Abstract database layer for Vacuum Wall.
Provides query ID constants and an abstract Database baseclass so subsystems
interact with the database through opaque query identifiers, never raw SQL.
Backend implementations (SQLite, PostgreSQL) provide the actual SQL.
Usage:
from lib.db import Q_INSERT_USER, Database, get_db
class SQLiteBackend(Database):
QUERY_MAP = {
Q_INSERT_USER: "INSERT INTO users (username, password_hash) VALUES (?, ?)",
...
}
"""
from __future__ import annotations
import logging
import os
from abc import ABC, abstractmethod
from pathlib import Path
from typing import Any, ClassVar
logger = logging.getLogger(__name__)
PROJECT_DIR = Path(__file__).resolve().parent.parent
# ---------------------------------------------------------------------------
# Query ID constants — single source of truth for all database operations
# ---------------------------------------------------------------------------
Q_INIT_TABLES = "init_tables"
Q_INSERT_USER = "insert_user"
Q_SELECT_USER_BY_NAME = "select_user_by_name"
Q_SELECT_USER_BY_ID = "select_user_by_id"
Q_UPDATE_PASSWORD = "update_password"
Q_DELETE_USER = "delete_user"
Q_UPSERT_PERMISSION = "upsert_permission"
Q_SELECT_PERMISSIONS = "select_permissions"
Q_DELETE_PERMISSIONS = "delete_permissions"
Q_INSERT_BLACKLIST = "insert_blacklist"
Q_SELECT_BLACKLIST = "select_blacklist_jti"
Q_DELETE_EXPIRED_BLACKLIST = "delete_expired_blacklist"
Q_UPSERT_REFRESH_TOKEN = "upsert_refresh_token"
Q_SELECT_REFRESH_TOKEN = "select_refresh_token"
Q_DELETE_REFRESH_TOKEN = "delete_refresh_token"
Q_INSERT_WEBAUTHN = "insert_webauthn"
Q_SELECT_WEBAUTHN_USER = "select_webauthn_user"
Q_SELECT_WEBAUTHN_ID = "select_webauthn_id"
Q_SELECT_WEBAUTHN_COUNTS = "select_webauthn_counts"
Q_DELETE_WEBAUTHN = "delete_webauthn"
Q_UPDATE_WEBAUTHN_SIGN_COUNT = "update_webauthn_sign_count"
Q_SELECT_ALL_USERS = "select_all_users"
# ---------------------------------------------------------------------------
# Schema DDL
# ---------------------------------------------------------------------------
INIT_SQL = """
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
created_at INTEGER NOT NULL DEFAULT (unixepoch())
);
CREATE TABLE IF NOT EXISTS permissions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL REFERENCES users(username) ON DELETE CASCADE,
subsystem TEXT NOT NULL,
level TEXT NOT NULL CHECK (level IN ('read', 'rw')),
UNIQUE(username, subsystem)
);
CREATE TABLE IF NOT EXISTS token_blacklist (
jti TEXT PRIMARY KEY,
token_type TEXT NOT NULL,
expires INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS refresh_tokens (
username TEXT UNIQUE NOT NULL,
jti TEXT NOT NULL,
issued_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS webauthn_creds (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL REFERENCES users(username) ON DELETE CASCADE,
credential_id TEXT NOT NULL,
public_key TEXT NOT NULL,
sign_count INTEGER NOT NULL DEFAULT 0,
name TEXT NOT NULL DEFAULT '',
transports TEXT NOT NULL DEFAULT '[]',
UNIQUE(username, credential_id)
);
CREATE TABLE IF NOT EXISTS init_sequence (
seq INTEGER PRIMARY KEY
);
"""
class Transaction:
"""Context manager for database transactions.
Provides BEGIN/COMMIT/ROLLBACK semantics. Auto-commit is suppressed
inside the transaction block.
Usage:
with db.in_transaction() as tx:
tx.run(Q_INSERT_USER, ("user1", "hash"))
tx.run(Q_UPSERT_PERMISSION, ("user1", "firewall", "rw"))
"""
def __init__(self, parent: Database) -> None:
self._parent = parent
def __enter__(self) -> Transaction:
self._parent._begin()
self._parent._suppress_auto_commit()
return self
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> bool:
try:
if exc_type is None:
self._parent._commit()
else:
self._parent._rollback()
finally:
self._parent._restore_auto_commit()
return False
def query(self, query_id: str, params: tuple = ()) -> list[dict]:
return self._parent.query(query_id, params)
def run(self, query_id: str, params: tuple = ()) -> int:
return self._parent.run(query_id, params)
def run_one(self, query_id: str, params: tuple = ()) -> int | dict:
return self._parent.run_one(query_id, params)
class Database(ABC):
"""Abstract database interface.
All subsystems interact with the database through this interface.
Queries are identified by string IDs (e.g. Q_INSERT_USER) — never
raw SQL strings.
Connection is cached via the ``conn`` property. Prepared statements
are auto-cached on first use.
"""
QUERY_MAP: ClassVar[dict[str, str]] = {}
def __init__(self, connection_string: str) -> None:
self._connection_string = connection_string
self._conn: Any = None
self._prepared: dict[str, Any] = {}
self._in_transaction = False
@property
def conn(self) -> Any:
"""Return the cached database connection, creating it lazily."""
if self._conn is None:
self._conn = self._connect(self._connection_string)
return self._conn
@abstractmethod
def _connect(self, cs: str) -> Any: ...
@abstractmethod
def _prepare(self, sql: str) -> Any: ...
@abstractmethod
def _execute(self, stmt: Any, params: tuple) -> tuple[list[dict], int]:
"""Execute a prepared statement. Returns (rows, rowcount)."""
...
@abstractmethod
def _last_insert_id(self, stmt: Any) -> int | dict: ...
@abstractmethod
def _execute_direct(self, sql: str) -> None:
"""Execute raw SQL without prepared statements (for DDL, not implemented by base)."""
def _begin(self) -> None: # noqa: B027
"""Begin a transaction (not implemented by base)."""
def _commit(self) -> None: # noqa: B027
"""Commit a transaction (not implemented by base)."""
def _rollback(self) -> None: # noqa: B027
"""Rollback a transaction (not implemented by base)."""
def _suppress_auto_commit(self) -> None: # noqa: B027
"""Suppress auto-commit (not implemented by base)."""
def _restore_auto_commit(self) -> None: # noqa: B027
"""Restore auto-commit (not implemented by base)."""
def query(self, query_id: str, params: tuple = ()) -> list[dict]:
"""Execute a SELECT query. Returns list of row dicts."""
stmt = self._get_prepared(query_id)
rows, _ = self._execute(stmt, params)
return rows
def run(self, query_id: str, params: tuple = ()) -> int:
"""Execute an INSERT/UPDATE/DELETE. Returns affected row count."""
stmt = self._get_prepared(query_id)
_, count = self._execute(stmt, params)
return count
def run_one(self, query_id: str, params: tuple = ()) -> int | dict:
"""Execute and return the last insert ID or row dict."""
stmt = self._get_prepared(query_id)
_, _ = self._execute(stmt, params)
return self._last_insert_id(stmt)
def in_transaction(self) -> Transaction:
"""Return a transaction context manager."""
return Transaction(self)
def init_tables(self) -> None:
"""Create schema tables if they don't exist."""
self._execute_direct(INIT_SQL)
def _get_prepared(self, query_id: str) -> Any:
if query_id not in self._prepared:
if query_id not in self.QUERY_MAP:
raise KeyError(f"Unknown query ID: {query_id!r}")
self._prepared[query_id] = self._prepare(self.QUERY_MAP[query_id])
return self._prepared[query_id]
# ---------------------------------------------------------------------------
# Singleton accessor
# ---------------------------------------------------------------------------
_db_instance: Database | None = None
def _get_backend_name() -> str:
return os.environ.get("VACUUM_WALL_DB_BACKEND", "sqlite")
def _get_db_path() -> str:
return os.environ.get("VACUUM_WALL_DB_PATH", str(PROJECT_DIR / "data" / "auth.db"))
def get_db() -> Database:
"""Return the singleton Database instance.
Creates the instance on first call using the backend specified by
``VACUUM_WALL_DB_BACKEND`` env var (default: sqlite).
Call this at application startup to ensure the DB is initialized.
"""
global _db_instance
if _db_instance is None:
backend = _get_backend_name()
if backend == "sqlite":
from lib.db_sqlite import SQLiteBackend
path = _get_db_path()
_db_instance = SQLiteBackend(path)
Path(path).parent.mkdir(parents=True, exist_ok=True)
_db_instance.init_tables()
else:
raise ValueError(f"Unknown database backend: {backend!r}")
return _db_instance
def reset_db_for_test() -> None:
"""Reset the singleton — only for tests."""
global _db_instance
if _db_instance is not None:
_db_instance = None
+168
View File
@@ -0,0 +1,168 @@
"""SQLite backend for Vacuum Wall database.
Concrete implementation of the Database abstract class using SQLite3.
Uses Python 3.13+ sqlite3.Statement for prepared statements.
"""
from __future__ import annotations
import contextlib
import logging
import sqlite3
from typing import Any, ClassVar
from lib.db import (
Q_DELETE_EXPIRED_BLACKLIST,
Q_DELETE_PERMISSIONS,
Q_DELETE_REFRESH_TOKEN,
Q_DELETE_USER,
Q_DELETE_WEBAUTHN,
Q_INSERT_BLACKLIST,
Q_INSERT_USER,
Q_INSERT_WEBAUTHN,
Q_SELECT_ALL_USERS,
Q_SELECT_BLACKLIST,
Q_SELECT_PERMISSIONS,
Q_SELECT_REFRESH_TOKEN,
Q_SELECT_USER_BY_ID,
Q_SELECT_USER_BY_NAME,
Q_SELECT_WEBAUTHN_COUNTS,
Q_SELECT_WEBAUTHN_ID,
Q_SELECT_WEBAUTHN_USER,
Q_UPDATE_PASSWORD,
Q_UPDATE_WEBAUTHN_SIGN_COUNT,
Q_UPSERT_PERMISSION,
Q_UPSERT_REFRESH_TOKEN,
Database,
)
logger = logging.getLogger(__name__)
class SQLiteBackend(Database):
"""SQLite implementation of the Database interface.
Uses ``sqlite3.Connection.execute()`` for statement execution and
``sqlite3.Row`` for row-factory dict access.
"""
def __init__(self, connection_string: str) -> None:
super().__init__(connection_string)
self._last_rowid: int = 0
QUERY_MAP: ClassVar[dict[str, str]] = {
# Schema init is handled by direct execution, not prepared statements
# init_tables is called as _execute_direct(INIT_SQL)
# Users
Q_INSERT_USER: ("INSERT INTO users (username, password_hash) VALUES (?, ?)"),
Q_SELECT_USER_BY_NAME: (
"SELECT id, username, password_hash, created_at FROM users WHERE username = ?"
),
Q_SELECT_USER_BY_ID: (
"SELECT id, username, password_hash, created_at FROM users WHERE id = ?"
),
Q_UPDATE_PASSWORD: "UPDATE users SET password_hash = ? WHERE username = ?",
Q_DELETE_USER: "DELETE FROM users WHERE username = ?",
Q_SELECT_ALL_USERS: (
"SELECT id, username, created_at FROM users ORDER BY username"
),
# Permissions
Q_UPSERT_PERMISSION: (
"INSERT INTO permissions (username, subsystem, level) "
"VALUES (?, ?, ?) "
"ON CONFLICT(username, subsystem) DO UPDATE SET level = excluded.level"
),
Q_SELECT_PERMISSIONS: (
"SELECT subsystem, level FROM permissions WHERE username = ?"
),
Q_DELETE_PERMISSIONS: ("DELETE FROM permissions WHERE username = ?"),
# Token blacklist
Q_INSERT_BLACKLIST: (
"INSERT OR IGNORE INTO token_blacklist (jti, token_type, expires) VALUES (?, ?, ?)"
),
Q_SELECT_BLACKLIST: "SELECT jti FROM token_blacklist WHERE jti = ?",
Q_DELETE_EXPIRED_BLACKLIST: "DELETE FROM token_blacklist WHERE expires < ?",
# Refresh tokens
Q_UPSERT_REFRESH_TOKEN: (
"INSERT INTO refresh_tokens (username, jti, issued_at) "
"VALUES (?, ?, ?) "
"ON CONFLICT(username) DO UPDATE SET jti = excluded.jti, issued_at = excluded.issued_at"
),
Q_SELECT_REFRESH_TOKEN: "SELECT username, jti, issued_at FROM refresh_tokens WHERE username = ?",
Q_DELETE_REFRESH_TOKEN: "DELETE FROM refresh_tokens WHERE username = ?",
# WebAuthn
Q_INSERT_WEBAUTHN: (
"INSERT INTO webauthn_creds "
"(username, credential_id, public_key, sign_count, name, transports) "
"VALUES (?, ?, ?, ?, ?, ?)"
),
Q_SELECT_WEBAUTHN_USER: (
"SELECT id, credential_id, public_key, sign_count, name, transports "
"FROM webauthn_creds WHERE username = ?"
),
Q_SELECT_WEBAUTHN_ID: (
"SELECT id, username, credential_id, public_key, sign_count, name, transports "
"FROM webauthn_creds WHERE credential_id = ?"
),
Q_SELECT_WEBAUTHN_COUNTS: (
"SELECT username, COUNT(*) as cred_count FROM webauthn_creds "
"GROUP BY username"
),
Q_UPDATE_WEBAUTHN_SIGN_COUNT: (
"UPDATE webauthn_creds SET sign_count = ? WHERE credential_id = ?"
),
Q_DELETE_WEBAUTHN: "DELETE FROM webauthn_creds WHERE credential_id = ?",
}
def _connect(self, cs: str) -> Any:
"""Create a SQLite connection with WAL mode and row factory."""
conn = sqlite3.connect(cs, isolation_level=None)
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON")
conn.row_factory = sqlite3.Row
return conn
def _prepare(self, sql: str) -> Any:
"""Store the SQL string for later execution.
SQLite in-memory DB doesn't support the Python 3.13 conn.prepare()
API, so we store the raw SQL and execute via conn.execute().
"""
return sql
def _execute(self, stmt: Any, params: tuple) -> tuple[list[dict], int]:
"""Execute a prepared statement, returning (rows, rowcount)."""
cursor = self.conn.execute(stmt, params)
self._last_rowid = cursor.lastrowid
result = cursor.fetchall()
rows: list[dict] = []
for row in result:
rows.append(dict(row))
return rows, cursor.rowcount
def _last_insert_id(self, stmt: Any) -> int | dict:
"""Return the last insert row ID from the most recent execute."""
return self._last_rowid
def _begin(self) -> None:
self.conn.execute("BEGIN")
def _commit(self) -> None:
self.conn.execute("COMMIT")
def _rollback(self) -> None:
with contextlib.suppress(sqlite3.Error):
self.conn.execute("ROLLBACK")
def _suppress_auto_commit(self) -> None:
self._in_transaction = True
def _restore_auto_commit(self) -> None:
self._in_transaction = False
def _execute_direct(self, sql: str) -> None:
"""Execute raw SQL without prepared statements (for DDL)."""
for line in sql.split(";"):
line = line.strip()
if line:
self.conn.execute(line)
+1 -26
View File
@@ -14,7 +14,7 @@ from typing import Any
from jinja2 import Environment, FileSystemLoader
from lib.acme import find_cert_dir
from lib.common import ensure_dirs, load_json, save_json
from lib.common import _hash_password, ensure_dirs, load_json, save_json
logger = logging.getLogger(__name__)
@@ -50,10 +50,6 @@ DEFAULT_SSL: dict[str, Any] = {
WEBUI_BACKEND: dict[str, Any] = {
"label": "Vacuum Wall WebUI",
"builtin": True,
"auth": {
"user": "admin",
"htpasswd": str(HTPASSWD_FILE),
},
"paths": {
"/": {
"backend": {"host": "127.0.0.1", "port": 9090, "proto": "http"},
@@ -120,13 +116,6 @@ def _ensure_webui_backend(raw: dict[str, Any]) -> None:
return
backends["webui"] = deepcopy(WEBUI_BACKEND)
backends["webui"]["_migrated"] = True
# Harvest auth from legacy path-level auth if present
for dom in raw.get("domains", {}).values():
paths = dom.get("paths", {})
root = paths.get("/", {})
if root.get("auth"):
backends["webui"]["auth"] = root["auth"]
break
def _migrate_mgmt_domains(raw: dict[str, Any]) -> None:
@@ -577,20 +566,6 @@ def write_htpasswd(user: str, password: str) -> None:
os.replace(tmp, HTPASSWD_FILE)
def _hash_password(password: str) -> str:
"""Hash *password* using SHA-256 crypt (``$5$`` format) via passlib.
Args:
password: Plain-text password to hash.
Returns:
The hashed password string suitable for ``.htpasswd`` (e.g. ``$5$rounds=…$…``).
"""
from passlib.hash import sha256_crypt
return sha256_crypt.hash(password)
__all__ = [
"WEBUI_BACKEND",
"_ensure_webui_backend",
+83
View File
@@ -0,0 +1,83 @@
"""Password hashing with Argon2id.
Handles user password storage for the auth DB. Uses argon2-cffi (C
implementation of the Argon2id memory-hard KDF). Random 16-byte salt is
generated per hash by the library.
Argon2id is used for auth user passwords ONLY. Nginx basic-auth htpasswd
files continue to use sha256_crypt (passlib) — that's a separate concern
with different constraints (htpasswd format is standardized).
"""
from __future__ import annotations
from argon2 import PasswordHasher
from argon2.exceptions import InvalidHashError, VerifyMismatchError
from argon2.low_level import Type
# Argon2id: OWASP recommended parameters
# 64 MiB memory, 3 iterations, 4 parallel threads
_PH = PasswordHasher(
time_cost=3,
memory_cost=65536, # 64 MiB
parallelism=4,
hash_len=32,
salt_len=16,
type=Type.ID,
)
def hash_password(password: str) -> str:
"""Generate an Argon2id hash of *password*.
A random 16-byte salt is generated automatically by argon2.
The resulting hash string starts with ``$argon2id$`` and encodes
the algorithm version, parameters, salt, and hash output.
Args:
password: Plain-text password string.
Returns:
Full Argon2id hash string (e.g. ``$argon2id$v=19$m=65536,t=3,p=4$...``).
Raises:
TypeError: If *password* is not a string or contains NUL bytes.
"""
return _PH.hash(password)
def verify_password(password: str, hash_string: str) -> bool:
"""Verify *password* against an Argon2id *hash_string*.
The hash string must have been produced by :func:`hash_password`.
Args:
password: Plain-text password to verify.
hash_string: Argon2id hash string to compare against.
Returns:
``True`` if the password matches, ``False`` otherwise.
Raises:
TypeError: If *hash_string* is not a valid Argon2id hash.
"""
try:
_PH.verify(hash_string, password)
return True
except (InvalidHashError, VerifyMismatchError, ValueError):
return False
def needs_rehash(hash_string: str) -> bool:
"""Check if *hash_string* needs to be rehashed with updated parameters.
Returns True if the hash was not produced with the current parameters
of the hasher, indicating it should be rehashed on next login.
Args:
hash_string: Argon2id hash string to check.
Returns:
``True`` if the hash parameters should be upgraded.
"""
return _PH.check_needs_rehash(hash_string)
+355
View File
@@ -0,0 +1,355 @@
"""WebAuthn passkey support for Vacuum Wall.
Uses the Duo Labs webauthn library (v3) to handle the FIDO2/WebAuthn ceremony:
registration, authentication, and credential management.
Credential data is stored in the database webauthn_creds table.
Configuration comes from config/auth/config.json (webauthn section).
"""
from __future__ import annotations
import base64
import json
import logging
from typing import Any
from webauthn import (
generate_authentication_options,
generate_registration_options,
options_to_json,
verify_authentication_response,
verify_registration_response,
)
from webauthn.helpers.cose import COSEAlgorithmIdentifier
from webauthn.helpers.structs import (
AttestationConveyancePreference,
AuthenticatorSelectionCriteria,
PublicKeyCredentialDescriptor,
ResidentKeyRequirement,
UserVerificationRequirement,
)
from lib.auth import AUTH_CONFIG_PATH
from lib.common import load_json
from lib.db import (
Q_DELETE_WEBAUTHN,
Q_INSERT_WEBAUTHN,
Q_SELECT_WEBAUTHN_COUNTS,
Q_SELECT_WEBAUTHN_ID,
Q_SELECT_WEBAUTHN_USER,
Q_UPDATE_WEBAUTHN_SIGN_COUNT,
get_db,
)
logger = logging.getLogger(__name__)
# Crypto algorithms we support
_SUPPORTED_ALGS = [
COSEAlgorithmIdentifier.ECDSA_SHA_256,
COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_256,
COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_384,
COSEAlgorithmIdentifier.ECDSA_SHA_512,
COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_512,
COSEAlgorithmIdentifier.EDDSA,
]
# b64url helpers
def b64u_encode(b: bytes) -> str:
return base64.urlsafe_b64encode(b).decode("ascii").rstrip("=")
def b64u_decode(s: str) -> bytes:
padding = 4 - len(s) % 4
if padding != 4:
s += "=" * padding
return base64.urlsafe_b64decode(s)
def _get_webauthn_config() -> dict[str, Any]:
"""Load WebAuthn configuration from auth config."""
raw = load_json(AUTH_CONFIG_PATH)
return raw.get("webauthn", {})
def get_rp_id() -> str:
"""Return the Relying Party ID from config."""
return _get_webauthn_config().get("rp_id", "localhost")
def get_rp_name() -> str:
"""Return the Relying Party name from config."""
return _get_webauthn_config().get("rp_name", "Vacuum Wall")
def get_origin() -> str:
"""Return the WebAuthn origin from config."""
return _get_webauthn_config().get("origin", "http://localhost")
# ---------------------------------------------------------------------------
# Registration
# ---------------------------------------------------------------------------
def create_registration_options(username: str) -> dict[str, Any]:
"""Create WebAuthn registration options for a new credential.
Returns a dict serializable to JSON, matching the format expected by
``navigator.credentials.create()``.
"""
# Load existing credential IDs to exclude
db = get_db()
rows = db.query(Q_SELECT_WEBAUTHN_USER, (username,))
exclude_credentials = [
PublicKeyCredentialDescriptor(id=b64u_decode(row["credential_id"]))
for row in rows
]
# Pad username to >= 8 bytes (required for user_id)
user_id = username.encode("utf-8")
if len(user_id) < 8:
user_id = user_id + b"\x00" * (8 - len(user_id))
options = generate_registration_options(
rp_id=get_rp_id(),
rp_name=get_rp_name(),
user_name=username,
user_display_name=username,
user_id=user_id,
attestation=AttestationConveyancePreference.NONE,
authenticator_selection=AuthenticatorSelectionCriteria(
resident_key=ResidentKeyRequirement.PREFERRED,
user_verification=UserVerificationRequirement.PREFERRED,
),
supported_pub_key_algs=_SUPPORTED_ALGS,
exclude_credentials=exclude_credentials,
)
# Serialize using the library's built-in function
return json.loads(options_to_json(options))
def verify_registration(
username: str,
credential_response: dict[str, Any],
registration_options: dict[str, Any],
credential_name: str = "",
) -> dict[str, Any]:
"""Verify a registration response and persist the credential.
Args:
username: The user registering the credential.
credential_response: Browser response from ``credentials.create()``.
registration_options: The options dict from ``create_registration_options``.
credential_name: Optional human-readable label.
Returns:
Dict with ``id``, ``name``, ``transports``, ``sign_count``.
"""
expected_origin = get_origin()
expected_rp_id = get_rp_id()
challenge = b64u_decode(registration_options["challenge"])
# The library accepts the credential response as a JSON-serializable dict
# We pass it directly — the library handles the parsing
col = verify_registration_response(
credential=credential_response,
expected_challenge=challenge,
expected_origin=expected_origin,
expected_rp_id=expected_rp_id,
require_user_verification=False,
)
new_cred = col.credential
new_credential_id = b64u_encode(new_cred.id)
new_public_key = b64u_encode(new_cred.public_key)
new_sign_count = new_cred.sign_count
transports = []
if hasattr(new_cred.response, "transports") and new_cred.response.transports:
transports = [str(t) for t in new_cred.response.transports]
if not transports:
transports_raw = credential_response.get("response", {}).get("transports", [])
transports = [
t
for t in transports_raw
if t
in (
"internal",
"hybrid",
"nfc",
"ble",
"usb",
"smart-card",
)
] or ["internal"]
db = get_db()
db.run(
Q_INSERT_WEBAUTHN,
(
username,
new_credential_id,
new_public_key,
new_sign_count,
credential_name,
json.dumps(transports),
),
)
logger.info(
"WebAuthn credential registered for %s: %s",
username,
credential_name or new_credential_id[:16],
)
return {
"id": new_credential_id,
"name": credential_name,
"transports": transports,
"sign_count": new_sign_count,
}
# ---------------------------------------------------------------------------
# Authentication
# ---------------------------------------------------------------------------
def create_authentication_options(username: str) -> dict[str, Any] | None:
"""Create authentication options for a user.
Returns a dict serializable to JSON (for ``navigator.credentials.get()``),
or ``None`` if the user has no registered credentials.
"""
db = get_db()
rows = db.query(Q_SELECT_WEBAUTHN_USER, (username,))
if not rows:
return None
allow_credentials = [
PublicKeyCredentialDescriptor(id=b64u_decode(row["credential_id"]))
for row in rows
]
options = generate_authentication_options(
rp_id=get_rp_id(),
allow_credentials=allow_credentials,
user_verification=UserVerificationRequirement.PREFERRED,
)
return json.loads(options_to_json(options))
def verify_authentication(
username: str,
assertion_response: dict[str, Any],
auth_options: dict[str, Any],
) -> bool:
"""Verify an authentication assertion.
Args:
username: The user authenticating.
assertion_response: Browser response from ``credentials.get()``.
auth_options: The options dict from ``create_authentication_options``.
Returns:
True on successful verification.
Raises:
ValueError: On verification failure.
"""
expected_origin = get_origin()
expected_rp_id = get_rp_id()
challenge = b64u_decode(auth_options["challenge"])
cred_id_str = assertion_response["id"]
# Load credential from DB
db = get_db()
cred_rows = db.query(Q_SELECT_WEBAUTHN_ID, (cred_id_str,))
if not cred_rows:
raise ValueError("Credential not found")
cred_row = cred_rows[0]
public_key = b64u_decode(cred_row["public_key"])
old_sign_count = cred_row["sign_count"]
col = verify_authentication_response(
credential=assertion_response,
expected_challenge=challenge,
expected_origin=expected_origin,
expected_rp_id=expected_rp_id,
credential_public_key=public_key,
credential_current_sign_count=old_sign_count,
require_user_verification=False,
)
new_sign_count = col.credential_sign_count
if new_sign_count > old_sign_count:
db.run(
Q_UPDATE_WEBAUTHN_SIGN_COUNT,
(new_sign_count, cred_id_str),
)
logger.info("WebAuthn assertion verified for %s", username)
return True
# ---------------------------------------------------------------------------
# Credential management
# ---------------------------------------------------------------------------
def list_credentials(username: str) -> list[dict[str, Any]]:
"""List all registered credentials for a user."""
db = get_db()
rows = db.query(Q_SELECT_WEBAUTHN_USER, (username,))
result = []
for row in rows:
transports_str = row.get("transports", "[]")
try:
transports = json.loads(transports_str)
except (json.JSONDecodeError, TypeError):
transports = []
result.append(
{
"id": row["credential_id"],
"name": row.get("name") or "",
"transports": transports,
"sign_count": row.get("sign_count", 0),
}
)
return result
def remove_credential(username: str, credential_id: str) -> bool:
"""Remove a credential.
Raises:
ValueError: If credential not found or doesn't belong to user.
"""
db = get_db()
rows = db.query(Q_SELECT_WEBAUTHN_ID, (credential_id,))
if not rows:
raise ValueError(f"Credential {credential_id!r} not found")
if rows[0]["username"] != username:
raise ValueError("Credential does not belong to this user")
db.run(Q_DELETE_WEBAUTHN, (credential_id,))
logger.info("WebAuthn credential removed: %s, %s", username, credential_id)
return True
def get_all_credential_counts() -> dict[str, int]:
"""Return credential counts for all users.
Returns:
Dict mapping usernames to credential counts.
"""
db = get_db()
rows = db.query(Q_SELECT_WEBAUTHN_COUNTS, ())
return {row["username"]: row["cred_count"] for row in rows}