stegdoc 5.7.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/src/lib/crypto.js CHANGED
@@ -18,75 +18,6 @@ function deriveKey(password, salt) {
18
18
  return crypto.pbkdf2Sync(password, salt, PBKDF2_ITERATIONS, KEY_LENGTH, 'sha256');
19
19
  }
20
20
 
21
- /**
22
- * Encrypt plaintext using AES-256-GCM
23
- * @param {string} plaintext - Data to encrypt
24
- * @param {string} password - User password
25
- * @returns {object} { ciphertext, iv, salt, authTag } - all as base64 strings
26
- */
27
- function encrypt(plaintext, password) {
28
- // Generate random salt and IV
29
- const salt = crypto.randomBytes(SALT_LENGTH);
30
- const iv = crypto.randomBytes(IV_LENGTH);
31
-
32
- // Derive key from password
33
- const key = deriveKey(password, salt);
34
-
35
- // Create cipher and encrypt
36
- const cipher = crypto.createCipheriv(ALGORITHM, key, iv, {
37
- authTagLength: AUTH_TAG_LENGTH,
38
- });
39
-
40
- let ciphertext = cipher.update(plaintext, 'utf8', 'base64');
41
- ciphertext += cipher.final('base64');
42
-
43
- // Get authentication tag
44
- const authTag = cipher.getAuthTag();
45
-
46
- return {
47
- ciphertext,
48
- iv: iv.toString('base64'),
49
- salt: salt.toString('base64'),
50
- authTag: authTag.toString('base64'),
51
- };
52
- }
53
-
54
- /**
55
- * Decrypt ciphertext using AES-256-GCM
56
- * @param {string} ciphertext - Encrypted data (base64)
57
- * @param {string} password - User password
58
- * @param {string} ivBase64 - Initialization vector (base64)
59
- * @param {string} saltBase64 - Salt used for key derivation (base64)
60
- * @param {string} authTagBase64 - Authentication tag (base64)
61
- * @returns {string} Decrypted plaintext
62
- * @throws {Error} If decryption fails (wrong password or tampered data)
63
- */
64
- function decrypt(ciphertext, password, ivBase64, saltBase64, authTagBase64) {
65
- // Convert from base64
66
- const iv = Buffer.from(ivBase64, 'base64');
67
- const salt = Buffer.from(saltBase64, 'base64');
68
- const authTag = Buffer.from(authTagBase64, 'base64');
69
-
70
- // Derive key from password
71
- const key = deriveKey(password, salt);
72
-
73
- // Create decipher
74
- const decipher = crypto.createDecipheriv(ALGORITHM, key, iv, {
75
- authTagLength: AUTH_TAG_LENGTH,
76
- });
77
-
78
- // Set auth tag for verification
79
- decipher.setAuthTag(authTag);
80
-
81
- try {
82
- let plaintext = decipher.update(ciphertext, 'base64', 'utf8');
83
- plaintext += decipher.final('utf8');
84
- return plaintext;
85
- } catch (error) {
86
- throw new Error('Decryption failed: Invalid password or corrupted data');
87
- }
88
- }
89
-
90
21
  /**
91
22
  * Pack encryption metadata into a single string for storage
92
23
  * @param {object} encryptionData - { iv, salt, authTag }
@@ -96,19 +27,6 @@ function packEncryptionMeta(encryptionData) {
96
27
  return `${encryptionData.iv}:${encryptionData.salt}:${encryptionData.authTag}`;
97
28
  }
98
29
 
99
- /**
100
- * Unpack encryption metadata from storage string
101
- * @param {string} packed - Packed string (iv:salt:authTag)
102
- * @returns {object} { iv, salt, authTag }
103
- */
104
- function unpackEncryptionMeta(packed) {
105
- const [iv, salt, authTag] = packed.split(':');
106
- if (!iv || !salt || !authTag) {
107
- throw new Error('Invalid encryption metadata format');
108
- }
109
- return { iv, salt, authTag };
110
- }
111
-
112
30
  /**
113
31
  * Generate a random salt for key derivation (one per encode session)
114
32
  * @returns {Buffer} Random salt
@@ -139,34 +57,9 @@ function createEncryptStream(password, salt) {
139
57
  };
140
58
  }
141
59
 
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
-
163
60
  module.exports = {
164
- encrypt,
165
- decrypt,
166
61
  deriveKey,
167
62
  packEncryptionMeta,
168
- unpackEncryptionMeta,
169
63
  generateSalt,
170
64
  createEncryptStream,
171
- createDecryptStream,
172
65
  };
@@ -1,16 +1,11 @@
1
1
  const {
2
2
  Document, Paragraph, TextRun, Packer, Table, TableRow, TableCell,
3
- AlignmentType, HeadingLevel, WidthType, ShadingType, BorderStyle, PageBreak,
3
+ AlignmentType, HeadingLevel, WidthType, ShadingType,
4
4
  } = require('docx');
5
5
  const fs = require('fs');
6
6
  const path = require('path');
7
- const { serializeMetadata, parseMetadata } = require('./metadata');
8
- const { parseXmlFromZip, ensureArray, extractTextContent } = require('./xml-utils');
9
7
  const { generateIncident, generateHebrewDate } = require('./docx-templates');
10
- const {
11
- encodePayloadToLogLines, decodeLogLines, generateLogHeaders, resetTimeState,
12
- BYTES_PER_DATA_LINE, calculateDataLineCount,
13
- } = require('./log-generator');
8
+ const { encodePayloadToLogLines, resetTimeState } = require('./log-generator');
14
9
 
15
10
  // ─── Shared Styles ──────────────────────────────────────────────────────────
16
11
 
@@ -311,277 +306,6 @@ function createHebrewCell(text, isHeader, widthPct) {
311
306
  });
312
307
  }
313
308
 
314
- // ─── v5 DOCX Reader ─────────────────────────────────────────────────────────
315
-
316
- /**
317
- * Read a v5 log-embed DOCX file and extract payload.
318
- * Scans for monospace paragraphs that contain log lines.
319
- * @param {string} docxPath - Path to DOCX file
320
- * @returns {object} { payloadBuffer, metadataJson, encryptionMeta, metadata, formatVersion }
321
- */
322
- async function readDocxV5(docxPath) {
323
- const docParsed = parseXmlFromZip(docxPath, 'word/document.xml');
324
- if (!docParsed) {
325
- throw new Error('Could not find document.xml in DOCX file');
326
- }
327
-
328
- // Also parse styles to identify monospace/code runs
329
- const stylesParsed = parseXmlFromZip(docxPath, 'word/styles.xml');
330
-
331
- // Extract all paragraphs with their formatting info
332
- const body = docParsed?.document?.body;
333
- if (!body) throw new Error('Empty document body');
334
-
335
- const paragraphs = ensureArray(body.p);
336
- const logLines = [];
337
-
338
- for (const para of paragraphs) {
339
- const runs = ensureArray(para.r);
340
- let paraText = '';
341
- let isCode = false;
342
-
343
- for (const run of runs) {
344
- if (run.t !== undefined) {
345
- paraText += extractTextContent(run.t);
346
- }
347
-
348
- // Check if run uses monospace font (Consolas/Courier)
349
- const rPr = run.rPr;
350
- if (rPr) {
351
- const fonts = rPr.rFonts;
352
- if (fonts) {
353
- const fontName = fonts['@_w:ascii'] || fonts['@_ascii'] || '';
354
- if (/consolas|courier/i.test(fontName)) {
355
- isCode = true;
356
- }
357
- }
358
- }
359
- }
360
-
361
- // Also check paragraph-level shading as indicator of code block
362
- const pPr = para.pPr;
363
- if (pPr && pPr.shd) {
364
- const fill = pPr.shd['@_w:fill'] || pPr.shd['@_fill'] || '';
365
- if (fill === 'F2F2F2' || fill === 'f2f2f2') {
366
- isCode = true;
367
- }
368
- }
369
-
370
- // Collect code paragraphs that look like log lines
371
- if (isCode && paraText.trim().length > 0) {
372
- // Skip the comment line
373
- if (paraText.startsWith('//')) continue;
374
- logLines.push(paraText.trim());
375
- }
376
- }
377
-
378
- if (logLines.length === 0) {
379
- throw new Error('No log lines found in DOCX file. This may not be a v5 stegdoc file.');
380
- }
381
-
382
- // Parse log lines back into row arrays
383
- const rows = logLines.map(parseLogLine);
384
-
385
- // Decode using the same engine as XLSX
386
- return decodeLogLines(rows);
387
- }
388
-
389
- /**
390
- * Parse a formatted log line string back into a row array.
391
- * Input format: `IP - - [timestamp] "request" status bytes "referer" "ua" "requestId" "traceId"`
392
- */
393
- function parseLogLine(line) {
394
- // Regex to parse nginx combined log format with extra fields
395
- const regex = /^(\S+)\s+-\s+-\s+(\[[^\]]+\])\s+"([^"]+)"\s+(\d+)\s+(\d+)\s+"([^"]+)"\s+"([^"]+)"\s+"([^"]+)"\s+"([^"]+)"$/;
396
- const match = line.match(regex);
397
-
398
- if (!match) {
399
- // Fallback: try to extract what we can
400
- return ['', '', '', line, '', '', '', '', '', ''];
401
- }
402
-
403
- const [, ip, timestamp, request, status, bytes, referer, ua, requestId, traceId] = match;
404
-
405
- // Extract method from request
406
- const methodMatch = request.match(/^(\w+)\s/);
407
- const method = methodMatch ? methodMatch[1] : '';
408
-
409
- return [ip, timestamp, method, request, status, bytes, referer, ua, requestId, traceId];
410
- }
411
-
412
- /**
413
- * Detect if a DOCX file is v5 (log-embed) format.
414
- * Checks for the presence of log-formatted monospace content.
415
- */
416
- function detectDocxVersion(docxPath) {
417
- try {
418
- const docParsed = parseXmlFromZip(docxPath, 'word/document.xml');
419
- if (!docParsed) return 'legacy';
420
-
421
- const body = docParsed?.document?.body;
422
- if (!body) return 'legacy';
423
-
424
- // Quick check: look for the STGD05 marker in raw text
425
- const paragraphs = ensureArray(body.p);
426
- for (const para of paragraphs) {
427
- const runs = ensureArray(para.r);
428
- for (const run of runs) {
429
- const text = extractTextContent(run.t || '');
430
- if (text.includes('/api/v1/health/')) {
431
- return 'v5';
432
- }
433
- }
434
- }
435
-
436
- // Check for WHITENER_METADATA (legacy)
437
- for (const para of paragraphs) {
438
- const runs = ensureArray(para.r);
439
- for (const run of runs) {
440
- const text = extractTextContent(run.t || '');
441
- if (text.includes('WHITENER_METADATA:')) {
442
- return 'legacy';
443
- }
444
- }
445
- }
446
-
447
- return 'legacy';
448
- } catch {
449
- return 'legacy';
450
- }
451
- }
452
-
453
- // ─── Legacy DOCX (v3/v4) ───────────────────────────────────────────────────
454
-
455
- /**
456
- * Create a legacy DOCX file with base64 content (v3/v4 format)
457
- */
458
- async function createDocxWithBase64(options) {
459
- const { base64Content, metadata, outputPath } = options;
460
-
461
- const metadataStr = serializeMetadata(metadata);
462
-
463
- const doc = new Document({
464
- sections: [{
465
- properties: {},
466
- children: [
467
- new Paragraph({
468
- children: [
469
- new TextRun({
470
- text: `WHITENER_METADATA:${metadataStr}`,
471
- size: 1,
472
- }),
473
- ],
474
- }),
475
- new Paragraph({
476
- children: [
477
- new TextRun({ text: '---', break: 1 }),
478
- ],
479
- }),
480
- new Paragraph({
481
- children: [
482
- new TextRun({
483
- text: base64Content,
484
- font: 'Courier New',
485
- size: 16,
486
- }),
487
- ],
488
- }),
489
- ],
490
- }],
491
- });
492
-
493
- const buffer = await Packer.toBuffer(doc);
494
-
495
- const outputDir = path.dirname(outputPath);
496
- if (!fs.existsSync(outputDir)) {
497
- fs.mkdirSync(outputDir, { recursive: true });
498
- }
499
-
500
- fs.writeFileSync(outputPath, buffer);
501
- return outputPath;
502
- }
503
-
504
- // ─── Unified Reader ─────────────────────────────────────────────────────────
505
-
506
- /**
507
- * Read a DOCX file, auto-detecting v5 vs legacy format.
508
- */
509
- async function readDocxBase64(docxPath) {
510
- if (!fs.existsSync(docxPath)) {
511
- throw new Error(`DOCX file not found: ${docxPath}`);
512
- }
513
-
514
- const version = detectDocxVersion(docxPath);
515
-
516
- if (version === 'v5') {
517
- const result = await readDocxV5(docxPath);
518
- return {
519
- ...result,
520
- formatVersion: 'v5',
521
- };
522
- }
523
-
524
- // Legacy path
525
- try {
526
- const docParsed = parseXmlFromZip(docxPath, 'word/document.xml');
527
- if (!docParsed) {
528
- throw new Error('Could not find document.xml in DOCX file');
529
- }
530
-
531
- const fullText = extractAllText(docParsed);
532
- const metadataMarker = 'WHITENER_METADATA:';
533
- const metadataStart = fullText.indexOf(metadataMarker);
534
-
535
- if (metadataStart === -1) {
536
- throw new Error('No metadata found in DOCX file. This may not be a stegdoc-encoded file.');
537
- }
538
-
539
- const separatorIndex = fullText.indexOf('---', metadataStart);
540
- if (separatorIndex === -1) {
541
- throw new Error('Invalid file format: separator not found');
542
- }
543
-
544
- const metadataStr = fullText.substring(metadataStart + metadataMarker.length, separatorIndex).trim();
545
- const metadata = parseMetadata(metadataStr);
546
- const base64Content = fullText.substring(separatorIndex + 3).trim();
547
-
548
- return {
549
- base64Content,
550
- metadata,
551
- formatVersion: 'legacy',
552
- };
553
- } catch (error) {
554
- throw new Error(`Failed to read DOCX file: ${error.message}`);
555
- }
556
- }
557
-
558
- /**
559
- * Extract all text content from parsed DOCX document (legacy)
560
- */
561
- function extractAllText(docParsed) {
562
- let fullText = '';
563
- const body = docParsed?.document?.body;
564
- if (!body) return fullText;
565
-
566
- const paragraphs = ensureArray(body.p);
567
- for (const para of paragraphs) {
568
- const runs = ensureArray(para.r);
569
- for (const run of runs) {
570
- if (run.t !== undefined) {
571
- fullText += extractTextContent(run.t);
572
- }
573
- }
574
- }
575
- return fullText;
576
- }
577
-
578
309
  module.exports = {
579
- // v5
580
310
  createDocxV5,
581
- readDocxV5,
582
- detectDocxVersion,
583
- // Legacy
584
- createDocxWithBase64,
585
- // Unified
586
- readDocxBase64,
587
311
  };
@@ -657,9 +657,12 @@ function decodeLogLines(allRows) {
657
657
  const headerStr = headerPayload.toString('utf8');
658
658
 
659
659
  // Parse header: "STGD05|<metaLen>|<encLen>|{metadataJson}{encryptionMeta}"
660
- const markerIdx = headerStr.indexOf('STGD05|');
660
+ const markerIdx = headerStr.search(/STGD0[56]\|/);
661
661
  if (markerIdx === -1) {
662
- throw new Error('Invalid v5 format: magic marker not found. This may not be a stegdoc v5 file.');
662
+ throw new Error('Invalid log-embed format: magic marker not found. This may not be a stegdoc file.');
663
+ }
664
+ if (headerStr.startsWith('STGD06|', markerIdx)) {
665
+ throw new Error('This file uses the v6 format, which the JavaScript decoder cannot read. The native engine is required.');
663
666
  }
664
667
 
665
668
  const afterMarker = headerStr.slice(markerIdx + 7);
@@ -24,15 +24,11 @@ function createMetadata({
24
24
  encrypted = true,
25
25
  compressed = false,
26
26
  contentHash = null,
27
- // v5 fields
28
- stegoMethod = null,
29
27
  compressionAlgo = null,
30
28
  payloadSize = null,
31
29
  dataLineCount = null,
32
30
  headerLineCount = null,
33
31
  }) {
34
- const isV5 = stegoMethod === 'log-embed';
35
-
36
32
  const meta = {
37
33
  originalFilename,
38
34
  originalExtension,
@@ -44,20 +40,17 @@ function createMetadata({
44
40
  encrypted,
45
41
  compressed,
46
42
  contentHash,
47
- pipelineOrder: isV5 ? 'brotli-encrypt-logEmbed' : 'compress-encrypt-base64',
43
+ pipelineOrder: 'brotli-encrypt-logEmbed',
48
44
  encodingDate: new Date().toISOString(),
49
- version: isV5 ? '5.0.0' : '4.0.0',
45
+ version: '5.0.0',
50
46
  tool: 'stegdoc',
47
+ stegoMethod: 'log-embed',
48
+ compressionAlgo: compressionAlgo || 'brotli',
51
49
  };
52
50
 
53
- // v5-specific fields
54
- if (isV5) {
55
- meta.stegoMethod = 'log-embed';
56
- meta.compressionAlgo = compressionAlgo || 'brotli';
57
- if (payloadSize !== null) meta.payloadSize = payloadSize;
58
- if (dataLineCount !== null) meta.dataLineCount = dataLineCount;
59
- if (headerLineCount !== null) meta.headerLineCount = headerLineCount;
60
- }
51
+ if (payloadSize !== null) meta.payloadSize = payloadSize;
52
+ if (dataLineCount !== null) meta.dataLineCount = dataLineCount;
53
+ if (headerLineCount !== null) meta.headerLineCount = headerLineCount;
61
54
 
62
55
  return meta;
63
56
  }
@@ -71,81 +64,7 @@ function serializeMetadata(metadata) {
71
64
  return JSON.stringify(metadata);
72
65
  }
73
66
 
74
- /**
75
- * Parse metadata from string
76
- * @param {string} metadataStr - JSON string
77
- * @returns {object} Metadata object
78
- */
79
- function parseMetadata(metadataStr) {
80
- try {
81
- return JSON.parse(metadataStr);
82
- } catch (error) {
83
- throw new Error(`Failed to parse metadata: ${error.message}`);
84
- }
85
- }
86
-
87
- /**
88
- * Validate metadata object
89
- * @param {object} metadata - Metadata to validate
90
- * @returns {boolean} True if valid
91
- * @throws {Error} If metadata is invalid
92
- */
93
- function validateMetadata(metadata) {
94
- const required = ['originalFilename', 'originalExtension', 'hash', 'tool'];
95
-
96
- for (const field of required) {
97
- if (!metadata[field]) {
98
- throw new Error(`Missing required metadata field: ${field}`);
99
- }
100
- }
101
-
102
- if (metadata.tool !== 'stegdoc' && metadata.tool !== 'whitener') {
103
- throw new Error('Invalid tool identifier in metadata');
104
- }
105
-
106
- // If it's a multi-part file, validate part info
107
- if (metadata.totalParts !== null && metadata.totalParts > 1) {
108
- if (metadata.partNumber === null || metadata.partNumber < 1 || metadata.partNumber > metadata.totalParts) {
109
- throw new Error('Invalid part number in metadata');
110
- }
111
- }
112
-
113
- return true;
114
- }
115
-
116
- /**
117
- * Check if metadata indicates a multi-part file
118
- * @param {object} metadata - Metadata object
119
- * @returns {boolean} True if multi-part
120
- */
121
- function isMultiPart(metadata) {
122
- return metadata.totalParts !== null && metadata.totalParts > 1;
123
- }
124
-
125
- /**
126
- * Check if metadata indicates the v4 streaming format
127
- * @param {object} metadata - Metadata object
128
- * @returns {boolean} True if streaming format (v4+)
129
- */
130
- function isStreamingFormat(metadata) {
131
- return metadata.pipelineOrder === 'compress-encrypt-base64';
132
- }
133
-
134
- /**
135
- * Check if metadata indicates the v5 log-embed format
136
- * @param {object} metadata - Metadata object
137
- * @returns {boolean} True if log-embed format (v5+)
138
- */
139
- function isLogEmbedFormat(metadata) {
140
- return metadata.stegoMethod === 'log-embed' || metadata.pipelineOrder === 'brotli-encrypt-logEmbed';
141
- }
142
-
143
67
  module.exports = {
144
68
  createMetadata,
145
69
  serializeMetadata,
146
- parseMetadata,
147
- validateMetadata,
148
- isMultiPart,
149
- isStreamingFormat,
150
- isLogEmbedFormat,
151
70
  };
@@ -0,0 +1,68 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+
5
+ const { hostTarget, packageName } = require('./bindings');
6
+
7
+ const NATIVE_DIR = path.join(__dirname, '..', '..', 'native');
8
+
9
+ let binding;
10
+ let resolved = false;
11
+
12
+ /**
13
+ * Load the native format engine, or return null when it is not available.
14
+ *
15
+ * Resolution order:
16
+ * 1. `STEGDOC_NATIVE_PATH`, an explicit `.node` file.
17
+ * 2. The platform package (`@stegdoc/binding-<suffix>`) from the published
18
+ * install's optionalDependencies.
19
+ * 3. `native/stegdoc.node`, the local `pnpm build:native` output.
20
+ *
21
+ * The binding is required for log-embed (v5/v6) decode and is the preferred
22
+ * encoder. The CommonJS v5 encoder survives only as a fallback for hosts where
23
+ * the binding cannot be loaded. Set `STEGDOC_DISABLE_NATIVE=1` to force that
24
+ * fallback; it does not affect decode, which has no JS path.
25
+ *
26
+ * @returns {object|null} The binding, or null when unavailable.
27
+ */
28
+ function loadNative() {
29
+ if (resolved) return binding;
30
+ resolved = true;
31
+
32
+ if (process.env.STEGDOC_DISABLE_NATIVE === '1') {
33
+ binding = null;
34
+ return binding;
35
+ }
36
+
37
+ const explicit = process.env.STEGDOC_NATIVE_PATH;
38
+ if (explicit) {
39
+ binding = require(explicit);
40
+ return binding;
41
+ }
42
+
43
+ const target = hostTarget();
44
+ if (target) {
45
+ try {
46
+ binding = require(packageName(target));
47
+ return binding;
48
+ } catch {
49
+ // Not installed; fall through to the development build.
50
+ }
51
+ }
52
+
53
+ try {
54
+ binding = require(path.join(NATIVE_DIR, 'stegdoc.node'));
55
+ } catch {
56
+ binding = null;
57
+ }
58
+ return binding;
59
+ }
60
+
61
+ /**
62
+ * @returns {boolean} Whether the native engine is loaded.
63
+ */
64
+ function nativeAvailable() {
65
+ return loadNative() !== null;
66
+ }
67
+
68
+ module.exports = { loadNative, nativeAvailable };