@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/dist/index.mjs ADDED
@@ -0,0 +1,1058 @@
1
+ // src/index.ts
2
+ import { createCipheriv, randomBytes } from "react-native-quick-crypto";
3
+
4
+ // src/identity.ts
5
+ import { createHmac, randomUUID } from "react-native-quick-crypto";
6
+ import * as SecureStore from "expo-secure-store";
7
+
8
+ // src/utils.ts
9
+ var EMOJIS = ["\u{1F43C}", "\u{1F98A}", "\u{1F428}", "\u{1F981}", "\u{1F42F}", "\u{1F438}", "\u{1F98B}", "\u{1F419}", "\u{1F984}", "\u{1F433}"];
10
+ function generateDisplayName(rpiPrefix) {
11
+ const hash = rpiPrefix.split("").reduce((a, b) => a + b.charCodeAt(0), 0);
12
+ const emoji = EMOJIS[hash % EMOJIS.length];
13
+ const number = hash % 100;
14
+ return `${emoji} ${number}`;
15
+ }
16
+
17
+ // src/identity.ts
18
+ var MASTER_KEY_STORE = "vailix_master_key";
19
+ var DEFAULT_EPOCH_MS = 15 * 60 * 1e3;
20
+ var DefaultKeyStorage = class {
21
+ async getKey() {
22
+ return SecureStore.getItemAsync(MASTER_KEY_STORE);
23
+ }
24
+ async setKey(key) {
25
+ await SecureStore.setItemAsync(MASTER_KEY_STORE, key);
26
+ }
27
+ };
28
+ var IdentityManager = class {
29
+ masterKey = null;
30
+ epochMs;
31
+ keyStorage;
32
+ constructor(config = {}) {
33
+ this.epochMs = config.rpiDurationMs ?? DEFAULT_EPOCH_MS;
34
+ this.keyStorage = config.keyStorage ?? new DefaultKeyStorage();
35
+ }
36
+ async initialize() {
37
+ try {
38
+ let key = await this.keyStorage.getKey();
39
+ if (!key) {
40
+ key = randomUUID();
41
+ await this.keyStorage.setKey(key);
42
+ }
43
+ this.masterKey = key;
44
+ } catch (error) {
45
+ throw new Error(`Failed to initialize identity: ${error}`);
46
+ }
47
+ }
48
+ getCurrentRPI() {
49
+ if (!this.masterKey) throw new Error("Not initialized");
50
+ return this._generateRPI(Math.floor(Date.now() / this.epochMs));
51
+ }
52
+ // Get RPI history for the past N days
53
+ // Note: Synchronous HMAC computation. For STD apps (24h RPI) = 14 calls.
54
+ // For contact tracing (15min RPI) = 1,344 calls - acceptable on modern devices.
55
+ getHistory(days) {
56
+ if (!this.masterKey) throw new Error("Not initialized");
57
+ const epochsPerDay = 24 * 60 * 60 * 1e3 / this.epochMs;
58
+ const currentEpoch = Math.floor(Date.now() / this.epochMs);
59
+ return Array.from(
60
+ { length: days * epochsPerDay },
61
+ (_, i) => this._generateRPI(currentEpoch - i)
62
+ );
63
+ }
64
+ // Generate 128-bit Rolling Proximity Identifier (RPI) for the given epoch.
65
+ _generateRPI(epoch) {
66
+ return createHmac("sha256", this.masterKey).update(epoch.toString()).digest("hex").substring(0, 32);
67
+ }
68
+ // Generate a key specifically for encrypting metadata for an RPI
69
+ getMetadataKey(rpi) {
70
+ if (!this.masterKey) throw new Error("Not initialized");
71
+ return createHmac("sha256", this.masterKey).update(`meta:${rpi}`).digest("hex").substring(0, 64);
72
+ }
73
+ // Get master key for database encryption (SQLCipher)
74
+ getMasterKey() {
75
+ if (!this.masterKey) throw new Error("Not initialized");
76
+ return this.masterKey;
77
+ }
78
+ /**
79
+ * Get anonymous display name derived from current RPI.
80
+ * Used for showing user's identity in UI without revealing master key.
81
+ * Format: "Emoji Number" (e.g. "🐼 42")
82
+ */
83
+ getDisplayName() {
84
+ const rpi = this.getCurrentRPI();
85
+ return generateDisplayName(rpi.substring(0, 16));
86
+ }
87
+ };
88
+
89
+ // src/storage.ts
90
+ import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core";
91
+ import { lt, gt, inArray } from "drizzle-orm";
92
+ import { randomUUID as randomUUID2 } from "react-native-quick-crypto";
93
+ import AsyncStorage from "@react-native-async-storage/async-storage";
94
+ var SCAN_HISTORY_KEY = "vailix_scan_history";
95
+ var scannedEvents = sqliteTable("scanned_events", {
96
+ id: text("id").primaryKey(),
97
+ rpi: text("rpi").notNull(),
98
+ metadataKey: text("metadata_key").notNull(),
99
+ timestamp: integer("timestamp").notNull()
100
+ }, (t) => [index("rpi_idx").on(t.rpi)]);
101
+ var StorageService = class _StorageService {
102
+ constructor(db, config = {}) {
103
+ this.db = db;
104
+ this.rescanIntervalMs = config.rescanIntervalMs ?? 0;
105
+ }
106
+ rescanIntervalMs;
107
+ lastScanByRpi = /* @__PURE__ */ new Map();
108
+ // Limit scan history to prevent unbounded memory growth
109
+ static MAX_SCAN_HISTORY_SIZE = 1e4;
110
+ // Load persisted scan history on init
111
+ async initialize() {
112
+ const stored = await AsyncStorage.getItem(SCAN_HISTORY_KEY);
113
+ if (stored) {
114
+ const entries = JSON.parse(stored);
115
+ this.lastScanByRpi = new Map(entries);
116
+ }
117
+ }
118
+ // Check if rescan is allowed for this RPI
119
+ canScan(rpi) {
120
+ if (this.rescanIntervalMs === 0) return true;
121
+ const lastScan = this.lastScanByRpi.get(rpi);
122
+ if (!lastScan) return true;
123
+ return Date.now() - lastScan >= this.rescanIntervalMs;
124
+ }
125
+ async logScan(rpi, metadataKey, timestamp) {
126
+ await this.db.insert(scannedEvents).values({ id: randomUUID2(), rpi, metadataKey, timestamp });
127
+ this.lastScanByRpi.set(rpi, Date.now());
128
+ if (this.lastScanByRpi.size > _StorageService.MAX_SCAN_HISTORY_SIZE) {
129
+ const entries2 = Array.from(this.lastScanByRpi.entries());
130
+ entries2.sort((a, b) => a[1] - b[1]);
131
+ const toRemove = entries2.slice(0, entries2.length - _StorageService.MAX_SCAN_HISTORY_SIZE);
132
+ for (const [key] of toRemove) {
133
+ this.lastScanByRpi.delete(key);
134
+ }
135
+ }
136
+ const entries = Array.from(this.lastScanByRpi.entries());
137
+ await AsyncStorage.setItem(SCAN_HISTORY_KEY, JSON.stringify(entries));
138
+ }
139
+ async cleanupOldScans() {
140
+ const cutoff = Date.now() - 14 * 24 * 60 * 60 * 1e3;
141
+ await this.db.delete(scannedEvents).where(lt(scannedEvents.timestamp, cutoff));
142
+ if (this.rescanIntervalMs > 0) {
143
+ const now = Date.now();
144
+ for (const [rpi, lastScan] of this.lastScanByRpi) {
145
+ if (now - lastScan > this.rescanIntervalMs) {
146
+ this.lastScanByRpi.delete(rpi);
147
+ }
148
+ }
149
+ const entries = Array.from(this.lastScanByRpi.entries());
150
+ await AsyncStorage.setItem(SCAN_HISTORY_KEY, JSON.stringify(entries));
151
+ }
152
+ }
153
+ // Get only scans matching the given RPIs (efficient DB-level filtering)
154
+ // Batches queries to respect SQLite variable limits
155
+ async getMatchingScans(rpiList) {
156
+ if (rpiList.length === 0) return [];
157
+ const BATCH_SIZE = 500;
158
+ const results = [];
159
+ for (let i = 0; i < rpiList.length; i += BATCH_SIZE) {
160
+ const batch = rpiList.slice(i, i + BATCH_SIZE);
161
+ const batchResults = await this.db.select().from(scannedEvents).where(inArray(scannedEvents.rpi, batch));
162
+ results.push(...batchResults);
163
+ }
164
+ return results;
165
+ }
166
+ async getRecentPairs(withinHours = 24) {
167
+ const cutoff = Date.now() - withinHours * 60 * 60 * 1e3;
168
+ const recent = await this.db.select().from(scannedEvents).where(gt(scannedEvents.timestamp, cutoff)).orderBy(scannedEvents.timestamp);
169
+ return recent;
170
+ }
171
+ };
172
+
173
+ // src/matcher.ts
174
+ import AsyncStorage2 from "@react-native-async-storage/async-storage";
175
+ import { createDecipheriv } from "react-native-quick-crypto";
176
+ import { EventEmitter } from "eventemitter3";
177
+ var LAST_SYNC_KEY = "vailix_last_sync";
178
+ var MatcherService = class extends EventEmitter {
179
+ constructor(storage, downloadUrl, appSecret) {
180
+ super();
181
+ this.storage = storage;
182
+ this.downloadUrl = downloadUrl;
183
+ this.appSecret = appSecret;
184
+ }
185
+ async fetchAndMatch() {
186
+ try {
187
+ let lastSync = parseInt(await AsyncStorage2.getItem(LAST_SYNC_KEY) || "0", 10);
188
+ const allMatches = [];
189
+ let maxReportedAt = lastSync;
190
+ await this._downloadAndProcessKeys(lastSync, async (keys) => {
191
+ if (keys.length === 0) return;
192
+ const pageMax = Math.max(...keys.map((k) => k.reportedAt));
193
+ maxReportedAt = Math.max(maxReportedAt, pageMax);
194
+ const infectedMap = /* @__PURE__ */ new Map();
195
+ for (const key of keys) infectedMap.set(key.rpi, key);
196
+ const infectedRpis = Array.from(infectedMap.keys());
197
+ const matchingScans = await this.storage.getMatchingScans(infectedRpis);
198
+ for (const s of matchingScans) {
199
+ const serverKey = infectedMap.get(s.rpi);
200
+ await AsyncStorage2.setItem(`vailix_match_cache_${s.rpi}`, JSON.stringify({
201
+ encryptedMetadata: serverKey.metadata,
202
+ // Still encrypted from server
203
+ metadataKey: s.metadataKey,
204
+ // Decryption key
205
+ timestamp: s.timestamp,
206
+ reportedAt: serverKey.reportedAt
207
+ }));
208
+ allMatches.push({
209
+ rpi: s.rpi,
210
+ timestamp: s.timestamp,
211
+ metadata: void 0,
212
+ // Not decrypted yet!
213
+ reportedAt: serverKey.reportedAt
214
+ });
215
+ }
216
+ });
217
+ if (maxReportedAt > lastSync) {
218
+ await AsyncStorage2.setItem(LAST_SYNC_KEY, maxReportedAt.toString());
219
+ }
220
+ if (allMatches.length > 0) {
221
+ this.emit("match", allMatches);
222
+ }
223
+ await this.storage.cleanupOldScans();
224
+ return allMatches;
225
+ } catch (error) {
226
+ this.emit("error", error);
227
+ return [];
228
+ }
229
+ }
230
+ async _downloadAndProcessKeys(since, processor) {
231
+ let cursor = null;
232
+ do {
233
+ const url = new URL(`${this.downloadUrl}/v1/download`);
234
+ url.searchParams.set("since", since.toString());
235
+ if (cursor) url.searchParams.set("cursor", cursor);
236
+ url.searchParams.set("format", "bin");
237
+ const res = await fetch(url.toString(), {
238
+ headers: { "x-vailix-secret": this.appSecret }
239
+ });
240
+ if (!res.ok) throw new Error(`Server error: ${res.status}`);
241
+ const buffer = await res.arrayBuffer();
242
+ const keys = this._parseBinaryResponse(buffer);
243
+ await processor(keys);
244
+ cursor = res.headers.get("x-vailix-next-cursor") || null;
245
+ await new Promise((resolve) => setTimeout(resolve, 0));
246
+ } while (cursor);
247
+ }
248
+ _parseBinaryResponse(buffer) {
249
+ const view = new DataView(buffer);
250
+ const keys = [];
251
+ let offset = 0;
252
+ if (buffer.byteLength < 4) return [];
253
+ const count = view.getUint32(offset);
254
+ offset += 4;
255
+ for (let i = 0; i < count; i++) {
256
+ if (offset + 26 > buffer.byteLength) {
257
+ console.warn(`Binary response truncated at key ${i}/${count}`);
258
+ break;
259
+ }
260
+ const rpiBytes = new Uint8Array(buffer, offset, 16);
261
+ const rpi = Array.from(rpiBytes).map((b) => b.toString(16).padStart(2, "0")).join("");
262
+ offset += 16;
263
+ const reportedAt = view.getFloat64(offset);
264
+ offset += 8;
265
+ const metaLen = view.getUint16(offset);
266
+ offset += 2;
267
+ let metadata = void 0;
268
+ if (metaLen > 0) {
269
+ if (offset + metaLen > buffer.byteLength) {
270
+ console.warn(`Binary response truncated during metadata at key ${i}/${count}`);
271
+ break;
272
+ }
273
+ const metaBytes = new Uint8Array(buffer, offset, metaLen);
274
+ metadata = new TextDecoder("utf-8").decode(metaBytes);
275
+ offset += metaLen;
276
+ }
277
+ keys.push({ rpi, reportedAt, metadata });
278
+ }
279
+ return keys;
280
+ }
281
+ _decrypt(encryptedStr, keyHex) {
282
+ if (!encryptedStr) return void 0;
283
+ try {
284
+ const parts = encryptedStr.split(":");
285
+ if (parts.length !== 3) return void 0;
286
+ const [ivB64, authTagB64, encryptedB64] = parts;
287
+ const key = Buffer.from(keyHex, "hex");
288
+ const iv = Buffer.from(ivB64, "base64");
289
+ const authTag = Buffer.from(authTagB64, "base64");
290
+ const decipher = createDecipheriv("aes-256-gcm", key, iv);
291
+ decipher.setAuthTag(authTag);
292
+ let decrypted = decipher.update(encryptedB64, "base64", "utf8");
293
+ decrypted += decipher.final("utf8");
294
+ return JSON.parse(decrypted);
295
+ } catch (e) {
296
+ console.warn("Failed to decrypt metadata", e);
297
+ return void 0;
298
+ }
299
+ }
300
+ /**
301
+ * Retrieve a specific match by RPI with on-demand decryption.
302
+ * Used when app needs to display exposure details to user.
303
+ *
304
+ * PRIVACY: Decrypted metadata exists ONLY in-memory (return value).
305
+ * Never persisted to storage.
306
+ *
307
+ * @param rpi - The RPI (match ID) to retrieve
308
+ * @returns Match with decrypted metadata, or null if not found
309
+ */
310
+ async getMatchById(rpi) {
311
+ try {
312
+ const cachedData = await AsyncStorage2.getItem(`vailix_match_cache_${rpi}`);
313
+ if (!cachedData) {
314
+ console.warn(`Match ${rpi} not found in cache`);
315
+ return null;
316
+ }
317
+ const cached = JSON.parse(cachedData);
318
+ const decryptedMetadata = this._decrypt(
319
+ cached.encryptedMetadata,
320
+ cached.metadataKey
321
+ );
322
+ return {
323
+ rpi,
324
+ timestamp: cached.timestamp,
325
+ metadata: decryptedMetadata,
326
+ reportedAt: cached.reportedAt
327
+ };
328
+ } catch (error) {
329
+ console.error("Failed to get match by ID:", error);
330
+ return null;
331
+ }
332
+ }
333
+ };
334
+
335
+ // src/ble.ts
336
+ import { BleManager, State } from "react-native-ble-plx";
337
+ var VAILIX_SERVICE_UUID = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
338
+ var RPI_OUT_CHAR_UUID = "a1b2c3d4-e5f6-7890-abcd-ef1234567891";
339
+ var RPI_IN_CHAR_UUID = "a1b2c3d4-e5f6-7890-abcd-ef1234567892";
340
+ var META_OUT_CHAR_UUID = "a1b2c3d4-e5f6-7890-abcd-ef1234567893";
341
+ var META_IN_CHAR_UUID = "a1b2c3d4-e5f6-7890-abcd-ef1234567894";
342
+ var DEFAULT_DISCOVERY_TIMEOUT_MS = 15e3;
343
+ var DEFAULT_PROXIMITY_THRESHOLD = -70;
344
+ function extractRpiPrefix(device, serviceUUID) {
345
+ const serviceData = device.serviceData;
346
+ if (serviceData && serviceData[serviceUUID]) {
347
+ const data = serviceData[serviceUUID];
348
+ if (data && data.length >= 16) {
349
+ return data.substring(0, 16);
350
+ }
351
+ }
352
+ const mfgData = device.manufacturerData;
353
+ if (mfgData && mfgData.length >= 16) {
354
+ return mfgData.substring(0, 16);
355
+ }
356
+ return null;
357
+ }
358
+ var BleService = class {
359
+ manager;
360
+ isScanning = false;
361
+ nearbyUsers = /* @__PURE__ */ new Map();
362
+ pendingRequests = /* @__PURE__ */ new Map();
363
+ // Configuration
364
+ discoveryTimeoutMs;
365
+ proximityThreshold;
366
+ autoAccept;
367
+ serviceUUID;
368
+ // State
369
+ cleanupInterval;
370
+ scanSubscription;
371
+ onNearbyUpdated;
372
+ // Identity (set when discovery starts)
373
+ myRpi;
374
+ myMetadataKey;
375
+ // Storage reference for persisting pairs
376
+ storage;
377
+ constructor(config = {}) {
378
+ this.manager = new BleManager();
379
+ this.discoveryTimeoutMs = config.discoveryTimeoutMs ?? DEFAULT_DISCOVERY_TIMEOUT_MS;
380
+ this.proximityThreshold = config.proximityThreshold ?? DEFAULT_PROXIMITY_THRESHOLD;
381
+ this.autoAccept = config.autoAccept ?? true;
382
+ this.serviceUUID = config.serviceUUID ?? VAILIX_SERVICE_UUID;
383
+ }
384
+ /**
385
+ * Set storage service reference (called by SDK)
386
+ */
387
+ setStorage(storage) {
388
+ this.storage = storage;
389
+ }
390
+ /**
391
+ * Check if BLE is available and enabled
392
+ */
393
+ async initialize() {
394
+ return new Promise((resolve) => {
395
+ const subscription = this.manager.onStateChange((state) => {
396
+ if (state === State.PoweredOn) {
397
+ subscription.remove();
398
+ resolve(true);
399
+ } else if (state === State.PoweredOff || state === State.Unauthorized) {
400
+ subscription.remove();
401
+ resolve(false);
402
+ }
403
+ }, true);
404
+ });
405
+ }
406
+ /**
407
+ * Check if BLE is supported on this device
408
+ */
409
+ static async isSupported() {
410
+ const manager = new BleManager();
411
+ return new Promise((resolve) => {
412
+ const subscription = manager.onStateChange((state) => {
413
+ subscription.remove();
414
+ manager.destroy();
415
+ resolve(state !== State.Unsupported);
416
+ }, true);
417
+ });
418
+ }
419
+ /**
420
+ * Start advertising our RPI + scanning for others.
421
+ * Call when pairing screen opens.
422
+ */
423
+ async startDiscovery(myRpi, myMetadataKey) {
424
+ if (this.isScanning) return;
425
+ this.myRpi = myRpi;
426
+ this.myMetadataKey = myMetadataKey;
427
+ this.isScanning = true;
428
+ this.nearbyUsers.clear();
429
+ this.startCleanupInterval();
430
+ await this.startScanning();
431
+ }
432
+ /**
433
+ * Stop advertising and scanning.
434
+ * Call when leaving pairing screen.
435
+ */
436
+ async stopDiscovery() {
437
+ this.isScanning = false;
438
+ if (this.scanSubscription) {
439
+ this.scanSubscription.remove();
440
+ this.scanSubscription = void 0;
441
+ }
442
+ this.manager.stopDeviceScan();
443
+ if (this.cleanupInterval) {
444
+ clearInterval(this.cleanupInterval);
445
+ this.cleanupInterval = void 0;
446
+ }
447
+ this.myRpi = void 0;
448
+ this.myMetadataKey = void 0;
449
+ }
450
+ /**
451
+ * Get current list of nearby users (public type without internal fields)
452
+ */
453
+ getNearbyUsers() {
454
+ return Array.from(this.nearbyUsers.values()).filter((u) => u.rssi >= this.proximityThreshold).map(this.toPublicUser);
455
+ }
456
+ /**
457
+ * Subscribe to nearby user updates.
458
+ * Returns cleanup function for React useEffect compatibility.
459
+ */
460
+ onNearbyUsersChanged(callback) {
461
+ this.onNearbyUpdated = callback;
462
+ return () => {
463
+ this.onNearbyUpdated = void 0;
464
+ };
465
+ }
466
+ /**
467
+ * Initiate pairing with a specific user.
468
+ * In explicit consent mode, this also accepts pending incoming requests.
469
+ */
470
+ async pairWithUser(userId) {
471
+ const internalUser = this.nearbyUsers.get(userId);
472
+ if (!internalUser) {
473
+ return { success: false, error: "User not found" };
474
+ }
475
+ const pendingRequest = this.pendingRequests.get(userId);
476
+ if (pendingRequest && !this.autoAccept) {
477
+ return this.acceptPendingRequest(userId, pendingRequest);
478
+ }
479
+ try {
480
+ const result = await this.doExchange(internalUser);
481
+ return result;
482
+ } catch (error) {
483
+ if (internalUser.fullRpi && this.storage) {
484
+ const alreadyPaired = await this.hasStoredRpi(internalUser.fullRpi);
485
+ if (alreadyPaired) {
486
+ return { success: true, partnerRpi: internalUser.fullRpi };
487
+ }
488
+ }
489
+ return {
490
+ success: false,
491
+ error: error instanceof Error ? error.message : "Unknown error"
492
+ };
493
+ }
494
+ }
495
+ /**
496
+ * Unpair with a user (removes from storage and resets status)
497
+ */
498
+ async unpairUser(userId) {
499
+ const internalUser = this.nearbyUsers.get(userId);
500
+ if (internalUser) {
501
+ internalUser.paired = false;
502
+ internalUser.hasIncomingRequest = false;
503
+ internalUser.fullRpi = void 0;
504
+ internalUser.metadataKey = void 0;
505
+ this.pendingRequests.delete(userId);
506
+ this.emitUpdate();
507
+ }
508
+ }
509
+ /**
510
+ * Cleanup resources
511
+ */
512
+ destroy() {
513
+ this.stopDiscovery();
514
+ this.manager.destroy();
515
+ }
516
+ // ========================================================================
517
+ // Private Methods
518
+ // ========================================================================
519
+ async startScanning() {
520
+ this.manager.startDeviceScan(
521
+ [this.serviceUUID],
522
+ { allowDuplicates: true },
523
+ (error, device) => {
524
+ if (error) {
525
+ console.warn("BLE scan error:", error);
526
+ return;
527
+ }
528
+ if (device) {
529
+ this.handleDiscoveredDevice(device);
530
+ }
531
+ }
532
+ );
533
+ }
534
+ handleDiscoveredDevice(device) {
535
+ const rpiPrefix = extractRpiPrefix(device, this.serviceUUID);
536
+ if (!rpiPrefix) return;
537
+ const existingUser = this.nearbyUsers.get(device.id);
538
+ if (existingUser) {
539
+ existingUser.rssi = device.rssi ?? -100;
540
+ existingUser.discoveredAt = Date.now();
541
+ } else {
542
+ const newUser = {
543
+ id: device.id,
544
+ displayName: generateDisplayName(rpiPrefix),
545
+ rssi: device.rssi ?? -100,
546
+ discoveredAt: Date.now(),
547
+ paired: false,
548
+ hasIncomingRequest: false,
549
+ rpiPrefix
550
+ };
551
+ this.nearbyUsers.set(device.id, newUser);
552
+ }
553
+ this.emitUpdate();
554
+ }
555
+ startCleanupInterval() {
556
+ this.cleanupInterval = setInterval(() => {
557
+ const now = Date.now();
558
+ let changed = false;
559
+ for (const [id, user] of this.nearbyUsers) {
560
+ if (now - user.discoveredAt > this.discoveryTimeoutMs) {
561
+ this.nearbyUsers.delete(id);
562
+ this.pendingRequests.delete(id);
563
+ changed = true;
564
+ }
565
+ }
566
+ if (changed) {
567
+ this.emitUpdate();
568
+ }
569
+ }, 1e3);
570
+ }
571
+ async doExchange(user) {
572
+ if (!this.myRpi || !this.myMetadataKey) {
573
+ return { success: false, error: "Discovery not started" };
574
+ }
575
+ let connectedDevice = null;
576
+ try {
577
+ connectedDevice = await this.manager.connectToDevice(user.id, {
578
+ timeout: 1e4
579
+ });
580
+ await connectedDevice.discoverAllServicesAndCharacteristics();
581
+ const rpiChar = await connectedDevice.readCharacteristicForService(
582
+ this.serviceUUID,
583
+ RPI_OUT_CHAR_UUID
584
+ );
585
+ const partnerRpi = rpiChar.value ? Buffer.from(rpiChar.value, "base64").toString("hex") : null;
586
+ const metaChar = await connectedDevice.readCharacteristicForService(
587
+ this.serviceUUID,
588
+ META_OUT_CHAR_UUID
589
+ );
590
+ const partnerMetadataKey = metaChar.value ? Buffer.from(metaChar.value, "base64").toString("hex") : null;
591
+ if (!partnerRpi || !partnerMetadataKey) {
592
+ return { success: false, error: "Failed to read partner data" };
593
+ }
594
+ const myRpiBase64 = Buffer.from(this.myRpi, "hex").toString("base64");
595
+ await connectedDevice.writeCharacteristicWithResponseForService(
596
+ this.serviceUUID,
597
+ RPI_IN_CHAR_UUID,
598
+ myRpiBase64
599
+ );
600
+ const myMetaBase64 = Buffer.from(this.myMetadataKey, "hex").toString("base64");
601
+ await connectedDevice.writeCharacteristicWithResponseForService(
602
+ this.serviceUUID,
603
+ META_IN_CHAR_UUID,
604
+ myMetaBase64
605
+ );
606
+ if (this.storage) {
607
+ const canStore = this.storage.canScan(partnerRpi);
608
+ if (canStore) {
609
+ await this.storage.logScan(partnerRpi, partnerMetadataKey, Date.now());
610
+ }
611
+ }
612
+ user.fullRpi = partnerRpi;
613
+ user.metadataKey = partnerMetadataKey;
614
+ user.paired = true;
615
+ user.hasIncomingRequest = false;
616
+ user.pairedAt = Date.now();
617
+ this.emitUpdate();
618
+ return {
619
+ success: true,
620
+ partnerRpi,
621
+ partnerMetadataKey
622
+ };
623
+ } catch (error) {
624
+ const message = error instanceof Error ? error.message : "Connection failed";
625
+ return { success: false, error: message };
626
+ } finally {
627
+ if (connectedDevice) {
628
+ try {
629
+ await this.manager.cancelDeviceConnection(connectedDevice.id);
630
+ } catch {
631
+ }
632
+ }
633
+ }
634
+ }
635
+ async acceptPendingRequest(userId, request) {
636
+ const user = this.nearbyUsers.get(userId);
637
+ if (!user) {
638
+ return { success: false, error: "User not found" };
639
+ }
640
+ if (this.storage) {
641
+ const canStore = this.storage.canScan(request.fullRpi);
642
+ if (canStore) {
643
+ await this.storage.logScan(request.fullRpi, request.metadataKey, Date.now());
644
+ }
645
+ }
646
+ user.fullRpi = request.fullRpi;
647
+ user.metadataKey = request.metadataKey;
648
+ user.paired = true;
649
+ user.hasIncomingRequest = false;
650
+ user.pairedAt = Date.now();
651
+ this.pendingRequests.delete(userId);
652
+ this.emitUpdate();
653
+ return {
654
+ success: true,
655
+ partnerRpi: request.fullRpi,
656
+ partnerMetadataKey: request.metadataKey
657
+ };
658
+ }
659
+ /**
660
+ * Handle incoming write from another device (GATT server callback).
661
+ * Called when another device writes to our RPI_IN or META_IN characteristics.
662
+ */
663
+ async handleIncomingPair(deviceId, rpi, metadataKey) {
664
+ let user = this.nearbyUsers.get(deviceId);
665
+ if (!user) {
666
+ user = {
667
+ id: deviceId,
668
+ displayName: generateDisplayName(rpi.substring(0, 16)),
669
+ rssi: -50,
670
+ // Assume close proximity for incoming connection
671
+ discoveredAt: Date.now(),
672
+ paired: false,
673
+ hasIncomingRequest: false,
674
+ rpiPrefix: rpi.substring(0, 16)
675
+ };
676
+ this.nearbyUsers.set(deviceId, user);
677
+ }
678
+ if (this.autoAccept) {
679
+ if (this.storage) {
680
+ const canStore = this.storage.canScan(rpi);
681
+ if (canStore) {
682
+ await this.storage.logScan(rpi, metadataKey, Date.now());
683
+ }
684
+ }
685
+ user.fullRpi = rpi;
686
+ user.metadataKey = metadataKey;
687
+ user.paired = true;
688
+ user.hasIncomingRequest = false;
689
+ user.pairedAt = Date.now();
690
+ } else {
691
+ this.pendingRequests.set(deviceId, {
692
+ fullRpi: rpi,
693
+ metadataKey,
694
+ receivedAt: Date.now()
695
+ });
696
+ user.hasIncomingRequest = true;
697
+ user.paired = false;
698
+ }
699
+ this.emitUpdate();
700
+ }
701
+ async hasStoredRpi(rpi) {
702
+ if (!this.storage) return false;
703
+ return !this.storage.canScan(rpi);
704
+ }
705
+ toPublicUser(internal) {
706
+ return {
707
+ id: internal.id,
708
+ displayName: internal.displayName,
709
+ rssi: internal.rssi,
710
+ discoveredAt: internal.discoveredAt,
711
+ paired: internal.paired,
712
+ hasIncomingRequest: internal.hasIncomingRequest
713
+ };
714
+ }
715
+ emitUpdate() {
716
+ if (this.onNearbyUpdated) {
717
+ const publicUsers = Array.from(this.nearbyUsers.values()).filter((u) => u.rssi >= this.proximityThreshold).map((u) => this.toPublicUser(u));
718
+ this.onNearbyUpdated(publicUsers);
719
+ }
720
+ }
721
+ };
722
+
723
+ // src/transport.ts
724
+ var VERSION = "v1";
725
+ function formatQR(rpi, metadataKey) {
726
+ return `proto:${VERSION}:${rpi}:${Date.now()}:${metadataKey}`;
727
+ }
728
+ function parseQR(data) {
729
+ const p = data.split(":");
730
+ if (p.length !== 5 || p[0] !== "proto" || p[1] !== VERSION) return null;
731
+ const ts = parseInt(p[3], 10);
732
+ if (isNaN(ts)) return null;
733
+ return { rpi: p[2], timestamp: ts, metadataKey: p[4] };
734
+ }
735
+
736
+ // src/db.ts
737
+ import { drizzle } from "drizzle-orm/expo-sqlite";
738
+ import { openDatabaseSync, deleteDatabaseSync } from "expo-sqlite";
739
+ import { sql } from "drizzle-orm";
740
+ var DB_NAME = "vailix.db";
741
+ async function initializeDatabase(masterKey) {
742
+ try {
743
+ return await openEncryptedDatabase(masterKey);
744
+ } catch (error) {
745
+ console.warn("Database key mismatch, recreating fresh database");
746
+ deleteDatabaseSync(DB_NAME);
747
+ return await openEncryptedDatabase(masterKey);
748
+ }
749
+ }
750
+ async function openEncryptedDatabase(masterKey) {
751
+ const expo = openDatabaseSync(DB_NAME);
752
+ const db = drizzle(expo);
753
+ if (!/^[0-9a-f]+$/i.test(masterKey)) {
754
+ throw new Error("Invalid master key format");
755
+ }
756
+ await db.run(sql.raw(`PRAGMA key = '${masterKey}'`));
757
+ await db.run(sql`SELECT 1`);
758
+ await db.run(sql`
759
+ CREATE TABLE IF NOT EXISTS scanned_events (
760
+ id TEXT PRIMARY KEY,
761
+ rpi TEXT NOT NULL,
762
+ metadata_key TEXT NOT NULL,
763
+ timestamp INTEGER NOT NULL
764
+ )
765
+ `);
766
+ await db.run(sql`
767
+ CREATE INDEX IF NOT EXISTS rpi_idx ON scanned_events(rpi)
768
+ `);
769
+ return db;
770
+ }
771
+
772
+ // src/index.ts
773
+ var VailixSDK = class _VailixSDK {
774
+ identity;
775
+ storage;
776
+ matcher;
777
+ ble;
778
+ reportUrl;
779
+ appSecret;
780
+ reportDays;
781
+ rpiDurationMs;
782
+ constructor(identity, storage, matcher, ble, reportUrl, appSecret, reportDays, rpiDurationMs) {
783
+ this.identity = identity;
784
+ this.storage = storage;
785
+ this.matcher = matcher;
786
+ this.ble = ble;
787
+ this.reportUrl = reportUrl;
788
+ this.appSecret = appSecret;
789
+ this.reportDays = reportDays;
790
+ this.rpiDurationMs = rpiDurationMs;
791
+ }
792
+ /**
793
+ * Create and initialize the VailixSDK.
794
+ *
795
+ * @param config - Unified configuration object
796
+ */
797
+ static async create(config) {
798
+ const rpiDuration = config.rpiDurationMs ?? 15 * 60 * 1e3;
799
+ if (config.rescanIntervalMs && config.rescanIntervalMs > rpiDuration) {
800
+ throw new Error(`rescanIntervalMs (${config.rescanIntervalMs}) cannot exceed rpiDurationMs (${rpiDuration})`);
801
+ }
802
+ const identity = new IdentityManager({
803
+ rpiDurationMs: config.rpiDurationMs,
804
+ keyStorage: config.keyStorage
805
+ });
806
+ await identity.initialize();
807
+ const masterKey = identity.getMasterKey();
808
+ const db = await initializeDatabase(masterKey);
809
+ const storage = new StorageService(db, {
810
+ rescanIntervalMs: config.rescanIntervalMs
811
+ });
812
+ await storage.initialize();
813
+ const matcher = new MatcherService(storage, config.downloadUrl, config.appSecret);
814
+ const ble = new BleService({
815
+ discoveryTimeoutMs: config.bleDiscoveryTimeoutMs,
816
+ proximityThreshold: config.proximityThreshold,
817
+ autoAccept: config.autoAcceptIncomingPairs,
818
+ serviceUUID: config.serviceUUID
819
+ });
820
+ ble.setStorage(storage);
821
+ await storage.cleanupOldScans();
822
+ return new _VailixSDK(
823
+ identity,
824
+ storage,
825
+ matcher,
826
+ ble,
827
+ config.reportUrl,
828
+ config.appSecret,
829
+ config.reportDays ?? 14,
830
+ rpiDuration
831
+ );
832
+ }
833
+ // ========================================================================
834
+ // QR Code Methods
835
+ // ========================================================================
836
+ /** Get current QR code data */
837
+ getQRCode() {
838
+ const rpi = this.identity.getCurrentRPI();
839
+ const metaKey = this.identity.getMetadataKey(rpi);
840
+ return formatQR(rpi, metaKey);
841
+ }
842
+ /**
843
+ * Scan another user's QR code and log it.
844
+ * Returns false if: QR invalid, expired (>RPI duration), rescan blocked, or error
845
+ */
846
+ async scanQR(qrData) {
847
+ try {
848
+ const parsed = parseQR(qrData);
849
+ if (!parsed) return false;
850
+ if (Date.now() - parsed.timestamp > this.rpiDurationMs) {
851
+ return false;
852
+ }
853
+ if (!this.storage.canScan(parsed.rpi)) {
854
+ return false;
855
+ }
856
+ await this.storage.logScan(parsed.rpi, parsed.metadataKey, Date.now());
857
+ return true;
858
+ } catch (error) {
859
+ this.matcher.emit("error", error);
860
+ return false;
861
+ }
862
+ }
863
+ // ========================================================================
864
+ // BLE Discovery & Pairing Methods
865
+ // ========================================================================
866
+ /**
867
+ * Check if BLE is supported on this device.
868
+ */
869
+ static async isBleSupported() {
870
+ return BleService.isSupported();
871
+ }
872
+ /**
873
+ * Start BLE discovery (call when pairing screen opens).
874
+ * Begins advertising our RPI and scanning for nearby users.
875
+ */
876
+ async startDiscovery() {
877
+ const rpi = this.identity.getCurrentRPI();
878
+ const metaKey = this.identity.getMetadataKey(rpi);
879
+ await this.ble.startDiscovery(rpi, metaKey);
880
+ }
881
+ /**
882
+ * Stop BLE discovery (call when leaving pairing screen).
883
+ */
884
+ async stopDiscovery() {
885
+ await this.ble.stopDiscovery();
886
+ }
887
+ /**
888
+ * Get current list of nearby users.
889
+ */
890
+ getNearbyUsers() {
891
+ return this.ble.getNearbyUsers();
892
+ }
893
+ /**
894
+ * Subscribe to nearby user updates.
895
+ * Returns cleanup function for React useEffect compatibility.
896
+ *
897
+ * @example
898
+ * useEffect(() => {
899
+ * const cleanup = sdk.onNearbyUsersChanged(setNearbyUsers);
900
+ * return cleanup;
901
+ * }, []);
902
+ */
903
+ onNearbyUsersChanged(callback) {
904
+ return this.ble.onNearbyUsersChanged(callback);
905
+ }
906
+ /**
907
+ * Pair with a specific user (one-tap action).
908
+ * In explicit consent mode, this also accepts pending incoming requests.
909
+ *
910
+ * @param userId - The user's ID from NearbyUser.id
911
+ */
912
+ async pairWithUser(userId) {
913
+ return this.ble.pairWithUser(userId);
914
+ }
915
+ /**
916
+ * Unpair with a user (removes from storage and resets status).
917
+ * Useful for "undo" functionality or rejecting requests.
918
+ *
919
+ * @param userId - The user's ID from NearbyUser.id
920
+ */
921
+ async unpairUser(userId) {
922
+ return this.ble.unpairUser(userId);
923
+ }
924
+ /**
925
+ * Get list of recent pairings from storage (for history/recap features).
926
+ * Returns pairs from the last N hours (default: 24h).
927
+ *
928
+ * @param withinHours - Look back window in hours (default: 24)
929
+ * @returns Array of NearbyUser objects representing recent pairs
930
+ */
931
+ async getRecentPairs(withinHours = 24) {
932
+ const recentScans = await this.storage.getRecentPairs(withinHours);
933
+ return recentScans.map((scan) => ({
934
+ id: scan.rpi,
935
+ // Use RPI as ID for history items
936
+ displayName: `User-${scan.rpi.substring(0, 5)}`,
937
+ // Generate display name from RPI
938
+ rssi: -50,
939
+ // Fixed value for history (not actively scanned)
940
+ discoveredAt: scan.timestamp,
941
+ paired: true,
942
+ hasIncomingRequest: false
943
+ }));
944
+ }
945
+ // ========================================================================
946
+ // Reporting Methods
947
+ // ========================================================================
948
+ /**
949
+ * Report positive (upload configured days of history).
950
+ *
951
+ * @param attestToken - Optional attestation token (e.g., Firebase App Check)
952
+ * @param metadata - App-specific data (e.g., STD type, test date). If null, a generic positive is reported.
953
+ * @param overrideReportDays - Optional: Override reportDays for this specific report
954
+ * (e.g., for apps with per-condition exposure windows)
955
+ */
956
+ async report(attestToken, metadata, overrideReportDays) {
957
+ try {
958
+ const daysToReport = overrideReportDays ?? this.reportDays;
959
+ const keys = this.identity.getHistory(daysToReport);
960
+ const reports = keys.map((rpi) => ({
961
+ rpi,
962
+ encryptedMetadata: this._encrypt(metadata, this.identity.getMetadataKey(rpi))
963
+ }));
964
+ const headers = {
965
+ "Content-Type": "application/json",
966
+ "x-vailix-secret": this.appSecret
967
+ };
968
+ if (attestToken) {
969
+ headers["x-attest-token"] = attestToken;
970
+ }
971
+ const res = await fetch(`${this.reportUrl}/v1/report`, {
972
+ method: "POST",
973
+ headers,
974
+ body: JSON.stringify({ reports })
975
+ });
976
+ return res.ok;
977
+ } catch (error) {
978
+ this.matcher.emit("error", error);
979
+ return false;
980
+ }
981
+ }
982
+ // ========================================================================
983
+ // Encryption Helpers
984
+ // ========================================================================
985
+ // Max metadata size: 8KB (leaves headroom under 64KB binary format limit)
986
+ static MAX_METADATA_SIZE = 8 * 1024;
987
+ _encrypt(metadata, keyHex) {
988
+ if (!metadata) return "";
989
+ const jsonStr = JSON.stringify(metadata);
990
+ if (jsonStr.length > _VailixSDK.MAX_METADATA_SIZE) {
991
+ throw new Error(`Metadata exceeds maximum size of ${_VailixSDK.MAX_METADATA_SIZE} bytes`);
992
+ }
993
+ const key = Buffer.from(keyHex, "hex");
994
+ const iv = randomBytes(12);
995
+ const cipher = createCipheriv("aes-256-gcm", key, iv);
996
+ let encrypted = cipher.update(jsonStr, "utf8", "base64");
997
+ encrypted += cipher.final("base64");
998
+ const authTag = cipher.getAuthTag().toString("base64");
999
+ return `${iv.toString("base64")}:${authTag}:${encrypted}`;
1000
+ }
1001
+ // ========================================================================
1002
+ // Event Handlers
1003
+ // ========================================================================
1004
+ /**
1005
+ * Subscribe to match events.
1006
+ * Returns cleanup function for React useEffect compatibility.
1007
+ */
1008
+ onMatch(handler) {
1009
+ this.matcher.on("match", handler);
1010
+ return () => this.matcher.off("match", handler);
1011
+ }
1012
+ /**
1013
+ * Subscribe to error events.
1014
+ * Returns cleanup function for React useEffect compatibility.
1015
+ */
1016
+ onError(handler) {
1017
+ this.matcher.on("error", handler);
1018
+ return () => this.matcher.off("error", handler);
1019
+ }
1020
+ /** Explicit cleanup for match handler */
1021
+ offMatch(handler) {
1022
+ this.matcher.off("match", handler);
1023
+ }
1024
+ /** Explicit cleanup for error handler */
1025
+ offError(handler) {
1026
+ this.matcher.off("error", handler);
1027
+ }
1028
+ // ========================================================================
1029
+ // Match Retrieval Methods (for on-demand decryption)
1030
+ // ========================================================================
1031
+ /**
1032
+ * Get a specific match by ID with decrypted metadata.
1033
+ * Used for on-demand decryption when user views exposure details.
1034
+ *
1035
+ * @param matchId - RPI of the match to retrieve
1036
+ * @returns Match with decrypted metadata, or null if not found
1037
+ *
1038
+ * @example
1039
+ * // App calls this when user taps on notification
1040
+ * const match = await sdk.getMatchById('abc123');
1041
+ * console.log(match.metadata.conditions); // ["HIV", "Syphilis"]
1042
+ */
1043
+ async getMatchById(matchId) {
1044
+ return this.matcher.getMatchById(matchId);
1045
+ }
1046
+ /**
1047
+ * Get own display name (emoji + number derived from current RPI).
1048
+ * Used for showing user's anonymous identity in UI.
1049
+ */
1050
+ getOwnDisplayName() {
1051
+ return this.identity.getDisplayName();
1052
+ }
1053
+ };
1054
+ export {
1055
+ VailixSDK,
1056
+ formatQR,
1057
+ parseQR
1058
+ };