From f2a17950166140a9058510ffa2b24fd48c95ee3c Mon Sep 17 00:00:00 2001 From: Aco Piper Date: Wed, 2 Sep 2026 08:18:54 +0900 Subject: [PATCH] Share registrar scenario deployment setup The red-team and endurance scenarios each carried their own copy of the same isolated deployment: the run root and its audit tmpfs, the four allocated host ports, the fingerprinted provisioning config, the responder image build, `infra install`, `init` under sudo, the DNS aliases step-ca resolves its challenge through, the daemon configuration, and the supervisor that owns the inherited listener. Two copies of one deployment drift, and the drift is invisible: each arm still passes against whatever deployment it happens to be standing up. They share one implementation now, and keep their own assertions. The instance name derives through that shared helper too, against the budget the binary validates rather than a per-scenario literal. Both arms keep the name they had for every token that fits, which is every token either launcher passes; an over-long one now truncates to its discriminating tail instead of failing `infra install` minutes into a run. `scripts/validate-e2e-run-scope.sh` exercises the helper for both prefixes rather than grepping one script for the lines it used to hold. `append_configured_anchors` is documented where a later reader will find it: what gates it, why an unchanged root-anchor pin needs it across a leaf renewal, and that the registrar surface is its only consumer today while the behaviour sits in the shared publication arm any future one would inherit. That disclosure was owed when the change merged, and a pull request description is not where it survives. Closes #981 --- docs/reference/registrar-client-identity.md | 14 + scripts/impl/lib/registrar-docker.sh | 444 +++++++++++++++++++- scripts/impl/run-registrar-endurance.sh | 192 ++------- scripts/impl/run-registrar-redteam.sh | 192 ++------- scripts/validate-e2e-run-scope.sh | 71 +++- src/acme/flow.rs | 34 ++ 6 files changed, 608 insertions(+), 339 deletions(-) diff --git a/docs/reference/registrar-client-identity.md b/docs/reference/registrar-client-identity.md index d80126de..e2875d17 100644 --- a/docs/reference/registrar-client-identity.md +++ b/docs/reference/registrar-client-identity.md @@ -244,6 +244,20 @@ bundle the server sends alongside its leaf, whose fingerprints are exactly what rotation, keep both generations in the file until the endpoint's leaf has been reissued under the new anchor. +That anchor arrives on the wire because bootroot puts it there. An ACME response +commonly stops at an intermediate, so publishing a surface leaf with the response +chain alone would leave a correctly renewed endpoint presenting nothing a +root-anchor pin could chain to — and this file is written once, at install, by a +tool that does not run again when a leaf is renewed. `append_configured_anchors` +(`src/acme/flow.rs`) therefore adds the configured trust anchors the issuer did +not return, so an **unchanged** pin keeps accepting the endpoint across renewal. +It is gated on `trust.ca_bundle_path`: with no bundle configured there is no +chain to add to, and what is published is what the CA returned. It lives in the +shared `LeafPublication::LeafWithChain` arm rather than in registrar-specific +code, so any future consumer of that variant inherits it; today the sole consumer +is `SURFACE_LEAF_PUBLICATION` in `src/registrar_certs.rs`, which publishes both +registrar surface leaves. Ordinary service issuance is `LeafOnly` and unaffected. + ## 5. Observed extended key usage on issued certificates The CSR is where this repository's decision ends. On the CSR diff --git a/scripts/impl/lib/registrar-docker.sh b/scripts/impl/lib/registrar-docker.sh index 9ca298fe..59a72fcc 100644 --- a/scripts/impl/lib/registrar-docker.sh +++ b/scripts/impl/lib/registrar-docker.sh @@ -7,7 +7,7 @@ # exercise the same contract. registrar_docker_require_launcher_contract() { - [ "$#" -eq 0 ] || fail "the registrar-redteam launcher takes no positional arguments" + [ "$#" -eq 0 ] || fail "a registrar scenario launcher takes no positional arguments" for registrar_docker_variable in BOOTROOT_PROJECT_DIR BOOTROOT_BIN ARTIFACT_DIR; do registrar_docker_value="${!registrar_docker_variable:-}" case "$registrar_docker_value" in @@ -97,3 +97,445 @@ registrar_docker_prepare_deployment_tree() { cp "$project_dir/responder.toml.compose" "$work_dir/" || fail "could not copy the responder configuration" } + +# --------------------------------------------------------------------------- +# Shared isolated-deployment and daemon-supervisor setup +# --------------------------------------------------------------------------- +# +# Both Docker-backed registrar scenarios — the per-pull-request red-team arm +# and the extended-tier endurance arm — stand the same deployment up before +# they diverge: a run-scoped Compose project on four freshly allocated host +# ports, `bootroot init` under `sudo`, the registrar DNS aliases step-ca +# resolves its challenge through, and a root-owned inherited listener under a +# small Python supervisor. Only what each scenario then asserts, and how long +# it waits, differs. That setup lives here once so the two arms cannot slowly +# grow different deployments; each scenario's assertions and workload stay in +# its own script, and nothing scenario-specific belongs here. +# +# These functions read and write the run's own shell variables rather than +# taking each as an argument. RUN_ROOT, WORK_DIR, INSTANCE and the paths below +# are the run's identity, and every scenario needs them under exactly those +# names afterwards; passing them in and out again would only add a second +# spelling of each. Every function states what it requires and what it leaves +# set. +# +# Requires a caller-defined `fail`, `lib/ports.sh` for `pick_free_port`, the +# launcher contract's BOOTROOT_PROJECT_DIR, BOOTROOT_BIN and ARTIFACT_DIR, the +# derived INSTANCE and RUN_TOKEN, and RUN_LOG — the scenario log every command +# below appends its output to. + +# The instance-name budget `infra install` validates `--instance-name` +# against: `MAX_INSTANCE_NAME_LEN` in src/commands/compose_project.rs, the +# DNS-label limit less the longest container-name suffix. The binary rejects a +# longer name outright rather than normalising it, so a name derived past this +# fails the install minutes into a run. `scripts/validate-e2e-run-scope.sh` +# holds this literal to the value the binary derives. +REGISTRAR_DOCKER_MAX_INSTANCE_NAME_LEN=39 + +# Derives this run's instance name from a scenario prefix and the run token. +# +# The tail is what survives when a token does not fit: a suite token ends in +# the launcher PID, which is the part that differs between concurrent runs of +# the same scenario. A token already inside the budget is kept whole — Bash's +# negative substring offset yields the empty string for a token shorter than +# the offset, so the two cases cannot be one expression. The complete token +# still scopes artifact paths and image tags, neither of which is bounded. +registrar_docker_instance_name() { + local prefix="$1" token="$2" budget + budget=$((REGISTRAR_DOCKER_MAX_INSTANCE_NAME_LEN - ${#prefix})) + [ "$budget" -ge 1 ] || + fail "the instance-name prefix '${prefix}' leaves no room for a run token within ${REGISTRAR_DOCKER_MAX_INSTANCE_NAME_LEN} characters" + [ -n "$token" ] || fail "a registrar scenario instance name needs a non-empty run token" + [ "${#token}" -le "$budget" ] || token="${token:$((${#token} - budget))}" + printf '%s%s\n' "$prefix" "$token" +} + +# `docker compose` and `bootroot` for this run's deployment. Both resolve the +# compose file copied into the run root and the run-scoped instance, so no +# caller spells either out. +registrar_docker_compose() { + BOOTROOT_INSTANCE="$INSTANCE" docker compose -p "$INSTANCE" \ + -f "$WORK_DIR/docker-compose.deploy.yml" "$@" +} + +registrar_docker_bootroot() { + (cd "$WORK_DIR" && "$BOOTROOT_BIN" "$@") +} + +registrar_docker_sha256_file() { + if command -v sha256sum >/dev/null; then + sha256sum "$1" | awk '{print $1}' + else + shasum -a 256 "$1" | awk '{print $1}' + fi +} + +# Creates the run root, every path the two scenarios share, and the +# scenario-local audit tmpfs. `slug` scopes the temporary directory's name. +# +# The audit store is a run-local tmpfs rather than a directory on the host +# filesystem: it bounds what the daemon can write without a quota on the host, +# and it takes the run's audit records with it when the run ends. Its size is +# fixed here rather than per scenario because the red-team arm fills it to its +# low-water and exhausted thresholds, and those thresholds are chosen against +# this figure. +# +# Leaves set: RUN_ROOT, WORK_DIR, AUDIT_DIR, RECORD_DIR, SURFACE_DIR, +# SOCKET_DIR, SOCKET_PATH, CONTROL_FIFO, DAEMON_CONFIG, PROVISIONING, +# INITIAL_CONFIG, SUMMARY, TOKEN_FILE, TOKEN_CURL, and — once the mount +# succeeds — AUDIT_TMPFS_MOUNTED, the flag the caller's own cleanup unmounts +# on. A scenario adds whatever further run-root paths it needs after this +# returns; it does not re-derive these. +# +# shellcheck disable=SC2034 # every name below is the caller's, by contract. +registrar_docker_prepare_run_root() { + local slug="$1" + RUN_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/bootroot-registrar-${slug}-XXXXXX")" + WORK_DIR="$RUN_ROOT/bootroot" + AUDIT_DIR="$RUN_ROOT/audit" + RECORD_DIR="$AUDIT_DIR/records" + SURFACE_DIR="$RUN_ROOT/surface" + SOCKET_DIR="$RUN_ROOT/socket" + SOCKET_PATH="$SOCKET_DIR/registrar.sock" + CONTROL_FIFO="$RUN_ROOT/agent-control" + DAEMON_CONFIG="$RUN_ROOT/registrar-agent.toml" + PROVISIONING="$RUN_ROOT/provisioning.toml" + INITIAL_CONFIG="$WORK_DIR/operator-agent.toml" + SUMMARY="$RUN_ROOT/init-summary.json" + TOKEN_FILE="$RUN_ROOT/openbao-root-token" + TOKEN_CURL="$RUN_ROOT/openbao-curl.conf" + mkdir -p "$AUDIT_DIR" "$SURFACE_DIR" "$SOCKET_DIR" || + fail "could not create the scenario run root" + registrar_docker_prepare_deployment_tree "$BOOTROOT_PROJECT_DIR" "$WORK_DIR" + chmod 0755 "$RUN_ROOT" + sudo -n chown 0:0 "$AUDIT_DIR" "$SOCKET_DIR" + sudo -n chmod 0700 "$AUDIT_DIR" + sudo -n chmod 0755 "$SOCKET_DIR" + sudo -n mount -t tmpfs -o size=16m,mode=0700 tmpfs "$AUDIT_DIR" || + fail "could not mount the scenario-local audit tmpfs" + AUDIT_TMPFS_MOUNTED=1 +} + +# Allocates the four host ports this run's deployment publishes. +# +# Leaves set: PORT_POSTGRES, PORT_OPENBAO, PORT_STEPCA, PORT_HTTP01, and +# OPENBAO_URL — the TLS URL OpenBao answers on once `init` has reowned it. +registrar_docker_allocate_ports() { + local name + for name in POSTGRES OPENBAO STEPCA HTTP01; do + pick_free_port + printf -v "PORT_${name}" '%s' "$PICKED_PORT" + done + OPENBAO_URL="https://localhost:${PORT_OPENBAO}" +} + +# Writes the two configuration files `init` is given: the fingerprinted +# provisioning config, and the operator agent config naming this run's audit +# store. The caller owns only the component body — the one part of these that +# the two scenarios genuinely differ on — and hands it over as a file, which +# this consumes. +registrar_docker_write_configs() { + local body="$1" + printf 'fingerprint = "%s"\n' "$(registrar_docker_sha256_file "$body")" >"$PROVISIONING" + cat "$body" >>"$PROVISIONING" + rm -f "$body" + cat >"$INITIAL_CONFIG" <>"$RUN_LOG" 2>&1 || + fail "could not pre-pull third-party deployment images" +} + +# Records the explicit empty agent EAB that `init --no-eab` leaves absent. +# +# The registrar's production reader distinguishes an explicitly cleared EAB +# payload from a missing KV entry, so the isolated deployment needs the former +# written before the daemon starts. The exchange is kept as artifacts because +# anything other than a 200 here surfaces much later, as a daemon that cannot +# read a credential rather than as a setup step that did not happen. +registrar_docker_record_empty_agent_eab() { + local status + status="$(sudo -n curl -sS --cacert "$OPENBAO_CA" --header @"$TOKEN_CURL" -X POST \ + --data '{"data":{"kid":"","hmac":""}}' \ + --dump-header "$ARTIFACT_DIR/empty-eab-headers.txt" \ + --output "$ARTIFACT_DIR/empty-eab-response.json" \ + --write-out '%{http_code}' \ + "$OPENBAO_URL/v1/secret/data/bootroot/agent/eab")" || status="curl-failed" + printf '%s\n' "$status" >"$ARTIFACT_DIR/empty-eab-status.txt" + if [ "$status" != "200" ]; then + cat "$ARTIFACT_DIR/empty-eab-status.txt" "$ARTIFACT_DIR/empty-eab-headers.txt" \ + "$ARTIFACT_DIR/empty-eab-response.json" >>"$RUN_LOG" 2>/dev/null || true + fail "could not record the explicit empty agent EAB" + fi +} + +# Builds the responder image, installs the deployment on the allocated ports, +# and runs `init` to completion under `sudo`. +# +# `slug` is the scenario's own name and the only value that differs between +# the two runs of this: it scopes the responder image tag, both init secrets, +# and the endpoint host label the state predicate is seeded with. +# +# `init` has already recreated the responder with its rendered HMAC and +# started the OpenBao agents by the time this returns. Replaying `infra up` +# afterwards races that rendered configuration with the base image and leaves +# the registrar's pre-issued HMAC unable to authenticate to the responder. +# +# Leaves set: HTTP01_IMAGE and the exported BOOTROOT_HTTP01_IMAGE, +# HTTP01_IMAGE_BUILT once the build succeeds — the flag the caller's cleanup +# removes the image on — the contents of TOKEN_FILE and TOKEN_CURL, and +# OPENBAO_CA. +# +# shellcheck disable=SC2034 # HTTP01_IMAGE_BUILT is the caller's cleanup flag. +# shellcheck disable=SC2024 # the invoking user owns the run root the raw init +# log is captured into; only the command being run needs to be root. +registrar_docker_build_and_initialize() { + local slug="$1" init_raw_log="$RUN_ROOT/init.raw.log" + HTTP01_IMAGE="bootroot-http01-responder:registrar-${slug}-${RUN_TOKEN}" + export BOOTROOT_HTTP01_IMAGE="$HTTP01_IMAGE" + docker build -t "$HTTP01_IMAGE" -f "$BOOTROOT_PROJECT_DIR/docker/http01-responder/Dockerfile" \ + "$BOOTROOT_PROJECT_DIR" >>"$RUN_LOG" 2>&1 || fail "could not build responder image" + HTTP01_IMAGE_BUILT=1 + registrar_docker_prepull_third_party_images + registrar_docker_bootroot infra install --compose-file "$WORK_DIR/docker-compose.deploy.yml" \ + --instance-name "$INSTANCE" --postgres-host-port "$PORT_POSTGRES" \ + --openbao-host-port "$PORT_OPENBAO" --stepca-host-port "$PORT_STEPCA" \ + --http01-admin-host-port "$PORT_HTTP01" --no-build >>"$RUN_LOG" 2>&1 || + fail "infra install failed" + for _ in $(seq 1 60); do + curl -fsS "http://localhost:${PORT_OPENBAO}/v1/sys/seal-status" >/dev/null 2>&1 && break + sleep 1 + done + curl -fsS "http://localhost:${PORT_OPENBAO}/v1/sys/seal-status" >/dev/null 2>&1 || + fail "OpenBao did not become reachable" + # A fresh `infra install` deliberately creates no state inventory. Seed + # the one endpoint predicate `init` must preserve while it writes the + # complete state record after provisioning. + jq -n --arg url "http://localhost:${PORT_OPENBAO}" --arg host "$slug" \ + '{openbao_url: $url, kv_mount: "secret", registrar_endpoint: {enabled: true, domain: "trusted.domain", host: $host}}' \ + >"$WORK_DIR/state.json" || fail "could not seed endpoint predicate" + if ! sudo -n env HOME="$HOME" BOOTROOT_HTTP01_IMAGE="$HTTP01_IMAGE" bash -c 'cd "$1" && exec "$2" init --compose-file "$3" --secrets-dir "$4" --enable auto-generate,show-secrets,db-provision --stepca-password "$5" --http-hmac "$6" --no-eab --save-unseal-keys --overwrite-password --overwrite-ca-json --overwrite-state --confirm-db-provision --db-user step --db-name stepca --responder-url "$7" --agent-config "$8" --summary-json "$9"' _ "$WORK_DIR" "$BOOTROOT_BIN" "$WORK_DIR/docker-compose.deploy.yml" "$WORK_DIR/secrets" "${slug}-${RUN_TOKEN}" "${slug}-hmac-${RUN_TOKEN}" "http://127.0.0.1:${PORT_HTTP01}" "$INITIAL_CONFIG" "$SUMMARY" "$init_raw_log" 2>&1; then + sed 's/^\(root token: \).*/\1/' "$init_raw_log" >"$ARTIFACT_DIR/init.log" || true + fail "bootroot init failed" + fi + sed 's/^\(root token: \).*/\1/' "$init_raw_log" >"$ARTIFACT_DIR/init.log" + sudo -n jq -r '.root_token // empty' "$SUMMARY" | sudo -n sh -c 'umask 077; cat >"$1"' _ "$TOKEN_FILE" + sudo -n test -s "$TOKEN_FILE" || fail "init did not write a root token" + # A header file keeps the init root token out of the process arguments. + # `curl --header @file` consumes the literal HTTP field line, unlike a + # curl config file where an extra escape would change the field name. + sudo -n sh -c 'printf "%s: %s\n" "X-Vault-Token" "$(cat "$1")" >"$2"; chmod 600 "$2"' _ "$TOKEN_FILE" "$TOKEN_CURL" + OPENBAO_CA="$RUN_ROOT/openbao-ca.pem" + sudo -n sh -c 'cat "$1" "$2" >"$3"; chmod 644 "$3"' _ "$WORK_DIR/secrets/certs/root_ca.crt" "$WORK_DIR/secrets/certs/intermediate_ca.crt" "$OPENBAO_CA" + registrar_docker_record_empty_agent_eab +} + +# Reads the KV mount `init` recorded and the two production KV paths both +# scenarios name. A scenario needing further path constants reads them with +# `registrar_docker_rust_string_constant` itself. +# +# Leaves set: KV_MOUNT, RESPONDER_HMAC_PATH, AGENT_EAB_PATH. +# +# shellcheck disable=SC2034 # all three are read by the calling scenario. +registrar_docker_load_openbao_paths() { + local init_constants="$BOOTROOT_PROJECT_DIR/src/commands/init/constants.rs" + KV_MOUNT="$(jq -er '.kv_mount' "$WORK_DIR/state.json")" || fail "init did not record the KV mount" + RESPONDER_HMAC_PATH="$(registrar_docker_rust_string_constant "$init_constants" PATH_RESPONDER_HMAC)" + AGENT_EAB_PATH="$(registrar_docker_rust_string_constant "$init_constants" PATH_AGENT_EAB)" +} + +# Gives the responder container both registrar hostnames as network aliases, +# and proves step-ca resolves and reaches each one. +# +# The endpoint's HTTP-01 challenge is answered by the responder under the +# registrar's own name, so a missing alias surfaces as an issuance that never +# completes rather than as a name that does not resolve. The override is +# written into the artifact directory so a failed run keeps it. +registrar_docker_apply_endpoint_dns_alias() { + local client_alias="$1" endpoint_alias="$2" alias + local override="$ARTIFACT_DIR/docker-compose.registrar-endpoint-alias.yml" + local responder_override="$WORK_DIR/secrets/responder/docker-compose.responder.override.yml" + cat >"$override" <>"$RUN_LOG" 2>&1 || + fail "could not apply the registrar endpoint DNS aliases" + for alias in "$client_alias" "$endpoint_alias"; do + for _ in $(seq 1 15); do + if docker exec "${INSTANCE}-ca" bash -lc "timeout 2 bash -lc 'echo > /dev/tcp/${alias}/80'" >/dev/null 2>&1; then + break + fi + sleep 1 + done + docker exec "${INSTANCE}-ca" bash -lc "timeout 2 bash -lc 'echo > /dev/tcp/${alias}/80'" >/dev/null 2>&1 || + fail "step-ca cannot reach registrar hostname ${alias} through its DNS alias" + done +} + +# Writes the root-owned daemon configuration: the internal agent config `init` +# rendered, followed by this run's registrar and endpoint sections. +# +# `extra_registrar_keys`, when given, is appended inside `[registrar]`. It is +# where a scenario puts a key only it needs — the red-team arm's audit-store +# budget — and it is the only part of this file the two arms differ on. +# +# Leaves set: INTERNAL_DIR, ROOT_CA, and the root-owned RECORD_DIR. +# +# shellcheck disable=SC2034 # ROOT_CA is the anchor each scenario then pins. +registrar_docker_write_daemon_config() { + local extra_registrar_keys="${1:-}" + INTERNAL_DIR="$WORK_DIR/secrets/registrar-internal" + ROOT_CA="$WORK_DIR/secrets/certs/root_ca.crt" + { + cat <"$RUN_ROOT/endpoint.toml" + sudo -n sh -c 'cat "$1" "$2" >"$3"; chmod 600 "$3"; chown 0:0 "$3"' _ "$INTERNAL_DIR/agent.toml" "$RUN_ROOT/endpoint.toml" "$DAEMON_CONFIG" + sudo -n mkdir -p "$RECORD_DIR" + sudo -n chown 0:0 "$RECORD_DIR" + sudo -n chmod 0700 "$RECORD_DIR" +} + +# Writes the supervisor the daemon runs under. +# +# The deployed daemon inherits its listener rather than binding one, so +# something has to own that socket across a restart. This parent binds it, +# makes it root-owned 0700, and hands it over as fd 3 under the `LISTEN_FDS` +# protocol; the control FIFO then drives restart, stop and quit without the +# socket ever being rebound. Both scenarios depend on that inode surviving a +# restart, so there is one supervisor rather than one each. +registrar_docker_write_supervisor() { + cat >"$RUN_ROOT/supervisor.py" <<'PY' +import os, signal, socket, sys +sock_path, control, pid_file, agent_bin, config = sys.argv[1:] +sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM); sock.bind(sock_path); sock.listen(32); os.chown(sock_path, 0, 0); os.chmod(sock_path, 0o700); os.mkfifo(control, 0o600); child = None +def spawn(): + global child + child = os.fork() + if child == 0: + os.dup2(sock.fileno(), 3); os.set_inheritable(3, True); env = os.environ.copy(); env['LISTEN_PID'] = str(os.getpid()); env['LISTEN_FDS'] = '1'; os.execvpe(agent_bin, [agent_bin, '--config', config], env) + open(pid_file, 'w', encoding='ascii').write(str(child)) +def stop(): + global child + if child is not None: + try: os.kill(child, signal.SIGTERM) + except ProcessLookupError: pass + os.waitpid(child, 0); child = None +spawn() +while True: + with open(control, encoding='ascii') as stream: + for line in stream: + if line.strip() == 'restart': stop(); spawn() + elif line.strip() == 'stop': stop() + elif line.strip() == 'quit': stop(); sys.exit(0) +PY +} + +# Sends one word — `restart`, `stop` or `quit` — to the supervisor's FIFO. +registrar_docker_control() { + printf '%s\n' "$1" | sudo -n tee "$CONTROL_FIFO" >/dev/null +} + +# Starts the supervisor as root in the background. +# +# Any arguments are a launch prefix placed before `python3`: the endurance +# arm's `env` and `strace` wrapper, which the red-team arm does not use. The +# supervisor's own output goes to the scenario's agent log, so the daemon's +# startup diagnostics survive a failed run. +# +# Leaves set: SUPERVISOR_PID, the background parent the caller's cleanup stops. +# +# shellcheck disable=SC2024 # the invoking user owns the artifact directory the +# supervisor's output is appended to; only the supervisor itself needs to be +# root. +registrar_docker_start_supervisor() { + registrar_docker_write_supervisor + sudo -n "$@" python3 "$RUN_ROOT/supervisor.py" "$SOCKET_PATH" "$CONTROL_FIFO" \ + "$RUN_ROOT/agent.pid" "$BOOTROOT_AGENT_BIN" "$DAEMON_CONFIG" \ + >>"$ARTIFACT_DIR/agent.log" 2>&1 & + SUPERVISOR_PID=$! +} + +# True when every named surface certificate exists and is non-empty. +# +# Read through `sudo`: the daemon writes this material as root, and a +# readability difference must not be mistaken for material that is not there +# yet. +registrar_docker_surface_material_present() { + local material + for material in "$@"; do + sudo -n test -s "$material" || return 1 + done +} + +# Waits for the socket, the daemon's pid file, and every named piece of +# surface material. The caller names the material because that is where the +# two scenarios differ: the red-team arm needs the client leaf it hands the +# attacker, and the endurance arm needs both leaves it watches across renewal. +registrar_docker_await_surface_material() { + for _ in $(seq 1 90); do + [ -S "$SOCKET_PATH" ] && [ -s "$RUN_ROOT/agent.pid" ] && + registrar_docker_surface_material_present "$@" && break + sleep 1 + done + registrar_docker_surface_material_present "$@" || + fail "daemon did not issue registrar surface material" +} + +# Stops the supervisor and the daemon it owns, and reaps both. +# +# `quit` is the ordinary path: the supervisor terminates its child and exits. +# The signal fallback covers a supervisor that has stopped reading its FIFO — +# the daemon is signalled through the pid file it wrote, because it is root's +# child and not this shell's. +registrar_docker_stop_supervisor() { + [ -n "${SUPERVISOR_PID:-}" ] && kill -0 "$SUPERVISOR_PID" 2>/dev/null || return 0 + registrar_docker_control quit || true + for _ in $(seq 1 15); do + kill -0 "$SUPERVISOR_PID" 2>/dev/null || break + sleep 1 + done + if kill -0 "$SUPERVISOR_PID" 2>/dev/null; then + [ -s "$RUN_ROOT/agent.pid" ] && sudo -n kill -TERM "$(cat "$RUN_ROOT/agent.pid")" 2>/dev/null || true + kill -TERM "$SUPERVISOR_PID" 2>/dev/null || true + fi + wait "$SUPERVISOR_PID" 2>/dev/null || true +} diff --git a/scripts/impl/run-registrar-endurance.sh b/scripts/impl/run-registrar-endurance.sh index 0821b35c..fc3b51a6 100755 --- a/scripts/impl/run-registrar-endurance.sh +++ b/scripts/impl/run-registrar-endurance.sh @@ -49,18 +49,8 @@ ARTIFACT_DIR="$(cd "$ARTIFACT_DIR" && pwd)" RUN_LOG="$ARTIFACT_DIR/run.log" PHASE_LOG="$ARTIFACT_DIR/phases.log" RUN_TOKEN="$(registrar_docker_run_token)" -# `infra install` accepts instance names up to 39 characters. Keep the token -# tail: suite tokens end in the launcher PID, which is the part that differs -# between concurrent runs with the same scenario prefix. A token already -# within the 19-character budget remains whole; Bash's negative substring -# offset otherwise yields an empty string for it. The complete token still -# scopes artifacts and image tags below. -if [ "${#RUN_TOKEN}" -le 19 ]; then - INSTANCE_TOKEN="$RUN_TOKEN" -else - INSTANCE_TOKEN="${RUN_TOKEN: -19}" -fi -INSTANCE="registrar-endurance-${INSTANCE_TOKEN}" +SCENARIO_SLUG=endurance +INSTANCE="$(registrar_docker_instance_name "registrar-${SCENARIO_SLUG}-" "$RUN_TOKEN")" BOOTROOT_AGENT_BIN="$(dirname "$BOOTROOT_BIN")/bootroot-agent" DRIVER="$BOOTROOT_PROJECT_DIR/tests/e2e/registrar/redteam_client.py" ENDPOINT_NAME="001.bootroot-registrar-endpoint.endurance.trusted.domain" @@ -69,13 +59,9 @@ CLIENT_NAME="001.bootroot-registrar.endurance.trusted.domain" log_phase() { CURRENT_PHASE="$1"; printf '{"ts":"%s","phase":"%s"}\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$1" >>"$PHASE_LOG"; printf '[registrar-endurance][%s]\n' "$1" | tee -a "$RUN_LOG"; } pass() { printf '[registrar-endurance][%s] PASS %s\n' "$CURRENT_PHASE" "$1" | tee -a "$RUN_LOG"; } require() { command -v "$1" >/dev/null 2>&1 || fail "$1 is required"; } -digest_file() { if command -v sha256sum >/dev/null; then sha256sum "$1" | awk '{print $1}'; else shasum -a 256 "$1" | awk '{print $1}'; fi; } root_digest_file() { sudo -n sh -c 'if command -v sha256sum >/dev/null; then sha256sum "$1" | awk "{print \$1}"; else shasum -a 256 "$1" | awk "{print \$1}"; fi' _ "$1"; } -certificate_der_digest() { if command -v sha256sum >/dev/null; then openssl x509 -in "$1" -outform DER | sha256sum | awk '{print $1}'; else openssl x509 -in "$1" -outform DER | shasum -a 256 | awk '{print $1}'; fi; } root_certificate_der_digest() { if command -v sha256sum >/dev/null; then sudo -n openssl x509 -in "$1" -outform DER | sha256sum | awk '{print $1}'; else sudo -n openssl x509 -in "$1" -outform DER | shasum -a 256 | awk '{print $1}'; fi; } certificate_not_after_epoch() { sudo -n openssl x509 -in "$1" -noout -enddate | python3 -c 'import datetime, sys; value=sys.stdin.read().strip().split("=", 1)[1]; print(int(datetime.datetime.strptime(value, "%b %d %H:%M:%S %Y %Z").replace(tzinfo=datetime.timezone.utc).timestamp()))'; } -compose() { BOOTROOT_INSTANCE="$INSTANCE" docker compose -p "$INSTANCE" -f "$WORK_DIR/docker-compose.deploy.yml" "$@"; } -bootroot() { (cd "$WORK_DIR" && "$BOOTROOT_BIN" "$@"); } timeout_report() { TIMED_OUT=1 @@ -87,15 +73,7 @@ cleanup() { local cleanup_status=0 log_phase cleanup [ "$TIMED_OUT" -eq 0 ] || printf '{"timeout":"20m","artifacts":"%s"}\n' "$ARTIFACT_DIR" >"$ARTIFACT_DIR/timeout.json" || true - if [ -n "$SUPERVISOR_PID" ] && kill -0 "$SUPERVISOR_PID" 2>/dev/null; then - control quit || true - for _ in $(seq 1 15); do kill -0 "$SUPERVISOR_PID" 2>/dev/null || break; sleep 1; done - if kill -0 "$SUPERVISOR_PID" 2>/dev/null; then - [ -s "$RUN_ROOT/agent.pid" ] && sudo -n kill -TERM "$(cat "$RUN_ROOT/agent.pid")" 2>/dev/null || true - kill -TERM "$SUPERVISOR_PID" 2>/dev/null || true - fi - wait "$SUPERVISOR_PID" 2>/dev/null || true - fi + registrar_docker_stop_supervisor # Keep all teardown failures visible: a test pass is not a clean scenario # if it leaves run-scoped Docker state, a mounted tmpfs, or its responder # image on the host. `teardown_instance` still tries each resource class @@ -139,8 +117,8 @@ cleanup() { teardown_instance() { local ids status=0 if [ -n "$WORK_DIR" ] && [ -f "$WORK_DIR/docker-compose.deploy.yml" ]; then - compose ps >"$ARTIFACT_DIR/compose-ps.log" 2>&1 || true - compose logs --no-color >"$ARTIFACT_DIR/compose-logs.log" 2>&1 || true + registrar_docker_compose ps >"$ARTIFACT_DIR/compose-ps.log" 2>&1 || true + registrar_docker_compose logs --no-color >"$ARTIFACT_DIR/compose-logs.log" 2>&1 || true # Early failures precede `init`, so its generated Compose environment is # absent. These values only satisfy interpolation while `down` resolves # the copied manifest; it never creates or reconfigures a service. @@ -191,17 +169,15 @@ assert_image_removed() { on_timeout() { timeout_report; exit 124; } prepare_workspace() { - RUN_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/bootroot-registrar-endurance-XXXXXX")" - WORK_DIR="$RUN_ROOT/bootroot"; AUDIT_DIR="$RUN_ROOT/audit"; RECORD_DIR="$AUDIT_DIR/records"; SURFACE_DIR="$RUN_ROOT/surface"; SOCKET_DIR="$RUN_ROOT/socket"; SOCKET_PATH="$SOCKET_DIR/registrar.sock"; CONTROL_FIFO="$RUN_ROOT/agent-control"; DAEMON_CONFIG="$RUN_ROOT/registrar-agent.toml"; PROVISIONING="$RUN_ROOT/provisioning.toml"; INITIAL_CONFIG="$WORK_DIR/operator-agent.toml"; SUMMARY="$RUN_ROOT/init-summary.json"; TOKEN_FILE="$RUN_ROOT/openbao-root-token"; TOKEN_CURL="$RUN_ROOT/openbao-curl.conf"; APPROLES_DIR="$RUN_ROOT/approle-control"; EMPTY_PAYLOAD="$RUN_ROOT/empty.json" - mkdir -p "$AUDIT_DIR" "$SURFACE_DIR" "$SOCKET_DIR" "$APPROLES_DIR" - registrar_docker_prepare_deployment_tree "$BOOTROOT_PROJECT_DIR" "$WORK_DIR" - chmod 0755 "$RUN_ROOT"; sudo -n chown 0:0 "$AUDIT_DIR" "$SOCKET_DIR" "$APPROLES_DIR"; sudo -n chmod 0700 "$AUDIT_DIR" "$APPROLES_DIR"; sudo -n chmod 0755 "$SOCKET_DIR" - sudo -n mount -t tmpfs -o size=16m,mode=0700 tmpfs "$AUDIT_DIR" || fail "could not mount the scenario-local audit tmpfs" - AUDIT_TMPFS_MOUNTED=1 + registrar_docker_prepare_run_root "$SCENARIO_SLUG" + # The two root-owned AppRole control credentials the renewal trace watches + # are this scenario's alone, and so is the payload its post-expiry + # unknown-operation exchange sends. + APPROLES_DIR="$RUN_ROOT/approle-control"; EMPTY_PAYLOAD="$RUN_ROOT/empty.json" + mkdir -p "$APPROLES_DIR" + sudo -n chown 0:0 "$APPROLES_DIR"; sudo -n chmod 0700 "$APPROLES_DIR" } -allocate_ports() { for name in POSTGRES OPENBAO STEPCA HTTP01; do pick_free_port; printf -v "PORT_${name}" '%s' "$PICKED_PORT"; done; OPENBAO_URL="https://localhost:${PORT_OPENBAO}"; } - write_configs() { local body="$RUN_ROOT/provisioning.body" cat >"$body" <<'EOF' @@ -213,68 +189,7 @@ multiplicity = "one-per-deployment" cert_group = 3000 reload = { kind = "docker-restart", target = "review" } EOF - printf 'fingerprint = "%s"\n' "$(digest_file "$body")" >"$PROVISIONING"; cat "$body" >>"$PROVISIONING"; rm -f "$body" - cat >"$INITIAL_CONFIG" <>"$RUN_LOG" 2>&1 || fail "could not pre-pull third-party deployment images" -} - -build_and_initialize() { - local init_raw_log="$RUN_ROOT/init.raw.log" - HTTP01_IMAGE="bootroot-http01-responder:registrar-endurance-${RUN_TOKEN}"; export BOOTROOT_HTTP01_IMAGE="$HTTP01_IMAGE" - docker build -t "$HTTP01_IMAGE" -f "$BOOTROOT_PROJECT_DIR/docker/http01-responder/Dockerfile" "$BOOTROOT_PROJECT_DIR" >>"$RUN_LOG" 2>&1 || fail "could not build responder image"; HTTP01_IMAGE_BUILT=1 - prepull_third_party_images - bootroot infra install --compose-file "$WORK_DIR/docker-compose.deploy.yml" --instance-name "$INSTANCE" --postgres-host-port "$PORT_POSTGRES" --openbao-host-port "$PORT_OPENBAO" --stepca-host-port "$PORT_STEPCA" --http01-admin-host-port "$PORT_HTTP01" --no-build >>"$RUN_LOG" 2>&1 || fail "infra install failed" - for _ in $(seq 1 60); do curl -fsS "http://localhost:${PORT_OPENBAO}/v1/sys/seal-status" >/dev/null 2>&1 && break; sleep 1; done - curl -fsS "http://localhost:${PORT_OPENBAO}/v1/sys/seal-status" >/dev/null 2>&1 || fail "OpenBao did not become reachable" - jq -n --arg url "http://localhost:${PORT_OPENBAO}" '{openbao_url: $url, kv_mount: "secret", registrar_endpoint: {enabled: true, domain: "trusted.domain", host: "endurance"}}' >"$WORK_DIR/state.json" - if ! sudo -n env HOME="$HOME" BOOTROOT_HTTP01_IMAGE="$HTTP01_IMAGE" bash -c 'cd "$1" && exec "$2" init --compose-file "$3" --secrets-dir "$4" --enable auto-generate,show-secrets,db-provision --stepca-password "$5" --http-hmac "$6" --no-eab --save-unseal-keys --overwrite-password --overwrite-ca-json --overwrite-state --confirm-db-provision --db-user step --db-name stepca --responder-url "$7" --agent-config "$8" --summary-json "$9"' _ "$WORK_DIR" "$BOOTROOT_BIN" "$WORK_DIR/docker-compose.deploy.yml" "$WORK_DIR/secrets" "endurance-${RUN_TOKEN}" "endurance-hmac-${RUN_TOKEN}" "http://127.0.0.1:${PORT_HTTP01}" "$INITIAL_CONFIG" "$SUMMARY" "$init_raw_log" 2>&1; then - sed 's/^\(root token: \).*/\1/' "$init_raw_log" >"$ARTIFACT_DIR/init.log" || true - fail "bootroot init failed" - fi - sed 's/^\(root token: \).*/\1/' "$init_raw_log" >"$ARTIFACT_DIR/init.log" - sudo -n jq -r '.root_token // empty' "$SUMMARY" | sudo -n sh -c 'umask 077; cat >"$1"' _ "$TOKEN_FILE"; sudo -n test -s "$TOKEN_FILE" || fail "init did not write a root token" - sudo -n sh -c 'printf "%s: %s\n" "X-Vault-Token" "$(cat "$1")" >"$2"; chmod 600 "$2"' _ "$TOKEN_FILE" "$TOKEN_CURL" - OPENBAO_CA="$RUN_ROOT/openbao-ca.pem"; sudo -n sh -c 'cat "$1" "$2" >"$3"; chmod 644 "$3"' _ "$WORK_DIR/secrets/certs/root_ca.crt" "$WORK_DIR/secrets/certs/intermediate_ca.crt" "$OPENBAO_CA" - # `--no-eab` leaves this key absent. The registrar's production reader - # distinguishes an explicit clear EAB payload from a missing KV entry, so - # create the former in the isolated deployment before starting the daemon. - sudo -n curl -fsS --cacert "$OPENBAO_CA" --header @"$TOKEN_CURL" -X POST --data '{"data":{"kid":"","hmac":""}}' "$OPENBAO_URL/v1/secret/data/bootroot/agent/eab" >/dev/null || fail "could not record the explicit empty agent EAB" - pass "initialized an isolated live TLS OpenBao deployment" -} - -load_openbao_paths() { - KV_MOUNT="$(jq -er '.kv_mount' "$WORK_DIR/state.json")" || fail "init did not record the KV mount" - RESPONDER_HMAC_PATH="$(registrar_docker_rust_string_constant "$BOOTROOT_PROJECT_DIR/src/commands/init/constants.rs" PATH_RESPONDER_HMAC)" - AGENT_EAB_PATH="$(registrar_docker_rust_string_constant "$BOOTROOT_PROJECT_DIR/src/commands/init/constants.rs" PATH_AGENT_EAB)" -} - -apply_endpoint_dns_alias() { - local override="$ARTIFACT_DIR/docker-compose.registrar-endpoint-alias.yml" responder_override="$WORK_DIR/secrets/responder/docker-compose.responder.override.yml" - cat >"$override" <>"$RUN_LOG" 2>&1 || fail "could not apply registrar DNS aliases" - for alias in "$CLIENT_NAME" "$ENDPOINT_NAME"; do - for _ in $(seq 1 15); do docker exec "${INSTANCE}-ca" bash -lc "timeout 2 bash -lc 'echo > /dev/tcp/${alias}/80'" >/dev/null 2>&1 && break; sleep 1; done - docker exec "${INSTANCE}-ca" bash -lc "timeout 2 bash -lc 'echo > /dev/tcp/${alias}/80'" >/dev/null 2>&1 || fail "step-ca cannot reach registrar hostname ${alias}" - done + registrar_docker_write_configs "$body" } patch_duration_template() { @@ -296,7 +211,7 @@ PY docker restart "$sidecar" >>"$RUN_LOG" 2>&1 || fail "could not restart run-scoped Step CA OpenBao Agent sidecar ${sidecar}" for _ in $(seq 1 60); do sudo -n jq -e '.authority.provisioners[] | select(.type == "ACME" and .name == "acme") | .claims.defaultTLSCertDuration == "6m"' "$rendered" >/dev/null 2>&1 && break; sleep 1; done sudo -n jq -e '.authority.provisioners[] | select(.type == "ACME" and .name == "acme") | .claims.defaultTLSCertDuration == "6m"' "$rendered" >/dev/null || fail "Step CA sidecar did not render the 6-minute copied template" - compose restart step-ca >>"$RUN_LOG" 2>&1 || fail "could not restart Step CA onto rendered 6-minute configuration" + registrar_docker_compose restart step-ca >>"$RUN_LOG" 2>&1 || fail "could not restart Step CA onto rendered 6-minute configuration" for _ in $(seq 1 60); do curl -kfsS "https://localhost:${PORT_STEPCA}/health" >/dev/null 2>&1 && break; sleep 1; done curl -kfsS "https://localhost:${PORT_STEPCA}/health" >/dev/null || fail "Step CA did not become ready after the template and sidecar sequence" sudo -n cp "$template" "$ARTIFACT_DIR/ca.json.ctmpl"; sudo -n cp "$rendered" "$ARTIFACT_DIR/ca.json" @@ -317,28 +232,6 @@ EOF sudo -n cp "$internal" "$ARTIFACT_DIR/registrar-internal-agent.toml"; sudo -n chown "$(id -u):$(id -g)" "$ARTIFACT_DIR/registrar-internal-agent.toml" } -write_daemon_config() { - INTERNAL_DIR="$WORK_DIR/secrets/registrar-internal"; ROOT_CA="$WORK_DIR/secrets/certs/root_ca.crt" - cat >"$RUN_ROOT/endpoint.toml" <"$3"; chmod 600 "$3"; chown 0:0 "$3"' _ "$INTERNAL_DIR/agent.toml" "$RUN_ROOT/endpoint.toml" "$DAEMON_CONFIG" - sudo -n mkdir -p "$RECORD_DIR"; sudo -n chown 0:0 "$RECORD_DIR"; sudo -n chmod 0700 "$RECORD_DIR" -} - prepare_anchor_pin() { PIN_FILE="$SURFACE_DIR/registrar-endpoint-anchors.sha256" PINNED_ANCHOR_DIGEST="$(root_certificate_der_digest "$ROOT_CA")" @@ -403,44 +296,15 @@ PY pass "shared strace parser reports exactly two watched control opens and ignores unrelated opens" } -write_supervisor() { - cat >"$RUN_ROOT/supervisor.py" <<'PY' -import os, signal, socket, sys -sock_path, control, pid_file, agent_bin, config = sys.argv[1:] -sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM); sock.bind(sock_path); sock.listen(32); os.chown(sock_path, 0, 0); os.chmod(sock_path, 0o700); os.mkfifo(control, 0o600); child = None -def spawn(): - global child - child = os.fork() - if child == 0: - os.dup2(sock.fileno(), 3); os.set_inheritable(3, True); env = os.environ.copy(); env['LISTEN_PID'] = str(os.getpid()); env['LISTEN_FDS'] = '1'; os.execvpe(agent_bin, [agent_bin, '--config', config], env) - open(pid_file, 'w', encoding='ascii').write(str(child)) -def stop(): - global child - if child is not None: - try: os.kill(child, signal.SIGTERM) - except ProcessLookupError: pass - os.waitpid(child, 0); child = None -spawn() -while True: - with open(control, encoding='ascii') as stream: - for line in stream: - if line.strip() == 'restart': stop(); spawn() - elif line.strip() == 'stop': stop() - elif line.strip() == 'quit': stop(); sys.exit(0) -PY -} - -control() { printf '%s\n' "$1" | sudo -n tee "$CONTROL_FIFO" >/dev/null; } - start_daemon_trace() { - write_supervisor # This test-only value is inert in the submitted binary. It makes the # documented temporary AppRole-routing mutation reproducible: that variant # reads exactly the two root-owned control paths that this trace watches. - sudo -n env BOOTROOT_REGISTRAR_ENDURANCE_APPROLE_DIR="$APPROLES_DIR" strace -ff -e trace=open,openat,openat2 -o "$ARTIFACT_DIR/daemon-trace" python3 "$RUN_ROOT/supervisor.py" "$SOCKET_PATH" "$CONTROL_FIFO" "$RUN_ROOT/agent.pid" "$BOOTROOT_AGENT_BIN" "$DAEMON_CONFIG" >>"$ARTIFACT_DIR/agent.log" 2>&1 & - SUPERVISOR_PID=$! - for _ in $(seq 1 90); do [ -S "$SOCKET_PATH" ] && [ -s "$RUN_ROOT/agent.pid" ] && sudo -n test -s "$SURFACE_DIR/registrar-client.crt" && sudo -n test -s "$SURFACE_DIR/registrar-endpoint.crt" && break; sleep 1; done - sudo -n test -s "$SURFACE_DIR/registrar-client.crt" && sudo -n test -s "$SURFACE_DIR/registrar-endpoint.crt" || fail "daemon did not issue registrar surface material" + registrar_docker_start_supervisor \ + env BOOTROOT_REGISTRAR_ENDURANCE_APPROLE_DIR="$APPROLES_DIR" \ + strace -ff -e trace=open,openat,openat2 -o "$ARTIFACT_DIR/daemon-trace" + registrar_docker_await_surface_material \ + "$SURFACE_DIR/registrar-client.crt" "$SURFACE_DIR/registrar-endpoint.crt" } record_original_leaves() { @@ -504,9 +368,9 @@ assert_post_expiry_endpoint() { } assert_daemon_trace() { - control quit || true - for _ in $(seq 1 15); do kill -0 "$SUPERVISOR_PID" 2>/dev/null || break; sleep 1; done - wait "$SUPERVISOR_PID" 2>/dev/null || true + # The trace has to cover the whole renewal window and be flushed before it + # is read, so the daemon is stopped here rather than left to cleanup. + registrar_docker_stop_supervisor parse_watched_opens "$ARTIFACT_DIR/daemon-trace" "$ARTIFACT_DIR/daemon-trace-matches.log" if [ -s "$ARTIFACT_DIR/daemon-trace-matches.log" ]; then cat "$ARTIFACT_DIR/daemon-trace-matches.log" >>"$RUN_LOG" @@ -524,9 +388,17 @@ main() { [ -f "$DRIVER" ] || fail "registrar external client wrapper is missing" log_phase deployment - prepare_workspace; allocate_ports; write_configs; build_and_initialize; load_openbao_paths; apply_endpoint_dns_alias + prepare_workspace + registrar_docker_allocate_ports + write_configs + registrar_docker_build_and_initialize "$SCENARIO_SLUG" + pass "initialized an isolated live TLS OpenBao deployment" + registrar_docker_load_openbao_paths + registrar_docker_apply_endpoint_dns_alias "$CLIENT_NAME" "$ENDPOINT_NAME" log_phase overrides - patch_duration_template; set_internal_cadence; write_daemon_config; prepare_anchor_pin; create_approle_control; assert_control_trace + patch_duration_template; set_internal_cadence + registrar_docker_write_daemon_config + prepare_anchor_pin; create_approle_control; assert_control_trace log_phase renewal-window start_daemon_trace; record_original_leaves; assert_post_expiry_client; assert_post_expiry_endpoint; assert_daemon_trace log_phase "done" diff --git a/scripts/impl/run-registrar-redteam.sh b/scripts/impl/run-registrar-redteam.sh index a3043610..41575e49 100755 --- a/scripts/impl/run-registrar-redteam.sh +++ b/scripts/impl/run-registrar-redteam.sh @@ -35,7 +35,8 @@ ARTIFACT_DIR="$(cd "$ARTIFACT_DIR" && pwd)" RUN_LOG="$ARTIFACT_DIR/run.log" PHASE_LOG="$ARTIFACT_DIR/phases.log" RUN_TOKEN="$(registrar_docker_run_token)" -INSTANCE="registrar-redteam-${RUN_TOKEN}" +SCENARIO_SLUG=redteam +INSTANCE="$(registrar_docker_instance_name "registrar-${SCENARIO_SLUG}-" "$RUN_TOKEN")" MANIFEST="$BOOTROOT_PROJECT_DIR/tests/e2e/registrar/registrar-leak-manifest.txt" POLICIES="$BOOTROOT_PROJECT_DIR/tests/e2e/registrar/privileged-policies.txt" DRIVER="$BOOTROOT_PROJECT_DIR/tests/e2e/registrar/redteam_client.py" @@ -48,10 +49,7 @@ pass() { printf '[registrar-redteam][%s] PASS %s\n' "$CURRENT_PHASE" "$1" | tee require() { command -v "$1" >/dev/null 2>&1 || fail "$1 is required"; } stat_mode() { stat -c '%u:%g:%a' "$1" 2>/dev/null || stat -f '%u:%g:%OLp' "$1"; } root_stat_mode() { sudo -n stat -c '%u:%g:%a' "$1" 2>/dev/null || sudo -n stat -f '%u:%g:%OLp' "$1"; } -digest_file() { if command -v sha256sum >/dev/null; then sha256sum "$1" | awk '{print $1}'; else shasum -a 256 "$1" | awk '{print $1}'; fi; } certificate_der_digest() { if command -v sha256sum >/dev/null; then openssl x509 -in "$1" -outform DER | sha256sum | awk '{print $1}'; else openssl x509 -in "$1" -outform DER | shasum -a 256 | awk '{print $1}'; fi; } -compose() { BOOTROOT_INSTANCE="$INSTANCE" docker compose -p "$INSTANCE" -f "$WORK_DIR/docker-compose.deploy.yml" "$@"; } -bootroot() { (cd "$WORK_DIR" && "$BOOTROOT_BIN" "$@"); } record_wall_clock() { local finished_at finished_epoch elapsed @@ -68,24 +66,13 @@ cleanup() { local status=$? log_phase cleanup record_wall_clock - if [ -n "$SUPERVISOR_PID" ] && kill -0 "$SUPERVISOR_PID" 2>/dev/null; then - control quit || true - for _ in $(seq 1 15); do - kill -0 "$SUPERVISOR_PID" 2>/dev/null || break - sleep 1 - done - if kill -0 "$SUPERVISOR_PID" 2>/dev/null; then - [ -s "$RUN_ROOT/agent.pid" ] && sudo -n kill -TERM "$(cat "$RUN_ROOT/agent.pid")" 2>/dev/null || true - kill -TERM "$SUPERVISOR_PID" 2>/dev/null || true - fi - wait "$SUPERVISOR_PID" 2>/dev/null || true - fi + registrar_docker_stop_supervisor if [ -n "$WORK_DIR" ] && [ -d "$WORK_DIR" ]; then - compose logs --no-color >"$ARTIFACT_DIR/compose-logs.log" 2>&1 || true + registrar_docker_compose logs --no-color >"$ARTIFACT_DIR/compose-logs.log" 2>&1 || true if command -v timeout >/dev/null 2>&1; then timeout --kill-after=10 90 env BOOTROOT_INSTANCE="$INSTANCE" docker compose -p "$INSTANCE" -f "$WORK_DIR/docker-compose.deploy.yml" down --volumes --remove-orphans >>"$RUN_LOG" 2>&1 || true else - compose down --volumes --remove-orphans >>"$RUN_LOG" 2>&1 || true + registrar_docker_compose down --volumes --remove-orphans >>"$RUN_LOG" 2>&1 || true fi fi [ "$HTTP01_IMAGE_BUILT" -eq 1 ] && docker image rm -f "$HTTP01_IMAGE" >>"$RUN_LOG" 2>&1 || true @@ -107,18 +94,13 @@ run_policy_guard() { } prepare_workspace() { - RUN_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/bootroot-registrar-redteam-XXXXXX")" - WORK_DIR="$RUN_ROOT/bootroot"; AUDIT_DIR="$RUN_ROOT/audit"; RECORD_DIR="$AUDIT_DIR/records"; SURFACE_DIR="$RUN_ROOT/surface"; SOCKET_DIR="$RUN_ROOT/socket"; SOCKET_PATH="$SOCKET_DIR/registrar.sock"; CONTROL_FIFO="$RUN_ROOT/agent-control"; DAEMON_CONFIG="$RUN_ROOT/registrar-agent.toml"; PROVISIONING="$RUN_ROOT/provisioning.toml"; INITIAL_CONFIG="$WORK_DIR/operator-agent.toml"; SUMMARY="$RUN_ROOT/init-summary.json"; TOKEN_FILE="$RUN_ROOT/openbao-root-token"; TOKEN_CURL="$RUN_ROOT/openbao-curl.conf" - mkdir -p "$AUDIT_DIR" "$SURFACE_DIR" "$SOCKET_DIR" "$RUN_ROOT/registrar-client-inputs" - registrar_docker_prepare_deployment_tree "$BOOTROOT_PROJECT_DIR" "$WORK_DIR" - chmod 0755 "$RUN_ROOT"; sudo -n chown 0:0 "$AUDIT_DIR" "$SOCKET_DIR"; sudo -n chmod 0700 "$AUDIT_DIR"; sudo -n chmod 0755 "$SOCKET_DIR" - sudo -n mount -t tmpfs -o size=16m,mode=0700 tmpfs "$AUDIT_DIR" || fail "could not mount the scenario-local audit tmpfs" - AUDIT_TMPFS_MOUNTED=1 + registrar_docker_prepare_run_root "$SCENARIO_SLUG" + # The staging area the attacker bundle is copied out of is this + # scenario's alone: the endurance arm models no leak. + mkdir -p "$RUN_ROOT/registrar-client-inputs" pass "created run-scoped root-owned directories and audit tmpfs" } -allocate_ports() { for name in POSTGRES OPENBAO STEPCA HTTP01; do pick_free_port; printf -v "PORT_${name}" '%s' "$PICKED_PORT"; done; OPENBAO_URL="https://localhost:${PORT_OPENBAO}"; } - write_configs() { local body="$RUN_ROOT/provisioning.body" cat >"$body" <<'EOF' @@ -140,158 +122,41 @@ multiplicity = "one-per-deployment" cert_group = 3000 reload = { kind = "docker-restart", target = "review" } EOF - printf 'fingerprint = "%s"\n' "$(digest_file "$body")" >"$PROVISIONING"; cat "$body" >>"$PROVISIONING"; rm -f "$body" - cat >"$INITIAL_CONFIG" <>"$RUN_LOG" 2>&1 || - fail "could not pre-pull third-party deployment images" + registrar_docker_write_configs "$body" } build_and_initialize() { - local init_raw_log="$RUN_ROOT/init.raw.log" - HTTP01_IMAGE="bootroot-http01-responder:registrar-redteam-${RUN_TOKEN}"; export BOOTROOT_HTTP01_IMAGE="$HTTP01_IMAGE" - docker build -t "$HTTP01_IMAGE" -f "$BOOTROOT_PROJECT_DIR/docker/http01-responder/Dockerfile" "$BOOTROOT_PROJECT_DIR" >>"$RUN_LOG" 2>&1 || fail "could not build responder image"; HTTP01_IMAGE_BUILT=1 - prepull_third_party_images - bootroot infra install --compose-file "$WORK_DIR/docker-compose.deploy.yml" --instance-name "$INSTANCE" --postgres-host-port "$PORT_POSTGRES" --openbao-host-port "$PORT_OPENBAO" --stepca-host-port "$PORT_STEPCA" --http01-admin-host-port "$PORT_HTTP01" --no-build >>"$RUN_LOG" 2>&1 || fail "infra install failed" - for _ in $(seq 1 60); do curl -fsS "http://localhost:${PORT_OPENBAO}/v1/sys/seal-status" >/dev/null 2>&1 && break; sleep 1; done - curl -fsS "http://localhost:${PORT_OPENBAO}/v1/sys/seal-status" >/dev/null 2>&1 || fail "OpenBao did not become reachable" - # A fresh `infra install` deliberately creates no state inventory. Seed - # the one endpoint predicate `init` must preserve while it writes the - # complete state record after provisioning. - jq -n --arg url "http://localhost:${PORT_OPENBAO}" '{openbao_url: $url, kv_mount: "secret", registrar_endpoint: {enabled: true, domain: "trusted.domain", host: "redteam"}}' >"$WORK_DIR/state.json" || fail "could not seed endpoint predicate" - if ! sudo -n env HOME="$HOME" BOOTROOT_HTTP01_IMAGE="$HTTP01_IMAGE" bash -c 'cd "$1" && exec "$2" init --compose-file "$3" --secrets-dir "$4" --enable auto-generate,show-secrets,db-provision --stepca-password "$5" --http-hmac "$6" --no-eab --save-unseal-keys --overwrite-password --overwrite-ca-json --overwrite-state --confirm-db-provision --db-user step --db-name stepca --responder-url "$7" --agent-config "$8" --summary-json "$9"' _ "$WORK_DIR" "$BOOTROOT_BIN" "$WORK_DIR/docker-compose.deploy.yml" "$WORK_DIR/secrets" "redteam-${RUN_TOKEN}" "redteam-hmac-${RUN_TOKEN}" "http://127.0.0.1:${PORT_HTTP01}" "$INITIAL_CONFIG" "$SUMMARY" "$init_raw_log" 2>&1; then - sed 's/^\(root token: \).*/\1/' "$init_raw_log" >"$ARTIFACT_DIR/init.log" || true - fail "bootroot init failed" - fi - sed 's/^\(root token: \).*/\1/' "$init_raw_log" >"$ARTIFACT_DIR/init.log" - sudo -n jq -r '.root_token // empty' "$SUMMARY" | sudo -n sh -c 'umask 077; cat >"$1"' _ "$TOKEN_FILE"; sudo -n test -s "$TOKEN_FILE" || fail "init did not write a root token" - # A header file keeps the init root token out of the process arguments. - # `curl --header @file` consumes the literal HTTP field line, unlike a - # curl config file where an extra escape would change the field name. - sudo -n sh -c 'printf "%s: %s\n" "X-Vault-Token" "$(cat "$1")" >"$2"; chmod 600 "$2"' _ "$TOKEN_FILE" "$TOKEN_CURL" - OPENBAO_CA="$RUN_ROOT/openbao-ca.pem" - sudo -n sh -c 'cat "$1" "$2" >"$3"; chmod 644 "$3"' _ "$WORK_DIR/secrets/certs/root_ca.crt" "$WORK_DIR/secrets/certs/intermediate_ca.crt" "$OPENBAO_CA" - EMPTY_EAB_STATUS="$(sudo -n curl -sS --cacert "$OPENBAO_CA" --header @"$TOKEN_CURL" -X POST --data '{"data":{"kid":"","hmac":""}}' --dump-header "$ARTIFACT_DIR/empty-eab-headers.txt" --output "$ARTIFACT_DIR/empty-eab-response.json" --write-out '%{http_code}' "$OPENBAO_URL/v1/secret/data/bootroot/agent/eab")" || EMPTY_EAB_STATUS="curl-failed" - printf '%s\n' "$EMPTY_EAB_STATUS" >"$ARTIFACT_DIR/empty-eab-status.txt" - if [ "$EMPTY_EAB_STATUS" != "200" ]; then - cat "$ARTIFACT_DIR/empty-eab-status.txt" "$ARTIFACT_DIR/empty-eab-headers.txt" "$ARTIFACT_DIR/empty-eab-response.json" >>"$RUN_LOG" 2>/dev/null || true - fail "could not record the explicit empty agent EAB" - fi - # `init` has already recreated the responder with its rendered HMAC and - # started the OpenBao agents. Replaying `infra up` here races that rendered - # configuration with the base image and leaves the registrar's pre-issued - # HMAC unable to authenticate to the responder. + registrar_docker_build_and_initialize "$SCENARIO_SLUG" pass "initialized an isolated live TLS OpenBao deployment" } load_openbao_paths() { - KV_MOUNT="$(jq -er '.kv_mount' "$WORK_DIR/state.json")" || fail "init did not record the KV mount" + registrar_docker_load_openbao_paths + # The three paths only this scenario reaches for: the trust anchor and the + # minted-service material its direct-KV attack must be refused at. CA_TRUST_PATH="$(registrar_docker_rust_string_constant "$BOOTROOT_PROJECT_DIR/src/trust_bootstrap.rs" CA_TRUST_KV_PATH)" SERVICE_KV_BASE="$(registrar_docker_rust_string_constant "$BOOTROOT_PROJECT_DIR/src/trust_bootstrap.rs" SERVICE_KV_BASE)" SERVICE_SECRET_ID_SUFFIX="$(registrar_docker_rust_string_constant "$BOOTROOT_PROJECT_DIR/src/trust_bootstrap.rs" SERVICE_SECRET_ID_KV_SUFFIX)" - RESPONDER_HMAC_PATH="$(registrar_docker_rust_string_constant "$BOOTROOT_PROJECT_DIR/src/commands/init/constants.rs" PATH_RESPONDER_HMAC)" - AGENT_EAB_PATH="$(registrar_docker_rust_string_constant "$BOOTROOT_PROJECT_DIR/src/commands/init/constants.rs" PATH_AGENT_EAB)" pass "loaded the configured KV mount and production path constants" } apply_endpoint_dns_alias() { - local client_alias="001.bootroot-registrar.redteam.trusted.domain" - local endpoint_alias="001.bootroot-registrar-endpoint.redteam.trusted.domain" - local override="$ARTIFACT_DIR/docker-compose.registrar-endpoint-alias.yml" - local responder_override="$WORK_DIR/secrets/responder/docker-compose.responder.override.yml" - cat >"$override" <>"$RUN_LOG" 2>&1 || fail "could not apply the registrar endpoint DNS alias" - for alias in "$client_alias" "$endpoint_alias"; do - for _ in $(seq 1 15); do - if docker exec "${INSTANCE}-ca" bash -lc "timeout 2 bash -lc 'echo > /dev/tcp/${alias}/80'" >/dev/null 2>&1; then - break - fi - sleep 1 - done - docker exec "${INSTANCE}-ca" bash -lc "timeout 2 bash -lc 'echo > /dev/tcp/${alias}/80'" >/dev/null 2>&1 || fail "step-ca cannot reach registrar hostname ${alias} through its DNS alias" - done + registrar_docker_apply_endpoint_dns_alias \ + "001.bootroot-registrar.redteam.trusted.domain" \ + "001.bootroot-registrar-endpoint.redteam.trusted.domain" pass "step-ca can reach both registrar hostnames through DNS aliases" } write_daemon_config() { - INTERNAL_DIR="$WORK_DIR/secrets/registrar-internal"; ROOT_CA="$WORK_DIR/secrets/certs/root_ca.crt" - cat >"$RUN_ROOT/endpoint.toml" <"$3"; chmod 600 "$3"; chown 0:0 "$3"' _ "$INTERNAL_DIR/agent.toml" "$RUN_ROOT/endpoint.toml" "$DAEMON_CONFIG" - sudo -n mkdir -p "$RECORD_DIR"; sudo -n chown 0:0 "$RECORD_DIR"; sudo -n chmod 0700 "$RECORD_DIR" -} - -write_supervisor() { - cat >"$RUN_ROOT/supervisor.py" <<'PY' -import os, signal, socket, sys -sock_path, control, pid_file, agent_bin, config = sys.argv[1:] -sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM); sock.bind(sock_path); sock.listen(32); os.chown(sock_path, 0, 0); os.chmod(sock_path, 0o700); os.mkfifo(control, 0o600); child = None -def spawn(): - global child - child = os.fork() - if child == 0: - os.dup2(sock.fileno(), 3); os.set_inheritable(3, True); env = os.environ.copy(); env['LISTEN_PID'] = str(os.getpid()); env['LISTEN_FDS'] = '1'; os.execvpe(agent_bin, [agent_bin, '--config', config], env) - open(pid_file, 'w', encoding='ascii').write(str(child)) -def stop(): - global child - if child is not None: - try: os.kill(child, signal.SIGTERM) - except ProcessLookupError: pass - os.waitpid(child, 0); child = None -spawn() -while True: - with open(control, encoding='ascii') as stream: - for line in stream: - if line.strip() == 'restart': stop(); spawn() - elif line.strip() == 'stop': stop() - elif line.strip() == 'quit': stop(); sys.exit(0) -PY + # Only this scenario drives the audit store to its low-water and + # exhausted states, so only this scenario bounds the budget. + registrar_docker_write_daemon_config 'audit_store_reserve_bytes = 10485760 +audit_store_low_water_bytes = 8388608' } start_daemon() { - write_supervisor - sudo -n python3 "$RUN_ROOT/supervisor.py" "$SOCKET_PATH" "$CONTROL_FIFO" "$RUN_ROOT/agent.pid" "$BOOTROOT_AGENT_BIN" "$DAEMON_CONFIG" >>"$ARTIFACT_DIR/agent.log" 2>&1 & - SUPERVISOR_PID=$! - for _ in $(seq 1 90); do [ -S "$SOCKET_PATH" ] && [ -s "$RUN_ROOT/agent.pid" ] && [ -s "$SURFACE_DIR/registrar-client.crt" ] && break; sleep 1; done - [ -s "$SURFACE_DIR/registrar-client.crt" ] || fail "daemon did not issue registrar surface material" + registrar_docker_start_supervisor + registrar_docker_await_surface_material "$SURFACE_DIR/registrar-client.crt" printf '%s\n' "$(certificate_der_digest "$ROOT_CA")" >"$SURFACE_DIR/registrar-endpoint-anchors.sha256" } @@ -320,7 +185,6 @@ assert_socket_contract() { # path-occupation checks below prove that an unprivileged caller cannot reach # this socket at all. client() { sudo -n python3 "$DRIVER" --socket "$SOCKET_PATH" --pins "$BUNDLE/registrar-endpoint-anchors.sha256" --ca "$BUNDLE/registrar-endpoint-ca.pem" --cert "$BUNDLE/registrar-client.crt" --key "$BUNDLE/registrar-client.key" --endpoint-name "001.bootroot-registrar-endpoint.redteam.trusted.domain" "$@"; } -control() { printf '%s\n' "$1" | sudo -n tee "$CONTROL_FIFO" >/dev/null; } write_mint() { local service_name="${3:-review}"; jq -n --arg group "$2" --arg service_name "$service_name" '{protocol_version:1,service_name:$service_name,delivery_mode:"RemoteBootstrap",host:"redteam",spec:{component:$service_name,service_name:$service_name,reload:"{ kind = \"docker-restart\", target = \"review\" }",cert_group:$group},wrap_ttl:60,idempotency_key:"redteam-mint"}' >"$1"; } assert_escalation_denied() { @@ -478,14 +342,14 @@ assert_socket_refusals() { printf '{}' >"$payload"; client --operation enumerate --payload "$payload" --expect-unknown-operation || fail "unknown socket operation was not explicitly refused" printf '%064d\n' 0 >"$wrong"; if sudo -n python3 "$DRIVER" --socket "$SOCKET_PATH" --pins "$wrong" --ca "$BUNDLE/registrar-endpoint-ca.pem" --cert "$BUNDLE/registrar-client.crt" --key "$BUNDLE/registrar-client.key" --endpoint-name "001.bootroot-registrar-endpoint.redteam.trusted.domain" --operation mint --payload "$payload"; then fail "client accepted a fingerprint mismatch"; fi if sudo -n python3 "$DRIVER" --socket "$SOCKET_PATH" --pins "$BUNDLE/registrar-endpoint-anchors.sha256" --ca "$BUNDLE/registrar-endpoint-ca.pem" --cert "$BUNDLE/registrar-client.crt" --key "$BUNDLE/registrar-client.key" --endpoint-name "wrong.bootroot-registrar-endpoint.redteam.trusted.domain" --operation mint --payload "$payload"; then fail "client accepted a wrong-name endpoint leaf"; fi - before="$(stat -c '%d:%i' "$SOCKET_PATH" 2>/dev/null || stat -f '%d:%i' "$SOCKET_PATH")"; control restart; sleep 2; after="$(stat -c '%d:%i' "$SOCKET_PATH" 2>/dev/null || stat -f '%d:%i' "$SOCKET_PATH")"; [ "$before" = "$after" ] || fail "daemon restart changed inherited listener inode" - nobody="$(id -un 65534 2>/dev/null || printf nobody)"; control stop; sleep 1 + before="$(stat -c '%d:%i' "$SOCKET_PATH" 2>/dev/null || stat -f '%d:%i' "$SOCKET_PATH")"; registrar_docker_control restart; sleep 2; after="$(stat -c '%d:%i' "$SOCKET_PATH" 2>/dev/null || stat -f '%d:%i' "$SOCKET_PATH")"; [ "$before" = "$after" ] || fail "daemon restart changed inherited listener inode" + nobody="$(id -un 65534 2>/dev/null || printf nobody)"; registrar_docker_control stop; sleep 1 sudo -n cp "$DAEMON_CONFIG" "$RUN_ROOT/registrar-agent.good.toml" # Startup repairs unusable surface material before endpoint activation. An # unset material path instead fails settings validation before any repair or # endpoint work begins, which gives this fixture a deterministic failed start. sudo -n python3 -c 'import pathlib,re,sys; p=pathlib.Path(sys.argv[1]); source=p.read_text(); changed,count=re.subn(r"(?m)^server_key_path[ \t]*=[^\n]*\n?", "", source, count=1); assert count == 1, "missing registrar endpoint server_key_path"; p.write_text(changed)' "$DAEMON_CONFIG" - control restart + registrar_docker_control restart # The supervisor starts the child as root. An unprivileged `kill -0` sees # EPERM for a live child, which is not evidence that the bad configuration # made it exit. A root-owned zombie is also an exited child waiting for the @@ -500,7 +364,7 @@ assert_socket_refusals() { case "$agent_state" in ''|Z*) ;; *) fail "deliberately invalid daemon configuration did not fail startup" ;; esac if sudo -n -u "$nobody" python3 -c 'import socket,sys; s=socket.socket(socket.AF_UNIX); s.connect(sys.argv[1])' "$SOCKET_PATH"; then fail "unprivileged peer connected while daemon stopped"; fi if sudo -n -u "$nobody" python3 -c 'import os,socket,sys; os.unlink(sys.argv[1]); socket.socket(socket.AF_UNIX).bind(sys.argv[1])' "$SOCKET_PATH"; then fail "unprivileged peer occupied stopped socket path"; fi - sudo -n mv "$RUN_ROOT/registrar-agent.good.toml" "$DAEMON_CONFIG"; control restart + sudo -n mv "$RUN_ROOT/registrar-agent.good.toml" "$DAEMON_CONFIG"; registrar_docker_control restart pass "all non-verbs, pin failures, restart behavior, and path occupation attempts are refused" } @@ -513,7 +377,7 @@ main() { sudo -n true >/dev/null 2>&1 || fail "passwordless sudo is required for the root-owned registrar socket scenario" [ -x "$BOOTROOT_AGENT_BIN" ] || fail "bootroot-agent matching BOOTROOT_BIN is not executable"; [ -f "$MANIFEST" ] && [ -f "$DRIVER" ] || fail "red-team support data is missing" assert_policy_fixture; run_policy_guard - log_phase deployment; prepare_workspace; allocate_ports; write_configs; build_and_initialize; load_openbao_paths; apply_endpoint_dns_alias; write_daemon_config; start_daemon; assert_socket_contract; stage_bundle + log_phase deployment; prepare_workspace; registrar_docker_allocate_ports; write_configs; build_and_initialize; load_openbao_paths; apply_endpoint_dns_alias; write_daemon_config; start_daemon; assert_socket_contract; stage_bundle log_phase containment; assert_escalation_denied log_phase functionality; assert_functionality_and_audit log_phase socket; assert_socket_refusals diff --git a/scripts/validate-e2e-run-scope.sh b/scripts/validate-e2e-run-scope.sh index 9015c7b3..02c1aeab 100755 --- a/scripts/validate-e2e-run-scope.sh +++ b/scripts/validate-e2e-run-scope.sh @@ -43,6 +43,13 @@ export BOOTROOT_E2E_HOSTS_LOCK="$WORK_DIR/hosts.lock" # shellcheck source=impl/lib/run-scope.sh . "$IMPL_DIR/lib/run-scope.sh" +# The registrar scenarios derive their identity through their own library +# rather than this one: they install a whole deployment under a run-scoped +# instance but write no marker and take no hosts lock. The one rule they share +# is the length limit, and the checks below hold both to it. +# shellcheck source=impl/lib/registrar-docker.sh +. "$IMPL_DIR/lib/registrar-docker.sh" + # The library aborts through `fail`, which its callers define. Here it # raises a status the checks can catch, so a derivation that is supposed # to be rejected can be asserted on rather than taking this script down. @@ -314,19 +321,55 @@ check_truncation_keeps_the_discriminating_tail() { ok "a truncated identifier keeps its tail, so runs differing only there stay distinct" } -# The registrar endurance scenario has a shorter instance-name budget than -# the lifecycle harnesses. Its suite token is deliberately short, while a -# manual run can supply a long CI token. Both must remain run-scoped: Bash's -# `${token: -N}` expands to an empty string when the token is shorter than N. -check_registrar_endurance_token_budget() { - local script="$IMPL_DIR/run-registrar-endurance.sh" - grep -Fq "if [ \"\${#RUN_TOKEN}\" -le 19 ]; then" "$script" \ - || die "registrar endurance does not preserve short run tokens" - grep -Fq "INSTANCE_TOKEN=\"\$RUN_TOKEN\"" "$script" \ - || die "registrar endurance does not retain its whole short run token" - grep -Fq "INSTANCE_TOKEN=\"\${RUN_TOKEN: -19}\"" "$script" \ - || die "registrar endurance does not retain long run-token tails" - ok "registrar endurance retains short tokens and long-token PID tails" +# The two registrar scenarios, and the slug each derives its instance prefix +# from. Read out of the shipped scripts, so a renamed scenario cannot leave +# this file validating a prefix nothing derives. +REGISTRAR_SCENARIO_SCRIPTS=( + run-registrar-redteam.sh + run-registrar-endurance.sh +) + +registrar_scenario_slug() { + local script="$1" slug + slug="$(sed -n 's/^SCENARIO_SLUG=\(.*\)$/\1/p' "$IMPL_DIR/$script")" + [ -n "$slug" ] || die "${script} declares no SCENARIO_SLUG" + printf '%s' "$slug" +} + +# Both registrar scenarios have a shorter instance-name budget than the +# lifecycle harnesses, because their prefixes are longer, and both derive the +# name through one shared helper. A suite token is deliberately short, while a +# manual run can supply a long CI token. Both must stay run-scoped: Bash's +# `${token: -N}` expands to an empty string when the token is shorter than N, +# and a name derived past the limit is one `infra install` rejects outright. +check_registrar_instance_name_budget() { + local script slug prefix short long first second + [ "$REGISTRAR_DOCKER_MAX_INSTANCE_NAME_LEN" -eq "$BOOTROOT_MAX_INSTANCE_NAME_LEN" ] \ + || die "lib/registrar-docker.sh caps instance names at ${REGISTRAR_DOCKER_MAX_INSTANCE_NAME_LEN}, but the limit the binary derives is ${BOOTROOT_MAX_INSTANCE_NAME_LEN}" + long="$(printf 'a%.0s' $(seq 1 120))" + for script in "${REGISTRAR_SCENARIO_SCRIPTS[@]}"; do + grep -Fq 'registrar_docker_instance_name "registrar-${SCENARIO_SLUG}-" "$RUN_TOKEN"' \ + "$IMPL_DIR/$script" \ + || die "${script} does not derive its instance name through the shared registrar helper" + slug="$(registrar_scenario_slug "$script")" + prefix="registrar-${slug}-" + short="$(registrar_docker_instance_name "$prefix" "e4242")" + [ "$short" = "${prefix}e4242" ] \ + || die "${script} does not retain its whole short run token: '${short}'" + instance_name_is_valid "$short" \ + || die "${script} derives '${short}' from a short run token, which infra install would reject" + first="$(registrar_docker_instance_name "$prefix" "${long}-4242")" + second="$(registrar_docker_instance_name "$prefix" "${long}-4243")" + instance_name_is_valid "$first" \ + || die "${script} derives '${first}' from a long run token, which infra install would reject" + [ "$first" != "$second" ] \ + || die "${script} derives the same instance '${first}' for two tokens differing only in their tail" + case "$first" in + *4242) ;; + *) die "${script} dropped the tail: '${first}' does not end in the pid" ;; + esac + done + ok "both registrar scenarios retain short tokens and long-token PID tails, within the instance-name limit" } # The separation only holds because the binary ranks the exported @@ -1428,7 +1471,7 @@ check_harness_namespaces_are_declared check_no_namespace_can_name_the_default_identity check_project_derivation_rejects_what_compose_would check_truncation_keeps_the_discriminating_tail -check_registrar_endurance_token_budget +check_registrar_instance_name_budget check_derivation_rejects_what_it_cannot_derive check_the_binary_ranks_the_override_above_the_flag check_markers diff --git a/src/acme/flow.rs b/src/acme/flow.rs index df9a1e66..9fe666ed 100644 --- a/src/acme/flow.rs +++ b/src/acme/flow.rs @@ -751,6 +751,40 @@ async fn run_issuance( /// presents. ACME responses commonly stop at an intermediate, so a /// root anchor selected from the configured bundle must travel with a /// registrar-surface certificate too. +/// +/// # Why a renewal needs it +/// +/// The pin file is over trust **anchors**, it carries digests and no +/// certificate material, and it is written once by the provisioning +/// tool at install — see `docs/reference/registrar-client-identity.md` +/// §4. Nothing in this repository rewrites it, and a leaf renewal must +/// not need it rewritten: every issuance generates a fresh key pair, so +/// the pinned root is the one thing about the endpoint that does not +/// change across renewal. A caller can only build a chain to a pinned +/// anchor the server actually presented, so if a renewed leaf were +/// published with the ACME response's chain alone — commonly leaf and +/// intermediate, stopping short of the root — a caller holding an +/// unchanged root-anchor pin would be refused by a correctly renewed +/// endpoint. Appending the configured anchors is what keeps that pin +/// valid across renewal. +/// +/// # What it is gated on +/// +/// `[trust].ca_bundle_path`. With no bundle configured there is no +/// split to make: the caller above hands the whole downloaded PEM over +/// as the leaf, the chain is empty, and this never runs. So this +/// publishes only anchors the deployment already configured as its +/// trust, and never a certificate the ACME response introduced. +/// +/// # Who inherits it +/// +/// The behaviour lives in the shared [`LeafPublication::LeafWithChain`] +/// arm rather than in registrar-specific code, so any future consumer +/// of that variant inherits it. Today there is exactly one: +/// `SURFACE_LEAF_PUBLICATION` in `src/registrar_certs.rs`, which +/// publishes both registrar surface leaves. `LeafPublication::LeafOnly` +/// — every ordinary service issuance — is unaffected, and reaches its +/// consumers' trust through `[trust].ca_bundle_path` as it always has. fn append_configured_anchors(chain: &mut Vec>, bundle_path: &Path) { // A missing, malformed, or unreadable bundle remains on the existing // bootstrap/repair path. `write_merged_ca_bundle` is still the sole