@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/ANALYSIS_REPORT.md +211 -0
- package/BLE_IMPLEMENTATION_PLAN.md +703 -0
- package/CHANGELOG.md +13 -0
- package/LICENSE +21 -0
- package/README.md +365 -0
- package/dist/index.d.mts +347 -0
- package/dist/index.d.ts +347 -0
- package/dist/index.js +1095 -0
- package/dist/index.mjs +1058 -0
- package/package.json +62 -0
- package/src/ble.ts +504 -0
- package/src/db.ts +57 -0
- package/src/identity.ts +91 -0
- package/src/index.ts +375 -0
- package/src/matcher.ts +224 -0
- package/src/storage.ts +110 -0
- package/src/transport.ts +20 -0
- package/src/types.ts +100 -0
- package/src/utils.ts +20 -0
- package/tsconfig.json +15 -0
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
|
+
}
|