@oxyhq/core 12.9.0 → 12.10.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/crypto/keyManager.js +89 -0
- package/dist/cjs/index.js +7 -4
- package/dist/cjs/mixins/OxyServices.auth.js +4 -1
- package/dist/cjs/mixins/OxyServices.identityBackup.js +11 -0
- package/dist/cjs/mixins/OxyServices.user.js +4 -0
- package/dist/cjs/utils/commonsApproval.js +31 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/crypto/keyManager.js +89 -0
- package/dist/esm/index.js +2 -1
- package/dist/esm/mixins/OxyServices.auth.js +1 -0
- package/dist/esm/mixins/OxyServices.identityBackup.js +11 -0
- package/dist/esm/mixins/OxyServices.user.js +4 -0
- package/dist/esm/utils/commonsApproval.js +27 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/crypto/keyManager.d.ts +33 -0
- package/dist/types/index.d.ts +2 -1
- package/dist/types/mixins/OxyServices.auth.d.ts +4 -4
- package/dist/types/utils/commonsApproval.d.ts +13 -0
- package/package.json +1 -1
- package/src/crypto/__tests__/keyManager.recoveryMnemonic.test.ts +138 -0
- package/src/crypto/keyManager.ts +99 -0
- package/src/index.ts +7 -1
- package/src/mixins/OxyServices.auth.ts +9 -5
- package/src/mixins/OxyServices.identityBackup.ts +13 -0
- package/src/mixins/OxyServices.user.ts +4 -0
- package/src/mixins/__tests__/identityBackup.test.ts +57 -0
- package/src/mixins/__tests__/privacyCacheInvalidation.test.ts +6 -0
- package/src/utils/__tests__/commonsApproval.test.ts +60 -0
- package/src/utils/commonsApproval.ts +39 -0
|
@@ -396,6 +396,39 @@ export declare class KeyManager {
|
|
|
396
396
|
* and NEVER cached, so a poisoned "no identity" verdict can no longer stick.
|
|
397
397
|
*/
|
|
398
398
|
static getPublicKey(): Promise<string | null>;
|
|
399
|
+
/**
|
|
400
|
+
* Persist the recovery mnemonic (the 12-word phrase) into its dedicated,
|
|
401
|
+
* device-only keychain slot so the user can re-reveal it from Settings after
|
|
402
|
+
* onboarding.
|
|
403
|
+
*
|
|
404
|
+
* Called best-effort at identity creation/import, where the phrase is already
|
|
405
|
+
* in memory: a failure to persist it must NEVER fail the identity itself, so
|
|
406
|
+
* callers deliberately swallow the thrown error (logging it). Storage errors
|
|
407
|
+
* throw {@link IdentityUnavailableError} — same "cannot determine" semantics as
|
|
408
|
+
* the other getters — so a caller MAY observe/log the failure.
|
|
409
|
+
*
|
|
410
|
+
* The mnemonic is stored ONLY here — never in the marker, `getIdentityStatus`,
|
|
411
|
+
* logs, or any exported bundle.
|
|
412
|
+
*/
|
|
413
|
+
static storeRecoveryMnemonic(mnemonic: string): Promise<void>;
|
|
414
|
+
/**
|
|
415
|
+
* Read the stored recovery mnemonic for re-reveal in Settings.
|
|
416
|
+
*
|
|
417
|
+
* Returns the phrase, or `null` when a read SUCCEEDS and finds none — the
|
|
418
|
+
* expected result for any identity created/imported before this feature
|
|
419
|
+
* existed, since the phrase was never captured for those. THROWS
|
|
420
|
+
* {@link IdentityUnavailableError} when storage is unreadable (keychain locked
|
|
421
|
+
* / module load failure), matching {@link getPublicKey}'s contract — a thrown
|
|
422
|
+
* read is never flattened to `null`, so a caller distinguishes "phrase was
|
|
423
|
+
* never stored" from "keychain temporarily locked, retry".
|
|
424
|
+
*/
|
|
425
|
+
static getRecoveryMnemonic(): Promise<string | null>;
|
|
426
|
+
/**
|
|
427
|
+
* Delete the stored recovery mnemonic. Best-effort: a delete failure is logged
|
|
428
|
+
* and swallowed, never thrown — it runs inside the identity-deletion path where
|
|
429
|
+
* an unreadable keychain must not abort the wider teardown.
|
|
430
|
+
*/
|
|
431
|
+
static deleteRecoveryMnemonic(): Promise<void>;
|
|
399
432
|
/**
|
|
400
433
|
* Check if a complete, parseable identity exists on this device.
|
|
401
434
|
*
|
package/dist/types/index.d.ts
CHANGED
|
@@ -21,7 +21,8 @@ export { OxyServices, AssetUrlResolutionError, OxyAuthenticationError, OxyAuthen
|
|
|
21
21
|
export { OXY_CLOUD_URL, oxyClient } from './OxyServices';
|
|
22
22
|
export type { LinkedHttpClient } from './OxyServices.base';
|
|
23
23
|
export type { AuthRefreshReason, AuthRefreshHandler } from './HttpService';
|
|
24
|
-
export { ServiceCredentialMismatchError } from './mixins/OxyServices.auth';
|
|
24
|
+
export { ServiceCredentialMismatchError, } from './mixins/OxyServices.auth';
|
|
25
|
+
export { getCommonsApprovalBlockingReason, parseCommonsApprovalExpiresAt, } from './utils/commonsApproval';
|
|
25
26
|
export type { ServiceTokenResponse } from './mixins/OxyServices.auth';
|
|
26
27
|
export type { CommonsSignInHandle, CommonsSignInStatus, CommonsApprovalInfo, CommonsSignInActionResult, } from './mixins/OxyServices.auth';
|
|
27
28
|
export type { ServiceApp, ServiceActingAsVerification } from './mixins/OxyServices.utility';
|
|
@@ -8,6 +8,7 @@ import type { LoginResult, LoginSessionResult } from '@oxyhq/contracts';
|
|
|
8
8
|
import type { SessionLoginResponse } from '../models/session';
|
|
9
9
|
import type { OxyServicesBase } from '../OxyServices.base';
|
|
10
10
|
import type { PublicApplication } from './OxyServices.connectedApps';
|
|
11
|
+
export { getCommonsApprovalBlockingReason, parseCommonsApprovalExpiresAt, } from '../utils/commonsApproval';
|
|
11
12
|
export interface ChallengeResponse {
|
|
12
13
|
challenge: string;
|
|
13
14
|
expiresAt: string;
|
|
@@ -71,7 +72,7 @@ export interface CommonsSignInStatus {
|
|
|
71
72
|
*/
|
|
72
73
|
export interface CommonsApprovalInfo {
|
|
73
74
|
/** Sanitized, display-safe identity of the requesting application. */
|
|
74
|
-
application: PublicApplication;
|
|
75
|
+
application: PublicApplication | null;
|
|
75
76
|
/** OAuth scopes the application is requesting. */
|
|
76
77
|
scopes: string[];
|
|
77
78
|
/** The origin the session is bound to (the RP web origin), when applicable. */
|
|
@@ -84,8 +85,8 @@ export interface CommonsApprovalInfo {
|
|
|
84
85
|
* "not verified") by {@link OxyServicesAuthMixin.getCommonsApprovalInfo}.
|
|
85
86
|
*/
|
|
86
87
|
originVerified: boolean;
|
|
87
|
-
/** Server-authoritative expiry (epoch
|
|
88
|
-
expiresAt: number;
|
|
88
|
+
/** Server-authoritative expiry (epoch ms or ISO-8601 string from the API). */
|
|
89
|
+
expiresAt: number | string;
|
|
89
90
|
/** Session lifecycle status. */
|
|
90
91
|
status: string;
|
|
91
92
|
}
|
|
@@ -596,4 +597,3 @@ export declare function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(B
|
|
|
596
597
|
}>;
|
|
597
598
|
};
|
|
598
599
|
} & T;
|
|
599
|
-
export {};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { PublicApplication } from '../mixins/OxyServices.connectedApps';
|
|
2
|
+
export interface CommonsApprovalValidationInput {
|
|
3
|
+
application: PublicApplication | null;
|
|
4
|
+
status: string;
|
|
5
|
+
expiresAt: number | string;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Returns a user-facing blocking reason when an approval payload must not be
|
|
9
|
+
* shown as actionable, or `null` when the request is still pending and valid.
|
|
10
|
+
*/
|
|
11
|
+
export declare function getCommonsApprovalBlockingReason(info: CommonsApprovalValidationInput): string | null;
|
|
12
|
+
/** Normalize API `expiresAt` (number or ISO string) to epoch ms. */
|
|
13
|
+
export declare function parseCommonsApprovalExpiresAt(expiresAt: CommonsApprovalValidationInput['expiresAt']): number | null;
|
package/package.json
CHANGED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recovery-mnemonic storage: a dedicated, device-only keychain slot that lets a
|
|
3
|
+
* user re-reveal their 12-word phrase from Settings. Isolated from the identity
|
|
4
|
+
* primary/backup slots (its own keychain service), null-on-absent, typed-throw
|
|
5
|
+
* on a locked keychain, and wiped when the identity is deleted.
|
|
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 MNEMONIC_SVC = 'oxy_identity_mnemonic';
|
|
57
|
+
const MNEMONIC_KEY = 'oxy_identity_mnemonic_v1';
|
|
58
|
+
const PRIMARY_SVC = 'oxy_identity';
|
|
59
|
+
const PHRASE = 'legal winner thank year wave sausage worth useful legal winner thank yellow';
|
|
60
|
+
|
|
61
|
+
interface SecureStoreTestHandle {
|
|
62
|
+
__resetStore__: () => void;
|
|
63
|
+
__getRaw__: (key: string, service?: string) => string | null;
|
|
64
|
+
__simulateKeystoreDeath__: (service: string) => void;
|
|
65
|
+
__failPlan__: { failKey?: string; failOp?: 'set' | 'get'; failTimes?: number; failService?: string };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
describe('KeyManager recovery mnemonic storage', () => {
|
|
69
|
+
let KeyManager: typeof import('../keyManager').KeyManager;
|
|
70
|
+
let IdentityUnavailableError: typeof import('../keyManager').IdentityUnavailableError;
|
|
71
|
+
let ss: SecureStoreTestHandle;
|
|
72
|
+
|
|
73
|
+
beforeAll(() => {
|
|
74
|
+
setPlatformOS('ios');
|
|
75
|
+
(globalThis as unknown as { navigator: unknown }).navigator = { product: 'ReactNative' };
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
beforeEach(async () => {
|
|
79
|
+
jest.resetModules();
|
|
80
|
+
setPlatformOS('ios');
|
|
81
|
+
ss = (await import('expo-secure-store' as string)) as unknown as SecureStoreTestHandle;
|
|
82
|
+
ss.__resetStore__();
|
|
83
|
+
const km = await import('../keyManager');
|
|
84
|
+
KeyManager = km.KeyManager;
|
|
85
|
+
IdentityUnavailableError = km.IdentityUnavailableError;
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('round-trips a stored mnemonic', async () => {
|
|
89
|
+
await KeyManager.storeRecoveryMnemonic(PHRASE);
|
|
90
|
+
expect(await KeyManager.getRecoveryMnemonic()).toBe(PHRASE);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('persists under its OWN keychain service, isolated from the identity slots', async () => {
|
|
94
|
+
await KeyManager.storeRecoveryMnemonic(PHRASE);
|
|
95
|
+
// Present under the mnemonic service, invisible under the primary service.
|
|
96
|
+
expect(ss.__getRaw__(MNEMONIC_KEY, MNEMONIC_SVC)).toBe(PHRASE);
|
|
97
|
+
expect(ss.__getRaw__(MNEMONIC_KEY, PRIMARY_SVC)).toBeNull();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('returns null when no mnemonic was ever stored (pre-feature identity)', async () => {
|
|
101
|
+
expect(await KeyManager.getRecoveryMnemonic()).toBeNull();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('survives a keystore death of the identity primary service', async () => {
|
|
105
|
+
await KeyManager.storeRecoveryMnemonic(PHRASE);
|
|
106
|
+
// The identity primary slot dies; the mnemonic lives in a distinct service.
|
|
107
|
+
ss.__simulateKeystoreDeath__(PRIMARY_SVC);
|
|
108
|
+
expect(await KeyManager.getRecoveryMnemonic()).toBe(PHRASE);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('throws IdentityUnavailableError (never null) when the read throws', async () => {
|
|
112
|
+
await KeyManager.storeRecoveryMnemonic(PHRASE);
|
|
113
|
+
ss.__failPlan__.failOp = 'get';
|
|
114
|
+
ss.__failPlan__.failKey = MNEMONIC_KEY;
|
|
115
|
+
ss.__failPlan__.failService = MNEMONIC_SVC;
|
|
116
|
+
await expect(KeyManager.getRecoveryMnemonic()).rejects.toBeInstanceOf(IdentityUnavailableError);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('throws IdentityUnavailableError when the write throws', async () => {
|
|
120
|
+
ss.__failPlan__.failOp = 'set';
|
|
121
|
+
ss.__failPlan__.failKey = MNEMONIC_KEY;
|
|
122
|
+
ss.__failPlan__.failService = MNEMONIC_SVC;
|
|
123
|
+
await expect(KeyManager.storeRecoveryMnemonic(PHRASE)).rejects.toBeInstanceOf(IdentityUnavailableError);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('deleteRecoveryMnemonic removes the stored phrase', async () => {
|
|
127
|
+
await KeyManager.storeRecoveryMnemonic(PHRASE);
|
|
128
|
+
await KeyManager.deleteRecoveryMnemonic();
|
|
129
|
+
expect(await KeyManager.getRecoveryMnemonic()).toBeNull();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('deleteIdentity(force) wipes the stored mnemonic', async () => {
|
|
133
|
+
await KeyManager.createIdentity();
|
|
134
|
+
await KeyManager.storeRecoveryMnemonic(PHRASE);
|
|
135
|
+
await KeyManager.deleteIdentity(true, true, true);
|
|
136
|
+
expect(await KeyManager.getRecoveryMnemonic()).toBeNull();
|
|
137
|
+
});
|
|
138
|
+
});
|
package/src/crypto/keyManager.ts
CHANGED
|
@@ -198,6 +198,25 @@ const V2_STORAGE_KEYS = {
|
|
|
198
198
|
BACKUP_TIMESTAMP: 'oxy_identity_backup_timestamp_v2',
|
|
199
199
|
} as const;
|
|
200
200
|
|
|
201
|
+
/**
|
|
202
|
+
* Dedicated keychain slot for the recovery mnemonic (the 12-word phrase).
|
|
203
|
+
*
|
|
204
|
+
* Stored under its OWN keychain service — distinct from the v2 primary, backup,
|
|
205
|
+
* and shared slots — so it shares an AndroidKeyStore key with none of them
|
|
206
|
+
* (blast-radius isolation, same rationale as the v2 primary/backup split).
|
|
207
|
+
* Written `WHEN_UNLOCKED_THIS_DEVICE_ONLY` and NEVER exported off-device: it
|
|
208
|
+
* exists solely so the user can RE-READ their phrase from Settings on the SAME
|
|
209
|
+
* device that generated/imported it.
|
|
210
|
+
*
|
|
211
|
+
* This is convenience persistence, NOT a recovery mechanism — a keystore death
|
|
212
|
+
* wipes it alongside the keys, exactly like the private key itself. The user's
|
|
213
|
+
* written-down phrase remains the sole out-of-band recovery path. The mnemonic
|
|
214
|
+
* lives ONLY in this slot: it is never mirrored into the identity marker,
|
|
215
|
+
* {@link KeyManager.getIdentityStatus}, logs, or any exported bundle.
|
|
216
|
+
*/
|
|
217
|
+
const RECOVERY_MNEMONIC_KEYCHAIN_SERVICE = 'oxy_identity_mnemonic';
|
|
218
|
+
const RECOVERY_MNEMONIC_STORAGE_KEY = 'oxy_identity_mnemonic_v1';
|
|
219
|
+
|
|
201
220
|
/**
|
|
202
221
|
* Advisory AsyncStorage fast-path flag: set once the v2 slots own the identity.
|
|
203
222
|
* Re-derivable (its loss just re-runs the cheap slot check), so it lives in
|
|
@@ -1773,6 +1792,81 @@ export class KeyManager {
|
|
|
1773
1792
|
}
|
|
1774
1793
|
}
|
|
1775
1794
|
|
|
1795
|
+
/**
|
|
1796
|
+
* Persist the recovery mnemonic (the 12-word phrase) into its dedicated,
|
|
1797
|
+
* device-only keychain slot so the user can re-reveal it from Settings after
|
|
1798
|
+
* onboarding.
|
|
1799
|
+
*
|
|
1800
|
+
* Called best-effort at identity creation/import, where the phrase is already
|
|
1801
|
+
* in memory: a failure to persist it must NEVER fail the identity itself, so
|
|
1802
|
+
* callers deliberately swallow the thrown error (logging it). Storage errors
|
|
1803
|
+
* throw {@link IdentityUnavailableError} — same "cannot determine" semantics as
|
|
1804
|
+
* the other getters — so a caller MAY observe/log the failure.
|
|
1805
|
+
*
|
|
1806
|
+
* The mnemonic is stored ONLY here — never in the marker, `getIdentityStatus`,
|
|
1807
|
+
* logs, or any exported bundle.
|
|
1808
|
+
*/
|
|
1809
|
+
static async storeRecoveryMnemonic(mnemonic: string): Promise<void> {
|
|
1810
|
+
if (isWebPlatform()) {
|
|
1811
|
+
return; // Identity storage is only available on native platforms
|
|
1812
|
+
}
|
|
1813
|
+
try {
|
|
1814
|
+
const store = await initSecureStore();
|
|
1815
|
+
await store.setItemAsync(
|
|
1816
|
+
RECOVERY_MNEMONIC_STORAGE_KEY,
|
|
1817
|
+
mnemonic,
|
|
1818
|
+
KeyManager._privateWriteOpts(store, RECOVERY_MNEMONIC_KEYCHAIN_SERVICE),
|
|
1819
|
+
);
|
|
1820
|
+
} catch (error) {
|
|
1821
|
+
if (isDev()) {
|
|
1822
|
+
logger.warn('Failed to persist recovery mnemonic', { component: 'KeyManager' }, error);
|
|
1823
|
+
}
|
|
1824
|
+
throw new IdentityUnavailableError('Failed to persist recovery mnemonic.', error);
|
|
1825
|
+
}
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
/**
|
|
1829
|
+
* Read the stored recovery mnemonic for re-reveal in Settings.
|
|
1830
|
+
*
|
|
1831
|
+
* Returns the phrase, or `null` when a read SUCCEEDS and finds none — the
|
|
1832
|
+
* expected result for any identity created/imported before this feature
|
|
1833
|
+
* existed, since the phrase was never captured for those. THROWS
|
|
1834
|
+
* {@link IdentityUnavailableError} when storage is unreadable (keychain locked
|
|
1835
|
+
* / module load failure), matching {@link getPublicKey}'s contract — a thrown
|
|
1836
|
+
* read is never flattened to `null`, so a caller distinguishes "phrase was
|
|
1837
|
+
* never stored" from "keychain temporarily locked, retry".
|
|
1838
|
+
*/
|
|
1839
|
+
static async getRecoveryMnemonic(): Promise<string | null> {
|
|
1840
|
+
if (isWebPlatform()) {
|
|
1841
|
+
return null; // Identity storage is only available on native platforms
|
|
1842
|
+
}
|
|
1843
|
+
try {
|
|
1844
|
+
const store = await initSecureStore();
|
|
1845
|
+
return await store.getItemAsync(
|
|
1846
|
+
RECOVERY_MNEMONIC_STORAGE_KEY,
|
|
1847
|
+
KeyManager._slotOpts(RECOVERY_MNEMONIC_KEYCHAIN_SERVICE),
|
|
1848
|
+
);
|
|
1849
|
+
} catch (error) {
|
|
1850
|
+
if (isDev()) {
|
|
1851
|
+
logger.warn('Failed to read recovery mnemonic', { component: 'KeyManager' }, error);
|
|
1852
|
+
}
|
|
1853
|
+
throw new IdentityUnavailableError('Failed to read recovery mnemonic from secure storage.', error);
|
|
1854
|
+
}
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
/**
|
|
1858
|
+
* Delete the stored recovery mnemonic. Best-effort: a delete failure is logged
|
|
1859
|
+
* and swallowed, never thrown — it runs inside the identity-deletion path where
|
|
1860
|
+
* an unreadable keychain must not abort the wider teardown.
|
|
1861
|
+
*/
|
|
1862
|
+
static async deleteRecoveryMnemonic(): Promise<void> {
|
|
1863
|
+
if (isWebPlatform()) {
|
|
1864
|
+
return; // Identity storage is only available on native platforms
|
|
1865
|
+
}
|
|
1866
|
+
const store = await initSecureStore();
|
|
1867
|
+
await KeyManager._bestEffortDelete(store, RECOVERY_MNEMONIC_STORAGE_KEY, RECOVERY_MNEMONIC_KEYCHAIN_SERVICE);
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1776
1870
|
/**
|
|
1777
1871
|
* Check if a complete, parseable identity exists on this device.
|
|
1778
1872
|
*
|
|
@@ -2016,6 +2110,11 @@ export class KeyManager {
|
|
|
2016
2110
|
await KeyManager._bestEffortDelete(store, STORAGE_KEYS.PRIVATE_KEY);
|
|
2017
2111
|
await KeyManager._bestEffortDelete(store, STORAGE_KEYS.PUBLIC_KEY);
|
|
2018
2112
|
|
|
2113
|
+
// Always drop the stored recovery mnemonic — it is scoped to the identity
|
|
2114
|
+
// being deleted, so a leftover would let Settings reveal a stale phrase for
|
|
2115
|
+
// an identity that no longer exists (or a DIFFERENT one after re-onboarding).
|
|
2116
|
+
await KeyManager.deleteRecoveryMnemonic();
|
|
2117
|
+
|
|
2019
2118
|
// Also clear backups + the shared slot on force deletion, so a deleted
|
|
2020
2119
|
// identity cannot be resurrected from any recovery source.
|
|
2021
2120
|
if (force) {
|
package/src/index.ts
CHANGED
|
@@ -34,7 +34,13 @@ export type { AuthRefreshReason, AuthRefreshHandler } from './HttpService';
|
|
|
34
34
|
// ---------------------------------------------------------------------------
|
|
35
35
|
// Authentication
|
|
36
36
|
// ---------------------------------------------------------------------------
|
|
37
|
-
export {
|
|
37
|
+
export {
|
|
38
|
+
ServiceCredentialMismatchError,
|
|
39
|
+
} from './mixins/OxyServices.auth';
|
|
40
|
+
export {
|
|
41
|
+
getCommonsApprovalBlockingReason,
|
|
42
|
+
parseCommonsApprovalExpiresAt,
|
|
43
|
+
} from './utils/commonsApproval';
|
|
38
44
|
export type { ServiceTokenResponse } from './mixins/OxyServices.auth';
|
|
39
45
|
// "Sign in with Oxy" — handoff (Workstream C)
|
|
40
46
|
export type {
|
|
@@ -9,6 +9,10 @@ import { loginResultSchema, safeParseContract } from '@oxyhq/contracts';
|
|
|
9
9
|
import type { SessionLoginResponse } from '../models/session';
|
|
10
10
|
import type { OxyServicesBase } from '../OxyServices.base';
|
|
11
11
|
import type { PublicApplication } from './OxyServices.connectedApps';
|
|
12
|
+
export {
|
|
13
|
+
getCommonsApprovalBlockingReason,
|
|
14
|
+
parseCommonsApprovalExpiresAt,
|
|
15
|
+
} from '../utils/commonsApproval';
|
|
12
16
|
import { OxyAuthenticationError } from '../OxyServices.errors';
|
|
13
17
|
import { KeyManager } from '../crypto/keyManager';
|
|
14
18
|
import { SignatureService } from '../crypto/signatureService';
|
|
@@ -96,7 +100,7 @@ export interface CommonsSignInStatus {
|
|
|
96
100
|
*/
|
|
97
101
|
export interface CommonsApprovalInfo {
|
|
98
102
|
/** Sanitized, display-safe identity of the requesting application. */
|
|
99
|
-
application: PublicApplication;
|
|
103
|
+
application: PublicApplication | null;
|
|
100
104
|
/** OAuth scopes the application is requesting. */
|
|
101
105
|
scopes: string[];
|
|
102
106
|
/** The origin the session is bound to (the RP web origin), when applicable. */
|
|
@@ -109,8 +113,8 @@ export interface CommonsApprovalInfo {
|
|
|
109
113
|
* "not verified") by {@link OxyServicesAuthMixin.getCommonsApprovalInfo}.
|
|
110
114
|
*/
|
|
111
115
|
originVerified: boolean;
|
|
112
|
-
/** Server-authoritative expiry (epoch
|
|
113
|
-
expiresAt: number;
|
|
116
|
+
/** Server-authoritative expiry (epoch ms or ISO-8601 string from the API). */
|
|
117
|
+
expiresAt: number | string;
|
|
114
118
|
/** Session lifecycle status. */
|
|
115
119
|
status: string;
|
|
116
120
|
}
|
|
@@ -122,11 +126,11 @@ export interface CommonsApprovalInfo {
|
|
|
122
126
|
* into {@link CommonsApprovalInfo}.
|
|
123
127
|
*/
|
|
124
128
|
interface CommonsApprovalInfoResponse {
|
|
125
|
-
application: PublicApplication;
|
|
129
|
+
application: PublicApplication | null;
|
|
126
130
|
scopes: string[];
|
|
127
131
|
boundOrigin?: string;
|
|
128
132
|
originVerified?: unknown;
|
|
129
|
-
expiresAt: number;
|
|
133
|
+
expiresAt: number | string;
|
|
130
134
|
status: string;
|
|
131
135
|
}
|
|
132
136
|
|
|
@@ -218,6 +218,19 @@ export function OxyServicesIdentityBackupMixin<T extends typeof OxyServicesBase>
|
|
|
218
218
|
);
|
|
219
219
|
const payload = JSON.parse(new TextDecoder().decode(plaintext)) as BackupPayload;
|
|
220
220
|
|
|
221
|
+
if (!payload.privateKey || !payload.publicKey) {
|
|
222
|
+
throw new Error('Backup payload is missing key material');
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const derivedFromPhrase = await RecoveryPhraseService.derivePublicKeyFromPhrase(phrase);
|
|
226
|
+
const derivedFromPrivate = KeyManager.derivePublicKey(payload.privateKey);
|
|
227
|
+
const phrasePk = derivedFromPhrase.toLowerCase();
|
|
228
|
+
const payloadPk = payload.publicKey.toLowerCase();
|
|
229
|
+
const privatePk = derivedFromPrivate.toLowerCase();
|
|
230
|
+
if (phrasePk !== payloadPk || privatePk !== payloadPk) {
|
|
231
|
+
throw new Error('Backup payload does not match the recovery phrase');
|
|
232
|
+
}
|
|
233
|
+
|
|
221
234
|
// Persist the recovered key. Native-only; refuses to clobber a different
|
|
222
235
|
// identity unless overwrite — the IdentityAlreadyExistsError propagates.
|
|
223
236
|
return await KeyManager.importKeyPair(payload.privateKey, {
|
|
@@ -606,6 +606,10 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
606
606
|
const result = await this.makeRequest<PrivacySettings>('PATCH', `/privacy/${id}/privacy`, settings, {
|
|
607
607
|
cache: false,
|
|
608
608
|
});
|
|
609
|
+
this.clearCacheByPrefix('GET:/session/user/');
|
|
610
|
+
this.clearCacheByPrefix('GET:/users/me');
|
|
611
|
+
this.clearCacheByPrefix('GET:/profiles/username/');
|
|
612
|
+
this.clearCacheEntry(`GET:/users/${id}`);
|
|
609
613
|
this.clearCacheEntry(`GET:/privacy/${id}/privacy`);
|
|
610
614
|
return result;
|
|
611
615
|
} catch (error) {
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import { OxyServices } from '../../OxyServices';
|
|
16
16
|
import { KeyManager, IdentityAlreadyExistsError } from '../../crypto/keyManager';
|
|
17
17
|
import { RecoveryPhraseService } from '../../crypto/recoveryPhrase';
|
|
18
|
+
import { encryptAead, decryptAead } from '../../crypto/aead';
|
|
18
19
|
import type { EncryptedBackupEnvelope, BackupUploadRequest } from '@oxyhq/contracts';
|
|
19
20
|
|
|
20
21
|
const FIXED_PHRASE =
|
|
@@ -192,6 +193,62 @@ describe('encrypted identity backup mixin', () => {
|
|
|
192
193
|
await expect(oxy.restoreFromEncryptedBackup(FIXED_PHRASE)).rejects.toThrow();
|
|
193
194
|
});
|
|
194
195
|
|
|
196
|
+
it('rejects when decrypted payload publicKey does not match the recovery phrase', async () => {
|
|
197
|
+
fetchMock.mockResolvedValueOnce(plainResponse({ exists: true }));
|
|
198
|
+
await oxy.createEncryptedBackup(FIXED_PHRASE);
|
|
199
|
+
const uploaded = uploadBodyFromCall(0);
|
|
200
|
+
|
|
201
|
+
const { backupKey } = await RecoveryPhraseService.deriveBackupMaterial(FIXED_PHRASE);
|
|
202
|
+
const aad = new TextEncoder().encode(
|
|
203
|
+
JSON.stringify({ version: uploaded.version, publicKeyHint: uploaded.publicKeyHint }),
|
|
204
|
+
);
|
|
205
|
+
const fromHex = (hex: string): Uint8Array => {
|
|
206
|
+
const out = new Uint8Array(hex.length / 2);
|
|
207
|
+
for (let i = 0; i < out.length; i += 1) {
|
|
208
|
+
out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
|
209
|
+
}
|
|
210
|
+
return out;
|
|
211
|
+
};
|
|
212
|
+
const toHex = (bytes: Uint8Array): string =>
|
|
213
|
+
Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
|
214
|
+
|
|
215
|
+
const plaintext = decryptAead(
|
|
216
|
+
backupKey,
|
|
217
|
+
fromHex(uploaded.nonce),
|
|
218
|
+
fromHex(uploaded.ciphertext),
|
|
219
|
+
aad,
|
|
220
|
+
);
|
|
221
|
+
const payload = JSON.parse(new TextDecoder().decode(plaintext)) as {
|
|
222
|
+
privateKey: string;
|
|
223
|
+
publicKey: string;
|
|
224
|
+
createdAt: string;
|
|
225
|
+
};
|
|
226
|
+
payload.publicKey = `${payload.publicKey.slice(0, -1)}0`;
|
|
227
|
+
|
|
228
|
+
const { nonce, ciphertext } = encryptAead(
|
|
229
|
+
backupKey,
|
|
230
|
+
new TextEncoder().encode(JSON.stringify(payload)),
|
|
231
|
+
aad,
|
|
232
|
+
);
|
|
233
|
+
const tampered: EncryptedBackupEnvelope = {
|
|
234
|
+
version: uploaded.version,
|
|
235
|
+
algorithm: uploaded.algorithm,
|
|
236
|
+
kdfInfo: uploaded.kdfInfo,
|
|
237
|
+
nonce: toHex(nonce),
|
|
238
|
+
ciphertext: toHex(ciphertext),
|
|
239
|
+
publicKeyHint: uploaded.publicKeyHint,
|
|
240
|
+
createdAt: uploaded.createdAt,
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
const importSpy = jest.spyOn(KeyManager, 'importKeyPair').mockResolvedValue('x');
|
|
244
|
+
fetchMock.mockResolvedValueOnce(plainResponse(tampered));
|
|
245
|
+
|
|
246
|
+
await expect(oxy.restoreFromEncryptedBackup(FIXED_PHRASE)).rejects.toThrow(
|
|
247
|
+
/does not match the recovery phrase/,
|
|
248
|
+
);
|
|
249
|
+
expect(importSpy).not.toHaveBeenCalled();
|
|
250
|
+
});
|
|
251
|
+
|
|
195
252
|
it('propagates IdentityAlreadyExistsError UNCHANGED (so the caller can offer overwrite)', async () => {
|
|
196
253
|
fetchMock.mockResolvedValueOnce(plainResponse({ exists: true }));
|
|
197
254
|
await oxy.createEncryptedBackup(FIXED_PHRASE);
|
|
@@ -129,6 +129,7 @@ describe('privacy cache invalidation', () => {
|
|
|
129
129
|
|
|
130
130
|
it('invalidates the exact logical keys on block/restrict/settings writes', async () => {
|
|
131
131
|
const clearSpy = jest.spyOn(oxy, 'clearCacheEntry');
|
|
132
|
+
const clearPrefixSpy = jest.spyOn(oxy, 'clearCacheByPrefix');
|
|
132
133
|
|
|
133
134
|
fetchMock.mockResolvedValueOnce(jsonResponse({ message: 'ok' }));
|
|
134
135
|
await oxy.blockUser('u1');
|
|
@@ -141,7 +142,12 @@ describe('privacy cache invalidation', () => {
|
|
|
141
142
|
fetchMock.mockResolvedValueOnce(jsonResponse({ isPrivateAccount: true }));
|
|
142
143
|
await oxy.updatePrivacySettings({ isPrivateAccount: true }, 'me');
|
|
143
144
|
expect(clearSpy).toHaveBeenCalledWith('GET:/privacy/me/privacy');
|
|
145
|
+
expect(clearSpy).toHaveBeenCalledWith('GET:/users/me');
|
|
146
|
+
expect(clearPrefixSpy).toHaveBeenCalledWith('GET:/session/user/');
|
|
147
|
+
expect(clearPrefixSpy).toHaveBeenCalledWith('GET:/users/me');
|
|
148
|
+
expect(clearPrefixSpy).toHaveBeenCalledWith('GET:/profiles/username/');
|
|
144
149
|
|
|
145
150
|
clearSpy.mockRestore();
|
|
151
|
+
clearPrefixSpy.mockRestore();
|
|
146
152
|
});
|
|
147
153
|
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getCommonsApprovalBlockingReason,
|
|
3
|
+
parseCommonsApprovalExpiresAt,
|
|
4
|
+
} from '../commonsApproval';
|
|
5
|
+
|
|
6
|
+
describe('commonsApproval validation', () => {
|
|
7
|
+
const application = {
|
|
8
|
+
id: 'app1',
|
|
9
|
+
name: 'Mention',
|
|
10
|
+
type: 'first_party' as const,
|
|
11
|
+
isOfficial: true,
|
|
12
|
+
isInternal: false,
|
|
13
|
+
scopes: ['profile'],
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
it('blocks when the application is missing', () => {
|
|
17
|
+
expect(
|
|
18
|
+
getCommonsApprovalBlockingReason({
|
|
19
|
+
application: null,
|
|
20
|
+
status: 'pending',
|
|
21
|
+
expiresAt: Date.now() + 60_000,
|
|
22
|
+
}),
|
|
23
|
+
).toMatch(/could not be resolved/i);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('blocks non-pending sessions', () => {
|
|
27
|
+
expect(
|
|
28
|
+
getCommonsApprovalBlockingReason({
|
|
29
|
+
application,
|
|
30
|
+
status: 'expired',
|
|
31
|
+
expiresAt: Date.now() + 60_000,
|
|
32
|
+
}),
|
|
33
|
+
).toMatch(/invalid, already used, or expired/i);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('blocks expired pending sessions (ISO expiresAt)', () => {
|
|
37
|
+
expect(
|
|
38
|
+
getCommonsApprovalBlockingReason({
|
|
39
|
+
application,
|
|
40
|
+
status: 'pending',
|
|
41
|
+
expiresAt: new Date(Date.now() - 60_000).toISOString(),
|
|
42
|
+
}),
|
|
43
|
+
).toMatch(/expired/i);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('allows a pending, unexpired session', () => {
|
|
47
|
+
expect(
|
|
48
|
+
getCommonsApprovalBlockingReason({
|
|
49
|
+
application,
|
|
50
|
+
status: 'pending',
|
|
51
|
+
expiresAt: Date.now() + 60_000,
|
|
52
|
+
}),
|
|
53
|
+
).toBeNull();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('parses ISO expiresAt strings', () => {
|
|
57
|
+
const iso = '2026-07-19T12:00:00.000Z';
|
|
58
|
+
expect(parseCommonsApprovalExpiresAt(iso)).toBe(Date.parse(iso));
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { PublicApplication } from '../mixins/OxyServices.connectedApps';
|
|
2
|
+
|
|
3
|
+
export interface CommonsApprovalValidationInput {
|
|
4
|
+
application: PublicApplication | null;
|
|
5
|
+
status: string;
|
|
6
|
+
expiresAt: number | string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Returns a user-facing blocking reason when an approval payload must not be
|
|
11
|
+
* shown as actionable, or `null` when the request is still pending and valid.
|
|
12
|
+
*/
|
|
13
|
+
export function getCommonsApprovalBlockingReason(
|
|
14
|
+
info: CommonsApprovalValidationInput,
|
|
15
|
+
): string | null {
|
|
16
|
+
if (!info.application?.id) {
|
|
17
|
+
return 'The requesting application could not be resolved.';
|
|
18
|
+
}
|
|
19
|
+
if (info.status !== 'pending') {
|
|
20
|
+
return 'This sign-in request is invalid, already used, or expired.';
|
|
21
|
+
}
|
|
22
|
+
const expiresAtMs = parseCommonsApprovalExpiresAt(info.expiresAt);
|
|
23
|
+
if (expiresAtMs !== null && expiresAtMs < Date.now()) {
|
|
24
|
+
return 'This sign-in request has expired. Ask for a new QR code.';
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Normalize API `expiresAt` (number or ISO string) to epoch ms. */
|
|
30
|
+
export function parseCommonsApprovalExpiresAt(
|
|
31
|
+
expiresAt: CommonsApprovalValidationInput['expiresAt'],
|
|
32
|
+
): number | null {
|
|
33
|
+
if (typeof expiresAt === 'number' && Number.isFinite(expiresAt)) return expiresAt;
|
|
34
|
+
if (typeof expiresAt === 'string') {
|
|
35
|
+
const ms = Date.parse(expiresAt);
|
|
36
|
+
return Number.isFinite(ms) ? ms : null;
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|