deploy: POST /api/wallets + full swagger

This commit is contained in:
ZOMBIIIIIII
2026-05-03 20:01:58 +03:00
parent 59a7d1d9ca
commit 295c3a9d6d
27 changed files with 1994 additions and 430 deletions

View File

@@ -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' };
}