feat(security): field encryption AES-256-GCM + random IV (at-rest)
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
@@ -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<number>('apiPort') ?? 4010;
|
||||
await app.listen(port, '0.0.0.0');
|
||||
Logger.log(`Tripz API on :${port} (docs at /api/docs)`, 'Bootstrap');
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 في القاعدة.
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user