@vailix/mask 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "@vailix/mask",
3
+ "version": "0.1.2",
4
+ "description": "Privacy-preserving proximity tracing SDK for React Native & Expo",
5
+ "author": "Gil Eyni",
6
+ "keywords": [
7
+ "react-native",
8
+ "expo",
9
+ "privacy",
10
+ "proximity-tracing",
11
+ "bluetooth",
12
+ "ble",
13
+ "contact-tracing",
14
+ "sdk"
15
+ ],
16
+ "homepage": "https://github.com/vailix/core/tree/main/packages/mask#readme",
17
+ "bugs": {
18
+ "url": "https://github.com/vailix/core/issues"
19
+ },
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/vailix/core.git",
23
+ "directory": "packages/mask"
24
+ },
25
+ "license": "MIT",
26
+ "publishConfig": {
27
+ "access": "public"
28
+ },
29
+ "main": "dist/index.js",
30
+ "module": "dist/index.mjs",
31
+ "types": "dist/index.d.ts",
32
+ "exports": {
33
+ ".": {
34
+ "types": "./dist/index.d.ts",
35
+ "import": "./dist/index.mjs",
36
+ "require": "./dist/index.js"
37
+ }
38
+ },
39
+ "peerDependencies": {
40
+ "expo": "~52.0.0",
41
+ "react-native": ">=0.76.0"
42
+ },
43
+ "dependencies": {
44
+ "react-native-quick-crypto": "^1.0.6",
45
+ "expo-secure-store": "~15.0.8",
46
+ "expo-sqlite": "~16.0.10",
47
+ "drizzle-orm": "^0.45.1",
48
+ "@react-native-async-storage/async-storage": "^1.21.0",
49
+ "eventemitter3": "^5.0.0",
50
+ "react-native-ble-plx": "^3.2.1"
51
+ },
52
+ "devDependencies": {
53
+ "drizzle-kit": "^0.30.0",
54
+ "typescript": "^5.7.0",
55
+ "tsup": "^8.0.0"
56
+ },
57
+ "scripts": {
58
+ "build": "tsup src/index.ts --format esm,cjs --dts --clean",
59
+ "test": "vitest run",
60
+ "lint": "eslint src/"
61
+ }
62
+ }
package/src/ble.ts ADDED
@@ -0,0 +1,504 @@
1
+ import { BleManager, Device, State, BleError } from 'react-native-ble-plx';
2
+ import type { NearbyUser, PairResult } from './types';
3
+ import type { StorageService } from './storage';
4
+
5
+ // ============================================================================
6
+ // Constants
7
+ // ============================================================================
8
+
9
+ // Service UUID (unique to Vailix)
10
+ const VAILIX_SERVICE_UUID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
11
+
12
+ // Characteristic UUIDs (IN/OUT separation for security)
13
+ const RPI_OUT_CHAR_UUID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567891'; // Others read my RPI
14
+ const RPI_IN_CHAR_UUID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567892'; // Others write their RPI
15
+ const META_OUT_CHAR_UUID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567893'; // Others read my key
16
+ const META_IN_CHAR_UUID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567894'; // Others write their key
17
+
18
+ // Defaults
19
+ const DEFAULT_DISCOVERY_TIMEOUT_MS = 15000;
20
+ const DEFAULT_PROXIMITY_THRESHOLD = -70;
21
+
22
+ import { generateDisplayName } from './utils';
23
+
24
+ /**
25
+ * Extract RPI prefix from advertisement manufacturer data or service data.
26
+ */
27
+ function extractRpiPrefix(device: Device, serviceUUID: string): string | null {
28
+ // Try to extract from service data first
29
+ const serviceData = device.serviceData;
30
+ if (serviceData && serviceData[serviceUUID]) {
31
+ const data = serviceData[serviceUUID];
32
+ if (data && data.length >= 16) {
33
+ // First 16 chars = 8 bytes hex
34
+ return data.substring(0, 16);
35
+ }
36
+ }
37
+
38
+ // Fallback: try manufacturer data
39
+ const mfgData = device.manufacturerData;
40
+ if (mfgData && mfgData.length >= 16) {
41
+ return mfgData.substring(0, 16);
42
+ }
43
+
44
+ return null;
45
+ }
46
+
47
+ // ============================================================================
48
+ // BleService Class
49
+ // ============================================================================
50
+
51
+ export class BleService {
52
+ private manager: BleManager;
53
+ private isScanning: boolean = false;
54
+ private nearbyUsers: Map<string, InternalNearbyUser> = new Map();
55
+ private pendingRequests: Map<string, PendingPairRequest> = new Map();
56
+
57
+ // Configuration
58
+ private discoveryTimeoutMs: number;
59
+ private proximityThreshold: number;
60
+ private autoAccept: boolean;
61
+ private serviceUUID: string;
62
+
63
+
64
+ // State
65
+ private cleanupInterval?: ReturnType<typeof setInterval>;
66
+ private scanSubscription?: { remove: () => void };
67
+ private onNearbyUpdated?: (users: NearbyUser[]) => void;
68
+
69
+ // Identity (set when discovery starts)
70
+ private myRpi?: string;
71
+ private myMetadataKey?: string;
72
+
73
+ // Storage reference for persisting pairs
74
+ private storage?: StorageService;
75
+
76
+ constructor(config: BleServiceConfig = {}) {
77
+ this.manager = new BleManager();
78
+ this.discoveryTimeoutMs = config.discoveryTimeoutMs ?? DEFAULT_DISCOVERY_TIMEOUT_MS;
79
+ this.proximityThreshold = config.proximityThreshold ?? DEFAULT_PROXIMITY_THRESHOLD;
80
+ this.autoAccept = config.autoAccept ?? true;
81
+ this.serviceUUID = config.serviceUUID ?? VAILIX_SERVICE_UUID;
82
+ }
83
+
84
+ /**
85
+ * Set storage service reference (called by SDK)
86
+ */
87
+ setStorage(storage: StorageService): void {
88
+ this.storage = storage;
89
+ }
90
+
91
+ /**
92
+ * Check if BLE is available and enabled
93
+ */
94
+ async initialize(): Promise<boolean> {
95
+ return new Promise((resolve) => {
96
+ const subscription = this.manager.onStateChange((state: typeof State[keyof typeof State]) => {
97
+ if (state === State.PoweredOn) {
98
+ subscription.remove();
99
+ resolve(true);
100
+ } else if (state === State.PoweredOff || state === State.Unauthorized) {
101
+ subscription.remove();
102
+ resolve(false);
103
+ }
104
+ }, true);
105
+ });
106
+ }
107
+
108
+ /**
109
+ * Check if BLE is supported on this device
110
+ */
111
+ static async isSupported(): Promise<boolean> {
112
+ const manager = new BleManager();
113
+ return new Promise((resolve) => {
114
+ const subscription = manager.onStateChange((state: typeof State[keyof typeof State]) => {
115
+ subscription.remove();
116
+ manager.destroy();
117
+ resolve(state !== State.Unsupported);
118
+ }, true);
119
+ });
120
+ }
121
+
122
+ /**
123
+ * Start advertising our RPI + scanning for others.
124
+ * Call when pairing screen opens.
125
+ */
126
+ async startDiscovery(myRpi: string, myMetadataKey: string): Promise<void> {
127
+ if (this.isScanning) return;
128
+
129
+ this.myRpi = myRpi;
130
+ this.myMetadataKey = myMetadataKey;
131
+ this.isScanning = true;
132
+ this.nearbyUsers.clear();
133
+
134
+ // Start cleanup interval to remove stale users
135
+ this.startCleanupInterval();
136
+
137
+ // Start scanning for other devices
138
+ await this.startScanning();
139
+
140
+ // Note: Advertising (peripheral role) requires native module support
141
+ // react-native-ble-plx primarily supports central role
142
+ // For full bidirectional exchange, we rely on GATT connections
143
+ }
144
+
145
+ /**
146
+ * Stop advertising and scanning.
147
+ * Call when leaving pairing screen.
148
+ */
149
+ async stopDiscovery(): Promise<void> {
150
+ this.isScanning = false;
151
+
152
+ if (this.scanSubscription) {
153
+ this.scanSubscription.remove();
154
+ this.scanSubscription = undefined;
155
+ }
156
+
157
+ this.manager.stopDeviceScan();
158
+
159
+ if (this.cleanupInterval) {
160
+ clearInterval(this.cleanupInterval);
161
+ this.cleanupInterval = undefined;
162
+ }
163
+
164
+ this.myRpi = undefined;
165
+ this.myMetadataKey = undefined;
166
+ }
167
+
168
+ /**
169
+ * Get current list of nearby users (public type without internal fields)
170
+ */
171
+ getNearbyUsers(): NearbyUser[] {
172
+ return Array.from(this.nearbyUsers.values())
173
+ .filter(u => u.rssi >= this.proximityThreshold)
174
+ .map(this.toPublicUser);
175
+ }
176
+
177
+ /**
178
+ * Subscribe to nearby user updates.
179
+ * Returns cleanup function for React useEffect compatibility.
180
+ */
181
+ onNearbyUsersChanged(callback: (users: NearbyUser[]) => void): () => void {
182
+ this.onNearbyUpdated = callback;
183
+ return () => {
184
+ this.onNearbyUpdated = undefined;
185
+ };
186
+ }
187
+
188
+ /**
189
+ * Initiate pairing with a specific user.
190
+ * In explicit consent mode, this also accepts pending incoming requests.
191
+ */
192
+ async pairWithUser(userId: string): Promise<PairResult> {
193
+ const internalUser = this.nearbyUsers.get(userId);
194
+ if (!internalUser) {
195
+ return { success: false, error: 'User not found' };
196
+ }
197
+
198
+ // Check if there's a pending incoming request (explicit consent mode)
199
+ const pendingRequest = this.pendingRequests.get(userId);
200
+ if (pendingRequest && !this.autoAccept) {
201
+ // Accept the pending request
202
+ return this.acceptPendingRequest(userId, pendingRequest);
203
+ }
204
+
205
+ // Initiate outgoing pairing
206
+ try {
207
+ const result = await this.doExchange(internalUser);
208
+ return result;
209
+ } catch (error) {
210
+ // Check if we already have this user's RPI (paired via reverse connection)
211
+ if (internalUser.fullRpi && this.storage) {
212
+ const alreadyPaired = await this.hasStoredRpi(internalUser.fullRpi);
213
+ if (alreadyPaired) {
214
+ return { success: true, partnerRpi: internalUser.fullRpi };
215
+ }
216
+ }
217
+ return {
218
+ success: false,
219
+ error: error instanceof Error ? error.message : 'Unknown error'
220
+ };
221
+ }
222
+ }
223
+
224
+ /**
225
+ * Unpair with a user (removes from storage and resets status)
226
+ */
227
+ async unpairUser(userId: string): Promise<void> {
228
+ const internalUser = this.nearbyUsers.get(userId);
229
+ if (internalUser) {
230
+ internalUser.paired = false;
231
+ internalUser.hasIncomingRequest = false;
232
+ internalUser.fullRpi = undefined;
233
+ internalUser.metadataKey = undefined;
234
+ this.pendingRequests.delete(userId);
235
+ this.emitUpdate();
236
+ }
237
+ }
238
+
239
+ /**
240
+ * Cleanup resources
241
+ */
242
+ destroy(): void {
243
+ this.stopDiscovery();
244
+ this.manager.destroy();
245
+ }
246
+
247
+ // ========================================================================
248
+ // Private Methods
249
+ // ========================================================================
250
+
251
+ private async startScanning(): Promise<void> {
252
+ this.manager.startDeviceScan(
253
+ [this.serviceUUID],
254
+ { allowDuplicates: true },
255
+ (error: BleError | null, device: Device | null) => {
256
+ if (error) {
257
+ console.warn('BLE scan error:', error);
258
+ return;
259
+ }
260
+ if (device) {
261
+ this.handleDiscoveredDevice(device);
262
+ }
263
+ }
264
+ );
265
+ }
266
+
267
+ private handleDiscoveredDevice(device: Device): void {
268
+ const rpiPrefix = extractRpiPrefix(device, this.serviceUUID);
269
+ if (!rpiPrefix) return;
270
+
271
+ const existingUser = this.nearbyUsers.get(device.id);
272
+
273
+ if (existingUser) {
274
+ // Update existing user
275
+ existingUser.rssi = device.rssi ?? -100;
276
+ existingUser.discoveredAt = Date.now();
277
+ } else {
278
+ // Add new user
279
+ const newUser: InternalNearbyUser = {
280
+ id: device.id,
281
+ displayName: generateDisplayName(rpiPrefix),
282
+ rssi: device.rssi ?? -100,
283
+ discoveredAt: Date.now(),
284
+ paired: false,
285
+ hasIncomingRequest: false,
286
+ rpiPrefix,
287
+ };
288
+ this.nearbyUsers.set(device.id, newUser);
289
+ }
290
+
291
+ this.emitUpdate();
292
+ }
293
+
294
+ private startCleanupInterval(): void {
295
+ this.cleanupInterval = setInterval(() => {
296
+ const now = Date.now();
297
+ let changed = false;
298
+
299
+ for (const [id, user] of this.nearbyUsers) {
300
+ if (now - user.discoveredAt > this.discoveryTimeoutMs) {
301
+ this.nearbyUsers.delete(id);
302
+ this.pendingRequests.delete(id);
303
+ changed = true;
304
+ }
305
+ }
306
+
307
+ if (changed) {
308
+ this.emitUpdate();
309
+ }
310
+ }, 1000);
311
+ }
312
+
313
+ private async doExchange(user: InternalNearbyUser): Promise<PairResult> {
314
+ if (!this.myRpi || !this.myMetadataKey) {
315
+ return { success: false, error: 'Discovery not started' };
316
+ }
317
+
318
+ let connectedDevice: Device | null = null;
319
+
320
+ try {
321
+ // Connect to the device
322
+ connectedDevice = await this.manager.connectToDevice(user.id, {
323
+ timeout: 10000,
324
+ });
325
+
326
+ // Discover services and characteristics
327
+ await connectedDevice.discoverAllServicesAndCharacteristics();
328
+
329
+ // Read partner's RPI
330
+ const rpiChar = await connectedDevice.readCharacteristicForService(
331
+ this.serviceUUID,
332
+ RPI_OUT_CHAR_UUID
333
+ );
334
+ const partnerRpi = rpiChar.value ? Buffer.from(rpiChar.value, 'base64').toString('hex') : null;
335
+
336
+ // Read partner's metadata key
337
+ const metaChar = await connectedDevice.readCharacteristicForService(
338
+ this.serviceUUID,
339
+ META_OUT_CHAR_UUID
340
+ );
341
+ const partnerMetadataKey = metaChar.value ? Buffer.from(metaChar.value, 'base64').toString('hex') : null;
342
+
343
+ if (!partnerRpi || !partnerMetadataKey) {
344
+ return { success: false, error: 'Failed to read partner data' };
345
+ }
346
+
347
+ // Write our RPI to partner
348
+ const myRpiBase64 = Buffer.from(this.myRpi, 'hex').toString('base64');
349
+ await connectedDevice.writeCharacteristicWithResponseForService(
350
+ this.serviceUUID,
351
+ RPI_IN_CHAR_UUID,
352
+ myRpiBase64
353
+ );
354
+
355
+ // Write our metadata key to partner
356
+ const myMetaBase64 = Buffer.from(this.myMetadataKey, 'hex').toString('base64');
357
+ await connectedDevice.writeCharacteristicWithResponseForService(
358
+ this.serviceUUID,
359
+ META_IN_CHAR_UUID,
360
+ myMetaBase64
361
+ );
362
+
363
+ // Store partner's data locally
364
+ if (this.storage) {
365
+ const canStore = this.storage.canScan(partnerRpi);
366
+ if (canStore) {
367
+ await this.storage.logScan(partnerRpi, partnerMetadataKey, Date.now());
368
+ }
369
+ }
370
+
371
+ // Update internal state
372
+ user.fullRpi = partnerRpi;
373
+ user.metadataKey = partnerMetadataKey;
374
+ user.paired = true;
375
+ user.hasIncomingRequest = false;
376
+ user.pairedAt = Date.now();
377
+ this.emitUpdate();
378
+
379
+ return {
380
+ success: true,
381
+ partnerRpi,
382
+ partnerMetadataKey
383
+ };
384
+
385
+ } catch (error) {
386
+ const message = error instanceof Error ? error.message : 'Connection failed';
387
+ return { success: false, error: message };
388
+ } finally {
389
+ // Always disconnect
390
+ if (connectedDevice) {
391
+ try {
392
+ await this.manager.cancelDeviceConnection(connectedDevice.id);
393
+ } catch {
394
+ // Ignore disconnect errors
395
+ }
396
+ }
397
+ }
398
+ }
399
+
400
+ private async acceptPendingRequest(userId: string, request: PendingPairRequest): Promise<PairResult> {
401
+ const user = this.nearbyUsers.get(userId);
402
+ if (!user) {
403
+ return { success: false, error: 'User not found' };
404
+ }
405
+
406
+ // Store the data that was held in memory
407
+ if (this.storage) {
408
+ const canStore = this.storage.canScan(request.fullRpi);
409
+ if (canStore) {
410
+ await this.storage.logScan(request.fullRpi, request.metadataKey, Date.now());
411
+ }
412
+ }
413
+
414
+ // Update state
415
+ user.fullRpi = request.fullRpi;
416
+ user.metadataKey = request.metadataKey;
417
+ user.paired = true;
418
+ user.hasIncomingRequest = false;
419
+ user.pairedAt = Date.now();
420
+ this.pendingRequests.delete(userId);
421
+ this.emitUpdate();
422
+
423
+ return {
424
+ success: true,
425
+ partnerRpi: request.fullRpi,
426
+ partnerMetadataKey: request.metadataKey
427
+ };
428
+ }
429
+
430
+ /**
431
+ * Handle incoming write from another device (GATT server callback).
432
+ * Called when another device writes to our RPI_IN or META_IN characteristics.
433
+ */
434
+ async handleIncomingPair(deviceId: string, rpi: string, metadataKey: string): Promise<void> {
435
+ let user = this.nearbyUsers.get(deviceId);
436
+
437
+ // Create user entry if not exists
438
+ if (!user) {
439
+ user = {
440
+ id: deviceId,
441
+ displayName: generateDisplayName(rpi.substring(0, 16)),
442
+ rssi: -50, // Assume close proximity for incoming connection
443
+ discoveredAt: Date.now(),
444
+ paired: false,
445
+ hasIncomingRequest: false,
446
+ rpiPrefix: rpi.substring(0, 16),
447
+ };
448
+ this.nearbyUsers.set(deviceId, user);
449
+ }
450
+
451
+ if (this.autoAccept) {
452
+ // Auto-accept: store immediately
453
+ if (this.storage) {
454
+ const canStore = this.storage.canScan(rpi);
455
+ if (canStore) {
456
+ await this.storage.logScan(rpi, metadataKey, Date.now());
457
+ }
458
+ }
459
+ user.fullRpi = rpi;
460
+ user.metadataKey = metadataKey;
461
+ user.paired = true;
462
+ user.hasIncomingRequest = false;
463
+ user.pairedAt = Date.now();
464
+ } else {
465
+ // Explicit consent mode: hold in memory
466
+ this.pendingRequests.set(deviceId, {
467
+ fullRpi: rpi,
468
+ metadataKey,
469
+ receivedAt: Date.now(),
470
+ });
471
+ user.hasIncomingRequest = true;
472
+ user.paired = false;
473
+ }
474
+
475
+ this.emitUpdate();
476
+ }
477
+
478
+ private async hasStoredRpi(rpi: string): Promise<boolean> {
479
+ if (!this.storage) return false;
480
+ // Check if we can scan (if not, it means we already have it)
481
+ return !this.storage.canScan(rpi);
482
+ }
483
+
484
+ private toPublicUser(internal: InternalNearbyUser): NearbyUser {
485
+ return {
486
+ id: internal.id,
487
+ displayName: internal.displayName,
488
+ rssi: internal.rssi,
489
+ discoveredAt: internal.discoveredAt,
490
+ paired: internal.paired,
491
+ hasIncomingRequest: internal.hasIncomingRequest,
492
+ };
493
+ }
494
+
495
+ private emitUpdate(): void {
496
+ if (this.onNearbyUpdated) {
497
+ const publicUsers = Array.from(this.nearbyUsers.values())
498
+ .filter(u => u.rssi >= this.proximityThreshold)
499
+ .map(u => this.toPublicUser(u));
500
+ this.onNearbyUpdated(publicUsers);
501
+ }
502
+ }
503
+ }
504
+
package/src/db.ts ADDED
@@ -0,0 +1,57 @@
1
+ import { drizzle } from 'drizzle-orm/expo-sqlite';
2
+ import { openDatabaseSync, deleteDatabaseSync } from 'expo-sqlite';
3
+ import { sql } from 'drizzle-orm';
4
+ import type { VailixDB } from './types';
5
+
6
+ const DB_NAME = 'vailix.db';
7
+
8
+ /**
9
+ * Initialize encrypted database using SQLCipher.
10
+ * If database exists but key doesn't match (e.g., restored backup with new key),
11
+ * the corrupted database is deleted and recreated fresh.
12
+ *
13
+ * @param masterKey The user's master key, used to derive encryption password
14
+ */
15
+ export async function initializeDatabase(masterKey: string): Promise<VailixDB> {
16
+ try {
17
+ return await openEncryptedDatabase(masterKey);
18
+ } catch (error) {
19
+ // Key mismatch: "file is not a database" or similar SQLCipher error
20
+ // This happens when DB was restored from backup but key is different
21
+ console.warn('Database key mismatch, recreating fresh database');
22
+ deleteDatabaseSync(DB_NAME);
23
+ return await openEncryptedDatabase(masterKey);
24
+ }
25
+ }
26
+
27
+ async function openEncryptedDatabase(masterKey: string): Promise<VailixDB> {
28
+ const expo = openDatabaseSync(DB_NAME);
29
+ const db = drizzle(expo);
30
+
31
+ // Enable SQLCipher encryption using master key as password
32
+ // This encrypts the entire database at rest (AES-256)
33
+ // Validate key is hex to prevent SQL injection
34
+ if (!/^[0-9a-f]+$/i.test(masterKey)) {
35
+ throw new Error('Invalid master key format');
36
+ }
37
+ await db.run(sql.raw(`PRAGMA key = '${masterKey}'`));
38
+
39
+ // Verify key works by attempting a read operation
40
+ // SQLCipher will throw if key is wrong
41
+ await db.run(sql`SELECT 1`);
42
+
43
+ await db.run(sql`
44
+ CREATE TABLE IF NOT EXISTS scanned_events (
45
+ id TEXT PRIMARY KEY,
46
+ rpi TEXT NOT NULL,
47
+ metadata_key TEXT NOT NULL,
48
+ timestamp INTEGER NOT NULL
49
+ )
50
+ `);
51
+
52
+ await db.run(sql`
53
+ CREATE INDEX IF NOT EXISTS rpi_idx ON scanned_events(rpi)
54
+ `);
55
+
56
+ return db;
57
+ }