176 lines
5.9 KiB
TypeScript
176 lines
5.9 KiB
TypeScript
import {
|
|
createCipheriv,
|
|
createDecipheriv,
|
|
createHmac,
|
|
randomBytes,
|
|
timingSafeEqual,
|
|
} from "node:crypto";
|
|
|
|
function required(name: string, min = 32): Buffer {
|
|
const value = process.env[name];
|
|
if (!value) throw new Error(`${name} is required`);
|
|
const decoded = name.endsWith("_ENCRYPTION_KEY")
|
|
? Buffer.from(value, "base64")
|
|
: Buffer.from(value);
|
|
if (decoded.length < min)
|
|
throw new Error(`${name} must be at least ${min} bytes`);
|
|
return decoded;
|
|
}
|
|
const REQUIRED_KEYS = [
|
|
"PHONE_ENCRYPTION_KEY",
|
|
"PHONE_HMAC_KEY",
|
|
"VERIFICATION_CODE_HMAC_KEY",
|
|
"REFRESH_TOKEN_HMAC_KEY",
|
|
"LEASE_TOKEN_HMAC_KEY",
|
|
"LEASE_TOKEN_ENCRYPTION_KEY",
|
|
"JWT_SECRET",
|
|
] as const;
|
|
type AuthSecrets = Record<(typeof REQUIRED_KEYS)[number], Buffer>;
|
|
let secrets: AuthSecrets | undefined;
|
|
let configuredWebOrigin: string | undefined;
|
|
|
|
function parseWebOrigin(): string {
|
|
const raw = process.env.WEB_ORIGIN;
|
|
if (!raw) throw new Error("WEB_ORIGIN is required");
|
|
let url: URL;
|
|
try {
|
|
url = new URL(raw);
|
|
} catch {
|
|
throw new Error("WEB_ORIGIN must be an absolute HTTP(S) origin");
|
|
}
|
|
if (
|
|
!["http:", "https:"].includes(url.protocol) ||
|
|
url.username ||
|
|
url.password ||
|
|
url.pathname !== "/" ||
|
|
url.search ||
|
|
url.hash ||
|
|
url.origin !== raw
|
|
)
|
|
throw new Error(
|
|
"WEB_ORIGIN must be one absolute HTTP(S) origin without credentials or path",
|
|
);
|
|
return url.origin;
|
|
}
|
|
|
|
export function validateAuthEnvironment(): void {
|
|
const loaded = Object.fromEntries(
|
|
REQUIRED_KEYS.map((name) => [name, required(name)]),
|
|
) as unknown as AuthSecrets;
|
|
for (const name of [
|
|
"PHONE_ENCRYPTION_KEY",
|
|
"LEASE_TOKEN_ENCRYPTION_KEY",
|
|
] as const) {
|
|
if (loaded[name].length !== 32)
|
|
throw new Error(`${name} must decode to exactly 32 bytes`);
|
|
}
|
|
const fingerprints = REQUIRED_KEYS.map((name) =>
|
|
loaded[name].toString("hex"),
|
|
);
|
|
if (new Set(fingerprints).size !== fingerprints.length)
|
|
throw new Error("Auth secrets must be independent");
|
|
const origin = parseWebOrigin();
|
|
if (process.env.DEMO_SMS_CODE_ENABLED === "true" && !demoEnvironmentAllowed())
|
|
throw new Error(
|
|
"DEMO_SMS_CODE_ENABLED is forbidden outside test or local development",
|
|
);
|
|
secrets = loaded;
|
|
configuredWebOrigin = origin;
|
|
}
|
|
export function resetAuthEnvironmentForTests(): void {
|
|
if (process.env.NODE_ENV !== "test")
|
|
throw new Error("Auth environment reset is test-only");
|
|
secrets = undefined;
|
|
configuredWebOrigin = undefined;
|
|
}
|
|
function authSecrets(): AuthSecrets {
|
|
if (!secrets) validateAuthEnvironment();
|
|
return secrets!;
|
|
}
|
|
export const jwtSecret = (): Buffer => authSecrets().JWT_SECRET;
|
|
export const webOrigin = (): string => {
|
|
if (!configuredWebOrigin) validateAuthEnvironment();
|
|
return configuredWebOrigin!;
|
|
};
|
|
const demoEnvironmentAllowed = (): boolean =>
|
|
process.env.NODE_ENV === "test" ||
|
|
(process.env.NODE_ENV === "development" && process.env.APP_ENV === "local");
|
|
export const demoSmsCodeEnabled = (): boolean =>
|
|
process.env.DEMO_SMS_CODE_ENABLED === "true" && demoEnvironmentAllowed();
|
|
export const phoneHmac = (phone: string): string =>
|
|
createHmac("sha256", authSecrets().PHONE_HMAC_KEY)
|
|
.update(phone)
|
|
.digest("hex");
|
|
export const codeHmac = (phoneDigest: string, code: string): string =>
|
|
createHmac("sha256", authSecrets().VERIFICATION_CODE_HMAC_KEY)
|
|
.update(`${phoneDigest}:${code}`)
|
|
.digest("hex");
|
|
export const refreshHmac = (token: string): string =>
|
|
createHmac("sha256", authSecrets().REFRESH_TOKEN_HMAC_KEY)
|
|
.update(token)
|
|
.digest("hex");
|
|
export const leaseHmac = (token: string): string =>
|
|
createHmac("sha256", authSecrets().LEASE_TOKEN_HMAC_KEY)
|
|
.update(token)
|
|
.digest("hex");
|
|
export function encryptLeaseToken(
|
|
token: string,
|
|
leaseId: string,
|
|
): Uint8Array<ArrayBuffer> {
|
|
const nonce = randomBytes(12);
|
|
const cipher = createCipheriv(
|
|
"aes-256-gcm",
|
|
authSecrets().LEASE_TOKEN_ENCRYPTION_KEY,
|
|
nonce,
|
|
);
|
|
cipher.setAAD(Buffer.from(leaseId));
|
|
const body = Buffer.concat([cipher.update(token, "utf8"), cipher.final()]);
|
|
const encrypted = Buffer.concat([nonce, cipher.getAuthTag(), body]);
|
|
return new Uint8Array(encrypted).slice();
|
|
}
|
|
export function decryptLeaseToken(value: Uint8Array, leaseId: string): string {
|
|
// nonce (12) + authentication tag (16) + canonical 32-byte token (43 base64url chars)
|
|
if (value.length !== 71) throw new Error("Invalid lease token ciphertext");
|
|
const decipher = createDecipheriv(
|
|
"aes-256-gcm",
|
|
authSecrets().LEASE_TOKEN_ENCRYPTION_KEY,
|
|
Buffer.from(value.subarray(0, 12)),
|
|
);
|
|
decipher.setAAD(Buffer.from(leaseId));
|
|
decipher.setAuthTag(Buffer.from(value.subarray(12, 28)));
|
|
const token = Buffer.concat([
|
|
decipher.update(Buffer.from(value.subarray(28))),
|
|
decipher.final(),
|
|
]).toString();
|
|
if (!/^[A-Za-z0-9_-]{43}$/.test(token))
|
|
throw new Error("Invalid lease token ciphertext");
|
|
return token;
|
|
}
|
|
export const safeEqual = (a: string, b: string): boolean => {
|
|
const x = Buffer.from(a);
|
|
const y = Buffer.from(b);
|
|
return x.length === y.length && timingSafeEqual(x, y);
|
|
};
|
|
export function encryptPhone(phone: string): Buffer {
|
|
const key = authSecrets().PHONE_ENCRYPTION_KEY;
|
|
if (key.length !== 32)
|
|
throw new Error("PHONE_ENCRYPTION_KEY must decode to exactly 32 bytes");
|
|
const nonce = randomBytes(12);
|
|
const cipher = createCipheriv("aes-256-gcm", key, nonce);
|
|
const body = Buffer.concat([cipher.update(phone, "utf8"), cipher.final()]);
|
|
return Buffer.concat([nonce, cipher.getAuthTag(), body]);
|
|
}
|
|
export function decryptPhone(value: Buffer): string {
|
|
const key = authSecrets().PHONE_ENCRYPTION_KEY;
|
|
const decipher = createDecipheriv("aes-256-gcm", key, value.subarray(0, 12));
|
|
decipher.setAuthTag(value.subarray(12, 28));
|
|
return Buffer.concat([
|
|
decipher.update(value.subarray(28)),
|
|
decipher.final(),
|
|
]).toString();
|
|
}
|
|
export const envInt = (name: string, fallback: number): number => {
|
|
const n = Number(process.env[name] ?? fallback);
|
|
return Number.isInteger(n) && n > 0 ? n : fallback;
|
|
};
|