@abhishekvardanbotta/mask 0.1.0 → 0.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/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  Keep personal data out of LLM prompts, tool calls, memory and logs, and still get it back where it is allowed.
4
4
 
5
5
  `mask` finds personal data in structured and unstructured values. It replaces each value with a reversible
6
- token such as `[EMAIL_1]`, keeps the original encrypted in a vault, and restores it only for principals
6
+ token such as `ss1_njpskmng`, keeps the original encrypted in a vault, and restores it only for principals
7
7
  granted that kind of data. It has zero runtime dependencies and uses only `node:crypto` and `node:net`.
8
8
 
9
9
  It is the engine behind `privacy: { enabled: true }` in [kiln](https://www.npmjs.com/package/@abhishekvardanbotta/kiln), and works on its own with any LLM stack.
@@ -19,7 +19,7 @@ const privacy = createPrivacy({ key: process.env.KILN_PRIVACY_KEY }); // 32-by
19
19
  const session = privacy.session("conversation-123");
20
20
 
21
21
  const prompt = session.protectSync("I'm Priya Sharma, priya@example.com, card 4111 1111 1111 1111, cvv 123");
22
- // "I'm [PERSON_1], [EMAIL_1], card [CREDIT_CARD_1], cvv [BLOCKED:CARD_SECURITY_CODE]"
22
+ // "I'm ss1_ubjboike, ss1_njpskmng, card ss1_efecfvfz, cvv [BLOCKED:CARD_SECURITY_CODE]" (letters vary with the key)
23
23
 
24
24
  const reply = await callYourModel(prompt); // the model only ever sees placeholders
25
25
  session.restore(reply, { allow: ["PII"] }); // email and name restored; the card stays [REDACTED:CREDIT_CARD]
@@ -80,11 +80,11 @@ createPrivacy({ detectors: [{ name: "ner", detect: async (text) => (await ner(te
80
80
 
81
81
  ## Tokens, the vault and keys
82
82
 
83
- - **Tokens** are `[TYPE_<id>]`. The same value always gets the same token within a scope (looked up by an HMAC-SHA256 fingerprint, RFC 2104), so a model can still tell "the same customer wrote twice". `tokenStyle` picks the id:
84
- - `"sequential"` (default): `[EMAIL_1]`, `[EMAIL_2]`, numbered per type within the scope. Small models copy these more reliably. The number reveals nothing about the value, but ids are guessable within a scope.
85
- - `"opaque"`: `[EMAIL_3f9a1c0b7e]`, 40 bits of the keyed HMAC. Unguessable, and the same value gives different tokens in different scopes or under different keys.
86
- - **Vault records** are encrypted with AES-256-GCM (NIST SP 800-38D). Each record has a random 96-bit IV and is bound to its scope and token via AAD, so records cannot be swapped. A `VaultStore` (in-memory by default, or your Redis or SQL implementation) only ever holds ciphertext and keyed fingerprints.
87
- - **Keys**: one 256-bit master key, split with HKDF-SHA256 (RFC 5869) into an encryption key and a token key. Generate one with `generateKey()`. Without a key, a random per-process key is used, which is right for an in-memory vault.
83
+ - **Tokens.** The same value always gets the same token within a scope (looked up by an HMAC-SHA256 fingerprint, RFC 2104), so a model can still tell "the same customer wrote twice" and analytics on masked data still join and group correctly. Different scopes, or different keys, give unrelated tokens. `tokenStyle` picks the format:
84
+ - `"random"` (default): `ss1_njpskmng`, 8 letters from the keyed HMAC. Reveals nothing, not even what kind of value it replaced, and can't be guessed.
85
+ - `"sequential"`: `[EMAIL_1]`, `[PERSON_2]`, numbered per type within the scope. The type is visible and ids are guessable within a scope, but small models copy these most reliably.
86
+ - `"opaque"`: `[EMAIL_3f9a1c0b7e]`, the type plus 40 bits of the keyed HMAC.
87
+ - **Numbers** in JSON stay numbers (amounts, salaries, ages, counts), so aggregates are unchanged. A number is masked only when it is an identifier: in a classified field such as `ssn`, or a long integer that validates as a card or phone number. It restores as a number.
88
88
  - **Retention**: `destroy(scope)` deletes a scope; its tokens, wherever they were logged, become permanently opaque. Records also carry `expiresAt` (`retentionMs`, default one hour).
89
89
 
90
90
  ```ts
package/dist/index.d.ts CHANGED
@@ -7,6 +7,8 @@ export type { Recognizer } from "./detectors/recognizers.js";
7
7
  export { aadhaarValid, cardBrand, emailValid, ibanValid, luhnValid, shannonEntropy, ssnValid, verhoeffValid } from "./detectors/validators.js";
8
8
  export { inferFieldType } from "./fields.js";
9
9
  export { generateKey } from "./crypto.js";
10
+ export { renderToken, TOKEN_REFERENCE } from "./tokens.js";
11
+ export type { TokenStyle } from "./tokens.js";
10
12
  export { InMemoryVaultStore } from "./vault.js";
11
13
  export type { VaultRecord, VaultStore } from "./vault.js";
12
14
  export { PrivacyError } from "./types.js";
package/dist/index.js CHANGED
@@ -4,5 +4,6 @@ export { BUILT_IN_RECOGNIZERS } from "./detectors/recognizers.js";
4
4
  export { aadhaarValid, cardBrand, emailValid, ibanValid, luhnValid, shannonEntropy, ssnValid, verhoeffValid } from "./detectors/validators.js";
5
5
  export { inferFieldType } from "./fields.js";
6
6
  export { generateKey } from "./crypto.js";
7
+ export { renderToken, TOKEN_REFERENCE } from "./tokens.js";
7
8
  export { InMemoryVaultStore } from "./vault.js";
8
9
  export { PrivacyError } from "./types.js";
package/dist/policy.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { BUILT_IN_RECOGNIZERS } from "./detectors/recognizers.js";
2
2
  import { compileFieldRules, isClassification } from "./fields.js";
3
3
  import { PrivacyError } from "./types.js";
4
+ import { renderToken } from "./tokens.js";
4
5
  /**
5
6
  * Default handling per entity type, following NIST SP 800-122's confidentiality-impact guidance, GDPR Art. 9
6
7
  * special categories, and PCI DSS v4.0:
@@ -109,7 +110,7 @@ export function maskValue(type, value) {
109
110
  }
110
111
  export function deniedReplacement(mode, type, value, token) {
111
112
  if (mode === "token")
112
- return `[${token}]`;
113
+ return renderToken(token);
113
114
  if (mode === "mask")
114
115
  return maskValue(type, value);
115
116
  return `[REDACTED:${type}]`;
package/dist/privacy.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { type CompiledPolicy } from "./policy.js";
2
2
  import { type AuditEvent, type Detection, type EntityType, type PolicyConfig, type PrivacySummary, type ProtectOptions, type RestoreOptions } from "./types.js";
3
3
  import { type VaultStore } from "./vault.js";
4
+ import { type TokenStyle } from "./tokens.js";
4
5
  export interface PrivacyOptions extends PolicyConfig {
5
6
  /**
6
7
  * 256-bit master key (32 bytes, or base64 of 32 bytes). Omitted, a random key is generated per process: tokens then
@@ -13,13 +14,14 @@ export interface PrivacyOptions extends PolicyConfig {
13
14
  /** How long an idle scope stays restorable, in ms. Default one hour. Records get a matching `expiresAt`. */
14
15
  retentionMs?: number;
15
16
  /**
16
- * How token ids look.
17
- * - `"sequential"` (default): `[EMAIL_1]`, `[PERSON_2]` — numbered per type within the scope. Small models copy
18
- * these reliably; with random ids they were measured to splice one token's id onto another's type. The number says
19
- * nothing about the value, and scopes stay unlinkable, but ids are guessable within a scope.
20
- * - `"opaque"`: `[EMAIL_3f9a1c0b7e]` — 40 bits of the keyed HMAC, so a model can only reference tokens it was shown.
17
+ * How tokens look.
18
+ * - `"random"` (default): `ss1_kqfmzxat` — 8 letters from the keyed HMAC. Reveals nothing, not even what kind of value
19
+ * it replaced, and is unguessable. The same value still gets the same token within a scope.
20
+ * - `"sequential"`: `[EMAIL_1]`, `[PERSON_2]` — type plus a per-scope counter. Small models copy these most reliably,
21
+ * but the type is visible and ids are guessable within a scope.
22
+ * - `"opaque"`: `[EMAIL_3f9a1c0b7e]` — type plus 40 bits of the keyed HMAC.
21
23
  */
22
- tokenStyle?: "sequential" | "opaque";
24
+ tokenStyle?: TokenStyle;
23
25
  }
24
26
  interface Entry {
25
27
  token: string;
package/dist/privacy.js CHANGED
@@ -4,11 +4,9 @@ import { asNode, inferFieldType, itemSchema, matchFieldRule, nodeAnnotation, pro
4
4
  import { compilePolicy, deniedReplacement, isGranted, mergePolicyConfig } from "./policy.js";
5
5
  import { PrivacyError, } from "./types.js";
6
6
  import { InMemoryVaultStore } from "./vault.js";
7
- /** The token marker the model sees: `[EMAIL_1]` (sequential) or `[EMAIL_3f9a1c0b7e]` (opaque). */
7
+ import { randomToken, renderToken, TOKEN_MARKER, TOKEN_REFERENCE } from "./tokens.js";
8
8
  const TOKEN_ID_LENGTH = 10;
9
- const TOKEN_MARKER = /\[(?:[A-Z][A-Z0-9_]*_(?:[0-9a-f]{10}|[1-9]\d{0,5})|BLOCKED:[A-Z][A-Z0-9_]*|REDACTED:[A-Z][A-Z0-9_]*)\]/g;
10
9
  /** Strict `[TYPE_id]` plus a bare `TYPE_id` (models sometimes drop the brackets); only tokens in this scope's vault are ever resolved. */
11
- const TOKEN_REFERENCE = /(?<![A-Za-z0-9_])\[?([A-Z][A-Z0-9_]*_(?:[0-9a-f]{10}|[1-9]\d{0,5}))\]?(?![A-Za-z0-9_])/g;
12
10
  const MAX_AUDIT_EVENTS = 5000;
13
11
  const MAX_MEMO_ENTRIES = 2000;
14
12
  /** Everything one scope knows: shared by every policy view of that scope. */
@@ -105,9 +103,9 @@ export class Privacy {
105
103
  this.keys = deriveKeys(normalizeMasterKey(key));
106
104
  this.store = store ?? new InMemoryVaultStore();
107
105
  this.retentionMs = retentionMs ?? 60 * 60 * 1000;
108
- if (tokenStyle !== undefined && tokenStyle !== "sequential" && tokenStyle !== "opaque")
109
- throw new PrivacyError(`mask: tokenStyle must be "sequential" or "opaque".`, "config");
110
- this.tokenStyle = tokenStyle ?? "sequential";
106
+ if (tokenStyle !== undefined && tokenStyle !== "random" && tokenStyle !== "sequential" && tokenStyle !== "opaque")
107
+ throw new PrivacyError(`mask: tokenStyle must be "random", "sequential" or "opaque".`, "config");
108
+ this.tokenStyle = tokenStyle ?? "random";
111
109
  this.baseConfig = policy;
112
110
  this.basePolicy = compilePolicy(policy);
113
111
  }
@@ -169,7 +167,13 @@ export class Privacy {
169
167
  return existing;
170
168
  }
171
169
  let token;
172
- if (this.tokenStyle === "sequential") {
170
+ if (this.tokenStyle === "random") {
171
+ let attempt = 0;
172
+ token = randomToken(fp, attempt);
173
+ while (state.byToken.has(token))
174
+ token = randomToken(fp, ++attempt);
175
+ }
176
+ else if (this.tokenStyle === "sequential") {
173
177
  let n = state.sequence.get(type) ?? 0;
174
178
  do {
175
179
  n += 1;
@@ -462,7 +466,7 @@ export class PrivacySession {
462
466
  return kind === "number" ? Number(value) : value;
463
467
  if (fieldClass.classification === "BLOCK")
464
468
  return this.block(type);
465
- return `[${this.engine.tokenFor(this.state, type, value, fieldClass.classification, kind)}]`;
469
+ return renderToken(this.engine.tokenFor(this.state, type, value, fieldClass.classification, kind));
466
470
  }
467
471
  block(type) {
468
472
  if (this.policy.onBlock === "throw")
@@ -486,10 +490,10 @@ export class PrivacySession {
486
490
  output += text.slice(cursor, d.start);
487
491
  const original = text.slice(d.start, d.end);
488
492
  if (d.token)
489
- output += `[${d.token}]`;
493
+ output += renderToken(d.token);
490
494
  else {
491
495
  const classification = this.policy.classify(d.type);
492
- output += classification === "BLOCK" ? this.block(d.type) : `[${this.engine.tokenFor(this.state, d.type, original, classification, "string")}]`;
496
+ output += classification === "BLOCK" ? this.block(d.type) : renderToken(this.engine.tokenFor(this.state, d.type, original, classification, "string"));
493
497
  }
494
498
  cursor = d.end;
495
499
  }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * How tokens look, in one place. Three styles:
3
+ * random (default) ss1_kqfmzxat — keyed-HMAC letters, no type shown; written bare
4
+ * sequential [EMAIL_1] — type plus a per-scope counter; written in brackets
5
+ * opaque [EMAIL_3f9a1c0b7e] — type plus keyed-HMAC hex; written in brackets
6
+ */
7
+ export type TokenStyle = "random" | "sequential" | "opaque";
8
+ export declare const RANDOM_PREFIX = "ss1_";
9
+ export declare const RANDOM_ID_LENGTH = 8;
10
+ /** Anything already masked: a token in any style, or a BLOCKED/REDACTED marker. Detection never touches these spans. */
11
+ export declare const TOKEN_MARKER: RegExp;
12
+ /**
13
+ * A token reference to restore. Brackets are optional on both forms, since models add or drop them; capture group 1 is
14
+ * the bare token. Only tokens present in the scope's vault are ever resolved, so look-alikes stay untouched.
15
+ */
16
+ export declare const TOKEN_REFERENCE: RegExp;
17
+ /** How a token is written into text: random tokens bare, typed tokens in brackets. */
18
+ export declare function renderToken(token: string): string;
19
+ /** `ss1_` + 8 letters derived from a hex fingerprint; `attempt` shifts the window to resolve the (rare) collision. */
20
+ export declare function randomToken(fingerprintHex: string, attempt: number): string;
package/dist/tokens.js ADDED
@@ -0,0 +1,25 @@
1
+ export const RANDOM_PREFIX = "ss1_";
2
+ export const RANDOM_ID_LENGTH = 8;
3
+ const RANDOM_ALPHABET = "abcdefghijklmnopqrstuvwxyz";
4
+ const TYPED = String.raw `[A-Z][A-Z0-9_]*_(?:[0-9a-f]{10}|[1-9]\d{0,5})`;
5
+ const RANDOM = String.raw `ss1_[a-z]{8}`;
6
+ /** Anything already masked: a token in any style, or a BLOCKED/REDACTED marker. Detection never touches these spans. */
7
+ export const TOKEN_MARKER = new RegExp(String.raw `\[(?:${TYPED}|BLOCKED:[A-Z][A-Z0-9_]*|REDACTED:[A-Z][A-Z0-9_]*)\]|(?<![A-Za-z0-9_])\[?${RANDOM}\]?(?![A-Za-z0-9_])`, "g");
8
+ /**
9
+ * A token reference to restore. Brackets are optional on both forms, since models add or drop them; capture group 1 is
10
+ * the bare token. Only tokens present in the scope's vault are ever resolved, so look-alikes stay untouched.
11
+ */
12
+ export const TOKEN_REFERENCE = new RegExp(String.raw `(?<![A-Za-z0-9_])\[?(${TYPED}|${RANDOM})\]?(?![A-Za-z0-9_])`, "g");
13
+ /** How a token is written into text: random tokens bare, typed tokens in brackets. */
14
+ export function renderToken(token) {
15
+ return token.startsWith(RANDOM_PREFIX) ? token : `[${token}]`;
16
+ }
17
+ /** `ss1_` + 8 letters derived from a hex fingerprint; `attempt` shifts the window to resolve the (rare) collision. */
18
+ export function randomToken(fingerprintHex, attempt) {
19
+ let id = "";
20
+ for (let i = 0; i < RANDOM_ID_LENGTH; i++) {
21
+ const at = (attempt * 2 + i * 2) % (fingerprintHex.length - 1);
22
+ id += RANDOM_ALPHABET[parseInt(fingerprintHex.slice(at, at + 2), 16) % RANDOM_ALPHABET.length];
23
+ }
24
+ return RANDOM_PREFIX + id;
25
+ }
package/package.json CHANGED
@@ -1,52 +1,52 @@
1
- {
2
- "name": "@abhishekvardanbotta/mask",
3
- "version": "0.1.0",
4
- "description": "Schema-aware PII classification, validated detection, reversible vault tokenization and authorization-checked restoration for LLM applications. Zero runtime dependencies.",
5
- "type": "module",
6
- "license": "MIT",
7
- "author": "Abhishek Vardan Botta",
8
- "main": "./dist/index.js",
9
- "types": "./dist/index.d.ts",
10
- "exports": {
11
- ".": {
12
- "types": "./dist/index.d.ts",
13
- "default": "./dist/index.js"
14
- }
15
- },
16
- "files": [
17
- "dist",
18
- "README.md",
19
- "LICENSE"
20
- ],
21
- "publishConfig": {
22
- "access": "public"
23
- },
24
- "keywords": [
25
- "pii",
26
- "privacy",
27
- "tokenization",
28
- "pseudonymization",
29
- "redaction",
30
- "llm",
31
- "ai",
32
- "agents",
33
- "kiln",
34
- "gdpr",
35
- "pci"
36
- ],
37
- "engines": {
38
- "node": ">=20.3"
39
- },
40
- "scripts": {
41
- "clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"",
42
- "build": "npm run clean && tsc -p tsconfig.json",
43
- "test": "tsx --test tests/*.test.ts",
44
- "typecheck": "tsc -p tsconfig.json --noEmit",
45
- "prepublishOnly": "npm run build && npm test"
46
- },
47
- "devDependencies": {
48
- "@types/node": "^22.13.5",
49
- "tsx": "^4.19.3",
50
- "typescript": "^5.7.3"
51
- }
52
- }
1
+ {
2
+ "name": "@abhishekvardanbotta/mask",
3
+ "version": "0.2.0",
4
+ "description": "Schema-aware PII classification, validated detection, reversible vault tokenization and authorization-checked restoration for LLM applications. Zero runtime dependencies.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Abhishek Vardan Botta",
8
+ "main": "./dist/index.js",
9
+ "types": "./dist/index.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "default": "./dist/index.js"
14
+ }
15
+ },
16
+ "files": [
17
+ "dist",
18
+ "README.md",
19
+ "LICENSE"
20
+ ],
21
+ "publishConfig": {
22
+ "access": "public"
23
+ },
24
+ "keywords": [
25
+ "pii",
26
+ "privacy",
27
+ "tokenization",
28
+ "pseudonymization",
29
+ "redaction",
30
+ "llm",
31
+ "ai",
32
+ "agents",
33
+ "kiln",
34
+ "gdpr",
35
+ "pci"
36
+ ],
37
+ "engines": {
38
+ "node": ">=20.3"
39
+ },
40
+ "scripts": {
41
+ "clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"",
42
+ "build": "npm run clean && tsc -p tsconfig.json",
43
+ "test": "tsx --test tests/*.test.ts",
44
+ "typecheck": "tsc -p tsconfig.json --noEmit",
45
+ "prepublishOnly": "npm run build && npm test"
46
+ },
47
+ "devDependencies": {
48
+ "@types/node": "^22.13.5",
49
+ "tsx": "^4.19.3",
50
+ "typescript": "^5.7.3"
51
+ }
52
+ }