feat: security audit fixes

This commit is contained in:
ZOMBIIIIIII
2026-05-13 00:17:32 +03:00
parent e87d178d71
commit 1498ed3431
31 changed files with 2198 additions and 339 deletions

View File

@@ -1,64 +1,155 @@
-- CryptoWallet API — DB schema (idempotent, custodial v5.0)
-- ╔══════════════════════════════════════════════════════════════════╗
-- ║ CryptoWallet API — Production DB schema (idempotent, custodial) ║
-- ║ Применять: psql -h <host> -U postgres_user -d postgres -f ... ║
-- ║ Безопасно прогонять повторно на existing БД. ║
-- ╚══════════════════════════════════════════════════════════════════╝
CREATE TABLE IF NOT EXISTS users (
id VARCHAR(26) PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
last_name VARCHAR(255),
first_name VARCHAR(255),
middle_name VARCHAR(255),
id VARCHAR(26) NOT NULL PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
last_name VARCHAR(128),
first_name VARCHAR(128),
middle_name VARCHAR(128),
birth_date DATE,
-- DEPRECATED: исторически generic crypto address; для ETH используем erc20 ниже.
crypto_wallet VARCHAR(255),
phone VARCHAR(64),
bik VARCHAR(64),
account_number VARCHAR(64),
card_number VARCHAR(64),
inn VARCHAR(64),
kyc_verified BOOLEAN NOT NULL DEFAULT FALSE,
kyc_verified_at TIMESTAMPTZ,
is_deleted BOOLEAN NOT NULL DEFAULT FALSE,
encrypted_vault TEXT, -- legacy
vault_salt VARCHAR(128), -- legacy
encrypted_mnemonic TEXT, -- AES-256-GCM blob (custodial)
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
phone VARCHAR(16),
inn VARCHAR(12),
kyc_verified BOOLEAN NOT NULL DEFAULT FALSE,
kyc_verified_at TIMESTAMP WITH TIME ZONE,
is_deleted BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
passport_data VARCHAR(255),
erc20 VARCHAR(255),
-- EXTENSION (custodial wallet support):
-- AES-256-GCM blob: IV(12) || ciphertext || authTag(16), base64. Master-key в Vault.
encrypted_mnemonic TEXT
);
-- Idempotent ALTERs для existing БД без extension-columns
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'users' AND column_name = 'encrypted_mnemonic'
) THEN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'encrypted_mnemonic') THEN
ALTER TABLE users ADD COLUMN encrypted_mnemonic TEXT;
END IF;
END $$;
-- AES-GCM blob: 12 IV + plaintext + 16 tag.
-- 12-word mnemonic ~ 116 байт = ~156 base64 chars; 24-word ~ 212 байт = ~284 chars.
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'users_encrypted_mnemonic_size'
) THEN
ALTER TABLE users
ADD CONSTRAINT users_encrypted_mnemonic_size
CHECK (encrypted_mnemonic IS NULL OR (char_length(encrypted_mnemonic) BETWEEN 140 AND 512));
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'erc20') THEN
ALTER TABLE users ADD COLUMN erc20 VARCHAR(255);
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'passport_data') THEN
ALTER TABLE users ADD COLUMN passport_data VARCHAR(255);
END IF;
END $$;
-- Sanity check на blob size. Floor 100 (worst-case 12-word всё-3char mnemonic):
-- plaintext 47 bytes + IV(12) + tag(16) = 75 raw → 100 base64
-- typical 12-word: 113 raw → 152 base64; 24-word: 240 raw → 320 base64
-- (Раньше floor 140 отвергал ~4% валидных 12-word mnemonics — fixed.)
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'users_encrypted_mnemonic_size') THEN
ALTER TABLE users DROP CONSTRAINT users_encrypted_mnemonic_size;
END IF;
ALTER TABLE users
ADD CONSTRAINT users_encrypted_mnemonic_size
CHECK (encrypted_mnemonic IS NULL OR (char_length(encrypted_mnemonic) BETWEEN 100 AND 512));
END $$;
-- Case-insensitive email uniqueness (Alice@x.com ≠ alice@x.com → ACCOUNT HIJACKING fix)
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'users_email_lower_unique') THEN
CREATE UNIQUE INDEX users_email_lower_unique ON users (lower(email));
END IF;
END $$;
-- Partial index для active-user queries
CREATE INDEX IF NOT EXISTS idx_users_active ON users(id) WHERE is_deleted = FALSE;
-- erc20 format check (NULL or 0x + 40 hex)
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'users_erc20_format') THEN
ALTER TABLE users
ADD CONSTRAINT users_erc20_format
CHECK (erc20 IS NULL OR erc20 ~ '^0x[0-9a-fA-F]{40}$');
END IF;
END $$;
-- KYC consistency: verified=true requires verified_at NOT NULL
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'users_kyc_consistency') THEN
ALTER TABLE users
ADD CONSTRAINT users_kyc_consistency
CHECK ((kyc_verified = FALSE) OR (kyc_verified = TRUE AND kyc_verified_at IS NOT NULL));
END IF;
END $$;
-- ── WALLETS ─────────────────────────────────────────────────────────
-- ON DELETE RESTRICT: hard-delete user → запрос отвергнут пока есть wallets.
-- Это защита от unrecoverable fund loss при GDPR-wipe или admin удалении.
-- Use is_deleted=true для soft-delete.
CREATE TABLE IF NOT EXISTS wallets (
id VARCHAR(26) PRIMARY KEY,
user_id VARCHAR(26) NOT NULL REFERENCES users(id) ON DELETE CASCADE,
chain VARCHAR(16) NOT NULL,
address VARCHAR(256) NOT NULL,
derivation_path VARCHAR(64) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (user_id, chain)
id VARCHAR(26) NOT NULL PRIMARY KEY,
user_id VARCHAR(26) NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
chain VARCHAR(16) NOT NULL,
address VARCHAR(128) NOT NULL,
derivation_path VARCHAR(64) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (user_id, chain),
CHECK (chain IN ('ETH', 'BSC', 'BTC', 'TRX', 'SOL'))
);
CREATE INDEX IF NOT EXISTS idx_wallets_user_id ON wallets(user_id);
CREATE INDEX IF NOT EXISTS idx_wallets_address ON wallets(address);
-- sessions table removed — JWT-stateless, не используется в коде.
-- Если существует от старой версии — оператор может drop вручную:
-- DROP TABLE IF EXISTS sessions CASCADE;
-- Idempotent FK migration: если raised на старой DB с CASCADE — поменять
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.referential_constraints
WHERE constraint_name LIKE 'wallets_user_id_fkey%' AND delete_rule = 'CASCADE'
) THEN
ALTER TABLE wallets DROP CONSTRAINT IF EXISTS wallets_user_id_fkey;
ALTER TABLE wallets ADD CONSTRAINT wallets_user_id_fkey
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE RESTRICT;
END IF;
END $$;
-- ── AUDIT_LOG (durable sink для критических custodial операций) ─────
-- Pre-mutation INSERT 'pending', post-mutation UPDATE 'completed' с txid.
-- Если INSERT fails — операция НЕ происходит (fail-secure).
CREATE TABLE IF NOT EXISTS audit_log (
id VARCHAR(26) NOT NULL PRIMARY KEY,
user_id VARCHAR(26) NOT NULL,
event VARCHAR(64) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'success', 'failure')),
error_code VARCHAR(64),
ip VARCHAR(64),
trace_id VARCHAR(64),
meta JSONB,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_audit_log_user_id ON audit_log(user_id);
CREATE INDEX IF NOT EXISTS idx_audit_log_event_created ON audit_log(event, created_at DESC);
-- ── IDEMPOTENCY_KEYS (защита от double-spend на retry) ──────────────
-- Client шлёт Idempotency-Key header. Pre-mutation INSERT row, post-mutation UPDATE с response.
-- На retry — возвращаем cached response без второго broadcast.
CREATE TABLE IF NOT EXISTS idempotency_keys (
user_id VARCHAR(26) NOT NULL,
key VARCHAR(128) NOT NULL,
request_hash VARCHAR(64) NOT NULL,
response_status SMALLINT,
response_body TEXT,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, key)
);
CREATE INDEX IF NOT EXISTS idx_idempotency_keys_created ON idempotency_keys(created_at);
-- Retention cleanup (run via cron): DELETE FROM idempotency_keys WHERE created_at < NOW() - INTERVAL '24 hours';