bastion agent 0.1.14
This commit is contained in:
@@ -0,0 +1,238 @@
|
||||
"""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: <out>/sealed/<id>-<stamp>.tar.age, <out>/sealed/latest.json (a
|
||||
pointer, not a symlink — Windows), <out>/seal.summary.json. Optional
|
||||
detached signature with the host's key: SEAL_SIGN_KEY (PEM path) →
|
||||
<file>.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
|
||||
Reference in New Issue
Block a user