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:
2026-07-16 19:24:27 +03:00
co-authored by Claude Opus 4.8
parent bb6a7bc7ec
commit f3383c3cf8
6 changed files with 114 additions and 2 deletions
+5
View File
@@ -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
+63
View File
@@ -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),
};
+7
View File
@@ -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 في القاعدة.