From f3383c3cf8130c2911559340f4f7c3006efe371c Mon Sep 17 00:00:00 2001 From: Hamza Date: Thu, 16 Jul 2026 19:24:27 +0300 Subject: [PATCH] feat(security): field encryption AES-256-GCM + random IV (at-rest) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - common/crypto/crypto.util.ts: encrypt/decrypt (GCM, random 96-bit IV), format v1:base64(iv|tag|ct) - EncryptedTransformer applied to users.name + drivers.vehicle_plate (auto, no service change) - blindIndex (HMAC) helper for future searchable-field encryption (phone) - tolerant decrypt for legacy plaintext → no migration needed (varchar holds base64) - ENCRYPTION_KEY env + boot warning if unset; docs/16-encryption.md - fixes Siro's documented CBC+fixed-IV flaw Co-Authored-By: Claude Opus 4.8 --- backend/.env.example | 5 ++ backend/src/common/crypto/crypto.util.ts | 63 +++++++++++++++++++ backend/src/main.ts | 7 +++ .../modules/drivers/entities/driver.entity.ts | 4 +- .../src/modules/users/entities/user.entity.ts | 4 +- docs/16-encryption.md | 33 ++++++++++ 6 files changed, 114 insertions(+), 2 deletions(-) create mode 100644 backend/src/common/crypto/crypto.util.ts create mode 100644 docs/16-encryption.md diff --git a/backend/.env.example b/backend/.env.example index 562b68c..05141fa 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -24,6 +24,11 @@ QUEUE_PREFIX=tripz_ # ---- Auth ---- JWT_SECRET=change_me_jwt_secret + +# ---- تشفير الحقول الحساسة at-rest (AES-256-GCM) ---- +# 32 بايت بصيغة hex (64 محرف). ولّده: openssl rand -hex 32 +# إلزامي للإنتاج — بدونه يُستخدم مفتاح تطوير غير آمن +ENCRYPTION_KEY= JWT_EXPIRES=15m JWT_REFRESH_EXPIRES=30d diff --git a/backend/src/common/crypto/crypto.util.ts b/backend/src/common/crypto/crypto.util.ts new file mode 100644 index 0000000..e8f34e7 --- /dev/null +++ b/backend/src/common/crypto/crypto.util.ts @@ -0,0 +1,63 @@ +import { + createCipheriv, + createDecipheriv, + createHash, + createHmac, + randomBytes, +} from 'crypto'; + +/** + * تشفير الحقول الحساسة عند الراحة (at-rest) بـ AES-256-GCM مع IV عشوائي لكل قيمة + * (تشفير موثَّق authenticated — يمنع العبث). صيغة التخزين: v1:base64(iv|tag|ct). + * IV عشوائي 96-بت لكل عملية — لا IV ثابت إطلاقاً (راجع docs/16). + * + * للحقول القابلة للبحث بالتساوي (مثل الهاتف): تشفير GCM عشوائي لا يسمح بالمطابقة، + * فنستخدم blindIndex (HMAC حتمي) كعمود بحث منفصل — مخطّط لاحقاً. + */ +const VERSION = 'v1'; + +function getKey(): Buffer { + const raw = process.env.ENCRYPTION_KEY || ''; + if (/^[0-9a-f]{64}$/i.test(raw)) return Buffer.from(raw, 'hex'); // 32 بايت hex + if (!raw) return createHash('sha256').update('tripz-dev-insecure-key').digest(); + return createHash('sha256').update(raw).digest(); // أي سر → 32 بايت +} + +export function encrypt(plain: string | null | undefined): string | null { + if (plain === null || plain === undefined) return plain as any; + const iv = randomBytes(12); + const cipher = createCipheriv('aes-256-gcm', getKey(), iv); + const ct = Buffer.concat([cipher.update(String(plain), 'utf8'), cipher.final()]); + const tag = cipher.getAuthTag(); + return `${VERSION}:${Buffer.concat([iv, tag, ct]).toString('base64')}`; +} + +export function decrypt(value: string | null | undefined): string | null { + if (value === null || value === undefined) return value as any; + if (typeof value !== 'string' || !value.startsWith(`${VERSION}:`)) { + return value as any; // نص قديم غير مشفّر — تسامح أثناء الانتقال + } + try { + const raw = Buffer.from(value.slice(VERSION.length + 1), 'base64'); + const iv = raw.subarray(0, 12); + const tag = raw.subarray(12, 28); + const ct = raw.subarray(28); + const d = createDecipheriv('aes-256-gcm', getKey(), iv); + d.setAuthTag(tag); + return Buffer.concat([d.update(ct), d.final()]).toString('utf8'); + } catch { + return value as any; // لا نُسقط القراءة عند قيمة تالفة/قديمة + } +} + +/** فهرس أعمى حتمي للحقول القابلة للبحث (مثل الهاتف) — HMAC-SHA256. */ +export function blindIndex(value: string): string { + const key = process.env.ENCRYPTION_KEY || 'tripz-dev-insecure-key'; + return createHmac('sha256', key).update(value.trim().toLowerCase()).digest('hex'); +} + +/** محوّل TypeORM: يشفّر عند الكتابة ويفكّ عند القراءة تلقائياً. */ +export const EncryptedTransformer = { + to: (v: string | null) => encrypt(v), + from: (v: string | null) => decrypt(v), +}; diff --git a/backend/src/main.ts b/backend/src/main.ts index 997909c..ca66707 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -29,6 +29,13 @@ async function bootstrap() { .build(); SwaggerModule.setup('api/docs', app, SwaggerModule.createDocument(app, swagger)); + if (!process.env.ENCRYPTION_KEY) { + Logger.warn( + 'ENCRYPTION_KEY غير مضبوط — يُستخدم مفتاح تطوير غير آمن. اضبطه للإنتاج (openssl rand -hex 32).', + 'Security', + ); + } + const port = cfg.get('apiPort') ?? 4010; await app.listen(port, '0.0.0.0'); Logger.log(`Tripz API on :${port} (docs at /api/docs)`, 'Bootstrap'); diff --git a/backend/src/modules/drivers/entities/driver.entity.ts b/backend/src/modules/drivers/entities/driver.entity.ts index 19c57b5..67c3a67 100644 --- a/backend/src/modules/drivers/entities/driver.entity.ts +++ b/backend/src/modules/drivers/entities/driver.entity.ts @@ -6,6 +6,7 @@ import { PrimaryGeneratedColumn, UpdateDateColumn, } from 'typeorm'; +import { EncryptedTransformer } from '../../../common/crypto/crypto.util'; export type VerificationStatus = 'pending' | 'approved' | 'rejected'; @@ -34,7 +35,8 @@ export class Driver { @Column({ nullable: true }) vehicle_model: string; - @Column({ nullable: true }) + // مشفّر at-rest (AES-256-GCM) + @Column({ type: 'varchar', nullable: true, transformer: EncryptedTransformer }) vehicle_plate: string; @Column({ nullable: true }) diff --git a/backend/src/modules/users/entities/user.entity.ts b/backend/src/modules/users/entities/user.entity.ts index f7dda06..6f8d3a3 100644 --- a/backend/src/modules/users/entities/user.entity.ts +++ b/backend/src/modules/users/entities/user.entity.ts @@ -1,4 +1,5 @@ import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm'; +import { EncryptedTransformer } from '../../../common/crypto/crypto.util'; export enum UserRole { RIDER = 'rider', @@ -18,7 +19,8 @@ export class User { @Column() phone: string; - @Column({ nullable: true }) + // مشفّر at-rest (AES-256-GCM) — يُفكّ تلقائياً عند القراءة + @Column({ type: 'varchar', nullable: true, transformer: EncryptedTransformer }) name: string; // varchar (لا enum) لمطابقة الهجرة وتفادي إنشاء نوع enum في القاعدة. diff --git a/docs/16-encryption.md b/docs/16-encryption.md new file mode 100644 index 0000000..297c81c --- /dev/null +++ b/docs/16-encryption.md @@ -0,0 +1,33 @@ +# 16 — التشفير (معيار at-rest) + +> تصحيح لخطأ سيرو الموثّق (CBC بـ IV ثابت). المعيار عندنا: **AES-256-GCM + IV عشوائي لكل قيمة**. + +## المعيار +- **الخوارزمية:** AES-256-GCM (تشفير موثَّق authenticated — يكشف أي عبث). +- **IV:** عشوائي **96-بت لكل عملية** (`randomBytes(12)`) — **لا IV ثابت إطلاقاً**. +- **صيغة التخزين:** `v1:base64(iv | authTag | ciphertext)` — الإصدار في المقدمة للترقية المستقبلية. +- **المفتاح:** `ENCRYPTION_KEY` (32 بايت hex؛ `openssl rand -hex 32`). إلزامي للإنتاج؛ بدونه مفتاح تطوير غير آمن + تحذير عند الإقلاع. +- **الكود:** `common/crypto/crypto.util.ts` — `encrypt` / `decrypt` / `blindIndex` / `EncryptedTransformer`. + +## كيف يُطبَّق (بلا تعقيد) +عبر **TypeORM transformer** على العمود: يشفّر عند الكتابة ويفكّ عند القراءة **تلقائياً**. لا يتغير منطق الخدمات ولا استجابات الـ API (تظهر مفكوكة). +```ts +@Column({ type: 'varchar', nullable: true, transformer: EncryptedTransformer }) +name: string; +``` + +## المطبّق حالياً +- `users.name` · `drivers.vehicle_plate`. + +## الحقول القابلة للبحث (مثل الهاتف) — الحل الصحيح +تشفير GCM عشوائي **يمنع المطابقة بالتساوي** (كل تشفير مختلف). للبحث نستخدم **فهرس أعمى (blind index)**: عمود إضافي = `HMAC-SHA256(القيمة)` حتمي للبحث + العمود المشفّر للتخزين. مخطّط عند بناء بحث الهاتف/الهوية. + +## الميغريشن (صحيح ومتوافق) +- **لا تغيير مخطّط:** الأعمدة `varchar` تتّسع للنص المشفّر (base64). لا هجرة لازمة لتحويل النوع. +- **تسامح انتقالي:** `decrypt` يُرجع النص القديم غير المشفّر كما هو (يبدأ بلا `v1:`) — فلا تنكسر القراءة على بيانات قديمة؛ والكتابة الجديدة تُشفّر. +- **عند نقل بيانات حقيقية من سيرو:** تُقرأ مفكوكةً من سيرو ثم تُكتب فتُشفَّر عندنا تلقائياً. + +## ما يُشفَّر مستقبلاً +وثائق السائق وأرقام الهوية/الرخصة (وحدة الوثائق)، ووجهات السحب الحساسة، وأي PII يُضاف — بنفس المحوّل. + +← يُقرأ مع: [14-server-conventions](14-server-conventions.md) · [08-data-model](08-data-model.md)