Files
ops-warden/src/warden/ca.py
tegwick 42ca370085 feat(bootstrap): WARDEN-WP-0001 initial implementation — 42 tests passing
- LocalCA: ssh-keygen -s signing, keypair generation, cert parsing via ssh-keygen -L
- VaultCA: Vault SSH engine backend via httpx
- Inventory: YAML actor registry with ActorType, principals, TTL policy
- Scorecard: four cert-side compliance checks (prefixes, principals, no expired/stale)
- CLI: sign (cert_command interface), issue, status, scorecard, inventory subcommands
- ops-ssh-wrapper: acquire cert and exec SSH command
- Fix: principal parser stops at section headers containing ':' (Critical Options, Extensions)
- Move WARDEN-WP-0001 workplan from ops-bridge; register repo in state-hub (74df727e)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 13:27:49 +02:00

165 lines
5.6 KiB
Python

"""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 <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)
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