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:
co-authored by
Claude Opus 4.8
parent
1eb79fee99
commit
253fbb4b2a
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user