docs(server): apps composer on releases page; deeper federal-readiness gap analysis

Two doc/website improvements:

build:341 build_releases_index() — new "Build your apps: block" section
between the pinning narrative and the channels explainer. Per-app
dropdowns (one each for archive/transmittal/classifier/mdedit/landing),
a live-updating YAML textarea, and a Copy button. The dropdowns clone
their options from the existing #version-picker (channels at top,
pinned versions below) so we don't duplicate version data into JS —
the picker is the single source of truth for "what versions exist."
~80 lines of HTML+JS added; no SHA-256 anywhere (per user direction
that code signing is the future supply-chain answer, not hash pinning).

zddc/README.md § Federal-readiness gap analysis — promoted four items
that previously were one-line bullets to per-item subsections so a
future implementor doesn't have to redo the design conversation:

  - FIPS-validated cryptography (NIST SC-13): captures cgo + OpenSSL
    implications, the platform-matrix reality, and the parallel
    zddc-server-fips build target architecture (linux-amd64 only,
    RHEL/UBI base, validated OpenSSL on host).

  - Authenticated proxy↔server channel (NIST IA-3): mTLS vs JWT
    trade-offs spelled out. Recommended: JWT first; mTLS available
    for deployments that already operate a private CA.

  - Policy export for change control (NIST CM-3): zddc-server policy
    export subcommand emitting every directory's resolved ACL in
    JSON / Markdown / CSV. Reuses zddc.ScanZddcFiles +
    zddc.EffectivePolicy + zddc.MatchesPattern.

  - Code-signed apps: URL fetches (NIST SI-7): replaces SHA-256
    pinning (operator hash-tracking burden) with code signing
    (operator trusts a public key once). Three-part implementation
    (build pipeline signs, public key on website, verifier in
    apps/fetch.go).

The bullet list at the top of the gap analysis stays as a one-line
index pointing at the subsections.

Items #6 (ABAC roles) and #7 (logs: block in root .zddc) stay as
bullets — commercial-deployment features, not federal-track.

No code changes to the binary. No tests touched. ~280 lines added
across the two files.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-05-04 21:32:58 -05:00
parent 570604ca80
commit 8b33683a59
2 changed files with 383 additions and 5 deletions

95
build
View file

@ -582,6 +582,44 @@ PIN_MID
<p class="pin-note">Your archive's tools are <strong>yours</strong>. The server is convenience; deletion of the server doesn't break your archive — every per-version download above is a real, immutable static file. Save what you trust.</p>
</section>
<!-- ───────────── Apps: composer ───────────── -->
<section class="card" style="border: 1px solid var(--color-border); border-radius: var(--radius-md); padding: var(--spacing-lg) var(--spacing-xl); margin-top: var(--spacing-xl);">
<h2 style="margin-top:0;">Build your <code>.zddc apps:</code> block</h2>
<p>Pick a channel or pinned version for each tool. The YAML on the right updates as you go — copy it into a <code class="inline">.zddc</code> file at the level of your archive where you want the config to apply. <em>Closer-to-leaf wins</em>, so a project subdirectory can override what its parent says.</p>
<div class="composer-grid" style="display: grid; grid-template-columns: minmax(260px, 1fr) 2fr; gap: var(--spacing-lg); margin-top: var(--spacing-md);">
<div class="composer-rows" style="display: flex; flex-direction: column; gap: var(--spacing-sm);">
<label class="composer-row" style="display: flex; align-items: center; justify-content: space-between; gap: var(--spacing-sm);">
<span style="flex: 1;"><code class="inline">archive</code> <span style="color: var(--color-text-muted); font-size: 0.85rem;">— Archive Browser</span></span>
<select class="composer-select" data-app="archive" style="min-width: 140px;"></select>
</label>
<label class="composer-row" style="display: flex; align-items: center; justify-content: space-between; gap: var(--spacing-sm);">
<span style="flex: 1;"><code class="inline">transmittal</code> <span style="color: var(--color-text-muted); font-size: 0.85rem;">— Transmittal Creator</span></span>
<select class="composer-select" data-app="transmittal" style="min-width: 140px;"></select>
</label>
<label class="composer-row" style="display: flex; align-items: center; justify-content: space-between; gap: var(--spacing-sm);">
<span style="flex: 1;"><code class="inline">classifier</code> <span style="color: var(--color-text-muted); font-size: 0.85rem;">— Classifier</span></span>
<select class="composer-select" data-app="classifier" style="min-width: 140px;"></select>
</label>
<label class="composer-row" style="display: flex; align-items: center; justify-content: space-between; gap: var(--spacing-sm);">
<span style="flex: 1;"><code class="inline">mdedit</code> <span style="color: var(--color-text-muted); font-size: 0.85rem;">— Markdown Editor</span></span>
<select class="composer-select" data-app="mdedit" style="min-width: 140px;"></select>
</label>
<label class="composer-row" style="display: flex; align-items: center; justify-content: space-between; gap: var(--spacing-sm);">
<span style="flex: 1;"><code class="inline">landing</code> <span style="color: var(--color-text-muted); font-size: 0.85rem;">— Landing</span></span>
<select class="composer-select" data-app="landing" style="min-width: 140px;"></select>
</label>
</div>
<div class="composer-output" style="display: flex; flex-direction: column; gap: var(--spacing-sm);">
<textarea id="composer-yaml" readonly rows="10" style="width: 100%; font-family: ui-monospace, SFMono-Regular, monospace; font-size: 0.875rem; padding: var(--spacing-sm); border: 1px solid var(--color-border); border-radius: var(--radius-sm); background: var(--color-bg); color: var(--color-text); resize: vertical;"></textarea>
<div style="display: flex; justify-content: space-between; align-items: center;">
<span id="composer-status" style="font-size: 0.85rem; color: var(--color-text-muted);"></span>
<button id="composer-copy" type="button" class="dl-primary" style="padding: 0.4rem 0.9rem; font-size: 0.875rem;">Copy</button>
</div>
</div>
</div>
<p style="margin-top: var(--spacing-md); font-size: 0.9rem; color: var(--color-text-muted);">Tip: leave a tool on <strong>stable</strong> if you don't have a reason to pin it. Pin specific versions when you depend on a behavior in that release. The server's resolution order is: a real file in your archive &rarr; <code class="inline">.zddc apps:</code> entry (closer-to-leaf wins) &rarr; embedded fallback.</p>
</section>
<!-- ───────────── Channels explainer ───────────── -->
<section class="card" style="border: 1px solid var(--color-border); border-radius: var(--radius-md); padding: var(--spacing-lg) var(--spacing-xl); margin-top: var(--spacing-xl); margin-bottom: var(--spacing-xl);">
<h2 style="margin-top:0;">Channels</h2>
@ -695,6 +733,63 @@ PIN_MID
// non-linux client, that needs to flip to the detected platform).
rewire(picker.value);
})();
// ── Apps: composer ─────────────────────────────────────────────
// Picks per-app channels/versions from dropdowns, emits an equivalent
// .zddc apps: YAML block in a textarea, with a Copy button. Reuses
// the version-picker's option list as the source of truth for what
// values are valid (avoids duplicating the version data into JS).
(function() {
var picker = document.getElementById('version-picker');
var textarea = document.getElementById('composer-yaml');
var copyBtn = document.getElementById('composer-copy');
var status = document.getElementById('composer-status');
if (!picker || !textarea) return;
var selects = document.querySelectorAll('.composer-select');
if (selects.length === 0) return;
// Clone version-picker's option list (channels + pinned versions)
// into each per-app composer-select. innerHTML round-trips fine
// because every option is a static <option value="...">label</option>
// produced by the build script — no inline JS, no event handlers.
var optionsHTML = picker.innerHTML;
selects.forEach(function(sel) {
sel.innerHTML = optionsHTML;
sel.value = 'stable'; // default per-app
});
function rebuild() {
var lines = ['apps:'];
selects.forEach(function(sel) {
lines.push(' ' + sel.dataset.app + ': ' + sel.value);
});
textarea.value = lines.join('\n') + '\n';
}
selects.forEach(function(sel) {
sel.addEventListener('change', rebuild);
});
rebuild();
if (copyBtn) {
copyBtn.addEventListener('click', function() {
textarea.select();
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(textarea.value);
} else {
document.execCommand('copy');
}
if (status) {
status.textContent = 'Copied to clipboard';
setTimeout(function() { status.textContent = ''; }, 2000);
}
} catch (e) {
if (status) status.textContent = 'Copy failed — select the text and copy manually';
}
});
}
})();
</script>
</body>
</html>

View file

@ -564,8 +564,11 @@ implemented today — this list is informational so security reviewers don't
have to redo the gap analysis from scratch.
- **FIPS 140-3 cryptography** (NIST SC-13) — current build uses Go stdlib
crypto. Required: build with `GOEXPERIMENT=systemcrypto` + RHEL FIPS
userspace, or use the `microsoft/go` (formerly goboring) toolchain.
crypto. *Required:* parallel `zddc-server-fips` build target using the
`microsoft/go` toolchain on a RHEL/UBI base with the validated OpenSSL
FIPS module. See `federal.html` for the deployment-shape narrative and
the §"FIPS-validated cryptography" subsection below for the engineering
detail.
- **TLS hardening** (NIST SC-8(1)) — *partially complete.* Server now
sets `MinVersion: tls.VersionTLS12`, the NIST SP 800-52 Rev. 2
AEAD-only cipher allowlist (ECDHE+AES-GCM and ECDHE+ChaCha20Poly1305
@ -576,8 +579,9 @@ have to redo the gap analysis from scratch.
audit-grade documentation pack mapping the cipher list to FIPS
140-3 validated implementations.
- **Authenticated proxy↔server channel** (NIST IA-3) — current trust is
network-level isolation only. Required: mTLS or signed forwarding token
(e.g. JWT signed by the proxy with a key zddc-server validates).
network-level isolation only. *Required:* mTLS or a signed forwarding
token (JWT). See §"Authenticated proxy↔server channel" subsection
below for the design trade-off and recommended path.
- **Multi-factor authentication** (NIST IA-2(1)) — delegated to upstream
proxy. Required: documented reference deployment with PIV/CAC via
oauth2-proxy or equivalent.
@ -606,9 +610,15 @@ have to redo the gap analysis from scratch.
- **Continuous monitoring hooks** (NIST CA-7) — automated alerting on
`.zddc` changes, admin endpoint use, repeated 403s from one identity.
Required: structured event emission to syslog/SIEM beyond the local file.
- **Configuration baseline export** (NIST CM-3) — change-control review
needs a way to enumerate every `.zddc` file's resolved effective ACL
for diff against a baseline. *Required:* `zddc-server policy export`
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 SHA-256 pinning and signature verification.
`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.
- **Data-at-rest encryption** (NIST SC-28) — delegated to the deployment
platform. Required: documented baseline (cloud KMS, LUKS, dm-crypt) with
key-rotation procedures.
@ -619,6 +629,279 @@ 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.
#### FIPS-validated cryptography (NIST SC-13)
**Why the current build doesn't qualify.** Go's stdlib crypto is
correct and well-reviewed but has not been submitted to NIST's CMVP
(Cryptographic Module Validation Program). Federal evaluators want a
specific module identifier ("the cryptography is performed by validated
module #4282" or whatever certificate number applies); Go stdlib has
none.
**Why "swap in microsoft/go" isn't free.** The microsoft/go fork
redirects every `crypto/*` call into the host's OpenSSL via cgo. The
moment we adopt it as the default toolchain:
- The "single static binary, CGO-free" property in the Features list
(line 19) goes away. The binary dynamically links `libssl`/`libcrypto`
and needs OpenSSL installed at the target ABI.
- Cross-compile becomes harder: cgo cross-compilation needs the target
platform's OpenSSL during the build. Today `./build` cross-compiles
all four release platforms from one container with no extra tooling.
- macOS/Windows builds either bundle a non-validated OpenSSL (defeats
the FIPS purpose) or fall back to Go stdlib for those platforms
(validation not transitive). The four-platform release matrix
collapses to one (linux-amd64).
- Binary size grows ~5-10 MB; deployment gains a runtime dependency.
- Commercial customers gain literally nothing — Go stdlib crypto is
cryptographically correct; FIPS is a procurement-checkbox property,
not a strength property.
**Recommended path: parallel `zddc-server-fips` build target.** Don't
swap the default toolchain. Instead, ship a second binary built with
microsoft/go on a RHEL/UBI base with the validated OpenSSL FIPS package
installed. linux-amd64 only. Distributed at
`releases/zddc-server-fips_<channel>_linux-amd64`. Federal customers
download the fips variant; everyone else continues to download the lean
pure-Go binary.
The validation belongs to OpenSSL, not to microsoft/go — the cert
number is for the OpenSSL FIPS provider. The microsoft/go toolchain is
the bridge that lets Go-built apps consume that validated module. So
the deployment commitment is "RHEL/UBI image, kernel-side
`update-crypto-policies --set FIPS`, validated OpenSSL on the host." A
helm chart `helm/zddc-server-fips/` would bundle the matching base
image and `securityContext`.
**Effort estimate:**
- `Containerfile.fips` and `helm/zddc-server-fips/` chart: ~50 lines
each.
- `./build` updates to add the new target: ~30 lines (one
`podman build -f Containerfile.fips` invocation, one
`promote_zddc_server_fips` helper, integration into the existing
release flow).
- Verification: a smoke test that runs the fips binary, hits a TLS
endpoint, and confirms the negotiated cipher came from the FIPS
provider (visible via `SSL_CIPHER` exposed from OpenSSL).
- Documentation: ~50 lines updating `federal.html` and this README to
describe the supported deployment shape concretely.
Total: ~250 lines net once the customer engages.
**What stays unchanged.** Application code is identical between tracks.
The TLS hardening already shipped (cipher allowlist, curve preferences,
HSTS) reuses verbatim — every cipher we ship is on the OpenSSL FIPS
allowlist, so the same `tls.Config` works in both tracks. The
difference is purely the toolchain and the host OS configuration.
#### Authenticated proxy↔server channel (NIST IA-3)
**The current trust assumption.** zddc-server reads
`X-Auth-Request-Email` from any client that can connect to the bind
address. Network isolation (loopback bind + same-host proxy, or k8s
NetworkPolicy gating ingress) is the only thing preventing email
forgery. NIST IA-3 (Device Identification and Authentication) requires
**cryptographic** binding between system components for higher assurance
levels.
**Two design options.**
**Option A — mTLS.**
The proxy presents a client certificate during the TLS handshake to
zddc-server. zddc-server verifies the cert against a trusted CA and
extracts the email from the cert's subject alternative names (or
matches the cert against an allowlist).
- *Pros:* Works at the connection layer; transparent to handler code
(the email comes from `r.TLS.PeerCertificates[0]` instead of a
header). Reuses existing TLS infrastructure. Zero per-request
overhead beyond the standard TLS handshake.
- *Cons:* Cert distribution. Every proxy needs its own client cert;
every cert eventually expires. Operator runs a small private CA
(cert-manager + k8s, smallstep, Vault PKI) or rotates manually.
Operationally heavier than the alternative.
**Option B — JWT (signed forwarding token).**
The proxy signs a JWT for each forwarded request using its private key.
zddc-server verifies the signature with the proxy's public key. The JWT
carries the email, an issuer claim, an audience claim, and a short
expiry (replay protection). Sent in `Authorization: Bearer <jwt>` or a
custom `X-Auth-Forwarding-Token` header.
- *Pros:* Lightweight. No PKI — just one public key to distribute to
zddc-server. Easy to debug (JWT is base64 JSON; `jwt.io` exists).
Easy to rotate (run two trusted public keys during rotation).
Integrates with existing oauth2-proxy / nginx pipelines that have
"sign and forward" modes built in.
- *Cons:* Adds a JWT library dep (~50KB). Requires a freshness model
(short expiry, e.g. 30s — so an exfiltrated token is useless after
half a minute). Requires key distribution + rotation, but at much
smaller scale than mTLS.
**Recommended path: JWT first; mTLS as a sidecar option.**
JWT is the better starting point. Lighter operationally, doesn't
compete with the existing TLS termination story (an stunnel-FIPS or
nginx-FIPS sidecar can still do mTLS at the network layer if needed),
and integrates with future role-based access (item AC-3(7) above) —
`roles` claims in the JWT can flow into the ABAC roles work.
**Implementation sketch.**
- New env var `ZDDC_JWT_PUBKEY=/etc/zddc/proxy-pub.pem` or
`ZDDC_JWT_PUBKEY_INLINE=...PEM bytes...`.
- New middleware (peer to `ACLMiddleware` in
`zddc/internal/handler/middleware.go`) that, when configured, reads
`Authorization: Bearer <jwt>` from the request, verifies the
signature, validates `aud == zddc-server`, `exp` in the future,
`nbf` in the past, and extracts `email` (and `roles`) from the
verified claims.
- Refusal mode: when `ZDDC_JWT_PUBKEY` is set and a request lacks a
valid JWT, return 401. No silent fallback — the operator opted in
to cryptographic binding.
- Library choice: `github.com/golang-jwt/jwt/v5` (pure Go, well
maintained) or `github.com/lestrrat-go/jwx` (more features, larger).
Lean toward the simpler `golang-jwt/jwt`.
**Effort estimate:** ~150 lines of Go (middleware + config wiring +
key parsing) + the JWT library dependency + ~30 lines of deployment
docs showing oauth2-proxy and nginx sign-and-forward configurations.
#### Policy export for change control (NIST CM-3)
**Why this is needed.** Federal change-control review wants a baseline
file that can be diffed against current state. For zddc-server, "the
configuration" is the union of every `.zddc` file in the served tree.
Today an operator wanting "what's the effective access at /Project-A?"
has to walk the cascade by hand. The `/.profile/effective-policy`
endpoint shortcuts a single `(path, email)` query, but it doesn't
enumerate the whole policy.
**What the export produces.** For every directory containing a `.zddc`,
the command emits:
- The directory path
- The raw `.zddc` contents
- The resolved effective ACL (the chain back to root, flattened into
this directory's effective allow/deny lists)
- Equivalence-class summary: `(email-class, decision)` pairs covering
groups like `*@mycompany.com → allow`, `*@external.com → deny`,
specific exceptions
Output formats:
- **JSON** — machine-readable for `diff` against a baseline file in a
Git repo or a change-control tool
- **Markdown** — human-readable summary suitable for ATO documentation
- **CSV** — pivot-ready for spreadsheet review
**Use cases:**
- *Baseline + diff workflow.* Operator commits the export to a Git repo.
Every `.zddc` change auto-runs the export in CI; the diff is the
change-control artifact. Reviewers see "alice was added to
/Project-X/.zddc, which means alice can now access /Project-X/,
/Project-X/sub/, … because the cascade propagates."
- *Drift detection.* Export at deploy time vs export every day; any
unexpected change triggers an alert.
- *ATO documentation.* Plug the export into the SSP's "Configuration
Inventory" section.
**Implementation sketch.** Mostly mechanical reuse of existing pieces:
- `zddc.ScanZddcFiles(root)` already walks the tree (used by the
profile page's admin-subtrees listing).
- `zddc.EffectivePolicy(root, dir)` already builds the chain (used by
every ACL check).
- `zddc.MatchesPattern` already covers the email matching.
What's missing is the CLI subcommand wiring (today the binary takes
flags only; needs subcommand parsing for `policy export`), the
equivalence-class computation (group emails by which patterns they
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** (each is a separate piece of work
that interlocks with the others):
1. **Signing in the build pipeline.** `./build alpha|beta|release`
gains a signing step: after producing each `<tool>_v*.html` and
each `zddc-server_v*` binary, sign with a project key (Ed25519 or
RSA) and publish the `.sig` alongside. Private key stored
securely (HashiCorp Vault, GitHub Actions secret, etc.).
2. **Public key on the website.** A "Verify your downloads" section
on the releases page with the PEM public key inline, a download
link to `pubkey.pem`, the key fingerprint shown in plain text,
and a `curl` example for verifying a downloaded artifact.
3. **Verifier in zddc-server.** New code in `apps/fetch.go` reading
a `signed_by:` field from `.zddc apps:` syntax (or just an
embedded default trusting the project key) and validating each
fetched artifact's signature before caching it. Two
implementation flavors:
- *Simple:* stdlib `crypto/ed25519`. ~200 lines including the
`apps:` schema extension to accept `signed_by:` and the
fetch-and-verify path. Fine for trusting one project key.
- *Sigstore:* `github.com/sigstore/sigstore` for transparency-
log-backed signing (Rekor inclusion proofs). Larger
dependency (~5MB). Federally-acceptable; aligns with how
modern federal stacks (DoD's Iron Bank, etc.) consume signed
artifacts.
**Recommended sequencing:** start with the simple Ed25519 case for the
project's own artifacts. Add Sigstore as an opt-in (`signed_by:
sigstore:...`) when a federal customer engages.
**What's currently in place.** Nothing — no signing, no `.sig` files,
no verifier. The current `apps/fetch.go` is fetch-once-cached-forever
with no integrity check. The whole feature is greenfield.
**Effort estimate (simple Ed25519 case):**
- `./build` signing step + key generation procedure: ~100 lines + ops
documentation.
- `pubkey.pem` distribution on the website + verify section in
`index.html` releases page: ~50 lines of HTML.
- `apps/` parser extension for `signed_by:` field: ~50 lines.
- Verifier logic in `apps/fetch.go`: ~150 lines.
- Tests: ~150 lines.
Total: ~500 lines net plus a key-management procedure.
### External policy decider (OPA-compatible)
For deployments that need policy decisions made by an external,