feat: auth surface exposée + ChatService partagé (v0.19.0)

Auth login fort : mot de passe scrypt + TOTP 2FA (otplib) + JWT HS256
(jose), login tout-ou-rien sans indice. ChatService factorise le tour
d'agent pour toutes les surfaces (Telegram refactoré). 60 tests, 0 vuln.

Palier de risque : reversible (logique d'auth ; surface API câblée en v0.20).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Kantin-Petit
2026-06-23 02:06:32 +02:00
parent b617487d0d
commit e322ed1167
7 changed files with 299 additions and 13 deletions
+8
View File
@@ -6,6 +6,14 @@ incompatibles. Chaque ligne renvoie à un commit dédié (un artefact = un commi
## [Unreleased] ## [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) ## [0.18.0] — 2026-06-23 — fin Phase 3 (alertes)
### Added ### Added
- `workflows-n8n/chlova-alerts.v1.0.0.json` : workflow n8n webhook → mail (formate - `workflows-n8n/chlova-alerts.v1.0.0.json` : workflow n8n webhook → mail (formate
+94 -1
View File
@@ -10,11 +10,13 @@
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "1.29.0", "@modelcontextprotocol/sdk": "1.29.0",
"fastify": "5.8.5", "fastify": "5.8.5",
"jose": "^6.2.3",
"otplib": "^13.4.1",
"pino": "10.3.1", "pino": "10.3.1",
"zod": "3.24.1" "zod": "3.24.1"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^24.13.2", "@types/node": "24.13.2",
"tsx": "4.22.4", "tsx": "4.22.4",
"typescript": "5.7.3", "typescript": "5.7.3",
"vitest": "4.1.9" "vitest": "4.1.9"
@@ -706,6 +708,74 @@
"@emnapi/runtime": "^1.7.1" "@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": { "node_modules/@oxc-project/types": {
"version": "0.133.0", "version": "0.133.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz",
@@ -1004,6 +1074,15 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@standard-schema/spec": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
@@ -2573,6 +2652,20 @@
"wrappy": "1" "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": { "node_modules/parseurl": {
"version": "1.3.3", "version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+2
View File
@@ -18,6 +18,8 @@
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "1.29.0", "@modelcontextprotocol/sdk": "1.29.0",
"fastify": "5.8.5", "fastify": "5.8.5",
"jose": "6.2.3",
"otplib": "13.4.1",
"pino": "10.3.1", "pino": "10.3.1",
"zod": "3.24.1" "zod": "3.24.1"
}, },
+35
View File
@@ -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<string> {
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;
}
}
+84
View File
@@ -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$<saltHex>$<hashHex>` (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<string> {
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<string | null> {
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<string | null> {
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);
}
+3 -12
View File
@@ -12,7 +12,7 @@ import { HttpAlertSender, NullAlertSender } from "./alerts/sender.js";
import { startAlertScheduler } from "./alerts/scheduler.js"; import { startAlertScheduler } from "./alerts/scheduler.js";
import type { AlertSender } from "./alerts/types.js"; import type { AlertSender } from "./alerts/types.js";
import type { Guard, ToolHandle } from "./agent/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 { buildSystemPrompt } from "./agent/system-prompt.js";
import { TelegramSurface } from "./surfaces/telegram.js"; import { TelegramSurface } from "./surfaces/telegram.js";
@@ -91,6 +91,7 @@ async function main(): Promise<void> {
apiKey: cfg.ollamaApiKey, apiKey: cfg.ollamaApiKey,
model: cfg.ollamaModel, model: cfg.ollamaModel,
}); });
const chat = new ChatService({ client, tools, guard, systemPrompt, logger });
// ── Surface Telegram (long-polling) ───────────────────────────────────── // ── Surface Telegram (long-polling) ─────────────────────────────────────
const telegram = new TelegramSurface( const telegram = new TelegramSurface(
@@ -129,17 +130,7 @@ async function main(): Promise<void> {
if (review && isCommand(text)) { if (review && isCommand(text)) {
return handleReviewCommand(review, text); return handleReviewCommand(review, text);
} }
const { reply, steps } = await runAgentTurn({ return chat.handle(`telegram:${userId}`, text);
client,
system: systemPrompt,
userText: text,
tools,
actor: `telegram:${userId}`,
guard,
logger,
});
logger.info({ actor: `telegram:${userId}`, steps }, "tour d'agent terminé");
return reply;
}); });
} }
+73
View File
@@ -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();
});
});