Skip to content

Commit 3c7f6eb

Browse files
TeoSlayerteovlclaude
authored
Signed app-store catalogue + Pages catalogue site (#249)
* feat(appstore): sign the catalogue and verify it fail-closed Replace the placeholder catalogue trust anchor with a real embedded ed25519 key (internal/catalogtrust, ldflags-overridable for rotation): - pilotctl loadCatalogue fetches a detached <url>.sig and verifies it against the embedded key before trusting any entry; unsigned, missing, or tampered catalogues are refused. - New 'pilotctl appstore sign-catalogue' writes the detached signature and refuses to sign with a key that doesn't match the embedded public key. - cmd/daemon passes the embedded key into appstore.Config.CatalogPubkey, retiring the all-zeros placeholder at the wire-up point. - Sign catalogue.json; document signing + key rotation in catalogue README. Tests: catalogtrust verify (happy/tamper/short/no-key), signed-catalogue load + fail-closed (missing sig, tamper). * feat(catalogue): static site rendering the live catalogue Dependency-free page (catalogue/site/index.html) that fetches catalogue.json same-origin and renders one card per app (id, version, description, install command, bundle link; methods when an entry provides them). The Pages deploy workflow is added separately (requires workflow scope on the push token). * fix(catalogue): rotate signing key + re-sign after #255; sign #255 test fixtures The previous catalogue trust anchor's private key (publicKeyB64 = "5aCD92R0UoZ2lGW6PYZeRrDw63ZNBC5oJZxFB8RNOPQ=") was lost and is unrecoverable, so the committed catalogue.json.sig could never be regenerated to match an edited catalogue. This anchor existed only on this PR branch — not on main and not in any shipped binary — so rotating it now has zero installed-base impact. Changes: - Generate a fresh ed25519 release keypair (private key stored only outside the repo, never committed) and embed its public half as the new trust anchor: publicKeyB64 = "iHdBWayA/hYjkwUOZopTXY70qOlR90d6ii/hin0ZMdI=" - Re-sign catalogue/catalogue.json with the new key (catalogue/catalogue.json.sig), which now verifies fail-closed against the embedded key. - Fix the test fixtures #255 left unsigned: stageCatalogue now signs each fixture with a per-test ephemeral key (swapped into the embedded anchor for the test's duration via catalogtrust.SignWithEphemeralKey, restored on cleanup). Tests exercise REAL fail-closed verification against a VALID signature — the gate is not skipped, disabled, or weakened; the negative tests (missing-signature, tamper) still pass. Rebased onto origin/main, reconciling #255's v2 metadata schema with this branch's catalogue signing (kept the fail-closed sig gate in loadCatalogue, both `view` and `sign-catalogue` subcommands, and main's metadata pin discipline in catalogue/README.md). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Teodor Calin <teodor@vulturelabs.io> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent e4e1a5b commit 3c7f6eb

11 files changed

Lines changed: 544 additions & 14 deletions

File tree

catalogue/README.md

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -144,19 +144,55 @@ from a remote — operators relying on a catalogue do so over `https` only).
144144
`bundle_sha256`, the teaser fields, **and** the new `metadata_sha256`
145145
from step 5. Commit everything together — the index pin and the detail
146146
doc must change in the same commit or `view` will reject a stale pin.
147-
The change goes live the moment the commit lands on `main` and the raw
148-
URLs serve the new bytes — no daemon restart, no pilotctl release.
149147

150-
> **Pin discipline:** `metadata_sha256` must be the sha256 of the exact
151-
> committed `metadata.json` bytes. Edit the doc, then recompute — never the
152-
> other way round. A mismatch makes `view` fall back to the teaser and warn.
148+
> **Pin discipline:** `metadata_sha256` must be the sha256 of the exact
149+
> committed `metadata.json` bytes. Edit the doc, then recompute — never
150+
> the other way round. A mismatch makes `view` fall back to the teaser
151+
> and warn.
152+
7. **Re-sign the catalogue** (the signature covers the exact `catalogue.json`
153+
bytes, so it must be regenerated on every edit — including the
154+
`metadata_sha256` pin change from step 6):
155+
```bash
156+
pilotctl appstore sign-catalogue --key /secure/path/catalog-signing.key \
157+
catalogue/catalogue.json
158+
```
159+
This writes `catalogue.json.sig` (detached, base64 ed25519). Commit
160+
`catalogue.json`, `catalogue.json.sig`, **and** the updated
161+
`apps/<id>/metadata.json` together. The change goes live the moment they
162+
land on `main` and the raw URLs serve the new bytes — no daemon restart,
163+
no pilotctl release.
164+
165+
`pilotctl` fetches `catalogue.json` **and** `catalogue.json.sig` and
166+
verifies the signature against the embedded catalogue public key before
167+
trusting any entry. An unsigned, missing-signature, or tampered catalogue
168+
is refused (fail-closed).
169+
170+
## Catalogue signing key
171+
172+
The catalogue is signed with a dedicated ed25519 key, separate from any
173+
app-publisher key. The **private** key is held by the release pipeline and
174+
is never committed. The **public** key is compiled into pilotctl and the
175+
daemon at `internal/catalogtrust` (`publicKeyB64`) and can be rotated at
176+
build time without a code change:
177+
178+
```bash
179+
go build -ldflags \
180+
"-X github.com/TeoSlayer/pilotprotocol/internal/catalogtrust.publicKeyB64=<new-b64-pubkey>" \
181+
./cmd/pilotctl ./cmd/daemon
182+
```
183+
184+
To rotate: generate a new keypair, store the private key securely, update
185+
the embedded public key (source default or `-ldflags`), and re-sign the
186+
catalogue. `sign-catalogue` refuses to sign with a key that doesn't match
187+
the embedded public key, so a mismatch is caught before publishing a dead
188+
signature.
153189

154190
## Trust model
155191

156192
| Layer | Trust anchor | Verifies |
157193
|---|---|---|
158194
| User trusts pilotctl | Project release pipeline (signed pilotctl binary) | The catalogue URL is correct |
159-
| pilotctl trusts the catalogue | Future: signed against `EmbeddedCatalogPubkey`; today: the raw URL itself | App IDs map to specific bundle URLs + SHAs |
195+
| pilotctl trusts the catalogue | Detached ed25519 signature against the embedded catalogue key (`internal/catalogtrust`) | The app list (IDs → bundle URLs + SHAs) is authentic; a substituted catalogue is rejected |
160196
| pilotctl trusts the bundle | Embedded `bundle_sha256` matches downloaded bytes | A CDN substitute is rejected |
161197
| pilotctl trusts the detail doc | Index `metadata_sha256` matches fetched `metadata.json` | A substituted listing is rejected (`view` falls back to the teaser) |
162198
| Daemon trusts the manifest | Embedded ed25519 publisher pubkey verifies the signature | The bundle's manifest hasn't been tampered with |

catalogue/catalogue.json.sig

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
VuWWD6mSCOQR1LLYWxhdUdDu/RHQQXr0UZJBh6fOmy7VyIgcM/W/yeMNo7ym0pYy70KN19xFbDe9SvPjdeFoBw==

catalogue/site/index.html

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="utf-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1" />
6+
<title>Pilot App Store — Catalogue</title>
7+
<meta name="description" content="Apps installable via pilotctl appstore install — the Pilot Protocol app store catalogue." />
8+
<style>
9+
:root {
10+
--bg: #0b0e14; --panel: #131823; --panel2: #1b2230; --line: #273043;
11+
--fg: #e6e9ef; --muted: #93a0b5; --accent: #5b8cff; --ok: #46d39a; --code: #0f1420;
12+
}
13+
* { box-sizing: border-box; }
14+
body {
15+
margin: 0; background: var(--bg); color: var(--fg);
16+
font: 16px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
17+
}
18+
header { padding: 56px 24px 28px; max-width: 1040px; margin: 0 auto; }
19+
h1 { font-size: 30px; margin: 0 0 8px; letter-spacing: -0.02em; }
20+
.sub { color: var(--muted); margin: 0; }
21+
.meta { color: var(--muted); font-size: 13px; margin-top: 14px; }
22+
main { max-width: 1040px; margin: 0 auto; padding: 0 24px 64px; }
23+
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 18px; }
24+
.card {
25+
background: linear-gradient(180deg, var(--panel), var(--panel2));
26+
border: 1px solid var(--line); border-radius: 14px; padding: 20px 20px 18px;
27+
display: flex; flex-direction: column; gap: 12px;
28+
}
29+
.card h2 { font-size: 18px; margin: 0; display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
30+
.ver { color: var(--ok); font-size: 13px; font-weight: 600; background: rgba(70,211,154,.12); padding: 2px 8px; border-radius: 999px; }
31+
.desc { color: var(--fg); margin: 0; }
32+
.label { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .06em; margin: 4px 0 2px; }
33+
.methods { display: flex; flex-wrap: wrap; gap: 6px; }
34+
.pill { background: var(--code); border: 1px solid var(--line); border-radius: 8px; padding: 2px 8px; font-size: 12px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--accent); }
35+
.muted { color: var(--muted); font-size: 13px; }
36+
pre { background: var(--code); border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px; margin: 0; overflow-x: auto; }
37+
code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 13px; color: var(--fg); }
38+
.row { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
39+
a { color: var(--accent); text-decoration: none; }
40+
a:hover { text-decoration: underline; }
41+
.foot { color: var(--muted); font-size: 13px; margin-top: 36px; border-top: 1px solid var(--line); padding-top: 18px; }
42+
.err { background: #2a1620; border: 1px solid #5a2740; color: #ffb4c4; padding: 16px; border-radius: 12px; }
43+
.sha { font-family: ui-monospace, monospace; font-size: 11px; color: var(--muted); word-break: break-all; }
44+
</style>
45+
</head>
46+
<body>
47+
<header>
48+
<h1>Pilot App Store</h1>
49+
<p class="sub">Apps installable with <code>pilotctl appstore install &lt;id&gt;</code> over the Pilot overlay network.</p>
50+
<p class="meta" id="meta"></p>
51+
</header>
52+
<main>
53+
<div id="content"><p class="muted">Loading catalogue…</p></div>
54+
<p class="foot">
55+
The catalogue is fetched from <code id="src"></code> and is signed (detached ed25519);
56+
<code>pilotctl</code> verifies the signature before any install.
57+
</p>
58+
</main>
59+
<script>
60+
// Same-origin catalogue copied next to this page at deploy time. Override
61+
// with ?src= for local testing against another catalogue URL.
62+
const params = new URLSearchParams(location.search);
63+
const SRC = params.get("src") || "./catalogue.json";
64+
65+
function el(tag, cls, text) {
66+
const e = document.createElement(tag);
67+
if (cls) e.className = cls;
68+
if (text != null) e.textContent = text;
69+
return e;
70+
}
71+
72+
function card(app) {
73+
const c = el("div", "card");
74+
const h = el("h2");
75+
h.appendChild(el("span", null, app.id || "(unknown id)"));
76+
if (app.version) h.appendChild(el("span", "ver", "v" + app.version));
77+
c.appendChild(h);
78+
79+
if (app.description) c.appendChild(el("p", "desc", app.description));
80+
81+
// Methods aren't part of the v1 catalogue schema (they live in each
82+
// app's manifest); render them when an entry provides them.
83+
if (Array.isArray(app.methods) && app.methods.length) {
84+
c.appendChild(el("div", "label", "Methods"));
85+
const m = el("div", "methods");
86+
app.methods.forEach(x => m.appendChild(el("span", "pill", x)));
87+
c.appendChild(m);
88+
}
89+
90+
c.appendChild(el("div", "label", "Install"));
91+
const pre = el("pre");
92+
pre.appendChild(el("code", null, "pilotctl appstore install " + (app.id || "")));
93+
c.appendChild(pre);
94+
95+
if (app.bundle_url) {
96+
const row = el("div", "row");
97+
const a = el("a", null, "bundle ↗");
98+
a.href = app.bundle_url; a.rel = "noopener"; a.target = "_blank";
99+
row.appendChild(a);
100+
if (app.bundle_sha256) row.appendChild(el("span", "sha", app.bundle_sha256.slice(0, 16) + "…"));
101+
c.appendChild(row);
102+
}
103+
return c;
104+
}
105+
106+
async function render() {
107+
const content = document.getElementById("content");
108+
document.getElementById("src").textContent = SRC;
109+
try {
110+
const res = await fetch(SRC, { cache: "no-store" });
111+
if (!res.ok) throw new Error("HTTP " + res.status);
112+
const cat = await res.json();
113+
document.getElementById("meta").textContent =
114+
"Schema v" + cat.version + (cat.updated_at ? " · updated " + cat.updated_at : "") +
115+
" · " + (cat.apps ? cat.apps.length : 0) + " app(s)";
116+
content.textContent = "";
117+
if (!cat.apps || !cat.apps.length) {
118+
content.appendChild(el("p", "muted", "The catalogue is empty."));
119+
return;
120+
}
121+
const grid = el("div", "grid");
122+
cat.apps.forEach(a => grid.appendChild(card(a)));
123+
content.appendChild(grid);
124+
} catch (e) {
125+
content.innerHTML = "";
126+
const box = el("div", "err", "Could not load the catalogue: " + e.message);
127+
content.appendChild(box);
128+
}
129+
}
130+
render();
131+
</script>
132+
</body>
133+
</html>

cmd/daemon/main.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ import (
3434
"github.com/pilot-protocol/skillinject"
3535
"github.com/pilot-protocol/trustedagents"
3636
"github.com/pilot-protocol/webhook"
37+
38+
"github.com/TeoSlayer/pilotprotocol/internal/catalogtrust"
3739
)
3840

3941
var version = "dev"
@@ -303,6 +305,9 @@ func main() {
303305
if err := rt.Register(&appstoreAdapter{svc: appstore.NewService(appstore.Config{
304306
InstallRoot: appstoreInstallRoot,
305307
RescanInterval: 2 * time.Second,
308+
// Real catalogue trust anchor (replaces the all-zeros
309+
// placeholder default): the embedded ed25519 catalogue key.
310+
CatalogPubkey: []byte(catalogtrust.PublicKey()),
306311
})}); err != nil {
307312
log.Fatalf("register appstore: %v", err)
308313
}

cmd/pilotctl/appstore.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,8 @@ func cmdAppStore(args []string) {
6969
cmdAppStoreGenKey(args[1:])
7070
case "sign":
7171
cmdAppStoreSign(args[1:])
72+
case "sign-catalogue", "sign-catalog":
73+
cmdAppStoreSignCatalogue(args[1:])
7274
case "catalogue", "catalog":
7375
cmdAppStoreCatalogue(args[1:])
7476
case "restart":
@@ -81,7 +83,7 @@ func cmdAppStore(args []string) {
8183
appStoreHelp()
8284
default:
8385
fatalHint("invalid_argument",
84-
"available: list, status, view, audit, uninstall, verify, install, gen-key, sign, catalogue, restart, caps, actions, call",
86+
"available: list, status, view, audit, uninstall, verify, install, gen-key, sign, sign-catalogue, catalogue, restart, caps, actions, call",
8587
"unknown appstore subcommand: %s", args[0])
8688
}
8789
}
@@ -116,6 +118,8 @@ Usage:
116118
pilotctl appstore gen-key <key-file> generate a fresh ed25519 publisher keypair; prints the public side
117119
pilotctl appstore sign --key <key-file> <manifest>
118120
sign (or re-sign) a manifest's store.signature so the supervisor accepts it
121+
pilotctl appstore sign-catalogue --key <key-file> <catalogue.json>
122+
sign the catalogue, writing a detached <catalogue>.sig pilotctl verifies on load
119123
pilotctl appstore restart <id> ask the daemon to clear crash-loop suspension and respawn this app
120124
pilotctl appstore caps <id> show the manifest's spend caps and current rolling-window usage
121125
pilotctl appstore actions [--tail N] [--event NAME]

cmd/pilotctl/appstore_catalogue.go

Lines changed: 101 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,11 @@ package main
3333

3434
import (
3535
"archive/tar"
36+
"bytes"
3637
"compress/gzip"
38+
"crypto/ed25519"
3739
"crypto/sha256"
40+
"encoding/base64"
3841
"encoding/hex"
3942
"encoding/json"
4043
"errors"
@@ -46,6 +49,8 @@ import (
4649
"path/filepath"
4750
"strings"
4851
"time"
52+
53+
"github.com/TeoSlayer/pilotprotocol/internal/catalogtrust"
4954
)
5055

5156
// defaultCatalogueURL points at the canonical catalogue.json on main.
@@ -112,14 +117,25 @@ func catalogueURL() string {
112117
// garbage.
113118
func loadCatalogue() (*catalogue, error) {
114119
u := catalogueURL()
115-
body, err := openURL(u)
120+
data, err := fetchAll(u)
116121
if err != nil {
117122
return nil, fmt.Errorf("fetch catalogue from %s: %w", u, err)
118123
}
119-
defer body.Close()
120-
data, err := io.ReadAll(io.LimitReader(body, 1<<20)) // 1 MiB cap
124+
// Fail-closed signature gate: the catalogue must carry a detached
125+
// ed25519 signature (at <url>.sig) that verifies against the embedded
126+
// catalogue public key. A compromised CDN/host can't substitute a
127+
// different app list (pointing installs at hostile bundle URLs)
128+
// without also forging this signature.
129+
sigRaw, err := fetchAll(u + ".sig")
130+
if err != nil {
131+
return nil, fmt.Errorf("fetch catalogue signature %s.sig: %w (the catalogue must be signed; see catalogue/README.md)", u, err)
132+
}
133+
sig, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(sigRaw)))
121134
if err != nil {
122-
return nil, fmt.Errorf("read catalogue body: %w", err)
135+
return nil, fmt.Errorf("decode catalogue signature: %w", err)
136+
}
137+
if err := catalogtrust.Verify(data, sig); err != nil {
138+
return nil, fmt.Errorf("catalogue signature: %w", err)
123139
}
124140
var c catalogue
125141
if err := json.Unmarshal(data, &c); err != nil {
@@ -135,6 +151,87 @@ func loadCatalogue() (*catalogue, error) {
135151
return &c, nil
136152
}
137153

154+
// fetchAll opens raw via openURL and reads the whole body (1 MiB cap).
155+
func fetchAll(raw string) ([]byte, error) {
156+
body, err := openURL(raw)
157+
if err != nil {
158+
return nil, err
159+
}
160+
defer body.Close()
161+
data, err := io.ReadAll(io.LimitReader(body, 1<<20)) // 1 MiB cap
162+
if err != nil {
163+
return nil, fmt.Errorf("read body: %w", err)
164+
}
165+
return data, nil
166+
}
167+
168+
// cmdAppStoreSignCatalogue signs a catalogue.json with the catalogue
169+
// signing key, writing a detached base64 ed25519 signature to
170+
// <catalogue>.sig. The signing key must match the embedded catalogue
171+
// public key (catalogtrust.PublicKey) — otherwise pilotctl would reject
172+
// the signature at load, so we refuse to produce a dead signature.
173+
//
174+
// pilotctl appstore sign-catalogue --key <key-file> <catalogue.json>
175+
func cmdAppStoreSignCatalogue(args []string) {
176+
var keyFile string
177+
rest := args
178+
for len(rest) > 0 && (rest[0] == "--key" || rest[0] == "-k") {
179+
if len(rest) < 2 {
180+
fatalHint("invalid_argument", "--key takes a path", "missing value after %s", rest[0])
181+
}
182+
keyFile = rest[1]
183+
rest = rest[2:]
184+
}
185+
if keyFile == "" || len(rest) == 0 {
186+
fatalHint("invalid_argument",
187+
"usage: pilotctl appstore sign-catalogue --key <key-file> <catalogue.json>",
188+
"missing --key or catalogue path")
189+
}
190+
cataloguePath := rest[0]
191+
192+
keyHex, err := os.ReadFile(keyFile)
193+
if err != nil {
194+
fatalHint("io_error", "the key path doesn't exist or is unreadable", "read key: %v", err)
195+
}
196+
privBytes, err := hex.DecodeString(strings.TrimSpace(string(keyHex)))
197+
if err != nil {
198+
fatalHint("invalid_argument", "the file should be a single hex-encoded ed25519 private key", "decode key: %v", err)
199+
}
200+
if len(privBytes) != ed25519.PrivateKeySize {
201+
fatalHint("invalid_argument", fmt.Sprintf("expected %d bytes; got %d", ed25519.PrivateKeySize, len(privBytes)), "key length mismatch")
202+
}
203+
priv := ed25519.PrivateKey(privBytes)
204+
pub := priv.Public().(ed25519.PublicKey)
205+
206+
// Guard: refuse to sign with a key that doesn't match the embedded
207+
// trust anchor — the resulting .sig would never verify in the wild.
208+
embed := catalogtrust.PublicKey()
209+
if embed == nil {
210+
fatalHint("internal_error", "rebuild pilotctl with a valid embedded catalogue key", "embedded catalogue public key is missing/malformed")
211+
}
212+
if !bytes.Equal(pub, embed) {
213+
fatalHint("invalid_argument",
214+
"this key does not match the embedded catalogue public key; pilotctl would reject the signature. Use the release catalogue key, or rebuild pilotctl with -ldflags overriding catalogtrust.publicKeyB64",
215+
"signing key pubkey %s != embedded %s",
216+
base64.StdEncoding.EncodeToString(pub), base64.StdEncoding.EncodeToString(embed))
217+
}
218+
219+
data, err := os.ReadFile(cataloguePath)
220+
if err != nil {
221+
fatalHint("io_error", "pass the path to a catalogue.json file", "read catalogue: %v", err)
222+
}
223+
sig := ed25519.Sign(priv, data)
224+
if err := catalogtrust.Verify(data, sig); err != nil {
225+
fatalHint("internal_error", "self-verify after signing failed — bug", "%v", err)
226+
}
227+
sigPath := cataloguePath + ".sig"
228+
if err := os.WriteFile(sigPath, []byte(base64.StdEncoding.EncodeToString(sig)+"\n"), 0o644); err != nil {
229+
fatalHint("io_error", "check the catalogue dir is writable", "write signature: %v", err)
230+
}
231+
fmt.Printf("signed %s\n", cataloguePath)
232+
fmt.Printf("signature: %s\n", sigPath)
233+
}
234+
138235
func cmdAppStoreCatalogue(_ []string) {
139236
c, err := loadCatalogue()
140237
if err != nil {

0 commit comments

Comments
 (0)