|
| 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