@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.
@@ -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
+ }