@syldel/crypto-utils 1.0.3 → 1.1.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/LICENSE.MD CHANGED
@@ -1,5 +1,5 @@
1
- Copyright (c) 2026, Syl-Studio Inc. c/o Syl. D.
2
-
3
- Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies.
4
-
1
+ Copyright (c) 2026, Syl-Studio Inc. c/o Syl. D.
2
+
3
+ Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies.
4
+
5
5
  THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
package/README.md CHANGED
@@ -30,7 +30,8 @@ const encrypted = AesGcmUtil.encrypt(message, keyHex);
30
30
  try {
31
31
  const original = AesGcmUtil.decrypt(encrypted, keyHex);
32
32
  } catch (error) {
33
- // Throws "Decryption failed" if data is tampered or key is wrong
33
+ // "Invalid key: …" if the key is missing, not hex or not 32 bytes (a config error)
34
+ // "Decryption failed" for anything wrong with the payload, or the wrong key
34
35
  }
35
36
  ```
36
37
 
@@ -58,10 +59,20 @@ try {
58
59
 
59
60
  - **Zero External Crypto Libs**: By using native Node.js APIs, this package minimizes the attack surface and dependency supply chain risks.
60
61
 
61
- - **Side-Channel Protection**: Leverages OpenSSL's C++ implementation to protect against timing attacks.
62
+ - **Side-Channel Protection**: JWT signatures are compared with `crypto.timingSafeEqual`, in constant time.
62
63
 
63
64
  - **GCM Integrity**: Unlike AES-CTR or CBC, AES-GCM ensures that any single-bit modification to the encrypted data will be detected during decryption.
64
65
 
66
+ ### ⚠️ What is refused, and why
67
+
68
+ - `AesGcmUtil.decrypt` requires a full 16-byte tag and a 12-byte IV. Without this, Node accepts tags down to 4 bytes, and a random forged tag would pass with a 1 in 2^32 chance instead of 2^128.
69
+ - Every hex input (key, data, IV, tag) is decoded strictly: a stray character fails instead of being silently cut off.
70
+ - `decrypt` refuses a ciphertext above `AesGcmUtil.MAX_CIPHERTEXT_BYTES` (1 MiB) before decoding it.
71
+ - A missing, non-hex or wrongly sized key throws `Invalid key: …`, never `Decryption failed`: it is a configuration error, and blaming the payload sent the search the wrong way. The message never quotes the key. Everything wrong with the payload stays a single generic `Decryption failed`, so an attacker learns nothing about which check failed.
72
+ - `PureJwtUtil.verify` only accepts `alg: "HS256"`, a JSON object payload, a numeric `exp`, and the canonical base64url spelling of the signature.
73
+ - Both `sign` and `verify` refuse an empty or missing secret: HMAC happily signs with an empty key, which anyone can guess.
74
+ - `nbf`, `iat`, `aud` and `iss` are **not** checked: callers that rely on them must check them themselves.
75
+
65
76
 
66
77
  ## 🧪 Testing
67
78
 
@@ -5,7 +5,29 @@ export interface EncryptedData {
5
5
  }
6
6
  export declare class AesGcmUtil {
7
7
  private static readonly ALGORITHM;
8
+ private static readonly KEY_BYTES;
9
+ private static readonly IV_BYTES;
10
+ private static readonly TAG_BYTES;
11
+ /**
12
+ * Largest ciphertext `decrypt` accepts, in bytes (1 MiB). Checked on the hex
13
+ * string before any decoding: without it, a payload of hundreds of MB was
14
+ * decoded in full before GCM refused it. Our largest real payload is an
15
+ * agent key, 66 characters.
16
+ */
17
+ static readonly MAX_CIPHERTEXT_BYTES: number;
18
+ /**
19
+ * Une clé invalide est une erreur de configuration de l'appelant, pas une
20
+ * entrée d'attaquant : elle se nomme au lieu de se fondre dans « Decryption
21
+ * failed », qui accusait le chiffré quand le secret manquait dans l'env.
22
+ * Le message ne cite jamais la clé.
23
+ */
8
24
  private static validateKey;
25
+ /**
26
+ * Strict hex decoding. Buffer.from(hex) stops at the first invalid character
27
+ * without a word: a damaged field was read half-way, and a key followed by
28
+ * junk ("<64 hex>zz") was accepted as its first 32 bytes.
29
+ */
30
+ private static hexToBuffer;
9
31
  /**
10
32
  * Encrypts text using native Node.js crypto (AES-256-GCM)
11
33
  */
@@ -36,21 +36,53 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.AesGcmUtil = void 0;
37
37
  const crypto = __importStar(require("crypto"));
38
38
  class AesGcmUtil {
39
+ /**
40
+ * Une clé invalide est une erreur de configuration de l'appelant, pas une
41
+ * entrée d'attaquant : elle se nomme au lieu de se fondre dans « Decryption
42
+ * failed », qui accusait le chiffré quand le secret manquait dans l'env.
43
+ * Le message ne cite jamais la clé.
44
+ */
39
45
  static validateKey(key) {
40
- const encryptionKey = typeof key === 'string' ? Buffer.from(key, 'hex') : key;
41
- if (encryptionKey.length !== 32) {
42
- throw new Error('Invalid Key: Key must be 32 bytes (64 hex characters)');
46
+ if (key === undefined || key === null || key.length === 0) {
47
+ throw new Error('Invalid key: missing or empty (is the secret configured?)');
48
+ }
49
+ let encryptionKey;
50
+ try {
51
+ encryptionKey = typeof key === 'string' ? this.hexToBuffer(key) : key;
52
+ }
53
+ catch {
54
+ throw new Error('Invalid key: not a hex string');
55
+ }
56
+ if (!Buffer.isBuffer(encryptionKey) || encryptionKey.length !== this.KEY_BYTES) {
57
+ throw new Error('Invalid key: must be 32 bytes (64 hex characters)');
43
58
  }
44
59
  return encryptionKey;
45
60
  }
61
+ /**
62
+ * Strict hex decoding. Buffer.from(hex) stops at the first invalid character
63
+ * without a word: a damaged field was read half-way, and a key followed by
64
+ * junk ("<64 hex>zz") was accepted as its first 32 bytes.
65
+ */
66
+ static hexToBuffer(hex, expectedBytes) {
67
+ if (typeof hex !== 'string' || hex.length % 2 !== 0 || !/^[0-9a-fA-F]*$/.test(hex)) {
68
+ throw new Error('Invalid hex string');
69
+ }
70
+ const buffer = Buffer.from(hex, 'hex');
71
+ if (expectedBytes !== undefined && buffer.length !== expectedBytes) {
72
+ throw new Error(`Expected ${expectedBytes} bytes, got ${buffer.length}`);
73
+ }
74
+ return buffer;
75
+ }
46
76
  /**
47
77
  * Encrypts text using native Node.js crypto (AES-256-GCM)
48
78
  */
49
79
  static encrypt(text, key) {
80
+ const encryptionKey = this.validateKey(key);
50
81
  try {
51
- const encryptionKey = this.validateKey(key);
52
- const iv = crypto.randomBytes(12);
53
- const cipher = crypto.createCipheriv(this.ALGORITHM, encryptionKey, iv);
82
+ const iv = crypto.randomBytes(this.IV_BYTES);
83
+ const cipher = crypto.createCipheriv(this.ALGORITHM, encryptionKey, iv, {
84
+ authTagLength: this.TAG_BYTES,
85
+ });
54
86
  let encrypted = cipher.update(text, 'utf8', 'hex');
55
87
  encrypted += cipher.final('hex');
56
88
  return {
@@ -67,11 +99,22 @@ class AesGcmUtil {
67
99
  * Decrypts a payload using native Node.js crypto (AES-256-GCM)
68
100
  */
69
101
  static decrypt(payload, key) {
102
+ const encryptionKey = this.validateKey(key);
70
103
  try {
71
- const encryptionKey = this.validateKey(key);
72
- const decipher = crypto.createDecipheriv(this.ALGORITHM, encryptionKey, Buffer.from(payload.iv, 'hex'));
73
- decipher.setAuthTag(Buffer.from(payload.tag, 'hex'));
74
- let decrypted = decipher.update(payload.data, 'hex', 'utf8');
104
+ if (typeof payload.data !== 'string' || payload.data.length > 2 * this.MAX_CIPHERTEXT_BYTES) {
105
+ throw new Error('Ciphertext missing or above the size limit');
106
+ }
107
+ const iv = this.hexToBuffer(payload.iv, this.IV_BYTES);
108
+ const tag = this.hexToBuffer(payload.tag, this.TAG_BYTES);
109
+ const data = this.hexToBuffer(payload.data);
110
+ // Sans `authTagLength`, Node accepte un tag de 4 à 16 octets : mesuré sur
111
+ // la 1.0.3, un tag tronqué à 4 octets déchiffrait, et un tag forgé au
112
+ // hasard passait avec une chance sur 2^32 au lieu de 2^128.
113
+ const decipher = crypto.createDecipheriv(this.ALGORITHM, encryptionKey, iv, {
114
+ authTagLength: this.TAG_BYTES,
115
+ });
116
+ decipher.setAuthTag(tag);
117
+ let decrypted = decipher.update(data, undefined, 'utf8');
75
118
  decrypted += decipher.final('utf8');
76
119
  return decrypted;
77
120
  }
@@ -82,3 +125,13 @@ class AesGcmUtil {
82
125
  }
83
126
  exports.AesGcmUtil = AesGcmUtil;
84
127
  AesGcmUtil.ALGORITHM = 'aes-256-gcm';
128
+ AesGcmUtil.KEY_BYTES = 32;
129
+ AesGcmUtil.IV_BYTES = 12;
130
+ AesGcmUtil.TAG_BYTES = 16;
131
+ /**
132
+ * Largest ciphertext `decrypt` accepts, in bytes (1 MiB). Checked on the hex
133
+ * string before any decoding: without it, a payload of hundreds of MB was
134
+ * decoded in full before GCM refused it. Our largest real payload is an
135
+ * agent key, 66 characters.
136
+ */
137
+ AesGcmUtil.MAX_CIPHERTEXT_BYTES = 1024 * 1024;
package/dist/jwt.util.js CHANGED
@@ -40,22 +40,37 @@ class PureJwtUtil {
40
40
  * Verifies a JWT's signature and expiration, then returns the decoded payload.
41
41
  */
42
42
  static verify(token, secret) {
43
+ assertSecret(secret);
43
44
  const parts = token.split('.');
44
45
  if (parts.length !== 3)
45
46
  throw new Error('Invalid token format');
46
47
  const [headerB64, payloadB64, signatureB64] = parts;
47
48
  const dataToVerify = `${headerB64}.${payloadB64}`;
48
- const expectedSignature = crypto
49
- .createHmac('sha256', secret)
50
- .update(dataToVerify)
51
- .digest('base64url');
52
- if (signatureB64 !== expectedSignature) {
49
+ const expected = crypto.createHmac('sha256', secret).update(dataToVerify).digest();
50
+ const received = Buffer.from(signatureB64, 'base64url');
51
+ // Comparaison en temps constant : `!==` s'arrête au premier caractère
52
+ // différent, et le temps de réponse renseigne sur la signature attendue.
53
+ // timingSafeEqual lance sur deux longueurs différentes, d'où le test avant.
54
+ // Le décodage base64url est tolérant : plusieurs écritures donnent les mêmes
55
+ // octets. Seule la forme canonique est admise, comme avec l'ancien `!==`.
56
+ if (received.toString('base64url') !== signatureB64 ||
57
+ received.length !== expected.length ||
58
+ !crypto.timingSafeEqual(received, expected)) {
53
59
  throw new Error('Invalid signature');
54
60
  }
55
- const payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString('utf8'));
56
- const payloadRecord = payload;
57
- if (typeof payloadRecord.exp === 'number') {
58
- if (Date.now() >= payloadRecord.exp * 1000) {
61
+ // L'en-tête n'était jamais lu : un jeton annonçant « none » ou un autre
62
+ // algorithme passait du moment que sa signature HS256 était juste.
63
+ const header = parseJsonObject(headerB64, 'Invalid token header');
64
+ if (header.alg !== 'HS256') {
65
+ throw new Error('Unsupported algorithm');
66
+ }
67
+ const payload = parseJsonObject(payloadB64, 'Invalid token payload');
68
+ // Un `exp` non numérique était ignoré : le jeton n'expirait jamais.
69
+ if (payload.exp !== undefined) {
70
+ if (typeof payload.exp !== 'number' || !Number.isFinite(payload.exp)) {
71
+ throw new Error('Invalid token payload');
72
+ }
73
+ if (Date.now() >= payload.exp * 1000) {
59
74
  throw new Error('Token expired');
60
75
  }
61
76
  }
@@ -65,6 +80,7 @@ class PureJwtUtil {
65
80
  * Signs a payload and returns a compact JWT string.
66
81
  */
67
82
  static sign(payload, secret) {
83
+ assertSecret(secret);
68
84
  const header = { alg: 'HS256', typ: 'JWT' };
69
85
  const encode = (obj) => Buffer.from(JSON.stringify(obj)).toString('base64url');
70
86
  const encodedHeader = encode(header);
@@ -77,3 +93,25 @@ class PureJwtUtil {
77
93
  }
78
94
  }
79
95
  exports.PureJwtUtil = PureJwtUtil;
96
+ /**
97
+ * createHmac signe avec une chaîne vide (mesuré sur le bot le 2026-10-06) : la
98
+ * signature attendue devient calculable par quiconque devine le secret vide.
99
+ */
100
+ function assertSecret(secret) {
101
+ if (typeof secret !== 'string' || secret.length === 0) {
102
+ throw new Error('Invalid secret: a non-empty string is required');
103
+ }
104
+ }
105
+ function parseJsonObject(segment, error) {
106
+ let value;
107
+ try {
108
+ value = JSON.parse(Buffer.from(segment, 'base64url').toString('utf8'));
109
+ }
110
+ catch {
111
+ throw new Error(error);
112
+ }
113
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
114
+ throw new Error(error);
115
+ }
116
+ return value;
117
+ }
package/package.json CHANGED
@@ -1,49 +1,58 @@
1
- {
2
- "name": "@syldel/crypto-utils",
3
- "version": "1.0.3",
4
- "description": "Lightweight JWT and AES-256-GCM utilities",
5
- "main": "dist/index.js",
6
- "types": "dist/index.d.ts",
7
- "files": [
8
- "dist"
9
- ],
10
- "scripts": {
11
- "lint": "eslint .",
12
- "lint:fix": "eslint . --fix",
13
- "clean": "rm -rf dist",
14
- "build": "npm run lint && npm run clean && tsc -p tsconfig.json",
15
- "test": "jest",
16
- "test:watch": "jest --watch",
17
- "prepublishOnly": "npm run build",
18
- "release": "npm run build && npm version patch && npm publish"
19
- },
20
- "publishConfig": {
21
- "access": "public"
22
- },
23
- "keywords": [
24
- "jwt",
25
- "aes",
26
- "crypto",
27
- "security",
28
- "nestjs"
29
- ],
30
- "author": "Syl. D.",
31
- "license": "ISC",
32
- "devDependencies": {
33
- "@eslint/js": "^10.0.1",
34
- "@types/jest": "^30.0.0",
35
- "eslint": "^10.0.1",
36
- "eslint-config-prettier": "^10.1.8",
37
- "eslint-plugin-jsonc": "^3.0.0",
38
- "eslint-plugin-prettier": "^5.5.5",
39
- "jest": "^30.2.0",
40
- "jsonc-eslint-parser": "^3.1.0",
41
- "prettier": "^3.8.1",
42
- "ts-jest": "^29.4.6",
43
- "typescript": "^5.9.3",
44
- "typescript-eslint": "^8.56.0"
45
- },
46
- "overrides": {
47
- "minimatch": "^10.2.1"
48
- }
49
- }
1
+ {
2
+ "name": "@syldel/crypto-utils",
3
+ "version": "1.1.0",
4
+ "description": "Lightweight JWT and AES-256-GCM utilities",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/Syldel/crypto-utils.git"
10
+ },
11
+ "bugs": {
12
+ "url": "https://github.com/Syldel/crypto-utils/issues"
13
+ },
14
+ "homepage": "https://github.com/Syldel/crypto-utils#readme",
15
+ "files": [
16
+ "dist"
17
+ ],
18
+ "scripts": {
19
+ "lint": "eslint .",
20
+ "lint:fix": "eslint . --fix",
21
+ "clean": "rm -rf dist",
22
+ "build": "npm run lint && npm run clean && tsc -p tsconfig.json",
23
+ "test": "jest",
24
+ "test:watch": "jest --watch",
25
+ "prepublishOnly": "npm run build",
26
+ "release": "npm run build && npm version patch && npm publish"
27
+ },
28
+ "publishConfig": {
29
+ "access": "public"
30
+ },
31
+ "keywords": [
32
+ "jwt",
33
+ "aes",
34
+ "crypto",
35
+ "security",
36
+ "nestjs"
37
+ ],
38
+ "author": "Syl. D.",
39
+ "license": "ISC",
40
+ "devDependencies": {
41
+ "@eslint/js": "^10.0.1",
42
+ "@types/jest": "^30.0.0",
43
+ "@types/node": "^20.19.43",
44
+ "eslint": "^10.0.1",
45
+ "eslint-config-prettier": "^10.1.8",
46
+ "eslint-plugin-jsonc": "^3.0.0",
47
+ "eslint-plugin-prettier": "^5.5.5",
48
+ "jest": "^30.2.0",
49
+ "jsonc-eslint-parser": "^3.1.0",
50
+ "prettier": "^3.8.1",
51
+ "ts-jest": "^29.4.6",
52
+ "typescript": "^5.9.3",
53
+ "typescript-eslint": "^8.56.0"
54
+ },
55
+ "overrides": {
56
+ "minimatch": "^10.2.1"
57
+ }
58
+ }