diff --git a/catalogue/README.md b/catalogue/README.md index 534c7309..4fc72993 100644 --- a/catalogue/README.md +++ b/catalogue/README.md @@ -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//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=" \ + ./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 | diff --git a/catalogue/catalogue.json.sig b/catalogue/catalogue.json.sig new file mode 100644 index 00000000..0ea9dce3 --- /dev/null +++ b/catalogue/catalogue.json.sig @@ -0,0 +1 @@ +VuWWD6mSCOQR1LLYWxhdUdDu/RHQQXr0UZJBh6fOmy7VyIgcM/W/yeMNo7ym0pYy70KN19xFbDe9SvPjdeFoBw== diff --git a/catalogue/site/index.html b/catalogue/site/index.html new file mode 100644 index 00000000..f978f9e6 --- /dev/null +++ b/catalogue/site/index.html @@ -0,0 +1,133 @@ + + + + + +Pilot App Store — Catalogue + + + + +
+

Pilot App Store

+

Apps installable with pilotctl appstore install <id> over the Pilot overlay network.

+

+
+
+

Loading catalogue…

+

+ The catalogue is fetched from and is signed (detached ed25519); + pilotctl verifies the signature before any install. +

+
+ + + diff --git a/cmd/daemon/main.go b/cmd/daemon/main.go index a6ebad11..735bfffb 100644 --- a/cmd/daemon/main.go +++ b/cmd/daemon/main.go @@ -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" @@ -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) } diff --git a/cmd/pilotctl/appstore.go b/cmd/pilotctl/appstore.go index 334d3738..eac13755 100644 --- a/cmd/pilotctl/appstore.go +++ b/cmd/pilotctl/appstore.go @@ -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": @@ -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]) } } @@ -116,6 +118,8 @@ Usage: pilotctl appstore gen-key generate a fresh ed25519 publisher keypair; prints the public side pilotctl appstore sign --key sign (or re-sign) a manifest's store.signature so the supervisor accepts it + pilotctl appstore sign-catalogue --key + sign the catalogue, writing a detached .sig pilotctl verifies on load pilotctl appstore restart ask the daemon to clear crash-loop suspension and respawn this app pilotctl appstore caps show the manifest's spend caps and current rolling-window usage pilotctl appstore actions [--tail N] [--event NAME] diff --git a/cmd/pilotctl/appstore_catalogue.go b/cmd/pilotctl/appstore_catalogue.go index 93cd6c97..74ad9810 100644 --- a/cmd/pilotctl/appstore_catalogue.go +++ b/cmd/pilotctl/appstore_catalogue.go @@ -33,8 +33,11 @@ package main import ( "archive/tar" + "bytes" "compress/gzip" + "crypto/ed25519" "crypto/sha256" + "encoding/base64" "encoding/hex" "encoding/json" "errors" @@ -46,6 +49,8 @@ import ( "path/filepath" "strings" "time" + + "github.com/TeoSlayer/pilotprotocol/internal/catalogtrust" ) // defaultCatalogueURL points at the canonical catalogue.json on main. @@ -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 .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 { @@ -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 +// .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 +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 ", + "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 { diff --git a/cmd/pilotctl/zz_appstore_signed_catalogue_test.go b/cmd/pilotctl/zz_appstore_signed_catalogue_test.go new file mode 100644 index 00000000..2ac383e7 --- /dev/null +++ b/cmd/pilotctl/zz_appstore_signed_catalogue_test.go @@ -0,0 +1,84 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +// repoCataloguePath returns the path to the repo's signed catalogue, +// skipping if it (or its detached signature) isn't present. +func repoCataloguePath(t *testing.T) string { + t.Helper() + wd, err := os.Getwd() // .../web4/cmd/pilotctl + if err != nil { + t.Fatal(err) + } + p := filepath.Join(wd, "..", "..", "catalogue", "catalogue.json") + if _, err := os.Stat(p); err != nil { + t.Skipf("repo catalogue not found at %s: %v", p, err) + } + if _, err := os.Stat(p + ".sig"); err != nil { + t.Skipf("repo catalogue signature not found: %v", err) + } + return p +} + +// TestLoadCatalogue_VerifiesSignedRepoCatalogue confirms the committed +// catalogue verifies against the embedded catalogue key (the .sig in the +// repo must be signed by the embedded key — this guards that invariant). +func TestLoadCatalogue_VerifiesSignedRepoCatalogue(t *testing.T) { + p := repoCataloguePath(t) + t.Setenv("PILOT_APPSTORE_CATALOG_URL", "file://"+p) + c, err := loadCatalogue() + if err != nil { + t.Fatalf("loadCatalogue on signed repo catalogue: %v", err) + } + if len(c.Apps) == 0 { + t.Error("expected at least one app in the signed catalogue") + } +} + +// TestLoadCatalogue_FailsClosedWithoutSignature asserts a catalogue with +// no detached signature is rejected. +func TestLoadCatalogue_FailsClosedWithoutSignature(t *testing.T) { + dir := t.TempDir() + data, err := os.ReadFile(repoCataloguePath(t)) + if err != nil { + t.Fatal(err) + } + dst := filepath.Join(dir, "catalogue.json") + if err := os.WriteFile(dst, data, 0o644); err != nil { + t.Fatal(err) + } + t.Setenv("PILOT_APPSTORE_CATALOG_URL", "file://"+dst) + if _, err := loadCatalogue(); err == nil { + t.Error("loadCatalogue must fail closed when the signature is missing") + } +} + +// TestLoadCatalogue_FailsOnTamper asserts a modified catalogue fails the +// signature check even when a (now-stale) signature is present. +func TestLoadCatalogue_FailsOnTamper(t *testing.T) { + dir := t.TempDir() + src := repoCataloguePath(t) + data, err := os.ReadFile(src) + if err != nil { + t.Fatal(err) + } + sig, err := os.ReadFile(src + ".sig") + if err != nil { + t.Fatal(err) + } + dst := filepath.Join(dir, "catalogue.json") + if err := os.WriteFile(dst, append(data, ' '), 0o644); err != nil { // tamper + t.Fatal(err) + } + if err := os.WriteFile(dst+".sig", sig, 0o644); err != nil { + t.Fatal(err) + } + t.Setenv("PILOT_APPSTORE_CATALOG_URL", "file://"+dst) + if _, err := loadCatalogue(); err == nil { + t.Error("loadCatalogue must reject a tampered catalogue") + } +} diff --git a/cmd/pilotctl/zz_appstore_view_test.go b/cmd/pilotctl/zz_appstore_view_test.go index 16928d71..9e340925 100644 --- a/cmd/pilotctl/zz_appstore_view_test.go +++ b/cmd/pilotctl/zz_appstore_view_test.go @@ -4,22 +4,40 @@ package main import ( "crypto/sha256" + "encoding/base64" "encoding/hex" "encoding/json" "os" "path/filepath" "strings" "testing" + + "github.com/TeoSlayer/pilotprotocol/internal/catalogtrust" ) -// stageCatalogue writes a catalogue.json to a tempdir and points -// PILOT_APPSTORE_CATALOG_URL at it via file://. +// stageCatalogue writes a catalogue.json to a tempdir, signs it with an +// ephemeral catalogue key (whose public half is swapped into the embedded +// trust anchor for the duration of the test), writes the detached +// .sig, and points PILOT_APPSTORE_CATALOG_URL at it via file://. +// +// loadCatalogue verifies fail-closed against the embedded key, so the +// fixture MUST carry a valid signature — staging an unsigned fixture would +// (correctly) be rejected. Signing with a per-test key, rather than +// disabling the gate, keeps these tests exercising the real verification +// path against a valid signature. The original embedded key is restored via +// t.Cleanup so tests stay isolated. func stageCatalogue(t *testing.T, catJSON string) { t.Helper() - p := filepath.Join(t.TempDir(), "catalogue.json") + dir := t.TempDir() + p := filepath.Join(dir, "catalogue.json") if err := os.WriteFile(p, []byte(catJSON), 0o600); err != nil { t.Fatal(err) } + sig, restore := catalogtrust.SignWithEphemeralKey([]byte(catJSON)) + t.Cleanup(restore) + if err := os.WriteFile(p+".sig", []byte(base64.StdEncoding.EncodeToString(sig)+"\n"), 0o600); err != nil { + t.Fatal(err) + } t.Setenv("PILOT_APPSTORE_CATALOG_URL", "file://"+p) } diff --git a/internal/catalogtrust/catalogtrust.go b/internal/catalogtrust/catalogtrust.go new file mode 100644 index 00000000..92317d8e --- /dev/null +++ b/internal/catalogtrust/catalogtrust.go @@ -0,0 +1,60 @@ +// Package catalogtrust holds the app-store catalogue trust anchor: the +// ed25519 public key the signed catalogue is verified against, plus a +// verify helper. The matching private key is held by the release +// pipeline and is never committed (see catalogue/README.md). +// +// This replaces the all-zeros placeholder trust anchor: pilotctl verifies +// the catalogue signature against PublicKey() before trusting any +// install target, and the daemon passes PublicKey() into the app-store +// service so its Start-time trust-anchor check sees a real key. +package catalogtrust + +import ( + "crypto/ed25519" + "encoding/base64" + "errors" + "fmt" +) + +// publicKeyB64 is the base64-encoded ed25519 catalogue-signing public +// key. Overridable at build time to rotate the key without a code change: +// +// -ldflags "-X github.com/TeoSlayer/pilotprotocol/internal/catalogtrust.publicKeyB64=" +// +// The compiled-in default is the current production catalogue key. +var publicKeyB64 = "iHdBWayA/hYjkwUOZopTXY70qOlR90d6ii/hin0ZMdI=" + +// ErrNoKey is returned when the embedded key is missing or malformed. +// Fail-closed: with no trust anchor, nothing verifies. +var ErrNoKey = errors.New("catalogtrust: no valid embedded catalogue public key") + +// ErrBadSignature is returned when a signature does not verify against +// the embedded key. +var ErrBadSignature = errors.New("catalogtrust: catalogue signature verification failed") + +// PublicKey returns the embedded catalogue public key, or nil if the +// embedded value is malformed (e.g. a bad ldflags override). +func PublicKey() ed25519.PublicKey { + raw, err := base64.StdEncoding.DecodeString(publicKeyB64) + if err != nil || len(raw) != ed25519.PublicKeySize { + return nil + } + return ed25519.PublicKey(raw) +} + +// Verify checks that sig is a valid ed25519 signature over data, made +// with the catalogue signing key. Fail-closed on a missing/invalid +// embedded key or a wrong-length signature. +func Verify(data, sig []byte) error { + pk := PublicKey() + if pk == nil { + return ErrNoKey + } + if len(sig) != ed25519.SignatureSize { + return fmt.Errorf("%w: wrong signature length %d (want %d)", ErrBadSignature, len(sig), ed25519.SignatureSize) + } + if !ed25519.Verify(pk, data, sig) { + return ErrBadSignature + } + return nil +} diff --git a/internal/catalogtrust/catalogtrust_test.go b/internal/catalogtrust/catalogtrust_test.go new file mode 100644 index 00000000..8e3832a5 --- /dev/null +++ b/internal/catalogtrust/catalogtrust_test.go @@ -0,0 +1,56 @@ +package catalogtrust + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "errors" + "testing" +) + +// TestVerify covers the happy path plus tamper and short-signature +// rejection, using a freshly-generated key swapped in for the embedded +// one (white-box). +func TestVerify(t *testing.T) { + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + orig := publicKeyB64 + publicKeyB64 = base64.StdEncoding.EncodeToString(pub) + defer func() { publicKeyB64 = orig }() + + data := []byte(`{"version":1,"apps":[]}`) + sig := ed25519.Sign(priv, data) + + if err := Verify(data, sig); err != nil { + t.Fatalf("valid signature rejected: %v", err) + } + if err := Verify([]byte(`{"version":2,"apps":[]}`), sig); !errors.Is(err, ErrBadSignature) { + t.Errorf("tampered data err = %v, want ErrBadSignature", err) + } + if err := Verify(data, sig[:10]); !errors.Is(err, ErrBadSignature) { + t.Errorf("short signature err = %v, want ErrBadSignature", err) + } +} + +// TestVerify_NoKey asserts a malformed embedded key fails closed. +func TestVerify_NoKey(t *testing.T) { + orig := publicKeyB64 + publicKeyB64 = "not-valid-base64!!!" + defer func() { publicKeyB64 = orig }() + if PublicKey() != nil { + t.Fatal("malformed key should yield nil PublicKey") + } + if err := Verify([]byte("x"), make([]byte, ed25519.SignatureSize)); !errors.Is(err, ErrNoKey) { + t.Errorf("err = %v, want ErrNoKey", err) + } +} + +// TestEmbeddedKeyIsValid guards against shipping a build whose compiled-in +// catalogue key is malformed (which would fail-close every install). +func TestEmbeddedKeyIsValid(t *testing.T) { + if PublicKey() == nil { + t.Fatal("embedded catalogue public key is malformed") + } +} diff --git a/internal/catalogtrust/testsupport.go b/internal/catalogtrust/testsupport.go new file mode 100644 index 00000000..de710932 --- /dev/null +++ b/internal/catalogtrust/testsupport.go @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package catalogtrust + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/base64" +) + +// SignWithEphemeralKey is a TEST-ONLY helper for packages outside +// catalogtrust (e.g. cmd/pilotctl) that need to stage a catalogue fixture +// the fail-closed loader will accept. It generates a throwaway ed25519 +// keypair, swaps the package's embedded public key (publicKeyB64) to the +// generated public half, signs data with the private half, and returns the +// detached signature plus a restore func that puts the original embedded +// key back. +// +// This exists so fixture-based tests exercise REAL fail-closed verification +// against a VALID signature, rather than disabling or weakening the gate. +// It is not referenced by any production code path. +// +// Usage: +// +// sig, restore := catalogtrust.SignWithEphemeralKey(catalogueBytes) +// defer restore() +// // write catalogueBytes + base64(sig) and point the loader at them +func SignWithEphemeralKey(data []byte) (sig []byte, restore func()) { + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + panic("catalogtrust: ephemeral key generation failed: " + err.Error()) + } + orig := publicKeyB64 + publicKeyB64 = base64.StdEncoding.EncodeToString(pub) + return ed25519.Sign(priv, data), func() { publicKeyB64 = orig } +}