@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/src/identity.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { createHmac, randomUUID } from 'react-native-quick-crypto';
|
|
2
|
+
import * as SecureStore from 'expo-secure-store';
|
|
3
|
+
import type { KeyStorage } from './types';
|
|
4
|
+
import { generateDisplayName } from './utils';
|
|
5
|
+
|
|
6
|
+
const MASTER_KEY_STORE = 'vailix_master_key';
|
|
7
|
+
const DEFAULT_EPOCH_MS = 15 * 60 * 1000; // 15 minutes default
|
|
8
|
+
|
|
9
|
+
// Default key storage using expo-secure-store (device-only)
|
|
10
|
+
class DefaultKeyStorage implements KeyStorage {
|
|
11
|
+
async getKey(): Promise<string | null> {
|
|
12
|
+
return SecureStore.getItemAsync(MASTER_KEY_STORE);
|
|
13
|
+
}
|
|
14
|
+
async setKey(key: string): Promise<void> {
|
|
15
|
+
await SecureStore.setItemAsync(MASTER_KEY_STORE, key);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class IdentityManager {
|
|
20
|
+
private masterKey: string | null = null;
|
|
21
|
+
private epochMs: number;
|
|
22
|
+
private keyStorage: KeyStorage;
|
|
23
|
+
|
|
24
|
+
constructor(config: { rpiDurationMs?: number; keyStorage?: KeyStorage } = {}) {
|
|
25
|
+
this.epochMs = config.rpiDurationMs ?? DEFAULT_EPOCH_MS;
|
|
26
|
+
this.keyStorage = config.keyStorage ?? new DefaultKeyStorage();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async initialize(): Promise<void> {
|
|
30
|
+
try {
|
|
31
|
+
let key = await this.keyStorage.getKey();
|
|
32
|
+
if (!key) {
|
|
33
|
+
key = randomUUID();
|
|
34
|
+
await this.keyStorage.setKey(key);
|
|
35
|
+
}
|
|
36
|
+
this.masterKey = key;
|
|
37
|
+
} catch (error) {
|
|
38
|
+
throw new Error(`Failed to initialize identity: ${error}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
getCurrentRPI(): string {
|
|
43
|
+
if (!this.masterKey) throw new Error('Not initialized');
|
|
44
|
+
return this._generateRPI(Math.floor(Date.now() / this.epochMs));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Get RPI history for the past N days
|
|
48
|
+
// Note: Synchronous HMAC computation. For STD apps (24h RPI) = 14 calls.
|
|
49
|
+
// For contact tracing (15min RPI) = 1,344 calls - acceptable on modern devices.
|
|
50
|
+
getHistory(days: number): string[] {
|
|
51
|
+
if (!this.masterKey) throw new Error('Not initialized');
|
|
52
|
+
const epochsPerDay = (24 * 60 * 60 * 1000) / this.epochMs;
|
|
53
|
+
const currentEpoch = Math.floor(Date.now() / this.epochMs);
|
|
54
|
+
return Array.from({ length: days * epochsPerDay }, (_, i) =>
|
|
55
|
+
this._generateRPI(currentEpoch - i)
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Generate 128-bit Rolling Proximity Identifier (RPI) for the given epoch.
|
|
60
|
+
private _generateRPI(epoch: number): string {
|
|
61
|
+
return createHmac('sha256', this.masterKey!)
|
|
62
|
+
.update(epoch.toString())
|
|
63
|
+
.digest('hex')
|
|
64
|
+
.substring(0, 32);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Generate a key specifically for encrypting metadata for an RPI
|
|
68
|
+
getMetadataKey(rpi: string): string {
|
|
69
|
+
if (!this.masterKey) throw new Error('Not initialized');
|
|
70
|
+
return createHmac('sha256', this.masterKey!)
|
|
71
|
+
.update(`meta:${rpi}`)
|
|
72
|
+
.digest('hex')
|
|
73
|
+
.substring(0, 64); // 32 bytes for AES-256
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Get master key for database encryption (SQLCipher)
|
|
77
|
+
getMasterKey(): string {
|
|
78
|
+
if (!this.masterKey) throw new Error('Not initialized');
|
|
79
|
+
return this.masterKey;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Get anonymous display name derived from current RPI.
|
|
84
|
+
* Used for showing user's identity in UI without revealing master key.
|
|
85
|
+
* Format: "Emoji Number" (e.g. "🐼 42")
|
|
86
|
+
*/
|
|
87
|
+
getDisplayName(): string {
|
|
88
|
+
const rpi = this.getCurrentRPI();
|
|
89
|
+
return generateDisplayName(rpi.substring(0, 16));
|
|
90
|
+
}
|
|
91
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
import { createCipheriv, randomBytes } from 'react-native-quick-crypto';
|
|
2
|
+
import { IdentityManager } from './identity';
|
|
3
|
+
import { StorageService } from './storage';
|
|
4
|
+
import { MatcherService } from './matcher';
|
|
5
|
+
import { BleService } from './ble';
|
|
6
|
+
import { formatQR, parseQR } from './transport';
|
|
7
|
+
import { initializeDatabase } from './db';
|
|
8
|
+
import type {
|
|
9
|
+
Match,
|
|
10
|
+
MatchHandler,
|
|
11
|
+
ReportMetadata,
|
|
12
|
+
KeyStorage,
|
|
13
|
+
VailixConfig,
|
|
14
|
+
NearbyUser,
|
|
15
|
+
PairResult
|
|
16
|
+
} from './types';
|
|
17
|
+
|
|
18
|
+
export class VailixSDK {
|
|
19
|
+
public identity: IdentityManager;
|
|
20
|
+
public storage: StorageService;
|
|
21
|
+
public matcher: MatcherService;
|
|
22
|
+
private ble: BleService;
|
|
23
|
+
private reportUrl: string;
|
|
24
|
+
private appSecret: string;
|
|
25
|
+
private reportDays: number;
|
|
26
|
+
private rpiDurationMs: number;
|
|
27
|
+
|
|
28
|
+
private constructor(
|
|
29
|
+
identity: IdentityManager,
|
|
30
|
+
storage: StorageService,
|
|
31
|
+
matcher: MatcherService,
|
|
32
|
+
ble: BleService,
|
|
33
|
+
reportUrl: string,
|
|
34
|
+
appSecret: string,
|
|
35
|
+
reportDays: number,
|
|
36
|
+
rpiDurationMs: number
|
|
37
|
+
) {
|
|
38
|
+
this.identity = identity;
|
|
39
|
+
this.storage = storage;
|
|
40
|
+
this.matcher = matcher;
|
|
41
|
+
this.ble = ble;
|
|
42
|
+
this.reportUrl = reportUrl;
|
|
43
|
+
this.appSecret = appSecret;
|
|
44
|
+
this.reportDays = reportDays;
|
|
45
|
+
this.rpiDurationMs = rpiDurationMs;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Create and initialize the VailixSDK.
|
|
50
|
+
*
|
|
51
|
+
* @param config - Unified configuration object
|
|
52
|
+
*/
|
|
53
|
+
static async create(config: VailixConfig): Promise<VailixSDK> {
|
|
54
|
+
// Validate: rescanInterval cannot exceed rpiDuration
|
|
55
|
+
const rpiDuration = config.rpiDurationMs ?? 15 * 60 * 1000; // Default 15 min
|
|
56
|
+
if (config.rescanIntervalMs && config.rescanIntervalMs > rpiDuration) {
|
|
57
|
+
throw new Error(`rescanIntervalMs (${config.rescanIntervalMs}) cannot exceed rpiDurationMs (${rpiDuration})`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Initialize identity first to get master key for database encryption
|
|
61
|
+
const identity = new IdentityManager({
|
|
62
|
+
rpiDurationMs: config.rpiDurationMs,
|
|
63
|
+
keyStorage: config.keyStorage,
|
|
64
|
+
});
|
|
65
|
+
await identity.initialize();
|
|
66
|
+
|
|
67
|
+
// Initialize encrypted database (SQLCipher) using master key
|
|
68
|
+
const masterKey = identity.getMasterKey();
|
|
69
|
+
const db = await initializeDatabase(masterKey);
|
|
70
|
+
|
|
71
|
+
const storage = new StorageService(db, {
|
|
72
|
+
rescanIntervalMs: config.rescanIntervalMs
|
|
73
|
+
});
|
|
74
|
+
await storage.initialize(); // Load persisted scan history
|
|
75
|
+
|
|
76
|
+
const matcher = new MatcherService(storage, config.downloadUrl, config.appSecret);
|
|
77
|
+
|
|
78
|
+
// Initialize BLE service with config options
|
|
79
|
+
const ble = new BleService({
|
|
80
|
+
discoveryTimeoutMs: config.bleDiscoveryTimeoutMs,
|
|
81
|
+
proximityThreshold: config.proximityThreshold,
|
|
82
|
+
autoAccept: config.autoAcceptIncomingPairs,
|
|
83
|
+
serviceUUID: config.serviceUUID,
|
|
84
|
+
});
|
|
85
|
+
ble.setStorage(storage);
|
|
86
|
+
|
|
87
|
+
// Cleanup old scans on init
|
|
88
|
+
await storage.cleanupOldScans();
|
|
89
|
+
|
|
90
|
+
return new VailixSDK(
|
|
91
|
+
identity,
|
|
92
|
+
storage,
|
|
93
|
+
matcher,
|
|
94
|
+
ble,
|
|
95
|
+
config.reportUrl,
|
|
96
|
+
config.appSecret,
|
|
97
|
+
config.reportDays ?? 14,
|
|
98
|
+
rpiDuration
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ========================================================================
|
|
103
|
+
// QR Code Methods
|
|
104
|
+
// ========================================================================
|
|
105
|
+
|
|
106
|
+
/** Get current QR code data */
|
|
107
|
+
getQRCode(): string {
|
|
108
|
+
const rpi = this.identity.getCurrentRPI();
|
|
109
|
+
const metaKey = this.identity.getMetadataKey(rpi);
|
|
110
|
+
return formatQR(rpi, metaKey);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Scan another user's QR code and log it.
|
|
115
|
+
* Returns false if: QR invalid, expired (>RPI duration), rescan blocked, or error
|
|
116
|
+
*/
|
|
117
|
+
async scanQR(qrData: string): Promise<boolean> {
|
|
118
|
+
try {
|
|
119
|
+
const parsed = parseQR(qrData);
|
|
120
|
+
if (!parsed) return false;
|
|
121
|
+
|
|
122
|
+
// Reject QR codes older than RPI duration (QR is only valid while RPI is valid)
|
|
123
|
+
if (Date.now() - parsed.timestamp > this.rpiDurationMs) {
|
|
124
|
+
return false; // Expired QR
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Check rescan protection (returns false if scanned too recently)
|
|
128
|
+
if (!this.storage.canScan(parsed.rpi)) {
|
|
129
|
+
return false; // Rescan blocked
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
await this.storage.logScan(parsed.rpi, parsed.metadataKey, Date.now());
|
|
133
|
+
return true;
|
|
134
|
+
} catch (error) {
|
|
135
|
+
this.matcher.emit('error', error);
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ========================================================================
|
|
141
|
+
// BLE Discovery & Pairing Methods
|
|
142
|
+
// ========================================================================
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Check if BLE is supported on this device.
|
|
146
|
+
*/
|
|
147
|
+
static async isBleSupported(): Promise<boolean> {
|
|
148
|
+
return BleService.isSupported();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Start BLE discovery (call when pairing screen opens).
|
|
153
|
+
* Begins advertising our RPI and scanning for nearby users.
|
|
154
|
+
*/
|
|
155
|
+
async startDiscovery(): Promise<void> {
|
|
156
|
+
const rpi = this.identity.getCurrentRPI();
|
|
157
|
+
const metaKey = this.identity.getMetadataKey(rpi);
|
|
158
|
+
await this.ble.startDiscovery(rpi, metaKey);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Stop BLE discovery (call when leaving pairing screen).
|
|
163
|
+
*/
|
|
164
|
+
async stopDiscovery(): Promise<void> {
|
|
165
|
+
await this.ble.stopDiscovery();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Get current list of nearby users.
|
|
170
|
+
*/
|
|
171
|
+
getNearbyUsers(): NearbyUser[] {
|
|
172
|
+
return this.ble.getNearbyUsers();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Subscribe to nearby user updates.
|
|
177
|
+
* Returns cleanup function for React useEffect compatibility.
|
|
178
|
+
*
|
|
179
|
+
* @example
|
|
180
|
+
* useEffect(() => {
|
|
181
|
+
* const cleanup = sdk.onNearbyUsersChanged(setNearbyUsers);
|
|
182
|
+
* return cleanup;
|
|
183
|
+
* }, []);
|
|
184
|
+
*/
|
|
185
|
+
onNearbyUsersChanged(callback: (users: NearbyUser[]) => void): () => void {
|
|
186
|
+
return this.ble.onNearbyUsersChanged(callback);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Pair with a specific user (one-tap action).
|
|
191
|
+
* In explicit consent mode, this also accepts pending incoming requests.
|
|
192
|
+
*
|
|
193
|
+
* @param userId - The user's ID from NearbyUser.id
|
|
194
|
+
*/
|
|
195
|
+
async pairWithUser(userId: string): Promise<PairResult> {
|
|
196
|
+
return this.ble.pairWithUser(userId);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Unpair with a user (removes from storage and resets status).
|
|
201
|
+
* Useful for "undo" functionality or rejecting requests.
|
|
202
|
+
*
|
|
203
|
+
* @param userId - The user's ID from NearbyUser.id
|
|
204
|
+
*/
|
|
205
|
+
async unpairUser(userId: string): Promise<void> {
|
|
206
|
+
return this.ble.unpairUser(userId);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Get list of recent pairings from storage (for history/recap features).
|
|
211
|
+
* Returns pairs from the last N hours (default: 24h).
|
|
212
|
+
*
|
|
213
|
+
* @param withinHours - Look back window in hours (default: 24)
|
|
214
|
+
* @returns Array of NearbyUser objects representing recent pairs
|
|
215
|
+
*/
|
|
216
|
+
async getRecentPairs(withinHours: number = 24): Promise<NearbyUser[]> {
|
|
217
|
+
const recentScans = await this.storage.getRecentPairs(withinHours);
|
|
218
|
+
|
|
219
|
+
// Map ScannedEvent to NearbyUser format for UI compatibility
|
|
220
|
+
return recentScans.map(scan => ({
|
|
221
|
+
id: scan.rpi, // Use RPI as ID for history items
|
|
222
|
+
displayName: `User-${scan.rpi.substring(0, 5)}`, // Generate display name from RPI
|
|
223
|
+
rssi: -50, // Fixed value for history (not actively scanned)
|
|
224
|
+
discoveredAt: scan.timestamp,
|
|
225
|
+
paired: true,
|
|
226
|
+
hasIncomingRequest: false,
|
|
227
|
+
}));
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ========================================================================
|
|
231
|
+
// Reporting Methods
|
|
232
|
+
// ========================================================================
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Report positive (upload configured days of history).
|
|
236
|
+
*
|
|
237
|
+
* @param attestToken - Optional attestation token (e.g., Firebase App Check)
|
|
238
|
+
* @param metadata - App-specific data (e.g., STD type, test date). If null, a generic positive is reported.
|
|
239
|
+
* @param overrideReportDays - Optional: Override reportDays for this specific report
|
|
240
|
+
* (e.g., for apps with per-condition exposure windows)
|
|
241
|
+
*/
|
|
242
|
+
async report(
|
|
243
|
+
attestToken?: string,
|
|
244
|
+
metadata?: ReportMetadata,
|
|
245
|
+
overrideReportDays?: number
|
|
246
|
+
): Promise<boolean> {
|
|
247
|
+
try {
|
|
248
|
+
// Use override if provided, else fall back to global config
|
|
249
|
+
const daysToReport = overrideReportDays ?? this.reportDays;
|
|
250
|
+
const keys = this.identity.getHistory(daysToReport);
|
|
251
|
+
|
|
252
|
+
// Encrypt metadata individually for each key in history
|
|
253
|
+
const reports = keys.map(rpi => ({
|
|
254
|
+
rpi,
|
|
255
|
+
encryptedMetadata: this._encrypt(metadata, this.identity.getMetadataKey(rpi))
|
|
256
|
+
}));
|
|
257
|
+
|
|
258
|
+
const headers: Record<string, string> = {
|
|
259
|
+
'Content-Type': 'application/json',
|
|
260
|
+
'x-vailix-secret': this.appSecret,
|
|
261
|
+
};
|
|
262
|
+
if (attestToken) {
|
|
263
|
+
headers['x-attest-token'] = attestToken;
|
|
264
|
+
}
|
|
265
|
+
const res = await fetch(`${this.reportUrl}/v1/report`, {
|
|
266
|
+
method: 'POST',
|
|
267
|
+
headers,
|
|
268
|
+
body: JSON.stringify({ reports }),
|
|
269
|
+
});
|
|
270
|
+
return res.ok;
|
|
271
|
+
} catch (error) {
|
|
272
|
+
this.matcher.emit('error', error);
|
|
273
|
+
return false;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ========================================================================
|
|
278
|
+
// Encryption Helpers
|
|
279
|
+
// ========================================================================
|
|
280
|
+
|
|
281
|
+
// Max metadata size: 8KB (leaves headroom under 64KB binary format limit)
|
|
282
|
+
private static readonly MAX_METADATA_SIZE = 8 * 1024;
|
|
283
|
+
|
|
284
|
+
private _encrypt(metadata: ReportMetadata | undefined, keyHex: string): string {
|
|
285
|
+
if (!metadata) return '';
|
|
286
|
+
|
|
287
|
+
const jsonStr = JSON.stringify(metadata);
|
|
288
|
+
if (jsonStr.length > VailixSDK.MAX_METADATA_SIZE) {
|
|
289
|
+
throw new Error(`Metadata exceeds maximum size of ${VailixSDK.MAX_METADATA_SIZE} bytes`);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const key = Buffer.from(keyHex, 'hex');
|
|
293
|
+
const iv = randomBytes(12); // 96-bit IV for GCM
|
|
294
|
+
const cipher = createCipheriv('aes-256-gcm', key, iv);
|
|
295
|
+
|
|
296
|
+
let encrypted = cipher.update(jsonStr, 'utf8', 'base64');
|
|
297
|
+
encrypted += cipher.final('base64');
|
|
298
|
+
const authTag = cipher.getAuthTag().toString('base64');
|
|
299
|
+
|
|
300
|
+
// Format: iv:authTag:encryptedData
|
|
301
|
+
return `${iv.toString('base64')}:${authTag}:${encrypted}`;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ========================================================================
|
|
305
|
+
// Event Handlers
|
|
306
|
+
// ========================================================================
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Subscribe to match events.
|
|
310
|
+
* Returns cleanup function for React useEffect compatibility.
|
|
311
|
+
*/
|
|
312
|
+
onMatch(handler: MatchHandler): () => void {
|
|
313
|
+
this.matcher.on('match', handler);
|
|
314
|
+
return () => this.matcher.off('match', handler);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Subscribe to error events.
|
|
319
|
+
* Returns cleanup function for React useEffect compatibility.
|
|
320
|
+
*/
|
|
321
|
+
onError(handler: (error: Error) => void): () => void {
|
|
322
|
+
this.matcher.on('error', handler);
|
|
323
|
+
return () => this.matcher.off('error', handler);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Explicit cleanup for match handler */
|
|
327
|
+
offMatch(handler: MatchHandler): void {
|
|
328
|
+
this.matcher.off('match', handler);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Explicit cleanup for error handler */
|
|
332
|
+
offError(handler: (error: Error) => void): void {
|
|
333
|
+
this.matcher.off('error', handler);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// ========================================================================
|
|
337
|
+
// Match Retrieval Methods (for on-demand decryption)
|
|
338
|
+
// ========================================================================
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Get a specific match by ID with decrypted metadata.
|
|
342
|
+
* Used for on-demand decryption when user views exposure details.
|
|
343
|
+
*
|
|
344
|
+
* @param matchId - RPI of the match to retrieve
|
|
345
|
+
* @returns Match with decrypted metadata, or null if not found
|
|
346
|
+
*
|
|
347
|
+
* @example
|
|
348
|
+
* // App calls this when user taps on notification
|
|
349
|
+
* const match = await sdk.getMatchById('abc123');
|
|
350
|
+
* console.log(match.metadata.conditions); // ["HIV", "Syphilis"]
|
|
351
|
+
*/
|
|
352
|
+
async getMatchById(matchId: string): Promise<Match | null> {
|
|
353
|
+
return this.matcher.getMatchById(matchId);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Get own display name (emoji + number derived from current RPI).
|
|
358
|
+
* Used for showing user's anonymous identity in UI.
|
|
359
|
+
*/
|
|
360
|
+
getOwnDisplayName(): string {
|
|
361
|
+
return this.identity.getDisplayName();
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Re-exports
|
|
366
|
+
export { formatQR, parseQR };
|
|
367
|
+
export type {
|
|
368
|
+
Match,
|
|
369
|
+
MatchHandler,
|
|
370
|
+
ReportMetadata,
|
|
371
|
+
KeyStorage,
|
|
372
|
+
VailixConfig,
|
|
373
|
+
NearbyUser,
|
|
374
|
+
PairResult
|
|
375
|
+
};
|
package/src/matcher.ts
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
2
|
+
import { createDecipheriv } from 'react-native-quick-crypto';
|
|
3
|
+
import { EventEmitter } from 'eventemitter3';
|
|
4
|
+
import type { StorageService } from './storage';
|
|
5
|
+
import type { Match, ReportMetadata } from './types';
|
|
6
|
+
|
|
7
|
+
const LAST_SYNC_KEY = 'vailix_last_sync';
|
|
8
|
+
|
|
9
|
+
interface ServerKey {
|
|
10
|
+
rpi: string;
|
|
11
|
+
metadata?: string;
|
|
12
|
+
reportedAt: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class MatcherService extends EventEmitter {
|
|
16
|
+
constructor(
|
|
17
|
+
private storage: StorageService,
|
|
18
|
+
private downloadUrl: string,
|
|
19
|
+
private appSecret: string
|
|
20
|
+
) { super(); }
|
|
21
|
+
|
|
22
|
+
async fetchAndMatch(): Promise<Match[]> {
|
|
23
|
+
try {
|
|
24
|
+
let lastSync = parseInt(await AsyncStorage.getItem(LAST_SYNC_KEY) || '0', 10);
|
|
25
|
+
const allMatches: Match[] = [];
|
|
26
|
+
let maxReportedAt = lastSync;
|
|
27
|
+
|
|
28
|
+
// Stream keys page by page to avoid OOM
|
|
29
|
+
// Process each page immediately and discard from memory
|
|
30
|
+
await this._downloadAndProcessKeys(lastSync, async (keys) => {
|
|
31
|
+
if (keys.length === 0) return;
|
|
32
|
+
|
|
33
|
+
// Track max timestamp for sync cursor
|
|
34
|
+
const pageMax = Math.max(...keys.map(k => k.reportedAt));
|
|
35
|
+
maxReportedAt = Math.max(maxReportedAt, pageMax);
|
|
36
|
+
|
|
37
|
+
// Map for O(1) lookup
|
|
38
|
+
const infectedMap = new Map<string, ServerKey>();
|
|
39
|
+
for (const key of keys) infectedMap.set(key.rpi, key);
|
|
40
|
+
|
|
41
|
+
// Match current batch against DB
|
|
42
|
+
const infectedRpis = Array.from(infectedMap.keys());
|
|
43
|
+
const matchingScans = await this.storage.getMatchingScans(infectedRpis);
|
|
44
|
+
|
|
45
|
+
// Process matches
|
|
46
|
+
for (const s of matchingScans) {
|
|
47
|
+
const serverKey = infectedMap.get(s.rpi)!;
|
|
48
|
+
|
|
49
|
+
// PRIVACY: Store ENCRYPTED metadata for on-demand decryption
|
|
50
|
+
// Never persist decrypted data to storage
|
|
51
|
+
await AsyncStorage.setItem(`vailix_match_cache_${s.rpi}`, JSON.stringify({
|
|
52
|
+
encryptedMetadata: serverKey.metadata, // Still encrypted from server
|
|
53
|
+
metadataKey: s.metadataKey, // Decryption key
|
|
54
|
+
timestamp: s.timestamp,
|
|
55
|
+
reportedAt: serverKey.reportedAt
|
|
56
|
+
}));
|
|
57
|
+
|
|
58
|
+
// Return match with empty metadata (will be decrypted on-demand)
|
|
59
|
+
allMatches.push({
|
|
60
|
+
rpi: s.rpi,
|
|
61
|
+
timestamp: s.timestamp,
|
|
62
|
+
metadata: undefined, // Not decrypted yet!
|
|
63
|
+
reportedAt: serverKey.reportedAt,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// Update sync checkpoint only after successful processing
|
|
69
|
+
if (maxReportedAt > lastSync) {
|
|
70
|
+
await AsyncStorage.setItem(LAST_SYNC_KEY, maxReportedAt.toString());
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (allMatches.length > 0) {
|
|
74
|
+
this.emit('match', allMatches);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
await this.storage.cleanupOldScans();
|
|
78
|
+
return allMatches;
|
|
79
|
+
} catch (error) {
|
|
80
|
+
this.emit('error', error);
|
|
81
|
+
return [];
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private async _downloadAndProcessKeys(since: number, processor: (keys: ServerKey[]) => Promise<void>): Promise<void> {
|
|
86
|
+
let cursor: string | null = null;
|
|
87
|
+
|
|
88
|
+
do {
|
|
89
|
+
const url = new URL(`${this.downloadUrl}/v1/download`);
|
|
90
|
+
url.searchParams.set('since', since.toString());
|
|
91
|
+
if (cursor) url.searchParams.set('cursor', cursor);
|
|
92
|
+
url.searchParams.set('format', 'bin');
|
|
93
|
+
|
|
94
|
+
const res = await fetch(url.toString(), {
|
|
95
|
+
headers: { 'x-vailix-secret': this.appSecret },
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
if (!res.ok) throw new Error(`Server error: ${res.status}`);
|
|
99
|
+
|
|
100
|
+
const buffer = await res.arrayBuffer();
|
|
101
|
+
const keys = this._parseBinaryResponse(buffer);
|
|
102
|
+
|
|
103
|
+
// Process chunk immediately
|
|
104
|
+
await processor(keys);
|
|
105
|
+
|
|
106
|
+
cursor = res.headers.get('x-vailix-next-cursor') || null;
|
|
107
|
+
|
|
108
|
+
// Yield to event loop to free memory/prevent UI freeze
|
|
109
|
+
await new Promise(resolve => setTimeout(resolve, 0));
|
|
110
|
+
} while (cursor);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private _parseBinaryResponse(buffer: ArrayBuffer): ServerKey[] {
|
|
114
|
+
const view = new DataView(buffer);
|
|
115
|
+
const keys: ServerKey[] = [];
|
|
116
|
+
let offset = 0;
|
|
117
|
+
|
|
118
|
+
// Header: Count (4 bytes)
|
|
119
|
+
if (buffer.byteLength < 4) return [];
|
|
120
|
+
const count = view.getUint32(offset);
|
|
121
|
+
offset += 4;
|
|
122
|
+
|
|
123
|
+
for (let i = 0; i < count; i++) {
|
|
124
|
+
// Bounds check: minimum key size is 16 (RPI) + 8 (timestamp) + 2 (metaLen) = 26 bytes
|
|
125
|
+
if (offset + 26 > buffer.byteLength) {
|
|
126
|
+
console.warn(`Binary response truncated at key ${i}/${count}`);
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// RPI: 16 bytes (Bin) -> 32 hex chars
|
|
131
|
+
const rpiBytes = new Uint8Array(buffer, offset, 16);
|
|
132
|
+
const rpi = Array.from(rpiBytes).map(b => b.toString(16).padStart(2, '0')).join('');
|
|
133
|
+
offset += 16;
|
|
134
|
+
|
|
135
|
+
// Timestamp: 8 bytes (Double)
|
|
136
|
+
const reportedAt = view.getFloat64(offset);
|
|
137
|
+
offset += 8;
|
|
138
|
+
|
|
139
|
+
// Metadata: Len (2 bytes) + Bytes
|
|
140
|
+
const metaLen = view.getUint16(offset);
|
|
141
|
+
offset += 2;
|
|
142
|
+
|
|
143
|
+
let metadata: string | undefined = undefined;
|
|
144
|
+
if (metaLen > 0) {
|
|
145
|
+
// Bounds check for metadata
|
|
146
|
+
if (offset + metaLen > buffer.byteLength) {
|
|
147
|
+
console.warn(`Binary response truncated during metadata at key ${i}/${count}`);
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
const metaBytes = new Uint8Array(buffer, offset, metaLen);
|
|
151
|
+
// TextDecoder correctly handles multi-byte UTF-8 (available in Hermes)
|
|
152
|
+
metadata = new TextDecoder('utf-8').decode(metaBytes);
|
|
153
|
+
offset += metaLen;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
keys.push({ rpi, reportedAt, metadata });
|
|
157
|
+
}
|
|
158
|
+
return keys;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
private _decrypt(encryptedStr: string | undefined | null, keyHex: string): ReportMetadata | undefined {
|
|
162
|
+
if (!encryptedStr) return undefined;
|
|
163
|
+
try {
|
|
164
|
+
const parts = encryptedStr.split(':');
|
|
165
|
+
if (parts.length !== 3) return undefined;
|
|
166
|
+
|
|
167
|
+
const [ivB64, authTagB64, encryptedB64] = parts;
|
|
168
|
+
const key = Buffer.from(keyHex, 'hex');
|
|
169
|
+
const iv = Buffer.from(ivB64, 'base64');
|
|
170
|
+
const authTag = Buffer.from(authTagB64, 'base64');
|
|
171
|
+
|
|
172
|
+
const decipher = createDecipheriv('aes-256-gcm', key, iv);
|
|
173
|
+
decipher.setAuthTag(authTag as any);
|
|
174
|
+
|
|
175
|
+
let decrypted = decipher.update(encryptedB64, 'base64', 'utf8');
|
|
176
|
+
decrypted += decipher.final('utf8');
|
|
177
|
+
|
|
178
|
+
return JSON.parse(decrypted);
|
|
179
|
+
} catch (e) {
|
|
180
|
+
console.warn('Failed to decrypt metadata', e);
|
|
181
|
+
return undefined;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Retrieve a specific match by RPI with on-demand decryption.
|
|
187
|
+
* Used when app needs to display exposure details to user.
|
|
188
|
+
*
|
|
189
|
+
* PRIVACY: Decrypted metadata exists ONLY in-memory (return value).
|
|
190
|
+
* Never persisted to storage.
|
|
191
|
+
*
|
|
192
|
+
* @param rpi - The RPI (match ID) to retrieve
|
|
193
|
+
* @returns Match with decrypted metadata, or null if not found
|
|
194
|
+
*/
|
|
195
|
+
async getMatchById(rpi: string): Promise<Match | null> {
|
|
196
|
+
try {
|
|
197
|
+
// Retrieve encrypted match from cache
|
|
198
|
+
const cachedData = await AsyncStorage.getItem(`vailix_match_cache_${rpi}`);
|
|
199
|
+
if (!cachedData) {
|
|
200
|
+
console.warn(`Match ${rpi} not found in cache`);
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const cached = JSON.parse(cachedData);
|
|
205
|
+
|
|
206
|
+
// Decrypt metadata ON-DEMAND (result only in memory)
|
|
207
|
+
const decryptedMetadata = this._decrypt(
|
|
208
|
+
cached.encryptedMetadata,
|
|
209
|
+
cached.metadataKey
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
// Return ephemeral result (lives only in caller's memory)
|
|
213
|
+
return {
|
|
214
|
+
rpi: rpi,
|
|
215
|
+
timestamp: cached.timestamp,
|
|
216
|
+
metadata: decryptedMetadata,
|
|
217
|
+
reportedAt: cached.reportedAt
|
|
218
|
+
};
|
|
219
|
+
} catch (error) {
|
|
220
|
+
console.error('Failed to get match by ID:', error);
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|