@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
@@ -32,13 +32,13 @@ import { isDev, logger } from '../logger';
32
32
  import { deriveScopedSeedFromKey, signSocialReceiveDigest } from './identityDerivations';
33
33
  import { type IdentitySignerStore, registerIdentitySignerStore } from './identitySigner';
34
34
  import { getOrganizationPrivateKey, isOrganizationAccountId } from './organizationKeys';
35
- import { forgetRetiredIdentityKeys, getRetiredPrivateKey, retiredKeyScope } from './retiredKeys';
35
+ import { getRetiredPrivateKey, retiredKeyScope, unmirrorPersonalRetiredKeys } from './retiredKeys';
36
36
  import {
37
37
  type KeychainItemClass,
38
38
  groupedKeychainStore,
39
39
  resolveKeychainAccess,
40
40
  } from '../utils/keychainAccessGroups';
41
- import { ensureKeychainGroupsMigrated } from './keychainGroupMigration';
41
+ import { prepareKeychainAccess } from './keychainGroupMigration';
42
42
  import {
43
43
  RECOVERY_MNEMONIC_KEYCHAIN_SERVICE,
44
44
  RECOVERY_MNEMONIC_STORAGE_KEY,
@@ -300,9 +300,7 @@ async function loadRawSecureStore(): Promise<ExpoSecureStoreLike> {
300
300
  async function keychainItemStore(item: KeychainItemClass): Promise<ExpoSecureStoreLike> {
301
301
  const raw = await loadRawSecureStore();
302
302
  const access = await resolveKeychainAccess();
303
- if (access.mode === 'grouped' && access.role === 'holder') {
304
- await ensureKeychainGroupsMigrated();
305
- }
303
+ await prepareKeychainAccess(access);
306
304
  return groupedKeychainStore(raw, access, item);
307
305
  }
308
306
 
@@ -1072,6 +1070,14 @@ export class KeyManager {
1072
1070
  * `signSocialReceive`) is fresh, so a cached key here would let a caller
1073
1071
  * compare a new seed against an old key.
1074
1072
  *
1073
+ * iOS, in an app that holds the private key (Commons, the Peable wallet:
1074
+ * keychain roles `holder` and `wallet`): DERIVED from the private key in the
1075
+ * wallet group, never read from the public-key item, which sits in the shared
1076
+ * group every Oxy app can write (R5-1); `null` when that app holds no private
1077
+ * key. Every other iOS app reads the public-key item: it holds nothing it
1078
+ * could derive from, and it never spends. Money code uses
1079
+ * {@link getIdentityPublicKeyForMoney}.
1080
+ *
1075
1081
  * @returns Shared public key or null if no shared identity exists
1076
1082
  */
1077
1083
  static async getSharedPublicKey(): Promise<string | null> {
@@ -1079,6 +1085,16 @@ export class KeyManager {
1079
1085
  return null;
1080
1086
  }
1081
1087
 
1088
+ if (isIOS() && (await KeyManager._holdsSpendingKeys())) {
1089
+ // Not cached: a rotation in Commons changes it under the wallet.
1090
+ try {
1091
+ const privateKey = await KeyManager.getSharedPrivateKey();
1092
+ return privateKey ? KeyManager.derivePublicKey(privateKey) : null;
1093
+ } catch {
1094
+ return null;
1095
+ }
1096
+ }
1097
+
1082
1098
  // Return cached value if available (iOS only; see above).
1083
1099
  if (!isAndroid() && KeyManager.cachedSharedPublicKey !== null) {
1084
1100
  return KeyManager.cachedSharedPublicKey;
@@ -1113,6 +1129,70 @@ export class KeyManager {
1113
1129
  }
1114
1130
  }
1115
1131
 
1132
+ /**
1133
+ * Whether this iOS app holds spending keys (keychain role `holder` or
1134
+ * `wallet`), so a public key it uses for money must be derived from them.
1135
+ * Throws while the keychain cannot say. @internal
1136
+ */
1137
+ private static async _holdsSpendingKeys(): Promise<boolean> {
1138
+ const access = await resolveKeychainAccess();
1139
+ return access.mode === 'grouped' && access.role !== 'member';
1140
+ }
1141
+
1142
+ /**
1143
+ * THE public key of the identity a money holder (the Peable wallet, Commons)
1144
+ * receives and spends with: the personal key, or with `opts.accountId` that
1145
+ * organization's (ADR 0061 D6). It is the public half of exactly the private
1146
+ * key {@link deriveScopedSeed} and {@link signSocialReceive} use, so an
1147
+ * address shown from it is one this app can spend (R5-1).
1148
+ *
1149
+ * - iOS (Commons, the Peable wallet): DERIVED from the private key in the
1150
+ * restricted keychain groups (personal: the wallet group's slot, then
1151
+ * Commons' primary; organization: its pair). Never the shared group's
1152
+ * public-key item, which every Oxy app can write. `null` when no private
1153
+ * key is held, and in every other iOS app.
1154
+ * - Android: inside Commons derived from its own key; elsewhere Commons'
1155
+ * identity host (`describe`), which answers from Commons' own signer
1156
+ * store over signature-protected IPC.
1157
+ * - Web: `null`.
1158
+ *
1159
+ * Compare the answer with the account's published `#key-1` before showing a
1160
+ * receive address (Peable ADR 0002). Lowercase uncompressed (`04…`).
1161
+ */
1162
+ static async getIdentityPublicKeyForMoney(opts?: { accountId?: string }): Promise<string | null> {
1163
+ if (isWebPlatform()) {
1164
+ return null;
1165
+ }
1166
+ const accountId = opts?.accountId;
1167
+ if (accountId !== undefined) {
1168
+ if (!isOrganizationAccountId(accountId)) return null;
1169
+ if (isAndroid() && !KeyManager.identitySignerStore) {
1170
+ const bridge = await loadCommonsIdentityBridge();
1171
+ const description = (await bridge?.describe(accountId)) ?? null;
1172
+ return description?.accountId === accountId && description.publicKey
1173
+ ? KeyManager.canonicalPublicKey(description.publicKey)
1174
+ : null;
1175
+ }
1176
+ const organizationKey = await getOrganizationPrivateKey(accountId);
1177
+ return organizationKey ? KeyManager.derivePublicKey(organizationKey) : null;
1178
+ }
1179
+ if (isAndroid()) {
1180
+ if (KeyManager.identitySignerStore) {
1181
+ const local = await KeyManager.getPrivateKey();
1182
+ return local ? KeyManager.derivePublicKey(local) : null;
1183
+ }
1184
+ const bridge = await loadCommonsIdentityBridge();
1185
+ const publicKey = (await bridge?.describe())?.publicKey ?? null;
1186
+ return publicKey ? KeyManager.canonicalPublicKey(publicKey) : null;
1187
+ }
1188
+ if (!isIOS()) {
1189
+ return null;
1190
+ }
1191
+ const privateKey =
1192
+ (await KeyManager.getSharedPrivateKey()) ?? (await KeyManager.getPrivateKey());
1193
+ return privateKey ? KeyManager.derivePublicKey(privateKey) : null;
1194
+ }
1195
+
1116
1196
  /**
1117
1197
  * The shared private key, where this app may hold it: iOS only.
1118
1198
  *
@@ -2612,16 +2692,12 @@ export class KeyManager {
2612
2692
  if (force) {
2613
2693
  await KeyManager._bestEffortDeleteBackupsAllGenerations(store);
2614
2694
  await KeyManager._clearSharedSlot(store);
2615
- // The person's retired keys go with the identity they belonged to.
2616
- try {
2617
- await forgetRetiredIdentityKeys();
2618
- } catch (error) {
2619
- logger.warn(
2620
- 'Failed to clear retired keys during delete',
2621
- { component: 'KeyManager' },
2622
- error,
2623
- );
2624
- }
2695
+ // The retired ring is NOT cleared (R4-4): it is money under the person's
2696
+ // previous keys, and "Start over" or a forced delete must not cost it.
2697
+ // Every personal entry is bound to its owner's account id (R5-2), so the
2698
+ // same person signing in again finds it, and a different person on this
2699
+ // phone never lists, derives from or signs with it. Only deleting the Oxy
2700
+ // ACCOUNT clears it, explicitly (`forgetRetiredIdentityKeys`).
2625
2701
  }
2626
2702
 
2627
2703
  // The device backup goes on EVERY delete, forced or not: it is restored
@@ -2630,8 +2706,19 @@ export class KeyManager {
2630
2706
  await KeyManager._clearDeviceBackup();
2631
2707
 
2632
2708
  // Same for the Android signer store: Commons' identity host must stop
2633
- // answering for a deleted identity, forced or not.
2709
+ // answering for a deleted identity, forced or not — its personal retired
2710
+ // keys included (R5-2): they stay in Commons' own ring, bound to their
2711
+ // owner, but the host must not hand them to the next person on the phone.
2634
2712
  await KeyManager._clearIdentitySigner();
2713
+ try {
2714
+ await unmirrorPersonalRetiredKeys();
2715
+ } catch (error) {
2716
+ logger.warn(
2717
+ 'Failed to withdraw the personal retired keys from the identity host',
2718
+ { component: 'KeyManager' },
2719
+ error,
2720
+ );
2721
+ }
2635
2722
 
2636
2723
  // Clear the marker AFTER key deletion succeeds — a marker must never outlive
2637
2724
  // its identity (a leftover marker would route a truly-absent device to
@@ -3244,17 +3331,22 @@ export class KeyManager {
3244
3331
  * (personal, or `accountId`'s) whose public key it names: the current key
3245
3332
  * when it matches, else a RETIRED key Commons kept after a rotation or
3246
3333
  * replacement (`./retiredKeys`, ADR 0061 D7 as amended). Spending only: a
3247
- * retired key never signs in. Never falls back to another key.
3334
+ * retired key never signs in. Never falls back to another key. A PERSONAL
3335
+ * retired key is reached only with `opts.ownerAccountId`, the signed-in
3336
+ * person's account id, and only when the ring keeps it for that account
3337
+ * (R5-2); on Android outside Commons the identity host holds only the
3338
+ * personal keys of the person Commons is signed in as.
3248
3339
  *
3249
3340
  * @param info Context/domain-binding label (distinct labels → independent seeds).
3250
3341
  * @param opts.accountId An organization's account id, for its key.
3251
3342
  * @param opts.publicKey Selects one key of that account by its public key.
3343
+ * @param opts.ownerAccountId The person's account id, for a personal retired key.
3252
3344
  * @returns 32 bytes of derived keying material, or `null` on web / when no
3253
3345
  * identity key is available to this app.
3254
3346
  */
3255
3347
  static async deriveScopedSeed(
3256
3348
  info: string,
3257
- opts?: { accountId?: string; publicKey?: string },
3349
+ opts?: { accountId?: string; publicKey?: string; ownerAccountId?: string },
3258
3350
  ): Promise<Uint8Array | null> {
3259
3351
  if (isWebPlatform()) {
3260
3352
  return null;
@@ -3268,7 +3360,7 @@ export class KeyManager {
3268
3360
  (await bridge?.deriveRetiredScopedSeed?.(info, selected, opts.accountId)) ?? null;
3269
3361
  return seed ? hexToBytes(seed) : null;
3270
3362
  }
3271
- const selected = await KeyManager._selectedPrivateKey(opts.publicKey, opts.accountId);
3363
+ const selected = await KeyManager._selectedPrivateKey({ ...opts, publicKey: opts.publicKey });
3272
3364
  return selected ? deriveScopedSeedFromKey(selected, info) : null;
3273
3365
  }
3274
3366
  if (opts?.accountId !== undefined) {
@@ -3332,7 +3424,7 @@ export class KeyManager {
3332
3424
  static async signSocialReceive(
3333
3425
  index: number,
3334
3426
  digest: string,
3335
- opts?: { accountId?: string; publicKey?: string },
3427
+ opts?: { accountId?: string; publicKey?: string; ownerAccountId?: string },
3336
3428
  ): Promise<CommonsSocialReceiveSignature | null> {
3337
3429
  if (isWebPlatform()) {
3338
3430
  return null;
@@ -3347,7 +3439,7 @@ export class KeyManager {
3347
3439
  null
3348
3440
  );
3349
3441
  }
3350
- const selected = await KeyManager._selectedPrivateKey(opts.publicKey, opts.accountId);
3442
+ const selected = await KeyManager._selectedPrivateKey({ ...opts, publicKey: opts.publicKey });
3351
3443
  return selected ? signSocialReceiveDigest(selected, index, digest) : null;
3352
3444
  }
3353
3445
  const accountId = opts?.accountId;
@@ -3388,10 +3480,12 @@ export class KeyManager {
3388
3480
  return KeyManager.isValidPublicKey(publicKey) ? KeyManager.canonicalPublicKey(publicKey) : null;
3389
3481
  }
3390
3482
 
3391
- private static async _selectedPrivateKey(
3392
- publicKey: string,
3393
- accountId?: string,
3394
- ): Promise<string | null> {
3483
+ private static async _selectedPrivateKey(opts: {
3484
+ publicKey: string;
3485
+ accountId?: string;
3486
+ ownerAccountId?: string;
3487
+ }): Promise<string | null> {
3488
+ const { publicKey, accountId, ownerAccountId } = opts;
3395
3489
  if (retiredKeyScope({ accountId }) === null || !KeyManager.isValidPublicKey(publicKey)) {
3396
3490
  return null;
3397
3491
  }
@@ -3405,7 +3499,14 @@ export class KeyManager {
3405
3499
  if (current && KeyManager.canonicalPublicKey(KeyManager.derivePublicKey(current)) === wanted) {
3406
3500
  return current;
3407
3501
  }
3408
- return getRetiredPrivateKey(wanted, accountId !== undefined ? { accountId } : undefined);
3502
+ return getRetiredPrivateKey(
3503
+ wanted,
3504
+ accountId !== undefined
3505
+ ? { accountId }
3506
+ : ownerAccountId !== undefined
3507
+ ? { ownerAccountId }
3508
+ : undefined,
3509
+ );
3409
3510
  }
3410
3511
 
3411
3512
  /**