@oxyhq/core 12.7.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/cjs/mixins/OxyServices.utility.js +11 -1
- package/dist/cjs/server/auth.js +3 -0
- package/dist/cjs/server/index.js +2 -1
- package/dist/cjs/utils/oxyServiceEnvironment.js +19 -0
- 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/esm/mixins/OxyServices.utility.js +11 -1
- package/dist/esm/server/auth.js +2 -0
- package/dist/esm/server/index.js +1 -1
- package/dist/esm/utils/oxyServiceEnvironment.js +16 -0
- 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/dist/types/mixins/OxyServices.utility.d.ts +3 -0
- package/dist/types/server/auth.d.ts +4 -0
- package/dist/types/server/index.d.ts +2 -2
- package/dist/types/utils/oxyServiceEnvironment.d.ts +17 -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/OxyServices.utility.ts +19 -1
- package/src/mixins/__tests__/OxyServices.deviceBoot.test.ts +4 -2
- package/src/mixins/__tests__/commonsSignIn.test.ts +84 -1
- package/src/mixins/__tests__/serviceAuth.test.ts +65 -0
- package/src/server/auth.ts +5 -0
- package/src/server/index.ts +2 -0
- package/src/utils/__tests__/oxyServiceEnvironment.test.ts +7 -0
- package/src/utils/oxyServiceEnvironment.ts +17 -0
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cache-poisoning safety: a thrown read must never be cached as "no identity",
|
|
3
|
+
* and the create/import overwrite guards must read storage DIRECTLY (+ the
|
|
4
|
+
* marker), never a stale/poisoned cache — so a transient failure can never let
|
|
5
|
+
* onboarding silently overwrite a real identity.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { setPlatformOS } from '../../utils/platform';
|
|
9
|
+
|
|
10
|
+
jest.mock(
|
|
11
|
+
'expo-secure-store',
|
|
12
|
+
() => {
|
|
13
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
14
|
+
const { createSecureStoreMock } = require('./identityMocks');
|
|
15
|
+
return createSecureStoreMock();
|
|
16
|
+
},
|
|
17
|
+
{ virtual: true },
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
jest.mock(
|
|
21
|
+
'expo-crypto',
|
|
22
|
+
() => ({
|
|
23
|
+
__esModule: true,
|
|
24
|
+
getRandomBytes: (length: number) => {
|
|
25
|
+
const out = new Uint8Array(length);
|
|
26
|
+
for (let i = 0; i < length; i++) out[i] = (Math.random() * 256) & 0xff;
|
|
27
|
+
return out;
|
|
28
|
+
},
|
|
29
|
+
digestStringAsync: async () => '0'.repeat(64),
|
|
30
|
+
CryptoDigestAlgorithm: { SHA256: 'SHA-256' },
|
|
31
|
+
}),
|
|
32
|
+
{ virtual: true },
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
jest.mock('@oxyhq/protocol', () => {
|
|
36
|
+
const actual = jest.requireActual('@oxyhq/protocol');
|
|
37
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
38
|
+
const { createAsyncStorageMock } = require('./identityMocks');
|
|
39
|
+
const asyncStorage = createAsyncStorageMock();
|
|
40
|
+
return {
|
|
41
|
+
__esModule: true,
|
|
42
|
+
...actual,
|
|
43
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
44
|
+
loadExpoCrypto: async () => require('expo-crypto'),
|
|
45
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
46
|
+
loadSecureStore: async () => require('expo-secure-store'),
|
|
47
|
+
loadAsyncStorage: async () => ({ default: asyncStorage }),
|
|
48
|
+
loadSharedIdentityBridge: async () => null,
|
|
49
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
50
|
+
loadNodeCrypto: async () => require('crypto'),
|
|
51
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
52
|
+
getRandomBytesRN: (n: number) => require('expo-crypto').getRandomBytes(n),
|
|
53
|
+
};
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const PRIMARY_SVC = 'oxy_identity';
|
|
57
|
+
const V2_PRIV = 'oxy_identity_private_key_v2';
|
|
58
|
+
const V2_PUB = 'oxy_identity_public_key_v2';
|
|
59
|
+
|
|
60
|
+
interface SecureStoreTestHandle {
|
|
61
|
+
__resetStore__: () => void;
|
|
62
|
+
__getRaw__: (key: string, service?: string) => string | null;
|
|
63
|
+
__simulateKeystoreDeath__: (service: string) => void;
|
|
64
|
+
__failPlan__: { failKey?: string; failOp?: 'set' | 'get'; failTimes?: number; failService?: string };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
describe('KeyManager cache-poisoning safety', () => {
|
|
68
|
+
let KeyManager: typeof import('../keyManager').KeyManager;
|
|
69
|
+
let IdentityAlreadyExistsError: typeof import('../keyManager').IdentityAlreadyExistsError;
|
|
70
|
+
let ss: SecureStoreTestHandle;
|
|
71
|
+
|
|
72
|
+
const km = () =>
|
|
73
|
+
KeyManager as unknown as {
|
|
74
|
+
cachedPublicKey: unknown;
|
|
75
|
+
cachedHasIdentity: unknown;
|
|
76
|
+
cachedPublicKeyResolved: unknown;
|
|
77
|
+
};
|
|
78
|
+
const resetCaches = () => {
|
|
79
|
+
km().cachedPublicKey = null;
|
|
80
|
+
km().cachedHasIdentity = null;
|
|
81
|
+
km().cachedPublicKeyResolved = false;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
beforeAll(() => {
|
|
85
|
+
setPlatformOS('ios');
|
|
86
|
+
(globalThis as unknown as { navigator: unknown }).navigator = { product: 'ReactNative' };
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
beforeEach(async () => {
|
|
90
|
+
jest.resetModules();
|
|
91
|
+
setPlatformOS('ios');
|
|
92
|
+
ss = (await import('expo-secure-store' as string)) as unknown as SecureStoreTestHandle;
|
|
93
|
+
ss.__resetStore__();
|
|
94
|
+
const mod = await import('../keyManager');
|
|
95
|
+
KeyManager = mod.KeyManager;
|
|
96
|
+
IdentityAlreadyExistsError = mod.IdentityAlreadyExistsError;
|
|
97
|
+
resetCaches();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('getPublicKey does NOT cache null when the read throws (a later read still succeeds)', async () => {
|
|
101
|
+
const pub = await KeyManager.createIdentity();
|
|
102
|
+
resetCaches();
|
|
103
|
+
|
|
104
|
+
ss.__failPlan__.failOp = 'get';
|
|
105
|
+
ss.__failPlan__.failKey = V2_PUB;
|
|
106
|
+
ss.__failPlan__.failService = PRIMARY_SVC;
|
|
107
|
+
await expect(KeyManager.getPublicKey()).rejects.toMatchObject({ name: 'IdentityUnavailableError' });
|
|
108
|
+
|
|
109
|
+
// Cache must NOT hold a null verdict — clearing the fault returns the key.
|
|
110
|
+
ss.__failPlan__.failKey = undefined;
|
|
111
|
+
ss.__failPlan__.failOp = undefined;
|
|
112
|
+
ss.__failPlan__.failService = undefined;
|
|
113
|
+
expect(await KeyManager.getPublicKey()).toBe(pub.toLowerCase());
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('getPublicKey caches a genuine-absent (successful empty) read as null', async () => {
|
|
117
|
+
// Fresh device: read succeeds empty → null, cacheable.
|
|
118
|
+
expect(await KeyManager.getPublicKey()).toBeNull();
|
|
119
|
+
// The resolved-null flag is set (distinct from a thrown read).
|
|
120
|
+
expect(km().cachedPublicKeyResolved).toBe(true);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('createIdentity overwrite guard refuses over a POISONED cache (direct read finds the identity)', async () => {
|
|
124
|
+
await KeyManager.createIdentity();
|
|
125
|
+
// Poison the read cache as if a prior transient failure had "resolved null".
|
|
126
|
+
resetCaches();
|
|
127
|
+
km().cachedPublicKeyResolved = true; // getPublicKey would now claim "no identity"
|
|
128
|
+
|
|
129
|
+
await expect(KeyManager.createIdentity()).rejects.toBeInstanceOf(IdentityAlreadyExistsError);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('createIdentity overwrite guard refuses (IdentityUnavailableError) when storage throws — never writes blind', async () => {
|
|
133
|
+
const pub = await KeyManager.createIdentity();
|
|
134
|
+
resetCaches();
|
|
135
|
+
ss.__failPlan__.failOp = 'get';
|
|
136
|
+
ss.__failPlan__.failKey = V2_PRIV;
|
|
137
|
+
ss.__failPlan__.failService = PRIMARY_SVC;
|
|
138
|
+
|
|
139
|
+
await expect(KeyManager.createIdentity()).rejects.toMatchObject({ name: 'IdentityUnavailableError' });
|
|
140
|
+
|
|
141
|
+
// The identity was untouched (no blind overwrite).
|
|
142
|
+
ss.__failPlan__.failKey = undefined;
|
|
143
|
+
ss.__failPlan__.failOp = undefined;
|
|
144
|
+
ss.__failPlan__.failService = undefined;
|
|
145
|
+
resetCaches();
|
|
146
|
+
expect(await KeyManager.getPublicKey()).toBe(pub.toLowerCase());
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('createIdentity refuses over a LOST identity (marker present, keys gone) — routes to recovery, not create', async () => {
|
|
150
|
+
await KeyManager.createIdentity();
|
|
151
|
+
ss.__simulateKeystoreDeath__(PRIMARY_SVC);
|
|
152
|
+
resetCaches();
|
|
153
|
+
|
|
154
|
+
// Even with keys gone, the marker records an identity → refuse a blind create.
|
|
155
|
+
await expect(KeyManager.createIdentity()).rejects.toBeInstanceOf(IdentityAlreadyExistsError);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('importKeyPair refuses a DIFFERENT key in the lost state, but ALLOWS re-importing the same (recovery)', async () => {
|
|
159
|
+
const pub = await KeyManager.createIdentity();
|
|
160
|
+
const originalPriv = await KeyManager.getPrivateKey();
|
|
161
|
+
if (!originalPriv) throw new Error('expected private key');
|
|
162
|
+
ss.__simulateKeystoreDeath__(PRIMARY_SVC);
|
|
163
|
+
resetCaches();
|
|
164
|
+
|
|
165
|
+
// A different key over a lost identity → refuse.
|
|
166
|
+
const otherPriv = (await KeyManager.generateKeyPair()).privateKey;
|
|
167
|
+
await expect(KeyManager.importKeyPair(otherPriv)).rejects.toBeInstanceOf(IdentityAlreadyExistsError);
|
|
168
|
+
|
|
169
|
+
// The SAME key (recovery-by-phrase into the lost state) → allowed.
|
|
170
|
+
resetCaches();
|
|
171
|
+
const restored = await KeyManager.importKeyPair(originalPriv);
|
|
172
|
+
expect(restored).toBe(pub.toLowerCase());
|
|
173
|
+
expect(await KeyManager.getPublicKey()).toBe(pub.toLowerCase());
|
|
174
|
+
});
|
|
175
|
+
});
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* getIdentityStatus tri-state (+ unavailable) verdict, the marker lifecycle, and
|
|
3
|
+
* the honest-typed-throw semantics of hasIdentity — the corruption-vs-fresh
|
|
4
|
+
* disambiguation that routing keys off of.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { setPlatformOS } from '../../utils/platform';
|
|
8
|
+
|
|
9
|
+
jest.mock(
|
|
10
|
+
'expo-secure-store',
|
|
11
|
+
() => {
|
|
12
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
13
|
+
const { createSecureStoreMock } = require('./identityMocks');
|
|
14
|
+
return createSecureStoreMock();
|
|
15
|
+
},
|
|
16
|
+
{ virtual: true },
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
jest.mock(
|
|
20
|
+
'expo-crypto',
|
|
21
|
+
() => ({
|
|
22
|
+
__esModule: true,
|
|
23
|
+
getRandomBytes: (length: number) => {
|
|
24
|
+
const out = new Uint8Array(length);
|
|
25
|
+
for (let i = 0; i < length; i++) out[i] = (Math.random() * 256) & 0xff;
|
|
26
|
+
return out;
|
|
27
|
+
},
|
|
28
|
+
digestStringAsync: async () => '0'.repeat(64),
|
|
29
|
+
CryptoDigestAlgorithm: { SHA256: 'SHA-256' },
|
|
30
|
+
}),
|
|
31
|
+
{ virtual: true },
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
jest.mock('@oxyhq/protocol', () => {
|
|
35
|
+
const actual = jest.requireActual('@oxyhq/protocol');
|
|
36
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
37
|
+
const { createAsyncStorageMock } = require('./identityMocks');
|
|
38
|
+
const asyncStorage = createAsyncStorageMock();
|
|
39
|
+
return {
|
|
40
|
+
__esModule: true,
|
|
41
|
+
...actual,
|
|
42
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
43
|
+
loadExpoCrypto: async () => require('expo-crypto'),
|
|
44
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
45
|
+
loadSecureStore: async () => require('expo-secure-store'),
|
|
46
|
+
loadAsyncStorage: async () => ({ default: asyncStorage }),
|
|
47
|
+
loadSharedIdentityBridge: async () => null,
|
|
48
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
49
|
+
loadNodeCrypto: async () => require('crypto'),
|
|
50
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
51
|
+
getRandomBytesRN: (n: number) => require('expo-crypto').getRandomBytes(n),
|
|
52
|
+
};
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const PRIMARY_SVC = 'oxy_identity';
|
|
56
|
+
const BACKUP_SVC = 'oxy_identity_backup';
|
|
57
|
+
const V2_PRIV = 'oxy_identity_private_key_v2';
|
|
58
|
+
const V2_PUB = 'oxy_identity_public_key_v2';
|
|
59
|
+
|
|
60
|
+
interface SecureStoreTestHandle {
|
|
61
|
+
__resetStore__: () => void;
|
|
62
|
+
__getRaw__: (key: string, service?: string) => string | null;
|
|
63
|
+
__setRaw__: (key: string, value: string, service?: string) => void;
|
|
64
|
+
__simulateKeystoreDeath__: (service: string) => void;
|
|
65
|
+
__failPlan__: { failKey?: string; failOp?: 'set' | 'get'; failTimes?: number; failService?: string };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
describe('KeyManager.getIdentityStatus + marker lifecycle', () => {
|
|
69
|
+
let KeyManager: typeof import('../keyManager').KeyManager;
|
|
70
|
+
let readIdentityMarker: typeof import('../identityMarker').readIdentityMarker;
|
|
71
|
+
let clearIdentityMarker: typeof import('../identityMarker').clearIdentityMarker;
|
|
72
|
+
let ss: SecureStoreTestHandle;
|
|
73
|
+
|
|
74
|
+
const resetCaches = () => {
|
|
75
|
+
const km = KeyManager as unknown as {
|
|
76
|
+
cachedPublicKey: unknown;
|
|
77
|
+
cachedHasIdentity: unknown;
|
|
78
|
+
cachedPublicKeyResolved: unknown;
|
|
79
|
+
};
|
|
80
|
+
km.cachedPublicKey = null;
|
|
81
|
+
km.cachedHasIdentity = null;
|
|
82
|
+
km.cachedPublicKeyResolved = false;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
beforeAll(() => {
|
|
86
|
+
setPlatformOS('ios');
|
|
87
|
+
(globalThis as unknown as { navigator: unknown }).navigator = { product: 'ReactNative' };
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
beforeEach(async () => {
|
|
91
|
+
jest.resetModules();
|
|
92
|
+
setPlatformOS('ios');
|
|
93
|
+
ss = (await import('expo-secure-store' as string)) as unknown as SecureStoreTestHandle;
|
|
94
|
+
ss.__resetStore__();
|
|
95
|
+
const km = await import('../keyManager');
|
|
96
|
+
KeyManager = km.KeyManager;
|
|
97
|
+
// Dynamically import the marker module AFTER resetModules so it shares the
|
|
98
|
+
// SAME (post-reset) AsyncStorage instance KeyManager writes markers to.
|
|
99
|
+
const marker = await import('../identityMarker');
|
|
100
|
+
readIdentityMarker = marker.readIdentityMarker;
|
|
101
|
+
clearIdentityMarker = marker.clearIdentityMarker;
|
|
102
|
+
resetCaches();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('reports absent on a genuinely fresh device (no keys, no marker)', async () => {
|
|
106
|
+
const status = await KeyManager.getIdentityStatus();
|
|
107
|
+
expect(status.state).toBe('absent');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('reports present and writes a marker for a healthy identity', async () => {
|
|
111
|
+
const pub = await KeyManager.createIdentity();
|
|
112
|
+
const status = await KeyManager.getIdentityStatus();
|
|
113
|
+
expect(status).toEqual({ state: 'present', publicKey: pub.toLowerCase() });
|
|
114
|
+
const marker = await readIdentityMarker();
|
|
115
|
+
expect(marker?.publicKey.toLowerCase()).toBe(pub.toLowerCase());
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('backfills a MISSING marker on the first healthy read (origin: backfill)', async () => {
|
|
119
|
+
const pub = await KeyManager.createIdentity();
|
|
120
|
+
// Simulate a device whose loss/creation predated markers: drop the marker.
|
|
121
|
+
await clearIdentityMarker();
|
|
122
|
+
expect(await readIdentityMarker()).toBeNull();
|
|
123
|
+
|
|
124
|
+
const status = await KeyManager.getIdentityStatus();
|
|
125
|
+
expect(status.state).toBe('present');
|
|
126
|
+
const marker = await readIdentityMarker();
|
|
127
|
+
expect(marker?.publicKey.toLowerCase()).toBe(pub.toLowerCase());
|
|
128
|
+
expect(marker?.origin).toBe('backfill');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('reports LOST when the keys are gone (keystore death) but the marker survives', async () => {
|
|
132
|
+
const pub = await KeyManager.createIdentity();
|
|
133
|
+
// Kill the primary keychain service — the marker in AsyncStorage survives.
|
|
134
|
+
ss.__simulateKeystoreDeath__(PRIMARY_SVC);
|
|
135
|
+
resetCaches();
|
|
136
|
+
|
|
137
|
+
const status = await KeyManager.getIdentityStatus();
|
|
138
|
+
expect(status.state).toBe('lost');
|
|
139
|
+
if (status.state === 'lost') {
|
|
140
|
+
expect(status.marker.publicKey.toLowerCase()).toBe(pub.toLowerCase());
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('reports UNAVAILABLE on a storage throw and NEVER caches that verdict', async () => {
|
|
145
|
+
await KeyManager.createIdentity();
|
|
146
|
+
resetCaches();
|
|
147
|
+
|
|
148
|
+
// Make the primary read throw (migration already resolved to v2 above).
|
|
149
|
+
ss.__failPlan__.failOp = 'get';
|
|
150
|
+
ss.__failPlan__.failKey = V2_PUB;
|
|
151
|
+
ss.__failPlan__.failService = PRIMARY_SVC;
|
|
152
|
+
const unavailable = await KeyManager.getIdentityStatus();
|
|
153
|
+
expect(unavailable.state).toBe('unavailable');
|
|
154
|
+
|
|
155
|
+
// Clearing the fault yields a fresh, CORRECT verdict — proof it was not cached.
|
|
156
|
+
ss.__failPlan__.failKey = undefined;
|
|
157
|
+
ss.__failPlan__.failOp = undefined;
|
|
158
|
+
ss.__failPlan__.failService = undefined;
|
|
159
|
+
const present = await KeyManager.getIdentityStatus();
|
|
160
|
+
expect(present.state).toBe('present');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('hasIdentity throws IdentityUnavailableError (not false) on a storage throw', async () => {
|
|
164
|
+
await KeyManager.createIdentity();
|
|
165
|
+
resetCaches();
|
|
166
|
+
ss.__failPlan__.failOp = 'get';
|
|
167
|
+
ss.__failPlan__.failKey = V2_PRIV;
|
|
168
|
+
ss.__failPlan__.failService = PRIMARY_SVC;
|
|
169
|
+
await expect(KeyManager.hasIdentity()).rejects.toMatchObject({ name: 'IdentityUnavailableError' });
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe('marker lifecycle', () => {
|
|
173
|
+
it('writes a create-origin marker on createIdentity', async () => {
|
|
174
|
+
const pub = await KeyManager.createIdentity();
|
|
175
|
+
const marker = await readIdentityMarker();
|
|
176
|
+
expect(marker?.publicKey.toLowerCase()).toBe(pub.toLowerCase());
|
|
177
|
+
expect(marker?.origin).toBe('create');
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('leaves the marker untouched when a persist rolls back', async () => {
|
|
181
|
+
const original = await KeyManager.createIdentity();
|
|
182
|
+
resetCaches();
|
|
183
|
+
// Fail the overwrite's primary write → rollback (marker must not change).
|
|
184
|
+
ss.__failPlan__.failOp = 'set';
|
|
185
|
+
ss.__failPlan__.failKey = V2_PRIV;
|
|
186
|
+
ss.__failPlan__.failService = PRIMARY_SVC;
|
|
187
|
+
await expect(KeyManager.createIdentity({ overwrite: true })).rejects.toBeDefined();
|
|
188
|
+
|
|
189
|
+
const marker = await readIdentityMarker();
|
|
190
|
+
expect(marker?.publicKey.toLowerCase()).toBe(original.toLowerCase());
|
|
191
|
+
expect(marker?.origin).toBe('create');
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('clears the marker on force deleteIdentity', async () => {
|
|
195
|
+
await KeyManager.createIdentity();
|
|
196
|
+
expect(await readIdentityMarker()).not.toBeNull();
|
|
197
|
+
await KeyManager.deleteIdentity(true, true, true);
|
|
198
|
+
expect(await readIdentityMarker()).toBeNull();
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('preserves onboardingComplete + createdAt across a same-identity re-persist', async () => {
|
|
202
|
+
const pub = await KeyManager.createIdentity();
|
|
203
|
+
const marker = await import('../identityMarker');
|
|
204
|
+
await marker.updateIdentityMarker({ onboardingComplete: true });
|
|
205
|
+
const before = await readIdentityMarker();
|
|
206
|
+
resetCaches();
|
|
207
|
+
// Re-import the same identity (idempotent refresh).
|
|
208
|
+
const priv = await KeyManager.getPrivateKey();
|
|
209
|
+
if (!priv) throw new Error('expected private key');
|
|
210
|
+
await KeyManager.importKeyPair(priv);
|
|
211
|
+
const after = await readIdentityMarker();
|
|
212
|
+
expect(after?.publicKey.toLowerCase()).toBe(pub.toLowerCase());
|
|
213
|
+
expect(after?.onboardingComplete).toBe(true);
|
|
214
|
+
expect(after?.createdAt).toBe(before?.createdAt);
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
});
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* attemptIdentityRecovery — the ladder that restores a `lost` identity from an
|
|
3
|
+
* independent, key_v1-surviving source (backup slot, then cross-app shared slot)
|
|
4
|
+
* WITHOUT the recovery phrase. Every rung validates well-formed + derive-match +
|
|
5
|
+
* `publicKey === marker.publicKey`, so a source holding a DIFFERENT account is
|
|
6
|
+
* skipped (never a silent switch).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { setPlatformOS } from '../../utils/platform';
|
|
10
|
+
|
|
11
|
+
jest.mock(
|
|
12
|
+
'expo-secure-store',
|
|
13
|
+
() => {
|
|
14
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
15
|
+
const { createSecureStoreMock } = require('./identityMocks');
|
|
16
|
+
return createSecureStoreMock();
|
|
17
|
+
},
|
|
18
|
+
{ virtual: true },
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
jest.mock(
|
|
22
|
+
'expo-crypto',
|
|
23
|
+
() => ({
|
|
24
|
+
__esModule: true,
|
|
25
|
+
getRandomBytes: (length: number) => {
|
|
26
|
+
const out = new Uint8Array(length);
|
|
27
|
+
for (let i = 0; i < length; i++) out[i] = (Math.random() * 256) & 0xff;
|
|
28
|
+
return out;
|
|
29
|
+
},
|
|
30
|
+
digestStringAsync: async () => '0'.repeat(64),
|
|
31
|
+
CryptoDigestAlgorithm: { SHA256: 'SHA-256' },
|
|
32
|
+
}),
|
|
33
|
+
{ virtual: true },
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
jest.mock('@oxyhq/protocol', () => {
|
|
37
|
+
const actual = jest.requireActual('@oxyhq/protocol');
|
|
38
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
39
|
+
const { createAsyncStorageMock } = require('./identityMocks');
|
|
40
|
+
const asyncStorage = createAsyncStorageMock();
|
|
41
|
+
return {
|
|
42
|
+
__esModule: true,
|
|
43
|
+
...actual,
|
|
44
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
45
|
+
loadExpoCrypto: async () => require('expo-crypto'),
|
|
46
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
47
|
+
loadSecureStore: async () => require('expo-secure-store'),
|
|
48
|
+
loadAsyncStorage: async () => ({ default: asyncStorage }),
|
|
49
|
+
loadSharedIdentityBridge: async () => null,
|
|
50
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
51
|
+
loadNodeCrypto: async () => require('crypto'),
|
|
52
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
53
|
+
getRandomBytesRN: (n: number) => require('expo-crypto').getRandomBytes(n),
|
|
54
|
+
};
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const PRIMARY_SVC = 'oxy_identity';
|
|
58
|
+
const BACKUP_SVC = 'oxy_identity_backup';
|
|
59
|
+
const V2_PRIV = 'oxy_identity_private_key_v2';
|
|
60
|
+
const V2_PUB = 'oxy_identity_public_key_v2';
|
|
61
|
+
const V2_BPRIV = 'oxy_identity_backup_private_key_v2';
|
|
62
|
+
const V2_BPUB = 'oxy_identity_backup_public_key_v2';
|
|
63
|
+
|
|
64
|
+
interface SecureStoreTestHandle {
|
|
65
|
+
__resetStore__: () => void;
|
|
66
|
+
__getRaw__: (key: string, service?: string) => string | null;
|
|
67
|
+
__setRaw__: (key: string, value: string, service?: string) => void;
|
|
68
|
+
__simulateKeystoreDeath__: (service: string) => void;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
describe('KeyManager.attemptIdentityRecovery (recovery ladder)', () => {
|
|
72
|
+
let KeyManager: typeof import('../keyManager').KeyManager;
|
|
73
|
+
let ss: SecureStoreTestHandle;
|
|
74
|
+
|
|
75
|
+
const resetCaches = () => {
|
|
76
|
+
const km = KeyManager as unknown as {
|
|
77
|
+
cachedPublicKey: unknown;
|
|
78
|
+
cachedHasIdentity: unknown;
|
|
79
|
+
cachedPublicKeyResolved: unknown;
|
|
80
|
+
cachedSharedPublicKey: unknown;
|
|
81
|
+
cachedHasSharedIdentity: unknown;
|
|
82
|
+
};
|
|
83
|
+
km.cachedPublicKey = null;
|
|
84
|
+
km.cachedHasIdentity = null;
|
|
85
|
+
km.cachedPublicKeyResolved = false;
|
|
86
|
+
km.cachedSharedPublicKey = null;
|
|
87
|
+
km.cachedHasSharedIdentity = null;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
beforeAll(() => {
|
|
91
|
+
setPlatformOS('ios');
|
|
92
|
+
(globalThis as unknown as { navigator: unknown }).navigator = { product: 'ReactNative' };
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
beforeEach(async () => {
|
|
96
|
+
jest.resetModules();
|
|
97
|
+
setPlatformOS('ios');
|
|
98
|
+
ss = (await import('expo-secure-store' as string)) as unknown as SecureStoreTestHandle;
|
|
99
|
+
ss.__resetStore__();
|
|
100
|
+
const mod = await import('../keyManager');
|
|
101
|
+
KeyManager = mod.KeyManager;
|
|
102
|
+
resetCaches();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('recovers from the BACKUP slot when the primary keychain key dies', async () => {
|
|
106
|
+
const pub = await KeyManager.createIdentity();
|
|
107
|
+
// Kill ONLY the primary service — the backup slot (independent key) survives.
|
|
108
|
+
ss.__simulateKeystoreDeath__(PRIMARY_SVC);
|
|
109
|
+
resetCaches();
|
|
110
|
+
|
|
111
|
+
expect((await KeyManager.getIdentityStatus()).state).toBe('lost');
|
|
112
|
+
resetCaches();
|
|
113
|
+
|
|
114
|
+
const result = await KeyManager.attemptIdentityRecovery();
|
|
115
|
+
expect(result).toEqual({ recovered: true, source: 'backup', publicKey: pub.toLowerCase() });
|
|
116
|
+
expect((await KeyManager.getIdentityStatus()).state).toBe('present');
|
|
117
|
+
expect(ss.__getRaw__(V2_PRIV, PRIMARY_SVC)).toBe((await KeyManager.getPrivateKey()));
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('recovers from the SHARED slot when both primary and backup keys die', async () => {
|
|
121
|
+
await KeyManager.createIdentity();
|
|
122
|
+
const priv = await KeyManager.getPrivateKey();
|
|
123
|
+
if (!priv) throw new Error('expected private key');
|
|
124
|
+
const pub = KeyManager.derivePublicKey(priv);
|
|
125
|
+
// Mirror the identity into the cross-app shared slot (survives key_v1 death).
|
|
126
|
+
await KeyManager.importSharedIdentity(priv);
|
|
127
|
+
|
|
128
|
+
ss.__simulateKeystoreDeath__(PRIMARY_SVC);
|
|
129
|
+
ss.__simulateKeystoreDeath__(BACKUP_SVC);
|
|
130
|
+
resetCaches();
|
|
131
|
+
|
|
132
|
+
expect((await KeyManager.getIdentityStatus()).state).toBe('lost');
|
|
133
|
+
resetCaches();
|
|
134
|
+
|
|
135
|
+
const result = await KeyManager.attemptIdentityRecovery();
|
|
136
|
+
expect(result).toEqual({ recovered: true, source: 'shared', publicKey: pub.toLowerCase() });
|
|
137
|
+
expect((await KeyManager.getIdentityStatus()).state).toBe('present');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('SKIPS a source that holds a DIFFERENT account (never silently switches)', async () => {
|
|
141
|
+
await KeyManager.createIdentity();
|
|
142
|
+
// Plant a DIFFERENT identity B in both the backup and shared slots.
|
|
143
|
+
const b = await KeyManager.generateKeyPair();
|
|
144
|
+
ss.__setRaw__(V2_BPRIV, b.privateKey, BACKUP_SVC);
|
|
145
|
+
ss.__setRaw__(V2_BPUB, b.publicKey, BACKUP_SVC);
|
|
146
|
+
await KeyManager.importSharedIdentity(b.privateKey);
|
|
147
|
+
|
|
148
|
+
ss.__simulateKeystoreDeath__(PRIMARY_SVC);
|
|
149
|
+
resetCaches();
|
|
150
|
+
|
|
151
|
+
const result = await KeyManager.attemptIdentityRecovery();
|
|
152
|
+
expect(result).toEqual({ recovered: false, reason: 'mismatch' });
|
|
153
|
+
// The primary was NOT switched to B.
|
|
154
|
+
expect(ss.__getRaw__(V2_PRIV, PRIMARY_SVC)).not.toBe(b.privateKey);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('is a no-op when the identity is NOT lost (present)', async () => {
|
|
158
|
+
await KeyManager.createIdentity();
|
|
159
|
+
const result = await KeyManager.attemptIdentityRecovery();
|
|
160
|
+
expect(result).toEqual({ recovered: false, reason: 'not-lost' });
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('is a no-op on a genuinely absent (fresh) device', async () => {
|
|
164
|
+
const result = await KeyManager.attemptIdentityRecovery();
|
|
165
|
+
expect(result).toEqual({ recovered: false, reason: 'not-lost' });
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('reports no-sources when every recovery slot is empty or malformed', async () => {
|
|
169
|
+
await KeyManager.createIdentity();
|
|
170
|
+
// Corrupt the backup so it is not a valid candidate.
|
|
171
|
+
ss.__setRaw__(V2_BPRIV, 'zz-not-hex', BACKUP_SVC);
|
|
172
|
+
ss.__setRaw__(V2_BPUB, 'garbage', BACKUP_SVC);
|
|
173
|
+
ss.__simulateKeystoreDeath__(PRIMARY_SVC);
|
|
174
|
+
resetCaches();
|
|
175
|
+
|
|
176
|
+
const result = await KeyManager.attemptIdentityRecovery();
|
|
177
|
+
expect(result).toEqual({ recovered: false, reason: 'no-sources' });
|
|
178
|
+
});
|
|
179
|
+
});
|