Compare commits

...

2 commits

Author SHA1 Message Date
9765fa2f5e feat(apps): code-signed URL fetches; dev chart overlays prod data RO
Two interlocking pieces shipped together:

1. Strict Ed25519 signature verification on URL-fetched apps artifacts.
   Every URL the apps cascade resolves must publish a corresponding
   <url>.sig (raw 64-byte Ed25519 signature). The fetcher rejects on
   any failure (sig 404, transport error, wrong key, tampered body)
   and the resolver falls back to the embedded copy.

   The trusted public key is OPERATOR-CONFIGURED via --apps-pubkey /
   ZDDC_APPS_PUBKEY (PEM file path). No baked-in default — same posture
   as TLS certificates. Operators using zddc.varasys.io's canonical
   channels download pubkey.pem from there and configure the local
   path. Operators with their own signing infrastructure pass their
   own public key.

   Build pipeline (./build) gains sign_release_artifacts: walks
   dist/release-output/ after promote and produces an Ed25519 .sig
   alongside every real file. ZDDC_SIGNING_KEY=~/.config/zddc-signing/
   key.pem (mode 0600). Symlinks skip — the .sig at the symlink
   target is what counts.

   Test coverage: parse-PEM round-trip, malformed/wrong-type PEM
   rejection, valid-signature accept, tampered-body reject, wrong-key
   reject, malformed-signature reject, end-to-end fetch+sign+verify,
   fetch-rejects-tampered, fetch-rejects-missing-sig, fetch-rejects-
   wrong-key. Existing fetch tests updated to use signed-fixture
   helpers.

2. Dev Helm chart mounts production data READ-ONLY and layers an
   OverlayFS writable scratch on top. Prod data is the lowerdir;
   dev's writes (form submissions, archive index state, .zddc edits)
   land in upperdir; main container sees the merged read-write view
   at $ZDDC_ROOT. Setup runs in a privileged init container; main
   container runs unprivileged. Solves the dev-replica-on-shared-
   dataset problem at the filesystem layer with no zddc-server code
   change.

Docs: env-var tables in zddc/README.md and AGENTS.md gain a
ZDDC_APPS_PUBKEY row. The Federal-readiness gap analysis "Code-signed
apps: URL fetches" subsection is rewritten as "what's currently in
place" instead of "what would need to be added," with a forward
pointer to per-entry signed_by: (multi-key) and Sigstore as the
federally-acceptable evolution.

The website "Verify your downloads" section + the embedded pubkey
gone — but the website needs separate updates landing in zddc-website
to publish pubkey.pem and add the verify section. Pending in that
repo's commit.

Production binary unchanged at 13.1 MB. All 11 Go test packages green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 21:59:07 -05:00
8b33683a59 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>
2026-05-04 21:32:58 -05:00
11 changed files with 1057 additions and 13 deletions

View file

@ -396,6 +396,7 @@ ZDDC_ROOT=/path/to/your/archive ZDDC_TLS_CERT=none ZDDC_ADDR=:8080 \
| `ZDDC_OPA_URL` | `internal` | Policy decider endpoint. `internal` (default) = in-process Go evaluator (same `.zddc` cascade we always had). `http(s)://...` or `unix:///...` = external OPA — every access decision becomes a `POST /v1/data/zddc/access/allow` to the configured endpoint. Federal customers with their own audited Rego use this; commercial deployments leave it `internal`. |
| `ZDDC_OPA_FAIL_OPEN` | *(empty)* | External OPA only. `1` = allow on transport error; default = fail closed (deny). |
| `ZDDC_OPA_CACHE_TTL` | `1s` | External OPA only. Per-decision cache TTL — amortizes round-trips on bursty patterns (e.g. `.archive` listings hit the same `(email, dir)` tuple many times). `0` disables. Format is Go `time.ParseDuration`. |
| `ZDDC_APPS_PUBKEY` | *(empty)* | Path to PEM Ed25519 pubkey for verifying signatures on URL-fetched `apps:` artifacts. Empty = URL apps refused. Download from `zddc.varasys.io/pubkey.pem` (canonical channels) or supply your own. No baked-in default — same posture as TLS certs. |
| `ZDDC_ACCESS_LOG` | `<ZDDC_ROOT>/.zddc.d/logs/access-<host>.log` | JSON-line audit log (lumberjack-rotated, 100 MB / 10 backups / 90 days, gzipped). Server auto-mkdirs the parent. Set explicitly to empty (`--access-log=`) to disable. Per-host filename + `host` field in every record so multi-replica deployments writing to the same `.zddc.d/` dir disambiguate cleanly. |
### Release tagging

199
build
View file

@ -294,6 +294,79 @@ echo " binary version: $ZDDC_BINARY_VERSION"
done
'
# --- Sign release artifacts -----------------------------------------------
# After a channel/release cut has populated $RELEASES_DIR with the actual
# bytes for this build, walk the dir and produce a detached Ed25519 .sig
# alongside every real artifact. Symlinks (channel mirrors, partial-version
# pins) skip — the .sig at the symlink's target is what counts.
#
# Operators verify with stdlib openssl:
#
# openssl pkeyutl -verify -pubin -inkey pubkey.pem \
# -rawin -in archive_v0.0.16.html \
# -sigfile archive_v0.0.16.html.sig
#
# zddc-server's apps/fetch.go fetches the .sig alongside the artifact and
# verifies against the public key embedded at compile time
# (zddc/internal/apps/embedded/pubkey.pem).
#
# Key location: ZDDC_SIGNING_KEY (path to PEM-encoded Ed25519 private key).
# Unset → fail; signing is mandatory for release/channel cuts because the
# verifier rejects unsigned bytes outright.
sign_release_artifacts() {
_dir="$1"
if [ -z "${ZDDC_SIGNING_KEY:-}" ]; then
echo "ERROR: ZDDC_SIGNING_KEY is unset; cannot sign release artifacts." >&2
echo " Generate a key once with:" >&2
echo " mkdir -p ~/.config/zddc-signing && chmod 700 ~/.config/zddc-signing" >&2
echo " openssl genpkey -algorithm Ed25519 -out ~/.config/zddc-signing/key.pem" >&2
echo " chmod 600 ~/.config/zddc-signing/key.pem" >&2
echo " export ZDDC_SIGNING_KEY=~/.config/zddc-signing/key.pem" >&2
echo " Then publish the matching public key:" >&2
echo " openssl pkey -in \$ZDDC_SIGNING_KEY -pubout > /path/to/pubkey.pem" >&2
return 1
fi
if [ ! -f "$ZDDC_SIGNING_KEY" ]; then
echo "ERROR: ZDDC_SIGNING_KEY=$ZDDC_SIGNING_KEY does not exist." >&2
return 1
fi
# Collect the artifact list first so the signing loop runs in this
# shell (no subshell counter scope issue). find: real files only
# (-P, the default), matching <tool>_v*.html, <tool>_<channel>.html,
# and zddc-server_v*_<plat>(.exe). Excludes the index, stub pages,
# and any pre-existing .sig files.
_list=$(find "$_dir" -maxdepth 1 -type f \( \
-name '*_v*.html' -o \
-name '*_stable.html' -o -name '*_beta.html' -o -name '*_alpha.html' -o \
-name 'zddc-server_v*' -o \
-name 'zddc-server_stable_*' -o -name 'zddc-server_beta_*' -o -name 'zddc-server_alpha_*' \
\) ! -name '*.sig' ! -name 'index.html' ! -name 'zddc-server_*.html' 2>/dev/null)
_signed=0
_skipped=0
OLDIFS="$IFS"
IFS='
'
for _f in $_list; do
_sig="$_f.sig"
if openssl pkeyutl -sign \
-inkey "$ZDDC_SIGNING_KEY" \
-rawin -in "$_f" \
-out "$_sig" 2>/dev/null; then
_signed=$((_signed + 1))
else
echo " WARN: failed to sign $_f" >&2
_skipped=$((_skipped + 1))
fi
done
IFS="$OLDIFS"
echo " Signed $_signed artifacts, $_skipped skipped"
if [ "$_skipped" -gt 0 ]; then
return 1
fi
}
# --- Promote zddc-server release artifacts ---------------------------------
# On a channel/release cut, copy the freshly cross-compiled binaries to
# the website worktree's releases/ under their canonical names +
@ -582,6 +655,71 @@ 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>
<!-- ───────────── Verify your downloads ───────────── -->
<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;">Verify your downloads</h2>
<p>Every artifact published here is signed with a long-lived Ed25519 key. <strong>You can verify any download against the public key</strong> using stock <code class="inline">openssl</code> — no special tooling. <code class="inline">zddc-server</code> verifies automatically when fetching tools via the <code class="inline">apps:</code> cascade once the operator configures <code class="inline">ZDDC_APPS_PUBKEY</code> — same posture as TLS certificates: the binary bakes nothing in, the operator points it at the public key they trust.</p>
<div class="grid-2" style="margin-top: var(--spacing-md);">
<div class="pin-card">
<h3>Public key</h3>
<p><a href="../pubkey.pem"><strong>Download <code class="inline">pubkey.pem</code></strong></a></p>
<p style="font-size: 0.85rem; color: var(--color-text-muted);">SHA-256 fingerprint of the DER-encoded SubjectPublicKeyInfo:</p>
<pre style="word-break: break-all; white-space: pre-wrap;">7766dc8cf963f32156ddcc96825c52ba0333ffe4c243ad54f9eaf26195b065ab</pre>
<p style="font-size: 0.85rem; color: var(--color-text-muted);">Verify the fingerprint after downloading:</p>
<pre>openssl pkey -pubin -in pubkey.pem -outform DER | sha256sum</pre>
</div>
<div class="pin-card">
<h3>Verify a download</h3>
<p>Each artifact has a matching <code class="inline">.sig</code> file alongside it (<code class="inline">archive_stable.html</code> &rarr; <code class="inline">archive_stable.html.sig</code>, etc.). Fetch both, then:</p>
<pre>curl -O https://zddc.varasys.io/releases/archive_stable.html
curl -O https://zddc.varasys.io/releases/archive_stable.html.sig
openssl pkeyutl -verify -pubin -inkey pubkey.pem \
-rawin -in archive_stable.html \
-sigfile archive_stable.html.sig</pre>
<p style="font-size: 0.85rem;">Output is <code class="inline">Signature Verified Successfully</code> on a clean download. Any other output (or no output and a non-zero exit) means the bytes do not match the published signature — do not trust them.</p>
</div>
</div>
<p style="margin-top: var(--spacing-md); font-size: 0.9rem; color: var(--color-text-muted);">For server deployments, configure the public key once and forget it: download <code class="inline">pubkey.pem</code>, save it on your server, and pass <code class="inline">ZDDC_APPS_PUBKEY=/path/to/pubkey.pem</code> when starting <code class="inline">zddc-server</code>. The resolver fetches the <code class="inline">.sig</code> automatically and rejects any unsigned or invalid-signature artifact, falling back to the embedded copy. Operators wanting to enforce signature verification on locally-saved artifacts (Path A, "drop a copy into your archive") run the <code class="inline">openssl</code> verify command above as part of their save workflow.</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 +833,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>
@ -706,6 +901,10 @@ PIN_END
# Matrix index + verifier only run when we touched the website
# worktree. Dev builds leave the worktree alone.
if [ -n "$RELEASE_CHANNEL" ]; then
echo ""
echo "=== Signing release artifacts ==="
sign_release_artifacts "$RELEASES_DIR"
echo ""
echo "=== Building releases/index.html ==="
build_releases_index

View file

@ -31,10 +31,65 @@ spec:
volumes:
- name: zddc-bin
emptyDir: {}
- name: data
# Production data volume — mounted READ-ONLY so the dev pod
# cannot corrupt prod even with a bug. Becomes the lowerdir of
# the OverlayFS mount below.
- name: data-readonly
persistentVolumeClaim:
claimName: {{ .Values.data.pvcName }}
readOnly: true
# Writable scratch for OverlayFS upperdir + workdir. emptyDir
# is ephemeral by default — dev tweaks evaporate on pod restart,
# which is usually right for a dev replica. Replace with a
# small PVC if persistence across restarts matters.
- name: overlay-scratch
emptyDir: {}
# The composed read-write view zddc-server reads from. Populated
# by the setup-overlay init container; passed through to the main
# container as ZDDC_ROOT.
- name: data
emptyDir: {}
initContainers:
# OverlayFS sandwich:
# lowerdir = /mnt/data-readonly (prod data, RO)
# upperdir = /mnt/overlay-scratch/upper
# workdir = /mnt/overlay-scratch/work
# merged = /mnt/data (what main container sees)
#
# Why this exists: dev runs against the same on-disk dataset as
# prod, but its writes (anything zddc-server writes — index
# state, form submissions during testing, .zddc edits via the
# admin page, etc.) MUST NOT mutate prod data. OverlayFS solves
# this at the filesystem layer: prod data is RO, dev's writes
# land in upperdir, the dev container sees the merged view. No
# zddc-server code change required.
#
# Requires CAP_SYS_ADMIN (the overlay mount syscall is
# privileged). Stays scoped to this one init container; the main
# container runs without elevated privs.
- name: setup-overlay
image: {{ printf "%s:%s" .Values.runtimeImage.repository .Values.runtimeImage.tag | quote }}
securityContext:
privileged: true
command: ["/bin/sh", "-c"]
args:
- |
set -eu
mkdir -p /mnt/overlay-scratch/upper /mnt/overlay-scratch/work
mount -t overlay overlay \
-o lowerdir=/mnt/data-readonly,upperdir=/mnt/overlay-scratch/upper,workdir=/mnt/overlay-scratch/work \
/mnt/data
echo "OverlayFS mounted: /mnt/data-readonly (RO) + /mnt/overlay-scratch (RW) -> /mnt/data"
ls -la /mnt/data | head -10
volumeMounts:
- name: data-readonly
mountPath: /mnt/data-readonly
readOnly: true
- name: overlay-scratch
mountPath: /mnt/overlay-scratch
- name: data
mountPath: /mnt/data
mountPropagation: Bidirectional
- name: build-zddc-server
image: {{ printf "%s:%s" .Values.buildImage.repository .Values.buildImage.tag | quote }}
imagePullPolicy: Always

View file

@ -61,6 +61,7 @@ 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. |
| `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) |
@ -564,8 +565,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 +580,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 +611,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 +630,274 @@ 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** that interlock:
1. **Signing in the build pipeline.** `./build alpha|beta|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 (channel mirrors, partial-version pins) skip — the .sig
at the symlink target is what counts.
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 from `cfg.AppsPubKey` (`--apps-pubkey` /
`ZDDC_APPS_PUBKEY`). Failure cases — 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,

View file

@ -313,6 +313,24 @@ func setupApps(cfg config.Config) (*apps.Server, error) {
return nil, fmt.Errorf("create cache: %w", err)
}
fetcher := apps.NewFetcher(cache, slog.Default())
// Apps signing pubkey. Optional — when unset, URL-fetched apps
// refuse-by-default (the resolver falls back to embedded). Operators
// using zddc.varasys.io's canonical channels download pubkey.pem
// from there and configure --apps-pubkey or ZDDC_APPS_PUBKEY. Same
// posture as TLS certificates: zddc-server bakes nothing in.
if cfg.AppsPubKey != "" {
pub, err := apps.LoadPubKey(cfg.AppsPubKey)
if err != nil {
return nil, fmt.Errorf("apps-pubkey: %w", err)
}
fetcher.VerifyKey = pub
slog.Info("apps signing pubkey loaded", "path", cfg.AppsPubKey)
} else {
slog.Warn("apps-pubkey not configured; URL-fetched apps will be refused (only embedded + local-path apps will work). " +
"Set --apps-pubkey or ZDDC_APPS_PUBKEY to a PEM file containing the Ed25519 signing key you trust.")
}
return apps.NewServer(cfg.Root, cache, fetcher, version), nil
}

View file

@ -1,6 +1,8 @@
package main
import (
"crypto/ed25519"
"crypto/rand"
"net/http"
"net/http/httptest"
"net/url"
@ -97,7 +99,20 @@ func TestDispatchAppsResolution(t *testing.T) {
root := t.TempDir()
body := []byte("<!doctype html>archive content")
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("GenerateKey: %v", err)
}
sig := ed25519.Sign(priv, body)
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Same body for every artifact; same signature for every .sig
// (since the body is identical across the five tools in this
// fixture). Real deployments publish a distinct .sig per
// artifact; the test only cares that the verify gate passes.
if strings.HasSuffix(r.URL.Path, ".sig") {
_, _ = w.Write(sig)
return
}
w.Header().Set("ETag", `"v1"`)
_, _ = w.Write(body)
}))
@ -145,6 +160,9 @@ func TestDispatchAppsResolution(t *testing.T) {
if err != nil {
t.Fatalf("setupApps: %v", err)
}
// Override the production embedded public key with the test fixture's
// pubkey so signature verification of upstream.Sign'd bodies succeeds.
appsSrv.Fetcher.VerifyKey = pub
// GET /archive.html → fetched from upstream (archive is available everywhere)
rec := httptest.NewRecorder()

View file

@ -2,6 +2,8 @@ package apps
import (
"context"
"crypto/ed25519"
"errors"
"fmt"
"io"
"log/slog"
@ -15,12 +17,37 @@ import (
// the handler reads the file directly.
//
// Concurrent calls for the same URL dedupe via singleflight. There is no
// background refresh, no conditional GET, no SHA-256 verification.
// background refresh, no conditional GET.
//
// Signature verification (Ed25519). Strict. On every fetch, also
// fetches <url>.sig (raw 64-byte Ed25519 signature). The fetched body
// is rejected unless the .sig is present, well-formed, and verifies
// against the trusted public key. Rejection causes the apps resolver
// to fall through to the embedded copy.
//
// There is no "accept unsigned with a warning" mode and no embedded
// default key. The operator configures VerifyKey explicitly via
// --apps-pubkey or ZDDC_APPS_PUBKEY (same posture as TLS certificates:
// zddc-server bakes nothing in). When VerifyKey is nil, every URL fetch
// is rejected with an error noting the missing config — the resolver
// falls back to embedded and operators get a clear signal that they
// need to opt in.
//
// Every URL the resolver might fetch is expected to have a
// corresponding .sig published by whoever signed the artifact.
// Operators using custom mirrors must sign their own artifacts and
// host the .sig alongside, then configure their public key here.
type Fetcher struct {
Cache *Cache
Client *http.Client
Logger *slog.Logger
// VerifyKey is the Ed25519 public key against which fetched
// artifacts are verified. Set at startup from the operator's
// configured --apps-pubkey path. nil = URL fetches refuse-by-
// default (caller falls back to embedded).
VerifyKey ed25519.PublicKey
sf singleflightGroup
embeddedFails sync.Map // url → struct{} (rate-limit "fell back to embedded" warnings)
}
@ -34,6 +61,9 @@ func NewFetcher(cache *Cache, logger *slog.Logger) *Fetcher {
return &Fetcher{
Cache: cache,
Logger: logger,
// VerifyKey starts nil. Operator configures it via
// cfg.AppsPubKey at server startup; main.go sets it on the
// returned Fetcher before any request is served.
Client: &http.Client{
Timeout: 10 * time.Second,
CheckRedirect: func(*http.Request, []*http.Request) error {
@ -82,6 +112,14 @@ func (f *Fetcher) fetchOnce(ctx context.Context, urlStr string) ([]byte, error)
if int64(len(body)) > maxBytes {
return nil, fmt.Errorf("response from %s exceeds %d bytes", urlStr, maxBytes)
}
// Signature verification gate. See Fetcher type docstring for the
// decision matrix. The transitional period accepts unsigned artifacts
// with a WARN log; flipping RequireSigs makes it strict-reject.
if err := f.verifyFetched(ctx, urlStr, body); err != nil {
return nil, fmt.Errorf("signature verification failed: %w", err)
}
if f.Cache != nil {
if err := f.Cache.Write(urlStr, body); err != nil {
f.Logger.Warn("cache write failed; serving from response anyway",
@ -91,6 +129,59 @@ func (f *Fetcher) fetchOnce(ctx context.Context, urlStr string) ([]byte, error)
return body, nil
}
// verifyFetched fetches <urlStr>.sig and validates body against it.
// Returns nil only when the signature is present, well-formed, and
// verifies against f.VerifyKey. Any other outcome is a hard reject:
// the caller drops the body and the apps resolver falls through to
// the embedded copy.
//
// f.VerifyKey == nil means the operator hasn't configured an apps-
// pubkey. We reject every URL fetch in that state — the operator
// needs to opt in to a specific signing key explicitly. The reject
// error is informative so the WARN log line tells the operator
// exactly what to fix.
func (f *Fetcher) verifyFetched(ctx context.Context, urlStr string, body []byte) error {
if f.VerifyKey == nil {
return errors.New("ZDDC_APPS_PUBKEY is not configured; URL-fetched apps require an explicit signing key (see zddc.varasys.io/pubkey.pem for the canonical-channel key)")
}
sigURL := urlStr + ".sig"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, sigURL, nil)
if err != nil {
return fmt.Errorf("build sig request for %s: %w", sigURL, err)
}
resp, err := f.Client.Do(req)
if err != nil {
return fmt.Errorf("fetch %s: %w", sigURL, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("%s returned HTTP %d", sigURL, resp.StatusCode)
}
// Raw Ed25519 sig is 64 bytes; cap at a small limit so a hostile
// upstream can't flood us with a garbage "signature."
const maxSigBytes = 256
sig, err := io.ReadAll(io.LimitReader(resp.Body, maxSigBytes+1))
if err != nil {
return fmt.Errorf("read %s: %w", sigURL, err)
}
if len(sig) > maxSigBytes {
return fmt.Errorf("%s exceeds %d bytes", sigURL, maxSigBytes)
}
if err := VerifyEd25519(f.VerifyKey, body, sig); err != nil {
// Verification failure is positive evidence of tampering or a
// build/key mismatch. Logged at WARN so operators see it; the
// resolver's existing embedded-fallback logging will note that
// the embedded copy is being served instead.
f.Logger.Warn("REJECTED: artifact signature does not verify",
"url", urlStr, "sig_url", sigURL, "err", err)
return err
}
f.Logger.Debug("artifact signature verified", "url", urlStr)
return nil
}
// LogEmbeddedFallback emits a one-time warning when the embedded fallback
// is used for a particular source URL. Rate-limited per URL.
func (f *Fetcher) LogEmbeddedFallback(app, urlStr string, reason error) {

View file

@ -1,6 +1,8 @@
package apps
import (
"crypto/ed25519"
"crypto/rand"
"net/http"
"net/http/httptest"
"net/url"
@ -13,6 +15,33 @@ import (
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
)
// signedFixture returns a (publicKey, handler) pair where the handler
// serves `body` for any URL ending in `.html` and the corresponding
// Ed25519 signature for the same URL with `.sig` appended. Tests use
// this to stand up upstream stubs that exercise the apps fetcher's
// strict signature-verification path.
//
// All tests share one pattern: the fetcher's VerifyKey gets overridden
// to this fixture's publicKey so verification passes against the
// fixture's signature instead of the production embedded key.
func signedFixture(t *testing.T, body []byte) (ed25519.PublicKey, http.HandlerFunc) {
t.Helper()
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("GenerateKey: %v", err)
}
sig := ed25519.Sign(priv, body)
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasSuffix(r.URL.Path, ".sig"):
_, _ = w.Write(sig)
default:
_, _ = w.Write(body)
}
})
return pub, handler
}
func TestMatchAppHTML(t *testing.T) {
cases := []struct {
path, wantApp, wantDir string
@ -34,12 +63,14 @@ func TestMatchAppHTML(t *testing.T) {
}
}
// Build a Server with a fake upstream serving body.
// Build a Server with a fake upstream serving body. The upstream
// also publishes a valid Ed25519 signature alongside (.sig) and the
// fetcher's VerifyKey is overridden to the matching test pubkey so
// fetched bytes pass the strict-signature gate.
func newTestServer(t *testing.T, body []byte) (*Server, *httptest.Server, string) {
t.Helper()
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(body)
}))
pub, handler := signedFixture(t, body)
upstream := httptest.NewServer(handler)
t.Cleanup(upstream.Close)
root := t.TempDir()
cache, err := NewCache(filepath.Join(root, CacheDirName))
@ -47,6 +78,7 @@ func newTestServer(t *testing.T, body []byte) (*Server, *httptest.Server, string
t.Fatal(err)
}
f := NewFetcher(cache, nil)
f.VerifyKey = pub
return NewServer(root, cache, f, "test"), upstream, root
}
@ -94,15 +126,33 @@ func TestServer_OverrideURL_FetchesAndCaches(t *testing.T) {
func TestServer_OverrideURL_CacheHitOnSecondCall(t *testing.T) {
var hits atomic.Int64
body := []byte("body")
pub, _, sig := func() (ed25519.PublicKey, ed25519.PrivateKey, []byte) {
p, k, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("GenerateKey: %v", err)
}
return p, k, ed25519.Sign(k, body)
}()
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
_, _ = w.Write([]byte("body"))
// Count only artifact fetches (not .sig fetches) so the assertion
// "1 hit means cache works" stays meaningful: cache stores the
// artifact body, signature verification re-runs each time the
// resolver hits the URL but only on the first miss does it fetch
// the artifact bytes itself. After that, cache.Read short-circuits.
if !strings.HasSuffix(r.URL.Path, ".sig") {
hits.Add(1)
_, _ = w.Write(body)
return
}
_, _ = w.Write(sig)
}))
defer upstream.Close()
root := t.TempDir()
cache, _ := NewCache(filepath.Join(root, CacheDirName))
f := NewFetcher(cache, nil)
f.VerifyKey = pub
srv := NewServer(root, cache, f, "test")
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{

View file

@ -0,0 +1,73 @@
package apps
import (
"crypto/ed25519"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"os"
)
// LoadPubKey reads a PEM-encoded SubjectPublicKeyInfo (the format
// `openssl pkey -pubout` emits) from path and returns the underlying
// Ed25519 public key.
//
// Operators distribute and configure this key explicitly — same posture
// as the TLS certificate: zddc-server bakes nothing in. Customers
// running against zddc.varasys.io's release channel download the
// canonical key from zddc.varasys.io/pubkey.pem and pass the local
// path via --apps-pubkey or ZDDC_APPS_PUBKEY. Customers running their
// own signing infrastructure pass their own public key instead.
//
// Returns a descriptive error for missing files, malformed PEM, wrong
// PEM type, or non-Ed25519 keys. Callers (cmd/zddc-server's startup
// path) treat any error as fatal — refusing to start with a misconfigured
// apps-pubkey is the right posture.
func LoadPubKey(path string) (ed25519.PublicKey, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read apps-pubkey from %s: %w", path, err)
}
return ParsePubKeyPEM(data)
}
// ParsePubKeyPEM is LoadPubKey's content-only variant. Useful when the
// PEM bytes come from somewhere other than disk (test fixtures, etc.).
func ParsePubKeyPEM(pemBytes []byte) (ed25519.PublicKey, error) {
block, _ := pem.Decode(pemBytes)
if block == nil {
return nil, errors.New("no PEM block found")
}
if block.Type != "PUBLIC KEY" {
return nil, fmt.Errorf("unexpected PEM type %q (want PUBLIC KEY)", block.Type)
}
pub, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("parse PKIX: %w", err)
}
edPub, ok := pub.(ed25519.PublicKey)
if !ok {
return nil, fmt.Errorf("public key is not Ed25519 (got %T)", pub)
}
return edPub, nil
}
// VerifyEd25519 checks that sig is a valid Ed25519 signature of body
// produced with the private key matching pub. Returns nil on success
// or a descriptive error otherwise.
//
// sig must be exactly 64 bytes (the raw Ed25519 signature format
// produced by `openssl pkeyutl -sign -rawin`).
func VerifyEd25519(pub ed25519.PublicKey, body, sig []byte) error {
if pub == nil {
return errors.New("no public key configured")
}
if len(sig) != ed25519.SignatureSize {
return fmt.Errorf("signature has wrong length: %d (want %d)", len(sig), ed25519.SignatureSize)
}
if !ed25519.Verify(pub, body, sig) {
return errors.New("signature does not verify against trusted public key")
}
return nil
}

View file

@ -0,0 +1,255 @@
package apps
import (
"context"
"crypto/ed25519"
"crypto/rand"
"crypto/x509"
"encoding/pem"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
)
// genTestKey returns a fresh Ed25519 keypair for tests so the test
// suite never depends on the embedded production key.
func genTestKey(t *testing.T) (ed25519.PublicKey, ed25519.PrivateKey) {
t.Helper()
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("GenerateKey: %v", err)
}
return pub, priv
}
func TestParseEd25519PublicKeyPEM_RoundTrip(t *testing.T) {
pub, _ := genTestKey(t)
derBytes, err := x509.MarshalPKIXPublicKey(pub)
if err != nil {
t.Fatalf("MarshalPKIXPublicKey: %v", err)
}
pemBytes := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: derBytes})
parsed, err := ParsePubKeyPEM(pemBytes)
if err != nil {
t.Fatalf("parse: %v", err)
}
if !pub.Equal(parsed) {
t.Errorf("round-trip pubkey mismatch")
}
}
func TestParseEd25519PublicKeyPEM_RejectsRSA(t *testing.T) {
// PEM containing a non-Ed25519 key should error rather than
// silently coerce. Use a hand-crafted bad PEM block.
bad := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: []byte("not a valid SubjectPublicKeyInfo")})
if _, err := ParsePubKeyPEM(bad); err == nil {
t.Error("ParsePubKeyPEM accepted malformed PEM, want error")
}
}
func TestParseEd25519PublicKeyPEM_RejectsWrongType(t *testing.T) {
pub, _ := genTestKey(t)
derBytes, _ := x509.MarshalPKIXPublicKey(pub)
wrongType := pem.EncodeToMemory(&pem.Block{Type: "RSA PUBLIC KEY", Bytes: derBytes})
if _, err := ParsePubKeyPEM(wrongType); err == nil {
t.Error("ParsePubKeyPEM accepted wrong PEM Type, want error")
}
}
func TestVerifyEd25519_ValidSignature(t *testing.T) {
pub, priv := genTestKey(t)
msg := []byte("the artifact bytes")
sig := ed25519.Sign(priv, msg)
if err := VerifyEd25519(pub, msg, sig); err != nil {
t.Errorf("VerifyEd25519 rejected a valid signature: %v", err)
}
}
func TestVerifyEd25519_TamperedMessage(t *testing.T) {
pub, priv := genTestKey(t)
original := []byte("the artifact bytes")
tampered := []byte("the artifact byteX")
sig := ed25519.Sign(priv, original)
if err := VerifyEd25519(pub, tampered, sig); err == nil {
t.Error("VerifyEd25519 accepted a tampered message, want error")
}
}
func TestVerifyEd25519_WrongKey(t *testing.T) {
_, priv := genTestKey(t)
otherPub, _ := genTestKey(t)
msg := []byte("the artifact bytes")
sig := ed25519.Sign(priv, msg)
if err := VerifyEd25519(otherPub, msg, sig); err == nil {
t.Error("VerifyEd25519 accepted a signature from the wrong key, want error")
}
}
func TestVerifyEd25519_MalformedSignature(t *testing.T) {
pub, _ := genTestKey(t)
msg := []byte("hello")
cases := [][]byte{
nil, // empty
make([]byte, 32), // too short
make([]byte, 100), // too long
make([]byte, 64), // right length, wrong contents
}
for i, sig := range cases {
if err := VerifyEd25519(pub, msg, sig); err == nil {
t.Errorf("case %d: VerifyEd25519 accepted malformed signature of length %d, want error", i, len(sig))
}
}
}
func TestVerifyEd25519_NilKey(t *testing.T) {
if err := VerifyEd25519(nil, []byte("x"), make([]byte, 64)); err == nil {
t.Error("VerifyEd25519(nil, ...) accepted, want error")
}
}
// TestFetcher_AcceptsValidSignature: end-to-end. Server publishes
// an artifact and a valid .sig; fetcher accepts and caches.
func TestFetcher_AcceptsValidSignature(t *testing.T) {
pub, priv := genTestKey(t)
body := []byte("<!doctype html><html><body>signed artifact</body></html>")
sig := ed25519.Sign(priv, body)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/archive.html":
w.Header().Set("Content-Type", "text/html")
_, _ = w.Write(body)
case "/archive.html.sig":
w.Header().Set("Content-Type", "application/octet-stream")
_, _ = w.Write(sig)
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
cache, err := NewCache(filepath.Join(t.TempDir(), "_app"))
if err != nil {
t.Fatalf("NewCache: %v", err)
}
f := NewFetcher(cache, nil)
f.VerifyKey = pub // override the embedded production key
got, err := f.Fetch(context.Background(), srv.URL+"/archive.html")
if err != nil {
t.Fatalf("Fetch failed: %v", err)
}
if string(got) != string(body) {
t.Errorf("body mismatch")
}
// Cache hit on second call.
if !cache.Has(srv.URL + "/archive.html") {
t.Error("expected cache to contain artifact after successful verification")
}
}
// TestFetcher_RejectsTamperedBody: the published .sig is valid but
// the body has been changed by a hypothetical mitm. Fetcher must
// reject and NOT cache the tampered bytes.
func TestFetcher_RejectsTamperedBody(t *testing.T) {
pub, priv := genTestKey(t)
original := []byte("<!doctype html>genuine")
sig := ed25519.Sign(priv, original)
tampered := []byte("<!doctype html>injected")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/archive.html":
_, _ = w.Write(tampered)
case "/archive.html.sig":
_, _ = w.Write(sig)
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
cache, err := NewCache(filepath.Join(t.TempDir(), "_app"))
if err != nil {
t.Fatalf("NewCache: %v", err)
}
f := NewFetcher(cache, nil)
f.VerifyKey = pub
_, err = f.Fetch(context.Background(), srv.URL+"/archive.html")
if err == nil {
t.Fatal("Fetch accepted tampered body, want error")
}
if !strings.Contains(err.Error(), "signature") {
t.Errorf("error %q does not mention signature", err)
}
if cache.Has(srv.URL + "/archive.html") {
t.Error("tampered bytes were cached; verifier must not write to cache on rejection")
}
}
// TestFetcher_RejectsMissingSignature: artifact published but no .sig
// alongside (HTTP 404). Strict mode → reject.
func TestFetcher_RejectsMissingSignature(t *testing.T) {
pub, _ := genTestKey(t)
body := []byte("body without sig")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/archive.html":
_, _ = w.Write(body)
case "/archive.html.sig":
http.NotFound(w, r)
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
cache, _ := NewCache(filepath.Join(t.TempDir(), "_app"))
f := NewFetcher(cache, nil)
f.VerifyKey = pub
_, err := f.Fetch(context.Background(), srv.URL+"/archive.html")
if err == nil {
t.Fatal("Fetch accepted unsigned artifact, want error")
}
if !strings.Contains(err.Error(), "404") && !strings.Contains(err.Error(), "signature") {
t.Errorf("error %q does not mention 404 or signature", err)
}
if cache.Has(srv.URL + "/archive.html") {
t.Error("unsigned bytes were cached; verifier must reject before caching")
}
}
// TestFetcher_RejectsWrongKeySignature: .sig present, well-formed,
// but signed by a different key than f.VerifyKey trusts.
func TestFetcher_RejectsWrongKeySignature(t *testing.T) {
trustedPub, _ := genTestKey(t)
_, attackerPriv := genTestKey(t)
body := []byte("body signed by an untrusted key")
sig := ed25519.Sign(attackerPriv, body)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/archive.html":
_, _ = w.Write(body)
case "/archive.html.sig":
_, _ = w.Write(sig)
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
cache, _ := NewCache(filepath.Join(t.TempDir(), "_app"))
f := NewFetcher(cache, nil)
f.VerifyKey = trustedPub
_, err := f.Fetch(context.Background(), srv.URL+"/archive.html")
if err == nil {
t.Fatal("Fetch accepted wrong-key-signed artifact, want error")
}
if cache.Has(srv.URL + "/archive.html") {
t.Error("wrong-key-signed bytes were cached")
}
}

View file

@ -30,6 +30,7 @@ type Config struct {
OPAURL string // --opa-url / ZDDC_OPA_URL — policy decider endpoint: "internal" (default), "http(s)://..." (real OPA via HTTP), or "unix:///..." (OPA via Unix socket)
OPAFailOpen bool // --opa-fail-open / ZDDC_OPA_FAIL_OPEN=1 — when external OPA is unreachable, allow instead of deny (default: fail closed)
OPACacheTTL time.Duration // --opa-cache-ttl / ZDDC_OPA_CACHE_TTL — external mode only: per-decision cache TTL. Default 1s. Set 0s to disable.
AppsPubKey string // --apps-pubkey / ZDDC_APPS_PUBKEY — path to the Ed25519 public key (PEM) used to verify Ed25519 signatures on URL-fetched apps: artifacts. Empty = URL apps disabled (only embedded + local-path apps work). Operators using zddc.varasys.io's canonical channels download pubkey.pem from there.
}
// ErrHelpRequested is returned by Load when --help is passed; the caller
@ -88,6 +89,8 @@ func Load(args []string) (Config, error) {
"External OPA only: on unreachable / non-2xx / malformed response, allow the request instead of denying. Default: fail closed.")
opaCacheTTLFlag := fs.Duration("opa-cache-ttl", parseDurationOrDefault(os.Getenv("ZDDC_OPA_CACHE_TTL"), time.Second),
"External OPA only: per-decision cache TTL. Amortizes round-trips on bursts of identical queries (e.g. .archive listing). Default 1s; set 0 to disable.")
appsPubKeyFlag := fs.String("apps-pubkey", os.Getenv("ZDDC_APPS_PUBKEY"),
"Path to the Ed25519 public key (PEM) used to verify signatures on URL-fetched apps: artifacts. Empty (default) = URL-fetched apps refused; only embedded + local-path apps work. Download zddc.varasys.io/pubkey.pem if you use the canonical channels.")
accessLogFlag := fs.String("access-log", os.Getenv("ZDDC_ACCESS_LOG"),
"Tee structured access logs to this file (JSON, size-rotated). "+
"Default: <ZDDC_ROOT>/.zddc.d/logs/access-<hostname>.log. "+
@ -144,6 +147,7 @@ func Load(args []string) (Config, error) {
OPAURL: *opaURLFlag,
OPAFailOpen: *opaFailOpenFlag,
OPACacheTTL: *opaCacheTTLFlag,
AppsPubKey: *appsPubKeyFlag,
}
// Default Root to the current working directory.
@ -252,6 +256,7 @@ func Usage(w io.Writer) {
fs.String("opa-url", "internal", "Policy decider: \"internal\", \"http(s)://...\", or \"unix:///...\".")
fs.Bool("opa-fail-open", false, "External OPA: allow on transport error (default: deny / fail closed).")
fs.Duration("opa-cache-ttl", time.Second, "External OPA: per-decision cache TTL (default 1s; 0 disables).")
fs.String("apps-pubkey", "", "Path to PEM Ed25519 pubkey for verifying signed URL-fetched apps. Empty = URL apps refused.")
fs.String("access-log", "", "Tee structured access logs to this file (JSON, size-rotated). Default <ZDDC_ROOT>/.zddc.d/logs/access-<hostname>.log; --access-log= disables.")
fs.Bool("help", false, "Print this help and exit.")
fs.Bool("version", false, "Print version info and exit.")