@verbeth/sdk 0.1.16 → 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.
@@ -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;AAGpB;;;;;;;GAOG;AACH,wBAAgB,cAAc,CAC5B,OAAO,EAAE,cAAc,EACvB,MAAM,EAAE,aAAa,EACrB,UAAU,EAAE,UAAU,GACrB,aAAa,GAAG,IAAI,CA4EtB;AAgKD;;;;;;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"}
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. Update session state
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;AAG1E;;;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,CAqDf"}
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. Encrypt with message key using XSalsa20-Poly1305
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(plaintext, nonce, messageKey);
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;AAGpB;;;;;;;GAOG;AACH,wBAAgB,cAAc,CAC5B,OAAO,EAAE,cAAc,EACvB,MAAM,EAAE,aAAa,EACrB,UAAU,EAAE,UAAU,GACrB,aAAa,GAAG,IAAI,CA4EtB;AAgKD;;;;;;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"}
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. Update session state
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;AAG1E;;;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,CAqDf"}
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. Encrypt with message key using XSalsa20-Poly1305
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(plaintext, nonce, messageKey);
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@verbeth/sdk",
3
- "version": "0.1.16",
3
+ "version": "0.2.0",
4
4
  "private": false,
5
5
  "main": "dist/src/index.js",
6
6
  "module": "dist/esm/src/index.js",