docs(server): rewrite README apps section for the local-only override model

Brings zddc/README.md in line with the apps-fetch removal:
- "Apps: virtual tool HTMLs" override section now describes the 3-tier local
  resolution (on-disk file → <root>/.zddc.zip member → embedded); drops the
  URL/channel/version spec forms, the _app/ cache, signatures, and the apps:
  example.
- Remove the ZDDC_APPS_PUBKEY env-var row.
- Security invariants #4 (in two places) reframed: a tool HTML on disk or in
  the site .zddc.zip is a full UI mount (write access = UI-mounting authority;
  <root>/.zddc.zip = site-wide), with no fetch and nothing to sign.
- Federal gap analysis: SI-7 no longer cites apps URL fetches; delete the
  whole "Code-signed apps: URL fetches (NIST SI-7)" subsection (feature gone).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-06-04 09:06:49 -05:00
parent 4eeb25c0ef
commit 4e86b1533d

View file

@ -61,7 +61,6 @@ There is no Containerfile / Dockerfile / compose file in this repo. Two ways to
| `ZDDC_OPA_URL` | `internal` | Policy decider endpoint. `internal` = built-in Go evaluator (default). `http(s)://...` or `unix:///...` = external OPA-compatible server (federal deployments using their own audited Rego). See "External policy decider" below. |
| `ZDDC_OPA_FAIL_OPEN` | *(empty)* | External OPA only. `1` = on transport error, allow the request (availability over correctness). Default = fail closed (deny). Never set to `1` in federal contexts. |
| `ZDDC_OPA_CACHE_TTL` | `1s` | External OPA only. Per-decision cache TTL — bursts of identical queries (a single `.archive` listing can hit the same `(email, dir)` tuple many times) collapse to one OPA round-trip. Set `0` to disable. Format is Go's `time.ParseDuration` (`500ms`, `2s`, `1m`). |
| `ZDDC_APPS_PUBKEY` | *(empty)* | Path to a PEM-encoded Ed25519 public key used to verify signatures on URL-fetched `apps:` artifacts. Empty (default) = URL-fetched apps refused; only embedded + local-path apps work. Operators using zddc.varasys.io's canonical channels download `pubkey.pem` from there and pass the local path here. Operators with their own signing infrastructure pass their own public key. Same posture as `ZDDC_TLS_CERT` — zddc-server bakes nothing in. **Alternative inline form:** the same key can be pasted as `apps_pubkey:` in the root `<ZDDC_ROOT>/.zddc` (root-only, like `admins:`). The env/flag wins when both are set. |
| `ZDDC_LOG_LEVEL` | `info` | Log level: `debug`, `info`, `warn`, `error` |
| `ZDDC_INDEX_PATH` | `.archive` | URL path segment name for the virtual archive index |
| `ZDDC_EMAIL_HEADER` | `X-Auth-Request-Email` | HTTP request header containing the authenticated user's email (the oauth2-proxy / nginx auth-request convention) |
@ -672,14 +671,15 @@ naive intuition suggests.
at `zddc/internal/zddc/file.go:17-20` (and `IsAdmin`) only reads root. This is
the only upward-escalation gate; subtree write access never grants admin.
4. **An `apps:` URL override is a full UI mount, not just a tool version pin.**
Any `.zddc` writer in a subtree can pin `archive: https://attacker.example/...`
and serve arbitrary HTML to every viewer below that level. Subtree write
authority on `.zddc` should be treated as full UI-mounting authority. The
`_app/` cache is fetch-once-and-keep — operators clear it by deleting
`<ZDDC_ROOT>/_app/<scheme>/<host>[:<port>]/<path>`. (See "Apps: virtual tool HTMLs" below for
the resolver order; SHA-256 pinning is on the federal-readiness list, not
currently implemented.)
4. **Dropping a tool HTML on disk is a full UI mount, not just a file.**
A real `<app>.html` at a path — or an `<app>.html` member of the site
`<ZDDC_ROOT>/.zddc.zip` bundle — is served verbatim to every viewer at or
below that scope. So write access to a directory is effectively UI-mounting
authority for it, and write access to `<root>/.zddc.zip` is a **site-wide**
UI mount (treat it like `admins:` — keep the root writable only by admins).
There is no remote fetch and nothing to sign: the bytes are whatever sits on
the local filesystem, governed by the same ACL/WORM as any other file. (See
"Apps: virtual tool HTMLs" below for the resolver order.)
5. **Relying on `/Archive/` being unbrowsable to "hide" sibling vendor folders'
existence.** Sibling-vendor names are hidden because directories the caller
@ -742,10 +742,12 @@ guarantee these for the model above to hold:
logging, ship the JSON-line file to an external append-only sink (syslog,
SIEM) via a sidecar; do not treat the local rotation as the system of
record.
4. **`apps:` URL fetches have no integrity check.** Fetched once on first
miss, cached at `<ZDDC_ROOT>/_app/<scheme>/<host>[:<port>]/<path>` forever — no SHA-256 pin,
no signature. Use only URLs you control, treat the apps cache as a trust
boundary, and audit who has `.zddc` write authority where.
4. **Tool-HTML overrides are local files, not fetched/signed.** A tool's HTML
comes from a real file at the path, an `<app>.html` member of the site
`<ZDDC_ROOT>/.zddc.zip`, or the embedded default — never the network. There
is nothing to verify; the trust boundary is filesystem write access. Audit
who can write tool HTML at each scope (especially `<root>/.zddc.zip`, a
site-wide UI mount).
### Debugging permissions
@ -949,9 +951,9 @@ have to redo the gap analysis from scratch.
subcommand. See §"Policy export for change control" below.
- **Supply-chain integrity** (NIST SI-7) — vendored libs (jszip,
docx-preview, xlsx) need SBOM, CVE tracking, automated update pipeline.
`apps:` URL fetches need code signing (operator trusts a published
public key once; no per-artifact hash management). See §"Code-signed
apps: URL fetches" below.
Tool HTML is no longer fetched at runtime (overrides are local files /
the site `.zddc.zip` bundle, governed by filesystem ACL), so there is no
remote-artifact signing requirement here.
- **Data-at-rest encryption** (NIST SC-28) — delegated to the deployment
platform. Required: documented baseline (cloud KMS, LUKS, dm-crypt) with
key-rotation procedures.
@ -962,11 +964,10 @@ have to redo the gap analysis from scratch.
A full SSP / control-by-control mapping consumes this list as input; it is
not a substitute for one.
The four bullets most likely to need engineering depth — FIPS, the
authenticated proxy channel, policy export, and signed `apps:` URL
fetches — have their own subsections below capturing the design
considerations and effort estimates so a future implementor doesn't
restart from zero.
The bullets most likely to need engineering depth — FIPS, the
authenticated proxy channel, and policy export — have their own
subsections below capturing the design considerations and effort
estimates so a future implementor doesn't restart from zero.
#### FIPS-validated cryptography (NIST SC-13)
@ -1164,77 +1165,6 @@ match — small graph problem), and the format renderers.
**Effort estimate:** ~250 lines of Go (CLI subcommand + equivalence-
class computation + JSON/Markdown/CSV renderers) + ~100 lines of tests.
#### Code-signed `apps:` URL fetches (NIST SI-7)
**The supply-chain risk today.** The `apps:` mechanism fetches a URL
once on first request, caches the bytes forever, and serves them to
every viewer below that level. There's no integrity check. If the
fetched URL is ever compromised — DNS hijack, CDN account takeover,
malicious upstream commit, MITM during the one fetch window — every
customer caches the bad bytes. The blast radius is "every user who
visits an archive page in a subtree where this `.zddc` applies."
**Why code signing instead of SHA-256 pinning.** SHA-256 pinning would
require operators to track-and-update a hash in `.zddc` every time an
artifact changes. Wrong workflow for this product. Code signing
sidesteps the operator entirely:
- Release pipeline signs each artifact once at publish time with a
long-lived private key.
- Operator trusts the published public key once and never deals with
hashes.
- zddc-server fetches the URL, downloads the detached signature
(e.g. `<artifact>.sig`), verifies against the configured public key,
caches if valid.
**Implementation has three parts** that interlock:
1. **Signing in the build pipeline.** `./build release` runs
`sign_release_artifacts` (in `./build`) after promote: walks
`dist/release-output/` and produces a detached Ed25519 signature
(`<artifact>.sig`) alongside every real file. Private key path comes
from `ZDDC_SIGNING_KEY`; absent or unreadable → release fails.
Symlinks (the canonical `<tool>.html` and `zddc-server_<platform>`
URLs) skip — the .sig at the symlink target is what counts; a
companion `.sig` symlink chains the canonical URL to that target.
2. **Public key on the website.** `pubkey.pem` is a real file in
`~/src/zddc-website/`, deployed to `zddc.varasys.io/pubkey.pem`.
The releases-page index includes a "Verify your downloads"
section with a download link, the SHA-256 fingerprint shown in
plain text, and a `curl + openssl pkeyutl -verify` example.
3. **Verifier in zddc-server (`apps/fetch.go`).** When fetching a
URL-pinned `apps:` artifact, also fetch `<url>.sig`, then call
`VerifyEd25519` against `Fetcher.VerifyKey`. The key is loaded
at startup with this resolution order:
1. `--apps-pubkey` / `ZDDC_APPS_PUBKEY` (path to PEM file)
2. `apps_pubkey:` inline PEM in the root `.zddc` file (root-only,
same trust-anchor treatment as `admins:`)
3. nothing → URL fetches refused
Failure cases at fetch time — sig 404, transport error, wrong key,
tampered body — all reject; the body is dropped and the apps
resolver falls back to the embedded copy. No baked-in default
public key; same posture as TLS certificates.
**Trust model.** The operator decides which signing infrastructure to
trust by configuring `--apps-pubkey`. The website publishes the
canonical-channel public key; operators who use `apps: archive: stable`
download it once, save the file on their server, and configure the
path. Operators running their own signing point at their own pubkey
instead. zddc-server has no opinion.
**Future direction.** A `signed_by:` field per-`apps:` entry would let
a single deployment trust multiple signing keys (one for canonical
channels, one for an in-house mirror). Sigstore integration
(transparency-log-backed signing via `github.com/sigstore/sigstore`)
is the federally-acceptable evolution. Both are additive on top of
the current single-key-per-server model.
**What's currently in place.** All three parts. The scaffolding
matches the design above one-to-one; future enhancements are
extensions, not refactors.
### External policy decider (OPA-compatible)
For deployments that need policy decisions made by an external,
@ -1521,8 +1451,8 @@ fsnotify watcher's debounce window (~2 s) — no service restart needed.
`zddc-server` virtually serves the tool HTMLs (archive, transmittal,
classifier, landing, browse, form, tables) at the appropriate paths.
The current-stable build of each tool is **baked into the binary at
compile time** via `//go:embed`; that's the default. No fetch happens
out of the box.
compile time** via `//go:embed`; that's the default. Overrides are
**local only** — there is no network fetch, ever.
### Where each tool is served
@ -1536,40 +1466,40 @@ out of the box.
Outside these locations, the corresponding `<app>.html` URL returns 404.
### Override and version-pin
### Override (local only)
For any path, the resolution order is:
1. **Real file at the path**operator drops `archive.html` (or any other)
into a directory; the static handler serves it. Beats everything below.
2. **Closer-to-leaf `.zddc apps:` entry** — walks `.zddc` files leaf→root
for an `apps.<app>` entry. The first match wins. Spec is one of:
- `stable` (canonical upstream "current stable")
- `v0.0.4` (canonical upstream exact-version pin)
- `https://...` (full URL to a custom mirror)
- `./local.html` / `/abs/path.html` (local file)
1. **Real file at the path**drop a real `archive.html` (or `browse.html`,
or a brand-new `mytool.html`) into a directory; the static handler serves
it. Beats everything below.
2. **Site bundle `<ZDDC_ROOT>/.zddc.zip`** — a local zip whose `<app>.html`
members override the embedded default site-wide (and let you add new
`<name>.html` tools). The server reads members from the filesystem via
`internal/zipfs` — no fetch, no signature. The bundle is re-stat'd on each
request, so dropping in a new one takes effect immediately.
3. **Embedded** — the build-time HTML compiled into the binary.
URL sources are fetched once on first request and cached forever in
`<ZDDC_ROOT>/_app/<scheme>/<host>[:<port>]/<path>`. There is no background refresh and no
hash verification — to pull a new build, delete the cache file. Concurrent
misses for the same URL share one outbound fetch (singleflight). Direct
URL access to `/_app/...` is blocked at dispatch; cached HTMLs are served
only via the apps resolver.
If a configured URL fetch fails (network down, 5xx), the server falls back
to the embedded copy and emits a one-time WARN log per source. The
`X-ZDDC-Source` response header always reports what was served:
`fetch:URL`, `cache:URL`, `path:/abs`, or `embedded:<app>@<build>`.
There is no `apps:` `.zddc` key, no channels/versions, no URL fetching, and no
signature verification — all removed in favour of this local model. `.zddc.zip`
is config, not content: a direct `GET /.zddc.zip` returns 404 for everyone,
while the server reads its members internally (so resolution works for any
user). The `X-ZDDC-Source` response header reports what was served:
`bundle:<app>.html` or `embedded:<app>@<build>` (an on-disk override is served
by the static handler with its own headers).
### Example
```yaml
# <ZDDC_ROOT>/Project-A/.zddc
apps:
classifier: v0.0.4 # pin classifier to v0.0.4 for this project
archive: https://my-mirror.internal/zddc/archive_v0.0.4.html # custom mirror, pinned
browse: ./our-browse.html # local fork
Override `browse` everywhere and add a custom `report` tool via the bundle:
```sh
cd <ZDDC_ROOT> && zip .zddc.zip browse.html report.html
```
Or override a single tool in one project by dropping a file:
```sh
cp our-browse.html <ZDDC_ROOT>/Project-A/browse.html
```
### Env vars