@mostajs/enrollment 0.2.0 → 0.2.1
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/cjs/agent.js +123 -0
- package/dist/cjs/code.js +106 -0
- package/dist/cjs/index.js +46 -0
- package/dist/cjs/machine.js +108 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/service.js +179 -0
- package/dist/cjs/types.js +6 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/package.json +8 -5
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @mostajs/enrollment — L'agent MACHINE : se présente, insiste, se rattache tout seul.
|
|
4
|
+
* @author Dr Hamid MADANI <drmdh@msn.com>
|
|
5
|
+
* Licence : AGPL-3.0-or-later
|
|
6
|
+
*/
|
|
7
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8
|
+
exports.generateIdentity = generateIdentity;
|
|
9
|
+
exports.createEnrollmentAgent = createEnrollmentAgent;
|
|
10
|
+
const crypto_box_1 = require("@mostajs/crypto-box");
|
|
11
|
+
/** Génère l'identité de la machine (au premier démarrage). La PRIVÉE ne quitte jamais le poste. */
|
|
12
|
+
function generateIdentity() {
|
|
13
|
+
return (0, crypto_box_1.generateSigningKeyPair)();
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Crée l'agent d'enrôlement.
|
|
17
|
+
*
|
|
18
|
+
* ─── LA RE-TENTATIVE EST LE CŒUR ─────────────────────────────────────────────
|
|
19
|
+
* Sans elle, l'approbation manuelle est un piège : le client installe un dimanche à 8 h, voit
|
|
20
|
+
* « en attente »… et devrait tout recommencer lundi ? Absurde.
|
|
21
|
+
*
|
|
22
|
+
* L'agent RE-SONDE en boucle (`@mostajs/scheduler`). Le client installe et part travailler ;
|
|
23
|
+
* vous approuvez quand vous voulez ; la machine se rattache TOUTE SEULE et la bannière
|
|
24
|
+
* disparaît. **Personne ne refait rien.**
|
|
25
|
+
*
|
|
26
|
+
* L'appel est SORTANT : aucun certificat, aucun port ouvert, aucun reverse proxy chez le client.
|
|
27
|
+
* Fonctionne derrière une box, un NAT, un pare-feu d'entreprise.
|
|
28
|
+
*/
|
|
29
|
+
function createEnrollmentAgent(opts) {
|
|
30
|
+
const http = opts.fetchImpl ?? fetch;
|
|
31
|
+
let etat = { state: 'non_enrole' };
|
|
32
|
+
const poser = async (s) => {
|
|
33
|
+
etat = s;
|
|
34
|
+
await opts.persist(s);
|
|
35
|
+
opts.onState?.(s);
|
|
36
|
+
};
|
|
37
|
+
async function cycle() {
|
|
38
|
+
if (etat.state === 'enrole' || etat.state === 'refuse')
|
|
39
|
+
return; // terminal
|
|
40
|
+
try {
|
|
41
|
+
// 1. Se présenter (une fois), puis 2. sonder jusqu'à décision.
|
|
42
|
+
if (etat.state !== 'en_attente') {
|
|
43
|
+
const r = await http(`${opts.consoleUrl}/enroll/request`, {
|
|
44
|
+
method: 'POST',
|
|
45
|
+
headers: { 'content-type': 'application/json' },
|
|
46
|
+
body: JSON.stringify({
|
|
47
|
+
code: opts.code,
|
|
48
|
+
machineId: opts.machineId,
|
|
49
|
+
hostname: opts.hostname,
|
|
50
|
+
app: opts.app,
|
|
51
|
+
version: opts.version,
|
|
52
|
+
publicKey: Buffer.from(opts.identity.publicKey).toString('hex'),
|
|
53
|
+
}),
|
|
54
|
+
}).then((x) => x.json());
|
|
55
|
+
if (!r.ok) {
|
|
56
|
+
await poser({ state: 'erreur', message: r.error ?? 'refus' });
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
await poser({ state: 'en_attente', requestId: r.requestId, depuis: new Date().toISOString() });
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const s = await http(`${opts.consoleUrl}/enroll/status/${etat.requestId}`).then((x) => x.json());
|
|
63
|
+
if (s.state === 'rejected') {
|
|
64
|
+
await poser({ state: 'refuse' });
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (s.state !== 'approved')
|
|
68
|
+
return; // toujours en attente — on repassera
|
|
69
|
+
// ── VÉRIFIER LA SIGNATURE avant de croire quoi que ce soit.
|
|
70
|
+
const approval = s.approval;
|
|
71
|
+
const valide = (0, crypto_box_1.verifySignature)(opts.consolePublicKey, JSON.stringify(approval), Buffer.from(s.signature, 'hex'));
|
|
72
|
+
if (!valide) {
|
|
73
|
+
// Une réponse non signée par NOTRE console est une réponse d'imposteur. On ne l'applique
|
|
74
|
+
// pas, et on ne se tait pas.
|
|
75
|
+
await poser({ state: 'erreur', message: 'approbation NON SIGNÉE par la console attendue — réponse ignorée' });
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
// La console doit aussi présenter LA clé publique qu'on connaît.
|
|
79
|
+
if (!(0, crypto_box_1.equalsConstantTime)(approval.consolePublicKey, Buffer.from(opts.consolePublicKey).toString('hex'))) {
|
|
80
|
+
await poser({ state: 'erreur', message: 'la console renvoie une clé publique INATTENDUE' });
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
await poser({ state: 'enrole', siteId: approval.siteId, name: approval.name, token: approval.token });
|
|
84
|
+
}
|
|
85
|
+
catch (e) {
|
|
86
|
+
// Console injoignable : ce n'est PAS une erreur fatale. La machine continue de servir en
|
|
87
|
+
// local, et réessaiera au prochain tour. Les tickets doivent continuer à sortir.
|
|
88
|
+
opts.onState?.({ state: 'erreur', message: e.message });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
// ─── POURQUOI PAS `@mostajs/scheduler` ? ──────────────────────────────────
|
|
92
|
+
// La tentation était forte : il y a une boucle, une cadence. Mais son CONTRAT est celui d'un
|
|
93
|
+
// déclencheur à FRONTIÈRES : il adopte la période courante au démarrage SANS rien lancer, et
|
|
94
|
+
// ne déclenche qu'une fois par frontière franchie. C'est exactement ce qu'il faut pour une
|
|
95
|
+
// sauvegarde nocturne — et exactement ce qu'il NE faut PAS ici : l'agent doit sonder à CHAQUE
|
|
96
|
+
// tour, dès le premier, jusqu'à obtenir une réponse.
|
|
97
|
+
//
|
|
98
|
+
// Le détourner (mode `count` avec un compteur artificiel) « marcherait »… puis casserait au
|
|
99
|
+
// premier changement de sémantique du planificateur. Une boucle de re-tentative n'est pas un
|
|
100
|
+
// planificateur : on ne force pas l'un dans l'autre. Douze lignes ici valent mieux qu'une
|
|
101
|
+
// dépendance mal utilisée.
|
|
102
|
+
let timer = null;
|
|
103
|
+
return {
|
|
104
|
+
start() {
|
|
105
|
+
if (timer)
|
|
106
|
+
return;
|
|
107
|
+
void cycle(); // on sonde IMMÉDIATEMENT — le client ne doit pas attendre une minute pour rien
|
|
108
|
+
timer = setInterval(() => { void cycle(); }, opts.intervalMs ?? 60_000);
|
|
109
|
+
// Le processus ne doit pas être maintenu en vie par cette boucle.
|
|
110
|
+
if (typeof timer?.unref === 'function') {
|
|
111
|
+
timer.unref();
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
stop() {
|
|
115
|
+
if (timer) {
|
|
116
|
+
clearInterval(timer);
|
|
117
|
+
timer = null;
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
tick: cycle,
|
|
121
|
+
state: () => etat,
|
|
122
|
+
};
|
|
123
|
+
}
|
package/dist/cjs/code.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @mostajs/enrollment — Le code d'enrôlement : dictable au téléphone.
|
|
4
|
+
* @author Dr Hamid MADANI <drmdh@msn.com>
|
|
5
|
+
* Licence : AGPL-3.0-or-later
|
|
6
|
+
*/
|
|
7
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8
|
+
exports._internal = void 0;
|
|
9
|
+
exports.generateCode = generateCode;
|
|
10
|
+
exports.normalizeCode = normalizeCode;
|
|
11
|
+
exports.parseCode = parseCode;
|
|
12
|
+
exports.hashCode = hashCode;
|
|
13
|
+
exports.verifyCode = verifyCode;
|
|
14
|
+
const crypto_box_1 = require("@mostajs/crypto-box");
|
|
15
|
+
/**
|
|
16
|
+
* Alphabet **Crockford Base32** : ni `I`, ni `L`, ni `O`, ni `U`.
|
|
17
|
+
*
|
|
18
|
+
* Ce n'est pas un détail esthétique. Un code se dicte au téléphone, et « c'est un zéro ou un
|
|
19
|
+
* O ? », « un 1 ou un I ? » sont les deux questions qui font échouer une installation à
|
|
20
|
+
* distance. `U` est retiré pour ne pas former de mot grossier par accident.
|
|
21
|
+
*/
|
|
22
|
+
const ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
|
|
23
|
+
/** Confusions courantes, corrigées SILENCIEUSEMENT à la lecture (Crockford). */
|
|
24
|
+
const CORRECTIONS = { O: '0', I: '1', L: '1', U: 'V' };
|
|
25
|
+
const PREFIX = 'MJSE';
|
|
26
|
+
const RANDOM_LEN = 10;
|
|
27
|
+
const CHECK_LEN = 2;
|
|
28
|
+
/** Somme de contrôle : 2 caractères dérivés du corps du code. */
|
|
29
|
+
function checksum(body) {
|
|
30
|
+
const digest = (0, crypto_box_1.sha256)(`mostajs-enrollment-code/v1:${body}`); // hex
|
|
31
|
+
const n = parseInt(digest.slice(0, 4), 16); // 16 bits
|
|
32
|
+
return ALPHABET[(n >> 5) & 31] + ALPHABET[n & 31];
|
|
33
|
+
}
|
|
34
|
+
/** Met en forme : `MJSE-XXXX-XXXX-XXXX`. */
|
|
35
|
+
function format(body12) {
|
|
36
|
+
return `${PREFIX}-${body12.slice(0, 4)}-${body12.slice(4, 8)}-${body12.slice(8, 12)}`;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Génère un code d'enrôlement.
|
|
40
|
+
*
|
|
41
|
+
* 10 caractères aléatoires (50 bits) + 2 de contrôle. 50 bits ne résistent PAS à une attaque
|
|
42
|
+
* en ligne illimitée — c'est pourquoi le service verrouille après quelques échecs (voir
|
|
43
|
+
* `service.ts`). Un code n'est pas un mot de passe : il est à usage unique et expire en 7 jours.
|
|
44
|
+
*/
|
|
45
|
+
function generateCode(secret) {
|
|
46
|
+
const bytes = (0, crypto_box_1.randomKey)(16);
|
|
47
|
+
let body = '';
|
|
48
|
+
for (let i = 0; i < RANDOM_LEN; i++)
|
|
49
|
+
body += ALPHABET[bytes[i] & 31];
|
|
50
|
+
body += checksum(body);
|
|
51
|
+
return { display: format(body), canonical: body, hash: hashCode(body, secret) };
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Normalise une saisie humaine : majuscules, tirets et espaces retirés, préfixe optionnel,
|
|
55
|
+
* confusions Crockford corrigées (`O`→`0`, `I`/`L`→`1`, `U`→`V`).
|
|
56
|
+
*
|
|
57
|
+
* On accepte donc `mjse 7k4p-2xq9 m3ta`, `7K4P2XQ9M3TA`, ou une saisie où le client a tapé `O`
|
|
58
|
+
* au lieu de zéro. Refuser un code correct pour une virgule de forme serait absurde : c'est
|
|
59
|
+
* l'ENTROPIE qui protège, pas la ponctuation.
|
|
60
|
+
*/
|
|
61
|
+
function normalizeCode(input) {
|
|
62
|
+
let s = String(input ?? '').toUpperCase().replace(/[\s-]/g, '');
|
|
63
|
+
if (s.startsWith(PREFIX))
|
|
64
|
+
s = s.slice(PREFIX.length);
|
|
65
|
+
return [...s].map((c) => CORRECTIONS[c] ?? c).join('');
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Valide la FORME d'un code, **hors ligne**.
|
|
69
|
+
*
|
|
70
|
+
* C'est tout l'intérêt de la somme de contrôle : une faute de frappe est rejetée **avant le
|
|
71
|
+
* moindre appel réseau**. Le client voit « code invalide » immédiatement, au lieu d'attendre
|
|
72
|
+
* trente secondes pour un « refusé » qui ne lui dit pas si c'est lui ou le serveur.
|
|
73
|
+
*
|
|
74
|
+
* Elle ne prouve RIEN sur la validité du code — seulement qu'il a été correctement recopié.
|
|
75
|
+
*/
|
|
76
|
+
function parseCode(input) {
|
|
77
|
+
const c = normalizeCode(input);
|
|
78
|
+
if (c.length !== RANDOM_LEN + CHECK_LEN)
|
|
79
|
+
return { ok: false, error: 'longueur' };
|
|
80
|
+
if ([...c].some((ch) => !ALPHABET.includes(ch)))
|
|
81
|
+
return { ok: false, error: 'caractere_invalide' };
|
|
82
|
+
const body = c.slice(0, RANDOM_LEN);
|
|
83
|
+
const check = c.slice(RANDOM_LEN);
|
|
84
|
+
if (check !== checksum(body))
|
|
85
|
+
return { ok: false, error: 'somme_de_controle' };
|
|
86
|
+
return { ok: true, canonical: c };
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Empreinte du code, pour la console.
|
|
90
|
+
*
|
|
91
|
+
* Le code n'est **jamais** stocké en clair : une fuite de la base ne doit donner **aucun** code
|
|
92
|
+
* utilisable. C'est un secret d'authentification — on le traite comme un mot de passe.
|
|
93
|
+
* HMAC (et non simple SHA) : sans le `secret` de la console, on ne peut pas pré-calculer un
|
|
94
|
+
* dictionnaire de 2⁵⁰ empreintes.
|
|
95
|
+
*/
|
|
96
|
+
function hashCode(canonical, secret) {
|
|
97
|
+
return (0, crypto_box_1.hmacSha256)(secret, `mostajs-enrollment-code/v1:${normalizeCode(canonical)}`);
|
|
98
|
+
}
|
|
99
|
+
/** Compare un code saisi à une empreinte — en TEMPS CONSTANT. */
|
|
100
|
+
function verifyCode(input, hash, secret) {
|
|
101
|
+
const p = parseCode(input);
|
|
102
|
+
if (!p.ok)
|
|
103
|
+
return false;
|
|
104
|
+
return (0, crypto_box_1.equalsConstantTime)(hashCode(p.canonical, secret), hash);
|
|
105
|
+
}
|
|
106
|
+
exports._internal = { ALPHABET, checksum, format, PREFIX, RANDOM_LEN, CHECK_LEN };
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @mostajs/enrollment — Enrôler une machine chez un client distant, sans configuration.
|
|
4
|
+
* @author Dr Hamid MADANI <drmdh@msn.com>
|
|
5
|
+
* Licence : AGPL-3.0-or-later
|
|
6
|
+
* Niveau : N1 · Compose : crypto-box (Ed25519, HMAC) · scheduler (re-tentative)
|
|
7
|
+
*
|
|
8
|
+
* ─── LE PROBLÈME ─────────────────────────────────────────────────────────────
|
|
9
|
+
* Un `.exe` identique pour tous, installé chez N clients, doit s'IDENTIFIER auprès d'une console
|
|
10
|
+
* centrale — sans que le client configure quoi que ce soit.
|
|
11
|
+
*
|
|
12
|
+
* ─── CE QUE CE MODULE N'EST PAS ──────────────────────────────────────────────
|
|
13
|
+
* Ce n'est PAS de la licence. Deux questions distinctes, qu'il ne faut jamais fusionner :
|
|
14
|
+
* « Ce client a-t-il le DROIT d'utiliser le logiciel ? » → @mostajs/licensing
|
|
15
|
+
* « Cette machine EST-ELLE bien la sienne ? » → ce module
|
|
16
|
+
* Les mélanger interdirait de révoquer un enrôlement (machine volée) sans couper la licence.
|
|
17
|
+
*
|
|
18
|
+
* ─── LES TROIS VERROUS ───────────────────────────────────────────────────────
|
|
19
|
+
* 1. **Pas de jeton générique dans le binaire.** Quiconque possède le `.exe` pourrait s'enrôler.
|
|
20
|
+
* → Un code PAR CLIENT, dictable au téléphone, à usage unique, 7 jours.
|
|
21
|
+
* 2. **`machineId` n'authentifie pas** — il identifie. Il est public et falsifiable.
|
|
22
|
+
* → Une paire Ed25519 générée sur la machine ; la clé PRIVÉE ne sort jamais. Un jeton volé est
|
|
23
|
+
* rejouable ; une clé qui ne bouge pas, non.
|
|
24
|
+
* 3. **La console peut être usurpée** (DNS détourné, certificat frauduleux).
|
|
25
|
+
* → Chaque approbation est SIGNÉE, et vérifiée avec la clé publique EMBARQUÉE dans le binaire.
|
|
26
|
+
*
|
|
27
|
+
* ─── ET LE CLIENT NE REFAIT JAMAIS RIEN ──────────────────────────────────────
|
|
28
|
+
* L'approbation est un CLIC humain — mais l'agent RE-SONDE en boucle. Le client installe et part
|
|
29
|
+
* travailler ; vous approuvez quand vous voulez ; la machine se rattache toute seule.
|
|
30
|
+
*/
|
|
31
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
32
|
+
exports.readMachineId = exports.generateIdentity = exports.createEnrollmentAgent = exports.createEnrollmentService = exports.verifyCode = exports.hashCode = exports.normalizeCode = exports.parseCode = exports.generateCode = exports.moduleInfo = void 0;
|
|
33
|
+
exports.moduleInfo = { name: '@mostajs/enrollment', version: '0.2.1', level: 'N1' };
|
|
34
|
+
var code_js_1 = require("./code.js");
|
|
35
|
+
Object.defineProperty(exports, "generateCode", { enumerable: true, get: function () { return code_js_1.generateCode; } });
|
|
36
|
+
Object.defineProperty(exports, "parseCode", { enumerable: true, get: function () { return code_js_1.parseCode; } });
|
|
37
|
+
Object.defineProperty(exports, "normalizeCode", { enumerable: true, get: function () { return code_js_1.normalizeCode; } });
|
|
38
|
+
Object.defineProperty(exports, "hashCode", { enumerable: true, get: function () { return code_js_1.hashCode; } });
|
|
39
|
+
Object.defineProperty(exports, "verifyCode", { enumerable: true, get: function () { return code_js_1.verifyCode; } });
|
|
40
|
+
var service_js_1 = require("./service.js");
|
|
41
|
+
Object.defineProperty(exports, "createEnrollmentService", { enumerable: true, get: function () { return service_js_1.createEnrollmentService; } });
|
|
42
|
+
var agent_js_1 = require("./agent.js");
|
|
43
|
+
Object.defineProperty(exports, "createEnrollmentAgent", { enumerable: true, get: function () { return agent_js_1.createEnrollmentAgent; } });
|
|
44
|
+
Object.defineProperty(exports, "generateIdentity", { enumerable: true, get: function () { return agent_js_1.generateIdentity; } });
|
|
45
|
+
var machine_js_1 = require("./machine.js");
|
|
46
|
+
Object.defineProperty(exports, "readMachineId", { enumerable: true, get: function () { return machine_js_1.readMachineId; } });
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @mostajs/enrollment — Lire l'identifiant de la MACHINE, sans dépendance native.
|
|
4
|
+
* @author Dr Hamid MADANI <drmdh@msn.com>
|
|
5
|
+
* Licence : AGPL-3.0-or-later
|
|
6
|
+
*
|
|
7
|
+
* ─── POURQUOI ICI ────────────────────────────────────────────────────────────
|
|
8
|
+
* L'agent exige un `machineId`, et TOUTE machine qui s'enrôle doit savoir le produire. Le laisser
|
|
9
|
+
* à la charge de chaque application, c'est garantir N implémentations divergentes — et une
|
|
10
|
+
* dépendance à `node-machine-id` (binaire natif) qui interdirait un `.exe` PUR-JS.
|
|
11
|
+
*
|
|
12
|
+
* ─── CE QUE CET IDENTIFIANT EST, ET N'EST PAS ────────────────────────────────
|
|
13
|
+
* Il **identifie**, il n'**authentifie** pas. Il est lisible par n'importe qui sur le poste et
|
|
14
|
+
* falsifiable par quiconque contrôle la machine. C'est la clé Ed25519 — privée, jamais transmise —
|
|
15
|
+
* qui authentifie. Le `machineId` sert à une seule chose : reconnaître « c'est le même poste » afin
|
|
16
|
+
* de LEVER L'ALERTE de ré-enrôlement quand la clé, elle, a changé.
|
|
17
|
+
*
|
|
18
|
+
* ─── ET IL EST HACHÉ ─────────────────────────────────────────────────────────
|
|
19
|
+
* On ne transmet JAMAIS le GUID brut de l'OS. Il est haché avec un `salt` propre à l'application :
|
|
20
|
+
* - la console n'apprend pas l'identifiant système du client (elle n'en a aucun besoin) ;
|
|
21
|
+
* - le même poste enrôlé dans deux applications donne deux identifiants DIFFÉRENTS — impossible
|
|
22
|
+
* de recouper les parcs de deux clients.
|
|
23
|
+
* Le hachage est stable : même poste + même salt ⇒ même identifiant, indéfiniment.
|
|
24
|
+
*/
|
|
25
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
26
|
+
exports.readMachineId = readMachineId;
|
|
27
|
+
const node_child_process_1 = require("node:child_process");
|
|
28
|
+
const promises_1 = require("node:fs/promises");
|
|
29
|
+
const crypto_box_1 = require("@mostajs/crypto-box");
|
|
30
|
+
const run = (cmd, args) => new Promise((resolve, reject) => {
|
|
31
|
+
// execFile, PAS exec : aucun shell, donc aucune injection possible même si un jour un
|
|
32
|
+
// paramètre venait de l'extérieur.
|
|
33
|
+
(0, node_child_process_1.execFile)(cmd, args, { timeout: 5_000, windowsHide: true }, (err, stdout) => {
|
|
34
|
+
if (err)
|
|
35
|
+
reject(err);
|
|
36
|
+
else
|
|
37
|
+
resolve(String(stdout));
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
/** Le GUID BRUT de l'OS. Interne : il ne doit jamais sortir d'ici sans être haché. */
|
|
41
|
+
async function guidNatif() {
|
|
42
|
+
try {
|
|
43
|
+
if (process.platform === 'win32') {
|
|
44
|
+
// MachineGuid : posé par Windows à l'installation, stable, lisible sans privilège.
|
|
45
|
+
const out = await run(`${process.env.SystemRoot ?? 'C:\\Windows'}\\System32\\reg.exe`, [
|
|
46
|
+
'QUERY', 'HKLM\\SOFTWARE\\Microsoft\\Cryptography', '/v', 'MachineGuid',
|
|
47
|
+
]);
|
|
48
|
+
const m = /MachineGuid\s+REG_SZ\s+(\S+)/i.exec(out);
|
|
49
|
+
if (m)
|
|
50
|
+
return { brut: m[1], source: 'windows-registry' };
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
if (process.platform === 'darwin') {
|
|
54
|
+
const out = await run('/usr/sbin/ioreg', ['-rd1', '-c', 'IOPlatformExpertDevice']);
|
|
55
|
+
const m = /"IOPlatformUUID"\s*=\s*"([^"]+)"/.exec(out);
|
|
56
|
+
if (m)
|
|
57
|
+
return { brut: m[1], source: 'macos-ioplatform' };
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
if (process.platform === 'linux') {
|
|
61
|
+
// /etc/machine-id (systemd) ; le repli D-Bus couvre les distributions qui ne l'ont pas.
|
|
62
|
+
for (const f of ['/etc/machine-id', '/var/lib/dbus/machine-id']) {
|
|
63
|
+
try {
|
|
64
|
+
const brut = (await (0, promises_1.readFile)(f, 'utf8')).trim();
|
|
65
|
+
if (brut)
|
|
66
|
+
return { brut, source: 'linux-machine-id' };
|
|
67
|
+
}
|
|
68
|
+
catch { /* fichier absent : on essaie le suivant */ }
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
// Tout autre OS (AIX, BSD, SunOS…) : on ne devine PAS. Étiqueter `linux-machine-id` un
|
|
72
|
+
// identifiant qui ne vient pas de Linux serait un mensonge dans les journaux — et le jour où
|
|
73
|
+
// il faudra diagnostiquer une machine qui se ré-enrôle sans cesse, ce mensonge coûtera cher.
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
// Registre verrouillé par une GPO, conteneur sans /etc/machine-id, binaire absent… On ne
|
|
78
|
+
// fait pas échouer l'application pour ça : l'appelant recevra `aleatoire` et saura quoi dire.
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Lit l'identifiant de la machine, haché avec le sel de l'application.
|
|
84
|
+
*
|
|
85
|
+
* ─── LE REPLI ALÉATOIRE EST UN PIÈGE — LISEZ CECI ────────────────────────────
|
|
86
|
+
* Quand l'OS ne fournit rien (conteneur nu, registre verrouillé), on renvoie `source: 'aleatoire'`
|
|
87
|
+
* et un identifiant tiré au sort. **L'hôte DOIT le persister** et le repasser en `fallback` aux
|
|
88
|
+
* démarrages suivants. Sinon l'identifiant change à chaque lancement — et la machine, déjà
|
|
89
|
+
* enrôlée, se présente à chaque fois comme un poste inconnu.
|
|
90
|
+
*
|
|
91
|
+
* On ne le persiste pas nous-mêmes : ce module ne choisit pas votre stockage (fichier ? base ?
|
|
92
|
+
* `userData` d'Electron ?). Mais on vous DIT, par `source`, que la persistance est obligatoire.
|
|
93
|
+
*/
|
|
94
|
+
async function readMachineId(opts) {
|
|
95
|
+
const natif = await guidNatif();
|
|
96
|
+
if (natif) {
|
|
97
|
+
// HMAC et pas SHA-256 nu : sans clé, un GUID Windows (espace connu et énumérable) se
|
|
98
|
+
// retrouverait par force brute à partir du haché. Le sel, secret ou non, coupe le dictionnaire.
|
|
99
|
+
return { id: (0, crypto_box_1.hmacSha256)(opts.salt, natif.brut), source: natif.source };
|
|
100
|
+
}
|
|
101
|
+
const repli = opts.fallback?.trim();
|
|
102
|
+
if (repli)
|
|
103
|
+
return { id: (0, crypto_box_1.hmacSha256)(opts.salt, repli), source: 'aleatoire' };
|
|
104
|
+
// Aucune graine fournie : on en tire une — et on la REND, pour que l'hôte la persiste. La
|
|
105
|
+
// garder pour nous produirait un identifiant neuf à chaque démarrage : le pire des deux mondes.
|
|
106
|
+
const graine = (0, crypto_box_1.randomKey)(32).toString('hex');
|
|
107
|
+
return { id: (0, crypto_box_1.hmacSha256)(opts.salt, graine), source: 'aleatoire', seedToPersist: graine };
|
|
108
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"type":"commonjs"}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @mostajs/enrollment — Le service CONSOLE : reçoit, approuve, révoque.
|
|
4
|
+
* @author Dr Hamid MADANI <drmdh@msn.com>
|
|
5
|
+
* Licence : AGPL-3.0-or-later
|
|
6
|
+
*/
|
|
7
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8
|
+
exports.createEnrollmentService = createEnrollmentService;
|
|
9
|
+
const crypto_box_1 = require("@mostajs/crypto-box");
|
|
10
|
+
const code_js_1 = require("./code.js");
|
|
11
|
+
/**
|
|
12
|
+
* Le service d'enrôlement, côté console.
|
|
13
|
+
*
|
|
14
|
+
* ─── CE QU'IL PROTÈGE ────────────────────────────────────────────────────────
|
|
15
|
+
* 1. **La force brute.** 50 bits d'entropie ne résistent PAS à une attaque en ligne illimitée.
|
|
16
|
+
* Le service VERROUILLE après quelques échecs — par code ET par origine. Sans ce verrou, le
|
|
17
|
+
* code dictable serait une fausse sécurité.
|
|
18
|
+
* 2. **Le ré-enrôlement usurpé.** Une machine déjà enrôlée qui revient avec une clé publique
|
|
19
|
+
* DIFFÉRENTE : disque remplacé, ou usurpation ? On ne peut pas deviner — donc on ne décide
|
|
20
|
+
* PAS. On lève un drapeau et on exige un clic conscient.
|
|
21
|
+
* 3. **La console usurpée.** Chaque approbation est SIGNÉE (Ed25519). La machine vérifie avec la
|
|
22
|
+
* clé publique embarquée dans son binaire : même si TLS tombait (DNS détourné, certificat
|
|
23
|
+
* frauduleux), un faux jeton ne pourrait pas être forgé.
|
|
24
|
+
*/
|
|
25
|
+
function createEnrollmentService(opts) {
|
|
26
|
+
const clock = opts.now ?? (() => new Date());
|
|
27
|
+
const maxAttempts = opts.maxAttempts ?? 5;
|
|
28
|
+
const lockoutMs = opts.lockoutMs ?? 15 * 60_000;
|
|
29
|
+
/** Compteurs d'échec, par origine (IP) ET par code présenté. */
|
|
30
|
+
const echecs = new Map();
|
|
31
|
+
function verrouille(cle) {
|
|
32
|
+
const e = echecs.get(cle);
|
|
33
|
+
if (!e)
|
|
34
|
+
return false;
|
|
35
|
+
// Aucun verrou POSÉ (jusqua = 0) : on est encore en phase de comptage. Surtout NE PAS
|
|
36
|
+
// purger l'entrée ici — une première version le faisait, et remettait donc le compteur à
|
|
37
|
+
// zéro à chaque appel : il ne dépassait jamais 1, et le verrou ne se déclenchait JAMAIS.
|
|
38
|
+
// Un anti-force-brute qui ne bloque rien est pire que pas d'anti-force-brute : on se croit
|
|
39
|
+
// protégé.
|
|
40
|
+
if (e.jusqua === 0)
|
|
41
|
+
return false;
|
|
42
|
+
if (clock().getTime() < e.jusqua)
|
|
43
|
+
return true;
|
|
44
|
+
echecs.delete(cle); // le verrou a expiré : on repart à zéro
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
function echec(cle) {
|
|
48
|
+
const e = echecs.get(cle) ?? { n: 0, jusqua: 0 };
|
|
49
|
+
e.n += 1;
|
|
50
|
+
if (e.n >= maxAttempts)
|
|
51
|
+
e.jusqua = clock().getTime() + lockoutMs;
|
|
52
|
+
echecs.set(cle, e);
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
/**
|
|
56
|
+
* Une machine se présente. Elle ne reçoit RIEN d'utile ici : seulement un `requestId` pour
|
|
57
|
+
* sonder. Le jeton n'existe qu'après approbation humaine.
|
|
58
|
+
*/
|
|
59
|
+
async request(input) {
|
|
60
|
+
const origine = input.origin ?? 'inconnue';
|
|
61
|
+
if (verrouille(origine))
|
|
62
|
+
return { ok: false, error: 'verrouille' };
|
|
63
|
+
// Forme d'abord : rejeter une faute de frappe ne doit RIEN coûter, ni compter comme un
|
|
64
|
+
// échec de sécurité — sinon un client maladroit se verrouille tout seul.
|
|
65
|
+
const forme = (0, code_js_1.parseCode)(input.code);
|
|
66
|
+
if (!forme.ok)
|
|
67
|
+
return { ok: false, error: 'code_malforme' };
|
|
68
|
+
const invitation = await opts.store.findInvitationByCode((h) => (0, code_js_1.verifyCode)(input.code, h, opts.codeSecret));
|
|
69
|
+
if (!invitation) {
|
|
70
|
+
echec(origine);
|
|
71
|
+
return { ok: false, error: 'code_invalide' };
|
|
72
|
+
}
|
|
73
|
+
if (invitation.usedAt)
|
|
74
|
+
return { ok: false, error: 'code_deja_utilise' };
|
|
75
|
+
if (new Date(invitation.expiresAt) < clock())
|
|
76
|
+
return { ok: false, error: 'code_expire' };
|
|
77
|
+
// ── Le drapeau du ré-enrôlement. On NE tranche PAS : on alerte.
|
|
78
|
+
const existant = await opts.store.findSiteByMachineId(input.machineId);
|
|
79
|
+
const cleChangee = !!existant && !(0, crypto_box_1.equalsConstantTime)(existant.publicKey, input.publicKey);
|
|
80
|
+
const req = {
|
|
81
|
+
id: `req-${Date.now()}-${(0, crypto_box_1.randomKey)(6).toString('hex')}`,
|
|
82
|
+
invitationId: invitation.id,
|
|
83
|
+
machineId: input.machineId,
|
|
84
|
+
hostname: input.hostname,
|
|
85
|
+
publicKey: input.publicKey,
|
|
86
|
+
app: input.app,
|
|
87
|
+
version: input.version,
|
|
88
|
+
origin: origine,
|
|
89
|
+
state: 'pending',
|
|
90
|
+
createdAt: clock().toISOString(),
|
|
91
|
+
/**
|
|
92
|
+
* Machine connue, clé DIFFÉRENTE. C'est soit un disque remplacé chez le client, soit
|
|
93
|
+
* quelqu'un qui usurpe son identité — et rien dans la requête ne permet de distinguer
|
|
94
|
+
* les deux. L'accepter en silence serait le piège classique de ce protocole.
|
|
95
|
+
*/
|
|
96
|
+
reenrollmentAlert: cleChangee
|
|
97
|
+
? { previousSiteId: existant.siteId, previousPublicKey: existant.publicKey }
|
|
98
|
+
: undefined,
|
|
99
|
+
};
|
|
100
|
+
await opts.store.saveRequest(req);
|
|
101
|
+
echecs.delete(origine); // code valide → on remet le compteur à zéro
|
|
102
|
+
return { ok: true, requestId: req.id, state: 'pending' };
|
|
103
|
+
},
|
|
104
|
+
/** La machine sonde. Tant qu'aucun humain n'a cliqué, elle n'obtient rien. */
|
|
105
|
+
async status(requestId) {
|
|
106
|
+
const req = await opts.store.findRequest(requestId);
|
|
107
|
+
if (!req)
|
|
108
|
+
return { state: 'unknown' };
|
|
109
|
+
if (req.state !== 'approved' || !req.approval) {
|
|
110
|
+
return { state: req.state };
|
|
111
|
+
}
|
|
112
|
+
// L'approbation est SIGNÉE : la machine la vérifie avec la clé publique embarquée dans son
|
|
113
|
+
// binaire. Un imposteur ne peut pas forger de jeton, même en cassant TLS.
|
|
114
|
+
const payload = JSON.stringify(req.approval);
|
|
115
|
+
return { state: 'approved', approval: req.approval, signature: (0, crypto_box_1.sign)(opts.signingKey, payload).toString('hex') };
|
|
116
|
+
},
|
|
117
|
+
/**
|
|
118
|
+
* Le clic. `acceptReenrollment` DOIT être explicite quand un drapeau est levé : on refuse de
|
|
119
|
+
* laisser approuver une usurpation par inadvertance.
|
|
120
|
+
*/
|
|
121
|
+
async approve(input) {
|
|
122
|
+
const req = await opts.store.findRequest(input.requestId);
|
|
123
|
+
if (!req)
|
|
124
|
+
return { ok: false, error: 'inconnue' };
|
|
125
|
+
if (req.state !== 'pending')
|
|
126
|
+
return { ok: false, error: 'deja_traitee' };
|
|
127
|
+
if (req.reenrollmentAlert && !input.acceptReenrollment) {
|
|
128
|
+
// Refus DÉLIBÉRÉ : l'approbateur doit avoir vu l'alerte et l'avoir acceptée sciemment.
|
|
129
|
+
return { ok: false, error: 'reenrolement_non_confirme' };
|
|
130
|
+
}
|
|
131
|
+
const token = (0, crypto_box_1.randomKey)(32).toString('hex');
|
|
132
|
+
const site = {
|
|
133
|
+
siteId: input.siteId,
|
|
134
|
+
name: input.name,
|
|
135
|
+
machineId: req.machineId,
|
|
136
|
+
publicKey: req.publicKey,
|
|
137
|
+
app: req.app,
|
|
138
|
+
token,
|
|
139
|
+
enrolledAt: clock().toISOString(),
|
|
140
|
+
revokedAt: null,
|
|
141
|
+
};
|
|
142
|
+
req.state = 'approved';
|
|
143
|
+
req.approval = { siteId: site.siteId, name: site.name, token, consolePublicKey: Buffer.from(opts.signingPublicKey).toString('hex') };
|
|
144
|
+
req.decidedAt = clock().toISOString();
|
|
145
|
+
req.decidedBy = input.approvedBy;
|
|
146
|
+
await opts.store.saveSite(site);
|
|
147
|
+
await opts.store.saveRequest(req);
|
|
148
|
+
// Le code est CONSOMMÉ. Un secret qui traîne est une porte ouverte pour rien.
|
|
149
|
+
await opts.store.markInvitationUsed(req.invitationId, clock().toISOString());
|
|
150
|
+
return { ok: true, site };
|
|
151
|
+
},
|
|
152
|
+
async reject(requestId, by) {
|
|
153
|
+
const req = await opts.store.findRequest(requestId);
|
|
154
|
+
if (!req || req.state !== 'pending')
|
|
155
|
+
return { ok: false };
|
|
156
|
+
req.state = 'rejected';
|
|
157
|
+
req.decidedAt = clock().toISOString();
|
|
158
|
+
req.decidedBy = by;
|
|
159
|
+
await opts.store.saveRequest(req);
|
|
160
|
+
return { ok: true };
|
|
161
|
+
},
|
|
162
|
+
/** Révoque un site — machine volée, client parti. La licence, elle, n'est PAS touchée. */
|
|
163
|
+
async revoke(siteId) {
|
|
164
|
+
const site = await opts.store.findSite(siteId);
|
|
165
|
+
if (!site || site.revokedAt)
|
|
166
|
+
return { ok: false };
|
|
167
|
+
site.revokedAt = clock().toISOString();
|
|
168
|
+
await opts.store.saveSite(site);
|
|
169
|
+
return { ok: true };
|
|
170
|
+
},
|
|
171
|
+
/** Le jeton présenté est-il celui d'un site enrôlé et NON révoqué ? */
|
|
172
|
+
async authenticate(siteId, token) {
|
|
173
|
+
const site = await opts.store.findSite(siteId);
|
|
174
|
+
if (!site || site.revokedAt)
|
|
175
|
+
return null;
|
|
176
|
+
return (0, crypto_box_1.equalsConstantTime)(site.token, token) ? site : null;
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
*/
|
|
30
30
|
export declare const moduleInfo: {
|
|
31
31
|
readonly name: "@mostajs/enrollment";
|
|
32
|
-
readonly version: "0.2.
|
|
32
|
+
readonly version: "0.2.1";
|
|
33
33
|
readonly level: "N1";
|
|
34
34
|
};
|
|
35
35
|
export { generateCode, parseCode, normalizeCode, hashCode, verifyCode } from './code.js';
|
package/dist/index.js
CHANGED
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
* L'approbation est un CLIC humain — mais l'agent RE-SONDE en boucle. Le client installe et part
|
|
28
28
|
* travailler ; vous approuvez quand vous voulez ; la machine se rattache toute seule.
|
|
29
29
|
*/
|
|
30
|
-
export const moduleInfo = { name: '@mostajs/enrollment', version: '0.2.
|
|
30
|
+
export const moduleInfo = { name: '@mostajs/enrollment', version: '0.2.1', level: 'N1' };
|
|
31
31
|
export { generateCode, parseCode, normalizeCode, hashCode, verifyCode } from './code.js';
|
|
32
32
|
export { createEnrollmentService } from './service.js';
|
|
33
33
|
export { createEnrollmentAgent, generateIdentity } from './agent.js';
|
package/package.json
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mostajs/enrollment",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Enrôler une machine chez un client distant, sans configuration : code dictable au téléphone (usage unique, 7 jours), identité Ed25519 générée sur la machine, approbation par un clic dans la console, re-tentative automatique. Compose crypto-box et scheduler.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/cjs/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
5
8
|
"license": "AGPL-3.0-or-later",
|
|
6
9
|
"author": "Dr Hamid MADANI <drmdh@msn.com>",
|
|
7
|
-
"type": "module",
|
|
8
10
|
"exports": {
|
|
9
11
|
".": {
|
|
10
12
|
"types": "./dist/index.d.ts",
|
|
11
13
|
"import": "./dist/index.js",
|
|
14
|
+
"require": "./dist/cjs/index.js",
|
|
12
15
|
"default": "./dist/index.js"
|
|
13
16
|
}
|
|
14
17
|
},
|
|
@@ -19,7 +22,7 @@
|
|
|
19
22
|
"LICENSE"
|
|
20
23
|
],
|
|
21
24
|
"scripts": {
|
|
22
|
-
"build": "tsc",
|
|
25
|
+
"build": "tsc && tsc -p tsconfig.cjs.json && node -e \"require('fs').writeFileSync('dist/cjs/package.json', JSON.stringify({type:'commonjs'}))\"",
|
|
23
26
|
"test": "bash test-scripts/run-tests.sh",
|
|
24
27
|
"example": "node examples/demo.mjs",
|
|
25
28
|
"prepublishOnly": "npm run build"
|
|
@@ -34,11 +37,11 @@
|
|
|
34
37
|
"mostajs"
|
|
35
38
|
],
|
|
36
39
|
"dependencies": {
|
|
37
|
-
"@mostajs/crypto-box": "^0.2.
|
|
40
|
+
"@mostajs/crypto-box": "^0.2.1"
|
|
38
41
|
},
|
|
39
42
|
"devDependencies": {
|
|
40
43
|
"@mostajs/mjs-unit": "^0.4.1",
|
|
41
44
|
"@types/node": "^20",
|
|
42
45
|
"typescript": "^5.6.0"
|
|
43
46
|
}
|
|
44
|
-
}
|
|
47
|
+
}
|