106 lines
3.4 KiB
TypeScript
106 lines
3.4 KiB
TypeScript
import crypto from "crypto";
|
|
import { env } from "../config/env";
|
|
|
|
const PASSWORD_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@#$%^&*";
|
|
|
|
type EncryptedEnvelope = {
|
|
__enc: "v1";
|
|
iv: string;
|
|
tag: string;
|
|
data: string;
|
|
};
|
|
|
|
function getEncryptionKey() {
|
|
const seed = env.SETTINGS_ENCRYPTION_KEY ?? env.JWT_SECRET;
|
|
return crypto.createHash("sha256").update(seed).digest();
|
|
}
|
|
|
|
function normalizeSecret(value: string) {
|
|
return crypto.createHash("sha256").update(value).digest("hex");
|
|
}
|
|
|
|
export function generateSecurePassword(length = 20) {
|
|
const bytes = crypto.randomBytes(length * 2);
|
|
let output = "";
|
|
for (let i = 0; i < bytes.length && output.length < length; i += 1) {
|
|
output += PASSWORD_ALPHABET[bytes[i] % PASSWORD_ALPHABET.length];
|
|
}
|
|
return output;
|
|
}
|
|
|
|
export function hashToken(token: string) {
|
|
return normalizeSecret(token);
|
|
}
|
|
|
|
export function timingSafeEqualHash(candidate: string, storedHash: string) {
|
|
const candidateHash = Buffer.from(normalizeSecret(candidate), "utf8");
|
|
const knownHash = Buffer.from(storedHash, "utf8");
|
|
if (candidateHash.length !== knownHash.length) return false;
|
|
return crypto.timingSafeEqual(candidateHash, knownHash);
|
|
}
|
|
|
|
export function generateRecoveryCodes(count = 8) {
|
|
const codes: string[] = [];
|
|
for (let i = 0; i < count; i += 1) {
|
|
const raw = crypto.randomBytes(5).toString("hex").toUpperCase();
|
|
codes.push(`${raw.slice(0, 5)}-${raw.slice(5, 10)}`);
|
|
}
|
|
return codes;
|
|
}
|
|
|
|
export function hashRecoveryCodes(codes: string[]) {
|
|
return codes.map((code) => normalizeSecret(code.trim().toUpperCase()));
|
|
}
|
|
|
|
export function consumeRecoveryCode(input: string, hashes: string[]) {
|
|
const normalized = normalizeSecret(input.trim().toUpperCase());
|
|
const matchIndex = hashes.findIndex((hash) => hash === normalized);
|
|
if (matchIndex < 0) {
|
|
return { matched: false, remainingHashes: hashes };
|
|
}
|
|
const remainingHashes = [...hashes.slice(0, matchIndex), ...hashes.slice(matchIndex + 1)];
|
|
return { matched: true, remainingHashes };
|
|
}
|
|
|
|
function isEncryptedEnvelope(value: unknown): value is EncryptedEnvelope {
|
|
return (
|
|
typeof value === "object" &&
|
|
value !== null &&
|
|
(value as Record<string, unknown>).__enc === "v1" &&
|
|
typeof (value as Record<string, unknown>).iv === "string" &&
|
|
typeof (value as Record<string, unknown>).tag === "string" &&
|
|
typeof (value as Record<string, unknown>).data === "string"
|
|
);
|
|
}
|
|
|
|
export function encryptJson(value: unknown): EncryptedEnvelope {
|
|
const key = getEncryptionKey();
|
|
const iv = crypto.randomBytes(12);
|
|
const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
|
|
const payload = Buffer.from(JSON.stringify(value), "utf8");
|
|
const encrypted = Buffer.concat([cipher.update(payload), cipher.final()]);
|
|
const tag = cipher.getAuthTag();
|
|
|
|
return {
|
|
__enc: "v1",
|
|
iv: iv.toString("base64"),
|
|
tag: tag.toString("base64"),
|
|
data: encrypted.toString("base64")
|
|
};
|
|
}
|
|
|
|
export function decryptJson<T = unknown>(value: unknown): T {
|
|
if (!isEncryptedEnvelope(value)) {
|
|
return value as T;
|
|
}
|
|
|
|
const key = getEncryptionKey();
|
|
const iv = Buffer.from(value.iv, "base64");
|
|
const tag = Buffer.from(value.tag, "base64");
|
|
const encrypted = Buffer.from(value.data, "base64");
|
|
const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv);
|
|
decipher.setAuthTag(tag);
|
|
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]).toString("utf8");
|
|
return JSON.parse(decrypted) as T;
|
|
}
|