Skip to content

Commit 2f290c3

Browse files
Alex Godorojaclaude
andcommitted
remove redundant per-publisher trust anchor (catalogue is the source of truth)
The release-signed catalogue pins each bundle's sha256, and pilotctl verifies that pin at install — so a non-sideloaded app is, by construction, vouched for by the catalogue signature (the root of trust). The separate per-publisher TrustedPublishers allow-list (added in #7, enforced in #23) is redundant with that, and unworkable in practice: apps are self-signed with per-app keys, the publishers registry isn't populated, and the metadata publisher_pubkeys are placeholders/mismatched — so enforcement skipped EVERY catalogue app unless an env var was set on each host. Removed: - manifest.TrustedPublishers + Manifest.VerifyTrustAnchor (+ their unit tests) - the supervisor's VerifyTrustAnchor enforcement on the catalogue path (VerifySignature still confirms manifest integrity) The catalogue path now: install-time sha pin (signed catalogue) + VerifySignature. Sideloaded apps remain clamped to the safe grant subset. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 74e0f28 commit 2f290c3

5 files changed

Lines changed: 54 additions & 226 deletions

File tree

pkg/manifest/manifest.go

Lines changed: 5 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
package manifest
1010

1111
import (
12-
"bytes"
1312
"crypto/ed25519"
1413
"crypto/sha256"
1514
"encoding/base64"
@@ -180,9 +179,7 @@ func canonicalJSON(v any) ([]byte, error) {
180179
// signingPayload builds the canonical byte-string the Store.Signature
181180
// must sign. The publisher key is included so that a signature cannot
182181
// be reused with a different publisher identity — swapping the
183-
// publisher key invalidates the signature. Once a trust-anchor check
184-
// (hardcoded publisher pubkey match) is added, this guarantees the
185-
// manifest was signed by the known publisher.
182+
// publisher key invalidates the signature.
186183
//
187184
// Format: publisher || ":" || id || ":" || manifest_version || ":" || binary.sha256 || ":" || grants-sha256-hex
188185
func (m *Manifest) signingPayload() ([]byte, error) {
@@ -196,61 +193,17 @@ func (m *Manifest) signingPayload() ([]byte, error) {
196193
return []byte(payload), nil
197194
}
198195

199-
// TrustedPublishers is the compile-time-embedded list of publisher
200-
// ed25519 public keys ("ed25519:<base64>" or raw base64) that are
201-
// trusted to sign manifests. Empty list = fail-closed (no publisher
202-
// passes the trust-anchor check). Production builds MUST populate
203-
// this list with the known-good publisher keys.
204-
var TrustedPublishers []string
205-
206-
// VerifyTrustAnchor checks that Store.Publisher is on the trusted
207-
// publishers list. Without this check, VerifySignature only confirms
208-
// the manifest was signed by whoever claims to be the publisher;
209-
// VerifyTrustAnchor confirms the publisher itself is known and trusted.
210-
//
211-
// Returns nil if Store.Publisher is in TrustedPublishers.
212-
// Returns an error if TrustedPublishers is empty (fail-closed) or if
213-
// the publisher is not found.
214-
func (m *Manifest) VerifyTrustAnchor() error {
215-
if len(TrustedPublishers) == 0 {
216-
return fmt.Errorf("trust anchor: TrustedPublishers is empty — no publisher is trusted")
217-
}
218-
219-
pubkeyRaw, ok := strings.CutPrefix(m.Store.Publisher, "ed25519:")
220-
if !ok {
221-
return fmt.Errorf("store.publisher must be \"ed25519:<base64>\"")
222-
}
223-
pubkey, err := base64.StdEncoding.DecodeString(pubkeyRaw)
224-
if err != nil {
225-
return fmt.Errorf("store.publisher: invalid base64: %w", err)
226-
}
227-
if len(pubkey) != ed25519.PublicKeySize {
228-
return fmt.Errorf("store.publisher: wrong key length %d, want %d", len(pubkey), ed25519.PublicKeySize)
229-
}
230-
231-
for _, trusted := range TrustedPublishers {
232-
trustedRaw := strings.TrimPrefix(trusted, "ed25519:")
233-
trustedKey, err := base64.StdEncoding.DecodeString(trustedRaw)
234-
if err != nil {
235-
continue // skip malformed entries
236-
}
237-
if bytes.Equal(pubkey, trustedKey) {
238-
return nil
239-
}
240-
}
241-
return fmt.Errorf("trust anchor: publisher %s is not on the trusted-publishers list", m.Store.Publisher)
242-
}
243-
244196
// VerifySignature checks that Store.Signature is a valid ed25519
245197
// signature over the signing payload, verified against the Store.Publisher
246198
// key embedded in the manifest. This provides cryptographic integrity —
247199
// tampering with any manifest field that feeds the signing payload
248200
// (Publisher, ID, ManifestVersion, Binary.SHA256, Grants) will cause
249201
// verification to fail.
250202
//
251-
// IMPORTANT: This does NOT check that Store.Publisher is a trusted key.
252-
// Callers MUST also call VerifyTrustAnchor() after VerifySignature()
253-
// to confirm the publisher is on the TrustedPublishers list.
203+
// For catalogue installs, trust comes from the release-signed catalogue,
204+
// which pins each bundle's sha256 (verified by pilotctl at install time);
205+
// VerifySignature here confirms the manifest itself was not altered after
206+
// the publisher signed it. There is no separate per-publisher trust anchor.
254207
func (m *Manifest) VerifySignature() error {
255208
pubkeyRaw, ok := strings.CutPrefix(m.Store.Publisher, "ed25519:")
256209
if !ok {

pkg/manifest/manifest_test.go

Lines changed: 4 additions & 110 deletions
Original file line numberDiff line numberDiff line change
@@ -61,10 +61,10 @@ func TestValidWalletManifest(t *testing.T) {
6161

6262
func TestRejectsBadID(t *testing.T) {
6363
cases := map[string]string{
64-
"empty": "",
65-
"no_dot": "wallet",
66-
"uppercase": "io.Pilot.Wallet",
67-
"trailing_dot": "io.pilot.wallet.",
64+
"empty": "",
65+
"no_dot": "wallet",
66+
"uppercase": "io.Pilot.Wallet",
67+
"trailing_dot": "io.pilot.wallet.",
6868
}
6969
for name, badID := range cases {
7070
t.Run(name, func(t *testing.T) {
@@ -390,112 +390,6 @@ func TestVerifySignatureRejectsEmptySignature(t *testing.T) {
390390
}
391391
}
392392

393-
func TestVerifyTrustAnchorEmptyListIsFailClosed(t *testing.T) {
394-
// With TrustedPublishers empty (default), VerifyTrustAnchor must reject all publishers.
395-
orig := TrustedPublishers
396-
TrustedPublishers = nil
397-
defer func() { TrustedPublishers = orig }()
398-
399-
m := mustValid(t)
400-
if err := m.VerifyTrustAnchor(); err == nil {
401-
t.Error("expected error with empty TrustedPublishers, got nil")
402-
}
403-
}
404-
405-
func TestVerifyTrustAnchorRejectsUntrustedPublisher(t *testing.T) {
406-
trustedPub, _, err := ed25519.GenerateKey(rand.Reader)
407-
if err != nil {
408-
t.Fatal(err)
409-
}
410-
untrustedPub, _, err := ed25519.GenerateKey(rand.Reader)
411-
if err != nil {
412-
t.Fatal(err)
413-
}
414-
415-
orig := TrustedPublishers
416-
TrustedPublishers = []string{"ed25519:" + base64Enc(trustedPub)}
417-
defer func() { TrustedPublishers = orig }()
418-
419-
m := mustValid(t)
420-
m.Store.Publisher = "ed25519:" + base64Enc(untrustedPub)
421-
if err := m.VerifyTrustAnchor(); err == nil {
422-
t.Error("expected error for untrusted publisher, got nil")
423-
}
424-
}
425-
426-
func TestVerifyTrustAnchorAcceptsTrustedPublisher(t *testing.T) {
427-
pub, priv, err := ed25519.GenerateKey(rand.Reader)
428-
if err != nil {
429-
t.Fatal(err)
430-
}
431-
432-
orig := TrustedPublishers
433-
TrustedPublishers = []string{"ed25519:" + base64Enc(pub)}
434-
defer func() { TrustedPublishers = orig }()
435-
436-
m := mustValid(t)
437-
m.Store.Publisher = "ed25519:" + base64Enc(pub)
438-
sig, err := signTestManifest(m, priv)
439-
if err != nil {
440-
t.Fatal(err)
441-
}
442-
m.Store.Signature = sig
443-
444-
// VerifySignature must pass for a valid signature.
445-
if err := m.VerifySignature(); err != nil {
446-
t.Fatalf("valid signature rejected: %v", err)
447-
}
448-
// VerifyTrustAnchor must pass because the publisher IS trusted.
449-
if err := m.VerifyTrustAnchor(); err != nil {
450-
t.Errorf("trusted publisher rejected by VerifyTrustAnchor: %v", err)
451-
}
452-
}
453-
454-
func TestVerifyTrustAnchorMultipleTrustedKeys(t *testing.T) {
455-
pub1, _, err := ed25519.GenerateKey(rand.Reader)
456-
if err != nil {
457-
t.Fatal(err)
458-
}
459-
pub2, priv2, err := ed25519.GenerateKey(rand.Reader)
460-
if err != nil {
461-
t.Fatal(err)
462-
}
463-
464-
orig := TrustedPublishers
465-
TrustedPublishers = []string{
466-
"ed25519:" + base64Enc(pub1),
467-
"ed25519:" + base64Enc(pub2),
468-
}
469-
defer func() { TrustedPublishers = orig }()
470-
471-
m := mustValid(t)
472-
m.Store.Publisher = "ed25519:" + base64Enc(pub2)
473-
sig, err := signTestManifest(m, priv2)
474-
if err != nil {
475-
t.Fatal(err)
476-
}
477-
m.Store.Signature = sig
478-
479-
if err := m.VerifySignature(); err != nil {
480-
t.Fatalf("valid signature rejected: %v", err)
481-
}
482-
if err := m.VerifyTrustAnchor(); err != nil {
483-
t.Errorf("second trusted publisher rejected: %v", err)
484-
}
485-
}
486-
487-
func TestVerifyTrustAnchorRejectsBadPublisherFormat(t *testing.T) {
488-
orig := TrustedPublishers
489-
TrustedPublishers = []string{"ed25519:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="}
490-
defer func() { TrustedPublishers = orig }()
491-
492-
m := mustValid(t)
493-
m.Store.Publisher = "not-valid-publisher"
494-
if err := m.VerifyTrustAnchor(); err == nil {
495-
t.Error("expected error with bad publisher format, got nil")
496-
}
497-
}
498-
499393
func hasErrorContaining(errs []error, substr string) bool {
500394
for _, e := range errs {
501395
if strings.Contains(e.Error(), substr) {

plugin/appstore/supervisor.go

Lines changed: 22 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -58,14 +58,14 @@ const maxAuditLogSize = 10 * 1024 * 1024
5858
// auditEvent is one line in the supervisor.log JSONL stream.
5959
// AppID + EventType + At are always populated; the rest depends on type.
6060
type auditEvent struct {
61-
At time.Time `json:"at"`
62-
AppID string `json:"app"`
63-
Event string `json:"event"` // "spawn", "exit", "suspend", "verify-fail"
64-
PID int `json:"pid,omitempty"`
65-
ExitCode int `json:"exit_code,omitempty"`
66-
Reason string `json:"reason,omitempty"`
67-
SHA256 string `json:"sha256,omitempty"` // pinned hash, recorded on spawn for traceability
68-
BinaryAt string `json:"binary_path,omitempty"`
61+
At time.Time `json:"at"`
62+
AppID string `json:"app"`
63+
Event string `json:"event"` // "spawn", "exit", "suspend", "verify-fail"
64+
PID int `json:"pid,omitempty"`
65+
ExitCode int `json:"exit_code,omitempty"`
66+
Reason string `json:"reason,omitempty"`
67+
SHA256 string `json:"sha256,omitempty"` // pinned hash, recorded on spawn for traceability
68+
BinaryAt string `json:"binary_path,omitempty"`
6969
}
7070

7171
// writeAuditLine appends one JSON-encoded event to the app's
@@ -164,9 +164,9 @@ type supervisor struct {
164164

165165
// mu guards installed + ready + crashes + appCancel.
166166
mu sync.RWMutex
167-
installed map[string]*installedApp // app_id → record
168-
ready map[string]bool // app_id → socket has appeared at least once
169-
crashes map[string]*crashRecord // app_id → sliding-window crash counter
167+
installed map[string]*installedApp // app_id → record
168+
ready map[string]bool // app_id → socket has appeared at least once
169+
crashes map[string]*crashRecord // app_id → sliding-window crash counter
170170
appCancel map[string]context.CancelFunc // app_id → cancel its per-app context (used to stop a supervise goroutine on detected uninstall)
171171
}
172172

@@ -335,22 +335,21 @@ func (s *supervisor) scanInstalled() ([]*installedApp, error) {
335335
continue
336336
}
337337
} else {
338-
// Catalogue path: a non-sideloaded install must satisfy the
339-
// FULL trust chain, not just signature integrity.
340-
// VerifySignature alone only proves the manifest was signed
341-
// by whoever claims to be the publisher — a self-signed
342-
// manifest from an UNTRUSTED key passes it. VerifyTrustAnchor
343-
// then confirms that publisher key is on the trusted list.
344-
// Both are required; sideloading (above) is the explicit,
345-
// local opt-out of this chain.
338+
// Catalogue path: trust comes from the RELEASE-SIGNED CATALOGUE,
339+
// which pins each bundle's sha256. `pilotctl install` verifies that
340+
// pin against the signed catalogue before extracting, so a
341+
// non-sideloaded app is — by construction — vouched for by the
342+
// catalogue signature (the root of trust). VerifySignature here
343+
// additionally confirms manifest integrity (the binary sha + grants
344+
// were not altered after the publisher signed). A separate
345+
// per-publisher allow-list is redundant with the catalogue anchor
346+
// (and unworkable: apps are self-signed with per-app keys), so it is
347+
// intentionally not enforced. Sideloading (above) is the explicit
348+
// local opt-out, clamped to the safe grant subset.
346349
if err := m.VerifySignature(); err != nil {
347350
s.logger.Printf("skip %s: signature verification failed: %v", e.Name(), err)
348351
continue
349352
}
350-
if err := m.VerifyTrustAnchor(); err != nil {
351-
s.logger.Printf("skip %s: publisher not trusted: %v", e.Name(), err)
352-
continue
353-
}
354353
}
355354
// Reject path traversal in manifest.binary.path. Without this
356355
// a manifest containing binary.path="../../../bin/sh" (or any

plugin/appstore/testhelpers_test.go

Lines changed: 12 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,8 @@ func newQuietLogger(t *testing.T) *log.Logger {
2525
}
2626

2727
// testPublisherSeed is a fixed Ed25519 seed so every helper-built
28-
// manifest is signed by the SAME publisher key. TestMain pins that key
29-
// into manifest.TrustedPublishers exactly once, before any test runs,
30-
// so the catalogue (non-sideloaded) trust-anchor check passes for
31-
// helper-built apps. Using a fixed key (set once, never mutated during
32-
// the run) keeps this race-free under `go test -race`, where supervisor
33-
// goroutines read TrustedPublishers concurrently.
28+
// manifest is signed by the SAME publisher key — keeping signatures
29+
// deterministic across the suite.
3430
var testPublisherSeed = [ed25519.SeedSize]byte{
3531
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16,
3632
17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32,
@@ -41,15 +37,6 @@ func testPublisherKey() (ed25519.PublicKey, ed25519.PrivateKey) {
4137
return priv.Public().(ed25519.PublicKey), priv
4238
}
4339

44-
// TestMain pins the fixed test publisher as the sole trust anchor for
45-
// the whole package, then runs the suite. Set once, before any goroutine
46-
// reads it — no mutation during the run, so no data race.
47-
func TestMain(m *testing.M) {
48-
pub, _ := testPublisherKey()
49-
manifest.TrustedPublishers = []string{"ed25519:" + base64.StdEncoding.EncodeToString(pub)}
50-
os.Exit(m.Run())
51-
}
52-
5340
// parseDummyManifest returns a minimal *manifest.Manifest with the
5441
// given id. Used by tests that need a manifest struct without going
5542
// through the disk layout. The values are intentionally minimal — the
@@ -66,13 +53,12 @@ func parseDummyManifest(t *testing.T, id string) *manifest.Manifest {
6653
}
6754

6855
// writeValidAppDir creates <root>/<id>/manifest.json with a manifest
69-
// that passes manifest.Parse + Validate + VerifySignature AND
70-
// VerifyTrustAnchor (so scanInstalled accepts it on the catalogue path).
71-
// It signs with the fixed test publisher key that TestMain pins into
72-
// manifest.TrustedPublishers. No binary is written — the supervisor
73-
// will hit verify-fail when it tries to spawn, but for tests that only
74-
// care about discovery / registration (rescan, Apps()) that's the
75-
// desired behavior.
56+
// that passes manifest.Parse + Validate + VerifySignature (so
57+
// scanInstalled accepts it on the catalogue path). It signs with the
58+
// fixed test publisher key. No binary is written — the supervisor will
59+
// hit verify-fail when it tries to spawn, but for tests that only care
60+
// about discovery / registration (rescan, Apps()) that's the desired
61+
// behavior.
7662
func writeValidAppDir(t *testing.T, root, id string) string {
7763
t.Helper()
7864
dir := filepath.Join(root, id)
@@ -123,12 +109,10 @@ func writeValidAppDir(t *testing.T, root, id string) string {
123109
}
124110

125111
// writeUntrustedSignedAppDir creates <root>/<id>/manifest.json with a
126-
// manifest that carries a VALID self-signature from a FRESH (untrusted)
127-
// publisher key — i.e. it passes VerifySignature but NOT VerifyTrustAnchor.
128-
// No `.sideloaded` marker is planted. This is the trust-boundary case:
129-
// a catalogue install must be refused unless the publisher is on the
130-
// trusted-publishers list, even when the signature itself is internally
131-
// consistent.
112+
// manifest that carries a VALID self-signature from a FRESH publisher key
113+
// (i.e. it passes VerifySignature), with no `.sideloaded` marker. Trust
114+
// for the catalogue path is the release-signed catalogue, not the
115+
// publisher key, so such an app is accepted on scan.
132116
func writeUntrustedSignedAppDir(t *testing.T, root, id string) string {
133117
t.Helper()
134118
dir := filepath.Join(root, id)

plugin/appstore/zz_sideload_scan_test.go

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -125,26 +125,24 @@ func TestScanInstalled_UnsignedWithoutMarkerStillRejected(t *testing.T) {
125125
}
126126
}
127127

128-
// TestScanInstalled_UntrustedPublisherWithoutMarkerRejected is the
129-
// trust-boundary regression: a manifest whose signature VERIFIES but
130-
// whose publisher is NOT on the trusted-publishers list, with no
131-
// `.sideloaded` marker, must be refused. Signature validity alone is
132-
// not trust — a non-sideloaded (catalogue) install must satisfy the
133-
// full trust-anchor check. Before the fix, scanInstalled ran only
134-
// VerifySignature on the catalogue path, so a self-signed-by-anyone
135-
// manifest was silently accepted and spawned.
136-
func TestScanInstalled_UntrustedPublisherWithoutMarkerRejected(t *testing.T) {
128+
// TestScanInstalled_CatalogueAppWithoutMarkerAccepted: a non-sideloaded app with
129+
// a valid self-signature is accepted on the catalogue path. Trust is the
130+
// release-signed catalogue (pilotctl pins each bundle's sha256 at install); the
131+
// supervisor confirms manifest integrity via VerifySignature. There is no
132+
// separate per-publisher allow-list (it was redundant with the catalogue anchor
133+
// and unworkable with per-app publisher keys).
134+
func TestScanInstalled_CatalogueAppWithoutMarkerAccepted(t *testing.T) {
137135
t.Parallel()
138136
root := t.TempDir()
139-
// Valid self-signature, fresh untrusted key, NO .sideloaded marker.
140-
writeUntrustedSignedAppDir(t, root, "io.untrusted.app")
137+
// Valid self-signature, fresh publisher key, NO .sideloaded marker.
138+
writeUntrustedSignedAppDir(t, root, "io.catalogue.app")
141139

142140
sup := newSupervisor(Config{InstallRoot: root}, Deps{}, newQuietLogger(t))
143141
apps, err := sup.scanInstalled()
144142
if err != nil {
145143
t.Fatal(err)
146144
}
147-
if len(apps) != 0 {
148-
t.Fatalf("apps = %d, want 0 (validly-signed but UNTRUSTED publisher must be refused on the catalogue path)", len(apps))
145+
if len(apps) != 1 {
146+
t.Fatalf("apps = %d, want 1 (a validly-signed catalogue app is accepted; trust is the signed catalogue, not a publisher allow-list)", len(apps))
149147
}
150148
}

0 commit comments

Comments
 (0)