@oxy.so/core 1.9.1 → 1.11.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/README.md +4 -4
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/HttpService.js +56 -163
- package/dist/cjs/OxyServices.base.js +4 -2
- package/dist/cjs/boot/sessionColdBoot.js +3 -0
- package/dist/cjs/crypto/deviceBackup.js +75 -0
- package/dist/cjs/crypto/keyManager.js +285 -12
- package/dist/cjs/mixins/OxyServices.linkedAccounts.js +79 -0
- package/dist/cjs/mixins/OxyServices.user.js +7 -6
- package/dist/cjs/mixins/index.js +4 -0
- package/dist/cjs/server/cors.js +0 -1
- package/dist/cjs/server/securityHeaders.js +3 -0
- package/dist/cjs/session/refresh.js +32 -2
- package/dist/cjs/utils/errorUtils.js +1 -1
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/HttpService.js +56 -163
- package/dist/esm/OxyServices.base.js +4 -2
- package/dist/esm/boot/sessionColdBoot.js +3 -0
- package/dist/esm/crypto/deviceBackup.js +70 -0
- package/dist/esm/crypto/keyManager.js +252 -12
- package/dist/esm/mixins/OxyServices.linkedAccounts.js +76 -0
- package/dist/esm/mixins/OxyServices.user.js +7 -6
- package/dist/esm/mixins/index.js +4 -0
- package/dist/esm/server/cors.js +0 -1
- package/dist/esm/server/securityHeaders.js +3 -0
- package/dist/esm/session/refresh.js +32 -2
- package/dist/esm/utils/errorUtils.js +1 -1
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/HttpService.d.ts +31 -7
- package/dist/types/OxyServices.base.d.ts +3 -1
- package/dist/types/crypto/deviceBackup.d.ts +60 -0
- package/dist/types/crypto/keyManager.d.ts +79 -12
- package/dist/types/index.d.ts +1 -0
- package/dist/types/mixins/OxyServices.civic.d.ts +11 -1
- package/dist/types/mixins/OxyServices.linkedAccounts.d.ts +107 -0
- package/dist/types/mixins/OxyServices.user.d.ts +8 -6
- package/dist/types/mixins/index.d.ts +2 -1
- package/dist/types/models/interfaces.d.ts +30 -0
- package/dist/types/server/securityHeaders.d.ts +5 -0
- package/dist/types/session/refresh.d.ts +7 -0
- package/dist/types/utils/errorUtils.d.ts +1 -1
- package/package.json +2 -2
- package/src/HttpService.ts +59 -187
- package/src/OxyServices.base.ts +4 -2
- package/src/__tests__/httpServiceFormEncoded.test.ts +2 -5
- package/src/__tests__/{httpServiceCsrf.test.ts → httpServiceWrites.test.ts} +49 -109
- package/src/__tests__/inSessionRefresh.test.ts +53 -0
- package/src/__tests__/linkedClient.test.ts +0 -7
- package/src/boot/__tests__/sessionColdBoot.identity.test.ts +1 -1
- package/src/boot/__tests__/sessionColdBoot.sharedDevice.test.ts +1 -1
- package/src/boot/__tests__/sessionColdBoot.test.ts +1 -1
- package/src/boot/sessionColdBoot.ts +3 -0
- package/src/crypto/__tests__/keyManager.deviceBackup.test.ts +310 -0
- package/src/crypto/deviceBackup.ts +108 -0
- package/src/crypto/keyManager.ts +278 -13
- package/src/index.ts +1 -0
- package/src/mixins/OxyServices.linkedAccounts.ts +129 -0
- package/src/mixins/OxyServices.user.ts +9 -7
- package/src/mixins/__tests__/backgroundCredential.httpIntegration.test.ts +5 -10
- package/src/mixins/__tests__/discoveryErrorHandling.test.ts +3 -4
- package/src/mixins/__tests__/linkedAccounts.test.ts +109 -0
- package/src/mixins/__tests__/topics.test.ts +2 -3
- package/src/mixins/index.ts +6 -0
- package/src/models/interfaces.ts +30 -1
- package/src/server/__tests__/securityHeaders.test.ts +7 -0
- package/src/server/cors.ts +0 -1
- package/src/server/securityHeaders.ts +8 -0
- package/src/session/__tests__/refresh.identityPin.test.ts +1 -1
- package/src/session/__tests__/refresh.test.ts +115 -2
- package/src/session/refresh.ts +38 -2
- package/src/utils/errorUtils.ts +1 -1
|
@@ -233,6 +233,59 @@ describe('HttpService in-session refresh handler', () => {
|
|
|
233
233
|
});
|
|
234
234
|
});
|
|
235
235
|
|
|
236
|
+
describe('HttpService.endSession — a sign-out outranks a refresh', () => {
|
|
237
|
+
it('plants nothing when the session ends while a re-mint is in flight', async () => {
|
|
238
|
+
const oldToken = createJwt({ userId: 'u', exp: farFutureExp(), jti: 'old' });
|
|
239
|
+
const lateToken = createJwt({ userId: 'u', exp: farFutureExp(), jti: 'late' });
|
|
240
|
+
const http = new HttpService({ baseURL: 'https://api.mention.earth', enableRetry: false });
|
|
241
|
+
http.setTokens(oldToken);
|
|
242
|
+
|
|
243
|
+
let finish!: (token: string | null) => void;
|
|
244
|
+
http.setAuthRefreshHandler(
|
|
245
|
+
() =>
|
|
246
|
+
new Promise((resolve) => {
|
|
247
|
+
finish = resolve;
|
|
248
|
+
}),
|
|
249
|
+
);
|
|
250
|
+
const seen: Array<string | null> = [];
|
|
251
|
+
http.addTokenChangeListener((token) => seen.push(token));
|
|
252
|
+
|
|
253
|
+
const pending = http.refreshAccessToken('preflight');
|
|
254
|
+
http.endSession();
|
|
255
|
+
finish(lateToken);
|
|
256
|
+
|
|
257
|
+
await expect(pending).resolves.toBeNull();
|
|
258
|
+
expect(http.getAccessToken()).toBeNull();
|
|
259
|
+
expect(seen).toEqual([null]);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('abandons only what was in flight: a later re-mint (another tab signed in) still runs', async () => {
|
|
263
|
+
const http = new HttpService({ baseURL: 'https://api.mention.earth', enableRetry: false });
|
|
264
|
+
const fresh = createJwt({ userId: 'u', exp: farFutureExp(), jti: 'fresh' });
|
|
265
|
+
const handler = jest.fn(async () => fresh);
|
|
266
|
+
http.setAuthRefreshHandler(handler);
|
|
267
|
+
|
|
268
|
+
http.endSession();
|
|
269
|
+
expect(http.hasSessionEnded()).toBe(true);
|
|
270
|
+
await expect(http.refreshAccessToken('preflight')).resolves.toBe(fresh);
|
|
271
|
+
expect(http.getAccessToken()).toBe(fresh);
|
|
272
|
+
expect(http.hasSessionEnded()).toBe(false);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it('is what OxyServices.clearTokens does; a plain HttpService.clearTokens (a mirror) is not', () => {
|
|
276
|
+
const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
|
|
277
|
+
const before = oxy.httpService.getSessionEpoch();
|
|
278
|
+
oxy.clearTokens();
|
|
279
|
+
expect(oxy.httpService.getSessionEpoch()).toBe(before + 1);
|
|
280
|
+
expect(oxy.httpService.hasSessionEnded()).toBe(true);
|
|
281
|
+
|
|
282
|
+
const mirror = new HttpService({ baseURL: 'https://api.mention.earth', enableRetry: false });
|
|
283
|
+
mirror.clearTokens();
|
|
284
|
+
expect(mirror.getSessionEpoch()).toBe(0);
|
|
285
|
+
expect(mirror.hasSessionEnded()).toBe(false);
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|
|
236
289
|
describe('OxyServices.getAccessTokenExpiry', () => {
|
|
237
290
|
it('returns the JWT exp (seconds) of the current access token', () => {
|
|
238
291
|
const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
|
|
@@ -208,13 +208,6 @@ describe('OxyServices.createLinkedClient', () => {
|
|
|
208
208
|
const url = String(input);
|
|
209
209
|
calls.push({ url, init });
|
|
210
210
|
|
|
211
|
-
if (url.endsWith('/csrf-token')) {
|
|
212
|
-
return new Response(JSON.stringify({ csrfToken: 'csrf_1' }), {
|
|
213
|
-
status: 200,
|
|
214
|
-
headers: { 'content-type': 'application/json' },
|
|
215
|
-
});
|
|
216
|
-
}
|
|
217
|
-
|
|
218
211
|
if (url.endsWith('/api/queue/current')) {
|
|
219
212
|
queueWriteAttempts += 1;
|
|
220
213
|
if (queueWriteAttempts === 1) {
|
|
@@ -104,7 +104,7 @@ function makeOxy(overrides: OxyOverrides = {}): { oxy: OxyServices; setTokens: j
|
|
|
104
104
|
overrides.requestChallenge
|
|
105
105
|
?? (async () => ({ challenge: 'chal-1', expiresAt: '2030-01-01T00:00:00.000Z' })),
|
|
106
106
|
verifyChallenge: overrides.verifyChallenge ?? (async () => IDENTITY_SESSION),
|
|
107
|
-
httpService: { runSingleFlightDeviceSecretMint: makeMintSingleFlight() },
|
|
107
|
+
httpService: { runSingleFlightDeviceSecretMint: makeMintSingleFlight(), getSessionEpoch: () => 0 },
|
|
108
108
|
} as unknown as OxyServices;
|
|
109
109
|
return { oxy, setTokens };
|
|
110
110
|
}
|
|
@@ -79,7 +79,7 @@ function makeOxy(overrides: {
|
|
|
79
79
|
setTokens,
|
|
80
80
|
mintFromDeviceSecret,
|
|
81
81
|
signInWithSharedIdentity,
|
|
82
|
-
httpService: { runSingleFlightDeviceSecretMint: makeMintSingleFlight() },
|
|
82
|
+
httpService: { runSingleFlightDeviceSecretMint: makeMintSingleFlight(), getSessionEpoch: () => 0 },
|
|
83
83
|
} as unknown as OxyServices;
|
|
84
84
|
return { oxy, setTokens, mintFromDeviceSecret, signInWithSharedIdentity };
|
|
85
85
|
}
|
|
@@ -45,7 +45,7 @@ function makeOxy(overrides: OxyOverrides = {}): { oxy: OxyServices; setTokens: j
|
|
|
45
45
|
throw new Error('mintFromDeviceSecret not stubbed');
|
|
46
46
|
}),
|
|
47
47
|
// The device-secret-mint step runs through the client's single-flight.
|
|
48
|
-
httpService: { runSingleFlightDeviceSecretMint: makeMintSingleFlight() },
|
|
48
|
+
httpService: { runSingleFlightDeviceSecretMint: makeMintSingleFlight(), getSessionEpoch: () => 0 },
|
|
49
49
|
} as unknown as OxyServices;
|
|
50
50
|
return { oxy, setTokens };
|
|
51
51
|
}
|
|
@@ -326,6 +326,9 @@ export async function runSessionColdBoot(
|
|
|
326
326
|
{ component: 'sessionColdBoot', method: 'device-secret-mint' },
|
|
327
327
|
);
|
|
328
328
|
return { kind: 'skip' };
|
|
329
|
+
case 'session-ended':
|
|
330
|
+
// Signed out while the boot was minting; the arm planted nothing.
|
|
331
|
+
return { kind: 'skip' };
|
|
329
332
|
case 'no-secret':
|
|
330
333
|
return { kind: 'skip' };
|
|
331
334
|
}
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The device backup (oxy#1388): a copy of the identity held OUTSIDE the app's
|
|
3
|
+
* keystore, so a wipe of the whole shared-UID Android Keystore — which takes the
|
|
4
|
+
* primary, the backup slot, the phrase slot and the shared slot together — is
|
|
5
|
+
* undone silently instead of asking for the recovery phrase.
|
|
6
|
+
*
|
|
7
|
+
* The store is an in-memory fake of what Commons registers (Android Block
|
|
8
|
+
* Store); the keystore wipe is the secure-store mock's keystore death applied to
|
|
9
|
+
* every keychain service the SDK uses.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { setPlatformOS } from '../../utils/platform';
|
|
13
|
+
import type { IdentityDeviceBackupStore } from '../deviceBackup';
|
|
14
|
+
|
|
15
|
+
jest.mock(
|
|
16
|
+
'expo-secure-store',
|
|
17
|
+
() => {
|
|
18
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
19
|
+
const { createSecureStoreMock } = require('./identityMocks');
|
|
20
|
+
return createSecureStoreMock();
|
|
21
|
+
},
|
|
22
|
+
{ virtual: true },
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
jest.mock(
|
|
26
|
+
'expo-crypto',
|
|
27
|
+
() => ({
|
|
28
|
+
__esModule: true,
|
|
29
|
+
getRandomBytes: (length: number) => {
|
|
30
|
+
const out = new Uint8Array(length);
|
|
31
|
+
for (let i = 0; i < length; i++) out[i] = (Math.random() * 256) & 0xff;
|
|
32
|
+
return out;
|
|
33
|
+
},
|
|
34
|
+
digestStringAsync: async () => '0'.repeat(64),
|
|
35
|
+
CryptoDigestAlgorithm: { SHA256: 'SHA-256' },
|
|
36
|
+
}),
|
|
37
|
+
{ virtual: true },
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
jest.mock('@oxy.so/protocol', () => {
|
|
41
|
+
const actual = jest.requireActual('@oxy.so/protocol');
|
|
42
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
43
|
+
const { createAsyncStorageMock } = require('./identityMocks');
|
|
44
|
+
const asyncStorage = createAsyncStorageMock();
|
|
45
|
+
return {
|
|
46
|
+
__esModule: true,
|
|
47
|
+
...actual,
|
|
48
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
49
|
+
loadExpoCrypto: async () => require('expo-crypto'),
|
|
50
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
51
|
+
loadSecureStore: async () => require('expo-secure-store'),
|
|
52
|
+
loadAsyncStorage: async () => ({ default: asyncStorage }),
|
|
53
|
+
loadSharedIdentityBridge: async () => null,
|
|
54
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
55
|
+
loadNodeCrypto: async () => require('node:crypto'),
|
|
56
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
57
|
+
getRandomBytesRN: (n: number) => require('expo-crypto').getRandomBytes(n),
|
|
58
|
+
};
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
/** Every keychain service the SDK writes under: what one UID keystore wipe kills. */
|
|
62
|
+
const ALL_SERVICES = ['oxy_identity', 'oxy_identity_backup', 'oxy_identity_mnemonic', 'default'];
|
|
63
|
+
const PRIMARY_SVC = 'oxy_identity';
|
|
64
|
+
const V2_PRIV = 'oxy_identity_private_key_v2';
|
|
65
|
+
|
|
66
|
+
interface SecureStoreTestHandle {
|
|
67
|
+
__resetStore__: () => void;
|
|
68
|
+
__getRaw__: (key: string, service?: string) => string | null;
|
|
69
|
+
__simulateKeystoreDeath__: (service: string) => void;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface FakeStore extends IdentityDeviceBackupStore {
|
|
73
|
+
value: string | null;
|
|
74
|
+
writes: number;
|
|
75
|
+
failReads: boolean;
|
|
76
|
+
failWrites: boolean;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function createFakeStore(): FakeStore {
|
|
80
|
+
const fake: FakeStore = {
|
|
81
|
+
name: 'fake',
|
|
82
|
+
value: null,
|
|
83
|
+
writes: 0,
|
|
84
|
+
failReads: false,
|
|
85
|
+
failWrites: false,
|
|
86
|
+
read: async () => {
|
|
87
|
+
if (fake.failReads) throw new Error('store unreachable');
|
|
88
|
+
return fake.value;
|
|
89
|
+
},
|
|
90
|
+
write: async (value: string) => {
|
|
91
|
+
if (fake.failWrites) throw new Error('store write failed');
|
|
92
|
+
fake.writes += 1;
|
|
93
|
+
fake.value = value;
|
|
94
|
+
},
|
|
95
|
+
clear: async () => {
|
|
96
|
+
fake.value = null;
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
return fake;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
describe('KeyManager device backup', () => {
|
|
103
|
+
let KeyManager: typeof import('../keyManager').KeyManager;
|
|
104
|
+
let RecoveryPhraseService: typeof import('../recoveryPhrase').RecoveryPhraseService;
|
|
105
|
+
let clearIdentityMarker: typeof import('../identityMarker').clearIdentityMarker;
|
|
106
|
+
let ss: SecureStoreTestHandle;
|
|
107
|
+
let store: FakeStore;
|
|
108
|
+
|
|
109
|
+
const resetCaches = () => {
|
|
110
|
+
const km = KeyManager as unknown as Record<string, unknown>;
|
|
111
|
+
km.cachedPublicKey = null;
|
|
112
|
+
km.cachedHasIdentity = null;
|
|
113
|
+
km.cachedPublicKeyResolved = false;
|
|
114
|
+
km.cachedSharedPublicKey = null;
|
|
115
|
+
km.cachedHasSharedIdentity = null;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/** What a sibling's `pm clear` does to this app: every keystore-wrapped copy dies. */
|
|
119
|
+
const wipeUidKeystore = () => {
|
|
120
|
+
for (const service of ALL_SERVICES) {
|
|
121
|
+
ss.__simulateKeystoreDeath__(service);
|
|
122
|
+
}
|
|
123
|
+
resetCaches();
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const record = () => (store.value ? JSON.parse(store.value) : null);
|
|
127
|
+
|
|
128
|
+
beforeAll(() => {
|
|
129
|
+
(globalThis as unknown as { navigator: unknown }).navigator = { product: 'ReactNative' };
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
beforeEach(async () => {
|
|
133
|
+
jest.resetModules();
|
|
134
|
+
setPlatformOS('android');
|
|
135
|
+
ss = (await import('expo-secure-store' as string)) as unknown as SecureStoreTestHandle;
|
|
136
|
+
ss.__resetStore__();
|
|
137
|
+
KeyManager = (await import('../keyManager')).KeyManager;
|
|
138
|
+
RecoveryPhraseService = (await import('../recoveryPhrase')).RecoveryPhraseService;
|
|
139
|
+
clearIdentityMarker = (await import('../identityMarker')).clearIdentityMarker;
|
|
140
|
+
store = createFakeStore();
|
|
141
|
+
KeyManager.setDeviceBackupStore(store);
|
|
142
|
+
resetCaches();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe('backup', () => {
|
|
146
|
+
it('writes the identity on creation', async () => {
|
|
147
|
+
const pub = await KeyManager.createIdentity();
|
|
148
|
+
const priv = await KeyManager.getPrivateKey();
|
|
149
|
+
expect(record()).toMatchObject({ version: 1, publicKey: pub.toLowerCase(), privateKey: priv });
|
|
150
|
+
expect(record().mnemonic).toBeUndefined();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('carries the phrase only when it derives the identity', async () => {
|
|
154
|
+
const pending = await RecoveryPhraseService.derivePendingIdentity();
|
|
155
|
+
await KeyManager.importKeyPair(pending.privateKey);
|
|
156
|
+
await KeyManager.storeRecoveryMnemonic(pending.phrase);
|
|
157
|
+
expect(record().mnemonic).toBe(pending.phrase);
|
|
158
|
+
|
|
159
|
+
const other = await RecoveryPhraseService.derivePendingIdentity();
|
|
160
|
+
await KeyManager.storeRecoveryMnemonic(other.phrase);
|
|
161
|
+
// A phrase for another identity never reaches the backup.
|
|
162
|
+
expect(record().mnemonic).toBe(pending.phrase);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('follows a key rotation and drops the old phrase', async () => {
|
|
166
|
+
const oldId = await RecoveryPhraseService.derivePendingIdentity();
|
|
167
|
+
await KeyManager.importKeyPair(oldId.privateKey);
|
|
168
|
+
await KeyManager.storeRecoveryMnemonic(oldId.phrase);
|
|
169
|
+
|
|
170
|
+
const newId = await RecoveryPhraseService.derivePendingIdentity();
|
|
171
|
+
await KeyManager.importKeyPair(newId.privateKey, { overwrite: true });
|
|
172
|
+
expect(record()).toMatchObject({ publicKey: newId.publicKey.toLowerCase(), privateKey: newId.privateKey });
|
|
173
|
+
expect(record().mnemonic).toBeUndefined();
|
|
174
|
+
|
|
175
|
+
await KeyManager.storeRecoveryMnemonic(newId.phrase);
|
|
176
|
+
expect(record().mnemonic).toBe(newId.phrase);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('never fails an identity write when the store fails', async () => {
|
|
180
|
+
store.failWrites = true;
|
|
181
|
+
const pub = await KeyManager.createIdentity();
|
|
182
|
+
expect((await KeyManager.getIdentityStatus()).state).toBe('present');
|
|
183
|
+
expect(pub).toBeTruthy();
|
|
184
|
+
expect(store.value).toBeNull();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('ensureDeviceBackup backfills a missing backup and repairs a stale one', async () => {
|
|
188
|
+
store.failWrites = true;
|
|
189
|
+
const pending = await RecoveryPhraseService.derivePendingIdentity();
|
|
190
|
+
await KeyManager.importKeyPair(pending.privateKey);
|
|
191
|
+
await KeyManager.storeRecoveryMnemonic(pending.phrase);
|
|
192
|
+
expect(store.value).toBeNull();
|
|
193
|
+
|
|
194
|
+
store.failWrites = false;
|
|
195
|
+
expect(await KeyManager.ensureDeviceBackup()).toBe(true);
|
|
196
|
+
expect(record()).toMatchObject({ publicKey: pending.publicKey.toLowerCase(), mnemonic: pending.phrase });
|
|
197
|
+
|
|
198
|
+
// Current already: one read, no write.
|
|
199
|
+
const writes = store.writes;
|
|
200
|
+
expect(await KeyManager.ensureDeviceBackup()).toBe(true);
|
|
201
|
+
expect(store.writes).toBe(writes);
|
|
202
|
+
|
|
203
|
+
// Stale (e.g. a rotation whose backup write failed): rewritten.
|
|
204
|
+
const stale = await KeyManager.generateKeyPair();
|
|
205
|
+
store.value = JSON.stringify({ ...record(), privateKey: stale.privateKey, publicKey: stale.publicKey });
|
|
206
|
+
expect(await KeyManager.ensureDeviceBackup()).toBe(true);
|
|
207
|
+
expect(record().publicKey).toBe(pending.publicKey.toLowerCase());
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('ensureDeviceBackup is a no-op without a store or an identity', async () => {
|
|
211
|
+
expect(await KeyManager.ensureDeviceBackup()).toBe(false);
|
|
212
|
+
KeyManager.setDeviceBackupStore(null);
|
|
213
|
+
await KeyManager.createIdentity();
|
|
214
|
+
expect(await KeyManager.ensureDeviceBackup()).toBe(false);
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
describe('restore', () => {
|
|
219
|
+
it('restores silently after a wipe of the whole UID keystore, phrase included', async () => {
|
|
220
|
+
const pending = await RecoveryPhraseService.derivePendingIdentity();
|
|
221
|
+
await KeyManager.importKeyPair(pending.privateKey);
|
|
222
|
+
await KeyManager.storeRecoveryMnemonic(pending.phrase);
|
|
223
|
+
|
|
224
|
+
wipeUidKeystore();
|
|
225
|
+
expect((await KeyManager.getIdentityStatus({ bypassCache: true })).state).toBe('lost');
|
|
226
|
+
|
|
227
|
+
const result = await KeyManager.attemptIdentityRecovery();
|
|
228
|
+
expect(result).toEqual({
|
|
229
|
+
recovered: true,
|
|
230
|
+
source: 'device-backup',
|
|
231
|
+
publicKey: pending.publicKey.toLowerCase(),
|
|
232
|
+
});
|
|
233
|
+
resetCaches();
|
|
234
|
+
expect(await KeyManager.getIdentityStatus()).toEqual({
|
|
235
|
+
state: 'present',
|
|
236
|
+
publicKey: pending.publicKey.toLowerCase(),
|
|
237
|
+
});
|
|
238
|
+
expect(ss.__getRaw__(V2_PRIV, PRIMARY_SVC)).toBe(pending.privateKey);
|
|
239
|
+
expect(await KeyManager.getRecoveryMnemonic()).toBe(pending.phrase);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('without a device backup the same wipe still ends at the phrase', async () => {
|
|
243
|
+
KeyManager.setDeviceBackupStore(null);
|
|
244
|
+
await KeyManager.createIdentity();
|
|
245
|
+
wipeUidKeystore();
|
|
246
|
+
expect(await KeyManager.attemptIdentityRecovery()).toEqual({ recovered: false, reason: 'no-sources' });
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('restores when this app lost its own data too (absent: no keys, no marker)', async () => {
|
|
250
|
+
const pub = await KeyManager.createIdentity();
|
|
251
|
+
wipeUidKeystore();
|
|
252
|
+
await clearIdentityMarker();
|
|
253
|
+
expect((await KeyManager.getIdentityStatus({ bypassCache: true })).state).toBe('absent');
|
|
254
|
+
|
|
255
|
+
const result = await KeyManager.attemptIdentityRecovery();
|
|
256
|
+
expect(result).toEqual({ recovered: true, source: 'device-backup', publicKey: pub.toLowerCase() });
|
|
257
|
+
resetCaches();
|
|
258
|
+
expect((await KeyManager.getIdentityStatus()).state).toBe('present');
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('never switches to a backup that holds a different account than the marker', async () => {
|
|
262
|
+
await KeyManager.createIdentity();
|
|
263
|
+
const other = await KeyManager.generateKeyPair();
|
|
264
|
+
store.value = JSON.stringify({
|
|
265
|
+
version: 1,
|
|
266
|
+
privateKey: other.privateKey,
|
|
267
|
+
publicKey: other.publicKey,
|
|
268
|
+
updatedAt: new Date().toISOString(),
|
|
269
|
+
});
|
|
270
|
+
wipeUidKeystore();
|
|
271
|
+
|
|
272
|
+
expect(await KeyManager.attemptIdentityRecovery()).toEqual({ recovered: false, reason: 'mismatch' });
|
|
273
|
+
expect(ss.__getRaw__(V2_PRIV, PRIMARY_SVC)).toBeNull();
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it('ignores a malformed or unhealthy record and an unreachable store', async () => {
|
|
277
|
+
await KeyManager.createIdentity();
|
|
278
|
+
wipeUidKeystore();
|
|
279
|
+
|
|
280
|
+
store.value = '{"version":1,"privateKey":"zz","publicKey":"00","updatedAt":"x"}';
|
|
281
|
+
expect(await KeyManager.attemptIdentityRecovery()).toEqual({ recovered: false, reason: 'no-sources' });
|
|
282
|
+
|
|
283
|
+
store.value = 'not json';
|
|
284
|
+
expect(await KeyManager.attemptIdentityRecovery()).toEqual({ recovered: false, reason: 'no-sources' });
|
|
285
|
+
|
|
286
|
+
store.failReads = true;
|
|
287
|
+
expect(await KeyManager.attemptIdentityRecovery()).toEqual({ recovered: false, reason: 'no-sources' });
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('a fresh device with no backup stays absent', async () => {
|
|
291
|
+
expect(await KeyManager.attemptIdentityRecovery()).toEqual({ recovered: false, reason: 'not-lost' });
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
describe('delete', () => {
|
|
296
|
+
it('every delete clears the backup, so a deleted identity never comes back', async () => {
|
|
297
|
+
await KeyManager.createIdentity();
|
|
298
|
+
expect(store.value).not.toBeNull();
|
|
299
|
+
await KeyManager.deleteIdentity(true, true, true);
|
|
300
|
+
expect(store.value).toBeNull();
|
|
301
|
+
expect(await KeyManager.attemptIdentityRecovery()).toEqual({ recovered: false, reason: 'not-lost' });
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('a user-confirmed, non-forced delete clears it too', async () => {
|
|
305
|
+
await KeyManager.createIdentity();
|
|
306
|
+
await KeyManager.deleteIdentity(false, false, true);
|
|
307
|
+
expect(store.value).toBeNull();
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The device backup of the self-custody identity: a copy that lives OUTSIDE this
|
|
3
|
+
* app's own keystore, so a keystore wipe cannot take it along with the keys.
|
|
4
|
+
*
|
|
5
|
+
* Why it exists (OxyHQ/oxy#1388): on Android every Oxy app shares the Linux UID
|
|
6
|
+
* `so.oxy.shared`, and the Android Keystore is per UID. Clearing the storage of
|
|
7
|
+
* ANY sibling app (`pm clear`, or Settings › Apps › Storage › Clear storage)
|
|
8
|
+
* wipes the Keystore of the whole UID. Every copy the SDK keeps in
|
|
9
|
+
* expo-secure-store (primary, backup slot, phrase slot) and in the androidx
|
|
10
|
+
* encrypted prefs (the cross-app shared slot) is wrapped by a key in that
|
|
11
|
+
* Keystore, so they all die together, and until now the only way back was the
|
|
12
|
+
* recovery phrase.
|
|
13
|
+
*
|
|
14
|
+
* `@oxy.so/core` never imports a native module, so the store is injected by the
|
|
15
|
+
* app that owns the identity ({@link KeyManager.setDeviceBackupStore}). Commons
|
|
16
|
+
* registers Android Block Store; every other app registers nothing, which keeps
|
|
17
|
+
* every code path below a no-op for them. The design and the threat model are in
|
|
18
|
+
* `docs/identity/device-backup.md`.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Where the device backup is kept. Implementations must hold the value outside
|
|
23
|
+
* the calling app's keystore (that is the whole point) and must never send it
|
|
24
|
+
* anywhere Oxy can read it.
|
|
25
|
+
*
|
|
26
|
+
* `read` resolves `null` when nothing is stored AND when the store cannot be
|
|
27
|
+
* reached (no Google Play services, an old binary without the native module):
|
|
28
|
+
* the caller treats both as "no device backup" and falls back to the phrase.
|
|
29
|
+
*/
|
|
30
|
+
export interface IdentityDeviceBackupStore {
|
|
31
|
+
/** Short name for logs, e.g. `android-block-store`. Never contains secrets. */
|
|
32
|
+
readonly name: string;
|
|
33
|
+
read(): Promise<string | null>;
|
|
34
|
+
write(value: string): Promise<void>;
|
|
35
|
+
clear(): Promise<void>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The record format. Bump {@link DEVICE_BACKUP_VERSION} on a breaking change. */
|
|
39
|
+
export const DEVICE_BACKUP_VERSION = 1;
|
|
40
|
+
|
|
41
|
+
export interface DeviceBackupRecord {
|
|
42
|
+
version: typeof DEVICE_BACKUP_VERSION;
|
|
43
|
+
/** Canonical lowercase 64-hex secp256k1 private key. */
|
|
44
|
+
privateKey: string;
|
|
45
|
+
/** Canonical lowercase public key. */
|
|
46
|
+
publicKey: string;
|
|
47
|
+
/**
|
|
48
|
+
* The BIP-39 phrase, present only when it was verified to derive
|
|
49
|
+
* {@link publicKey}. Carried so a restored identity can still reveal its
|
|
50
|
+
* phrase in Settings and create the phrase-keyed Oxy backup; the phrase is
|
|
51
|
+
* never more powerful than the private key already in the record.
|
|
52
|
+
*/
|
|
53
|
+
mnemonic?: string;
|
|
54
|
+
/** ISO-8601 time of the last write. */
|
|
55
|
+
updatedAt: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function serializeDeviceBackup(record: DeviceBackupRecord): string {
|
|
59
|
+
const out: DeviceBackupRecord = {
|
|
60
|
+
version: DEVICE_BACKUP_VERSION,
|
|
61
|
+
privateKey: record.privateKey,
|
|
62
|
+
publicKey: record.publicKey,
|
|
63
|
+
updatedAt: record.updatedAt,
|
|
64
|
+
};
|
|
65
|
+
if (record.mnemonic) {
|
|
66
|
+
out.mnemonic = record.mnemonic;
|
|
67
|
+
}
|
|
68
|
+
return JSON.stringify(out);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Parse a stored record, or `null` for anything that is not a well-formed
|
|
73
|
+
* version-1 record. Checks SHAPE only; the caller still proves the key pair is
|
|
74
|
+
* healthy (well-formed, public derives from private) before trusting it.
|
|
75
|
+
*/
|
|
76
|
+
export function parseDeviceBackup(raw: string | null | undefined): DeviceBackupRecord | null {
|
|
77
|
+
if (!raw) {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
let parsed: unknown;
|
|
81
|
+
try {
|
|
82
|
+
parsed = JSON.parse(raw);
|
|
83
|
+
} catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
const candidate = parsed as Record<string, unknown>;
|
|
90
|
+
if (
|
|
91
|
+
candidate.version !== DEVICE_BACKUP_VERSION ||
|
|
92
|
+
typeof candidate.privateKey !== 'string' ||
|
|
93
|
+
typeof candidate.publicKey !== 'string' ||
|
|
94
|
+
typeof candidate.updatedAt !== 'string'
|
|
95
|
+
) {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
const record: DeviceBackupRecord = {
|
|
99
|
+
version: DEVICE_BACKUP_VERSION,
|
|
100
|
+
privateKey: candidate.privateKey,
|
|
101
|
+
publicKey: candidate.publicKey,
|
|
102
|
+
updatedAt: candidate.updatedAt,
|
|
103
|
+
};
|
|
104
|
+
if (typeof candidate.mnemonic === 'string' && candidate.mnemonic.length > 0) {
|
|
105
|
+
record.mnemonic = candidate.mnemonic;
|
|
106
|
+
}
|
|
107
|
+
return record;
|
|
108
|
+
}
|