# ADR-142 — The Secrets Capsule Is Served Over the Web, Without a Passphrase, Behind a Signed Token and an Arming Window

| | |
|---|---|
| **Status** | Accepted |
| **Date** | 2026-08-19 |
| **Decision maker** | hkl — *"secret without paraphase as PWA we are designing itself is auth gated."* |
| **Applies to** | `provision-proxy` (`/opt/provision-proxy`), the `provision` PWA, and the vault at `/opt/provision-vault`. See [[project_provision_pwa]]. |

## Context

Provisioning a designer machine or a replacement DL previously meant walking a pen drive over:
`D:\handoff-student\` plus a `secrets-onetime.otc` capsule carried hand to hand. The capsule holds
`.claude.json`, the VPS SSH private key, five Gmail accounts' OAuth tokens and three sidecar
bearers — it is **account-equivalent to whoever holds it**, by design: its AES key is embedded in
the file, so its encryption buys tamper-evidence, not secrecy.

hkl asked for a PWA that delivers the whole handover over the web, capsule included, so a machine
can be provisioned without physical transport.

## The decision, and the wrong turn that produced it

The initial recommendation was **do not serve the capsule at all** — have the PWA carry only the
non-secret material (bundle, bootstrap `.cmd`, handoff prompt, parity report) and orchestrate the
capsule out of band. hkl overrode it, on the stated ground that the PWA is itself auth gated.

**The override was correct — but only after the premise was repaired**, and that repair is the
actual content of this decision. Two measurements showed "auth gated" did not yet mean anything:

1. **A file under `/var/www/360lm` has no gate whatsoever.** `360lm-web` is `httpd:alpine` serving
   static files; the hub's `lm360-session` check is browser JavaScript. `curl .../provision/x.otc`
   would have returned the bytes with no login and no log.
2. **The `Authorization: Hub <base64>` header several sibling proxies accept is unsigned.**
   `hub_media_proxy.py:_parse_hub_session()` base64-decodes JSON and checks only `empId` presence
   and `loginAt` age. Anyone can mint one.

So the decision as implemented is:

- The capsule lives in **`/opt/provision-vault/` — outside the web root**, 700, files 600. Every
  byte leaves only through `provision-proxy`.
- That endpoint accepts **only a fail-closed HS256 Bearer** (ADR-105, `verify_hub_jwt`, the secret
  `hub.login` signs with). **No `Hub <b64>` fallback is implemented.**
- Two independent authorisations, both required: the `provision` group in the JWT's `groups` claim
  (from `hub.employee_pwa_access`) **and** membership of the `PROVISION_ACTORS` env allow-list.
- **Arming.** A capsule is downloadable only inside a window opened at publish time from DL
  (`-ArmHours`, default 24 h). Outside it, `410`. The capsule's own expiry is enforced server-side
  too and **beats** arming. This is not a passphrase — the operator types nothing extra — but it
  collapses exposure from the capsule's 7–14 day life to the length of one handover.
- Every fetch and every denial is appended to `/opt/provision-vault/audit.log` (JSONL: actor, ts,
  ua, sha256, bytes).
- **No capsule is parked between handovers.** Pack and arm at the moment of use.

Verified, not assumed: no auth / forged `Hub` header / tampered signature / expired token → **401**;
valid token without the group, or a non-allow-listed actor → **403**; a real capsule while disarmed
→ **410**; an allow-listed actor inside the window → **200**, logged. The last case is the positive
control — a gate that refused everything would have looked identical to a working one.

## Consequences

**The PIN is now the entire secret.** During an armed window, anyone holding a `harish` or `pramod`
hub PIN can fetch account-equivalent material including VPS root SSH. That is the residual risk hkl
owns, and it is the reason arming exists rather than a permanently-available endpoint.

Adding a provisioner requires **two** edits — a `hub.employee_pwa_access` row and a
`PROVISION_ACTORS` line in `/root/360lm-web/docker-compose.yml` plus a container recreate. This is
deliberate (neither a stale DB row nor a stale env var alone opens the door) and is the likely cause
of any future "why is X refused when the database looks right".

## Alternatives rejected

- **Passphrase-locked capsule** (scrypt + AES-256-GCM, the original `secrets_capsule.py` variant,
  passphrase out of band). Rejected by hkl: operator burden, and the login is meant to be the gate.
- **Serve nothing secret; orchestrate only.** Rejected: it leaves the 2026-08-17 migration failure
  unaddressed, where the capsule was precisely the thing that did not arrive and the migration
  "succeeded" while broken.
- **Static file plus a Traefik deny router** (the `hubmediablock`/ADR-049 pattern). Not needed here
  — that pattern exists because hub media *must* live under the web root. A vault outside it is
  strictly stronger, so this ADR does not reuse it.
- **Per-IP rate limiting on the endpoint.** Infeasible on this host: Docker SNAT masks the client
  address (every external caller arrives as `172.18.0.1`, measured 2026-07-20). Throttling is
  per-empId instead.

## Revisit If

- The PIN policy changes, a PIN is shared, or PINs stop being per-person.
- ADR-105's rollout changes what mints tokens, or `hub.login`'s claim shape changes (it currently
  returns `access_token` / `refresh_token` / `expires_in`, `exp` = 1800 s).
- The audit log ever shows a capsule fetch by an actor, at a time, or from a client that was not
  expected.
- A second service on this box starts handling credential-bearing material, or there is any plan to
  expose `provision-proxy` outside Traefik — see the deliberate non-change below.
- The capsule's contents grow to include anything not already account-equivalent (it cannot get
  much worse, but it could get broader).

## Considered and deliberately NOT changed

**`provision-proxy` runs Flask's development server**, like every other Python sidecar on this box
(`hub-media-proxy`, `recce-upload-proxy`, `sales-proxy`, `ocr-proxy`, `ai-finance-proxy`). It is
unreachable except through Traefik, which terminates TLS. Diverging one service to gunicorn
unilaterally is a larger change than the risk warrants, and a fleet-wide change is its own ADR.
**Trigger to revisit:** a second credential-bearing sidecar, or any plan to expose this one directly.

## Does NOT govern

The bundle, the bootstrap `.cmd`, the handoff prompt and the parity report. Those are non-secret
(the bundle passes a value-shape secret scan, a name-based cross-check and a PII content scan before
publishing) and are gated only for tidiness and audit, not because their contents require it.
