homeio
PublicForked from doctor-io/homeio
totp-crypto.test.ts
import { randomBytes } from "node:crypto";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { mockEnv } = vi.hoisted(() => ({
mockEnv: {
AUTH_SESSION_SECRET: "test-session-secret-32-chars-long-aaaa",
AUTH_TOTP_ENCRYPTION_KEY: undefined as string | undefined,
},
}));
vi.mock("@/lib/server/env", () => ({
serverEnv: mockEnv,
}));
import {
decryptSecret,
encryptSecret,
} from "@/lib/server/modules/auth/totp-crypto";
const DEFAULT_SECRET = "test-session-secret-32-chars-long-aaaa";
beforeEach(() => {
mockEnv.AUTH_SESSION_SECRET = DEFAULT_SECRET;
mockEnv.AUTH_TOTP_ENCRYPTION_KEY = undefined;
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("totp-crypto", () => {
describe("encryptSecret / decryptSecret", () => {
it("round-trips a plaintext through the packed format", () => {
const plaintext = "JBSWY3DPEHPK3PXP";
const packed = encryptSecret(plaintext);
expect(packed).toMatch(/^[A-Za-z0-9+/=]+\.[A-Za-z0-9+/=]+\.[A-Za-z0-9+/=]+$/);
expect(decryptSecret(packed)).toBe(plaintext);
});
it("round-trips multi-byte UTF-8", () => {
const plaintext = "café — résumé — 🔐 backup\ncodes";
expect(decryptSecret(encryptSecret(plaintext))).toBe(plaintext);
});
it("produces a different ciphertext each call (random IV)", () => {
const plaintext = "JBSWY3DPEHPK3PXP";
const a = encryptSecret(plaintext);
const b = encryptSecret(plaintext);
expect(a).not.toBe(b);
expect(decryptSecret(a)).toBe(plaintext);
expect(decryptSecret(b)).toBe(plaintext);
});
it("rejects a packed string with the wrong number of parts", () => {
expect(() => decryptSecret("only.two")).toThrowError(
/Invalid encrypted secret format/,
);
expect(() => decryptSecret("a.b.c.d")).toThrowError(
/Invalid encrypted secret format/,
);
});
it("rejects an IV of the wrong length", () => {
const packed = encryptSecret("hello");
const [, cipherPart, tagPart] = packed.split(".");
// 8-byte IV — too short for AES-GCM-96.
const shortIv = Buffer.alloc(8, 0).toString("base64");
expect(() =>
decryptSecret(`${shortIv}.${cipherPart}.${tagPart}`),
).toThrowError(/Invalid encrypted secret format/);
});
it("rejects a tag of the wrong length", () => {
const packed = encryptSecret("hello");
const [ivPart, cipherPart] = packed.split(".");
const shortTag = Buffer.alloc(8, 0).toString("base64");
expect(() =>
decryptSecret(`${ivPart}.${cipherPart}.${shortTag}`),
).toThrowError(/Invalid encrypted secret format/);
});
it("throws when the ciphertext is tampered with", () => {
const packed = encryptSecret("hello");
const [ivPart, cipherPart, tagPart] = packed.split(".");
const tampered = Buffer.from(cipherPart, "base64");
tampered[0] = tampered[0] ^ 0xff;
expect(() =>
decryptSecret(`${ivPart}.${tampered.toString("base64")}.${tagPart}`),
).toThrow();
});
it("throws when the tag is tampered with", () => {
const packed = encryptSecret("hello");
const [ivPart, cipherPart, tagPart] = packed.split(".");
const tampered = Buffer.from(tagPart, "base64");
tampered[0] = tampered[0] ^ 0xff;
expect(() =>
decryptSecret(`${ivPart}.${cipherPart}.${tampered.toString("base64")}`),
).toThrow();
});
});
describe("key resolution", () => {
it("derives a key from AUTH_SESSION_SECRET when no explicit key is set", () => {
const packed = encryptSecret("secret");
expect(decryptSecret(packed)).toBe("secret");
});
it("cannot decrypt a ciphertext produced with a different AUTH_SESSION_SECRET", () => {
mockEnv.AUTH_SESSION_SECRET = "first-secret-32-chars-long-aaaaaaaaa";
const packed = encryptSecret("locked");
mockEnv.AUTH_SESSION_SECRET = "second-secret-32-chars-long-bbbbbbbb";
expect(() => decryptSecret(packed)).toThrow();
});
it("uses the explicit AUTH_TOTP_ENCRYPTION_KEY when provided", () => {
const explicitKey = randomBytes(32).toString("base64");
mockEnv.AUTH_TOTP_ENCRYPTION_KEY = explicitKey;
const packed = encryptSecret("explicit");
expect(decryptSecret(packed)).toBe("explicit");
});
it("produces a different ciphertext when the explicit key changes", () => {
const keyA = randomBytes(32).toString("base64");
const keyB = randomBytes(32).toString("base64");
mockEnv.AUTH_TOTP_ENCRYPTION_KEY = keyA;
const packedWithA = encryptSecret("rotate-me");
mockEnv.AUTH_TOTP_ENCRYPTION_KEY = keyB;
expect(() => decryptSecret(packedWithA)).toThrow();
});
it("rejects an explicit key that does not decode to 32 bytes", () => {
mockEnv.AUTH_TOTP_ENCRYPTION_KEY = Buffer.alloc(16, 0).toString("base64");
expect(() => encryptSecret("oops")).toThrowError(
/AUTH_TOTP_ENCRYPTION_KEY must decode to 32 bytes/,
);
});
it("ignores a whitespace-only AUTH_TOTP_ENCRYPTION_KEY and falls back to HKDF", () => {
mockEnv.AUTH_TOTP_ENCRYPTION_KEY = " ";
const packed = encryptSecret("fallback");
expect(decryptSecret(packed)).toBe("fallback");
});
});
});