generated from coulomb/repo-seed
Initial Commit
This commit is contained in:
164
src/warden/ca.py
Normal file
164
src/warden/ca.py
Normal file
@@ -0,0 +1,164 @@
|
||||
"""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 stripped.endswith(":") 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
|
||||
Reference in New Issue
Block a user