stegano-kit 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/LICENSE +21 -0
- package/README.md +156 -0
- package/dist/index.d.mts +57 -0
- package/dist/index.d.ts +57 -0
- package/dist/index.js +228 -0
- package/dist/index.mjs +199 -0
- package/package.json +57 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Prateek Singh
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# stegano-kit
|
|
2
|
+
|
|
3
|
+
[](https://github.com/PrateekSingh070/stegano-kit/actions/workflows/ci.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/stegano-kit)
|
|
5
|
+
[](https://opensource.org/licenses/MIT)
|
|
6
|
+
[](https://www.typescriptlang.org/)
|
|
7
|
+
[](https://www.npmjs.com/package/stegano-kit)
|
|
8
|
+
|
|
9
|
+
Lightweight, zero-dependency steganography library for the browser and Node.js. Hide secret messages inside images using LSB (Least Significant Bit) encoding, with optional AES-256 encryption.
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
npm install stegano-kit
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Why?
|
|
16
|
+
|
|
17
|
+
Existing JS steganography packages are either abandoned, browser-only, lack TypeScript support, or have heavy dependencies. `stegano-kit` is:
|
|
18
|
+
|
|
19
|
+
- **Tiny** — under 8 KB bundled, zero runtime dependencies
|
|
20
|
+
- **Typed** — first-class TypeScript with full type exports
|
|
21
|
+
- **Flexible** — configurable bits-per-channel (1–4), channel selection (R/G/B/A)
|
|
22
|
+
- **Secure** — optional AES-256-GCM encryption via Web Crypto
|
|
23
|
+
- **Universal** — works in browsers (Canvas API) and Node.js (raw pixel buffers)
|
|
24
|
+
|
|
25
|
+
## Quick Start
|
|
26
|
+
|
|
27
|
+
### Browser
|
|
28
|
+
|
|
29
|
+
```js
|
|
30
|
+
import { encode, decode, capacity } from 'stegano-kit';
|
|
31
|
+
|
|
32
|
+
// Load image onto a canvas
|
|
33
|
+
const img = document.getElementById('source-image');
|
|
34
|
+
const canvas = document.createElement('canvas');
|
|
35
|
+
canvas.width = img.naturalWidth;
|
|
36
|
+
canvas.height = img.naturalHeight;
|
|
37
|
+
const ctx = canvas.getContext('2d');
|
|
38
|
+
ctx.drawImage(img, 0, 0);
|
|
39
|
+
|
|
40
|
+
// Check capacity
|
|
41
|
+
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
|
42
|
+
console.log(capacity(imageData));
|
|
43
|
+
// → { totalBytes: 3742, readable: '3.7 KB', width: 100, height: 100 }
|
|
44
|
+
|
|
45
|
+
// Encode a secret message
|
|
46
|
+
const encoded = await encode(imageData, 'Attack at dawn 🌅');
|
|
47
|
+
ctx.putImageData(new ImageData(encoded.data, encoded.width, encoded.height), 0, 0);
|
|
48
|
+
|
|
49
|
+
// Decode
|
|
50
|
+
const decoded = await decode(encoded);
|
|
51
|
+
console.log(decoded); // → "Attack at dawn 🌅"
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### Node.js (with raw pixel data)
|
|
55
|
+
|
|
56
|
+
```js
|
|
57
|
+
const { encode, decode } = require('stegano-kit');
|
|
58
|
+
|
|
59
|
+
// Create or load RGBA pixel data from your image library of choice
|
|
60
|
+
const imageData = {
|
|
61
|
+
width: 200,
|
|
62
|
+
height: 200,
|
|
63
|
+
data: new Uint8ClampedArray(200 * 200 * 4), // your pixel data
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const encoded = await encode(imageData, 'Secret message');
|
|
67
|
+
const secret = await decode(encoded);
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## API
|
|
71
|
+
|
|
72
|
+
### `encode(imageData, message, options?)`
|
|
73
|
+
|
|
74
|
+
Embeds a secret string into image pixel data.
|
|
75
|
+
|
|
76
|
+
| Parameter | Type | Description |
|
|
77
|
+
| ----------- | ------------ | ---------------------------------------- |
|
|
78
|
+
| `imageData` | `ImageLike` | Object with `width`, `height`, and `data` (Uint8ClampedArray RGBA) |
|
|
79
|
+
| `message` | `string` | The secret text to hide |
|
|
80
|
+
| `options` | `EncodeOptions` | Optional settings (see below) |
|
|
81
|
+
|
|
82
|
+
Returns `Promise<ImageLike>` — a new pixel buffer with the message embedded.
|
|
83
|
+
|
|
84
|
+
### `decode(imageData, options?)`
|
|
85
|
+
|
|
86
|
+
Extracts a hidden message from image pixel data.
|
|
87
|
+
|
|
88
|
+
| Parameter | Type | Description |
|
|
89
|
+
| ----------- | ------------ | ---------------------------------------- |
|
|
90
|
+
| `imageData` | `ImageLike` | Encoded image pixel data |
|
|
91
|
+
| `options` | `DecodeOptions` | Must match encoding options |
|
|
92
|
+
|
|
93
|
+
Returns `Promise<string>` — the decoded secret.
|
|
94
|
+
|
|
95
|
+
### `capacity(imageData, options?)`
|
|
96
|
+
|
|
97
|
+
Calculates how many bytes of secret data the image can hold.
|
|
98
|
+
|
|
99
|
+
Returns `CapacityInfo`:
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
{
|
|
103
|
+
totalBytes: number; // max payload size in bytes
|
|
104
|
+
readable: string; // human-friendly string like "12.4 KB"
|
|
105
|
+
width: number;
|
|
106
|
+
height: number;
|
|
107
|
+
}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Options
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
interface EncodeOptions {
|
|
114
|
+
bitsPerChannel?: number; // 1–4, default: 1
|
|
115
|
+
channels?: ('r'|'g'|'b'|'a')[]; // default: ['r','g','b']
|
|
116
|
+
password?: string; // enables AES-256-GCM encryption
|
|
117
|
+
}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
| Option | Default | Description |
|
|
121
|
+
| ---------------- | --------------- | ---------------------------------- |
|
|
122
|
+
| `bitsPerChannel` | `1` | More bits = more capacity, but more visible artifacts |
|
|
123
|
+
| `channels` | `['r','g','b']` | Which color channels to use |
|
|
124
|
+
| `password` | `undefined` | If set, payload is encrypted with AES-256-GCM |
|
|
125
|
+
|
|
126
|
+
> **Tip:** `bitsPerChannel: 1` with RGB channels is virtually invisible to the human eye. Increase to 2 for ~2x capacity if minor color shifts are acceptable.
|
|
127
|
+
|
|
128
|
+
## Encryption
|
|
129
|
+
|
|
130
|
+
Pass a `password` to both `encode` and `decode` to encrypt the payload with AES-256-GCM (PBKDF2 key derivation, 100k iterations):
|
|
131
|
+
|
|
132
|
+
```js
|
|
133
|
+
const encoded = await encode(imageData, 'Top secret', { password: 'my-key' });
|
|
134
|
+
const decoded = await decode(encoded, { password: 'my-key' });
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Without the correct password, `decode` will throw.
|
|
138
|
+
|
|
139
|
+
## How It Works
|
|
140
|
+
|
|
141
|
+
1. Your message is converted to bytes (UTF-8), optionally encrypted
|
|
142
|
+
2. A bit stream is constructed: `[32-bit magic header][32-bit payload length][payload bits]`
|
|
143
|
+
3. Each bit is written into the least significant bit(s) of selected color channels
|
|
144
|
+
4. On decode, the magic header is verified, length is read, and payload is extracted
|
|
145
|
+
|
|
146
|
+
The image looks identical to the naked eye — pixel values change by at most ±1 (with default 1-bit encoding).
|
|
147
|
+
|
|
148
|
+
## Limitations
|
|
149
|
+
|
|
150
|
+
- Input must be raw RGBA pixel data (`Uint8ClampedArray`). Use Canvas API in browsers, or a library like `sharp`/`jimp` in Node.js to get pixel buffers.
|
|
151
|
+
- JPEG re-compression destroys hidden data. Always save encoded images as PNG.
|
|
152
|
+
- Alpha channel encoding (`channels: ['r','g','b','a']`) may cause issues with transparent images.
|
|
153
|
+
|
|
154
|
+
## License
|
|
155
|
+
|
|
156
|
+
MIT © [Prateek Singh](https://github.com/PrateekSingh070)
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
interface EncodeOptions {
|
|
2
|
+
/** Number of least-significant bits to use per color channel (1-4). Default: 1 */
|
|
3
|
+
bitsPerChannel?: number;
|
|
4
|
+
/** Which color channels to embed data in. Default: ['r','g','b'] */
|
|
5
|
+
channels?: Array<'r' | 'g' | 'b' | 'a'>;
|
|
6
|
+
/** Optional encryption password — AES-256-GCM via SubtleCrypto */
|
|
7
|
+
password?: string;
|
|
8
|
+
}
|
|
9
|
+
interface DecodeOptions {
|
|
10
|
+
/** Must match the bitsPerChannel used during encoding. Default: 1 */
|
|
11
|
+
bitsPerChannel?: number;
|
|
12
|
+
/** Must match the channels used during encoding. Default: ['r','g','b'] */
|
|
13
|
+
channels?: Array<'r' | 'g' | 'b' | 'a'>;
|
|
14
|
+
/** Password if the message was encrypted during encoding */
|
|
15
|
+
password?: string;
|
|
16
|
+
}
|
|
17
|
+
interface CapacityInfo {
|
|
18
|
+
/** Total bytes that can be hidden in this image with current settings */
|
|
19
|
+
totalBytes: number;
|
|
20
|
+
/** Human-readable capacity string (e.g. "12.4 KB") */
|
|
21
|
+
readable: string;
|
|
22
|
+
/** Image dimensions */
|
|
23
|
+
width: number;
|
|
24
|
+
height: number;
|
|
25
|
+
}
|
|
26
|
+
interface ImageLike {
|
|
27
|
+
width: number;
|
|
28
|
+
height: number;
|
|
29
|
+
data: Uint8ClampedArray;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Encode a secret message into raw RGBA pixel data using LSB steganography.
|
|
34
|
+
*
|
|
35
|
+
* @param imageData - Object with `width`, `height`, and `data` (Uint8ClampedArray of RGBA pixels).
|
|
36
|
+
* In the browser, pass the result of `ctx.getImageData(...)`.
|
|
37
|
+
* @param message - The secret string to hide.
|
|
38
|
+
* @param options - Optional encoding parameters.
|
|
39
|
+
* @returns A new ImageData-like object with the message embedded.
|
|
40
|
+
*/
|
|
41
|
+
declare function encode(imageData: ImageLike, message: string, options?: EncodeOptions): Promise<ImageLike>;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Decode a hidden message from RGBA pixel data.
|
|
45
|
+
*
|
|
46
|
+
* @param imageData - Object with `width`, `height`, and `data` (Uint8ClampedArray of RGBA pixels).
|
|
47
|
+
* @param options - Must match the options used during encoding.
|
|
48
|
+
* @returns The decoded secret string.
|
|
49
|
+
*/
|
|
50
|
+
declare function decode(imageData: ImageLike, options?: DecodeOptions): Promise<string>;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Calculate how much secret data an image can hold with the given settings.
|
|
54
|
+
*/
|
|
55
|
+
declare function capacity(imageData: ImageLike, options?: EncodeOptions): CapacityInfo;
|
|
56
|
+
|
|
57
|
+
export { type CapacityInfo, type DecodeOptions, type EncodeOptions, type ImageLike, capacity, decode, encode };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
interface EncodeOptions {
|
|
2
|
+
/** Number of least-significant bits to use per color channel (1-4). Default: 1 */
|
|
3
|
+
bitsPerChannel?: number;
|
|
4
|
+
/** Which color channels to embed data in. Default: ['r','g','b'] */
|
|
5
|
+
channels?: Array<'r' | 'g' | 'b' | 'a'>;
|
|
6
|
+
/** Optional encryption password — AES-256-GCM via SubtleCrypto */
|
|
7
|
+
password?: string;
|
|
8
|
+
}
|
|
9
|
+
interface DecodeOptions {
|
|
10
|
+
/** Must match the bitsPerChannel used during encoding. Default: 1 */
|
|
11
|
+
bitsPerChannel?: number;
|
|
12
|
+
/** Must match the channels used during encoding. Default: ['r','g','b'] */
|
|
13
|
+
channels?: Array<'r' | 'g' | 'b' | 'a'>;
|
|
14
|
+
/** Password if the message was encrypted during encoding */
|
|
15
|
+
password?: string;
|
|
16
|
+
}
|
|
17
|
+
interface CapacityInfo {
|
|
18
|
+
/** Total bytes that can be hidden in this image with current settings */
|
|
19
|
+
totalBytes: number;
|
|
20
|
+
/** Human-readable capacity string (e.g. "12.4 KB") */
|
|
21
|
+
readable: string;
|
|
22
|
+
/** Image dimensions */
|
|
23
|
+
width: number;
|
|
24
|
+
height: number;
|
|
25
|
+
}
|
|
26
|
+
interface ImageLike {
|
|
27
|
+
width: number;
|
|
28
|
+
height: number;
|
|
29
|
+
data: Uint8ClampedArray;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Encode a secret message into raw RGBA pixel data using LSB steganography.
|
|
34
|
+
*
|
|
35
|
+
* @param imageData - Object with `width`, `height`, and `data` (Uint8ClampedArray of RGBA pixels).
|
|
36
|
+
* In the browser, pass the result of `ctx.getImageData(...)`.
|
|
37
|
+
* @param message - The secret string to hide.
|
|
38
|
+
* @param options - Optional encoding parameters.
|
|
39
|
+
* @returns A new ImageData-like object with the message embedded.
|
|
40
|
+
*/
|
|
41
|
+
declare function encode(imageData: ImageLike, message: string, options?: EncodeOptions): Promise<ImageLike>;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Decode a hidden message from RGBA pixel data.
|
|
45
|
+
*
|
|
46
|
+
* @param imageData - Object with `width`, `height`, and `data` (Uint8ClampedArray of RGBA pixels).
|
|
47
|
+
* @param options - Must match the options used during encoding.
|
|
48
|
+
* @returns The decoded secret string.
|
|
49
|
+
*/
|
|
50
|
+
declare function decode(imageData: ImageLike, options?: DecodeOptions): Promise<string>;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Calculate how much secret data an image can hold with the given settings.
|
|
54
|
+
*/
|
|
55
|
+
declare function capacity(imageData: ImageLike, options?: EncodeOptions): CapacityInfo;
|
|
56
|
+
|
|
57
|
+
export { type CapacityInfo, type DecodeOptions, type EncodeOptions, type ImageLike, capacity, decode, encode };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
capacity: () => capacity,
|
|
24
|
+
decode: () => decode,
|
|
25
|
+
encode: () => encode
|
|
26
|
+
});
|
|
27
|
+
module.exports = __toCommonJS(index_exports);
|
|
28
|
+
|
|
29
|
+
// src/utils.ts
|
|
30
|
+
var HEADER_BITS = 32;
|
|
31
|
+
var MAGIC = 1398031687;
|
|
32
|
+
function getChannelIndices(channels) {
|
|
33
|
+
const map = { r: 0, g: 1, b: 2, a: 3 };
|
|
34
|
+
return channels.map((c) => map[c]);
|
|
35
|
+
}
|
|
36
|
+
function resolveEncodeOpts(opts) {
|
|
37
|
+
return {
|
|
38
|
+
bitsPerChannel: opts?.bitsPerChannel ?? 1,
|
|
39
|
+
channels: opts?.channels ?? ["r", "g", "b"],
|
|
40
|
+
password: opts?.password
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
function resolveDecodeOpts(opts) {
|
|
44
|
+
return {
|
|
45
|
+
bitsPerChannel: opts?.bitsPerChannel ?? 1,
|
|
46
|
+
channels: opts?.channels ?? ["r", "g", "b"],
|
|
47
|
+
password: opts?.password
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
function textToBytes(text) {
|
|
51
|
+
return new TextEncoder().encode(text);
|
|
52
|
+
}
|
|
53
|
+
function bytesToText(bytes) {
|
|
54
|
+
return new TextDecoder().decode(bytes);
|
|
55
|
+
}
|
|
56
|
+
function buildBitStream(payload) {
|
|
57
|
+
const totalBits = HEADER_BITS + HEADER_BITS + payload.length * 8;
|
|
58
|
+
const bits = new Uint8Array(totalBits);
|
|
59
|
+
let idx = 0;
|
|
60
|
+
for (let i = 31; i >= 0; i--) bits[idx++] = MAGIC >>> i & 1;
|
|
61
|
+
for (let i = 31; i >= 0; i--) bits[idx++] = payload.length >>> i & 1;
|
|
62
|
+
for (const byte of payload) {
|
|
63
|
+
for (let i = 7; i >= 0; i--) {
|
|
64
|
+
bits[idx++] = byte >>> i & 1;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return bits;
|
|
68
|
+
}
|
|
69
|
+
function readUint32(bits, offset) {
|
|
70
|
+
let value = 0;
|
|
71
|
+
for (let i = 0; i < 32; i++) {
|
|
72
|
+
value = value << 1 | bits[offset + i];
|
|
73
|
+
}
|
|
74
|
+
return value >>> 0;
|
|
75
|
+
}
|
|
76
|
+
function maxPayloadBytes(imageData, bitsPerChannel, channelCount) {
|
|
77
|
+
const totalUsableBits = imageData.width * imageData.height * channelCount * bitsPerChannel;
|
|
78
|
+
const headerBits = HEADER_BITS * 2;
|
|
79
|
+
return Math.floor((totalUsableBits - headerBits) / 8);
|
|
80
|
+
}
|
|
81
|
+
async function deriveKey(password, salt) {
|
|
82
|
+
const enc = new TextEncoder();
|
|
83
|
+
const keyMaterial = await crypto.subtle.importKey(
|
|
84
|
+
"raw",
|
|
85
|
+
enc.encode(password),
|
|
86
|
+
"PBKDF2",
|
|
87
|
+
false,
|
|
88
|
+
["deriveKey"]
|
|
89
|
+
);
|
|
90
|
+
return crypto.subtle.deriveKey(
|
|
91
|
+
{ name: "PBKDF2", salt, iterations: 1e5, hash: "SHA-256" },
|
|
92
|
+
keyMaterial,
|
|
93
|
+
{ name: "AES-GCM", length: 256 },
|
|
94
|
+
false,
|
|
95
|
+
["encrypt", "decrypt"]
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
async function encrypt(data, password) {
|
|
99
|
+
const salt = crypto.getRandomValues(new Uint8Array(16));
|
|
100
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
101
|
+
const key = await deriveKey(password, salt);
|
|
102
|
+
const cipher = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, data);
|
|
103
|
+
const out = new Uint8Array(salt.length + iv.length + cipher.byteLength);
|
|
104
|
+
out.set(salt, 0);
|
|
105
|
+
out.set(iv, salt.length);
|
|
106
|
+
out.set(new Uint8Array(cipher), salt.length + iv.length);
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
async function decrypt(data, password) {
|
|
110
|
+
const salt = data.slice(0, 16);
|
|
111
|
+
const iv = data.slice(16, 28);
|
|
112
|
+
const cipher = data.slice(28);
|
|
113
|
+
const key = await deriveKey(password, salt);
|
|
114
|
+
const plain = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, cipher);
|
|
115
|
+
return new Uint8Array(plain);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// src/encode.ts
|
|
119
|
+
async function encode(imageData, message, options) {
|
|
120
|
+
const { bitsPerChannel, channels, password } = resolveEncodeOpts(options);
|
|
121
|
+
let payload = textToBytes(message);
|
|
122
|
+
if (password) {
|
|
123
|
+
payload = await encrypt(payload, password);
|
|
124
|
+
}
|
|
125
|
+
const capacity2 = maxPayloadBytes(imageData, bitsPerChannel, channels.length);
|
|
126
|
+
if (payload.length > capacity2) {
|
|
127
|
+
throw new RangeError(
|
|
128
|
+
`Message too large: ${payload.length} bytes, but image can hold at most ${capacity2} bytes with current settings (${bitsPerChannel} bits/channel, channels: ${channels.join(",")}).`
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
const bits = buildBitStream(payload);
|
|
132
|
+
const channelIdx = getChannelIndices(channels);
|
|
133
|
+
const out = new Uint8ClampedArray(imageData.data);
|
|
134
|
+
const mask = 255 << bitsPerChannel;
|
|
135
|
+
let bitPos = 0;
|
|
136
|
+
outer: for (let px = 0; px < imageData.width * imageData.height; px++) {
|
|
137
|
+
const base = px * 4;
|
|
138
|
+
for (const ci of channelIdx) {
|
|
139
|
+
if (bitPos >= bits.length) break outer;
|
|
140
|
+
let value = 0;
|
|
141
|
+
for (let b = bitsPerChannel - 1; b >= 0; b--) {
|
|
142
|
+
if (bitPos < bits.length) {
|
|
143
|
+
value |= bits[bitPos++] << b;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
out[base + ci] = out[base + ci] & mask | value;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return { width: imageData.width, height: imageData.height, data: out };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// src/decode.ts
|
|
153
|
+
async function decode(imageData, options) {
|
|
154
|
+
const { bitsPerChannel, channels, password } = resolveDecodeOpts(options);
|
|
155
|
+
const channelIdx = getChannelIndices(channels);
|
|
156
|
+
const extractBits = (count, startBit) => {
|
|
157
|
+
const bits = new Uint8Array(count);
|
|
158
|
+
let bitPos = startBit;
|
|
159
|
+
let written = 0;
|
|
160
|
+
let px = Math.floor(bitPos / (channelIdx.length * bitsPerChannel));
|
|
161
|
+
let channelOffset = Math.floor(bitPos % (channelIdx.length * bitsPerChannel) / bitsPerChannel);
|
|
162
|
+
let bitInChannel = bitPos % bitsPerChannel;
|
|
163
|
+
outer: for (; px < imageData.width * imageData.height; px++) {
|
|
164
|
+
const base = px * 4;
|
|
165
|
+
for (; channelOffset < channelIdx.length; channelOffset++) {
|
|
166
|
+
const val = imageData.data[base + channelIdx[channelOffset]];
|
|
167
|
+
for (; bitInChannel < bitsPerChannel; bitInChannel++) {
|
|
168
|
+
if (written >= count) break outer;
|
|
169
|
+
const shift = bitsPerChannel - 1 - bitInChannel;
|
|
170
|
+
bits[written++] = val >>> shift & 1;
|
|
171
|
+
bitPos++;
|
|
172
|
+
}
|
|
173
|
+
bitInChannel = 0;
|
|
174
|
+
}
|
|
175
|
+
channelOffset = 0;
|
|
176
|
+
}
|
|
177
|
+
return { bits, nextBit: bitPos };
|
|
178
|
+
};
|
|
179
|
+
const { bits: magicBits, nextBit: afterMagic } = extractBits(HEADER_BITS, 0);
|
|
180
|
+
const magic = readUint32(magicBits, 0);
|
|
181
|
+
if (magic !== MAGIC) {
|
|
182
|
+
throw new Error(
|
|
183
|
+
"No hidden message found (magic header mismatch). Make sure decode options match the ones used during encoding."
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
const { bits: lenBits, nextBit: afterLen } = extractBits(HEADER_BITS, afterMagic);
|
|
187
|
+
const payloadLen = readUint32(lenBits, 0);
|
|
188
|
+
if (payloadLen < 0 || payloadLen > imageData.width * imageData.height) {
|
|
189
|
+
throw new Error("Invalid payload length detected. The image may not contain a hidden message.");
|
|
190
|
+
}
|
|
191
|
+
const { bits: payloadBits } = extractBits(payloadLen * 8, afterLen);
|
|
192
|
+
const bytes = new Uint8Array(payloadLen);
|
|
193
|
+
for (let i = 0; i < payloadLen; i++) {
|
|
194
|
+
let byte = 0;
|
|
195
|
+
for (let b = 7; b >= 0; b--) {
|
|
196
|
+
byte |= payloadBits[i * 8 + (7 - b)] << b;
|
|
197
|
+
}
|
|
198
|
+
bytes[i] = byte;
|
|
199
|
+
}
|
|
200
|
+
if (password) {
|
|
201
|
+
const decrypted = await decrypt(bytes, password);
|
|
202
|
+
return bytesToText(decrypted);
|
|
203
|
+
}
|
|
204
|
+
return bytesToText(bytes);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// src/capacity.ts
|
|
208
|
+
function humanReadable(bytes) {
|
|
209
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
210
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
211
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
212
|
+
}
|
|
213
|
+
function capacity(imageData, options) {
|
|
214
|
+
const { bitsPerChannel, channels } = resolveEncodeOpts(options);
|
|
215
|
+
const totalBytes = maxPayloadBytes(imageData, bitsPerChannel, channels.length);
|
|
216
|
+
return {
|
|
217
|
+
totalBytes,
|
|
218
|
+
readable: humanReadable(totalBytes),
|
|
219
|
+
width: imageData.width,
|
|
220
|
+
height: imageData.height
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
224
|
+
0 && (module.exports = {
|
|
225
|
+
capacity,
|
|
226
|
+
decode,
|
|
227
|
+
encode
|
|
228
|
+
});
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// src/utils.ts
|
|
2
|
+
var HEADER_BITS = 32;
|
|
3
|
+
var MAGIC = 1398031687;
|
|
4
|
+
function getChannelIndices(channels) {
|
|
5
|
+
const map = { r: 0, g: 1, b: 2, a: 3 };
|
|
6
|
+
return channels.map((c) => map[c]);
|
|
7
|
+
}
|
|
8
|
+
function resolveEncodeOpts(opts) {
|
|
9
|
+
return {
|
|
10
|
+
bitsPerChannel: opts?.bitsPerChannel ?? 1,
|
|
11
|
+
channels: opts?.channels ?? ["r", "g", "b"],
|
|
12
|
+
password: opts?.password
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
function resolveDecodeOpts(opts) {
|
|
16
|
+
return {
|
|
17
|
+
bitsPerChannel: opts?.bitsPerChannel ?? 1,
|
|
18
|
+
channels: opts?.channels ?? ["r", "g", "b"],
|
|
19
|
+
password: opts?.password
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
function textToBytes(text) {
|
|
23
|
+
return new TextEncoder().encode(text);
|
|
24
|
+
}
|
|
25
|
+
function bytesToText(bytes) {
|
|
26
|
+
return new TextDecoder().decode(bytes);
|
|
27
|
+
}
|
|
28
|
+
function buildBitStream(payload) {
|
|
29
|
+
const totalBits = HEADER_BITS + HEADER_BITS + payload.length * 8;
|
|
30
|
+
const bits = new Uint8Array(totalBits);
|
|
31
|
+
let idx = 0;
|
|
32
|
+
for (let i = 31; i >= 0; i--) bits[idx++] = MAGIC >>> i & 1;
|
|
33
|
+
for (let i = 31; i >= 0; i--) bits[idx++] = payload.length >>> i & 1;
|
|
34
|
+
for (const byte of payload) {
|
|
35
|
+
for (let i = 7; i >= 0; i--) {
|
|
36
|
+
bits[idx++] = byte >>> i & 1;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return bits;
|
|
40
|
+
}
|
|
41
|
+
function readUint32(bits, offset) {
|
|
42
|
+
let value = 0;
|
|
43
|
+
for (let i = 0; i < 32; i++) {
|
|
44
|
+
value = value << 1 | bits[offset + i];
|
|
45
|
+
}
|
|
46
|
+
return value >>> 0;
|
|
47
|
+
}
|
|
48
|
+
function maxPayloadBytes(imageData, bitsPerChannel, channelCount) {
|
|
49
|
+
const totalUsableBits = imageData.width * imageData.height * channelCount * bitsPerChannel;
|
|
50
|
+
const headerBits = HEADER_BITS * 2;
|
|
51
|
+
return Math.floor((totalUsableBits - headerBits) / 8);
|
|
52
|
+
}
|
|
53
|
+
async function deriveKey(password, salt) {
|
|
54
|
+
const enc = new TextEncoder();
|
|
55
|
+
const keyMaterial = await crypto.subtle.importKey(
|
|
56
|
+
"raw",
|
|
57
|
+
enc.encode(password),
|
|
58
|
+
"PBKDF2",
|
|
59
|
+
false,
|
|
60
|
+
["deriveKey"]
|
|
61
|
+
);
|
|
62
|
+
return crypto.subtle.deriveKey(
|
|
63
|
+
{ name: "PBKDF2", salt, iterations: 1e5, hash: "SHA-256" },
|
|
64
|
+
keyMaterial,
|
|
65
|
+
{ name: "AES-GCM", length: 256 },
|
|
66
|
+
false,
|
|
67
|
+
["encrypt", "decrypt"]
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
async function encrypt(data, password) {
|
|
71
|
+
const salt = crypto.getRandomValues(new Uint8Array(16));
|
|
72
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
73
|
+
const key = await deriveKey(password, salt);
|
|
74
|
+
const cipher = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, data);
|
|
75
|
+
const out = new Uint8Array(salt.length + iv.length + cipher.byteLength);
|
|
76
|
+
out.set(salt, 0);
|
|
77
|
+
out.set(iv, salt.length);
|
|
78
|
+
out.set(new Uint8Array(cipher), salt.length + iv.length);
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
async function decrypt(data, password) {
|
|
82
|
+
const salt = data.slice(0, 16);
|
|
83
|
+
const iv = data.slice(16, 28);
|
|
84
|
+
const cipher = data.slice(28);
|
|
85
|
+
const key = await deriveKey(password, salt);
|
|
86
|
+
const plain = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, cipher);
|
|
87
|
+
return new Uint8Array(plain);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// src/encode.ts
|
|
91
|
+
async function encode(imageData, message, options) {
|
|
92
|
+
const { bitsPerChannel, channels, password } = resolveEncodeOpts(options);
|
|
93
|
+
let payload = textToBytes(message);
|
|
94
|
+
if (password) {
|
|
95
|
+
payload = await encrypt(payload, password);
|
|
96
|
+
}
|
|
97
|
+
const capacity2 = maxPayloadBytes(imageData, bitsPerChannel, channels.length);
|
|
98
|
+
if (payload.length > capacity2) {
|
|
99
|
+
throw new RangeError(
|
|
100
|
+
`Message too large: ${payload.length} bytes, but image can hold at most ${capacity2} bytes with current settings (${bitsPerChannel} bits/channel, channels: ${channels.join(",")}).`
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
const bits = buildBitStream(payload);
|
|
104
|
+
const channelIdx = getChannelIndices(channels);
|
|
105
|
+
const out = new Uint8ClampedArray(imageData.data);
|
|
106
|
+
const mask = 255 << bitsPerChannel;
|
|
107
|
+
let bitPos = 0;
|
|
108
|
+
outer: for (let px = 0; px < imageData.width * imageData.height; px++) {
|
|
109
|
+
const base = px * 4;
|
|
110
|
+
for (const ci of channelIdx) {
|
|
111
|
+
if (bitPos >= bits.length) break outer;
|
|
112
|
+
let value = 0;
|
|
113
|
+
for (let b = bitsPerChannel - 1; b >= 0; b--) {
|
|
114
|
+
if (bitPos < bits.length) {
|
|
115
|
+
value |= bits[bitPos++] << b;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
out[base + ci] = out[base + ci] & mask | value;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return { width: imageData.width, height: imageData.height, data: out };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// src/decode.ts
|
|
125
|
+
async function decode(imageData, options) {
|
|
126
|
+
const { bitsPerChannel, channels, password } = resolveDecodeOpts(options);
|
|
127
|
+
const channelIdx = getChannelIndices(channels);
|
|
128
|
+
const extractBits = (count, startBit) => {
|
|
129
|
+
const bits = new Uint8Array(count);
|
|
130
|
+
let bitPos = startBit;
|
|
131
|
+
let written = 0;
|
|
132
|
+
let px = Math.floor(bitPos / (channelIdx.length * bitsPerChannel));
|
|
133
|
+
let channelOffset = Math.floor(bitPos % (channelIdx.length * bitsPerChannel) / bitsPerChannel);
|
|
134
|
+
let bitInChannel = bitPos % bitsPerChannel;
|
|
135
|
+
outer: for (; px < imageData.width * imageData.height; px++) {
|
|
136
|
+
const base = px * 4;
|
|
137
|
+
for (; channelOffset < channelIdx.length; channelOffset++) {
|
|
138
|
+
const val = imageData.data[base + channelIdx[channelOffset]];
|
|
139
|
+
for (; bitInChannel < bitsPerChannel; bitInChannel++) {
|
|
140
|
+
if (written >= count) break outer;
|
|
141
|
+
const shift = bitsPerChannel - 1 - bitInChannel;
|
|
142
|
+
bits[written++] = val >>> shift & 1;
|
|
143
|
+
bitPos++;
|
|
144
|
+
}
|
|
145
|
+
bitInChannel = 0;
|
|
146
|
+
}
|
|
147
|
+
channelOffset = 0;
|
|
148
|
+
}
|
|
149
|
+
return { bits, nextBit: bitPos };
|
|
150
|
+
};
|
|
151
|
+
const { bits: magicBits, nextBit: afterMagic } = extractBits(HEADER_BITS, 0);
|
|
152
|
+
const magic = readUint32(magicBits, 0);
|
|
153
|
+
if (magic !== MAGIC) {
|
|
154
|
+
throw new Error(
|
|
155
|
+
"No hidden message found (magic header mismatch). Make sure decode options match the ones used during encoding."
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
const { bits: lenBits, nextBit: afterLen } = extractBits(HEADER_BITS, afterMagic);
|
|
159
|
+
const payloadLen = readUint32(lenBits, 0);
|
|
160
|
+
if (payloadLen < 0 || payloadLen > imageData.width * imageData.height) {
|
|
161
|
+
throw new Error("Invalid payload length detected. The image may not contain a hidden message.");
|
|
162
|
+
}
|
|
163
|
+
const { bits: payloadBits } = extractBits(payloadLen * 8, afterLen);
|
|
164
|
+
const bytes = new Uint8Array(payloadLen);
|
|
165
|
+
for (let i = 0; i < payloadLen; i++) {
|
|
166
|
+
let byte = 0;
|
|
167
|
+
for (let b = 7; b >= 0; b--) {
|
|
168
|
+
byte |= payloadBits[i * 8 + (7 - b)] << b;
|
|
169
|
+
}
|
|
170
|
+
bytes[i] = byte;
|
|
171
|
+
}
|
|
172
|
+
if (password) {
|
|
173
|
+
const decrypted = await decrypt(bytes, password);
|
|
174
|
+
return bytesToText(decrypted);
|
|
175
|
+
}
|
|
176
|
+
return bytesToText(bytes);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// src/capacity.ts
|
|
180
|
+
function humanReadable(bytes) {
|
|
181
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
182
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
183
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
184
|
+
}
|
|
185
|
+
function capacity(imageData, options) {
|
|
186
|
+
const { bitsPerChannel, channels } = resolveEncodeOpts(options);
|
|
187
|
+
const totalBytes = maxPayloadBytes(imageData, bitsPerChannel, channels.length);
|
|
188
|
+
return {
|
|
189
|
+
totalBytes,
|
|
190
|
+
readable: humanReadable(totalBytes),
|
|
191
|
+
width: imageData.width,
|
|
192
|
+
height: imageData.height
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
export {
|
|
196
|
+
capacity,
|
|
197
|
+
decode,
|
|
198
|
+
encode
|
|
199
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "stegano-kit",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Lightweight browser & Node.js steganography library — hide and extract secret messages in images using LSB encoding",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"module": "dist/index.mjs",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"files": [
|
|
9
|
+
"dist"
|
|
10
|
+
],
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"import": "./dist/index.mjs",
|
|
15
|
+
"require": "./dist/index.js"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "tsup src/index.ts --format esm --format cjs --dts --clean",
|
|
20
|
+
"test": "vitest run",
|
|
21
|
+
"test:watch": "vitest",
|
|
22
|
+
"lint": "tsc --noEmit",
|
|
23
|
+
"prepublishOnly": "npm run build"
|
|
24
|
+
},
|
|
25
|
+
"keywords": [
|
|
26
|
+
"steganography",
|
|
27
|
+
"stego",
|
|
28
|
+
"lsb",
|
|
29
|
+
"image",
|
|
30
|
+
"hide",
|
|
31
|
+
"secret",
|
|
32
|
+
"message",
|
|
33
|
+
"encode",
|
|
34
|
+
"decode",
|
|
35
|
+
"canvas",
|
|
36
|
+
"pixel",
|
|
37
|
+
"watermark",
|
|
38
|
+
"covert",
|
|
39
|
+
"browser",
|
|
40
|
+
"typescript"
|
|
41
|
+
],
|
|
42
|
+
"author": "Prateek Singh",
|
|
43
|
+
"license": "MIT",
|
|
44
|
+
"repository": {
|
|
45
|
+
"type": "git",
|
|
46
|
+
"url": "https://github.com/PrateekSingh070/stegano-kit"
|
|
47
|
+
},
|
|
48
|
+
"homepage": "https://github.com/PrateekSingh070/stegano-kit#readme",
|
|
49
|
+
"bugs": {
|
|
50
|
+
"url": "https://github.com/PrateekSingh070/stegano-kit/issues"
|
|
51
|
+
},
|
|
52
|
+
"devDependencies": {
|
|
53
|
+
"tsup": "^8.0.0",
|
|
54
|
+
"typescript": "^5.4.0",
|
|
55
|
+
"vitest": "^2.0.0"
|
|
56
|
+
}
|
|
57
|
+
}
|