bytifi 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 +98 -11
- package/bin/bytifi.js +365 -48
- package/lib/crypto.js +43 -0
- package/lib/decrypt.js +664 -0
- package/lib/upload.js +118 -18
- package/package.json +9 -5
package/lib/crypto.js
CHANGED
|
@@ -200,6 +200,49 @@ export function importToken(token) {
|
|
|
200
200
|
return tokenBytes
|
|
201
201
|
}
|
|
202
202
|
|
|
203
|
+
export function normalizeClientEncryptionMeta(rawMeta) {
|
|
204
|
+
if (!rawMeta || typeof rawMeta !== 'object') return null
|
|
205
|
+
|
|
206
|
+
const chunkSize = Number(rawMeta.chunkSize)
|
|
207
|
+
const chunkCount = Number(rawMeta.chunkCount)
|
|
208
|
+
const originalSize = Number(rawMeta.originalSize)
|
|
209
|
+
|
|
210
|
+
if (!Number.isFinite(chunkSize) || chunkSize <= 0) return null
|
|
211
|
+
if (!Number.isFinite(chunkCount) || chunkCount <= 0) return null
|
|
212
|
+
if (!Number.isFinite(originalSize) || originalSize < 0) return null
|
|
213
|
+
if (typeof rawMeta.noncePrefix !== 'string' || !rawMeta.noncePrefix) return null
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
version: Number(rawMeta.version) || 1,
|
|
217
|
+
algorithm: String(rawMeta.algorithm || 'AES-GCM'),
|
|
218
|
+
chunkSize,
|
|
219
|
+
chunkCount,
|
|
220
|
+
noncePrefix: rawMeta.noncePrefix,
|
|
221
|
+
originalSize,
|
|
222
|
+
mimeType: String(rawMeta.mimeType || 'application/octet-stream'),
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function buildEncryptedChunkPlan(meta) {
|
|
227
|
+
const chunks = []
|
|
228
|
+
let offset = 0
|
|
229
|
+
|
|
230
|
+
for (let chunkIndex = 0; chunkIndex < meta.chunkCount; chunkIndex += 1) {
|
|
231
|
+
const start = chunkIndex * meta.chunkSize
|
|
232
|
+
const plainSize = Math.min(meta.originalSize - start, meta.chunkSize)
|
|
233
|
+
const encryptedSize = plainSize + 16
|
|
234
|
+
|
|
235
|
+
chunks.push({ chunkIndex, encryptedSize, plainSize })
|
|
236
|
+
offset += encryptedSize
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return {
|
|
240
|
+
chunks,
|
|
241
|
+
totalEncryptedSize: offset,
|
|
242
|
+
totalPlainSize: meta.originalSize,
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
203
246
|
function guessMimeType(filename) {
|
|
204
247
|
const ext = path.extname(filename).toLowerCase()
|
|
205
248
|
const map = {
|
package/lib/decrypt.js
ADDED
|
@@ -0,0 +1,664 @@
|
|
|
1
|
+
import fs from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import process from 'node:process'
|
|
4
|
+
import {
|
|
5
|
+
buildEncryptedChunkPlan,
|
|
6
|
+
decryptChunk,
|
|
7
|
+
importToken,
|
|
8
|
+
normalizeClientEncryptionMeta,
|
|
9
|
+
} from './crypto.js'
|
|
10
|
+
import { fromBase64Url } from './base64url.js'
|
|
11
|
+
import { BytifiApiError, BytifiNetworkError } from './upload.js'
|
|
12
|
+
|
|
13
|
+
const DEFAULT_BASE_URL = 'https://bytifi.com'
|
|
14
|
+
|
|
15
|
+
function normalizeBaseUrl(baseUrl) {
|
|
16
|
+
return String(baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, '')
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function readResponseBody(response) {
|
|
20
|
+
const text = await response.text()
|
|
21
|
+
if (!text) return null
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
return JSON.parse(text)
|
|
25
|
+
} catch {
|
|
26
|
+
return text
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function publicFetch(baseUrl, requestPath, { signal } = {}) {
|
|
31
|
+
const url = `${normalizeBaseUrl(baseUrl)}${requestPath}`
|
|
32
|
+
|
|
33
|
+
let response
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
response = await fetch(url, { signal })
|
|
37
|
+
} catch (error) {
|
|
38
|
+
if (signal?.aborted) {
|
|
39
|
+
throw new Error('Decrypt aborted.')
|
|
40
|
+
}
|
|
41
|
+
throw new BytifiNetworkError(error.message || 'Network request failed.', { cause: error })
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const payload = await readResponseBody(response)
|
|
45
|
+
|
|
46
|
+
if (!response.ok) {
|
|
47
|
+
const message = typeof payload === 'object' && payload?.error
|
|
48
|
+
? payload.error
|
|
49
|
+
: typeof payload === 'string' && payload
|
|
50
|
+
? payload
|
|
51
|
+
: `Request failed with status ${response.status}.`
|
|
52
|
+
throw new BytifiApiError(message, { status: response.status, body: payload })
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return payload
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function parseDecryptInput(input, { encryptionToken = '', baseUrl = DEFAULT_BASE_URL } = {}) {
|
|
59
|
+
const trimmed = String(input || '').trim()
|
|
60
|
+
if (!trimmed) {
|
|
61
|
+
throw new Error('Missing share URL or link token.')
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let resolvedBaseUrl = normalizeBaseUrl(baseUrl)
|
|
65
|
+
let linkToken = ''
|
|
66
|
+
let resolvedEncryptionToken = String(encryptionToken || '').trim()
|
|
67
|
+
|
|
68
|
+
if (/^https?:\/\//i.test(trimmed) || trimmed.startsWith('/')) {
|
|
69
|
+
const url = new URL(trimmed, `${resolvedBaseUrl}/`)
|
|
70
|
+
|
|
71
|
+
if (url.origin !== 'null:') {
|
|
72
|
+
resolvedBaseUrl = `${url.protocol}//${url.host}`
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const hashParams = new URLSearchParams(url.hash.startsWith('#') ? url.hash.slice(1) : url.hash)
|
|
76
|
+
if (!resolvedEncryptionToken) {
|
|
77
|
+
resolvedEncryptionToken = hashParams.get('token') || ''
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
linkToken = url.searchParams.get('link') || ''
|
|
81
|
+
|
|
82
|
+
if (!linkToken) {
|
|
83
|
+
const fileMatch = url.pathname.match(/^\/f\/([^/]+)/)
|
|
84
|
+
linkToken = fileMatch?.[1] || ''
|
|
85
|
+
}
|
|
86
|
+
} else {
|
|
87
|
+
linkToken = trimmed
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (!linkToken) {
|
|
91
|
+
throw new Error('Could not find a link token in the input URL.')
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (!resolvedEncryptionToken) {
|
|
95
|
+
throw new Error('Missing encryption token. Include #token=... in the URL or pass --token.')
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
baseUrl: resolvedBaseUrl,
|
|
100
|
+
linkToken,
|
|
101
|
+
encryptionToken: resolvedEncryptionToken,
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function parseShareReference(input, { encryptionToken = '', baseUrl = DEFAULT_BASE_URL } = {}) {
|
|
106
|
+
const trimmed = String(input || '').trim()
|
|
107
|
+
if (!trimmed) {
|
|
108
|
+
return {
|
|
109
|
+
baseUrl: normalizeBaseUrl(baseUrl),
|
|
110
|
+
linkToken: '',
|
|
111
|
+
encryptionToken: String(encryptionToken || '').trim(),
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (!/^https?:\/\//i.test(trimmed) && !trimmed.startsWith('/')) {
|
|
116
|
+
return {
|
|
117
|
+
baseUrl: normalizeBaseUrl(baseUrl),
|
|
118
|
+
linkToken: trimmed,
|
|
119
|
+
encryptionToken: String(encryptionToken || '').trim(),
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const url = new URL(trimmed, `${normalizeBaseUrl(baseUrl)}/`)
|
|
124
|
+
const hashParams = new URLSearchParams(url.hash.startsWith('#') ? url.hash.slice(1) : url.hash)
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
baseUrl: `${url.protocol}//${url.host}`,
|
|
128
|
+
linkToken: url.searchParams.get('link') || url.pathname.match(/^\/f\/([^/]+)/)?.[1] || '',
|
|
129
|
+
encryptionToken: String(encryptionToken || hashParams.get('token') || '').trim(),
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function looksLikeRemoteInput(input) {
|
|
134
|
+
const trimmed = String(input || '').trim()
|
|
135
|
+
return /^https?:\/\//i.test(trimmed)
|
|
136
|
+
|| trimmed.startsWith('/link')
|
|
137
|
+
|| trimmed.startsWith('/f/')
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function pathExists(inputPath) {
|
|
141
|
+
try {
|
|
142
|
+
await fs.access(path.resolve(inputPath))
|
|
143
|
+
return true
|
|
144
|
+
} catch {
|
|
145
|
+
return false
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function readMetaFile(metaPath) {
|
|
150
|
+
const raw = await fs.readFile(path.resolve(metaPath), 'utf8')
|
|
151
|
+
const parsed = JSON.parse(raw)
|
|
152
|
+
const meta = normalizeClientEncryptionMeta(parsed?.clientEncryptionMeta || parsed)
|
|
153
|
+
|
|
154
|
+
if (!meta) {
|
|
155
|
+
throw new Error('Invalid encryption metadata file.')
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return meta
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function resolveEncryptionMeta({
|
|
162
|
+
metaPath = '',
|
|
163
|
+
linkToken = '',
|
|
164
|
+
baseUrl = DEFAULT_BASE_URL,
|
|
165
|
+
signal,
|
|
166
|
+
}) {
|
|
167
|
+
if (metaPath) {
|
|
168
|
+
return readMetaFile(metaPath)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (!linkToken) {
|
|
172
|
+
throw new Error('Pass --link to fetch encryption metadata, or --meta with a saved metadata JSON file.')
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const linkInfo = await fetchLinkInfo(baseUrl, linkToken, signal)
|
|
176
|
+
|
|
177
|
+
if (linkInfo.status === 'expired') {
|
|
178
|
+
throw new Error('This file link has expired.')
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (!linkInfo.clientEncrypted) {
|
|
182
|
+
throw new Error('This link is not an encrypted file.')
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const meta = normalizeClientEncryptionMeta(linkInfo.clientEncryptionMeta)
|
|
186
|
+
if (!meta) {
|
|
187
|
+
throw new Error('Invalid encryption metadata for this file.')
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return { meta, linkInfo }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function sanitizeOutputName(filename) {
|
|
194
|
+
const base = path.basename(String(filename || 'download').replace(/[\0\r\n]/g, ''))
|
|
195
|
+
return base || 'download'
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function fetchLinkInfo(baseUrl, linkToken, signal) {
|
|
199
|
+
return publicFetch(baseUrl, `/api/link/${encodeURIComponent(linkToken)}`, { signal })
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function fetchEncryptedPart(baseUrl, linkToken, partNumber, signal) {
|
|
203
|
+
const response = await fetch(
|
|
204
|
+
`${normalizeBaseUrl(baseUrl)}/f/${encodeURIComponent(linkToken)}/p/${partNumber}`,
|
|
205
|
+
{ signal },
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
if (!response.ok) {
|
|
209
|
+
const text = await response.text()
|
|
210
|
+
throw new BytifiApiError(text || `Failed to download part ${partNumber}.`, {
|
|
211
|
+
status: response.status,
|
|
212
|
+
body: text,
|
|
213
|
+
})
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return Buffer.from(await response.arrayBuffer())
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function decryptFromParts({
|
|
220
|
+
baseUrl,
|
|
221
|
+
linkToken,
|
|
222
|
+
meta,
|
|
223
|
+
tokenBytes,
|
|
224
|
+
noncePrefix,
|
|
225
|
+
outputPath,
|
|
226
|
+
onProgress,
|
|
227
|
+
signal,
|
|
228
|
+
}) {
|
|
229
|
+
const { chunks } = buildEncryptedChunkPlan(meta)
|
|
230
|
+
const fileHandle = await fs.open(outputPath, 'w')
|
|
231
|
+
let decryptedBytes = 0
|
|
232
|
+
|
|
233
|
+
try {
|
|
234
|
+
for (let index = 0; index < chunks.length; index += 1) {
|
|
235
|
+
if (signal?.aborted) {
|
|
236
|
+
throw new Error('Decrypt aborted.')
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const chunk = chunks[index]
|
|
240
|
+
const encryptedPart = await fetchEncryptedPart(baseUrl, linkToken, chunk.chunkIndex + 1, signal)
|
|
241
|
+
const plainPart = decryptChunk(encryptedPart, tokenBytes, noncePrefix, chunk.chunkIndex)
|
|
242
|
+
|
|
243
|
+
await fileHandle.write(plainPart)
|
|
244
|
+
decryptedBytes += plainPart.length
|
|
245
|
+
onProgress?.(Math.round((decryptedBytes / meta.originalSize) * 100))
|
|
246
|
+
}
|
|
247
|
+
} finally {
|
|
248
|
+
await fileHandle.close()
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async function decryptFromSingleFile({
|
|
253
|
+
encryptedFileUrl,
|
|
254
|
+
meta,
|
|
255
|
+
tokenBytes,
|
|
256
|
+
noncePrefix,
|
|
257
|
+
outputPath,
|
|
258
|
+
onProgress,
|
|
259
|
+
signal,
|
|
260
|
+
}) {
|
|
261
|
+
const { chunks, totalEncryptedSize } = buildEncryptedChunkPlan(meta)
|
|
262
|
+
const response = await fetch(encryptedFileUrl, { signal })
|
|
263
|
+
|
|
264
|
+
if (!response.ok) {
|
|
265
|
+
const text = await response.text()
|
|
266
|
+
throw new BytifiApiError(text || 'Failed to download encrypted file.', {
|
|
267
|
+
status: response.status,
|
|
268
|
+
body: text,
|
|
269
|
+
})
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (!response.body) {
|
|
273
|
+
const encryptedBuffer = Buffer.from(await response.arrayBuffer())
|
|
274
|
+
await writeDecryptedBuffer(encryptedBuffer, chunks, meta, tokenBytes, noncePrefix, outputPath, onProgress, signal)
|
|
275
|
+
return
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const reader = response.body.getReader()
|
|
279
|
+
const fileHandle = await fs.open(outputPath, 'w')
|
|
280
|
+
const pending = []
|
|
281
|
+
let pendingLength = 0
|
|
282
|
+
let downloadedBytes = 0
|
|
283
|
+
let nextChunkIndex = 0
|
|
284
|
+
let decryptedBytes = 0
|
|
285
|
+
|
|
286
|
+
const takeBytes = (length) => {
|
|
287
|
+
while (pendingLength < length) {
|
|
288
|
+
return null
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const out = Buffer.alloc(length)
|
|
292
|
+
let offset = 0
|
|
293
|
+
|
|
294
|
+
while (offset < length) {
|
|
295
|
+
const head = pending[0]
|
|
296
|
+
const take = Math.min(length - offset, head.length)
|
|
297
|
+
head.copy(out, offset, 0, take)
|
|
298
|
+
offset += take
|
|
299
|
+
|
|
300
|
+
if (take === head.length) {
|
|
301
|
+
pending.shift()
|
|
302
|
+
} else {
|
|
303
|
+
pending[0] = head.subarray(take)
|
|
304
|
+
}
|
|
305
|
+
pendingLength -= take
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return out
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
try {
|
|
312
|
+
while (nextChunkIndex < chunks.length) {
|
|
313
|
+
if (signal?.aborted) {
|
|
314
|
+
throw new Error('Decrypt aborted.')
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const chunk = chunks[nextChunkIndex]
|
|
318
|
+
|
|
319
|
+
while (pendingLength < chunk.encryptedSize) {
|
|
320
|
+
const { done, value } = await reader.read()
|
|
321
|
+
if (done) break
|
|
322
|
+
const buffer = Buffer.from(value)
|
|
323
|
+
pending.push(buffer)
|
|
324
|
+
pendingLength += buffer.length
|
|
325
|
+
downloadedBytes += buffer.length
|
|
326
|
+
onProgress?.(Math.min(99, Math.round((downloadedBytes / totalEncryptedSize) * 100)))
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const encryptedChunk = takeBytes(chunk.encryptedSize)
|
|
330
|
+
if (!encryptedChunk) {
|
|
331
|
+
throw new Error('Encrypted file ended before all parts were downloaded.')
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const plainPart = decryptChunk(encryptedChunk, tokenBytes, noncePrefix, chunk.chunkIndex)
|
|
335
|
+
await fileHandle.write(plainPart)
|
|
336
|
+
decryptedBytes += plainPart.length
|
|
337
|
+
nextChunkIndex += 1
|
|
338
|
+
onProgress?.(Math.min(99, Math.round((decryptedBytes / meta.originalSize) * 100)))
|
|
339
|
+
}
|
|
340
|
+
} finally {
|
|
341
|
+
await reader.cancel().catch(() => {})
|
|
342
|
+
await fileHandle.close()
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
onProgress?.(100)
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
async function writeDecryptedBuffer(encryptedBuffer, chunks, meta, tokenBytes, noncePrefix, outputPath, onProgress, signal) {
|
|
349
|
+
const fileHandle = await fs.open(outputPath, 'w')
|
|
350
|
+
let offset = 0
|
|
351
|
+
let decryptedBytes = 0
|
|
352
|
+
|
|
353
|
+
try {
|
|
354
|
+
for (const chunk of chunks) {
|
|
355
|
+
if (signal?.aborted) {
|
|
356
|
+
throw new Error('Decrypt aborted.')
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const encryptedChunk = encryptedBuffer.subarray(offset, offset + chunk.encryptedSize)
|
|
360
|
+
if (encryptedChunk.length !== chunk.encryptedSize) {
|
|
361
|
+
throw new Error('Encrypted file ended before all parts were downloaded.')
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const plainPart = decryptChunk(encryptedChunk, tokenBytes, noncePrefix, chunk.chunkIndex)
|
|
365
|
+
await fileHandle.write(plainPart)
|
|
366
|
+
offset += chunk.encryptedSize
|
|
367
|
+
decryptedBytes += plainPart.length
|
|
368
|
+
onProgress?.(Math.round((decryptedBytes / meta.originalSize) * 100))
|
|
369
|
+
}
|
|
370
|
+
} finally {
|
|
371
|
+
await fileHandle.close()
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
onProgress?.(100)
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
async function decryptFromLocalSingleFile({
|
|
378
|
+
encryptedFilePath,
|
|
379
|
+
meta,
|
|
380
|
+
tokenBytes,
|
|
381
|
+
noncePrefix,
|
|
382
|
+
outputPath,
|
|
383
|
+
onProgress,
|
|
384
|
+
signal,
|
|
385
|
+
}) {
|
|
386
|
+
const { chunks } = buildEncryptedChunkPlan(meta)
|
|
387
|
+
const stat = await fs.stat(encryptedFilePath)
|
|
388
|
+
|
|
389
|
+
if (stat.size <= 64 * 1024 * 1024) {
|
|
390
|
+
const encryptedBuffer = await fs.readFile(encryptedFilePath)
|
|
391
|
+
await writeDecryptedBuffer(encryptedBuffer, chunks, meta, tokenBytes, noncePrefix, outputPath, onProgress, signal)
|
|
392
|
+
return
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const fileHandle = await fs.open(encryptedFilePath, 'r')
|
|
396
|
+
const outputHandle = await fs.open(outputPath, 'w')
|
|
397
|
+
let offset = 0
|
|
398
|
+
let decryptedBytes = 0
|
|
399
|
+
|
|
400
|
+
try {
|
|
401
|
+
for (const chunk of chunks) {
|
|
402
|
+
if (signal?.aborted) {
|
|
403
|
+
throw new Error('Decrypt aborted.')
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const encryptedChunk = Buffer.alloc(chunk.encryptedSize)
|
|
407
|
+
const { bytesRead } = await fileHandle.read(encryptedChunk, 0, chunk.encryptedSize, offset)
|
|
408
|
+
|
|
409
|
+
if (bytesRead !== chunk.encryptedSize) {
|
|
410
|
+
throw new Error('Encrypted file ended before all parts were read.')
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const plainPart = decryptChunk(encryptedChunk, tokenBytes, noncePrefix, chunk.chunkIndex)
|
|
414
|
+
await outputHandle.write(plainPart)
|
|
415
|
+
offset += chunk.encryptedSize
|
|
416
|
+
decryptedBytes += plainPart.length
|
|
417
|
+
onProgress?.(Math.round((decryptedBytes / meta.originalSize) * 100))
|
|
418
|
+
}
|
|
419
|
+
} finally {
|
|
420
|
+
await fileHandle.close()
|
|
421
|
+
await outputHandle.close()
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
onProgress?.(100)
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
async function listLocalPartFiles(dirPath) {
|
|
428
|
+
const entries = await fs.readdir(dirPath)
|
|
429
|
+
const parts = entries
|
|
430
|
+
.map((name) => {
|
|
431
|
+
const match = name.match(/^(\d+)(?:\.part)?$/)
|
|
432
|
+
return match ? { partNumber: Number(match[1]), name } : null
|
|
433
|
+
})
|
|
434
|
+
.filter(Boolean)
|
|
435
|
+
.sort((left, right) => left.partNumber - right.partNumber)
|
|
436
|
+
|
|
437
|
+
if (parts.length === 0) {
|
|
438
|
+
throw new Error('No numbered part files found in directory. Expected names like 1, 2, or 1.part, 2.part.')
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
return parts
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
async function decryptFromLocalParts({
|
|
445
|
+
partsDirectory,
|
|
446
|
+
meta,
|
|
447
|
+
tokenBytes,
|
|
448
|
+
noncePrefix,
|
|
449
|
+
outputPath,
|
|
450
|
+
onProgress,
|
|
451
|
+
signal,
|
|
452
|
+
}) {
|
|
453
|
+
const { chunks } = buildEncryptedChunkPlan(meta)
|
|
454
|
+
const parts = await listLocalPartFiles(partsDirectory)
|
|
455
|
+
|
|
456
|
+
if (parts.length !== chunks.length) {
|
|
457
|
+
throw new Error(`Expected ${chunks.length} part files, found ${parts.length}.`)
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const fileHandle = await fs.open(outputPath, 'w')
|
|
461
|
+
let decryptedBytes = 0
|
|
462
|
+
|
|
463
|
+
try {
|
|
464
|
+
for (const chunk of chunks) {
|
|
465
|
+
if (signal?.aborted) {
|
|
466
|
+
throw new Error('Decrypt aborted.')
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const partNumber = chunk.chunkIndex + 1
|
|
470
|
+
const partEntry = parts[chunk.chunkIndex]
|
|
471
|
+
|
|
472
|
+
if (!partEntry || partEntry.partNumber !== partNumber) {
|
|
473
|
+
throw new Error(`Missing local part file ${partNumber}.`)
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const encryptedPart = await fs.readFile(path.join(partsDirectory, partEntry.name))
|
|
477
|
+
const plainPart = decryptChunk(encryptedPart, tokenBytes, noncePrefix, chunk.chunkIndex)
|
|
478
|
+
|
|
479
|
+
await fileHandle.write(plainPart)
|
|
480
|
+
decryptedBytes += plainPart.length
|
|
481
|
+
onProgress?.(Math.round((decryptedBytes / meta.originalSize) * 100))
|
|
482
|
+
}
|
|
483
|
+
} finally {
|
|
484
|
+
await fileHandle.close()
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
onProgress?.(100)
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function resolveEncryptionToken(encryptionToken, shareReference) {
|
|
491
|
+
const resolved = String(encryptionToken || shareReference?.encryptionToken || '').trim()
|
|
492
|
+
|
|
493
|
+
if (!resolved) {
|
|
494
|
+
throw new Error('Missing encryption token. Pass --token or include #token=... in --share-url.')
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
return resolved
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function buildDecryptResult({
|
|
501
|
+
outputPath,
|
|
502
|
+
originalName,
|
|
503
|
+
size,
|
|
504
|
+
mimeType,
|
|
505
|
+
expiresAt = null,
|
|
506
|
+
linkToken = '',
|
|
507
|
+
storageMode = 'single',
|
|
508
|
+
sourcePath = '',
|
|
509
|
+
}) {
|
|
510
|
+
return {
|
|
511
|
+
outputPath,
|
|
512
|
+
originalName,
|
|
513
|
+
size,
|
|
514
|
+
mimeType,
|
|
515
|
+
expiresAt,
|
|
516
|
+
linkToken,
|
|
517
|
+
storageMode,
|
|
518
|
+
sourcePath,
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
async function decryptLocalFile(inputPath, options = {}) {
|
|
523
|
+
const absolutePath = path.resolve(inputPath)
|
|
524
|
+
const stat = await fs.stat(absolutePath)
|
|
525
|
+
const shareReference = parseShareReference(options.shareUrl || '', {
|
|
526
|
+
encryptionToken: options.encryptionToken,
|
|
527
|
+
baseUrl: options.baseUrl,
|
|
528
|
+
})
|
|
529
|
+
const linkToken = options.linkToken || shareReference.linkToken
|
|
530
|
+
const resolved = await resolveEncryptionMeta({
|
|
531
|
+
metaPath: options.metaPath,
|
|
532
|
+
linkToken,
|
|
533
|
+
baseUrl: options.baseUrl || shareReference.baseUrl,
|
|
534
|
+
signal: options.signal,
|
|
535
|
+
})
|
|
536
|
+
const meta = resolved.meta || resolved
|
|
537
|
+
const linkInfo = resolved.linkInfo || null
|
|
538
|
+
const encryptionToken = resolveEncryptionToken(options.encryptionToken, shareReference)
|
|
539
|
+
const tokenBytes = importToken(encryptionToken)
|
|
540
|
+
const noncePrefix = fromBase64Url(meta.noncePrefix)
|
|
541
|
+
const originalName = linkInfo?.originalName || options.originalName || 'download'
|
|
542
|
+
const outputName = sanitizeOutputName(options.output ? path.basename(options.output) : originalName)
|
|
543
|
+
const outputPath = path.resolve(options.output || path.join(options.outputDirectory || process.cwd(), outputName))
|
|
544
|
+
|
|
545
|
+
await fs.mkdir(path.dirname(outputPath), { recursive: true })
|
|
546
|
+
|
|
547
|
+
if (stat.isDirectory()) {
|
|
548
|
+
await decryptFromLocalParts({
|
|
549
|
+
partsDirectory: absolutePath,
|
|
550
|
+
meta,
|
|
551
|
+
tokenBytes,
|
|
552
|
+
noncePrefix,
|
|
553
|
+
outputPath,
|
|
554
|
+
onProgress: options.onProgress,
|
|
555
|
+
signal: options.signal,
|
|
556
|
+
})
|
|
557
|
+
|
|
558
|
+
return buildDecryptResult({
|
|
559
|
+
outputPath,
|
|
560
|
+
originalName,
|
|
561
|
+
size: meta.originalSize,
|
|
562
|
+
mimeType: meta.mimeType,
|
|
563
|
+
expiresAt: linkInfo?.expiresAt || null,
|
|
564
|
+
linkToken,
|
|
565
|
+
storageMode: 'parts',
|
|
566
|
+
sourcePath: absolutePath,
|
|
567
|
+
})
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
await decryptFromLocalSingleFile({
|
|
571
|
+
encryptedFilePath: absolutePath,
|
|
572
|
+
meta,
|
|
573
|
+
tokenBytes,
|
|
574
|
+
noncePrefix,
|
|
575
|
+
outputPath,
|
|
576
|
+
onProgress: options.onProgress,
|
|
577
|
+
signal: options.signal,
|
|
578
|
+
})
|
|
579
|
+
|
|
580
|
+
return buildDecryptResult({
|
|
581
|
+
outputPath,
|
|
582
|
+
originalName,
|
|
583
|
+
size: meta.originalSize,
|
|
584
|
+
mimeType: meta.mimeType,
|
|
585
|
+
expiresAt: linkInfo?.expiresAt || null,
|
|
586
|
+
linkToken,
|
|
587
|
+
storageMode: 'single',
|
|
588
|
+
sourcePath: absolutePath,
|
|
589
|
+
})
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
export async function decryptFile(input, options = {}) {
|
|
593
|
+
const trimmedInput = String(input || '').trim()
|
|
594
|
+
if (!trimmedInput) {
|
|
595
|
+
throw new Error('Missing share URL, link token, or encrypted file path.')
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
if (options.localFile || (!looksLikeRemoteInput(trimmedInput) && await pathExists(trimmedInput))) {
|
|
599
|
+
return decryptLocalFile(trimmedInput, options)
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
const parsed = parseDecryptInput(trimmedInput, {
|
|
603
|
+
encryptionToken: options.encryptionToken,
|
|
604
|
+
baseUrl: options.baseUrl,
|
|
605
|
+
})
|
|
606
|
+
|
|
607
|
+
const linkInfo = await fetchLinkInfo(parsed.baseUrl, parsed.linkToken, options.signal)
|
|
608
|
+
|
|
609
|
+
if (linkInfo.status === 'expired') {
|
|
610
|
+
throw new Error('This file link has expired.')
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
if (!linkInfo.clientEncrypted) {
|
|
614
|
+
throw new Error('This link is not an encrypted file.')
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
const meta = normalizeClientEncryptionMeta(linkInfo.clientEncryptionMeta)
|
|
618
|
+
if (!meta) {
|
|
619
|
+
throw new Error('Invalid encryption metadata for this file.')
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
const tokenBytes = importToken(parsed.encryptionToken)
|
|
623
|
+
const noncePrefix = fromBase64Url(meta.noncePrefix)
|
|
624
|
+
const outputName = sanitizeOutputName(options.output ? path.basename(options.output) : linkInfo.originalName)
|
|
625
|
+
const outputPath = path.resolve(options.output || path.join(options.outputDirectory || process.cwd(), outputName))
|
|
626
|
+
|
|
627
|
+
await fs.mkdir(path.dirname(outputPath), { recursive: true })
|
|
628
|
+
|
|
629
|
+
if (linkInfo.storageMode === 'parts') {
|
|
630
|
+
await decryptFromParts({
|
|
631
|
+
baseUrl: parsed.baseUrl,
|
|
632
|
+
linkToken: parsed.linkToken,
|
|
633
|
+
meta,
|
|
634
|
+
tokenBytes,
|
|
635
|
+
noncePrefix,
|
|
636
|
+
outputPath,
|
|
637
|
+
onProgress: options.onProgress,
|
|
638
|
+
signal: options.signal,
|
|
639
|
+
})
|
|
640
|
+
} else {
|
|
641
|
+
const encryptedFileUrl = linkInfo.downloadUrl
|
|
642
|
+
|| `${parsed.baseUrl}/f/${encodeURIComponent(parsed.linkToken)}`
|
|
643
|
+
|
|
644
|
+
await decryptFromSingleFile({
|
|
645
|
+
encryptedFileUrl,
|
|
646
|
+
meta,
|
|
647
|
+
tokenBytes,
|
|
648
|
+
noncePrefix,
|
|
649
|
+
outputPath,
|
|
650
|
+
onProgress: options.onProgress,
|
|
651
|
+
signal: options.signal,
|
|
652
|
+
})
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
return buildDecryptResult({
|
|
656
|
+
outputPath,
|
|
657
|
+
originalName: linkInfo.originalName,
|
|
658
|
+
size: linkInfo.size,
|
|
659
|
+
mimeType: linkInfo.mimeType,
|
|
660
|
+
expiresAt: linkInfo.expiresAt,
|
|
661
|
+
linkToken: parsed.linkToken,
|
|
662
|
+
storageMode: linkInfo.storageMode || 'single',
|
|
663
|
+
})
|
|
664
|
+
}
|