@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.
@@ -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
  *
@@ -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 milliseconds). */
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/core",
3
- "version": "12.9.0",
3
+ "version": "12.10.0",
4
4
  "description": "OxyHQ SDK Foundation — API client, authentication, cryptographic identity, and shared utilities",
5
5
  "main": "dist/cjs/index.js",
6
6
  "module": "dist/esm/index.js",
@@ -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
+ });
@@ -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 { ServiceCredentialMismatchError } from './mixins/OxyServices.auth';
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 milliseconds). */
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
+ }