@nivinjoseph/n-sec 6.0.2 → 7.0.1

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.
Files changed (40) hide show
  1. package/.yarn/releases/yarn-4.14.1.cjs +940 -0
  2. package/.yarnrc.yml +6 -1
  3. package/README.md +114 -1
  4. package/dist/api-security/claim.js +2 -0
  5. package/dist/api-security/claim.js.map +1 -1
  6. package/dist/api-security/claims-identity.js +1 -0
  7. package/dist/api-security/claims-identity.js.map +1 -1
  8. package/dist/api-security/expired-token-exception.js +1 -0
  9. package/dist/api-security/expired-token-exception.js.map +1 -1
  10. package/dist/api-security/invalid-token-exception.js +2 -0
  11. package/dist/api-security/invalid-token-exception.js.map +1 -1
  12. package/dist/api-security/json-web-token.d.ts +1 -1
  13. package/dist/api-security/json-web-token.d.ts.map +1 -1
  14. package/dist/api-security/json-web-token.js +35 -10
  15. package/dist/api-security/json-web-token.js.map +1 -1
  16. package/dist/api-security/security-token.js +2 -0
  17. package/dist/api-security/security-token.js.map +1 -1
  18. package/dist/bin.js +1 -1
  19. package/dist/bin.js.map +1 -1
  20. package/dist/crypto/hash.d.ts +40 -0
  21. package/dist/crypto/hash.d.ts.map +1 -1
  22. package/dist/crypto/hash.js +60 -1
  23. package/dist/crypto/hash.js.map +1 -1
  24. package/dist/crypto/symmetric-encryption.d.ts +51 -3
  25. package/dist/crypto/symmetric-encryption.d.ts.map +1 -1
  26. package/dist/crypto/symmetric-encryption.js +90 -44
  27. package/dist/crypto/symmetric-encryption.js.map +1 -1
  28. package/dist/tsconfig.json +1 -0
  29. package/eslint.config.js +5 -5
  30. package/package.json +15 -15
  31. package/src/api-security/json-web-token.ts +37 -12
  32. package/src/bin.ts +1 -1
  33. package/src/crypto/hash.ts +66 -1
  34. package/src/crypto/symmetric-encryption.ts +98 -55
  35. package/test/hash.test.ts +89 -0
  36. package/test/hmac.test.ts +12 -12
  37. package/test/json-web-token.test.ts +76 -10
  38. package/test/symmetric-encryption.test.ts +51 -12
  39. package/tsconfig.json +5 -2
  40. package/.yarn/releases/yarn-4.0.2.cjs +0 -893
@@ -4,55 +4,101 @@ import { CryptoException } from "./crypto-exception.js";
4
4
  // public
5
5
  export class SymmetricEncryption {
6
6
  constructor() { }
7
+ /**
8
+ * Generates a cryptographically random 256-bit key suitable for use with
9
+ * {@link encrypt} and {@link decrypt}.
10
+ *
11
+ * @returns A 64-character uppercase hex string (32 bytes) sourced from the
12
+ * platform CSPRNG via Node's `crypto.randomBytes`.
13
+ */
7
14
  static generateKey() {
8
- return new Promise((resolve, reject) => {
9
- randomBytes(32, (err, buf) => {
10
- if (err) {
11
- reject(err);
12
- return;
13
- }
14
- resolve(buf.toString("hex").toUpperCase());
15
- });
16
- });
15
+ return randomBytes(32).toString("hex").toUpperCase();
17
16
  }
18
- static encrypt(key, value) {
19
- return new Promise((resolve, reject) => {
20
- given(key, "key").ensureHasValue().ensureIsString();
21
- given(value, "value").ensureHasValue().ensureIsString();
22
- key = key.trim();
23
- value = value.trim();
24
- randomBytes(16, (err, buf) => {
25
- if (err) {
26
- reject(err);
27
- return;
28
- }
29
- try {
30
- const iv = buf;
31
- const cipher = createCipheriv("AES-256-CBC", Buffer.from(key, "hex"), iv);
32
- let encrypted = cipher.update(value, "utf8", "hex");
33
- encrypted += cipher.final("hex");
34
- const cipherText = `${encrypted}.${iv.toString("hex")}`;
35
- resolve(cipherText.toUpperCase());
36
- }
37
- catch (error) {
38
- reject(error);
39
- }
40
- });
41
- });
17
+ /**
18
+ * Encrypts a UTF-8 string using AES-256-GCM with a fresh random 96-bit IV.
19
+ *
20
+ * The result is an authenticated ciphertext: any bit-flip, truncation, or
21
+ * substitution will cause {@link decrypt} to throw a {@link CryptoException}.
22
+ *
23
+ * @param key - A 64-character hex string (32 bytes) as produced by
24
+ * {@link generateKey}. Case-insensitive.
25
+ * @param value - The plaintext to encrypt. Interpreted as UTF-8.
26
+ * @param aad - Optional Additional Authenticated Data. When supplied, its
27
+ * UTF-8 bytes are bound into the auth tag but not stored in the output;
28
+ * the exact same `aad` must be passed to {@link decrypt}, otherwise
29
+ * decryption will fail. Use this to bind a ciphertext to its context
30
+ * (e.g. `` `user:${userId}` ``) so a valid ciphertext from one record
31
+ * cannot be substituted into another.
32
+ * @returns An uppercase hex string in the form `IV.CIPHERTEXT.TAG`, where
33
+ * `IV` is 24 hex chars (12 bytes), `TAG` is 32 hex chars (16 bytes), and
34
+ * `CIPHERTEXT` is the encrypted payload.
35
+ * @throws {CryptoException} If `key` is not exactly 64 hex characters.
36
+ */
37
+ static encrypt(key, value, aad) {
38
+ given(key, "key").ensureHasValue().ensureIsString();
39
+ given(value, "value").ensureHasValue().ensureIsString();
40
+ if (aad != null)
41
+ given(aad, "aad").ensureIsString();
42
+ const keyBuf = SymmetricEncryption._decodeKey(key);
43
+ const iv = randomBytes(12);
44
+ const cipher = createCipheriv("aes-256-gcm", keyBuf, iv);
45
+ if (aad != null && aad.length > 0)
46
+ cipher.setAAD(Buffer.from(aad, "utf8"));
47
+ const ct = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]);
48
+ const tag = cipher.getAuthTag();
49
+ return [iv.toString("hex"), ct.toString("hex"), tag.toString("hex")]
50
+ .join(".").toUpperCase();
42
51
  }
43
- static decrypt(key, value) {
52
+ /**
53
+ * Decrypts a ciphertext produced by {@link encrypt} and returns the
54
+ * original UTF-8 plaintext. Integrity is verified via the GCM auth tag
55
+ * before the plaintext is returned — if verification fails, nothing is
56
+ * returned.
57
+ *
58
+ * @param key - The same 64-character hex key that was used to encrypt.
59
+ * Case-insensitive.
60
+ * @param value - The ciphertext string in `IV.CIPHERTEXT.TAG` form as
61
+ * produced by {@link encrypt}.
62
+ * @param aad - The same Additional Authenticated Data that was supplied
63
+ * to {@link encrypt}, or omitted if none was supplied. Any mismatch
64
+ * (wrong value, supplied here but not at encrypt, or vice versa) will
65
+ * cause the auth tag check to fail.
66
+ * @returns The original plaintext decoded as UTF-8.
67
+ * @throws {CryptoException} If `key` is not exactly 64 hex characters,
68
+ * if `value` is not in the expected `IV.CIPHERTEXT.TAG` format with
69
+ * correct component lengths, or if the auth tag verification fails
70
+ * (wrong key, tampered ciphertext, or mismatched `aad`).
71
+ */
72
+ static decrypt(key, value, aad) {
44
73
  given(key, "key").ensureHasValue().ensureIsString();
45
74
  given(value, "value").ensureHasValue().ensureIsString();
46
- key = key.trim();
47
- value = value.trim();
48
- const splitted = value.split(".");
49
- if (splitted.length !== 2)
50
- throw new CryptoException("Invalid value.");
51
- const iv = Buffer.from(splitted[1], "hex");
52
- const deCipher = createDecipheriv("AES-256-CBC", Buffer.from(key, "hex"), iv);
53
- let decrypted = deCipher.update(splitted[0], "hex", "utf8");
54
- decrypted += deCipher.final("utf8");
55
- return decrypted;
75
+ if (aad != null)
76
+ given(aad, "aad").ensureIsString();
77
+ const keyBuf = SymmetricEncryption._decodeKey(key);
78
+ const parts = value.split(".");
79
+ if (parts.length !== 3)
80
+ throw new CryptoException("Malformed ciphertext.");
81
+ const iv = Buffer.from(parts[0], "hex");
82
+ const ct = Buffer.from(parts[1], "hex");
83
+ const tag = Buffer.from(parts[2], "hex");
84
+ if (iv.length !== 12 || ct.length === 0 || tag.length !== 16)
85
+ throw new CryptoException("Malformed ciphertext.");
86
+ try {
87
+ const decipher = createDecipheriv("aes-256-gcm", keyBuf, iv);
88
+ if (aad != null && aad.length > 0)
89
+ decipher.setAAD(Buffer.from(aad, "utf8"));
90
+ decipher.setAuthTag(tag);
91
+ return Buffer.concat([decipher.update(ct), decipher.final()]).toString("utf8");
92
+ }
93
+ catch {
94
+ throw new CryptoException("Integrity check failed.");
95
+ }
96
+ }
97
+ static _decodeKey(key) {
98
+ const buf = Buffer.from(key, "hex");
99
+ if (buf.length !== 32 || buf.toString("hex").toUpperCase() !== key.toUpperCase())
100
+ throw new CryptoException("Key must be 64 hex characters (32 bytes).");
101
+ return buf;
56
102
  }
57
103
  }
58
104
  //# sourceMappingURL=symmetric-encryption.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"symmetric-encryption.js","sourceRoot":"","sources":["../../src/crypto/symmetric-encryption.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"symmetric-encryption.js","sourceRoot":"","sources":["../../src/crypto/symmetric-encryption.ts"],"names":[],"mappings":"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"}
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "extends": "../tsconfig.json",
3
3
  "compilerOptions": {
4
+ "rootDir": "../src",
4
5
  "outDir": ".",
5
6
  "declaration": true,
6
7
  "declarationDir": ".",
package/eslint.config.js CHANGED
@@ -11,7 +11,7 @@ export default defineConfig(
11
11
  tsEslint.configs.recommended,
12
12
  importPlugin.flatConfigs.recommended,
13
13
  {
14
- ignores: ["dist/**", "node_modules/**", "**/*.js", "**/*.map", "**/*d.ts"]
14
+ ignores: ["dist/**", "node_modules/**", "**/*.js", "**/*.map", "**/*.d.ts"]
15
15
  },
16
16
  {
17
17
  files: ["**/*.ts"],
@@ -50,9 +50,9 @@ export default defineConfig(
50
50
  files: ["**/*.ts"],
51
51
  languageOptions: {
52
52
  parserOptions: {
53
- project: [
54
- "./tsconfig.json"
55
- ],
53
+ // project: [
54
+ // "./tsconfig.json"
55
+ // ],
56
56
  tsconfigRootDir: import.meta.dirname,
57
57
  projectService: true
58
58
  }
@@ -155,7 +155,7 @@ export default defineConfig(
155
155
  ],
156
156
  "default-param-last": "off",
157
157
  "@typescript-eslint/default-param-last": "error",
158
- "@typescript-eslint/explicit-function-return-type": "error",
158
+ "@typescript-eslint/explicit-function-return-type": ["error", { "allowExpressions": true }],
159
159
  "@typescript-eslint/explicit-member-accessibility": "error",
160
160
  "@typescript-eslint/explicit-module-boundary-types": "error",
161
161
  "func-call-spacing": "off",
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@nivinjoseph/n-sec",
3
- "version": "6.0.2",
3
+ "version": "7.0.1",
4
4
  "description": "Security library",
5
- "packageManager": "yarn@4.0.2",
5
+ "packageManager": "yarn@4.14.1",
6
6
  "type": "module",
7
7
  "exports": "./dist/index.js",
8
8
  "types": "./dist/index.d.ts",
@@ -13,7 +13,7 @@
13
13
  "ts-build": "yarn ts-compile && yarn ts-lint",
14
14
  "ts-build-dist": "yarn ts-build && tsc -p ./dist",
15
15
  "test": "yarn ts-build && node --test --enable-source-maps ./test/**/*.test.js",
16
- "publish-package": "yarn ts-build-dist && git add . && git commit -m 'preparing to publish new version' && npm version patch && git push && npm publish --access=public"
16
+ "publish-package": "yarn ts-build-dist && git add . && git commit -m 'preparing to publish new version' && yarn version patch && git add . && git commit -m 'new version' && git push && npm publish --access=public"
17
17
  },
18
18
  "repository": {
19
19
  "type": "git",
@@ -30,20 +30,20 @@
30
30
  },
31
31
  "homepage": "https://github.com/nivinjoseph/n-sec#readme",
32
32
  "devDependencies": {
33
- "@eslint/js": "^9.39.1",
34
- "@stylistic/eslint-plugin": "^5.5.0",
35
- "@types/node": "^24.10",
36
- "eslint": "^9.39.1",
37
- "eslint-import-resolver-typescript": "^4.4.4",
38
- "eslint-plugin-import": "^2.32.0",
39
- "typescript": "^5.9.3",
40
- "typescript-eslint": "^8.46.3"
33
+ "@eslint/js": "10.0.1",
34
+ "@stylistic/eslint-plugin": "5.10.0",
35
+ "@types/node": "24.10.0",
36
+ "eslint": "10.2.1",
37
+ "eslint-import-resolver-typescript": "4.4.4",
38
+ "eslint-plugin-import": "2.32.0",
39
+ "typescript": "6.0.3",
40
+ "typescript-eslint": "8.59.0"
41
41
  },
42
42
  "dependencies": {
43
- "@nivinjoseph/n-defensive": "^2.0.2",
44
- "@nivinjoseph/n-exception": "^2.0.2",
45
- "@nivinjoseph/n-ext": "^2.0.2",
46
- "@nivinjoseph/n-util": "^3.0.2"
43
+ "@nivinjoseph/n-defensive": "2.0.3",
44
+ "@nivinjoseph/n-exception": "2.0.3",
45
+ "@nivinjoseph/n-ext": "2.0.5",
46
+ "@nivinjoseph/n-util": "4.0.1"
47
47
  },
48
48
  "engines": {
49
49
  "node": ">=24.10"
@@ -1,5 +1,6 @@
1
1
  import { given } from "@nivinjoseph/n-defensive";
2
2
  import { InvalidOperationException } from "@nivinjoseph/n-exception";
3
+ import { timingSafeEqual } from "node:crypto";
3
4
  import { Hmac } from "./../crypto/hmac.js";
4
5
  import { AlgType } from "./alg-type.js";
5
6
  import { Claim } from "./claim.js";
@@ -14,7 +15,7 @@ export class JsonWebToken
14
15
  private readonly _issuer: string;
15
16
  private readonly _algType: AlgType;
16
17
  private readonly _key: string;
17
- private readonly _isfullKey: boolean;
18
+ private readonly _isFullKey: boolean;
18
19
  private readonly _expiry: number;
19
20
  private readonly _claims: Array<Claim>;
20
21
 
@@ -22,7 +23,7 @@ export class JsonWebToken
22
23
  public get issuer(): string { return this._issuer; }
23
24
  public get algType(): AlgType { return this._algType; }
24
25
  public get key(): string { return this._key; }
25
- public get canGenerateToken(): boolean { return this._isfullKey; }
26
+ public get canGenerateToken(): boolean { return this._isFullKey; }
26
27
  public get expiry(): number { return this._expiry; }
27
28
  public get isExpired(): boolean { return this._expiry <= Date.now(); }
28
29
  public get claims(): ReadonlyArray<Claim> { return this._claims; }
@@ -42,7 +43,7 @@ export class JsonWebToken
42
43
  this._issuer = issuer.trim();
43
44
  this._algType = algType;
44
45
  this._key = key.trim();
45
- this._isfullKey = isFullKey;
46
+ this._isFullKey = isFullKey;
46
47
  this._expiry = expiry;
47
48
  this._claims = [...claims];
48
49
  }
@@ -72,8 +73,26 @@ export class JsonWebToken
72
73
  const bodyString = tokenSplitted[1];
73
74
  const signature = tokenSplitted[2];
74
75
 
75
- const header: Header = JsonWebToken._toObject(headerString) as Header;
76
- const body: any = JsonWebToken._toObject(bodyString);
76
+ let parsedHeader: unknown;
77
+ let parsedBody: unknown;
78
+ try
79
+ {
80
+ parsedHeader = JsonWebToken._toObject(headerString);
81
+ parsedBody = JsonWebToken._toObject(bodyString);
82
+ }
83
+ catch
84
+ {
85
+ throw new InvalidTokenException(token, "header or body could not be parsed");
86
+ }
87
+
88
+ if (parsedHeader == null || typeof parsedHeader !== "object" || Array.isArray(parsedHeader))
89
+ throw new InvalidTokenException(token, "header is not an object");
90
+
91
+ if (parsedBody == null || typeof parsedBody !== "object" || Array.isArray(parsedBody))
92
+ throw new InvalidTokenException(token, "body is not an object");
93
+
94
+ const header = parsedHeader as Header;
95
+ const body = parsedBody as Record<string, unknown>;
77
96
 
78
97
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
79
98
  if (header.iss === undefined || header.iss === null)
@@ -116,26 +135,32 @@ export class JsonWebToken
116
135
  // }
117
136
 
118
137
  const computedSignature = Hmac.create(key, headerString + "." + bodyString);
119
- if (computedSignature !== signature)
138
+ const expected = Buffer.from(computedSignature, "utf8");
139
+ const provided = Buffer.from(signature, "utf8");
140
+ if (expected.length !== provided.length || !timingSafeEqual(expected, provided))
120
141
  throw new InvalidTokenException(token, "signature could not be verified");
121
142
 
143
+ const invalidBodyKeys = new Set(["__proto__", "constructor", "prototype"]);
122
144
  const claims = new Array<Claim>();
123
- for (const item in body)
124
- claims.push(new Claim(item, body[item]));
145
+ for (const [type, value] of Object.entries(body))
146
+ {
147
+ if (invalidBodyKeys.has(type))
148
+ throw new InvalidTokenException(token, `body contains invalid key '${type}'`);
149
+ claims.push(new Claim(type, value));
150
+ }
125
151
 
126
152
  return new JsonWebToken(issuer, algType, key, false, header.exp, claims);
127
153
  }
128
154
 
129
- private static _toObject(hex: string): object
155
+ private static _toObject(hex: string): unknown
130
156
  {
131
157
  const json = Buffer.from(hex.toLowerCase(), "hex").toString("utf8");
132
- const obj = JSON.parse(json) as object;
133
- return obj;
158
+ return JSON.parse(json);
134
159
  }
135
160
 
136
161
  public generateToken(): string
137
162
  {
138
- if (!this._isfullKey)
163
+ if (!this._isFullKey)
139
164
  throw new InvalidOperationException("generating token using an instance created from token");
140
165
 
141
166
  const header: Header = {
package/src/bin.ts CHANGED
@@ -20,7 +20,7 @@ async function executeCommand(command: SupportedCommands): Promise<void>
20
20
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
21
21
  case SupportedCommands.generateSymmetricKey:
22
22
  {
23
- const key = await SymmetricEncryption.generateKey();
23
+ const key = SymmetricEncryption.generateKey();
24
24
  console.log("SYMMETRIC KEY => ", key);
25
25
  break;
26
26
  }
@@ -1,5 +1,5 @@
1
1
  import { given } from "@nivinjoseph/n-defensive";
2
- import { createHash } from "node:crypto";
2
+ import { createHash, scryptSync, timingSafeEqual } from "node:crypto";
3
3
 
4
4
 
5
5
  // public
@@ -18,6 +18,16 @@ export class Hash
18
18
  return hash.digest("hex").toUpperCase();
19
19
  }
20
20
 
21
+ /**
22
+ * @deprecated Unsafe for password storage. Uses a single round of
23
+ * SHA-512, which a GPU can compute at billions of hashes/second —
24
+ * leaked hashes can be brute-forced rapidly against any wordlist.
25
+ * Additionally trims leading/trailing whitespace from both `value`
26
+ * and `salt`, so inputs differing only in whitespace collide.
27
+ *
28
+ * For password hashing, use {@link createForPassword} +
29
+ * {@link verifyPassword} instead.
30
+ */
21
31
  public static createUsingSalt(value: string, salt: string): string
22
32
  {
23
33
  given(value, "value").ensureHasValue().ensureIsString();
@@ -43,4 +53,59 @@ export class Hash
43
53
 
44
54
  return Hash.create(saltedValue);
45
55
  }
56
+
57
+ /**
58
+ * Derives a 64-byte hash from `password` and `salt` using scrypt with
59
+ * parameters N=2^15, r=8, p=1 (RFC 7914 recommended defaults). Suitable
60
+ * for password storage: slow and memory-hard, so leaked hashes resist
61
+ * GPU brute-force attacks far better than a plain SHA-512.
62
+ *
63
+ * Inputs are NOT trimmed — the exact bytes of `password` and `salt` are
64
+ * hashed. Two passwords differing only in whitespace produce different
65
+ * outputs.
66
+ *
67
+ * @param password - The password to hash. Hashed as UTF-8.
68
+ * @param salt - A per-user random value (recommended: ≥16 random bytes,
69
+ * e.g. `randomBytes(16).toString("hex")`). Must be stored alongside
70
+ * the hash so the same value can be passed on verification.
71
+ * @returns A 128-character uppercase hex string (64 bytes).
72
+ */
73
+ public static createForPassword(password: string, salt: string): string
74
+ {
75
+ given(password, "password").ensureHasValue().ensureIsString();
76
+ given(salt, "salt").ensureHasValue().ensureIsString();
77
+
78
+ const derived = scryptSync(password, salt, 64, {
79
+ N: 1 << 15,
80
+ r: 8,
81
+ p: 1,
82
+ maxmem: 64 * 1024 * 1024
83
+ });
84
+ return derived.toString("hex").toUpperCase();
85
+ }
86
+
87
+ /**
88
+ * Verifies a password against a hash previously produced by
89
+ * {@link createForPassword}. The comparison is constant-time, so
90
+ * timing cannot be used to learn how many leading bytes matched.
91
+ *
92
+ * @param password - The candidate password to verify. Hashed as UTF-8.
93
+ * @param salt - The same salt that was passed to
94
+ * {@link createForPassword} when the stored hash was created.
95
+ * @param expectedHash - The previously-stored hash (hex string, any case).
96
+ * @returns `true` if the candidate matches; `false` if it doesn't,
97
+ * if `expectedHash` is not valid hex, or if lengths differ.
98
+ */
99
+ public static verifyPassword(password: string, salt: string, expectedHash: string): boolean
100
+ {
101
+ given(password, "password").ensureHasValue().ensureIsString();
102
+ given(salt, "salt").ensureHasValue().ensureIsString();
103
+ given(expectedHash, "expectedHash").ensureHasValue().ensureIsString();
104
+
105
+ const computed = Buffer.from(Hash.createForPassword(password, salt), "hex");
106
+ const expected = Buffer.from(expectedHash, "hex");
107
+ if (computed.length !== expected.length)
108
+ return false;
109
+ return timingSafeEqual(computed, expected);
110
+ }
46
111
  }
@@ -9,74 +9,117 @@ export class SymmetricEncryption
9
9
  private constructor() { }
10
10
 
11
11
 
12
- public static generateKey(): Promise<string>
12
+ /**
13
+ * Generates a cryptographically random 256-bit key suitable for use with
14
+ * {@link encrypt} and {@link decrypt}.
15
+ *
16
+ * @returns A 64-character uppercase hex string (32 bytes) sourced from the
17
+ * platform CSPRNG via Node's `crypto.randomBytes`.
18
+ */
19
+ public static generateKey(): string
13
20
  {
14
- return new Promise<string>((resolve, reject) =>
15
- {
16
- randomBytes(32, (err, buf) =>
17
- {
18
- if (err)
19
- {
20
- reject(err);
21
- return;
22
- }
23
-
24
- resolve(buf.toString("hex").toUpperCase());
25
- });
26
- });
21
+ return randomBytes(32).toString("hex").toUpperCase();
27
22
  }
28
23
 
29
- public static encrypt(key: string, value: string): Promise<string>
24
+ /**
25
+ * Encrypts a UTF-8 string using AES-256-GCM with a fresh random 96-bit IV.
26
+ *
27
+ * The result is an authenticated ciphertext: any bit-flip, truncation, or
28
+ * substitution will cause {@link decrypt} to throw a {@link CryptoException}.
29
+ *
30
+ * @param key - A 64-character hex string (32 bytes) as produced by
31
+ * {@link generateKey}. Case-insensitive.
32
+ * @param value - The plaintext to encrypt. Interpreted as UTF-8.
33
+ * @param aad - Optional Additional Authenticated Data. When supplied, its
34
+ * UTF-8 bytes are bound into the auth tag but not stored in the output;
35
+ * the exact same `aad` must be passed to {@link decrypt}, otherwise
36
+ * decryption will fail. Use this to bind a ciphertext to its context
37
+ * (e.g. `` `user:${userId}` ``) so a valid ciphertext from one record
38
+ * cannot be substituted into another.
39
+ * @returns An uppercase hex string in the form `IV.CIPHERTEXT.TAG`, where
40
+ * `IV` is 24 hex chars (12 bytes), `TAG` is 32 hex chars (16 bytes), and
41
+ * `CIPHERTEXT` is the encrypted payload.
42
+ * @throws {CryptoException} If `key` is not exactly 64 hex characters.
43
+ */
44
+ public static encrypt(key: string, value: string, aad?: string): string
30
45
  {
31
- return new Promise<string>((resolve, reject) =>
32
- {
33
- given(key, "key").ensureHasValue().ensureIsString();
34
- given(value, "value").ensureHasValue().ensureIsString();
46
+ given(key, "key").ensureHasValue().ensureIsString();
47
+ given(value, "value").ensureHasValue().ensureIsString();
48
+ if (aad != null)
49
+ given(aad, "aad").ensureIsString();
35
50
 
36
- key = key.trim();
37
- value = value.trim();
51
+ const keyBuf = SymmetricEncryption._decodeKey(key);
52
+ const iv = randomBytes(12);
38
53
 
39
- randomBytes(16, (err, buf) =>
40
- {
41
- if (err)
42
- {
43
- reject(err);
44
- return;
45
- }
54
+ const cipher = createCipheriv("aes-256-gcm", keyBuf, iv);
55
+ if (aad != null && aad.length > 0)
56
+ cipher.setAAD(Buffer.from(aad, "utf8"));
57
+ const ct = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]);
58
+ const tag = cipher.getAuthTag();
46
59
 
47
- try
48
- {
49
- const iv = buf;
50
- const cipher = createCipheriv("AES-256-CBC", Buffer.from(key, "hex"), iv);
51
- let encrypted = cipher.update(value, "utf8", "hex");
52
- encrypted += cipher.final("hex");
53
- const cipherText = `${encrypted}.${iv.toString("hex")}`;
54
- resolve(cipherText.toUpperCase());
55
- }
56
- catch (error)
57
- {
58
- reject(error);
59
- }
60
- });
61
- });
60
+ return [iv.toString("hex"), ct.toString("hex"), tag.toString("hex")]
61
+ .join(".").toUpperCase();
62
62
  }
63
63
 
64
- public static decrypt(key: string, value: string): string
64
+ /**
65
+ * Decrypts a ciphertext produced by {@link encrypt} and returns the
66
+ * original UTF-8 plaintext. Integrity is verified via the GCM auth tag
67
+ * before the plaintext is returned — if verification fails, nothing is
68
+ * returned.
69
+ *
70
+ * @param key - The same 64-character hex key that was used to encrypt.
71
+ * Case-insensitive.
72
+ * @param value - The ciphertext string in `IV.CIPHERTEXT.TAG` form as
73
+ * produced by {@link encrypt}.
74
+ * @param aad - The same Additional Authenticated Data that was supplied
75
+ * to {@link encrypt}, or omitted if none was supplied. Any mismatch
76
+ * (wrong value, supplied here but not at encrypt, or vice versa) will
77
+ * cause the auth tag check to fail.
78
+ * @returns The original plaintext decoded as UTF-8.
79
+ * @throws {CryptoException} If `key` is not exactly 64 hex characters,
80
+ * if `value` is not in the expected `IV.CIPHERTEXT.TAG` format with
81
+ * correct component lengths, or if the auth tag verification fails
82
+ * (wrong key, tampered ciphertext, or mismatched `aad`).
83
+ */
84
+ public static decrypt(key: string, value: string, aad?: string): string
65
85
  {
66
86
  given(key, "key").ensureHasValue().ensureIsString();
67
87
  given(value, "value").ensureHasValue().ensureIsString();
88
+ if (aad != null)
89
+ given(aad, "aad").ensureIsString();
90
+
91
+ const keyBuf = SymmetricEncryption._decodeKey(key);
68
92
 
69
- key = key.trim();
70
- value = value.trim();
93
+ const parts = value.split(".");
94
+ if (parts.length !== 3)
95
+ throw new CryptoException("Malformed ciphertext.");
71
96
 
72
- const splitted = value.split(".");
73
- if (splitted.length !== 2)
74
- throw new CryptoException("Invalid value.");
97
+ const iv = Buffer.from(parts[0], "hex");
98
+ const ct = Buffer.from(parts[1], "hex");
99
+ const tag = Buffer.from(parts[2], "hex");
75
100
 
76
- const iv = Buffer.from(splitted[1], "hex");
77
- const deCipher = createDecipheriv("AES-256-CBC", Buffer.from(key, "hex"), iv);
78
- let decrypted = deCipher.update(splitted[0], "hex", "utf8");
79
- decrypted += deCipher.final("utf8");
80
- return decrypted;
101
+ if (iv.length !== 12 || ct.length === 0 || tag.length !== 16)
102
+ throw new CryptoException("Malformed ciphertext.");
103
+
104
+ try
105
+ {
106
+ const decipher = createDecipheriv("aes-256-gcm", keyBuf, iv);
107
+ if (aad != null && aad.length > 0)
108
+ decipher.setAAD(Buffer.from(aad, "utf8"));
109
+ decipher.setAuthTag(tag);
110
+ return Buffer.concat([decipher.update(ct), decipher.final()]).toString("utf8");
111
+ }
112
+ catch
113
+ {
114
+ throw new CryptoException("Integrity check failed.");
115
+ }
116
+ }
117
+
118
+ private static _decodeKey(key: string): Buffer
119
+ {
120
+ const buf = Buffer.from(key, "hex");
121
+ if (buf.length !== 32 || buf.toString("hex").toUpperCase() !== key.toUpperCase())
122
+ throw new CryptoException("Key must be 64 hex characters (32 bytes).");
123
+ return buf;
81
124
  }
82
- }
125
+ }