Files
VoiceStudio/tests/test_auth_principal.py
19ae20111a fix(security): replace persistent admin keys with scoped sessions (#1528)
* fix(security): replace persistent admin keys with sessions

Exchange the remote administrator key once for bounded, revocable credentials. Canonicalize backend principals, enforce cookie CSRF and exact origins, and use path-bound one-use WebSocket tickets.

Migrate the bundled UI away from durable master-key storage and credential-bearing URLs. Add unit, integration, static-hygiene, and production-browser regressions plus synchronized operator documentation.

* docs: link session hardening to PR 1528

* fix(security): key session indexes with process pepper

Use HMAC-SHA-256 instead of an unkeyed digest for in-memory session and WebSocket-ticket indexes. This preserves constant-size lookup identifiers, makes copied records unusable without the process pepper, and resolves CodeQL's weak sensitive-data hash finding.

* fix(auth): align empty bearer migration precedence

Centralize the Authorization-channel presence decision with canonical principal parsing. Bearer followed only by spaces now remains an empty channel during legacy cookie migration, while unsupported or invalid explicit credentials stay authoritative and fail closed.

* fix(security): harden admin session review boundaries

* fix(security): derive key generations with HKDF

* fix(auth): anchor the admin-session store so module reloads cannot fork it

test_master_exchange_does_not_bypass_pin_on_normal_routes failed in full-suite
runs: test_mcp_bindings' client fixture purges the services.* tree from
sys.modules and reloads main, so api.routers.auth re-imported a fresh
services.admin_sessions (new AdminSessionStore) while core.auth kept its
import-time reference to the old one — the exchange issued the cookie into
one store and the middleware resolved it against another, turning the
expected "PIN required" into "API key required".

Root cause is the class of bug, not the one test: a process-global auth
store defined as a bare module-level singleton forks under importlib.reload
or purge-and-reimport. Fix at the source: admin_session_store now resolves
through a synthetic sys.modules anchor (_omnivoice_admin_session_store_anchor)
that reloads never re-execute and package-prefix purges never match, so every
copy of the module shares the one per-process store. No consumer or behavior
changes.

Regression test reproduces both fork vectors (in-place reload and
sys.modules purge + fresh import) and asserts previously issued sessions
still resolve and the store identity is preserved; it fails before this fix
and passes after.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(auth): honor X-Forwarded-Proto for CSRF origin and Secure cookies behind TLS proxies

Behind Tailscale Serve (docs/remote-gpu.md) or any TLS-terminating proxy,
the browser talks https while the backend hop stays http, so exact-origin
CSRF compared an https Origin against an http expectation and rejected
every legitimate request, and the session cookie shipped without Secure.
uvicorn's ProxyHeadersMiddleware only rewrites the scope for loopback
peers, which misses Docker and any non-loopback proxy topology.

New core.csrf.effective_scheme derives the client-facing scheme: resolved
scope first (uvicorn's trusted-proxy rewrite wins), then an upgrade-only
read of X-Forwarded-Proto's first value — https/wss promotes http to
https, everything else is ignored, and a genuine TLS hop can never be
downgraded. Used by both the destination-origin comparison and
auth._secure_cookie so the WS-ticket/logout CSRF paths and the cookie
Secure flag agree. Spoofing gains nothing: the host:port half of the
origin tuple is untouched, browsers cannot attach the header cross-site
without a preflight this API never grants, and forging it on plain http
only adds Secure (the browser then drops the cookie — self-harm only).

Regression tests: proxied https origin accepted (origin check, Secure
flag, logout), comma-separated chains, scope-fallback path, spoofed
header still rejects cross-origin, cannot downgrade real https, junk
values ignored.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(auth): consume the stored admin key only after a successful exchange

A remote-backend user upgrading with their backend unreachable lost the
only stored copy of OMNIVOICE_API_KEY: every migration path deleted the
durable ov_api_key BEFORE the session exchange settled, stranding them
until they recovered the key from the server box. Close the whole class:

- client.ts bootstrap: read the legacy key, exchange first, and remove
  the durable copy only after the exchange succeeds; on failure the key
  stays so the next launch retries the migration (auth gate still rises).
- authSession.ts exchangeApiKey: move removeLegacyMaster from before the
  fetch to the cookie/bearer success paths — the key never coexists with
  a live session, but a rejected or hung exchange no longer consumes it.
- remoteBackendProbe.ts configuredRemoteBackend: stop wiping the key on
  every app mount.
- RemoteBackendPanel: a connection test or an aborted save no longer
  wipes the pending key; only disabling the remote backend discards it.
- prefKeys.js: ov_api_key moves from PREF_KEYS to PRESERVED_KEYS —
  factory reset preserves the pending connection credential exactly like
  ov_backend_url; the successful migration is what deletes it.

Tighten the credential-hygiene static guard to match: it accepted
sessionStorage.setItem('ov_api_key', …) — the exact class it exists to
close. The guard now flags .setItem(<master key>) on any storage
receiver, quote style, or injected-store alias, with a self-test pinning
what it catches and what stays legal.

Fail-before/pass-after regression tests: backend unreachable retains the
key and the next bootstrap retries it; a successful exchange removes it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* perf(auth): make session validation occupancy-independent

* test(auth): catch optional master-key storage calls

* feat(docs): add PR control document for bultodepapas in VoiceStudio

* docs: keep the PR tracking board in the fork; credit the changelog line

The pr-control document is excellent process discipline, but it is the
contributor's own operational board (their inventory, their update
commands) — it lives naturally in their fork, and docs/agents/ here is
context every repo agent loads. Removed with appreciation; the changelog
line gains its contributor credit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: debpalash <4178343+debpalash@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 20:46:24 +00:00

462 lines
15 KiB
Python

"""Canonical authentication decision shared by HTTP, WS, and dependencies."""
from __future__ import annotations
import importlib
from types import SimpleNamespace
from typing import TYPE_CHECKING
import pytest
if TYPE_CHECKING:
from core.auth import (
ADMIN_CAPABILITIES,
LOOPBACK_CAPABILITIES,
AuthPrincipal,
CredentialTransport,
PrincipalKind,
credential_matches,
is_local_host,
principal_for,
resolve_principal,
)
from services.admin_sessions import AdminSessionStore
MASTER = "MASTER_DO_NOT_LEAK_7d29"
_AUTH_SYMBOLS = (
"ADMIN_CAPABILITIES",
"LOOPBACK_CAPABILITIES",
"AuthPrincipal",
"CredentialTransport",
"PrincipalKind",
"credential_matches",
"is_local_host",
"principal_for",
"resolve_principal",
)
@pytest.fixture(autouse=True)
def _resolve_active_auth_modules():
"""Bind active modules after tests that isolate or replace sys.modules."""
auth = importlib.import_module("core.auth")
sessions = importlib.import_module("services.admin_sessions")
globals().update({name: getattr(auth, name) for name in _AUTH_SYMBOLS})
globals()["AdminSessionStore"] = sessions.AdminSessionStore
class Connection:
def __init__(
self,
*,
host: str = "10.0.0.5",
headers: dict[str, str] | None = None,
query: dict[str, str] | None = None,
cookies: dict[str, str] | None = None,
scope_type: str = "http",
path: str = "/v1/audio/voices",
root_path: str = "",
pin: str | None = None,
) -> None:
network_share = SimpleNamespace(pin=pin) if pin is not None else None
self.app = SimpleNamespace(state=SimpleNamespace(network_share=network_share))
self.client = SimpleNamespace(host=host) if host else None
self.headers = headers or {}
self.query_params = query or {}
self.cookies = cookies or {}
self.scope = {
"type": scope_type,
"path": path,
"root_path": root_path,
"state": {},
"client": (host, 1),
}
@pytest.fixture
def store(_resolve_active_auth_modules) -> AdminSessionStore:
return AdminSessionStore(pepper=b"x" * 32)
def test_loopback_principal_has_native_capability(monkeypatch, store):
monkeypatch.setenv("OMNIVOICE_API_KEY", MASTER)
principal = resolve_principal(Connection(host="127.0.0.1"), store=store)
assert principal.kind is PrincipalKind.LOOPBACK
assert principal.capabilities == LOOPBACK_CAPABILITIES
assert principal.transport is CredentialTransport.NONE
def test_principal_capability_helper_is_explicit():
principal = AuthPrincipal(PrincipalKind.API_KEY, ADMIN_CAPABILITIES)
assert principal.allows("consume") is True
assert principal.allows("admin") is True
assert principal.allows("native") is False
def test_trusted_network_parser_ignores_invalid_entries_and_maps_ipv4(monkeypatch):
monkeypatch.setenv(
"OMNIVOICE_TRUSTED_NETWORKS",
"not-a-network, 10.0.0.0/8",
)
assert is_local_host("10.2.3.4") is True
assert is_local_host("::ffff:10.2.3.4") is True
assert is_local_host("192.168.1.1") is False
assert is_local_host("not-an-address") is False
assert is_local_host(None) is False
def test_valid_master_header_produces_admin_principal(monkeypatch, store):
monkeypatch.setenv("OMNIVOICE_API_KEY", f" {MASTER} ")
principal = resolve_principal(
Connection(headers={"authorization": f"Bearer {MASTER}"}), store=store
)
assert principal.kind is PrincipalKind.API_KEY
assert principal.capabilities == ADMIN_CAPABILITIES
assert principal.transport is CredentialTransport.HEADER
assert MASTER not in repr(principal)
def test_unicode_master_is_compared_as_utf8_bytes(monkeypatch, store):
master = "clé-administrateur-sécurité"
monkeypatch.setenv("OMNIVOICE_API_KEY", master)
principal = resolve_principal(
Connection(headers={"authorization": f"Bearer {master}"}),
store=store,
)
assert credential_matches(master, master) is True
assert principal.kind is PrincipalKind.API_KEY
def test_credential_comparison_handles_unpaired_surrogates_without_raising():
assert credential_matches("key-\ud800", "key-\ud800") is True
assert credential_matches("key-\ud800", "key-\ud801") is False
@pytest.mark.parametrize(
"connection",
[
Connection(headers={"authorization": "Bearer clé-incorrecte"}),
Connection(query={"api_key": "sécurité-incorrecte"}),
Connection(cookies={"ov_key": "contraseña-incorrecta"}),
Connection(headers={"x-omnivoice-pin": "clé"}, pin="123456"),
],
)
def test_non_ascii_invalid_credentials_fail_closed_without_type_error(
monkeypatch,
store,
connection,
):
monkeypatch.setenv("OMNIVOICE_API_KEY", MASTER)
principal = resolve_principal(connection, store=store)
assert principal.kind is PrincipalKind.ANONYMOUS
def test_valid_session_header_produces_admin_principal(monkeypatch, store):
monkeypatch.setenv("OMNIVOICE_API_KEY", MASTER)
session = store.issue(MASTER)
principal = resolve_principal(
Connection(headers={"authorization": f"Bearer {session.token}"}), store=store
)
assert principal.kind is PrincipalKind.ADMIN_SESSION
assert principal.capabilities == ADMIN_CAPABILITIES
assert principal.transport is CredentialTransport.HEADER
assert principal.credential_id
assert session.token not in repr(principal)
def test_valid_session_cookie_produces_admin_principal(monkeypatch, store):
monkeypatch.setenv("OMNIVOICE_API_KEY", MASTER)
session = store.issue(MASTER)
principal = resolve_principal(
Connection(cookies={"ov_session": session.token}), store=store
)
assert principal.kind is PrincipalKind.ADMIN_SESSION
assert principal.transport is CredentialTransport.COOKIE
def test_default_store_resolves_current_process_singleton_after_module_rebind(monkeypatch):
monkeypatch.setenv("OMNIVOICE_API_KEY", MASTER)
sessions = importlib.import_module("services.admin_sessions")
active_store = sessions.AdminSessionStore(pepper=b"a" * 32)
stale_store = sessions.AdminSessionStore(pepper=b"s" * 32)
issued = active_store.issue(MASTER)
monkeypatch.setattr(sessions, "admin_session_store", active_store)
auth = importlib.import_module("core.auth")
monkeypatch.setattr(auth, "admin_session_store", stale_store, raising=False)
principal = resolve_principal(
Connection(headers={"authorization": f"Bearer {issued.token}"}),
)
assert principal.kind is PrincipalKind.ADMIN_SESSION
assert active_store.active_session_count == 1
assert stale_store.active_session_count == 0
def test_query_and_legacy_cookie_remain_master_compatibility_channels(monkeypatch, store):
monkeypatch.setenv("OMNIVOICE_API_KEY", MASTER)
query = resolve_principal(Connection(query={"api_key": MASTER}), store=store)
cookie = resolve_principal(Connection(cookies={"ov_key": MASTER}), store=store)
assert query.kind is PrincipalKind.API_KEY
assert query.transport is CredentialTransport.QUERY
assert cookie.kind is PrincipalKind.API_KEY
assert cookie.transport is CredentialTransport.LEGACY_COOKIE
def test_invalid_nonempty_explicit_header_is_authoritative(monkeypatch, store):
monkeypatch.setenv("OMNIVOICE_API_KEY", MASTER)
session = store.issue(MASTER)
principal = resolve_principal(
Connection(
headers={"authorization": "Bearer wrong"},
query={"api_key": MASTER},
cookies={"ov_session": session.token, "ov_key": MASTER},
),
store=store,
)
assert principal.kind is PrincipalKind.ANONYMOUS
assert principal.transport is CredentialTransport.HEADER
@pytest.mark.parametrize("authorization", ["Basic Zm9vOmJhcg==", "Bearer"])
def test_unsupported_or_malformed_authorization_cannot_fall_back_to_cookie(
monkeypatch,
store,
authorization,
):
monkeypatch.setenv("OMNIVOICE_API_KEY", MASTER)
session = store.issue(MASTER)
principal = resolve_principal(
Connection(
headers={"authorization": authorization},
cookies={"ov_session": session.token, "ov_key": MASTER},
),
store=store,
)
assert principal.kind is PrincipalKind.ANONYMOUS
assert principal.transport is CredentialTransport.HEADER
def test_whitespace_header_and_query_fall_back_to_session_cookie(monkeypatch, store):
monkeypatch.setenv("OMNIVOICE_API_KEY", MASTER)
session = store.issue(MASTER)
principal = resolve_principal(
Connection(
headers={"authorization": "Bearer "},
query={"api_key": " "},
cookies={"ov_session": session.token},
),
store=store,
)
assert principal.kind is PrincipalKind.ADMIN_SESSION
assert principal.transport is CredentialTransport.COOKIE
def test_invalid_session_cookie_is_authoritative_over_legacy_master_cookie(monkeypatch, store):
monkeypatch.setenv("OMNIVOICE_API_KEY", MASTER)
principal = resolve_principal(
Connection(
cookies={
"ov_session": "ovs_admin_session_" + "a" * 43,
"ov_key": MASTER,
}
),
store=store,
)
assert principal.kind is PrincipalKind.ANONYMOUS
assert principal.transport is CredentialTransport.COOKIE
def test_trusted_network_is_consumption_only(monkeypatch, store):
monkeypatch.delenv("OMNIVOICE_API_KEY", raising=False)
monkeypatch.setenv("OMNIVOICE_TRUSTED_NETWORKS", "10.0.0.0/24")
principal = resolve_principal(Connection(host="10.0.0.5"), store=store)
assert principal.kind is PrincipalKind.TRUSTED_NETWORK
assert principal.capabilities == frozenset({"consume"})
def test_valid_api_key_beats_trusted_network_identity(monkeypatch, store):
monkeypatch.setenv("OMNIVOICE_API_KEY", MASTER)
monkeypatch.setenv("OMNIVOICE_TRUSTED_NETWORKS", "10.0.0.0/24")
principal = resolve_principal(
Connection(host="10.0.0.5", headers={"authorization": f"Bearer {MASTER}"}),
store=store,
)
assert principal.kind is PrincipalKind.API_KEY
def test_pin_is_consumption_only(monkeypatch, store):
monkeypatch.delenv("OMNIVOICE_API_KEY", raising=False)
principal = resolve_principal(
Connection(headers={"x-omnivoice-pin": "123456"}, pin="123456"),
store=store,
)
assert principal.kind is PrincipalKind.PIN
assert principal.capabilities == frozenset({"consume"})
def test_wrong_pin_is_anonymous(monkeypatch, store):
monkeypatch.delenv("OMNIVOICE_API_KEY", raising=False)
principal = resolve_principal(
Connection(headers={"x-omnivoice-pin": "654321"}, pin="123456"),
store=store,
)
assert principal.kind is PrincipalKind.ANONYMOUS
def test_ws_ticket_is_consumed_and_attached_once(monkeypatch, store):
monkeypatch.setenv("OMNIVOICE_API_KEY", MASTER)
session = store.issue(MASTER)
ticket = store.issue_ws_ticket(session.token, "/ws/events", MASTER)
connection = Connection(
scope_type="websocket",
path="/ws/events",
query={"ws_ticket": ticket.token},
)
first = resolve_principal(connection, store=store)
second = principal_for(connection, store=store)
assert first.kind is PrincipalKind.ADMIN_SESSION
assert first.transport is CredentialTransport.WS_TICKET
assert second is first
assert store.consume_ws_ticket(ticket.token, "/ws/events", MASTER) is None
def test_ws_ticket_accepts_only_its_asgi_configured_root_path_prefix(monkeypatch, store):
monkeypatch.setenv("OMNIVOICE_API_KEY", MASTER)
session = store.issue(MASTER)
ticket = store.issue_ws_ticket(session.token, "/ws/events", MASTER)
principal = resolve_principal(
Connection(
scope_type="websocket",
path="/studio/ws/events",
root_path="/studio",
query={"ws_ticket": ticket.token},
),
store=store,
)
assert principal.kind is PrincipalKind.ADMIN_SESSION
assert principal.transport is CredentialTransport.WS_TICKET
def test_ws_ticket_rejects_unconfigured_lookalike_prefix(monkeypatch, store):
monkeypatch.setenv("OMNIVOICE_API_KEY", MASTER)
session = store.issue(MASTER)
ticket = store.issue_ws_ticket(session.token, "/ws/events", MASTER)
principal = resolve_principal(
Connection(
scope_type="websocket",
path="/untrusted/ws/events",
query={"ws_ticket": ticket.token},
),
store=store,
)
assert principal.kind is PrincipalKind.ANONYMOUS
assert principal.transport is CredentialTransport.WS_TICKET
def test_invalid_ws_ticket_is_authoritative_over_legacy_query_key(monkeypatch, store):
monkeypatch.setenv("OMNIVOICE_API_KEY", MASTER)
connection = Connection(
scope_type="websocket",
path="/ws/events",
query={
"ws_ticket": "ovs_ws_ticket_" + "a" * 43,
"api_key": MASTER,
},
)
principal = resolve_principal(connection, store=store)
assert principal.kind is PrincipalKind.ANONYMOUS
assert principal.transport is CredentialTransport.WS_TICKET
def test_key_rotation_invalidates_attached_session_only_on_new_scope(monkeypatch, store):
monkeypatch.setenv("OMNIVOICE_API_KEY", MASTER)
session = store.issue(MASTER)
first_connection = Connection(headers={"authorization": f"Bearer {session.token}"})
assert resolve_principal(first_connection, store=store).kind is PrincipalKind.ADMIN_SESSION
monkeypatch.setenv("OMNIVOICE_API_KEY", "rotated")
next_request = resolve_principal(
Connection(headers={"authorization": f"Bearer {session.token}"}), store=store
)
assert next_request.kind is PrincipalKind.ANONYMOUS
def test_key_removal_cannot_revive_session_when_same_key_returns(monkeypatch, store):
monkeypatch.setenv("OMNIVOICE_API_KEY", MASTER)
session = store.issue(MASTER)
monkeypatch.delenv("OMNIVOICE_API_KEY")
while_removed = resolve_principal(
Connection(headers={"authorization": f"Bearer {session.token}"}),
store=store,
)
monkeypatch.setenv("OMNIVOICE_API_KEY", MASTER)
after_restore = resolve_principal(
Connection(headers={"authorization": f"Bearer {session.token}"}),
store=store,
)
assert while_removed.kind is PrincipalKind.ANONYMOUS
assert after_restore.kind is PrincipalKind.ANONYMOUS
def test_principal_for_reuses_scope_decision_without_reparsing(monkeypatch, store):
monkeypatch.setenv("OMNIVOICE_API_KEY", MASTER)
connection = Connection(headers={"authorization": f"Bearer {MASTER}"})
first = principal_for(connection, store=store)
connection.headers = {"authorization": "Bearer wrong"}
second = principal_for(connection, store=store)
assert second is first
assert second.kind is PrincipalKind.API_KEY
def test_principal_dataclass_cannot_carry_a_raw_secret():
assert "credential" not in AuthPrincipal.__dataclass_fields__
assert "token" not in AuthPrincipal.__dataclass_fields__
assert "secret" not in AuthPrincipal.__dataclass_fields__