diff --git a/catalogue/catalogue.json b/catalogue/catalogue.json index b4437c6c..fb423b41 100644 --- a/catalogue/catalogue.json +++ b/catalogue/catalogue.json @@ -19,7 +19,8 @@ "source_url": "https://github.com/pilot-protocol/wallet", "license": "AGPL-3.0-or-later", "metadata_url": "https://raw.githubusercontent.com/pilot-protocol/pilotprotocol/main/catalogue/apps/io.pilot.wallet/metadata.json", - "metadata_sha256": "b0ed7dee416144c39d8938f4dbeaac8946be3290a6c44473d898a5945eb6cadb" + "metadata_sha256": "b0ed7dee416144c39d8938f4dbeaac8946be3290a6c44473d898a5945eb6cadb", + "publisher": "ed25519:VF8fdEP/Oe2aWN3ozQ7Ar22137tHb7dkSw0hlzlk/os=" }, { "id": "io.pilot.cosift", @@ -38,7 +39,8 @@ "source_url": "https://github.com/pilot-protocol/cosift", "license": "MIT", "metadata_url": "https://raw.githubusercontent.com/pilot-protocol/pilotprotocol/main/catalogue/apps/io.pilot.cosift/metadata.json", - "metadata_sha256": "2b13511562dc1cfe09a6f53928149509a96ef893ee783fd1e055ed379b08b640" + "metadata_sha256": "2b13511562dc1cfe09a6f53928149509a96ef893ee783fd1e055ed379b08b640", + "publisher": "ed25519:EjoZEiV+j6oNZLRWNEEf8lEnC8XBkrvBAk4fxuZLLyU=" }, { "id": "io.pilot.sixtyfour", @@ -75,7 +77,8 @@ "bundle_url": "https://github.com/pilot-protocol/catalog/releases/download/sixtyfour-v0.1.0/io.pilot.sixtyfour-0.1.0-darwin-amd64.tar.gz", "bundle_sha256": "d970483e9cad84207f853d681cc810e954e236acd5e410b402880dc4d8304aa2" } - } + }, + "publisher": "ed25519:VoVCiQKPr73di2MlUd091a2Y6TCj/edSbCwRDtnYquI=" }, { "id": "io.pilot.smolmachines", @@ -106,7 +109,8 @@ } }, "metadata_url": "https://raw.githubusercontent.com/pilot-protocol/pilotprotocol/main/catalogue/apps/io.pilot.smolmachines/metadata.json", - "metadata_sha256": "f43493f690786b8adbe1ac1072bbec0b0d04e05d9a247e7b14e5d36c4e397a3b" + "metadata_sha256": "f43493f690786b8adbe1ac1072bbec0b0d04e05d9a247e7b14e5d36c4e397a3b", + "publisher": "ed25519:3QJm6H6OdjtfrF+Es1lrRjfFmdtq2tGvVSWxia63vcI=" } ] } diff --git a/catalogue/catalogue.json.sig b/catalogue/catalogue.json.sig index 35c78e00..732b92db 100644 --- a/catalogue/catalogue.json.sig +++ b/catalogue/catalogue.json.sig @@ -1 +1 @@ -Qx9a3z30QrOgX1u4BTxqlXF2UkSJCmrg7va+0xAioJPNmCdjKlkoPz0QX4Gk6oIv5His+gqNi5a+ij31vHShAg== +R9fRDEpqBDxnoKv2yuRG/sBKmN/QkqehEYql7yyCueuiYqunPdDUPGgMZXxPmmDvZymh0NqAjKA+uHmslKWpCQ== diff --git a/cmd/daemon/main.go b/cmd/daemon/main.go index c0c73d98..5d3f2275 100644 --- a/cmd/daemon/main.go +++ b/cmd/daemon/main.go @@ -36,6 +36,7 @@ import ( "github.com/pilot-protocol/webhook" "github.com/pilot-protocol/pilotprotocol/internal/catalogtrust" + "github.com/pilot-protocol/pilotprotocol/internal/catalogue" "github.com/pilot-protocol/pilotprotocol/pkg/telemetry" ) @@ -349,6 +350,36 @@ func main() { if r := os.Getenv("PILOT_APPSTORE_ROOT"); r != "" { appstoreInstallRoot = r } + // Catalogue trust anchor: load the per-app publisher pins from the + // release-signed catalogue and feed them to the supervisor. A non-sideloaded + // app is spawned only if its manifest publisher matches the key the catalogue + // pins for its id (see appstore.Config.CataloguePublisher). The pins are + // cached on disk so a transient catalogue outage on restart doesn't fail-close + // every app; with neither a live catalogue nor a cache, apps fail closed. + cataloguePins := catalogue.NewProvider( + catalogue.URL(), + filepath.Join(filepath.Dir(appstoreInstallRoot), "catalogue-pins.json"), + ) + if err := cataloguePins.Refresh(); err != nil { + if cataloguePins.LoadCache() { + log.Printf("appstore: catalogue refresh failed (%v); using %d cached publisher pin(s)", err, cataloguePins.Count()) + } else { + log.Printf("appstore: catalogue refresh failed (%v) and no cache; catalogue apps fail closed until the next refresh succeeds", err) + } + } else { + log.Printf("appstore: loaded %d catalogue publisher pin(s)", cataloguePins.Count()) + } + // Refresh the pins periodically so newly-catalogued apps become spawnable + // without a daemon restart. Daemon-lifetime loop; the process exit stops it. + go func() { + t := time.NewTicker(10 * time.Minute) + defer t.Stop() + for range t.C { + if err := cataloguePins.Refresh(); err != nil { + log.Printf("appstore: catalogue pin refresh failed: %v (keeping previous pins)", err) + } + } + }() // The app-usage telemetry emitter shares the daemon's identity file // and telemetry URL. When consent is off (empty URL) the client is // a permanent no-op — no goroutines, no dials, no buffering. @@ -368,6 +399,10 @@ func main() { // Real catalogue trust anchor (replaces the all-zeros // placeholder default): the embedded ed25519 catalogue key. CatalogPubkey: []byte(catalogtrust.PublicKey()), + // Per-app publisher pins from the release-signed catalogue: the + // supervisor confirms each non-sideloaded app's manifest publisher + // against this before spawning. nil/unpinned => fail closed. + CataloguePublisher: cataloguePins.Publisher, }), telemetryURL: *telemetryURL, identityPath: idPath, diff --git a/cmd/pilotctl/appstore_catalogue.go b/cmd/pilotctl/appstore_catalogue.go index 86f1ed08..174a5f87 100644 --- a/cmd/pilotctl/appstore_catalogue.go +++ b/cmd/pilotctl/appstore_catalogue.go @@ -89,6 +89,12 @@ type catalogueEntry struct { BundleURL string `json:"bundle_url"` BundleSHA string `json:"bundle_sha256"` + // Publisher is the app's ed25519 publisher key ("ed25519:"). It is + // the trust pin: the daemon (internal/catalogue) reads it from the + // signature-verified catalogue and the app-store supervisor confirms each + // non-sideloaded app's manifest publisher matches it before spawning. + Publisher string `json:"publisher,omitempty"` + // --- v3 per-platform bundles --- // Bundles maps "os/arch" (e.g. "darwin/arm64") → that platform's // tarball + sha256. When present, install picks the host's entry; diff --git a/go.mod b/go.mod index bc1ccc56..16a60b1e 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.25.11 require ( github.com/coder/websocket v1.8.15 - github.com/pilot-protocol/app-store v1.0.1 + github.com/pilot-protocol/app-store v1.0.2 github.com/pilot-protocol/beacon v0.2.6 github.com/pilot-protocol/common v0.5.5 github.com/pilot-protocol/dataexchange v0.2.1-beta.1.0.20260615113607-fac933edea98 diff --git a/go.sum b/go.sum index 24aa22ad..4f807a6c 100644 --- a/go.sum +++ b/go.sum @@ -4,8 +4,8 @@ github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNU github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/expr-lang/expr v1.17.8 h1:W1loDTT+0PQf5YteHSTpju2qfUfNoBt4yw9+wOEU9VM= github.com/expr-lang/expr v1.17.8/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4= -github.com/pilot-protocol/app-store v1.0.1 h1:17VPa02PhRRHAcspCg/CG+bBo5qUPrZQFi8hz66kAjE= -github.com/pilot-protocol/app-store v1.0.1/go.mod h1:deltPnaQkiTgMcxWU+honz3+Bl2R1cthhuZra4pQ4PI= +github.com/pilot-protocol/app-store v1.0.2 h1:oK7cNl3e/gfxVhhkUFKNLRN256+7sDSBw81oC9QmnB0= +github.com/pilot-protocol/app-store v1.0.2/go.mod h1:deltPnaQkiTgMcxWU+honz3+Bl2R1cthhuZra4pQ4PI= github.com/pilot-protocol/beacon v0.2.6 h1:grxwaVyPRUT0W6coyjYfNkO0rpzOIrwrKn94S21DuVE= github.com/pilot-protocol/beacon v0.2.6/go.mod h1:I/UhEv097g1z/qtAVDZbEhf3R5tzM0Dp71vGHah52A4= github.com/pilot-protocol/common v0.5.5 h1:mnv3q84alVaotGD+Qxfo4ECFEquqsUwrI3mjKIGUKFY= diff --git a/internal/catalogue/catalogue.go b/internal/catalogue/catalogue.go new file mode 100644 index 00000000..61858b09 --- /dev/null +++ b/internal/catalogue/catalogue.go @@ -0,0 +1,222 @@ +// Package catalogue loads the release-signed app-store catalogue and exposes +// the per-app publisher "pins" the daemon uses as its trust anchor. +// +// The catalogue (signed by the embedded catalogue key, see internal/catalogtrust) +// is the root of trust: it declares, per app id, the ed25519 publisher key that +// app's manifest must be signed by. The app-store supervisor confirms each +// non-sideloaded app's manifest.Store.Publisher matches this pin before spawning +// (manifest.VerifyTrustAnchor). This package is what feeds those pins to the +// supervisor via appstore.Config.CataloguePublisher. +package catalogue + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" + "sync" + "time" + + "github.com/pilot-protocol/pilotprotocol/internal/catalogtrust" +) + +// DefaultURL is the production catalogue location; override with +// $PILOT_APPSTORE_CATALOG_URL (kept identical to pilotctl's default). +const DefaultURL = "https://raw.githubusercontent.com/pilot-protocol/pilotprotocol/main/catalogue/catalogue.json" + +// URL returns the catalogue URL the daemon should load — env override wins. +func URL() string { + if u := strings.TrimSpace(os.Getenv("PILOT_APPSTORE_CATALOG_URL")); u != "" { + return u + } + return DefaultURL +} + +// entry is the minimal slice of a catalogue entry this package needs: the app +// id and the publisher pin. All other catalogue fields are ignored. +type entry struct { + ID string `json:"id"` + Publisher string `json:"publisher"` +} + +type doc struct { + Version int `json:"version"` + Apps []entry `json:"apps"` +} + +// LoadPublishers fetches the catalogue at url (and its detached .sig), +// verifies the signature against the embedded catalogue key (fail-closed), and +// returns appID -> publisher pin ("ed25519:") for every entry that +// declares a publisher. The signature check is the same gate pilotctl uses at +// install time — a substituted catalogue cannot change the pins. +func LoadPublishers(url string) (map[string]string, error) { + data, err := fetch(url) + if err != nil { + return nil, fmt.Errorf("fetch catalogue from %s: %w", url, err) + } + sigRaw, err := fetch(url + ".sig") + if err != nil { + return nil, fmt.Errorf("fetch catalogue signature %s.sig: %w", url, err) + } + sig, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(sigRaw))) + if err != nil { + return nil, fmt.Errorf("decode catalogue signature: %w", err) + } + if err := catalogtrust.Verify(data, sig); err != nil { + return nil, fmt.Errorf("catalogue signature: %w", err) + } + var d doc + if err := json.Unmarshal(data, &d); err != nil { + return nil, fmt.Errorf("parse catalogue: %w", err) + } + pins := make(map[string]string, len(d.Apps)) + for _, e := range d.Apps { + if e.ID != "" && strings.TrimSpace(e.Publisher) != "" { + pins[e.ID] = e.Publisher + } + } + return pins, nil +} + +// fetch reads up to 1 MiB from a file://, https://, or http://localhost URL. +// Mirrors pilotctl's openURL: plaintext http is refused for non-loopback hosts. +func fetch(raw string) ([]byte, error) { + u, err := url.Parse(raw) + if err != nil { + return nil, err + } + var body io.ReadCloser + switch u.Scheme { + case "file": + f, err := os.Open(u.Path) + if err != nil { + return nil, err + } + body = f + case "https": + body, err = httpGet(raw) + if err != nil { + return nil, err + } + case "http": + if h := u.Hostname(); h != "localhost" && h != "127.0.0.1" && h != "::1" { + return nil, fmt.Errorf("refusing plaintext http for non-localhost host %q (use https)", h) + } + body, err = httpGet(raw) + if err != nil { + return nil, err + } + default: + return nil, fmt.Errorf("unsupported url scheme %q", u.Scheme) + } + defer body.Close() + data, err := io.ReadAll(io.LimitReader(body, 1<<20)) + if err != nil { + return nil, fmt.Errorf("read body: %w", err) + } + return data, nil +} + +func httpGet(raw string) (io.ReadCloser, error) { + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Get(raw) //nolint:noctx // short-lived, bounded by client.Timeout + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + resp.Body.Close() + return nil, fmt.Errorf("GET %s: status %d", raw, resp.StatusCode) + } + return resp.Body, nil +} + +// Provider serves catalogue publisher pins to the app-store supervisor and +// refreshes them from the signed catalogue. Safe for concurrent use: the +// supervisor reads via Publisher on every scan while a background loop writes +// via Refresh. A disk cache lets the daemon survive a transient catalogue +// outage on restart (fail-closed only when there is neither a live catalogue +// nor a cache). +type Provider struct { + url string + cachePath string + + mu sync.RWMutex + pins map[string]string +} + +// NewProvider builds a Provider for the catalogue at url, caching the last +// verified pin set at cachePath (empty disables the cache). +func NewProvider(url, cachePath string) *Provider { + return &Provider{url: url, cachePath: cachePath, pins: map[string]string{}} +} + +// Publisher implements appstore.Config.CataloguePublisher: it returns the +// catalogue-pinned publisher for appID and whether appID is pinned. +func (p *Provider) Publisher(appID string) (string, bool) { + p.mu.RLock() + defer p.mu.RUnlock() + pub, ok := p.pins[appID] + return pub, ok +} + +// Refresh fetches + verifies the catalogue and atomically swaps in the new pin +// set. On success it also writes the disk cache. On failure the previous pins +// are kept (so a transient outage doesn't suddenly fail-close running apps). +func (p *Provider) Refresh() error { + pins, err := LoadPublishers(p.url) + if err != nil { + return err + } + p.mu.Lock() + p.pins = pins + p.mu.Unlock() + p.writeCache(pins) + return nil +} + +// LoadCache populates the pin set from the disk cache. Best-effort: used at +// startup when the initial Refresh fails (e.g. the daemon booted offline). +// Returns true if any pins were loaded. +func (p *Provider) LoadCache() bool { + if p.cachePath == "" { + return false + } + data, err := os.ReadFile(p.cachePath) + if err != nil { + return false + } + var pins map[string]string + if err := json.Unmarshal(data, &pins); err != nil || len(pins) == 0 { + return false + } + p.mu.Lock() + p.pins = pins + p.mu.Unlock() + return true +} + +func (p *Provider) writeCache(pins map[string]string) { + if p.cachePath == "" { + return + } + data, err := json.Marshal(pins) + if err != nil { + return + } + tmp := p.cachePath + ".tmp" + if err := os.WriteFile(tmp, data, 0o600); err != nil { + return + } + _ = os.Rename(tmp, p.cachePath) // atomic replace; best-effort +} + +// Count returns how many apps are currently pinned (for startup logging). +func (p *Provider) Count() int { + p.mu.RLock() + defer p.mu.RUnlock() + return len(p.pins) +} diff --git a/internal/catalogue/catalogue_test.go b/internal/catalogue/catalogue_test.go new file mode 100644 index 00000000..d60eec69 --- /dev/null +++ b/internal/catalogue/catalogue_test.go @@ -0,0 +1,125 @@ +package catalogue + +import ( + "encoding/base64" + "os" + "path/filepath" + "testing" + + "github.com/pilot-protocol/pilotprotocol/internal/catalogtrust" +) + +// writeSignedCatalogue writes catalogue.json + a valid .sig (signed with an +// ephemeral key swapped into catalogtrust) and returns the file:// URL plus a +// restore func that undoes the key swap. Not parallel-safe: it mutates the +// process-global catalogue verify key. +func writeSignedCatalogue(t *testing.T, body string) (url string, restore func()) { + t.Helper() + dir := t.TempDir() + cat := filepath.Join(dir, "catalogue.json") + if err := os.WriteFile(cat, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + sig, restore := catalogtrust.SignWithEphemeralKey([]byte(body)) + sigB64 := base64.StdEncoding.EncodeToString(sig) + if err := os.WriteFile(cat+".sig", []byte(sigB64), 0o600); err != nil { + t.Fatal(err) + } + return "file://" + cat, restore +} + +const twoAppCatalogue = `{ + "version": 2, + "apps": [ + {"id": "io.pilot.smolmachines", "publisher": "ed25519:3QJm6H6OdjtfrF+Es1lrRjfFmdtq2tGvVSWxia63vcI="}, + {"id": "io.pilot.nopublisher"} + ] +}` + +func TestLoadPublishers_VerifiesAndParses(t *testing.T) { + url, restore := writeSignedCatalogue(t, twoAppCatalogue) + defer restore() + + pins, err := LoadPublishers(url) + if err != nil { + t.Fatalf("LoadPublishers: %v", err) + } + if got := pins["io.pilot.smolmachines"]; got != "ed25519:3QJm6H6OdjtfrF+Es1lrRjfFmdtq2tGvVSWxia63vcI=" { + t.Errorf("smolmachines pin = %q, want the declared key", got) + } + if _, ok := pins["io.pilot.nopublisher"]; ok { + t.Error("an entry without a publisher must not be pinned") + } +} + +func TestLoadPublishers_RejectsTamperedCatalogue(t *testing.T) { + url, restore := writeSignedCatalogue(t, twoAppCatalogue) + defer restore() + + // Tamper the catalogue body after signing — the signature must no longer verify. + cat := url[len("file://"):] + tampered := `{"version":2,"apps":[{"id":"io.evil.app","publisher":"ed25519:3QJm6H6OdjtfrF+Es1lrRjfFmdtq2tGvVSWxia63vcI="}]}` + if err := os.WriteFile(cat, []byte(tampered), 0o600); err != nil { + t.Fatal(err) + } + if _, err := LoadPublishers(url); err == nil { + t.Fatal("expected a signature error for a tampered catalogue, got nil") + } +} + +func TestLoadPublishers_RejectsMissingSignature(t *testing.T) { + dir := t.TempDir() + cat := filepath.Join(dir, "catalogue.json") + if err := os.WriteFile(cat, []byte(twoAppCatalogue), 0o600); err != nil { + t.Fatal(err) + } + // No .sig file written. + if _, err := LoadPublishers("file://" + cat); err == nil { + t.Fatal("expected an error when the catalogue signature is missing, got nil") + } +} + +func TestProvider_RefreshPublisherAndCache(t *testing.T) { + url, restore := writeSignedCatalogue(t, twoAppCatalogue) + defer restore() + + cachePath := filepath.Join(t.TempDir(), "pins.json") + p := NewProvider(url, cachePath) + if err := p.Refresh(); err != nil { + t.Fatalf("Refresh: %v", err) + } + if pub, ok := p.Publisher("io.pilot.smolmachines"); !ok || pub == "" { + t.Error("Publisher should report smolmachines as pinned after Refresh") + } + if _, ok := p.Publisher("io.pilot.unknown"); ok { + t.Error("Publisher must report an unknown app as not pinned") + } + if p.Count() != 1 { + t.Errorf("Count = %d, want 1", p.Count()) + } + // Refresh wrote the cache; a fresh Provider must recover pins from it offline. + if _, err := os.Stat(cachePath); err != nil { + t.Fatalf("cache not written: %v", err) + } + p2 := NewProvider("file:///nonexistent/catalogue.json", cachePath) + if err := p2.Refresh(); err == nil { + t.Fatal("expected Refresh to fail against a nonexistent catalogue") + } + if !p2.LoadCache() { + t.Fatal("LoadCache should recover pins from the disk cache") + } + if _, ok := p2.Publisher("io.pilot.smolmachines"); !ok { + t.Error("after LoadCache, the cached pin must be served") + } +} + +func TestProvider_NilCacheAndFailClosed(t *testing.T) { + // A provider that has never loaded anything reports nothing pinned. + p := NewProvider("file:///nonexistent", "") + if _, ok := p.Publisher("io.pilot.smolmachines"); ok { + t.Error("an empty provider must report apps as not pinned (fail closed)") + } + if p.LoadCache() { + t.Error("LoadCache with no cache path must report no pins") + } +}