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>
This commit is contained in:
ZDDC 2026-05-04 21:59:07 -05:00
parent 8b33683a59
commit 9765fa2f5e
11 changed files with 715 additions and 49 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

104
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 +
@ -620,6 +693,33 @@ PIN_MID
<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>
@ -801,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) |
@ -854,53 +855,48 @@ sidesteps the operator entirely:
(e.g. `<artifact>.sig`), verifies against the configured public key,
caches if valid.
**Implementation has three parts** (each is a separate piece of work
that interlocks with the others):
**Implementation has three parts** that interlock:
1. **Signing in the build pipeline.** `./build alpha|beta|release`
gains a signing step: after producing each `<tool>_v*.html` and
each `zddc-server_v*` binary, sign with a project key (Ed25519 or
RSA) and publish the `.sig` alongside. Private key stored
securely (HashiCorp Vault, GitHub Actions secret, etc.).
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.** A "Verify your downloads" section
on the releases page with the PEM public key inline, a download
link to `pubkey.pem`, the key fingerprint shown in plain text,
and a `curl` example for verifying a downloaded artifact.
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.** New code in `apps/fetch.go` reading
a `signed_by:` field from `.zddc apps:` syntax (or just an
embedded default trusting the project key) and validating each
fetched artifact's signature before caching it. Two
implementation flavors:
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.
- *Simple:* stdlib `crypto/ed25519`. ~200 lines including the
`apps:` schema extension to accept `signed_by:` and the
fetch-and-verify path. Fine for trusting one project key.
- *Sigstore:* `github.com/sigstore/sigstore` for transparency-
log-backed signing (Rekor inclusion proofs). Larger
dependency (~5MB). Federally-acceptable; aligns with how
modern federal stacks (DoD's Iron Bank, etc.) consume signed
artifacts.
**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.
**Recommended sequencing:** start with the simple Ed25519 case for the
project's own artifacts. Add Sigstore as an opt-in (`signed_by:
sigstore:...`) when a federal customer engages.
**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.** Nothing — no signing, no `.sig` files,
no verifier. The current `apps/fetch.go` is fetch-once-cached-forever
with no integrity check. The whole feature is greenfield.
**Effort estimate (simple Ed25519 case):**
- `./build` signing step + key generation procedure: ~100 lines + ops
documentation.
- `pubkey.pem` distribution on the website + verify section in
`index.html` releases page: ~50 lines of HTML.
- `apps/` parser extension for `signed_by:` field: ~50 lines.
- Verifier logic in `apps/fetch.go`: ~150 lines.
- Tests: ~150 lines.
Total: ~500 lines net plus a key-management procedure.
**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)

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.")