-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathindex.ts
447 lines (411 loc) · 15.8 KB
/
index.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
import aes, { utils as aesUtils } from 'micro-aes-gcm';
const ENCRYPTED_METADATA_SIZE = 28; // AES-GCM 12-byte IV + 16-byte auth tag
function validateBits(bitsTaken: number) {
const b = bitsTaken;
if (!(Number.isSafeInteger(b) && b >= 1 && b <= 8))
throw new Error('Bits taken must be >= 1 and <= 8');
}
// clearBits(0b101010, 4) => 0b100000
function clearBits(n: number, bits: number): number {
return (n >> bits) << bits;
}
function readBit(byte: number, pos: number): number {
return (byte >> (7 - pos)) & 1;
}
function isAlpha(pixel: number): boolean {
return pixel % 4 === 3;
}
function getRandomByte(): number {
return aesUtils.randomBytes(1)[0];
}
export const createView = (arr: Uint8Array) =>
new DataView(arr.buffer, arr.byteOffset, arr.byteLength);
type PackedFile = Uint8Array;
/**
* Represents a file, its name and size.
*
* We store the file name, because if you hide many files, it can be hard to find them.
* Create a flat byte array structure with 5 fields ABCDE, that represents file and its metadata:
* * A `bytes 0..1` name length, 4GB max
* * B `bytes 1..[1+name length]` name, 32 bytes max
* * C `bytes B..B+4` file size length, 4GB max
* * D `bytes C..[C+file length]` file contents
* * E `bytes D..end` padding filled with zeros — zeros are okay, since we encrypt them
* @example
* const file = new RawFile(utils.utf8ToBytes('hello world'), 'file.txt');
* file.pack();
*/
export class RawFile {
readonly size: number;
/**
* Unpacks packed file. Packed file is size||name||contents.
*/
static fromPacked(packed: PackedFile): RawFile {
const padded = Uint8Array.from(packed);
const view = createView(padded);
let offset = 0;
const nsize = view.getUint8(offset);
offset += 1;
if (nsize < 1) throw new Error('file name must contain at least 1 character');
const name = utils.bytesToUtf8(padded.subarray(offset, offset + nsize));
offset += nsize;
const fsize = view.getUint32(offset);
offset += 4;
const unpadded = padded.subarray(offset, offset + fsize);
return new RawFile(unpadded, name);
}
/**
* Reads HTML input[type=file] into byte array and creates new RawFile from it
* @param element input[type=file]
*/
static async fromFileInput(element: HTMLInputElement): Promise<RawFile> {
return new Promise((resolve, reject) => {
const file = FileReader && element.files && element.files[0];
if (!file) return reject();
const reader = new FileReader();
reader.addEventListener('load', () => {
let res = reader.result;
if (typeof res === 'string') res = utils.utf8ToBytes(res);
if (!res) return reject(new Error('No file'));
resolve(new RawFile(new Uint8Array(res), file.name));
});
reader.addEventListener('error', reject);
reader.readAsArrayBuffer(file);
});
}
constructor(readonly data: Uint8Array, readonly name: string) {
this.size = data.byteLength;
if (!this.name) this.name = `file-${this.size}.file`;
}
protected createHeader() {
const nbytes = utils.utf8ToBytes(this.name);
const nsize = nbytes.byteLength;
if (nsize < 1 || nsize > 255) throw new Error('File name must be 1-255 chars');
const metadataSize = 1 + nsize + 4;
const meta = new Uint8Array(metadataSize);
const view = createView(meta);
// name length
let offset = 0;
view.setUint8(offset, nsize);
offset += 1;
// name
meta.set(nbytes, offset);
offset += nsize;
// size
view.setUint32(offset, this.size);
offset += 4;
return meta;
}
/**
* Creates (size||name||contents) byte array from RawFile.
*/
pack(): PackedFile {
const header = this.createHeader();
const packed = new Uint8Array(header.byteLength + this.size);
// packed = header || data
packed.set(header);
packed.set(this.data, header.byteLength);
return packed;
}
/**
* Creates (size||name||contents||padding) byte array from RawFile.
* Warning: pads with zeros, which are detectable if used as-is.
* However, the result would be encrypted, so we don't care about that.
* There is no need to use CSPRNG instead of zeros: even if we've did,
* the size||name stuff would still be detectable.
* When used with encryption, make sure to reduce requiredLength by
* encryption metadata size.
* @param requiredLength byte array of this length would be created
*/
packWithPadding(requiredLength: number): PackedFile {
const packed = this.pack();
const difference = requiredLength - packed.length;
if (difference < 0) throw new Error('requiredLength is lesser than result');
const padded = new Uint8Array(packed.length + difference);
padded.set(packed, 0);
return padded;
}
download() {
utils.downloadFile(utils.bytesToURL(this.data), this.name);
}
}
/**
* StegImage represents a PNG image that may contain hidden data.
* First 4 bits contain `bitsTaken` param which tells `StegImage.reveal()`
* How much bits we should take from every pixel.
* Encryption disguises hidden data and makes it unrecognizable from garbage.
* TODO: investigate if `bitsTaken` can be used to detect steg; research alternatives.
* @example
* const png = new StegImage(document.querySelector('.user-image'));
* const file = new RawFile(utf8ToBytes('hello'), 'readme.txt');
* const encryptionKey = randomBytes(32);
* const stegPng = await png.hide(file, encryptionKey);
*/
export class StegImage {
protected canvas: HTMLCanvasElement;
protected imageData: ImageData;
static async fromBytesOrURL(urlOrBytes: string | Uint8Array): Promise<StegImage> {
const image = new Image();
const src = urlOrBytes instanceof Uint8Array ? utils.bytesToURL(urlOrBytes) : urlOrBytes;
await utils.setImageSource(image, src, true);
return new StegImage(image);
}
constructor(protected readonly image: HTMLImageElement) {
const { canvas, imageData } = this.createCanvas(image);
this.canvas = canvas;
this.imageData = imageData;
}
protected createCanvas(image = this.image) {
// const { image } = this;
const canvas = document.createElement('canvas');
canvas.width = image.width;
canvas.height = image.height;
const context = canvas.getContext('2d');
if (!context) throw new Error('Invalid context');
context.drawImage(image, 0, 0);
const imageData = context.getImageData(0, 0, image.width, image.height);
// if (urlOrBytes instanceof Uint8Array) URL.revokeObjectURL(image.src);
return { canvas, imageData };
}
// We repeat constructor logic, but we cannot reuse reset() in constructor because
// of TypeScript errors
protected reset() {
const { canvas, imageData } = this.createCanvas();
this.canvas = canvas;
this.imageData = imageData;
}
calcCapacity(bitsTaken: number) {
validateBits(bitsTaken);
// Total RGBA channels
const channels = this.imageData!.data;
const rgba = 4;
// First pixel is used for storing bitsTaken
const channelsNoFirst = channels.length - rgba;
// Calculate total pixels by dividing channels by RGBA
const pixels = channelsNoFirst / rgba;
// We don't use alpha channel
const rgb = 3;
// Multiply pixels by RGB since we don't use alpha channel
const bits = pixels * rgb * bitsTaken;
// 15 bits = 1 byte, not 2 bytes
const bytes = Math.floor(bits / 8);
return { bits, bytes };
}
// End result is:
// 12-byte IV ||
// encrypted file data (4-byte size || 32-byte name || plaintext data || zeros-padding) ||
// 16-byte auth tag
async hide(rawFile: RawFile, key: Uint8Array, bitsTaken = 1): Promise<string> {
const capacity = this.calcCapacity(bitsTaken).bytes;
const packed = rawFile.packWithPadding(capacity - ENCRYPTED_METADATA_SIZE);
const ciphertext = await aes.encrypt(key, packed);
if (ciphertext.byteLength !== capacity)
throw new Error('Encrypted blob must be equal to total data length');
return await this.hideBlob(ciphertext, bitsTaken);
}
async reveal(key: Uint8Array): Promise<RawFile> {
const ciphertext = await this.revealBlob();
const packed = await aes.decrypt(key, ciphertext);
return RawFile.fromPacked(packed); // compatible with RawFile and PaddedFile
}
/**
* Hides arbitrary data in png.
* Don't use it directly, prefer `hide()` with padding & encryption.
* 1 pixel is represented by 4 bytes RGBA. We can use:
*
* 1. RGB channels to encode data (24 bits)
* 2. A alpha / transparency channel (8 bit)
*
* PNG encoder in browsers losses data because of alpha channel multiplication optimization.
* Because of that, and since 24 > 8, we pick 1). Summing up, inside 1 pixel we can hide
* from 3 bits at bitsTaken=1 up to 24 bits at bitsTaken=8.
* @param hData - data that would be hidden inside of the png
* @param bitsTaken - how many bits we can place in single channel
* @returns url
*/
async hideBlob(hData: Uint8Array, bitsTaken = 1): Promise<string> {
if (!(hData instanceof Uint8Array)) throw new Error('Uint8Array expected');
// TODO:
const canvas = this.canvas;
const channels = this.imageData!.data;
const channelsLen = channels.length;
const hDataLen = hData.byteLength;
validateBits(bitsTaken);
const cap = this.calcCapacity(bitsTaken).bytes;
if (hDataLen > cap)
throw new Error(
'StegImage#hideBlob: ' +
`Can't hide ${hDataLen} bytes in ${cap} bytes at ${bitsTaken} bits taken`
);
let channelId = 0; // first channel of second pixel
function writeChannel(data: number, bits = bitsTaken) {
const curr = channels[channelId];
channels[channelId++] = clearBits(curr, bits) | data;
// alpha channel is always black, skip
// TODO: 256 is 0, maybe we need 255?
if (isAlpha(channelId)) channels[channelId++] = 256;
}
// First pixel aka first 4 bytes represent amount of bits per value.
// NOTE: this is the only value stored in plaintext, we can't encrypt it,
// because we won't know how many bits per channel to read.
// Instead of storing bitsTaken, another approach would be to walk through all
// possible bitsTaken choices, but it will make the process 8x slower:
// for (let i = 1; i < 9; i++) try { return reveal({ bitsTaken: i }); } catch(e) {}
// throw new Error('Cannot find data');
// read bits starting from 5 (bitsTaken can be 3 bit (7) at most)
// bitsTaken-1 is because we store 1..8 in 0..7
while (channelId < 3) writeChannel(readBit(bitsTaken - 1, 8 - 3 + channelId), 1);
// Buffer to place bits
let buf = 0;
// How many bits we've placed into buffer
let bufBits = 0;
// Start hiding the data
for (let byte = 0; byte < hData.length; byte++) {
let hiddenDataByte = hData[byte];
// Iterate through byte bits
for (let bit = 0; bit < 8; bit++) {
// buf.push(bit)
// 0b111 << 1 = 0b1110
// 0b111 << 1 | 1 = 0b1111
buf = (buf << 1) | readBit(hiddenDataByte, bit);
// We've added one bit, increment the counter
bufBits++;
// We have enough data to write in single channel of current pixel
if (bufBits === bitsTaken) {
writeChannel(buf);
buf = 0;
bufBits = 0;
}
}
}
// Leftovers, at this point we have some bits in buffer, but they are less
// bitsTaken, so we cannot write full channel
if (bufBits) {
const randomByte = getRandomByte();
// How many random bits we need to write in buffer
const leftoverBits = bitsTaken - bufBits;
for (let i = 0; i < leftoverBits; i++) {
// Should not happen
if (i > 7) throw new Error('StegImage#hideBlob: Need more than 7 random bits');
const randomBit = readBit(randomByte, i);
// Write random bit to buffer
buf = (buf << 1) | randomBit;
// We've added one bit, increment the counter
bufBits++;
}
// Should not happen
if (bufBits !== bitsTaken) throw new Error('StegImage#hideBlob: bufBits !== bitsTaken');
writeChannel(buf);
}
// Even after flushing buffer, there still can be channels without randomData
while (channelId < channelsLen) {
const randomByte = getRandomByte();
const bitsTakenMask = 2 ** bitsTaken - 1;
writeChannel(randomByte & bitsTakenMask);
}
if (channelId !== channelsLen) {
throw new Error(
'StegImage#hideBlob: Current pixel length ' +
`${channelId} is different from total capacity ${channelsLen}`
);
}
const vctx = canvas.getContext('2d');
if (!vctx) throw new Error('StegImage#hideBlob: No context');
// Write data to image
vctx.putImageData(this.imageData, 0, 0);
// Verify that image contains same data after re-decoding.
const vchannels = vctx.getImageData(0, 0, canvas.width, canvas.height).data;
for (let i = 0; i < channelsLen; i++) {
const v1 = channels[i];
const v2 = vchannels[i];
if (v1 !== v2) {
throw new Error(
`StegImage#hideBlob: Mismatch after verification; idx=${i} v1=${v1} v2=${v2} pos=${i % 4}`
);
}
}
return await new Promise((resolve, reject) => {
// toDataURL() is 300ms slower, probably because of base64 encoding
canvas.toBlob((b) => {
if (b) resolve(URL.createObjectURL(b));
else reject(new Error('StegImage#hideBlob: No blob'));
this.reset();
});
});
}
// Can throw
revealBitsTaken(): number {
const channels = this.imageData.data;
const bit0 = readBit(channels[0], 7) << 2;
const bit1 = readBit(channels[1], 7) << 1;
const bit2 = readBit(channels[2], 7);
// 0 represents 1 bitsTaken, 7 represents 8.
const bitsTaken = 1 + (bit0 | bit1 | bit2);
validateBits(bitsTaken);
return bitsTaken;
}
async revealBlob(): Promise<Uint8Array> {
const channels = this.imageData.data;
const bitsTaken = this.revealBitsTaken();
// We can read up to this amount of bytes from image
const { bytes } = this.calcCapacity(bitsTaken);
const mask = 2 ** bitsTaken - 1;
let buf = 0;
let bufBits = 0;
const out = new Uint8Array(bytes);
let outPos = 0;
for (let channelId = 4; channelId < channels.length; channelId++) {
// skip alpha channel
if (isAlpha(channelId)) channelId++;
// read bitsTaken bits from current channel into buffer
buf = (buf << bitsTaken) | (channels[channelId] & mask);
bufBits += bitsTaken;
// If buffer has at least 8 bits, we can create byte from them
if (bufBits >= 8) {
// push 8 bits from buffer to bytes
const leftBits = bufBits - 8;
out[outPos++] = buf >> leftBits;
// remove 8 bits from buffer
buf = buf & (2 ** leftBits - 1);
bufBits = leftBits;
}
}
return out;
}
}
export const utils = {
utf8ToBytes(str: string) {
return new TextEncoder().encode(str);
},
bytesToUtf8(bytes: Uint8Array) {
return new TextDecoder().decode(bytes);
},
bytesToURL(bytes: Uint8Array) {
return URL.createObjectURL(new Blob([bytes]));
},
setImageSource(el: HTMLImageElement, url: string, revoke = false): Promise<void> {
return new Promise((resolve) => {
el.src = url;
el.addEventListener('load', () => {
// Revoking object URL would still show the image on page, but
// the ability to download it would be broken.
if (revoke) URL.revokeObjectURL(url);
resolve();
});
});
},
downloadFile(url: string, fileName = `hidden-${new Date().toISOString()}.png`) {
const link = document.createElement('a');
link.href = url;
link.textContent = 'Download';
link.setAttribute('download', fileName);
link.click();
},
formatSize(bytes: number): string {
const KB = 1024;
const MB = 1024 * 1024;
if (bytes < KB) return `${bytes}B`;
if (bytes < MB) return `${(bytes / KB).toFixed(2)}KB`;
return `${(bytes / MB).toFixed(2)}MB`;
}
};