1539 lines
52 KiB
Python
1539 lines
52 KiB
Python
"""Single-host Owner authority and machine signer for AIOS.
|
|
|
|
This module deliberately removes ambient signer discovery from the authority
|
|
path. Runtime code addresses signers by stable signer_id in a root-owned
|
|
registry; private key paths are implementation details of the local authority
|
|
service and never appear in that registry.
|
|
|
|
Threat model
|
|
============
|
|
* One physical/logical AIOS server is the host trust anchor.
|
|
* Host root compromise is therefore a trust-root compromise. This module does
|
|
not pretend otherwise when no TPM/HSM/external signer exists.
|
|
* Agent, Worker, Bridge, Task context, Git and logs must never contain private
|
|
key bytes.
|
|
* The Owner key may sign only structured AIOS authorization objects. There is
|
|
no arbitrary-byte signing API exposed by ``LocalOwnerAuthority``.
|
|
* High-frequency operational capabilities are signed by a distinct machine key
|
|
and must monotonically narrow an already-valid OwnerDelegationEpoch.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import asdict, dataclass
|
|
import base64
|
|
import hashlib
|
|
import json
|
|
import os
|
|
from pathlib import Path, PurePosixPath
|
|
import re
|
|
import secrets
|
|
import stat
|
|
import struct
|
|
import tempfile
|
|
import time
|
|
from typing import Any, Mapping, Sequence
|
|
|
|
from cryptography.hazmat.primitives import serialization
|
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
|
Ed25519PrivateKey,
|
|
Ed25519PublicKey,
|
|
)
|
|
|
|
|
|
OWNER_NAMESPACE = "aios-owner-approval-v1"
|
|
MACHINE_NAMESPACE = "aios-machine-capability-v1"
|
|
OWNER_IDENTITY = "aios-task-grant-signer"
|
|
|
|
REGISTRY_SCHEMA = "aios.single-host-signer-registry.v1"
|
|
POLICY_SCHEMA = "aios.single-host-owner-policy.v1"
|
|
AUTHORIZATION_BUNDLE_SCHEMA = "aios.owner-authorization-bundle.v1"
|
|
MACHINE_CAPABILITY_SCHEMA = "aios.machine-derived-capability.v1"
|
|
BOOTSTRAP_STATE_SCHEMA = "aios.single-host-bootstrap-state.v1"
|
|
OWNER_EPOCH_SCHEMA = "aios.owner-delegation-epoch.v2"
|
|
|
|
DEFAULT_REGISTRY_PATH = Path("/etc/aios/single-host-signer-registry.json")
|
|
DEFAULT_OWNER_SIGNER_ID = "local-owner-authority"
|
|
DEFAULT_MACHINE_SIGNER_ID = "machine-operational"
|
|
|
|
_HEX64 = re.compile(r"^[0-9a-f]{64}$")
|
|
_CAP = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{1,127}$")
|
|
_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:@/-]{2,255}$")
|
|
_RISK = {"low": 0, "medium": 1, "high": 2}
|
|
_MAX_JSON = 128 * 1024
|
|
_MAX_KEY = 64 * 1024
|
|
_MAGIC = b"SSHSIG"
|
|
_VERSION = 1
|
|
_HASH_ALG = "sha512"
|
|
|
|
|
|
class SingleHostAuthorityError(RuntimeError):
|
|
"""Stable fail-closed authority error."""
|
|
|
|
def __init__(self, code: str) -> None:
|
|
super().__init__(code)
|
|
self.code = code
|
|
|
|
|
|
def _fail(code: str) -> None:
|
|
raise SingleHostAuthorityError(code)
|
|
|
|
|
|
def canonical_json_bytes(value: Any) -> bytes:
|
|
return json.dumps(
|
|
value,
|
|
sort_keys=True,
|
|
separators=(",", ":"),
|
|
ensure_ascii=False,
|
|
allow_nan=False,
|
|
).encode("utf-8")
|
|
|
|
|
|
def sha256_json(value: Any) -> str:
|
|
return hashlib.sha256(canonical_json_bytes(value)).hexdigest()
|
|
|
|
|
|
def _ssh_string(value: bytes) -> bytes:
|
|
return struct.pack(">I", len(value)) + value
|
|
|
|
|
|
def _read_ssh_string(blob: bytes, offset: int) -> tuple[bytes, int]:
|
|
if offset + 4 > len(blob):
|
|
_fail("SSHSIG_TRUNCATED")
|
|
length = struct.unpack(">I", blob[offset : offset + 4])[0]
|
|
offset += 4
|
|
if length > len(blob) - offset:
|
|
_fail("SSHSIG_TRUNCATED")
|
|
return blob[offset : offset + length], offset + length
|
|
|
|
|
|
def _public_blob(public_key: Ed25519PublicKey) -> bytes:
|
|
raw = public_key.public_bytes(
|
|
serialization.Encoding.Raw,
|
|
serialization.PublicFormat.Raw,
|
|
)
|
|
return _ssh_string(b"ssh-ed25519") + _ssh_string(raw)
|
|
|
|
|
|
def ssh_public_line(public_key: Ed25519PublicKey, comment: str = "") -> str:
|
|
blob = _public_blob(public_key)
|
|
suffix = f" {comment}" if comment else ""
|
|
return f"ssh-ed25519 {base64.b64encode(blob).decode('ascii')}{suffix}"
|
|
|
|
|
|
def ssh_fingerprint(public_key: Ed25519PublicKey) -> str:
|
|
digest = hashlib.sha256(_public_blob(public_key)).digest()
|
|
return "SHA256:" + base64.b64encode(digest).decode("ascii").rstrip("=")
|
|
|
|
|
|
def _signed_data(namespace: str, payload: bytes, *, hash_algorithm: str) -> bytes:
|
|
if not namespace or any(ord(ch) < 32 or ord(ch) == 127 for ch in namespace):
|
|
_fail("SIGNATURE_NAMESPACE_INVALID")
|
|
if hash_algorithm not in {"sha256", "sha512"}:
|
|
_fail("SIGNATURE_HASH_ALGORITHM_INVALID")
|
|
hashed = hashlib.new(hash_algorithm, payload).digest()
|
|
return (
|
|
_MAGIC
|
|
+ _ssh_string(namespace.encode("utf-8"))
|
|
+ _ssh_string(b"")
|
|
+ _ssh_string(hash_algorithm.encode("ascii"))
|
|
+ _ssh_string(hashed)
|
|
)
|
|
|
|
|
|
def sign_sshsig(
|
|
private_key: Ed25519PrivateKey,
|
|
payload: bytes,
|
|
*,
|
|
namespace: str,
|
|
hash_algorithm: str = _HASH_ALG,
|
|
) -> str:
|
|
"""Return an OpenSSH-compatible armored detached SSHSIG."""
|
|
|
|
public_key = private_key.public_key()
|
|
signed = _signed_data(namespace, payload, hash_algorithm=hash_algorithm)
|
|
raw_signature = private_key.sign(signed)
|
|
signature_blob = _ssh_string(b"ssh-ed25519") + _ssh_string(raw_signature)
|
|
envelope = (
|
|
_MAGIC
|
|
+ struct.pack(">I", _VERSION)
|
|
+ _ssh_string(_public_blob(public_key))
|
|
+ _ssh_string(namespace.encode("utf-8"))
|
|
+ _ssh_string(b"")
|
|
+ _ssh_string(hash_algorithm.encode("ascii"))
|
|
+ _ssh_string(signature_blob)
|
|
)
|
|
encoded = base64.b64encode(envelope).decode("ascii")
|
|
lines = [encoded[i : i + 76] for i in range(0, len(encoded), 76)]
|
|
return (
|
|
"-----BEGIN SSH SIGNATURE-----\n"
|
|
+ "\n".join(lines)
|
|
+ "\n-----END SSH SIGNATURE-----\n"
|
|
)
|
|
|
|
|
|
def _parse_public_blob(blob: bytes) -> Ed25519PublicKey:
|
|
algorithm, offset = _read_ssh_string(blob, 0)
|
|
raw, offset = _read_ssh_string(blob, offset)
|
|
if offset != len(blob) or algorithm != b"ssh-ed25519" or len(raw) != 32:
|
|
_fail("SSHSIG_PUBLIC_KEY_INVALID")
|
|
try:
|
|
return Ed25519PublicKey.from_public_bytes(raw)
|
|
except ValueError as exc:
|
|
raise SingleHostAuthorityError("SSHSIG_PUBLIC_KEY_INVALID") from exc
|
|
|
|
|
|
def verify_sshsig(
|
|
public_key: Ed25519PublicKey,
|
|
payload: bytes,
|
|
signature: str,
|
|
*,
|
|
namespace: str,
|
|
expected_key_id: str,
|
|
) -> bool:
|
|
"""Strictly verify one armored Ed25519 SSHSIG."""
|
|
|
|
try:
|
|
if not signature.startswith("-----BEGIN SSH SIGNATURE-----\n"):
|
|
return False
|
|
if not signature.endswith("-----END SSH SIGNATURE-----\n"):
|
|
return False
|
|
middle = signature[
|
|
len("-----BEGIN SSH SIGNATURE-----\n") :
|
|
-len("-----END SSH SIGNATURE-----\n")
|
|
]
|
|
compact = "".join(middle.splitlines())
|
|
if not compact or any(ch.isspace() for ch in compact):
|
|
return False
|
|
envelope = base64.b64decode(compact, validate=True)
|
|
if not envelope.startswith(_MAGIC) or len(envelope) < 10:
|
|
return False
|
|
offset = len(_MAGIC)
|
|
version = struct.unpack(">I", envelope[offset : offset + 4])[0]
|
|
offset += 4
|
|
if version != _VERSION:
|
|
return False
|
|
public_blob, offset = _read_ssh_string(envelope, offset)
|
|
envelope_namespace, offset = _read_ssh_string(envelope, offset)
|
|
reserved, offset = _read_ssh_string(envelope, offset)
|
|
hash_algorithm, offset = _read_ssh_string(envelope, offset)
|
|
signature_blob, offset = _read_ssh_string(envelope, offset)
|
|
if offset != len(envelope):
|
|
return False
|
|
if reserved != b"":
|
|
return False
|
|
if envelope_namespace.decode("utf-8") != namespace:
|
|
return False
|
|
hash_name = hash_algorithm.decode("ascii")
|
|
if hash_name not in {"sha256", "sha512"}:
|
|
return False
|
|
|
|
embedded = _parse_public_blob(public_blob)
|
|
embedded_raw = embedded.public_bytes(
|
|
serialization.Encoding.Raw, serialization.PublicFormat.Raw
|
|
)
|
|
expected_raw = public_key.public_bytes(
|
|
serialization.Encoding.Raw, serialization.PublicFormat.Raw
|
|
)
|
|
if not secrets.compare_digest(embedded_raw, expected_raw):
|
|
return False
|
|
if ssh_fingerprint(public_key) != expected_key_id:
|
|
return False
|
|
|
|
algorithm, sig_offset = _read_ssh_string(signature_blob, 0)
|
|
raw_signature, sig_offset = _read_ssh_string(signature_blob, sig_offset)
|
|
if (
|
|
sig_offset != len(signature_blob)
|
|
or algorithm != b"ssh-ed25519"
|
|
or len(raw_signature) != 64
|
|
):
|
|
return False
|
|
public_key.verify(
|
|
raw_signature,
|
|
_signed_data(namespace, payload, hash_algorithm=hash_name),
|
|
)
|
|
return True
|
|
except (
|
|
SingleHostAuthorityError,
|
|
ValueError,
|
|
UnicodeDecodeError,
|
|
base64.binascii.Error,
|
|
):
|
|
return False
|
|
except Exception:
|
|
# cryptography InvalidSignature deliberately maps to fail-closed False
|
|
return False
|
|
|
|
|
|
def _strict_json_object(raw: bytes, *, maximum: int = _MAX_JSON) -> dict[str, Any]:
|
|
if not raw or len(raw) > maximum:
|
|
_fail("JSON_INPUT_INVALID")
|
|
|
|
def hook(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
|
|
value: dict[str, Any] = {}
|
|
for key, item in pairs:
|
|
if key in value:
|
|
_fail("JSON_DUPLICATE_FIELD")
|
|
value[key] = item
|
|
return value
|
|
|
|
try:
|
|
value = json.loads(
|
|
raw.decode("utf-8"),
|
|
object_pairs_hook=hook,
|
|
parse_constant=lambda _value: _fail("JSON_CONSTANT_INVALID"),
|
|
)
|
|
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
raise SingleHostAuthorityError("JSON_INPUT_INVALID") from exc
|
|
if not isinstance(value, dict):
|
|
_fail("JSON_OBJECT_REQUIRED")
|
|
return value
|
|
|
|
|
|
def _open_secure(
|
|
path: Path,
|
|
*,
|
|
private: bool,
|
|
require_root_owner: bool,
|
|
maximum: int,
|
|
) -> bytes:
|
|
path = Path(path)
|
|
if not path.is_absolute():
|
|
_fail("SECURE_PATH_NOT_ABSOLUTE")
|
|
flags = os.O_RDONLY
|
|
if hasattr(os, "O_NOFOLLOW"):
|
|
flags |= os.O_NOFOLLOW
|
|
try:
|
|
fd = os.open(path, flags)
|
|
except OSError as exc:
|
|
raise SingleHostAuthorityError("SECURE_FILE_OPEN_FAILED") from exc
|
|
try:
|
|
info = os.fstat(fd)
|
|
if not stat.S_ISREG(info.st_mode):
|
|
_fail("SECURE_FILE_NOT_REGULAR")
|
|
if os.name == "posix":
|
|
mode = stat.S_IMODE(info.st_mode)
|
|
if mode & 0o022:
|
|
_fail("SECURE_FILE_WRITABLE_BY_GROUP_OR_OTHER")
|
|
if private and mode & 0o077:
|
|
_fail("SECURE_PRIVATE_FILE_PERMISSIONS_INVALID")
|
|
if require_root_owner and info.st_uid != 0:
|
|
_fail("SECURE_FILE_NOT_ROOT_OWNED")
|
|
data = bytearray()
|
|
while len(data) <= maximum:
|
|
chunk = os.read(fd, min(65536, maximum + 1 - len(data)))
|
|
if not chunk:
|
|
break
|
|
data.extend(chunk)
|
|
if not data or len(data) > maximum:
|
|
_fail("SECURE_FILE_SIZE_INVALID")
|
|
return bytes(data)
|
|
finally:
|
|
os.close(fd)
|
|
|
|
|
|
def _ensure_secure_directory(
|
|
path: Path,
|
|
*,
|
|
require_root_owner: bool,
|
|
create: bool,
|
|
private: bool = True,
|
|
) -> None:
|
|
path = Path(path)
|
|
if not path.is_absolute():
|
|
_fail("SECURE_DIRECTORY_NOT_ABSOLUTE")
|
|
if create and not path.exists():
|
|
mode = 0o700 if private else 0o755
|
|
try:
|
|
path.mkdir(parents=True, mode=mode)
|
|
except OSError as exc:
|
|
raise SingleHostAuthorityError("SECURE_DIRECTORY_CREATE_FAILED") from exc
|
|
if os.name == "posix":
|
|
os.chmod(path, mode)
|
|
try:
|
|
info = path.lstat()
|
|
except OSError as exc:
|
|
raise SingleHostAuthorityError("SECURE_DIRECTORY_MISSING") from exc
|
|
if stat.S_ISLNK(info.st_mode) or not stat.S_ISDIR(info.st_mode):
|
|
_fail("SECURE_DIRECTORY_INVALID")
|
|
if os.name == "posix":
|
|
mode = stat.S_IMODE(info.st_mode)
|
|
if (private and mode & 0o077) or ((not private) and mode & 0o022):
|
|
_fail("SECURE_DIRECTORY_PERMISSIONS_INVALID")
|
|
if require_root_owner and info.st_uid != 0:
|
|
_fail("SECURE_DIRECTORY_NOT_ROOT_OWNED")
|
|
|
|
|
|
def _atomic_write(
|
|
path: Path,
|
|
data: bytes,
|
|
*,
|
|
mode: int,
|
|
require_root_owner: bool,
|
|
create_only: bool,
|
|
private_parent: bool = True,
|
|
) -> None:
|
|
path = Path(path)
|
|
_ensure_secure_directory(
|
|
path.parent,
|
|
require_root_owner=require_root_owner,
|
|
create=True,
|
|
private=private_parent,
|
|
)
|
|
fd, temp_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
|
temp = Path(temp_name)
|
|
try:
|
|
os.fchmod(fd, mode)
|
|
view = memoryview(data)
|
|
while view:
|
|
written = os.write(fd, view)
|
|
view = view[written:]
|
|
os.fsync(fd)
|
|
os.close(fd)
|
|
fd = -1
|
|
if create_only and path.exists():
|
|
_fail("SECURE_FILE_ALREADY_EXISTS")
|
|
os.replace(temp, path)
|
|
directory_fd = os.open(path.parent, os.O_RDONLY)
|
|
try:
|
|
os.fsync(directory_fd)
|
|
finally:
|
|
os.close(directory_fd)
|
|
finally:
|
|
if fd >= 0:
|
|
os.close(fd)
|
|
try:
|
|
temp.unlink()
|
|
except FileNotFoundError:
|
|
pass
|
|
|
|
|
|
def _load_private_key(
|
|
path: Path,
|
|
*,
|
|
require_root_owner: bool,
|
|
) -> Ed25519PrivateKey:
|
|
raw = _open_secure(
|
|
path,
|
|
private=True,
|
|
require_root_owner=require_root_owner,
|
|
maximum=_MAX_KEY,
|
|
)
|
|
try:
|
|
key = serialization.load_pem_private_key(raw, password=None)
|
|
except (TypeError, ValueError) as exc:
|
|
raise SingleHostAuthorityError("PRIVATE_KEY_INVALID") from exc
|
|
if not isinstance(key, Ed25519PrivateKey):
|
|
_fail("PRIVATE_KEY_ALGORITHM_INVALID")
|
|
return key
|
|
|
|
|
|
def _load_public_key_line(value: str) -> Ed25519PublicKey:
|
|
parts = value.strip().split()
|
|
if len(parts) < 2 or parts[0] != "ssh-ed25519":
|
|
_fail("PUBLIC_KEY_INVALID")
|
|
try:
|
|
blob = base64.b64decode(parts[1], validate=True)
|
|
except ValueError as exc:
|
|
raise SingleHostAuthorityError("PUBLIC_KEY_INVALID") from exc
|
|
return _parse_public_blob(blob)
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class SignerRecord:
|
|
signer_id: str
|
|
role: str
|
|
provider: str
|
|
fingerprint: str
|
|
public_key: str
|
|
socket: str
|
|
secret_ref: str
|
|
status: str
|
|
generation: int
|
|
parent_signer_id: str = ""
|
|
parent_fingerprint: str = ""
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
return asdict(self)
|
|
|
|
|
|
class SignerRegistry:
|
|
"""Root-owned public signer registry. Private paths are forbidden."""
|
|
|
|
def __init__(
|
|
self,
|
|
path: Path,
|
|
*,
|
|
require_root_owner: bool = True,
|
|
) -> None:
|
|
self.path = Path(path)
|
|
raw = _open_secure(
|
|
self.path,
|
|
private=False,
|
|
require_root_owner=require_root_owner,
|
|
maximum=_MAX_JSON,
|
|
)
|
|
value = _strict_json_object(raw)
|
|
if set(value) != {"schema", "authority_generation", "signers"}:
|
|
_fail("SIGNER_REGISTRY_FIELDS_INVALID")
|
|
if value["schema"] != REGISTRY_SCHEMA:
|
|
_fail("SIGNER_REGISTRY_SCHEMA_INVALID")
|
|
if type(value["authority_generation"]) is not int or value["authority_generation"] < 1:
|
|
_fail("SIGNER_REGISTRY_GENERATION_INVALID")
|
|
if not isinstance(value["signers"], list) or not value["signers"]:
|
|
_fail("SIGNER_REGISTRY_EMPTY")
|
|
self.authority_generation = value["authority_generation"]
|
|
self._records: dict[str, SignerRecord] = {}
|
|
allowed = set(SignerRecord.__dataclass_fields__)
|
|
for item in value["signers"]:
|
|
if not isinstance(item, Mapping) or set(item) != allowed:
|
|
_fail("SIGNER_REGISTRY_RECORD_INVALID")
|
|
if any(
|
|
key in item
|
|
for key in ("private_key", "private_key_path", "passphrase", "password")
|
|
):
|
|
_fail("SIGNER_REGISTRY_SECRET_MATERIAL_FORBIDDEN")
|
|
try:
|
|
record = SignerRecord(**dict(item))
|
|
except TypeError as exc:
|
|
raise SingleHostAuthorityError("SIGNER_REGISTRY_RECORD_INVALID") from exc
|
|
if (
|
|
record.signer_id in self._records
|
|
or not _ID.fullmatch(record.signer_id)
|
|
or record.role not in {"OWNER_AUTHORITY", "MACHINE_OPERATIONAL"}
|
|
or record.provider != "local_unix_service"
|
|
or record.status != "ACTIVE"
|
|
or type(record.generation) is not int
|
|
or record.generation < 1
|
|
or not record.socket.startswith("/run/")
|
|
or not record.secret_ref.startswith("aios-secret://")
|
|
):
|
|
_fail("SIGNER_REGISTRY_RECORD_INVALID")
|
|
public = _load_public_key_line(record.public_key)
|
|
if ssh_fingerprint(public) != record.fingerprint:
|
|
_fail("SIGNER_REGISTRY_FINGERPRINT_MISMATCH")
|
|
self._records[record.signer_id] = record
|
|
|
|
def require(self, signer_id: str, *, role: str) -> SignerRecord:
|
|
record = self._records.get(signer_id)
|
|
if record is None or record.role != role or record.status != "ACTIVE":
|
|
_fail("SIGNER_NOT_ACTIVE")
|
|
return record
|
|
|
|
def records(self) -> tuple[SignerRecord, ...]:
|
|
return tuple(self._records[key] for key in sorted(self._records))
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class SingleHostOwnerPolicy:
|
|
repository: str
|
|
trust_domain: str
|
|
allowed_branch_prefixes: tuple[str, ...]
|
|
allowed_capabilities: frozenset[str]
|
|
allowed_file_prefixes: tuple[str, ...]
|
|
risk_ceiling: str
|
|
production_scope: str
|
|
destructive_policy: str
|
|
maximum_epoch_ttl_seconds: int
|
|
maximum_machine_capability_ttl_seconds: int
|
|
allowed_caller_uids: tuple[int, ...]
|
|
schema: str = POLICY_SCHEMA
|
|
|
|
@classmethod
|
|
def from_path(
|
|
cls,
|
|
path: Path,
|
|
*,
|
|
require_root_owner: bool = True,
|
|
) -> "SingleHostOwnerPolicy":
|
|
value = _strict_json_object(
|
|
_open_secure(
|
|
Path(path),
|
|
private=False,
|
|
require_root_owner=require_root_owner,
|
|
maximum=_MAX_JSON,
|
|
)
|
|
)
|
|
fields = set(cls.__dataclass_fields__)
|
|
if set(value) != fields:
|
|
_fail("OWNER_POLICY_FIELDS_INVALID")
|
|
if value.get("schema") != POLICY_SCHEMA:
|
|
_fail("OWNER_POLICY_SCHEMA_INVALID")
|
|
list_fields = (
|
|
"allowed_branch_prefixes",
|
|
"allowed_capabilities",
|
|
"allowed_file_prefixes",
|
|
"allowed_caller_uids",
|
|
)
|
|
if any(not isinstance(value[name], list) for name in list_fields):
|
|
_fail("OWNER_POLICY_FIELDS_INVALID")
|
|
try:
|
|
policy = cls(
|
|
repository=value["repository"],
|
|
trust_domain=value["trust_domain"],
|
|
allowed_branch_prefixes=tuple(value["allowed_branch_prefixes"]),
|
|
allowed_capabilities=frozenset(value["allowed_capabilities"]),
|
|
allowed_file_prefixes=tuple(value["allowed_file_prefixes"]),
|
|
risk_ceiling=value["risk_ceiling"],
|
|
production_scope=value["production_scope"],
|
|
destructive_policy=value["destructive_policy"],
|
|
maximum_epoch_ttl_seconds=value["maximum_epoch_ttl_seconds"],
|
|
maximum_machine_capability_ttl_seconds=value[
|
|
"maximum_machine_capability_ttl_seconds"
|
|
],
|
|
allowed_caller_uids=tuple(value["allowed_caller_uids"]),
|
|
schema=value["schema"],
|
|
)
|
|
except (KeyError, TypeError) as exc:
|
|
raise SingleHostAuthorityError("OWNER_POLICY_FIELDS_INVALID") from exc
|
|
policy.validate()
|
|
return policy
|
|
|
|
def validate(self) -> None:
|
|
if (
|
|
self.schema != POLICY_SCHEMA
|
|
or not self.repository
|
|
or not self.trust_domain
|
|
or not self.allowed_branch_prefixes
|
|
or not self.allowed_capabilities
|
|
or not self.allowed_file_prefixes
|
|
or self.risk_ceiling not in _RISK
|
|
or not self.production_scope
|
|
or self.destructive_policy not in {"forbidden", "rollback-only"}
|
|
or type(self.maximum_epoch_ttl_seconds) is not int
|
|
or not 3600 <= self.maximum_epoch_ttl_seconds <= 366 * 24 * 3600
|
|
or type(self.maximum_machine_capability_ttl_seconds) is not int
|
|
or not 60 <= self.maximum_machine_capability_ttl_seconds <= 3600
|
|
or not self.allowed_caller_uids
|
|
or any(type(uid) is not int or uid < 0 for uid in self.allowed_caller_uids)
|
|
or any(not _CAP.fullmatch(item) for item in self.allowed_capabilities)
|
|
or any(
|
|
not item.startswith("agent/")
|
|
or "\\" in item
|
|
or ".." in PurePosixPath(item).parts
|
|
for item in self.allowed_branch_prefixes
|
|
)
|
|
or any(
|
|
PurePosixPath(item).is_absolute()
|
|
or "\\" in item
|
|
or ".." in PurePosixPath(item).parts
|
|
or item.startswith(".git")
|
|
for item in self.allowed_file_prefixes
|
|
)
|
|
):
|
|
_fail("OWNER_POLICY_INVALID")
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
return {
|
|
"repository": self.repository,
|
|
"trust_domain": self.trust_domain,
|
|
"allowed_branch_prefixes": list(self.allowed_branch_prefixes),
|
|
"allowed_capabilities": sorted(self.allowed_capabilities),
|
|
"allowed_file_prefixes": list(self.allowed_file_prefixes),
|
|
"risk_ceiling": self.risk_ceiling,
|
|
"production_scope": self.production_scope,
|
|
"destructive_policy": self.destructive_policy,
|
|
"maximum_epoch_ttl_seconds": self.maximum_epoch_ttl_seconds,
|
|
"maximum_machine_capability_ttl_seconds": self.maximum_machine_capability_ttl_seconds,
|
|
"allowed_caller_uids": list(self.allowed_caller_uids),
|
|
"schema": self.schema,
|
|
}
|
|
|
|
@property
|
|
def digest(self) -> str:
|
|
return sha256_json(self.to_dict())
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class SignedArtifact:
|
|
payload: Mapping[str, Any]
|
|
canonical_payload: bytes
|
|
signature: str
|
|
key_id: str
|
|
namespace: str
|
|
|
|
@property
|
|
def payload_sha256(self) -> str:
|
|
return hashlib.sha256(self.canonical_payload).hexdigest()
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class OwnerEpochRequest:
|
|
epoch_id: str
|
|
allowed_branch_prefixes: tuple[str, ...]
|
|
allowed_capabilities: frozenset[str]
|
|
allowed_file_prefixes: tuple[str, ...]
|
|
risk_ceiling: str
|
|
business_goal: str
|
|
production_scope: str
|
|
destructive_policy: str
|
|
ttl_seconds: int
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class OwnerAuthorizationBundle:
|
|
bundle_id: str
|
|
nonce: str
|
|
repository: str
|
|
trust_domain: str
|
|
policy_digest: str
|
|
release_epoch_digest: str
|
|
action_epoch_digest: str
|
|
bootstrap_digest: str
|
|
issued_at: int
|
|
not_before: int
|
|
expires_at: int
|
|
revocation_epoch: int
|
|
status: str
|
|
schema: str = AUTHORIZATION_BUNDLE_SCHEMA
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
return asdict(self)
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class MachineCapabilityRequest:
|
|
capability_id: str
|
|
capability: str
|
|
resource_kind: str
|
|
resource: str
|
|
task_id: str
|
|
release_session_id: str
|
|
generation: int
|
|
risk: str
|
|
ttl_seconds: int
|
|
nonce: str
|
|
|
|
|
|
class LocalFileSigner:
|
|
def __init__(
|
|
self,
|
|
key_path: Path,
|
|
*,
|
|
signer_id: str,
|
|
namespace: str,
|
|
require_root_owner: bool = True,
|
|
) -> None:
|
|
self.key_path = Path(key_path)
|
|
self.signer_id = signer_id
|
|
self.namespace = namespace
|
|
self.require_root_owner = require_root_owner
|
|
|
|
def _key(self) -> Ed25519PrivateKey:
|
|
return _load_private_key(
|
|
self.key_path,
|
|
require_root_owner=self.require_root_owner,
|
|
)
|
|
|
|
@property
|
|
def public_key(self) -> Ed25519PublicKey:
|
|
return self._key().public_key()
|
|
|
|
@property
|
|
def fingerprint(self) -> str:
|
|
return ssh_fingerprint(self.public_key)
|
|
|
|
def _sign_canonical(self, value: Mapping[str, Any]) -> SignedArtifact:
|
|
payload = canonical_json_bytes(dict(value))
|
|
key = self._key()
|
|
signature = sign_sshsig(
|
|
key,
|
|
payload,
|
|
namespace=self.namespace,
|
|
)
|
|
return SignedArtifact(
|
|
payload=dict(value),
|
|
canonical_payload=payload,
|
|
signature=signature,
|
|
key_id=ssh_fingerprint(key.public_key()),
|
|
namespace=self.namespace,
|
|
)
|
|
|
|
|
|
class LocalOwnerAuthority(LocalFileSigner):
|
|
"""Structured Owner signer; arbitrary-byte signing is intentionally absent."""
|
|
|
|
def __init__(
|
|
self,
|
|
key_path: Path,
|
|
policy: SingleHostOwnerPolicy,
|
|
*,
|
|
require_root_owner: bool = True,
|
|
clock: Any = time.time,
|
|
) -> None:
|
|
super().__init__(
|
|
key_path,
|
|
signer_id=DEFAULT_OWNER_SIGNER_ID,
|
|
namespace=OWNER_NAMESPACE,
|
|
require_root_owner=require_root_owner,
|
|
)
|
|
self.policy = policy
|
|
self.clock = clock
|
|
|
|
@staticmethod
|
|
def _prefix_narrows(requested: str, allowed: Sequence[str]) -> bool:
|
|
base = requested.rstrip("/")
|
|
return any(
|
|
base == prefix.rstrip("/")
|
|
or base.startswith(prefix.rstrip("/") + "/")
|
|
or requested.startswith(prefix)
|
|
for prefix in allowed
|
|
)
|
|
|
|
def issue_epoch(self, request: OwnerEpochRequest) -> SignedArtifact:
|
|
now = int(self.clock())
|
|
if (
|
|
not _ID.fullmatch(request.epoch_id)
|
|
or not request.allowed_capabilities
|
|
or not request.allowed_branch_prefixes
|
|
or not request.allowed_file_prefixes
|
|
or not request.allowed_capabilities.issubset(self.policy.allowed_capabilities)
|
|
or any(
|
|
not self._prefix_narrows(item, self.policy.allowed_branch_prefixes)
|
|
for item in request.allowed_branch_prefixes
|
|
)
|
|
or any(
|
|
not self._prefix_narrows(item, self.policy.allowed_file_prefixes)
|
|
for item in request.allowed_file_prefixes
|
|
)
|
|
or request.risk_ceiling not in _RISK
|
|
or _RISK[request.risk_ceiling] > _RISK[self.policy.risk_ceiling]
|
|
or request.production_scope != self.policy.production_scope
|
|
or request.destructive_policy != self.policy.destructive_policy
|
|
or type(request.ttl_seconds) is not int
|
|
or not 60 <= request.ttl_seconds <= self.policy.maximum_epoch_ttl_seconds
|
|
or not request.business_goal
|
|
):
|
|
_fail("OWNER_EPOCH_REQUEST_WIDENS_POLICY")
|
|
|
|
value: dict[str, Any] = {
|
|
"epoch_id": request.epoch_id,
|
|
"owner_identity": OWNER_IDENTITY,
|
|
"trust_domain": self.policy.trust_domain,
|
|
"repository": self.policy.repository,
|
|
"allowed_branch_prefixes": list(request.allowed_branch_prefixes),
|
|
"allowed_capabilities": sorted(request.allowed_capabilities),
|
|
"allowed_file_prefixes": list(request.allowed_file_prefixes),
|
|
"risk_ceiling": request.risk_ceiling,
|
|
"issued_at": now,
|
|
"not_before": max(0, now - 30),
|
|
"expires_at": now + request.ttl_seconds,
|
|
"revocation_epoch": 0,
|
|
"policy_digest": self.policy.digest,
|
|
"business_goal": request.business_goal,
|
|
"production_scope": request.production_scope,
|
|
"destructive_policy": request.destructive_policy,
|
|
"signature_namespace": OWNER_NAMESPACE,
|
|
"signature_key_id": self.fingerprint,
|
|
"status": "ACTIVE",
|
|
"schema": OWNER_EPOCH_SCHEMA,
|
|
}
|
|
value["canonical_payload_digest"] = sha256_json(value)
|
|
return self._sign_canonical(value)
|
|
|
|
def issue_authorization_bundle(
|
|
self,
|
|
*,
|
|
bundle_id: str,
|
|
nonce: str,
|
|
release_epoch_digest: str,
|
|
action_epoch_digest: str,
|
|
bootstrap_digest: str,
|
|
ttl_seconds: int,
|
|
) -> SignedArtifact:
|
|
now = int(self.clock())
|
|
if (
|
|
not _ID.fullmatch(bundle_id)
|
|
or not _ID.fullmatch(nonce)
|
|
or any(
|
|
not _HEX64.fullmatch(item)
|
|
for item in (
|
|
release_epoch_digest,
|
|
action_epoch_digest,
|
|
bootstrap_digest,
|
|
)
|
|
)
|
|
or type(ttl_seconds) is not int
|
|
or not 60 <= ttl_seconds <= self.policy.maximum_epoch_ttl_seconds
|
|
):
|
|
_fail("OWNER_AUTHORIZATION_BUNDLE_INVALID")
|
|
bundle = OwnerAuthorizationBundle(
|
|
bundle_id=bundle_id,
|
|
nonce=nonce,
|
|
repository=self.policy.repository,
|
|
trust_domain=self.policy.trust_domain,
|
|
policy_digest=self.policy.digest,
|
|
release_epoch_digest=release_epoch_digest,
|
|
action_epoch_digest=action_epoch_digest,
|
|
bootstrap_digest=bootstrap_digest,
|
|
issued_at=now,
|
|
not_before=max(0, now - 30),
|
|
expires_at=now + ttl_seconds,
|
|
revocation_epoch=0,
|
|
status="ACTIVE",
|
|
)
|
|
return self._sign_canonical(bundle.to_dict())
|
|
|
|
|
|
class RegistryOwnerApprovalVerifier:
|
|
"""Verifier compatible with current Task admission verifier dependency."""
|
|
|
|
def __init__(
|
|
self,
|
|
registry_path: Path,
|
|
*,
|
|
require_root_owner: bool = True,
|
|
signer_id: str = DEFAULT_OWNER_SIGNER_ID,
|
|
) -> None:
|
|
self.registry_path = Path(registry_path)
|
|
self.require_root_owner = require_root_owner
|
|
self.signer_id = signer_id
|
|
|
|
def verify(
|
|
self,
|
|
*,
|
|
namespace: str,
|
|
payload: bytes,
|
|
signature: str,
|
|
key_id: str,
|
|
) -> bool:
|
|
if namespace != OWNER_NAMESPACE:
|
|
return False
|
|
try:
|
|
registry = SignerRegistry(
|
|
self.registry_path,
|
|
require_root_owner=self.require_root_owner,
|
|
)
|
|
record = registry.require(self.signer_id, role="OWNER_AUTHORITY")
|
|
if record.fingerprint != key_id:
|
|
return False
|
|
public = _load_public_key_line(record.public_key)
|
|
return verify_sshsig(
|
|
public,
|
|
payload,
|
|
signature,
|
|
namespace=namespace,
|
|
expected_key_id=key_id,
|
|
)
|
|
except SingleHostAuthorityError:
|
|
return False
|
|
|
|
|
|
class GrantRootOwnerApprovalVerifier(RegistryOwnerApprovalVerifier):
|
|
"""Zero-argument live adapter loaded by Task admission.
|
|
|
|
The registry path is a fixed machine contract, not an environment variable.
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
super().__init__(
|
|
DEFAULT_REGISTRY_PATH,
|
|
require_root_owner=True,
|
|
signer_id=DEFAULT_OWNER_SIGNER_ID,
|
|
)
|
|
|
|
|
|
class MachineOperationalSigner(LocalFileSigner):
|
|
def __init__(
|
|
self,
|
|
key_path: Path,
|
|
*,
|
|
require_root_owner: bool = True,
|
|
clock: Any = time.time,
|
|
) -> None:
|
|
super().__init__(
|
|
key_path,
|
|
signer_id=DEFAULT_MACHINE_SIGNER_ID,
|
|
namespace=MACHINE_NAMESPACE,
|
|
require_root_owner=require_root_owner,
|
|
)
|
|
self.clock = clock
|
|
|
|
def derive_capability(
|
|
self,
|
|
parent_epoch: Mapping[str, Any],
|
|
request: MachineCapabilityRequest,
|
|
*,
|
|
maximum_ttl_seconds: int,
|
|
) -> SignedArtifact:
|
|
now = int(self.clock())
|
|
required = {
|
|
"epoch_id", "owner_identity", "trust_domain", "repository",
|
|
"allowed_branch_prefixes", "allowed_capabilities",
|
|
"allowed_file_prefixes", "risk_ceiling", "issued_at", "not_before",
|
|
"expires_at", "revocation_epoch", "policy_digest", "business_goal",
|
|
"production_scope", "destructive_policy", "signature_namespace",
|
|
"signature_key_id", "canonical_payload_digest", "status", "schema",
|
|
}
|
|
if not isinstance(parent_epoch, Mapping) or set(parent_epoch) != required:
|
|
_fail("MACHINE_PARENT_EPOCH_INVALID")
|
|
expected_digest = dict(parent_epoch)
|
|
supplied = expected_digest.pop("canonical_payload_digest")
|
|
if (
|
|
parent_epoch["schema"] != OWNER_EPOCH_SCHEMA
|
|
or parent_epoch["status"] != "ACTIVE"
|
|
or parent_epoch["revocation_epoch"] != 0
|
|
or supplied != sha256_json(expected_digest)
|
|
or not int(parent_epoch["not_before"]) <= now < int(parent_epoch["expires_at"])
|
|
):
|
|
_fail("MACHINE_PARENT_EPOCH_INVALID")
|
|
if (
|
|
not _ID.fullmatch(request.capability_id)
|
|
or not _CAP.fullmatch(request.capability)
|
|
or request.capability not in parent_epoch["allowed_capabilities"]
|
|
or request.resource_kind not in {"branch", "file", "production", "action"}
|
|
or not request.resource
|
|
or request.risk not in _RISK
|
|
or _RISK[request.risk] > _RISK[parent_epoch["risk_ceiling"]]
|
|
or type(request.generation) is not int
|
|
or request.generation < 1
|
|
or type(request.ttl_seconds) is not int
|
|
or not 30 <= request.ttl_seconds <= maximum_ttl_seconds
|
|
or now + request.ttl_seconds > int(parent_epoch["expires_at"])
|
|
or not _ID.fullmatch(request.nonce)
|
|
):
|
|
_fail("MACHINE_CAPABILITY_REQUEST_INVALID")
|
|
|
|
if request.resource_kind == "branch":
|
|
if not LocalOwnerAuthority._prefix_narrows(
|
|
request.resource,
|
|
parent_epoch["allowed_branch_prefixes"],
|
|
):
|
|
_fail("MACHINE_CAPABILITY_SCOPE_WIDENING")
|
|
elif request.resource_kind == "file":
|
|
if not LocalOwnerAuthority._prefix_narrows(
|
|
request.resource,
|
|
parent_epoch["allowed_file_prefixes"],
|
|
):
|
|
_fail("MACHINE_CAPABILITY_SCOPE_WIDENING")
|
|
else:
|
|
if request.resource != parent_epoch["production_scope"]:
|
|
_fail("MACHINE_CAPABILITY_SCOPE_WIDENING")
|
|
|
|
value = {
|
|
"schema": MACHINE_CAPABILITY_SCHEMA,
|
|
"capability_id": request.capability_id,
|
|
"parent_epoch_id": parent_epoch["epoch_id"],
|
|
"parent_epoch_digest": hashlib.sha256(
|
|
canonical_json_bytes(dict(parent_epoch))
|
|
).hexdigest(),
|
|
"signer_id": self.signer_id,
|
|
"repository": parent_epoch["repository"],
|
|
"trust_domain": parent_epoch["trust_domain"],
|
|
"capability": request.capability,
|
|
"resource_kind": request.resource_kind,
|
|
"resource": request.resource,
|
|
"task_id": request.task_id,
|
|
"release_session_id": request.release_session_id,
|
|
"generation": request.generation,
|
|
"risk": request.risk,
|
|
"policy_digest": parent_epoch["policy_digest"],
|
|
"issued_at": now,
|
|
"expires_at": now + request.ttl_seconds,
|
|
"nonce": request.nonce,
|
|
}
|
|
return self._sign_canonical(value)
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class ReadinessResult:
|
|
ready: bool
|
|
blockers: tuple[str, ...]
|
|
owner_key_id: str
|
|
machine_key_id: str
|
|
registry_generation: int
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
return asdict(self)
|
|
|
|
|
|
class SignerReadinessGate:
|
|
def __init__(
|
|
self,
|
|
registry: SignerRegistry,
|
|
owner: LocalOwnerAuthority,
|
|
machine: MachineOperationalSigner,
|
|
) -> None:
|
|
self.registry = registry
|
|
self.owner = owner
|
|
self.machine = machine
|
|
|
|
def evaluate(self) -> ReadinessResult:
|
|
blockers: list[str] = []
|
|
owner_record: SignerRecord | None = None
|
|
machine_record: SignerRecord | None = None
|
|
try:
|
|
owner_record = self.registry.require(
|
|
DEFAULT_OWNER_SIGNER_ID,
|
|
role="OWNER_AUTHORITY",
|
|
)
|
|
except SingleHostAuthorityError:
|
|
blockers.append("OWNER_SIGNER_REGISTRY_MISSING")
|
|
try:
|
|
machine_record = self.registry.require(
|
|
DEFAULT_MACHINE_SIGNER_ID,
|
|
role="MACHINE_OPERATIONAL",
|
|
)
|
|
except SingleHostAuthorityError:
|
|
blockers.append("MACHINE_SIGNER_REGISTRY_MISSING")
|
|
|
|
owner_fp = ""
|
|
machine_fp = ""
|
|
try:
|
|
owner_fp = self.owner.fingerprint
|
|
except SingleHostAuthorityError:
|
|
blockers.append("OWNER_SIGNER_KEY_UNAVAILABLE")
|
|
try:
|
|
machine_fp = self.machine.fingerprint
|
|
except SingleHostAuthorityError:
|
|
blockers.append("MACHINE_SIGNER_KEY_UNAVAILABLE")
|
|
|
|
if owner_record is not None and owner_fp and owner_record.fingerprint != owner_fp:
|
|
blockers.append("OWNER_SIGNER_IDENTITY_DRIFT")
|
|
if machine_record is not None and machine_fp and machine_record.fingerprint != machine_fp:
|
|
blockers.append("MACHINE_SIGNER_IDENTITY_DRIFT")
|
|
if owner_fp and machine_fp and owner_fp == machine_fp:
|
|
blockers.append("OWNER_MACHINE_KEY_REUSE_FORBIDDEN")
|
|
if (
|
|
owner_record is not None
|
|
and machine_record is not None
|
|
and (
|
|
machine_record.parent_signer_id != owner_record.signer_id
|
|
or machine_record.parent_fingerprint != owner_record.fingerprint
|
|
)
|
|
):
|
|
blockers.append("MACHINE_PARENT_BINDING_INVALID")
|
|
|
|
challenge = canonical_json_bytes(
|
|
{
|
|
"schema": "aios.signer-readiness-challenge.v1",
|
|
"registry_generation": self.registry.authority_generation,
|
|
"nonce": "readiness-" + sha256_json(
|
|
{"owner": owner_fp, "machine": machine_fp}
|
|
)[:32],
|
|
}
|
|
)
|
|
if owner_record is not None and owner_fp:
|
|
try:
|
|
signature = sign_sshsig(
|
|
self.owner._key(),
|
|
challenge,
|
|
namespace=OWNER_NAMESPACE,
|
|
)
|
|
if not verify_sshsig(
|
|
_load_public_key_line(owner_record.public_key),
|
|
challenge,
|
|
signature,
|
|
namespace=OWNER_NAMESPACE,
|
|
expected_key_id=owner_record.fingerprint,
|
|
):
|
|
blockers.append("OWNER_SIGNER_CHALLENGE_FAILED")
|
|
except SingleHostAuthorityError:
|
|
blockers.append("OWNER_SIGNER_CHALLENGE_FAILED")
|
|
|
|
if machine_record is not None and machine_fp:
|
|
try:
|
|
signature = sign_sshsig(
|
|
self.machine._key(),
|
|
challenge,
|
|
namespace=MACHINE_NAMESPACE,
|
|
)
|
|
if not verify_sshsig(
|
|
_load_public_key_line(machine_record.public_key),
|
|
challenge,
|
|
signature,
|
|
namespace=MACHINE_NAMESPACE,
|
|
expected_key_id=machine_record.fingerprint,
|
|
):
|
|
blockers.append("MACHINE_SIGNER_CHALLENGE_FAILED")
|
|
except SingleHostAuthorityError:
|
|
blockers.append("MACHINE_SIGNER_CHALLENGE_FAILED")
|
|
|
|
ordered = tuple(dict.fromkeys(blockers))
|
|
return ReadinessResult(
|
|
ready=not ordered,
|
|
blockers=ordered,
|
|
owner_key_id=owner_fp,
|
|
machine_key_id=machine_fp,
|
|
registry_generation=self.registry.authority_generation,
|
|
)
|
|
|
|
|
|
class BootstrapRetirementStore:
|
|
def __init__(
|
|
self,
|
|
path: Path,
|
|
*,
|
|
require_root_owner: bool = True,
|
|
clock: Any = time.time,
|
|
) -> None:
|
|
self.path = Path(path)
|
|
self.require_root_owner = require_root_owner
|
|
self.clock = clock
|
|
|
|
def state(self) -> dict[str, Any]:
|
|
if not self.path.exists():
|
|
return {
|
|
"schema": BOOTSTRAP_STATE_SCHEMA,
|
|
"retired": False,
|
|
"consumed_generation": 0,
|
|
"retired_at": 0,
|
|
"receipt_digest": "",
|
|
}
|
|
return _strict_json_object(
|
|
_open_secure(
|
|
self.path,
|
|
private=False,
|
|
require_root_owner=self.require_root_owner,
|
|
maximum=_MAX_JSON,
|
|
)
|
|
)
|
|
|
|
def retire(self, *, generation: int, receipt_digest: str) -> dict[str, Any]:
|
|
if type(generation) is not int or generation < 1 or not _HEX64.fullmatch(receipt_digest):
|
|
_fail("BOOTSTRAP_RETIREMENT_INPUT_INVALID")
|
|
current = self.state()
|
|
expected_fields = {
|
|
"schema", "retired", "consumed_generation", "retired_at",
|
|
"receipt_digest",
|
|
}
|
|
if set(current) != expected_fields or current.get("schema") != BOOTSTRAP_STATE_SCHEMA:
|
|
_fail("BOOTSTRAP_STATE_INVALID")
|
|
if current["retired"]:
|
|
if (
|
|
current["consumed_generation"] == generation
|
|
and current["receipt_digest"] == receipt_digest
|
|
):
|
|
return current
|
|
_fail("BOOTSTRAP_ALREADY_RETIRED")
|
|
value = {
|
|
"schema": BOOTSTRAP_STATE_SCHEMA,
|
|
"retired": True,
|
|
"consumed_generation": generation,
|
|
"retired_at": int(self.clock()),
|
|
"receipt_digest": receipt_digest,
|
|
}
|
|
_atomic_write(
|
|
self.path,
|
|
canonical_json_bytes(value),
|
|
mode=0o600,
|
|
require_root_owner=self.require_root_owner,
|
|
create_only=False,
|
|
)
|
|
readback = self.state()
|
|
if readback != value:
|
|
_fail("BOOTSTRAP_RETIREMENT_READBACK_MISMATCH")
|
|
return value
|
|
|
|
def assert_legacy_allowed(self) -> None:
|
|
if self.state().get("retired") is True:
|
|
_fail("LEGACY_BOOTSTRAP_RETIRED")
|
|
|
|
|
|
def bootstrap_key(
|
|
path: Path,
|
|
*,
|
|
require_root_owner: bool = True,
|
|
) -> Ed25519PublicKey:
|
|
"""Create one Ed25519 key exactly once, or validate the existing key."""
|
|
|
|
path = Path(path)
|
|
if path.exists():
|
|
return _load_private_key(
|
|
path,
|
|
require_root_owner=require_root_owner,
|
|
).public_key()
|
|
_ensure_secure_directory(
|
|
path.parent,
|
|
require_root_owner=require_root_owner,
|
|
create=True,
|
|
)
|
|
key = Ed25519PrivateKey.generate()
|
|
pem = key.private_bytes(
|
|
serialization.Encoding.PEM,
|
|
serialization.PrivateFormat.PKCS8,
|
|
serialization.NoEncryption(),
|
|
)
|
|
_atomic_write(
|
|
path,
|
|
pem,
|
|
mode=0o600,
|
|
require_root_owner=require_root_owner,
|
|
create_only=True,
|
|
private_parent=True,
|
|
)
|
|
loaded = _load_private_key(
|
|
path,
|
|
require_root_owner=require_root_owner,
|
|
)
|
|
if loaded.public_key().public_bytes(
|
|
serialization.Encoding.Raw,
|
|
serialization.PublicFormat.Raw,
|
|
) != key.public_key().public_bytes(
|
|
serialization.Encoding.Raw,
|
|
serialization.PublicFormat.Raw,
|
|
):
|
|
_fail("KEY_BOOTSTRAP_READBACK_MISMATCH")
|
|
return loaded.public_key()
|
|
|
|
|
|
def write_signer_registry(
|
|
path: Path,
|
|
*,
|
|
owner_public_key: Ed25519PublicKey,
|
|
machine_public_key: Ed25519PublicKey,
|
|
authority_generation: int,
|
|
owner_socket: str = "/run/aios/owner-authority.sock",
|
|
machine_socket: str = "/run/aios/machine-signer.sock",
|
|
require_root_owner: bool = True,
|
|
) -> None:
|
|
if (
|
|
type(authority_generation) is not int
|
|
or authority_generation < 1
|
|
or ssh_fingerprint(owner_public_key) == ssh_fingerprint(machine_public_key)
|
|
):
|
|
_fail("SIGNER_REGISTRY_BOOTSTRAP_INVALID")
|
|
owner_record = SignerRecord(
|
|
signer_id=DEFAULT_OWNER_SIGNER_ID,
|
|
role="OWNER_AUTHORITY",
|
|
provider="local_unix_service",
|
|
fingerprint=ssh_fingerprint(owner_public_key),
|
|
public_key=ssh_public_line(owner_public_key, DEFAULT_OWNER_SIGNER_ID),
|
|
socket=owner_socket,
|
|
secret_ref="aios-secret://single-host/local-owner-authority",
|
|
status="ACTIVE",
|
|
generation=authority_generation,
|
|
)
|
|
machine_record = SignerRecord(
|
|
signer_id=DEFAULT_MACHINE_SIGNER_ID,
|
|
role="MACHINE_OPERATIONAL",
|
|
provider="local_unix_service",
|
|
fingerprint=ssh_fingerprint(machine_public_key),
|
|
public_key=ssh_public_line(machine_public_key, DEFAULT_MACHINE_SIGNER_ID),
|
|
socket=machine_socket,
|
|
secret_ref="aios-secret://single-host/machine-operational",
|
|
status="ACTIVE",
|
|
generation=authority_generation,
|
|
parent_signer_id=owner_record.signer_id,
|
|
parent_fingerprint=owner_record.fingerprint,
|
|
)
|
|
value = {
|
|
"schema": REGISTRY_SCHEMA,
|
|
"authority_generation": authority_generation,
|
|
"signers": [owner_record.to_dict(), machine_record.to_dict()],
|
|
}
|
|
path = Path(path)
|
|
desired = canonical_json_bytes(value)
|
|
if path.exists():
|
|
existing = _open_secure(
|
|
path,
|
|
private=False,
|
|
require_root_owner=require_root_owner,
|
|
maximum=_MAX_JSON,
|
|
)
|
|
if existing != desired:
|
|
_fail("SIGNER_REGISTRY_EXISTING_MISMATCH")
|
|
return
|
|
_atomic_write(
|
|
path,
|
|
desired,
|
|
mode=0o644,
|
|
require_root_owner=require_root_owner,
|
|
create_only=True,
|
|
private_parent=False,
|
|
)
|
|
|
|
|
|
def install_owner_policy(
|
|
source: Path,
|
|
destination: Path,
|
|
*,
|
|
expected_policy_digest: str,
|
|
require_root_owner: bool = True,
|
|
) -> SingleHostOwnerPolicy:
|
|
"""Install one exact policy once; never silently replace an existing policy."""
|
|
|
|
source = Path(source)
|
|
try:
|
|
raw = source.read_bytes()
|
|
except OSError as exc:
|
|
raise SingleHostAuthorityError("OWNER_POLICY_SOURCE_UNAVAILABLE") from exc
|
|
value = _strict_json_object(raw)
|
|
# Validate through a temporary private file so policy parsing uses the same
|
|
# implementation as production while allowing an explicit candidate source.
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
temp_root = Path(directory)
|
|
if os.name == "posix":
|
|
os.chmod(temp_root, 0o700)
|
|
temp_policy = temp_root / "policy.json"
|
|
temp_policy.write_bytes(canonical_json_bytes(value))
|
|
if os.name == "posix":
|
|
os.chmod(temp_policy, 0o644)
|
|
candidate = SingleHostOwnerPolicy.from_path(
|
|
temp_policy,
|
|
require_root_owner=False,
|
|
)
|
|
if candidate.digest != expected_policy_digest:
|
|
_fail("OWNER_POLICY_DIGEST_MISMATCH")
|
|
destination = Path(destination)
|
|
desired = canonical_json_bytes(candidate.to_dict())
|
|
if destination.exists():
|
|
existing = _open_secure(
|
|
destination,
|
|
private=False,
|
|
require_root_owner=require_root_owner,
|
|
maximum=_MAX_JSON,
|
|
)
|
|
if existing != desired:
|
|
_fail("OWNER_POLICY_EXISTING_MISMATCH")
|
|
return SingleHostOwnerPolicy.from_path(
|
|
destination,
|
|
require_root_owner=require_root_owner,
|
|
)
|
|
_atomic_write(
|
|
destination,
|
|
desired,
|
|
mode=0o644,
|
|
require_root_owner=require_root_owner,
|
|
create_only=True,
|
|
private_parent=False,
|
|
)
|
|
return SingleHostOwnerPolicy.from_path(
|
|
destination,
|
|
require_root_owner=require_root_owner,
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class SingleHostBootstrapResult:
|
|
owner_key_id: str
|
|
machine_key_id: str
|
|
policy_digest: str
|
|
registry_generation: int
|
|
readiness: Mapping[str, Any]
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
return {
|
|
"owner_key_id": self.owner_key_id,
|
|
"machine_key_id": self.machine_key_id,
|
|
"policy_digest": self.policy_digest,
|
|
"registry_generation": self.registry_generation,
|
|
"readiness": dict(self.readiness),
|
|
"private_key_exported": False,
|
|
"ambient_signer_dependencies": False,
|
|
}
|
|
|
|
|
|
def bootstrap_single_host_authority(
|
|
*,
|
|
owner_state_directory: Path,
|
|
machine_state_directory: Path,
|
|
registry_path: Path,
|
|
policy_source: Path,
|
|
policy_destination: Path,
|
|
expected_policy_digest: str,
|
|
authority_generation: int,
|
|
require_root_owner: bool = True,
|
|
) -> SingleHostBootstrapResult:
|
|
"""Create/validate local authority state and prove readiness.
|
|
|
|
The operation is idempotent for exact existing state. Any identity or policy
|
|
mismatch fails closed instead of rotating keys during a release.
|
|
"""
|
|
|
|
if not _HEX64.fullmatch(expected_policy_digest):
|
|
_fail("OWNER_POLICY_DIGEST_INVALID")
|
|
policy = install_owner_policy(
|
|
policy_source,
|
|
policy_destination,
|
|
expected_policy_digest=expected_policy_digest,
|
|
require_root_owner=require_root_owner,
|
|
)
|
|
owner_key_path = Path(owner_state_directory) / "owner-authority.key"
|
|
machine_key_path = Path(machine_state_directory) / "machine-operational.key"
|
|
owner_public = bootstrap_key(
|
|
owner_key_path,
|
|
require_root_owner=require_root_owner,
|
|
)
|
|
machine_public = bootstrap_key(
|
|
machine_key_path,
|
|
require_root_owner=require_root_owner,
|
|
)
|
|
write_signer_registry(
|
|
registry_path,
|
|
owner_public_key=owner_public,
|
|
machine_public_key=machine_public,
|
|
authority_generation=authority_generation,
|
|
require_root_owner=require_root_owner,
|
|
)
|
|
registry = SignerRegistry(
|
|
registry_path,
|
|
require_root_owner=require_root_owner,
|
|
)
|
|
owner = LocalOwnerAuthority(
|
|
owner_key_path,
|
|
policy,
|
|
require_root_owner=require_root_owner,
|
|
)
|
|
machine = MachineOperationalSigner(
|
|
machine_key_path,
|
|
require_root_owner=require_root_owner,
|
|
)
|
|
readiness = SignerReadinessGate(registry, owner, machine).evaluate()
|
|
if not readiness.ready:
|
|
_fail("SIGNER_READINESS_FAILED:" + ",".join(readiness.blockers))
|
|
return SingleHostBootstrapResult(
|
|
owner_key_id=readiness.owner_key_id,
|
|
machine_key_id=readiness.machine_key_id,
|
|
policy_digest=policy.digest,
|
|
registry_generation=readiness.registry_generation,
|
|
readiness=readiness.to_dict(),
|
|
)
|
|
|
|
|
|
def write_signed_epoch_directory(
|
|
root: Path,
|
|
artifact: SignedArtifact,
|
|
*,
|
|
require_root_owner: bool = True,
|
|
) -> None:
|
|
"""Write the exact files already consumed by SignedEpochDirectoryProvider."""
|
|
|
|
root = Path(root)
|
|
_ensure_secure_directory(
|
|
root,
|
|
require_root_owner=require_root_owner,
|
|
create=True,
|
|
private=False,
|
|
)
|
|
for name, data, mode in (
|
|
("owner-delegation-epoch.canonical.json", artifact.canonical_payload, 0o644),
|
|
("owner-delegation-epoch.sig", artifact.signature.encode("utf-8"), 0o644),
|
|
("owner-delegation-epoch.key-id", (artifact.key_id + "\n").encode("utf-8"), 0o644),
|
|
):
|
|
_atomic_write(
|
|
root / name,
|
|
data,
|
|
mode=mode,
|
|
require_root_owner=require_root_owner,
|
|
create_only=False,
|
|
private_parent=False,
|
|
)
|
|
|
|
|
|
def module_has_ambient_signer_dependencies() -> bool:
|
|
"""Static self-check used by readiness/tests."""
|
|
|
|
forbidden = {
|
|
"SSH_AUTH_SOCK",
|
|
"USERPROFILE",
|
|
"HOME",
|
|
"ssh-agent",
|
|
"ssh-add",
|
|
}
|
|
source = Path(__file__).read_text(encoding="utf-8")
|
|
# Ignore this function's own literal names by limiting the scan to the
|
|
# portion before its definition.
|
|
source = source.split("def module_has_ambient_signer_dependencies", 1)[0]
|
|
return any(item in source for item in forbidden)
|