Skip to content

Commit ff8cf81

Browse files
committed
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).
1 parent 982f006 commit ff8cf81

8 files changed

Lines changed: 349 additions & 9 deletions

File tree

catalogue/README.md

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,16 +59,49 @@ from a remote — operators relying on a catalogue do so over `https` only).
5959
equivalent — GitHub releases, Cloudflare R2, anywhere reachable over
6060
HTTPS).
6161
5. Update this `catalogue.json` with the new `version`, `bundle_url`, and
62-
`bundle_sha256`. Commit. The change goes live the moment the commit
63-
lands on `main` and the raw URL serves the new bytes — no daemon
64-
restart, no pilotctl release.
62+
`bundle_sha256`.
63+
6. **Re-sign the catalogue** (the signature covers the exact bytes, so it
64+
must be regenerated on every edit):
65+
```bash
66+
pilotctl appstore sign-catalogue --key /secure/path/catalog-signing.key \
67+
catalogue/catalogue.json
68+
```
69+
This writes `catalogue.json.sig` (detached, base64 ed25519). Commit
70+
**both** `catalogue.json` and `catalogue.json.sig` together. The change
71+
goes live the moment they land on `main` and the raw URLs serve the new
72+
bytes — no daemon restart, no pilotctl release.
73+
74+
`pilotctl` fetches `catalogue.json` **and** `catalogue.json.sig` and
75+
verifies the signature against the embedded catalogue public key before
76+
trusting any entry. An unsigned, missing-signature, or tampered catalogue
77+
is refused (fail-closed).
78+
79+
## Catalogue signing key
80+
81+
The catalogue is signed with a dedicated ed25519 key, separate from any
82+
app-publisher key. The **private** key is held by the release pipeline and
83+
is never committed. The **public** key is compiled into pilotctl and the
84+
daemon at `internal/catalogtrust` (`publicKeyB64`) and can be rotated at
85+
build time without a code change:
86+
87+
```bash
88+
go build -ldflags \
89+
"-X github.com/TeoSlayer/pilotprotocol/internal/catalogtrust.publicKeyB64=<new-b64-pubkey>" \
90+
./cmd/pilotctl ./cmd/daemon
91+
```
92+
93+
To rotate: generate a new keypair, store the private key securely, update
94+
the embedded public key (source default or `-ldflags`), and re-sign the
95+
catalogue. `sign-catalogue` refuses to sign with a key that doesn't match
96+
the embedded public key, so a mismatch is caught before publishing a dead
97+
signature.
6598

6699
## Trust model
67100

68101
| Layer | Trust anchor | Verifies |
69102
|---|---|---|
70103
| User trusts pilotctl | Project release pipeline (signed pilotctl binary) | The catalogue URL is correct |
71-
| pilotctl trusts the catalogue | Future: signed against `EmbeddedCatalogPubkey`; today: the raw URL itself | App IDs map to specific bundle URLs + SHAs |
104+
| 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 |
72105
| pilotctl trusts the bundle | Embedded `bundle_sha256` matches downloaded bytes | A CDN substitute is rejected |
73106
| Daemon trusts the manifest | Embedded ed25519 publisher pubkey verifies the signature | The bundle's manifest hasn't been tampered with |
74107

catalogue/catalogue.json.sig

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
CD3TrFKmG4MxgX0eK85z6jPgqpH3gb6+8px87iVALCYF7x7//FqByN3CCkBtHIgxgDUE5afGKJptWNpddw6SCw==

cmd/daemon/main.go

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

3840
var version = "dev"
@@ -279,6 +281,9 @@ func main() {
279281
if err := rt.Register(&appstoreAdapter{svc: appstore.NewService(appstore.Config{
280282
InstallRoot: appstoreInstallRoot,
281283
RescanInterval: 2 * time.Second,
284+
// Real catalogue trust anchor (replaces the all-zeros
285+
// placeholder default): the embedded ed25519 catalogue key.
286+
CatalogPubkey: []byte(catalogtrust.PublicKey()),
282287
})}); err != nil {
283288
log.Fatalf("register appstore: %v", err)
284289
}

cmd/pilotctl/appstore.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ func cmdAppStore(args []string) {
6767
cmdAppStoreGenKey(args[1:])
6868
case "sign":
6969
cmdAppStoreSign(args[1:])
70+
case "sign-catalogue", "sign-catalog":
71+
cmdAppStoreSignCatalogue(args[1:])
7072
case "catalogue", "catalog":
7173
cmdAppStoreCatalogue(args[1:])
7274
case "restart":
@@ -79,7 +81,7 @@ func cmdAppStore(args []string) {
7981
appStoreHelp()
8082
default:
8183
fatalHint("invalid_argument",
82-
"available: list, status, audit, uninstall, verify, install, gen-key, sign, catalogue, restart, caps, actions, call",
84+
"available: list, status, audit, uninstall, verify, install, gen-key, sign, sign-catalogue, catalogue, restart, caps, actions, call",
8385
"unknown appstore subcommand: %s", args[0])
8486
}
8587
}
@@ -110,6 +112,8 @@ Usage:
110112
pilotctl appstore gen-key <key-file> generate a fresh ed25519 publisher keypair; prints the public side
111113
pilotctl appstore sign --key <key-file> <manifest>
112114
sign (or re-sign) a manifest's store.signature so the supervisor accepts it
115+
pilotctl appstore sign-catalogue --key <key-file> <catalogue.json>
116+
sign the catalogue, writing a detached <catalogue>.sig pilotctl verifies on load
113117
pilotctl appstore restart <id> ask the daemon to clear crash-loop suspension and respawn this app
114118
pilotctl appstore caps <id> show the manifest's spend caps and current rolling-window usage
115119
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.
@@ -90,14 +95,25 @@ func catalogueURL() string {
9095
// garbage.
9196
func loadCatalogue() (*catalogue, error) {
9297
u := catalogueURL()
93-
body, err := openURL(u)
98+
data, err := fetchAll(u)
9499
if err != nil {
95100
return nil, fmt.Errorf("fetch catalogue from %s: %w", u, err)
96101
}
97-
defer body.Close()
98-
data, err := io.ReadAll(io.LimitReader(body, 1<<20)) // 1 MiB cap
102+
// Fail-closed signature gate: the catalogue must carry a detached
103+
// ed25519 signature (at <url>.sig) that verifies against the embedded
104+
// catalogue public key. A compromised CDN/host can't substitute a
105+
// different app list (pointing installs at hostile bundle URLs)
106+
// without also forging this signature.
107+
sigRaw, err := fetchAll(u + ".sig")
108+
if err != nil {
109+
return nil, fmt.Errorf("fetch catalogue signature %s.sig: %w (the catalogue must be signed; see catalogue/README.md)", u, err)
110+
}
111+
sig, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(sigRaw)))
99112
if err != nil {
100-
return nil, fmt.Errorf("read catalogue body: %w", err)
113+
return nil, fmt.Errorf("decode catalogue signature: %w", err)
114+
}
115+
if err := catalogtrust.Verify(data, sig); err != nil {
116+
return nil, fmt.Errorf("catalogue signature: %w", err)
101117
}
102118
var c catalogue
103119
if err := json.Unmarshal(data, &c); err != nil {
@@ -109,6 +125,87 @@ func loadCatalogue() (*catalogue, error) {
109125
return &c, nil
110126
}
111127

128+
// fetchAll opens raw via openURL and reads the whole body (1 MiB cap).
129+
func fetchAll(raw string) ([]byte, error) {
130+
body, err := openURL(raw)
131+
if err != nil {
132+
return nil, err
133+
}
134+
defer body.Close()
135+
data, err := io.ReadAll(io.LimitReader(body, 1<<20)) // 1 MiB cap
136+
if err != nil {
137+
return nil, fmt.Errorf("read body: %w", err)
138+
}
139+
return data, nil
140+
}
141+
142+
// cmdAppStoreSignCatalogue signs a catalogue.json with the catalogue
143+
// signing key, writing a detached base64 ed25519 signature to
144+
// <catalogue>.sig. The signing key must match the embedded catalogue
145+
// public key (catalogtrust.PublicKey) — otherwise pilotctl would reject
146+
// the signature at load, so we refuse to produce a dead signature.
147+
//
148+
// pilotctl appstore sign-catalogue --key <key-file> <catalogue.json>
149+
func cmdAppStoreSignCatalogue(args []string) {
150+
var keyFile string
151+
rest := args
152+
for len(rest) > 0 && (rest[0] == "--key" || rest[0] == "-k") {
153+
if len(rest) < 2 {
154+
fatalHint("invalid_argument", "--key takes a path", "missing value after %s", rest[0])
155+
}
156+
keyFile = rest[1]
157+
rest = rest[2:]
158+
}
159+
if keyFile == "" || len(rest) == 0 {
160+
fatalHint("invalid_argument",
161+
"usage: pilotctl appstore sign-catalogue --key <key-file> <catalogue.json>",
162+
"missing --key or catalogue path")
163+
}
164+
cataloguePath := rest[0]
165+
166+
keyHex, err := os.ReadFile(keyFile)
167+
if err != nil {
168+
fatalHint("io_error", "the key path doesn't exist or is unreadable", "read key: %v", err)
169+
}
170+
privBytes, err := hex.DecodeString(strings.TrimSpace(string(keyHex)))
171+
if err != nil {
172+
fatalHint("invalid_argument", "the file should be a single hex-encoded ed25519 private key", "decode key: %v", err)
173+
}
174+
if len(privBytes) != ed25519.PrivateKeySize {
175+
fatalHint("invalid_argument", fmt.Sprintf("expected %d bytes; got %d", ed25519.PrivateKeySize, len(privBytes)), "key length mismatch")
176+
}
177+
priv := ed25519.PrivateKey(privBytes)
178+
pub := priv.Public().(ed25519.PublicKey)
179+
180+
// Guard: refuse to sign with a key that doesn't match the embedded
181+
// trust anchor — the resulting .sig would never verify in the wild.
182+
embed := catalogtrust.PublicKey()
183+
if embed == nil {
184+
fatalHint("internal_error", "rebuild pilotctl with a valid embedded catalogue key", "embedded catalogue public key is missing/malformed")
185+
}
186+
if !bytes.Equal(pub, embed) {
187+
fatalHint("invalid_argument",
188+
"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",
189+
"signing key pubkey %s != embedded %s",
190+
base64.StdEncoding.EncodeToString(pub), base64.StdEncoding.EncodeToString(embed))
191+
}
192+
193+
data, err := os.ReadFile(cataloguePath)
194+
if err != nil {
195+
fatalHint("io_error", "pass the path to a catalogue.json file", "read catalogue: %v", err)
196+
}
197+
sig := ed25519.Sign(priv, data)
198+
if err := catalogtrust.Verify(data, sig); err != nil {
199+
fatalHint("internal_error", "self-verify after signing failed — bug", "%v", err)
200+
}
201+
sigPath := cataloguePath + ".sig"
202+
if err := os.WriteFile(sigPath, []byte(base64.StdEncoding.EncodeToString(sig)+"\n"), 0o644); err != nil {
203+
fatalHint("io_error", "check the catalogue dir is writable", "write signature: %v", err)
204+
}
205+
fmt.Printf("signed %s\n", cataloguePath)
206+
fmt.Printf("signature: %s\n", sigPath)
207+
}
208+
112209
func cmdAppStoreCatalogue(_ []string) {
113210
c, err := loadCatalogue()
114211
if err != nil {
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package main
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
)
8+
9+
// repoCataloguePath returns the path to the repo's signed catalogue,
10+
// skipping if it (or its detached signature) isn't present.
11+
func repoCataloguePath(t *testing.T) string {
12+
t.Helper()
13+
wd, err := os.Getwd() // .../web4/cmd/pilotctl
14+
if err != nil {
15+
t.Fatal(err)
16+
}
17+
p := filepath.Join(wd, "..", "..", "catalogue", "catalogue.json")
18+
if _, err := os.Stat(p); err != nil {
19+
t.Skipf("repo catalogue not found at %s: %v", p, err)
20+
}
21+
if _, err := os.Stat(p + ".sig"); err != nil {
22+
t.Skipf("repo catalogue signature not found: %v", err)
23+
}
24+
return p
25+
}
26+
27+
// TestLoadCatalogue_VerifiesSignedRepoCatalogue confirms the committed
28+
// catalogue verifies against the embedded catalogue key (the .sig in the
29+
// repo must be signed by the embedded key — this guards that invariant).
30+
func TestLoadCatalogue_VerifiesSignedRepoCatalogue(t *testing.T) {
31+
p := repoCataloguePath(t)
32+
t.Setenv("PILOT_APPSTORE_CATALOG_URL", "file://"+p)
33+
c, err := loadCatalogue()
34+
if err != nil {
35+
t.Fatalf("loadCatalogue on signed repo catalogue: %v", err)
36+
}
37+
if len(c.Apps) == 0 {
38+
t.Error("expected at least one app in the signed catalogue")
39+
}
40+
}
41+
42+
// TestLoadCatalogue_FailsClosedWithoutSignature asserts a catalogue with
43+
// no detached signature is rejected.
44+
func TestLoadCatalogue_FailsClosedWithoutSignature(t *testing.T) {
45+
dir := t.TempDir()
46+
data, err := os.ReadFile(repoCataloguePath(t))
47+
if err != nil {
48+
t.Fatal(err)
49+
}
50+
dst := filepath.Join(dir, "catalogue.json")
51+
if err := os.WriteFile(dst, data, 0o644); err != nil {
52+
t.Fatal(err)
53+
}
54+
t.Setenv("PILOT_APPSTORE_CATALOG_URL", "file://"+dst)
55+
if _, err := loadCatalogue(); err == nil {
56+
t.Error("loadCatalogue must fail closed when the signature is missing")
57+
}
58+
}
59+
60+
// TestLoadCatalogue_FailsOnTamper asserts a modified catalogue fails the
61+
// signature check even when a (now-stale) signature is present.
62+
func TestLoadCatalogue_FailsOnTamper(t *testing.T) {
63+
dir := t.TempDir()
64+
src := repoCataloguePath(t)
65+
data, err := os.ReadFile(src)
66+
if err != nil {
67+
t.Fatal(err)
68+
}
69+
sig, err := os.ReadFile(src + ".sig")
70+
if err != nil {
71+
t.Fatal(err)
72+
}
73+
dst := filepath.Join(dir, "catalogue.json")
74+
if err := os.WriteFile(dst, append(data, ' '), 0o644); err != nil { // tamper
75+
t.Fatal(err)
76+
}
77+
if err := os.WriteFile(dst+".sig", sig, 0o644); err != nil {
78+
t.Fatal(err)
79+
}
80+
t.Setenv("PILOT_APPSTORE_CATALOG_URL", "file://"+dst)
81+
if _, err := loadCatalogue(); err == nil {
82+
t.Error("loadCatalogue must reject a tampered catalogue")
83+
}
84+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
// Package catalogtrust holds the app-store catalogue trust anchor: the
2+
// ed25519 public key the signed catalogue is verified against, plus a
3+
// verify helper. The matching private key is held by the release
4+
// pipeline and is never committed (see catalogue/README.md).
5+
//
6+
// This replaces the all-zeros placeholder trust anchor: pilotctl verifies
7+
// the catalogue signature against PublicKey() before trusting any
8+
// install target, and the daemon passes PublicKey() into the app-store
9+
// service so its Start-time trust-anchor check sees a real key.
10+
package catalogtrust
11+
12+
import (
13+
"crypto/ed25519"
14+
"encoding/base64"
15+
"errors"
16+
"fmt"
17+
)
18+
19+
// publicKeyB64 is the base64-encoded ed25519 catalogue-signing public
20+
// key. Overridable at build time to rotate the key without a code change:
21+
//
22+
// -ldflags "-X github.com/TeoSlayer/pilotprotocol/internal/catalogtrust.publicKeyB64=<b64>"
23+
//
24+
// The compiled-in default is the current production catalogue key.
25+
var publicKeyB64 = "5aCD92R0UoZ2lGW6PYZeRrDw63ZNBC5oJZxFB8RNOPQ="
26+
27+
// ErrNoKey is returned when the embedded key is missing or malformed.
28+
// Fail-closed: with no trust anchor, nothing verifies.
29+
var ErrNoKey = errors.New("catalogtrust: no valid embedded catalogue public key")
30+
31+
// ErrBadSignature is returned when a signature does not verify against
32+
// the embedded key.
33+
var ErrBadSignature = errors.New("catalogtrust: catalogue signature verification failed")
34+
35+
// PublicKey returns the embedded catalogue public key, or nil if the
36+
// embedded value is malformed (e.g. a bad ldflags override).
37+
func PublicKey() ed25519.PublicKey {
38+
raw, err := base64.StdEncoding.DecodeString(publicKeyB64)
39+
if err != nil || len(raw) != ed25519.PublicKeySize {
40+
return nil
41+
}
42+
return ed25519.PublicKey(raw)
43+
}
44+
45+
// Verify checks that sig is a valid ed25519 signature over data, made
46+
// with the catalogue signing key. Fail-closed on a missing/invalid
47+
// embedded key or a wrong-length signature.
48+
func Verify(data, sig []byte) error {
49+
pk := PublicKey()
50+
if pk == nil {
51+
return ErrNoKey
52+
}
53+
if len(sig) != ed25519.SignatureSize {
54+
return fmt.Errorf("%w: wrong signature length %d (want %d)", ErrBadSignature, len(sig), ed25519.SignatureSize)
55+
}
56+
if !ed25519.Verify(pk, data, sig) {
57+
return ErrBadSignature
58+
}
59+
return nil
60+
}

0 commit comments

Comments
 (0)