@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.
@@ -117,6 +117,24 @@ const V2_STORAGE_KEYS = {
117
117
  BACKUP_PUBLIC_KEY: 'oxy_identity_backup_public_key_v2',
118
118
  BACKUP_TIMESTAMP: 'oxy_identity_backup_timestamp_v2',
119
119
  };
120
+ /**
121
+ * Dedicated keychain slot for the recovery mnemonic (the 12-word phrase).
122
+ *
123
+ * Stored under its OWN keychain service — distinct from the v2 primary, backup,
124
+ * and shared slots — so it shares an AndroidKeyStore key with none of them
125
+ * (blast-radius isolation, same rationale as the v2 primary/backup split).
126
+ * Written `WHEN_UNLOCKED_THIS_DEVICE_ONLY` and NEVER exported off-device: it
127
+ * exists solely so the user can RE-READ their phrase from Settings on the SAME
128
+ * device that generated/imported it.
129
+ *
130
+ * This is convenience persistence, NOT a recovery mechanism — a keystore death
131
+ * wipes it alongside the keys, exactly like the private key itself. The user's
132
+ * written-down phrase remains the sole out-of-band recovery path. The mnemonic
133
+ * lives ONLY in this slot: it is never mirrored into the identity marker,
134
+ * {@link KeyManager.getIdentityStatus}, logs, or any exported bundle.
135
+ */
136
+ const RECOVERY_MNEMONIC_KEYCHAIN_SERVICE = 'oxy_identity_mnemonic';
137
+ const RECOVERY_MNEMONIC_STORAGE_KEY = 'oxy_identity_mnemonic_v1';
120
138
  /**
121
139
  * Advisory AsyncStorage fast-path flag: set once the v2 slots own the identity.
122
140
  * Re-derivable (its loss just re-runs the cheap slot check), so it lives in
@@ -1471,6 +1489,73 @@ export class KeyManager {
1471
1489
  throw new IdentityUnavailableError('Failed to read identity from secure storage.', error);
1472
1490
  }
1473
1491
  }
1492
+ /**
1493
+ * Persist the recovery mnemonic (the 12-word phrase) into its dedicated,
1494
+ * device-only keychain slot so the user can re-reveal it from Settings after
1495
+ * onboarding.
1496
+ *
1497
+ * Called best-effort at identity creation/import, where the phrase is already
1498
+ * in memory: a failure to persist it must NEVER fail the identity itself, so
1499
+ * callers deliberately swallow the thrown error (logging it). Storage errors
1500
+ * throw {@link IdentityUnavailableError} — same "cannot determine" semantics as
1501
+ * the other getters — so a caller MAY observe/log the failure.
1502
+ *
1503
+ * The mnemonic is stored ONLY here — never in the marker, `getIdentityStatus`,
1504
+ * logs, or any exported bundle.
1505
+ */
1506
+ static async storeRecoveryMnemonic(mnemonic) {
1507
+ if (isWebPlatform()) {
1508
+ return; // Identity storage is only available on native platforms
1509
+ }
1510
+ try {
1511
+ const store = await initSecureStore();
1512
+ await store.setItemAsync(RECOVERY_MNEMONIC_STORAGE_KEY, mnemonic, KeyManager._privateWriteOpts(store, RECOVERY_MNEMONIC_KEYCHAIN_SERVICE));
1513
+ }
1514
+ catch (error) {
1515
+ if (isDev()) {
1516
+ logger.warn('Failed to persist recovery mnemonic', { component: 'KeyManager' }, error);
1517
+ }
1518
+ throw new IdentityUnavailableError('Failed to persist recovery mnemonic.', error);
1519
+ }
1520
+ }
1521
+ /**
1522
+ * Read the stored recovery mnemonic for re-reveal in Settings.
1523
+ *
1524
+ * Returns the phrase, or `null` when a read SUCCEEDS and finds none — the
1525
+ * expected result for any identity created/imported before this feature
1526
+ * existed, since the phrase was never captured for those. THROWS
1527
+ * {@link IdentityUnavailableError} when storage is unreadable (keychain locked
1528
+ * / module load failure), matching {@link getPublicKey}'s contract — a thrown
1529
+ * read is never flattened to `null`, so a caller distinguishes "phrase was
1530
+ * never stored" from "keychain temporarily locked, retry".
1531
+ */
1532
+ static async getRecoveryMnemonic() {
1533
+ if (isWebPlatform()) {
1534
+ return null; // Identity storage is only available on native platforms
1535
+ }
1536
+ try {
1537
+ const store = await initSecureStore();
1538
+ return await store.getItemAsync(RECOVERY_MNEMONIC_STORAGE_KEY, KeyManager._slotOpts(RECOVERY_MNEMONIC_KEYCHAIN_SERVICE));
1539
+ }
1540
+ catch (error) {
1541
+ if (isDev()) {
1542
+ logger.warn('Failed to read recovery mnemonic', { component: 'KeyManager' }, error);
1543
+ }
1544
+ throw new IdentityUnavailableError('Failed to read recovery mnemonic from secure storage.', error);
1545
+ }
1546
+ }
1547
+ /**
1548
+ * Delete the stored recovery mnemonic. Best-effort: a delete failure is logged
1549
+ * and swallowed, never thrown — it runs inside the identity-deletion path where
1550
+ * an unreadable keychain must not abort the wider teardown.
1551
+ */
1552
+ static async deleteRecoveryMnemonic() {
1553
+ if (isWebPlatform()) {
1554
+ return; // Identity storage is only available on native platforms
1555
+ }
1556
+ const store = await initSecureStore();
1557
+ await KeyManager._bestEffortDelete(store, RECOVERY_MNEMONIC_STORAGE_KEY, RECOVERY_MNEMONIC_KEYCHAIN_SERVICE);
1558
+ }
1474
1559
  /**
1475
1560
  * Check if a complete, parseable identity exists on this device.
1476
1561
  *
@@ -1686,6 +1771,10 @@ export class KeyManager {
1686
1771
  await KeyManager._bestEffortDeleteV2Primary(store);
1687
1772
  await KeyManager._bestEffortDelete(store, STORAGE_KEYS.PRIVATE_KEY);
1688
1773
  await KeyManager._bestEffortDelete(store, STORAGE_KEYS.PUBLIC_KEY);
1774
+ // Always drop the stored recovery mnemonic — it is scoped to the identity
1775
+ // being deleted, so a leftover would let Settings reveal a stale phrase for
1776
+ // an identity that no longer exists (or a DIFFERENT one after re-onboarding).
1777
+ await KeyManager.deleteRecoveryMnemonic();
1689
1778
  // Also clear backups + the shared slot on force deletion, so a deleted
1690
1779
  // identity cannot be resurrected from any recovery source.
1691
1780
  if (force) {
package/dist/esm/index.js CHANGED
@@ -26,7 +26,8 @@ export { OXY_CLOUD_URL, oxyClient } from './OxyServices.js';
26
26
  // ---------------------------------------------------------------------------
27
27
  // Authentication
28
28
  // ---------------------------------------------------------------------------
29
- export { ServiceCredentialMismatchError } from './mixins/OxyServices.auth.js';
29
+ export { ServiceCredentialMismatchError, } from './mixins/OxyServices.auth.js';
30
+ export { getCommonsApprovalBlockingReason, parseCommonsApprovalExpiresAt, } from './utils/commonsApproval.js';
30
31
  export { OxyAppDataIdentifierError } from './mixins/OxyServices.appData.js';
31
32
  // ---------------------------------------------------------------------------
32
33
  // User identity and handles
@@ -1,4 +1,5 @@
1
1
  import { loginResultSchema, safeParseContract } from '@oxyhq/contracts';
2
+ export { getCommonsApprovalBlockingReason, parseCommonsApprovalExpiresAt, } from '../utils/commonsApproval.js';
2
3
  import { OxyAuthenticationError } from '../OxyServices.errors.js';
3
4
  import { KeyManager } from '../crypto/keyManager.js';
4
5
  import { SignatureService } from '../crypto/signatureService.js';
@@ -138,6 +138,17 @@ export function OxyServicesIdentityBackupMixin(Base) {
138
138
  const aad = buildBackupAad(envelope.version, envelope.publicKeyHint);
139
139
  const plaintext = decryptAead(backupKey, fromHex(envelope.nonce), fromHex(envelope.ciphertext), aad);
140
140
  const payload = JSON.parse(new TextDecoder().decode(plaintext));
141
+ if (!payload.privateKey || !payload.publicKey) {
142
+ throw new Error('Backup payload is missing key material');
143
+ }
144
+ const derivedFromPhrase = await RecoveryPhraseService.derivePublicKeyFromPhrase(phrase);
145
+ const derivedFromPrivate = KeyManager.derivePublicKey(payload.privateKey);
146
+ const phrasePk = derivedFromPhrase.toLowerCase();
147
+ const payloadPk = payload.publicKey.toLowerCase();
148
+ const privatePk = derivedFromPrivate.toLowerCase();
149
+ if (phrasePk !== payloadPk || privatePk !== payloadPk) {
150
+ throw new Error('Backup payload does not match the recovery phrase');
151
+ }
141
152
  // Persist the recovered key. Native-only; refuses to clobber a different
142
153
  // identity unless overwrite — the IdentityAlreadyExistsError propagates.
143
154
  return await KeyManager.importKeyPair(payload.privateKey, {
@@ -420,6 +420,10 @@ export function OxyServicesUserMixin(Base) {
420
420
  const result = await this.makeRequest('PATCH', `/privacy/${id}/privacy`, settings, {
421
421
  cache: false,
422
422
  });
423
+ this.clearCacheByPrefix('GET:/session/user/');
424
+ this.clearCacheByPrefix('GET:/users/me');
425
+ this.clearCacheByPrefix('GET:/profiles/username/');
426
+ this.clearCacheEntry(`GET:/users/${id}`);
423
427
  this.clearCacheEntry(`GET:/privacy/${id}/privacy`);
424
428
  return result;
425
429
  }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Returns a user-facing blocking reason when an approval payload must not be
3
+ * shown as actionable, or `null` when the request is still pending and valid.
4
+ */
5
+ export function getCommonsApprovalBlockingReason(info) {
6
+ if (!info.application?.id) {
7
+ return 'The requesting application could not be resolved.';
8
+ }
9
+ if (info.status !== 'pending') {
10
+ return 'This sign-in request is invalid, already used, or expired.';
11
+ }
12
+ const expiresAtMs = parseCommonsApprovalExpiresAt(info.expiresAt);
13
+ if (expiresAtMs !== null && expiresAtMs < Date.now()) {
14
+ return 'This sign-in request has expired. Ask for a new QR code.';
15
+ }
16
+ return null;
17
+ }
18
+ /** Normalize API `expiresAt` (number or ISO string) to epoch ms. */
19
+ export function parseCommonsApprovalExpiresAt(expiresAt) {
20
+ if (typeof expiresAt === 'number' && Number.isFinite(expiresAt))
21
+ return expiresAt;
22
+ if (typeof expiresAt === 'string') {
23
+ const ms = Date.parse(expiresAt);
24
+ return Number.isFinite(ms) ? ms : null;
25
+ }
26
+ return null;
27
+ }