@vailix/mask 0.2.0 → 0.2.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/CHANGELOG.md +12 -0
- package/LICENSE +21 -21
- package/README.md +36 -333
- package/dist/index.js +44 -25
- package/dist/index.mjs +44 -25
- package/package.json +4 -4
- package/src/ble.ts +44 -0
- package/src/db.ts +75 -37
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @vailix/mask
|
|
2
2
|
|
|
3
|
+
## 0.2.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 4b6a9d5: fix: ensure database is closed before deletion on key mismatch and add missing internal types
|
|
8
|
+
|
|
9
|
+
## 0.2.1
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- b3be912: docs: fix repository URLs and update README documentation
|
|
14
|
+
|
|
3
15
|
## 0.2.0
|
|
4
16
|
|
|
5
17
|
### Minor Changes
|
package/LICENSE
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2026 Gil Eyni
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
11
|
-
|
|
12
|
-
The above copyright notice and this permission notice shall be included in all
|
|
13
|
-
copies or substantial portions of the Software.
|
|
14
|
-
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
-
SOFTWARE.
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Gil Eyni
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,365 +1,68 @@
|
|
|
1
1
|
# @vailix/mask
|
|
2
2
|
|
|
3
|
-
Privacy-preserving proximity tracing SDK for React Native
|
|
3
|
+
**Privacy-preserving proximity tracing SDK for React Native & Expo.**
|
|
4
|
+
|
|
5
|
+
Part of the **Vailix Core Framework**. This package handles:
|
|
6
|
+
- **Identity**: Rolling Proximity Identifier (RPI) generation/rotation.
|
|
7
|
+
- **BLE**: Broadcasting and scanning for nearby devices.
|
|
8
|
+
- **Matching**: Matching downloaded keys against local history.
|
|
9
|
+
- **Storage**: Securely storing keys and metadata.
|
|
4
10
|
|
|
5
11
|
## Installation
|
|
6
12
|
|
|
13
|
+
This package relies on several native modules which must be installed as **peer dependencies** in your application.
|
|
14
|
+
|
|
7
15
|
```bash
|
|
16
|
+
# 1. Install the SDK
|
|
8
17
|
npm install @vailix/mask
|
|
9
|
-
```
|
|
10
|
-
|
|
11
|
-
> **Note:** This package requires Expo Development Builds (not Expo Go) due to native dependencies.
|
|
12
18
|
|
|
13
|
-
|
|
14
|
-
npx expo
|
|
15
|
-
npx expo run:android # or run:ios
|
|
19
|
+
# 2. Install required native peer dependencies
|
|
20
|
+
npx expo install react-native-quick-crypto expo-secure-store expo-sqlite react-native-ble-plx
|
|
16
21
|
```
|
|
17
22
|
|
|
18
|
-
|
|
23
|
+
### Expo Configuration (`app.json`)
|
|
24
|
+
|
|
25
|
+
You must add the config plugin for BLE permissions:
|
|
19
26
|
|
|
20
27
|
```json
|
|
21
28
|
{
|
|
22
29
|
"expo": {
|
|
23
30
|
"plugins": [
|
|
24
|
-
[
|
|
31
|
+
[
|
|
32
|
+
"@config-plugins/react-native-ble-plx",
|
|
33
|
+
{
|
|
34
|
+
"isBackgroundEnabled": false,
|
|
35
|
+
"modes": ["central", "peripheral"],
|
|
36
|
+
"bluetoothAlwaysPermission": "Allow app to scan for nearby devices."
|
|
37
|
+
}
|
|
38
|
+
]
|
|
25
39
|
]
|
|
26
40
|
}
|
|
27
41
|
}
|
|
28
42
|
```
|
|
29
43
|
|
|
30
|
-
##
|
|
44
|
+
## Basic Usage
|
|
31
45
|
|
|
32
46
|
```typescript
|
|
33
47
|
import { VailixSDK } from '@vailix/mask';
|
|
34
48
|
|
|
35
|
-
// Initialize
|
|
49
|
+
// Initialize
|
|
36
50
|
const sdk = await VailixSDK.create({
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
// Set up event handlers
|
|
43
|
-
const cleanup = sdk.onMatch((matches) => {
|
|
44
|
-
console.log('Exposure detected:', matches);
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
// Start background sync
|
|
48
|
-
await sdk.matcher.fetchAndMatch();
|
|
49
|
-
```
|
|
50
|
-
|
|
51
|
-
## Configuration
|
|
52
|
-
|
|
53
|
-
| Option | Type | Default | Description |
|
|
54
|
-
|--------|------|---------|-------------|
|
|
55
|
-
| `reportUrl` | string | required | API endpoint for submitting reports |
|
|
56
|
-
| `downloadUrl` | string | required | API endpoint for downloading infected keys |
|
|
57
|
-
| `appSecret` | string | required | Shared secret for API authentication |
|
|
58
|
-
| `rpiDurationMs` | number | 900000 (15min) | How long each RPI persists. Use 86400000 (24h) for STD apps |
|
|
59
|
-
| `rescanIntervalMs` | number | 0 | Minimum time between scans of same RPI. 0 = no limit |
|
|
60
|
-
| `reportDays` | number | 14 | Days of history to upload when reporting |
|
|
61
|
-
| `keyStorage` | KeyStorage | SecureStore | Custom key storage adapter (for cloud backup) |
|
|
62
|
-
| `bleDiscoveryTimeoutMs` | number | 15000 | Timeout before removing user from nearby list |
|
|
63
|
-
| `proximityThreshold` | number | -70 | Minimum RSSI to consider a user "nearby" |
|
|
64
|
-
| `autoAcceptIncomingPairs` | boolean | true | Auto-accept incoming pair requests |
|
|
65
|
-
|
|
66
|
-
### Example Configuration
|
|
67
|
-
|
|
68
|
-
```typescript
|
|
69
|
-
const sdk = await VailixSDK.create({
|
|
70
|
-
reportUrl: 'https://api.yourapp.com',
|
|
71
|
-
downloadUrl: 'https://api.yourapp.com',
|
|
72
|
-
appSecret: 'your-secret-key',
|
|
73
|
-
rpiDurationMs: 24 * 60 * 60 * 1000, // 24 hours
|
|
74
|
-
rescanIntervalMs: 24 * 60 * 60 * 1000, // Block rescan for 24h
|
|
75
|
-
reportDays: 14,
|
|
76
|
-
bleDiscoveryTimeoutMs: 15000, // 15 seconds
|
|
77
|
-
proximityThreshold: -70, // ~1-2 meters
|
|
78
|
-
autoAcceptIncomingPairs: true, // Instant mutual exchange
|
|
79
|
-
});
|
|
80
|
-
```
|
|
81
|
-
|
|
82
|
-
## Pairing Methods
|
|
83
|
-
|
|
84
|
-
Both methods result in **mutual notification** — if either user reports positive, the other is notified.
|
|
85
|
-
|
|
86
|
-
### BLE Pairing (Recommended)
|
|
87
|
-
|
|
88
|
-
One-tap proximity-based pairing via Bluetooth Low Energy:
|
|
89
|
-
|
|
90
|
-
```typescript
|
|
91
|
-
import { useState, useEffect } from 'react';
|
|
92
|
-
import { NearbyUser } from '@vailix/mask';
|
|
93
|
-
|
|
94
|
-
function PairingScreen({ sdk }) {
|
|
95
|
-
const [nearbyUsers, setNearbyUsers] = useState<NearbyUser[]>([]);
|
|
96
|
-
|
|
97
|
-
useEffect(() => {
|
|
98
|
-
// Start discovery when screen opens
|
|
99
|
-
sdk.startDiscovery();
|
|
100
|
-
|
|
101
|
-
// Subscribe to nearby user updates
|
|
102
|
-
const cleanup = sdk.onNearbyUsersChanged(setNearbyUsers);
|
|
103
|
-
|
|
104
|
-
return () => {
|
|
105
|
-
cleanup();
|
|
106
|
-
sdk.stopDiscovery();
|
|
107
|
-
};
|
|
108
|
-
}, []);
|
|
109
|
-
|
|
110
|
-
const handlePair = async (user: NearbyUser) => {
|
|
111
|
-
const result = await sdk.pairWithUser(user.id);
|
|
112
|
-
if (result.success) {
|
|
113
|
-
console.log('Paired successfully with', user.displayName);
|
|
114
|
-
}
|
|
115
|
-
};
|
|
116
|
-
|
|
117
|
-
return (
|
|
118
|
-
<FlatList
|
|
119
|
-
data={nearbyUsers}
|
|
120
|
-
renderItem={({ item }) => (
|
|
121
|
-
<View>
|
|
122
|
-
<Text>{item.displayName}</Text>
|
|
123
|
-
{item.paired ? (
|
|
124
|
-
<Text>✓ Paired</Text>
|
|
125
|
-
) : (
|
|
126
|
-
<Button title="Pair" onPress={() => handlePair(item)} />
|
|
127
|
-
)}
|
|
128
|
-
</View>
|
|
129
|
-
)}
|
|
130
|
-
/>
|
|
131
|
-
);
|
|
132
|
-
}
|
|
133
|
-
```
|
|
134
|
-
|
|
135
|
-
**User Flow:**
|
|
136
|
-
1. Both users open the pairing screen
|
|
137
|
-
2. They see each other in the nearby users list (e.g., "🐼 42", "🦊 17")
|
|
138
|
-
3. Either user taps to pair
|
|
139
|
-
4. Both phones exchange data automatically
|
|
140
|
-
|
|
141
|
-
### QR Code Pairing (Fallback)
|
|
142
|
-
|
|
143
|
-
Both users must scan each other's QR code:
|
|
144
|
-
|
|
145
|
-
```typescript
|
|
146
|
-
// User A shows QR
|
|
147
|
-
const qrData = sdk.getQRCode();
|
|
148
|
-
// Display qrData as QR code
|
|
149
|
-
|
|
150
|
-
// User B scans
|
|
151
|
-
const success = await sdk.scanQR(scannedData);
|
|
152
|
-
|
|
153
|
-
// Then swap: B shows, A scans
|
|
154
|
-
```
|
|
155
|
-
|
|
156
|
-
## BLE Configuration
|
|
157
|
-
|
|
158
|
-
### iOS Configuration
|
|
159
|
-
|
|
160
|
-
Add to `ios/[AppName]/Info.plist`:
|
|
161
|
-
|
|
162
|
-
```xml
|
|
163
|
-
<key>NSBluetoothAlwaysUsageDescription</key>
|
|
164
|
-
<string>Used to discover and pair with nearby users</string>
|
|
165
|
-
<key>NSBluetoothPeripheralUsageDescription</key>
|
|
166
|
-
<string>Used to allow other users to discover you</string>
|
|
167
|
-
```
|
|
168
|
-
|
|
169
|
-
### Android Configuration
|
|
170
|
-
|
|
171
|
-
Add to `android/app/src/main/AndroidManifest.xml`:
|
|
172
|
-
|
|
173
|
-
```xml
|
|
174
|
-
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
|
|
175
|
-
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
|
|
176
|
-
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
|
177
|
-
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
|
178
|
-
```
|
|
179
|
-
|
|
180
|
-
## Explicit Consent Mode
|
|
181
|
-
|
|
182
|
-
By default, pair requests are auto-accepted. For apps requiring explicit consent:
|
|
183
|
-
|
|
184
|
-
```typescript
|
|
185
|
-
const sdk = await VailixSDK.create({
|
|
186
|
-
...config,
|
|
187
|
-
autoAcceptIncomingPairs: false, // Require explicit acceptance
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
// In your pairing screen:
|
|
191
|
-
{item.hasIncomingRequest ? (
|
|
192
|
-
<Button title="Accept" onPress={() => sdk.pairWithUser(item.id)} />
|
|
193
|
-
) : item.paired ? (
|
|
194
|
-
<Text>✓ Paired</Text>
|
|
195
|
-
) : (
|
|
196
|
-
<Button title="Pair" onPress={() => handlePair(item)} />
|
|
197
|
-
)}
|
|
198
|
-
```
|
|
199
|
-
|
|
200
|
-
## Reporting Positive
|
|
201
|
-
|
|
202
|
-
When a user tests positive:
|
|
203
|
-
|
|
204
|
-
```typescript
|
|
205
|
-
const success = await sdk.report(
|
|
206
|
-
attestToken, // Optional: Firebase App Check token
|
|
207
|
-
{ // Optional: metadata (encrypted per-contact)
|
|
208
|
-
condition: 'chlamydia',
|
|
209
|
-
testDate: '2025-01-05',
|
|
210
|
-
}
|
|
211
|
-
);
|
|
212
|
-
```
|
|
213
|
-
|
|
214
|
-
This uploads the user's last 14 days of RPIs (configurable via `reportDays`).
|
|
215
|
-
|
|
216
|
-
## Receiving Notifications
|
|
217
|
-
|
|
218
|
-
```typescript
|
|
219
|
-
// Subscribe to matches
|
|
220
|
-
const cleanup = sdk.onMatch((matches) => {
|
|
221
|
-
for (const match of matches) {
|
|
222
|
-
console.log('Exposure on:', new Date(match.timestamp));
|
|
223
|
-
console.log('Reported at:', new Date(match.reportedAt));
|
|
224
|
-
console.log('Details:', match.metadata); // Decrypted per-contact
|
|
225
|
-
}
|
|
51
|
+
appSecret: "YOUR_SECRET",
|
|
52
|
+
reportUrl: "https://your-drop-server.com",
|
|
53
|
+
downloadUrl: "https://your-drop-server.com",
|
|
226
54
|
});
|
|
227
55
|
|
|
228
|
-
//
|
|
229
|
-
await sdk.
|
|
230
|
-
|
|
231
|
-
// Cleanup when component unmounts (React)
|
|
232
|
-
useEffect(() => {
|
|
233
|
-
return sdk.onMatch(handler); // Returns cleanup function
|
|
234
|
-
}, []);
|
|
235
|
-
```
|
|
236
|
-
|
|
237
|
-
## Error Handling
|
|
56
|
+
// Start Scanning
|
|
57
|
+
await sdk.startDiscovery();
|
|
238
58
|
|
|
239
|
-
|
|
240
|
-
const
|
|
241
|
-
console.
|
|
242
|
-
});
|
|
243
|
-
```
|
|
244
|
-
|
|
245
|
-
## Data Recovery
|
|
246
|
-
|
|
247
|
-
The SDK uses SQLCipher AES-256 encryption for all stored data. Recovery behavior depends on whether you implement `keyStorage`.
|
|
248
|
-
|
|
249
|
-
### Without keyStorage (Default)
|
|
250
|
-
|
|
251
|
-
Master key stored locally only. On reinstall:
|
|
252
|
-
- New master key generated → new identity
|
|
253
|
-
- Old scan history unreadable (wrong key)
|
|
254
|
-
- SDK automatically deletes corrupted database
|
|
255
|
-
|
|
256
|
-
### With keyStorage (Recommended)
|
|
257
|
-
|
|
258
|
-
Enable cross-device recovery by syncing the master key:
|
|
259
|
-
|
|
260
|
-
```typescript
|
|
261
|
-
// Custom storage for iCloud/Google Drive sync
|
|
262
|
-
// Recommended library: react-native-cloud-storage
|
|
263
|
-
import { CloudStorage } from 'react-native-cloud-storage';
|
|
264
|
-
|
|
265
|
-
const sdk = await VailixSDK.create({
|
|
266
|
-
...config,
|
|
267
|
-
keyStorage: {
|
|
268
|
-
getKey: () => CloudStorage.getItem('vailix_key'),
|
|
269
|
-
setKey: (k) => CloudStorage.setItem('vailix_key', k),
|
|
270
|
-
},
|
|
59
|
+
// Listen for updates
|
|
60
|
+
const unsubscribe = sdk.onNearbyUsersChanged((users) => {
|
|
61
|
+
console.log("Nearby:", users);
|
|
271
62
|
});
|
|
272
63
|
```
|
|
273
64
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
1. **First install:** SDK generates key → `setKey()` saves to cloud
|
|
277
|
-
2. **Normal use:** Scans saved locally, OS backs up database
|
|
278
|
-
3. **Phone lost:** Key in cloud, database in iCloud/Google backup
|
|
279
|
-
4. **New phone:** User signs into same Apple ID/Google Account
|
|
280
|
-
5. **Reinstall:** SDK calls `getKey()` → returns existing key → opens database
|
|
281
|
-
|
|
282
|
-
**No user action required** except signing into the same cloud account.
|
|
283
|
-
|
|
284
|
-
### Recovery Scenarios
|
|
285
|
-
|
|
286
|
-
| Scenario | Master Key | Database | Result |
|
|
287
|
-
|----------|------------|----------|--------|
|
|
288
|
-
| Both recovered | ✅ From cloud | ✅ From backup | Full restore |
|
|
289
|
-
| Key only | ✅ From cloud | ❌ Empty | Fresh DB, can still report |
|
|
290
|
-
| New key, old DB | ❌ New | ⚠️ Wrong key | SDK deletes old DB, starts fresh |
|
|
291
|
-
| Neither | ❌ New | ❌ Empty | New identity |
|
|
292
|
-
|
|
293
|
-
### KeyStorage Interface
|
|
294
|
-
|
|
295
|
-
```typescript
|
|
296
|
-
interface KeyStorage {
|
|
297
|
-
getKey(): Promise<string | null>;
|
|
298
|
-
setKey(key: string): Promise<void>;
|
|
299
|
-
}
|
|
300
|
-
```
|
|
301
|
-
|
|
302
|
-
> [!IMPORTANT]
|
|
303
|
-
> The SDK does NOT require app-level login. Cloud sync uses device-level Apple ID / Google Account.
|
|
304
|
-
|
|
305
|
-
## API Reference
|
|
306
|
-
|
|
307
|
-
### VailixSDK
|
|
308
|
-
|
|
309
|
-
| Method | Description |
|
|
310
|
-
|--------|-------------|
|
|
311
|
-
| `VailixSDK.create(config)` | Initialize SDK with VailixConfig |
|
|
312
|
-
| `VailixSDK.isBleSupported()` | Check if device supports BLE |
|
|
313
|
-
| `sdk.getQRCode()` | Get QR code data for display |
|
|
314
|
-
| `sdk.scanQR(data)` | Scan and store another user's QR |
|
|
315
|
-
| `sdk.startDiscovery()` | Start BLE discovery (call when pairing screen opens) |
|
|
316
|
-
| `sdk.stopDiscovery()` | Stop BLE discovery (call when leaving pairing screen) |
|
|
317
|
-
| `sdk.getNearbyUsers()` | Get current list of nearby users |
|
|
318
|
-
| `sdk.onNearbyUsersChanged(handler)` | Subscribe to nearby user updates |
|
|
319
|
-
| `sdk.pairWithUser(userId)` | Pair with a specific user |
|
|
320
|
-
| `sdk.unpairUser(userId)` | Unpair/reject a user |
|
|
321
|
-
| `sdk.report(token?, metadata?)` | Report positive |
|
|
322
|
-
| `sdk.onMatch(handler)` | Subscribe to exposure matches |
|
|
323
|
-
| `sdk.offMatch(handler)` | Unsubscribe from matches |
|
|
324
|
-
| `sdk.onError(handler)` | Subscribe to errors |
|
|
325
|
-
| `sdk.offError(handler)` | Unsubscribe from errors |
|
|
326
|
-
| `sdk.matcher.fetchAndMatch()` | Sync and check for matches |
|
|
327
|
-
|
|
328
|
-
### NearbyUser Object
|
|
329
|
-
|
|
330
|
-
```typescript
|
|
331
|
-
interface NearbyUser {
|
|
332
|
-
id: string; // Opaque identifier for pairing
|
|
333
|
-
displayName: string; // Generated emoji + number (e.g., "🐼 42")
|
|
334
|
-
rssi: number; // Signal strength
|
|
335
|
-
discoveredAt: number; // Timestamp of last advertisement
|
|
336
|
-
paired: boolean; // true if already paired
|
|
337
|
-
hasIncomingRequest: boolean; // true if they sent a pair request (explicit mode)
|
|
338
|
-
}
|
|
339
|
-
```
|
|
340
|
-
|
|
341
|
-
### PairResult Object
|
|
342
|
-
|
|
343
|
-
```typescript
|
|
344
|
-
interface PairResult {
|
|
345
|
-
success: boolean;
|
|
346
|
-
partnerRpi?: string;
|
|
347
|
-
partnerMetadataKey?: string;
|
|
348
|
-
error?: string;
|
|
349
|
-
}
|
|
350
|
-
```
|
|
351
|
-
|
|
352
|
-
### Match Object
|
|
353
|
-
|
|
354
|
-
```typescript
|
|
355
|
-
interface Match {
|
|
356
|
-
rpi: string; // The matched identifier
|
|
357
|
-
timestamp: number; // When the contact occurred
|
|
358
|
-
metadata?: object; // Decrypted reporter metadata
|
|
359
|
-
reportedAt?: number; // When the report was submitted
|
|
360
|
-
}
|
|
361
|
-
```
|
|
362
|
-
|
|
363
|
-
## License
|
|
65
|
+
## Compatibility
|
|
364
66
|
|
|
365
|
-
|
|
67
|
+
- **Expo**: SDK 52+ (Development Build required, **Expo Go not supported**)
|
|
68
|
+
- **React Native**: 0.76+
|
package/dist/index.js
CHANGED
|
@@ -775,35 +775,54 @@ var import_expo_sqlite2 = require("expo-sqlite");
|
|
|
775
775
|
var import_drizzle_orm2 = require("drizzle-orm");
|
|
776
776
|
var DB_NAME = "vailix.db";
|
|
777
777
|
async function initializeDatabase(masterKey) {
|
|
778
|
+
const result = await tryOpenEncryptedDatabase(masterKey);
|
|
779
|
+
if (result.success) {
|
|
780
|
+
return result.db;
|
|
781
|
+
}
|
|
782
|
+
console.warn("Database key mismatch, recreating fresh database");
|
|
783
|
+
if (result.expo) {
|
|
784
|
+
result.expo.closeSync();
|
|
785
|
+
}
|
|
786
|
+
(0, import_expo_sqlite2.deleteDatabaseSync)(DB_NAME);
|
|
787
|
+
const retryResult = await tryOpenEncryptedDatabase(masterKey);
|
|
788
|
+
if (!retryResult.success) {
|
|
789
|
+
if (retryResult.expo) {
|
|
790
|
+
retryResult.expo.closeSync();
|
|
791
|
+
}
|
|
792
|
+
throw retryResult.error;
|
|
793
|
+
}
|
|
794
|
+
return retryResult.db;
|
|
795
|
+
}
|
|
796
|
+
async function tryOpenEncryptedDatabase(masterKey) {
|
|
797
|
+
let expo = null;
|
|
778
798
|
try {
|
|
779
|
-
|
|
799
|
+
expo = (0, import_expo_sqlite2.openDatabaseSync)(DB_NAME);
|
|
800
|
+
const db = (0, import_expo_sqlite.drizzle)(expo);
|
|
801
|
+
if (!/^[0-9a-f]+$/i.test(masterKey)) {
|
|
802
|
+
throw new Error("Invalid master key format");
|
|
803
|
+
}
|
|
804
|
+
await db.run(import_drizzle_orm2.sql.raw(`PRAGMA key = '${masterKey}'`));
|
|
805
|
+
await db.run(import_drizzle_orm2.sql`SELECT 1`);
|
|
806
|
+
await db.run(import_drizzle_orm2.sql`
|
|
807
|
+
CREATE TABLE IF NOT EXISTS scanned_events (
|
|
808
|
+
id TEXT PRIMARY KEY,
|
|
809
|
+
rpi TEXT NOT NULL,
|
|
810
|
+
metadata_key TEXT NOT NULL,
|
|
811
|
+
timestamp INTEGER NOT NULL
|
|
812
|
+
)
|
|
813
|
+
`);
|
|
814
|
+
await db.run(import_drizzle_orm2.sql`
|
|
815
|
+
CREATE INDEX IF NOT EXISTS rpi_idx ON scanned_events(rpi)
|
|
816
|
+
`);
|
|
817
|
+
return { success: true, db, expo };
|
|
780
818
|
} catch (error) {
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
819
|
+
return {
|
|
820
|
+
success: false,
|
|
821
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
822
|
+
expo
|
|
823
|
+
};
|
|
784
824
|
}
|
|
785
825
|
}
|
|
786
|
-
async function openEncryptedDatabase(masterKey) {
|
|
787
|
-
const expo = (0, import_expo_sqlite2.openDatabaseSync)(DB_NAME);
|
|
788
|
-
const db = (0, import_expo_sqlite.drizzle)(expo);
|
|
789
|
-
if (!/^[0-9a-f]+$/i.test(masterKey)) {
|
|
790
|
-
throw new Error("Invalid master key format");
|
|
791
|
-
}
|
|
792
|
-
await db.run(import_drizzle_orm2.sql.raw(`PRAGMA key = '${masterKey}'`));
|
|
793
|
-
await db.run(import_drizzle_orm2.sql`SELECT 1`);
|
|
794
|
-
await db.run(import_drizzle_orm2.sql`
|
|
795
|
-
CREATE TABLE IF NOT EXISTS scanned_events (
|
|
796
|
-
id TEXT PRIMARY KEY,
|
|
797
|
-
rpi TEXT NOT NULL,
|
|
798
|
-
metadata_key TEXT NOT NULL,
|
|
799
|
-
timestamp INTEGER NOT NULL
|
|
800
|
-
)
|
|
801
|
-
`);
|
|
802
|
-
await db.run(import_drizzle_orm2.sql`
|
|
803
|
-
CREATE INDEX IF NOT EXISTS rpi_idx ON scanned_events(rpi)
|
|
804
|
-
`);
|
|
805
|
-
return db;
|
|
806
|
-
}
|
|
807
826
|
|
|
808
827
|
// src/index.ts
|
|
809
828
|
var VailixSDK = class _VailixSDK {
|
package/dist/index.mjs
CHANGED
|
@@ -739,35 +739,54 @@ import { openDatabaseSync, deleteDatabaseSync } from "expo-sqlite";
|
|
|
739
739
|
import { sql } from "drizzle-orm";
|
|
740
740
|
var DB_NAME = "vailix.db";
|
|
741
741
|
async function initializeDatabase(masterKey) {
|
|
742
|
+
const result = await tryOpenEncryptedDatabase(masterKey);
|
|
743
|
+
if (result.success) {
|
|
744
|
+
return result.db;
|
|
745
|
+
}
|
|
746
|
+
console.warn("Database key mismatch, recreating fresh database");
|
|
747
|
+
if (result.expo) {
|
|
748
|
+
result.expo.closeSync();
|
|
749
|
+
}
|
|
750
|
+
deleteDatabaseSync(DB_NAME);
|
|
751
|
+
const retryResult = await tryOpenEncryptedDatabase(masterKey);
|
|
752
|
+
if (!retryResult.success) {
|
|
753
|
+
if (retryResult.expo) {
|
|
754
|
+
retryResult.expo.closeSync();
|
|
755
|
+
}
|
|
756
|
+
throw retryResult.error;
|
|
757
|
+
}
|
|
758
|
+
return retryResult.db;
|
|
759
|
+
}
|
|
760
|
+
async function tryOpenEncryptedDatabase(masterKey) {
|
|
761
|
+
let expo = null;
|
|
742
762
|
try {
|
|
743
|
-
|
|
763
|
+
expo = openDatabaseSync(DB_NAME);
|
|
764
|
+
const db = drizzle(expo);
|
|
765
|
+
if (!/^[0-9a-f]+$/i.test(masterKey)) {
|
|
766
|
+
throw new Error("Invalid master key format");
|
|
767
|
+
}
|
|
768
|
+
await db.run(sql.raw(`PRAGMA key = '${masterKey}'`));
|
|
769
|
+
await db.run(sql`SELECT 1`);
|
|
770
|
+
await db.run(sql`
|
|
771
|
+
CREATE TABLE IF NOT EXISTS scanned_events (
|
|
772
|
+
id TEXT PRIMARY KEY,
|
|
773
|
+
rpi TEXT NOT NULL,
|
|
774
|
+
metadata_key TEXT NOT NULL,
|
|
775
|
+
timestamp INTEGER NOT NULL
|
|
776
|
+
)
|
|
777
|
+
`);
|
|
778
|
+
await db.run(sql`
|
|
779
|
+
CREATE INDEX IF NOT EXISTS rpi_idx ON scanned_events(rpi)
|
|
780
|
+
`);
|
|
781
|
+
return { success: true, db, expo };
|
|
744
782
|
} catch (error) {
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
783
|
+
return {
|
|
784
|
+
success: false,
|
|
785
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
786
|
+
expo
|
|
787
|
+
};
|
|
748
788
|
}
|
|
749
789
|
}
|
|
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
790
|
|
|
772
791
|
// src/index.ts
|
|
773
792
|
var VailixSDK = class _VailixSDK {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vailix/mask",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "Privacy-preserving proximity tracing SDK for React Native & Expo",
|
|
5
5
|
"author": "Gil Eyni",
|
|
6
6
|
"keywords": [
|
|
@@ -13,13 +13,13 @@
|
|
|
13
13
|
"contact-tracing",
|
|
14
14
|
"sdk"
|
|
15
15
|
],
|
|
16
|
-
"homepage": "https://github.com/vailix/
|
|
16
|
+
"homepage": "https://github.com/vailix-dev/vailix/tree/main/packages/mask#readme",
|
|
17
17
|
"bugs": {
|
|
18
|
-
"url": "https://github.com/vailix/
|
|
18
|
+
"url": "https://github.com/vailix-dev/vailix/issues"
|
|
19
19
|
},
|
|
20
20
|
"repository": {
|
|
21
21
|
"type": "git",
|
|
22
|
-
"url": "git+https://github.com/vailix/
|
|
22
|
+
"url": "git+https://github.com/vailix-dev/vailix.git",
|
|
23
23
|
"directory": "packages/mask"
|
|
24
24
|
},
|
|
25
25
|
"license": "MIT",
|
package/src/ble.ts
CHANGED
|
@@ -21,6 +21,50 @@ const DEFAULT_PROXIMITY_THRESHOLD = -70;
|
|
|
21
21
|
|
|
22
22
|
import { generateDisplayName } from './utils';
|
|
23
23
|
|
|
24
|
+
// ============================================================================
|
|
25
|
+
// Internal Types (not exported - implementation details)
|
|
26
|
+
// ============================================================================
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Internal representation of a nearby user with additional fields for BLE management.
|
|
30
|
+
* The public NearbyUser type hides these implementation details.
|
|
31
|
+
*/
|
|
32
|
+
interface InternalNearbyUser {
|
|
33
|
+
id: string;
|
|
34
|
+
displayName: string;
|
|
35
|
+
rssi: number;
|
|
36
|
+
discoveredAt: number;
|
|
37
|
+
paired: boolean;
|
|
38
|
+
hasIncomingRequest: boolean;
|
|
39
|
+
pairedAt?: number;
|
|
40
|
+
/** First 8 bytes (16 hex chars) of RPI from advertisement */
|
|
41
|
+
rpiPrefix: string;
|
|
42
|
+
/** Full RPI (received via GATT exchange) */
|
|
43
|
+
fullRpi?: string;
|
|
44
|
+
/** Metadata key (received via GATT exchange) */
|
|
45
|
+
metadataKey?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Pending incoming pair request (explicit consent mode).
|
|
50
|
+
* Holds data in memory until user accepts.
|
|
51
|
+
*/
|
|
52
|
+
interface PendingPairRequest {
|
|
53
|
+
fullRpi: string;
|
|
54
|
+
metadataKey: string;
|
|
55
|
+
receivedAt: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Configuration options for BleService constructor.
|
|
60
|
+
*/
|
|
61
|
+
interface BleServiceConfig {
|
|
62
|
+
discoveryTimeoutMs?: number;
|
|
63
|
+
proximityThreshold?: number;
|
|
64
|
+
autoAccept?: boolean;
|
|
65
|
+
serviceUUID?: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
24
68
|
/**
|
|
25
69
|
* Extract RPI prefix from advertisement manufacturer data or service data.
|
|
26
70
|
*/
|
package/src/db.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { drizzle } from 'drizzle-orm/expo-sqlite';
|
|
2
|
-
import { openDatabaseSync, deleteDatabaseSync } from 'expo-sqlite';
|
|
2
|
+
import { openDatabaseSync, deleteDatabaseSync, type SQLiteDatabase } from 'expo-sqlite';
|
|
3
3
|
import { sql } from 'drizzle-orm';
|
|
4
4
|
import type { VailixDB } from './types';
|
|
5
5
|
|
|
@@ -13,45 +13,83 @@ const DB_NAME = 'vailix.db';
|
|
|
13
13
|
* @param masterKey The user's master key, used to derive encryption password
|
|
14
14
|
*/
|
|
15
15
|
export async function initializeDatabase(masterKey: string): Promise<VailixDB> {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
16
|
+
const result = await tryOpenEncryptedDatabase(masterKey);
|
|
17
|
+
|
|
18
|
+
if (result.success) {
|
|
19
|
+
return result.db;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Key mismatch: "file is not a database" or similar SQLCipher error
|
|
23
|
+
// This happens when DB was restored from backup but key is different
|
|
24
|
+
console.warn('Database key mismatch, recreating fresh database');
|
|
25
|
+
|
|
26
|
+
// Close the connection before deletion (required by SQLite)
|
|
27
|
+
if (result.expo) {
|
|
28
|
+
result.expo.closeSync();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
deleteDatabaseSync(DB_NAME);
|
|
32
|
+
|
|
33
|
+
// Retry - if this fails, let it throw (unrecoverable error)
|
|
34
|
+
const retryResult = await tryOpenEncryptedDatabase(masterKey);
|
|
35
|
+
if (!retryResult.success) {
|
|
36
|
+
if (retryResult.expo) {
|
|
37
|
+
retryResult.expo.closeSync();
|
|
38
|
+
}
|
|
39
|
+
throw retryResult.error;
|
|
24
40
|
}
|
|
41
|
+
|
|
42
|
+
return retryResult.db;
|
|
25
43
|
}
|
|
26
44
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
45
|
+
type OpenResult =
|
|
46
|
+
| { success: true; db: VailixDB; expo: SQLiteDatabase }
|
|
47
|
+
| { success: false; error: Error; expo: SQLiteDatabase | null };
|
|
30
48
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
49
|
+
/**
|
|
50
|
+
* Attempt to open and configure the encrypted database.
|
|
51
|
+
* Returns a result object that includes the raw expo connection for cleanup.
|
|
52
|
+
*/
|
|
53
|
+
async function tryOpenEncryptedDatabase(masterKey: string): Promise<OpenResult> {
|
|
54
|
+
let expo: SQLiteDatabase | null = null;
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
expo = openDatabaseSync(DB_NAME);
|
|
58
|
+
const db = drizzle(expo);
|
|
59
|
+
|
|
60
|
+
// Validate key is hex to prevent SQL injection
|
|
61
|
+
if (!/^[0-9a-f]+$/i.test(masterKey)) {
|
|
62
|
+
throw new Error('Invalid master key format');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Enable SQLCipher encryption using master key as password
|
|
66
|
+
// This encrypts the entire database at rest (AES-256)
|
|
67
|
+
await db.run(sql.raw(`PRAGMA key = '${masterKey}'`));
|
|
68
|
+
|
|
69
|
+
// Verify key works by attempting a read operation
|
|
70
|
+
// SQLCipher will throw if key is wrong
|
|
71
|
+
await db.run(sql`SELECT 1`);
|
|
72
|
+
|
|
73
|
+
// Create schema
|
|
74
|
+
await db.run(sql`
|
|
75
|
+
CREATE TABLE IF NOT EXISTS scanned_events (
|
|
76
|
+
id TEXT PRIMARY KEY,
|
|
77
|
+
rpi TEXT NOT NULL,
|
|
78
|
+
metadata_key TEXT NOT NULL,
|
|
79
|
+
timestamp INTEGER NOT NULL
|
|
80
|
+
)
|
|
81
|
+
`);
|
|
82
|
+
|
|
83
|
+
await db.run(sql`
|
|
84
|
+
CREATE INDEX IF NOT EXISTS rpi_idx ON scanned_events(rpi)
|
|
85
|
+
`);
|
|
86
|
+
|
|
87
|
+
return { success: true, db, expo };
|
|
88
|
+
} catch (error) {
|
|
89
|
+
return {
|
|
90
|
+
success: false,
|
|
91
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
92
|
+
expo
|
|
93
|
+
};
|
|
36
94
|
}
|
|
37
|
-
await db.run(sql.raw(`PRAGMA key = '${masterKey}'`));
|
|
38
|
-
|
|
39
|
-
// Verify key works by attempting a read operation
|
|
40
|
-
// SQLCipher will throw if key is wrong
|
|
41
|
-
await db.run(sql`SELECT 1`);
|
|
42
|
-
|
|
43
|
-
await db.run(sql`
|
|
44
|
-
CREATE TABLE IF NOT EXISTS scanned_events (
|
|
45
|
-
id TEXT PRIMARY KEY,
|
|
46
|
-
rpi TEXT NOT NULL,
|
|
47
|
-
metadata_key TEXT NOT NULL,
|
|
48
|
-
timestamp INTEGER NOT NULL
|
|
49
|
-
)
|
|
50
|
-
`);
|
|
51
|
-
|
|
52
|
-
await db.run(sql`
|
|
53
|
-
CREATE INDEX IF NOT EXISTS rpi_idx ON scanned_events(rpi)
|
|
54
|
-
`);
|
|
55
|
-
|
|
56
|
-
return db;
|
|
57
95
|
}
|