959 lines
46 KiB
Python
959 lines
46 KiB
Python
#!/usr/bin/env python3
|
|
"""Fail-closed AF_UNIX builder for server-owned trusted candidate evidence."""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import base64
|
|
import hashlib
|
|
import hmac
|
|
import json
|
|
import os
|
|
import secrets
|
|
import socket
|
|
import stat
|
|
import struct
|
|
import subprocess
|
|
import time
|
|
from datetime import datetime, timezone
|
|
from dataclasses import dataclass, replace
|
|
from pathlib import Path
|
|
from typing import Any, Callable, Mapping, Protocol
|
|
|
|
|
|
class BuilderError(RuntimeError):
|
|
"""A stable, non-sensitive error code returned by the builder boundary."""
|
|
|
|
|
|
def _canonical_json(value: Mapping[str, Any]) -> bytes:
|
|
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode("utf-8")
|
|
|
|
|
|
def _sha256(*parts: str) -> str:
|
|
return hashlib.sha256("\0".join(parts).encode("utf-8")).hexdigest()
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class PeerIdentity:
|
|
pid: int
|
|
uid: int
|
|
gid: int
|
|
|
|
|
|
class DomainPurposeSigner(Protocol):
|
|
"""A signer deliberately limited to the builder's one domain purpose."""
|
|
|
|
@property
|
|
def key_id(self) -> str:
|
|
...
|
|
|
|
@property
|
|
def key_generation(self) -> int:
|
|
...
|
|
|
|
def sign_for_purpose(self, purpose: str, payload: bytes) -> bytes:
|
|
...
|
|
|
|
|
|
class CredentialPathEd25519Signer:
|
|
"""Reads a systemd-mounted credential path; never exports or logs key bytes."""
|
|
|
|
def __init__(self, credential_path: Path, *, key_id: str, key_generation: int, allowed_purpose: str):
|
|
self._credential_path = Path(credential_path)
|
|
self._key_id = str(key_id)
|
|
self._key_generation = key_generation
|
|
self._allowed_purpose = str(allowed_purpose)
|
|
if not self._key_id or not isinstance(key_generation, int) or key_generation < 1:
|
|
raise BuilderError("SIGNING_IDENTITY_INVALID")
|
|
if not self._credential_path.is_file():
|
|
raise BuilderError("SIGNING_CREDENTIAL_UNAVAILABLE")
|
|
try:
|
|
from cryptography.hazmat.primitives.serialization import load_pem_private_key
|
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
|
|
self._private_key = load_pem_private_key(self._credential_path.read_bytes(), password=None)
|
|
if not isinstance(self._private_key, Ed25519PrivateKey):
|
|
raise TypeError("signing key is not Ed25519")
|
|
except Exception as exc: # do not expose parse/key material details
|
|
raise BuilderError("SIGNING_CREDENTIAL_INVALID") from exc
|
|
|
|
@property
|
|
def key_id(self) -> str:
|
|
return self._key_id
|
|
|
|
@property
|
|
def key_generation(self) -> int:
|
|
return self._key_generation
|
|
|
|
def sign_for_purpose(self, purpose: str, payload: bytes) -> bytes:
|
|
if purpose != self._allowed_purpose:
|
|
raise BuilderError("SIGNING_PURPOSE_DENIED")
|
|
return self._private_key.sign(payload)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class BuilderKeyring:
|
|
builder_hmac: bytes
|
|
signing_private_key_path: Path | None = None
|
|
|
|
def __post_init__(self) -> None:
|
|
if len(self.builder_hmac) < 32:
|
|
raise BuilderError("BUILDER_HMAC_INVALID")
|
|
|
|
@classmethod
|
|
def from_credentials_directory(cls, credentials_directory: Path) -> "BuilderKeyring":
|
|
root = Path(credentials_directory)
|
|
hmac_path = root / "builder_hmac"
|
|
signing_path = root / "signing_private_key"
|
|
try:
|
|
secret = hmac_path.read_bytes().strip()
|
|
except OSError as exc:
|
|
raise BuilderError("BUILDER_HMAC_UNAVAILABLE") from exc
|
|
if not signing_path.is_file():
|
|
raise BuilderError("SIGNING_CREDENTIAL_UNAVAILABLE")
|
|
return cls(builder_hmac=secret, signing_private_key_path=signing_path)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class TrustedCandidateBuilderConfig:
|
|
policy_schema: str
|
|
repository: str
|
|
request_schema: str
|
|
response_schema: str
|
|
operation: str
|
|
readiness_operation: str
|
|
maximum_request_age_seconds: int
|
|
request_timeout_seconds: int
|
|
allowed_peer_uid: int
|
|
allowed_peer_cgroups: tuple[str, ...]
|
|
allowed_peer_executable_prefix: str
|
|
allowed_peer_runtime_prefix: str
|
|
allowed_peer_runtime_suffix: str
|
|
required_checks: tuple[str, ...]
|
|
evidence_layout: Mapping[str, str]
|
|
recipes: Mapping[str, Mapping[str, str]]
|
|
governed_recipes: Mapping[str, Mapping[str, str]]
|
|
policy_sha256: str
|
|
source_admission_resolver: Callable[[str, str | None, str], Mapping[str, str]] | None = None
|
|
migration_plan_resolver: Callable[[str, str, str], Any] | None = None
|
|
|
|
@classmethod
|
|
def from_policy(cls, policy: Mapping[str, Any]) -> "TrustedCandidateBuilderConfig":
|
|
required = {
|
|
"schema", "repository", "request_schema", "response_schema", "operation",
|
|
"readiness_operation", "maximum_request_age_seconds", "request_timeout_seconds",
|
|
"allowed_peer_uid", "allowed_peer_cgroups", "allowed_peer_executable_prefix",
|
|
"allowed_peer_runtime_prefix", "allowed_peer_runtime_suffix",
|
|
"required_checks", "evidence_layout", "recipes", "governed_recipes",
|
|
}
|
|
if set(policy) != required or policy.get("schema") != "aios.trusted-candidate-builder-policy.v1":
|
|
raise BuilderError("POLICY_SCHEMA_INVALID")
|
|
recipes = policy.get("recipes")
|
|
if not isinstance(recipes, Mapping) or set(recipes) != {"V2R2C2", "V2R2C3"}:
|
|
raise BuilderError("POLICY_RECIPES_INVALID")
|
|
c2, c3 = recipes["V2R2C2"], recipes["V2R2C3"]
|
|
if not isinstance(c2, Mapping) or set(c2) != {"mode", "task_id", "candidate_id", "base_oid", "source_commit", "version", "purpose"} or c2.get("mode") != "fixed":
|
|
raise BuilderError("POLICY_V2R2C2_INVALID")
|
|
if not isinstance(c3, Mapping) or set(c3) != {"mode", "derives_from", "version", "purpose"} or c3.get("mode") != "governed-derived-only" or c3.get("derives_from") != "V2R2C2":
|
|
raise BuilderError("POLICY_V2R2C3_INVALID")
|
|
if (
|
|
policy["repository"] != "aios/aios"
|
|
or policy["operation"] != "BuildTrustedCandidateV1"
|
|
or policy["readiness_operation"] != "ProbeTrustedCandidateBuilderV1"
|
|
):
|
|
raise BuilderError("POLICY_SCOPE_INVALID")
|
|
if not isinstance(policy["maximum_request_age_seconds"], int) or not 1 <= policy["maximum_request_age_seconds"] <= 300:
|
|
raise BuilderError("POLICY_FRESHNESS_INVALID")
|
|
if not isinstance(policy["request_timeout_seconds"], int) or not 1 <= policy["request_timeout_seconds"] <= 120:
|
|
raise BuilderError("POLICY_TIMEOUT_INVALID")
|
|
if not isinstance(policy["allowed_peer_uid"], int) or policy["allowed_peer_uid"] != 1000:
|
|
raise BuilderError("POLICY_PEER_UID_INVALID")
|
|
cgroups, checks = policy["allowed_peer_cgroups"], policy["required_checks"]
|
|
if not isinstance(cgroups, list) or not cgroups or not all(isinstance(value, str) and value.startswith("/") for value in cgroups):
|
|
raise BuilderError("POLICY_CGROUPS_INVALID")
|
|
for field in (
|
|
"allowed_peer_executable_prefix",
|
|
"allowed_peer_runtime_prefix",
|
|
"allowed_peer_runtime_suffix",
|
|
):
|
|
if not isinstance(policy[field], str) or not policy[field].startswith("/"):
|
|
raise BuilderError("POLICY_PROCESS_IDENTITY_INVALID")
|
|
if not isinstance(checks, list) or not checks or len(set(checks)) != len(checks) or not all(isinstance(value, str) for value in checks):
|
|
raise BuilderError("POLICY_CHECKS_INVALID")
|
|
layout = policy["evidence_layout"]
|
|
evidence_names = {
|
|
"artifact", "manifest", "package_closure", "tests", "canary",
|
|
"gitea", "rollback", "policy", "secret_scan",
|
|
}
|
|
if (
|
|
not isinstance(layout, Mapping)
|
|
or set(layout) != evidence_names
|
|
or not all(
|
|
isinstance(value, str)
|
|
and value.startswith("{version}/")
|
|
and ".." not in Path(value).parts
|
|
and not Path(value).is_absolute()
|
|
for value in layout.values()
|
|
)
|
|
):
|
|
raise BuilderError("POLICY_EVIDENCE_LAYOUT_INVALID")
|
|
return cls(
|
|
policy_schema=str(policy["schema"]), repository=str(policy["repository"]),
|
|
request_schema=str(policy["request_schema"]), response_schema=str(policy["response_schema"]),
|
|
operation=str(policy["operation"]), readiness_operation=str(policy["readiness_operation"]),
|
|
maximum_request_age_seconds=policy["maximum_request_age_seconds"],
|
|
request_timeout_seconds=policy["request_timeout_seconds"],
|
|
allowed_peer_uid=policy["allowed_peer_uid"], allowed_peer_cgroups=tuple(cgroups),
|
|
allowed_peer_executable_prefix=policy["allowed_peer_executable_prefix"],
|
|
allowed_peer_runtime_prefix=policy["allowed_peer_runtime_prefix"],
|
|
allowed_peer_runtime_suffix=policy["allowed_peer_runtime_suffix"],
|
|
required_checks=tuple(checks), evidence_layout=dict(layout),
|
|
recipes={name: dict(value) for name, value in recipes.items()},
|
|
governed_recipes={name: dict(value) for name, value in policy["governed_recipes"].items()} if isinstance(policy["governed_recipes"], Mapping) else _invalid_governed_recipes(),
|
|
policy_sha256=hashlib.sha256(_canonical_json(dict(policy))).hexdigest(),
|
|
)
|
|
|
|
def fixed_recipe(self, name: str) -> Mapping[str, str]:
|
|
recipe = self.recipes.get(name)
|
|
if recipe is None:
|
|
raise BuilderError("RECIPE_UNKNOWN")
|
|
if recipe["mode"] == "governed-derived-only":
|
|
derived = self.governed_recipes.get(name)
|
|
required = {"mode", "task_id", "candidate_id", "base_oid", "source_commit", "version", "purpose", "derived_from_v2r2c2_attestation", "builder_engineering_oid"}
|
|
if not isinstance(derived, Mapping) or set(derived) != required or derived.get("mode") != "governed-derived" or derived.get("version") != name or derived.get("purpose") != recipe.get("purpose"):
|
|
raise BuilderError("GOVERNED_V2R2C3_DERIVATION_REQUIRED")
|
|
for name in ("base_oid", "source_commit", "builder_engineering_oid"):
|
|
value = derived.get(name)
|
|
if not isinstance(value, str) or len(value) != 40 or any(char not in "0123456789abcdef" for char in value.lower()):
|
|
raise BuilderError("GOVERNED_V2R2C3_DERIVATION_REQUIRED")
|
|
return dict(derived)
|
|
return recipe
|
|
|
|
def resolve_recipe(self, version: str, candidate_id: str | None, source_commit: str) -> Mapping[str, str]:
|
|
recipe = self.recipes.get(version)
|
|
if recipe is not None:
|
|
resolved = self.fixed_recipe(version)
|
|
# Fixed/governed-derived policy recipes remain immutable. A
|
|
# source admission can never override one of these identities.
|
|
effective_candidate_id = str(candidate_id or resolved.get("candidate_id") or "")
|
|
if {
|
|
"version": resolved.get("version"),
|
|
"candidate_id": resolved.get("candidate_id"),
|
|
"source_commit": resolved.get("source_commit"),
|
|
} != {
|
|
"version": version,
|
|
"candidate_id": effective_candidate_id,
|
|
"source_commit": source_commit,
|
|
}:
|
|
raise BuilderError("REQUEST_RELEASE_BINDING_INVALID")
|
|
return resolved
|
|
|
|
resolver = self.source_admission_resolver
|
|
if resolver is None:
|
|
raise BuilderError("RECIPE_UNKNOWN")
|
|
try:
|
|
admitted = dict(resolver(version, candidate_id, source_commit))
|
|
except Exception as exc:
|
|
raise BuilderError("SOURCE_ADMISSION_REQUIRED") from exc
|
|
required = {
|
|
"mode",
|
|
"task_id",
|
|
"candidate_id",
|
|
"base_oid",
|
|
"source_commit",
|
|
"version",
|
|
"purpose",
|
|
}
|
|
if (
|
|
set(admitted) != required
|
|
or admitted.get("mode") != "governed-source-admission"
|
|
or admitted.get("version") != version
|
|
or admitted.get("source_commit") != source_commit
|
|
or not isinstance(admitted.get("candidate_id"), str)
|
|
or not admitted.get("candidate_id")
|
|
):
|
|
raise BuilderError("SOURCE_ADMISSION_INVALID")
|
|
effective_candidate_id = str(candidate_id or admitted["candidate_id"])
|
|
if effective_candidate_id != admitted["candidate_id"]:
|
|
raise BuilderError("REQUEST_RELEASE_BINDING_INVALID")
|
|
for name in ("base_oid", "source_commit"):
|
|
value = admitted.get(name)
|
|
if (
|
|
not isinstance(value, str)
|
|
or len(value) != 40
|
|
or any(char not in "0123456789abcdef" for char in value.lower())
|
|
):
|
|
raise BuilderError("SOURCE_ADMISSION_INVALID")
|
|
if admitted.get("purpose") != "AIOS_TRUSTED_RELEASE_BUILDER":
|
|
raise BuilderError("SOURCE_ADMISSION_INVALID")
|
|
return admitted
|
|
|
|
|
|
def _invalid_governed_recipes() -> Mapping[str, Mapping[str, str]]:
|
|
raise BuilderError("POLICY_GOVERNED_RECIPES_INVALID")
|
|
|
|
|
|
def load_builder_policy(path: Path) -> Mapping[str, Any]:
|
|
try:
|
|
decoded = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
except (OSError, json.JSONDecodeError) as exc:
|
|
raise BuilderError("POLICY_UNAVAILABLE") from exc
|
|
if not isinstance(decoded, Mapping):
|
|
raise BuilderError("POLICY_SCHEMA_INVALID")
|
|
TrustedCandidateBuilderConfig.from_policy(decoded)
|
|
return decoded
|
|
|
|
|
|
class ServerOwnedEvidenceSource:
|
|
"""Recomputes a fixed on-host evidence closure without caller-owned paths."""
|
|
|
|
def __init__(
|
|
self,
|
|
config: TrustedCandidateBuilderConfig,
|
|
authority_git_dir: Path,
|
|
evidence_root: Path,
|
|
*,
|
|
expected_owner_uid: int | None = 0,
|
|
git_runner: Callable[..., Any] = subprocess.run,
|
|
):
|
|
self._config = config
|
|
self._authority_git_dir = Path(authority_git_dir)
|
|
self._evidence_root = Path(evidence_root)
|
|
self._expected_owner_uid = expected_owner_uid
|
|
self._git_runner = git_runner
|
|
|
|
def _regular_path(self, relative: str) -> Path:
|
|
root = self._evidence_root.resolve(strict=True)
|
|
candidate = self._evidence_root / relative
|
|
current = self._evidence_root
|
|
for part in Path(relative).parts:
|
|
current = current / part
|
|
if current.is_symlink():
|
|
raise BuilderError("COLLECTOR_SYMLINK_DENIED")
|
|
try:
|
|
resolved = candidate.resolve(strict=True)
|
|
resolved.relative_to(root)
|
|
metadata = resolved.stat(follow_symlinks=False)
|
|
except (OSError, ValueError) as exc:
|
|
raise BuilderError("COLLECTOR_EVIDENCE_UNAVAILABLE") from exc
|
|
if not stat.S_ISREG(metadata.st_mode):
|
|
raise BuilderError("COLLECTOR_SPECIAL_FILE_DENIED")
|
|
if os.name != "nt" and metadata.st_mode & 0o022:
|
|
raise BuilderError("COLLECTOR_EVIDENCE_MODE_DENIED")
|
|
if (
|
|
os.name != "nt"
|
|
and
|
|
self._expected_owner_uid is not None
|
|
and metadata.st_uid != self._expected_owner_uid
|
|
):
|
|
raise BuilderError("COLLECTOR_EVIDENCE_OWNER_DENIED")
|
|
return resolved
|
|
|
|
def assert_ready(self) -> None:
|
|
"""Validate the server-owned evidence facility without requiring recipe evidence.
|
|
|
|
Readiness is an infrastructure/property check. Candidate-specific evidence
|
|
remains mandatory for ``BuildTrustedCandidateV1`` and is validated by
|
|
:meth:`__call__`; an empty evidence root therefore no longer makes the
|
|
builder globally unavailable.
|
|
"""
|
|
|
|
for path, code in (
|
|
(self._authority_git_dir, "COLLECTOR_AUTHORITY_GIT_UNAVAILABLE"),
|
|
(self._evidence_root, "COLLECTOR_EVIDENCE_ROOT_UNAVAILABLE"),
|
|
):
|
|
try:
|
|
metadata = path.stat(follow_symlinks=False)
|
|
except OSError as exc:
|
|
raise BuilderError(code) from exc
|
|
if not stat.S_ISDIR(metadata.st_mode) or path.is_symlink():
|
|
raise BuilderError(code)
|
|
if path == self._evidence_root:
|
|
if os.name != "nt" and metadata.st_mode & 0o022:
|
|
raise BuilderError("COLLECTOR_EVIDENCE_ROOT_MODE_DENIED")
|
|
if (
|
|
os.name != "nt"
|
|
and self._expected_owner_uid is not None
|
|
and metadata.st_uid != self._expected_owner_uid
|
|
):
|
|
raise BuilderError("COLLECTOR_EVIDENCE_ROOT_OWNER_DENIED")
|
|
|
|
def _digest(self, path: Path) -> str:
|
|
digest = hashlib.sha256()
|
|
with path.open("rb") as handle:
|
|
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
digest.update(chunk)
|
|
return digest.hexdigest()
|
|
|
|
def _json(self, path: Path) -> Mapping[str, Any]:
|
|
try:
|
|
if path.stat().st_size > 2 * 1024 * 1024:
|
|
raise BuilderError("COLLECTOR_EVIDENCE_SIZE_INVALID")
|
|
value = json.loads(path.read_text(encoding="utf-8"))
|
|
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
raise BuilderError("COLLECTOR_EVIDENCE_INVALID") from exc
|
|
if not isinstance(value, Mapping):
|
|
raise BuilderError("COLLECTOR_EVIDENCE_INVALID")
|
|
return value
|
|
|
|
def __call__(self, recipe_name: str, recipe: Mapping[str, str]) -> Mapping[str, Any]:
|
|
try:
|
|
tree_result = self._git_runner(
|
|
["git", f"--git-dir={self._authority_git_dir}", "rev-parse", f"{recipe['source_commit']}^{{tree}}"],
|
|
check=True, capture_output=True, text=True, timeout=15,
|
|
env={"PATH": os.environ.get("PATH", "")},
|
|
)
|
|
ancestry_result = self._git_runner(
|
|
["git", f"--git-dir={self._authority_git_dir}", "merge-base", "--is-ancestor", recipe["base_oid"], recipe["source_commit"]],
|
|
check=False, capture_output=True, text=True, timeout=15,
|
|
env={"PATH": os.environ.get("PATH", "")},
|
|
)
|
|
tree_oid = tree_result.stdout.strip().lower()
|
|
except (OSError, subprocess.SubprocessError) as exc:
|
|
raise BuilderError("COLLECTOR_EVIDENCE_UNAVAILABLE") from exc
|
|
if getattr(ancestry_result, "returncode", 1) != 0:
|
|
raise BuilderError("COLLECTOR_GIT_MISMATCH")
|
|
|
|
receipt = self._json(self._regular_path(f"{recipe_name}/receipt.json"))
|
|
if set(receipt) != {"schema", "version", "candidate", "release_plan"}:
|
|
raise BuilderError("COLLECTOR_EVIDENCE_INVALID")
|
|
if (
|
|
receipt.get("schema") != "aios.trusted-candidate-builder.evidence.v1"
|
|
or receipt.get("version") != recipe_name
|
|
):
|
|
raise BuilderError("COLLECTOR_EVIDENCE_INVALID")
|
|
candidate = receipt.get("candidate")
|
|
release_plan = receipt.get("release_plan")
|
|
if not isinstance(candidate, Mapping) or not isinstance(release_plan, Mapping):
|
|
raise BuilderError("COLLECTOR_EVIDENCE_INVALID")
|
|
if (
|
|
candidate.get("candidate_id") != recipe["candidate_id"]
|
|
or candidate.get("source_commit") != recipe["source_commit"]
|
|
or candidate.get("tree_oid") != tree_oid
|
|
):
|
|
raise BuilderError("COLLECTOR_GIT_MISMATCH")
|
|
git_consistency = candidate.get("git_consistency")
|
|
if (
|
|
not isinstance(git_consistency, Mapping)
|
|
or git_consistency.get("consistent") is not True
|
|
or git_consistency.get("source_commit") != recipe["source_commit"]
|
|
or git_consistency.get("tree_oid") != tree_oid
|
|
or git_consistency.get("repository") != self._config.repository
|
|
):
|
|
raise BuilderError("COLLECTOR_GIT_MISMATCH")
|
|
|
|
paths = {
|
|
name: self._regular_path(template.format(version=recipe_name))
|
|
for name, template in self._config.evidence_layout.items()
|
|
}
|
|
deployment = release_plan.get("deployment")
|
|
if not isinstance(deployment, Mapping):
|
|
raise BuilderError("COLLECTOR_EVIDENCE_INVALID")
|
|
digests = {name: self._digest(path) for name, path in paths.items()}
|
|
if (
|
|
digests["artifact"] != candidate.get("artifact_sha256")
|
|
or digests["manifest"] != candidate.get("manifest_sha256")
|
|
or digests["package_closure"] != deployment.get("package_closure_sha256")
|
|
or digests["policy"] != release_plan.get("policy_sha256")
|
|
or digests["policy"] != deployment.get("policy_sha256")
|
|
):
|
|
raise BuilderError("COLLECTOR_DIGEST_MISMATCH")
|
|
|
|
tests = self._json(paths["tests"])
|
|
canary = self._json(paths["canary"])
|
|
gitea = self._json(paths["gitea"])
|
|
rollback = self._json(paths["rollback"])
|
|
secret_scan = self._json(paths["secret_scan"])
|
|
if tests != candidate.get("tests") or tests.get("passed") is not True:
|
|
raise BuilderError("COLLECTOR_TESTS_MISMATCH")
|
|
if canary != candidate.get("canary") or canary.get("passed") is not True:
|
|
raise BuilderError("COLLECTOR_CANARY_MISMATCH")
|
|
if (
|
|
digests["tests"] != hashlib.sha256(_canonical_json(dict(tests))).hexdigest()
|
|
or digests["canary"] != hashlib.sha256(_canonical_json(dict(canary))).hexdigest()
|
|
):
|
|
raise BuilderError("COLLECTOR_EVIDENCE_CANONICALIZATION_MISMATCH")
|
|
if (
|
|
gitea != candidate.get("gitea_consistency")
|
|
or gitea.get("consistent") is not True
|
|
or gitea.get("repository") != self._config.repository
|
|
or gitea.get("source_commit") != recipe["source_commit"]
|
|
or gitea.get("tree_oid") != tree_oid
|
|
or gitea.get("remote_ref_oid") != recipe["source_commit"]
|
|
):
|
|
raise BuilderError("COLLECTOR_GITEA_MISMATCH")
|
|
if (
|
|
candidate.get("rollback_ready") is not True
|
|
or rollback.get("ready") is not True
|
|
or rollback.get("source_commit") != recipe["source_commit"]
|
|
or rollback.get("rollback_sha256") != deployment.get("rollback_sha256")
|
|
):
|
|
raise BuilderError("COLLECTOR_ROLLBACK_MISMATCH")
|
|
if (
|
|
digests["gitea"] != hashlib.sha256(_canonical_json(dict(gitea))).hexdigest()
|
|
or digests["rollback"] != hashlib.sha256(_canonical_json(dict(rollback))).hexdigest()
|
|
):
|
|
raise BuilderError("COLLECTOR_EVIDENCE_CANONICALIZATION_MISMATCH")
|
|
if (
|
|
secret_scan.get("passed") is not True
|
|
or secret_scan.get("artifact_sha256") != candidate.get("artifact_sha256")
|
|
):
|
|
raise BuilderError("COLLECTOR_SECRET_SCAN_MISMATCH")
|
|
checks = {name: True for name in self._config.required_checks}
|
|
return {
|
|
"candidate": dict(candidate),
|
|
"release_plan": dict(release_plan),
|
|
"checks": checks,
|
|
"evidence_digests": digests,
|
|
}
|
|
|
|
|
|
class DeterministicEvidenceCollector:
|
|
"""Validates a server-owned source; request fields never influence collection."""
|
|
|
|
_BUNDLE_FIELDS = {"candidate", "release_plan", "checks", "evidence_digests"}
|
|
|
|
def __init__(self, config: TrustedCandidateBuilderConfig, *, evidence_source: Callable[[str, Mapping[str, str]], Mapping[str, Any]] | None = None):
|
|
self._config = config
|
|
self._evidence_source = evidence_source
|
|
|
|
def assert_ready(self) -> None:
|
|
"""Fail closed on collector configuration, not on recipe evidence presence."""
|
|
|
|
if self._evidence_source is None:
|
|
raise BuilderError("COLLECTOR_UNCONFIGURED")
|
|
checker = getattr(self._evidence_source, "assert_ready", None)
|
|
if checker is not None:
|
|
checker()
|
|
|
|
def collect(self, recipe_name: str, recipe: Mapping[str, str]) -> Mapping[str, Any]:
|
|
self.assert_ready()
|
|
bundle = self._evidence_source(recipe_name, recipe)
|
|
if not isinstance(bundle, Mapping) or set(bundle) != self._BUNDLE_FIELDS:
|
|
raise BuilderError("COLLECTOR_EVIDENCE_INVALID")
|
|
raw_checks = bundle.get("checks")
|
|
if (
|
|
not isinstance(raw_checks, Mapping)
|
|
or set(raw_checks) != set(self._config.required_checks)
|
|
or any(raw_checks.get(name) is not True for name in self._config.required_checks)
|
|
):
|
|
raise BuilderError("COLLECTOR_CHECKS_INCOMPLETE")
|
|
evidence_digests = bundle.get("evidence_digests")
|
|
if (
|
|
not isinstance(evidence_digests, Mapping)
|
|
or set(evidence_digests) != set(self._config.evidence_layout)
|
|
or any(
|
|
not isinstance(value, str)
|
|
or len(value) != 64
|
|
or any(char not in "0123456789abcdef" for char in value.lower())
|
|
for value in evidence_digests.values()
|
|
)
|
|
):
|
|
raise BuilderError("COLLECTOR_EVIDENCE_DIGESTS_INVALID")
|
|
try:
|
|
from silent_release_controller import ReleaseCandidate
|
|
from silent_release_hardening import ReleasePlan, TrustedCandidateBundle
|
|
from release_migration_plan import (
|
|
MigrationPlanError,
|
|
MigrationPlanRequired,
|
|
)
|
|
|
|
candidate = ReleaseCandidate.from_dict(dict(bundle["candidate"]))
|
|
release_plan_payload = dict(bundle["release_plan"])
|
|
migration_resolver = self._config.migration_plan_resolver
|
|
if migration_resolver is not None:
|
|
try:
|
|
migration = migration_resolver(
|
|
recipe_name,
|
|
recipe["candidate_id"],
|
|
recipe["source_commit"],
|
|
)
|
|
except MigrationPlanRequired as exc:
|
|
raise BuilderError("MIGRATION_PLAN_REQUIRED") from exc
|
|
except MigrationPlanError as exc:
|
|
raise BuilderError("MIGRATION_PLAN_INVALID") from exc
|
|
if migration is not None:
|
|
release_plan_payload["migration"] = migration.to_dict()
|
|
release_plan = ReleasePlan.from_dict(release_plan_payload)
|
|
checks = dict(raw_checks)
|
|
TrustedCandidateBundle(candidate, release_plan, checks).validate()
|
|
except BuilderError:
|
|
raise
|
|
except (ImportError, KeyError, TypeError, ValueError, RuntimeError) as exc:
|
|
raise BuilderError("COLLECTOR_EVIDENCE_INVALID") from exc
|
|
if candidate.source_commit != recipe["source_commit"] or candidate.candidate_id != recipe["candidate_id"]:
|
|
raise BuilderError("COLLECTOR_SOURCE_MISMATCH")
|
|
return {
|
|
"candidate": candidate.to_dict(),
|
|
"release_plan": release_plan.to_dict(),
|
|
"checks": checks,
|
|
"evidence_digests": dict(evidence_digests),
|
|
}
|
|
|
|
|
|
def _extract_peer_identity(connection: socket.socket) -> PeerIdentity:
|
|
if not hasattr(socket, "SO_PEERCRED"):
|
|
raise BuilderError("SO_PEERCRED_UNAVAILABLE")
|
|
raw = connection.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, struct.calcsize("3i"))
|
|
if len(raw) != struct.calcsize("3i"):
|
|
raise BuilderError("SO_PEERCRED_INVALID")
|
|
return PeerIdentity(*struct.unpack("3i", raw))
|
|
|
|
|
|
def _resolve_cgroups(pid: int) -> tuple[str, ...]:
|
|
try:
|
|
return tuple(
|
|
line.rsplit(":", 1)[1].strip()
|
|
for line in Path(f"/proc/{pid}/cgroup").read_text(encoding="utf-8").splitlines()
|
|
if ":" in line and line.rsplit(":", 1)[1].strip().startswith("/")
|
|
)
|
|
except OSError as exc:
|
|
raise BuilderError("PEER_CGROUP_UNAVAILABLE") from exc
|
|
|
|
|
|
def _resolve_process_identity(pid: int) -> tuple[str, tuple[str, ...]]:
|
|
try:
|
|
executable = os.path.realpath(f"/proc/{pid}/exe")
|
|
argv = tuple(
|
|
item.decode("utf-8", "strict")
|
|
for item in Path(f"/proc/{pid}/cmdline").read_bytes().split(b"\0")
|
|
if item
|
|
)
|
|
except (OSError, UnicodeDecodeError) as exc:
|
|
raise BuilderError("PEER_PROCESS_UNAVAILABLE") from exc
|
|
if not executable or not argv:
|
|
raise BuilderError("PEER_PROCESS_UNAVAILABLE")
|
|
return executable, argv
|
|
|
|
|
|
class TrustedCandidateBuilder:
|
|
def __init__(
|
|
self, config: TrustedCandidateBuilderConfig, keyring: BuilderKeyring, *,
|
|
collector: DeterministicEvidenceCollector, signer: DomainPurposeSigner,
|
|
peer_identity_resolver: Callable[[socket.socket], PeerIdentity] = _extract_peer_identity,
|
|
cgroup_resolver: Callable[[int], tuple[str, ...] | str] = _resolve_cgroups,
|
|
process_identity_resolver: Callable[[int], tuple[str, tuple[str, ...]]] = _resolve_process_identity,
|
|
clock: Callable[[], float] = time.time, nonce_ledger_path: Path | None = None,
|
|
) -> None:
|
|
self.config, self.keyring, self.collector, self.signer = config, keyring, collector, signer
|
|
self.peer_identity_resolver, self.cgroup_resolver, self.clock = peer_identity_resolver, cgroup_resolver, clock
|
|
self.process_identity_resolver = process_identity_resolver
|
|
self._nonce_ledger_path = Path(nonce_ledger_path) if nonce_ledger_path else None
|
|
self._nonce_ledger: dict[str, tuple[bytes, dict[str, Any]]] = self._load_nonce_ledger()
|
|
|
|
def _load_nonce_ledger(self) -> dict[str, tuple[bytes, dict[str, Any]]]:
|
|
if self._nonce_ledger_path is None or not self._nonce_ledger_path.exists():
|
|
return {}
|
|
if self._nonce_ledger_path.is_symlink():
|
|
raise BuilderError("NONCE_LEDGER_INVALID")
|
|
try:
|
|
raw = json.loads(self._nonce_ledger_path.read_text(encoding="utf-8"))
|
|
return {nonce: (bytes.fromhex(value["request_sha256"]), dict(value["result"])) for nonce, value in raw.items()}
|
|
except (OSError, ValueError, TypeError, KeyError, json.JSONDecodeError) as exc:
|
|
raise BuilderError("NONCE_LEDGER_INVALID") from exc
|
|
|
|
def _persist_nonce_ledger(self) -> None:
|
|
if self._nonce_ledger_path is None:
|
|
return
|
|
try:
|
|
self._nonce_ledger_path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
temporary = self._nonce_ledger_path.with_suffix(".tmp")
|
|
payload = {nonce: {"request_sha256": digest.hex(), "result": result} for nonce, (digest, result) in self._nonce_ledger.items()}
|
|
with temporary.open("wb") as handle:
|
|
handle.write(_canonical_json(payload))
|
|
handle.flush()
|
|
os.fsync(handle.fileno())
|
|
os.chmod(temporary, 0o600)
|
|
os.replace(temporary, self._nonce_ledger_path)
|
|
if os.name != "nt":
|
|
descriptor = os.open(self._nonce_ledger_path.parent, os.O_RDONLY)
|
|
try:
|
|
os.fsync(descriptor)
|
|
finally:
|
|
os.close(descriptor)
|
|
except OSError as exc:
|
|
raise BuilderError("NONCE_LEDGER_PERSIST_FAILED") from exc
|
|
|
|
def _verify_peer(self, connection: socket.socket) -> None:
|
|
peer = self.peer_identity_resolver(connection)
|
|
if peer.uid != self.config.allowed_peer_uid or peer.pid <= 0:
|
|
raise BuilderError("PEER_IDENTITY_DENIED")
|
|
resolved = self.cgroup_resolver(peer.pid)
|
|
cgroups = (resolved,) if isinstance(resolved, str) else tuple(resolved)
|
|
if not cgroups or not any(item in self.config.allowed_peer_cgroups for item in cgroups):
|
|
raise BuilderError("PEER_CGROUP_DENIED")
|
|
executable, argv = self.process_identity_resolver(peer.pid)
|
|
if not executable.startswith(self.config.allowed_peer_executable_prefix):
|
|
raise BuilderError("PEER_PROCESS_DENIED")
|
|
runtime_arguments = [
|
|
value
|
|
for value in argv
|
|
if value.startswith(self.config.allowed_peer_runtime_prefix)
|
|
and value.endswith(self.config.allowed_peer_runtime_suffix)
|
|
]
|
|
if len(runtime_arguments) != 1:
|
|
raise BuilderError("PEER_PROCESS_DENIED")
|
|
|
|
def handle_request(self, request: Mapping[str, Any], *, connection: socket.socket) -> dict[str, Any]:
|
|
if not isinstance(request, Mapping):
|
|
raise BuilderError("REQUEST_SCHEMA_INVALID")
|
|
expected_keys = {"schema", "operation", "recipe", "repository", "task_id", "candidate_id", "base_oid", "source_commit", "version", "purpose", "issued_at", "nonce", "hmac"}
|
|
if (
|
|
set(request) != expected_keys
|
|
or request.get("schema") != self.config.request_schema
|
|
or request.get("operation") not in {
|
|
self.config.operation,
|
|
self.config.readiness_operation,
|
|
}
|
|
):
|
|
raise BuilderError("REQUEST_SCHEMA_INVALID")
|
|
supplied_mac = request.get("hmac")
|
|
unsigned = {key: value for key, value in request.items() if key != "hmac"}
|
|
if not isinstance(supplied_mac, str) or not hmac.compare_digest(hmac.new(self.keyring.builder_hmac, _canonical_json(unsigned), hashlib.sha256).hexdigest(), supplied_mac):
|
|
raise BuilderError("REQUEST_HMAC_INVALID")
|
|
issued_at, nonce = request.get("issued_at"), request.get("nonce")
|
|
if not isinstance(issued_at, int) or not isinstance(nonce, str) or len(nonce) < 16 or len(nonce) > 256:
|
|
raise BuilderError("REQUEST_SCHEMA_INVALID")
|
|
self._verify_peer(connection)
|
|
request_bytes = _canonical_json(dict(request))
|
|
replay = self._nonce_ledger.get(nonce)
|
|
if replay is not None:
|
|
if hmac.compare_digest(replay[0], hashlib.sha256(request_bytes).digest()):
|
|
return dict(replay[1])
|
|
raise BuilderError("REPLAY_CONFLICT")
|
|
if abs(int(self.clock()) - issued_at) > self.config.maximum_request_age_seconds:
|
|
raise BuilderError("REQUEST_STALE")
|
|
recipe_name = request.get("recipe")
|
|
if not isinstance(recipe_name, str):
|
|
raise BuilderError("REQUEST_SCHEMA_INVALID")
|
|
recipe = self.config.resolve_recipe(str(request.get("version") or ""), str(request.get("candidate_id") or ""), str(request.get("source_commit") or ""))
|
|
if recipe_name != recipe.get("version"):
|
|
raise BuilderError("REQUEST_RELEASE_BINDING_INVALID")
|
|
bound = {"repository": self.config.repository, "task_id": recipe["task_id"], "candidate_id": recipe["candidate_id"], "base_oid": recipe["base_oid"], "source_commit": recipe["source_commit"], "version": recipe["version"], "purpose": recipe["purpose"]}
|
|
if {key: request.get(key) for key in bound} != bound:
|
|
raise BuilderError("REQUEST_SCOPE_INVALID")
|
|
if request["operation"] == self.config.readiness_operation:
|
|
self.collector.assert_ready()
|
|
# Accessing the identity properties validates that the signer is
|
|
# configured and usable without invoking the private key.
|
|
builder_key_id = self.signer.key_id
|
|
builder_key_generation = self.signer.key_generation
|
|
result = {
|
|
"schema": self.config.response_schema,
|
|
"status": "READY",
|
|
"repository": self.config.repository,
|
|
"recipe": recipe_name,
|
|
"candidate_id": recipe["candidate_id"],
|
|
"source_commit": recipe["source_commit"],
|
|
"builder_key_id": builder_key_id,
|
|
"builder_key_generation": builder_key_generation,
|
|
"signature_algorithm": "Ed25519",
|
|
"signature_purpose": "AIOS_TRUSTED_RELEASE_BUILDER",
|
|
"policy_sha256": self.config.policy_sha256,
|
|
"collector_ready": True,
|
|
"signer_ready": True,
|
|
}
|
|
if len(self._nonce_ledger) >= 2048:
|
|
self._nonce_ledger.pop(next(iter(self._nonce_ledger)))
|
|
self._nonce_ledger[nonce] = (
|
|
hashlib.sha256(request_bytes).digest(),
|
|
dict(result),
|
|
)
|
|
self._persist_nonce_ledger()
|
|
return result
|
|
|
|
bundle = dict(self.collector.collect(recipe_name, recipe))
|
|
candidate = bundle["candidate"]
|
|
release_plan = bundle["release_plan"]
|
|
checks = bundle.get("checks")
|
|
evidence_digests = bundle["evidence_digests"]
|
|
if not isinstance(checks, Mapping) or set(checks) != set(self.config.required_checks) or not all(value is True for value in checks.values()):
|
|
raise BuilderError("COLLECTOR_CHECKS_INCOMPLETE")
|
|
|
|
issued = datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
|
attestation = {
|
|
"builder_attestation_id": "builder-attestation-" + _sha256(recipe_name, recipe["task_id"], candidate["artifact_sha256"])[:24],
|
|
"builder_key_id": self.signer.key_id,
|
|
"builder_key_generation": self.signer.key_generation,
|
|
"signature_algorithm": "Ed25519",
|
|
"signature_purpose": "AIOS_TRUSTED_RELEASE_BUILDER",
|
|
"source_commit": candidate["source_commit"], "tree_oid": candidate["tree_oid"],
|
|
"artifact_sha256": candidate["artifact_sha256"], "manifest_sha256": candidate["manifest_sha256"],
|
|
"package_closure_sha256": release_plan["deployment"]["package_closure_sha256"],
|
|
"test_evidence_sha256": evidence_digests["tests"],
|
|
"canary_evidence_sha256": evidence_digests["canary"],
|
|
"gitea_evidence_sha256": evidence_digests["gitea"],
|
|
"rollback_evidence_sha256": evidence_digests["rollback"],
|
|
"policy_evidence_sha256": evidence_digests["policy"],
|
|
"secret_scan_evidence_sha256": evidence_digests["secret_scan"],
|
|
"issued_at": issued, "trust_domain": "aios.trusted-builder.attestation.v1",
|
|
}
|
|
canonical_attestation = _canonical_json(attestation)
|
|
fingerprint = hashlib.sha256(canonical_attestation).hexdigest()
|
|
signature = base64.b64encode(self.signer.sign_for_purpose("AIOS_TRUSTED_RELEASE_BUILDER", canonical_attestation)).decode("ascii")
|
|
result = {
|
|
"schema": self.config.response_schema, "status": "BUILT", "recipe": recipe_name,
|
|
"repository": self.config.repository, "task_id": recipe["task_id"], "base_oid": recipe["base_oid"],
|
|
"purpose": recipe["purpose"], "checks": dict(checks),
|
|
"evidence": {"candidate_id": candidate["candidate_id"], "source_commit": candidate["source_commit"], "tree_oid": candidate["tree_oid"], "artifact_sha256": candidate["artifact_sha256"], "manifest_sha256": candidate["manifest_sha256"], "package_closure_sha256": release_plan["deployment"]["package_closure_sha256"], "test_evidence_sha256": attestation["test_evidence_sha256"], "canary_evidence_sha256": attestation["canary_evidence_sha256"]},
|
|
"attestation": {**attestation, "fingerprint": fingerprint, "signature": signature},
|
|
}
|
|
result["silent_release_bundle"] = {"candidate": candidate, "release_plan": release_plan, "checks": dict(checks), "attestation": result["attestation"]}
|
|
if len(self._nonce_ledger) >= 2048:
|
|
self._nonce_ledger.pop(next(iter(self._nonce_ledger)))
|
|
self._nonce_ledger[nonce] = (hashlib.sha256(request_bytes).digest(), dict(result))
|
|
self._persist_nonce_ledger()
|
|
return result
|
|
|
|
def serve_forever(self, listener: socket.socket) -> None:
|
|
while True:
|
|
connection, _ = listener.accept()
|
|
with connection:
|
|
try:
|
|
raw = connection.makefile("rb").readline(65537)
|
|
if not raw or len(raw) > 65536:
|
|
raise BuilderError("REQUEST_SIZE_INVALID")
|
|
result = self.handle_request(json.loads(raw), connection=connection)
|
|
except (BuilderError, UnicodeDecodeError, json.JSONDecodeError):
|
|
result = {"schema": self.config.response_schema, "status": "REJECTED", "error_code": "REQUEST_REJECTED"}
|
|
connection.sendall(_canonical_json(result) + b"\n")
|
|
|
|
|
|
class UnixTrustedCandidateBuilderClient:
|
|
def __init__(self, config: TrustedCandidateBuilderConfig, keyring: BuilderKeyring, *, socket_path: Path | None = None, clock: Callable[[], float] = time.time) -> None:
|
|
self.config, self.keyring, self.socket_path, self.clock = config, keyring, socket_path, clock
|
|
|
|
def create_request(self, version: str, candidate_id: str | None, source_commit: str, *, nonce: str, operation: str | None = None) -> dict[str, Any]:
|
|
selected = self.config.resolve_recipe(version, candidate_id, source_commit)
|
|
selected_operation = operation or self.config.operation
|
|
if selected_operation not in {self.config.operation, self.config.readiness_operation}:
|
|
raise BuilderError("REQUEST_OPERATION_INVALID")
|
|
request = {"schema": self.config.request_schema, "operation": selected_operation, "recipe": version, "repository": self.config.repository, "task_id": selected["task_id"], "candidate_id": selected["candidate_id"], "base_oid": selected["base_oid"], "source_commit": selected["source_commit"], "version": selected["version"], "purpose": selected["purpose"], "issued_at": int(self.clock()), "nonce": nonce}
|
|
request["hmac"] = hmac.new(self.keyring.builder_hmac, _canonical_json(request), hashlib.sha256).hexdigest()
|
|
return request
|
|
|
|
def _exchange(self, request: Mapping[str, Any]) -> Mapping[str, Any]:
|
|
if self.socket_path is None:
|
|
raise BuilderError("BUILDER_SOCKET_UNCONFIGURED")
|
|
try:
|
|
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection:
|
|
connection.settimeout(self.config.request_timeout_seconds)
|
|
connection.connect(os.fspath(self.socket_path))
|
|
connection.sendall(_canonical_json(dict(request)) + b"\n")
|
|
raw = connection.makefile("rb").readline(65537)
|
|
except (OSError, TimeoutError) as exc:
|
|
raise BuilderError("BUILDER_TRANSPORT_UNAVAILABLE") from exc
|
|
try:
|
|
result = json.loads(raw)
|
|
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
raise BuilderError("BUILDER_RESPONSE_INVALID") from exc
|
|
if not isinstance(result, Mapping) or result.get("schema") != self.config.response_schema:
|
|
raise BuilderError("BUILDER_RESPONSE_INVALID")
|
|
return result
|
|
|
|
def build(self, version: str, candidate_id: str | None, source_commit: str, *, nonce: str) -> Mapping[str, Any]:
|
|
result = self._exchange(
|
|
self.create_request(version, candidate_id, source_commit, nonce=nonce)
|
|
)
|
|
if result.get("status") != "BUILT":
|
|
raise BuilderError("BUILDER_REQUEST_REJECTED")
|
|
return result
|
|
|
|
def probe(self, *, nonce: str | None = None) -> Mapping[str, Any]:
|
|
recipe = self.config.fixed_recipe("V2R2C2")
|
|
result = self._exchange(
|
|
self.create_request(
|
|
"V2R2C2",
|
|
recipe["candidate_id"],
|
|
recipe["source_commit"],
|
|
nonce=nonce or secrets.token_hex(32),
|
|
operation=self.config.readiness_operation,
|
|
)
|
|
)
|
|
if result.get("status") != "READY":
|
|
raise BuilderError("BUILDER_NOT_READY")
|
|
return result
|
|
|
|
|
|
def readiness_probe(client: UnixTrustedCandidateBuilderClient) -> Mapping[str, Any]:
|
|
result = dict(client.probe())
|
|
required_true = {"collector_ready", "signer_ready"}
|
|
if any(result.get(name) is not True for name in required_true):
|
|
raise BuilderError("BUILDER_NOT_READY")
|
|
return result
|
|
|
|
|
|
def _main() -> None:
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--socket-fd", required=True, type=int)
|
|
parser.add_argument("--policy", required=True, type=Path)
|
|
arguments = parser.parse_args()
|
|
config = TrustedCandidateBuilderConfig.from_policy(load_builder_policy(arguments.policy))
|
|
credentials = os.environ.get("CREDENTIALS_DIRECTORY")
|
|
if not credentials:
|
|
raise SystemExit("CREDENTIALS_DIRECTORY is required")
|
|
keyring = BuilderKeyring.from_credentials_directory(Path(credentials))
|
|
source_admission_root = os.environ.get(
|
|
"AIOS_TRUSTED_BUILDER_SOURCE_ADMISSION_ROOT", ""
|
|
).strip()
|
|
if source_admission_root:
|
|
from release_source_admission import open_source_admission_store
|
|
|
|
try:
|
|
source_admission_hmac = (
|
|
Path(credentials) / "source_admission_hmac"
|
|
).read_bytes().strip()
|
|
except OSError as exc:
|
|
raise BuilderError("SOURCE_ADMISSION_HMAC_UNAVAILABLE") from exc
|
|
source_store = open_source_admission_store(
|
|
Path(source_admission_root),
|
|
repository=config.repository,
|
|
policy=arguments.policy,
|
|
hmac_key=source_admission_hmac,
|
|
expected_owner_uid=0,
|
|
writable=False,
|
|
)
|
|
config = replace(
|
|
config,
|
|
source_admission_resolver=source_store.resolve_recipe,
|
|
)
|
|
migration_plan_root = os.environ.get(
|
|
"AIOS_TRUSTED_BUILDER_MIGRATION_PLAN_ROOT", ""
|
|
).strip()
|
|
migration_profile_root = os.environ.get(
|
|
"AIOS_TRUSTED_BUILDER_MIGRATION_PROFILE_ROOT", ""
|
|
).strip()
|
|
if bool(migration_plan_root) != bool(migration_profile_root):
|
|
raise BuilderError("MIGRATION_PLAN_CONFIGURATION_INVALID")
|
|
if migration_plan_root:
|
|
from release_migration_plan import FileMigrationPlanResolver
|
|
|
|
migration_resolver = FileMigrationPlanResolver(
|
|
Path(migration_plan_root),
|
|
profile_root=Path(migration_profile_root),
|
|
expected_owner_uid=0,
|
|
)
|
|
config = replace(
|
|
config,
|
|
migration_plan_resolver=migration_resolver.resolve,
|
|
)
|
|
signer = CredentialPathEd25519Signer(
|
|
keyring.signing_private_key_path,
|
|
key_id=os.environ["AIOS_TRUSTED_BUILDER_KEY_ID"],
|
|
key_generation=int(os.environ["AIOS_TRUSTED_BUILDER_KEY_GENERATION"]),
|
|
allowed_purpose="AIOS_TRUSTED_RELEASE_BUILDER",
|
|
)
|
|
listener = socket.fromfd(arguments.socket_fd, socket.AF_UNIX, socket.SOCK_STREAM)
|
|
collector = DeterministicEvidenceCollector(
|
|
config,
|
|
evidence_source=ServerOwnedEvidenceSource(
|
|
config,
|
|
Path(os.environ["AIOS_TRUSTED_BUILDER_GIT_DIR"]),
|
|
Path(os.environ["AIOS_TRUSTED_BUILDER_EVIDENCE_ROOT"]),
|
|
),
|
|
)
|
|
TrustedCandidateBuilder(config, keyring, collector=collector, signer=signer, nonce_ledger_path=Path(os.environ["AIOS_TRUSTED_BUILDER_NONCE_LEDGER"])).serve_forever(listener)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
_main()
|