116 lines
3.0 KiB
TypeScript
116 lines
3.0 KiB
TypeScript
import { createHash, randomBytes, timingSafeEqual } from 'crypto';
|
|
import { SignJWT, jwtVerify } from 'jose';
|
|
import { fetchVaultKV2 } from '../config/vault';
|
|
import { env, getVaultToken } from '../config/env';
|
|
import { logger } from '../lib/logger';
|
|
|
|
export const CSRF_COOKIE_NAME = 'csrf_token';
|
|
|
|
export const CSRF_HEADER_NAME = 'X-CSRF-Token';
|
|
|
|
const TTL_SECONDS = 3600;
|
|
|
|
let signingKeyBytes: Uint8Array | null = null;
|
|
|
|
|
|
export function setCsrfSigningKey(secret: string): void {
|
|
if (secret.length < 32) {
|
|
throw new Error('CSRF secret must be at least 32 characters');
|
|
}
|
|
signingKeyBytes = new Uint8Array(createHash('sha256').update(secret, 'utf8').digest());
|
|
}
|
|
|
|
|
|
export function hasCsrfSigningKey(): boolean {
|
|
return signingKeyBytes !== null;
|
|
}
|
|
|
|
|
|
function getKey(): Uint8Array {
|
|
if (!signingKeyBytes) {
|
|
throw new Error('CSRF signing key not configured');
|
|
}
|
|
return signingKeyBytes;
|
|
}
|
|
|
|
|
|
export async function issueCsrfToken(): Promise<string> {
|
|
const nonce = randomBytes(24).toString('base64url');
|
|
const now = Math.floor(Date.now() / 1000);
|
|
return new SignJWT({ scope: 'csrf', nce: nonce })
|
|
.setProtectedHeader({ alg: 'HS256' })
|
|
.setIssuedAt(now)
|
|
.setNotBefore(now)
|
|
.setExpirationTime(now + TTL_SECONDS)
|
|
.sign(getKey());
|
|
}
|
|
|
|
|
|
export async function verifyCsrfPair(
|
|
cookieToken: string | undefined,
|
|
headerToken: string | undefined,
|
|
): Promise<void> {
|
|
if (!cookieToken || !headerToken) {
|
|
const e = new Error('CSRF token missing') as Error & { status: number };
|
|
e.status = 403;
|
|
throw e;
|
|
}
|
|
const a = Buffer.from(cookieToken);
|
|
const b = Buffer.from(headerToken);
|
|
if (a.length !== b.length || !timingSafeEqual(a, b)) {
|
|
const e = new Error('CSRF token mismatch') as Error & { status: number };
|
|
e.status = 403;
|
|
throw e;
|
|
}
|
|
try {
|
|
const { payload } = await jwtVerify(cookieToken, getKey(), {
|
|
algorithms: ['HS256'],
|
|
clockTolerance: 10,
|
|
});
|
|
if (payload.scope !== 'csrf') {
|
|
throw new Error('invalid');
|
|
}
|
|
} catch {
|
|
const e = new Error('CSRF token invalid') as Error & { status: number };
|
|
e.status = 403;
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
|
|
export function getCsrfCookieMaxAgeMs(): number {
|
|
return TTL_SECONDS * 1000;
|
|
}
|
|
|
|
|
|
export async function loadCsrfSecretFromVault(): Promise<boolean> {
|
|
const token = getVaultToken();
|
|
if (!token || !env.vault.addr) {
|
|
return false;
|
|
}
|
|
const data = await fetchVaultKV2(env.vault.addr, token, env.vault.mount, env.vault.csrfSecretPath);
|
|
const key = data?.CSRF_SECRET_KEY;
|
|
if (!key || key.length < 32) {
|
|
logger.warn('Vault CSRF secret missing or too short');
|
|
return false;
|
|
}
|
|
setCsrfSigningKey(key);
|
|
logger.info('CSRF signing key loaded from Vault');
|
|
return true;
|
|
}
|
|
|
|
|
|
export function finalizeCsrfConfigFromEnv(): void {
|
|
if (hasCsrfSigningKey()) {
|
|
return;
|
|
}
|
|
const k = process.env.CSRF_SECRET_KEY;
|
|
if (k && k.length >= 32) {
|
|
setCsrfSigningKey(k);
|
|
logger.info('CSRF signing key loaded from environment');
|
|
return;
|
|
}
|
|
logger.error('CSRF_SECRET_KEY is required (Vault path or env, min 32 characters)');
|
|
process.exit(1);
|
|
}
|