Compare commits
2 commits
570604ca80
...
9765fa2f5e
| Author | SHA1 | Date | |
|---|---|---|---|
| 9765fa2f5e | |||
| 8b33683a59 |
11 changed files with 1057 additions and 13 deletions
|
|
@ -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
199
build
|
|
@ -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 → <code class="inline">.zddc apps:</code> entry (closer-to-leaf wins) → 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> → <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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
289
zddc/README.md
289
zddc/README.md
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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{{
|
||||
|
|
|
|||
73
zddc/internal/apps/verify.go
Normal file
73
zddc/internal/apps/verify.go
Normal 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
|
||||
}
|
||||
255
zddc/internal/apps/verify_test.go
Normal file
255
zddc/internal/apps/verify_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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.")
|
||||
|
|
|
|||
Loading…
Reference in a new issue