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
+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],