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.
- package/README.md +111 -28
- package/bin/storebase-keystore.js +89 -0
- package/bin/storebase-publish.js +104 -54
- package/bin/storebase-telegram.js +121 -0
- package/lib/gateway-client.js +259 -0
- package/lib/publisher-token.js +58 -0
- package/lib/publisher.js +70 -30
- package/lib/secure-prompt.js +60 -0
- package/lib/signing-profiles.js +83 -0
- package/lib/signing-vault.js +372 -0
- package/lib/telegram-client.js +178 -0
- package/lib/telegram-storage.js +405 -0
- package/package.json +11 -5
|
@@ -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 };
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
function fail(message) {
|
|
4
|
+
throw new Error(message);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function readTelegramConfig(env = process.env, options = {}) {
|
|
8
|
+
const apiIdText = env.STOREBASE_TELEGRAM_API_ID;
|
|
9
|
+
const apiHash = env.STOREBASE_TELEGRAM_API_HASH;
|
|
10
|
+
const botToken = env.STOREBASE_TELEGRAM_BOT_TOKEN;
|
|
11
|
+
const session = env.STOREBASE_TELEGRAM_SESSION || '';
|
|
12
|
+
const readSession = env.STOREBASE_TELEGRAM_READ_SESSION || '';
|
|
13
|
+
const channelValue = env.STOREBASE_TELEGRAM_CHANNEL;
|
|
14
|
+
const apiId = Number(apiIdText);
|
|
15
|
+
|
|
16
|
+
if (!apiIdText || !Number.isSafeInteger(apiId) || apiId <= 0) {
|
|
17
|
+
fail('Set STOREBASE_TELEGRAM_API_ID to the Telegram application API ID.');
|
|
18
|
+
}
|
|
19
|
+
if (typeof apiHash !== 'string' || !apiHash.trim()) {
|
|
20
|
+
fail('Set STOREBASE_TELEGRAM_API_HASH to the Telegram application API hash.');
|
|
21
|
+
}
|
|
22
|
+
if (options.requireChannel !== false && (!channelValue || !String(channelValue).trim())) {
|
|
23
|
+
fail('Set STOREBASE_TELEGRAM_CHANNEL to the target channel username (for example @StoreBaseApps) or channel ID.');
|
|
24
|
+
}
|
|
25
|
+
if (!session && !botToken) {
|
|
26
|
+
fail('Set STOREBASE_TELEGRAM_BOT_TOKEN for initial MTProto bot authorization, or provide an authorized STOREBASE_TELEGRAM_SESSION.');
|
|
27
|
+
}
|
|
28
|
+
if (options.requireReader !== false && !readSession) {
|
|
29
|
+
fail('Set STOREBASE_TELEGRAM_READ_SESSION to an authorized personal MTProto StringSession with read access to the channel. Telegram bots cannot read channel history.');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
apiId,
|
|
34
|
+
apiHash: apiHash.trim(),
|
|
35
|
+
botToken: botToken?.trim(),
|
|
36
|
+
session,
|
|
37
|
+
readSession,
|
|
38
|
+
channel: channelValue ? String(channelValue).trim() : undefined,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function createClient(StringSession, TelegramClient, session, config) {
|
|
43
|
+
return new TelegramClient(new StringSession(session), config.apiId, config.apiHash, {
|
|
44
|
+
connectionRetries: 5,
|
|
45
|
+
autoReconnect: true,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function requireBroadcastChannel(entity, label) {
|
|
50
|
+
if (!entity || entity.className !== 'Channel' || entity.broadcast !== true) {
|
|
51
|
+
fail(`${label} must resolve to a Telegram broadcast channel.`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function disconnectTelegram(connection) {
|
|
56
|
+
if (!connection) return;
|
|
57
|
+
const clients = [...new Set([connection.client, connection.readerClient].filter(Boolean))];
|
|
58
|
+
await Promise.allSettled(clients.map((client) => client.disconnect()));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function disconnectTelegramReader(connection) {
|
|
62
|
+
if (!connection?.client) return;
|
|
63
|
+
await Promise.allSettled([connection.client.disconnect()]);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function readTelegramReaderConfig(env = process.env) {
|
|
67
|
+
const apiIdText = env.STOREBASE_TELEGRAM_API_ID;
|
|
68
|
+
const apiHash = env.STOREBASE_TELEGRAM_API_HASH;
|
|
69
|
+
const readSession = env.STOREBASE_TELEGRAM_READ_SESSION;
|
|
70
|
+
const channelValue = env.STOREBASE_TELEGRAM_CHANNEL;
|
|
71
|
+
const apiId = Number(apiIdText);
|
|
72
|
+
if (!apiIdText || !Number.isSafeInteger(apiId) || apiId <= 0) {
|
|
73
|
+
fail('Set STOREBASE_TELEGRAM_API_ID to the Telegram application API ID.');
|
|
74
|
+
}
|
|
75
|
+
if (typeof apiHash !== 'string' || !apiHash.trim()) {
|
|
76
|
+
fail('Set STOREBASE_TELEGRAM_API_HASH to the Telegram application API hash.');
|
|
77
|
+
}
|
|
78
|
+
if (typeof readSession !== 'string' || !readSession.trim()) {
|
|
79
|
+
fail('Set STOREBASE_TELEGRAM_READ_SESSION to an authorized personal MTProto StringSession with read access to the channel.');
|
|
80
|
+
}
|
|
81
|
+
if (!channelValue || !String(channelValue).trim()) {
|
|
82
|
+
fail('Set STOREBASE_TELEGRAM_CHANNEL to the target channel username or channel ID.');
|
|
83
|
+
}
|
|
84
|
+
return { apiId, apiHash: apiHash.trim(), readSession: readSession.trim(), channel: String(channelValue).trim() };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function connectTelegramReader(env = process.env) {
|
|
88
|
+
const config = readTelegramReaderConfig(env);
|
|
89
|
+
const { TelegramClient } = require('teleproto');
|
|
90
|
+
const { StringSession } = require('teleproto/sessions');
|
|
91
|
+
const client = createClient(StringSession, TelegramClient, config.readSession, config);
|
|
92
|
+
try {
|
|
93
|
+
await client.connect();
|
|
94
|
+
if (!await client.checkAuthorization()) {
|
|
95
|
+
fail('STOREBASE_TELEGRAM_READ_SESSION is not authorized. Supply a valid user StringSession with access to the StoreBase channel.');
|
|
96
|
+
}
|
|
97
|
+
if (typeof client.isBot === 'function' && await client.isBot()) {
|
|
98
|
+
fail('STOREBASE_TELEGRAM_READ_SESSION must be a personal user session. Bot sessions cannot read channel history.');
|
|
99
|
+
}
|
|
100
|
+
const channelEntity = await client.getEntity(config.channel);
|
|
101
|
+
requireBroadcastChannel(channelEntity, 'STOREBASE_TELEGRAM_CHANNEL as seen by the reader session');
|
|
102
|
+
await client.getMessages(channelEntity, { limit: 1 });
|
|
103
|
+
return { client, channelEntity, channel: config.channel };
|
|
104
|
+
} catch (error) {
|
|
105
|
+
await disconnectTelegramReader({ client });
|
|
106
|
+
throw error;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function connectTelegram(env = process.env, options = {}) {
|
|
111
|
+
const config = readTelegramConfig(env, options);
|
|
112
|
+
const { TelegramClient } = require('teleproto');
|
|
113
|
+
const { StringSession } = require('teleproto/sessions');
|
|
114
|
+
const client = createClient(StringSession, TelegramClient, config.session, config);
|
|
115
|
+
let readerClient;
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
if (config.session) {
|
|
119
|
+
await client.connect();
|
|
120
|
+
const authorized = await client.checkAuthorization();
|
|
121
|
+
if (!authorized) {
|
|
122
|
+
if (!config.botToken) fail('The saved Telegram bot session is not authorized. Set STOREBASE_TELEGRAM_BOT_TOKEN to authenticate it again.');
|
|
123
|
+
await client.start({ botAuthToken: config.botToken });
|
|
124
|
+
}
|
|
125
|
+
} else {
|
|
126
|
+
await client.start({ botAuthToken: config.botToken });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (typeof client.isBot === 'function' && !await client.isBot()) {
|
|
130
|
+
fail('STOREBASE_TELEGRAM_SESSION must belong to the StoreBase admin bot; use STOREBASE_TELEGRAM_READ_SESSION for a personal user session.');
|
|
131
|
+
}
|
|
132
|
+
const channelEntity = config.channel ? await client.getEntity(config.channel) : undefined;
|
|
133
|
+
if (config.channel) requireBroadcastChannel(channelEntity, 'STOREBASE_TELEGRAM_CHANNEL');
|
|
134
|
+
|
|
135
|
+
let readerChannel;
|
|
136
|
+
if (options.requireReader !== false) {
|
|
137
|
+
readerClient = createClient(StringSession, TelegramClient, config.readSession, config);
|
|
138
|
+
await readerClient.connect();
|
|
139
|
+
if (!await readerClient.checkAuthorization()) {
|
|
140
|
+
fail('STOREBASE_TELEGRAM_READ_SESSION is not authorized. Supply a valid user StringSession with access to the StoreBase channel.');
|
|
141
|
+
}
|
|
142
|
+
if (typeof readerClient.isBot === 'function' && await readerClient.isBot()) {
|
|
143
|
+
fail('STOREBASE_TELEGRAM_READ_SESSION must be a personal user session. Bot sessions cannot read channel history.');
|
|
144
|
+
}
|
|
145
|
+
readerChannel = await readerClient.getEntity(config.channel);
|
|
146
|
+
requireBroadcastChannel(readerChannel, 'STOREBASE_TELEGRAM_CHANNEL as seen by the reader session');
|
|
147
|
+
if (String(readerChannel.id) !== String(channelEntity.id)) {
|
|
148
|
+
fail('The bot and reader sessions resolved STOREBASE_TELEGRAM_CHANNEL to different channels.');
|
|
149
|
+
}
|
|
150
|
+
// Bot accounts are barred from reading channel history; a personal MTProto session
|
|
151
|
+
// performs this read-only check and handles catalog listing and artifact retrieval.
|
|
152
|
+
await readerClient.getMessages(readerChannel, { limit: 1 });
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
client,
|
|
157
|
+
readerClient,
|
|
158
|
+
channelEntity,
|
|
159
|
+
readerChannel,
|
|
160
|
+
channel: config.channel,
|
|
161
|
+
session: client.session.save(),
|
|
162
|
+
readSession: config.readSession,
|
|
163
|
+
hasStoredSession: Boolean(config.session),
|
|
164
|
+
};
|
|
165
|
+
} catch (error) {
|
|
166
|
+
await disconnectTelegram({ client, readerClient });
|
|
167
|
+
throw error;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
module.exports = {
|
|
172
|
+
connectTelegram,
|
|
173
|
+
connectTelegramReader,
|
|
174
|
+
disconnectTelegram,
|
|
175
|
+
disconnectTelegramReader,
|
|
176
|
+
readTelegramConfig,
|
|
177
|
+
readTelegramReaderConfig,
|
|
178
|
+
};
|