@scalebun/cli 1.13.0 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/commands/doctor.d.ts.map +1 -1
- package/lib/commands/doctor.js +48 -9
- package/lib/commands/doctor.js.map +1 -1
- package/lib/commands/ota-bundle.d.ts.map +1 -1
- package/lib/commands/ota-bundle.js +8 -14
- package/lib/commands/ota-bundle.js.map +1 -1
- package/lib/commands/ota-keys.d.ts +29 -30
- package/lib/commands/ota-keys.d.ts.map +1 -1
- package/lib/commands/ota-keys.js +372 -267
- package/lib/commands/ota-keys.js.map +1 -1
- package/lib/commands/ota-publish.d.ts.map +1 -1
- package/lib/commands/ota-publish.js +67 -10
- package/lib/commands/ota-publish.js.map +1 -1
- package/lib/commands/quickstart.d.ts.map +1 -1
- package/lib/commands/quickstart.js +20 -17
- package/lib/commands/quickstart.js.map +1 -1
- package/lib/utils/signing.d.ts +70 -0
- package/lib/utils/signing.d.ts.map +1 -0
- package/lib/utils/signing.js +171 -0
- package/lib/utils/signing.js.map +1 -0
- package/lib/vendor/ota-protocol/base64.d.ts +11 -0
- package/lib/vendor/ota-protocol/base64.d.ts.map +1 -0
- package/lib/vendor/ota-protocol/base64.js +39 -0
- package/lib/vendor/ota-protocol/base64.js.map +1 -0
- package/lib/vendor/ota-protocol/codec.d.ts +26 -0
- package/lib/vendor/ota-protocol/codec.d.ts.map +1 -0
- package/lib/vendor/ota-protocol/codec.js +349 -0
- package/lib/vendor/ota-protocol/codec.js.map +1 -0
- package/lib/vendor/ota-protocol/hex.d.ts +8 -0
- package/lib/vendor/ota-protocol/hex.d.ts.map +1 -0
- package/lib/vendor/ota-protocol/hex.js +31 -0
- package/lib/vendor/ota-protocol/hex.js.map +1 -0
- package/lib/vendor/ota-protocol/index.d.ts +17 -0
- package/lib/vendor/ota-protocol/index.d.ts.map +1 -0
- package/lib/vendor/ota-protocol/index.js +41 -0
- package/lib/vendor/ota-protocol/index.js.map +1 -0
- package/lib/vendor/ota-protocol/limits.d.ts +39 -0
- package/lib/vendor/ota-protocol/limits.d.ts.map +1 -0
- package/lib/vendor/ota-protocol/limits.js +42 -0
- package/lib/vendor/ota-protocol/limits.js.map +1 -0
- package/lib/vendor/ota-protocol/types.d.ts +73 -0
- package/lib/vendor/ota-protocol/types.d.ts.map +1 -0
- package/lib/vendor/ota-protocol/types.js +24 -0
- package/lib/vendor/ota-protocol/types.js.map +1 -0
- package/package.json +1 -1
package/lib/commands/ota-keys.js
CHANGED
|
@@ -36,11 +36,13 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
36
36
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
37
|
};
|
|
38
38
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
+
exports.spkiFingerprint = exports.KEY_ID_FILE = exports.PUBLIC_KEY_FILE = exports.PRIVATE_KEY_FILE = exports.KEY_DIR = void 0;
|
|
40
|
+
exports.privateKeyPath = privateKeyPath;
|
|
41
|
+
exports.readLocalKeyId = readLocalKeyId;
|
|
42
|
+
exports.readLocalPrivatePem = readLocalPrivatePem;
|
|
43
|
+
exports.localPublicPem = localPublicPem;
|
|
39
44
|
exports.ensureKeyDirGitignored = ensureKeyDirGitignored;
|
|
40
|
-
exports.rawPublicKeyHex = rawPublicKeyHex;
|
|
41
|
-
exports.localPublicKeyHex = localPublicKeyHex;
|
|
42
45
|
exports.registerOtaKeysCommand = registerOtaKeysCommand;
|
|
43
|
-
exports.signBundleHash = signBundleHash;
|
|
44
46
|
const crypto = __importStar(require("crypto"));
|
|
45
47
|
const fs = __importStar(require("fs"));
|
|
46
48
|
const path = __importStar(require("path"));
|
|
@@ -51,44 +53,70 @@ const context_1 = require("../utils/context");
|
|
|
51
53
|
const prompt_1 = require("../utils/prompt");
|
|
52
54
|
const output_1 = require("../utils/output");
|
|
53
55
|
const client_1 = require("../api/client");
|
|
56
|
+
const signing_1 = require("../utils/signing");
|
|
57
|
+
Object.defineProperty(exports, "spkiFingerprint", { enumerable: true, get: function () { return signing_1.spkiFingerprint; } });
|
|
54
58
|
/**
|
|
55
|
-
*
|
|
59
|
+
* OTA signing keys for SB-OTA-RSA-SHA256-V1 (RSA-3072 / SHA-256).
|
|
56
60
|
*
|
|
57
|
-
* The security model,
|
|
58
|
-
*
|
|
59
|
-
*
|
|
61
|
+
* The security model, and the reason this command exists: the private key is
|
|
62
|
+
* generated on the developer's machine and NEVER leaves it. Only the SPKI PUBLIC
|
|
63
|
+
* key is uploaded/registered. A server that cannot sign cannot forge a release,
|
|
60
64
|
* so a compromise of ScaleBun's infrastructure still cannot push code to a
|
|
61
65
|
* customer's users.
|
|
62
66
|
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
67
|
+
* (Pre-1.14 ScaleBun signed with ed25519 over the bundle's SHA-256. That path is
|
|
68
|
+
* removed: the protocol is now native-verifiable RSA on every supported Android
|
|
69
|
+
* API level. Legacy ed25519 key ROWS remain readable in the dashboard, labelled
|
|
70
|
+
* "Ed25519 — Legacy"; new keys are RSA only.)
|
|
66
71
|
*/
|
|
67
|
-
|
|
68
|
-
|
|
72
|
+
exports.KEY_DIR = '.scalebun';
|
|
73
|
+
exports.PRIVATE_KEY_FILE = 'ota-signing-key.pem';
|
|
74
|
+
exports.PUBLIC_KEY_FILE = 'ota-signing-key.pub.pem';
|
|
75
|
+
exports.KEY_ID_FILE = 'ota-key-id.txt';
|
|
76
|
+
/** Absolute path to the private key for a key dir (default ./.scalebun). */
|
|
77
|
+
function privateKeyPath(keyDir = exports.KEY_DIR) {
|
|
78
|
+
return path.join(path.resolve(keyDir), exports.PRIVATE_KEY_FILE);
|
|
79
|
+
}
|
|
80
|
+
/** The locally stored keyId, if any (written next to the private key at generate). */
|
|
81
|
+
function readLocalKeyId(keyDir = exports.KEY_DIR) {
|
|
82
|
+
try {
|
|
83
|
+
const p = path.join(path.resolve(keyDir), exports.KEY_ID_FILE);
|
|
84
|
+
if (!fs.existsSync(p))
|
|
85
|
+
return null;
|
|
86
|
+
const v = fs.readFileSync(p, 'utf8').trim();
|
|
87
|
+
return v || null;
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/** The local private key PEM, if present. */
|
|
94
|
+
function readLocalPrivatePem(keyDir = exports.KEY_DIR) {
|
|
95
|
+
const p = privateKeyPath(keyDir);
|
|
96
|
+
return fs.existsSync(p) ? fs.readFileSync(p, 'utf8') : null;
|
|
97
|
+
}
|
|
98
|
+
/** SPKI public-key PEM derived from the local private key, if present. */
|
|
99
|
+
function localPublicPem(keyDir = exports.KEY_DIR) {
|
|
100
|
+
const priv = readLocalPrivatePem(keyDir);
|
|
101
|
+
if (!priv)
|
|
102
|
+
return null;
|
|
103
|
+
try {
|
|
104
|
+
return crypto.createPublicKey(priv).export({ type: 'spki', format: 'pem' });
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
69
110
|
/**
|
|
70
111
|
* Keep the private key out of version control WITHOUT relying on the operator
|
|
71
|
-
* reading a warning.
|
|
72
|
-
*
|
|
73
|
-
* committed their signing key, and a signing key in git history is compromised
|
|
74
|
-
* even after the file is deleted.
|
|
75
|
-
*
|
|
76
|
-
* Rules, in order of caution:
|
|
77
|
-
* - Only the key's directory relative to cwd can be ignored by cwd's
|
|
78
|
-
* .gitignore; if --out points outside the project, we cannot help — report
|
|
79
|
-
* the manual step instead of writing a useless entry.
|
|
80
|
-
* - An existing .gitignore is appended to, never rewritten.
|
|
81
|
-
* - A missing .gitignore is created only when cwd is actually a git repo
|
|
82
|
-
* (a `.git` entry exists) — creating one in a non-repo would be litter.
|
|
83
|
-
*
|
|
84
|
-
* @returns a human-readable sentence describing what happened, for the summary.
|
|
112
|
+
* reading a warning. An existing .gitignore is appended to; a missing one is
|
|
113
|
+
* created only inside an actual git repo. Returns a human-readable outcome.
|
|
85
114
|
*/
|
|
86
115
|
function ensureKeyDirGitignored(outDir, cwd = process.cwd()) {
|
|
87
116
|
const rel = path.relative(cwd, outDir);
|
|
88
117
|
if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
89
118
|
return `outside this project — add ${outDir} to that location's .gitignore yourself`;
|
|
90
119
|
}
|
|
91
|
-
// Normalise to forward slashes: gitignore patterns use `/` on every platform.
|
|
92
120
|
const entry = rel.split(path.sep).join('/') + '/';
|
|
93
121
|
const gitignorePath = path.join(cwd, '.gitignore');
|
|
94
122
|
try {
|
|
@@ -115,301 +143,378 @@ function ensureKeyDirGitignored(outDir, cwd = process.cwd()) {
|
|
|
115
143
|
return `could not update .gitignore (${err?.message ?? err}) — add ${entry} yourself`;
|
|
116
144
|
}
|
|
117
145
|
}
|
|
118
|
-
/**
|
|
119
|
-
function
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
return spki.subarray(spki.length - 32).toString('hex');
|
|
146
|
+
/** A friendly default keyId like `production-2026-01`. */
|
|
147
|
+
function defaultKeyId() {
|
|
148
|
+
const d = new Date();
|
|
149
|
+
return `key-${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
|
|
123
150
|
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
if (!fs.existsSync(privatePath))
|
|
134
|
-
return null;
|
|
135
|
-
const priv = crypto.createPrivateKey(fs.readFileSync(privatePath));
|
|
136
|
-
return rawPublicKeyHex(crypto.createPublicKey(priv));
|
|
151
|
+
async function registerPublicKey(client, appId, body) {
|
|
152
|
+
await client.post(`/saas/apps/${appId}/ota/signing-keys`, body);
|
|
153
|
+
}
|
|
154
|
+
// ─── generate ─────────────────────────────────────────────────────────────────
|
|
155
|
+
async function runGenerate(opts) {
|
|
156
|
+
const algorithm = (opts.algorithm ?? 'rsa-3072').toLowerCase();
|
|
157
|
+
const m = /^rsa-(\d+)$/.exec(algorithm);
|
|
158
|
+
if (!m) {
|
|
159
|
+
(0, output_1.fail)(`Unsupported --algorithm "${opts.algorithm}".`, ['Use rsa-3072 (recommended) or rsa-2048.'], output_1.ExitCode.Usage);
|
|
137
160
|
}
|
|
138
|
-
|
|
139
|
-
|
|
161
|
+
const bits = parseInt(m[1], 10);
|
|
162
|
+
const outDir = path.resolve(opts.out ?? exports.KEY_DIR);
|
|
163
|
+
const privatePath = opts.privateKeyOut ? path.resolve(opts.privateKeyOut) : path.join(outDir, exports.PRIVATE_KEY_FILE);
|
|
164
|
+
const publicPath = opts.publicKeyOut ? path.resolve(opts.publicKeyOut) : path.join(outDir, exports.PUBLIC_KEY_FILE);
|
|
165
|
+
const keyId = (opts.keyId ?? defaultKeyId()).trim();
|
|
166
|
+
if (fs.existsSync(privatePath) && !opts.force) {
|
|
167
|
+
(0, output_1.fail)(`A signing key already exists at ${privatePath}.`, [
|
|
168
|
+
'Overwriting it means releases signed with the old key can no longer be verified',
|
|
169
|
+
'by apps that embed only the old public key. Pass --force if that is intended.',
|
|
170
|
+
], output_1.ExitCode.Usage);
|
|
140
171
|
}
|
|
172
|
+
const { privatePem, publicPem, info } = (0, signing_1.generateRsaKeyPair)({ modulusBits: bits });
|
|
173
|
+
fs.mkdirSync(path.dirname(privatePath), { recursive: true });
|
|
174
|
+
// 0600 on POSIX; the ACL call is what actually protects it on Windows.
|
|
175
|
+
fs.writeFileSync(privatePath, privatePem, { mode: 0o600 });
|
|
176
|
+
(0, credentials_1.restrictToCurrentUser)(privatePath, 'your OTA signing private key');
|
|
177
|
+
fs.writeFileSync(publicPath, publicPem);
|
|
178
|
+
// Persist the keyId next to the key so publish/register can find it.
|
|
179
|
+
fs.writeFileSync(path.join(path.dirname(privatePath), exports.KEY_ID_FILE), keyId + '\n');
|
|
180
|
+
const gitignoreOutcome = ensureKeyDirGitignored(path.dirname(privatePath));
|
|
181
|
+
console.log(`\n🔑 RSA-${info.modulusBits} signing keypair generated.\n`);
|
|
182
|
+
console.log(` Key ID : ${keyId}`);
|
|
183
|
+
console.log(` Algorithm : RSA-${info.modulusBits} / SHA-256 (SB-OTA-RSA-SHA256-V1)`);
|
|
184
|
+
console.log(` Fingerprint : SHA-256:${info.fingerprintDisplay}`);
|
|
185
|
+
console.log(` Private key : ${privatePath}`);
|
|
186
|
+
console.log(` NEVER commit or share this file.`);
|
|
187
|
+
console.log(` Public key : ${publicPath}`);
|
|
188
|
+
console.log(` Git : ${gitignoreOutcome}\n`);
|
|
189
|
+
const appId = opts.appId || (0, context_1.getContext)().appId;
|
|
190
|
+
if (appId) {
|
|
191
|
+
const creds = (0, credentials_1.readCredentials)();
|
|
192
|
+
if (!creds) {
|
|
193
|
+
console.log('ℹ️ Not logged in — the keypair was written but not registered.');
|
|
194
|
+
console.log(` Register it later with:\n scalebun ota keys register --key-id ${keyId} --public-key ${publicPath}\n`);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
const client = new client_1.ApiClient(creds);
|
|
198
|
+
try {
|
|
199
|
+
await registerPublicKey(client, appId, {
|
|
200
|
+
publicKey: publicPem,
|
|
201
|
+
keyId,
|
|
202
|
+
algorithm: 'RSA_SHA256',
|
|
203
|
+
name: opts.name ?? `CLI ${new Date().toISOString().slice(0, 10)}`,
|
|
204
|
+
});
|
|
205
|
+
console.log('✅ Public key registered with ScaleBun.\n');
|
|
206
|
+
}
|
|
207
|
+
catch (err) {
|
|
208
|
+
console.log(chalk_1.default.yellow(`⚠️ Could not register the public key: ${err.message}`));
|
|
209
|
+
console.log(` Retry: scalebun ota keys register --key-id ${keyId} --public-key ${publicPath}\n`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
else {
|
|
213
|
+
console.log('ℹ️ Not a linked project — run `scalebun link` (or pass --app-id) to register this public key.\n');
|
|
214
|
+
}
|
|
215
|
+
console.log('Next: embed the PUBLIC key in your native app (Android assets/, iOS bundle');
|
|
216
|
+
console.log('resource) and pin its key ID. The SDK verifies every release against the');
|
|
217
|
+
console.log('embedded key and REFUSES any that does not match. See');
|
|
218
|
+
console.log('docs/ota/SB-OTA-RSA-SHA256-V1.md.\n');
|
|
141
219
|
}
|
|
142
220
|
function registerOtaKeysCommand(ota) {
|
|
143
|
-
ota
|
|
144
|
-
|
|
145
|
-
.
|
|
221
|
+
const keys = ota.command('keys').description('Manage OTA release signing keys (RSA-3072 / SHA-256)');
|
|
222
|
+
keys
|
|
223
|
+
.command('generate')
|
|
224
|
+
.description('Generate an RSA signing keypair locally and register the public half')
|
|
225
|
+
.option('--algorithm <alg>', 'rsa-3072 (recommended) or rsa-2048', 'rsa-3072')
|
|
226
|
+
.option('--key-id <id>', 'Stable key identifier (e.g. production-2026-01)')
|
|
227
|
+
.option('--private-key-out <path>', 'Where to write the PKCS#8 private key')
|
|
228
|
+
.option('--public-key-out <path>', 'Where to write the SPKI public key')
|
|
229
|
+
.option('--out <dir>', `Directory for both keys (default: ./${exports.KEY_DIR})`)
|
|
146
230
|
.option('--app-id <id>', 'Register the public key against this app')
|
|
147
231
|
.option('--name <name>', 'Label for the key in the dashboard')
|
|
148
|
-
.option('--out <dir>', `Directory for the private key (default: ./${KEY_DIR})`)
|
|
149
232
|
.option('--force', 'Overwrite an existing private key')
|
|
150
|
-
.action(
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
{ mode: 0o600 });
|
|
168
|
-
(0, credentials_1.restrictToCurrentUser)(privatePath, 'your OTA signing private key');
|
|
169
|
-
const gitignoreOutcome = ensureKeyDirGitignored(outDir);
|
|
170
|
-
const publicHex = rawPublicKeyHex(publicKey);
|
|
171
|
-
console.log(`\n🔑 ed25519 signing keypair generated.\n`);
|
|
172
|
-
console.log(` Private key : ${privatePath}`);
|
|
173
|
-
console.log(` NEVER commit or share this file.`);
|
|
174
|
-
console.log(` Git : ${gitignoreOutcome}`);
|
|
175
|
-
console.log(` Public key : ${publicHex}\n`);
|
|
176
|
-
// Register against the flagged app, or the one this project is linked to
|
|
177
|
-
// (scalebun.json) — the same resolution `publish` uses, so a linked
|
|
178
|
-
// project registers automatically without repeating --app-id.
|
|
179
|
-
const appId = opts.appId || (0, context_1.getContext)().appId;
|
|
180
|
-
if (appId) {
|
|
181
|
-
const creds = (0, credentials_1.readCredentials)();
|
|
182
|
-
if (!creds) {
|
|
183
|
-
console.error('❌ Not logged in. Run `scalebun login` first.');
|
|
184
|
-
console.error(' The keypair was still written; register it later with:');
|
|
185
|
-
console.error(` scalebun ota register-key --public-key ${publicHex}`);
|
|
186
|
-
process.exit(1);
|
|
187
|
-
}
|
|
188
|
-
const client = new client_1.ApiClient(creds);
|
|
189
|
-
try {
|
|
190
|
-
await client.post(`/saas/apps/${appId}/ota/signing-keys`, {
|
|
191
|
-
publicKey: publicHex,
|
|
192
|
-
name: opts.name ?? `CLI ${new Date().toISOString().slice(0, 10)}`,
|
|
193
|
-
});
|
|
194
|
-
console.log('✅ Public key registered with ScaleBun.\n');
|
|
195
|
-
}
|
|
196
|
-
catch (err) {
|
|
197
|
-
console.error(`❌ Failed to register public key: ${err.message}`);
|
|
198
|
-
console.error(` Retry with: scalebun ota register-key --public-key ${publicHex}\n`);
|
|
199
|
-
process.exit(1);
|
|
200
|
-
}
|
|
233
|
+
.action(runGenerate);
|
|
234
|
+
keys
|
|
235
|
+
.command('inspect')
|
|
236
|
+
.description('Validate a public key and print its algorithm, size, and fingerprint')
|
|
237
|
+
.requiredOption('--public-key <path>', 'Path to an SPKI public-key PEM')
|
|
238
|
+
.action((opts) => {
|
|
239
|
+
const p = path.resolve(opts.publicKey);
|
|
240
|
+
if (!fs.existsSync(p))
|
|
241
|
+
(0, output_1.fail)(`No such file: ${p}`, [], output_1.ExitCode.Usage);
|
|
242
|
+
try {
|
|
243
|
+
const info = (0, signing_1.inspectPublicKey)(fs.readFileSync(p));
|
|
244
|
+
console.log('');
|
|
245
|
+
console.log(` Algorithm : RSA-${info.modulusBits} / SHA-256`);
|
|
246
|
+
console.log(` Modulus : ${info.modulusBits} bits${info.modulusBits < 3072 ? ' (2048 accepted; 3072 recommended)' : ''}`);
|
|
247
|
+
console.log(` Fingerprint : SHA-256:${info.fingerprintDisplay}`);
|
|
248
|
+
console.log(` Fingerprint : ${info.fingerprint} (hex)`);
|
|
249
|
+
console.log('');
|
|
201
250
|
}
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
' register this public key automatically.\n');
|
|
251
|
+
catch (err) {
|
|
252
|
+
(0, output_1.fail)(`Invalid public key: ${err.message}`, [], output_1.ExitCode.Rejected);
|
|
205
253
|
}
|
|
206
|
-
// This used to print `verifySignature: myEd25519Verifier` — a
|
|
207
|
-
// placeholder that left every adopter to hand-write ~100 lines of
|
|
208
|
-
// crypto plumbing for the most security-critical path in the product.
|
|
209
|
-
// The SDK now ships that verifier; all the developer owes is one
|
|
210
|
-
// install and the pinned key.
|
|
211
|
-
console.log('Next: install the verifier peers (once per app):\n');
|
|
212
|
-
console.log(' npm install @noble/ed25519 @noble/hashes\n');
|
|
213
|
-
console.log('Then embed the PUBLIC key in your app and enforce it at init:\n');
|
|
214
|
-
console.log(' ScaleBun.init({');
|
|
215
|
-
console.log(' // …');
|
|
216
|
-
console.log(' ota: {');
|
|
217
|
-
console.log(' enabled: true,');
|
|
218
|
-
console.log(` publicSigningKey: '${publicHex}',`);
|
|
219
|
-
console.log(' },');
|
|
220
|
-
console.log(' })\n');
|
|
221
|
-
console.log('The SDK verifies every bundle against that key and REFUSES any update\n' +
|
|
222
|
-
'that does not match. Supply `ota.verifySignature` only if you need to\n' +
|
|
223
|
-
'override it — your own function always takes precedence.\n');
|
|
224
254
|
});
|
|
225
|
-
|
|
226
|
-
.command('register
|
|
227
|
-
.description('Register an existing
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
// them (or a local key), so CI is not silently blocked on a prompt.
|
|
232
|
-
.option('--app-id <id>', 'Scalebun app ID (resolved from scalebun.json, else picked)')
|
|
233
|
-
.option('--public-key <hex>', 'ed25519 public key (hex or base64); read from ./.scalebun when omitted')
|
|
255
|
+
keys
|
|
256
|
+
.command('register')
|
|
257
|
+
.description('Register an existing RSA public key (SPKI PEM) with an app')
|
|
258
|
+
.option('--app-id <id>', 'Scalebun app ID (resolved from scalebun.json when omitted)')
|
|
259
|
+
.option('--key-id <id>', 'Stable key identifier (defaults to the local key id)')
|
|
260
|
+
.option('--public-key <path>', 'SPKI public-key PEM path (defaults to the local ./.scalebun key)')
|
|
234
261
|
.option('--name <name>', 'Label for the key in the dashboard')
|
|
235
262
|
.action(async (opts) => {
|
|
236
263
|
const creds = (0, credentials_1.readCredentials)();
|
|
237
|
-
if (!creds)
|
|
264
|
+
if (!creds)
|
|
238
265
|
(0, output_1.fail)('Not logged in. Run `scalebun login` first.', [], output_1.ExitCode.Usage);
|
|
239
|
-
}
|
|
240
266
|
const client = new client_1.ApiClient(creds);
|
|
241
267
|
const nonInteractive = (0, resolve_1.isNonInteractive)();
|
|
242
|
-
// App id: flag > env > scalebun.json > interactive picker.
|
|
243
268
|
const appId = await (0, resolve_1.resolveAppId)({ appId: opts.appId });
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
}
|
|
256
|
-
publicKey = local;
|
|
257
|
-
}
|
|
258
|
-
else {
|
|
259
|
-
publicKey = (await (0, prompt_1.text)('ed25519 public key (hex)', {
|
|
260
|
-
default: local ?? '',
|
|
261
|
-
help: local
|
|
262
|
-
? 'Enter to use the public half of your local ./.scalebun signing key.'
|
|
263
|
-
: 'Paste the 64-character hex (or base64) public key.',
|
|
264
|
-
validate: (v) => /\s/.test(v)
|
|
265
|
-
? 'A key has no spaces.'
|
|
266
|
-
: v.trim().length < 40
|
|
267
|
-
? 'That looks too short for an ed25519 key.'
|
|
268
|
-
: null,
|
|
269
|
-
})).trim();
|
|
269
|
+
let pem;
|
|
270
|
+
if (opts.publicKey) {
|
|
271
|
+
const p = path.resolve(opts.publicKey);
|
|
272
|
+
if (!fs.existsSync(p))
|
|
273
|
+
(0, output_1.fail)(`No such file: ${p}`, [], output_1.ExitCode.Usage);
|
|
274
|
+
pem = fs.readFileSync(p, 'utf8');
|
|
275
|
+
}
|
|
276
|
+
else {
|
|
277
|
+
pem = localPublicPem() ?? undefined;
|
|
278
|
+
if (!pem) {
|
|
279
|
+
(0, output_1.fail)('A public key is required.', ['Pass --public-key <path> to an SPKI PEM.'], output_1.ExitCode.Usage);
|
|
270
280
|
}
|
|
271
281
|
}
|
|
272
|
-
|
|
282
|
+
let info;
|
|
283
|
+
try {
|
|
284
|
+
info = (0, signing_1.inspectPublicKey)(pem);
|
|
285
|
+
}
|
|
286
|
+
catch (err) {
|
|
287
|
+
(0, output_1.fail)(`Not a usable RSA public key: ${err.message}`, [], output_1.ExitCode.Rejected);
|
|
288
|
+
}
|
|
289
|
+
const keyId = (opts.keyId ?? readLocalKeyId() ?? defaultKeyId()).trim();
|
|
273
290
|
let name = opts.name?.trim();
|
|
274
291
|
if (!name && !nonInteractive) {
|
|
275
|
-
name = (await (0, prompt_1.text)('Key name (label shown in the dashboard)', {
|
|
276
|
-
default: 'cli',
|
|
277
|
-
allowEmpty: true,
|
|
278
|
-
})).trim();
|
|
292
|
+
name = (await (0, prompt_1.text)('Key name (label shown in the dashboard)', { default: 'cli', allowEmpty: true })).trim();
|
|
279
293
|
}
|
|
280
294
|
(0, prompt_1.closePrompts)();
|
|
281
295
|
try {
|
|
282
|
-
await client
|
|
283
|
-
publicKey,
|
|
296
|
+
await registerPublicKey(client, appId, {
|
|
297
|
+
publicKey: info.spkiPem,
|
|
298
|
+
keyId,
|
|
299
|
+
algorithm: 'RSA_SHA256',
|
|
284
300
|
name: name || 'CLI',
|
|
285
301
|
});
|
|
286
|
-
console.log(
|
|
302
|
+
console.log(`\n✅ Public key registered (key ID ${keyId}, SHA-256:${info.fingerprintDisplay.slice(0, 23)}…).\n`);
|
|
287
303
|
}
|
|
288
304
|
catch (err) {
|
|
289
305
|
(0, output_1.fail)(`Failed to register public key: ${err.message}`, [], output_1.ExitCode.Rejected);
|
|
290
306
|
}
|
|
291
307
|
});
|
|
292
|
-
|
|
293
|
-
.command('
|
|
308
|
+
keys
|
|
309
|
+
.command('list')
|
|
294
310
|
.description('List signing keys registered for an app')
|
|
295
|
-
// Resolves from scalebun.json / a picker like every other OTA command — a
|
|
296
|
-
// linked project should not have to repeat --app-id just to list its keys.
|
|
297
311
|
.option('--app-id <id>', 'Scalebun app ID (resolved from scalebun.json when omitted)')
|
|
298
312
|
.action(async (opts) => {
|
|
299
313
|
const creds = (0, credentials_1.readCredentials)();
|
|
300
|
-
if (!creds)
|
|
301
|
-
|
|
302
|
-
process.exit(1);
|
|
303
|
-
}
|
|
314
|
+
if (!creds)
|
|
315
|
+
(0, output_1.fail)('Not logged in. Run `scalebun login` first.', [], output_1.ExitCode.Usage);
|
|
304
316
|
const appId = await (0, resolve_1.resolveAppId)({ appId: opts.appId });
|
|
305
317
|
const client = new client_1.ApiClient(creds);
|
|
306
318
|
try {
|
|
307
|
-
const
|
|
319
|
+
const rows = await client.get(`/saas/apps/${appId}/ota/signing-keys`);
|
|
308
320
|
console.log(`\n🔑 Signing keys for app ${appId}:\n`);
|
|
309
|
-
if (!
|
|
310
|
-
console.log(' (none — run `scalebun ota generate
|
|
321
|
+
if (!rows?.length) {
|
|
322
|
+
console.log(' (none — run `scalebun ota keys generate`)\n');
|
|
311
323
|
return;
|
|
312
324
|
}
|
|
313
|
-
for (const k of
|
|
325
|
+
for (const k of rows) {
|
|
314
326
|
const state = k.revokedAt ? 'REVOKED' : 'active';
|
|
315
|
-
|
|
316
|
-
console.log(`
|
|
327
|
+
const alg = k.algorithm === 'ED25519' ? 'Ed25519 (legacy)' : k.algorithm ? k.algorithm.replace('_', '/') : 'unknown';
|
|
328
|
+
console.log(` • ${(k.name ?? 'unnamed').padEnd(22)} ${state.padEnd(8)} ${alg}`);
|
|
329
|
+
console.log(` id=${k.id}${k.keyId ? ` keyId=${k.keyId}` : ''}`);
|
|
317
330
|
}
|
|
318
331
|
console.log('');
|
|
319
332
|
}
|
|
320
333
|
catch (err) {
|
|
321
|
-
|
|
322
|
-
process.exit(1);
|
|
334
|
+
(0, output_1.fail)(`Failed to list keys: ${err.message}`, [], output_1.ExitCode.Rejected);
|
|
323
335
|
}
|
|
324
336
|
});
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
.command('revoke-key')
|
|
339
|
-
.description('Revoke a signing key — bundles signed with it stop verifying')
|
|
340
|
-
.requiredOption('--key-id <id>', 'Signing key to revoke (from `scalebun ota keys`)')
|
|
337
|
+
keys
|
|
338
|
+
.command('verify')
|
|
339
|
+
.description('Prove possession of a signing key private half (proof-of-possession)')
|
|
340
|
+
.requiredOption('--challenge <id>', 'Challenge id issued by the dashboard/API')
|
|
341
|
+
.option('--private-key <path>', 'PKCS#8 private-key PEM (defaults to the local ./.scalebun key)')
|
|
342
|
+
.option('--app-id <id>', 'Scalebun app ID (resolved from scalebun.json when omitted)')
|
|
343
|
+
.option('--key-id <id>', 'Signing key ROW id (auto-resolved from the local key when omitted)')
|
|
344
|
+
.option('--json', 'Machine-readable output')
|
|
345
|
+
.action(runVerify);
|
|
346
|
+
keys
|
|
347
|
+
.command('revoke')
|
|
348
|
+
.description('Revoke a signing key — releases signed with it stop verifying')
|
|
349
|
+
.requiredOption('--key-id <id>', 'Signing key row id to revoke (from `scalebun ota keys list`)')
|
|
341
350
|
.option('--app-id <id>', 'Scalebun app ID (resolved from scalebun.json when omitted)')
|
|
351
|
+
.option('--reason <text>', 'Why the key is being revoked (recorded in the audit trail)')
|
|
342
352
|
.option('--yes', 'Skip the confirmation (required off a TTY)')
|
|
343
353
|
.option('--json', 'Machine-readable output')
|
|
344
|
-
.action(
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
354
|
+
.action(runRevoke);
|
|
355
|
+
// ── Back-compat aliases (pre-1.14 top-level commands) ─────────────────────────
|
|
356
|
+
// These kept working across every prior release; forward them so scripts and
|
|
357
|
+
// muscle memory survive the move under `keys`.
|
|
358
|
+
ota
|
|
359
|
+
.command('generate-key-pair')
|
|
360
|
+
.description('(alias of `ota keys generate`)')
|
|
361
|
+
.option('--algorithm <alg>', 'rsa-3072 (recommended) or rsa-2048', 'rsa-3072')
|
|
362
|
+
.option('--key-id <id>', 'Stable key identifier')
|
|
363
|
+
.option('--app-id <id>', 'Register the public key against this app')
|
|
364
|
+
.option('--name <name>', 'Label for the key in the dashboard')
|
|
365
|
+
.option('--out <dir>', `Directory for the keys (default: ./${exports.KEY_DIR})`)
|
|
366
|
+
.option('--force', 'Overwrite an existing private key')
|
|
367
|
+
.action(runGenerate);
|
|
368
|
+
ota
|
|
369
|
+
.command('revoke-key')
|
|
370
|
+
.description('(alias of `ota keys revoke`)')
|
|
371
|
+
.requiredOption('--key-id <id>', 'Signing key row id to revoke')
|
|
372
|
+
.option('--app-id <id>', 'Scalebun app ID')
|
|
373
|
+
.option('--yes', 'Skip the confirmation')
|
|
374
|
+
.option('--json', 'Machine-readable output')
|
|
375
|
+
.action(runRevoke);
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* Resolve the signing-key ROW id for a verify: prefer --key-id, else match the
|
|
379
|
+
* local private key's public fingerprint against the app's registered keys.
|
|
380
|
+
*/
|
|
381
|
+
async function resolveKeyRowId(client, appId, explicitKeyId, localPubKey) {
|
|
382
|
+
if (explicitKeyId)
|
|
383
|
+
return explicitKeyId;
|
|
384
|
+
if (!localPubKey) {
|
|
385
|
+
(0, output_1.fail)('Could not determine which signing key to verify.', ['Pass --key-id <row id> (see `scalebun ota keys list`), or run from a project with ./.scalebun.'], output_1.ExitCode.Usage);
|
|
386
|
+
}
|
|
387
|
+
const localFp = (0, signing_1.spkiFingerprint)(localPubKey).hex;
|
|
388
|
+
let rows;
|
|
389
|
+
try {
|
|
390
|
+
rows = await client.get(`/saas/apps/${appId}/ota/signing-keys`);
|
|
391
|
+
}
|
|
392
|
+
catch (err) {
|
|
393
|
+
(0, output_1.failFromError)('Could not list signing keys', err);
|
|
394
|
+
}
|
|
395
|
+
const match = (rows ?? []).find((k) => (k.fingerprint ?? '').toLowerCase() === localFp.toLowerCase());
|
|
396
|
+
if (!match) {
|
|
397
|
+
(0, output_1.fail)('No registered key matches your local signing key.', ['Pass --key-id <row id> explicitly (from `scalebun ota keys list`).'], output_1.ExitCode.Usage);
|
|
398
|
+
}
|
|
399
|
+
return match.id;
|
|
400
|
+
}
|
|
401
|
+
async function runVerify(opts) {
|
|
402
|
+
const creds = (0, credentials_1.readCredentials)();
|
|
403
|
+
if (!creds)
|
|
404
|
+
(0, output_1.fail)('Not signed in.', ['Run `scalebun login` first.'], output_1.ExitCode.Usage);
|
|
405
|
+
const appId = await (0, resolve_1.resolveAppId)({ appId: opts.appId, nonInteractive: opts.json });
|
|
406
|
+
const client = new client_1.ApiClient(creds);
|
|
407
|
+
// Load the private key locally — it NEVER leaves this machine.
|
|
408
|
+
let privatePem;
|
|
409
|
+
if (opts.privateKey) {
|
|
410
|
+
const p = path.resolve(opts.privateKey);
|
|
411
|
+
if (!fs.existsSync(p))
|
|
412
|
+
(0, output_1.fail)(`No such file: ${p}`, [], output_1.ExitCode.Usage);
|
|
413
|
+
privatePem = fs.readFileSync(p, 'utf8');
|
|
414
|
+
}
|
|
415
|
+
else {
|
|
416
|
+
privatePem = readLocalPrivatePem();
|
|
417
|
+
if (!privatePem) {
|
|
418
|
+
(0, output_1.fail)('A private key is required.', ['Pass --private-key <path> to a PKCS#8 PEM.'], output_1.ExitCode.Usage);
|
|
384
419
|
}
|
|
385
|
-
|
|
386
|
-
|
|
420
|
+
}
|
|
421
|
+
let privateKey;
|
|
422
|
+
let localPubKey = null;
|
|
423
|
+
try {
|
|
424
|
+
privateKey = crypto.createPrivateKey(privatePem);
|
|
425
|
+
localPubKey = crypto.createPublicKey(privateKey);
|
|
426
|
+
}
|
|
427
|
+
catch (err) {
|
|
428
|
+
(0, output_1.fail)(`Could not read the private key: ${err.message}`, [], output_1.ExitCode.Rejected);
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
const keyRowId = await resolveKeyRowId(client, appId, opts.keyId, localPubKey);
|
|
432
|
+
// Fetch the EXACT bytes the server stored; sign those, nothing invented locally.
|
|
433
|
+
let challenge;
|
|
434
|
+
try {
|
|
435
|
+
challenge = await client.get(`/saas/apps/${appId}/ota/signing-keys/${keyRowId}/challenges/${opts.challenge}`);
|
|
436
|
+
}
|
|
437
|
+
catch (err) {
|
|
438
|
+
(0, output_1.failFromError)('Could not fetch the challenge', err, [
|
|
439
|
+
'Check the challenge id and that it has not expired.',
|
|
440
|
+
]);
|
|
441
|
+
}
|
|
442
|
+
if (challenge.consumed) {
|
|
443
|
+
(0, output_1.fail)('This challenge was already used.', ['Request a fresh challenge and retry.'], output_1.ExitCode.Rejected);
|
|
444
|
+
}
|
|
445
|
+
const signature = crypto
|
|
446
|
+
.sign('sha256', Buffer.from(challenge.payload, 'base64'), privateKey)
|
|
447
|
+
.toString('base64');
|
|
448
|
+
let result;
|
|
449
|
+
try {
|
|
450
|
+
result = await client.post(`/saas/apps/${appId}/ota/signing-keys/${keyRowId}/verify`, { challengeId: opts.challenge, signature });
|
|
451
|
+
}
|
|
452
|
+
catch (err) {
|
|
453
|
+
(0, output_1.failFromError)('Ownership verification failed', err, [
|
|
454
|
+
'The signature did not verify against the registered public key, or the challenge expired.',
|
|
455
|
+
]);
|
|
456
|
+
}
|
|
457
|
+
(0, output_1.emit)(() => {
|
|
458
|
+
console.log(`\n✅ Ownership verified for key ${keyRowId}.`);
|
|
459
|
+
if (result.activated)
|
|
460
|
+
console.log(' The key is now ACTIVE.');
|
|
461
|
+
else if (result.status)
|
|
462
|
+
console.log(` Key status: ${result.status}`);
|
|
463
|
+
console.log('');
|
|
464
|
+
}, { ok: true, keyId: keyRowId, verified: !!result.verified, activated: !!result.activated, status: result.status });
|
|
465
|
+
}
|
|
466
|
+
async function runRevoke(opts) {
|
|
467
|
+
const creds = (0, credentials_1.readCredentials)();
|
|
468
|
+
if (!creds)
|
|
469
|
+
(0, output_1.fail)('Not signed in.', ['Run `scalebun login` first.'], output_1.ExitCode.Usage);
|
|
470
|
+
const appId = await (0, resolve_1.resolveAppId)({ appId: opts.appId, nonInteractive: opts.json });
|
|
471
|
+
const client = new client_1.ApiClient(creds);
|
|
472
|
+
let rows;
|
|
473
|
+
try {
|
|
474
|
+
rows = await client.get(`/saas/apps/${appId}/ota/signing-keys`);
|
|
475
|
+
}
|
|
476
|
+
catch (err) {
|
|
477
|
+
(0, output_1.failFromError)('Could not list signing keys', err);
|
|
478
|
+
}
|
|
479
|
+
const target = (rows ?? []).find((k) => k.id === opts.keyId);
|
|
480
|
+
if (!target) {
|
|
481
|
+
(0, output_1.fail)(`No signing key ${opts.keyId} on this app.`, (rows ?? []).map((k) => ` ${k.id} ${k.name ?? 'unnamed'}`), output_1.ExitCode.Usage);
|
|
482
|
+
}
|
|
483
|
+
if (target.revokedAt) {
|
|
484
|
+
(0, output_1.emit)(() => console.log(`\n${chalk_1.default.dim(`${target.name ?? target.id} is already revoked.`)}\n`), {
|
|
485
|
+
ok: true,
|
|
486
|
+
keyId: target.id,
|
|
487
|
+
alreadyRevoked: true,
|
|
488
|
+
});
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
if (!opts.yes) {
|
|
492
|
+
if ((0, resolve_1.isNonInteractive)({ nonInteractive: opts.json })) {
|
|
493
|
+
(0, output_1.fail)('Refusing to revoke a signing key without confirmation.', ['Pass --yes if this is intentional.'], output_1.ExitCode.Usage);
|
|
387
494
|
}
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
495
|
+
console.log('');
|
|
496
|
+
console.log(chalk_1.default.yellow(`⚠️ Revoking ${chalk_1.default.bold(target.name ?? target.id)}.\n` +
|
|
497
|
+
' Apps pinning ONLY this key will refuse every future update — fail-closed by\n' +
|
|
498
|
+
' design. Register the replacement and ship a build embedding both first.'));
|
|
499
|
+
const ok = await (0, prompt_1.confirmPhrase)(' Type "revoke" to confirm: ', 'revoke', { caseInsensitive: true });
|
|
500
|
+
(0, prompt_1.closePrompts)();
|
|
501
|
+
if (!ok) {
|
|
502
|
+
console.log('Cancelled — the key is still active.');
|
|
503
|
+
process.exit(output_1.ExitCode.Ok);
|
|
392
504
|
}
|
|
393
|
-
|
|
505
|
+
}
|
|
506
|
+
try {
|
|
507
|
+
await client.post(`/saas/apps/${appId}/ota/signing-keys/${target.id}/revoke`, {
|
|
508
|
+
reason: (opts.reason ?? '').trim() || 'Revoked via CLI',
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
catch (err) {
|
|
512
|
+
(0, output_1.failFromError)('Could not revoke the key', err, ['Revoking a signing key requires the ota.keys.revoke permission.']);
|
|
513
|
+
}
|
|
514
|
+
(0, output_1.emit)(() => console.log(`\n🔒 Revoked ${chalk_1.default.bold(target.name ?? target.id)}\n`), {
|
|
515
|
+
ok: true,
|
|
516
|
+
keyId: target.id,
|
|
517
|
+
revoked: true,
|
|
394
518
|
});
|
|
395
519
|
}
|
|
396
|
-
/**
|
|
397
|
-
* Sign a bundle hash with the locally-held private key.
|
|
398
|
-
*
|
|
399
|
-
* Returns null when no key is present — publishing without signing stays valid
|
|
400
|
-
* for apps that have not adopted it, and `ota publish` reports which happened
|
|
401
|
-
* rather than failing.
|
|
402
|
-
*/
|
|
403
|
-
function signBundleHash(sha256Hex, keyDir) {
|
|
404
|
-
const privatePath = path.join(path.resolve(keyDir ?? KEY_DIR), PRIVATE_KEY_FILE);
|
|
405
|
-
if (!fs.existsSync(privatePath))
|
|
406
|
-
return null;
|
|
407
|
-
const privateKey = crypto.createPrivateKey(fs.readFileSync(privatePath));
|
|
408
|
-
// The signature covers the bundle's SHA-256, which the device already has from
|
|
409
|
-
// the check response and re-computes from the downloaded bytes. Signing the
|
|
410
|
-
// hash rather than the bundle keeps the CLI from re-reading 20 MB, and gives
|
|
411
|
-
// the device one value to verify that transitively covers the whole artifact.
|
|
412
|
-
const signature = crypto.sign(null, Buffer.from(sha256Hex, 'hex'), privateKey);
|
|
413
|
-
return signature.toString('hex');
|
|
414
|
-
}
|
|
415
520
|
//# sourceMappingURL=ota-keys.js.map
|