@scalebun-release/cli 2.5.0
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/bin/scalebun.js +2 -0
- package/lib/api/client.d.ts +52 -0
- package/lib/api/client.d.ts.map +1 -0
- package/lib/api/client.js +335 -0
- package/lib/api/client.js.map +1 -0
- package/lib/commands/ci-init.d.ts +3 -0
- package/lib/commands/ci-init.d.ts.map +1 -0
- package/lib/commands/ci-init.js +348 -0
- package/lib/commands/ci-init.js.map +1 -0
- package/lib/commands/doctor.d.ts +31 -0
- package/lib/commands/doctor.d.ts.map +1 -0
- package/lib/commands/doctor.js +559 -0
- package/lib/commands/doctor.js.map +1 -0
- package/lib/commands/launch.d.ts +3 -0
- package/lib/commands/launch.d.ts.map +1 -0
- package/lib/commands/launch.js +23 -0
- package/lib/commands/launch.js.map +1 -0
- package/lib/commands/link.d.ts +16 -0
- package/lib/commands/link.d.ts.map +1 -0
- package/lib/commands/link.js +164 -0
- package/lib/commands/link.js.map +1 -0
- package/lib/commands/login.d.ts +12 -0
- package/lib/commands/login.d.ts.map +1 -0
- package/lib/commands/login.js +124 -0
- package/lib/commands/login.js.map +1 -0
- package/lib/commands/migrate-codepush.d.ts +13 -0
- package/lib/commands/migrate-codepush.d.ts.map +1 -0
- package/lib/commands/migrate-codepush.js +98 -0
- package/lib/commands/migrate-codepush.js.map +1 -0
- package/lib/commands/ota-bundle.d.ts +22 -0
- package/lib/commands/ota-bundle.d.ts.map +1 -0
- package/lib/commands/ota-bundle.js +253 -0
- package/lib/commands/ota-bundle.js.map +1 -0
- package/lib/commands/ota-channels.d.ts +3 -0
- package/lib/commands/ota-channels.d.ts.map +1 -0
- package/lib/commands/ota-channels.js +231 -0
- package/lib/commands/ota-channels.js.map +1 -0
- package/lib/commands/ota-delete-bundle.d.ts +16 -0
- package/lib/commands/ota-delete-bundle.d.ts.map +1 -0
- package/lib/commands/ota-delete-bundle.js +96 -0
- package/lib/commands/ota-delete-bundle.js.map +1 -0
- package/lib/commands/ota-inspect.d.ts +3 -0
- package/lib/commands/ota-inspect.d.ts.map +1 -0
- package/lib/commands/ota-inspect.js +258 -0
- package/lib/commands/ota-inspect.js.map +1 -0
- package/lib/commands/ota-keys.d.ts +91 -0
- package/lib/commands/ota-keys.d.ts.map +1 -0
- package/lib/commands/ota-keys.js +765 -0
- package/lib/commands/ota-keys.js.map +1 -0
- package/lib/commands/ota-promote.d.ts +3 -0
- package/lib/commands/ota-promote.d.ts.map +1 -0
- package/lib/commands/ota-promote.js +194 -0
- package/lib/commands/ota-promote.js.map +1 -0
- package/lib/commands/ota-publish.d.ts +26 -0
- package/lib/commands/ota-publish.d.ts.map +1 -0
- package/lib/commands/ota-publish.js +892 -0
- package/lib/commands/ota-publish.js.map +1 -0
- package/lib/commands/ota-rollout.d.ts +3 -0
- package/lib/commands/ota-rollout.d.ts.map +1 -0
- package/lib/commands/ota-rollout.js +265 -0
- package/lib/commands/ota-rollout.js.map +1 -0
- package/lib/commands/ota-sign.d.ts +19 -0
- package/lib/commands/ota-sign.d.ts.map +1 -0
- package/lib/commands/ota-sign.js +245 -0
- package/lib/commands/ota-sign.js.map +1 -0
- package/lib/commands/push-apns.d.ts +3 -0
- package/lib/commands/push-apns.d.ts.map +1 -0
- package/lib/commands/push-apns.js +166 -0
- package/lib/commands/push-apns.js.map +1 -0
- package/lib/commands/quickstart.d.ts +4 -0
- package/lib/commands/quickstart.d.ts.map +1 -0
- package/lib/commands/quickstart.js +672 -0
- package/lib/commands/quickstart.js.map +1 -0
- package/lib/commands/shell.d.ts +28 -0
- package/lib/commands/shell.d.ts.map +1 -0
- package/lib/commands/shell.js +512 -0
- package/lib/commands/shell.js.map +1 -0
- package/lib/commands/tls-pins.d.ts +27 -0
- package/lib/commands/tls-pins.d.ts.map +1 -0
- package/lib/commands/tls-pins.js +209 -0
- package/lib/commands/tls-pins.js.map +1 -0
- package/lib/index.d.ts +2 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +233 -0
- package/lib/index.js.map +1 -0
- package/lib/ui/banner.d.ts +23 -0
- package/lib/ui/banner.d.ts.map +1 -0
- package/lib/ui/banner.js +162 -0
- package/lib/ui/banner.js.map +1 -0
- package/lib/ui/graphics.d.ts +18 -0
- package/lib/ui/graphics.d.ts.map +1 -0
- package/lib/ui/graphics.js +134 -0
- package/lib/ui/graphics.js.map +1 -0
- package/lib/ui/mark.d.ts +4 -0
- package/lib/ui/mark.d.ts.map +1 -0
- package/lib/ui/mark.js +21 -0
- package/lib/ui/mark.js.map +1 -0
- package/lib/ui/menu.d.ts +109 -0
- package/lib/ui/menu.d.ts.map +1 -0
- package/lib/ui/menu.js +314 -0
- package/lib/ui/menu.js.map +1 -0
- package/lib/ui/sixel.d.ts +6 -0
- package/lib/ui/sixel.d.ts.map +1 -0
- package/lib/ui/sixel.js +9 -0
- package/lib/ui/sixel.js.map +1 -0
- package/lib/utils/apps.d.ts +29 -0
- package/lib/utils/apps.d.ts.map +1 -0
- package/lib/utils/apps.js +19 -0
- package/lib/utils/apps.js.map +1 -0
- package/lib/utils/browser.d.ts +9 -0
- package/lib/utils/browser.d.ts.map +1 -0
- package/lib/utils/browser.js +39 -0
- package/lib/utils/browser.js.map +1 -0
- package/lib/utils/bundle.d.ts +153 -0
- package/lib/utils/bundle.d.ts.map +1 -0
- package/lib/utils/bundle.js +459 -0
- package/lib/utils/bundle.js.map +1 -0
- package/lib/utils/bundleStore.d.ts +128 -0
- package/lib/utils/bundleStore.d.ts.map +1 -0
- package/lib/utils/bundleStore.js +250 -0
- package/lib/utils/bundleStore.js.map +1 -0
- package/lib/utils/context.d.ts +88 -0
- package/lib/utils/context.d.ts.map +1 -0
- package/lib/utils/context.js +247 -0
- package/lib/utils/context.js.map +1 -0
- package/lib/utils/credentials.d.ts +59 -0
- package/lib/utils/credentials.d.ts.map +1 -0
- package/lib/utils/credentials.js +147 -0
- package/lib/utils/credentials.js.map +1 -0
- package/lib/utils/guide.d.ts +63 -0
- package/lib/utils/guide.d.ts.map +1 -0
- package/lib/utils/guide.js +191 -0
- package/lib/utils/guide.js.map +1 -0
- package/lib/utils/hash.d.ts +3 -0
- package/lib/utils/hash.d.ts.map +1 -0
- package/lib/utils/hash.js +44 -0
- package/lib/utils/hash.js.map +1 -0
- package/lib/utils/native-id.d.ts +17 -0
- package/lib/utils/native-id.d.ts.map +1 -0
- package/lib/utils/native-id.js +105 -0
- package/lib/utils/native-id.js.map +1 -0
- package/lib/utils/output.d.ts +96 -0
- package/lib/utils/output.d.ts.map +1 -0
- package/lib/utils/output.js +135 -0
- package/lib/utils/output.js.map +1 -0
- package/lib/utils/prompt.d.ts +106 -0
- package/lib/utils/prompt.d.ts.map +1 -0
- package/lib/utils/prompt.js +457 -0
- package/lib/utils/prompt.js.map +1 -0
- package/lib/utils/provenance.d.ts +30 -0
- package/lib/utils/provenance.d.ts.map +1 -0
- package/lib/utils/provenance.js +157 -0
- package/lib/utils/provenance.js.map +1 -0
- package/lib/utils/resolve.d.ts +81 -0
- package/lib/utils/resolve.d.ts.map +1 -0
- package/lib/utils/resolve.js +139 -0
- package/lib/utils/resolve.js.map +1 -0
- package/lib/utils/sdkBin.d.ts +18 -0
- package/lib/utils/sdkBin.d.ts.map +1 -0
- package/lib/utils/sdkBin.js +76 -0
- package/lib/utils/sdkBin.js.map +1 -0
- package/lib/utils/signer.d.ts +94 -0
- package/lib/utils/signer.d.ts.map +1 -0
- package/lib/utils/signer.js +230 -0
- package/lib/utils/signer.js.map +1 -0
- package/lib/utils/signing.d.ts +102 -0
- package/lib/utils/signing.d.ts.map +1 -0
- package/lib/utils/signing.js +229 -0
- package/lib/utils/signing.js.map +1 -0
- package/lib/utils/transportSecurity.d.ts +36 -0
- package/lib/utils/transportSecurity.d.ts.map +1 -0
- package/lib/utils/transportSecurity.js +74 -0
- package/lib/utils/transportSecurity.js.map +1 -0
- package/lib/utils/trustAnchor.d.ts +109 -0
- package/lib/utils/trustAnchor.d.ts.map +1 -0
- package/lib/utils/trustAnchor.js +169 -0
- package/lib/utils/trustAnchor.js.map +1 -0
- package/lib/utils/ui.d.ts +12 -0
- package/lib/utils/ui.d.ts.map +1 -0
- package/lib/utils/ui.js +60 -0
- package/lib/utils/ui.js.map +1 -0
- package/lib/utils/validate.d.ts +62 -0
- package/lib/utils/validate.d.ts.map +1 -0
- package/lib/utils/validate.js +189 -0
- package/lib/utils/validate.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 +37 -0
|
@@ -0,0 +1,765 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
36
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
|
+
};
|
|
38
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
+
exports.spkiFingerprint = exports.ENV_REQUIRE_SIGNING = exports.ENV_KEY_ID = exports.ENV_SIGNING_KEY_PASSPHRASE = exports.ENV_SIGNING_PRIVATE_KEY = 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;
|
|
44
|
+
exports.resolveSigningMaterial = resolveSigningMaterial;
|
|
45
|
+
exports.signingRequired = signingRequired;
|
|
46
|
+
exports.ensureKeyDirGitignored = ensureKeyDirGitignored;
|
|
47
|
+
exports.proveOwnership = proveOwnership;
|
|
48
|
+
exports.registerOtaKeysCommand = registerOtaKeysCommand;
|
|
49
|
+
const crypto = __importStar(require("crypto"));
|
|
50
|
+
const fs = __importStar(require("fs"));
|
|
51
|
+
const path = __importStar(require("path"));
|
|
52
|
+
const chalk_1 = __importDefault(require("chalk"));
|
|
53
|
+
const credentials_1 = require("../utils/credentials");
|
|
54
|
+
const resolve_1 = require("../utils/resolve");
|
|
55
|
+
const context_1 = require("../utils/context");
|
|
56
|
+
const prompt_1 = require("../utils/prompt");
|
|
57
|
+
const output_1 = require("../utils/output");
|
|
58
|
+
const client_1 = require("../api/client");
|
|
59
|
+
const signing_1 = require("../utils/signing");
|
|
60
|
+
Object.defineProperty(exports, "spkiFingerprint", { enumerable: true, get: function () { return signing_1.spkiFingerprint; } });
|
|
61
|
+
/**
|
|
62
|
+
* OTA signing keys for SB-OTA-RSA-SHA256-V1 (RSA-3072 / SHA-256).
|
|
63
|
+
*
|
|
64
|
+
* The security model, and the reason this command exists: the private key is
|
|
65
|
+
* generated on the developer's machine and NEVER leaves it. Only the SPKI PUBLIC
|
|
66
|
+
* key is uploaded/registered. A server that cannot sign cannot forge a release,
|
|
67
|
+
* so a compromise of ScaleBun's infrastructure still cannot push code to a
|
|
68
|
+
* customer's users.
|
|
69
|
+
*
|
|
70
|
+
* (Pre-1.14 ScaleBun signed with ed25519 over the bundle's SHA-256. That path is
|
|
71
|
+
* removed: the protocol is now native-verifiable RSA on every supported Android
|
|
72
|
+
* API level. Legacy ed25519 key ROWS remain readable in the dashboard, labelled
|
|
73
|
+
* "Ed25519 — Legacy"; new keys are RSA only.)
|
|
74
|
+
*/
|
|
75
|
+
exports.KEY_DIR = '.scalebun';
|
|
76
|
+
exports.PRIVATE_KEY_FILE = 'ota-signing-key.pem';
|
|
77
|
+
exports.PUBLIC_KEY_FILE = 'ota-signing-key.pub.pem';
|
|
78
|
+
exports.KEY_ID_FILE = 'ota-key-id.txt';
|
|
79
|
+
/** Absolute path to the private key for a key dir (default ./.scalebun). */
|
|
80
|
+
function privateKeyPath(keyDir = exports.KEY_DIR) {
|
|
81
|
+
return path.join(path.resolve(keyDir), exports.PRIVATE_KEY_FILE);
|
|
82
|
+
}
|
|
83
|
+
/** The locally stored keyId, if any (written next to the private key at generate). */
|
|
84
|
+
function readLocalKeyId(keyDir = exports.KEY_DIR) {
|
|
85
|
+
try {
|
|
86
|
+
const p = path.join(path.resolve(keyDir), exports.KEY_ID_FILE);
|
|
87
|
+
if (!fs.existsSync(p))
|
|
88
|
+
return null;
|
|
89
|
+
const v = fs.readFileSync(p, 'utf8').trim();
|
|
90
|
+
return v || null;
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/** The local private key PEM, if present. */
|
|
97
|
+
function readLocalPrivatePem(keyDir = exports.KEY_DIR) {
|
|
98
|
+
const p = privateKeyPath(keyDir);
|
|
99
|
+
return fs.existsSync(p) ? fs.readFileSync(p, 'utf8') : null;
|
|
100
|
+
}
|
|
101
|
+
/** SPKI public-key PEM derived from the local private key, if present. */
|
|
102
|
+
function localPublicPem(keyDir = exports.KEY_DIR) {
|
|
103
|
+
const priv = readLocalPrivatePem(keyDir);
|
|
104
|
+
if (!priv)
|
|
105
|
+
return null;
|
|
106
|
+
try {
|
|
107
|
+
return crypto.createPublicKey(priv).export({ type: 'spki', format: 'pem' });
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
// ─── CI signing material (env vars the dashboard CI/CD page issues) ─────────────
|
|
114
|
+
//
|
|
115
|
+
// The dashboard's CI/CD setup page documents these exact names. Honouring them
|
|
116
|
+
// here is what lets a publish that works locally (key on disk) work headless in
|
|
117
|
+
// CI (key in a secret) with no bridging step in the workflow.
|
|
118
|
+
exports.ENV_SIGNING_PRIVATE_KEY = 'SCALEBUN_SIGNING_PRIVATE_KEY';
|
|
119
|
+
exports.ENV_SIGNING_KEY_PASSPHRASE = 'SCALEBUN_SIGNING_KEY_PASSPHRASE';
|
|
120
|
+
exports.ENV_KEY_ID = 'SCALEBUN_KEY_ID';
|
|
121
|
+
exports.ENV_REQUIRE_SIGNING = 'SCALEBUN_REQUIRE_SIGNING';
|
|
122
|
+
/**
|
|
123
|
+
* A private-key secret often arrives with literal `\n` instead of real newlines
|
|
124
|
+
* (single-line CI secrets, some UIs). A PEM without real line breaks is invalid,
|
|
125
|
+
* so restore them — but only when there are no real newlines already.
|
|
126
|
+
*/
|
|
127
|
+
function normalizePem(raw) {
|
|
128
|
+
const s = raw.trim();
|
|
129
|
+
return s.includes('\\n') && !s.includes('\n') ? s.replace(/\\n/g, '\n') : s;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Resolve the RSA signing material with precedence **CLI flag > env var > local
|
|
133
|
+
* .scalebun file**, for each of: private key, key id, passphrase.
|
|
134
|
+
*
|
|
135
|
+
* private key : --key-dir <dir>/ota-signing-key.pem
|
|
136
|
+
* → $SCALEBUN_SIGNING_PRIVATE_KEY (PEM contents)
|
|
137
|
+
* → ./.scalebun/ota-signing-key.pem
|
|
138
|
+
* key id : --key-id → $SCALEBUN_KEY_ID → ./.scalebun/ota-key-id.txt
|
|
139
|
+
* passphrase : --passphrase → $SCALEBUN_SIGNING_KEY_PASSPHRASE
|
|
140
|
+
*/
|
|
141
|
+
function resolveSigningMaterial(opts) {
|
|
142
|
+
let privatePem = null;
|
|
143
|
+
let privateSource = 'none';
|
|
144
|
+
if (opts.keyDir) {
|
|
145
|
+
privatePem = readLocalPrivatePem(opts.keyDir);
|
|
146
|
+
privateSource = privatePem ? 'flag' : 'none';
|
|
147
|
+
}
|
|
148
|
+
else if (process.env[exports.ENV_SIGNING_PRIVATE_KEY]) {
|
|
149
|
+
privatePem = normalizePem(process.env[exports.ENV_SIGNING_PRIVATE_KEY]);
|
|
150
|
+
privateSource = 'env';
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
privatePem = readLocalPrivatePem();
|
|
154
|
+
privateSource = privatePem ? 'file' : 'none';
|
|
155
|
+
}
|
|
156
|
+
// Key id: --key-id > (the id file inside an explicit --key-dir) > SCALEBUN_KEY_ID
|
|
157
|
+
// > the default .scalebun id file. An explicit --key-dir binds the key AND its
|
|
158
|
+
// id together (both flag-level), so an unrelated SCALEBUN_KEY_ID in the env
|
|
159
|
+
// cannot pair a dir's key with the wrong id — a mismatch the device rejects.
|
|
160
|
+
let keyId = null;
|
|
161
|
+
let keyIdSource = 'none';
|
|
162
|
+
if (opts.keyId) {
|
|
163
|
+
keyId = opts.keyId;
|
|
164
|
+
keyIdSource = 'flag';
|
|
165
|
+
}
|
|
166
|
+
else if (opts.keyDir) {
|
|
167
|
+
keyId = readLocalKeyId(opts.keyDir);
|
|
168
|
+
keyIdSource = keyId ? 'flag' : 'none';
|
|
169
|
+
}
|
|
170
|
+
else if (process.env[exports.ENV_KEY_ID]) {
|
|
171
|
+
keyId = process.env[exports.ENV_KEY_ID].trim() || null;
|
|
172
|
+
keyIdSource = keyId ? 'env' : 'none';
|
|
173
|
+
}
|
|
174
|
+
else {
|
|
175
|
+
keyId = readLocalKeyId();
|
|
176
|
+
keyIdSource = keyId ? 'file' : 'none';
|
|
177
|
+
}
|
|
178
|
+
const passphrase = opts.passphrase ?? process.env[exports.ENV_SIGNING_KEY_PASSPHRASE] ?? undefined;
|
|
179
|
+
return { privatePem, privateSource, keyId, keyIdSource, passphrase };
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Whether this publish must be signed (so a missing key fails closed instead of
|
|
183
|
+
* silently shipping an unsigned release a signature-enforcing device rejects).
|
|
184
|
+
*
|
|
185
|
+
* True when: --require-signing / $SCALEBUN_REQUIRE_SIGNING is set, OR a key id is
|
|
186
|
+
* configured by any source (a configured key id means "sign with this key").
|
|
187
|
+
*/
|
|
188
|
+
function signingRequired(opts, material) {
|
|
189
|
+
if (opts.requireSigning)
|
|
190
|
+
return true;
|
|
191
|
+
const envFlag = process.env[exports.ENV_REQUIRE_SIGNING];
|
|
192
|
+
if (envFlag && envFlag !== '0' && envFlag.toLowerCase() !== 'false')
|
|
193
|
+
return true;
|
|
194
|
+
return material.keyId != null;
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Keep the private key out of version control WITHOUT relying on the operator
|
|
198
|
+
* reading a warning. An existing .gitignore is appended to; a missing one is
|
|
199
|
+
* created only inside an actual git repo. Returns a human-readable outcome.
|
|
200
|
+
*/
|
|
201
|
+
function ensureKeyDirGitignored(outDir, cwd = process.cwd()) {
|
|
202
|
+
const rel = path.relative(cwd, outDir);
|
|
203
|
+
if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
204
|
+
return `outside this project — add ${outDir} to that location's .gitignore yourself`;
|
|
205
|
+
}
|
|
206
|
+
const entry = rel.split(path.sep).join('/') + '/';
|
|
207
|
+
const gitignorePath = path.join(cwd, '.gitignore');
|
|
208
|
+
try {
|
|
209
|
+
if (fs.existsSync(gitignorePath)) {
|
|
210
|
+
const lines = fs.readFileSync(gitignorePath, 'utf8').split(/\r?\n/);
|
|
211
|
+
const covered = lines.some((l) => {
|
|
212
|
+
const t = l.trim();
|
|
213
|
+
return t === entry || t === entry.slice(0, -1) || t === '/' + entry || t === '/' + entry.slice(0, -1);
|
|
214
|
+
});
|
|
215
|
+
if (covered)
|
|
216
|
+
return `already ignored by .gitignore (${entry})`;
|
|
217
|
+
const body = fs.readFileSync(gitignorePath, 'utf8');
|
|
218
|
+
const sep = body.length === 0 || body.endsWith('\n') ? '' : '\n';
|
|
219
|
+
fs.appendFileSync(gitignorePath, `${sep}\n# ScaleBun OTA signing key — never commit\n${entry}\n`);
|
|
220
|
+
return `added ${entry} to .gitignore`;
|
|
221
|
+
}
|
|
222
|
+
if (fs.existsSync(path.join(cwd, '.git'))) {
|
|
223
|
+
fs.writeFileSync(gitignorePath, `# ScaleBun OTA signing key — never commit\n${entry}\n`);
|
|
224
|
+
return `created .gitignore with ${entry}`;
|
|
225
|
+
}
|
|
226
|
+
return `no git repo detected — if you version this directory, ignore ${entry}`;
|
|
227
|
+
}
|
|
228
|
+
catch (err) {
|
|
229
|
+
return `could not update .gitignore (${err?.message ?? err}) — add ${entry} yourself`;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
/** A friendly default keyId like `production-2026-01`. */
|
|
233
|
+
function defaultKeyId() {
|
|
234
|
+
const d = new Date();
|
|
235
|
+
return `key-${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
|
|
236
|
+
}
|
|
237
|
+
async function registerPublicKey(client, appId, body) {
|
|
238
|
+
return client.post(`/saas/apps/${appId}/ota/signing-keys`, body);
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Prove possession of a signing key without leaving the terminal.
|
|
242
|
+
*
|
|
243
|
+
* Registering a public key only asserts "trust this key". Proving possession
|
|
244
|
+
* shows the caller actually holds the private half — and until that happens the
|
|
245
|
+
* key is not ACTIVE and every promote is refused as high-risk. It is not
|
|
246
|
+
* optional in practice, so it should not be a separate errand.
|
|
247
|
+
*
|
|
248
|
+
* The private key never leaves this machine: the server mints the bytes, we sign
|
|
249
|
+
* those exact bytes, and only the signature goes back.
|
|
250
|
+
*/
|
|
251
|
+
async function proveOwnership(client, appId, keyRowId, privateKey) {
|
|
252
|
+
const opened = await client.post(`/saas/apps/${appId}/ota/signing-keys/${keyRowId}/challenges`, {});
|
|
253
|
+
const signature = crypto
|
|
254
|
+
.sign('sha256', Buffer.from(opened.payload, 'base64'), privateKey)
|
|
255
|
+
.toString('base64');
|
|
256
|
+
const res = await client.post(`/saas/apps/${appId}/ota/signing-keys/${keyRowId}/verify`, { challengeId: opened.challengeId, signature });
|
|
257
|
+
return res.verified !== false;
|
|
258
|
+
}
|
|
259
|
+
// ─── generate ─────────────────────────────────────────────────────────────────
|
|
260
|
+
async function runGenerate(opts) {
|
|
261
|
+
const algorithm = (opts.algorithm ?? 'rsa-3072').toLowerCase();
|
|
262
|
+
const m = /^rsa-(\d+)$/.exec(algorithm);
|
|
263
|
+
if (!m) {
|
|
264
|
+
(0, output_1.fail)(`Unsupported --algorithm "${opts.algorithm}".`, ['Use rsa-3072 (recommended) or rsa-2048.'], output_1.ExitCode.Usage);
|
|
265
|
+
}
|
|
266
|
+
const bits = parseInt(m[1], 10);
|
|
267
|
+
const outDir = path.resolve(opts.out ?? exports.KEY_DIR);
|
|
268
|
+
const privatePath = opts.privateKeyOut ? path.resolve(opts.privateKeyOut) : path.join(outDir, exports.PRIVATE_KEY_FILE);
|
|
269
|
+
const publicPath = opts.publicKeyOut ? path.resolve(opts.publicKeyOut) : path.join(outDir, exports.PUBLIC_KEY_FILE);
|
|
270
|
+
// Ask for the name when one was not supplied and a human is present. The key
|
|
271
|
+
// id is the label in the dashboard AND the id every signed release carries;
|
|
272
|
+
// it outlives this command by years, and `key-2026-09` says nothing about
|
|
273
|
+
// which app, environment or team it belongs to once there are several. A flag
|
|
274
|
+
// still wins, so scripts and CI are unaffected.
|
|
275
|
+
let keyId = (opts.keyId ?? defaultKeyId()).trim();
|
|
276
|
+
if (!opts.keyId && process.stdin.isTTY) {
|
|
277
|
+
const suggested = keyId;
|
|
278
|
+
const answer = (await (0, prompt_1.text)(`Name this signing key [${suggested}]`, { allowEmpty: true })).trim();
|
|
279
|
+
const cleaned = (answer || suggested).replace(/[^A-Za-z0-9._-]+/g, '-');
|
|
280
|
+
if (answer && cleaned !== answer) {
|
|
281
|
+
console.log(chalk_1.default.dim(` Using "${cleaned}" — a key id may contain only letters, digits, dot, dash or underscore.`));
|
|
282
|
+
}
|
|
283
|
+
keyId = cleaned;
|
|
284
|
+
}
|
|
285
|
+
if (fs.existsSync(privatePath) && !opts.force) {
|
|
286
|
+
// Telling someone to re-run with --force is a dead end when a human is
|
|
287
|
+
// sitting right there: it makes them retype the command to answer a
|
|
288
|
+
// yes/no question the CLI could simply ask. Ask it — and make the
|
|
289
|
+
// destructive option an explicit second confirmation, because overwriting
|
|
290
|
+
// strands every app that embeds only the old public key.
|
|
291
|
+
if (!process.stdin.isTTY) {
|
|
292
|
+
(0, output_1.fail)(`A signing key already exists at ${privatePath}.`, [
|
|
293
|
+
'Overwriting it means releases signed with the old key can no longer be verified',
|
|
294
|
+
'by apps that embed only the old public key. Pass --force if that is intended.',
|
|
295
|
+
], output_1.ExitCode.Usage);
|
|
296
|
+
}
|
|
297
|
+
console.log(`\n⚠️ A signing key already exists at ${privatePath}`);
|
|
298
|
+
const choice = await (0, prompt_1.select)('What would you like to do?', [
|
|
299
|
+
{ label: 'Keep the existing key', value: 'keep', hint: 'nothing is written' },
|
|
300
|
+
{
|
|
301
|
+
label: 'Overwrite it with a new keypair',
|
|
302
|
+
value: 'overwrite',
|
|
303
|
+
hint: 'apps embedding only the old public key stop accepting releases',
|
|
304
|
+
},
|
|
305
|
+
]);
|
|
306
|
+
if (choice === 'keep') {
|
|
307
|
+
console.log('\nKept the existing key. Nothing was written.\n');
|
|
308
|
+
(0, prompt_1.closePrompts)();
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
const sure = await (0, prompt_1.confirm)('Overwrite the existing signing key? Releases signed with the old key will no longer verify on apps that embed only it.');
|
|
312
|
+
if (!sure) {
|
|
313
|
+
console.log('\nCancelled. The existing key is untouched.\n');
|
|
314
|
+
(0, prompt_1.closePrompts)();
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
const { privatePem, publicPem, info } = (0, signing_1.generateRsaKeyPair)({ modulusBits: bits });
|
|
319
|
+
fs.mkdirSync(path.dirname(privatePath), { recursive: true });
|
|
320
|
+
// 0600 on POSIX; the ACL call is what actually protects it on Windows.
|
|
321
|
+
fs.writeFileSync(privatePath, privatePem, { mode: 0o600 });
|
|
322
|
+
(0, credentials_1.restrictToCurrentUser)(privatePath, 'your OTA signing private key');
|
|
323
|
+
fs.writeFileSync(publicPath, publicPem);
|
|
324
|
+
// Persist the keyId next to the key so publish/register can find it.
|
|
325
|
+
fs.writeFileSync(path.join(path.dirname(privatePath), exports.KEY_ID_FILE), keyId + '\n');
|
|
326
|
+
const gitignoreOutcome = ensureKeyDirGitignored(path.dirname(privatePath));
|
|
327
|
+
console.log(`\n🔑 RSA-${info.modulusBits} signing keypair generated.\n`);
|
|
328
|
+
console.log(` Key ID : ${keyId}`);
|
|
329
|
+
console.log(` Algorithm : RSA-${info.modulusBits} / SHA-256 (SB-OTA-RSA-SHA256-V1)`);
|
|
330
|
+
console.log(` Fingerprint : SHA-256:${info.fingerprintDisplay}`);
|
|
331
|
+
console.log(` Private key : ${privatePath}`);
|
|
332
|
+
console.log(` NEVER commit or share this file.`);
|
|
333
|
+
console.log(` Public key : ${publicPath}`);
|
|
334
|
+
console.log(` Git : ${gitignoreOutcome}\n`);
|
|
335
|
+
const appId = opts.appId || (0, context_1.getContext)().appId;
|
|
336
|
+
if (appId) {
|
|
337
|
+
const creds = (0, credentials_1.readCredentials)();
|
|
338
|
+
if (!creds) {
|
|
339
|
+
console.log('ℹ️ Not logged in — the keypair was written but not registered.');
|
|
340
|
+
console.log(` Register it later with:\n scalebun ota keys register --key-id ${keyId} --public-key ${publicPath}\n`);
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
const client = new client_1.ApiClient(creds);
|
|
344
|
+
try {
|
|
345
|
+
const row = await registerPublicKey(client, appId, {
|
|
346
|
+
publicKey: publicPem,
|
|
347
|
+
keyId,
|
|
348
|
+
algorithm: 'RSA_SHA256',
|
|
349
|
+
name: opts.name ?? `CLI ${new Date().toISOString().slice(0, 10)}`,
|
|
350
|
+
});
|
|
351
|
+
console.log('✅ Public key registered with ScaleBun.');
|
|
352
|
+
// Registration alone leaves the key UNVERIFIED, and an unverified key is
|
|
353
|
+
// refused at promote (`signing_key_ownership_unverified`). We are holding
|
|
354
|
+
// the private half at this exact moment, so finish the job here instead of
|
|
355
|
+
// sending the user to the dashboard to open a challenge by hand.
|
|
356
|
+
// Prefer the id the register call returned, but do not depend on its shape:
|
|
357
|
+
// fall back to matching our own public fingerprint against the app's keys,
|
|
358
|
+
// which is how `keys verify` resolves a row anyway.
|
|
359
|
+
const privateKeyObj = crypto.createPrivateKey(privatePem);
|
|
360
|
+
let rowId = row?.id;
|
|
361
|
+
if (!rowId) {
|
|
362
|
+
try {
|
|
363
|
+
rowId = await resolveKeyRowId(client, appId, undefined, crypto.createPublicKey(privateKeyObj));
|
|
364
|
+
}
|
|
365
|
+
catch { /* fall through to the manual hint below */ }
|
|
366
|
+
}
|
|
367
|
+
if (rowId) {
|
|
368
|
+
try {
|
|
369
|
+
const ok = await proveOwnership(client, appId, rowId, privateKeyObj);
|
|
370
|
+
console.log(ok
|
|
371
|
+
? '✅ Ownership proven — the key is ACTIVE and can sign releases.\n'
|
|
372
|
+
: '⚠️ Ownership was not confirmed; run `scalebun ota keys verify`.\n');
|
|
373
|
+
}
|
|
374
|
+
catch (err) {
|
|
375
|
+
console.log(chalk_1.default.yellow(`⚠️ Could not prove ownership automatically: ${err.message}`));
|
|
376
|
+
console.log(' Finish it with: scalebun ota keys verify\n');
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
else {
|
|
380
|
+
console.log(chalk_1.default.dim(' Prove ownership with: scalebun ota keys verify\n'));
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
catch (err) {
|
|
384
|
+
console.log(chalk_1.default.yellow(`⚠️ Could not register the public key: ${err.message}`));
|
|
385
|
+
console.log(` Retry: scalebun ota keys register --key-id ${keyId} --public-key ${publicPath}\n`);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
else {
|
|
389
|
+
console.log('ℹ️ Not a linked project — run `scalebun link` (or pass --app-id) to register this public key.\n');
|
|
390
|
+
}
|
|
391
|
+
console.log('Next: embed the PUBLIC key in your native app (Android assets/, iOS bundle');
|
|
392
|
+
console.log('resource) and pin its key ID. The SDK verifies every release against the');
|
|
393
|
+
console.log('embedded key and REFUSES any that does not match. See');
|
|
394
|
+
console.log('docs/ota/SB-OTA-RSA-SHA256-V1.md.\n');
|
|
395
|
+
}
|
|
396
|
+
async function runList(opts) {
|
|
397
|
+
const creds = (0, credentials_1.readCredentials)();
|
|
398
|
+
if (!creds)
|
|
399
|
+
(0, output_1.fail)('Not logged in. Run `scalebun login` first.', [], output_1.ExitCode.Usage);
|
|
400
|
+
const appId = await (0, resolve_1.resolveAppId)({ appId: opts.appId });
|
|
401
|
+
const client = new client_1.ApiClient(creds);
|
|
402
|
+
try {
|
|
403
|
+
const rows = await client.get(`/saas/apps/${appId}/ota/signing-keys`);
|
|
404
|
+
console.log(`\n🔑 Signing keys for app ${appId}:\n`);
|
|
405
|
+
if (!rows?.length) {
|
|
406
|
+
console.log(' (none — run `scalebun ota keys generate`)\n');
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
for (const k of rows) {
|
|
410
|
+
const state = k.revokedAt ? 'REVOKED' : 'active';
|
|
411
|
+
const alg = k.algorithm === 'ED25519' ? 'Ed25519 (legacy)' : k.algorithm ? k.algorithm.replace('_', '/') : 'unknown';
|
|
412
|
+
console.log(` • ${(k.name ?? 'unnamed').padEnd(22)} ${state.padEnd(8)} ${alg}`);
|
|
413
|
+
console.log(` id=${k.id}${k.keyId ? ` keyId=${k.keyId}` : ''}`);
|
|
414
|
+
}
|
|
415
|
+
console.log('');
|
|
416
|
+
}
|
|
417
|
+
catch (err) {
|
|
418
|
+
(0, output_1.fail)(`Failed to list keys: ${err.message}`, [], output_1.ExitCode.Rejected);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* Pull `--app-id` down from whichever command actually parsed it.
|
|
423
|
+
*
|
|
424
|
+
* THIS WAS SILENT AND IT MATTERED. `keys` is a three-level command
|
|
425
|
+
* (`ota keys list`), and the group declares `--app-id` so that a bare
|
|
426
|
+
* `ota keys --app-id X` works. Commander binds an option to the command whose
|
|
427
|
+
* declaration it matches, and the leaf then receives `appId: undefined` — so
|
|
428
|
+
* `ota keys register --app-id <other app>` fell back to scalebun.json and
|
|
429
|
+
* registered the public signing key against the WRONG APP, reporting success.
|
|
430
|
+
* `--app-id` was not rejected, not warned about, just ignored.
|
|
431
|
+
*
|
|
432
|
+
* Walking the parent chain fixes it wherever the flag landed, and keeps working
|
|
433
|
+
* if the nesting changes again. Explicit still beats ambient: this only fills in
|
|
434
|
+
* when the leaf's own value is absent.
|
|
435
|
+
*/
|
|
436
|
+
function mergeAppId(opts, cmd) {
|
|
437
|
+
if (opts?.appId)
|
|
438
|
+
return opts;
|
|
439
|
+
let node = cmd;
|
|
440
|
+
while (node) {
|
|
441
|
+
const o = typeof node.opts === 'function' ? node.opts() : null;
|
|
442
|
+
if (o && typeof o.appId === 'string' && o.appId)
|
|
443
|
+
return { ...(opts ?? {}), appId: o.appId };
|
|
444
|
+
node = node.parent;
|
|
445
|
+
}
|
|
446
|
+
return opts;
|
|
447
|
+
}
|
|
448
|
+
function registerOtaKeysCommand(ota) {
|
|
449
|
+
const keys = ota
|
|
450
|
+
.command('keys')
|
|
451
|
+
.description('Manage OTA release signing keys (RSA-3072 / SHA-256)')
|
|
452
|
+
// Declared on the GROUP, not only on `list`. Commander rejects a flag the
|
|
453
|
+
// command it lands on has not declared, so `ota keys --app-id X` died with
|
|
454
|
+
// "unknown option '--app-id'" even though the group defaults to list and
|
|
455
|
+
// runList needs exactly that flag. The default action is only useful if the
|
|
456
|
+
// options it consumes are accepted here too.
|
|
457
|
+
.option('--app-id <id>', 'Scalebun app ID (resolved from scalebun.json when omitted)')
|
|
458
|
+
.option('--json', 'Machine-readable output');
|
|
459
|
+
// Running `scalebun ota keys` with no subcommand — which is exactly what the
|
|
460
|
+
// interactive menu's "keys" entry does — used to print help and exit 1, a
|
|
461
|
+
// dead end. Default the group to `list`, the sensible landing action, so it
|
|
462
|
+
// shows the registered keys instead of a usage dump.
|
|
463
|
+
keys.action((opts, cmd) => runList(mergeAppId(opts ?? {}, cmd)));
|
|
464
|
+
keys
|
|
465
|
+
.command('generate')
|
|
466
|
+
.description('Generate an RSA signing keypair locally and register the public half')
|
|
467
|
+
.option('--algorithm <alg>', 'rsa-3072 (recommended) or rsa-2048', 'rsa-3072')
|
|
468
|
+
.option('--key-id <id>', 'Stable key identifier (e.g. production-2026-01)')
|
|
469
|
+
.option('--private-key-out <path>', 'Where to write the PKCS#8 private key')
|
|
470
|
+
.option('--public-key-out <path>', 'Where to write the SPKI public key')
|
|
471
|
+
.option('--out <dir>', `Directory for both keys (default: ./${exports.KEY_DIR})`)
|
|
472
|
+
.option('--app-id <id>', 'Register the public key against this app')
|
|
473
|
+
.option('--name <name>', 'Label for the key in the dashboard')
|
|
474
|
+
.option('--force', 'Overwrite an existing private key')
|
|
475
|
+
.action((o, c) => runGenerate(mergeAppId(o, c)));
|
|
476
|
+
keys
|
|
477
|
+
.command('inspect')
|
|
478
|
+
.description('Validate a public key and print its algorithm, size, and fingerprint')
|
|
479
|
+
.requiredOption('--public-key <path>', 'Path to an SPKI public-key PEM')
|
|
480
|
+
.action((opts) => {
|
|
481
|
+
const p = path.resolve(opts.publicKey);
|
|
482
|
+
if (!fs.existsSync(p))
|
|
483
|
+
(0, output_1.fail)(`No such file: ${p}`, [], output_1.ExitCode.Usage);
|
|
484
|
+
try {
|
|
485
|
+
const info = (0, signing_1.inspectPublicKey)(fs.readFileSync(p));
|
|
486
|
+
console.log('');
|
|
487
|
+
console.log(` Algorithm : RSA-${info.modulusBits} / SHA-256`);
|
|
488
|
+
console.log(` Modulus : ${info.modulusBits} bits${info.modulusBits < 3072 ? ' (2048 accepted; 3072 recommended)' : ''}`);
|
|
489
|
+
console.log(` Fingerprint : SHA-256:${info.fingerprintDisplay}`);
|
|
490
|
+
console.log(` Fingerprint : ${info.fingerprint} (hex)`);
|
|
491
|
+
console.log('');
|
|
492
|
+
}
|
|
493
|
+
catch (err) {
|
|
494
|
+
(0, output_1.fail)(`Invalid public key: ${err.message}`, [], output_1.ExitCode.Rejected);
|
|
495
|
+
}
|
|
496
|
+
});
|
|
497
|
+
keys
|
|
498
|
+
.command('register')
|
|
499
|
+
.description('Register an existing RSA public key (SPKI PEM) with an app')
|
|
500
|
+
.option('--app-id <id>', 'Scalebun app ID (resolved from scalebun.json when omitted)')
|
|
501
|
+
.option('--key-id <id>', 'Stable key identifier (defaults to the local key id)')
|
|
502
|
+
.option('--public-key <path>', 'SPKI public-key PEM path (defaults to the local ./.scalebun key)')
|
|
503
|
+
.option('--name <name>', 'Label for the key in the dashboard')
|
|
504
|
+
.action(async (opts) => {
|
|
505
|
+
const creds = (0, credentials_1.readCredentials)();
|
|
506
|
+
if (!creds)
|
|
507
|
+
(0, output_1.fail)('Not logged in. Run `scalebun login` first.', [], output_1.ExitCode.Usage);
|
|
508
|
+
const client = new client_1.ApiClient(creds);
|
|
509
|
+
const nonInteractive = (0, resolve_1.isNonInteractive)();
|
|
510
|
+
const appId = await (0, resolve_1.resolveAppId)({ appId: opts.appId });
|
|
511
|
+
let pem;
|
|
512
|
+
if (opts.publicKey) {
|
|
513
|
+
const p = path.resolve(opts.publicKey);
|
|
514
|
+
if (!fs.existsSync(p))
|
|
515
|
+
(0, output_1.fail)(`No such file: ${p}`, [], output_1.ExitCode.Usage);
|
|
516
|
+
pem = fs.readFileSync(p, 'utf8');
|
|
517
|
+
}
|
|
518
|
+
else {
|
|
519
|
+
pem = localPublicPem() ?? undefined;
|
|
520
|
+
if (!pem) {
|
|
521
|
+
(0, output_1.fail)('A public key is required.', ['Pass --public-key <path> to an SPKI PEM.'], output_1.ExitCode.Usage);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
let info;
|
|
525
|
+
try {
|
|
526
|
+
info = (0, signing_1.inspectPublicKey)(pem);
|
|
527
|
+
}
|
|
528
|
+
catch (err) {
|
|
529
|
+
(0, output_1.fail)(`Not a usable RSA public key: ${err.message}`, [], output_1.ExitCode.Rejected);
|
|
530
|
+
}
|
|
531
|
+
const keyId = (opts.keyId ?? readLocalKeyId() ?? defaultKeyId()).trim();
|
|
532
|
+
let name = opts.name?.trim();
|
|
533
|
+
if (!name && !nonInteractive) {
|
|
534
|
+
name = (await (0, prompt_1.text)('Key name (label shown in the dashboard)', { default: 'cli', allowEmpty: true })).trim();
|
|
535
|
+
}
|
|
536
|
+
(0, prompt_1.closePrompts)();
|
|
537
|
+
try {
|
|
538
|
+
await registerPublicKey(client, appId, {
|
|
539
|
+
publicKey: info.spkiPem,
|
|
540
|
+
keyId,
|
|
541
|
+
algorithm: 'RSA_SHA256',
|
|
542
|
+
name: name || 'CLI',
|
|
543
|
+
});
|
|
544
|
+
console.log(`\n✅ Public key registered (key ID ${keyId}, SHA-256:${info.fingerprintDisplay.slice(0, 23)}…).\n`);
|
|
545
|
+
}
|
|
546
|
+
catch (err) {
|
|
547
|
+
(0, output_1.fail)(`Failed to register public key: ${err.message}`, [], output_1.ExitCode.Rejected);
|
|
548
|
+
}
|
|
549
|
+
});
|
|
550
|
+
keys
|
|
551
|
+
.command('list')
|
|
552
|
+
.description('List signing keys registered for an app')
|
|
553
|
+
.option('--app-id <id>', 'Scalebun app ID (resolved from scalebun.json when omitted)')
|
|
554
|
+
.action((o, c) => runList(mergeAppId(o, c)));
|
|
555
|
+
keys
|
|
556
|
+
.command('verify')
|
|
557
|
+
.description('Prove possession of a signing key private half (proof-of-possession)')
|
|
558
|
+
.option('--challenge <id>', 'Challenge id to answer. Omit to open one automatically (the usual case).')
|
|
559
|
+
.option('--private-key <path>', 'PKCS#8 private-key PEM (defaults to the local ./.scalebun key)')
|
|
560
|
+
.option('--app-id <id>', 'Scalebun app ID (resolved from scalebun.json when omitted)')
|
|
561
|
+
.option('--key-id <id>', 'Signing key ROW id (auto-resolved from the local key when omitted)')
|
|
562
|
+
.option('--json', 'Machine-readable output')
|
|
563
|
+
.action((o, c) => runVerify(mergeAppId(o, c)));
|
|
564
|
+
keys
|
|
565
|
+
.command('revoke')
|
|
566
|
+
.description('Revoke a signing key — releases signed with it stop verifying')
|
|
567
|
+
.requiredOption('--key-id <id>', 'Signing key row id to revoke (from `scalebun ota keys list`)')
|
|
568
|
+
.option('--app-id <id>', 'Scalebun app ID (resolved from scalebun.json when omitted)')
|
|
569
|
+
.option('--reason <text>', 'Why the key is being revoked (recorded in the audit trail)')
|
|
570
|
+
.option('--yes', 'Skip the confirmation (required off a TTY)')
|
|
571
|
+
.option('--json', 'Machine-readable output')
|
|
572
|
+
.action((o, c) => runRevoke(mergeAppId(o, c)));
|
|
573
|
+
// ── Back-compat aliases (pre-1.14 top-level commands) ─────────────────────────
|
|
574
|
+
// These kept working across every prior release; forward them so scripts and
|
|
575
|
+
// muscle memory survive the move under `keys`.
|
|
576
|
+
ota
|
|
577
|
+
.command('generate-key-pair')
|
|
578
|
+
.description('(alias of `ota keys generate`)')
|
|
579
|
+
.option('--algorithm <alg>', 'rsa-3072 (recommended) or rsa-2048', 'rsa-3072')
|
|
580
|
+
.option('--key-id <id>', 'Stable key identifier')
|
|
581
|
+
.option('--app-id <id>', 'Register the public key against this app')
|
|
582
|
+
.option('--name <name>', 'Label for the key in the dashboard')
|
|
583
|
+
.option('--out <dir>', `Directory for the keys (default: ./${exports.KEY_DIR})`)
|
|
584
|
+
.option('--force', 'Overwrite an existing private key')
|
|
585
|
+
.action((o, c) => runGenerate(mergeAppId(o, c)));
|
|
586
|
+
ota
|
|
587
|
+
.command('revoke-key')
|
|
588
|
+
.description('(alias of `ota keys revoke`)')
|
|
589
|
+
.requiredOption('--key-id <id>', 'Signing key row id to revoke')
|
|
590
|
+
.option('--app-id <id>', 'Scalebun app ID')
|
|
591
|
+
.option('--yes', 'Skip the confirmation')
|
|
592
|
+
.option('--json', 'Machine-readable output')
|
|
593
|
+
.action((o, c) => runRevoke(mergeAppId(o, c)));
|
|
594
|
+
}
|
|
595
|
+
/**
|
|
596
|
+
* Resolve the signing-key ROW id for a verify: prefer --key-id, else match the
|
|
597
|
+
* local private key's public fingerprint against the app's registered keys.
|
|
598
|
+
*/
|
|
599
|
+
async function resolveKeyRowId(client, appId, explicitKeyId, localPubKey) {
|
|
600
|
+
if (explicitKeyId)
|
|
601
|
+
return explicitKeyId;
|
|
602
|
+
if (!localPubKey) {
|
|
603
|
+
(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);
|
|
604
|
+
}
|
|
605
|
+
const localFp = (0, signing_1.spkiFingerprint)(localPubKey).hex;
|
|
606
|
+
let rows;
|
|
607
|
+
try {
|
|
608
|
+
rows = await client.get(`/saas/apps/${appId}/ota/signing-keys`);
|
|
609
|
+
}
|
|
610
|
+
catch (err) {
|
|
611
|
+
(0, output_1.failFromError)('Could not list signing keys', err);
|
|
612
|
+
}
|
|
613
|
+
const match = (rows ?? []).find((k) => (k.fingerprint ?? '').toLowerCase() === localFp.toLowerCase());
|
|
614
|
+
if (!match) {
|
|
615
|
+
(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);
|
|
616
|
+
}
|
|
617
|
+
return match.id;
|
|
618
|
+
}
|
|
619
|
+
async function runVerify(opts) {
|
|
620
|
+
const creds = (0, credentials_1.readCredentials)();
|
|
621
|
+
if (!creds)
|
|
622
|
+
(0, output_1.fail)('Not signed in.', ['Run `scalebun login` first.'], output_1.ExitCode.Usage);
|
|
623
|
+
const appId = await (0, resolve_1.resolveAppId)({ appId: opts.appId, nonInteractive: opts.json });
|
|
624
|
+
const client = new client_1.ApiClient(creds);
|
|
625
|
+
// Load the private key locally — it NEVER leaves this machine.
|
|
626
|
+
let privatePem;
|
|
627
|
+
if (opts.privateKey) {
|
|
628
|
+
const p = path.resolve(opts.privateKey);
|
|
629
|
+
if (!fs.existsSync(p))
|
|
630
|
+
(0, output_1.fail)(`No such file: ${p}`, [], output_1.ExitCode.Usage);
|
|
631
|
+
privatePem = fs.readFileSync(p, 'utf8');
|
|
632
|
+
}
|
|
633
|
+
else {
|
|
634
|
+
privatePem = readLocalPrivatePem();
|
|
635
|
+
if (!privatePem) {
|
|
636
|
+
(0, output_1.fail)('A private key is required.', ['Pass --private-key <path> to a PKCS#8 PEM.'], output_1.ExitCode.Usage);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
let privateKey;
|
|
640
|
+
let localPubKey = null;
|
|
641
|
+
try {
|
|
642
|
+
privateKey = crypto.createPrivateKey(privatePem);
|
|
643
|
+
localPubKey = crypto.createPublicKey(privateKey);
|
|
644
|
+
}
|
|
645
|
+
catch (err) {
|
|
646
|
+
(0, output_1.fail)(`Could not read the private key: ${err.message}`, [], output_1.ExitCode.Rejected);
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
const keyRowId = await resolveKeyRowId(client, appId, opts.keyId, localPubKey);
|
|
650
|
+
// Either bytes the server already stored, or bytes it mints for us now. Either
|
|
651
|
+
// way they come FROM the server and are signed unmodified — nothing is invented
|
|
652
|
+
// locally, which is the whole point of proof-of-possession.
|
|
653
|
+
//
|
|
654
|
+
// `--challenge` used to be required, and only the dashboard could open one. That
|
|
655
|
+
// made a key registered from the CLI unverifiable from the CLI: you had to leave
|
|
656
|
+
// the terminal, find the key in the console, click through a wizard, and paste an
|
|
657
|
+
// id back. Worse, the challenge UI lived only inside the *register* wizard, so a
|
|
658
|
+
// key created by `ota keys generate` had no route to verification at all — and an
|
|
659
|
+
// unverified key is refused at promote (`signing_key_ownership_unverified`)
|
|
660
|
+
// forever. The endpoint was always callable with these same credentials; nothing
|
|
661
|
+
// but wiring was missing.
|
|
662
|
+
let challenge;
|
|
663
|
+
let challengeId = opts.challenge;
|
|
664
|
+
if (opts.challenge) {
|
|
665
|
+
try {
|
|
666
|
+
challenge = await client.get(`/saas/apps/${appId}/ota/signing-keys/${keyRowId}/challenges/${opts.challenge}`);
|
|
667
|
+
}
|
|
668
|
+
catch (err) {
|
|
669
|
+
(0, output_1.failFromError)('Could not fetch the challenge', err, [
|
|
670
|
+
'Check the challenge id and that it has not expired.',
|
|
671
|
+
]);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
else {
|
|
675
|
+
try {
|
|
676
|
+
const opened = await client.post(`/saas/apps/${appId}/ota/signing-keys/${keyRowId}/challenges`, {});
|
|
677
|
+
challenge = { payload: opened.payload, expiresAt: opened.expiresAt };
|
|
678
|
+
challengeId = opened.challengeId;
|
|
679
|
+
}
|
|
680
|
+
catch (err) {
|
|
681
|
+
(0, output_1.failFromError)('Could not open a verification challenge', err, [
|
|
682
|
+
'You need the ota.keys.verify permission on this app.',
|
|
683
|
+
'Or pass --challenge <id> for one opened from the dashboard.',
|
|
684
|
+
]);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
if (challenge.consumed) {
|
|
688
|
+
(0, output_1.fail)('This challenge was already used.', ['Request a fresh challenge and retry.'], output_1.ExitCode.Rejected);
|
|
689
|
+
}
|
|
690
|
+
const signature = crypto
|
|
691
|
+
.sign('sha256', Buffer.from(challenge.payload, 'base64'), privateKey)
|
|
692
|
+
.toString('base64');
|
|
693
|
+
let result;
|
|
694
|
+
try {
|
|
695
|
+
result = await client.post(`/saas/apps/${appId}/ota/signing-keys/${keyRowId}/verify`, { challengeId, signature });
|
|
696
|
+
}
|
|
697
|
+
catch (err) {
|
|
698
|
+
(0, output_1.failFromError)('Ownership verification failed', err, [
|
|
699
|
+
'The signature did not verify against the registered public key, or the challenge expired.',
|
|
700
|
+
]);
|
|
701
|
+
}
|
|
702
|
+
(0, output_1.emit)(() => {
|
|
703
|
+
console.log(`\n✅ Ownership verified for key ${keyRowId}.`);
|
|
704
|
+
if (result.activated)
|
|
705
|
+
console.log(' The key is now ACTIVE.');
|
|
706
|
+
else if (result.status)
|
|
707
|
+
console.log(` Key status: ${result.status}`);
|
|
708
|
+
console.log('');
|
|
709
|
+
}, { ok: true, keyId: keyRowId, verified: !!result.verified, activated: !!result.activated, status: result.status });
|
|
710
|
+
}
|
|
711
|
+
async function runRevoke(opts) {
|
|
712
|
+
const creds = (0, credentials_1.readCredentials)();
|
|
713
|
+
if (!creds)
|
|
714
|
+
(0, output_1.fail)('Not signed in.', ['Run `scalebun login` first.'], output_1.ExitCode.Usage);
|
|
715
|
+
const appId = await (0, resolve_1.resolveAppId)({ appId: opts.appId, nonInteractive: opts.json });
|
|
716
|
+
const client = new client_1.ApiClient(creds);
|
|
717
|
+
let rows;
|
|
718
|
+
try {
|
|
719
|
+
rows = await client.get(`/saas/apps/${appId}/ota/signing-keys`);
|
|
720
|
+
}
|
|
721
|
+
catch (err) {
|
|
722
|
+
(0, output_1.failFromError)('Could not list signing keys', err);
|
|
723
|
+
}
|
|
724
|
+
const target = (rows ?? []).find((k) => k.id === opts.keyId);
|
|
725
|
+
if (!target) {
|
|
726
|
+
(0, output_1.fail)(`No signing key ${opts.keyId} on this app.`, (rows ?? []).map((k) => ` ${k.id} ${k.name ?? 'unnamed'}`), output_1.ExitCode.Usage);
|
|
727
|
+
}
|
|
728
|
+
if (target.revokedAt) {
|
|
729
|
+
(0, output_1.emit)(() => console.log(`\n${chalk_1.default.dim(`${target.name ?? target.id} is already revoked.`)}\n`), {
|
|
730
|
+
ok: true,
|
|
731
|
+
keyId: target.id,
|
|
732
|
+
alreadyRevoked: true,
|
|
733
|
+
});
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
if (!opts.yes) {
|
|
737
|
+
if ((0, resolve_1.isNonInteractive)({ nonInteractive: opts.json })) {
|
|
738
|
+
(0, output_1.fail)('Refusing to revoke a signing key without confirmation.', ['Pass --yes if this is intentional.'], output_1.ExitCode.Usage);
|
|
739
|
+
}
|
|
740
|
+
console.log('');
|
|
741
|
+
console.log(chalk_1.default.yellow(`⚠️ Revoking ${chalk_1.default.bold(target.name ?? target.id)}.\n` +
|
|
742
|
+
' Apps pinning ONLY this key will refuse every future update — fail-closed by\n' +
|
|
743
|
+
' design. Register the replacement and ship a build embedding both first.'));
|
|
744
|
+
const ok = await (0, prompt_1.confirmPhrase)(' Type "revoke" to confirm: ', 'revoke', { caseInsensitive: true });
|
|
745
|
+
(0, prompt_1.closePrompts)();
|
|
746
|
+
if (!ok) {
|
|
747
|
+
console.log('Cancelled — the key is still active.');
|
|
748
|
+
process.exit(output_1.ExitCode.Ok);
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
try {
|
|
752
|
+
await client.post(`/saas/apps/${appId}/ota/signing-keys/${target.id}/revoke`, {
|
|
753
|
+
reason: (opts.reason ?? '').trim() || 'Revoked via CLI',
|
|
754
|
+
});
|
|
755
|
+
}
|
|
756
|
+
catch (err) {
|
|
757
|
+
(0, output_1.failFromError)('Could not revoke the key', err, ['Revoking a signing key requires the ota.keys.revoke permission.']);
|
|
758
|
+
}
|
|
759
|
+
(0, output_1.emit)(() => console.log(`\n🔒 Revoked ${chalk_1.default.bold(target.name ?? target.id)}\n`), {
|
|
760
|
+
ok: true,
|
|
761
|
+
keyId: target.id,
|
|
762
|
+
revoked: true,
|
|
763
|
+
});
|
|
764
|
+
}
|
|
765
|
+
//# sourceMappingURL=ota-keys.js.map
|