@mostajs/enrollment 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,29 @@
1
+ GNU AFFERO GENERAL PUBLIC LICENSE
2
+ Version 3, 19 November 2007
3
+
4
+ Copyright (c) 2026 Dr Hamid MADANI <drmdh@msn.com>
5
+
6
+ This program is free software: you can redistribute it and/or modify
7
+ it under the terms of the GNU Affero General Public License as published by
8
+ the Free Software Foundation, either version 3 of the License, or
9
+ (at your option) any later version.
10
+
11
+ This program is distributed in the hope that it will be useful,
12
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
13
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14
+ GNU Affero General Public License for more details.
15
+
16
+ You should have received a copy of the GNU Affero General Public License
17
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
18
+
19
+ COMMERCIAL LICENSE
20
+
21
+ For organizations that cannot comply with the AGPL open-source requirements,
22
+ a commercial license is available. Contact: drmdh@msn.com
23
+
24
+ The commercial license allows you to:
25
+ - Use the software in proprietary/closed-source projects
26
+ - Modify without publishing your source code
27
+ - Get priority support and SLA
28
+
29
+ Contact: Dr Hamid MADANI <drmdh@msn.com>
package/README.md ADDED
@@ -0,0 +1,80 @@
1
+ # @mostajs/enrollment
2
+
3
+ **Auteur** : Dr Hamid MADANI <drmdh@msn.com> · **Licence** : AGPL-3.0-or-later
4
+ **Niveau** : N1 · **Dépendance** : `@mostajs/crypto-box`
5
+
6
+ Enrôler une machine chez un client distant — **sans configuration**.
7
+
8
+ ## Ce que ce module n'est PAS
9
+
10
+ **Ce n'est pas de la licence.** Deux questions distinctes, qu'il ne faut **jamais** fusionner :
11
+
12
+ | Question | Module |
13
+ |---|---|
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
+
17
+ Les mélanger interdirait de révoquer un enrôlement (machine volée) **sans couper la licence**.
18
+
19
+ ## Les trois verrous
20
+
21
+ **1. Pas de jeton générique dans le binaire.** Un jeton d'amorçage embarqué dans chaque `.exe`
22
+ est une faille : **quiconque possède le binaire** pourrait s'enrôler et devenir un « site »
23
+ légitime de votre console.
24
+ → Un **code par client**, dictable au téléphone, **usage unique**, **7 jours**.
25
+
26
+ **2. `machineId` n'authentifie pas** — il **identifie**. C'est une valeur publique et
27
+ **falsifiable**.
28
+ → Une paire **Ed25519** générée sur la machine ; la clé **privée ne sort jamais**. Un jeton volé
29
+ (fichier de config, sauvegarde, capture réseau) est **rejouable** ; une clé qui ne bouge pas, non.
30
+
31
+ **3. La console peut être usurpée** (DNS détourné, certificat frauduleux). TLS prouve qu'on parle
32
+ à `console.example` — pas qu'on parle à **LA** console.
33
+ → Chaque approbation est **signée Ed25519**, vérifiée avec la clé publique **embarquée dans le
34
+ binaire**. Même si TLS tombait, un faux jeton **ne pourrait pas être forgé**.
35
+
36
+ ## Rien à configurer chez le client
37
+
38
+ L'enrôlement est **sortant** : la machine **appelle** la console, jamais l'inverse. **Aucun
39
+ certificat, aucun port ouvert, aucun reverse proxy.** Fonctionne derrière une box, un NAT, un
40
+ pare-feu d'entreprise.
41
+
42
+ ## Le code, dictable au téléphone
43
+
44
+ ```
45
+ MJSE-7K4P-2XQ9-M3TA
46
+ ```
47
+
48
+ **Crockford Base32** — ni `I`, ni `L`, ni `O`, ni `U`. Fini le « c'est un zéro ou un O ? », la
49
+ question qui fait échouer une installation à distance. Insensible à la casse, tirets décoratifs.
50
+
51
+ **10 caractères aléatoires + 2 de contrôle** : une faute de frappe est rejetée **hors ligne**, le
52
+ client voit « code invalide » **tout de suite** — pas après trente secondes d'attente pour un refus
53
+ qui ne lui dit pas si c'est lui ou le serveur.
54
+
55
+ **Jamais stocké en clair** côté console : seule son empreinte HMAC est conservée. Une fuite de la
56
+ base ne donne **aucun** code utilisable.
57
+
58
+ ## Le clic est indolore
59
+
60
+ L'approbation est **humaine** — mais l'agent **re-sonde en boucle**. Le client installe et part
61
+ travailler ; vous approuvez **quand vous voulez** ; la machine **se rattache toute seule**.
62
+ **Personne ne refait rien.**
63
+
64
+ Sans cette re-tentative, le client qui installe un dimanche à 8 h devrait tout recommencer lundi.
65
+
66
+ ## Le piège du ré-enrôlement
67
+
68
+ Une machine **déjà enrôlée** qui revient avec une **clé publique différente** : disque remplacé
69
+ chez le client, ou **usurpation** ? **On ne peut pas deviner** — donc on **ne décide pas**. La
70
+ console lève une **alerte explicite** et **refuse d'approuver** sans confirmation consciente.
71
+
72
+ ## Tests
73
+
74
+ ```bash
75
+ npm test # 16 tests mjs-unit
76
+ ```
77
+
78
+ Un protocole d'enrôlement se juge sur ce qu'il **refuse** : force brute verrouillée, faute de
79
+ frappe qui **ne verrouille pas** le client maladroit, code expiré, code réutilisé, ré-enrôlement
80
+ non confirmé, **approbation forgée par un imposteur**.
@@ -0,0 +1,75 @@
1
+ /**
2
+ * @mostajs/enrollment — L'agent MACHINE : se présente, insiste, se rattache tout seul.
3
+ * @author Dr Hamid MADANI <drmdh@msn.com>
4
+ * Licence : AGPL-3.0-or-later
5
+ */
6
+ export interface EnrollmentAgent {
7
+ start(): void;
8
+ stop(): void;
9
+ /** Un tour de boucle (tests, ou déclenchement manuel). */
10
+ tick(): Promise<void>;
11
+ state(): AgentState;
12
+ }
13
+ export type AgentState = {
14
+ state: 'non_enrole';
15
+ } | {
16
+ state: 'en_attente';
17
+ requestId: string;
18
+ depuis: string;
19
+ } | {
20
+ state: 'refuse';
21
+ } | {
22
+ state: 'enrole';
23
+ siteId: string;
24
+ name: string;
25
+ token: string;
26
+ } | {
27
+ state: 'erreur';
28
+ message: string;
29
+ };
30
+ export interface AgentIdentity {
31
+ publicKey: Uint8Array;
32
+ privateKey: Uint8Array;
33
+ }
34
+ export interface EnrollmentAgentOptions {
35
+ /** URL de la console (HTTPS). EMBARQUÉE dans le binaire, jamais saisie par le client. */
36
+ consoleUrl: string;
37
+ /** Code d'enrôlement saisi par le client (une seule fois). */
38
+ code: string;
39
+ machineId: string;
40
+ hostname: string;
41
+ app: string;
42
+ version: string;
43
+ /** Identité de la machine. Générée au 1er démarrage, PERSISTÉE — la privée ne sort jamais. */
44
+ identity: AgentIdentity;
45
+ /**
46
+ * Clé publique Ed25519 de la console, EMBARQUÉE dans le binaire.
47
+ *
48
+ * C'est le verrou contre la console usurpée : TLS prouve qu'on parle à `console.example`, mais
49
+ * pas qu'on parle à LA console — DNS détourné, certificat frauduleux. La signature, si.
50
+ */
51
+ consolePublicKey: Uint8Array;
52
+ /** Persistance de l'état (fichier, base…). Fournie par l'hôte. */
53
+ persist: (state: AgentState) => Promise<void> | void;
54
+ onState?: (state: AgentState) => void;
55
+ /** Cadence de re-tentative (ms). Défaut 60 000. */
56
+ intervalMs?: number;
57
+ fetchImpl?: typeof fetch;
58
+ }
59
+ /** Génère l'identité de la machine (au premier démarrage). La PRIVÉE ne quitte jamais le poste. */
60
+ export declare function generateIdentity(): AgentIdentity;
61
+ /**
62
+ * Crée l'agent d'enrôlement.
63
+ *
64
+ * ─── LA RE-TENTATIVE EST LE CŒUR ─────────────────────────────────────────────
65
+ * Sans elle, l'approbation manuelle est un piège : le client installe un dimanche à 8 h, voit
66
+ * « en attente »… et devrait tout recommencer lundi ? Absurde.
67
+ *
68
+ * L'agent RE-SONDE en boucle (`@mostajs/scheduler`). Le client installe et part travailler ;
69
+ * vous approuvez quand vous voulez ; la machine se rattache TOUTE SEULE et la bannière
70
+ * disparaît. **Personne ne refait rien.**
71
+ *
72
+ * L'appel est SORTANT : aucun certificat, aucun port ouvert, aucun reverse proxy chez le client.
73
+ * Fonctionne derrière une box, un NAT, un pare-feu d'entreprise.
74
+ */
75
+ export declare function createEnrollmentAgent(opts: EnrollmentAgentOptions): EnrollmentAgent;
package/dist/agent.js ADDED
@@ -0,0 +1,119 @@
1
+ /**
2
+ * @mostajs/enrollment — L'agent MACHINE : se présente, insiste, se rattache tout seul.
3
+ * @author Dr Hamid MADANI <drmdh@msn.com>
4
+ * Licence : AGPL-3.0-or-later
5
+ */
6
+ import { generateSigningKeyPair, verifySignature, equalsConstantTime } from '@mostajs/crypto-box';
7
+ /** Génère l'identité de la machine (au premier démarrage). La PRIVÉE ne quitte jamais le poste. */
8
+ export function generateIdentity() {
9
+ return generateSigningKeyPair();
10
+ }
11
+ /**
12
+ * Crée l'agent d'enrôlement.
13
+ *
14
+ * ─── LA RE-TENTATIVE EST LE CŒUR ─────────────────────────────────────────────
15
+ * Sans elle, l'approbation manuelle est un piège : le client installe un dimanche à 8 h, voit
16
+ * « en attente »… et devrait tout recommencer lundi ? Absurde.
17
+ *
18
+ * L'agent RE-SONDE en boucle (`@mostajs/scheduler`). Le client installe et part travailler ;
19
+ * vous approuvez quand vous voulez ; la machine se rattache TOUTE SEULE et la bannière
20
+ * disparaît. **Personne ne refait rien.**
21
+ *
22
+ * L'appel est SORTANT : aucun certificat, aucun port ouvert, aucun reverse proxy chez le client.
23
+ * Fonctionne derrière une box, un NAT, un pare-feu d'entreprise.
24
+ */
25
+ export function createEnrollmentAgent(opts) {
26
+ const http = opts.fetchImpl ?? fetch;
27
+ let etat = { state: 'non_enrole' };
28
+ const poser = async (s) => {
29
+ etat = s;
30
+ await opts.persist(s);
31
+ opts.onState?.(s);
32
+ };
33
+ async function cycle() {
34
+ if (etat.state === 'enrole' || etat.state === 'refuse')
35
+ return; // terminal
36
+ try {
37
+ // 1. Se présenter (une fois), puis 2. sonder jusqu'à décision.
38
+ if (etat.state !== 'en_attente') {
39
+ const r = await http(`${opts.consoleUrl}/enroll/request`, {
40
+ method: 'POST',
41
+ headers: { 'content-type': 'application/json' },
42
+ body: JSON.stringify({
43
+ code: opts.code,
44
+ machineId: opts.machineId,
45
+ hostname: opts.hostname,
46
+ app: opts.app,
47
+ version: opts.version,
48
+ publicKey: Buffer.from(opts.identity.publicKey).toString('hex'),
49
+ }),
50
+ }).then((x) => x.json());
51
+ if (!r.ok) {
52
+ await poser({ state: 'erreur', message: r.error ?? 'refus' });
53
+ return;
54
+ }
55
+ await poser({ state: 'en_attente', requestId: r.requestId, depuis: new Date().toISOString() });
56
+ return;
57
+ }
58
+ const s = await http(`${opts.consoleUrl}/enroll/status/${etat.requestId}`).then((x) => x.json());
59
+ if (s.state === 'rejected') {
60
+ await poser({ state: 'refuse' });
61
+ return;
62
+ }
63
+ if (s.state !== 'approved')
64
+ return; // toujours en attente — on repassera
65
+ // ── VÉRIFIER LA SIGNATURE avant de croire quoi que ce soit.
66
+ const approval = s.approval;
67
+ const valide = verifySignature(opts.consolePublicKey, JSON.stringify(approval), Buffer.from(s.signature, 'hex'));
68
+ if (!valide) {
69
+ // Une réponse non signée par NOTRE console est une réponse d'imposteur. On ne l'applique
70
+ // pas, et on ne se tait pas.
71
+ await poser({ state: 'erreur', message: 'approbation NON SIGNÉE par la console attendue — réponse ignorée' });
72
+ return;
73
+ }
74
+ // La console doit aussi présenter LA clé publique qu'on connaît.
75
+ if (!equalsConstantTime(approval.consolePublicKey, Buffer.from(opts.consolePublicKey).toString('hex'))) {
76
+ await poser({ state: 'erreur', message: 'la console renvoie une clé publique INATTENDUE' });
77
+ return;
78
+ }
79
+ await poser({ state: 'enrole', siteId: approval.siteId, name: approval.name, token: approval.token });
80
+ }
81
+ catch (e) {
82
+ // Console injoignable : ce n'est PAS une erreur fatale. La machine continue de servir en
83
+ // local, et réessaiera au prochain tour. Les tickets doivent continuer à sortir.
84
+ opts.onState?.({ state: 'erreur', message: e.message });
85
+ }
86
+ }
87
+ // ─── POURQUOI PAS `@mostajs/scheduler` ? ──────────────────────────────────
88
+ // La tentation était forte : il y a une boucle, une cadence. Mais son CONTRAT est celui d'un
89
+ // déclencheur à FRONTIÈRES : il adopte la période courante au démarrage SANS rien lancer, et
90
+ // ne déclenche qu'une fois par frontière franchie. C'est exactement ce qu'il faut pour une
91
+ // sauvegarde nocturne — et exactement ce qu'il NE faut PAS ici : l'agent doit sonder à CHAQUE
92
+ // tour, dès le premier, jusqu'à obtenir une réponse.
93
+ //
94
+ // Le détourner (mode `count` avec un compteur artificiel) « marcherait »… puis casserait au
95
+ // premier changement de sémantique du planificateur. Une boucle de re-tentative n'est pas un
96
+ // planificateur : on ne force pas l'un dans l'autre. Douze lignes ici valent mieux qu'une
97
+ // dépendance mal utilisée.
98
+ let timer = null;
99
+ return {
100
+ start() {
101
+ if (timer)
102
+ return;
103
+ void cycle(); // on sonde IMMÉDIATEMENT — le client ne doit pas attendre une minute pour rien
104
+ timer = setInterval(() => { void cycle(); }, opts.intervalMs ?? 60_000);
105
+ // Le processus ne doit pas être maintenu en vie par cette boucle.
106
+ if (typeof timer?.unref === 'function') {
107
+ timer.unref();
108
+ }
109
+ },
110
+ stop() {
111
+ if (timer) {
112
+ clearInterval(timer);
113
+ timer = null;
114
+ }
115
+ },
116
+ tick: cycle,
117
+ state: () => etat,
118
+ };
119
+ }
package/dist/code.d.ts ADDED
@@ -0,0 +1,71 @@
1
+ /**
2
+ * @mostajs/enrollment — Le code d'enrôlement : dictable au téléphone.
3
+ * @author Dr Hamid MADANI <drmdh@msn.com>
4
+ * Licence : AGPL-3.0-or-later
5
+ */
6
+ export interface EnrollmentCode {
7
+ /** Forme affichée / dictée : `MJSE-7K4P-2XQ9-M3TA`. */
8
+ display: string;
9
+ /** Forme canonique (sans tirets ni préfixe) — c'est ELLE qu'on hache. */
10
+ canonical: string;
11
+ /** Empreinte à stocker côté console. Le code EN CLAIR n'est jamais persisté. */
12
+ hash: string;
13
+ }
14
+ /** Somme de contrôle : 2 caractères dérivés du corps du code. */
15
+ declare function checksum(body: string): string;
16
+ /** Met en forme : `MJSE-XXXX-XXXX-XXXX`. */
17
+ declare function format(body12: string): string;
18
+ /**
19
+ * Génère un code d'enrôlement.
20
+ *
21
+ * 10 caractères aléatoires (50 bits) + 2 de contrôle. 50 bits ne résistent PAS à une attaque
22
+ * en ligne illimitée — c'est pourquoi le service verrouille après quelques échecs (voir
23
+ * `service.ts`). Un code n'est pas un mot de passe : il est à usage unique et expire en 7 jours.
24
+ */
25
+ export declare function generateCode(secret: string): EnrollmentCode;
26
+ /**
27
+ * Normalise une saisie humaine : majuscules, tirets et espaces retirés, préfixe optionnel,
28
+ * confusions Crockford corrigées (`O`→`0`, `I`/`L`→`1`, `U`→`V`).
29
+ *
30
+ * On accepte donc `mjse 7k4p-2xq9 m3ta`, `7K4P2XQ9M3TA`, ou une saisie où le client a tapé `O`
31
+ * au lieu de zéro. Refuser un code correct pour une virgule de forme serait absurde : c'est
32
+ * l'ENTROPIE qui protège, pas la ponctuation.
33
+ */
34
+ export declare function normalizeCode(input: string): string;
35
+ export type CodeError = 'longueur' | 'caractere_invalide' | 'somme_de_controle';
36
+ /**
37
+ * Valide la FORME d'un code, **hors ligne**.
38
+ *
39
+ * C'est tout l'intérêt de la somme de contrôle : une faute de frappe est rejetée **avant le
40
+ * moindre appel réseau**. Le client voit « code invalide » immédiatement, au lieu d'attendre
41
+ * trente secondes pour un « refusé » qui ne lui dit pas si c'est lui ou le serveur.
42
+ *
43
+ * Elle ne prouve RIEN sur la validité du code — seulement qu'il a été correctement recopié.
44
+ */
45
+ export declare function parseCode(input: string): {
46
+ ok: true;
47
+ canonical: string;
48
+ } | {
49
+ ok: false;
50
+ error: CodeError;
51
+ };
52
+ /**
53
+ * Empreinte du code, pour la console.
54
+ *
55
+ * Le code n'est **jamais** stocké en clair : une fuite de la base ne doit donner **aucun** code
56
+ * utilisable. C'est un secret d'authentification — on le traite comme un mot de passe.
57
+ * HMAC (et non simple SHA) : sans le `secret` de la console, on ne peut pas pré-calculer un
58
+ * dictionnaire de 2⁵⁰ empreintes.
59
+ */
60
+ export declare function hashCode(canonical: string, secret: string): string;
61
+ /** Compare un code saisi à une empreinte — en TEMPS CONSTANT. */
62
+ export declare function verifyCode(input: string, hash: string, secret: string): boolean;
63
+ export declare const _internal: {
64
+ ALPHABET: string;
65
+ checksum: typeof checksum;
66
+ format: typeof format;
67
+ PREFIX: string;
68
+ RANDOM_LEN: number;
69
+ CHECK_LEN: number;
70
+ };
71
+ export {};
package/dist/code.js ADDED
@@ -0,0 +1,98 @@
1
+ /**
2
+ * @mostajs/enrollment — Le code d'enrôlement : dictable au téléphone.
3
+ * @author Dr Hamid MADANI <drmdh@msn.com>
4
+ * Licence : AGPL-3.0-or-later
5
+ */
6
+ import { sha256, hmacSha256, equalsConstantTime, randomKey } from '@mostajs/crypto-box';
7
+ /**
8
+ * Alphabet **Crockford Base32** : ni `I`, ni `L`, ni `O`, ni `U`.
9
+ *
10
+ * 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
11
+ * O ? », « un 1 ou un I ? » sont les deux questions qui font échouer une installation à
12
+ * distance. `U` est retiré pour ne pas former de mot grossier par accident.
13
+ */
14
+ const ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
15
+ /** Confusions courantes, corrigées SILENCIEUSEMENT à la lecture (Crockford). */
16
+ const CORRECTIONS = { O: '0', I: '1', L: '1', U: 'V' };
17
+ const PREFIX = 'MJSE';
18
+ const RANDOM_LEN = 10;
19
+ const CHECK_LEN = 2;
20
+ /** Somme de contrôle : 2 caractères dérivés du corps du code. */
21
+ function checksum(body) {
22
+ const digest = sha256(`mostajs-enrollment-code/v1:${body}`); // hex
23
+ const n = parseInt(digest.slice(0, 4), 16); // 16 bits
24
+ return ALPHABET[(n >> 5) & 31] + ALPHABET[n & 31];
25
+ }
26
+ /** Met en forme : `MJSE-XXXX-XXXX-XXXX`. */
27
+ function format(body12) {
28
+ return `${PREFIX}-${body12.slice(0, 4)}-${body12.slice(4, 8)}-${body12.slice(8, 12)}`;
29
+ }
30
+ /**
31
+ * Génère un code d'enrôlement.
32
+ *
33
+ * 10 caractères aléatoires (50 bits) + 2 de contrôle. 50 bits ne résistent PAS à une attaque
34
+ * en ligne illimitée — c'est pourquoi le service verrouille après quelques échecs (voir
35
+ * `service.ts`). Un code n'est pas un mot de passe : il est à usage unique et expire en 7 jours.
36
+ */
37
+ export function generateCode(secret) {
38
+ const bytes = randomKey(16);
39
+ let body = '';
40
+ for (let i = 0; i < RANDOM_LEN; i++)
41
+ body += ALPHABET[bytes[i] & 31];
42
+ body += checksum(body);
43
+ return { display: format(body), canonical: body, hash: hashCode(body, secret) };
44
+ }
45
+ /**
46
+ * Normalise une saisie humaine : majuscules, tirets et espaces retirés, préfixe optionnel,
47
+ * confusions Crockford corrigées (`O`→`0`, `I`/`L`→`1`, `U`→`V`).
48
+ *
49
+ * On accepte donc `mjse 7k4p-2xq9 m3ta`, `7K4P2XQ9M3TA`, ou une saisie où le client a tapé `O`
50
+ * au lieu de zéro. Refuser un code correct pour une virgule de forme serait absurde : c'est
51
+ * l'ENTROPIE qui protège, pas la ponctuation.
52
+ */
53
+ export function normalizeCode(input) {
54
+ let s = String(input ?? '').toUpperCase().replace(/[\s-]/g, '');
55
+ if (s.startsWith(PREFIX))
56
+ s = s.slice(PREFIX.length);
57
+ return [...s].map((c) => CORRECTIONS[c] ?? c).join('');
58
+ }
59
+ /**
60
+ * Valide la FORME d'un code, **hors ligne**.
61
+ *
62
+ * C'est tout l'intérêt de la somme de contrôle : une faute de frappe est rejetée **avant le
63
+ * moindre appel réseau**. Le client voit « code invalide » immédiatement, au lieu d'attendre
64
+ * trente secondes pour un « refusé » qui ne lui dit pas si c'est lui ou le serveur.
65
+ *
66
+ * Elle ne prouve RIEN sur la validité du code — seulement qu'il a été correctement recopié.
67
+ */
68
+ export function parseCode(input) {
69
+ const c = normalizeCode(input);
70
+ if (c.length !== RANDOM_LEN + CHECK_LEN)
71
+ return { ok: false, error: 'longueur' };
72
+ if ([...c].some((ch) => !ALPHABET.includes(ch)))
73
+ return { ok: false, error: 'caractere_invalide' };
74
+ const body = c.slice(0, RANDOM_LEN);
75
+ const check = c.slice(RANDOM_LEN);
76
+ if (check !== checksum(body))
77
+ return { ok: false, error: 'somme_de_controle' };
78
+ return { ok: true, canonical: c };
79
+ }
80
+ /**
81
+ * Empreinte du code, pour la console.
82
+ *
83
+ * Le code n'est **jamais** stocké en clair : une fuite de la base ne doit donner **aucun** code
84
+ * utilisable. C'est un secret d'authentification — on le traite comme un mot de passe.
85
+ * HMAC (et non simple SHA) : sans le `secret` de la console, on ne peut pas pré-calculer un
86
+ * dictionnaire de 2⁵⁰ empreintes.
87
+ */
88
+ export function hashCode(canonical, secret) {
89
+ return hmacSha256(secret, `mostajs-enrollment-code/v1:${normalizeCode(canonical)}`);
90
+ }
91
+ /** Compare un code saisi à une empreinte — en TEMPS CONSTANT. */
92
+ export function verifyCode(input, hash, secret) {
93
+ const p = parseCode(input);
94
+ if (!p.ok)
95
+ return false;
96
+ return equalsConstantTime(hashCode(p.canonical, secret), hash);
97
+ }
98
+ export const _internal = { ALPHABET, checksum, format, PREFIX, RANDOM_LEN, CHECK_LEN };
@@ -0,0 +1,41 @@
1
+ /**
2
+ * @mostajs/enrollment — Enrôler une machine chez un client distant, sans configuration.
3
+ * @author Dr Hamid MADANI <drmdh@msn.com>
4
+ * Licence : AGPL-3.0-or-later
5
+ * Niveau : N1 · Compose : crypto-box (Ed25519, HMAC) · scheduler (re-tentative)
6
+ *
7
+ * ─── LE PROBLÈME ─────────────────────────────────────────────────────────────
8
+ * Un `.exe` identique pour tous, installé chez N clients, doit s'IDENTIFIER auprès d'une console
9
+ * centrale — sans que le client configure quoi que ce soit.
10
+ *
11
+ * ─── CE QUE CE MODULE N'EST PAS ──────────────────────────────────────────────
12
+ * Ce n'est PAS de la licence. Deux questions distinctes, qu'il ne faut jamais fusionner :
13
+ * « Ce client a-t-il le DROIT d'utiliser le logiciel ? » → @mostajs/licensing
14
+ * « Cette machine EST-ELLE bien la sienne ? » → ce module
15
+ * Les mélanger interdirait de révoquer un enrôlement (machine volée) sans couper la licence.
16
+ *
17
+ * ─── LES TROIS VERROUS ───────────────────────────────────────────────────────
18
+ * 1. **Pas de jeton générique dans le binaire.** Quiconque possède le `.exe` pourrait s'enrôler.
19
+ * → Un code PAR CLIENT, dictable au téléphone, à usage unique, 7 jours.
20
+ * 2. **`machineId` n'authentifie pas** — il identifie. Il est public et falsifiable.
21
+ * → Une paire Ed25519 générée sur la machine ; la clé PRIVÉE ne sort jamais. Un jeton volé est
22
+ * rejouable ; une clé qui ne bouge pas, non.
23
+ * 3. **La console peut être usurpée** (DNS détourné, certificat frauduleux).
24
+ * → Chaque approbation est SIGNÉE, et vérifiée avec la clé publique EMBARQUÉE dans le binaire.
25
+ *
26
+ * ─── ET LE CLIENT NE REFAIT JAMAIS RIEN ──────────────────────────────────────
27
+ * L'approbation est un CLIC humain — mais l'agent RE-SONDE en boucle. Le client installe et part
28
+ * travailler ; vous approuvez quand vous voulez ; la machine se rattache toute seule.
29
+ */
30
+ export declare const moduleInfo: {
31
+ readonly name: "@mostajs/enrollment";
32
+ readonly version: "0.1.0";
33
+ readonly level: "N1";
34
+ };
35
+ export { generateCode, parseCode, normalizeCode, hashCode, verifyCode } from './code.js';
36
+ export type { EnrollmentCode, CodeError } from './code.js';
37
+ export { createEnrollmentService } from './service.js';
38
+ export type { EnrollmentServiceOptions, RequestOutcome } from './service.js';
39
+ export { createEnrollmentAgent, generateIdentity } from './agent.js';
40
+ export type { EnrollmentAgentOptions, AgentState, AgentIdentity } from './agent.js';
41
+ export type { EnrollmentState, EnrollmentRequest, EnrolledSite, Invitation, EnrollmentStore, ApprovalPayload, ReenrollmentAlert, } from './types.js';
package/dist/index.js ADDED
@@ -0,0 +1,33 @@
1
+ /**
2
+ * @mostajs/enrollment — Enrôler une machine chez un client distant, sans configuration.
3
+ * @author Dr Hamid MADANI <drmdh@msn.com>
4
+ * Licence : AGPL-3.0-or-later
5
+ * Niveau : N1 · Compose : crypto-box (Ed25519, HMAC) · scheduler (re-tentative)
6
+ *
7
+ * ─── LE PROBLÈME ─────────────────────────────────────────────────────────────
8
+ * Un `.exe` identique pour tous, installé chez N clients, doit s'IDENTIFIER auprès d'une console
9
+ * centrale — sans que le client configure quoi que ce soit.
10
+ *
11
+ * ─── CE QUE CE MODULE N'EST PAS ──────────────────────────────────────────────
12
+ * Ce n'est PAS de la licence. Deux questions distinctes, qu'il ne faut jamais fusionner :
13
+ * « Ce client a-t-il le DROIT d'utiliser le logiciel ? » → @mostajs/licensing
14
+ * « Cette machine EST-ELLE bien la sienne ? » → ce module
15
+ * Les mélanger interdirait de révoquer un enrôlement (machine volée) sans couper la licence.
16
+ *
17
+ * ─── LES TROIS VERROUS ───────────────────────────────────────────────────────
18
+ * 1. **Pas de jeton générique dans le binaire.** Quiconque possède le `.exe` pourrait s'enrôler.
19
+ * → Un code PAR CLIENT, dictable au téléphone, à usage unique, 7 jours.
20
+ * 2. **`machineId` n'authentifie pas** — il identifie. Il est public et falsifiable.
21
+ * → Une paire Ed25519 générée sur la machine ; la clé PRIVÉE ne sort jamais. Un jeton volé est
22
+ * rejouable ; une clé qui ne bouge pas, non.
23
+ * 3. **La console peut être usurpée** (DNS détourné, certificat frauduleux).
24
+ * → Chaque approbation est SIGNÉE, et vérifiée avec la clé publique EMBARQUÉE dans le binaire.
25
+ *
26
+ * ─── ET LE CLIENT NE REFAIT JAMAIS RIEN ──────────────────────────────────────
27
+ * L'approbation est un CLIC humain — mais l'agent RE-SONDE en boucle. Le client installe et part
28
+ * travailler ; vous approuvez quand vous voulez ; la machine se rattache toute seule.
29
+ */
30
+ export const moduleInfo = { name: '@mostajs/enrollment', version: '0.1.0', level: 'N1' };
31
+ export { generateCode, parseCode, normalizeCode, hashCode, verifyCode } from './code.js';
32
+ export { createEnrollmentService } from './service.js';
33
+ export { createEnrollmentAgent, generateIdentity } from './agent.js';
@@ -0,0 +1,91 @@
1
+ /**
2
+ * @mostajs/enrollment — Le service CONSOLE : reçoit, approuve, révoque.
3
+ * @author Dr Hamid MADANI <drmdh@msn.com>
4
+ * Licence : AGPL-3.0-or-later
5
+ */
6
+ import type { EnrolledSite, EnrollmentStore, ApprovalPayload } from './types.js';
7
+ export interface EnrollmentServiceOptions {
8
+ store: EnrollmentStore;
9
+ /** Secret de la console — sert à hacher les codes. Jamais exposé. */
10
+ codeSecret: string;
11
+ /** Clé PRIVÉE Ed25519 de la console : signe les approbations. */
12
+ signingKey: Uint8Array;
13
+ /** Clé PUBLIQUE correspondante — celle EMBARQUÉE dans le `.exe`. */
14
+ signingPublicKey: Uint8Array;
15
+ /** Échecs consécutifs tolérés avant verrouillage. Défaut 5. */
16
+ maxAttempts?: number;
17
+ /** Durée du verrouillage (ms). Défaut 15 min. */
18
+ lockoutMs?: number;
19
+ now?: () => Date;
20
+ }
21
+ export type RequestOutcome = {
22
+ ok: true;
23
+ requestId: string;
24
+ state: 'pending';
25
+ } | {
26
+ ok: false;
27
+ error: 'code_malforme' | 'code_invalide' | 'code_expire' | 'code_deja_utilise' | 'verrouille';
28
+ };
29
+ /**
30
+ * Le service d'enrôlement, côté console.
31
+ *
32
+ * ─── CE QU'IL PROTÈGE ────────────────────────────────────────────────────────
33
+ * 1. **La force brute.** 50 bits d'entropie ne résistent PAS à une attaque en ligne illimitée.
34
+ * Le service VERROUILLE après quelques échecs — par code ET par origine. Sans ce verrou, le
35
+ * code dictable serait une fausse sécurité.
36
+ * 2. **Le ré-enrôlement usurpé.** Une machine déjà enrôlée qui revient avec une clé publique
37
+ * DIFFÉRENTE : disque remplacé, ou usurpation ? On ne peut pas deviner — donc on ne décide
38
+ * PAS. On lève un drapeau et on exige un clic conscient.
39
+ * 3. **La console usurpée.** Chaque approbation est SIGNÉE (Ed25519). La machine vérifie avec la
40
+ * clé publique embarquée dans son binaire : même si TLS tombait (DNS détourné, certificat
41
+ * frauduleux), un faux jeton ne pourrait pas être forgé.
42
+ */
43
+ export declare function createEnrollmentService(opts: EnrollmentServiceOptions): {
44
+ /**
45
+ * Une machine se présente. Elle ne reçoit RIEN d'utile ici : seulement un `requestId` pour
46
+ * sonder. Le jeton n'existe qu'après approbation humaine.
47
+ */
48
+ request(input: {
49
+ code: string;
50
+ machineId: string;
51
+ hostname: string;
52
+ publicKey: string;
53
+ app: string;
54
+ version: string;
55
+ origin?: string;
56
+ }): Promise<RequestOutcome>;
57
+ /** La machine sonde. Tant qu'aucun humain n'a cliqué, elle n'obtient rien. */
58
+ status(requestId: string): Promise<{
59
+ state: "pending" | "rejected" | "unknown";
60
+ } | {
61
+ state: "approved";
62
+ approval: ApprovalPayload;
63
+ signature: string;
64
+ }>;
65
+ /**
66
+ * Le clic. `acceptReenrollment` DOIT être explicite quand un drapeau est levé : on refuse de
67
+ * laisser approuver une usurpation par inadvertance.
68
+ */
69
+ approve(input: {
70
+ requestId: string;
71
+ siteId: string;
72
+ name: string;
73
+ approvedBy: string;
74
+ acceptReenrollment?: boolean;
75
+ }): Promise<{
76
+ ok: true;
77
+ site: EnrolledSite;
78
+ } | {
79
+ ok: false;
80
+ error: "inconnue" | "deja_traitee" | "reenrolement_non_confirme";
81
+ }>;
82
+ reject(requestId: string, by: string): Promise<{
83
+ ok: boolean;
84
+ }>;
85
+ /** Révoque un site — machine volée, client parti. La licence, elle, n'est PAS touchée. */
86
+ revoke(siteId: string): Promise<{
87
+ ok: boolean;
88
+ }>;
89
+ /** Le jeton présenté est-il celui d'un site enrôlé et NON révoqué ? */
90
+ authenticate(siteId: string, token: string): Promise<EnrolledSite | null>;
91
+ };
@@ -0,0 +1,176 @@
1
+ /**
2
+ * @mostajs/enrollment — Le service CONSOLE : reçoit, approuve, révoque.
3
+ * @author Dr Hamid MADANI <drmdh@msn.com>
4
+ * Licence : AGPL-3.0-or-later
5
+ */
6
+ import { sign, randomKey, equalsConstantTime } from '@mostajs/crypto-box';
7
+ import { verifyCode, parseCode } from './code.js';
8
+ /**
9
+ * Le service d'enrôlement, côté console.
10
+ *
11
+ * ─── CE QU'IL PROTÈGE ────────────────────────────────────────────────────────
12
+ * 1. **La force brute.** 50 bits d'entropie ne résistent PAS à une attaque en ligne illimitée.
13
+ * Le service VERROUILLE après quelques échecs — par code ET par origine. Sans ce verrou, le
14
+ * code dictable serait une fausse sécurité.
15
+ * 2. **Le ré-enrôlement usurpé.** Une machine déjà enrôlée qui revient avec une clé publique
16
+ * DIFFÉRENTE : disque remplacé, ou usurpation ? On ne peut pas deviner — donc on ne décide
17
+ * PAS. On lève un drapeau et on exige un clic conscient.
18
+ * 3. **La console usurpée.** Chaque approbation est SIGNÉE (Ed25519). La machine vérifie avec la
19
+ * clé publique embarquée dans son binaire : même si TLS tombait (DNS détourné, certificat
20
+ * frauduleux), un faux jeton ne pourrait pas être forgé.
21
+ */
22
+ export function createEnrollmentService(opts) {
23
+ const clock = opts.now ?? (() => new Date());
24
+ const maxAttempts = opts.maxAttempts ?? 5;
25
+ const lockoutMs = opts.lockoutMs ?? 15 * 60_000;
26
+ /** Compteurs d'échec, par origine (IP) ET par code présenté. */
27
+ const echecs = new Map();
28
+ function verrouille(cle) {
29
+ const e = echecs.get(cle);
30
+ if (!e)
31
+ return false;
32
+ // Aucun verrou POSÉ (jusqua = 0) : on est encore en phase de comptage. Surtout NE PAS
33
+ // purger l'entrée ici — une première version le faisait, et remettait donc le compteur à
34
+ // zéro à chaque appel : il ne dépassait jamais 1, et le verrou ne se déclenchait JAMAIS.
35
+ // Un anti-force-brute qui ne bloque rien est pire que pas d'anti-force-brute : on se croit
36
+ // protégé.
37
+ if (e.jusqua === 0)
38
+ return false;
39
+ if (clock().getTime() < e.jusqua)
40
+ return true;
41
+ echecs.delete(cle); // le verrou a expiré : on repart à zéro
42
+ return false;
43
+ }
44
+ function echec(cle) {
45
+ const e = echecs.get(cle) ?? { n: 0, jusqua: 0 };
46
+ e.n += 1;
47
+ if (e.n >= maxAttempts)
48
+ e.jusqua = clock().getTime() + lockoutMs;
49
+ echecs.set(cle, e);
50
+ }
51
+ return {
52
+ /**
53
+ * Une machine se présente. Elle ne reçoit RIEN d'utile ici : seulement un `requestId` pour
54
+ * sonder. Le jeton n'existe qu'après approbation humaine.
55
+ */
56
+ async request(input) {
57
+ const origine = input.origin ?? 'inconnue';
58
+ if (verrouille(origine))
59
+ return { ok: false, error: 'verrouille' };
60
+ // Forme d'abord : rejeter une faute de frappe ne doit RIEN coûter, ni compter comme un
61
+ // échec de sécurité — sinon un client maladroit se verrouille tout seul.
62
+ const forme = parseCode(input.code);
63
+ if (!forme.ok)
64
+ return { ok: false, error: 'code_malforme' };
65
+ const invitation = await opts.store.findInvitationByCode((h) => verifyCode(input.code, h, opts.codeSecret));
66
+ if (!invitation) {
67
+ echec(origine);
68
+ return { ok: false, error: 'code_invalide' };
69
+ }
70
+ if (invitation.usedAt)
71
+ return { ok: false, error: 'code_deja_utilise' };
72
+ if (new Date(invitation.expiresAt) < clock())
73
+ return { ok: false, error: 'code_expire' };
74
+ // ── Le drapeau du ré-enrôlement. On NE tranche PAS : on alerte.
75
+ const existant = await opts.store.findSiteByMachineId(input.machineId);
76
+ const cleChangee = !!existant && !equalsConstantTime(existant.publicKey, input.publicKey);
77
+ const req = {
78
+ id: `req-${Date.now()}-${randomKey(6).toString('hex')}`,
79
+ invitationId: invitation.id,
80
+ machineId: input.machineId,
81
+ hostname: input.hostname,
82
+ publicKey: input.publicKey,
83
+ app: input.app,
84
+ version: input.version,
85
+ origin: origine,
86
+ state: 'pending',
87
+ createdAt: clock().toISOString(),
88
+ /**
89
+ * Machine connue, clé DIFFÉRENTE. C'est soit un disque remplacé chez le client, soit
90
+ * quelqu'un qui usurpe son identité — et rien dans la requête ne permet de distinguer
91
+ * les deux. L'accepter en silence serait le piège classique de ce protocole.
92
+ */
93
+ reenrollmentAlert: cleChangee
94
+ ? { previousSiteId: existant.siteId, previousPublicKey: existant.publicKey }
95
+ : undefined,
96
+ };
97
+ await opts.store.saveRequest(req);
98
+ echecs.delete(origine); // code valide → on remet le compteur à zéro
99
+ return { ok: true, requestId: req.id, state: 'pending' };
100
+ },
101
+ /** La machine sonde. Tant qu'aucun humain n'a cliqué, elle n'obtient rien. */
102
+ async status(requestId) {
103
+ const req = await opts.store.findRequest(requestId);
104
+ if (!req)
105
+ return { state: 'unknown' };
106
+ if (req.state !== 'approved' || !req.approval) {
107
+ return { state: req.state };
108
+ }
109
+ // L'approbation est SIGNÉE : la machine la vérifie avec la clé publique embarquée dans son
110
+ // binaire. Un imposteur ne peut pas forger de jeton, même en cassant TLS.
111
+ const payload = JSON.stringify(req.approval);
112
+ return { state: 'approved', approval: req.approval, signature: sign(opts.signingKey, payload).toString('hex') };
113
+ },
114
+ /**
115
+ * Le clic. `acceptReenrollment` DOIT être explicite quand un drapeau est levé : on refuse de
116
+ * laisser approuver une usurpation par inadvertance.
117
+ */
118
+ async approve(input) {
119
+ const req = await opts.store.findRequest(input.requestId);
120
+ if (!req)
121
+ return { ok: false, error: 'inconnue' };
122
+ if (req.state !== 'pending')
123
+ return { ok: false, error: 'deja_traitee' };
124
+ if (req.reenrollmentAlert && !input.acceptReenrollment) {
125
+ // Refus DÉLIBÉRÉ : l'approbateur doit avoir vu l'alerte et l'avoir acceptée sciemment.
126
+ return { ok: false, error: 'reenrolement_non_confirme' };
127
+ }
128
+ const token = randomKey(32).toString('hex');
129
+ const site = {
130
+ siteId: input.siteId,
131
+ name: input.name,
132
+ machineId: req.machineId,
133
+ publicKey: req.publicKey,
134
+ app: req.app,
135
+ token,
136
+ enrolledAt: clock().toISOString(),
137
+ revokedAt: null,
138
+ };
139
+ req.state = 'approved';
140
+ req.approval = { siteId: site.siteId, name: site.name, token, consolePublicKey: Buffer.from(opts.signingPublicKey).toString('hex') };
141
+ req.decidedAt = clock().toISOString();
142
+ req.decidedBy = input.approvedBy;
143
+ await opts.store.saveSite(site);
144
+ await opts.store.saveRequest(req);
145
+ // Le code est CONSOMMÉ. Un secret qui traîne est une porte ouverte pour rien.
146
+ await opts.store.markInvitationUsed(req.invitationId, clock().toISOString());
147
+ return { ok: true, site };
148
+ },
149
+ async reject(requestId, by) {
150
+ const req = await opts.store.findRequest(requestId);
151
+ if (!req || req.state !== 'pending')
152
+ return { ok: false };
153
+ req.state = 'rejected';
154
+ req.decidedAt = clock().toISOString();
155
+ req.decidedBy = by;
156
+ await opts.store.saveRequest(req);
157
+ return { ok: true };
158
+ },
159
+ /** Révoque un site — machine volée, client parti. La licence, elle, n'est PAS touchée. */
160
+ async revoke(siteId) {
161
+ const site = await opts.store.findSite(siteId);
162
+ if (!site || site.revokedAt)
163
+ return { ok: false };
164
+ site.revokedAt = clock().toISOString();
165
+ await opts.store.saveSite(site);
166
+ return { ok: true };
167
+ },
168
+ /** Le jeton présenté est-il celui d'un site enrôlé et NON révoqué ? */
169
+ async authenticate(siteId, token) {
170
+ const site = await opts.store.findSite(siteId);
171
+ if (!site || site.revokedAt)
172
+ return null;
173
+ return equalsConstantTime(site.token, token) ? site : null;
174
+ },
175
+ };
176
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * @mostajs/enrollment — types partagés.
3
+ * @author Dr Hamid MADANI <drmdh@msn.com>
4
+ */
5
+ export type EnrollmentState = 'pending' | 'approved' | 'rejected' | 'unknown';
6
+ /** Ce que la console renvoie à la machine, APRÈS approbation. Signé Ed25519. */
7
+ export interface ApprovalPayload {
8
+ siteId: string;
9
+ name: string;
10
+ /** Jeton PROPRE à cette machine. Remplace le code d'enrôlement, qui est consommé. */
11
+ token: string;
12
+ /** Clé publique de la console — la machine la compare à celle EMBARQUÉE dans son binaire. */
13
+ consolePublicKey: string;
14
+ }
15
+ /** Drapeau levé quand une machine connue revient avec une AUTRE clé publique. */
16
+ export interface ReenrollmentAlert {
17
+ previousSiteId: string;
18
+ previousPublicKey: string;
19
+ }
20
+ export interface EnrollmentRequest {
21
+ id: string;
22
+ invitationId: string;
23
+ machineId: string;
24
+ hostname: string;
25
+ /** Clé PUBLIQUE Ed25519 de la machine (hex). La privée ne quitte jamais le client. */
26
+ publicKey: string;
27
+ app: string;
28
+ version: string;
29
+ origin: string;
30
+ state: EnrollmentState;
31
+ createdAt: string;
32
+ decidedAt?: string;
33
+ decidedBy?: string;
34
+ approval?: ApprovalPayload;
35
+ /** Non nul ⇒ l'approbation exige une confirmation EXPLICITE. */
36
+ reenrollmentAlert?: ReenrollmentAlert;
37
+ }
38
+ /** Invitation = un code émis pour UN client. À usage unique, expirant. */
39
+ export interface Invitation {
40
+ id: string;
41
+ /** EMPREINTE du code. Le code en clair n'est JAMAIS persisté. */
42
+ codeHash: string;
43
+ label: string;
44
+ expiresAt: string;
45
+ usedAt?: string | null;
46
+ }
47
+ export interface EnrolledSite {
48
+ siteId: string;
49
+ name: string;
50
+ machineId: string;
51
+ publicKey: string;
52
+ app: string;
53
+ token: string;
54
+ enrolledAt: string;
55
+ revokedAt: string | null;
56
+ }
57
+ /**
58
+ * Persistance — INJECTÉE.
59
+ *
60
+ * Le module ne choisit pas votre base : il ne dépend donc pas de l'ORM. Une console qui veut du
61
+ * @mostajs/orm l'implémente avec ; une démo, avec une Map en mémoire. C'est ce qui rend le
62
+ * module réutilisable ailleurs.
63
+ */
64
+ export interface EnrollmentStore {
65
+ /** Retrouve l'invitation dont l'empreinte satisfait `match` (comparaison à temps constant). */
66
+ findInvitationByCode(match: (codeHash: string) => boolean): Promise<Invitation | null>;
67
+ markInvitationUsed(invitationId: string, at: string): Promise<void>;
68
+ saveRequest(req: EnrollmentRequest): Promise<void>;
69
+ findRequest(id: string): Promise<EnrollmentRequest | null>;
70
+ listPendingRequests(): Promise<EnrollmentRequest[]>;
71
+ saveSite(site: EnrolledSite): Promise<void>;
72
+ findSite(siteId: string): Promise<EnrolledSite | null>;
73
+ findSiteByMachineId(machineId: string): Promise<EnrolledSite | null>;
74
+ listSites(): Promise<EnrolledSite[]>;
75
+ }
package/dist/types.js ADDED
@@ -0,0 +1,5 @@
1
+ /**
2
+ * @mostajs/enrollment — types partagés.
3
+ * @author Dr Hamid MADANI <drmdh@msn.com>
4
+ */
5
+ export {};
package/llms.txt ADDED
@@ -0,0 +1,80 @@
1
+ # @mostajs/enrollment
2
+
3
+ **Auteur** : Dr Hamid MADANI <drmdh@msn.com> · **Licence** : AGPL-3.0-or-later
4
+ **Niveau** : N1 · **Dépendance** : `@mostajs/crypto-box`
5
+
6
+ Enrôler une machine chez un client distant — **sans configuration**.
7
+
8
+ ## Ce que ce module n'est PAS
9
+
10
+ **Ce n'est pas de la licence.** Deux questions distinctes, qu'il ne faut **jamais** fusionner :
11
+
12
+ | Question | Module |
13
+ |---|---|
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
+
17
+ Les mélanger interdirait de révoquer un enrôlement (machine volée) **sans couper la licence**.
18
+
19
+ ## Les trois verrous
20
+
21
+ **1. Pas de jeton générique dans le binaire.** Un jeton d'amorçage embarqué dans chaque `.exe`
22
+ est une faille : **quiconque possède le binaire** pourrait s'enrôler et devenir un « site »
23
+ légitime de votre console.
24
+ → Un **code par client**, dictable au téléphone, **usage unique**, **7 jours**.
25
+
26
+ **2. `machineId` n'authentifie pas** — il **identifie**. C'est une valeur publique et
27
+ **falsifiable**.
28
+ → Une paire **Ed25519** générée sur la machine ; la clé **privée ne sort jamais**. Un jeton volé
29
+ (fichier de config, sauvegarde, capture réseau) est **rejouable** ; une clé qui ne bouge pas, non.
30
+
31
+ **3. La console peut être usurpée** (DNS détourné, certificat frauduleux). TLS prouve qu'on parle
32
+ à `console.example` — pas qu'on parle à **LA** console.
33
+ → Chaque approbation est **signée Ed25519**, vérifiée avec la clé publique **embarquée dans le
34
+ binaire**. Même si TLS tombait, un faux jeton **ne pourrait pas être forgé**.
35
+
36
+ ## Rien à configurer chez le client
37
+
38
+ L'enrôlement est **sortant** : la machine **appelle** la console, jamais l'inverse. **Aucun
39
+ certificat, aucun port ouvert, aucun reverse proxy.** Fonctionne derrière une box, un NAT, un
40
+ pare-feu d'entreprise.
41
+
42
+ ## Le code, dictable au téléphone
43
+
44
+ ```
45
+ MJSE-7K4P-2XQ9-M3TA
46
+ ```
47
+
48
+ **Crockford Base32** — ni `I`, ni `L`, ni `O`, ni `U`. Fini le « c'est un zéro ou un O ? », la
49
+ question qui fait échouer une installation à distance. Insensible à la casse, tirets décoratifs.
50
+
51
+ **10 caractères aléatoires + 2 de contrôle** : une faute de frappe est rejetée **hors ligne**, le
52
+ client voit « code invalide » **tout de suite** — pas après trente secondes d'attente pour un refus
53
+ qui ne lui dit pas si c'est lui ou le serveur.
54
+
55
+ **Jamais stocké en clair** côté console : seule son empreinte HMAC est conservée. Une fuite de la
56
+ base ne donne **aucun** code utilisable.
57
+
58
+ ## Le clic est indolore
59
+
60
+ L'approbation est **humaine** — mais l'agent **re-sonde en boucle**. Le client installe et part
61
+ travailler ; vous approuvez **quand vous voulez** ; la machine **se rattache toute seule**.
62
+ **Personne ne refait rien.**
63
+
64
+ Sans cette re-tentative, le client qui installe un dimanche à 8 h devrait tout recommencer lundi.
65
+
66
+ ## Le piège du ré-enrôlement
67
+
68
+ Une machine **déjà enrôlée** qui revient avec une **clé publique différente** : disque remplacé
69
+ chez le client, ou **usurpation** ? **On ne peut pas deviner** — donc on **ne décide pas**. La
70
+ console lève une **alerte explicite** et **refuse d'approuver** sans confirmation consciente.
71
+
72
+ ## Tests
73
+
74
+ ```bash
75
+ npm test # 16 tests mjs-unit
76
+ ```
77
+
78
+ Un protocole d'enrôlement se juge sur ce qu'il **refuse** : force brute verrouillée, faute de
79
+ frappe qui **ne verrouille pas** le client maladroit, code expiré, code réutilisé, ré-enrôlement
80
+ non confirmé, **approbation forgée par un imposteur**.
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@mostajs/enrollment",
3
+ "version": "0.1.0",
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
+ "license": "AGPL-3.0-or-later",
6
+ "author": "Dr Hamid MADANI <drmdh@msn.com>",
7
+ "type": "module",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js",
12
+ "default": "./dist/index.js"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist",
17
+ "README.md",
18
+ "llms.txt",
19
+ "LICENSE"
20
+ ],
21
+ "scripts": {
22
+ "build": "tsc",
23
+ "test": "bash test-scripts/run-tests.sh",
24
+ "example": "node examples/demo.mjs",
25
+ "prepublishOnly": "npm run build"
26
+ },
27
+ "keywords": [
28
+ "enrollment",
29
+ "provisioning",
30
+ "device",
31
+ "fleet",
32
+ "bootstrap",
33
+ "ed25519",
34
+ "mostajs"
35
+ ],
36
+ "dependencies": {
37
+ "@mostajs/crypto-box": "^0.2.0"
38
+ },
39
+ "devDependencies": {
40
+ "@mostajs/mjs-unit": "^0.4.1",
41
+ "@types/node": "^20",
42
+ "typescript": "^5.6.0"
43
+ }
44
+ }