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.
@@ -0,0 +1,271 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.KEYID_ENV = exports.SIGNING_CONFIG_PATH = exports.SIGNING_ALG = exports.PRIVATE_KEY_ENV = void 0;
4
+ exports.privateKeyMatchesCertificate = privateKeyMatchesCertificate;
5
+ exports.verifyManifestSignature = verifyManifestSignature;
6
+ exports.signManifest = signManifest;
7
+ exports.loadSigningMaterial = loadSigningMaterial;
8
+ exports.readSigningConfig = readSigningConfig;
9
+ exports.writeSigningConfig = writeSigningConfig;
10
+ exports.upsertSigningConfigKey = upsertSigningConfigKey;
11
+ exports.gitWorkTreeContaining = gitWorkTreeContaining;
12
+ exports.assertKeyDirOutsideGit = assertKeyDirOutsideGit;
13
+ exports.writePrivateKey = writePrivateKey;
14
+ exports.writePublicFile = writePublicFile;
15
+ exports.expiryWarnings = expiryWarnings;
16
+ exports.generateSimpleIdentity = generateSimpleIdentity;
17
+ exports.commonNameOf = commonNameOf;
18
+ exports.generateChainRoot = generateChainRoot;
19
+ exports.issueLeaf = issueLeaf;
20
+ exports.fingerprintSha256 = fingerprintSha256;
21
+ exports.resolveFromProject = resolveFromProject;
22
+ const child_process_1 = require("child_process");
23
+ const crypto_1 = require("crypto");
24
+ const fs_1 = require("fs");
25
+ const path_1 = require("path");
26
+ const code_signing_certificates_1 = require("@expo/code-signing-certificates");
27
+ const x509_1 = require("./x509");
28
+ const child_env_1 = require("./child-env");
29
+ Object.defineProperty(exports, "PRIVATE_KEY_ENV", { enumerable: true, get: function () { return child_env_1.PRIVATE_KEY_ENV; } });
30
+ exports.SIGNING_ALG = "rsa-v1_5-sha256";
31
+ exports.SIGNING_CONFIG_PATH = (0, path_1.join)(".ubriot", "signing.json");
32
+ exports.KEYID_ENV = "UBRIOT_OTA_KEYID";
33
+ function publicKeyDer(key) {
34
+ const publicKey = key.type === "private" ? (0, crypto_1.createPublicKey)(key) : key;
35
+ return publicKey.export({ type: "spki", format: "der" });
36
+ }
37
+ function privateKeyMatchesCertificate(privateKeyPem, certificatePem) {
38
+ try {
39
+ return publicKeyDer((0, crypto_1.createPrivateKey)(privateKeyPem)).equals(publicKeyDer(new crypto_1.X509Certificate(certificatePem).publicKey));
40
+ }
41
+ catch {
42
+ return false;
43
+ }
44
+ }
45
+ function verifyManifestSignature(manifestJson, sig, certificatePem) {
46
+ try {
47
+ return (0, crypto_1.verify)("sha256", Buffer.from(manifestJson, "utf8"), new crypto_1.X509Certificate(certificatePem).publicKey, Buffer.from(sig, "base64"));
48
+ }
49
+ catch {
50
+ return false;
51
+ }
52
+ }
53
+ function signManifest(manifestJson, material) {
54
+ const sig = (0, crypto_1.sign)("sha256", Buffer.from(manifestJson, "utf8"), (0, crypto_1.createPrivateKey)(material.privateKeyPem)).toString("base64");
55
+ if (!verifyManifestSignature(manifestJson, sig, material.certificatePem)) {
56
+ throw new Error(`The private key for keyid "${material.keyid}" does not match its certificate. Nothing was published.`);
57
+ }
58
+ return { keyid: material.keyid, alg: exports.SIGNING_ALG, sig };
59
+ }
60
+ function readText(path, label) {
61
+ if (!(0, fs_1.existsSync)(path))
62
+ throw new Error(`${label} not found: ${path}`);
63
+ return (0, fs_1.readFileSync)(path, "utf8");
64
+ }
65
+ function matchRegisteredKey(privateKeyPem, registered) {
66
+ const usable = registered.filter((key) => key.status === "active" && key.custody === "customer");
67
+ return usable.find((key) => privateKeyMatchesCertificate(privateKeyPem, key.certificatePem)) ?? null;
68
+ }
69
+ function loadSigningMaterial(opts) {
70
+ const registered = opts.registeredKeys ?? [];
71
+ const keyidFor = (privateKeyPem, fallback) => {
72
+ const match = matchRegisteredKey(privateKeyPem, registered);
73
+ const keyid = opts.signWith?.length === 1 ? opts.signWith[0] : match?.keyid ?? fallback;
74
+ if (!keyid)
75
+ throw new Error("Could not tell which keyid this private key belongs to. Pass --sign-with <keyid>.");
76
+ return { keyid, match };
77
+ };
78
+ if (opts.privateKeyPath) {
79
+ const privateKeyPem = readText((0, path_1.resolve)(opts.projectDir, opts.privateKeyPath), "Private key");
80
+ const { keyid, match } = keyidFor(privateKeyPem, opts.env[exports.KEYID_ENV]);
81
+ const certificatePem = opts.certificatePath ? readText((0, path_1.resolve)(opts.projectDir, opts.certificatePath), "Certificate") : match?.certificatePem;
82
+ if (!certificatePem)
83
+ throw new Error("Pass --certificate-path, or register the certificate with: ubriot signing import");
84
+ return [{ keyid, privateKeyPem, certificatePem }];
85
+ }
86
+ const envKey = opts.env[child_env_1.PRIVATE_KEY_ENV]?.trim();
87
+ if (envKey) {
88
+ const privateKeyPem = envKey.includes("\\n") && !envKey.includes("\n") ? envKey.replace(/\\n/g, "\n") : envKey;
89
+ if (!opts.certificatePath && !matchRegisteredKey(privateKeyPem, registered)) {
90
+ throw new Error(`${child_env_1.PRIVATE_KEY_ENV} does not match any active registered certificate for this app. Pass --certificate-path or run: ubriot signing status`);
91
+ }
92
+ const { keyid, match } = keyidFor(privateKeyPem, opts.env[exports.KEYID_ENV]);
93
+ const certificatePem = opts.certificatePath ? readText((0, path_1.resolve)(opts.projectDir, opts.certificatePath), "Certificate") : match?.certificatePem;
94
+ if (!certificatePem)
95
+ throw new Error(`${child_env_1.PRIVATE_KEY_ENV} does not match any active registered certificate for this app. Pass --certificate-path or run: ubriot signing status`);
96
+ return [{ keyid, privateKeyPem, certificatePem }];
97
+ }
98
+ const config = readSigningConfig(opts.projectDir);
99
+ if (!config || config.keys.length === 0)
100
+ return [];
101
+ const activeKeyids = registered.length ? new Set(registered.filter((key) => key.status === "active").map((key) => key.keyid)) : null;
102
+ let keys = config.keys;
103
+ if (opts.signWith?.length) {
104
+ const missing = opts.signWith.filter((keyid) => !keys.some((key) => key.keyid === keyid));
105
+ if (missing.length)
106
+ throw new Error(`No local key for keyid ${missing.map((k) => `"${k}"`).join(", ")} in ${exports.SIGNING_CONFIG_PATH}`);
107
+ keys = keys.filter((key) => opts.signWith.includes(key.keyid));
108
+ }
109
+ else if (activeKeyids) {
110
+ const active = keys.filter((key) => activeKeyids.has(key.keyid));
111
+ if (active.length === 0) {
112
+ const describe = keys.map((key) => `"${key.keyid}" (${registered.find((r) => r.keyid === key.keyid)?.status ?? "not registered"})`).join(", ");
113
+ throw new Error(`No active signing key in ${exports.SIGNING_CONFIG_PATH}: ${describe}. Rotate with: ubriot signing rotate, or remove the stale keys from ${exports.SIGNING_CONFIG_PATH} to publish unsigned.`);
114
+ }
115
+ keys = active;
116
+ }
117
+ return keys.map((key) => ({
118
+ keyid: key.keyid,
119
+ privateKeyPem: readText((0, path_1.resolve)(opts.projectDir, key.privateKeyPath), `Private key for "${key.keyid}"`),
120
+ certificatePem: readText((0, path_1.resolve)(opts.projectDir, key.certificatePath), `Certificate for "${key.keyid}"`),
121
+ }));
122
+ }
123
+ function readSigningConfig(projectDir) {
124
+ const path = (0, path_1.join)(projectDir, exports.SIGNING_CONFIG_PATH);
125
+ if (!(0, fs_1.existsSync)(path))
126
+ return null;
127
+ const parsed = JSON.parse((0, fs_1.readFileSync)(path, "utf8"));
128
+ const keys = Array.isArray(parsed?.keys) ? parsed.keys : [];
129
+ return { app: typeof parsed?.app === "string" ? parsed.app : undefined, keys };
130
+ }
131
+ function writeSigningConfig(projectDir, config) {
132
+ const serialized = JSON.stringify(config, null, 2);
133
+ if (/PRIVATE KEY|BEGIN CERTIFICATE/.test(serialized))
134
+ throw new Error(`${exports.SIGNING_CONFIG_PATH} stores file paths only, never key or certificate contents`);
135
+ const path = (0, path_1.join)(projectDir, exports.SIGNING_CONFIG_PATH);
136
+ (0, fs_1.mkdirSync)((0, path_1.dirname)(path), { recursive: true });
137
+ (0, fs_1.writeFileSync)(path, `${serialized}\n`, "utf8");
138
+ }
139
+ function upsertSigningConfigKey(projectDir, app, key) {
140
+ const current = readSigningConfig(projectDir) ?? { app, keys: [] };
141
+ const keys = current.keys.filter((existing) => existing.keyid !== key.keyid);
142
+ writeSigningConfig(projectDir, { app: current.app ?? app, keys: [...keys, key] });
143
+ }
144
+ function nearestExistingDirectory(path) {
145
+ let current = (0, path_1.resolve)(path);
146
+ while (!(0, fs_1.existsSync)(current)) {
147
+ const parent = (0, path_1.dirname)(current);
148
+ if (parent === current)
149
+ break;
150
+ current = parent;
151
+ }
152
+ return current;
153
+ }
154
+ function gitWorkTreeContaining(path) {
155
+ const result = (0, child_process_1.spawnSync)("git", ["-C", nearestExistingDirectory(path), "rev-parse", "--show-toplevel"], { encoding: "utf8" });
156
+ if (result.status !== 0)
157
+ return null;
158
+ return result.stdout.trim() || null;
159
+ }
160
+ function assertKeyDirOutsideGit(dir, allowInRepo) {
161
+ const repo = gitWorkTreeContaining(dir);
162
+ if (repo && !allowInRepo) {
163
+ throw new Error(`Refusing to write private keys inside the git working tree ${repo}. Choose --key-dir outside the repository, or pass --allow-in-repo if you understand the risk.`);
164
+ }
165
+ }
166
+ function writePrivateKey(path, pem) {
167
+ if ((0, fs_1.existsSync)(path))
168
+ throw new Error(`Refusing to overwrite an existing key file: ${path}`);
169
+ (0, fs_1.mkdirSync)((0, path_1.dirname)(path), { recursive: true, mode: 0o700 });
170
+ (0, fs_1.writeFileSync)(path, pem, { encoding: "utf8", mode: 0o600, flag: "wx" });
171
+ (0, fs_1.chmodSync)(path, 0o600);
172
+ }
173
+ function writePublicFile(path, contents) {
174
+ (0, fs_1.mkdirSync)((0, path_1.dirname)(path), { recursive: true });
175
+ (0, fs_1.writeFileSync)(path, contents, "utf8");
176
+ }
177
+ function expiryWarnings(keys, now = new Date()) {
178
+ const warnings = [];
179
+ for (const key of keys) {
180
+ if (key.status && key.status !== "active")
181
+ continue;
182
+ const days = Math.floor((new Date(key.notAfter).getTime() - now.getTime()) / 86400000);
183
+ if (days < 0)
184
+ warnings.push(`Certificate for keyid "${key.keyid}" has expired. Devices reject updates signed with it.`);
185
+ else if (days <= 14)
186
+ warnings.push(`Certificate for keyid "${key.keyid}" expires in ${days} days. Rotate now.`);
187
+ else if (days <= 60)
188
+ warnings.push(`Certificate for keyid "${key.keyid}" expires in ${days} days. Plan a rotation.`);
189
+ }
190
+ return warnings;
191
+ }
192
+ function dayBefore(date) {
193
+ return new Date(date.getTime() - 86400000);
194
+ }
195
+ function yearsFrom(date, years) {
196
+ const next = new Date(date.getTime());
197
+ next.setUTCFullYear(next.getUTCFullYear() + years);
198
+ return next;
199
+ }
200
+ function generateSimpleIdentity(opts) {
201
+ const now = opts.now ?? new Date();
202
+ const keyPair = (0, code_signing_certificates_1.generateKeyPair)();
203
+ const certificate = (0, code_signing_certificates_1.generateSelfSignedCodeSigningCertificate)({
204
+ keyPair,
205
+ validityNotBefore: dayBefore(now),
206
+ validityNotAfter: yearsFrom(now, opts.validityYears),
207
+ commonName: opts.commonName,
208
+ });
209
+ const pems = (0, code_signing_certificates_1.convertKeyPairToPEM)(keyPair);
210
+ return { privateKeyPem: pems.privateKeyPEM, publicKeyPem: pems.publicKeyPEM, certificatePem: (0, code_signing_certificates_1.convertCertificateToCertificatePEM)(certificate) };
211
+ }
212
+ function rsaKeyPair(modulusLength) {
213
+ const { privateKey, publicKey } = (0, crypto_1.generateKeyPairSync)("rsa", { modulusLength, publicExponent: 0x10001 });
214
+ return {
215
+ privateKeyPem: privateKey.export({ type: "pkcs8", format: "pem" }).toString(),
216
+ publicKeyPem: publicKey.export({ type: "spki", format: "pem" }).toString(),
217
+ };
218
+ }
219
+ function commonNameOf(certificatePem) {
220
+ const subject = new crypto_1.X509Certificate(certificatePem).subject;
221
+ const line = subject.split("\n").find((part) => part.startsWith("CN="));
222
+ if (!line)
223
+ throw new Error("Certificate has no common name");
224
+ return line.slice(3);
225
+ }
226
+ function generateChainRoot(opts) {
227
+ const now = opts.now ?? new Date();
228
+ const keys = rsaKeyPair(4096);
229
+ const certificatePem = (0, x509_1.buildCertificatePem)({
230
+ subjectCommonName: opts.commonName,
231
+ issuerCommonName: opts.commonName,
232
+ publicKeyPem: keys.publicKeyPem,
233
+ issuerPublicKeyPem: keys.publicKeyPem,
234
+ signingPrivateKeyPem: keys.privateKeyPem,
235
+ notBefore: dayBefore(now),
236
+ notAfter: yearsFrom(now, opts.validityYears),
237
+ ca: true,
238
+ pathLength: 0,
239
+ codeSigning: false,
240
+ });
241
+ return { privateKeyPem: keys.privateKeyPem, certificatePem };
242
+ }
243
+ function issueLeaf(opts) {
244
+ if (!privateKeyMatchesCertificate(opts.rootPrivateKeyPem, opts.rootCertificatePem)) {
245
+ throw new Error("The root private key does not match the root certificate");
246
+ }
247
+ const root = new crypto_1.X509Certificate(opts.rootCertificatePem);
248
+ if (!root.ca)
249
+ throw new Error("The root certificate is not a certificate authority");
250
+ const now = opts.now ?? new Date();
251
+ const notAfter = new Date(Math.min(now.getTime() + opts.validityDays * 86400000, new Date(root.validTo).getTime()));
252
+ const keys = rsaKeyPair(2048);
253
+ const certificatePem = (0, x509_1.buildCertificatePem)({
254
+ subjectCommonName: opts.commonName,
255
+ issuerCommonName: commonNameOf(opts.rootCertificatePem),
256
+ publicKeyPem: keys.publicKeyPem,
257
+ issuerPublicKeyPem: root.publicKey.export({ type: "spki", format: "pem" }).toString(),
258
+ signingPrivateKeyPem: opts.rootPrivateKeyPem,
259
+ notBefore: dayBefore(now),
260
+ notAfter,
261
+ ca: false,
262
+ codeSigning: true,
263
+ });
264
+ return { privateKeyPem: keys.privateKeyPem, certificatePem };
265
+ }
266
+ function fingerprintSha256(certificatePem) {
267
+ return new crypto_1.X509Certificate(certificatePem).fingerprint256.replace(/:/g, "").toLowerCase();
268
+ }
269
+ function resolveFromProject(projectDir, path) {
270
+ return (0, path_1.isAbsolute)(path) ? path : (0, path_1.resolve)(projectDir, path);
271
+ }
@@ -0,0 +1,22 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.parsePlayReleaseStatus = parsePlayReleaseStatus;
4
+ exports.parseRolloutPercentage = parseRolloutPercentage;
5
+ function parsePlayReleaseStatus(value) {
6
+ if (value === undefined)
7
+ return undefined;
8
+ const status = String(value).trim().toLowerCase();
9
+ if (status !== "draft" && status !== "completed") {
10
+ throw new Error('--release-status must be "draft" (staged in the Play Console) or "completed" (live release)');
11
+ }
12
+ return status;
13
+ }
14
+ function parseRolloutPercentage(value) {
15
+ if (value === undefined)
16
+ return undefined;
17
+ const parsed = Number(value);
18
+ if (!Number.isFinite(parsed) || parsed <= 0 || parsed >= 100) {
19
+ throw new Error("--rollout must be greater than 0 and less than 100. Drop it and use --release-status completed for a full rollout.");
20
+ }
21
+ return parsed;
22
+ }
@@ -0,0 +1,74 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.fetchSigningStatus = fetchSigningStatus;
4
+ exports.fetchAppIdentity = fetchAppIdentity;
5
+ exports.ubriotManifestExtra = ubriotManifestExtra;
6
+ exports.prepareSigning = prepareSigning;
7
+ exports.signPlatforms = signPlatforms;
8
+ const ota_signing_1 = require("./ota-signing");
9
+ function status(err) {
10
+ return err?.response?.status;
11
+ }
12
+ async function fetchSigningStatus(client, app) {
13
+ try {
14
+ const { data } = await client.get(`/ubriot/apps/${encodeURIComponent(app)}/ota-signing`);
15
+ return data;
16
+ }
17
+ catch (err) {
18
+ if (status(err) === 404 && !err?.response?.data?.message?.includes("App not found"))
19
+ return null;
20
+ throw err;
21
+ }
22
+ }
23
+ async function fetchAppIdentity(client, app) {
24
+ try {
25
+ const { data } = await client.get(`/ubriot/apps/${encodeURIComponent(app)}`);
26
+ const slugs = new Set([data.slug]);
27
+ try {
28
+ const aliases = await client.get(`/ubriot/apps/${data.id}/slug-aliases`);
29
+ for (const alias of aliases.data?.aliases ?? [])
30
+ if (typeof alias?.slug === "string")
31
+ slugs.add(alias.slug);
32
+ }
33
+ catch { }
34
+ return { id: Number(data.id), slug: data.slug, slugs: [...slugs] };
35
+ }
36
+ catch {
37
+ return null;
38
+ }
39
+ }
40
+ function ubriotManifestExtra(identity, app, channel) {
41
+ if (!identity)
42
+ return { appSlug: app, appSlugs: [app], channel };
43
+ return { appId: identity.id, appSlug: identity.slug, appSlugs: identity.slugs, channel };
44
+ }
45
+ async function prepareSigning(input) {
46
+ const signingStatus = await fetchSigningStatus(input.client, input.app);
47
+ const registeredKeys = signingStatus?.keys ?? [];
48
+ const materials = (0, ota_signing_1.loadSigningMaterial)({
49
+ projectDir: input.projectDir,
50
+ env: input.env,
51
+ privateKeyPath: input.privateKeyPath,
52
+ certificatePath: input.certificatePath,
53
+ signWith: input.signWith,
54
+ registeredKeys,
55
+ });
56
+ if (signingStatus?.custody === "managed" && materials.length) {
57
+ throw new Error("This app signs with Ubriot-managed keys. Publish without local signing keys; Ubriot signs the update.");
58
+ }
59
+ if (signingStatus?.required && materials.length === 0 && signingStatus.custody !== "managed") {
60
+ throw new Error(`App "${input.app}" requires signed OTA updates but no signing key was found. Pass --private-key-path, set UBRIOT_OTA_PRIVATE_KEY in CI, or run: ubriot signing status --app ${input.app}`);
61
+ }
62
+ const relevant = registeredKeys.filter((key) => materials.some((material) => material.keyid === key.keyid));
63
+ const warnings = (0, ota_signing_1.expiryWarnings)(relevant);
64
+ const signs = materials.length > 0 || signingStatus?.custody === "managed";
65
+ if (signs && input.runtimeVersion && signingStatus?.recentUnsignedUpdates.some((u) => u.runtimeVersion === input.runtimeVersion)) {
66
+ warnings.push(`Unsigned updates were published recently on runtime ${input.runtimeVersion}. Give signing-enabled binaries a new runtimeVersion so they never share a runtime with unsigned updates.`);
67
+ }
68
+ return { materials, status: signingStatus, warnings };
69
+ }
70
+ function signPlatforms(platforms, materials) {
71
+ if (!materials.length)
72
+ return platforms;
73
+ return platforms.map((platform) => ({ ...platform, signatures: materials.map((material) => (0, ota_signing_1.signManifest)(platform.manifestJson, material)) }));
74
+ }
@@ -0,0 +1,36 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.preflightDecision = preflightDecision;
4
+ exports.renderPreflight = renderPreflight;
5
+ exports.fetchRuntimePreflight = fetchRuntimePreflight;
6
+ function preflightDecision(result, opts) {
7
+ if (!result || opts.yes || !opts.interactive)
8
+ return "continue";
9
+ return result.warnings.some((w) => w.level === "warn") ? "prompt" : "continue";
10
+ }
11
+ function renderPreflight(result) {
12
+ const lines = [];
13
+ for (const a of result.assessments) {
14
+ if (a.totalDevices > 0) {
15
+ lines.push(`Runtime ${result.runtimeVersion} can reach ${a.reachableDevices} of ${a.totalDevices} ${a.platform} devices seen on "${result.channel}" in the last ${result.windowDays} days.`);
16
+ }
17
+ }
18
+ for (const w of result.warnings) {
19
+ lines.push(`${w.level === "warn" ? "Warning" : "Note"} (${w.platform}): ${w.message}`);
20
+ }
21
+ return lines;
22
+ }
23
+ async function fetchRuntimePreflight(client, input) {
24
+ try {
25
+ const params = { runtimeVersion: input.runtimeVersion, channel: input.channel };
26
+ if (input.platform === "ios" || input.platform === "android")
27
+ params.platform = input.platform;
28
+ const { data } = await client.get(`/ubriot/apps/${encodeURIComponent(input.app)}/updates/preflight`, { params, timeout: 20000 });
29
+ if (!data || !Array.isArray(data.warnings) || !Array.isArray(data.assessments))
30
+ return null;
31
+ return data;
32
+ }
33
+ catch {
34
+ return null;
35
+ }
36
+ }