Merge pull request 'feat(api): CryptoService for API key encryption (MS22-P1b)' (#606) from feat/ms22-p1b-crypto into main
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
Reviewed-on: #606
This commit was merged in pull request #606.
This commit is contained in:
@@ -39,6 +39,7 @@ import { JobStepsModule } from "./job-steps/job-steps.module";
|
|||||||
import { CoordinatorIntegrationModule } from "./coordinator-integration/coordinator-integration.module";
|
import { CoordinatorIntegrationModule } from "./coordinator-integration/coordinator-integration.module";
|
||||||
import { FederationModule } from "./federation/federation.module";
|
import { FederationModule } from "./federation/federation.module";
|
||||||
import { CredentialsModule } from "./credentials/credentials.module";
|
import { CredentialsModule } from "./credentials/credentials.module";
|
||||||
|
import { CryptoModule } from "./crypto/crypto.module";
|
||||||
import { MosaicTelemetryModule } from "./mosaic-telemetry";
|
import { MosaicTelemetryModule } from "./mosaic-telemetry";
|
||||||
import { SpeechModule } from "./speech/speech.module";
|
import { SpeechModule } from "./speech/speech.module";
|
||||||
import { DashboardModule } from "./dashboard/dashboard.module";
|
import { DashboardModule } from "./dashboard/dashboard.module";
|
||||||
@@ -111,6 +112,7 @@ import { RlsContextInterceptor } from "./common/interceptors/rls-context.interce
|
|||||||
CoordinatorIntegrationModule,
|
CoordinatorIntegrationModule,
|
||||||
FederationModule,
|
FederationModule,
|
||||||
CredentialsModule,
|
CredentialsModule,
|
||||||
|
CryptoModule,
|
||||||
MosaicTelemetryModule,
|
MosaicTelemetryModule,
|
||||||
SpeechModule,
|
SpeechModule,
|
||||||
DashboardModule,
|
DashboardModule,
|
||||||
|
|||||||
10
apps/api/src/crypto/crypto.module.ts
Normal file
10
apps/api/src/crypto/crypto.module.ts
Normal file
@@ -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 {}
|
||||||
71
apps/api/src/crypto/crypto.service.spec.ts
Normal file
71
apps/api/src/crypto/crypto.service.spec.ts
Normal file
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
82
apps/api/src/crypto/crypto.service.ts
Normal file
82
apps/api/src/crypto/crypto.service.ts
Normal file
@@ -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<string>("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");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user