@vailix/mask 0.2.3 → 0.2.5
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 +18 -0
- package/__tests__/singleton.test.ts +235 -0
- package/dist/index.js +12 -9
- package/dist/index.mjs +17 -14
- package/package.json +3 -2
- package/src/identity.ts +2 -2
- package/src/index.ts +15 -11
- package/vitest.config.ts +9 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# @vailix/mask
|
|
2
2
|
|
|
3
|
+
## 0.2.5
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- a48760e: Fix master key generation to use cryptographically secure random bytes
|
|
8
|
+
|
|
9
|
+
Changed master key generation from `randomUUID()` to `randomBytes(32).toString('hex')`.
|
|
10
|
+
This fixes the "Invalid master key format" error caused by UUID hyphens failing hex validation,
|
|
11
|
+
and provides proper 256-bit cryptographic randomness following security best practices.
|
|
12
|
+
|
|
13
|
+
## 0.2.4
|
|
14
|
+
|
|
15
|
+
### Patch Changes
|
|
16
|
+
|
|
17
|
+
- dfda662: fix: make singleton initialization truly atomic
|
|
18
|
+
- Fix race condition where check-and-set wasn't atomic by using IIFE pattern
|
|
19
|
+
- Add comprehensive tests for singleton pattern (12 tests including stress test with 100 concurrent calls)
|
|
20
|
+
|
|
3
21
|
## 0.2.3
|
|
4
22
|
|
|
5
23
|
### Patch Changes
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for VailixSDK singleton pattern.
|
|
3
|
+
*
|
|
4
|
+
* These tests verify that the race condition fix works correctly:
|
|
5
|
+
* - Multiple concurrent calls return the same instance
|
|
6
|
+
* - The promise is set atomically (no gap between check and set)
|
|
7
|
+
* - destroy() properly resets the singleton
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
11
|
+
|
|
12
|
+
// We need to test the singleton pattern in isolation.
|
|
13
|
+
// Since VailixSDK.doCreate has many dependencies, we'll mock them.
|
|
14
|
+
|
|
15
|
+
// Mock all the heavy dependencies using proper class constructors
|
|
16
|
+
vi.mock('../src/identity', () => {
|
|
17
|
+
return {
|
|
18
|
+
IdentityManager: class MockIdentityManager {
|
|
19
|
+
initialize = vi.fn().mockResolvedValue(undefined);
|
|
20
|
+
getMasterKey = vi.fn().mockReturnValue('0123456789abcdef0123456789abcdef');
|
|
21
|
+
getCurrentRPI = vi.fn().mockReturnValue('testrpi');
|
|
22
|
+
getMetadataKey = vi.fn().mockReturnValue('testkey');
|
|
23
|
+
getHistory = vi.fn().mockReturnValue([]);
|
|
24
|
+
getDisplayName = vi.fn().mockReturnValue('Test-123');
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
vi.mock('../src/db', () => ({
|
|
30
|
+
initializeDatabase: vi.fn().mockResolvedValue({
|
|
31
|
+
run: vi.fn().mockResolvedValue(undefined),
|
|
32
|
+
}),
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
vi.mock('../src/storage', () => {
|
|
36
|
+
return {
|
|
37
|
+
StorageService: class MockStorageService {
|
|
38
|
+
initialize = vi.fn().mockResolvedValue(undefined);
|
|
39
|
+
cleanupOldScans = vi.fn().mockResolvedValue(undefined);
|
|
40
|
+
canScan = vi.fn().mockReturnValue(true);
|
|
41
|
+
logScan = vi.fn().mockResolvedValue(undefined);
|
|
42
|
+
getRecentPairs = vi.fn().mockResolvedValue([]);
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
vi.mock('../src/matcher', () => {
|
|
48
|
+
return {
|
|
49
|
+
MatcherService: class MockMatcherService {
|
|
50
|
+
on = vi.fn();
|
|
51
|
+
off = vi.fn();
|
|
52
|
+
emit = vi.fn();
|
|
53
|
+
getMatchById = vi.fn().mockResolvedValue(null);
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
vi.mock('../src/ble', () => {
|
|
59
|
+
return {
|
|
60
|
+
BleService: class MockBleService {
|
|
61
|
+
setStorage = vi.fn();
|
|
62
|
+
destroy = vi.fn();
|
|
63
|
+
startDiscovery = vi.fn().mockResolvedValue(undefined);
|
|
64
|
+
stopDiscovery = vi.fn().mockResolvedValue(undefined);
|
|
65
|
+
getNearbyUsers = vi.fn().mockReturnValue([]);
|
|
66
|
+
onNearbyUsersChanged = vi.fn().mockReturnValue(() => { });
|
|
67
|
+
pairWithUser = vi.fn().mockResolvedValue({ success: true });
|
|
68
|
+
unpairUser = vi.fn().mockResolvedValue(undefined);
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
vi.mock('react-native-quick-crypto', () => ({
|
|
74
|
+
createCipheriv: vi.fn(),
|
|
75
|
+
randomBytes: vi.fn().mockReturnValue(Buffer.alloc(12)),
|
|
76
|
+
}));
|
|
77
|
+
|
|
78
|
+
// Import after mocks are set up
|
|
79
|
+
import { VailixSDK } from '../src/index';
|
|
80
|
+
|
|
81
|
+
const TEST_CONFIG = {
|
|
82
|
+
appSecret: 'test-secret',
|
|
83
|
+
reportUrl: 'https://test.example.com',
|
|
84
|
+
downloadUrl: 'https://test.example.com',
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
describe('VailixSDK Singleton Pattern', () => {
|
|
88
|
+
beforeEach(async () => {
|
|
89
|
+
// Reset singleton state before each test
|
|
90
|
+
await VailixSDK.destroy();
|
|
91
|
+
vi.clearAllMocks();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe('create()', () => {
|
|
95
|
+
it('should return the same instance on multiple sequential calls', async () => {
|
|
96
|
+
const sdk1 = await VailixSDK.create(TEST_CONFIG);
|
|
97
|
+
const sdk2 = await VailixSDK.create(TEST_CONFIG);
|
|
98
|
+
const sdk3 = await VailixSDK.create(TEST_CONFIG);
|
|
99
|
+
|
|
100
|
+
expect(sdk1).toBe(sdk2);
|
|
101
|
+
expect(sdk2).toBe(sdk3);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('should return the same instance on concurrent calls (race condition test)', async () => {
|
|
105
|
+
// Launch multiple concurrent create() calls
|
|
106
|
+
const promises = [
|
|
107
|
+
VailixSDK.create(TEST_CONFIG),
|
|
108
|
+
VailixSDK.create(TEST_CONFIG),
|
|
109
|
+
VailixSDK.create(TEST_CONFIG),
|
|
110
|
+
VailixSDK.create(TEST_CONFIG),
|
|
111
|
+
VailixSDK.create(TEST_CONFIG),
|
|
112
|
+
];
|
|
113
|
+
|
|
114
|
+
const results = await Promise.all(promises);
|
|
115
|
+
|
|
116
|
+
// All should be the exact same instance
|
|
117
|
+
const firstInstance = results[0];
|
|
118
|
+
results.forEach((sdk) => {
|
|
119
|
+
expect(sdk).toBe(firstInstance);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('should only initialize once even with concurrent calls', async () => {
|
|
124
|
+
const { initializeDatabase } = await import('../src/db');
|
|
125
|
+
|
|
126
|
+
// Launch concurrent calls
|
|
127
|
+
await Promise.all([
|
|
128
|
+
VailixSDK.create(TEST_CONFIG),
|
|
129
|
+
VailixSDK.create(TEST_CONFIG),
|
|
130
|
+
VailixSDK.create(TEST_CONFIG),
|
|
131
|
+
]);
|
|
132
|
+
|
|
133
|
+
// initializeDatabase should only be called once
|
|
134
|
+
expect(initializeDatabase).toHaveBeenCalledTimes(1);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('should use config from first call only', async () => {
|
|
138
|
+
const config1 = { ...TEST_CONFIG, reportUrl: 'https://first.com' };
|
|
139
|
+
const config2 = { ...TEST_CONFIG, reportUrl: 'https://second.com' };
|
|
140
|
+
|
|
141
|
+
const sdk1 = await VailixSDK.create(config1);
|
|
142
|
+
const sdk2 = await VailixSDK.create(config2);
|
|
143
|
+
|
|
144
|
+
// Both should be the same instance
|
|
145
|
+
expect(sdk1).toBe(sdk2);
|
|
146
|
+
// The instance should use the first config (verified indirectly by same instance)
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe('isInitialized()', () => {
|
|
151
|
+
it('should return false before create() is called', () => {
|
|
152
|
+
expect(VailixSDK.isInitialized()).toBe(false);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('should return true after create() completes', async () => {
|
|
156
|
+
await VailixSDK.create(TEST_CONFIG);
|
|
157
|
+
expect(VailixSDK.isInitialized()).toBe(true);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('should return false after destroy() is called', async () => {
|
|
161
|
+
await VailixSDK.create(TEST_CONFIG);
|
|
162
|
+
expect(VailixSDK.isInitialized()).toBe(true);
|
|
163
|
+
|
|
164
|
+
await VailixSDK.destroy();
|
|
165
|
+
expect(VailixSDK.isInitialized()).toBe(false);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
describe('destroy()', () => {
|
|
170
|
+
it('should allow creating a new instance after destroy', async () => {
|
|
171
|
+
const sdk1 = await VailixSDK.create(TEST_CONFIG);
|
|
172
|
+
await VailixSDK.destroy();
|
|
173
|
+
|
|
174
|
+
const sdk2 = await VailixSDK.create(TEST_CONFIG);
|
|
175
|
+
|
|
176
|
+
// Should be different instances
|
|
177
|
+
expect(sdk1).not.toBe(sdk2);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('should be safe to call destroy() multiple times', async () => {
|
|
181
|
+
await VailixSDK.create(TEST_CONFIG);
|
|
182
|
+
|
|
183
|
+
// Multiple destroy calls should not throw
|
|
184
|
+
await VailixSDK.destroy();
|
|
185
|
+
await VailixSDK.destroy();
|
|
186
|
+
await VailixSDK.destroy();
|
|
187
|
+
|
|
188
|
+
expect(VailixSDK.isInitialized()).toBe(false);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('should be safe to call destroy() without prior create()', async () => {
|
|
192
|
+
// Should not throw
|
|
193
|
+
await VailixSDK.destroy();
|
|
194
|
+
expect(VailixSDK.isInitialized()).toBe(false);
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
describe('Race condition stress test', () => {
|
|
199
|
+
it('should handle many concurrent calls correctly', async () => {
|
|
200
|
+
const CONCURRENT_CALLS = 100;
|
|
201
|
+
|
|
202
|
+
const promises = Array.from({ length: CONCURRENT_CALLS }, () =>
|
|
203
|
+
VailixSDK.create(TEST_CONFIG)
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
const results = await Promise.all(promises);
|
|
207
|
+
|
|
208
|
+
// All should be the exact same instance
|
|
209
|
+
const firstInstance = results[0];
|
|
210
|
+
expect(results.every(sdk => sdk === firstInstance)).toBe(true);
|
|
211
|
+
|
|
212
|
+
// Verify initialization only happened once
|
|
213
|
+
const { initializeDatabase } = await import('../src/db');
|
|
214
|
+
expect(initializeDatabase).toHaveBeenCalledTimes(1);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('should handle interleaved create and destroy calls', async () => {
|
|
218
|
+
// First creation
|
|
219
|
+
const sdk1 = await VailixSDK.create(TEST_CONFIG);
|
|
220
|
+
|
|
221
|
+
// Destroy and recreate
|
|
222
|
+
await VailixSDK.destroy();
|
|
223
|
+
const sdk2 = await VailixSDK.create(TEST_CONFIG);
|
|
224
|
+
|
|
225
|
+
// Destroy and recreate again
|
|
226
|
+
await VailixSDK.destroy();
|
|
227
|
+
const sdk3 = await VailixSDK.create(TEST_CONFIG);
|
|
228
|
+
|
|
229
|
+
// All should be different instances
|
|
230
|
+
expect(sdk1).not.toBe(sdk2);
|
|
231
|
+
expect(sdk2).not.toBe(sdk3);
|
|
232
|
+
expect(sdk1).not.toBe(sdk3);
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
});
|
package/dist/index.js
CHANGED
|
@@ -73,7 +73,7 @@ var IdentityManager = class {
|
|
|
73
73
|
try {
|
|
74
74
|
let key = await this.keyStorage.getKey();
|
|
75
75
|
if (!key) {
|
|
76
|
-
key = (0, import_react_native_quick_crypto.
|
|
76
|
+
key = (0, import_react_native_quick_crypto.randomBytes)(32).toString("hex");
|
|
77
77
|
await this.keyStorage.setKey(key);
|
|
78
78
|
}
|
|
79
79
|
this.masterKey = key;
|
|
@@ -862,14 +862,17 @@ var VailixSDK = class _VailixSDK {
|
|
|
862
862
|
if (_VailixSDK.initPromise) {
|
|
863
863
|
return _VailixSDK.initPromise;
|
|
864
864
|
}
|
|
865
|
-
_VailixSDK.initPromise =
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
865
|
+
_VailixSDK.initPromise = (async () => {
|
|
866
|
+
try {
|
|
867
|
+
const sdk = await _VailixSDK.doCreate(config);
|
|
868
|
+
_VailixSDK.instance = sdk;
|
|
869
|
+
return sdk;
|
|
870
|
+
} catch (error) {
|
|
871
|
+
_VailixSDK.initPromise = null;
|
|
872
|
+
throw error;
|
|
873
|
+
}
|
|
874
|
+
})();
|
|
875
|
+
return _VailixSDK.initPromise;
|
|
873
876
|
}
|
|
874
877
|
/**
|
|
875
878
|
* Internal initialization logic (extracted for singleton pattern).
|
package/dist/index.mjs
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
|
-
import { createCipheriv, randomBytes } from "react-native-quick-crypto";
|
|
2
|
+
import { createCipheriv, randomBytes as randomBytes2 } from "react-native-quick-crypto";
|
|
3
3
|
|
|
4
4
|
// src/identity.ts
|
|
5
|
-
import { createHmac,
|
|
5
|
+
import { createHmac, randomBytes } from "react-native-quick-crypto";
|
|
6
6
|
import * as SecureStore from "expo-secure-store";
|
|
7
7
|
|
|
8
8
|
// src/utils.ts
|
|
@@ -37,7 +37,7 @@ var IdentityManager = class {
|
|
|
37
37
|
try {
|
|
38
38
|
let key = await this.keyStorage.getKey();
|
|
39
39
|
if (!key) {
|
|
40
|
-
key =
|
|
40
|
+
key = randomBytes(32).toString("hex");
|
|
41
41
|
await this.keyStorage.setKey(key);
|
|
42
42
|
}
|
|
43
43
|
this.masterKey = key;
|
|
@@ -89,7 +89,7 @@ var IdentityManager = class {
|
|
|
89
89
|
// src/storage.ts
|
|
90
90
|
import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core";
|
|
91
91
|
import { lt, gt, inArray } from "drizzle-orm";
|
|
92
|
-
import { randomUUID
|
|
92
|
+
import { randomUUID } from "react-native-quick-crypto";
|
|
93
93
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
|
94
94
|
var SCAN_HISTORY_KEY = "vailix_scan_history";
|
|
95
95
|
var scannedEvents = sqliteTable("scanned_events", {
|
|
@@ -123,7 +123,7 @@ var StorageService = class _StorageService {
|
|
|
123
123
|
return Date.now() - lastScan >= this.rescanIntervalMs;
|
|
124
124
|
}
|
|
125
125
|
async logScan(rpi, metadataKey, timestamp) {
|
|
126
|
-
await this.db.insert(scannedEvents).values({ id:
|
|
126
|
+
await this.db.insert(scannedEvents).values({ id: randomUUID(), rpi, metadataKey, timestamp });
|
|
127
127
|
this.lastScanByRpi.set(rpi, Date.now());
|
|
128
128
|
if (this.lastScanByRpi.size > _StorageService.MAX_SCAN_HISTORY_SIZE) {
|
|
129
129
|
const entries2 = Array.from(this.lastScanByRpi.entries());
|
|
@@ -826,14 +826,17 @@ var VailixSDK = class _VailixSDK {
|
|
|
826
826
|
if (_VailixSDK.initPromise) {
|
|
827
827
|
return _VailixSDK.initPromise;
|
|
828
828
|
}
|
|
829
|
-
_VailixSDK.initPromise =
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
829
|
+
_VailixSDK.initPromise = (async () => {
|
|
830
|
+
try {
|
|
831
|
+
const sdk = await _VailixSDK.doCreate(config);
|
|
832
|
+
_VailixSDK.instance = sdk;
|
|
833
|
+
return sdk;
|
|
834
|
+
} catch (error) {
|
|
835
|
+
_VailixSDK.initPromise = null;
|
|
836
|
+
throw error;
|
|
837
|
+
}
|
|
838
|
+
})();
|
|
839
|
+
return _VailixSDK.initPromise;
|
|
837
840
|
}
|
|
838
841
|
/**
|
|
839
842
|
* Internal initialization logic (extracted for singleton pattern).
|
|
@@ -1052,7 +1055,7 @@ var VailixSDK = class _VailixSDK {
|
|
|
1052
1055
|
throw new Error(`Metadata exceeds maximum size of ${_VailixSDK.MAX_METADATA_SIZE} bytes`);
|
|
1053
1056
|
}
|
|
1054
1057
|
const key = Buffer.from(keyHex, "hex");
|
|
1055
|
-
const iv =
|
|
1058
|
+
const iv = randomBytes2(12);
|
|
1056
1059
|
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
|
1057
1060
|
let encrypted = cipher.update(jsonStr, "utf8", "base64");
|
|
1058
1061
|
encrypted += cipher.final("base64");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vailix/mask",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.5",
|
|
4
4
|
"description": "Privacy-preserving proximity tracing SDK for React Native & Expo",
|
|
5
5
|
"author": "Gil Eyni",
|
|
6
6
|
"keywords": [
|
|
@@ -52,7 +52,8 @@
|
|
|
52
52
|
"devDependencies": {
|
|
53
53
|
"drizzle-kit": "^0.30.0",
|
|
54
54
|
"typescript": "^5.7.0",
|
|
55
|
-
"tsup": "^8.0.0"
|
|
55
|
+
"tsup": "^8.0.0",
|
|
56
|
+
"vitest": "^4.0.0"
|
|
56
57
|
},
|
|
57
58
|
"scripts": {
|
|
58
59
|
"build": "tsup src/index.ts --format esm,cjs --dts --clean",
|
package/src/identity.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createHmac,
|
|
1
|
+
import { createHmac, randomBytes } from 'react-native-quick-crypto';
|
|
2
2
|
import * as SecureStore from 'expo-secure-store';
|
|
3
3
|
import type { KeyStorage } from './types';
|
|
4
4
|
import { generateDisplayName } from './utils';
|
|
@@ -30,7 +30,7 @@ export class IdentityManager {
|
|
|
30
30
|
try {
|
|
31
31
|
let key = await this.keyStorage.getKey();
|
|
32
32
|
if (!key) {
|
|
33
|
-
key =
|
|
33
|
+
key = randomBytes(32).toString('hex');
|
|
34
34
|
await this.keyStorage.setKey(key);
|
|
35
35
|
}
|
|
36
36
|
this.masterKey = key;
|
package/src/index.ts
CHANGED
|
@@ -63,22 +63,26 @@ export class VailixSDK {
|
|
|
63
63
|
return VailixSDK.instance;
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
-
// Initialization in progress - wait for it
|
|
66
|
+
// Initialization in progress - wait for it
|
|
67
67
|
if (VailixSDK.initPromise) {
|
|
68
68
|
return VailixSDK.initPromise;
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
//
|
|
72
|
-
|
|
71
|
+
// CRITICAL: Set promise SYNCHRONOUSLY before any await
|
|
72
|
+
// This makes the check-and-set atomic within the same microtask
|
|
73
|
+
VailixSDK.initPromise = (async () => {
|
|
74
|
+
try {
|
|
75
|
+
const sdk = await VailixSDK.doCreate(config);
|
|
76
|
+
VailixSDK.instance = sdk;
|
|
77
|
+
return sdk;
|
|
78
|
+
} catch (error) {
|
|
79
|
+
// Clear promise on failure so retry is possible
|
|
80
|
+
VailixSDK.initPromise = null;
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
})();
|
|
73
84
|
|
|
74
|
-
|
|
75
|
-
VailixSDK.instance = await VailixSDK.initPromise;
|
|
76
|
-
return VailixSDK.instance;
|
|
77
|
-
} catch (error) {
|
|
78
|
-
// Clear promise on failure so retry is possible
|
|
79
|
-
VailixSDK.initPromise = null;
|
|
80
|
-
throw error;
|
|
81
|
-
}
|
|
85
|
+
return VailixSDK.initPromise;
|
|
82
86
|
}
|
|
83
87
|
|
|
84
88
|
/**
|