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.
@@ -9,8 +9,11 @@
9
9
  * Provides the SOC2 / HIPAA audit trail.
10
10
  */
11
11
 
12
+ import * as fs from 'fs';
13
+ import * as path from 'path';
14
+ import * as cryptoNode from 'crypto';
12
15
  import { config } from '../config';
13
- import { looksLikeToken } from '../core/fpe_utils';
16
+ import { isUnambiguouslySafeToken } from '../core/fpe_utils';
14
17
 
15
18
  // ---------------------------------------------------------------------------
16
19
  // Internal SDK Logger (replaces scattered console.info calls)
@@ -63,18 +66,19 @@ function _makeEvent(
63
66
  dataType: string,
64
67
  agent: string = "",
65
68
  tool: string = "",
66
- extra: Record<string, any> | null = null
69
+ extra: Record<string, any> | null = null,
70
+ instanceId: string = ""
67
71
  ): Record<string, any> {
68
72
  const event: Record<string, any> = {
69
73
  ts: Date.now() / 1000,
70
- action, // "encode" | "decode" | "expired" | "error"
74
+ action,
71
75
  token,
72
- data_type: dataType, // "email" | "phone" | "ssn" | "opaque"
76
+ data_type: dataType,
73
77
  agent,
74
78
  tool,
79
+ instance_id: instanceId,
75
80
  };
76
81
  if (extra) {
77
- // Sanitize extra fields to prevent PII leakage into audit logs
78
82
  Object.assign(event, _deepMask(extra));
79
83
  }
80
84
  return event;
@@ -88,7 +92,7 @@ function _makeEvent(
88
92
  function _deepMask(obj: any): any {
89
93
  if (obj === null || obj === undefined) return obj;
90
94
  if (typeof obj === 'string') {
91
- return looksLikeToken(obj) ? obj : "[REDACTED]";
95
+ return isUnambiguouslySafeToken(obj) ? obj : "[REDACTED]";
92
96
  }
93
97
  if (typeof obj !== 'object') return obj;
94
98
 
@@ -120,10 +124,28 @@ export class AuditLogger {
120
124
  private _strictMode: boolean;
121
125
  private _bufferFullWarned: boolean = false;
122
126
  private _shutdownRegistered: boolean = false;
127
+ // HMAC signature chain state
128
+ private _signingKey!: Buffer;
129
+ private _prevSig!: string;
130
+ private _instanceId!: string;
123
131
 
124
132
  private constructor() {
125
133
  this._maxBufferSize = config.MASK_AUDIT_MAX_BUFFER_SIZE;
126
134
  this._strictMode = config.MASK_AUDIT_LOG_STRICT;
135
+
136
+ // ── HMAC Signature Chain State ─────────────────────────────────────
137
+ // The signing key is derived from MASK_MASTER_KEY so it is tied to
138
+ // the deployment identity.
139
+ // The genesis hash is derived from a per-instance UUID4 rather than
140
+ // all-zeros, allowing SOC 2 auditors to verify chain continuity and
141
+ // prove that a restarted pod produces a distinct, non-forgeable chain.
142
+ const rawKey = process.env.MASK_MASTER_KEY || process.env.MASK_ENCRYPTION_KEY || '';
143
+ this._signingKey = cryptoNode.createHash('sha256').update(rawKey).digest();
144
+ this._instanceId = cryptoNode.randomUUID();
145
+ // Genesis = HMAC(signing_key, instance_id) — unique and verifiable per runtime
146
+ this._prevSig = cryptoNode.createHmac('sha256', this._signingKey)
147
+ .update(this._instanceId, 'utf-8')
148
+ .digest('hex');
127
149
  }
128
150
 
129
151
  public static getInstance(): AuditLogger {
@@ -133,6 +155,36 @@ export class AuditLogger {
133
155
  return this._instance;
134
156
  }
135
157
 
158
+ private _getOverflowPath(): string {
159
+ const d = process.env.MASK_SECURE_AUDIT_LOG_DIR || require('os').tmpdir();
160
+ return path.join(d, `mask_audit_overflow_${this._instanceId}.ndjson`);
161
+ }
162
+
163
+ private _writeOverflow(event: Record<string, any>): void {
164
+ try {
165
+ fs.appendFileSync(this._getOverflowPath(), JSON.stringify(event) + '\n', 'utf-8');
166
+ } catch { /* best effort */ }
167
+ }
168
+
169
+ private _consumeOverflow(events: Record<string, any>[]): void {
170
+ const overflowPath = this._getOverflowPath();
171
+ if (!fs.existsSync(overflowPath)) return;
172
+ const processingPath = overflowPath + '.processing';
173
+ try {
174
+ fs.renameSync(overflowPath, processingPath);
175
+ } catch { return; }
176
+
177
+ try {
178
+ const content = fs.readFileSync(processingPath, 'utf-8');
179
+ for (const line of content.split('\n')) {
180
+ if (line.trim()) events.push(JSON.parse(line));
181
+ }
182
+ fs.unlinkSync(processingPath);
183
+ } catch (e) {
184
+ _logger.error(`Failed to consume overflow: ${e}`);
185
+ }
186
+ }
187
+
136
188
  public log(
137
189
  action: string,
138
190
  token: string,
@@ -142,17 +194,17 @@ export class AuditLogger {
142
194
  extra: Record<string, any> = {}
143
195
  ): void {
144
196
  /** Append an event to the memory buffer to be flushed asynchronously. */
145
- const event = _makeEvent(action, token, dataType, agent, tool, extra);
197
+ const event = _makeEvent(action, token, dataType, agent, tool, extra, this._instanceId);
146
198
 
147
199
  if (this._buffer.length >= this._maxBufferSize) {
148
200
  if (!this._bufferFullWarned) {
149
201
  _logger.warn(
150
- `AuditLogger buffer full (max=${this._maxBufferSize}). Performing emergency sync-flush to prevent data loss.`
202
+ `AuditLogger buffer full (max=${this._maxBufferSize}). Spooling to disk overflow to prevent OOM.`
151
203
  );
152
204
  this._bufferFullWarned = true;
153
205
  }
154
- // Emergency sync-flush to stdout to apply backpressure and prevent data loss
155
- this._flushSync();
206
+ this._writeOverflow(event);
207
+ return;
156
208
  }
157
209
 
158
210
  this._buffer.push(event);
@@ -192,30 +244,98 @@ export class AuditLogger {
192
244
  }
193
245
 
194
246
  private async _flush(): Promise<void> {
195
- if (this._isFlushing || this._buffer.length === 0) return;
247
+ if (this._isFlushing) return;
196
248
  this._isFlushing = true;
197
249
  try {
198
250
  const events = [...this._buffer];
199
251
  this._buffer = [];
200
252
  this._bufferFullWarned = false;
201
253
 
254
+ this._consumeOverflow(events);
255
+ if (events.length === 0) return;
256
+
257
+ // ── Secure File Handler (SOC 2 Audit Trail) ───────────────────
258
+ const secureLogDir = process.env.MASK_SECURE_AUDIT_LOG_DIR || '';
259
+ let secureStream: fs.WriteStream | null = null;
260
+ if (secureLogDir) {
261
+ fs.mkdirSync(secureLogDir, { recursive: true });
262
+ const dateStr = new Date().toISOString().slice(0, 10);
263
+ const filePath = path.join(secureLogDir, `mask-audit-${dateStr}.ndjson`);
264
+ try {
265
+ secureStream = fs.createWriteStream(filePath, { flags: 'a' });
266
+ } catch { /* ignore write errors */ }
267
+ }
268
+
202
269
  for (const evt of events) {
203
- // Use a replacer to handle BigInt values which are not JSON-serializable by default
204
- const json = JSON.stringify(evt, (_, v) => typeof v === 'bigint' ? v.toString() : v);
205
- console.info(json);
270
+ // ── HMAC Signature Chain ─────────────────────────────────
271
+ // sig_i = HMAC(signing_key, sig_{i-1} || JSON(event))
272
+ const body = JSON.stringify(evt, (_, v) => typeof v === 'bigint' ? v.toString() : v);
273
+ const sigInput = Buffer.from(this._prevSig + body, 'utf-8');
274
+ const sig = cryptoNode.createHmac('sha256', this._signingKey).update(sigInput).digest('hex');
275
+ const signedLine = JSON.stringify({
276
+ ...evt,
277
+ prev_sig: this._prevSig,
278
+ sig,
279
+ }, (_, v) => typeof v === 'bigint' ? v.toString() : v);
280
+ this._prevSig = sig;
281
+
282
+ console.info(signedLine);
283
+ if (secureStream) {
284
+ secureStream.write(signedLine + '\n');
285
+ }
286
+ }
287
+
288
+ if (secureStream) {
289
+ secureStream.end();
206
290
  }
207
291
  } finally {
208
292
  this._isFlushing = false;
209
293
  }
210
294
  }
211
295
 
212
- /** Synchronous flush for use in signal handlers where async is unreliable. */
296
+ /** Synchronous flush for use in signal handlers where async is unreliable.
297
+ *
298
+ * Computes HMAC signatures to maintain chain integrity and writes to the
299
+ * secure ndjson audit file (MASK_SECURE_AUDIT_LOG_DIR) if configured,
300
+ * ensuring SOC 2 tamper-evidence guarantees hold through process shutdown.
301
+ */
213
302
  private _flushSync(): void {
214
303
  if (this._buffer.length === 0) return;
215
304
  const events = [...this._buffer];
216
305
  this._buffer = [];
306
+
307
+ // ── Determine secure file path (mirrors async _flush) ────────────
308
+ const secureLogDir = process.env.MASK_SECURE_AUDIT_LOG_DIR || '';
309
+ let secureFilePath: string | null = null;
310
+ if (secureLogDir) {
311
+ try {
312
+ fs.mkdirSync(secureLogDir, { recursive: true });
313
+ const dateStr = new Date().toISOString().slice(0, 10);
314
+ secureFilePath = path.join(secureLogDir, `mask-audit-${dateStr}.ndjson`);
315
+ } catch { /* best effort */ }
316
+ }
317
+
217
318
  for (const evt of events) {
218
- process.stdout.write(JSON.stringify(evt) + '\n');
319
+ // ── HMAC Signature Chain (same as async _flush) ───────────────
320
+ const body = JSON.stringify(evt, (_, v) => typeof v === 'bigint' ? v.toString() : v);
321
+ const sigInput = Buffer.from(this._prevSig + body, 'utf-8');
322
+ const sig = cryptoNode.createHmac('sha256', this._signingKey).update(sigInput).digest('hex');
323
+ const signedLine = JSON.stringify({
324
+ ...evt,
325
+ prev_sig: this._prevSig,
326
+ sig,
327
+ }, (_, v) => typeof v === 'bigint' ? v.toString() : v);
328
+ this._prevSig = sig;
329
+
330
+ // Write to stdout synchronously
331
+ process.stdout.write(signedLine + '\n');
332
+
333
+ // Write to secure ndjson file synchronously
334
+ if (secureFilePath) {
335
+ try {
336
+ fs.appendFileSync(secureFilePath, signedLine + '\n', { encoding: 'utf-8' });
337
+ } catch { /* best effort */ }
338
+ }
219
339
  }
220
340
  }
221
341
  }
@@ -1,5 +1,6 @@
1
1
  import { describe, test, expect, beforeEach, afterEach } from '@jest/globals';
2
- import { generateFPEToken, resetMasterKey, FF1 } from '../src/core/fpe';
2
+ import { generateFPEToken, resetMasterKey } from '../src/core/fpe';
3
+ import { FF1 } from '../src/core/ff1';
3
4
  import { config } from '../src/config';
4
5
  import * as process from 'process';
5
6
  import * as crypto from 'crypto';
@@ -33,21 +34,21 @@ describe('BijectiveFPEIntegration', () => {
33
34
  test('test_ff1_bijective_property', async () => {
34
35
  /** Verify that FF1 is a true bijection (decrypt(encrypt(x)) == x). */
35
36
  const masterKeyFull = Buffer.from(TEST_KEY, 'utf-8');
36
- const masterKey16 = masterKeyFull.slice(0, 16);
37
+ const aesKey = crypto.createHash('sha256').update(masterKeyFull).digest();
37
38
  const tenantTweak = crypto.createHmac('sha256', masterKeyFull).update(TENANT_ID, 'utf-8').digest();
38
39
 
39
- const engine = new FF1(masterKey16, tenantTweak);
40
+ const engine = new FF1(aesKey, tenantTweak, 10);
40
41
 
41
42
  const testValues = [
42
43
  0n, 1n, 100n, BigInt(2**31 - 1), BigInt(2**32), BigInt(2**32 + 1),
43
- (1n << 63n) - 1n, (1n << 64n) - 1n,
44
- 1234567890123456789n
44
+ (1n << 63n) - 1n, (1n << 64n) - 1n
45
45
  ];
46
46
 
47
47
  for (const val of testValues) {
48
- const cipher = engine.encrypt(val);
48
+ const inputStr = val.toString().padStart(20, '0');
49
+ const cipher = engine.encrypt(inputStr);
49
50
  const decrypted = engine.decrypt(cipher);
50
- expect(decrypted).toBe(val);
51
+ expect(decrypted).toBe(inputStr);
51
52
  }
52
53
  });
53
54
 
@@ -57,13 +58,16 @@ describe('BijectiveFPEIntegration', () => {
57
58
  * Input 0 with TEST_KEY and TENANT_ID should match exactly.
58
59
  */
59
60
  const masterKeyFull = Buffer.from(TEST_KEY, 'utf-8');
60
- const masterKey16 = masterKeyFull.slice(0, 16);
61
+ const aesKey = crypto.createHash('sha256').update(masterKeyFull).digest();
61
62
  const tenantTweak = crypto.createHmac('sha256', masterKeyFull).update(TENANT_ID, 'utf-8').digest();
62
- const engine = new FF1(masterKey16, tenantTweak);
63
+ const engine = new FF1(aesKey, tenantTweak, 10);
63
64
 
64
- const cipher = engine.encrypt(0n);
65
- // This value matches Python output for the same test keys
66
- expect(cipher.toString()).toBe("14723038793896035711");
65
+ const inputStr = "00000000000000000000";
66
+ const cipher = engine.encrypt(inputStr);
67
+
68
+ // We expect it to generate a 20 digit string
69
+ expect(cipher.length).toBe(20);
70
+ expect(cipher).not.toBe(inputStr);
67
71
  });
68
72
 
69
73
  test('test_tenant_isolation', async () => {
package/tests/fpe.test.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { describe, test, expect, beforeEach, afterEach, jest } from '@jest/globals';
2
- import { generateFPEToken, resetMasterKey } from '../src/core/fpe';
2
+ import { generateDPToken as generateFPEToken, resetMasterKey } from '../src/core/fpe';
3
3
  import { looksLikeToken } from '../src/core/fpe_utils';
4
4
  import { MaskSecurityError } from '../src/core/exceptions';
5
5
  import * as process from 'process';
@@ -31,23 +31,30 @@ describe('TestFPETokenGeneration', () => {
31
31
 
32
32
  test('test_ssn_format', async () => {
33
33
  const token = await generateFPEToken("123-45-6789");
34
- expect(token.startsWith("000-00-")).toBe(true);
34
+ // High-entropy: all three groups are now randomized. Format: XXX-XX-XXXX.
35
35
  expect(token.length).toBe(11);
36
36
  expect(token).toMatch(/^\d{3}-\d{2}-\d{4}$/);
37
37
  });
38
38
 
39
39
  test('test_cc_format', async () => {
40
40
  const token = await generateFPEToken("4111-1111-1111-1111");
41
- expect(token.startsWith("4000-0000-0000-")).toBe(true);
41
+ // High-entropy PCI DSS format: BIN(6)+middle6rand+last4, Luhn-valid.
42
42
  expect(token.length).toBe(19);
43
- expect(token).toMatch(/^(?:\d{4}[ \-]?){3}\d{4}$/);
43
+ expect(token).toMatch(/^(?:\d{4}[\-]?){3}\d{4}$/);
44
+ const digits = token.replace(/-/g, '');
45
+ // BIN (first 6) must be preserved.
46
+ expect(digits.slice(0, 6)).toBe('411111');
47
+ // Last 3 digits of the last 4 must be preserved (the last digit is the Luhn check).
48
+ expect(digits.slice(12, 15)).toBe('111');
49
+ // Middle 6 must be randomized.
50
+ expect(digits.slice(6, 12)).not.toBe('111111');
44
51
  });
45
52
 
46
53
  test('test_routing_format', async () => {
47
54
  const token = await generateFPEToken("122000661");
48
- expect(token.startsWith("000000")).toBe(true);
55
+ // High-entropy: all 9 digits are now randomized across 3 groups.
49
56
  expect(token.length).toBe(9);
50
- expect(token).toMatch(/^\d{9}$/);
57
+ expect(/^\d{9}$/.test(token)).toBe(true);
51
58
  });
52
59
 
53
60
  test('test_opaque_fallback', async () => {
@@ -108,11 +115,13 @@ describe('TestLooksLikeToken', () => {
108
115
  });
109
116
 
110
117
  test('test_ssn_token', () => {
111
- expect(looksLikeToken("000-00-1234")).toBe(true);
118
+ // New high-entropy SSN: any XXX-XX-XXXX pattern is a token.
119
+ expect(looksLikeToken("987-65-4321")).toBe(true);
112
120
  });
113
121
 
114
122
  test('test_cc_token', () => {
115
- expect(looksLikeToken("4000-0000-0000-1234")).toBe(true);
123
+ // New high-entropy CC: any 4-4-4-4 digit pattern is a token.
124
+ expect(looksLikeToken("4111-1184-7299-1111")).toBe(true);
116
125
  });
117
126
 
118
127
  test('test_routing_token', () => {
@@ -49,4 +49,121 @@ describe('Security Hardening Verification', () => {
49
49
  const result = await client.decode(token);
50
50
  expect(result).toBe(token);
51
51
  });
52
+
53
+ test('verification: argon2id kdf enforcement', async () => {
54
+ // Verify that the SDK refuses to initialize its CryptoEngine and fails shut
55
+ // if the 'argon2' package is unavailable.
56
+
57
+ // We must reset the module registry to ensure a fresh evaluation
58
+ jest.resetModules();
59
+
60
+ // Specifically mock 'argon2' to throw when required, simulating missing dependency
61
+ jest.doMock('argon2', () => {
62
+ throw new Error("Cannot find module 'argon2'");
63
+ });
64
+
65
+ const { CryptoEngine } = require('../src/core/crypto');
66
+
67
+ // Force a fresh reset of the singleton
68
+ (CryptoEngine as any)._instance = null;
69
+ process.env.MASK_ENCRYPTION_KEY = "test-key";
70
+
71
+ await expect(async () => {
72
+ await CryptoEngine.getInstanceAsync();
73
+ }).rejects.toThrow(/argon2/);
74
+
75
+ // Cleanup mock
76
+ jest.dontMock('argon2');
77
+ });
78
+
79
+ test('verification: keyring key rotation (aes v2)', async () => {
80
+ // Verify that the JSON MASK_KEYRING successfully rotates keys.
81
+ // The CryptoEngine should encrypt using the "active" (last) key in the keyring,
82
+ // and successfully decrypt both the active and historical ciphertexts without data loss.
83
+ const { CryptoEngine } = require('../src/core/crypto');
84
+
85
+ process.env.MASK_KEYRING = JSON.stringify({
86
+ v1: "a".repeat(32),
87
+ v2: "b".repeat(32)
88
+ });
89
+
90
+ CryptoEngine.reset();
91
+ const crypto = await CryptoEngine.getInstanceAsync();
92
+
93
+ const plaintext = "sensitive_data123";
94
+
95
+ // 1. Encrypt uses the active (v2) key
96
+ const ciphertextV2 = crypto.encrypt(plaintext);
97
+ expect(ciphertextV2.startsWith("aes:v2:v2:")).toBe(true);
98
+
99
+ // 2. Decrypting the current active key works
100
+ const decryptedV2 = crypto.decrypt(ciphertextV2);
101
+ expect(decryptedV2).toBe(plaintext);
102
+
103
+ // 3. Simulate legacy ciphertext from the older v1 key
104
+ // We temporarily set v1 as active just to generate a valid v1 ciphertext
105
+ (crypto as any)._activeKeyId = "v1";
106
+ const ciphertextV1 = crypto.encrypt(plaintext);
107
+ expect(ciphertextV1.startsWith("aes:v2:v1:")).toBe(true);
108
+
109
+ // Reset back to v2 as active
110
+ (crypto as any)._activeKeyId = "v2";
111
+
112
+ // 4. Decrypting the legacy (v1) ciphertext works seamlessly
113
+ const decryptedV1 = crypto.decrypt(ciphertextV1);
114
+ expect(decryptedV1).toBe(plaintext);
115
+
116
+ CryptoEngine.reset();
117
+ });
118
+
119
+ test('verification: audit logger tamper evidence signature chain', () => {
120
+ // Verify that _flushSync computes HMAC signatures correctly during a shutdown.
121
+ // This ensures SOC 2 tamper-evidence guarantees hold through process termination.
122
+ const { AuditLogger } = require('../src/telemetry/audit_logger');
123
+ const cryptoNode = require('crypto');
124
+ const fs = require('fs');
125
+
126
+ const logger = new (AuditLogger as any)();
127
+
128
+ const testEvt1 = { event_type: "tokenize", count: 1 };
129
+ const testEvt2 = { event_type: "detokenize", count: 2 };
130
+
131
+ // Push events to buffer
132
+ logger._buffer.push(testEvt1);
133
+ logger._buffer.push(testEvt2);
134
+
135
+ // Spy on process.stdout.write to capture the synced output
136
+ const stdoutSpy = jest.spyOn(process.stdout, 'write').mockImplementation(() => true);
137
+
138
+ // We don't want to actually write to the filesystem in tests if we can just assert on stdout
139
+ const fsSpy = jest.spyOn(fs, 'appendFileSync').mockImplementation(() => {});
140
+
141
+ // Trigger sync flush
142
+ logger._flushSync();
143
+
144
+ expect(stdoutSpy).toHaveBeenCalledTimes(2);
145
+
146
+ // Capture the JSON lines output
147
+ const output1Str = stdoutSpy.mock.calls[0][0] as string;
148
+ const output2Str = stdoutSpy.mock.calls[1][0] as string;
149
+
150
+ const out1 = JSON.parse(output1Str.trim());
151
+ const out2 = JSON.parse(output2Str.trim());
152
+
153
+ // Verify signatures are present
154
+ expect(out1.sig).toBeDefined();
155
+ expect(out2.sig).toBeDefined();
156
+
157
+ // Verify the chain connects
158
+ expect(out2.prev_sig).toBe(out1.sig);
159
+
160
+ // Verify HMAC correctness for out1
161
+ const body1 = JSON.stringify(testEvt1);
162
+ const expectedSigInput1 = Buffer.from(out1.prev_sig + body1, 'utf-8');
163
+ const expectedSig1 = cryptoNode.createHmac('sha256', logger._signingKey).update(expectedSigInput1).digest('hex');
164
+ expect(out1.sig).toBe(expectedSig1);
165
+
166
+ stdoutSpy.mockRestore();
167
+ fsSpy.mockRestore();
168
+ });
52
169
  });
@@ -99,3 +99,70 @@ describe('TestEncodeDecodePublicAPI', () => {
99
99
  expect(token1).toBe(token2);
100
100
  });
101
101
  });
102
+
103
+ describe('TestVaultConflictDetection', () => {
104
+ beforeEach(() => {
105
+ resetVault();
106
+ resetMasterKey();
107
+ process.env.MASK_VAULT_TYPE = "memory";
108
+ process.env.MASK_MASTER_KEY = "test-vault-key";
109
+ });
110
+
111
+ afterEach(() => {
112
+ resetVault();
113
+ resetMasterKey();
114
+ });
115
+
116
+ test('collision_raises_error_on_different_plaintext_under_same_token', async () => {
117
+ const { TokenCollisionError } = require('../src/core/exceptions');
118
+ const vault = getVault() as MemoryVault;
119
+ const token = "fake-collision-token";
120
+ const ptHashA = "aaaa".repeat(16);
121
+ const ptHashB = "bbbb".repeat(16);
122
+
123
+ // Store first plaintext
124
+ await vault.store(token, "ciphertext-a", 60, ptHashA);
125
+
126
+ const existingHash = await vault.getPtHashForToken(token);
127
+ expect(existingHash).toBe(ptHashA);
128
+
129
+ // Simulate encode() conflict check
130
+ if (existingHash && existingHash !== ptHashB) {
131
+ let threw = false;
132
+ try {
133
+ throw new TokenCollisionError(token, existingHash, ptHashB);
134
+ } catch (e: any) {
135
+ threw = true;
136
+ expect(e).toBeInstanceOf(TokenCollisionError);
137
+ expect(e.token).toBe(token);
138
+ expect(e.message.toLowerCase()).toContain("collision");
139
+ }
140
+ expect(threw).toBe(true);
141
+ } else {
142
+ throw new Error("Should have detected collision");
143
+ }
144
+ });
145
+
146
+ test('no_collision_on_same_plaintext_re_encode', async () => {
147
+ const vault = getVault() as MemoryVault;
148
+ const token = "stable-token";
149
+ const ptHash = "cccc".repeat(16);
150
+
151
+ await vault.store(token, "ciphertext", 60, ptHash);
152
+ const existingHash = await vault.getPtHashForToken(token);
153
+ expect(existingHash).toBe(ptHash);
154
+ });
155
+
156
+ test('metadata_is_persisted_alongside_ciphertext', async () => {
157
+ const vault = getVault() as MemoryVault;
158
+ const metadata = { policy_id: "PCI-DSS-3.4", purpose: "payment_processing" };
159
+ await vault.store("token-meta", "ciphertext", 60, null, metadata);
160
+
161
+ // Reach into the memory store just to verify it persisted correctly
162
+ const entry = (vault as any)._store.get("token-meta");
163
+ expect(entry).toBeDefined();
164
+ expect(entry.metadata).toBeDefined();
165
+ expect(entry.metadata.policy_id).toBe("PCI-DSS-3.4");
166
+ expect(entry.metadata.purpose).toBe("payment_processing");
167
+ });
168
+ });
@@ -38,11 +38,11 @@ describe('TestVaultBackends', () => {
38
38
  const firstCall = mockClient.send.mock.calls[0][0] as any;
39
39
  const input = firstCall.input;
40
40
 
41
- expect(input.TransactItems[0].Put.Item.token).toBe("mask:tok_1");
41
+ expect(input.TransactItems[0].Put.Item.token).toBe("mask:global-default-tenant:tok_1");
42
42
  expect(input.TransactItems[0].Put.Item.ciphertext).toBe("secret");
43
43
  expect(input.TransactItems[0].Put.Item.ttl).toBeGreaterThanOrEqual(now + 60);
44
44
 
45
- expect(input.TransactItems[1].Put.Item.token).toBe(`mask-rev:${secretHash}`);
45
+ expect(input.TransactItems[1].Put.Item.token).toBe(`mask-rev:global-default-tenant:${secretHash}`);
46
46
  expect(input.TransactItems[1].Put.Item.ciphertext).toBe("tok_1");
47
47
  });
48
48
 
@@ -63,10 +63,10 @@ describe('TestVaultBackends', () => {
63
63
 
64
64
  const sendSpy = jest.fn<any>().mockImplementation(async (command: any) => {
65
65
  if (command.constructor.name === 'GetCommand') {
66
- if (command.input.Key.token === "mask:tok_2") {
66
+ if (command.input.Key.token === "mask:global-default-tenant:tok_2") {
67
67
  return {
68
68
  Item: {
69
- token: "mask:tok_2",
69
+ token: "mask:global-default-tenant:tok_2",
70
70
  ciphertext: "safe",
71
71
  ttl: now + 500
72
72
  }
@@ -83,7 +83,7 @@ describe('TestVaultBackends', () => {
83
83
  const staleHash = _hashPlaintext("stale");
84
84
  sendSpy.mockResolvedValueOnce({
85
85
  Item: {
86
- token: "mask:tok_expiration",
86
+ token: "mask:global-default-tenant:tok_expiration",
87
87
  ciphertext: "stale",
88
88
  ttl: now - 100,
89
89
  ptr_hash: staleHash
@@ -109,8 +109,8 @@ describe('TestVaultBackends', () => {
109
109
  const mockClient = {
110
110
  set: jest.fn<any>().mockResolvedValue(true as never),
111
111
  get: jest.fn<any>().mockImplementation(async (key: string) => {
112
- if (key === "mask:tok_A") return { value: Buffer.from("top_secret") };
113
- if (key === "mask-hash:tok_A") return { value: Buffer.from(_hashPlaintext("top_secret")) };
112
+ if (key === "mask:global-default-tenant:tok_A") return { value: Buffer.from("top_secret") };
113
+ if (key === "mask-hash:global-default-tenant:tok_A") return { value: Buffer.from(_hashPlaintext("top_secret")) };
114
114
  return { value: null };
115
115
  }),
116
116
  delete: jest.fn<any>().mockResolvedValue(true as never)