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 ----
|
# ---- Auth ----
|
||||||
JWT_SECRET=change_me_jwt_secret
|
JWT_SECRET=change_me_jwt_secret
|
||||||
|
|
||||||
|
# ---- تشفير الحقول الحساسة at-rest (AES-256-GCM) ----
|
||||||
|
# 32 بايت بصيغة hex (64 محرف). ولّده: openssl rand -hex 32
|
||||||
|
# إلزامي للإنتاج — بدونه يُستخدم مفتاح تطوير غير آمن
|
||||||
|
ENCRYPTION_KEY=
|
||||||
JWT_EXPIRES=15m
|
JWT_EXPIRES=15m
|
||||||
JWT_REFRESH_EXPIRES=30d
|
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();
|
.build();
|
||||||
SwaggerModule.setup('api/docs', app, SwaggerModule.createDocument(app, swagger));
|
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;
|
const port = cfg.get<number>('apiPort') ?? 4010;
|
||||||
await app.listen(port, '0.0.0.0');
|
await app.listen(port, '0.0.0.0');
|
||||||
Logger.log(`Tripz API on :${port} (docs at /api/docs)`, 'Bootstrap');
|
Logger.log(`Tripz API on :${port} (docs at /api/docs)`, 'Bootstrap');
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
PrimaryGeneratedColumn,
|
PrimaryGeneratedColumn,
|
||||||
UpdateDateColumn,
|
UpdateDateColumn,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
|
import { EncryptedTransformer } from '../../../common/crypto/crypto.util';
|
||||||
|
|
||||||
export type VerificationStatus = 'pending' | 'approved' | 'rejected';
|
export type VerificationStatus = 'pending' | 'approved' | 'rejected';
|
||||||
|
|
||||||
@@ -34,7 +35,8 @@ export class Driver {
|
|||||||
@Column({ nullable: true })
|
@Column({ nullable: true })
|
||||||
vehicle_model: string;
|
vehicle_model: string;
|
||||||
|
|
||||||
@Column({ nullable: true })
|
// مشفّر at-rest (AES-256-GCM)
|
||||||
|
@Column({ type: 'varchar', nullable: true, transformer: EncryptedTransformer })
|
||||||
vehicle_plate: string;
|
vehicle_plate: string;
|
||||||
|
|
||||||
@Column({ nullable: true })
|
@Column({ nullable: true })
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
|
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
|
||||||
|
import { EncryptedTransformer } from '../../../common/crypto/crypto.util';
|
||||||
|
|
||||||
export enum UserRole {
|
export enum UserRole {
|
||||||
RIDER = 'rider',
|
RIDER = 'rider',
|
||||||
@@ -18,7 +19,8 @@ export class User {
|
|||||||
@Column()
|
@Column()
|
||||||
phone: string;
|
phone: string;
|
||||||
|
|
||||||
@Column({ nullable: true })
|
// مشفّر at-rest (AES-256-GCM) — يُفكّ تلقائياً عند القراءة
|
||||||
|
@Column({ type: 'varchar', nullable: true, transformer: EncryptedTransformer })
|
||||||
name: string;
|
name: string;
|
||||||
|
|
||||||
// varchar (لا enum) لمطابقة الهجرة وتفادي إنشاء نوع enum في القاعدة.
|
// 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