@oxyhq/core 1.11.11 → 1.11.13
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/CrossDomainAuth.js +3 -1
- package/dist/cjs/HttpService.js +227 -51
- package/dist/cjs/OxyServices.base.js +9 -0
- package/dist/cjs/OxyServices.js +8 -3
- package/dist/cjs/crypto/index.js +3 -1
- package/dist/cjs/crypto/keyManager.js +476 -172
- package/dist/cjs/crypto/polyfill.js +14 -65
- package/dist/cjs/crypto/recoveryPhrase.js +30 -11
- package/dist/cjs/crypto/signatureService.js +25 -60
- package/dist/cjs/i18n/locales/en-US.json +46 -1
- package/dist/cjs/i18n/locales/es-ES.json +46 -1
- package/dist/cjs/i18n/locales/locales/en-US.json +46 -1
- package/dist/cjs/i18n/locales/locales/es-ES.json +46 -1
- package/dist/cjs/index.js +7 -2
- package/dist/cjs/mixins/OxyServices.assets.js +9 -4
- package/dist/cjs/mixins/OxyServices.auth.js +27 -0
- package/dist/cjs/mixins/OxyServices.contacts.js +50 -0
- package/dist/cjs/mixins/OxyServices.features.js +0 -11
- package/dist/cjs/mixins/OxyServices.fedcm.js +4 -3
- package/dist/cjs/mixins/OxyServices.language.js +5 -36
- package/dist/cjs/mixins/OxyServices.redirect.js +6 -2
- package/dist/cjs/mixins/OxyServices.security.js +13 -2
- package/dist/cjs/mixins/OxyServices.user.js +70 -38
- package/dist/cjs/mixins/OxyServices.utility.js +19 -43
- package/dist/cjs/mixins/index.js +11 -3
- package/dist/cjs/utils/accountUtils.js +71 -2
- package/dist/cjs/utils/asyncUtils.js +34 -5
- package/dist/cjs/utils/deviceManager.js +5 -36
- package/dist/cjs/utils/platformCrypto.js +165 -0
- package/dist/cjs/utils/platformCrypto.native.js +123 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/CrossDomainAuth.js +3 -1
- package/dist/esm/HttpService.js +228 -52
- package/dist/esm/OxyServices.base.js +9 -0
- package/dist/esm/OxyServices.js +8 -3
- package/dist/esm/crypto/index.js +1 -1
- package/dist/esm/crypto/keyManager.js +473 -138
- package/dist/esm/crypto/polyfill.js +14 -32
- package/dist/esm/crypto/recoveryPhrase.js +30 -11
- package/dist/esm/crypto/signatureService.js +25 -27
- package/dist/esm/i18n/locales/en-US.json +46 -1
- package/dist/esm/i18n/locales/es-ES.json +46 -1
- package/dist/esm/i18n/locales/locales/en-US.json +46 -1
- package/dist/esm/i18n/locales/locales/es-ES.json +46 -1
- package/dist/esm/index.js +2 -2
- package/dist/esm/mixins/OxyServices.assets.js +9 -4
- package/dist/esm/mixins/OxyServices.auth.js +27 -0
- package/dist/esm/mixins/OxyServices.contacts.js +47 -0
- package/dist/esm/mixins/OxyServices.features.js +0 -11
- package/dist/esm/mixins/OxyServices.fedcm.js +4 -3
- package/dist/esm/mixins/OxyServices.language.js +5 -3
- package/dist/esm/mixins/OxyServices.redirect.js +6 -2
- package/dist/esm/mixins/OxyServices.security.js +13 -2
- package/dist/esm/mixins/OxyServices.user.js +70 -38
- package/dist/esm/mixins/OxyServices.utility.js +19 -10
- package/dist/esm/mixins/index.js +11 -3
- package/dist/esm/utils/accountUtils.js +67 -1
- package/dist/esm/utils/asyncUtils.js +34 -5
- package/dist/esm/utils/deviceManager.js +5 -3
- package/dist/esm/utils/platformCrypto.js +125 -0
- package/dist/esm/utils/platformCrypto.native.js +80 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/HttpService.d.ts +47 -3
- package/dist/types/OxyServices.base.d.ts +7 -0
- package/dist/types/OxyServices.d.ts +36 -3
- package/dist/types/crypto/index.d.ts +1 -1
- package/dist/types/crypto/keyManager.d.ts +110 -9
- package/dist/types/crypto/polyfill.d.ts +3 -1
- package/dist/types/crypto/recoveryPhrase.d.ts +31 -7
- package/dist/types/crypto/signatureService.d.ts +4 -0
- package/dist/types/index.d.ts +4 -3
- package/dist/types/mixins/OxyServices.analytics.d.ts +1 -0
- package/dist/types/mixins/OxyServices.assets.d.ts +6 -10
- package/dist/types/mixins/OxyServices.auth.d.ts +16 -0
- package/dist/types/mixins/OxyServices.contacts.d.ts +99 -0
- package/dist/types/mixins/OxyServices.developer.d.ts +1 -0
- package/dist/types/mixins/OxyServices.devices.d.ts +1 -0
- package/dist/types/mixins/OxyServices.features.d.ts +2 -7
- package/dist/types/mixins/OxyServices.fedcm.d.ts +1 -0
- package/dist/types/mixins/OxyServices.karma.d.ts +1 -0
- package/dist/types/mixins/OxyServices.language.d.ts +1 -0
- package/dist/types/mixins/OxyServices.location.d.ts +1 -0
- package/dist/types/mixins/OxyServices.managedAccounts.d.ts +1 -0
- package/dist/types/mixins/OxyServices.payment.d.ts +1 -0
- package/dist/types/mixins/OxyServices.popup.d.ts +1 -0
- package/dist/types/mixins/OxyServices.privacy.d.ts +1 -0
- package/dist/types/mixins/OxyServices.redirect.d.ts +1 -0
- package/dist/types/mixins/OxyServices.security.d.ts +1 -0
- package/dist/types/mixins/OxyServices.topics.d.ts +1 -0
- package/dist/types/mixins/OxyServices.user.d.ts +40 -11
- package/dist/types/mixins/OxyServices.utility.d.ts +1 -0
- package/dist/types/mixins/index.d.ts +52 -4
- package/dist/types/models/interfaces.d.ts +62 -3
- package/dist/types/utils/accountUtils.d.ts +41 -1
- package/dist/types/utils/asyncUtils.d.ts +6 -2
- package/dist/types/utils/platformCrypto.d.ts +87 -0
- package/dist/types/utils/platformCrypto.native.d.ts +54 -0
- package/package.json +28 -1
- package/src/CrossDomainAuth.ts +12 -10
- package/src/HttpService.ts +264 -51
- package/src/OxyServices.base.ts +10 -0
- package/src/OxyServices.ts +9 -4
- package/src/crypto/__tests__/keyManager.test.ts +336 -0
- package/src/crypto/index.ts +6 -1
- package/src/crypto/keyManager.ts +529 -151
- package/src/crypto/polyfill.ts +14 -34
- package/src/crypto/recoveryPhrase.ts +56 -17
- package/src/crypto/signatureService.ts +25 -29
- package/src/i18n/locales/en-US.json +46 -1
- package/src/i18n/locales/es-ES.json +46 -1
- package/src/index.ts +16 -3
- package/src/mixins/OxyServices.assets.ts +15 -11
- package/src/mixins/OxyServices.auth.ts +28 -0
- package/src/mixins/OxyServices.contacts.ts +73 -0
- package/src/mixins/OxyServices.features.ts +2 -12
- package/src/mixins/OxyServices.fedcm.ts +4 -3
- package/src/mixins/OxyServices.language.ts +6 -4
- package/src/mixins/OxyServices.redirect.ts +6 -2
- package/src/mixins/OxyServices.security.ts +18 -8
- package/src/mixins/OxyServices.user.ts +90 -49
- package/src/mixins/OxyServices.utility.ts +19 -10
- package/src/mixins/index.ts +58 -7
- package/src/models/interfaces.ts +65 -3
- package/src/utils/__tests__/asyncUtils.test.ts +187 -0
- package/src/utils/accountUtils.ts +82 -2
- package/src/utils/asyncUtils.ts +39 -9
- package/src/utils/deviceManager.ts +7 -4
- package/src/utils/platformCrypto.native.ts +101 -0
- package/src/utils/platformCrypto.ts +145 -0
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for KeyManager safety invariants.
|
|
3
|
+
*
|
|
4
|
+
* These tests pin down the no-clobber guarantees that protect users from
|
|
5
|
+
* permanent account loss. Every scenario below corresponds to a real bug
|
|
6
|
+
* that COULD silently destroy an account's only copy of the private key:
|
|
7
|
+
*
|
|
8
|
+
* 1. createIdentity() must refuse to overwrite an existing identity
|
|
9
|
+
* unless { overwrite: true } is passed.
|
|
10
|
+
* 2. importKeyPair() must refuse to clobber a DIFFERENT existing identity.
|
|
11
|
+
* 3. importKeyPair() with the SAME phrase should be idempotent.
|
|
12
|
+
* 4. After createIdentity(), hasIdentity() must report true AND
|
|
13
|
+
* verifyIdentityIntegrity() must succeed AND a backup must exist.
|
|
14
|
+
* 5. RecoveryPhraseService must be a function (same phrase always
|
|
15
|
+
* produces the same public key).
|
|
16
|
+
* 6. RecoveryPhraseService round-trip: derive phrase → restore phrase
|
|
17
|
+
* must yield the exact same public key.
|
|
18
|
+
* 7. restoreIdentityFromBackup() must refuse to overwrite a verifying
|
|
19
|
+
* primary, and must refuse to switch the user to a different
|
|
20
|
+
* identity when the backup public key doesn't match the (broken)
|
|
21
|
+
* primary public key.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { setPlatformOS } from '../../utils/platform';
|
|
25
|
+
|
|
26
|
+
// Mock expo-secure-store BEFORE importing KeyManager so the lazy import
|
|
27
|
+
// inside keyManager picks up our in-memory implementation.
|
|
28
|
+
jest.mock(
|
|
29
|
+
'expo-secure-store',
|
|
30
|
+
() => {
|
|
31
|
+
const store = new Map<string, string>();
|
|
32
|
+
return {
|
|
33
|
+
__esModule: true,
|
|
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
|
+
};
|
|
46
|
+
},
|
|
47
|
+
{ virtual: true },
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
jest.mock(
|
|
51
|
+
'expo-crypto',
|
|
52
|
+
() => ({
|
|
53
|
+
__esModule: true,
|
|
54
|
+
getRandomBytes: (length: number) => {
|
|
55
|
+
// Deterministic-but-distinct random for tests
|
|
56
|
+
const out = new Uint8Array(length);
|
|
57
|
+
for (let i = 0; i < length; i++) {
|
|
58
|
+
out[i] = (Math.random() * 256) & 0xff;
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
},
|
|
62
|
+
digestStringAsync: async () => '0'.repeat(64),
|
|
63
|
+
CryptoDigestAlgorithm: { SHA256: 'SHA-256' },
|
|
64
|
+
}),
|
|
65
|
+
{ virtual: true },
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
// Production code routes platform-specific module loads through
|
|
69
|
+
// `platformCrypto`, which ships in two physical variants on disk
|
|
70
|
+
// (`platformCrypto.ts` / `platformCrypto.react-native.ts`) selected by the
|
|
71
|
+
// consumer's bundler. Jest runs on Node — it picks the default variant,
|
|
72
|
+
// which references Node's built-in `crypto`, not `expo-*`. For the test
|
|
73
|
+
// suite to exercise the RN code paths, we mock the helper module to
|
|
74
|
+
// delegate to the virtual `expo-*` modules registered above.
|
|
75
|
+
jest.mock('../../utils/platformCrypto', () => ({
|
|
76
|
+
__esModule: true,
|
|
77
|
+
loadExpoCrypto: async () => {
|
|
78
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
79
|
+
return require('expo-crypto');
|
|
80
|
+
},
|
|
81
|
+
loadSecureStore: async () => {
|
|
82
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
83
|
+
return require('expo-secure-store');
|
|
84
|
+
},
|
|
85
|
+
loadAsyncStorage: async () => {
|
|
86
|
+
// Tests don't currently exercise AsyncStorage paths; return a stub
|
|
87
|
+
// shaped like the real module so accidental calls fail loudly.
|
|
88
|
+
return { default: { getItem: async () => null, setItem: async () => undefined, removeItem: async () => undefined } };
|
|
89
|
+
},
|
|
90
|
+
loadNodeCrypto: async () => {
|
|
91
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
92
|
+
return require('crypto');
|
|
93
|
+
},
|
|
94
|
+
getRandomBytesRN: (n: number) => {
|
|
95
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
96
|
+
const crypto = require('expo-crypto');
|
|
97
|
+
return crypto.getRandomBytes(n);
|
|
98
|
+
},
|
|
99
|
+
}));
|
|
100
|
+
|
|
101
|
+
describe('KeyManager safety invariants', () => {
|
|
102
|
+
let KeyManager: typeof import('../keyManager').KeyManager;
|
|
103
|
+
let IdentityAlreadyExistsError: typeof import('../keyManager').IdentityAlreadyExistsError;
|
|
104
|
+
let RecoveryPhraseService: typeof import('../recoveryPhrase').RecoveryPhraseService;
|
|
105
|
+
|
|
106
|
+
beforeAll(() => {
|
|
107
|
+
// KeyManager refuses to operate on 'web'; pretend we're on iOS for tests.
|
|
108
|
+
setPlatformOS('ios');
|
|
109
|
+
// navigator.product is checked by some helpers; set it for RN-detection.
|
|
110
|
+
(globalThis as any).navigator = { product: 'ReactNative' };
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
beforeEach(async () => {
|
|
114
|
+
jest.resetModules();
|
|
115
|
+
setPlatformOS('ios');
|
|
116
|
+
const secureStore = (await import('expo-secure-store' as string)) as unknown as {
|
|
117
|
+
__resetStore__: () => void;
|
|
118
|
+
};
|
|
119
|
+
secureStore.__resetStore__();
|
|
120
|
+
|
|
121
|
+
const km = await import('../keyManager');
|
|
122
|
+
KeyManager = km.KeyManager;
|
|
123
|
+
IdentityAlreadyExistsError = km.IdentityAlreadyExistsError;
|
|
124
|
+
// Invalidate caches between tests.
|
|
125
|
+
(KeyManager as unknown as { cachedPublicKey: unknown; cachedHasIdentity: unknown }).cachedPublicKey = null;
|
|
126
|
+
(KeyManager as unknown as { cachedPublicKey: unknown; cachedHasIdentity: unknown }).cachedHasIdentity = null;
|
|
127
|
+
|
|
128
|
+
const rp = await import('../recoveryPhrase');
|
|
129
|
+
RecoveryPhraseService = rp.RecoveryPhraseService;
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe('createIdentity', () => {
|
|
133
|
+
it('persists a complete identity with backup on first call', async () => {
|
|
134
|
+
const publicKey = await KeyManager.createIdentity();
|
|
135
|
+
expect(publicKey).toMatch(/^[0-9a-f]+$/i);
|
|
136
|
+
|
|
137
|
+
expect(await KeyManager.hasIdentity()).toBe(true);
|
|
138
|
+
expect(await KeyManager.verifyIdentityIntegrity()).toBe(true);
|
|
139
|
+
// Backup was written as part of the atomic persist
|
|
140
|
+
const store = (await import('expo-secure-store' as string)) as unknown as {
|
|
141
|
+
__getStore__: () => Map<string, string>;
|
|
142
|
+
};
|
|
143
|
+
const m = store.__getStore__();
|
|
144
|
+
expect(m.get('oxy_identity_backup_private_key')).toBeTruthy();
|
|
145
|
+
expect(m.get('oxy_identity_backup_public_key')).toBeTruthy();
|
|
146
|
+
expect(m.get('oxy_identity_backup_timestamp')).toBeTruthy();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('refuses to overwrite an existing identity without explicit consent', async () => {
|
|
150
|
+
const firstPublicKey = await KeyManager.createIdentity();
|
|
151
|
+
await expect(KeyManager.createIdentity()).rejects.toBeInstanceOf(IdentityAlreadyExistsError);
|
|
152
|
+
// Original identity is unchanged
|
|
153
|
+
expect(await KeyManager.getPublicKey()).toBe(firstPublicKey);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('allows overwrite when explicitly requested', async () => {
|
|
157
|
+
const firstPublicKey = await KeyManager.createIdentity();
|
|
158
|
+
const secondPublicKey = await KeyManager.createIdentity({ overwrite: true });
|
|
159
|
+
expect(secondPublicKey).not.toBe(firstPublicKey);
|
|
160
|
+
expect(await KeyManager.getPublicKey()).toBe(secondPublicKey);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe('importKeyPair', () => {
|
|
165
|
+
it('refuses to clobber a DIFFERENT existing identity', async () => {
|
|
166
|
+
await KeyManager.createIdentity();
|
|
167
|
+
// Generate a separate identity to attempt importing
|
|
168
|
+
const otherPrivate = (await KeyManager.generateKeyPair()).privateKey;
|
|
169
|
+
await expect(KeyManager.importKeyPair(otherPrivate)).rejects.toBeInstanceOf(
|
|
170
|
+
IdentityAlreadyExistsError,
|
|
171
|
+
);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('is a no-op refresh when importing the SAME identity', async () => {
|
|
175
|
+
const firstPublic = await KeyManager.createIdentity();
|
|
176
|
+
const currentPrivate = await KeyManager.getPrivateKey();
|
|
177
|
+
if (!currentPrivate) throw new Error('expected private key');
|
|
178
|
+
const reimported = await KeyManager.importKeyPair(currentPrivate);
|
|
179
|
+
expect(reimported).toBe(firstPublic);
|
|
180
|
+
expect(await KeyManager.getPublicKey()).toBe(firstPublic);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('rejects invalid private keys', async () => {
|
|
184
|
+
await expect(KeyManager.importKeyPair('not-hex')).rejects.toThrow(/Invalid private key/);
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe('hasIdentity', () => {
|
|
189
|
+
it('returns false when no identity is stored', async () => {
|
|
190
|
+
expect(await KeyManager.hasIdentity()).toBe(false);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('returns false when only the private key was written (partial state)', async () => {
|
|
194
|
+
const store = (await import('expo-secure-store' as string)) as unknown as {
|
|
195
|
+
__getStore__: () => Map<string, string>;
|
|
196
|
+
};
|
|
197
|
+
const m = store.__getStore__();
|
|
198
|
+
// Simulate a half-written identity: private without public
|
|
199
|
+
m.set('oxy_identity_private_key', 'a'.repeat(64));
|
|
200
|
+
// Invalidate cache so the next call re-reads
|
|
201
|
+
(KeyManager as unknown as { cachedHasIdentity: unknown }).cachedHasIdentity = null;
|
|
202
|
+
expect(await KeyManager.hasIdentity()).toBe(false);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('returns false when the stored public key does not derive from the private key', async () => {
|
|
206
|
+
await KeyManager.createIdentity();
|
|
207
|
+
const store = (await import('expo-secure-store' as string)) as unknown as {
|
|
208
|
+
__getStore__: () => Map<string, string>;
|
|
209
|
+
};
|
|
210
|
+
const m = store.__getStore__();
|
|
211
|
+
// Tamper with the stored public key
|
|
212
|
+
m.set('oxy_identity_public_key', '04' + 'b'.repeat(128));
|
|
213
|
+
(KeyManager as unknown as { cachedHasIdentity: unknown; cachedPublicKey: unknown }).cachedHasIdentity = null;
|
|
214
|
+
(KeyManager as unknown as { cachedHasIdentity: unknown; cachedPublicKey: unknown }).cachedPublicKey = null;
|
|
215
|
+
expect(await KeyManager.hasIdentity()).toBe(false);
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
describe('verifyIdentityIntegrity', () => {
|
|
220
|
+
it('returns true for a fresh identity', async () => {
|
|
221
|
+
await KeyManager.createIdentity();
|
|
222
|
+
expect(await KeyManager.verifyIdentityIntegrity()).toBe(true);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('returns false when the stored keys do not match', async () => {
|
|
226
|
+
await KeyManager.createIdentity();
|
|
227
|
+
const store = (await import('expo-secure-store' as string)) as unknown as {
|
|
228
|
+
__getStore__: () => Map<string, string>;
|
|
229
|
+
};
|
|
230
|
+
store.__getStore__().set('oxy_identity_public_key', '04' + 'c'.repeat(128));
|
|
231
|
+
(KeyManager as unknown as { cachedPublicKey: unknown }).cachedPublicKey = null;
|
|
232
|
+
expect(await KeyManager.verifyIdentityIntegrity()).toBe(false);
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
describe('restoreIdentityFromBackup', () => {
|
|
237
|
+
it('does NOT overwrite a verifying primary', async () => {
|
|
238
|
+
const firstPublic = await KeyManager.createIdentity();
|
|
239
|
+
const restored = await KeyManager.restoreIdentityFromBackup();
|
|
240
|
+
expect(restored).toBe(false);
|
|
241
|
+
// Primary unchanged
|
|
242
|
+
expect(await KeyManager.getPublicKey()).toBe(firstPublic);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('refuses to restore if the backup public key does not match a still-present (broken) primary', async () => {
|
|
246
|
+
await KeyManager.createIdentity();
|
|
247
|
+
const store = (await import('expo-secure-store' as string)) as unknown as {
|
|
248
|
+
__getStore__: () => Map<string, string>;
|
|
249
|
+
};
|
|
250
|
+
const m = store.__getStore__();
|
|
251
|
+
// Corrupt the primary public key (so integrity fails), but leave the
|
|
252
|
+
// broken primary in place. The backup will not match.
|
|
253
|
+
m.set('oxy_identity_public_key', '04' + 'd'.repeat(128));
|
|
254
|
+
// Tamper with the backup too — write a backup from a completely
|
|
255
|
+
// different identity.
|
|
256
|
+
const otherPair = await KeyManager.generateKeyPair();
|
|
257
|
+
m.set('oxy_identity_backup_private_key', otherPair.privateKey);
|
|
258
|
+
m.set('oxy_identity_backup_public_key', otherPair.publicKey);
|
|
259
|
+
|
|
260
|
+
(KeyManager as unknown as { cachedPublicKey: unknown; cachedHasIdentity: unknown }).cachedPublicKey = null;
|
|
261
|
+
(KeyManager as unknown as { cachedPublicKey: unknown; cachedHasIdentity: unknown }).cachedHasIdentity = null;
|
|
262
|
+
|
|
263
|
+
const restored = await KeyManager.restoreIdentityFromBackup();
|
|
264
|
+
expect(restored).toBe(false);
|
|
265
|
+
// The (corrupted) primary public key should be unchanged, not the
|
|
266
|
+
// attacker-backup public key.
|
|
267
|
+
expect(m.get('oxy_identity_public_key')).not.toBe(otherPair.publicKey);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it('restores a missing primary from a valid backup', async () => {
|
|
271
|
+
const original = await KeyManager.createIdentity();
|
|
272
|
+
const store = (await import('expo-secure-store' as string)) as unknown as {
|
|
273
|
+
__getStore__: () => Map<string, string>;
|
|
274
|
+
};
|
|
275
|
+
const m = store.__getStore__();
|
|
276
|
+
// Wipe primary only
|
|
277
|
+
m.delete('oxy_identity_private_key');
|
|
278
|
+
m.delete('oxy_identity_public_key');
|
|
279
|
+
(KeyManager as unknown as { cachedPublicKey: unknown; cachedHasIdentity: unknown }).cachedPublicKey = null;
|
|
280
|
+
(KeyManager as unknown as { cachedPublicKey: unknown; cachedHasIdentity: unknown }).cachedHasIdentity = null;
|
|
281
|
+
|
|
282
|
+
const restored = await KeyManager.restoreIdentityFromBackup();
|
|
283
|
+
expect(restored).toBe(true);
|
|
284
|
+
expect(await KeyManager.getPublicKey()).toBe(original);
|
|
285
|
+
expect(await KeyManager.verifyIdentityIntegrity()).toBe(true);
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
describe('RecoveryPhraseService round-trip determinism', () => {
|
|
290
|
+
it('always derives the SAME public key from the same phrase', async () => {
|
|
291
|
+
const result = await RecoveryPhraseService.generateIdentityWithRecovery();
|
|
292
|
+
const phrase = result.phrase;
|
|
293
|
+
const firstPublicKey = result.publicKey;
|
|
294
|
+
|
|
295
|
+
// Restore on a "clean device" — wipe and re-import via phrase
|
|
296
|
+
const store = (await import('expo-secure-store' as string)) as unknown as {
|
|
297
|
+
__resetStore__: () => void;
|
|
298
|
+
};
|
|
299
|
+
store.__resetStore__();
|
|
300
|
+
(KeyManager as unknown as { cachedPublicKey: unknown; cachedHasIdentity: unknown }).cachedPublicKey = null;
|
|
301
|
+
(KeyManager as unknown as { cachedPublicKey: unknown; cachedHasIdentity: unknown }).cachedHasIdentity = null;
|
|
302
|
+
|
|
303
|
+
const restoredPublicKey = await RecoveryPhraseService.restoreFromPhrase(phrase);
|
|
304
|
+
expect(restoredPublicKey).toBe(firstPublicKey);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it('derivePublicKeyFromPhrase matches the result of restoreFromPhrase', async () => {
|
|
308
|
+
const { phrase, publicKey } = await RecoveryPhraseService.generateIdentityWithRecovery();
|
|
309
|
+
const derived = await RecoveryPhraseService.derivePublicKeyFromPhrase(phrase);
|
|
310
|
+
expect(derived).toBe(publicKey);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it('refuses to overwrite an existing different identity during restore', async () => {
|
|
314
|
+
const a = await RecoveryPhraseService.generateIdentityWithRecovery();
|
|
315
|
+
// Reset cache but keep the on-device identity
|
|
316
|
+
(KeyManager as unknown as { cachedPublicKey: unknown; cachedHasIdentity: unknown }).cachedPublicKey = null;
|
|
317
|
+
(KeyManager as unknown as { cachedPublicKey: unknown; cachedHasIdentity: unknown }).cachedHasIdentity = null;
|
|
318
|
+
|
|
319
|
+
// Manually generate a different phrase
|
|
320
|
+
const bip39 = await import('bip39');
|
|
321
|
+
const otherPhrase = bip39.generateMnemonic(128);
|
|
322
|
+
// Sanity check phrases are different
|
|
323
|
+
expect(otherPhrase).not.toBe(a.phrase);
|
|
324
|
+
|
|
325
|
+
await expect(RecoveryPhraseService.restoreFromPhrase(otherPhrase)).rejects.toBeInstanceOf(
|
|
326
|
+
IdentityAlreadyExistsError,
|
|
327
|
+
);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it('rejects invalid phrases', async () => {
|
|
331
|
+
await expect(
|
|
332
|
+
RecoveryPhraseService.restoreFromPhrase('not a real phrase at all'),
|
|
333
|
+
).rejects.toThrow(/Invalid recovery phrase/);
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
});
|
package/src/crypto/index.ts
CHANGED
|
@@ -8,7 +8,12 @@
|
|
|
8
8
|
// Import polyfills first - this ensures Buffer is available for bip39 and other libraries
|
|
9
9
|
import './polyfill';
|
|
10
10
|
|
|
11
|
-
export {
|
|
11
|
+
export {
|
|
12
|
+
KeyManager,
|
|
13
|
+
IdentityAlreadyExistsError,
|
|
14
|
+
IdentityPersistError,
|
|
15
|
+
type KeyPair,
|
|
16
|
+
} from './keyManager';
|
|
12
17
|
export {
|
|
13
18
|
SignatureService,
|
|
14
19
|
type SignedMessage,
|