telstore 0.1.0 → 0.1.2
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 +109 -36
- package/bin/{telark.js → telstore.js} +16 -3
- package/package.json +6 -6
- package/src/caption.js +2 -2
- package/src/chat.js +2 -2
- package/src/chunking.js +14 -1
- package/src/cli.js +44 -31
- package/src/client.js +21 -9
- package/src/commands/config.js +8 -8
- package/src/commands/delete.js +5 -5
- package/src/commands/list.js +3 -3
- package/src/commands/login.js +88 -15
- package/src/commands/logout.js +15 -5
- package/src/commands/restore.js +8 -5
- package/src/commands/status.js +85 -7
- package/src/commands/token.js +80 -0
- package/src/commands/upload.js +7 -7
- package/src/config.js +28 -4
- package/src/downloader.js +2 -2
- package/src/manifest.js +4 -4
- package/src/prompt.js +81 -0
- package/src/session.js +24 -0
- package/src/settings.js +32 -6
- package/src/state.js +35 -4
- package/src/token.js +221 -0
- package/src/uploader.js +3 -3
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 {
|
|
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 =
|
|
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/manifest.js
CHANGED
|
@@ -8,7 +8,7 @@ export function newBackupId(now = new Date(), randomHex = () => randomBytes(3).t
|
|
|
8
8
|
const yyyy = now.getUTCFullYear()
|
|
9
9
|
const mm = String(now.getUTCMonth() + 1).padStart(2, '0')
|
|
10
10
|
const dd = String(now.getUTCDate()).padStart(2, '0')
|
|
11
|
-
return `
|
|
11
|
+
return `telstore-${yyyy}${mm}${dd}-${randomHex()}`
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
export function chunkFileName(id, i) {
|
|
@@ -72,7 +72,7 @@ export function manifestMessageIds(manifest) {
|
|
|
72
72
|
throw new Error(
|
|
73
73
|
`Manifest gives ${JSON.stringify(msgId)} as the message id of chunk ${index + 1}, ` +
|
|
74
74
|
'which is not a message id. Deleting from this manifest could remove the wrong ' +
|
|
75
|
-
'messages, so
|
|
75
|
+
'messages, so telstore is not deleting anything.',
|
|
76
76
|
)
|
|
77
77
|
}
|
|
78
78
|
|
|
@@ -85,7 +85,7 @@ export function parseManifest(input) {
|
|
|
85
85
|
|
|
86
86
|
if (manifest.v !== MANIFEST_VERSION) {
|
|
87
87
|
throw new Error(
|
|
88
|
-
`Manifest uses version ${manifest.v}, this build of
|
|
88
|
+
`Manifest uses version ${manifest.v}, this build of telstore only understands version ${MANIFEST_VERSION}.`,
|
|
89
89
|
)
|
|
90
90
|
}
|
|
91
91
|
|
|
@@ -147,7 +147,7 @@ export function parseManifest(input) {
|
|
|
147
147
|
// uniform: every chunk is chunkSize, except the last one which is the remainder.
|
|
148
148
|
// A correct total with individually wrong sizes yields a file with a hole or
|
|
149
149
|
// extra length while every per-chunk sha256 still matches — silently wrong data,
|
|
150
|
-
// precisely what
|
|
150
|
+
// precisely what telstore must never produce.
|
|
151
151
|
manifest.chunks.forEach((chunk, index) => {
|
|
152
152
|
const expected = Math.min(manifest.chunkSize, manifest.size - index * manifest.chunkSize)
|
|
153
153
|
if (chunk.size !== expected) {
|
package/src/prompt.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
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.
|
|
7
|
+
function veiledOutput(output) {
|
|
8
|
+
let hidden = false
|
|
9
|
+
|
|
10
|
+
return {
|
|
11
|
+
hide: () => {
|
|
12
|
+
hidden = true
|
|
13
|
+
},
|
|
14
|
+
show: () => {
|
|
15
|
+
hidden = false
|
|
16
|
+
},
|
|
17
|
+
// The question goes straight to the real output, past the curtain, so it stays on screen
|
|
18
|
+
// while the answer to it does not.
|
|
19
|
+
say: (text) => output.write(text),
|
|
20
|
+
stream: new Writable({
|
|
21
|
+
write(chunk, encoding, callback) {
|
|
22
|
+
if (!hidden) output.write(chunk, encoding)
|
|
23
|
+
callback()
|
|
24
|
+
},
|
|
25
|
+
}),
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const NO_TERMINAL =
|
|
30
|
+
'There is no terminal here to type a secret into. Run this where you can type it.'
|
|
31
|
+
|
|
32
|
+
// One readline for a whole conversation, some of whose answers must not stay on the screen.
|
|
33
|
+
// It has to be one: two readlines over a single stdin do not take turns — the first keeps the
|
|
34
|
+
// listener and everything typed after it lands nowhere, so the second reaches end-of-input
|
|
35
|
+
// having read nothing and reports it as Ctrl-D. Verified against a real terminal, because a
|
|
36
|
+
// fake stream takes turns perfectly well and would have called this fine.
|
|
37
|
+
export function createPrompts({ input = stdin, output = stdout } = {}) {
|
|
38
|
+
const veil = veiledOutput(output)
|
|
39
|
+
// terminal follows stdin: true is what stops the tty driver from echoing on its own, which
|
|
40
|
+
// is what leaves the curtain as the only thing between the keyboard and the screen. Forcing
|
|
41
|
+
// it on a pipe would put readline into line editing over input with no terminal behind it.
|
|
42
|
+
const rl = readline.createInterface({ input, output: veil.stream, terminal: Boolean(input.isTTY) })
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
ask: (question) => rl.question(question),
|
|
46
|
+
async askSecret(question) {
|
|
47
|
+
// A prompt written where nobody can see it, waiting on a stream that will never carry a
|
|
48
|
+
// typed answer, is a hang — the one failure this project refuses to produce anywhere.
|
|
49
|
+
// Reading it from the pipe instead would be worse: the secret would then have come from
|
|
50
|
+
// somewhere that kept a copy of it.
|
|
51
|
+
if (!input.isTTY) throw new Error(NO_TERMINAL)
|
|
52
|
+
|
|
53
|
+
veil.say(question)
|
|
54
|
+
veil.hide()
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
return await rl.question('')
|
|
58
|
+
} finally {
|
|
59
|
+
veil.show()
|
|
60
|
+
veil.say('\n')
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
// Not optional: terminal mode put stdin in raw mode, and leaving it there hands the user
|
|
64
|
+
// back a shell that no longer echoes what they type.
|
|
65
|
+
close: () => rl.close(),
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// The single-question case. Its question goes to stderr rather than stdout, because
|
|
70
|
+
// "npx telstore token > token.txt" has to still show it, and stdout there carries one thing.
|
|
71
|
+
export async function readSecret(question, { input = stdin, output = stderr } = {}) {
|
|
72
|
+
if (!input.isTTY) throw new Error(NO_TERMINAL)
|
|
73
|
+
|
|
74
|
+
const prompts = createPrompts({ input, output })
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
return await prompts.askSecret(question)
|
|
78
|
+
} finally {
|
|
79
|
+
prompts.close()
|
|
80
|
+
}
|
|
81
|
+
}
|
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
|
-
|
|
4
|
+
DEFAULT_DOWNLOAD_CONCURRENCY,
|
|
5
|
+
DEFAULT_UPLOAD_CONCURRENCY,
|
|
5
6
|
MAX_CONCURRENCY,
|
|
6
7
|
parseSize,
|
|
7
8
|
} from './chunking.js'
|
|
@@ -9,7 +10,7 @@ import { formatBytes } from './progress.js'
|
|
|
9
10
|
|
|
10
11
|
export const DEFAULT_LIMIT = 20
|
|
11
12
|
|
|
12
|
-
// The three keys
|
|
13
|
+
// The three keys telstore writes for itself. Naming them separately is what lets the
|
|
13
14
|
// unknown-key error say "managed by login" instead of listing a session as something the
|
|
14
15
|
// user forgot to spell correctly.
|
|
15
16
|
const MANAGED_BY_LOGIN = new Set(['session', 'apiId', 'apiHash'])
|
|
@@ -74,9 +75,9 @@ export const SETTINGS = {
|
|
|
74
75
|
format: (value) => String(value),
|
|
75
76
|
describe: (value) => formatBytes(value),
|
|
76
77
|
},
|
|
77
|
-
|
|
78
|
-
flag: 'concurrency',
|
|
79
|
-
default:
|
|
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,
|
|
@@ -181,10 +196,21 @@ export function resolveSettings(options = {}, config = {}, { file = 'the config
|
|
|
181
196
|
export function requireChat(values) {
|
|
182
197
|
if (values.chat === null || values.chat === undefined) {
|
|
183
198
|
throw new Error(
|
|
184
|
-
'No destination set — run "npx
|
|
199
|
+
'No destination set — run "npx telstore config chat @my_backups" to set one ' +
|
|
185
200
|
'("config chat me" for Saved Messages), or pass --to to choose one for this run.',
|
|
186
201
|
)
|
|
187
202
|
}
|
|
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
|
|
114
|
-
|
|
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
|
|
@@ -120,11 +126,11 @@ export async function listStates(configDir = defaultConfigDir()) {
|
|
|
120
126
|
// delete needs the file a record came from, not just its contents — and the name of that
|
|
121
127
|
// file is a hash of the path, size and mtime *inside* the record, so recomputing it would
|
|
122
128
|
// be trusting an untrusted file to say where it lives. A hand-edited path yields a key that
|
|
123
|
-
// names no file at all, clearState ignores a file that is not there, and
|
|
129
|
+
// names no file at all, clearState ignores a file that is not there, and telstore reports a
|
|
124
130
|
// record dropped that is still sitting on disk. Matching the id inside each file is the one
|
|
125
131
|
// way that cannot point at the wrong one.
|
|
126
132
|
//
|
|
127
|
-
// Every record claiming the id is returned rather than the first: two of them means
|
|
133
|
+
// Every record claiming the id is returned rather than the first: two of them means telstore
|
|
128
134
|
// cannot know which to drop, and that is the caller's decision to refuse, not ours to make
|
|
129
135
|
// by picking one.
|
|
130
136
|
export async function findStates(backupId, configDir = defaultConfigDir()) {
|
|
@@ -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 {
|
|
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
|
|
|
@@ -13,7 +13,7 @@ const read = promisify(readCallback)
|
|
|
13
13
|
|
|
14
14
|
// Telegram splits its upload API by file size: only files above 10MB may use the
|
|
15
15
|
// "big" family. GramJS picks the same threshold (LARGE_FILE_THRESHOLD in
|
|
16
|
-
// node_modules/telegram/client/uploads.js), and
|
|
16
|
+
// node_modules/telegram/client/uploads.js), and telstore follows it.
|
|
17
17
|
export const LARGE_FILE_THRESHOLD = 10 * 1024 * 1024
|
|
18
18
|
|
|
19
19
|
async function readExactly(fd, length, position) {
|
|
@@ -39,7 +39,7 @@ export async function uploadRange(client, fd, options) {
|
|
|
39
39
|
offset,
|
|
40
40
|
length,
|
|
41
41
|
fileName,
|
|
42
|
-
concurrency =
|
|
42
|
+
concurrency = DEFAULT_UPLOAD_CONCURRENCY,
|
|
43
43
|
partSize = PART_SIZE,
|
|
44
44
|
onProgress,
|
|
45
45
|
retryOptions,
|