ubriot 1.2.0 → 1.4.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/README.md +21 -0
- package/dist/api.js +24 -10
- package/dist/app-config-signing.js +67 -0
- package/dist/binary-certificate.js +93 -0
- package/dist/build-identity.js +72 -0
- package/dist/build-preflight.js +99 -0
- package/dist/child-env.js +10 -0
- package/dist/config.js +23 -13
- package/dist/index.js +565 -54
- package/dist/ota-signing.js +271 -0
- package/dist/play-options.js +22 -0
- package/dist/publish-signing.js +74 -0
- package/dist/runtime-preflight.js +36 -0
- package/dist/signing-commands.js +609 -0
- package/dist/unreal-detect.js +69 -0
- package/dist/x509.js +117 -0
- package/package.json +4 -1
|
@@ -0,0 +1,609 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.resignGroups = resignGroups;
|
|
4
|
+
exports.registerSigningCommands = registerSigningCommands;
|
|
5
|
+
const fs_1 = require("fs");
|
|
6
|
+
const path_1 = require("path");
|
|
7
|
+
const api_1 = require("./api");
|
|
8
|
+
const config_1 = require("./config");
|
|
9
|
+
const readline_1 = require("./readline");
|
|
10
|
+
const ota_signing_1 = require("./ota-signing");
|
|
11
|
+
const app_config_signing_1 = require("./app-config-signing");
|
|
12
|
+
const publish_signing_1 = require("./publish-signing");
|
|
13
|
+
const binary_certificate_1 = require("./binary-certificate");
|
|
14
|
+
function collect(value, previous) {
|
|
15
|
+
return [...previous, value];
|
|
16
|
+
}
|
|
17
|
+
function safeName(value) {
|
|
18
|
+
return value.replace(/[^A-Za-z0-9._-]/g, "-");
|
|
19
|
+
}
|
|
20
|
+
function todayKeyid(prefix) {
|
|
21
|
+
return `${prefix}-${new Date().toISOString().slice(0, 10).replace(/-/g, "")}`;
|
|
22
|
+
}
|
|
23
|
+
function projectRelative(projectDir, path) {
|
|
24
|
+
const rel = (0, path_1.relative)(projectDir, path);
|
|
25
|
+
return rel.startsWith("..") ? rel : `./${rel}`;
|
|
26
|
+
}
|
|
27
|
+
function interactive() {
|
|
28
|
+
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
29
|
+
}
|
|
30
|
+
async function requireStatus(client, app) {
|
|
31
|
+
const status = await (0, publish_signing_1.fetchSigningStatus)(client, app);
|
|
32
|
+
if (!status)
|
|
33
|
+
throw new Error("This Ubriot API does not support OTA code signing yet.");
|
|
34
|
+
return status;
|
|
35
|
+
}
|
|
36
|
+
function findKey(status, keyid) {
|
|
37
|
+
const key = status.keys.find((k) => k.keyid === keyid && k.status !== "revoked") ?? status.keys.find((k) => k.keyid === keyid);
|
|
38
|
+
if (!key)
|
|
39
|
+
throw new Error(`No signing key with keyid "${keyid}" on this app`);
|
|
40
|
+
return key;
|
|
41
|
+
}
|
|
42
|
+
async function registerKey(client, app, body) {
|
|
43
|
+
const { data } = await client.post(`/ubriot/apps/${encodeURIComponent(app)}/ota-signing/keys`, body);
|
|
44
|
+
return data;
|
|
45
|
+
}
|
|
46
|
+
async function resignGroups(client, app, materials, groupId) {
|
|
47
|
+
const { data: targets } = await client.get(`/ubriot/apps/${encodeURIComponent(app)}/ota-signing/resign-targets`);
|
|
48
|
+
const selected = groupId ? targets.filter((t) => t.updateGroupId === groupId) : targets;
|
|
49
|
+
if (groupId && selected.length === 0) {
|
|
50
|
+
const { data } = await client.get(`/ubriot/apps/${encodeURIComponent(app)}/updates`);
|
|
51
|
+
if (!Array.isArray(data) || !data.some((g) => g.id === groupId))
|
|
52
|
+
throw new Error(`Update group #${groupId} not found`);
|
|
53
|
+
throw new Error(`Update group #${groupId} is not live on any channel. Only groups a channel points at, now or as its rollback target, are re-signed.`);
|
|
54
|
+
}
|
|
55
|
+
const byGroup = new Map();
|
|
56
|
+
for (const target of selected) {
|
|
57
|
+
const entries = byGroup.get(target.updateGroupId) ?? [];
|
|
58
|
+
entries.push({ platform: target.platform, signatures: materials.map((m) => (0, ota_signing_1.signManifest)(target.manifestJson, m)) });
|
|
59
|
+
byGroup.set(target.updateGroupId, entries);
|
|
60
|
+
}
|
|
61
|
+
for (const [id, platforms] of byGroup) {
|
|
62
|
+
await client.post(`/ubriot/apps/${encodeURIComponent(app)}/updates/${id}/signatures`, { platforms });
|
|
63
|
+
}
|
|
64
|
+
return [...byGroup.keys()];
|
|
65
|
+
}
|
|
66
|
+
async function confirmBackup(paths, yes) {
|
|
67
|
+
console.log("\nBack up these private keys now (password manager or offline storage). If they are lost, devices that trust them can only be fixed with a new native build:");
|
|
68
|
+
for (const path of paths)
|
|
69
|
+
console.log(` ${path}`);
|
|
70
|
+
if (yes)
|
|
71
|
+
return true;
|
|
72
|
+
const answer = await (0, readline_1.readlineSync)('Type "backed up" once the keys are stored safely: ');
|
|
73
|
+
return answer.trim().toLowerCase() === "backed up";
|
|
74
|
+
}
|
|
75
|
+
function printNextSteps(app) {
|
|
76
|
+
console.log("\nNext steps:");
|
|
77
|
+
console.log(` 1. ubriot signing configure --app ${app}`);
|
|
78
|
+
console.log(" 2. Bump runtimeVersion and ship a new native build. Old binaries keep updating unsigned.");
|
|
79
|
+
console.log(` 3. Publish as usual; updates are signed automatically. When old binaries are gone: ubriot signing require --app ${app}`);
|
|
80
|
+
}
|
|
81
|
+
async function createIdentity(opts) {
|
|
82
|
+
if (!opts.yes && !interactive())
|
|
83
|
+
throw new Error("Run this in a terminal to confirm the key backup, or pass --yes once you have a backup plan.");
|
|
84
|
+
const status = await requireStatus(opts.client, opts.app);
|
|
85
|
+
if (status.custody === "managed")
|
|
86
|
+
throw new Error("This app uses Ubriot-managed signing keys. Customer keys and managed keys are never mixed on one app.");
|
|
87
|
+
if (status.keys.some((k) => k.keyid === opts.keyid && k.status !== "revoked"))
|
|
88
|
+
throw new Error(`keyid "${opts.keyid}" is already registered. Pick another --keyid.`);
|
|
89
|
+
(0, ota_signing_1.assertKeyDirOutsideGit)(opts.keyDir, opts.allowInRepo);
|
|
90
|
+
const certificatePath = (0, path_1.join)(opts.certDir, `ubriot-ota-${safeName(opts.keyid)}.pem`);
|
|
91
|
+
const privateKeyPath = (0, path_1.join)(opts.keyDir, `${safeName(opts.app)}-${safeName(opts.keyid)}.private.pem`);
|
|
92
|
+
const written = [];
|
|
93
|
+
let body;
|
|
94
|
+
let rootCertificatePath;
|
|
95
|
+
if (opts.mode === "simple") {
|
|
96
|
+
const identity = (0, ota_signing_1.generateSimpleIdentity)({ commonName: `${opts.app} OTA ${opts.keyid}`, validityYears: opts.validityYears });
|
|
97
|
+
(0, ota_signing_1.writePrivateKey)(privateKeyPath, identity.privateKeyPem);
|
|
98
|
+
(0, ota_signing_1.writePublicFile)(certificatePath, identity.certificatePem);
|
|
99
|
+
written.push(privateKeyPath);
|
|
100
|
+
body = { keyid: opts.keyid, mode: "simple", certificatePem: identity.certificatePem };
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
const root = (0, ota_signing_1.generateChainRoot)({ commonName: `${opts.app} OTA root`, validityYears: opts.validityYears });
|
|
104
|
+
const rootKeyPath = (0, path_1.join)(opts.keyDir, `${safeName(opts.app)}-root.private.pem`);
|
|
105
|
+
rootCertificatePath = (0, path_1.join)(opts.certDir, "ubriot-ota-root.pem");
|
|
106
|
+
const leaf = (0, ota_signing_1.issueLeaf)({ rootPrivateKeyPem: root.privateKeyPem, rootCertificatePem: root.certificatePem, commonName: `${opts.app} OTA ${opts.keyid}`, validityDays: opts.leafValidityDays });
|
|
107
|
+
(0, ota_signing_1.writePrivateKey)(rootKeyPath, root.privateKeyPem);
|
|
108
|
+
(0, ota_signing_1.writePrivateKey)(privateKeyPath, leaf.privateKeyPem);
|
|
109
|
+
(0, ota_signing_1.writePublicFile)(rootCertificatePath, root.certificatePem);
|
|
110
|
+
(0, ota_signing_1.writePublicFile)(certificatePath, leaf.certificatePem);
|
|
111
|
+
written.push(rootKeyPath, privateKeyPath);
|
|
112
|
+
console.log(`\nChain mode: keep ${rootKeyPath} offline. It is only needed to issue new signing leaves (ubriot signing issue-leaf).`);
|
|
113
|
+
body = { keyid: opts.keyid, mode: "chain", certificatePem: leaf.certificatePem, rootCertificatePem: root.certificatePem };
|
|
114
|
+
}
|
|
115
|
+
if (!(await confirmBackup(written, opts.yes))) {
|
|
116
|
+
console.log(`Keys were written but not registered. Register them later with: ubriot signing import --app ${opts.app} --keyid ${opts.keyid} --certificate ${certificatePath}`);
|
|
117
|
+
process.exitCode = 1;
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
const registered = await registerKey(opts.client, opts.app, body);
|
|
121
|
+
(0, ota_signing_1.upsertSigningConfigKey)(opts.projectDir, opts.app, {
|
|
122
|
+
keyid: opts.keyid,
|
|
123
|
+
mode: opts.mode,
|
|
124
|
+
privateKeyPath: projectRelative(opts.projectDir, privateKeyPath),
|
|
125
|
+
certificatePath: projectRelative(opts.projectDir, certificatePath),
|
|
126
|
+
...(rootCertificatePath ? { rootCertificatePath: projectRelative(opts.projectDir, rootCertificatePath) } : {}),
|
|
127
|
+
});
|
|
128
|
+
console.log(`Registered ${opts.mode} signing key "${opts.keyid}" (SHA-256 ${registered.fingerprintSha256}). Certificate: ${certificatePath}`);
|
|
129
|
+
return registered;
|
|
130
|
+
}
|
|
131
|
+
function registerSigningCommands(program, updates) {
|
|
132
|
+
const signing = program.command("signing").description("Manage OTA code signing keys");
|
|
133
|
+
signing
|
|
134
|
+
.command("init")
|
|
135
|
+
.description("Generate a signing key and certificate, keep the private key locally, register the certificate")
|
|
136
|
+
.requiredOption("--app <appIdOrSlug>")
|
|
137
|
+
.option("--mode <mode>", "simple or chain", "simple")
|
|
138
|
+
.option("--keyid <keyid>", "Key id embedded in the app (simple mode default: main)")
|
|
139
|
+
.option("--key-dir <dir>", "Where private keys are written; must be outside the git repository", "../ubriot-keys")
|
|
140
|
+
.option("--cert-dir <dir>", "Where public certificates are written, inside the project", "certs")
|
|
141
|
+
.option("--project-dir <dir>", "App project directory", ".")
|
|
142
|
+
.option("--validity-years <n>", "Certificate validity (root validity in chain mode)", "10")
|
|
143
|
+
.option("--leaf-validity-days <n>", "Chain mode leaf validity in days", "365")
|
|
144
|
+
.option("--allow-in-repo", "Allow writing private keys inside the git working tree", false)
|
|
145
|
+
.option("--yes", "Skip the backup confirmation prompt", false)
|
|
146
|
+
.action(async (opts) => {
|
|
147
|
+
(0, config_1.requireAuth)();
|
|
148
|
+
try {
|
|
149
|
+
if (opts.mode !== "simple" && opts.mode !== "chain")
|
|
150
|
+
throw new Error("--mode must be simple or chain");
|
|
151
|
+
const projectDir = (0, path_1.resolve)(opts.projectDir);
|
|
152
|
+
const registered = await createIdentity({
|
|
153
|
+
client: (0, api_1.makeClient)(),
|
|
154
|
+
app: opts.app,
|
|
155
|
+
mode: opts.mode,
|
|
156
|
+
keyid: opts.keyid ?? (opts.mode === "simple" ? "main" : todayKeyid("leaf")),
|
|
157
|
+
projectDir,
|
|
158
|
+
keyDir: (0, path_1.resolve)(projectDir, opts.keyDir),
|
|
159
|
+
certDir: (0, path_1.resolve)(projectDir, opts.certDir),
|
|
160
|
+
validityYears: Number(opts.validityYears),
|
|
161
|
+
leafValidityDays: Number(opts.leafValidityDays),
|
|
162
|
+
yes: opts.yes === true,
|
|
163
|
+
allowInRepo: opts.allowInRepo === true,
|
|
164
|
+
});
|
|
165
|
+
if (registered)
|
|
166
|
+
printNextSteps(opts.app);
|
|
167
|
+
}
|
|
168
|
+
catch (err) {
|
|
169
|
+
(0, api_1.handleError)(err);
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
signing
|
|
173
|
+
.command("import")
|
|
174
|
+
.description("Register an existing certificate, for example one from EAS Update code signing")
|
|
175
|
+
.requiredOption("--app <appIdOrSlug>")
|
|
176
|
+
.requiredOption("--certificate <path>")
|
|
177
|
+
.requiredOption("--keyid <keyid>")
|
|
178
|
+
.option("--mode <mode>", "simple or chain", "simple")
|
|
179
|
+
.option("--root-certificate <path>", "Chain mode: the root certificate embedded in the app")
|
|
180
|
+
.option("--chain <path>", "Chain mode: intermediate certificates")
|
|
181
|
+
.option("--private-key-path <path>", "Local private key path to remember in .ubriot/signing.json (never uploaded)")
|
|
182
|
+
.option("--project-dir <dir>", "App project directory", ".")
|
|
183
|
+
.action(async (opts) => {
|
|
184
|
+
(0, config_1.requireAuth)();
|
|
185
|
+
try {
|
|
186
|
+
const projectDir = (0, path_1.resolve)(opts.projectDir);
|
|
187
|
+
const certificatePem = (0, fs_1.readFileSync)((0, path_1.resolve)(projectDir, opts.certificate), "utf8");
|
|
188
|
+
if (opts.privateKeyPath) {
|
|
189
|
+
const privateKeyPem = (0, fs_1.readFileSync)((0, path_1.resolve)(projectDir, opts.privateKeyPath), "utf8");
|
|
190
|
+
if (!(0, ota_signing_1.privateKeyMatchesCertificate)(privateKeyPem, certificatePem))
|
|
191
|
+
throw new Error("The private key does not match the certificate");
|
|
192
|
+
}
|
|
193
|
+
const registered = await registerKey((0, api_1.makeClient)(), opts.app, {
|
|
194
|
+
keyid: opts.keyid,
|
|
195
|
+
mode: opts.mode,
|
|
196
|
+
certificatePem,
|
|
197
|
+
...(opts.rootCertificate ? { rootCertificatePem: (0, fs_1.readFileSync)((0, path_1.resolve)(projectDir, opts.rootCertificate), "utf8") } : {}),
|
|
198
|
+
...(opts.chain ? { chainPem: (0, fs_1.readFileSync)((0, path_1.resolve)(projectDir, opts.chain), "utf8") } : {}),
|
|
199
|
+
});
|
|
200
|
+
if (opts.privateKeyPath) {
|
|
201
|
+
(0, ota_signing_1.upsertSigningConfigKey)(projectDir, opts.app, {
|
|
202
|
+
keyid: opts.keyid,
|
|
203
|
+
mode: opts.mode,
|
|
204
|
+
privateKeyPath: projectRelative(projectDir, (0, path_1.resolve)(projectDir, opts.privateKeyPath)),
|
|
205
|
+
certificatePath: projectRelative(projectDir, (0, path_1.resolve)(projectDir, opts.certificate)),
|
|
206
|
+
...(opts.rootCertificate ? { rootCertificatePath: projectRelative(projectDir, (0, path_1.resolve)(projectDir, opts.rootCertificate)) } : {}),
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
console.log(`Registered "${registered.keyid}" (SHA-256 ${registered.fingerprintSha256}). Binaries that embed this certificate keep receiving updates signed with it.`);
|
|
210
|
+
}
|
|
211
|
+
catch (err) {
|
|
212
|
+
(0, api_1.handleError)(err);
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
signing
|
|
216
|
+
.command("configure")
|
|
217
|
+
.description("Write the code signing certificate into app.json (expo-updates or ubriot-updates-rn)")
|
|
218
|
+
.requiredOption("--app <appIdOrSlug>")
|
|
219
|
+
.option("--keyid <keyid>", "Key from .ubriot/signing.json to embed")
|
|
220
|
+
.option("--expo-config <path>", "Path to app.json", "app.json")
|
|
221
|
+
.option("--also-trust <keyid>", "ubriot-updates-rn only: also embed this local key as a pre-provisioned rotation key (repeatable)", collect, [])
|
|
222
|
+
.option("--certificate <path>", "Embed this certificate instead of a key from .ubriot/signing.json (for Ubriot-managed keys)")
|
|
223
|
+
.option("--mode <mode>", "With --certificate: simple or chain", "simple")
|
|
224
|
+
.option("--project-dir <dir>", "App project directory", ".")
|
|
225
|
+
.action((opts) => {
|
|
226
|
+
try {
|
|
227
|
+
const projectDir = (0, path_1.resolve)(opts.projectDir);
|
|
228
|
+
const config = (0, ota_signing_1.readSigningConfig)(projectDir) ?? { keys: [] };
|
|
229
|
+
if (opts.certificate && opts.mode !== "simple" && opts.mode !== "chain")
|
|
230
|
+
throw new Error("--mode must be simple or chain");
|
|
231
|
+
const key = opts.certificate
|
|
232
|
+
? { keyid: opts.keyid ?? "root", mode: opts.mode, certificatePath: opts.certificate, rootCertificatePath: opts.certificate, privateKeyPath: "" }
|
|
233
|
+
: opts.keyid
|
|
234
|
+
? config.keys.find((k) => k.keyid === opts.keyid)
|
|
235
|
+
: config.keys[config.keys.length - 1];
|
|
236
|
+
if (!key)
|
|
237
|
+
throw new Error(opts.keyid ? `No local key "${opts.keyid}" in .ubriot/signing.json` : `No keys in .ubriot/signing.json. Run: ubriot signing init --app ${opts.app}`);
|
|
238
|
+
const certificatePath = key.mode === "chain" ? key.rootCertificatePath : key.certificatePath;
|
|
239
|
+
if (!certificatePath)
|
|
240
|
+
throw new Error("Chain mode needs rootCertificatePath in .ubriot/signing.json");
|
|
241
|
+
const additionalCertificates = opts.alsoTrust.map((keyid) => {
|
|
242
|
+
const extra = config.keys.find((k) => k.keyid === keyid);
|
|
243
|
+
if (!extra)
|
|
244
|
+
throw new Error(`No local key "${keyid}" in .ubriot/signing.json`);
|
|
245
|
+
return { certificate: extra.certificatePath, keyid: extra.keyid };
|
|
246
|
+
});
|
|
247
|
+
const input = { certificatePath, keyid: key.mode === "chain" ? "root" : key.keyid, mode: key.mode, additionalCertificates };
|
|
248
|
+
const appJsonPath = (0, path_1.resolve)(projectDir, opts.expoConfig);
|
|
249
|
+
if (["app.config.js", "app.config.ts"].some((name) => (0, fs_1.existsSync)((0, path_1.join)(projectDir, name))) || !(0, fs_1.existsSync)(appJsonPath)) {
|
|
250
|
+
console.log("This project uses a dynamic app config. Add these settings yourself:");
|
|
251
|
+
console.log(JSON.stringify((0, app_config_signing_1.applyExpoSigningConfig)({}, input).expo, null, 2));
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
const appJson = JSON.parse((0, fs_1.readFileSync)(appJsonPath, "utf8"));
|
|
255
|
+
const bare = (0, app_config_signing_1.hasUpdatesRnPlugin)(appJson);
|
|
256
|
+
if (!bare && additionalCertificates.length)
|
|
257
|
+
throw new Error("--also-trust needs ubriot-updates-rn. expo-updates trusts exactly one certificate.");
|
|
258
|
+
const next = bare ? (0, app_config_signing_1.applyBareSigningConfig)(appJson, input) : (0, app_config_signing_1.applyExpoSigningConfig)(appJson, input);
|
|
259
|
+
(0, fs_1.writeFileSync)(appJsonPath, `${JSON.stringify(next, null, 2)}\n`, "utf8");
|
|
260
|
+
console.log(`Updated ${opts.expoConfig} to trust ${certificatePath} (${key.mode} mode).`);
|
|
261
|
+
console.log("Bump runtimeVersion and ship a new native build. Code signing takes effect only in binaries built after this change.");
|
|
262
|
+
}
|
|
263
|
+
catch (err) {
|
|
264
|
+
(0, api_1.handleError)(err);
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
signing
|
|
268
|
+
.command("status")
|
|
269
|
+
.description("Show signing keys, device counts, rejections and recent unsigned publishes")
|
|
270
|
+
.requiredOption("--app <appIdOrSlug>")
|
|
271
|
+
.action(async (opts) => {
|
|
272
|
+
(0, config_1.requireAuth)();
|
|
273
|
+
try {
|
|
274
|
+
const status = await requireStatus((0, api_1.makeClient)(), opts.app);
|
|
275
|
+
console.log(`Signed updates required: ${status.required ? "yes" : "no"}${status.custody ? ` Custody: ${status.custody}` : ""}${status.servingEnabled ? "" : " Serving: DISABLED"}`);
|
|
276
|
+
if (!status.keys.length)
|
|
277
|
+
console.log("No signing keys. Run: ubriot signing init --app " + opts.app);
|
|
278
|
+
for (const key of status.keys) {
|
|
279
|
+
console.log(` ${key.keyid.padEnd(20)} ${key.mode.padEnd(7)} ${key.status.padEnd(8)} expires ${key.notAfter.slice(0, 10)} devices(30d) ${key.devicesLast30Days} sha256 ${key.fingerprintSha256.slice(0, 16)}`);
|
|
280
|
+
}
|
|
281
|
+
for (const warning of (0, ota_signing_1.expiryWarnings)(status.keys))
|
|
282
|
+
console.log(` ! ${warning}`);
|
|
283
|
+
if (status.rejections.length) {
|
|
284
|
+
console.log("Rejections (30 days):");
|
|
285
|
+
for (const r of status.rejections)
|
|
286
|
+
console.log(` ${r.source.padEnd(7)} ${r.platform.padEnd(8)} ${r.reason.padEnd(24)} x${r.count}${r.manifestId ? ` update ${r.manifestId}` : ""}`);
|
|
287
|
+
}
|
|
288
|
+
if (status.recentUnsignedUpdates.length) {
|
|
289
|
+
console.log("Recent unsigned publishes:");
|
|
290
|
+
for (const u of status.recentUnsignedUpdates)
|
|
291
|
+
console.log(` group #${u.updateGroupId} ${u.platform} runtime ${u.runtimeVersion} at ${u.createdAt}`);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
catch (err) {
|
|
295
|
+
(0, api_1.handleError)(err);
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
signing
|
|
299
|
+
.command("retire")
|
|
300
|
+
.description("Stop accepting new publishes signed by a key; existing signatures keep being served")
|
|
301
|
+
.requiredOption("--app <appIdOrSlug>")
|
|
302
|
+
.requiredOption("--keyid <keyid>")
|
|
303
|
+
.action(async (opts) => {
|
|
304
|
+
(0, config_1.requireAuth)();
|
|
305
|
+
try {
|
|
306
|
+
const client = (0, api_1.makeClient)();
|
|
307
|
+
const key = findKey(await requireStatus(client, opts.app), opts.keyid);
|
|
308
|
+
await client.post(`/ubriot/apps/${encodeURIComponent(opts.app)}/ota-signing/keys/${key.id}/retire`);
|
|
309
|
+
console.log(`Key "${opts.keyid}" retired.`);
|
|
310
|
+
}
|
|
311
|
+
catch (err) {
|
|
312
|
+
(0, api_1.handleError)(err);
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
signing
|
|
316
|
+
.command("revoke")
|
|
317
|
+
.description("Stop serving signatures from a key and refuse publishes signed by it")
|
|
318
|
+
.requiredOption("--app <appIdOrSlug>")
|
|
319
|
+
.requiredOption("--keyid <keyid>")
|
|
320
|
+
.option("--confirm <keyid>", "Repeat the keyid to confirm without a prompt")
|
|
321
|
+
.action(async (opts) => {
|
|
322
|
+
(0, config_1.requireAuth)();
|
|
323
|
+
try {
|
|
324
|
+
const client = (0, api_1.makeClient)();
|
|
325
|
+
const key = findKey(await requireStatus(client, opts.app), opts.keyid);
|
|
326
|
+
const confirm = opts.confirm ?? (interactive() ? await (0, readline_1.readlineSync)(`Type ${opts.keyid} to revoke it: `) : "");
|
|
327
|
+
if (confirm !== opts.keyid)
|
|
328
|
+
throw new Error("Confirmation did not match. Nothing was revoked.");
|
|
329
|
+
await client.post(`/ubriot/apps/${encodeURIComponent(opts.app)}/ota-signing/keys/${key.id}/revoke`, { confirm });
|
|
330
|
+
console.log(`Key "${opts.keyid}" revoked. Devices asking for it now get no update until you publish or re-sign with another trusted key.`);
|
|
331
|
+
}
|
|
332
|
+
catch (err) {
|
|
333
|
+
(0, api_1.handleError)(err);
|
|
334
|
+
}
|
|
335
|
+
});
|
|
336
|
+
signing
|
|
337
|
+
.command("require")
|
|
338
|
+
.description("Refuse unsigned publishes for an app (use --off to allow them again)")
|
|
339
|
+
.requiredOption("--app <appIdOrSlug>")
|
|
340
|
+
.option("--off", "Allow unsigned publishes again", false)
|
|
341
|
+
.action(async (opts) => {
|
|
342
|
+
(0, config_1.requireAuth)();
|
|
343
|
+
try {
|
|
344
|
+
const required = opts.off !== true;
|
|
345
|
+
await (0, api_1.makeClient)().patch(`/ubriot/apps/${encodeURIComponent(opts.app)}/ota-signing`, { required });
|
|
346
|
+
console.log(required ? "Unsigned publishes are now refused for this app." : "Unsigned publishes are allowed again. The app owner was notified.");
|
|
347
|
+
}
|
|
348
|
+
catch (err) {
|
|
349
|
+
(0, api_1.handleError)(err);
|
|
350
|
+
}
|
|
351
|
+
});
|
|
352
|
+
const issueLeafCommand = async (opts) => {
|
|
353
|
+
const projectDir = (0, path_1.resolve)(opts.projectDir);
|
|
354
|
+
const config = (0, ota_signing_1.readSigningConfig)(projectDir);
|
|
355
|
+
const chainEntry = config?.keys.filter((k) => k.mode === "chain").at(-1);
|
|
356
|
+
const rootCertificatePath = opts.rootCertificate ? (0, path_1.resolve)(projectDir, opts.rootCertificate) : chainEntry?.rootCertificatePath ? (0, path_1.resolve)(projectDir, chainEntry.rootCertificatePath) : null;
|
|
357
|
+
if (!rootCertificatePath)
|
|
358
|
+
throw new Error("Pass --root-certificate (the root certificate embedded in the app)");
|
|
359
|
+
const rootCertificatePem = (0, fs_1.readFileSync)(rootCertificatePath, "utf8");
|
|
360
|
+
const rootPrivateKeyPem = (0, fs_1.readFileSync)((0, path_1.resolve)(projectDir, opts.rootKey), "utf8");
|
|
361
|
+
const keyid = opts.keyid ?? todayKeyid("leaf");
|
|
362
|
+
const keyDir = (0, path_1.resolve)(projectDir, opts.keyDir);
|
|
363
|
+
(0, ota_signing_1.assertKeyDirOutsideGit)(keyDir, opts.allowInRepo === true);
|
|
364
|
+
const client = (0, api_1.makeClient)();
|
|
365
|
+
const status = await requireStatus(client, opts.app);
|
|
366
|
+
if (status.keys.some((k) => k.keyid === keyid && k.status !== "revoked"))
|
|
367
|
+
throw new Error(`keyid "${keyid}" is already registered`);
|
|
368
|
+
const leaf = (0, ota_signing_1.issueLeaf)({ rootPrivateKeyPem, rootCertificatePem, commonName: `${opts.app} OTA ${keyid}`, validityDays: Number(opts.validityDays) });
|
|
369
|
+
const privateKeyPath = (0, path_1.join)(keyDir, `${safeName(opts.app)}-${safeName(keyid)}.private.pem`);
|
|
370
|
+
const certificatePath = (0, path_1.resolve)(projectDir, opts.certDir, `ubriot-ota-${safeName(keyid)}.pem`);
|
|
371
|
+
(0, ota_signing_1.writePrivateKey)(privateKeyPath, leaf.privateKeyPem);
|
|
372
|
+
(0, ota_signing_1.writePublicFile)(certificatePath, leaf.certificatePem);
|
|
373
|
+
await registerKey(client, opts.app, { keyid, mode: "chain", certificatePem: leaf.certificatePem, rootCertificatePem });
|
|
374
|
+
(0, ota_signing_1.upsertSigningConfigKey)(projectDir, opts.app, {
|
|
375
|
+
keyid,
|
|
376
|
+
mode: "chain",
|
|
377
|
+
privateKeyPath: projectRelative(projectDir, privateKeyPath),
|
|
378
|
+
certificatePath: projectRelative(projectDir, certificatePath),
|
|
379
|
+
rootCertificatePath: projectRelative(projectDir, rootCertificatePath),
|
|
380
|
+
});
|
|
381
|
+
const groups = await resignGroups(client, opts.app, [{ keyid, privateKeyPem: leaf.privateKeyPem, certificatePem: leaf.certificatePem }]);
|
|
382
|
+
console.log(`Issued and registered leaf "${keyid}" (SHA-256 ${(0, ota_signing_1.fingerprintSha256)(leaf.certificatePem)}). Re-signed ${groups.length} live update group(s).`);
|
|
383
|
+
if (opts.keepOld !== true) {
|
|
384
|
+
for (const old of status.keys.filter((k) => k.mode === "chain" && k.status === "active" && k.custody === "customer")) {
|
|
385
|
+
await client.post(`/ubriot/apps/${encodeURIComponent(opts.app)}/ota-signing/keys/${old.id}/retire`);
|
|
386
|
+
console.log(`Retired previous leaf "${old.keyid}".`);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
console.log("No new binary is needed: devices trust the root, and the new leaf chains to it.");
|
|
390
|
+
};
|
|
391
|
+
signing
|
|
392
|
+
.command("issue-leaf")
|
|
393
|
+
.description("Chain mode: issue a new signing leaf from the offline root, register it and re-sign live updates")
|
|
394
|
+
.requiredOption("--app <appIdOrSlug>")
|
|
395
|
+
.requiredOption("--root-key <path>", "Path to the offline root private key")
|
|
396
|
+
.option("--root-certificate <path>", "Root certificate (defaults to the one in .ubriot/signing.json)")
|
|
397
|
+
.option("--keyid <keyid>", "New leaf keyid (default leaf-YYYYMMDD)")
|
|
398
|
+
.option("--validity-days <n>", "Leaf validity in days", "365")
|
|
399
|
+
.option("--key-dir <dir>", "Where the new leaf private key is written", "../ubriot-keys")
|
|
400
|
+
.option("--cert-dir <dir>", "Where the new leaf certificate is written", "certs")
|
|
401
|
+
.option("--project-dir <dir>", "App project directory", ".")
|
|
402
|
+
.option("--keep-old", "Keep the previous leaf active", false)
|
|
403
|
+
.option("--allow-in-repo", "Allow writing private keys inside the git working tree", false)
|
|
404
|
+
.action(async (opts) => {
|
|
405
|
+
(0, config_1.requireAuth)();
|
|
406
|
+
try {
|
|
407
|
+
await issueLeafCommand(opts);
|
|
408
|
+
}
|
|
409
|
+
catch (err) {
|
|
410
|
+
(0, api_1.handleError)(err);
|
|
411
|
+
}
|
|
412
|
+
});
|
|
413
|
+
signing
|
|
414
|
+
.command("rotate")
|
|
415
|
+
.description("Guided key rotation. Chain mode issues a new leaf; simple mode creates the next key for a new binary")
|
|
416
|
+
.requiredOption("--app <appIdOrSlug>")
|
|
417
|
+
.option("--root-key <path>", "Chain mode: offline root private key")
|
|
418
|
+
.option("--root-certificate <path>", "Chain mode: root certificate")
|
|
419
|
+
.option("--keyid <keyid>", "New keyid")
|
|
420
|
+
.option("--validity-days <n>", "Chain mode leaf validity in days", "365")
|
|
421
|
+
.option("--validity-years <n>", "Simple mode certificate validity", "10")
|
|
422
|
+
.option("--key-dir <dir>", "Where private keys are written", "../ubriot-keys")
|
|
423
|
+
.option("--cert-dir <dir>", "Where certificates are written", "certs")
|
|
424
|
+
.option("--project-dir <dir>", "App project directory", ".")
|
|
425
|
+
.option("--keep-old", "Keep the previous leaf active", false)
|
|
426
|
+
.option("--allow-in-repo", "Allow writing private keys inside the git working tree", false)
|
|
427
|
+
.option("--yes", "Skip the backup confirmation prompt", false)
|
|
428
|
+
.action(async (opts) => {
|
|
429
|
+
(0, config_1.requireAuth)();
|
|
430
|
+
try {
|
|
431
|
+
const client = (0, api_1.makeClient)();
|
|
432
|
+
const status = await requireStatus(client, opts.app);
|
|
433
|
+
const current = status.keys.find((k) => k.status === "active" && k.custody === "customer");
|
|
434
|
+
if (!current)
|
|
435
|
+
throw new Error(`No active customer key to rotate. Run: ubriot signing init --app ${opts.app}`);
|
|
436
|
+
if (current.mode === "chain") {
|
|
437
|
+
if (!opts.rootKey)
|
|
438
|
+
throw new Error("Chain mode rotation needs --root-key (the offline root private key)");
|
|
439
|
+
await issueLeafCommand(opts);
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
if (!opts.keyid)
|
|
443
|
+
throw new Error("Pass --keyid for the next key, for example --keyid main-2");
|
|
444
|
+
const projectDir = (0, path_1.resolve)(opts.projectDir);
|
|
445
|
+
const registered = await createIdentity({
|
|
446
|
+
client,
|
|
447
|
+
app: opts.app,
|
|
448
|
+
mode: "simple",
|
|
449
|
+
keyid: opts.keyid,
|
|
450
|
+
projectDir,
|
|
451
|
+
keyDir: (0, path_1.resolve)(projectDir, opts.keyDir),
|
|
452
|
+
certDir: (0, path_1.resolve)(projectDir, opts.certDir),
|
|
453
|
+
validityYears: Number(opts.validityYears),
|
|
454
|
+
leafValidityDays: 365,
|
|
455
|
+
yes: opts.yes === true,
|
|
456
|
+
allowInRepo: opts.allowInRepo === true,
|
|
457
|
+
});
|
|
458
|
+
if (!registered)
|
|
459
|
+
return;
|
|
460
|
+
console.log("\nSimple mode rotation needs a new binary:");
|
|
461
|
+
console.log(` 1. ubriot signing configure --app ${opts.app} --keyid ${opts.keyid}, bump runtimeVersion, ship a new build.`);
|
|
462
|
+
console.log(` 2. Until old binaries are gone, publish with both keys: --sign-with ${current.keyid} --sign-with ${opts.keyid}`);
|
|
463
|
+
console.log(` 3. When "ubriot signing status" shows no devices on "${current.keyid}": ubriot signing retire --app ${opts.app} --keyid ${current.keyid}`);
|
|
464
|
+
}
|
|
465
|
+
catch (err) {
|
|
466
|
+
(0, api_1.handleError)(err);
|
|
467
|
+
}
|
|
468
|
+
});
|
|
469
|
+
signing
|
|
470
|
+
.command("verify-binary <file>")
|
|
471
|
+
.description("List the code signing certificates embedded in an .ipa, .apk or .aab and check them against your keys")
|
|
472
|
+
.option("--app <appIdOrSlug>", "Also compare with the keys registered for this app")
|
|
473
|
+
.option("--project-dir <dir>", "App project directory", ".")
|
|
474
|
+
.action(async (file, opts) => {
|
|
475
|
+
try {
|
|
476
|
+
const anchors = [];
|
|
477
|
+
const projectDir = (0, path_1.resolve)(opts.projectDir);
|
|
478
|
+
for (const key of (0, ota_signing_1.readSigningConfig)(projectDir)?.keys ?? []) {
|
|
479
|
+
for (const path of [key.certificatePath, key.rootCertificatePath].filter((p) => !!p)) {
|
|
480
|
+
const full = (0, path_1.resolve)(projectDir, path);
|
|
481
|
+
if ((0, fs_1.existsSync)(full))
|
|
482
|
+
anchors.push({ label: `local ${key.keyid} (${path})`, fingerprintSha256: (0, ota_signing_1.fingerprintSha256)((0, fs_1.readFileSync)(full, "utf8")) });
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
if (opts.app) {
|
|
486
|
+
(0, config_1.requireAuth)();
|
|
487
|
+
const status = await requireStatus((0, api_1.makeClient)(), opts.app);
|
|
488
|
+
for (const key of status.keys.filter((k) => k.status !== "revoked")) {
|
|
489
|
+
anchors.push({ label: `registered ${key.keyid}`, fingerprintSha256: key.fingerprintSha256 });
|
|
490
|
+
if (key.rootFingerprintSha256)
|
|
491
|
+
anchors.push({ label: `registered ${key.keyid} root`, fingerprintSha256: key.rootFingerprintSha256 });
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
const embedded = (0, binary_certificate_1.matchAnchors)((0, binary_certificate_1.extractEmbeddedCertificates)((0, path_1.resolve)(file)), anchors);
|
|
495
|
+
if (!embedded.length) {
|
|
496
|
+
console.log("No code signing certificate is embedded in this binary. It accepts unsigned updates.");
|
|
497
|
+
process.exitCode = 1;
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
for (const certificate of embedded) {
|
|
501
|
+
console.log(`${certificate.subject.replace(/\n/g, ", ")} sha256 ${certificate.fingerprintSha256} expires ${certificate.notAfter}`);
|
|
502
|
+
console.log(` found in ${certificate.foundIn.join(", ")}`);
|
|
503
|
+
console.log(certificate.matches.length ? ` trusted key: ${certificate.matches.join(", ")}` : " ! does not match any of your keys");
|
|
504
|
+
}
|
|
505
|
+
if (embedded.some((certificate) => certificate.matches.length === 0))
|
|
506
|
+
process.exitCode = 1;
|
|
507
|
+
}
|
|
508
|
+
catch (err) {
|
|
509
|
+
(0, api_1.handleError)(err);
|
|
510
|
+
}
|
|
511
|
+
});
|
|
512
|
+
const managed = signing.command("managed").description("Ubriot-managed signing keys (paid plans). Ubriot holds the private key.");
|
|
513
|
+
managed
|
|
514
|
+
.command("create")
|
|
515
|
+
.description("Create a Ubriot-managed signing key and save its certificate for embedding in the app")
|
|
516
|
+
.requiredOption("--app <appIdOrSlug>")
|
|
517
|
+
.option("--mode <mode>", "chain (recommended, rotates without new binaries) or simple", "chain")
|
|
518
|
+
.option("--keyid <keyid>", "Key id")
|
|
519
|
+
.option("--cert-dir <dir>", "Where the certificate to embed is written", "certs")
|
|
520
|
+
.option("--project-dir <dir>", "App project directory", ".")
|
|
521
|
+
.action(async (opts) => {
|
|
522
|
+
(0, config_1.requireAuth)();
|
|
523
|
+
try {
|
|
524
|
+
const { data } = await (0, api_1.makeClient)().post(`/ubriot/apps/${encodeURIComponent(opts.app)}/ota-signing/managed-keys`, {
|
|
525
|
+
mode: opts.mode,
|
|
526
|
+
...(opts.keyid ? { keyid: opts.keyid } : {}),
|
|
527
|
+
});
|
|
528
|
+
const embedPem = data.mode === "chain" ? data.rootCertificatePem : data.certificatePem;
|
|
529
|
+
const path = (0, path_1.resolve)(opts.projectDir, opts.certDir, data.mode === "chain" ? "ubriot-managed-root.pem" : `ubriot-managed-${safeName(data.keyid)}.pem`);
|
|
530
|
+
(0, ota_signing_1.writePublicFile)(path, embedPem);
|
|
531
|
+
console.log(`Created Ubriot-managed ${data.mode} key "${data.keyid}". Certificate to embed: ${path}`);
|
|
532
|
+
console.log("Ubriot-managed keys stop tampering in storage and transit, but not a compromised Ubriot account. Use customer-held keys (ubriot signing init) for that.");
|
|
533
|
+
console.log(`Next: ubriot signing configure --app ${opts.app} --certificate ${projectRelative((0, path_1.resolve)(opts.projectDir), path)} --mode ${data.mode}${data.mode === "simple" ? ` --keyid ${data.keyid}` : ""}`);
|
|
534
|
+
}
|
|
535
|
+
catch (err) {
|
|
536
|
+
(0, api_1.handleError)(err);
|
|
537
|
+
}
|
|
538
|
+
});
|
|
539
|
+
managed
|
|
540
|
+
.command("rotate")
|
|
541
|
+
.description("Rotate the Ubriot-managed key. Chain mode needs no new binary")
|
|
542
|
+
.requiredOption("--app <appIdOrSlug>")
|
|
543
|
+
.option("--cert-dir <dir>", "Where a new simple mode certificate is written", "certs")
|
|
544
|
+
.option("--project-dir <dir>", "App project directory", ".")
|
|
545
|
+
.action(async (opts) => {
|
|
546
|
+
(0, config_1.requireAuth)();
|
|
547
|
+
try {
|
|
548
|
+
const { data } = await (0, api_1.makeClient)().post(`/ubriot/apps/${encodeURIComponent(opts.app)}/ota-signing/managed-keys/rotate`);
|
|
549
|
+
console.log(`Rotated to "${data.key.keyid}". Re-signed ${data.resignedUpdates} live update(s).${data.retired.length ? ` Retired: ${data.retired.join(", ")}.` : ""}`);
|
|
550
|
+
if (data.newBinaryRequired) {
|
|
551
|
+
const path = (0, path_1.resolve)(opts.projectDir, opts.certDir, `ubriot-managed-${safeName(data.key.keyid)}.pem`);
|
|
552
|
+
(0, ota_signing_1.writePublicFile)(path, data.key.certificatePem);
|
|
553
|
+
console.log(`Simple mode: embed ${path} in a new build, then retire the old key once its devices are gone.`);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
catch (err) {
|
|
557
|
+
(0, api_1.handleError)(err);
|
|
558
|
+
}
|
|
559
|
+
});
|
|
560
|
+
managed
|
|
561
|
+
.command("audit")
|
|
562
|
+
.description("Show the signing audit log: every managed signature and every key change")
|
|
563
|
+
.requiredOption("--app <appIdOrSlug>")
|
|
564
|
+
.action(async (opts) => {
|
|
565
|
+
(0, config_1.requireAuth)();
|
|
566
|
+
try {
|
|
567
|
+
const { data } = await (0, api_1.makeClient)().get(`/ubriot/apps/${encodeURIComponent(opts.app)}/ota-signing/audit`);
|
|
568
|
+
if (!data.length)
|
|
569
|
+
console.log("No signing activity yet.");
|
|
570
|
+
for (const entry of data)
|
|
571
|
+
console.log(`${entry.createdAt} ${entry.action.padEnd(36)} ${JSON.stringify(entry.metadata ?? {})}`);
|
|
572
|
+
}
|
|
573
|
+
catch (err) {
|
|
574
|
+
(0, api_1.handleError)(err);
|
|
575
|
+
}
|
|
576
|
+
});
|
|
577
|
+
updates
|
|
578
|
+
.command("resign")
|
|
579
|
+
.description("Sign live update groups again with the current key (rotation, or before a leaf expires)")
|
|
580
|
+
.requiredOption("--app <appIdOrSlug>")
|
|
581
|
+
.option("--group <id>", "Only this update group")
|
|
582
|
+
.option("--sign-with <keyid>", "Local key to sign with (repeatable)", collect, [])
|
|
583
|
+
.option("--private-key-path <path>", "Private key PEM")
|
|
584
|
+
.option("--certificate-path <path>", "Certificate PEM matching the private key")
|
|
585
|
+
.option("--project-dir <dir>", "App project directory", ".")
|
|
586
|
+
.action(async (opts) => {
|
|
587
|
+
(0, config_1.requireAuth)();
|
|
588
|
+
try {
|
|
589
|
+
const client = (0, api_1.makeClient)();
|
|
590
|
+
const status = await requireStatus(client, opts.app);
|
|
591
|
+
const materials = (0, ota_signing_1.loadSigningMaterial)({
|
|
592
|
+
projectDir: (0, path_1.resolve)(opts.projectDir),
|
|
593
|
+
env: process.env,
|
|
594
|
+
privateKeyPath: opts.privateKeyPath,
|
|
595
|
+
certificatePath: opts.certificatePath,
|
|
596
|
+
signWith: opts.signWith,
|
|
597
|
+
registeredKeys: status.keys,
|
|
598
|
+
});
|
|
599
|
+
if (!materials.length)
|
|
600
|
+
throw new Error("No signing key found. Pass --private-key-path or set UBRIOT_OTA_PRIVATE_KEY.");
|
|
601
|
+
const groups = await resignGroups(client, opts.app, materials, opts.group ? Number(opts.group) : undefined);
|
|
602
|
+
console.log(`Re-signed ${groups.length} update group(s) with ${materials.map((m) => m.keyid).join(", ")}.`);
|
|
603
|
+
}
|
|
604
|
+
catch (err) {
|
|
605
|
+
(0, api_1.handleError)(err);
|
|
606
|
+
}
|
|
607
|
+
});
|
|
608
|
+
return signing;
|
|
609
|
+
}
|