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