From 9e46026b49a6f66ec4292bdab22ad1fe07571bf1 Mon Sep 17 00:00:00 2001 From: matthew-pilot Date: Fri, 29 May 2026 19:08:41 +0000 Subject: [PATCH] fix(app-store): add VerifyTrustAnchor to gate manifest publisher against trusted-publisher list (PILOT-243) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VerifySignature() in pkg/manifest/manifest.go only checked that the manifest signature is valid for the embedded publisher key — it did NOT verify that the publisher itself is trusted. An attacker could self-sign a manifest with their own key and it would pass. This commit: - Adds TrustedPublishers []string, a compile-time-embedded list of known-good publisher ed25519 public keys (fail-closed: empty list rejects all publishers). - Adds VerifyTrustAnchor() method that checks Store.Publisher against TrustedPublishers using bytes.Equal on the raw key bytes (encoding-agnostic). - Adds 5 tests: empty-list-fail-closed, untrusted rejection, trusted acceptance, multiple-key support, bad-format rejection. Callers should call VerifyTrustAnchor() after VerifySignature() to complete the trust chain: signature confirms integrity, trust anchor confirms the signer is known. Closes PILOT-243 --- pkg/manifest/manifest.go | 52 ++++++++++++++++- pkg/manifest/manifest_test.go | 106 ++++++++++++++++++++++++++++++++++ 2 files changed, 155 insertions(+), 3 deletions(-) diff --git a/pkg/manifest/manifest.go b/pkg/manifest/manifest.go index c952afd..5421b48 100644 --- a/pkg/manifest/manifest.go +++ b/pkg/manifest/manifest.go @@ -9,6 +9,7 @@ package manifest import ( + "bytes" "crypto/ed25519" "crypto/sha256" "encoding/base64" @@ -193,6 +194,51 @@ func (m *Manifest) signingPayload() ([]byte, error) { return []byte(payload), nil } +// TrustedPublishers is the compile-time-embedded list of publisher +// ed25519 public keys ("ed25519:" or raw base64) that are +// trusted to sign manifests. Empty list = fail-closed (no publisher +// passes the trust-anchor check). Production builds MUST populate +// this list with the known-good publisher keys. +var TrustedPublishers []string + +// VerifyTrustAnchor checks that Store.Publisher is on the trusted +// publishers list. Without this check, VerifySignature only confirms +// the manifest was signed by whoever claims to be the publisher; +// VerifyTrustAnchor confirms the publisher itself is known and trusted. +// +// Returns nil if Store.Publisher is in TrustedPublishers. +// Returns an error if TrustedPublishers is empty (fail-closed) or if +// the publisher is not found. +func (m *Manifest) VerifyTrustAnchor() error { + if len(TrustedPublishers) == 0 { + return fmt.Errorf("trust anchor: TrustedPublishers is empty — no publisher is trusted") + } + + pubkeyRaw, ok := strings.CutPrefix(m.Store.Publisher, "ed25519:") + if !ok { + return fmt.Errorf("store.publisher must be \"ed25519:\"") + } + pubkey, err := base64.StdEncoding.DecodeString(pubkeyRaw) + if err != nil { + return fmt.Errorf("store.publisher: invalid base64: %w", err) + } + if len(pubkey) != ed25519.PublicKeySize { + return fmt.Errorf("store.publisher: wrong key length %d, want %d", len(pubkey), ed25519.PublicKeySize) + } + + for _, trusted := range TrustedPublishers { + trustedRaw := strings.TrimPrefix(trusted, "ed25519:") + trustedKey, err := base64.StdEncoding.DecodeString(trustedRaw) + if err != nil { + continue // skip malformed entries + } + if bytes.Equal(pubkey, trustedKey) { + return nil + } + } + return fmt.Errorf("trust anchor: publisher %s is not on the trusted-publishers list", m.Store.Publisher) +} + // VerifySignature checks that Store.Signature is a valid ed25519 // signature over the signing payload, verified against the Store.Publisher // key embedded in the manifest. This provides cryptographic integrity — @@ -200,9 +246,9 @@ func (m *Manifest) signingPayload() ([]byte, error) { // (Publisher, ID, ManifestVersion, Binary.SHA256, Grants) will cause // verification to fail. // -// NOTE: This does NOT check that Store.Publisher is a trusted key; -// a trust-anchor check (verifying Store.Publisher against a -// daemon-embedded trusted-publisher pubkey) is the next hardening step. +// IMPORTANT: This does NOT check that Store.Publisher is a trusted key. +// Callers MUST also call VerifyTrustAnchor() after VerifySignature() +// to confirm the publisher is on the TrustedPublishers list. func (m *Manifest) VerifySignature() error { pubkeyRaw, ok := strings.CutPrefix(m.Store.Publisher, "ed25519:") if !ok { diff --git a/pkg/manifest/manifest_test.go b/pkg/manifest/manifest_test.go index bac5012..77ac0e3 100644 --- a/pkg/manifest/manifest_test.go +++ b/pkg/manifest/manifest_test.go @@ -390,6 +390,112 @@ func TestVerifySignatureRejectsEmptySignature(t *testing.T) { } } +func TestVerifyTrustAnchorEmptyListIsFailClosed(t *testing.T) { + // With TrustedPublishers empty (default), VerifyTrustAnchor must reject all publishers. + orig := TrustedPublishers + TrustedPublishers = nil + defer func() { TrustedPublishers = orig }() + + m := mustValid(t) + if err := m.VerifyTrustAnchor(); err == nil { + t.Error("expected error with empty TrustedPublishers, got nil") + } +} + +func TestVerifyTrustAnchorRejectsUntrustedPublisher(t *testing.T) { + trustedPub, _, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + untrustedPub, _, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + + orig := TrustedPublishers + TrustedPublishers = []string{"ed25519:" + base64Enc(trustedPub)} + defer func() { TrustedPublishers = orig }() + + m := mustValid(t) + m.Store.Publisher = "ed25519:" + base64Enc(untrustedPub) + if err := m.VerifyTrustAnchor(); err == nil { + t.Error("expected error for untrusted publisher, got nil") + } +} + +func TestVerifyTrustAnchorAcceptsTrustedPublisher(t *testing.T) { + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + + orig := TrustedPublishers + TrustedPublishers = []string{"ed25519:" + base64Enc(pub)} + defer func() { TrustedPublishers = orig }() + + m := mustValid(t) + m.Store.Publisher = "ed25519:" + base64Enc(pub) + sig, err := signTestManifest(m, priv) + if err != nil { + t.Fatal(err) + } + m.Store.Signature = sig + + // VerifySignature must pass for a valid signature. + if err := m.VerifySignature(); err != nil { + t.Fatalf("valid signature rejected: %v", err) + } + // VerifyTrustAnchor must pass because the publisher IS trusted. + if err := m.VerifyTrustAnchor(); err != nil { + t.Errorf("trusted publisher rejected by VerifyTrustAnchor: %v", err) + } +} + +func TestVerifyTrustAnchorMultipleTrustedKeys(t *testing.T) { + pub1, _, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + pub2, priv2, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + + orig := TrustedPublishers + TrustedPublishers = []string{ + "ed25519:" + base64Enc(pub1), + "ed25519:" + base64Enc(pub2), + } + defer func() { TrustedPublishers = orig }() + + m := mustValid(t) + m.Store.Publisher = "ed25519:" + base64Enc(pub2) + sig, err := signTestManifest(m, priv2) + if err != nil { + t.Fatal(err) + } + m.Store.Signature = sig + + if err := m.VerifySignature(); err != nil { + t.Fatalf("valid signature rejected: %v", err) + } + if err := m.VerifyTrustAnchor(); err != nil { + t.Errorf("second trusted publisher rejected: %v", err) + } +} + +func TestVerifyTrustAnchorRejectsBadPublisherFormat(t *testing.T) { + orig := TrustedPublishers + TrustedPublishers = []string{"ed25519:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="} + defer func() { TrustedPublishers = orig }() + + m := mustValid(t) + m.Store.Publisher = "not-valid-publisher" + if err := m.VerifyTrustAnchor(); err == nil { + t.Error("expected error with bad publisher format, got nil") + } +} + func hasErrorContaining(errs []error, substr string) bool { for _, e := range errs { if strings.Contains(e.Error(), substr) {