stegdoc 1.0.1 → 4.0.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stegdoc",
3
- "version": "1.0.1",
3
+ "version": "4.0.0",
4
4
  "description": "Hide files inside Office documents (XLSX/DOCX) with AES-256 encryption and steganography",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -41,20 +41,19 @@
41
41
  },
42
42
  "files": [
43
43
  "src",
44
- "bootstrap.js",
45
44
  "LICENSE",
46
45
  "README.md"
47
46
  ],
48
47
  "packageManager": "pnpm@10.19.0",
49
48
  "dependencies": {
50
49
  "adm-zip": "^0.5.16",
51
- "chalk": "4.1.2",
50
+ "chalk": "^4.1.2",
52
51
  "commander": "^14.0.2",
53
52
  "docx": "^9.5.1",
54
53
  "exceljs": "^4.4.0",
55
- "fast-xml-parser": "^5.3.2",
56
- "file-type": "^21.1.1",
57
- "inquirer": "^13.0.1",
58
- "ora": "5.4.1"
54
+ "fast-xml-parser": "^5.3.3",
55
+ "file-type": "^16.5.4",
56
+ "inquirer": "^8.2.6",
57
+ "ora": "^5.4.1"
59
58
  }
60
59
  }
@@ -1,15 +1,17 @@
1
1
  const path = require('path');
2
2
  const fs = require('fs');
3
+ const { finished } = require('stream/promises');
3
4
  const chalk = require('chalk');
4
5
  const ora = require('ora');
5
- const { readDocxBase64 } = require('../lib/docx-handler');
6
+ const { readDocxBase64, } = require('../lib/docx-handler');
6
7
  const { readXlsxBase64 } = require('../lib/xlsx-handler');
7
- const { validateMetadata, isMultiPart } = require('../lib/metadata');
8
+ const { validateMetadata, isMultiPart, isStreamingFormat } = require('../lib/metadata');
8
9
  const { detectFormat, formatBytes, generateContentHash } = require('../lib/utils');
9
- const { decrypt, unpackEncryptionMeta } = require('../lib/crypto');
10
- const { decompress } = require('../lib/compression');
10
+ const { decrypt, unpackEncryptionMeta, createDecryptStream } = require('../lib/crypto');
11
+ const { decompress, createDecompressStream } = require('../lib/compression');
11
12
  const { promptPassword, promptOverwrite } = require('../lib/interactive');
12
13
  const { extractContent, findMultiPartFiles, mergeBase64Chunks } = require('../lib/file-utils');
14
+ const { HashPassthrough } = require('../lib/streams');
13
15
 
14
16
  /**
15
17
  * Read file based on format
@@ -29,10 +31,6 @@ async function readFile(filePath, format) {
29
31
  * Decode a DOCX/XLSX file back to original format
30
32
  * @param {string} inputFile - Path to input file
31
33
  * @param {object} options - Command options
32
- * @param {string} options.output - Output file path
33
- * @param {string} options.password - Decryption password
34
- * @param {boolean} options.force - Overwrite existing files without asking
35
- * @param {boolean} options.quiet - Minimal output
36
34
  */
37
35
  async function decodeCommand(inputFile, options) {
38
36
  const quiet = options.quiet || false;
@@ -71,18 +69,28 @@ async function decodeCommand(inputFile, options) {
71
69
  if (quiet || options.yes) {
72
70
  throw new Error('Password is required for encrypted files. Use -p or --password to specify.');
73
71
  }
74
- // Prompt for password interactively
75
72
  options.password = await promptPassword();
76
73
  }
77
74
 
78
75
  // Determine output path and check overwrite
79
- const outputPath = options.output || path.join(process.cwd(), metadata.originalFilename);
76
+ let outputPath;
77
+ if (options.output) {
78
+ if (fs.existsSync(options.output) && fs.statSync(options.output).isDirectory()) {
79
+ outputPath = path.join(options.output, metadata.originalFilename);
80
+ } else if (!path.extname(options.output) && !fs.existsSync(options.output)) {
81
+ fs.mkdirSync(options.output, { recursive: true });
82
+ outputPath = path.join(options.output, metadata.originalFilename);
83
+ } else {
84
+ outputPath = options.output;
85
+ }
86
+ } else {
87
+ outputPath = path.join(process.cwd(), metadata.originalFilename);
88
+ }
80
89
 
81
90
  if (fs.existsSync(outputPath) && !options.force) {
82
91
  if (quiet || options.yes) {
83
92
  throw new Error(`File already exists: ${outputPath}. Use --force to overwrite.`);
84
93
  }
85
- // Prompt for overwrite confirmation
86
94
  const shouldOverwrite = await promptOverwrite(outputPath);
87
95
  if (!shouldOverwrite) {
88
96
  console.log(chalk.yellow('Operation cancelled.'));
@@ -90,111 +98,245 @@ async function decodeCommand(inputFile, options) {
90
98
  }
91
99
  }
92
100
 
93
- // Check if multi-part
94
- let finalBase64;
101
+ // Route to v4 streaming decode or legacy decode
102
+ if (isStreamingFormat(metadata)) {
103
+ await decodeStreaming(inputFile, format, metadata, encryptedContent, encryptionMeta, isEncrypted, isCompressed, options, outputPath, spinner, quiet);
104
+ } else {
105
+ await decodeLegacy(inputFile, format, metadata, encryptedContent, encryptionMeta, isEncrypted, isCompressed, options, outputPath, spinner, quiet);
106
+ }
107
+ } catch (error) {
108
+ spinner.fail && spinner.fail('Decoding failed');
109
+ console.error(chalk.red(`Error: ${error.message}`));
110
+ process.exit(1);
111
+ }
112
+ }
95
113
 
96
- if (isMultiPart(metadata)) {
97
- spinner.start && (spinner.text = `Multi-part file detected (${metadata.totalParts} parts)`);
114
+ /**
115
+ * v4 streaming decode — per-part decryption, streaming decompress and write
116
+ */
117
+ async function decodeStreaming(inputFile, format, metadata, encryptedContent, encryptionMeta, isEncrypted, isCompressed, options, outputPath, spinner, quiet) {
118
+ // Ensure output directory exists
119
+ const outputDir = path.dirname(outputPath);
120
+ if (!fs.existsSync(outputDir)) {
121
+ fs.mkdirSync(outputDir, { recursive: true });
122
+ }
98
123
 
99
- // Find all parts
100
- const inputDir = path.dirname(inputFile);
101
- const allParts = findMultiPartFiles(inputDir, metadata.hash, format, metadata.totalParts);
124
+ // Set up output pipeline: [decompress] → hash → file
125
+ const hashStream = new HashPassthrough();
126
+ const outputStream = fs.createWriteStream(outputPath);
102
127
 
103
- if (allParts.length !== metadata.totalParts) {
104
- throw new Error(
105
- `Missing parts! Found ${allParts.length} of ${metadata.totalParts} parts. ` +
106
- `Make sure all parts are in the same directory.`
107
- );
108
- }
128
+ let decompressStream = null;
129
+ if (isCompressed) {
130
+ decompressStream = createDecompressStream();
131
+ decompressStream.pipe(hashStream).pipe(outputStream);
132
+ } else {
133
+ hashStream.pipe(outputStream);
134
+ }
109
135
 
110
- spinner.succeed && spinner.succeed(`Found all ${metadata.totalParts} parts`);
136
+ const writeTarget = isCompressed ? decompressStream : hashStream;
111
137
 
112
- // Read all parts
113
- const chunks = [];
138
+ // v4 files may have partNumber set but totalParts null (unknown at encode time).
139
+ // Detect multi-part by checking partNumber OR totalParts.
140
+ const hasMultipleParts = isMultiPart(metadata) || metadata.partNumber !== null;
141
+ let totalPartsFound = 1;
114
142
 
115
- for (let i = 0; i < allParts.length; i++) {
116
- const partSpinner = quiet ? spinner : ora(`Reading part ${i + 1} of ${metadata.totalParts}...`).start();
117
- const partResult = await readFile(allParts[i].path, format);
118
- const { encryptedContent: partContent } = extractContent(partResult, format);
119
- chunks.push(partContent);
120
- partSpinner.succeed && partSpinner.succeed(`Part ${i + 1} read`);
121
- }
143
+ if (hasMultipleParts) {
144
+ // Find all parts by filename matching
145
+ const inputDir = path.dirname(inputFile);
146
+ const allParts = findMultiPartFiles(inputDir, metadata.hash, format);
147
+ totalPartsFound = allParts.length;
122
148
 
123
- // Merge chunks
124
- spinner.text = 'Merging parts...';
125
- const mergedContent = mergeBase64Chunks(chunks);
126
- spinner.succeed && spinner.succeed('Parts merged successfully');
149
+ // Validate against totalParts if available
150
+ if (metadata.totalParts !== null && totalPartsFound !== metadata.totalParts) {
151
+ throw new Error(
152
+ `Missing parts! Found ${totalPartsFound} of ${metadata.totalParts} parts. ` +
153
+ `Make sure all parts are in the same directory.`
154
+ );
155
+ }
156
+
157
+ spinner.text = `Multi-part file detected (${totalPartsFound} parts)`;
158
+ spinner.succeed && spinner.succeed(`Found all ${totalPartsFound} parts`);
159
+
160
+ // Process each part
161
+ for (let i = 0; i < allParts.length; i++) {
162
+ const partSpinner = quiet ? spinner : ora(`Decoding part ${i + 1} of ${totalPartsFound}...`).start();
163
+
164
+ const partResult = await readFile(allParts[i].path, format);
165
+ const { encryptedContent: partContent, encryptionMeta: partEncMeta } = extractContent(partResult, format);
166
+
167
+ // Decode base64 to binary
168
+ const binaryData = Buffer.from(partContent, 'base64');
127
169
 
128
- // Decrypt if needed
129
- if (isEncrypted) {
130
- spinner.text = 'Decrypting content...';
131
- const { iv, salt, authTag } = unpackEncryptionMeta(encryptionMeta);
132
- finalBase64 = decrypt(mergedContent, options.password, iv, salt, authTag);
133
- spinner.succeed && spinner.succeed('Content decrypted');
134
- } else {
135
- finalBase64 = mergedContent;
136
- }
137
- } else {
138
- // Single file - Decrypt if needed
139
170
  if (isEncrypted) {
140
- spinner.text = 'Decrypting content...';
141
- const { iv, salt, authTag } = unpackEncryptionMeta(encryptionMeta);
142
- finalBase64 = decrypt(encryptedContent, options.password, iv, salt, authTag);
143
- spinner.succeed && spinner.succeed('Content decrypted');
171
+ // Each part has its own encryption metadata (per-part encryption)
172
+ const { iv, salt, authTag } = unpackEncryptionMeta(partEncMeta);
173
+ const decipher = createDecryptStream(options.password, iv, salt, authTag);
174
+ try {
175
+ const decrypted = Buffer.concat([decipher.update(binaryData), decipher.final()]);
176
+ writeTarget.write(decrypted);
177
+ } catch (error) {
178
+ throw new Error('Decryption failed: Invalid password or corrupted data');
179
+ }
144
180
  } else {
145
- finalBase64 = encryptedContent;
181
+ writeTarget.write(binaryData);
146
182
  }
183
+
184
+ partSpinner.succeed && partSpinner.succeed(`Part ${i + 1} decoded`);
147
185
  }
186
+ } else {
187
+ // Single file
188
+ spinner.text = 'Decoding...';
189
+
190
+ const binaryData = Buffer.from(encryptedContent, 'base64');
148
191
 
149
- // Decompress if needed
150
- let fileBuffer;
151
- if (isCompressed) {
152
- spinner.text = 'Decompressing...';
153
- const compressedBuffer = Buffer.from(finalBase64, 'base64');
154
- fileBuffer = await decompress(compressedBuffer);
155
- spinner.succeed && spinner.succeed(`Decompressed: ${formatBytes(compressedBuffer.length)} → ${formatBytes(fileBuffer.length)}`);
192
+ if (isEncrypted) {
193
+ const { iv, salt, authTag } = unpackEncryptionMeta(encryptionMeta);
194
+ const decipher = createDecryptStream(options.password, iv, salt, authTag);
195
+ try {
196
+ const decrypted = Buffer.concat([decipher.update(binaryData), decipher.final()]);
197
+ writeTarget.write(decrypted);
198
+ } catch (error) {
199
+ throw new Error('Decryption failed: Invalid password or corrupted data');
200
+ }
156
201
  } else {
157
- fileBuffer = Buffer.from(finalBase64, 'base64');
202
+ writeTarget.write(binaryData);
158
203
  }
204
+ }
159
205
 
160
- // Verify integrity if content hash is available
161
- if (metadata.contentHash) {
162
- spinner.text = 'Verifying integrity...';
163
- const actualHash = generateContentHash(fileBuffer);
206
+ // End the pipeline and wait for it to finish
207
+ writeTarget.end();
208
+ await finished(outputStream);
164
209
 
165
- if (actualHash !== metadata.contentHash) {
166
- throw new Error('Integrity check failed! The file may be corrupted or tampered with.');
167
- }
168
- spinner.succeed && spinner.succeed('Integrity verified (SHA-256 match)');
210
+ // Verify integrity
211
+ if (metadata.contentHash) {
212
+ spinner.text = 'Verifying integrity...';
213
+ const actualHash = hashStream.digest;
214
+
215
+ if (actualHash !== metadata.contentHash) {
216
+ // Clean up the output file on integrity failure
217
+ try { fs.unlinkSync(outputPath); } catch { /* ignore */ }
218
+ throw new Error('Integrity check failed! The file may be corrupted or tampered with.');
169
219
  }
220
+ spinner.succeed && spinner.succeed('Integrity verified (SHA-256 match)');
221
+ }
170
222
 
171
- // Write to file
172
- spinner.text = 'Writing output file...';
223
+ spinner.succeed && spinner.succeed('Decoding complete!');
173
224
 
174
- // Ensure output directory exists
175
- const outputDir = path.dirname(outputPath);
176
- if (!fs.existsSync(outputDir)) {
177
- fs.mkdirSync(outputDir, { recursive: true });
225
+ if (!quiet) {
226
+ const outputSize = fs.statSync(outputPath).size;
227
+ console.log();
228
+ console.log(chalk.green.bold('✓ File decoded successfully!'));
229
+ console.log(chalk.cyan(` Original: ${metadata.originalFilename}`));
230
+ console.log(chalk.cyan(` Output: ${outputPath}`));
231
+ console.log(chalk.cyan(` Size: ${formatBytes(outputSize)}`));
232
+ if (hasMultipleParts) {
233
+ console.log(chalk.cyan(` Parts merged: ${totalPartsFound}`));
178
234
  }
235
+ }
236
+ }
179
237
 
180
- fs.writeFileSync(outputPath, fileBuffer);
238
+ /**
239
+ * Legacy v3 in-memory decode — shared encryption across all parts
240
+ */
241
+ async function decodeLegacy(inputFile, format, metadata, encryptedContent, encryptionMeta, isEncrypted, isCompressed, options, outputPath, spinner, quiet) {
242
+ let finalBase64;
181
243
 
182
- spinner.succeed && spinner.succeed('Decoding complete!');
244
+ if (isMultiPart(metadata)) {
245
+ spinner.start && (spinner.text = `Multi-part file detected (${metadata.totalParts} parts)`);
183
246
 
184
- if (!quiet) {
185
- console.log();
186
- console.log(chalk.green.bold('✓ File decoded successfully!'));
187
- console.log(chalk.cyan(` Original: ${metadata.originalFilename}`));
188
- console.log(chalk.cyan(` Output: ${outputPath}`));
189
- console.log(chalk.cyan(` Size: ${formatBytes(fileBuffer.length)}`));
190
- if (isMultiPart(metadata)) {
191
- console.log(chalk.cyan(` Parts merged: ${metadata.totalParts}`));
192
- }
247
+ // Find all parts
248
+ const inputDir = path.dirname(inputFile);
249
+ const allParts = findMultiPartFiles(inputDir, metadata.hash, format, metadata.totalParts);
250
+
251
+ if (allParts.length !== metadata.totalParts) {
252
+ throw new Error(
253
+ `Missing parts! Found ${allParts.length} of ${metadata.totalParts} parts. ` +
254
+ `Make sure all parts are in the same directory.`
255
+ );
256
+ }
257
+
258
+ spinner.succeed && spinner.succeed(`Found all ${metadata.totalParts} parts`);
259
+
260
+ // Read all parts
261
+ const chunks = [];
262
+
263
+ for (let i = 0; i < allParts.length; i++) {
264
+ const partSpinner = quiet ? spinner : ora(`Reading part ${i + 1} of ${metadata.totalParts}...`).start();
265
+ const partResult = await readFile(allParts[i].path, format);
266
+ const { encryptedContent: partContent } = extractContent(partResult, format);
267
+ chunks.push(partContent);
268
+ partSpinner.succeed && partSpinner.succeed(`Part ${i + 1} read`);
269
+ }
270
+
271
+ // Merge chunks
272
+ spinner.text = 'Merging parts...';
273
+ const mergedContent = mergeBase64Chunks(chunks);
274
+ spinner.succeed && spinner.succeed('Parts merged successfully');
275
+
276
+ // Decrypt if needed
277
+ if (isEncrypted) {
278
+ spinner.text = 'Decrypting content...';
279
+ const { iv, salt, authTag } = unpackEncryptionMeta(encryptionMeta);
280
+ finalBase64 = decrypt(mergedContent, options.password, iv, salt, authTag);
281
+ spinner.succeed && spinner.succeed('Content decrypted');
282
+ } else {
283
+ finalBase64 = mergedContent;
284
+ }
285
+ } else {
286
+ // Single file - Decrypt if needed
287
+ if (isEncrypted) {
288
+ spinner.text = 'Decrypting content...';
289
+ const { iv, salt, authTag } = unpackEncryptionMeta(encryptionMeta);
290
+ finalBase64 = decrypt(encryptedContent, options.password, iv, salt, authTag);
291
+ spinner.succeed && spinner.succeed('Content decrypted');
292
+ } else {
293
+ finalBase64 = encryptedContent;
294
+ }
295
+ }
296
+
297
+ // Decompress if needed
298
+ let fileBuffer;
299
+ if (isCompressed) {
300
+ spinner.text = 'Decompressing...';
301
+ const compressedBuffer = Buffer.from(finalBase64, 'base64');
302
+ fileBuffer = await decompress(compressedBuffer);
303
+ spinner.succeed && spinner.succeed(`Decompressed: ${formatBytes(compressedBuffer.length)} → ${formatBytes(fileBuffer.length)}`);
304
+ } else {
305
+ fileBuffer = Buffer.from(finalBase64, 'base64');
306
+ }
307
+
308
+ // Verify integrity if content hash is available
309
+ if (metadata.contentHash) {
310
+ spinner.text = 'Verifying integrity...';
311
+ const actualHash = generateContentHash(fileBuffer);
312
+
313
+ if (actualHash !== metadata.contentHash) {
314
+ throw new Error('Integrity check failed! The file may be corrupted or tampered with.');
315
+ }
316
+ spinner.succeed && spinner.succeed('Integrity verified (SHA-256 match)');
317
+ }
318
+
319
+ // Write to file
320
+ spinner.text = 'Writing output file...';
321
+
322
+ const outputDir = path.dirname(outputPath);
323
+ if (!fs.existsSync(outputDir)) {
324
+ fs.mkdirSync(outputDir, { recursive: true });
325
+ }
326
+
327
+ fs.writeFileSync(outputPath, fileBuffer);
328
+
329
+ spinner.succeed && spinner.succeed('Decoding complete!');
330
+
331
+ if (!quiet) {
332
+ console.log();
333
+ console.log(chalk.green.bold('✓ File decoded successfully!'));
334
+ console.log(chalk.cyan(` Original: ${metadata.originalFilename}`));
335
+ console.log(chalk.cyan(` Output: ${outputPath}`));
336
+ console.log(chalk.cyan(` Size: ${formatBytes(fileBuffer.length)}`));
337
+ if (isMultiPart(metadata)) {
338
+ console.log(chalk.cyan(` Parts merged: ${metadata.totalParts}`));
193
339
  }
194
- } catch (error) {
195
- spinner.fail && spinner.fail('Decoding failed');
196
- console.error(chalk.red(`Error: ${error.message}`));
197
- process.exit(1);
198
340
  }
199
341
  }
200
342