"""CA backends for OpsWarden: LocalCA (ssh-keygen) and abstract base.""" from __future__ import annotations 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 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 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 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 elif stripped == "Principals:": in_principals = True elif in_principals: if stripped and ":" not in stripped and stripped != "(none)": 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, "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.""" 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 -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) dest = self._state_dir / f"{spec.actor_name}-cert.pub" shutil.copy2(cert_path_tmp, dest) return CertRecord( 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, ) 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