Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 42 additions & 39 deletions pkg/manifest/manifest.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:<base64>" 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:<base64>" (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:<base64>\"")
// 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:<base64>" 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
Expand All @@ -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 {
Expand Down
89 changes: 27 additions & 62 deletions pkg/manifest/manifest_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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")
}
}

Expand Down
26 changes: 19 additions & 7 deletions plugin/appstore/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,18 @@ type Config struct {
// dev key via NewServiceWithKey.
CatalogPubkey []byte

// CataloguePublisher returns the publisher key ("ed25519:<base64>") 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
Expand Down Expand Up @@ -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
Expand Down
35 changes: 20 additions & 15 deletions plugin/appstore/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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()
Expand Down Expand Up @@ -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()
Expand All @@ -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()
Expand All @@ -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()
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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{
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading