@oxy.so/core 7.31.0 → 7.33.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 (33) hide show
  1. package/dist/cjs/api/identity.js +155 -24
  2. package/dist/cjs/crypto/identityMarker.js +15 -0
  3. package/dist/cjs/crypto/index.js +11 -2
  4. package/dist/cjs/crypto/keyManager.js +111 -16
  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 +315 -64
  8. package/dist/cjs/utils/keychainAccessGroups.js +111 -25
  9. package/dist/esm/api/identity.js +155 -24
  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 +113 -18
  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 +306 -63
  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/keyManager.d.ts +45 -1
  21. package/dist/types/crypto/keychainGroupMigration.d.ts +28 -6
  22. package/dist/types/crypto/organizationKeys.d.ts +4 -2
  23. package/dist/types/crypto/retiredKeys.d.ts +108 -17
  24. package/dist/types/utils/keychainAccessGroups.d.ts +41 -11
  25. package/package.json +1 -1
  26. package/src/api/identity.ts +198 -41
  27. package/src/crypto/identityMarker.ts +15 -0
  28. package/src/crypto/index.ts +12 -1
  29. package/src/crypto/keyManager.ts +127 -26
  30. package/src/crypto/keychainGroupMigration.ts +390 -185
  31. package/src/crypto/organizationKeys.ts +38 -6
  32. package/src/crypto/retiredKeys.ts +369 -65
  33. package/src/utils/keychainAccessGroups.ts +121 -24
@@ -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, unmirrorPersonalRetiredKeys } 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. */
@@ -751,12 +749,30 @@ export class KeyManager {
751
749
  * `signSocialReceive`) is fresh, so a cached key here would let a caller
752
750
  * compare a new seed against an old key.
753
751
  *
752
+ * iOS, in an app that holds the private key (Commons, the Peable wallet:
753
+ * keychain roles `holder` and `wallet`): DERIVED from the private key in the
754
+ * wallet group, never read from the public-key item, which sits in the shared
755
+ * group every Oxy app can write (R5-1); `null` when that app holds no private
756
+ * key. Every other iOS app reads the public-key item: it holds nothing it
757
+ * could derive from, and it never spends. Money code uses
758
+ * {@link getIdentityPublicKeyForMoney}.
759
+ *
754
760
  * @returns Shared public key or null if no shared identity exists
755
761
  */
756
762
  static async getSharedPublicKey() {
757
763
  if (isWebPlatform()) {
758
764
  return null;
759
765
  }
766
+ if (isIOS() && (await KeyManager._holdsSpendingKeys())) {
767
+ // Not cached: a rotation in Commons changes it under the wallet.
768
+ try {
769
+ const privateKey = await KeyManager.getSharedPrivateKey();
770
+ return privateKey ? KeyManager.derivePublicKey(privateKey) : null;
771
+ }
772
+ catch {
773
+ return null;
774
+ }
775
+ }
760
776
  // Return cached value if available (iOS only; see above).
761
777
  if (!isAndroid() && KeyManager.cachedSharedPublicKey !== null) {
762
778
  return KeyManager.cachedSharedPublicKey;
@@ -789,6 +805,68 @@ export class KeyManager {
789
805
  return null;
790
806
  }
791
807
  }
808
+ /**
809
+ * Whether this iOS app holds spending keys (keychain role `holder` or
810
+ * `wallet`), so a public key it uses for money must be derived from them.
811
+ * Throws while the keychain cannot say. @internal
812
+ */
813
+ static async _holdsSpendingKeys() {
814
+ const access = await resolveKeychainAccess();
815
+ return access.mode === 'grouped' && access.role !== 'member';
816
+ }
817
+ /**
818
+ * THE public key of the identity a money holder (the Peable wallet, Commons)
819
+ * receives and spends with: the personal key, or with `opts.accountId` that
820
+ * organization's (ADR 0061 D6). It is the public half of exactly the private
821
+ * key {@link deriveScopedSeed} and {@link signSocialReceive} use, so an
822
+ * address shown from it is one this app can spend (R5-1).
823
+ *
824
+ * - iOS (Commons, the Peable wallet): DERIVED from the private key in the
825
+ * restricted keychain groups (personal: the wallet group's slot, then
826
+ * Commons' primary; organization: its pair). Never the shared group's
827
+ * public-key item, which every Oxy app can write. `null` when no private
828
+ * key is held, and in every other iOS app.
829
+ * - Android: inside Commons derived from its own key; elsewhere Commons'
830
+ * identity host (`describe`), which answers from Commons' own signer
831
+ * store over signature-protected IPC.
832
+ * - Web: `null`.
833
+ *
834
+ * Compare the answer with the account's published `#key-1` before showing a
835
+ * receive address (Peable ADR 0002). Lowercase uncompressed (`04…`).
836
+ */
837
+ static async getIdentityPublicKeyForMoney(opts) {
838
+ if (isWebPlatform()) {
839
+ return null;
840
+ }
841
+ const accountId = opts?.accountId;
842
+ if (accountId !== undefined) {
843
+ if (!isOrganizationAccountId(accountId))
844
+ return null;
845
+ if (isAndroid() && !KeyManager.identitySignerStore) {
846
+ const bridge = await loadCommonsIdentityBridge();
847
+ const description = (await bridge?.describe(accountId)) ?? null;
848
+ return description?.accountId === accountId && description.publicKey
849
+ ? KeyManager.canonicalPublicKey(description.publicKey)
850
+ : null;
851
+ }
852
+ const organizationKey = await getOrganizationPrivateKey(accountId);
853
+ return organizationKey ? KeyManager.derivePublicKey(organizationKey) : null;
854
+ }
855
+ if (isAndroid()) {
856
+ if (KeyManager.identitySignerStore) {
857
+ const local = await KeyManager.getPrivateKey();
858
+ return local ? KeyManager.derivePublicKey(local) : null;
859
+ }
860
+ const bridge = await loadCommonsIdentityBridge();
861
+ const publicKey = (await bridge?.describe())?.publicKey ?? null;
862
+ return publicKey ? KeyManager.canonicalPublicKey(publicKey) : null;
863
+ }
864
+ if (!isIOS()) {
865
+ return null;
866
+ }
867
+ const privateKey = (await KeyManager.getSharedPrivateKey()) ?? (await KeyManager.getPrivateKey());
868
+ return privateKey ? KeyManager.derivePublicKey(privateKey) : null;
869
+ }
792
870
  /**
793
871
  * The shared private key, where this app may hold it: iOS only.
794
872
  *
@@ -2050,21 +2128,28 @@ export class KeyManager {
2050
2128
  if (force) {
2051
2129
  await KeyManager._bestEffortDeleteBackupsAllGenerations(store);
2052
2130
  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
- }
2131
+ // The retired ring is NOT cleared (R4-4): it is money under the person's
2132
+ // previous keys, and "Start over" or a forced delete must not cost it.
2133
+ // Every personal entry is bound to its owner's account id (R5-2), so the
2134
+ // same person signing in again finds it, and a different person on this
2135
+ // phone never lists, derives from or signs with it. Only deleting the Oxy
2136
+ // ACCOUNT clears it, explicitly (`forgetRetiredIdentityKeys`).
2060
2137
  }
2061
2138
  // The device backup goes on EVERY delete, forced or not: it is restored
2062
2139
  // silently, so leaving it would bring a deleted identity back on the next
2063
2140
  // launch. Best-effort, like the other recovery sources.
2064
2141
  await KeyManager._clearDeviceBackup();
2065
2142
  // Same for the Android signer store: Commons' identity host must stop
2066
- // answering for a deleted identity, forced or not.
2143
+ // answering for a deleted identity, forced or not — its personal retired
2144
+ // keys included (R5-2): they stay in Commons' own ring, bound to their
2145
+ // owner, but the host must not hand them to the next person on the phone.
2067
2146
  await KeyManager._clearIdentitySigner();
2147
+ try {
2148
+ await unmirrorPersonalRetiredKeys();
2149
+ }
2150
+ catch (error) {
2151
+ logger.warn('Failed to withdraw the personal retired keys from the identity host', { component: 'KeyManager' }, error);
2152
+ }
2068
2153
  // Clear the marker AFTER key deletion succeeds — a marker must never outlive
2069
2154
  // its identity (a leftover marker would route a truly-absent device to
2070
2155
  // `recovery` instead of `welcome`).
@@ -2593,11 +2678,16 @@ export class KeyManager {
2593
2678
  * (personal, or `accountId`'s) whose public key it names: the current key
2594
2679
  * when it matches, else a RETIRED key Commons kept after a rotation or
2595
2680
  * replacement (`./retiredKeys`, ADR 0061 D7 as amended). Spending only: a
2596
- * retired key never signs in. Never falls back to another key.
2681
+ * retired key never signs in. Never falls back to another key. A PERSONAL
2682
+ * retired key is reached only with `opts.ownerAccountId`, the signed-in
2683
+ * person's account id, and only when the ring keeps it for that account
2684
+ * (R5-2); on Android outside Commons the identity host holds only the
2685
+ * personal keys of the person Commons is signed in as.
2597
2686
  *
2598
2687
  * @param info Context/domain-binding label (distinct labels → independent seeds).
2599
2688
  * @param opts.accountId An organization's account id, for its key.
2600
2689
  * @param opts.publicKey Selects one key of that account by its public key.
2690
+ * @param opts.ownerAccountId The person's account id, for a personal retired key.
2601
2691
  * @returns 32 bytes of derived keying material, or `null` on web / when no
2602
2692
  * identity key is available to this app.
2603
2693
  */
@@ -2614,7 +2704,7 @@ export class KeyManager {
2614
2704
  const seed = (await bridge?.deriveRetiredScopedSeed?.(info, selected, opts.accountId)) ?? null;
2615
2705
  return seed ? hexToBytes(seed) : null;
2616
2706
  }
2617
- const selected = await KeyManager._selectedPrivateKey(opts.publicKey, opts.accountId);
2707
+ const selected = await KeyManager._selectedPrivateKey({ ...opts, publicKey: opts.publicKey });
2618
2708
  return selected ? deriveScopedSeedFromKey(selected, info) : null;
2619
2709
  }
2620
2710
  if (opts?.accountId !== undefined) {
@@ -2682,7 +2772,7 @@ export class KeyManager {
2682
2772
  return ((await bridge?.signRetiredSocialReceive?.(index, digest, selected, opts.accountId)) ??
2683
2773
  null);
2684
2774
  }
2685
- const selected = await KeyManager._selectedPrivateKey(opts.publicKey, opts.accountId);
2775
+ const selected = await KeyManager._selectedPrivateKey({ ...opts, publicKey: opts.publicKey });
2686
2776
  return selected ? signSocialReceiveDigest(selected, index, digest) : null;
2687
2777
  }
2688
2778
  const accountId = opts?.accountId;
@@ -2721,7 +2811,8 @@ export class KeyManager {
2721
2811
  static _selectorKey(publicKey) {
2722
2812
  return KeyManager.isValidPublicKey(publicKey) ? KeyManager.canonicalPublicKey(publicKey) : null;
2723
2813
  }
2724
- static async _selectedPrivateKey(publicKey, accountId) {
2814
+ static async _selectedPrivateKey(opts) {
2815
+ const { publicKey, accountId, ownerAccountId } = opts;
2725
2816
  if (retiredKeyScope({ accountId }) === null || !KeyManager.isValidPublicKey(publicKey)) {
2726
2817
  return null;
2727
2818
  }
@@ -2734,7 +2825,11 @@ export class KeyManager {
2734
2825
  if (current && KeyManager.canonicalPublicKey(KeyManager.derivePublicKey(current)) === wanted) {
2735
2826
  return current;
2736
2827
  }
2737
- return getRetiredPrivateKey(wanted, accountId !== undefined ? { accountId } : undefined);
2828
+ return getRetiredPrivateKey(wanted, accountId !== undefined
2829
+ ? { accountId }
2830
+ : ownerAccountId !== undefined
2831
+ ? { ownerAccountId }
2832
+ : undefined);
2738
2833
  }
2739
2834
  /**
2740
2835
  * Get a shortened version of the public key for display