Files
aios/tools/aios_spark_runner/provenance.py
T

204 lines
6.9 KiB
Python

"""Cryptographically verified provenance for runner artifacts."""
from __future__ import annotations
import base64
from datetime import datetime
import hmac
import json
import os
from pathlib import Path
import re
import stat
import subprocess
try:
from tools.aios_spark_runner.errors import ConfigError
from tools.aios_spark_runner.securefs import (
read_private_json,
reject_symlink_chain,
sha256_file,
)
except ModuleNotFoundError:
from aios_spark_runner.errors import ConfigError
from aios_spark_runner.securefs import (
read_private_json,
reject_symlink_chain,
sha256_file,
)
_OID = re.compile(r"^[0-9a-f]{40}$")
_SHA = re.compile(r"^[0-9a-f]{64}$")
_SIGNATURE = re.compile(r"^ed25519:([A-Za-z0-9+/]+={0,2})$")
_FIELDS = {
"source_commit",
"artifact_digest",
"sha256",
"signature",
"created_at",
}
_NOFOLLOW = getattr(os, "O_NOFOLLOW", 0)
def _git(repo: Path, *args: str) -> str:
repo = Path(repo)
reject_symlink_chain(repo)
command = ["git", "-C", str(repo)] if (repo / ".git").exists() else [
"git", f"--git-dir={repo}"
]
try:
return subprocess.check_output(
[*command, *args], text=True, stderr=subprocess.PIPE
).strip()
except subprocess.CalledProcessError as exc:
raise ConfigError("source_commit is not an actual Git commit") from exc
def git_commit_created_at(repo: Path, commit: str) -> str:
if not _OID.fullmatch(commit):
raise ConfigError("source_commit must be a full Git OID")
_git(repo, "cat-file", "-e", f"{commit}^{{commit}}")
created_at = _git(repo, "show", "-s", "--format=%cI", commit)
try:
datetime.fromisoformat(created_at)
except ValueError as exc:
raise ConfigError("Git commit timestamp is invalid") from exc
return created_at
def _payload(fields: dict[str, str]) -> bytes:
return (
json.dumps(fields, sort_keys=True, separators=(",", ":")) + "\n"
).encode("utf-8")
def _validated_key_fd(path: Path, *, expected_uid: int | None) -> int:
path = Path(path)
if not path.is_absolute():
raise ConfigError("provenance key path must be absolute")
reject_symlink_chain(path)
before = os.lstat(path)
if not stat.S_ISREG(before.st_mode):
raise ConfigError("provenance key must be a regular file")
if expected_uid is not None and before.st_uid != expected_uid:
raise ConfigError("provenance key owner mismatch")
if before.st_mode & (stat.S_IWGRP | stat.S_IWOTH):
raise ConfigError("provenance key mode is unsafe")
fd = os.open(path, os.O_RDONLY | _NOFOLLOW)
after = os.fstat(fd)
if (before.st_dev, before.st_ino) != (after.st_dev, after.st_ino):
os.close(fd)
raise ConfigError("provenance key changed during secure open")
return fd
def _openssl(
payload: bytes,
key: Path,
*,
expected_uid: int | None,
) -> bytes:
fd = _validated_key_fd(key, expected_uid=expected_uid)
if not hasattr(os, "memfd_create"):
os.close(fd)
raise ConfigError("anonymous provenance input unavailable")
payload_fd = os.memfd_create("aios-provenance-payload", flags=0)
os.write(payload_fd, payload)
os.lseek(payload_fd, 0, os.SEEK_SET)
command = [
"openssl", "pkeyutl", "-sign", "-rawin",
"-inkey", f"/proc/self/fd/{fd}",
"-in", f"/proc/self/fd/{payload_fd}",
]
try:
result = subprocess.run(
command,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
pass_fds=(fd, payload_fd),
check=False,
)
if result.returncode != 0:
raise ConfigError("artifact signature verification failed")
return result.stdout
except OSError as exc:
raise ConfigError("OpenSSL provenance verification unavailable") from exc
finally:
os.close(fd)
os.close(payload_fd)
def sign_fields(
fields: dict[str, str], signing_key: Path, *, expected_uid: int | None
) -> str:
signature = _openssl(
_payload(fields), signing_key, expected_uid=expected_uid
)
return "ed25519:" + base64.b64encode(signature).decode("ascii")
def verify_manifest(
artifact: Path,
manifest: Path,
trusted_public_key: Path,
source_repo: Path,
*,
expected_uid: int | None,
) -> dict[str, str]:
data = read_private_json(
Path(manifest), expected_uid=expected_uid, expected_modes=(0o600,)
)
if set(data) != _FIELDS or not all(isinstance(data[key], str) for key in _FIELDS):
raise ConfigError("artifact manifest schema mismatch")
commit = data["source_commit"]
created_at = git_commit_created_at(Path(source_repo), commit)
if data["created_at"] != created_at:
raise ConfigError("artifact created_at does not match Git commit")
actual = sha256_file(Path(artifact), expected_uid=expected_uid)
if not _SHA.fullmatch(data["sha256"]):
raise ConfigError("artifact manifest SHA-256 is invalid")
if data["artifact_digest"] != f"sha256:{actual}":
raise ConfigError("artifact digest mismatch")
if not hmac.compare_digest(data["sha256"], actual):
raise ConfigError("artifact SHA-256 mismatch")
match = _SIGNATURE.fullmatch(data["signature"])
if not match:
raise ConfigError("artifact signature format is invalid")
try:
signature = base64.b64decode(match.group(1), validate=True)
except ValueError as exc:
raise ConfigError("artifact signature format is invalid") from exc
fields = {key: data[key] for key in _FIELDS - {"signature"}}
# OpenSSL verification uses temporary anonymous descriptors only; trusted
# key material never comes from the runner config or manifest.
key_fd = _validated_key_fd(trusted_public_key, expected_uid=expected_uid)
if not hasattr(os, "memfd_create"):
os.close(key_fd)
raise ConfigError("anonymous provenance input unavailable")
signature_fd = os.memfd_create("aios-provenance-signature", flags=0)
payload_fd = os.memfd_create("aios-provenance-payload", flags=0)
try:
os.write(signature_fd, signature)
os.lseek(signature_fd, 0, os.SEEK_SET)
os.write(payload_fd, _payload(fields))
os.lseek(payload_fd, 0, os.SEEK_SET)
result = subprocess.run(
[
"openssl", "pkeyutl", "-verify", "-rawin", "-pubin",
"-inkey", f"/proc/self/fd/{key_fd}",
"-sigfile", f"/proc/self/fd/{signature_fd}",
"-in", f"/proc/self/fd/{payload_fd}",
],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
pass_fds=(key_fd, signature_fd, payload_fd),
check=False,
)
if result.returncode != 0:
raise ConfigError("artifact signature verification failed")
finally:
os.close(key_fd)
os.close(signature_fd)
os.close(payload_fd)
return data