stegdoc 6.0.0 → 6.1.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/README.md +16 -7
- package/package.json +8 -9
- package/src/commands/decode.js +60 -366
- package/src/commands/encode.js +73 -137
- package/src/commands/info.js +24 -102
- package/src/commands/verify.js +20 -186
- package/src/index.js +0 -1
- package/src/lib/compression.js +1 -161
- package/src/lib/crypto.js +0 -107
- package/src/lib/docx-handler.js +2 -278
- package/src/lib/metadata.js +7 -88
- package/src/lib/native.js +3 -3
- package/src/lib/streams.js +0 -32
- package/src/lib/utils.js +0 -47
- package/src/lib/xlsx-handler.js +1 -272
- package/src/lib/file-utils.js +0 -160
- package/src/lib/xml-utils.js +0 -115
package/src/commands/encode.js
CHANGED
|
@@ -3,14 +3,13 @@ const fs = require('fs');
|
|
|
3
3
|
const { pipeline } = require('stream/promises');
|
|
4
4
|
const chalk = require('chalk');
|
|
5
5
|
const ora = require('ora');
|
|
6
|
-
const
|
|
7
|
-
const { createDocxWithBase64, createDocxV5 } = require('../lib/docx-handler');
|
|
6
|
+
const { createDocxV5 } = require('../lib/docx-handler');
|
|
8
7
|
const { createXlsxPartV5 } = require('../lib/xlsx-handler');
|
|
9
8
|
const { createMetadata, serializeMetadata } = require('../lib/metadata');
|
|
10
9
|
const crypto = require('crypto');
|
|
11
10
|
const { generateHash, parseSizeToBytes, formatBytes, generateFilename } = require('../lib/utils');
|
|
12
11
|
const { packEncryptionMeta, generateSalt, createEncryptStream } = require('../lib/crypto');
|
|
13
|
-
const {
|
|
12
|
+
const { createBrotliCompressStream } = require('../lib/compression');
|
|
14
13
|
const { resetTimeWindow } = require('../lib/decoy-generator');
|
|
15
14
|
const { resetTimeState, BYTES_PER_DATA_LINE, calculateDataLineCount } = require('../lib/log-generator');
|
|
16
15
|
const { shouldRunInteractive, promptEncodeOptions } = require('../lib/interactive');
|
|
@@ -18,12 +17,52 @@ const { BinaryChunkCollector, ProgressTransform } = require('../lib/streams');
|
|
|
18
17
|
const { loadNative } = require('../lib/native');
|
|
19
18
|
|
|
20
19
|
/**
|
|
21
|
-
*
|
|
20
|
+
* Load the native engine, failing loudly when it is unavailable. Bundling has
|
|
21
|
+
* no JavaScript implementation any more: the archive is built by the core.
|
|
22
|
+
*
|
|
23
|
+
* @returns {object} The native binding.
|
|
24
|
+
*/
|
|
25
|
+
function requireNative() {
|
|
26
|
+
const native = loadNative();
|
|
27
|
+
if (!native || typeof native.zip !== 'function') {
|
|
28
|
+
throw new Error(
|
|
29
|
+
'Bundling inputs requires the native engine, which is not installed. ' +
|
|
30
|
+
'Reinstall stegdoc, or build it with `pnpm build:native`.'
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
return native;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Recursively collect a directory's files as archive entries under `prefix`.
|
|
38
|
+
*
|
|
39
|
+
* @param {string} dirPath Directory to walk.
|
|
40
|
+
* @param {string} prefix Forward-slash entry path prefix.
|
|
41
|
+
* @param {Array<{name: string, bytes: Buffer}>} entries Accumulator.
|
|
42
|
+
*/
|
|
43
|
+
function collectDirectory(dirPath, prefix, entries) {
|
|
44
|
+
for (const dirent of fs.readdirSync(dirPath, { withFileTypes: true })) {
|
|
45
|
+
const full = path.join(dirPath, dirent.name);
|
|
46
|
+
const name = `${prefix}/${dirent.name}`;
|
|
47
|
+
if (dirent.isDirectory()) {
|
|
48
|
+
collectDirectory(full, name, entries);
|
|
49
|
+
} else {
|
|
50
|
+
entries.push({ name, bytes: fs.readFileSync(full) });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Zip a folder into a buffer, keeping the folder's basename as a prefix.
|
|
57
|
+
*
|
|
58
|
+
* @param {string} folderPath Folder to archive.
|
|
59
|
+
* @returns {Buffer} The archive bytes.
|
|
22
60
|
*/
|
|
23
61
|
function zipFolder(folderPath) {
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
62
|
+
const native = requireNative();
|
|
63
|
+
const entries = [];
|
|
64
|
+
collectDirectory(folderPath, path.basename(folderPath), entries);
|
|
65
|
+
return native.zip(entries);
|
|
27
66
|
}
|
|
28
67
|
|
|
29
68
|
/**
|
|
@@ -59,18 +98,19 @@ function uniqueEntryName(name, used) {
|
|
|
59
98
|
* @returns {Buffer} The archive bytes.
|
|
60
99
|
*/
|
|
61
100
|
function zipInputs(inputs) {
|
|
62
|
-
const
|
|
101
|
+
const native = requireNative();
|
|
102
|
+
const entries = [];
|
|
63
103
|
const used = new Set();
|
|
64
104
|
for (const input of inputs) {
|
|
65
105
|
const base = path.basename(input.replace(/[\\/]+$/, ''));
|
|
66
106
|
const entry = uniqueEntryName(base, used);
|
|
67
107
|
if (fs.statSync(input).isDirectory()) {
|
|
68
|
-
|
|
108
|
+
collectDirectory(input, entry, entries);
|
|
69
109
|
} else {
|
|
70
|
-
|
|
110
|
+
entries.push({ name: entry, bytes: fs.readFileSync(input) });
|
|
71
111
|
}
|
|
72
112
|
}
|
|
73
|
-
return zip
|
|
113
|
+
return native.zip(entries);
|
|
74
114
|
}
|
|
75
115
|
|
|
76
116
|
/**
|
|
@@ -86,21 +126,23 @@ function bundleFilename(name) {
|
|
|
86
126
|
}
|
|
87
127
|
|
|
88
128
|
/**
|
|
89
|
-
*
|
|
129
|
+
* Extensions whose contents are already compressed, so Brotli only wastes CPU.
|
|
90
130
|
*/
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
131
|
+
const COMPRESSED_EXTENSIONS = new Set([
|
|
132
|
+
'zip', 'gz', 'tgz', 'bz2', 'xz', '7z', 'rar',
|
|
133
|
+
'jpg', 'jpeg', 'png', 'gif', 'webp',
|
|
134
|
+
'mp3', 'mp4', 'm4a', 'mov', 'avi', 'mkv', 'webm', 'ogg', 'opus', 'flac',
|
|
135
|
+
'pdf', 'apk', 'jar', 'docx', 'xlsx', 'pptx', 'wasm', 'br',
|
|
136
|
+
]);
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Check whether an extension is already compressed.
|
|
140
|
+
*
|
|
141
|
+
* @param {string} ext Extension, with or without a leading dot.
|
|
142
|
+
* @returns {boolean} True when the format needs no further compression.
|
|
143
|
+
*/
|
|
144
|
+
function isCompressedExtension(ext) {
|
|
145
|
+
return COMPRESSED_EXTENSIONS.has(String(ext || '').toLowerCase().replace(/^\./, ''));
|
|
104
146
|
}
|
|
105
147
|
|
|
106
148
|
/**
|
|
@@ -143,13 +185,9 @@ async function encodeCommand(input, options) {
|
|
|
143
185
|
}
|
|
144
186
|
|
|
145
187
|
const quiet = options.quiet || false;
|
|
146
|
-
const legacy = options.legacy || false;
|
|
147
188
|
if (options.v5 && options.v6) {
|
|
148
189
|
throw new Error('--v5 and --v6 cannot be combined.');
|
|
149
190
|
}
|
|
150
|
-
if (legacy && (options.v5 || options.v6)) {
|
|
151
|
-
throw new Error('--legacy cannot be combined with --v5 or --v6.');
|
|
152
|
-
}
|
|
153
191
|
const version = options.v5 ? 'v5' : 'v6';
|
|
154
192
|
const spinner = quiet ? { start: () => {}, succeed: () => {}, fail: () => {}, info: () => {}, text: '' } : ora('Starting encoding process...').start();
|
|
155
193
|
const createdFiles = [];
|
|
@@ -220,33 +258,22 @@ async function encodeCommand(input, options) {
|
|
|
220
258
|
|
|
221
259
|
spinner.text = 'Checking file type...';
|
|
222
260
|
let useCompression = true;
|
|
223
|
-
const
|
|
261
|
+
const extensionKey = extension.replace(/^\./, '').toLowerCase();
|
|
224
262
|
|
|
225
|
-
if (
|
|
263
|
+
if (isCompressedExtension(extensionKey)) {
|
|
226
264
|
useCompression = false;
|
|
227
|
-
spinner.info && spinner.info(`Skipping compression (${
|
|
265
|
+
spinner.info && spinner.info(`Skipping compression (${extensionKey} is already compressed)`);
|
|
228
266
|
}
|
|
229
267
|
|
|
230
|
-
// DOCX v5 size limit (not applicable
|
|
268
|
+
// DOCX v5 size limit (not applicable with --no-limit)
|
|
231
269
|
const noLimit = options.noLimit || options.limit === false;
|
|
232
|
-
if (format === 'docx' && !
|
|
270
|
+
if (format === 'docx' && !noLimit && fileSize > 1 * 1024 * 1024) {
|
|
233
271
|
throw new Error(
|
|
234
272
|
`DOCX format is limited to files under 1 MB (yours is ${formatBytes(fileSize)}). ` +
|
|
235
273
|
`Use XLSX format (-f xlsx) for larger files, or --no-limit to bypass.`
|
|
236
274
|
);
|
|
237
275
|
}
|
|
238
276
|
|
|
239
|
-
// Route to legacy or v5 pipeline
|
|
240
|
-
if (legacy) {
|
|
241
|
-
if (format === 'docx') {
|
|
242
|
-
await encodeLegacyDocx(streamSource, filename, extension, fileSize, options, useCompression, useEncryption, spinner, quiet, createdFiles);
|
|
243
|
-
} else {
|
|
244
|
-
throw new Error('Legacy XLSX format (--legacy) is no longer supported. Use v5 format (default) or legacy DOCX (-f docx --legacy).');
|
|
245
|
-
}
|
|
246
|
-
if (tempZipPath) cleanupTemp(tempZipPath);
|
|
247
|
-
return;
|
|
248
|
-
}
|
|
249
|
-
|
|
250
277
|
// === Log-Embed Pipeline ===
|
|
251
278
|
const hash = generateHash();
|
|
252
279
|
const outputDir = options.outputDir || process.cwd();
|
|
@@ -274,7 +301,7 @@ async function encodeCommand(input, options) {
|
|
|
274
301
|
// Pre-compute content hash
|
|
275
302
|
spinner.text = 'Computing file hash...';
|
|
276
303
|
|
|
277
|
-
// Prefer the native engine
|
|
304
|
+
// Prefer the native engine; the JavaScript encoder only covers v5.
|
|
278
305
|
const native = loadNative();
|
|
279
306
|
if (version === 'v6' && !native) {
|
|
280
307
|
throw new Error(
|
|
@@ -393,7 +420,6 @@ async function encodeCommand(input, options) {
|
|
|
393
420
|
encrypted: useEncryption,
|
|
394
421
|
compressed: useCompression,
|
|
395
422
|
contentHash,
|
|
396
|
-
stegoMethod: 'log-embed',
|
|
397
423
|
compressionAlgo: 'brotli',
|
|
398
424
|
payloadSize: payloadBuffer.length,
|
|
399
425
|
dataLineCount,
|
|
@@ -497,96 +523,6 @@ async function encodeCommand(input, options) {
|
|
|
497
523
|
}
|
|
498
524
|
}
|
|
499
525
|
|
|
500
|
-
// ─── Legacy v4 DOCX Pipeline ────────────────────────────────────────────────
|
|
501
|
-
|
|
502
|
-
async function encodeLegacyDocx(inputPath, filename, extension, fileSize, options, useCompression, useEncryption, spinner, quiet, createdFiles) {
|
|
503
|
-
const { compress } = require('../lib/compression');
|
|
504
|
-
const { encrypt, packEncryptionMeta: packMeta } = require('../lib/crypto');
|
|
505
|
-
const { generateContentHash } = require('../lib/utils');
|
|
506
|
-
|
|
507
|
-
const fileBuffer = fs.readFileSync(inputPath);
|
|
508
|
-
const contentHash = generateContentHash(fileBuffer);
|
|
509
|
-
|
|
510
|
-
let processedBuffer = fileBuffer;
|
|
511
|
-
if (useCompression) {
|
|
512
|
-
spinner.text = 'Compressing...';
|
|
513
|
-
const compressedBuffer = await compress(fileBuffer);
|
|
514
|
-
if (compressedBuffer.length < fileBuffer.length) {
|
|
515
|
-
processedBuffer = compressedBuffer;
|
|
516
|
-
spinner.succeed && spinner.succeed(`Compressed: ${formatBytes(fileBuffer.length)} → ${formatBytes(compressedBuffer.length)}`);
|
|
517
|
-
} else {
|
|
518
|
-
useCompression = false;
|
|
519
|
-
spinner.info && spinner.info('Compression skipped (no size benefit)');
|
|
520
|
-
}
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
const base64 = processedBuffer.toString('base64');
|
|
524
|
-
|
|
525
|
-
let contentToStore;
|
|
526
|
-
let encryptionMeta = null;
|
|
527
|
-
|
|
528
|
-
if (useEncryption) {
|
|
529
|
-
spinner.text = 'Encrypting content...';
|
|
530
|
-
const { ciphertext, iv, salt, authTag } = encrypt(base64, options.password);
|
|
531
|
-
encryptionMeta = packMeta({ iv, salt, authTag });
|
|
532
|
-
contentToStore = ciphertext;
|
|
533
|
-
spinner.succeed && spinner.succeed('Content encrypted with AES-256-GCM');
|
|
534
|
-
} else {
|
|
535
|
-
contentToStore = base64;
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
const hash = generateHash();
|
|
539
|
-
const outputDir = options.outputDir || process.cwd();
|
|
540
|
-
const format = 'docx';
|
|
541
|
-
|
|
542
|
-
const metadata = createMetadata({
|
|
543
|
-
originalFilename: filename,
|
|
544
|
-
originalExtension: extension,
|
|
545
|
-
hash,
|
|
546
|
-
partNumber: null,
|
|
547
|
-
totalParts: null,
|
|
548
|
-
originalSize: fileSize,
|
|
549
|
-
format,
|
|
550
|
-
encrypted: useEncryption,
|
|
551
|
-
compressed: useCompression,
|
|
552
|
-
contentHash,
|
|
553
|
-
});
|
|
554
|
-
|
|
555
|
-
const outputFilename = generateFilename(hash, null, null, format);
|
|
556
|
-
const outputPath = path.join(outputDir, outputFilename);
|
|
557
|
-
|
|
558
|
-
if (fs.existsSync(outputPath) && !options.force) {
|
|
559
|
-
throw new Error(`File already exists: ${outputPath}. Use --force to overwrite.`);
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
const docxContent = useEncryption
|
|
563
|
-
? `${encryptionMeta}|||${contentToStore}`
|
|
564
|
-
: contentToStore;
|
|
565
|
-
|
|
566
|
-
await createDocxWithBase64({
|
|
567
|
-
base64Content: docxContent,
|
|
568
|
-
metadata,
|
|
569
|
-
outputPath,
|
|
570
|
-
});
|
|
571
|
-
|
|
572
|
-
createdFiles.push(outputPath);
|
|
573
|
-
spinner.succeed && spinner.succeed('Encoding complete!');
|
|
574
|
-
|
|
575
|
-
if (!quiet) {
|
|
576
|
-
console.log();
|
|
577
|
-
console.log(chalk.green.bold('✓ File encoded successfully!'));
|
|
578
|
-
console.log(chalk.cyan(` Format: DOCX (v4 legacy)`));
|
|
579
|
-
console.log(chalk.cyan(` Hash: ${hash}`));
|
|
580
|
-
console.log(chalk.cyan(` Output: ${outputFilename}`));
|
|
581
|
-
console.log(chalk.cyan(` Encrypted: ${useEncryption ? 'Yes' : 'No'}`));
|
|
582
|
-
console.log(chalk.cyan(` Compressed: ${useCompression ? 'Yes' : 'No'}`));
|
|
583
|
-
console.log(chalk.cyan(` Location: ${outputDir}`));
|
|
584
|
-
if (useEncryption) {
|
|
585
|
-
console.log(chalk.yellow(` Remember your password - it cannot be recovered!`));
|
|
586
|
-
}
|
|
587
|
-
}
|
|
588
|
-
}
|
|
589
|
-
|
|
590
526
|
function cleanupTemp(tempPath) {
|
|
591
527
|
try {
|
|
592
528
|
if (fs.existsSync(tempPath)) {
|
package/src/commands/info.js
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
|
-
const path = require('path');
|
|
2
1
|
const chalk = require('chalk');
|
|
3
2
|
const ora = require('ora');
|
|
4
|
-
const {
|
|
5
|
-
const { readXlsxBase64 } = require('../lib/xlsx-handler');
|
|
6
|
-
const { validateMetadata, isMultiPart, isLogEmbedFormat } = require('../lib/metadata');
|
|
7
|
-
const { detectFormat, formatBytes } = require('../lib/utils');
|
|
8
|
-
const { extractContent, findMultiPartFiles } = require('../lib/file-utils');
|
|
3
|
+
const { formatBytes } = require('../lib/utils');
|
|
9
4
|
const { loadNative } = require('../lib/native');
|
|
10
5
|
|
|
6
|
+
const NATIVE_REQUIRED =
|
|
7
|
+
'Reading file metadata requires the native engine, which is not installed. ' +
|
|
8
|
+
'Reinstall stegdoc, or build it with `pnpm build:native`.';
|
|
9
|
+
|
|
10
|
+
const LEGACY_UNSUPPORTED =
|
|
11
|
+
'The legacy v3/v4 format is no longer supported. Re-encode the file with a current version of stegdoc.';
|
|
12
|
+
|
|
11
13
|
/**
|
|
12
14
|
* Print metadata read by the native engine, which handles v5 and v6.
|
|
13
15
|
*/
|
|
@@ -54,109 +56,29 @@ async function infoCommand(inputFile, options) {
|
|
|
54
56
|
const spinner = ora('Reading file metadata...').start();
|
|
55
57
|
|
|
56
58
|
try {
|
|
57
|
-
const format = detectFormat(inputFile);
|
|
58
|
-
if (!format) {
|
|
59
|
-
throw new Error('Unknown file format. Supported formats: .xlsx, .docx');
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
// The native engine reads v5 and v6; the JS reader only knows v5.
|
|
63
59
|
const native = loadNative();
|
|
64
|
-
if (native
|
|
65
|
-
|
|
66
|
-
spinner.succeed('File metadata read successfully');
|
|
67
|
-
printNativeInfo(info);
|
|
68
|
-
return;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
let readResult;
|
|
72
|
-
if (format === 'xlsx') {
|
|
73
|
-
readResult = await readXlsxBase64(inputFile);
|
|
74
|
-
} else {
|
|
75
|
-
readResult = await readDocxBase64(inputFile);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
const extracted = extractContent(readResult, format);
|
|
79
|
-
const metadata = extracted.metadata;
|
|
80
|
-
const encryptionMeta = extracted.encryptionMeta;
|
|
81
|
-
|
|
82
|
-
validateMetadata(metadata);
|
|
83
|
-
|
|
84
|
-
const isEncrypted = metadata.encrypted || (encryptionMeta && encryptionMeta.length > 0);
|
|
85
|
-
const isCompressed = metadata.compressed || false;
|
|
86
|
-
const isV5 = isLogEmbedFormat(metadata);
|
|
87
|
-
|
|
88
|
-
spinner.succeed('File metadata read successfully');
|
|
89
|
-
console.log();
|
|
90
|
-
|
|
91
|
-
console.log(chalk.bold.white('File Information:'));
|
|
92
|
-
console.log(chalk.cyan(` Format: ${format.toUpperCase()}`));
|
|
93
|
-
console.log(chalk.cyan(` Tool version: ${metadata.version || '1.x'}`));
|
|
94
|
-
if (isV5) {
|
|
95
|
-
console.log(chalk.cyan(` Stego method: Log-embed (v5)`));
|
|
96
|
-
console.log(chalk.cyan(` Compression: ${metadata.compressionAlgo || 'brotli'}`));
|
|
60
|
+
if (!native) {
|
|
61
|
+
throw new Error(NATIVE_REQUIRED);
|
|
97
62
|
}
|
|
98
|
-
console.log();
|
|
99
|
-
|
|
100
|
-
console.log(chalk.bold.white('Original File:'));
|
|
101
|
-
console.log(chalk.cyan(` Filename: ${metadata.originalFilename}`));
|
|
102
|
-
console.log(chalk.cyan(` Extension: ${metadata.originalExtension}`));
|
|
103
|
-
console.log(chalk.cyan(` Size: ${formatBytes(metadata.originalSize)}`));
|
|
104
|
-
console.log();
|
|
105
|
-
|
|
106
|
-
console.log(chalk.bold.white('Encoding Options:'));
|
|
107
|
-
console.log(chalk.cyan(` Encrypted: ${isEncrypted ? chalk.yellow('Yes') : 'No'}`));
|
|
108
|
-
console.log(chalk.cyan(` Compressed: ${isCompressed ? chalk.green('Yes') : 'No'}`));
|
|
109
|
-
console.log(chalk.cyan(` Encoded on: ${metadata.encodingDate || 'Unknown'}`));
|
|
110
63
|
|
|
111
|
-
|
|
112
|
-
|
|
64
|
+
let probe;
|
|
65
|
+
try {
|
|
66
|
+
probe = native.probe(inputFile);
|
|
67
|
+
} catch {
|
|
68
|
+
probe = 'unknown';
|
|
113
69
|
}
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
console.log(chalk.cyan(` Data lines: ${metadata.dataLineCount}`));
|
|
117
|
-
}
|
|
118
|
-
console.log();
|
|
119
|
-
|
|
120
|
-
const hasMultipleParts = isMultiPart(metadata) || metadata.partNumber !== null;
|
|
121
|
-
if (hasMultipleParts) {
|
|
122
|
-
console.log(chalk.bold.white('Multi-part File:'));
|
|
123
|
-
const inputDir = path.dirname(inputFile);
|
|
124
|
-
const allParts = findMultiPartFiles(inputDir, metadata.hash, format);
|
|
125
|
-
const totalParts = metadata.totalParts || allParts.length;
|
|
126
|
-
|
|
127
|
-
console.log(chalk.cyan(` This is part: ${metadata.partNumber} of ${totalParts}`));
|
|
128
|
-
console.log(chalk.cyan(` Hash: ${metadata.hash}`));
|
|
129
|
-
|
|
130
|
-
console.log();
|
|
131
|
-
console.log(chalk.bold.white('Parts found in directory:'));
|
|
132
|
-
|
|
133
|
-
for (let i = 1; i <= totalParts; i++) {
|
|
134
|
-
const part = allParts.find(p => p.partNumber === i);
|
|
135
|
-
if (part) {
|
|
136
|
-
console.log(chalk.green(` ✓ Part ${i}: ${part.filename}`));
|
|
137
|
-
} else {
|
|
138
|
-
console.log(chalk.red(` ✗ Part ${i}: MISSING`));
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
if (allParts.length >= totalParts) {
|
|
143
|
-
console.log();
|
|
144
|
-
console.log(chalk.green.bold('All parts found - ready to decode'));
|
|
145
|
-
} else {
|
|
146
|
-
console.log();
|
|
147
|
-
console.log(chalk.yellow.bold(`Missing ${totalParts - allParts.length} part(s)`));
|
|
148
|
-
}
|
|
149
|
-
} else {
|
|
150
|
-
console.log(chalk.bold.white('Single File:'));
|
|
151
|
-
console.log(chalk.cyan(` Hash: ${metadata.hash}`));
|
|
152
|
-
console.log();
|
|
153
|
-
console.log(chalk.green.bold('Ready to decode'));
|
|
70
|
+
if (probe === 'legacy') {
|
|
71
|
+
throw new Error(LEGACY_UNSUPPORTED);
|
|
154
72
|
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
73
|
+
if (probe !== 'v5' && probe !== 'archive') {
|
|
74
|
+
throw new Error(
|
|
75
|
+
'Unknown file format. Supported formats: .xlsx, .docx, or a zip of them'
|
|
76
|
+
);
|
|
158
77
|
}
|
|
159
78
|
|
|
79
|
+
const info = native.info(inputFile);
|
|
80
|
+
spinner.succeed('File metadata read successfully');
|
|
81
|
+
printNativeInfo(info);
|
|
160
82
|
} catch (error) {
|
|
161
83
|
spinner.fail('Failed to read file info');
|
|
162
84
|
console.error(chalk.red(`Error: ${error.message}`));
|
package/src/commands/verify.js
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
|
-
const path = require('path');
|
|
2
1
|
const chalk = require('chalk');
|
|
3
2
|
const ora = require('ora');
|
|
4
|
-
const {
|
|
5
|
-
const { readXlsxBase64 } = require('../lib/xlsx-handler');
|
|
6
|
-
const { validateMetadata, isMultiPart, isStreamingFormat, isLogEmbedFormat } = require('../lib/metadata');
|
|
7
|
-
const { detectFormat, formatBytes } = require('../lib/utils');
|
|
8
|
-
const { decrypt, unpackEncryptionMeta, createDecryptStream } = require('../lib/crypto');
|
|
9
|
-
const { extractContent, findMultiPartFiles } = require('../lib/file-utils');
|
|
3
|
+
const { formatBytes } = require('../lib/utils');
|
|
10
4
|
const { loadNative } = require('../lib/native');
|
|
11
5
|
|
|
6
|
+
const NATIVE_REQUIRED =
|
|
7
|
+
'Verifying a file requires the native engine, which is not installed. ' +
|
|
8
|
+
'Reinstall stegdoc, or build it with `pnpm build:native`.';
|
|
9
|
+
|
|
10
|
+
const LEGACY_UNSUPPORTED =
|
|
11
|
+
'The legacy v3/v4 format is no longer supported. Re-encode the file with a current version of stegdoc.';
|
|
12
|
+
|
|
12
13
|
/**
|
|
13
14
|
* Verify a v5/v6 file with the native engine, which also reads v6.
|
|
14
15
|
*/
|
|
@@ -73,133 +74,29 @@ function verifyNative(native, inputFile, options, spinner) {
|
|
|
73
74
|
*/
|
|
74
75
|
async function verifyCommand(inputFile, options) {
|
|
75
76
|
const spinner = ora('Verifying file...').start();
|
|
76
|
-
const issues = [];
|
|
77
77
|
|
|
78
78
|
try {
|
|
79
|
-
const format = detectFormat(inputFile);
|
|
80
|
-
if (!format) {
|
|
81
|
-
throw new Error('Unknown file format. Supported formats: .xlsx, .docx');
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
// The native engine reads v5 and v6; the JS reader only knows v5.
|
|
85
79
|
const native = loadNative();
|
|
86
|
-
if (native
|
|
87
|
-
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
spinner.text = `Reading ${format.toUpperCase()} file...`;
|
|
91
|
-
|
|
92
|
-
let readResult;
|
|
93
|
-
if (format === 'xlsx') {
|
|
94
|
-
readResult = await readXlsxBase64(inputFile);
|
|
95
|
-
} else {
|
|
96
|
-
readResult = await readDocxBase64(inputFile);
|
|
80
|
+
if (!native) {
|
|
81
|
+
throw new Error(NATIVE_REQUIRED);
|
|
97
82
|
}
|
|
98
83
|
|
|
99
|
-
|
|
100
|
-
const metadata = extracted.metadata;
|
|
101
|
-
const encryptionMeta = extracted.encryptionMeta;
|
|
102
|
-
|
|
84
|
+
let probe;
|
|
103
85
|
try {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
issues.push(`Metadata: ${e.message}`);
|
|
108
|
-
spinner.warn('Metadata issues found');
|
|
86
|
+
probe = native.probe(inputFile);
|
|
87
|
+
} catch {
|
|
88
|
+
probe = 'unknown';
|
|
109
89
|
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
const isV5 = isLogEmbedFormat(metadata);
|
|
113
|
-
const isV4 = !isV5 && isStreamingFormat(metadata);
|
|
114
|
-
|
|
115
|
-
// Check multi-part
|
|
116
|
-
const hasMultipleParts = isMultiPart(metadata) || metadata.partNumber !== null;
|
|
117
|
-
if (hasMultipleParts) {
|
|
118
|
-
spinner.text = 'Checking multi-part files...';
|
|
119
|
-
const inputDir = path.dirname(inputFile);
|
|
120
|
-
const allParts = findMultiPartFiles(inputDir, metadata.hash, format);
|
|
121
|
-
const totalParts = metadata.totalParts || allParts.length;
|
|
122
|
-
|
|
123
|
-
if (metadata.totalParts !== null && allParts.length !== metadata.totalParts) {
|
|
124
|
-
const missing = [];
|
|
125
|
-
const foundParts = new Set(allParts.map(p => p.partNumber));
|
|
126
|
-
for (let i = 1; i <= metadata.totalParts; i++) {
|
|
127
|
-
if (!foundParts.has(i)) missing.push(i);
|
|
128
|
-
}
|
|
129
|
-
issues.push(`Missing parts: ${missing.join(', ')}`);
|
|
130
|
-
spinner.warn(`Found ${allParts.length}/${metadata.totalParts} parts`);
|
|
131
|
-
} else {
|
|
132
|
-
spinner.succeed(`All ${totalParts} parts found`);
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
// Check password for encrypted files
|
|
137
|
-
if (isEncrypted) {
|
|
138
|
-
if (!options.password) {
|
|
139
|
-
issues.push('File is encrypted but no password provided');
|
|
140
|
-
spinner.warn('Encryption check skipped (no password)');
|
|
141
|
-
} else {
|
|
142
|
-
spinner.text = 'Verifying decryption...';
|
|
143
|
-
try {
|
|
144
|
-
if (isV5) {
|
|
145
|
-
await verifyV5Encryption(extracted, options.password);
|
|
146
|
-
} else if (isV4) {
|
|
147
|
-
await verifyV4Encryption(inputFile, format, encryptionMeta, options.password);
|
|
148
|
-
} else {
|
|
149
|
-
await verifyV3Encryption(inputFile, format, metadata, extracted.encryptedContent, encryptionMeta, options.password);
|
|
150
|
-
}
|
|
151
|
-
spinner.succeed('Decryption password valid');
|
|
152
|
-
} catch (e) {
|
|
153
|
-
issues.push('Decryption failed - wrong password or corrupted data');
|
|
154
|
-
spinner.fail('Decryption check failed');
|
|
155
|
-
}
|
|
156
|
-
}
|
|
90
|
+
if (probe === 'legacy') {
|
|
91
|
+
throw new Error(LEGACY_UNSUPPORTED);
|
|
157
92
|
}
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
if (issues.length === 0) {
|
|
163
|
-
console.log(chalk.green.bold('✓ File verification passed!'));
|
|
164
|
-
console.log(chalk.cyan(` Original file: ${metadata.originalFilename}`));
|
|
165
|
-
console.log(chalk.cyan(` Size: ${formatBytes(metadata.originalSize)}`));
|
|
166
|
-
console.log(chalk.cyan(` Encrypted: ${isEncrypted ? 'Yes' : 'No'}`));
|
|
167
|
-
console.log(chalk.cyan(` Compressed: ${metadata.compressed ? 'Yes' : 'No'}`));
|
|
168
|
-
|
|
169
|
-
let versionStr = 'v3 (legacy)';
|
|
170
|
-
if (isV5) versionStr = 'v5 (log-embed)';
|
|
171
|
-
else if (isV4) versionStr = 'v4 (streaming)';
|
|
172
|
-
console.log(chalk.cyan(` Format version: ${versionStr}`));
|
|
173
|
-
|
|
174
|
-
if (hasMultipleParts) {
|
|
175
|
-
const totalParts = metadata.totalParts || 'unknown';
|
|
176
|
-
console.log(chalk.cyan(` Parts: ${totalParts}`));
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
console.log();
|
|
180
|
-
console.log(chalk.green('File is ready to decode.'));
|
|
181
|
-
} else {
|
|
182
|
-
console.log(chalk.yellow.bold('⚠ Verification completed with issues:'));
|
|
183
|
-
console.log();
|
|
184
|
-
for (const issue of issues) {
|
|
185
|
-
console.log(chalk.yellow(` • ${issue}`));
|
|
186
|
-
}
|
|
187
|
-
console.log();
|
|
188
|
-
|
|
189
|
-
const hasBlockingIssue = issues.some(i =>
|
|
190
|
-
i.includes('Missing parts') ||
|
|
191
|
-
i.includes('Decryption failed') ||
|
|
192
|
-
i.includes('Metadata')
|
|
93
|
+
if (probe !== 'v5' && probe !== 'archive') {
|
|
94
|
+
throw new Error(
|
|
95
|
+
'Unknown file format. Supported formats: .xlsx, .docx, or a zip of them'
|
|
193
96
|
);
|
|
194
|
-
|
|
195
|
-
if (hasBlockingIssue) {
|
|
196
|
-
console.log(chalk.red('File cannot be decoded until issues are resolved.'));
|
|
197
|
-
process.exit(1);
|
|
198
|
-
} else {
|
|
199
|
-
console.log(chalk.yellow('File may still be decodable. Run decode to attempt.'));
|
|
200
|
-
}
|
|
201
97
|
}
|
|
202
98
|
|
|
99
|
+
return verifyNative(native, inputFile, options, spinner);
|
|
203
100
|
} catch (error) {
|
|
204
101
|
spinner.fail('Verification failed');
|
|
205
102
|
console.error(chalk.red(`Error: ${error.message}`));
|
|
@@ -207,67 +104,4 @@ async function verifyCommand(inputFile, options) {
|
|
|
207
104
|
}
|
|
208
105
|
}
|
|
209
106
|
|
|
210
|
-
/**
|
|
211
|
-
* Verify v5 log-embed encryption
|
|
212
|
-
*/
|
|
213
|
-
async function verifyV5Encryption(extracted, password) {
|
|
214
|
-
const { payloadBuffer, encryptionMeta } = extracted;
|
|
215
|
-
const { iv, salt, authTag } = unpackEncryptionMeta(encryptionMeta);
|
|
216
|
-
const decipher = createDecryptStream(password, iv, salt, authTag);
|
|
217
|
-
decipher.update(payloadBuffer);
|
|
218
|
-
decipher.final();
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
/**
|
|
222
|
-
* Verify v4 per-part encryption
|
|
223
|
-
*/
|
|
224
|
-
async function verifyV4Encryption(inputFile, format, encryptionMeta, password) {
|
|
225
|
-
const { iv, salt, authTag } = unpackEncryptionMeta(encryptionMeta);
|
|
226
|
-
|
|
227
|
-
let readResult;
|
|
228
|
-
if (format === 'xlsx') {
|
|
229
|
-
readResult = await readXlsxBase64(inputFile);
|
|
230
|
-
} else {
|
|
231
|
-
readResult = await readDocxBase64(inputFile);
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
const { encryptedContent } = extractContent(readResult, format);
|
|
235
|
-
const binaryData = Buffer.from(encryptedContent, 'base64');
|
|
236
|
-
|
|
237
|
-
const decipher = createDecryptStream(password, iv, salt, authTag);
|
|
238
|
-
decipher.update(binaryData);
|
|
239
|
-
decipher.final();
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
/**
|
|
243
|
-
* Verify v3 shared encryption
|
|
244
|
-
*/
|
|
245
|
-
async function verifyV3Encryption(inputFile, format, metadata, encryptedContent, encryptionMeta, password) {
|
|
246
|
-
const { iv, salt, authTag } = unpackEncryptionMeta(encryptionMeta);
|
|
247
|
-
|
|
248
|
-
let fullContent = encryptedContent;
|
|
249
|
-
|
|
250
|
-
if (isMultiPart(metadata)) {
|
|
251
|
-
const inputDir = path.dirname(inputFile);
|
|
252
|
-
const allParts = findMultiPartFiles(inputDir, metadata.hash, format);
|
|
253
|
-
|
|
254
|
-
if (allParts.length === metadata.totalParts) {
|
|
255
|
-
const contentParts = [];
|
|
256
|
-
for (const part of allParts) {
|
|
257
|
-
let partResult;
|
|
258
|
-
if (format === 'xlsx') {
|
|
259
|
-
partResult = await readXlsxBase64(part.path);
|
|
260
|
-
} else {
|
|
261
|
-
partResult = await readDocxBase64(part.path);
|
|
262
|
-
}
|
|
263
|
-
const { encryptedContent: partContent } = extractContent(partResult, format);
|
|
264
|
-
contentParts.push(partContent);
|
|
265
|
-
}
|
|
266
|
-
fullContent = contentParts.join('');
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
decrypt(fullContent, password, iv, salt, authTag);
|
|
271
|
-
}
|
|
272
|
-
|
|
273
107
|
module.exports = verifyCommand;
|