Metadata-Version: 2.4
Name: aac-trust-anchor-publisher
Version: 0.1.1
Summary: AAC tenant-side trust-anchor publisher daemon: pushes root-key JWK Sets to the AAC control plane.
Author: Agent Authority Cloud Project
License-Expression: Apache-2.0
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: cryptography>=42.0
Requires-Dist: pyjwt>=2.8
Requires-Dist: httpx>=0.28
Provides-Extra: test
Requires-Dist: pytest>=8.0; extra == "test"
Dynamic: license-file

# aac-trust-anchor-publisher

The tenant-side trust-anchor publisher daemon (Eng Spec §XVI component
map). It runs **inside the tenant boundary**, one instance per tenant
(single-writer; `replicas: 1` per §X — HA via leader election is a V2
concern), and keeps the AAC control plane's copy of the tenant's trust
anchors current:

1. Read the tenant's **root signing public keys** from a
   `RootKeyPublisherSource` (filesystem in Stage 2; the §IX.5 KMS
   adapters — Vault Transit, AWS KMS, Azure Key Vault, GCP KMS — are
   backlogged pilot work).
2. Package them as a canonical RFC 7517 **JWK Set**.
3. Sign an **`AAC-Tenant-Admin` JWS** (compact, `typ
   aac-tenant-admin+jwt`, EdDSA) with the tenant-admin private key.
4. `POST /v1/root-keys/ingest` on the control plane — at startup and
   whenever the key set changes.

Since Week 11 the SAME daemon also carries the **spiffe-bundle role**
(the §IX.5 `TrustBundleSource` role — there is no separate SPIFFE
publisher): it reads the tenant's SPIFFE CA anchors from a
`SpiffeBundlePublisherSource` (filesystem in Stage 2; SPIRE/KMS
adapters are backlogged pilot work), packages them as the unified
trust-anchor bundle JSON (Workload Identity Bootstrap §7), signs the
same `AAC-Tenant-Admin` JWS with `artifact_class: spiffe_bundle`, and
`POST /v1/spiffe-bundle/ingest`s at startup + on change.

The two roles are PEERS (B105): each activates via its own
both-or-neither env-var pair, and a tenant configures only the roles it
plays — root-keys for a tenant that MINTS chain roots, spiffe-bundle for
a tenant whose workloads PRESENT. A forwarding-only tenant (e.g.
JPMorgan in Wedge A) runs a spiffe-bundle-only publisher. Configuring
zero roles is a boot error.

Verifiers never talk to the publisher: sidecars fetch the published
artifacts from the control plane's public `GET
/.well-known/aac-root-keys/{tenant_id}` and `GET
/.well-known/spiffe-bundle/{tenant_id}`.

Terminology: JWSs are **signed/created** — "mint" is reserved for
macaroon/token operations in AAC vocabulary.

## What this daemon holds (and doesn't)

* It holds the **tenant-admin private key** — the credential that
  authenticates publishes to the control plane. That is the ONLY
  private key it touches.
* It reads **public** root-key material only. Root signing private
  keys never pass through it (and with KMS sources, even the admin JWS
  should eventually be signed by the KMS's sign API — see Backlog).
* The control plane never sees any tenant private key, ever.

## Configuration (env vars — control-plane idiom, §XVI posture (f))

Always required:

| Variable | Meaning |
| --- | --- |
| `AAC_TAP_TENANT_ID` | This tenant's id (the JWS `iss` claim). |
| `AAC_TAP_ADMIN_KEY_FILE` | Path to the tenant-admin Ed25519 private key PEM. |

Root-keys role (both-or-neither; setting exactly one is a boot error):

| Variable | Meaning |
| --- | --- |
| `AAC_TAP_ROOT_KEYS_DIR` | Directory of `<key_id>.pub.pem` root signing public keys. |
| `AAC_TAP_ROOT_KEYS_INGEST_URL` | Full root-keys ingest URL, e.g. `https://aac-cloud.example/v1/root-keys/ingest`. |

Spiffe-bundle role (both-or-neither; setting exactly one is a boot
error):

| Variable | Meaning |
| --- | --- |
| `AAC_TAP_SPIFFE_BUNDLE_DIR` | Directory of `<anchor_id>.ca.pem` SPIFFE CA certificates. |
| `AAC_TAP_SPIFFE_BUNDLE_INGEST_URL` | Full spiffe-bundle ingest URL, e.g. `https://aac-cloud.example/v1/spiffe-bundle/ingest`. |

At least one role must be configured (zero roles = boot error, exit 2).
The env-var shape is `AAC_TAP_<ROLE>_<THING>`, with the role prefix
matching the wire `artifact_class` (`root_keys` / `spiffe_bundle`).

Optional: `AAC_TAP_POLL_INTERVAL_SECONDS` (60),
`AAC_TAP_REQUEST_TIMEOUT_SECONDS` (10), `AAC_TAP_LOG_LEVEL` (info).

The admin public key must be registered with the control plane first
(`tenant_admin_pubkey_pem` on `POST /v1/tenants`, rotate via `PATCH
/v1/tenants/{tenant_id}`); the daemon derives its own `kid`
(`tadm_` + SPKI fingerprint) offline — no registration round-trip.

## Key directory convention

`<key_id>.pub.pem`, one file per root signing public key (Ed25519, or
EC/P-256 for the §2.1 KMS-fallback case). The whole stem is the key_id
— dots allowed. This differs deliberately from the VERIFIER-side
sidecar convention `<tenant_id>.<key_id>.pub.pem`: the publisher's
directory holds one tenant's own keys, so filenames don't carry a
tenant id (config does).

Rotation workflow: drop the new `<key_id>.pub.pem` alongside the old
one (transition set — §2.1 rule 4 requires retaining at least one
previously-active key), wait a poll interval, then delete the old file;
the control plane serves the removed key as DEPRECATED for the 24h
grace window.

## Bundle directory convention (spiffe-bundle role)

`<anchor_id>.ca.pem`, one file per SPIFFE CA certificate (must parse,
carry BasicConstraints CA=TRUE, and be unexpired — invalid files are
skipped with a warning). The whole stem is the anchor_id. The
filesystem source reports `source: "filesystem"` and derives
`spiffe_id_pattern` as `spiffe://<tenant_id>/*` — per-anchor patterns
arrive with the real SPIRE/KMS adapters. CA rotation is
whole-document: drop the new CA alongside the old (one bundle carries
both), then delete the old file once downstream SVIDs have rolled —
no server-side grace window (the bundle replaces atomically; overlap
is expressed IN the document).

## Installing (two channels, same daemon — B115)

> Step-by-step tenant-operator runbook (both channels, systemd setup,
> success checks): [`INSTALL.md`](INSTALL.md).

The daemon releases in **dual mode**; pick the channel that matches
your deployment:

* **Container** (Kubernetes / Docker Compose tenants):

  ```bash
  docker pull ghcr.io/cascadeauth/aac-trust-anchor-publisher:latest
  ```

* **pip** (non-container hosts — the daemon then runs under systemd,
  see below):

  ```bash
  pip install aac-trust-anchor-publisher
  ```

  Installing the wheel also generates the `aac-trust-anchor-publisher`
  console command (equivalent to `python -m trust_anchor_publisher`).

Repo developers use neither: `uv run python -m trust_anchor_publisher`
from the workspace.

## Running

```bash
python -m trust_anchor_publisher        # repo / module form
aac-trust-anchor-publisher              # pip-installed console command
```

Either form runs the same long-lived foreground process: forced publish
at startup, then poll-and-republish-on-change forever. It does NOT
run-once-and-exit — in a terminal it sits until Ctrl-C; in production
a supervisor (systemd or a container runtime) owns its lifecycle.

Container: bind-mount the key directory and admin key read-only and set
the env vars. The daemon needs outbound HTTPS to the control plane and
no inbound ports.

Boot posture: local misconfiguration (bad env, unreadable/non-Ed25519
admin key) exits 2 immediately; an unreachable or rejecting control
plane is survivable — the poll loop is the retry mechanism.

## Deploying as a systemd service (non-container hosts)

The publisher is a "new-style daemon": it deliberately stays a plain
foreground process (no self-forking, no pidfile, stdout/stderr
logging) and lets systemd supply the daemonization — start at boot,
restart on failure, journal capture. A complete, commented example
unit ships at
[`systemd/aac-trust-anchor-publisher.service`](systemd/aac-trust-anchor-publisher.service);
the short version:

```bash
# one-time setup (as root)
python -m venv /opt/aac/venv
/opt/aac/venv/bin/pip install aac-trust-anchor-publisher
useradd --system --home /var/lib/aac aac
# write /etc/aac/trust-anchor-publisher.env with the AAC_TAP_* vars (chmod 600)
# the DAEMON user (not systemd) opens the admin key — make it readable:
chown root:aac /etc/aac/keys/tenant-admin.pem
chmod 640 /etc/aac/keys/tenant-admin.pem
cp trust_anchor_publisher/systemd/aac-trust-anchor-publisher.service /etc/systemd/system/  # from the repo root (or copy from GitHub)
systemctl daemon-reload
systemctl enable --now aac-trust-anchor-publisher
journalctl -u aac-trust-anchor-publisher -f
```

Unit-file details worth keeping: `Restart=on-failure` with
`RestartPreventExitStatus=2` (exit 2 = local misconfiguration — a
restart loop cannot fix a missing env var or an unreadable admin key;
fail visibly instead of flapping), `EnvironmentFile=` for the
`AAC_TAP_*` config, a dedicated non-root `User=aac`, and read-only
sandboxing (`ProtectSystem=strict` — the daemon never writes the
filesystem).

## Sequence strategy (recorded trade-off)

Sequences are timestamp-derived (`max(unix_now, last_sent + 1)`) —
stateless across restarts, no control-plane read surface (Week 10 PR-B
design Q1, option c). Known limits, accepted for Stage 2 and backlogged
(medium-high, pre-pilot): a clock stepped backward past the last
accepted value stalls publishing with `ERR_INGEST_SEQUENCE_STALE` until
wall time catches up; concurrent publisher instances for one tenant
have no coordination (single-writer deployment is the §X contract).
The upgrade path — an authoritative last-sequence read + increment —
needs no wire-contract change.

## Parity copy notice

`ingest_jws.py` deliberately duplicates the signing half of
`control_plane/ingest_auth.py`: the two deployables never import each
other (§XVI). `tests/test_ingest_jws_parity.py` enforces byte-identical
output; change one side and the test names the drift.
