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 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
+ [![CI](https://github.com/PrateekSingh070/stegano-kit/actions/workflows/ci.yml/badge.svg)](https://github.com/PrateekSingh070/stegano-kit/actions/workflows/ci.yml)
4
+ [![npm version](https://img.shields.io/npm/v/stegano-kit.svg)](https://www.npmjs.com/package/stegano-kit)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
6
+ [![TypeScript](https://img.shields.io/badge/TypeScript-strict-blue.svg)](https://www.typescriptlang.org/)
7
+ [![Zero Dependencies](https://img.shields.io/badge/dependencies-0-brightgreen.svg)](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)
@@ -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 };
@@ -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
+ }