telstore 0.1.1 → 0.1.3

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
@@ -14,6 +14,7 @@ npx telstore status # account, destination, unfinished bac
14
14
  npx telstore list # what is already stored in the destination
15
15
  npx telstore restore telstore-20260905-7f3a91
16
16
  npx telstore delete telstore-20260905-7f3a91 # take it back out of the chat, for good
17
+ npx telstore token # a session token for a machine you do not trust
17
18
  ```
18
19
 
19
20
  ## What you need
@@ -40,7 +41,8 @@ npx telstore config chunkSize --unset # back to the default
40
41
  |---|---|---|---|
41
42
  | `chat` | `--to` | none | `@username`, `-100123…`, or `me`. A negative channel id works with a space or an `=`, as a flag or as a config value. |
42
43
  | `chunkSize` | `--chunk-size` | `1800MB` | e.g. `1.8GB`, `500MB`. Hard ceiling 1950MB. An unfinished backup keeps the size it started with. |
43
- | `concurrency` | `--concurrency` | `8` | 512KB parts sent in parallel. An integer from 1 to 64. Upload only — `restore` downloads through its own fixed pool of workers. |
44
+ | `uploadConcurrency` | `--upload-concurrency` | `32` | 512KB parts sent in parallel while uploading. An integer from 1 to 64. |
45
+ | `downloadConcurrency` | `--download-concurrency` | `8` | 8MB slices fetched in parallel while restoring. An integer from 1 to 64. |
44
46
  | `limit` | `--limit` | `20` | How many backups `list` shows, newest first |
45
47
  | `verbose` | `--verbose` | off | Show the Telegram client's own connection logs, hidden by default so they do not break up the progress bar |
46
48
 
@@ -127,10 +129,71 @@ manifest or local record giving a message id that is not a whole positive number
127
129
  deletes anything at all. A manifest too damaged for `restore` to use *can* still be deleted —
128
130
  that is usually the one you want gone.
129
131
 
132
+ ## Running on a machine you do not trust
133
+
134
+ `login` leaves your Telegram session on the machine you run it on, in plain text. When that
135
+ machine is not yours, print a **session token** on one that is and log in with that instead:
136
+
137
+ ```bash
138
+ # on the machine you are already logged in on
139
+ npx telstore token # asks for a passphrase, twice
140
+ # prints one line: tls1.…
141
+
142
+ # on the other machine
143
+ npx telstore login --token # paste the token, then type the passphrase
144
+ npx telstore restore telstore-20260905-7f3a91
145
+ npx telstore logout # when you are done
146
+ ```
147
+
148
+ The token holds your `api_id`, `api_hash`, session and settings, encrypted with the
149
+ passphrase (AES-256-GCM, key derived with scrypt). It is one line of base64url, so it
150
+ survives a chat message, an email or a QR code — and a copy that leaks without the
151
+ passphrase is not enough to use your account.
152
+
153
+ `login --token` stores the token exactly as it arrived, so **the session never exists on that
154
+ machine in a form anyone can read**. Every command that talks to Telegram asks for the
155
+ passphrase and opens it in memory:
156
+
157
+ ```json
158
+ { "sealed": "tls1.…", "settings": { "chat": "@my_backups" } }
159
+ ```
160
+
161
+ `npx telstore status` shows which of the two you are running:
162
+
163
+ ```
164
+ Session /home/you/.telstore/config.json (sealed — opened with a passphrase)
165
+ Account Sho (@shovity)
166
+ Destination https://web.telegram.org/k/#@my_backups
167
+ Unfinished none
168
+ ```
169
+
170
+ ### What is left behind
171
+
172
+ - **No readable session.** `logout` removes the sealed blob, and with it the `api_hash`,
173
+ which lives inside it.
174
+ - **State files, yes.** An upload writes `~/.telstore/state/<key>.json` so it can be resumed
175
+ after an interruption. It holds the file path, the chunk sizes and the message ids — no
176
+ secret — and losing it would strand chunks in the chat with nothing able to name them.
177
+
178
+ ### Things worth knowing before you use one
179
+
180
+ - **`--token` takes no value, on purpose.** A token written on the command line would sit in
181
+ `ps` for the whole life of the command and stay in that machine's shell history afterwards.
182
+ It is pasted at a prompt that does not echo it.
183
+ - **The passphrase may be left empty**, and then the token says so on its face: it starts
184
+ `tls0.` instead of `tls1.`, `token` warns, and `login` stores the session in plain text
185
+ exactly as an ordinary login would. telstore will not write a file that looks encrypted and
186
+ is not.
187
+ - **telstore cannot take a token back.** There is no expiry and no revocation. Like `logout`,
188
+ it says nothing to Telegram — to end the session for good, open Telegram → Settings →
189
+ Devices (Active sessions) and terminate it. Every copy of the token dies with it.
190
+ - **A wrong passphrase and a damaged token are the same event** to AES-GCM: one failed
191
+ authentication check. telstore names both possibilities rather than guessing which it was.
192
+
130
193
  ## Limits worth knowing
131
194
 
132
195
  - A chunk cannot exceed 1950MB: Telegram accepts at most 4000 parts of 512KB per file, an arithmetic ceiling of about 1953MB, and telstore stops at 1950MB to leave a safety margin.
133
- - The data is **not** encrypted. Don't upload anything you would mind sitting on someone else's infrastructure.
196
+ - The data is **not** encrypted. Don't upload anything you would mind sitting on someone else's infrastructure. The one thing telstore does encrypt is a session token, and that protects your login rather than your files.
134
197
  - Deleting a chunk message on Telegram destroys the backup, with no way to recover it. Use `npx telstore delete <backup-id>` when that is what you actually want.
135
198
  - Keep the `backupId`. Without it you have to hunt for the manifest in the chat by hand.
136
199
 
@@ -147,6 +210,16 @@ that is usually the one you want gone.
147
210
  }
148
211
  ```
149
212
 
213
+ A machine logged in with `npx telstore login --token` holds `sealed` instead of those three
214
+ fields, and nothing else about the account:
215
+
216
+ ```json
217
+ { "sealed": "tls1.…", "settings": { "chat": "@my_backups" } }
218
+ ```
219
+
220
+ A file holding both shapes is refused rather than guessed at — there would be no way to know
221
+ which account was meant.
222
+
150
223
  Editing it by hand is fine, and a value that cannot be used is named on the next run — with the file and the key, never with a flag you did not type.
151
224
 
152
225
  `npx telstore logout` **only deletes the locally stored session** and keeps the rest — the session is still alive on Telegram's side. To revoke access for good, open Telegram → Settings → Devices (Active sessions) and terminate that session.
package/bin/telstore.js CHANGED
@@ -7,6 +7,7 @@ import { runList } from '../src/commands/list.js'
7
7
  import { runLogout } from '../src/commands/logout.js'
8
8
  import { runRestore } from '../src/commands/restore.js'
9
9
  import { runStatus } from '../src/commands/status.js'
10
+ import { runToken } from '../src/commands/token.js'
10
11
  import { runUpload } from '../src/commands/upload.js'
11
12
 
12
13
  const SIGINT_EXIT_CODE = 130
@@ -18,6 +19,10 @@ let currentCommand = null
18
19
  let currentBackupId = null
19
20
 
20
21
  process.on('SIGINT', () => {
22
+ // A passphrase prompt has stdin in raw mode, and process.exit skips readline's own cleanup.
23
+ // Without this, Ctrl-C hands back a shell that no longer echoes what is typed into it.
24
+ if (process.stdin.isTTY) process.stdin.setRawMode(false)
25
+
21
26
  process.stderr.write(interruptMessage(currentCommand, { backupId: currentBackupId }))
22
27
  process.exit(SIGINT_EXIT_CODE)
23
28
  })
@@ -41,7 +46,11 @@ async function main() {
41
46
  return
42
47
 
43
48
  case 'login':
44
- await runLogin({ verbose: Boolean(parsed.options.verbose) })
49
+ await runLogin({
50
+ args: parsed.args,
51
+ token: Boolean(parsed.options.token),
52
+ verbose: Boolean(parsed.options.verbose),
53
+ })
45
54
  return
46
55
 
47
56
  case 'logout':
@@ -60,6 +69,10 @@ async function main() {
60
69
  await runConfig(parsed.args, parsed.options)
61
70
  return
62
71
 
72
+ case 'token':
73
+ await runToken(parsed.args, parsed.options)
74
+ return
75
+
63
76
  case 'upload':
64
77
  await runUpload(parsed.args[0], parsed.options, {
65
78
  onBackupId: (id) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "telstore",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Split large files into chunks and store them on Telegram",
5
5
  "keywords": [
6
6
  "telegram",
package/src/chunking.js CHANGED
@@ -7,7 +7,20 @@ export const MAX_PARTS = 4000
7
7
  export const SLICE_SIZE = 8 * 1024 * 1024
8
8
  export const MAX_CHUNK_SIZE = 1950 * 1024 * 1024
9
9
  export const DEFAULT_CHUNK_SIZE = 1800 * 1024 * 1024
10
- export const DEFAULT_CONCURRENCY = 8
10
+ // Upload counts 512KB parts and download counts 8MB slices, so one number cannot serve both.
11
+ // Measured on a 1Gb/s line against a single 1800MB chunk, so every run transfers for three
12
+ // to five minutes rather than the seconds a burst benchmark samples — the distinction the
13
+ // parallel-download spec insists on, and it matters: measured in 20s bursts the download
14
+ // order came out reversed, with 4 apparently the fastest value.
15
+ // upload 16 -> 7.7 MB/s (234s), 32 -> 9.4 (193s), 64 -> 9.5 (191s)
16
+ // download 4 -> 5.8 MB/s (311s), 8 -> 6.0 (300s), 16 -> 6.0 (302s)
17
+ // Upload takes 32 because 64 measures the same speed (1% apart, inside the noise) at twice
18
+ // the cost: 64 puts 32MB in flight, and a batch's last part then needs 4.4 Mbps to land
19
+ // before the 60s stall deadline, against 2.1 Mbps at 32. A slow link would be told its
20
+ // transfer stalled while it was merely slow, which is the one thing that deadline must not say.
21
+ // Download takes 8 for the same reason from the other side: 16 buys nothing and 4 is slower.
22
+ export const DEFAULT_UPLOAD_CONCURRENCY = 32
23
+ export const DEFAULT_DOWNLOAD_CONCURRENCY = 8
11
24
  export const MAX_CONCURRENCY = 64
12
25
 
13
26
  // Every chunk is one message in the chat and one entry in the manifest, so a plan this long
package/src/cli.js CHANGED
@@ -8,18 +8,21 @@ const SUBCOMMANDS = new Set([
8
8
  'delete',
9
9
  'status',
10
10
  'config',
11
+ 'token',
11
12
  'help',
12
13
  ])
13
14
 
14
15
  const OPTIONS = {
15
16
  to: { type: 'string' },
16
17
  'chunk-size': { type: 'string' },
17
- concurrency: { type: 'string' },
18
+ 'upload-concurrency': { type: 'string' },
19
+ 'download-concurrency': { type: 'string' },
18
20
  out: { type: 'string' },
19
21
  limit: { type: 'string' },
20
22
  verbose: { type: 'boolean' },
21
23
  unset: { type: 'boolean' },
22
24
  yes: { type: 'boolean' },
25
+ token: { type: 'boolean' },
23
26
  help: { type: 'boolean', short: 'h' },
24
27
  }
25
28
 
@@ -35,28 +38,38 @@ Usage:
35
38
  npx telstore config Show every setting and where its value comes from
36
39
  npx telstore logout Remove the saved session
37
40
 
41
+ Running on a machine you do not trust:
42
+ npx telstore token Print a session token for another machine
43
+ npx telstore login --token Log in there by pasting one, session stays sealed
44
+
38
45
  Settings:
39
46
  npx telstore config <name> Print one setting's value
40
47
  npx telstore config <name> <value> Change it for good
41
48
  npx telstore config <name> --unset Drop it and fall back to the default
42
49
 
43
- chat Where backups go: @username, -100123..., or me. No default.
44
- chunkSize Size of each chunk, default 1800MB. Examples: 1.8GB, 500MB.
45
- concurrency 512KB parts sent in parallel, default 8, max 64. Upload only.
46
- limit How many backups list shows, default 20.
47
- verbose Show Telegram connection logs, default false.
50
+ chat Where backups go: @username, -100123..., or me. No default.
51
+ chunkSize Size of each chunk, default 1800MB. Examples: 1.8GB, 500MB.
52
+ uploadConcurrency 512KB parts sent in parallel, default 32, max 64.
53
+ downloadConcurrency 8MB slices fetched in parallel, default 8, max 64.
54
+ limit How many backups list shows, default 20.
55
+ verbose Show Telegram connection logs, default false.
48
56
 
49
57
  Options apply to one run and are never saved. Use config to change a setting for good.
50
- --to <chat> Destination for this run only.
51
- --chunk-size <n> Chunk size for this run only. An unfinished backup keeps the size
52
- it started with.
53
- --concurrency <n> Parts in parallel for this run only. Upload only — restore always
54
- downloads with its own fixed pool of workers.
55
- --out <path> Where to write the restored file. Defaults to the basename in the manifest.
56
- --limit <n> How many backups list shows this run.
57
- --yes Delete without asking to confirm first.
58
- --verbose Show Telegram connection logs for this run.
59
- -h, --help Show this help.
58
+ --to <chat> Destination for this run only.
59
+ --chunk-size <n> Chunk size for this run only. An unfinished backup keeps the
60
+ size it started with.
61
+ --upload-concurrency <n> 512KB parts in parallel while uploading, this run only.
62
+ --download-concurrency <n> 8MB slices in parallel while restoring, this run only.
63
+ --out <path> Where to write the restored file. Defaults to the basename in
64
+ the manifest.
65
+ --limit <n> How many backups list shows this run.
66
+ --token Log in by pasting a session token. It takes no value on
67
+ purpose: a token written on the command line would sit in
68
+ "ps" for the whole life of the command, and stay in that
69
+ machine's shell history afterwards.
70
+ --yes Delete without asking to confirm first.
71
+ --verbose Show Telegram connection logs for this run.
72
+ -h, --help Show this help.
60
73
  `
61
74
 
62
75
  // What Ctrl-C means depends on the command that was running: upload has written every
package/src/client.js CHANGED
@@ -5,6 +5,7 @@ import { StringSession } from 'telegram/sessions/index.js'
5
5
 
6
6
  import { manifestFileName } from './manifest.js'
7
7
  import { withRetry } from './retry.js'
8
+ import { unlockConfig } from './session.js'
8
9
  import { DEFAULT_STALL_MS, withStallTimeout } from './stall.js'
9
10
 
10
11
  // GramJS narrates its version, every connection and every disconnect at info level, and
@@ -121,16 +122,27 @@ export async function closeQuietly(client, disconnect, onWarn) {
121
122
  }
122
123
  }
123
124
 
125
+ // Two shapes count as logged in: the ordinary one login writes, and the sealed blob that
126
+ // "login --token" leaves, which holds the same three fields behind a passphrase. Checked
127
+ // before anything asks for that passphrase, so somebody who never logged in is told so rather
128
+ // than asked to type a secret for an account that is not there.
124
129
  export function assertLoggedIn(config) {
130
+ if (config.sealed) return
131
+
125
132
  if (!config.session || !config.apiId || !config.apiHash) {
126
133
  throw new Error('Not logged in — run "npx telstore login" first.')
127
134
  }
128
135
  }
129
136
 
130
- export async function connect(config, { verbose = false } = {}) {
137
+ // Every command that needs Telegram comes through here, which makes this the one place a
138
+ // sealed session has to be opened. Doing it anywhere else would mean eight places to keep in
139
+ // step, and a ninth command would simply forget.
140
+ export async function connect(config, { verbose = false, unlock = unlockConfig } = {}) {
131
141
  assertLoggedIn(config)
132
142
 
133
- const client = new TelegramClient(new StringSession(config.session), config.apiId, config.apiHash, {
143
+ const { apiId, apiHash, session } = await unlock(config)
144
+
145
+ const client = new TelegramClient(new StringSession(session), apiId, apiHash, {
134
146
  connectionRetries: 5,
135
147
  floodSleepThreshold: 60,
136
148
  baseLogger: createLogger(verbose),
@@ -1,21 +1,12 @@
1
- import readline from 'node:readline/promises'
2
- import { stdin, stdout } from 'node:process'
3
-
4
1
  import { TelegramClient } from 'telegram'
5
2
  import { StringSession } from 'telegram/sessions/index.js'
6
3
 
7
4
  import { loadConfig, saveConfig, defaultConfigDir } from '../config.js'
8
5
  import { normalizeChatTarget } from '../chat.js'
9
- import { createLogger } from '../client.js'
10
- import { resolveSettings } from '../settings.js'
11
-
12
- function createPrompts() {
13
- const rl = readline.createInterface({ input: stdin, output: stdout })
14
- return {
15
- ask: (question) => rl.question(question),
16
- close: () => rl.close(),
17
- }
18
- }
6
+ import { closeQuietly, connect as realConnect, createLogger } from '../client.js'
7
+ import { createPrompts } from '../prompt.js'
8
+ import { knownSettings, resolveSettings } from '../settings.js'
9
+ import { decodeToken, isSealedToken } from '../token.js'
19
10
 
20
11
  const LOGIN_ERROR_MESSAGES = {
21
12
  PHONE_NUMBER_INVALID: 'invalid phone number',
@@ -43,14 +34,96 @@ export function describeLoginError(err) {
43
34
  const createTelegramClient = (apiId, apiHash, options) =>
44
35
  new TelegramClient(new StringSession(''), apiId, apiHash, options)
45
36
 
37
+ // The token never reaches here as an argument, and telstore refuses to pretend otherwise: a
38
+ // token on the command line sits in the shell history of a machine the user does not trust,
39
+ // and ignoring it silently would leave it there for nothing.
40
+ function refuseArguments(args) {
41
+ if (args.length === 0) return
42
+
43
+ throw new Error(
44
+ 'login takes no arguments. A session token is pasted at a prompt, never written on the ' +
45
+ 'command line — there it stays in this machine\'s shell history and is visible in "ps" ' +
46
+ 'for as long as the command runs. Run "npx telstore login --token" and paste it when asked.',
47
+ )
48
+ }
49
+
50
+ // Logging in with a token telstore printed elsewhere. The blob is stored exactly as it
51
+ // arrived rather than opened and written back out: what makes this worth doing is that the
52
+ // session never exists on this machine's disk in a form anyone can read.
53
+ async function loginWithToken({ configDir, prompts, connectWith, shutdown, verbose, log }) {
54
+ const token = (await prompts.askSecret('Session token: ')).trim()
55
+ const passphrase = isSealedToken(token) ? await prompts.askSecret('Passphrase for the token: ') : ''
56
+
57
+ // Opened here, before anything is written, so a wrong passphrase is a sentence now rather
58
+ // than a failure at the start of a restore that was going to take twenty minutes.
59
+ const bundle = await decodeToken(token, passphrase)
60
+ const account = { apiId: bundle.apiId, apiHash: bundle.apiHash, session: bundle.session }
61
+
62
+ // A token says what the session was when it was made. Only Telegram can say whether that
63
+ // session is still alive, and finding out now is the difference between a login that failed
64
+ // and a login that appeared to work.
65
+ const client = await connectWith(account, { verbose })
66
+
67
+ let me
68
+ try {
69
+ me = await client.getMe()
70
+ } finally {
71
+ await closeQuietly(client, shutdown)
72
+ }
73
+
74
+ const config = await loadConfig(configDir)
75
+ // Built fresh rather than merged over what was there: a config holding both a sealed
76
+ // session and a plain one is refused on the next run, and merging is how it would come to
77
+ // hold both. Settings are the exception, because they are nobody's secret.
78
+ const next = passphrase === '' ? { ...account } : { sealed: token }
79
+ const settings = { ...config.settings, ...knownSettings(bundle.settings) }
80
+
81
+ if (Object.keys(settings).length > 0) next.settings = settings
82
+
83
+ await saveConfig(next, configDir)
84
+
85
+ log(`\nLogged in as ${me.username ? `@${me.username}` : me.firstName}.`)
86
+
87
+ if (passphrase === '') {
88
+ log(
89
+ `Config saved to ${configDir}/config.json. This token had no passphrase, so the session ` +
90
+ 'is stored here in plain text, exactly as an ordinary login would store it.',
91
+ )
92
+ return
93
+ }
94
+
95
+ log(
96
+ `Config saved to ${configDir}/config.json, with the session sealed behind your ` +
97
+ 'passphrase. Every command that talks to Telegram will ask for it.',
98
+ )
99
+ }
100
+
46
101
  export async function runLogin({
47
102
  configDir = defaultConfigDir(),
48
- prompts = createPrompts(),
103
+ args = [],
104
+ token = false,
105
+ prompts = null,
106
+ connectWith = realConnect,
49
107
  verbose = false,
50
108
  createClient = createTelegramClient,
51
109
  shutdown = (client) => client.destroy(),
52
110
  log = (line) => console.log(line),
53
111
  } = {}) {
112
+ refuseArguments(args)
113
+
114
+ // Opened here and not in the parameter list: a default argument runs on every call, so the
115
+ // refusal above would open a readline on stdin it never reads from and the process would
116
+ // hang with nothing left to do.
117
+ prompts = prompts ?? createPrompts()
118
+
119
+ if (token) {
120
+ try {
121
+ return await loginWithToken({ configDir, prompts, connectWith, shutdown, verbose, log })
122
+ } finally {
123
+ prompts.close()
124
+ }
125
+ }
126
+
54
127
  const config = await loadConfig(configDir)
55
128
  const storedChat = config.settings?.chat
56
129
  const loud = verbose || resolveSettings({}, config).values.verbose
@@ -80,7 +153,7 @@ export async function runLogin({
80
153
  await client.start({
81
154
  phoneNumber: () => prompts.ask('Phone number (e.g. +1...): '),
82
155
  phoneCode: () => prompts.ask('Verification code Telegram just sent: '),
83
- password: () => prompts.ask('Two-step password (leave blank if not enabled): '),
156
+ password: () => prompts.askSecret('Two-step password (leave blank if not enabled): '),
84
157
  onError: (err) => console.error(`Login failed: ${describeLoginError(err)}`),
85
158
  })
86
159
 
@@ -1,11 +1,21 @@
1
- import { clearSession, defaultConfigDir } from '../config.js'
1
+ import { clearSession, defaultConfigDir, loadConfig } from '../config.js'
2
+
3
+ // The same true fact in both cases — the session outlives this machine — but not the same
4
+ // sentence about what is left behind. An ordinary login keeps the api_id and api_hash beside
5
+ // the session, and a sealed one keeps them inside it, so there the api_hash goes too. Saying
6
+ // otherwise would describe a machine other than the one in front of the reader.
7
+ export async function runLogout({ configDir = defaultConfigDir(), log = (line) => console.log(line) } = {}) {
8
+ const { sealed } = await loadConfig(configDir)
2
9
 
3
- export async function runLogout({ configDir = defaultConfigDir() } = {}) {
4
10
  await clearSession(configDir)
5
- console.log(
6
- 'Removed the session stored on this machine. api_id, api_hash and the destination are kept.',
11
+
12
+ log(
13
+ sealed
14
+ ? 'Removed the sealed session stored on this machine. The api_id and api_hash were ' +
15
+ 'inside it, so they are gone with it; the destination is kept.'
16
+ : 'Removed the session stored on this machine. api_id, api_hash and the destination are kept.',
7
17
  )
8
- console.log(
18
+ log(
9
19
  'Note: this only deletes the local copy — the session is still alive on Telegram\'s side. ' +
10
20
  'To revoke access for good, open Telegram → Settings → Devices (Active sessions) ' +
11
21
  'and terminate that session.',
@@ -28,8 +28,8 @@ async function realGetMessage(client, peer, msgId) {
28
28
  return message ?? null
29
29
  }
30
30
 
31
- export async function realDownloadChunk(client, message, handle, offset, onProgress, retryOptions) {
32
- return await downloadToFile(client, message, handle.fd, { offset, onProgress, retryOptions })
31
+ export async function realDownloadChunk(client, message, handle, offset, onProgress, options) {
32
+ return await downloadToFile(client, message, handle.fd, { offset, onProgress, ...options })
33
33
  }
34
34
 
35
35
  // manifest.name comes from data downloaded off Telegram — don't trust it when picking
@@ -165,7 +165,10 @@ export async function runRestore(backupId, options = {}, deps = {}) {
165
165
  handle,
166
166
  chunk.i * manifest.chunkSize,
167
167
  progress.advance,
168
- { ...retryOptions, onRetry },
168
+ {
169
+ retryOptions: { ...retryOptions, onRetry },
170
+ concurrency: settings.downloadConcurrency,
171
+ },
169
172
  )
170
173
 
171
174
  if (size !== chunk.size) {
@@ -1,12 +1,10 @@
1
- import path from 'node:path'
2
-
3
1
  import { countChunks } from '../chunking.js'
4
2
  import { describeChat } from '../chat.js'
5
3
  import { assertLoggedIn, closeQuietly, connect as realConnect } from '../client.js'
6
4
  import { configFile, defaultConfigDir, loadConfig } from '../config.js'
7
5
  import { formatBytes } from '../progress.js'
8
6
  import { resolveSettings } from '../settings.js'
9
- import { listStates } from '../state.js'
7
+ import { canResume, listStates } from '../state.js'
10
8
 
11
9
  const LABEL_WIDTH = 'Destination'.length + 2
12
10
 
@@ -14,6 +12,66 @@ function row(label, value) {
14
12
  return `${label.padEnd(LABEL_WIDTH)}${value}`
15
13
  }
16
14
 
15
+ // Each unfinished backup gets its own indented block, so the fields line up under a heading
16
+ // that is the id — the one string `restore` and `delete` both take.
17
+ const FIELD_WIDTH = 'Resume'.length + 3
18
+ const CONTINUATION = ' '.repeat(FIELD_WIDTH + 2)
19
+
20
+ function field(label, value) {
21
+ return ` ${label.padEnd(FIELD_WIDTH)}${value}`
22
+ }
23
+
24
+ // The Resume line is a command meant to be pasted, so anything a shell would take apart has
25
+ // to come back quoted — a path with a space in it is the ordinary case, not an exotic one.
26
+ const BARE_ARG = /^[A-Za-z0-9_@%+:,./-]+$/
27
+
28
+ function shellArg(text) {
29
+ const value = String(text)
30
+
31
+ return BARE_ARG.test(value) ? value : `'${value.replaceAll("'", `'\\''`)}'`
32
+ }
33
+
34
+ // runUpload refuses to send the rest of a backup to a different chat, so the command has to
35
+ // name the one the chunks are already in — unless the destination in force is that chat
36
+ // anyway, where --to would just be noise. Not knowing the destination counts as not matching:
37
+ // leaving --to out would be a guess about where a backup already in progress went.
38
+ function resumeCommand(state, destination) {
39
+ const matches = destination !== null && state.chat === String(destination)
40
+
41
+ return `npx telstore ${shellArg(state.path)}${matches ? '' : ` --to ${shellArg(state.chat)}`}`
42
+ }
43
+
44
+ // Why a resume is off the table, in the words of the thing the user would have to fix. The
45
+ // record is keyed on the file's path, size and mtime, so any of these means runUpload would
46
+ // hash the file to a different key, find nothing, and start a second backup instead.
47
+ const NO_RESUME = {
48
+ missing: 'the file is no longer there',
49
+ changed: 'the file has changed since the backup started',
50
+ 'not-a-file': 'that path is no longer a file',
51
+ unreadable: 'the record does not name a file that can be read',
52
+ }
53
+
54
+ // A command is printed only when it will really resume. Printing one regardless would be
55
+ // telling the user to run something that quietly starts a second backup and abandons every
56
+ // chunk this one already sent — and those chunks are then findable only by this id, which
57
+ // is worth saying while there are any.
58
+ async function resumeLines(key, state, destination, done) {
59
+ const check = await canResume(key, state)
60
+
61
+ if (check.ok) return [field('Resume', resumeCommand(state, destination))]
62
+
63
+ const lines = [field('Resume', `not possible: ${NO_RESUME[check.reason]}.`)]
64
+
65
+ if (done > 0) {
66
+ lines.push(
67
+ `${CONTINUATION}${done} chunk${done === 1 ? ' is' : 's are'} already in the chat, ` +
68
+ 'searchable by this id.',
69
+ )
70
+ }
71
+
72
+ return lines
73
+ }
74
+
17
75
  function describeAccount(me) {
18
76
  const name = [me.firstName, me.lastName].filter(Boolean).join(' ')
19
77
  const handle = me.username ? ` (@${me.username})` : ''
@@ -72,6 +130,17 @@ export async function runStatus(options = {}, deps = {}) {
72
130
  settingsError = err.message
73
131
  }
74
132
 
133
+ // Which config this report is about, and whether the session in it can be read. Printed
134
+ // unconditionally: status never said where it looked before, and a row that shows up only
135
+ // when something is unusual reads as a warning rather than as a fact.
136
+ log(
137
+ row(
138
+ 'Session',
139
+ config.sealed
140
+ ? `${configFile(configDir)} (sealed — opened with a passphrase)`
141
+ : configFile(configDir),
142
+ ),
143
+ )
75
144
  log(row('Account', await accountLine(config, settings?.verbose ?? false, { connect, disconnect })))
76
145
  log(
77
146
  row(
@@ -92,11 +161,20 @@ export async function runStatus(options = {}, deps = {}) {
92
161
 
93
162
  log(row('Unfinished', `${states.length} backup${states.length === 1 ? '' : 's'}`))
94
163
 
95
- for (const state of states) {
164
+ // The destination is what decides whether the resume command needs a --to. A row that
165
+ // failed to parse leaves nothing to compare against, which is not the same as a match.
166
+ const destination = settings?.chat ?? null
167
+
168
+ for (const { key, state } of states) {
96
169
  const total = countChunks(state.size, state.chunkSize)
97
170
  const done = Object.keys(state.done ?? {}).length
98
171
 
99
- log(` ${state.id} ${path.basename(state.path)} ${done}/${total} chunks ${formatBytes(state.size)}`)
100
- log(` → ${describeChat(state.chat)}`)
172
+ log('')
173
+ log(` ${state.id}`)
174
+ log(field('File', `${state.path} (${formatBytes(state.size)})`))
175
+ log(field('Chunks', `${done} of ${total} uploaded`))
176
+ log(field('Chat', describeChat(state.chat)))
177
+
178
+ for (const line of await resumeLines(key, state, destination, done)) log(line)
101
179
  }
102
180
  }
@@ -0,0 +1,80 @@
1
+ import { assertLoggedIn } from '../client.js'
2
+ import { defaultConfigDir, loadConfig } from '../config.js'
3
+ import { createPrompts } from '../prompt.js'
4
+ import { unlockConfig } from '../session.js'
5
+ import { knownSettings } from '../settings.js'
6
+ import { encodeToken } from '../token.js'
7
+
8
+ // Short enough that whoever holds the token can work through the possibilities faster than
9
+ // scrypt can slow them down. Not a rule — a length minimum is a preference wearing a check's
10
+ // clothes, and the main thing one teaches is to append digits — so this warns and goes on.
11
+ const SHORT_PASSPHRASE = 12
12
+
13
+ export async function runToken(args = [], options = {}, deps = {}) {
14
+ const {
15
+ configDir = defaultConfigDir(),
16
+ prompts = null,
17
+ log = (line) => console.log(line),
18
+ writeErr = (line) => process.stderr.write(line),
19
+ } = deps
20
+
21
+ const config = await loadConfig(configDir)
22
+
23
+ // Before anything is asked for. Someone who has never logged in should be told that, not
24
+ // asked to invent a passphrase for an account that is not there.
25
+ assertLoggedIn(config)
26
+
27
+ // Opened here rather than in the parameter list, so the command that refuses above never
28
+ // touches stdin. Every question in this run goes through this one interface: asking each
29
+ // through its own would leave the second one at end-of-input having read nothing.
30
+ const ask = prompts ?? createPrompts({ output: process.stderr })
31
+
32
+ try {
33
+ return await mint(config, ask, { log, writeErr })
34
+ } finally {
35
+ ask.close()
36
+ }
37
+ }
38
+
39
+ async function mint(config, ask, { log, writeErr }) {
40
+ const readSecret = (question) => ask.askSecret(question)
41
+ const account = await unlockConfig(config, { readSecret })
42
+
43
+ const passphrase = await readSecret('Passphrase to protect the token: ')
44
+ const again = await readSecret('Repeat it: ')
45
+
46
+ if (passphrase !== again) {
47
+ throw new Error('The two passphrases are different. Nothing was printed — run the command again.')
48
+ }
49
+
50
+ if (passphrase === '') {
51
+ writeErr(
52
+ 'This token has no passphrase, so anyone who reads it can use your Telegram account. ' +
53
+ 'Do not send it through anything that keeps a copy.\n',
54
+ )
55
+ } else if (passphrase.length < SHORT_PASSPHRASE) {
56
+ writeErr(
57
+ `That passphrase is ${passphrase.length} characters. Whoever holds the token can try ` +
58
+ 'passphrases offline as fast as their hardware allows, and telstore can only make ' +
59
+ 'each attempt cost about half a second.\n',
60
+ )
61
+ }
62
+
63
+ // The same true fact logout already states, said the same way: telstore cannot take a token
64
+ // back, and the session it carries outlives every copy of the token.
65
+ writeErr(
66
+ 'This token carries your Telegram session. telstore cannot take it back — as with ' +
67
+ 'logout, the session stays alive on Telegram\'s side until you open Telegram → ' +
68
+ 'Settings → Devices (Active sessions) and terminate it. It is about to be printed ' +
69
+ 'here, so it will sit in this terminal\'s scrollback until you clear it.\n\n',
70
+ )
71
+
72
+ // stdout carries the token and nothing else, the rule `config <name>` already follows, so
73
+ // this can be piped into a QR encoder with every warning above still on the screen.
74
+ log(
75
+ await encodeToken(
76
+ { ...account, settings: knownSettings(config.settings) },
77
+ passphrase,
78
+ ),
79
+ )
80
+ }
@@ -95,7 +95,7 @@ export async function runUpload(filePath, options = {}, deps = {}) {
95
95
  file: configFile(configDir),
96
96
  })
97
97
  const chat = requireChat(settings)
98
- const concurrency = settings.concurrency
98
+ const concurrency = settings.uploadConcurrency
99
99
 
100
100
  const key = stateKey(absPath, stat.size, stat.mtimeMs)
101
101
 
package/src/config.js CHANGED
@@ -37,6 +37,26 @@ export function checkConfigShape(raw, file) {
37
37
  )
38
38
  }
39
39
 
40
+ // What `login --token` leaves behind: the session and the api_hash sealed inside one blob
41
+ // that only a passphrase opens. It is read by `connect`, never by anything that writes.
42
+ if (raw.sealed !== undefined && typeof raw.sealed !== 'string') {
43
+ throw new Error(
44
+ `"sealed" in ${file} holds ${Array.isArray(raw.sealed) ? 'a list' : typeof raw.sealed}, ` +
45
+ 'not a sealed session. Log in again with "npx telstore login --token", or remove that ' +
46
+ 'entry and log in with a phone number.',
47
+ )
48
+ }
49
+
50
+ // Two sources of truth for one account, and nothing to say which one was meant. Guessing
51
+ // would mean connecting as an account the user did not choose — either the stale one they
52
+ // thought they had replaced, or the one they thought they had left behind.
53
+ if (raw.sealed !== undefined && (raw.session !== undefined || raw.apiHash !== undefined)) {
54
+ throw new Error(
55
+ `${file} holds both a sealed session and a plain one. telstore will not guess which ` +
56
+ 'account was meant — delete the file and log in again.',
57
+ )
58
+ }
59
+
40
60
  return raw
41
61
  }
42
62
 
@@ -79,8 +99,12 @@ export async function saveConfig(config, dir = defaultConfigDir()) {
79
99
  await writeJsonAtomic(configFile(dir), config)
80
100
  }
81
101
 
102
+ // Both shapes go, because they are the same thing written two ways. On a machine that logged
103
+ // in with a token the api_hash lives inside the sealed blob, so keeping it "like an ordinary
104
+ // logout does" would keep the entire account.
82
105
  export async function clearSession(dir = defaultConfigDir()) {
83
106
  const config = await loadConfig(dir)
84
107
  delete config.session
108
+ delete config.sealed
85
109
  await saveConfig(config, dir)
86
110
  }
package/src/downloader.js CHANGED
@@ -4,7 +4,7 @@ import { promisify } from 'node:util'
4
4
 
5
5
  import { returnBigInt } from 'telegram/Helpers.js'
6
6
 
7
- import { DEFAULT_CONCURRENCY, PART_SIZE, SLICE_SIZE } from './chunking.js'
7
+ import { DEFAULT_DOWNLOAD_CONCURRENCY, PART_SIZE, SLICE_SIZE } from './chunking.js'
8
8
  import { withRetry } from './retry.js'
9
9
  import { DEFAULT_STALL_MS, withStallTimeout } from './stall.js'
10
10
 
@@ -57,7 +57,7 @@ export async function downloadToFile(
57
57
  client,
58
58
  message,
59
59
  fd,
60
- { offset, onProgress, retryOptions, concurrency = DEFAULT_CONCURRENCY, stallMs = DEFAULT_STALL_MS } = {},
60
+ { offset, onProgress, retryOptions, concurrency = DEFAULT_DOWNLOAD_CONCURRENCY, stallMs = DEFAULT_STALL_MS } = {},
61
61
  ) {
62
62
  const document = message?.media?.document
63
63
 
package/src/prompt.js ADDED
@@ -0,0 +1,119 @@
1
+ import readline from 'node:readline/promises'
2
+ import { stdin, stdout, stderr } from 'node:process'
3
+ import { Writable } from 'node:stream'
4
+
5
+ // readline echoes what it reads through its own output, so putting a curtain in front of that
6
+ // output is what makes typing invisible. The curtain is also where the mask is drawn from:
7
+ // every echo readline makes is a write arriving here, and a write arriving here is the one
8
+ // signal that something was typed which does not involve reading readline's internals.
9
+ function veiledOutput(output) {
10
+ let onEcho = null
11
+
12
+ return {
13
+ hide: (draw) => {
14
+ onEcho = draw
15
+ },
16
+ show: () => {
17
+ onEcho = null
18
+ },
19
+ // The question goes straight to the real output, past the curtain, so it stays on screen
20
+ // while the answer to it does not.
21
+ say: (text) => output.write(text),
22
+ stream: new Writable({
23
+ write(chunk, encoding, callback) {
24
+ if (onEcho) onEcho(String(chunk))
25
+ else output.write(chunk, encoding)
26
+ callback()
27
+ },
28
+ }),
29
+ }
30
+ }
31
+
32
+ // What the line reads while a secret is being typed. The mask is capped to the line the question
33
+ // sits on: askSecret redraws the whole line on every keystroke, and that redraw clears one line
34
+ // only, so a mask allowed to wrap would leave stale asterisks on the rows above it. The trailing
35
+ // ellipsis says the counting stopped, not the typing.
36
+ export function maskLine(question, length, columns) {
37
+ // One column is kept back for the ellipsis, and one for the mask itself however narrow the
38
+ // terminal is: a question that fills the line would otherwise mask to nothing, which is the
39
+ // silence this whole thing exists to end.
40
+ const budget = Math.max(1, (columns || 80) - question.length - 1)
41
+
42
+ if (length <= budget) return question + '*'.repeat(length)
43
+
44
+ return `${question}${'*'.repeat(budget)}\u2026`
45
+ }
46
+
47
+ const NO_TERMINAL =
48
+ 'There is no terminal here to type a secret into. Run this where you can type it.'
49
+
50
+ // One readline for a whole conversation, some of whose answers must not stay on the screen.
51
+ // It has to be one: two readlines over a single stdin do not take turns — the first keeps the
52
+ // listener and everything typed after it lands nowhere, so the second reaches end-of-input
53
+ // having read nothing and reports it as Ctrl-D. Verified against a real terminal, because a
54
+ // fake stream takes turns perfectly well and would have called this fine.
55
+ export function createPrompts({ input = stdin, output = stdout } = {}) {
56
+ const veil = veiledOutput(output)
57
+ // terminal follows stdin: true is what stops the tty driver from echoing on its own, which
58
+ // is what leaves the curtain as the only thing between the keyboard and the screen. Forcing
59
+ // it on a pipe would put readline into line editing over input with no terminal behind it.
60
+ const rl = readline.createInterface({ input, output: veil.stream, terminal: Boolean(input.isTTY) })
61
+
62
+ return {
63
+ ask: (question) => rl.question(question),
64
+ async askSecret(question) {
65
+ // A prompt written where nobody can see it, waiting on a stream that will never carry a
66
+ // typed answer, is a hang — the one failure this project refuses to produce anywhere.
67
+ // Reading it from the pipe instead would be worse: the secret would then have come from
68
+ // somewhere that kept a copy of it.
69
+ if (!input.isTTY) throw new Error(NO_TERMINAL)
70
+
71
+ veil.say(question)
72
+ // The question is already on screen and carries no asterisks yet, so the first echo has
73
+ // nothing to add. rl.line is what the mask counts — readline's own idea of the line,
74
+ // after it has applied the keystroke, whether that was a character, a paste, a backspace
75
+ // or a Ctrl-U.
76
+ let drawn = question
77
+
78
+ veil.hide((echo) => {
79
+ // Readline announces the finished line by writing a newline, and by then it has already
80
+ // emptied rl.line: redrawing on that would wipe the mask off the screen at the very
81
+ // moment the answer was accepted.
82
+ if (echo.includes('\n')) return
83
+
84
+ const line = maskLine(question, rl.line.length, output.columns)
85
+
86
+ // One keystroke can cost readline four writes — a redraw per write would be the same
87
+ // line four times and a cursor that flickers for nothing.
88
+ if (line === drawn) return
89
+
90
+ drawn = line
91
+ veil.say(`\x1b[2K\r${line}`)
92
+ })
93
+
94
+ try {
95
+ return await rl.question('')
96
+ } finally {
97
+ veil.show()
98
+ veil.say('\n')
99
+ }
100
+ },
101
+ // Not optional: terminal mode put stdin in raw mode, and leaving it there hands the user
102
+ // back a shell that no longer echoes what they type.
103
+ close: () => rl.close(),
104
+ }
105
+ }
106
+
107
+ // The single-question case. Its question goes to stderr rather than stdout, because
108
+ // "npx telstore token > token.txt" has to still show it, and stdout there carries one thing.
109
+ export async function readSecret(question, { input = stdin, output = stderr } = {}) {
110
+ if (!input.isTTY) throw new Error(NO_TERMINAL)
111
+
112
+ const prompts = createPrompts({ input, output })
113
+
114
+ try {
115
+ return await prompts.askSecret(question)
116
+ } finally {
117
+ prompts.close()
118
+ }
119
+ }
package/src/session.js ADDED
@@ -0,0 +1,24 @@
1
+ import { readSecret as realReadSecret } from './prompt.js'
2
+ import { decodeToken } from './token.js'
3
+
4
+ // How a stored config becomes the three things GramJS needs. There are two shapes on disk —
5
+ // the ordinary login, and the sealed blob `login --token` writes — and this is the one place
6
+ // that knows the difference, so `connect` stays about Telegram and the commands stay about
7
+ // their own narrative.
8
+ export async function unlockConfig(config, { readSecret = realReadSecret } = {}) {
9
+ const { sealed } = config
10
+
11
+ if (!sealed) {
12
+ const { apiId, apiHash, session } = config
13
+
14
+ return { apiId, apiHash, session }
15
+ }
16
+
17
+ const passphrase = await readSecret('Passphrase for the stored session: ')
18
+ // Everything comes out of the blob and nothing from around it. The fields beside it on disk
19
+ // are editable by anyone who can reach the file, and an apiId taken from there would let an
20
+ // edit decide which account a passphrase unlocks.
21
+ const { apiId, apiHash, session } = await decodeToken(sealed, passphrase)
22
+
23
+ return { apiId, apiHash, session }
24
+ }
package/src/settings.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { normalizeChatTarget } from './chat.js'
2
2
  import {
3
3
  DEFAULT_CHUNK_SIZE,
4
- DEFAULT_CONCURRENCY,
4
+ DEFAULT_DOWNLOAD_CONCURRENCY,
5
+ DEFAULT_UPLOAD_CONCURRENCY,
5
6
  MAX_CONCURRENCY,
6
7
  parseSize,
7
8
  } from './chunking.js'
@@ -74,9 +75,9 @@ export const SETTINGS = {
74
75
  format: (value) => String(value),
75
76
  describe: (value) => formatBytes(value),
76
77
  },
77
- concurrency: {
78
- flag: 'concurrency',
79
- default: DEFAULT_CONCURRENCY,
78
+ uploadConcurrency: {
79
+ flag: 'upload-concurrency',
80
+ default: DEFAULT_UPLOAD_CONCURRENCY,
80
81
  parse: (raw, where) =>
81
82
  wholeNumber(
82
83
  raw,
@@ -88,6 +89,20 @@ export const SETTINGS = {
88
89
  ),
89
90
  format: (value) => String(value),
90
91
  },
92
+ downloadConcurrency: {
93
+ flag: 'download-concurrency',
94
+ default: DEFAULT_DOWNLOAD_CONCURRENCY,
95
+ parse: (raw, where) =>
96
+ wholeNumber(
97
+ raw,
98
+ where,
99
+ 1,
100
+ MAX_CONCURRENCY,
101
+ `Must be an integer from 1 to ${MAX_CONCURRENCY} — each slot runs its own download ` +
102
+ 'stream, and Telegram answers with FLOOD_WAIT if too many requests go out at once.',
103
+ ),
104
+ format: (value) => String(value),
105
+ },
91
106
  limit: {
92
107
  flag: 'limit',
93
108
  default: DEFAULT_LIMIT,
@@ -188,3 +203,14 @@ export function requireChat(values) {
188
203
 
189
204
  return values.chat
190
205
  }
206
+
207
+ // The snapshot of somebody's settings that a session token carries to a machine which has no
208
+ // config file of its own. A stray key would arrive there as a line in `config` telling the
209
+ // reader to remove a setting they never wrote, so keys telstore does not know are left behind
210
+ // rather than carried along. This lives here because this is the one place that knows which
211
+ // settings exist.
212
+ export function knownSettings(stored = {}) {
213
+ return Object.fromEntries(
214
+ SETTING_KEYS.filter((key) => stored[key] !== undefined).map((key) => [key, stored[key]]),
215
+ )
216
+ }
package/src/state.js CHANGED
@@ -96,6 +96,10 @@ export async function pruneStates(configDir = defaultConfigDir(), keep = MAX_STA
96
96
  // status needs every unfinished backup at once. A state file that cannot be read is skipped
97
97
  // rather than fatal, for the same reason loadState returns null: one corrupt file must not
98
98
  // hide the other backups still waiting to be finished.
99
+ //
100
+ // The key comes back alongside each record because canResume needs it, and the file name is
101
+ // the only place it survives: the record's own path, size and mtime are exactly what a
102
+ // rewritten file makes stale, so recomputing the key from them would always say yes.
99
103
  export async function listStates(configDir = defaultConfigDir()) {
100
104
  let names
101
105
  try {
@@ -110,8 +114,10 @@ export async function listStates(configDir = defaultConfigDir()) {
110
114
  for (const name of names) {
111
115
  if (!name.endsWith('.json')) continue
112
116
 
113
- const state = await loadState(name.slice(0, -'.json'.length), configDir)
114
- if (state) states.push(state)
117
+ const key = name.slice(0, -'.json'.length)
118
+ const state = await loadState(key, configDir)
119
+
120
+ if (state) states.push({ key, state })
115
121
  }
116
122
 
117
123
  return states
@@ -149,3 +155,28 @@ export async function findStates(backupId, configDir = defaultConfigDir()) {
149
155
 
150
156
  return found
151
157
  }
158
+
159
+ // Whether a record can still be resumed, which is not a question about the record alone:
160
+ // runUpload hashes the file it finds on disk and looks the result up, so a backup is
161
+ // resumable exactly when that hash is still the key this record is filed under. Recomputing
162
+ // through stateKey rather than comparing size and mtime by hand is the point — a second way
163
+ // of asking is a second way to drift, and status would end up promising a resume that upload
164
+ // turns into a brand new backup, stranding every chunk already sent.
165
+ //
166
+ // Never throws. status calls this for every record it prints, and one damaged path must not
167
+ // take the rest of the report down with it.
168
+ export async function canResume(key, state) {
169
+ let stat
170
+
171
+ try {
172
+ stat = await fs.stat(state.path)
173
+ } catch (err) {
174
+ if (err.code === 'ENOENT') return { ok: false, reason: 'missing' }
175
+ return { ok: false, reason: 'unreadable' }
176
+ }
177
+
178
+ if (!stat.isFile()) return { ok: false, reason: 'not-a-file' }
179
+ if (stateKey(state.path, stat.size, stat.mtimeMs) !== key) return { ok: false, reason: 'changed' }
180
+
181
+ return { ok: true }
182
+ }
package/src/token.js ADDED
@@ -0,0 +1,221 @@
1
+ import { createCipheriv, createDecipheriv, randomBytes, scrypt } from 'node:crypto'
2
+ import { promisify } from 'node:util'
3
+
4
+ const derive = promisify(scrypt)
5
+
6
+ // A session token is this machine's Telegram login, written down so another machine can use
7
+ // it. Two formats, not one format with a flag inside: a blob that looked encrypted and was
8
+ // not would be telstore lying about what it handed over, and the prefix is the one part a
9
+ // reader can check before knowing anything else about the bytes.
10
+ export const TOKEN_PREFIX_SEALED = 'tls1.'
11
+ export const TOKEN_PREFIX_PLAIN = 'tls0.'
12
+
13
+ const SALT_BYTES = 16
14
+ const IV_BYTES = 12
15
+ const TAG_BYTES = 16
16
+ const KEY_BYTES = 32
17
+ const OVERHEAD = SALT_BYTES + IV_BYTES + TAG_BYTES
18
+
19
+ // Pinned to the prefix above, never carried inside the token. Whoever holds a token can try
20
+ // passphrases offline as fast as their hardware allows, and 64MB per attempt is what makes
21
+ // that expensive; letting a token name its own parameters would let a stranger's N decide how
22
+ // much memory this machine allocates, which is a denial of service that needs no passphrase
23
+ // at all. maxmem is spelled out because Node's own default is 32MB and would refuse these
24
+ // outright, as an error that reads like a bug in telstore.
25
+ const SCRYPT = { N: 65536, r: 8, p: 1, maxmem: 128 * 1024 * 1024 }
26
+
27
+ const MAKE_ONE = 'Make a new one with "npx telstore token" on the machine you are logged in on.'
28
+
29
+ // Whitespace is the one damage repaired rather than refused: a chat client or a mail reader
30
+ // wrapping the line is the likeliest thing to happen to a token, whitespace is never part of
31
+ // one, and joining it back up cannot produce a *different* valid token, because the
32
+ // authentication tag still has to match. Anything else is refused — repairing it would be
33
+ // guessing at what somebody meant.
34
+ function tidy(token) {
35
+ return String(token).replace(/\s+/g, '')
36
+ }
37
+
38
+ export function isSealedToken(text) {
39
+ return tidy(text).startsWith(TOKEN_PREFIX_SEALED)
40
+ }
41
+
42
+ function isPlainObject(value) {
43
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
44
+ }
45
+
46
+ // A token is untrusted input in the same category as a manifest or a state file. Opening one
47
+ // proves whoever made it knew the passphrase, not that they made it correctly — and the
48
+ // unprotected format proves nothing at all. An apiId of undefined does not throw here; it
49
+ // reaches GramJS and fails much later as something that reads like a network problem.
50
+ export function checkTokenBundle(bundle) {
51
+ if (!isPlainObject(bundle)) {
52
+ throw new Error(
53
+ `The session token holds ${Array.isArray(bundle) ? 'a list' : typeof bundle}, ` +
54
+ `not an account. ${MAKE_ONE}`,
55
+ )
56
+ }
57
+
58
+ if (!Number.isSafeInteger(bundle.apiId) || bundle.apiId < 1) {
59
+ throw new Error(
60
+ `The session token gives ${JSON.stringify(bundle.apiId)} as the api_id, which is not ` +
61
+ `one. ${MAKE_ONE}`,
62
+ )
63
+ }
64
+
65
+ for (const field of ['apiHash', 'session']) {
66
+ if (typeof bundle[field] !== 'string' || bundle[field] === '') {
67
+ throw new Error(`The session token is missing its ${field}. ${MAKE_ONE}`)
68
+ }
69
+ }
70
+
71
+ // The twin of checkConfigShape, refused for the twin's reason: every lookup below a
72
+ // settings that is not an object returns undefined, and telstore would then run on its
73
+ // built-in defaults while the choices carried in the token sat there ignored.
74
+ if (bundle.settings !== undefined && !isPlainObject(bundle.settings)) {
75
+ throw new Error(
76
+ `"settings" in the session token holds ` +
77
+ `${Array.isArray(bundle.settings) ? 'a list' : typeof bundle.settings}, not a group ` +
78
+ `of settings. ${MAKE_ONE}`,
79
+ )
80
+ }
81
+
82
+ return bundle
83
+ }
84
+
85
+ // Both spellings of the same accented passphrase have to derive the same key: macOS hands
86
+ // back one and Linux the other, and the difference would surface as "wrong passphrase" for a
87
+ // passphrase that is right.
88
+ function keyFrom(passphrase, salt) {
89
+ return derive(String(passphrase).normalize('NFC'), salt, KEY_BYTES, SCRYPT)
90
+ }
91
+
92
+ export async function encodeToken(bundle, passphrase) {
93
+ checkTokenBundle(bundle)
94
+
95
+ const json = JSON.stringify(bundle)
96
+
97
+ // An empty passphrase is not a weak secret, it is the absence of one. Saying so in the
98
+ // prefix is what keeps the format honest: the alternative is a token that opens for anyone
99
+ // who reads the channel it travelled through while looking exactly like a protected one.
100
+ if (String(passphrase) === '') {
101
+ return TOKEN_PREFIX_PLAIN + Buffer.from(json, 'utf8').toString('base64url')
102
+ }
103
+
104
+ const salt = randomBytes(SALT_BYTES)
105
+ const iv = randomBytes(IV_BYTES)
106
+ const cipher = createCipheriv('aes-256-gcm', await keyFrom(passphrase, salt), iv)
107
+ const body = Buffer.concat([cipher.update(json, 'utf8'), cipher.final()])
108
+
109
+ return (
110
+ TOKEN_PREFIX_SEALED +
111
+ Buffer.concat([salt, iv, cipher.getAuthTag(), body]).toString('base64url')
112
+ )
113
+ }
114
+
115
+ function splitToken(token) {
116
+ const text = tidy(token)
117
+
118
+ for (const prefix of [TOKEN_PREFIX_SEALED, TOKEN_PREFIX_PLAIN]) {
119
+ if (text.startsWith(prefix)) return { sealed: prefix === TOKEN_PREFIX_SEALED, encoded: text.slice(prefix.length) }
120
+ }
121
+
122
+ const version = /^([a-z]+\d+)\./.exec(text)
123
+
124
+ if (version) {
125
+ throw new Error(
126
+ `This session token is in format "${version[1]}", which this telstore does not know ` +
127
+ `(it reads "${TOKEN_PREFIX_SEALED.slice(0, -1)}" and ` +
128
+ `"${TOKEN_PREFIX_PLAIN.slice(0, -1)}"). Upgrade telstore, or make a new token with ` +
129
+ 'the version you have.',
130
+ )
131
+ }
132
+
133
+ throw new Error(
134
+ `That does not look like a session token: one starts with "${TOKEN_PREFIX_SEALED}" or ` +
135
+ `"${TOKEN_PREFIX_PLAIN}". ${MAKE_ONE}`,
136
+ )
137
+ }
138
+
139
+ function bodyBytes(encoded) {
140
+ const trimmed = encoded.replace(/=+$/, '')
141
+ const bytes = Buffer.from(trimmed, 'base64url')
142
+
143
+ // Buffer.from silently drops every character outside the alphabet, so a token with a stray
144
+ // quote in it decodes to *something* — which would then fail authentication and be
145
+ // reported as a wrong passphrase. Re-encoding and comparing is what turns that into the
146
+ // sentence that actually helps.
147
+ if (bytes.toString('base64url') !== trimmed) {
148
+ throw new Error(
149
+ 'The session token has characters that do not belong to one — it was probably ' +
150
+ 'truncated or altered on the way here. Copy it again, whole.',
151
+ )
152
+ }
153
+
154
+ return bytes
155
+ }
156
+
157
+ export async function decodeToken(token, passphrase) {
158
+ const { sealed, encoded } = splitToken(token)
159
+ const bytes = bodyBytes(encoded)
160
+
161
+ if (!sealed) {
162
+ return checkTokenBundle(readJson(bytes.toString('utf8')))
163
+ }
164
+
165
+ // Asked here rather than at the top: a token nobody can read is a different problem from a
166
+ // passphrase nobody supplied, and telling someone to type a passphrase for a token that was
167
+ // damaged in transit sends them after the wrong thing.
168
+ if (String(passphrase) === '') {
169
+ throw new Error(
170
+ 'This session token is protected by a passphrase, and none was given. Run the command ' +
171
+ 'again where you can type it.',
172
+ )
173
+ }
174
+
175
+ if (bytes.length <= OVERHEAD) {
176
+ throw new Error(
177
+ `The session token is too short to be one: it carries ${bytes.length} bytes and the ` +
178
+ `salt, nonce and tag alone need ${OVERHEAD}. Copy it again, whole.`,
179
+ )
180
+ }
181
+
182
+ const decipher = createDecipheriv(
183
+ 'aes-256-gcm',
184
+ await keyFrom(passphrase, bytes.subarray(0, SALT_BYTES)),
185
+ bytes.subarray(SALT_BYTES, SALT_BYTES + IV_BYTES),
186
+ )
187
+
188
+ decipher.setAuthTag(bytes.subarray(SALT_BYTES + IV_BYTES, OVERHEAD))
189
+
190
+ let json
191
+ try {
192
+ json = Buffer.concat([
193
+ decipher.update(bytes.subarray(OVERHEAD)),
194
+ decipher.final(),
195
+ ]).toString('utf8')
196
+ } catch {
197
+ // AES-GCM cannot tell a wrong key from altered bytes: both are one failed tag check, and
198
+ // guessing which it was would send half the readers after the wrong thing. Name both, put
199
+ // the likelier one first, and say out loud that telstore is not guessing.
200
+ throw new Error(
201
+ 'Could not open the session token: either the passphrase is wrong or the token was ' +
202
+ 'altered on the way here. Encryption cannot tell those two apart, so telstore will ' +
203
+ 'not guess — check the passphrase first, then copy the token again, whole.',
204
+ )
205
+ }
206
+
207
+ return checkTokenBundle(readJson(json))
208
+ }
209
+
210
+ // Past the tag check the plaintext is ours, so bad JSON here is not somebody else's mistake:
211
+ // it means the token was written by a telstore that disagreed with this one about the format.
212
+ function readJson(json) {
213
+ try {
214
+ return JSON.parse(json)
215
+ } catch (err) {
216
+ throw new Error(
217
+ `The session token opened, but what is inside it is not what telstore writes ` +
218
+ `(${err.message}). ${MAKE_ONE}`,
219
+ )
220
+ }
221
+ }
package/src/uploader.js CHANGED
@@ -5,7 +5,7 @@ import { promisify } from 'node:util'
5
5
  import { Api } from 'telegram'
6
6
  import { readBigIntFromBuffer } from 'telegram/Helpers.js'
7
7
 
8
- import { DEFAULT_CONCURRENCY, PART_SIZE, MAX_PARTS } from './chunking.js'
8
+ import { DEFAULT_UPLOAD_CONCURRENCY, PART_SIZE, MAX_PARTS } from './chunking.js'
9
9
  import { withRetry } from './retry.js'
10
10
  import { DEFAULT_STALL_MS, withStallTimeout } from './stall.js'
11
11
 
@@ -39,7 +39,7 @@ export async function uploadRange(client, fd, options) {
39
39
  offset,
40
40
  length,
41
41
  fileName,
42
- concurrency = DEFAULT_CONCURRENCY,
42
+ concurrency = DEFAULT_UPLOAD_CONCURRENCY,
43
43
  partSize = PART_SIZE,
44
44
  onProgress,
45
45
  retryOptions,