"""bastion agent `seal` stage — encrypt the catalog to the owner (and named recipients) so it can leave the machine. What is sealed — and ONLY this — is an allowlisted bundle: manifest.json the manifest as executed (minus signature) catalog.jsonl file cards catalog.dirs.jsonl directory cards *.summary.json every stage's summary — counts, digests, tool names; never a value seal.manifest.json bundle inventory with sha256 per member Never in the bundle, by construction (the allowlist is the mechanism, a test is the proof): the extract store (plaintext), the sanitized store, the vault, vectors.db, chunks. `sink.plaintext: never` is enforced here one more time by not having a code path that could add those files. The envelope is age (X25519 + ChaCha20-Poly1305): a small, widely reviewed format with a single static binary on Mac/Windows/Linux. It is REQUIRED for this stage — the stdlib cannot produce the envelope, and a catalog that cannot be sealed does not leave. Recipients: owner FREEAGENT_OWNER_RECIPIENT (an age1… public key), from the environment, never the manifest. Always present. seal_to manifest sink.seal_to — each entry an age1… recipient (or an ssh-ed25519/ssh-rsa public key line, which age accepts). Grantees (manifest `grants`) are NOT recipients: they query the container over the mesh; they never receive the sealed catalog. Output: /sealed/-.tar.age, /sealed/latest.json (a pointer, not a symlink — Windows), /seal.summary.json. Optional detached signature with the host's key: SEAL_SIGN_KEY (PEM path) → .sig via openssl, so a receiving host can check who sealed it once the runtime's CA verification lands. """ import datetime as _dt import hashlib import io import json import os import re import shutil import subprocess import tarfile import tempfile from .manifest import signing_digest SEAL_VERSION = 1 AGE_RECIPIENT_RE = re.compile(r"^(age1[a-z0-9]{58}|ssh-(ed25519|rsa) [A-Za-z0-9+/=]+( .*)?)$") BUNDLE_ALLOW = ("catalog.jsonl", "catalog.dirs.jsonl") SUMMARY_GLOB_RE = re.compile(r"^[a-z]+\.summary\.json$") TOOL_TIMEOUT = 300 class SealError(Exception): """Fail closed: nothing leaves.""" def tool_path(name): return shutil.which(name) def run(argv, data=None, timeout=TOOL_TIMEOUT): try: proc = subprocess.run(argv, input=data, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=timeout, check=False) except (OSError, subprocess.TimeoutExpired) as exc: raise SealError("%s: %s" % (argv[0], exc)) if proc.returncode != 0: err = proc.stderr.decode("utf-8", "replace").strip().splitlines() raise SealError("%s exited %d: %s" % (argv[0], proc.returncode, err[-1] if err else "")) return proc.stdout def recipients_for(manifest, env): owner = env.get("FREEAGENT_OWNER_RECIPIENT", "").strip() if not owner: raise SealError("FREEAGENT_OWNER_RECIPIENT is not set — the owner's age public key " "is the mandatory recipient; refusing to seal to nobody") recips = [owner] + list(manifest.sink.seal_to) bad = [r for r in recips if not AGE_RECIPIENT_RE.match(r)] if bad: raise SealError("not an age recipient (age1… or ssh-ed25519/ssh-rsa public key): %s" % ", ".join(r[:24] + "…" if len(r) > 24 else r for r in bad)) seen = set() out = [] for r in recips: if r not in seen: seen.add(r) out.append(r) return out def _sha256_file(path): h = hashlib.sha256() with open(path, "rb") as fh: for buf in iter(lambda: fh.read(1024 * 1024), b""): h.update(buf) return h.hexdigest() def build_bundle(manifest, out_dir, stamp): """The plaintext tar, in memory. Returns (bytes, inventory).""" members = [] for name in BUNDLE_ALLOW: path = os.path.join(out_dir, name) if not os.path.exists(path): raise SealError("no %s in %s — run the catalog stage first" % (name, out_dir)) members.append(name) for name in sorted(os.listdir(out_dir)): if SUMMARY_GLOB_RE.match(name) and name != "seal.summary.json": members.append(name) inventory = {"freeagent": 1, "seal_version": SEAL_VERSION, "manifest_id": manifest.id, "manifest_digest": "sha256:" + signing_digest(manifest.raw), "sealed_at": stamp, "members": {}} buf = io.BytesIO() with tarfile.open(fileobj=buf, mode="w") as tar: def add_bytes(name, data): info = tarfile.TarInfo(name) info.size = len(data) info.mtime = 0 # reproducible: the content is what is signed, not the clock info.mode = 0o600 tar.addfile(info, io.BytesIO(data)) inventory["members"][name] = {"sha256": hashlib.sha256(data).hexdigest(), "bytes": len(data)} body = {k: v for k, v in manifest.raw.items() if k != "signature"} add_bytes("manifest.json", json.dumps(body, sort_keys=True, indent=2, default=str).encode("utf-8")) for name in members: with open(os.path.join(out_dir, name), "rb") as fh: add_bytes(name, fh.read()) add_bytes("seal.manifest.json", json.dumps(inventory, sort_keys=True, indent=2).encode("utf-8")) return buf.getvalue(), inventory def seal(manifest, out_dir, env=None, now=None): if "seal" not in manifest.stages: raise SealError("'seal' is not in this manifest's stages") env = dict(os.environ) if env is None else env age = tool_path("age") if not age: raise SealError("`age` is not on PATH — the envelope cannot be produced, nothing leaves " "(install age: https://age-encryption.org, one static binary)") recips = recipients_for(manifest, env) stamp = now or _dt.datetime.now(_dt.timezone.utc).strftime("%Y%m%dT%H%M%SZ") plain, inventory = build_bundle(manifest, out_dir, stamp) sealed_dir = os.path.join(out_dir, "sealed") os.makedirs(sealed_dir, exist_ok=True) fname = "%s-%s.tar.age" % (manifest.id, stamp) target = os.path.join(sealed_dir, fname) argv = [age, "--encrypt"] for r in recips: argv += ["-r", r] argv += ["-o", target + ".tmp"] run(argv, data=plain) os.replace(target + ".tmp", target) try: os.chmod(target, 0o600) except OSError: pass signature = None sign_key = env.get("SEAL_SIGN_KEY", "").strip() if sign_key: openssl = tool_path("openssl") if not openssl: raise SealError("SEAL_SIGN_KEY set but openssl is not on PATH") sig = run([openssl, "dgst", "-sha256", "-sign", sign_key, target]) with open(target + ".sig", "wb") as fh: fh.write(sig) signature = os.path.basename(target) + ".sig" summary = { "freeagent": 1, "stage": "seal", "seal_version": SEAL_VERSION, "manifest_id": manifest.id, "manifest_digest": inventory["manifest_digest"], "sealed_at": stamp, "file": os.path.join("sealed", fname), "bytes_plain": len(plain), "bytes_sealed": os.path.getsize(target), "sha256_plain": hashlib.sha256(plain).hexdigest(), "sha256_sealed": _sha256_file(target), "recipients": recips, # public keys — safe to record "members": sorted(inventory["members"]), "signature": signature, "catalog_sink": manifest.sink.catalog, } pointer = {"file": summary["file"], "sha256_sealed": summary["sha256_sealed"], "sealed_at": stamp, "manifest_digest": summary["manifest_digest"]} _atomic_write(os.path.join(sealed_dir, "latest.json"), json.dumps(pointer, indent=2, sort_keys=True) + "\n") _atomic_write(os.path.join(out_dir, "seal.summary.json"), json.dumps(summary, indent=2, sort_keys=True) + "\n") return summary def unseal(sealed_path, identity_path, dest_dir): """Owner-side: open a sealed bundle with an age identity file into dest_dir. Verifies every member against the inventory.""" age = tool_path("age") if not age: raise SealError("`age` is not on PATH") plain = run([age, "--decrypt", "-i", identity_path, sealed_path]) os.makedirs(dest_dir, exist_ok=True) with tarfile.open(fileobj=io.BytesIO(plain), mode="r") as tar: names = tar.getnames() if any(n.startswith("/") or ".." in n.split("/") for n in names): raise SealError("bundle contains an unsafe path") inv_member = tar.extractfile("seal.manifest.json") if inv_member is None: raise SealError("bundle has no seal.manifest.json") inventory = json.loads(inv_member.read().decode("utf-8")) for name in names: data = tar.extractfile(name).read() if name != "seal.manifest.json": want = inventory["members"].get(name, {}).get("sha256") if want != hashlib.sha256(data).hexdigest(): raise SealError("member %s does not match the inventory" % name) with open(os.path.join(dest_dir, name), "wb") as fh: fh.write(data) return inventory def _atomic_write(path, text): d = os.path.dirname(path) os.makedirs(d, exist_ok=True) fd, tmp = tempfile.mkstemp(prefix=".tmp-", dir=d) try: with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as fh: fh.write(text) os.replace(tmp, path) except BaseException: try: os.unlink(tmp) except OSError: pass raise