telstore 0.1.10 → 0.1.11

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
@@ -37,6 +37,7 @@ npx telstore restore telstore-20260905-7f3a91
37
37
  | `telstore restore <backup-id> -- <command>...` | Restore onto the command's stdin instead of a file. |
38
38
  | `telstore verify <backup-id>...` | Check that every chunk of a backup is still in the chat. Downloads nothing. |
39
39
  | `telstore delete <backup-id>...` | Remove a backup's chunks and manifest from the chat, for good. Several ids are listed and confirmed once. |
40
+ | `telstore join <manifest.json>` | Reassemble chunks you downloaded by hand. Offline: no login, no connection. |
40
41
  | `telstore status` | Account, destination, and unfinished uploads and restores. |
41
42
  | `telstore config` | Show or change settings. |
42
43
  | `telstore token` | Print a session token for a machine you do not trust. |
@@ -180,6 +181,25 @@ one. That is also how your data is compressed or encrypted **before** it reaches
180
181
  stderr is left as it is, so one that fails explains itself in its own words and telstore adds
181
182
  only the exit code and what it did about it.
182
183
 
184
+ ## Encrypting a backup
185
+
186
+ ```bash
187
+ npx telstore photos.tar --encrypt
188
+ npx telstore tarc photos.tar.gz ./photos --encrypt
189
+ ```
190
+
191
+ telstore asks for a password twice and for an optional hint, then encrypts every chunk before
192
+ it leaves the machine (AES-256-CTR, the key derived with scrypt, the manifest sealed with
193
+ AES-256-GCM). `restore`, `tarx` and `join` see that a backup is encrypted, print its hint, and
194
+ ask for the password. The hint is shown in the chat as plain text — telstore refuses one that
195
+ contains the password. If telstore ever restores a backup you encrypted without asking for its
196
+ password, the manifest in the chat has been replaced: do not trust the result.
197
+
198
+ What stays readable to anyone who can read the chat: the file name, the note, the size, the
199
+ number of chunks, the dates and the hint. **A forgotten password is a lost backup** — nothing
200
+ can recover it. The password is only ever typed at a terminal; for unattended encrypted
201
+ backups, use `--` with a key-based tool such as `age`.
202
+
183
203
  ## Checking a backup is still there
184
204
 
185
205
  A backup is a set of messages in a chat, and messages can be deleted by hand. `list` reads
@@ -210,6 +230,26 @@ Chunk 3/12 is gone: message 1042 is no longer in @my_backups.
210
230
  12 chunks checked, 1 damaged. This backup cannot be restored.
211
231
  ```
212
232
 
233
+ ## Joining chunks downloaded by hand
234
+
235
+ A backup is ordinary files in a chat, so it can be fetched without telstore — from Telegram
236
+ web, on a machine where you cannot or would rather not log in. Download the manifest
237
+ (`<backupId>.manifest.json`) and every chunk (`<backupId>.part0001`, `.part0002`, …) into one
238
+ folder, keeping the names they have in the chat, then:
239
+
240
+ ```bash
241
+ npx telstore join ~/Downloads/telstore-20260905-7f3a91.manifest.json
242
+ npx telstore join ~/Downloads/telstore-20260905-7f3a91.manifest.json --out data.tar
243
+ ```
244
+
245
+ `join` makes the same promise `restore` does, from the manifest rather than the chat: every
246
+ chunk is checked for its length before anything is written, every missing or short one is
247
+ named in one go, each chunk's sha256 is checked as it is copied, and the file takes its real
248
+ name only after all of it passes. Until then it is `<target>.joining`, which a failure or a
249
+ Ctrl-C removes — the chunks are still in the folder, so there is nothing worth keeping. A
250
+ browser that saves a name twice tends to add ` (1)` to it; `join` does not guess past that,
251
+ so rename the file back.
252
+
213
253
  ## Several backups at once
214
254
 
215
255
  `restore`, `verify` and `delete` take a list of ids the same way, over one connection, with a
@@ -269,9 +309,9 @@ There is no expiry and no revocation: to end a session for good, terminate it un
269
309
 
270
310
  ## Limits worth knowing
271
311
 
272
- - **Your data is not encrypted.** Don't upload anything you would mind sitting on someone
273
- else's infrastructure — the one thing telstore encrypts is a session token, and that
274
- protects your login rather than your files.
312
+ - **Your data is not encrypted unless you pass `--encrypt`.** Without it, don't upload anything
313
+ you would mind sitting on someone else's infrastructure. With it, the contents are encrypted
314
+ but the file name, note, size and hint are not, and a forgotten password cannot be recovered.
275
315
  - A chunk cannot exceed 1950MB: Telegram accepts at most 4000 parts of 512KB per file.
276
316
  - **A backup made from a command cannot be resumed**, so a run that fails or is interrupted
277
317
  removes the chunks it had already sent instead of keeping them. Ctrl-C takes a moment longer
package/bin/telstore.js CHANGED
@@ -38,8 +38,9 @@ let settled = false
38
38
  // This process is already on its way out, and has already said why.
39
39
  let leaving = false
40
40
 
41
- // The chunk file a stream upload is buffering into right now, or null. The run removes its
42
- // own on every ending it gets to run code for; this exists for the one ending it does not.
41
+ // The chunk file a stream upload is buffering into right now, or the half-joined file a join
42
+ // is writing, or null. The run removes its own on every ending it gets to run code for; this
43
+ // exists for the one ending it does not.
43
44
  let tempChunk = null
44
45
 
45
46
  // The command a streaming restore is feeding, if one is running. A stream upload unwinds
@@ -99,8 +100,8 @@ function dropTempChunk() {
99
100
  if (err.code === 'ENOENT') return ''
100
101
 
101
102
  return (
102
- `\nThe chunk telstore was buffering is still on this machine: ${file} ` +
103
- `(${err.message}). It holds up to one chunk — remove it by hand.\n`
103
+ `\nThe temporary file telstore was writing is still on this machine: ${file} ` +
104
+ `(${err.message}). Nothing needs it — remove it by hand.\n`
104
105
  )
105
106
  }
106
107
  }
@@ -410,6 +411,19 @@ async function main() {
410
411
  return
411
412
  }
412
413
 
414
+ case 'join': {
415
+ const { runJoin } = await import('../src/commands/join.js')
416
+
417
+ // The half-joined file rides the same seam as a stream upload's buffered chunk, so the
418
+ // SIGINT handler removes it on the way out rather than leaving it for someone to find.
419
+ await runJoin(parsed.args[0], parsed.options, {
420
+ onTempChunk: (file) => {
421
+ tempChunk = file
422
+ },
423
+ })
424
+ return
425
+ }
426
+
413
427
  default:
414
428
  throw new Error(`Unknown command: ${parsed.command}`)
415
429
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "telstore",
3
- "version": "0.1.10",
3
+ "version": "0.1.11",
4
4
  "description": "Split large files into chunks and store them on Telegram",
5
5
  "keywords": [
6
6
  "telegram",
package/src/caption.js CHANGED
@@ -14,6 +14,23 @@ function oneLine(name) {
14
14
  return String(name).replace(/\s+/g, ' ').trim()
15
15
  }
16
16
 
17
+ // C0 and C1, DEL included. What makes a string an instruction to the terminal rather than text
18
+ // on it: an escape sequence can clear the screen, move the cursor, or rewrite the line above.
19
+ const CONTROL_CHARACTERS = /[\x00-\x1f\x7f-\x9f]/g
20
+
21
+ export function hasControlCharacter(text) {
22
+ return /[\x00-\x1f\x7f-\x9f]/.test(String(text))
23
+ }
24
+
25
+ // A hint is printed before anything can prove it genuine: the seal covers it, but only a password
26
+ // that opens the seal says so, and the hint is what someone reads while deciding what to type. So
27
+ // everywhere it reaches a terminal it goes through here first — whitespace folded the way oneLine
28
+ // folds it, every other control character dropped — and the most a hint someone else wrote can do
29
+ // is say something unhelpful, never repaint the screen around the password prompt.
30
+ export function terminalSafe(text) {
31
+ return oneLine(String(text).replace(/\s/g, ' ').replace(CONTROL_CHARACTERS, ''))
32
+ }
33
+
17
34
  // Telegram takes 1024 characters in a caption, and the card around the note already spends
18
35
  // some of them — a file name alone may be 255. 500 leaves both room to spare.
19
36
  export const MAX_NOTE_LENGTH = 500
@@ -45,6 +62,42 @@ export function parseNote(raw) {
45
62
  return note
46
63
  }
47
64
 
65
+ // What the card has left once every other line has had its share. Measured 2026-09-14: the
66
+ // worst card telstore writes without encryption — a 255-character name, a 500-character note,
67
+ // 10000 chunks — is 899 of Telegram's 1024 characters, and the lock and hint lines leave 108.
68
+ export const MAX_HINT_LENGTH = 100
69
+
70
+ const LOCK_LINE = '🔒 encrypted'
71
+
72
+ // Written at the password prompt and shown in the open: on the card, in `list`, and above the
73
+ // password prompt at restore time. So a hint holding the password is a password in the chat.
74
+ //
75
+ // Stripped of control characters here, once, rather than only when printed: parseManifest refuses
76
+ // a hint carrying one, so a hint that kept it would be an honest backup restore turns away.
77
+ export function parseHint(raw, password = null) {
78
+ if (raw === undefined || raw === null) return null
79
+
80
+ const hint = terminalSafe(raw)
81
+
82
+ if (hint === '') return null
83
+
84
+ if (hint.length > MAX_HINT_LENGTH) {
85
+ throw new Error(
86
+ `The hint is ${hint.length} characters, and the card in the chat has room for ` +
87
+ `${MAX_HINT_LENGTH}. Shorten it: telstore will not cut it short by itself.`,
88
+ )
89
+ }
90
+
91
+ if (password && hint.toLowerCase().includes(String(password).toLowerCase())) {
92
+ throw new Error(
93
+ 'The hint contains the password itself, and the hint is shown in the chat as plain ' +
94
+ 'text. Write something only you would connect with it.',
95
+ )
96
+ }
97
+
98
+ return hint
99
+ }
100
+
48
101
  function utcMinutes(createdAt) {
49
102
  return `${new Date(createdAt).toISOString().slice(0, 16).replace('T', ' ')} UTC`
50
103
  }
@@ -58,7 +111,7 @@ export function chunkCaption({ id, number, total }) {
58
111
  : `📦 ${id} · ${number}/${total}`
59
112
  }
60
113
 
61
- export function manifestCaption({ id, name, size, chunks, createdAt, note = null }) {
114
+ export function manifestCaption({ id, name, size, chunks, createdAt, note = null, encrypted = false, hint = null }) {
62
115
  return [
63
116
  `📄 ${oneLine(name)}`,
64
117
  `💾 ${formatBytes(size)} · ${chunks} chunk${chunks === 1 ? '' : 's'}`,
@@ -67,6 +120,10 @@ export function manifestCaption({ id, name, size, chunks, createdAt, note = null
67
120
  // Below the facts telstore knows, above the line that says how to get the file back:
68
121
  // the note is the one part of the card a person wrote, so it reads last of the four.
69
122
  ...(note ? [`📝 ${oneLine(note)}`] : []),
123
+ // Below the note and above the restore line: the lock is a fact about the backup a person
124
+ // needs before they try to restore it, and the hint is what they will need at the prompt.
125
+ ...(encrypted ? [LOCK_LINE] : []),
126
+ ...(encrypted && hint ? [`💡 ${oneLine(hint)}`] : []),
70
127
  '',
71
128
  `↩ npx telstore restore ${id}`,
72
129
  MANIFEST_TAG,
@@ -93,11 +150,16 @@ export function parseManifestCaption(text) {
93
150
  // one marker whose absence means "there is no note" rather than "this is not a card".
94
151
  const note = marker(lines, '📝')
95
152
 
153
+ // Both optional, like the note: every card telstore wrote before encryption existed is a
154
+ // complete card with neither.
155
+ const encrypted = lines.includes(LOCK_LINE)
156
+ const hint = marker(lines, '💡')
157
+
96
158
  if (!name || !totals || !id || !createdAt) return null
97
159
 
98
160
  const match = /^(.+) · (\d+) chunks?$/.exec(totals)
99
161
 
100
162
  if (!match) return null
101
163
 
102
- return { id, name, size: match[1], chunks: Number(match[2]), createdAt, note }
164
+ return { id, name, size: match[1], chunks: Number(match[2]), createdAt, note, encrypted, hint }
103
165
  }
package/src/cipher.js ADDED
@@ -0,0 +1,211 @@
1
+ import { createCipheriv, createDecipheriv, createHash, createHmac, hkdfSync, randomBytes, scrypt } from 'node:crypto'
2
+ import { promisify } from 'node:util'
3
+
4
+ const derive = promisify(scrypt)
5
+
6
+ // Pinned to manifest version 2 and never read from a manifest, for the reason src/token.js
7
+ // pins its own: a manifest naming its own N would let a stranger decide how much memory this
8
+ // machine allocates, and whoever holds a manifest can try passwords offline as fast as their
9
+ // hardware allows — 64MB per attempt is what makes that expensive.
10
+ const SCRYPT = { N: 65536, r: 8, p: 1, maxmem: 128 * 1024 * 1024 }
11
+ const KEY_BYTES = 32
12
+ const SALT_BYTES = 16
13
+ const IV_BYTES = 8
14
+ const NONCE_BYTES = 12
15
+ const TAG_BYTES = 16
16
+ const BLOCK = 16
17
+ const SHA256 = /^[0-9a-f]{64}$/
18
+
19
+ // Large enough that a 1800MB chunk is a couple of hundred reads, and a multiple of the AES
20
+ // block so a full pass never needs the partial-block path.
21
+ const IN_PLACE_BLOCK = 8 * 1024 * 1024
22
+
23
+ export function newSalt() {
24
+ return randomBytes(SALT_BYTES).toString('hex')
25
+ }
26
+
27
+ // Fresh for every attempt at a chunk, never derived from its index: a run that dies halfway
28
+ // through chunk 3 leaves parts of it on Telegram's servers, and a derived nonce would put the
29
+ // next run's chunk 3 — possibly different bytes, if the file changed — under the same keystream.
30
+ export function newIv() {
31
+ return randomBytes(IV_BYTES).toString('hex')
32
+ }
33
+
34
+ // Two keys, because GCM is CTR inside: one key used for both would let a counter block of the
35
+ // manifest seal coincide with a counter block of some chunk.
36
+ export async function deriveKeys(password, salt) {
37
+ const saltBytes = Buffer.from(salt, 'hex')
38
+ const master = await derive(String(password).normalize('NFC'), saltBytes, KEY_BYTES, SCRYPT)
39
+
40
+ return {
41
+ chunkKey: Buffer.from(hkdfSync('sha256', master, saltBytes, 'telstore v2 chunk key', KEY_BYTES)),
42
+ manifestKey: Buffer.from(hkdfSync('sha256', master, saltBytes, 'telstore v2 manifest key', KEY_BYTES)),
43
+ }
44
+ }
45
+
46
+ // What an unfinished upload keeps on disk instead of the password: enough to refuse a resume
47
+ // under a different one, which would put two keys into one backup.
48
+ export function passwordCheck(keys) {
49
+ return createHmac('sha256', keys.manifestKey).update('telstore v2 password check').digest('hex')
50
+ }
51
+
52
+ // CTR at any offset inside a chunk: the counter block for byte `offset` is the chunk's iv
53
+ // followed by the 64-bit block number, and the bytes before `offset` inside that block are
54
+ // discarded. A chunk is at most 1950MB, about 1.3e8 blocks, so the counter never carries into
55
+ // the iv.
56
+ export function chunkCipher(chunkKey, iv) {
57
+ const prefix = Buffer.from(iv, 'hex')
58
+
59
+ return {
60
+ apply(bytes, offset) {
61
+ const counter = Buffer.alloc(BLOCK)
62
+ prefix.copy(counter, 0)
63
+ counter.writeBigUInt64BE(BigInt(Math.floor(offset / BLOCK)), IV_BYTES)
64
+
65
+ const cipher = createCipheriv('aes-256-ctr', chunkKey, counter)
66
+ const skip = offset % BLOCK
67
+
68
+ if (skip > 0) cipher.update(Buffer.alloc(skip))
69
+
70
+ return Buffer.concat([cipher.update(bytes), cipher.final()])
71
+ },
72
+ }
73
+ }
74
+
75
+ // Built from the fields, in one fixed order, and never by serializing the parsed object again:
76
+ // an array has one serialization, while an object's depends on the key order of a file a person
77
+ // can edit. Every field is covered, the readable ones included. That proves the hint genuine only
78
+ // once a password opens the seal, and it is printed before then, so it is also made safe to print
79
+ // (terminalSafe in src/caption.js) rather than trusted because of this.
80
+ export function additionalData(manifest) {
81
+ return Buffer.from(
82
+ JSON.stringify([
83
+ 'telstore-enc-v2',
84
+ manifest.id,
85
+ manifest.name,
86
+ manifest.size,
87
+ manifest.chunkSize,
88
+ manifest.createdAt,
89
+ manifest.note ?? null,
90
+ manifest.enc.salt,
91
+ manifest.enc.hint ?? null,
92
+ manifest.chunks.map((chunk) => [chunk.i, chunk.msgId, chunk.size, chunk.sha256, chunk.iv]),
93
+ ]),
94
+ 'utf8',
95
+ )
96
+ }
97
+
98
+ export function sealManifest(manifest, keys, plainSha256) {
99
+ const nonce = randomBytes(NONCE_BYTES)
100
+ const cipher = createCipheriv('aes-256-gcm', keys.manifestKey, nonce)
101
+
102
+ cipher.setAAD(additionalData(manifest))
103
+
104
+ const body = Buffer.concat([cipher.update(JSON.stringify({ plainSha256 }), 'utf8'), cipher.final()])
105
+
106
+ return {
107
+ ...manifest,
108
+ enc: {
109
+ ...manifest.enc,
110
+ sealed: Buffer.concat([nonce, cipher.getAuthTag(), body]).toString('base64'),
111
+ },
112
+ }
113
+ }
114
+
115
+ // Null when the tag fails, which is one failure with two causes — a wrong password or an
116
+ // altered manifest — and the caller is the one that decides whether to ask again. Past the tag
117
+ // the contents are ours, so anything malformed there is a telstore that disagreed with this one
118
+ // about the format, and that throws rather than reading as a wrong password.
119
+ export async function openManifest(manifest, password) {
120
+ const keys = await deriveKeys(password, manifest.enc.salt)
121
+ const sealed = Buffer.from(manifest.enc.sealed, 'base64')
122
+
123
+ if (sealed.length <= NONCE_BYTES + TAG_BYTES) return null
124
+
125
+ const decipher = createDecipheriv('aes-256-gcm', keys.manifestKey, sealed.subarray(0, NONCE_BYTES))
126
+ decipher.setAAD(additionalData(manifest))
127
+ decipher.setAuthTag(sealed.subarray(NONCE_BYTES, NONCE_BYTES + TAG_BYTES))
128
+
129
+ let text
130
+ try {
131
+ text = Buffer.concat([
132
+ decipher.update(sealed.subarray(NONCE_BYTES + TAG_BYTES)),
133
+ decipher.final(),
134
+ ]).toString('utf8')
135
+ } catch {
136
+ return null
137
+ }
138
+
139
+ let inside = null
140
+ try {
141
+ inside = JSON.parse(text)
142
+ } catch {
143
+ // Falls through to the refusal below.
144
+ }
145
+
146
+ const hashes = inside?.plainSha256
147
+
148
+ if (
149
+ !Array.isArray(hashes) ||
150
+ hashes.length !== manifest.chunks.length ||
151
+ !hashes.every((hash) => typeof hash === 'string' && SHA256.test(hash))
152
+ ) {
153
+ throw new Error(
154
+ `The manifest of ${manifest.id} opened, but what is sealed inside it is not what telstore ` +
155
+ 'writes. Refusing to restore from it.',
156
+ )
157
+ }
158
+
159
+ return { keys, plainSha256: hashes }
160
+ }
161
+
162
+ async function readFully(handle, buffer, length, position) {
163
+ let filled = 0
164
+
165
+ while (filled < length) {
166
+ const { bytesRead } = await handle.read(buffer, filled, length - filled, position + filled)
167
+
168
+ if (bytesRead === 0) {
169
+ throw new Error(`Short read: needed ${length} bytes at offset ${position} but the file ended.`)
170
+ }
171
+
172
+ filled += bytesRead
173
+ }
174
+ }
175
+
176
+ async function writeFully(handle, buffer, position) {
177
+ let written = 0
178
+
179
+ while (written < buffer.length) {
180
+ const { bytesWritten } = await handle.write(buffer, written, buffer.length - written, position + written)
181
+
182
+ // readFully's guard, for the same reason: a write that makes no progress would spin here
183
+ // forever rather than fail, and a .partial half-decrypted with nobody told is the worst end.
184
+ if (bytesWritten === 0) {
185
+ throw new Error(`Short write: ${buffer.length - written} bytes at offset ${position + written} would not go to disk.`)
186
+ }
187
+
188
+ written += bytesWritten
189
+ }
190
+ }
191
+
192
+ // Reads a range that holds a verified chunk's ciphertext, writes its plaintext back over it,
193
+ // and hashes the plaintext on the way. Called only after the ciphertext sha256 has matched, so
194
+ // the hash it returns is the second check, not the first.
195
+ export async function decryptInPlace(handle, offset, length, cipher, { blockSize = IN_PLACE_BLOCK } = {}) {
196
+ const hash = createHash('sha256')
197
+ const buffer = Buffer.allocUnsafe(Math.min(blockSize, Math.max(length, 1)))
198
+
199
+ for (let at = 0; at < length; at += blockSize) {
200
+ const size = Math.min(blockSize, length - at)
201
+
202
+ await readFully(handle, buffer, size, offset + at)
203
+
204
+ const clear = cipher.apply(buffer.subarray(0, size), at)
205
+
206
+ hash.update(clear)
207
+ await writeFully(handle, clear, offset + at)
208
+ }
209
+
210
+ return hash.digest('hex')
211
+ }
package/src/cli.js CHANGED
@@ -23,6 +23,7 @@ const SUBCOMMANDS = new Set([
23
23
  'restore',
24
24
  'verify',
25
25
  'delete',
26
+ 'join',
26
27
  'status',
27
28
  'config',
28
29
  'token',
@@ -37,6 +38,7 @@ export const OPTIONS = {
37
38
  'download-concurrency': { type: 'string' },
38
39
  out: { type: 'string' },
39
40
  note: { type: 'string' },
41
+ encrypt: { type: 'boolean' },
40
42
  limit: { type: 'string' },
41
43
  search: { type: 'string' },
42
44
  verbose: { type: 'boolean' },
@@ -60,6 +62,7 @@ Usage:
60
62
  npx telstore restore <backup-id>... Download the chunks and reassemble the files
61
63
  npx telstore verify <backup-id>... Check that a backup's chunks are all still in the chat
62
64
  npx telstore delete <backup-id>... Remove backups' chunks and manifests from the chat
65
+ npx telstore join <manifest.json> Reassemble chunks you downloaded by hand, offline
63
66
  npx telstore status Show the account, the destination and unfinished uploads and restores
64
67
  npx telstore config Show every setting and where its value comes from
65
68
  npx telstore logout Remove the saved session
@@ -106,6 +109,11 @@ an exit code that reports any that failed. delete shows everything it is about t
106
109
  asks once. verify downloads nothing: it asks the chat whether every chunk message is still
107
110
  there at the length the manifest records, which is what restore would need.
108
111
 
112
+ join is restore without Telegram, for chunks you downloaded yourself — from Telegram web, say.
113
+ Put the manifest and every <id>.partNNNN file in one folder under the names they have in the
114
+ chat, and point join at the manifest. It needs no login and opens no connection, and it checks
115
+ each chunk's size and sha256 against the manifest before the file takes its real name.
116
+
109
117
  Settings:
110
118
  npx telstore config <name> Print one setting's value
111
119
  npx telstore config <name> <value> Change it for good
@@ -124,14 +132,20 @@ Options apply to one run and are never saved. Use config to change a setting for
124
132
  size it started with.
125
133
  --upload-concurrency <n> 512KB parts in parallel while uploading, this run only.
126
134
  --download-concurrency <n> 8MB slices in parallel while restoring, this run only.
127
- --out <path> Where to write the restored file. Defaults to the basename in
128
- the manifest, and works with one backup id only.
135
+ --out <path> Where to write the restored or joined file. Defaults to the
136
+ basename in the manifest, and works with one backup id only.
129
137
  --note <text> A note to store with the upload. It goes into the manifest and
130
138
  onto the manifest message, where Telegram's own search can find
131
139
  it, and every file of a batch gets the same one. A note with
132
140
  spaces in it has to be quoted — --note "march archive" — or the
133
141
  shell hands the words after the first to telstore as more files
134
142
  to upload.
143
+ --encrypt Encrypt the contents with a password before they leave this
144
+ machine. Asks for the password twice and for an optional hint,
145
+ which is shown in the chat as plain text. restore, tarx and join
146
+ see from the manifest that a backup is encrypted and ask for the
147
+ password themselves. The file name, note and size stay readable,
148
+ and a forgotten password is a lost backup.
135
149
  --limit <n> How many backups list shows this run.
136
150
  --search <text> List only the backups whose file name, note, backup id or
137
151
  creation day contains this text. Telegram's own index does
@@ -271,6 +285,13 @@ export function interruptMessage(
271
285
  )
272
286
  }
273
287
 
288
+ // A join writes nothing under the real name until the end, and the half-joined file is
289
+ // removed on the way out, so there is nothing to carry on from — and nothing in the chat
290
+ // it could have touched.
291
+ if (command === 'join') {
292
+ return '\nStopped. Nothing was joined — run the same command again to start over.\n'
293
+ }
294
+
274
295
  return '\nStopped.\n'
275
296
  }
276
297
 
@@ -409,7 +430,7 @@ function requireOneBackupId(ids, what) {
409
430
  }
410
431
  }
411
432
 
412
- export function route(argv) {
433
+ function routeLine(argv) {
413
434
  const { head, childArgv } = splitAtTerminator(argv)
414
435
 
415
436
  const { values, positionals, tokens } = parseArgs({
@@ -514,6 +535,22 @@ export function route(argv) {
514
535
  const shortcut = SHORTCUTS.get(first)
515
536
  if (shortcut) return shortcut(rest, values, filesAfterNote)
516
537
 
538
+ // --out names one file, and each manifest is one file: a second manifest on the line would
539
+ // have to be written somewhere nobody said.
540
+ if (first === 'join') {
541
+ if (rest.length === 0) {
542
+ throw new Error(
543
+ 'Missing the manifest to join. Example: npx telstore join ./telstore-20260905-7f3a91.manifest.json',
544
+ )
545
+ }
546
+
547
+ if (rest.length > 1) {
548
+ throw new Error(
549
+ `join takes one manifest and got ${rest.length}: ${rest.join(', ')}. Run it once per backup.`,
550
+ )
551
+ }
552
+ }
553
+
517
554
  if (SUBCOMMANDS.has(first)) {
518
555
  return { command: first, args: rest, options: values, filesAfterNote, childArgv, shortcut: null }
519
556
  }
@@ -522,3 +559,19 @@ export function route(argv) {
522
559
  // rest without a word, which is the one thing this project never does.
523
560
  return { command: 'upload', args: positionals, options: values, filesAfterNote, childArgv, shortcut: null }
524
561
  }
562
+
563
+ // Encryption is decided when a backup is made; every command that reads one learns it from the
564
+ // manifest. A flag beside a restore would read as "decrypt with this", which it would not be,
565
+ // and a flag that silently does nothing is one nobody can predict without the source.
566
+ export function route(argv) {
567
+ const parsed = routeLine(argv)
568
+
569
+ if (parsed.options.encrypt && parsed.command !== 'upload' && parsed.command !== 'help') {
570
+ throw new Error(
571
+ '--encrypt applies to uploads only. restore, tarx and join see from the manifest that a ' +
572
+ 'backup is encrypted and ask for its password by themselves.',
573
+ )
574
+ }
575
+
576
+ return parsed
577
+ }