diff --git a/SCOPE.md b/SCOPE.md index 161bd19..709f4df 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -4,12 +4,13 @@ > direction belongs in `INTENT.md`; the current stage belongs in `GOAL.md`; > current work and gates belong in `workplans/`. -## Status — 2026-09-09 +## Status — 2026-09-10 -**Specification, declaration and the domain core are complete. No service is -deployed.** +**The domain core, browser sign-in shell and Approval Engine HTTP adapter +are implemented. The approval surface is not deployed.** -What exists and is tested (87 tests): +What exists and is tested (206 tests, including the explicit real-engine +component suite; 100 were present before browser integration): - layer and stance declarations — `layer.yaml`, `pep-stance.yaml`, `informed_decision/stance.py`, with published-equals-shipped asserted; @@ -20,11 +21,20 @@ What exists and is tested (87 tests): `disposition.py` (the verb vocabulary and guards `G_NOAGENT`, `G_STEP`, `G_PRES`, `G_ACK`, `G_REASONS`, `G_SEALED`), `provenance.py` (claim routes, A-16), `evidence.py` (the local outbox and commitment records); -- `approval_client.py` — the seam to `approval-engine` plus a fake carrying its - actual refusal semantics. +- `approval_client.py` — the seam to `approval-engine` plus a fake; +- `oidc.py` and `web.py` — public-client PKCE sign-in, verified human/MFA + profile, bounded server-side sessions, protected cookies and CSRF sign-out; +- `approval_http.py` and `http_transport.py` — get-by-id and human-entry + transport, declared-control checks, real entry correlation, no consume route + or automatic mutation retry. This adapter has no public browser mutation route. -What does not exist: any HTTP surface, any persistence, any UI, any deployment. -The origin `decisions.coulomb.social` is live but serves an nginx placeholder. +Remaining: durable memo/presentation/disposition storage and transactional +evidence outbox, entitlement-before-render integration, L3 review/acknowledgment/ +binding UI, independent audit delivery and native deployment proof. The existing +`Outbox` is in memory. Browser sessions are ephemeral, with no approval state. +`/readyz` returns 503 until the protected approval path is connected. +The origin `decisions.coulomb.social` still serves an nginx placeholder. +See [browser-authentication.md](docs/browser-authentication.md). `INFD-WP-0001-T08` remains open for the live end-to-end proof, which is gated on `APPROVAL-WP-0002-T01` and a deployed `approval-engine`. @@ -72,7 +82,7 @@ trail. - The unreachable-engine stance map, built to v0.8 obligation 3, with published-equals-shipped asserted by test (`tests/test_layer_conformance.py`). -**Specified, not built:** +**Built as domain operations; durable HTTP integration remains:** - The presentation record: what was rendered, to whom, when, in which locale and UI release. @@ -82,6 +92,9 @@ trail. memo-level. - The browser-facing OIDC client: authorization-code + S256 PKCE against `key-cape`, scopes `[openid, approval:read, approval:approve]`. + +**Specified, not built:** + - An L3 approver surface calling `approval-engine`'s approval-entry mutation. - The evidence bundle as an offline-verifiable export. @@ -130,12 +143,13 @@ Stated here because a scope file that only lists capabilities overstates them. ## Open -- **The deployed origin** — the one remaining input to `T07`. `client_id` and - the callback URI must name a real origin, since redirects match exactly. -- **`audit-core` registration and cadence** — the payload is ruled - (commitment-only, with the existence assertion); the sender registration and - whether reconciliation-plus-heartbeat suits a mixed-volume source are still - `audit-core`'s to answer. Required before `T08` ships. +- **Native browser registration and human proof** — T07 supplied the real + origin and submitted the contract. T08 retains registration rollout and a real + human login accepted by the deployed Approval Engine. +- **`audit-core` custody and live delivery** — source registration and cadence + have owner returns (`AUDIT-IN-0003`, `AUDIT-WP-0009` T04/T06/T07); native + credentials, independent receipt and reconciliation still require proof before + T08 ships. *Closed 2026-09-10:* the human token tenant (`GH-DEC-2026-013`, `key-cape` `329e48f`) and the evidence payload question (`GH-DEC-2026-014`). diff --git a/docs/browser-authentication.md b/docs/browser-authentication.md new file mode 100644 index 0000000..8ec0976 --- /dev/null +++ b/docs/browser-authentication.md @@ -0,0 +1,107 @@ +# Browser authentication and Approval Engine transport + +Implemented under `INFD-WP-0001-T08`. The service currently supplies sign-in and +sign-out. It does not yet render memos or expose an approval-entry route. + +Install and exercise from the repository: + +```sh +uv venv +uv pip install -e '.[dev]' +uv run python -m pytest -q +INFD_APPROVAL_ENGINE_SOURCE=/home/worsch/approval-engine uv run python -m pytest -q +uv run python tools/smoke_browser_shell.py +INFD_KEYCAPE_ISSUER=https://kc.coulomb.social uv run informed-decision-web +``` + +The component checks require the explicit Approval Engine source path. They use +its actual JWT verifier and API with locally signed synthetic identity fixtures. +They are not native human authentication or custody proof. The smoke script +needs free loopback port 8080, checks the installed entrypoint and stops it. It +never follows the authorization redirect or contacts KeyCape. `--receipt PATH` +writes metadata-only results. + +The entrypoint listens on `127.0.0.1:8080` for a local reverse proxy. The public +origin/callback is fixed at `https://decisions.coulomb.social/auth/callback`; +Host, forwarded headers and return URLs cannot replace it. A development HTTP +listener does not replace this HTTPS callback registration. Deployment and +registration remain pending. `/healthz` checks this process; `/readyz` returns +503 because the protected approval path is incomplete. + +## Identity boundary + +`KeyCapeLogin` uses a configured HTTPS issuer with its `/authorize`, `/token` +and `/jwks` endpoints. No discovery or JWT header can substitute an endpoint. +It requests only `openid approval:read approval:approve`, with public-client +S256 PKCE. State, a separate browser cookie and nonce are random; the callback +consumes its five-minute pending state before token exchange. The access token +never enters a browser cookie, HTML, URL or error message. + +The verifier accepts RS256 only and checks both tokens against the issuer JWKS: +ID-token audience is the client, access-token audience is `approval-engine`. +Subject, tenant, provenance and assurance must agree. Nonce, time bounds, exact +scopes, human principal, platform tenant and the published KeyCape MFA facts are +required. JWKS refresh failure refuses login; expired cached keys are not a +fallback. Login does not decide entitlement to view or approve anything. + +KeyCape source `f9812ab3b2bfe8f0817185f44071e612264ec3ee`, specifically +`src/internal/server/oidc/token.go`, is the provenance contract reviewed here: +the code handler looks up the authenticated user and emits `human`; client +credentials emit `service`. `tenant_source=directory` and `registration` stay +distinct. Unknown/default provenance is refused in this flow. A verified +registration-supplied tenant remains the bounded GH-DEC-2026-013 gap. + +MFA `at` is preserved as authentication time. A successful login does not assert +that MFA meets an action-specific freshness policy; the policy integration must +use these facts for the action. No local approval verdict is inferred. + +Sessions last at most fifteen minutes and never outlive either token. They are +bounded to 1,024 entries, protected by a process lock, rotated on sign-in and +deleted on sign-out/expiry. Restart requires reauthentication. This is a +single-process design; replicas need a separate shared-session design. These +sessions contain no approval current state or evidence. Cookies are Secure, +HttpOnly, SameSite=Lax with the `__Host-` prefix. Sign-out requires POST, the +exact origin and a session CSRF token. Responses prohibit caching and framing. +The proxy must omit callback query strings, bearer headers and cookies from +access logs. Waitress has no request-target access log enabled by this entrypoint. + +## Approval transport boundary + +`ApprovalHTTPClient` implements get-by-id and add-entry using the session's +access token. Each read reaches the engine. It requires exact +`binding.human_control=true` for this first factory integration and preserves +the native `sha256:<64 hex>` act digest. A pre-entry read rejects an undeclared +object before mutation. Identifiers cannot inject extra paths or query strings. + +The default origin is HTTPS. The current in-cluster service can be wired with +explicit `allow_internal_http=True` and a fixed `.svc`/`.svc.cluster.local` +origin; that option also permits numeric loopback for component tests. Public +HTTP origins are refused. Redirects are never followed, response bodies are +limited to 256 KiB, and socket operations use a five-second timeout. No ambient +proxy credentials are inherited. + +POST bodies are empty objects: Approval Engine derives identity and evidence +from its verified token and discards caller content. The adapter extracts +`(approval_id, subject_id, approved_at)` from the matching actual human entry, +never `updated_at` or local time. A `409 duplicate_approver` triggers a GET of +that original entry and returns `duplicate=True`. The eventual UI must preserve +the original presentation correlation; it cannot attach a new presentation to +an old entry just because a duplicate exists. Other conflicts remain refusals. + +A transport failure after POST is ambiguous: the engine may have committed. +There is no automatic POST retry. Durable disposition processing must reconcile +the current engine entry and its correlation before reporting success or +offering a retry. A failed dependency must never be recorded as a human decline. + +The client is an internal seam, not a sufficient binding flow. Before a browser +route can call it, T08 must connect: + +1. The access-engine entitlement decision before rendering a named memo. +2. A durable presentation/version, actor match and required acknowledgments. +3. Dispositions and a transactional evidence outbox, including return/discuss. +4. Independent audit custody/delivery and entry-correlation reconciliation. +5. Native registered KeyCape login and the deployed Approval Engine proof. + +The browser currently exposes no memo or entry route and no consume capability. +The current `evidence.Outbox` is in memory and cannot satisfy durable evidence +requirements. These remaining items stay in the active T08 record. diff --git a/docs/evidence/2026-09-10-browser-authentication.json b/docs/evidence/2026-09-10-browser-authentication.json new file mode 100644 index 0000000..014a5f2 --- /dev/null +++ b/docs/evidence/2026-09-10-browser-authentication.json @@ -0,0 +1,58 @@ +{ + "schema": "helixforge.browser-authentication-source.v1", + "observed_at": "2026-09-10T19:20:43.362423+00:00", + "status": "passed", + "implementation_base": "4e103f62a0e68d9a273c0abec76ee6ab35db2531", + "contracts": { + "key-cape": "f9812ab3b2bfe8f0817185f44071e612264ec3ee", + "approval-engine": "2fdb01d42aa721f3b44f46abe018e53a7813e916" + }, + "validation": { + "tests_passed": 206, + "tests_failed": 0, + "tests_skipped": 0, + "existing_tests": 100, + "real_engine_component_tests": 3, + "command": "INFD_APPROVAL_ENGINE_SOURCE=/home/worsch/approval-engine /tmp/hfact-browser-20260910/.venv/bin/python -m pytest -q", + "identity_evidence": "locally signed synthetic fixture, validated by login and actual Approval Engine JWT verifier; not a native KeyCape human login", + "local_http_smoke": { + "status": "passed", + "checks": { + "installed_entrypoint_health": true, + "fixed_issuer_authorization_redirect": true, + "exact_registered_callback_and_scopes": true, + "pkce_s256_and_browser_cookie": true, + "incomplete_approval_path_not_ready": true, + "binding_route_unavailable": true + }, + "issuer_contacted": false, + "native_human_login": false, + "model_calls": 0 + } + }, + "source_sha256": { + "informed_decision/approval_http.py": "14187ab81f0ce2d9d61ec548ff4da8563d7d1d6084f3d91dddfba9cc90d1864d", + "informed_decision/http_transport.py": "d861f9cea798af7afeffb78b93b353ade0037b4536cf44b6e71ef7d6dc794940", + "informed_decision/oidc.py": "f68d0145e0eb08e46fbbaeaf21b34bfaf51b4fdddc247e46d57c20a19f692134", + "informed_decision/web.py": "c4eb8f828e38437aec305796adf30b947207a2d34b2e5fc9c63ed9d5eff99729", + "informed_decision/provenance.py": "de02c6bf162865ba9619d74cfd68dd0a5d7f81e6c9e427f67f848cb2c2214f83", + "pyproject.toml": "8e8e008035077e409c883fb1198387b24c4c63b37a3d695c8740a237cd3258d9", + "tests/test_approval_component.py": "eea6cd523b81fb99478b72666a50ea46d5c031b7459241e0522a2606534ecbbc", + "tests/test_approval_http.py": "81bdd8aaa0c039d6e9c37ba030a87956adf132fa2391882f18120f8ec578b4fd", + "tests/test_browser_auth.py": "01f4088968eea87c58ac0b30806111f1367349e6eaf55f1d3c59499e6c68b590", + "tests/test_http_transport.py": "314f7210748a548adebedb7c63cbc9b2b5790db00c082ef1fff2664e871e350e", + "tools/smoke_browser_shell.py": "373524286e0bd475b6c6afa974f1836f0f12a39b041e8b4682c42bdd91ebc56d" + }, + "native_human_login_proven": false, + "browser_approval_route_available": false, + "deployed": false, + "factory_attempts": 0, + "paid_model_calls": 0, + "remaining_owner_record": "INFD-WP-0001-T08", + "remaining": [ + "durable presentation/disposition plus transactional evidence outbox", + "entitlement before render and actor/version/ack guards in browser path", + "independent audit custody/delivery and duplicate/ambiguous entry reconciliation", + "native client registration/login and deployed Approval Engine proof" + ] +} diff --git a/docs/keycape-client-registration.md b/docs/keycape-client-registration.md index 304059b..fe84a8c 100644 --- a/docs/keycape-client-registration.md +++ b/docs/keycape-client-registration.md @@ -2,8 +2,10 @@ **Workplan task:** `INFD-WP-0001-T07` **For:** `key-cape` (`KEY-WP-0013-T02`, `KEY-WP-0013-T05`) -**Status:** **draft — not yet submitted.** One input outstanding: the deployed -origin (§2). Everything else is fixed and stable. +**Status:** **submitted 2026-09-10** (`INFD-WP-0001-T07` done). The +origin is live. Registration rollout and a native human token accepted by +Approval Engine remain T08; the browser login implementation is described in +[browser-authentication.md](browser-authentication.md). **Rulings:** `GH-DEC-2026-012`, `GH-DEC-2026-013` **Contracts cited:** `key-cape/docs/approval-engine-auth-contract.md`, `key-cape/docs/tenant-claim-contract.md`, @@ -47,11 +49,10 @@ than reconciled afterwards. `hub.coulomb.social`, `kc.coulomb.social`), Traefik `ingressClassName`, TLS secret per host. - **DNS resolves** to the cluster address as of 2026-09-10. -- **Still requires, in `railiance-apps` rather than here:** an Ingress manifest - and an issued certificate. Manifest written 2026-09-10 - (`manifests/informed-decision-ingress.yaml`); not yet applied. +- **Delivered by `railiance-apps`:** Ingress and issued certificate, applied + 2026-09-10 (`manifests/informed-decision-ingress.yaml`). -**The host answers. This document is ready to submit.** 2026-09-10 14:32 UTC: +**The host answers. This document has been submitted.** 2026-09-10 14:32 UTC: `railiance-apps` applied the Ingress and cert-manager issued a Let's Encrypt certificate for `decisions.coulomb.social`; `GET https://decisions.coulomb.social/auth/callback` returns `200` over a @@ -146,11 +147,12 @@ Consequences accepted here: 1. **This is transitional.** When the directory carries tenants, the same `key-cape` code stops supplying and starts enforcing agreement, with no second migration. This registration is expected to survive that unchanged. -2. **The claim is stored with its provenance.** `key-cape` emits `tenant` as a - bare string, so a consumer cannot distinguish a directory-asserted tenant from - a registration-supplied one. Until the claim carries its own provenance - (`GH-DEC-2026-013` §5, second field deliberately undesigned), this surface - records which route the value arrived by. See `PR-09`. +2. **The claim is stored with its provenance.** Current `key-cape` emits + `tenant_source`: `directory`, `registration`, or `default`. The browser + adapter preserves the first two as distinct routes and refuses missing, + unknown or default provenance for this platform approver flow. It does not + relabel registration-supplied tenancy as a fact asserted by the directory. + See `PR-09` and `key-cape/src/internal/server/oidc/token.go`. 3. **The tenant claim is never used as the act-scope.** `binding.target` is the act-scope and is committed inside `view_hash`. Two different facts — which scope this act enters, and which tenant this person belongs to — must not @@ -180,5 +182,17 @@ while it stands. | # | Item | Owner | | --- | --- | --- | | 1 | ~~DNS A record + Ingress + TLS for `decisions.coulomb.social`~~ — **done 2026-09-10**, cert issued, host answers 200 | `railiance-apps` / deployment | -| 2 | Submit this document to `key-cape`, closing `KEY-WP-0013-T02` | this repo, once 1 lands | -| 3 | Prove a token issued against the registration is accepted by `approval-engine` | `T08` | +| 2 | ~~Submit the client contract to `key-cape`~~ — **done**, source T07 return | this repo | +| 3 | Deploy/admit the registration and prove a native human token is accepted by `approval-engine` | `T08`, `KEY-WP-0013-T05`, `APPROVAL-WP-0002-T01` | + +## 8. Humanity provenance correction — 2026-09-10 + +The earlier assertion that `principal_type: human` came from registration was +incorrect for the current KeyCape implementation (`f9812ab`). Its code exchange +consumes a user-authenticated PKCE session, looks up that user and emits the +literal `human`; its client-credentials handler emits `service`. +`oidc.py` verifies the fixed issuer, signature, audiences, nonce, paired subject, +exact scopes, tenant provenance and MFA facts before assigning the +`authentication-derived` route. The generic provenance guard continues refusing +registration-only assertions. This is a checked source contract, not a claim +that the current deployed issuer or a native human login has been verified here. diff --git a/informed_decision/approval_http.py b/informed_decision/approval_http.py new file mode 100644 index 0000000..5fa6019 --- /dev/null +++ b/informed_decision/approval_http.py @@ -0,0 +1,82 @@ +"""Authenticated get-by-id and human entry transport; no policy or consume API. + +The caller owes entitlement, durable presentation/disposition and audit custody +before invoking add_entry. This adapter is deliberately not a browser route. +""" + +from __future__ import annotations + +from datetime import datetime +import re +import time + +from .approval_client import ApprovalEngineError, EntryResult, is_success +from .http_transport import JSONTransport, TransportError, fixed_origin +from .oidc import HumanSession +from .provenance import assert_human_control_dischargeable + + +class ApprovalHTTPClient: + def __init__(self, origin: str, session: HumanSession, *, transport=None, clock=time.time, + allow_internal_http=False): + self.origin = fixed_origin(origin, allow_internal_http=allow_internal_http) + assert_human_control_dischargeable(session.principal_type) + if session.tenant.value != "tenant:platform": + raise ValueError("platform approver required") + self.session = session + self.transport = transport or JSONTransport(allow_internal_http=allow_internal_http) + self.clock = clock + + def _call(self, method: str, approval_id: str, suffix="") -> dict: + if not isinstance(approval_id, str) or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_-]{0,127}", approval_id): + raise ValueError("invalid approval id") + if self.session.expires_at <= self.clock(): + raise ApprovalEngineError(401, "session_expired") + try: + status, data = self.transport.request(method, self.origin + "/v1/approvals/" + approval_id + suffix, + headers={"Authorization": "Bearer " + self.session.access_token, + "Content-Type": "application/json"}, body=b"{}" if method == "POST" else None) + except TransportError: + # A failed POST transport may already have committed. Never retry it + # automatically or manufacture a disposition from this uncertainty. + raise ApprovalEngineError(502, "upstream_unavailable") from None + if status != 200: + reason = data.get("error") + known = {"unauthenticated", "forbidden", "not_found", "conflict", + "duplicate_approver", "unprocessable", "store_unavailable"} + raise ApprovalEngineError(status, reason if isinstance(reason, str) and reason in known else "upstream_refused") + binding = data.get("binding") + if (data.get("id") != approval_id or not isinstance(binding, dict) + or binding.get("human_control") is not True + or not isinstance(binding.get("digest"), str) + or not re.fullmatch(r"sha256:[0-9a-f]{64}", binding["digest"]) + or data.get("status") not in {"requested", "approved", "consumed", "expired", "revoked", "superseded"}): + raise ApprovalEngineError(502, "invalid_approval_response") + return data + + def get_approval(self, approval_id: str) -> dict: + return self._call("GET", approval_id) + + def add_entry(self, approval_id: str) -> EntryResult: + self.get_approval(approval_id) # Require the declared human-control object before mutation. + duplicate = False + try: + data = self._call("POST", approval_id, "/entries") + except ApprovalEngineError as exc: + if not is_success(exc): + raise + duplicate = True + data = self.get_approval(approval_id) + entries = data.get("entries") + if not isinstance(entries, list) or any(not isinstance(e, dict) for e in entries): + raise ApprovalEngineError(502, "entry_correlation_missing") + matches = [e for e in entries if e.get("subject_id") == self.session.subject] + try: + if len(matches) != 1 or matches[0].get("principal_type") != "human": + raise ValueError() + at = matches[0]["approved_at"] + if not isinstance(at, str) or datetime.fromisoformat(at.replace("Z", "+00:00")).tzinfo is None: + raise ValueError() + except (KeyError, TypeError, ValueError): + raise ApprovalEngineError(502, "entry_correlation_missing") from None + return EntryResult(approval_id, self.session.subject, at, data["status"], duplicate) diff --git a/informed_decision/http_transport.py b/informed_decision/http_transport.py new file mode 100644 index 0000000..f894368 --- /dev/null +++ b/informed_decision/http_transport.py @@ -0,0 +1,73 @@ +"""Bounded JSON transport for fixed, deployment-owned HTTPS endpoints.""" + +from __future__ import annotations + +import json +import ipaddress +from http.client import HTTPException +from urllib.error import HTTPError, URLError +from urllib.parse import urlsplit +from urllib.request import HTTPRedirectHandler, ProxyHandler, Request, build_opener + + +class TransportError(Exception): + """Deliberately contains no response body, URL, token or request data.""" + + +def fixed_origin(value: str, *, allow_internal_http=False) -> str: + parsed = urlsplit(value) + internal_http = False + if allow_internal_http and parsed.scheme == "http" and parsed.hostname: + try: + internal_http = ipaddress.ip_address(parsed.hostname).is_loopback + except ValueError: + internal_http = parsed.hostname.endswith((".svc", ".svc.cluster.local")) + if ((parsed.scheme != "https" and not internal_http) or not parsed.hostname or parsed.username + or parsed.password or parsed.path or parsed.query or parsed.fragment + or any(c.isspace() for c in value)): + raise ValueError("a fixed HTTPS origin without path or credentials is required") + return value + + +def https_origin(value: str) -> str: + return fixed_origin(value) + + +class _NoRedirect(HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + return None + + +class JSONTransport: + """No redirects or ambient proxy credentials; five-second request timeout.""" + + def __init__(self, *, allow_internal_http=False) -> None: + self.allow_internal_http = allow_internal_http + self._opener = build_opener(ProxyHandler({}), _NoRedirect()) + + def request(self, method: str, url: str, *, headers=None, body=None) -> tuple[int, dict]: + parsed = urlsplit(url) + try: + fixed_origin(parsed.scheme + "://" + parsed.netloc, + allow_internal_http=self.allow_internal_http) + except ValueError: + raise TransportError("trusted transport required") from None + req = Request(url, method=method, data=body, headers=headers or {}) + try: + try: + response = self._opener.open(req, timeout=5) + except HTTPError as exc: + response = exc + with response: + status = response.code + if 300 <= status < 400: + raise TransportError("upstream redirect refused") + raw = response.read(262145) + if len(raw) > 262144: + raise TransportError("upstream response too large") + result = json.loads(raw) + if not isinstance(result, dict): + raise TransportError("upstream object required") + return status, result + except (URLError, OSError, ValueError, HTTPException): + raise TransportError("upstream request failed") from None diff --git a/informed_decision/oidc.py b/informed_decision/oidc.py new file mode 100644 index 0000000..a92806e --- /dev/null +++ b/informed_decision/oidc.py @@ -0,0 +1,187 @@ +"""KeyCape public-client authorization-code/PKCE login, never local identity. + +The issuer and its fixed endpoints are trusted deployment configuration. The +human provenance mapping is specific to KeyCape's user-authenticated code flow +in internal/server/oidc/token.go, not a property inferred from client metadata. +""" + +from __future__ import annotations + +import base64 +import hashlib +import secrets +import threading +import time +from dataclasses import dataclass, field +from urllib.parse import urlencode + +import jwt + +from .approval_client import REQUIRED_SCOPES +from .http_transport import JSONTransport, TransportError, https_origin +from .provenance import Claim, Route, assert_human_control_dischargeable + +CLIENT_ID = "informed-decision-approver" +ORIGIN = "https://decisions.coulomb.social" +CALLBACK = ORIGIN + "/auth/callback" + + +class LoginError(Exception): + """Safe, fixed errors: never include issuer response bodies or tokens.""" + + +@dataclass(frozen=True) +class HumanSession: + subject: str + tenant: Claim + principal_type: Claim + assurance: dict = field(repr=False) + expires_at: float + access_token: str = field(repr=False) + csrf: str = field(default_factory=lambda: secrets.token_urlsafe(32), repr=False) + + +class KeyCapeLogin: + """Single-process, bounded ephemeral sessions. Restart means reauthenticate. + + No refresh token, approval state, memo, or evidence is stored here. Multiple + replicas need a separately designed shared session store before deployment. + """ + + def __init__(self, issuer: str, *, transport=None, clock=time.time, capacity=1024): + self.issuer = https_origin(issuer) + self.transport = transport or JSONTransport() + self.clock = clock + self.capacity = capacity + self._lock = threading.RLock() + self._pending: dict[str, tuple] = {} + self._sessions: dict[str, HumanSession] = {} + self._keys: dict[str, object] = {} + self._keys_until = 0.0 + + def _prune(self): + now = self.clock() + self._pending = {k: v for k, v in self._pending.items() if v[0] > now} + self._sessions = {k: v for k, v in self._sessions.items() if v.expires_at > now} + + def start(self) -> tuple[str, str]: + with self._lock: + self._prune() + if len(self._pending) >= self.capacity: + raise LoginError("login capacity reached") + state, browser, nonce = (secrets.token_urlsafe(32) for _ in range(3)) + verifier = secrets.token_urlsafe(64) + self._pending[state] = (self.clock() + 300, browser, nonce, verifier) + challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode() + return self.issuer + "/authorize?" + urlencode({ + "response_type": "code", "client_id": CLIENT_ID, "redirect_uri": CALLBACK, + "scope": " ".join(REQUIRED_SCOPES), "state": state, "nonce": nonce, + "code_challenge": challenge, "code_challenge_method": "S256", + }), browser + + def _decode(self, token: str, audience: str) -> dict: + if not isinstance(token, str) or not token or len(token) > 32768: + raise LoginError("token verification failed") + try: + header = jwt.get_unverified_header(token) + kid = header.get("kid") + if header.get("alg") != "RS256" or not isinstance(kid, str) or not kid: + raise LoginError("token verification failed") + with self._lock: + if self.clock() >= self._keys_until or kid not in self._keys: + status, data = self.transport.request("GET", self.issuer + "/jwks") + if status != 200 or not isinstance(data.get("keys"), list): + raise LoginError("token verification failed") + keys = {} + for raw in data["keys"]: + if (raw.get("kty") != "RSA" or raw.get("use", "sig") != "sig" + or raw.get("alg", "RS256") != "RS256"): + continue + key = jwt.PyJWK.from_dict(raw, algorithm="RS256") + if not key.key_id or key.key_id in keys: + raise LoginError("token verification failed") + keys[key.key_id] = key.key + self._keys, self._keys_until = keys, self.clock() + 300 + key = self._keys[kid] + claims = jwt.decode(token, key, algorithms=["RS256"], issuer=self.issuer, + audience=audience, leeway=30, options={ + "require": ["iss", "aud", "sub", "exp", "iat"], + "strict_aud": True, + }) + if (not isinstance(claims["sub"], str) or not claims["sub"] + or any(type(claims[k]) is not int for k in ("iat", "exp")) + or claims["exp"] <= self.clock() or claims["exp"] <= claims["iat"]): + raise LoginError("token verification failed") + return claims + except (jwt.PyJWTError, TransportError, KeyError, TypeError, ValueError, AttributeError): + raise LoginError("token verification failed") from None + + def finish(self, state: str, browser: str, code: str) -> str: + with self._lock: + self._prune() + pending = self._pending.pop(state, None) + if not pending or not browser or not secrets.compare_digest(browser, pending[1]): + raise LoginError("login state invalid or expired") + if not code or len(code) > 4096: + raise LoginError("authorization code missing or invalid") + try: + status, tokens = self.transport.request("POST", self.issuer + "/token", + headers={"Content-Type": "application/x-www-form-urlencoded"}, + body=urlencode({"grant_type": "authorization_code", "client_id": CLIENT_ID, + "redirect_uri": CALLBACK, "code_verifier": pending[3], + "code": code}).encode()) + except TransportError: + raise LoginError("token exchange unavailable") from None + token_type = tokens.get("token_type") + if status != 200 or not isinstance(token_type, str) or token_type.lower() != "bearer": + raise LoginError("token exchange refused") + identity = self._decode(tokens.get("id_token"), CLIENT_ID) + access = self._decode(tokens.get("access_token"), "approval-engine") + if identity.get("nonce") != pending[2] or identity["sub"] != access["sub"]: + raise LoginError("login identity mismatch") + for name in ("tenant", "tenant_source", "principal_type", "assurance"): + if identity.get(name) != access.get(name): + raise LoginError("login identity mismatch") + scope = access.get("scope") + if not isinstance(scope, str) or set(scope.split()) != set(REQUIRED_SCOPES): + raise LoginError("unexpected token scopes") + if access.get("principal_type") != "human" or access.get("tenant") != "tenant:platform": + raise LoginError("human approver profile required") + if (not isinstance(access.get("roles"), list) + or any(not isinstance(x, str) for x in access["roles"])): + raise LoginError("human approver profile required") + assurance = access.get("assurance") + if (not isinstance(assurance, dict) or assurance.get("level") != "aal2" + or assurance.get("mfa") is not True or assurance.get("source") != "key-cape" + or assurance.get("methods") != ["pwd", "otp"] + or type(assurance.get("at")) is not int or assurance["at"] <= 0 + or assurance["at"] > min(access["iat"], self.clock()) + 30): + raise LoginError("verified MFA facts required") + tenant_source = access.get("tenant_source") + if not isinstance(tenant_source, str): + raise LoginError("tenant provenance required") + tenant_route = {"directory": Route.DIRECTORY, "registration": Route.REGISTRATION, + "default": Route.INDETERMINATE}.get(tenant_source, Route.INDETERMINATE) + if tenant_route is Route.INDETERMINATE: + raise LoginError("tenant provenance required") + human = Claim("human", Route.AUTHENTICATION) + assert_human_control_dischargeable(human) + session = HumanSession(access["sub"], Claim(access["tenant"], tenant_route), human, + dict(assurance), min(identity["exp"], access["exp"], self.clock() + 900), + tokens["access_token"]) + with self._lock: + self._prune() + if len(self._sessions) >= self.capacity: + raise LoginError("session capacity reached") + sid = secrets.token_urlsafe(32) + self._sessions[sid] = session + return sid + + def session(self, sid: str) -> HumanSession | None: + with self._lock: + self._prune() + return self._sessions.get(sid) + + def logout(self, sid: str) -> None: + with self._lock: + self._sessions.pop(sid, None) diff --git a/informed_decision/provenance.py b/informed_decision/provenance.py index b356e53..3851bf7 100644 --- a/informed_decision/provenance.py +++ b/informed_decision/provenance.py @@ -1,11 +1,12 @@ """Claim provenance — A-16 applied to claims this surface consumes. -`key-cape` emits `tenant` and `principal_type` as bare strings. A consumer -cannot tell a value the *directory asserted about the person* from one a -*registration supplied about the client they came through*. +`key-cape` emits `tenant_source` alongside `tenant`. Its authorization-code +handler derives `principal_type=human` from the authenticated user flow; its +client-credentials handler emits `service`. These are distinct provenance +contracts, not two properties inferred from a client registration. -`GH-DEC-2026-013` §5 requires the claim to carry its provenance. Until it does, -this surface records which route the value arrived by rather than storing an +`GH-DEC-2026-013` §5 requires the claim to carry its provenance. This surface +records which route the value arrived by rather than storing an undifferentiated string (PR-09), and never discharges a human-in-the-loop control on a registration-supplied assertion of humanity (PR-11, `GH-DEC-2026-016` §5). @@ -74,9 +75,9 @@ class HumanControlNotDischargeable(Exception): def assert_human_control_dischargeable(principal_type: Claim) -> None: """Guard for `GH-DEC-2026-016` §5 / PR-11. - Today `principal_type: human` is a property of the client registration, so - this raises. That is correct and deliberate: the guard exists so the - limitation is visible at the point of use rather than buried in a document. + The KeyCape login adapter supplies AUTHENTICATION only after verifying the + issuer's code-flow tokens. A bare or registration-supplied assertion still + cannot discharge the control. """ if principal_type.value != "human": raise HumanControlNotDischargeable( diff --git a/informed_decision/web.py b/informed_decision/web.py new file mode 100644 index 0000000..68338d4 --- /dev/null +++ b/informed_decision/web.py @@ -0,0 +1,114 @@ +"""Browser login shell. Protected memo rendering/binding is not wired yet.""" + +from __future__ import annotations + +import html +from http.cookies import CookieError, SimpleCookie +import json +import os +import secrets +from urllib.parse import parse_qs + +from .oidc import KeyCapeLogin, LoginError, ORIGIN + +FLOW_COOKIE = "__Host-infd-flow" +SESSION_COOKIE = "__Host-infd-session" + + +def _cookie(name: str, value: str, seconds: int) -> tuple[str, str]: + return "Set-Cookie", f"{name}={value}; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age={seconds}" + + +def _one(query: str) -> dict[str, str]: + if len(query) > 8192: + raise ValueError("request too large") + values = parse_qs(query, keep_blank_values=True, max_num_fields=16) + if any(len(v) != 1 for v in values.values()): + raise ValueError("duplicate parameter") + return {k: v[0] for k, v in values.items()} + + +class App: + def __init__(self, login: KeyCapeLogin): + self.login = login + + def __call__(self, environ, start_response): + headers = [ + ("Cache-Control", "no-store"), ("Pragma", "no-cache"), + ("Referrer-Policy", "no-referrer"), ("X-Content-Type-Options", "nosniff"), + ("Content-Security-Policy", "default-src 'none'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'"), + ("Strict-Transport-Security", "max-age=31536000"), + ] + try: + cookies = SimpleCookie() + cookies.load(environ.get("HTTP_COOKIE", "")) + sid = cookies[SESSION_COOKIE].value if SESSION_COOKIE in cookies else "" + browser = cookies[FLOW_COOKIE].value if FLOW_COOKIE in cookies else "" + if not sid.isascii() or not browser.isascii(): + raise ValueError("invalid cookie") + status, body, content_type, extra = self.dispatch(environ, sid, browser) + headers.extend(extra) + except LoginError: + status, body, content_type = 401, "Sign-in could not be completed. Start a new sign-in.", "text/plain" + headers.append(_cookie(FLOW_COOKIE, "", 0)) + except (ValueError, CookieError, UnicodeError): + status, body, content_type = 400, "Invalid request.", "text/plain" + raw = body.encode() + headers += [("Content-Type", content_type + "; charset=utf-8"), ("Content-Length", str(len(raw)))] + start_response(f"{status} {'OK' if status < 400 else 'ERROR'}", headers) + return [raw] + + def dispatch(self, environ, sid, browser): + method, path = environ.get("REQUEST_METHOD", "GET"), environ.get("PATH_INFO", "/") + if method == "GET" and path == "/healthz": + return 200, '{"status":"ok"}', "application/json", [] + if method == "GET" and path == "/readyz": + return 503, json.dumps({"status": "incomplete", "reason": "approval_path_not_connected"}), "application/json", [] + if method == "GET" and path == "/auth/start": + url, browser = self.login.start() + return 303, "", "text/plain", [("Location", url), _cookie(FLOW_COOKIE, browser, 300)] + if method == "GET" and path == "/auth/callback": + params = _one(environ.get("QUERY_STRING", "")) + # Even an issuer refusal consumes state. Never echo error_description. + new_sid = self.login.finish(params.get("state", ""), browser, + "" if "error" in params else params.get("code", "")) + self.login.logout(sid) + return 303, "", "text/plain", [("Location", "/"), _cookie(FLOW_COOKIE, "", 0), + _cookie(SESSION_COOKIE, new_sid, 900)] + session = self.login.session(sid) + if method == "POST" and path == "/auth/logout": + if environ.get("HTTP_ORIGIN") != ORIGIN or not session: + return 403, "Invalid sign-out request.", "text/plain", [] + length = int(environ.get("CONTENT_LENGTH") or 0) + if not 0 < length <= 2048 or environ.get("CONTENT_TYPE", "").split(";")[0] != "application/x-www-form-urlencoded": + raise ValueError("invalid body") + raw = environ["wsgi.input"].read(length) + if len(raw) != length: + raise ValueError("truncated body") + csrf = _one(raw.decode()).get("csrf", "") + if not csrf.isascii() or not secrets.compare_digest(csrf, session.csrf): + return 403, "Invalid sign-out request.", "text/plain", [] + self.login.logout(sid) + return 303, "", "text/plain", [("Location", "/"), _cookie(SESSION_COOKIE, "", 0)] + if method == "GET" and path == "/": + if session: + content = (f"

Signed in as {html.escape(session.subject)}.

" + "

Decision review is being prepared. No approval has been recorded.

" + '
' + f'' + '
') + else: + content = '

Sign in with KeyCape

' + return 200, ('' + '' + 'Informed Decision

Informed Decision

' + + content + ''), "text/html", [] + return 404, "Not found.", "text/plain", [] + + +def main(): + from waitress import serve + # Waitress does not log request targets; a proxy must also omit callback + # query strings and cookies. No debug traceback middleware belongs here. + app = App(KeyCapeLogin(os.environ["INFD_KEYCAPE_ISSUER"])) + serve(app, host="127.0.0.1", port=8080, threads=4) diff --git a/pyproject.toml b/pyproject.toml index 37cde6e..d1fee40 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,10 +3,13 @@ name = "informed-decision" version = "0.1.0" description = "Presentation and binding surface for decisions — the Decision Memo and its evidence." requires-python = ">=3.11" -dependencies = [] +dependencies = ["PyJWT[crypto]>=2.10,<3", "waitress>=3,<4"] + +[project.scripts] +informed-decision-web = "informed_decision.web:main" [project.optional-dependencies] -dev = ["pytest>=8"] +dev = ["pytest>=8", "PyYAML>=6,<7"] [build-system] requires = ["setuptools>=68"] diff --git a/tests/test_approval_component.py b/tests/test_approval_component.py new file mode 100644 index 0000000..06b01ce --- /dev/null +++ b/tests/test_approval_component.py @@ -0,0 +1,101 @@ +"""Opt-in actual engine contract check, using signed synthetic identity fixtures. + +INFD_APPROVAL_ENGINE_SOURCE=/path/to/approval-engine python -m pytest -q +No native credentials, human login, policy verdict or audit admission is proved. +""" + +from datetime import datetime, timedelta, timezone +import io +import json +import os +from pathlib import Path +import sys + +import jwt +import pytest + +from informed_decision.approval_client import ApprovalEngineError +from informed_decision.approval_http import ApprovalHTTPClient +from test_browser_auth import ISSUER, IssuerFixture, finish, signing_key +from informed_decision.oidc import KeyCapeLogin + + +@pytest.fixture +def component(signing_key, tmp_path): + source = os.environ.get("INFD_APPROVAL_ENGINE_SOURCE") + if not source: + pytest.skip("set INFD_APPROVAL_ENGINE_SOURCE for the real-engine contract check") + assert (Path(source) / "approval_engine" / "api.py").is_file() + sys.path.insert(0, source) + from approval_engine.api import App + from approval_engine.auth import JWTAuthenticator + from approval_engine.store import Engine + + issuer = IssuerFixture(signing_key) + login = KeyCapeLogin(ISSUER, transport=issuer) + sid = finish((login, issuer)) + session = login.session(sid) + + class Keys: + def get_signing_key_from_jwt(self, token): + # Use the exact same synthetic JWKS that the login client verified. + return jwt.PyJWK.from_dict(issuer.request("GET", ISSUER + "/jwks")[1]["keys"][0]) + + engine = Engine(tmp_path / "approval.db") + auth = JWTAuthenticator(issuer=ISSUER, audience="approval-engine", jwks_url=ISSUER + "/jwks", jwks_client=Keys()) + app = App(engine, auth) + + class Transport: + def __init__(self): self.calls = [] + def request(self, method, url, *, headers=None, body=None): + self.calls.append((method, url)) + payload = body or b"" + environ = {"PATH_INFO": url.removeprefix("https://approval.test"), "REQUEST_METHOD": method, + "CONTENT_LENGTH": str(len(payload)), "wsgi.input": io.BytesIO(payload), + "HTTP_AUTHORIZATION": (headers or {}).get("Authorization", "")} + result = {} + def start(status, headers): result["status"] = int(status.split()[0]) + raw = b"".join(app(environ, start)) + return result["status"], json.loads(raw) + + transport = Transport() + now = datetime.now(timezone.utc) + engine.create({"actor": "synthetic-requester", "principal": "factory-fixture", + "action": "deliver", "purpose": "local-component-proof", "target": {"resource": "fixture"}}, + {"not_before": (now - timedelta(seconds=1)).isoformat(), + "expires_at": (now + timedelta(minutes=10)).isoformat()}, + human_control=True, approval_id="fixture") + client = ApprovalHTTPClient("https://approval.test", session, transport=transport) + yield client, engine, transport, session, signing_key + + +def test_real_engine_verifies_human_access_jwt_and_duplicate_correlation(component): + client, engine, transport, session, key = component + result = client.add_entry("fixture") + assert result.subject == session.subject and result.status == "approved" + entry = engine.get("fixture").as_dict()["entries"][0] + assert result.approved_at == entry["approved_at"] + assert entry["principal_type"] == "human" and entry["evidence_ref"].startswith("jwt-sha256:") + duplicate = client.add_entry("fixture") + assert duplicate.duplicate and duplicate.correlation == result.correlation + assert len(engine.get("fixture").entries) == 1 + assert not any(url.endswith("/consume") for _, url in transport.calls) + + +def test_real_engine_service_token_cannot_bind_declared_control(component): + client, engine, transport, session, key = component + claims = jwt.decode(session.access_token, options={"verify_signature": False}) + claims["principal_type"] = "service" + service = jwt.encode(claims, key, algorithm="RS256", headers={"kid": "key-1"}) + status, body = transport.request("POST", "https://approval.test/v1/approvals/fixture/entries", + headers={"Authorization": "Bearer " + service}, body=b"{}") + assert (status, body["error"]) == (403, "forbidden") + assert engine.get("fixture").entries == [] + + +def test_real_engine_revocation_stays_a_conflict(component): + client, engine, transport, session, key = component + engine.revoke("fixture") + with pytest.raises(ApprovalEngineError) as error: client.add_entry("fixture") + assert error.value.status == 409 and error.value.reason == "conflict" + assert engine.get("fixture").entries == [] diff --git a/tests/test_approval_http.py b/tests/test_approval_http.py new file mode 100644 index 0000000..64780c3 --- /dev/null +++ b/tests/test_approval_http.py @@ -0,0 +1,120 @@ +import copy +from dataclasses import replace +import time + +import pytest + +from informed_decision.approval_client import ApprovalEngineError +from informed_decision.approval_http import ApprovalHTTPClient +from informed_decision.http_transport import TransportError +from informed_decision.oidc import HumanSession +from informed_decision.provenance import Claim, Route + +ORIGIN = "https://approval.test" +APPROVAL = {"id": "fixture", "binding": {"digest": "sha256:" + "a" * 64, "human_control": True}, + "status": "approved", "updated_at": "2099-01-01T00:00:00Z", "entries": [ + {"subject_id": "human-fixture", "principal_type": "human", "approved_at": "2026-09-10T00:00:00Z"}]} + + +def session(): + return HumanSession("human-fixture", Claim("tenant:platform", Route.REGISTRATION), + Claim("human", Route.AUTHENTICATION), {}, time.time() + 900, "synthetic-access-token") + + +class Responses: + def __init__(self, *responses): + self.responses = list(responses) + self.calls = [] + + def request(self, method, url, **kwargs): + self.calls.append((method, url, kwargs)) + result = self.responses.pop(0) + if isinstance(result, Exception): raise result + return copy.deepcopy(result) + + +def test_entry_uses_access_token_empty_body_and_actual_entry_correlation(): + transport = Responses((200, APPROVAL), (200, APPROVAL)) + client = ApprovalHTTPClient(ORIGIN, session(), transport=transport) + result = client.add_entry("fixture") + assert result.correlation == ("fixture", "human-fixture", "2026-09-10T00:00:00Z") + assert not result.duplicate + assert [x[:2] for x in transport.calls] == [("GET", ORIGIN + "/v1/approvals/fixture"), + ("POST", ORIGIN + "/v1/approvals/fixture/entries")] + assert transport.calls[1][2]["body"] == b"{}" + assert transport.calls[1][2]["headers"]["Authorization"] == "Bearer synthetic-access-token" + assert not hasattr(client, "consume") + + +def test_duplicate_recovers_original_entry_never_invents_time_or_reposts(): + transport = Responses((200, APPROVAL), (409, {"error": "duplicate_approver"}), (200, APPROVAL)) + result = ApprovalHTTPClient(ORIGIN, session(), transport=transport).add_entry("fixture") + assert result.duplicate and result.approved_at == APPROVAL["entries"][0]["approved_at"] + assert [x[0] for x in transport.calls] == ["GET", "POST", "GET"] + + +@pytest.mark.parametrize("value", [False, None, "true", 1]) +def test_undeclared_or_malformed_human_control_refused_before_mutation(value): + data = copy.deepcopy(APPROVAL) + data["binding"]["human_control"] = value + transport = Responses((200, data)) + with pytest.raises(ApprovalEngineError, match="invalid_approval_response"): + ApprovalHTTPClient(ORIGIN, session(), transport=transport).add_entry("fixture") + assert len(transport.calls) == 1 and transport.calls[0][0] == "GET" + + +@pytest.mark.parametrize("value", [[], [{"subject_id": "other"}], [1], None, + [{"subject_id": "human-fixture", "principal_type": "service", "approved_at": "2026-09-10T00:00:00Z"}], + [{"subject_id": "human-fixture", "principal_type": "human", "approved_at": "2026-09-10"}], + [{"subject_id": "human-fixture", "principal_type": "human", "approved_at": None}]]) +def test_missing_corresponding_entry_cannot_be_reported_as_success(value): + data = copy.deepcopy(APPROVAL) + data["entries"] = value + transport = Responses((200, APPROVAL), (200, data)) + with pytest.raises(ApprovalEngineError, match="entry_correlation_missing"): + ApprovalHTTPClient(ORIGIN, session(), transport=transport).add_entry("fixture") + + +@pytest.mark.parametrize("response,reason", [ + ((403, {"error": "forbidden", "message": "secret-sentinel"}), "forbidden"), + ((409, {"error": "conflict"}), "conflict"), + ((503, {"error": "store_unavailable"}), "store_unavailable"), + ((500, {"error": ["secret-sentinel"]}), "upstream_refused"), + (TransportError("secret-sentinel"), "upstream_unavailable"), +]) +def test_refusal_and_uncertain_post_are_not_retried(response, reason): + transport = Responses((200, APPROVAL), response) + with pytest.raises(ApprovalEngineError) as error: + ApprovalHTTPClient(ORIGIN, session(), transport=transport).add_entry("fixture") + assert error.value.reason == reason and "secret-sentinel" not in str(error.value) + assert len(transport.calls) == 2 + + +@pytest.mark.parametrize("identifier", ["../consume", "a/entries", "a?consume", "a%2Fentries", "", "a" * 129]) +def test_identifier_cannot_inject_a_route(identifier): + transport = Responses() + with pytest.raises(ValueError): + ApprovalHTTPClient(ORIGIN, session(), transport=transport).get_approval(identifier) + assert not transport.calls + + +def test_session_expiry_refused_before_request(): + transport = Responses() + with pytest.raises(ApprovalEngineError, match="session_expired"): + ApprovalHTTPClient(ORIGIN, replace(session(), expires_at=1), transport=transport).get_approval("fixture") + assert not transport.calls + + +def test_read_current_state_each_time(): + revoked = {**APPROVAL, "status": "revoked"} + transport = Responses((200, APPROVAL), (200, revoked)) + client = ApprovalHTTPClient(ORIGIN, session(), transport=transport) + assert client.get_approval("fixture")["status"] == "approved" + assert client.get_approval("fixture")["status"] == "revoked" + + +def test_cluster_http_is_explicit_and_cannot_select_a_public_http_endpoint(): + with pytest.raises(ValueError): ApprovalHTTPClient("http://approval-engine.approval-engine.svc", session()) + client = ApprovalHTTPClient("http://approval-engine.approval-engine.svc", session(), allow_internal_http=True) + assert client.origin.startswith("http:") + with pytest.raises(ValueError): ApprovalHTTPClient("http://attacker.test", session(), allow_internal_http=True) diff --git a/tests/test_browser_auth.py b/tests/test_browser_auth.py new file mode 100644 index 0000000..f441c7a --- /dev/null +++ b/tests/test_browser_auth.py @@ -0,0 +1,302 @@ +"""Signed synthetic issuer fixtures: these are not proof of a native human login.""" + +import base64 +import copy +import hashlib +import io +import json +import time +from urllib.parse import parse_qs, urlencode, urlsplit + +from cryptography.hazmat.primitives.asymmetric import rsa +import jwt +import pytest + +from informed_decision.http_transport import JSONTransport, TransportError +from informed_decision.oidc import CALLBACK, CLIENT_ID, KeyCapeLogin, LoginError, ORIGIN +from informed_decision.provenance import Route +from informed_decision.web import App, FLOW_COOKIE, SESSION_COOKIE + +ISSUER = "https://keycape.test" + + +@pytest.fixture(scope="module") +def signing_key(): + return rsa.generate_private_key(public_exponent=65537, key_size=2048) + + +class IssuerFixture: + def __init__(self, key): + self.key = key + self.params = None + self.calls = [] + self.change = lambda tokens, claims: None + self.now = int(time.time()) + + def request(self, method, url, *, headers=None, body=None): + self.calls.append((method, url, headers, body)) + if url == ISSUER + "/jwks": + jwk = json.loads(jwt.algorithms.RSAAlgorithm.to_jwk(self.key.public_key())) + return 200, {"keys": [{**jwk, "kid": "key-1", "alg": "RS256", "use": "sig"}]} + assert method == "POST" and url == ISSUER + "/token" + params = {k: v[0] for k, v in parse_qs(body.decode()).items()} + assert params["client_id"] == CLIENT_ID and params["redirect_uri"] == CALLBACK + assert params["grant_type"] == "authorization_code" and "client_secret" not in params + challenge = base64.urlsafe_b64encode(hashlib.sha256(params["code_verifier"].encode()).digest()).rstrip(b"=").decode() + assert challenge == self.params["code_challenge"] + claims = {"iss": ISSUER, "sub": "human-fixture", "iat": self.now, "exp": self.now + 900, + "tenant": "tenant:platform", "tenant_source": "registration", "principal_type": "human", + "roles": [], "assurance": {"at": self.now - 120, "level": "aal2", "mfa": True, + "methods": ["pwd", "otp"], "source": "key-cape"}} + payloads = {"id_token": {**copy.deepcopy(claims), "aud": CLIENT_ID, "nonce": self.params["nonce"]}, + "access_token": {**copy.deepcopy(claims), "aud": "approval-engine", + "scope": "openid approval:read approval:approve"}} + tokens = {"token_type": "Bearer"} + self.change(tokens, payloads) + for name, value in payloads.items(): + tokens.setdefault(name, jwt.encode(value, self.key, algorithm="RS256", headers={"kid": "key-1"})) + return 200, tokens + + +@pytest.fixture +def login(signing_key): + transport = IssuerFixture(signing_key) + login = KeyCapeLogin(ISSUER, transport=transport) + return login, transport + + +def begin(pair): + login, issuer = pair + url, browser = login.start() + issuer.params = {k: v[0] for k, v in parse_qs(urlsplit(url).query).items()} + return issuer.params["state"], browser + + +def finish(pair): + state, browser = begin(pair) + return pair[0].finish(state, browser, "synthetic-code") + + +def call(app, path="/", method="GET", cookie="", query="", body=b"", origin=None, content_type="application/x-www-form-urlencoded"): + env = {"PATH_INFO": path, "REQUEST_METHOD": method, "HTTP_COOKIE": cookie, + "QUERY_STRING": query, "wsgi.input": io.BytesIO(body), "CONTENT_LENGTH": str(len(body)), + "CONTENT_TYPE": content_type} + if origin is not None: + env["HTTP_ORIGIN"] = origin + response = {} + def start(status, headers): + response.update(status=int(status.split()[0]), headers=headers) + response["body"] = b"".join(app(env, start)).decode() + return response + + +def test_pkce_nonce_scope_and_imported_human_session(login): + sid = finish(login) + session = login[0].session(sid) + assert session.subject == "human-fixture" + assert session.principal_type.route is Route.AUTHENTICATION + assert session.tenant.route is Route.REGISTRATION + assert session.assurance["at"] == login[1].now - 120 # Authentication, never mint time. + assert session.access_token not in repr(session) + assert login[1].params["scope"] == "openid approval:read approval:approve" + assert login[1].params["code_challenge_method"] == "S256" + assert login[1].params["nonce"] != login[1].params["state"] + + +@pytest.mark.parametrize("where,key,value", [ + ("access_token", "aud", CLIENT_ID), ("id_token", "aud", "approval-engine"), + ("access_token", "aud", ["approval-engine", CLIENT_ID]), + ("access_token", "iss", "https://attacker.test"), + ("id_token", "nonce", "wrong"), ("id_token", "nonce", None), + ("access_token", "sub", "another-human"), ("access_token", "sub", ""), + ("both", "principal_type", "service"), ("both", "principal_type", "agent"), + ("both", "tenant", "tenant:coulomb"), ("both", "tenant_source", None), + ("both", "tenant_source", "default"), ("both", "tenant_source", "invented"), + ("both", "tenant_source", ["registration"]), + ("access_token", "scope", "openid approval:read approval:approve approval:consume"), + ("access_token", "scope", "openid approval:approve"), ("access_token", "scope", ["openid"]), + ("access_token", "roles", None), ("access_token", "roles", [1]), + ("both", "assurance", {}), + ("access_token", "exp", 1), ("id_token", "exp", 1), + ("access_token", "iat", 9999999999), ("access_token", "exp", "9999999999"), +]) +def test_invalid_identity_refused(login, where, key, value): + def change(tokens, claims): + for name in claims if where == "both" else [where]: + claims[name][key] = value + login[1].change = change + with pytest.raises(LoginError): + finish(login) + assert not login[0]._sessions + + +@pytest.mark.parametrize("key,value", [ + ("mfa", False), ("mfa", 1), ("level", "aal1"), ("methods", ["pwd"]), + ("source", "self"), ("at", 0), ("at", "123"), ("at", 9999999999), +]) +def test_mfa_profile_required(login, key, value): + def change(tokens, claims): + for payload in claims.values(): + payload["assurance"][key] = value + login[1].change = change + with pytest.raises(LoginError, match="MFA"): + finish(login) + + +def test_directory_provenance_preserved(login): + def change(tokens, claims): + for payload in claims.values(): + payload["tenant_source"] = "directory" + login[1].change = change + assert login[0].session(finish(login)).tenant.route is Route.DIRECTORY + + +@pytest.mark.parametrize("kind", ["wrong-browser", "missing-browser", "unknown-state", "expired", "missing-code"]) +def test_callback_fails_before_exchange(login, kind): + state, browser = begin(login) + code = "code" + if kind == "wrong-browser": browser = "other" + if kind == "missing-browser": browser = "" + if kind == "unknown-state": state = "unknown" + if kind == "expired": login[0].clock = lambda: time.time() + 301 + if kind == "missing-code": code = "" + with pytest.raises(LoginError): login[0].finish(state, browser, code) + assert not login[1].calls + + +def test_callback_single_use_even_after_refusal(login): + state, browser = begin(login) + login[0].finish(state, browser, "code") + count = len(login[1].calls) + with pytest.raises(LoginError): login[0].finish(state, browser, "code") + assert len(login[1].calls) == count + + +@pytest.mark.parametrize("value", ["broken", "", None]) +def test_bad_token_never_becomes_session(login, value): + login[1].change = lambda tokens, claims: tokens.update(access_token=value) + with pytest.raises(LoginError): finish(login) + + +def test_wrong_signature_refused(login): + other = rsa.generate_private_key(public_exponent=65537, key_size=2048) + def change(tokens, claims): + tokens["access_token"] = jwt.encode(claims["access_token"], other, algorithm="RS256", headers={"kid": "key-1"}) + login[1].change = change + with pytest.raises(LoginError): finish(login) + + +@pytest.mark.parametrize("algorithm,kid", [("HS256", "key-1"), ("RS256", "unknown-key")]) +def test_algorithm_confusion_and_unknown_key_refused(login, algorithm, kid): + def change(tokens, claims): + key = b"synthetic-key-material-for-this-test-only" if algorithm == "HS256" else login[1].key + tokens["access_token"] = jwt.encode(claims["access_token"], key, algorithm=algorithm, headers={"kid": kid}) + login[1].change = change + with pytest.raises(LoginError): finish(login) + + +@pytest.mark.parametrize("failure", ["token", "jwks", "expired-jwks-cache"]) +def test_dependency_outage_cannot_authenticate_from_stale_keys(login, failure): + if failure == "expired-jwks-cache": + finish(login) + login[0]._keys_until = 0 + state, browser = begin(login) + original = login[1].request + def request(method, url, **kwargs): + if url.endswith("/token" if failure == "token" else "/jwks"): + raise TransportError("secret-sentinel") + return original(method, url, **kwargs) + login[1].request = request + count = len(login[0]._sessions) + with pytest.raises(LoginError) as error: login[0].finish(state, browser, "code") + assert "secret-sentinel" not in str(error.value) and len(login[0]._sessions) == count + + +def test_browser_subject_is_html_escaped(login): + def change(tokens, claims): + for payload in claims.values(): payload["sub"] = '' + login[1].change = change + sid = finish(login) + body = call(App(login[0]), cookie=f"{SESSION_COOKIE}={sid}")["body"] + assert "