From e59e517d5cf28780052fe2016c7eb7349532b82e Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 1 Mar 2026 08:40:40 -0600 Subject: [PATCH 1/2] feat(api): add CryptoService for secret encryption (MS22-P1b) --- apps/api/src/app.module.ts | 2 + apps/api/src/crypto/crypto.module.ts | 10 +++ apps/api/src/crypto/crypto.service.spec.ts | 71 +++++++++++++++++++ apps/api/src/crypto/crypto.service.ts | 82 ++++++++++++++++++++++ 4 files changed, 165 insertions(+) create mode 100644 apps/api/src/crypto/crypto.module.ts create mode 100644 apps/api/src/crypto/crypto.service.spec.ts create mode 100644 apps/api/src/crypto/crypto.service.ts diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index ba05285..59d358a 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -39,6 +39,7 @@ import { JobStepsModule } from "./job-steps/job-steps.module"; import { CoordinatorIntegrationModule } from "./coordinator-integration/coordinator-integration.module"; import { FederationModule } from "./federation/federation.module"; import { CredentialsModule } from "./credentials/credentials.module"; +import { CryptoModule } from "./crypto/crypto.module"; import { MosaicTelemetryModule } from "./mosaic-telemetry"; import { SpeechModule } from "./speech/speech.module"; import { DashboardModule } from "./dashboard/dashboard.module"; @@ -111,6 +112,7 @@ import { RlsContextInterceptor } from "./common/interceptors/rls-context.interce CoordinatorIntegrationModule, FederationModule, CredentialsModule, + CryptoModule, MosaicTelemetryModule, SpeechModule, DashboardModule, diff --git a/apps/api/src/crypto/crypto.module.ts b/apps/api/src/crypto/crypto.module.ts new file mode 100644 index 0000000..db5f669 --- /dev/null +++ b/apps/api/src/crypto/crypto.module.ts @@ -0,0 +1,10 @@ +import { Module } from "@nestjs/common"; +import { ConfigModule } from "@nestjs/config"; +import { CryptoService } from "./crypto.service"; + +@Module({ + imports: [ConfigModule], + providers: [CryptoService], + exports: [CryptoService], +}) +export class CryptoModule {} diff --git a/apps/api/src/crypto/crypto.service.spec.ts b/apps/api/src/crypto/crypto.service.spec.ts new file mode 100644 index 0000000..70588c6 --- /dev/null +++ b/apps/api/src/crypto/crypto.service.spec.ts @@ -0,0 +1,71 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { ConfigService } from "@nestjs/config"; +import { CryptoService } from "./crypto.service"; + +function createConfigService(secret?: string): ConfigService { + return { + get: (key: string) => { + if (key === "MOSAIC_SECRET_KEY") { + return secret; + } + return undefined; + }, + } as unknown as ConfigService; +} + +describe("CryptoService", () => { + let service: CryptoService; + + beforeEach(() => { + service = new CryptoService(createConfigService("this-is-a-test-secret-key-with-32+chars")); + }); + + it("encrypt -> decrypt roundtrip", () => { + const plaintext = "my-secret-api-key"; + + const encrypted = service.encrypt(plaintext); + const decrypted = service.decrypt(encrypted); + + expect(encrypted.startsWith("enc:")).toBe(true); + expect(decrypted).toBe(plaintext); + }); + + it("decrypt rejects tampered ciphertext", () => { + const encrypted = service.encrypt("sensitive-token"); + const payload = encrypted.slice(4); + const bytes = Buffer.from(payload, "base64"); + + bytes[bytes.length - 1] = bytes[bytes.length - 1]! ^ 0xff; + + const tampered = `enc:${bytes.toString("base64")}`; + + expect(() => service.decrypt(tampered)).toThrow(); + }); + + it("decrypt rejects non-encrypted string", () => { + expect(() => service.decrypt("plain-text-value")).toThrow(); + }); + + it("isEncrypted detects prefix correctly", () => { + expect(service.isEncrypted("enc:abc")).toBe(true); + expect(service.isEncrypted("ENC:abc")).toBe(false); + expect(service.isEncrypted("plain-text")).toBe(false); + }); + + it("generateToken returns 64-char hex string", () => { + const token = service.generateToken(); + + expect(token).toMatch(/^[0-9a-f]{64}$/); + }); + + it("different plaintexts produce different ciphertexts (random IV)", () => { + const encryptedA = service.encrypt("value-a"); + const encryptedB = service.encrypt("value-b"); + + expect(encryptedA).not.toBe(encryptedB); + }); + + it("missing MOSAIC_SECRET_KEY throws on construction", () => { + expect(() => new CryptoService(createConfigService(undefined))).toThrow(); + }); +}); diff --git a/apps/api/src/crypto/crypto.service.ts b/apps/api/src/crypto/crypto.service.ts new file mode 100644 index 0000000..9da524b --- /dev/null +++ b/apps/api/src/crypto/crypto.service.ts @@ -0,0 +1,82 @@ +import { Injectable } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { createCipheriv, createDecipheriv, hkdfSync, randomBytes } from "crypto"; + +const ALGORITHM = "aes-256-gcm"; +const ENCRYPTED_PREFIX = "enc:"; +const IV_LENGTH = 12; +const AUTH_TAG_LENGTH = 16; +const DERIVED_KEY_LENGTH = 32; +const HKDF_SALT = "mosaic.crypto.v1"; +const HKDF_INFO = "mosaic-db-secret-encryption"; + +@Injectable() +export class CryptoService { + private readonly key: Buffer; + + constructor(private readonly config: ConfigService) { + const secret = this.config.get("MOSAIC_SECRET_KEY"); + + if (!secret) { + throw new Error("MOSAIC_SECRET_KEY environment variable is required"); + } + + if (secret.length < 32) { + throw new Error("MOSAIC_SECRET_KEY must be at least 32 characters"); + } + + this.key = Buffer.from( + hkdfSync( + "sha256", + Buffer.from(secret, "utf8"), + Buffer.from(HKDF_SALT, "utf8"), + Buffer.from(HKDF_INFO, "utf8"), + DERIVED_KEY_LENGTH + ) + ); + } + + encrypt(plaintext: string): string { + const iv = randomBytes(IV_LENGTH); + const cipher = createCipheriv(ALGORITHM, this.key, iv); + const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]); + const authTag = cipher.getAuthTag(); + const payload = Buffer.concat([iv, ciphertext, authTag]).toString("base64"); + + return `${ENCRYPTED_PREFIX}${payload}`; + } + + decrypt(encrypted: string): string { + if (!this.isEncrypted(encrypted)) { + throw new Error("Value is not encrypted"); + } + + const payloadBase64 = encrypted.slice(ENCRYPTED_PREFIX.length); + + try { + const payload = Buffer.from(payloadBase64, "base64"); + if (payload.length < IV_LENGTH + AUTH_TAG_LENGTH) { + throw new Error("Encrypted payload is too short"); + } + + const iv = payload.subarray(0, IV_LENGTH); + const authTag = payload.subarray(payload.length - AUTH_TAG_LENGTH); + const ciphertext = payload.subarray(IV_LENGTH, payload.length - AUTH_TAG_LENGTH); + + const decipher = createDecipheriv(ALGORITHM, this.key, iv); + decipher.setAuthTag(authTag); + + return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8"); + } catch { + throw new Error("Failed to decrypt value"); + } + } + + isEncrypted(value: string): boolean { + return value.startsWith(ENCRYPTED_PREFIX); + } + + generateToken(): string { + return randomBytes(32).toString("hex"); + } +} -- 2.49.1 From 7a46c81897d930112ff5e857e4f3fb8f7a6447d3 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 1 Mar 2026 08:41:45 -0600 Subject: [PATCH 2/2] feat(api): add agent fleet Prisma schema (MS22-P1a) --- .../migration.sql | 109 ++++++++++++++++++ apps/api/prisma/schema.prisma | 78 +++++++++++++ 2 files changed, 187 insertions(+) create mode 100644 apps/api/prisma/migrations/20260301144006_ms22_agent_fleet_schema/migration.sql diff --git a/apps/api/prisma/migrations/20260301144006_ms22_agent_fleet_schema/migration.sql b/apps/api/prisma/migrations/20260301144006_ms22_agent_fleet_schema/migration.sql new file mode 100644 index 0000000..e680137 --- /dev/null +++ b/apps/api/prisma/migrations/20260301144006_ms22_agent_fleet_schema/migration.sql @@ -0,0 +1,109 @@ +-- CreateTable +CREATE TABLE "SystemConfig" ( + "id" TEXT NOT NULL, + "key" TEXT NOT NULL, + "value" TEXT NOT NULL, + "encrypted" BOOLEAN NOT NULL DEFAULT false, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "SystemConfig_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "BreakglassUser" ( + "id" TEXT NOT NULL, + "username" TEXT NOT NULL, + "passwordHash" TEXT NOT NULL, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "BreakglassUser_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "LlmProvider" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "displayName" TEXT NOT NULL, + "type" TEXT NOT NULL, + "baseUrl" TEXT, + "apiKey" TEXT, + "apiType" TEXT NOT NULL DEFAULT 'openai-completions', + "models" JSONB NOT NULL DEFAULT '[]', + "isActive" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "LlmProvider_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "UserContainer" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "containerId" TEXT, + "containerName" TEXT NOT NULL, + "gatewayPort" INTEGER, + "gatewayToken" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'stopped', + "lastActiveAt" TIMESTAMP(3), + "idleTimeoutMin" INTEGER NOT NULL DEFAULT 30, + "config" JSONB NOT NULL DEFAULT '{}', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "UserContainer_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "SystemContainer" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "role" TEXT NOT NULL, + "containerId" TEXT, + "gatewayPort" INTEGER, + "gatewayToken" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'stopped', + "primaryModel" TEXT NOT NULL, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "SystemContainer_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "UserAgentConfig" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "primaryModel" TEXT, + "fallbackModels" JSONB NOT NULL DEFAULT '[]', + "personality" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "UserAgentConfig_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "SystemConfig_key_key" ON "SystemConfig"("key"); + +-- CreateIndex +CREATE UNIQUE INDEX "BreakglassUser_username_key" ON "BreakglassUser"("username"); + +-- CreateIndex +CREATE INDEX "LlmProvider_userId_idx" ON "LlmProvider"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "LlmProvider_userId_name_key" ON "LlmProvider"("userId", "name"); + +-- CreateIndex +CREATE UNIQUE INDEX "UserContainer_userId_key" ON "UserContainer"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "SystemContainer_name_key" ON "SystemContainer"("name"); + +-- CreateIndex +CREATE UNIQUE INDEX "UserAgentConfig_userId_key" ON "UserAgentConfig"("userId"); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 936538b..97312c1 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -1625,3 +1625,81 @@ model ConversationArchive { @@index([startedAt]) @@map("conversation_archives") } + +// ============================================ +// AGENT FLEET MODULE +// ============================================ + +model SystemConfig { + id String @id @default(cuid()) + key String @unique + value String + encrypted Boolean @default(false) + updatedAt DateTime @updatedAt +} + +model BreakglassUser { + id String @id @default(cuid()) + username String @unique + passwordHash String + isActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model LlmProvider { + id String @id @default(cuid()) + userId String + name String + displayName String + type String + baseUrl String? + apiKey String? + apiType String @default("openai-completions") + models Json @default("[]") + isActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([userId, name]) + @@index([userId]) +} + +model UserContainer { + id String @id @default(cuid()) + userId String @unique + containerId String? + containerName String + gatewayPort Int? + gatewayToken String + status String @default("stopped") + lastActiveAt DateTime? + idleTimeoutMin Int @default(30) + config Json @default("{}") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model SystemContainer { + id String @id @default(cuid()) + name String @unique + role String + containerId String? + gatewayPort Int? + gatewayToken String + status String @default("stopped") + primaryModel String + isActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model UserAgentConfig { + id String @id @default(cuid()) + userId String @unique + primaryModel String? + fallbackModels Json @default("[]") + personality String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} -- 2.49.1