@syldel/crypto-utils 1.1.0 → 3.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/README.md CHANGED
@@ -3,46 +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
- // "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
35
- }
40
+ ```text
41
+ {"alg":"dir","enc":"A256GCM","kid":"2026-11","ctx":"hl-agent-key:<userId>","crit":["ctx"]}
36
42
  ```
37
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
+
38
64
  ### Pure JWT Utilities
65
+
39
66
  A lightweight way to handle JWTs in your NestJS guards or services.
40
67
 
41
68
  ```ts
42
69
  import { PureJwtUtil } from '@syldel/crypto-utils';
43
70
 
44
- const secret = "your-secure-secret";
45
- const payload = { sub: "user_123", role: "admin" };
71
+ const secret = 'your-secure-secret';
72
+ const payload = { sub: 'user_123', role: 'admin' };
46
73
 
47
74
  // Sign a token
48
75
  const token = PureJwtUtil.sign(payload, secret);
@@ -61,22 +88,41 @@ try {
61
88
 
62
89
  - **Side-Channel Protection**: JWT signatures are compared with `crypto.timingSafeEqual`, in constant time.
63
90
 
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.
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.
65
92
 
66
93
  ### ⚠️ What is refused, and why
67
94
 
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.
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.
72
102
  - `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.
103
+ - Both `sign` and `verify` require a secret of exactly 64 hex characters, the form `crypto.randomBytes(32).toString('hex')` produces: 256 bits of randomness, the HS256 output size (OWASP JWT Cheat Sheet, RFC 8725 §3.5). HMAC happily signs with an empty key or a passphrase, which anyone can guess, and a length alone does not prove randomness. The secret is used as is, its 64 ASCII bytes, not decoded.
74
104
  - `nbf`, `iat`, `aud` and `iss` are **not** checked: callers that rely on them must check them themselves.
75
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.
76
122
 
77
123
  ## 🧪 Testing
78
124
 
79
- 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.
80
126
 
81
127
  ```bash
82
128
  npm run test
@@ -85,18 +131,23 @@ npm run test
85
131
  ## 👨‍💻 Développement
86
132
 
87
133
  ### Installation des dépendances
134
+
88
135
  ```bash
89
136
  npm install
90
137
  ```
91
138
 
92
139
  ### Compilation
140
+
93
141
  Génère le dossier `dist/` contenant les fichiers JavaScript et les déclarations de types (`.d.ts`).
142
+
94
143
  ```bash
95
144
  npm run build
96
145
  ```
97
146
 
98
147
  ### Qualité du code
148
+
99
149
  Le projet utilise **ESLint** pour la logique et **Prettier** pour le formatage.
150
+
100
151
  ```bash
101
152
  # Vérifier les erreurs
102
153
  npx eslint .
@@ -120,17 +171,21 @@ Pour utiliser ce package dans tes autres projets sans le publier sur NPM :
120
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.
121
172
 
122
173
  ### Configuration du Token
174
+
123
175
  1. Générer un token sur NPM avec les permissions `Read and Write`.
124
176
  2. Restreindre l'accès au package `@syldel/crypto-utils` uniquement.
125
177
  3. Utiliser l'option `Bypass 2FA` pour permettre l'automatisation.
126
178
 
127
179
  ### Commande de publication rapide
180
+
128
181
  Si tu n'utilises pas de fichier `.npmrc`, tu peux publier en passant le token directement :
182
+
129
183
  ```bash
130
184
  npm publish --access public --//registry.npmjs.org/:_authToken=TON_TOKEN_ICI
131
185
  ```
132
186
 
133
187
  ### Configuration de la publication
188
+
134
189
  Créez un fichier .npmrc à la racine (ignoré par Git) pour l'authentification :
135
190
 
136
191
  ```
@@ -138,6 +193,7 @@ Créez un fichier .npmrc à la racine (ignoré par Git) pour l'authentification
138
193
  ```
139
194
 
140
195
  ### Publier une nouvelle version
196
+
141
197
  La commande suivante automatise le build, l'incrémentation de version et l'envoi vers NPM :
142
198
 
143
199
  ```bash
@@ -145,6 +201,7 @@ npm run release
145
201
  ```
146
202
 
147
203
  ### Pousse le commit ET le tag sur GitHub
204
+
148
205
  ```bash
149
206
  git push origin main --follow-tags
150
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
@@ -94,13 +94,28 @@ class PureJwtUtil {
94
94
  }
95
95
  exports.PureJwtUtil = PureJwtUtil;
96
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.
97
+ * Le secret doit être exactement 64 caractères hexadécimaux : la forme que
98
+ * produit `crypto.randomBytes(32).toString('hex')`, soit 256 bits d'aléa, la
99
+ * taille de sortie de HS256 (fiche JWT de l'OWASP, section MAC ; RFC 8725
100
+ * §3.5, qui interdit un mot de passe comme clé). Ce qui compte, c'est l'aléa,
101
+ * qu'une longueur seule ne prouve pas : « ceci-est-mon-mot-de-passe-de-prod »
102
+ * fait 33 octets. 64 caractères hexadécimaux ne se tapent pas à la main.
103
+ *
104
+ * Il sert tel quel de clé HMAC (ses 64 octets ASCII), sans être décodé :
105
+ * le décoder changerait toutes les signatures échangées entre les services.
106
+ *
107
+ * Un secret vide est refusé à part : createHmac signe avec une chaîne vide
108
+ * (mesuré sur le bot le 2026-10-06).
99
109
  */
110
+ const SECRET_PATTERN = /^[0-9a-fA-F]{64}$/;
100
111
  function assertSecret(secret) {
101
112
  if (typeof secret !== 'string' || secret.length === 0) {
102
113
  throw new Error('Invalid secret: a non-empty string is required');
103
114
  }
115
+ if (!SECRET_PATTERN.test(secret)) {
116
+ const allHex = /^[0-9a-fA-F]*$/.test(secret);
117
+ throw new Error(`Invalid secret: 64 hex characters required, got ${secret.length} characters${allHex ? '' : ', not all hex'} (generate it with crypto.randomBytes(32).toString('hex'))`);
118
+ }
104
119
  }
105
120
  function parseJsonObject(segment, error) {
106
121
  let value;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@syldel/crypto-utils",
3
- "version": "1.1.0",
4
- "description": "Lightweight JWT and AES-256-GCM utilities",
3
+ "version": "3.0.0",
4
+ "description": "Lightweight JWT and JWE (AES-256-GCM) utilities",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
7
7
  "repository": {
@@ -30,6 +30,7 @@
30
30
  },
31
31
  "keywords": [
32
32
  "jwt",
33
+ "jwe",
33
34
  "aes",
34
35
  "crypto",
35
36
  "security",
@@ -40,7 +41,7 @@
40
41
  "devDependencies": {
41
42
  "@eslint/js": "^10.0.1",
42
43
  "@types/jest": "^30.0.0",
43
- "@types/node": "^20.19.43",
44
+ "@types/node": "^24.19.2",
44
45
  "eslint": "^10.0.1",
45
46
  "eslint-config-prettier": "^10.1.8",
46
47
  "eslint-plugin-jsonc": "^3.0.0",
@@ -1,39 +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 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
- */
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;
31
- /**
32
- * Encrypts text using native Node.js crypto (AES-256-GCM)
33
- */
34
- static encrypt(text: string, key: Buffer | string): EncryptedData;
35
- /**
36
- * Decrypts a payload using native Node.js crypto (AES-256-GCM)
37
- */
38
- static decrypt(payload: EncryptedData, key: Buffer | string): string;
39
- }
@@ -1,137 +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
- /**
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
- */
45
- static validateKey(key) {
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)');
58
- }
59
- return encryptionKey;
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
- }
76
- /**
77
- * Encrypts text using native Node.js crypto (AES-256-GCM)
78
- */
79
- static encrypt(text, key) {
80
- const encryptionKey = this.validateKey(key);
81
- try {
82
- const iv = crypto.randomBytes(this.IV_BYTES);
83
- const cipher = crypto.createCipheriv(this.ALGORITHM, encryptionKey, iv, {
84
- authTagLength: this.TAG_BYTES,
85
- });
86
- let encrypted = cipher.update(text, 'utf8', 'hex');
87
- encrypted += cipher.final('hex');
88
- return {
89
- data: encrypted,
90
- iv: iv.toString('hex'),
91
- tag: cipher.getAuthTag().toString('hex'),
92
- };
93
- }
94
- catch {
95
- throw new Error('Encryption failed');
96
- }
97
- }
98
- /**
99
- * Decrypts a payload using native Node.js crypto (AES-256-GCM)
100
- */
101
- static decrypt(payload, key) {
102
- const encryptionKey = this.validateKey(key);
103
- try {
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');
118
- decrypted += decipher.final('utf8');
119
- return decrypted;
120
- }
121
- catch {
122
- throw new Error('Decryption failed');
123
- }
124
- }
125
- }
126
- exports.AesGcmUtil = AesGcmUtil;
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;