server/models/decorators/Encrypted.ts
Rama main · réplica de lectura del repositorio original.
import crypto from "node:crypto";
import { isEmpty, isNil } from "es-toolkit/compat";
import { getAttributes } from "sequelize-typescript";
import { toError, errToString } from "@shared/utils/error";
import env from "@server/env";
import Logger from "@server/logging/Logger";
const algorithm = "aes-256-cbc";
const ivLength = 16;
/**
* Encrypt a string value for storage in a database column. The returned buffer
* is the initialization vector followed by the ciphertext (`iv || ciphertext`)
* using AES-256-CBC, matching the on-disk format of previously encrypted data.
*
* @param value The plaintext value to encrypt.
* @returns A buffer containing the IV concatenated with the ciphertext.
*/
export function encrypt(value: string): Buffer {
const iv = crypto.randomBytes(ivLength);
const cipher = crypto.createCipheriv(
algorithm,
Buffer.from(env.SECRET_KEY, "hex"),
iv
);
const ciphertext = Buffer.concat([
cipher.update(value, "utf-8"),
cipher.final(),
]);
return Buffer.concat([iv, ciphertext]);
}
/**
* Decrypt a buffer produced by `encrypt` back into its original string value.
*
* @param value A buffer containing the IV concatenated with the ciphertext.
* @returns The decrypted plaintext value.
*/
export function decrypt(value: Buffer): string {
const iv = value.subarray(0, ivLength);
const ciphertext = value.subarray(ivLength);
const decipher = crypto.createDecipheriv(
algorithm,
Buffer.from(env.SECRET_KEY, "hex"),
iv
);
return (
decipher.update(ciphertext, undefined, "utf-8") + decipher.final("utf-8")
);
}
/**
* A decorator that transparently encrypts and decrypts the value of a database
* column. Must be accompanied by a @Column(DataType.BLOB) annotation.
*/
export default function Encrypted(target: object, propertyKey: string) {
// Ensure that the Encrypted decorator is the first decorator applied to the property, we can check
// this by looking at the attributes of the target and checking if the propertyKey is already defined.
if (getAttributes(target)[propertyKey]) {
Logger.fatal(
`The Encrypted decorator must be the first decorator applied to the property ${propertyKey} in ${target.constructor.name}`,
new Error()
);
}
return {
configurable: true,
enumerable: true,
get() {
const attributeOptions = getAttributes(target);
const defaultValue = attributeOptions[propertyKey].allowNull ? null : "";
const previous = this.getDataValue(propertyKey);
if (!previous) {
return defaultValue;
}
try {
const value = JSON.parse(decrypt(previous));
// An empty object can be the result of a `null` column value, in which
// case we return the default value instead.
return isEmpty(value) && typeof value === "object"
? defaultValue
: value;
} catch (err) {
const message = errToString(err);
if (message.includes("Unexpected end of JSON input")) {
return defaultValue;
}
if (message.includes("bad decrypt")) {
Logger.fatal(
`Failed to decrypt database column (${propertyKey}). The SECRET_KEY environment variable may have changed since installation.`,
toError(err)
);
}
throw err;
}
},
set(value: string | null) {
try {
if (isNil(value)) {
this.setDataValue(propertyKey, value);
} else {
this.setDataValue(propertyKey, encrypt(JSON.stringify(value)));
}
} catch (err) {
if (
err instanceof Error &&
err.message.includes("Invalid key length")
) {
Logger.fatal(
`Failed to encrypt database column (${propertyKey}). The SECRET_KEY environment variable is not the correct length.`,
err
);
}
throw err;
}
},
// oxlint-disable-next-line @typescript-eslint/no-explicit-any -- TS rejects PropertyDescriptor return for legacy decorator; descriptor is consumed by Sequelize at runtime.
} as any;
}