Files
Privatae 3ae3a2841c
ci / personal-data (push) Successful in 3s
ci / test (push) Successful in 45s
ci / reproducible (push) Successful in 3s
bastion agent 0.1.17
2026-10-29 21:42:06 +00:00

92 lines
4.4 KiB
Markdown

# bastion agent `seal` stage: the catalog in one encrypted bundle
`seal` is the last stage of a run: it packs an allowlisted bundle of the
catalog into one file, encrypted with age to the owner and the manifest's named
recipients. It writes the bundle into the output folder and sends it nowhere.
`serve` answers queries with cards and excerpts, unsealed
([serve.md](serve.md)). It is `bastion_agent/seal.py`.
## What it does
`python3 -m bastion_agent seal <manifest> [--out DIR] [--require-signed]`
builds the bundle and encrypts it to the owner and the manifest's
`sink.seal_to` recipients:
```
<out>/sealed/<id>-<stamp>.tar.age the envelope (0600)
<out>/sealed/<id>-<stamp>.tar.age.sig optional detached signature
<out>/sealed/latest.json pointer to the newest envelope
<out>/seal.summary.json sizes, digests, recipients, members
```
`python3 -m bastion_agent unseal <file.tar.age> --identity <age key> --dest DIR`
is the owner's side: it decrypts, refuses unsafe paths, and checks every
member's SHA-256 against the inventory the sealer wrote inside the bundle.
## What is in the bundle, and what cannot be
| Member | Content |
|---|---|
| `manifest.json` | The manifest as executed (signature block removed). |
| `catalog.jsonl`, `catalog.dirs.jsonl` | The cards: sanitized text only. |
| `<stage>.summary.json` | The earlier stages' summaries: counts, digests, tool and model names. |
| `seal.manifest.json` | The inventory: sha256 and size per member, the manifest digest, the time of sealing. |
The bundle is built from an **allowlist**: the two catalog files
(`BUNDLE_ALLOW`), the stage summaries (names matching `<stage>.summary.json`),
and the two files the sealer writes itself. There is no code path that adds
the extract store, the sanitized store, the vault, `vectors.db`, or any other
`*.jsonl` index. A test opens the bundle and checks exactly that, and searches
it for the fixture's personal data. The tar is built with fixed member times
and a fixed order, so the same catalog sealed at the same moment is the same
plaintext.
## Recipients
- **Owner**: `FREEAGENT_OWNER_RECIPIENT`, an `age1…` public key, from the
environment. Mandatory: no owner recipient, no seal. It is never in the
manifest, because the manifest is written by the host and the owner's key is
the machine's business.
- **`sink.seal_to`**: each entry is an `age1…` recipient or an `ssh-ed25519`
or `ssh-rsa` public-key line (age accepts both). Any other string is refused
before anything is written.
- Duplicates collapse; the owner is always first.
- **Grantees are not recipients.** A `grants` entry lets another host *query*
this agent; it never receives the sealed catalog. Sharing is access, not a
copy.
## Why age
age (X25519 and ChaCha20-Poly1305) is a small, widely reviewed format with a
single static binary for Mac, Windows and Linux. The envelope cannot be made
with the standard library (it has no X25519 and no ChaCha20-Poly1305), so,
unlike the extract stage's optional tools, **age is required here**: without
it no bundle is written at all, and the stage says so and exits 1.
## Signature (optional)
`SEAL_SIGN_KEY=<path to a PEM private key>` adds a detached
`openssl dgst -sha256` signature over the envelope, so a receiving host can
check who sealed it. The signature covers that one envelope (age encrypts
differently every time), not the plaintext. The stage only produces the
signature; checking it against a certificate is the receiver's job. A test
proves it verifies against the public half.
## Tests
`python3 -m unittest bastion_agent.test_seal` covers: no owner recipient,
refused with nothing written; malformed recipients refused and named; no `age`
on PATH, refused; `seal` not in the stages, refused; the bundle allowlist
(present members, forbidden members absent, no values, sanitized placeholders
present); reproducible plaintext; a round trip with two real recipients and a
stranger refused; owner and recipient de-duplication and an ECDSA P-256
signature that verifies with openssl; and a tampered member refused on unseal.
The round-trip tests are skipped where `age` or `age-keygen` is not installed.
## Where it sits
walk → extract → sanitize → chunk → embed → catalog → seal, each usable on its
own and each with its own document here. The runtime around them (the `sweep`
scheduler and the manifest gate) is [runtime.md](runtime.md); the query
endpoint is [serve.md](serve.md).