Skip to content

Commit babe414

Browse files
committed
feat(pilotctl): appstore gen-key + sign for app publishers
The supervisor refuses any manifest whose ed25519 store.signature doesn't verify against store.publisher. Until now, signing a manifest required a one-off Go program — publishers couldn't ship a bundle without first writing their own tool. This adds the two missing publisher-side commands: pilotctl appstore gen-key <key-file> ed25519 keygen. Writes the private key as a single hex line (0600 perms) so it survives copy-paste into shell scripts. Prints the public side as `ed25519:<base64>` ready to paste into a manifest's store.publisher field. Refuses to overwrite an existing key — a publisher key rotation is a deliberate multi-step operation, never an accident. pilotctl appstore sign --key <key-file> <manifest> Recompute the canonical signing payload (kept in lockstep with manifest.signingPayload — see app-store pkg/manifest/manifest.go), sign with the supplied key, rewrite store.publisher to match the key, and write the new store.signature back into the manifest. Self-verifies before writing so a buggy version of this tool can't ship a bundle the supervisor rejects. The publisher-rewrite is the important footgun guard: if the operator swaps a key but forgets to update store.publisher, the manifest would silently produce a signature that doesn't verify. Forcing publisher to match the key means the round-trip always succeeds. The flip-side trade-off: a key file by itself is enough to claim any publisher identity, which is intentional — publisher identity IS the key.
1 parent 83d642e commit babe414

2 files changed

Lines changed: 183 additions & 1 deletion

File tree

cmd/pilotctl/appstore.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,10 @@ func cmdAppStore(args []string) {
6363
cmdAppStoreVerify(args[1:])
6464
case "install":
6565
cmdAppStoreInstall(args[1:])
66+
case "gen-key":
67+
cmdAppStoreGenKey(args[1:])
68+
case "sign":
69+
cmdAppStoreSign(args[1:])
6670
case "restart":
6771
cmdAppStoreRestart(args[1:])
6872
case "caps":
@@ -73,7 +77,7 @@ func cmdAppStore(args []string) {
7377
appStoreHelp()
7478
default:
7579
fatalHint("invalid_argument",
76-
"available: list, status, audit, uninstall, verify, install, restart, caps, actions, call",
80+
"available: list, status, audit, uninstall, verify, install, gen-key, sign, restart, caps, actions, call",
7781
"unknown appstore subcommand: %s", args[0])
7882
}
7983
}
@@ -97,6 +101,9 @@ Usage:
97101
pilotctl appstore verify <bundle-dir> sha256-check a pre-install bundle against its manifest
98102
pilotctl appstore install <bundle-dir> [--force]
99103
stage + atomically place a verified bundle into the install root
104+
pilotctl appstore gen-key <key-file> generate a fresh ed25519 publisher keypair; prints the public side
105+
pilotctl appstore sign --key <key-file> <manifest>
106+
sign (or re-sign) a manifest's store.signature so the supervisor accepts it
100107
pilotctl appstore restart <id> ask the daemon to clear crash-loop suspension and respawn this app
101108
pilotctl appstore caps <id> show the manifest's spend caps and current rolling-window usage
102109
pilotctl appstore actions [--tail N] [--event NAME]

cmd/pilotctl/appstore_sign.go

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
// SPDX-License-Identifier: AGPL-3.0-or-later
2+
//
3+
// pilotctl appstore sign / gen-key — publisher-side manifest signing.
4+
//
5+
// The app-store supervisor refuses to spawn an app whose manifest's
6+
// ed25519 store.signature doesn't verify against store.publisher. This
7+
// file gives an operator the two commands needed to publish a bundle:
8+
//
9+
// 1. gen-key — once per publisher identity, write a fresh
10+
// ed25519 private key to disk. The matching public key is
11+
// printed so it can be pasted into the manifest.
12+
//
13+
// 2. sign --key <key> <manifest> — recompute the canonical signing
14+
// payload (publisher || ":" || id || ":" || manifest_version ||
15+
// ":" || binary.sha256 || ":" || grants-sha256-hex) and write
16+
// a fresh signature into the manifest's store fields. Publisher
17+
// is also rewritten to match the key — preventing the
18+
// footgun where someone swaps the key but leaves a stale
19+
// publisher line and ships a half-broken bundle.
20+
//
21+
// Format note: the signing payload is reconstructed manually here
22+
// because manifest.signingPayload is package-private. The format is
23+
// documented in app-store/pkg/manifest/manifest.go:185 — keep them in
24+
// lockstep.
25+
26+
package main
27+
28+
import (
29+
"crypto/ed25519"
30+
"crypto/rand"
31+
"crypto/sha256"
32+
"encoding/base64"
33+
"encoding/hex"
34+
"encoding/json"
35+
"fmt"
36+
"os"
37+
38+
"github.com/pilot-protocol/app-store/pkg/manifest"
39+
)
40+
41+
func cmdAppStoreGenKey(args []string) {
42+
if len(args) != 1 {
43+
fatalHint("invalid_argument",
44+
"usage: pilotctl appstore gen-key <key-file>",
45+
"missing or extra argument")
46+
}
47+
keyFile := args[0]
48+
if _, err := os.Stat(keyFile); err == nil {
49+
fatalHint("invalid_argument",
50+
"refuse to overwrite an existing key; pass a fresh path or remove the old one first",
51+
"%s already exists", keyFile)
52+
}
53+
54+
pub, priv, err := ed25519.GenerateKey(rand.Reader)
55+
if err != nil {
56+
fatalHint("crypto_error", "the ed25519 keygen failed; retry once", "generate: %v", err)
57+
}
58+
if err := os.WriteFile(keyFile, []byte(hex.EncodeToString(priv)), 0o600); err != nil {
59+
fatalHint("io_error",
60+
"check the parent dir is writable and not full",
61+
"write key: %v", err)
62+
}
63+
fmt.Printf("private key written to %s (mode 0600 — protect this file)\n", keyFile)
64+
fmt.Printf("publisher (paste verbatim into manifest.store.publisher):\n")
65+
fmt.Printf("ed25519:%s\n", base64.StdEncoding.EncodeToString(pub))
66+
}
67+
68+
func cmdAppStoreSign(args []string) {
69+
var keyFile string
70+
rest := args
71+
for len(rest) > 0 {
72+
switch rest[0] {
73+
case "--key", "-k":
74+
if len(rest) < 2 {
75+
fatalHint("invalid_argument",
76+
"--key takes a path",
77+
"missing value after %s", rest[0])
78+
}
79+
keyFile = rest[1]
80+
rest = rest[2:]
81+
default:
82+
if keyFile == "" {
83+
fatalHint("invalid_argument",
84+
"usage: pilotctl appstore sign --key <key-file> <manifest>",
85+
"--key must come before the manifest path")
86+
}
87+
// Anything that isn't a flag is the manifest path.
88+
break
89+
}
90+
if len(rest) > 0 && rest[0] != "--key" && rest[0] != "-k" {
91+
break
92+
}
93+
}
94+
if keyFile == "" || len(rest) == 0 {
95+
fatalHint("invalid_argument",
96+
"usage: pilotctl appstore sign --key <key-file> <manifest>",
97+
"missing --key or manifest path")
98+
}
99+
mfPath := rest[0]
100+
101+
keyHex, err := os.ReadFile(keyFile)
102+
if err != nil {
103+
fatalHint("io_error",
104+
"the key path doesn't exist or is unreadable; did you mean a different file?",
105+
"read key: %v", err)
106+
}
107+
privBytes, err := hex.DecodeString(string(keyHex))
108+
if err != nil {
109+
fatalHint("invalid_argument",
110+
"the file should be a single hex-encoded ed25519 private key — same shape `pilotctl appstore gen-key` produces",
111+
"decode key: %v", err)
112+
}
113+
if len(privBytes) != ed25519.PrivateKeySize {
114+
fatalHint("invalid_argument",
115+
fmt.Sprintf("expected %d bytes; got %d — wrong file?", ed25519.PrivateKeySize, len(privBytes)),
116+
"key length mismatch")
117+
}
118+
priv := ed25519.PrivateKey(privBytes)
119+
pub := priv.Public().(ed25519.PublicKey)
120+
121+
raw, err := os.ReadFile(mfPath)
122+
if err != nil {
123+
fatalHint("io_error",
124+
"pass the path to a manifest.json file",
125+
"read manifest: %v", err)
126+
}
127+
m, err := manifest.Parse(raw)
128+
if err != nil {
129+
fatalHint("invalid_argument",
130+
"the manifest JSON failed to parse; check it loads without errors first",
131+
"parse: %v", err)
132+
}
133+
134+
// Set publisher to match the supplied key BEFORE building the
135+
// signing payload — the publisher is part of the payload, so any
136+
// mismatch would silently produce a signature that doesn't verify.
137+
m.Store.Publisher = "ed25519:" + base64.StdEncoding.EncodeToString(pub)
138+
139+
// Canonical signing payload — must match manifest.signingPayload
140+
// in the app-store package (private). See manifest.go:185.
141+
grantsJSON, err := json.Marshal(m.Grants)
142+
if err != nil {
143+
fatalHint("internal_error",
144+
"the manifest's grants slice failed to marshal — bug",
145+
"%v", err)
146+
}
147+
grantsHash := sha256.Sum256(grantsJSON)
148+
payload := fmt.Sprintf("%s:%s:%d:%s:%x",
149+
m.Store.Publisher, m.ID, m.ManifestVersion, m.Binary.SHA256, grantsHash)
150+
151+
sig := ed25519.Sign(priv, []byte(payload))
152+
m.Store.Signature = base64.StdEncoding.EncodeToString(sig)
153+
154+
// Self-verify before writing — refuse to ship a manifest the
155+
// supervisor will then reject. Belt-and-braces: a future
156+
// change to the payload format would surface here, not at
157+
// install time.
158+
if err := m.VerifySignature(); err != nil {
159+
fatalHint("internal_error",
160+
"self-verify after signing failed — pilotctl and the app-store package may be on different signing-payload formats; rebuild pilotctl",
161+
"%v", err)
162+
}
163+
164+
out, err := json.MarshalIndent(m, "", " ")
165+
if err != nil {
166+
fatalHint("internal_error", "marshal signed manifest", "%v", err)
167+
}
168+
if err := os.WriteFile(mfPath, out, 0o644); err != nil {
169+
fatalHint("io_error",
170+
"check the manifest path is writable",
171+
"write manifest: %v", err)
172+
}
173+
fmt.Printf("signed %s\n", mfPath)
174+
fmt.Printf("publisher: %s\n", m.Store.Publisher)
175+
}

0 commit comments

Comments
 (0)