obscr 0.2.1 → 1.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/bin/index.js +94 -42
- package/{bin/utils → lib}/crypto.js +67 -22
- package/{bin/utils → lib}/mersenne-twister.js +2 -1
- package/{bin/utils → lib}/steg.js +49 -34
- package/{bin/utils → lib}/utils.js +25 -5
- package/package.json +42 -5
- package/CHANGELOG.md +0 -92
package/bin/index.js
CHANGED
|
@@ -6,11 +6,12 @@ const { prompt } = require("inquirer");
|
|
|
6
6
|
const ora = require("ora");
|
|
7
7
|
const boxen = require("boxen");
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
// Shared core logic (reused by CLI and desktop UI)
|
|
10
|
+
const { encrypt, decrypt } = require("../lib/crypto");
|
|
10
11
|
const {
|
|
11
12
|
encodeMessageToImage,
|
|
12
13
|
extractMessageFromImage,
|
|
13
|
-
} = require("
|
|
14
|
+
} = require("../lib/steg");
|
|
14
15
|
|
|
15
16
|
// NOTE: This is just to increase obfuscation for steganography, NOT used for AES encryption
|
|
16
17
|
// Kept for backward compatibility with images encoded using older versions
|
|
@@ -20,35 +21,67 @@ const SECRET_KEY = "S3cReTK3Y";
|
|
|
20
21
|
let VERBOSE = false;
|
|
21
22
|
let QUIET = false;
|
|
22
23
|
|
|
24
|
+
// Color palette - using custom green #B4FA72 for consistent branding
|
|
25
|
+
const colors = {
|
|
26
|
+
primary: chalk.hex("#B4FA72"), // Custom lime green
|
|
27
|
+
secondary: chalk.hex("#B4FA72"), // Same green for consistency
|
|
28
|
+
accent: chalk.hex("#B4FA72"), // Same green
|
|
29
|
+
muted: chalk.gray, // Gray for secondary text
|
|
30
|
+
error: chalk.red,
|
|
31
|
+
warning: chalk.yellow,
|
|
32
|
+
dim: chalk.hex("#B4FA72").dim, // Dimmed custom green for subtle text
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* ASCII Art Logo (matching the UI)
|
|
37
|
+
*/
|
|
38
|
+
const asciiLogo = `
|
|
39
|
+
██████╗ ██████╗ ███████╗ ██████╗██████╗
|
|
40
|
+
██╔═══██╗██╔══██╗██╔════╝██╔════╝██╔══██╗
|
|
41
|
+
██║ ██║██████╔╝███████╗██║ ██████╔╝
|
|
42
|
+
██║ ██║██╔══██╗╚════██║██║ ██╔══██╗
|
|
43
|
+
╚██████╔╝██████╔╝███████║╚██████╗██║ ██║
|
|
44
|
+
╚═════╝ ╚═════╝ ╚══════╝ ╚═════╝╚═╝ ╚═╝
|
|
45
|
+
`;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Terminal-style controls (for visual effect)
|
|
49
|
+
*/
|
|
50
|
+
const terminalControls = chalk.red("●") + " " + chalk.yellow("●") + " " + chalk.green("●");
|
|
51
|
+
|
|
23
52
|
/**
|
|
24
53
|
* Logging utilities
|
|
25
54
|
*/
|
|
26
55
|
const log = {
|
|
27
|
-
info: (msg) => !QUIET && console.log(
|
|
28
|
-
success: (msg) => !QUIET && console.log(
|
|
29
|
-
error: (msg) => console.error(
|
|
30
|
-
warn: (msg) => !QUIET && console.log(
|
|
31
|
-
verbose: (msg) => VERBOSE && console.log(
|
|
56
|
+
info: (msg) => !QUIET && console.log(colors.accent("ℹ"), msg),
|
|
57
|
+
success: (msg) => !QUIET && console.log(colors.primary("✔"), msg),
|
|
58
|
+
error: (msg) => console.error(colors.error("✖"), msg),
|
|
59
|
+
warn: (msg) => !QUIET && console.log(colors.warning("⚠"), msg),
|
|
60
|
+
verbose: (msg) => VERBOSE && console.log(colors.dim("→"), msg),
|
|
32
61
|
box: (msg, options = {}) => !QUIET && console.log(boxen(msg, {
|
|
33
62
|
padding: 1,
|
|
34
63
|
margin: 1,
|
|
35
64
|
borderStyle: "round",
|
|
36
|
-
borderColor: "
|
|
65
|
+
borderColor: "#B4FA72", // Custom lime green
|
|
37
66
|
...options
|
|
38
67
|
})),
|
|
39
68
|
};
|
|
40
69
|
|
|
41
70
|
/**
|
|
42
|
-
* Display welcome banner
|
|
71
|
+
* Display welcome banner with ASCII art
|
|
43
72
|
*/
|
|
44
73
|
function showBanner() {
|
|
45
74
|
if (QUIET) return;
|
|
46
75
|
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
76
|
+
const header = terminalControls;
|
|
77
|
+
const logo = colors.primary(asciiLogo);
|
|
78
|
+
const subtitle = colors.primary("$") + " " + chalk.white("Steganography & Encryption Tool");
|
|
79
|
+
const info = colors.muted("AES-256-GCM Encryption | LSB Steganography | Secure Data Hiding");
|
|
80
|
+
const version = colors.dim("v1.0.0");
|
|
50
81
|
|
|
51
|
-
|
|
82
|
+
const banner = `${header}\n${logo}\n${subtitle}\n${info}\n\n${version}`;
|
|
83
|
+
|
|
84
|
+
log.box(banner, { borderColor: "green", padding: 0, margin: { top: 1, bottom: 1, left: 1, right: 1 } });
|
|
52
85
|
}
|
|
53
86
|
|
|
54
87
|
/**
|
|
@@ -56,22 +89,22 @@ function showBanner() {
|
|
|
56
89
|
*/
|
|
57
90
|
function showExamples() {
|
|
58
91
|
const examples = `
|
|
59
|
-
${
|
|
92
|
+
${colors.primary.bold("Examples:")}
|
|
60
93
|
|
|
61
|
-
${
|
|
62
|
-
$ obscr encrypt -f photo.png -o secret.png --compress
|
|
94
|
+
${colors.primary(">")} ${colors.secondary("Encrypt with compression:")}
|
|
95
|
+
${colors.dim("$")} obscr encrypt -f photo.png -o secret.png --compress
|
|
63
96
|
|
|
64
|
-
${
|
|
65
|
-
$ obscr decrypt -f secret.png -o message.txt
|
|
97
|
+
${colors.primary(">")} ${colors.secondary("Decrypt to file:")}
|
|
98
|
+
${colors.dim("$")} obscr decrypt -f secret.png -o message.txt
|
|
66
99
|
|
|
67
|
-
${
|
|
68
|
-
$ obscr interactive
|
|
100
|
+
${colors.primary(">")} ${colors.secondary("Interactive mode:")}
|
|
101
|
+
${colors.dim("$")} obscr interactive
|
|
69
102
|
|
|
70
|
-
${
|
|
71
|
-
$ obscr encrypt -f image.png --verbose
|
|
103
|
+
${colors.primary(">")} ${colors.secondary("Verbose output:")}
|
|
104
|
+
${colors.dim("$")} obscr encrypt -f image.png --verbose
|
|
72
105
|
|
|
73
|
-
${
|
|
74
|
-
$ obscr decrypt -f image.png --quiet
|
|
106
|
+
${colors.primary(">")} ${colors.secondary("Quiet mode (minimal output):")}
|
|
107
|
+
${colors.dim("$")} obscr decrypt -f image.png --quiet
|
|
75
108
|
`;
|
|
76
109
|
|
|
77
110
|
console.log(examples);
|
|
@@ -88,10 +121,10 @@ async function interactiveMode() {
|
|
|
88
121
|
name: "action",
|
|
89
122
|
message: "What would you like to do?",
|
|
90
123
|
choices: [
|
|
91
|
-
{ name: "
|
|
92
|
-
{ name: "
|
|
93
|
-
{ name: "
|
|
94
|
-
{ name: "
|
|
124
|
+
{ name: colors.primary("[+]") + " Encrypt and hide a message in an image", value: "encrypt" },
|
|
125
|
+
{ name: colors.secondary("[-]") + " Decrypt and extract a message from an image", value: "decrypt" },
|
|
126
|
+
{ name: colors.accent("[?]") + " Show usage examples", value: "examples" },
|
|
127
|
+
{ name: colors.dim("[x]") + " Exit", value: "exit" },
|
|
95
128
|
],
|
|
96
129
|
});
|
|
97
130
|
|
|
@@ -260,13 +293,13 @@ async function encryptCommand(filename, output, compress, message, password) {
|
|
|
260
293
|
// Show summary
|
|
261
294
|
if (!QUIET) {
|
|
262
295
|
const summary = `
|
|
263
|
-
${
|
|
296
|
+
${colors.primary.bold("✔ Encryption Successful")}
|
|
264
297
|
|
|
265
|
-
${
|
|
266
|
-
${
|
|
267
|
-
${
|
|
268
|
-
${
|
|
269
|
-
${compress ?
|
|
298
|
+
${colors.primary(">")} ${colors.muted("Output:")} ${chalk.white(output)}
|
|
299
|
+
${colors.primary(">")} ${colors.muted("Capacity Used:")} ${colors.secondary(result.capacity.utilization)}
|
|
300
|
+
${colors.dim("Total:")} ${result.capacity.totalBits} bits
|
|
301
|
+
${colors.dim("Used:")} ${result.capacity.usedBits} bits
|
|
302
|
+
${compress ? colors.primary(">") + " " + colors.muted("Compression:") + " " + colors.secondary("Enabled") : ""}
|
|
270
303
|
`;
|
|
271
304
|
log.box(summary.trim(), { borderColor: "green" });
|
|
272
305
|
}
|
|
@@ -326,9 +359,10 @@ async function decryptCommand(filename, output, password) {
|
|
|
326
359
|
} else {
|
|
327
360
|
if (!QUIET) {
|
|
328
361
|
const messageBox = `
|
|
329
|
-
${
|
|
362
|
+
${colors.primary.bold("✔ Decryption Successful")}
|
|
363
|
+
|
|
364
|
+
${colors.primary(">")} ${colors.muted("Message:")}
|
|
330
365
|
|
|
331
|
-
${chalk.bold("Message:")}
|
|
332
366
|
${chalk.white(decrypted)}
|
|
333
367
|
`;
|
|
334
368
|
log.box(messageBox.trim(), { borderColor: "green" });
|
|
@@ -348,7 +382,10 @@ ${chalk.white(decrypted)}
|
|
|
348
382
|
}
|
|
349
383
|
|
|
350
384
|
// Parse arguments
|
|
351
|
-
const usage =
|
|
385
|
+
const usage = colors.primary("\nUsage: obscr <command> [options]");
|
|
386
|
+
|
|
387
|
+
// Track if a command was executed
|
|
388
|
+
let commandExecuted = false;
|
|
352
389
|
|
|
353
390
|
const argv = yargs
|
|
354
391
|
.usage(usage)
|
|
@@ -390,6 +427,7 @@ const argv = yargs
|
|
|
390
427
|
},
|
|
391
428
|
},
|
|
392
429
|
async (argv) => {
|
|
430
|
+
commandExecuted = true;
|
|
393
431
|
VERBOSE = argv.verbose;
|
|
394
432
|
QUIET = argv.quiet;
|
|
395
433
|
|
|
@@ -443,6 +481,7 @@ const argv = yargs
|
|
|
443
481
|
},
|
|
444
482
|
},
|
|
445
483
|
async (argv) => {
|
|
484
|
+
commandExecuted = true;
|
|
446
485
|
VERBOSE = argv.verbose;
|
|
447
486
|
QUIET = argv.quiet;
|
|
448
487
|
|
|
@@ -465,6 +504,7 @@ const argv = yargs
|
|
|
465
504
|
"Start interactive mode with guided workflow",
|
|
466
505
|
{},
|
|
467
506
|
async (argv) => {
|
|
507
|
+
commandExecuted = true;
|
|
468
508
|
VERBOSE = argv.verbose;
|
|
469
509
|
QUIET = argv.quiet;
|
|
470
510
|
await interactiveMode();
|
|
@@ -475,15 +515,27 @@ const argv = yargs
|
|
|
475
515
|
"Show usage examples",
|
|
476
516
|
{},
|
|
477
517
|
() => {
|
|
518
|
+
commandExecuted = true;
|
|
478
519
|
showBanner();
|
|
479
520
|
showExamples();
|
|
480
521
|
}
|
|
481
522
|
)
|
|
482
|
-
.epilog(`Run ${
|
|
523
|
+
.epilog(`Run ${colors.primary("obscr examples")} to see usage examples`)
|
|
483
524
|
.help()
|
|
484
525
|
.alias("help", "h")
|
|
485
|
-
.version("0.
|
|
526
|
+
.version("1.0.0")
|
|
486
527
|
.alias("version", "V")
|
|
487
|
-
.
|
|
488
|
-
|
|
489
|
-
|
|
528
|
+
.strict();
|
|
529
|
+
|
|
530
|
+
// Parse the arguments
|
|
531
|
+
const parsedArgv = argv.parse();
|
|
532
|
+
|
|
533
|
+
// If no command was executed and not showing help/version, enter interactive mode
|
|
534
|
+
if (!commandExecuted && !parsedArgv.help && !parsedArgv.version) {
|
|
535
|
+
VERBOSE = parsedArgv.verbose;
|
|
536
|
+
QUIET = parsedArgv.quiet;
|
|
537
|
+
interactiveMode().catch((err) => {
|
|
538
|
+
log.error(err.message);
|
|
539
|
+
process.exit(1);
|
|
540
|
+
});
|
|
541
|
+
}
|
|
@@ -70,32 +70,56 @@ const encrypt = async (message, password, compress = false) => {
|
|
|
70
70
|
* @throws {Error} If decryption fails
|
|
71
71
|
*/
|
|
72
72
|
const decrypt = async (encrypted, password) => {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
73
|
+
// Validate input
|
|
74
|
+
if (!encrypted || typeof encrypted !== "string") {
|
|
75
|
+
throw new Error(
|
|
76
|
+
"Invalid encrypted data. The image may not contain a hidden message, or the extraction failed."
|
|
77
|
+
);
|
|
78
|
+
}
|
|
77
79
|
|
|
78
|
-
const
|
|
79
|
-
const nonce = base64Decoding(dataSplit[1]);
|
|
80
|
-
const ciphertext = dataSplit[2];
|
|
81
|
-
const gcmTag = base64Decoding(dataSplit[3]);
|
|
80
|
+
const dataSplit = encrypted.split(":");
|
|
82
81
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
82
|
+
// Validate format - should have 4 or 5 parts (salt:nonce:ciphertext:tag[:compressed])
|
|
83
|
+
if (dataSplit.length < 4 || dataSplit.length > 5) {
|
|
84
|
+
throw new Error(
|
|
85
|
+
"Invalid encrypted message format. This may indicate:\n" +
|
|
86
|
+
" • Wrong password used for decryption\n" +
|
|
87
|
+
" • Image does not contain an encrypted message\n" +
|
|
88
|
+
" • Corrupted or modified image file"
|
|
89
|
+
);
|
|
90
|
+
}
|
|
90
91
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
92
|
+
// Validate that all required parts exist and are not empty
|
|
93
|
+
if (!dataSplit[0] || !dataSplit[1] || !dataSplit[2] || !dataSplit[3]) {
|
|
94
|
+
throw new Error(
|
|
95
|
+
"Incomplete encrypted data. Wrong password or corrupted message."
|
|
96
|
+
);
|
|
97
|
+
}
|
|
97
98
|
|
|
98
99
|
try {
|
|
100
|
+
// Check if compression flag is present (backward compatible)
|
|
101
|
+
const isCompressed = dataSplit.length === 5 && dataSplit[4] === "1";
|
|
102
|
+
|
|
103
|
+
const salt = base64Decoding(dataSplit[0]);
|
|
104
|
+
const nonce = base64Decoding(dataSplit[1]);
|
|
105
|
+
const ciphertext = dataSplit[2];
|
|
106
|
+
const gcmTag = base64Decoding(dataSplit[3]);
|
|
107
|
+
|
|
108
|
+
const key = await pbkdf2Async(
|
|
109
|
+
password,
|
|
110
|
+
salt,
|
|
111
|
+
cryptoConfig.iterations,
|
|
112
|
+
cryptoConfig.keyLength,
|
|
113
|
+
cryptoConfig.digest
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
const decipher = crypto.createDecipheriv(
|
|
117
|
+
cryptoConfig.cipherAlgorithm,
|
|
118
|
+
key,
|
|
119
|
+
nonce
|
|
120
|
+
);
|
|
121
|
+
decipher.setAuthTag(gcmTag);
|
|
122
|
+
|
|
99
123
|
const decryptedChunks = [];
|
|
100
124
|
decryptedChunks.push(decipher.update(Buffer.from(ciphertext, "base64")));
|
|
101
125
|
decryptedChunks.push(decipher.final());
|
|
@@ -108,7 +132,27 @@ const decrypt = async (encrypted, password) => {
|
|
|
108
132
|
|
|
109
133
|
return decrypted.toString("utf8");
|
|
110
134
|
} catch (err) {
|
|
111
|
-
|
|
135
|
+
// Check for common error patterns
|
|
136
|
+
if (err.message && err.message.includes("Unsupported state or unable to authenticate data")) {
|
|
137
|
+
throw new Error(
|
|
138
|
+
"Authentication failed: Incorrect password or tampered message.\n" +
|
|
139
|
+
"Make sure you're using the same password that was used for encryption."
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (err.message && err.message.includes("Invalid base64")) {
|
|
144
|
+
throw new Error(
|
|
145
|
+
"Invalid data encoding. This indicates:\n" +
|
|
146
|
+
" • Wrong password (most likely)\n" +
|
|
147
|
+
" • Corrupted or modified image"
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Generic decryption error
|
|
152
|
+
throw new Error(
|
|
153
|
+
`Decryption failed: ${err.message}\n` +
|
|
154
|
+
"This is most likely due to an incorrect password."
|
|
155
|
+
);
|
|
112
156
|
}
|
|
113
157
|
};
|
|
114
158
|
|
|
@@ -127,3 +171,4 @@ const base64Encoding = (input) => input.toString("base64");
|
|
|
127
171
|
const base64Decoding = (input) => Buffer.from(input, "base64");
|
|
128
172
|
|
|
129
173
|
module.exports = { encrypt, decrypt };
|
|
174
|
+
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
permission.
|
|
46
46
|
|
|
47
47
|
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
|
48
|
-
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
|
48
|
+
\"AS IS\" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
|
49
49
|
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
|
50
50
|
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
|
|
51
51
|
CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
|
|
@@ -185,3 +185,4 @@ MersenneTwister.prototype.genrand_int32 = function () {
|
|
|
185
185
|
/* These real versions are due to Isaku Wada, 2002/01/09 added */
|
|
186
186
|
|
|
187
187
|
module.exports = { MersenneTwister };
|
|
188
|
+
|
|
@@ -7,31 +7,33 @@ const { PNG } = require("pngjs");
|
|
|
7
7
|
* @param {number[]} dataBits - Array of data bits to write
|
|
8
8
|
* @param {string} encryptionKey - Key for scrambling the data
|
|
9
9
|
* @param {number} totalCapacity - Total capacity of the image in bits
|
|
10
|
-
* @
|
|
10
|
+
* @param {boolean} obfuscate - Whether to fill unused bits with random data (default: true)
|
|
11
|
+
* @returns {Object} Object with sparse bit mapping and metadata
|
|
11
12
|
* @throws {Error} If data is too large for the image
|
|
12
13
|
*/
|
|
13
|
-
const prepare_write_data = (dataBits, encryptionKey, totalCapacity) => {
|
|
14
|
+
const prepare_write_data = (dataBits, encryptionKey, totalCapacity, obfuscate = true) => {
|
|
14
15
|
const dataBitsLength = dataBits.length;
|
|
15
16
|
if (dataBitsLength > totalCapacity) {
|
|
16
17
|
throw new Error(
|
|
17
18
|
`Message too large! Message requires ${dataBitsLength} bits, but image can only hold ${totalCapacity} bits. ` +
|
|
18
|
-
|
|
19
|
+
`Try using a larger image or enabling compression with --compress flag.`
|
|
19
20
|
);
|
|
20
21
|
}
|
|
21
22
|
|
|
22
|
-
//
|
|
23
|
-
const result = new Array(totalCapacity);
|
|
24
|
-
for (let i = 0; i < totalCapacity; i++) {
|
|
25
|
-
result[i] = Math.floor(Math.random() * 2);
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
// Scramble actual data into random positions
|
|
23
|
+
// Always scramble message bits across entire image using password-derived positions
|
|
29
24
|
const scrambledOrder = get_hashed_order(encryptionKey, totalCapacity);
|
|
25
|
+
const sparseData = {};
|
|
26
|
+
|
|
30
27
|
for (let i = 0; i < dataBitsLength; i++) {
|
|
31
|
-
|
|
28
|
+
const position = scrambledOrder[i];
|
|
29
|
+
sparseData[position] = dataBits[i];
|
|
32
30
|
}
|
|
33
31
|
|
|
34
|
-
return
|
|
32
|
+
return {
|
|
33
|
+
sparse: sparseData,
|
|
34
|
+
obfuscate: obfuscate,
|
|
35
|
+
totalCapacity: totalCapacity,
|
|
36
|
+
};
|
|
35
37
|
};
|
|
36
38
|
|
|
37
39
|
/**
|
|
@@ -61,9 +63,9 @@ const get_bits_lsb = (imageData) => {
|
|
|
61
63
|
const result = [];
|
|
62
64
|
// Process RGB channels (skip alpha at i+3)
|
|
63
65
|
for (let i = 0; i < imageData.data.length; i += 4) {
|
|
64
|
-
result.push(imageData.data[i] % 2 === 1 ? 1 : 0);
|
|
65
|
-
result.push(imageData.data[i + 1] % 2 === 1 ? 1 : 0);
|
|
66
|
-
result.push(imageData.data[i + 2] % 2 === 1 ? 1 : 0);
|
|
66
|
+
result.push(imageData.data[i] % 2 === 1 ? 1 : 0); // Red LSB
|
|
67
|
+
result.push(imageData.data[i + 1] % 2 === 1 ? 1 : 0); // Green LSB
|
|
68
|
+
result.push(imageData.data[i + 2] % 2 === 1 ? 1 : 0); // Blue LSB
|
|
67
69
|
}
|
|
68
70
|
return result;
|
|
69
71
|
};
|
|
@@ -71,10 +73,10 @@ const get_bits_lsb = (imageData) => {
|
|
|
71
73
|
/**
|
|
72
74
|
* Writes bits to the least significant bits of image RGB channels
|
|
73
75
|
* @param {Object} imageData - PNG image data object
|
|
74
|
-
* @param {
|
|
76
|
+
* @param {Object} writeData - Object with sparse bit mapping and metadata
|
|
75
77
|
* @returns {Object} Modified image data
|
|
76
78
|
*/
|
|
77
|
-
const write_lsb = (imageData,
|
|
79
|
+
const write_lsb = (imageData, writeData) => {
|
|
78
80
|
/**
|
|
79
81
|
* Clears the LSB of a byte value
|
|
80
82
|
* @param {number} value - Byte value
|
|
@@ -93,21 +95,29 @@ const write_lsb = (imageData, bitsToWrite) => {
|
|
|
93
95
|
return value % 2 === 1 ? value : value + 1;
|
|
94
96
|
};
|
|
95
97
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
98
|
+
const { sparse, obfuscate, totalCapacity } = writeData;
|
|
99
|
+
|
|
100
|
+
// Process each bit position in the image
|
|
101
|
+
for (let bitPos = 0; bitPos < totalCapacity; bitPos++) {
|
|
102
|
+
const pixelIdx = Math.floor(bitPos / 3);
|
|
103
|
+
const channelIdx = bitPos % 3;
|
|
104
|
+
const dataIdx = pixelIdx * 4 + channelIdx; // RGBA offset
|
|
105
|
+
|
|
106
|
+
if (sparse.hasOwnProperty(bitPos)) {
|
|
107
|
+
// Write actual message bit at this position
|
|
108
|
+
imageData.data[dataIdx] = sparse[bitPos]
|
|
109
|
+
? setLSB(imageData.data[dataIdx])
|
|
110
|
+
: clearLSB(imageData.data[dataIdx]);
|
|
111
|
+
} else if (obfuscate) {
|
|
112
|
+
// Fill unused position with random bit for obfuscation
|
|
113
|
+
const randomBit = Math.floor(Math.random() * 2);
|
|
114
|
+
imageData.data[dataIdx] = randomBit
|
|
115
|
+
? setLSB(imageData.data[dataIdx])
|
|
116
|
+
: clearLSB(imageData.data[dataIdx]);
|
|
117
|
+
}
|
|
118
|
+
// If obfuscate is false and no data at this position, leave pixel unchanged
|
|
110
119
|
}
|
|
120
|
+
|
|
111
121
|
return imageData;
|
|
112
122
|
};
|
|
113
123
|
|
|
@@ -156,7 +166,8 @@ const extractMessageFromImage = async (imagePath, encryptionKey) => {
|
|
|
156
166
|
if (message == null) {
|
|
157
167
|
return {
|
|
158
168
|
success: false,
|
|
159
|
-
error:
|
|
169
|
+
error:
|
|
170
|
+
"Decryption failed. Possible causes: wrong password, corrupted file, or no hidden message.",
|
|
160
171
|
};
|
|
161
172
|
}
|
|
162
173
|
|
|
@@ -175,13 +186,15 @@ const extractMessageFromImage = async (imagePath, encryptionKey) => {
|
|
|
175
186
|
* @param {string} message - Encrypted message to hide
|
|
176
187
|
* @param {string} encryptionKey - Key for scrambling the data
|
|
177
188
|
* @param {string} outputPath - Path for the output image (default: "encoded.png")
|
|
189
|
+
* @param {boolean} obfuscate - Whether to fill unused bits with random data (default: true)
|
|
178
190
|
* @returns {Promise<{success: boolean, outputPath?: string, capacity?: Object, error?: string}>} Result object
|
|
179
191
|
*/
|
|
180
192
|
const encodeMessageToImage = async (
|
|
181
193
|
imagePath,
|
|
182
194
|
message,
|
|
183
195
|
encryptionKey,
|
|
184
|
-
outputPath = "encoded.png"
|
|
196
|
+
outputPath = "encoded.png",
|
|
197
|
+
obfuscate = true
|
|
185
198
|
) => {
|
|
186
199
|
try {
|
|
187
200
|
validateImageFile(imagePath);
|
|
@@ -203,7 +216,8 @@ const encodeMessageToImage = async (
|
|
|
203
216
|
const encryptedBitStream = prepare_write_data(
|
|
204
217
|
bitStream,
|
|
205
218
|
encryptionKey,
|
|
206
|
-
totalCapacity
|
|
219
|
+
totalCapacity,
|
|
220
|
+
obfuscate
|
|
207
221
|
);
|
|
208
222
|
|
|
209
223
|
const encryptedImageData = write_lsb(imageData, encryptedBitStream);
|
|
@@ -229,3 +243,4 @@ module.exports = {
|
|
|
229
243
|
encodeMessageToImage,
|
|
230
244
|
calculateImageCapacity,
|
|
231
245
|
};
|
|
246
|
+
|
|
@@ -25,9 +25,19 @@ const get_hashed_order = (password, arrayLength) => {
|
|
|
25
25
|
};
|
|
26
26
|
|
|
27
27
|
/**
|
|
28
|
-
* Decodes a UTF-8 byte array to a string
|
|
29
|
-
*
|
|
30
|
-
*
|
|
28
|
+
* Decodes a UTF-8 byte array to a string with bounds checking
|
|
29
|
+
* Handles truncated multi-byte sequences gracefully by stopping at truncation
|
|
30
|
+
* rather than reading undefined values. This is especially important when
|
|
31
|
+
* decrypting with the wrong password, which produces random byte sequences.
|
|
32
|
+
*
|
|
33
|
+
* @param {number[]} bytes - Array of byte values (0-255)
|
|
34
|
+
* @returns {string} Decoded UTF-8 string (stops at first truncated sequence)
|
|
35
|
+
* @example
|
|
36
|
+
* // Valid UTF-8
|
|
37
|
+
* utf8Decode([72, 101, 108, 108, 111]); // "Hello"
|
|
38
|
+
*
|
|
39
|
+
* // Truncated multi-byte sequence (wrong password scenario)
|
|
40
|
+
* utf8Decode([72, 101, 195]); // "He" (stops at truncated byte)
|
|
31
41
|
*/
|
|
32
42
|
const utf8Decode = (bytes) => {
|
|
33
43
|
const chars = [];
|
|
@@ -37,8 +47,6 @@ const utf8Decode = (bytes) => {
|
|
|
37
47
|
|
|
38
48
|
while (offset < length) {
|
|
39
49
|
currentByte = bytes[offset];
|
|
40
|
-
secondByte = bytes[offset + 1];
|
|
41
|
-
thirdByte = bytes[offset + 2];
|
|
42
50
|
|
|
43
51
|
if (128 > currentByte) {
|
|
44
52
|
// Single-byte character (ASCII)
|
|
@@ -46,10 +54,21 @@ const utf8Decode = (bytes) => {
|
|
|
46
54
|
offset += 1;
|
|
47
55
|
} else if (191 < currentByte && currentByte < 224) {
|
|
48
56
|
// Two-byte character
|
|
57
|
+
if (offset + 1 >= length) {
|
|
58
|
+
// Truncated multi-byte sequence
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
secondByte = bytes[offset + 1];
|
|
49
62
|
chars.push(String.fromCharCode(((currentByte & 31) << 6) | (secondByte & 63)));
|
|
50
63
|
offset += 2;
|
|
51
64
|
} else {
|
|
52
65
|
// Three-byte character
|
|
66
|
+
if (offset + 2 >= length) {
|
|
67
|
+
// Truncated multi-byte sequence
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
secondByte = bytes[offset + 1];
|
|
71
|
+
thirdByte = bytes[offset + 2];
|
|
53
72
|
chars.push(
|
|
54
73
|
String.fromCharCode(
|
|
55
74
|
((currentByte & 15) << 12) | ((secondByte & 63) << 6) | (thirdByte & 63)
|
|
@@ -171,3 +190,4 @@ const str_to_bits = (str, numCopies) => {
|
|
|
171
190
|
};
|
|
172
191
|
|
|
173
192
|
module.exports = { get_hashed_order, str_to_bits, bits_to_str };
|
|
193
|
+
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "obscr",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Encrypt and hide your secure data",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Encrypt and hide your secure data in images using AES-256-GCM encryption and LSB steganography",
|
|
5
5
|
"main": "bin/index.js",
|
|
6
6
|
"bin": {
|
|
7
7
|
"obscr": "./bin/index.js"
|
|
@@ -10,11 +10,19 @@
|
|
|
10
10
|
"test": "jest --coverage",
|
|
11
11
|
"test:watch": "jest --watch",
|
|
12
12
|
"test:unit": "jest test/unit",
|
|
13
|
-
"test:integration": "jest test/integration"
|
|
13
|
+
"test:integration": "jest test/integration",
|
|
14
|
+
"dev": "concurrently -k \"vite\" \"npm:electron-dev\"",
|
|
15
|
+
"electron-dev": "electron electron/main.js",
|
|
16
|
+
"build:react": "vite build",
|
|
17
|
+
"build": "npm run build:react && electron-builder"
|
|
14
18
|
},
|
|
15
19
|
"publishConfig": {
|
|
16
20
|
"registry": "https://registry.npmjs.org"
|
|
17
21
|
},
|
|
22
|
+
"files": [
|
|
23
|
+
"bin/",
|
|
24
|
+
"lib/"
|
|
25
|
+
],
|
|
18
26
|
"author": "Johannes Dragulanescu",
|
|
19
27
|
"license": "MIT",
|
|
20
28
|
"repository": "github:jdragulanescu/obscr",
|
|
@@ -28,7 +36,6 @@
|
|
|
28
36
|
"dependencies": {
|
|
29
37
|
"boxen": "^5.1.2",
|
|
30
38
|
"chalk": "^4.1.2",
|
|
31
|
-
"cli-progress": "^3.12.0",
|
|
32
39
|
"crypto-js": "^4.1.1",
|
|
33
40
|
"inquirer": "^8.0.0",
|
|
34
41
|
"ora": "^5.4.1",
|
|
@@ -36,7 +43,37 @@
|
|
|
36
43
|
"yargs": "^17.5.1"
|
|
37
44
|
},
|
|
38
45
|
"devDependencies": {
|
|
39
|
-
"
|
|
46
|
+
"@tailwindcss/postcss": "^4.1.18",
|
|
47
|
+
"@vitejs/plugin-react": "^4.0.0",
|
|
48
|
+
"autoprefixer": "^10.4.23",
|
|
49
|
+
"class-variance-authority": "^0.7.1",
|
|
50
|
+
"cli-progress": "^3.12.0",
|
|
51
|
+
"clsx": "^2.1.1",
|
|
52
|
+
"concurrently": "^9.0.0",
|
|
53
|
+
"electron": "^40.0.0",
|
|
54
|
+
"electron-builder": "^26.4.0",
|
|
55
|
+
"jest": "^29.7.0",
|
|
56
|
+
"lucide-react": "^0.562.0",
|
|
57
|
+
"postcss": "^8.5.6",
|
|
58
|
+
"react": "^18.3.1",
|
|
59
|
+
"react-dom": "^18.3.1",
|
|
60
|
+
"tailwind-merge": "^3.4.0",
|
|
61
|
+
"tailwindcss": "^4.1.18",
|
|
62
|
+
"vite": "^6.0.0",
|
|
63
|
+
"zxcvbn": "^4.4.2"
|
|
64
|
+
},
|
|
65
|
+
"build": {
|
|
66
|
+
"appId": "com.obscr.app",
|
|
67
|
+
"files": [
|
|
68
|
+
"dist/**",
|
|
69
|
+
"electron/**",
|
|
70
|
+
"lib/**",
|
|
71
|
+
"bin/**",
|
|
72
|
+
"package.json"
|
|
73
|
+
],
|
|
74
|
+
"extraMetadata": {
|
|
75
|
+
"main": "electron/main.js"
|
|
76
|
+
}
|
|
40
77
|
},
|
|
41
78
|
"jest": {
|
|
42
79
|
"testEnvironment": "node",
|
package/CHANGELOG.md
DELETED
|
@@ -1,92 +0,0 @@
|
|
|
1
|
-
# Changelog
|
|
2
|
-
|
|
3
|
-
All notable changes to this project will be documented in this file.
|
|
4
|
-
|
|
5
|
-
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
|
-
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
-
|
|
8
|
-
## [0.2.1] - 2026-01-20
|
|
9
|
-
|
|
10
|
-
### Added
|
|
11
|
-
- `.npmignore` file to exclude tests and development files from npm package
|
|
12
|
-
- 23 new CLI integration tests for command-line interface validation
|
|
13
|
-
- 3 additional steg unit tests for edge case validation
|
|
14
|
-
|
|
15
|
-
### Changed
|
|
16
|
-
- Interactive mode now runs by default when no command is provided
|
|
17
|
-
- Improved spacing throughout CLI output for better readability
|
|
18
|
-
- Package size reduced (only production files included in npm package)
|
|
19
|
-
|
|
20
|
-
### Fixed
|
|
21
|
-
- Fixed boxen borderColor compatibility issue (changed from "violet" to "magenta")
|
|
22
|
-
|
|
23
|
-
### Testing
|
|
24
|
-
- Total test count: 103 tests (up from 80)
|
|
25
|
-
- Overall coverage: 90.45%
|
|
26
|
-
- All tests passing
|
|
27
|
-
|
|
28
|
-
## [0.2.0] - 2026-01-20
|
|
29
|
-
|
|
30
|
-
### Added
|
|
31
|
-
- **Compression Support**: Optional gzip compression using `--compress` flag to reduce message size by 50-90%
|
|
32
|
-
- **Custom Output Filename**: `-o/--output` flag for encrypt command to specify output filename (default: "encoded.png")
|
|
33
|
-
- **Image Capacity Validation**: Automatic validation that message fits in image with helpful error messages
|
|
34
|
-
- **Capacity Information**: Shows capacity utilization after successful encryption
|
|
35
|
-
- **Comprehensive Test Suite**: 80 tests with 90%+ code coverage
|
|
36
|
-
- Unit tests for crypto (100% coverage), utils (100% coverage), and steg (97% coverage)
|
|
37
|
-
- Integration tests for full workflows
|
|
38
|
-
- Automated test fixtures and isolated test outputs
|
|
39
|
-
- **JSDoc Documentation**: All exported functions now have detailed JSDoc comments
|
|
40
|
-
- **Enhanced README**: Comprehensive documentation with examples, security notes, and technical details
|
|
41
|
-
|
|
42
|
-
### UX Enhancements
|
|
43
|
-
- **Interactive Mode**: New `obscr interactive` command with guided menu-driven workflow
|
|
44
|
-
- **Progress Indicators**: Real-time spinners showing encryption/decryption progress with ora
|
|
45
|
-
- **Visual Feedback**: Color-coded messages with icons (✔, ✖, ℹ, ⚠) and beautiful boxed outputs
|
|
46
|
-
- **Verbose Mode**: `-v/--verbose` flag for detailed technical information and debugging
|
|
47
|
-
- **Quiet Mode**: `-q/--quiet` flag for minimal output, perfect for scripting and automation
|
|
48
|
-
- **Confirmation Prompts**: Asks before overwriting existing files
|
|
49
|
-
- **Examples Command**: New `obscr examples` command showing usage examples
|
|
50
|
-
- **Better Help Text**: Improved help messages with clearer descriptions
|
|
51
|
-
- **Welcome Banner**: Stylish welcome banner with version information (can be suppressed with --quiet)
|
|
52
|
-
- **Password Masking**: Visual asterisks when entering passwords
|
|
53
|
-
- **Smart Validation**: Real-time input validation with helpful error messages
|
|
54
|
-
|
|
55
|
-
### Changed
|
|
56
|
-
- **Async/Await Pattern**: Replaced synchronous crypto operations with async Promise-based approach for better performance
|
|
57
|
-
- **Modern JavaScript**: Converted `var` to `const`/`let`, using template literals throughout
|
|
58
|
-
- **Better Variable Names**: Improved naming for clarity (e.g., `c` → `currentByte`, `tmp` → `powerOfTwo`)
|
|
59
|
-
- **Error Handling**: Consistent error handling with structured return types `{success, data, error}`
|
|
60
|
-
- **Return Types**: Changed from tuple returns `[boolean, data]` to objects for better clarity
|
|
61
|
-
- **Array Optimizations**: Pre-allocated arrays instead of dynamic growth
|
|
62
|
-
|
|
63
|
-
### Fixed
|
|
64
|
-
- **Debug Output Removed**: Removed debug `console.log` statement in encrypt command
|
|
65
|
-
- **Error Object Handling**: Now throws proper Error objects instead of strings
|
|
66
|
-
- **Fixed Output Filename**: Encrypt command now supports custom output instead of hardcoded "encoded.png"
|
|
67
|
-
|
|
68
|
-
### Security
|
|
69
|
-
- **Backward Compatibility Maintained**:
|
|
70
|
-
- SECRET_KEY preserved for compatibility with older encrypted images
|
|
71
|
-
- Can decrypt images created with previous versions
|
|
72
|
-
- Automatically detects compressed vs uncompressed messages
|
|
73
|
-
- All encryption parameters unchanged (AES-256-GCM, PBKDF2 with 65,535 iterations)
|
|
74
|
-
|
|
75
|
-
### Technical Notes
|
|
76
|
-
- Compression format adds optional 5th field: `salt:nonce:ciphertext:tag[:1]`
|
|
77
|
-
- Async operations provide foundation for future streaming implementations
|
|
78
|
-
- Test outputs isolated in `test/output/` directory and properly gitignored
|
|
79
|
-
|
|
80
|
-
## [0.1.2] - 2022-09-11
|
|
81
|
-
|
|
82
|
-
### Changed
|
|
83
|
-
- Updated dependencies
|
|
84
|
-
- Cleaned up codebase
|
|
85
|
-
|
|
86
|
-
## [0.1.1] - 2022-07-08
|
|
87
|
-
|
|
88
|
-
### Added
|
|
89
|
-
- Initial release with AES-256-GCM encryption
|
|
90
|
-
- LSB steganography for PNG images
|
|
91
|
-
- Password-based key derivation with PBKDF2
|
|
92
|
-
- CLI interface with encrypt/decrypt commands
|