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>
427 lines
15 KiB
Go
427 lines
15 KiB
Go
package apps
|
|
|
|
import (
|
|
"crypto/ed25519"
|
|
"crypto/rand"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
|
|
"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
|
|
}{
|
|
{"/", "landing", ""},
|
|
{"/index.html", "landing", ""},
|
|
{"/archive.html", "archive", ""},
|
|
{"/Project-X/archive.html", "archive", "Project-X"},
|
|
{"/Project-X/Working/mdedit.html", "mdedit", "Project-X/Working"},
|
|
{"/foo.html", "", ""},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.path, func(t *testing.T) {
|
|
gotApp, gotDir := MatchAppHTML(tc.path)
|
|
if gotApp != tc.wantApp || gotDir != tc.wantDir {
|
|
t.Errorf("got (%q,%q), want (%q,%q)", gotApp, gotDir, tc.wantApp, tc.wantDir)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// 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()
|
|
pub, handler := signedFixture(t, body)
|
|
upstream := httptest.NewServer(handler)
|
|
t.Cleanup(upstream.Close)
|
|
root := t.TempDir()
|
|
cache, err := NewCache(filepath.Join(root, CacheDirName))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
f := NewFetcher(cache, nil)
|
|
f.VerifyKey = pub
|
|
return NewServer(root, cache, f, "test"), upstream, root
|
|
}
|
|
|
|
func TestServer_NoOverride_ServesEmbedded(t *testing.T) {
|
|
srv, _, root := newTestServer(t, []byte("upstream body"))
|
|
saved := embeddedArchive
|
|
embeddedArchive = []byte("EMBEDDED archive")
|
|
defer func() { embeddedArchive = saved }()
|
|
|
|
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{}}}
|
|
rec := httptest.NewRecorder()
|
|
srv.Serve(rec, httptest.NewRequest(http.MethodGet, "/archive.html", nil), "archive", chain, root)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status=%d", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), "EMBEDDED") {
|
|
t.Errorf("expected embedded body, got %q", rec.Body.String())
|
|
}
|
|
if !strings.HasPrefix(rec.Header().Get("X-ZDDC-Source"), "embedded:archive@") {
|
|
t.Errorf("X-ZDDC-Source=%q", rec.Header().Get("X-ZDDC-Source"))
|
|
}
|
|
}
|
|
|
|
func TestServer_OverrideURL_FetchesAndCaches(t *testing.T) {
|
|
body := []byte("from upstream")
|
|
srv, up, root := newTestServer(t, body)
|
|
chain := zddc.PolicyChain{
|
|
Levels: []zddc.ZddcFile{{
|
|
Apps: map[string]string{"archive": up.URL + "/archive_stable.html"},
|
|
}},
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
srv.Serve(rec, httptest.NewRequest(http.MethodGet, "/archive.html", nil), "archive", chain, root)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status=%d", rec.Code)
|
|
}
|
|
if rec.Body.String() != string(body) {
|
|
t.Errorf("body mismatch")
|
|
}
|
|
// Cache should be populated.
|
|
if !srv.Cache.Has(up.URL + "/archive_stable.html") {
|
|
t.Errorf("cache miss after fetch")
|
|
}
|
|
}
|
|
|
|
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) {
|
|
// 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{{
|
|
Apps: map[string]string{"archive": upstream.URL + "/archive_stable.html"},
|
|
}}}
|
|
for i := 0; i < 3; i++ {
|
|
rec := httptest.NewRecorder()
|
|
srv.Serve(rec, httptest.NewRequest(http.MethodGet, "/archive.html", nil), "archive", chain, root)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("call %d status=%d", i, rec.Code)
|
|
}
|
|
}
|
|
if hits.Load() != 1 {
|
|
t.Errorf("upstream fetched %d times, want exactly 1 (cache forever)", hits.Load())
|
|
}
|
|
}
|
|
|
|
func TestServer_PathOverride_ServedDirectly(t *testing.T) {
|
|
root := t.TempDir()
|
|
pathFile := filepath.Join(root, "local.html")
|
|
body := []byte("local archive bytes")
|
|
if err := os.WriteFile(pathFile, body, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cache, _ := NewCache(filepath.Join(root, CacheDirName))
|
|
f := NewFetcher(cache, nil)
|
|
srv := NewServer(root, cache, f, "test")
|
|
|
|
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{
|
|
{Apps: map[string]string{"archive": "./local.html"}},
|
|
}}
|
|
rec := httptest.NewRecorder()
|
|
srv.Serve(rec, httptest.NewRequest(http.MethodGet, "/archive.html", nil), "archive", chain, root)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status=%d", rec.Code)
|
|
}
|
|
if rec.Body.String() != string(body) {
|
|
t.Errorf("body mismatch")
|
|
}
|
|
if !strings.HasPrefix(rec.Header().Get("X-ZDDC-Source"), "path:") {
|
|
t.Errorf("X-ZDDC-Source=%q", rec.Header().Get("X-ZDDC-Source"))
|
|
}
|
|
}
|
|
|
|
func TestServer_FetchFailFallsBackToEmbedded(t *testing.T) {
|
|
srv, _, root := newTestServer(t, []byte("ok"))
|
|
saved := embeddedArchive
|
|
embeddedArchive = []byte("EMBEDDED")
|
|
defer func() { embeddedArchive = saved }()
|
|
|
|
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{
|
|
Apps: map[string]string{"archive": "https://no-such.example/archive.html"},
|
|
}}}
|
|
rec := httptest.NewRecorder()
|
|
srv.Serve(rec, httptest.NewRequest(http.MethodGet, "/archive.html", nil), "archive", chain, root)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status=%d (want 200 from embedded)", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), "EMBEDDED") {
|
|
t.Errorf("body did not come from embedded fallback: %q", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
// ── ?v= per-request override ─────────────────────────────────────────────
|
|
|
|
func TestServer_VParam_CacheHitServesFromCache(t *testing.T) {
|
|
srv, _, root := newTestServer(t, []byte("ignored"))
|
|
// Pre-populate the cache with a known URL.
|
|
cachedURL := "https://zddc.varasys.io/releases/archive_beta.html"
|
|
cachedBody := []byte("CACHED beta archive")
|
|
if err := srv.Cache.Write(cachedURL, cachedBody); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{}}}
|
|
rec := httptest.NewRecorder()
|
|
srv.Serve(rec, httptest.NewRequest(http.MethodGet, "/archive.html?v=beta", nil), "archive", chain, root)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
if rec.Body.String() != string(cachedBody) {
|
|
t.Errorf("body=%q, want CACHED bytes", rec.Body.String())
|
|
}
|
|
if got := rec.Header().Get("X-ZDDC-Source"); got != "cache:"+cachedURL {
|
|
t.Errorf("X-ZDDC-Source=%q", got)
|
|
}
|
|
}
|
|
|
|
func TestServer_VParam_CacheMissReturns404(t *testing.T) {
|
|
srv, _, root := newTestServer(t, []byte("ignored"))
|
|
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{}}}
|
|
rec := httptest.NewRecorder()
|
|
srv.Serve(rec, httptest.NewRequest(http.MethodGet, "/archive.html?v=beta", nil), "archive", chain, root)
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Fatalf("status=%d (want 404)", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), "not in the local cache") {
|
|
t.Errorf("body should explain cache miss, got %q", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestServer_VParam_RejectsPathSource(t *testing.T) {
|
|
srv, _, root := newTestServer(t, []byte("ignored"))
|
|
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{}}}
|
|
rec := httptest.NewRecorder()
|
|
srv.Serve(rec, httptest.NewRequest(http.MethodGet, "/archive.html?v=./local.html", nil), "archive", chain, root)
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Errorf("status=%d (want 400 for path source via ?v=)", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestServer_VParam_BadSpecReturns400(t *testing.T) {
|
|
srv, _, root := newTestServer(t, []byte("ignored"))
|
|
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{}}}
|
|
rec := httptest.NewRecorder()
|
|
srv.Serve(rec, httptest.NewRequest(http.MethodGet, "/archive.html?v=not%20a%20spec", nil), "archive", chain, root)
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Errorf("status=%d (want 400)", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestServer_VParam_CombinesWithCascadeURLPrefix(t *testing.T) {
|
|
// Cascade has a default URL prefix; ?v=:beta should resolve against it.
|
|
srv, _, root := newTestServer(t, []byte("ignored"))
|
|
cachedURL := "https://my-mirror.example/releases/archive_beta.html"
|
|
if err := srv.Cache.Write(cachedURL, []byte("MIRROR beta")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{
|
|
Apps: map[string]string{"default": "https://my-mirror.example/releases:stable"},
|
|
}}}
|
|
rec := httptest.NewRecorder()
|
|
srv.Serve(rec, httptest.NewRequest(http.MethodGet, "/archive.html?v=:beta", nil), "archive", chain, root)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
if rec.Body.String() != "MIRROR beta" {
|
|
t.Errorf("body=%q", rec.Body.String())
|
|
}
|
|
if got := rec.Header().Get("X-ZDDC-Source"); got != "cache:"+cachedURL {
|
|
t.Errorf("X-ZDDC-Source=%q (expected mirror URL)", got)
|
|
}
|
|
}
|
|
|
|
func TestServer_VParam_OverridesPathTerminalFromCascade(t *testing.T) {
|
|
// Operator's cascade specifies a path source. User passes ?v=stable.
|
|
// ?v= overrides → resolves to canonical/archive_stable.html, then cache check.
|
|
srv, _, root := newTestServer(t, []byte("ignored"))
|
|
cachedURL := "https://zddc.varasys.io/releases/archive_stable.html"
|
|
if err := srv.Cache.Write(cachedURL, []byte("CACHED stable")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
pathFile := filepath.Join(root, "operator-version.html")
|
|
if err := os.WriteFile(pathFile, []byte("OPERATOR PATH"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{
|
|
Apps: map[string]string{"archive": "./operator-version.html"},
|
|
}}}
|
|
rec := httptest.NewRecorder()
|
|
srv.Serve(rec, httptest.NewRequest(http.MethodGet, "/archive.html?v=stable", nil), "archive", chain, root)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
if rec.Body.String() != "CACHED stable" {
|
|
t.Errorf("body=%q (expected ?v= override to win)", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestServer_VParam_FullURLForm(t *testing.T) {
|
|
// `?v=https://my-fork/archive.html` — terminal full URL, must be cached.
|
|
srv, _, root := newTestServer(t, []byte("ignored"))
|
|
cachedURL := "https://my-fork.example/custom.html"
|
|
if err := srv.Cache.Write(cachedURL, []byte("FORK custom")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{}}}
|
|
target := "/archive.html?v=" + url.QueryEscape(cachedURL)
|
|
rec := httptest.NewRecorder()
|
|
srv.Serve(rec, httptest.NewRequest(http.MethodGet, target, nil), "archive", chain, root)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
if rec.Body.String() != "FORK custom" {
|
|
t.Errorf("body=%q", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestServer_Embedded_ConditionalGET verifies the ETag/If-None-Match dance
|
|
// for the embedded fallback path: a fresh GET returns 200 with an ETag,
|
|
// and a follow-up with a matching If-None-Match returns 304 + empty body.
|
|
// This is the cache-friendliness fix that lets a browser revalidate
|
|
// against zddc-server's embedded HTML without re-transferring the bytes.
|
|
func TestServer_Embedded_ConditionalGET(t *testing.T) {
|
|
srv, _, root := newTestServer(t, []byte("upstream"))
|
|
|
|
saved := embeddedArchive
|
|
embeddedArchive = []byte("EMBEDDED archive bytes for ETag test")
|
|
defer func() {
|
|
embeddedArchive = saved
|
|
etagCacheByApp.Delete("archive") // reset memoization for sibling tests
|
|
}()
|
|
etagCacheByApp.Delete("archive") // ensure clean state for THIS test
|
|
|
|
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{}}}
|
|
|
|
// First request: full body + ETag header.
|
|
rec1 := httptest.NewRecorder()
|
|
srv.Serve(rec1, httptest.NewRequest(http.MethodGet, "/archive.html", nil), "archive", chain, root)
|
|
if rec1.Code != http.StatusOK {
|
|
t.Fatalf("first GET: status=%d body=%s", rec1.Code, rec1.Body.String())
|
|
}
|
|
etag := rec1.Header().Get("ETag")
|
|
if etag == "" {
|
|
t.Fatalf("first GET: missing ETag header")
|
|
}
|
|
if cc := rec1.Header().Get("Cache-Control"); !strings.Contains(cc, "max-age=0") || !strings.Contains(cc, "must-revalidate") {
|
|
t.Errorf("first GET: Cache-Control=%q (want max-age=0 + must-revalidate)", cc)
|
|
}
|
|
if !strings.Contains(rec1.Body.String(), "EMBEDDED archive bytes") {
|
|
t.Errorf("first GET: body=%q", rec1.Body.String())
|
|
}
|
|
|
|
// Second request with matching If-None-Match: 304, empty body.
|
|
rec2 := httptest.NewRecorder()
|
|
req2 := httptest.NewRequest(http.MethodGet, "/archive.html", nil)
|
|
req2.Header.Set("If-None-Match", etag)
|
|
srv.Serve(rec2, req2, "archive", chain, root)
|
|
if rec2.Code != http.StatusNotModified {
|
|
t.Fatalf("If-None-Match match: status=%d (want 304)", rec2.Code)
|
|
}
|
|
if rec2.Body.Len() != 0 {
|
|
t.Errorf("304 response should have empty body; got %d bytes", rec2.Body.Len())
|
|
}
|
|
|
|
// Third request with stale If-None-Match: 200, full body.
|
|
rec3 := httptest.NewRecorder()
|
|
req3 := httptest.NewRequest(http.MethodGet, "/archive.html", nil)
|
|
req3.Header.Set("If-None-Match", `"deadbeef"`)
|
|
srv.Serve(rec3, req3, "archive", chain, root)
|
|
if rec3.Code != http.StatusOK {
|
|
t.Errorf("stale If-None-Match: status=%d (want 200)", rec3.Code)
|
|
}
|
|
if rec3.Body.Len() == 0 {
|
|
t.Errorf("stale If-None-Match: empty body; want full")
|
|
}
|
|
}
|
|
|
|
// TestEmbeddedETag_Stable asserts EmbeddedETag is deterministic and
|
|
// content-addressed: same bytes → same ETag, different bytes → different.
|
|
func TestEmbeddedETag_Stable(t *testing.T) {
|
|
saved := embeddedArchive
|
|
defer func() {
|
|
embeddedArchive = saved
|
|
etagCacheByApp.Delete("archive")
|
|
}()
|
|
|
|
embeddedArchive = []byte("alpha")
|
|
etagCacheByApp.Delete("archive")
|
|
a1 := EmbeddedETag("archive")
|
|
a2 := EmbeddedETag("archive")
|
|
if a1 == "" || a1 != a2 {
|
|
t.Errorf("EmbeddedETag should be stable for same bytes; got %q vs %q", a1, a2)
|
|
}
|
|
|
|
embeddedArchive = []byte("beta")
|
|
etagCacheByApp.Delete("archive")
|
|
b := EmbeddedETag("archive")
|
|
if b == a1 {
|
|
t.Errorf("EmbeddedETag should differ for different bytes; both %q", b)
|
|
}
|
|
}
|