Merge branch 'main' into fix/gro-478-auto-link-staff-user
This commit is contained in:
@@ -24,11 +24,11 @@ describe("encryptSecret / decryptSecret", () => {
|
||||
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 parts = encrypted.split(":");
|
||||
|
||||
expect(parts).toHaveLength(3);
|
||||
expect(parts).toHaveLength(4);
|
||||
// Each part should be valid base64
|
||||
parts.forEach((part) => {
|
||||
expect(() => Buffer.from(part, "base64")).not.toThrow();
|
||||
@@ -62,11 +62,12 @@ describe("encryptSecret / decryptSecret", () => {
|
||||
|
||||
it("throws when decrypting invalid format (wrong number of parts)", () => {
|
||||
const encrypted = encryptSecret("test");
|
||||
// Replace the last ":authTag" part by matching colon + non-colon chars at the end
|
||||
const invalid = encrypted.replace(/:[^:]+$/, "");
|
||||
// Replace the last two parts with a single part to create a 2-part string
|
||||
// This can't be parsed as either legacy (3 parts) or new (4 parts) format
|
||||
const invalid = encrypted.replace(/:[^:]+$/, "").replace(/:[^:]+$/, "");
|
||||
|
||||
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"
|
||||
);
|
||||
});
|
||||
|
||||
@@ -93,4 +94,4 @@ describe("encryptSecret / decryptSecret", () => {
|
||||
|
||||
expect(decrypted).toBe(plaintext);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -167,7 +167,6 @@ api.route("/impersonation", impersonationRouter);
|
||||
api.route("/admin/settings", settingsRouter);
|
||||
api.route("/admin/auth-provider", authProviderRouter);
|
||||
api.route("/admin/seed", adminSeedRouter);
|
||||
api.route("/admin/auth-provider", authProviderRouter);
|
||||
api.route("/search", searchRouter);
|
||||
|
||||
const port = Number(process.env.PORT ?? 3000);
|
||||
|
||||
@@ -40,6 +40,9 @@ export default defineConfig({
|
||||
},
|
||||
workbox: {
|
||||
globPatterns: ["**/*.{js,css,html,ico,png,svg,woff2}"],
|
||||
navigateFallbackDenylist: [
|
||||
/^\/api\/auth\/oauth2\/callback\//,
|
||||
],
|
||||
runtimeCaching: [
|
||||
{
|
||||
urlPattern: /^http.*\/api\/.*/i,
|
||||
|
||||
+33
-21
@@ -7,18 +7,15 @@ const SALT_LENGTH = 16;
|
||||
|
||||
/**
|
||||
* Derives a 32-byte key from BETTER_AUTH_SECRET using scrypt.
|
||||
* BETTER_AUTH_SECRET is used as the password, with a fixed salt derived from the package name.
|
||||
* A unique random salt is generated per encryptSecret() call and prepended to the output.
|
||||
*/
|
||||
function deriveKey(secret: string): Buffer {
|
||||
// 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);
|
||||
return scryptSync(secret, packageSalt, 32);
|
||||
function deriveKey(secret: string, salt: Buffer): Buffer {
|
||||
return scryptSync(secret, salt, 32);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
const secret = process.env.BETTER_AUTH_SECRET;
|
||||
@@ -26,7 +23,8 @@ export function encryptSecret(plaintext: string): string {
|
||||
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 cipher = createCipheriv(ALGORITHM, key, iv, {
|
||||
@@ -38,8 +36,9 @@ export function encryptSecret(plaintext: string): string {
|
||||
|
||||
const authTag = cipher.getAuthTag();
|
||||
|
||||
// Format: base64(iv):base64(ciphertext):base64(authTag)
|
||||
// Format: base64(salt):base64(iv):base64(ciphertext):base64(authTag)
|
||||
return [
|
||||
salt.toString("base64"),
|
||||
iv.toString("base64"),
|
||||
ciphertext.toString("base64"),
|
||||
authTag.toString("base64"),
|
||||
@@ -48,7 +47,7 @@ export function encryptSecret(plaintext: string): string {
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
export function decryptSecret(encrypted: string): string {
|
||||
const secret = process.env.BETTER_AUTH_SECRET;
|
||||
@@ -57,18 +56,31 @@ export function decryptSecret(encrypted: string): string {
|
||||
}
|
||||
|
||||
const parts = encrypted.split(":");
|
||||
if (parts.length !== 3) {
|
||||
throw new Error("Invalid encrypted value format: expected iv:ciphertext:authTag");
|
||||
|
||||
let salt: Buffer;
|
||||
let iv: Buffer;
|
||||
let ciphertext: Buffer;
|
||||
let authTag: Buffer;
|
||||
|
||||
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 if (parts.length === 3) {
|
||||
// Legacy format: iv:ciphertext:authTag — use fixed package salt
|
||||
salt = scryptSync("groombook-auth-provider-config", "", SALT_LENGTH);
|
||||
iv = Buffer.from(parts[0]!, "base64");
|
||||
ciphertext = Buffer.from(parts[1]!, "base64");
|
||||
authTag = Buffer.from(parts[2]!, "base64");
|
||||
} else {
|
||||
throw new Error(
|
||||
"Invalid encrypted value format: expected salt:iv:ciphertext:authTag or iv:ciphertext:authTag"
|
||||
);
|
||||
}
|
||||
|
||||
const ivBase64 = parts[0]!;
|
||||
const ciphertextBase64 = parts[1]!;
|
||||
const authTagBase64 = parts[2]!;
|
||||
const iv = Buffer.from(ivBase64, "base64");
|
||||
const ciphertext = Buffer.from(ciphertextBase64, "base64");
|
||||
const authTag = Buffer.from(authTagBase64, "base64");
|
||||
|
||||
const key = deriveKey(secret);
|
||||
const key = deriveKey(secret, salt);
|
||||
|
||||
const decipher = createDecipheriv(ALGORITHM, key, iv, {
|
||||
authTagLength: AUTH_TAG_LENGTH,
|
||||
@@ -79,4 +91,4 @@ export function decryptSecret(encrypted: string): string {
|
||||
plaintext = Buffer.concat([plaintext, decipher.final()]);
|
||||
|
||||
return plaintext.toString("utf8");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user