@syldel/crypto-utils 1.0.3 → 2.0.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
@@ -3,45 +3,73 @@
3
3
  A robust, high-performance cryptographic toolkit for Node.js and NestJS applications. This package prioritizes security by leveraging Node's native crypto module (OpenSSL) for maximum protection of sensitive data and private keys.
4
4
 
5
5
  ## 🚀 Key Features
6
+
6
7
  - **Native Security**: Built on top of Node.js crypto (OpenSSL) for hardware-accelerated encryption.
7
8
 
8
- - **AES-256-GCM**: Industry-standard authenticated encryption (AEAD) to ensure both confidentiality and data integrity.
9
+ - **JWE compact (RFC 7516)**: authenticated encryption with `alg: "dir"` and `enc: "A256GCM"` (AES-256-GCM), bound to a key id and a context, checked against the RFC 7520 test vector.
9
10
 
10
11
  - **Lightweight JWT**: Pure implementation for signing and verifying tokens without heavy dependencies.
11
12
 
12
- - **Standardized Error Handling**: Harmonized error messages ("Decryption failed") for better security and predictable testing.
13
+ - **Errors that say what is wrong, and nothing more**: configuration and public-format errors are named; anything about the encrypted content is a single `Decryption failed`.
13
14
 
14
15
  ## 🛠 Usage
15
16
 
16
- ### AES-256-GCM (Authenticated Encryption)
17
- Designed for high-security environments. It automatically handles Initialization Vectors (IV) and Authentication Tags.
17
+ ### JWE (Authenticated Encryption) — 2.0.0
18
18
 
19
- ```ts
20
- import { AesGcmUtil } from '@syldel/crypto-utils';
19
+ A value is sealed into a JWE compact string. Its protected header names the key (`kid`) and the
20
+ context it belongs to (`ctx`), and the whole header is authenticated: neither can be rewritten.
21
21
 
22
- const keyHex = "0123456789abcdef..."; // 64 hex characters (32 bytes)
23
- const message = "Sensitive data";
22
+ ```ts
23
+ import { JweUtil } from '@syldel/crypto-utils';
24
+
25
+ // Sealing: one key, named by its kid, and the context the value belongs to.
26
+ const token = JweUtil.seal(agentPrivateKey, {
27
+ kid: '2026-11',
28
+ key: masterKeyHex, // 32 bytes, as a Buffer or 64 hex characters
29
+ context: `hl-agent-key:${userId}`,
30
+ });
31
+ // eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIiwia2lkIjoiMjAyNi0xMSIs... (5 segments, the 2nd empty)
32
+
33
+ // Opening: every known key by kid, read and checked once at startup.
34
+ const keys = JweUtil.parseKeyring(process.env.AGENT_ENCRYPTION_KEYS); // "2026-11:<hex>,2025-02:<hex>"
35
+ const secret = JweUtil.open(token, { keys, context: `hl-agent-key:${userId}` });
36
+ ```
24
37
 
25
- // Encrypt
26
- const encrypted = AesGcmUtil.encrypt(message, keyHex);
27
- // Returns: { data: '...', iv: '...', tag: '...' }
38
+ Protected header, byte for byte — no space, this key order, nothing more; anything else is refused:
28
39
 
29
- // Decrypt
30
- try {
31
- const original = AesGcmUtil.decrypt(encrypted, keyHex);
32
- } catch (error) {
33
- // Throws "Decryption failed" if data is tampered or key is wrong
34
- }
40
+ ```text
41
+ {"alg":"dir","enc":"A256GCM","kid":"2026-11","ctx":"hl-agent-key:<userId>","crit":["ctx"]}
35
42
  ```
36
43
 
44
+ | Error | Meaning |
45
+ | -------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
46
+ | `Invalid key …`, `Invalid kid …`, `Invalid context …`, `Invalid keyring …` | configuration of the caller; never quotes a key |
47
+ | `Unsupported format: …` | not a JWE string, e.g. the 1.x `{ data, iv, tag }` object |
48
+ | `Invalid JWE: …` | the public structure or the protected header is not the expected one |
49
+ | `Unknown key id: "…"` | no key with that kid in the keyring |
50
+ | `Decryption failed` | anything about the encrypted content, or the wrong key under the right kid |
51
+ | `Context mismatch: sealed for "…", expected "…"` | authenticated, so it states a fact: the value belongs to someone else |
52
+
53
+ #### Rotating a key
54
+
55
+ 1. Add the new key to the keyring of every service that opens, and deploy.
56
+ 2. Seal again with the new kid.
57
+ 3. Remove the old key from the keyring.
58
+
59
+ #### Migrating from 1.x
60
+
61
+ `AesGcmUtil.encrypt`/`decrypt` and `EncryptedData` are removed. A 1.x object is refused by name
62
+ (`Unsupported format: { data, iv, tag } is the crypto-utils 1.x format`): seal the value again.
63
+
37
64
  ### Pure JWT Utilities
65
+
38
66
  A lightweight way to handle JWTs in your NestJS guards or services.
39
67
 
40
68
  ```ts
41
69
  import { PureJwtUtil } from '@syldel/crypto-utils';
42
70
 
43
- const secret = "your-secure-secret";
44
- const payload = { sub: "user_123", role: "admin" };
71
+ const secret = 'your-secure-secret';
72
+ const payload = { sub: 'user_123', role: 'admin' };
45
73
 
46
74
  // Sign a token
47
75
  const token = PureJwtUtil.sign(payload, secret);
@@ -58,14 +86,43 @@ try {
58
86
 
59
87
  - **Zero External Crypto Libs**: By using native Node.js APIs, this package minimizes the attack surface and dependency supply chain risks.
60
88
 
61
- - **Side-Channel Protection**: Leverages OpenSSL's C++ implementation to protect against timing attacks.
89
+ - **Side-Channel Protection**: JWT signatures are compared with `crypto.timingSafeEqual`, in constant time.
90
+
91
+ - **GCM Integrity**: Unlike AES-CTR or CBC, AES-GCM ensures that any single-bit modification to the encrypted data, the IV or the protected header will be detected during decryption.
92
+
93
+ ### ⚠️ What is refused, and why
62
94
 
63
- - **GCM Integrity**: Unlike AES-CTR or CBC, AES-GCM ensures that any single-bit modification to the encrypted data will be detected during decryption.
95
+ - `JweUtil.open` requires a 12-byte IV and a full 16-byte tag (RFC 7518 §5.3). 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.
96
+ - Every base64url segment and every hex key is decoded strictly and canonically: a stray character fails instead of being silently skipped.
97
+ - The protected header must be byte for byte the one `seal` writes: no extra field (`zip`, `jku`…), no duplicated field (which `JSON.parse` would silently overwrite), no token without a context.
98
+ - The key is chosen by its kid, never by trying every key: AES-GCM does not commit to its key, so one ciphertext can open under two keys.
99
+ - A kid is looked up as an own property only: `constructor` is not a key.
100
+ - `open` refuses a token above 2 MiB, `seal` a plaintext above `JweUtil.MAX_PLAINTEXT_BYTES` (1 MiB), before any decoding.
101
+ - 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.
102
+ - `PureJwtUtil.verify` only accepts `alg: "HS256"`, a JSON object payload, a numeric `exp`, and the canonical base64url spelling of the signature.
103
+ - Both `sign` and `verify` refuse an empty or missing secret: HMAC happily signs with an empty key, which anyone can guess.
104
+ - `nbf`, `iat`, `aud` and `iss` are **not** checked: callers that rely on them must check them themselves.
64
105
 
106
+ ### ⚠️ Known limit: the master key lives in plain text
107
+
108
+ Tink advises against keeping keys in plain text and recommends a key encryption key in a KMS
109
+ (Google Cloud KMS, AWS KMS, HashiCorp Vault). Here the keyring is an environment variable of each
110
+ service: whoever reads that environment reads the keys. A KMS is out of scope for now.
111
+
112
+ ### Sources, read raw on 2026-11-02
113
+
114
+ - RFC 7516 (JWE) §5.1 step 14: the AAD is the ASCII of the encoded protected header; §4.1.6 `kid`.
115
+ - RFC 7518 (JWA) §4.5 `dir`, §5.3 AES-GCM (96-bit IV required, 128-bit tag), §8.4 at most 2^32 encryptions per key.
116
+ - RFC 7515 (JWS) §4.1.11 `crit`, which RFC 7516 §4.1.13 applies to JWE.
117
+ - RFC 7520 §5.6, the `dir` + AES-GCM test vector the tests reproduce in both directions.
118
+ - Tink, _Keysets_: rotation order (add, deploy, then make primary); the key-id prefix is not authenticated, only a hint.
119
+ - Tink, _AEAD_: a user id as associated data stops a value from moving to another user; AES-GCM is not key-committing.
120
+ - Tink, _Key management_: keys should not be stored in plain text (see the known limit above).
121
+ - AWS Encryption SDK specification, `framework/structures.md`: the encryption context is non-secret, canonically serialized, and authenticated.
65
122
 
66
123
  ## 🧪 Testing
67
124
 
68
- Full test suite with 100% coverage on core crypto logic.
125
+ Full test suite with 100% coverage, including the RFC 7520 §5.6 vector and a reproducible fuzz of 3000 tampered tokens.
69
126
 
70
127
  ```bash
71
128
  npm run test
@@ -74,18 +131,23 @@ npm run test
74
131
  ## 👨‍💻 Développement
75
132
 
76
133
  ### Installation des dépendances
134
+
77
135
  ```bash
78
136
  npm install
79
137
  ```
80
138
 
81
139
  ### Compilation
140
+
82
141
  Génère le dossier `dist/` contenant les fichiers JavaScript et les déclarations de types (`.d.ts`).
142
+
83
143
  ```bash
84
144
  npm run build
85
145
  ```
86
146
 
87
147
  ### Qualité du code
148
+
88
149
  Le projet utilise **ESLint** pour la logique et **Prettier** pour le formatage.
150
+
89
151
  ```bash
90
152
  # Vérifier les erreurs
91
153
  npx eslint .
@@ -109,17 +171,21 @@ Pour utiliser ce package dans tes autres projets sans le publier sur NPM :
109
171
  Le projet utilise des **Granular Access Tokens** pour la publication afin de contourner la double authentification (2FA) manuelle tout en maintenant une sécurité maximale.
110
172
 
111
173
  ### Configuration du Token
174
+
112
175
  1. Générer un token sur NPM avec les permissions `Read and Write`.
113
176
  2. Restreindre l'accès au package `@syldel/crypto-utils` uniquement.
114
177
  3. Utiliser l'option `Bypass 2FA` pour permettre l'automatisation.
115
178
 
116
179
  ### Commande de publication rapide
180
+
117
181
  Si tu n'utilises pas de fichier `.npmrc`, tu peux publier en passant le token directement :
182
+
118
183
  ```bash
119
184
  npm publish --access public --//registry.npmjs.org/:_authToken=TON_TOKEN_ICI
120
185
  ```
121
186
 
122
187
  ### Configuration de la publication
188
+
123
189
  Créez un fichier .npmrc à la racine (ignoré par Git) pour l'authentification :
124
190
 
125
191
  ```
@@ -127,6 +193,7 @@ Créez un fichier .npmrc à la racine (ignoré par Git) pour l'authentification
127
193
  ```
128
194
 
129
195
  ### Publier une nouvelle version
196
+
130
197
  La commande suivante automatise le build, l'incrémentation de version et l'envoi vers NPM :
131
198
 
132
199
  ```bash
@@ -134,6 +201,7 @@ npm run release
134
201
  ```
135
202
 
136
203
  ### Pousse le commit ET le tag sur GitHub
204
+
137
205
  ```bash
138
206
  git push origin main --follow-tags
139
207
  ```
package/dist/index.d.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  export * from './jwt.util';
2
- export * from './aes-gcm.util';
2
+ export * from './jwe.util';
package/dist/index.js CHANGED
@@ -15,4 +15,4 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
15
15
  };
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
17
  __exportStar(require("./jwt.util"), exports);
18
- __exportStar(require("./aes-gcm.util"), exports);
18
+ __exportStar(require("./jwe.util"), exports);
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Cœur JWE (RFC 7516), sérialisation compacte, algorithme `dir` uniquement :
3
+ * la clé partagée sert directement de clé de contenu (RFC 7518 §4.5), la clé
4
+ * chiffrée est donc vide. Ce module ne décide d'aucune politique (quel en-tête,
5
+ * quelle clé, quel contexte) : c'est le rôle de `JweUtil`. Il est séparé pour
6
+ * être éprouvé tel quel contre le vecteur de la RFC 7520 §5.6, qui utilise
7
+ * A128GCM. Il n'est pas exporté par le paquet.
8
+ */
9
+ export type ContentEncryption = 'A128GCM' | 'A256GCM';
10
+ /** RFC 7518 §5.3 : `enc` est fixé par la taille de la clé, jamais lu seul. */
11
+ export declare const ENC_BY_KEY_BYTES: Readonly<Record<number, ContentEncryption>>;
12
+ /** RFC 7518 §5.3 : IV de 96 bits REQUIS, tag de 128 bits quelle que soit la clé. */
13
+ export declare const IV_BYTES = 12;
14
+ export declare const TAG_BYTES = 16;
15
+ export interface CompactParts {
16
+ /** Le segment d'en-tête tel que reçu : c'est lui, et non une re-sérialisation, qui fait l'AAD. */
17
+ protectedHeader: string;
18
+ /** Le JSON de l'en-tête, décodé mais pas encore authentifié. */
19
+ headerJson: string;
20
+ header: Record<string, unknown>;
21
+ iv: string;
22
+ ciphertext: string;
23
+ tag: string;
24
+ }
25
+ /**
26
+ * base64url strict et canonique. `Buffer.from(s, 'base64url')` saute les
27
+ * caractères invalides sans rien dire et accepte des bits de remplissage non
28
+ * nuls : deux écritures différentes donnaient le même octet, et un segment
29
+ * abîmé était lu à moitié.
30
+ */
31
+ export declare function base64urlDecode(segment: string): Buffer;
32
+ /**
33
+ * Découpe et lit la structure, et lève `Invalid JWE: …`. Tout ce qui est
34
+ * vérifié ici est public :
35
+ * l'en-tête n'est pas chiffré, et rien de ce qui échoue ici ne dit quoi que
36
+ * ce soit sur la clé ou le clair.
37
+ */
38
+ export declare function parseCompact(token: string): CompactParts;
39
+ /**
40
+ * Chiffre `plaintext` sous `header` (RFC 7516 §5.1). L'IV n'est passé que par
41
+ * les tests, pour reproduire le vecteur de la RFC 7520 ; partout ailleurs il
42
+ * est tiré au hasard.
43
+ */
44
+ export declare function encryptDir(plaintext: string, key: Buffer, header: Readonly<Record<string, unknown>>, iv?: Buffer): string;
45
+ /**
46
+ * Déchiffre des segments déjà découpés. Toute erreur ici concerne le contenu
47
+ * chiffré : l'appelant la rend sous un message unique.
48
+ */
49
+ export declare function decryptDir(parts: CompactParts, key: Buffer): string;
@@ -0,0 +1,153 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.TAG_BYTES = exports.IV_BYTES = exports.ENC_BY_KEY_BYTES = void 0;
37
+ exports.base64urlDecode = base64urlDecode;
38
+ exports.parseCompact = parseCompact;
39
+ exports.encryptDir = encryptDir;
40
+ exports.decryptDir = decryptDir;
41
+ const crypto = __importStar(require("crypto"));
42
+ /** RFC 7518 §5.3 : `enc` est fixé par la taille de la clé, jamais lu seul. */
43
+ exports.ENC_BY_KEY_BYTES = {
44
+ 16: 'A128GCM',
45
+ 32: 'A256GCM',
46
+ };
47
+ /** RFC 7518 §5.3 : IV de 96 bits REQUIS, tag de 128 bits quelle que soit la clé. */
48
+ exports.IV_BYTES = 12;
49
+ exports.TAG_BYTES = 16;
50
+ /**
51
+ * base64url strict et canonique. `Buffer.from(s, 'base64url')` saute les
52
+ * caractères invalides sans rien dire et accepte des bits de remplissage non
53
+ * nuls : deux écritures différentes donnaient le même octet, et un segment
54
+ * abîmé était lu à moitié.
55
+ */
56
+ function base64urlDecode(segment) {
57
+ if (!/^[A-Za-z0-9_-]*$/.test(segment)) {
58
+ throw new Error('not base64url');
59
+ }
60
+ const bytes = Buffer.from(segment, 'base64url');
61
+ if (bytes.toString('base64url') !== segment) {
62
+ throw new Error('not canonical base64url');
63
+ }
64
+ return bytes;
65
+ }
66
+ /**
67
+ * Découpe et lit la structure, et lève `Invalid JWE: …`. Tout ce qui est
68
+ * vérifié ici est public :
69
+ * l'en-tête n'est pas chiffré, et rien de ce qui échoue ici ne dit quoi que
70
+ * ce soit sur la clé ou le clair.
71
+ */
72
+ function parseCompact(token) {
73
+ const segments = token.split('.');
74
+ if (segments.length !== 5) {
75
+ throw new Error(`Invalid JWE: expected 5 segments, got ${segments.length}`);
76
+ }
77
+ const [protectedHeader, encryptedKey, iv, ciphertext, tag] = segments;
78
+ if (encryptedKey !== '') {
79
+ throw new Error('Invalid JWE: the encrypted key must be empty with alg "dir"');
80
+ }
81
+ let headerJson;
82
+ let header;
83
+ try {
84
+ headerJson = base64urlDecode(protectedHeader).toString('utf8');
85
+ header = JSON.parse(headerJson);
86
+ }
87
+ catch {
88
+ throw new Error('Invalid JWE: unreadable protected header');
89
+ }
90
+ if (typeof header !== 'object' || header === null || Array.isArray(header)) {
91
+ throw new Error('Invalid JWE: the protected header is not a JSON object');
92
+ }
93
+ return {
94
+ protectedHeader,
95
+ headerJson,
96
+ header: header,
97
+ iv,
98
+ ciphertext,
99
+ tag,
100
+ };
101
+ }
102
+ /**
103
+ * Chiffre `plaintext` sous `header` (RFC 7516 §5.1). L'IV n'est passé que par
104
+ * les tests, pour reproduire le vecteur de la RFC 7520 ; partout ailleurs il
105
+ * est tiré au hasard.
106
+ */
107
+ function encryptDir(plaintext, key, header, iv = crypto.randomBytes(exports.IV_BYTES)) {
108
+ const enc = exports.ENC_BY_KEY_BYTES[key.length];
109
+ if (header.alg !== 'dir' || enc === undefined || header.enc !== enc) {
110
+ throw new Error('header and key do not match alg "dir" with A128GCM or A256GCM');
111
+ }
112
+ if (iv.length !== exports.IV_BYTES) {
113
+ throw new Error(`the IV must be ${exports.IV_BYTES} bytes`);
114
+ }
115
+ const protectedHeader = Buffer.from(JSON.stringify(header), 'utf8').toString('base64url');
116
+ const cipher = crypto.createCipheriv(enc === 'A128GCM' ? 'aes-128-gcm' : 'aes-256-gcm', key, iv, {
117
+ authTagLength: exports.TAG_BYTES,
118
+ });
119
+ // RFC 7516 §5.1 étape 14 : l'AAD est l'ASCII du segment d'en-tête encodé.
120
+ cipher.setAAD(Buffer.from(protectedHeader, 'ascii'));
121
+ const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
122
+ return [
123
+ protectedHeader,
124
+ '',
125
+ iv.toString('base64url'),
126
+ ciphertext.toString('base64url'),
127
+ cipher.getAuthTag().toString('base64url'),
128
+ ].join('.');
129
+ }
130
+ /**
131
+ * Déchiffre des segments déjà découpés. Toute erreur ici concerne le contenu
132
+ * chiffré : l'appelant la rend sous un message unique.
133
+ */
134
+ function decryptDir(parts, key) {
135
+ const enc = exports.ENC_BY_KEY_BYTES[key.length];
136
+ if (parts.header.alg !== 'dir' || enc === undefined || parts.header.enc !== enc) {
137
+ throw new Error('header and key do not match');
138
+ }
139
+ const iv = base64urlDecode(parts.iv);
140
+ const tag = base64urlDecode(parts.tag);
141
+ const ciphertext = base64urlDecode(parts.ciphertext);
142
+ // Sans ces deux contrôles et `authTagLength`, Node accepte un tag de 4 à 16
143
+ // octets : mesuré sur crypto-utils 1.0.3, un tag tronqué déchiffrait.
144
+ if (iv.length !== exports.IV_BYTES || tag.length !== exports.TAG_BYTES) {
145
+ throw new Error('wrong IV or tag length');
146
+ }
147
+ const decipher = crypto.createDecipheriv(enc === 'A128GCM' ? 'aes-128-gcm' : 'aes-256-gcm', key, iv, {
148
+ authTagLength: exports.TAG_BYTES,
149
+ });
150
+ decipher.setAAD(Buffer.from(parts.protectedHeader, 'ascii'));
151
+ decipher.setAuthTag(tag);
152
+ return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8');
153
+ }
@@ -0,0 +1,53 @@
1
+ /** Une clé de 32 octets, en Buffer ou en 64 caractères hexadécimaux. */
2
+ export type JweKey = Buffer | string;
3
+ /** Les clés connues, par identifiant (`kid`). */
4
+ export type JweKeyring = Readonly<Record<string, JweKey>>;
5
+ export interface SealOptions {
6
+ /** Identifiant de la clé, écrit dans l'en-tête : `2026-11`, `session`. */
7
+ kid: string;
8
+ key: JweKey;
9
+ /** Ce à quoi le clair est lié, par exemple `hl-agent-key:<userId>`. Non secret. */
10
+ context: string;
11
+ }
12
+ export interface OpenOptions {
13
+ keys: JweKeyring;
14
+ /** Le contexte attendu : un JWE scellé pour un autre est refusé. */
15
+ context: string;
16
+ }
17
+ /**
18
+ * Chiffrement authentifié au format JWE compact (RFC 7516), `alg: "dir"` et
19
+ * `enc: "A256GCM"` (RFC 7518), sans dépendance.
20
+ *
21
+ * L'en-tête protégé porte le `kid` et le contexte (`ctx`), déclaré critique
22
+ * (`crit`, RFC 7515 §4.1.11) : une bibliothèque JOSE qui ne le connaît pas doit
23
+ * refuser le jeton au lieu d'ignorer le contrôle. L'en-tête entier est l'AAD,
24
+ * donc ni le `kid` ni le contexte ne se réécrivent sans casser le tag —
25
+ * contrairement au préfixe de clé de Tink, que sa doc dit non authentifié.
26
+ *
27
+ * Erreurs : ce qui concerne la configuration (`Invalid key`, `Invalid kid`,
28
+ * `Invalid context`, `Invalid keyring`) et la structure publique du jeton
29
+ * (`Invalid JWE`, `Unsupported format`, `Unknown key id`) se nomme. Tout ce
30
+ * qui touche au contenu chiffré reste un seul `Decryption failed`. Un contexte
31
+ * différent ne se signale qu'après authentification : le message dit alors un
32
+ * fait vérifié, pas ce que prétend un en-tête forgé.
33
+ */
34
+ export declare class JweUtil {
35
+ /** Plus grand clair accepté (1 Mio), et donc plus grand jeton lu, avant tout décodage. */
36
+ static readonly MAX_PLAINTEXT_BYTES: number;
37
+ static seal(plaintext: string, { kid, key, context }: SealOptions): string;
38
+ static open(token: string, { keys, context }: OpenOptions): string;
39
+ /**
40
+ * Lit un trousseau écrit `kid:hex,kid:hex` (une variable d'environnement) et
41
+ * le valide entièrement : à appeler au démarrage, pour qu'un trousseau
42
+ * absent ou abîmé arrête le service au lieu du premier déchiffrement.
43
+ */
44
+ static parseKeyring(raw: string | undefined): JweKeyring;
45
+ private static header;
46
+ private static assertKid;
47
+ private static assertContext;
48
+ /**
49
+ * Une clé de 32 octets, en Buffer ou en hexadécimal strict. Le message ne
50
+ * cite jamais la clé.
51
+ */
52
+ private static toKey;
53
+ }
@@ -0,0 +1,138 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.JweUtil = void 0;
4
+ const jwe_compact_1 = require("./jwe-compact");
5
+ const KEY_BYTES = 32;
6
+ const KID_PATTERN = /^[a-z0-9-]{1,32}$/;
7
+ const MAX_CONTEXT_LENGTH = 256;
8
+ /**
9
+ * Chiffrement authentifié au format JWE compact (RFC 7516), `alg: "dir"` et
10
+ * `enc: "A256GCM"` (RFC 7518), sans dépendance.
11
+ *
12
+ * L'en-tête protégé porte le `kid` et le contexte (`ctx`), déclaré critique
13
+ * (`crit`, RFC 7515 §4.1.11) : une bibliothèque JOSE qui ne le connaît pas doit
14
+ * refuser le jeton au lieu d'ignorer le contrôle. L'en-tête entier est l'AAD,
15
+ * donc ni le `kid` ni le contexte ne se réécrivent sans casser le tag —
16
+ * contrairement au préfixe de clé de Tink, que sa doc dit non authentifié.
17
+ *
18
+ * Erreurs : ce qui concerne la configuration (`Invalid key`, `Invalid kid`,
19
+ * `Invalid context`, `Invalid keyring`) et la structure publique du jeton
20
+ * (`Invalid JWE`, `Unsupported format`, `Unknown key id`) se nomme. Tout ce
21
+ * qui touche au contenu chiffré reste un seul `Decryption failed`. Un contexte
22
+ * différent ne se signale qu'après authentification : le message dit alors un
23
+ * fait vérifié, pas ce que prétend un en-tête forgé.
24
+ */
25
+ class JweUtil {
26
+ static seal(plaintext, { kid, key, context }) {
27
+ this.assertKid(kid);
28
+ this.assertContext(context);
29
+ const contentKey = this.toKey(key, 'Invalid key');
30
+ if (typeof plaintext !== 'string') {
31
+ throw new Error('Invalid plaintext: a string is required');
32
+ }
33
+ if (Buffer.byteLength(plaintext, 'utf8') > this.MAX_PLAINTEXT_BYTES) {
34
+ throw new Error('Invalid plaintext: above the size limit');
35
+ }
36
+ return (0, jwe_compact_1.encryptDir)(plaintext, contentKey, this.header(kid, context));
37
+ }
38
+ static open(token, { keys, context }) {
39
+ this.assertContext(context);
40
+ if (typeof token !== 'string') {
41
+ // Le format 1.x était un objet { data, iv, tag } : il se refuse en le
42
+ // nommant, pour que la cause se lise sans ouvrir le code.
43
+ const legacy = typeof token === 'object' && token !== null && 'data' in token && 'iv' in token;
44
+ throw new Error(legacy
45
+ ? 'Unsupported format: { data, iv, tag } is the crypto-utils 1.x format; seal the value again'
46
+ : 'Unsupported format: a JWE compact string is required');
47
+ }
48
+ // base64url grossit de 4/3 : deux fois la limite couvre l'en-tête et la marge.
49
+ if (token.length > 2 * this.MAX_PLAINTEXT_BYTES) {
50
+ throw new Error('Invalid JWE: above the size limit');
51
+ }
52
+ const parts = (0, jwe_compact_1.parseCompact)(token);
53
+ // L'en-tête doit être exactement celui que `seal` écrit, octet pour octet :
54
+ // ni champ en plus (`zip`, `jku`…), ni doublon que JSON.parse aurait
55
+ // écrasé en silence, ni autre ordre ou espace.
56
+ const { kid, ctx } = parts.header;
57
+ if (typeof kid !== 'string' ||
58
+ typeof ctx !== 'string' ||
59
+ parts.headerJson !== JSON.stringify(this.header(kid, ctx))) {
60
+ throw new Error('Invalid JWE: the protected header must be exactly {alg:"dir", enc:"A256GCM", kid, ctx, crit:["ctx"]}');
61
+ }
62
+ // Une clé se choisit par son `kid`, jamais en les essayant toutes : GCM
63
+ // n'engage pas la clé, un chiffré peut s'ouvrir sous deux clés (Tink, page AEAD).
64
+ if (!Object.prototype.hasOwnProperty.call(keys, kid)) {
65
+ throw new Error(`Unknown key id: ${JSON.stringify(kid.slice(0, 64))}`);
66
+ }
67
+ const contentKey = this.toKey(keys[kid], `Invalid key ${JSON.stringify(kid)}`);
68
+ let plaintext;
69
+ try {
70
+ plaintext = (0, jwe_compact_1.decryptDir)(parts, contentKey);
71
+ }
72
+ catch {
73
+ throw new Error('Decryption failed');
74
+ }
75
+ if (ctx !== context) {
76
+ throw new Error(`Context mismatch: sealed for ${JSON.stringify(ctx)}, expected ${JSON.stringify(context)}`);
77
+ }
78
+ return plaintext;
79
+ }
80
+ /**
81
+ * Lit un trousseau écrit `kid:hex,kid:hex` (une variable d'environnement) et
82
+ * le valide entièrement : à appeler au démarrage, pour qu'un trousseau
83
+ * absent ou abîmé arrête le service au lieu du premier déchiffrement.
84
+ */
85
+ static parseKeyring(raw) {
86
+ if (typeof raw !== 'string' || raw.trim() === '') {
87
+ throw new Error('Invalid keyring: missing or empty (is the secret configured?)');
88
+ }
89
+ const keyring = Object.create(null);
90
+ for (const entry of raw.split(',')) {
91
+ const separator = entry.indexOf(':');
92
+ const kid = separator === -1 ? '' : entry.slice(0, separator).trim();
93
+ if (!KID_PATTERN.test(kid)) {
94
+ throw new Error('Invalid keyring: each entry must read kid:hex, kid matching [a-z0-9-]{1,32}');
95
+ }
96
+ if (kid in keyring) {
97
+ throw new Error(`Invalid keyring: key id ${JSON.stringify(kid)} appears twice`);
98
+ }
99
+ keyring[kid] = this.toKey(entry.slice(separator + 1).trim(), `Invalid keyring: key ${JSON.stringify(kid)}`);
100
+ }
101
+ return Object.freeze(keyring);
102
+ }
103
+ static header(kid, ctx) {
104
+ return { alg: 'dir', enc: 'A256GCM', kid, ctx, crit: ['ctx'] };
105
+ }
106
+ static assertKid(kid) {
107
+ if (typeof kid !== 'string' || !KID_PATTERN.test(kid)) {
108
+ throw new Error('Invalid kid: it must match [a-z0-9-]{1,32}');
109
+ }
110
+ }
111
+ static assertContext(context) {
112
+ if (typeof context !== 'string' ||
113
+ context.length === 0 ||
114
+ context.length > MAX_CONTEXT_LENGTH) {
115
+ throw new Error(`Invalid context: a non-empty string of at most ${MAX_CONTEXT_LENGTH} characters is required`);
116
+ }
117
+ }
118
+ /**
119
+ * Une clé de 32 octets, en Buffer ou en hexadécimal strict. Le message ne
120
+ * cite jamais la clé.
121
+ */
122
+ static toKey(key, label) {
123
+ if (key === undefined || key === null || key.length === 0) {
124
+ throw new Error(`${label}: missing or empty (is the secret configured?)`);
125
+ }
126
+ if (typeof key === 'string' && (key.length % 2 !== 0 || !/^[0-9a-fA-F]*$/.test(key))) {
127
+ throw new Error(`${label}: not a hex string`);
128
+ }
129
+ const bytes = typeof key === 'string' ? Buffer.from(key, 'hex') : key;
130
+ if (!Buffer.isBuffer(bytes) || bytes.length !== KEY_BYTES) {
131
+ throw new Error(`${label}: must be 32 bytes (64 hex characters)`);
132
+ }
133
+ return bytes;
134
+ }
135
+ }
136
+ exports.JweUtil = JweUtil;
137
+ /** Plus grand clair accepté (1 Mio), et donc plus grand jeton lu, avant tout décodage. */
138
+ JweUtil.MAX_PLAINTEXT_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,59 @@
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": "2.0.0",
4
+ "description": "Lightweight JWT and JWE (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
+ "jwe",
34
+ "aes",
35
+ "crypto",
36
+ "security",
37
+ "nestjs"
38
+ ],
39
+ "author": "Syl. D.",
40
+ "license": "ISC",
41
+ "devDependencies": {
42
+ "@eslint/js": "^10.0.1",
43
+ "@types/jest": "^30.0.0",
44
+ "@types/node": "^24.19.2",
45
+ "eslint": "^10.0.1",
46
+ "eslint-config-prettier": "^10.1.8",
47
+ "eslint-plugin-jsonc": "^3.0.0",
48
+ "eslint-plugin-prettier": "^5.5.5",
49
+ "jest": "^30.2.0",
50
+ "jsonc-eslint-parser": "^3.1.0",
51
+ "prettier": "^3.8.1",
52
+ "ts-jest": "^29.4.6",
53
+ "typescript": "^5.9.3",
54
+ "typescript-eslint": "^8.56.0"
55
+ },
56
+ "overrides": {
57
+ "minimatch": "^10.2.1"
58
+ }
59
+ }
@@ -1,17 +0,0 @@
1
- export interface EncryptedData {
2
- data: string;
3
- iv: string;
4
- tag: string;
5
- }
6
- export declare class AesGcmUtil {
7
- private static readonly ALGORITHM;
8
- private static validateKey;
9
- /**
10
- * Encrypts text using native Node.js crypto (AES-256-GCM)
11
- */
12
- static encrypt(text: string, key: Buffer | string): EncryptedData;
13
- /**
14
- * Decrypts a payload using native Node.js crypto (AES-256-GCM)
15
- */
16
- static decrypt(payload: EncryptedData, key: Buffer | string): string;
17
- }
@@ -1,84 +0,0 @@
1
- "use strict";
2
- var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
- if (k2 === undefined) k2 = k;
4
- var desc = Object.getOwnPropertyDescriptor(m, k);
5
- if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
- desc = { enumerable: true, get: function() { return m[k]; } };
7
- }
8
- Object.defineProperty(o, k2, desc);
9
- }) : (function(o, m, k, k2) {
10
- if (k2 === undefined) k2 = k;
11
- o[k2] = m[k];
12
- }));
13
- var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
- Object.defineProperty(o, "default", { enumerable: true, value: v });
15
- }) : function(o, v) {
16
- o["default"] = v;
17
- });
18
- var __importStar = (this && this.__importStar) || (function () {
19
- var ownKeys = function(o) {
20
- ownKeys = Object.getOwnPropertyNames || function (o) {
21
- var ar = [];
22
- for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
- return ar;
24
- };
25
- return ownKeys(o);
26
- };
27
- return function (mod) {
28
- if (mod && mod.__esModule) return mod;
29
- var result = {};
30
- if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
- __setModuleDefault(result, mod);
32
- return result;
33
- };
34
- })();
35
- Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.AesGcmUtil = void 0;
37
- const crypto = __importStar(require("crypto"));
38
- class AesGcmUtil {
39
- 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)');
43
- }
44
- return encryptionKey;
45
- }
46
- /**
47
- * Encrypts text using native Node.js crypto (AES-256-GCM)
48
- */
49
- static encrypt(text, key) {
50
- try {
51
- const encryptionKey = this.validateKey(key);
52
- const iv = crypto.randomBytes(12);
53
- const cipher = crypto.createCipheriv(this.ALGORITHM, encryptionKey, iv);
54
- let encrypted = cipher.update(text, 'utf8', 'hex');
55
- encrypted += cipher.final('hex');
56
- return {
57
- data: encrypted,
58
- iv: iv.toString('hex'),
59
- tag: cipher.getAuthTag().toString('hex'),
60
- };
61
- }
62
- catch {
63
- throw new Error('Encryption failed');
64
- }
65
- }
66
- /**
67
- * Decrypts a payload using native Node.js crypto (AES-256-GCM)
68
- */
69
- static decrypt(payload, key) {
70
- 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');
75
- decrypted += decipher.final('utf8');
76
- return decrypted;
77
- }
78
- catch {
79
- throw new Error('Decryption failed');
80
- }
81
- }
82
- }
83
- exports.AesGcmUtil = AesGcmUtil;
84
- AesGcmUtil.ALGORITHM = 'aes-256-gcm';