Skip to content

Commit 6846bd8

Browse files
committed
fixed-size encryption supporting up to 254 bytes of data
1 parent ff6e891 commit 6846bd8

File tree

8 files changed

+320
-126
lines changed

8 files changed

+320
-126
lines changed

README.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,8 @@ const account = deriveStealthAccount(privateKey, toHex(random));
7474
### Classic Encryption (`/crypt.js`)
7575

7676
#### `encrypt(data: string, publicKey: Hex): Hex`
77-
ECDH encryption with ephemeral keys and AES-256-GCM.
77+
ECDH encryption with ephemeral keys and AES-256-GCM. **Max 254 bytes input.**
78+
- All ciphertexts are fixed-length (255 bytes padded) for perfect length obfuscation.
7879

7980
#### `decrypt(privateKey: Hash, encodedData: Hex): string`
8081
Decrypt data encrypted with `encrypt()`.
@@ -87,7 +88,8 @@ Generate ML-KEM-768 key pair. Optional seed for deterministic generation.
8788
- **Key sizes:** 1184 bytes (public), 2400 bytes (secret)
8889

8990
#### `encryptQuantum(data: string, publicKey: Hex): Hex`
90-
Quantum-resistant encryption using ML-KEM-768 + AES-256-GCM.
91+
Quantum-resistant encryption using ML-KEM-768 + AES-256-GCM. **Max 254 bytes input.**
92+
- All ciphertexts are fixed-length (255 bytes padded) for perfect length obfuscation.
9193

9294
#### `decryptQuantum(secretKey: Hex, encodedData: Hex): string`
9395
Decrypt quantum-encrypted data.
@@ -113,12 +115,14 @@ Multiply private key by scalar (modulo curve order).
113115
- ✅ Authenticated encryption (AES-256-GCM)
114116
- ✅ Random IVs per operation
115117
- ✅ ECDH on secp256k1 curve
118+
- ✅ Fixed-length ciphertexts (length obfuscation)
116119

117120
### Quantum Encryption
118121
- ✅ ML-KEM-768 (NIST FIPS 203)
119122
- ✅ Post-quantum secure
120123
- ✅ Hybrid encryption (KEM + AES-GCM)
121124
- ✅ Non-deterministic by default
125+
- ✅ Fixed-length ciphertexts (length obfuscation)
122126

123127
### Best Practices
124128
- Never share or transmit private keys
@@ -153,7 +157,7 @@ npm run typecheck
153157
npm run lint
154158
```
155159

156-
**Test coverage:** 89+ tests covering encryption, stealth addresses, edge cases, and build validation.
160+
**Test coverage:** 128 tests covering encryption, stealth addresses, edge cases, and build validation.
157161

158162
## Dependencies
159163

package-lock.json

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

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@zeroledger/vycrypt",
3-
"version": "1.0.0-alpha",
3+
"version": "1.0.0-beta.1",
44
"description": "Crypto primitives for ZeroLedger Protocol",
55
"files": [
66
"*.js",

src/crypt.ts

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -61,11 +61,27 @@ function getSharedSecret(privateKey: Hash, publicKey: Hex) {
6161
}
6262

6363
/**
64-
* @notice Encrypt a number with the instance's public key
65-
* @param randomNumber Number as instance of RandomNumber class
66-
* @returns Hex strings of uncompressed 65 byte public key and 32 byte ciphertext
64+
* @notice Encrypt data with ECIES and length obfuscation
65+
* @param data String to encrypt
66+
* @param counterpartyPubKey Recipient's public key
67+
* @returns Hex strings of uncompressed 65 byte public key and ciphertext
68+
* @description Adds a 1-byte header + paddingSize (1-254 bytes) before the actual data to obfuscate length
6769
*/
6870
export const encrypt = (data: string, counterpartyPubKey: Hex) => {
71+
const dataBytes = encoder.encode(data);
72+
73+
if (dataBytes.length >= 255) {
74+
throw new Error("Data length must be less than 255");
75+
}
76+
77+
const fakePrefixSize = 255 - dataBytes.length;
78+
const fakePrefix = randomBytes(fakePrefixSize);
79+
// Create padded data: [prefix_size(1 byte)][fake_prefix(1-254 bytes)][actual_data]
80+
const paddedData = new Uint8Array(1 + fakePrefixSize + dataBytes.length);
81+
paddedData[0] = fakePrefixSize;
82+
paddedData.set(fakePrefix, 1);
83+
paddedData.set(dataBytes, 1 + fakePrefixSize);
84+
6985
// Get shared secret to use as encryption key
7086
const ephemeralPrivateKey = secp256k1Utils.randomPrivateKey();
7187
const ephemeralPublicKey = Point.fromPrivateKey(ephemeralPrivateKey);
@@ -77,12 +93,18 @@ export const encrypt = (data: string, counterpartyPubKey: Hex) => {
7793
);
7894

7995
const iv = randomBytes(12);
80-
const rawData = encoder.encode(data);
8196
const aes = gcm(sharedSecret, iv);
82-
const ciphertext = aes.encrypt(rawData);
97+
const ciphertext = aes.encrypt(paddedData);
8398
return `${toHex(iv)}${ephemeralPublicKeyHex.slice(2)}${toHex(ciphertext).slice(2)}` as Hex;
8499
};
85100

101+
/**
102+
* @notice Decrypt data encrypted with ECIES
103+
* @param privateKey Your private key
104+
* @param encodedData Encrypted data from encrypt()
105+
* @returns Decrypted string
106+
* @description Automatically removes fake prefix padding added during encryption
107+
*/
86108
export const decrypt = (privateKey: Hash, encodedData: Hex) => {
87109
// manually split string by bytes12, bytes64, the rest
88110
const iv = toBytes(encodedData.slice(0, 26));
@@ -95,6 +117,13 @@ export const decrypt = (privateKey: Hash, encodedData: Hex) => {
95117
const sharedSecret = getSharedSecret(privateKey, ephemeralPublicKey);
96118

97119
const aes = gcm(sharedSecret, iv);
120+
const decryptedBytes = aes.decrypt(ciphertext);
121+
122+
// Read the fake prefix size from first byte
123+
const fakePrefixSize = decryptedBytes[0];
124+
125+
// Skip header (1 byte) and fake prefix, return actual data
126+
const actualData = decryptedBytes.slice(1 + fakePrefixSize);
98127

99-
return decoder.decode(aes.decrypt(ciphertext));
128+
return decoder.decode(actualData);
100129
};

src/qcrypt.ts

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -67,14 +67,29 @@ export function generateQuantumKeyPair(seed?: string): QuantumKeyPair {
6767
}
6868

6969
/**
70-
* @notice Encrypt data using quantum-resistant ML-KEM-768
70+
* @notice Encrypt data using quantum-resistant ML-KEM-768 with length obfuscation
7171
* @param data String to encrypt (supports any UTF-8 data)
7272
* @param publicKey Recipient's ML-KEM-768 public key (0x-prefixed hex string, 1184 bytes)
7373
* @returns Hex string of encrypted data
74+
* @description Adds a 1-byte header + paddingSize (1-254 bytes) before the actual data to obfuscate length
7475
*/
7576
export const encryptQuantum = (data: string, publicKey: Hex): Hex => {
7677
assertValidQuantumPublicKey(publicKey);
7778

79+
const dataBytes = encoder.encode(data);
80+
81+
if (dataBytes.length >= 255) {
82+
throw new Error("Data length must be less than 255");
83+
}
84+
85+
const fakePrefixSize = 255 - dataBytes.length;
86+
const fakePrefix = randomBytes(fakePrefixSize);
87+
// Create padded data: [prefix_size(1 byte)][fake_prefix(1-254 bytes)][actual_data]
88+
const paddedData = new Uint8Array(1 + fakePrefixSize + dataBytes.length);
89+
paddedData[0] = fakePrefixSize;
90+
paddedData.set(fakePrefix, 1);
91+
paddedData.set(dataBytes, 1 + fakePrefixSize);
92+
7893
// Convert public key from hex to bytes
7994
const publicKeyBytes = toBytes(publicKey);
8095

@@ -87,9 +102,8 @@ export const encryptQuantum = (data: string, publicKey: Hex): Hex => {
87102

88103
// Encrypt data with AES-256-GCM
89104
const iv = randomBytes(12);
90-
const rawData = encoder.encode(data);
91105
const aes = gcm(aesKey, iv);
92-
const ciphertext = aes.encrypt(rawData);
106+
const ciphertext = aes.encrypt(paddedData);
93107

94108
// Format: iv(12) + kemCiphertext(1088) + ciphertext(variable)
95109
return `${toHex(iv)}${toHex(kemCiphertext).slice(2)}${toHex(ciphertext).slice(2)}` as Hex;
@@ -100,6 +114,7 @@ export const encryptQuantum = (data: string, publicKey: Hex): Hex => {
100114
* @param secretKey Your ML-KEM-768 secret key (0x-prefixed hex string, 2400 bytes)
101115
* @param encodedData Encrypted data from encryptQuantum()
102116
* @returns Decrypted string
117+
* @description Automatically removes fake prefix padding added during encryption
103118
*/
104119
export const decryptQuantum = (secretKey: Hex, encodedData: Hex): string => {
105120
assertValidQuantumSecretKey(secretKey);
@@ -120,6 +135,13 @@ export const decryptQuantum = (secretKey: Hex, encodedData: Hex): string => {
120135

121136
// Decrypt with AES-256-GCM
122137
const aes = gcm(aesKey, iv);
138+
const decryptedBytes = aes.decrypt(ciphertext);
139+
140+
// Read the fake prefix size from first byte
141+
const fakePrefixSize = decryptedBytes[0];
142+
143+
// Skip header (1 byte) and fake prefix, return actual data
144+
const actualData = decryptedBytes.slice(1 + fakePrefixSize);
123145

124-
return decoder.decode(aes.decrypt(ciphertext));
146+
return decoder.decode(actualData);
125147
};

test/crypt.spec.ts

Lines changed: 124 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,27 @@
11
import { isHex } from "viem";
22
import { privateKeyToAccount, generatePrivateKey } from "viem/accounts";
33
import { encrypt, decrypt } from "../src/crypt.ts";
4-
import * as fs from "fs";
54

65
describe("crypt", () => {
76
const privKey = generatePrivateKey();
87
const account = privateKeyToAccount(privKey);
98

109
const hexData = `0xa5eaba8f6b292d059d9e8c3a2f1b16af`;
11-
const jsonData = fs.readFileSync("./test/mocks/arbitraryData.json", "utf8");
1210

1311
describe("encrypt", () => {
1412
it("should encrypt hex string", () => {
1513
expect(isHex(encrypt(hexData, account.publicKey))).toBeTruthy();
1614
});
1715

18-
it("should encrypt json string", () => {
19-
expect(isHex(encrypt(jsonData, account.publicKey))).toBeTruthy();
16+
it("should encrypt json string (small)", () => {
17+
const smallJson = JSON.stringify({
18+
name: "Alice",
19+
age: 30,
20+
active: true,
21+
});
22+
const encrypted = encrypt(smallJson, account.publicKey);
23+
expect(isHex(encrypted)).toBeTruthy();
24+
expect(decrypt(privKey, encrypted)).toBe(smallJson);
2025
});
2126

2227
it("should encrypt empty string", () => {
@@ -25,11 +30,18 @@ describe("crypt", () => {
2530
expect(decrypt(privKey, encrypted)).toBe("");
2631
});
2732

28-
it("should encrypt large data", () => {
29-
const largeData = "x".repeat(10000);
30-
const encrypted = encrypt(largeData, account.publicKey);
33+
it("should encrypt maximum allowed data (254 bytes)", () => {
34+
const maxData = "x".repeat(254);
35+
const encrypted = encrypt(maxData, account.publicKey);
3136
expect(isHex(encrypted)).toBeTruthy();
32-
expect(decrypt(privKey, encrypted)).toBe(largeData);
37+
expect(decrypt(privKey, encrypted)).toBe(maxData);
38+
});
39+
40+
it("should throw error for data >= 255 bytes", () => {
41+
const tooLarge = "x".repeat(255);
42+
expect(() => encrypt(tooLarge, account.publicKey)).toThrow(
43+
"Data length must be less than 255",
44+
);
3345
});
3446

3547
it("should encrypt unicode data", () => {
@@ -70,20 +82,25 @@ describe("crypt", () => {
7082
expect(decrypt(privKey, encryptedData)).toBe(hexData);
7183
});
7284

73-
it("should decrypt json string", () => {
74-
const encryptedData = encrypt(jsonData, account.publicKey);
75-
expect(decrypt(privKey, encryptedData)).toBe(jsonData);
85+
it("should decrypt json string (small)", () => {
86+
const smallJson = JSON.stringify({
87+
name: "Bob",
88+
id: 123,
89+
verified: false,
90+
});
91+
const encryptedData = encrypt(smallJson, account.publicKey);
92+
expect(decrypt(privKey, encryptedData)).toBe(smallJson);
7693
});
7794

7895
it("should decrypt empty string", () => {
7996
const encryptedData = encrypt("", account.publicKey);
8097
expect(decrypt(privKey, encryptedData)).toBe("");
8198
});
8299

83-
it("should decrypt large data", () => {
84-
const largeData = "x".repeat(10000);
85-
const encryptedData = encrypt(largeData, account.publicKey);
86-
expect(decrypt(privKey, encryptedData)).toBe(largeData);
100+
it("should decrypt maximum allowed data (254 bytes)", () => {
101+
const maxData = "x".repeat(254);
102+
const encryptedData = encrypt(maxData, account.publicKey);
103+
expect(decrypt(privKey, encryptedData)).toBe(maxData);
87104
});
88105

89106
it("should decrypt unicode data", () => {
@@ -147,10 +164,17 @@ describe("crypt", () => {
147164
});
148165

149166
describe("edge cases", () => {
150-
it("should handle very long strings", () => {
151-
const longString = "a".repeat(100000);
152-
const encrypted = encrypt(longString, account.publicKey);
153-
expect(decrypt(privKey, encrypted)).toBe(longString);
167+
it("should handle maximum size strings (254 bytes)", () => {
168+
const maxString = "a".repeat(254);
169+
const encrypted = encrypt(maxString, account.publicKey);
170+
expect(decrypt(privKey, encrypted)).toBe(maxString);
171+
});
172+
173+
it("should throw for strings over 254 bytes", () => {
174+
const tooLong = "a".repeat(255);
175+
expect(() => encrypt(tooLong, account.publicKey)).toThrow(
176+
"Data length must be less than 255",
177+
);
154178
});
155179

156180
it("should handle null bytes in data", () => {
@@ -197,4 +221,85 @@ describe("crypt", () => {
197221
expect(encrypted1).not.toBe(encrypted2);
198222
});
199223
});
224+
225+
describe("length obfuscation", () => {
226+
it("should produce same length for all plaintexts (fixed 255 bytes)", () => {
227+
const data1 = "short";
228+
const data2 = "x".repeat(254);
229+
const data3 = "";
230+
231+
const encrypted1 = encrypt(data1, account.publicKey);
232+
const encrypted2 = encrypt(data2, account.publicKey);
233+
const encrypted3 = encrypt(data3, account.publicKey);
234+
235+
// All should be same length (255 bytes padded + overhead)
236+
expect(encrypted1.length).toBe(encrypted2.length);
237+
expect(encrypted2.length).toBe(encrypted3.length);
238+
});
239+
240+
it("should pad to exactly 255 bytes before encryption", () => {
241+
// All encrypted data should be same length regardless of input
242+
const data1 = "test";
243+
const data2 = "";
244+
const data3 = "x".repeat(100);
245+
246+
const encrypted1 = encrypt(data1, account.publicKey);
247+
const encrypted2 = encrypt(data2, account.publicKey);
248+
const encrypted3 = encrypt(data3, account.publicKey);
249+
250+
// All should be exactly the same length
251+
expect(encrypted1.length).toBe(encrypted2.length);
252+
expect(encrypted2.length).toBe(encrypted3.length);
253+
});
254+
255+
it("should correctly decrypt data with different sizes", () => {
256+
const testData = [
257+
"",
258+
"a",
259+
"test data",
260+
"x".repeat(50),
261+
"x".repeat(100),
262+
"x".repeat(254),
263+
];
264+
265+
for (const data of testData) {
266+
const encrypted = encrypt(data, account.publicKey);
267+
const decrypted = decrypt(privKey, encrypted);
268+
expect(decrypted).toBe(data);
269+
}
270+
});
271+
272+
it("should handle maximum padding (254 bytes for empty string)", () => {
273+
const data = "";
274+
const encrypted = encrypt(data, account.publicKey);
275+
const decrypted = decrypt(privKey, encrypted);
276+
expect(decrypted).toBe(data);
277+
278+
// Should be same length as any other encryption
279+
const other = encrypt("test", account.publicKey);
280+
expect(encrypted.length).toBe(other.length);
281+
});
282+
283+
it("should handle minimum padding (1 byte for 254-byte string)", () => {
284+
const data = "x".repeat(254);
285+
const encrypted = encrypt(data, account.publicKey);
286+
const decrypted = decrypt(privKey, encrypted);
287+
expect(decrypted).toBe(data);
288+
289+
// Should be same length as any other encryption
290+
const other = encrypt("test", account.publicKey);
291+
expect(encrypted.length).toBe(other.length);
292+
});
293+
294+
it("should throw error for corrupted data", () => {
295+
const data = "test data";
296+
const encrypted = encrypt(data, account.publicKey);
297+
298+
// Corrupt the encrypted data
299+
const corruptedData = encrypted.slice(0, -10) + "ff".repeat(5);
300+
301+
// Should fail at AES decryption
302+
expect(() => decrypt(privKey, corruptedData as `0x${string}`)).toThrow();
303+
});
304+
});
200305
});

0 commit comments

Comments
 (0)