Skip to content

Commit cecb842

Browse files
authored
trust anchor: repoint VerifyTrustAnchor at the catalogue pin (not a static list) (#25)
1 parent df0e4a3 commit cecb842

9 files changed

Lines changed: 239 additions & 186 deletions

File tree

pkg/manifest/manifest.go

Lines changed: 42 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -196,49 +196,52 @@ func (m *Manifest) signingPayload() ([]byte, error) {
196196
return []byte(payload), nil
197197
}
198198

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")
199+
// decodeEd25519Pub parses an "ed25519:<base64>" (or bare base64) public key
200+
// into raw bytes, validating the length.
201+
func decodeEd25519Pub(s string) ([]byte, error) {
202+
raw := strings.TrimPrefix(strings.TrimSpace(s), "ed25519:")
203+
key, err := base64.StdEncoding.DecodeString(raw)
204+
if err != nil {
205+
return nil, fmt.Errorf("invalid base64: %w", err)
217206
}
207+
if len(key) != ed25519.PublicKeySize {
208+
return nil, fmt.Errorf("wrong key length %d, want %d", len(key), ed25519.PublicKeySize)
209+
}
210+
return key, nil
211+
}
218212

219-
pubkeyRaw, ok := strings.CutPrefix(m.Store.Publisher, "ed25519:")
220-
if !ok {
221-
return fmt.Errorf("store.publisher must be \"ed25519:<base64>\"")
213+
// VerifyTrustAnchor confirms that Store.Publisher matches the publisher key the
214+
// release-signed catalogue pins for this app. This is the trust anchor for
215+
// non-sideloaded (catalogue) installs: the catalogue is the root of trust
216+
// (the installer verifies the catalogue signature and pins each app's bundle
217+
// sha256), and this check re-confirms on every launch that the installed
218+
// manifest is published by the catalogue-declared key.
219+
//
220+
// Without it, VerifySignature alone only proves a manifest is internally
221+
// self-consistent — a manifest self-signed by ANY key would pass — which would
222+
// let an app dropped into the install root run with full grants.
223+
//
224+
// cataloguePublisher is the "ed25519:<base64>" key the verified catalogue
225+
// declares for m.ID; the caller (the supervisor) obtains it from the
226+
// signature-verified catalogue via Config.CataloguePublisher. An empty string
227+
// means the app is not pinned by the catalogue, which is fail-closed. Returns
228+
// nil only when the manifest's publisher equals the catalogue-pinned key.
229+
func (m *Manifest) VerifyTrustAnchor(cataloguePublisher string) error {
230+
if strings.TrimSpace(cataloguePublisher) == "" {
231+
return fmt.Errorf("trust anchor: %s is not pinned by the signed catalogue", m.ID)
222232
}
223-
pubkey, err := base64.StdEncoding.DecodeString(pubkeyRaw)
233+
pubkey, err := decodeEd25519Pub(m.Store.Publisher)
224234
if err != nil {
225-
return fmt.Errorf("store.publisher: invalid base64: %w", err)
235+
return fmt.Errorf("store.publisher: %w", err)
226236
}
227-
if len(pubkey) != ed25519.PublicKeySize {
228-
return fmt.Errorf("store.publisher: wrong key length %d, want %d", len(pubkey), ed25519.PublicKeySize)
237+
trustedKey, err := decodeEd25519Pub(cataloguePublisher)
238+
if err != nil {
239+
return fmt.Errorf("catalogue publisher for %s: %w", m.ID, err)
229240
}
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-
}
241+
if !bytes.Equal(pubkey, trustedKey) {
242+
return fmt.Errorf("trust anchor: publisher %s does not match the catalogue pin for %s", m.Store.Publisher, m.ID)
240243
}
241-
return fmt.Errorf("trust anchor: publisher %s is not on the trusted-publishers list", m.Store.Publisher)
244+
return nil
242245
}
243246

244247
// VerifySignature checks that Store.Signature is a valid ed25519
@@ -248,9 +251,9 @@ func (m *Manifest) VerifyTrustAnchor() error {
248251
// (Publisher, ID, ManifestVersion, Binary.SHA256, Grants) will cause
249252
// verification to fail.
250253
//
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.
254+
// IMPORTANT: This does NOT check that Store.Publisher is trusted. For
255+
// non-sideloaded apps, callers MUST also call VerifyTrustAnchor(cataloguePublisher)
256+
// to confirm the publisher matches the key the signed catalogue pins for the app.
254257
func (m *Manifest) VerifySignature() error {
255258
pubkeyRaw, ok := strings.CutPrefix(m.Store.Publisher, "ed25519:")
256259
if !ok {

pkg/manifest/manifest_test.go

Lines changed: 27 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -390,49 +390,39 @@ 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-
393+
func TestVerifyTrustAnchorEmptyPinIsFailClosed(t *testing.T) {
394+
// An app the catalogue does not pin (empty publisher) must be rejected,
395+
// even if its own self-signature is valid.
399396
m := mustValid(t)
400-
if err := m.VerifyTrustAnchor(); err == nil {
401-
t.Error("expected error with empty TrustedPublishers, got nil")
397+
if err := m.VerifyTrustAnchor(""); err == nil {
398+
t.Error("expected error with empty catalogue pin (app not pinned), got nil")
402399
}
403400
}
404401

405-
func TestVerifyTrustAnchorRejectsUntrustedPublisher(t *testing.T) {
406-
trustedPub, _, err := ed25519.GenerateKey(rand.Reader)
402+
func TestVerifyTrustAnchorRejectsMismatchedPublisher(t *testing.T) {
403+
cataloguePub, _, err := ed25519.GenerateKey(rand.Reader)
407404
if err != nil {
408405
t.Fatal(err)
409406
}
410-
untrustedPub, _, err := ed25519.GenerateKey(rand.Reader)
407+
otherPub, _, err := ed25519.GenerateKey(rand.Reader)
411408
if err != nil {
412409
t.Fatal(err)
413410
}
414411

415-
orig := TrustedPublishers
416-
TrustedPublishers = []string{"ed25519:" + base64Enc(trustedPub)}
417-
defer func() { TrustedPublishers = orig }()
418-
419412
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")
413+
m.Store.Publisher = "ed25519:" + base64Enc(otherPub)
414+
// Catalogue pins cataloguePub but the manifest is published by otherPub.
415+
if err := m.VerifyTrustAnchor("ed25519:" + base64Enc(cataloguePub)); err == nil {
416+
t.Error("expected error: manifest publisher does not match the catalogue pin")
423417
}
424418
}
425419

426-
func TestVerifyTrustAnchorAcceptsTrustedPublisher(t *testing.T) {
420+
func TestVerifyTrustAnchorAcceptsCataloguePinnedPublisher(t *testing.T) {
427421
pub, priv, err := ed25519.GenerateKey(rand.Reader)
428422
if err != nil {
429423
t.Fatal(err)
430424
}
431425

432-
orig := TrustedPublishers
433-
TrustedPublishers = []string{"ed25519:" + base64Enc(pub)}
434-
defer func() { TrustedPublishers = orig }()
435-
436426
m := mustValid(t)
437427
m.Store.Publisher = "ed25519:" + base64Enc(pub)
438428
sig, err := signTestManifest(m, priv)
@@ -445,54 +435,29 @@ func TestVerifyTrustAnchorAcceptsTrustedPublisher(t *testing.T) {
445435
if err := m.VerifySignature(); err != nil {
446436
t.Fatalf("valid signature rejected: %v", err)
447437
}
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)
438+
// VerifyTrustAnchor must pass: the manifest publisher equals the catalogue pin.
439+
if err := m.VerifyTrustAnchor("ed25519:" + base64Enc(pub)); err != nil {
440+
t.Errorf("catalogue-pinned publisher rejected by VerifyTrustAnchor: %v", err)
451441
}
452442
}
453443

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),
444+
func TestVerifyTrustAnchorRejectsBadPublisherFormat(t *testing.T) {
445+
m := mustValid(t)
446+
m.Store.Publisher = "not-valid-publisher"
447+
if err := m.VerifyTrustAnchor("ed25519:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="); err == nil {
448+
t.Error("expected error with bad publisher format, got nil")
468449
}
469-
defer func() { TrustedPublishers = orig }()
450+
}
470451

471-
m := mustValid(t)
472-
m.Store.Publisher = "ed25519:" + base64Enc(pub2)
473-
sig, err := signTestManifest(m, priv2)
452+
func TestVerifyTrustAnchorRejectsBadCataloguePinFormat(t *testing.T) {
453+
pub, _, err := ed25519.GenerateKey(rand.Reader)
474454
if err != nil {
475455
t.Fatal(err)
476456
}
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-
492457
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")
458+
m.Store.Publisher = "ed25519:" + base64Enc(pub)
459+
if err := m.VerifyTrustAnchor("not-a-valid-key"); err == nil {
460+
t.Error("expected error with malformed catalogue pin, got nil")
496461
}
497462
}
498463

plugin/appstore/service.go

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,18 @@ type Config struct {
3636
// dev key via NewServiceWithKey.
3737
CatalogPubkey []byte
3838

39+
// CataloguePublisher returns the publisher key ("ed25519:<base64>") that the
40+
// release-signed catalogue pins for appID, and whether appID is pinned at
41+
// all. It is the trust anchor for non-sideloaded apps: before spawning a
42+
// catalogue app the supervisor confirms the installed manifest's publisher
43+
// matches this pin (manifest.VerifyTrustAnchor). The daemon supplies it from
44+
// the catalogue it has signature-verified with CatalogPubkey.
45+
//
46+
// When nil (or it reports an app as not pinned), non-sideloaded apps
47+
// fail closed — they are not spawned. Sideloaded apps bypass this and are
48+
// clamped to the safe grant subset instead.
49+
CataloguePublisher func(appID string) (publisher string, pinned bool)
50+
3951
// Logger optionally redirects internal messages. When nil the
4052
// service logs via the standard log package.
4153
Logger *log.Logger
@@ -135,13 +147,13 @@ func (s *Service) Order() int { return 120 }
135147
// Go's structural typing makes this work as long as the methods used here
136148
// are present on the real types.
137149
type Deps struct {
138-
Streams any // coreapi.Streams — Dial, Listen, SendDatagram
139-
Identity any // coreapi.Identity — NodeID, Address, PublicKey, Sign
140-
Resolver any
141-
Events any // coreapi.EventBus — Publish, Subscribe
142-
Logger any
143-
Trust any
144-
Telemetry TelemetryEmitter // optional; no-op when nil
150+
Streams any // coreapi.Streams — Dial, Listen, SendDatagram
151+
Identity any // coreapi.Identity — NodeID, Address, PublicKey, Sign
152+
Resolver any
153+
Events any // coreapi.EventBus — Publish, Subscribe
154+
Logger any
155+
Trust any
156+
Telemetry TelemetryEmitter // optional; no-op when nil
145157
}
146158

147159
// Start scans InstallRoot for installed apps, verifies each binary's

plugin/appstore/service_test.go

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,9 @@ func TestStartWarnsOnPlaceholderCatalogPubkey(t *testing.T) {
2020
t.Run("placeholder (all-zeros) → warns", func(t *testing.T) {
2121
var buf strings.Builder
2222
s := NewService(Config{
23-
InstallRoot: t.TempDir(),
24-
Logger: log.New(&buf, "", 0),
23+
CataloguePublisher: testCatPub,
24+
InstallRoot: t.TempDir(),
25+
Logger: log.New(&buf, "", 0),
2526
})
2627
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
2728
defer cancel()
@@ -38,9 +39,10 @@ func TestStartWarnsOnPlaceholderCatalogPubkey(t *testing.T) {
3839
realKey := make([]byte, 32)
3940
realKey[0] = 0x01 // any non-zero byte qualifies
4041
s := NewService(Config{
41-
InstallRoot: t.TempDir(),
42-
CatalogPubkey: realKey,
43-
Logger: log.New(&buf, "", 0),
42+
CataloguePublisher: testCatPub,
43+
InstallRoot: t.TempDir(),
44+
CatalogPubkey: realKey,
45+
Logger: log.New(&buf, "", 0),
4446
})
4547
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
4648
defer cancel()
@@ -72,7 +74,7 @@ func TestNewServiceDefaults(t *testing.T) {
7274

7375
func TestStartStopEmptyInstallRoot(t *testing.T) {
7476
dir := t.TempDir()
75-
s := NewService(Config{InstallRoot: dir})
77+
s := NewService(Config{InstallRoot: dir, CataloguePublisher: testCatPub})
7678

7779
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
7880
defer cancel()
@@ -87,7 +89,7 @@ func TestStartStopEmptyInstallRoot(t *testing.T) {
8789

8890
func TestStartCreatesInstallRoot(t *testing.T) {
8991
dir := filepath.Join(t.TempDir(), "nested", "apps")
90-
s := NewService(Config{InstallRoot: dir})
92+
s := NewService(Config{InstallRoot: dir, CataloguePublisher: testCatPub})
9193

9294
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
9395
defer cancel()
@@ -111,8 +113,9 @@ func TestRescanDiscoversAppInstalledMidRun(t *testing.T) {
111113
writeValidAppDir(t, root, "io.app1")
112114

113115
svc := NewService(Config{
114-
InstallRoot: root,
115-
RescanInterval: 30 * time.Millisecond, // fast for tests
116+
CataloguePublisher: testCatPub,
117+
InstallRoot: root,
118+
RescanInterval: 30 * time.Millisecond, // fast for tests
116119
})
117120
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
118121
defer cancel()
@@ -152,8 +155,9 @@ func TestRescanDetectsUninstall(t *testing.T) {
152155
writeValidAppDir(t, root, "io.app2")
153156

154157
svc := NewService(Config{
155-
InstallRoot: root,
156-
RescanInterval: 30 * time.Millisecond,
158+
CataloguePublisher: testCatPub,
159+
InstallRoot: root,
160+
RescanInterval: 30 * time.Millisecond,
157161
})
158162
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
159163
defer cancel()
@@ -195,7 +199,7 @@ func TestRescanResumeClearsSuspendedMarker(t *testing.T) {
195199
root := t.TempDir()
196200
appDir := writeValidAppDir(t, root, "io.suspended.app")
197201

198-
sup := newSupervisor(Config{InstallRoot: root}, Deps{}, newQuietLogger(t))
202+
sup := newSupervisor(Config{InstallRoot: root, CataloguePublisher: testCatPub}, Deps{}, newQuietLogger(t))
199203
sup.mu.Lock()
200204
sup.installed["io.suspended.app"] = &installedApp{
201205
Dir: appDir,
@@ -241,8 +245,9 @@ func TestRescanResumesAppOnMarker(t *testing.T) {
241245
// exceeding the crash-loop cap; we don't need the live goroutine
242246
// for the resume signal to be testable).
243247
sup := newSupervisor(Config{
244-
InstallRoot: root,
245-
RescanInterval: 20 * time.Millisecond,
248+
CataloguePublisher: testCatPub,
249+
InstallRoot: root,
250+
RescanInterval: 20 * time.Millisecond,
246251
}, Deps{}, newQuietLogger(t))
247252
sup.mu.Lock()
248253
sup.installed["io.suspended.app"] = &installedApp{
@@ -285,7 +290,7 @@ func TestScanIgnoresInvalidManifest(t *testing.T) {
285290
empty := filepath.Join(root, "io.empty.app")
286291
_ = os.MkdirAll(empty, 0o755)
287292

288-
sup := newSupervisor(Config{InstallRoot: root}, Deps{}, newQuietLogger(t))
293+
sup := newSupervisor(Config{InstallRoot: root, CataloguePublisher: testCatPub}, Deps{}, newQuietLogger(t))
289294
apps, err := sup.scanInstalled()
290295
if err != nil {
291296
t.Fatalf("scan: %v", err)

0 commit comments

Comments
 (0)