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
48 changes: 42 additions & 6 deletions catalogue/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,19 +144,55 @@ from a remote — operators relying on a catalogue do so over `https` only).
`bundle_sha256`, the teaser fields, **and** the new `metadata_sha256`
from step 5. Commit everything together — the index pin and the detail
doc must change in the same commit or `view` will reject a stale pin.
The change goes live the moment the commit lands on `main` and the raw
URLs serve the new bytes — no daemon restart, no pilotctl release.

> **Pin discipline:** `metadata_sha256` must be the sha256 of the exact
> committed `metadata.json` bytes. Edit the doc, then recompute — never the
> other way round. A mismatch makes `view` fall back to the teaser and warn.
> **Pin discipline:** `metadata_sha256` must be the sha256 of the exact
> committed `metadata.json` bytes. Edit the doc, then recompute — never
> the other way round. A mismatch makes `view` fall back to the teaser
> and warn.
7. **Re-sign the catalogue** (the signature covers the exact `catalogue.json`
bytes, so it must be regenerated on every edit — including the
`metadata_sha256` pin change from step 6):
```bash
pilotctl appstore sign-catalogue --key /secure/path/catalog-signing.key \
catalogue/catalogue.json
```
This writes `catalogue.json.sig` (detached, base64 ed25519). Commit
`catalogue.json`, `catalogue.json.sig`, **and** the updated
`apps/<id>/metadata.json` together. The change goes live the moment they
land on `main` and the raw URLs serve the new bytes — no daemon restart,
no pilotctl release.

`pilotctl` fetches `catalogue.json` **and** `catalogue.json.sig` and
verifies the signature against the embedded catalogue public key before
trusting any entry. An unsigned, missing-signature, or tampered catalogue
is refused (fail-closed).

## Catalogue signing key

The catalogue is signed with a dedicated ed25519 key, separate from any
app-publisher key. The **private** key is held by the release pipeline and
is never committed. The **public** key is compiled into pilotctl and the
daemon at `internal/catalogtrust` (`publicKeyB64`) and can be rotated at
build time without a code change:

```bash
go build -ldflags \
"-X github.com/TeoSlayer/pilotprotocol/internal/catalogtrust.publicKeyB64=<new-b64-pubkey>" \
./cmd/pilotctl ./cmd/daemon
```

To rotate: generate a new keypair, store the private key securely, update
the embedded public key (source default or `-ldflags`), and re-sign the
catalogue. `sign-catalogue` refuses to sign with a key that doesn't match
the embedded public key, so a mismatch is caught before publishing a dead
signature.

## Trust model

| Layer | Trust anchor | Verifies |
|---|---|---|
| User trusts pilotctl | Project release pipeline (signed pilotctl binary) | The catalogue URL is correct |
| pilotctl trusts the catalogue | Future: signed against `EmbeddedCatalogPubkey`; today: the raw URL itself | App IDs map to specific bundle URLs + SHAs |
| 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 |
| pilotctl trusts the bundle | Embedded `bundle_sha256` matches downloaded bytes | A CDN substitute is rejected |
| pilotctl trusts the detail doc | Index `metadata_sha256` matches fetched `metadata.json` | A substituted listing is rejected (`view` falls back to the teaser) |
| Daemon trusts the manifest | Embedded ed25519 publisher pubkey verifies the signature | The bundle's manifest hasn't been tampered with |
Expand Down
1 change: 1 addition & 0 deletions catalogue/catalogue.json.sig
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
VuWWD6mSCOQR1LLYWxhdUdDu/RHQQXr0UZJBh6fOmy7VyIgcM/W/yeMNo7ym0pYy70KN19xFbDe9SvPjdeFoBw==
133 changes: 133 additions & 0 deletions catalogue/site/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Pilot App Store — Catalogue</title>
<meta name="description" content="Apps installable via pilotctl appstore install — the Pilot Protocol app store catalogue." />
<style>
:root {
--bg: #0b0e14; --panel: #131823; --panel2: #1b2230; --line: #273043;
--fg: #e6e9ef; --muted: #93a0b5; --accent: #5b8cff; --ok: #46d39a; --code: #0f1420;
}
* { box-sizing: border-box; }
body {
margin: 0; background: var(--bg); color: var(--fg);
font: 16px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
header { padding: 56px 24px 28px; max-width: 1040px; margin: 0 auto; }
h1 { font-size: 30px; margin: 0 0 8px; letter-spacing: -0.02em; }
.sub { color: var(--muted); margin: 0; }
.meta { color: var(--muted); font-size: 13px; margin-top: 14px; }
main { max-width: 1040px; margin: 0 auto; padding: 0 24px 64px; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 18px; }
.card {
background: linear-gradient(180deg, var(--panel), var(--panel2));
border: 1px solid var(--line); border-radius: 14px; padding: 20px 20px 18px;
display: flex; flex-direction: column; gap: 12px;
}
.card h2 { font-size: 18px; margin: 0; display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
.ver { color: var(--ok); font-size: 13px; font-weight: 600; background: rgba(70,211,154,.12); padding: 2px 8px; border-radius: 999px; }
.desc { color: var(--fg); margin: 0; }
.label { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .06em; margin: 4px 0 2px; }
.methods { display: flex; flex-wrap: wrap; gap: 6px; }
.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); }
.muted { color: var(--muted); font-size: 13px; }
pre { background: var(--code); border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px; margin: 0; overflow-x: auto; }
code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 13px; color: var(--fg); }
.row { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
.foot { color: var(--muted); font-size: 13px; margin-top: 36px; border-top: 1px solid var(--line); padding-top: 18px; }
.err { background: #2a1620; border: 1px solid #5a2740; color: #ffb4c4; padding: 16px; border-radius: 12px; }
.sha { font-family: ui-monospace, monospace; font-size: 11px; color: var(--muted); word-break: break-all; }
</style>
</head>
<body>
<header>
<h1>Pilot App Store</h1>
<p class="sub">Apps installable with <code>pilotctl appstore install &lt;id&gt;</code> over the Pilot overlay network.</p>
<p class="meta" id="meta"></p>
</header>
<main>
<div id="content"><p class="muted">Loading catalogue…</p></div>
<p class="foot">
The catalogue is fetched from <code id="src"></code> and is signed (detached ed25519);
<code>pilotctl</code> verifies the signature before any install.
</p>
</main>
<script>
// Same-origin catalogue copied next to this page at deploy time. Override
// with ?src= for local testing against another catalogue URL.
const params = new URLSearchParams(location.search);
const SRC = params.get("src") || "./catalogue.json";

function el(tag, cls, text) {
const e = document.createElement(tag);
if (cls) e.className = cls;
if (text != null) e.textContent = text;
return e;
}

function card(app) {
const c = el("div", "card");
const h = el("h2");
h.appendChild(el("span", null, app.id || "(unknown id)"));
if (app.version) h.appendChild(el("span", "ver", "v" + app.version));
c.appendChild(h);

if (app.description) c.appendChild(el("p", "desc", app.description));

// Methods aren't part of the v1 catalogue schema (they live in each
// app's manifest); render them when an entry provides them.
if (Array.isArray(app.methods) && app.methods.length) {
c.appendChild(el("div", "label", "Methods"));
const m = el("div", "methods");
app.methods.forEach(x => m.appendChild(el("span", "pill", x)));
c.appendChild(m);
}

c.appendChild(el("div", "label", "Install"));
const pre = el("pre");
pre.appendChild(el("code", null, "pilotctl appstore install " + (app.id || "")));
c.appendChild(pre);

if (app.bundle_url) {
const row = el("div", "row");
const a = el("a", null, "bundle ↗");
a.href = app.bundle_url; a.rel = "noopener"; a.target = "_blank";
row.appendChild(a);
if (app.bundle_sha256) row.appendChild(el("span", "sha", app.bundle_sha256.slice(0, 16) + "…"));
c.appendChild(row);
}
return c;
}

async function render() {
const content = document.getElementById("content");
document.getElementById("src").textContent = SRC;
try {
const res = await fetch(SRC, { cache: "no-store" });
if (!res.ok) throw new Error("HTTP " + res.status);
const cat = await res.json();
document.getElementById("meta").textContent =
"Schema v" + cat.version + (cat.updated_at ? " · updated " + cat.updated_at : "") +
" · " + (cat.apps ? cat.apps.length : 0) + " app(s)";
content.textContent = "";
if (!cat.apps || !cat.apps.length) {
content.appendChild(el("p", "muted", "The catalogue is empty."));
return;
}
const grid = el("div", "grid");
cat.apps.forEach(a => grid.appendChild(card(a)));
content.appendChild(grid);
} catch (e) {
content.innerHTML = "";
const box = el("div", "err", "Could not load the catalogue: " + e.message);
content.appendChild(box);
}
}
render();
</script>
</body>
</html>
5 changes: 5 additions & 0 deletions cmd/daemon/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ import (
"github.com/pilot-protocol/skillinject"
"github.com/pilot-protocol/trustedagents"
"github.com/pilot-protocol/webhook"

"github.com/TeoSlayer/pilotprotocol/internal/catalogtrust"
)

var version = "dev"
Expand Down Expand Up @@ -303,6 +305,9 @@ func main() {
if err := rt.Register(&appstoreAdapter{svc: appstore.NewService(appstore.Config{
InstallRoot: appstoreInstallRoot,
RescanInterval: 2 * time.Second,
// Real catalogue trust anchor (replaces the all-zeros
// placeholder default): the embedded ed25519 catalogue key.
CatalogPubkey: []byte(catalogtrust.PublicKey()),
})}); err != nil {
log.Fatalf("register appstore: %v", err)
}
Expand Down
6 changes: 5 additions & 1 deletion cmd/pilotctl/appstore.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ func cmdAppStore(args []string) {
cmdAppStoreGenKey(args[1:])
case "sign":
cmdAppStoreSign(args[1:])
case "sign-catalogue", "sign-catalog":
cmdAppStoreSignCatalogue(args[1:])
case "catalogue", "catalog":
cmdAppStoreCatalogue(args[1:])
case "restart":
Expand All @@ -81,7 +83,7 @@ func cmdAppStore(args []string) {
appStoreHelp()
default:
fatalHint("invalid_argument",
"available: list, status, view, audit, uninstall, verify, install, gen-key, sign, catalogue, restart, caps, actions, call",
"available: list, status, view, audit, uninstall, verify, install, gen-key, sign, sign-catalogue, catalogue, restart, caps, actions, call",
"unknown appstore subcommand: %s", args[0])
}
}
Expand Down Expand Up @@ -116,6 +118,8 @@ Usage:
pilotctl appstore gen-key <key-file> generate a fresh ed25519 publisher keypair; prints the public side
pilotctl appstore sign --key <key-file> <manifest>
sign (or re-sign) a manifest's store.signature so the supervisor accepts it
pilotctl appstore sign-catalogue --key <key-file> <catalogue.json>
sign the catalogue, writing a detached <catalogue>.sig pilotctl verifies on load
pilotctl appstore restart <id> ask the daemon to clear crash-loop suspension and respawn this app
pilotctl appstore caps <id> show the manifest's spend caps and current rolling-window usage
pilotctl appstore actions [--tail N] [--event NAME]
Expand Down
105 changes: 101 additions & 4 deletions cmd/pilotctl/appstore_catalogue.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,11 @@ package main

import (
"archive/tar"
"bytes"
"compress/gzip"
"crypto/ed25519"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
Expand All @@ -46,6 +49,8 @@ import (
"path/filepath"
"strings"
"time"

"github.com/TeoSlayer/pilotprotocol/internal/catalogtrust"
)

// defaultCatalogueURL points at the canonical catalogue.json on main.
Expand Down Expand Up @@ -112,14 +117,25 @@ func catalogueURL() string {
// garbage.
func loadCatalogue() (*catalogue, error) {
u := catalogueURL()
body, err := openURL(u)
data, err := fetchAll(u)
if err != nil {
return nil, fmt.Errorf("fetch catalogue from %s: %w", u, err)
}
defer body.Close()
data, err := io.ReadAll(io.LimitReader(body, 1<<20)) // 1 MiB cap
// Fail-closed signature gate: the catalogue must carry a detached
// ed25519 signature (at <url>.sig) that verifies against the embedded
// catalogue public key. A compromised CDN/host can't substitute a
// different app list (pointing installs at hostile bundle URLs)
// without also forging this signature.
sigRaw, err := fetchAll(u + ".sig")
if err != nil {
return nil, fmt.Errorf("fetch catalogue signature %s.sig: %w (the catalogue must be signed; see catalogue/README.md)", u, err)
}
sig, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(sigRaw)))
if err != nil {
return nil, fmt.Errorf("read catalogue body: %w", err)
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 c catalogue
if err := json.Unmarshal(data, &c); err != nil {
Expand All @@ -135,6 +151,87 @@ func loadCatalogue() (*catalogue, error) {
return &c, nil
}

// fetchAll opens raw via openURL and reads the whole body (1 MiB cap).
func fetchAll(raw string) ([]byte, error) {
body, err := openURL(raw)
if err != nil {
return nil, err
}
defer body.Close()
data, err := io.ReadAll(io.LimitReader(body, 1<<20)) // 1 MiB cap
if err != nil {
return nil, fmt.Errorf("read body: %w", err)
}
return data, nil
}

// cmdAppStoreSignCatalogue signs a catalogue.json with the catalogue
// signing key, writing a detached base64 ed25519 signature to
// <catalogue>.sig. The signing key must match the embedded catalogue
// public key (catalogtrust.PublicKey) — otherwise pilotctl would reject
// the signature at load, so we refuse to produce a dead signature.
//
// pilotctl appstore sign-catalogue --key <key-file> <catalogue.json>
func cmdAppStoreSignCatalogue(args []string) {
var keyFile string
rest := args
for len(rest) > 0 && (rest[0] == "--key" || rest[0] == "-k") {
if len(rest) < 2 {
fatalHint("invalid_argument", "--key takes a path", "missing value after %s", rest[0])
}
keyFile = rest[1]
rest = rest[2:]
}
if keyFile == "" || len(rest) == 0 {
fatalHint("invalid_argument",
"usage: pilotctl appstore sign-catalogue --key <key-file> <catalogue.json>",
"missing --key or catalogue path")
}
cataloguePath := rest[0]

keyHex, err := os.ReadFile(keyFile)
if err != nil {
fatalHint("io_error", "the key path doesn't exist or is unreadable", "read key: %v", err)
}
privBytes, err := hex.DecodeString(strings.TrimSpace(string(keyHex)))
if err != nil {
fatalHint("invalid_argument", "the file should be a single hex-encoded ed25519 private key", "decode key: %v", err)
}
if len(privBytes) != ed25519.PrivateKeySize {
fatalHint("invalid_argument", fmt.Sprintf("expected %d bytes; got %d", ed25519.PrivateKeySize, len(privBytes)), "key length mismatch")
}
priv := ed25519.PrivateKey(privBytes)
pub := priv.Public().(ed25519.PublicKey)

// Guard: refuse to sign with a key that doesn't match the embedded
// trust anchor — the resulting .sig would never verify in the wild.
embed := catalogtrust.PublicKey()
if embed == nil {
fatalHint("internal_error", "rebuild pilotctl with a valid embedded catalogue key", "embedded catalogue public key is missing/malformed")
}
if !bytes.Equal(pub, embed) {
fatalHint("invalid_argument",
"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",
"signing key pubkey %s != embedded %s",
base64.StdEncoding.EncodeToString(pub), base64.StdEncoding.EncodeToString(embed))
}

data, err := os.ReadFile(cataloguePath)
if err != nil {
fatalHint("io_error", "pass the path to a catalogue.json file", "read catalogue: %v", err)
}
sig := ed25519.Sign(priv, data)
if err := catalogtrust.Verify(data, sig); err != nil {
fatalHint("internal_error", "self-verify after signing failed — bug", "%v", err)
}
sigPath := cataloguePath + ".sig"
if err := os.WriteFile(sigPath, []byte(base64.StdEncoding.EncodeToString(sig)+"\n"), 0o644); err != nil {
fatalHint("io_error", "check the catalogue dir is writable", "write signature: %v", err)
}
fmt.Printf("signed %s\n", cataloguePath)
fmt.Printf("signature: %s\n", sigPath)
}

func cmdAppStoreCatalogue(_ []string) {
c, err := loadCatalogue()
if err != nil {
Expand Down
Loading
Loading