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