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
12 changes: 8 additions & 4 deletions catalogue/catalogue.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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="
}
]
}
2 changes: 1 addition & 1 deletion catalogue/catalogue.json.sig
Original file line number Diff line number Diff line change
@@ -1 +1 @@
Qx9a3z30QrOgX1u4BTxqlXF2UkSJCmrg7va+0xAioJPNmCdjKlkoPz0QX4Gk6oIv5His+gqNi5a+ij31vHShAg==
R9fRDEpqBDxnoKv2yuRG/sBKmN/QkqehEYql7yyCueuiYqunPdDUPGgMZXxPmmDvZymh0NqAjKA+uHmslKWpCQ==
35 changes: 35 additions & 0 deletions cmd/daemon/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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())
Comment thread
Alexgodoroja marked this conversation as resolved.
Dismissed
} 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())
Comment thread
Alexgodoroja marked this conversation as resolved.
Dismissed
}
// 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.
Expand All @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions cmd/pilotctl/appstore_catalogue.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:<base64>"). 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;
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
222 changes: 222 additions & 0 deletions internal/catalogue/catalogue.go
Original file line number Diff line number Diff line change
@@ -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 <url>.sig),
// verifies the signature against the embedded catalogue key (fail-closed), and
// returns appID -> publisher pin ("ed25519:<base64>") 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()

Check warning

Code scanning / gosec

Errors unhandled Warning

Errors unhandled
Comment thread
Alexgodoroja marked this conversation as resolved.
Dismissed
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)
}
Loading
Loading