ops-warden/src/warden/ca.py

215 lines
7.5 KiB
Python
Raw Normal View History

2026-03-28 00:45:43 +00:00
"""CA backends for OpsWarden: LocalCA (ssh-keygen) and abstract base."""
from __future__ import annotations
import json
2026-03-28 00:45:43 +00:00
import os
import shutil
import subprocess
import tempfile
from abc import ABC, abstractmethod
from datetime import datetime, timezone
from pathlib import Path
from typing import List, Optional
from warden.models import CertRecord, CertSpec, MAX_TTL_HOURS
2026-03-28 00:45:43 +00:00
class CAError(Exception):
"""Raised when a CA operation fails."""
class CABackend(ABC):
@abstractmethod
def sign(self, spec: CertSpec) -> CertRecord:
"""Sign the public key in spec and return a CertRecord."""
...
def _enforce_ttl(spec: CertSpec) -> None:
"""Raise CAError if spec.ttl_hours exceeds the type maximum (directive §2)."""
max_h = MAX_TTL_HOURS[spec.actor_type]
if spec.ttl_hours > max_h:
raise CAError(
f"TTL {spec.ttl_hours}h exceeds maximum {max_h}h for actor type "
f"{spec.actor_type.value!r} (AccessManagementDirective §2)"
)
def _evict_cert(actor_name: str, state_dir: Path) -> None:
"""Remove the existing cert for actor_name from state_dir, if present."""
cert_path = state_dir / f"{actor_name}-cert.pub"
cert_path.unlink(missing_ok=True)
def _append_signature_log(
record: CertRecord, spec: CertSpec, state_dir: Path, backend: str
) -> None:
"""Append one JSONL line to state_dir/signatures.log."""
entry = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"actor": spec.actor_name,
"actor_type": spec.actor_type.value,
"identity": record.identity,
"principals": record.principals,
"ttl_hours": spec.ttl_hours,
"valid_before": record.valid_before.isoformat(),
"cert_path": str(record.cert_path),
"backend": backend,
}
state_dir.mkdir(parents=True, exist_ok=True)
with (state_dir / "signatures.log").open("a") as f:
f.write(json.dumps(entry) + "\n")
2026-03-28 00:45:43 +00:00
def parse_cert_metadata(cert_path: Path) -> dict:
"""Parse ssh-keygen -L output into identity, valid_before, and principals.
Note: ssh-keygen displays timestamps without explicit timezone; we treat them
as UTC, consistent with how ssh-keygen internally stores certificate validity.
"""
result = subprocess.run(
["ssh-keygen", "-L", "-f", str(cert_path)],
capture_output=True,
text=True,
)
if result.returncode != 0:
raise CAError(f"ssh-keygen -L failed: {result.stderr.strip()}")
identity: Optional[str] = None
valid_before: Optional[datetime] = None
valid_from: Optional[datetime] = None
2026-03-28 00:45:43 +00:00
principals: List[str] = []
in_principals = False
for line in result.stdout.splitlines():
stripped = line.strip()
if stripped.startswith("Key ID:"):
# Key ID: "agt-state-hub-bridge"
raw = stripped.split(":", 1)[1].strip()
identity = raw.strip('"')
elif stripped.startswith("Valid:"):
# Valid: from 2026-03-28T10:00:00 to 2026-03-29T10:00:00
parts = stripped.split(" to ", 1)
if len(parts) == 2:
ts_str = parts[1].strip()
try:
dt = datetime.fromisoformat(ts_str)
valid_before = dt.replace(tzinfo=timezone.utc)
except ValueError:
pass
from_parts = parts[0].split("from ", 1)
if len(from_parts) == 2:
try:
dt_from = datetime.fromisoformat(from_parts[1].strip())
valid_from = dt_from.replace(tzinfo=timezone.utc)
except ValueError:
pass
2026-03-28 00:45:43 +00:00
elif stripped == "Principals:":
in_principals = True
elif in_principals:
if stripped and ":" not in stripped and stripped != "(none)":
2026-03-28 00:45:43 +00:00
principals.append(stripped)
else:
in_principals = False
if valid_before is None:
raise CAError(
f"Could not parse valid_before from cert at {cert_path}. "
f"Ensure the cert has a valid TTL."
)
return {
"identity": identity or "",
"valid_before": valid_before,
"valid_from": valid_from,
2026-03-28 00:45:43 +00:00
"principals": principals,
}
class LocalCA(CABackend):
"""File-based CA using ssh-keygen. Requires the CA private key on disk."""
def __init__(self, ca_key: Path, state_dir: Path) -> None:
self._ca_key = Path(os.path.expanduser(str(ca_key)))
self._state_dir = Path(os.path.expanduser(str(state_dir)))
def sign(self, spec: CertSpec) -> CertRecord:
"""Sign the public key in spec. Returns a CertRecord; cert saved to state_dir."""
_enforce_ttl(spec)
2026-03-28 00:45:43 +00:00
pubkey = Path(os.path.expanduser(str(spec.pubkey_path)))
if not pubkey.exists():
raise CAError(f"Public key not found: {pubkey}")
if not self._ca_key.exists():
raise CAError(f"CA key not found: {self._ca_key}")
principals_str = ",".join(spec.principals)
with tempfile.TemporaryDirectory() as tmpdir:
tmpdir_path = Path(tmpdir)
pubkey_copy = tmpdir_path / "key.pub"
shutil.copy2(pubkey, pubkey_copy)
# ssh-keygen -s writes cert to <input_stem>-cert.pub
cert_path_tmp = tmpdir_path / "key-cert.pub"
cmd = [
"ssh-keygen",
"-s", str(self._ca_key),
"-I", spec.identity,
"-n", principals_str,
"-V", f"+{spec.ttl_hours}h",
str(pubkey_copy),
]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
raise CAError(f"Signing failed: {result.stderr.strip()}")
if not cert_path_tmp.exists():
raise CAError(
f"Expected cert not written after signing: {cert_path_tmp}. "
f"ssh-keygen stderr: {result.stderr.strip()}"
)
meta = parse_cert_metadata(cert_path_tmp)
self._state_dir.mkdir(parents=True, exist_ok=True)
_evict_cert(spec.actor_name, self._state_dir)
2026-03-28 00:45:43 +00:00
dest = self._state_dir / f"{spec.actor_name}-cert.pub"
shutil.copy2(cert_path_tmp, dest)
record = CertRecord(
2026-03-28 00:45:43 +00:00
identity=meta["identity"] or spec.identity,
valid_before=meta["valid_before"],
cert_path=dest,
signed_at=datetime.now(timezone.utc),
principals=meta["principals"],
actor_name=spec.actor_name,
)
_append_signature_log(record, spec, self._state_dir, "local")
return record
2026-03-28 00:45:43 +00:00
def generate_keypair(self, actor_name: str) -> tuple[Path, Path]:
"""Generate an ed25519 keypair for an actor.
Returns (privkey_path, pubkey_path). Overwrites existing files.
"""
key_dir = self._state_dir / "keys"
key_dir.mkdir(parents=True, exist_ok=True)
privkey = key_dir / f"{actor_name}_ed25519"
pubkey = key_dir / f"{actor_name}_ed25519.pub"
for p in (privkey, pubkey):
if p.exists():
p.unlink()
cmd = [
"ssh-keygen", "-t", "ed25519",
"-f", str(privkey),
"-N", "", # no passphrase
"-C", actor_name,
]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
raise CAError(f"Key generation failed: {result.stderr.strip()}")
return privkey, pubkey