mask-privacy 4.0.0 → 4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mask-privacy",
3
- "version": "4.0.0",
3
+ "version": "4.2.0",
4
4
  "description": "Enterprise-grade AI Data Loss Prevention (DLP) SDK for TypeScript",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -35,6 +35,7 @@
35
35
  "@aws-sdk/lib-dynamodb": "^3.1012.0",
36
36
  "@huggingface/transformers": "^3.8.1",
37
37
  "@langchain/core": "^1.1.33",
38
+ "argon2": "^0.41.1",
38
39
  "llamaindex": "^0.12.1",
39
40
  "piscina": "^5.1.4"
40
41
  },
package/src/config.ts CHANGED
@@ -43,12 +43,16 @@ export const config = {
43
43
 
44
44
  // --- SECURITY & CRYPTOGRAPHY ---
45
45
  get MASK_ENCRYPTION_KEY() { return process.env.MASK_ENCRYPTION_KEY || null; },
46
+ // JSON map of keyId -> base64 key string for key rotation, e.g. {"v1":"...","v2":"..."}
47
+ // The last entry in the map is treated as the active (encryption) key.
48
+ get MASK_KEYRING() { return process.env.MASK_KEYRING || null; },
46
49
  get MASK_MASTER_KEY() {
47
50
  return process.env.MASK_MASTER_KEY || process.env.MASK_ENCRYPTION_KEY || '';
48
51
  },
49
52
  get MASK_ENCRYPTED_KEY() { return process.env.MASK_ENCRYPTED_KEY || null; },
50
53
  get MASK_STRICT_PROD() { return getEnvBool('MASK_STRICT_PROD', false); },
51
54
  get MASK_BLIND_INDEX_SALT() { return process.env.MASK_BLIND_INDEX_SALT || "mask-blind-index"; },
55
+ get MASK_KDF_SALT() { return process.env.MASK_KDF_SALT || "mask-kdf-v4-argon2id"; },
52
56
  get VAULT_TOKEN() { return process.env.VAULT_TOKEN || null; },
53
57
 
54
58
  // --- VAULT & STORAGE ---
@@ -56,6 +60,7 @@ export const config = {
56
60
  get MASK_FAIL_STRATEGY() { return (process.env.MASK_FAIL_STRATEGY || "").toLowerCase(); },
57
61
  get MASK_VAULT_TTL() { return getEnvInt('MASK_VAULT_TTL', 600); },
58
62
  get MASK_VAULT_CLEANUP_FREQUENCY() { return getEnvFloat('MASK_VAULT_CLEANUP_FREQUENCY', 0.01); },
63
+ get MASK_VAULT_MAX_MEMORY_KEYS() { return getEnvInt('MASK_VAULT_MAX_MEMORY_KEYS', 100_000); },
59
64
 
60
65
  // --- BACKEND CONNECTIONS ---
61
66
  get MASK_REDIS_URL() { return process.env.MASK_REDIS_URL || "redis://localhost:6379/0"; },
@@ -1,14 +1,16 @@
1
1
  /**
2
2
  * Core cryptography engine for Mask SDK.
3
3
  *
4
- * Provides a CryptoEngine singleton that handles Envelope Encryption,
5
- * ensuring that plaintext PII is encrypted locally before being
6
- * transmitted and stored in distributed vaults (Redis/Memcached/DynamoDB).
4
+ * Supports a JSON-based Keyring for transparent key rotation:
5
+ * MASK_KEYRING='{"v1":"oldkey...","v2":"newkey..."}'
6
+ * The *last* key in the JSON object is the active encryption key.
7
+ * All keys in the keyring are available for decryption (zero-downtime rotation).
7
8
  *
8
- * Uses AES-256-GCM (authenticated encryption) via native Node.js crypto.
9
- * Includes a compatibility layer to decrypt legacy Fernet-format tokens.
9
+ * Legacy single-key mode (MASK_ENCRYPTION_KEY) is fully mapped to key ID "default".
10
10
  *
11
- * Requires MASK_ENCRYPTION_KEY to be set in the environment.
11
+ * Ciphertext envelope format: aes:v2:{keyId}:{base64(iv+authTag+ciphertext)}
12
+ *
13
+ * Uses AES-256-GCM via native Node.js crypto and Argon2id for key derivation.
12
14
  */
13
15
 
14
16
  import { config } from '../config';
@@ -19,24 +21,32 @@ import { MaskDecryptionError } from './exceptions';
19
21
  // ---------------------------------------------------------------------------
20
22
  // AES-256-GCM constants
21
23
  // ---------------------------------------------------------------------------
22
- const AES_KEY_BYTES = 32; // 256 bits
23
- const GCM_IV_BYTES = 12; // 96-bit nonce (NIST recommended for GCM)
24
- const GCM_AUTH_TAG_BYTES = 16; // 128-bit auth tag
24
+ const AES_KEY_BYTES = 32; // 256 bits
25
+ const GCM_IV_BYTES = 12; // 96-bit nonce (NIST recommended for GCM)
26
+ const GCM_AUTH_TAG_BYTES = 16; // 128-bit auth tag
25
27
  const GCM_ALGORITHM = 'aes-256-gcm';
26
28
 
27
- // Prefix for new AES-GCM tokens to distinguish from legacy Fernet
28
- const AES_GCM_PREFIX = 'aes:';
29
+ // Envelope prefixes (in priority order for decryption)
30
+ const AES_V2_PREFIX = 'aes:v2:'; // current: aes:v2:{keyId}:{base64}
31
+ const AES_GCM_PREFIX = 'aes:v1:'; // legacy single-key
32
+ const AES_GCM_LEGACY_PREFIX = 'aes:'; // oldest legacy
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Keyring type
36
+ // ---------------------------------------------------------------------------
37
+ type Keyring = Map<string, Buffer>; // keyId -> derived AES key
29
38
 
30
39
  export class CryptoEngine {
31
40
  private static _instance: CryptoEngine | null = null;
32
- private _aesKey: Buffer | null = null;
41
+ private _keyring: Keyring = new Map();
42
+ private _activeKeyId: string = 'default';
33
43
  private _indexSecret: Buffer | null = null;
34
44
 
35
45
  private constructor() {}
36
46
 
37
- /**
47
+ /**
38
48
  * Return the singleton instance, initialising it if necessary.
39
- * This is asynchronous because key providers (KMS, etc.) might be async.
49
+ * Async because Argon2id key derivation is async.
40
50
  */
41
51
  public static async getInstanceAsync(): Promise<CryptoEngine> {
42
52
  if (this._instance === null) {
@@ -54,52 +64,122 @@ export class CryptoEngine {
54
64
  return this._instance;
55
65
  }
56
66
 
57
- /** Clear the singleton instance to force re-initialization (useful for key rotation). */
67
+ /** Clear the singleton (useful for key rotation / tests). */
58
68
  public static reset(): void {
59
69
  this._instance = null;
60
70
  }
61
71
 
72
+ private async _deriveAesKey(rawKey: string, keyId: string): Promise<Buffer> {
73
+ /**
74
+ * Derive a 256-bit AES key from a raw key string using Argon2id.
75
+ * Salt = KDF_SALT + tenant_id + key_id — unique per tenant and per key version.
76
+ */
77
+ let argon2: any;
78
+ try {
79
+ argon2 = require('argon2');
80
+ } catch (e) {
81
+ throw new Error(
82
+ "The 'argon2' package is required for Mask SDK cryptographic operations. " +
83
+ "Install with: npm install argon2"
84
+ );
85
+ }
86
+ const kdfSaltStr = config.MASK_KDF_SALT + '-' + config.MASK_TENANT_ID + '-' + keyId;
87
+ const kdfSaltBytes = cryptoNode.createHash('sha256').update(kdfSaltStr).digest().subarray(0, 16);
88
+ return await argon2.hash(rawKey, {
89
+ type: argon2.argon2id,
90
+ memoryCost: 19456,
91
+ timeCost: 2,
92
+ parallelism: 1,
93
+ hashLength: AES_KEY_BYTES,
94
+ salt: kdfSaltBytes,
95
+ raw: true,
96
+ }) as Buffer;
97
+ }
98
+
62
99
  private async _init(): Promise<void> {
63
100
  /**
64
- * Initialize the AES-256-GCM engine.
65
- *
66
- * The encryption key is retrieved from the active KeyProvider.
67
- * If no key is available, a throwaway key is auto-generated for
68
- * local/test/demo use.
101
+ * Initialize the keyring. Loading order:
102
+ * 1. MASK_KEYRING (JSON): {"v1": "oldkey", "v2": "newkey"}
103
+ * Last key is treated as the active key.
104
+ * 2. MASK_ENCRYPTION_KEY (legacy): single key mapped to ID "default".
105
+ * 3. Dev mode: auto-generate ephemeral key if MASK_DEV_MODE=true.
69
106
  */
107
+ let argon2: any;
108
+ try {
109
+ argon2 = require('argon2');
110
+ } catch (e) {
111
+ throw new Error("The 'argon2' package is required. Install with: npm install argon2");
112
+ }
113
+
70
114
  const provider = getKeyProvider();
71
- const keyFromProvider = await provider.getEncryptionKey();
72
-
73
- let key: string;
74
- if (!keyFromProvider) {
75
- if (config.MASK_DEV_MODE) {
76
- key = cryptoNode.randomBytes(32).toString('base64');
77
- // We can't easily write back to config exports, but we can update process.env for the legacy path below
78
- process.env.MASK_ENCRYPTION_KEY = key;
79
- console.warn(
80
- "MASK_DEV_MODE is enabled. Using a generated throwaway key. " +
81
- "DO NOT USE THIS IN PRODUCTION tokens will be lost on restart."
82
- );
83
- } else {
84
- throw new Error(
85
- 'MASK_ENCRYPTION_KEY is not set. Set MASK_ENCRYPTION_KEY to a valid ' +
86
- 'encryption key, or set MASK_DEV_MODE=true to use an ephemeral throwaway key.'
87
- );
115
+
116
+ // ── Build raw key map ─────────────────────────────────────────────────
117
+ const rawKeys: Map<string, string> = new Map();
118
+ let activeKeyId = 'default';
119
+
120
+ const keyringJson = await provider.getKeyring();
121
+ if (keyringJson) {
122
+ let parsed: Record<string, string>;
123
+ try {
124
+ parsed = JSON.parse(keyringJson);
125
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
126
+ throw new Error('MASK_KEYRING must be a non-empty JSON object.');
127
+ }
128
+ } catch (e) {
129
+ throw new Error(`Invalid MASK_KEYRING format: ${e}`);
88
130
  }
131
+ const entries = Object.entries(parsed);
132
+ if (entries.length === 0) throw new Error('MASK_KEYRING must contain at least one key.');
133
+ for (const [kid, k] of entries) rawKeys.set(kid, k);
134
+ // Last key in JSON insertion order is the active key
135
+ activeKeyId = entries[entries.length - 1][0];
89
136
  } else {
90
- key = keyFromProvider;
137
+ // Legacy single-key mode
138
+ const keyFromProvider = await provider.getEncryptionKey();
139
+ let key: string;
140
+ if (!keyFromProvider) {
141
+ if (config.MASK_DEV_MODE) {
142
+ key = cryptoNode.randomBytes(32).toString('base64');
143
+ process.env.MASK_ENCRYPTION_KEY = key;
144
+ console.warn(
145
+ "MASK_DEV_MODE is enabled. Using a generated throwaway key. " +
146
+ "DO NOT USE THIS IN PRODUCTION — tokens will be lost on restart."
147
+ );
148
+ } else {
149
+ throw new Error(
150
+ 'MASK_ENCRYPTION_KEY or MASK_KEYRING is not set. ' +
151
+ 'Set one of these, or set MASK_DEV_MODE=true for ephemeral use.'
152
+ );
153
+ }
154
+ } else {
155
+ key = keyFromProvider;
156
+ }
157
+ rawKeys.set('default', key);
158
+ activeKeyId = 'default';
91
159
  }
92
160
 
93
- // Derive a 32-byte AES key from the provided key material.
94
- // This normalises any key format (Fernet base64, raw base64, etc.)
95
- // into a consistent 32-byte key via SHA-256 derivation.
96
- this._aesKey = cryptoNode.createHash('sha256').update(key).digest();
161
+ // ── Derive AES-256 keys for every keyring entry ───────────────────────
162
+ this._keyring = new Map();
163
+ for (const [kid, rawKey] of rawKeys) {
164
+ this._keyring.set(kid, await this._deriveAesKey(rawKey, kid));
165
+ }
166
+ this._activeKeyId = activeKeyId;
97
167
 
98
- // Derive a separate secret for blind indexing (HMAC-SHA256)
99
- // We derive it from the master encryption key so we don't need a 3rd env var.
100
- const masterKey = await provider.getMasterKey() || key;
101
- const salt = config.MASK_BLIND_INDEX_SALT;
102
- this._indexSecret = cryptoNode.createHmac('sha256', masterKey).update(salt).digest();
168
+ // ── Blind Index Secret (separate Argon2id derivation) ─────────────────
169
+ const rawKeysArr = Array.from(rawKeys.values());
170
+ const lastRawKey = rawKeysArr[rawKeysArr.length - 1];
171
+ const masterKey = await provider.getMasterKey() || lastRawKey;
172
+ const indexSaltStr = config.MASK_BLIND_INDEX_SALT + '-' + config.MASK_TENANT_ID;
173
+ const indexSaltBytes = cryptoNode.createHash('sha256').update(indexSaltStr).digest().subarray(0, 16);
174
+ this._indexSecret = await argon2.hash(masterKey, {
175
+ type: argon2.argon2id,
176
+ memoryCost: 19456,
177
+ timeCost: 2,
178
+ parallelism: 1,
179
+ hashLength: AES_KEY_BYTES,
180
+ salt: indexSaltBytes,
181
+ raw: true,
182
+ }) as Buffer;
103
183
  }
104
184
 
105
185
  /** Return the secret used for HMAC-based blind indexing. */
@@ -110,75 +190,83 @@ export class CryptoEngine {
110
190
  return this._indexSecret!;
111
191
  }
112
192
 
193
+ /** Encrypt plaintext using the active keyring key.
194
+ * Envelope format: aes:v2:{keyId}:{base64(iv+authTag+ciphertext)}
195
+ */
113
196
  public encrypt(plaintext: string): string {
114
- /** Encrypt plaintext using AES-256-GCM. Returns prefixed base64 string. */
115
- if (!this._aesKey) {
116
- throw new Error("CryptoEngine not initialised. AES key missing.");
197
+ const aesKey = this._keyring.get(this._activeKeyId);
198
+ if (!aesKey) {
199
+ throw new Error(`CryptoEngine: active key ID '${this._activeKeyId}' not found in keyring.`);
117
200
  }
118
201
 
119
202
  const iv = cryptoNode.randomBytes(GCM_IV_BYTES);
120
- const cipher = cryptoNode.createCipheriv(GCM_ALGORITHM, this._aesKey, iv);
121
-
122
- const encrypted = Buffer.concat([
123
- cipher.update(plaintext, 'utf8'),
124
- cipher.final()
125
- ]);
203
+ const cipher = cryptoNode.createCipheriv(GCM_ALGORITHM, aesKey, iv);
204
+ const plaintextBuf = Buffer.from(plaintext, 'utf8');
205
+ const encrypted = Buffer.concat([cipher.update(plaintextBuf), cipher.final()]);
126
206
  const authTag = cipher.getAuthTag();
127
207
 
128
- // Wire format: iv (12) + authTag (16) + ciphertext (variable)
208
+ // Zero out the plaintext buffer to minimise time-in-memory for sensitive data
209
+ plaintextBuf.fill(0);
210
+
129
211
  const combined = Buffer.concat([iv, authTag, encrypted]);
130
- return AES_GCM_PREFIX + combined.toString('base64');
212
+ return `${AES_V2_PREFIX}${this._activeKeyId}:${combined.toString('base64')}`;
131
213
  }
132
214
 
215
+ /** Decrypt ciphertext. Supports all historical envelope formats. */
133
216
  public decrypt(ciphertext: string): string {
134
- /** Decrypt ciphertext. Supports both new AES-GCM and legacy Fernet formats. */
135
- if (!this._aesKey) {
136
- throw new Error("CryptoEngine not initialised. AES key missing.");
217
+ if (this._keyring.size === 0) {
218
+ throw new Error("CryptoEngine not initialised.");
137
219
  }
138
220
 
139
221
  try {
222
+ if (ciphertext.startsWith(AES_V2_PREFIX)) {
223
+ // aes:v2:{keyId}:{base64}
224
+ const rest = ciphertext.slice(AES_V2_PREFIX.length);
225
+ const sep = rest.indexOf(':');
226
+ if (sep === -1) throw new Error('Malformed aes:v2 envelope: missing key ID separator.');
227
+ const keyId = rest.slice(0, sep);
228
+ const b64 = rest.slice(sep + 1);
229
+ return this._decryptAesGcm(keyId, b64);
230
+ }
231
+
140
232
  if (ciphertext.startsWith(AES_GCM_PREFIX)) {
141
- return this._decryptAesGcm(ciphertext.slice(AES_GCM_PREFIX.length));
233
+ // aes:v1:{base64} — implicit key ID "default"
234
+ return this._decryptAesGcm('default', ciphertext.slice(AES_GCM_PREFIX.length));
142
235
  }
143
236
 
144
- // Legacy path: attempt Fernet-format decryption
237
+ if (ciphertext.startsWith(AES_GCM_LEGACY_PREFIX)) {
238
+ // aes:{base64} — oldest format, implicit key ID "default"
239
+ return this._decryptAesGcm('default', ciphertext.slice(AES_GCM_LEGACY_PREFIX.length));
240
+ }
241
+
242
+ // Final fallback: legacy Fernet format
145
243
  return this._decryptLegacyFernet(ciphertext);
146
244
  } catch (e) {
147
- console.error("Failed to decrypt vault payload. Check your MASK_ENCRYPTION_KEY. Inner error:", e);
245
+ console.error("Failed to decrypt vault payload. Check your MASK_ENCRYPTION_KEY / MASK_KEYRING. Inner error:", e);
148
246
  throw new MaskDecryptionError("Decryption failed");
149
247
  }
150
248
  }
151
249
 
152
- /** Decrypt an AES-256-GCM token (base64 encoded). */
153
- private _decryptAesGcm(b64: string): string {
250
+ private _decryptAesGcm(keyId: string, b64: string): string {
251
+ const aesKey = this._keyring.get(keyId);
252
+ if (!aesKey) {
253
+ throw new MaskDecryptionError(
254
+ `No key found for key ID '${keyId}'. Ensure the key is present in MASK_KEYRING.`
255
+ );
256
+ }
154
257
  const combined = Buffer.from(b64, 'base64');
155
258
  if (combined.length < GCM_IV_BYTES + GCM_AUTH_TAG_BYTES) {
156
259
  throw new Error("Ciphertext too short for AES-GCM");
157
260
  }
158
-
159
261
  const iv = combined.subarray(0, GCM_IV_BYTES);
160
262
  const authTag = combined.subarray(GCM_IV_BYTES, GCM_IV_BYTES + GCM_AUTH_TAG_BYTES);
161
263
  const encrypted = combined.subarray(GCM_IV_BYTES + GCM_AUTH_TAG_BYTES);
162
-
163
- const decipher = cryptoNode.createDecipheriv(GCM_ALGORITHM, this._aesKey!, iv);
264
+ const decipher = cryptoNode.createDecipheriv(GCM_ALGORITHM, aesKey, iv);
164
265
  decipher.setAuthTag(authTag);
165
-
166
- const decrypted = Buffer.concat([
167
- decipher.update(encrypted),
168
- decipher.final()
169
- ]);
170
-
266
+ const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]);
171
267
  return decrypted.toString('utf8');
172
268
  }
173
269
 
174
- /**
175
- * Attempt to decrypt a legacy Fernet-format token.
176
- *
177
- * Fernet format: Version (1) || Timestamp (8) || IV (16) || Ciphertext (var) || HMAC (32)
178
- * All base64url-encoded.
179
- *
180
- * We try to use the `fernet` npm package if available, otherwise throw.
181
- */
182
270
  private _decryptLegacyFernet(ciphertext: string): string {
183
271
  let fernet: any;
184
272
  try {
@@ -189,10 +277,7 @@ export class CryptoEngine {
189
277
  "Please run 'npm install fernet' to support legacy tokens."
190
278
  );
191
279
  }
192
-
193
280
  try {
194
- // Reconstruct the original Fernet key from our AES key
195
- // This won't work if the key was derived differently, but it's a best-effort compat layer
196
281
  const token = new fernet.Token({
197
282
  secret: new fernet.Secret(config.MASK_ENCRYPTION_KEY || process.env.MASK_ENCRYPTION_KEY || ''),
198
283
  token: ciphertext,
@@ -200,7 +285,6 @@ export class CryptoEngine {
200
285
  });
201
286
  return token.decode();
202
287
  } catch (e) {
203
- // If decryption fails, throw to caller
204
288
  throw new MaskDecryptionError(
205
289
  "Failed to decrypt legacy Fernet token. The key may have changed or the token is corrupt."
206
290
  );
@@ -24,3 +24,28 @@ export class MaskNLPTimeout extends MaskError {}
24
24
 
25
25
  /** Raised when mandatory security keys (MASK_MASTER_KEY, etc.) are missing. */
26
26
  export class MaskSecurityError extends MaskError {}
27
+
28
+ /**
29
+ * Raised when a newly generated token already maps to a *different* plaintext.
30
+ *
31
+ * This indicates a Birthday-Paradox collision in the deterministic pseudonymization
32
+ * engine — two distinct plaintexts produced the same token. The vault refuses to
33
+ * overwrite existing PII, so the caller must handle the collision.
34
+ */
35
+ export class TokenCollisionError extends MaskError {
36
+ public readonly token: string;
37
+ public readonly existingHash: string;
38
+ public readonly incomingHash: string;
39
+
40
+ constructor(token: string, existingHash: string, incomingHash: string) {
41
+ super(
42
+ `Token collision detected for token '${token}'. ` +
43
+ `Existing plaintext hash '${existingHash.slice(0, 8)}…' conflicts with ` +
44
+ `incoming hash '${incomingHash.slice(0, 8)}…'. ` +
45
+ 'Increase token entropy or adjust tenant salt configuration.'
46
+ );
47
+ this.token = token;
48
+ this.existingHash = existingHash;
49
+ this.incomingHash = incomingHash;
50
+ }
51
+ }
@@ -0,0 +1,196 @@
1
+ import * as crypto from 'crypto';
2
+
3
+ export class FF1 {
4
+ private key: Buffer;
5
+ private tweak: Buffer;
6
+ private radix: number;
7
+ private chars: string = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
8
+
9
+ constructor(key: Buffer, tweak: Buffer, radix: number) {
10
+ this.key = key;
11
+ this.tweak = tweak;
12
+ this.radix = radix;
13
+ if (radix > this.chars.length) {
14
+ throw new Error(`Radix ${radix} not supported`);
15
+ }
16
+ }
17
+
18
+ private _prf(x: Buffer): Buffer {
19
+ const cipher = crypto.createCipheriv('aes-256-cbc', this.key, Buffer.alloc(16, 0));
20
+ cipher.setAutoPadding(false);
21
+ return Buffer.concat([cipher.update(x), cipher.final()]).subarray(-16);
22
+ }
23
+
24
+ private _ciph(x: Buffer): Buffer {
25
+ const cipher = crypto.createCipheriv('aes-256-ecb', this.key, null);
26
+ cipher.setAutoPadding(false);
27
+ return Buffer.concat([cipher.update(x), cipher.final()]);
28
+ }
29
+
30
+ private _strToInt(s: string): bigint {
31
+ let n = 0n;
32
+ const r = BigInt(this.radix);
33
+ for (let i = 0; i < s.length; i++) {
34
+ n = n * r + BigInt(this.chars.indexOf(s[i]));
35
+ }
36
+ return n;
37
+ }
38
+
39
+ private _intToStr(num: bigint, length: number): string {
40
+ if (num === 0n) {
41
+ return this.chars[0].repeat(length);
42
+ }
43
+ let digits: string[] = [];
44
+ let n = num;
45
+ const r = BigInt(this.radix);
46
+ while (n > 0n) {
47
+ digits.push(this.chars[Number(n % r)]);
48
+ n /= r;
49
+ }
50
+ let s = digits.reverse().join('');
51
+ while (s.length < length) s = this.chars[0] + s;
52
+ return s;
53
+ }
54
+
55
+ private _bigintToBuffer(num: bigint, bytes: number): Buffer {
56
+ const buf = Buffer.alloc(bytes);
57
+ let n = num;
58
+ for (let i = bytes - 1; i >= 0; i--) {
59
+ buf[i] = Number(n & 0xFFn);
60
+ n >>= 8n;
61
+ }
62
+ return buf;
63
+ }
64
+
65
+ encrypt(X: string): string {
66
+ const n = X.length;
67
+ const t = this.tweak.length;
68
+ if (n < 2) return X;
69
+ const u = Math.floor(n / 2);
70
+ const v = n - u;
71
+
72
+ let A = X.substring(0, u);
73
+ let B = X.substring(u);
74
+
75
+ const b = Math.ceil(Math.ceil(v * Math.log2(this.radix)) / 8);
76
+ const d = 4 * Math.ceil(b / 4) + 4;
77
+
78
+ const P = Buffer.alloc(16);
79
+ P[0] = 1; P[1] = 2; P[2] = 1;
80
+ P.writeUIntBE(this.radix, 3, 3);
81
+ P[6] = 10; P[7] = u % 256;
82
+ P.writeUInt32BE(n, 8);
83
+ P.writeUInt32BE(t, 12);
84
+
85
+ for (let i = 0; i < 10; i++) {
86
+ const m = i % 2 === 0 ? u : v;
87
+ // padding length calculates correctly using JS modulo for negative numbers
88
+ const padLen = ((-t - b - 1) % 16 + 16) % 16;
89
+
90
+ const Q = Buffer.alloc(t + padLen + 1 + b);
91
+ this.tweak.copy(Q, 0);
92
+ Q[t + padLen] = i;
93
+ this._bigintToBuffer(this._strToInt(B), b).copy(Q, t + padLen + 1);
94
+
95
+ const R = this._prf(Buffer.concat([P, Q]));
96
+ let S = Buffer.from(R);
97
+ let j = 1;
98
+
99
+ while (S.length < d) {
100
+ const xorBlock = Buffer.alloc(16);
101
+ const jBuf = Buffer.alloc(16);
102
+ jBuf.writeUInt32BE(j, 12); // j fits in 32 bits natively
103
+ for (let k = 0; k < 16; k++) xorBlock[k] = R[k] ^ jBuf[k];
104
+ S = Buffer.concat([S, this._ciph(xorBlock)]);
105
+ j++;
106
+ }
107
+
108
+ S = S.subarray(0, d);
109
+
110
+ // Convert S to bigint
111
+ let y = 0n;
112
+ for (let k = 0; k < S.length; k++) {
113
+ y = (y << 8n) + BigInt(S[k]);
114
+ }
115
+
116
+ const modulo = BigInt(this.radix) ** BigInt(m);
117
+ const c = (this._strToInt(A) + y) % modulo;
118
+ const C = this._intToStr(c, m);
119
+
120
+ A = B;
121
+ B = C;
122
+ }
123
+
124
+ return A + B;
125
+ }
126
+
127
+ decrypt(X: string): string {
128
+ const n = X.length;
129
+ const t = this.tweak.length;
130
+ if (n < 2) return X;
131
+ const u = Math.floor(n / 2);
132
+ const v = n - u;
133
+
134
+ let A = X.substring(0, u);
135
+ let B = X.substring(u);
136
+
137
+ if (n % 2 !== 0) {
138
+ const temp = A; A = B; B = temp;
139
+ }
140
+
141
+ const b = Math.ceil(Math.ceil(v * Math.log2(this.radix)) / 8);
142
+ const d = 4 * Math.ceil(b / 4) + 4;
143
+
144
+ const P = Buffer.alloc(16);
145
+ P[0] = 1; P[1] = 2; P[2] = 1;
146
+ P.writeUIntBE(this.radix, 3, 3);
147
+ P[6] = 10; P[7] = u % 256;
148
+ P.writeUInt32BE(n, 8);
149
+ P.writeUInt32BE(t, 12);
150
+
151
+ for (let i = 9; i >= 0; i--) {
152
+ const m = i % 2 === 0 ? u : v;
153
+ const padLen = ((-t - b - 1) % 16 + 16) % 16;
154
+
155
+ const Q = Buffer.alloc(t + padLen + 1 + b);
156
+ this.tweak.copy(Q, 0);
157
+ Q[t + padLen] = i;
158
+ this._bigintToBuffer(this._strToInt(A), b).copy(Q, t + padLen + 1);
159
+
160
+ const R = this._prf(Buffer.concat([P, Q]));
161
+ let S = Buffer.from(R);
162
+ let j = 1;
163
+
164
+ while (S.length < d) {
165
+ const xorBlock = Buffer.alloc(16);
166
+ const jBuf = Buffer.alloc(16);
167
+ jBuf.writeUInt32BE(j, 12);
168
+ for (let k = 0; k < 16; k++) xorBlock[k] = R[k] ^ jBuf[k];
169
+ S = Buffer.concat([S, this._ciph(xorBlock)]);
170
+ j++;
171
+ }
172
+
173
+ S = S.subarray(0, d);
174
+
175
+ let y = 0n;
176
+ for (let k = 0; k < S.length; k++) {
177
+ y = (y << 8n) + BigInt(S[k]);
178
+ }
179
+
180
+ const modulo = BigInt(this.radix) ** BigInt(m);
181
+ let c = (this._strToInt(B) - y) % modulo;
182
+ if (c < 0n) c += modulo;
183
+
184
+ const C = this._intToStr(c, m);
185
+
186
+ B = A;
187
+ A = C;
188
+ }
189
+
190
+ if (n % 2 !== 0) {
191
+ const temp = A; A = B; B = temp;
192
+ }
193
+
194
+ return A + B;
195
+ }
196
+ }