@oxyhq/core 12.8.0 → 12.9.0
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/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/boot/sessionColdBoot.js +16 -3
- package/dist/cjs/crypto/identityMarker.js +255 -0
- package/dist/cjs/crypto/keyManager.js +844 -106
- package/dist/cjs/index.js +8 -4
- package/dist/cjs/mixins/OxyServices.auth.js +21 -6
- package/dist/cjs/mixins/OxyServices.deviceBoot.js +9 -1
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/boot/sessionColdBoot.js +16 -3
- package/dist/esm/crypto/identityMarker.js +248 -0
- package/dist/esm/crypto/keyManager.js +843 -106
- package/dist/esm/index.js +2 -1
- package/dist/esm/mixins/OxyServices.auth.js +21 -6
- package/dist/esm/mixins/OxyServices.deviceBoot.js +9 -1
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/boot/sessionColdBoot.d.ts +25 -0
- package/dist/types/crypto/identityMarker.d.ts +94 -0
- package/dist/types/crypto/keyManager.d.ts +212 -3
- package/dist/types/index.d.ts +4 -2
- package/dist/types/mixins/OxyServices.auth.d.ts +27 -2
- package/dist/types/mixins/OxyServices.deviceBoot.d.ts +8 -0
- package/package.json +1 -1
- package/src/boot/__tests__/sessionColdBoot.test.ts +113 -0
- package/src/boot/sessionColdBoot.ts +42 -3
- package/src/crypto/__tests__/identityMocks.ts +125 -0
- package/src/crypto/__tests__/keyManager.atomicity.test.ts +79 -94
- package/src/crypto/__tests__/keyManager.cacheSafety.test.ts +175 -0
- package/src/crypto/__tests__/keyManager.identityStatus.test.ts +217 -0
- package/src/crypto/__tests__/keyManager.recoveryLadder.test.ts +179 -0
- package/src/crypto/__tests__/keyManager.storageMigration.test.ts +227 -0
- package/src/crypto/__tests__/keyManager.test.ts +77 -87
- package/src/crypto/identityMarker.ts +291 -0
- package/src/crypto/keyManager.ts +1026 -105
- package/src/index.ts +7 -1
- package/src/mixins/OxyServices.auth.ts +31 -7
- package/src/mixins/OxyServices.deviceBoot.ts +9 -1
- package/src/mixins/__tests__/OxyServices.deviceBoot.test.ts +4 -2
- package/src/mixins/__tests__/commonsSignIn.test.ts +84 -1
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lazy migration of the identity from the legacy shared-`key_v1` slots onto the
|
|
3
|
+
* isolated v2 slots (primary service `oxy_identity`, backup service
|
|
4
|
+
* `oxy_identity_backup`).
|
|
5
|
+
*
|
|
6
|
+
* INVARIANT under test: at every instant ≥1 readable copy of a previously
|
|
7
|
+
* existing identity remains — legacy is deleted ONLY after the v2 copy is
|
|
8
|
+
* verified re-readable; a failed v2 write serves legacy for the session; any
|
|
9
|
+
* read-throw defers everything (zero writes/deletes).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { setPlatformOS } from '../../utils/platform';
|
|
13
|
+
|
|
14
|
+
jest.mock(
|
|
15
|
+
'expo-secure-store',
|
|
16
|
+
() => {
|
|
17
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
18
|
+
const { createSecureStoreMock } = require('./identityMocks');
|
|
19
|
+
return createSecureStoreMock();
|
|
20
|
+
},
|
|
21
|
+
{ virtual: true },
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
jest.mock(
|
|
25
|
+
'expo-crypto',
|
|
26
|
+
() => ({
|
|
27
|
+
__esModule: true,
|
|
28
|
+
getRandomBytes: (length: number) => {
|
|
29
|
+
const out = new Uint8Array(length);
|
|
30
|
+
for (let i = 0; i < length; i++) out[i] = (Math.random() * 256) & 0xff;
|
|
31
|
+
return out;
|
|
32
|
+
},
|
|
33
|
+
digestStringAsync: async () => '0'.repeat(64),
|
|
34
|
+
CryptoDigestAlgorithm: { SHA256: 'SHA-256' },
|
|
35
|
+
}),
|
|
36
|
+
{ virtual: true },
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
jest.mock('@oxyhq/protocol', () => {
|
|
40
|
+
const actual = jest.requireActual('@oxyhq/protocol');
|
|
41
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
42
|
+
const { createAsyncStorageMock } = require('./identityMocks');
|
|
43
|
+
const asyncStorage = createAsyncStorageMock();
|
|
44
|
+
return {
|
|
45
|
+
__esModule: true,
|
|
46
|
+
...actual,
|
|
47
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
48
|
+
loadExpoCrypto: async () => require('expo-crypto'),
|
|
49
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
50
|
+
loadSecureStore: async () => require('expo-secure-store'),
|
|
51
|
+
loadAsyncStorage: async () => ({ default: asyncStorage }),
|
|
52
|
+
loadSharedIdentityBridge: async () => null,
|
|
53
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
54
|
+
loadNodeCrypto: async () => require('crypto'),
|
|
55
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
56
|
+
getRandomBytesRN: (n: number) => require('expo-crypto').getRandomBytes(n),
|
|
57
|
+
};
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const PRIMARY_SVC = 'oxy_identity';
|
|
61
|
+
const BACKUP_SVC = 'oxy_identity_backup';
|
|
62
|
+
const V2_PRIV = 'oxy_identity_private_key_v2';
|
|
63
|
+
const V2_PUB = 'oxy_identity_public_key_v2';
|
|
64
|
+
const V2_BPRIV = 'oxy_identity_backup_private_key_v2';
|
|
65
|
+
const V2_BPUB = 'oxy_identity_backup_public_key_v2';
|
|
66
|
+
// Legacy slots (default keychain service).
|
|
67
|
+
const L_PRIV = 'oxy_identity_private_key';
|
|
68
|
+
const L_PUB = 'oxy_identity_public_key';
|
|
69
|
+
const L_BPRIV = 'oxy_identity_backup_private_key';
|
|
70
|
+
const L_BPUB = 'oxy_identity_backup_public_key';
|
|
71
|
+
|
|
72
|
+
interface SecureStoreTestHandle {
|
|
73
|
+
__resetStore__: () => void;
|
|
74
|
+
__getRaw__: (key: string, service?: string) => string | null;
|
|
75
|
+
__setRaw__: (key: string, value: string, service?: string) => void;
|
|
76
|
+
__simulateKeystoreDeath__: (service: string) => void;
|
|
77
|
+
__failPlan__: { failKey?: string; failOp?: 'set' | 'get'; failTimes?: number; failService?: string };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
interface MigrationCapable {
|
|
81
|
+
_ensureIdentitySlotsMigrated: () => Promise<{ mode: 'v2' | 'legacy' | 'deferred' }>;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
describe('KeyManager identity slot migration (legacy → v2)', () => {
|
|
85
|
+
let KeyManager: typeof import('../keyManager').KeyManager;
|
|
86
|
+
let ss: SecureStoreTestHandle;
|
|
87
|
+
|
|
88
|
+
const resetCaches = () => {
|
|
89
|
+
const km = KeyManager as unknown as {
|
|
90
|
+
cachedPublicKey: unknown;
|
|
91
|
+
cachedHasIdentity: unknown;
|
|
92
|
+
cachedPublicKeyResolved: unknown;
|
|
93
|
+
};
|
|
94
|
+
km.cachedPublicKey = null;
|
|
95
|
+
km.cachedHasIdentity = null;
|
|
96
|
+
km.cachedPublicKeyResolved = false;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
beforeAll(() => {
|
|
100
|
+
setPlatformOS('ios');
|
|
101
|
+
(globalThis as unknown as { navigator: unknown }).navigator = { product: 'ReactNative' };
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
beforeEach(async () => {
|
|
105
|
+
jest.resetModules();
|
|
106
|
+
setPlatformOS('ios');
|
|
107
|
+
ss = (await import('expo-secure-store' as string)) as unknown as SecureStoreTestHandle;
|
|
108
|
+
ss.__resetStore__();
|
|
109
|
+
const km = await import('../keyManager');
|
|
110
|
+
KeyManager = km.KeyManager;
|
|
111
|
+
resetCaches();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
/** Seed a valid legacy identity (default keychain service) without migrating. */
|
|
115
|
+
const seedLegacyIdentity = async (): Promise<{ privateKey: string; publicKey: string }> => {
|
|
116
|
+
const kp = await KeyManager.generateKeyPair();
|
|
117
|
+
ss.__setRaw__(L_PRIV, kp.privateKey);
|
|
118
|
+
ss.__setRaw__(L_PUB, kp.publicKey);
|
|
119
|
+
ss.__setRaw__(L_BPRIV, kp.privateKey);
|
|
120
|
+
ss.__setRaw__(L_BPUB, kp.publicKey);
|
|
121
|
+
return kp;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
it('migrates a healthy legacy identity into the v2 slots and deletes the legacy copy', async () => {
|
|
125
|
+
const kp = await seedLegacyIdentity();
|
|
126
|
+
|
|
127
|
+
// Trigger migration via any accessor.
|
|
128
|
+
expect(await KeyManager.getPublicKey()).toBe(kp.publicKey.toLowerCase());
|
|
129
|
+
|
|
130
|
+
// v2 now owns the identity...
|
|
131
|
+
expect(ss.__getRaw__(V2_PRIV, PRIMARY_SVC)).toBe(kp.privateKey.toLowerCase());
|
|
132
|
+
expect(ss.__getRaw__(V2_PUB, PRIMARY_SVC)).toBe(kp.publicKey.toLowerCase());
|
|
133
|
+
// ...and the legacy copy is gone (only after the v2 copy verified).
|
|
134
|
+
expect(ss.__getRaw__(L_PRIV)).toBeNull();
|
|
135
|
+
expect(ss.__getRaw__(L_PUB)).toBeNull();
|
|
136
|
+
expect(await KeyManager.hasIdentity()).toBe(true);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('is idempotent — a second run leaves v2 intact and does not resurrect legacy', async () => {
|
|
140
|
+
const kp = await seedLegacyIdentity();
|
|
141
|
+
await KeyManager.getPublicKey();
|
|
142
|
+
resetCaches();
|
|
143
|
+
// Re-run (fresh caches, same process → migration memoized as v2).
|
|
144
|
+
expect(await KeyManager.getPublicKey()).toBe(kp.publicKey.toLowerCase());
|
|
145
|
+
expect(ss.__getRaw__(V2_PRIV, PRIMARY_SVC)).toBe(kp.privateKey.toLowerCase());
|
|
146
|
+
expect(ss.__getRaw__(L_PRIV)).toBeNull();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('interrupted-after-copy (v2 healthy AND legacy still present) → cleans up legacy', async () => {
|
|
150
|
+
// Model a prior run that copied to v2 but crashed before deleting legacy.
|
|
151
|
+
const kp = await KeyManager.generateKeyPair();
|
|
152
|
+
ss.__setRaw__(V2_PRIV, kp.privateKey.toLowerCase(), PRIMARY_SVC);
|
|
153
|
+
ss.__setRaw__(V2_PUB, kp.publicKey.toLowerCase(), PRIMARY_SVC);
|
|
154
|
+
ss.__setRaw__(L_PRIV, kp.privateKey);
|
|
155
|
+
ss.__setRaw__(L_PUB, kp.publicKey);
|
|
156
|
+
|
|
157
|
+
expect(await KeyManager.getPublicKey()).toBe(kp.publicKey.toLowerCase());
|
|
158
|
+
// v2 kept, legacy cleaned up.
|
|
159
|
+
expect(ss.__getRaw__(V2_PRIV, PRIMARY_SVC)).toBe(kp.privateKey.toLowerCase());
|
|
160
|
+
expect(ss.__getRaw__(L_PRIV)).toBeNull();
|
|
161
|
+
expect(ss.__getRaw__(L_PUB)).toBeNull();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('a failed v2 write serves the identity from legacy this session and never deletes legacy', async () => {
|
|
165
|
+
const kp = await seedLegacyIdentity();
|
|
166
|
+
// Make every v2 primary public write fail → migration cannot verify v2.
|
|
167
|
+
ss.__failPlan__.failOp = 'set';
|
|
168
|
+
ss.__failPlan__.failKey = V2_PUB;
|
|
169
|
+
ss.__failPlan__.failService = PRIMARY_SVC;
|
|
170
|
+
|
|
171
|
+
// Reads still succeed — served from the UNTOUCHED legacy slots.
|
|
172
|
+
expect(await KeyManager.getPublicKey()).toBe(kp.publicKey);
|
|
173
|
+
expect(await KeyManager.hasIdentity()).toBe(true);
|
|
174
|
+
// Legacy is intact; v2 primary was not left half-written.
|
|
175
|
+
expect(ss.__getRaw__(L_PRIV)).toBe(kp.privateKey);
|
|
176
|
+
expect(ss.__getRaw__(V2_PRIV, PRIMARY_SVC)).toBeNull();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('a legacy read throwing defers everything (unavailable, zero writes)', async () => {
|
|
180
|
+
await seedLegacyIdentity();
|
|
181
|
+
// v2 read succeeds (empty); the legacy private read throws → defer.
|
|
182
|
+
ss.__failPlan__.failOp = 'get';
|
|
183
|
+
ss.__failPlan__.failKey = L_PRIV;
|
|
184
|
+
ss.__failPlan__.failService = 'default';
|
|
185
|
+
|
|
186
|
+
const status = await KeyManager.getIdentityStatus();
|
|
187
|
+
expect(status.state).toBe('unavailable');
|
|
188
|
+
await expect(KeyManager.hasIdentity()).rejects.toMatchObject({ name: 'IdentityUnavailableError' });
|
|
189
|
+
// Nothing was written to v2.
|
|
190
|
+
expect(ss.__getRaw__(V2_PRIV, PRIMARY_SVC)).toBeNull();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('a keystore death BEFORE migration (loss predates markers) → absent, not a phantom identity', async () => {
|
|
194
|
+
await seedLegacyIdentity();
|
|
195
|
+
// key_v1 death: the legacy (default-service) entries are deleted on read.
|
|
196
|
+
ss.__simulateKeystoreDeath__('default');
|
|
197
|
+
|
|
198
|
+
const status = await KeyManager.getIdentityStatus();
|
|
199
|
+
// No marker existed (loss predates markers) → a genuine fresh device.
|
|
200
|
+
expect(status.state).toBe('absent');
|
|
201
|
+
expect(ss.__getRaw__(V2_PRIV, PRIMARY_SVC)).toBeNull();
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('a fresh device (no legacy, no v2) resolves to the empty v2 layout', async () => {
|
|
205
|
+
const status = await KeyManager.getIdentityStatus();
|
|
206
|
+
expect(status.state).toBe('absent');
|
|
207
|
+
expect(await KeyManager.hasIdentity()).toBe(false);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('concurrent callers share ONE migration run', async () => {
|
|
211
|
+
const kp = await seedLegacyIdentity();
|
|
212
|
+
const km = KeyManager as unknown as MigrationCapable;
|
|
213
|
+
const [a, b] = await Promise.all([km._ensureIdentitySlotsMigrated(), km._ensureIdentitySlotsMigrated()]);
|
|
214
|
+
// Same memoized result object → a single shared run.
|
|
215
|
+
expect(a).toBe(b);
|
|
216
|
+
expect(a.mode).toBe('v2');
|
|
217
|
+
expect(ss.__getRaw__(V2_PRIV, PRIMARY_SVC)).toBe(kp.privateKey.toLowerCase());
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('migrates a healthy legacy backup into the v2 backup slot', async () => {
|
|
221
|
+
const kp = await seedLegacyIdentity();
|
|
222
|
+
await KeyManager.getPublicKey();
|
|
223
|
+
expect(ss.__getRaw__(V2_BPRIV, BACKUP_SVC)).toBe(kp.privateKey.toLowerCase());
|
|
224
|
+
expect(ss.__getRaw__(V2_BPUB, BACKUP_SVC)).toBe(kp.publicKey.toLowerCase());
|
|
225
|
+
expect(ss.__getRaw__(L_BPRIV)).toBeNull();
|
|
226
|
+
});
|
|
227
|
+
});
|
|
@@ -24,25 +24,16 @@
|
|
|
24
24
|
import { setPlatformOS } from '../../utils/platform';
|
|
25
25
|
|
|
26
26
|
// Mock expo-secure-store BEFORE importing KeyManager so the lazy import
|
|
27
|
-
// inside keyManager picks up our in-memory implementation.
|
|
27
|
+
// inside keyManager picks up our in-memory implementation. The shared mock keys
|
|
28
|
+
// entries by `(keychainService ?? 'default') + ' ' + key` so the v2 slot layout
|
|
29
|
+
// (primary service `oxy_identity`, backup service `oxy_identity_backup`) is
|
|
30
|
+
// modeled faithfully.
|
|
28
31
|
jest.mock(
|
|
29
32
|
'expo-secure-store',
|
|
30
33
|
() => {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'WHEN_UNLOCKED_THIS_DEVICE_ONLY',
|
|
35
|
-
WHEN_UNLOCKED: 'WHEN_UNLOCKED',
|
|
36
|
-
setItemAsync: jest.fn(async (key: string, value: string) => {
|
|
37
|
-
store.set(key, value);
|
|
38
|
-
}),
|
|
39
|
-
getItemAsync: jest.fn(async (key: string) => store.get(key) ?? null),
|
|
40
|
-
deleteItemAsync: jest.fn(async (key: string) => {
|
|
41
|
-
store.delete(key);
|
|
42
|
-
}),
|
|
43
|
-
__resetStore__: () => store.clear(),
|
|
44
|
-
__getStore__: () => store,
|
|
45
|
-
};
|
|
34
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
35
|
+
const { createSecureStoreMock } = require('./identityMocks');
|
|
36
|
+
return createSecureStoreMock();
|
|
46
37
|
},
|
|
47
38
|
{ virtual: true },
|
|
48
39
|
);
|
|
@@ -72,33 +63,44 @@ jest.mock(
|
|
|
72
63
|
// Node's built-in `crypto`, not `expo-*`. For the test suite to exercise the
|
|
73
64
|
// RN code paths, we override only the platform loaders to delegate to the
|
|
74
65
|
// virtual `expo-*` modules registered above, keeping every other protocol
|
|
75
|
-
// export (canonical bytes, signing, the platform predicates) real.
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
66
|
+
// export (canonical bytes, signing, the platform predicates) real. AsyncStorage
|
|
67
|
+
// is a real in-memory map so the identity marker + advisory migration flag work.
|
|
68
|
+
jest.mock('@oxyhq/protocol', () => {
|
|
69
|
+
const actual = jest.requireActual('@oxyhq/protocol');
|
|
70
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
71
|
+
const { createAsyncStorageMock } = require('./identityMocks');
|
|
72
|
+
const asyncStorage = createAsyncStorageMock();
|
|
73
|
+
return {
|
|
74
|
+
__esModule: true,
|
|
75
|
+
...actual,
|
|
80
76
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
81
|
-
|
|
82
|
-
},
|
|
83
|
-
loadSecureStore: async () => {
|
|
77
|
+
loadExpoCrypto: async () => require('expo-crypto'),
|
|
84
78
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
// Tests don't currently exercise AsyncStorage paths; return a stub
|
|
89
|
-
// shaped like the real module so accidental calls fail loudly.
|
|
90
|
-
return { default: { getItem: async () => null, setItem: async () => undefined, removeItem: async () => undefined } };
|
|
91
|
-
},
|
|
92
|
-
loadNodeCrypto: async () => {
|
|
79
|
+
loadSecureStore: async () => require('expo-secure-store'),
|
|
80
|
+
loadAsyncStorage: async () => ({ default: asyncStorage }),
|
|
81
|
+
loadSharedIdentityBridge: async () => null,
|
|
93
82
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
94
|
-
|
|
95
|
-
},
|
|
96
|
-
getRandomBytesRN: (n: number) => {
|
|
83
|
+
loadNodeCrypto: async () => require('crypto'),
|
|
97
84
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
85
|
+
getRandomBytesRN: (n: number) => require('expo-crypto').getRandomBytes(n),
|
|
86
|
+
};
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// v2 slot key names + keychain services (must match keyManager.ts).
|
|
90
|
+
const PRIMARY_SVC = 'oxy_identity';
|
|
91
|
+
const BACKUP_SVC = 'oxy_identity_backup';
|
|
92
|
+
const V2_PRIV = 'oxy_identity_private_key_v2';
|
|
93
|
+
const V2_PUB = 'oxy_identity_public_key_v2';
|
|
94
|
+
const V2_BPRIV = 'oxy_identity_backup_private_key_v2';
|
|
95
|
+
const V2_BPUB = 'oxy_identity_backup_public_key_v2';
|
|
96
|
+
const V2_BTS = 'oxy_identity_backup_timestamp_v2';
|
|
97
|
+
|
|
98
|
+
interface SecureStoreTestHandle {
|
|
99
|
+
__resetStore__: () => void;
|
|
100
|
+
__getRaw__: (key: string, service?: string) => string | null;
|
|
101
|
+
__setRaw__: (key: string, value: string, service?: string) => void;
|
|
102
|
+
__deleteRaw__: (key: string, service?: string) => void;
|
|
103
|
+
}
|
|
102
104
|
|
|
103
105
|
describe('KeyManager safety invariants', () => {
|
|
104
106
|
let KeyManager: typeof import('../keyManager').KeyManager;
|
|
@@ -123,14 +125,23 @@ describe('KeyManager safety invariants', () => {
|
|
|
123
125
|
const km = await import('../keyManager');
|
|
124
126
|
KeyManager = km.KeyManager;
|
|
125
127
|
IdentityAlreadyExistsError = km.IdentityAlreadyExistsError;
|
|
126
|
-
|
|
127
|
-
(KeyManager as unknown as { cachedPublicKey: unknown; cachedHasIdentity: unknown }).cachedPublicKey = null;
|
|
128
|
-
(KeyManager as unknown as { cachedPublicKey: unknown; cachedHasIdentity: unknown }).cachedHasIdentity = null;
|
|
128
|
+
resetCaches();
|
|
129
129
|
|
|
130
130
|
const rp = await import('../recoveryPhrase');
|
|
131
131
|
RecoveryPhraseService = rp.RecoveryPhraseService;
|
|
132
132
|
});
|
|
133
133
|
|
|
134
|
+
const resetCaches = () => {
|
|
135
|
+
const km = KeyManager as unknown as {
|
|
136
|
+
cachedPublicKey: unknown;
|
|
137
|
+
cachedHasIdentity: unknown;
|
|
138
|
+
cachedPublicKeyResolved: unknown;
|
|
139
|
+
};
|
|
140
|
+
km.cachedPublicKey = null;
|
|
141
|
+
km.cachedHasIdentity = null;
|
|
142
|
+
km.cachedPublicKeyResolved = false;
|
|
143
|
+
};
|
|
144
|
+
|
|
134
145
|
describe('createIdentity', () => {
|
|
135
146
|
it('persists a complete identity with backup on first call', async () => {
|
|
136
147
|
const publicKey = await KeyManager.createIdentity();
|
|
@@ -138,14 +149,11 @@ describe('KeyManager safety invariants', () => {
|
|
|
138
149
|
|
|
139
150
|
expect(await KeyManager.hasIdentity()).toBe(true);
|
|
140
151
|
expect(await KeyManager.verifyIdentityIntegrity()).toBe(true);
|
|
141
|
-
// Backup was written as part of the atomic persist
|
|
142
|
-
const store = (await import('expo-secure-store' as string)) as unknown as
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
expect(m.get('oxy_identity_backup_private_key')).toBeTruthy();
|
|
147
|
-
expect(m.get('oxy_identity_backup_public_key')).toBeTruthy();
|
|
148
|
-
expect(m.get('oxy_identity_backup_timestamp')).toBeTruthy();
|
|
152
|
+
// Backup was written to the isolated v2 backup slot as part of the atomic persist
|
|
153
|
+
const store = (await import('expo-secure-store' as string)) as unknown as SecureStoreTestHandle;
|
|
154
|
+
expect(store.__getRaw__(V2_BPRIV, BACKUP_SVC)).toBeTruthy();
|
|
155
|
+
expect(store.__getRaw__(V2_BPUB, BACKUP_SVC)).toBeTruthy();
|
|
156
|
+
expect(store.__getRaw__(V2_BTS, BACKUP_SVC)).toBeTruthy();
|
|
149
157
|
});
|
|
150
158
|
|
|
151
159
|
it('refuses to overwrite an existing identity without explicit consent', async () => {
|
|
@@ -193,27 +201,20 @@ describe('KeyManager safety invariants', () => {
|
|
|
193
201
|
});
|
|
194
202
|
|
|
195
203
|
it('returns false when only the private key was written (partial state)', async () => {
|
|
196
|
-
const store = (await import('expo-secure-store' as string)) as unknown as
|
|
197
|
-
__getStore__: () => Map<string, string>;
|
|
198
|
-
};
|
|
199
|
-
const m = store.__getStore__();
|
|
204
|
+
const store = (await import('expo-secure-store' as string)) as unknown as SecureStoreTestHandle;
|
|
200
205
|
// Simulate a half-written identity: private without public
|
|
201
|
-
|
|
206
|
+
store.__setRaw__(V2_PRIV, 'a'.repeat(64), PRIMARY_SVC);
|
|
202
207
|
// Invalidate cache so the next call re-reads
|
|
203
|
-
(
|
|
208
|
+
resetCaches();
|
|
204
209
|
expect(await KeyManager.hasIdentity()).toBe(false);
|
|
205
210
|
});
|
|
206
211
|
|
|
207
212
|
it('returns false when the stored public key does not derive from the private key', async () => {
|
|
208
213
|
await KeyManager.createIdentity();
|
|
209
|
-
const store = (await import('expo-secure-store' as string)) as unknown as
|
|
210
|
-
__getStore__: () => Map<string, string>;
|
|
211
|
-
};
|
|
212
|
-
const m = store.__getStore__();
|
|
214
|
+
const store = (await import('expo-secure-store' as string)) as unknown as SecureStoreTestHandle;
|
|
213
215
|
// Tamper with the stored public key
|
|
214
|
-
|
|
215
|
-
(
|
|
216
|
-
(KeyManager as unknown as { cachedHasIdentity: unknown; cachedPublicKey: unknown }).cachedPublicKey = null;
|
|
216
|
+
store.__setRaw__(V2_PUB, '04' + 'b'.repeat(128), PRIMARY_SVC);
|
|
217
|
+
resetCaches();
|
|
217
218
|
expect(await KeyManager.hasIdentity()).toBe(false);
|
|
218
219
|
});
|
|
219
220
|
});
|
|
@@ -226,11 +227,9 @@ describe('KeyManager safety invariants', () => {
|
|
|
226
227
|
|
|
227
228
|
it('returns false when the stored keys do not match', async () => {
|
|
228
229
|
await KeyManager.createIdentity();
|
|
229
|
-
const store = (await import('expo-secure-store' as string)) as unknown as
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
store.__getStore__().set('oxy_identity_public_key', '04' + 'c'.repeat(128));
|
|
233
|
-
(KeyManager as unknown as { cachedPublicKey: unknown }).cachedPublicKey = null;
|
|
230
|
+
const store = (await import('expo-secure-store' as string)) as unknown as SecureStoreTestHandle;
|
|
231
|
+
store.__setRaw__(V2_PUB, '04' + 'c'.repeat(128), PRIMARY_SVC);
|
|
232
|
+
resetCaches();
|
|
234
233
|
expect(await KeyManager.verifyIdentityIntegrity()).toBe(false);
|
|
235
234
|
});
|
|
236
235
|
});
|
|
@@ -246,40 +245,31 @@ describe('KeyManager safety invariants', () => {
|
|
|
246
245
|
|
|
247
246
|
it('refuses to restore if the backup public key does not match a still-present (broken) primary', async () => {
|
|
248
247
|
await KeyManager.createIdentity();
|
|
249
|
-
const store = (await import('expo-secure-store' as string)) as unknown as
|
|
250
|
-
__getStore__: () => Map<string, string>;
|
|
251
|
-
};
|
|
252
|
-
const m = store.__getStore__();
|
|
248
|
+
const store = (await import('expo-secure-store' as string)) as unknown as SecureStoreTestHandle;
|
|
253
249
|
// Corrupt the primary public key (so integrity fails), but leave the
|
|
254
250
|
// broken primary in place. The backup will not match.
|
|
255
|
-
|
|
251
|
+
store.__setRaw__(V2_PUB, '04' + 'd'.repeat(128), PRIMARY_SVC);
|
|
256
252
|
// Tamper with the backup too — write a backup from a completely
|
|
257
253
|
// different identity.
|
|
258
254
|
const otherPair = await KeyManager.generateKeyPair();
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
(KeyManager as unknown as { cachedPublicKey: unknown; cachedHasIdentity: unknown }).cachedPublicKey = null;
|
|
263
|
-
(KeyManager as unknown as { cachedPublicKey: unknown; cachedHasIdentity: unknown }).cachedHasIdentity = null;
|
|
255
|
+
store.__setRaw__(V2_BPRIV, otherPair.privateKey, BACKUP_SVC);
|
|
256
|
+
store.__setRaw__(V2_BPUB, otherPair.publicKey, BACKUP_SVC);
|
|
257
|
+
resetCaches();
|
|
264
258
|
|
|
265
259
|
const restored = await KeyManager.restoreIdentityFromBackup();
|
|
266
260
|
expect(restored).toBe(false);
|
|
267
261
|
// The (corrupted) primary public key should be unchanged, not the
|
|
268
262
|
// attacker-backup public key.
|
|
269
|
-
expect(
|
|
263
|
+
expect(store.__getRaw__(V2_PUB, PRIMARY_SVC)).not.toBe(otherPair.publicKey);
|
|
270
264
|
});
|
|
271
265
|
|
|
272
266
|
it('restores a missing primary from a valid backup', async () => {
|
|
273
267
|
const original = await KeyManager.createIdentity();
|
|
274
|
-
const store = (await import('expo-secure-store' as string)) as unknown as
|
|
275
|
-
__getStore__: () => Map<string, string>;
|
|
276
|
-
};
|
|
277
|
-
const m = store.__getStore__();
|
|
268
|
+
const store = (await import('expo-secure-store' as string)) as unknown as SecureStoreTestHandle;
|
|
278
269
|
// Wipe primary only
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
(
|
|
282
|
-
(KeyManager as unknown as { cachedPublicKey: unknown; cachedHasIdentity: unknown }).cachedHasIdentity = null;
|
|
270
|
+
store.__deleteRaw__(V2_PRIV, PRIMARY_SVC);
|
|
271
|
+
store.__deleteRaw__(V2_PUB, PRIMARY_SVC);
|
|
272
|
+
resetCaches();
|
|
283
273
|
|
|
284
274
|
const restored = await KeyManager.restoreIdentityFromBackup();
|
|
285
275
|
expect(restored).toBe(true);
|