storebase-cli 1.0.0 → 1.2.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,259 @@
1
+ 'use strict';
2
+
3
+ const {
4
+ createDecipheriv,
5
+ generateKeyPairSync,
6
+ privateDecrypt,
7
+ constants,
8
+ sign,
9
+ randomUUID,
10
+ } = require('node:crypto');
11
+
12
+ const PROTOCOL = 'STOREBASE_GATEWAY_V1';
13
+ const APP_ID = 'com.nexuss0781.storebase';
14
+ const CATALOG_BOOTSTRAP_PATH = '/api/v1/catalog/bootstrap';
15
+ const PUBLISHER_BOOTSTRAP_PATH = '/api/v1/publisher/bootstrap';
16
+ const TOKEN_PATTERN = /^[A-Za-z0-9_-]{32,256}$/;
17
+ const MAX_RESPONSE_BYTES = 2 * 1024 * 1024 + 64 * 1024;
18
+
19
+ function fail(message) {
20
+ throw new Error(message);
21
+ }
22
+
23
+ function base64Bytes(value, label, maxBytes) {
24
+ if (typeof value !== 'string' || !value || value.length > Math.ceil(maxBytes * 4 / 3) + 8) {
25
+ fail(`Gateway response has an invalid ${label}.`);
26
+ }
27
+ const decoded = Buffer.from(value, 'base64');
28
+ if (!decoded.length || decoded.length > maxBytes || decoded.toString('base64') !== value) {
29
+ fail(`Gateway response has an invalid ${label}.`);
30
+ }
31
+ return decoded;
32
+ }
33
+
34
+ function canonicalRequest({ appId, requestId, timestamp, publicKey, publishToken, path }) {
35
+ const fields = [PROTOCOL, 'POST', path, appId, requestId, String(timestamp), publicKey];
36
+ if (path === PUBLISHER_BOOTSTRAP_PATH) fields.push(publishToken || '');
37
+ return fields.join('\n');
38
+ }
39
+
40
+ function canonicalPublisherRequest(request) {
41
+ return canonicalRequest({ ...request, path: PUBLISHER_BOOTSTRAP_PATH });
42
+ }
43
+
44
+ function envelopeAad(envelope) {
45
+ return [PROTOCOL, APP_ID, envelope.requestId, envelope.issuedAt].join('\n');
46
+ }
47
+
48
+ function validateCatalogSettings(settings) {
49
+ const telegram = settings?.telegram;
50
+ if (!settings || settings.schemaVersion !== 1 || settings.settingsVersion !== 1
51
+ || !telegram || !Number.isSafeInteger(telegram.apiId) || telegram.apiId <= 0
52
+ || typeof telegram.apiHash !== 'string' || !telegram.apiHash
53
+ || typeof telegram.readSession !== 'string' || !telegram.readSession
54
+ || typeof telegram.channel !== 'string' || !telegram.channel.trim()
55
+ || Object.hasOwn(telegram, 'botSession') || Object.hasOwn(telegram, 'botToken')) {
56
+ fail('Gateway returned invalid read-only Telegram settings.');
57
+ }
58
+ return settings;
59
+ }
60
+
61
+ function validatePublisherSettings(settings) {
62
+ const telegram = settings?.telegram;
63
+ if (!settings || settings.schemaVersion !== 1 || settings.settingsVersion !== 1
64
+ || !telegram || !Number.isSafeInteger(telegram.apiId) || telegram.apiId <= 0
65
+ || typeof telegram.apiHash !== 'string' || !telegram.apiHash
66
+ || typeof telegram.botSession !== 'string' || !telegram.botSession
67
+ || typeof telegram.readSession !== 'string' || !telegram.readSession
68
+ || typeof telegram.channel !== 'string' || !telegram.channel.trim()
69
+ || Object.hasOwn(telegram, 'botToken')) {
70
+ fail('Gateway returned invalid Telegram publisher settings.');
71
+ }
72
+ return settings;
73
+ }
74
+
75
+ function decryptSettings(envelope, privateKey, requestId, validator = validatePublisherSettings) {
76
+ if (!envelope || envelope.protocol !== PROTOCOL || envelope.appId !== APP_ID
77
+ || envelope.requestId !== requestId || typeof envelope.issuedAt !== 'string'
78
+ || envelope.keyWrap !== 'RSA-OAEP-SHA256' || envelope.contentEncryption !== 'AES-256-GCM') {
79
+ fail('Gateway returned an invalid or mismatched encrypted settings envelope.');
80
+ }
81
+ const wrappedKey = base64Bytes(envelope.encryptedKey, 'wrapped key', 16 * 1024);
82
+ const iv = base64Bytes(envelope.iv, 'initialization vector', 32);
83
+ const ciphertext = base64Bytes(envelope.ciphertext, 'ciphertext', MAX_RESPONSE_BYTES);
84
+ const tag = base64Bytes(envelope.tag, 'authentication tag', 32);
85
+ if (iv.length !== 12 || tag.length !== 16) fail('Gateway returned an invalid encryption envelope.');
86
+
87
+ let contentKey;
88
+ let plaintext;
89
+ try {
90
+ contentKey = privateDecrypt({
91
+ key: privateKey,
92
+ padding: constants.RSA_PKCS1_OAEP_PADDING,
93
+ oaepHash: 'sha256',
94
+ }, wrappedKey);
95
+ if (contentKey.length !== 32) fail('Gateway returned an invalid content key.');
96
+ const decipher = createDecipheriv('aes-256-gcm', contentKey, iv);
97
+ decipher.setAAD(Buffer.from(envelopeAad(envelope), 'utf8'));
98
+ decipher.setAuthTag(tag);
99
+ plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
100
+ return validator(JSON.parse(plaintext.toString('utf8')));
101
+ } catch {
102
+ fail('Could not authenticate or decrypt the gateway settings response.');
103
+ } finally {
104
+ contentKey?.fill(0);
105
+ plaintext?.fill(0);
106
+ wrappedKey.fill(0);
107
+ iv.fill(0);
108
+ ciphertext.fill(0);
109
+ tag.fill(0);
110
+ }
111
+ }
112
+
113
+ function resolveGatewayUrl(env, pathname) {
114
+ const configured = env.STOREBASE_GATEWAY_URL || 'https://storebase.wasmer.app';
115
+ let base;
116
+ try {
117
+ base = new URL(configured);
118
+ } catch {
119
+ fail('STOREBASE_GATEWAY_URL must be a valid HTTPS URL.');
120
+ }
121
+ const loopback = ['localhost', '127.0.0.1', '::1', '[::1]'].includes(base.hostname);
122
+ if ((base.protocol !== 'https:' && !loopback) || base.username || base.password || base.search || base.hash) {
123
+ fail('STOREBASE_GATEWAY_URL must use HTTPS and must not contain credentials, query parameters, or a fragment.');
124
+ }
125
+ return new URL(pathname, base);
126
+ }
127
+
128
+ async function fetchEncryptedSettings(env, pathname, validator, options = {}) {
129
+ const publisher = pathname === PUBLISHER_BOOTSTRAP_PATH;
130
+ const publishToken = publisher ? env.STOREBASE_GATEWAY_PUBLISH_TOKEN : undefined;
131
+ if (publisher && (typeof publishToken !== 'string' || !TOKEN_PATTERN.test(publishToken))) {
132
+ fail('Set STOREBASE_GATEWAY_PUBLISH_TOKEN to the high-entropy publisher access token.');
133
+ }
134
+ const endpoint = resolveGatewayUrl(env, pathname);
135
+ const keyPair = generateKeyPairSync('rsa', { modulusLength: 2048 });
136
+ const publicKey = keyPair.publicKey.export({ format: 'der', type: 'spki' }).toString('base64');
137
+ const requestId = (options.randomUUID || randomUUID)();
138
+ const timestamp = (options.now || Date.now)();
139
+ const unsigned = {
140
+ appId: APP_ID,
141
+ requestId,
142
+ timestamp,
143
+ publicKey,
144
+ ...(publisher ? { publishToken } : {}),
145
+ };
146
+ const signature = sign(
147
+ 'RSA-SHA256',
148
+ Buffer.from(canonicalRequest({ ...unsigned, path: pathname }), 'utf8'),
149
+ keyPair.privateKey,
150
+ ).toString('base64');
151
+ const controller = new AbortController();
152
+ const timeout = setTimeout(() => controller.abort(), options.timeoutMs || 15_000);
153
+ timeout.unref?.();
154
+
155
+ try {
156
+ const response = await (options.fetch || globalThis.fetch)(endpoint, {
157
+ method: 'POST',
158
+ redirect: 'error',
159
+ signal: controller.signal,
160
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
161
+ body: JSON.stringify({ ...unsigned, signature }),
162
+ });
163
+ const length = Number(response.headers?.get?.('content-length'));
164
+ if (Number.isFinite(length) && length > MAX_RESPONSE_BYTES) fail('Gateway response exceeded the allowed size.');
165
+ if (!response.ok) fail(`Gateway bootstrap failed with HTTP ${response.status}.`);
166
+ const text = await response.text();
167
+ if (Buffer.byteLength(text, 'utf8') > MAX_RESPONSE_BYTES) fail('Gateway response exceeded the allowed size.');
168
+ let envelope;
169
+ try { envelope = JSON.parse(text); } catch { fail('Gateway returned an invalid response.'); }
170
+ return decryptSettings(envelope, keyPair.privateKey, requestId, validator);
171
+ } catch (error) {
172
+ if (/^Gateway |^Set STOREBASE_|^Could not authenticate|^Gateway response/.test(error.message || '')) throw error;
173
+ if (error?.name === 'AbortError') fail('Gateway bootstrap timed out.');
174
+ fail('Could not connect to the StoreBase gateway bootstrap.');
175
+ } finally {
176
+ clearTimeout(timeout);
177
+ }
178
+ }
179
+
180
+ async function fetchPublisherSettings(env = process.env, options = {}) {
181
+ return fetchEncryptedSettings(env, PUBLISHER_BOOTSTRAP_PATH, validatePublisherSettings, options);
182
+ }
183
+
184
+ async function fetchCatalogSettings(env = process.env, options = {}) {
185
+ return fetchEncryptedSettings(env, CATALOG_BOOTSTRAP_PATH, validateCatalogSettings, options);
186
+ }
187
+
188
+ function scrubConnectionError(error, runtimeEnv) {
189
+ let message = String(error?.message || 'Telegram connection failed.');
190
+ for (const secret of Object.values(runtimeEnv)) {
191
+ if (secret && secret.length > 8) message = message.split(secret).join('[redacted]');
192
+ }
193
+ return new Error(message);
194
+ }
195
+
196
+ async function connectTelegramFromGateway(env = process.env, options = {}) {
197
+ const settings = await (options.fetchSettings || fetchPublisherSettings)(env, options);
198
+ const telegram = settings.telegram;
199
+ const runtimeEnv = {
200
+ STOREBASE_TELEGRAM_API_ID: String(telegram.apiId),
201
+ STOREBASE_TELEGRAM_API_HASH: telegram.apiHash,
202
+ STOREBASE_TELEGRAM_SESSION: telegram.botSession,
203
+ STOREBASE_TELEGRAM_READ_SESSION: telegram.readSession,
204
+ STOREBASE_TELEGRAM_CHANNEL: telegram.channel,
205
+ };
206
+ try {
207
+ const connector = options.connectTelegram || require('./telegram-client').connectTelegram;
208
+ const connection = await connector(runtimeEnv, { requireReader: true });
209
+ delete connection.session;
210
+ delete connection.readSession;
211
+ delete connection.hasStoredSession;
212
+ return connection;
213
+ } catch (error) {
214
+ throw scrubConnectionError(error, runtimeEnv);
215
+ } finally {
216
+ for (const key of Object.keys(runtimeEnv)) runtimeEnv[key] = '';
217
+ telegram.apiHash = '';
218
+ telegram.botSession = '';
219
+ telegram.readSession = '';
220
+ telegram.channel = '';
221
+ }
222
+ }
223
+
224
+ async function connectTelegramReaderFromGateway(env = process.env, options = {}) {
225
+ const settings = await (options.fetchSettings || fetchCatalogSettings)(env, options);
226
+ const telegram = settings.telegram;
227
+ const runtimeEnv = {
228
+ STOREBASE_TELEGRAM_API_ID: String(telegram.apiId),
229
+ STOREBASE_TELEGRAM_API_HASH: telegram.apiHash,
230
+ STOREBASE_TELEGRAM_READ_SESSION: telegram.readSession,
231
+ STOREBASE_TELEGRAM_CHANNEL: telegram.channel,
232
+ };
233
+ try {
234
+ const connector = options.connectTelegramReader || require('./telegram-client').connectTelegramReader;
235
+ return await connector(runtimeEnv);
236
+ } catch (error) {
237
+ throw scrubConnectionError(error, runtimeEnv);
238
+ } finally {
239
+ for (const key of Object.keys(runtimeEnv)) runtimeEnv[key] = '';
240
+ telegram.apiHash = '';
241
+ telegram.readSession = '';
242
+ telegram.channel = '';
243
+ }
244
+ }
245
+
246
+ module.exports = {
247
+ APP_ID,
248
+ CATALOG_BOOTSTRAP_PATH,
249
+ PUBLISHER_BOOTSTRAP_PATH,
250
+ canonicalPublisherRequest,
251
+ canonicalRequest,
252
+ connectTelegramFromGateway,
253
+ connectTelegramReaderFromGateway,
254
+ decryptSettings,
255
+ fetchCatalogSettings,
256
+ fetchPublisherSettings,
257
+ validateCatalogSettings,
258
+ validatePublisherSettings,
259
+ };
@@ -0,0 +1,58 @@
1
+ 'use strict';
2
+ const { stdin, stdout } = require('node:process');
3
+ const TOKEN_PATTERN = /^[A-Za-z0-9_-]{32,256}$/;
4
+
5
+ function fail(message) {
6
+ throw new Error(message);
7
+ }
8
+
9
+ function readHiddenToken(input = stdin, output = stdout) {
10
+ if (!input.isTTY || typeof input.setRawMode !== 'function' || !output.isTTY) {
11
+ return Promise.reject(new Error('Set STOREBASE_GATEWAY_PUBLISH_TOKEN in the environment for non-interactive use.'));
12
+ }
13
+ return new Promise((resolve, reject) => {
14
+ let value = '';
15
+ let finished = false;
16
+ const cleanup = () => {
17
+ input.removeListener('data', onData);
18
+ try { input.setRawMode(false); } catch {}
19
+ input.pause();
20
+ output.write('\n');
21
+ };
22
+ const finish = (error) => {
23
+ if (finished) return;
24
+ finished = true;
25
+ cleanup();
26
+ if (error) reject(error);
27
+ else resolve(value);
28
+ };
29
+ const onData = (chunk) => {
30
+ for (const character of String(chunk)) {
31
+ if (character === '\u0003') return finish(new Error('Cancelled.'));
32
+ if (character === '\r' || character === '\n') return finish();
33
+ if (character === '\u007f' || character === '\b') value = value.slice(0, -1);
34
+ else if (character >= ' ' && value.length < 256) value += character;
35
+ }
36
+ };
37
+ output.write('StoreBase gateway publisher key (input hidden): ');
38
+ input.setRawMode(true);
39
+ input.setEncoding('utf8');
40
+ input.resume();
41
+ input.on('data', onData);
42
+ });
43
+ }
44
+
45
+ async function getPublisherEnvironment(env = process.env, input = stdin, output = stdout) {
46
+ let token = env.STOREBASE_GATEWAY_PUBLISH_TOKEN;
47
+ if (token === undefined || token === '') token = await readHiddenToken(input, output);
48
+ if (typeof token !== 'string' || !TOKEN_PATTERN.test(token)) {
49
+ fail('STOREBASE_GATEWAY_PUBLISH_TOKEN must be a high-entropy 32–256 character token using letters, numbers, underscores, or hyphens.');
50
+ }
51
+ const runtimeEnv = { STOREBASE_GATEWAY_PUBLISH_TOKEN: token };
52
+ if (typeof env.STOREBASE_GATEWAY_URL === 'string' && env.STOREBASE_GATEWAY_URL) {
53
+ runtimeEnv.STOREBASE_GATEWAY_URL = env.STOREBASE_GATEWAY_URL;
54
+ }
55
+ return runtimeEnv;
56
+ }
57
+
58
+ module.exports = { TOKEN_PATTERN, getPublisherEnvironment, readHiddenToken };
package/lib/publisher.js CHANGED
@@ -13,9 +13,6 @@ const { createReadStream } = require('node:fs');
13
13
  const { createHash, randomBytes } = require('node:crypto');
14
14
  const path = require('node:path');
15
15
  const { spawnSync } = require('node:child_process');
16
- const { createInterface } = require('node:readline/promises');
17
- const { stdin, stdout } = require('node:process');
18
- const { tmpdir } = require('node:os');
19
16
 
20
17
  const CATEGORIES = Object.freeze([
21
18
  'Education',
@@ -89,6 +86,23 @@ async function validateInput(raw, options = {}) {
89
86
  if (!/^[A-Za-z0-9][A-Za-z0-9.+_-]{0,63}$/.test(version)) {
90
87
  fail('Version is required and must be 1–64 safe characters (letters, numbers, dot, plus, underscore, or hyphen).');
91
88
  }
89
+ const id = slugify(name);
90
+ const androidVersionCode = raw.androidVersionCode === undefined || raw.androidVersionCode === ''
91
+ ? undefined : Number(raw.androidVersionCode);
92
+ const minimumAndroidVersionCode = raw.minimumAndroidVersionCode === undefined || raw.minimumAndroidVersionCode === ''
93
+ ? 0 : Number(raw.minimumAndroidVersionCode);
94
+ if (id === 'storebase' && !Number.isSafeInteger(androidVersionCode)) {
95
+ fail('StoreBase Telegram releases require --android-version-code.');
96
+ }
97
+ if (id !== 'storebase' && androidVersionCode !== undefined) {
98
+ fail('Android updater version metadata is reserved for the StoreBase app ID.');
99
+ }
100
+ if ((androidVersionCode !== undefined && (!Number.isSafeInteger(androidVersionCode) || androidVersionCode <= 0 || androidVersionCode > 2147483647))
101
+ || !Number.isSafeInteger(minimumAndroidVersionCode) || minimumAndroidVersionCode < 0
102
+ || (androidVersionCode === undefined && minimumAndroidVersionCode !== 0)
103
+ || (androidVersionCode !== undefined && minimumAndroidVersionCode > androidVersionCode)) {
104
+ fail('Android version codes must be valid positive integers and the minimum cannot exceed the release code.');
105
+ }
92
106
 
93
107
  const description = typeof raw.description === 'string' ? raw.description.trim() : '';
94
108
  if (!description) fail('Description is required.');
@@ -110,10 +124,35 @@ async function validateInput(raw, options = {}) {
110
124
  } finally {
111
125
  await apkHandle.close();
112
126
  }
113
- const keystorePath = await requireFile(raw.keystore, 'Signed keystore file', cwd);
127
+ let deltaPath;
128
+ let deltaBaseVersionCode;
129
+ if (raw.delta) {
130
+ if (id !== 'storebase') fail('Telegram delta artifacts are currently supported only for the StoreBase Android app.');
131
+ if (!Number.isSafeInteger(androidVersionCode)) fail('A StoreBase delta requires --android-version-code.');
132
+ deltaBaseVersionCode = Number(raw.deltaBaseVersionCode);
133
+ if (!Number.isSafeInteger(deltaBaseVersionCode) || deltaBaseVersionCode <= 0 || deltaBaseVersionCode >= androidVersionCode) {
134
+ fail('--delta-base-version-code must be a positive version lower than --android-version-code.');
135
+ }
136
+ deltaPath = await requireFile(raw.delta, 'Delta patch file', cwd);
137
+ if (path.extname(deltaPath).toLowerCase() !== '.sbd') fail('Delta patch file must have the .sbd extension.');
138
+ const patchFile = await require('node:fs/promises').open(deltaPath, 'r');
139
+ try {
140
+ const patchHeader = Buffer.alloc(2);
141
+ const { bytesRead } = await patchFile.read(patchHeader, 0, 2, 0);
142
+ if (bytesRead !== 2 || patchHeader[0] !== 0x1f || patchHeader[1] !== 0x8b) {
143
+ fail('Delta patch must be a gzip-compressed SBD file.');
144
+ }
145
+ } finally {
146
+ await patchFile.close();
147
+ }
148
+ } else if (raw.deltaBaseVersionCode !== undefined && raw.deltaBaseVersionCode !== '') {
149
+ fail('--delta-base-version-code requires --delta.');
150
+ }
151
+ if (options.signingProfile && (!/^[A-F0-9]{64}$/.test(options.signingProfile.fingerprint) || typeof options.signingProfile.alias !== 'string' || !options.signingProfile.alias)) fail('Invalid signing profile certificate.');
152
+ const keystorePath = options.signingProfile ? undefined : await requireFile(raw.keystore, 'Signed keystore file', cwd);
114
153
 
115
154
  return {
116
- id: slugify(name),
155
+ id,
117
156
  name,
118
157
  avatarPath,
119
158
  avatarExtension,
@@ -123,6 +162,10 @@ async function validateInput(raw, options = {}) {
123
162
  description,
124
163
  keystorePath,
125
164
  alias: typeof raw.alias === 'string' && raw.alias.trim() ? raw.alias.trim() : undefined,
165
+ androidVersionCode,
166
+ minimumAndroidVersionCode,
167
+ deltaPath,
168
+ deltaBaseVersionCode,
126
169
  };
127
170
  }
128
171
 
@@ -159,35 +202,31 @@ function parseApkSignerFingerprints(output) {
159
202
  }
160
203
 
161
204
  async function inspectKeystore(keystorePath, password) {
162
- if (typeof password !== 'string') fail('A keystore password is required to verify the signing certificate.');
163
- if (password.includes('\n') || password.includes('\r')) fail('Keystore passwords cannot contain line breaks.');
164
- const fs = require('node:fs');
165
- const folder = await require('node:fs/promises').mkdtemp(path.join(tmpdir(), 'storebase-keystore-'));
166
- const passwordFile = path.join(folder, 'password');
205
+ if (typeof password !== 'string' || /[\r\n\0]/.test(password)) fail('A valid keystore password is required.');
167
206
  try {
168
- await writeFile(passwordFile, `${password}\n`, { mode: 0o600, flag: 'wx' });
169
207
  const output = runTool('keytool', [
170
- '-J-Duser.language=en',
171
- '-J-Duser.country=US',
172
- '-list',
173
- '-v',
174
- '-keystore',
175
- keystorePath,
176
- '-storepass:file',
177
- passwordFile,
178
- ]);
208
+ '-J-Duser.language=en', '-J-Duser.country=US', '-list', '-v',
209
+ '-keystore', keystorePath, '-storepass:env', 'STOREBASE_KEYTOOL_STORE_PASSWORD',
210
+ ], { env: { ...process.env, LC_ALL: 'C', LANG: 'C', STOREBASE_KEYTOOL_STORE_PASSWORD: password } });
179
211
  const entries = parseKeystoreEntries(output);
180
- if (!entries.length) fail('The keystore has no readable private-key entry with a SHA-256 certificate.');
212
+ if (!entries.length) fail('No private-key certificate found.');
181
213
  return entries;
182
- } finally {
183
- // Best-effort overwrite before removal; the temporary directory is private (0700).
184
- try {
185
- const fd = fs.openSync(passwordFile, 'r+');
186
- const size = fs.fstatSync(fd).size;
187
- fs.writeSync(fd, Buffer.alloc(size));
188
- fs.closeSync(fd);
189
- } catch {}
190
- await rm(folder, { recursive: true, force: true });
214
+ } catch {
215
+ fail('Could not inspect keystore. Check the password, file format, and Java keytool installation.');
216
+ }
217
+ }
218
+
219
+ async function verifyKeystoreKeyPassword(keystorePath, storePassword, alias, keyPassword) {
220
+ if (typeof keyPassword !== 'string' || /[\r\n\0]/.test(keyPassword)) fail('A valid private-key password is required.');
221
+ try {
222
+ runTool('keytool', [
223
+ '-J-Duser.language=en', '-J-Duser.country=US', '-certreq', '-keystore', keystorePath,
224
+ '-alias', alias, '-storepass:env', 'STOREBASE_KEYTOOL_STORE_PASSWORD',
225
+ '-keypass:env', 'STOREBASE_KEYTOOL_KEY_PASSWORD',
226
+ ], { env: { ...process.env, LC_ALL: 'C', LANG: 'C', STOREBASE_KEYTOOL_STORE_PASSWORD: storePassword,
227
+ STOREBASE_KEYTOOL_KEY_PASSWORD: keyPassword } });
228
+ } catch {
229
+ fail('Could not unlock the private key. Check its password and alias.');
191
230
  }
192
231
  }
193
232
 
@@ -345,4 +384,5 @@ module.exports = {
345
384
  validateInput,
346
385
  verifyApkSignature,
347
386
  inspectKeystore,
387
+ verifyKeystoreKeyPassword,
348
388
  };
@@ -0,0 +1,60 @@
1
+ 'use strict';
2
+ const { stdin, stdout } = require('node:process');
3
+ const { createInterface } = require('node:readline/promises');
4
+
5
+ function hidden(label, input = stdin, output = stdout) {
6
+ if (!input.isTTY || !output.isTTY || typeof input.setRawMode !== 'function') {
7
+ return Promise.reject(new Error('Signing secrets require an interactive terminal; no password arguments or project files are accepted.'));
8
+ }
9
+ return new Promise((resolve, reject) => {
10
+ let value = '';
11
+ let done = false;
12
+ const wasRaw = Boolean(input.isRaw);
13
+ const finish = (error) => {
14
+ if (done) return;
15
+ done = true;
16
+ input.removeListener('data', onData);
17
+ input.removeListener('end', onEnd);
18
+ input.removeListener('error', onEnd);
19
+ try { input.setRawMode(wasRaw); } catch {}
20
+ input.pause();
21
+ output.write('\n');
22
+ const result = value;
23
+ value = '';
24
+ if (error) reject(error); else resolve(result);
25
+ };
26
+ const onEnd = () => finish(new Error('Secret input cancelled.'));
27
+ const onData = (chunk) => {
28
+ for (const ch of String(chunk)) {
29
+ if (ch === '\u0003' || ch === '\u0004') return onEnd();
30
+ if (ch === '\r' || ch === '\n') return finish();
31
+ if (ch === '\u007f' || ch === '\b') value = [...value].slice(0, -1).join('');
32
+ else if (ch >= ' ' && ch !== '\u001b') {
33
+ if (value.length >= 4096) return finish(new Error('Secret input is too long.'));
34
+ value += ch;
35
+ }
36
+ }
37
+ };
38
+ output.write(`${label} (input hidden): `);
39
+ input.setEncoding('utf8');
40
+ input.on('data', onData);
41
+ input.once('end', onEnd);
42
+ input.once('error', onEnd);
43
+ try { input.setRawMode(true); input.resume(); } catch { onEnd(); }
44
+ });
45
+ }
46
+
47
+ async function question(label) {
48
+ if (!stdin.isTTY || !stdout.isTTY) throw new Error('This operation requires an interactive terminal.');
49
+ const rl = createInterface({ input: stdin, output: stdout });
50
+ try { return (await rl.question(label)).trim(); } finally { rl.close(); }
51
+ }
52
+
53
+ async function newPassphrase() {
54
+ const passphrase = await hidden('New vault passphrase (at least 12 characters)');
55
+ if ([...passphrase].length < 12) throw new Error('Vault passphrase must be at least 12 characters.');
56
+ const confirm = await hidden('Confirm vault passphrase');
57
+ if (passphrase !== confirm) throw new Error('Vault passphrases do not match.');
58
+ return passphrase;
59
+ }
60
+ module.exports = { hidden, question, newPassphrase };
@@ -0,0 +1,83 @@
1
+ 'use strict';
2
+ const fs = require('node:fs/promises');
3
+ const path = require('node:path');
4
+ const os = require('node:os');
5
+ const { inspectKeystore, verifyKeystoreKeyPassword } = require('./publisher');
6
+
7
+ function profileName(value) {
8
+ if (typeof value !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/.test(value)) {
9
+ throw new Error('Profile names must be 1–64 letters, numbers, underscores, or hyphens.');
10
+ }
11
+ if (['__proto__', 'constructor', 'prototype'].includes(value)) throw new Error('Reserved profile name.');
12
+ return value;
13
+ }
14
+
15
+ async function importProfile(file, storePassword, keyPassword, alias, options = {}) {
16
+ const handle = await fs.open(path.resolve(file), 'r');
17
+ let bytes;
18
+ let folder;
19
+ try {
20
+ const info = await handle.stat();
21
+ if (!info.isFile() || info.size < 1 || info.size > 8 * 1024 * 1024) throw new Error('Keystore must be a non-empty file of at most 8 MiB.');
22
+ const bounded = Buffer.alloc(8 * 1024 * 1024 + 1);
23
+ let count = 0;
24
+ try {
25
+ while (count < bounded.length) {
26
+ const { bytesRead } = await handle.read(bounded, count, bounded.length - count, null);
27
+ if (!bytesRead) break;
28
+ count += bytesRead;
29
+ }
30
+ if (count > 8 * 1024 * 1024) throw new Error('Keystore exceeds the size limit.');
31
+ if (!count) throw new Error('Keystore is empty.');
32
+ bytes = Buffer.from(bounded.subarray(0, count));
33
+ } finally { bounded.fill(0); }
34
+ folder = await fs.mkdtemp(path.join(os.tmpdir(), 'storebase-signing-import-'));
35
+ await fs.chmod(folder, 0o700);
36
+ const temporary = path.join(folder, 'keystore');
37
+ await fs.writeFile(temporary, bytes, { mode: 0o600, flag: 'wx' });
38
+ const entries = await (options.inspect || inspectKeystore)(temporary, storePassword);
39
+ let selected;
40
+ if (alias) selected = entries.find((entry) => entry.alias === alias);
41
+ else if (entries.length === 1) selected = entries[0];
42
+ if (!selected) throw new Error('Choose a valid private-key alias with --alias (required for multiple entries).');
43
+ await (options.verify || verifyKeystoreKeyPassword)(temporary, storePassword, selected.alias, keyPassword);
44
+ return { keystore: bytes.toString('base64'), storePassword, keyPassword,
45
+ alias: selected.alias, fingerprint: selected.fingerprint };
46
+ } finally {
47
+ await handle.close();
48
+ if (folder) {
49
+ try {
50
+ const file = path.join(folder, 'keystore');
51
+ const handle = await fs.open(file, 'r+');
52
+ try { await handle.write(Buffer.alloc(bytes?.length || 0), 0, bytes?.length || 0, 0); } finally { await handle.close(); }
53
+ } catch {}
54
+ await fs.rm(folder, { recursive: true, force: true });
55
+ }
56
+ bytes?.fill(0);
57
+ }
58
+ }
59
+
60
+ function entryForProfile(data, name) {
61
+ profileName(name);
62
+ if (!Object.hasOwn(data.profiles, name)) throw new Error('Signing profile not found. Import it with storebase-keystore import.');
63
+ const profile = data.profiles[name];
64
+ // Publishing only needs the certificate verified at import time, not a plaintext private key.
65
+ return { alias: profile.alias, fingerprint: profile.fingerprint };
66
+ }
67
+
68
+ function clearProfiles(data) {
69
+ if (!data?.profiles) return;
70
+ for (const profile of Object.values(data.profiles)) {
71
+ profile.keystore = ''; profile.storePassword = ''; profile.keyPassword = '';
72
+ }
73
+ }
74
+ async function loadProfileEntry(name, options = {}) {
75
+ profileName(name);
76
+ const vault = options.vault || new (require('./signing-vault').SigningVault)();
77
+ const prompt = options.prompt || require('./secure-prompt').hidden;
78
+ let passphrase = await prompt('Vault passphrase');
79
+ let data;
80
+ try { data = await vault.read(passphrase); return entryForProfile(data, name); }
81
+ finally { passphrase = ''; clearProfiles(data); }
82
+ }
83
+ module.exports = { profileName, importProfile, entryForProfile, clearProfiles, loadProfileEntry };