107 lines
3.0 KiB
TypeScript
107 lines
3.0 KiB
TypeScript
import { Injectable } from "@nestjs/common";
|
|
import { ConfigService } from "@nestjs/config";
|
|
import { createCipheriv, createDecipheriv, hkdfSync, randomBytes } from "node: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 EncryptionService {
|
|
private key: Buffer | null = null;
|
|
|
|
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;
|
|
}
|
|
|
|
return this.decrypt(value);
|
|
}
|
|
|
|
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.getOrCreateKey(), 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);
|
|
}
|
|
|
|
private getOrCreateKey(): Buffer {
|
|
if (this.key !== null) {
|
|
return this.key;
|
|
}
|
|
|
|
const secret = this.configService.get<string>("MOSAIC_SECRET_KEY");
|
|
if (!secret) {
|
|
throw new Error(
|
|
"orchestrator: MOSAIC_SECRET_KEY is required. Set it in your config or via MOSAIC_SECRET_KEY."
|
|
);
|
|
}
|
|
|
|
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
|
|
)
|
|
);
|
|
|
|
return this.key;
|
|
}
|
|
}
|