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.
@@ -3,9 +3,9 @@ const chalk = require('chalk');
3
3
  const ora = require('ora');
4
4
  const { readDocxBase64 } = require('../lib/docx-handler');
5
5
  const { readXlsxBase64 } = require('../lib/xlsx-handler');
6
- const { validateMetadata, isMultiPart } = require('../lib/metadata');
6
+ const { validateMetadata, isMultiPart, isStreamingFormat } = require('../lib/metadata');
7
7
  const { detectFormat, formatBytes } = require('../lib/utils');
8
- const { decrypt, unpackEncryptionMeta } = require('../lib/crypto');
8
+ const { decrypt, unpackEncryptionMeta, createDecryptStream } = require('../lib/crypto');
9
9
  const { extractContent, findMultiPartFiles } = require('../lib/file-utils');
10
10
 
11
11
  /**
@@ -47,6 +47,7 @@ async function verifyCommand(inputFile, options) {
47
47
  }
48
48
 
49
49
  const isEncrypted = metadata.encrypted || (encryptionMeta && encryptionMeta.length > 0);
50
+ const isV4 = isStreamingFormat(metadata);
50
51
 
51
52
  // Check multi-part
52
53
  if (isMultiPart(metadata)) {
@@ -81,35 +82,13 @@ async function verifyCommand(inputFile, options) {
81
82
  spinner.text = 'Verifying decryption...';
82
83
 
83
84
  try {
84
- const { iv, salt, authTag } = unpackEncryptionMeta(encryptionMeta);
85
-
86
- // For multi-part files, we need to merge all parts before decryption
87
- // AES-GCM requires the complete ciphertext to verify the auth tag
88
- let fullContent = encryptedContent;
89
-
90
- if (isMultiPart(metadata)) {
91
- const inputDir = path.dirname(inputFile);
92
- const allParts = findMultiPartFiles(inputDir, metadata.hash, format);
93
-
94
- if (allParts.length === metadata.totalParts) {
95
- // Read and merge all parts
96
- const contentParts = [];
97
- for (const part of allParts) {
98
- let partResult;
99
- if (format === 'xlsx') {
100
- partResult = await readXlsxBase64(part.path);
101
- } else {
102
- partResult = await readDocxBase64(part.path);
103
- }
104
- const { encryptedContent: partContent } = extractContent(partResult, format);
105
- contentParts.push(partContent);
106
- }
107
- fullContent = contentParts.join('');
108
- }
85
+ if (isV4) {
86
+ // v4: per-part encryption — verify each part independently
87
+ await verifyV4Encryption(inputFile, format, metadata, encryptionMeta, options.password, spinner);
88
+ } else {
89
+ // v3: shared encryption — merge all parts then decrypt
90
+ await verifyV3Encryption(inputFile, format, metadata, encryptedContent, encryptionMeta, options.password, spinner);
109
91
  }
110
-
111
- // Decrypt the full content to verify password
112
- decrypt(fullContent, options.password, iv, salt, authTag);
113
92
  spinner.succeed('Decryption password valid');
114
93
  } catch (e) {
115
94
  issues.push('Decryption failed - wrong password or corrupted data');
@@ -127,6 +106,7 @@ async function verifyCommand(inputFile, options) {
127
106
  console.log(chalk.cyan(` Size: ${formatBytes(metadata.originalSize)}`));
128
107
  console.log(chalk.cyan(` Encrypted: ${isEncrypted ? 'Yes' : 'No'}`));
129
108
  console.log(chalk.cyan(` Compressed: ${metadata.compressed ? 'Yes' : 'No'}`));
109
+ console.log(chalk.cyan(` Format version: ${isV4 ? 'v4 (streaming)' : 'v3 (legacy)'}`));
130
110
 
131
111
  if (isMultiPart(metadata)) {
132
112
  console.log(chalk.cyan(` Parts: ${metadata.totalParts}`));
@@ -166,4 +146,59 @@ async function verifyCommand(inputFile, options) {
166
146
  }
167
147
  }
168
148
 
149
+ /**
150
+ * Verify v4 per-part encryption by decrypting the first part
151
+ */
152
+ async function verifyV4Encryption(inputFile, format, metadata, encryptionMeta, password, spinner) {
153
+ // For v4, each part has its own encryption metadata
154
+ // Verify by decrypting the first part's content
155
+ const { iv, salt, authTag } = unpackEncryptionMeta(encryptionMeta);
156
+
157
+ // Read the first part's content
158
+ let readResult;
159
+ if (format === 'xlsx') {
160
+ readResult = await readXlsxBase64(inputFile);
161
+ } else {
162
+ readResult = await readDocxBase64(inputFile);
163
+ }
164
+
165
+ const { encryptedContent } = extractContent(readResult, format);
166
+ const binaryData = Buffer.from(encryptedContent, 'base64');
167
+
168
+ const decipher = createDecryptStream(password, iv, salt, authTag);
169
+ decipher.update(binaryData);
170
+ decipher.final(); // Throws on wrong password
171
+ }
172
+
173
+ /**
174
+ * Verify v3 shared encryption by merging all parts and decrypting
175
+ */
176
+ async function verifyV3Encryption(inputFile, format, metadata, encryptedContent, encryptionMeta, password, spinner) {
177
+ const { iv, salt, authTag } = unpackEncryptionMeta(encryptionMeta);
178
+
179
+ let fullContent = encryptedContent;
180
+
181
+ if (isMultiPart(metadata)) {
182
+ const inputDir = path.dirname(inputFile);
183
+ const allParts = findMultiPartFiles(inputDir, metadata.hash, format);
184
+
185
+ if (allParts.length === metadata.totalParts) {
186
+ const contentParts = [];
187
+ for (const part of allParts) {
188
+ let partResult;
189
+ if (format === 'xlsx') {
190
+ partResult = await readXlsxBase64(part.path);
191
+ } else {
192
+ partResult = await readDocxBase64(part.path);
193
+ }
194
+ const { encryptedContent: partContent } = extractContent(partResult, format);
195
+ contentParts.push(partContent);
196
+ }
197
+ fullContent = contentParts.join('');
198
+ }
199
+ }
200
+
201
+ decrypt(fullContent, password, iv, salt, authTag);
202
+ }
203
+
169
204
  module.exports = verifyCommand;
package/src/index.js CHANGED
@@ -10,8 +10,8 @@ const verifyCommand = require('./commands/verify');
10
10
  // CLI Configuration
11
11
  program
12
12
  .name('stegdoc')
13
- .description('Hide files inside Office documents with AES-256 encryption and steganography')
14
- .version('1.0.1');
13
+ .description('CLI tool to encode files into Office documents with AES-256 encryption')
14
+ .version('3.0.2');
15
15
 
16
16
  // Encode command
17
17
  program
@@ -89,9 +89,27 @@ function decompress(buffer) {
89
89
  });
90
90
  }
91
91
 
92
+ /**
93
+ * Create a streaming gzip compression transform
94
+ * @returns {zlib.Gzip} Gzip transform stream
95
+ */
96
+ function createCompressStream() {
97
+ return zlib.createGzip({ level: 9 });
98
+ }
99
+
100
+ /**
101
+ * Create a streaming gunzip decompression transform
102
+ * @returns {zlib.Gunzip} Gunzip transform stream
103
+ */
104
+ function createDecompressStream() {
105
+ return zlib.createGunzip();
106
+ }
107
+
92
108
  module.exports = {
93
109
  isCompressedMime,
94
110
  compress,
95
111
  decompress,
112
+ createCompressStream,
113
+ createDecompressStream,
96
114
  COMPRESSED_MIMES,
97
115
  };
package/src/lib/crypto.js CHANGED
@@ -109,10 +109,64 @@ function unpackEncryptionMeta(packed) {
109
109
  return { iv, salt, authTag };
110
110
  }
111
111
 
112
+ /**
113
+ * Generate a random salt for key derivation (one per encode session)
114
+ * @returns {Buffer} Random salt
115
+ */
116
+ function generateSalt() {
117
+ return crypto.randomBytes(SALT_LENGTH);
118
+ }
119
+
120
+ /**
121
+ * Create a streaming encrypt cipher for per-part encryption.
122
+ * Each call generates a unique IV. The salt should be shared across parts.
123
+ * Call getAuthTag() AFTER the cipher stream has ended (after .final()).
124
+ * @param {string} password - User password
125
+ * @param {Buffer} salt - Salt buffer (shared across parts in a session)
126
+ * @returns {object} { stream, iv, salt, getAuthTag }
127
+ */
128
+ function createEncryptStream(password, salt) {
129
+ const iv = crypto.randomBytes(IV_LENGTH);
130
+ const key = deriveKey(password, salt);
131
+ const cipher = crypto.createCipheriv(ALGORITHM, key, iv, {
132
+ authTagLength: AUTH_TAG_LENGTH,
133
+ });
134
+ return {
135
+ stream: cipher,
136
+ iv: iv.toString('base64'),
137
+ salt: salt.toString('base64'),
138
+ getAuthTag: () => cipher.getAuthTag().toString('base64'),
139
+ };
140
+ }
141
+
142
+ /**
143
+ * Create a streaming decrypt decipher for per-part decryption.
144
+ * Auth tag is set immediately and verified on .final().
145
+ * @param {string} password - User password
146
+ * @param {string} ivBase64 - IV (base64)
147
+ * @param {string} saltBase64 - Salt (base64)
148
+ * @param {string} authTagBase64 - Auth tag (base64)
149
+ * @returns {crypto.Decipher} Decipher transform stream
150
+ */
151
+ function createDecryptStream(password, ivBase64, saltBase64, authTagBase64) {
152
+ const iv = Buffer.from(ivBase64, 'base64');
153
+ const salt = Buffer.from(saltBase64, 'base64');
154
+ const authTag = Buffer.from(authTagBase64, 'base64');
155
+ const key = deriveKey(password, salt);
156
+ const decipher = crypto.createDecipheriv(ALGORITHM, key, iv, {
157
+ authTagLength: AUTH_TAG_LENGTH,
158
+ });
159
+ decipher.setAuthTag(authTag);
160
+ return decipher;
161
+ }
162
+
112
163
  module.exports = {
113
164
  encrypt,
114
165
  decrypt,
115
166
  deriveKey,
116
167
  packEncryptionMeta,
117
168
  unpackEncryptionMeta,
169
+ generateSalt,
170
+ createEncryptStream,
171
+ createDecryptStream,
118
172
  };
@@ -100,7 +100,7 @@ async function readDocxBase64(docxPath) {
100
100
  const metadataStart = fullText.indexOf(metadataMarker);
101
101
 
102
102
  if (metadataStart === -1) {
103
- throw new Error('No metadata found in DOCX file. This may not be a whitener-encoded file.');
103
+ throw new Error('No metadata found in DOCX file. This may not be a stegdoc-encoded file.');
104
104
  }
105
105
 
106
106
  // Find the separator "---" which comes after the metadata
@@ -36,8 +36,9 @@ function createMetadata({
36
36
  encrypted,
37
37
  compressed,
38
38
  contentHash,
39
+ pipelineOrder: 'compress-encrypt-base64',
39
40
  encodingDate: new Date().toISOString(),
40
- version: '1.0.1',
41
+ version: '4.0.0',
41
42
  tool: 'stegdoc',
42
43
  };
43
44
  }
@@ -79,7 +80,7 @@ function validateMetadata(metadata) {
79
80
  }
80
81
  }
81
82
 
82
- if (metadata.tool !== 'stegdoc' && metadata.tool !== 'docstash' && metadata.tool !== 'whitener') {
83
+ if (metadata.tool !== 'stegdoc' && metadata.tool !== 'whitener') {
83
84
  throw new Error('Invalid tool identifier in metadata');
84
85
  }
85
86
 
@@ -102,10 +103,20 @@ function isMultiPart(metadata) {
102
103
  return metadata.totalParts !== null && metadata.totalParts > 1;
103
104
  }
104
105
 
106
+ /**
107
+ * Check if metadata indicates the v4 streaming format
108
+ * @param {object} metadata - Metadata object
109
+ * @returns {boolean} True if streaming format (v4+)
110
+ */
111
+ function isStreamingFormat(metadata) {
112
+ return metadata.pipelineOrder === 'compress-encrypt-base64';
113
+ }
114
+
105
115
  module.exports = {
106
116
  createMetadata,
107
117
  serializeMetadata,
108
118
  parseMetadata,
109
119
  validateMetadata,
110
120
  isMultiPart,
121
+ isStreamingFormat,
111
122
  };
@@ -0,0 +1,197 @@
1
+ const { Transform, Writable } = require('stream');
2
+ const crypto = require('crypto');
3
+
4
+ /**
5
+ * Transform stream that converts binary input to base64 text output.
6
+ * Buffers incomplete 3-byte groups across chunk boundaries.
7
+ */
8
+ class Base64EncodeTransform extends Transform {
9
+ constructor() {
10
+ super();
11
+ this._remainder = Buffer.alloc(0);
12
+ }
13
+
14
+ _transform(chunk, encoding, callback) {
15
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, encoding);
16
+ const combined = this._remainder.length > 0 ? Buffer.concat([this._remainder, buf]) : buf;
17
+ const usable = combined.length - (combined.length % 3);
18
+ if (usable > 0) {
19
+ this.push(combined.slice(0, usable).toString('base64'));
20
+ }
21
+ this._remainder = usable < combined.length ? combined.slice(usable) : Buffer.alloc(0);
22
+ callback();
23
+ }
24
+
25
+ _flush(callback) {
26
+ if (this._remainder.length > 0) {
27
+ this.push(this._remainder.toString('base64'));
28
+ this._remainder = Buffer.alloc(0);
29
+ }
30
+ callback();
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Transform stream that converts base64 text input to binary output.
36
+ * Buffers incomplete 4-char groups across chunk boundaries.
37
+ */
38
+ class Base64DecodeTransform extends Transform {
39
+ constructor() {
40
+ super();
41
+ this._remainder = '';
42
+ }
43
+
44
+ _transform(chunk, encoding, callback) {
45
+ const str = this._remainder + (Buffer.isBuffer(chunk) ? chunk.toString() : chunk);
46
+ const usable = str.length - (str.length % 4);
47
+ if (usable > 0) {
48
+ this.push(Buffer.from(str.slice(0, usable), 'base64'));
49
+ }
50
+ this._remainder = usable < str.length ? str.slice(usable) : '';
51
+ callback();
52
+ }
53
+
54
+ _flush(callback) {
55
+ if (this._remainder.length > 0) {
56
+ this.push(Buffer.from(this._remainder, 'base64'));
57
+ this._remainder = '';
58
+ }
59
+ callback();
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Transform stream that passes data through unchanged while computing SHA-256 hash.
65
+ * Access the hex hash via .digest after the stream has ended.
66
+ */
67
+ class HashPassthrough extends Transform {
68
+ constructor() {
69
+ super();
70
+ this._hash = crypto.createHash('sha256');
71
+ this._finalized = false;
72
+ }
73
+
74
+ _transform(chunk, encoding, callback) {
75
+ this._hash.update(chunk);
76
+ this.push(chunk);
77
+ callback();
78
+ }
79
+
80
+ _flush(callback) {
81
+ this._finalized = true;
82
+ callback();
83
+ }
84
+
85
+ get digest() {
86
+ if (!this._finalized) {
87
+ throw new Error('Cannot read digest before stream has ended');
88
+ }
89
+ return this._hash.digest('hex');
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Writable stream that collects string output up to maxBytes.
95
+ * Calls an async onChunkReady callback when a chunk is full, applying
96
+ * backpressure to pause upstream until the callback resolves.
97
+ */
98
+ class ChunkCollector extends Writable {
99
+ constructor(maxBytes, onChunkReady) {
100
+ super({ decodeStrings: false });
101
+ this._maxBytes = maxBytes;
102
+ this._buffer = '';
103
+ this._chunkIndex = 0;
104
+ this._onChunkReady = onChunkReady;
105
+ }
106
+
107
+ async _write(chunk, encoding, callback) {
108
+ try {
109
+ this._buffer += typeof chunk === 'string' ? chunk : chunk.toString();
110
+ while (this._buffer.length >= this._maxBytes) {
111
+ const piece = this._buffer.slice(0, this._maxBytes);
112
+ this._buffer = this._buffer.slice(this._maxBytes);
113
+ await this._onChunkReady(piece, this._chunkIndex++);
114
+ }
115
+ callback();
116
+ } catch (err) {
117
+ callback(err);
118
+ }
119
+ }
120
+
121
+ async _final(callback) {
122
+ try {
123
+ if (this._buffer.length > 0) {
124
+ await this._onChunkReady(this._buffer, this._chunkIndex++);
125
+ this._buffer = '';
126
+ }
127
+ callback();
128
+ } catch (err) {
129
+ callback(err);
130
+ }
131
+ }
132
+
133
+ get totalChunks() {
134
+ return this._chunkIndex;
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Writable stream that collects binary Buffer output up to maxBytes.
140
+ * Calls an async onChunkReady callback with a Buffer when full.
141
+ */
142
+ class BinaryChunkCollector extends Writable {
143
+ constructor(maxBytes, onChunkReady) {
144
+ super();
145
+ this._maxBytes = maxBytes;
146
+ this._buffers = [];
147
+ this._currentSize = 0;
148
+ this._chunkIndex = 0;
149
+ this._onChunkReady = onChunkReady;
150
+ }
151
+
152
+ async _write(chunk, encoding, callback) {
153
+ try {
154
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, encoding);
155
+ this._buffers.push(buf);
156
+ this._currentSize += buf.length;
157
+
158
+ while (this._currentSize >= this._maxBytes) {
159
+ const combined = Buffer.concat(this._buffers);
160
+ const piece = combined.slice(0, this._maxBytes);
161
+ const leftover = combined.slice(this._maxBytes);
162
+ this._buffers = leftover.length > 0 ? [leftover] : [];
163
+ this._currentSize = leftover.length;
164
+ await this._onChunkReady(piece, this._chunkIndex++);
165
+ }
166
+ callback();
167
+ } catch (err) {
168
+ callback(err);
169
+ }
170
+ }
171
+
172
+ async _final(callback) {
173
+ try {
174
+ if (this._currentSize > 0) {
175
+ const combined = Buffer.concat(this._buffers);
176
+ await this._onChunkReady(combined, this._chunkIndex++);
177
+ this._buffers = [];
178
+ this._currentSize = 0;
179
+ }
180
+ callback();
181
+ } catch (err) {
182
+ callback(err);
183
+ }
184
+ }
185
+
186
+ get totalChunks() {
187
+ return this._chunkIndex;
188
+ }
189
+ }
190
+
191
+ module.exports = {
192
+ Base64EncodeTransform,
193
+ Base64DecodeTransform,
194
+ HashPassthrough,
195
+ ChunkCollector,
196
+ BinaryChunkCollector,
197
+ };
package/src/lib/utils.js CHANGED
@@ -100,13 +100,13 @@ function generateFilename(hash, partNumber = null, totalParts = null, format = '
100
100
 
101
101
  if (format === 'xlsx') {
102
102
  // Server metrics theme - looks like periodic 4-hour monitoring exports
103
- if (partNumber !== null && totalParts !== null) {
103
+ if (partNumber !== null) {
104
104
  return `server_metrics_${dateStr}_${timeStr}_${reportId}_part${partNumber}.${ext}`;
105
105
  }
106
106
  return `server_metrics_${dateStr}_${timeStr}_${reportId}.${ext}`;
107
107
  } else {
108
108
  // DOCX - use a different theme
109
- if (partNumber !== null && totalParts !== null) {
109
+ if (partNumber !== null) {
110
110
  return `system_report_${dateStr}_${timeStr}_${reportId}_part${partNumber}.${ext}`;
111
111
  }
112
112
  return `system_report_${dateStr}_${timeStr}_${reportId}.${ext}`;