deploy: POST /api/wallets + full swagger
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import crypto from 'crypto';
|
||||
import { logger } from '../lib/logger';
|
||||
import { fetchVaultKV2 } from '../config/vault';
|
||||
|
||||
/**
|
||||
* CSRF token validation compatible with Python's `itsdangerous`
|
||||
@@ -18,57 +18,70 @@ import { logger } from '../lib/logger';
|
||||
|
||||
const ITSDANGEROUS_EPOCH = 1293840000; // 2011-01-01 UTC in unix time
|
||||
|
||||
let csrfSecret: string | null = null;
|
||||
let csrfSalt = 'itsdangerous.Signer';
|
||||
let csrfDigest: 'sha1' | 'sha256' | 'sha512' = 'sha512';
|
||||
let csrfMaxAgeSec = 60 * 60 * 24 * 7; // 7 days
|
||||
export interface CsrfConfig {
|
||||
secret: string;
|
||||
salt: string;
|
||||
digest: 'sha1' | 'sha256' | 'sha512';
|
||||
maxAgeSec: number;
|
||||
}
|
||||
|
||||
export async function loadCsrfSecret(
|
||||
// Live config — атомарно подменяется через swapCsrfConfig()
|
||||
let current: CsrfConfig | null = null;
|
||||
|
||||
export function swapCsrfConfig(cfg: CsrfConfig | null): void {
|
||||
current = cfg;
|
||||
}
|
||||
|
||||
export function isCsrfConfigured(): boolean {
|
||||
return current !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-fetch CSRF config из Vault — НЕ мутирует глобал, возвращает новый объект.
|
||||
*/
|
||||
export async function fetchCsrfConfig(
|
||||
addr: string,
|
||||
token: string,
|
||||
mount: string,
|
||||
path: string,
|
||||
): Promise<void> {
|
||||
const { fetchVaultKV2 } = await import('../config/vault');
|
||||
|
||||
): Promise<CsrfConfig> {
|
||||
const secrets = await fetchVaultKV2(addr, token, mount, path);
|
||||
if (!secrets) {
|
||||
logger.warn('Failed to load CSRF secret from Vault');
|
||||
return;
|
||||
throw new Error('Failed to load CSRF secret from Vault');
|
||||
}
|
||||
|
||||
const secret = secrets.secret_key || secrets.SECRET_KEY || secrets.key || secrets.csrf_secret;
|
||||
if (!secret) {
|
||||
logger.warn('CSRF secret not found in Vault payload (expected key: secret_key)');
|
||||
return;
|
||||
const secret =
|
||||
secrets.secret_key || secrets.SECRET_KEY || secrets.key || secrets.csrf_secret;
|
||||
if (!secret || typeof secret !== 'string' || secret.length < 32) {
|
||||
throw new Error('CSRF secret invalid: must be string >= 32 chars');
|
||||
}
|
||||
|
||||
csrfSecret = secret;
|
||||
if (secrets.salt) csrfSalt = secrets.salt;
|
||||
const salt = secrets.salt || 'itsdangerous.Signer';
|
||||
if (typeof salt !== 'string' || salt.length < 8) {
|
||||
throw new Error('CSRF salt invalid: must be string >= 8 chars');
|
||||
}
|
||||
|
||||
let digest: 'sha1' | 'sha256' | 'sha512' = 'sha512';
|
||||
if (secrets.digest === 'sha1' || secrets.digest === 'sha256' || secrets.digest === 'sha512') {
|
||||
csrfDigest = secrets.digest as 'sha1' | 'sha256' | 'sha512';
|
||||
digest = secrets.digest;
|
||||
}
|
||||
|
||||
let maxAgeSec = 60 * 60 * 24 * 7; // 7 days
|
||||
if (secrets.max_age_sec) {
|
||||
const n = parseInt(secrets.max_age_sec);
|
||||
if (!Number.isNaN(n) && n > 0) csrfMaxAgeSec = n;
|
||||
if (!Number.isNaN(n) && n > 0) maxAgeSec = n;
|
||||
}
|
||||
|
||||
logger.info(`CSRF secret loaded (digest=${csrfDigest}, salt="${csrfSalt}", max_age=${csrfMaxAgeSec}s)`);
|
||||
}
|
||||
|
||||
export function isCsrfConfigured(): boolean {
|
||||
return csrfSecret !== null;
|
||||
return { secret, salt, digest, maxAgeSec };
|
||||
}
|
||||
|
||||
function b64urlDecode(s: string): Buffer {
|
||||
// itsdangerous strips padding
|
||||
const pad = s.length % 4 === 0 ? 0 : 4 - (s.length % 4);
|
||||
const padded = s + '='.repeat(pad);
|
||||
return Buffer.from(padded.replace(/-/g, '+').replace(/_/g, '/'), 'base64');
|
||||
}
|
||||
|
||||
function deriveKey(secret: string, salt: string, digest: string): Buffer {
|
||||
// itsdangerous `Signer.derive_key`: HMAC(secret, salt + "signer")
|
||||
return crypto.createHmac(digest, secret).update(salt + 'signer').digest();
|
||||
}
|
||||
|
||||
@@ -85,13 +98,13 @@ export interface CsrfVerifyResult {
|
||||
}
|
||||
|
||||
export function verifyCsrfToken(token: string): CsrfVerifyResult {
|
||||
if (!csrfSecret) return { valid: false, reason: 'CSRF secret not loaded' };
|
||||
if (!current) return { valid: false, reason: 'CSRF secret not loaded' };
|
||||
if (!token || typeof token !== 'string') return { valid: false, reason: 'Empty token' };
|
||||
|
||||
const lastDot = token.lastIndexOf('.');
|
||||
if (lastDot < 0) return { valid: false, reason: 'Malformed token (no signature)' };
|
||||
|
||||
const payloadTs = token.slice(0, lastDot); // "<payload>.<timestamp>"
|
||||
const payloadTs = token.slice(0, lastDot);
|
||||
const sigStr = token.slice(lastDot + 1);
|
||||
|
||||
const prevDot = payloadTs.lastIndexOf('.');
|
||||
@@ -99,8 +112,8 @@ export function verifyCsrfToken(token: string): CsrfVerifyResult {
|
||||
|
||||
const tsStr = payloadTs.slice(prevDot + 1);
|
||||
|
||||
const derived = deriveKey(csrfSecret, csrfSalt, csrfDigest);
|
||||
const expectedSig = crypto.createHmac(csrfDigest, derived).update(payloadTs).digest();
|
||||
const derived = deriveKey(current.secret, current.salt, current.digest);
|
||||
const expectedSig = crypto.createHmac(current.digest, derived).update(payloadTs).digest();
|
||||
|
||||
let actualSig: Buffer;
|
||||
try {
|
||||
@@ -116,12 +129,11 @@ export function verifyCsrfToken(token: string): CsrfVerifyResult {
|
||||
return { valid: false, reason: 'Signature mismatch' };
|
||||
}
|
||||
|
||||
// Timestamp check
|
||||
try {
|
||||
const issuedAt = decodeTimestamp(tsStr);
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (issuedAt > now + 60) return { valid: false, reason: 'Token from the future' };
|
||||
if (now - issuedAt > csrfMaxAgeSec) return { valid: false, reason: 'Token expired' };
|
||||
if (now - issuedAt > current.maxAgeSec) return { valid: false, reason: 'Token expired' };
|
||||
} catch {
|
||||
return { valid: false, reason: 'Invalid timestamp' };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user