From 4e86b1533d644c8ef40f8b5962abb90dfced5c7b Mon Sep 17 00:00:00 2001 From: ZDDC Date: Thu, 4 Jun 2026 09:06:49 -0500 Subject: [PATCH] docs(server): rewrite README apps section for the local-only override model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 → /.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; /.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) --- zddc/README.md | 170 +++++++++++++++---------------------------------- 1 file changed, 50 insertions(+), 120 deletions(-) diff --git a/zddc/README.md b/zddc/README.md index 7b3b882..97889b2 100644 --- a/zddc/README.md +++ b/zddc/README.md @@ -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-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 - `/_app//[:]/`. (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 `.html` at a path — or an `.html` member of the site + `/.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 `/.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 `/_app//[:]/` 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 `.html` member of the site + `/.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 `/.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. `.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 - (`.sig`) alongside every real file. Private key path comes - from `ZDDC_SIGNING_KEY`; absent or unreadable → release fails. - Symlinks (the canonical `.html` and `zddc-server_` - 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 `.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 `.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.` 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.zip`** — a local zip whose `.html` + members override the embedded default site-wide (and let you add new + `.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 -`/_app//[:]/`. 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:@`. +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:.html` or `embedded:@` (an on-disk override is served +by the static handler with its own headers). ### Example -```yaml -# /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 && zip .zddc.zip browse.html report.html +``` + +Or override a single tool in one project by dropping a file: + +```sh +cp our-browse.html /Project-A/browse.html ``` ### Env vars