hazo_secure 1.4.0 → 1.5.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/CHANGE_LOG.md CHANGED
@@ -1,5 +1,38 @@
1
1
  # Change Log
2
2
 
3
+ ## 1.5.0 — 2026-07-21
4
+
5
+ ### New: `hazo_secure/mask` — `maskTFN` / `maskABN` display masks
6
+
7
+ - **`maskTFN(tfn)`** — masks an Australian Tax File Number for display,
8
+ revealing only the last 3 digits (`"***-***-789"`). Strips whitespace
9
+ before masking so both `"123456789"` and `"123 456 789"` normalise the
10
+ same way. Returns `"***"` for inputs too short to safely reveal 3 digits,
11
+ `""` for null/undefined/empty.
12
+ - **`maskABN(abn)`** — same shape for Australian Business Numbers
13
+ (`"**-***-***-901"`).
14
+ - Deliberately not added to `BUILTIN_TRANSFORMS` — these take no `maskKey`
15
+ and aren't deterministic-by-key like the existing DB-anonymisation
16
+ transforms; they're pure display formatting. Ported from Hazodocs
17
+ `lib/formatting.ts` (`maskTFN`/`maskABN`, previously app-local per D-011).
18
+
19
+ ### New: `hazo_secure/crypto` — `randomCode` / `randomToken` / `hashToken` / `verifyToken`
20
+
21
+ - **`randomCode(length = 16)`** — cryptographically secure code from a
22
+ human-readable alphabet (`[A-Z2-9]`, excludes ambiguous `0`/`O`/`1`/`I`/`L`).
23
+ Throws for `length < 8` (inadequate entropy).
24
+ - **`randomToken(bytes = 32)`** — cryptographically secure base64url token
25
+ (43 chars at the default byte length).
26
+ - **`hashToken(raw)`** — scrypt-hashes a raw token under a random 16-byte
27
+ salt for at-rest storage; returns `"<saltHex>:<hashHex>"`.
28
+ - **`verifyToken(raw, stored)`** — recomputes the scrypt hash under the
29
+ embedded salt and compares in constant time via `timingSafeEqual`. Fails
30
+ closed (`false`, never throws) for a malformed `stored` value.
31
+ - Added as a sibling module (`src/crypto/tokens.ts`) alongside the existing
32
+ `signHmac`/`verifyHmac` primitives in the same `./crypto` subpath — no new
33
+ subpath export. Ported from Hazodocs `lib/security/random.ts` and
34
+ `lib/invitations/tokens.ts`.
35
+
3
36
  ## 1.4.0 — 2026-07-12
4
37
 
5
38
  ### New: `hazo_secure/crypto` — generic HMAC sign/verify primitive
package/README.md CHANGED
@@ -93,6 +93,33 @@ gdpr.registerExporter({
93
93
  });
94
94
  ```
95
95
 
96
+ ## New in 1.5.0
97
+
98
+ ### `hazo_secure/mask` — `maskTFN` / `maskABN` display masks
99
+
100
+ ```ts
101
+ import { maskTFN, maskABN } from "hazo_secure/mask";
102
+
103
+ maskTFN("123 456 789"); // "***-***-789"
104
+ maskABN("12 345 678 901"); // "**-***-***-901"
105
+ ```
106
+
107
+ Pure display formatting for Australian Tax File Numbers / ABNs — reveals only the last 3 digits, tolerant of space-formatted input, `""` for null/undefined/empty. Not part of `BUILTIN_TRANSFORMS` (no `maskKey`, not a DB-anonymisation transform).
108
+
109
+ ### `hazo_secure/crypto` — `randomCode` / `randomToken` / `hashToken` / `verifyToken`
110
+
111
+ ```ts
112
+ import { randomCode, randomToken, hashToken, verifyToken } from "hazo_secure/crypto";
113
+
114
+ const code = randomCode(); // "K7XQ9M2Z4RHY8VNC" — human-readable, no 0/O/1/I/L
115
+ const token = randomToken(); // base64url, 32 random bytes by default
116
+
117
+ const stored = await hashToken(token); // "<saltHex>:<hashHex>" — store this
118
+ const ok = await verifyToken(token, stored); // true
119
+ ```
120
+
121
+ `hashToken`/`verifyToken` use scrypt with a random per-token salt; `verifyToken` fails closed (`false`, never throws) for a malformed stored value.
122
+
96
123
  ## New in 1.0.0
97
124
 
98
125
  ### `hazo_secure/ratelimit` — ConnectRateLimitStore (token bucket)
@@ -55,6 +55,51 @@ type HmacVerifyResult = {
55
55
  */
56
56
  declare function verifyHmac(token: string, secret: string): HmacVerifyResult;
57
57
 
58
+ /**
59
+ * Generate a cryptographically secure random code using a human-readable
60
+ * base32-ish alphabet. Excludes visually ambiguous characters (`0`/`O`,
61
+ * `1`/`I`/`L`) to minimize transcription errors — suitable for codes a
62
+ * person reads aloud or copies from a screen/print-out (invitation codes,
63
+ * short-lived verification codes, etc.).
64
+ *
65
+ * @param length - desired code length (default: 16 chars)
66
+ * @throws {Error} if `length < 8` (inadequate entropy)
67
+ * @returns a string of `length` characters drawn from `[A-Z2-9]` excluding `0`/`O`/`1`/`I`/`L`
68
+ */
69
+ declare function randomCode(length?: number): string;
70
+ /**
71
+ * Generate a cryptographically secure random token in base64url encoding.
72
+ * Suitable for session tokens, API keys, password-reset tokens, and
73
+ * invitation tokens.
74
+ *
75
+ * @param bytes - number of random bytes (default: 32, resulting in 43 base64url chars)
76
+ * @returns a base64url string (no `+`, `/`, or `=` padding)
77
+ */
78
+ declare function randomToken(bytes?: number): string;
79
+ /**
80
+ * Hash a raw token for at-rest storage using scrypt with a random 16-byte
81
+ * salt. The returned string embeds the salt so `verifyToken` doesn't need it
82
+ * supplied separately — store the returned string verbatim (e.g. in a
83
+ * `token_hash` column) and discard the raw token.
84
+ *
85
+ * Format: `<saltHex>:<hashHex>` (scrypt output is 64 bytes / 128 hex chars).
86
+ *
87
+ * @param raw - the plaintext token to hash (e.g. output of `randomToken`)
88
+ * @returns `"<saltHex>:<hashHex>"`
89
+ */
90
+ declare function hashToken(raw: string): Promise<string>;
91
+ /**
92
+ * Verify a raw token against a hash produced by `hashToken`. Recomputes the
93
+ * scrypt hash under the embedded salt and compares in constant time via
94
+ * `timingSafeEqual`. Fails closed — returns `false` (never throws) for a
95
+ * malformed `stored` value (wrong shape, non-hex segments, etc.).
96
+ *
97
+ * @param raw - the plaintext token supplied by the caller
98
+ * @param stored - the `"<saltHex>:<hashHex>"` string previously returned by `hashToken`
99
+ * @returns `true` if `raw` hashes to `stored` under its embedded salt, else `false`
100
+ */
101
+ declare function verifyToken(raw: string, stored: string): Promise<boolean>;
102
+
58
103
  /**
59
104
  * Source of symmetric keys for `encryptField` / `decryptField`. The current
60
105
  * key is used for encryption; `byId` resolves any key (current or rotated-out)
@@ -201,4 +246,4 @@ declare class LookupKeyProvider implements KeyProvider {
201
246
  * Convention: `<table>:<entityId>:<field>` — collision-free across tables. */
202
247
  declare function aadFor(table: string, entityId: string, field: string): string;
203
248
 
204
- export { CryptoError, type CryptoErrorCode, type EncryptedField, EnvKeyProvider, type HmacVerifyResult, HttpKeyProvider, type HttpKeyProviderOptions, type KeyProvider, LookupKeyProvider, StaticKeyProvider, aadFor, decryptField, encryptField, signHmac, verifyHmac };
249
+ export { CryptoError, type CryptoErrorCode, type EncryptedField, EnvKeyProvider, type HmacVerifyResult, HttpKeyProvider, type HttpKeyProviderOptions, type KeyProvider, LookupKeyProvider, StaticKeyProvider, aadFor, decryptField, encryptField, hashToken, randomCode, randomToken, signHmac, verifyHmac, verifyToken };
@@ -1,5 +1,5 @@
1
1
  // src/crypto/index.ts
2
- import { createCipheriv, createDecipheriv, randomBytes } from "crypto";
2
+ import { createCipheriv, createDecipheriv, randomBytes as randomBytes2 } from "crypto";
3
3
  import { HazoError } from "hazo_core";
4
4
 
5
5
  // src/crypto/provider-http.ts
@@ -128,6 +128,38 @@ function verifyHmac(token, secret) {
128
128
  return { valid: true, payload };
129
129
  }
130
130
 
131
+ // src/crypto/tokens.ts
132
+ import { randomBytes, randomInt, scryptSync, timingSafeEqual as timingSafeEqual2 } from "crypto";
133
+ var CODE_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789";
134
+ var SCRYPT_KEY_LEN = 64;
135
+ function randomCode(length = 16) {
136
+ if (length < 8) {
137
+ throw new Error(`randomCode length must be >= 8 for adequate entropy (got ${length})`);
138
+ }
139
+ let out = "";
140
+ for (let i = 0; i < length; i++) {
141
+ out += CODE_ALPHABET[randomInt(0, CODE_ALPHABET.length)];
142
+ }
143
+ return out;
144
+ }
145
+ function randomToken(bytes = 32) {
146
+ return randomBytes(bytes).toString("base64url");
147
+ }
148
+ async function hashToken(raw) {
149
+ const salt = randomBytes(16);
150
+ const hash = scryptSync(raw, salt, SCRYPT_KEY_LEN);
151
+ return `${salt.toString("hex")}:${hash.toString("hex")}`;
152
+ }
153
+ async function verifyToken(raw, stored) {
154
+ const [saltHex, hashHex] = stored.split(":");
155
+ if (!saltHex || !hashHex) return false;
156
+ const salt = Buffer.from(saltHex, "hex");
157
+ const expected = Buffer.from(hashHex, "hex");
158
+ if (salt.length === 0 || expected.length === 0) return false;
159
+ const candidate = scryptSync(raw, salt, SCRYPT_KEY_LEN);
160
+ return candidate.length === expected.length && timingSafeEqual2(candidate, expected);
161
+ }
162
+
131
163
  // src/crypto/index.ts
132
164
  var CryptoError = class extends HazoError {
133
165
  constructor(message, code) {
@@ -159,7 +191,7 @@ async function encryptField(plaintext, opts) {
159
191
  "invalid_key"
160
192
  );
161
193
  }
162
- const iv = randomBytes(IV_LEN);
194
+ const iv = randomBytes2(IV_LEN);
163
195
  const cipher = createCipheriv(ALG, key, iv, { authTagLength: TAG_LEN });
164
196
  if (opts.aad) cipher.setAAD(Buffer.from(opts.aad, "utf8"));
165
197
  const ct = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
@@ -317,6 +349,10 @@ export {
317
349
  aadFor,
318
350
  decryptField,
319
351
  encryptField,
352
+ hashToken,
353
+ randomCode,
354
+ randomToken,
320
355
  signHmac,
321
- verifyHmac
356
+ verifyHmac,
357
+ verifyToken
322
358
  };
@@ -34,6 +34,32 @@ declare function drop(_value: unknown, _col: string, _maskKey: string): unknown;
34
34
  * nullify — replace with null (less aggressive than drop; preserves column semantics).
35
35
  */
36
36
  declare function nullify(_value: unknown, _col: string, _maskKey: string): unknown;
37
+ /**
38
+ * maskTFN — mask an Australian Tax File Number for display, revealing only
39
+ * the last 3 digits.
40
+ *
41
+ * TFNs are commonly rendered/entered as `123 456 789` or `123456789`;
42
+ * whitespace is stripped before masking so both forms produce the same
43
+ * output. Returns `"***"` for inputs too short to safely reveal 3 digits,
44
+ * and `""` for null/undefined/empty input.
45
+ *
46
+ * @param tfn - raw or space-formatted TFN, or null/undefined
47
+ * @returns a `"***-***-789"`-shaped string, `"***"`, or `""`
48
+ */
49
+ declare function maskTFN(tfn: string | null | undefined): string;
50
+ /**
51
+ * maskABN — mask an Australian Business Number for display, revealing only
52
+ * the last 3 digits.
53
+ *
54
+ * ABNs are commonly rendered/entered as `12 345 678 901` or `12345678901`;
55
+ * whitespace is stripped before masking so both forms produce the same
56
+ * output. Returns `"***"` for inputs too short to safely reveal 3 digits,
57
+ * and `""` for null/undefined/empty input.
58
+ *
59
+ * @param abn - raw or space-formatted ABN, or null/undefined
60
+ * @returns a `"**-***-***-901"`-shaped string, `"***"`, or `""`
61
+ */
62
+ declare function maskABN(abn: string | null | undefined): string;
37
63
  /**
38
64
  * redact_pii — NER-based PII redaction (wink-nlp lite model).
39
65
  * Detects named entities (dates, cardinals, etc.) and replaces them with [REDACTED_TYPE].
@@ -45,4 +71,4 @@ declare function redact_pii(value: unknown, _col: string, _maskKey: string): unk
45
71
  declare const BUILTIN_TRANSFORMS: Record<string, (value: unknown, col: string, maskKey: string) => unknown>;
46
72
  type MaskTransformFn = (value: unknown, col: string, maskKey: string) => unknown;
47
73
 
48
- export { BUILTIN_TRANSFORMS, type MaskTransformFn, drop, fake_name, hash, jitter_date, mask_email, mask_phone, nullify, redact_pii, tokenize };
74
+ export { BUILTIN_TRANSFORMS, type MaskTransformFn, drop, fake_name, hash, jitter_date, maskABN, maskTFN, mask_email, mask_phone, nullify, redact_pii, tokenize };
@@ -58,6 +58,18 @@ function drop(_value, _col, _maskKey) {
58
58
  function nullify(_value, _col, _maskKey) {
59
59
  return null;
60
60
  }
61
+ function maskTFN(tfn) {
62
+ if (!tfn) return "";
63
+ const cleaned = tfn.replace(/\s/g, "");
64
+ if (cleaned.length < 3) return "***";
65
+ return `***-***-${cleaned.slice(-3)}`;
66
+ }
67
+ function maskABN(abn) {
68
+ if (!abn) return "";
69
+ const cleaned = abn.replace(/\s/g, "");
70
+ if (cleaned.length < 3) return "***";
71
+ return `**-***-***-${cleaned.slice(-3)}`;
72
+ }
61
73
  var _esmReq = createRequire(import.meta.url);
62
74
  var _nlpInstance = null;
63
75
  var _nlpIts = null;
@@ -107,6 +119,8 @@ export {
107
119
  fake_name,
108
120
  hash,
109
121
  jitter_date,
122
+ maskABN,
123
+ maskTFN,
110
124
  mask_email,
111
125
  mask_phone,
112
126
  nullify,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hazo_secure",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "Security & compliance primitives — SSRF-safe fetch, rate limiting, field-level encryption, GDPR orchestration, CSRF. Five subpath modules, pick the ones you need.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",