storebase-cli 1.1.0 → 1.3.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 CHANGED
@@ -7,7 +7,7 @@ The gateway provides encrypted Telegram settings at runtime. The CLI decrypts th
7
7
  ## Requirements
8
8
 
9
9
  - Node.js 18 or newer
10
- - Java `keytool`
10
+ - Java 21 or newer `keytool` for the signing-profile import workflow (tested with Java 21; supports password delivery through `:env`)
11
11
  - Android SDK Build Tools `apksigner` (on `PATH`, or under `$ANDROID_SDK_ROOT/build-tools/` or `$ANDROID_HOME/build-tools/`)
12
12
  - A Telegram bot that is an administrator of the broadcast channel with permission to post
13
13
  - Telegram API ID and API hash, a stored bot MTProto session, and a separate personal-user read session
@@ -34,6 +34,17 @@ To install this checkout globally:
34
34
  npm install --global ./cli
35
35
  ```
36
36
 
37
+ ## Agent skill: complete end-to-end workflows
38
+
39
+ Version 1.3 bundles a real agent skill with YAML frontmatter and detailed workflow references. It covers CLI setup, runtime publisher keys, encrypted gateway bootstrap, signing-profile import/reuse/replacement/rekey/removal, approved APK/icon/metadata publishing, immutable Telegram history and deltas, default-version pins, catalog downloads, Apps/Explore limitations, Android direct Telegram access and install approval/reopen behavior, local Wasmer deployment, and source/release testing.
40
+
41
+ ```sh
42
+ storebase-skill path
43
+ storebase-skill install --dir ./agent-skills
44
+ ```
45
+
46
+ `path` prints the installed `SKILL.md`. `install --dir <skills-root>` copies the skill into `<skills-root>/storebase/` and refuses to overwrite an existing destination. Choose the skills root supported by your agent and enable/reload it there when required; this command does not change agent configuration. Upgrading npm updates the bundled skill, not previously copied installations. The skill's entry point is [`skills/storebase/SKILL.md`](skills/storebase/SKILL.md); its linked references contain full command examples and troubleshooting. Multi-line shell examples in this README use POSIX syntax; use a single line or PowerShell continuations on Windows. Native Windows APK-tool discovery and Android installation must be verified separately.
47
+
37
48
  ## Runtime configuration
38
49
 
39
50
  Gateway mode is the default. `storebase-telegram list`, `metadata`, and `download` use the read-only catalog bootstrap and do not require publisher credentials. `storebase-publish` and `storebase-telegram pin` use the separate publisher bootstrap and require the same high-entropy `STOREBASE_GATEWAY_PUBLISH_TOKEN` in the gateway's secret environment and the CLI runtime. Keep that token in a secret manager or mode-0600 `.env` file; do not commit it. In an interactive terminal, these write commands prompt for it without echoing it when the environment variable is absent; non-interactive runs must set the environment variable. The gateway URL is optional and defaults to `https://storebase.wasmer.app`.
@@ -73,6 +84,52 @@ Supported categories: Education, Social, Productivity, Communication, Entertainm
73
84
 
74
85
  Use `--storage local` to opt into the older local JSON/files workflow. Its `--catalog` and `--assets-dir` options are only used in local mode.
75
86
 
87
+ ## Import a signing keystore once
88
+
89
+ Version 1.2 adds a local encrypted signing vault. Create a named profile once, then select that profile for any app tag or release instead of repeatedly supplying a keystore file:
90
+
91
+ ```sh
92
+ storebase-keystore import release --file /path/to/release.jks --alias release
93
+ storebase-keystore list
94
+ storebase-publish --signing-profile release \
95
+ --name "Example App" --version 2.0.0 --apk ./app-release.apk \
96
+ --avatar ./icon.png --category Productivity --description "Updated release."
97
+ ```
98
+
99
+ Import prompts without echo for a vault passphrase (at least 12 characters, confirmed on creation), the keystore password, and the private-key password. Press Enter at the private-key prompt to reuse the keystore password. Use a long, unique vault passphrase; there is no saved unlock key or recovery service. Profile operations and profile-based publishing require an interactive terminal. Password arguments, password files, and environment variables are not accepted by these new profile commands. The older one-off `--keystore` workflow remains compatible, including its legacy runtime password variable; do not save signing passwords in `.env` or shell history.
100
+
101
+ Java `keytool` validates the imported keystore, private-key alias, private-key password, and certificate. Multiple private-key entries require `--alias`. The CLI stores an encrypted copy rather than a source path, so the source keystore need not remain at the original location. `--signing-profile` cannot be combined with `--keystore` or `--alias`. Profiles are explicit, not automatically tied to app names or selected as a default.
102
+
103
+ Publishing continues to **verify an already-signed APK**, not sign or re-sign it. It compares the APK certificate to the certificate validated when the profile was imported. Profile-based publishing never extracts the stored private key to disk. A different signer is rejected. Neither the keystore nor its passwords are uploaded to Telegram or the gateway; only the public certificate fingerprint belongs in release metadata.
104
+
105
+ ### Update, rotate, or remove
106
+
107
+ ```sh
108
+ # Update passwords or replace the stored key after validating the replacement
109
+ storebase-keystore import release --file /path/to/replacement.jks --alias release --replace
110
+
111
+ # Re-encrypt every profile under a new vault passphrase
112
+ storebase-keystore rekey
113
+
114
+ # Delete a profile from the current encrypted vault
115
+ storebase-keystore remove release
116
+
117
+ # Print the location without unlocking the vault
118
+ storebase-keystore location
119
+ ```
120
+
121
+ Replacing and removing profiles require an explicit typed confirmation. Replacement validates the new keystore before atomically updating the vault; a failed import leaves the old profile intact. Changing the vault passphrase does not change the keystore's own passwords or signing key. Replacing a key does not generate Android signing lineage, change Play App Signing, or make existing installations accept a different certificate. Keep the old key backed up before rotation; otherwise updates to existing installs may become impossible.
122
+
123
+ ### Where secrets are stored
124
+
125
+ The vault is `signing-vault.json` under `~/.local/share/storebase-cli/` on Linux, `~/Library/Application Support/storebase-cli/` on macOS, or `~/AppData/Local/storebase-cli/` on Windows. The CLI does not use the current project, repository, npm install directory, Telegram, or an environment-selected path for its production vault. It does not use an OS keychain: the separate master passphrase is requested for every command and is never stored.
126
+
127
+ The complete profile collection—including keystore bytes, store password, private-key password, alias, and certificate—is encrypted with AES-256-GCM. A fresh random salt and nonce are used for each write; scrypt derives the encryption key from the master passphrase (N=32768, r=8, p=1). Authentication detects wrong passphrases and modified vaults. Writes are locked and atomic. On POSIX, the dedicated directory is mode 0700 and files are mode 0600; unsafe permissions, ownership, or symlink paths are refused. On Windows, keep the user-data directory restricted to your account using Windows permissions; ciphertext encryption is not a substitute for protecting the account.
128
+
129
+ Import briefly creates a private mode-0700 temporary directory containing a mode-0600 copy of the keystore so Java can validate exactly the bytes being imported. It overwrites that copy best-effort and removes the directory in `finally`, on success or failure. Passwords are passed to Java through its child-process environment, never arguments, logs, or plaintext password files. Decrypted data exists in process memory while unlocked. Buffers are cleared best-effort, but JavaScript strings, OS swap, privileged process inspection, crashes, and malicious code running as your user cannot be made safe by this vault. Temporary files may remain after an uncatchable termination; remove orphaned `storebase-signing-import-*` directories from your OS temporary directory securely.
130
+
131
+ Keep encrypted-vault backups and your master passphrase safe and separate. Losing the passphrase cannot be recovered by the CLI. Removing a profile or rekeying replaces the current vault but does not erase originals, old snapshots, filesystem journals, or backups; no forensic erasure is promised. The npm file allowlist contains only CLI JavaScript and documentation, never local vaults, `.env`, `.jks`, `.keystore`, `.p12`, or `.pfx` files.
132
+
76
133
  ## Telegram storage and versions
77
134
 
78
135
  Each release adds an APK document, an icon document, an optional delta document, then a JSON manifest text post. The manifest is posted last, so an incomplete upload is not listed as a release. The artifacts and metadata stay in Telegram; publishing a later version does not delete older versions.
@@ -0,0 +1,89 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ const fs = require('node:fs/promises');
4
+ const { SigningVault, vaultPath } = require('../lib/signing-vault');
5
+ const { hidden, question, newPassphrase } = require('../lib/secure-prompt');
6
+ const { profileName, importProfile, clearProfiles } = require('../lib/signing-profiles');
7
+
8
+ function parseArguments(args) {
9
+ if (!args.length || args.includes('--help') || args.includes('-h')) return { command: 'help' };
10
+ const [command, ...rest] = args;
11
+ if (!['import', 'list', 'remove', 'rekey', 'location'].includes(command)) throw new Error('Unknown keystore command. Use --help.');
12
+ const result = { command };
13
+ if (['import', 'remove'].includes(command)) result.name = profileName(rest.shift());
14
+ while (rest.length) {
15
+ const arg = rest.shift();
16
+ if (command === 'import' && arg === '--replace' && !result.replace) result.replace = true;
17
+ else if (command === 'import' && ['--file', '--alias'].includes(arg)) {
18
+ const key = arg.slice(2);
19
+ if (result[key] !== undefined || !rest[0] || rest[0].startsWith('--')) throw new Error('Invalid or duplicate keystore option.');
20
+ result[key] = rest.shift();
21
+ } else throw new Error('Unexpected keystore argument. Password arguments are not supported.');
22
+ }
23
+ if (command === 'import' && !result.file) throw new Error('Import requires --file <keystore>.');
24
+ return result;
25
+ }
26
+
27
+ async function main(args = process.argv.slice(2)) {
28
+ const opts = parseArguments(args);
29
+ if (opts.command === 'help') {
30
+ process.stdout.write(`StoreBase encrypted signing profiles\n\nCommands:\n storebase-keystore import <profile> --file <keystore> [--alias <alias>] [--replace]\n storebase-keystore list\n storebase-keystore remove <profile>\n storebase-keystore rekey\n storebase-keystore location\n\nSecrets are collected only through hidden terminal prompts. The encrypted vault is\noutside the project in your user data directory. Its master passphrase is never saved.\nUse --signing-profile <profile> with storebase-publish to reuse a verified certificate.\nImport --replace replaces a profile; it does not create Android signing lineage.\n`);
31
+ return;
32
+ }
33
+ if (opts.command === 'location') { process.stdout.write(`${vaultPath()}\n`); return; }
34
+ const vault = new SigningVault();
35
+ let exists = true;
36
+ try { await fs.lstat(vaultPath()); } catch (e) { if (e.code === 'ENOENT') exists = false; else throw e; }
37
+ if (!exists && opts.command !== 'import') throw new Error('No signing vault exists. Import a keystore first.');
38
+ let passphrase = exists ? await hidden('Vault passphrase') : await newPassphrase();
39
+ let data;
40
+ let imported;
41
+ try {
42
+ data = await vault.read(passphrase);
43
+ if (opts.command === 'list') {
44
+ for (const name of Object.keys(data.profiles).sort()) {
45
+ const p = data.profiles[name];
46
+ process.stdout.write(`${name}\talias=${JSON.stringify(p.alias)}\tSHA256=${p.fingerprint}\n`);
47
+ }
48
+ if (!Object.keys(data.profiles).length) process.stdout.write('No signing profiles stored.\n');
49
+ } else if (opts.command === 'import') {
50
+ const replacing = Object.hasOwn(data.profiles, opts.name);
51
+ if (replacing && !opts.replace) throw new Error('Profile already exists. Use --replace to explicitly rotate or update it.');
52
+ if (opts.replace) {
53
+ if (!replacing) throw new Error('Cannot replace a profile that does not exist.');
54
+ process.stdout.write('Replacing a signing key may prevent Android updates to existing installs. Keep a secure backup of the old key.\n');
55
+ if (await question(`Type REPLACE ${opts.name} to continue: `) !== `REPLACE ${opts.name}`) throw new Error('Cancelled.');
56
+ }
57
+ let storePassword = await hidden('Keystore password');
58
+ let keyPassword = await hidden('Private-key password (Enter to reuse keystore password)');
59
+ if (!keyPassword) keyPassword = storePassword;
60
+ try { imported = await importProfile(opts.file, storePassword, keyPassword, opts.alias); }
61
+ finally { storePassword = ''; keyPassword = ''; }
62
+ await vault.mutate(passphrase, (current) => {
63
+ if (JSON.stringify(current.profiles[opts.name]) !== JSON.stringify(data.profiles[opts.name])) throw new Error('Profile changed concurrently. Retry.');
64
+ current.profiles[opts.name] = imported;
65
+ });
66
+ process.stdout.write(`Signing profile ${opts.replace ? 'replaced' : 'imported'}. Original keystore was not changed or uploaded.\n`);
67
+ } else if (opts.command === 'remove') {
68
+ if (!Object.hasOwn(data.profiles, opts.name)) throw new Error('Signing profile not found.');
69
+ if (await question(`Type REMOVE ${opts.name} to delete its stored keystore and passwords: `) !== `REMOVE ${opts.name}`) throw new Error('Cancelled.');
70
+ await vault.mutate(passphrase, (current) => {
71
+ if (JSON.stringify(current.profiles[opts.name]) !== JSON.stringify(data.profiles[opts.name])) throw new Error('Profile changed concurrently. Retry.');
72
+ delete current.profiles[opts.name];
73
+ });
74
+ process.stdout.write('Signing profile removed. Original source files and backups are not deleted.\n');
75
+ } else if (opts.command === 'rekey') {
76
+ let replacement = await newPassphrase();
77
+ try { await vault.rekey(passphrase, replacement); } finally { replacement = ''; }
78
+ process.stdout.write('Vault passphrase changed; all profiles re-encrypted.\n');
79
+ }
80
+ } finally {
81
+ passphrase = '';
82
+ clearProfiles(data);
83
+ if (imported) { imported.keystore = ''; imported.storePassword = ''; imported.keyPassword = ''; }
84
+ }
85
+ }
86
+ if (require.main === module) main().catch((error) => {
87
+ process.stderr.write(`storebase-keystore: ${error.message}\n`); process.exitCode = 1;
88
+ });
89
+ module.exports = { parseArguments, main };
@@ -1,12 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  'use strict';
3
3
 
4
- require('dotenv').config();
5
-
6
4
  const { createInterface } = require('node:readline/promises');
7
5
  const { stdin, stdout } = require('node:process');
8
6
  const { connectTelegramFromGateway } = require('../lib/gateway-client');
9
7
  const { getPublisherEnvironment } = require('../lib/publisher-token');
8
+ const { loadProfileEntry } = require('../lib/signing-profiles');
9
+ const { hidden } = require('../lib/secure-prompt');
10
10
  const {
11
11
  CATEGORIES,
12
12
  inspectKeystore,
@@ -19,7 +19,7 @@ const { createTelegramStorage } = require('../lib/telegram-storage');
19
19
 
20
20
  const OPTION_NAMES = new Set([
21
21
  'name', 'avatar', 'version', 'apk', 'category', 'description', 'keystore', 'alias', 'storage', 'catalog', 'assets-dir',
22
- 'telegram-config',
22
+ 'telegram-config', 'signing-profile',
23
23
  'android-version-code', 'minimum-android-version-code', 'delta', 'delta-base-version-code',
24
24
  ]);
25
25
 
@@ -58,7 +58,8 @@ Options (omitted app details are prompted interactively):
58
58
  --delta-base-version-code <n> Installed base code for the patch
59
59
  --category <category> One of the 13 StoreBase categories
60
60
  --description <text> App description
61
- --keystore <file> Signing keystore used for the APK
61
+ --signing-profile <profile> Reuse a profile from storebase-keystore
62
+ --keystore <file> One-off signing keystore (no profile)
62
63
  --alias <name> Private-key alias, if the keystore has multiple
63
64
  --storage <backend> telegram (default) or local
64
65
  --telegram-config <mode> gateway (default) or local direct Telegram settings
@@ -75,41 +76,6 @@ prompt or read from STOREBASE_KEYSTORE_PASSWORD.
75
76
  `);
76
77
  }
77
78
 
78
- function askHidden(label) {
79
- if (!stdin.isTTY || typeof stdin.setRawMode !== 'function') {
80
- return Promise.reject(new Error('Set STOREBASE_KEYSTORE_PASSWORD in the environment for non-interactive use.'));
81
- }
82
- return new Promise((resolve, reject) => {
83
- let value = '';
84
- let finished = false;
85
- const cleanup = () => {
86
- stdin.removeListener('data', onData);
87
- try { stdin.setRawMode(false); } catch {}
88
- stdin.pause();
89
- stdout.write('\n');
90
- };
91
- const finish = (error) => {
92
- if (finished) return;
93
- finished = true;
94
- cleanup();
95
- if (error) reject(error);
96
- else resolve(value);
97
- };
98
- const onData = (chunk) => {
99
- for (const character of String(chunk)) {
100
- if (character === '\u0003') return finish(new Error('Cancelled.'));
101
- if (character === '\r' || character === '\n') return finish();
102
- if (character === '\u007f' || character === '\b') value = value.slice(0, -1);
103
- else if (character >= ' ') value += character;
104
- }
105
- };
106
- stdout.write(`${label}: `);
107
- stdin.setRawMode(true);
108
- stdin.setEncoding('utf8');
109
- stdin.resume();
110
- stdin.on('data', onData);
111
- });
112
- }
113
79
 
114
80
  async function collectInput(options) {
115
81
  const required = [
@@ -121,6 +87,7 @@ async function collectInput(options) {
121
87
  ['description', 'Description'],
122
88
  ['keystore', 'Signed keystore file path'],
123
89
  ];
90
+ if (options['signing-profile']) required.splice(required.findIndex(([key]) => key === 'keystore'), 1);
124
91
  const missing = required.filter(([key]) => !options[key]);
125
92
  if (missing.length && (!stdin.isTTY || !stdout.isTTY)) {
126
93
  throw new Error(`Missing required options: ${missing.map(([key]) => `--${key}`).join(', ')}. Prompts require an interactive terminal.`);
@@ -183,6 +150,7 @@ async function selectEntry(entries, aliasOption) {
183
150
  }
184
151
 
185
152
  async function main() {
153
+ require('dotenv').config();
186
154
  const parsed = parseArguments(process.argv.slice(2));
187
155
  if (parsed.help) return showHelp();
188
156
  const options = parsed.options;
@@ -194,12 +162,17 @@ async function main() {
194
162
  if (!['gateway', 'local'].includes(options['telegram-config'])) {
195
163
  throw new Error('--telegram-config must be either gateway or local.');
196
164
  }
165
+ if (options['signing-profile'] && (options.keystore || options.alias)) {
166
+ throw new Error('--signing-profile cannot be combined with --keystore or --alias.');
167
+ }
197
168
  const rawInput = await collectInput(options);
198
- const input = await validateInput(rawInput);
199
-
200
- const password = process.env.STOREBASE_KEYSTORE_PASSWORD ?? await askHidden('Keystore password (input hidden)');
201
- const entries = await inspectKeystore(input.keystorePath, password);
202
- const entry = await selectEntry(entries, input.alias);
169
+ let entry = options['signing-profile'] ? await loadProfileEntry(options['signing-profile']) : undefined;
170
+ const input = await validateInput(rawInput, { signingProfile: entry });
171
+ if (!entry) {
172
+ let password = process.env.STOREBASE_KEYSTORE_PASSWORD ?? await hidden('Keystore password');
173
+ try { entry = await selectEntry(await inspectKeystore(input.keystorePath, password), input.alias); }
174
+ finally { password = ''; }
175
+ }
203
176
  const certificate = verifyApkSignature(input.apkPath, entry);
204
177
  if (options.storage === 'local') {
205
178
  if (input.androidVersionCode !== undefined || input.deltaPath) {
@@ -236,10 +209,12 @@ async function main() {
236
209
  await disconnectTelegram(connection);
237
210
  }
238
211
  }
239
- stdout.write('The keystore was used only to verify the APK signature; it was not copied.\n');
212
+ stdout.write('APK signing certificate verified. No keystore or signing passwords were uploaded.\n');
240
213
  }
241
214
 
242
- main().catch((error) => {
215
+ if (require.main === module) main().catch((error) => {
243
216
  process.stderr.write(`storebase-publish: ${error.message}\n`);
244
217
  process.exitCode = 1;
245
218
  });
219
+
220
+ module.exports = { parseArguments, collectInput };
@@ -0,0 +1,63 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('node:fs/promises');
5
+ const path = require('node:path');
6
+
7
+ const source = path.resolve(__dirname, '..', 'skills', 'storebase');
8
+ const DOCUMENTS = [
9
+ 'SKILL.md',
10
+ 'references/cli-workflows.md',
11
+ 'references/runtime-and-android.md',
12
+ 'references/updates-and-development.md',
13
+ ];
14
+
15
+ function parseArguments(args) {
16
+ if (!args.length || (args.length === 1 && ['--help', '-h'].includes(args[0]))) return { command: 'help' };
17
+ if (args.length === 1 && args[0] === 'path') return { command: 'path' };
18
+ if (args.length === 3 && args[0] === 'install' && args[1] === '--dir' && args[2].trim() && !args[2].startsWith('--')) {
19
+ return { command: 'install', root: path.resolve(args[2]) };
20
+ }
21
+ throw new Error('Use storebase-skill path or storebase-skill install --dir <skills-root>.');
22
+ }
23
+
24
+ async function main(args = process.argv.slice(2)) {
25
+ const options = parseArguments(args);
26
+ if (options.command === 'help') {
27
+ process.stdout.write(`StoreBase agent skill\n\nUsage:\n storebase-skill path\n storebase-skill install --dir <skills-root>\n\npath prints the bundled SKILL.md location. install copies the skill into the\nstorebase child of the chosen root. Existing destinations are never overwritten.\nEnable or reload the skill in your agent separately when required.\n`);
28
+ return;
29
+ }
30
+ if (options.command === 'path') {
31
+ await fs.access(path.join(source, 'SKILL.md'));
32
+ process.stdout.write(`${path.join(source, 'SKILL.md')}\n`);
33
+ return;
34
+ }
35
+ // Read only the explicitly packaged documents, never arbitrary local files.
36
+ const documents = await Promise.all(DOCUMENTS.map(async name => [name, await fs.readFile(path.join(source, name))]));
37
+ await fs.mkdir(options.root, { recursive: true });
38
+ const destination = path.join(options.root, 'storebase');
39
+ try {
40
+ await fs.mkdir(destination);
41
+ } catch (error) {
42
+ if (error.code === 'EEXIST') throw new Error(`Skill destination already exists; review or back it up before replacing it: ${destination}`);
43
+ throw error;
44
+ }
45
+ try {
46
+ for (const [name, content] of documents) {
47
+ const file = path.join(destination, name);
48
+ await fs.mkdir(path.dirname(file), { recursive: true });
49
+ await fs.writeFile(file, content, { flag: 'wx' });
50
+ }
51
+ } catch (error) {
52
+ await fs.rm(destination, { recursive: true, force: true });
53
+ throw error;
54
+ }
55
+ process.stdout.write(`StoreBase skill installed: ${path.join(destination, 'SKILL.md')}\nEnable or reload it using your agent's skill settings when required.\n`);
56
+ }
57
+
58
+ if (require.main === module) main().catch(error => {
59
+ process.stderr.write(`storebase-skill: ${error.message}\n`);
60
+ process.exitCode = 1;
61
+ });
62
+
63
+ module.exports = { parseArguments, main, DOCUMENTS };
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',
@@ -151,7 +148,8 @@ async function validateInput(raw, options = {}) {
151
148
  } else if (raw.deltaBaseVersionCode !== undefined && raw.deltaBaseVersionCode !== '') {
152
149
  fail('--delta-base-version-code requires --delta.');
153
150
  }
154
- const keystorePath = await requireFile(raw.keystore, 'Signed keystore file', cwd);
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);
155
153
 
156
154
  return {
157
155
  id,
@@ -204,35 +202,31 @@ function parseApkSignerFingerprints(output) {
204
202
  }
205
203
 
206
204
  async function inspectKeystore(keystorePath, password) {
207
- if (typeof password !== 'string') fail('A keystore password is required to verify the signing certificate.');
208
- if (password.includes('\n') || password.includes('\r')) fail('Keystore passwords cannot contain line breaks.');
209
- const fs = require('node:fs');
210
- const folder = await require('node:fs/promises').mkdtemp(path.join(tmpdir(), 'storebase-keystore-'));
211
- const passwordFile = path.join(folder, 'password');
205
+ if (typeof password !== 'string' || /[\r\n\0]/.test(password)) fail('A valid keystore password is required.');
212
206
  try {
213
- await writeFile(passwordFile, `${password}\n`, { mode: 0o600, flag: 'wx' });
214
207
  const output = runTool('keytool', [
215
- '-J-Duser.language=en',
216
- '-J-Duser.country=US',
217
- '-list',
218
- '-v',
219
- '-keystore',
220
- keystorePath,
221
- '-storepass:file',
222
- passwordFile,
223
- ]);
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 } });
224
211
  const entries = parseKeystoreEntries(output);
225
- 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.');
226
213
  return entries;
227
- } finally {
228
- // Best-effort overwrite before removal; the temporary directory is private (0700).
229
- try {
230
- const fd = fs.openSync(passwordFile, 'r+');
231
- const size = fs.fstatSync(fd).size;
232
- fs.writeSync(fd, Buffer.alloc(size));
233
- fs.closeSync(fd);
234
- } catch {}
235
- 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.');
236
230
  }
237
231
  }
238
232
 
@@ -390,4 +384,5 @@ module.exports = {
390
384
  validateInput,
391
385
  verifyApkSignature,
392
386
  inspectKeystore,
387
+ verifyKeystoreKeyPassword,
393
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 };