generated from coulomb/repo-seed
- 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>
165 lines
5.6 KiB
Python
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
|