Skip to content

Commit 0184950

Browse files
authored
Feat/pq encryption (#2)
post-quantum encryption with qcrypt
1 parent 1f6c23a commit 0184950

File tree

8 files changed

+576
-235
lines changed

8 files changed

+576
-235
lines changed

README.md

Lines changed: 109 additions & 232 deletions
Large diffs are not rendered by default.

package-lock.json

Lines changed: 44 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
},
2828
"dependencies": {
2929
"@noble/ciphers": "^2.0.1",
30+
"@noble/post-quantum": "^0.5.2",
3031
"viem": "^2.38.6"
3132
},
3233
"scripts": {
@@ -42,7 +43,8 @@
4243
"exports": {
4344
".": "./index.js",
4445
"./crypt.js": "./crypt.js",
45-
"./stealth.js": "./stealth/index.js"
46+
"./qcrypt.js": "./qcrypt.js",
47+
"./stealth/index.js": "./stealth/index.js"
4648
},
4749
"engines": {
4850
"node": ">= 20.19.0"
@@ -72,4 +74,4 @@
7274
"sideEffects": false,
7375
"author": "Dzmitry Lahunouski",
7476
"license": "SEE LICENSE IN LICENSE"
75-
}
77+
}

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export * from "./crypt.ts";
2+
export * from "./qcrypt.ts";
23
export * from "./stealth/index.ts";

src/qcrypt.ts

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import { randomBytes } from "@noble/hashes/utils";
2+
import { gcm } from "@noble/ciphers/aes.js";
3+
import { ml_kem768 } from "@noble/post-quantum/ml-kem.js";
4+
import { sha512 } from "@noble/hashes/sha2";
5+
6+
import { type Hex, isHex, sha256, toHex, toBytes } from "viem";
7+
8+
const decoder = new TextDecoder();
9+
const encoder = new TextEncoder();
10+
11+
/**
12+
* Quantum-resistant key pair for ML-KEM encryption
13+
*/
14+
export interface QuantumKeyPair {
15+
publicKey: Hex;
16+
secretKey: Hex;
17+
}
18+
19+
/**
20+
* @notice Throws if provided public key is not valid.
21+
* @param publicKey ML-KEM-768 public key as hex string
22+
*/
23+
export function assertValidQuantumPublicKey(publicKey: Hex) {
24+
if (!isHex(publicKey)) {
25+
throw new Error("Must provide public key as hex string");
26+
}
27+
const keyBytes = toBytes(publicKey);
28+
if (keyBytes.length !== 1184) {
29+
throw new Error("Invalid ML-KEM-768 public key length");
30+
}
31+
}
32+
33+
/**
34+
* @notice Throws if provided secret key is not valid.
35+
* @param secretKey ML-KEM-768 secret key as hex string
36+
*/
37+
export function assertValidQuantumSecretKey(secretKey: Hex) {
38+
if (!isHex(secretKey)) {
39+
throw new Error("Must provide secret key as hex string");
40+
}
41+
const keyBytes = toBytes(secretKey);
42+
if (keyBytes.length !== 2400) {
43+
throw new Error("Invalid ML-KEM-768 secret key length");
44+
}
45+
}
46+
47+
/**
48+
* @notice Generate a quantum-resistant key pair using ML-KEM-768
49+
* @param seed Optional seed string for deterministic key generation
50+
* @returns QuantumKeyPair with public and secret keys as hex strings
51+
*/
52+
export function generateQuantumKeyPair(seed?: string): QuantumKeyPair {
53+
let seedBytes: Uint8Array;
54+
55+
if (seed !== undefined) {
56+
// Generate 64-byte seed from string using SHA-512 (produces exactly 64 bytes)
57+
seedBytes = sha512(encoder.encode(seed));
58+
} else {
59+
seedBytes = randomBytes(64);
60+
}
61+
62+
const keyPair = ml_kem768.keygen(seedBytes);
63+
return {
64+
publicKey: toHex(keyPair.publicKey),
65+
secretKey: toHex(keyPair.secretKey),
66+
};
67+
}
68+
69+
/**
70+
* @notice Encrypt data using quantum-resistant ML-KEM-768
71+
* @param data String to encrypt (supports any UTF-8 data)
72+
* @param publicKey Recipient's ML-KEM-768 public key (0x-prefixed hex string, 1184 bytes)
73+
* @returns Hex string of encrypted data
74+
*/
75+
export const encryptQuantum = (data: string, publicKey: Hex): Hex => {
76+
assertValidQuantumPublicKey(publicKey);
77+
78+
// Convert public key from hex to bytes
79+
const publicKeyBytes = toBytes(publicKey);
80+
81+
// Encapsulate to get shared secret and KEM ciphertext
82+
const { cipherText: kemCiphertext, sharedSecret } =
83+
ml_kem768.encapsulate(publicKeyBytes);
84+
85+
// Derive AES key from shared secret
86+
const aesKey = toBytes(sha256(toHex(sharedSecret)));
87+
88+
// Encrypt data with AES-256-GCM
89+
const iv = randomBytes(12);
90+
const rawData = encoder.encode(data);
91+
const aes = gcm(aesKey, iv);
92+
const ciphertext = aes.encrypt(rawData);
93+
94+
// Format: iv(12) + kemCiphertext(1088) + ciphertext(variable)
95+
return `${toHex(iv)}${toHex(kemCiphertext).slice(2)}${toHex(ciphertext).slice(2)}` as Hex;
96+
};
97+
98+
/**
99+
* @notice Decrypt data using quantum-resistant ML-KEM-768
100+
* @param secretKey Your ML-KEM-768 secret key (0x-prefixed hex string, 2400 bytes)
101+
* @param encodedData Encrypted data from encryptQuantum()
102+
* @returns Decrypted string
103+
*/
104+
export const decryptQuantum = (secretKey: Hex, encodedData: Hex): string => {
105+
assertValidQuantumSecretKey(secretKey);
106+
107+
// Convert secret key from hex to bytes
108+
const secretKeyBytes = toBytes(secretKey);
109+
110+
// Manually split string: bytes12 (iv) + bytes1088 (KEM ciphertext) + rest (AES ciphertext)
111+
const iv = toBytes(encodedData.slice(0, 26)); // 0x + 12*2 = 26
112+
const kemCiphertext = toBytes(`0x${encodedData.slice(26, 2202)}`); // 26 + 1088*2 = 2202
113+
const ciphertext = toBytes(`0x${encodedData.slice(2202)}`);
114+
115+
// Decapsulate to get shared secret
116+
const sharedSecret = ml_kem768.decapsulate(kemCiphertext, secretKeyBytes);
117+
118+
// Derive AES key from shared secret
119+
const aesKey = toBytes(sha256(toHex(sharedSecret)));
120+
121+
// Decrypt with AES-256-GCM
122+
const aes = gcm(aesKey, iv);
123+
124+
return decoder.decode(aes.decrypt(ciphertext));
125+
};

test/build-output.spec.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ describe("Build Output Validation", () => {
2020
"crypt.d.ts",
2121
"crypt.js.map",
2222
"crypt.d.ts.map",
23+
"qcrypt.js",
24+
"qcrypt.d.ts",
25+
"qcrypt.js.map",
26+
"qcrypt.d.ts.map",
2327
"stealth/index.js",
2428
"stealth/index.d.ts",
2529
"stealth/index.js.map",
@@ -109,7 +113,8 @@ describe("Build Output Validation", () => {
109113
expect(pkg.exports).toBeDefined();
110114
expect(pkg.exports["."]).toBe("./index.js");
111115
expect(pkg.exports["./crypt.js"]).toBe("./crypt.js");
112-
expect(pkg.exports["./stealth.js"]).toBe("./stealth/index.js");
116+
expect(pkg.exports["./qcrypt.js"]).toBe("./qcrypt.js");
117+
expect(pkg.exports["./stealth/index.js"]).toBe("./stealth/index.js");
113118
});
114119

115120
it("main entry point should be index.js", () => {

test/integration/esm-imports.mjs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
*/
77

88
import { encrypt, decrypt } from "../../crypt.js";
9+
import { generateQuantumKeyPair, encryptQuantum, decryptQuantum } from "../../qcrypt.js";
910
import { createStealth, deriveStealthAccount } from "../../stealth/index.js";
1011
import { mulPublicKey, mulPrivateKey } from "../../stealth/index.js";
1112

@@ -15,6 +16,9 @@ console.log("✅ All imports successful from built files");
1516
const checks = [
1617
{ name: "encrypt", value: encrypt },
1718
{ name: "decrypt", value: decrypt },
19+
{ name: "generateQuantumKeyPair", value: generateQuantumKeyPair },
20+
{ name: "encryptQuantum", value: encryptQuantum },
21+
{ name: "decryptQuantum", value: decryptQuantum },
1822
{ name: "createStealth", value: createStealth },
1923
{ name: "deriveStealthAccount", value: deriveStealthAccount },
2024
{ name: "mulPublicKey", value: mulPublicKey },

0 commit comments

Comments
 (0)