Skip to content

Commit 1acfbd1

Browse files
authored
daemon: anchor catalogue apps to the catalogue publisher pin (companion to app-store#25) (#324)
1 parent d2e7eaf commit 1acfbd1

8 files changed

Lines changed: 400 additions & 8 deletions

File tree

catalogue/catalogue.json

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@
1919
"source_url": "https://github.com/pilot-protocol/wallet",
2020
"license": "AGPL-3.0-or-later",
2121
"metadata_url": "https://raw.githubusercontent.com/pilot-protocol/pilotprotocol/main/catalogue/apps/io.pilot.wallet/metadata.json",
22-
"metadata_sha256": "b0ed7dee416144c39d8938f4dbeaac8946be3290a6c44473d898a5945eb6cadb"
22+
"metadata_sha256": "b0ed7dee416144c39d8938f4dbeaac8946be3290a6c44473d898a5945eb6cadb",
23+
"publisher": "ed25519:VF8fdEP/Oe2aWN3ozQ7Ar22137tHb7dkSw0hlzlk/os="
2324
},
2425
{
2526
"id": "io.pilot.cosift",
@@ -38,7 +39,8 @@
3839
"source_url": "https://github.com/pilot-protocol/cosift",
3940
"license": "MIT",
4041
"metadata_url": "https://raw.githubusercontent.com/pilot-protocol/pilotprotocol/main/catalogue/apps/io.pilot.cosift/metadata.json",
41-
"metadata_sha256": "2b13511562dc1cfe09a6f53928149509a96ef893ee783fd1e055ed379b08b640"
42+
"metadata_sha256": "2b13511562dc1cfe09a6f53928149509a96ef893ee783fd1e055ed379b08b640",
43+
"publisher": "ed25519:EjoZEiV+j6oNZLRWNEEf8lEnC8XBkrvBAk4fxuZLLyU="
4244
},
4345
{
4446
"id": "io.pilot.sixtyfour",
@@ -75,7 +77,8 @@
7577
"bundle_url": "https://github.com/pilot-protocol/catalog/releases/download/sixtyfour-v0.1.0/io.pilot.sixtyfour-0.1.0-darwin-amd64.tar.gz",
7678
"bundle_sha256": "d970483e9cad84207f853d681cc810e954e236acd5e410b402880dc4d8304aa2"
7779
}
78-
}
80+
},
81+
"publisher": "ed25519:VoVCiQKPr73di2MlUd091a2Y6TCj/edSbCwRDtnYquI="
7982
},
8083
{
8184
"id": "io.pilot.smolmachines",
@@ -106,7 +109,8 @@
106109
}
107110
},
108111
"metadata_url": "https://raw.githubusercontent.com/pilot-protocol/pilotprotocol/main/catalogue/apps/io.pilot.smolmachines/metadata.json",
109-
"metadata_sha256": "f43493f690786b8adbe1ac1072bbec0b0d04e05d9a247e7b14e5d36c4e397a3b"
112+
"metadata_sha256": "f43493f690786b8adbe1ac1072bbec0b0d04e05d9a247e7b14e5d36c4e397a3b",
113+
"publisher": "ed25519:3QJm6H6OdjtfrF+Es1lrRjfFmdtq2tGvVSWxia63vcI="
110114
}
111115
]
112116
}

catalogue/catalogue.json.sig

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
Qx9a3z30QrOgX1u4BTxqlXF2UkSJCmrg7va+0xAioJPNmCdjKlkoPz0QX4Gk6oIv5His+gqNi5a+ij31vHShAg==
1+
R9fRDEpqBDxnoKv2yuRG/sBKmN/QkqehEYql7yyCueuiYqunPdDUPGgMZXxPmmDvZymh0NqAjKA+uHmslKWpCQ==

cmd/daemon/main.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import (
3636
"github.com/pilot-protocol/webhook"
3737

3838
"github.com/pilot-protocol/pilotprotocol/internal/catalogtrust"
39+
"github.com/pilot-protocol/pilotprotocol/internal/catalogue"
3940
"github.com/pilot-protocol/pilotprotocol/pkg/telemetry"
4041
)
4142

@@ -349,6 +350,36 @@ func main() {
349350
if r := os.Getenv("PILOT_APPSTORE_ROOT"); r != "" {
350351
appstoreInstallRoot = r
351352
}
353+
// Catalogue trust anchor: load the per-app publisher pins from the
354+
// release-signed catalogue and feed them to the supervisor. A non-sideloaded
355+
// app is spawned only if its manifest publisher matches the key the catalogue
356+
// pins for its id (see appstore.Config.CataloguePublisher). The pins are
357+
// cached on disk so a transient catalogue outage on restart doesn't fail-close
358+
// every app; with neither a live catalogue nor a cache, apps fail closed.
359+
cataloguePins := catalogue.NewProvider(
360+
catalogue.URL(),
361+
filepath.Join(filepath.Dir(appstoreInstallRoot), "catalogue-pins.json"),
362+
)
363+
if err := cataloguePins.Refresh(); err != nil {
364+
if cataloguePins.LoadCache() {
365+
log.Printf("appstore: catalogue refresh failed (%v); using %d cached publisher pin(s)", err, cataloguePins.Count())
366+
} else {
367+
log.Printf("appstore: catalogue refresh failed (%v) and no cache; catalogue apps fail closed until the next refresh succeeds", err)
368+
}
369+
} else {
370+
log.Printf("appstore: loaded %d catalogue publisher pin(s)", cataloguePins.Count())
371+
}
372+
// Refresh the pins periodically so newly-catalogued apps become spawnable
373+
// without a daemon restart. Daemon-lifetime loop; the process exit stops it.
374+
go func() {
375+
t := time.NewTicker(10 * time.Minute)
376+
defer t.Stop()
377+
for range t.C {
378+
if err := cataloguePins.Refresh(); err != nil {
379+
log.Printf("appstore: catalogue pin refresh failed: %v (keeping previous pins)", err)
380+
}
381+
}
382+
}()
352383
// The app-usage telemetry emitter shares the daemon's identity file
353384
// and telemetry URL. When consent is off (empty URL) the client is
354385
// a permanent no-op — no goroutines, no dials, no buffering.
@@ -368,6 +399,10 @@ func main() {
368399
// Real catalogue trust anchor (replaces the all-zeros
369400
// placeholder default): the embedded ed25519 catalogue key.
370401
CatalogPubkey: []byte(catalogtrust.PublicKey()),
402+
// Per-app publisher pins from the release-signed catalogue: the
403+
// supervisor confirms each non-sideloaded app's manifest publisher
404+
// against this before spawning. nil/unpinned => fail closed.
405+
CataloguePublisher: cataloguePins.Publisher,
371406
}),
372407
telemetryURL: *telemetryURL,
373408
identityPath: idPath,

cmd/pilotctl/appstore_catalogue.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,12 @@ type catalogueEntry struct {
8989
BundleURL string `json:"bundle_url"`
9090
BundleSHA string `json:"bundle_sha256"`
9191

92+
// Publisher is the app's ed25519 publisher key ("ed25519:<base64>"). It is
93+
// the trust pin: the daemon (internal/catalogue) reads it from the
94+
// signature-verified catalogue and the app-store supervisor confirms each
95+
// non-sideloaded app's manifest publisher matches it before spawning.
96+
Publisher string `json:"publisher,omitempty"`
97+
9298
// --- v3 per-platform bundles ---
9399
// Bundles maps "os/arch" (e.g. "darwin/arm64") → that platform's
94100
// tarball + sha256. When present, install picks the host's entry;

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ go 1.25.11
44

55
require (
66
github.com/coder/websocket v1.8.15
7-
github.com/pilot-protocol/app-store v1.0.1
7+
github.com/pilot-protocol/app-store v1.0.2
88
github.com/pilot-protocol/beacon v0.2.6
99
github.com/pilot-protocol/common v0.5.5
1010
github.com/pilot-protocol/dataexchange v0.2.1-beta.1.0.20260615113607-fac933edea98

go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNU
44
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
55
github.com/expr-lang/expr v1.17.8 h1:W1loDTT+0PQf5YteHSTpju2qfUfNoBt4yw9+wOEU9VM=
66
github.com/expr-lang/expr v1.17.8/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4=
7-
github.com/pilot-protocol/app-store v1.0.1 h1:17VPa02PhRRHAcspCg/CG+bBo5qUPrZQFi8hz66kAjE=
8-
github.com/pilot-protocol/app-store v1.0.1/go.mod h1:deltPnaQkiTgMcxWU+honz3+Bl2R1cthhuZra4pQ4PI=
7+
github.com/pilot-protocol/app-store v1.0.2 h1:oK7cNl3e/gfxVhhkUFKNLRN256+7sDSBw81oC9QmnB0=
8+
github.com/pilot-protocol/app-store v1.0.2/go.mod h1:deltPnaQkiTgMcxWU+honz3+Bl2R1cthhuZra4pQ4PI=
99
github.com/pilot-protocol/beacon v0.2.6 h1:grxwaVyPRUT0W6coyjYfNkO0rpzOIrwrKn94S21DuVE=
1010
github.com/pilot-protocol/beacon v0.2.6/go.mod h1:I/UhEv097g1z/qtAVDZbEhf3R5tzM0Dp71vGHah52A4=
1111
github.com/pilot-protocol/common v0.5.5 h1:mnv3q84alVaotGD+Qxfo4ECFEquqsUwrI3mjKIGUKFY=

internal/catalogue/catalogue.go

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
// Package catalogue loads the release-signed app-store catalogue and exposes
2+
// the per-app publisher "pins" the daemon uses as its trust anchor.
3+
//
4+
// The catalogue (signed by the embedded catalogue key, see internal/catalogtrust)
5+
// is the root of trust: it declares, per app id, the ed25519 publisher key that
6+
// app's manifest must be signed by. The app-store supervisor confirms each
7+
// non-sideloaded app's manifest.Store.Publisher matches this pin before spawning
8+
// (manifest.VerifyTrustAnchor). This package is what feeds those pins to the
9+
// supervisor via appstore.Config.CataloguePublisher.
10+
package catalogue
11+
12+
import (
13+
"encoding/base64"
14+
"encoding/json"
15+
"fmt"
16+
"io"
17+
"net/http"
18+
"net/url"
19+
"os"
20+
"strings"
21+
"sync"
22+
"time"
23+
24+
"github.com/pilot-protocol/pilotprotocol/internal/catalogtrust"
25+
)
26+
27+
// DefaultURL is the production catalogue location; override with
28+
// $PILOT_APPSTORE_CATALOG_URL (kept identical to pilotctl's default).
29+
const DefaultURL = "https://raw.githubusercontent.com/pilot-protocol/pilotprotocol/main/catalogue/catalogue.json"
30+
31+
// URL returns the catalogue URL the daemon should load — env override wins.
32+
func URL() string {
33+
if u := strings.TrimSpace(os.Getenv("PILOT_APPSTORE_CATALOG_URL")); u != "" {
34+
return u
35+
}
36+
return DefaultURL
37+
}
38+
39+
// entry is the minimal slice of a catalogue entry this package needs: the app
40+
// id and the publisher pin. All other catalogue fields are ignored.
41+
type entry struct {
42+
ID string `json:"id"`
43+
Publisher string `json:"publisher"`
44+
}
45+
46+
type doc struct {
47+
Version int `json:"version"`
48+
Apps []entry `json:"apps"`
49+
}
50+
51+
// LoadPublishers fetches the catalogue at url (and its detached <url>.sig),
52+
// verifies the signature against the embedded catalogue key (fail-closed), and
53+
// returns appID -> publisher pin ("ed25519:<base64>") for every entry that
54+
// declares a publisher. The signature check is the same gate pilotctl uses at
55+
// install time — a substituted catalogue cannot change the pins.
56+
func LoadPublishers(url string) (map[string]string, error) {
57+
data, err := fetch(url)
58+
if err != nil {
59+
return nil, fmt.Errorf("fetch catalogue from %s: %w", url, err)
60+
}
61+
sigRaw, err := fetch(url + ".sig")
62+
if err != nil {
63+
return nil, fmt.Errorf("fetch catalogue signature %s.sig: %w", url, err)
64+
}
65+
sig, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(sigRaw)))
66+
if err != nil {
67+
return nil, fmt.Errorf("decode catalogue signature: %w", err)
68+
}
69+
if err := catalogtrust.Verify(data, sig); err != nil {
70+
return nil, fmt.Errorf("catalogue signature: %w", err)
71+
}
72+
var d doc
73+
if err := json.Unmarshal(data, &d); err != nil {
74+
return nil, fmt.Errorf("parse catalogue: %w", err)
75+
}
76+
pins := make(map[string]string, len(d.Apps))
77+
for _, e := range d.Apps {
78+
if e.ID != "" && strings.TrimSpace(e.Publisher) != "" {
79+
pins[e.ID] = e.Publisher
80+
}
81+
}
82+
return pins, nil
83+
}
84+
85+
// fetch reads up to 1 MiB from a file://, https://, or http://localhost URL.
86+
// Mirrors pilotctl's openURL: plaintext http is refused for non-loopback hosts.
87+
func fetch(raw string) ([]byte, error) {
88+
u, err := url.Parse(raw)
89+
if err != nil {
90+
return nil, err
91+
}
92+
var body io.ReadCloser
93+
switch u.Scheme {
94+
case "file":
95+
f, err := os.Open(u.Path)
96+
if err != nil {
97+
return nil, err
98+
}
99+
body = f
100+
case "https":
101+
body, err = httpGet(raw)
102+
if err != nil {
103+
return nil, err
104+
}
105+
case "http":
106+
if h := u.Hostname(); h != "localhost" && h != "127.0.0.1" && h != "::1" {
107+
return nil, fmt.Errorf("refusing plaintext http for non-localhost host %q (use https)", h)
108+
}
109+
body, err = httpGet(raw)
110+
if err != nil {
111+
return nil, err
112+
}
113+
default:
114+
return nil, fmt.Errorf("unsupported url scheme %q", u.Scheme)
115+
}
116+
defer body.Close()
117+
data, err := io.ReadAll(io.LimitReader(body, 1<<20))
118+
if err != nil {
119+
return nil, fmt.Errorf("read body: %w", err)
120+
}
121+
return data, nil
122+
}
123+
124+
func httpGet(raw string) (io.ReadCloser, error) {
125+
client := &http.Client{Timeout: 15 * time.Second}
126+
resp, err := client.Get(raw) //nolint:noctx // short-lived, bounded by client.Timeout
127+
if err != nil {
128+
return nil, err
129+
}
130+
if resp.StatusCode != http.StatusOK {
131+
resp.Body.Close()
132+
return nil, fmt.Errorf("GET %s: status %d", raw, resp.StatusCode)
133+
}
134+
return resp.Body, nil
135+
}
136+
137+
// Provider serves catalogue publisher pins to the app-store supervisor and
138+
// refreshes them from the signed catalogue. Safe for concurrent use: the
139+
// supervisor reads via Publisher on every scan while a background loop writes
140+
// via Refresh. A disk cache lets the daemon survive a transient catalogue
141+
// outage on restart (fail-closed only when there is neither a live catalogue
142+
// nor a cache).
143+
type Provider struct {
144+
url string
145+
cachePath string
146+
147+
mu sync.RWMutex
148+
pins map[string]string
149+
}
150+
151+
// NewProvider builds a Provider for the catalogue at url, caching the last
152+
// verified pin set at cachePath (empty disables the cache).
153+
func NewProvider(url, cachePath string) *Provider {
154+
return &Provider{url: url, cachePath: cachePath, pins: map[string]string{}}
155+
}
156+
157+
// Publisher implements appstore.Config.CataloguePublisher: it returns the
158+
// catalogue-pinned publisher for appID and whether appID is pinned.
159+
func (p *Provider) Publisher(appID string) (string, bool) {
160+
p.mu.RLock()
161+
defer p.mu.RUnlock()
162+
pub, ok := p.pins[appID]
163+
return pub, ok
164+
}
165+
166+
// Refresh fetches + verifies the catalogue and atomically swaps in the new pin
167+
// set. On success it also writes the disk cache. On failure the previous pins
168+
// are kept (so a transient outage doesn't suddenly fail-close running apps).
169+
func (p *Provider) Refresh() error {
170+
pins, err := LoadPublishers(p.url)
171+
if err != nil {
172+
return err
173+
}
174+
p.mu.Lock()
175+
p.pins = pins
176+
p.mu.Unlock()
177+
p.writeCache(pins)
178+
return nil
179+
}
180+
181+
// LoadCache populates the pin set from the disk cache. Best-effort: used at
182+
// startup when the initial Refresh fails (e.g. the daemon booted offline).
183+
// Returns true if any pins were loaded.
184+
func (p *Provider) LoadCache() bool {
185+
if p.cachePath == "" {
186+
return false
187+
}
188+
data, err := os.ReadFile(p.cachePath)
189+
if err != nil {
190+
return false
191+
}
192+
var pins map[string]string
193+
if err := json.Unmarshal(data, &pins); err != nil || len(pins) == 0 {
194+
return false
195+
}
196+
p.mu.Lock()
197+
p.pins = pins
198+
p.mu.Unlock()
199+
return true
200+
}
201+
202+
func (p *Provider) writeCache(pins map[string]string) {
203+
if p.cachePath == "" {
204+
return
205+
}
206+
data, err := json.Marshal(pins)
207+
if err != nil {
208+
return
209+
}
210+
tmp := p.cachePath + ".tmp"
211+
if err := os.WriteFile(tmp, data, 0o600); err != nil {
212+
return
213+
}
214+
_ = os.Rename(tmp, p.cachePath) // atomic replace; best-effort
215+
}
216+
217+
// Count returns how many apps are currently pinned (for startup logging).
218+
func (p *Provider) Count() int {
219+
p.mu.RLock()
220+
defer p.mu.RUnlock()
221+
return len(p.pins)
222+
}

0 commit comments

Comments
 (0)