Merge pull request #223 from groombook/fix/gro-453-random-salt-crypto
fix(db): use random per-encryption salt in crypto.ts (GRO-453)
This commit was merged in pull request #223.
This commit is contained in:
@@ -24,11 +24,11 @@ describe("encryptSecret / decryptSecret", () => {
|
|||||||
expect(decrypted).toBe(plaintext);
|
expect(decrypted).toBe(plaintext);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("produces output in iv:ciphertext:authTag format", () => {
|
it("produces output in salt:iv:ciphertext:authTag format", () => {
|
||||||
const encrypted = encryptSecret("test");
|
const encrypted = encryptSecret("test");
|
||||||
const parts = encrypted.split(":");
|
const parts = encrypted.split(":");
|
||||||
|
|
||||||
expect(parts).toHaveLength(3);
|
expect(parts).toHaveLength(4);
|
||||||
// Each part should be valid base64
|
// Each part should be valid base64
|
||||||
parts.forEach((part) => {
|
parts.forEach((part) => {
|
||||||
expect(() => Buffer.from(part, "base64")).not.toThrow();
|
expect(() => Buffer.from(part, "base64")).not.toThrow();
|
||||||
@@ -61,12 +61,11 @@ describe("encryptSecret / decryptSecret", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("throws when decrypting invalid format (wrong number of parts)", () => {
|
it("throws when decrypting invalid format (wrong number of parts)", () => {
|
||||||
const encrypted = encryptSecret("test");
|
// 2 parts is invalid for both legacy (3) and new (4) format
|
||||||
// Replace the last ":authTag" part by matching colon + non-colon chars at the end
|
const invalid = "not-enough-parts";
|
||||||
const invalid = encrypted.replace(/:[^:]+$/, "");
|
|
||||||
|
|
||||||
expect(() => decryptSecret(invalid)).toThrow(
|
expect(() => decryptSecret(invalid)).toThrow(
|
||||||
"Invalid encrypted value format: expected iv:ciphertext:authTag"
|
"Invalid encrypted value format: expected salt:iv:ciphertext:authTag or iv:ciphertext:authTag"
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+38
-20
@@ -6,19 +6,22 @@ const AUTH_TAG_LENGTH = 16; // 128-bit auth tag
|
|||||||
const SALT_LENGTH = 16;
|
const SALT_LENGTH = 16;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Derives a 32-byte key from BETTER_AUTH_SECRET using scrypt.
|
* Legacy fixed salt used for backward-compatible decryption of pre-salt format values.
|
||||||
* BETTER_AUTH_SECRET is used as the password, with a fixed salt derived from the package name.
|
* Do not use for new encryptions.
|
||||||
*/
|
*/
|
||||||
function deriveKey(secret: string): Buffer {
|
const LEGACY_PACKAGE_SALT = scryptSync("groombook-auth-provider-config", "", SALT_LENGTH);
|
||||||
// Use a fixed salt derived from the package name for key derivation
|
|
||||||
// This gives us stable key derivation without storing an extra salt
|
/**
|
||||||
const packageSalt = scryptSync("groombook-auth-provider-config", "", SALT_LENGTH);
|
* Derives a 32-byte key from BETTER_AUTH_SECRET using scrypt.
|
||||||
return scryptSync(secret, packageSalt, 32);
|
* Uses the provided salt (random per encryption for new values).
|
||||||
|
*/
|
||||||
|
function deriveKey(secret: string, salt: Buffer): Buffer {
|
||||||
|
return scryptSync(secret, salt, 32);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Encrypts a plaintext string using AES-256-GCM.
|
* Encrypts a plaintext string using AES-256-GCM.
|
||||||
* Returns a base64-encoded string in the format: iv:ciphertext:authTag
|
* Returns a base64-encoded string in the format: salt:iv:ciphertext:authTag
|
||||||
*/
|
*/
|
||||||
export function encryptSecret(plaintext: string): string {
|
export function encryptSecret(plaintext: string): string {
|
||||||
const secret = process.env.BETTER_AUTH_SECRET;
|
const secret = process.env.BETTER_AUTH_SECRET;
|
||||||
@@ -26,7 +29,8 @@ export function encryptSecret(plaintext: string): string {
|
|||||||
throw new Error("BETTER_AUTH_SECRET environment variable is required");
|
throw new Error("BETTER_AUTH_SECRET environment variable is required");
|
||||||
}
|
}
|
||||||
|
|
||||||
const key = deriveKey(secret);
|
const salt = randomBytes(SALT_LENGTH);
|
||||||
|
const key = deriveKey(secret, salt);
|
||||||
const iv = randomBytes(IV_LENGTH);
|
const iv = randomBytes(IV_LENGTH);
|
||||||
|
|
||||||
const cipher = createCipheriv(ALGORITHM, key, iv, {
|
const cipher = createCipheriv(ALGORITHM, key, iv, {
|
||||||
@@ -38,8 +42,9 @@ export function encryptSecret(plaintext: string): string {
|
|||||||
|
|
||||||
const authTag = cipher.getAuthTag();
|
const authTag = cipher.getAuthTag();
|
||||||
|
|
||||||
// Format: base64(iv):base64(ciphertext):base64(authTag)
|
// Format: base64(salt):base64(iv):base64(ciphertext):base64(authTag)
|
||||||
return [
|
return [
|
||||||
|
salt.toString("base64"),
|
||||||
iv.toString("base64"),
|
iv.toString("base64"),
|
||||||
ciphertext.toString("base64"),
|
ciphertext.toString("base64"),
|
||||||
authTag.toString("base64"),
|
authTag.toString("base64"),
|
||||||
@@ -48,7 +53,8 @@ export function encryptSecret(plaintext: string): string {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Decrypts a ciphertext string produced by encryptSecret.
|
* Decrypts a ciphertext string produced by encryptSecret.
|
||||||
* Expects the format: iv:ciphertext:authTag (all base64-encoded)
|
* Supports both new format (salt:iv:ciphertext:authTag) and legacy format (iv:ciphertext:authTag).
|
||||||
|
* All values are base64-encoded.
|
||||||
*/
|
*/
|
||||||
export function decryptSecret(encrypted: string): string {
|
export function decryptSecret(encrypted: string): string {
|
||||||
const secret = process.env.BETTER_AUTH_SECRET;
|
const secret = process.env.BETTER_AUTH_SECRET;
|
||||||
@@ -57,18 +63,30 @@ export function decryptSecret(encrypted: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const parts = encrypted.split(":");
|
const parts = encrypted.split(":");
|
||||||
if (parts.length !== 3) {
|
if (parts.length !== 3 && parts.length !== 4) {
|
||||||
throw new Error("Invalid encrypted value format: expected iv:ciphertext:authTag");
|
throw new Error("Invalid encrypted value format: expected salt:iv:ciphertext:authTag or iv:ciphertext:authTag");
|
||||||
}
|
}
|
||||||
|
|
||||||
const ivBase64 = parts[0]!;
|
let salt: Buffer;
|
||||||
const ciphertextBase64 = parts[1]!;
|
let iv: Buffer;
|
||||||
const authTagBase64 = parts[2]!;
|
let ciphertext: Buffer;
|
||||||
const iv = Buffer.from(ivBase64, "base64");
|
let authTag: Buffer;
|
||||||
const ciphertext = Buffer.from(ciphertextBase64, "base64");
|
|
||||||
const authTag = Buffer.from(authTagBase64, "base64");
|
|
||||||
|
|
||||||
const key = deriveKey(secret);
|
if (parts.length === 4) {
|
||||||
|
// New format: salt:iv:ciphertext:authTag
|
||||||
|
salt = Buffer.from(parts[0]!, "base64");
|
||||||
|
iv = Buffer.from(parts[1]!, "base64");
|
||||||
|
ciphertext = Buffer.from(parts[2]!, "base64");
|
||||||
|
authTag = Buffer.from(parts[3]!, "base64");
|
||||||
|
} else {
|
||||||
|
// Legacy format: iv:ciphertext:authTag — use fixed package salt
|
||||||
|
salt = LEGACY_PACKAGE_SALT;
|
||||||
|
iv = Buffer.from(parts[0]!, "base64");
|
||||||
|
ciphertext = Buffer.from(parts[1]!, "base64");
|
||||||
|
authTag = Buffer.from(parts[2]!, "base64");
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = deriveKey(secret, salt);
|
||||||
|
|
||||||
const decipher = createDecipheriv(ALGORITHM, key, iv, {
|
const decipher = createDecipheriv(ALGORITHM, key, iv, {
|
||||||
authTagLength: AUTH_TAG_LENGTH,
|
authTagLength: AUTH_TAG_LENGTH,
|
||||||
|
|||||||
Reference in New Issue
Block a user