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,6 @@
import * as jose from 'jose';
import { env } from '../config/env';
import { fetchVaultKV2 } from '../config/vault';
import { logger } from '../lib/logger';
export interface AccessTokenPayload {
@@ -19,21 +20,41 @@ export interface AuthContext {
token: AccessTokenPayload;
}
const keyMap = new Map<string, Awaited<ReturnType<typeof jose.importSPKI>>>();
type KeyType = Awaited<ReturnType<typeof jose.importSPKI>>;
export async function loadJwtKeysFromVault(
// Whitelist надёжных асимметричных алгоритмов. Никогда не разрешаем 'none'/HS*
// (HS — симметричные, могли бы быть подставлены через algorithm confusion).
const ALLOWED_ALGORITHMS = new Set(['RS256', 'RS384', 'RS512', 'ES256', 'ES384', 'ES512', 'EdDSA', 'PS256', 'PS384', 'PS512']);
if (!ALLOWED_ALGORITHMS.has(env.jwt.algorithm)) {
throw new Error(`JWT_ALGORITHM "${env.jwt.algorithm}" not allowed. Use one of: ${[...ALLOWED_ALGORITHMS].join(', ')}`);
}
// Live key store — атомарно подменяется через swapKeyMap()
let keyMap: Map<string, KeyType> = new Map();
export function swapKeyMap(newMap: Map<string, KeyType>): void {
keyMap = newMap;
}
export function getKeyMapSize(): number {
return keyMap.size;
}
/**
* Pre-fetch JWT public keys from Vault, не мутируя глобальный keyMap.
* Возвращает новую Map для атомарного swap'а.
*/
export async function fetchJwtKeysFromVault(
vaultAddr: string,
vaultToken: string,
mount: string,
kidPath: string,
kidsPrefix: string,
): Promise<void> {
const { fetchVaultKV2 } = await import('../config/vault');
): Promise<Map<string, KeyType>> {
const kidData = await fetchVaultKV2(vaultAddr, vaultToken, mount, kidPath);
if (!kidData) {
logger.warn('Failed to read JWT kid config from Vault');
return;
throw new Error('Failed to read JWT kid config from Vault');
}
const kids: string[] = [];
@@ -41,10 +62,11 @@ export async function loadJwtKeysFromVault(
if (kidData.previous && kidData.previous !== kidData.active) kids.push(kidData.previous);
if (kids.length === 0) {
logger.warn('No active/previous kids found in Vault');
return;
throw new Error('No active/previous kids found in Vault');
}
const next = new Map<string, KeyType>();
for (const kid of kids) {
const kidSecret = await fetchVaultKV2(vaultAddr, vaultToken, mount, `${kidsPrefix}/${kid}`);
if (!kidSecret?.public_key) {
@@ -52,16 +74,15 @@ export async function loadJwtKeysFromVault(
continue;
}
try {
const key = await jose.importSPKI(kidSecret.public_key, env.jwt.algorithm);
keyMap.set(kid, key);
logger.info(`Loaded JWT public key for kid=${kid}`);
} catch (err: any) {
logger.error(`Failed to import public key for kid=${kid}: ${err.message}`);
}
const key = await jose.importSPKI(kidSecret.public_key, env.jwt.algorithm);
next.set(kid, key);
}
logger.info(`JWT key store loaded: ${keyMap.size} key(s)`);
if (next.size === 0) {
throw new Error('No public keys could be loaded from Vault');
}
return next;
}
export async function verifyAccessToken(token: string): Promise<AuthContext> {
@@ -71,17 +92,17 @@ export async function verifyAccessToken(token: string): Promise<AuthContext> {
const header = jose.decodeProtectedHeader(token);
const kid = header.kid;
if (!kid) {
throw Object.assign(new Error('Missing kid in token header'), { status: 401 });
if (!kid || typeof kid !== 'string' || !/^[A-Za-z0-9_-]{1,64}$/.test(kid)) {
throw Object.assign(new Error('Missing or invalid kid in token header'), { status: 401 });
}
const key = keyMap.get(kid);
if (!key) {
logger.warn(`Unknown kid=${kid}`);
throw Object.assign(new Error('Unknown token kid'), { status: 401 });
}
if (header.alg !== env.jwt.algorithm) {
// Двойная защита от algorithm confusion: проверяем точное совпадение
if (header.alg !== env.jwt.algorithm || !ALLOWED_ALGORITHMS.has(String(header.alg))) {
throw Object.assign(new Error('Invalid token algorithm'), { status: 401 });
}