@oxy.so/core 1.10.0 → 1.12.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.
Files changed (41) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/crypto/deviceBackup.js +75 -0
  3. package/dist/cjs/crypto/keyManager.js +285 -12
  4. package/dist/cjs/mixins/OxyServices.linkedAccounts.js +79 -0
  5. package/dist/cjs/mixins/OxyServices.user.js +5 -1
  6. package/dist/cjs/mixins/index.js +4 -0
  7. package/dist/cjs/server/securityHeaders.js +3 -0
  8. package/dist/cjs/session/accountDialogController.js +41 -0
  9. package/dist/esm/.tsbuildinfo +1 -1
  10. package/dist/esm/crypto/deviceBackup.js +70 -0
  11. package/dist/esm/crypto/keyManager.js +252 -12
  12. package/dist/esm/mixins/OxyServices.linkedAccounts.js +76 -0
  13. package/dist/esm/mixins/OxyServices.user.js +5 -1
  14. package/dist/esm/mixins/index.js +4 -0
  15. package/dist/esm/server/securityHeaders.js +3 -0
  16. package/dist/esm/session/accountDialogController.js +41 -0
  17. package/dist/types/.tsbuildinfo +1 -1
  18. package/dist/types/crypto/deviceBackup.d.ts +60 -0
  19. package/dist/types/crypto/keyManager.d.ts +79 -12
  20. package/dist/types/index.d.ts +2 -1
  21. package/dist/types/mixins/OxyServices.linkedAccounts.d.ts +107 -0
  22. package/dist/types/mixins/OxyServices.user.d.ts +7 -2
  23. package/dist/types/mixins/index.d.ts +2 -1
  24. package/dist/types/models/interfaces.d.ts +30 -0
  25. package/dist/types/server/securityHeaders.d.ts +5 -0
  26. package/dist/types/session/accountDialogController.d.ts +45 -0
  27. package/package.json +2 -2
  28. package/src/crypto/__tests__/keyManager.deviceBackup.test.ts +310 -0
  29. package/src/crypto/deviceBackup.ts +108 -0
  30. package/src/crypto/keyManager.ts +278 -13
  31. package/src/index.ts +2 -0
  32. package/src/mixins/OxyServices.linkedAccounts.ts +129 -0
  33. package/src/mixins/OxyServices.user.ts +7 -2
  34. package/src/mixins/__tests__/linkedAccounts.test.ts +109 -0
  35. package/src/mixins/index.ts +6 -0
  36. package/src/models/interfaces.ts +30 -1
  37. package/src/server/__tests__/securityHeaders.test.ts +7 -0
  38. package/src/server/securityHeaders.ts +8 -0
  39. package/src/session/__tests__/accountDialogController.test.ts +173 -0
  40. package/src/session/__tests__/accountDialogShape.test.ts +1 -0
  41. package/src/session/accountDialogController.ts +65 -0
@@ -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
+ }