feat: المجموعة E (كشف الاحتيال) + تشفير الهاتف at-rest

E — اتصال ثم إلغاء (من driver_ride_scam عند سيرو):
- E1: driver_called_passenger + last_call_at/by/count على الرحلة نفسها لا
  Redis فقط — حقيقة دائمة تفيد التحقيق بعد انتهاء الرحلة وزوالها من الكاش.
  يُسجَّل عند call:offer (بدء المكالمة الفعلي) في RealtimeGateway عبر
  Repository<Trip> مباشرة — لا استيراد TripsModule كاملاً (يستورد
  RealtimeModule أصلاً، فدورة استيراد)
- E2: FraudService.recordCallThenCancel — عدّاد يومي منفصل عن عدّاد الإلغاء
  العام بالساعة. 3/يوم = إنذار لا يمنع الإلغاء، 6/يوم = حظر مؤقت (طلب
  المالك حرفياً). يرتبط فقط إن كانت المكالمة خلال 30 دقيقة من الإلغاء

تشفير الهاتف at-rest (ملاحظة المالك) — بفهرس أعمى منفصل، لا تشفير ساذج:
- AES-256-GCM عشوائي IV يمنع WHERE phone = :x من المطابقة أصلاً (كل تشفير
  مختلف شكلاً حتى لنفس الرقم) — لو طُبِّق التشفير مباشرة بلا هذا لكسر تسجيل
  الدخول بالكامل بصمت
- users.phone_bidx = HMAC-SHA256(الرقم المطبَّع) — حتمي، يصلح للبحث
  وللقيد الفريد. القيد انتقل من (tenant_id, phone) إلى (tenant_id,
  phone_bidx) — القديم كان سيقبل تكرار نفس الرقم الحقيقي فعلياً
- findByPhone/create يحسبان البصمة؛ لا نقطة أخرى تقرأ phone بمساواة خام
- هجرة EncryptPhone تُشفّر الصفوف القائمة فوراً وتبني البصمة بأثر رجعي —
  استثناء متعمَّد من تسامح decrypt() المعتاد (الهاتف عمود بحث يومي، خطره
  كنص صافٍ أعلى من name)
- يعتمد على تطبيع D1: بصمة حتمية لنفس *النص*، فلا بد أن يصل الرقم مطبَّعاً
  قبلها وإلا عاد نفس الرقم بصيغتين ليصير رقمين مرة أخرى

هجرات: TripCallSignal · EncryptPhone.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hamza-Ayed
2026-07-17 17:25:18 +03:00
co-authored by Claude Opus 4.8
parent 1eb79fee99
commit 253fbb4b2a
12 changed files with 345 additions and 16 deletions
@@ -0,0 +1,26 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* إشارة المكالمة على الرحلة (docs/17 — E1/E2؛ مكافئ isDriverCallPassenger
* عند سيرو). تُخزَّن دائماً على الرحلة نفسها — لا في Redis فقط — لأنها
* حقيقة تفيد التحقيق حتى بعد زوال حالة الرحلة من الكاش.
*/
export class TripCallSignal1721890000000 implements MigrationInterface {
public async up(q: QueryRunner): Promise<void> {
await q.query(
`ALTER TABLE tripz_trips ADD COLUMN IF NOT EXISTS driver_called_passenger boolean NOT NULL DEFAULT false`,
);
await q.query(`ALTER TABLE tripz_trips ADD COLUMN IF NOT EXISTS last_call_at timestamptz`);
await q.query(`ALTER TABLE tripz_trips ADD COLUMN IF NOT EXISTS last_call_by varchar`);
await q.query(
`ALTER TABLE tripz_trips ADD COLUMN IF NOT EXISTS call_count integer NOT NULL DEFAULT 0`,
);
}
public async down(q: QueryRunner): Promise<void> {
await q.query(`ALTER TABLE tripz_trips DROP COLUMN IF EXISTS call_count`);
await q.query(`ALTER TABLE tripz_trips DROP COLUMN IF EXISTS last_call_by`);
await q.query(`ALTER TABLE tripz_trips DROP COLUMN IF EXISTS last_call_at`);
await q.query(`ALTER TABLE tripz_trips DROP COLUMN IF EXISTS driver_called_passenger`);
}
}
@@ -0,0 +1,56 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import { encrypt, blindIndex } from '../../common/crypto/crypto.util';
/**
* تشفير الهاتف at-rest + فهرس أعمى للبحث (docs/16، docs/17 — D1 إضافة).
*
* **لماذا فهرس منفصل**: `phone` بعد التشفير غير قابل للبحث بالمساواة إطلاقاً
* — AES-256-GCM يستعمل IV عشوائياً لكل عملية، فنفس الرقم الحقيقي ينتج شيفرة
* مختلفة كل مرة. القيد الفريد ونقاط `WHERE phone = …` (تسجيل الدخول بأكمله)
* ينتقلان إلى `phone_bidx` — بصمة حتمية (HMAC) لا تكشف الرقم لكنها تكرّر
* نفسها لنفس الرقم دائماً.
*
* تُشفَّر الصفوف القائمة في نفس الهجرة (لا تُترك نصّاً صافياً) — الحماية
* التلقائية عبر `decrypt()` المتسامح كانت ستُبقيها صافية للأبد بلا هذا.
*/
export class EncryptPhone1721900000000 implements MigrationInterface {
public async up(q: QueryRunner): Promise<void> {
await q.query(`ALTER TABLE tripz_users ADD COLUMN IF NOT EXISTS phone_bidx varchar`);
const rows: { id: string; phone: string }[] = await q.query(
`SELECT id, phone FROM tripz_users`,
);
for (const row of rows) {
// تسامح إعادة التشغيل الجزئي: لا نُشفّر قيمة مشفّرة أصلاً مرة أخرى.
const alreadyEncrypted = row.phone?.startsWith('v1:');
const canonicalPhone = alreadyEncrypted ? null : row.phone;
const bidx = blindIndex(canonicalPhone ?? row.phone);
if (alreadyEncrypted) {
await q.query(`UPDATE tripz_users SET phone_bidx = $1 WHERE id = $2`, [bidx, row.id]);
} else {
await q.query(`UPDATE tripz_users SET phone = $1, phone_bidx = $2 WHERE id = $3`, [
encrypt(row.phone),
bidx,
row.id,
]);
}
}
await q.query(`ALTER TABLE tripz_users ALTER COLUMN phone_bidx SET NOT NULL`);
await q.query(`DROP INDEX IF EXISTS "UQ_tripz_users_tenant_phone"`);
await q.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_tripz_users_tenant_phone_bidx"
ON tripz_users (tenant_id, phone_bidx)
`);
}
public async down(q: QueryRunner): Promise<void> {
// لا رجوع لفكّ التشفير جماعياً هنا عمداً — decrypt() يبقى متاحاً عند
// القراءة عبر الـORM؛ التراجع عن هذه الهجرة يعني قبول عدم قابلية البحث
// إن أُعيد phone فهرساً فريداً وحده، فلا نعيد بناء ذلك القيد تلقائياً.
await q.query(`DROP INDEX IF EXISTS "UQ_tripz_users_tenant_phone_bidx"`);
await q.query(`ALTER TABLE tripz_users ALTER COLUMN phone_bidx DROP NOT NULL`);
await q.query(`ALTER TABLE tripz_users DROP COLUMN IF EXISTS phone_bidx`);
}
}
@@ -23,6 +23,14 @@ export class FraudService {
private readonly ARRIVED_MAX_M = 350; // أقصى بعد مقبول عند "وصل"
private readonly FAST_MIN_SEC = 30; // إنهاء أسرع من هذا يُشتبه به
// اتصال ثم إلغاء (docs/17 — E2؛ من driver_ride_scam عند سيرو).
// نافذة يومية لا ساعة — مقصودة ومنفصلة عن CANCEL_SOFT/HARD: تلك تعدّ كل
// إلغاء أياً كان سببه، وهذه تعدّ فقط الإلغاء الذي **سبقته مكالمة** — إشارة
// اتفاق خارج التطبيق، لا مجرد كثرة إلغاء.
private readonly CALL_CANCEL_WINDOW_MIN = 30; // اتصال خلال آخر نصف ساعة قبل الإلغاء يُحتسب مرتبطاً
private readonly CALL_CANCEL_SOFT = 3; // إنذار — طلب المالك حرفياً: «3 إلغاءات/يوم»
private readonly CALL_CANCEL_HARD = 6; // التكرار → إجراء (حظر مؤقت)
constructor(
@InjectRepository(FraudFlag) private readonly repo: Repository<FraudFlag>,
@Inject(REDIS) private readonly redis: Redis,
@@ -70,6 +78,41 @@ export class FraudService {
}
}
/**
* ربط الاتصال بالإلغاء (docs/17 — E2): اتصال ثم إلغاء سريع = مؤشر اتفاق
* خارج التطبيق (الطرفان اتفقا هاتفياً وألغيا ليتجنّبا العمولة). يُنادى
* من `TripsService.cancel` مع آخر وقت اتصال على الرحلة (أو null إن لم
* تحدث مكالمة، فيخرج فوراً بلا أثر).
*
* عدّاد **يومي منفصل** عن `recordCancellation` عمداً: التكرار هنا سببه
* الاتصال تحديداً لا كثرة الإلغاء العامة.
*/
async recordCallThenCancel(
tenantId: string,
subjectType: 'rider' | 'driver',
userId: string,
tripId: string,
lastCallAt: Date | null,
): Promise<void> {
if (!lastCallAt) return;
const minutesSinceCall = (Date.now() - lastCallAt.getTime()) / 60000;
if (minutesSinceCall > this.CALL_CANCEL_WINDOW_MIN) return; // اتصال قديم — غير مرتبط
const today = new Date().toISOString().slice(0, 10);
const key = `call_cancel:${tenantId}:${userId}:${today}`;
const count = await this.redis.incr(key);
if (count === 1) await this.redis.expire(key, 26 * 3600); // يتجاوز منتصف الليل بأمان
if (count >= this.CALL_CANCEL_HARD) {
await this.flag(tenantId, subjectType, userId, 'call_then_cancel_hard', { count }, tripId);
throw new ForbiddenException('Too many call-then-cancel patterns today — temporarily blocked');
}
if (count >= this.CALL_CANCEL_SOFT) {
// إنذار فقط — لا يمنع الإلغاء نفسه (طلب المالك: 3 = إنذار لا حظر).
await this.flag(tenantId, subjectType, userId, 'call_then_cancel_soft', { count }, tripId);
}
}
/**
* يتحقق من قرب السائق عند "وصل"؛ يبلّغ إن كان بعيداً.
* `driverAt` هو الموقع **الحيّ** من Redis — لا لقطة القاعدة الدورية، وإلا
@@ -121,6 +121,22 @@ export class Trip {
@Column({ type: 'numeric', precision: 12, scale: 3, nullable: true })
cancel_fee: number | null;
// ---- إشارة المكالمة (docs/17 — E1؛ مكافئ isDriverCallPassenger عند سيرو) ----
// اتصال ثم إلغاء سريع = مؤشر اتفاق خارج التطبيق (E2). نُخزَّن دائماً
// (لا نكتفي بـRedis) لأنها حقيقة دائمة عن الرحلة تفيد التحقيق لاحقاً حتى
// بعد انتهاء الرحلة وزوال حالتها من Redis.
@Column({ default: false })
driver_called_passenger: boolean;
@Column({ type: 'timestamptz', nullable: true })
last_call_at: Date | null;
@Column({ type: 'varchar', nullable: true })
last_call_by: string | null; // rider | driver
@Column({ type: 'int', default: 0 })
call_count: number;
// ---- طوابع دقيقة (docs/17 — B7؛ مقابلها في سيرو بين قوسين) ----
@CreateDateColumn()
requested_at: Date;
@@ -354,6 +354,21 @@ export class TripsService {
// كشف إساءة الإلغاء (قد يرمي عند الحد الصارم)
await this.fraud.recordCancellation(tenantId, actor, actorUserId, tripId);
// اتصال ثم إلغاء = مؤشر اتفاق خارج التطبيق (docs/17 — E2). last_call_at
// ليست في حالة Redis (مكالمة نادرة الحدوث، لا تستحق كاش) — قراءة خفيفة
// من القاعدة بعمود واحد.
const callRow = await this.trips.findOne({
where: { tenant_id: tenantId, id: tripId },
select: { last_call_at: true },
});
await this.fraud.recordCallThenCancel(
tenantId,
actor,
actorUserId,
tripId,
callRow?.last_call_at ?? null,
);
const fee = await this.cancelFee(tenantId, tripId, snapshot);
const res = await this.trips.update(
{ tenant_id: tenantId, id: tripId, status: snapshot.status },
@@ -1,4 +1,4 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, Index } from 'typeorm';
import { EncryptedTransformer } from '../../../common/crypto/crypto.util';
export enum UserRole {
@@ -8,7 +8,10 @@ export enum UserRole {
ADMIN = 'admin',
}
// القيد الحقيقي بعد التشفير (docs/17 — D1 إضافة): `phone` عشوائي التشفير
// فلا يصلح فهرساً فريداً؛ `phone_bidx` الحتمي هو من يمنع الازدواج فعلياً.
@Entity('users')
@Index(['tenant_id', 'phone_bidx'], { unique: true })
export class User {
@PrimaryGeneratedColumn('uuid')
id: string;
@@ -16,9 +19,21 @@ export class User {
@Column({ type: 'uuid' })
tenant_id: string;
@Column()
// مشفّر at-rest (AES-256-GCM) — يُفكّ تلقائياً عند القراءة.
// **لا يُستعمل في WHERE مباشرة**: IV عشوائي لكل عملية تشفير يعني أن نفس
// الرقم ينتج شيفرة مختلفة كل مرة، فأي بحث بالمساواة على هذا العمود يفشل
// دائماً. البحث والتفرّد كلاهما عبر `phone_bidx` (docs/16 §"blindIndex").
@Column({ transformer: EncryptedTransformer })
phone: string;
/**
* فهرس أعمى حتمي (HMAC-SHA256) — نفس الرقم الحقيقي ينتج نفس القيمة دائماً،
* فيصلح للبحث `WHERE` وللقيد الفريد `(tenant_id, phone_bidx)`. لا يكشف
* الرقم (اتجاه واحد)، وهو المفتاح الفعلي لا `phone` (docs/17 — D1 إضافة).
*/
@Column()
phone_bidx: string;
// مشفّر at-rest (AES-256-GCM) — يُفكّ تلقائياً عند القراءة
@Column({ type: 'varchar', nullable: true, transformer: EncryptedTransformer })
name: string;
@@ -0,0 +1,108 @@
import { randomUUID } from 'crypto';
import { newDb } from 'pg-mem';
import { DataSource } from 'typeorm';
import { User } from './entities/user.entity';
import { UsersService } from './users.service';
import { blindIndex, decrypt } from '../../common/crypto/crypto.util';
const TENANT_A = '11111111-1111-1111-1111-111111111111';
const TENANT_B = '22222222-2222-2222-2222-222222222222';
function fakeCache() {
return { wrap: jest.fn(async (_k: string, _t: number, loader: () => any) => loader()), set: jest.fn() } as any;
}
describe('UsersService — الهاتف مشفَّر at-rest (docs/16، docs/17 D1)', () => {
let ds: DataSource;
let users: UsersService;
beforeEach(async () => {
const db = newDb({ autoCreateForeignKeyIndices: true });
db.public.registerFunction({ name: 'version', returns: 'text' as any, implementation: () => 'pg-mem' });
db.public.registerFunction({
name: 'current_database',
returns: 'text' as any,
implementation: () => 'tripz',
});
db.registerExtension('uuid-ossp', (schema) =>
schema.registerFunction({
name: 'uuid_generate_v4',
returns: 'uuid' as any,
implementation: () => randomUUID(),
impure: true,
}),
);
await db.public.none(`CREATE EXTENSION "uuid-ossp"`);
ds = (await db.adapters.createTypeormDataSource({
type: 'postgres',
entities: [User],
entityPrefix: 'tripz_',
})) as DataSource;
await ds.initialize();
await ds.synchronize();
users = new UsersService(ds.getRepository(User), fakeCache());
});
afterEach(async () => {
if (ds?.isInitialized) await ds.destroy();
});
it('يقرأ رقماً مفكوك التشفير رغم أنه مخزَّن مشفّراً', async () => {
const created = await users.create(TENANT_A, '962790000000');
expect(created.phone).toBe('962790000000'); // الكائن المُعاد فوراً واضح
const fetched = await users.findById(TENANT_A, created.id);
expect(fetched!.phone).toBe('962790000000'); // وكذلك بعد إعادة القراءة
});
it('العمود على القاعدة مشفّر فعلاً — ليس نصّاً صافياً', async () => {
const created = await users.create(TENANT_A, '962790000000');
const raw = await ds.query(`SELECT phone FROM tripz_users WHERE id = $1`, [created.id]);
expect(raw[0].phone).not.toBe('962790000000');
expect(raw[0].phone).toMatch(/^v1:/); // صيغة common/crypto/crypto.util
expect(decrypt(raw[0].phone)).toBe('962790000000'); // لكنه قابل للفكّ
});
it('نفس الرقم يُعطي نفس الفهرس الأعمى دائماً — والبحث يعمل رغم التشفير العشوائي', async () => {
const created = await users.create(TENANT_A, '962790000000');
const found = await users.findByPhone(TENANT_A, '962790000000');
expect(found?.id).toBe(created.id);
});
it('الفهرس الأعمى نفسه لا يكشف الرقم — بصمة أحادية الاتجاه', async () => {
const created = await users.create(TENANT_A, '962790000000');
const raw = await ds.query(`SELECT phone_bidx FROM tripz_users WHERE id = $1`, [created.id]);
expect(raw[0].phone_bidx).toBe(blindIndex('962790000000'));
expect(raw[0].phone_bidx).not.toContain('790000000');
});
it('رقم مختلف لا يتصادف مع فهرس رقم آخر', async () => {
await users.create(TENANT_A, '962790000000');
expect(await users.findByPhone(TENANT_A, '962790000001')).toBeNull();
});
it('القيد الفريد يحمي من ازدواج نفس الرقم داخل المستأجر الواحد', async () => {
await users.create(TENANT_A, '962790000000');
await expect(users.create(TENANT_A, '962790000000')).rejects.toThrow();
});
it('نفس الرقم تحت مستأجرين مختلفين = مستخدمان منفصلان (عزل)', async () => {
const a = await users.create(TENANT_A, '962790000000');
const b = await users.create(TENANT_B, '962790000000');
expect(a.id).not.toBe(b.id);
expect((await users.findByPhone(TENANT_A, '962790000000'))!.id).toBe(a.id);
expect((await users.findByPhone(TENANT_B, '962790000000'))!.id).toBe(b.id);
});
it('findOrCreate لا يُنشئ نسخة ثانية لنفس الرقم', async () => {
const first = await users.findOrCreate(TENANT_A, '962790000000');
const second = await users.findOrCreate(TENANT_A, '962790000000');
expect(first.id).toBe(second.id);
});
});
+14 -2
View File
@@ -3,6 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User, UserRole } from './entities/user.entity';
import { CacheService, CacheKeys, TTL } from '../../common/cache/cache.service';
import { blindIndex } from '../../common/crypto/crypto.util';
@Injectable()
export class UsersService {
@@ -12,8 +13,14 @@ export class UsersService {
private readonly cache: CacheService,
) {}
/**
* `phone` مشفّر عشوائي at-rest — `WHERE phone = :x` لا يطابق أبداً حتى مع
* نفس الرقم (IV مختلف كل مرة). البحث دائماً عبر `phone_bidx` الحتمي.
*/
async findByPhone(tenantId: string, phone: string): Promise<User | null> {
return this.userRepository.findOne({ where: { tenant_id: tenantId, phone } });
return this.userRepository.findOne({
where: { tenant_id: tenantId, phone_bidx: blindIndex(phone) },
});
}
async findById(tenantId: string, id: string): Promise<User | null> {
@@ -21,7 +28,12 @@ export class UsersService {
}
async create(tenantId: string, phone: string, role: string = 'rider'): Promise<User> {
const user = this.userRepository.create({ tenant_id: tenantId, phone, role: role as UserRole });
const user = this.userRepository.create({
tenant_id: tenantId,
phone,
phone_bidx: blindIndex(phone),
role: role as UserRole,
});
return this.userRepository.save(user);
}
+30 -1
View File
@@ -8,8 +8,11 @@ import {
} from '@nestjs/websockets';
import { Logger } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Server, Socket } from 'socket.io';
import { DriversService } from '../modules/drivers/drivers.service';
import { Trip } from '../modules/trips/entities/trip.entity';
/**
* البوابة الحية (Socket.IO) — docs/09. مصادقة JWT + غرف مُنطَّقة بالمستأجر،
@@ -25,6 +28,7 @@ export class RealtimeGateway implements OnGatewayConnection {
constructor(
private readonly jwt: JwtService,
private readonly drivers: DriversService,
@InjectRepository(Trip) private readonly trips: Repository<Trip>,
) {}
handleConnection(client: Socket) {
@@ -91,9 +95,34 @@ export class RealtimeGateway implements OnGatewayConnection {
}
// ---- إشارات WebRTC (مكالمة مجانية بين الطرفين عبر غرفة الرحلة) ----
/**
* `offer` هو حدث **بدء الاتصال** فعلياً (answer/ice/end إشارات تفاوض
* لاحقة على نفس المكالمة) — لذا هنا فقط نُسجّل إشارة الاحتيال (docs/17 —
* E1). الكتابة على القاعدة مباشرة لا عبر Redis: تكرار المكالمات لا يقارن
* بتكرار نبضات الموقع، فلا حاجة لتجميع (نمط H) هنا.
*/
@SubscribeMessage('call:offer')
onCallOffer(@ConnectedSocket() c: Socket, @MessageBody() b: any) {
async onCallOffer(@ConnectedSocket() c: Socket, @MessageBody() b: any) {
this.relay(c, 'call:offer', b);
const u = c.data.user;
if (!u || !b?.tripId) return;
try {
await this.trips
.createQueryBuilder()
.update(Trip)
.set({
last_call_at: () => 'now()',
last_call_by: u.role,
call_count: () => 'call_count + 1',
...(u.role === 'driver' ? { driver_called_passenger: true } : {}),
})
.where('tenant_id = :tenantId AND id = :tripId', { tenantId: u.tenantId, tripId: b.tripId })
.execute();
} catch (e: any) {
// تسجيل الإشارة لا يجوز أن يقطع المكالمة نفسها — أُعيد المحاولة لاحقاً
// غير ممكنة هنا، فنكتفي باللوغ.
this.logger.warn(`call signal record failed: ${e?.message}`);
}
}
@SubscribeMessage('call:answer')
+5
View File
@@ -1,12 +1,17 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { RealtimeGateway } from './realtime.gateway';
import { DriversModule } from '../modules/drivers/drivers.module';
import { Trip } from '../modules/trips/entities/trip.entity';
@Module({
imports: [
DriversModule,
// تسجيل الكيان مباشرة لا وحدة TripsModule كاملة — TripsModule تستورد
// RealtimeModule أصلاً، فاستيراد معاكس يصنع دورة (docs/17 — E1).
TypeOrmModule.forFeature([Trip]),
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
+10 -6
View File
@@ -17,15 +17,19 @@ name: string;
```
## المطبّق حالياً
- `users.name` · `drivers.vehicle_plate`.
- `users.name` · `users.phone` · `drivers.vehicle_plate`.
## الحقول القابلة للبحث (مثل الهاتف) — الحل الصحيح
تشفير GCM عشوائي **يمنع المطابقة بالتساوي** (كل تشفير مختلف). للبحث نستخدم **فهرس أعمى (blind index)**: عمود إضافي = `HMAC-SHA256(القيمة)` حتمي للبحث + العمود المشفّر للتخزين. مخطّط عند بناء بحث الهاتف/الهوية.
## الحقول القابلة للبحث (مثل الهاتف) — ✅ منفَّذ (docs/17 — D1 إضافة)
تشفير GCM عشوائي **يمنع المطابقة بالتساوي** (كل تشفير مختلف) — `WHERE phone = :x` لا يطابق أبداً حتى مع نفس الرقم. الحل: **فهرس أعمى (blind index)** — `users.phone_bidx = HMAC-SHA256(canonicalPhone)` حتمي، عمود إضافي غير مشفَّر (بصمة أحادية الاتجاه، لا تكشف الرقم). كل بحث وتفرّد على الهاتف يمرّ عبره لا عبر `phone`:
- `UsersService.findByPhone` يحسب `blindIndex(phone)` ويبحث به.
- القيد الفريد `(tenant_id, phone_bidx)` — لا `(tenant_id, phone)` (ذاك كان سيقبل تكرار نفس الرقم الحقيقي لأن كل تشفير مختلف شكلاً).
- **يعتمد على تطبيع D1**: البصمة حتمية لنفس *النص*، فلا بد أن يصل `phone` مطبَّعاً (`common/phone/phone.service.ts`) قبل الوصول لهذا الكود — وإلا اعتُبر نفس الرقم بصيغتين مختلفتين رقمين مختلفين مرة أخرى.
## الميغريشن (صحيح ومتوافق)
- **لا تغيير مخطّط:** الأعمدة `varchar` تتّسع للنص المشفّر (base64). لا هجرة لازمة لتحويل النوع.
- **تسامح انتقالي:** `decrypt` يُرجع النص القديم غير المشفّر كما هو (يبدأ بلا `v1:`) — فلا تنكسر القراءة على بيانات قديمة؛ والكتابة الجديدة تُشفّر.
- **عند نقل بيانات حقيقية من سيرو:** تُقرأ مفكوكةً من سيرو ثم تُكتب فتُشفَّر عندنا تلقائياً.
- **لا تغيير مخطّط لـ`varchar`:** الأعمدة تتّسع للنص المشفّر (base64). لا هجرة لتحويل النوع.
- **تسامح انتقالي:** `decrypt` يُرجع النص القديم غير المشفّر كما هو (يبدأ بلا `v1:`) — فلا تنكسر القراءة على بيانات قديمة.
- **الهاتف استثناء متعمَّد لهذا التسامح**: هجرة `EncryptPhone` تُشفّر الصفوف القائمة **فوراً** (لا تتركها صافية للأبد بالاعتماد على `decrypt` المتسامح) — لأن `phone` عمود بحث نشط يومياً (كل تسجيل دخول)، ونصّه الصافي في القاعدة أخطر من `name` (نادراً ما يُقرأ مباشرة كنص خام). نفس الهجرة تبني `phone_bidx` بأثر رجعي لكل صف قائم.
- **عند نقل بيانات حقيقية من سيرو:** تُقرأ مفكوكةً من سيرو ثم تُكتب فتُشفَّر عندنا تلقائياً — ويجب تمريرها عبر `PhoneService.normalize` أولاً قبل الكتابة، وإلا فسدت البصمة.
## ما يُشفَّر مستقبلاً
وثائق السائق وأرقام الهوية/الرخصة (وحدة الوثائق)، ووجهات السحب الحساسة، وأي PII يُضاف — بنفس المحوّل.
+5 -5
View File
@@ -76,12 +76,12 @@
---
## المجموعة E — كشف الاحتيال (من `driver_ride_scam`)
## المجموعة E — كشف الاحتيال (من `driver_ride_scam`) — ✅ منفَّذة
| # | البند | التفصيل |
|---|-------|---------|
| E1 | **تسجيل زر الاتصال** | `isDriverCallPassenger` لكل رحلة (سيرو). |
| E2 | **ربط الاتصال بالإلغاء** | اتصال ثم إلغاء = مؤشر اتفاق خارج التطبيق. **3 إلغاءات/يوم → إنذار**؛ التكرار → إجراء. |
| # | البند | الحالة |
|---|-------|--------|
| E1 | **تسجيل زر الاتصال** | ✅ `driver_called_passenger` + `last_call_at`/`last_call_by`/`call_count` على الرحلة نفسها (لا Redis فقط — حقيقة دائمة تفيد التحقيق بعد انتهاء الرحلة). يُسجَّل عند `call:offer` في `RealtimeGateway` — لحظة **بدء** الاتصال الفعلية، لا `answer`/`ice`/`end` (إشارات تفاوض لاحقة لنفس المكالمة). |
| E2 | **ربط الاتصال بالإلغاء** | ✅ `FraudService.recordCallThenCancel` — عدّاد **يومي منفصل** عن عدّاد الإلغاء العام (ذاك بالساعة ويعدّ كل إلغاء؛ هذا باليوم ويعدّ فقط الإلغاء **المسبوق باتصال خلال 30 دقيقة**). **3/يوم = إنذار (لا يمنع الإلغاء) — 6/يوم = حظر مؤقت**، طبق طلب المالك حرفياً. يُنادى من `TripsService.cancel` بقراءة خفيفة لعمود واحد قبل التحديث. |
---