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..363a029ac0 --- /dev/null +++ b/docs/pr-314-controller-owned-workload-role-isolation.md @@ -0,0 +1,425 @@ +# 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. 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. + +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 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. + +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 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. + +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 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. + +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. + +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. + +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. + +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 +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/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..62b50bc8cc --- /dev/null +++ b/tools/release-certification/protected/restricted_workload_app.py @@ -0,0 +1,216 @@ +"""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] + # 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 (worker['pidNamespace'] == daemon['pidNamespace']) != outer_worker + 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..a3bd3324dc --- /dev/null +++ b/tools/release-certification/protected/restricted_workload_controller.py @@ -0,0 +1,241 @@ +#!/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 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(): + 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, 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, + '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, runtime_object(runtime_body), + 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 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') + 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: + if handle_request(connection, observer.pw_uid): + last_observer = time.monotonic() + 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..5e2096aadd --- /dev/null +++ b/tools/release-certification/protected/restricted_workload_prepare.py @@ -0,0 +1,241 @@ +"""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 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', + '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 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) + 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) + # 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. + 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..b2ef32e50c --- /dev/null +++ b/tools/release-certification/protected/test_restricted_workload.py @@ -0,0 +1,687 @@ +"""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 sys +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): + @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() + 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 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 + 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: + 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() + + 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', '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), + ('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..1658ce3458 --- /dev/null +++ b/tools/release-certification/protected/test_restricted_workload_app.py @@ -0,0 +1,274 @@ +"""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_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] + 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..07783a9b96 --- /dev/null +++ b/tools/release-certification/protected/test_restricted_workload_network.py @@ -0,0 +1,233 @@ +"""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): + @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. + 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..bab37619ee --- /dev/null +++ b/tools/release-certification/restricted/pr314_acceptance.py @@ -0,0 +1,428 @@ +"""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 hashlib +import json +import re + +CONTRACT = 'pr314-workload-roles-v8' +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', 'admittedAppDigest') +STATUSES = frozenset(('passed', 'failed', 'setup-failed', 'not-executed', 'inconclusive')) + + +@dataclass(frozen=True) +class Case: + actor: str + 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', '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'), +} +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 _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'] + + +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 _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) + 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 payload_commitment(name, witness): + """Canonical payload binding, not authentication; compare to independent driver context.""" + 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() + + +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 _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'], + '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']} + and (principals is None or (witness['observerUid'] == principals['observerUid'] + and witness['roles'] == principals['roles']))) + if kind == 'app': + 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')) + 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 _principals(principals) + and witness['serverInvocationId'] == next(row['invocationId'] for row in principals['roles'] + if row['role'] == case.server_role)) + if kind == 'resources': + return _resources(witness, principals) + if kind == 'restart': + 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 + 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', + '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 + ('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, ('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'] == [] + 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 + + +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, 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': + 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, 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'] + 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': + 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') + 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 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 + 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 + host/namespace PID and start epoch; it must not be copied from candidate API claims. + """ + 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: + if not (_closed(attempt, ('contract', 'identity', 'declaredCases', 'observations', + 'guestStopped', 'attemptCompleted', 'principals', 'targets', 'caseCommitments', + '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 + 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 (_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']) + 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'], + attempt['caseCommitments'], attempt['appProcess'], attempt['restartContext']) + 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..946c6fa2d4 --- /dev/null +++ b/tools/release-certification/restricted/pr314_workload_driver.py @@ -0,0 +1,182 @@ +#!/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 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') + 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: + 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(): + 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..6b7e84226c --- /dev/null +++ b/tools/release-certification/restricted/systemd/cryptad-workload@.service @@ -0,0 +1,41 @@ +[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 +# 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 +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..0b773f4f83 --- /dev/null +++ b/tools/release-certification/restricted/test_pr314_acceptance.py @@ -0,0 +1,525 @@ +"""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 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 + 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=identity()['admittedAppDigest']), + 'exchange': dict(caseId=name, operationDigest=f'{1000+list(a.CASES).index(name):064x}', + 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')}, + '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), + attackDigest=f'{3000+list(a.CASES).index(name):064x}', + targetIdentity=target(name), + attackStartedNs=3, targetActiveBeforeNs=2, + 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) + 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) + + +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()), + 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')}, + restartContext=restart_context(), + appProcess=observation('signed-apphost-child')['witness'], + observations=[observation(name) for name in a.CASES], + guestStopped=True, attemptCompleted=True) + + +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')): + 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] + 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] + 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] + 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: + 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] + 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: + 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') + 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), + ('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'], 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']), + ('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', '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']) + + 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() + 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': + 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']) + 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(), 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'): + 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(), 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'], attempt()['restartContext']) + + 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(), attempt()['principals'], attempt()['targets'], attempt()['caseCommitments'], attempt()['appProcess'], attempt()['restartContext']) + + 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(), attempt()['principals'], attempt()['targets'], attempt()['caseCommitments'], attempt()['appProcess'], attempt()['restartContext']) + + +if __name__ == '__main__': + unittest.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() 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