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
|
@@ -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 {
|
|
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,
|
|
74
|
+
action,
|
|
71
75
|
token,
|
|
72
|
-
data_type: dataType,
|
|
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
|
|
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}).
|
|
202
|
+
`AuditLogger buffer full (max=${this._maxBufferSize}). Spooling to disk overflow to prevent OOM.`
|
|
151
203
|
);
|
|
152
204
|
this._bufferFullWarned = true;
|
|
153
205
|
}
|
|
154
|
-
|
|
155
|
-
|
|
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
|
|
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
|
-
//
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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(
|
|
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
|
|
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(
|
|
63
|
+
const engine = new FF1(aesKey, tenantTweak, 10);
|
|
63
64
|
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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}[
|
|
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
|
-
|
|
55
|
+
// High-entropy: all 9 digits are now randomized across 3 groups.
|
|
49
56
|
expect(token.length).toBe(9);
|
|
50
|
-
expect(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
});
|
package/tests/vault.test.ts
CHANGED
|
@@ -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)
|