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.
" + '') + else: + content = '' + return 200, ('' + '' + '