@oxy.so/core 7.30.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 (40) 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/session/authStateStore.js +13 -0
  9. package/dist/cjs/session/index.js +5 -2
  10. package/dist/cjs/utils/keychainAccessGroups.js +208 -51
  11. package/dist/esm/api/identity.js +146 -23
  12. package/dist/esm/crypto/identityMarker.js +14 -0
  13. package/dist/esm/crypto/index.js +3 -3
  14. package/dist/esm/crypto/keyManager.js +9 -12
  15. package/dist/esm/crypto/keychainGroupMigration.js +327 -162
  16. package/dist/esm/crypto/organizationKeys.js +34 -6
  17. package/dist/esm/crypto/retiredKeys.js +76 -17
  18. package/dist/esm/session/authStateStore.js +12 -0
  19. package/dist/esm/session/index.js +1 -1
  20. package/dist/esm/utils/keychainAccessGroups.js +205 -51
  21. package/dist/types/api/identity.d.ts +36 -0
  22. package/dist/types/crypto/identityMarker.d.ts +8 -0
  23. package/dist/types/crypto/index.d.ts +4 -4
  24. package/dist/types/crypto/keychainGroupMigration.d.ts +28 -6
  25. package/dist/types/crypto/organizationKeys.d.ts +4 -2
  26. package/dist/types/crypto/retiredKeys.d.ts +30 -4
  27. package/dist/types/session/authStateStore.d.ts +9 -0
  28. package/dist/types/session/index.d.ts +2 -2
  29. package/dist/types/utils/keychainAccessGroups.d.ts +108 -26
  30. package/package.json +2 -2
  31. package/src/api/identity.ts +185 -40
  32. package/src/crypto/identityMarker.ts +15 -0
  33. package/src/crypto/index.ts +10 -1
  34. package/src/crypto/keyManager.ts +9 -15
  35. package/src/crypto/keychainGroupMigration.ts +396 -186
  36. package/src/crypto/organizationKeys.ts +38 -6
  37. package/src/crypto/retiredKeys.ts +87 -17
  38. package/src/session/authStateStore.ts +14 -0
  39. package/src/session/index.ts +8 -1
  40. package/src/utils/keychainAccessGroups.ts +276 -52
@@ -47,7 +47,8 @@ import {
47
47
  } from '../utils/keychainAccessGroups';
48
48
  import { isAndroid, isIOS, isWeb } from '../utils/platform';
49
49
  import { registeredIdentitySignerStore } from './identitySigner';
50
- import { ensureKeychainGroupsMigrated } from './keychainGroupMigration';
50
+ import { prepareKeychainAccess } from './keychainGroupMigration';
51
+ import { retireIdentityKey } from './retiredKeys';
51
52
  import {
52
53
  ORGANIZATION_ANDROID_KEYCHAIN_SERVICE as ANDROID_KEYCHAIN_SERVICE,
53
54
  ORGANIZATION_INDEX_KEYCHAIN_SERVICE as INDEX_KEYCHAIN_SERVICE,
@@ -158,9 +159,7 @@ export async function organizationKeyFromPhrase(phrase: string): Promise<Organiz
158
159
  async function secureStore(item: KeychainItemClass): Promise<ExpoSecureStoreLike> {
159
160
  const raw = await loadSecureStore();
160
161
  const access = await resolveKeychainAccess();
161
- if (access.mode === 'grouped' && access.role === 'holder') {
162
- await ensureKeychainGroupsMigrated();
163
- }
162
+ await prepareKeychainAccess(access);
164
163
  return groupedKeychainStore(raw, access, item);
165
164
  }
166
165
 
@@ -252,8 +251,10 @@ async function readLocalPair(accountId: string): Promise<OrganizationKeyPair | n
252
251
  * it; on iOS only Commons holds the groups it writes (any other app throws).
253
252
  *
254
253
  * Refuses a pair whose public key does not derive from its private key, and a
255
- * phrase that does not derive the pair. Overwrites whatever this device held for
256
- * the organization (a replaced key is the caller's decision, ADR 0061 D7).
254
+ * phrase that does not derive the pair. A DIFFERENT key this device already
255
+ * held for the organization is first kept in the organization's retired ring
256
+ * (ADR 0061 D7 as amended; R4-12): coins under it still need it. When that
257
+ * cannot be confirmed nothing is overwritten and this throws.
257
258
  */
258
259
  export async function storeOrganizationKey(
259
260
  accountId: string,
@@ -280,6 +281,19 @@ export async function storeOrganizationKey(
280
281
  );
281
282
  }
282
283
  const store = await pairStore();
284
+ const previous = await heldPrivateKey(store, accountId);
285
+ if (previous) {
286
+ const previousPublic = deriveSecp256k1PublicKey(previous);
287
+ if (previousPublic !== publicKey) {
288
+ try {
289
+ await retireIdentityKey({ privateKey: previous, publicKey: previousPublic }, { accountId });
290
+ } catch (error) {
291
+ throw new Error(
292
+ `organization key: the key this device held could not be kept as a previous key, so it was not replaced (${error instanceof Error ? error.message : 'unknown'})`,
293
+ );
294
+ }
295
+ }
296
+ }
283
297
  await store.setItemAsync(privateKeyName(accountId), privateKey, pairOptions(store, true));
284
298
  await store.setItemAsync(publicKeyName(accountId), publicKey, pairOptions(store, false));
285
299
  if (
@@ -362,6 +376,24 @@ export async function listOrganizationKeys(): Promise<string[]> {
362
376
  return readIndex(await indexStore());
363
377
  }
364
378
 
379
+ /**
380
+ * The private key this device holds for `accountId`, whatever its public half
381
+ * says (an unhealthy pair is still a key coins may sit under): the slot, then
382
+ * on Android the signer store's copy. `null` when there is none.
383
+ */
384
+ async function heldPrivateKey(
385
+ store: ExpoSecureStoreLike,
386
+ accountId: string,
387
+ ): Promise<string | null> {
388
+ const local = await store.getItemAsync(privateKeyName(accountId), pairOptions(store, false));
389
+ if (local && isValidSecp256k1PrivateKey(local)) return normalizeSecp256k1PrivateKey(local);
390
+ const signer = isAndroid() ? registeredIdentitySignerStore() : null;
391
+ const mirrored = (await signer?.readOrganization?.(accountId)) ?? null;
392
+ return mirrored && isValidSecp256k1PrivateKey(mirrored.privateKey)
393
+ ? normalizeSecp256k1PrivateKey(mirrored.privateKey)
394
+ : null;
395
+ }
396
+
365
397
  /** Forget an organization key on this device: its pair, its phrase, its signer copy and its index entry. */
366
398
  export async function deleteOrganizationKey(accountId: string): Promise<void> {
367
399
  assertAccountId(accountId);
@@ -60,8 +60,8 @@ import { logger } from '../logger';
60
60
  import { groupedKeychainStore, resolveKeychainAccess } from '../utils/keychainAccessGroups';
61
61
  import { isAndroid, isIOS, isWeb } from '../utils/platform';
62
62
  import { registeredIdentitySignerStore } from './identitySigner';
63
- import { ensureKeychainGroupsMigrated } from './keychainGroupMigration';
64
- import { isOrganizationAccountId } from './organizationKeys';
63
+ import { isOrganizationAccountId } from './keychainItems';
64
+ import { prepareKeychainAccess } from './keychainGroupMigration';
65
65
  import type { OxySecureStoreOptions as SecureStoreOptions } from './secureStoreOptions';
66
66
 
67
67
  /** Whose ring: the person's (`accountId` absent) or an organization's. */
@@ -97,15 +97,14 @@ const PUBLIC_KEY = /^04[0-9a-f]{128}$/;
97
97
  * The secure store for the ring. On iOS every item is a `spending-key`
98
98
  * (ADR 0062): the private keys AND the index live in the wallet group, which
99
99
  * only Commons and the Peable wallet can read, never the shared one. In
100
- * Commons every access first awaits the access-group migration. On Android
101
- * the grouping is a no-op and Commons' own services below apply.
100
+ * Commons every access first awaits the access-group migration (`gate: false`
101
+ * is the migration itself, keeping a pre-split copy). On Android the grouping
102
+ * is a no-op and Commons' own services below apply.
102
103
  */
103
- async function ringStore(): Promise<ExpoSecureStoreLike> {
104
+ async function ringStore(gate = true): Promise<ExpoSecureStoreLike> {
104
105
  const raw = await loadSecureStore();
105
106
  const access = await resolveKeychainAccess();
106
- if (access.mode === 'grouped' && access.role === 'holder') {
107
- await ensureKeychainGroupsMigrated();
108
- }
107
+ if (gate) await prepareKeychainAccess(access);
109
108
  return groupedKeychainStore(raw, access, 'spending-key');
110
109
  }
111
110
 
@@ -213,14 +212,25 @@ function toListed(entry: IndexEntry): RetiredIdentityKey {
213
212
  : { publicKey: entry.publicKey, accountId: entry.scope, retiredAt: entry.retiredAt };
214
213
  }
215
214
 
216
- /** Whether this app writes the ring: Commons (Android: the signer store it registers; iOS: native). */
217
- function assertMayWrite(): void {
215
+ /**
216
+ * Whether this app writes the ring: Commons only (Android: the signer store it
217
+ * registers; iOS: the keychain role `holder`). The Peable wallet holds the
218
+ * wallet group too, but only reads, derives and signs: a key the wallet could
219
+ * add to the ring is a key it could choose (R4-14).
220
+ */
221
+ async function assertMayWrite(): Promise<void> {
218
222
  if (isWeb()) throw new Error('retired key: only a native device holds one');
219
223
  if (isAndroid() && !registeredIdentitySignerStore()?.writeRetired) {
220
224
  throw new Error(
221
225
  'On Android only Commons holds retired keys; this app has no identity signer store for them.',
222
226
  );
223
227
  }
228
+ if (isIOS()) {
229
+ const access = await resolveKeychainAccess();
230
+ if (access.mode === 'grouped' && access.role !== 'holder') {
231
+ throw new Error('On iOS only Commons writes retired keys; this app may only read them.');
232
+ }
233
+ }
224
234
  }
225
235
 
226
236
  /**
@@ -233,16 +243,36 @@ function assertMayWrite(): void {
233
243
  export async function retireIdentityKey(
234
244
  key: RetiredKeyPair,
235
245
  selector?: RetiredKeySelector,
246
+ ): Promise<void> {
247
+ await keepRetired(key, selector, true);
248
+ }
249
+
250
+ /**
251
+ * {@link retireIdentityKey} from inside the iOS access-group migration, which
252
+ * keeps a replaced pre-split copy before deleting it from the shared group and
253
+ * cannot await itself. @internal
254
+ */
255
+ export async function retireIdentityKeyDuringMigration(
256
+ key: RetiredKeyPair,
257
+ selector?: RetiredKeySelector,
258
+ ): Promise<void> {
259
+ await keepRetired(key, selector, false);
260
+ }
261
+
262
+ async function keepRetired(
263
+ key: RetiredKeyPair,
264
+ selector: RetiredKeySelector | undefined,
265
+ gate: boolean,
236
266
  ): Promise<void> {
237
267
  const scope = retiredKeyScope(selector);
238
268
  if (!scope) throw new Error('retired key: accountId must be a 24-hex or uuid account id');
239
- assertMayWrite();
269
+ await assertMayWrite();
240
270
  if (!isHealthyPair(key.privateKey, key.publicKey)) {
241
271
  throw new Error('retired key: the public key does not derive from the private key');
242
272
  }
243
273
  const privateKey = normalizeSecp256k1PrivateKey(key.privateKey);
244
274
  const publicKey = normalizeSecp256k1PublicKey(key.publicKey);
245
- const store = await ringStore();
275
+ const store = await ringStore(gate);
246
276
  await store.setItemAsync(
247
277
  privateKeyName(scope, publicKey),
248
278
  privateKey,
@@ -357,7 +387,7 @@ export async function forgetRetiredIdentityKey(
357
387
  const scope = retiredKeyScope(selector);
358
388
  const canonical = canonicalPublicKey(publicKey);
359
389
  if (!scope || !canonical) throw new Error('retired key: invalid selector or public key');
360
- assertMayWrite();
390
+ await assertMayWrite();
361
391
  const store = await ringStore();
362
392
  await store.deleteItemAsync(privateKeyName(scope, canonical), privateOptions(store, false));
363
393
  const signer = isAndroid() ? registeredIdentitySignerStore() : null;
@@ -371,10 +401,12 @@ export async function forgetRetiredIdentityKey(
371
401
  }
372
402
 
373
403
  /**
374
- * Forget every retired key of one ring (the person's, or an organization's):
375
- * called when the identity itself is deleted from this device, so a phone
376
- * handed on keeps none of the person's old keys. Commons only. Resolves how
377
- * many it removed.
404
+ * Forget every retired key of one ring (the person's, or an organization's).
405
+ * Commons only, and only when the person deletes their Oxy ACCOUNT (Commons'
406
+ * delete-account flow), which tells them the coins go with it. Deleting the
407
+ * identity from this device — "Start over", a forced delete — never calls it:
408
+ * the ring is money, and the same person restoring their identity later must
409
+ * find it (R4-4). Resolves how many it removed.
378
410
  */
379
411
  export async function forgetRetiredIdentityKeys(selector?: RetiredKeySelector): Promise<number> {
380
412
  const scope = retiredKeyScope(selector);
@@ -432,6 +464,44 @@ export async function syncRetiredKeys(): Promise<number> {
432
464
  return mirrored;
433
465
  }
434
466
 
467
+ /**
468
+ * Every retired key of `selector`'s ring WITH its private half, for the
469
+ * encrypted identity backup (`oxy.identity.backup.create`), which carries them
470
+ * under the CURRENT phrase so a lost phone costs no more than the current
471
+ * phrase does (R4-4). Commons only; an entry whose private half is missing or
472
+ * does not derive its public key is left out. Never log or transmit the result
473
+ * except inside that encrypted envelope.
474
+ */
475
+ export async function exportRetiredIdentityKeys(
476
+ selector?: RetiredKeySelector,
477
+ ): Promise<Array<RetiredKeyPair & { retiredAt: number }>> {
478
+ const scope = retiredKeyScope(selector);
479
+ if (!scope || isWeb()) return [];
480
+ await assertMayWrite();
481
+ const out: Array<RetiredKeyPair & { retiredAt: number }> = [];
482
+ for (const entry of await listRetiredIdentityKeys(selector)) {
483
+ const privateKey = await getRetiredPrivateKey(entry.publicKey, selector);
484
+ if (privateKey)
485
+ out.push({ privateKey, publicKey: entry.publicKey, retiredAt: entry.retiredAt });
486
+ }
487
+ return out;
488
+ }
489
+
490
+ /**
491
+ * The pair a previous key's raw PRIVATE KEY gives (64 hex, an optional `0x`),
492
+ * for a person whose old identity was imported as a private key and so has no
493
+ * phrase to re-enter. Stores nothing: the caller checks the public key against
494
+ * the account's key history and only then calls {@link retireIdentityKey}.
495
+ */
496
+ export function retiredKeyFromPrivateKey(privateKeyHex: string): RetiredKeyPair {
497
+ const trimmed = privateKeyHex.trim().toLowerCase().replace(/^0x/, '');
498
+ if (!/^[0-9a-f]{64}$/.test(trimmed) || !isValidSecp256k1PrivateKey(trimmed)) {
499
+ throw new Error('retired key: not a valid private key');
500
+ }
501
+ const privateKey = normalizeSecp256k1PrivateKey(trimmed);
502
+ return { privateKey, publicKey: publicKeyOf(privateKey) };
503
+ }
504
+
435
505
  /**
436
506
  * The pair a previous recovery phrase derives (`seed[0:32]`, the personal-root
437
507
  * and organization-key derivation; 12–24 words). Stores nothing: the caller
@@ -18,6 +18,7 @@
18
18
  */
19
19
 
20
20
  import { logger } from '../logger';
21
+ import type { OwnKeychainItemNames } from '../utils/keychainAccessGroups';
21
22
 
22
23
  /**
23
24
  * The persisted session credential set for a single origin.
@@ -150,6 +151,19 @@ export const AUTH_STATE_STORAGE_KEY = 'oxy.auth.v1';
150
151
  export const AUTOMATIC_IDENTITY_SIGNIN_SUPPRESSED_KEY =
151
152
  'oxy.auth.automatic-identity-signin-suppressed.v1';
152
153
 
154
+ /**
155
+ * What `ownKeychainItemPlan` (ADR 0062 D6) must know about a key a native
156
+ * session store keeps — this module's three and the identity pin: every Oxy app
157
+ * writes it under the same name, so on iOS a copy in the shared group may be
158
+ * another app's session and is never read as this app's. The sign-out marker
159
+ * can only withhold, so a shared copy of it still counts.
160
+ */
161
+ export function nativeSessionItemNames(key: string): OwnKeychainItemNames {
162
+ return key === AUTOMATIC_IDENTITY_SIGNIN_SUPPRESSED_KEY
163
+ ? 'every-oxy-app-withholding'
164
+ : 'every-oxy-app';
165
+ }
166
+
153
167
  /**
154
168
  * Versioned BEST-EFFORT warm-token storage key. Holds the short-lived
155
169
  * `{ accessToken, expiresAt }` pair only. Its write is genuinely non-fatal — a
@@ -134,7 +134,10 @@ export {
134
134
  createWebAuthStateStore,
135
135
  createNativeAuthStateStore,
136
136
  createMemoryAuthStateStore,
137
+ nativeSessionItemNames,
137
138
  AUTH_STATE_STORAGE_KEY,
139
+ AUTH_STATE_TOKEN_STORAGE_KEY,
140
+ AUTOMATIC_IDENTITY_SIGNIN_SUPPRESSED_KEY,
138
141
  } from './authStateStore';
139
142
  export type {
140
143
  PersistedAuthState,
@@ -176,7 +179,11 @@ export {
176
179
  deleteThroughPlan,
177
180
  resolveKeychainAccess,
178
181
  } from '../utils/keychainAccessGroups';
179
- export type { KeychainAccess, KeychainGroupOption } from '../utils/keychainAccessGroups';
182
+ export type {
183
+ KeychainAccess,
184
+ KeychainGroupOption,
185
+ OwnKeychainItemNames,
186
+ } from '../utils/keychainAccessGroups';
180
187
 
181
188
  // Identity-bound sessions (the identity vault). The pin is the durable
182
189
  // `{publicKey, accountId}` binding between this device's PRIMARY identity key