# Tâche 1.9 — Phase 2 (sécurité credentials) — Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development ou superpowers:executing-plans. Étapes en checkbox. **Goal:** Isoler les secrets SSH dans une table dédiée `machine_credentials` (+ table `machine_host_keys`), de façon **non destructive** : nouvelle table, écriture dédiée, lecture prioritaire avec fallback sur `machines.enc_password`, et backfill des machines existantes. **Architecture:** Ajout additif (Drizzle/SQLite, migration `0003`). `machines.enc_password`/`enc_sudo_password` sont CONSERVÉS (non droppés) comme fallback/legacy. Un service `credentials` écrit/lit `machine_credentials` ; `createMachine` y insère, `getCreds` lit `machine_credentials` puis retombe sur les colonnes `machines` si absent ; un backfill (idempotent) crée les lignes manquantes au démarrage. `machine_host_keys` est créée (schéma) pour la future vérification host key (pas de logique de vérif en Phase 2). **Tech Stack:** Drizzle ORM, better-sqlite3, drizzle-kit, vitest. --- ## Invariants - **Non destructif** : ne pas dropper `machines.enc_password`/`enc_sudo_password` (NOT NULL conservé). Pas de perte des machines réelles existantes. - Secrets uniquement chiffrés (AES-256-GCM existant, `server/crypto/secrets.ts`). `machine_credentials` n'est JAMAIS exposée via l'API publique (la `MachineView` reste sans secret). - Rétro-compatibilité : une machine sans ligne `machine_credentials` reste utilisable (fallback). Le backfill comble le manque. - **Ne pas committer** (l'utilisateur gère les commits). Étapes « commit » remplacées par vérification. - Tree partagé avec du WIP concurrent : ne toucher QUE `server/db/schema.ts`, migrations, `server/services/credentials.ts` (+test), `server/services/machines.ts`, et le point de backfill (`server/db/migrate.ts` ou `server/index.ts`). Relire chaque fichier avant édition (drift possible). ## File Structure ``` server/db/schema.ts # MODIF : +machine_credentials, +machine_host_keys server/db/migrations/0003_*.sql # généré server/services/credentials.ts # NOUVEAU : writeCredentials/readCreds/backfill server/services/credentials.test.ts # NOUVEAU server/services/machines.ts # MODIF : createMachine écrit credentials ; getCreds lit credentials+fallback server/db/migrate.ts # MODIF : appeler backfill après migrate ``` --- ## Task 1 : Tables `machine_credentials` + `machine_host_keys` **Files:** Modify `server/db/schema.ts` ; generate migration ; extend `server/db/schema.test.ts`. - [ ] **Step 1 : Relire le schéma réel** Run: `rtk read server/db/schema.ts` (capter l'état courant, préserver tout l'existant : machines/snapshots/executions/apiClients + les 7 tables Phase 1). - [ ] **Step 2 : Ajouter les deux tables** (à la fin de `schema.ts`, avant ou après `apiClients`, sans rien supprimer) ```ts export const machineCredentials = sqliteTable("machine_credentials", { machineId: text("machine_id").primaryKey().references(() => machines.id, { onDelete: "cascade" }), authMethod: text("auth_method").notNull(), // password | ssh_key encPassword: text("enc_password"), encSudoPassword: text("enc_sudo_password"), encPrivateKey: text("enc_private_key"), encKeyPassphrase: text("enc_key_passphrase"), sudoMode: text("sudo_mode").notNull(), // same_as_ssh | separate | none createdAt: text("created_at").notNull(), updatedAt: text("updated_at").notNull(), lastTestAt: text("last_test_at"), status: text("status"), // ok | error | unknown }); export const machineHostKeys = sqliteTable("machine_host_keys", { id: text("id").primaryKey(), machineId: text("machine_id").references(() => machines.id, { onDelete: "cascade" }), hostname: text("hostname").notNull(), port: integer("port").notNull(), keyType: text("key_type"), fingerprintSha256: text("fingerprint_sha256").notNull(), publicKey: text("public_key"), status: text("status").notNull(), // approved | changed | rejected | unknown firstSeenAt: text("first_seen_at").notNull(), lastSeenAt: text("last_seen_at").notNull(), }); ``` - [ ] **Step 3 : Générer la migration** Run: `rtk pnpm db:generate` Expected: `server/db/migrations/0003_*.sql` (CREATE TABLE machine_credentials + machine_host_keys uniquement). Vérifier qu'aucun DROP ni recréation de table existante n'apparaît (sinon corriger le schéma et régénérer). - [ ] **Step 4 : Étendre `server/db/schema.test.ts`** — ajouter un test ```ts it("crée les tables de credentials Phase 2", () => { const sqlite = freshMigratedDb(); const tables = tableNames(sqlite); expect(tables).toEqual(expect.arrayContaining(["machine_credentials", "machine_host_keys"])); // machines conserve ses colonnes secrets legacy (fallback) expect(columnNames(sqlite, "machines")).toContain("enc_password"); }); ``` - [ ] **Step 5 :** Run `rtk pnpm vitest run server/db/schema.test.ts` → PASS. Puis `rtk pnpm check` → 0 erreur. - [ ] **Step 6 : (pas de commit)** --- ## Task 2 : Service `credentials` (write / read / backfill) — TDD **Files:** Create `server/services/credentials.ts`, `server/services/credentials.test.ts`. - [ ] **Step 1 : Test (échec attendu)** — `server/services/credentials.test.ts` ```ts import { describe, it, expect } from "vitest"; import { resolveCreds } from "./credentials.js"; describe("resolveCreds", () => { it("préfère la ligne machine_credentials", () => { const out = resolveCreds( { encPassword: "M_PWD", encSudoPassword: null }, // machines (legacy) { encPassword: "C_PWD", encSudoPassword: "C_SUDO" }, // machine_credentials ); expect(out).toEqual({ encPassword: "C_PWD", encSudoPassword: "C_SUDO" }); }); it("retombe sur machines si pas de credentials", () => { const out = resolveCreds({ encPassword: "M_PWD", encSudoPassword: "M_SUDO" }, null); expect(out).toEqual({ encPassword: "M_PWD", encSudoPassword: "M_SUDO" }); }); }); ``` - [ ] **Step 2 :** Run `rtk pnpm vitest run server/services/credentials.test.ts` → FAIL (module manquant). - [ ] **Step 3 : Implémenter `server/services/credentials.ts`** ```ts // server/services/credentials.ts import { eq } from "drizzle-orm"; import { db, schema } from "../db/client.js"; interface EncPair { encPassword: string | null; encSudoPassword: string | null; } /** Résout la source des secrets : machine_credentials prioritaire, sinon legacy machines (fonction pure). */ export function resolveCreds(legacy: EncPair, creds: EncPair | null): EncPair { if (creds && creds.encPassword) return { encPassword: creds.encPassword, encSudoPassword: creds.encSudoPassword }; return { encPassword: legacy.encPassword, encSudoPassword: legacy.encSudoPassword }; } /** Écrit (insert/replace) la ligne machine_credentials pour une machine (secrets déjà chiffrés). */ export function writeCredentials(input: { machineId: string; encPassword: string | null; encSudoPassword: string | null; }): void { const now = new Date().toISOString(); db.insert(schema.machineCredentials) .values({ machineId: input.machineId, authMethod: "password", encPassword: input.encPassword, encSudoPassword: input.encSudoPassword, sudoMode: input.encSudoPassword ? "separate" : "same_as_ssh", createdAt: now, updatedAt: now, status: "unknown", }) .onConflictDoUpdate({ target: schema.machineCredentials.machineId, set: { encPassword: input.encPassword, encSudoPassword: input.encSudoPassword, updatedAt: now }, }) .run(); } /** Lit la ligne machine_credentials (ou null). */ export function readCredentials(machineId: string): EncPair | null { const row = db.select().from(schema.machineCredentials) .where(eq(schema.machineCredentials.machineId, machineId)).get(); return row ? { encPassword: row.encPassword, encSudoPassword: row.encSudoPassword } : null; } /** Backfill idempotent : crée une ligne machine_credentials pour chaque machine qui n'en a pas. */ export function backfillCredentials(): number { const machines = db.select().from(schema.machines).all(); let created = 0; for (const m of machines) { if (readCredentials(m.id)) continue; writeCredentials({ machineId: m.id, encPassword: m.encPassword, encSudoPassword: m.encSudoPassword }); created++; } return created; } ``` - [ ] **Step 4 :** Run `rtk pnpm vitest run server/services/credentials.test.ts` → PASS (2). (Le test n'appelle que `resolveCreds`, pur ; pas de DB.) - [ ] **Step 5 : (pas de commit)** --- ## Task 3 : Brancher dans `machines.ts` + backfill au démarrage **Files:** Modify `server/services/machines.ts`, `server/db/migrate.ts`. - [ ] **Step 1 : Relire `server/services/machines.ts`** (état réel : `getCreds`, `createMachine`). - [ ] **Step 2 : `createMachine` écrit aussi machine_credentials** Importer en tête : `import { writeCredentials, readCredentials, resolveCreds } from "./credentials.js";` Après l'`insert` de la ligne `machines` (avant `return toView(row)`), ajouter : ```ts writeCredentials({ machineId: id, encPassword: row.encPassword, encSudoPassword: row.encSudoPassword }); ``` (On conserve aussi l'écriture dans `machines.enc_password` — non destructif.) - [ ] **Step 3 : `getCreds` lit machine_credentials en priorité** Remplacer le corps de `getCreds` par : ```ts export function getCreds(m: MachineRow): SshCreds { const key = env.requireMasterKey(); const { encPassword, encSudoPassword } = resolveCreds( { encPassword: m.encPassword, encSudoPassword: m.encSudoPassword }, readCredentials(m.id), ); if (!encPassword) throw new Error("Aucun secret pour cette machine"); return { hostname: m.hostname, port: m.port, username: m.username, password: decryptSecret(encPassword, key), sudoPassword: encSudoPassword ? decryptSecret(encSudoPassword, key) : null, }; } ``` - [ ] **Step 4 : Backfill au démarrage** — dans `server/db/migrate.ts`, après `runMigrations()`, exposer et appeler le backfill. Modifier `runMigrations` pour enchaîner : ```ts // server/db/migrate.ts import { migrate } from "drizzle-orm/better-sqlite3/migrator"; import { db } from "./client.js"; import { backfillCredentials } from "../services/credentials.js"; export function runMigrations(): void { migrate(db, { migrationsFolder: "./server/db/migrations" }); const n = backfillCredentials(); if (n > 0) console.log(`[migrate] backfill credentials: ${n} machine(s)`); } ``` - [ ] **Step 5 :** Run `rtk pnpm check && rtk pnpm test` → 0 erreur TS ; tests verts (48 attendus : +2 credentials). Si un test hors périmètre (WIP concurrent) casse, le signaler sans corriger. - [ ] **Step 6 : (pas de commit)** --- ## Task 4 : Vérification finale Phase 2 - [ ] **Step 1 :** `rtk pnpm check && rtk pnpm test && rtk pnpm build` → tout vert + `dist` produit. - [ ] **Step 2 : Boot + backfill + tables** ```bash export SU_MASTER_KEY=$(openssl rand -hex 32) SU_DB_PATH=./data/p2.db SU_REPORTS_DIR=./data/p2-reports node dist/index.js > ./data/p2.log 2>&1 & sleep 3 curl -s localhost:8787/health node -e "const D=require('better-sqlite3');const db=new D('./data/p2.db');console.log(db.prepare(\"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'machine_%'\").all().map(r=>r.name).join(', '));" kill %1 2>/dev/null rm -rf ./data/p2.db* ./data/p2-reports ./data/p2.log ``` Expected: `{"ok":true}` ; tables incluent `machine_credentials`, `machine_host_keys`. (Backfill = 0 sur DB neuve, normal.) - [ ] **Step 3 :** Reporter à l'utilisateur (tables ajoutées, dual-read/backfill, non-régression). **Ne pas committer.** --- ## Self-Review (couverture tache1.9 §14 Phase 2) - créer `machine_credentials` → Task 1. ✓ - migrer `enc_password`/`enc_sudo_password` → approche non destructive : dual-write + backfill + lecture prioritaire (Tasks 2-3). Les colonnes legacy restent comme fallback (drop = phase ultérieure de nettoyage). ✓ - créer `machine_host_keys` → Task 1 (schéma ; vérification host key = logique ultérieure). ✓ - audit événements secrets → léger : non inclus en Phase 2 (le `recordEvent` Phase 1 existe ; l'audit systématique des déchiffrements relève de tâche 7 sécurité). Noté comme suite. Décision assumée : non destructif (pas de DROP des colonnes secrets de `machines`) pour protéger les machines réelles existantes. Noms cohérents : `resolveCreds`/`writeCredentials`/`readCredentials`/`backfillCredentials` définis Task 2, utilisés Task 3.