@verbeth/sdk 0.1.15 → 0.2.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/dist/esm/src/ratchet/decrypt.d.ts.map +1 -1
- package/dist/esm/src/ratchet/decrypt.js +13 -3
- package/dist/esm/src/ratchet/encrypt.d.ts.map +1 -1
- package/dist/esm/src/ratchet/encrypt.js +9 -2
- package/dist/esm/src/ratchet/padding.d.ts +23 -0
- package/dist/esm/src/ratchet/padding.d.ts.map +1 -0
- package/dist/esm/src/ratchet/padding.js +119 -0
- package/dist/src/ratchet/decrypt.d.ts.map +1 -1
- package/dist/src/ratchet/decrypt.js +13 -3
- package/dist/src/ratchet/encrypt.d.ts.map +1 -1
- package/dist/src/ratchet/encrypt.js +9 -2
- package/dist/src/ratchet/padding.d.ts +23 -0
- package/dist/src/ratchet/padding.d.ts.map +1 -0
- package/dist/src/ratchet/padding.js +119 -0
- package/package.json +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"decrypt.d.ts","sourceRoot":"","sources":["../../../../src/ratchet/decrypt.ts"],"names":[],"mappings":"AAcA,OAAO,EACL,cAAc,EACd,aAAa,EACb,aAAa,EAKd,MAAM,YAAY,CAAC;
|
|
1
|
+
{"version":3,"file":"decrypt.d.ts","sourceRoot":"","sources":["../../../../src/ratchet/decrypt.ts"],"names":[],"mappings":"AAcA,OAAO,EACL,cAAc,EACd,aAAa,EACb,aAAa,EAKd,MAAM,YAAY,CAAC;AAIpB;;;;;;;GAOG;AACH,wBAAgB,cAAc,CAC5B,OAAO,EAAE,cAAc,EACvB,MAAM,EAAE,aAAa,EACrB,UAAU,EAAE,UAAU,GACrB,aAAa,GAAG,IAAI,CAkFtB;AAqKD;;;;;;GAMG;AACH,wBAAgB,uBAAuB,CACrC,OAAO,EAAE,cAAc,EACvB,QAAQ,GAAE,MAA4B,GACrC,cAAc,CAyBhB;AAED;;;;;;;GAOG;AACH,wBAAgB,mBAAmB,CACjC,OAAO,EAAE,cAAc,EACvB,KAAK,EAAE,KAAK,MAAM,EAAE,GACnB,SAAS,GAAG,UAAU,GAAG,IAAI,CAY/B"}
|
|
@@ -12,6 +12,7 @@ import nacl from 'tweetnacl';
|
|
|
12
12
|
import { hexlify } from 'ethers';
|
|
13
13
|
import { MAX_SKIP_PER_MESSAGE, MAX_STORED_SKIPPED_KEYS, TOPIC_TRANSITION_WINDOW_MS, } from './types.js';
|
|
14
14
|
import { kdfRootKey, kdfChainKey, dh, generateDHKeyPair, deriveTopic } from './kdf.js';
|
|
15
|
+
import { unpadPlaintext } from './padding.js';
|
|
15
16
|
/**
|
|
16
17
|
* Decrypt a message using the ratchet.
|
|
17
18
|
*
|
|
@@ -69,7 +70,12 @@ export function ratchetDecrypt(session, header, ciphertext) {
|
|
|
69
70
|
if (!plaintext) {
|
|
70
71
|
return null;
|
|
71
72
|
}
|
|
72
|
-
// 8.
|
|
73
|
+
// 8. Unpad plaintext
|
|
74
|
+
const unpadded = unpadPlaintext(plaintext);
|
|
75
|
+
if (!unpadded) {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
// 9. Update session state
|
|
73
79
|
newSession = {
|
|
74
80
|
...newSession,
|
|
75
81
|
receivingChainKey: newReceivingChainKey,
|
|
@@ -78,7 +84,7 @@ export function ratchetDecrypt(session, header, ciphertext) {
|
|
|
78
84
|
};
|
|
79
85
|
return {
|
|
80
86
|
session: newSession,
|
|
81
|
-
plaintext,
|
|
87
|
+
plaintext: unpadded,
|
|
82
88
|
};
|
|
83
89
|
}
|
|
84
90
|
/**
|
|
@@ -173,6 +179,10 @@ function trySkippedKeys(session, dhPubHex, msgNumber, ciphertext) {
|
|
|
173
179
|
if (!plaintext) {
|
|
174
180
|
return null;
|
|
175
181
|
}
|
|
182
|
+
const unpadded = unpadPlaintext(plaintext);
|
|
183
|
+
if (!unpadded) {
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
176
186
|
const newSkippedKeys = [...session.skippedKeys];
|
|
177
187
|
newSkippedKeys.splice(idx, 1);
|
|
178
188
|
// Wipe the key
|
|
@@ -187,7 +197,7 @@ function trySkippedKeys(session, dhPubHex, msgNumber, ciphertext) {
|
|
|
187
197
|
skippedKeys: newSkippedKeys,
|
|
188
198
|
updatedAt: Date.now(),
|
|
189
199
|
},
|
|
190
|
-
plaintext,
|
|
200
|
+
plaintext: unpadded,
|
|
191
201
|
};
|
|
192
202
|
}
|
|
193
203
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"encrypt.d.ts","sourceRoot":"","sources":["../../../../src/ratchet/encrypt.ts"],"names":[],"mappings":"AAaA,OAAO,EAAE,cAAc,EAAE,aAAa,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;
|
|
1
|
+
{"version":3,"file":"encrypt.d.ts","sourceRoot":"","sources":["../../../../src/ratchet/encrypt.ts"],"names":[],"mappings":"AAaA,OAAO,EAAE,cAAc,EAAE,aAAa,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAI1E;;;GAGG;AACH,wBAAgB,YAAY,CAAC,MAAM,EAAE,aAAa,GAAG,UAAU,CAM9D;AAED;;;;;;;;GAQG;AACH,wBAAgB,cAAc,CAC5B,OAAO,EAAE,cAAc,EACvB,SAAS,EAAE,UAAU,EACrB,gBAAgB,EAAE,UAAU,GAC3B,aAAa,CAwDf"}
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import nacl from 'tweetnacl';
|
|
12
12
|
import { kdfChainKey } from './kdf.js';
|
|
13
|
+
import { padPlaintext } from './padding.js';
|
|
13
14
|
/**
|
|
14
15
|
* Encode message header as 40 bytes for signing.
|
|
15
16
|
* Format: dh (32) + pn (4, BE) + n (4, BE)
|
|
@@ -42,9 +43,15 @@ export function ratchetEncrypt(session, plaintext, signingSecretKey) {
|
|
|
42
43
|
pn: session.previousChainLength,
|
|
43
44
|
n: session.sendingMsgNumber,
|
|
44
45
|
};
|
|
45
|
-
// 3.
|
|
46
|
+
// 3. Pad plaintext and encrypt with message key using XSalsa20-Poly1305
|
|
47
|
+
const padded = padPlaintext(plaintext);
|
|
46
48
|
const nonce = nacl.randomBytes(nacl.secretbox.nonceLength); // 24 bytes
|
|
47
|
-
const ciphertext = nacl.secretbox(
|
|
49
|
+
const ciphertext = nacl.secretbox(padded, nonce, messageKey);
|
|
50
|
+
// Wipe padded buffer after encryption
|
|
51
|
+
try {
|
|
52
|
+
padded.fill(0);
|
|
53
|
+
}
|
|
54
|
+
catch { }
|
|
48
55
|
// 4. Combine nonce + ciphertext
|
|
49
56
|
const encryptedPayload = new Uint8Array(nonce.length + ciphertext.length);
|
|
50
57
|
encryptedPayload.set(nonce, 0);
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export declare const PADDING_MARKER = 0;
|
|
2
|
+
export declare const LENGTH_PREFIX_SIZE = 4;
|
|
3
|
+
export declare const FRAMING_SIZE: number;
|
|
4
|
+
export declare const MIN_BUCKET = 64;
|
|
5
|
+
export declare const MAX_EXPONENTIAL_BUCKET = 16384;
|
|
6
|
+
export declare const LINEAR_STEP = 4096;
|
|
7
|
+
export declare const JITTER_FRACTION = 8;
|
|
8
|
+
/**
|
|
9
|
+
* Generate a uniform random integer in [0, max) using rejection sampling
|
|
10
|
+
*/
|
|
11
|
+
export declare function secureRandomInt(max: number): number;
|
|
12
|
+
/**
|
|
13
|
+
* Pad plaintext into a fixed-size envelope before encryption.
|
|
14
|
+
*
|
|
15
|
+
* Format: [0x00 marker] [plaintext_length (4 bytes BE)] [plaintext] [random padding]
|
|
16
|
+
*/
|
|
17
|
+
export declare function padPlaintext(plaintext: Uint8Array): Uint8Array;
|
|
18
|
+
/**
|
|
19
|
+
* Remove padding from a decrypted envelope. Strict validation —
|
|
20
|
+
* returns null on any malformed envelope.
|
|
21
|
+
*/
|
|
22
|
+
export declare function unpadPlaintext(decrypted: Uint8Array): Uint8Array | null;
|
|
23
|
+
//# sourceMappingURL=padding.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"padding.d.ts","sourceRoot":"","sources":["../../../../src/ratchet/padding.ts"],"names":[],"mappings":"AAYA,eAAO,MAAM,cAAc,IAAO,CAAC;AACnC,eAAO,MAAM,kBAAkB,IAAI,CAAC;AACpC,eAAO,MAAM,YAAY,QAAyB,CAAC;AACnD,eAAO,MAAM,UAAU,KAAK,CAAC;AAC7B,eAAO,MAAM,sBAAsB,QAAQ,CAAC;AAC5C,eAAO,MAAM,WAAW,OAAO,CAAC;AAChC,eAAO,MAAM,eAAe,IAAI,CAAC;AAEjC;;GAEG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CA0BnD;AAgCD;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,SAAS,EAAE,UAAU,GAAG,UAAU,CA0B9D;AAED;;;GAGG;AACH,wBAAgB,cAAc,CAAC,SAAS,EAAE,UAAU,GAAG,UAAU,GAAG,IAAI,CAqBvE"}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// packages/sdk/src/ratchet/padding.ts
|
|
2
|
+
/**
|
|
3
|
+
* Ciphertext padding for metadata leak reduction.
|
|
4
|
+
*
|
|
5
|
+
* Bucket-pads plaintext before encryption so on-chain ciphertext lengths
|
|
6
|
+
* reveal only O(log n) bits of plaintext length (power-of-2 buckets with
|
|
7
|
+
* additive jitter). Internal to the ratchet module, not exported publicly.
|
|
8
|
+
*/
|
|
9
|
+
import nacl from 'tweetnacl';
|
|
10
|
+
export const PADDING_MARKER = 0x00;
|
|
11
|
+
export const LENGTH_PREFIX_SIZE = 4;
|
|
12
|
+
export const FRAMING_SIZE = 1 + LENGTH_PREFIX_SIZE;
|
|
13
|
+
export const MIN_BUCKET = 64;
|
|
14
|
+
export const MAX_EXPONENTIAL_BUCKET = 16384;
|
|
15
|
+
export const LINEAR_STEP = 4096;
|
|
16
|
+
export const JITTER_FRACTION = 8;
|
|
17
|
+
/**
|
|
18
|
+
* Generate a uniform random integer in [0, max) using rejection sampling
|
|
19
|
+
*/
|
|
20
|
+
export function secureRandomInt(max) {
|
|
21
|
+
if (max <= 0)
|
|
22
|
+
return 0;
|
|
23
|
+
if (max === 1)
|
|
24
|
+
return 0;
|
|
25
|
+
// Find smallest power of 2 >= max
|
|
26
|
+
let ceiling = 1;
|
|
27
|
+
while (ceiling < max) {
|
|
28
|
+
ceiling <<= 1;
|
|
29
|
+
}
|
|
30
|
+
const mask = ceiling - 1;
|
|
31
|
+
// Determine how many bytes we need
|
|
32
|
+
const bytesNeeded = Math.ceil(Math.log2(ceiling) / 8) || 1;
|
|
33
|
+
// Rejection sampling
|
|
34
|
+
for (;;) {
|
|
35
|
+
const bytes = nacl.randomBytes(bytesNeeded);
|
|
36
|
+
let value = 0;
|
|
37
|
+
for (let i = 0; i < bytesNeeded; i++) {
|
|
38
|
+
value = (value << 8) | bytes[i];
|
|
39
|
+
}
|
|
40
|
+
value &= mask;
|
|
41
|
+
if (value < max) {
|
|
42
|
+
return value;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Compute the next power of 2 >= n.
|
|
48
|
+
*/
|
|
49
|
+
function nextPowerOf2(n) {
|
|
50
|
+
if (n <= 1)
|
|
51
|
+
return 1;
|
|
52
|
+
let v = n - 1;
|
|
53
|
+
v |= v >> 1;
|
|
54
|
+
v |= v >> 2;
|
|
55
|
+
v |= v >> 4;
|
|
56
|
+
v |= v >> 8;
|
|
57
|
+
v |= v >> 16;
|
|
58
|
+
return v + 1;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Select the bucket size for a given framed plaintext size.
|
|
62
|
+
*/
|
|
63
|
+
function selectBucket(framedSize) {
|
|
64
|
+
if (framedSize <= MIN_BUCKET) {
|
|
65
|
+
return MIN_BUCKET;
|
|
66
|
+
}
|
|
67
|
+
if (framedSize <= MAX_EXPONENTIAL_BUCKET) {
|
|
68
|
+
return nextPowerOf2(framedSize);
|
|
69
|
+
}
|
|
70
|
+
// Above MAX_EXPONENTIAL_BUCKET: linear steps of LINEAR_STEP
|
|
71
|
+
return Math.ceil(framedSize / LINEAR_STEP) * LINEAR_STEP;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Pad plaintext into a fixed-size envelope before encryption.
|
|
75
|
+
*
|
|
76
|
+
* Format: [0x00 marker] [plaintext_length (4 bytes BE)] [plaintext] [random padding]
|
|
77
|
+
*/
|
|
78
|
+
export function padPlaintext(plaintext) {
|
|
79
|
+
const framedSize = FRAMING_SIZE + plaintext.length;
|
|
80
|
+
const bucket = selectBucket(framedSize);
|
|
81
|
+
const jitterMax = Math.floor(bucket / JITTER_FRACTION);
|
|
82
|
+
const jitter = jitterMax > 0 ? secureRandomInt(jitterMax) : 0;
|
|
83
|
+
const paddedSize = bucket + jitter;
|
|
84
|
+
const result = new Uint8Array(paddedSize);
|
|
85
|
+
// Marker
|
|
86
|
+
result[0] = PADDING_MARKER;
|
|
87
|
+
// Plaintext length as 4 bytes big-endian
|
|
88
|
+
const view = new DataView(result.buffer, result.byteOffset, result.byteLength);
|
|
89
|
+
view.setUint32(1, plaintext.length, false);
|
|
90
|
+
result.set(plaintext, FRAMING_SIZE);
|
|
91
|
+
// Random padding fill (indistinguishable from ciphertext after encryption)
|
|
92
|
+
const paddingLength = paddedSize - framedSize;
|
|
93
|
+
if (paddingLength > 0) {
|
|
94
|
+
const randomPadding = nacl.randomBytes(paddingLength);
|
|
95
|
+
result.set(randomPadding, framedSize);
|
|
96
|
+
}
|
|
97
|
+
return result;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Remove padding from a decrypted envelope. Strict validation —
|
|
101
|
+
* returns null on any malformed envelope.
|
|
102
|
+
*/
|
|
103
|
+
export function unpadPlaintext(decrypted) {
|
|
104
|
+
if (decrypted.length < FRAMING_SIZE) {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
if (decrypted[0] !== PADDING_MARKER) {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
const view = new DataView(decrypted.buffer, decrypted.byteOffset, decrypted.byteLength);
|
|
111
|
+
const len = view.getUint32(1, false);
|
|
112
|
+
if (FRAMING_SIZE + len > decrypted.length) {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
// Extract plaintext before wiping
|
|
116
|
+
const plaintext = decrypted.slice(FRAMING_SIZE, FRAMING_SIZE + len);
|
|
117
|
+
decrypted.fill(0);
|
|
118
|
+
return plaintext;
|
|
119
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"decrypt.d.ts","sourceRoot":"","sources":["../../../src/ratchet/decrypt.ts"],"names":[],"mappings":"AAcA,OAAO,EACL,cAAc,EACd,aAAa,EACb,aAAa,EAKd,MAAM,YAAY,CAAC;
|
|
1
|
+
{"version":3,"file":"decrypt.d.ts","sourceRoot":"","sources":["../../../src/ratchet/decrypt.ts"],"names":[],"mappings":"AAcA,OAAO,EACL,cAAc,EACd,aAAa,EACb,aAAa,EAKd,MAAM,YAAY,CAAC;AAIpB;;;;;;;GAOG;AACH,wBAAgB,cAAc,CAC5B,OAAO,EAAE,cAAc,EACvB,MAAM,EAAE,aAAa,EACrB,UAAU,EAAE,UAAU,GACrB,aAAa,GAAG,IAAI,CAkFtB;AAqKD;;;;;;GAMG;AACH,wBAAgB,uBAAuB,CACrC,OAAO,EAAE,cAAc,EACvB,QAAQ,GAAE,MAA4B,GACrC,cAAc,CAyBhB;AAED;;;;;;;GAOG;AACH,wBAAgB,mBAAmB,CACjC,OAAO,EAAE,cAAc,EACvB,KAAK,EAAE,KAAK,MAAM,EAAE,GACnB,SAAS,GAAG,UAAU,GAAG,IAAI,CAY/B"}
|
|
@@ -12,6 +12,7 @@ import nacl from 'tweetnacl';
|
|
|
12
12
|
import { hexlify } from 'ethers';
|
|
13
13
|
import { MAX_SKIP_PER_MESSAGE, MAX_STORED_SKIPPED_KEYS, TOPIC_TRANSITION_WINDOW_MS, } from './types.js';
|
|
14
14
|
import { kdfRootKey, kdfChainKey, dh, generateDHKeyPair, deriveTopic } from './kdf.js';
|
|
15
|
+
import { unpadPlaintext } from './padding.js';
|
|
15
16
|
/**
|
|
16
17
|
* Decrypt a message using the ratchet.
|
|
17
18
|
*
|
|
@@ -69,7 +70,12 @@ export function ratchetDecrypt(session, header, ciphertext) {
|
|
|
69
70
|
if (!plaintext) {
|
|
70
71
|
return null;
|
|
71
72
|
}
|
|
72
|
-
// 8.
|
|
73
|
+
// 8. Unpad plaintext
|
|
74
|
+
const unpadded = unpadPlaintext(plaintext);
|
|
75
|
+
if (!unpadded) {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
// 9. Update session state
|
|
73
79
|
newSession = {
|
|
74
80
|
...newSession,
|
|
75
81
|
receivingChainKey: newReceivingChainKey,
|
|
@@ -78,7 +84,7 @@ export function ratchetDecrypt(session, header, ciphertext) {
|
|
|
78
84
|
};
|
|
79
85
|
return {
|
|
80
86
|
session: newSession,
|
|
81
|
-
plaintext,
|
|
87
|
+
plaintext: unpadded,
|
|
82
88
|
};
|
|
83
89
|
}
|
|
84
90
|
/**
|
|
@@ -173,6 +179,10 @@ function trySkippedKeys(session, dhPubHex, msgNumber, ciphertext) {
|
|
|
173
179
|
if (!plaintext) {
|
|
174
180
|
return null;
|
|
175
181
|
}
|
|
182
|
+
const unpadded = unpadPlaintext(plaintext);
|
|
183
|
+
if (!unpadded) {
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
176
186
|
const newSkippedKeys = [...session.skippedKeys];
|
|
177
187
|
newSkippedKeys.splice(idx, 1);
|
|
178
188
|
// Wipe the key
|
|
@@ -187,7 +197,7 @@ function trySkippedKeys(session, dhPubHex, msgNumber, ciphertext) {
|
|
|
187
197
|
skippedKeys: newSkippedKeys,
|
|
188
198
|
updatedAt: Date.now(),
|
|
189
199
|
},
|
|
190
|
-
plaintext,
|
|
200
|
+
plaintext: unpadded,
|
|
191
201
|
};
|
|
192
202
|
}
|
|
193
203
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"encrypt.d.ts","sourceRoot":"","sources":["../../../src/ratchet/encrypt.ts"],"names":[],"mappings":"AAaA,OAAO,EAAE,cAAc,EAAE,aAAa,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;
|
|
1
|
+
{"version":3,"file":"encrypt.d.ts","sourceRoot":"","sources":["../../../src/ratchet/encrypt.ts"],"names":[],"mappings":"AAaA,OAAO,EAAE,cAAc,EAAE,aAAa,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAI1E;;;GAGG;AACH,wBAAgB,YAAY,CAAC,MAAM,EAAE,aAAa,GAAG,UAAU,CAM9D;AAED;;;;;;;;GAQG;AACH,wBAAgB,cAAc,CAC5B,OAAO,EAAE,cAAc,EACvB,SAAS,EAAE,UAAU,EACrB,gBAAgB,EAAE,UAAU,GAC3B,aAAa,CAwDf"}
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import nacl from 'tweetnacl';
|
|
12
12
|
import { kdfChainKey } from './kdf.js';
|
|
13
|
+
import { padPlaintext } from './padding.js';
|
|
13
14
|
/**
|
|
14
15
|
* Encode message header as 40 bytes for signing.
|
|
15
16
|
* Format: dh (32) + pn (4, BE) + n (4, BE)
|
|
@@ -42,9 +43,15 @@ export function ratchetEncrypt(session, plaintext, signingSecretKey) {
|
|
|
42
43
|
pn: session.previousChainLength,
|
|
43
44
|
n: session.sendingMsgNumber,
|
|
44
45
|
};
|
|
45
|
-
// 3.
|
|
46
|
+
// 3. Pad plaintext and encrypt with message key using XSalsa20-Poly1305
|
|
47
|
+
const padded = padPlaintext(plaintext);
|
|
46
48
|
const nonce = nacl.randomBytes(nacl.secretbox.nonceLength); // 24 bytes
|
|
47
|
-
const ciphertext = nacl.secretbox(
|
|
49
|
+
const ciphertext = nacl.secretbox(padded, nonce, messageKey);
|
|
50
|
+
// Wipe padded buffer after encryption
|
|
51
|
+
try {
|
|
52
|
+
padded.fill(0);
|
|
53
|
+
}
|
|
54
|
+
catch { }
|
|
48
55
|
// 4. Combine nonce + ciphertext
|
|
49
56
|
const encryptedPayload = new Uint8Array(nonce.length + ciphertext.length);
|
|
50
57
|
encryptedPayload.set(nonce, 0);
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export declare const PADDING_MARKER = 0;
|
|
2
|
+
export declare const LENGTH_PREFIX_SIZE = 4;
|
|
3
|
+
export declare const FRAMING_SIZE: number;
|
|
4
|
+
export declare const MIN_BUCKET = 64;
|
|
5
|
+
export declare const MAX_EXPONENTIAL_BUCKET = 16384;
|
|
6
|
+
export declare const LINEAR_STEP = 4096;
|
|
7
|
+
export declare const JITTER_FRACTION = 8;
|
|
8
|
+
/**
|
|
9
|
+
* Generate a uniform random integer in [0, max) using rejection sampling
|
|
10
|
+
*/
|
|
11
|
+
export declare function secureRandomInt(max: number): number;
|
|
12
|
+
/**
|
|
13
|
+
* Pad plaintext into a fixed-size envelope before encryption.
|
|
14
|
+
*
|
|
15
|
+
* Format: [0x00 marker] [plaintext_length (4 bytes BE)] [plaintext] [random padding]
|
|
16
|
+
*/
|
|
17
|
+
export declare function padPlaintext(plaintext: Uint8Array): Uint8Array;
|
|
18
|
+
/**
|
|
19
|
+
* Remove padding from a decrypted envelope. Strict validation —
|
|
20
|
+
* returns null on any malformed envelope.
|
|
21
|
+
*/
|
|
22
|
+
export declare function unpadPlaintext(decrypted: Uint8Array): Uint8Array | null;
|
|
23
|
+
//# sourceMappingURL=padding.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"padding.d.ts","sourceRoot":"","sources":["../../../src/ratchet/padding.ts"],"names":[],"mappings":"AAYA,eAAO,MAAM,cAAc,IAAO,CAAC;AACnC,eAAO,MAAM,kBAAkB,IAAI,CAAC;AACpC,eAAO,MAAM,YAAY,QAAyB,CAAC;AACnD,eAAO,MAAM,UAAU,KAAK,CAAC;AAC7B,eAAO,MAAM,sBAAsB,QAAQ,CAAC;AAC5C,eAAO,MAAM,WAAW,OAAO,CAAC;AAChC,eAAO,MAAM,eAAe,IAAI,CAAC;AAEjC;;GAEG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CA0BnD;AAgCD;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,SAAS,EAAE,UAAU,GAAG,UAAU,CA0B9D;AAED;;;GAGG;AACH,wBAAgB,cAAc,CAAC,SAAS,EAAE,UAAU,GAAG,UAAU,GAAG,IAAI,CAqBvE"}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// packages/sdk/src/ratchet/padding.ts
|
|
2
|
+
/**
|
|
3
|
+
* Ciphertext padding for metadata leak reduction.
|
|
4
|
+
*
|
|
5
|
+
* Bucket-pads plaintext before encryption so on-chain ciphertext lengths
|
|
6
|
+
* reveal only O(log n) bits of plaintext length (power-of-2 buckets with
|
|
7
|
+
* additive jitter). Internal to the ratchet module, not exported publicly.
|
|
8
|
+
*/
|
|
9
|
+
import nacl from 'tweetnacl';
|
|
10
|
+
export const PADDING_MARKER = 0x00;
|
|
11
|
+
export const LENGTH_PREFIX_SIZE = 4;
|
|
12
|
+
export const FRAMING_SIZE = 1 + LENGTH_PREFIX_SIZE;
|
|
13
|
+
export const MIN_BUCKET = 64;
|
|
14
|
+
export const MAX_EXPONENTIAL_BUCKET = 16384;
|
|
15
|
+
export const LINEAR_STEP = 4096;
|
|
16
|
+
export const JITTER_FRACTION = 8;
|
|
17
|
+
/**
|
|
18
|
+
* Generate a uniform random integer in [0, max) using rejection sampling
|
|
19
|
+
*/
|
|
20
|
+
export function secureRandomInt(max) {
|
|
21
|
+
if (max <= 0)
|
|
22
|
+
return 0;
|
|
23
|
+
if (max === 1)
|
|
24
|
+
return 0;
|
|
25
|
+
// Find smallest power of 2 >= max
|
|
26
|
+
let ceiling = 1;
|
|
27
|
+
while (ceiling < max) {
|
|
28
|
+
ceiling <<= 1;
|
|
29
|
+
}
|
|
30
|
+
const mask = ceiling - 1;
|
|
31
|
+
// Determine how many bytes we need
|
|
32
|
+
const bytesNeeded = Math.ceil(Math.log2(ceiling) / 8) || 1;
|
|
33
|
+
// Rejection sampling
|
|
34
|
+
for (;;) {
|
|
35
|
+
const bytes = nacl.randomBytes(bytesNeeded);
|
|
36
|
+
let value = 0;
|
|
37
|
+
for (let i = 0; i < bytesNeeded; i++) {
|
|
38
|
+
value = (value << 8) | bytes[i];
|
|
39
|
+
}
|
|
40
|
+
value &= mask;
|
|
41
|
+
if (value < max) {
|
|
42
|
+
return value;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Compute the next power of 2 >= n.
|
|
48
|
+
*/
|
|
49
|
+
function nextPowerOf2(n) {
|
|
50
|
+
if (n <= 1)
|
|
51
|
+
return 1;
|
|
52
|
+
let v = n - 1;
|
|
53
|
+
v |= v >> 1;
|
|
54
|
+
v |= v >> 2;
|
|
55
|
+
v |= v >> 4;
|
|
56
|
+
v |= v >> 8;
|
|
57
|
+
v |= v >> 16;
|
|
58
|
+
return v + 1;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Select the bucket size for a given framed plaintext size.
|
|
62
|
+
*/
|
|
63
|
+
function selectBucket(framedSize) {
|
|
64
|
+
if (framedSize <= MIN_BUCKET) {
|
|
65
|
+
return MIN_BUCKET;
|
|
66
|
+
}
|
|
67
|
+
if (framedSize <= MAX_EXPONENTIAL_BUCKET) {
|
|
68
|
+
return nextPowerOf2(framedSize);
|
|
69
|
+
}
|
|
70
|
+
// Above MAX_EXPONENTIAL_BUCKET: linear steps of LINEAR_STEP
|
|
71
|
+
return Math.ceil(framedSize / LINEAR_STEP) * LINEAR_STEP;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Pad plaintext into a fixed-size envelope before encryption.
|
|
75
|
+
*
|
|
76
|
+
* Format: [0x00 marker] [plaintext_length (4 bytes BE)] [plaintext] [random padding]
|
|
77
|
+
*/
|
|
78
|
+
export function padPlaintext(plaintext) {
|
|
79
|
+
const framedSize = FRAMING_SIZE + plaintext.length;
|
|
80
|
+
const bucket = selectBucket(framedSize);
|
|
81
|
+
const jitterMax = Math.floor(bucket / JITTER_FRACTION);
|
|
82
|
+
const jitter = jitterMax > 0 ? secureRandomInt(jitterMax) : 0;
|
|
83
|
+
const paddedSize = bucket + jitter;
|
|
84
|
+
const result = new Uint8Array(paddedSize);
|
|
85
|
+
// Marker
|
|
86
|
+
result[0] = PADDING_MARKER;
|
|
87
|
+
// Plaintext length as 4 bytes big-endian
|
|
88
|
+
const view = new DataView(result.buffer, result.byteOffset, result.byteLength);
|
|
89
|
+
view.setUint32(1, plaintext.length, false);
|
|
90
|
+
result.set(plaintext, FRAMING_SIZE);
|
|
91
|
+
// Random padding fill (indistinguishable from ciphertext after encryption)
|
|
92
|
+
const paddingLength = paddedSize - framedSize;
|
|
93
|
+
if (paddingLength > 0) {
|
|
94
|
+
const randomPadding = nacl.randomBytes(paddingLength);
|
|
95
|
+
result.set(randomPadding, framedSize);
|
|
96
|
+
}
|
|
97
|
+
return result;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Remove padding from a decrypted envelope. Strict validation —
|
|
101
|
+
* returns null on any malformed envelope.
|
|
102
|
+
*/
|
|
103
|
+
export function unpadPlaintext(decrypted) {
|
|
104
|
+
if (decrypted.length < FRAMING_SIZE) {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
if (decrypted[0] !== PADDING_MARKER) {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
const view = new DataView(decrypted.buffer, decrypted.byteOffset, decrypted.byteLength);
|
|
111
|
+
const len = view.getUint32(1, false);
|
|
112
|
+
if (FRAMING_SIZE + len > decrypted.length) {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
// Extract plaintext before wiping
|
|
116
|
+
const plaintext = decrypted.slice(FRAMING_SIZE, FRAMING_SIZE + len);
|
|
117
|
+
decrypted.fill(0);
|
|
118
|
+
return plaintext;
|
|
119
|
+
}
|