@syldel/crypto-utils 1.1.0 → 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/README.md +83 -26
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/jwe-compact.d.ts +49 -0
- package/dist/jwe-compact.js +153 -0
- package/dist/jwe.util.d.ts +53 -0
- package/dist/jwe.util.js +138 -0
- package/package.json +4 -3
- package/dist/aes-gcm.util.d.ts +0 -39
- package/dist/aes-gcm.util.js +0 -137
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
|
-
- **
|
|
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
|
-
- **
|
|
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
|
-
###
|
|
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
|
-
|
|
20
|
-
|
|
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
|
-
|
|
23
|
-
|
|
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
|
-
|
|
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
|
-
|
|
30
|
-
|
|
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 =
|
|
45
|
-
const payload = { sub:
|
|
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
|
-
- `
|
|
69
|
-
- Every
|
|
70
|
-
- `
|
|
71
|
-
-
|
|
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
103
|
- Both `sign` and `verify` refuse an empty or missing secret: HMAC happily signs with an empty key, which anyone can guess.
|
|
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
|
|
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 './
|
|
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("./
|
|
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
|
+
}
|
package/dist/jwe.util.js
ADDED
|
@@ -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/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syldel/crypto-utils",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "Lightweight JWT and AES-256-GCM utilities",
|
|
3
|
+
"version": "2.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": "^
|
|
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",
|
package/dist/aes-gcm.util.d.ts
DELETED
|
@@ -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
|
-
}
|
package/dist/aes-gcm.util.js
DELETED
|
@@ -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;
|