204 lines
6.9 KiB
Python
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
|