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("signed artifact") 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("genuine") sig := ed25519.Sign(priv, original) tampered := []byte("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") } }