broadfield-dev commited on
Commit
3f376e8
·
verified ·
1 Parent(s): 6a04a72

Create keylock-decoder.js

Browse files
Files changed (1) hide show
  1. keylock-decoder.js +202 -0
keylock-decoder.js ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * KeyLock-JS-Decoder
3
+ * A client-side JavaScript module to decode data from images created by the KeyLock app.
4
+ * Uses Web Crypto API for decryption and HTML Canvas for LSB steganography extraction.
5
+ *
6
+ * @version 1.1.0
7
+ * @license MIT
8
+ */
9
+
10
+ /**
11
+ * Converts a PEM-formatted key string (PKCS8) to a DER ArrayBuffer.
12
+ * This is a necessary preprocessing step for the Web Crypto API.
13
+ * @param {string} pem The PEM key string.
14
+ * @returns {ArrayBuffer} The key in DER format.
15
+ */
16
+ function pemToDer(pem) {
17
+ const b64 = pem
18
+ .replace(/-----BEGIN PRIVATE KEY-----/g, '')
19
+ .replace(/-----END PRIVATE KEY-----/g, '')
20
+ .replace(/\s/g, '');
21
+
22
+ const binaryDer = atob(b64);
23
+ const buffer = new ArrayBuffer(binaryDer.length);
24
+ const bytes = new Uint8Array(buffer);
25
+ for (let i = 0; i < binaryDer.length; i++) {
26
+ bytes[i] = binaryDer.charCodeAt(i);
27
+ }
28
+ return buffer;
29
+ }
30
+
31
+ /**
32
+ * Extracts the hidden data payload from an image's LSBs using a Canvas.
33
+ * @param {HTMLImageElement} imageElement The <img> element containing the stego image.
34
+ * @returns {Promise<Uint8Array>} A promise that resolves with the extracted data payload.
35
+ */
36
+ function extractDataFromImage(imageElement) {
37
+ return new Promise((resolve, reject) => {
38
+ const canvas = document.createElement('canvas');
39
+ const ctx = canvas.getContext('2d', { willReadFrequently: true });
40
+
41
+ // Ensure the canvas is the same size as the image to avoid scaling artifacts
42
+ canvas.width = imageElement.naturalWidth;
43
+ canvas.height = imageElement.naturalHeight;
44
+ ctx.drawImage(imageElement, 0, 0);
45
+
46
+ const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
47
+ const channels = 4; // RGBA
48
+
49
+ // 1. Extract the header to find the data length
50
+ const HEADER_BITS = 32; // 4 bytes for the length header
51
+ if (imageData.length < Math.ceil(HEADER_BITS / 3) * channels) {
52
+ return reject(new Error("Image is too small to contain a valid header."));
53
+ }
54
+
55
+ let headerBinaryString = '';
56
+ for (let i = 0; headerBinaryString.length < HEADER_BITS; i++) {
57
+ const pixelIndex = i * channels;
58
+ headerBinaryString += imageData[pixelIndex] & 1; // R
59
+ if (headerBinaryString.length < HEADER_BITS) headerBinaryString += imageData[pixelIndex + 1] & 1; // G
60
+ if (headerBinaryString.length < HEADER_BITS) headerBinaryString += imageData[pixelIndex + 2] & 1; // B
61
+ }
62
+
63
+ const headerBytes = new Uint8Array(HEADER_BITS / 8);
64
+ for (let i = 0; i < HEADER_BITS / 8; i++) {
65
+ headerBytes[i] = parseInt(headerBinaryString.substring(i * 8, (i + 1) * 8), 2);
66
+ }
67
+
68
+ const dataView = new DataView(headerBytes.buffer);
69
+ const dataLengthInBytes = dataView.getUint32(0, false); // false for big-endian
70
+
71
+ if (dataLengthInBytes === 0) {
72
+ return resolve(new Uint8Array(0));
73
+ }
74
+
75
+ // 2. Extract the data payload
76
+ const dataLengthInBits = dataLengthInBytes * 8;
77
+ const bitOffset = HEADER_BITS;
78
+
79
+ if (imageData.length < Math.ceil((bitOffset + dataLengthInBits) / 3) * channels) {
80
+ return reject(new Error("Image is too small for the data specified in the header. File may be corrupt."));
81
+ }
82
+
83
+ let dataBinaryString = '';
84
+ for (let i = 0; dataBinaryString.length < dataLengthInBits; i++) {
85
+ const bitPosition = bitOffset + i;
86
+ const pixelIndex = Math.floor(bitPosition / 3) * channels;
87
+ const channelOffset = bitPosition % 3;
88
+ dataBinaryString += imageData[pixelIndex + channelOffset] & 1;
89
+ }
90
+
91
+ if (dataBinaryString.length !== dataLengthInBits) {
92
+ return reject(new Error(`Data extraction error: expected ${dataLengthInBits} bits, but got ${dataBinaryString.length}. Image may be truncated or corrupt.`));
93
+ }
94
+
95
+ const dataBytes = new Uint8Array(dataLengthInBytes);
96
+ for (let i = 0; i < dataLengthInBytes; i++) {
97
+ dataBytes[i] = parseInt(dataBinaryString.substring(i * 8, (i + 1) * 8), 2);
98
+ }
99
+
100
+ resolve(dataBytes);
101
+ });
102
+ }
103
+
104
+ /**
105
+ * Decrypts a hybrid RSA-AES payload using the Web Crypto API.
106
+ * @param {Uint8Array} cryptoPayload The full encrypted payload.
107
+ * @param {string} privateKeyPem The recipient's private key in PEM format.
108
+ * @returns {Promise<object>} A promise that resolves with the decrypted JavaScript object.
109
+ */
110
+ async function decryptHybridPayload(cryptoPayload, privateKeyPem) {
111
+ // --- Constants from Python core.py ---
112
+ const ENCRYPTED_AES_KEY_LEN_SIZE = 4;
113
+ const AES_GCM_NONCE_SIZE = 12;
114
+
115
+ // 1. Import the RSA private key
116
+ const privateKey = await crypto.subtle.importKey(
117
+ 'pkcs8',
118
+ pemToDer(privateKeyPem),
119
+ { name: 'RSA-OAEP', hash: 'SHA-256' },
120
+ true,
121
+ ['decrypt']
122
+ );
123
+
124
+ // 2. Parse the crypto payload
125
+ const encryptedAesKeyLen = new DataView(cryptoPayload.buffer, 0, ENCRYPTED_AES_KEY_LEN_SIZE).getUint32(0, false);
126
+
127
+ let offset = ENCRYPTED_AES_KEY_LEN_SIZE;
128
+ const encryptedAesKey = cryptoPayload.slice(offset, offset + encryptedAesKeyLen);
129
+
130
+ offset += encryptedAesKeyLen;
131
+ const nonce = cryptoPayload.slice(offset, offset + AES_GCM_NONCE_SIZE);
132
+
133
+ offset += AES_GCM_NONCE_SIZE;
134
+ const ciphertextWithTag = cryptoPayload.slice(offset);
135
+
136
+ // 3. Decrypt the AES key with the RSA private key
137
+ const decryptedAesKeyBytes = await crypto.subtle.decrypt(
138
+ { name: 'RSA-OAEP' },
139
+ privateKey,
140
+ encryptedAesKey
141
+ );
142
+
143
+ // 4. Import the decrypted AES key
144
+ const aesKey = await crypto.subtle.importKey(
145
+ 'raw',
146
+ decryptedAesKeyBytes,
147
+ { name: 'AES-GCM', length: 256 },
148
+ true,
149
+ ['decrypt']
150
+ );
151
+
152
+ // 5. Decrypt the final data with the AES key
153
+ const decryptedDataBuffer = await crypto.subtle.decrypt(
154
+ { name: 'AES-GCM', iv: nonce },
155
+ aesKey,
156
+ ciphertextWithTag
157
+ );
158
+
159
+ // 6. Decode the result from UTF-8 and parse as JSON
160
+ const decryptedText = new TextDecoder().decode(decryptedDataBuffer);
161
+ return JSON.parse(decryptedText);
162
+ }
163
+
164
+
165
+ /**
166
+ * Main public function. Decodes an auth object from a KeyLock image.
167
+ *
168
+ * @param {HTMLImageElement} imageElement The <img> element displaying the KeyLock PNG.
169
+ * Note: The image must be served from the same origin or have CORS headers
170
+ * allowing it to be drawn on a canvas. For file uploads, this is not an issue.
171
+ * @param {string} privateKeyPem The user's private RSA key in PEM format.
172
+ * @returns {Promise<object>} A promise that resolves to the decoded credentials object.
173
+ * @throws {Error} Throws an error if decoding or decryption fails.
174
+ */
175
+ export async function decodeAuthFromImage(imageElement, privateKeyPem) {
176
+ if (!imageElement || !(imageElement instanceof HTMLImageElement)) {
177
+ throw new Error("A valid HTMLImageElement must be provided.");
178
+ }
179
+ if (!privateKeyPem || typeof privateKeyPem !== 'string') {
180
+ throw new Error("A valid private key in PEM format (string) must be provided.");
181
+ }
182
+
183
+ try {
184
+ // Step 1: Extract the encrypted byte payload from the image LSBs
185
+ const cryptoPayload = await extractDataFromImage(imageElement);
186
+ if (cryptoPayload.length === 0) {
187
+ throw new Error("No data found in image. It may not be a valid KeyLock file.");
188
+ }
189
+
190
+ // Step 2: Decrypt the payload
191
+ const credentials = await decryptHybridPayload(cryptoPayload, privateKeyPem);
192
+ return credentials;
193
+
194
+ } catch (error) {
195
+ // Provide a more user-friendly error message for common failures
196
+ if (error.name === 'OperationError' || (error.message && error.message.toLowerCase().includes('decryption failed'))) {
197
+ throw new Error("Decryption failed. This usually means the private key is incorrect or the image data is corrupted.");
198
+ }
199
+ // Re-throw other errors
200
+ throw error;
201
+ }
202
+ }