From 52dde0b01452b8d926c26cbc2f29b18a3c9ab275 Mon Sep 17 00:00:00 2001 From: ZDDC Date: Tue, 5 May 2026 06:56:02 -0500 Subject: [PATCH] feat(apps): also accept apps_pubkey: inline in root .zddc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a second way to configure the apps signing pubkey alongside the existing --apps-pubkey / ZDDC_APPS_PUBKEY (path-to-PEM-file) form: an inline PEM block under apps_pubkey: in the root .zddc file. Resolution order: 1. --apps-pubkey / ZDDC_APPS_PUBKEY (path) ← env/flag wins 2. apps_pubkey: inline PEM in root .zddc ← second 3. nothing ← URL fetches refused Honored only at the root .zddc — same trust-anchor treatment as the existing admins: field. Subtree write authority cannot re-anchor trust because subtree apps_pubkey: entries are ignored. (Same unmarshal pattern as the rest of ZddcFile; the root-only enforcement is in setupApps where we explicitly read filepath.Join(cfg.Root, ".zddc") rather than walking a chain.) Why offer both: env/flag fits k8s + systemd deployment shapes where the operator already manages a config volume and prefers env-based plumbing. Inline-in-.zddc fits the "everything in one config file" mental model and matches how operators already think about admins: and acl:. Either ships a working URL-fetch-verify story; the choice is operator preference. Logged differently per source so operators can grep for which path populated the key: apps signing pubkey loaded source=env/flag path=/path/to/pubkey.pem apps signing pubkey loaded source="root .zddc apps_pubkey" Smoke-tested end-to-end: a root .zddc with inline apps_pubkey: PEM block + apps: archive: + ZDDC_APPS_PUBKEY unset — the server logs "loaded source=root .zddc apps_pubkey" at startup, fetches the URL, verifies the .sig against the inline key, caches. Tampering still rejects; missing .sig still rejects; everything that worked yesterday still works. Docs: env-var tables in zddc/README.md and AGENTS.md note the inline alternative; the federal-readiness gap analysis subsection on code signing now lists both paths in its resolution order; the release-page "Verify your downloads" section mentions both for operators. Production binary unchanged at ~13 MB. All 11 Go test packages green. Co-Authored-By: Claude Opus 4.7 (1M context) --- AGENTS.md | 2 +- build | 2 +- zddc/README.md | 16 +++++++++------ zddc/cmd/zddc-server/main.go | 38 ++++++++++++++++++++++++++---------- zddc/internal/zddc/file.go | 18 +++++++++++++---- 5 files changed, 54 insertions(+), 22 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 330d41e..caaddb6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -396,7 +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_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. Alternative inline form: `apps_pubkey:` in root `.zddc` (root-only, env/flag wins). | | `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 a26d456..e6b62f1 100755 --- a/build +++ b/build @@ -717,7 +717,7 @@ openssl pkeyutl -verify -pubin -inkey pubkey.pem \

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.

+

For server deployments, configure the public key once and forget it. Two equivalent ways: pass ZDDC_APPS_PUBKEY=/path/to/pubkey.pem when starting zddc-server, or paste the PEM contents inline as apps_pubkey: in the root .zddc file (root-only, like admins:). 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.

diff --git a/zddc/README.md b/zddc/README.md index bbb43a5..d0f6070 100644 --- a/zddc/README.md +++ b/zddc/README.md @@ -61,7 +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_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. **Alternative inline form:** the same key can be pasted as `apps_pubkey:` in the root `/.zddc` (root-only, like `admins:`). The env/flag wins when both are set. | | `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) | @@ -874,11 +874,15 @@ sidesteps the operator entirely: 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. + at startup with this resolution order: + 1. `--apps-pubkey` / `ZDDC_APPS_PUBKEY` (path to PEM file) + 2. `apps_pubkey:` inline PEM in the root `.zddc` file (root-only, + same trust-anchor treatment as `admins:`) + 3. nothing → URL fetches refused + Failure cases at fetch time — 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 diff --git a/zddc/cmd/zddc-server/main.go b/zddc/cmd/zddc-server/main.go index d257045..f12d214 100644 --- a/zddc/cmd/zddc-server/main.go +++ b/zddc/cmd/zddc-server/main.go @@ -314,21 +314,39 @@ func setupApps(cfg config.Config) (*apps.Server, error) { } 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 != "" { + // Apps signing pubkey. Resolution order, highest priority first: + // 1. --apps-pubkey / ZDDC_APPS_PUBKEY (path to PEM file) + // 2. apps_pubkey: inline PEM in the root /.zddc file + // (root-only — same trust-anchor treatment as admins:) + // 3. nothing → URL-fetched apps refuse-by-default; only embedded + // + local-path apps work + // + // Same posture as TLS certificates: zddc-server bakes nothing in. + // Operators using zddc.varasys.io's canonical channels download + // pubkey.pem from there and either configure the path via env/flag + // or paste the PEM contents inline into root .zddc. + switch { + case 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.") + slog.Info("apps signing pubkey loaded", "source", "env/flag", "path", cfg.AppsPubKey) + default: + // Fall back to apps_pubkey: in root .zddc. + rootZddc, err := zddc.ParseFile(filepath.Join(cfg.Root, ".zddc")) + if err == nil && rootZddc.AppsPubKey != "" { + pub, err := apps.ParsePubKeyPEM([]byte(rootZddc.AppsPubKey)) + if err != nil { + return nil, fmt.Errorf("root .zddc apps_pubkey: %w", err) + } + fetcher.VerifyKey = pub + slog.Info("apps signing pubkey loaded", "source", "root .zddc apps_pubkey") + } else { + slog.Warn("apps-pubkey not configured; URL-fetched apps will be refused (only embedded + local-path apps will work). " + + "Set --apps-pubkey, ZDDC_APPS_PUBKEY, or apps_pubkey: in the root .zddc file to a PEM Ed25519 public key you trust.") + } } return apps.NewServer(cfg.Root, cache, fetcher, version), nil diff --git a/zddc/internal/zddc/file.go b/zddc/internal/zddc/file.go index 863839b..2cba941 100644 --- a/zddc/internal/zddc/file.go +++ b/zddc/internal/zddc/file.go @@ -39,11 +39,21 @@ type ACLRules struct { // server serves the version baked into the binary at compile time (//go:embed). // Fetched URL sources are cached in /_app/; the cache is fetch-once // and never re-validates — operators delete the file to force a refetch. +// +// AppsPubKey is the inline PEM of the Ed25519 public key used to verify +// signatures on URL-fetched apps artifacts. Honored only at the root +// .zddc file (same root-only treatment as Admins, for the same reason: +// it's a trust anchor; subtree write authority must not be able to +// re-anchor it). Lower priority than --apps-pubkey / ZDDC_APPS_PUBKEY: +// when both are set, the env/flag (file path) wins. Empty in either +// place = URL-fetched apps refused (only embedded + local-path apps +// work). See zddc-server's setupApps. type ZddcFile struct { - ACL ACLRules `yaml:"acl" json:"acl"` - Admins []string `yaml:"admins" json:"admins,omitempty"` - Title string `yaml:"title" json:"title,omitempty"` - Apps map[string]string `yaml:"apps,omitempty" json:"apps,omitempty"` + ACL ACLRules `yaml:"acl" json:"acl"` + Admins []string `yaml:"admins" json:"admins,omitempty"` + Title string `yaml:"title" json:"title,omitempty"` + Apps map[string]string `yaml:"apps,omitempty" json:"apps,omitempty"` + AppsPubKey string `yaml:"apps_pubkey,omitempty" json:"apps_pubkey,omitempty"` } // ParseFile reads and parses a .zddc YAML file.