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 +6 -7
- package/src/commands/decode.js +233 -91
- package/src/commands/encode.js +302 -199
- package/src/commands/info.js +10 -9
- package/src/commands/verify.js +65 -30
- package/src/index.js +2 -2
- package/src/lib/compression.js +18 -0
- package/src/lib/crypto.js +54 -0
- package/src/lib/docx-handler.js +1 -1
- package/src/lib/metadata.js +13 -2
- package/src/lib/streams.js +197 -0
- package/src/lib/utils.js +2 -2
- package/src/lib/xlsx-handler.js +133 -76
- package/bootstrap.js +0 -33
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "stegdoc",
|
|
3
|
-
"version": "
|
|
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.
|
|
56
|
-
"file-type": "^
|
|
57
|
-
"inquirer": "^
|
|
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
|
}
|
package/src/commands/decode.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
94
|
-
|
|
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
|
-
|
|
97
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
124
|
+
// Set up output pipeline: [decompress] → hash → file
|
|
125
|
+
const hashStream = new HashPassthrough();
|
|
126
|
+
const outputStream = fs.createWriteStream(outputPath);
|
|
102
127
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
136
|
+
const writeTarget = isCompressed ? decompressStream : hashStream;
|
|
111
137
|
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
141
|
-
const { iv, salt, authTag } = unpackEncryptionMeta(
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
202
|
+
writeTarget.write(binaryData);
|
|
158
203
|
}
|
|
204
|
+
}
|
|
159
205
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
172
|
-
spinner.text = 'Writing output file...';
|
|
223
|
+
spinner.succeed && spinner.succeed('Decoding complete!');
|
|
173
224
|
|
|
174
|
-
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
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
|
-
|
|
244
|
+
if (isMultiPart(metadata)) {
|
|
245
|
+
spinner.start && (spinner.text = `Multi-part file detected (${metadata.totalParts} parts)`);
|
|
183
246
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
|