@mostajs/crypto-box 0.1.0 → 0.2.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/dist/aead.d.ts +50 -0
- package/dist/aead.js +214 -0
- package/dist/envelope.d.ts +70 -0
- package/dist/envelope.js +174 -0
- package/dist/hash.d.ts +43 -0
- package/dist/hash.js +70 -0
- package/dist/index.d.ts +19 -89
- package/dist/index.js +21 -136
- package/dist/types.d.ts +2 -0
- package/dist/types.js +1 -0
- package/package.json +29 -6
package/dist/aead.d.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @mostajs/crypto-box — Chiffrement authentifié (AEAD), en un coup et EN FLUX.
|
|
3
|
+
* @author Dr Hamid MADANI <drmdh@msn.com>
|
|
4
|
+
* Licence : AGPL-3.0-or-later
|
|
5
|
+
*/
|
|
6
|
+
import type { Bytes } from './types.js';
|
|
7
|
+
/**
|
|
8
|
+
* AES-256-GCM par défaut. ChaCha20-Poly1305 en option : AES n'est rapide QUE si le processeur
|
|
9
|
+
* possède AES-NI. Sur les petits ARM sans accélération — exactement le matériel des bornes et
|
|
10
|
+
* serveurs TicketFlow — ChaCha20 est nettement plus rapide, et à temps constant par construction.
|
|
11
|
+
*/
|
|
12
|
+
export type AeadAlgo = 'aes-256-gcm' | 'chacha20-poly1305';
|
|
13
|
+
/** Taille de bloc du flux. 64 Kio : compromis mémoire / surcoût de tag (16 o par bloc). */
|
|
14
|
+
export declare const CHUNK_SIZE: number;
|
|
15
|
+
/**
|
|
16
|
+
* Chiffre en un coup. Format autoportant : `MJSC2 | algo(1) | iv(12) | tag(16) | chiffré`.
|
|
17
|
+
*
|
|
18
|
+
* ⚠ Tout est mis EN MÉMOIRE. Pour une archive de sauvegarde, utilisez `aeadEncryptStream` :
|
|
19
|
+
* une base de plusieurs gigaoctets ferait tomber le processus ici.
|
|
20
|
+
*
|
|
21
|
+
* Le nonce est tiré au hasard À CHAQUE APPEL et n'est jamais fourni par l'appelant : le
|
|
22
|
+
* réutiliser en GCM révèle le XOR des clairs et permet de forger des messages. L'option
|
|
23
|
+
* n'existe donc pas.
|
|
24
|
+
*/
|
|
25
|
+
export declare function aeadEncrypt(key: Bytes, plaintext: Bytes | string, algo?: AeadAlgo): Buffer;
|
|
26
|
+
/**
|
|
27
|
+
* Déchiffre un blob `aeadEncrypt`. Lève si la clé est fausse OU si **un seul bit** a changé :
|
|
28
|
+
* un déchiffrement qui « réussirait » sur des octets corrompus rendrait une sauvegarde
|
|
29
|
+
* silencieusement fausse.
|
|
30
|
+
*
|
|
31
|
+
* Lit aussi le format `MJSC1` de la 0.1.0 (AES-GCM implicite) : on ne casse pas ce qu'on a
|
|
32
|
+
* déjà pu écrire.
|
|
33
|
+
*/
|
|
34
|
+
export declare function aeadDecrypt(key: Bytes, blob: Bytes): Buffer;
|
|
35
|
+
/**
|
|
36
|
+
* Chiffre un FLUX. Sortie : `MJSS1 | algo(1) | base(8)` puis, par bloc,
|
|
37
|
+
* `len(4) | tag(16) | chiffré`.
|
|
38
|
+
*
|
|
39
|
+
* Chaque bloc porte son propre tag, son compteur et son drapeau final. Le flux résiste donc à
|
|
40
|
+
* l'altération, au réordonnancement, à la suppression ET à la troncature — les quatre attaques
|
|
41
|
+
* qu'un simple « chiffrer par morceaux » laisse passer.
|
|
42
|
+
*/
|
|
43
|
+
export declare function aeadEncryptStream(key: Bytes, source: AsyncIterable<Bytes> | Iterable<Bytes>, algo?: AeadAlgo): AsyncGenerator<Buffer>;
|
|
44
|
+
/**
|
|
45
|
+
* Déchiffre un flux produit par `aeadEncryptStream`.
|
|
46
|
+
*
|
|
47
|
+
* LÈVE si le flux est tronqué (bloc final jamais vu), réordonné, ou altéré d'un bit. Une
|
|
48
|
+
* archive à moitié restaurée est pire qu'une restauration qui échoue franchement.
|
|
49
|
+
*/
|
|
50
|
+
export declare function aeadDecryptStream(key: Bytes, source: AsyncIterable<Bytes> | Iterable<Bytes>): AsyncGenerator<Buffer>;
|
package/dist/aead.js
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @mostajs/crypto-box — Chiffrement authentifié (AEAD), en un coup et EN FLUX.
|
|
3
|
+
* @author Dr Hamid MADANI <drmdh@msn.com>
|
|
4
|
+
* Licence : AGPL-3.0-or-later
|
|
5
|
+
*/
|
|
6
|
+
import { randomBytes, createCipheriv, createDecipheriv } from 'node:crypto';
|
|
7
|
+
const ALGO_ID = { 'aes-256-gcm': 1, 'chacha20-poly1305': 2 };
|
|
8
|
+
const ID_ALGO = { 1: 'aes-256-gcm', 2: 'chacha20-poly1305' };
|
|
9
|
+
const MAGIC_ONE = Buffer.from('MJSC2'); // un coup (0.2.0 : porte l'algorithme)
|
|
10
|
+
const MAGIC_ONE_V1 = Buffer.from('MJSC1'); // un coup (0.1.0 : AES-GCM implicite) — relu, plus écrit
|
|
11
|
+
const MAGIC_STREAM = Buffer.from('MJSS1'); // flux
|
|
12
|
+
const IV_LEN = 12; // 96 bits : la taille recommandée pour GCM comme pour ChaCha20-Poly1305
|
|
13
|
+
const TAG_LEN = 16;
|
|
14
|
+
/** Taille de bloc du flux. 64 Kio : compromis mémoire / surcoût de tag (16 o par bloc). */
|
|
15
|
+
export const CHUNK_SIZE = 64 * 1024;
|
|
16
|
+
function requireKey(key) {
|
|
17
|
+
if (key.length !== 32)
|
|
18
|
+
throw new Error('crypto-box : la clé doit faire 32 octets (voir deriveKey/randomKey)');
|
|
19
|
+
return Buffer.from(key);
|
|
20
|
+
}
|
|
21
|
+
// ─── Un coup (petites charges : clés enveloppées, manifestes, jetons) ────────
|
|
22
|
+
/**
|
|
23
|
+
* Chiffre en un coup. Format autoportant : `MJSC2 | algo(1) | iv(12) | tag(16) | chiffré`.
|
|
24
|
+
*
|
|
25
|
+
* ⚠ Tout est mis EN MÉMOIRE. Pour une archive de sauvegarde, utilisez `aeadEncryptStream` :
|
|
26
|
+
* une base de plusieurs gigaoctets ferait tomber le processus ici.
|
|
27
|
+
*
|
|
28
|
+
* Le nonce est tiré au hasard À CHAQUE APPEL et n'est jamais fourni par l'appelant : le
|
|
29
|
+
* réutiliser en GCM révèle le XOR des clairs et permet de forger des messages. L'option
|
|
30
|
+
* n'existe donc pas.
|
|
31
|
+
*/
|
|
32
|
+
export function aeadEncrypt(key, plaintext, algo = 'aes-256-gcm') {
|
|
33
|
+
const k = requireKey(key);
|
|
34
|
+
const iv = randomBytes(IV_LEN);
|
|
35
|
+
// `as never` ferait choisir à TS la surcharge CCM, dont `setAAD` prend 2 arguments.
|
|
36
|
+
const cipher = createCipheriv(algo, k, iv, { authTagLength: TAG_LEN });
|
|
37
|
+
const body = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
38
|
+
return Buffer.concat([MAGIC_ONE, Buffer.from([ALGO_ID[algo]]), iv, cipher.getAuthTag(), body]);
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Déchiffre un blob `aeadEncrypt`. Lève si la clé est fausse OU si **un seul bit** a changé :
|
|
42
|
+
* un déchiffrement qui « réussirait » sur des octets corrompus rendrait une sauvegarde
|
|
43
|
+
* silencieusement fausse.
|
|
44
|
+
*
|
|
45
|
+
* Lit aussi le format `MJSC1` de la 0.1.0 (AES-GCM implicite) : on ne casse pas ce qu'on a
|
|
46
|
+
* déjà pu écrire.
|
|
47
|
+
*/
|
|
48
|
+
export function aeadDecrypt(key, blob) {
|
|
49
|
+
const k = requireKey(key);
|
|
50
|
+
const buf = Buffer.from(blob);
|
|
51
|
+
let algo;
|
|
52
|
+
let off;
|
|
53
|
+
if (buf.subarray(0, 5).equals(MAGIC_ONE)) {
|
|
54
|
+
algo = ID_ALGO[buf[5]];
|
|
55
|
+
if (!algo)
|
|
56
|
+
throw new Error('crypto-box : algorithme inconnu');
|
|
57
|
+
off = 6;
|
|
58
|
+
}
|
|
59
|
+
else if (buf.subarray(0, 5).equals(MAGIC_ONE_V1)) {
|
|
60
|
+
algo = 'aes-256-gcm';
|
|
61
|
+
off = 5;
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
throw new Error('crypto-box : format inconnu (ni MJSC1 ni MJSC2)');
|
|
65
|
+
}
|
|
66
|
+
if (buf.length < off + IV_LEN + TAG_LEN)
|
|
67
|
+
throw new Error('crypto-box : blob tronqué');
|
|
68
|
+
const iv = buf.subarray(off, off + IV_LEN);
|
|
69
|
+
const tag = buf.subarray(off + IV_LEN, off + IV_LEN + TAG_LEN);
|
|
70
|
+
const body = buf.subarray(off + IV_LEN + TAG_LEN);
|
|
71
|
+
const decipher = createDecipheriv(algo, k, iv, { authTagLength: TAG_LEN });
|
|
72
|
+
decipher.setAuthTag(tag);
|
|
73
|
+
return Buffer.concat([decipher.update(body), decipher.final()]); // `final()` vérifie le tag
|
|
74
|
+
}
|
|
75
|
+
// ─── Flux (archives : la lacune bloquante de la 0.1.0) ───────────────────────
|
|
76
|
+
/**
|
|
77
|
+
* Nonce d'un bloc : `base(8) ‖ compteur(4, big-endian)`.
|
|
78
|
+
*
|
|
79
|
+
* Le compteur est ce qui interdit le RÉORDONNANCEMENT : un bloc déplacé se déchiffre avec un
|
|
80
|
+
* nonce différent de celui qui l'a chiffré, donc son tag échoue.
|
|
81
|
+
*/
|
|
82
|
+
function chunkNonce(base, counter) {
|
|
83
|
+
const n = Buffer.alloc(IV_LEN);
|
|
84
|
+
base.copy(n, 0, 0, 8);
|
|
85
|
+
n.writeUInt32BE(counter, 8);
|
|
86
|
+
return n;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Données associées d'un bloc : `compteur(4) ‖ final(1)`.
|
|
90
|
+
*
|
|
91
|
+
* Le drapeau `final` est ce qui interdit la TRONCATURE. Sans lui, un attaquant coupe l'archive
|
|
92
|
+
* en deux et le déchiffrement « réussit » sur la première moitié — chaque bloc restant
|
|
93
|
+
* authentique. Le déchiffreur exige d'avoir vu le bloc marqué final, sinon il LÈVE.
|
|
94
|
+
*/
|
|
95
|
+
function chunkAad(counter, final) {
|
|
96
|
+
const aad = Buffer.alloc(5);
|
|
97
|
+
aad.writeUInt32BE(counter, 0);
|
|
98
|
+
aad[4] = final ? 1 : 0;
|
|
99
|
+
return aad;
|
|
100
|
+
}
|
|
101
|
+
/** Re-découpe une source hétérogène en blocs de taille fixe. */
|
|
102
|
+
async function* rechunk(source, size) {
|
|
103
|
+
let buf = Buffer.alloc(0);
|
|
104
|
+
for await (const part of source) {
|
|
105
|
+
buf = Buffer.concat([buf, Buffer.from(part)]);
|
|
106
|
+
while (buf.length >= size) {
|
|
107
|
+
yield buf.subarray(0, size);
|
|
108
|
+
buf = buf.subarray(size);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
yield buf; // dernier bloc (éventuellement vide : une archive vide reste une archive)
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Chiffre un FLUX. Sortie : `MJSS1 | algo(1) | base(8)` puis, par bloc,
|
|
115
|
+
* `len(4) | tag(16) | chiffré`.
|
|
116
|
+
*
|
|
117
|
+
* Chaque bloc porte son propre tag, son compteur et son drapeau final. Le flux résiste donc à
|
|
118
|
+
* l'altération, au réordonnancement, à la suppression ET à la troncature — les quatre attaques
|
|
119
|
+
* qu'un simple « chiffrer par morceaux » laisse passer.
|
|
120
|
+
*/
|
|
121
|
+
export async function* aeadEncryptStream(key, source, algo = 'aes-256-gcm') {
|
|
122
|
+
const k = requireKey(key);
|
|
123
|
+
const base = randomBytes(8);
|
|
124
|
+
yield Buffer.concat([MAGIC_STREAM, Buffer.from([ALGO_ID[algo]]), base]);
|
|
125
|
+
let counter = 0;
|
|
126
|
+
let pending = null;
|
|
127
|
+
for await (const chunk of rechunk(source, CHUNK_SIZE)) {
|
|
128
|
+
if (pending !== null) {
|
|
129
|
+
yield sealChunk(k, algo, base, counter++, pending, false);
|
|
130
|
+
}
|
|
131
|
+
pending = chunk;
|
|
132
|
+
}
|
|
133
|
+
// Le dernier bloc — et lui seul — porte le drapeau final.
|
|
134
|
+
yield sealChunk(k, algo, base, counter, pending ?? Buffer.alloc(0), true);
|
|
135
|
+
}
|
|
136
|
+
function sealChunk(key, algo, base, counter, data, final) {
|
|
137
|
+
const cipher = createCipheriv(algo, key, chunkNonce(base, counter), { authTagLength: TAG_LEN });
|
|
138
|
+
cipher.setAAD(chunkAad(counter, final));
|
|
139
|
+
const body = Buffer.concat([cipher.update(data), cipher.final()]);
|
|
140
|
+
const header = Buffer.alloc(4);
|
|
141
|
+
header.writeUInt32BE(body.length, 0);
|
|
142
|
+
return Buffer.concat([header, cipher.getAuthTag(), body]);
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Déchiffre un flux produit par `aeadEncryptStream`.
|
|
146
|
+
*
|
|
147
|
+
* LÈVE si le flux est tronqué (bloc final jamais vu), réordonné, ou altéré d'un bit. Une
|
|
148
|
+
* archive à moitié restaurée est pire qu'une restauration qui échoue franchement.
|
|
149
|
+
*/
|
|
150
|
+
export async function* aeadDecryptStream(key, source) {
|
|
151
|
+
const k = requireKey(key);
|
|
152
|
+
let buf = Buffer.alloc(0);
|
|
153
|
+
let algo = null;
|
|
154
|
+
let base = null;
|
|
155
|
+
let counter = 0;
|
|
156
|
+
let done = false;
|
|
157
|
+
for await (const part of source) {
|
|
158
|
+
buf = Buffer.concat([buf, Buffer.from(part)]);
|
|
159
|
+
if (algo === null) {
|
|
160
|
+
if (buf.length < 14)
|
|
161
|
+
continue;
|
|
162
|
+
if (!buf.subarray(0, 5).equals(MAGIC_STREAM))
|
|
163
|
+
throw new Error('crypto-box : format inconnu (pas un flux MJSS1)');
|
|
164
|
+
algo = ID_ALGO[buf[5]];
|
|
165
|
+
if (!algo)
|
|
166
|
+
throw new Error('crypto-box : algorithme inconnu');
|
|
167
|
+
base = buf.subarray(6, 14);
|
|
168
|
+
buf = buf.subarray(14);
|
|
169
|
+
}
|
|
170
|
+
// Un bloc = len(4) | tag(16) | corps(len)
|
|
171
|
+
for (;;) {
|
|
172
|
+
// L'ordre compte : on sort D'ABORD s'il ne reste rien à lire. Tester `done` avant
|
|
173
|
+
// reviendrait à traiter la FIN NORMALE d'un flux valide comme une falsification —
|
|
174
|
+
// c'est exactement le faux positif qu'a levé le test ChaCha.
|
|
175
|
+
if (buf.length < 4 + TAG_LEN)
|
|
176
|
+
break;
|
|
177
|
+
if (done)
|
|
178
|
+
throw new Error('crypto-box : données après le bloc final (flux falsifié)');
|
|
179
|
+
const len = buf.readUInt32BE(0);
|
|
180
|
+
if (buf.length < 4 + TAG_LEN + len)
|
|
181
|
+
break;
|
|
182
|
+
const tag = buf.subarray(4, 4 + TAG_LEN);
|
|
183
|
+
const body = buf.subarray(4 + TAG_LEN, 4 + TAG_LEN + len);
|
|
184
|
+
buf = buf.subarray(4 + TAG_LEN + len);
|
|
185
|
+
// On ignore quel bloc est final : on ESSAIE « non final », puis « final ». Le tag
|
|
186
|
+
// tranche — impossible de mentir sur le drapeau sans casser l'authentification.
|
|
187
|
+
const out = openChunk(k, algo, base, counter, tag, body);
|
|
188
|
+
if (out.final)
|
|
189
|
+
done = true;
|
|
190
|
+
counter += 1;
|
|
191
|
+
yield out.data;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if (!done) {
|
|
195
|
+
// Le cas capital : l'archive a été coupée. Chaque bloc reçu était authentique, mais il
|
|
196
|
+
// en manque. Sans ce contrôle, on restaurerait une base à moitié — silencieusement.
|
|
197
|
+
throw new Error('crypto-box : flux TRONQUÉ (bloc final absent)');
|
|
198
|
+
}
|
|
199
|
+
if (buf.length > 0)
|
|
200
|
+
throw new Error('crypto-box : octets résiduels après le bloc final');
|
|
201
|
+
}
|
|
202
|
+
function openChunk(key, algo, base, counter, tag, body) {
|
|
203
|
+
for (const final of [false, true]) {
|
|
204
|
+
try {
|
|
205
|
+
const d = createDecipheriv(algo, key, chunkNonce(base, counter), { authTagLength: TAG_LEN });
|
|
206
|
+
d.setAAD(chunkAad(counter, final));
|
|
207
|
+
d.setAuthTag(tag);
|
|
208
|
+
const data = Buffer.concat([d.update(body), d.final()]);
|
|
209
|
+
return { data, final };
|
|
210
|
+
}
|
|
211
|
+
catch { /* essaie l'autre drapeau */ }
|
|
212
|
+
}
|
|
213
|
+
throw new Error(`crypto-box : bloc ${counter} invalide (altéré, réordonné ou clé fausse)`);
|
|
214
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @mostajs/crypto-box — Signature Ed25519, échange X25519, et CHIFFREMENT PAR ENVELOPPE.
|
|
3
|
+
* @author Dr Hamid MADANI <drmdh@msn.com>
|
|
4
|
+
* Licence : AGPL-3.0-or-later
|
|
5
|
+
*
|
|
6
|
+
* ─── LE MODÈLE D'ENVELOPPE, ET POURQUOI IL EST TRANCHÉ AINSI ─────────────────
|
|
7
|
+
*
|
|
8
|
+
* Chiffrer une archive DIRECTEMENT avec la phrase du client a deux conséquences fâcheuses :
|
|
9
|
+
* 1. changer de phrase impose de RE-CHIFFRER toutes les archives (40 Go pour un mot) ;
|
|
10
|
+
* 2. on ne peut pas chiffrer « pour » un client sans connaître sa phrase.
|
|
11
|
+
*
|
|
12
|
+
* Le modèle d'enveloppe (celui d'`age`) les supprime tous les deux :
|
|
13
|
+
* - chaque archive est chiffrée par une clé de données ALÉATOIRE (DEK) ;
|
|
14
|
+
* - la DEK est ENVELOPPÉE pour une ou plusieurs clés publiques de destinataires (KEK).
|
|
15
|
+
*
|
|
16
|
+
* Ce que cela permet, concrètement :
|
|
17
|
+
* - le client change de phrase → on ré-enveloppe 32 OCTETS, pas les archives ;
|
|
18
|
+
* - la console peut DÉTENIR une enveloppe qu'elle NE SAIT PAS OUVRIR : on vend la
|
|
19
|
+
* conservation et la restauration sans jamais détenir les secrets du client ;
|
|
20
|
+
* - une CLÉ DE SECOURS (hors ligne, chez le client) s'ajoute comme second destinataire —
|
|
21
|
+
* ce qui atténue le « clé perdue = tout perdu » qu'il faudrait sinon contractualiser.
|
|
22
|
+
*
|
|
23
|
+
* X25519 et Ed25519 sont NATIFS dans node:crypto : zéro dépendance ajoutée.
|
|
24
|
+
*/
|
|
25
|
+
import type { Bytes } from './types.js';
|
|
26
|
+
/** Paire de clés : clés BRUTES de 32 octets, faciles à stocker et à transporter. */
|
|
27
|
+
export interface KeyPair {
|
|
28
|
+
publicKey: Buffer;
|
|
29
|
+
privateKey: Buffer;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Paire de signature. L'intégrité (SHA-256, tag AEAD) prouve qu'une archive n'a pas été
|
|
33
|
+
* corrompue PAR ACCIDENT ; la signature prouve QUI l'a produite. Pour une archive restaurée
|
|
34
|
+
* chez un client — et pour une garantie de déploiement — c'est une différence de nature.
|
|
35
|
+
*/
|
|
36
|
+
export declare function generateSigningKeyPair(): KeyPair;
|
|
37
|
+
/** Signe des données (Ed25519, RFC 8032). */
|
|
38
|
+
export declare function sign(privateKey: Bytes, data: Bytes | string): Buffer;
|
|
39
|
+
/** Vérifie une signature. Retourne `false` — ne lève pas : une signature fausse est un cas normal. */
|
|
40
|
+
export declare function verifySignature(publicKey: Bytes, data: Bytes | string, signature: Bytes): boolean;
|
|
41
|
+
/**
|
|
42
|
+
* Dérive une sous-clé d'une clé DÉJÀ FORTE (RFC 5869).
|
|
43
|
+
*
|
|
44
|
+
* À ne pas confondre avec `deriveKey` (scrypt), qui transforme une PHRASE HUMAINE faible en
|
|
45
|
+
* clé et qui est lent À DESSEIN. Dériver une sous-clé d'une clé de 256 bits n'a aucune raison
|
|
46
|
+
* d'être lent : détourner scrypt pour cela est un contresens coûteux.
|
|
47
|
+
*/
|
|
48
|
+
export declare function deriveSubKey(masterKey: Bytes, info: string, opts?: {
|
|
49
|
+
salt?: Bytes;
|
|
50
|
+
length?: number;
|
|
51
|
+
}): Buffer;
|
|
52
|
+
/** Paire de destinataire (X25519). La clé PUBLIQUE peut être distribuée sans risque. */
|
|
53
|
+
export declare function generateRecipientKeyPair(): KeyPair;
|
|
54
|
+
/**
|
|
55
|
+
* Chiffre POUR un ou plusieurs destinataires, sans connaître aucun de leurs secrets.
|
|
56
|
+
*
|
|
57
|
+
* Format : `MJSE1 | n(1) | [ephPub(32) | len(2) | DEK enveloppée]×n | corps AEAD(DEK)`.
|
|
58
|
+
*
|
|
59
|
+
* Chaque destinataire reçoit sa propre enveloppe de la MÊME clé de données — d'où la
|
|
60
|
+
* possibilité d'ajouter une clé de secours sans dupliquer l'archive.
|
|
61
|
+
*/
|
|
62
|
+
export declare function seal(recipients: Bytes[], plaintext: Bytes | string): Buffer;
|
|
63
|
+
/**
|
|
64
|
+
* Ouvre une enveloppe avec SA clé privée.
|
|
65
|
+
*
|
|
66
|
+
* On essaie chaque emplacement : un destinataire ne sait pas lequel est le sien (les
|
|
67
|
+
* enveloppes ne portent aucun identifiant — révéler POUR QUI une archive est chiffrée serait
|
|
68
|
+
* une fuite de métadonnée en soi).
|
|
69
|
+
*/
|
|
70
|
+
export declare function open(privateKey: Bytes, blob: Bytes): Buffer;
|
package/dist/envelope.js
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @mostajs/crypto-box — Signature Ed25519, échange X25519, et CHIFFREMENT PAR ENVELOPPE.
|
|
3
|
+
* @author Dr Hamid MADANI <drmdh@msn.com>
|
|
4
|
+
* Licence : AGPL-3.0-or-later
|
|
5
|
+
*
|
|
6
|
+
* ─── LE MODÈLE D'ENVELOPPE, ET POURQUOI IL EST TRANCHÉ AINSI ─────────────────
|
|
7
|
+
*
|
|
8
|
+
* Chiffrer une archive DIRECTEMENT avec la phrase du client a deux conséquences fâcheuses :
|
|
9
|
+
* 1. changer de phrase impose de RE-CHIFFRER toutes les archives (40 Go pour un mot) ;
|
|
10
|
+
* 2. on ne peut pas chiffrer « pour » un client sans connaître sa phrase.
|
|
11
|
+
*
|
|
12
|
+
* Le modèle d'enveloppe (celui d'`age`) les supprime tous les deux :
|
|
13
|
+
* - chaque archive est chiffrée par une clé de données ALÉATOIRE (DEK) ;
|
|
14
|
+
* - la DEK est ENVELOPPÉE pour une ou plusieurs clés publiques de destinataires (KEK).
|
|
15
|
+
*
|
|
16
|
+
* Ce que cela permet, concrètement :
|
|
17
|
+
* - le client change de phrase → on ré-enveloppe 32 OCTETS, pas les archives ;
|
|
18
|
+
* - la console peut DÉTENIR une enveloppe qu'elle NE SAIT PAS OUVRIR : on vend la
|
|
19
|
+
* conservation et la restauration sans jamais détenir les secrets du client ;
|
|
20
|
+
* - une CLÉ DE SECOURS (hors ligne, chez le client) s'ajoute comme second destinataire —
|
|
21
|
+
* ce qui atténue le « clé perdue = tout perdu » qu'il faudrait sinon contractualiser.
|
|
22
|
+
*
|
|
23
|
+
* X25519 et Ed25519 sont NATIFS dans node:crypto : zéro dépendance ajoutée.
|
|
24
|
+
*/
|
|
25
|
+
import { generateKeyPairSync, createPublicKey, createPrivateKey, diffieHellman, sign as nodeSign, verify as nodeVerify, hkdfSync, randomBytes, } from 'node:crypto';
|
|
26
|
+
import { aeadEncrypt, aeadDecrypt } from './aead.js';
|
|
27
|
+
const b64u = (b) => b.toString('base64url');
|
|
28
|
+
function toPublic(raw, crv) {
|
|
29
|
+
return createPublicKey({
|
|
30
|
+
key: { kty: 'OKP', crv: crv === 'ed25519' ? 'Ed25519' : 'X25519', x: b64u(Buffer.from(raw)) },
|
|
31
|
+
format: 'jwk',
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
function toPrivate(raw, crv) {
|
|
35
|
+
// JWK exige la partie publique ; on la recalcule en important d'abord la privée en DER.
|
|
36
|
+
// Plus simple et sûr : on garde `d`, et Node dérive `x` lui-même ? Non — JWK OKP l'exige.
|
|
37
|
+
// On stocke donc la privée AVEC sa publique dans `privateKey` (64 o) ? Non plus : on
|
|
38
|
+
// recalcule `x` par une multiplication scalaire… indisponible en JS pur.
|
|
39
|
+
// Solution retenue : on encode la privée en PKCS#8 DER, ce que Node accepte sans `x`.
|
|
40
|
+
const prefix = crv === 'ed25519'
|
|
41
|
+
? Buffer.from('302e020100300506032b657004220420', 'hex') // PKCS#8 Ed25519
|
|
42
|
+
: Buffer.from('302e020100300506032b656e04220420', 'hex'); // PKCS#8 X25519
|
|
43
|
+
return createPrivateKey({ key: Buffer.concat([prefix, Buffer.from(raw)]), format: 'der', type: 'pkcs8' });
|
|
44
|
+
}
|
|
45
|
+
/** Extrait les 32 octets bruts d'une clé (publique ou privée). */
|
|
46
|
+
function rawOf(key) {
|
|
47
|
+
const jwk = key.export({ format: 'jwk' });
|
|
48
|
+
const b64 = key.type === 'private' ? jwk.d : jwk.x;
|
|
49
|
+
return Buffer.from(b64, 'base64url');
|
|
50
|
+
}
|
|
51
|
+
// ─── Signature (Ed25519) ────────────────────────────────────────────────────
|
|
52
|
+
/**
|
|
53
|
+
* Paire de signature. L'intégrité (SHA-256, tag AEAD) prouve qu'une archive n'a pas été
|
|
54
|
+
* corrompue PAR ACCIDENT ; la signature prouve QUI l'a produite. Pour une archive restaurée
|
|
55
|
+
* chez un client — et pour une garantie de déploiement — c'est une différence de nature.
|
|
56
|
+
*/
|
|
57
|
+
export function generateSigningKeyPair() {
|
|
58
|
+
const { publicKey, privateKey } = generateKeyPairSync('ed25519');
|
|
59
|
+
return { publicKey: rawOf(publicKey), privateKey: rawOf(privateKey) };
|
|
60
|
+
}
|
|
61
|
+
/** Signe des données (Ed25519, RFC 8032). */
|
|
62
|
+
export function sign(privateKey, data) {
|
|
63
|
+
return nodeSign(null, Buffer.from(data), toPrivate(privateKey, 'ed25519'));
|
|
64
|
+
}
|
|
65
|
+
/** Vérifie une signature. Retourne `false` — ne lève pas : une signature fausse est un cas normal. */
|
|
66
|
+
export function verifySignature(publicKey, data, signature) {
|
|
67
|
+
try {
|
|
68
|
+
return nodeVerify(null, Buffer.from(data), toPublic(publicKey, 'ed25519'), Buffer.from(signature));
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// ─── Dérivation de sous-clés (HKDF) ─────────────────────────────────────────
|
|
75
|
+
/**
|
|
76
|
+
* Dérive une sous-clé d'une clé DÉJÀ FORTE (RFC 5869).
|
|
77
|
+
*
|
|
78
|
+
* À ne pas confondre avec `deriveKey` (scrypt), qui transforme une PHRASE HUMAINE faible en
|
|
79
|
+
* clé et qui est lent À DESSEIN. Dériver une sous-clé d'une clé de 256 bits n'a aucune raison
|
|
80
|
+
* d'être lent : détourner scrypt pour cela est un contresens coûteux.
|
|
81
|
+
*/
|
|
82
|
+
export function deriveSubKey(masterKey, info, opts = {}) {
|
|
83
|
+
const salt = opts.salt ? Buffer.from(opts.salt) : Buffer.alloc(0);
|
|
84
|
+
const len = opts.length ?? 32;
|
|
85
|
+
return Buffer.from(hkdfSync('sha256', Buffer.from(masterKey), salt, Buffer.from(info, 'utf8'), len));
|
|
86
|
+
}
|
|
87
|
+
// ─── Enveloppe (X25519 + AEAD) ──────────────────────────────────────────────
|
|
88
|
+
const MAGIC_SEAL = Buffer.from('MJSE1');
|
|
89
|
+
const HKDF_INFO = 'mostajs-crypto-box/envelope/v1';
|
|
90
|
+
/** Paire de destinataire (X25519). La clé PUBLIQUE peut être distribuée sans risque. */
|
|
91
|
+
export function generateRecipientKeyPair() {
|
|
92
|
+
const { publicKey, privateKey } = generateKeyPairSync('x25519');
|
|
93
|
+
return { publicKey: rawOf(publicKey), privateKey: rawOf(privateKey) };
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Clé d'enveloppement, dérivée du secret partagé X25519.
|
|
97
|
+
*
|
|
98
|
+
* Le secret brut de Diffie-Hellman ne doit JAMAIS servir de clé tel quel (sa distribution
|
|
99
|
+
* n'est pas uniforme) : on le passe par HKDF, en liant la dérivation aux DEUX clés publiques
|
|
100
|
+
* — sans quoi la même enveloppe pourrait être rejouée pour un autre destinataire.
|
|
101
|
+
*/
|
|
102
|
+
function wrapKeyFor(ourPriv, theirPub, ephPub, rcptPub) {
|
|
103
|
+
const shared = diffieHellman({ privateKey: ourPriv, publicKey: theirPub });
|
|
104
|
+
return deriveSubKey(shared, HKDF_INFO, { salt: Buffer.concat([ephPub, rcptPub]) });
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Chiffre POUR un ou plusieurs destinataires, sans connaître aucun de leurs secrets.
|
|
108
|
+
*
|
|
109
|
+
* Format : `MJSE1 | n(1) | [ephPub(32) | len(2) | DEK enveloppée]×n | corps AEAD(DEK)`.
|
|
110
|
+
*
|
|
111
|
+
* Chaque destinataire reçoit sa propre enveloppe de la MÊME clé de données — d'où la
|
|
112
|
+
* possibilité d'ajouter une clé de secours sans dupliquer l'archive.
|
|
113
|
+
*/
|
|
114
|
+
export function seal(recipients, plaintext) {
|
|
115
|
+
if (recipients.length === 0)
|
|
116
|
+
throw new Error('crypto-box : au moins un destinataire est requis');
|
|
117
|
+
if (recipients.length > 255)
|
|
118
|
+
throw new Error('crypto-box : 255 destinataires au maximum');
|
|
119
|
+
const dek = randomBytes(32);
|
|
120
|
+
const slots = [];
|
|
121
|
+
for (const rcpt of recipients) {
|
|
122
|
+
const rcptPub = Buffer.from(rcpt);
|
|
123
|
+
if (rcptPub.length !== 32)
|
|
124
|
+
throw new Error('crypto-box : clé publique de destinataire invalide (32 octets)');
|
|
125
|
+
// Une paire ÉPHÉMÈRE par destinataire : la même DEK est enveloppée n fois, jamais le
|
|
126
|
+
// même secret partagé deux fois.
|
|
127
|
+
const eph = generateKeyPairSync('x25519');
|
|
128
|
+
const ephPub = rawOf(eph.publicKey);
|
|
129
|
+
const wrapKey = wrapKeyFor(eph.privateKey, toPublic(rcptPub, 'x25519'), ephPub, rcptPub);
|
|
130
|
+
const wrapped = aeadEncrypt(wrapKey, dek);
|
|
131
|
+
const len = Buffer.alloc(2);
|
|
132
|
+
len.writeUInt16BE(wrapped.length, 0);
|
|
133
|
+
slots.push(Buffer.concat([ephPub, len, wrapped]));
|
|
134
|
+
}
|
|
135
|
+
return Buffer.concat([
|
|
136
|
+
MAGIC_SEAL,
|
|
137
|
+
Buffer.from([recipients.length]),
|
|
138
|
+
...slots,
|
|
139
|
+
aeadEncrypt(dek, plaintext),
|
|
140
|
+
]);
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Ouvre une enveloppe avec SA clé privée.
|
|
144
|
+
*
|
|
145
|
+
* On essaie chaque emplacement : un destinataire ne sait pas lequel est le sien (les
|
|
146
|
+
* enveloppes ne portent aucun identifiant — révéler POUR QUI une archive est chiffrée serait
|
|
147
|
+
* une fuite de métadonnée en soi).
|
|
148
|
+
*/
|
|
149
|
+
export function open(privateKey, blob) {
|
|
150
|
+
const buf = Buffer.from(blob);
|
|
151
|
+
if (!buf.subarray(0, 5).equals(MAGIC_SEAL))
|
|
152
|
+
throw new Error('crypto-box : format inconnu (pas une enveloppe MJSE1)');
|
|
153
|
+
const priv = toPrivate(privateKey, 'x25519');
|
|
154
|
+
const ourPub = rawOf(createPublicKey(priv));
|
|
155
|
+
const n = buf[5];
|
|
156
|
+
let off = 6;
|
|
157
|
+
const wrapped = [];
|
|
158
|
+
for (let i = 0; i < n; i++) {
|
|
159
|
+
const ephPub = buf.subarray(off, off + 32);
|
|
160
|
+
const len = buf.readUInt16BE(off + 32);
|
|
161
|
+
wrapped.push({ ephPub, blob: buf.subarray(off + 34, off + 34 + len) });
|
|
162
|
+
off += 34 + len;
|
|
163
|
+
}
|
|
164
|
+
const body = buf.subarray(off);
|
|
165
|
+
for (const slot of wrapped) {
|
|
166
|
+
try {
|
|
167
|
+
const wrapKey = wrapKeyFor(priv, toPublic(slot.ephPub, 'x25519'), slot.ephPub, ourPub);
|
|
168
|
+
const dek = aeadDecrypt(wrapKey, slot.blob);
|
|
169
|
+
return aeadDecrypt(dek, body);
|
|
170
|
+
}
|
|
171
|
+
catch { /* pas notre emplacement — on essaie le suivant */ }
|
|
172
|
+
}
|
|
173
|
+
throw new Error('crypto-box : aucune enveloppe ne correspond à cette clé (pas un destinataire)');
|
|
174
|
+
}
|
package/dist/hash.d.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { Bytes, Encoding } from './types.js';
|
|
2
|
+
/** SHA-256. Défaut : hex (convention des checksums et de SigV4). */
|
|
3
|
+
export declare function sha256(data: Bytes | string, encoding?: Encoding): string;
|
|
4
|
+
/** SHA-256 brut — pour les chaînages. */
|
|
5
|
+
export declare function sha256Bytes(data: Bytes | string): Buffer;
|
|
6
|
+
/** HMAC-SHA256. Défaut : hex. */
|
|
7
|
+
export declare function hmacSha256(key: Bytes | string, data: Bytes | string, encoding?: Encoding): string;
|
|
8
|
+
/** HMAC-SHA256 brut — pour les chaînages (SigV4 dérive sa clé par HMAC successifs). */
|
|
9
|
+
export declare function hmacSha256Bytes(key: Bytes | string, data: Bytes | string): Buffer;
|
|
10
|
+
/**
|
|
11
|
+
* Compare deux valeurs en TEMPS CONSTANT. **Toute** comparaison de secret, de signature ou de
|
|
12
|
+
* jeton doit passer par ici — jamais par `===`, jamais par une boucle maison.
|
|
13
|
+
*
|
|
14
|
+
* `timingSafeEqual` de Node LÈVE si les tailles diffèrent : un appel naïf ferait tomber le
|
|
15
|
+
* serveur sur une signature tronquée, c'est-à-dire sur une entrée d'attaquant. On borne donc
|
|
16
|
+
* avant. La longueur ne fuit rien : celle d'un HMAC-SHA256 est publique et constante.
|
|
17
|
+
*/
|
|
18
|
+
export declare function equalsConstantTime(a: Bytes | string, b: Bytes | string): boolean;
|
|
19
|
+
export interface DeriveKeyOptions {
|
|
20
|
+
/** Sel — 16 octets aléatoires recommandés. Généré si absent (et RETOURNÉ : conservez-le). */
|
|
21
|
+
salt?: Bytes;
|
|
22
|
+
/** Coût CPU/mémoire (puissance de 2). Défaut 2^17 — ~100 ms, coûteux à brute-forcer. */
|
|
23
|
+
cost?: number;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Dérive une clé AES-256 (32 octets) depuis une PHRASE SECRÈTE, par scrypt (RFC 7914).
|
|
27
|
+
*
|
|
28
|
+
* Un simple SHA-256 de la phrase serait INSTANTANÉ — donc une phrase faible se casserait par
|
|
29
|
+
* force brute à des millions d'essais par seconde. scrypt est lent et gourmand À DESSEIN.
|
|
30
|
+
*
|
|
31
|
+
* Argon2id serait l'état de l'art (OWASP), mais il n'existe pas dans `node:crypto` : il
|
|
32
|
+
* exigerait un binaire natif ou du WASM, ce qui détruirait le zéro-dépendance ET le .exe
|
|
33
|
+
* pur-JS. Choix ASSUMÉ, à réévaluer si Node l'intègre (cf. docs/01-ETUDE-ETAT-ART).
|
|
34
|
+
*
|
|
35
|
+
* Le `salt` est RETOURNÉ : sans lui la clé est irrécupérable. Il n'est pas secret et se
|
|
36
|
+
* conserve à côté du chiffré.
|
|
37
|
+
*/
|
|
38
|
+
export declare function deriveKey(passphrase: string, opts?: DeriveKeyOptions): Promise<{
|
|
39
|
+
key: Buffer;
|
|
40
|
+
salt: Buffer;
|
|
41
|
+
}>;
|
|
42
|
+
/** Secret aléatoire (défaut 32 octets) — clés, sels, jetons. */
|
|
43
|
+
export declare function randomKey(bytes?: number): Buffer;
|
package/dist/hash.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @mostajs/crypto-box — Empreintes, HMAC, comparaison à temps constant.
|
|
3
|
+
* @author Dr Hamid MADANI <drmdh@msn.com>
|
|
4
|
+
* Licence : AGPL-3.0-or-later
|
|
5
|
+
*/
|
|
6
|
+
import { createHash, createHmac, timingSafeEqual as nodeTimingSafeEqual, randomBytes, scrypt as nodeScrypt } from 'node:crypto';
|
|
7
|
+
const enc = (buf, encoding) => encoding === 'hex' ? buf.toString('hex') : buf.toString('base64url');
|
|
8
|
+
/** SHA-256. Défaut : hex (convention des checksums et de SigV4). */
|
|
9
|
+
export function sha256(data, encoding = 'hex') {
|
|
10
|
+
return enc(createHash('sha256').update(data).digest(), encoding);
|
|
11
|
+
}
|
|
12
|
+
/** SHA-256 brut — pour les chaînages. */
|
|
13
|
+
export function sha256Bytes(data) {
|
|
14
|
+
return createHash('sha256').update(data).digest();
|
|
15
|
+
}
|
|
16
|
+
/** HMAC-SHA256. Défaut : hex. */
|
|
17
|
+
export function hmacSha256(key, data, encoding = 'hex') {
|
|
18
|
+
return enc(createHmac('sha256', key).update(data).digest(), encoding);
|
|
19
|
+
}
|
|
20
|
+
/** HMAC-SHA256 brut — pour les chaînages (SigV4 dérive sa clé par HMAC successifs). */
|
|
21
|
+
export function hmacSha256Bytes(key, data) {
|
|
22
|
+
return createHmac('sha256', key).update(data).digest();
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Compare deux valeurs en TEMPS CONSTANT. **Toute** comparaison de secret, de signature ou de
|
|
26
|
+
* jeton doit passer par ici — jamais par `===`, jamais par une boucle maison.
|
|
27
|
+
*
|
|
28
|
+
* `timingSafeEqual` de Node LÈVE si les tailles diffèrent : un appel naïf ferait tomber le
|
|
29
|
+
* serveur sur une signature tronquée, c'est-à-dire sur une entrée d'attaquant. On borne donc
|
|
30
|
+
* avant. La longueur ne fuit rien : celle d'un HMAC-SHA256 est publique et constante.
|
|
31
|
+
*/
|
|
32
|
+
export function equalsConstantTime(a, b) {
|
|
33
|
+
const bufA = Buffer.isBuffer(a) ? a : Buffer.from(a);
|
|
34
|
+
const bufB = Buffer.isBuffer(b) ? b : Buffer.from(b);
|
|
35
|
+
if (bufA.length !== bufB.length)
|
|
36
|
+
return false;
|
|
37
|
+
if (bufA.length === 0)
|
|
38
|
+
return true;
|
|
39
|
+
return nodeTimingSafeEqual(bufA, bufB);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Dérive une clé AES-256 (32 octets) depuis une PHRASE SECRÈTE, par scrypt (RFC 7914).
|
|
43
|
+
*
|
|
44
|
+
* Un simple SHA-256 de la phrase serait INSTANTANÉ — donc une phrase faible se casserait par
|
|
45
|
+
* force brute à des millions d'essais par seconde. scrypt est lent et gourmand À DESSEIN.
|
|
46
|
+
*
|
|
47
|
+
* Argon2id serait l'état de l'art (OWASP), mais il n'existe pas dans `node:crypto` : il
|
|
48
|
+
* exigerait un binaire natif ou du WASM, ce qui détruirait le zéro-dépendance ET le .exe
|
|
49
|
+
* pur-JS. Choix ASSUMÉ, à réévaluer si Node l'intègre (cf. docs/01-ETUDE-ETAT-ART).
|
|
50
|
+
*
|
|
51
|
+
* Le `salt` est RETOURNÉ : sans lui la clé est irrécupérable. Il n'est pas secret et se
|
|
52
|
+
* conserve à côté du chiffré.
|
|
53
|
+
*/
|
|
54
|
+
export function deriveKey(passphrase, opts = {}) {
|
|
55
|
+
const salt = opts.salt ? Buffer.from(opts.salt) : randomBytes(16);
|
|
56
|
+
const cost = opts.cost ?? 2 ** 17;
|
|
57
|
+
return new Promise((resolve, reject) => {
|
|
58
|
+
// `maxmem` doit suivre le coût, sinon Node refuse (« memory limit exceeded »).
|
|
59
|
+
nodeScrypt(passphrase, salt, 32, { N: cost, r: 8, p: 1, maxmem: 256 * cost * 8 }, (err, key) => {
|
|
60
|
+
if (err)
|
|
61
|
+
reject(err);
|
|
62
|
+
else
|
|
63
|
+
resolve({ key: key, salt });
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
/** Secret aléatoire (défaut 32 octets) — clés, sels, jetons. */
|
|
68
|
+
export function randomKey(bytes = 32) {
|
|
69
|
+
return randomBytes(bytes);
|
|
70
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -5,99 +5,29 @@
|
|
|
5
5
|
* Niveau : N0 (primitif) · ZÉRO dépendance (`node:crypto` uniquement)
|
|
6
6
|
*
|
|
7
7
|
* ─── POURQUOI CE MODULE EXISTE ───────────────────────────────────────────────
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* Voilà le vrai coût d'un doublon cryptographique : ce n'est pas la place qu'il prend,
|
|
14
|
-
* c'est que **les corrections ne remontent pas**. Une seule maison = un seul endroit à
|
|
15
|
-
* durcir, un seul endroit à auditer.
|
|
8
|
+
* En consolidant les URLs signées (13/07/2026), on a trouvé la MÊME comparaison de signature
|
|
9
|
+
* implémentée deux fois : « best effort » dans @mostajs/url — le KERNEL, utilisé partout — et
|
|
10
|
+
* correcte (timingSafeEqual) dans @mostajs/storage, une simple feuille. Le durcissement
|
|
11
|
+
* n'avait jamais circulé. Voilà le vrai coût d'un doublon cryptographique : ce n'est pas la
|
|
12
|
+
* place qu'il prend, c'est que LES CORRECTIONS NE REMONTENT PAS.
|
|
16
13
|
*
|
|
17
14
|
* ─── CE QUE CE MODULE N'EST PAS ──────────────────────────────────────────────
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* - `deriveKey` → réclamé par @mostajs/backup (clé depuis un secret)
|
|
28
|
-
* On l'enrichira au prochain besoin RÉEL. Pas avant.
|
|
29
|
-
*
|
|
30
|
-
* ─── CE DONT CE MODULE NE RÉPOND PAS ─────────────────────────────────────────
|
|
31
|
-
* La GARDE de la clé n'est pas son affaire — c'est une décision produit. Pour les
|
|
32
|
-
* sauvegardes TicketFlow : la clé reste chez le client, la console ne stocke que du chiffré
|
|
33
|
-
* qu'elle ne peut pas lire. Corollaire assumé : **clé perdue = archive irrécupérable.**
|
|
15
|
+
* PAS une « bibliothèque de crypto ». Une enveloppe MINCE et OPINIONÉE sur node:crypto :
|
|
16
|
+
* pas de choix d'algorithme hasardeux, pas de mode dangereux, JAMAIS de nonce fourni par
|
|
17
|
+
* l'appelant. Un module de crypto qui offre des options est la façon classique d'écrire du
|
|
18
|
+
* chiffrement cassé. Il n'ajoute que ce qu'un consommateur RÉEL réclame.
|
|
19
|
+
*
|
|
20
|
+
* ─── CE DONT IL NE RÉPOND PAS ────────────────────────────────────────────────
|
|
21
|
+
* La GARDE de la clé est une décision produit. Le modèle d'ENVELOPPE (voir envelope.ts) est
|
|
22
|
+
* précisément ce qui permet de vendre la conservation d'archives SANS détenir les secrets du
|
|
23
|
+
* client : la console garde une enveloppe qu'elle ne sait pas ouvrir.
|
|
34
24
|
*/
|
|
25
|
+
export type { Bytes, Encoding } from './types.js';
|
|
26
|
+
export { sha256, sha256Bytes, hmacSha256, hmacSha256Bytes, equalsConstantTime, deriveKey, randomKey, type DeriveKeyOptions, } from './hash.js';
|
|
27
|
+
export { aeadEncrypt, aeadDecrypt, aeadEncryptStream, aeadDecryptStream, CHUNK_SIZE, type AeadAlgo, } from './aead.js';
|
|
28
|
+
export { generateSigningKeyPair, sign, verifySignature, deriveSubKey, generateRecipientKeyPair, seal, open, type KeyPair, } from './envelope.js';
|
|
35
29
|
export declare const moduleInfo: {
|
|
36
30
|
readonly name: "@mostajs/crypto-box";
|
|
37
|
-
readonly version: "0.
|
|
31
|
+
readonly version: "0.2.0";
|
|
38
32
|
readonly level: "N0";
|
|
39
33
|
};
|
|
40
|
-
export type Bytes = Uint8Array | Buffer;
|
|
41
|
-
type Encoding = 'hex' | 'base64url';
|
|
42
|
-
/** SHA-256. Défaut : hex (convention des checksums et de SigV4). */
|
|
43
|
-
export declare function sha256(data: Bytes | string, encoding?: Encoding): string;
|
|
44
|
-
/** SHA-256 brut — pour les chaînages (dérivation de clé SigV4, par ex.). */
|
|
45
|
-
export declare function sha256Bytes(data: Bytes | string): Buffer;
|
|
46
|
-
/** HMAC-SHA256. Défaut : hex. */
|
|
47
|
-
export declare function hmacSha256(key: Bytes | string, data: Bytes | string, encoding?: Encoding): string;
|
|
48
|
-
/** HMAC-SHA256 brut — pour les chaînages (SigV4 dérive sa clé par HMAC successifs). */
|
|
49
|
-
export declare function hmacSha256Bytes(key: Bytes | string, data: Bytes | string): Buffer;
|
|
50
|
-
/**
|
|
51
|
-
* Compare deux valeurs en TEMPS CONSTANT. **Toute** comparaison de secret, de signature ou
|
|
52
|
-
* de jeton doit passer par ici — jamais par `===`, jamais par une boucle maison.
|
|
53
|
-
*
|
|
54
|
-
* C'est LA fonction dont l'absence d'un foyer unique a causé la divergence du 13/07 : le
|
|
55
|
-
* kernel comparait « best effort » quand une feuille faisait correctement.
|
|
56
|
-
*
|
|
57
|
-
* La différence de longueur est renvoyée sans comparaison — elle ne fuit rien : la longueur
|
|
58
|
-
* d'un HMAC-SHA256 est publique et constante.
|
|
59
|
-
*/
|
|
60
|
-
export declare function equalsConstantTime(a: Bytes | string, b: Bytes | string): boolean;
|
|
61
|
-
export interface DeriveKeyOptions {
|
|
62
|
-
/** Sel — 16 octets aléatoires recommandés. Généré si absent (et RETOURNÉ : conservez-le). */
|
|
63
|
-
salt?: Bytes;
|
|
64
|
-
/** Coût CPU/mémoire (puissance de 2). Défaut 2^17 — ~100 ms, coûteux à brute-forcer. */
|
|
65
|
-
cost?: number;
|
|
66
|
-
}
|
|
67
|
-
/**
|
|
68
|
-
* Dérive une clé AES-256 (32 octets) depuis une phrase secrète, par **scrypt**.
|
|
69
|
-
*
|
|
70
|
-
* Pourquoi scrypt et pas un simple SHA-256 de la phrase : un hachage est INSTANTANÉ, donc
|
|
71
|
-
* une phrase faible se casse par force brute à des millions d'essais par seconde. scrypt est
|
|
72
|
-
* délibérément **lent et gourmand en mémoire** — c'est le but.
|
|
73
|
-
*
|
|
74
|
-
* Le `salt` est RETOURNÉ : sans lui, la clé est irrécupérable. Il n'est pas secret et doit
|
|
75
|
-
* être stocké à côté du chiffré (c'est ce que fait `aeadEncrypt`).
|
|
76
|
-
*/
|
|
77
|
-
export declare function deriveKey(passphrase: string, opts?: DeriveKeyOptions): Promise<{
|
|
78
|
-
key: Buffer;
|
|
79
|
-
salt: Buffer;
|
|
80
|
-
}>;
|
|
81
|
-
/** Secret aléatoire (défaut 32 octets) — clés, sels, jetons. */
|
|
82
|
-
export declare function randomKey(bytes?: number): Buffer;
|
|
83
|
-
/**
|
|
84
|
-
* Chiffre en **AES-256-GCM** (chiffrement AUTHENTIFIÉ : confidentialité ET intégrité).
|
|
85
|
-
*
|
|
86
|
-
* Le format de sortie est **autoportant** — `MJSC1 | iv(12) | tag(16) | chiffré` : il n'y a
|
|
87
|
-
* rien d'autre à conserver que la clé. Sans cela, l'appelant devrait gérer lui-même l'IV et
|
|
88
|
-
* le tag, et un seul oubli rend le déchiffrement impossible… ou pire, l'IV est réutilisé.
|
|
89
|
-
*
|
|
90
|
-
* L'IV est TIRÉ AU HASARD À CHAQUE APPEL et n'est jamais fourni par l'appelant. Réutiliser un
|
|
91
|
-
* IV en GCM est catastrophique — cela révèle le XOR des clairs et permet de forger des
|
|
92
|
-
* messages. C'est la raison pour laquelle cette API n'offre pas l'option.
|
|
93
|
-
*/
|
|
94
|
-
export declare function aeadEncrypt(key: Bytes, plaintext: Bytes | string): Buffer;
|
|
95
|
-
/**
|
|
96
|
-
* Déchiffre un blob produit par `aeadEncrypt`.
|
|
97
|
-
*
|
|
98
|
-
* Lève si la clé est fausse OU si le chiffré a été **altéré d'un seul bit** : c'est le tag
|
|
99
|
-
* GCM qui l'impose. Un déchiffrement qui « réussit » sur des octets corrompus est exactement
|
|
100
|
-
* ce qu'on ne veut pas d'une sauvegarde.
|
|
101
|
-
*/
|
|
102
|
-
export declare function aeadDecrypt(key: Bytes, blob: Bytes): Buffer;
|
|
103
|
-
export {};
|
package/dist/index.js
CHANGED
|
@@ -5,142 +5,27 @@
|
|
|
5
5
|
* Niveau : N0 (primitif) · ZÉRO dépendance (`node:crypto` uniquement)
|
|
6
6
|
*
|
|
7
7
|
* ─── POURQUOI CE MODULE EXISTE ───────────────────────────────────────────────
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* Voilà le vrai coût d'un doublon cryptographique : ce n'est pas la place qu'il prend,
|
|
14
|
-
* c'est que **les corrections ne remontent pas**. Une seule maison = un seul endroit à
|
|
15
|
-
* durcir, un seul endroit à auditer.
|
|
8
|
+
* En consolidant les URLs signées (13/07/2026), on a trouvé la MÊME comparaison de signature
|
|
9
|
+
* implémentée deux fois : « best effort » dans @mostajs/url — le KERNEL, utilisé partout — et
|
|
10
|
+
* correcte (timingSafeEqual) dans @mostajs/storage, une simple feuille. Le durcissement
|
|
11
|
+
* n'avait jamais circulé. Voilà le vrai coût d'un doublon cryptographique : ce n'est pas la
|
|
12
|
+
* place qu'il prend, c'est que LES CORRECTIONS NE REMONTENT PAS.
|
|
16
13
|
*
|
|
17
14
|
* ─── CE QUE CE MODULE N'EST PAS ──────────────────────────────────────────────
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* - `deriveKey` → réclamé par @mostajs/backup (clé depuis un secret)
|
|
28
|
-
* On l'enrichira au prochain besoin RÉEL. Pas avant.
|
|
29
|
-
*
|
|
30
|
-
* ─── CE DONT CE MODULE NE RÉPOND PAS ─────────────────────────────────────────
|
|
31
|
-
* La GARDE de la clé n'est pas son affaire — c'est une décision produit. Pour les
|
|
32
|
-
* sauvegardes TicketFlow : la clé reste chez le client, la console ne stocke que du chiffré
|
|
33
|
-
* qu'elle ne peut pas lire. Corollaire assumé : **clé perdue = archive irrécupérable.**
|
|
34
|
-
*/
|
|
35
|
-
import { createHash, createHmac, timingSafeEqual as nodeTimingSafeEqual, randomBytes, createCipheriv, createDecipheriv, scrypt as nodeScrypt, } from 'node:crypto';
|
|
36
|
-
export const moduleInfo = { name: '@mostajs/crypto-box', version: '0.1.0', level: 'N0' };
|
|
37
|
-
const enc = (buf, encoding) => encoding === 'hex' ? buf.toString('hex') : buf.toString('base64url');
|
|
38
|
-
// ─── Empreintes ──────────────────────────────────────────────────────────────
|
|
39
|
-
/** SHA-256. Défaut : hex (convention des checksums et de SigV4). */
|
|
40
|
-
export function sha256(data, encoding = 'hex') {
|
|
41
|
-
return enc(createHash('sha256').update(data).digest(), encoding);
|
|
42
|
-
}
|
|
43
|
-
/** SHA-256 brut — pour les chaînages (dérivation de clé SigV4, par ex.). */
|
|
44
|
-
export function sha256Bytes(data) {
|
|
45
|
-
return createHash('sha256').update(data).digest();
|
|
46
|
-
}
|
|
47
|
-
/** HMAC-SHA256. Défaut : hex. */
|
|
48
|
-
export function hmacSha256(key, data, encoding = 'hex') {
|
|
49
|
-
return enc(createHmac('sha256', key).update(data).digest(), encoding);
|
|
50
|
-
}
|
|
51
|
-
/** HMAC-SHA256 brut — pour les chaînages (SigV4 dérive sa clé par HMAC successifs). */
|
|
52
|
-
export function hmacSha256Bytes(key, data) {
|
|
53
|
-
return createHmac('sha256', key).update(data).digest();
|
|
54
|
-
}
|
|
55
|
-
// ─── Comparaison ─────────────────────────────────────────────────────────────
|
|
56
|
-
/**
|
|
57
|
-
* Compare deux valeurs en TEMPS CONSTANT. **Toute** comparaison de secret, de signature ou
|
|
58
|
-
* de jeton doit passer par ici — jamais par `===`, jamais par une boucle maison.
|
|
59
|
-
*
|
|
60
|
-
* C'est LA fonction dont l'absence d'un foyer unique a causé la divergence du 13/07 : le
|
|
61
|
-
* kernel comparait « best effort » quand une feuille faisait correctement.
|
|
62
|
-
*
|
|
63
|
-
* La différence de longueur est renvoyée sans comparaison — elle ne fuit rien : la longueur
|
|
64
|
-
* d'un HMAC-SHA256 est publique et constante.
|
|
65
|
-
*/
|
|
66
|
-
export function equalsConstantTime(a, b) {
|
|
67
|
-
const bufA = Buffer.isBuffer(a) ? a : Buffer.from(a);
|
|
68
|
-
const bufB = Buffer.isBuffer(b) ? b : Buffer.from(b);
|
|
69
|
-
if (bufA.length !== bufB.length)
|
|
70
|
-
return false;
|
|
71
|
-
if (bufA.length === 0)
|
|
72
|
-
return true;
|
|
73
|
-
return nodeTimingSafeEqual(bufA, bufB);
|
|
74
|
-
}
|
|
75
|
-
/**
|
|
76
|
-
* Dérive une clé AES-256 (32 octets) depuis une phrase secrète, par **scrypt**.
|
|
77
|
-
*
|
|
78
|
-
* Pourquoi scrypt et pas un simple SHA-256 de la phrase : un hachage est INSTANTANÉ, donc
|
|
79
|
-
* une phrase faible se casse par force brute à des millions d'essais par seconde. scrypt est
|
|
80
|
-
* délibérément **lent et gourmand en mémoire** — c'est le but.
|
|
81
|
-
*
|
|
82
|
-
* Le `salt` est RETOURNÉ : sans lui, la clé est irrécupérable. Il n'est pas secret et doit
|
|
83
|
-
* être stocké à côté du chiffré (c'est ce que fait `aeadEncrypt`).
|
|
84
|
-
*/
|
|
85
|
-
export function deriveKey(passphrase, opts = {}) {
|
|
86
|
-
const salt = opts.salt ? Buffer.from(opts.salt) : randomBytes(16);
|
|
87
|
-
const cost = opts.cost ?? 2 ** 17;
|
|
88
|
-
return new Promise((resolve, reject) => {
|
|
89
|
-
// `maxmem` doit suivre le coût, sinon Node refuse (« memory limit exceeded »).
|
|
90
|
-
nodeScrypt(passphrase, salt, 32, { N: cost, r: 8, p: 1, maxmem: 256 * cost * 8 }, (err, key) => {
|
|
91
|
-
if (err)
|
|
92
|
-
reject(err);
|
|
93
|
-
else
|
|
94
|
-
resolve({ key: key, salt });
|
|
95
|
-
});
|
|
96
|
-
});
|
|
97
|
-
}
|
|
98
|
-
/** Secret aléatoire (défaut 32 octets) — clés, sels, jetons. */
|
|
99
|
-
export function randomKey(bytes = 32) {
|
|
100
|
-
return randomBytes(bytes);
|
|
101
|
-
}
|
|
102
|
-
// ─── Chiffrement authentifié (AEAD) ──────────────────────────────────────────
|
|
103
|
-
const MAGIC = Buffer.from('MJSC1'); // 5 octets — repère de format, versionné
|
|
104
|
-
const IV_LEN = 12; // GCM : 96 bits, la taille recommandée
|
|
105
|
-
const TAG_LEN = 16;
|
|
106
|
-
/**
|
|
107
|
-
* Chiffre en **AES-256-GCM** (chiffrement AUTHENTIFIÉ : confidentialité ET intégrité).
|
|
108
|
-
*
|
|
109
|
-
* Le format de sortie est **autoportant** — `MJSC1 | iv(12) | tag(16) | chiffré` : il n'y a
|
|
110
|
-
* rien d'autre à conserver que la clé. Sans cela, l'appelant devrait gérer lui-même l'IV et
|
|
111
|
-
* le tag, et un seul oubli rend le déchiffrement impossible… ou pire, l'IV est réutilisé.
|
|
112
|
-
*
|
|
113
|
-
* L'IV est TIRÉ AU HASARD À CHAQUE APPEL et n'est jamais fourni par l'appelant. Réutiliser un
|
|
114
|
-
* IV en GCM est catastrophique — cela révèle le XOR des clairs et permet de forger des
|
|
115
|
-
* messages. C'est la raison pour laquelle cette API n'offre pas l'option.
|
|
116
|
-
*/
|
|
117
|
-
export function aeadEncrypt(key, plaintext) {
|
|
118
|
-
if (key.length !== 32)
|
|
119
|
-
throw new Error('crypto-box : la clé AES-256 doit faire 32 octets (voir deriveKey)');
|
|
120
|
-
const iv = randomBytes(IV_LEN);
|
|
121
|
-
const cipher = createCipheriv('aes-256-gcm', Buffer.from(key), iv);
|
|
122
|
-
const body = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
123
|
-
return Buffer.concat([MAGIC, iv, cipher.getAuthTag(), body]);
|
|
124
|
-
}
|
|
125
|
-
/**
|
|
126
|
-
* Déchiffre un blob produit par `aeadEncrypt`.
|
|
127
|
-
*
|
|
128
|
-
* Lève si la clé est fausse OU si le chiffré a été **altéré d'un seul bit** : c'est le tag
|
|
129
|
-
* GCM qui l'impose. Un déchiffrement qui « réussit » sur des octets corrompus est exactement
|
|
130
|
-
* ce qu'on ne veut pas d'une sauvegarde.
|
|
15
|
+
* PAS une « bibliothèque de crypto ». Une enveloppe MINCE et OPINIONÉE sur node:crypto :
|
|
16
|
+
* pas de choix d'algorithme hasardeux, pas de mode dangereux, JAMAIS de nonce fourni par
|
|
17
|
+
* l'appelant. Un module de crypto qui offre des options est la façon classique d'écrire du
|
|
18
|
+
* chiffrement cassé. Il n'ajoute que ce qu'un consommateur RÉEL réclame.
|
|
19
|
+
*
|
|
20
|
+
* ─── CE DONT IL NE RÉPOND PAS ────────────────────────────────────────────────
|
|
21
|
+
* La GARDE de la clé est une décision produit. Le modèle d'ENVELOPPE (voir envelope.ts) est
|
|
22
|
+
* précisément ce qui permet de vendre la conservation d'archives SANS détenir les secrets du
|
|
23
|
+
* client : la console garde une enveloppe qu'elle ne sait pas ouvrir.
|
|
131
24
|
*/
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
throw new Error('crypto-box : format inconnu (pas un blob MJSC1)');
|
|
140
|
-
const iv = buf.subarray(MAGIC.length, MAGIC.length + IV_LEN);
|
|
141
|
-
const tag = buf.subarray(MAGIC.length + IV_LEN, MAGIC.length + IV_LEN + TAG_LEN);
|
|
142
|
-
const body = buf.subarray(MAGIC.length + IV_LEN + TAG_LEN);
|
|
143
|
-
const decipher = createDecipheriv('aes-256-gcm', Buffer.from(key), iv);
|
|
144
|
-
decipher.setAuthTag(tag);
|
|
145
|
-
return Buffer.concat([decipher.update(body), decipher.final()]); // `final()` vérifie le tag
|
|
146
|
-
}
|
|
25
|
+
// Empreintes, HMAC, comparaison, clé depuis une phrase.
|
|
26
|
+
export { sha256, sha256Bytes, hmacSha256, hmacSha256Bytes, equalsConstantTime, deriveKey, randomKey, } from './hash.js';
|
|
27
|
+
// Chiffrement authentifié — en un coup ET EN FLUX (les archives ne tiennent pas en mémoire).
|
|
28
|
+
export { aeadEncrypt, aeadDecrypt, aeadEncryptStream, aeadDecryptStream, CHUNK_SIZE, } from './aead.js';
|
|
29
|
+
// Signature (Ed25519), sous-clés (HKDF), et chiffrement par ENVELOPPE (X25519).
|
|
30
|
+
export { generateSigningKeyPair, sign, verifySignature, deriveSubKey, generateRecipientKeyPair, seal, open, } from './envelope.js';
|
|
31
|
+
export const moduleInfo = { name: '@mostajs/crypto-box', version: '0.2.0', level: 'N0' };
|
package/dist/types.d.ts
ADDED
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,23 +1,46 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mostajs/crypto-box",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Les primitives cryptographiques de l'écosystème, une seule maison :
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Les primitives cryptographiques de l'écosystème, une seule maison : AEAD EN FLUX (AES-GCM / ChaCha20-Poly1305), chiffrement par ENVELOPPE (X25519, modèle age), signature Ed25519, HKDF, scrypt, comparaison à temps constant. Enveloppe MINCE et opinionée sur node:crypto. Primitive N0, zéro dépendance.",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"author": "Dr Hamid MADANI <drmdh@msn.com>",
|
|
7
7
|
"type": "module",
|
|
8
8
|
"exports": {
|
|
9
|
-
".": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js",
|
|
12
|
+
"default": "./dist/index.js"
|
|
13
|
+
}
|
|
10
14
|
},
|
|
11
|
-
"files": [
|
|
15
|
+
"files": [
|
|
16
|
+
"dist",
|
|
17
|
+
"README.md",
|
|
18
|
+
"llms.txt",
|
|
19
|
+
"LICENSE"
|
|
20
|
+
],
|
|
12
21
|
"scripts": {
|
|
13
22
|
"build": "tsc",
|
|
14
23
|
"test": "bash test-scripts/run-tests.sh",
|
|
15
24
|
"prepublishOnly": "npm run build"
|
|
16
25
|
},
|
|
17
|
-
"keywords": [
|
|
26
|
+
"keywords": [
|
|
27
|
+
"crypto",
|
|
28
|
+
"aead",
|
|
29
|
+
"streaming",
|
|
30
|
+
"envelope",
|
|
31
|
+
"x25519",
|
|
32
|
+
"ed25519",
|
|
33
|
+
"hkdf",
|
|
34
|
+
"chacha20",
|
|
35
|
+
"aes-256-gcm",
|
|
36
|
+
"scrypt",
|
|
37
|
+
"timing-safe",
|
|
38
|
+
"zero-dep",
|
|
39
|
+
"mostajs"
|
|
40
|
+
],
|
|
18
41
|
"devDependencies": {
|
|
19
42
|
"@mostajs/mjs-unit": "^0.3.1",
|
|
20
43
|
"@types/node": "^20",
|
|
21
44
|
"typescript": "^5.6.0"
|
|
22
45
|
}
|
|
23
|
-
}
|
|
46
|
+
}
|