From 3688f89c371df6b35f3efd38e95d3fe268bfded3 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 1 Mar 2026 08:40:40 -0600 Subject: [PATCH] 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"); + } +}