diff --git a/pkg/manifest/manifest.go b/pkg/manifest/manifest.go index 188c75b..b13807a 100644 --- a/pkg/manifest/manifest.go +++ b/pkg/manifest/manifest.go @@ -196,49 +196,52 @@ 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") +// decodeEd25519Pub parses an "ed25519:" (or bare base64) public key +// into raw bytes, validating the length. +func decodeEd25519Pub(s string) ([]byte, error) { + raw := strings.TrimPrefix(strings.TrimSpace(s), "ed25519:") + key, err := base64.StdEncoding.DecodeString(raw) + if err != nil { + return nil, fmt.Errorf("invalid base64: %w", err) } + if len(key) != ed25519.PublicKeySize { + return nil, fmt.Errorf("wrong key length %d, want %d", len(key), ed25519.PublicKeySize) + } + return key, nil +} - pubkeyRaw, ok := strings.CutPrefix(m.Store.Publisher, "ed25519:") - if !ok { - return fmt.Errorf("store.publisher must be \"ed25519:\"") +// VerifyTrustAnchor confirms that Store.Publisher matches the publisher key the +// release-signed catalogue pins for this app. This is the trust anchor for +// non-sideloaded (catalogue) installs: the catalogue is the root of trust +// (the installer verifies the catalogue signature and pins each app's bundle +// sha256), and this check re-confirms on every launch that the installed +// manifest is published by the catalogue-declared key. +// +// Without it, VerifySignature alone only proves a manifest is internally +// self-consistent — a manifest self-signed by ANY key would pass — which would +// let an app dropped into the install root run with full grants. +// +// cataloguePublisher is the "ed25519:" key the verified catalogue +// declares for m.ID; the caller (the supervisor) obtains it from the +// signature-verified catalogue via Config.CataloguePublisher. An empty string +// means the app is not pinned by the catalogue, which is fail-closed. Returns +// nil only when the manifest's publisher equals the catalogue-pinned key. +func (m *Manifest) VerifyTrustAnchor(cataloguePublisher string) error { + if strings.TrimSpace(cataloguePublisher) == "" { + return fmt.Errorf("trust anchor: %s is not pinned by the signed catalogue", m.ID) } - pubkey, err := base64.StdEncoding.DecodeString(pubkeyRaw) + pubkey, err := decodeEd25519Pub(m.Store.Publisher) if err != nil { - return fmt.Errorf("store.publisher: invalid base64: %w", err) + return fmt.Errorf("store.publisher: %w", err) } - if len(pubkey) != ed25519.PublicKeySize { - return fmt.Errorf("store.publisher: wrong key length %d, want %d", len(pubkey), ed25519.PublicKeySize) + trustedKey, err := decodeEd25519Pub(cataloguePublisher) + if err != nil { + return fmt.Errorf("catalogue publisher for %s: %w", m.ID, err) } - - 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 - } + if !bytes.Equal(pubkey, trustedKey) { + return fmt.Errorf("trust anchor: publisher %s does not match the catalogue pin for %s", m.Store.Publisher, m.ID) } - return fmt.Errorf("trust anchor: publisher %s is not on the trusted-publishers list", m.Store.Publisher) + return nil } // VerifySignature checks that Store.Signature is a valid ed25519 @@ -248,9 +251,9 @@ func (m *Manifest) VerifyTrustAnchor() error { // (Publisher, ID, ManifestVersion, Binary.SHA256, Grants) will cause // verification to fail. // -// 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. +// IMPORTANT: This does NOT check that Store.Publisher is trusted. For +// non-sideloaded apps, callers MUST also call VerifyTrustAnchor(cataloguePublisher) +// to confirm the publisher matches the key the signed catalogue pins for the app. 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 77ac0e3..d2b6496 100644 --- a/pkg/manifest/manifest_test.go +++ b/pkg/manifest/manifest_test.go @@ -390,49 +390,39 @@ 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 }() - +func TestVerifyTrustAnchorEmptyPinIsFailClosed(t *testing.T) { + // An app the catalogue does not pin (empty publisher) must be rejected, + // even if its own self-signature is valid. m := mustValid(t) - if err := m.VerifyTrustAnchor(); err == nil { - t.Error("expected error with empty TrustedPublishers, got nil") + if err := m.VerifyTrustAnchor(""); err == nil { + t.Error("expected error with empty catalogue pin (app not pinned), got nil") } } -func TestVerifyTrustAnchorRejectsUntrustedPublisher(t *testing.T) { - trustedPub, _, err := ed25519.GenerateKey(rand.Reader) +func TestVerifyTrustAnchorRejectsMismatchedPublisher(t *testing.T) { + cataloguePub, _, err := ed25519.GenerateKey(rand.Reader) if err != nil { t.Fatal(err) } - untrustedPub, _, err := ed25519.GenerateKey(rand.Reader) + otherPub, _, 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") + m.Store.Publisher = "ed25519:" + base64Enc(otherPub) + // Catalogue pins cataloguePub but the manifest is published by otherPub. + if err := m.VerifyTrustAnchor("ed25519:" + base64Enc(cataloguePub)); err == nil { + t.Error("expected error: manifest publisher does not match the catalogue pin") } } -func TestVerifyTrustAnchorAcceptsTrustedPublisher(t *testing.T) { +func TestVerifyTrustAnchorAcceptsCataloguePinnedPublisher(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) @@ -445,54 +435,29 @@ func TestVerifyTrustAnchorAcceptsTrustedPublisher(t *testing.T) { 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) + // VerifyTrustAnchor must pass: the manifest publisher equals the catalogue pin. + if err := m.VerifyTrustAnchor("ed25519:" + base64Enc(pub)); err != nil { + t.Errorf("catalogue-pinned 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), +func TestVerifyTrustAnchorRejectsBadPublisherFormat(t *testing.T) { + m := mustValid(t) + m.Store.Publisher = "not-valid-publisher" + if err := m.VerifyTrustAnchor("ed25519:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="); err == nil { + t.Error("expected error with bad publisher format, got nil") } - defer func() { TrustedPublishers = orig }() +} - m := mustValid(t) - m.Store.Publisher = "ed25519:" + base64Enc(pub2) - sig, err := signTestManifest(m, priv2) +func TestVerifyTrustAnchorRejectsBadCataloguePinFormat(t *testing.T) { + pub, _, err := ed25519.GenerateKey(rand.Reader) 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") + m.Store.Publisher = "ed25519:" + base64Enc(pub) + if err := m.VerifyTrustAnchor("not-a-valid-key"); err == nil { + t.Error("expected error with malformed catalogue pin, got nil") } } diff --git a/plugin/appstore/service.go b/plugin/appstore/service.go index f679aa8..9195ef3 100644 --- a/plugin/appstore/service.go +++ b/plugin/appstore/service.go @@ -36,6 +36,18 @@ type Config struct { // dev key via NewServiceWithKey. CatalogPubkey []byte + // CataloguePublisher returns the publisher key ("ed25519:") that the + // release-signed catalogue pins for appID, and whether appID is pinned at + // all. It is the trust anchor for non-sideloaded apps: before spawning a + // catalogue app the supervisor confirms the installed manifest's publisher + // matches this pin (manifest.VerifyTrustAnchor). The daemon supplies it from + // the catalogue it has signature-verified with CatalogPubkey. + // + // When nil (or it reports an app as not pinned), non-sideloaded apps + // fail closed — they are not spawned. Sideloaded apps bypass this and are + // clamped to the safe grant subset instead. + CataloguePublisher func(appID string) (publisher string, pinned bool) + // Logger optionally redirects internal messages. When nil the // service logs via the standard log package. Logger *log.Logger @@ -135,13 +147,13 @@ func (s *Service) Order() int { return 120 } // Go's structural typing makes this work as long as the methods used here // are present on the real types. type Deps struct { - Streams any // coreapi.Streams — Dial, Listen, SendDatagram - Identity any // coreapi.Identity — NodeID, Address, PublicKey, Sign - Resolver any - Events any // coreapi.EventBus — Publish, Subscribe - Logger any - Trust any - Telemetry TelemetryEmitter // optional; no-op when nil + Streams any // coreapi.Streams — Dial, Listen, SendDatagram + Identity any // coreapi.Identity — NodeID, Address, PublicKey, Sign + Resolver any + Events any // coreapi.EventBus — Publish, Subscribe + Logger any + Trust any + Telemetry TelemetryEmitter // optional; no-op when nil } // Start scans InstallRoot for installed apps, verifies each binary's diff --git a/plugin/appstore/service_test.go b/plugin/appstore/service_test.go index 92477a2..323e729 100644 --- a/plugin/appstore/service_test.go +++ b/plugin/appstore/service_test.go @@ -20,8 +20,9 @@ func TestStartWarnsOnPlaceholderCatalogPubkey(t *testing.T) { t.Run("placeholder (all-zeros) → warns", func(t *testing.T) { var buf strings.Builder s := NewService(Config{ - InstallRoot: t.TempDir(), - Logger: log.New(&buf, "", 0), + CataloguePublisher: testCatPub, + InstallRoot: t.TempDir(), + Logger: log.New(&buf, "", 0), }) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() @@ -38,9 +39,10 @@ func TestStartWarnsOnPlaceholderCatalogPubkey(t *testing.T) { realKey := make([]byte, 32) realKey[0] = 0x01 // any non-zero byte qualifies s := NewService(Config{ - InstallRoot: t.TempDir(), - CatalogPubkey: realKey, - Logger: log.New(&buf, "", 0), + CataloguePublisher: testCatPub, + InstallRoot: t.TempDir(), + CatalogPubkey: realKey, + Logger: log.New(&buf, "", 0), }) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() @@ -72,7 +74,7 @@ func TestNewServiceDefaults(t *testing.T) { func TestStartStopEmptyInstallRoot(t *testing.T) { dir := t.TempDir() - s := NewService(Config{InstallRoot: dir}) + s := NewService(Config{InstallRoot: dir, CataloguePublisher: testCatPub}) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() @@ -87,7 +89,7 @@ func TestStartStopEmptyInstallRoot(t *testing.T) { func TestStartCreatesInstallRoot(t *testing.T) { dir := filepath.Join(t.TempDir(), "nested", "apps") - s := NewService(Config{InstallRoot: dir}) + s := NewService(Config{InstallRoot: dir, CataloguePublisher: testCatPub}) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() @@ -111,8 +113,9 @@ func TestRescanDiscoversAppInstalledMidRun(t *testing.T) { writeValidAppDir(t, root, "io.app1") svc := NewService(Config{ - InstallRoot: root, - RescanInterval: 30 * time.Millisecond, // fast for tests + CataloguePublisher: testCatPub, + InstallRoot: root, + RescanInterval: 30 * time.Millisecond, // fast for tests }) ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() @@ -152,8 +155,9 @@ func TestRescanDetectsUninstall(t *testing.T) { writeValidAppDir(t, root, "io.app2") svc := NewService(Config{ - InstallRoot: root, - RescanInterval: 30 * time.Millisecond, + CataloguePublisher: testCatPub, + InstallRoot: root, + RescanInterval: 30 * time.Millisecond, }) ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() @@ -195,7 +199,7 @@ func TestRescanResumeClearsSuspendedMarker(t *testing.T) { root := t.TempDir() appDir := writeValidAppDir(t, root, "io.suspended.app") - sup := newSupervisor(Config{InstallRoot: root}, Deps{}, newQuietLogger(t)) + sup := newSupervisor(Config{InstallRoot: root, CataloguePublisher: testCatPub}, Deps{}, newQuietLogger(t)) sup.mu.Lock() sup.installed["io.suspended.app"] = &installedApp{ Dir: appDir, @@ -241,8 +245,9 @@ func TestRescanResumesAppOnMarker(t *testing.T) { // exceeding the crash-loop cap; we don't need the live goroutine // for the resume signal to be testable). sup := newSupervisor(Config{ - InstallRoot: root, - RescanInterval: 20 * time.Millisecond, + CataloguePublisher: testCatPub, + InstallRoot: root, + RescanInterval: 20 * time.Millisecond, }, Deps{}, newQuietLogger(t)) sup.mu.Lock() sup.installed["io.suspended.app"] = &installedApp{ @@ -285,7 +290,7 @@ func TestScanIgnoresInvalidManifest(t *testing.T) { empty := filepath.Join(root, "io.empty.app") _ = os.MkdirAll(empty, 0o755) - sup := newSupervisor(Config{InstallRoot: root}, Deps{}, newQuietLogger(t)) + sup := newSupervisor(Config{InstallRoot: root, CataloguePublisher: testCatPub}, Deps{}, newQuietLogger(t)) apps, err := sup.scanInstalled() if err != nil { t.Fatalf("scan: %v", err) diff --git a/plugin/appstore/supervisor.go b/plugin/appstore/supervisor.go index f8cd13c..9fcc9a4 100644 --- a/plugin/appstore/supervisor.go +++ b/plugin/appstore/supervisor.go @@ -58,14 +58,14 @@ const maxAuditLogSize = 10 * 1024 * 1024 // auditEvent is one line in the supervisor.log JSONL stream. // AppID + EventType + At are always populated; the rest depends on type. type auditEvent struct { - At time.Time `json:"at"` - AppID string `json:"app"` - Event string `json:"event"` // "spawn", "exit", "suspend", "verify-fail" - PID int `json:"pid,omitempty"` - ExitCode int `json:"exit_code,omitempty"` - Reason string `json:"reason,omitempty"` - SHA256 string `json:"sha256,omitempty"` // pinned hash, recorded on spawn for traceability - BinaryAt string `json:"binary_path,omitempty"` + At time.Time `json:"at"` + AppID string `json:"app"` + Event string `json:"event"` // "spawn", "exit", "suspend", "verify-fail" + PID int `json:"pid,omitempty"` + ExitCode int `json:"exit_code,omitempty"` + Reason string `json:"reason,omitempty"` + SHA256 string `json:"sha256,omitempty"` // pinned hash, recorded on spawn for traceability + BinaryAt string `json:"binary_path,omitempty"` } // writeAuditLine appends one JSON-encoded event to the app's @@ -164,9 +164,9 @@ type supervisor struct { // mu guards installed + ready + crashes + appCancel. mu sync.RWMutex - installed map[string]*installedApp // app_id → record - ready map[string]bool // app_id → socket has appeared at least once - crashes map[string]*crashRecord // app_id → sliding-window crash counter + installed map[string]*installedApp // app_id → record + ready map[string]bool // app_id → socket has appeared at least once + crashes map[string]*crashRecord // app_id → sliding-window crash counter appCancel map[string]context.CancelFunc // app_id → cancel its per-app context (used to stop a supervise goroutine on detected uninstall) } @@ -337,17 +337,21 @@ func (s *supervisor) scanInstalled() ([]*installedApp, error) { } else { // Catalogue path: a non-sideloaded install must satisfy the // FULL trust chain, not just signature integrity. - // VerifySignature alone only proves the manifest was signed - // by whoever claims to be the publisher — a self-signed - // manifest from an UNTRUSTED key passes it. VerifyTrustAnchor - // then confirms that publisher key is on the trusted list. - // Both are required; sideloading (above) is the explicit, - // local opt-out of this chain. + // VerifySignature alone only proves the manifest was signed by + // whoever claims to be the publisher — a self-signed manifest from + // ANY key passes it. VerifyTrustAnchor then anchors that publisher + // to the release-signed catalogue: it must equal the publisher key + // the catalogue pins for this app id. Both are required; sideloading + // (above) is the explicit, local opt-out of this chain. if err := m.VerifySignature(); err != nil { s.logger.Printf("skip %s: signature verification failed: %v", e.Name(), err) continue } - if err := m.VerifyTrustAnchor(); err != nil { + var cataloguePub string + if s.cfg.CataloguePublisher != nil { + cataloguePub, _ = s.cfg.CataloguePublisher(m.ID) + } + if err := m.VerifyTrustAnchor(cataloguePub); err != nil { s.logger.Printf("skip %s: publisher not trusted: %v", e.Name(), err) continue } diff --git a/plugin/appstore/testhelpers_test.go b/plugin/appstore/testhelpers_test.go index e65e48e..057b9a0 100644 --- a/plugin/appstore/testhelpers_test.go +++ b/plugin/appstore/testhelpers_test.go @@ -25,12 +25,9 @@ func newQuietLogger(t *testing.T) *log.Logger { } // testPublisherSeed is a fixed Ed25519 seed so every helper-built -// manifest is signed by the SAME publisher key. TestMain pins that key -// into manifest.TrustedPublishers exactly once, before any test runs, -// so the catalogue (non-sideloaded) trust-anchor check passes for -// helper-built apps. Using a fixed key (set once, never mutated during -// the run) keeps this race-free under `go test -race`, where supervisor -// goroutines read TrustedPublishers concurrently. +// manifest is signed by the SAME publisher key, and testCatPub pins that +// key as the catalogue publisher — so helper-built (catalogue) apps pass +// the trust anchor in supervisor tests. var testPublisherSeed = [ed25519.SeedSize]byte{ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, @@ -41,13 +38,14 @@ func testPublisherKey() (ed25519.PublicKey, ed25519.PrivateKey) { return priv.Public().(ed25519.PublicKey), priv } -// TestMain pins the fixed test publisher as the sole trust anchor for -// the whole package, then runs the suite. Set once, before any goroutine -// reads it — no mutation during the run, so no data race. -func TestMain(m *testing.M) { +// testCatPub is a Config.CataloguePublisher that pins every app id to the fixed +// test publisher key. Wire it into a supervisor Config so a writeValidAppDir app +// (signed by that key) passes VerifyTrustAnchor on the catalogue path, while an +// app signed by any other key (writeUntrustedSignedAppDir) is rejected as a +// publisher/catalogue-pin mismatch. +func testCatPub(string) (string, bool) { pub, _ := testPublisherKey() - manifest.TrustedPublishers = []string{"ed25519:" + base64.StdEncoding.EncodeToString(pub)} - os.Exit(m.Run()) + return "ed25519:" + base64.StdEncoding.EncodeToString(pub), true } // parseDummyManifest returns a minimal *manifest.Manifest with the diff --git a/plugin/appstore/zz3_supervisor_call_test.go b/plugin/appstore/zz3_supervisor_call_test.go index 3b6ecf6..e0db5c8 100644 --- a/plugin/appstore/zz3_supervisor_call_test.go +++ b/plugin/appstore/zz3_supervisor_call_test.go @@ -79,7 +79,7 @@ func TestSupervisor_Call_HappyPath(t *testing.T) { mh := parseDummyManifest(t, "io.call.happy") mh.Exposes = []string{"echo"} - sup := newSupervisor(Config{InstallRoot: dir}, Deps{}, newQuietLogger(t)) + sup := newSupervisor(Config{InstallRoot: dir, CataloguePublisher: testCatPub}, Deps{}, newQuietLogger(t)) sup.mu.Lock() sup.installed["io.call.happy"] = &installedApp{ Dir: appDir, @@ -118,7 +118,7 @@ func TestSupervisor_Call_PropagatesServerError(t *testing.T) { me := parseDummyManifest(t, "io.call.err") me.Exposes = []string{"boom"} - sup := newSupervisor(Config{InstallRoot: dir}, Deps{}, newQuietLogger(t)) + sup := newSupervisor(Config{InstallRoot: dir, CataloguePublisher: testCatPub}, Deps{}, newQuietLogger(t)) sup.mu.Lock() sup.installed["io.call.err"] = &installedApp{ Dir: appDir, @@ -146,8 +146,9 @@ func TestRescanForResume_IntegrationViaRunLoop(t *testing.T) { appDir := writeValidAppDir(t, root, "io.resume.run") svc := NewService(Config{ - InstallRoot: root, - RescanInterval: 30 * time.Millisecond, + CataloguePublisher: testCatPub, + InstallRoot: root, + RescanInterval: 30 * time.Millisecond, }) ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() @@ -188,7 +189,7 @@ func TestRescanForResume_IntegrationViaRunLoop(t *testing.T) { func TestRotateAuditIfLarge_NoActiveLogIsNoOp(t *testing.T) { t.Parallel() dir := t.TempDir() - sup := newSupervisor(Config{InstallRoot: dir, AuditLogMaxBytes: 1}, Deps{}, newQuietLogger(t)) + sup := newSupervisor(Config{InstallRoot: dir, AuditLogMaxBytes: 1, CataloguePublisher: testCatPub}, Deps{}, newQuietLogger(t)) // Should not panic or create anything. sup.rotateAuditIfLarge(dir) if entries, _ := os.ReadDir(dir); len(entries) != 0 { @@ -214,7 +215,7 @@ func TestWriteAuditLine_OpenFailure(t *testing.T) { } defer os.Chmod(appDir, 0o700) //nolint:errcheck // best-effort teardown app := &installedApp{Dir: appDir, Manifest: parseDummyManifest(t, "io.audit.locked")} - sup := newSupervisor(Config{InstallRoot: dir}, Deps{}, newQuietLogger(t)) + sup := newSupervisor(Config{InstallRoot: dir, CataloguePublisher: testCatPub}, Deps{}, newQuietLogger(t)) // Must not panic — the supervisor logs the open error and returns. sup.writeAuditLine(app, auditEvent{Event: "spawn", PID: 1}) } @@ -241,7 +242,7 @@ func TestSuperviseOne_VerifyFailRetriesPastBackoff(t *testing.T) { IDPath: filepath.Join(appDir, "identity.json"), Manifest: parseDummyManifest(t, "io.supervise.retry"), } - sup := newSupervisor(Config{InstallRoot: dir}, Deps{}, newQuietLogger(t)) + sup := newSupervisor(Config{InstallRoot: dir, CataloguePublisher: testCatPub}, Deps{}, newQuietLogger(t)) ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) defer cancel() diff --git a/plugin/appstore/zz4_downgrade_test.go b/plugin/appstore/zz4_downgrade_test.go index 54ebb02..20f3155 100644 --- a/plugin/appstore/zz4_downgrade_test.go +++ b/plugin/appstore/zz4_downgrade_test.go @@ -29,8 +29,8 @@ func TestCompareVersions(t *testing.T) { {"1.0.0", "1.0.1", -1}, {"0.0.1", "0.0.0", 1}, {"9.99.9", "10.0.0", -1}, - {"1.0.0-alpha", "1.0.0", -1}, // prerelease < release - {"1.0.0", "1.0.0-alpha", 1}, // release > prerelease + {"1.0.0-alpha", "1.0.0", -1}, // prerelease < release + {"1.0.0", "1.0.0-alpha", 1}, // release > prerelease {"1.0.0-alpha", "1.0.0-beta", -1}, {"1.0.0-beta", "1.0.0-alpha", 1}, } @@ -51,7 +51,7 @@ func TestRegisterRefusesDowngrade(t *testing.T) { root := t.TempDir() appDir := writeValidAppDir(t, root, "io.test.app") - sup := newSupervisor(Config{InstallRoot: root}, Deps{}, newQuietLogger(t)) + sup := newSupervisor(Config{InstallRoot: root, CataloguePublisher: testCatPub}, Deps{}, newQuietLogger(t)) // Register the current (newer) version first. current := &installedApp{ @@ -89,7 +89,7 @@ func TestRegisterAllowsUpgrade(t *testing.T) { root := t.TempDir() appDir := writeValidAppDir(t, root, "io.test.app") - sup := newSupervisor(Config{InstallRoot: root}, Deps{}, newQuietLogger(t)) + sup := newSupervisor(Config{InstallRoot: root, CataloguePublisher: testCatPub}, Deps{}, newQuietLogger(t)) old := &installedApp{ Dir: appDir, @@ -118,7 +118,7 @@ func TestRegisterSameVersionIsIdempotent(t *testing.T) { root := t.TempDir() appDir := writeValidAppDir(t, root, "io.test.app") - sup := newSupervisor(Config{InstallRoot: root}, Deps{}, newQuietLogger(t)) + sup := newSupervisor(Config{InstallRoot: root, CataloguePublisher: testCatPub}, Deps{}, newQuietLogger(t)) a1 := &installedApp{ Dir: appDir, @@ -194,8 +194,9 @@ func TestRescanRefusesDowngradeMidRun(t *testing.T) { appDir := writeAppDirWithVersion(t, root, "io.test.app", "2.0.0") sup := newSupervisor(Config{ - InstallRoot: root, - RescanInterval: 20 * 1e6, // not used directly in this test + CataloguePublisher: testCatPub, + InstallRoot: root, + RescanInterval: 20 * 1e6, // not used directly in this test }, Deps{}, newQuietLogger(t)) // Register the app in-memory as if already discovered at startup. @@ -231,8 +232,9 @@ func TestRescanAllowsUpgradeMidRun(t *testing.T) { appDir := writeAppDirWithVersion(t, root, "io.test.app", "1.0.0") sup := newSupervisor(Config{ - InstallRoot: root, - RescanInterval: 20 * 1e6, + CataloguePublisher: testCatPub, + InstallRoot: root, + RescanInterval: 20 * 1e6, }, Deps{}, newQuietLogger(t)) entry := &installedApp{ @@ -267,8 +269,9 @@ func TestRescanAuditLogsUpgradeApplied(t *testing.T) { appDir := writeAppDirWithVersion(t, root, "io.test.app", "1.0.0") sup := newSupervisor(Config{ - InstallRoot: root, - RescanInterval: 20 * 1e6, + CataloguePublisher: testCatPub, + InstallRoot: root, + RescanInterval: 20 * 1e6, }, Deps{}, newQuietLogger(t)) entry := &installedApp{ @@ -306,8 +309,9 @@ func TestRescanAuditLogsDowngradeRefusal(t *testing.T) { appDir := writeAppDirWithVersion(t, root, "io.test.app", "2.0.0") sup := newSupervisor(Config{ - InstallRoot: root, - RescanInterval: 20 * 1e6, + CataloguePublisher: testCatPub, + InstallRoot: root, + RescanInterval: 20 * 1e6, }, Deps{}, newQuietLogger(t)) entry := &installedApp{ diff --git a/plugin/appstore/zz_sideload_scan_test.go b/plugin/appstore/zz_sideload_scan_test.go index 96587c2..6f832d3 100644 --- a/plugin/appstore/zz_sideload_scan_test.go +++ b/plugin/appstore/zz_sideload_scan_test.go @@ -45,7 +45,7 @@ func TestScanInstalled_SideloadedSkipsSignatureWhenPolicyOK(t *testing.T) { t.Fatal(err) } - sup := newSupervisor(Config{InstallRoot: root}, Deps{}, newQuietLogger(t)) + sup := newSupervisor(Config{InstallRoot: root, CataloguePublisher: testCatPub}, Deps{}, newQuietLogger(t)) apps, err := sup.scanInstalled() if err != nil { t.Fatal(err) @@ -90,7 +90,7 @@ func TestScanInstalled_SideloadedRejectsWiderGrants(t *testing.T) { t.Fatal(err) } - sup := newSupervisor(Config{InstallRoot: root}, Deps{}, newQuietLogger(t)) + sup := newSupervisor(Config{InstallRoot: root, CataloguePublisher: testCatPub}, Deps{}, newQuietLogger(t)) apps, err := sup.scanInstalled() if err != nil { t.Fatal(err) @@ -115,7 +115,7 @@ func TestScanInstalled_UnsignedWithoutMarkerStillRejected(t *testing.T) { } // Deliberately do NOT plant .sideloaded. - sup := newSupervisor(Config{InstallRoot: root}, Deps{}, newQuietLogger(t)) + sup := newSupervisor(Config{InstallRoot: root, CataloguePublisher: testCatPub}, Deps{}, newQuietLogger(t)) apps, err := sup.scanInstalled() if err != nil { t.Fatal(err) @@ -126,25 +126,86 @@ func TestScanInstalled_UnsignedWithoutMarkerStillRejected(t *testing.T) { } // TestScanInstalled_UntrustedPublisherWithoutMarkerRejected is the -// trust-boundary regression: a manifest whose signature VERIFIES but -// whose publisher is NOT on the trusted-publishers list, with no -// `.sideloaded` marker, must be refused. Signature validity alone is -// not trust — a non-sideloaded (catalogue) install must satisfy the -// full trust-anchor check. Before the fix, scanInstalled ran only -// VerifySignature on the catalogue path, so a self-signed-by-anyone -// manifest was silently accepted and spawned. +// trust-boundary regression: a manifest whose self-signature VERIFIES but +// whose publisher does NOT match the key the signed catalogue pins for the +// app, with no `.sideloaded` marker, must be refused. Signature validity +// alone is not trust — without the anchor, a self-signed-by-anyone manifest +// dropped into the install root would be accepted and spawned. Here the +// catalogue pins the fixed test key (testCatPub) but the app is signed by a +// fresh key, so VerifyTrustAnchor rejects the publisher/pin mismatch. func TestScanInstalled_UntrustedPublisherWithoutMarkerRejected(t *testing.T) { t.Parallel() root := t.TempDir() - // Valid self-signature, fresh untrusted key, NO .sideloaded marker. + // Valid self-signature, fresh key that the catalogue does NOT pin, NO marker. writeUntrustedSignedAppDir(t, root, "io.untrusted.app") - sup := newSupervisor(Config{InstallRoot: root}, Deps{}, newQuietLogger(t)) + sup := newSupervisor(Config{InstallRoot: root, CataloguePublisher: testCatPub}, Deps{}, newQuietLogger(t)) apps, err := sup.scanInstalled() if err != nil { t.Fatal(err) } if len(apps) != 0 { - t.Fatalf("apps = %d, want 0 (validly-signed but UNTRUSTED publisher must be refused on the catalogue path)", len(apps)) + t.Fatalf("apps = %d, want 0 (publisher not matching the catalogue pin must be refused)", len(apps)) + } +} + +// TestScanInstalled_CataloguePinnedAppAccepted is the positive case: an app +// signed by the key the catalogue pins for its id, with no sideload marker, +// passes VerifySignature + VerifyTrustAnchor and is accepted on the catalogue +// path (not marked sideloaded). +func TestScanInstalled_CataloguePinnedAppAccepted(t *testing.T) { + t.Parallel() + root := t.TempDir() + writeValidAppDir(t, root, "io.pinned.app") // signed by the test key, which testCatPub pins + + sup := newSupervisor(Config{InstallRoot: root, CataloguePublisher: testCatPub}, Deps{}, newQuietLogger(t)) + apps, err := sup.scanInstalled() + if err != nil { + t.Fatal(err) + } + if len(apps) != 1 { + t.Fatalf("apps = %d, want 1 (catalogue-pinned app must be accepted)", len(apps)) + } + if apps[0].Sideloaded { + t.Error("catalogue-pinned app must not be marked sideloaded") + } +} + +// TestScanInstalled_AppNotPinnedByCatalogueRejected proves the fail-closed +// property for the *pin presence*: an app whose self-signature is valid AND +// signed by the test key, but which the catalogue does NOT list (pinned=false), +// must be refused. Only apps the release-signed catalogue vouches for may run. +func TestScanInstalled_AppNotPinnedByCatalogueRejected(t *testing.T) { + t.Parallel() + root := t.TempDir() + writeValidAppDir(t, root, "io.unpinned.app") // validly signed by the test key + notPinned := func(string) (string, bool) { return "", false } + + sup := newSupervisor(Config{InstallRoot: root, CataloguePublisher: notPinned}, Deps{}, newQuietLogger(t)) + apps, err := sup.scanInstalled() + if err != nil { + t.Fatal(err) + } + if len(apps) != 0 { + t.Fatalf("apps = %d, want 0 (an app not pinned by the signed catalogue must be refused)", len(apps)) + } +} + +// TestScanInstalled_NilCataloguePublisherFailsClosed guards the production +// default: if the daemon never wires a CataloguePublisher (nil), the supervisor +// cannot anchor any catalogue app, so non-sideloaded apps are refused rather +// than silently spawned unanchored. +func TestScanInstalled_NilCataloguePublisherFailsClosed(t *testing.T) { + t.Parallel() + root := t.TempDir() + writeValidAppDir(t, root, "io.app") + + sup := newSupervisor(Config{InstallRoot: root}, Deps{}, newQuietLogger(t)) // CataloguePublisher nil + apps, err := sup.scanInstalled() + if err != nil { + t.Fatal(err) + } + if len(apps) != 0 { + t.Fatalf("apps = %d, want 0 (nil CataloguePublisher must fail closed)", len(apps)) } }