@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/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
|
+
};
|