bytifi 0.1.2 → 0.1.4
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 +84 -7
- package/bin/bytifi.js +250 -31
- package/lib/crypto.js +43 -0
- package/lib/decrypt.js +765 -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,11 +49,61 @@ 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
|
-
bytifi upload ./
|
|
55
|
-
bytifi upload ./
|
|
56
|
-
bytifi upload ./large.iso
|
|
56
|
+
bytifi upload "./my video (1).mp4"
|
|
57
|
+
bytifi upload ./report.pdf --expires 60 --delete-on-download
|
|
58
|
+
bytifi upload ./large.iso --json > upload.json
|
|
59
|
+
bytifi upload ./photo.png -q
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Upload accepts **one file at a time**. Quote paths that contain spaces. Avoid shell globs like `**` — your shell may expand them into dozens of paths.
|
|
63
|
+
|
|
64
|
+
### Decrypt from a link
|
|
65
|
+
|
|
66
|
+
Download and decrypt directly from a share URL or link token. No API key required.
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
bytifi decrypt 'https://bytifi.com/link?link=LINK_ID#token=ENCRYPTION_TOKEN'
|
|
70
|
+
bytifi decrypt LINK_ID --token ENCRYPTION_TOKEN -o ./restored.mp4
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### Decrypt a downloaded encrypted file
|
|
74
|
+
|
|
75
|
+
If you already downloaded the encrypted blob from `/f/LINK_ID` (browser, curl, etc.):
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
# Easiest — use the upload JSON from when you uploaded
|
|
79
|
+
bytifi decrypt ./downloaded-file --upload-json upload.json
|
|
80
|
+
|
|
81
|
+
# Or pass both values manually
|
|
82
|
+
bytifi decrypt "./my video (1).mp4" \
|
|
83
|
+
--link LINK_ID \
|
|
84
|
+
--token ENCRYPTION_TOKEN
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
#### Link ID vs encryption token
|
|
88
|
+
|
|
89
|
+
Bytifi uses two different values — don't swap them:
|
|
90
|
+
|
|
91
|
+
| Name | Upload JSON field | Example location |
|
|
92
|
+
|------|-------------------|------------------|
|
|
93
|
+
| **Link ID** | `token` | `/f/QeVuslvdaP-okMxG`, `link?link=QeVuslvdaP-okMxG` |
|
|
94
|
+
| **Encryption token** | `encryptionToken` | `#token=2LTlmBrDkO4GJg0...` in `shareUrl` |
|
|
95
|
+
|
|
96
|
+
- `--link` = link ID (short, ~16 chars)
|
|
97
|
+
- `--token` = encryption key (long, ~43 chars)
|
|
98
|
+
|
|
99
|
+
### Offline decrypt workflow
|
|
100
|
+
|
|
101
|
+
Save metadata when you upload, so you can decrypt after the link expires:
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
bytifi upload ./report.pdf --json > upload.json
|
|
105
|
+
curl -L "$(jq -r .encryptedFile upload.json)" -o report.encrypted
|
|
106
|
+
bytifi decrypt ./report.encrypted --upload-json upload.json -o ./report.pdf
|
|
57
107
|
```
|
|
58
108
|
|
|
59
109
|
Without a global install:
|
|
@@ -65,7 +115,7 @@ npm exec bytifi -- upload ./photo.png --api-key usk_your_api_key_here
|
|
|
65
115
|
|
|
66
116
|
Note: with `npm exec`, put `--` before the file path so npm does not swallow `--api-key`.
|
|
67
117
|
|
|
68
|
-
###
|
|
118
|
+
### Upload options
|
|
69
119
|
|
|
70
120
|
| Flag | Description |
|
|
71
121
|
|------|-------------|
|
|
@@ -78,20 +128,47 @@ Note: with `npm exec`, put `--` before the file path so npm does not swallow `--
|
|
|
78
128
|
| `--mime-type` | Override detected MIME type |
|
|
79
129
|
| `--base-url` | API base URL (default: `https://bytifi.com`) |
|
|
80
130
|
|
|
131
|
+
### Decrypt options
|
|
132
|
+
|
|
133
|
+
| Flag | Description |
|
|
134
|
+
|------|-------------|
|
|
135
|
+
| `--token` | Encryption key from `#token=...` (`encryptionToken` in upload JSON) |
|
|
136
|
+
| `--link` | Link ID from upload JSON `token` field (`/f/TOKEN`) |
|
|
137
|
+
| `--upload-json` | Upload `--json` output file (recommended for downloaded files) |
|
|
138
|
+
| `--meta` | Saved `clientEncryptionMeta` JSON for offline decrypt |
|
|
139
|
+
| `--share-url` | Share URL to read token/metadata while decrypting a local file |
|
|
140
|
+
| `-o, --output` | Output file path |
|
|
141
|
+
| `--output-dir` | Output directory when saving under the original filename |
|
|
142
|
+
| `--json` | Machine-readable JSON output |
|
|
143
|
+
| `-q, --quiet` | Print only the output file path |
|
|
144
|
+
| `--verbose` | Print error details to stderr |
|
|
145
|
+
| `--base-url` | API base URL (default: `https://bytifi.com`) |
|
|
146
|
+
|
|
81
147
|
Exit codes: `0` success, `1` usage error, `2` API error, `3` network error.
|
|
82
148
|
|
|
83
|
-
JSON output (`--json`) includes `shareUrl`, `
|
|
149
|
+
JSON output (`--json`) for upload includes `shareUrl`, `encryptedFile`, `encryptionToken`, `clientEncryptionMeta`, `expiresAt`, and `token`.
|
|
150
|
+
|
|
151
|
+
JSON output for decrypt includes `outputPath`, `originalName`, `size`, `mimeType`, `expiresAt`, `linkToken`, `storageMode`, and `sourcePath` (for local decrypt).
|
|
84
152
|
|
|
85
153
|
Files over ~100 MB encrypted use multipart upload automatically. Progress prints to stderr unless `--json` or `--quiet` is set.
|
|
86
154
|
|
|
87
155
|
## How it works
|
|
88
156
|
|
|
157
|
+
**Upload**
|
|
158
|
+
|
|
89
159
|
1. Encrypts the file locally with AES-GCM (same format as the website)
|
|
90
160
|
2. Uploads encrypted bytes via the public API (parallel part uploads for large files)
|
|
91
161
|
3. Prints a share URL including `#token=...`
|
|
92
162
|
|
|
163
|
+
**Decrypt**
|
|
164
|
+
|
|
165
|
+
1. Reads link metadata from `/api/link/:token`, from `--upload-json`, or from `--meta`
|
|
166
|
+
2. Downloads the encrypted file (unless you pass a local encrypted file)
|
|
167
|
+
3. Decrypts locally and writes the original file to disk
|
|
168
|
+
|
|
93
169
|
## Development
|
|
94
170
|
|
|
95
171
|
```bash
|
|
96
|
-
node bin/bytifi.js upload ./file.png --json
|
|
172
|
+
node bin/bytifi.js upload ./file.png --json > upload.json
|
|
173
|
+
node bin/bytifi.js decrypt ./file.encrypted --upload-json upload.json
|
|
97
174
|
```
|
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,21 @@ 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 key from #token=... (not the link ID)
|
|
33
|
+
--link <token> Link ID from upload JSON "token" field (/f/TOKEN)
|
|
34
|
+
--upload-json <path> Upload --json output (easiest for downloaded files)
|
|
35
|
+
--meta <path> Saved clientEncryptionMeta JSON (offline decrypt)
|
|
36
|
+
--share-url <url> Share URL for token/metadata when decrypting a local file
|
|
37
|
+
-o, --output <path> Output file path (default: original filename)
|
|
38
|
+
--output-dir <dir> Directory for decrypted file (default: cwd)
|
|
39
|
+
--json Print machine-readable JSON to stdout
|
|
40
|
+
-q, --quiet Print only the output file path
|
|
41
|
+
--verbose Show error details on stderr
|
|
42
|
+
--base-url <url> API base URL (default: https://bytifi.com)
|
|
43
|
+
|
|
44
|
+
Global:
|
|
27
45
|
-V, --version Show version
|
|
28
46
|
-h, --help Show this help
|
|
29
47
|
|
|
@@ -35,7 +53,14 @@ Exit codes:
|
|
|
35
53
|
|
|
36
54
|
Examples:
|
|
37
55
|
bytifi upload ./photo.png
|
|
38
|
-
|
|
56
|
+
bytifi upload ./video.mp4 --expires 60 --json > upload.json
|
|
57
|
+
bytifi upload ./large.iso -q
|
|
58
|
+
|
|
59
|
+
bytifi decrypt 'https://bytifi.com/link?link=abc#token=...'
|
|
60
|
+
bytifi decrypt abc --token 'ENCRYPTION_TOKEN' -o ./restored.mp4
|
|
61
|
+
|
|
62
|
+
bytifi decrypt ./downloaded.encrypted --upload-json upload.json
|
|
63
|
+
bytifi decrypt "./my file(1).mp4" --link abc --token 'ENCRYPTION_TOKEN'
|
|
39
64
|
`)
|
|
40
65
|
}
|
|
41
66
|
|
|
@@ -47,7 +72,7 @@ function readFlagValue(argv, index, flagName) {
|
|
|
47
72
|
return value
|
|
48
73
|
}
|
|
49
74
|
|
|
50
|
-
function
|
|
75
|
+
function parseUploadArgs(argv) {
|
|
51
76
|
const positional = []
|
|
52
77
|
const options = {
|
|
53
78
|
apiKey: process.env.BYTIFI_API_KEY || '',
|
|
@@ -134,6 +159,110 @@ function parseArgs(argv) {
|
|
|
134
159
|
return { positional, options }
|
|
135
160
|
}
|
|
136
161
|
|
|
162
|
+
function parseDecryptArgs(argv) {
|
|
163
|
+
const positional = []
|
|
164
|
+
const options = {
|
|
165
|
+
encryptionToken: '',
|
|
166
|
+
linkToken: '',
|
|
167
|
+
metaPath: '',
|
|
168
|
+
uploadJsonPath: '',
|
|
169
|
+
shareUrl: '',
|
|
170
|
+
output: '',
|
|
171
|
+
outputDirectory: '',
|
|
172
|
+
json: false,
|
|
173
|
+
quiet: false,
|
|
174
|
+
verbose: false,
|
|
175
|
+
baseUrl: 'https://bytifi.com',
|
|
176
|
+
help: false,
|
|
177
|
+
version: false,
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
181
|
+
const arg = argv[index]
|
|
182
|
+
|
|
183
|
+
if (arg === '--help' || arg === '-h') {
|
|
184
|
+
options.help = true
|
|
185
|
+
continue
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (arg === '--version' || arg === '-V') {
|
|
189
|
+
options.version = true
|
|
190
|
+
continue
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (arg === '--json') {
|
|
194
|
+
options.json = true
|
|
195
|
+
continue
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (arg === '--quiet' || arg === '-q') {
|
|
199
|
+
options.quiet = true
|
|
200
|
+
continue
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (arg === '--verbose') {
|
|
204
|
+
options.verbose = true
|
|
205
|
+
continue
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (arg === '--token') {
|
|
209
|
+
options.encryptionToken = readFlagValue(argv, index, arg)
|
|
210
|
+
index += 1
|
|
211
|
+
continue
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (arg === '--link') {
|
|
215
|
+
options.linkToken = readFlagValue(argv, index, arg)
|
|
216
|
+
index += 1
|
|
217
|
+
continue
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (arg === '--meta') {
|
|
221
|
+
options.metaPath = readFlagValue(argv, index, arg)
|
|
222
|
+
index += 1
|
|
223
|
+
continue
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (arg === '--upload-json') {
|
|
227
|
+
options.uploadJsonPath = readFlagValue(argv, index, arg)
|
|
228
|
+
index += 1
|
|
229
|
+
continue
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (arg === '--share-url') {
|
|
233
|
+
options.shareUrl = readFlagValue(argv, index, arg)
|
|
234
|
+
index += 1
|
|
235
|
+
continue
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (arg === '--output' || arg === '-o') {
|
|
239
|
+
options.output = readFlagValue(argv, index, arg)
|
|
240
|
+
index += 1
|
|
241
|
+
continue
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (arg === '--output-dir') {
|
|
245
|
+
options.outputDirectory = readFlagValue(argv, index, arg)
|
|
246
|
+
index += 1
|
|
247
|
+
continue
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (arg === '--base-url') {
|
|
251
|
+
options.baseUrl = readFlagValue(argv, index, arg)
|
|
252
|
+
index += 1
|
|
253
|
+
continue
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (arg.startsWith('-')) {
|
|
257
|
+
throw new Error(`Unknown option: ${arg}`)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
positional.push(arg)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return { positional, options }
|
|
264
|
+
}
|
|
265
|
+
|
|
137
266
|
function validateExpires(minutes) {
|
|
138
267
|
const allowed = new Set([5, 15, 30, 60, 120])
|
|
139
268
|
if (!allowed.has(minutes)) {
|
|
@@ -141,8 +270,8 @@ function validateExpires(minutes) {
|
|
|
141
270
|
}
|
|
142
271
|
}
|
|
143
272
|
|
|
144
|
-
function writeProgress(percent) {
|
|
145
|
-
process.stderr.write(`\
|
|
273
|
+
function writeProgress(label, percent) {
|
|
274
|
+
process.stderr.write(`\r${label}: ${percent}%`)
|
|
146
275
|
}
|
|
147
276
|
|
|
148
277
|
function clearProgressLine() {
|
|
@@ -188,7 +317,7 @@ async function runUpload(filePath, options) {
|
|
|
188
317
|
? (percent) => {
|
|
189
318
|
if (percent !== lastPercent) {
|
|
190
319
|
lastPercent = percent
|
|
191
|
-
writeProgress(percent)
|
|
320
|
+
writeProgress('Encrypting and uploading', percent)
|
|
192
321
|
}
|
|
193
322
|
}
|
|
194
323
|
: undefined,
|
|
@@ -209,7 +338,64 @@ async function runUpload(filePath, options) {
|
|
|
209
338
|
}
|
|
210
339
|
|
|
211
340
|
process.stdout.write(`Share URL:\n${result.shareUrl}\n`)
|
|
212
|
-
process.stdout.write(`
|
|
341
|
+
process.stdout.write(`Encrypted file:\n${result.encryptedFile}\n`)
|
|
342
|
+
process.stdout.write(`Expires: ${result.expiresAt}\n`)
|
|
343
|
+
} finally {
|
|
344
|
+
process.off('SIGINT', handleSignal)
|
|
345
|
+
process.off('SIGTERM', handleSignal)
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
async function runDecrypt(input, options) {
|
|
350
|
+
const abortController = new AbortController()
|
|
351
|
+
|
|
352
|
+
const handleSignal = () => {
|
|
353
|
+
abortController.abort()
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
process.on('SIGINT', handleSignal)
|
|
357
|
+
process.on('SIGTERM', handleSignal)
|
|
358
|
+
|
|
359
|
+
const showProgress = !options.quiet && !options.json
|
|
360
|
+
let lastPercent = -1
|
|
361
|
+
|
|
362
|
+
try {
|
|
363
|
+
const result = await decryptFile(input, {
|
|
364
|
+
encryptionToken: options.encryptionToken,
|
|
365
|
+
linkToken: options.linkToken,
|
|
366
|
+
metaPath: options.metaPath,
|
|
367
|
+
uploadJsonPath: options.uploadJsonPath,
|
|
368
|
+
shareUrl: options.shareUrl,
|
|
369
|
+
output: options.output || undefined,
|
|
370
|
+
outputDirectory: options.outputDirectory || undefined,
|
|
371
|
+
baseUrl: options.baseUrl,
|
|
372
|
+
signal: abortController.signal,
|
|
373
|
+
onProgress: showProgress
|
|
374
|
+
? (percent) => {
|
|
375
|
+
if (percent !== lastPercent) {
|
|
376
|
+
lastPercent = percent
|
|
377
|
+
writeProgress('Downloading and decrypting', percent)
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
: undefined,
|
|
381
|
+
})
|
|
382
|
+
|
|
383
|
+
if (showProgress) {
|
|
384
|
+
clearProgressLine()
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
if (options.json) {
|
|
388
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`)
|
|
389
|
+
return
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
if (options.quiet) {
|
|
393
|
+
process.stdout.write(`${result.outputPath}\n`)
|
|
394
|
+
return
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
process.stdout.write(`Saved: ${result.outputPath}\n`)
|
|
398
|
+
process.stdout.write(`Original name: ${result.originalName}\n`)
|
|
213
399
|
process.stdout.write(`Expires: ${result.expiresAt}\n`)
|
|
214
400
|
} finally {
|
|
215
401
|
process.off('SIGINT', handleSignal)
|
|
@@ -224,7 +410,7 @@ function exitCodeForError(error) {
|
|
|
224
410
|
}
|
|
225
411
|
|
|
226
412
|
function printError(error, verbose) {
|
|
227
|
-
process.stderr.write(`${error.message || '
|
|
413
|
+
process.stderr.write(`${error.message || 'Command failed.'}\n`)
|
|
228
414
|
|
|
229
415
|
if (!verbose) return
|
|
230
416
|
|
|
@@ -255,41 +441,74 @@ async function main() {
|
|
|
255
441
|
process.exit(0)
|
|
256
442
|
}
|
|
257
443
|
|
|
258
|
-
if (command
|
|
259
|
-
|
|
444
|
+
if (command === 'help') {
|
|
445
|
+
printHelp()
|
|
446
|
+
process.exit(0)
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
if (command === 'upload') {
|
|
450
|
+
const { positional, options } = parseUploadArgs(rest)
|
|
451
|
+
|
|
452
|
+
if (options.help) {
|
|
260
453
|
printHelp()
|
|
261
454
|
process.exit(0)
|
|
262
455
|
}
|
|
263
456
|
|
|
264
|
-
|
|
265
|
-
|
|
457
|
+
if (options.version) {
|
|
458
|
+
process.stdout.write(`${version}\n`)
|
|
459
|
+
process.exit(0)
|
|
460
|
+
}
|
|
266
461
|
|
|
267
|
-
|
|
462
|
+
if (options.json && options.quiet) {
|
|
463
|
+
throw new Error('Use either --json or --quiet, not both.')
|
|
464
|
+
}
|
|
268
465
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
466
|
+
const filePath = positional[0]
|
|
467
|
+
if (!filePath) {
|
|
468
|
+
throw new Error('Missing file path. Usage: bytifi upload <file>')
|
|
469
|
+
}
|
|
273
470
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
471
|
+
if (positional.length > 1) {
|
|
472
|
+
throw new Error(
|
|
473
|
+
`Upload accepts one file at a time (got ${positional.length}). Quote paths with spaces and avoid shell globs like **.`,
|
|
474
|
+
)
|
|
475
|
+
}
|
|
278
476
|
|
|
279
|
-
|
|
280
|
-
|
|
477
|
+
await runUpload(filePath, options)
|
|
478
|
+
return
|
|
281
479
|
}
|
|
282
480
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
481
|
+
if (command === 'decrypt') {
|
|
482
|
+
const { positional, options } = parseDecryptArgs(rest)
|
|
483
|
+
|
|
484
|
+
if (options.help) {
|
|
485
|
+
printHelp()
|
|
486
|
+
process.exit(0)
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
if (options.version) {
|
|
490
|
+
process.stdout.write(`${version}\n`)
|
|
491
|
+
process.exit(0)
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
if (options.json && options.quiet) {
|
|
495
|
+
throw new Error('Use either --json or --quiet, not both.')
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const input = positional[0]
|
|
499
|
+
if (!input) {
|
|
500
|
+
throw new Error('Missing input. Usage: bytifi decrypt <url-or-token|encrypted-file>')
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
if (positional.length > 1) {
|
|
504
|
+
process.stderr.write(`Warning: ignoring extra arguments: ${positional.slice(1).join(', ')}\n`)
|
|
505
|
+
}
|
|
287
506
|
|
|
288
|
-
|
|
289
|
-
|
|
507
|
+
await runDecrypt(input, options)
|
|
508
|
+
return
|
|
290
509
|
}
|
|
291
510
|
|
|
292
|
-
|
|
511
|
+
throw new Error(`Unknown command: ${command}`)
|
|
293
512
|
}
|
|
294
513
|
|
|
295
514
|
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,765 @@
|
|
|
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(
|
|
96
|
+
'Missing encryption token. Pass --token with the `#token=...` value from the share URL '
|
|
97
|
+
+ '(stored as `encryptionToken` in upload JSON). This is not the same as the link ID.',
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
baseUrl: resolvedBaseUrl,
|
|
103
|
+
linkToken,
|
|
104
|
+
encryptionToken: resolvedEncryptionToken,
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function parseShareReference(input, { encryptionToken = '', baseUrl = DEFAULT_BASE_URL } = {}) {
|
|
109
|
+
const trimmed = String(input || '').trim()
|
|
110
|
+
if (!trimmed) {
|
|
111
|
+
return {
|
|
112
|
+
baseUrl: normalizeBaseUrl(baseUrl),
|
|
113
|
+
linkToken: '',
|
|
114
|
+
encryptionToken: String(encryptionToken || '').trim(),
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (!/^https?:\/\//i.test(trimmed) && !trimmed.startsWith('/')) {
|
|
119
|
+
return {
|
|
120
|
+
baseUrl: normalizeBaseUrl(baseUrl),
|
|
121
|
+
linkToken: trimmed,
|
|
122
|
+
encryptionToken: String(encryptionToken || '').trim(),
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const url = new URL(trimmed, `${normalizeBaseUrl(baseUrl)}/`)
|
|
127
|
+
const hashParams = new URLSearchParams(url.hash.startsWith('#') ? url.hash.slice(1) : url.hash)
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
baseUrl: `${url.protocol}//${url.host}`,
|
|
131
|
+
linkToken: url.searchParams.get('link') || url.pathname.match(/^\/f\/([^/]+)/)?.[1] || '',
|
|
132
|
+
encryptionToken: String(encryptionToken || hashParams.get('token') || '').trim(),
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function looksLikeRemoteInput(input) {
|
|
137
|
+
const trimmed = String(input || '').trim()
|
|
138
|
+
return /^https?:\/\//i.test(trimmed)
|
|
139
|
+
|| trimmed.startsWith('/link')
|
|
140
|
+
|| trimmed.startsWith('/f/')
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function pathExists(inputPath) {
|
|
144
|
+
try {
|
|
145
|
+
await fs.access(path.resolve(inputPath))
|
|
146
|
+
return true
|
|
147
|
+
} catch {
|
|
148
|
+
return false
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function readMetaFile(metaPath) {
|
|
153
|
+
const raw = await fs.readFile(path.resolve(metaPath), 'utf8')
|
|
154
|
+
const parsed = JSON.parse(raw)
|
|
155
|
+
const meta = normalizeClientEncryptionMeta(parsed?.clientEncryptionMeta || parsed)
|
|
156
|
+
|
|
157
|
+
if (!meta) {
|
|
158
|
+
throw new Error('Invalid encryption metadata file.')
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return meta
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function resolveEncryptionMeta({
|
|
165
|
+
metaPath = '',
|
|
166
|
+
inlineMeta = null,
|
|
167
|
+
linkToken = '',
|
|
168
|
+
baseUrl = DEFAULT_BASE_URL,
|
|
169
|
+
signal,
|
|
170
|
+
}) {
|
|
171
|
+
if (inlineMeta) {
|
|
172
|
+
return { meta: inlineMeta }
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (metaPath) {
|
|
176
|
+
return { meta: await readMetaFile(metaPath) }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (!linkToken) {
|
|
180
|
+
throw new Error(
|
|
181
|
+
'Missing link metadata for a downloaded file. Pass --upload-json upload.json, --link LINK_ID, or --meta meta.json.\n'
|
|
182
|
+
+ 'The link ID is the `token` field in upload JSON (also appears as /f/TOKEN and link?link=TOKEN).',
|
|
183
|
+
)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const linkInfo = await fetchLinkInfo(baseUrl, linkToken, signal)
|
|
187
|
+
|
|
188
|
+
if (linkInfo.status === 'expired') {
|
|
189
|
+
throw new Error('This file link has expired.')
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (!linkInfo.clientEncrypted) {
|
|
193
|
+
throw new Error('This link is not an encrypted file.')
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const meta = normalizeClientEncryptionMeta(linkInfo.clientEncryptionMeta)
|
|
197
|
+
if (!meta) {
|
|
198
|
+
throw new Error('Invalid encryption metadata for this file.')
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return { meta, linkInfo }
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function sanitizeOutputName(filename) {
|
|
205
|
+
const base = path.basename(String(filename || 'download').replace(/[\0\r\n]/g, ''))
|
|
206
|
+
return base || 'download'
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function fetchLinkInfo(baseUrl, linkToken, signal) {
|
|
210
|
+
return publicFetch(baseUrl, `/api/link/${encodeURIComponent(linkToken)}`, { signal })
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function fetchEncryptedPart(baseUrl, linkToken, partNumber, signal) {
|
|
214
|
+
const response = await fetch(
|
|
215
|
+
`${normalizeBaseUrl(baseUrl)}/f/${encodeURIComponent(linkToken)}/p/${partNumber}`,
|
|
216
|
+
{ signal },
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
if (!response.ok) {
|
|
220
|
+
const text = await response.text()
|
|
221
|
+
throw new BytifiApiError(text || `Failed to download part ${partNumber}.`, {
|
|
222
|
+
status: response.status,
|
|
223
|
+
body: text,
|
|
224
|
+
})
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return Buffer.from(await response.arrayBuffer())
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function decryptFromParts({
|
|
231
|
+
baseUrl,
|
|
232
|
+
linkToken,
|
|
233
|
+
meta,
|
|
234
|
+
tokenBytes,
|
|
235
|
+
noncePrefix,
|
|
236
|
+
outputPath,
|
|
237
|
+
onProgress,
|
|
238
|
+
signal,
|
|
239
|
+
}) {
|
|
240
|
+
const { chunks } = buildEncryptedChunkPlan(meta)
|
|
241
|
+
const fileHandle = await fs.open(outputPath, 'w')
|
|
242
|
+
let decryptedBytes = 0
|
|
243
|
+
|
|
244
|
+
try {
|
|
245
|
+
for (let index = 0; index < chunks.length; index += 1) {
|
|
246
|
+
if (signal?.aborted) {
|
|
247
|
+
throw new Error('Decrypt aborted.')
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const chunk = chunks[index]
|
|
251
|
+
const encryptedPart = await fetchEncryptedPart(baseUrl, linkToken, chunk.chunkIndex + 1, signal)
|
|
252
|
+
const plainPart = decryptChunk(encryptedPart, tokenBytes, noncePrefix, chunk.chunkIndex)
|
|
253
|
+
|
|
254
|
+
await fileHandle.write(plainPart)
|
|
255
|
+
decryptedBytes += plainPart.length
|
|
256
|
+
onProgress?.(Math.round((decryptedBytes / meta.originalSize) * 100))
|
|
257
|
+
}
|
|
258
|
+
} finally {
|
|
259
|
+
await fileHandle.close()
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async function decryptFromSingleFile({
|
|
264
|
+
encryptedFileUrl,
|
|
265
|
+
meta,
|
|
266
|
+
tokenBytes,
|
|
267
|
+
noncePrefix,
|
|
268
|
+
outputPath,
|
|
269
|
+
onProgress,
|
|
270
|
+
signal,
|
|
271
|
+
}) {
|
|
272
|
+
const { chunks, totalEncryptedSize } = buildEncryptedChunkPlan(meta)
|
|
273
|
+
const response = await fetch(encryptedFileUrl, { signal })
|
|
274
|
+
|
|
275
|
+
if (!response.ok) {
|
|
276
|
+
const text = await response.text()
|
|
277
|
+
throw new BytifiApiError(text || 'Failed to download encrypted file.', {
|
|
278
|
+
status: response.status,
|
|
279
|
+
body: text,
|
|
280
|
+
})
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (!response.body) {
|
|
284
|
+
const encryptedBuffer = Buffer.from(await response.arrayBuffer())
|
|
285
|
+
await writeDecryptedBuffer(encryptedBuffer, chunks, meta, tokenBytes, noncePrefix, outputPath, onProgress, signal)
|
|
286
|
+
return
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const reader = response.body.getReader()
|
|
290
|
+
const fileHandle = await fs.open(outputPath, 'w')
|
|
291
|
+
const pending = []
|
|
292
|
+
let pendingLength = 0
|
|
293
|
+
let downloadedBytes = 0
|
|
294
|
+
let nextChunkIndex = 0
|
|
295
|
+
let decryptedBytes = 0
|
|
296
|
+
|
|
297
|
+
const takeBytes = (length) => {
|
|
298
|
+
while (pendingLength < length) {
|
|
299
|
+
return null
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const out = Buffer.alloc(length)
|
|
303
|
+
let offset = 0
|
|
304
|
+
|
|
305
|
+
while (offset < length) {
|
|
306
|
+
const head = pending[0]
|
|
307
|
+
const take = Math.min(length - offset, head.length)
|
|
308
|
+
head.copy(out, offset, 0, take)
|
|
309
|
+
offset += take
|
|
310
|
+
|
|
311
|
+
if (take === head.length) {
|
|
312
|
+
pending.shift()
|
|
313
|
+
} else {
|
|
314
|
+
pending[0] = head.subarray(take)
|
|
315
|
+
}
|
|
316
|
+
pendingLength -= take
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return out
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
try {
|
|
323
|
+
while (nextChunkIndex < chunks.length) {
|
|
324
|
+
if (signal?.aborted) {
|
|
325
|
+
throw new Error('Decrypt aborted.')
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const chunk = chunks[nextChunkIndex]
|
|
329
|
+
|
|
330
|
+
while (pendingLength < chunk.encryptedSize) {
|
|
331
|
+
const { done, value } = await reader.read()
|
|
332
|
+
if (done) break
|
|
333
|
+
const buffer = Buffer.from(value)
|
|
334
|
+
pending.push(buffer)
|
|
335
|
+
pendingLength += buffer.length
|
|
336
|
+
downloadedBytes += buffer.length
|
|
337
|
+
onProgress?.(Math.min(99, Math.round((downloadedBytes / totalEncryptedSize) * 100)))
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const encryptedChunk = takeBytes(chunk.encryptedSize)
|
|
341
|
+
if (!encryptedChunk) {
|
|
342
|
+
throw new Error('Encrypted file ended before all parts were downloaded.')
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const plainPart = decryptChunk(encryptedChunk, tokenBytes, noncePrefix, chunk.chunkIndex)
|
|
346
|
+
await fileHandle.write(plainPart)
|
|
347
|
+
decryptedBytes += plainPart.length
|
|
348
|
+
nextChunkIndex += 1
|
|
349
|
+
onProgress?.(Math.min(99, Math.round((decryptedBytes / meta.originalSize) * 100)))
|
|
350
|
+
}
|
|
351
|
+
} finally {
|
|
352
|
+
await reader.cancel().catch(() => {})
|
|
353
|
+
await fileHandle.close()
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
onProgress?.(100)
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
async function writeDecryptedBuffer(encryptedBuffer, chunks, meta, tokenBytes, noncePrefix, outputPath, onProgress, signal) {
|
|
360
|
+
const fileHandle = await fs.open(outputPath, 'w')
|
|
361
|
+
let offset = 0
|
|
362
|
+
let decryptedBytes = 0
|
|
363
|
+
|
|
364
|
+
try {
|
|
365
|
+
for (const chunk of chunks) {
|
|
366
|
+
if (signal?.aborted) {
|
|
367
|
+
throw new Error('Decrypt aborted.')
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const encryptedChunk = encryptedBuffer.subarray(offset, offset + chunk.encryptedSize)
|
|
371
|
+
if (encryptedChunk.length !== chunk.encryptedSize) {
|
|
372
|
+
throw new Error('Encrypted file ended before all parts were downloaded.')
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const plainPart = decryptChunk(encryptedChunk, tokenBytes, noncePrefix, chunk.chunkIndex)
|
|
376
|
+
await fileHandle.write(plainPart)
|
|
377
|
+
offset += chunk.encryptedSize
|
|
378
|
+
decryptedBytes += plainPart.length
|
|
379
|
+
onProgress?.(Math.round((decryptedBytes / meta.originalSize) * 100))
|
|
380
|
+
}
|
|
381
|
+
} finally {
|
|
382
|
+
await fileHandle.close()
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
onProgress?.(100)
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
async function decryptFromLocalSingleFile({
|
|
389
|
+
encryptedFilePath,
|
|
390
|
+
meta,
|
|
391
|
+
tokenBytes,
|
|
392
|
+
noncePrefix,
|
|
393
|
+
outputPath,
|
|
394
|
+
onProgress,
|
|
395
|
+
signal,
|
|
396
|
+
}) {
|
|
397
|
+
const { chunks } = buildEncryptedChunkPlan(meta)
|
|
398
|
+
const stat = await fs.stat(encryptedFilePath)
|
|
399
|
+
|
|
400
|
+
if (stat.size <= 64 * 1024 * 1024) {
|
|
401
|
+
const encryptedBuffer = await fs.readFile(encryptedFilePath)
|
|
402
|
+
await writeDecryptedBuffer(encryptedBuffer, chunks, meta, tokenBytes, noncePrefix, outputPath, onProgress, signal)
|
|
403
|
+
return
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const fileHandle = await fs.open(encryptedFilePath, 'r')
|
|
407
|
+
const outputHandle = await fs.open(outputPath, 'w')
|
|
408
|
+
let offset = 0
|
|
409
|
+
let decryptedBytes = 0
|
|
410
|
+
|
|
411
|
+
try {
|
|
412
|
+
for (const chunk of chunks) {
|
|
413
|
+
if (signal?.aborted) {
|
|
414
|
+
throw new Error('Decrypt aborted.')
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const encryptedChunk = Buffer.alloc(chunk.encryptedSize)
|
|
418
|
+
const { bytesRead } = await fileHandle.read(encryptedChunk, 0, chunk.encryptedSize, offset)
|
|
419
|
+
|
|
420
|
+
if (bytesRead !== chunk.encryptedSize) {
|
|
421
|
+
throw new Error('Encrypted file ended before all parts were read.')
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const plainPart = decryptChunk(encryptedChunk, tokenBytes, noncePrefix, chunk.chunkIndex)
|
|
425
|
+
await outputHandle.write(plainPart)
|
|
426
|
+
offset += chunk.encryptedSize
|
|
427
|
+
decryptedBytes += plainPart.length
|
|
428
|
+
onProgress?.(Math.round((decryptedBytes / meta.originalSize) * 100))
|
|
429
|
+
}
|
|
430
|
+
} finally {
|
|
431
|
+
await fileHandle.close()
|
|
432
|
+
await outputHandle.close()
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
onProgress?.(100)
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
async function listLocalPartFiles(dirPath) {
|
|
439
|
+
const entries = await fs.readdir(dirPath)
|
|
440
|
+
const parts = entries
|
|
441
|
+
.map((name) => {
|
|
442
|
+
const match = name.match(/^(\d+)(?:\.part)?$/)
|
|
443
|
+
return match ? { partNumber: Number(match[1]), name } : null
|
|
444
|
+
})
|
|
445
|
+
.filter(Boolean)
|
|
446
|
+
.sort((left, right) => left.partNumber - right.partNumber)
|
|
447
|
+
|
|
448
|
+
if (parts.length === 0) {
|
|
449
|
+
throw new Error('No numbered part files found in directory. Expected names like 1, 2, or 1.part, 2.part.')
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
return parts
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
async function decryptFromLocalParts({
|
|
456
|
+
partsDirectory,
|
|
457
|
+
meta,
|
|
458
|
+
tokenBytes,
|
|
459
|
+
noncePrefix,
|
|
460
|
+
outputPath,
|
|
461
|
+
onProgress,
|
|
462
|
+
signal,
|
|
463
|
+
}) {
|
|
464
|
+
const { chunks } = buildEncryptedChunkPlan(meta)
|
|
465
|
+
const parts = await listLocalPartFiles(partsDirectory)
|
|
466
|
+
|
|
467
|
+
if (parts.length !== chunks.length) {
|
|
468
|
+
throw new Error(`Expected ${chunks.length} part files, found ${parts.length}.`)
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const fileHandle = await fs.open(outputPath, 'w')
|
|
472
|
+
let decryptedBytes = 0
|
|
473
|
+
|
|
474
|
+
try {
|
|
475
|
+
for (const chunk of chunks) {
|
|
476
|
+
if (signal?.aborted) {
|
|
477
|
+
throw new Error('Decrypt aborted.')
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const partNumber = chunk.chunkIndex + 1
|
|
481
|
+
const partEntry = parts[chunk.chunkIndex]
|
|
482
|
+
|
|
483
|
+
if (!partEntry || partEntry.partNumber !== partNumber) {
|
|
484
|
+
throw new Error(`Missing local part file ${partNumber}.`)
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const encryptedPart = await fs.readFile(path.join(partsDirectory, partEntry.name))
|
|
488
|
+
const plainPart = decryptChunk(encryptedPart, tokenBytes, noncePrefix, chunk.chunkIndex)
|
|
489
|
+
|
|
490
|
+
await fileHandle.write(plainPart)
|
|
491
|
+
decryptedBytes += plainPart.length
|
|
492
|
+
onProgress?.(Math.round((decryptedBytes / meta.originalSize) * 100))
|
|
493
|
+
}
|
|
494
|
+
} finally {
|
|
495
|
+
await fileHandle.close()
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
onProgress?.(100)
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function hintTokenConfusion(encryptionToken, linkToken) {
|
|
502
|
+
const token = String(encryptionToken || '').trim()
|
|
503
|
+
|
|
504
|
+
if (!token || linkToken) return
|
|
505
|
+
|
|
506
|
+
if (token.length <= 20) {
|
|
507
|
+
throw new Error(
|
|
508
|
+
`"${token}" looks like a link ID, not an encryption token.\n`
|
|
509
|
+
+ 'Use --link for the link ID (`token` in upload JSON) and --token for the encryption key (`encryptionToken`).\n'
|
|
510
|
+
+ 'Easiest: bytifi decrypt ./file.encrypted --upload-json upload.json',
|
|
511
|
+
)
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
export async function loadUploadJson(uploadJsonPath) {
|
|
516
|
+
const raw = await fs.readFile(path.resolve(uploadJsonPath), 'utf8')
|
|
517
|
+
let parsed
|
|
518
|
+
|
|
519
|
+
try {
|
|
520
|
+
parsed = JSON.parse(raw)
|
|
521
|
+
} catch {
|
|
522
|
+
throw new Error('Invalid upload JSON file.')
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const meta = normalizeClientEncryptionMeta(parsed.clientEncryptionMeta)
|
|
526
|
+
if (!meta) {
|
|
527
|
+
throw new Error('Upload JSON is missing clientEncryptionMeta.')
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const encryptionToken = String(parsed.encryptionToken || '').trim()
|
|
531
|
+
if (!encryptionToken) {
|
|
532
|
+
throw new Error('Upload JSON is missing encryptionToken.')
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
return {
|
|
536
|
+
linkToken: String(parsed.token || '').trim(),
|
|
537
|
+
encryptionToken,
|
|
538
|
+
meta,
|
|
539
|
+
originalName: parsed.originalName || 'download',
|
|
540
|
+
shareUrl: parsed.shareUrl || '',
|
|
541
|
+
expiresAt: parsed.expiresAt || null,
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
async function applyUploadJsonDefaults(options) {
|
|
546
|
+
if (!options.uploadJsonPath) {
|
|
547
|
+
return options
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
const upload = await loadUploadJson(options.uploadJsonPath)
|
|
551
|
+
|
|
552
|
+
return {
|
|
553
|
+
...options,
|
|
554
|
+
linkToken: options.linkToken || upload.linkToken,
|
|
555
|
+
encryptionToken: options.encryptionToken || upload.encryptionToken,
|
|
556
|
+
inlineMeta: options.inlineMeta || upload.meta,
|
|
557
|
+
originalName: options.originalName || upload.originalName,
|
|
558
|
+
shareUrl: options.shareUrl || upload.shareUrl,
|
|
559
|
+
uploadExpiresAt: upload.expiresAt,
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function resolveEncryptionToken(encryptionToken, shareReference, linkToken = '') {
|
|
564
|
+
const resolved = String(encryptionToken || shareReference?.encryptionToken || '').trim()
|
|
565
|
+
|
|
566
|
+
hintTokenConfusion(resolved, linkToken)
|
|
567
|
+
|
|
568
|
+
if (!resolved) {
|
|
569
|
+
throw new Error(
|
|
570
|
+
'Missing encryption token. Pass --token with the `#token=...` value from the share URL '
|
|
571
|
+
+ '(stored as `encryptionToken` in upload JSON), or use --upload-json upload.json.',
|
|
572
|
+
)
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
return resolved
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function buildDecryptResult({
|
|
579
|
+
outputPath,
|
|
580
|
+
originalName,
|
|
581
|
+
size,
|
|
582
|
+
mimeType,
|
|
583
|
+
expiresAt = null,
|
|
584
|
+
linkToken = '',
|
|
585
|
+
storageMode = 'single',
|
|
586
|
+
sourcePath = '',
|
|
587
|
+
}) {
|
|
588
|
+
return {
|
|
589
|
+
outputPath,
|
|
590
|
+
originalName,
|
|
591
|
+
size,
|
|
592
|
+
mimeType,
|
|
593
|
+
expiresAt,
|
|
594
|
+
linkToken,
|
|
595
|
+
storageMode,
|
|
596
|
+
sourcePath,
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
async function decryptLocalFile(inputPath, options = {}) {
|
|
601
|
+
const resolvedOptions = await applyUploadJsonDefaults(options)
|
|
602
|
+
const absolutePath = path.resolve(inputPath)
|
|
603
|
+
const stat = await fs.stat(absolutePath)
|
|
604
|
+
const shareReference = parseShareReference(resolvedOptions.shareUrl || '', {
|
|
605
|
+
encryptionToken: resolvedOptions.encryptionToken,
|
|
606
|
+
baseUrl: resolvedOptions.baseUrl,
|
|
607
|
+
})
|
|
608
|
+
const linkToken = resolvedOptions.linkToken || shareReference.linkToken
|
|
609
|
+
|
|
610
|
+
if (
|
|
611
|
+
!resolvedOptions.uploadJsonPath
|
|
612
|
+
&& !resolvedOptions.metaPath
|
|
613
|
+
&& !resolvedOptions.inlineMeta
|
|
614
|
+
&& !linkToken
|
|
615
|
+
) {
|
|
616
|
+
hintTokenConfusion(resolvedOptions.encryptionToken, '')
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
const resolved = await resolveEncryptionMeta({
|
|
620
|
+
metaPath: resolvedOptions.metaPath,
|
|
621
|
+
inlineMeta: resolvedOptions.inlineMeta,
|
|
622
|
+
linkToken,
|
|
623
|
+
baseUrl: resolvedOptions.baseUrl || shareReference.baseUrl,
|
|
624
|
+
signal: resolvedOptions.signal,
|
|
625
|
+
})
|
|
626
|
+
const meta = resolved.meta || resolved
|
|
627
|
+
const linkInfo = resolved.linkInfo || null
|
|
628
|
+
const encryptionToken = resolveEncryptionToken(
|
|
629
|
+
resolvedOptions.encryptionToken,
|
|
630
|
+
shareReference,
|
|
631
|
+
linkToken,
|
|
632
|
+
)
|
|
633
|
+
const tokenBytes = importToken(encryptionToken)
|
|
634
|
+
const noncePrefix = fromBase64Url(meta.noncePrefix)
|
|
635
|
+
const originalName = linkInfo?.originalName || resolvedOptions.originalName || 'download'
|
|
636
|
+
const outputName = sanitizeOutputName(resolvedOptions.output ? path.basename(resolvedOptions.output) : originalName)
|
|
637
|
+
const outputPath = path.resolve(
|
|
638
|
+
resolvedOptions.output || path.join(resolvedOptions.outputDirectory || process.cwd(), outputName),
|
|
639
|
+
)
|
|
640
|
+
|
|
641
|
+
await fs.mkdir(path.dirname(outputPath), { recursive: true })
|
|
642
|
+
|
|
643
|
+
if (stat.isDirectory()) {
|
|
644
|
+
await decryptFromLocalParts({
|
|
645
|
+
partsDirectory: absolutePath,
|
|
646
|
+
meta,
|
|
647
|
+
tokenBytes,
|
|
648
|
+
noncePrefix,
|
|
649
|
+
outputPath,
|
|
650
|
+
onProgress: resolvedOptions.onProgress,
|
|
651
|
+
signal: resolvedOptions.signal,
|
|
652
|
+
})
|
|
653
|
+
|
|
654
|
+
return buildDecryptResult({
|
|
655
|
+
outputPath,
|
|
656
|
+
originalName,
|
|
657
|
+
size: meta.originalSize,
|
|
658
|
+
mimeType: meta.mimeType,
|
|
659
|
+
expiresAt: linkInfo?.expiresAt || resolvedOptions.uploadExpiresAt || null,
|
|
660
|
+
linkToken,
|
|
661
|
+
storageMode: 'parts',
|
|
662
|
+
sourcePath: absolutePath,
|
|
663
|
+
})
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
await decryptFromLocalSingleFile({
|
|
667
|
+
encryptedFilePath: absolutePath,
|
|
668
|
+
meta,
|
|
669
|
+
tokenBytes,
|
|
670
|
+
noncePrefix,
|
|
671
|
+
outputPath,
|
|
672
|
+
onProgress: resolvedOptions.onProgress,
|
|
673
|
+
signal: resolvedOptions.signal,
|
|
674
|
+
})
|
|
675
|
+
|
|
676
|
+
return buildDecryptResult({
|
|
677
|
+
outputPath,
|
|
678
|
+
originalName,
|
|
679
|
+
size: meta.originalSize,
|
|
680
|
+
mimeType: meta.mimeType,
|
|
681
|
+
expiresAt: linkInfo?.expiresAt || resolvedOptions.uploadExpiresAt || null,
|
|
682
|
+
linkToken,
|
|
683
|
+
storageMode: 'single',
|
|
684
|
+
sourcePath: absolutePath,
|
|
685
|
+
})
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
export async function decryptFile(input, options = {}) {
|
|
689
|
+
const resolvedOptions = await applyUploadJsonDefaults(options)
|
|
690
|
+
const trimmedInput = String(input || '').trim()
|
|
691
|
+
if (!trimmedInput) {
|
|
692
|
+
throw new Error('Missing share URL, link token, or encrypted file path.')
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
if (resolvedOptions.localFile || (!looksLikeRemoteInput(trimmedInput) && await pathExists(trimmedInput))) {
|
|
696
|
+
return decryptLocalFile(trimmedInput, resolvedOptions)
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
const parsed = parseDecryptInput(trimmedInput, {
|
|
700
|
+
encryptionToken: resolvedOptions.encryptionToken,
|
|
701
|
+
baseUrl: resolvedOptions.baseUrl,
|
|
702
|
+
})
|
|
703
|
+
|
|
704
|
+
const linkInfo = await fetchLinkInfo(parsed.baseUrl, parsed.linkToken, resolvedOptions.signal)
|
|
705
|
+
|
|
706
|
+
if (linkInfo.status === 'expired') {
|
|
707
|
+
throw new Error('This file link has expired.')
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
if (!linkInfo.clientEncrypted) {
|
|
711
|
+
throw new Error('This link is not an encrypted file.')
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
const meta = normalizeClientEncryptionMeta(linkInfo.clientEncryptionMeta)
|
|
715
|
+
if (!meta) {
|
|
716
|
+
throw new Error('Invalid encryption metadata for this file.')
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
const tokenBytes = importToken(parsed.encryptionToken)
|
|
720
|
+
const noncePrefix = fromBase64Url(meta.noncePrefix)
|
|
721
|
+
const outputName = sanitizeOutputName(
|
|
722
|
+
resolvedOptions.output ? path.basename(resolvedOptions.output) : linkInfo.originalName,
|
|
723
|
+
)
|
|
724
|
+
const outputPath = path.resolve(
|
|
725
|
+
resolvedOptions.output || path.join(resolvedOptions.outputDirectory || process.cwd(), outputName),
|
|
726
|
+
)
|
|
727
|
+
|
|
728
|
+
await fs.mkdir(path.dirname(outputPath), { recursive: true })
|
|
729
|
+
|
|
730
|
+
if (linkInfo.storageMode === 'parts') {
|
|
731
|
+
await decryptFromParts({
|
|
732
|
+
baseUrl: parsed.baseUrl,
|
|
733
|
+
linkToken: parsed.linkToken,
|
|
734
|
+
meta,
|
|
735
|
+
tokenBytes,
|
|
736
|
+
noncePrefix,
|
|
737
|
+
outputPath,
|
|
738
|
+
onProgress: resolvedOptions.onProgress,
|
|
739
|
+
signal: resolvedOptions.signal,
|
|
740
|
+
})
|
|
741
|
+
} else {
|
|
742
|
+
const encryptedFileUrl = linkInfo.downloadUrl
|
|
743
|
+
|| `${parsed.baseUrl}/f/${encodeURIComponent(parsed.linkToken)}`
|
|
744
|
+
|
|
745
|
+
await decryptFromSingleFile({
|
|
746
|
+
encryptedFileUrl,
|
|
747
|
+
meta,
|
|
748
|
+
tokenBytes,
|
|
749
|
+
noncePrefix,
|
|
750
|
+
outputPath,
|
|
751
|
+
onProgress: resolvedOptions.onProgress,
|
|
752
|
+
signal: resolvedOptions.signal,
|
|
753
|
+
})
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
return buildDecryptResult({
|
|
757
|
+
outputPath,
|
|
758
|
+
originalName: linkInfo.originalName,
|
|
759
|
+
size: linkInfo.size,
|
|
760
|
+
mimeType: linkInfo.mimeType,
|
|
761
|
+
expiresAt: linkInfo.expiresAt,
|
|
762
|
+
linkToken: parsed.linkToken,
|
|
763
|
+
storageMode: linkInfo.storageMode || 'single',
|
|
764
|
+
})
|
|
765
|
+
}
|
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.4",
|
|
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
|
},
|