obscr 0.1.1 → 0.2.1
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/CHANGELOG.md +92 -0
- package/README.md +214 -26
- package/bin/index.js +417 -85
- package/bin/utils/crypto.js +69 -30
- package/bin/utils/steg.js +193 -86
- package/bin/utils/utils.js +113 -64
- package/package.json +24 -4
- package/.github/workflows/publish.yml +0 -18
package/bin/utils/crypto.js
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
const crypto = require("crypto");
|
|
2
|
+
const zlib = require("zlib");
|
|
3
|
+
const { promisify } = require("util");
|
|
4
|
+
|
|
5
|
+
const gzipAsync = promisify(zlib.gzip);
|
|
6
|
+
const gunzipAsync = promisify(zlib.gunzip);
|
|
7
|
+
const pbkdf2Async = promisify(crypto.pbkdf2);
|
|
2
8
|
|
|
3
9
|
const cryptoConfig = {
|
|
4
10
|
cipherAlgorithm: "aes-256-gcm",
|
|
@@ -10,9 +16,16 @@ const cryptoConfig = {
|
|
|
10
16
|
digest: "sha512",
|
|
11
17
|
};
|
|
12
18
|
|
|
13
|
-
|
|
19
|
+
/**
|
|
20
|
+
* Encrypts a message using AES-256-GCM with optional compression
|
|
21
|
+
* @param {string} message - The message to encrypt
|
|
22
|
+
* @param {string} password - The password to use for encryption
|
|
23
|
+
* @param {boolean} compress - Whether to compress the message before encryption
|
|
24
|
+
* @returns {Promise<string>} The encrypted message in format: salt:nonce:ciphertext:tag[:compressed]
|
|
25
|
+
*/
|
|
26
|
+
const encrypt = async (message, password, compress = false) => {
|
|
14
27
|
const salt = crypto.randomBytes(cryptoConfig.saltLength);
|
|
15
|
-
const key =
|
|
28
|
+
const key = await pbkdf2Async(
|
|
16
29
|
password,
|
|
17
30
|
salt,
|
|
18
31
|
cryptoConfig.iterations,
|
|
@@ -20,6 +33,13 @@ const encrypt = (message, password) => {
|
|
|
20
33
|
cryptoConfig.digest
|
|
21
34
|
);
|
|
22
35
|
|
|
36
|
+
let dataToEncrypt = Buffer.from(message, "utf8");
|
|
37
|
+
|
|
38
|
+
// Compress if requested
|
|
39
|
+
if (compress) {
|
|
40
|
+
dataToEncrypt = await gzipAsync(dataToEncrypt);
|
|
41
|
+
}
|
|
42
|
+
|
|
23
43
|
const nonce = crypto.randomBytes(cryptoConfig.nonceLength);
|
|
24
44
|
const cipher = crypto.createCipheriv(
|
|
25
45
|
cryptoConfig.cipherAlgorithm,
|
|
@@ -27,31 +47,40 @@ const encrypt = (message, password) => {
|
|
|
27
47
|
nonce
|
|
28
48
|
);
|
|
29
49
|
|
|
30
|
-
|
|
31
|
-
cipher.
|
|
32
|
-
cipher.
|
|
33
|
-
|
|
34
|
-
// do nothing console.log(encryptedBase64);
|
|
35
|
-
// Prints: some clear text data
|
|
36
|
-
});
|
|
37
|
-
cipher.write(message);
|
|
38
|
-
cipher.end();
|
|
50
|
+
const encryptedChunks = [];
|
|
51
|
+
encryptedChunks.push(cipher.update(dataToEncrypt));
|
|
52
|
+
encryptedChunks.push(cipher.final());
|
|
53
|
+
const encrypted = Buffer.concat(encryptedChunks);
|
|
39
54
|
|
|
40
55
|
const saltBase64 = base64Encoding(salt);
|
|
41
56
|
const nonceBase64 = base64Encoding(nonce);
|
|
57
|
+
const encryptedBase64 = base64Encoding(encrypted);
|
|
42
58
|
const gcmTagBase64 = base64Encoding(cipher.getAuthTag());
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
59
|
+
|
|
60
|
+
// Add compression flag to the end for backward compatibility
|
|
61
|
+
const compressionFlag = compress ? ":1" : "";
|
|
62
|
+
return `${saltBase64}:${nonceBase64}:${encryptedBase64}:${gcmTagBase64}${compressionFlag}`;
|
|
46
63
|
};
|
|
47
64
|
|
|
48
|
-
|
|
65
|
+
/**
|
|
66
|
+
* Decrypts an encrypted message
|
|
67
|
+
* @param {string} encrypted - The encrypted message string
|
|
68
|
+
* @param {string} password - The password to use for decryption
|
|
69
|
+
* @returns {Promise<string>} The decrypted message
|
|
70
|
+
* @throws {Error} If decryption fails
|
|
71
|
+
*/
|
|
72
|
+
const decrypt = async (encrypted, password) => {
|
|
49
73
|
const dataSplit = encrypted.split(":");
|
|
74
|
+
|
|
75
|
+
// Check if compression flag is present (backward compatible)
|
|
76
|
+
const isCompressed = dataSplit.length === 5 && dataSplit[4] === "1";
|
|
77
|
+
|
|
50
78
|
const salt = base64Decoding(dataSplit[0]);
|
|
51
79
|
const nonce = base64Decoding(dataSplit[1]);
|
|
52
80
|
const ciphertext = dataSplit[2];
|
|
53
81
|
const gcmTag = base64Decoding(dataSplit[3]);
|
|
54
|
-
|
|
82
|
+
|
|
83
|
+
const key = await pbkdf2Async(
|
|
55
84
|
password,
|
|
56
85
|
salt,
|
|
57
86
|
cryptoConfig.iterations,
|
|
@@ -66,25 +95,35 @@ const decrypt = (encrypted, password) => {
|
|
|
66
95
|
);
|
|
67
96
|
decipher.setAuthTag(gcmTag);
|
|
68
97
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
98
|
+
try {
|
|
99
|
+
const decryptedChunks = [];
|
|
100
|
+
decryptedChunks.push(decipher.update(Buffer.from(ciphertext, "base64")));
|
|
101
|
+
decryptedChunks.push(decipher.final());
|
|
102
|
+
let decrypted = Buffer.concat(decryptedChunks);
|
|
103
|
+
|
|
104
|
+
// Decompress if it was compressed
|
|
105
|
+
if (isCompressed) {
|
|
106
|
+
decrypted = await gunzipAsync(decrypted);
|
|
73
107
|
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
throw err.message;
|
|
80
|
-
});
|
|
81
|
-
decipher.write(ciphertext, "base64");
|
|
82
|
-
decipher.end();
|
|
83
|
-
return decrypted;
|
|
108
|
+
|
|
109
|
+
return decrypted.toString("utf8");
|
|
110
|
+
} catch (err) {
|
|
111
|
+
throw new Error(`Decryption failed: ${err.message}`);
|
|
112
|
+
}
|
|
84
113
|
};
|
|
85
114
|
|
|
115
|
+
/**
|
|
116
|
+
* Encodes a buffer to base64 string
|
|
117
|
+
* @param {Buffer} input - The buffer to encode
|
|
118
|
+
* @returns {string} Base64 encoded string
|
|
119
|
+
*/
|
|
86
120
|
const base64Encoding = (input) => input.toString("base64");
|
|
87
121
|
|
|
122
|
+
/**
|
|
123
|
+
* Decodes a base64 string to buffer
|
|
124
|
+
* @param {string} input - The base64 string to decode
|
|
125
|
+
* @returns {Buffer} Decoded buffer
|
|
126
|
+
*/
|
|
88
127
|
const base64Decoding = (input) => Buffer.from(input, "base64");
|
|
89
128
|
|
|
90
129
|
module.exports = { encrypt, decrypt };
|
package/bin/utils/steg.js
CHANGED
|
@@ -1,124 +1,231 @@
|
|
|
1
1
|
const fs = require("fs");
|
|
2
|
-
const { loadImage, createCanvas } = require("canvas");
|
|
3
2
|
const { get_hashed_order, str_to_bits, bits_to_str } = require("./utils");
|
|
3
|
+
const { PNG } = require("pngjs");
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Prepares data bits for writing by scrambling them with a password-based key
|
|
7
|
+
* @param {number[]} dataBits - Array of data bits to write
|
|
8
|
+
* @param {string} encryptionKey - Key for scrambling the data
|
|
9
|
+
* @param {number} totalCapacity - Total capacity of the image in bits
|
|
10
|
+
* @returns {number[]} Scrambled bit array with obfuscation
|
|
11
|
+
* @throws {Error} If data is too large for the image
|
|
12
|
+
*/
|
|
13
|
+
const prepare_write_data = (dataBits, encryptionKey, totalCapacity) => {
|
|
14
|
+
const dataBitsLength = dataBits.length;
|
|
15
|
+
if (dataBitsLength > totalCapacity) {
|
|
16
|
+
throw new Error(
|
|
17
|
+
`Message too large! Message requires ${dataBitsLength} bits, but image can only hold ${totalCapacity} bits. ` +
|
|
18
|
+
`Try using a larger image or enabling compression with --compress flag.`
|
|
19
|
+
);
|
|
20
|
+
}
|
|
4
21
|
|
|
5
|
-
|
|
6
|
-
const
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
for (let i = 0; i < encode_len; i++) {
|
|
10
|
-
result[i] = Math.floor(Math.random() * 2); //obfuscation
|
|
22
|
+
// Initialize with random bits for obfuscation
|
|
23
|
+
const result = new Array(totalCapacity);
|
|
24
|
+
for (let i = 0; i < totalCapacity; i++) {
|
|
25
|
+
result[i] = Math.floor(Math.random() * 2);
|
|
11
26
|
}
|
|
12
27
|
|
|
13
|
-
|
|
14
|
-
|
|
28
|
+
// Scramble actual data into random positions
|
|
29
|
+
const scrambledOrder = get_hashed_order(encryptionKey, totalCapacity);
|
|
30
|
+
for (let i = 0; i < dataBitsLength; i++) {
|
|
31
|
+
result[scrambledOrder[i]] = dataBits[i];
|
|
32
|
+
}
|
|
15
33
|
|
|
16
34
|
return result;
|
|
17
35
|
};
|
|
18
36
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
37
|
+
/**
|
|
38
|
+
* Extracts and unscrambles data bits using password-based key
|
|
39
|
+
* @param {number[]} dataBits - Array of scrambled bits from image
|
|
40
|
+
* @param {string} encryptionKey - Key for unscrambling the data
|
|
41
|
+
* @returns {number[]} Unscrambled bit array
|
|
42
|
+
*/
|
|
43
|
+
const prepare_read_data = (dataBits, encryptionKey) => {
|
|
44
|
+
const dataBitsLength = dataBits.length;
|
|
45
|
+
const result = new Array(dataBitsLength);
|
|
46
|
+
const scrambledOrder = get_hashed_order(encryptionKey, dataBitsLength);
|
|
47
|
+
|
|
48
|
+
for (let i = 0; i < dataBitsLength; i++) {
|
|
49
|
+
result[i] = dataBits[scrambledOrder[i]];
|
|
50
|
+
}
|
|
25
51
|
|
|
26
52
|
return result;
|
|
27
53
|
};
|
|
28
54
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
55
|
+
/**
|
|
56
|
+
* Extracts the least significant bits from image RGB channels
|
|
57
|
+
* @param {Object} imageData - PNG image data object
|
|
58
|
+
* @returns {number[]} Array of extracted LSB bits
|
|
59
|
+
*/
|
|
60
|
+
const get_bits_lsb = (imageData) => {
|
|
61
|
+
const result = [];
|
|
62
|
+
// Process RGB channels (skip alpha at i+3)
|
|
63
|
+
for (let i = 0; i < imageData.data.length; i += 4) {
|
|
64
|
+
result.push(imageData.data[i] % 2 === 1 ? 1 : 0); // Red LSB
|
|
65
|
+
result.push(imageData.data[i + 1] % 2 === 1 ? 1 : 0); // Green LSB
|
|
66
|
+
result.push(imageData.data[i + 2] % 2 === 1 ? 1 : 0); // Blue LSB
|
|
35
67
|
}
|
|
36
68
|
return result;
|
|
37
69
|
};
|
|
38
70
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
71
|
+
/**
|
|
72
|
+
* Writes bits to the least significant bits of image RGB channels
|
|
73
|
+
* @param {Object} imageData - PNG image data object
|
|
74
|
+
* @param {number[]} bitsToWrite - Array of bits to write
|
|
75
|
+
* @returns {Object} Modified image data
|
|
76
|
+
*/
|
|
77
|
+
const write_lsb = (imageData, bitsToWrite) => {
|
|
78
|
+
/**
|
|
79
|
+
* Clears the LSB of a byte value
|
|
80
|
+
* @param {number} value - Byte value
|
|
81
|
+
* @returns {number} Value with LSB cleared
|
|
82
|
+
*/
|
|
83
|
+
const clearLSB = (value) => {
|
|
84
|
+
return value % 2 === 1 ? value - 1 : value;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Sets the LSB of a byte value
|
|
89
|
+
* @param {number} value - Byte value
|
|
90
|
+
* @returns {number} Value with LSB set
|
|
91
|
+
*/
|
|
92
|
+
const setLSB = (value) => {
|
|
93
|
+
return value % 2 === 1 ? value : value + 1;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
let bitIndex = 0;
|
|
97
|
+
for (let i = 0; i < imageData.data.length; i += 4) {
|
|
98
|
+
// Write to RGB channels
|
|
99
|
+
imageData.data[i] = bitsToWrite[bitIndex]
|
|
100
|
+
? setLSB(imageData.data[i])
|
|
101
|
+
: clearLSB(imageData.data[i]);
|
|
102
|
+
imageData.data[i + 1] = bitsToWrite[bitIndex + 1]
|
|
103
|
+
? setLSB(imageData.data[i + 1])
|
|
104
|
+
: clearLSB(imageData.data[i + 1]);
|
|
105
|
+
imageData.data[i + 2] = bitsToWrite[bitIndex + 2]
|
|
106
|
+
? setLSB(imageData.data[i + 2])
|
|
107
|
+
: clearLSB(imageData.data[i + 2]);
|
|
108
|
+
imageData.data[i + 3] = 255; // Keep alpha channel at full opacity
|
|
109
|
+
bitIndex += 3;
|
|
42
110
|
}
|
|
111
|
+
return imageData;
|
|
112
|
+
};
|
|
43
113
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
: unsetbit(imgData.data[i]);
|
|
52
|
-
imgData.data[i + 1] = setdata[j + 1]
|
|
53
|
-
? setbit(imgData.data[i + 1])
|
|
54
|
-
: unsetbit(imgData.data[i + 1]);
|
|
55
|
-
imgData.data[i + 2] = setdata[j + 2]
|
|
56
|
-
? setbit(imgData.data[i + 2])
|
|
57
|
-
: unsetbit(imgData.data[i + 2]);
|
|
58
|
-
imgData.data[i + 3] = 255;
|
|
59
|
-
j += 3;
|
|
60
|
-
}
|
|
61
|
-
return imgData;
|
|
114
|
+
/**
|
|
115
|
+
* Calculates the bit capacity of an image
|
|
116
|
+
* @param {Object} imageData - PNG image data object
|
|
117
|
+
* @returns {number} Capacity in bits
|
|
118
|
+
*/
|
|
119
|
+
const calculateImageCapacity = (imageData) => {
|
|
120
|
+
return Math.floor(imageData.data.length / 4) * 3; // 3 bits per pixel (RGB)
|
|
62
121
|
};
|
|
63
122
|
|
|
64
|
-
|
|
65
|
-
|
|
123
|
+
/**
|
|
124
|
+
* Validates that an image file exists and is readable
|
|
125
|
+
* @param {string} imagePath - Path to the image file
|
|
126
|
+
* @throws {Error} If file doesn't exist or isn't readable
|
|
127
|
+
*/
|
|
128
|
+
const validateImageFile = (imagePath) => {
|
|
129
|
+
if (!fs.existsSync(imagePath)) {
|
|
130
|
+
throw new Error(`Image file not found: ${imagePath}`);
|
|
131
|
+
}
|
|
66
132
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
ctx = c.getContext("2d");
|
|
71
|
-
ctx.drawImage(img, 0, 0, img.width, img.height);
|
|
72
|
-
imgData = ctx.getImageData(0, 0, c.width, c.height);
|
|
73
|
-
} catch (err) {
|
|
74
|
-
return [false, err];
|
|
133
|
+
const stats = fs.statSync(imagePath);
|
|
134
|
+
if (!stats.isFile()) {
|
|
135
|
+
throw new Error(`Path is not a file: ${imagePath}`);
|
|
75
136
|
}
|
|
137
|
+
};
|
|
76
138
|
|
|
139
|
+
/**
|
|
140
|
+
* Extracts and decrypts a hidden message from a PNG image
|
|
141
|
+
* @param {string} imagePath - Path to the PNG image
|
|
142
|
+
* @param {string} encryptionKey - Key for unscrambling the data
|
|
143
|
+
* @returns {Promise<{success: boolean, data?: string, error?: string}>} Result object
|
|
144
|
+
*/
|
|
145
|
+
const extractMessageFromImage = async (imagePath, encryptionKey) => {
|
|
77
146
|
try {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
147
|
+
validateImageFile(imagePath);
|
|
148
|
+
|
|
149
|
+
const imageBuffer = fs.readFileSync(imagePath);
|
|
150
|
+
const imageData = PNG.sync.read(imageBuffer);
|
|
151
|
+
|
|
152
|
+
const bitsStream = get_bits_lsb(imageData);
|
|
153
|
+
const decryptedBitsStream = prepare_read_data(bitsStream, encryptionKey);
|
|
154
|
+
const message = bits_to_str(decryptedBitsStream, 1);
|
|
155
|
+
|
|
156
|
+
if (message == null) {
|
|
157
|
+
return {
|
|
158
|
+
success: false,
|
|
159
|
+
error: "Decryption failed. Possible causes: wrong password, corrupted file, or no hidden message.",
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return { success: true, data: message };
|
|
87
164
|
} catch (err) {
|
|
88
|
-
return
|
|
89
|
-
false,
|
|
90
|
-
|
|
91
|
-
|
|
165
|
+
return {
|
|
166
|
+
success: false,
|
|
167
|
+
error: err.message || "Failed to extract message from image",
|
|
168
|
+
};
|
|
92
169
|
}
|
|
93
170
|
};
|
|
94
171
|
|
|
95
|
-
|
|
172
|
+
/**
|
|
173
|
+
* Encodes and hides an encrypted message into a PNG image
|
|
174
|
+
* @param {string} imagePath - Path to the source PNG image
|
|
175
|
+
* @param {string} message - Encrypted message to hide
|
|
176
|
+
* @param {string} encryptionKey - Key for scrambling the data
|
|
177
|
+
* @param {string} outputPath - Path for the output image (default: "encoded.png")
|
|
178
|
+
* @returns {Promise<{success: boolean, outputPath?: string, capacity?: Object, error?: string}>} Result object
|
|
179
|
+
*/
|
|
180
|
+
const encodeMessageToImage = async (
|
|
181
|
+
imagePath,
|
|
182
|
+
message,
|
|
183
|
+
encryptionKey,
|
|
184
|
+
outputPath = "encoded.png"
|
|
185
|
+
) => {
|
|
96
186
|
try {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
const
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
187
|
+
validateImageFile(imagePath);
|
|
188
|
+
|
|
189
|
+
const imageBuffer = fs.readFileSync(imagePath);
|
|
190
|
+
const imageData = PNG.sync.read(imageBuffer);
|
|
191
|
+
|
|
192
|
+
const totalCapacity = calculateImageCapacity(imageData);
|
|
193
|
+
const bitStream = str_to_bits(message, 1);
|
|
194
|
+
|
|
195
|
+
// Prepare capacity info for user
|
|
196
|
+
const capacityInfo = {
|
|
197
|
+
totalBits: totalCapacity,
|
|
198
|
+
usedBits: bitStream.length,
|
|
199
|
+
utilization: ((bitStream.length / totalCapacity) * 100).toFixed(2) + "%",
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
// This will throw if message is too large
|
|
109
203
|
const encryptedBitStream = prepare_write_data(
|
|
110
204
|
bitStream,
|
|
111
|
-
|
|
112
|
-
|
|
205
|
+
encryptionKey,
|
|
206
|
+
totalCapacity
|
|
113
207
|
);
|
|
114
208
|
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
fs.writeFileSync(
|
|
209
|
+
const encryptedImageData = write_lsb(imageData, encryptedBitStream);
|
|
210
|
+
const outputBuffer = PNG.sync.write(encryptedImageData);
|
|
211
|
+
|
|
212
|
+
fs.writeFileSync(outputPath, outputBuffer);
|
|
119
213
|
|
|
120
|
-
return
|
|
214
|
+
return {
|
|
215
|
+
success: true,
|
|
216
|
+
outputPath,
|
|
217
|
+
capacity: capacityInfo,
|
|
218
|
+
};
|
|
121
219
|
} catch (err) {
|
|
122
|
-
|
|
220
|
+
return {
|
|
221
|
+
success: false,
|
|
222
|
+
error: err.message || "Failed to encode message to image",
|
|
223
|
+
};
|
|
123
224
|
}
|
|
124
225
|
};
|
|
226
|
+
|
|
227
|
+
module.exports = {
|
|
228
|
+
extractMessageFromImage,
|
|
229
|
+
encodeMessageToImage,
|
|
230
|
+
calculateImageCapacity,
|
|
231
|
+
};
|