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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # Bytifi CLI
2
2
 
3
- Official command-line tool for encrypting and uploading files to [Bytifi](https://bytifi.com).
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
- ### Options
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`, `downloadUrl`, `encryptionToken`, `expiresAt`, and `token`.
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 upload files
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
- Options:
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 parseArgs(argv) {
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(`\rEncrypting and uploading: ${percent}%`)
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(`Download URL:\n${result.downloadUrl}\n`)
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 || 'Upload failed.'}\n`)
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 !== 'upload') {
259
- if (command === 'help') {
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
- throw new Error(`Unknown command: ${command}`)
265
- }
446
+ if (options.version) {
447
+ process.stdout.write(`${version}\n`)
448
+ process.exit(0)
449
+ }
266
450
 
267
- const { positional, options } = parseArgs(rest)
451
+ if (options.json && options.quiet) {
452
+ throw new Error('Use either --json or --quiet, not both.')
453
+ }
268
454
 
269
- if (options.help) {
270
- printHelp()
271
- process.exit(0)
272
- }
455
+ const filePath = positional[0]
456
+ if (!filePath) {
457
+ throw new Error('Missing file path. Usage: bytifi upload <file>')
458
+ }
273
459
 
274
- if (options.version) {
275
- process.stdout.write(`${version}\n`)
276
- process.exit(0)
277
- }
460
+ if (positional.length > 1) {
461
+ process.stderr.write(`Warning: ignoring extra files: ${positional.slice(1).join(', ')}\n`)
462
+ }
278
463
 
279
- if (options.json && options.quiet) {
280
- throw new Error('Use either --json or --quiet, not both.')
464
+ await runUpload(filePath, options)
465
+ return
281
466
  }
282
467
 
283
- const filePath = positional[0]
284
- if (!filePath) {
285
- throw new Error('Missing file path. Usage: bytifi upload <file>')
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
- if (positional.length > 1) {
289
- process.stderr.write(`Warning: ignoring extra files: ${positional.slice(1).join(', ')}\n`)
494
+ await runDecrypt(input, options)
495
+ return
290
496
  }
291
497
 
292
- await runUpload(filePath, options)
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
- downloadUrl: payload.downloadUrl,
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.2",
4
- "description": "Official Bytifi CLI — encrypt and upload files from the terminal",
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
  },