From 2f8bed69ca472d9f99fa721bed469e5c7c00ae40 Mon Sep 17 00:00:00 2001 From: Teodor Calin Date: Mon, 22 Jun 2026 17:36:50 +0300 Subject: [PATCH] Enforce trust anchor for non-sideloaded installs scanInstalled ran only VerifySignature on the catalogue (non-sideloaded) path, so a manifest self-signed by any key was accepted and spawned. Signature validity is integrity, not trust: a non-sideloaded install must also pass VerifyTrustAnchor against the trusted-publishers list. Sideloading remains the explicit local opt-out of this chain. Add a regression test: a validly self-signed manifest from an untrusted publisher, with no .sideloaded marker, is refused. --- plugin/appstore/supervisor.go | 14 +++- plugin/appstore/testhelpers_test.go | 97 ++++++++++++++++++++++-- plugin/appstore/zz4_downgrade_test.go | 6 +- plugin/appstore/zz_sideload_scan_test.go | 24 ++++++ 4 files changed, 126 insertions(+), 15 deletions(-) diff --git a/plugin/appstore/supervisor.go b/plugin/appstore/supervisor.go index af3fea9..f8cd13c 100644 --- a/plugin/appstore/supervisor.go +++ b/plugin/appstore/supervisor.go @@ -335,12 +335,22 @@ func (s *supervisor) scanInstalled() ([]*installedApp, error) { continue } } else { - // Catalogue path: signature must verify against the - // publisher key embedded in the manifest. + // 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. 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 { + s.logger.Printf("skip %s: publisher not trusted: %v", e.Name(), err) + continue + } } // Reject path traversal in manifest.binary.path. Without this // a manifest containing binary.path="../../../bin/sh" (or any diff --git a/plugin/appstore/testhelpers_test.go b/plugin/appstore/testhelpers_test.go index 8d01870..e65e48e 100644 --- a/plugin/appstore/testhelpers_test.go +++ b/plugin/appstore/testhelpers_test.go @@ -24,6 +24,32 @@ func newQuietLogger(t *testing.T) *log.Logger { return log.New(io.Discard, "", 0) } +// 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. +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, +} + +func testPublisherKey() (ed25519.PublicKey, ed25519.PrivateKey) { + priv := ed25519.NewKeyFromSeed(testPublisherSeed[:]) + 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) { + pub, _ := testPublisherKey() + manifest.TrustedPublishers = []string{"ed25519:" + base64.StdEncoding.EncodeToString(pub)} + os.Exit(m.Run()) +} + // parseDummyManifest returns a minimal *manifest.Manifest with the // given id. Used by tests that need a manifest struct without going // through the disk layout. The values are intentionally minimal — the @@ -40,12 +66,13 @@ func parseDummyManifest(t *testing.T, id string) *manifest.Manifest { } // writeValidAppDir creates //manifest.json with a manifest -// that passes manifest.Parse + Validate + VerifySignature (so scanInstalled -// accepts it). A fresh ed25519 keypair is generated per call so every -// test app has a self-consistent signature. No binary is written — the -// supervisor will hit verify-fail when it tries to spawn, but for tests -// that only care about discovery / registration (rescan, Apps()) that's -// the desired behavior. +// that passes manifest.Parse + Validate + VerifySignature AND +// VerifyTrustAnchor (so scanInstalled accepts it on the catalogue path). +// It signs with the fixed test publisher key that TestMain pins into +// manifest.TrustedPublishers. No binary is written — the supervisor +// will hit verify-fail when it tries to spawn, but for tests that only +// care about discovery / registration (rescan, Apps()) that's the +// desired behavior. func writeValidAppDir(t *testing.T, root, id string) string { t.Helper() dir := filepath.Join(root, id) @@ -53,6 +80,62 @@ func writeValidAppDir(t *testing.T, root, id string) string { t.Fatalf("mkdir %s: %v", dir, err) } + pub, priv := testPublisherKey() + pubB64 := base64.StdEncoding.EncodeToString(pub) + + template := strings.NewReplacer("ID", id, "PUBKEY", pubB64).Replace(`{ + "id": "ID", + "manifest_version": 1, + "app_version": "0.0.0", + "protection": "shareable", + "binary": {"runtime": "go", "path": "bin/x", "sha256": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"}, + "exposes": ["ID.method"], + "grants": [ + {"cap": "fs.read", "target": "$APP/data.db"} + ], + "store": { + "publisher": "ed25519:PUBKEY", + "signature": "" + } + }`) + + // Parse, sign, re-serialize. + m, err := manifest.Parse([]byte(template)) + if err != nil { + t.Fatalf("parse template: %v", err) + } + // Compute the signing payload the same way manifest.VerifySignature expects. + grantsJSON, _ := json.Marshal(m.Grants) + grantsHash := sha256.Sum256(grantsJSON) + payload := fmt.Sprintf("%s:%s:%d:%s:%x", + m.Store.Publisher, m.ID, m.ManifestVersion, m.Binary.SHA256, grantsHash) + sig := ed25519.Sign(priv, []byte(payload)) + m.Store.Signature = base64.StdEncoding.EncodeToString(sig) + + raw, err := json.Marshal(m) + if err != nil { + t.Fatalf("marshal signed manifest: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "manifest.json"), raw, 0o644); err != nil { + t.Fatalf("write manifest: %v", err) + } + return dir +} + +// writeUntrustedSignedAppDir creates //manifest.json with a +// manifest that carries a VALID self-signature from a FRESH (untrusted) +// publisher key — i.e. it passes VerifySignature but NOT VerifyTrustAnchor. +// No `.sideloaded` marker is planted. This is the trust-boundary case: +// a catalogue install must be refused unless the publisher is on the +// trusted-publishers list, even when the signature itself is internally +// consistent. +func writeUntrustedSignedAppDir(t *testing.T, root, id string) string { + t.Helper() + dir := filepath.Join(root, id) + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatalf("mkdir %s: %v", dir, err) + } + pub, priv, err := ed25519.GenerateKey(rand.Reader) if err != nil { t.Fatalf("generate key: %v", err) @@ -75,12 +158,10 @@ func writeValidAppDir(t *testing.T, root, id string) string { } }`) - // Parse, sign, re-serialize. m, err := manifest.Parse([]byte(template)) if err != nil { t.Fatalf("parse template: %v", err) } - // Compute the signing payload the same way manifest.VerifySignature expects. grantsJSON, _ := json.Marshal(m.Grants) grantsHash := sha256.Sum256(grantsJSON) payload := fmt.Sprintf("%s:%s:%d:%s:%x", diff --git a/plugin/appstore/zz4_downgrade_test.go b/plugin/appstore/zz4_downgrade_test.go index 0de4a9a..54ebb02 100644 --- a/plugin/appstore/zz4_downgrade_test.go +++ b/plugin/appstore/zz4_downgrade_test.go @@ -2,7 +2,6 @@ package appstore import ( "crypto/ed25519" - "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/json" @@ -159,10 +158,7 @@ func writeAppDirWithVersion(t *testing.T, root, id, version string) string { t.Fatal(err) } - pub, priv, err := ed25519.GenerateKey(rand.Reader) - if err != nil { - t.Fatalf("generate key: %v", err) - } + pub, priv := testPublisherKey() pubB64 := base64.StdEncoding.EncodeToString(pub) template := fmt.Sprintf( diff --git a/plugin/appstore/zz_sideload_scan_test.go b/plugin/appstore/zz_sideload_scan_test.go index 99cfc80..96587c2 100644 --- a/plugin/appstore/zz_sideload_scan_test.go +++ b/plugin/appstore/zz_sideload_scan_test.go @@ -124,3 +124,27 @@ func TestScanInstalled_UnsignedWithoutMarkerStillRejected(t *testing.T) { t.Fatalf("apps = %d, want 0 (unsigned manifest without sideload marker must be refused)", len(apps)) } } + +// 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. +func TestScanInstalled_UntrustedPublisherWithoutMarkerRejected(t *testing.T) { + t.Parallel() + root := t.TempDir() + // Valid self-signature, fresh untrusted key, NO .sideloaded marker. + writeUntrustedSignedAppDir(t, root, "io.untrusted.app") + + sup := newSupervisor(Config{InstallRoot: root}, 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)) + } +}