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:
Generated
+94
-1
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user