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 +43 -3
- package/bin/telstore.js +18 -4
- package/package.json +1 -1
- package/src/caption.js +64 -2
- package/src/cipher.js +211 -0
- package/src/cli.js +56 -3
- package/src/commands/join.js +273 -0
- package/src/commands/list.js +11 -4
- package/src/commands/restore-stream.js +28 -1
- package/src/commands/restore.js +46 -21
- package/src/commands/status.js +6 -1
- package/src/commands/upload-stream.js +51 -3
- package/src/commands/upload.js +175 -4
- package/src/commands/verify.js +8 -1
- package/src/manifest.js +108 -4
- package/src/password.js +101 -0
- package/src/state.js +24 -2
- package/src/uploader.js +6 -1
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
|
|
273
|
-
|
|
274
|
-
|
|
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
|
|
42
|
-
// own on every ending it gets to run code for; this
|
|
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
|
|
103
|
-
`(${err.message}).
|
|
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
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
|
|
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
|
-
|
|
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
|
+
}
|