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>
255 lines
7.6 KiB
Go
255 lines
7.6 KiB
Go
package apps
|
|
|
|
import (
|
|
"context"
|
|
"crypto/ed25519"
|
|
"crypto/rand"
|
|
"crypto/x509"
|
|
"encoding/pem"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// genTestKey returns a fresh Ed25519 keypair for tests so the test
|
|
// suite never depends on the embedded production key.
|
|
func genTestKey(t *testing.T) (ed25519.PublicKey, ed25519.PrivateKey) {
|
|
t.Helper()
|
|
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
|
if err != nil {
|
|
t.Fatalf("GenerateKey: %v", err)
|
|
}
|
|
return pub, priv
|
|
}
|
|
|
|
func TestParseEd25519PublicKeyPEM_RoundTrip(t *testing.T) {
|
|
pub, _ := genTestKey(t)
|
|
derBytes, err := x509.MarshalPKIXPublicKey(pub)
|
|
if err != nil {
|
|
t.Fatalf("MarshalPKIXPublicKey: %v", err)
|
|
}
|
|
pemBytes := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: derBytes})
|
|
|
|
parsed, err := ParsePubKeyPEM(pemBytes)
|
|
if err != nil {
|
|
t.Fatalf("parse: %v", err)
|
|
}
|
|
if !pub.Equal(parsed) {
|
|
t.Errorf("round-trip pubkey mismatch")
|
|
}
|
|
}
|
|
|
|
func TestParseEd25519PublicKeyPEM_RejectsRSA(t *testing.T) {
|
|
// PEM containing a non-Ed25519 key should error rather than
|
|
// silently coerce. Use a hand-crafted bad PEM block.
|
|
bad := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: []byte("not a valid SubjectPublicKeyInfo")})
|
|
if _, err := ParsePubKeyPEM(bad); err == nil {
|
|
t.Error("ParsePubKeyPEM accepted malformed PEM, want error")
|
|
}
|
|
}
|
|
|
|
func TestParseEd25519PublicKeyPEM_RejectsWrongType(t *testing.T) {
|
|
pub, _ := genTestKey(t)
|
|
derBytes, _ := x509.MarshalPKIXPublicKey(pub)
|
|
wrongType := pem.EncodeToMemory(&pem.Block{Type: "RSA PUBLIC KEY", Bytes: derBytes})
|
|
if _, err := ParsePubKeyPEM(wrongType); err == nil {
|
|
t.Error("ParsePubKeyPEM accepted wrong PEM Type, want error")
|
|
}
|
|
}
|
|
|
|
func TestVerifyEd25519_ValidSignature(t *testing.T) {
|
|
pub, priv := genTestKey(t)
|
|
msg := []byte("the artifact bytes")
|
|
sig := ed25519.Sign(priv, msg)
|
|
if err := VerifyEd25519(pub, msg, sig); err != nil {
|
|
t.Errorf("VerifyEd25519 rejected a valid signature: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestVerifyEd25519_TamperedMessage(t *testing.T) {
|
|
pub, priv := genTestKey(t)
|
|
original := []byte("the artifact bytes")
|
|
tampered := []byte("the artifact byteX")
|
|
sig := ed25519.Sign(priv, original)
|
|
if err := VerifyEd25519(pub, tampered, sig); err == nil {
|
|
t.Error("VerifyEd25519 accepted a tampered message, want error")
|
|
}
|
|
}
|
|
|
|
func TestVerifyEd25519_WrongKey(t *testing.T) {
|
|
_, priv := genTestKey(t)
|
|
otherPub, _ := genTestKey(t)
|
|
msg := []byte("the artifact bytes")
|
|
sig := ed25519.Sign(priv, msg)
|
|
if err := VerifyEd25519(otherPub, msg, sig); err == nil {
|
|
t.Error("VerifyEd25519 accepted a signature from the wrong key, want error")
|
|
}
|
|
}
|
|
|
|
func TestVerifyEd25519_MalformedSignature(t *testing.T) {
|
|
pub, _ := genTestKey(t)
|
|
msg := []byte("hello")
|
|
cases := [][]byte{
|
|
nil, // empty
|
|
make([]byte, 32), // too short
|
|
make([]byte, 100), // too long
|
|
make([]byte, 64), // right length, wrong contents
|
|
}
|
|
for i, sig := range cases {
|
|
if err := VerifyEd25519(pub, msg, sig); err == nil {
|
|
t.Errorf("case %d: VerifyEd25519 accepted malformed signature of length %d, want error", i, len(sig))
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestVerifyEd25519_NilKey(t *testing.T) {
|
|
if err := VerifyEd25519(nil, []byte("x"), make([]byte, 64)); err == nil {
|
|
t.Error("VerifyEd25519(nil, ...) accepted, want error")
|
|
}
|
|
}
|
|
|
|
// TestFetcher_AcceptsValidSignature: end-to-end. Server publishes
|
|
// an artifact and a valid .sig; fetcher accepts and caches.
|
|
func TestFetcher_AcceptsValidSignature(t *testing.T) {
|
|
pub, priv := genTestKey(t)
|
|
body := []byte("<!doctype html><html><body>signed artifact</body></html>")
|
|
sig := ed25519.Sign(priv, body)
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/archive.html":
|
|
w.Header().Set("Content-Type", "text/html")
|
|
_, _ = w.Write(body)
|
|
case "/archive.html.sig":
|
|
w.Header().Set("Content-Type", "application/octet-stream")
|
|
_, _ = w.Write(sig)
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
defer srv.Close()
|
|
|
|
cache, err := NewCache(filepath.Join(t.TempDir(), "_app"))
|
|
if err != nil {
|
|
t.Fatalf("NewCache: %v", err)
|
|
}
|
|
f := NewFetcher(cache, nil)
|
|
f.VerifyKey = pub // override the embedded production key
|
|
got, err := f.Fetch(context.Background(), srv.URL+"/archive.html")
|
|
if err != nil {
|
|
t.Fatalf("Fetch failed: %v", err)
|
|
}
|
|
if string(got) != string(body) {
|
|
t.Errorf("body mismatch")
|
|
}
|
|
// Cache hit on second call.
|
|
if !cache.Has(srv.URL + "/archive.html") {
|
|
t.Error("expected cache to contain artifact after successful verification")
|
|
}
|
|
}
|
|
|
|
// TestFetcher_RejectsTamperedBody: the published .sig is valid but
|
|
// the body has been changed by a hypothetical mitm. Fetcher must
|
|
// reject and NOT cache the tampered bytes.
|
|
func TestFetcher_RejectsTamperedBody(t *testing.T) {
|
|
pub, priv := genTestKey(t)
|
|
original := []byte("<!doctype html>genuine")
|
|
sig := ed25519.Sign(priv, original)
|
|
tampered := []byte("<!doctype html>injected")
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/archive.html":
|
|
_, _ = w.Write(tampered)
|
|
case "/archive.html.sig":
|
|
_, _ = w.Write(sig)
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
defer srv.Close()
|
|
|
|
cache, err := NewCache(filepath.Join(t.TempDir(), "_app"))
|
|
if err != nil {
|
|
t.Fatalf("NewCache: %v", err)
|
|
}
|
|
f := NewFetcher(cache, nil)
|
|
f.VerifyKey = pub
|
|
_, err = f.Fetch(context.Background(), srv.URL+"/archive.html")
|
|
if err == nil {
|
|
t.Fatal("Fetch accepted tampered body, want error")
|
|
}
|
|
if !strings.Contains(err.Error(), "signature") {
|
|
t.Errorf("error %q does not mention signature", err)
|
|
}
|
|
if cache.Has(srv.URL + "/archive.html") {
|
|
t.Error("tampered bytes were cached; verifier must not write to cache on rejection")
|
|
}
|
|
}
|
|
|
|
// TestFetcher_RejectsMissingSignature: artifact published but no .sig
|
|
// alongside (HTTP 404). Strict mode → reject.
|
|
func TestFetcher_RejectsMissingSignature(t *testing.T) {
|
|
pub, _ := genTestKey(t)
|
|
body := []byte("body without sig")
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/archive.html":
|
|
_, _ = w.Write(body)
|
|
case "/archive.html.sig":
|
|
http.NotFound(w, r)
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
defer srv.Close()
|
|
|
|
cache, _ := NewCache(filepath.Join(t.TempDir(), "_app"))
|
|
f := NewFetcher(cache, nil)
|
|
f.VerifyKey = pub
|
|
_, err := f.Fetch(context.Background(), srv.URL+"/archive.html")
|
|
if err == nil {
|
|
t.Fatal("Fetch accepted unsigned artifact, want error")
|
|
}
|
|
if !strings.Contains(err.Error(), "404") && !strings.Contains(err.Error(), "signature") {
|
|
t.Errorf("error %q does not mention 404 or signature", err)
|
|
}
|
|
if cache.Has(srv.URL + "/archive.html") {
|
|
t.Error("unsigned bytes were cached; verifier must reject before caching")
|
|
}
|
|
}
|
|
|
|
// TestFetcher_RejectsWrongKeySignature: .sig present, well-formed,
|
|
// but signed by a different key than f.VerifyKey trusts.
|
|
func TestFetcher_RejectsWrongKeySignature(t *testing.T) {
|
|
trustedPub, _ := genTestKey(t)
|
|
_, attackerPriv := genTestKey(t)
|
|
body := []byte("body signed by an untrusted key")
|
|
sig := ed25519.Sign(attackerPriv, body)
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/archive.html":
|
|
_, _ = w.Write(body)
|
|
case "/archive.html.sig":
|
|
_, _ = w.Write(sig)
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
defer srv.Close()
|
|
|
|
cache, _ := NewCache(filepath.Join(t.TempDir(), "_app"))
|
|
f := NewFetcher(cache, nil)
|
|
f.VerifyKey = trustedPub
|
|
_, err := f.Fetch(context.Background(), srv.URL+"/archive.html")
|
|
if err == nil {
|
|
t.Fatal("Fetch accepted wrong-key-signed artifact, want error")
|
|
}
|
|
if cache.Has(srv.URL + "/archive.html") {
|
|
t.Error("wrong-key-signed bytes were cached")
|
|
}
|
|
}
|