Wire secure runtime RC qualification

This commit is contained in:
rcourtman 2026-08-31 01:50:48 +01:00
parent 92b5f3a9e1
commit 7650fdaba3
18 changed files with 1184 additions and 18 deletions

View file

@ -559,6 +559,65 @@ sed -i "s|^CP_PULSE_IMAGE=.*|CP_PULSE_IMAGE=ghcr.io/rcourtman/pulse:v${VERSION}|
printf '%s\n' "${VERSION}" > "${provider_msp_bundle_dir}/VERSION"
tar -czf "${provider_msp_bundle_asset}" -C "${BUILD_DIR}" "${provider_msp_bundle_root}"
# Import the small GitHub-hosted secure-runtime qualification packet before
# SBOM, checksum, and signature generation. The ordinary release payload stays
# canonical for current binaries: hosted compiler output is accepted only when
# collector-v4, helper, and runner reproduce those exact bytes.
if [[ -n "${PULSE_SECURE_RUNTIME_QUALIFICATION_DIR:-}" ]]; then
qualification_dir="$(cd "${PULSE_SECURE_RUNTIME_QUALIFICATION_DIR}" && pwd)"
qualification_expected=(
pulse-secure-runtime-collector-v1-linux-amd64
pulse-secure-runtime-collector-v2-linux-amd64
pulse-secure-runtime-collector-v3-linux-amd64
pulse-agent-linux-amd64
pulse-agent-helper-linux-amd64
pulse-agent-runner-linux-amd64
secure-runtime-build-contract-v1.json
secure-runtime-compiler-provenance.sigstore.json
secure-runtime-compiler-subjects.sha256
)
mapfile -t qualification_actual < <(find "${qualification_dir}" -mindepth 1 -maxdepth 1 -printf '%f\n' | sort)
mapfile -t qualification_expected_sorted < <(printf '%s\n' "${qualification_expected[@]}" | sort)
if [[ "$(printf '%s\n' "${qualification_actual[@]}")" != "$(printf '%s\n' "${qualification_expected_sorted[@]}")" ]]; then
echo "Error: hosted secure-runtime qualification packet has an unexpected file set." >&2
printf 'Expected:\n%s\nActual:\n%s\n' \
"$(printf '%s\n' "${qualification_expected_sorted[@]}")" \
"$(printf '%s\n' "${qualification_actual[@]}")" >&2
exit 1
fi
for qualification_name in "${qualification_expected[@]}"; do
if [[ ! -f "${qualification_dir}/${qualification_name}" || -L "${qualification_dir}/${qualification_name}" ]]; then
echo "Error: hosted secure-runtime qualification subject is not a regular non-symlink file: ${qualification_name}" >&2
exit 1
fi
done
cmp "${qualification_dir}/pulse-agent-linux-amd64" "${BUILD_DIR}/pulse-agent-linux-amd64" || {
echo "Error: hosted secure-runtime collector-v4 does not reproduce the release collector." >&2
exit 1
}
cmp "${qualification_dir}/pulse-agent-helper-linux-amd64" "${BUILD_DIR}/pulse-agent-helper-linux-amd64" || {
echo "Error: hosted secure-runtime helper does not reproduce the release helper." >&2
exit 1
}
cmp "${qualification_dir}/pulse-agent-runner-linux-amd64" "${BUILD_DIR}/pulse-agent-runner-linux-amd64" || {
echo "Error: hosted secure-runtime runner does not reproduce the release runner." >&2
exit 1
}
for qualification_name in \
pulse-secure-runtime-collector-v1-linux-amd64 \
pulse-secure-runtime-collector-v2-linux-amd64 \
pulse-secure-runtime-collector-v3-linux-amd64 \
secure-runtime-build-contract-v1.json \
secure-runtime-compiler-provenance.sigstore.json; do
install -m 0644 "${qualification_dir}/${qualification_name}" "${RELEASE_DIR}/${qualification_name}"
done
chmod 0755 "${RELEASE_DIR}"/pulse-secure-runtime-collector-v*-linux-amd64
echo "Imported hosted secure-runtime qualification packet."
elif [[ "${PULSE_REQUIRE_SECURE_RUNTIME_QUALIFICATION:-false}" == "true" ]]; then
echo "Error: release requires the hosted secure-runtime qualification packet." >&2
exit 1
fi
pulse_release_generate_packet_sbom "${RELEASE_DIR}" "${RELEASE_PACKET_SBOM}"
mapfile -t checksum_files < <(pulse_release_collect_checksum_files "${RELEASE_DIR}")
pulse_release_write_checksums_and_signatures "${RELEASE_DIR}" "${checksum_files[@]}"

View file

@ -0,0 +1,216 @@
#!/usr/bin/env bash
# Build the exact Linux/amd64 secure-runtime qualification subjects on a
# GitHub-hosted runner. Candidate assembly publishes the three predecessor
# collectors and requires the current collector/helper/runner subjects to be
# byte-identical to the ordinary release payload before signing the packet.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
cd "${REPO_ROOT}"
VERSION="${1:-}"
OUTPUT_DIR="${2:-}"
TARGET_ARCH="${3:-amd64}"
if [[ -z "${VERSION}" || -z "${OUTPUT_DIR}" || -n "${4:-}" ]]; then
echo "Usage: $0 <release-version> <output-directory> [amd64|arm64]" >&2
exit 2
fi
case "${TARGET_ARCH}" in
amd64|arm64) ;;
*)
echo "Error: unsupported secure-runtime qualification architecture: ${TARGET_ARCH}" >&2
exit 2
;;
esac
if [[ "$(tr -d '\n\r[:space:]' < VERSION)" != "${VERSION}" ]]; then
echo "Error: requested version ${VERSION} does not match VERSION." >&2
exit 3
fi
if [[ -z "${PULSE_UPDATE_SIGNING_PUBLIC_KEY:-}" ]]; then
echo "Error: PULSE_UPDATE_SIGNING_PUBLIC_KEY is required." >&2
exit 3
fi
required_go="go1.26.7"
go_version="$(go env GOVERSION 2>/dev/null || true)"
if [[ "${go_version}" != "${required_go}" ]]; then
echo "Error: Go toolchain must be ${required_go} (got ${go_version:-unknown})." >&2
exit 3
fi
OUTPUT_DIR="$(python3 -c 'import os, sys; print(os.path.abspath(sys.argv[1]))' "${OUTPUT_DIR}")"
case "${OUTPUT_DIR}" in
/|"${REPO_ROOT}"|"${REPO_ROOT}/scripts")
echo "Error: refusing unsafe secure-runtime qualification output: ${OUTPUT_DIR}" >&2
exit 2
;;
esac
if [[ -e "${OUTPUT_DIR}" ]] && find "${OUTPUT_DIR}" -mindepth 1 -print -quit | grep -q .; then
echo "Error: secure-runtime qualification output must be absent or empty: ${OUTPUT_DIR}" >&2
exit 2
fi
mkdir -p "${OUTPUT_DIR}"
source_sha="$(git rev-parse HEAD)"
if [[ ! "${source_sha}" =~ ^[0-9a-f]{40}$ ]]; then
echo "Error: secure-runtime qualification requires an exact Git commit." >&2
exit 3
fi
update_public_key="$(printf '%s' "${PULSE_UPDATE_SIGNING_PUBLIC_KEY}" | tr -d '\r\n[:space:]')"
update_key_fingerprint="$(go run ./scripts/release_update_key.go fingerprint --public-key "${update_public_key}")"
release_tag="v${VERSION}"
predecessor_base="${VERSION%%-*}"
collector_v1_version="${predecessor_base}-0.secure.v6.1"
collector_v2_version="${predecessor_base}-0.secure.v6.2"
collector_v3_version="${predecessor_base}-0.secure.v6.3"
collector_v1_ldflags="$(./scripts/release_ldflags.sh agent --version "${collector_v1_version}" --update-public-keys "${update_public_key}")"
collector_v2_ldflags="$(./scripts/release_ldflags.sh agent --version "${collector_v2_version}" --update-public-keys "${update_public_key}")"
collector_v3_ldflags="$(./scripts/release_ldflags.sh agent --version "${collector_v3_version}" --update-public-keys "${update_public_key}")"
release_agent_ldflags="$(./scripts/release_ldflags.sh agent --version "${VERSION}" --update-public-keys "${update_public_key}")"
artifact_asset() {
case "$1" in
collector_v1) printf 'pulse-secure-runtime-collector-v1-linux-%s\n' "${TARGET_ARCH}" ;;
collector_v2) printf 'pulse-secure-runtime-collector-v2-linux-%s\n' "${TARGET_ARCH}" ;;
collector_v3) printf 'pulse-secure-runtime-collector-v3-linux-%s\n' "${TARGET_ARCH}" ;;
collector_v4) printf 'pulse-agent-linux-%s\n' "${TARGET_ARCH}" ;;
helper) printf 'pulse-agent-helper-linux-%s\n' "${TARGET_ARCH}" ;;
runner) printf 'pulse-agent-runner-linux-%s\n' "${TARGET_ARCH}" ;;
esac
}
artifact_package() {
case "$1" in
collector_v1|collector_v2|collector_v3|collector_v4)
printf '%s\n' 'github.com/rcourtman/pulse-go-rewrite/cmd/pulse-agent'
;;
helper) printf '%s\n' 'github.com/rcourtman/pulse-go-rewrite/cmd/pulse-agent-helper' ;;
runner) printf '%s\n' 'github.com/rcourtman/pulse-go-rewrite/cmd/pulse-agent-runner' ;;
esac
}
artifact_version() {
case "$1" in
collector_v1) printf '%s\n' "${collector_v1_version}" ;;
collector_v2) printf '%s\n' "${collector_v2_version}" ;;
collector_v3) printf '%s\n' "${collector_v3_version}" ;;
collector_v4|helper|runner) printf '%s\n' "${VERSION}" ;;
esac
}
artifact_ldflags() {
case "$1" in
collector_v1) printf '%s\n' "${collector_v1_ldflags}" ;;
collector_v2) printf '%s\n' "${collector_v2_ldflags}" ;;
collector_v3) printf '%s\n' "${collector_v3_ldflags}" ;;
collector_v4|helper) printf '%s\n' "${release_agent_ldflags}" ;;
runner) printf '\n' ;;
esac
}
build_subject() {
local name="$1"
local package="./cmd/pulse-agent"
local output="${OUTPUT_DIR}/$(artifact_asset "${name}")"
local -a command=(go build -buildvcs=false -trimpath)
case "${name}" in
helper) package="./cmd/pulse-agent-helper" ;;
runner) package="./cmd/pulse-agent-runner" ;;
esac
local ldflags="$(artifact_ldflags "${name}")"
if [[ -n "${ldflags}" ]]; then
command+=("-ldflags=${ldflags}")
fi
command+=(-o "${output}" "${package}")
env CGO_ENABLED=0 GOOS=linux GOARCH="${TARGET_ARCH}" "${command[@]}"
}
for name in collector_v1 collector_v2 collector_v3 collector_v4 helper runner; do
build_subject "${name}"
done
export SECURE_RUNTIME_OUTPUT_DIR="${OUTPUT_DIR}"
export SECURE_RUNTIME_VERSION="${VERSION}"
export SECURE_RUNTIME_TAG="${release_tag}"
export SECURE_RUNTIME_SOURCE_SHA="${source_sha}"
export SECURE_RUNTIME_TARGET_ARCH="${TARGET_ARCH}"
export SECURE_RUNTIME_GO_VERSION="${go_version}"
export SECURE_RUNTIME_UPDATE_PUBLIC_KEY="${update_public_key}"
export SECURE_RUNTIME_UPDATE_KEY_FINGERPRINT="${update_key_fingerprint}"
for name in collector_v1 collector_v2 collector_v3 collector_v4 helper runner; do
upper_name="$(printf '%s' "${name}" | tr '[:lower:]' '[:upper:]')"
export "SECURE_RUNTIME_${upper_name}_ASSET=$(artifact_asset "${name}")"
export "SECURE_RUNTIME_${upper_name}_PACKAGE=$(artifact_package "${name}")"
export "SECURE_RUNTIME_${upper_name}_VERSION=$(artifact_version "${name}")"
export "SECURE_RUNTIME_${upper_name}_LDFLAGS=$(artifact_ldflags "${name}")"
done
python3 - <<'PY'
import hashlib
import json
import os
from pathlib import Path
root = Path(os.environ["SECURE_RUNTIME_OUTPUT_DIR"])
names = ("collector_v1", "collector_v2", "collector_v3", "collector_v4", "helper", "runner")
artifacts = {}
subject_lines = []
for name in names:
prefix = f"SECURE_RUNTIME_{name.upper()}_"
asset = os.environ[prefix + "ASSET"]
path = root / asset
digest = hashlib.sha256(path.read_bytes()).hexdigest()
ldflags = os.environ[prefix + "LDFLAGS"]
artifacts[name] = {
"release_asset": asset,
"sha256": digest,
"build": {
"tool": "go build",
"package": os.environ[prefix + "PACKAGE"],
"target_os": "linux",
"target_arch": os.environ["SECURE_RUNTIME_TARGET_ARCH"],
"cgo_enabled": 0,
"go_version": os.environ["SECURE_RUNTIME_GO_VERSION"],
"trimpath": True,
"buildvcs": False,
"build_args": ["-buildvcs=false", "-trimpath"],
"ldflags": ldflags,
"ldflags_sha256": hashlib.sha256(ldflags.encode()).hexdigest(),
"version": os.environ[prefix + "VERSION"],
"update_key_fingerprint": os.environ["SECURE_RUNTIME_UPDATE_KEY_FINGERPRINT"],
},
}
subject_lines.append(f"{digest} {asset}")
contract = {
"schema_version": 1,
"repository": "rcourtman/Pulse",
"assembly_signer_workflow": "github.com/rcourtman/Pulse/.github/workflows/build-release-candidate.yml",
"compiler_signer_workflow": "github.com/rcourtman/Pulse/.github/workflows/compile-release-payload.yml",
"compiler_runner_trust": "github-hosted-deny-self-hosted",
"tag": os.environ["SECURE_RUNTIME_TAG"],
"version": os.environ["SECURE_RUNTIME_VERSION"],
"source_sha": os.environ["SECURE_RUNTIME_SOURCE_SHA"],
"update_public_keys": os.environ["SECURE_RUNTIME_UPDATE_PUBLIC_KEY"],
"update_key_fingerprint": os.environ["SECURE_RUNTIME_UPDATE_KEY_FINGERPRINT"],
"artifacts": artifacts,
}
(root / "secure-runtime-build-contract-v1.json").write_text(
json.dumps(contract, indent=2, sort_keys=True) + "\n", encoding="utf-8"
)
contract_digest = hashlib.sha256(
(root / "secure-runtime-build-contract-v1.json").read_bytes()
).hexdigest()
subject_lines.append(f"{contract_digest} secure-runtime-build-contract-v1.json")
(root / "secure-runtime-compiler-subjects.sha256").write_text(
"\n".join(subject_lines) + "\n", encoding="utf-8"
)
PY
echo "Built hosted secure-runtime qualification subjects in ${OUTPUT_DIR}."

View file

@ -2491,6 +2491,160 @@ func TestReleaseUpdateKeyPublicKeySSHAcceptsPublicKey(t *testing.T) {
}
}
func TestReleaseUpdateKeyVerifiesDetachedUpdateSignature(t *testing.T) {
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("generate signing key: %v", err)
}
root := t.TempDir()
artifact := filepath.Join(root, "artifact")
signature := filepath.Join(root, "artifact.sig")
if err := os.WriteFile(artifact, []byte("release bytes"), 0o600); err != nil {
t.Fatalf("write artifact: %v", err)
}
sign := exec.Command("go", "run", "./scripts/release_update_key.go", "sign", "--private-key", base64.StdEncoding.EncodeToString(privateKey), "--file", artifact)
sign.Dir = repoFile()
signatureBytes, err := sign.CombinedOutput()
if err != nil {
t.Fatalf("sign release artifact: %v\n%s", err, signatureBytes)
}
if err := os.WriteFile(signature, signatureBytes, 0o600); err != nil {
t.Fatalf("write detached signature: %v", err)
}
verify := exec.Command("go", "run", "./scripts/release_update_key.go", "verify", "--public-key", base64.StdEncoding.EncodeToString(publicKey), "--file", artifact, "--signature-file", signature)
verify.Dir = repoFile()
if output, err := verify.CombinedOutput(); err != nil {
t.Fatalf("verify release artifact: %v\n%s", err, output)
}
if err := os.WriteFile(artifact, []byte("tampered bytes"), 0o600); err != nil {
t.Fatalf("tamper artifact: %v", err)
}
verify = exec.Command("go", "run", "./scripts/release_update_key.go", "verify", "--public-key", base64.StdEncoding.EncodeToString(publicKey), "--file", artifact, "--signature-file", signature)
verify.Dir = repoFile()
if output, err := verify.CombinedOutput(); err == nil || !strings.Contains(string(output), "signature verification failed") {
t.Fatalf("tampered release artifact passed verification: err=%v output=%s", err, output)
}
}
func TestSecureRuntimeQualificationPacketIsHostedAndReleaseBound(t *testing.T) {
read := func(parts ...string) string {
t.Helper()
content, err := os.ReadFile(repoFile(parts...))
if err != nil {
t.Fatalf("read %s: %v", strings.Join(parts, "/"), err)
}
return string(content)
}
compilerWorkflow := read(".github", "workflows", "compile-release-payload.yml")
hostedJob := workflowJobBlock(t, compilerWorkflow, "compile-secure-runtime-qualification")
for _, required := range []string{
"runs-on: ubuntu-24.04",
"attestations: write",
"id-token: write",
"./scripts/build-secure-runtime-qualification.sh",
"secure-runtime-compiler-subjects.sha256",
"secure-runtime-compiler-provenance.sigstore.json",
} {
if !strings.Contains(hostedJob, required) {
t.Fatalf("hosted secure-runtime compiler job missing %q", required)
}
}
if strings.Contains(hostedJob, "PULSE_UPDATE_SIGNING_KEY") || strings.Contains(hostedJob, "PULSE_LICENSE_PUBLIC_KEY") {
t.Fatal("hosted secure-runtime compiler must not receive private signing or license material")
}
builder := read("scripts", "build-secure-runtime-qualification.sh")
for _, required := range []string{
"go build -buildvcs=false -trimpath",
"collector_v1_version=\"${predecessor_base}-0.secure.v6.1\"",
"collector_v3_version=\"${predecessor_base}-0.secure.v6.3\"",
"compiler_runner_trust\": \"github-hosted-deny-self-hosted\"",
"secure-runtime-build-contract-v1.json",
} {
if !strings.Contains(builder, required) {
t.Fatalf("secure-runtime qualification builder missing %q", required)
}
}
candidateWorkflow := read(".github", "workflows", "build-release-candidate.yml")
for _, required := range []string{
"secure_runtime_artifact_digest",
"Verify hosted secure-runtime compiler packet",
"--deny-self-hosted-runners",
"secure-runtime-compiler-provenance.sigstore.json",
"cmp secure-runtime-qualification/pulse-agent-linux-amd64 release-compiled/payload/binaries/pulse-agent-linux-amd64",
"PULSE_REQUIRE_SECURE_RUNTIME_QUALIFICATION: \"true\"",
} {
if !strings.Contains(candidateWorkflow, required) {
t.Fatalf("candidate workflow missing secure-runtime packet binding %q", required)
}
}
buildRelease := read("scripts", "build-release.sh")
if strings.Index(buildRelease, "Imported hosted secure-runtime qualification packet") > strings.Index(buildRelease, "pulse_release_generate_packet_sbom") {
t.Fatal("secure-runtime packet must be imported before SBOM and checksum generation")
}
for _, required := range []string{
"hosted secure-runtime collector-v4 does not reproduce the release collector",
"secure-runtime-build-contract-v1.json",
"secure-runtime-compiler-provenance.sigstore.json",
} {
if !strings.Contains(buildRelease, required) {
t.Fatalf("build-release.sh missing secure-runtime import guard %q", required)
}
}
assetHelper := read("scripts", "release_asset_common.sh")
for _, required := range []string{
`pulse-agent-runner-linux-*`,
`pulse-secure-runtime-collector-v*-linux-*`,
`secure-runtime-build-contract-v1.json`,
`secure-runtime-compiler-provenance.sigstore.json`,
} {
if !strings.Contains(assetHelper, required) {
t.Fatalf("release checksum inventory missing %q", required)
}
}
publicationWorkflow := read(".github", "workflows", "create-release.yml")
for _, required := range []string{
"release/secure-runtime-build-contract-v1.json",
"release/secure-runtime-compiler-provenance.sigstore.json",
"release/pulse-secure-runtime-collector-v1-linux-amd64",
"release/pulse-secure-runtime-collector-v3-linux-amd64",
"qualify-secure-runtime-release.yml/dispatches",
`{ref: "main", return_run_details: true, inputs: {tag: $tag}}`,
"Secure-runtime qualification dispatch did not return an exact workflow run.",
"Immutable RC publication did not retain an exact secure-runtime qualification run identity.",
} {
if !strings.Contains(publicationWorkflow, required) {
t.Fatalf("release publication missing secure-runtime asset %q", required)
}
}
qualificationWorkflow := read(".github", "workflows", "qualify-secure-runtime-release.yml")
if strings.Contains(qualificationWorkflow, "release:\n types: [published]") {
t.Fatal("secure-runtime qualification must be explicitly dispatched after immutable publication, not rely on suppressed release events")
}
for _, required := range []string{
".immutable == true",
"ca-certificates curl dbus systemd systemd-sysv util-linux",
`for command in curl id nsenter runuser systemctl`,
"PULSE_SECURE_RUNTIME_SYSTEMD_LAB=disposable-v1",
"^TestSecureRuntimeSystemdLab$",
"--release-candidate-tag",
"--collector-v4-signature",
"secure_runtime_attestation_v6.py",
} {
if !strings.Contains(qualificationWorkflow, required) {
t.Fatalf("post-publication secure-runtime qualification missing %q", required)
}
}
}
func TestReleaseAssetCommonRunsUpdateKeyThroughModulePath(t *testing.T) {
if _, err := exec.LookPath("bash"); err != nil {
t.Skip("bash not installed")

View file

@ -254,6 +254,18 @@ pulse_release_collect_checksum_files() {
if compgen -G "pulse-agent-helper-linux-*" > /dev/null; then
checksum_files+=( pulse-agent-helper-linux-* )
fi
if compgen -G "pulse-agent-runner-linux-*" > /dev/null; then
checksum_files+=( pulse-agent-runner-linux-* )
fi
if compgen -G "pulse-secure-runtime-collector-v*-linux-*" > /dev/null; then
checksum_files+=( pulse-secure-runtime-collector-v*-linux-* )
fi
if [[ -f "secure-runtime-build-contract-v1.json" ]]; then
checksum_files+=( secure-runtime-build-contract-v1.json )
fi
if [[ -f "secure-runtime-compiler-provenance.sigstore.json" ]]; then
checksum_files+=( secure-runtime-compiler-provenance.sigstore.json )
fi
if compgen -G "pulse-agent-freebsd-*" > /dev/null; then
checksum_files+=( pulse-agent-freebsd-* )
fi

View file

@ -40,6 +40,7 @@ BUILD_CONTRACT_NAME = "secure-runtime-build-contract-v1.json"
CHECKSUMS_NAME = "checksums.txt"
ASSEMBLY_PROVENANCE_NAME = "release-build-provenance.sigstore.json"
COMPILER_PROVENANCE_NAME = "secure-runtime-compiler-provenance.sigstore.json"
COLLECTOR_SIGNATURE_NAMES = ("collector_v1", "collector_v2", "collector_v3", "collector_v4")
REQUIRED_SCENARIOS = (
"legacy_root_command_capable_install",
@ -376,6 +377,37 @@ def immutable_artifact_snapshot(
)
@contextlib.contextmanager
def immutable_signature_snapshot(
signatures: dict[str, Path], expected_assets: dict[str, str]
) -> Iterator[tuple[dict[str, Path], dict[str, str]]]:
if set(signatures) != set(COLLECTOR_SIGNATURE_NAMES):
raise v5.AttestationError("collector release signature set is incomplete")
with tempfile.TemporaryDirectory(prefix="pulse-secure-runtime-signatures-") as temporary:
snapshot_root = Path(temporary)
snapshot_root.chmod(0o700)
snapshots: dict[str, Path] = {}
digests: dict[str, str] = {}
for name in COLLECTOR_SIGNATURE_NAMES:
expected_filename = expected_assets[name] + ".sig"
source = signatures[name]
if source.name != expected_filename:
raise v5.AttestationError(
f"collector release signature {name} must be named {expected_filename}"
)
snapshot = snapshot_root / expected_filename
digests[name] = copy_immutable_input(
source, snapshot, f"collector release signature {name}"
)
snapshots[name] = snapshot
yield snapshots, digests
for name, path in snapshots.items():
if sha256_file(path) != digests[name]:
raise v5.AttestationError(
f"private collector release signature snapshot {name} changed during verification"
)
def verify_release_build_contract(
*,
path: Path,
@ -516,6 +548,7 @@ def verify_release_candidate_packet(
receipt: dict[str, Any],
artifacts: dict[str, Path],
artifact_hashes: dict[str, str],
collector_signatures: dict[str, Path],
) -> dict[str, Any]:
tag_identity = verify_release_candidate_tag_identity(checkout, qualified_commit, tag, repository)
if not re.fullmatch(r"[1-9][0-9]*", release_id):
@ -595,6 +628,27 @@ def verify_release_candidate_packet(
cwd=checkout,
label=f"hosted compiler provenance verification for {artifact_name}",
)
run_checked(
[
"gh",
"attestation",
"verify",
str(build_contract_snapshot),
"--repo",
repository,
"--signer-workflow",
COMPILER_SIGNER_WORKFLOW,
"--source-digest",
qualified_commit,
"--deny-self-hosted-runners",
"--predicate-type",
"https://slsa.dev/provenance/v1",
"--bundle",
str(compiler_provenance_snapshot),
],
cwd=checkout,
label="hosted compiler provenance verification for the build contract",
)
verify_release_sidecar_snapshot_unchanged(snapshots, snapshot_digests)
checksums = parse_checksums(checksums_snapshot)
build_identity = verify_release_build_contract(
@ -607,6 +661,50 @@ def verify_release_candidate_packet(
artifact_hashes=artifact_hashes,
checksums=checksums,
)
release_assets = {
name: str(build_identity[name]["release_asset"])
for name in COLLECTOR_SIGNATURE_NAMES
}
with immutable_signature_snapshot(collector_signatures, release_assets) as (
signature_snapshots,
signature_digests,
):
update_public_key = load_json_object(
build_contract_snapshot, "secure-runtime build contract"
)["update_public_keys"]
for name in COLLECTOR_SIGNATURE_NAMES:
signature_path = signature_snapshots[name]
run_checked(
[
"gh",
"release",
"verify-asset",
tag,
str(signature_path),
"--repo",
repository,
"--format",
"json",
],
cwd=checkout,
label=f"release attestation verification for {signature_path.name}",
)
run_checked(
[
"go",
"run",
"./scripts/release_update_key.go",
"verify",
"--public-key",
str(update_public_key),
"--file",
str(artifacts[name]),
"--signature-file",
str(signature_path),
],
cwd=checkout,
label=f"production update signature verification for {name}",
)
verify_release_sidecar_snapshot_unchanged(snapshots, snapshot_digests)
return {
**tag_identity,
@ -619,6 +717,14 @@ def verify_release_candidate_packet(
"compiler_signer_workflow": COMPILER_SIGNER_WORKFLOW,
"compiler_runner_trust": "github-hosted-deny-self-hosted",
"build_identity": build_identity,
"collector_signatures": {
name: {
"release_asset": release_assets[name] + ".sig",
"sha256": signature_digests[name],
"verified_with_release_update_key": True,
}
for name in COLLECTOR_SIGNATURE_NAMES
},
"update_key_fingerprint": expected_update_key_fingerprint,
}
@ -641,6 +747,7 @@ def _create_attestation_with_snapshotted_artifacts(
release_compiler_provenance_path: Path | None = None,
release_build_contract_path: Path | None = None,
expected_release_update_key_fingerprint: str | None = None,
collector_signatures: dict[str, Path] | None = None,
) -> dict[str, Any]:
checkout = checkout.resolve()
qualified_commit = v5.resolve_commit(checkout, commit, "qualified commit")
@ -673,6 +780,7 @@ def _create_attestation_with_snapshotted_artifacts(
release_compiler_provenance_path,
release_build_contract_path,
expected_release_update_key_fingerprint,
collector_signatures,
)
release_packet: dict[str, Any] | None = None
if release_candidate_tag is None:
@ -683,7 +791,7 @@ def _create_attestation_with_snapshotted_artifacts(
else:
if any(value is None for value in release_arguments):
raise v5.AttestationError(
"release-candidate classification requires repository, release id, signed checksums, hosted assembly and compiler provenance, build contract, and update-key fingerprint"
"release-candidate classification requires repository, release id, signed checksums, hosted assembly and compiler provenance, build contract, four collector signatures, and update-key fingerprint"
)
release_packet = verify_release_candidate_packet(
checkout=checkout,
@ -699,6 +807,7 @@ def _create_attestation_with_snapshotted_artifacts(
receipt=receipt,
artifacts=artifacts,
artifact_hashes=artifact_hashes,
collector_signatures=dict(collector_signatures),
)
artifact_build_identity = release_packet["build_identity"]
classification = "release-candidate-hosted-compiler-chain-artifact-bound-self-attested-systemd"
@ -777,6 +886,7 @@ def create_attestation(
release_compiler_provenance_path: Path | None = None,
release_build_contract_path: Path | None = None,
expected_release_update_key_fingerprint: str | None = None,
collector_signatures: dict[str, Path] | None = None,
) -> dict[str, Any]:
# Hash, inspect, and externally verify the same private artifact bytes.
# Caller-owned paths can otherwise be swapped between receipt hashing,
@ -799,6 +909,7 @@ def create_attestation(
release_compiler_provenance_path=release_compiler_provenance_path,
release_build_contract_path=release_build_contract_path,
expected_release_update_key_fingerprint=expected_release_update_key_fingerprint,
collector_signatures=collector_signatures,
)
@ -825,6 +936,10 @@ def parse_args(argv: Sequence[str]) -> argparse.Namespace:
parser.add_argument("--release-compiler-provenance", type=Path)
parser.add_argument("--release-build-contract", type=Path)
parser.add_argument("--expected-release-update-key-fingerprint")
parser.add_argument("--collector-v1-signature", type=Path)
parser.add_argument("--collector-v2-signature", type=Path)
parser.add_argument("--collector-v3-signature", type=Path)
parser.add_argument("--collector-v4-signature", type=Path)
parser.add_argument("--output", type=Path, required=True)
return parser.parse_args(argv)
@ -839,6 +954,21 @@ def main(argv: Sequence[str] | None = None) -> int:
"helper": args.helper,
"runner": args.runner,
}
collector_signatures = None
signature_arguments = (
args.collector_v1_signature,
args.collector_v2_signature,
args.collector_v3_signature,
args.collector_v4_signature,
)
if any(value is not None for value in signature_arguments):
if any(value is None for value in signature_arguments):
print(
"secure runtime schema-v6 attestation failed: all four collector signatures are required together",
file=sys.stderr,
)
return 1
collector_signatures = dict(zip(COLLECTOR_SIGNATURE_NAMES, signature_arguments, strict=True))
try:
attestation = create_attestation(
checkout=args.checkout,
@ -857,6 +987,7 @@ def main(argv: Sequence[str] | None = None) -> int:
release_compiler_provenance_path=args.release_compiler_provenance,
release_build_contract_path=args.release_build_contract,
expected_release_update_key_fingerprint=args.expected_release_update_key_fingerprint,
collector_signatures=collector_signatures,
)
args.output.write_text(json.dumps(attestation, indent=2) + "\n", encoding="utf-8")
except v5.AttestationError as exc:

View file

@ -75,6 +75,12 @@ class SecureRuntimeAttestationV6Test(unittest.TestCase):
path = self.root / f"artifact-{name}"
path.write_bytes(name.encode())
self.artifacts[name] = path
contract = self.build_contract()
self.collector_signatures = {}
for name in ("collector_v1", "collector_v2", "collector_v3", "collector_v4"):
path = self.root / f"{contract['artifacts'][name]['release_asset']}.sig"
path.write_text(base64.b64encode(name.encode()).decode() + "\n", encoding="utf-8")
self.collector_signatures[name] = path
def tearDown(self) -> None:
self.temporary.cleanup()
@ -453,17 +459,60 @@ class SecureRuntimeAttestationV6Test(unittest.TestCase):
receipt=self.receipt,
artifacts=self.artifacts,
artifact_hashes=self.artifact_hashes,
collector_signatures=self.collector_signatures,
)
self.assertEqual(packet["assembly_signer_workflow"], ASSEMBLY_SIGNER_WORKFLOW)
self.assertEqual(packet["compiler_signer_workflow"], COMPILER_SIGNER_WORKFLOW)
self.assertEqual(packet["compiler_runner_trust"], "github-hosted-deny-self-hosted")
self.assertTrue(any(Path(call[0]).name == "verify-github-release-integrity.sh" for call in calls))
self.assertEqual(sum(call[:3] == ["gh", "release", "verify-asset"] for call in calls), 4)
self.assertEqual(sum(call[:3] == ["gh", "release", "verify-asset"] for call in calls), 8)
provenance_calls = [call for call in calls if call[:3] == ["gh", "attestation", "verify"]]
self.assertEqual(len(provenance_calls), 1 + len(v5.ARTIFACT_ARGUMENTS))
self.assertEqual(len(provenance_calls), 2 + len(v5.ARTIFACT_ARGUMENTS))
self.assertTrue(all("--deny-self-hosted-runners" in call for call in provenance_calls))
self.assertIn(ASSEMBLY_SIGNER_WORKFLOW, provenance_calls[0])
self.assertTrue(all(COMPILER_SIGNER_WORKFLOW in call for call in provenance_calls[1:]))
signature_calls = [call for call in calls if call[:4] == ["go", "run", "./scripts/release_update_key.go", "verify"]]
self.assertEqual(len(signature_calls), 4)
self.assertTrue(all(self.update_public_keys in call for call in signature_calls))
self.assertEqual(set(packet["collector_signatures"]), {"collector_v1", "collector_v2", "collector_v3", "collector_v4"})
def test_release_packet_rejects_misnamed_collector_signature(self) -> None:
contract_path, checksums_path, _ = self.write_contract_and_checksums()
assembly_provenance_path = self.root / ASSEMBLY_PROVENANCE_NAME
assembly_provenance_path.write_text("{}\n", encoding="utf-8")
compiler_provenance_path = self.root / COMPILER_PROVENANCE_NAME
compiler_provenance_path.write_text("{}\n", encoding="utf-8")
signatures = dict(self.collector_signatures)
signatures["collector_v1"] = self.root / "wrong.sig"
signatures["collector_v1"].write_text("bad\n", encoding="utf-8")
with (
mock.patch(
"secure_runtime_attestation_v6.verify_release_candidate_tag_identity",
return_value={"tag": self.tag},
),
mock.patch(
"secure_runtime_attestation_v6.subprocess.run",
return_value=subprocess.CompletedProcess([], 0, b"{}\n", b""),
),
self.assertRaisesRegex(v5.AttestationError, "must be named"),
):
verify_release_candidate_packet(
checkout=self.root,
qualified_commit=self.commit,
tag=self.tag,
repository=CANONICAL_REPOSITORY,
release_id="12345",
checksums_path=checksums_path,
assembly_provenance_path=assembly_provenance_path,
compiler_provenance_path=compiler_provenance_path,
build_contract_path=contract_path,
expected_update_key_fingerprint=self.fingerprint,
receipt=self.receipt,
artifacts=self.artifacts,
artifact_hashes=self.artifact_hashes,
collector_signatures=signatures,
)
def test_release_packet_rejects_private_snapshot_swap_during_verification(self) -> None:
contract_path, checksums_path, _ = self.write_contract_and_checksums()
@ -501,6 +550,7 @@ class SecureRuntimeAttestationV6Test(unittest.TestCase):
receipt=self.receipt,
artifacts=self.artifacts,
artifact_hashes=self.artifact_hashes,
collector_signatures=self.collector_signatures,
)
def test_cli_does_not_accept_v5_release_candidate_ref_shortcut(self) -> None:

View file

@ -7,6 +7,7 @@
".github/workflows/build-release-candidate.yml",
".github/workflows/compile-release-payload.yml",
".github/workflows/create-release.yml",
".github/workflows/qualify-secure-runtime-release.yml",
"VERSION",
"go.mod",
"go.sum",
@ -29,6 +30,7 @@
"scripts/backfill-release-assets.sh",
"scripts/build-release-binaries.sh",
"scripts/build-release.sh",
"scripts/build-secure-runtime-qualification.sh",
"scripts/check-github-release-immutability.sh",
"scripts/package-helm-chart.sh",
"scripts/prepare-release-container-context.sh",

View file

@ -3,6 +3,7 @@
package main
import (
"bytes"
"crypto/ed25519"
"crypto/sha256"
"crypto/x509"
@ -21,6 +22,7 @@ func usage() {
fmt.Fprintln(os.Stderr, " release_update_key.go openssh-private-key --private-key <base64-ed25519-key-or-seed> [--comment <comment>]")
fmt.Fprintln(os.Stderr, " release_update_key.go fingerprint (--private-key <base64-ed25519-key-or-seed> | --public-key <base64-ed25519-public-key>)")
fmt.Fprintln(os.Stderr, " release_update_key.go sign --private-key <base64-ed25519-key-or-seed> --file <path>")
fmt.Fprintln(os.Stderr, " release_update_key.go verify --public-key <base64-ed25519-public-key> --file <path> --signature-file <path>")
os.Exit(1)
}
@ -147,6 +149,36 @@ func main() {
fail(err)
}
fmt.Println(signature)
case "verify":
verifyCmd := flag.NewFlagSet("verify", flag.ExitOnError)
publicKey := verifyCmd.String("public-key", "", "base64-encoded Ed25519 public key or PKIX public key")
filePath := verifyCmd.String("file", "", "path to the signed file")
signaturePath := verifyCmd.String("signature-file", "", "path to the base64-encoded Ed25519 signature")
_ = verifyCmd.Parse(os.Args[2:])
if *publicKey == "" || *filePath == "" || *signaturePath == "" {
usage()
}
key, err := decodePublicKey(*publicKey)
if err != nil {
fail(err)
}
data, err := os.ReadFile(*filePath)
if err != nil {
fail(fmt.Errorf("read file for verification: %w", err))
}
signatureText, err := os.ReadFile(*signaturePath)
if err != nil {
fail(fmt.Errorf("read signature for verification: %w", err))
}
signature, err := base64.StdEncoding.DecodeString(string(bytes.TrimSpace(signatureText)))
if err != nil {
fail(fmt.Errorf("invalid base64 signature: %w", err))
}
if !ed25519.Verify(key, data, signature) {
fail(fmt.Errorf("signature verification failed"))
}
fmt.Println("signature verified")
default:
usage()
}

View file

@ -466,6 +466,15 @@ required_assets=(
"pulse-agent-v${PULSE_VERSION}-freebsd-amd64.tar.gz"
"pulse-agent-v${PULSE_VERSION}-freebsd-arm64.tar.gz"
)
if [[ "${PULSE_REQUIRE_SECURE_RUNTIME_QUALIFICATION:-false}" == "true" ]]; then
required_assets+=(
"pulse-secure-runtime-collector-v1-linux-amd64"
"pulse-secure-runtime-collector-v2-linux-amd64"
"pulse-secure-runtime-collector-v3-linux-amd64"
"secure-runtime-build-contract-v1.json"
"secure-runtime-compiler-provenance.sigstore.json"
)
fi
missing_count=0
for asset in "${required_assets[@]}"; do