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/dist/index.d.mts +37 -31
- package/dist/index.d.ts +37 -31
- package/dist/index.js +794 -370
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +767 -342
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -1
- package/src/config.ts +5 -0
- package/src/core/crypto.ts +171 -87
- package/src/core/exceptions.ts +25 -0
- package/src/core/ff1.ts +196 -0
- package/src/core/fpe.ts +97 -175
- package/src/core/fpe_utils.ts +57 -11
- package/src/core/key_provider.ts +80 -0
- package/src/core/vault.ts +152 -78
- package/src/telemetry/audit_logger.ts +136 -16
- package/tests/bijective_fpe.test.ts +16 -12
- package/tests/fpe.test.ts +17 -8
- package/tests/security_hardening.test.ts +117 -0
- package/tests/vault.test.ts +67 -0
- package/tests/vault_backends.test.ts +7 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mask-privacy",
|
|
3
|
-
"version": "4.
|
|
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"; },
|
package/src/core/crypto.ts
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Core cryptography engine for Mask SDK.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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;
|
|
23
|
-
const GCM_IV_BYTES = 12;
|
|
24
|
-
const GCM_AUTH_TAG_BYTES = 16;
|
|
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
|
-
//
|
|
28
|
-
const
|
|
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
|
|
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
|
-
*
|
|
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
|
|
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
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
|
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
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
//
|
|
99
|
-
|
|
100
|
-
const
|
|
101
|
-
const
|
|
102
|
-
|
|
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
|
-
|
|
115
|
-
if (!
|
|
116
|
-
throw new Error(
|
|
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,
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
135
|
-
|
|
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
|
-
|
|
233
|
+
// aes:v1:{base64} — implicit key ID "default"
|
|
234
|
+
return this._decryptAesGcm('default', ciphertext.slice(AES_GCM_PREFIX.length));
|
|
142
235
|
}
|
|
143
236
|
|
|
144
|
-
|
|
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
|
-
|
|
153
|
-
|
|
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
|
);
|
package/src/core/exceptions.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/core/ff1.ts
ADDED
|
@@ -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
|
+
}
|