Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
7dfce95
install hash and cipher utilities from @noble
Forgata Mar 6, 2026
79bc7c6
update ignorefile to exlcude the data folder that will keep the paylo…
Forgata Mar 6, 2026
ed3ef55
feat: add function to prepend a high-entropy sync pattern to the bits…
Forgata Mar 6, 2026
8f059e1
feat: add function to convert bytes to bit for preamble injection by …
Forgata Mar 6, 2026
06a1730
feat: add GCM encryption espectially AES-GCM encryption of the payloa…
Forgata Mar 6, 2026
4805d40
feat: add function to break encrypted blob into chunks to ease recons…
Forgata Mar 6, 2026
8eb2316
feat: turn packets into a redundant array using reed-solomon encoding
Forgata Mar 6, 2026
17bac98
feat: derive key using the crypto's pbkdf2
Forgata Mar 6, 2026
dd3f877
add function to prevent acoustic event from wiping out the entire RS …
Forgata Mar 6, 2026
9043854
feat: added packet framing using the DataView for ease parsing later
Forgata Mar 6, 2026
80342e2
feat: add payload controller to bridge payload functionality in modul…
Forgata Mar 6, 2026
a2805e6
add function to pipe raw bytes into an uint8
Forgata Mar 6, 2026
3a02d48
refactor: add start function and invoked it
Forgata Mar 6, 2026
097b577
change: swap out @ronomon's reed solomon for sunspace's wasm implemen…
Forgata Mar 7, 2026
aa70dc1
add configs to support cjs and module interop
Forgata Mar 7, 2026
904738b
refactor change the recorder function to accept the bit stream from t…
Forgata Mar 7, 2026
aefa645
fix await the FEC function
Forgata Mar 7, 2026
3529a89
major feat: implemented the wasm RS by manual checking of erasure ins…
Forgata Mar 7, 2026
d812d3d
refactor: remove unnecessary comments and logs.
Forgata Mar 7, 2026
f825802
remove comments
Forgata Mar 7, 2026
7c643be
fix remove old package import
Forgata Mar 7, 2026
35e49e1
remove comments
Forgata Mar 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ node_modules
dist/
build/
.env
data/
42 changes: 42 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,10 @@
"typescript": "^5.9.3"
},
"dependencies": {
"@noble/ciphers": "^2.1.1",
"@noble/hashes": "^2.0.1",
Comment on lines +26 to +27
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Search for imports of `@noble` packages in the codebase
rg -n "@noble/ciphers|@noble/hashes" --type ts

Repository: Forgata/deephide

Length of output: 42


🏁 Script executed:

# Search for any reference to `@noble` packages in all file types, not just TypeScript
rg "@noble" --type-list

Repository: Forgata/deephide

Length of output: 449


🏁 Script executed:

# Search across all file types for `@noble` references
rg "@noble" -i

Repository: Forgata/deephide

Length of output: 564


🏁 Script executed:

# Also check the package.json to see the full dependency list and understand context
cat -n package.json

Repository: Forgata/deephide

Length of output: 1173


Remove unused dependencies or confirm planned use.

@noble/ciphers and @noble/hashes are added to dependencies but are not imported or used anywhere in the codebase. The crypto implementations use Node's built-in node:crypto module instead. Either remove these packages or clarify if they're intended for future use.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@package.json` around lines 26 - 27, package.json lists dependencies
"@noble/ciphers" and "@noble/hashes" but they are not used in the codebase;
either remove these two entries from dependencies or add a comment/README note
and the planned import locations (e.g., where you intend to use "@noble/ciphers"
and "@noble/hashes") to justify keeping them—update package.json accordingly and
run npm/yarn install to refresh lockfile if you remove them.

"@picovoice/pvrecorder-node": "^1.2.8",
"@subspace/reed-solomon-erasure.wasm": "^0.2.5",
"chalk": "^5.6.2",
"fft.js": "^4.0.4"
}
Expand Down
21 changes: 21 additions & 0 deletions src/core/embedding/bitstream/preamble.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/**
* Sync Preamble
* Prepends a high-entropy sync pattern to the bitstream.
*/

export function injectPreamble(payloadBits: Uint8Array) {
const PREAMBLE = new Uint8Array([
1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 1, 1, 1, 1, 0, 0, 1, 1,
0, 1, 0, 1, 1, 0, 0, 1, 1, 1, 1, 1, 0, 0, 1, 1, 0, 1, 0, 1, 1, 0, 0, 0, 0,
0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 1, 1, 1, 1, 1, 0, 1, 1, 0, 1, 1, 1, 0, 0, 0,
1, 0, 1, 1, 1,
]);

const totalBits = PREAMBLE.length + payloadBits.length;
const syncStream = new Uint8Array(totalBits);

syncStream.set(PREAMBLE, 0);
syncStream.set(payloadBits, PREAMBLE.length);

return syncStream;
}
24 changes: 24 additions & 0 deletions src/core/embedding/bitstream/serialiser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/**
* Bitstream Serialisation
* Unpacks bytes into an array of bits
*/

export function serialiseBits(interleadShards: Uint8Array[]): Uint8Array {
const totalBytes = interleadShards.reduce(
(acc, shard) => acc + shard.length,
0,
);
const bitstream = new Uint8Array(totalBytes * 8);

let bitIndex = 0;
for (const shard of interleadShards) {
for (let i = 0; i < shard.length; i++) {
const byte = shard[i];

for (let shift = 0; shift < 8; shift++) {
bitstream[bitIndex++] = (byte! >> shift) & 1;
}
}
}
return bitstream;
}
21 changes: 21 additions & 0 deletions src/core/embedding/crypto/aes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { createCipheriv, randomBytes } from "node:crypto";

/**
* AES-256-GCM Encryption
* Encrypts the framed payload and adds the authentication tag.
* @returns packet of @type Uint8Array
*/

export function encryptPayload(framedPayload: Uint8Array, key: Uint8Array) {
const nonce = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", key, nonce);

const cipherText = Buffer.concat([
cipher.update(framedPayload),
cipher.final(),
]);
const authTag = cipher.getAuthTag();

const encryptedPacket = Buffer.concat([nonce, cipherText, authTag]);
return new Uint8Array(encryptedPacket);
}
27 changes: 27 additions & 0 deletions src/core/embedding/crypto/keyDerivation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { pbkdf2 } from "node:crypto";
import { promisify } from "node:util";

const pbkdf2Async = promisify(pbkdf2);

/**
* Key Derivation
* Transforms a human password into a high-entropy 256-bit key.
*/
export async function deriveKey(password: string, salt: Uint8Array) {
const iterations = 600_000;
const keyLength = 32;
const digest = "sha256";

try {
const derivedKey = await pbkdf2Async(
password,
salt,
iterations,
keyLength,
digest,
);
return new Uint8Array(derivedKey);
} catch (error) {
throw new Error("Failed to derive key");
}
}
28 changes: 28 additions & 0 deletions src/core/embedding/fec/interleave.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/**
* Interleaving
* Reorders shards so that consecutive physical errors are distributed
* across different logical RS blocks.
*/

export function interleave(
shards: Uint8Array[],
dataShards: number,
parityShards: number,
) {
const totalShardsPerBlock = dataShards + parityShards;
const numBlocks = Math.ceil(shards.length / totalShardsPerBlock);
const interleaved: Uint8Array[] = new Array(shards.length);

let index = 0;
for (let col = 0; col < totalShardsPerBlock; col++) {
for (let row = 0; row < numBlocks; row++) {
const sourceIdx = row * totalShardsPerBlock + col;

if (sourceIdx < shards.length) {
interleaved[index++] = shards[sourceIdx]!;
}
}
}

return interleaved;
}
74 changes: 74 additions & 0 deletions src/core/embedding/fec/readSolomon.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { ReedSolomonErasure } from "@subspace/reed-solomon-erasure.wasm";
import fs from "node:fs";
import path from "node:path";
import { createRequire } from "node:module";

const require = createRequire(import.meta.url);
let rsInstance: ReedSolomonErasure | null = null;

async function getRSEngine(): Promise<ReedSolomonErasure> {
if (!rsInstance) {
const pkgPath = require.resolve("@subspace/reed-solomon-erasure.wasm");
const pkgDir = path.dirname(pkgPath);

const wasmPath = path.join(pkgDir, "reed_solomon_erasure_bg.wasm");
if (!fs.existsSync(wasmPath)) {
throw new Error(`WASM file missing! Looked for it at: ${wasmPath}`);
}

const wasmBuffer = fs.readFileSync(wasmPath);
rsInstance = ReedSolomonErasure.fromBytes(wasmBuffer);
}
return rsInstance;
}

/**
* Forward Error Correction (FEC)
*
* Groups packets into blocks and adds shard for recovery
*
* @param packets
* @param dataShards
* @param parityShards
*/

export async function applyFEC(
packets: Uint8Array[],
dataShards: number,
parityShards: number = 3,
): Promise<Uint8Array[]> {
if (packets.length === 0) return [];

const rs = await getRSEngine();
const shardLength = packets[0]!.length;
const encodedStream: Uint8Array[] = [];

for (let i = 0; i < packets.length; i += dataShards) {
const block = packets.slice(i, i + dataShards);

const totalShards = dataShards + parityShards;
const contiguousBuffer = new Uint8Array(totalShards * shardLength);

for (let j = 0; j < dataShards; j++) {
if (block[j]) {
contiguousBuffer.set(block[j]!, j * shardLength);
}
}

const result = rs.encode(contiguousBuffer, dataShards, parityShards);

if (result !== ReedSolomonErasure.RESULT_OK) {
throw new Error(`WASM FEC Encoding failed with internal code: ${result}`);
}

for (let j = 0; j < totalShards; j++) {
const shard = contiguousBuffer.slice(
j * shardLength,
(j + 1) * shardLength,
);
encodedStream.push(shard);
}
}

return encodedStream;
}
32 changes: 32 additions & 0 deletions src/core/embedding/generator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { randomBytes } from "node:crypto";
import { framePayload } from "./payload/framing.js";
import { loadFileToUint8 } from "./payload/Uint8FileReader.js";
import { deriveKey } from "./crypto/keyDerivation.js";
import { encryptPayload } from "./crypto/aes.js";
import { packetize } from "./payload/packer.js";
import { applyFEC } from "./fec/readSolomon.js";
import { interleave } from "./fec/interleave.js";
import { serialiseBits } from "./bitstream/serialiser.js";
import { injectPreamble } from "./bitstream/preamble.js";

export async function preparePayload(filename: string, password: string) {
const rawBytes = await loadFileToUint8(filename);
const framed = framePayload(rawBytes, filename);

const salt = randomBytes(16);
const key = await deriveKey(password, salt);

const encrypted = encryptPayload(framed, key);

const packets = packetize(encrypted, 256);

const FEC_SHARDS = await applyFEC(packets, 6, 3);

const interleaved = interleave(FEC_SHARDS, 6, 3);

const payloadBits = serialiseBits(interleaved);

const finalBitStream = injectPreamble(payloadBits);

return { finalBitStream, salt };
}
11 changes: 11 additions & 0 deletions src/core/embedding/payload/Uint8FileReader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
export async function loadFileToUint8(filename: string): Promise<Uint8Array> {
const filepath = path.join(process.cwd(), "data", filename);
try {
const buffer = await readFile(filepath);
return new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength);
} catch (error) {
throw new Error(`Failed to read file: ${filepath}`);
}
Comment on lines +3 to +10
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Path traversal vulnerability: validate filename input.

The filename parameter is joined directly into the path without sanitization. A malicious filename like "../../etc/passwd" could read arbitrary files outside the data/ directory.

🛡️ Proposed fix to prevent path traversal
 import { readFile } from "node:fs/promises";
 import path from "node:path";
+
 export async function loadFileToUint8(filename: string): Promise<Uint8Array> {
+  // Prevent path traversal by ensuring filename contains no directory components
+  const sanitizedFilename = path.basename(filename);
-  const filepath = path.join(process.cwd(), "data", filename);
+  const filepath = path.join(process.cwd(), "data", sanitizedFilename);
   try {
     const buffer = await readFile(filepath);
     return new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength);
   } catch (error) {
-    throw new Error(`Failed to read file: ${filepath}`);
+    throw new Error(`Failed to read file: ${sanitizedFilename}`, { cause: error });
   }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/core/embedding/payload/Uint8FileReader.ts` around lines 3 - 10, The
loadFileToUint8 function currently joins user-provided filename into a path
allowing path traversal; validate and constrain filename before reading: ensure
filename contains no directory traversal patterns or path separators (e.g.,
reject if it contains "..", "/" or "\"), or better, resolve the target with
path.resolve against path.join(process.cwd(), "data", filename) and verify the
resolved path starts with the resolved data directory path; if validation fails,
throw a descriptive error. Use the function name loadFileToUint8 and the
filepath/readFile usage as the modification points.

}
28 changes: 28 additions & 0 deletions src/core/embedding/payload/framing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/**
* Payload Framing
* Wraps raw bytes with a protocol header for identification and reconstruction.
* @returns packet of @type Uint8Array
*/

export function framePayload(filebytes: Uint8Array, filename: string) {
const encoder = new TextEncoder();
const filenameBytes = encoder.encode(filename);

if (filenameBytes.length > 255) {
throw new Error("Filename too long");
}

const headerSize = 10;
const totalSize = headerSize + filenameBytes.length + filebytes.length;
const packet = new Uint8Array(totalSize);
const view = new DataView(packet.buffer);

view.setUint32(4, 0x44484944, false);
view.setUint32(4, 0x01);
view.setUint8(5, filenameBytes.length);
view.setUint32(6, filebytes.length, false);
Comment on lines +20 to +23
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Critical: Header fields are incorrectly written with overlapping offsets.

The DataView writes have serious issues:

  1. Line 20 & 21: Both write to offset 4, so line 21 overwrites the magic number 0x44484944 with 0x01—the identifier is lost.
  2. Offset 0-3: Never written, leaving the first 4 bytes as zeros.
  3. Offset overlap: setUint32(6, ...) writes 4 bytes at offsets 6-9, but setUint8(5, ...) writes 1 byte at offset 5, and the magic should occupy 0-3.

The header layout appears to be intended as:

  • Bytes 0-3: Magic identifier
  • Byte 4: Version/flags (or use a smaller type)
  • Byte 5: Filename length
  • Bytes 6-9: File length
🐛 Proposed fix for header layout
-  view.setUint32(4, 0x44484944, false);
-  view.setUint32(4, 0x01);
-  view.setUint8(5, filenameBytes.length);
-  view.setUint32(6, filebytes.length, false);
+  view.setUint32(0, 0x44484944, false);  // Magic "DHID" at offset 0
+  view.setUint8(4, 0x01);                 // Version at offset 4
+  view.setUint8(5, filenameBytes.length); // Filename length at offset 5
+  view.setUint32(6, filebytes.length, false); // File length at offset 6
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
view.setUint32(4, 0x44484944, false);
view.setUint32(4, 0x01);
view.setUint8(5, filenameBytes.length);
view.setUint32(6, filebytes.length, false);
view.setUint32(0, 0x44484944, false); // Magic "DHID" at offset 0
view.setUint8(4, 0x01); // Version at offset 4
view.setUint8(5, filenameBytes.length); // Filename length at offset 5
view.setUint32(6, filebytes.length, false); // File length at offset 6
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/core/embedding/payload/framing.ts` around lines 20 - 23, The header
writes are overlapping; change the DataView writes so the magic 0x44484944 is
written at offset 0 (view.setUint32(0,...)), the version/flags byte is written
at offset 4 (view.setUint8(4,...)), the filename length byte stays at offset 5
(view.setUint8(5, filenameBytes.length)), and the file length is written at
offset 6 (view.setUint32(6, filebytes.length, false)); update the corresponding
writes in framing.ts (the lines using view.setUint32 and view.setUint8) to these
offsets and ensure endianness is consistent for both setUint32 calls.


packet.set(filenameBytes, headerSize);
packet.set(filebytes, headerSize + filenameBytes.length);
return packet;
}
32 changes: 32 additions & 0 deletions src/core/embedding/payload/packer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/**
* Packetization
* Slices the encrypted payload into manageable frames with IDs.
*/

export function packetize(
encryptedData: Uint8Array,
frameSize: number = 512,
): Uint8Array[] {
const frames: Uint8Array[] = [];
const totalBytes = encryptedData.length;

let offset = 0;
let frameId = 0;

while (offset < totalBytes) {
const end = Math.min(offset + frameSize, totalBytes);
const chunk = encryptedData.slice(offset, end);

const frame = new Uint8Array(4 + chunk.length);
const view = new DataView(frame.buffer);

view.setUint32(0, frameId, false);
frame.set(chunk, 4);
frames.push(frame);
Comment on lines +16 to +25
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Critical: Variable-length packets break downstream FEC encoding.

The last packet will be smaller than frameSize + 4 when encryptedData.length is not divisible by frameSize. However, applyFEC in src/core/embedding/fec/readSolomon.ts (line 43) assumes all packets have the same length as the first packet:

const shardLength = packets[0]!.length;

It then allocates fixed-size slots (totalShards * shardLength) and copies each packet assuming it fits exactly. A shorter final packet causes buffer misalignment and corrupted FEC shards.

🐛 Proposed fix: Pad final chunk to frameSize
   while (offset < totalBytes) {
     const end = Math.min(offset + frameSize, totalBytes);
-    const chunk = encryptedData.slice(offset, end);
+    const chunkRaw = encryptedData.slice(offset, end);
+    
+    // Pad to frameSize for consistent FEC shard lengths
+    const chunk = new Uint8Array(frameSize);
+    chunk.set(chunkRaw);

     const frame = new Uint8Array(4 + chunk.length);

Note: The decoder must know the original unpadded length to strip padding. Consider including the original payload length in the framing header or in the last packet.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/core/embedding/payload/packer.ts` around lines 16 - 25, The loop in
packer.ts creates variable-length frames when encryptedData.length % frameSize
!= 0 which breaks applyFEC (it assumes fixed shardLength); modify the framing
logic in the function building frames (use variables frameSize, encryptedData,
frameId, frames) so that every frame payload is padded to exactly frameSize
before being wrapped in the 4-byte header (pad the last chunk to frameSize with
a deterministic value such as zero), and include the original payload length
metadata (either in the 4-byte header expansion or an extra footer/header
packet) so the decoder can strip padding when unpacking before FEC/decoding.


offset += frameSize;
frameId++;
}

return frames;
}
Loading