feat(apps): also accept apps_pubkey: inline in root .zddc

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: <upstream-URL> + 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) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-05-05 06:56:02 -05:00
parent 9765fa2f5e
commit 52dde0b014
5 changed files with 54 additions and 22 deletions

View file

@ -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_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

2
build
View file

@ -717,7 +717,7 @@ openssl pkeyutl -verify -pubin -inkey pubkey.pem \
<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>
<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. Two equivalent ways: pass <code class="inline">ZDDC_APPS_PUBKEY=/path/to/pubkey.pem</code> when starting <code class="inline">zddc-server</code>, or paste the PEM contents inline as <code class="inline">apps_pubkey:</code> in the root <code class="inline">.zddc</code> file (root-only, like <code class="inline">admins:</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 ───────────── -->

View file

@ -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>/.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 `<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.
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

View file

@ -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_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

View file

@ -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 <ZDDC_ROOT>/_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.