From d60165572aafd194ec6e7236a72b42bb10aee84b Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sat, 7 Mar 2026 16:55:51 -0600 Subject: [PATCH] fix(orchestrator): encrypt OpenClaw provider tokens at rest --- .../agent-providers/agent-providers.module.ts | 3 +- .../agent-providers.service.spec.ts | 90 ++++++++++++++++++- .../agent-providers.service.ts | 39 +++++++- .../src/security/encryption.service.ts | 23 ++++- 4 files changed, 148 insertions(+), 7 deletions(-) diff --git a/apps/orchestrator/src/api/agent-providers/agent-providers.module.ts b/apps/orchestrator/src/api/agent-providers/agent-providers.module.ts index 28f25c6..2460ed6 100644 --- a/apps/orchestrator/src/api/agent-providers/agent-providers.module.ts +++ b/apps/orchestrator/src/api/agent-providers/agent-providers.module.ts @@ -1,12 +1,13 @@ import { Module } from "@nestjs/common"; import { PrismaModule } from "../../prisma/prisma.module"; import { OrchestratorApiKeyGuard } from "../../common/guards/api-key.guard"; +import { EncryptionService } from "../../security/encryption.service"; import { AgentProvidersController } from "./agent-providers.controller"; import { AgentProvidersService } from "./agent-providers.service"; @Module({ imports: [PrismaModule], controllers: [AgentProvidersController], - providers: [OrchestratorApiKeyGuard, AgentProvidersService], + providers: [OrchestratorApiKeyGuard, EncryptionService, AgentProvidersService], }) export class AgentProvidersModule {} diff --git a/apps/orchestrator/src/api/agent-providers/agent-providers.service.spec.ts b/apps/orchestrator/src/api/agent-providers/agent-providers.service.spec.ts index f161b13..4bb8e8a 100644 --- a/apps/orchestrator/src/api/agent-providers/agent-providers.service.spec.ts +++ b/apps/orchestrator/src/api/agent-providers/agent-providers.service.spec.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { NotFoundException } from "@nestjs/common"; +import { EncryptionService } from "../../security/encryption.service"; import { AgentProvidersService } from "./agent-providers.service"; import { PrismaService } from "../../prisma/prisma.service"; @@ -14,6 +15,9 @@ describe("AgentProvidersService", () => { delete: ReturnType; }; }; + let encryptionService: { + encryptIfNeeded: ReturnType; + }; beforeEach(() => { prisma = { @@ -26,7 +30,14 @@ describe("AgentProvidersService", () => { }, }; - service = new AgentProvidersService(prisma as unknown as PrismaService); + encryptionService = { + encryptIfNeeded: vi.fn((value: string) => `enc:${value}`), + }; + + service = new AgentProvidersService( + prisma as unknown as PrismaService, + encryptionService as unknown as EncryptionService + ); }); it("lists all provider configs", async () => { @@ -111,6 +122,42 @@ describe("AgentProvidersService", () => { credentials: {}, }, }); + expect(encryptionService.encryptIfNeeded).not.toHaveBeenCalled(); + expect(result).toEqual(created); + }); + + it("encrypts openclaw token credentials when creating provider config", async () => { + const created = { + id: "cfg-openclaw", + workspaceId: "8bcd7eda-a122-4d6c-adfd-b152f6f75369", + name: "OpenClaw", + provider: "openclaw", + gatewayUrl: "https://openclaw.example.com", + credentials: { apiToken: "enc:top-secret" }, + isActive: true, + createdAt: new Date("2026-03-07T18:00:00.000Z"), + updatedAt: new Date("2026-03-07T18:00:00.000Z"), + }; + prisma.agentProviderConfig.create.mockResolvedValue(created); + + const result = await service.create({ + workspaceId: "8bcd7eda-a122-4d6c-adfd-b152f6f75369", + name: "OpenClaw", + provider: "openclaw", + gatewayUrl: "https://openclaw.example.com", + credentials: { apiToken: "top-secret" }, + }); + + expect(encryptionService.encryptIfNeeded).toHaveBeenCalledWith("top-secret"); + expect(prisma.agentProviderConfig.create).toHaveBeenCalledWith({ + data: { + workspaceId: "8bcd7eda-a122-4d6c-adfd-b152f6f75369", + name: "OpenClaw", + provider: "openclaw", + gatewayUrl: "https://openclaw.example.com", + credentials: { apiToken: "enc:top-secret" }, + }, + }); expect(result).toEqual(created); }); @@ -156,6 +203,47 @@ describe("AgentProvidersService", () => { isActive: false, }, }); + expect(encryptionService.encryptIfNeeded).not.toHaveBeenCalled(); + expect(result).toEqual(updated); + }); + + it("encrypts openclaw token credentials when updating provider config", async () => { + prisma.agentProviderConfig.findUnique.mockResolvedValue({ + id: "cfg-openclaw", + workspaceId: "8bcd7eda-a122-4d6c-adfd-b152f6f75369", + name: "OpenClaw", + provider: "openclaw", + gatewayUrl: "https://openclaw.example.com", + credentials: { apiToken: "enc:existing" }, + isActive: true, + createdAt: new Date("2026-03-07T18:00:00.000Z"), + updatedAt: new Date("2026-03-07T18:00:00.000Z"), + }); + + const updated = { + id: "cfg-openclaw", + workspaceId: "8bcd7eda-a122-4d6c-adfd-b152f6f75369", + name: "OpenClaw", + provider: "openclaw", + gatewayUrl: "https://openclaw.example.com", + credentials: { apiToken: "enc:rotated-token" }, + isActive: true, + createdAt: new Date("2026-03-07T18:00:00.000Z"), + updatedAt: new Date("2026-03-07T19:00:00.000Z"), + }; + prisma.agentProviderConfig.update.mockResolvedValue(updated); + + const result = await service.update("cfg-openclaw", { + credentials: { apiToken: "rotated-token" }, + }); + + expect(encryptionService.encryptIfNeeded).toHaveBeenCalledWith("rotated-token"); + expect(prisma.agentProviderConfig.update).toHaveBeenCalledWith({ + where: { id: "cfg-openclaw" }, + data: { + credentials: { apiToken: "enc:rotated-token" }, + }, + }); expect(result).toEqual(updated); }); diff --git a/apps/orchestrator/src/api/agent-providers/agent-providers.service.ts b/apps/orchestrator/src/api/agent-providers/agent-providers.service.ts index 7b52c88..14bb797 100644 --- a/apps/orchestrator/src/api/agent-providers/agent-providers.service.ts +++ b/apps/orchestrator/src/api/agent-providers/agent-providers.service.ts @@ -1,12 +1,19 @@ import { Injectable, NotFoundException } from "@nestjs/common"; import type { AgentProviderConfig, Prisma } from "@prisma/client"; +import { EncryptionService } from "../../security/encryption.service"; import { PrismaService } from "../../prisma/prisma.service"; import { CreateAgentProviderDto } from "./dto/create-agent-provider.dto"; import { UpdateAgentProviderDto } from "./dto/update-agent-provider.dto"; +const OPENCLAW_PROVIDER_TYPE = "openclaw"; +const OPENCLAW_TOKEN_KEYS = ["apiToken", "token", "bearerToken"] as const; + @Injectable() export class AgentProvidersService { - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly encryptionService: EncryptionService + ) {} async list(): Promise { return this.prisma.agentProviderConfig.findMany({ @@ -27,20 +34,23 @@ export class AgentProvidersService { } async create(dto: CreateAgentProviderDto): Promise { + const credentials = this.sanitizeCredentials(dto.provider, dto.credentials ?? {}); + return this.prisma.agentProviderConfig.create({ data: { workspaceId: dto.workspaceId, name: dto.name, provider: dto.provider, gatewayUrl: dto.gatewayUrl, - credentials: this.toJsonValue(dto.credentials ?? {}), + credentials: this.toJsonValue(credentials), ...(dto.isActive !== undefined ? { isActive: dto.isActive } : {}), }, }); } async update(id: string, dto: UpdateAgentProviderDto): Promise { - await this.getById(id); + const existingConfig = await this.getById(id); + const provider = dto.provider ?? existingConfig.provider; const data: Prisma.AgentProviderConfigUpdateInput = { ...(dto.workspaceId !== undefined ? { workspaceId: dto.workspaceId } : {}), @@ -48,7 +58,9 @@ export class AgentProvidersService { ...(dto.provider !== undefined ? { provider: dto.provider } : {}), ...(dto.gatewayUrl !== undefined ? { gatewayUrl: dto.gatewayUrl } : {}), ...(dto.isActive !== undefined ? { isActive: dto.isActive } : {}), - ...(dto.credentials !== undefined ? { credentials: this.toJsonValue(dto.credentials) } : {}), + ...(dto.credentials !== undefined + ? { credentials: this.toJsonValue(this.sanitizeCredentials(provider, dto.credentials)) } + : {}), }; return this.prisma.agentProviderConfig.update({ @@ -65,6 +77,25 @@ export class AgentProvidersService { }); } + private sanitizeCredentials( + provider: string, + credentials: Record + ): Record { + if (provider.toLowerCase() !== OPENCLAW_PROVIDER_TYPE) { + return credentials; + } + + const nextCredentials: Record = { ...credentials }; + for (const key of OPENCLAW_TOKEN_KEYS) { + const tokenValue = nextCredentials[key]; + if (typeof tokenValue === "string" && tokenValue.length > 0) { + nextCredentials[key] = this.encryptionService.encryptIfNeeded(tokenValue); + } + } + + return nextCredentials; + } + private toJsonValue(value: Record): Prisma.InputJsonValue { return value as Prisma.InputJsonValue; } diff --git a/apps/orchestrator/src/security/encryption.service.ts b/apps/orchestrator/src/security/encryption.service.ts index bf4c576..5f5a406 100644 --- a/apps/orchestrator/src/security/encryption.service.ts +++ b/apps/orchestrator/src/security/encryption.service.ts @@ -1,6 +1,6 @@ import { Injectable } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; -import { createDecipheriv, hkdfSync } from "node:crypto"; +import { createCipheriv, createDecipheriv, hkdfSync, randomBytes } from "node:crypto"; const ALGORITHM = "aes-256-gcm"; const ENCRYPTED_PREFIX = "enc:"; @@ -16,6 +16,27 @@ export class EncryptionService { constructor(private readonly configService: ConfigService) {} + encryptIfNeeded(value: string): string { + if (this.isEncrypted(value)) { + return value; + } + + return this.encrypt(value); + } + + encrypt(plaintext: string): string { + try { + const iv = randomBytes(IV_LENGTH); + const cipher = createCipheriv(ALGORITHM, this.getOrCreateKey(), iv); + const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]); + const authTag = cipher.getAuthTag(); + const payload = Buffer.concat([iv, ciphertext, authTag]); + return `${ENCRYPTED_PREFIX}${payload.toString("base64")}`; + } catch { + throw new Error("Failed to encrypt value"); + } + } + decryptIfNeeded(value: string): string { if (!this.isEncrypted(value)) { return value;