From 204134e8f131dff377692fe35b453aaf55d18c55 Mon Sep 17 00:00:00 2001 From: leumor <116955025+leumor@users.noreply.github.com> Date: Tue, 22 Sep 2026 05:41:53 +0000 Subject: [PATCH 01/15] feat(protected): add controller-owned workload role isolation Add a fixed four-role source-build adapter with controller-owned launch intent, isolated storage and network namespaces, scoped process observation, and exact-invocation cleanup. Require controller-verified app sessions, set role access independently of administrator umask, and handle process-exit sampling races without weakening required bindings. Include an installed positive-path driver, acceptance contract, regression tests, and the remaining implementation and installed-validation gaps. Keep protected execution closed pending workload, finite-native, and original-authority prerequisites. --- ...ontroller-owned-workload-role-isolation.md | 328 ++++++++++ tools/interop/cross_version_workload.py | 520 ++++++++++++++++ tools/interop/test_cross_version_workload.py | 285 +++++++++ .../protected/restricted_workload.py | 416 +++++++++++++ .../protected/restricted_workload_app.py | 214 +++++++ .../restricted_workload_controller.py | 223 +++++++ .../protected/restricted_workload_launcher.py | 86 +++ .../protected/restricted_workload_mark.py | 37 ++ .../protected/restricted_workload_network.py | 330 ++++++++++ .../protected/restricted_workload_prepare.py | 239 ++++++++ .../protected/restricted_workload_storage.py | 264 ++++++++ .../protected/test_restricted_workload.py | 575 ++++++++++++++++++ .../protected/test_restricted_workload_app.py | 260 ++++++++ .../test_restricted_workload_network.py | 230 +++++++ .../test_restricted_workload_storage.py | 265 ++++++++ .../restricted/installation.py | 4 +- .../restricted/pr314_acceptance.py | 230 +++++++ .../restricted/pr314_workload_driver.py | 175 ++++++ .../cryptad-workload-controller.service | 44 ++ .../restricted/systemd/cryptad-workload.conf | 5 + .../systemd/cryptad-workload@.service | 39 ++ .../restricted/test_pr314_acceptance.py | 143 +++++ .../restricted/workload_installation.py | 89 +++ 23 files changed, 5000 insertions(+), 1 deletion(-) create mode 100644 docs/pr-314-controller-owned-workload-role-isolation.md create mode 100644 tools/interop/cross_version_workload.py create mode 100644 tools/interop/test_cross_version_workload.py create mode 100644 tools/release-certification/protected/restricted_workload.py create mode 100644 tools/release-certification/protected/restricted_workload_app.py create mode 100644 tools/release-certification/protected/restricted_workload_controller.py create mode 100644 tools/release-certification/protected/restricted_workload_launcher.py create mode 100644 tools/release-certification/protected/restricted_workload_mark.py create mode 100644 tools/release-certification/protected/restricted_workload_network.py create mode 100644 tools/release-certification/protected/restricted_workload_prepare.py create mode 100644 tools/release-certification/protected/restricted_workload_storage.py create mode 100644 tools/release-certification/protected/test_restricted_workload.py create mode 100644 tools/release-certification/protected/test_restricted_workload_app.py create mode 100644 tools/release-certification/protected/test_restricted_workload_network.py create mode 100644 tools/release-certification/protected/test_restricted_workload_storage.py create mode 100644 tools/release-certification/restricted/pr314_acceptance.py create mode 100644 tools/release-certification/restricted/pr314_workload_driver.py create mode 100644 tools/release-certification/restricted/systemd/cryptad-workload-controller.service create mode 100644 tools/release-certification/restricted/systemd/cryptad-workload.conf create mode 100644 tools/release-certification/restricted/systemd/cryptad-workload@.service create mode 100644 tools/release-certification/restricted/test_pr314_acceptance.py create mode 100644 tools/release-certification/restricted/workload_installation.py diff --git a/docs/pr-314-controller-owned-workload-role-isolation.md b/docs/pr-314-controller-owned-workload-role-isolation.md new file mode 100644 index 0000000000..e15d610fb8 --- /dev/null +++ b/docs/pr-314-controller-owned-workload-role-isolation.md @@ -0,0 +1,328 @@ +# Controller-owned workload role isolation + +This branch implements a source-build workload adapter and an installed positive driver for the +existing four-role experiment. It is not yet a complete PR-314 remediation: installed hostile +drivers and the actual reference execution remain outstanding. Installed workload acceptance, +finite-native acceptance and original protected authority remain separate requirements. + +## Source and evidence cutoff + +The implementation starts from `develop` at +`45eb43d6654074df884f59415fa598c1cda93e36`, the squash merge of GitHub PR #1415 +(project work item PR-313). GitHub reports that PR merged at `2026-09-22T04:28:18Z`; +its feature head was `90fc0323ff7b03202fd3f4f6f6114c68b3772819`. +The implementation branch is `feature/pr-314-controller-owned-workload-role-isolation`. + +The current API requery found Java CI `35683706077` successful, including build, +interop-smoke and certification; extended/perf-smoke were skipped. Beta `35683705860` +passed its dry run and skipped `production-beta`. Restricted prerequisites `35683705861` +passed. These are hosted checks for the inspected PR-313 source, not installed workload +observations or tests executed by this development session. + +The [PR-313 runbook](pr-313-installed-native-acceptance.md) retains its original 2026-09-16 +17-case positive observation at helper `19983163e45e2cc55eaac10ee458c1177bd60286`, the +separate PR-312 product identity, the interrupted next guest, and the earlier unresolved +SIGILL/SIGSEGV. Its 65-case finite-native contract remains incomplete. The runner corrections +after that observation do not change its source identity or cutoff. + +## Bounded prospective profile + +The profile is `debian13-systemd257-workload-v1`, on the existing disposable Debian 13, +systemd 257, cgroup-v2 reference. It retains the explicit `qemu64` CPU and selected +`tcg-single` or `tcg-multi` accelerator without automatic fallback, four vCPUs and 5632 MiB. +The pinned reference records kernel `6.12.107+deb13-amd64`, systemd `257.13-1~deb13u1`, +bubblewrap `0.12.0-1~deb13u1` and Temurin `25.0.4.1+1`. + +The bounded backend uses fixed per-role system-manager services and accounts, fixed namespace +setup, and controller-owned state under `/var/lib/cryptad-restricted-workload`. The roster is +`candidate-sender`, `candidate-recipient`, `previous`, and `relay-no-apps`. A smaller hostile +fixture does not establish the normal four-role topology. Source-built synthetic products are +eligible for local implementation testing, not historical-product authority. + +Controller selections must bind original registration, exact packages/JDK/apps/configuration, +finite deadline and resource budgets, invocation generation, immutable mappings and writable +storage generation. No client command, namespace, PID, port, path or systemd-property selection +is admitted. The profile must reject an unsupported adapter before mutation. + +## Audit and ownership matrix + +| Execution or state | Existing component to reuse | Required workload treatment | +| --- | --- | --- | +| Original registration, approval and revocation | Restricted controller and original-provider admission | Controller-owned; never copied into role environment | +| Lease, journal, checkpoint, expected identities and canaries | Existing observer private root and historical readers | Observer-owned 0700; no role traversal or exposure | +| Main packaged daemons | `cross_version_runtime.Supervisor` | Fixed distinct role services; no same-UID launch fallback | +| AppHost descendants | Real signed app install and sandbox provider | Within role UID, namespaces and service cgroup; actual nesting must pass | +| Package/JDK/staged bundle | Product admission and exact installed closure | Read-only fixed role mapping; explicitly translated app-install path | +| Daemon config/data/cache/run/logs | Existing daemon configuration and persistence | Role-only writable storage; normalized config separate from expected config | +| Catalog transport and Java helpers | `federated_catalog_runtime` | Confine selected helpers or reject profile before execution | +| Scheduler Java/Node/browser tasks | `scheduler_pressure_runtime` | Confine every candidate-bearing task or reject profile | +| Recovery clones | `cross_version_recovery` | New retained role invocation with same deadline and durable app state | +| Historical adapters and Mail child inspection | Existing adapters and `two_node_demo` | Explicit adapter coverage; no arbitrary PID adoption or forged provenance | +| Finite package/app/CMS verification | Existing keyless native service | Retain separate resolver/native context and recipient-key boundary | +| Candidate outputs | Existing bounded collectors | Adversarial bytes; regular-file/link/race checks and bounded private retention | + +The launch-site rows are obligations identified by the audit, not claims that every adapter is +implemented. Installed acceptance must enumerate the exact supported adapter set. Neither a Java +binary digest nor an app API's reported PID establishes the actual loaded product/app identity. + +## Management and data connections + +| Source | Destination | Required result | +| --- | --- | --- | +| Observer | Selected role's fixed FCP/HTTP/app management endpoint | Allowed through invocation-bound connector | +| Role daemon | Its own app and Platform API loopback endpoints | Allowed, preserving current bootstrap/session checks | +| Selected role | Approved peer FNP link | Allowed; actual cross-node content retrieval required | +| Candidate or app | Sibling FCP/HTTP/app endpoint | Denied against positively active server | +| Candidate or app | Observer, resolver, provider, baseline or host service | Denied against existing active target | +| Runner or observer | Arbitrary unit, namespace, PID or connector endpoint | Denied by server-side fixed selection | +| Candidate | Public Internet or metadata endpoint | No admitted route | + +Different loopback ports are insufficient. Dynamic app listeners retain origin, nonce, session +and current-worker binding. A redirect cannot grant an arbitrary endpoint, and browser CORS is +not a server-side authorization proof. Unavailable listeners and client timeouts are not denials. + +## Lifecycle and measurements + +Each role needs controller-retained intent before launch, exact manager invocation and cgroup, +boot identity, process epoch, immutable input identities and absolute deadline. A lost reply +reconciles that exact invocation. It must not launch again or adopt a matching process name. +Revocation forbids new work while leaving exact owned termination available. + +Cancellation, deadline, observer loss, controller restart and partial launch must stop scheduling, +terminate only the owned invocations, verify all descendants quiescent, then finalize bounded +output. No UID, port, mount or storage generation is reused while old descendants or recoverable +active records remain. Failure to prove quiescence retains reconciliation state. + +Cgroup memory remains cgroup memory, not process RSS. Missing cross-UID metrics are unavailable, +not zero. Kernel observations, controller intent and candidate counters retain separate provenance. +A prospective collector/profile change requires fresh comparison eligibility; historical layout-2 +same-UID results cannot be upgraded by replaying a checkpoint or changing its version. + +## Separate acceptance contract + +[`pr314_acceptance.py`](../tools/release-certification/restricted/pr314_acceptance.py) defines the +closed workload inventory and private driver-record verifier. It requires distinct role UIDs, +namespaces and invocations; real AppHost witness fields; control-side positive responses; +causal denial intervals; durable restart state; and terminal cgroup/descendant observations. +Unknown fields, duplicate attempts, omitted cases and stale identity prevent acceptance. + +The verifier accepts records only through an administrator driver whose guest transport and +source identity have been independently measured. It is not a report-import or approval tool; +valid JSON, hashes and synthetic unit-test records do not authenticate execution. The module +provides a supporting contract, not proof that the workload backend runs. +The contract's unit tests exercise valid and hostile record shapes only. + +| Dimension | Current evidence meaning | +| --- | --- | +| Role implementation and adapter coverage | Prospective implementation; audit obligations above | +| PR-313 finite-native acceptance | Separate 65-case contract; no complete installed run recorded | +| Workload isolation acceptance | No installed observations recorded | +| Actual operation/fault coverage | Contract cases are requirements, not executed attacks | +| Original protected authority | Not supplied by local synthetic provider context | +| Duration/resource comparison eligibility | No new baseline or long-soak claim | +| Cleanup and retained failure | Requires actual guest and role quiescence evidence | +| Phase 12 completeness | Unchanged; 49 mandatory assertions and historical 47 unresolved remain | + +Protected authorize/start/checkpoint must retain the unsafe path's denial until all applicable +finite-native, workload, original-authority and reviewed deployment prerequisites are established. +No administrator JSON checkbox or self-digest supplies them. + +## Implemented code path + +The production extension is deliberately separate from the credential-bearing resolver: + +| Component | Implemented responsibility | +| --- | --- | +| `restricted/workload_installation.py` | Explicit fixed-unit/account installation after immutable base closure verification; no protected enablement | +| `protected/restricted_workload_prepare.py` | Root-only source-build selection, existing archive/app admission, prospective configuration pins, immutable role staging and static account reservation | +| `protected/restricted_workload.py` | Retained campaign/role handles, operation/deadline checks, exact manager/cgroup observation, stop independent of current approval | +| `protected/restricted_workload_mark.py` | Fixed privileged `ExecStartPre` receipt binding the controller generation to the manager invocation before candidate execution | +| `protected/restricted_workload_launcher.py` | Empty-capability role identity, closed bwrap mappings, real packaged daemon invocation and original finite deadline | +| `protected/restricted_workload_network.py` | Four role namespaces plus isolated switch namespace; per-role and bridge UDP relay matrix; fixed connection descriptor transfer | +| `protected/restricted_workload_controller.py` | Observer-UID socket admission, fixed `{method, handle}` requests, finite request/watchdog budgets, semantic Mail bootstrap | +| `protected/restricted_workload_app.py` | Scoped kernel process/namespace/ancestry and current daemon listener binding, including Java's IPv4-mapped loopback sockets | +| `protected/restricted_workload_storage.py` | Pinned immutable input copies and bounded untrusted installed-app snapshots compared to exact admitted bytes | +| `interop/cross_version_workload.py` | Existing FCP parser, AppHandle route policy and journal; four-role relay connections, signed Mail child/bootstrap, CHK retrieval and daemon restart | + +The initial selection accepts only source-built Cryptad products, Mail on the candidate roles, +and the app-free relay. Catalog, scheduler, composed-budget, recovery-clone, migration, historical +product and higher-level Mail consumer selections are unsupported implementation coverage. They +must not be routed into the historical observer's `Popen` or host-loopback client paths. +The existing plan still retains all mandatory scenario assertions; the positive driver writes +a partial journal and cannot report the whole experiment complete. + +All four roles use fixed in-namespace FNP/FCP/HTTP ports `19400/19401/19402`. A selection's +`configDigest` must come from `configuration_identity(role, trustedKeysDigest)` in the preparation +module, and its producer must equal the existing installed `runner_identity()`, including the +immutable installation/dependency closure. Old loopback configuration pins cannot be reused. +The predecessor must have a distinct source and actual daemon JAR; repackaging current bytes does +not create a historical comparison. + +Each role has a nondelegated 512-task, 1 GiB-memory, zero-swap, 100%-CPU service budget, and a +512 MiB/32768-inode tmpfs for all writable node state. Restarts retain that tmpfs; host-reboot +continuation is unsupported. Runtime is at most 3600 seconds and remains tied to the original +campaign deadline. The observer inactivity bound is 240 seconds, above the fixed 180-second FCP +transaction bound. Loss of the controller stops bound role services through systemd. Unknown or +replaced invocations retain reconciliation failure. + +The role launcher permits AppHost's nested namespace construction; it does not copy the finite +native verifier's descendant-userns prohibition. Real AppHost nesting and hostile tmpfs/resource +exhaustion still need installed execution. The separate root sampler has `CAP_SYS_PTRACE` only to +satisfy cross-UID proc read checks, with ptrace, process-memory and perf-event syscalls denied. +Neither the observer nor candidate receives that capability. Root's fixed namespace setup and +the small controller/recorder remain part of the TCB. + +The lifecycle reads the recursive `populated` state described by the +[Linux 6.12 cgroup-v2 interface](https://www.kernel.org/doc/html/v6.12/admin-guide/cgroup-v2.html). +It keeps manager invocation, cgroup identity and proc epochs separate; the +[proc interface](https://www.kernel.org/doc/html/latest/filesystems/proc.html) does not turn a +candidate-reported PID into ownership. Unit settings target +[systemd v257 execution semantics](https://github.com/systemd/systemd/blob/v257/man/systemd.exec.xml) +and still require checks against the exact installed Debian patches. + +Preparation admits at most 512 MiB expanded package and 512 MiB JDK per role, plus bounded app +inputs. It reserves 12 GiB for staging/retained partial preparation and 4 GiB free-space headroom +by admission check; this is not a kernel filesystem quota or protection from unrelated writes. +Input/package bytes remain root-owned, outside the observer root. Failed app snapshots retain +their fixed reservation and reject retries; successful snapshots are removed after exact matching. +No static identity or retained campaign is automatically recycled. + +## Executable test entry and remaining implementation work + +The read-only prerequisite check is: + +```bash +python3 tools/release-certification/restricted/pr314_workload_driver.py +``` + +Exit 78 means that the installed lane was not executed. In this development session it reported +missing administrator context, dedicated VM, fixed installation and measured test kit. The host +is a container; no workload services, accounts, namespace fabric or candidate daemon were installed +on it. + +Inside a newly copied and admitted reference VM, first use the existing PR-313 base installation, +separate test-kit preparation and boot-closure verification. An administrator supplies the fixed +root-private `/root/pr314-workload-selection.json` with `plan`, `private`, and `authorization`. +Use two distinct source-built portable archives, a flattened exact JDK closure, and the normally +signed Mail bundle/public trust input. Produce the prospective config and installed runner pins +described above; do not change product bytes or invent source identities to satisfy the roster. +After independent source/test-kit checks, the explicit guest command is: + +```bash +python3 /opt/cryptad-restricted-test-kit/tools/release-certification/restricted/pr314_workload_driver.py --execute +``` + +That driver invokes production preparation/installation code, drops the observer to `cryptad-soak`, +uses the existing private journal, executes the adapter's actual positive sequence, and requires +terminal owned cgroup quiescence before namespace teardown. It retains role tmpfs state and +private records for diagnostics. Its public result has fixed status fields only. The private +result labels the workload verdict incomplete because it does not execute the full hostile +contract. Neither this command nor a passing positive sequence is a protected approval. + +Still required before calling PR-314 implementation complete: + +- Executable installed drivers for all applicable hostile and lifecycle cases, including active + sibling/admin canaries, real candidate/app-UID attacks, namespace/resource escape, late children, + revocation, controller/observer loss, output races and retained invocation reuse. +- Complete private fixture preparation and host orchestration of that workload suite through the + existing copied-reference/storage-budget transport. +- Actual installed validation and fixes derived from it, including effective service policy, + cross-UID sampling, real AppHost nesting and truthful terminal resource retention. +- Reviewed admission of any future original-product/protected selection. The current source-build + preparation rejects production-artifact comparison and does not bypass original artifact policy. + +These are implementation and acceptance gaps, not renamed operations-only debt. No PR-313 finite +case was executed by the new driver, and none of the 45 workload cases has an installed passing +observation from this session. + +## Resource inventory and execution limits + +The initial read-only local inventory found no QEMU process, QEMU executable on PATH, or reference +images/attempt disks under accessible `/work`, `/tmp`, `/var/tmp`, `/opt` and `/home/codex`. +The host is LXC with UID1000 and systemd PID1; it is not the dedicated VM required by +`disposable_integration.prerequisites`. A subsequent administrator metadata-only inventory of +`/root` found no files with the inspected `.qcow2`, `.raw` or `.img` disk suffixes (zero allocated +bytes for those candidates). This bounded search does not erase or supersede historical evidence. +The last storage check found 57,067,704,320 bytes available on the root filesystem. No VM was +allocated or deleted, and no guest storage budget was consumed in this session. + +Before every allocation, follow root `AGENTS.md`: inventory retained attempts and allocated blocks, +confirm stopped/disposable ownership before cleanup, then set one total task budget and minimum +free reserve. Reuse `pr313_acceptance_runner` storage admission and `pr313_boot_inputs` private +copied closure. Its 24 GiB guest-growth reserve and 256 MiB report reserve are admission estimates, +not filesystem quotas. Preserve failed/interrupted evidence and stop allocation if capacity fails. + +New administrator drivers belong in the separate test kit and must be excluded by +`installation.TEST_SEAMS`. Their public output must use fixed allowlisted fields, excluding +private source/selection commitments, paths, identities, keys, topology, logs and guest images. + +## Local verification on this branch + +These results describe local tests of the uncommitted implementation, not installed acceptance. +Totals overlap because some self-tests include the same underlying test modules. + +| Check | Result | +| --- | --- | +| Restricted Python discovery | 267 tests, 1 skipped, passed | +| Protected `test_restricted_*.py` discovery | 159 tests, 20 skipped, passed before the final preparation guard regression was added | +| New workload tests as root, including the final preparation guard | 87 tests, no skips, passed | +| Cross-version interop discovery | 206 tests, 8 skipped, passed | +| New observer adapter tests as root | 22 tests, no skips, passed | +| `cross-version-soak --self-test` | 152 tests, passed | +| `stable-maintenance --self-test` | 247 tests, passed | +| `stable-platform-api-1x --self-test` | 88 tests, passed | +| `phase-12-closeout --self-test` | 185 tests, passed | +| Catalog regressions | 24 tests, passed | +| Scheduler regressions | 19 tests, passed in each of two overlapping local invocations; numeric findings differ as described below | +| Mail regressions | 5 tests, passed | +| `:platform-devtools:installDist assembleCryptadDist` | Passed, 342 tasks: 307 executed, 35 up-to-date | +| `systemd-analyze verify` for both workload units | Passed static validation; no installed unit execution | +| Workload driver read-only prerequisite probe | Exit 78, installed execution not performed | + +Root fixture tests include actual filesystem permission/link attacks and local socket descriptor +transfer/listener checks. Manager invocation and many fault cases use simulated manager state; +they are not real role-service, namespace or AppHost observations. No shared Java runtime source +changed and the full Java test suite was not run. The distribution build emitted 329 Error Prone +warnings across 117 untouched Java files, plus a Gradle deprecation warning; successful exit is +not a clean analyzer result. + +The first scheduler invocation reported `runtime-reference-dispersion-exceeded` and +`runtime-regression-exceeded`, with `numericStatus=fail`. The second reported no numeric findings +and `numericStatus=within-reviewed-local-bounds`. Both reported `releaseEligible=false`, two +repetitions and one candidate execution. The invocations overlapped; the differing numeric +observations are retained without assigning an unverified cause. Neither establishes a fresh +performance baseline or release acceptance. + +The subsequent review fixes require every installed app session refresh, including after restart, +to use the controller's `bootstrap-mail` exchange. Candidate-provided ordinary bootstrap origins +cannot skip current app/process/listener validation. A failed refresh clears the previous session. +Preparation explicitly sets traversal permissions and durable record permissions independently of +the administrator's umask; observer authority remains private. Both reported failures reproduced +before correction. The new preparation regression uses `umask 077` and real UID-dropped children +to initialize/read role storage, with product admission, service state and mounts supplied by +fixtures. It does not establish installed acceptance. + +Review validation passed: 88 focused workload tests under root, 24 focused adapter tests under +root, 208 cross-version interop tests (8 skipped), and 161 protected tests under root (1 skipped). +These are subsequent local results, separate from the earlier verification table. + +A further sampler review reproduced `pidfd_open()` returning `ProcessLookupError` for a reaped +child still listed in the sampled process roster. Observation now counts that race as +`exitedDuringSample`, alongside disappearing proc files, and still rechecks the exact manager +invocation afterward. Regression tests use real reaped children with fixture cgroup/manager state; +they also verify rejection of invocation replacement and propagation of permission failures. +Validation passed: 91 workload tests under root (no skips), 24 adapter tests (8 skipped), and +12 acceptance-contract tests. No installed systemd/network/AppHost execution was performed. + +The later exit window, after process data is sampled but before pidfd readiness is checked, is +also covered. A distinct `ProcessExitedDuringSample` exception derives from `ProcessLookupError`, +so both roster consumers skip the exited entry; required app-process revalidation still rejects +its loss. A regression terminates and reaps a real child after pidfd acquisition and before the +readiness check, and verifies the final invocation check is retained. App-binding tests cover +unrelated helper exits, required process/ancestor exits during either pass, and ownership failures. +The subsequent checks passed: 95 workload tests under root without skips, 24 adapter tests with +8 skips, and 12 acceptance-contract tests. These remain local tests, not installed acceptance. + +The next composed-budget work remains PR-309's import/fetch concurrency, timeout, cancellation, +retry and owner-terminal causality, followed by dependent window/store/restart/privacy cases. +Workload isolation does not close Mail lifecycle, migration, long-run or independent review. diff --git a/tools/interop/cross_version_workload.py b/tools/interop/cross_version_workload.py new file mode 100644 index 0000000000..692e4cfbbd --- /dev/null +++ b/tools/interop/cross_version_workload.py @@ -0,0 +1,520 @@ +"""Observer adapter for the fixed installed four-role workload profile. + +No process is launched locally. Every connection and process sample belongs to the +controller-retained role invocation. This prospective path does not promote historical +same-UID observations, or establish original protected authority. +""" +from __future__ import annotations + +import array +from contextlib import contextmanager +import http.client +import json +import os +from pathlib import Path +import re +import socket +import stat +import struct +import time +import urllib.parse +import uuid + +import cross_version_runtime as runtime + +PROFILE = 'debian13-systemd257-workload-v1' +SOCKET = '/run/cryptad-workload/control.sock' +MAX_RESPONSE = 1024 * 1024 +METHODS = {'start', 'observe', 'stop', 'connect-fcp', 'connect-http', 'bootstrap-mail'} +EPOCH = ('handle', 'generation', 'managerInvocation', 'bootId') + + +def fail(code): + raise runtime.RuntimeFailure('workload-' + code) + + +def _object(pairs): + result = {} + for key, value in pairs: + if key in result: + fail('response-duplicate-field') + result[key] = value + return result + + +class WorkloadClient: + """Only fixed methods and opaque handles cross the installed root-owned socket.""" + def request(self, method, handle): + if method not in METHODS or not isinstance(handle, str) or not re.fullmatch('[a-f0-9]{64}', handle): + fail('request-invalid') + descriptors = [] + channel = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + try: + channel.settimeout(30) + channel.connect(SOCKET) + _, uid, _ = struct.unpack('3i', channel.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, 12)) + if uid != 0: + fail('controller-peer-not-root') + channel.sendall(json.dumps({'method': method, 'handle': handle}, separators=(',', ':')).encode() + b'\n') + payload = bytearray() + while b'\n' not in payload: + data, ancillary, flags, _ = channel.recvmsg(min(65536, MAX_RESPONSE + 1 - len(payload)), + socket.CMSG_SPACE(16 * array.array('i').itemsize), socket.MSG_CMSG_CLOEXEC) + for level, kind, value in ancillary: + if level != socket.SOL_SOCKET or kind != socket.SCM_RIGHTS: + fail('controller-ancillary-invalid') + fds = array.array('i') + fds.frombytes(value[:len(value) - len(value) % fds.itemsize]) + descriptors.extend(fds) + if flags & (socket.MSG_TRUNC | socket.MSG_CTRUNC): + fail('controller-response-truncated') + if not data: + fail('controller-response-incomplete') + payload.extend(data) + if len(payload) > MAX_RESPONSE: + fail('controller-response-budget') + if payload.count(b'\n') != 1 or not payload.endswith(b'\n'): + fail('controller-response-framing') + value = json.loads(payload, object_pairs_hook=_object) + if not isinstance(value, dict) or 'error' in value: + fail('controller-request-rejected') + if method.startswith('connect-'): + if len(descriptors) != 1: + fail('controller-connection-missing') + descriptor = descriptors.pop() + try: + connected = socket.socket(fileno=descriptor) + except BaseException: + os.close(descriptor) + raise + try: + if connected.family != socket.AF_INET or connected.type != socket.SOCK_STREAM: + fail('controller-connection-invalid') + connected.getpeername() + connected.settimeout(25) + return value, connected + except BaseException: + connected.close() + raise + if descriptors: + fail('controller-unexpected-connection') + return value + except (OSError, ValueError, UnicodeError): + fail('controller-transport-failed') + finally: + channel.close() + for descriptor in descriptors: + os.close(descriptor) + + +class ConnectedFcpClient(runtime.BoundedFcpClient): + """Reuse bounded FCP parsing and handshake over one constrained controller connection.""" + def __init__(self, connected, name, transcript_path, before_send): + self.before_send = before_send + self.host, self.port, self.name = '127.0.0.1', 19401, name + self.transcript_path = transcript_path + self.sock = connected + self.file = None + self.hello = None + try: + self.file = connected.makefile('rwb', buffering=0) + self.send('ClientHello', {'Name': name, 'ExpectedVersion': '2.0'}) + self.hello = self.read_message(30) + if self.hello.name != 'NodeHello': + fail('fcp-hello-invalid') + except BaseException: + if self.file is not None: + self.file.close() + connected.close() + raise + + + def _log_text(self, text): + raw = text.encode('utf-8') + fd = os.open(self.transcript_path, os.O_WRONLY | os.O_APPEND | os.O_CREAT | os.O_NOFOLLOW, 0o600) + try: + info = os.fstat(fd) + if (not stat.S_ISREG(info.st_mode) or info.st_uid != os.geteuid() + or info.st_nlink != 1 or info.st_mode & 0o077 + or info.st_size + len(raw) > 16 * 1024**2): + fail('fcp-transcript-budget-or-ownership') + pending = memoryview(raw) + while pending: + written = os.write(fd, pending) + if written <= 0: + fail('fcp-transcript-write') + pending = pending[written:] + finally: + os.close(fd) + + +class _Response: + def __init__(self, response, connection): + self.response, self.connection = response, connection + self.status, self.headers = response.status, response.headers + + def read(self, size): + return self.response.read(size) + + def __enter__(self): + return self + + def __exit__(self, *_): + try: + self.response.close() + finally: + self.connection.close() + + +class _RoleHttp: + """No DNS, proxy or redirect resolution: one fixed management connection per request.""" + def __init__(self, supervisor, role): + self.supervisor, self.role = supervisor, role + + def open(self, request, timeout=25): + selected = urllib.parse.urlsplit(request.full_url) + if (selected.scheme != 'http' or selected.hostname != '127.0.0.1' or selected.port != 19402 + or selected.username or selected.password or selected.fragment): + fail('http-target-not-approved') + connected = self.supervisor.connection(self.role, 'http') + connection = http.client.HTTPConnection('127.0.0.1', 19402, timeout=timeout) + connection.sock = connected + try: + connection.request(request.get_method(), selected.path + ('?' + selected.query if selected.query else ''), + body=request.data, headers=dict(request.header_items())) + return _Response(connection.getresponse(), connection) + except BaseException: + connection.close() + raise + + +class InstalledAppHandle(runtime.AppHandle): + """Retain the existing HTTP route policy and normal signed AppHost installation.""" + def __init__(self, supervisor, role, app_id='mail-prototype'): + if app_id != 'mail-prototype': + fail('app-adapter-unsupported') + super().__init__(supervisor, role, app_id) + self.base, self.api = 'http://127.0.0.1:19402', 'http://127.0.0.1:19402/api/v1' + self.opener = _RoleHttp(supervisor, role) + + def refresh_session(self): + """Accept sessions only after the controller verifies the current app boundary.""" + self.origin = self.session = self.session_expires_at = None + value = self.isolated_bootstrap() + origin = runtime.mail_demo.target(value.get('uiOrigin')) + session = value.get('browserSessionToken') + if not isinstance(session, str) or not session or len(session) > 4096: + fail('own-app-bootstrap-invalid') + if origin == self.base: + fail('isolated-own-app-origin-required') + self.origin, self.session = origin, session + self.session_expires_at = value.get('browserSessionExpiresAt') + return self + + def isolated_bootstrap(self): + self.supervisor.next_operation() + self.supervisor.observe(self.role) + value = self.supervisor.control.request('bootstrap-mail', self.supervisor.handles[self.role]) + self.supervisor.observe(self.role) + # The controller validates nonce, origin and dynamic listener inside the role. + if not isinstance(value, dict): + fail('bootstrap-response-invalid') + return value + + def observe_worker(self): + status, value = self.request('GET', '/api/v1/apps/mail-prototype/runtime') + reported = value.get('runtime', {}) + if (status != 200 or reported.get('running') is not True + or reported.get('sandbox', {}).get('provider') != 'bubblewrap' + or reported.get('sandbox', {}).get('active') is not True): + fail('real-app-sandbox-not-observed') + observation = self.supervisor.observe(self.role) + self.worker_identity = bind_worker(observation, reported.get('pid'), + self.supervisor.expected_jdk_digests[self.role]) + return self.worker_identity + + def mail_client(self): + # The historical Mail Client opens direct host-loopback sockets. Never fall back. + fail('mail-consumer-adapter-unsupported') + + +def bind_worker(observation, api_pid, expected_jdk_digest): + """API PID is a hint resolved only against the controller's current cgroup sample. + + The outer role daemon uses NSpid index 1 in this profile; an AppHost child + must have an additional inner PID namespace. Require its actual Java descendant in this sample. + """ + if type(api_pid) is not int or api_pid <= 1 or observation.get('provenance') != 'controller-kernel-sample': + fail('app-process-hint-invalid') + rows = observation.get('processes') + if not isinstance(rows, list) or len(rows) > 512: + fail('process-sample-invalid') + processes = {} + for row in rows: + pid = row.get('hostPid') + namespaces = row.get('namespacePids') + if (type(pid) is not int or pid <= 1 or pid in processes or not isinstance(namespaces, list) + or not namespaces or namespaces[0] != pid or any(type(value) is not int or value < 1 for value in namespaces) + or type(row.get('startTicks')) is not int or row['startTicks'] < 1 + or type(row.get('hostParentPid')) is not int + or row.get('noNewPrivileges') is not True or row.get('effectiveCapabilities') != 0): + fail('process-sample-invalid') + processes[pid] = row + matches = [row for row in rows if len(row['namespacePids']) >= 2 and row['namespacePids'][1] == api_pid] + worker = matches[0] if len(matches) == 1 else None + if worker is None: + fail('app-process-outside-role') + for row in rows: + if row.get('executableDigest') != expected_jdk_digest or len(row['namespacePids']) < 3: + continue + cursor, visited = row, set() + for _ in range(64): + if cursor['hostPid'] == worker['hostPid']: + return {**{key: observation[key] for key in EPOCH}, 'hostPid': worker['hostPid'], + 'startTicks': worker['startTicks'], 'javaHostPid': row['hostPid'], + 'javaStartTicks': row['startTicks'], 'provenance': 'controller-kernel-sample'} + if cursor['hostPid'] in visited: + break + visited.add(cursor['hostPid']) + child = cursor + cursor = processes.get(cursor['hostParentPid']) + if cursor is None or cursor['startTicks'] > child['startTicks']: + break + fail('app-java-descendant-not-observed') + + +class InstalledWorkloadAdapter: + """Finite observer orchestration over a root-prepared, immutable source-build selection. + + ``handoff`` and exact JDK executable digests come from the controller's retained + selection, never candidate replies. The supplied journal retains its existing lease. + Optional ``control`` permits isolated unit tests without an installed socket. + """ + def __init__(self, plan, private_config, authorization, journal, handoff, expected_jdk_digests=None, *, control=None): + expected_jdk_digests = expected_jdk_digests or handoff.get('expectedJdkDigests', {}) + if (handoff.get('profile') != PROFILE or set(handoff.get('handles', {})) != set(runtime.ROLES) + or plan.get('provenanceClass') != 'source-build-comparison' + or set(private_config) != {'root', 'nodes'} or set(private_config['nodes']) != set(runtime.ROLES) + or len(plan['nodes']) != 4 or {node['role'] for node in plan['nodes']} != set(runtime.ROLES) + or plan.get('workloadInputs') or plan.get('cohorts') + or authorization.get('syntheticContent') is not True + or authorization.get('planDigest') != runtime.canonical_digest(plan) + or authorization.get('experimentId') != plan.get('experimentId') + or set(expected_jdk_digests) != set(runtime.ROLES)): + fail('selection-unsupported') + if (any(not re.fullmatch('sha256:[0-9a-f]{64}', value) for value in expected_jdk_digests.values()) + or any(not re.fullmatch('[0-9a-f]{64}', value) for value in handoff['handles'].values()) + or len(set(handoff['handles'].values())) != 4): + fail('selection-identity-invalid') + for role in runtime.ROLES: + apps = private_config['nodes'][role].get('apps') + if (not isinstance(apps, list) or len(apps) > 1 + or any(app.get('appId') != 'mail-prototype' for app in apps) + or (role == 'relay-no-apps' and apps) + or (role in {'candidate-sender', 'candidate-recipient'} and len(apps) != 1)): + fail('app-selection-unsupported') + root = Path(private_config['root']) + lock = getattr(journal, '_lock', None) + if (not root.is_absolute() or root.is_symlink() or root.resolve() != root + or root.stat().st_uid != os.geteuid() or root.stat().st_mode & 0o077 + or authorization.get('root') != str(root) or getattr(journal, 'root', None) != root + or type(lock) is not int): + fail('observer-private-journal-required') + actual, retained = os.fstat(lock), (root / 'lease').stat() + if (actual.st_dev, actual.st_ino) != (retained.st_dev, retained.st_ino): + fail('observer-journal-lease-changed') + maximum = authorization.get('maxSeconds') + if (type(maximum) is not int or not 30 <= maximum <= 3600 + or type(authorization.get('maxOperations')) is not int or not 1 <= authorization['maxOperations'] <= 10000): + fail('budget-invalid') + self.plan, self.private, self.authorization, self.journal = plan, private_config, authorization, journal + self.handles = dict(handoff['handles']) + self.expected_jdk_digests = dict(expected_jdk_digests) + self.control = control or WorkloadClient() + self.root, self.deadline, self.operations = root, time.monotonic() + maximum, 0 + self.nodes, self.apps, self.outcomes = {}, {}, {} + self._journal_started_roles = set() + self.catalog_prepared = None + + def remaining(self, limit=180): + remaining = self.deadline - time.monotonic() + if remaining <= 0: + fail('observer-deadline') + return min(limit, remaining) + + def next_operation(self): + self.remaining() + self.operations += 1 + if self.operations > self.authorization['maxOperations']: + fail('operation-budget') + return 'op-' + uuid.uuid4().hex + + def emit(self, kind, role='', scenario='', operation='', outcome='pass', counters=None, peer_role='', node_epoch=None): + return self.journal.append(kind, role=role, scenario=scenario, operation=operation, + outcome=outcome, counters=counters or {}, peer_role=peer_role, node_epoch=node_epoch) + + def _bound(self, role, value): + if not isinstance(value, dict) or value.get('handle') != self.handles[role]: + fail('role-handle-changed') + if any(value.get(key) != self.nodes[role][key] for key in EPOCH): + fail('role-invocation-changed') + return value + + def observe(self, role): + self.remaining() + value = self._bound(role, self.control.request('observe', self.handles[role])) + if value.get('state') != 'running': + fail('role-not-running') + return value + + def connection(self, role, endpoint): + if endpoint not in {'fcp', 'http'}: + fail('endpoint-not-approved') + self.remaining() + self.observe(role) + value, connected = self.control.request('connect-' + endpoint, self.handles[role]) + try: + if value != {'status': 'connected'}: + fail('connection-response-invalid') + self.observe(role) + return connected + except BaseException: + connected.close() + raise + + @contextmanager + def client(self, role): + client = ConnectedFcpClient(self.connection(role, 'fcp'), 'workload-' + uuid.uuid4().hex, + self.root / ('fcp-' + role + '.log'), self.next_operation) + try: + yield client + finally: + client.close() + + def start(self, role): + if role not in runtime.ROLES: + fail('role-invalid') + self.remaining() + value = self.control.request('start', self.handles[role]) + if (value.get('handle') != self.handles[role] or value.get('state') != 'running' + or not re.fullmatch('[a-f0-9]{64}', str(value.get('generation'))) + or not re.fullmatch('[a-f0-9]{32}', str(value.get('managerInvocation'))) + or not re.fullmatch('[a-f0-9-]{36}', str(value.get('bootId')))): + fail('start-identity-invalid') + self.nodes[role] = value + deadline = time.monotonic() + self.remaining(180) + while True: + try: + with self.client(role) as client: + value['reference'] = runtime.interop.get_node_reference(client, 'node-identity') + break + except (OSError, runtime.RuntimeFailure, runtime.interop.InteropFailure): + if time.monotonic() >= deadline: + fail('daemon-readiness-timeout') + time.sleep(0.2) + self.emit('node-start', role=role, node_epoch=runtime.canonical_digest({key: value[key] for key in EPOCH})[7:39]) + self._journal_started_roles.add(role) + return value + + def connect(self): + for role in runtime.ROLES[:-1]: + with runtime.absolute_deadline(self.remaining(180)), self.client(role) as client, self.client('relay-no-apps') as peer: + relay, node = self.nodes['relay-no-apps']['reference'], self.nodes[role]['reference'] + runtime.interop.add_peer(client, 'peer-add', relay) + runtime.interop.add_peer(peer, 'peer-add', node) + runtime.interop.wait_for_peer_connection(client, 'peer-check', relay['identity'], 150) + runtime.interop.wait_for_peer_connection(peer, 'peer-check', node['identity'], 150) + + def provision_apps(self): + for role in runtime.ROLES: + handle = InstalledAppHandle(self, role) + handle.host_bootstrap() + status, contract = handle.request('GET', '/api/v1/platform/contract') + selected = next(node for node in self.plan['nodes'] if node['role'] == role) + if status != 200 or contract.get('contract', {}).get('contractVersion') != selected['contractVersion']: + fail('product-api-binding-mismatch') + status, inventory = handle.request('GET', '/api/v1/apps') + if status != 200 or inventory.get('apps') != []: + fail('initial-app-inventory-not-empty') + selected_apps = self.private['nodes'][role]['apps'] + if not selected_apps: + continue + if len(selected_apps) != 1 or selected_apps[0]['appId'] != 'mail-prototype' or role == 'relay-no-apps': + fail('app-selection-unsupported') + status, installed = handle.request('POST', '/api/v1/apps/install', {'stagedDir': '/inputs/apps/mail-prototype'}) + if status != 201 or installed.get('app', {}).get('appId') != 'mail-prototype': + fail('signed-app-install-not-admitted') + status, _ = handle.request('POST', '/api/v1/apps/mail-prototype/start') + if status not in {200, 201}: + fail('signed-app-start-failed') + handle.observe_worker() + handle.refresh_session() + self.apps[(role, 'mail-prototype')] = handle + + def stop(self, role): + # Stop remains usable after observer deadline; controller ownership scopes it. + value = self.control.request('stop', self.handles[role]) + if value.get('state') != 'quiescent' or value.get('handle') != self.handles[role]: + fail('role-quiescence-unestablished') + if role in self._journal_started_roles: + self.emit('node-stop', role=role) + self._journal_started_roles.remove(role) + return value + + def restart(self, role): + """New manager epoch over retained role data, without extending approved runtime.""" + before = dict(self.nodes[role]) + self.stop(role) + after = self.start(role) + if (before['generation'] == after['generation'] + or before['managerInvocation'] == after['managerInvocation'] + or before['bootId'] != after['bootId']): + fail('restart-epoch-invalid') + app = self.apps.get((role, 'mail-prototype')) + if app is not None: + app.host_bootstrap() + status, value = app.request('GET', '/api/v1/apps') + if status != 200 or [entry.get('appId') for entry in value.get('apps', [])] != ['mail-prototype']: + fail('restart-installed-app-not-retained') + status, value = app.request('GET', '/api/v1/apps/mail-prototype/runtime') + if status != 200: + fail('restart-app-runtime-unavailable') + if value.get('runtime', {}).get('running') is not True: + status, _ = app.request('POST', '/api/v1/apps/mail-prototype/start') + if status not in {200, 201}: + fail('restart-app-start-failed') + app.observe_worker() + app.refresh_session() + return after + + def cleanup(self): + complete = True + for role in reversed(runtime.ROLES): + try: + self.stop(role) + except (runtime.RuntimeFailure, OSError): + complete = False + self.emit('cleanup', outcome='pass' if complete else 'fail') + return 'complete' if complete else 'reconciliation-required' + + def run_positive(self): + """Exercise normal four-role transports and real app startup; always stop owned roles.""" + try: + for role in runtime.ROLES: + self.start(role) + self.connect() + self.provision_apps() + runtime.Supervisor.content(self, 'candidate-sender', 'candidate-recipient') + before = dict(self.nodes['candidate-sender']) + after = self.restart('candidate-sender') + new_epoch = (before['generation'] != after['generation'] + and before['managerInvocation'] != after['managerInvocation'] + and before['bootId'] == after['bootId']) + runtime.Supervisor.content(self, 'candidate-sender', 'candidate-recipient') + return {'profile': PROFILE, 'classification': 'synthetic-source-build-not-original-authority', + 'topologyRoles': len(self.nodes), 'signedAppWorkers': len(self.apps), + 'newEpoch': new_epoch, + 'contentRetrieval': self.outcomes.get('network-chk')} + finally: + if self.cleanup() != 'complete': + fail('terminal-reconciliation-required') diff --git a/tools/interop/test_cross_version_workload.py b/tools/interop/test_cross_version_workload.py new file mode 100644 index 0000000000..c7bc4cfd51 --- /dev/null +++ b/tools/interop/test_cross_version_workload.py @@ -0,0 +1,285 @@ +"""Bounded protocol, invocation, and current scoped-process mapping regressions.""" +import array +import copy +import json +import os +from pathlib import Path +import socket +import tempfile +import threading +import types +import unittest +from unittest import mock + +import cross_version_workload as workload + +HANDLE = 'a' * 64 +JDK = 'sha256:' + 'b' * 64 +EPOCH = {'handle': HANDLE, 'generation': 'c' * 64, 'managerInvocation': 'd' * 32, + 'bootId': '12345678-1234-1234-1234-123456789abc'} + + +def sample(): + def row(pid, parent, namespaces, digest): + return {'hostPid': pid, 'hostParentPid': parent, 'namespacePids': namespaces, + 'startTicks': pid + 100, 'executableDigest': digest, + 'noNewPrivileges': True, 'effectiveCapabilities': 0} + return {**EPOCH, 'state': 'running', 'provenance': 'controller-kernel-sample', 'processes': [ + row(100, 1, [100, 1], 'sha256:' + '0' * 64), + row(101, 100, [101, 2], JDK), + row(102, 101, [102, 3], 'sha256:' + '1' * 64), + row(103, 102, [103, 4, 1], JDK)]} + + +class ScopedWorkerTests(unittest.TestCase): + def test_real_scoped_inner_java_is_bound_to_wrapper_hint(self): + result = workload.bind_worker(sample(), 3, JDK) + self.assertEqual(102, result['hostPid']) + self.assertEqual(103, result['javaHostPid']) + self.assertEqual(EPOCH['managerInvocation'], result['managerInvocation']) + + def test_api_host_pid_is_not_namespace_pid(self): + with self.assertRaisesRegex(workload.runtime.RuntimeFailure, 'outside-role'): + workload.bind_worker(sample(), 102, JDK) + + def test_unrelated_matching_jvm_does_not_authenticate_app(self): + value = sample() + value['processes'][-1]['hostParentPid'] = 100 + with self.assertRaisesRegex(workload.runtime.RuntimeFailure, 'descendant-not-observed'): + workload.bind_worker(value, 3, JDK) + + def test_no_nested_pid_namespace_cannot_credit_sandbox(self): + value = sample() + value['processes'][-1]['namespacePids'] = [103, 4] + with self.assertRaisesRegex(workload.runtime.RuntimeFailure, 'descendant-not-observed'): + workload.bind_worker(value, 3, JDK) + + def test_wrong_executable_or_candidate_provenance_rejected(self): + with self.assertRaises(workload.runtime.RuntimeFailure): + workload.bind_worker(sample(), 3, 'sha256:' + '9' * 64) + value = sample() + value['provenance'] = 'candidate-api' + with self.assertRaises(workload.runtime.RuntimeFailure): + workload.bind_worker(value, 3, JDK) + + def test_duplicate_or_ambiguous_pids_rejected(self): + value = sample() + value['processes'].append(copy.deepcopy(value['processes'][-1])) + with self.assertRaises(workload.runtime.RuntimeFailure): + workload.bind_worker(value, 3, JDK) + value = sample() + value['processes'][-1]['namespacePids'][1] = 3 + with self.assertRaises(workload.runtime.RuntimeFailure): + workload.bind_worker(value, 3, JDK) + + +class ObserverPolicyTests(unittest.TestCase): + def test_every_session_refresh_uses_controller_even_with_candidate_origin(self): + supervisor = types.SimpleNamespace( + private={'nodes': {'candidate-sender': {'httpPort': 19402}}}, + handles={'candidate-sender': HANDLE}, next_operation=mock.Mock(), + observe=mock.Mock(), control=types.SimpleNamespace(request=mock.Mock())) + app = workload.InstalledAppHandle(supervisor, 'candidate-sender') + forged = {'uiOrigin': 'http://127.0.0.1:23456', 'browserSessionToken': 'forged'} + with mock.patch.object(app, 'request', return_value=(200, forged)) as ordinary: + for token in ('initial-session', 'restarted-session'): + supervisor.control.request.return_value = { + 'uiOrigin': 'http://127.0.0.1:23457', 'browserSessionToken': token, + 'browserSessionExpiresAt': 123456} + self.assertIs(app, app.refresh_session()) + self.assertEqual(token, app.session) + self.assertEqual('http://127.0.0.1:23457', app.origin) + ordinary.assert_not_called() + self.assertEqual([mock.call('bootstrap-mail', HANDLE)] * 2, + supervisor.control.request.call_args_list) + self.assertEqual(4, supervisor.observe.call_count) + + def test_failed_controller_refresh_discards_previous_session(self): + supervisor = types.SimpleNamespace(private={'nodes': {'candidate-sender': {'httpPort': 19402}}}) + app = workload.InstalledAppHandle(supervisor, 'candidate-sender') + app.origin, app.session, app.session_expires_at = 'http://127.0.0.1:23457', 'old', 123 + with mock.patch.object(app, 'isolated_bootstrap', side_effect=workload.runtime.RuntimeFailure('denied')): + with self.assertRaises(workload.runtime.RuntimeFailure): + app.refresh_session() + self.assertIsNone(app.session) + self.assertIsNone(app.origin) + self.assertIsNone(app.session_expires_at) + + def test_changed_manager_epoch_rejected(self): + adapter = object.__new__(workload.InstalledWorkloadAdapter) + adapter.handles = {'candidate-sender': HANDLE} + adapter.nodes = {'candidate-sender': dict(EPOCH)} + self.assertEqual(EPOCH, adapter._bound('candidate-sender', dict(EPOCH))) + for key in workload.EPOCH: + with self.subTest(key=key), self.assertRaises(workload.runtime.RuntimeFailure): + adapter._bound('candidate-sender', {**EPOCH, key: 'reused'}) + + def test_disallowed_routes_never_open_connection(self): + supervisor = types.SimpleNamespace(private={'nodes': {'candidate-sender': {'httpPort': 19402}}}) + app = workload.InstalledAppHandle(supervisor, 'candidate-sender') + with mock.patch.object(app.opener, 'open') as opened: + for path in ('/etc/passwd', '/api/v1/apps/other/start', '/api/v1/apps?escape=true', '/api/v1/app-data/../secret'): + with self.subTest(path=path), self.assertRaises(workload.runtime.RuntimeFailure): + app.request('GET', path) + opened.assert_not_called() + + def test_dynamic_target_cannot_use_management_opener(self): + opener = workload._RoleHttp(object(), 'candidate-sender') + for target in ('http://127.0.0.1:19403/', 'http://example.invalid/', 'http://127.0.0.1:19402/#fragment'): + with self.subTest(target=target), self.assertRaises(workload.runtime.RuntimeFailure): + opener.open(workload.runtime.urllib.request.Request(target)) + + def test_historical_mail_client_is_not_a_fallback(self): + supervisor = types.SimpleNamespace(private={'nodes': {'candidate-sender': {'httpPort': 19402}}}) + with self.assertRaisesRegex(workload.runtime.RuntimeFailure, 'consumer-adapter-unsupported'): + workload.InstalledAppHandle(supervisor, 'candidate-sender').mail_client() + + def test_restart_preserves_deadline_and_rejects_reused_invocation(self): + adapter = object.__new__(workload.InstalledWorkloadAdapter) + adapter.nodes = {'candidate-sender': dict(EPOCH)} + adapter.apps = {} + adapter.deadline = 12345 + adapter.stop = mock.Mock() + adapter.start = mock.Mock(return_value={**EPOCH, 'generation': 'e' * 64, 'managerInvocation': 'f' * 32}) + result = adapter.restart('candidate-sender') + self.assertEqual('e' * 64, result['generation']) + self.assertEqual(12345, adapter.deadline) + adapter.start.return_value = {**EPOCH, 'generation': 'e' * 64} + with self.assertRaisesRegex(workload.runtime.RuntimeFailure, 'restart-epoch-invalid'): + adapter.restart('candidate-sender') + + def test_unstarted_role_cleanup_does_not_forge_node_epoch_event(self): + adapter = object.__new__(workload.InstalledWorkloadAdapter) + adapter.handles = {'candidate-sender': HANDLE} + adapter._journal_started_roles = set() + adapter.control = types.SimpleNamespace(request=mock.Mock(return_value={'state': 'quiescent', 'handle': HANDLE})) + adapter.emit = mock.Mock() + adapter.stop('candidate-sender') + adapter.emit.assert_not_called() + adapter._journal_started_roles.add('candidate-sender') + adapter.stop('candidate-sender') + adapter.emit.assert_called_once_with('node-stop', role='candidate-sender') + self.assertFalse(adapter._journal_started_roles) + + def test_request_shape_rejected_before_socket_allocation(self): + with mock.patch.object(workload.socket, 'socket') as factory: + for method, handle in (('exec', HANDLE), ('observe', '/proc/1'), ('connect-19403', HANDLE)): + with self.assertRaises(workload.runtime.RuntimeFailure): + workload.WorkloadClient().request(method, handle) + factory.assert_not_called() + + +@unittest.skipUnless(os.geteuid() == 0, 'actual UNIX controller peer must be root') +class TransportTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory(prefix='workload-client-') + self.addCleanup(self.temporary.cleanup) + self.path = str(Path(self.temporary.name) / 'control.sock') + patch = mock.patch.object(workload, 'SOCKET', self.path) + patch.start() + self.addCleanup(patch.stop) + + def exchange(self, payload, method='observe', passed=None): + listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + listener.bind(self.path) + listener.listen(1) + listener.settimeout(5) + seen = [] + errors = [] + + def serve(): + try: + with listener.accept()[0] as channel: + channel.settimeout(5) + request = bytearray() + while not request.endswith(b'\n'): + request.extend(channel.recv(4096)) + seen.append(json.loads(request)) + ancillary = [] if passed is None else [(socket.SOL_SOCKET, socket.SCM_RIGHTS, array.array('i', [passed.fileno()]))] + channel.sendmsg([payload], ancillary) + except BaseException as error: + errors.append(error) + + thread = threading.Thread(target=serve) + thread.start() + try: + result = workload.WorkloadClient().request(method, HANDLE) + self.assertEqual([{'method': method, 'handle': HANDLE}], seen) + return result + finally: + thread.join(5) + listener.close() + self.assertFalse(thread.is_alive()) + if errors: + raise errors[0] + + def test_actual_root_peer_and_bounded_json_roundtrip(self): + self.assertEqual(EPOCH, self.exchange(json.dumps(EPOCH).encode() + b'\n')) + + def test_duplicate_fields_rejected(self): + with self.assertRaises(workload.runtime.RuntimeFailure): + self.exchange(b'{"state":"running","state":"quiescent"}\n') + + def test_missing_newline_is_not_a_response(self): + with self.assertRaises(workload.runtime.RuntimeFailure): + self.exchange(b'{}') + + def test_error_response_rejected(self): + with self.assertRaises(workload.runtime.RuntimeFailure): + self.exchange(b'{"error":"restricted-workload-request-failed"}\n') + + def test_actual_tcp_descriptor_handoff(self): + with socket.socket() as server: + server.bind(('127.0.0.1', 0)) + server.listen(1) + with socket.create_connection(server.getsockname()) as passed, server.accept()[0] as peer: + value, connected = self.exchange(b'{"status":"connected"}\n', 'connect-fcp', passed) + with connected: + connected.sendall(b'fixed-endpoint') + self.assertEqual(b'fixed-endpoint', peer.recv(32)) + self.assertFalse(os.get_inheritable(connected.fileno())) + self.assertEqual({'status': 'connected'}, value) + + def test_unexpected_descriptor_on_observation_is_rejected(self): + left, right = socket.socketpair() + try: + with self.assertRaises(workload.runtime.RuntimeFailure): + self.exchange(b'{}\n', passed=left) + finally: + left.close() + right.close() + + def test_unix_socket_is_not_accepted_as_role_tcp_connection(self): + left, right = socket.socketpair() + try: + with self.assertRaises(workload.runtime.RuntimeFailure): + self.exchange(b'{"status":"connected"}\n', 'connect-http', left) + finally: + left.close() + right.close() + + def test_connection_method_requires_descriptor(self): + with self.assertRaises(workload.runtime.RuntimeFailure): + self.exchange(b'{"status":"connected"}\n', 'connect-http') + + +class FcpParserTests(unittest.TestCase): + def test_handshake_uses_existing_fcp_parser_on_supplied_socket(self): + observer, daemon = socket.socketpair() + with tempfile.TemporaryDirectory() as temporary: + daemon.sendall(b'NodeHello\nVersion=test\nEndMessage\n') + charges = [] + client = workload.ConnectedFcpClient(observer, 'fixed-client', Path(temporary) / 'fcp.log', lambda: charges.append(1)) + try: + sent = daemon.recv(4096) + self.assertIn(b'ClientHello\n', sent) + self.assertIn(b'Name=fixed-client\n', sent) + self.assertEqual('NodeHello', client.hello.name) + self.assertEqual([1], charges) + finally: + client.close() + daemon.close() + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/release-certification/protected/restricted_workload.py b/tools/release-certification/protected/restricted_workload.py new file mode 100644 index 0000000000..48512c7fe2 --- /dev/null +++ b/tools/release-certification/protected/restricted_workload.py @@ -0,0 +1,416 @@ +"""Closed, controller-owned four-role workload lifecycle. + +The API is internal to installed trusted code. It never accepts executable paths, unit +properties, PIDs or namespace names from an observer. Provisioning requires a root-owned +selection prepared by the owning authority; possession of this API is not original approval. +The restricted provider channel remains closed pending both installed acceptance contracts. +""" +from contextlib import contextmanager +import fcntl +import hashlib +import json +import os +from pathlib import Path +import pwd +import re +import secrets +import select +import stat +import subprocess +import time + +from restricted_native_launcher import tree_identity, _trusted_read + +ROOT = Path('/var/lib/cryptad-restricted-workload') +ROLES = ('candidate-sender', 'candidate-recipient', 'previous', 'relay-no-apps') +PROFILE = 'debian13-systemd257-workload-v1' +ENV = {'PATH': '/usr/sbin:/usr/bin:/sbin:/bin', 'LANG': 'C.UTF-8'} +MAX_SECONDS = 3600 + + +class WorkloadError(ValueError): + """A fixed diagnostic, never candidate output.""" + + +class ProcessExitedDuringSample(ProcessLookupError): + """Pidfd-confirmed exit: skip in rosters, reject when a required binding is lost.""" + + +def reject(code='boundary-rejected'): + raise WorkloadError('restricted-workload-' + code) + + +def secured(path, *, directory=False): + path = Path(path) + for entry in (path, *path.parents): + info = entry.lstat() + if info.st_uid != 0 or info.st_mode & 0o022 or stat.S_ISLNK(info.st_mode): + reject('untrusted-authority') + if directory and not path.is_dir(): + reject('untrusted-authority') + return path + + +def read(path): + return _trusted_read(secured(path)) + + +def execution_record_digest(): + path = secured(Path('/opt/cryptad-cross-version/restricted-execution.json')) + checksum, size = hashlib.sha256(), 0 + descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) + try: + info = os.fstat(descriptor) + if not stat.S_ISREG(info.st_mode) or info.st_nlink != 1 or not 1 <= info.st_size <= 32 * 1024**2: + reject('execution-record-invalid') + while True: + block = os.read(descriptor, 65536) + if not block: + break + size += len(block) + if size > info.st_size: + reject('execution-record-changed') + checksum.update(block) + after = path.stat() + if size != info.st_size or any(getattr(info, field) != getattr(after, field) + for field in ('st_dev', 'st_ino', 'st_mtime_ns', 'st_ctime_ns')): + reject('execution-record-changed') + return checksum.hexdigest() + finally: + os.close(descriptor) + + +def write(path, value, *, create=False, mode=0o600): + """Root-only durable replacement; a partial intent is never erased on failure.""" + secured(path.parent, directory=True) + raw = json.dumps(value, sort_keys=True, separators=(',', ':'), allow_nan=False).encode() + if len(raw) > 65536: + reject('record-limit') + temporary = path.with_name('.' + path.name + '-' + secrets.token_hex(16)) + fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, mode) + try: + with os.fdopen(fd, 'wb') as stream: + # Publication permissions are part of the record contract, not the + # administrator's umask (role launch records must remain readable). + os.fchmod(stream.fileno(), mode) + stream.write(raw) + stream.flush() + os.fsync(stream.fileno()) + if create: + os.link(temporary, path, follow_symlinks=False) + temporary.unlink() + else: + os.replace(temporary, path) + parent = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) + try: + os.fsync(parent) + finally: + os.close(parent) + finally: + if temporary.exists(): + temporary.unlink() + + +def unit(role): + if role not in ROLES: + reject('role-invalid') + return 'cryptad-workload@' + role + '.service' + + +def account(role): + unit(role) + row = pwd.getpwnam('cryptad-role-' + role) + if row.pw_uid == 0 or row.pw_gid == 0 or row.pw_shell != '/usr/sbin/nologin': + reject('role-account-invalid') + return row + + +def boot(): + return Path('/proc/sys/kernel/random/boot_id').read_text().strip() + + +@contextmanager +def locked(): + if os.geteuid() != 0: + reject('root-controller-required') + secured(ROOT, directory=True) + fd = os.open(ROOT / 'lease', os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600) + try: + info = os.fstat(fd) + if not stat.S_ISREG(info.st_mode) or info.st_nlink != 1 or info.st_uid != 0: + reject('lease-invalid') + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + yield + finally: + os.close(fd) + + +def manager(role, operation): + if operation not in {'start', 'stop', 'show'}: + reject('manager-operation-invalid') + command = ['/usr/bin/systemctl', operation, unit(role)] + if operation == 'show': + command += ['--property=InvocationID,ActiveState,SubState,ControlGroup,MainPID,Result', '--no-pager'] + result = subprocess.run(command, stdin=subprocess.DEVNULL, capture_output=True, + env=ENV, timeout=35, check=True) + if len(result.stdout) > 8192 or len(result.stderr) > 8192: + reject('manager-output-limit') + if operation == 'show': + value = dict(line.split('=', 1) for line in result.stdout.decode().splitlines() if '=' in line) + if set(value) != {'InvocationID', 'ActiveState', 'SubState', 'ControlGroup', 'MainPID', 'Result'}: + reject('manager-observation-invalid') + return value + + +def group(role): + # The explicit Slice in the fixed template avoids systemd's default template slice. + return Path('/sys/fs/cgroup/system.slice') / unit(role) + + +def quiescent(role): + path = group(role) + if not path.exists(): + return True + values = dict(line.split() for line in (path / 'cgroup.events').read_text().splitlines()) + return values.get('populated') == '0' + + +def retained(handle): + if not isinstance(handle, str) or re.fullmatch('[a-f0-9]{64}', handle) is None: + reject('handle-invalid') + campaign = read(ROOT / 'campaign.json') + matches = [role for role in ROLES if campaign['handles'].get(role) == handle] + if len(matches) != 1: + reject('handle-unavailable') + role = matches[0] + record = read(ROOT / 'authority' / (role + '.json')) + if record['handle'] != handle or record['campaign'] != campaign['generation']: + reject('record-substituted') + return campaign, role, record + + +def current(campaign): + if campaign.get('state') != 'prepared': + reject('preparation-incomplete') + if campaign['bootId'] != boot(): + reject('boot-changed-reconciliation-required') + if os.path.lexists(ROOT / 'revoked') or time.monotonic_ns() >= campaign['deadlineMonotonicNs']: + reject('authority-expired-or-revoked') + if execution_record_digest() != campaign['executionRecordDigest']: + reject('installed-implementation-changed') + selection = read(ROOT / 'selection.json') + if hashlib.sha256(json.dumps(selection, sort_keys=True, separators=(',', ':')).encode()).hexdigest() != campaign['selectionDigest']: + reject('selection-changed') + + +def admit(campaign): + current(campaign) + consumed = campaign.get('usedOperations', 0) + if type(consumed) is not int or consumed >= campaign['maxOperations']: + reject('operation-budget-exhausted') + campaign['usedOperations'] = consumed + 1 + write(ROOT / 'campaign.json', campaign) + + +def launched(role, record, state): + """Reconcile the manager's privileged ExecStartPre receipt after a lost response.""" + marker = read(ROOT / 'authority' / (role + '-start.json')) + if (marker['generation'] != record['generation'] or marker['bootId'] != record['bootId'] + or marker['managerInvocation'] != state['InvocationID']): + reject('launch-reconciliation-required') + record.update(managerInvocation=marker['managerInvocation'], cgroupIdentity=marker['cgroupIdentity']) + exact(role, record, state) + return record + + +def exact(role, record, state): + if (record['bootId'] != boot() or not record.get('managerInvocation') + or state['InvocationID'] != record['managerInvocation'] + or state['ControlGroup'] != '/system.slice/' + unit(role)): + reject('invocation-changed-reconciliation-required') + info = group(role).stat() + if [info.st_dev, info.st_ino] != record.get('cgroupIdentity'): + reject('cgroup-changed-reconciliation-required') + + +def start(handle): + """Return retained invocation promptly; never hold provider credentials for the soak.""" + with locked(): + campaign, role, record = retained(handle) + admit(campaign) + state = manager(role, 'show') + if record['state'] == 'launching': + launched(role, record, state) + record['state'] = 'running' + write(ROOT / 'authority' / (role + '.json'), record) + if record['state'] == 'running': + exact(role, record, state) + if state['ActiveState'] == 'active': + return summary(role, record) + reject('running-service-lost') + if record['state'] not in {'prepared', 'quiescent'}: + reject('reconciliation-required') + if state['ActiveState'] not in {'inactive', 'failed'} or not quiescent(role): + reject('role-slot-busy') + inputs = ROOT / 'roles' / role + expected = read(inputs / 'launch.json') + for name, identity in expected['inputs'].items(): + if tree_identity(inputs / name, deadline=campaign['deadlineMonotonicNs'] / 1e9) != identity: + reject('input-substituted') + current(campaign) + record.update(state='launching', generation=secrets.token_hex(32), managerInvocation=None, + cgroupIdentity=None, stopReason=None) + write(ROOT / 'authority' / (role + '.json'), record) + # If this call/response is lost, intent remains launching. A retry cannot duplicate it. + manager(role, 'start') + state = manager(role, 'show') + if (state['ActiveState'] != 'active' or re.fullmatch('[0-9a-f]{32}', state['InvocationID']) is None + or state['ControlGroup'] != '/system.slice/' + unit(role)): + reject('launch-reconciliation-required') + launched(role, record, state) + record.update(state='running') + write(ROOT / 'authority' / (role + '.json'), record) + try: + current(campaign) + except WorkloadError: + _stop(role, record, 'authority-changed') + raise + return summary(role, record) + + +def summary(role, record): + """Private observer handoff only; never uploaded as a public report.""" + return {key: record[key] for key in ('handle', 'state', 'generation', 'managerInvocation', 'bootId')} + + +def _stop(role, record, reason): + state = manager(role, 'show') + if state['ActiveState'] in {'inactive', 'failed'} and quiescent(role): + record.update(state='quiescent', stopReason=reason) + write(ROOT / 'authority' / (role + '.json'), record) + return summary(role, record) + if record['state'] == 'launching': + launched(role, record, state) + exact(role, record, state) + record.update(state='stopping', stopReason=reason) + write(ROOT / 'authority' / (role + '.json'), record) + manager(role, 'stop') + after = manager(role, 'show') + if after['ActiveState'] not in {'inactive', 'failed'} or not quiescent(role): + record['state'] = 'reconciliation-required' + write(ROOT / 'authority' / (role + '.json'), record) + reject('descendants-not-quiescent') + record['state'] = 'quiescent' + write(ROOT / 'authority' / (role + '.json'), record) + return summary(role, record) + + +def stop(handle): + """Ownership-only cleanup: deliberately independent of current execution approval.""" + with locked(): + _campaign, role, record = retained(handle) + return _stop(role, record, 'requested') + + +def reconcile(): + """Stop observed owned invocations after controller/observer loss; retain uncertainty.""" + with locked(): + campaign = read(ROOT / 'campaign.json') + failures = [] + for role in ROLES: + try: + _campaign, _role, record = retained(campaign['handles'][role]) + _stop(role, record, 'controller-reconciliation') + except (OSError, ValueError, subprocess.SubprocessError): + failures.append(role) + if failures: + reject('reconciliation-required') + + +def _process(pid, role, expected_uid): + descriptor = os.pidfd_open(pid) + try: + result = _process_sample(pid, role, expected_uid) + if select.select([descriptor], [], [], 0)[0]: + raise ProcessExitedDuringSample('process-exited-during-sample') + return result + finally: + os.close(descriptor) + + +def _process_sample(pid, role, expected_uid): + """A fixed kernel-only projection. No cmdline, environment or caller-selected PID.""" + path = Path('/proc') / str(pid) + before = (path / 'stat').read_text().rsplit(')', 1)[1].split() + status = dict(line.split(':', 1) for line in (path / 'status').read_text().splitlines() if ':' in line) + if (set(map(int, status['Uid'].split())) != {expected_uid} + or (path / 'cgroup').read_text().strip() != '0::/system.slice/' + unit(role)): + reject('process-scope-changed') + executable = path / 'exe' + digest = hashlib.sha256() + with executable.open('rb') as source: + executable_identity = os.fstat(source.fileno()) + size = 0 + for block in iter(lambda: source.read(1024 * 1024), b''): + size += len(block) + if size > 64 * 1024 * 1024: + reject('executable-sample-limit') + digest.update(block) + after_executable = executable.stat() + if ((executable_identity.st_dev, executable_identity.st_ino, executable_identity.st_size, + executable_identity.st_mtime_ns, executable_identity.st_ctime_ns) + != (after_executable.st_dev, after_executable.st_ino, after_executable.st_size, + after_executable.st_mtime_ns, after_executable.st_ctime_ns)): + reject('process-executable-changed') + after = (path / 'stat').read_text().rsplit(')', 1)[1].split() + if before[19] != after[19]: + reject('process-epoch-changed') + return {'hostPid': pid, 'hostParentPid': int(before[1]), + 'pidNamespace': os.readlink(path / 'ns/pid'), + 'namespacePids': list(map(int, status['NSpid'].split())), + 'startTicks': int(before[19]), 'executableDigest': 'sha256:' + digest.hexdigest(), + 'rssBytes': int(status['VmRSS'].split()[0]) * 1024 if 'VmRSS' in status else None, + 'processThreads': int(status['Threads']), 'noNewPrivileges': status['NoNewPrivs'].strip() == '1', + 'effectiveCapabilities': int(status['CapEff'].strip(), 16)} + + +def observe(handle): + with locked(): + campaign, role, record = retained(handle) + admit(campaign) + before = manager(role, 'show') + exact(role, record, before) + root = group(role) + # No cgroup delegation is permitted. A nested group is a profile violation. + if any(path.is_dir() for path in root.iterdir()): + reject('unexpected-descendant-cgroup') + pids = (root / 'cgroup.procs').read_text().split() + if len(pids) > 512: + reject('task-limit') + processes, exited = [], 0 + for pid in sorted(set(map(int, pids))): + try: + processes.append(_process(pid, role, account(role).pw_uid)) + except (FileNotFoundError, ProcessLookupError): + exited += 1 + exact(role, record, manager(role, 'show')) + return {**summary(role, record), 'provenance': 'controller-kernel-sample', + 'processes': processes, 'exitedDuringSample': exited, + 'cgroupMemoryBytes': int((root / 'memory.current').read_text()), + 'cgroupTasks': int((root / 'pids.current').read_text())} + + +def connect(handle, endpoint): + with locked(): + campaign, role, record = retained(handle) + admit(campaign) + exact(role, record, manager(role, 'show')) + from restricted_workload_network import connect as connection + result = connection(role, endpoint) + try: + current(campaign) + exact(role, record, manager(role, 'show')) + return result + except BaseException: + result.close() + raise diff --git a/tools/release-certification/protected/restricted_workload_app.py b/tools/release-certification/protected/restricted_workload_app.py new file mode 100644 index 0000000000..37e386838c --- /dev/null +++ b/tools/release-certification/protected/restricted_workload_app.py @@ -0,0 +1,214 @@ +"""Bound a reported Mail worker and dynamic UI listener to one owned role's kernel scope. + +The installed controller calls this while holding the workload lease and an absolute +request deadline, before and after its semantic bootstrap exchange. Candidate runtime JSON +is only a hint. This projection proves process/namespace/ancestry and socket ownership; +it does not authenticate application classes or make candidate telemetry truthful. +""" +import os +from pathlib import Path +import re +import time + +import restricted_workload as workload + +PROC = Path('/proc') +MAX_TASKS = 512 +MAX_FDS = 1024 +MAX_TCP_BYTES = 512 * 1024 +MAX_SECONDS = 5 +IDENTITY_FIELDS = ('hostPid', 'hostParentPid', 'startTicks', 'pidNamespace', 'namespacePids', + 'executableDigest', 'noNewPrivileges', 'effectiveCapabilities') + + +def _reject(): + workload.reject('app-kernel-binding-unavailable') + + +def _time(deadline): + if time.monotonic() >= deadline: + _reject() + + +def _read(path, maximum, deadline): + """Only internally derived cgroup/proc files enter this bounded reader.""" + _time(deadline) + descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK | os.O_CLOEXEC) + try: + pieces, remaining = [], maximum + 1 + while remaining: + _time(deadline) + block = os.read(descriptor, min(65536, remaining)) + if not block: + break + pieces.append(block) + remaining -= len(block) + if remaining == 0: + _reject() + return b''.join(pieces).decode('ascii') + finally: + os.close(descriptor) + + +def _members(role, deadline): + root = workload.group(role) + # Delegation is forbidden. Nested membership would require a separately reviewed profile. + with os.scandir(root) as entries: + for count, entry in enumerate(entries, 1): + _time(deadline) + if count > 256 or entry.is_dir(follow_symlinks=False): + _reject() + values = _read(root / 'cgroup.procs', 8192, deadline).split() + if (not values or len(values) > MAX_TASKS + or any(re.fullmatch('[1-9][0-9]{0,9}', value) is None for value in values)): + _reject() + return sorted(set(map(int, values))) + + +def _epoch(process): + return {name: process[name] for name in IDENTITY_FIELDS} + + +def _descendant(pid, ancestor, processes): + seen = set() + for _ in range(64): + if pid == ancestor: + return True + if pid in seen or pid not in processes: + return False + seen.add(pid) + pid = processes[pid]['hostParentPid'] + return False + + +def _listener(daemon, port, deadline): + """Resolve only the daemon's own loopback listener, never an arbitrary candidate path.""" + path = PROC / str(daemon['hostPid']) + selected = [] + # The reference JDK uses an IPv4-mapped AF_INET6 socket even for an explicit + # InetAddress(127.0.0.1). It therefore appears in tcp6, while the logical endpoint + # and namespace-confined connector remain IPv4 loopback. No :: or wildcard binds pass. + for table, loopback in (('tcp', '0100007F'), + ('tcp6', '0000000000000000FFFF00000100007F')): + try: + rows = _read(path / 'net' / table, MAX_TCP_BYTES, deadline).splitlines() + except FileNotFoundError: + if table == 'tcp6': + continue + raise + wanted = loopback + ':' + format(port, '04X') + if not rows or len(rows) > 4097: + _reject() + for row in rows[1:]: + fields = row.split() + if len(fields) < 10: + _reject() + if fields[1].upper() == wanted and fields[3] == '0A': + if re.fullmatch('[1-9][0-9]{0,19}', fields[9]) is None: + _reject() + selected.append(int(fields[9])) + # Ambiguous SO_REUSEPORT listeners do not satisfy this fixed binding. + if len(selected) != 1: + _reject() + inode, found = selected[0], False + with os.scandir(path / 'fd') as entries: + for count, entry in enumerate(entries, 1): + _time(deadline) + if count > MAX_FDS or not entry.name.isdecimal(): + _reject() + try: + target = os.readlink(path / 'fd' / entry.name) + except FileNotFoundError: + continue + if target == 'socket:[' + str(inode) + ']': + found = True + if not found: + _reject() + return inode + + +def _projection(process): + return {name: process[name] for name in ('hostPid', 'startTicks', 'pidNamespace')} + + +def require_app_listener(role, runtime_dict, port): + """Return a private, fixed binding for current Mail JVM, daemon and UI listener. + + ``runtime_dict`` is the untrusted ``runtime`` member from the fixed Mail runtime API; + its PID is interpreted in the outer daemon namespace, never as a host PID selector. + The caller supplies the dynamic port only after validating the fixed launch redirect. + No returned process identifiers belong in public acceptance output. + """ + deadline = time.monotonic() + MAX_SECONDS + if (role not in workload.ROLES or type(port) is not int or not 1024 <= port <= 65535 + or not isinstance(runtime_dict, dict) or runtime_dict.get('running') is not True + or type(runtime_dict.get('pid')) is not int + or not 1 <= runtime_dict['pid'] <= 2147483647): + _reject() + sandbox = runtime_dict.get('sandbox') + if (not isinstance(sandbox, dict) or sandbox.get('provider') != 'bubblewrap' + or sandbox.get('active') is not True): + _reject() + try: + launch = workload.read(workload.ROOT / 'roles' / role / 'launch.json') + java_digest = launch.get('javaDigest') + if not isinstance(java_digest, str) or re.fullmatch('sha256:[0-9a-f]{64}', java_digest) is None: + _reject() + uid = workload.account(role).pw_uid + processes = {} + for pid in _members(role, deadline): + _time(deadline) + try: + processes[pid] = workload._process(pid, role, uid) + except (FileNotFoundError, ProcessLookupError): + continue + daemons = [value for value in processes.values() + if value['executableDigest'] == java_digest and len(value['namespacePids']) == 2] + if len(daemons) != 1: + _reject() + daemon = daemons[0] + workers = [value for value in processes.values() + if len(value['namespacePids']) >= 2 and value['namespacePids'][1] == runtime_dict['pid']] + if len(workers) != 1: + _reject() + worker = workers[0] + if (worker['hostPid'] == daemon['hostPid'] + or len(worker['namespacePids']) != 2 + or worker['pidNamespace'] != daemon['pidNamespace'] + or not _descendant(worker['hostPid'], daemon['hostPid'], processes)): + _reject() + app_jvms = [value for value in processes.values() + if value['executableDigest'] == java_digest + and len(value['namespacePids']) >= 3 + and value['pidNamespace'] != daemon['pidNamespace'] + and _descendant(value['hostPid'], worker['hostPid'], processes)] + if len(app_jvms) != 1: + _reject() + app = app_jvms[0] + if any(value['noNewPrivileges'] is not True or value['effectiveCapabilities'] != 0 + for value in (daemon, worker, app)): + _reject() + inode = _listener(daemon, port, deadline) + # Recheck each scoped ancestor as well as leaf identities; a reparent or PID reuse + # invalidates the relation. Cgroup membership is independently rechecked by _process. + required = set() + for origin in (worker['hostPid'], app['hostPid']): + cursor = origin + for _ in range(64): + required.add(cursor) + if cursor == daemon['hostPid']: + break + cursor = processes[cursor]['hostParentPid'] + else: + _reject() + for pid in sorted(required): + _time(deadline) + if _epoch(workload._process(pid, role, uid)) != _epoch(processes[pid]): + _reject() + if _listener(daemon, port, deadline) != inode: + _reject() + _time(deadline) + return {'daemon': _projection(daemon), 'worker': _projection(worker), + 'appJvm': _projection(app), 'listenerInode': inode} + except (OSError, ValueError, KeyError, IndexError, TypeError): + _reject() diff --git a/tools/release-certification/protected/restricted_workload_controller.py b/tools/release-certification/protected/restricted_workload_controller.py new file mode 100644 index 0000000000..b466199696 --- /dev/null +++ b/tools/release-certification/protected/restricted_workload_controller.py @@ -0,0 +1,223 @@ +#!/usr/bin/python3 +"""Tokenless fixed role controller. This service has no original-provider credentials. + +Only the observer UID can use the private socket. Each request names an already retained +role handle and one fixed method. Service death stops bound role units through systemd. +""" +import array +from contextlib import contextmanager +import http.client +import json +import os +from pathlib import Path +import pwd +import re +import select +import signal +import socket +import struct +import sys +import time +import urllib.parse + +if __package__ in (None, ''): + sys.path.insert(0, str(Path(__file__).resolve().parent)) +import restricted_workload as workload + +SOCKET = Path('/run/cryptad-workload/control.sock') +METHODS = frozenset({'start', 'observe', 'stop', 'connect-fcp', 'connect-http', 'bootstrap-mail'}) + + +@contextmanager +def deadline(seconds=15): + def expired(_signum, _frame): + workload.reject('request-deadline') + previous = signal.signal(signal.SIGALRM, expired) + started = time.monotonic() + outer = signal.getitimer(signal.ITIMER_REAL) + signal.setitimer(signal.ITIMER_REAL, min(seconds, outer[0]) if outer[0] else seconds) + try: + yield + finally: + signal.setitimer(signal.ITIMER_REAL, max(.001, outer[0] - (time.monotonic() - started)) if outer[0] else 0, + outer[1]) + signal.signal(signal.SIGALRM, previous) + + +def request(raw): + def pairs(rows): + value = {} + for name, entry in rows: + if name in value: + workload.reject('duplicate-request-field') + value[name] = entry + return value + if not 1 <= len(raw) <= 256: + workload.reject('request-limit') + value = json.loads(raw, object_pairs_hook=pairs) + if (not isinstance(value, dict) or set(value) != {'method', 'handle'} + or not isinstance(value['method'], str) or value['method'] not in METHODS + or not isinstance(value['handle'], str) + or re.fullmatch('[a-f0-9]{64}', value['handle']) is None): + workload.reject('request-invalid') + return value + + +def _http(sock, path, headers): + connection = http.client.HTTPConnection('127.0.0.1', sock.getpeername()[1], timeout=5) + connection.sock = sock + try: + connection.request('GET', path, headers=headers) + response = connection.getresponse() + body = response.read(65537) + if len(body) > 65536: + workload.reject('bootstrap-output-limit') + return response.status, dict(response.getheaders()), body + finally: + connection.close() + + +def bootstrap(handle): + """Resolve dynamic app origin only from this role's current daemon launch proof.""" + with workload.locked(), deadline(): + campaign, role, record = workload.retained(handle) + workload.admit(campaign) + workload.exact(role, record, workload.manager(role, 'show')) + from restricted_workload_storage import verify_installed_app + launch = workload.read(workload.ROOT / 'roles' / role / 'launch.json') + verify_installed_app(workload.ROOT / 'state' / role / 'data/node/apps/installed/mail-prototype', + launch['mailIdentity'], workload.ROOT / 'authority' / (role + '-app-snapshot'), + min(time.monotonic() + 5, campaign['deadlineMonotonicNs'] / 1e9)) + from restricted_workload_network import connect, _connect_port, HTTP_PORT + status, headers, _body = _http(connect(role, 'http'), + '/apps/mail-prototype/?cryptadIsolatedLaunch', {'Accept': 'text/html'}) + if status not in (301, 302, 303, 307, 308): + workload.reject('app-launch-unavailable') + location = next((value for key, value in headers.items() if key.lower() == 'location'), '') + selected = urllib.parse.urlsplit(location) + if (selected.scheme != 'http' or selected.hostname != '127.0.0.1' + or selected.username is not None or selected.password is not None + or selected.port is None or not 1024 <= selected.port <= 65535 + or selected.port == HTTP_PORT or selected.path not in ('/', '/static/') or selected.query): + workload.reject('app-origin-invalid') + fragment = urllib.parse.parse_qs(selected.fragment, strict_parsing=True) + if (set(fragment) != {'cryptadBootstrapNonce'} or len(fragment['cryptadBootstrapNonce']) != 1 + or re.fullmatch('[A-Za-z0-9_-]{16,512}', fragment['cryptadBootstrapNonce'][0]) is None): + workload.reject('app-nonce-invalid') + from restricted_workload_app import require_app_listener + runtime_status, _headers, runtime_body = _http(connect(role, 'http'), + '/api/v1/apps/mail-prototype/runtime', {'Accept': 'application/json'}) + if runtime_status != 200: + workload.reject('app-runtime-unavailable') + binding = require_app_listener(role, json.loads(runtime_body).get('runtime'), selected.port) + origin = 'http://127.0.0.1:' + str(selected.port) + status, _headers, body = _http(_connect_port(role, selected.port), + '/.well-known/cryptad-bootstrap.json', {'Accept': 'application/json', 'Origin': origin, + 'X-Crypta-App-Bootstrap-Nonce': fragment['cryptadBootstrapNonce'][0]}) + value = json.loads(body) + if (status != 200 or not isinstance(value, dict) or value.get('uiOrigin') != origin + or not isinstance(value.get('browserSessionToken'), str) + or not 1 <= len(value['browserSessionToken']) <= 4096): + workload.reject('app-bootstrap-invalid') + workload.current(campaign) + workload.exact(role, record, workload.manager(role, 'show')) + runtime_status, _headers, runtime_body = _http(connect(role, 'http'), + '/api/v1/apps/mail-prototype/runtime', {'Accept': 'application/json'}) + if (runtime_status != 200 or require_app_listener(role, json.loads(runtime_body).get('runtime'), + selected.port) != binding): + workload.reject('app-worker-changed') + # A private response; never inserted into an acceptance/public result. + return {key: value.get(key) for key in ('uiOrigin', 'browserSessionToken', 'browserSessionExpiresAt')} + + +def serve(connection, expected_uid): + credentials = connection.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, struct.calcsize('3i')) + _pid, uid, _gid = struct.unpack('3i', credentials) + if uid != expected_uid: + workload.reject('peer-denied') + connection.settimeout(5) + raw = bytearray() + while b'\n' not in raw: + block = connection.recv(257 - len(raw)) + if not block: + workload.reject('request-incomplete') + raw.extend(block) + if len(raw) > 256: + workload.reject('request-limit') + if raw[-1:] != b'\n' or raw.count(b'\n') != 1: + workload.reject('request-framing-invalid') + selected = request(bytes(raw[:-1])) + method, handle = selected['method'], selected['handle'] + if method.startswith('connect-'): + with workload.connect(handle, method.removeprefix('connect-')) as stream: + rights = array.array('i', [stream.fileno()]) + connection.sendmsg([b'{"status":"connected"}\n'], [(socket.SOL_SOCKET, socket.SCM_RIGHTS, rights)]) + else: + operation = {'start': workload.start, 'stop': workload.stop, 'observe': workload.observe, + 'bootstrap-mail': bootstrap}[method] + result = operation(handle) + raw = json.dumps(result, separators=(',', ':'), allow_nan=False).encode() + b'\n' + if len(raw) > 1024 * 1024: + workload.reject('response-limit') + connection.sendall(raw) + return method + + +def main(): + if len(sys.argv) != 1 or not sys.flags.isolated or not sys.flags.no_site or os.geteuid() != 0: + workload.reject('fixed-entry-required') + os.environ.clear() + os.umask(0o077) + workload.secured(Path(__file__)) + # Verify the same immutable installed code/dependency closure as the existing resolver. + sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'restricted')) + import installation + installation.verify_execution() + observer = pwd.getpwnam('cryptad-soak') + if (workload.ROOT / 'campaign.json').exists(): + workload.reconcile() + if SOCKET.exists() or SOCKET.is_symlink(): + workload.reject('socket-reconciliation-required') + server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + server.bind(str(SOCKET)) + SOCKET.chmod(0o600) + os.chown(SOCKET, observer.pw_uid, observer.pw_gid) + server.listen(8) + last_observer = time.monotonic() + try: + while True: + if (workload.ROOT / 'campaign.json').exists(): + try: + workload.current(workload.read(workload.ROOT / 'campaign.json')) + # Fixed FCP transactions may take 180 seconds without another RPC. + if time.monotonic() - last_observer > 240: + workload.reject('observer-lost') + except ValueError: + workload.reconcile() + return 1 + ready, _, _ = select.select([server], [], [], 1) + if not ready: + continue + connection, _ = server.accept() + with connection: + try: + with deadline(45): + serve(connection, observer.pw_uid) + last_observer = time.monotonic() + except (OSError, ValueError, KeyError, TypeError): + try: + connection.sendall(b'{"error":"restricted-workload-request-failed"}\n') + except OSError: + pass + finally: + server.close() + workload.reconcile() + SOCKET.unlink() + + +if __name__ == '__main__': + try: + result = main() + except Exception: + result = 1 + raise SystemExit(result) diff --git a/tools/release-certification/protected/restricted_workload_launcher.py b/tools/release-certification/protected/restricted_workload_launcher.py new file mode 100644 index 0000000000..c95f28fe90 --- /dev/null +++ b/tools/release-certification/protected/restricted_workload_launcher.py @@ -0,0 +1,86 @@ +#!/usr/bin/python3 +"""Fixed unprivileged entry point for one of four installed role services.""" +import os +from pathlib import Path +import pwd +import subprocess +import sys +import time + +ROOT = Path('/var/lib/cryptad-restricted-workload') +ROLES = ('candidate-sender', 'candidate-recipient', 'previous', 'relay-no-apps') + + +def command(role): + if role not in ROLES: + raise ValueError('workload-role-invalid') + inputs, state = ROOT / 'roles' / role, ROOT / 'state' / role + arguments = ['/usr/bin/bwrap', '--unshare-user', '--unshare-pid', '--unshare-ipc', + '--unshare-uts', '--die-with-parent', '--new-session', '--cap-drop', 'ALL', + '--ro-bind', '/usr', '/usr', '--symlink', 'usr/bin', '/bin', + '--symlink', 'usr/lib', '/lib', '--symlink', 'usr/lib64', '/lib64', + '--proc', '/proc', '--dev', '/dev', '--ro-bind', str(inputs / 'package'), '/package', + '--ro-bind', str(inputs / 'jdk'), '/jdk', '--ro-bind', str(inputs / 'apps'), '/inputs/apps', + '--ro-bind', str(inputs / 'public'), '/inputs/public', '--bind', str(state), '/node', + '--bind', str(state / 'tmp'), '/tmp', '--chdir', '/node', + '--remount-ro', '/dev/pts', '--remount-ro', '/dev', '--remount-ro', '/'] + child = ['/package/bin/cryptad', 'wrapper.java.maxmemory=256'] + values = ['--config-file', '/node/config/cryptad.ini'] + for name in ('config', 'data', 'cache', 'run', 'logs'): + values += ['--' + name + '-dir', '/node/' + name] + child += ['wrapper.app.parameter.' + str(i) + '=' + value for i, value in enumerate(values, 1)] + environment = {'PATH': '/jdk/bin:/usr/bin:/bin', 'JAVA_HOME': '/jdk', 'HOME': '/node', + 'LANG': 'C.UTF-8', 'TMPDIR': '/tmp', 'CRYPTAD_APPHOST_SANDBOX_PROVIDER': 'bubblewrap', + 'CRYPTAD_APPHOST_TRUSTED_KEYS_FILE': '/inputs/public/trusted-app-keys.properties'} + return arguments + ['--', *child], environment + + +def main(): + if len(sys.argv) != 2 or sys.argv[1] not in ROLES or not sys.flags.isolated or not sys.flags.no_site: + raise ValueError('workload-fixed-entry-required') + role = sys.argv[1] + user = pwd.getpwnam('cryptad-role-' + role) + status = dict(line.split(':', 1) for line in Path('/proc/self/status').read_text().splitlines() if ':' in line) + if (os.getuid() != user.pw_uid or os.geteuid() != user.pw_uid or os.getgid() != user.pw_gid + or os.getegid() != user.pw_gid or set(os.getgroups()) - {user.pw_gid} + or status['NoNewPrivs'].strip() != '1' + or any(int(status[key].strip(), 16) for key in ('CapEff', 'CapPrm', 'CapInh', 'CapAmb', 'CapBnd'))): + raise ValueError('workload-identity-invalid') + if Path('/proc/self/cgroup').read_text().strip() != '0::/system.slice/cryptad-workload@' + role + '.service': + raise ValueError('workload-cgroup-invalid') + os.environ.clear() + os.umask(0o077) + sys.path.insert(0, str(Path(__file__).resolve().parent)) + from restricted_native_launcher import _trusted_read, tree_identity + spec = _trusted_read(ROOT / 'roles' / role / 'launch.json') + remaining = spec['deadlineMonotonicNs'] / 1e9 - time.monotonic() + if (spec['role'] != role or spec['bootId'] != Path('/proc/sys/kernel/random/boot_id').read_text().strip() + or not 0 < remaining <= 3600): + raise ValueError('workload-expired') + for name, expected in spec['inputs'].items(): + if name not in {'package', 'jdk', 'apps', 'public'} or tree_identity( + ROOT / 'roles' / role / name, deadline=spec['deadlineMonotonicNs'] / 1e9) != expected: + raise ValueError('workload-input-changed') + for name in os.listdir('/proc/self/fd'): + if name.isdecimal() and int(name) > 2: + try: + os.close(int(name)) + except OSError: + pass + arguments, environment = command(role) + if spec['deadlineMonotonicNs'] <= time.monotonic_ns(): + raise ValueError('workload-expired') + # A main-process exit causes systemd to terminate the entire nondelegated role cgroup. + # Candidate stdout/stderr are discarded, avoiding a privileged candidate-file collector. + process = subprocess.Popen(arguments, env=environment, stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, close_fds=True) + remaining = spec['deadlineMonotonicNs'] / 1e9 - time.monotonic() + return process.wait(timeout=max(.001, remaining)) + + +if __name__ == '__main__': + try: + result = main() + except Exception: + result = 1 + raise SystemExit(result) diff --git a/tools/release-certification/protected/restricted_workload_mark.py b/tools/release-certification/protected/restricted_workload_mark.py new file mode 100644 index 0000000000..7a6c012201 --- /dev/null +++ b/tools/release-certification/protected/restricted_workload_mark.py @@ -0,0 +1,37 @@ +#!/usr/bin/python3 +"""Manager-only root ExecStartPre receipt, binding intent before candidate execution.""" +import os +from pathlib import Path +import re +import sys + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import restricted_workload as workload + + +def main(): + if (not sys.flags.isolated or not sys.flags.no_site or os.geteuid() != 0 + or len(sys.argv) != 2 or sys.argv[1] not in workload.ROLES): + workload.reject('manager-receipt-entry-invalid') + role = sys.argv[1] + invocation = os.environ.get('INVOCATION_ID', '') + if (re.fullmatch('[0-9a-f]{32}', invocation) is None + or Path('/proc/self/cgroup').read_text().strip() != '0::/system.slice/' + workload.unit(role)): + workload.reject('manager-receipt-scope-invalid') + os.environ.clear() + record = workload.read(workload.ROOT / 'authority' / (role + '.json')) + campaign = workload.read(workload.ROOT / 'campaign.json') + workload.current(campaign) + if record['state'] != 'launching' or record['bootId'] != workload.boot(): + workload.reject('manager-receipt-intent-invalid') + info = workload.group(role).stat() + workload.write(workload.ROOT / 'authority' / (role + '-start.json'), { + 'generation': record['generation'], 'bootId': record['bootId'], + 'managerInvocation': invocation, 'cgroupIdentity': [info.st_dev, info.st_ino]}) + + +if __name__ == '__main__': + try: + main() + except Exception: + raise SystemExit(1) from None diff --git a/tools/release-certification/protected/restricted_workload_network.py b/tools/release-certification/protected/restricted_workload_network.py new file mode 100644 index 0000000000..87b27f4207 --- /dev/null +++ b/tools/release-certification/protected/restricted_workload_network.py @@ -0,0 +1,330 @@ +#!/usr/bin/python3 +"""Closed four-role network fabric, called only by the installed workload controller. + +The caller holds its exclusive workload lease for setup, connections and teardown. It must +establish whole-role cgroup quiescence before teardown; namespace PID checks below are an +additional check, not a replacement. Partial setup is retained and never automatically adopted. +This module grants no external RPC and does not authenticate original workload authorization. +""" +import array +import json +import os +from pathlib import Path +import select +import signal +import socket +import stat +import subprocess + +ROLES = ('candidate-sender', 'candidate-recipient', 'previous', 'relay-no-apps') +# Only this controller authority directory is private. Its workload-layout parent may be +# traversable so fixed role UIDs can reach their own separately protected input/state roots. +ROOT = Path('/var/lib/cryptad-restricted-workload/authority') +NETNS_ROOT = Path('/run/netns') +IP = '/usr/sbin/ip' +NFT = '/usr/sbin/nft' +SWITCH = 'cryptad-role-switch' +FNP_PORT, FCP_PORT, HTTP_PORT = 19400, 19401, 19402 +ENDPOINTS = {'fcp': FCP_PORT, 'http': HTTP_PORT} +MAX_STATE_BYTES = 8192 + + +class NetworkBoundaryError(ValueError): + """Fixed diagnostics that never disclose candidate output or private topology.""" + + +def _reject(): + raise NetworkBoundaryError('restricted-workload-network-rejected') + + +def namespace(role): + if role not in ROLES: + _reject() + return 'cryptad-role-' + role + + +def address(role): + if role not in ROLES: + _reject() + return '10.231.0.' + str(ROLES.index(role) + 1) + + +def _peers(role): + return ROLES[:-1] if role == ROLES[-1] else (ROLES[-1],) + + +def _secure_directory(path): + for item in (path, *path.parents): + info = item.lstat() + if not stat.S_ISDIR(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o022: + _reject() + + +def _guard(): + if os.geteuid() != 0: + _reject() + _secure_directory(ROOT) + if ROOT.stat().st_mode & 0o077: + _reject() + + +def _boot(): + return Path('/proc/sys/kernel/random/boot_id').read_text().strip() + + +def _run(arguments, script=None): + """Only internal fixed command constructors call this; no candidate environment survives.""" + try: + return subprocess.run(arguments, input=script, text=True, check=True, timeout=15, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + env={'PATH': '/usr/sbin:/usr/bin:/sbin:/bin', 'LANG': 'C'}) + except (OSError, subprocess.SubprocessError): + raise NetworkBoundaryError('restricted-workload-network-command-failed') from None + + +def _save(state, initial=False): + _guard() + target = ROOT / ('network.json' if initial else 'network.new') + descriptor = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600) + with os.fdopen(descriptor, 'w') as stream: + json.dump(state, stream, sort_keys=True, separators=(',', ':')) + stream.flush() + os.fsync(stream.fileno()) + if not initial: + os.replace(target, ROOT / 'network.json') + descriptor = os.open(ROOT, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) + try: + os.fsync(descriptor) + finally: + os.close(descriptor) + + +def _load(): + _guard() + descriptor = os.open(ROOT / 'network.json', os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) + with os.fdopen(descriptor) as stream: + info = os.fstat(stream.fileno()) + if (not stat.S_ISREG(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o077 + or info.st_nlink != 1 or info.st_size > MAX_STATE_BYTES): + _reject() + state = json.loads(stream.read(MAX_STATE_BYTES + 1)) + if (set(state) != {'version', 'bootId', 'phase', 'pending', 'namespaces'} + or state['version'] != 1 or state['bootId'] != _boot() + or state['phase'] not in {'preparing', 'ready', 'stopping', 'retained'} + or not isinstance(state['namespaces'], dict) + or set(state['namespaces']) - {SWITCH, *(namespace(role) for role in ROLES)}): + _reject() + return state + + +def _namespace_identity(name): + _secure_directory(NETNS_ROOT) + descriptor = os.open(NETNS_ROOT / name, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC) + try: + info = os.fstat(descriptor) + if info.st_uid != 0: + _reject() + return [info.st_dev, info.st_ino] + finally: + os.close(descriptor) + + +def role_rules(role): + """Own loopback plus the exact UDP peer matrix; no established-connection bypass.""" + peers = ', '.join(address(peer) for peer in _peers(role)) + own = address(role) + return ('table inet cryptad_role {\n' + ' chain input { type filter hook input priority 0; policy drop;\n' + ' iifname "lo" accept\n' + f' iifname "data0" ip saddr {{ {peers} }} ip daddr {own} ' + f'udp sport {FNP_PORT} udp dport {FNP_PORT} accept\n }}\n' + ' chain output { type filter hook output priority 0; policy drop;\n' + ' oifname "lo" accept\n' + f' oifname "data0" ip saddr {own} ip daddr {{ {peers} }} ' + f'udp sport {FNP_PORT} udp dport {FNP_PORT} accept\n }}\n' + ' chain forward { type filter hook forward priority 0; policy drop; }\n}\n') + + +def switch_rules(): + lines = ['table bridge cryptad_switch {', + ' chain input { type filter hook input priority 0; policy drop; }', + ' chain output { type filter hook output priority 0; policy drop; }', + ' chain forward { type filter hook forward priority 0; policy drop;'] + for index, role in enumerate(ROLES[:-1]): + for source, target, src_role, dst_role in ((index, 3, role, ROLES[-1]), + (3, index, ROLES[-1], role)): + prefix = f' iifname "sw{source}" oifname "sw{target}" ' + lines.append(prefix + 'ether type arp accept') + lines.append(prefix + f'ether type ip ip saddr {address(src_role)} ' + f'ip daddr {address(dst_role)} ip protocol udp ' + f'udp sport {FNP_PORT} udp dport {FNP_PORT} accept') + return '\n'.join([*lines, ' }', '}', '']) + + +def setup(): + """Create the one fixed fabric, retaining intent before each namespace mutation. + + No role service may start until this returns. Existing names or retained state reject + setup before mutation; on failure the controller must retain reconciliation state. + """ + _guard() + names = (SWITCH, *(namespace(role) for role in ROLES)) + if (ROOT / 'network.json').exists() or any(os.path.lexists(NETNS_ROOT / name) for name in names): + _reject() + state = {'version': 1, 'bootId': _boot(), 'phase': 'preparing', + 'pending': None, 'namespaces': {}} + _save(state, initial=True) + for name in names: + state['pending'] = name + _save(state) + _run([IP, 'netns', 'add', name]) + state['namespaces'][name] = _namespace_identity(name) + state['pending'] = None + _save(state) + _run([IP, '-n', SWITCH, 'link', 'add', 'br0', 'type', 'bridge']) + _run([IP, 'netns', 'exec', SWITCH, NFT, '-f', '-'], switch_rules()) + for index, role in enumerate(ROLES): + name = namespace(role) + # Both ends are born in task namespaces: no transient host-side interface exists. + _run([IP, '-n', SWITCH, 'link', 'add', f'sw{index}', 'type', 'veth', + 'peer', 'name', 'data0', 'netns', name]) + _run([IP, '-n', SWITCH, 'link', 'set', f'sw{index}', 'master', 'br0']) + _run([IP, 'netns', 'exec', name, NFT, '-f', '-'], role_rules(role)) + _run([IP, '-n', name, 'address', 'add', address(role) + '/32', 'dev', 'data0']) + for peer in _peers(role): + _run([IP, '-n', name, 'route', 'add', address(peer) + '/32', 'dev', 'data0']) + _run([IP, '-n', name, 'link', 'set', 'lo', 'up']) + _run([IP, '-n', name, 'link', 'set', 'data0', 'up']) + _run([IP, '-n', SWITCH, 'link', 'set', f'sw{index}', 'up']) + _run([IP, '-n', SWITCH, 'link', 'set', 'br0', 'up']) + state['phase'] = 'ready' + _save(state) + + +def teardown(): + """Remove only recorded, unchanged, empty namespaces; keep the terminal record. + + Caller first verifies all owned role cgroups empty. A lost namespace-add response or + changed mount identity requires explicit controller reconciliation, never blind deletion. + """ + state = _load() + if state['pending'] is not None: + _reject() + for name, identity in state['namespaces'].items(): + if _namespace_identity(name) != identity: + _reject() + # Avoid unbounded stdout capture: any byte means a live member, regardless of PID count. + with subprocess.Popen([IP, 'netns', 'pids', name], stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + env={'PATH': '/usr/sbin:/usr/bin:/sbin:/bin', 'LANG': 'C'}) as process: + try: + if not select.select([process.stdout], [], [], 15)[0]: + process.kill() + _reject() + occupied = bool(process.stdout.read(1)) + if occupied: + process.kill() + code = process.wait(timeout=15) + if occupied or code != 0: + _reject() + except BaseException: + process.kill() + process.wait() + raise + state['phase'] = 'stopping' + _save(state) + for name in list(reversed(state['namespaces'])): + if _namespace_identity(name) != state['namespaces'][name]: + _reject() + state['pending'] = name + _save(state) + _run([IP, 'netns', 'delete', name]) + del state['namespaces'][name] + state['pending'] = None + _save(state) + state['phase'] = 'retained' + _save(state) + + +def connect(role, endpoint): + """Return a TCP socket for fixed FCP/HTTP in a current retained role namespace. + + Fork confines setns to a short-lived privileged child. Neither a namespace path, PID, + address nor dynamic port can enter through this API. Original authority and the retained + manager invocation are checked by the owning controller before and after this operation. + """ + if endpoint not in ENDPOINTS: + _reject() + return _connect_port(role, ENDPOINTS[endpoint]) + + +def _connect_port(role, port): + """Internal transport for controller-resolved app bootstrap, never an RPC selector. + + Only the fixed semantic bootstrap operation may select a dynamic port after validating + the current role's launch response, nonce and origin. The socket remains in that role's + network namespace; candidate redirects cannot select host or sibling management sockets. + """ + name = namespace(role) + if type(port) is not int or not 1 <= port <= 65535: + _reject() + state = _load() + if state['phase'] != 'ready' or state['pending'] is not None: + _reject() + expected = state['namespaces'].get(name) + if expected is None or _namespace_identity(name) != expected: + _reject() + descriptor = os.open(NETNS_ROOT / name, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC) + info = os.fstat(descriptor) + if [info.st_dev, info.st_ino] != expected: + os.close(descriptor) + _reject() + parent, child = socket.socketpair(socket.AF_UNIX, socket.SOCK_DGRAM) + pid = None + transferred = [] + try: + parent.settimeout(12) + pid = os.fork() + if pid == 0: + try: + parent.close() + signal.alarm(10) + os.setns(descriptor, 0x40000000) # CLONE_NEWNET only. + with socket.create_connection(('127.0.0.1', port), timeout=8) as connected: + child.sendmsg([b'1'], [(socket.SOL_SOCKET, socket.SCM_RIGHTS, + array.array('i', [connected.fileno()]))]) + os._exit(0) + except BaseException: + os._exit(1) + child.close() + body, controls, flags, _ = parent.recvmsg(1, socket.CMSG_SPACE(array.array('i').itemsize), + socket.MSG_CMSG_CLOEXEC) + for level, kind, raw in controls: + if level != socket.SOL_SOCKET or kind != socket.SCM_RIGHTS: + _reject() + values = array.array('i') + values.frombytes(raw) + transferred.extend(values) + _, status = os.waitpid(pid, 0) + pid = None + if body != b'1' or flags & socket.MSG_CTRUNC or len(transferred) != 1 or status != 0: + _reject() + if _namespace_identity(name) != expected: + _reject() + result = socket.socket(fileno=transferred.pop()) + result.settimeout(8) + return result + except (OSError, ValueError): + raise NetworkBoundaryError('restricted-workload-connect-failed') from None + finally: + if pid is not None and pid > 0: + try: + os.kill(pid, signal.SIGKILL) + except ProcessLookupError: + pass + os.waitpid(pid, 0) + for received in transferred: + os.close(received) + parent.close() + child.close() + os.close(descriptor) diff --git a/tools/release-certification/protected/restricted_workload_prepare.py b/tools/release-certification/protected/restricted_workload_prepare.py new file mode 100644 index 0000000000..21233e56dc --- /dev/null +++ b/tools/release-certification/protected/restricted_workload_prepare.py @@ -0,0 +1,239 @@ +"""Trusted preparation of the fixed four-role source-build workload profile. + +This is an in-process owner API, not a client request. Production artifact campaigns +continue to require the existing original product admission and protected activation. +The initial implemented lane is explicitly synthetic and cannot reopen that channel. +""" +import hashlib +import json +import os +from pathlib import Path +import secrets +import subprocess +import sys +import tarfile +import tempfile +import time + +import restricted_workload as workload +from restricted_workload_storage import copy_tree +from restricted_native_launcher import tree_identity + + +def configuration(role): + """Prospective fixed namespace config; historical layout-2 pins are never relabelled.""" + from restricted_workload_network import address, FNP_PORT, FCP_PORT, HTTP_PORT + sys.path.insert(0, str(Path(__file__).resolve().parents[2] / 'interop')) + import cross_version_runtime as runtime + workload.unit(role) + with tempfile.TemporaryDirectory(prefix='cryptad-workload-config-') as temporary: + node = Path(temporary) / 'node' + config = runtime.make_runtime_config(node, runtime.interop.Ports(FNP_PORT, FCP_PORT, 0, 0), HTTP_PORT) + text = config.read_text().replace(str(node), '/node') + return (text.replace('node.bindTo=127.0.0.1', 'node.bindTo=' + address(role)) + .replace('node.ipAddressOverride=127.0.0.1', 'node.ipAddressOverride=' + address(role)) + .replace('node.includeLocalAddressesInNoderefs=false', 'node.includeLocalAddressesInNoderefs=true')) + + +def configuration_identity(role, trust_digest): + text = configuration(role) + from cross_version_runtime import canonical_digest + return canonical_digest({'profile': workload.PROFILE, 'role': role, + 'configuration': text, 'appTrustDigest': trust_digest}) + + +def prepare(plan, private, authorization): + """Stage a bounded source-built comparison using existing package/app validation. + + The caller is a trusted installed owner or the separately installed administrator + test kit. All source ancestors must be root owned. No observer controls these paths. + One retained campaign reserves the entire static pool; reuse requires explicit + administrator reconciliation outside this API. + """ + from restricted_workload_network import setup, FNP_PORT, FCP_PORT, HTTP_PORT + sys.path.insert(0, str(Path(__file__).resolve().parents[2] / 'interop')) + import cross_version_runtime as runtime + if (plan.get('provenanceClass') != 'source-build-comparison' + or set(private) != {'root', 'nodes'} or plan.get('workloadInputs') + or set(private['nodes']) != set(workload.ROLES) + or [row['role'] for row in plan['nodes']] != list(workload.ROLES) + or plan.get('cohorts') or plan.get('composedBudgetInputs') + or authorization.get('syntheticContent') is not True + or authorization.get('planDigest') != runtime.canonical_digest(plan) + or authorization.get('experimentId') != plan.get('experimentId') + or type(authorization.get('maxSeconds')) is not int + or not 30 <= authorization['maxSeconds'] <= workload.MAX_SECONDS + or type(authorization.get('maxOperations')) is not int + or not 1 <= authorization['maxOperations'] <= 10000): + workload.reject('profile-selection-unsupported') + for selected in plan['nodes']: + role = selected['role'] + row = private['nodes'][role] + if (selected.get('product') != 'cryptad' + or set(row) != {'archivePath', 'javaHome', 'fnpPort', 'fcpPort', 'httpPort', + 'apps', 'trustedKeysPath', 'trustedKeysDigest'} + or (row['fnpPort'], row['fcpPort'], row['httpPort']) != (FNP_PORT, FCP_PORT, HTTP_PORT) + or not isinstance(row.get('apps'), list) + or len(row['apps']) > 1 + or any(app.get('appId') != 'mail-prototype' for app in row['apps']) + or role == 'relay-no-apps' and row['apps'] + or role in ('candidate-sender', 'candidate-recipient') and len(row['apps']) != 1 + or sorted(app.get('bundleDigest', '') for app in row['apps']) != sorted(selected.get('appDigests', []))): + workload.reject('profile-app-selection-unsupported') + sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + from cryptad_certification.cross_version_evidence import validate_plan + validate_plan(plan) + sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'restricted')) + import workload_installation + implementation = workload_installation.verify() + if (plan['producer']['sourceCommit'] != implementation['sourceCommit'] + or plan['producer'] != runtime.runner_identity()): + workload.reject('installed-source-selection-mismatch') + configurations = {} + for selected in plan['nodes']: + role = selected['role'] + if selected['configDigest'] != configuration_identity(role, private['nodes'][role]['trustedKeysDigest']): + workload.reject('profile-config-selection-mismatch') + configurations[role] = configuration(role) + with workload.locked(): + if (workload.ROOT / 'campaign.json').exists(): + workload.reject('retained-campaign-requires-reconciliation') + users = [workload.account(role) for role in workload.ROLES] + if len({user.pw_uid for user in users}) != 4 or len({user.pw_gid for user in users}) != 4: + workload.reject('role-pool-not-distinct') + reserved_uids = {user.pw_uid for user in users} + # Initial reservation also rejects processes outside the expected units. Only UIDs + # are inspected; no unrelated process environment, executable or command is read. + for entry in Path('/proc').iterdir(): + if not entry.name.isdecimal(): + continue + try: + if entry.stat().st_uid in reserved_uids: + workload.reject('role-identity-already-live') + except FileNotFoundError: + pass + for role in workload.ROLES: + state = workload.manager(role, 'show') + if state['ActiveState'] not in {'inactive', 'failed'} or not workload.quiescent(role): + workload.reject('role-pool-busy') + # Admission is a conservative allocation bound, not a filesystem quota. Candidate + # writable bytes have a separate hard tmpfs limit. Keep headroom for retained failures. + space = os.statvfs(workload.ROOT) + if space.f_bavail * space.f_frsize < 16 * 1024**3: + workload.reject('staging-storage-reserve-unavailable') + selection = {'plan': plan, 'private': private, 'authorization': authorization} + generation = secrets.token_hex(32) + deadline = time.monotonic_ns() + authorization['maxSeconds'] * 10**9 + handles = {role: secrets.token_hex(32) for role in workload.ROLES} + java_digests = {} + daemon_digests = {} + campaign = {'schemaVersion': 1, 'profile': workload.PROFILE, 'generation': generation, + 'implementationIdentity': implementation, + 'executionRecordDigest': workload.execution_record_digest(), + 'classification': 'synthetic-source-build-not-original-authority', 'bootId': workload.boot(), + 'deadlineMonotonicNs': deadline, 'maxOperations': authorization['maxOperations'], + 'selectionDigest': hashlib.sha256(json.dumps(selection, sort_keys=True, separators=(',', ':')).encode()).hexdigest(), + 'handles': handles, 'state': 'preparing'} + workload.write(workload.ROOT / 'selection.json', selection, create=True) + workload.write(workload.ROOT / 'campaign.json', campaign, create=True) + for directory, mode in (('authority', 0o700), ('staging', 0o700), ('roles', 0o711), ('state', 0o711)): + path = workload.ROOT / directory + path.mkdir(mode=0o700) + path.chmod(mode) + for selected in plan['nodes']: + role = selected['role'] + user = workload.account(role) + row = private['nodes'][role] + # Pin complete administrator inputs before parsing/extracting candidate archives. + for key in ('archivePath', 'javaHome'): + workload.secured(Path(row[key])) + if tree_identity(row['javaHome'], deadline=deadline / 1e9)['sizeBytes'] > 512 * 1024**2: + workload.reject('jdk-staging-budget-exceeded') + total, count = 0, 0 + with tarfile.open(row['archivePath'], 'r:*') as archive: + for member in archive: + total += member.size + count += 1 + if time.monotonic_ns() >= deadline or total > 512 * 1024**2 or count > 30000: + workload.reject('package-staging-budget-exceeded') + staging = workload.ROOT / 'staging' / role + staging.mkdir(mode=0o700) + package = runtime.extract_package(row['archivePath'], staging / 'package', + selected['artifactDigest'], selected['artifactSize']) + daemon_digests[role] = runtime.packaged_daemon_identity(package, selected['sourceCommit']) + runtime.require_native_target(package, selected['packageTarget'], row['javaHome']) + if runtime.tree_digest(row['javaHome']) != selected['runtimeDigest']: + workload.reject('jdk-selection-mismatch') + apps, public = staging / 'apps', staging / 'public' + apps.mkdir(mode=0o700) + public.mkdir(mode=0o700) + if sorted(app['bundleDigest'] for app in row['apps']) != sorted(selected['appDigests']): + workload.reject('app-selection-mismatch') + for app in row['apps']: + if app['appId'] != 'mail-prototype' or role == 'relay-no-apps': + workload.reject('app-adapter-unsupported') + workload.secured(Path(app['bundlePath'])) + runtime.extract_app_bundle(app['bundlePath'], apps / app['appId'], app['bundleDigest']) + if role in ('candidate-sender', 'candidate-recipient') and len(row['apps']) != 1: + workload.reject('mail-selection-required') + trust = b'' + if row['apps']: + trust_path = workload.secured(Path(row['trustedKeysPath'])) + with trust_path.open('rb') as stream: + trust = stream.read(65537) + if len(trust) > 65536 or 'sha256:' + hashlib.sha256(trust).hexdigest() != row['trustedKeysDigest']: + workload.reject('trust-selection-mismatch') + (public / 'trusted-app-keys.properties').write_bytes(trust) + inputs = workload.ROOT / 'roles' / role + inputs.mkdir(mode=0o700) + os.chown(inputs, 0, user.pw_gid) + inputs.chmod(0o750) + expected = {} + for name, source in (('package', package), ('jdk', Path(row['javaHome'])), ('apps', apps), ('public', public)): + copy_tree(source, inputs / name, deadline / 1e9) + expected[name] = tree_identity(inputs / name) + node = workload.ROOT / 'state' / role + node.mkdir(mode=0o700) + # A finite tmpfs contains every writable role byte, retained across daemon restart. + # This source-build profile deliberately does not support host-reboot continuation. + subprocess.run(['/usr/bin/mount', '-t', 'tmpfs', '-o', + 'size=512M,nr_inodes=32768,nosuid,nodev,mode=0700,uid=' + str(user.pw_uid) + ',gid=' + str(user.pw_gid), + 'cryptad-workload-' + role, str(node)], check=True, env=workload.ENV, timeout=10, + stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + # Initialize as the selected UID: never recursively chown a candidate tree. + child = os.fork() + if child == 0: + try: + os.setgroups([]) + os.setgid(user.pw_gid) + os.setuid(user.pw_uid) + config = runtime.make_runtime_config(node, runtime.interop.Ports(FNP_PORT, FCP_PORT, 0, 0), HTTP_PORT) + config.write_text(configurations[role]) + (node / 'tmp').mkdir(mode=0o700) + os._exit(0) + except BaseException: + os._exit(1) + if os.waitpid(child, 0)[1] != 0: + workload.reject('role-storage-initialization-failed') + # Expected initial bytes stay in root authority, separate from normalized runtime config. + expected_config = hashlib.sha256((node / 'config/cryptad.ini').read_bytes()).hexdigest() + java_digests[role] = runtime.digest_file(inputs / 'jdk/bin/java') + launch = {'role': role, 'bootId': campaign['bootId'], 'deadlineMonotonicNs': deadline, + 'javaDigest': java_digests[role], 'inputs': expected} + if row['apps']: + launch['mailIdentity'] = tree_identity(inputs / 'apps/mail-prototype') + if launch['mailIdentity']['sizeBytes'] > 64 * 1024 * 1024 or launch['mailIdentity']['fileCount'] > 4095: + workload.reject('app-observation-budget-exceeded') + workload.write(inputs / 'launch.json', launch, create=True, mode=0o444) + record = {'handle': handles[role], 'campaign': generation, 'bootId': campaign['bootId'], + 'generation': None, 'managerInvocation': None, 'cgroupIdentity': None, + 'state': 'prepared', 'stopReason': None, 'initialConfigDigest': expected_config} + workload.write(workload.ROOT / 'authority' / (role + '.json'), record, create=True) + if daemon_digests['previous'] == daemon_digests['candidate-sender']: + workload.reject('previous-repackages-current-daemon') + setup() + campaign['state'] = 'prepared' + workload.write(workload.ROOT / 'campaign.json', campaign) + return {'profile': workload.PROFILE, 'handles': handles, 'expectedJdkDigests': java_digests, + 'implementationIdentity': implementation, + 'classification': campaign['classification']} diff --git a/tools/release-certification/protected/restricted_workload_storage.py b/tools/release-certification/protected/restricted_workload_storage.py new file mode 100644 index 0000000000..75431294ad --- /dev/null +++ b/tools/release-certification/protected/restricted_workload_storage.py @@ -0,0 +1,264 @@ +"""Root-only, bounded immutable input copies for installed workload roles. + +Paths are controller selections, never request parameters. A failed copy retains its +exclusive destination for reconciliation; callers must not retry over those bytes. +This module does not authenticate product provenance or candidate-generated output. +""" +from __future__ import annotations + +from contextlib import contextmanager +import math +import os +from pathlib import Path +import stat +import time + +ROOT = Path('/var/lib/cryptad-restricted-workload') +ROLES = ('candidate-sender', 'candidate-recipient', 'previous', 'relay-no-apps') +MAX_BYTES = 8 * 1024**3 +MAX_FILES = 30000 +MAX_DEPTH = 32 + + +class StorageError(ValueError): + """Closed diagnostic; no selected private paths or contents.""" + + +def _check_deadline(deadline): + if time.monotonic() >= deadline: + raise StorageError('workload-storage-deadline') + + +def _metadata(info): + return (info.st_dev, info.st_ino, info.st_mode, info.st_uid, info.st_gid, + info.st_nlink, info.st_size, info.st_mtime_ns, info.st_ctime_ns) + + +def _trusted_directory(info): + if not stat.S_ISDIR(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o022: + raise StorageError('workload-storage-directory-not-trusted') + + +@contextmanager +def _directory(path): + """Pin and revalidate every ancestor; reject traversing writable or linked roots.""" + path = Path(path) + if not path.is_absolute() or '..' in path.parts: + raise StorageError('workload-storage-path-invalid') + descriptors, links = [], [] + try: + descriptor = os.open('/', os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) + descriptors.append(descriptor) + _trusted_directory(os.fstat(descriptor)) + for name in path.parts[1:]: + child = os.open(name, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, + dir_fd=descriptor) + descriptors.append(child) + info = os.fstat(child) + _trusted_directory(info) + links.append((descriptor, name, child, info.st_dev, info.st_ino)) + descriptor = child + yield descriptor + for parent, name, child, device, inode in links: + info = os.stat(name, dir_fd=parent, follow_symlinks=False) + _trusted_directory(info) + _trusted_directory(os.fstat(child)) + if (info.st_dev, info.st_ino) != (device, inode): + raise StorageError('workload-storage-ancestor-replaced') + finally: + for descriptor in reversed(descriptors): + os.close(descriptor) + + +def copy_tree(source, destination, deadline, *, max_bytes=MAX_BYTES, max_files=MAX_FILES): + """Copy a trusted tree to an exclusive immutable directory, retaining any failure. + + ``deadline`` is an absolute monotonic time. Source and destination-parent ancestors + must be root-owned and not writable by group/other. All entries, including directories, + consume ``max_files``; every source regular file must have exactly one hard link. + Returned byte/entry counts are copy accounting, not artifact-authentication evidence. + The caller owns admission, role-path mapping, mount policy and failed-copy retention. + """ + if os.geteuid() != 0: + raise StorageError('workload-storage-root-required') + if (isinstance(deadline, bool) or not isinstance(deadline, (int, float)) + or not math.isfinite(deadline) or type(max_bytes) is not int + or not 1 <= max_bytes <= MAX_BYTES or type(max_files) is not int + or not 1 <= max_files <= MAX_FILES): + raise StorageError('workload-storage-budget-invalid') + source, destination = Path(source), Path(destination) + if (not source.is_absolute() or not destination.is_absolute() + or '..' in source.parts or '..' in destination.parts or destination == Path('/') + or destination == source or destination.is_relative_to(source)): + raise StorageError('workload-storage-path-invalid') + counts = {'bytes': 0, 'entries': 0} + try: + _check_deadline(deadline) + with _directory(source) as src, _directory(destination.parent) as parent: + os.mkdir(destination.name, 0o700, dir_fd=parent) + dst = os.open(destination.name, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, + dir_fd=parent) + try: + _copy_directory(src, dst, deadline, max_bytes, max_files, counts, 0) + os.fchmod(dst, 0o755) + os.fsync(dst) + retained = os.stat(destination.name, dir_fd=parent, follow_symlinks=False) + if _metadata(retained) != _metadata(os.fstat(dst)): + raise StorageError('workload-storage-destination-replaced') + os.fsync(parent) + finally: + os.close(dst) + return counts + except OSError: + raise StorageError('workload-storage-copy-failed-retained') from None + + +def _copy_directory(src, dst, deadline, max_bytes, max_files, counts, depth): + _check_deadline(deadline) + if depth > MAX_DEPTH: + raise StorageError('workload-storage-depth-exceeded') + before = os.fstat(src) + _trusted_directory(before) + with os.scandir(src) as entries: + for entry in entries: + _check_deadline(deadline) + counts['entries'] += 1 + if counts['entries'] > max_files: + raise StorageError('workload-storage-entry-budget-exceeded') + pinned = os.open(entry.name, os.O_PATH | os.O_NOFOLLOW, dir_fd=src) + try: + info = os.fstat(pinned) + if info.st_uid != 0 or info.st_mode & 0o022: + raise StorageError('workload-storage-input-not-trusted') + if stat.S_ISDIR(info.st_mode): + os.mkdir(entry.name, 0o700, dir_fd=dst) + child_src = os.open('/proc/self/fd/' + str(pinned), os.O_RDONLY | os.O_DIRECTORY) + try: + child_dst = os.open(entry.name, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=dst) + try: + _copy_directory(child_src, child_dst, deadline, max_bytes, max_files, counts, depth + 1) + os.fchmod(child_dst, 0o755) + os.fsync(child_dst) + finally: + os.close(child_dst) + finally: + os.close(child_src) + elif stat.S_ISREG(info.st_mode) and info.st_nlink == 1: + if counts['bytes'] + info.st_size > max_bytes: + raise StorageError('workload-storage-byte-budget-exceeded') + _copy_file(pinned, dst, entry.name, info, deadline, max_bytes, counts) + else: + raise StorageError('workload-storage-special-or-linked-input') + retained = os.stat(entry.name, dir_fd=src, follow_symlinks=False) + if _metadata(info) != _metadata(os.fstat(pinned)) or _metadata(info) != _metadata(retained): + raise StorageError('workload-storage-input-replaced') + finally: + os.close(pinned) + if _metadata(before) != _metadata(os.fstat(src)): + raise StorageError('workload-storage-directory-changed') + + +def _copy_file(pinned, dst, name, info, deadline, max_bytes, counts): + reader = os.open('/proc/self/fd/' + str(pinned), os.O_RDONLY | os.O_NONBLOCK) + try: + writer = os.open(name, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600, dir_fd=dst) + try: + copied = 0 + while True: + _check_deadline(deadline) + chunk = os.read(reader, min(1024 * 1024, max_bytes - counts['bytes'] + 1)) + if not chunk: + break + counts['bytes'] += len(chunk) + copied += len(chunk) + if counts['bytes'] > max_bytes or copied > info.st_size: + raise StorageError('workload-storage-byte-budget-exceeded') + pending = memoryview(chunk) + while pending: + _check_deadline(deadline) + written = os.write(writer, pending) + if written <= 0: + raise StorageError('workload-storage-write-failed') + pending = pending[written:] + if copied != info.st_size or _metadata(info) != _metadata(os.fstat(reader)): + raise StorageError('workload-storage-input-changed') + os.fchmod(writer, 0o555 if info.st_mode & 0o111 else 0o444) + os.fsync(writer) + finally: + os.close(writer) + finally: + os.close(reader) + + +APP_MAX_BYTES = 64 * 1024**2 +APP_MAX_ENTRIES = 4096 + + +def verify_installed_app(source, expected_tree, scratch, deadline): + """Compare a bounded, untrusted installed app snapshot to its admitted immutable tree. + + The controller supplies the fixed role path, expected native tree identity and fixed + private scratch path. Candidate bytes never become authority merely by being copied. + An exclusive scratch directory is created before source inspection and retained on + any failure, preventing silent repeated attempts. Only an exactly matching root-owned + snapshot is removed. Source files and directories are never modified or deleted. + """ + import re + import shutil + from restricted_native import _copy + from restricted_native_launcher import tree_identity + + if os.geteuid() != 0: + raise StorageError('workload-app-verification-root-required') + if (isinstance(deadline, bool) or not isinstance(deadline, (int, float)) + or not math.isfinite(deadline) + or not isinstance(expected_tree, dict) + or set(expected_tree) != {'digest', 'fileCount', 'sizeBytes'} + or not isinstance(expected_tree['digest'], str) + or re.fullmatch('sha256:[0-9a-f]{64}', expected_tree['digest']) is None + or type(expected_tree['sizeBytes']) is not int + or not 1 <= expected_tree['sizeBytes'] <= APP_MAX_BYTES + or type(expected_tree['fileCount']) is not int + or not 1 <= expected_tree['fileCount'] < APP_MAX_ENTRIES): + raise StorageError('workload-app-verification-selection-invalid') + source, scratch = Path(source), Path(scratch) + if (not source.is_absolute() or not scratch.is_absolute() + or '..' in source.parts or '..' in scratch.parts + or source == Path('/') or scratch == Path('/') + or source == scratch or source.is_relative_to(scratch) or scratch.is_relative_to(source)): + raise StorageError('workload-app-verification-path-invalid') + try: + _check_deadline(deadline) + with _directory(scratch.parent) as parent: + # This reservation survives even a failure opening the candidate source root. + os.mkdir(scratch.name, 0o700, dir_fd=parent) + fd = os.open(scratch.name, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=parent) + try: + before = os.fstat(fd) + os.fsync(parent) + copied = Path('/proc/self/fd/' + str(fd)) / 'tree' + # Reuse the existing native copier's global budget accounting. Reserving + # the unused capacity produces this smaller fixed app verification limit. + budget = [4 * 1024**3 - APP_MAX_BYTES, 65536 - APP_MAX_ENTRIES] + _copy(source, copied, 0, 0, budget, deadline=deadline, trusted_source=False) + actual = tree_identity(copied, deadline=deadline) + if actual != expected_tree: + raise StorageError('workload-installed-app-identity-mismatch') + _check_deadline(deadline) + retained = os.stat(scratch.name, dir_fd=parent, follow_symlinks=False) + if (retained.st_dev, retained.st_ino, retained.st_uid, retained.st_mode) != ( + before.st_dev, before.st_ino, before.st_uid, before.st_mode): + raise StorageError('workload-app-verification-scratch-replaced') + # Only the root-created private copy is eligible. Python's fd-based rmtree + # rejects symlink substitution; no deletion walks the candidate source. + if not shutil.rmtree.avoids_symlink_attacks: + raise StorageError('workload-app-verification-cleanup-unavailable') + shutil.rmtree('tree', dir_fd=fd) + os.fsync(fd) + os.rmdir(scratch.name, dir_fd=parent) + os.fsync(parent) + return actual + finally: + os.close(fd) + except OSError: + raise StorageError('workload-app-verification-failed-retained') from None diff --git a/tools/release-certification/protected/test_restricted_workload.py b/tools/release-certification/protected/test_restricted_workload.py new file mode 100644 index 0000000000..bcf8ed54e9 --- /dev/null +++ b/tools/release-certification/protected/test_restricted_workload.py @@ -0,0 +1,575 @@ +"""Workload control fault tests with simulated manager state, not installed acceptance.""" +from contextlib import ExitStack, nullcontext +import copy +import json +import os +import socket +import stat +import threading +import struct +import subprocess +import tempfile +from pathlib import Path +from types import SimpleNamespace +import unittest +from unittest.mock import Mock, patch + +import restricted_workload as workload +import restricted_workload_controller as controller +import restricted_workload_launcher as launcher +import restricted_workload_prepare as preparation + + +class ProspectiveConfigurationTest(unittest.TestCase): + def test_role_configuration_keeps_management_loopback_and_distinct_data_plane(self): + from restricted_workload_network import address + identities = set() + for role in workload.ROLES: + text = preparation.configuration(role) + self.assertIn('node.bindTo=' + address(role) + '\n', text) + self.assertIn('node.ipAddressOverride=' + address(role) + '\n', text) + self.assertIn('fcp.bindTo=127.0.0.1\n', text) + self.assertIn('fproxy.bindTo=127.0.0.1\n', text) + self.assertIn('node.install.cfgDir=/node/config\n', text) + identities.add(preparation.configuration_identity(role, None)) + self.assertEqual(4, len(identities)) + + def test_profile_configuration_binds_public_trust_and_is_reproducible(self): + role = workload.ROLES[0] + original = preparation.configuration_identity(role, 'sha256:' + 'a' * 64) + self.assertEqual(original, preparation.configuration_identity(role, 'sha256:' + 'a' * 64)) + self.assertNotEqual(original, preparation.configuration_identity(role, 'sha256:' + 'b' * 64)) + + +class BootstrapHttpTest(unittest.TestCase): + def test_connected_loopback_listener_receives_its_real_host_port(self): + listener = socket.socket() + self.addCleanup(listener.close) + listener.bind(('127.0.0.1', 0)) + listener.listen(1) + received = [] + def respond(): + connection, _address = listener.accept() + with connection: + connection.settimeout(5) + raw = b'' + while b'\r\n\r\n' not in raw: + raw += connection.recv(4096) + received.append(raw) + connection.sendall(b'HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n{}') + worker = threading.Thread(target=respond, daemon=True) + worker.start() + with socket.create_connection(listener.getsockname(), timeout=5) as connection: + status, _headers, body = controller._http(connection, '/.well-known/cryptad-bootstrap.json', {}) + worker.join(timeout=5) + self.assertFalse(worker.is_alive()) + self.assertEqual((200, b'{}'), (status, body)) + self.assertIn(('Host: 127.0.0.1:' + str(listener.getsockname()[1]) + '\r\n').encode(), received[0]) + + +class CurrentImplementationTest(unittest.TestCase): + def test_partial_preparation_cannot_authorize_work(self): + with patch.object(workload, 'execution_record_digest') as identity: + for state in (None, 'preparing', 'failed'): + with self.subTest(state=state), self.assertRaisesRegex( + workload.WorkloadError, 'preparation-incomplete'): + workload.current({'state': state}) + identity.assert_not_called() + + def test_changed_installed_execution_record_cannot_continue_campaign(self): + selection = {'fixed': 'selection'} + campaign = {'state': 'prepared', 'bootId': BOOT, 'deadlineMonotonicNs': 10**20, + 'executionRecordDigest': 'a' * 64, + 'selectionDigest': workload.hashlib.sha256(json.dumps(selection, sort_keys=True, + separators=(',', ':')).encode()).hexdigest()} + with tempfile.TemporaryDirectory() as directory, patch.object(workload, 'ROOT', Path(directory)), \ + patch.object(workload, 'boot', return_value=BOOT), \ + patch.object(workload, 'read', return_value=selection), \ + patch.object(workload, 'execution_record_digest', return_value='a' * 64) as identity: + workload.current(campaign) + identity.return_value = 'b' * 64 + with self.assertRaisesRegex(workload.WorkloadError, 'implementation-changed'): + workload.current(campaign) + + +ROLE = 'candidate-sender' +HANDLE = 'a' * 64 +BOOT = 'example-current-boot' +INVOCATION = 'b' * 32 + + +def manager_state(active='active', invocation=INVOCATION): + return dict(InvocationID=invocation, ActiveState=active, SubState='running', + ControlGroup='/system.slice/' + workload.unit(ROLE), MainPID='123', Result='success') + + +class ObservationTest(unittest.TestCase): + def setUp(self): + self.stack = ExitStack() + self.addCleanup(self.stack.close) + root = Path(self.stack.enter_context(tempfile.TemporaryDirectory())) + self.root = root + (root / 'memory.current').write_text('4096') + (root / 'pids.current').write_text('0') + info = root.stat() + record = dict(handle=HANDLE, state='running', generation='generation', bootId=BOOT, + managerInvocation=INVOCATION, cgroupIdentity=[info.st_dev, info.st_ino]) + self.stack.enter_context(patch.object(workload, 'locked', side_effect=nullcontext)) + self.stack.enter_context(patch.object(workload, 'retained', return_value=({}, ROLE, record))) + self.stack.enter_context(patch.object(workload, 'admit')) + self.stack.enter_context(patch.object(workload, 'group', return_value=root)) + self.stack.enter_context(patch.object(workload, 'boot', return_value=BOOT)) + self.stack.enter_context(patch.object(workload, 'account', return_value=SimpleNamespace(pw_uid=os.getuid()))) + self.manager = self.stack.enter_context(patch.object(workload, 'manager', return_value=manager_state())) + + def reaped_child(self): + child = os.fork() + if child == 0: + os._exit(0) + self.assertEqual((child, 0), os.waitpid(child, 0)) + (self.root / 'cgroup.procs').write_text(str(child)) + + @unittest.skipUnless(hasattr(os, 'pidfd_open'), 'requires Linux pidfd support') + def test_reaped_process_is_counted_as_exited_sample(self): + self.reaped_child() + result = workload.observe(HANDLE) + self.assertEqual([], result['processes']) + self.assertEqual(1, result['exitedDuringSample']) + self.assertEqual(4096, result['cgroupMemoryBytes']) + self.assertEqual(2, self.manager.call_count) + + @unittest.skipUnless(hasattr(os, 'pidfd_open'), 'requires Linux pidfd support') + def test_reaped_process_does_not_skip_final_invocation_check(self): + self.reaped_child() + self.manager.side_effect = [manager_state(), manager_state(invocation='replacement')] + with self.assertRaisesRegex(workload.WorkloadError, 'invocation-changed'): + workload.observe(HANDLE) + + def test_sampling_permission_failure_is_not_an_exited_process(self): + (self.root / 'cgroup.procs').write_text('123') + with patch.object(workload, '_process', side_effect=PermissionError('denied')): + with self.assertRaises(PermissionError): + workload.observe(HANDLE) + + @unittest.skipUnless(hasattr(os, 'pidfd_open'), 'requires Linux pidfd support') + def test_exit_after_sample_is_counted_and_final_invocation_is_checked(self): + for replaced in (False, True): + with self.subTest(replaced=replaced), subprocess.Popen(['/usr/bin/sleep', '30']) as child: + try: + (self.root / 'cgroup.procs').write_text(str(child.pid)) + self.manager.side_effect = [manager_state(), manager_state( + invocation='replacement' if replaced else INVOCATION)] + def sampled(pid, role, uid): + self.assertEqual(child.pid, pid) + child.terminate() + child.wait(timeout=5) + return {'hostPid': pid} + # Keep the real pidfd open/readiness/close path; terminate only + # after _process has opened it, at the end of the sample. + with patch.object(workload, '_process_sample', side_effect=sampled): + if replaced: + with self.assertRaisesRegex(workload.WorkloadError, 'invocation-changed'): + workload.observe(HANDLE) + else: + result = workload.observe(HANDLE) + self.assertEqual([], result['processes']) + self.assertEqual(1, result['exitedDuringSample']) + finally: + if child.poll() is None: + child.kill() + child.wait(timeout=5) + + +class LifecycleTest(unittest.TestCase): + def setUp(self): + self.stack = ExitStack() + self.addCleanup(self.stack.close) + self.record = dict(handle=HANDLE, state='prepared', generation=None, bootId=BOOT, + managerInvocation=None, cgroupIdentity=None, stopReason=None) + self.campaign = dict(deadlineMonotonicNs=10**20, maxOperations=100) + self.marker = None + self.writes = [] + self.calls = [] + self.state = manager_state('inactive') + self.current = self.stack.enter_context(patch.object(workload, 'current')) + self.stack.enter_context(patch.object(workload, 'locked', side_effect=nullcontext)) + self.stack.enter_context(patch.object(workload, 'retained', side_effect=lambda _handle: + (self.campaign, ROLE, copy.deepcopy(self.record)))) + self.stack.enter_context(patch.object(workload, 'write', side_effect=self.write)) + self.stack.enter_context(patch.object(workload, 'read', side_effect=self.read)) + self.stack.enter_context(patch.object(workload, 'tree_identity', return_value='exact-input')) + self.stack.enter_context(patch.object(workload, 'boot', return_value=BOOT)) + self.group = Mock() + self.group.stat.return_value = SimpleNamespace(st_dev=1, st_ino=99) + self.stack.enter_context(patch.object(workload, 'group', return_value=self.group)) + self.quiescent = self.stack.enter_context(patch.object(workload, 'quiescent', return_value=True)) + self.manager = self.stack.enter_context(patch.object(workload, 'manager', side_effect=self.operation)) + + def read(self, path): + if path.name.endswith('-start.json'): + if self.marker is None: + raise FileNotFoundError('no-manager-receipt') + return copy.deepcopy(self.marker) + return {'inputs': {'package': 'exact-input'}} + + def write(self, path, value): + if path.name == 'campaign.json': + self.campaign = copy.deepcopy(value) + return + self.record = copy.deepcopy(value) + self.writes.append(copy.deepcopy(value)) + + def operation(self, role, operation): + self.assertEqual(ROLE, role) + self.calls.append(operation) + if operation == 'start': + self.assertEqual('launching', self.record['state']) + self.state = manager_state() + self.marker = dict(generation=self.record['generation'], bootId=BOOT, + managerInvocation=INVOCATION, cgroupIdentity=[1, 99]) + elif operation == 'stop': + self.assertEqual('stopping', self.record['state']) + self.state = manager_state('inactive') + elif operation == 'show': + return copy.deepcopy(self.state) + + def running(self): + workload.start(HANDLE) + self.calls.clear() + + def test_launch_intent_precedes_mutation_and_retry_returns_same_invocation(self): + first = workload.start(HANDLE) + second = workload.start(HANDLE) + self.assertEqual(first, second) + self.assertEqual(['launching', 'running'], [row['state'] for row in self.writes]) + self.assertEqual(1, self.calls.count('start')) + self.assertEqual([1, 99], self.record['cgroupIdentity']) + + def test_lost_start_response_retains_intent_and_prevents_duplicate_launch(self): + def lost(role, operation): + result = self.operation(role, operation) + if operation == 'start': + raise subprocess.TimeoutExpired('fixed-manager-start', 35) + return result + self.manager.side_effect = lost + with self.assertRaises(subprocess.TimeoutExpired): + workload.start(HANDLE) + self.assertEqual('launching', self.record['state']) + self.manager.side_effect = self.operation + result = workload.start(HANDLE) + self.assertEqual(1, self.calls.count('start')) + self.assertEqual('running', result['state']) + self.assertEqual(INVOCATION, result['managerInvocation']) + + def test_lost_start_without_manager_receipt_retains_uncertainty(self): + def lost(_role, operation): + if operation == 'start': + self.calls.append(operation) + raise subprocess.TimeoutExpired('fixed-manager-start', 35) + return copy.deepcopy(self.state) + self.manager.side_effect = lost + with self.assertRaises(subprocess.TimeoutExpired): + workload.start(HANDLE) + with self.assertRaises(FileNotFoundError): + workload.start(HANDLE) + self.assertEqual('launching', self.record['state']) + self.assertEqual(1, self.calls.count('start')) + + def test_stale_manager_receipt_is_not_adopted(self): + self.running() + self.record['state'] = 'launching' + self.marker['generation'] = 'different-generation' + with self.assertRaisesRegex(workload.WorkloadError, 'launch-reconciliation-required'): + workload.start(HANDLE) + self.assertEqual('launching', self.record['state']) + self.assertNotIn('start', self.calls) + + def test_exhausted_operation_budget_denies_launch_but_leaves_stop_available(self): + self.running() + self.campaign['maxOperations'] = self.campaign['usedOperations'] + with self.assertRaisesRegex(workload.WorkloadError, 'operation-budget-exhausted'): + workload.start(HANDLE) + result = workload.stop(HANDLE) + self.assertEqual('quiescent', result['state']) + + def test_changed_invocation_does_not_stop_unrelated_live_service(self): + self.running() + self.state['InvocationID'] = 'c' * 32 + with self.assertRaisesRegex(workload.WorkloadError, 'invocation-changed'): + workload.stop(HANDLE) + self.assertNotIn('stop', self.calls) + self.assertEqual('running', self.record['state']) + + def test_replaced_cgroup_does_not_signal_new_occupant(self): + self.running() + self.group.stat.return_value = SimpleNamespace(st_dev=1, st_ino=100) + with self.assertRaisesRegex(workload.WorkloadError, 'cgroup-changed'): + workload.stop(HANDLE) + self.assertNotIn('stop', self.calls) + + def test_changed_boot_does_not_adopt_old_process(self): + self.running() + with patch.object(workload, 'boot', return_value='different-boot'): + with self.assertRaisesRegex(workload.WorkloadError, 'invocation-changed'): + workload.start(HANDLE) + self.assertNotIn('start', self.calls) + + def test_stop_remains_available_after_expiry_and_revocation(self): + self.running() + self.current.reset_mock() + self.current.side_effect = workload.WorkloadError('restricted-workload-authority-expired-or-revoked') + result = workload.stop(HANDLE) + self.assertEqual('quiescent', result['state']) + self.assertIn('stop', self.calls) + self.current.assert_not_called() + + def test_nonempty_cgroup_retains_reconciliation_failure(self): + self.running() + self.quiescent.return_value = False + with self.assertRaisesRegex(workload.WorkloadError, 'descendants-not-quiescent'): + workload.stop(HANDLE) + self.assertEqual('reconciliation-required', self.record['state']) + self.assertEqual(HANDLE, self.record['handle']) + self.assertEqual(INVOCATION, self.record['managerInvocation']) + + def test_authority_change_after_start_stops_exact_owned_invocation(self): + self.current.side_effect = [None, None, + workload.WorkloadError('restricted-workload-authority-expired-or-revoked')] + with self.assertRaisesRegex(workload.WorkloadError, 'authority-expired-or-revoked'): + workload.start(HANDLE) + self.assertEqual('quiescent', self.record['state']) + self.assertEqual('authority-changed', self.record['stopReason']) + self.assertEqual(1, self.calls.count('stop')) + + def test_input_change_prevents_first_mutation(self): + with patch.object(workload, 'tree_identity', return_value='changed-input'): + with self.assertRaisesRegex(workload.WorkloadError, 'input-substituted'): + workload.start(HANDLE) + self.assertEqual([], self.writes) + self.assertNotIn('start', self.calls) + + +class FakeConnection: + def __init__(self, raw, uid=1000): + self.raw, self.uid = raw, uid + self.sent = [] + + def getsockopt(self, level, option, size): + assert (level, option, size) == (socket.SOL_SOCKET, socket.SO_PEERCRED, struct.calcsize('3i')) + return struct.pack('3i', 123, self.uid, 1000) + + def settimeout(self, timeout): + self.timeout = timeout + + def recv(self, length): + value, self.raw = self.raw[:length], self.raw[length:] + return value + + def sendall(self, value): + self.sent.append(value) + + +class ControllerRequestTest(unittest.TestCase): + def raw(self, **values): + return json.dumps(dict(method='start', handle=HANDLE, **values)).encode() + + def test_fixed_handle_request_dispatches_without_environment_or_paths(self): + connection = FakeConnection(self.raw() + b'\n') + with patch.object(workload, 'start', return_value={'state': 'running'}) as start: + self.assertEqual('start', controller.serve(connection, 1000)) + start.assert_called_once_with(HANDLE) + self.assertEqual([b'{"state":"running"}\n'], connection.sent) + + def test_duplicate_fields_are_rejected(self): + raw = ('{"method":"start","method":"stop","handle":"' + HANDLE + '"}').encode() + with self.assertRaisesRegex(workload.WorkloadError, 'duplicate-request-field'): + controller.request(raw) + + def test_client_cannot_add_pid_namespace_command_or_environment(self): + for key, value in (('pid', 1), ('namespace', '/proc/1/ns/net'), ('command', '/bin/sh'), + ('environment', {'LD_PRELOAD': '/tmp/evil'}), ('port', 22), + ('unit', 'unrelated.service')): + with self.subTest(key=key), self.assertRaises(workload.WorkloadError): + controller.request(self.raw(**{key: value})) + + def test_invalid_method_handle_and_size_are_rejected(self): + for raw in (b'', b' ' * 257, b'[]', b'{"method":"shell","handle":"' + HANDLE.encode() + b'"}', + b'{"method":"start","handle":"../other"}'): + with self.subTest(raw=raw), self.assertRaises(ValueError): + controller.request(raw) + + def test_wrong_peer_cannot_invoke_manager(self): + with patch.object(workload, 'start') as start: + with self.assertRaisesRegex(workload.WorkloadError, 'peer-denied'): + controller.serve(FakeConnection(self.raw() + b'\n', uid=2000), 1000) + start.assert_not_called() + + def test_multiple_or_trailing_requests_are_not_dispatched(self): + for raw in (self.raw() + b'\n{}\n', self.raw() + b'\ntrailing', self.raw()): + with patch.object(workload, 'start') as start: + with self.assertRaises(workload.WorkloadError): + controller.serve(FakeConnection(raw), 1000) + start.assert_not_called() + + +class LauncherTest(unittest.TestCase): + def test_only_role_state_and_exact_inputs_are_mounted(self): + for role in workload.ROLES: + with self.subTest(role=role): + command, environment = launcher.command(role) + mounted = [] + for index, token in enumerate(command): + if token in ('--bind', '--ro-bind'): + mounted.append((token, command[index+1], command[index+2])) + inputs = launcher.ROOT / 'roles' / role + state = launcher.ROOT / 'state' / role + self.assertEqual([ + ('--ro-bind', '/usr', '/usr'), + ('--ro-bind', str(inputs / 'package'), '/package'), + ('--ro-bind', str(inputs / 'jdk'), '/jdk'), + ('--ro-bind', str(inputs / 'apps'), '/inputs/apps'), + ('--ro-bind', str(inputs / 'public'), '/inputs/public'), + ('--bind', str(state), '/node'), ('--bind', str(state / 'tmp'), '/tmp')], mounted) + self.assertEqual('/package/bin/cryptad', command[command.index('--')+1]) + self.assertEqual('bubblewrap', environment['CRYPTAD_APPHOST_SANDBOX_PROVIDER']) + self.assertNotIn('GITHUB_TOKEN', environment) + self.assertNotIn('LD_PRELOAD', environment) + + def test_daemon_profile_permits_nested_apphost_namespace_creation(self): + command, _environment = launcher.command(ROLE) + self.assertIn('--unshare-user', command) + self.assertIn('--unshare-pid', command) + self.assertNotIn('--disable-userns', command) + self.assertNotIn('--assert-userns-disabled', command) + self.assertEqual('ALL', command[command.index('--cap-drop')+1]) + + def test_invalid_role_never_becomes_path_or_command(self): + for role in ('../authority', 'candidate-sender;id', 'previous/../../', None): + with self.subTest(role=role), self.assertRaises(ValueError): + launcher.command(role) + + +class PreparationTest(unittest.TestCase): + @unittest.skipUnless(os.geteuid() == 0, 'preparation fixture drops to a distinct role UID') + def test_restrictive_umask_preserves_role_access_and_private_authority(self): + # Exercise the real preparation writes and UID-dropped initialization. Product + # admission, systemd and tmpfs mounting are fixtures, not installed acceptance. + preparation.sys.path.insert(0, str(Path(__file__).resolve().parents[2] / 'interop')) + preparation.sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + import cross_version_runtime as runtime + import restricted_workload_network as network + with tempfile.TemporaryDirectory(prefix='workload-umask-', dir='/var/lib') as directory, ExitStack() as stack: + root = Path(directory) + root.chmod(0o711) + stack.enter_context(patch.object(workload, 'ROOT', root)) + stack.enter_context(patch.object(workload, 'locked', side_effect=nullcontext)) + users = {role: SimpleNamespace(pw_uid=61000 + index, pw_gid=61000 + index) + for index, role in enumerate(workload.ROLES)} + stack.enter_context(patch.object(workload, 'account', side_effect=users.__getitem__)) + stack.enter_context(patch.object(workload, 'manager', return_value={'ActiveState': 'inactive'})) + stack.enter_context(patch.object(workload, 'quiescent', return_value=True)) + stack.enter_context(patch.object(workload, 'execution_record_digest', return_value='fixed')) + stack.enter_context(patch.object(preparation.os, 'statvfs', return_value=SimpleNamespace( + f_bavail=20 * 1024**3, f_frsize=1))) + stack.enter_context(patch.object(network, 'setup')) + stack.enter_context(patch.dict('sys.modules', {'workload_installation': SimpleNamespace( + verify=lambda: {'sourceCommit': 'source'})})) + from cryptad_certification import cross_version_evidence + stack.enter_context(patch.object(cross_version_evidence, 'validate_plan')) + stack.enter_context(patch.object(runtime, 'runner_identity', return_value={'sourceCommit': 'source'})) + stack.enter_context(patch.object(preparation, 'configuration_identity', return_value='config')) + stack.enter_context(patch.object(preparation, 'configuration', return_value='fixed-config')) + stack.enter_context(patch.object(preparation, 'tree_identity', return_value={'sizeBytes': 0, 'fileCount': 0})) + stack.enter_context(patch.object(runtime, 'tree_digest', return_value='jdk')) + stack.enter_context(patch.object(runtime, 'require_native_target')) + stack.enter_context(patch.object(runtime, 'packaged_daemon_identity', side_effect=lambda _p, source: source)) + stack.enter_context(patch.object(runtime, 'digest_file', return_value='java')) + archive = root / 'fixture.tar' + with preparation.tarfile.open(archive, 'w'): + pass + jdk = root / 'jdk' + jdk.mkdir() + trust = root / 'trust' + trust.write_bytes(b'') + def extract(_source, destination, *_args): + destination.mkdir() + return destination + stack.enter_context(patch.object(runtime, 'extract_package', side_effect=extract)) + stack.enter_context(patch.object(runtime, 'extract_app_bundle', side_effect=extract)) + def mount(arguments, **_kwargs): + # Emulate only tmpfs root ownership; no mount or host unit is changed. + node = Path(arguments[-1]) + user = users[node.name] + os.chown(node, user.pw_uid, user.pw_gid) + node.chmod(0o700) + stack.enter_context(patch.object(preparation.subprocess, 'run', side_effect=mount)) + plan = dict(provenanceClass='source-build-comparison', experimentId='synthetic', + producer={'sourceCommit': 'source'}, nodes=[]) + private = dict(root='/private', nodes={}) + for role in workload.ROLES: + apps = [] if role in ('previous', 'relay-no-apps') else [dict( + appId='mail-prototype', bundleDigest='app', bundlePath=str(archive))] + plan['nodes'].append(dict(role=role, product='cryptad', appDigests=['app'] if apps else [], + configDigest='config', artifactDigest='fixture', artifactSize=0, + sourceCommit=role, packageTarget='fixture', runtimeDigest='jdk')) + private['nodes'][role] = dict(archivePath=str(archive), javaHome=str(jdk), + fnpPort=network.FNP_PORT, fcpPort=network.FCP_PORT, httpPort=network.HTTP_PORT, + apps=apps, trustedKeysPath=str(trust), + trustedKeysDigest='sha256:' + preparation.hashlib.sha256(b'').hexdigest()) + authorization = dict(syntheticContent=True, planDigest=runtime.canonical_digest(plan), + experimentId='synthetic', maxSeconds=60, maxOperations=10) + previous_umask = os.umask(0o077) + try: + preparation.prepare(plan, private, authorization) + finally: + os.umask(previous_umask) + for name in ('authority', 'staging'): + self.assertEqual(0o700, stat.S_IMODE((root / name).stat().st_mode)) + for name in ('roles', 'state'): + self.assertEqual(0o711, stat.S_IMODE((root / name).stat().st_mode)) + for role, user in users.items(): + inputs = root / 'roles' / role + self.assertEqual(0o750, stat.S_IMODE(inputs.stat().st_mode)) + self.assertEqual(0o444, stat.S_IMODE((inputs / 'launch.json').stat().st_mode)) + child = os.fork() + if child == 0: + try: + os.setgroups([]) + os.setgid(user.pw_gid) + os.setuid(user.pw_uid) + json.loads((inputs / 'launch.json').read_text()) + self.assertEqual('fixed-config', (root / 'state' / role / 'config/cryptad.ini').read_text()) + self.assertFalse(os.access(root / 'authority', os.X_OK)) + self.assertFalse(os.access(root / 'campaign.json', os.R_OK)) + os._exit(0) + except BaseException: + os._exit(1) + self.assertEqual(0, os.waitpid(child, 0)[1]) + + def selection(self): + return (dict(provenanceClass='source-build-comparison', experimentId='synthetic', + nodes=[{'role': role} for role in workload.ROLES]), + dict(root='/private', nodes={role: {} for role in workload.ROLES}), + dict(syntheticContent=True, planDigest='exact', experimentId='synthetic', + maxSeconds=60, maxOperations=10)) + + def test_unadapted_workloads_and_invalid_budgets_fail_before_lock_or_mutation(self): + runtime = SimpleNamespace(canonical_digest=lambda _plan: 'exact') + for section, key, value in (('plan', 'workloadInputs', {'catalog': True}), + ('plan', 'provenanceClass', 'original-release'), + ('authorization', 'maxSeconds', 3601), + ('authorization', 'maxOperations', 0), + ('authorization', 'syntheticContent', False)): + with self.subTest(key=key): + plan, private, authorization = self.selection() + (plan if section == 'plan' else authorization)[key] = value + with patch.dict('sys.modules', {'cross_version_runtime': runtime}), \ + patch.object(workload, 'locked') as locked, patch.object(workload, 'write') as write: + with self.assertRaisesRegex(workload.WorkloadError, 'profile-selection-unsupported'): + preparation.prepare(plan, private, authorization) + locked.assert_not_called() + write.assert_not_called() + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/release-certification/protected/test_restricted_workload_app.py b/tools/release-certification/protected/test_restricted_workload_app.py new file mode 100644 index 0000000000..548c97bb1d --- /dev/null +++ b/tools/release-certification/protected/test_restricted_workload_app.py @@ -0,0 +1,260 @@ +"""Scoped-process and listener-binding tests; synthetic proc data is not installed acceptance.""" +import copy +import os +from pathlib import Path +import socket +import tempfile +import time +from types import SimpleNamespace +import unittest +from unittest.mock import patch + +import restricted_workload_app as app + +JAVA = 'sha256:' + 'a' * 64 +OTHER = 'sha256:' + 'b' * 64 +ROLE = 'candidate-sender' +RUNTIME = {'running': True, 'pid': 8, 'sandbox': {'provider': 'bubblewrap', 'active': True}} + + +def process(pid, parent, namespace_pids, namespace, java=False): + return {'hostPid': pid, 'hostParentPid': parent, 'startTicks': pid * 100, + 'pidNamespace': namespace, 'namespacePids': namespace_pids, + 'executableDigest': JAVA if java else OTHER, + 'noNewPrivileges': True, 'effectiveCapabilities': 0} + + +class AppBindingTests(unittest.TestCase): + def setUp(self): + self.processes = { + 100: process(100, 90, [100, 4], 'pid:[5000]', java=True), + 110: process(110, 100, [110, 8], 'pid:[5000]'), + 120: process(120, 110, [120, 9, 1], 'pid:[5001]'), + 130: process(130, 120, [130, 10, 2], 'pid:[5001]', java=True), + } + self.called = [] + for owner, name, replacement in ( + (app, '_members', lambda role, deadline: sorted(self.processes)), + (app, '_listener', lambda daemon, port, deadline: 9001), + (app.workload, 'read', lambda path: {'javaDigest': JAVA}), + (app.workload, 'account', lambda role: SimpleNamespace(pw_uid=60001)), + (app.workload, '_process', self.observe), + ): + holder = patch.object(owner, name, replacement) + holder.start() + self.addCleanup(holder.stop) + + def observe(self, pid, role, uid): + self.assertEqual(role, ROLE) + self.assertEqual(uid, 60001) + self.assertIn(pid, self.processes) + self.called.append(pid) + return copy.deepcopy(self.processes[pid]) + + def test_namespace_pid_hint_resolves_scoped_worker_and_deeper_java(self): + result = app.require_app_listener(ROLE, RUNTIME, 22000) + self.assertEqual(result['daemon']['hostPid'], 100) + self.assertEqual(result['worker']['hostPid'], 110) + self.assertEqual(result['appJvm']['hostPid'], 130) + self.assertEqual(result['listenerInode'], 9001) + self.assertEqual(set(self.called), set(self.processes)) + for pid in self.processes: + self.assertEqual(self.called.count(pid), 2) + self.assertNotIn('executableDigest', result['appJvm']) + + def test_reported_host_pid_is_not_accepted_as_namespace_pid(self): + runtime = {**RUNTIME, 'pid': 110} + with self.assertRaises(app.workload.WorkloadError): + app.require_app_listener(ROLE, runtime, 22000) + self.assertNotIn(8, self.called) + + def test_exited_unrelated_helper_is_skipped_in_roster(self): + self.processes[140] = process(140, 100, [140, 11], 'pid:[5000]') + def sampled(pid, role, uid): + if pid == 140: + raise app.workload.ProcessExitedDuringSample() + return self.observe(pid, role, uid) + with patch.object(app.workload, '_process', sampled): + result = app.require_app_listener(ROLE, RUNTIME, 22000) + self.assertEqual(130, result['appJvm']['hostPid']) + + def test_required_process_exit_rejects_initial_and_final_binding(self): + for required in (100, 110, 120, 130): + for final in (False, True): + self.called.clear() + def sampled(pid, role, uid): + if pid == required and (not final or pid in self.called): + raise app.workload.ProcessExitedDuringSample() + return self.observe(pid, role, uid) + with self.subTest(pid=required, final=final), \ + patch.object(app.workload, '_process', sampled), \ + self.assertRaises(app.workload.WorkloadError): + app.require_app_listener(ROLE, RUNTIME, 22000) + + def test_ownership_failure_in_roster_is_not_skipped(self): + with patch.object(app.workload, '_process', side_effect=app.workload.WorkloadError('process-scope-changed')): + with self.assertRaises(app.workload.WorkloadError): + app.require_app_listener(ROLE, RUNTIME, 22000) + + def test_unsandboxed_or_nonrunning_report_is_denied_before_proc_scan(self): + for runtime in ({**RUNTIME, 'running': False}, + {**RUNTIME, 'sandbox': {'provider': 'none', 'active': True}}, + {**RUNTIME, 'sandbox': {'provider': 'bubblewrap', 'active': False}}, + {**RUNTIME, 'pid': True}): + with self.subTest(runtime=runtime), self.assertRaises(app.workload.WorkloadError): + app.require_app_listener(ROLE, runtime, 22000) + self.assertEqual(self.called, []) + + def test_same_namespace_java_does_not_satisfy_app_sandbox(self): + self.processes[130]['namespacePids'] = [130, 10] + self.processes[130]['pidNamespace'] = 'pid:[5000]' + with self.assertRaises(app.workload.WorkloadError): + app.require_app_listener(ROLE, RUNTIME, 22000) + + def test_unrelated_nested_java_does_not_satisfy_worker_descendant(self): + self.processes[130]['hostParentPid'] = 100 + with self.assertRaises(app.workload.WorkloadError): + app.require_app_listener(ROLE, RUNTIME, 22000) + + def test_app_java_cannot_substitute_for_outer_apphost_worker_pid(self): + with self.assertRaises(app.workload.WorkloadError): + app.require_app_listener(ROLE, {**RUNTIME, 'pid': 10}, 22000) + + def test_missing_deeper_java_rejects_reported_bubblewrap_success(self): + del self.processes[130] + with self.assertRaises(app.workload.WorkloadError): + app.require_app_listener(ROLE, RUNTIME, 22000) + + def test_capabilities_or_missing_no_new_privileges_reject_kernel_child(self): + for field, value in (('effectiveCapabilities', 1), ('noNewPrivileges', False)): + original = self.processes[130][field] + self.processes[130][field] = value + with self.subTest(field=field), self.assertRaises(app.workload.WorkloadError): + app.require_app_listener(ROLE, RUNTIME, 22000) + self.processes[130][field] = original + + def test_pid_reuse_during_binding_is_rejected(self): + def changed(pid, role, uid): + value = self.observe(pid, role, uid) + if self.called.count(pid) > 1 and pid == 130: + value['startTicks'] += 1 + return value + with patch.object(app.workload, '_process', changed), self.assertRaises(app.workload.WorkloadError): + app.require_app_listener(ROLE, RUNTIME, 22000) + + def test_ancestor_reparent_during_binding_is_rejected(self): + def changed(pid, role, uid): + value = self.observe(pid, role, uid) + if self.called.count(pid) > 1 and pid == 120: + value['hostParentPid'] = 100 + return value + with patch.object(app.workload, '_process', changed), self.assertRaises(app.workload.WorkloadError): + app.require_app_listener(ROLE, RUNTIME, 22000) + + def test_listener_replacement_during_binding_is_rejected(self): + with patch.object(app, '_listener', side_effect=[9001, 9002]), self.assertRaises(app.workload.WorkloadError): + app.require_app_listener(ROLE, RUNTIME, 22000) + + def test_missing_trusted_java_identity_is_rejected(self): + with patch.object(app.workload, 'read', return_value={}), self.assertRaises(app.workload.WorkloadError): + app.require_app_listener(ROLE, RUNTIME, 22000) + self.assertEqual(self.called, []) + + +class ProcListenerTests(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + self.daemon = self.root / '100' + (self.daemon / 'net').mkdir(parents=True) + (self.daemon / 'fd').mkdir() + holder = patch.object(app, 'PROC', self.root) + holder.start() + self.addCleanup(holder.stop) + self.write_table('0100007F:55F0', '0A', '9001') + (self.daemon / 'fd/7').symlink_to('socket:[9001]') + + def write_table(self, local, state, inode): + # Relevant fields follow Linux /proc/net/tcp's actual column order. + (self.daemon / 'net/tcp').write_text( + 'sl local_address rem_address st tx_queue rx_queue tr tm_when retrnsmt uid timeout inode\n' + f'0: {local} 00000000:0000 {state} 00000000:00000000 00:00000000 00000000 60001 0 {inode}\n') + + def test_real_bounded_reader_binds_table_inode_to_daemon_fd(self): + self.assertEqual(app._listener({'hostPid': 100}, 22000, float('inf')), 9001) + + def test_jdk_ipv4_mapped_ipv6_socket_preserves_loopback_binding(self): + self.write_table('0000000000000000FFFF00000100007F:55F0', '0A', '9001') + (self.daemon / 'net/tcp').rename(self.daemon / 'net/tcp6') + (self.daemon / 'net/tcp').write_text('header\n') + self.assertEqual(app._listener({'hostPid': 100}, 22000, float('inf')), 9001) + + def test_ipv6_wildcard_is_not_an_approved_loopback_listener(self): + self.write_table('00000000000000000000000000000000:55F0', '0A', '9001') + (self.daemon / 'net/tcp').rename(self.daemon / 'net/tcp6') + (self.daemon / 'net/tcp').write_text('header\n') + with self.assertRaises(app.workload.WorkloadError): + app._listener({'hostPid': 100}, 22000, float('inf')) + + def test_active_listener_owned_by_another_process_is_denied(self): + (self.daemon / 'fd/7').unlink() + unrelated = self.root / '110/fd' + unrelated.mkdir(parents=True) + (unrelated / '8').symlink_to('socket:[9001]') + with self.assertRaises(app.workload.WorkloadError): + app._listener({'hostPid': 100}, 22000, float('inf')) + + def test_wrong_bind_address_and_nonlistener_state_are_denied(self): + for local, state in (('00000000:55F0', '0A'), ('0100007F:55F0', '01'), ('0100007F:55F1', '0A')): + self.write_table(local, state, '9001') + with self.subTest(local=local, state=state), self.assertRaises(app.workload.WorkloadError): + app._listener({'hostPid': 100}, 22000, float('inf')) + + def test_ambiguous_reuseport_listener_is_denied(self): + target = self.daemon / 'net/tcp' + with target.open('a') as stream: + stream.write(target.read_text().splitlines()[1] + '\n') + with self.assertRaises(app.workload.WorkloadError): + app._listener({'hostPid': 100}, 22000, float('inf')) + + def test_proc_table_over_budget_is_rejected_without_unbounded_read(self): + (self.daemon / 'net/tcp').write_bytes(b'x' * (app.MAX_TCP_BYTES + 1)) + with self.assertRaises(app.workload.WorkloadError): + app._listener({'hostPid': 100}, 22000, float('inf')) + + def test_fd_enumeration_limit_is_enforced_even_after_matching_inode(self): + with patch.object(app, 'MAX_FDS', 1): + (self.daemon / 'fd/8').symlink_to('socket:[9002]') + with self.assertRaises(app.workload.WorkloadError): + app._listener({'hostPid': 100}, 22000, float('inf')) + + def test_expired_absolute_deadline_prevents_proc_read(self): + with patch.object(app.os, 'open') as opened, self.assertRaises(app.workload.WorkloadError): + app._listener({'hostPid': 100}, 22000, -1) + opened.assert_not_called() + + def test_cgroup_scope_rejects_nested_group(self): + group = self.root / 'group' + group.mkdir() + (group / 'cgroup.procs').write_text('100\n110\n') + (group / 'nested').mkdir() + with patch.object(app.workload, 'group', return_value=group), self.assertRaises(app.workload.WorkloadError): + app._members(ROLE, float('inf')) + + +class LocalKernelListenerTests(unittest.TestCase): + @unittest.skipUnless(Path('/proc/self/net/tcp').exists(), 'Linux proc interface required') + def test_actual_own_loopback_listener_matches_current_process_socket_inode(self): + # Same-UID socket ownership only; this does not exercise installed role isolation. + with socket.socket() as listener: + listener.bind(('127.0.0.1', 0)) + listener.listen(1) + expected = int(os.readlink('/proc/self/fd/' + str(listener.fileno()))[8:-1]) + actual = app._listener({'hostPid': os.getpid()}, listener.getsockname()[1], + time.monotonic() + 5) + self.assertEqual(actual, expected) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/release-certification/protected/test_restricted_workload_network.py b/tools/release-certification/protected/test_restricted_workload_network.py new file mode 100644 index 0000000000..7941306333 --- /dev/null +++ b/tools/release-certification/protected/test_restricted_workload_network.py @@ -0,0 +1,230 @@ +"""Offline command-policy and retained-state tests; never installed kernel acceptance.""" +import copy +import importlib.util +import os +from pathlib import Path +import socket +import tempfile +from types import SimpleNamespace +import unittest +from unittest.mock import patch + +SPEC = importlib.util.spec_from_file_location( + 'restricted_workload_network', Path(__file__).with_name('restricted_workload_network.py')) +network = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(network) + + +class NetworkPolicyTests(unittest.TestCase): + def test_fixed_roster_has_four_distinct_addresses_and_names(self): + self.assertEqual(len(set(map(network.address, network.ROLES))), 4) + self.assertEqual(len(set(map(network.namespace, network.ROLES))), 4) + for bad in ('unknown', '../relay-no-apps', 'candidate-sender; id', ''): + with self.subTest(role=bad), self.assertRaises(network.NetworkBoundaryError): + network.namespace(bad) + + def test_leaf_udp_policy_has_only_relay_peer_and_drops_other_traffic(self): + rules = network.role_rules('candidate-sender') + self.assertIn('ip daddr { 10.231.0.4 }', rules) + self.assertNotIn('10.231.0.2', rules) + self.assertNotIn('10.231.0.3', rules) + self.assertEqual(rules.count('policy drop'), 3) + self.assertEqual(rules.count('udp sport 19400 udp dport 19400'), 2) + self.assertNotIn('ct state', rules) + self.assertNotIn('tcp', rules) + + def test_relay_rules_preserve_all_three_peer_links(self): + rules = network.role_rules('relay-no-apps') + self.assertIn('{ 10.231.0.1, 10.231.0.2, 10.231.0.3 }', rules) + self.assertEqual(rules.count('policy drop'), 3) + + def test_switch_validates_ingress_egress_and_addresses(self): + rules = network.switch_rules() + for index in range(3): + self.assertIn(f'iifname "sw{index}" oifname "sw3" ether type ip ' + f'ip saddr 10.231.0.{index + 1} ip daddr 10.231.0.4', rules) + self.assertIn(f'iifname "sw3" oifname "sw{index}" ether type ip ' + f'ip saddr 10.231.0.4 ip daddr 10.231.0.{index + 1}', rules) + self.assertNotIn('iifname "sw0" oifname "sw1"', rules) + self.assertEqual(rules.count('ether type arp accept'), 6) + self.assertEqual(rules.count('udp sport 19400 udp dport 19400'), 6) + + def test_non_root_setup_is_rejected_before_mutation(self): + with patch.object(network.os, 'geteuid', return_value=1234), patch.object(network, '_run') as run: + with self.assertRaises(network.NetworkBoundaryError): + network.setup() + run.assert_not_called() + + def test_authority_leaf_must_remain_private_even_when_ancestors_are_traversable(self): + self.assertEqual(network.ROOT.name, 'authority') + root_owned_traversable = SimpleNamespace(st_mode=0o040711, st_uid=0) + root_owned_private = SimpleNamespace(st_mode=0o040700, st_uid=0) + with patch.object(network.os, 'geteuid', return_value=0), \ + patch.object(Path, 'lstat', return_value=root_owned_traversable), \ + patch.object(Path, 'stat', return_value=root_owned_private): + network._guard() + with patch.object(network.os, 'geteuid', return_value=0), \ + patch.object(Path, 'lstat', return_value=root_owned_traversable), \ + patch.object(Path, 'stat', return_value=root_owned_traversable): + with self.assertRaises(network.NetworkBoundaryError): + network._guard() + + def test_arbitrary_connection_selection_is_rejected_before_state_access(self): + with patch.object(network, '_load') as load: + for endpoint in ('19402', 'http://127.0.0.1:1234', 'app', '/run/netns/host'): + with self.subTest(endpoint=endpoint), self.assertRaises(network.NetworkBoundaryError): + network.connect('candidate-sender', endpoint) + load.assert_not_called() + + +class SetupStateTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + self.namespaces = self.root / 'namespaces' + self.namespaces.mkdir() + self.saved = [] + self.commands = [] + self.state = None + for name, value in (('ROOT', self.root), ('NETNS_ROOT', self.namespaces)): + holder = patch.object(network, name, value) + holder.start() + self.addCleanup(holder.stop) + for name, replacement in (('_guard', lambda: None), ('_boot', lambda: 'test-boot'), + ('_save', self.save), ('_run', self.record_command), + ('_namespace_identity', lambda name: [11, len(name)])): + holder = patch.object(network, name, replacement) + holder.start() + self.addCleanup(holder.stop) + + def save(self, value, initial=False): + self.state = copy.deepcopy(value) + self.saved.append(copy.deepcopy(value)) + + def record_command(self, arguments, script=None): + self.commands.append((arguments, script)) + + def test_setup_retains_intent_before_creation_and_filters_before_link_up(self): + def checked_run(arguments, script=None): + if arguments[1:3] == ['netns', 'add']: + self.assertEqual(self.state['pending'], arguments[-1]) + self.assertEqual(self.state['phase'], 'preparing') + if arguments[-2:] == ['data0', 'up']: + role_namespace = arguments[2] + prior = [command for command, _ in self.commands] + self.assertIn([network.IP, 'netns', 'exec', role_namespace, network.NFT, '-f', '-'], prior) + self.record_command(arguments, script) + with patch.object(network, '_run', checked_run): + network.setup() + self.assertEqual(self.state['phase'], 'ready') + self.assertEqual(len(self.state['namespaces']), 5) + self.assertIsNone(self.state['pending']) + self.assertEqual(sum(arguments[1:3] == ['netns', 'add'] for arguments, _ in self.commands), 5) + # No host-side link creation, routing or nft table exists even transiently. + for command, _ in self.commands: + if 'link' in command or 'route' in command or 'address' in command: + self.assertEqual(command[1], '-n') + if network.NFT in command: + self.assertEqual(command[1:3], ['netns', 'exec']) + self.assertNotIn('default', command) + + def test_preexisting_namespace_rejects_without_creating_state(self): + (self.namespaces / network.namespace('previous')).touch() + with self.assertRaises(network.NetworkBoundaryError): + network.setup() + self.assertEqual(self.commands, []) + self.assertEqual(self.saved, []) + + def test_dangling_namespace_symlink_also_rejects(self): + (self.namespaces / network.SWITCH).symlink_to(self.root / 'absent') + with self.assertRaises(network.NetworkBoundaryError): + network.setup() + self.assertEqual(self.commands, []) + + def test_retained_record_prevents_identity_recycling(self): + (self.root / 'network.json').write_text('{}') + with self.assertRaises(network.NetworkBoundaryError): + network.setup() + self.assertEqual(self.commands, []) + + def test_lost_add_response_retains_uncertain_intent_and_does_not_delete(self): + def fail(arguments, script=None): + self.record_command(arguments, script) + raise network.NetworkBoundaryError('injected-loss') + with patch.object(network, '_run', fail), self.assertRaises(network.NetworkBoundaryError): + network.setup() + self.assertEqual(self.state['pending'], network.SWITCH) + self.assertEqual(self.state['phase'], 'preparing') + self.assertEqual(len(self.commands), 1) + with patch.object(network, '_load', return_value=self.state): + with self.assertRaises(network.NetworkBoundaryError): + network.teardown() + + def test_filter_failure_never_marks_ready_or_erases_partial_fabric(self): + def fail_filter(arguments, script=None): + self.record_command(arguments, script) + if network.NFT in arguments: + raise network.NetworkBoundaryError('injected-filter-failure') + with patch.object(network, '_run', fail_filter), self.assertRaises(network.NetworkBoundaryError): + network.setup() + self.assertEqual(self.state['phase'], 'preparing') + self.assertEqual(len(self.state['namespaces']), 5) + self.assertFalse(any('delete' in arguments for arguments, _ in self.commands)) + self.assertFalse(any(arguments[-1] == 'up' for arguments, _ in self.commands)) + + def test_connect_rejects_changed_namespace_before_fork(self): + network.setup() + with patch.object(network, '_load', return_value=self.state), \ + patch.object(network, '_namespace_identity', return_value=[0, 0]), \ + patch.object(network.os, 'fork') as fork: + with self.assertRaises(network.NetworkBoundaryError): + network.connect('previous', 'http') + fork.assert_not_called() + + def test_teardown_rejects_changed_namespace_without_deleting_anything(self): + network.setup() + self.commands.clear() + with patch.object(network, '_load', return_value=self.state), \ + patch.object(network, '_namespace_identity', return_value=[0, 0]): + with self.assertRaises(network.NetworkBoundaryError): + network.teardown() + self.assertEqual(self.commands, []) + + +class SocketTransferTests(unittest.TestCase): + def test_real_fork_passes_connected_socket_without_changing_parent_namespace(self): + # This tests SCM_RIGHTS only. setns is deliberately mocked, so it is NOT evidence + # of installed role isolation or active sibling denial. + role = 'candidate-sender' + with tempfile.TemporaryDirectory() as directory, socket.socket() as listener: + root = Path(directory) + target = root / network.namespace(role) + target.touch() + info = target.stat() + identity = [info.st_dev, info.st_ino] + state = {'phase': 'ready', 'pending': None, + 'namespaces': {network.namespace(role): identity}} + listener.bind(('127.0.0.1', 0)) + listener.listen(1) + listener.settimeout(3) + parent_namespace = Path('/proc/self/ns/net').stat().st_ino + with patch.object(network, 'NETNS_ROOT', root), \ + patch.object(network, '_load', return_value=state), \ + patch.object(network, '_namespace_identity', return_value=identity), \ + patch.object(network.os, 'setns', return_value=None) as setns: + with network._connect_port(role, listener.getsockname()[1]) as connected: + peer, _ = listener.accept() + with peer: + peer.sendall(b'fixed-canary') + self.assertEqual(connected.recv(32), b'fixed-canary') + connected.sendall(b'ack') + self.assertEqual(peer.recv(3), b'ack') + self.assertFalse(os.get_inheritable(connected.fileno())) + # Calls in the forked child do not modify the parent's mock or namespace. + setns.assert_not_called() + self.assertEqual(Path('/proc/self/ns/net').stat().st_ino, parent_namespace) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/release-certification/protected/test_restricted_workload_storage.py b/tools/release-certification/protected/test_restricted_workload_storage.py new file mode 100644 index 0000000000..dffe3dddde --- /dev/null +++ b/tools/release-certification/protected/test_restricted_workload_storage.py @@ -0,0 +1,265 @@ +"""Exercise actual descriptor copies and malicious filesystem inputs.""" +import os +from pathlib import Path +import stat +import tempfile +import time +import unittest +from unittest import mock + +import restricted_workload_storage as storage + + +@unittest.skipUnless(os.geteuid() == 0, 'root-owned immutable staging requires root') +class WorkloadStorageTest(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory(prefix='cryptad-storage-test-', dir='/var/lib') + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + self.source = self.root / 'source' + self.source.mkdir(mode=0o700) + self.destination = self.root / 'destination' + self.deadline = time.monotonic() + 20 + + def copy(self, **kwargs): + return storage.copy_tree(self.source, self.destination, self.deadline, **kwargs) + + def test_copies_private_inputs_with_read_only_executable_modes(self): + (self.source / 'nested').mkdir() + (self.source / 'nested' / 'data').write_bytes(b'abc') + executable = self.source / 'launcher' + executable.write_bytes(b'fixed') + executable.chmod(0o700) + self.assertEqual({'bytes': 8, 'entries': 3}, self.copy()) + self.assertEqual(b'abc', (self.destination / 'nested' / 'data').read_bytes()) + self.assertEqual(0o444, stat.S_IMODE((self.destination / 'nested' / 'data').stat().st_mode)) + self.assertEqual(0o555, stat.S_IMODE((self.destination / 'launcher').stat().st_mode)) + self.assertEqual(0o755, stat.S_IMODE(self.destination.stat().st_mode)) + self.assertEqual(0, self.destination.stat().st_uid) + self.assertEqual(0o700, stat.S_IMODE(self.source.stat().st_mode)) + + def test_rejects_symlink_fifo_and_hardlink_without_opening_them(self): + for kind in ('symlink', 'fifo', 'hardlink'): + with self.subTest(kind=kind): + path = self.source / kind + if kind == 'symlink': + path.symlink_to('/etc/passwd') + elif kind == 'fifo': + os.mkfifo(path) + else: + original = self.root / 'original' + original.write_bytes(b'private') + os.link(original, path) + self.destination = self.root / ('destination-' + kind) + with self.assertRaises(storage.StorageError): + self.copy() + self.assertTrue(self.destination.is_dir()) + self.assertFalse((self.destination / kind).exists()) + path.unlink() + + def test_rejects_writable_ancestor_before_mutation(self): + self.root.chmod(0o777) + with self.assertRaises(storage.StorageError): + self.copy() + self.assertFalse(self.destination.exists()) + self.root.chmod(0o700) + + def test_rejects_non_root_file_owner(self): + path = self.source / 'candidate' + path.write_bytes(b'attack') + os.chown(path, 65534, 65534) + with self.assertRaises(storage.StorageError): + self.copy() + self.assertTrue(self.destination.is_dir()) + + def test_rejects_symlink_ancestor(self): + linked = self.root / 'linked' + linked.symlink_to(self.source, target_is_directory=True) + with self.assertRaises(storage.StorageError): + storage.copy_tree(linked, self.destination, self.deadline) + self.assertFalse(self.destination.exists()) + + def test_byte_and_entry_limits_retain_failed_destination(self): + (self.source / 'data').write_bytes(b'1234') + with self.assertRaisesRegex(storage.StorageError, 'byte-budget'): + self.copy(max_bytes=3) + self.assertTrue(self.destination.is_dir()) + self.destination = self.root / 'second-destination' + (self.source / 'other').write_bytes(b'a') + with self.assertRaisesRegex(storage.StorageError, 'entry-budget'): + self.copy(max_files=1) + self.assertTrue(self.destination.is_dir()) + + def test_existing_destination_never_overwritten(self): + self.destination.mkdir() + marker = self.destination / 'marker' + marker.write_bytes(b'retained') + with self.assertRaises(storage.StorageError): + self.copy() + self.assertEqual(b'retained', marker.read_bytes()) + + def test_expired_deadline_does_not_allocate(self): + self.deadline = time.monotonic() - 1 + with self.assertRaisesRegex(storage.StorageError, 'deadline'): + self.copy() + self.assertFalse(self.destination.exists()) + + def test_modified_input_is_not_credited(self): + path = self.source / 'data' + path.write_bytes(b'abcd') + real_read = os.read + changed = False + + def read_and_replace(fd, size): + nonlocal changed + result = real_read(fd, size) + if result and not changed: + changed = True + path.unlink() + path.write_bytes(b'fake') + return result + + with mock.patch.object(storage.os, 'read', side_effect=read_and_replace): + with self.assertRaises(storage.StorageError): + self.copy() + self.assertTrue(changed) + self.assertTrue(self.destination.exists()) + + def test_replaced_destination_is_rejected_and_both_trees_retained(self): + (self.source / 'data').write_bytes(b'abcd') + real_read = os.read + changed = False + retained = self.root / 'retained' + + def read_and_replace(fd, size): + nonlocal changed + result = real_read(fd, size) + if result and not changed: + changed = True + self.destination.rename(retained) + self.destination.mkdir() + return result + + with mock.patch.object(storage.os, 'read', side_effect=read_and_replace): + with self.assertRaisesRegex(storage.StorageError, 'destination-replaced'): + self.copy() + self.assertEqual(b'abcd', (retained / 'data').read_bytes()) + self.assertTrue(self.destination.is_dir()) + + def test_writable_input_file_is_rejected(self): + path = self.source / 'data' + path.write_bytes(b'untrusted') + path.chmod(0o666) + with self.assertRaisesRegex(storage.StorageError, 'input-not-trusted'): + self.copy() + self.assertFalse((self.destination / 'data').exists()) + + def test_depth_budget_retains_partial_copy(self): + current = self.source + for _ in range(storage.MAX_DEPTH + 1): + current /= 'd' + current.mkdir() + with self.assertRaisesRegex(storage.StorageError, 'depth-exceeded'): + self.copy() + self.assertTrue(self.destination.exists()) + + def test_destination_inside_source_is_rejected(self): + self.destination = self.source / 'recursive' + with self.assertRaisesRegex(storage.StorageError, 'path-invalid'): + self.copy() + self.assertFalse(self.destination.exists()) + + def test_invalid_budgets_rejected_before_mutation(self): + for value in (0, True, storage.MAX_BYTES + 1): + with self.subTest(value=value), self.assertRaises(storage.StorageError): + self.copy(max_bytes=value) + self.assertFalse(self.destination.exists()) + + +@unittest.skipUnless(os.geteuid() == 0, 'candidate snapshot verification requires root') +class InstalledAppSnapshotTest(unittest.TestCase): + def setUp(self): + from restricted_native_launcher import tree_identity + self.temporary = tempfile.TemporaryDirectory(prefix='cryptad-app-snapshot-test-', dir='/var/lib') + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + self.source = self.root / 'installed' + self.source.mkdir() + (self.source / 'run').write_bytes(b'signed-original') + (self.source / 'run').chmod(0o755) + self.expected = tree_identity(self.source) + # The source now really belongs to an untrusted workload identity. + os.chown(self.source / 'run', 65534, 65534) + os.chown(self.source, 65534, 65534) + self.scratch = self.root / 'verification' + + def verify(self): + return storage.verify_installed_app(self.source, self.expected, self.scratch, + time.monotonic() + 10) + + def test_exact_untrusted_installed_bytes_match_and_only_snapshot_removed(self): + self.assertEqual(self.expected, self.verify()) + self.assertFalse(self.scratch.exists()) + self.assertEqual(b'signed-original', (self.source / 'run').read_bytes()) + self.assertEqual(65534, self.source.stat().st_uid) + + def test_changed_bytes_retain_exclusive_failure_and_prevent_retry(self): + (self.source / 'run').write_bytes(b'candidate-forged') + with self.assertRaisesRegex(storage.StorageError, 'identity-mismatch'): + self.verify() + self.assertTrue((self.scratch / 'tree').is_dir()) + with self.assertRaises(storage.StorageError): + self.verify() + self.assertEqual(b'candidate-forged', (self.source / 'run').read_bytes()) + + def test_fifo_symlink_and_hardlink_fail_without_deleting_candidate_state(self): + from restricted_native import NativeBoundaryError + for kind in ('fifo', 'symlink', 'hardlink'): + with self.subTest(kind=kind): + hostile = self.source / 'hostile' + if kind == 'fifo': + os.mkfifo(hostile) + elif kind == 'symlink': + hostile.symlink_to('/etc/passwd') + else: + os.link(self.source / 'run', hostile) + self.scratch = self.root / ('verification-' + kind) + with self.assertRaises((storage.StorageError, NativeBoundaryError)): + self.verify() + self.assertTrue(self.scratch.is_dir()) + self.assertTrue(hostile.exists() or hostile.is_symlink()) + hostile.unlink() + + def test_oversized_sparse_output_is_rejected_before_copying(self): + from restricted_native import NativeBoundaryError + with (self.source / 'oversize').open('wb') as stream: + stream.truncate(storage.APP_MAX_BYTES + 1) + with self.assertRaises((storage.StorageError, NativeBoundaryError)): + self.verify() + self.assertTrue(self.scratch.is_dir()) + self.assertFalse((self.scratch / 'tree/oversize').exists()) + + def test_existing_scratch_does_not_inspect_or_replace_source(self): + self.scratch.mkdir() + (self.scratch / 'retained').write_bytes(b'failure') + with self.assertRaises(storage.StorageError): + self.verify() + self.assertEqual(b'failure', (self.scratch / 'retained').read_bytes()) + + def test_untrusted_scratch_ancestor_is_rejected_before_reservation(self): + self.root.chmod(0o777) + with self.assertRaises(storage.StorageError): + self.verify() + self.assertFalse(self.scratch.exists()) + self.root.chmod(0o700) + + +class StorageRootPolicyTest(unittest.TestCase): + def test_nonroot_cannot_stage(self): + with mock.patch.object(storage.os, 'geteuid', return_value=65534): + with self.assertRaisesRegex(storage.StorageError, 'root-required'): + storage.copy_tree('/missing', '/missing-copy', time.monotonic() + 1) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/release-certification/restricted/installation.py b/tools/release-certification/restricted/installation.py index 5dd7b98f06..1d2ffbfba7 100644 --- a/tools/release-certification/restricted/installation.py +++ b/tools/release-certification/restricted/installation.py @@ -33,7 +33,7 @@ 'pr312_output_faults.py', 'pr312_app_projection.py', 'pr313_boot_inputs.py', 'pr313_fixtures.py', 'pr313_faults.py', 'pr313_worker_faults.py', 'pr313_acceptance.py', 'pr313_acceptance_runner.py', 'pr313_observations.py', - 'pr313_public_faults.py')) + 'pr313_public_faults.py', 'pr314_acceptance.py', 'pr314_workload_driver.py')) MAX_FILE = 512 * 1024 * 1024 DEPENDENCY_ROOTS = ('/usr/lib/python3.13', '/usr/lib/x86_64-linux-gnu', '/usr/lib64', '/usr/libexec/sudo', '/usr/lib/polkit-1', '/usr/share/polkit-1', '/usr/share/dbus-1', '/etc/dbus-1') @@ -54,6 +54,8 @@ '/usr/share/dbus-1/system.d/org.freedesktop.PolicyKit1.conf', '/usr/lib/pam.d/polkit-1', '/usr/libexec/polkit-agent-helper-1', '/usr/bin/bwrap', '/usr/bin/prlimit', '/usr/bin/gh', '/usr/bin/git', + '/usr/sbin/ip', '/usr/sbin/nft', '/usr/bin/mount', '/usr/bin/umount', + '/usr/bin/env', '/usr/bin/bash', '/usr/bin/dirname', '/usr/bin/dpkg', '/usr/bin/getconf', '/usr/bin/systemd-sysusers', '/usr/bin/systemd-tmpfiles', '/usr/bin/systemctl', '/usr/bin/sudo', '/usr/bin/setpriv', '/usr/bin/true', '/usr/lib/systemd/systemd', '/etc/ld.so.cache', '/etc/ld.so.conf', '/etc/ssl/certs/ca-certificates.crt', diff --git a/tools/release-certification/restricted/pr314_acceptance.py b/tools/release-certification/restricted/pr314_acceptance.py new file mode 100644 index 0000000000..2372b4d154 --- /dev/null +++ b/tools/release-certification/restricted/pr314_acceptance.py @@ -0,0 +1,230 @@ +"""Closed prospective workload observations, separate from finite native acceptance. + +Only an administrator driver over its pinned guest transport may call this verifier with +observations. JSON shape and digests do not authenticate execution. There is deliberately no +report-import CLI or production approval API. Contract tests use synthetic records, which are +not installed observations and must never be published as such. +""" +from dataclasses import dataclass +import re + +CONTRACT = 'pr314-workload-roles-v1' +PROFILE = 'debian13-systemd257-workload-v1' +ROLES = ('candidate-sender', 'candidate-recipient', 'previous', 'relay-no-apps') +IDENTITY_FIELDS = ('helperSourceCommit', 'helperSourceTree', 'productSelectionDigest', + 'bundleIdentity', 'testKitDigest', 'profileDigest', 'bootClosureDigest', + 'preparedImageDigest', 'fixtureManifestDigest') +STATUSES = frozenset(('passed', 'failed', 'setup-failed', 'not-executed', 'inconclusive')) + + +@dataclass(frozen=True) +class Case: + actor: str + target: str + outcome: str + witness: str + + +CASES = { + 'installed-ready': Case('administrator', 'installation', 'verified', 'identity'), + 'four-role-start': Case('observer', 'four-role-roster', 'running', 'roster'), + 'signed-apphost-child': Case('observer', 'own-app', 'sandboxed', 'app'), + 'own-management': Case('observer', 'own-management', 'connected', 'exchange'), + 'fnp-content-retrieval': Case('observer', 'approved-fnp-links', 'retrieved', 'exchange'), + 'dynamic-app-bootstrap': Case('observer', 'own-app', 'bound-session', 'exchange'), + 'kernel-resource-scope': Case('administrator', 'owned-cgroups', 'measured', 'resources'), + 'restart-durable-state': Case('observer', 'owned-role', 'new-epoch', 'restart'), +} +for name, actor, target in ( + ('observer-private-read', 'candidate', 'observer-private'), + ('observer-private-write', 'candidate', 'observer-private'), + ('resolver-authority', 'candidate', 'resolver'), + ('provider-authority', 'app', 'original-provider'), + ('sibling-data', 'candidate', 'sibling-data'), + ('sibling-fcp', 'candidate', 'sibling-fcp'), + ('sibling-http', 'candidate', 'sibling-http'), + ('sibling-app', 'app', 'sibling-app'), + ('host-network', 'candidate', 'host-canary'), + ('runner-control', 'runner', 'manager'), + ('observer-control', 'observer', 'manager'), + ('arbitrary-endpoint', 'observer', 'connector'), + ('hostile-app-origin', 'candidate', 'own-app'), + ('package-expectation', 'candidate', 'immutable-inputs'), + ('forged-runtime-identity', 'candidate', 'process-observer'), + ('namespace-mount-escape', 'app', 'outer-role'), + ('cgroup-migration', 'candidate', 'owned-cgroups'), + ('resource-exhaustion', 'app', 'outer-role'), + ('output-symlink', 'candidate', 'output-collector'), + ('output-fifo', 'candidate', 'output-collector'), + ('output-hardlink', 'candidate', 'output-collector'), + ('output-replacement', 'candidate', 'output-collector'), + ('output-flood', 'candidate', 'output-collector'), + ('stale-handle', 'observer', 'controller'), + ('stale-pid-uid', 'observer', 'process-observer'), + ('forged-counters', 'candidate', 'kernel-metrics'), +): + CASES[name] = Case(actor, target, 'denied', 'denial') +for name in ('lost-start-response', 'observer-death', 'controller-restart', 'late-child', + 'setsid-double-fork', 'deadline', 'revocation-running', 'cancellation', + 'partial-launch', 'stuck-output', 'cleanup-race'): + CASES[name] = Case('administrator', 'owned-roles', 'quiescent', 'lifecycle') + + +def _closed(value, fields): + return isinstance(value, dict) and set(value) == set(fields) + + +def _hex(value, length=64): + return isinstance(value, str) and re.fullmatch('[0-9a-f]{%d}' % length, value) is not None + + +def _positive(value): + return type(value) is int and value > 0 + + +def _identity(value): + return (_closed(value, IDENTITY_FIELDS) and all(_hex(value[key], + 40 if key.endswith(('Commit', 'Tree')) else 64) for key in IDENTITY_FIELDS)) + + +def _roster(rows): + if not isinstance(rows, list) or len(rows) != len(ROLES): + return False + if not all(_closed(row, ('role', 'uid', 'gid', 'invocationId', 'processEpoch', + 'bootId', 'cgroupDigest', 'networkNamespace')) for row in rows): + return False + if not all(isinstance(row['role'], str) and row['role'] in ROLES and + all(_positive(row[key]) for key in ('uid', 'gid', 'processEpoch', 'networkNamespace')) + and _hex(row['invocationId'], 32) and _hex(row['bootId'], 32) + and _hex(row['cgroupDigest']) for row in rows): + return False + return (set(row['role'] for row in rows) == set(ROLES) + and all(len({row[key] for row in rows}) == 4 for key in + ('uid', 'gid', 'invocationId', 'cgroupDigest', 'networkNamespace')) + and len({row['bootId'] for row in rows}) == 1) + + +def _witness(case, witness, identity): + kind = case.witness + if kind == 'identity': + return witness == {'profile': PROFILE, 'bundleIdentity': identity['bundleIdentity'], + 'testKitDigest': identity['testKitDigest']} + if kind == 'roster': + return (_closed(witness, ('observerUid', 'roles')) and _roster(witness['roles']) + and _positive(witness['observerUid']) + and witness['observerUid'] not in {row['uid'] for row in witness['roles']}) + if kind == 'app': + return (_closed(witness, ('role', 'provider', 'hostPid', 'namespacePid', + 'processEpoch', 'invocationId', 'installedAppDigest')) + and witness['role'] == 'candidate-sender' and witness['provider'] == 'bubblewrap' + and all(_positive(witness[key]) for key in ('hostPid', 'namespacePid', 'processEpoch')) + and _hex(witness['invocationId'], 32) and _hex(witness['installedAppDigest'])) + if kind == 'exchange': + return (_closed(witness, ('requestDigest', 'expectedResponseDigest', 'responseDigest', + 'serverInvocationId', 'serverRequests')) + and all(_hex(witness[key]) for key in ('requestDigest', 'expectedResponseDigest', 'responseDigest')) + and witness['responseDigest'] == witness['expectedResponseDigest'] + and _hex(witness['serverInvocationId'], 32) and _positive(witness['serverRequests'])) + if kind == 'resources': + return (_closed(witness, ('source', 'memoryCurrentBytes', 'pidsCurrent', 'cpuUsageUsec')) + and witness['source'] == 'cgroup-v2' + and all(_positive(witness[key]) for key in ('memoryCurrentBytes', 'pidsCurrent', 'cpuUsageUsec'))) + if kind == 'restart': + return (_closed(witness, ('beforeInvocationId', 'afterInvocationId', 'beforeEpoch', + 'afterEpoch', 'beforeStateDigest', 'afterStateDigest', 'deadlineUnchanged')) + and all(_hex(witness[key], 32) for key in ('beforeInvocationId', 'afterInvocationId')) + and witness['beforeInvocationId'] != witness['afterInvocationId'] + and _positive(witness['beforeEpoch']) and _positive(witness['afterEpoch']) + and witness['beforeEpoch'] != witness['afterEpoch'] + and _hex(witness['beforeStateDigest']) + and witness['beforeStateDigest'] == witness['afterStateDigest'] + and witness['deadlineUnchanged'] is True) + if kind == 'denial': + return (_closed(witness, ('actorUid', 'attackStartedNs', 'targetActiveBeforeNs', + 'targetActiveAfterNs', 'controlResponseDigest', 'denialSource', + 'denialCode', 'unrelatedStateBefore', 'unrelatedStateAfter')) + and _positive(witness['actorUid']) + and all(_positive(witness[key]) for key in + ('attackStartedNs', 'targetActiveBeforeNs', 'targetActiveAfterNs')) + and witness['targetActiveBeforeNs'] < witness['attackStartedNs'] < witness['targetActiveAfterNs'] + and _hex(witness['controlResponseDigest']) + and witness['denialSource'] in ('kernel', 'server', 'controller') + and witness['denialCode'] in ('EACCES', 'EPERM', 'policy-rejected', 'resource-limit') + and _hex(witness['unrelatedStateBefore']) + and witness['unrelatedStateBefore'] == witness['unrelatedStateAfter']) + if kind == 'lifecycle': + return (_closed(witness, ('triggerStartedNs', 'terminalObservedNs', 'roles', + 'populatedCgroups', 'remainingDescendants', 'retention')) + and _positive(witness['triggerStartedNs']) and _positive(witness['terminalObservedNs']) + and witness['triggerStartedNs'] < witness['terminalObservedNs'] + and _roster(witness['roles']) and witness['populatedCgroups'] == [] + and type(witness['remainingDescendants']) is int and witness['remainingDescendants'] == 0 + and witness['retention'] in ('retained', 'cleaned-after-quiescence')) + return False + + +def inventory(statuses=None): + statuses = statuses or {} + return [{'caseId': name, 'actor': case.actor, 'target': case.target, + 'expectedOutcome': case.outcome, 'status': statuses.get(name, 'not-executed')} + for name, case in CASES.items()] + + +def observation_status(record, identity): + if not isinstance(record, dict) or not isinstance(record.get('caseId'), str) or record['caseId'] not in CASES: + raise ValueError('workload-case-invalid') + if record.get('status') != 'passed': + if (_closed(record, ('caseId', 'status')) and isinstance(record['status'], str) + and record['status'] in STATUSES - {'passed'}): + return record['status'] + raise ValueError('workload-status-invalid') + case = CASES[record['caseId']] + if not (_closed(record, ('caseId', 'status', 'actor', 'target', 'outcome', + 'startedMonotonicNs', 'finishedMonotonicNs', 'witness')) + and (record['actor'], record['target'], record['outcome']) == (case.actor, case.target, case.outcome) + and _positive(record['startedMonotonicNs']) and _positive(record['finishedMonotonicNs']) + and record['startedMonotonicNs'] < record['finishedMonotonicNs'] + and _witness(case, record['witness'], identity)): + raise ValueError('workload-observation-invalid') + for key in ('attackStartedNs', 'targetActiveBeforeNs', 'targetActiveAfterNs', + 'triggerStartedNs', 'terminalObservedNs'): + if key in record['witness'] and not ( + record['startedMonotonicNs'] <= record['witness'][key] <= record['finishedMonotonicNs']): + raise ValueError('workload-witness-outside-invocation') + return 'passed' + + +def verify_attempts(expected_identity, attempts): + """Check driver records; caller must independently bind transport and source identity.""" + statuses = {} + valid = _identity(expected_identity) and isinstance(attempts, list) and 0 < len(attempts) <= len(CASES) + for attempt in attempts if isinstance(attempts, list) and len(attempts) <= len(CASES) else (): + try: + if not (_closed(attempt, ('contract', 'identity', 'declaredCases', 'observations', + 'guestStopped', 'attemptCompleted')) + and attempt['contract'] == CONTRACT and attempt['identity'] == expected_identity + and attempt['guestStopped'] is True and attempt['attemptCompleted'] is True + and isinstance(attempt['declaredCases'], list) and attempt['declaredCases'] + and all(isinstance(name, str) and name in CASES for name in attempt['declaredCases']) + and len(set(attempt['declaredCases'])) == len(attempt['declaredCases']) + and not set(attempt['declaredCases']) & set(statuses) + and isinstance(attempt['observations'], list) + and len(attempt['observations']) == len(attempt['declaredCases'])): + raise ValueError('workload-attempt-invalid') + observed = {} + for row in attempt['observations']: + status = observation_status(row, expected_identity) + if row['caseId'] in observed: + raise ValueError('workload-duplicate-observation') + observed[row['caseId']] = status + if set(observed) != set(attempt['declaredCases']): + raise ValueError('workload-roster-mismatch') + statuses.update(observed) + except (KeyError, TypeError, ValueError): + valid = False + accepted = valid and all(statuses.get(name) == 'passed' for name in CASES) + return {'schemaVersion': 1, 'kind': 'pr314-workload-assessment', 'contract': CONTRACT, + 'profile': PROFILE, 'recordContractValid': valid, 'cases': inventory(statuses), + 'installedWorkloadAcceptanceSatisfied': accepted, + 'finiteNativeAcceptanceSatisfied': False, 'productionAuthorityObserved': False, + 'protectedExecutionEligible': False, 'phase12Complete': False} diff --git a/tools/release-certification/restricted/pr314_workload_driver.py b/tools/release-certification/restricted/pr314_workload_driver.py new file mode 100644 index 0000000000..793ae09e77 --- /dev/null +++ b/tools/release-certification/restricted/pr314_workload_driver.py @@ -0,0 +1,175 @@ +#!/usr/bin/python3 +"""Administrator test-kit entry for the real installed four-role positive sequence. + +Run only in the copied, explicitly disposable PR-313 reference after base installation. +The fixed private selection is prepared with two distinct source-built products and a +normally signed Mail bundle. This driver never manufactures original release authority. +Its positive result alone cannot satisfy the separate complete workload fault contract. +""" +import argparse +import json +import os +from pathlib import Path +import pwd +import select +import signal +import socket +import subprocess +import sys +import time + +INSTALLED = Path('/opt/cryptad-cross-version/current') +TEST_KIT = Path('/opt/cryptad-restricted-test-kit') +SELECTION = Path('/root/pr314-workload-selection.json') +REPORT = Path('/root/pr314-workload-observation.private.json') +ENV = {'PATH': '/usr/sbin:/usr/bin:/sbin:/bin', 'LANG': 'C.UTF-8'} + + +def prerequisites(): + reasons = [] + if os.geteuid() != 0: + reasons.append('administrator-required') + if 'VERSION_ID="13"' not in Path('/etc/os-release').read_text(): + reasons.append('debian13-required') + if Path('/proc/1/comm').read_text().strip() != 'systemd': + reasons.append('systemd-pid1-required') + result = subprocess.run(['/usr/bin/systemd-detect-virt', '--vm'], capture_output=True, env=ENV, timeout=10) + if result.returncode: + reasons.append('dedicated-disposable-vm-required') + if not (INSTALLED / '.restricted-manifest.json').is_file(): + reasons.append('fixed-installed-source-required') + if not (TEST_KIT / '.test-kit.json').is_file(): + reasons.append('separate-measured-test-kit-required') + return reasons + + +def execute(): + if prerequisites(): + raise ValueError('workload-reference-prerequisites-unavailable') + sys.path.insert(0, str(INSTALLED / 'tools/release-certification/restricted')) + sys.path.insert(0, str(INSTALLED / 'tools/release-certification/protected')) + sys.path.insert(0, str(INSTALLED / 'tools/release-certification')) + sys.path.insert(0, str(INSTALLED / 'tools/interop')) + import installation + import workload_installation + import restricted_workload as workload + from restricted_workload_prepare import prepare + from cross_version_workload import InstalledWorkloadAdapter + from cryptad_certification.cross_version_evidence import Journal + identity = installation.verify_execution() + kit = installation.read_json(installation.secured(TEST_KIT / '.test-kit.json')) + relative = 'tools/release-certification/restricted/pr314_workload_driver.py' + import hashlib + if (kit['sourceCommit'] != identity['sourceCommit'] + or Path(__file__).resolve() != TEST_KIT / relative + or hashlib.sha256(Path(__file__).read_bytes()).hexdigest() != kit['files'].get(relative)): + raise ValueError('workload-test-kit-source-mismatch') + selected = workload.read(SELECTION) + if set(selected) != {'plan', 'private', 'authorization'}: + raise ValueError('workload-test-selection-invalid') + observer = pwd.getpwnam('cryptad-soak') + root = Path(selected['private']['root']) + if (root.parent != Path('/var/lib/cryptad-cross-version/experiments') or root.exists() + or root.is_symlink()): + raise ValueError('workload-observer-root-not-new') + workload_installation.install() + handoff = prepare(selected['plan'], selected['private'], selected['authorization']) + root.mkdir(mode=0o700) + os.chown(root, observer.pw_uid, observer.pw_gid) + subprocess.run(['/usr/bin/systemctl', 'start', 'cryptad-workload-controller.service'], + check=True, timeout=30, env=ENV) + until = time.monotonic() + 30 + while not Path('/run/cryptad-workload/control.sock').exists(): + if time.monotonic() >= until: + raise ValueError('workload-controller-readiness-timeout') + time.sleep(.05) + parent, child = socket.socketpair(socket.AF_UNIX, socket.SOCK_STREAM) + pid = os.fork() + if pid == 0: + parent.close() + try: + os.setgroups([]) + os.setgid(observer.pw_gid) + os.setuid(observer.pw_uid) + os.environ.clear() + os.environ.update(ENV) + os.umask(0o077) + with Journal(root, selected['plan']) as journal: + journal.append('start') + adapter = InstalledWorkloadAdapter(selected['plan'], selected['private'], + selected['authorization'], journal, handoff) + result = adapter.run_positive() + # All other required scenarios retain their original missing status. + journal.checkpoint('partial') + child.sendall(json.dumps(result, separators=(',', ':')).encode()) + child.close() + os._exit(0) + except BaseException: + child.close() + os._exit(1) + child.close() + deadline = time.monotonic() + selected['authorization']['maxSeconds'] + 100 + raw = bytearray() + status = None + try: + while time.monotonic() < deadline: + ready, _, _ = select.select([parent], [], [], min(1, max(.01, deadline - time.monotonic()))) + if ready: + block = parent.recv(65537 - len(raw)) + if not block: + break + raw.extend(block) + if len(raw) > 65536: + raise ValueError('workload-observer-output-limit') + else: + raise ValueError('workload-observer-timeout') + _pid, status = os.waitpid(pid, 0) + if status != 0: + raise ValueError('workload-positive-sequence-failed') + result = json.loads(raw) + if result.get('contentRetrieval') != 'observed' or result.get('newEpoch') is not True: + raise ValueError('workload-positive-observation-incomplete') + result.update(identity=identity, finiteNativeAcceptance='not-executed-by-this-driver', + workloadAcceptance='incomplete-hostile-contract-not-executed', protectedExecutionEnabled=False) + workload.write(REPORT, result, create=True) + return {'status': 'positive-sequence-executed', 'workloadAcceptance': 'incomplete', + 'protectedExecutionEnabled': False} + finally: + parent.close() + if status is None: + # This is the exact unreaped fork child; it cannot have been PID-reused. + os.kill(pid, signal.SIGKILL) + os.waitpid(pid, 0) + workload.reconcile() + subprocess.run(['/usr/bin/systemctl', 'stop', 'cryptad-workload-controller.service'], + check=True, timeout=110, env=ENV) + with workload.locked(): + if not all(workload.quiescent(role) for role in workload.ROLES): + raise ValueError('workload-terminal-cgroups-not-empty') + from restricted_workload_network import teardown + teardown() + # Role tmpfs state and records remain explicitly retained for private diagnostics. + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--execute', action='store_true') + args = parser.parse_args() + reasons = prerequisites() + if reasons: + print(json.dumps({'status': 'not-executed', 'reasons': reasons, 'protectedExecutionEnabled': False})) + return 78 + if not args.execute: + print(json.dumps({'status': 'prerequisites-present-not-executed', 'protectedExecutionEnabled': False})) + return 0 + print(json.dumps(execute(), sort_keys=True)) + return 0 + + +if __name__ == '__main__': + try: + result = main() + except Exception: + print('{"status":"failed-private-reconciliation-required","protectedExecutionEnabled":false}') + result = 1 + raise SystemExit(result) diff --git a/tools/release-certification/restricted/systemd/cryptad-workload-controller.service b/tools/release-certification/restricted/systemd/cryptad-workload-controller.service new file mode 100644 index 0000000000..148198882c --- /dev/null +++ b/tools/release-certification/restricted/systemd/cryptad-workload-controller.service @@ -0,0 +1,44 @@ +[Unit] +Description=Cryptad fixed tokenless workload role controller + +[Service] +Type=exec +User=root +Group=root +ExecStart=/usr/bin/python3 -I -S /opt/cryptad-cross-version/current/tools/release-certification/protected/restricted_workload_controller.py +RuntimeDirectory=cryptad-workload +RuntimeDirectoryMode=0711 +WorkingDirectory=/ +UMask=0077 +StandardInput=null +StandardOutput=null +StandardError=null +# This small separate controller can enter only recorded role network namespaces. +# It receives no resolver/provider environment or credentials. It never executes candidate code. +NoNewPrivileges=yes +CapabilityBoundingSet=CAP_SYS_ADMIN CAP_SYS_PTRACE CAP_DAC_READ_SEARCH CAP_DAC_OVERRIDE CAP_CHOWN +AmbientCapabilities= +ProtectSystem=strict +ProtectHome=yes +PrivateTmp=yes +PrivateDevices=yes +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectKernelLogs=yes +ProtectControlGroups=yes +ReadWritePaths=/var/lib/cryptad-restricted-workload /run/cryptad-workload +InaccessiblePaths=/var/lib/cryptad-restricted /var/lib/cryptad-restricted-native /var/lib/cryptad-runtime-baselines /var/lib/cryptad-cross-version-authority /var/lib/cryptad-runner +RestrictAddressFamilies=AF_UNIX AF_INET AF_NETLINK +LockPersonality=yes +RestrictRealtime=yes +# CAP_SYS_PTRACE satisfies proc's cross-UID read access check for the fixed sampler. +# No process tracing, memory access or performance event operation is admitted. +SystemCallFilter=~ptrace process_vm_readv process_vm_writev perf_event_open +LimitCORE=0 +LimitNOFILE=128 +TasksMax=16 +MemoryMax=512M +CPUQuota=50% +TimeoutStopSec=100 +KillMode=control-group +Restart=no diff --git a/tools/release-certification/restricted/systemd/cryptad-workload.conf b/tools/release-certification/restricted/systemd/cryptad-workload.conf new file mode 100644 index 0000000000..9cf60a1f59 --- /dev/null +++ b/tools/release-certification/restricted/systemd/cryptad-workload.conf @@ -0,0 +1,5 @@ +# Static single-campaign pool. No identity is recycled by the controller. +u cryptad-role-candidate-sender - "Isolated candidate sender" /var/empty /usr/sbin/nologin +u cryptad-role-candidate-recipient - "Isolated candidate recipient" /var/empty /usr/sbin/nologin +u cryptad-role-previous - "Isolated predecessor" /var/empty /usr/sbin/nologin +u cryptad-role-relay-no-apps - "Isolated relay" /var/empty /usr/sbin/nologin diff --git a/tools/release-certification/restricted/systemd/cryptad-workload@.service b/tools/release-certification/restricted/systemd/cryptad-workload@.service new file mode 100644 index 0000000000..314ba360c8 --- /dev/null +++ b/tools/release-certification/restricted/systemd/cryptad-workload@.service @@ -0,0 +1,39 @@ +[Unit] +Description=Cryptad controller-owned isolated role %i +BindsTo=cryptad-workload-controller.service +After=cryptad-workload-controller.service + +[Service] +Type=exec +Slice=system.slice +User=cryptad-role-%i +Group=cryptad-role-%i +ExecStartPre=+/usr/bin/python3 -I -S /opt/cryptad-cross-version/current/tools/release-certification/protected/restricted_workload_mark.py %i +ExecStart=/usr/bin/python3 -I -S /opt/cryptad-cross-version/current/tools/release-certification/protected/restricted_workload_launcher.py %i +NetworkNamespacePath=/run/netns/cryptad-role-%i +WorkingDirectory=/ +UMask=0077 +StandardInput=null +StandardOutput=null +StandardError=null +NoNewPrivileges=yes +CapabilityBoundingSet= +AmbientCapabilities= +# The unprivileged launcher constructs the outer mount/PID boundary. Do not inherit +# resolver proc locks or disable descendant user namespaces needed by real AppHost. +LockPersonality=yes +RestrictRealtime=yes +Delegate=no +LimitCORE=0 +LimitNOFILE=1024 +LimitFSIZE=16M +TasksMax=512 +MemoryMax=1G +MemorySwapMax=0 +CPUQuota=100% +RuntimeMaxSec=3600 +TimeoutStartSec=20 +TimeoutStopSec=20 +KillMode=control-group +SendSIGKILL=yes +Restart=no diff --git a/tools/release-certification/restricted/test_pr314_acceptance.py b/tools/release-certification/restricted/test_pr314_acceptance.py new file mode 100644 index 0000000000..caccbba6cb --- /dev/null +++ b/tools/release-certification/restricted/test_pr314_acceptance.py @@ -0,0 +1,143 @@ +"""Contract-only synthetic observations, never installed workload acceptance.""" +import copy +import unittest + +import pr314_acceptance as a + + +def identity(): + return {key: 'a' * (40 if key.endswith(('Commit', 'Tree')) else 64) for key in a.IDENTITY_FIELDS} + + +def roles(): + return [dict(role=role, uid=2000+i, gid=2000+i, invocationId=f'{i+1:032x}', + processEpoch=100+i, bootId='a'*32, cgroupDigest=f'{i+1:064x}', + networkNamespace=300+i) for i, role in enumerate(a.ROLES)] + + +def observation(name): + case = a.CASES[name] + digest = 'b'*64 + witnesses = { + 'identity': dict(profile=a.PROFILE, bundleIdentity=identity()['bundleIdentity'], + testKitDigest=identity()['testKitDigest']), + 'roster': dict(observerUid=1000, roles=roles()), + 'app': dict(role='candidate-sender', provider='bubblewrap', hostPid=101, namespacePid=2, + processEpoch=100, invocationId='0'*31+'1', installedAppDigest=digest), + 'exchange': dict(requestDigest=digest, expectedResponseDigest=digest, responseDigest=digest, + serverInvocationId='0'*31+'1', serverRequests=1), + 'resources': dict(source='cgroup-v2', memoryCurrentBytes=1024, pidsCurrent=4, cpuUsageUsec=2), + 'restart': dict(beforeInvocationId='a'*32, afterInvocationId='b'*32, beforeEpoch=1, + afterEpoch=2, beforeStateDigest=digest, afterStateDigest=digest, + deadlineUnchanged=True), + 'denial': dict(actorUid=2000, attackStartedNs=3, targetActiveBeforeNs=2, + targetActiveAfterNs=4, controlResponseDigest=digest, denialSource='kernel', + denialCode='EACCES', unrelatedStateBefore=digest, unrelatedStateAfter=digest), + 'lifecycle': dict(triggerStartedNs=2, terminalObservedNs=4, roles=roles(), + populatedCgroups=[], remainingDescendants=0, retention='retained'), + } + return dict(caseId=name, status='passed', actor=case.actor, target=case.target, + outcome=case.outcome, startedMonotonicNs=1, finishedMonotonicNs=5, + witness=witnesses[case.witness]) + + +def attempt(): + return dict(contract=a.CONTRACT, identity=identity(), declaredCases=list(a.CASES), + observations=[observation(name) for name in a.CASES], + guestStopped=True, attemptCompleted=True) + + +class WorkloadAcceptanceTest(unittest.TestCase): + def test_complete_synthetic_contract_is_reachable_without_granting_authority(self): + result = a.verify_attempts(identity(), [attempt()]) + self.assertTrue(result['installedWorkloadAcceptanceSatisfied']) + for key in ('finiteNativeAcceptanceSatisfied', 'productionAuthorityObserved', + 'protectedExecutionEligible', 'phase12Complete'): + self.assertFalse(result[key]) + + def test_every_missing_case_prevents_acceptance(self): + for name in a.CASES: + with self.subTest(case=name): + value = attempt() + value['observations'] = [dict(caseId=name, status='not-executed') + if row['caseId'] == name else row for row in value['observations']] + result = a.verify_attempts(identity(), [value]) + self.assertTrue(result['recordContractValid']) + self.assertFalse(result['installedWorkloadAcceptanceSatisfied']) + + def test_positive_flag_without_causal_witness_is_rejected(self): + for name in a.CASES: + with self.subTest(case=name): + value = attempt() + value['observations'] = [dict(caseId=name, status='passed') + if row['caseId'] == name else row for row in value['observations']] + self.assertFalse(a.verify_attempts(identity(), [value])['recordContractValid']) + + def test_active_target_and_actual_denial_are_required(self): + for field, replacement in (('targetActiveBeforeNs', 4), ('targetActiveAfterNs', 2), + ('denialSource', 'timeout'), ('denialCode', 'no-listener'), + ('unrelatedStateAfter', 'c'*64), ('actorUid', 0)): + with self.subTest(field=field): + value = observation('sibling-fcp') + value['witness'][field] = replacement + with self.assertRaises(ValueError): + a.observation_status(value, identity()) + + def test_four_roles_have_distinct_uids_and_namespaces(self): + for field in ('uid', 'gid', 'invocationId', 'networkNamespace', 'cgroupDigest', 'role'): + with self.subTest(field=field): + value = observation('four-role-start') + value['witness']['roles'][1][field] = value['witness']['roles'][0][field] + with self.assertRaises(ValueError): + a.observation_status(value, identity()) + + def test_observer_uid_cannot_own_candidate(self): + value = observation('four-role-start') + value['witness']['observerUid'] = value['witness']['roles'][0]['uid'] + with self.assertRaises(ValueError): + a.observation_status(value, identity()) + + def test_remaining_descendant_or_populated_cgroup_prevents_terminal_pass(self): + for field, replacement in (('remainingDescendants', 1), ('remainingDescendants', False), + ('populatedCgroups', ['owned-role'])): + value = observation('late-child') + value['witness'][field] = replacement + with self.assertRaises(ValueError): + a.observation_status(value, identity()) + + def test_duplicate_attempt_cannot_hide_failed_attempt(self): + failed = attempt() + failed['observations'][0] = dict(caseId='installed-ready', status='failed') + self.assertFalse(a.verify_attempts(identity(), [failed, attempt()])['recordContractValid']) + + def test_identity_mismatch_and_live_guest_are_rejected(self): + for field, replacement in (('identity', {**identity(), 'profileDigest': 'c'*64}), + ('guestStopped', False), ('attemptCompleted', False)): + value = attempt() + value[field] = replacement + self.assertFalse(a.verify_attempts(identity(), [value])['installedWorkloadAcceptanceSatisfied']) + + def test_private_fields_are_not_projected(self): + value = attempt() + value['observations'][0]['privateCanary'] = 'must-not-escape' + result = a.verify_attempts(identity(), [value]) + self.assertFalse(result['recordContractValid']) + self.assertNotIn('must-not-escape', repr(result)) + + def test_malformed_inputs_fail_closed(self): + for value in (None, {}, [], [None], [dict(contract=[])], [attempt()] * (len(a.CASES)+1)): + with self.subTest(value=type(value)): + self.assertFalse(a.verify_attempts(identity(), value)['installedWorkloadAcceptanceSatisfied']) + self.assertFalse(a.verify_attempts({}, [attempt()])['installedWorkloadAcceptanceSatisfied']) + + def test_restart_requires_new_epoch_and_durable_state(self): + for field, replacement in (('afterEpoch', 1), ('afterInvocationId', 'a'*32), + ('afterStateDigest', 'c'*64), ('deadlineUnchanged', False)): + value = copy.deepcopy(observation('restart-durable-state')) + value['witness'][field] = replacement + with self.assertRaises(ValueError): + a.observation_status(value, identity()) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/release-certification/restricted/workload_installation.py b/tools/release-certification/restricted/workload_installation.py new file mode 100644 index 0000000000..93cec4df54 --- /dev/null +++ b/tools/release-certification/restricted/workload_installation.py @@ -0,0 +1,89 @@ +#!/usr/bin/python3 +"""Explicit administrator installation of the closed workload extension. + +Installing reviewed units is not installed acceptance and does not enable protected work. +The base restricted installation and its immutable dependency closure must already verify. +""" +import os +from pathlib import Path +import pwd +import subprocess +import sys + +import installation + +ROLES = ('candidate-sender', 'candidate-recipient', 'previous', 'relay-no-apps') +SOURCE = installation.PREFIX / 'current/tools/release-certification/restricted/systemd' +ASSETS = { + 'cryptad-workload-controller.service': Path('/etc/systemd/system/cryptad-workload-controller.service'), + 'cryptad-workload@.service': Path('/etc/systemd/system/cryptad-workload@.service'), + 'cryptad-workload.conf': Path('/usr/lib/sysusers.d/cryptad-workload.conf'), +} +ENV = {'PATH': '/usr/sbin:/usr/bin:/sbin:/bin', 'LANG': 'C'} + + +def verify(): + identity = installation.verify_execution() + users = [pwd.getpwnam('cryptad-role-' + role) for role in ROLES] + existing = [pwd.getpwnam('cryptad-' + name) for name in ('runner', 'native', 'workload', 'soak')] + if (len({user.pw_uid for user in users + existing}) != 8 + or len({user.pw_gid for user in users + existing}) != 8 + or any(user.pw_uid == 0 or user.pw_gid == 0 or user.pw_shell != '/usr/sbin/nologin' for user in users)): + raise ValueError('workload-account-policy-invalid') + import grp + if any(user.pw_name in group.gr_mem for user in users for group in grp.getgrall()): + raise ValueError('workload-supplementary-group-invalid') + if any(grp.getgrgid(user.pw_gid).gr_name != user.pw_name for user in users): + raise ValueError('workload-primary-group-invalid') + for user in (*users, pwd.getpwnam('cryptad-soak')): + probe = subprocess.run(['/usr/bin/sudo', '-n', '-l', '-U', user.pw_name], + capture_output=True, env=ENV, timeout=15) + installation.verify_sudo_denial(probe) + for name, target in ASSETS.items(): + if installation.secured(target).read_bytes() != installation.secured(SOURCE / name).read_bytes(): + raise ValueError('workload-installed-unit-changed') + for unit in ('cryptad-workload-controller.service', *(f'cryptad-workload@{role}.service' for role in ROLES)): + for root in ('/etc/systemd/system', '/run/systemd/system', '/usr/lib/systemd/system', + '/etc/systemd/system.control', '/run/systemd/system.control'): + for name in (unit + '.d', 'cryptad-workload@.service.d', 'cryptad-workload-.service.d'): + if (Path(root) / name).exists(): + raise ValueError('workload-unit-dropin-unreviewed') + shown = subprocess.run(['/usr/bin/systemctl', 'show', unit, '--property=FragmentPath,DropInPaths', '--no-pager'], + capture_output=True, env=ENV, timeout=15, check=True) + result = dict(line.split('=', 1) for line in shown.stdout.decode().splitlines() if '=' in line) + template = 'cryptad-workload@.service' if '@' in unit else unit + if result != {'FragmentPath': str(ASSETS[template]), 'DropInPaths': ''}: + raise ValueError('workload-unit-load-path-invalid') + return {**identity, 'profile': 'debian13-systemd257-workload-v1', + 'status': 'installed-unaccepted', 'protectedExecutionEnabled': False} + + +def install(): + if os.geteuid() != 0: + raise ValueError('workload-administrator-required') + installation.verify_execution() + root = Path('/var/lib/cryptad-restricted-workload') + if root.exists() or any(path.exists() or path.is_symlink() for path in ASSETS.values()): + raise ValueError('workload-existing-installation-requires-reconciliation') + for name, target in ASSETS.items(): + installation.secured(target.parent) + with target.open('xb') as output: + output.write(installation.secured(SOURCE / name).read_bytes()) + output.flush() + os.fsync(output.fileno()) + target.chmod(0o644) + subprocess.run(['/usr/bin/systemd-sysusers', str(ASSETS['cryptad-workload.conf'])], check=True, env=ENV, timeout=30) + root.mkdir(mode=0o711) + root.chmod(0o711) + subprocess.run(['/usr/bin/systemctl', 'daemon-reload'], check=True, env=ENV, timeout=30) + return verify() + + +if __name__ == '__main__': + if sys.argv[1:] not in (['install'], ['verify']): + raise SystemExit('workload-fixed-install-operation-required') + try: + result = install() if sys.argv[1] == 'install' else verify() + print(installation.encode(result).decode()) + except (OSError, ValueError, subprocess.SubprocessError): + raise SystemExit('workload-installation-rejected') from None From b092613a8192eb39960f77496983dbebcaefbf17 Mon Sep 17 00:00:00 2001 From: leumor <116955025+leumor@users.noreply.github.com> Date: Tue, 22 Sep 2026 06:01:12 +0000 Subject: [PATCH 02/15] fix(protected): derive workload paths from the fixed role roster Use controller-owned role constants throughout preparation after validating the selected roster. This removes private selection data from generated configuration paths and resolves the CodeQL clear-text data flow without suppressing the check. Cover substituted and reordered role selections before mutation. --- .../protected/restricted_workload_prepare.py | 14 ++++++++------ .../protected/test_restricted_workload.py | 3 +++ 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/tools/release-certification/protected/restricted_workload_prepare.py b/tools/release-certification/protected/restricted_workload_prepare.py index 21233e56dc..5e2096aadd 100644 --- a/tools/release-certification/protected/restricted_workload_prepare.py +++ b/tools/release-certification/protected/restricted_workload_prepare.py @@ -66,8 +66,8 @@ def prepare(plan, private, authorization): or type(authorization.get('maxOperations')) is not int or not 1 <= authorization['maxOperations'] <= 10000): workload.reject('profile-selection-unsupported') - for selected in plan['nodes']: - role = selected['role'] + for index, role in enumerate(workload.ROLES): + selected = plan['nodes'][index] row = private['nodes'][role] if (selected.get('product') != 'cryptad' or set(row) != {'archivePath', 'javaHome', 'fnpPort', 'fcpPort', 'httpPort', @@ -90,8 +90,8 @@ def prepare(plan, private, authorization): or plan['producer'] != runtime.runner_identity()): workload.reject('installed-source-selection-mismatch') configurations = {} - for selected in plan['nodes']: - role = selected['role'] + for index, role in enumerate(workload.ROLES): + selected = plan['nodes'][index] if selected['configDigest'] != configuration_identity(role, private['nodes'][role]['trustedKeysDigest']): workload.reject('profile-config-selection-mismatch') configurations[role] = configuration(role) @@ -140,8 +140,10 @@ def prepare(plan, private, authorization): path = workload.ROOT / directory path.mkdir(mode=0o700) path.chmod(mode) - for selected in plan['nodes']: - role = selected['role'] + # Paths and generated daemon configuration use controller constants, never + # values carried by the private selection. Roster order was checked above. + for index, role in enumerate(workload.ROLES): + selected = plan['nodes'][index] user = workload.account(role) row = private['nodes'][role] # Pin complete administrator inputs before parsing/extracting candidate archives. diff --git a/tools/release-certification/protected/test_restricted_workload.py b/tools/release-certification/protected/test_restricted_workload.py index bcf8ed54e9..94208c347f 100644 --- a/tools/release-certification/protected/test_restricted_workload.py +++ b/tools/release-certification/protected/test_restricted_workload.py @@ -556,6 +556,9 @@ def selection(self): def test_unadapted_workloads_and_invalid_budgets_fail_before_lock_or_mutation(self): runtime = SimpleNamespace(canonical_digest=lambda _plan: 'exact') for section, key, value in (('plan', 'workloadInputs', {'catalog': True}), + ('plan', 'nodes', [{'role': '../private-selection'}] + + [{'role': role} for role in workload.ROLES[1:]]), + ('plan', 'nodes', [{'role': role} for role in reversed(workload.ROLES)]), ('plan', 'provenanceClass', 'original-release'), ('authorization', 'maxSeconds', 3601), ('authorization', 'maxOperations', 0), From 4a36b7048e664e89d211517644067209d139b222 Mon Sep 17 00:00:00 2001 From: leumor <116955025+leumor@users.noreply.github.com> Date: Tue, 22 Sep 2026 06:08:36 +0000 Subject: [PATCH 03/15] fix(protected): contain HTTP faults and bind denial actors Keep malformed candidate HTTP responses inside the request error boundary so they cannot terminate the controller and healthy sibling roles. Require distinct measured principal context in workload acceptance v2 and bind denial UIDs to the fixed actor account. Add live HTTP parser and hostile principal regressions. --- ...ontroller-owned-workload-role-isolation.md | 14 ++++++ .../restricted_workload_controller.py | 23 +++++---- .../protected/test_restricted_workload.py | 35 ++++++++++++++ .../restricted/pr314_acceptance.py | 40 ++++++++++++---- .../restricted/test_pr314_acceptance.py | 47 ++++++++++++++++--- 5 files changed, 137 insertions(+), 22 deletions(-) diff --git a/docs/pr-314-controller-owned-workload-role-isolation.md b/docs/pr-314-controller-owned-workload-role-isolation.md index e15d610fb8..e567c70168 100644 --- a/docs/pr-314-controller-owned-workload-role-isolation.md +++ b/docs/pr-314-controller-owned-workload-role-isolation.md @@ -323,6 +323,20 @@ unrelated helper exits, required process/ancestor exits during either pass, and The subsequent checks passed: 95 workload tests under root without skips, 24 adapter tests with 8 skips, and 12 acceptance-contract tests. These remain local tests, not installed acceptance. +The subsequent PR review adds request-local handling of malformed role HTTP responses, including +`HTTPException` subclasses. A malformed bootstrap fails its own request without exiting the +controller and reconciling healthy siblings. Local TCP regressions exercise an invalid status +line and an oversized header, followed by a successful controller request. + +The prospective acceptance contract is now `pr314-workload-roles-v2`. Each attempt requires +measured `principals` containing the observer UID, runner UID and exact four-role roster; all +six host accounts must be distinct. The start witness must match this context. Candidate/app +denial probes in this initial contract originate in `candidate-sender`; their actor UID must +match that role, while observer/runner denials must match their respective accounts. Legacy v1 +records cannot satisfy v2. These checks bind host accounts only: the installed driver must still +measure the actual probe process and its app/role invocation. JSON context does not authenticate +execution. Review validation passed 96 workload tests under root and 15 contract tests. + The next composed-budget work remains PR-309's import/fetch concurrency, timeout, cancellation, retry and owner-terminal causality, followed by dependent window/store/restart/privacy cases. Workload isolation does not close Mail lifecycle, migration, long-run or independent review. diff --git a/tools/release-certification/protected/restricted_workload_controller.py b/tools/release-certification/protected/restricted_workload_controller.py index b466199696..9b544e77b1 100644 --- a/tools/release-certification/protected/restricted_workload_controller.py +++ b/tools/release-certification/protected/restricted_workload_controller.py @@ -163,6 +163,20 @@ def serve(connection, expected_uid): return method +def handle_request(connection, observer_uid): + """Contain adversarial protocol errors to this request, preserving owned siblings.""" + try: + with deadline(45): + serve(connection, observer_uid) + return True + except (OSError, ValueError, KeyError, TypeError, http.client.HTTPException): + try: + connection.sendall(b'{"error":"restricted-workload-request-failed"}\n') + except OSError: + pass + return False + + def main(): if len(sys.argv) != 1 or not sys.flags.isolated or not sys.flags.no_site or os.geteuid() != 0: workload.reject('fixed-entry-required') @@ -200,15 +214,8 @@ def main(): continue connection, _ = server.accept() with connection: - try: - with deadline(45): - serve(connection, observer.pw_uid) + if handle_request(connection, observer.pw_uid): last_observer = time.monotonic() - except (OSError, ValueError, KeyError, TypeError): - try: - connection.sendall(b'{"error":"restricted-workload-request-failed"}\n') - except OSError: - pass finally: server.close() workload.reconcile() diff --git a/tools/release-certification/protected/test_restricted_workload.py b/tools/release-certification/protected/test_restricted_workload.py index 94208c347f..5caf39b303 100644 --- a/tools/release-certification/protected/test_restricted_workload.py +++ b/tools/release-certification/protected/test_restricted_workload.py @@ -370,6 +370,41 @@ def sendall(self, value): class ControllerRequestTest(unittest.TestCase): + def test_malformed_role_http_fails_only_request_and_next_request_succeeds(self): + for response in (b'not-http\r\n', b'HTTP/1.1 200 OK\r\nX: ' + b'a' * 65537 + b'\r\n\r\n'): + with self.subTest(response_length=len(response)), socket.socket() as listener: + listener.bind(('127.0.0.1', 0)) + listener.listen(1) + errors = [] + def reply(): + try: + with listener.accept()[0] as peer: + peer.settimeout(5) + raw = b'' + while b'\r\n\r\n' not in raw: + raw += peer.recv(4096) + peer.sendall(response) + except Exception as error: + errors.append(error) + worker = threading.Thread(target=reply, daemon=True) + worker.start() + def bootstrap(_handle): + with socket.create_connection(listener.getsockname(), timeout=5) as stream: + return controller._http(stream, '/', {}) + failed = FakeConnection(json.dumps({'method': 'bootstrap-mail', 'handle': HANDLE}).encode() + b'\n') + with patch.object(controller, 'bootstrap', side_effect=bootstrap), \ + patch.object(workload, 'reconcile') as reconcile: + self.assertFalse(controller.handle_request(failed, 1000)) + healthy = FakeConnection(self.raw() + b'\n') + with patch.object(workload, 'start', return_value={'state': 'running'}): + self.assertTrue(controller.handle_request(healthy, 1000)) + reconcile.assert_not_called() + worker.join(5) + self.assertFalse(worker.is_alive()) + self.assertEqual([], errors) + self.assertEqual([b'{"error":"restricted-workload-request-failed"}\n'], failed.sent) + self.assertEqual([b'{"state":"running"}\n'], healthy.sent) + def raw(self, **values): return json.dumps(dict(method='start', handle=HANDLE, **values)).encode() diff --git a/tools/release-certification/restricted/pr314_acceptance.py b/tools/release-certification/restricted/pr314_acceptance.py index 2372b4d154..b5c529b156 100644 --- a/tools/release-certification/restricted/pr314_acceptance.py +++ b/tools/release-certification/restricted/pr314_acceptance.py @@ -8,7 +8,7 @@ from dataclasses import dataclass import re -CONTRACT = 'pr314-workload-roles-v1' +CONTRACT = 'pr314-workload-roles-v2' PROFILE = 'debian13-systemd257-workload-v1' ROLES = ('candidate-sender', 'candidate-recipient', 'previous', 'relay-no-apps') IDENTITY_FIELDS = ('helperSourceCommit', 'helperSourceTree', 'productSelectionDigest', @@ -104,7 +104,22 @@ def _roster(rows): and len({row['bootId'] for row in rows}) == 1) -def _witness(case, witness, identity): +def _principals(value): + """Measured per-attempt accounts; candidate/app probes originate in candidate-sender.""" + return (_closed(value, ('observerUid', 'runnerUid', 'roles')) + and _roster(value['roles']) + and _positive(value['observerUid']) and _positive(value['runnerUid']) + and len({value['observerUid'], value['runnerUid'], + *(row['uid'] for row in value['roles'])}) == len(ROLES) + 2) + + +def _actor_uid(actor, principals): + if actor in ('candidate', 'app'): + return next(row['uid'] for row in principals['roles'] if row['role'] == 'candidate-sender') + return principals[actor + 'Uid'] + + +def _witness(case, witness, identity, principals): kind = case.witness if kind == 'identity': return witness == {'profile': PROFILE, 'bundleIdentity': identity['bundleIdentity'], @@ -112,7 +127,9 @@ def _witness(case, witness, identity): if kind == 'roster': return (_closed(witness, ('observerUid', 'roles')) and _roster(witness['roles']) and _positive(witness['observerUid']) - and witness['observerUid'] not in {row['uid'] for row in witness['roles']}) + and witness['observerUid'] not in {row['uid'] for row in witness['roles']} + and (principals is None or (witness['observerUid'] == principals['observerUid'] + and witness['roles'] == principals['roles']))) if kind == 'app': return (_closed(witness, ('role', 'provider', 'hostPid', 'namespacePid', 'processEpoch', 'invocationId', 'installedAppDigest')) @@ -144,6 +161,8 @@ def _witness(case, witness, identity): 'targetActiveAfterNs', 'controlResponseDigest', 'denialSource', 'denialCode', 'unrelatedStateBefore', 'unrelatedStateAfter')) and _positive(witness['actorUid']) + and _principals(principals) + and witness['actorUid'] == _actor_uid(case.actor, principals) and all(_positive(witness[key]) for key in ('attackStartedNs', 'targetActiveBeforeNs', 'targetActiveAfterNs')) and witness['targetActiveBeforeNs'] < witness['attackStartedNs'] < witness['targetActiveAfterNs'] @@ -170,7 +189,7 @@ def inventory(statuses=None): for name, case in CASES.items()] -def observation_status(record, identity): +def observation_status(record, identity, principals=None): if not isinstance(record, dict) or not isinstance(record.get('caseId'), str) or record['caseId'] not in CASES: raise ValueError('workload-case-invalid') if record.get('status') != 'passed': @@ -184,7 +203,7 @@ def observation_status(record, identity): and (record['actor'], record['target'], record['outcome']) == (case.actor, case.target, case.outcome) and _positive(record['startedMonotonicNs']) and _positive(record['finishedMonotonicNs']) and record['startedMonotonicNs'] < record['finishedMonotonicNs'] - and _witness(case, record['witness'], identity)): + and _witness(case, record['witness'], identity, principals)): raise ValueError('workload-observation-invalid') for key in ('attackStartedNs', 'targetActiveBeforeNs', 'targetActiveAfterNs', 'triggerStartedNs', 'terminalObservedNs'): @@ -195,14 +214,19 @@ def observation_status(record, identity): def verify_attempts(expected_identity, attempts): - """Check driver records; caller must independently bind transport and source identity.""" + """Check driver records; caller must bind transport, source and measured principal context. + + UID equality binds the host account, not proof of app sandbox execution. The installed + driver must capture the actual probe process under its current role/app invocation. + """ statuses = {} valid = _identity(expected_identity) and isinstance(attempts, list) and 0 < len(attempts) <= len(CASES) for attempt in attempts if isinstance(attempts, list) and len(attempts) <= len(CASES) else (): try: if not (_closed(attempt, ('contract', 'identity', 'declaredCases', 'observations', - 'guestStopped', 'attemptCompleted')) + 'guestStopped', 'attemptCompleted', 'principals')) and attempt['contract'] == CONTRACT and attempt['identity'] == expected_identity + and _principals(attempt['principals']) and attempt['guestStopped'] is True and attempt['attemptCompleted'] is True and isinstance(attempt['declaredCases'], list) and attempt['declaredCases'] and all(isinstance(name, str) and name in CASES for name in attempt['declaredCases']) @@ -213,7 +237,7 @@ def verify_attempts(expected_identity, attempts): raise ValueError('workload-attempt-invalid') observed = {} for row in attempt['observations']: - status = observation_status(row, expected_identity) + status = observation_status(row, expected_identity, attempt['principals']) if row['caseId'] in observed: raise ValueError('workload-duplicate-observation') observed[row['caseId']] = status diff --git a/tools/release-certification/restricted/test_pr314_acceptance.py b/tools/release-certification/restricted/test_pr314_acceptance.py index caccbba6cb..cf66fe814f 100644 --- a/tools/release-certification/restricted/test_pr314_acceptance.py +++ b/tools/release-certification/restricted/test_pr314_acceptance.py @@ -30,7 +30,8 @@ def observation(name): 'restart': dict(beforeInvocationId='a'*32, afterInvocationId='b'*32, beforeEpoch=1, afterEpoch=2, beforeStateDigest=digest, afterStateDigest=digest, deadlineUnchanged=True), - 'denial': dict(actorUid=2000, attackStartedNs=3, targetActiveBeforeNs=2, + 'denial': dict(actorUid={'observer': 1000, 'runner': 1001}.get(case.actor, 2000), + attackStartedNs=3, targetActiveBeforeNs=2, targetActiveAfterNs=4, controlResponseDigest=digest, denialSource='kernel', denialCode='EACCES', unrelatedStateBefore=digest, unrelatedStateAfter=digest), 'lifecycle': dict(triggerStartedNs=2, terminalObservedNs=4, roles=roles(), @@ -43,11 +44,45 @@ def observation(name): def attempt(): return dict(contract=a.CONTRACT, identity=identity(), declaredCases=list(a.CASES), + principals=dict(observerUid=1000, runnerUid=1001, roles=roles()), observations=[observation(name) for name in a.CASES], guestStopped=True, attemptCompleted=True) class WorkloadAcceptanceTest(unittest.TestCase): + def test_denials_require_the_declared_installed_principal(self): + for name, case in a.CASES.items(): + if case.witness != 'denial': + continue + for uid in (0, 1000, 1001, 2000, 2001, 2002, 2003, 9999): + value = attempt() + row = next(row for row in value['observations'] if row['caseId'] == name) + expected_uid = row['witness']['actorUid'] + row['witness']['actorUid'] = uid + with self.subTest(case=name, uid=uid): + self.assertEqual(uid == expected_uid, + a.verify_attempts(identity(), [value])['installedWorkloadAcceptanceSatisfied']) + + def test_principal_context_is_required_distinct_and_matches_start_roster(self): + for change in ('missing', 'observer-role', 'runner-observer', 'roster-mismatch', 'legacy'): + value = attempt() + if change == 'missing': + del value['principals'] + elif change == 'observer-role': + value['principals']['observerUid'] = 2000 + elif change == 'runner-observer': + value['principals']['runnerUid'] = 1000 + elif change == 'legacy': + value['contract'] = 'pr314-workload-roles-v1' + else: + value['principals']['roles'][0]['uid'] = 9999 + with self.subTest(change=change): + self.assertFalse(a.verify_attempts(identity(), [value])['recordContractValid']) + + def test_denial_without_principal_context_is_not_accepted(self): + with self.assertRaises(ValueError): + a.observation_status(observation('sibling-fcp'), identity()) + def test_complete_synthetic_contract_is_reachable_without_granting_authority(self): result = a.verify_attempts(identity(), [attempt()]) self.assertTrue(result['installedWorkloadAcceptanceSatisfied']) @@ -81,7 +116,7 @@ def test_active_target_and_actual_denial_are_required(self): value = observation('sibling-fcp') value['witness'][field] = replacement with self.assertRaises(ValueError): - a.observation_status(value, identity()) + a.observation_status(value, identity(), attempt()['principals']) def test_four_roles_have_distinct_uids_and_namespaces(self): for field in ('uid', 'gid', 'invocationId', 'networkNamespace', 'cgroupDigest', 'role'): @@ -89,13 +124,13 @@ def test_four_roles_have_distinct_uids_and_namespaces(self): value = observation('four-role-start') value['witness']['roles'][1][field] = value['witness']['roles'][0][field] with self.assertRaises(ValueError): - a.observation_status(value, identity()) + a.observation_status(value, identity(), attempt()['principals']) def test_observer_uid_cannot_own_candidate(self): value = observation('four-role-start') value['witness']['observerUid'] = value['witness']['roles'][0]['uid'] with self.assertRaises(ValueError): - a.observation_status(value, identity()) + a.observation_status(value, identity(), attempt()['principals']) def test_remaining_descendant_or_populated_cgroup_prevents_terminal_pass(self): for field, replacement in (('remainingDescendants', 1), ('remainingDescendants', False), @@ -103,7 +138,7 @@ def test_remaining_descendant_or_populated_cgroup_prevents_terminal_pass(self): value = observation('late-child') value['witness'][field] = replacement with self.assertRaises(ValueError): - a.observation_status(value, identity()) + a.observation_status(value, identity(), attempt()['principals']) def test_duplicate_attempt_cannot_hide_failed_attempt(self): failed = attempt() @@ -136,7 +171,7 @@ def test_restart_requires_new_epoch_and_durable_state(self): value = copy.deepcopy(observation('restart-durable-state')) value['witness'][field] = replacement with self.assertRaises(ValueError): - a.observation_status(value, identity()) + a.observation_status(value, identity(), attempt()['principals']) if __name__ == '__main__': From 9592b50f385877e60cade6cdb1a214d986859669 Mon Sep 17 00:00:00 2001 From: leumor <116955025+leumor@users.noreply.github.com> Date: Tue, 22 Sep 2026 06:21:23 +0000 Subject: [PATCH 04/15] fix(protected): bind app acceptance to the active role invocation --- .../restricted/pr314_acceptance.py | 5 ++++- .../restricted/test_pr314_acceptance.py | 14 ++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/tools/release-certification/restricted/pr314_acceptance.py b/tools/release-certification/restricted/pr314_acceptance.py index b5c529b156..8c051c9fc8 100644 --- a/tools/release-certification/restricted/pr314_acceptance.py +++ b/tools/release-certification/restricted/pr314_acceptance.py @@ -135,7 +135,10 @@ def _witness(case, witness, identity, principals): 'processEpoch', 'invocationId', 'installedAppDigest')) and witness['role'] == 'candidate-sender' and witness['provider'] == 'bubblewrap' and all(_positive(witness[key]) for key in ('hostPid', 'namespacePid', 'processEpoch')) - and _hex(witness['invocationId'], 32) and _hex(witness['installedAppDigest'])) + and _hex(witness['invocationId'], 32) and _hex(witness['installedAppDigest']) + and _principals(principals) + and witness['invocationId'] == next(row['invocationId'] for row in principals['roles'] + if row['role'] == witness['role'])) if kind == 'exchange': return (_closed(witness, ('requestDigest', 'expectedResponseDigest', 'responseDigest', 'serverInvocationId', 'serverRequests')) diff --git a/tools/release-certification/restricted/test_pr314_acceptance.py b/tools/release-certification/restricted/test_pr314_acceptance.py index cf66fe814f..e3c93ce851 100644 --- a/tools/release-certification/restricted/test_pr314_acceptance.py +++ b/tools/release-certification/restricted/test_pr314_acceptance.py @@ -50,6 +50,20 @@ def attempt(): class WorkloadAcceptanceTest(unittest.TestCase): + def test_app_witness_must_match_active_sender_invocation(self): + for invocation in (roles()[1]['invocationId'], 'f' * 32): + value = attempt() + row = next(row for row in value['observations'] if row['caseId'] == 'signed-apphost-child') + row['witness']['invocationId'] = invocation + with self.subTest(invocation=invocation): + result = a.verify_attempts(identity(), [value]) + self.assertFalse(result['recordContractValid']) + self.assertFalse(result['installedWorkloadAcceptanceSatisfied']) + + def test_app_witness_requires_principal_context(self): + with self.assertRaises(ValueError): + a.observation_status(observation('signed-apphost-child'), identity()) + def test_denials_require_the_declared_installed_principal(self): for name, case in a.CASES.items(): if case.witness != 'denial': From 3c092ab8e2a8f80dd9e8f6ec972db1f16374569b Mon Sep 17 00:00:00 2001 From: leumor <116955025+leumor@users.noreply.github.com> Date: Tue, 22 Sep 2026 06:39:53 +0000 Subject: [PATCH 05/15] fix(protected): validate runtime bodies and bind workload witnesses --- ...ontroller-owned-workload-role-isolation.md | 10 ++++++ .../restricted_workload_controller.py | 15 +++++++-- .../protected/test_restricted_workload.py | 33 +++++++++++++++++++ .../restricted/pr314_acceptance.py | 13 +++++--- .../restricted/test_pr314_acceptance.py | 29 +++++++++++++++- 5 files changed, 93 insertions(+), 7 deletions(-) diff --git a/docs/pr-314-controller-owned-workload-role-isolation.md b/docs/pr-314-controller-owned-workload-role-isolation.md index e567c70168..24ff390ef0 100644 --- a/docs/pr-314-controller-owned-workload-role-isolation.md +++ b/docs/pr-314-controller-owned-workload-role-isolation.md @@ -337,6 +337,16 @@ records cannot satisfy v2. These checks bind host accounts only: the installed d measure the actual probe process and its app/role invocation. JSON context does not authenticate execution. Review validation passed 96 workload tests under root and 15 contract tests. +Subsequent witness-binding fixes require the signed app invocation and management/bootstrap +server invocation to match `candidate-sender`; FNP retrieval binds its serving observation to +`candidate-recipient`. Lifecycle terminal rosters must equal the measured attempt roster, +including process epochs, accounts, invocations, cgroups and namespaces. A driver exercising a +new restart epoch must supply that epoch's measured context for its terminal attempt; it cannot +reuse stale context. Runtime JSON is checked as an object with object-valued runtime/sandbox +members before either bootstrap process-binding pass, so malformed JSON shapes fail only the +request. Local validation passed 97 workload tests under root and 19 contract tests. The current +driver still does not execute the complete installed hostile/lifecycle suite. + The next composed-budget work remains PR-309's import/fetch concurrency, timeout, cancellation, retry and owner-terminal causality, followed by dependent window/store/restart/privacy cases. Workload isolation does not close Mail lifecycle, migration, long-run or independent review. diff --git a/tools/release-certification/protected/restricted_workload_controller.py b/tools/release-certification/protected/restricted_workload_controller.py index 9b544e77b1..a3bd3324dc 100644 --- a/tools/release-certification/protected/restricted_workload_controller.py +++ b/tools/release-certification/protected/restricted_workload_controller.py @@ -77,6 +77,17 @@ def _http(sock, path, headers): connection.close() +def runtime_object(body): + """Reject malformed candidate JSON before any runtime or sandbox dereference.""" + value = json.loads(body) + if not isinstance(value, dict) or not isinstance(value.get('runtime'), dict): + workload.reject('app-runtime-invalid') + runtime = value['runtime'] + if not isinstance(runtime.get('sandbox'), dict): + workload.reject('app-runtime-invalid') + return runtime + + def bootstrap(handle): """Resolve dynamic app origin only from this role's current daemon launch proof.""" with workload.locked(), deadline(): @@ -109,7 +120,7 @@ def bootstrap(handle): '/api/v1/apps/mail-prototype/runtime', {'Accept': 'application/json'}) if runtime_status != 200: workload.reject('app-runtime-unavailable') - binding = require_app_listener(role, json.loads(runtime_body).get('runtime'), selected.port) + binding = require_app_listener(role, runtime_object(runtime_body), selected.port) origin = 'http://127.0.0.1:' + str(selected.port) status, _headers, body = _http(_connect_port(role, selected.port), '/.well-known/cryptad-bootstrap.json', {'Accept': 'application/json', 'Origin': origin, @@ -123,7 +134,7 @@ def bootstrap(handle): workload.exact(role, record, workload.manager(role, 'show')) runtime_status, _headers, runtime_body = _http(connect(role, 'http'), '/api/v1/apps/mail-prototype/runtime', {'Accept': 'application/json'}) - if (runtime_status != 200 or require_app_listener(role, json.loads(runtime_body).get('runtime'), + if (runtime_status != 200 or require_app_listener(role, runtime_object(runtime_body), selected.port) != binding): workload.reject('app-worker-changed') # A private response; never inserted into an acceptance/public result. diff --git a/tools/release-certification/protected/test_restricted_workload.py b/tools/release-certification/protected/test_restricted_workload.py index 5caf39b303..c49a3cb706 100644 --- a/tools/release-certification/protected/test_restricted_workload.py +++ b/tools/release-certification/protected/test_restricted_workload.py @@ -370,6 +370,39 @@ def sendall(self, value): class ControllerRequestTest(unittest.TestCase): + def test_non_object_runtime_json_fails_only_bootstrap_request_in_both_passes(self): + import restricted_workload_app as app + import restricted_workload_network as network + import restricted_workload_storage as storage + valid_runtime = {'runtime': {'running': True, 'sandbox': {'provider': 'bubblewrap', 'active': True}}} + for body in ([], None, 'text', 7, {'runtime': []}, {'runtime': {'sandbox': []}}): + for final in (False, True): + with self.subTest(body=body, final=final), ExitStack() as stack: + stack.enter_context(patch.object(workload, 'locked', side_effect=nullcontext)) + stack.enter_context(patch.object(workload, 'retained', return_value=( + {'deadlineMonotonicNs': 10**20}, ROLE, {}))) + for name in ('admit', 'exact', 'manager', 'current'): + stack.enter_context(patch.object(workload, name)) + stack.enter_context(patch.object(workload, 'read', return_value={'mailIdentity': {}})) + stack.enter_context(patch.object(storage, 'verify_installed_app')) + stack.enter_context(patch.object(network, 'connect')) + stack.enter_context(patch.object(network, '_connect_port')) + stack.enter_context(patch.object(app, 'require_app_listener', return_value={'bound': True})) + responses = [(302, {'Location': 'http://127.0.0.1:23456/#cryptadBootstrapNonce=' + 'a'*16}, b'')] + if final: + responses.extend([(200, {}, json.dumps(valid_runtime).encode()), (200, {}, json.dumps({ + 'uiOrigin': 'http://127.0.0.1:23456', 'browserSessionToken': 'session'}).encode())]) + responses.append((200, {}, json.dumps(body).encode())) + stack.enter_context(patch.object(controller, '_http', side_effect=responses)) + reconcile = stack.enter_context(patch.object(workload, 'reconcile')) + failed = FakeConnection(json.dumps({'method': 'bootstrap-mail', 'handle': HANDLE}).encode() + b'\n') + self.assertFalse(controller.handle_request(failed, 1000)) + healthy = FakeConnection(self.raw() + b'\n') + with patch.object(workload, 'start', return_value={'state': 'running'}): + self.assertTrue(controller.handle_request(healthy, 1000)) + reconcile.assert_not_called() + self.assertEqual([b'{"error":"restricted-workload-request-failed"}\n'], failed.sent) + def test_malformed_role_http_fails_only_request_and_next_request_succeeds(self): for response in (b'not-http\r\n', b'HTTP/1.1 200 OK\r\nX: ' + b'a' * 65537 + b'\r\n\r\n'): with self.subTest(response_length=len(response)), socket.socket() as listener: diff --git a/tools/release-certification/restricted/pr314_acceptance.py b/tools/release-certification/restricted/pr314_acceptance.py index 8c051c9fc8..01ca9c7b5f 100644 --- a/tools/release-certification/restricted/pr314_acceptance.py +++ b/tools/release-certification/restricted/pr314_acceptance.py @@ -23,15 +23,16 @@ class Case: target: str outcome: str witness: str + server_role: str | None = None CASES = { 'installed-ready': Case('administrator', 'installation', 'verified', 'identity'), 'four-role-start': Case('observer', 'four-role-roster', 'running', 'roster'), 'signed-apphost-child': Case('observer', 'own-app', 'sandboxed', 'app'), - 'own-management': Case('observer', 'own-management', 'connected', 'exchange'), - 'fnp-content-retrieval': Case('observer', 'approved-fnp-links', 'retrieved', 'exchange'), - 'dynamic-app-bootstrap': Case('observer', 'own-app', 'bound-session', 'exchange'), + 'own-management': Case('observer', 'own-management', 'connected', 'exchange', 'candidate-sender'), + 'fnp-content-retrieval': Case('observer', 'approved-fnp-links', 'retrieved', 'exchange', 'candidate-recipient'), + 'dynamic-app-bootstrap': Case('observer', 'own-app', 'bound-session', 'exchange', 'candidate-sender'), 'kernel-resource-scope': Case('administrator', 'owned-cgroups', 'measured', 'resources'), 'restart-durable-state': Case('observer', 'owned-role', 'new-epoch', 'restart'), } @@ -144,7 +145,10 @@ def _witness(case, witness, identity, principals): 'serverInvocationId', 'serverRequests')) and all(_hex(witness[key]) for key in ('requestDigest', 'expectedResponseDigest', 'responseDigest')) and witness['responseDigest'] == witness['expectedResponseDigest'] - and _hex(witness['serverInvocationId'], 32) and _positive(witness['serverRequests'])) + and _hex(witness['serverInvocationId'], 32) and _positive(witness['serverRequests']) + and _principals(principals) + and witness['serverInvocationId'] == next(row['invocationId'] for row in principals['roles'] + if row['role'] == case.server_role)) if kind == 'resources': return (_closed(witness, ('source', 'memoryCurrentBytes', 'pidsCurrent', 'cpuUsageUsec')) and witness['source'] == 'cgroup-v2' @@ -180,6 +184,7 @@ def _witness(case, witness, identity, principals): and _positive(witness['triggerStartedNs']) and _positive(witness['terminalObservedNs']) and witness['triggerStartedNs'] < witness['terminalObservedNs'] and _roster(witness['roles']) and witness['populatedCgroups'] == [] + and _principals(principals) and witness['roles'] == principals['roles'] and type(witness['remainingDescendants']) is int and witness['remainingDescendants'] == 0 and witness['retention'] in ('retained', 'cleaned-after-quiescence')) return False diff --git a/tools/release-certification/restricted/test_pr314_acceptance.py b/tools/release-certification/restricted/test_pr314_acceptance.py index e3c93ce851..7ae70ac783 100644 --- a/tools/release-certification/restricted/test_pr314_acceptance.py +++ b/tools/release-certification/restricted/test_pr314_acceptance.py @@ -25,7 +25,7 @@ def observation(name): 'app': dict(role='candidate-sender', provider='bubblewrap', hostPid=101, namespacePid=2, processEpoch=100, invocationId='0'*31+'1', installedAppDigest=digest), 'exchange': dict(requestDigest=digest, expectedResponseDigest=digest, responseDigest=digest, - serverInvocationId='0'*31+'1', serverRequests=1), + serverInvocationId='0'*31+('2' if name == 'fnp-content-retrieval' else '1'), serverRequests=1), 'resources': dict(source='cgroup-v2', memoryCurrentBytes=1024, pidsCurrent=4, cpuUsageUsec=2), 'restart': dict(beforeInvocationId='a'*32, afterInvocationId='b'*32, beforeEpoch=1, afterEpoch=2, beforeStateDigest=digest, afterStateDigest=digest, @@ -50,6 +50,33 @@ def attempt(): class WorkloadAcceptanceTest(unittest.TestCase): + def test_exchange_must_match_expected_active_role(self): + for name in ('own-management', 'fnp-content-retrieval', 'dynamic-app-bootstrap'): + expected_role = 'candidate-recipient' if name == 'fnp-content-retrieval' else 'candidate-sender' + for invocation in [row['invocationId'] for row in roles() if row['role'] != expected_role] + ['f' * 32]: + value = attempt() + row = next(row for row in value['observations'] if row['caseId'] == name) + row['witness']['serverInvocationId'] = invocation + with self.subTest(case=name, invocation=invocation): + self.assertFalse(a.verify_attempts(identity(), [value])['recordContractValid']) + with self.assertRaises(ValueError): + a.observation_status(observation(name), identity()) + + def test_lifecycle_must_match_attempt_roster(self): + for name, case in a.CASES.items(): + if case.witness != 'lifecycle': + continue + for field, replacement in (('uid', 9999), ('gid', 9999), ('invocationId', 'f'*32), + ('processEpoch', 9999), ('networkNamespace', 9999), ('cgroupDigest', 'f'*64)): + value = attempt() + row = next(row for row in value['observations'] if row['caseId'] == name) + row['witness']['roles'][0][field] = replacement + with self.subTest(case=name, field=field): + self.assertTrue(a._roster(row['witness']['roles'])) + self.assertFalse(a.verify_attempts(identity(), [value])['recordContractValid']) + with self.assertRaises(ValueError): + a.observation_status(observation(name), identity()) + def test_app_witness_must_match_active_sender_invocation(self): for invocation in (roles()[1]['invocationId'], 'f' * 32): value = attempt() From 15492c2bc697fff05db58a17e7d17b6104b9b194 Mon Sep 17 00:00:00 2001 From: leumor <116955025+leumor@users.noreply.github.com> Date: Tue, 22 Sep 2026 07:52:13 +0000 Subject: [PATCH 06/15] fix(protected): scope resource and restart acceptance witnesses --- ...ontroller-owned-workload-role-isolation.md | 9 ++++ .../restricted/pr314_acceptance.py | 34 ++++++++++-- .../restricted/test_pr314_acceptance.py | 52 +++++++++++++++++-- 3 files changed, 87 insertions(+), 8 deletions(-) diff --git a/docs/pr-314-controller-owned-workload-role-isolation.md b/docs/pr-314-controller-owned-workload-role-isolation.md index 24ff390ef0..9b7b7b8083 100644 --- a/docs/pr-314-controller-owned-workload-role-isolation.md +++ b/docs/pr-314-controller-owned-workload-role-isolation.md @@ -347,6 +347,15 @@ members before either bootstrap process-binding pass, so malformed JSON shapes f request. Local validation passed 97 workload tests under root and 19 contract tests. The current driver still does not execute the complete installed hostile/lifecycle suite. +The next contract revision, `pr314-workload-roles-v3`, replaces unscoped aggregate resource +counters with exactly one measurement per owned role. Each measurement binds role, cgroup digest, +manager invocation, process epoch and boot identity to the attempt's measured principal roster; +duplicate, missing and unrelated groups reject. Restart evidence explicitly names +`candidate-sender` and binds its resulting invocation and epoch to that roster, in addition to +requiring a changed invocation/epoch, preserved state digest and unchanged deadline. Earlier +contract versions cannot satisfy this revised witness format. These checks establish record +consistency; actual measurements and causal restart execution remain installed-driver obligations. + The next composed-budget work remains PR-309's import/fetch concurrency, timeout, cancellation, retry and owner-terminal causality, followed by dependent window/store/restart/privacy cases. Workload isolation does not close Mail lifecycle, migration, long-run or independent review. diff --git a/tools/release-certification/restricted/pr314_acceptance.py b/tools/release-certification/restricted/pr314_acceptance.py index 01ca9c7b5f..ba6cac5e83 100644 --- a/tools/release-certification/restricted/pr314_acceptance.py +++ b/tools/release-certification/restricted/pr314_acceptance.py @@ -8,7 +8,7 @@ from dataclasses import dataclass import re -CONTRACT = 'pr314-workload-roles-v2' +CONTRACT = 'pr314-workload-roles-v3' PROFILE = 'debian13-systemd257-workload-v1' ROLES = ('candidate-sender', 'candidate-recipient', 'previous', 'relay-no-apps') IDENTITY_FIELDS = ('helperSourceCommit', 'helperSourceTree', 'productSelectionDigest', @@ -120,6 +120,28 @@ def _actor_uid(actor, principals): return principals[actor + 'Uid'] +def _resources(witness, principals): + if not (_closed(witness, ('source', 'measurements')) and witness['source'] == 'cgroup-v2' + and _principals(principals) and isinstance(witness['measurements'], list) + and len(witness['measurements']) == len(ROLES)): + return False + identity_fields = ('role', 'invocationId', 'processEpoch', 'bootId', 'cgroupDigest') + metrics = ('memoryCurrentBytes', 'pidsCurrent', 'cpuUsageUsec') + observed = set() + for measurement in witness['measurements']: + if not (_closed(measurement, (*identity_fields, *metrics)) + and isinstance(measurement['role'], str) and measurement['role'] in ROLES + and measurement['role'] not in observed + and all(_positive(measurement[key]) for key in metrics)): + return False + expected = next(row for row in principals['roles'] if row['role'] == measurement['role']) + if any(type(measurement[key]) is not type(expected[key]) or measurement[key] != expected[key] + for key in identity_fields): + return False + observed.add(measurement['role']) + return observed == set(ROLES) + + def _witness(case, witness, identity, principals): kind = case.witness if kind == 'identity': @@ -150,16 +172,18 @@ def _witness(case, witness, identity, principals): and witness['serverInvocationId'] == next(row['invocationId'] for row in principals['roles'] if row['role'] == case.server_role)) if kind == 'resources': - return (_closed(witness, ('source', 'memoryCurrentBytes', 'pidsCurrent', 'cpuUsageUsec')) - and witness['source'] == 'cgroup-v2' - and all(_positive(witness[key]) for key in ('memoryCurrentBytes', 'pidsCurrent', 'cpuUsageUsec'))) + return _resources(witness, principals) if kind == 'restart': - return (_closed(witness, ('beforeInvocationId', 'afterInvocationId', 'beforeEpoch', + return (_closed(witness, ('role', 'beforeInvocationId', 'afterInvocationId', 'beforeEpoch', 'afterEpoch', 'beforeStateDigest', 'afterStateDigest', 'deadlineUnchanged')) + and witness['role'] == 'candidate-sender' and _principals(principals) and all(_hex(witness[key], 32) for key in ('beforeInvocationId', 'afterInvocationId')) and witness['beforeInvocationId'] != witness['afterInvocationId'] and _positive(witness['beforeEpoch']) and _positive(witness['afterEpoch']) and witness['beforeEpoch'] != witness['afterEpoch'] + and any(row['role'] == witness['role'] + and row['invocationId'] == witness['afterInvocationId'] + and row['processEpoch'] == witness['afterEpoch'] for row in principals['roles']) and _hex(witness['beforeStateDigest']) and witness['beforeStateDigest'] == witness['afterStateDigest'] and witness['deadlineUnchanged'] is True) diff --git a/tools/release-certification/restricted/test_pr314_acceptance.py b/tools/release-certification/restricted/test_pr314_acceptance.py index 7ae70ac783..da5e242f44 100644 --- a/tools/release-certification/restricted/test_pr314_acceptance.py +++ b/tools/release-certification/restricted/test_pr314_acceptance.py @@ -26,9 +26,12 @@ def observation(name): processEpoch=100, invocationId='0'*31+'1', installedAppDigest=digest), 'exchange': dict(requestDigest=digest, expectedResponseDigest=digest, responseDigest=digest, serverInvocationId='0'*31+('2' if name == 'fnp-content-retrieval' else '1'), serverRequests=1), - 'resources': dict(source='cgroup-v2', memoryCurrentBytes=1024, pidsCurrent=4, cpuUsageUsec=2), - 'restart': dict(beforeInvocationId='a'*32, afterInvocationId='b'*32, beforeEpoch=1, - afterEpoch=2, beforeStateDigest=digest, afterStateDigest=digest, + 'resources': dict(source='cgroup-v2', measurements=[{ + **{key: row[key] for key in ('role', 'invocationId', 'processEpoch', 'bootId', 'cgroupDigest')}, + 'memoryCurrentBytes': 1024, 'pidsCurrent': 4, 'cpuUsageUsec': 2} for row in roles()]), + 'restart': dict(role='candidate-sender', beforeInvocationId='a'*32, + afterInvocationId=roles()[0]['invocationId'], beforeEpoch=1, + afterEpoch=roles()[0]['processEpoch'], beforeStateDigest=digest, afterStateDigest=digest, deadlineUnchanged=True), 'denial': dict(actorUid={'observer': 1000, 'runner': 1001}.get(case.actor, 2000), attackStartedNs=3, targetActiveBeforeNs=2, @@ -50,6 +53,49 @@ def attempt(): class WorkloadAcceptanceTest(unittest.TestCase): + def test_resource_measurements_bind_every_owned_cgroup(self): + for index in range(4): + for field, replacement in (('role', 'controller'), ('cgroupDigest', 'f'*64), + ('invocationId', 'f'*32), ('bootId', 'f'*32), ('processEpoch', 9999), + ('memoryCurrentBytes', True), ('pidsCurrent', -1), ('cpuUsageUsec', '2')): + value = attempt() + row = next(row for row in value['observations'] if row['caseId'] == 'kernel-resource-scope') + row['witness']['measurements'][index][field] = replacement + with self.subTest(index=index, field=field): + self.assertFalse(a.verify_attempts(identity(), [value])['recordContractValid']) + + def test_resource_measurements_require_complete_unique_roster(self): + for change in ('missing', 'duplicate', 'old-shape'): + value = observation('kernel-resource-scope') + if change == 'missing': + value['witness']['measurements'].pop() + elif change == 'duplicate': + value['witness']['measurements'][1] = value['witness']['measurements'][0] + else: + value['witness'] = dict(source='cgroup-v2', memoryCurrentBytes=1024, pidsCurrent=4, cpuUsageUsec=2) + with self.subTest(change=change), self.assertRaises(ValueError): + a.observation_status(value, identity(), attempt()['principals']) + with self.assertRaises(ValueError): + a.observation_status(observation('kernel-resource-scope'), identity()) + + def test_restart_terminal_identity_matches_sender(self): + for field, replacement in (('role', 'candidate-recipient'), ('role', 'controller'), + ('afterInvocationId', 'f'*32), ('afterInvocationId', roles()[1]['invocationId']), + ('afterEpoch', 9999), ('afterEpoch', roles()[1]['processEpoch'])): + value = attempt() + row = next(row for row in value['observations'] if row['caseId'] == 'restart-durable-state') + row['witness'][field] = replacement + with self.subTest(field=field, replacement=replacement): + self.assertFalse(a.verify_attempts(identity(), [value])['recordContractValid']) + with self.assertRaises(ValueError): + a.observation_status(observation('restart-durable-state'), identity()) + + def test_previous_contract_versions_cannot_supply_new_witnesses(self): + for version in ('pr314-workload-roles-v1', 'pr314-workload-roles-v2'): + value = attempt() + value['contract'] = version + self.assertFalse(a.verify_attempts(identity(), [value])['recordContractValid']) + def test_exchange_must_match_expected_active_role(self): for name in ('own-management', 'fnp-content-retrieval', 'dynamic-app-bootstrap'): expected_role = 'candidate-recipient' if name == 'fnp-content-retrieval' else 'candidate-sender' From e8db74946f827eb180ec97e0d8358c246429d7b7 Mon Sep 17 00:00:00 2001 From: leumor <116955025+leumor@users.noreply.github.com> Date: Tue, 22 Sep 2026 08:15:07 +0000 Subject: [PATCH 07/15] fix(protected): bind app selection and denial target evidence --- ...ontroller-owned-workload-role-isolation.md | 13 ++++ .../restricted/pr314_acceptance.py | 53 ++++++++++++-- .../restricted/test_pr314_acceptance.py | 71 ++++++++++++++++--- 3 files changed, 123 insertions(+), 14 deletions(-) diff --git a/docs/pr-314-controller-owned-workload-role-isolation.md b/docs/pr-314-controller-owned-workload-role-isolation.md index 9b7b7b8083..49aac2f1d5 100644 --- a/docs/pr-314-controller-owned-workload-role-isolation.md +++ b/docs/pr-314-controller-owned-workload-role-isolation.md @@ -356,6 +356,19 @@ requiring a changed invocation/epoch, preserved state digest and unchanged deadl contract versions cannot satisfy this revised witness format. These checks establish record consistency; actual measurements and causal restart execution remain installed-driver obligations. +Contract `pr314-workload-roles-v4` additionally binds the app witness to `admittedAppDigest` in +the independently supplied expected identity. The administrator driver must derive that value +from the authenticated selection's exact installed-app projection, not the observed installation. +Each attempt also carries measured denial targets keyed by its declared denial cases. A target +binds case, target kind, optional fixed workload role, service invocation, cgroup, boot identity, +control response and a case-specific probe digest committing to the endpoint/object and operation. +Witness targets must equal this context; role-backed targets also match the measured roster. +Sibling targets use `candidate-recipient`; own-app/input/outer-role/cgroup targets use +`candidate-sender`. Control-side targets cannot alias workload invocations or cgroups. Missing +targets, duplicate probe commitments and cross-case witness reuse reject. Context shape and +digest equality do not authenticate its measurements or execute an attack; those remain explicit +installed-driver obligations. Previous contract revisions cannot satisfy v4. + The next composed-budget work remains PR-309's import/fetch concurrency, timeout, cancellation, retry and owner-terminal causality, followed by dependent window/store/restart/privacy cases. Workload isolation does not close Mail lifecycle, migration, long-run or independent review. diff --git a/tools/release-certification/restricted/pr314_acceptance.py b/tools/release-certification/restricted/pr314_acceptance.py index ba6cac5e83..1038d448d4 100644 --- a/tools/release-certification/restricted/pr314_acceptance.py +++ b/tools/release-certification/restricted/pr314_acceptance.py @@ -8,12 +8,12 @@ from dataclasses import dataclass import re -CONTRACT = 'pr314-workload-roles-v3' +CONTRACT = 'pr314-workload-roles-v4' PROFILE = 'debian13-systemd257-workload-v1' ROLES = ('candidate-sender', 'candidate-recipient', 'previous', 'relay-no-apps') IDENTITY_FIELDS = ('helperSourceCommit', 'helperSourceTree', 'productSelectionDigest', 'bundleIdentity', 'testKitDigest', 'profileDigest', 'bootClosureDigest', - 'preparedImageDigest', 'fixtureManifestDigest') + 'preparedImageDigest', 'fixtureManifestDigest', 'admittedAppDigest') STATUSES = frozenset(('passed', 'failed', 'setup-failed', 'not-executed', 'inconclusive')) @@ -120,6 +120,35 @@ def _actor_uid(actor, principals): return principals[actor + 'Uid'] +TARGET_ROLES = {'sibling-data': 'candidate-recipient', 'sibling-fcp': 'candidate-recipient', + 'sibling-http': 'candidate-recipient', 'sibling-app': 'candidate-recipient', + 'own-app': 'candidate-sender', 'immutable-inputs': 'candidate-sender', + 'outer-role': 'candidate-sender', 'owned-cgroups': 'candidate-sender'} + + +def _target(name, target, principals): + case = CASES[name] + if not (_closed(target, ('caseId', 'target', 'role', 'invocationId', 'cgroupDigest', + 'bootId', 'probeDigest', 'controlResponseDigest')) + and target['caseId'] == name and target['target'] == case.target + and target['role'] == TARGET_ROLES.get(case.target) and _principals(principals) + and _hex(target['invocationId'], 32) and _hex(target['cgroupDigest']) + and _hex(target['probeDigest']) and _hex(target['controlResponseDigest']) + and target['bootId'] == principals['roles'][0]['bootId']): + return False + if target['role'] is not None: + expected = next(row for row in principals['roles'] if row['role'] == target['role']) + return all(target[key] == expected[key] for key in ('invocationId', 'cgroupDigest', 'bootId')) + return (target['invocationId'] not in {row['invocationId'] for row in principals['roles']} + and target['cgroupDigest'] not in {row['cgroupDigest'] for row in principals['roles']}) + + +def _targets(targets, principals, declared): + names = {name for name in declared if CASES[name].witness == 'denial'} + return (_closed(targets, names) and all(_target(name, targets[name], principals) for name in names) + and len({targets[name]['probeDigest'] for name in names}) == len(names)) + + def _resources(witness, principals): if not (_closed(witness, ('source', 'measurements')) and witness['source'] == 'cgroup-v2' and _principals(principals) and isinstance(witness['measurements'], list) @@ -159,6 +188,7 @@ def _witness(case, witness, identity, principals): and witness['role'] == 'candidate-sender' and witness['provider'] == 'bubblewrap' and all(_positive(witness[key]) for key in ('hostPid', 'namespacePid', 'processEpoch')) and _hex(witness['invocationId'], 32) and _hex(witness['installedAppDigest']) + and witness['installedAppDigest'] == identity['admittedAppDigest'] and _principals(principals) and witness['invocationId'] == next(row['invocationId'] for row in principals['roles'] if row['role'] == witness['role'])) @@ -190,7 +220,7 @@ def _witness(case, witness, identity, principals): if kind == 'denial': return (_closed(witness, ('actorUid', 'attackStartedNs', 'targetActiveBeforeNs', 'targetActiveAfterNs', 'controlResponseDigest', 'denialSource', - 'denialCode', 'unrelatedStateBefore', 'unrelatedStateAfter')) + 'denialCode', 'unrelatedStateBefore', 'unrelatedStateAfter', 'targetIdentity')) and _positive(witness['actorUid']) and _principals(principals) and witness['actorUid'] == _actor_uid(case.actor, principals) @@ -221,7 +251,7 @@ def inventory(statuses=None): for name, case in CASES.items()] -def observation_status(record, identity, principals=None): +def observation_status(record, identity, principals=None, targets=None): if not isinstance(record, dict) or not isinstance(record.get('caseId'), str) or record['caseId'] not in CASES: raise ValueError('workload-case-invalid') if record.get('status') != 'passed': @@ -237,6 +267,13 @@ def observation_status(record, identity, principals=None): and record['startedMonotonicNs'] < record['finishedMonotonicNs'] and _witness(case, record['witness'], identity, principals)): raise ValueError('workload-observation-invalid') + if case.witness == 'denial': + target = record['witness']['targetIdentity'] + if not (isinstance(targets, dict) and record['caseId'] in targets + and _target(record['caseId'], target, principals) + and target == targets[record['caseId']] + and record['witness']['controlResponseDigest'] == target['controlResponseDigest']): + raise ValueError('workload-denial-target-mismatch') for key in ('attackStartedNs', 'targetActiveBeforeNs', 'targetActiveAfterNs', 'triggerStartedNs', 'terminalObservedNs'): if key in record['witness'] and not ( @@ -250,18 +287,22 @@ def verify_attempts(expected_identity, attempts): UID equality binds the host account, not proof of app sandbox execution. The installed driver must capture the actual probe process under its current role/app invocation. + expected_identity.admittedAppDigest must come from the authenticated selection's exact + installed-app projection, never from the observed app. Targets are independently measured + active services; probeDigest commits to the endpoint/object and operation for that case. """ statuses = {} valid = _identity(expected_identity) and isinstance(attempts, list) and 0 < len(attempts) <= len(CASES) for attempt in attempts if isinstance(attempts, list) and len(attempts) <= len(CASES) else (): try: if not (_closed(attempt, ('contract', 'identity', 'declaredCases', 'observations', - 'guestStopped', 'attemptCompleted', 'principals')) + 'guestStopped', 'attemptCompleted', 'principals', 'targets')) and attempt['contract'] == CONTRACT and attempt['identity'] == expected_identity and _principals(attempt['principals']) and attempt['guestStopped'] is True and attempt['attemptCompleted'] is True and isinstance(attempt['declaredCases'], list) and attempt['declaredCases'] and all(isinstance(name, str) and name in CASES for name in attempt['declaredCases']) + and _targets(attempt['targets'], attempt['principals'], attempt['declaredCases']) and len(set(attempt['declaredCases'])) == len(attempt['declaredCases']) and not set(attempt['declaredCases']) & set(statuses) and isinstance(attempt['observations'], list) @@ -269,7 +310,7 @@ def verify_attempts(expected_identity, attempts): raise ValueError('workload-attempt-invalid') observed = {} for row in attempt['observations']: - status = observation_status(row, expected_identity, attempt['principals']) + status = observation_status(row, expected_identity, attempt['principals'], attempt['targets']) if row['caseId'] in observed: raise ValueError('workload-duplicate-observation') observed[row['caseId']] = status diff --git a/tools/release-certification/restricted/test_pr314_acceptance.py b/tools/release-certification/restricted/test_pr314_acceptance.py index da5e242f44..d646d75cfe 100644 --- a/tools/release-certification/restricted/test_pr314_acceptance.py +++ b/tools/release-certification/restricted/test_pr314_acceptance.py @@ -15,6 +15,16 @@ def roles(): networkNamespace=300+i) for i, role in enumerate(a.ROLES)] +def target(name): + case = a.CASES[name] + role = a.TARGET_ROLES.get(case.target) + owner = next((row for row in roles() if row['role'] == role), + dict(invocationId='e'*32, cgroupDigest='e'*64, bootId='a'*32)) + return dict(caseId=name, target=case.target, role=role, + **{key: owner[key] for key in ('invocationId', 'cgroupDigest', 'bootId')}, + probeDigest=f'{list(a.CASES).index(name)+1:064x}', controlResponseDigest='b'*64) + + def observation(name): case = a.CASES[name] digest = 'b'*64 @@ -23,7 +33,7 @@ def observation(name): testKitDigest=identity()['testKitDigest']), 'roster': dict(observerUid=1000, roles=roles()), 'app': dict(role='candidate-sender', provider='bubblewrap', hostPid=101, namespacePid=2, - processEpoch=100, invocationId='0'*31+'1', installedAppDigest=digest), + processEpoch=100, invocationId='0'*31+'1', installedAppDigest=identity()['admittedAppDigest']), 'exchange': dict(requestDigest=digest, expectedResponseDigest=digest, responseDigest=digest, serverInvocationId='0'*31+('2' if name == 'fnp-content-retrieval' else '1'), serverRequests=1), 'resources': dict(source='cgroup-v2', measurements=[{ @@ -34,6 +44,7 @@ def observation(name): afterEpoch=roles()[0]['processEpoch'], beforeStateDigest=digest, afterStateDigest=digest, deadlineUnchanged=True), 'denial': dict(actorUid={'observer': 1000, 'runner': 1001}.get(case.actor, 2000), + targetIdentity=target(name), attackStartedNs=3, targetActiveBeforeNs=2, targetActiveAfterNs=4, controlResponseDigest=digest, denialSource='kernel', denialCode='EACCES', unrelatedStateBefore=digest, unrelatedStateAfter=digest), @@ -48,11 +59,55 @@ def observation(name): def attempt(): return dict(contract=a.CONTRACT, identity=identity(), declaredCases=list(a.CASES), principals=dict(observerUid=1000, runnerUid=1001, roles=roles()), + targets={name: target(name) for name, case in a.CASES.items() if case.witness == 'denial'}, observations=[observation(name) for name in a.CASES], guestStopped=True, attemptCompleted=True) class WorkloadAcceptanceTest(unittest.TestCase): + def test_app_digest_must_match_expected_admitted_identity(self): + value = attempt() + row = next(row for row in value['observations'] if row['caseId'] == 'signed-apphost-child') + row['witness']['installedAppDigest'] = 'f'*64 + self.assertFalse(a.verify_attempts(identity(), [value])['recordContractValid']) + value['identity']['admittedAppDigest'] = 'f'*64 + self.assertFalse(a.verify_attempts(identity(), [value])['recordContractValid']) + + def test_denial_cannot_reuse_another_cases_target(self): + names = [name for name, case in a.CASES.items() if case.witness == 'denial'] + for name in names: + value = attempt() + row = next(row for row in value['observations'] if row['caseId'] == name) + other = next(other for other in names if other != name) + row['witness']['targetIdentity'] = target(other) + with self.subTest(case=name): + self.assertFalse(a.verify_attempts(identity(), [value])['recordContractValid']) + + def test_denial_target_must_match_independent_context(self): + for field, replacement in (('probeDigest', 'f'*64), ('controlResponseDigest', 'f'*64), + ('invocationId', 'f'*32), ('cgroupDigest', 'f'*64), ('bootId', 'f'*32), + ('role', 'candidate-sender'), ('target', 'resolver')): + value = attempt() + row = next(row for row in value['observations'] if row['caseId'] == 'sibling-fcp') + row['witness']['targetIdentity'][field] = replacement + with self.subTest(field=field): + self.assertFalse(a.verify_attempts(identity(), [value])['recordContractValid']) + + def test_targets_require_case_specific_probes_and_current_roster(self): + for change in ('missing', 'duplicate-probe', 'stale-role', 'wrong-control'): + value = attempt() + if change == 'missing': + del value['targets'] + elif change == 'duplicate-probe': + value['targets']['sibling-fcp']['probeDigest'] = value['targets']['sibling-http']['probeDigest'] + elif change == 'stale-role': + value['targets']['sibling-fcp']['invocationId'] = 'f'*32 + else: + row = next(row for row in value['observations'] if row['caseId'] == 'sibling-fcp') + row['witness']['controlResponseDigest'] = 'f'*64 + with self.subTest(change=change): + self.assertFalse(a.verify_attempts(identity(), [value])['recordContractValid']) + def test_resource_measurements_bind_every_owned_cgroup(self): for index in range(4): for field, replacement in (('role', 'controller'), ('cgroupDigest', 'f'*64), @@ -74,7 +129,7 @@ def test_resource_measurements_require_complete_unique_roster(self): else: value['witness'] = dict(source='cgroup-v2', memoryCurrentBytes=1024, pidsCurrent=4, cpuUsageUsec=2) with self.subTest(change=change), self.assertRaises(ValueError): - a.observation_status(value, identity(), attempt()['principals']) + a.observation_status(value, identity(), attempt()['principals'], attempt()['targets']) with self.assertRaises(ValueError): a.observation_status(observation('kernel-resource-scope'), identity()) @@ -91,7 +146,7 @@ def test_restart_terminal_identity_matches_sender(self): a.observation_status(observation('restart-durable-state'), identity()) def test_previous_contract_versions_cannot_supply_new_witnesses(self): - for version in ('pr314-workload-roles-v1', 'pr314-workload-roles-v2'): + for version in ('pr314-workload-roles-v1', 'pr314-workload-roles-v2', 'pr314-workload-roles-v3'): value = attempt() value['contract'] = version self.assertFalse(a.verify_attempts(identity(), [value])['recordContractValid']) @@ -203,7 +258,7 @@ def test_active_target_and_actual_denial_are_required(self): value = observation('sibling-fcp') value['witness'][field] = replacement with self.assertRaises(ValueError): - a.observation_status(value, identity(), attempt()['principals']) + a.observation_status(value, identity(), attempt()['principals'], attempt()['targets']) def test_four_roles_have_distinct_uids_and_namespaces(self): for field in ('uid', 'gid', 'invocationId', 'networkNamespace', 'cgroupDigest', 'role'): @@ -211,13 +266,13 @@ def test_four_roles_have_distinct_uids_and_namespaces(self): value = observation('four-role-start') value['witness']['roles'][1][field] = value['witness']['roles'][0][field] with self.assertRaises(ValueError): - a.observation_status(value, identity(), attempt()['principals']) + a.observation_status(value, identity(), attempt()['principals'], attempt()['targets']) def test_observer_uid_cannot_own_candidate(self): value = observation('four-role-start') value['witness']['observerUid'] = value['witness']['roles'][0]['uid'] with self.assertRaises(ValueError): - a.observation_status(value, identity(), attempt()['principals']) + a.observation_status(value, identity(), attempt()['principals'], attempt()['targets']) def test_remaining_descendant_or_populated_cgroup_prevents_terminal_pass(self): for field, replacement in (('remainingDescendants', 1), ('remainingDescendants', False), @@ -225,7 +280,7 @@ def test_remaining_descendant_or_populated_cgroup_prevents_terminal_pass(self): value = observation('late-child') value['witness'][field] = replacement with self.assertRaises(ValueError): - a.observation_status(value, identity(), attempt()['principals']) + a.observation_status(value, identity(), attempt()['principals'], attempt()['targets']) def test_duplicate_attempt_cannot_hide_failed_attempt(self): failed = attempt() @@ -258,7 +313,7 @@ def test_restart_requires_new_epoch_and_durable_state(self): value = copy.deepcopy(observation('restart-durable-state')) value['witness'][field] = replacement with self.assertRaises(ValueError): - a.observation_status(value, identity(), attempt()['principals']) + a.observation_status(value, identity(), attempt()['principals'], attempt()['targets']) if __name__ == '__main__': From 5a9b36565a19998e9fa6d5133dbecfdf56274023 Mon Sep 17 00:00:00 2001 From: leumor <116955025+leumor@users.noreply.github.com> Date: Tue, 22 Sep 2026 08:17:56 +0000 Subject: [PATCH 08/15] test(protected): make workload fixtures portable to macOS Provide the Linux peer-option name only within fake-connection tests, preserving protocol and malformed-response coverage on macOS. Guard the real descriptor-transfer test on its Linux proc, setns, and close-on-exec prerequisites. Keep production credential handling unchanged. --- .../protected/test_restricted_workload.py | 9 +++++++++ .../protected/test_restricted_workload_network.py | 3 +++ 2 files changed, 12 insertions(+) diff --git a/tools/release-certification/protected/test_restricted_workload.py b/tools/release-certification/protected/test_restricted_workload.py index c49a3cb706..d0c48202b1 100644 --- a/tools/release-certification/protected/test_restricted_workload.py +++ b/tools/release-certification/protected/test_restricted_workload.py @@ -370,6 +370,15 @@ def sendall(self, value): class ControllerRequestTest(unittest.TestCase): + def setUp(self): + # These tests use FakeConnection, not the host's peer-credential ABI. + # Supply only the missing option name on non-Linux hosts so protocol and + # adversarial HTTP/JSON tests still execute there. Production stays Linux-only. + option = patch.object(controller.socket, 'SO_PEERCRED', + getattr(socket, 'SO_PEERCRED', 17), create=True) + option.start() + self.addCleanup(option.stop) + def test_non_object_runtime_json_fails_only_bootstrap_request_in_both_passes(self): import restricted_workload_app as app import restricted_workload_network as network diff --git a/tools/release-certification/protected/test_restricted_workload_network.py b/tools/release-certification/protected/test_restricted_workload_network.py index 7941306333..07783a9b96 100644 --- a/tools/release-certification/protected/test_restricted_workload_network.py +++ b/tools/release-certification/protected/test_restricted_workload_network.py @@ -193,6 +193,9 @@ def test_teardown_rejects_changed_namespace_without_deleting_anything(self): class SocketTransferTests(unittest.TestCase): + @unittest.skipUnless(Path('/proc/self/ns/net').exists() and hasattr(os, 'setns') + and hasattr(socket, 'MSG_CMSG_CLOEXEC'), + 'requires Linux proc namespace and socket descriptor interfaces') def test_real_fork_passes_connected_socket_without_changing_parent_namespace(self): # This tests SCM_RIGHTS only. setns is deliberately mocked, so it is NOT evidence # of installed role isolation or active sibling denial. From f50583f50708cf94c08c755cf8ec661ae3e5423f Mon Sep 17 00:00:00 2001 From: leumor <116955025+leumor@users.noreply.github.com> Date: Tue, 22 Sep 2026 08:27:50 +0000 Subject: [PATCH 09/15] fix(protected): reject probe reuse across workload attempts --- ...controller-owned-workload-role-isolation.md | 3 ++- .../restricted/pr314_acceptance.py | 5 +++++ .../restricted/test_pr314_acceptance.py | 18 ++++++++++++++++++ 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/docs/pr-314-controller-owned-workload-role-isolation.md b/docs/pr-314-controller-owned-workload-role-isolation.md index 49aac2f1d5..c58d60c3bd 100644 --- a/docs/pr-314-controller-owned-workload-role-isolation.md +++ b/docs/pr-314-controller-owned-workload-role-isolation.md @@ -365,7 +365,8 @@ control response and a case-specific probe digest committing to the endpoint/obj Witness targets must equal this context; role-backed targets also match the measured roster. Sibling targets use `candidate-recipient`; own-app/input/outer-role/cgroup targets use `candidate-sender`. Control-side targets cannot alias workload invocations or cgroups. Missing -targets, duplicate probe commitments and cross-case witness reuse reject. Context shape and +targets, duplicate probe commitments across the entire assessment (including separate attempts), +and cross-case witness reuse reject. Context shape and digest equality do not authenticate its measurements or execute an attack; those remain explicit installed-driver obligations. Previous contract revisions cannot satisfy v4. diff --git a/tools/release-certification/restricted/pr314_acceptance.py b/tools/release-certification/restricted/pr314_acceptance.py index 1038d448d4..3ca8750d99 100644 --- a/tools/release-certification/restricted/pr314_acceptance.py +++ b/tools/release-certification/restricted/pr314_acceptance.py @@ -292,6 +292,7 @@ def verify_attempts(expected_identity, attempts): active services; probeDigest commits to the endpoint/object and operation for that case. """ statuses = {} + probe_commitments = set() valid = _identity(expected_identity) and isinstance(attempts, list) and 0 < len(attempts) <= len(CASES) for attempt in attempts if isinstance(attempts, list) and len(attempts) <= len(CASES) else (): try: @@ -308,6 +309,10 @@ def verify_attempts(expected_identity, attempts): and isinstance(attempt['observations'], list) and len(attempt['observations']) == len(attempt['declaredCases'])): raise ValueError('workload-attempt-invalid') + attempt_probes = {target['probeDigest'] for target in attempt['targets'].values()} + if probe_commitments & attempt_probes: + raise ValueError('workload-probe-reused-across-attempts') + probe_commitments.update(attempt_probes) observed = {} for row in attempt['observations']: status = observation_status(row, expected_identity, attempt['principals'], attempt['targets']) diff --git a/tools/release-certification/restricted/test_pr314_acceptance.py b/tools/release-certification/restricted/test_pr314_acceptance.py index d646d75cfe..f5ebb43d2a 100644 --- a/tools/release-certification/restricted/test_pr314_acceptance.py +++ b/tools/release-certification/restricted/test_pr314_acceptance.py @@ -65,6 +65,24 @@ def attempt(): class WorkloadAcceptanceTest(unittest.TestCase): + def test_probe_commitments_are_unique_across_all_attempts(self): + attempts = [] + for name in a.CASES: + value = attempt() + value['declaredCases'] = [name] + value['observations'] = [row for row in value['observations'] if row['caseId'] == name] + value['targets'] = {name: value['targets'][name]} if name in value['targets'] else {} + attempts.append(value) + self.assertTrue(a.verify_attempts(identity(), attempts)['installedWorkloadAcceptanceSatisfied']) + for value in attempts: + for selected in value['targets'].values(): + selected['probeDigest'] = 'f'*64 + value['observations'][0]['witness']['targetIdentity']['probeDigest'] = 'f'*64 + for ordered in (attempts, list(reversed(attempts))): + result = a.verify_attempts(identity(), ordered) + self.assertFalse(result['recordContractValid']) + self.assertFalse(result['installedWorkloadAcceptanceSatisfied']) + def test_app_digest_must_match_expected_admitted_identity(self): value = attempt() row = next(row for row in value['observations'] if row['caseId'] == 'signed-apphost-child') From daf5757aee37024a25e99f065cc1d0d25403da04 Mon Sep 17 00:00:00 2001 From: leumor <116955025+leumor@users.noreply.github.com> Date: Tue, 22 Sep 2026 08:44:43 +0000 Subject: [PATCH 10/15] fix(protected): bind exchange and lifecycle evidence to cases --- ...ontroller-owned-workload-role-isolation.md | 8 +++ .../restricted/pr314_acceptance.py | 33 +++++++-- .../restricted/test_pr314_acceptance.py | 68 ++++++++++++++++--- 3 files changed, 94 insertions(+), 15 deletions(-) diff --git a/docs/pr-314-controller-owned-workload-role-isolation.md b/docs/pr-314-controller-owned-workload-role-isolation.md index c58d60c3bd..20ee596471 100644 --- a/docs/pr-314-controller-owned-workload-role-isolation.md +++ b/docs/pr-314-controller-owned-workload-role-isolation.md @@ -370,6 +370,14 @@ and cross-case witness reuse reject. Context shape and digest equality do not authenticate its measurements or execute an attack; those remain explicit installed-driver obligations. Previous contract revisions cannot satisfy v4. +Contract `pr314-workload-roles-v5` extends this binding to positive exchanges and lifecycle +faults. Their witnesses name the case and carry an operation or trigger digest matching the +attempt's independently measured `caseCommitments`. The driver must construct those commitments +from the selected operation or actual trigger, rather than copying witness claims. Commitments +are unique across the entire assessment, including denial probes and separate attempts. Copying +one management exchange into the bootstrap case, or one terminal event across fault cases, +cannot satisfy the contract. These remain evidence consistency rules, not proof of execution. + The next composed-budget work remains PR-309's import/fetch concurrency, timeout, cancellation, retry and owner-terminal causality, followed by dependent window/store/restart/privacy cases. Workload isolation does not close Mail lifecycle, migration, long-run or independent review. diff --git a/tools/release-certification/restricted/pr314_acceptance.py b/tools/release-certification/restricted/pr314_acceptance.py index 3ca8750d99..da4dc7070e 100644 --- a/tools/release-certification/restricted/pr314_acceptance.py +++ b/tools/release-certification/restricted/pr314_acceptance.py @@ -8,7 +8,7 @@ from dataclasses import dataclass import re -CONTRACT = 'pr314-workload-roles-v4' +CONTRACT = 'pr314-workload-roles-v5' PROFILE = 'debian13-systemd257-workload-v1' ROLES = ('candidate-sender', 'candidate-recipient', 'previous', 'relay-no-apps') IDENTITY_FIELDS = ('helperSourceCommit', 'helperSourceTree', 'productSelectionDigest', @@ -149,6 +149,12 @@ def _targets(targets, principals, declared): and len({targets[name]['probeDigest'] for name in names}) == len(names)) +def _case_commitments(commitments, declared): + names = {name for name in declared if CASES[name].witness in ('exchange', 'lifecycle')} + return (_closed(commitments, names) and all(_hex(commitments[name]) for name in names) + and len(set(commitments.values())) == len(names)) + + def _resources(witness, principals): if not (_closed(witness, ('source', 'measurements')) and witness['source'] == 'cgroup-v2' and _principals(principals) and isinstance(witness['measurements'], list) @@ -193,7 +199,7 @@ def _witness(case, witness, identity, principals): and witness['invocationId'] == next(row['invocationId'] for row in principals['roles'] if row['role'] == witness['role'])) if kind == 'exchange': - return (_closed(witness, ('requestDigest', 'expectedResponseDigest', 'responseDigest', + return (_closed(witness, ('caseId', 'operationDigest', 'requestDigest', 'expectedResponseDigest', 'responseDigest', 'serverInvocationId', 'serverRequests')) and all(_hex(witness[key]) for key in ('requestDigest', 'expectedResponseDigest', 'responseDigest')) and witness['responseDigest'] == witness['expectedResponseDigest'] @@ -233,7 +239,7 @@ def _witness(case, witness, identity, principals): and _hex(witness['unrelatedStateBefore']) and witness['unrelatedStateBefore'] == witness['unrelatedStateAfter']) if kind == 'lifecycle': - return (_closed(witness, ('triggerStartedNs', 'terminalObservedNs', 'roles', + return (_closed(witness, ('caseId', 'triggerDigest', 'triggerStartedNs', 'terminalObservedNs', 'roles', 'populatedCgroups', 'remainingDescendants', 'retention')) and _positive(witness['triggerStartedNs']) and _positive(witness['terminalObservedNs']) and witness['triggerStartedNs'] < witness['terminalObservedNs'] @@ -251,7 +257,7 @@ def inventory(statuses=None): for name, case in CASES.items()] -def observation_status(record, identity, principals=None, targets=None): +def observation_status(record, identity, principals=None, targets=None, case_commitments=None): if not isinstance(record, dict) or not isinstance(record.get('caseId'), str) or record['caseId'] not in CASES: raise ValueError('workload-case-invalid') if record.get('status') != 'passed': @@ -267,6 +273,13 @@ def observation_status(record, identity, principals=None, targets=None): and record['startedMonotonicNs'] < record['finishedMonotonicNs'] and _witness(case, record['witness'], identity, principals)): raise ValueError('workload-observation-invalid') + if case.witness in ('exchange', 'lifecycle'): + field = 'operationDigest' if case.witness == 'exchange' else 'triggerDigest' + if not (record['witness']['caseId'] == record['caseId'] + and isinstance(case_commitments, dict) and record['caseId'] in case_commitments + and _hex(record['witness'][field]) + and record['witness'][field] == case_commitments[record['caseId']]): + raise ValueError('workload-case-commitment-mismatch') if case.witness == 'denial': target = record['witness']['targetIdentity'] if not (isinstance(targets, dict) and record['caseId'] in targets @@ -290,6 +303,8 @@ def verify_attempts(expected_identity, attempts): expected_identity.admittedAppDigest must come from the authenticated selection's exact installed-app projection, never from the observed app. Targets are independently measured active services; probeDigest commits to the endpoint/object and operation for that case. + caseCommitments independently binds each exchange operation and measured lifecycle trigger; + the driver must not derive this expected context by copying the submitted witness. """ statuses = {} probe_commitments = set() @@ -297,25 +312,31 @@ def verify_attempts(expected_identity, attempts): for attempt in attempts if isinstance(attempts, list) and len(attempts) <= len(CASES) else (): try: if not (_closed(attempt, ('contract', 'identity', 'declaredCases', 'observations', - 'guestStopped', 'attemptCompleted', 'principals', 'targets')) + 'guestStopped', 'attemptCompleted', 'principals', 'targets', 'caseCommitments')) and attempt['contract'] == CONTRACT and attempt['identity'] == expected_identity and _principals(attempt['principals']) and attempt['guestStopped'] is True and attempt['attemptCompleted'] is True and isinstance(attempt['declaredCases'], list) and attempt['declaredCases'] and all(isinstance(name, str) and name in CASES for name in attempt['declaredCases']) and _targets(attempt['targets'], attempt['principals'], attempt['declaredCases']) + and _case_commitments(attempt['caseCommitments'], attempt['declaredCases']) and len(set(attempt['declaredCases'])) == len(attempt['declaredCases']) and not set(attempt['declaredCases']) & set(statuses) and isinstance(attempt['observations'], list) and len(attempt['observations']) == len(attempt['declaredCases'])): raise ValueError('workload-attempt-invalid') attempt_probes = {target['probeDigest'] for target in attempt['targets'].values()} + operations = set(attempt['caseCommitments'].values()) + if attempt_probes & operations: + raise ValueError('workload-commitment-reused') + attempt_probes |= operations if probe_commitments & attempt_probes: raise ValueError('workload-probe-reused-across-attempts') probe_commitments.update(attempt_probes) observed = {} for row in attempt['observations']: - status = observation_status(row, expected_identity, attempt['principals'], attempt['targets']) + status = observation_status(row, expected_identity, attempt['principals'], attempt['targets'], + attempt['caseCommitments']) if row['caseId'] in observed: raise ValueError('workload-duplicate-observation') observed[row['caseId']] = status diff --git a/tools/release-certification/restricted/test_pr314_acceptance.py b/tools/release-certification/restricted/test_pr314_acceptance.py index f5ebb43d2a..40f07445f7 100644 --- a/tools/release-certification/restricted/test_pr314_acceptance.py +++ b/tools/release-certification/restricted/test_pr314_acceptance.py @@ -34,7 +34,8 @@ def observation(name): 'roster': dict(observerUid=1000, roles=roles()), 'app': dict(role='candidate-sender', provider='bubblewrap', hostPid=101, namespacePid=2, processEpoch=100, invocationId='0'*31+'1', installedAppDigest=identity()['admittedAppDigest']), - 'exchange': dict(requestDigest=digest, expectedResponseDigest=digest, responseDigest=digest, + 'exchange': dict(caseId=name, operationDigest=f'{1000+list(a.CASES).index(name):064x}', + requestDigest=digest, expectedResponseDigest=digest, responseDigest=digest, serverInvocationId='0'*31+('2' if name == 'fnp-content-retrieval' else '1'), serverRequests=1), 'resources': dict(source='cgroup-v2', measurements=[{ **{key: row[key] for key in ('role', 'invocationId', 'processEpoch', 'bootId', 'cgroupDigest')}, @@ -48,7 +49,8 @@ def observation(name): attackStartedNs=3, targetActiveBeforeNs=2, targetActiveAfterNs=4, controlResponseDigest=digest, denialSource='kernel', denialCode='EACCES', unrelatedStateBefore=digest, unrelatedStateAfter=digest), - 'lifecycle': dict(triggerStartedNs=2, terminalObservedNs=4, roles=roles(), + 'lifecycle': dict(caseId=name, triggerDigest=f'{1000+list(a.CASES).index(name):064x}', + triggerStartedNs=2, terminalObservedNs=4, roles=roles(), populatedCgroups=[], remainingDescendants=0, retention='retained'), } return dict(caseId=name, status='passed', actor=case.actor, target=case.target, @@ -60,11 +62,58 @@ def attempt(): return dict(contract=a.CONTRACT, identity=identity(), declaredCases=list(a.CASES), principals=dict(observerUid=1000, runnerUid=1001, roles=roles()), targets={name: target(name) for name, case in a.CASES.items() if case.witness == 'denial'}, + caseCommitments={name: f'{1000+list(a.CASES).index(name):064x}' for name, case in a.CASES.items() + if case.witness in ('exchange', 'lifecycle')}, observations=[observation(name) for name in a.CASES], guestStopped=True, attemptCompleted=True) class WorkloadAcceptanceTest(unittest.TestCase): + def test_exchange_and_lifecycle_witnesses_cannot_be_relabelled(self): + for kind in ('exchange', 'lifecycle'): + names = [name for name, case in a.CASES.items() if case.witness == kind] + for name in names: + other = next(other for other in names if other != name) + for relabel in (False, True): + value = attempt() + row = next(row for row in value['observations'] if row['caseId'] == name) + row['witness'] = observation(other)['witness'] + if relabel: + row['witness']['caseId'] = name + with self.subTest(case=name, relabel=relabel): + self.assertFalse(a.verify_attempts(identity(), [value])['recordContractValid']) + + def test_case_commitments_cannot_be_reused_within_or_across_attempts(self): + for split in (False, True): + value = attempt() + for name in value['caseCommitments']: + value['caseCommitments'][name] = 'f'*64 + row = next(row for row in value['observations'] if row['caseId'] == name) + field = 'operationDigest' if a.CASES[name].witness == 'exchange' else 'triggerDigest' + row['witness'][field] = 'f'*64 + attempts = [value] + if split: + attempts = [] + for name in a.CASES: + part = copy.deepcopy(value) + part['declaredCases'] = [name] + part['observations'] = [row for row in part['observations'] if row['caseId'] == name] + for key in ('targets', 'caseCommitments'): + part[key] = {name: part[key][name]} if name in part[key] else {} + attempts.append(part) + for ordered in (attempts, list(reversed(attempts))): + with self.subTest(split=split): + self.assertFalse(a.verify_attempts(identity(), ordered)['recordContractValid']) + + def test_missing_or_malformed_case_commitments_reject(self): + for replacement in (None, {}, {'own-management': 'not-a-digest'}): + value = attempt() + value['caseCommitments'] = replacement + self.assertFalse(a.verify_attempts(identity(), [value])['recordContractValid']) + for name in ('own-management', 'dynamic-app-bootstrap', 'deadline'): + with self.assertRaises(ValueError): + a.observation_status(observation(name), identity(), attempt()['principals'], attempt()['targets']) + def test_probe_commitments_are_unique_across_all_attempts(self): attempts = [] for name in a.CASES: @@ -72,6 +121,7 @@ def test_probe_commitments_are_unique_across_all_attempts(self): value['declaredCases'] = [name] value['observations'] = [row for row in value['observations'] if row['caseId'] == name] value['targets'] = {name: value['targets'][name]} if name in value['targets'] else {} + value['caseCommitments'] = {name: value['caseCommitments'][name]} if name in value['caseCommitments'] else {} attempts.append(value) self.assertTrue(a.verify_attempts(identity(), attempts)['installedWorkloadAcceptanceSatisfied']) for value in attempts: @@ -147,7 +197,7 @@ def test_resource_measurements_require_complete_unique_roster(self): else: value['witness'] = dict(source='cgroup-v2', memoryCurrentBytes=1024, pidsCurrent=4, cpuUsageUsec=2) with self.subTest(change=change), self.assertRaises(ValueError): - a.observation_status(value, identity(), attempt()['principals'], attempt()['targets']) + a.observation_status(value, identity(), attempt()['principals'], attempt()['targets'], attempt()['caseCommitments']) with self.assertRaises(ValueError): a.observation_status(observation('kernel-resource-scope'), identity()) @@ -164,7 +214,7 @@ def test_restart_terminal_identity_matches_sender(self): a.observation_status(observation('restart-durable-state'), identity()) def test_previous_contract_versions_cannot_supply_new_witnesses(self): - for version in ('pr314-workload-roles-v1', 'pr314-workload-roles-v2', 'pr314-workload-roles-v3'): + for version in ('pr314-workload-roles-v1', 'pr314-workload-roles-v2', 'pr314-workload-roles-v3', 'pr314-workload-roles-v4'): value = attempt() value['contract'] = version self.assertFalse(a.verify_attempts(identity(), [value])['recordContractValid']) @@ -276,7 +326,7 @@ def test_active_target_and_actual_denial_are_required(self): value = observation('sibling-fcp') value['witness'][field] = replacement with self.assertRaises(ValueError): - a.observation_status(value, identity(), attempt()['principals'], attempt()['targets']) + a.observation_status(value, identity(), attempt()['principals'], attempt()['targets'], attempt()['caseCommitments']) def test_four_roles_have_distinct_uids_and_namespaces(self): for field in ('uid', 'gid', 'invocationId', 'networkNamespace', 'cgroupDigest', 'role'): @@ -284,13 +334,13 @@ def test_four_roles_have_distinct_uids_and_namespaces(self): value = observation('four-role-start') value['witness']['roles'][1][field] = value['witness']['roles'][0][field] with self.assertRaises(ValueError): - a.observation_status(value, identity(), attempt()['principals'], attempt()['targets']) + a.observation_status(value, identity(), attempt()['principals'], attempt()['targets'], attempt()['caseCommitments']) def test_observer_uid_cannot_own_candidate(self): value = observation('four-role-start') value['witness']['observerUid'] = value['witness']['roles'][0]['uid'] with self.assertRaises(ValueError): - a.observation_status(value, identity(), attempt()['principals'], attempt()['targets']) + a.observation_status(value, identity(), attempt()['principals'], attempt()['targets'], attempt()['caseCommitments']) def test_remaining_descendant_or_populated_cgroup_prevents_terminal_pass(self): for field, replacement in (('remainingDescendants', 1), ('remainingDescendants', False), @@ -298,7 +348,7 @@ def test_remaining_descendant_or_populated_cgroup_prevents_terminal_pass(self): value = observation('late-child') value['witness'][field] = replacement with self.assertRaises(ValueError): - a.observation_status(value, identity(), attempt()['principals'], attempt()['targets']) + a.observation_status(value, identity(), attempt()['principals'], attempt()['targets'], attempt()['caseCommitments']) def test_duplicate_attempt_cannot_hide_failed_attempt(self): failed = attempt() @@ -331,7 +381,7 @@ def test_restart_requires_new_epoch_and_durable_state(self): value = copy.deepcopy(observation('restart-durable-state')) value['witness'][field] = replacement with self.assertRaises(ValueError): - a.observation_status(value, identity(), attempt()['principals'], attempt()['targets']) + a.observation_status(value, identity(), attempt()['principals'], attempt()['targets'], attempt()['caseCommitments']) if __name__ == '__main__': From 5aa876606f000e9e29a6bd205a56b0b59a028c50 Mon Sep 17 00:00:00 2001 From: leumor <116955025+leumor@users.noreply.github.com> Date: Tue, 22 Sep 2026 08:57:40 +0000 Subject: [PATCH 11/15] fix(protected): bind witness payloads and measured app processes --- ...ontroller-owned-workload-role-isolation.md | 11 +++ .../restricted/pr314_acceptance.py | 57 ++++++++++----- .../restricted/test_pr314_acceptance.py | 70 ++++++++++++++++--- 3 files changed, 112 insertions(+), 26 deletions(-) diff --git a/docs/pr-314-controller-owned-workload-role-isolation.md b/docs/pr-314-controller-owned-workload-role-isolation.md index 20ee596471..e580304d12 100644 --- a/docs/pr-314-controller-owned-workload-role-isolation.md +++ b/docs/pr-314-controller-owned-workload-role-isolation.md @@ -378,6 +378,17 @@ are unique across the entire assessment, including denial probes and separate at one management exchange into the bootstrap case, or one terminal event across fault cases, cannot satisfy the contract. These remain evidence consistency rules, not proof of execution. +Contract `pr314-workload-roles-v6` recomputes exchange/lifecycle commitments from canonical +case, target and complete witness payload (excluding only the commitment field), then compares +the result with independently supplied `caseCommitments`. Request/response digests, invocation, +request counts, trigger and terminal times, terminal roster and quiescence fields are covered. +Lifecycle witnesses include the trigger kind and measured event digest. Changing only case ID +and commitment cannot relabel an existing payload; recomputing a modified witness cannot replace +the independent expected commitment. App witnesses must equal a separate kernel-measured +`appProcess` context, including host PID, namespace PID and start epoch; host PID 1 is invalid. +Drivers must acquire this context independently, never copy candidate-reported identifiers. +Canonical hashing provides consistency only, not authentic execution or trusted measurement. + The next composed-budget work remains PR-309's import/fetch concurrency, timeout, cancellation, retry and owner-terminal causality, followed by dependent window/store/restart/privacy cases. Workload isolation does not close Mail lifecycle, migration, long-run or independent review. diff --git a/tools/release-certification/restricted/pr314_acceptance.py b/tools/release-certification/restricted/pr314_acceptance.py index da4dc7070e..37d3d2b508 100644 --- a/tools/release-certification/restricted/pr314_acceptance.py +++ b/tools/release-certification/restricted/pr314_acceptance.py @@ -6,9 +6,11 @@ not installed observations and must never be published as such. """ from dataclasses import dataclass +import hashlib +import json import re -CONTRACT = 'pr314-workload-roles-v5' +CONTRACT = 'pr314-workload-roles-v6' PROFILE = 'debian13-systemd257-workload-v1' ROLES = ('candidate-sender', 'candidate-recipient', 'previous', 'relay-no-apps') IDENTITY_FIELDS = ('helperSourceCommit', 'helperSourceTree', 'productSelectionDigest', @@ -177,7 +179,28 @@ def _resources(witness, principals): return observed == set(ROLES) -def _witness(case, witness, identity, principals): +def payload_commitment(name, witness): + """Canonical payload binding, not authentication; compare to independent driver context.""" + field = 'operationDigest' if CASES[name].witness == 'exchange' else 'triggerDigest' + payload = {key: value for key, value in witness.items() if key != field} + raw = json.dumps({'domain': CONTRACT, 'caseId': name, 'target': CASES[name].target, + 'payload': payload}, sort_keys=True, separators=(',', ':'), allow_nan=False) + return hashlib.sha256(raw.encode()).hexdigest() + + +def _app_process(value, identity, principals): + return (_closed(value, ('role', 'provider', 'hostPid', 'namespacePid', + 'processEpoch', 'invocationId', 'installedAppDigest')) + and value['role'] == 'candidate-sender' and value['provider'] == 'bubblewrap' + and all(_positive(value[key]) for key in ('hostPid', 'namespacePid', 'processEpoch')) + and value['hostPid'] > 1 and value['hostPid'] != value['namespacePid'] + and value['installedAppDigest'] == identity['admittedAppDigest'] + and _principals(principals) + and value['invocationId'] == next(row['invocationId'] for row in principals['roles'] + if row['role'] == value['role'])) + + +def _witness(case, witness, identity, principals, app_process): kind = case.witness if kind == 'identity': return witness == {'profile': PROFILE, 'bundleIdentity': identity['bundleIdentity'], @@ -189,15 +212,8 @@ def _witness(case, witness, identity, principals): and (principals is None or (witness['observerUid'] == principals['observerUid'] and witness['roles'] == principals['roles']))) if kind == 'app': - return (_closed(witness, ('role', 'provider', 'hostPid', 'namespacePid', - 'processEpoch', 'invocationId', 'installedAppDigest')) - and witness['role'] == 'candidate-sender' and witness['provider'] == 'bubblewrap' - and all(_positive(witness[key]) for key in ('hostPid', 'namespacePid', 'processEpoch')) - and _hex(witness['invocationId'], 32) and _hex(witness['installedAppDigest']) - and witness['installedAppDigest'] == identity['admittedAppDigest'] - and _principals(principals) - and witness['invocationId'] == next(row['invocationId'] for row in principals['roles'] - if row['role'] == witness['role'])) + return (_app_process(witness, identity, principals) + and _app_process(app_process, identity, principals) and witness == app_process) if kind == 'exchange': return (_closed(witness, ('caseId', 'operationDigest', 'requestDigest', 'expectedResponseDigest', 'responseDigest', 'serverInvocationId', 'serverRequests')) @@ -239,8 +255,11 @@ def _witness(case, witness, identity, principals): and _hex(witness['unrelatedStateBefore']) and witness['unrelatedStateBefore'] == witness['unrelatedStateAfter']) if kind == 'lifecycle': - return (_closed(witness, ('caseId', 'triggerDigest', 'triggerStartedNs', 'terminalObservedNs', 'roles', + return (_closed(witness, ('caseId', 'triggerDigest', 'trigger', 'triggerStartedNs', 'terminalObservedNs', 'roles', 'populatedCgroups', 'remainingDescendants', 'retention')) + and _closed(witness['trigger'], ('kind', 'eventDigest')) + and witness['trigger']['kind'] == witness['caseId'] + and _hex(witness['trigger']['eventDigest']) and _positive(witness['triggerStartedNs']) and _positive(witness['terminalObservedNs']) and witness['triggerStartedNs'] < witness['terminalObservedNs'] and _roster(witness['roles']) and witness['populatedCgroups'] == [] @@ -257,7 +276,7 @@ def inventory(statuses=None): for name, case in CASES.items()] -def observation_status(record, identity, principals=None, targets=None, case_commitments=None): +def observation_status(record, identity, principals=None, targets=None, case_commitments=None, app_process=None): if not isinstance(record, dict) or not isinstance(record.get('caseId'), str) or record['caseId'] not in CASES: raise ValueError('workload-case-invalid') if record.get('status') != 'passed': @@ -271,13 +290,14 @@ def observation_status(record, identity, principals=None, targets=None, case_com and (record['actor'], record['target'], record['outcome']) == (case.actor, case.target, case.outcome) and _positive(record['startedMonotonicNs']) and _positive(record['finishedMonotonicNs']) and record['startedMonotonicNs'] < record['finishedMonotonicNs'] - and _witness(case, record['witness'], identity, principals)): + and _witness(case, record['witness'], identity, principals, app_process)): raise ValueError('workload-observation-invalid') if case.witness in ('exchange', 'lifecycle'): field = 'operationDigest' if case.witness == 'exchange' else 'triggerDigest' if not (record['witness']['caseId'] == record['caseId'] and isinstance(case_commitments, dict) and record['caseId'] in case_commitments and _hex(record['witness'][field]) + and record['witness'][field] == payload_commitment(record['caseId'], record['witness']) and record['witness'][field] == case_commitments[record['caseId']]): raise ValueError('workload-case-commitment-mismatch') if case.witness == 'denial': @@ -305,6 +325,8 @@ def verify_attempts(expected_identity, attempts): active services; probeDigest commits to the endpoint/object and operation for that case. caseCommitments independently binds each exchange operation and measured lifecycle trigger; the driver must not derive this expected context by copying the submitted witness. + appProcess is a separate kernel observation of the current AppHost child, including its + host/namespace PID and start epoch; it must not be copied from candidate API claims. """ statuses = {} probe_commitments = set() @@ -312,12 +334,15 @@ def verify_attempts(expected_identity, attempts): for attempt in attempts if isinstance(attempts, list) and len(attempts) <= len(CASES) else (): try: if not (_closed(attempt, ('contract', 'identity', 'declaredCases', 'observations', - 'guestStopped', 'attemptCompleted', 'principals', 'targets', 'caseCommitments')) + 'guestStopped', 'attemptCompleted', 'principals', 'targets', 'caseCommitments', + 'appProcess')) and attempt['contract'] == CONTRACT and attempt['identity'] == expected_identity and _principals(attempt['principals']) and attempt['guestStopped'] is True and attempt['attemptCompleted'] is True and isinstance(attempt['declaredCases'], list) and attempt['declaredCases'] and all(isinstance(name, str) and name in CASES for name in attempt['declaredCases']) + and (_app_process(attempt['appProcess'], expected_identity, attempt['principals']) + if 'signed-apphost-child' in attempt['declaredCases'] else attempt['appProcess'] is None) and _targets(attempt['targets'], attempt['principals'], attempt['declaredCases']) and _case_commitments(attempt['caseCommitments'], attempt['declaredCases']) and len(set(attempt['declaredCases'])) == len(attempt['declaredCases']) @@ -336,7 +361,7 @@ def verify_attempts(expected_identity, attempts): observed = {} for row in attempt['observations']: status = observation_status(row, expected_identity, attempt['principals'], attempt['targets'], - attempt['caseCommitments']) + attempt['caseCommitments'], attempt['appProcess']) if row['caseId'] in observed: raise ValueError('workload-duplicate-observation') observed[row['caseId']] = status diff --git a/tools/release-certification/restricted/test_pr314_acceptance.py b/tools/release-certification/restricted/test_pr314_acceptance.py index 40f07445f7..027bb4f95e 100644 --- a/tools/release-certification/restricted/test_pr314_acceptance.py +++ b/tools/release-certification/restricted/test_pr314_acceptance.py @@ -35,7 +35,8 @@ def observation(name): 'app': dict(role='candidate-sender', provider='bubblewrap', hostPid=101, namespacePid=2, processEpoch=100, invocationId='0'*31+'1', installedAppDigest=identity()['admittedAppDigest']), 'exchange': dict(caseId=name, operationDigest=f'{1000+list(a.CASES).index(name):064x}', - requestDigest=digest, expectedResponseDigest=digest, responseDigest=digest, + requestDigest=f'{1000+list(a.CASES).index(name):064x}', + expectedResponseDigest=digest, responseDigest=digest, serverInvocationId='0'*31+('2' if name == 'fnp-content-retrieval' else '1'), serverRequests=1), 'resources': dict(source='cgroup-v2', measurements=[{ **{key: row[key] for key in ('role', 'invocationId', 'processEpoch', 'bootId', 'cgroupDigest')}, @@ -50,25 +51,70 @@ def observation(name): targetActiveAfterNs=4, controlResponseDigest=digest, denialSource='kernel', denialCode='EACCES', unrelatedStateBefore=digest, unrelatedStateAfter=digest), 'lifecycle': dict(caseId=name, triggerDigest=f'{1000+list(a.CASES).index(name):064x}', + trigger=dict(kind=name, eventDigest=f'{2000+list(a.CASES).index(name):064x}'), triggerStartedNs=2, terminalObservedNs=4, roles=roles(), populatedCgroups=[], remainingDescendants=0, retention='retained'), } + witness = witnesses[case.witness] + if case.witness in ('exchange', 'lifecycle'): + field = 'operationDigest' if case.witness == 'exchange' else 'triggerDigest' + witness[field] = a.payload_commitment(name, witness) return dict(caseId=name, status='passed', actor=case.actor, target=case.target, outcome=case.outcome, startedMonotonicNs=1, finishedMonotonicNs=5, - witness=witnesses[case.witness]) + witness=witness) def attempt(): return dict(contract=a.CONTRACT, identity=identity(), declaredCases=list(a.CASES), principals=dict(observerUid=1000, runnerUid=1001, roles=roles()), targets={name: target(name) for name, case in a.CASES.items() if case.witness == 'denial'}, - caseCommitments={name: f'{1000+list(a.CASES).index(name):064x}' for name, case in a.CASES.items() + caseCommitments={name: a.payload_commitment(name, observation(name)['witness']) for name, case in a.CASES.items() if case.witness in ('exchange', 'lifecycle')}, + appProcess=observation('signed-apphost-child')['witness'], observations=[observation(name) for name in a.CASES], guestStopped=True, attemptCompleted=True) class WorkloadAcceptanceTest(unittest.TestCase): + def test_relabel_and_replace_commitment_cannot_copy_payload(self): + for source, destination in (('own-management', 'dynamic-app-bootstrap'), + ('deadline', 'observer-death')): + for recompute in (False, True): + value = attempt() + row = next(row for row in value['observations'] if row['caseId'] == destination) + row['witness'] = observation(source)['witness'] + row['witness']['caseId'] = destination + field = 'operationDigest' if a.CASES[destination].witness == 'exchange' else 'triggerDigest' + row['witness'][field] = (a.payload_commitment(destination, row['witness']) if recompute + else value['caseCommitments'][destination]) + with self.subTest(destination=destination, recompute=recompute): + self.assertFalse(a.verify_attempts(identity(), [value])['recordContractValid']) + + def test_commitment_covers_exchange_and_terminal_payload(self): + for name, field, replacement in (('own-management', 'requestDigest', 'f'*64), + ('own-management', 'responseDigest', 'f'*64), ('own-management', 'serverRequests', 2), + ('deadline', 'terminalObservedNs', 5), ('deadline', 'retention', 'cleaned-after-quiescence'), + ('deadline', 'trigger', {'kind': 'deadline', 'eventDigest': 'f'*64})): + value = attempt() + row = next(row for row in value['observations'] if row['caseId'] == name) + row['witness'][field] = replacement + if field == 'responseDigest': + row['witness']['expectedResponseDigest'] = replacement + with self.subTest(case=name, field=field): + self.assertFalse(a.verify_attempts(identity(), [value])['recordContractValid']) + + def test_app_process_identity_must_match_independent_observation(self): + for field, replacement in (('hostPid', 1), ('hostPid', 999), ('namespacePid', 99), ('processEpoch', 999)): + value = attempt() + row = next(row for row in value['observations'] if row['caseId'] == 'signed-apphost-child') + row['witness'][field] = replacement + with self.subTest(field=field, replacement=replacement): + self.assertFalse(a.verify_attempts(identity(), [value])['recordContractValid']) + for replacement in (None, {}, {**observation('signed-apphost-child')['witness'], 'hostPid': 1}): + value = attempt() + value['appProcess'] = replacement + self.assertFalse(a.verify_attempts(identity(), [value])['recordContractValid']) + def test_exchange_and_lifecycle_witnesses_cannot_be_relabelled(self): for kind in ('exchange', 'lifecycle'): names = [name for name, case in a.CASES.items() if case.witness == kind] @@ -97,6 +143,8 @@ def test_case_commitments_cannot_be_reused_within_or_across_attempts(self): for name in a.CASES: part = copy.deepcopy(value) part['declaredCases'] = [name] + if name != 'signed-apphost-child': + part['appProcess'] = None part['observations'] = [row for row in part['observations'] if row['caseId'] == name] for key in ('targets', 'caseCommitments'): part[key] = {name: part[key][name]} if name in part[key] else {} @@ -119,6 +167,8 @@ def test_probe_commitments_are_unique_across_all_attempts(self): for name in a.CASES: value = attempt() value['declaredCases'] = [name] + if name != 'signed-apphost-child': + value['appProcess'] = None value['observations'] = [row for row in value['observations'] if row['caseId'] == name] value['targets'] = {name: value['targets'][name]} if name in value['targets'] else {} value['caseCommitments'] = {name: value['caseCommitments'][name]} if name in value['caseCommitments'] else {} @@ -197,7 +247,7 @@ def test_resource_measurements_require_complete_unique_roster(self): else: value['witness'] = dict(source='cgroup-v2', memoryCurrentBytes=1024, pidsCurrent=4, cpuUsageUsec=2) with self.subTest(change=change), self.assertRaises(ValueError): - a.observation_status(value, identity(), attempt()['principals'], attempt()['targets'], attempt()['caseCommitments']) + a.observation_status(value, identity(), attempt()['principals'], attempt()['targets'], attempt()['caseCommitments'], attempt()['appProcess']) with self.assertRaises(ValueError): a.observation_status(observation('kernel-resource-scope'), identity()) @@ -214,7 +264,7 @@ def test_restart_terminal_identity_matches_sender(self): a.observation_status(observation('restart-durable-state'), identity()) def test_previous_contract_versions_cannot_supply_new_witnesses(self): - for version in ('pr314-workload-roles-v1', 'pr314-workload-roles-v2', 'pr314-workload-roles-v3', 'pr314-workload-roles-v4'): + for version in ('pr314-workload-roles-v1', 'pr314-workload-roles-v2', 'pr314-workload-roles-v3', 'pr314-workload-roles-v4', 'pr314-workload-roles-v5'): value = attempt() value['contract'] = version self.assertFalse(a.verify_attempts(identity(), [value])['recordContractValid']) @@ -326,7 +376,7 @@ def test_active_target_and_actual_denial_are_required(self): value = observation('sibling-fcp') value['witness'][field] = replacement with self.assertRaises(ValueError): - a.observation_status(value, identity(), attempt()['principals'], attempt()['targets'], attempt()['caseCommitments']) + a.observation_status(value, identity(), attempt()['principals'], attempt()['targets'], attempt()['caseCommitments'], attempt()['appProcess']) def test_four_roles_have_distinct_uids_and_namespaces(self): for field in ('uid', 'gid', 'invocationId', 'networkNamespace', 'cgroupDigest', 'role'): @@ -334,13 +384,13 @@ def test_four_roles_have_distinct_uids_and_namespaces(self): value = observation('four-role-start') value['witness']['roles'][1][field] = value['witness']['roles'][0][field] with self.assertRaises(ValueError): - a.observation_status(value, identity(), attempt()['principals'], attempt()['targets'], attempt()['caseCommitments']) + a.observation_status(value, identity(), attempt()['principals'], attempt()['targets'], attempt()['caseCommitments'], attempt()['appProcess']) def test_observer_uid_cannot_own_candidate(self): value = observation('four-role-start') value['witness']['observerUid'] = value['witness']['roles'][0]['uid'] with self.assertRaises(ValueError): - a.observation_status(value, identity(), attempt()['principals'], attempt()['targets'], attempt()['caseCommitments']) + a.observation_status(value, identity(), attempt()['principals'], attempt()['targets'], attempt()['caseCommitments'], attempt()['appProcess']) def test_remaining_descendant_or_populated_cgroup_prevents_terminal_pass(self): for field, replacement in (('remainingDescendants', 1), ('remainingDescendants', False), @@ -348,7 +398,7 @@ def test_remaining_descendant_or_populated_cgroup_prevents_terminal_pass(self): value = observation('late-child') value['witness'][field] = replacement with self.assertRaises(ValueError): - a.observation_status(value, identity(), attempt()['principals'], attempt()['targets'], attempt()['caseCommitments']) + a.observation_status(value, identity(), attempt()['principals'], attempt()['targets'], attempt()['caseCommitments'], attempt()['appProcess']) def test_duplicate_attempt_cannot_hide_failed_attempt(self): failed = attempt() @@ -381,7 +431,7 @@ def test_restart_requires_new_epoch_and_durable_state(self): value = copy.deepcopy(observation('restart-durable-state')) value['witness'][field] = replacement with self.assertRaises(ValueError): - a.observation_status(value, identity(), attempt()['principals'], attempt()['targets'], attempt()['caseCommitments']) + a.observation_status(value, identity(), attempt()['principals'], attempt()['targets'], attempt()['caseCommitments'], attempt()['appProcess']) if __name__ == '__main__': From 145ec119900e73cb3fcc85a94e4102c4e4130f29 Mon Sep 17 00:00:00 2001 From: leumor <116955025+leumor@users.noreply.github.com> Date: Tue, 22 Sep 2026 11:05:53 +0000 Subject: [PATCH 12/15] fix(protected): bind probe commitments to denial payloads --- ...ontroller-owned-workload-role-isolation.md | 8 ++++ .../restricted/pr314_acceptance.py | 17 +++++++-- .../restricted/test_pr314_acceptance.py | 38 ++++++++++++++++++- 3 files changed, 57 insertions(+), 6 deletions(-) diff --git a/docs/pr-314-controller-owned-workload-role-isolation.md b/docs/pr-314-controller-owned-workload-role-isolation.md index e580304d12..a45be5f2c0 100644 --- a/docs/pr-314-controller-owned-workload-role-isolation.md +++ b/docs/pr-314-controller-owned-workload-role-isolation.md @@ -389,6 +389,14 @@ the independent expected commitment. App witnesses must equal a separate kernel- Drivers must acquire this context independently, never copy candidate-reported identifiers. Canonical hashing provides consistency only, not authentic execution or trusted measurement. +Contract `pr314-workload-roles-v7` applies the canonical payload binding to denials too. +`attackDigest` identifies the actual measured attack transcript; `probeDigest` is recomputed +over the case, target and complete denial witness, excluding only the nested probe digest. +This covers actor, attack digest, timing, denial source/code, state and target/control identity. +It must match the independently supplied target context, so changing target metadata cannot +relabel a copied denial, and recomputing modified evidence cannot replace the expected context. +Actual attack measurement remains an installed-driver obligation, not a property of a hash. + The next composed-budget work remains PR-309's import/fetch concurrency, timeout, cancellation, retry and owner-terminal causality, followed by dependent window/store/restart/privacy cases. Workload isolation does not close Mail lifecycle, migration, long-run or independent review. diff --git a/tools/release-certification/restricted/pr314_acceptance.py b/tools/release-certification/restricted/pr314_acceptance.py index 37d3d2b508..903cb204c5 100644 --- a/tools/release-certification/restricted/pr314_acceptance.py +++ b/tools/release-certification/restricted/pr314_acceptance.py @@ -10,7 +10,7 @@ import json import re -CONTRACT = 'pr314-workload-roles-v6' +CONTRACT = 'pr314-workload-roles-v7' PROFILE = 'debian13-systemd257-workload-v1' ROLES = ('candidate-sender', 'candidate-recipient', 'previous', 'relay-no-apps') IDENTITY_FIELDS = ('helperSourceCommit', 'helperSourceTree', 'productSelectionDigest', @@ -181,8 +181,13 @@ def _resources(witness, principals): def payload_commitment(name, witness): """Canonical payload binding, not authentication; compare to independent driver context.""" - field = 'operationDigest' if CASES[name].witness == 'exchange' else 'triggerDigest' - payload = {key: value for key, value in witness.items() if key != field} + kind = CASES[name].witness + if kind == 'denial': + payload = {**witness, 'targetIdentity': { + key: value for key, value in witness['targetIdentity'].items() if key != 'probeDigest'}} + else: + field = 'operationDigest' if kind == 'exchange' else 'triggerDigest' + payload = {key: value for key, value in witness.items() if key != field} raw = json.dumps({'domain': CONTRACT, 'caseId': name, 'target': CASES[name].target, 'payload': payload}, sort_keys=True, separators=(',', ':'), allow_nan=False) return hashlib.sha256(raw.encode()).hexdigest() @@ -240,10 +245,11 @@ def _witness(case, witness, identity, principals, app_process): and witness['beforeStateDigest'] == witness['afterStateDigest'] and witness['deadlineUnchanged'] is True) if kind == 'denial': - return (_closed(witness, ('actorUid', 'attackStartedNs', 'targetActiveBeforeNs', + return (_closed(witness, ('actorUid', 'attackDigest', 'attackStartedNs', 'targetActiveBeforeNs', 'targetActiveAfterNs', 'controlResponseDigest', 'denialSource', 'denialCode', 'unrelatedStateBefore', 'unrelatedStateAfter', 'targetIdentity')) and _positive(witness['actorUid']) + and _hex(witness['attackDigest']) and _principals(principals) and witness['actorUid'] == _actor_uid(case.actor, principals) and all(_positive(witness[key]) for key in @@ -304,6 +310,7 @@ def observation_status(record, identity, principals=None, targets=None, case_com target = record['witness']['targetIdentity'] if not (isinstance(targets, dict) and record['caseId'] in targets and _target(record['caseId'], target, principals) + and target['probeDigest'] == payload_commitment(record['caseId'], record['witness']) and target == targets[record['caseId']] and record['witness']['controlResponseDigest'] == target['controlResponseDigest']): raise ValueError('workload-denial-target-mismatch') @@ -323,6 +330,8 @@ def verify_attempts(expected_identity, attempts): expected_identity.admittedAppDigest must come from the authenticated selection's exact installed-app projection, never from the observed app. Targets are independently measured active services; probeDigest commits to the endpoint/object and operation for that case. + Denial attackDigest must identify the actual measured attack transcript; the probe + commitment covers it and the complete denial payload, not just target metadata. caseCommitments independently binds each exchange operation and measured lifecycle trigger; the driver must not derive this expected context by copying the submitted witness. appProcess is a separate kernel observation of the current AppHost child, including its diff --git a/tools/release-certification/restricted/test_pr314_acceptance.py b/tools/release-certification/restricted/test_pr314_acceptance.py index 027bb4f95e..52ec0bdbf8 100644 --- a/tools/release-certification/restricted/test_pr314_acceptance.py +++ b/tools/release-certification/restricted/test_pr314_acceptance.py @@ -46,6 +46,7 @@ def observation(name): afterEpoch=roles()[0]['processEpoch'], beforeStateDigest=digest, afterStateDigest=digest, deadlineUnchanged=True), 'denial': dict(actorUid={'observer': 1000, 'runner': 1001}.get(case.actor, 2000), + attackDigest=f'{3000+list(a.CASES).index(name):064x}', targetIdentity=target(name), attackStartedNs=3, targetActiveBeforeNs=2, targetActiveAfterNs=4, controlResponseDigest=digest, denialSource='kernel', @@ -59,6 +60,8 @@ def observation(name): if case.witness in ('exchange', 'lifecycle'): field = 'operationDigest' if case.witness == 'exchange' else 'triggerDigest' witness[field] = a.payload_commitment(name, witness) + elif case.witness == 'denial': + witness['targetIdentity']['probeDigest'] = a.payload_commitment(name, witness) return dict(caseId=name, status='passed', actor=case.actor, target=case.target, outcome=case.outcome, startedMonotonicNs=1, finishedMonotonicNs=5, witness=witness) @@ -67,7 +70,8 @@ def observation(name): def attempt(): return dict(contract=a.CONTRACT, identity=identity(), declaredCases=list(a.CASES), principals=dict(observerUid=1000, runnerUid=1001, roles=roles()), - targets={name: target(name) for name, case in a.CASES.items() if case.witness == 'denial'}, + targets={name: observation(name)['witness']['targetIdentity'] for name, case in a.CASES.items() + if case.witness == 'denial'}, caseCommitments={name: a.payload_commitment(name, observation(name)['witness']) for name, case in a.CASES.items() if case.witness in ('exchange', 'lifecycle')}, appProcess=observation('signed-apphost-child')['witness'], @@ -76,6 +80,36 @@ def attempt(): class WorkloadAcceptanceTest(unittest.TestCase): + def test_denial_payload_cannot_be_copied_by_replacing_target_metadata(self): + value = attempt() + original = observation('sibling-fcp')['witness'] + for row in value['observations']: + if a.CASES[row['caseId']].witness != 'denial': + continue + own = row['witness'] + row['witness'] = copy.deepcopy(original) + for key in ('actorUid', 'targetIdentity', 'controlResponseDigest'): + row['witness'][key] = own[key] + result = a.verify_attempts(identity(), [value]) + self.assertFalse(result['recordContractValid']) + self.assertFalse(result['installedWorkloadAcceptanceSatisfied']) + + def test_denial_commitment_covers_measured_attack_and_outcome(self): + for field, replacement in (('attackDigest', 'f'*64), ('attackStartedNs', 4), + ('denialSource', 'server'), ('denialCode', 'EPERM'), ('unrelatedStateBefore', 'f'*64)): + value = attempt() + row = next(row for row in value['observations'] if row['caseId'] == 'sibling-fcp') + # Preserve otherwise valid interval/state checks while changing the payload. + if field == 'attackStartedNs': + row['witness']['targetActiveAfterNs'] = 5 + if field == 'unrelatedStateBefore': + row['witness']['unrelatedStateAfter'] = replacement + row['witness'][field] = replacement + with self.subTest(field=field): + self.assertFalse(a.verify_attempts(identity(), [value])['recordContractValid']) + row['witness']['targetIdentity']['probeDigest'] = a.payload_commitment(row['caseId'], row['witness']) + self.assertFalse(a.verify_attempts(identity(), [value])['recordContractValid']) + def test_relabel_and_replace_commitment_cannot_copy_payload(self): for source, destination in (('own-management', 'dynamic-app-bootstrap'), ('deadline', 'observer-death')): @@ -264,7 +298,7 @@ def test_restart_terminal_identity_matches_sender(self): a.observation_status(observation('restart-durable-state'), identity()) def test_previous_contract_versions_cannot_supply_new_witnesses(self): - for version in ('pr314-workload-roles-v1', 'pr314-workload-roles-v2', 'pr314-workload-roles-v3', 'pr314-workload-roles-v4', 'pr314-workload-roles-v5'): + for version in ('pr314-workload-roles-v1', 'pr314-workload-roles-v2', 'pr314-workload-roles-v3', 'pr314-workload-roles-v4', 'pr314-workload-roles-v5', 'pr314-workload-roles-v6'): value = attempt() value['contract'] = version self.assertFalse(a.verify_attempts(identity(), [value])['recordContractValid']) From c5811959f1075d7b24dbca46b04f1e04e578d692 Mon Sep 17 00:00:00 2001 From: leumor <116955025+leumor@users.noreply.github.com> Date: Tue, 22 Sep 2026 12:49:39 +0000 Subject: [PATCH 13/15] fix(protected): stop workload controller before acquiring cleanup lease --- ...ontroller-owned-workload-role-isolation.md | 6 +- .../restricted/pr314_workload_driver.py | 35 +++++---- .../restricted/test_pr314_workload_driver.py | 72 +++++++++++++++++++ 3 files changed, 98 insertions(+), 15 deletions(-) create mode 100644 tools/release-certification/restricted/test_pr314_workload_driver.py diff --git a/docs/pr-314-controller-owned-workload-role-isolation.md b/docs/pr-314-controller-owned-workload-role-isolation.md index a45be5f2c0..4a2a27d2e3 100644 --- a/docs/pr-314-controller-owned-workload-role-isolation.md +++ b/docs/pr-314-controller-owned-workload-role-isolation.md @@ -214,7 +214,11 @@ python3 /opt/cryptad-restricted-test-kit/tools/release-certification/restricted/ That driver invokes production preparation/installation code, drops the observer to `cryptad-soak`, uses the existing private journal, executes the adapter's actual positive sequence, and requires -terminal owned cgroup quiescence before namespace teardown. It retains role tmpfs state and +terminal owned cgroup quiescence before namespace teardown. Error cleanup first stops the +controller under its fixed shutdown budget, then acquires the reconciliation lease; an in-flight +RPC holding that lease cannot skip the stop request. Observer-child cleanup errors also reach +this stop path. A failed stop, uncertain reconciliation or nonempty cgroup retains the network +and state for diagnosis. It retains role tmpfs state and private records for diagnostics. Its public result has fixed status fields only. The private result labels the workload verdict incomplete because it does not execute the full hostile contract. Neither this command nor a passing positive sequence is a protected approval. diff --git a/tools/release-certification/restricted/pr314_workload_driver.py b/tools/release-certification/restricted/pr314_workload_driver.py index 793ae09e77..946c6fa2d4 100644 --- a/tools/release-certification/restricted/pr314_workload_driver.py +++ b/tools/release-certification/restricted/pr314_workload_driver.py @@ -43,6 +43,19 @@ def prerequisites(): return reasons +def cleanup(workload): + """Stop the lease owner before acquiring its nonblocking reconciliation lease.""" + subprocess.run(['/usr/bin/systemctl', 'stop', 'cryptad-workload-controller.service'], + check=True, timeout=110, env=ENV) + workload.reconcile() + with workload.locked(): + if not all(workload.quiescent(role) for role in workload.ROLES): + raise ValueError('workload-terminal-cgroups-not-empty') + from restricted_workload_network import teardown + teardown() + # Role tmpfs state and records remain explicitly retained for private diagnostics. + + def execute(): if prerequisites(): raise ValueError('workload-reference-prerequisites-unavailable') @@ -135,20 +148,14 @@ def execute(): return {'status': 'positive-sequence-executed', 'workloadAcceptance': 'incomplete', 'protectedExecutionEnabled': False} finally: - parent.close() - if status is None: - # This is the exact unreaped fork child; it cannot have been PID-reused. - os.kill(pid, signal.SIGKILL) - os.waitpid(pid, 0) - workload.reconcile() - subprocess.run(['/usr/bin/systemctl', 'stop', 'cryptad-workload-controller.service'], - check=True, timeout=110, env=ENV) - with workload.locked(): - if not all(workload.quiescent(role) for role in workload.ROLES): - raise ValueError('workload-terminal-cgroups-not-empty') - from restricted_workload_network import teardown - teardown() - # Role tmpfs state and records remain explicitly retained for private diagnostics. + try: + parent.close() + if status is None: + # This is the exact unreaped fork child; it cannot have been PID-reused. + os.kill(pid, signal.SIGKILL) + os.waitpid(pid, 0) + finally: + cleanup(workload) def main(): diff --git a/tools/release-certification/restricted/test_pr314_workload_driver.py b/tools/release-certification/restricted/test_pr314_workload_driver.py new file mode 100644 index 0000000000..1f94fec062 --- /dev/null +++ b/tools/release-certification/restricted/test_pr314_workload_driver.py @@ -0,0 +1,72 @@ +"""Local cleanup ordering and retention tests; no installed systemd execution.""" +from contextlib import contextmanager +import fcntl +from pathlib import Path +import subprocess +import tempfile +from types import SimpleNamespace +import unittest +from unittest.mock import Mock, patch + +import pr314_workload_driver as driver + + +class CleanupTest(unittest.TestCase): + def test_controller_stop_releases_busy_lease_before_reconciliation(self): + events = [] + with tempfile.TemporaryDirectory() as directory: + lease = Path(directory) / 'lease' + with lease.open('w') as controller: + fcntl.flock(controller, fcntl.LOCK_EX | fcntl.LOCK_NB) + + @contextmanager + def locked(): + with lease.open('r') as observer: + fcntl.flock(observer, fcntl.LOCK_EX | fcntl.LOCK_NB) + yield + + # Reproduce the exact busy-lease failure of the previous first step. + with self.assertRaises(BlockingIOError), locked(): + pass + + def stop(command, **kwargs): + self.assertEqual(['/usr/bin/systemctl', 'stop', + 'cryptad-workload-controller.service'], command) + self.assertTrue(kwargs['check']) + self.assertEqual(110, kwargs['timeout']) + events.append('controller-stop') + fcntl.flock(controller, fcntl.LOCK_UN) + + def reconcile(): + with locked(): + events.append('reconcile') + + workload = SimpleNamespace(ROLES=('sender', 'recipient'), locked=locked, + reconcile=reconcile, quiescent=lambda role: events.append(role) or True) + network = SimpleNamespace(teardown=lambda: events.append('teardown')) + with patch.object(driver.subprocess, 'run', side_effect=stop), \ + patch.dict('sys.modules', {'restricted_workload_network': network}): + driver.cleanup(workload) + self.assertEqual(['controller-stop', 'reconcile', 'sender', 'recipient', 'teardown'], events) + + def test_uncertain_shutdown_retains_network_and_state(self): + from contextlib import nullcontext + for failure in ('stop', 'reconcile', 'quiescence'): + workload = SimpleNamespace(ROLES=('sender',), locked=nullcontext, + reconcile=Mock(), quiescent=Mock(return_value=failure != 'quiescence')) + network = SimpleNamespace(teardown=Mock()) + if failure == 'reconcile': + workload.reconcile.side_effect = BlockingIOError('lease still busy') + with self.subTest(failure=failure), \ + patch.object(driver.subprocess, 'run', side_effect=( + subprocess.TimeoutExpired('systemctl', 110) if failure == 'stop' else None)), \ + patch.dict('sys.modules', {'restricted_workload_network': network}): + with self.assertRaises((subprocess.TimeoutExpired, BlockingIOError, ValueError)): + driver.cleanup(workload) + network.teardown.assert_not_called() + if failure == 'stop': + workload.reconcile.assert_not_called() + + +if __name__ == '__main__': + unittest.main() From 3ef78579d8f06feb57026ed49f5709bd1a48b9b1 Mon Sep 17 00:00:00 2001 From: leumor <116955025+leumor@users.noreply.github.com> Date: Tue, 22 Sep 2026 13:07:15 +0000 Subject: [PATCH 14/15] fix(protected): support nested app PIDs and datastore files --- ...ontroller-owned-workload-role-isolation.md | 8 +++++ .../protected/restricted_workload_app.py | 6 ++-- .../protected/test_restricted_workload.py | 32 +++++++++++++++++++ .../protected/test_restricted_workload_app.py | 20 ++++++++++-- .../systemd/cryptad-workload@.service | 4 ++- 5 files changed, 64 insertions(+), 6 deletions(-) diff --git a/docs/pr-314-controller-owned-workload-role-isolation.md b/docs/pr-314-controller-owned-workload-role-isolation.md index 4a2a27d2e3..7e2c54712f 100644 --- a/docs/pr-314-controller-owned-workload-role-isolation.md +++ b/docs/pr-314-controller-owned-workload-role-isolation.md @@ -401,6 +401,14 @@ It must match the independently supplied target context, so changing target meta relabel a copied denial, and recomputing modified evidence cannot replace the expected context. Actual attack measurement remains an installed-driver obligation, not a property of a hash. +The Mail process binding accepts AppHost's daemon-visible PID hint for either the outer +launcher or an interpreter-managed nested descendant. It still requires the owned cgroup, +daemon ancestry, a nested admitted-JDK JVM, current process epochs and the daemon-owned +loopback listener. The role service permits files up to 64 MiB so the configured 64 MiB +datastore can initialize its CHK backing files; the 512 MiB role tmpfs remains the total +writable-storage bound. Synthetic process regressions and a local kernel file-limit check +cover these corrections, not installed AppHost or content-exchange acceptance. + The next composed-budget work remains PR-309's import/fetch concurrency, timeout, cancellation, retry and owner-terminal causality, followed by dependent window/store/restart/privacy cases. Workload isolation does not close Mail lifecycle, migration, long-run or independent review. diff --git a/tools/release-certification/protected/restricted_workload_app.py b/tools/release-certification/protected/restricted_workload_app.py index 37e386838c..62b50bc8cc 100644 --- a/tools/release-certification/protected/restricted_workload_app.py +++ b/tools/release-certification/protected/restricted_workload_app.py @@ -172,9 +172,11 @@ def require_app_listener(role, runtime_dict, port): if len(workers) != 1: _reject() worker = workers[0] + # AppHost may report its outer launcher or an interpreter-managed descendant. + # Both hints use the daemon-visible PID (NSpid[1]), including nested processes. + outer_worker = len(worker['namespacePids']) == 2 if (worker['hostPid'] == daemon['hostPid'] - or len(worker['namespacePids']) != 2 - or worker['pidNamespace'] != daemon['pidNamespace'] + or (worker['pidNamespace'] == daemon['pidNamespace']) != outer_worker or not _descendant(worker['hostPid'], daemon['hostPid'], processes)): _reject() app_jvms = [value for value in processes.values() diff --git a/tools/release-certification/protected/test_restricted_workload.py b/tools/release-certification/protected/test_restricted_workload.py index d0c48202b1..b2ef32e50c 100644 --- a/tools/release-certification/protected/test_restricted_workload.py +++ b/tools/release-certification/protected/test_restricted_workload.py @@ -8,6 +8,7 @@ import threading import struct import subprocess +import sys import tempfile from pathlib import Path from types import SimpleNamespace @@ -21,6 +22,37 @@ class ProspectiveConfigurationTest(unittest.TestCase): + @unittest.skipUnless(sys.platform == 'linux', 'Linux resource limits required') + def test_service_file_limit_allows_configured_store_and_remains_finite(self): + unit = Path(__file__).resolve().parents[1] / 'restricted/systemd/cryptad-workload@.service' + settings = dict(line.split('=', 1) for line in unit.read_text().splitlines() + if '=' in line and not line.startswith('#')) + limit = settings['LimitFSIZE'] + self.assertTrue(limit.endswith('M')) + limit_bytes = int(limit[:-1]) * 1024**2 + config = dict(line.split('=', 1) for line in preparation.configuration(workload.ROLES[0]).splitlines() + if '=' in line) + # An entire configured store is an upper bound on each CHK backing file. + store_bytes = int(config['node.storeSize']) + with tempfile.TemporaryDirectory() as temporary: + result = subprocess.run([sys.executable, '-c', ''' +import errno, os, resource, signal, sys +limit, required = map(int, sys.argv[1:3]) +resource.setrlimit(resource.RLIMIT_FSIZE, (limit, limit)) +signal.signal(signal.SIGXFSZ, signal.SIG_IGN) +with open(sys.argv[3], 'wb') as stream: + os.ftruncate(stream.fileno(), required) + assert os.fstat(stream.fileno()).st_size == required + try: + os.ftruncate(stream.fileno(), limit + 1) + except OSError as failure: + assert failure.errno == errno.EFBIG + else: + raise AssertionError('file size limit not enforced') +''', str(limit_bytes), str(store_bytes), str(Path(temporary) / 'store.hd')], + capture_output=True, text=True, timeout=10) + self.assertEqual(result.returncode, 0, result.stderr) + def test_role_configuration_keeps_management_loopback_and_distinct_data_plane(self): from restricted_workload_network import address identities = set() diff --git a/tools/release-certification/protected/test_restricted_workload_app.py b/tools/release-certification/protected/test_restricted_workload_app.py index 548c97bb1d..1658ce3458 100644 --- a/tools/release-certification/protected/test_restricted_workload_app.py +++ b/tools/release-certification/protected/test_restricted_workload_app.py @@ -116,9 +116,23 @@ def test_unrelated_nested_java_does_not_satisfy_worker_descendant(self): with self.assertRaises(app.workload.WorkloadError): app.require_app_listener(ROLE, RUNTIME, 22000) - def test_app_java_cannot_substitute_for_outer_apphost_worker_pid(self): - with self.assertRaises(app.workload.WorkloadError): - app.require_app_listener(ROLE, {**RUNTIME, 'pid': 10}, 22000) + def test_interpreter_descendant_hint_resolves_nested_init_or_java(self): + for namespace_pid, host_pid in ((9, 120), (10, 130)): + self.called.clear() + with self.subTest(pid=namespace_pid): + result = app.require_app_listener(ROLE, {**RUNTIME, 'pid': namespace_pid}, 22000) + self.assertEqual(result['worker']['hostPid'], host_pid) + self.assertEqual(result['appJvm']['hostPid'], 130) + for pid in self.processes: + self.assertEqual(self.called.count(pid), 2) + + def test_nested_hint_cannot_bind_unrelated_java_or_outer_namespace(self): + for field, value in (('hostParentPid', 90), ('pidNamespace', 'pid:[5000]')): + original = self.processes[130][field] + self.processes[130][field] = value + with self.subTest(field=field), self.assertRaises(app.workload.WorkloadError): + app.require_app_listener(ROLE, {**RUNTIME, 'pid': 10}, 22000) + self.processes[130][field] = original def test_missing_deeper_java_rejects_reported_bubblewrap_success(self): del self.processes[130] diff --git a/tools/release-certification/restricted/systemd/cryptad-workload@.service b/tools/release-certification/restricted/systemd/cryptad-workload@.service index 314ba360c8..6b7e84226c 100644 --- a/tools/release-certification/restricted/systemd/cryptad-workload@.service +++ b/tools/release-certification/restricted/systemd/cryptad-workload@.service @@ -26,7 +26,9 @@ RestrictRealtime=yes Delegate=no LimitCORE=0 LimitNOFILE=1024 -LimitFSIZE=16M +# The fixed 64 MiB datastore needs roughly 30 MiB per CHK backing file. +# Keep a finite per-file ceiling; the role's 512 MiB tmpfs bounds total writable bytes. +LimitFSIZE=64M TasksMax=512 MemoryMax=1G MemorySwapMax=0 From ad07c8e4463b5546eacfeac0747986c23823a7fb Mon Sep 17 00:00:00 2001 From: leumor <116955025+leumor@users.noreply.github.com> Date: Tue, 22 Sep 2026 13:20:38 +0000 Subject: [PATCH 15/15] fix(protected): bind both restart epochs to measured context --- ...ontroller-owned-workload-role-isolation.md | 11 +++ .../restricted/pr314_acceptance.py | 55 +++++++++++++-- .../restricted/test_pr314_acceptance.py | 67 +++++++++++++++++-- 3 files changed, 119 insertions(+), 14 deletions(-) diff --git a/docs/pr-314-controller-owned-workload-role-isolation.md b/docs/pr-314-controller-owned-workload-role-isolation.md index 7e2c54712f..363a029ac0 100644 --- a/docs/pr-314-controller-owned-workload-role-isolation.md +++ b/docs/pr-314-controller-owned-workload-role-isolation.md @@ -401,6 +401,17 @@ It must match the independently supplied target context, so changing target meta relabel a copied denial, and recomputing modified evidence cannot replace the expected context. Actual attack measurement remains an installed-driver obligation, not a property of a hash. +Contract `pr314-workload-roles-v8` requires independent `restartContext` snapshots before +and after the sender restart. Each binds the full role identity, measured durable-state +digest, controller deadline and monotonic observation time. Both witness identities must +match these snapshots; the terminal identity must also match the current role roster. +The invocation must change and the process epoch advance without changing the role's +account, boot, cgroup scope, network namespace, durable state or deadline. Samples must +fall within the case interval and before the deadline. Older contracts cannot establish +this binding. Drivers must capture both snapshots independently, not reconstruct the +pre-restart context from a witness. This contract correction is not installed execution +or original-authority evidence; the installed restart acceptance remains outstanding. + The Mail process binding accepts AppHost's daemon-visible PID hint for either the outer launcher or an interpreter-managed nested descendant. It still requires the owned cgroup, daemon ancestry, a nested admitted-JDK JVM, current process epochs and the daemon-owned diff --git a/tools/release-certification/restricted/pr314_acceptance.py b/tools/release-certification/restricted/pr314_acceptance.py index 903cb204c5..bab37619ee 100644 --- a/tools/release-certification/restricted/pr314_acceptance.py +++ b/tools/release-certification/restricted/pr314_acceptance.py @@ -10,7 +10,7 @@ import json import re -CONTRACT = 'pr314-workload-roles-v7' +CONTRACT = 'pr314-workload-roles-v8' PROFILE = 'debian13-systemd257-workload-v1' ROLES = ('candidate-sender', 'candidate-recipient', 'previous', 'relay-no-apps') IDENTITY_FIELDS = ('helperSourceCommit', 'helperSourceTree', 'productSelectionDigest', @@ -205,7 +205,35 @@ def _app_process(value, identity, principals): if row['role'] == value['role'])) -def _witness(case, witness, identity, principals, app_process): +def _restart_context(value, principals): + """Independent controller/kernel snapshots, never reconstructed from a witness.""" + if not (_closed(value, ('before', 'after')) and _principals(principals)): + return False + current = next(row for row in principals['roles'] if row['role'] == 'candidate-sender') + for snapshot in value.values(): + if not (_closed(snapshot, ('roleIdentity', 'stateDigest', 'deadlineNs', 'observedMonotonicNs')) + and _closed(snapshot['roleIdentity'], current) + and _hex(snapshot['stateDigest']) and _positive(snapshot['deadlineNs']) + and _positive(snapshot['observedMonotonicNs']) + and snapshot['observedMonotonicNs'] < snapshot['deadlineNs']): + return False + rows = [snapshot['roleIdentity'] if row['role'] == 'candidate-sender' else row + for row in principals['roles']] + if not _roster(rows): + return False + before, after = value['before'], value['after'] + old, new = before['roleIdentity'], after['roleIdentity'] + return (new == current + and all(old[key] == new[key] for key in current + if key not in ('invocationId', 'processEpoch')) + and old['invocationId'] != new['invocationId'] + and old['processEpoch'] < new['processEpoch'] + and before['observedMonotonicNs'] < after['observedMonotonicNs'] + and before['deadlineNs'] == after['deadlineNs'] + and before['stateDigest'] == after['stateDigest']) + + +def _witness(case, witness, identity, principals, app_process, restart_context): kind = case.witness if kind == 'identity': return witness == {'profile': PROFILE, 'bundleIdentity': identity['bundleIdentity'], @@ -243,7 +271,12 @@ def _witness(case, witness, identity, principals, app_process): and row['processEpoch'] == witness['afterEpoch'] for row in principals['roles']) and _hex(witness['beforeStateDigest']) and witness['beforeStateDigest'] == witness['afterStateDigest'] - and witness['deadlineUnchanged'] is True) + and witness['deadlineUnchanged'] is True + and _restart_context(restart_context, principals) + and all(witness[prefix + 'InvocationId'] == restart_context[prefix]['roleIdentity']['invocationId'] + and witness[prefix + 'Epoch'] == restart_context[prefix]['roleIdentity']['processEpoch'] + and witness[prefix + 'StateDigest'] == restart_context[prefix]['stateDigest'] + for prefix in ('before', 'after'))) if kind == 'denial': return (_closed(witness, ('actorUid', 'attackDigest', 'attackStartedNs', 'targetActiveBeforeNs', 'targetActiveAfterNs', 'controlResponseDigest', 'denialSource', @@ -282,7 +315,7 @@ def inventory(statuses=None): for name, case in CASES.items()] -def observation_status(record, identity, principals=None, targets=None, case_commitments=None, app_process=None): +def observation_status(record, identity, principals=None, targets=None, case_commitments=None, app_process=None, restart_context=None): if not isinstance(record, dict) or not isinstance(record.get('caseId'), str) or record['caseId'] not in CASES: raise ValueError('workload-case-invalid') if record.get('status') != 'passed': @@ -296,8 +329,12 @@ def observation_status(record, identity, principals=None, targets=None, case_com and (record['actor'], record['target'], record['outcome']) == (case.actor, case.target, case.outcome) and _positive(record['startedMonotonicNs']) and _positive(record['finishedMonotonicNs']) and record['startedMonotonicNs'] < record['finishedMonotonicNs'] - and _witness(case, record['witness'], identity, principals, app_process)): + and _witness(case, record['witness'], identity, principals, app_process, restart_context)): raise ValueError('workload-observation-invalid') + if case.witness == 'restart' and not all( + record['startedMonotonicNs'] <= restart_context[stage]['observedMonotonicNs'] + <= record['finishedMonotonicNs'] for stage in ('before', 'after')): + raise ValueError('workload-restart-context-outside-invocation') if case.witness in ('exchange', 'lifecycle'): field = 'operationDigest' if case.witness == 'exchange' else 'triggerDigest' if not (record['witness']['caseId'] == record['caseId'] @@ -325,6 +362,8 @@ def observation_status(record, identity, principals=None, targets=None, case_com def verify_attempts(expected_identity, attempts): """Check driver records; caller must bind transport, source and measured principal context. + restartContext must be captured independently before and after the restart, including + controller deadline, durable-state measurement and kernel/service role identity. UID equality binds the host account, not proof of app sandbox execution. The installed driver must capture the actual probe process under its current role/app invocation. expected_identity.admittedAppDigest must come from the authenticated selection's exact @@ -344,7 +383,7 @@ def verify_attempts(expected_identity, attempts): try: if not (_closed(attempt, ('contract', 'identity', 'declaredCases', 'observations', 'guestStopped', 'attemptCompleted', 'principals', 'targets', 'caseCommitments', - 'appProcess')) + 'appProcess', 'restartContext')) and attempt['contract'] == CONTRACT and attempt['identity'] == expected_identity and _principals(attempt['principals']) and attempt['guestStopped'] is True and attempt['attemptCompleted'] is True @@ -352,6 +391,8 @@ def verify_attempts(expected_identity, attempts): and all(isinstance(name, str) and name in CASES for name in attempt['declaredCases']) and (_app_process(attempt['appProcess'], expected_identity, attempt['principals']) if 'signed-apphost-child' in attempt['declaredCases'] else attempt['appProcess'] is None) + and (_restart_context(attempt['restartContext'], attempt['principals']) + if 'restart-durable-state' in attempt['declaredCases'] else attempt['restartContext'] is None) and _targets(attempt['targets'], attempt['principals'], attempt['declaredCases']) and _case_commitments(attempt['caseCommitments'], attempt['declaredCases']) and len(set(attempt['declaredCases'])) == len(attempt['declaredCases']) @@ -370,7 +411,7 @@ def verify_attempts(expected_identity, attempts): observed = {} for row in attempt['observations']: status = observation_status(row, expected_identity, attempt['principals'], attempt['targets'], - attempt['caseCommitments'], attempt['appProcess']) + attempt['caseCommitments'], attempt['appProcess'], attempt['restartContext']) if row['caseId'] in observed: raise ValueError('workload-duplicate-observation') observed[row['caseId']] = status diff --git a/tools/release-certification/restricted/test_pr314_acceptance.py b/tools/release-certification/restricted/test_pr314_acceptance.py index 52ec0bdbf8..0b773f4f83 100644 --- a/tools/release-certification/restricted/test_pr314_acceptance.py +++ b/tools/release-certification/restricted/test_pr314_acceptance.py @@ -67,6 +67,13 @@ def observation(name): witness=witness) +def restart_context(): + before = {**roles()[0], 'invocationId': 'a'*32, 'processEpoch': 1} + return {stage: dict(roleIdentity=row, stateDigest='b'*64, deadlineNs=10, + observedMonotonicNs=timestamp) + for stage, row, timestamp in (('before', before, 2), ('after', roles()[0], 4))} + + def attempt(): return dict(contract=a.CONTRACT, identity=identity(), declaredCases=list(a.CASES), principals=dict(observerUid=1000, runnerUid=1001, roles=roles()), @@ -74,6 +81,7 @@ def attempt(): if case.witness == 'denial'}, caseCommitments={name: a.payload_commitment(name, observation(name)['witness']) for name, case in a.CASES.items() if case.witness in ('exchange', 'lifecycle')}, + restartContext=restart_context(), appProcess=observation('signed-apphost-child')['witness'], observations=[observation(name) for name in a.CASES], guestStopped=True, attemptCompleted=True) @@ -177,6 +185,8 @@ def test_case_commitments_cannot_be_reused_within_or_across_attempts(self): for name in a.CASES: part = copy.deepcopy(value) part['declaredCases'] = [name] + if name != 'restart-durable-state': + part['restartContext'] = None if name != 'signed-apphost-child': part['appProcess'] = None part['observations'] = [row for row in part['observations'] if row['caseId'] == name] @@ -201,6 +211,8 @@ def test_probe_commitments_are_unique_across_all_attempts(self): for name in a.CASES: value = attempt() value['declaredCases'] = [name] + if name != 'restart-durable-state': + value['restartContext'] = None if name != 'signed-apphost-child': value['appProcess'] = None value['observations'] = [row for row in value['observations'] if row['caseId'] == name] @@ -281,10 +293,51 @@ def test_resource_measurements_require_complete_unique_roster(self): else: value['witness'] = dict(source='cgroup-v2', memoryCurrentBytes=1024, pidsCurrent=4, cpuUsageUsec=2) with self.subTest(change=change), self.assertRaises(ValueError): - a.observation_status(value, identity(), attempt()['principals'], attempt()['targets'], attempt()['caseCommitments'], attempt()['appProcess']) + a.observation_status(value, identity(), attempt()['principals'], attempt()['targets'], attempt()['caseCommitments'], attempt()['appProcess'], attempt()['restartContext']) with self.assertRaises(ValueError): a.observation_status(observation('kernel-resource-scope'), identity()) + def test_restart_cannot_invent_pre_restart_identity(self): + for field, replacement in (('beforeInvocationId', 'f'*32), ('beforeEpoch', 99), + ('beforeStateDigest', 'c'*64)): + value = attempt() + row = next(row for row in value['observations'] if row['caseId'] == 'restart-durable-state') + row['witness'][field] = replacement + if field == 'beforeStateDigest': + row['witness']['afterStateDigest'] = replacement + with self.subTest(field=field): + result = a.verify_attempts(identity(), [value]) + self.assertFalse(result['recordContractValid']) + self.assertFalse(result['installedWorkloadAcceptanceSatisfied']) + + def test_restart_requires_both_independent_measured_snapshots(self): + for replacement in (None, {}, {'after': restart_context()['after']}, + {'before': restart_context()['after'], 'after': restart_context()['after']}): + value = attempt() + value['restartContext'] = replacement + with self.subTest(context=replacement): + self.assertFalse(a.verify_attempts(identity(), [value])['recordContractValid']) + with self.assertRaises(ValueError): + value = attempt() + a.observation_status(observation('restart-durable-state'), identity(), value['principals']) + + def test_restart_context_rejects_changed_scope_deadline_state_and_timing(self): + for stage in ('before', 'after'): + for field, replacement in (('bootId', 'f'*32), ('uid', 9999), ('gid', 9999), + ('role', 'candidate-recipient'), ('cgroupDigest', 'f'*64), + ('networkNamespace', 9999), ('invocationId', roles()[1]['invocationId']), + ('processEpoch', True), ('deadlineNs', 11), ('stateDigest', 'f'*64), + ('observedMonotonicNs', 6), ('observedMonotonicNs', 1)): + value = attempt() + snapshot = value['restartContext'][stage] + container = snapshot['roleIdentity'] if field in snapshot['roleIdentity'] else snapshot + container[field] = replacement + # A before sample at the start of the case is legitimate. + if stage == 'before' and field == 'observedMonotonicNs' and replacement == 1: + continue + with self.subTest(stage=stage, field=field, replacement=replacement): + self.assertFalse(a.verify_attempts(identity(), [value])['recordContractValid']) + def test_restart_terminal_identity_matches_sender(self): for field, replacement in (('role', 'candidate-recipient'), ('role', 'controller'), ('afterInvocationId', 'f'*32), ('afterInvocationId', roles()[1]['invocationId']), @@ -298,7 +351,7 @@ def test_restart_terminal_identity_matches_sender(self): a.observation_status(observation('restart-durable-state'), identity()) def test_previous_contract_versions_cannot_supply_new_witnesses(self): - for version in ('pr314-workload-roles-v1', 'pr314-workload-roles-v2', 'pr314-workload-roles-v3', 'pr314-workload-roles-v4', 'pr314-workload-roles-v5', 'pr314-workload-roles-v6'): + for version in ('pr314-workload-roles-v1', 'pr314-workload-roles-v2', 'pr314-workload-roles-v3', 'pr314-workload-roles-v4', 'pr314-workload-roles-v5', 'pr314-workload-roles-v6', 'pr314-workload-roles-v7'): value = attempt() value['contract'] = version self.assertFalse(a.verify_attempts(identity(), [value])['recordContractValid']) @@ -410,7 +463,7 @@ def test_active_target_and_actual_denial_are_required(self): value = observation('sibling-fcp') value['witness'][field] = replacement with self.assertRaises(ValueError): - a.observation_status(value, identity(), attempt()['principals'], attempt()['targets'], attempt()['caseCommitments'], attempt()['appProcess']) + a.observation_status(value, identity(), attempt()['principals'], attempt()['targets'], attempt()['caseCommitments'], attempt()['appProcess'], attempt()['restartContext']) def test_four_roles_have_distinct_uids_and_namespaces(self): for field in ('uid', 'gid', 'invocationId', 'networkNamespace', 'cgroupDigest', 'role'): @@ -418,13 +471,13 @@ def test_four_roles_have_distinct_uids_and_namespaces(self): value = observation('four-role-start') value['witness']['roles'][1][field] = value['witness']['roles'][0][field] with self.assertRaises(ValueError): - a.observation_status(value, identity(), attempt()['principals'], attempt()['targets'], attempt()['caseCommitments'], attempt()['appProcess']) + a.observation_status(value, identity(), attempt()['principals'], attempt()['targets'], attempt()['caseCommitments'], attempt()['appProcess'], attempt()['restartContext']) def test_observer_uid_cannot_own_candidate(self): value = observation('four-role-start') value['witness']['observerUid'] = value['witness']['roles'][0]['uid'] with self.assertRaises(ValueError): - a.observation_status(value, identity(), attempt()['principals'], attempt()['targets'], attempt()['caseCommitments'], attempt()['appProcess']) + a.observation_status(value, identity(), attempt()['principals'], attempt()['targets'], attempt()['caseCommitments'], attempt()['appProcess'], attempt()['restartContext']) def test_remaining_descendant_or_populated_cgroup_prevents_terminal_pass(self): for field, replacement in (('remainingDescendants', 1), ('remainingDescendants', False), @@ -432,7 +485,7 @@ def test_remaining_descendant_or_populated_cgroup_prevents_terminal_pass(self): value = observation('late-child') value['witness'][field] = replacement with self.assertRaises(ValueError): - a.observation_status(value, identity(), attempt()['principals'], attempt()['targets'], attempt()['caseCommitments'], attempt()['appProcess']) + a.observation_status(value, identity(), attempt()['principals'], attempt()['targets'], attempt()['caseCommitments'], attempt()['appProcess'], attempt()['restartContext']) def test_duplicate_attempt_cannot_hide_failed_attempt(self): failed = attempt() @@ -465,7 +518,7 @@ def test_restart_requires_new_epoch_and_durable_state(self): value = copy.deepcopy(observation('restart-durable-state')) value['witness'][field] = replacement with self.assertRaises(ValueError): - a.observation_status(value, identity(), attempt()['principals'], attempt()['targets'], attempt()['caseCommitments'], attempt()['appProcess']) + a.observation_status(value, identity(), attempt()['principals'], attempt()['targets'], attempt()['caseCommitments'], attempt()['appProcess'], attempt()['restartContext']) if __name__ == '__main__':