feat: implement enterprise RBAC, profile identity, system management, and audit stabilization

This commit is contained in:
Austin A
2026-04-17 23:36:07 +01:00
parent 5def26e0df
commit 6279347e4b
28 changed files with 4521 additions and 326 deletions

105
backend/src/lib/security.ts Normal file
View File

@@ -0,0 +1,105 @@
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;
}