bytifi 0.1.2 → 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 +67 -3
- package/bin/bytifi.js +236 -30
- package/lib/crypto.js +43 -0
- package/lib/decrypt.js +664 -0
- package/lib/upload.js +2 -1
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Bytifi CLI
|
|
2
2
|
|
|
3
|
-
Official command-line tool for encrypting and
|
|
3
|
+
Official command-line tool for encrypting, uploading, and decrypting files with [Bytifi](https://bytifi.com).
|
|
4
4
|
|
|
5
5
|
## Install
|
|
6
6
|
|
|
@@ -49,6 +49,8 @@ Prefer the environment variable — keys on the command line can appear in shell
|
|
|
49
49
|
|
|
50
50
|
## Usage
|
|
51
51
|
|
|
52
|
+
### Upload
|
|
53
|
+
|
|
52
54
|
```bash
|
|
53
55
|
bytifi upload ./photo.png
|
|
54
56
|
bytifi upload ./photo.png --api-key usk_your_api_key_here
|
|
@@ -56,6 +58,42 @@ bytifi upload ./photo.png --expires 60 --json
|
|
|
56
58
|
bytifi upload ./large.iso -q
|
|
57
59
|
```
|
|
58
60
|
|
|
61
|
+
### Decrypt
|
|
62
|
+
|
|
63
|
+
Download and decrypt a shared file locally, or decrypt a file you already downloaded from `/f/...`. No API key required.
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
# From share URL (downloads + decrypts)
|
|
67
|
+
bytifi decrypt 'https://bytifi.com/link?link=TOKEN#token=ENCRYPTION_TOKEN'
|
|
68
|
+
bytifi decrypt TOKEN --token ENCRYPTION_TOKEN -o ./restored-file.pdf
|
|
69
|
+
|
|
70
|
+
# From a downloaded encrypted file
|
|
71
|
+
bytifi decrypt ./downloaded.encrypted --link TOKEN --token ENCRYPTION_TOKEN
|
|
72
|
+
bytifi decrypt ./downloaded.encrypted --meta ./upload-meta.json --token ENCRYPTION_TOKEN
|
|
73
|
+
bytifi decrypt ./parts/ --link TOKEN --token ENCRYPTION_TOKEN
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Use the full share URL (with `#token=...`) when possible. For `/f/...` downloads, pass the encryption token with `--token` and either `--link` (fetches metadata from the API) or `--meta` (saved `clientEncryptionMeta` JSON from upload `--json` output).
|
|
77
|
+
|
|
78
|
+
#### Offline decrypt workflow
|
|
79
|
+
|
|
80
|
+
Save metadata when you upload, so you can decrypt a downloaded `/f/...` file even after the link expires:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
# 1. Upload and save the JSON output
|
|
84
|
+
bytifi upload ./report.pdf --json > upload.json
|
|
85
|
+
|
|
86
|
+
# 2. Download the encrypted file manually (browser, curl, etc.)
|
|
87
|
+
curl -L "$(jq -r .encryptedFile upload.json)" -o report.encrypted
|
|
88
|
+
|
|
89
|
+
# 3. Save metadata and decrypt locally (no API call needed)
|
|
90
|
+
jq -c .clientEncryptionMeta upload.json > report.meta.json
|
|
91
|
+
bytifi decrypt ./report.encrypted \
|
|
92
|
+
--meta ./report.meta.json \
|
|
93
|
+
--token "$(jq -r .encryptionToken upload.json)" \
|
|
94
|
+
-o ./report.pdf
|
|
95
|
+
```
|
|
96
|
+
|
|
59
97
|
Without a global install:
|
|
60
98
|
|
|
61
99
|
```bash
|
|
@@ -65,7 +103,7 @@ npm exec bytifi -- upload ./photo.png --api-key usk_your_api_key_here
|
|
|
65
103
|
|
|
66
104
|
Note: with `npm exec`, put `--` before the file path so npm does not swallow `--api-key`.
|
|
67
105
|
|
|
68
|
-
###
|
|
106
|
+
### Upload options
|
|
69
107
|
|
|
70
108
|
| Flag | Description |
|
|
71
109
|
|------|-------------|
|
|
@@ -78,20 +116,46 @@ Note: with `npm exec`, put `--` before the file path so npm does not swallow `--
|
|
|
78
116
|
| `--mime-type` | Override detected MIME type |
|
|
79
117
|
| `--base-url` | API base URL (default: `https://bytifi.com`) |
|
|
80
118
|
|
|
119
|
+
### Decrypt options
|
|
120
|
+
|
|
121
|
+
| Flag | Description |
|
|
122
|
+
|------|-------------|
|
|
123
|
+
| `--token` | Encryption token (required if not in URL `#token=...`) |
|
|
124
|
+
| `--link` | Link token for metadata when decrypting a local encrypted file |
|
|
125
|
+
| `--meta` | Saved `clientEncryptionMeta` JSON for offline local decrypt |
|
|
126
|
+
| `--share-url` | Share URL to read token/metadata while decrypting a local file |
|
|
127
|
+
| `-o, --output` | Output file path |
|
|
128
|
+
| `--output-dir` | Output directory when saving under the original filename |
|
|
129
|
+
| `--json` | Machine-readable JSON output |
|
|
130
|
+
| `-q, --quiet` | Print only the output file path |
|
|
131
|
+
| `--verbose` | Print error details to stderr |
|
|
132
|
+
| `--base-url` | API base URL (default: `https://bytifi.com`) |
|
|
133
|
+
|
|
81
134
|
Exit codes: `0` success, `1` usage error, `2` API error, `3` network error.
|
|
82
135
|
|
|
83
|
-
JSON output (`--json`) includes `shareUrl`, `
|
|
136
|
+
JSON output (`--json`) for upload includes `shareUrl`, `encryptedFile`, `encryptionToken`, `clientEncryptionMeta`, `expiresAt`, and `token`.
|
|
137
|
+
|
|
138
|
+
JSON output for decrypt includes `outputPath`, `originalName`, `size`, `mimeType`, `expiresAt`, `linkToken`, `storageMode`, and `sourcePath` (for local decrypt).
|
|
84
139
|
|
|
85
140
|
Files over ~100 MB encrypted use multipart upload automatically. Progress prints to stderr unless `--json` or `--quiet` is set.
|
|
86
141
|
|
|
87
142
|
## How it works
|
|
88
143
|
|
|
144
|
+
**Upload**
|
|
145
|
+
|
|
89
146
|
1. Encrypts the file locally with AES-GCM (same format as the website)
|
|
90
147
|
2. Uploads encrypted bytes via the public API (parallel part uploads for large files)
|
|
91
148
|
3. Prints a share URL including `#token=...`
|
|
92
149
|
|
|
150
|
+
**Decrypt**
|
|
151
|
+
|
|
152
|
+
1. Reads link metadata from `/api/link/:token`, or from a saved `--meta` JSON file
|
|
153
|
+
2. Downloads the encrypted file (single blob or per-part for large uploads), unless you pass a local encrypted file
|
|
154
|
+
3. Decrypts locally and writes the original file to disk
|
|
155
|
+
|
|
93
156
|
## Development
|
|
94
157
|
|
|
95
158
|
```bash
|
|
96
159
|
node bin/bytifi.js upload ./file.png --json
|
|
160
|
+
node bin/bytifi.js decrypt ./file.encrypted --meta ./meta.json --token '...'
|
|
97
161
|
```
|
package/bin/bytifi.js
CHANGED
|
@@ -4,18 +4,21 @@ import fs from 'node:fs/promises'
|
|
|
4
4
|
import path from 'node:path'
|
|
5
5
|
import process from 'node:process'
|
|
6
6
|
import { createRequire } from 'node:module'
|
|
7
|
+
import { decryptFile } from '../lib/decrypt.js'
|
|
7
8
|
import { BytifiApiError, BytifiNetworkError, uploadFile } from '../lib/upload.js'
|
|
8
9
|
|
|
9
10
|
const require = createRequire(import.meta.url)
|
|
10
11
|
const { version } = require('../package.json')
|
|
11
12
|
|
|
12
13
|
function printHelp() {
|
|
13
|
-
process.stdout.write(`Bytifi CLI v${version} — encrypt and
|
|
14
|
+
process.stdout.write(`Bytifi CLI v${version} — encrypt, upload, and decrypt files
|
|
14
15
|
|
|
15
16
|
Usage:
|
|
16
17
|
bytifi upload <file> [options]
|
|
18
|
+
bytifi decrypt <url-or-token> [options]
|
|
19
|
+
bytifi decrypt <encrypted-file> [options]
|
|
17
20
|
|
|
18
|
-
|
|
21
|
+
Upload options:
|
|
19
22
|
-k, --api-key <key> API key (default: BYTIFI_API_KEY env var)
|
|
20
23
|
-e, --expires <minutes> Link lifetime: 5|15|30|60|120 (default: 30)
|
|
21
24
|
--delete-on-download Remove file after first download
|
|
@@ -24,6 +27,20 @@ Options:
|
|
|
24
27
|
--verbose Show API error details on stderr
|
|
25
28
|
--mime-type <type> Override detected MIME type
|
|
26
29
|
--base-url <url> API base URL (default: https://bytifi.com)
|
|
30
|
+
|
|
31
|
+
Decrypt options:
|
|
32
|
+
--token <token> Encryption token (if not in URL #token=...)
|
|
33
|
+
--link <token> Link token for metadata (local encrypted files)
|
|
34
|
+
--meta <path> Saved clientEncryptionMeta JSON (offline local decrypt)
|
|
35
|
+
--share-url <url> Share URL for token/metadata when decrypting a local file
|
|
36
|
+
-o, --output <path> Output file path (default: original filename)
|
|
37
|
+
--output-dir <dir> Directory for decrypted file (default: cwd)
|
|
38
|
+
--json Print machine-readable JSON to stdout
|
|
39
|
+
-q, --quiet Print only the output file path
|
|
40
|
+
--verbose Show error details on stderr
|
|
41
|
+
--base-url <url> API base URL (default: https://bytifi.com)
|
|
42
|
+
|
|
43
|
+
Global:
|
|
27
44
|
-V, --version Show version
|
|
28
45
|
-h, --help Show this help
|
|
29
46
|
|
|
@@ -36,6 +53,11 @@ Exit codes:
|
|
|
36
53
|
Examples:
|
|
37
54
|
bytifi upload ./photo.png
|
|
38
55
|
BYTIFI_API_KEY=usk_... bytifi upload report.pdf --expires 60 --json
|
|
56
|
+
bytifi decrypt 'https://bytifi.com/link?link=abc123#token=...'
|
|
57
|
+
bytifi decrypt abc123 --token '...' -o ./report.pdf
|
|
58
|
+
bytifi decrypt ./downloaded.encrypted --link abc123 --token '...'
|
|
59
|
+
bytifi decrypt ./downloaded.encrypted --meta ./upload-meta.json --token '...'
|
|
60
|
+
bytifi decrypt ./parts/ --link abc123 --token '...'
|
|
39
61
|
`)
|
|
40
62
|
}
|
|
41
63
|
|
|
@@ -47,7 +69,7 @@ function readFlagValue(argv, index, flagName) {
|
|
|
47
69
|
return value
|
|
48
70
|
}
|
|
49
71
|
|
|
50
|
-
function
|
|
72
|
+
function parseUploadArgs(argv) {
|
|
51
73
|
const positional = []
|
|
52
74
|
const options = {
|
|
53
75
|
apiKey: process.env.BYTIFI_API_KEY || '',
|
|
@@ -134,6 +156,103 @@ function parseArgs(argv) {
|
|
|
134
156
|
return { positional, options }
|
|
135
157
|
}
|
|
136
158
|
|
|
159
|
+
function parseDecryptArgs(argv) {
|
|
160
|
+
const positional = []
|
|
161
|
+
const options = {
|
|
162
|
+
encryptionToken: '',
|
|
163
|
+
linkToken: '',
|
|
164
|
+
metaPath: '',
|
|
165
|
+
shareUrl: '',
|
|
166
|
+
output: '',
|
|
167
|
+
outputDirectory: '',
|
|
168
|
+
json: false,
|
|
169
|
+
quiet: false,
|
|
170
|
+
verbose: false,
|
|
171
|
+
baseUrl: 'https://bytifi.com',
|
|
172
|
+
help: false,
|
|
173
|
+
version: false,
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
177
|
+
const arg = argv[index]
|
|
178
|
+
|
|
179
|
+
if (arg === '--help' || arg === '-h') {
|
|
180
|
+
options.help = true
|
|
181
|
+
continue
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (arg === '--version' || arg === '-V') {
|
|
185
|
+
options.version = true
|
|
186
|
+
continue
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (arg === '--json') {
|
|
190
|
+
options.json = true
|
|
191
|
+
continue
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (arg === '--quiet' || arg === '-q') {
|
|
195
|
+
options.quiet = true
|
|
196
|
+
continue
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (arg === '--verbose') {
|
|
200
|
+
options.verbose = true
|
|
201
|
+
continue
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (arg === '--token') {
|
|
205
|
+
options.encryptionToken = readFlagValue(argv, index, arg)
|
|
206
|
+
index += 1
|
|
207
|
+
continue
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (arg === '--link') {
|
|
211
|
+
options.linkToken = readFlagValue(argv, index, arg)
|
|
212
|
+
index += 1
|
|
213
|
+
continue
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (arg === '--meta') {
|
|
217
|
+
options.metaPath = readFlagValue(argv, index, arg)
|
|
218
|
+
index += 1
|
|
219
|
+
continue
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (arg === '--share-url') {
|
|
223
|
+
options.shareUrl = readFlagValue(argv, index, arg)
|
|
224
|
+
index += 1
|
|
225
|
+
continue
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (arg === '--output' || arg === '-o') {
|
|
229
|
+
options.output = readFlagValue(argv, index, arg)
|
|
230
|
+
index += 1
|
|
231
|
+
continue
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (arg === '--output-dir') {
|
|
235
|
+
options.outputDirectory = readFlagValue(argv, index, arg)
|
|
236
|
+
index += 1
|
|
237
|
+
continue
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (arg === '--base-url') {
|
|
241
|
+
options.baseUrl = readFlagValue(argv, index, arg)
|
|
242
|
+
index += 1
|
|
243
|
+
continue
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (arg.startsWith('-')) {
|
|
247
|
+
throw new Error(`Unknown option: ${arg}`)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
positional.push(arg)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return { positional, options }
|
|
254
|
+
}
|
|
255
|
+
|
|
137
256
|
function validateExpires(minutes) {
|
|
138
257
|
const allowed = new Set([5, 15, 30, 60, 120])
|
|
139
258
|
if (!allowed.has(minutes)) {
|
|
@@ -141,8 +260,8 @@ function validateExpires(minutes) {
|
|
|
141
260
|
}
|
|
142
261
|
}
|
|
143
262
|
|
|
144
|
-
function writeProgress(percent) {
|
|
145
|
-
process.stderr.write(`\
|
|
263
|
+
function writeProgress(label, percent) {
|
|
264
|
+
process.stderr.write(`\r${label}: ${percent}%`)
|
|
146
265
|
}
|
|
147
266
|
|
|
148
267
|
function clearProgressLine() {
|
|
@@ -188,7 +307,7 @@ async function runUpload(filePath, options) {
|
|
|
188
307
|
? (percent) => {
|
|
189
308
|
if (percent !== lastPercent) {
|
|
190
309
|
lastPercent = percent
|
|
191
|
-
writeProgress(percent)
|
|
310
|
+
writeProgress('Encrypting and uploading', percent)
|
|
192
311
|
}
|
|
193
312
|
}
|
|
194
313
|
: undefined,
|
|
@@ -209,7 +328,63 @@ async function runUpload(filePath, options) {
|
|
|
209
328
|
}
|
|
210
329
|
|
|
211
330
|
process.stdout.write(`Share URL:\n${result.shareUrl}\n`)
|
|
212
|
-
process.stdout.write(`
|
|
331
|
+
process.stdout.write(`Encrypted file:\n${result.encryptedFile}\n`)
|
|
332
|
+
process.stdout.write(`Expires: ${result.expiresAt}\n`)
|
|
333
|
+
} finally {
|
|
334
|
+
process.off('SIGINT', handleSignal)
|
|
335
|
+
process.off('SIGTERM', handleSignal)
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async function runDecrypt(input, options) {
|
|
340
|
+
const abortController = new AbortController()
|
|
341
|
+
|
|
342
|
+
const handleSignal = () => {
|
|
343
|
+
abortController.abort()
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
process.on('SIGINT', handleSignal)
|
|
347
|
+
process.on('SIGTERM', handleSignal)
|
|
348
|
+
|
|
349
|
+
const showProgress = !options.quiet && !options.json
|
|
350
|
+
let lastPercent = -1
|
|
351
|
+
|
|
352
|
+
try {
|
|
353
|
+
const result = await decryptFile(input, {
|
|
354
|
+
encryptionToken: options.encryptionToken,
|
|
355
|
+
linkToken: options.linkToken,
|
|
356
|
+
metaPath: options.metaPath,
|
|
357
|
+
shareUrl: options.shareUrl,
|
|
358
|
+
output: options.output || undefined,
|
|
359
|
+
outputDirectory: options.outputDirectory || undefined,
|
|
360
|
+
baseUrl: options.baseUrl,
|
|
361
|
+
signal: abortController.signal,
|
|
362
|
+
onProgress: showProgress
|
|
363
|
+
? (percent) => {
|
|
364
|
+
if (percent !== lastPercent) {
|
|
365
|
+
lastPercent = percent
|
|
366
|
+
writeProgress('Downloading and decrypting', percent)
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
: undefined,
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
if (showProgress) {
|
|
373
|
+
clearProgressLine()
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (options.json) {
|
|
377
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`)
|
|
378
|
+
return
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (options.quiet) {
|
|
382
|
+
process.stdout.write(`${result.outputPath}\n`)
|
|
383
|
+
return
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
process.stdout.write(`Saved: ${result.outputPath}\n`)
|
|
387
|
+
process.stdout.write(`Original name: ${result.originalName}\n`)
|
|
213
388
|
process.stdout.write(`Expires: ${result.expiresAt}\n`)
|
|
214
389
|
} finally {
|
|
215
390
|
process.off('SIGINT', handleSignal)
|
|
@@ -224,7 +399,7 @@ function exitCodeForError(error) {
|
|
|
224
399
|
}
|
|
225
400
|
|
|
226
401
|
function printError(error, verbose) {
|
|
227
|
-
process.stderr.write(`${error.message || '
|
|
402
|
+
process.stderr.write(`${error.message || 'Command failed.'}\n`)
|
|
228
403
|
|
|
229
404
|
if (!verbose) return
|
|
230
405
|
|
|
@@ -255,41 +430,72 @@ async function main() {
|
|
|
255
430
|
process.exit(0)
|
|
256
431
|
}
|
|
257
432
|
|
|
258
|
-
if (command
|
|
259
|
-
|
|
433
|
+
if (command === 'help') {
|
|
434
|
+
printHelp()
|
|
435
|
+
process.exit(0)
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
if (command === 'upload') {
|
|
439
|
+
const { positional, options } = parseUploadArgs(rest)
|
|
440
|
+
|
|
441
|
+
if (options.help) {
|
|
260
442
|
printHelp()
|
|
261
443
|
process.exit(0)
|
|
262
444
|
}
|
|
263
445
|
|
|
264
|
-
|
|
265
|
-
|
|
446
|
+
if (options.version) {
|
|
447
|
+
process.stdout.write(`${version}\n`)
|
|
448
|
+
process.exit(0)
|
|
449
|
+
}
|
|
266
450
|
|
|
267
|
-
|
|
451
|
+
if (options.json && options.quiet) {
|
|
452
|
+
throw new Error('Use either --json or --quiet, not both.')
|
|
453
|
+
}
|
|
268
454
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
455
|
+
const filePath = positional[0]
|
|
456
|
+
if (!filePath) {
|
|
457
|
+
throw new Error('Missing file path. Usage: bytifi upload <file>')
|
|
458
|
+
}
|
|
273
459
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
}
|
|
460
|
+
if (positional.length > 1) {
|
|
461
|
+
process.stderr.write(`Warning: ignoring extra files: ${positional.slice(1).join(', ')}\n`)
|
|
462
|
+
}
|
|
278
463
|
|
|
279
|
-
|
|
280
|
-
|
|
464
|
+
await runUpload(filePath, options)
|
|
465
|
+
return
|
|
281
466
|
}
|
|
282
467
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
468
|
+
if (command === 'decrypt') {
|
|
469
|
+
const { positional, options } = parseDecryptArgs(rest)
|
|
470
|
+
|
|
471
|
+
if (options.help) {
|
|
472
|
+
printHelp()
|
|
473
|
+
process.exit(0)
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
if (options.version) {
|
|
477
|
+
process.stdout.write(`${version}\n`)
|
|
478
|
+
process.exit(0)
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
if (options.json && options.quiet) {
|
|
482
|
+
throw new Error('Use either --json or --quiet, not both.')
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const input = positional[0]
|
|
486
|
+
if (!input) {
|
|
487
|
+
throw new Error('Missing input. Usage: bytifi decrypt <url-or-token|encrypted-file>')
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
if (positional.length > 1) {
|
|
491
|
+
process.stderr.write(`Warning: ignoring extra arguments: ${positional.slice(1).join(', ')}\n`)
|
|
492
|
+
}
|
|
287
493
|
|
|
288
|
-
|
|
289
|
-
|
|
494
|
+
await runDecrypt(input, options)
|
|
495
|
+
return
|
|
290
496
|
}
|
|
291
497
|
|
|
292
|
-
|
|
498
|
+
throw new Error(`Unknown command: ${command}`)
|
|
293
499
|
}
|
|
294
500
|
|
|
295
501
|
main().catch((error) => {
|
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
|
+
}
|
package/lib/upload.js
CHANGED
|
@@ -123,9 +123,10 @@ function buildResult(payload, context, shareUrl) {
|
|
|
123
123
|
return {
|
|
124
124
|
shareUrl,
|
|
125
125
|
url: payload.url,
|
|
126
|
-
|
|
126
|
+
encryptedFile: payload.downloadUrl,
|
|
127
127
|
token: payload.token,
|
|
128
128
|
encryptionToken: context.token,
|
|
129
|
+
clientEncryptionMeta: context.meta,
|
|
129
130
|
originalName: payload.originalName || context.originalName,
|
|
130
131
|
size: payload.size,
|
|
131
132
|
expiresAt: payload.expiresAt,
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bytifi",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "Official Bytifi CLI — encrypt and
|
|
3
|
+
"version": "0.1.3",
|
|
4
|
+
"description": "Official Bytifi CLI — encrypt, upload, and decrypt files from the terminal",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"bytifi": "./bin/bytifi.js"
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
},
|
|
16
16
|
"scripts": {
|
|
17
17
|
"upload": "node bin/bytifi.js upload",
|
|
18
|
+
"decrypt": "node bin/bytifi.js decrypt",
|
|
18
19
|
"build:bundle": "esbuild bin/bytifi.js --bundle --platform=node --format=cjs --outfile=dist/bytifi-bundle.cjs",
|
|
19
20
|
"build:win": "npm run build:bundle && pkg dist/bytifi-bundle.cjs --targets node22-win-x64 --output dist/bytifi.exe --compress GZip"
|
|
20
21
|
},
|