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>
193 lines
6.7 KiB
Go
193 lines
6.7 KiB
Go
package apps
|
|
|
|
import (
|
|
"context"
|
|
"crypto/ed25519"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Fetcher pulls URL sources once, caches the body forever, and serves
|
|
// from cache on subsequent calls. Path sources don't go through here —
|
|
// the handler reads the file directly.
|
|
//
|
|
// Concurrent calls for the same URL dedupe via singleflight. There is no
|
|
// 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)
|
|
}
|
|
|
|
// NewFetcher returns a Fetcher with sensible defaults: 10s timeout, no
|
|
// redirects (ops must point at the final URL).
|
|
func NewFetcher(cache *Cache, logger *slog.Logger) *Fetcher {
|
|
if logger == nil {
|
|
logger = slog.Default()
|
|
}
|
|
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 {
|
|
return http.ErrUseLastResponse
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
// Fetch returns the body for url. If the cache already has it, returns
|
|
// the cached bytes immediately. Otherwise fetches, caches, and returns.
|
|
// All concurrent requests for the same URL share one outbound fetch.
|
|
func (f *Fetcher) Fetch(ctx context.Context, urlStr string) ([]byte, error) {
|
|
if f.Cache != nil {
|
|
if body, err := f.Cache.Read(urlStr); err == nil {
|
|
return body, nil
|
|
}
|
|
}
|
|
val, err := f.sf.Do(urlStr, func() (any, error) {
|
|
return f.fetchOnce(ctx, urlStr)
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return val.([]byte), nil
|
|
}
|
|
|
|
func (f *Fetcher) fetchOnce(ctx context.Context, urlStr string) ([]byte, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, urlStr, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
resp, err := f.Client.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return nil, fmt.Errorf("upstream %s returned HTTP %d", urlStr, resp.StatusCode)
|
|
}
|
|
const maxBytes = 25 * 1024 * 1024
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, maxBytes+1))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
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",
|
|
"url", urlStr, "err", err)
|
|
}
|
|
}
|
|
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) {
|
|
if _, loaded := f.embeddedFails.LoadOrStore(urlStr, struct{}{}); loaded {
|
|
return
|
|
}
|
|
f.Logger.Warn("serving embedded fallback for app HTML",
|
|
"app", app, "url", urlStr, "reason", reason)
|
|
}
|