125 lines
4.8 KiB
TypeScript
125 lines
4.8 KiB
TypeScript
import app from './app';
|
||
import { env, initEnv } from './config/env';
|
||
import { refreshAllKeys, startKeyRotation, stopKeyRotation } from './services/key-rotation.service';
|
||
import { isCryptoReady, decryptMnemonic, encryptMnemonic } from './services/crypto.service';
|
||
import { db } from './config/database';
|
||
import { pingRedis, closeRedis } from './config/redis';
|
||
import { logger } from './lib/logger';
|
||
|
||
// Global error handlers — иначе unhandled errors идут в stderr без sanitization (leak secrets)
|
||
process.on('unhandledRejection', (reason: any) => {
|
||
logger.error(`Unhandled rejection: ${reason?.stack || reason?.message || reason}`);
|
||
});
|
||
process.on('uncaughtException', (err: Error) => {
|
||
logger.error(`Uncaught exception: ${err.stack || err.message}`);
|
||
// Process state could be corrupt — exit cleanly
|
||
process.exit(1);
|
||
});
|
||
|
||
async function main() {
|
||
logger.info(`Wallet service instance started with id ${logger.instanceId}`);
|
||
|
||
await initEnv();
|
||
const refreshResult = await refreshAllKeys();
|
||
if (!refreshResult.ok) {
|
||
logger.error(`Initial Vault refresh failed: ${refreshResult.reason}. Refusing to start.`);
|
||
process.exit(1);
|
||
}
|
||
|
||
// Custodial: без master-key сервис не может расшифровать ни одну мнемонику — fail fast.
|
||
if (!isCryptoReady()) {
|
||
logger.error('Crypto master key not loaded — refusing to start (custodial wallets require it)');
|
||
process.exit(1);
|
||
}
|
||
|
||
// Integrity self-test: если в БД уже есть encrypted_mnemonic, master-key должен их декриптить.
|
||
// Иначе мы стартовали с НЕПРАВИЛЬНЫМ ключом (например после потери Vault dev-mode state) —
|
||
// и любой /send или /sign будет тихо валиться с GCM auth error. Лучше упасть сразу.
|
||
await runCryptoIntegritySelfTest();
|
||
|
||
// KeyDB / Redis ping — idempotency critical для money flow; fail-fast если недоступен.
|
||
try {
|
||
await pingRedis();
|
||
logger.info('KeyDB/Redis self-test: PASSED');
|
||
} catch (err: any) {
|
||
logger.error(`KeyDB/Redis ping failed: ${err.message}. Refusing to start (idempotency unavailable).`);
|
||
process.exit(1);
|
||
}
|
||
|
||
startKeyRotation();
|
||
|
||
const server = app.listen(env.port, () => {
|
||
logger.info(`Server running on port ${env.port}`);
|
||
});
|
||
|
||
const shutdown = (signal: string) => {
|
||
logger.info(`${signal} received, shutting down gracefully`);
|
||
stopKeyRotation();
|
||
void closeRedis();
|
||
server.close(() => process.exit(0));
|
||
// Force exit if shutdown takes too long
|
||
setTimeout(() => process.exit(1), 10_000).unref();
|
||
};
|
||
|
||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||
}
|
||
|
||
async function runCryptoIntegritySelfTest(): Promise<void> {
|
||
// H4 — Two checks:
|
||
// (a) AES round-trip ALWAYS (даже на пустой DB): encrypt PROBE → decrypt → equal.
|
||
// Защита от corrupted master-key buffer (wrong size, zeros, etc.)
|
||
// (b) Если в БД есть encrypted_mnemonic — пытаемся декриптить (sample 3 rows).
|
||
// Защита от master-key drift (Vault rotation accidental).
|
||
|
||
// (a) AES round-trip
|
||
try {
|
||
const probe = 'self-test-PROBE-string-with-some-length-for-realism';
|
||
const ct = encryptMnemonic(probe);
|
||
const pt = decryptMnemonic(ct);
|
||
if (pt !== probe) {
|
||
logger.error('Crypto self-test: AES round-trip MISMATCH — master-key buffer corrupt');
|
||
process.exit(1);
|
||
}
|
||
} catch (err: any) {
|
||
logger.error(`Crypto self-test: AES round-trip failed: ${err?.message}`);
|
||
process.exit(1);
|
||
}
|
||
|
||
// (b) Existing-blob compatibility
|
||
try {
|
||
const rows = await db('users')
|
||
.whereNotNull('encrypted_mnemonic')
|
||
.select('encrypted_mnemonic')
|
||
.limit(3);
|
||
|
||
if (rows.length === 0) {
|
||
logger.info('Crypto self-test: PASSED (round-trip OK, no existing blobs to check)');
|
||
return;
|
||
}
|
||
|
||
for (const row of rows) {
|
||
try {
|
||
decryptMnemonic(row.encrypted_mnemonic);
|
||
} catch (err: any) {
|
||
logger.error(
|
||
'Crypto self-test: FAILED — master-key DOES NOT match stored encrypted_mnemonic. ' +
|
||
'Likely cause: Vault state was lost (dev-mode in-memory) and a different key was seeded. ' +
|
||
'Recovery: restore seed-cache.env from .seed-backup/ OR wipe DB with `docker compose down -v`. ' +
|
||
`Underlying: ${err?.message}`,
|
||
);
|
||
process.exit(1);
|
||
}
|
||
}
|
||
logger.info(`Crypto self-test: PASSED (round-trip OK + ${rows.length} existing blob(s) decryptable)`);
|
||
} catch (err: any) {
|
||
logger.error(`Crypto self-test: could not query DB: ${err?.message}`);
|
||
process.exit(1);
|
||
}
|
||
}
|
||
|
||
main().catch((err) => {
|
||
logger.error(`Failed to start: ${err.message}`);
|
||
process.exit(1);
|
||
});
|