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/src/core/vault.ts CHANGED
@@ -12,10 +12,10 @@
12
12
 
13
13
  import * as crypto from 'crypto';
14
14
  import { config } from '../config';
15
- import { generateFPEToken } from './fpe';
15
+ import { generateDPToken } from './fpe';
16
16
  import { looksLikeToken, TOKEN_PATTERN } from './fpe_utils';
17
17
  import { getCryptoEngineAsync } from './crypto';
18
- import { MaskVaultConnectionError } from './exceptions';
18
+ import { MaskVaultConnectionError, TokenCollisionError } from './exceptions';
19
19
  import { getAuditLogger } from '../telemetry/audit_logger';
20
20
  import { BucketManager } from './search';
21
21
 
@@ -50,8 +50,8 @@ export function _getFailStrategy(): string {
50
50
 
51
51
  /** Interface every vault backend must implement. */
52
52
  export abstract class BaseVault {
53
- /** Persist a token → plaintext mapping with a TTL. Optionally save a reverse lookup hash. */
54
- abstract store(token: string, plaintext: string, ttlSeconds: number, ptHash?: string | null): Promise<void>;
53
+ /** Persist a token → encrypted plaintext mapping with a TTL and optional compliance metadata. */
54
+ abstract store(token: string, plaintext: string, ttlSeconds: number, ptHash?: string | null, metadata?: Record<string, string> | null): Promise<void>;
55
55
 
56
56
  /** Return the existing unexpired token for a given plaintext hash, or null. */
57
57
  abstract getTokenByPlaintextHash(ptHash: string): Promise<string | null>;
@@ -59,6 +59,9 @@ export abstract class BaseVault {
59
59
  /** Return the plaintext for token, or null if missing/expired. */
60
60
  abstract retrieve(token: string): Promise<string | null>;
61
61
 
62
+ /** Return the plaintext hash stored for this token (used for collision detection), or null. */
63
+ abstract getPtHashForToken(token: string): Promise<string | null>;
64
+
62
65
  /** Delete a token and its reverse mapping. */
63
66
  abstract delete(token: string): Promise<void>;
64
67
  }
@@ -78,29 +81,57 @@ export function _hashPlaintext(plaintext: string, secret?: Buffer): string {
78
81
  return crypto.createHash('sha256').update(trimmed, 'utf-8').digest('hex');
79
82
  }
80
83
 
84
+ // ---------------------------------------------------------------------------
85
+ // Tenant-namespaced cache key helpers
86
+ // ---------------------------------------------------------------------------
87
+
88
+ function _vaultKey(token: string): string {
89
+ return `mask:${config.MASK_TENANT_ID}:${token}`;
90
+ }
91
+ function _vaultRevKey(ptHash: string): string {
92
+ return `mask-rev:${config.MASK_TENANT_ID}:${ptHash}`;
93
+ }
94
+ function _vaultHashKey(token: string): string {
95
+ return `mask-hash:${config.MASK_TENANT_ID}:${token}`;
96
+ }
97
+
98
+ /**
99
+ * Extract the ciphertext from a JSON compliance envelope {ct, meta}, or return raw.
100
+ * Fixes the fatal bug where tokens stored with metadata could never be decrypted.
101
+ */
102
+ function _unwrapPayload(raw: string): string {
103
+ if (raw && raw.startsWith('{')) {
104
+ try {
105
+ const obj = JSON.parse(raw);
106
+ if (obj.ct) return obj.ct;
107
+ } catch {}
108
+ }
109
+ return raw;
110
+ }
111
+
81
112
  /**
82
113
  * In-memory implementation (single-process, dev / testing)
83
114
  *
84
115
  * Map-backed vault. Fast, but state is lost across processes.
85
116
  */
86
117
  export class MemoryVault extends BaseVault {
87
- private _store: Map<string, { plaintext: string; expiry: number; ptHash: string | null }>;
118
+ private _store: Map<string, { plaintext: string; expiry: number; ptHash: string | null; metadata: Record<string, string> }>;
88
119
  private _reverseStore: Map<string, string>;
120
+ private _cleanupTimer: NodeJS.Timeout | null = null;
89
121
 
90
122
  constructor() {
91
123
  super();
92
124
  this._store = new Map();
93
125
  this._reverseStore = new Map();
126
+ // Replace probabilistic inline cleanup with a background timer (unreffed
127
+ // so it never prevents process exit) to eliminate request-path latency jitter.
128
+ this._cleanupTimer = setInterval(() => this._cleanup(), 60_000);
129
+ if (this._cleanupTimer && typeof this._cleanupTimer.unref === 'function') {
130
+ this._cleanupTimer.unref();
131
+ }
94
132
  }
95
133
 
96
134
  private _cleanup(): void {
97
- // Probabilistic cleanup to prevent memory bloat
98
- // Configurable frequency (default 1%) balances CPU vs Memory usage
99
- const cleanupFreq = config.MASK_VAULT_CLEANUP_FREQUENCY;
100
- if (Math.random() > cleanupFreq) {
101
- return;
102
- }
103
-
104
135
  const now = Date.now() / 1000;
105
136
  for (const [token, entry] of this._store.entries()) {
106
137
  if (now > entry.expiry) {
@@ -112,12 +143,28 @@ export class MemoryVault extends BaseVault {
112
143
  }
113
144
  }
114
145
 
115
- async store(token: string, plaintext: string, ttlSeconds: number, ptHash: string | null = null): Promise<void> {
116
- this._cleanup();
146
+ async store(token: string, ciphertext: string, ttlSeconds: number, ptHash: string | null = null, metadata: Record<string, string> | null = null): Promise<void> {
147
+ // Enforce max memory keys bound (OOM prevention)
148
+ if (!this._store.has(token) && this._store.size >= config.MASK_VAULT_MAX_MEMORY_KEYS) {
149
+ const firstKey = this._store.keys().next().value;
150
+ if (firstKey !== undefined) {
151
+ const oldEntry = this._store.get(firstKey);
152
+ this._store.delete(firstKey);
153
+ if (oldEntry?.ptHash && this._reverseStore.get(oldEntry.ptHash) === firstKey) {
154
+ this._reverseStore.delete(oldEntry.ptHash);
155
+ }
156
+ }
157
+ }
158
+
159
+ const existing = this._store.get(token);
160
+ // Delete to reset insertion order (implementing true LRU over simple FIFO)
161
+ if (existing) this._store.delete(token);
162
+
117
163
  this._store.set(token, {
118
- plaintext,
164
+ plaintext: ciphertext,
119
165
  expiry: (Date.now() / 1000) + ttlSeconds,
120
- ptHash
166
+ ptHash,
167
+ metadata: { ...(existing?.metadata || {}), ...(metadata || {}) },
121
168
  });
122
169
  if (ptHash) {
123
170
  this._reverseStore.set(ptHash, token);
@@ -125,7 +172,6 @@ export class MemoryVault extends BaseVault {
125
172
  }
126
173
 
127
174
  async getTokenByPlaintextHash(ptHash: string): Promise<string | null> {
128
- this._cleanup();
129
175
  const token = this._reverseStore.get(ptHash);
130
176
  if (token && this._store.has(token)) {
131
177
  return token;
@@ -133,8 +179,12 @@ export class MemoryVault extends BaseVault {
133
179
  return null;
134
180
  }
135
181
 
182
+ async getPtHashForToken(token: string): Promise<string | null> {
183
+ const entry = this._store.get(token);
184
+ return entry?.ptHash ?? null;
185
+ }
186
+
136
187
  async retrieve(token: string): Promise<string | null> {
137
- this._cleanup();
138
188
  const entry = this._store.get(token);
139
189
  if (!entry) {
140
190
  return null;
@@ -204,13 +254,14 @@ export class RedisVault extends BaseVault {
204
254
  }
205
255
  }
206
256
 
207
- async store(token: string, ciphertext: string, ttlSeconds: number, ptHash: string | null = null): Promise<void> {
257
+ async store(token: string, ciphertext: string, ttlSeconds: number, ptHash: string | null = null, metadata: Record<string, string> | null = null): Promise<void> {
208
258
  try {
209
259
  const pipeline = this._client.pipeline();
210
- pipeline.set(`mask:${token}`, ciphertext, 'EX', ttlSeconds);
260
+ const payload = metadata ? JSON.stringify({ ct: ciphertext, meta: metadata }) : ciphertext;
261
+ pipeline.set(_vaultKey(token), payload, 'EX', ttlSeconds);
211
262
  if (ptHash) {
212
- pipeline.set(`mask-rev:${ptHash}`, token, 'EX', ttlSeconds);
213
- pipeline.set(`mask-hash:${token}`, ptHash, 'EX', ttlSeconds);
263
+ pipeline.set(_vaultRevKey(ptHash), token, 'EX', ttlSeconds);
264
+ pipeline.set(_vaultHashKey(token), ptHash, 'EX', ttlSeconds);
214
265
  }
215
266
  const results = await pipeline.exec();
216
267
  if (results) {
@@ -223,14 +274,22 @@ export class RedisVault extends BaseVault {
223
274
  }
224
275
  }
225
276
 
277
+ async getPtHashForToken(token: string): Promise<string | null> {
278
+ try {
279
+ return await this._client.get(_vaultHashKey(token));
280
+ } catch {
281
+ return null;
282
+ }
283
+ }
284
+
226
285
  async getTokenByPlaintextHash(ptHash: string): Promise<string | null> {
227
286
  try {
228
- const token = await this._client.get(`mask-rev:${ptHash}`);
287
+ const token = await this._client.get(_vaultRevKey(ptHash));
229
288
  if (token) {
230
- if (await this._client.exists(`mask:${token}`)) {
289
+ if (await this._client.exists(_vaultKey(token))) {
231
290
  return token;
232
291
  } else {
233
- await this._client.del(`mask-rev:${ptHash}`);
292
+ await this._client.del(_vaultRevKey(ptHash));
234
293
  }
235
294
  }
236
295
  return null;
@@ -244,7 +303,8 @@ export class RedisVault extends BaseVault {
244
303
 
245
304
  async retrieve(token: string): Promise<string | null> {
246
305
  try {
247
- return await this._client.get(`mask:${token}`);
306
+ const raw = await this._client.get(_vaultKey(token));
307
+ return raw ? _unwrapPayload(raw) : null;
248
308
  } catch (e) {
249
309
  if (_getFailStrategy() === 'closed') {
250
310
  throw new MaskVaultConnectionError(`Redis read failed: ${e}`);
@@ -255,12 +315,12 @@ export class RedisVault extends BaseVault {
255
315
 
256
316
  async delete(token: string): Promise<void> {
257
317
  try {
258
- const ptHash = await this._client.get(`mask-hash:${token}`);
318
+ const ptHash = await this._client.get(_vaultHashKey(token));
259
319
  const pipeline = this._client.pipeline();
260
- pipeline.del(`mask:${token}`);
261
- pipeline.del(`mask-hash:${token}`);
320
+ pipeline.del(_vaultKey(token));
321
+ pipeline.del(_vaultHashKey(token));
262
322
  if (ptHash) {
263
- pipeline.del(`mask-rev:${ptHash}`);
323
+ pipeline.del(_vaultRevKey(ptHash));
264
324
  }
265
325
  await pipeline.exec();
266
326
  } catch (e) {
@@ -312,54 +372,38 @@ export class DynamoDBVault extends BaseVault {
312
372
  console.info(`DynamoDBVault connected to table ${this._tableName} in ${this._region}`);
313
373
  }
314
374
 
315
- async store(token: string, ciphertext: string, ttlSeconds: number, ptHash: string | null = null): Promise<void> {
375
+ async store(token: string, ciphertext: string, ttlSeconds: number, ptHash: string | null = null, metadata: Record<string, string> | null = null): Promise<void> {
316
376
  const { TransactWriteCommand, PutCommand } = require('@aws-sdk/lib-dynamodb');
317
377
  const now = Math.floor(Date.now() / 1000);
318
378
  const ttlVal = now + ttlSeconds;
319
- const item = {
320
- token: `mask:${token}`,
379
+ const primaryItem: Record<string, any> = {
380
+ token: _vaultKey(token),
321
381
  ciphertext: ciphertext,
322
382
  ttl: ttlVal,
323
- ptr_hash: ptHash || undefined
324
383
  };
384
+ if (ptHash) primaryItem.ptr_hash = ptHash;
385
+ if (metadata) primaryItem.meta_json = JSON.stringify(metadata);
325
386
 
326
387
  if (ptHash) {
327
388
  try {
328
389
  await this._client.send(new TransactWriteCommand({
329
390
  TransactItems: [
391
+ { Put: { TableName: this._tableName, Item: primaryItem } },
330
392
  {
331
393
  Put: {
332
394
  TableName: this._tableName,
333
- Item: {
334
- token: `mask:${token}`,
335
- ciphertext: ciphertext,
336
- ttl: ttlVal,
337
- ptr_hash: ptHash
338
- }
339
- }
340
- },
341
- {
342
- Put: {
343
- TableName: this._tableName,
344
- Item: {
345
- token: `mask-rev:${ptHash}`,
346
- ciphertext: token,
347
- ttl: ttlVal
348
- }
395
+ Item: { token: _vaultRevKey(ptHash), ciphertext: token, ttl: ttlVal }
349
396
  }
350
397
  }
351
398
  ]
352
399
  }));
353
400
  } catch (e: any) {
354
401
  console.error(`DynamoDB transact_write_items failed: ${e}`);
355
- // In both strategies, we must raise for DynamoDB failures to prevent data loss
356
- // since we didn't perform the atomic write.
357
402
  throw new MaskVaultConnectionError(`DynamoDB atomic write failed: ${e}`);
358
403
  }
359
404
  } else {
360
- // Single store (no reverse index)
361
405
  try {
362
- await this._client.send(new PutCommand({ TableName: this._tableName, Item: item }));
406
+ await this._client.send(new PutCommand({ TableName: this._tableName, Item: primaryItem }));
363
407
  } catch (e: any) {
364
408
  throw new MaskVaultConnectionError(`DynamoDB individual write failed: ${e}`);
365
409
  }
@@ -372,12 +416,12 @@ export class DynamoDBVault extends BaseVault {
372
416
  const now = Math.floor(Date.now() / 1000);
373
417
  const resp = await this._client.send(new GetCommand({
374
418
  TableName: this._tableName,
375
- Key: { token: `mask-rev:${ptHash}` }
419
+ Key: { token: _vaultRevKey(ptHash) }
376
420
  }));
377
421
  const item = resp.Item;
378
422
  if (!item) return null;
379
423
  if (now > (item.ttl || 0)) {
380
- await this._client.send(new DeleteCommand({ TableName: this._tableName, Key: { token: `mask-rev:${ptHash}` } }));
424
+ await this._client.send(new DeleteCommand({ TableName: this._tableName, Key: { token: _vaultRevKey(ptHash) } }));
381
425
  return null;
382
426
  }
383
427
  const token = item.ciphertext;
@@ -394,16 +438,16 @@ export class DynamoDBVault extends BaseVault {
394
438
  const now = Math.floor(Date.now() / 1000);
395
439
  const resp = await this._client.send(new GetCommand({
396
440
  TableName: this._tableName,
397
- Key: { token: `mask:${token}` }
441
+ Key: { token: _vaultKey(token) }
398
442
  }));
399
443
  const item = resp.Item;
400
444
  if (!item) return null;
401
445
  if (now > (item.ttl || 0)) {
402
446
  const ptHash = item.ptr_hash;
403
447
  if (ptHash) {
404
- await this._client.send(new DeleteCommand({ TableName: this._tableName, Key: { token: `mask-rev:${ptHash}` } }));
448
+ await this._client.send(new DeleteCommand({ TableName: this._tableName, Key: { token: _vaultRevKey(ptHash) } }));
405
449
  }
406
- await this._client.send(new DeleteCommand({ TableName: this._tableName, Key: { token: `mask:${token}` } }));
450
+ await this._client.send(new DeleteCommand({ TableName: this._tableName, Key: { token: _vaultKey(token) } }));
407
451
  return null;
408
452
  }
409
453
  return item.ciphertext;
@@ -413,18 +457,29 @@ export class DynamoDBVault extends BaseVault {
413
457
  }
414
458
  }
415
459
 
460
+ async getPtHashForToken(token: string): Promise<string | null> {
461
+ try {
462
+ const { GetCommand } = require('@aws-sdk/lib-dynamodb');
463
+ const resp = await this._client.send(new GetCommand({
464
+ TableName: this._tableName,
465
+ Key: { token: _vaultKey(token) }
466
+ }));
467
+ return resp.Item?.ptr_hash ?? null;
468
+ } catch { return null; }
469
+ }
470
+
416
471
  async delete(token: string): Promise<void> {
417
472
  try {
418
473
  const { GetCommand, DeleteCommand } = require('@aws-sdk/lib-dynamodb');
419
474
  const resp = await this._client.send(new GetCommand({
420
475
  TableName: this._tableName,
421
- Key: { token: `mask:${token}` }
476
+ Key: { token: _vaultKey(token) }
422
477
  }));
423
478
  const item = resp.Item;
424
479
  if (item && item.ptr_hash) {
425
- await this._client.send(new DeleteCommand({ TableName: this._tableName, Key: { token: `mask-rev:${item.ptr_hash}` } }));
480
+ await this._client.send(new DeleteCommand({ TableName: this._tableName, Key: { token: _vaultRevKey(item.ptr_hash) } }));
426
481
  }
427
- await this._client.send(new DeleteCommand({ TableName: this._tableName, Key: { token: `mask:${token}` } }));
482
+ await this._client.send(new DeleteCommand({ TableName: this._tableName, Key: { token: _vaultKey(token) } }));
428
483
  } catch (e: any) {
429
484
  if (_getFailStrategy() === 'closed') throw new MaskVaultConnectionError(`DynamoDB delete failed: ${e}`);
430
485
  }
@@ -450,21 +505,29 @@ export class MemcachedVault extends BaseVault {
450
505
  }
451
506
  }
452
507
 
453
- async store(token: string, ciphertext: string, ttlSeconds: number, ptHash: string | null = null): Promise<void> {
508
+ async store(token: string, ciphertext: string, ttlSeconds: number, ptHash: string | null = null, metadata: Record<string, string> | null = null): Promise<void> {
454
509
  try {
455
- await this._client.set(`mask:${token}`, Buffer.from(ciphertext), { expires: ttlSeconds });
510
+ const payload = metadata ? JSON.stringify({ ct: ciphertext, meta: metadata }) : ciphertext;
511
+ await this._client.set(_vaultKey(token), Buffer.from(payload), { expires: ttlSeconds });
456
512
  if (ptHash) {
457
- await this._client.set(`mask-rev:${ptHash}`, Buffer.from(token), { expires: ttlSeconds });
458
- await this._client.set(`mask-hash:${token}`, Buffer.from(ptHash), { expires: ttlSeconds });
513
+ await this._client.set(_vaultRevKey(ptHash), Buffer.from(token), { expires: ttlSeconds });
514
+ await this._client.set(_vaultHashKey(token), Buffer.from(ptHash), { expires: ttlSeconds });
459
515
  }
460
516
  } catch (e) {
461
517
  throw new MaskVaultConnectionError(`Memcached error: ${e}`);
462
518
  }
463
519
  }
464
520
 
521
+ async getPtHashForToken(token: string): Promise<string | null> {
522
+ try {
523
+ const { value } = await this._client.get(_vaultHashKey(token));
524
+ return value ? value.toString() : null;
525
+ } catch { return null; }
526
+ }
527
+
465
528
  async getTokenByPlaintextHash(ptHash: string): Promise<string | null> {
466
529
  try {
467
- const { value } = await this._client.get(`mask-rev:${ptHash}`);
530
+ const { value } = await this._client.get(_vaultRevKey(ptHash));
468
531
  if (!value) return null;
469
532
  const token = value.toString();
470
533
  return (await this.retrieve(token)) !== null ? token : null;
@@ -476,8 +539,9 @@ export class MemcachedVault extends BaseVault {
476
539
 
477
540
  async retrieve(token: string): Promise<string | null> {
478
541
  try {
479
- const { value } = await this._client.get(`mask:${token}`);
480
- return value ? value.toString() : null;
542
+ const { value } = await this._client.get(_vaultKey(token));
543
+ if (!value) return null;
544
+ return _unwrapPayload(value.toString());
481
545
  } catch (e) {
482
546
  if (_getFailStrategy() === 'closed') throw new MaskVaultConnectionError(`Memcached read failed: ${e}`);
483
547
  return null;
@@ -486,12 +550,12 @@ export class MemcachedVault extends BaseVault {
486
550
 
487
551
  async delete(token: string): Promise<void> {
488
552
  try {
489
- const { value } = await this._client.get(`mask-hash:${token}`);
553
+ const { value } = await this._client.get(_vaultHashKey(token));
490
554
  const ptHash = value ? value.toString() : null;
491
- await this._client.delete(`mask:${token}`);
492
- await this._client.delete(`mask-hash:${token}`);
555
+ await this._client.delete(_vaultKey(token));
556
+ await this._client.delete(_vaultHashKey(token));
493
557
  if (ptHash) {
494
- await this._client.delete(`mask-rev:${ptHash}`);
558
+ await this._client.delete(_vaultRevKey(ptHash));
495
559
  }
496
560
  } catch (e) {
497
561
  if (_getFailStrategy() === 'closed') throw new MaskVaultConnectionError(`Memcached delete failed: ${e}`);
@@ -539,6 +603,7 @@ export type EncodeOptions = {
539
603
  searchBuckets?: ('year' | 'month' | 'day' | 'numeric')[];
540
604
  searchBucketSize?: number;
541
605
  entityType?: string;
606
+ metadata?: Record<string, string> | null;
542
607
  };
543
608
 
544
609
  /**
@@ -566,16 +631,26 @@ export async function encode(rawText: string, options: EncodeOptions = {}): Prom
566
631
  }
567
632
 
568
633
  // 2. Generate new token
569
- const token = await generateFPEToken(text, options.entityType || 'UNKNOWN');
634
+ const token = await generateDPToken(text, options.entityType || 'UNKNOWN');
570
635
 
571
636
  // 3. Encrypt the plaintext before it touches the vault
572
637
  const ciphertext = cryptoEngine.encrypt(text);
573
638
 
574
- // 4. Store with primary reverse lookup hash always fail-shut to prevent PII leakage
639
+ // 4. Collision Detection refuse to overwrite a different plaintext under the same token
640
+ const existingCiphertext = await vault.retrieve(token);
641
+ if (existingCiphertext !== null) {
642
+ const existingHash = await vault.getPtHashForToken(token);
643
+ if (existingHash && existingHash !== ptHash) {
644
+ throw new TokenCollisionError(token, existingHash, ptHash);
645
+ }
646
+ // Same plaintext re-encoded (hash matches) — safe to proceed/update
647
+ }
648
+
649
+ // 5. Store with primary reverse lookup hash and optional compliance metadata
575
650
  const ttl = options.ttl || DEFAULT_TTL;
576
- await vault.store(token, ciphertext, ttl, ptHash);
651
+ await vault.store(token, ciphertext, ttl, ptHash, options.metadata || null);
577
652
 
578
- // 5. Store additional blind indices if buckets are requested
653
+ // 6. Store additional blind indices if buckets are requested
579
654
  if (options.searchBuckets && options.searchBuckets.length > 0) {
580
655
  for (const bType of options.searchBuckets) {
581
656
  let bucketVal: string;
@@ -586,7 +661,6 @@ export async function encode(rawText: string, options: EncodeOptions = {}): Prom
586
661
  }
587
662
  const bHash = await BucketManager.getBucketIndex(bucketVal);
588
663
  await vault.store(token, ciphertext, ttl, bHash);
589
-
590
664
  }
591
665
  }
592
666