diff --git a/AGENTS.md b/AGENTS.md index 3ffb65b..330d41e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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.d/logs/access-.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 diff --git a/build b/build index 54cb164..a26d456 100755 --- a/build +++ b/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 _v*.html, _.html, + # and zddc-server_v*_(.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

Tip: leave a tool on stable 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 → .zddc apps: entry (closer-to-leaf wins) → embedded fallback.

+ +
+

Verify your downloads

+

Every artifact published here is signed with a long-lived Ed25519 key. You can verify any download against the public key using stock openssl — no special tooling. zddc-server verifies automatically when fetching tools via the apps: cascade once the operator configures ZDDC_APPS_PUBKEY — same posture as TLS certificates: the binary bakes nothing in, the operator points it at the public key they trust.

+
+
+

Public key

+

Download pubkey.pem

+

SHA-256 fingerprint of the DER-encoded SubjectPublicKeyInfo:

+
7766dc8cf963f32156ddcc96825c52ba0333ffe4c243ad54f9eaf26195b065ab
+

Verify the fingerprint after downloading:

+
openssl pkey -pubin -in pubkey.pem -outform DER | sha256sum
+
+
+

Verify a download

+

Each artifact has a matching .sig file alongside it (archive_stable.htmlarchive_stable.html.sig, etc.). Fetch both, then:

+
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
+

Output is Signature Verified Successfully 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.

+
+
+

For server deployments, configure the public key once and forget it: download pubkey.pem, save it on your server, and pass ZDDC_APPS_PUBKEY=/path/to/pubkey.pem when starting zddc-server. The resolver fetches the .sig 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 openssl verify command above as part of their save workflow.

+
+

Channels

@@ -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 diff --git a/helm/zddc-server-dev/templates/deployment.yaml b/helm/zddc-server-dev/templates/deployment.yaml index ab200b4..bbd3d67 100644 --- a/helm/zddc-server-dev/templates/deployment.yaml +++ b/helm/zddc-server-dev/templates/deployment.yaml @@ -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 diff --git a/zddc/README.md b/zddc/README.md index acda5ef..bbb43a5 100644 --- a/zddc/README.md +++ b/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) | @@ -854,53 +855,48 @@ sidesteps the operator entirely: (e.g. `.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 `_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 + (`.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 `.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) diff --git a/zddc/cmd/zddc-server/main.go b/zddc/cmd/zddc-server/main.go index 87f982f..d257045 100644 --- a/zddc/cmd/zddc-server/main.go +++ b/zddc/cmd/zddc-server/main.go @@ -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 } diff --git a/zddc/cmd/zddc-server/main_test.go b/zddc/cmd/zddc-server/main_test.go index b292528..b583056 100644 --- a/zddc/cmd/zddc-server/main_test.go +++ b/zddc/cmd/zddc-server/main_test.go @@ -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("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() diff --git a/zddc/internal/apps/fetch.go b/zddc/internal/apps/fetch.go index 97aa82e..7572231 100644 --- a/zddc/internal/apps/fetch.go +++ b/zddc/internal/apps/fetch.go @@ -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 .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 .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) { diff --git a/zddc/internal/apps/handler_test.go b/zddc/internal/apps/handler_test.go index d578439..2cb0bb9 100644 --- a/zddc/internal/apps/handler_test.go +++ b/zddc/internal/apps/handler_test.go @@ -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{{ diff --git a/zddc/internal/apps/verify.go b/zddc/internal/apps/verify.go new file mode 100644 index 0000000..63168aa --- /dev/null +++ b/zddc/internal/apps/verify.go @@ -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 +} diff --git a/zddc/internal/apps/verify_test.go b/zddc/internal/apps/verify_test.go new file mode 100644 index 0000000..1e9b5f9 --- /dev/null +++ b/zddc/internal/apps/verify_test.go @@ -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("signed artifact") + 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("genuine") + sig := ed25519.Sign(priv, original) + tampered := []byte("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") + } +} diff --git a/zddc/internal/config/config.go b/zddc/internal/config/config.go index 481f8b7..1b6caf5 100644 --- a/zddc/internal/config/config.go +++ b/zddc/internal/config/config.go @@ -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.d/logs/access-.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.d/logs/access-.log; --access-log= disables.") fs.Bool("help", false, "Print this help and exit.") fs.Bool("version", false, "Print version info and exit.")