diff --git a/CHANGELOG.md b/CHANGELOG.md index ed5fba6..13bf048 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ incompatibles. Chaque ligne renvoie à un commit dédié (un artefact = un commi ## [Unreleased] +## [0.19.0] — 2026-06-23 — début Phase 4 (UI : auth + service chat) +### Added +- `src/api/auth.ts` : auth surface exposée — mot de passe scrypt (`node:crypto`), + TOTP 2FA (`otplib`), JWT HS256 (`jose`), `login` tout-ou-rien. 10 tests. +- `src/agent/chat-service.ts` : `ChatService` (un tour d'agent par message), + partagé par toutes les surfaces. Telegram refactoré pour l'utiliser. +- Deps épinglées `jose` 6.2.3, `otplib` 13.4.1 (`npm audit` 0 vuln). + ## [0.18.0] — 2026-06-23 — fin Phase 3 (alertes) ### Added - `workflows-n8n/chlova-alerts.v1.0.0.json` : workflow n8n webhook → mail (formate diff --git a/orchestrator/package-lock.json b/orchestrator/package-lock.json index c4b9f1f..0555226 100644 --- a/orchestrator/package-lock.json +++ b/orchestrator/package-lock.json @@ -10,11 +10,13 @@ "dependencies": { "@modelcontextprotocol/sdk": "1.29.0", "fastify": "5.8.5", + "jose": "^6.2.3", + "otplib": "^13.4.1", "pino": "10.3.1", "zod": "3.24.1" }, "devDependencies": { - "@types/node": "^24.13.2", + "@types/node": "24.13.2", "tsx": "4.22.4", "typescript": "5.7.3", "vitest": "4.1.9" @@ -706,6 +708,74 @@ "@emnapi/runtime": "^1.7.1" } }, + "node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@otplib/core": { + "version": "13.4.1", + "resolved": "https://registry.npmjs.org/@otplib/core/-/core-13.4.1.tgz", + "integrity": "sha512-KIXgK1hNtWJEBMTastbe1bpmuais+3f+ATeO8TkMs2rNkfGO1FbQy8+/UWVEu3TR/iTJerU0idkPudaPmLP2BA==", + "license": "MIT" + }, + "node_modules/@otplib/hotp": { + "version": "13.4.1", + "resolved": "https://registry.npmjs.org/@otplib/hotp/-/hotp-13.4.1.tgz", + "integrity": "sha512-g9q04SwpG5ZtMnVkUcgcoAlwCH4YLROZN1qhyBwgkBzqYYVSYhpP6gSGaxGHwePLt1c+e6NqDlgIZN+e1/XPuA==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.4.1", + "@otplib/uri": "13.4.1" + } + }, + "node_modules/@otplib/plugin-base32-scure": { + "version": "13.4.1", + "resolved": "https://registry.npmjs.org/@otplib/plugin-base32-scure/-/plugin-base32-scure-13.4.1.tgz", + "integrity": "sha512-Fs/r5qisC05SRhT6xWXaypB6PVC0vgWf6zztmi0J5RnQ09OJiPDWCJFH6cDm6ANsrdvB9di7X+Jb7L13BoEbUA==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.4.1", + "@scure/base": "^2.2.0" + } + }, + "node_modules/@otplib/plugin-crypto-noble": { + "version": "13.4.1", + "resolved": "https://registry.npmjs.org/@otplib/plugin-crypto-noble/-/plugin-crypto-noble-13.4.1.tgz", + "integrity": "sha512-PJfVW8/1hdS6CfxLheKPZSLTwDq4TijZbN4yRjxlv0ODdzmxpM+wGwWr1JXMdy0xJPxLziydQD5gdVqrR4/gAg==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^2.2.0", + "@otplib/core": "13.4.1" + } + }, + "node_modules/@otplib/totp": { + "version": "13.4.1", + "resolved": "https://registry.npmjs.org/@otplib/totp/-/totp-13.4.1.tgz", + "integrity": "sha512-QOkBVPrf6AM4qZaReZPSk9/I8ATVdZpIISJz115MqeVtcrbcr5llPZ0J7804tpnjnp1vCRkI5Qjd47HhgVteBQ==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.4.1", + "@otplib/hotp": "13.4.1", + "@otplib/uri": "13.4.1" + } + }, + "node_modules/@otplib/uri": { + "version": "13.4.1", + "resolved": "https://registry.npmjs.org/@otplib/uri/-/uri-13.4.1.tgz", + "integrity": "sha512-xaIm7bvICMhoB2rZIR5luiaMdssWR5nY5nXnR1fdezUgZuEO58D6zrGzLp7pQuBmlpmL0HagnscDQFoskp9yiA==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.4.1" + } + }, "node_modules/@oxc-project/types": { "version": "0.133.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", @@ -1004,6 +1074,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@scure/base": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.2.0.tgz", + "integrity": "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -2573,6 +2652,20 @@ "wrappy": "1" } }, + "node_modules/otplib": { + "version": "13.4.1", + "resolved": "https://registry.npmjs.org/otplib/-/otplib-13.4.1.tgz", + "integrity": "sha512-o5CxfDw6bh7hoDv0NUUIcc0RqzJ9ipfUrzeKheKJ+vs4rXZnDlA9n4a/7R1cDjpmLjKLix4BgNVRmoDkm5rLSQ==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.4.1", + "@otplib/hotp": "13.4.1", + "@otplib/plugin-base32-scure": "13.4.1", + "@otplib/plugin-crypto-noble": "13.4.1", + "@otplib/totp": "13.4.1", + "@otplib/uri": "13.4.1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", diff --git a/orchestrator/package.json b/orchestrator/package.json index 71844ec..cb7bbb1 100644 --- a/orchestrator/package.json +++ b/orchestrator/package.json @@ -18,6 +18,8 @@ "dependencies": { "@modelcontextprotocol/sdk": "1.29.0", "fastify": "5.8.5", + "jose": "6.2.3", + "otplib": "13.4.1", "pino": "10.3.1", "zod": "3.24.1" }, diff --git a/orchestrator/src/agent/chat-service.ts b/orchestrator/src/agent/chat-service.ts new file mode 100644 index 0000000..5c85e8e --- /dev/null +++ b/orchestrator/src/agent/chat-service.ts @@ -0,0 +1,35 @@ +import type { Logger } from "pino"; +import { OllamaClient } from "../llm/ollama.js"; +import { runAgentTurn } from "./loop.js"; +import type { Guard, ToolHandle } from "./types.js"; + +/** + * Service de conversation : un tour d'agent par message. Partagé par toutes les + * surfaces (Telegram, API/UI) pour garantir le MÊME comportement et le même + * contrôle (Guard, audit) quelle que soit l'entrée. + */ +export interface ChatServiceDeps { + client: OllamaClient; + tools: ToolHandle[]; + guard: Guard; + systemPrompt: string; + logger: Logger; +} + +export class ChatService { + constructor(private readonly deps: ChatServiceDeps) {} + + async handle(actor: string, text: string): Promise { + const { reply, steps } = await runAgentTurn({ + client: this.deps.client, + system: this.deps.systemPrompt, + userText: text, + tools: this.deps.tools, + actor, + guard: this.deps.guard, + logger: this.deps.logger, + }); + this.deps.logger.info({ actor, steps }, "tour d'agent terminé"); + return reply; + } +} diff --git a/orchestrator/src/api/auth.ts b/orchestrator/src/api/auth.ts new file mode 100644 index 0000000..607e30a --- /dev/null +++ b/orchestrator/src/api/auth.ts @@ -0,0 +1,84 @@ +import { randomBytes, scryptSync, timingSafeEqual } from "node:crypto"; +import { verifySync as totpVerifySync } from "otplib"; +import { SignJWT, jwtVerify } from "jose"; + +/** + * Authentification de la surface exposée (Phase 4 — login fort). + * + * Modèle propriétaire mono-utilisateur : mot de passe (scrypt, `node:crypto`) + * + TOTP 2FA (otplib) → JWT court (jose). Aucun secret en clair : le hash du + * mot de passe et le secret TOTP viennent de l'environnement (docs/security.md). + */ + +const SCRYPT_KEYLEN = 64; + +/** Génère un hash stockable `scrypt$$` (pour provisionner l'env). */ +export function hashPassword(password: string): string { + const salt = randomBytes(16); + const hash = scryptSync(password, salt, SCRYPT_KEYLEN); + return `scrypt$${salt.toString("hex")}$${hash.toString("hex")}`; +} + +/** Vérifie un mot de passe contre un hash stocké, en temps constant. */ +export function verifyPassword(password: string, stored: string): boolean { + const parts = stored.split("$"); + if (parts.length !== 3 || parts[0] !== "scrypt") return false; + const salt = Buffer.from(parts[1]!, "hex"); + const expected = Buffer.from(parts[2]!, "hex"); + const actual = scryptSync(password, salt, expected.length); + return expected.length === actual.length && timingSafeEqual(expected, actual); +} + +/** Vérifie un code TOTP. */ +export function verifyTotp(token: string, secret: string): boolean { + return totpVerifySync({ token: token.trim(), secret }).valid; +} + +export interface AuthConfig { + adminUser: string; + adminPasswordHash: string; + totpSecret: string; + jwtSecret: string; + jwtTtlSeconds?: number; +} + +export interface LoginInput { + user: string; + password: string; + totp: string; +} + +const enc = new TextEncoder(); + +/** Émet un JWT signé (HS256) pour l'utilisateur. */ +export async function issueJwt(cfg: AuthConfig): Promise { + const ttl = cfg.jwtTtlSeconds ?? 3600; + return new SignJWT({ role: "owner" }) + .setProtectedHeader({ alg: "HS256" }) + .setSubject(cfg.adminUser) + .setIssuedAt() + .setExpirationTime(`${ttl}s`) + .sign(enc.encode(cfg.jwtSecret)); +} + +/** Vérifie un JWT ; retourne le sujet ou null. */ +export async function verifyJwt(token: string, jwtSecret: string): Promise { + try { + const { payload } = await jwtVerify(token, enc.encode(jwtSecret)); + return typeof payload.sub === "string" ? payload.sub : null; + } catch { + return null; + } +} + +/** + * Flux de login : user + mot de passe + TOTP, tout-ou-rien (pas d'indice sur ce + * qui a échoué). Retourne un JWT en cas de succès, sinon null. + */ +export async function login(cfg: AuthConfig, input: LoginInput): Promise { + const okUser = input.user === cfg.adminUser; + const okPass = verifyPassword(input.password, cfg.adminPasswordHash); + const okTotp = verifyTotp(input.totp, cfg.totpSecret); + if (!okUser || !okPass || !okTotp) return null; + return issueJwt(cfg); +} diff --git a/orchestrator/src/index.ts b/orchestrator/src/index.ts index 4bcf111..874f545 100644 --- a/orchestrator/src/index.ts +++ b/orchestrator/src/index.ts @@ -12,7 +12,7 @@ import { HttpAlertSender, NullAlertSender } from "./alerts/sender.js"; import { startAlertScheduler } from "./alerts/scheduler.js"; import type { AlertSender } from "./alerts/types.js"; import type { Guard, ToolHandle } from "./agent/types.js"; -import { runAgentTurn } from "./agent/loop.js"; +import { ChatService } from "./agent/chat-service.js"; import { buildSystemPrompt } from "./agent/system-prompt.js"; import { TelegramSurface } from "./surfaces/telegram.js"; @@ -91,6 +91,7 @@ async function main(): Promise { apiKey: cfg.ollamaApiKey, model: cfg.ollamaModel, }); + const chat = new ChatService({ client, tools, guard, systemPrompt, logger }); // ── Surface Telegram (long-polling) ───────────────────────────────────── const telegram = new TelegramSurface( @@ -129,17 +130,7 @@ async function main(): Promise { if (review && isCommand(text)) { return handleReviewCommand(review, text); } - const { reply, steps } = await runAgentTurn({ - client, - system: systemPrompt, - userText: text, - tools, - actor: `telegram:${userId}`, - guard, - logger, - }); - logger.info({ actor: `telegram:${userId}`, steps }, "tour d'agent terminé"); - return reply; + return chat.handle(`telegram:${userId}`, text); }); } diff --git a/orchestrator/test/auth.test.ts b/orchestrator/test/auth.test.ts new file mode 100644 index 0000000..2656c2c --- /dev/null +++ b/orchestrator/test/auth.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect } from "vitest"; +import { generateSecret, generateSync } from "otplib"; +import { + hashPassword, + verifyPassword, + verifyTotp, + issueJwt, + verifyJwt, + login, + type AuthConfig, +} from "../src/api/auth.js"; + +describe("password (scrypt)", () => { + it("vérifie un mot de passe correct et rejette un faux", () => { + const h = hashPassword("s3cr3t!"); + expect(verifyPassword("s3cr3t!", h)).toBe(true); + expect(verifyPassword("wrong", h)).toBe(false); + }); + + it("rejette un hash malformé", () => { + expect(verifyPassword("x", "pasunhash")).toBe(false); + }); +}); + +describe("TOTP", () => { + it("valide un code généré pour le secret", () => { + const secret = generateSecret(); + const code = generateSync({ secret: secret }); + expect(verifyTotp(code, secret)).toBe(true); + expect(verifyTotp("000000", secret)).toBe(false); + }); +}); + +const cfg = (): AuthConfig => ({ + adminUser: "kantin", + adminPasswordHash: hashPassword("pw"), + totpSecret: generateSecret(), + jwtSecret: "a-very-long-jwt-secret-value-32+chars", +}); + +describe("JWT", () => { + it("émet et vérifie un JWT", async () => { + const c = cfg(); + const token = await issueJwt(c); + expect(await verifyJwt(token, c.jwtSecret)).toBe("kantin"); + }); + + it("rejette un JWT avec mauvais secret", async () => { + const c = cfg(); + const token = await issueJwt(c); + expect(await verifyJwt(token, "autre-secret-completement-different")).toBeNull(); + }); +}); + +describe("login (mdp + TOTP, tout-ou-rien)", () => { + it("réussit avec les bons facteurs", async () => { + const c = cfg(); + const token = await login(c, { + user: "kantin", + password: "pw", + totp: generateSync({ secret: c.totpSecret }), + }); + expect(token).not.toBeNull(); + }); + + it("échoue si un seul facteur est faux", async () => { + const c = cfg(); + const good = generateSync({ secret: c.totpSecret }); + expect(await login(c, { user: "kantin", password: "bad", totp: good })).toBeNull(); + expect(await login(c, { user: "kantin", password: "pw", totp: "000000" })).toBeNull(); + expect(await login(c, { user: "intrus", password: "pw", totp: good })).toBeNull(); + }); +});