storebase-cli 1.1.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.
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
@@ -73,6 +73,52 @@ Supported categories: Education, Social, Productivity, Communication, Entertainm
73
73
 
74
74
  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
75
 
76
+ ## Import a signing keystore once
77
+
78
+ 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:
79
+
80
+ ```sh
81
+ storebase-keystore import release --file /path/to/release.jks --alias release
82
+ storebase-keystore list
83
+ storebase-publish --signing-profile release \
84
+ --name "Example App" --version 2.0.0 --apk ./app-release.apk \
85
+ --avatar ./icon.png --category Productivity --description "Updated release."
86
+ ```
87
+
88
+ 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.
89
+
90
+ 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.
91
+
92
+ 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.
93
+
94
+ ### Update, rotate, or remove
95
+
96
+ ```sh
97
+ # Update passwords or replace the stored key after validating the replacement
98
+ storebase-keystore import release --file /path/to/replacement.jks --alias release --replace
99
+
100
+ # Re-encrypt every profile under a new vault passphrase
101
+ storebase-keystore rekey
102
+
103
+ # Delete a profile from the current encrypted vault
104
+ storebase-keystore remove release
105
+
106
+ # Print the location without unlocking the vault
107
+ storebase-keystore location
108
+ ```
109
+
110
+ 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.
111
+
112
+ ### Where secrets are stored
113
+
114
+ 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.
115
+
116
+ 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.
117
+
118
+ 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.
119
+
120
+ 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.
121
+
76
122
  ## Telegram storage and versions
77
123
 
78
124
  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 };
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 };
@@ -0,0 +1,372 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs/promises');
4
+ const os = require('node:os');
5
+ const path = require('node:path');
6
+ const crypto = require('node:crypto');
7
+ const { promisify } = require('node:util');
8
+
9
+ const scrypt = promisify(crypto.scrypt);
10
+ const VERSION = 1;
11
+ const SCRYPT_N = 32768;
12
+ const SCRYPT_R = 8;
13
+ const SCRYPT_P = 1;
14
+ const SCRYPT_MAXMEM = 64 * 1024 * 1024;
15
+ const MAX_FILE_SIZE = 32 * 1024 * 1024;
16
+ const MAX_PAYLOAD_SIZE = 31 * 1024 * 1024;
17
+ const MAX_KEYSTORE_SIZE = 8 * 1024 * 1024;
18
+ const AAD = Buffer.from('storebase-signing-vault:v1:scrypt-32768-8-1:aes-256-gcm', 'utf8');
19
+ const UNLOCK_ERROR = 'Signing vault could not be unlocked; passphrase may be incorrect or vault data is damaged.';
20
+ const LOCK_ERROR = 'Signing vault is locked by another operation; retry after it finishes.';
21
+
22
+ function vaultPath() {
23
+ const home = os.homedir();
24
+ if (process.platform === 'win32') return path.join(home, 'AppData', 'Local', 'storebase-cli', 'signing-vault.json');
25
+ if (process.platform === 'darwin') return path.join(home, 'Library', 'Application Support', 'storebase-cli', 'signing-vault.json');
26
+ return path.join(home, '.local', 'share', 'storebase-cli', 'signing-vault.json');
27
+ }
28
+
29
+ function checkPassphrase(passphrase) {
30
+ if (typeof passphrase !== 'string' || [...passphrase].length < 12) {
31
+ throw new Error('Signing vault passphrase must be at least 12 characters.');
32
+ }
33
+ }
34
+
35
+ function validatePayload(data) {
36
+ if (!data || typeof data !== 'object' || Array.isArray(data)
37
+ || Object.getPrototypeOf(data) !== Object.prototype
38
+ || Object.keys(data).length !== 1 || !Object.hasOwn(data, 'profiles')) {
39
+ throw new Error('Signing vault data must contain only a profiles object.');
40
+ }
41
+ const profiles = data.profiles;
42
+ if (!profiles || typeof profiles !== 'object' || Array.isArray(profiles)
43
+ || Object.getPrototypeOf(profiles) !== Object.prototype) {
44
+ throw new Error('Signing vault profiles must be an object.');
45
+ }
46
+ for (const [name, profile] of Object.entries(profiles)) {
47
+ if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(name)
48
+ || ['constructor', 'prototype', '__proto__'].includes(name)) {
49
+ throw new Error('Signing vault contains an invalid profile name.');
50
+ }
51
+ const fields = ['keystore', 'storePassword', 'keyPassword', 'alias', 'fingerprint'];
52
+ if (!profile || typeof profile !== 'object' || Array.isArray(profile)
53
+ || Object.getPrototypeOf(profile) !== Object.prototype
54
+ || Object.keys(profile).length !== fields.length
55
+ || fields.some((field) => !Object.hasOwn(profile, field))) {
56
+ throw new Error(`Signing profile "${name}" has an invalid schema.`);
57
+ }
58
+ if (typeof profile.keystore !== 'string' || profile.keystore.length === 0
59
+ || profile.keystore.length > Math.ceil(MAX_KEYSTORE_SIZE / 3) * 4
60
+ || /[^A-Za-z0-9+/=]/.test(profile.keystore)) {
61
+ throw new Error(`Signing profile "${name}" has invalid keystore data.`);
62
+ }
63
+ const keystoreBytes = Buffer.from(profile.keystore, 'base64');
64
+ if (!keystoreBytes.length || keystoreBytes.length > MAX_KEYSTORE_SIZE
65
+ || keystoreBytes.toString('base64') !== profile.keystore) {
66
+ keystoreBytes.fill(0);
67
+ throw new Error(`Signing profile "${name}" has invalid keystore data.`);
68
+ }
69
+ keystoreBytes.fill(0);
70
+ for (const field of fields.slice(1)) {
71
+ if (typeof profile[field] !== 'string' || profile[field].length > 4096
72
+ || (field !== 'storePassword' && field !== 'keyPassword' && profile[field].length === 0)
73
+ || (field === 'alias' && /[\x00-\x1f\x7f]/.test(profile[field]))
74
+ || (field === 'fingerprint' && !/^[A-F0-9]{64}$/.test(profile[field]))) {
75
+ throw new Error(`Signing profile "${name}" has an invalid ${field}.`);
76
+ }
77
+ }
78
+ }
79
+ const serialized = JSON.stringify(data);
80
+ if (Buffer.byteLength(serialized, 'utf8') > MAX_PAYLOAD_SIZE) {
81
+ throw new Error('Signing vault data exceeds the maximum supported size.');
82
+ }
83
+ return serialized;
84
+ }
85
+
86
+ function assertSafeStat(stat, { directory = false } = {}) {
87
+ if (directory ? !stat.isDirectory() : !stat.isFile()) {
88
+ throw new Error('Signing vault path has an unsafe file type.');
89
+ }
90
+ if (process.platform !== 'win32') {
91
+ if (typeof process.getuid === 'function' && stat.uid !== process.getuid()) {
92
+ throw new Error('Signing vault path is not owned by the current user.');
93
+ }
94
+ const mode = stat.mode & 0o777;
95
+ if ((mode & 0o077) !== 0 || (directory && (mode & 0o700) !== 0o700)) {
96
+ throw new Error('Signing vault path has unsafe permissions.');
97
+ }
98
+ }
99
+ }
100
+
101
+ async function lstatIfExists(target) {
102
+ try {
103
+ return await fs.lstat(target);
104
+ } catch (error) {
105
+ if (error.code === 'ENOENT') return null;
106
+ throw error;
107
+ }
108
+ }
109
+
110
+ async function ensureParent(parent, create) {
111
+ const absolute = path.resolve(parent);
112
+ const parsed = path.parse(absolute);
113
+ let current = parsed.root;
114
+ const components = absolute.slice(parsed.root.length).split(path.sep).filter(Boolean);
115
+ for (let index = 0; index < components.length; index += 1) {
116
+ current = path.join(current, components[index]);
117
+ let stat = await lstatIfExists(current);
118
+ if (!stat && create) {
119
+ try {
120
+ await fs.mkdir(current, { mode: 0o700 });
121
+ } catch (error) {
122
+ if (error.code !== 'EEXIST') throw error;
123
+ }
124
+ stat = await lstatIfExists(current);
125
+ }
126
+ if (!stat) return false;
127
+ if (stat.isSymbolicLink()) throw new Error('Signing vault parent directory must not be a symlink.');
128
+ if (!stat.isDirectory()) throw new Error('Signing vault parent path is not a directory.');
129
+ if (index === components.length - 1) assertSafeStat(stat, { directory: true });
130
+ }
131
+ return true;
132
+ }
133
+
134
+ async function checkVaultFile(filePath) {
135
+ const stat = await lstatIfExists(filePath);
136
+ if (!stat) return null;
137
+ if (stat.isSymbolicLink()) throw new Error('Signing vault file must not be a symlink.');
138
+ assertSafeStat(stat);
139
+ if (process.platform !== 'win32' && stat.nlink !== 1) {
140
+ throw new Error('Signing vault file has an unsafe link count.');
141
+ }
142
+ return stat;
143
+ }
144
+
145
+ async function deriveKey(passphrase, salt) {
146
+ const passphraseBytes = Buffer.from(passphrase, 'utf8');
147
+ try {
148
+ return await scrypt(passphraseBytes, salt, 32, {
149
+ N: SCRYPT_N, r: SCRYPT_R, p: SCRYPT_P, maxmem: SCRYPT_MAXMEM,
150
+ });
151
+ } finally {
152
+ passphraseBytes.fill(0);
153
+ }
154
+ }
155
+
156
+ function decodeBase64(value, expectedLength) {
157
+ if (typeof value !== 'string' || /[^A-Za-z0-9+/=]/.test(value)) return null;
158
+ const result = Buffer.from(value, 'base64');
159
+ if (result.toString('base64') !== value || (expectedLength !== undefined && result.length !== expectedLength)) {
160
+ result.fill(0);
161
+ return null;
162
+ }
163
+ return result;
164
+ }
165
+
166
+ function validateEnvelope(envelope) {
167
+ if (!envelope || typeof envelope !== 'object' || Array.isArray(envelope)
168
+ || Object.getPrototypeOf(envelope) !== Object.prototype
169
+ || Object.keys(envelope).sort().join(',') !== 'cipher,kdf,version'
170
+ || envelope.version !== VERSION) throw new Error(UNLOCK_ERROR);
171
+ const { kdf, cipher } = envelope;
172
+ if (!kdf || typeof kdf !== 'object' || Array.isArray(kdf)
173
+ || Object.keys(kdf).sort().join(',') !== 'N,name,p,r,salt'
174
+ || kdf.name !== 'scrypt' || kdf.N !== SCRYPT_N || kdf.r !== SCRYPT_R || kdf.p !== SCRYPT_P
175
+ || !cipher || typeof cipher !== 'object' || Array.isArray(cipher)
176
+ || Object.keys(cipher).sort().join(',') !== 'data,iv,name,tag' || cipher.name !== 'aes-256-gcm') {
177
+ throw new Error(UNLOCK_ERROR);
178
+ }
179
+ const salt = decodeBase64(kdf.salt, 16);
180
+ const iv = decodeBase64(cipher.iv, 12);
181
+ const tag = decodeBase64(cipher.tag, 16);
182
+ const ciphertext = decodeBase64(cipher.data);
183
+ if (!salt || !iv || !tag || !ciphertext || ciphertext.length > MAX_PAYLOAD_SIZE) {
184
+ for (const buffer of [salt, iv, tag, ciphertext]) buffer?.fill(0);
185
+ throw new Error(UNLOCK_ERROR);
186
+ }
187
+ return { salt, iv, tag, ciphertext };
188
+ }
189
+
190
+ async function decryptEnvelope(raw, passphrase) {
191
+ let plaintext;
192
+ let key;
193
+ let parsed;
194
+ let pieces;
195
+ try {
196
+ if (raw.length > MAX_FILE_SIZE) throw new Error(UNLOCK_ERROR);
197
+ parsed = JSON.parse(raw.toString('utf8'));
198
+ pieces = validateEnvelope(parsed);
199
+ key = await deriveKey(passphrase, pieces.salt);
200
+ const decipher = crypto.createDecipheriv('aes-256-gcm', key, pieces.iv);
201
+ decipher.setAAD(AAD);
202
+ decipher.setAuthTag(pieces.tag);
203
+ plaintext = Buffer.concat([decipher.update(pieces.ciphertext), decipher.final()]);
204
+ if (plaintext.length > MAX_PAYLOAD_SIZE) throw new Error(UNLOCK_ERROR);
205
+ const value = JSON.parse(plaintext.toString('utf8'));
206
+ validatePayload(value);
207
+ return value;
208
+ } catch {
209
+ throw new Error(UNLOCK_ERROR);
210
+ } finally {
211
+ key?.fill(0);
212
+ plaintext?.fill(0);
213
+ if (pieces) for (const value of Object.values(pieces)) value.fill(0);
214
+ }
215
+ }
216
+
217
+ async function encryptPayload(data, passphrase) {
218
+ const serialized = validatePayload(data);
219
+ const plaintext = Buffer.from(serialized, 'utf8');
220
+ const salt = crypto.randomBytes(16);
221
+ const iv = crypto.randomBytes(12);
222
+ let key;
223
+ let ciphertext;
224
+ try {
225
+ key = await deriveKey(passphrase, salt);
226
+ const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
227
+ cipher.setAAD(AAD);
228
+ ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
229
+ const envelope = {
230
+ version: VERSION,
231
+ kdf: { name: 'scrypt', N: SCRYPT_N, r: SCRYPT_R, p: SCRYPT_P, salt: salt.toString('base64') },
232
+ cipher: { name: 'aes-256-gcm', iv: iv.toString('base64'), tag: cipher.getAuthTag().toString('base64'), data: ciphertext.toString('base64') },
233
+ };
234
+ const output = Buffer.from(JSON.stringify(envelope), 'utf8');
235
+ if (output.length > MAX_FILE_SIZE) {
236
+ output.fill(0);
237
+ throw new Error('Signing vault data exceeds the maximum supported size.');
238
+ }
239
+ return output;
240
+ } finally {
241
+ plaintext.fill(0);
242
+ salt.fill(0);
243
+ iv.fill(0);
244
+ key?.fill(0);
245
+ ciphertext?.fill(0);
246
+ }
247
+ }
248
+
249
+ function clearData(data) {
250
+ if (!data?.profiles || typeof data.profiles !== 'object') return;
251
+ for (const profile of Object.values(data.profiles)) {
252
+ if (!profile || typeof profile !== 'object') continue;
253
+ for (const field of ['keystore', 'storePassword', 'keyPassword']) if (Object.hasOwn(profile, field)) profile[field] = '';
254
+ }
255
+ }
256
+
257
+ class SigningVault {
258
+ constructor({ filePath } = {}) {
259
+ this.filePath = path.resolve(filePath || vaultPath());
260
+ }
261
+
262
+ async _read(passphrase) {
263
+ checkPassphrase(passphrase);
264
+ const parentExists = await ensureParent(path.dirname(this.filePath), false);
265
+ if (!parentExists) return { profiles: {} };
266
+ const stat = await checkVaultFile(this.filePath);
267
+ if (!stat) return { profiles: {} };
268
+ if (stat.size > MAX_FILE_SIZE) throw new Error(UNLOCK_ERROR);
269
+ let handle;
270
+ let raw;
271
+ try {
272
+ const noFollow = fs.constants?.O_NOFOLLOW || 0;
273
+ handle = await fs.open(this.filePath, require('node:fs').constants.O_RDONLY | noFollow);
274
+ const opened = await handle.stat();
275
+ assertSafeStat(opened);
276
+ if (opened.size > MAX_FILE_SIZE) throw new Error(UNLOCK_ERROR);
277
+ raw = await handle.readFile();
278
+ return await decryptEnvelope(raw, passphrase);
279
+ } finally {
280
+ raw?.fill(0);
281
+ await handle?.close();
282
+ }
283
+ }
284
+
285
+ async read(passphrase) {
286
+ return this._read(passphrase);
287
+ }
288
+
289
+ async _write(data, passphrase) {
290
+ const output = await encryptPayload(data, passphrase);
291
+ const parent = path.dirname(this.filePath);
292
+ let tempPath;
293
+ let handle;
294
+ try {
295
+ // The parent and target are checked immediately before creating the atomic replacement.
296
+ await ensureParent(parent, true);
297
+ await checkVaultFile(this.filePath);
298
+ tempPath = path.join(parent, `.${path.basename(this.filePath)}.${process.pid}.${crypto.randomBytes(8).toString('hex')}.tmp`);
299
+ handle = await fs.open(tempPath, 'wx', 0o600);
300
+ if (process.platform !== 'win32') await handle.chmod(0o600);
301
+ await handle.writeFile(output);
302
+ await handle.sync();
303
+ await handle.close();
304
+ handle = null;
305
+ await checkVaultFile(this.filePath);
306
+ await fs.rename(tempPath, this.filePath);
307
+ tempPath = null;
308
+ if (process.platform !== 'win32') {
309
+ const dirHandle = await fs.open(parent, 'r');
310
+ try { await dirHandle.sync(); } finally { await dirHandle.close(); }
311
+ }
312
+ } finally {
313
+ output.fill(0);
314
+ await handle?.close().catch(() => {});
315
+ if (tempPath) await fs.unlink(tempPath).catch(() => {});
316
+ }
317
+ }
318
+
319
+ async _withLock(callback) {
320
+ await ensureParent(path.dirname(this.filePath), true);
321
+ const lockPath = `${this.filePath}.lock`;
322
+ let handle;
323
+ let lockStat;
324
+ try {
325
+ handle = await fs.open(lockPath, 'wx', 0o600);
326
+ if (process.platform !== 'win32') await handle.chmod(0o600);
327
+ lockStat = await handle.stat();
328
+ assertSafeStat(lockStat);
329
+ } catch (error) {
330
+ await handle?.close().catch(() => {});
331
+ if (error.code === 'EEXIST') throw new Error(LOCK_ERROR);
332
+ throw error;
333
+ }
334
+ try {
335
+ return await callback();
336
+ } finally {
337
+ await handle.close().catch(() => {});
338
+ try {
339
+ const current = await fs.lstat(lockPath);
340
+ if (!current.isSymbolicLink() && current.dev === lockStat.dev && current.ino === lockStat.ino) await fs.unlink(lockPath);
341
+ } catch { /* Best-effort cleanup; never remove a lock we do not own. */ }
342
+ }
343
+ }
344
+
345
+ async mutate(passphrase, updater) {
346
+ checkPassphrase(passphrase);
347
+ if (typeof updater !== 'function') throw new Error('Signing vault mutation callback is required.');
348
+ return this._withLock(async () => {
349
+ const data = await this._read(passphrase);
350
+ let updated;
351
+ let detached;
352
+ try {
353
+ const replacement = await updater(data);
354
+ updated = replacement === undefined ? data : replacement;
355
+ const serialized = validatePayload(updated);
356
+ detached = JSON.parse(serialized);
357
+ await this._write(detached, passphrase);
358
+ } finally { clearData(data); clearData(updated); clearData(detached); }
359
+ });
360
+ }
361
+
362
+ async rekey(oldPassphrase, newPassphrase) {
363
+ checkPassphrase(oldPassphrase);
364
+ checkPassphrase(newPassphrase);
365
+ return this._withLock(async () => {
366
+ const data = await this._read(oldPassphrase);
367
+ try { await this._write(data, newPassphrase); } finally { clearData(data); }
368
+ });
369
+ }
370
+ }
371
+
372
+ module.exports = { SigningVault, vaultPath };
package/package.json CHANGED
@@ -1,17 +1,18 @@
1
1
  {
2
2
  "name": "storebase-cli",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Publish signed Android apps to a versioned Telegram-backed StoreBase catalog",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
7
7
  "type": "commonjs",
8
8
  "bin": {
9
9
  "storebase-publish": "bin/storebase-publish.js",
10
- "storebase-telegram": "bin/storebase-telegram.js"
10
+ "storebase-telegram": "bin/storebase-telegram.js",
11
+ "storebase-keystore": "bin/storebase-keystore.js"
11
12
  },
12
13
  "files": [
13
- "bin/",
14
- "lib/",
14
+ "bin/*.js",
15
+ "lib/*.js",
15
16
  "README.md"
16
17
  ],
17
18
  "engines": {