@oxy.so/core 7.31.0 → 7.32.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 (32) hide show
  1. package/dist/cjs/api/identity.js +146 -23
  2. package/dist/cjs/crypto/identityMarker.js +15 -0
  3. package/dist/cjs/crypto/index.js +7 -2
  4. package/dist/cjs/crypto/keyManager.js +7 -10
  5. package/dist/cjs/crypto/keychainGroupMigration.js +327 -160
  6. package/dist/cjs/crypto/organizationKeys.js +33 -5
  7. package/dist/cjs/crypto/retiredKeys.js +80 -18
  8. package/dist/cjs/utils/keychainAccessGroups.js +111 -25
  9. package/dist/esm/api/identity.js +146 -23
  10. package/dist/esm/crypto/identityMarker.js +14 -0
  11. package/dist/esm/crypto/index.js +3 -3
  12. package/dist/esm/crypto/keyManager.js +9 -12
  13. package/dist/esm/crypto/keychainGroupMigration.js +327 -162
  14. package/dist/esm/crypto/organizationKeys.js +34 -6
  15. package/dist/esm/crypto/retiredKeys.js +76 -17
  16. package/dist/esm/utils/keychainAccessGroups.js +107 -24
  17. package/dist/types/api/identity.d.ts +36 -0
  18. package/dist/types/crypto/identityMarker.d.ts +8 -0
  19. package/dist/types/crypto/index.d.ts +3 -3
  20. package/dist/types/crypto/keychainGroupMigration.d.ts +28 -6
  21. package/dist/types/crypto/organizationKeys.d.ts +4 -2
  22. package/dist/types/crypto/retiredKeys.d.ts +30 -4
  23. package/dist/types/utils/keychainAccessGroups.d.ts +41 -11
  24. package/package.json +1 -1
  25. package/src/api/identity.ts +185 -40
  26. package/src/crypto/identityMarker.ts +15 -0
  27. package/src/crypto/index.ts +8 -1
  28. package/src/crypto/keyManager.ts +9 -15
  29. package/src/crypto/keychainGroupMigration.ts +390 -185
  30. package/src/crypto/organizationKeys.ts +38 -6
  31. package/src/crypto/retiredKeys.ts +87 -17
  32. package/src/utils/keychainAccessGroups.ts +121 -24
@@ -21,6 +21,7 @@ import { checkContract, IDENTITY_PROOF_ACTIONS, identityLinkCreateResponseCheck,
21
21
  import { identityKeyHistoryCheck } from '@oxy.so/contracts/client';
22
22
  import { isAndroid, isWeb } from '../utils/platform.js';
23
23
  import { logger } from '../logger/index.js';
24
+ import { extractErrorStatus } from '../utils/errorUtils.js';
24
25
  /** Short-TTL read cache for public identity reads. */
25
26
  const SHORT_TTL = 60 * 1000;
26
27
  /**
@@ -316,10 +317,31 @@ export class IdentityApi {
316
317
  }
317
318
  }
318
319
  }
320
+ // 8. The rotation deleted the encrypted backup (it was sealed under the
321
+ // replaced phrase). Seal a new one under the new phrase, carrying the
322
+ // retired ring — now with the key just replaced — so the coins under it
323
+ // survive a lost phone as long as the NEW phrase does (R4-4). Always,
324
+ // not only when one existed: after a rotation it is the only copy of the
325
+ // replaced key off this phone. Best-effort: the rotation already succeeded.
326
+ let backupRefreshed;
327
+ if (!isWeb() && !localPersistFailed) {
328
+ try {
329
+ await this.backup.create(pending.phrase);
330
+ backupRefreshed = true;
331
+ }
332
+ catch (backupError) {
333
+ backupRefreshed = false;
334
+ logger.warn('Key rotated, but the encrypted backup could not be re-created under the new phrase.', { component: 'oxy.identity', method: 'rotateKey' }, backupError);
335
+ }
336
+ }
319
337
  this.invalidate(userId);
320
- return localPersistFailed
321
- ? { newPublicKey, newPhrase: pending.phrase, words: pending.words, localPersistFailed: true }
322
- : { newPublicKey, newPhrase: pending.phrase, words: pending.words };
338
+ return {
339
+ newPublicKey,
340
+ newPhrase: pending.phrase,
341
+ words: pending.words,
342
+ ...(localPersistFailed ? { localPersistFailed: true } : {}),
343
+ ...(backupRefreshed === undefined ? {} : { backupRefreshed }),
344
+ };
323
345
  }
324
346
  /**
325
347
  * The signed-in user's signed, open-format data-export bundle
@@ -508,6 +530,10 @@ const BACKUP_ALGORITHM = 'xchacha20poly1305';
508
530
  * to recognise WHICH identity a backup belongs to, but only a prefix.
509
531
  */
510
532
  const PUBLIC_KEY_HINT_LENGTH = 16;
533
+ /** Whether a request error is the restore lookup's constant-shape 404. */
534
+ function isNotFound(error) {
535
+ return extractErrorStatus(error) === 404;
536
+ }
511
537
  /** Encode bytes as lowercase hex (cross-platform, no Buffer dependency). */
512
538
  function toHex(bytes) {
513
539
  let out = '';
@@ -567,7 +593,13 @@ export class IdentityBackupApi {
567
593
  const publicKey = KeyManager.derivePublicKey(privateKey);
568
594
  const createdAt = new Date().toISOString();
569
595
  const publicKeyHint = publicKey.slice(0, PUBLIC_KEY_HINT_LENGTH);
570
- const payload = { privateKey, publicKey, createdAt };
596
+ const retiredKeys = await this.retiredKeysToCarry(publicKey);
597
+ const payload = {
598
+ privateKey,
599
+ publicKey,
600
+ createdAt,
601
+ ...(retiredKeys.length > 0 ? { retiredKeys } : {}),
602
+ };
571
603
  const plaintext = new TextEncoder().encode(JSON.stringify(payload));
572
604
  const aad = buildBackupAad(BACKUP_ENVELOPE_VERSION, publicKeyHint);
573
605
  const { nonce, ciphertext } = encryptAead(backupKey, plaintext, aad);
@@ -585,6 +617,107 @@ export class IdentityBackupApi {
585
617
  cache: false,
586
618
  });
587
619
  }
620
+ /**
621
+ * The retired ring this device holds for the person, limited to keys Oxy's
622
+ * key history names as THIS account's retired keys (the ring's iOS group is
623
+ * writable by the wallet app too, and a previous person's ring can outlive a
624
+ * "Start over"). Empty where no ring is held (web, any app but Commons).
625
+ * Throws when the history cannot be read while a ring exists, so a backup
626
+ * never silently drops keys it should carry.
627
+ */
628
+ async retiredKeysToCarry(currentPublicKey) {
629
+ if (isWeb())
630
+ return [];
631
+ const { exportRetiredIdentityKeys } = await import('../crypto/index.js');
632
+ let held;
633
+ try {
634
+ held = await exportRetiredIdentityKeys();
635
+ }
636
+ catch {
637
+ return []; // Not Commons: this app holds no ring to carry.
638
+ }
639
+ if (held.length === 0)
640
+ return [];
641
+ const history = await this.ctx.request('GET', '/identity/key-history', undefined, {
642
+ cache: false,
643
+ });
644
+ const parsed = checkContract(identityKeyHistoryCheck, history);
645
+ if (!parsed)
646
+ throw new Error('identity/key-history returned an unexpected response shape');
647
+ const retired = new Set(parsed.retired.map((entry) => entry.publicKey.toLowerCase()));
648
+ const current = currentPublicKey.toLowerCase();
649
+ return held.filter((key) => key.publicKey.toLowerCase() !== current && retired.has(key.publicKey.toLowerCase()));
650
+ }
651
+ /**
652
+ * Fetch and open the backup the phrase locates, checking it is the phrase's
653
+ * own identity. With `missingIsNull`, `null` when there is none (404);
654
+ * otherwise the request's own error propagates.
655
+ */
656
+ async open(phrase, missingIsNull = false) {
657
+ const { KeyManager, RecoveryPhraseService, decryptAead } = await import('../crypto/index.js');
658
+ const { backupKey, lookupId } = await RecoveryPhraseService.deriveBackupMaterial(phrase);
659
+ let envelope;
660
+ try {
661
+ envelope = await this.ctx.request('GET', `/identity/backup/${encodeURIComponent(lookupId)}`, undefined, { cache: false });
662
+ }
663
+ catch (error) {
664
+ if (missingIsNull && isNotFound(error))
665
+ return null;
666
+ throw error;
667
+ }
668
+ if (envelope.algorithm !== BACKUP_ALGORITHM) {
669
+ throw new Error(`Unsupported backup algorithm: ${envelope.algorithm}`);
670
+ }
671
+ const aad = buildBackupAad(envelope.version, envelope.publicKeyHint);
672
+ const plaintext = decryptAead(backupKey, fromHex(envelope.nonce), fromHex(envelope.ciphertext), aad);
673
+ const payload = JSON.parse(new TextDecoder().decode(plaintext));
674
+ if (!payload.privateKey || !payload.publicKey) {
675
+ throw new Error('Backup payload is missing key material');
676
+ }
677
+ const phrasePk = (await RecoveryPhraseService.derivePublicKeyFromPhrase(phrase)).toLowerCase();
678
+ const payloadPk = payload.publicKey.toLowerCase();
679
+ const privatePk = KeyManager.derivePublicKey(payload.privateKey).toLowerCase();
680
+ if (phrasePk !== payloadPk || privatePk !== payloadPk) {
681
+ throw new Error('Backup payload does not match the recovery phrase');
682
+ }
683
+ return payload;
684
+ }
685
+ /**
686
+ * Keep every retired key a backup carries in this device's ring. Each pair
687
+ * must be healthy and must not be the current key; the envelope itself is
688
+ * authenticated by the phrase. Commons only (the ring refuses elsewhere).
689
+ * Resolves how many keys are now held; a key that fails is skipped.
690
+ */
691
+ async keepCarriedRetiredKeys(payload) {
692
+ const { KeyManager, retireIdentityKey } = await import('../crypto/index.js');
693
+ let kept = 0;
694
+ for (const key of Array.isArray(payload.retiredKeys) ? payload.retiredKeys : []) {
695
+ try {
696
+ if (typeof key?.privateKey !== 'string' || typeof key.publicKey !== 'string')
697
+ continue;
698
+ if (key.publicKey.toLowerCase() === payload.publicKey.toLowerCase())
699
+ continue;
700
+ if (KeyManager.derivePublicKey(key.privateKey).toLowerCase() !== key.publicKey.toLowerCase()) {
701
+ continue;
702
+ }
703
+ await retireIdentityKey({ privateKey: key.privateKey, publicKey: key.publicKey });
704
+ kept += 1;
705
+ }
706
+ catch (error) {
707
+ logger.warn('Could not keep a retired key from the encrypted backup', { component: 'oxy.identity', method: 'backup.restoreRetiredKeys' }, error);
708
+ }
709
+ }
710
+ return kept;
711
+ }
712
+ /**
713
+ * Restore ONLY the retired keys the phrase's backup carries into this
714
+ * device's ring (Commons, after a phrase import or a silent device-backup
715
+ * restore). Resolves how many were kept; 0 when there is no backup.
716
+ */
717
+ async restoreRetiredKeys(phrase) {
718
+ const payload = await this.open(phrase, true);
719
+ return payload ? this.keepCarriedRetiredKeys(payload) : 0;
720
+ }
588
721
  /**
589
722
  * Whether the signed-in user has a stored backup, plus its non-sensitive hint
590
723
  * and timestamp. Returns no ciphertext and no locator.
@@ -615,24 +748,14 @@ export class IdentityBackupApi {
615
748
  * fails authentication (tamper), or an existing identity blocks the import.
616
749
  */
617
750
  async restore(phrase, options) {
618
- const { KeyManager, RecoveryPhraseService, decryptAead } = await import('../crypto/index.js');
619
- const { backupKey, lookupId } = await RecoveryPhraseService.deriveBackupMaterial(phrase);
620
- const envelope = await this.ctx.request('GET', `/identity/backup/${encodeURIComponent(lookupId)}`, undefined, { cache: false });
621
- if (envelope.algorithm !== BACKUP_ALGORITHM) {
622
- throw new Error(`Unsupported backup algorithm: ${envelope.algorithm}`);
623
- }
624
- const aad = buildBackupAad(envelope.version, envelope.publicKeyHint);
625
- const plaintext = decryptAead(backupKey, fromHex(envelope.nonce), fromHex(envelope.ciphertext), aad);
626
- const payload = JSON.parse(new TextDecoder().decode(plaintext));
627
- if (!payload.privateKey || !payload.publicKey) {
628
- throw new Error('Backup payload is missing key material');
629
- }
630
- const phrasePk = (await RecoveryPhraseService.derivePublicKeyFromPhrase(phrase)).toLowerCase();
631
- const payloadPk = payload.publicKey.toLowerCase();
632
- const privatePk = KeyManager.derivePublicKey(payload.privateKey).toLowerCase();
633
- if (phrasePk !== payloadPk || privatePk !== payloadPk) {
634
- throw new Error('Backup payload does not match the recovery phrase');
635
- }
636
- return KeyManager.importKeyPair(payload.privateKey, { overwrite: options?.overwrite === true });
751
+ const { KeyManager } = await import('../crypto/index.js');
752
+ const payload = await this.open(phrase);
753
+ if (!payload)
754
+ throw new Error('No encrypted backup was found for this recovery phrase.');
755
+ const publicKey = await KeyManager.importKeyPair(payload.privateKey, {
756
+ overwrite: options?.overwrite === true,
757
+ });
758
+ await this.keepCarriedRetiredKeys(payload);
759
+ return publicKey;
637
760
  }
638
761
  }
@@ -145,6 +145,20 @@ export async function readIdentityMarker() {
145
145
  return null;
146
146
  }
147
147
  }
148
+ /**
149
+ * Read the marker, THROWING when the storage cannot answer (instead of the
150
+ * fail-open `null` of {@link readIdentityMarker}). For a caller that must not
151
+ * mistake "unreadable" for "never written": the iOS keychain migration, which
152
+ * adopts a pre-split secret only when this marker vouches for it, and records
153
+ * once that there is nothing to adopt.
154
+ */
155
+ export async function readIdentityMarkerStrict() {
156
+ const storage = await getStorage();
157
+ if (!storage) {
158
+ throw new Error('The identity marker storage is unavailable.');
159
+ }
160
+ return deserialize(await storage.getItem(IDENTITY_MARKER_STORAGE_KEY));
161
+ }
148
162
  /**
149
163
  * Write (create/replace) the marker. Returns `true` when it durably landed,
150
164
  * `false` when storage was unavailable or the write threw. Callers treat a
@@ -18,11 +18,11 @@ export { readIdentityMarker, updateIdentityMarker } from './identityMarker.js';
18
18
  // Organization keys on the device (docs/adr/0061-organization-identity-key.md D6)
19
19
  export { generateOrganizationKey, organizationKeyFromPhrase, storeOrganizationKey, getOrganizationPublicKey, getOrganizationPrivateKey, getOrganizationRecoveryPhrase, listOrganizationKeys, deleteOrganizationKey, syncOrganizationKeys, isOrganizationAccountId, isHealthyOrganizationKeyPair, } from './organizationKeys.js';
20
20
  // iOS keychain access groups (docs/identity/ios-keychain-access-groups.md, ADR 0062)
21
- export { KEYCHAIN_ACCESS_GROUPS, KeychainAccessUnavailableError, keychainGroupFor, mayHoldKeychainItem, resolveKeychainAccess, keychainAccessGroupValue, keychainReadPlan, keychainDeletePlan, keychainWriteOption, ownKeychainItemPlan, readThroughPlan, deleteThroughPlan, groupedKeychainStore, } from '../utils/keychainAccessGroups.js';
22
- export { ensureKeychainGroupsMigrated } from './keychainGroupMigration.js';
21
+ export { KEYCHAIN_ACCESS_GROUPS, KeychainAccessUnavailableError, keychainGroupFor, mayHoldKeychainItem, mayWriteKeychainItem, DEV_KEYCHAIN_GROUP_SUFFIX, resolveKeychainAccess, keychainAccessGroupValue, keychainReadPlan, keychainDeletePlan, keychainWriteOption, ownKeychainItemPlan, readThroughPlan, deleteThroughPlan, groupedKeychainStore, } from '../utils/keychainAccessGroups.js';
22
+ export { ensureKeychainGroupsMigrated, getPreSplitExposure, } from './keychainGroupMigration.js';
23
23
  // Retired identity keys: kept for spending after a rotation or replacement
24
24
  // (docs/adr/0061-organization-identity-key.md D7 as amended 2026-11-06)
25
- export { retireIdentityKey, listRetiredIdentityKeys, forgetRetiredIdentityKey, forgetRetiredIdentityKeys, syncRetiredKeys, retiredKeyFromPhrase, } from './retiredKeys.js';
25
+ export { retireIdentityKey, listRetiredIdentityKeys, forgetRetiredIdentityKey, forgetRetiredIdentityKeys, syncRetiredKeys, retiredKeyFromPhrase, retiredKeyFromPrivateKey, exportRetiredIdentityKeys, } from './retiredKeys.js';
26
26
  export { SignatureService } from './signatureService.js';
27
27
  export { RecoveryPhraseService, BACKUP_KDF_ENCRYPTION_INFO } from './recoveryPhrase.js';
28
28
  // Low-level primitives (encrypted backup, device transfer)
@@ -11,9 +11,9 @@ import { isDev, logger } from '../logger/index.js';
11
11
  import { deriveScopedSeedFromKey, signSocialReceiveDigest } from './identityDerivations.js';
12
12
  import { registerIdentitySignerStore } from './identitySigner.js';
13
13
  import { getOrganizationPrivateKey, isOrganizationAccountId } from './organizationKeys.js';
14
- import { forgetRetiredIdentityKeys, getRetiredPrivateKey, retiredKeyScope } from './retiredKeys.js';
14
+ import { getRetiredPrivateKey, retiredKeyScope } from './retiredKeys.js';
15
15
  import { groupedKeychainStore, resolveKeychainAccess, } from '../utils/keychainAccessGroups.js';
16
- import { ensureKeychainGroupsMigrated } from './keychainGroupMigration.js';
16
+ import { prepareKeychainAccess } from './keychainGroupMigration.js';
17
17
  import { RECOVERY_MNEMONIC_KEYCHAIN_SERVICE, RECOVERY_MNEMONIC_STORAGE_KEY, STORAGE_KEYS, V2_BACKUP_KEYCHAIN_SERVICE, V2_PRIMARY_KEYCHAIN_SERVICE, V2_STORAGE_KEYS, } from './keychainItems.js';
18
18
  import { clearIdentityMarker, readIdentityMarker, updateIdentityMarker, writeIdentityMarker, } from './identityMarker.js';
19
19
  import { DEVICE_BACKUP_VERSION, parseDeviceBackup, serializeDeviceBackup, } from './deviceBackup.js';
@@ -172,9 +172,7 @@ async function loadRawSecureStore() {
172
172
  async function keychainItemStore(item) {
173
173
  const raw = await loadRawSecureStore();
174
174
  const access = await resolveKeychainAccess();
175
- if (access.mode === 'grouped' && access.role === 'holder') {
176
- await ensureKeychainGroupsMigrated();
177
- }
175
+ await prepareKeychainAccess(access);
178
176
  return groupedKeychainStore(raw, access, item);
179
177
  }
180
178
  /** The store for the identity slots (primary, backup, legacy, phrase): Commons only on iOS. */
@@ -2050,13 +2048,12 @@ export class KeyManager {
2050
2048
  if (force) {
2051
2049
  await KeyManager._bestEffortDeleteBackupsAllGenerations(store);
2052
2050
  await KeyManager._clearSharedSlot(store);
2053
- // The person's retired keys go with the identity they belonged to.
2054
- try {
2055
- await forgetRetiredIdentityKeys();
2056
- }
2057
- catch (error) {
2058
- logger.warn('Failed to clear retired keys during delete', { component: 'KeyManager' }, error);
2059
- }
2051
+ // The retired ring is NOT cleared (R4-4): it is money under the person's
2052
+ // previous keys, and "Start over" or a forced delete must not cost it.
2053
+ // The same person restoring their identity finds it again; a different
2054
+ // identity on this phone cannot use it (each entry is checked against
2055
+ // that account's key history before use). Only deleting the Oxy ACCOUNT
2056
+ // clears it, explicitly (`forgetRetiredIdentityKeys`, Commons' flow).
2060
2057
  }
2061
2058
  // The device backup goes on EVERY delete, forced or not: it is restored
2062
2059
  // silently, so leaving it would bring a deleted identity back on the next