@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
@@ -4,6 +4,15 @@ export declare const KEYCHAIN_ACCESS_GROUPS: {
4
4
  readonly wallet: "so.oxy.wallet";
5
5
  readonly shared: "group.so.oxy.shared";
6
6
  };
7
+ /**
8
+ * The suffix a DEVELOPMENT build's restricted groups carry (`so.oxy.commons.dev`,
9
+ * `to.peable.app.dev`: `so.oxy.identity.dev`, `so.oxy.wallet.dev`). A dev build
10
+ * sits beside the production app on the same phone, is signed by the same team
11
+ * and runs JavaScript from a dev server, so it never shares a restricted group
12
+ * with production (`@oxy.so/app-preset` enforces the entitlements). The shared
13
+ * group has no variant: it holds nothing secret.
14
+ */
15
+ export declare const DEV_KEYCHAIN_GROUP_SUFFIX: ".dev";
7
16
  export type KeychainAccessGroupName = keyof typeof KEYCHAIN_ACCESS_GROUPS;
8
17
  /**
9
18
  * Every group an item class can be assigned: the three Oxy groups, or `app`,
@@ -28,6 +37,8 @@ export type KeychainAccess = {
28
37
  defaultGroup: string;
29
38
  role: KeychainRole;
30
39
  appGroup?: string;
40
+ /** {@link DEV_KEYCHAIN_GROUP_SUFFIX} for a dev build; absent for production. */
41
+ variant?: typeof DEV_KEYCHAIN_GROUP_SUFFIX;
31
42
  } | {
32
43
  mode: 'ungrouped';
33
44
  };
@@ -47,6 +58,14 @@ export declare class KeychainAccessUnavailableError extends Error {
47
58
  */
48
59
  export declare function keychainGroupFor(item: Exclude<KeychainItemClass, 'app'>): KeychainAccessGroupName;
49
60
  export declare function keychainGroupFor(item: KeychainItemClass): KeychainGroup;
61
+ /**
62
+ * Whether `role` may WRITE `item`. Only Commons writes a secret: the Peable
63
+ * wallet reads, derives and signs with spending keys (and the retired ring)
64
+ * but never adds one — a key the wallet could add is one the wallet could
65
+ * choose. (The keychain itself lets every app in the wallet group write it;
66
+ * this is the SDK's half, and readers check what they find, ADR 0062.)
67
+ */
68
+ export declare function mayWriteKeychainItem(role: KeychainRole, item: KeychainItemClass): boolean;
50
69
  /** Whether `role` holds the group `item` lives in. */
51
70
  export declare function mayHoldKeychainItem(role: KeychainRole, item: KeychainItemClass): boolean;
52
71
  /**
@@ -60,7 +79,7 @@ export declare function keychainAccessFromDefaultGroup(defaultGroup: string, app
60
79
  * every caller already treats as "storage unavailable, retry".
61
80
  */
62
81
  export declare function resolveKeychainAccess(): Promise<KeychainAccess>;
63
- /** Forget the memoized access. Tests only. @internal */
82
+ /** Forget the memoized access and the fallback state. Tests only. @internal */
64
83
  export declare function resetKeychainAccessForTests(): void;
65
84
  /**
66
85
  * The full `kSecAttrAccessGroup` value of `group`, or `undefined` for "no
@@ -68,17 +87,24 @@ export declare function resetKeychainAccessForTests(): void;
68
87
  * that cannot place items there (see {@link ownKeychainItemPlan}).
69
88
  */
70
89
  export declare function keychainAccessGroupValue(access: KeychainAccess, group: KeychainGroup): string | undefined;
90
+ /** The migration's switch for Commons' fallback. @internal */
91
+ export declare function setHolderSharedSecretFallback(open: boolean): void;
92
+ /** The wallet saw Commons' completion record: no shared fallback again in this process. @internal */
93
+ export declare function closeWalletSharedSecretFallback(): void;
71
94
  /**
72
- * The option bags a READ of `item` tries, in order: its own group, then, for
73
- * an identity secret or a spending key, the shared group, where those lived
74
- * before the split (release 4 of the doc drops that fallback). Empty when this
75
- * app may not hold the item: the caller answers `null` without touching the
76
- * keychain. An app's own items (`app`) take {@link ownKeychainItemPlan}.
95
+ * The option bags a READ of `item` tries, in order: its own group, then — for
96
+ * an identity secret or a spending key, and only while
97
+ * {@link sharedFallbackOpen} says so — the shared group, where those lived
98
+ * before the split. Empty when this app may not hold the item: the caller
99
+ * answers `null` without touching the keychain. Evaluated per read: the
100
+ * fallback closes during a process's life. An app's own items (`app`) take
101
+ * {@link ownKeychainItemPlan}.
77
102
  */
78
103
  export declare function keychainReadPlan(access: KeychainAccess, item: KeychainItemClass): KeychainGroupOption[];
79
104
  /**
80
- * Every place a DELETE of `item` must reach: the read plan, so a delete never
81
- * leaves a pre-split copy behind in the shared group.
105
+ * Every place a DELETE of `item` must reach: its own group AND the shared
106
+ * group, whatever the read fallback says, so a delete never leaves a copy
107
+ * behind where every Oxy app can read it.
82
108
  */
83
109
  export declare function keychainDeletePlan(access: KeychainAccess, item: KeychainItemClass): KeychainGroupOption[];
84
110
  /**
@@ -149,8 +175,12 @@ export declare function readThroughPlan(store: KeychainStoreLike, key: string, o
149
175
  export declare function deleteThroughPlan(store: KeychainStoreLike, key: string, options: object, plan: readonly KeychainGroupOption[]): Promise<void>;
150
176
  /**
151
177
  * `store` with every call routed to `item`'s groups for this app: reads walk
152
- * the read plan, writes carry the write option (throwing for a role that may
153
- * not hold the item), deletes reach every location. The constants of the
154
- * wrapped store are kept, so `store.WHEN_UNLOCKED_THIS_DEVICE_ONLY` still reads.
178
+ * the read plan (evaluated per read), writes carry the write option (throwing
179
+ * for a role that may not write the item), deletes reach every location. A
180
+ * secret WRITE also deletes the item's copy in the shared group (R4-6): a value
181
+ * Commons writes replaces whatever any app could read there, and a copy that
182
+ * cannot be removed fails the write instead of staying readable. The constants
183
+ * of the wrapped store are kept, so `store.WHEN_UNLOCKED_THIS_DEVICE_ONLY` still
184
+ * reads.
155
185
  */
156
186
  export declare function groupedKeychainStore<S extends KeychainStoreLike>(store: S, access: KeychainAccess, item: KeychainItemClass): S;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxy.so/core",
3
- "version": "7.31.0",
3
+ "version": "7.33.0",
4
4
  "description": "OxyHQ SDK Foundation — API client, authentication, cryptographic identity, and shared utilities",
5
5
  "main": "dist/cjs/index.js",
6
6
  "module": "dist/esm/index.js",
@@ -47,6 +47,7 @@ import type { OxyContext } from '../client/context';
47
47
  import type { PendingIdentityResult } from '../crypto/recoveryPhrase';
48
48
  import { isAndroid, isWeb } from '../utils/platform';
49
49
  import { logger } from '../logger';
50
+ import { extractErrorStatus } from '../utils/errorUtils';
50
51
 
51
52
  /** Short-TTL read cache for public identity reads. */
52
53
  const SHORT_TTL = 60 * 1000;
@@ -128,6 +129,14 @@ export interface RotateKeyResult {
128
129
  * should surface a recovery prompt. Omitted on full success.
129
130
  */
130
131
  localPersistFailed?: true;
132
+ /**
133
+ * Whether the encrypted identity backup was re-created under the NEW phrase,
134
+ * carrying the retired ring with the key just replaced (R4-4). The server
135
+ * deletes the old backup in the rotation's transaction; `false` (native, the
136
+ * upload failed) means the caller should offer "Create backup" again, or the
137
+ * replaced key lives only on this phone. Absent on web.
138
+ */
139
+ backupRefreshed?: boolean;
131
140
  }
132
141
 
133
142
  /**
@@ -328,7 +337,10 @@ export class IdentityApi {
328
337
  if (!isWeb()) {
329
338
  try {
330
339
  if (!oldPrivateKeyForRing) throw new Error('no readable private key');
331
- await retireIdentityKey({ privateKey: oldPrivateKeyForRing, publicKey: oldPublicKey });
340
+ await retireIdentityKey(
341
+ { privateKey: oldPrivateKeyForRing, publicKey: oldPublicKey },
342
+ { ownerAccountId: userId },
343
+ );
332
344
  } catch (retainError) {
333
345
  logger.warn(
334
346
  'Could not keep the current key as a retired key; not rotating.',
@@ -476,11 +488,36 @@ export class IdentityApi {
476
488
  }
477
489
  }
478
490
 
491
+ // 8. The rotation deleted the encrypted backup (it was sealed under the
492
+ // replaced phrase). Seal a new one under the new phrase, carrying the
493
+ // retired ring — now with the key just replaced — so the coins under it
494
+ // survive a lost phone as long as the NEW phrase does (R4-4). Always,
495
+ // not only when one existed: after a rotation it is the only copy of the
496
+ // replaced key off this phone. Best-effort: the rotation already succeeded.
497
+ let backupRefreshed: boolean | undefined;
498
+ if (!isWeb() && !localPersistFailed) {
499
+ try {
500
+ await this.backup.create(pending.phrase);
501
+ backupRefreshed = true;
502
+ } catch (backupError) {
503
+ backupRefreshed = false;
504
+ logger.warn(
505
+ 'Key rotated, but the encrypted backup could not be re-created under the new phrase.',
506
+ { component: 'oxy.identity', method: 'rotateKey' },
507
+ backupError,
508
+ );
509
+ }
510
+ }
511
+
479
512
  this.invalidate(userId);
480
513
 
481
- return localPersistFailed
482
- ? { newPublicKey, newPhrase: pending.phrase, words: pending.words, localPersistFailed: true }
483
- : { newPublicKey, newPhrase: pending.phrase, words: pending.words };
514
+ return {
515
+ newPublicKey,
516
+ newPhrase: pending.phrase,
517
+ words: pending.words,
518
+ ...(localPersistFailed ? { localPersistFailed: true as const } : {}),
519
+ ...(backupRefreshed === undefined ? {} : { backupRefreshed }),
520
+ };
484
521
  }
485
522
 
486
523
  /**
@@ -736,11 +773,29 @@ const BACKUP_ALGORITHM = 'xchacha20poly1305' as const;
736
773
  */
737
774
  const PUBLIC_KEY_HINT_LENGTH = 16;
738
775
 
739
- /** The decrypted backup payload. */
776
+ /** One retired key carried in the backup (R4-4). */
777
+ interface BackupRetiredKey {
778
+ privateKey: string;
779
+ publicKey: string;
780
+ retiredAt: number;
781
+ }
782
+
783
+ /**
784
+ * The decrypted backup payload. `retiredKeys` (since core 2026-11, optional,
785
+ * ignored by older readers) carries the person's retired ring, so the CURRENT
786
+ * phrase restores the keys their earlier coins sit under (ADR 0061 D7 as
787
+ * amended): a lost phone then costs no more than the current phrase does.
788
+ */
740
789
  interface BackupPayload {
741
790
  privateKey: string;
742
791
  publicKey: string;
743
792
  createdAt: string;
793
+ retiredKeys?: BackupRetiredKey[];
794
+ }
795
+
796
+ /** Whether a request error is the restore lookup's constant-shape 404. */
797
+ function isNotFound(error: unknown): boolean {
798
+ return extractErrorStatus(error) === 404;
744
799
  }
745
800
 
746
801
  /** Encode bytes as lowercase hex (cross-platform, no Buffer dependency). */
@@ -806,7 +861,13 @@ export class IdentityBackupApi {
806
861
  const createdAt = new Date().toISOString();
807
862
  const publicKeyHint = publicKey.slice(0, PUBLIC_KEY_HINT_LENGTH);
808
863
 
809
- const payload: BackupPayload = { privateKey, publicKey, createdAt };
864
+ const retiredKeys = await this.retiredKeysToCarry(publicKey);
865
+ const payload: BackupPayload = {
866
+ privateKey,
867
+ publicKey,
868
+ createdAt,
869
+ ...(retiredKeys.length > 0 ? { retiredKeys } : {}),
870
+ };
810
871
  const plaintext = new TextEncoder().encode(JSON.stringify(payload));
811
872
  const aad = buildBackupAad(BACKUP_ENVELOPE_VERSION, publicKeyHint);
812
873
  const { nonce, ciphertext } = encryptAead(backupKey, plaintext, aad);
@@ -827,6 +888,129 @@ export class IdentityBackupApi {
827
888
  });
828
889
  }
829
890
 
891
+ /**
892
+ * The retired ring this device holds for the person, limited to keys Oxy's
893
+ * key history names as THIS account's retired keys (the ring's iOS group is
894
+ * writable by the wallet app too, and a previous person's ring can outlive a
895
+ * "Start over"). Empty where no ring is held (web, any app but Commons).
896
+ * Throws when the history cannot be read while a ring exists, so a backup
897
+ * never silently drops keys it should carry.
898
+ */
899
+ private async retiredKeysToCarry(currentPublicKey: string): Promise<BackupRetiredKey[]> {
900
+ if (isWeb()) return [];
901
+ const { exportRetiredIdentityKeys } = await import('../crypto/index');
902
+ const userId = this.ctx.oxy.session.userId;
903
+ if (!userId) throw new Error('No authenticated user — sign in before backing up.');
904
+ let held: BackupRetiredKey[];
905
+ try {
906
+ held = await exportRetiredIdentityKeys({ ownerAccountId: userId });
907
+ } catch {
908
+ return []; // Not Commons: this app holds no ring to carry.
909
+ }
910
+ if (held.length === 0) return [];
911
+ const history = await this.ctx.request<unknown>('GET', '/identity/key-history', undefined, {
912
+ cache: false,
913
+ });
914
+ const parsed = checkContract(identityKeyHistoryCheck, history);
915
+ if (!parsed) throw new Error('identity/key-history returned an unexpected response shape');
916
+ const retired = new Set(parsed.retired.map((entry) => entry.publicKey.toLowerCase()));
917
+ const current = currentPublicKey.toLowerCase();
918
+ return held.filter(
919
+ (key) => key.publicKey.toLowerCase() !== current && retired.has(key.publicKey.toLowerCase()),
920
+ );
921
+ }
922
+
923
+ /**
924
+ * Fetch and open the backup the phrase locates, checking it is the phrase's
925
+ * own identity. With `missingIsNull`, `null` when there is none (404);
926
+ * otherwise the request's own error propagates.
927
+ */
928
+ private async open(phrase: string, missingIsNull = false): Promise<BackupPayload | null> {
929
+ const { KeyManager, RecoveryPhraseService, decryptAead } = await import('../crypto/index');
930
+ const { backupKey, lookupId } = await RecoveryPhraseService.deriveBackupMaterial(phrase);
931
+ let envelope: EncryptedBackupEnvelope;
932
+ try {
933
+ envelope = await this.ctx.request<EncryptedBackupEnvelope>(
934
+ 'GET',
935
+ `/identity/backup/${encodeURIComponent(lookupId)}`,
936
+ undefined,
937
+ { cache: false },
938
+ );
939
+ } catch (error) {
940
+ if (missingIsNull && isNotFound(error)) return null;
941
+ throw error;
942
+ }
943
+ if (envelope.algorithm !== BACKUP_ALGORITHM) {
944
+ throw new Error(`Unsupported backup algorithm: ${envelope.algorithm}`);
945
+ }
946
+ const aad = buildBackupAad(envelope.version, envelope.publicKeyHint);
947
+ const plaintext = decryptAead(
948
+ backupKey,
949
+ fromHex(envelope.nonce),
950
+ fromHex(envelope.ciphertext),
951
+ aad,
952
+ );
953
+ const payload = JSON.parse(new TextDecoder().decode(plaintext)) as BackupPayload;
954
+ if (!payload.privateKey || !payload.publicKey) {
955
+ throw new Error('Backup payload is missing key material');
956
+ }
957
+ const phrasePk = (await RecoveryPhraseService.derivePublicKeyFromPhrase(phrase)).toLowerCase();
958
+ const payloadPk = payload.publicKey.toLowerCase();
959
+ const privatePk = KeyManager.derivePublicKey(payload.privateKey).toLowerCase();
960
+ if (phrasePk !== payloadPk || privatePk !== payloadPk) {
961
+ throw new Error('Backup payload does not match the recovery phrase');
962
+ }
963
+ return payload;
964
+ }
965
+
966
+ /**
967
+ * Keep every retired key a backup carries in this device's ring. Each pair
968
+ * must be healthy and must not be the current key; the envelope itself is
969
+ * authenticated by the phrase. Commons only (the ring refuses elsewhere).
970
+ * Resolves how many keys are now held; a key that fails is skipped.
971
+ */
972
+ private async keepCarriedRetiredKeys(payload: BackupPayload): Promise<number> {
973
+ const { KeyManager, retireIdentityKey } = await import('../crypto/index');
974
+ // The backup is the signed-in account's (its phrase opened it): its keys
975
+ // are kept for that account only (R5-2).
976
+ const ownerAccountId = this.ctx.oxy.session.userId;
977
+ if (!ownerAccountId) throw new Error('No authenticated user — sign in before restoring.');
978
+ let kept = 0;
979
+ for (const key of Array.isArray(payload.retiredKeys) ? payload.retiredKeys : []) {
980
+ try {
981
+ if (typeof key?.privateKey !== 'string' || typeof key.publicKey !== 'string') continue;
982
+ if (key.publicKey.toLowerCase() === payload.publicKey.toLowerCase()) continue;
983
+ if (
984
+ KeyManager.derivePublicKey(key.privateKey).toLowerCase() !== key.publicKey.toLowerCase()
985
+ ) {
986
+ continue;
987
+ }
988
+ await retireIdentityKey(
989
+ { privateKey: key.privateKey, publicKey: key.publicKey },
990
+ { ownerAccountId },
991
+ );
992
+ kept += 1;
993
+ } catch (error) {
994
+ logger.warn(
995
+ 'Could not keep a retired key from the encrypted backup',
996
+ { component: 'oxy.identity', method: 'backup.restoreRetiredKeys' },
997
+ error,
998
+ );
999
+ }
1000
+ }
1001
+ return kept;
1002
+ }
1003
+
1004
+ /**
1005
+ * Restore ONLY the retired keys the phrase's backup carries into this
1006
+ * device's ring (Commons, after a phrase import or a silent device-backup
1007
+ * restore). Resolves how many were kept; 0 when there is no backup.
1008
+ */
1009
+ async restoreRetiredKeys(phrase: string): Promise<number> {
1010
+ const payload = await this.open(phrase, true);
1011
+ return payload ? this.keepCarriedRetiredKeys(payload) : 0;
1012
+ }
1013
+
830
1014
  /**
831
1015
  * Whether the signed-in user has a stored backup, plus its non-sensitive hint
832
1016
  * and timestamp. Returns no ciphertext and no locator.
@@ -859,40 +1043,13 @@ export class IdentityBackupApi {
859
1043
  * fails authentication (tamper), or an existing identity blocks the import.
860
1044
  */
861
1045
  async restore(phrase: string, options?: { overwrite?: boolean }): Promise<string> {
862
- const { KeyManager, RecoveryPhraseService, decryptAead } = await import('../crypto/index');
863
- const { backupKey, lookupId } = await RecoveryPhraseService.deriveBackupMaterial(phrase);
864
-
865
- const envelope = await this.ctx.request<EncryptedBackupEnvelope>(
866
- 'GET',
867
- `/identity/backup/${encodeURIComponent(lookupId)}`,
868
- undefined,
869
- { cache: false },
870
- );
871
-
872
- if (envelope.algorithm !== BACKUP_ALGORITHM) {
873
- throw new Error(`Unsupported backup algorithm: ${envelope.algorithm}`);
874
- }
875
-
876
- const aad = buildBackupAad(envelope.version, envelope.publicKeyHint);
877
- const plaintext = decryptAead(
878
- backupKey,
879
- fromHex(envelope.nonce),
880
- fromHex(envelope.ciphertext),
881
- aad,
882
- );
883
- const payload = JSON.parse(new TextDecoder().decode(plaintext)) as BackupPayload;
884
-
885
- if (!payload.privateKey || !payload.publicKey) {
886
- throw new Error('Backup payload is missing key material');
887
- }
888
-
889
- const phrasePk = (await RecoveryPhraseService.derivePublicKeyFromPhrase(phrase)).toLowerCase();
890
- const payloadPk = payload.publicKey.toLowerCase();
891
- const privatePk = KeyManager.derivePublicKey(payload.privateKey).toLowerCase();
892
- if (phrasePk !== payloadPk || privatePk !== payloadPk) {
893
- throw new Error('Backup payload does not match the recovery phrase');
894
- }
895
-
896
- return KeyManager.importKeyPair(payload.privateKey, { overwrite: options?.overwrite === true });
1046
+ const { KeyManager } = await import('../crypto/index');
1047
+ const payload = await this.open(phrase);
1048
+ if (!payload) throw new Error('No encrypted backup was found for this recovery phrase.');
1049
+ const publicKey = await KeyManager.importKeyPair(payload.privateKey, {
1050
+ overwrite: options?.overwrite === true,
1051
+ });
1052
+ await this.keepCarriedRetiredKeys(payload);
1053
+ return publicKey;
897
1054
  }
898
1055
  }
@@ -185,6 +185,21 @@ export async function readIdentityMarker(): Promise<IdentityMarker | null> {
185
185
  }
186
186
  }
187
187
 
188
+ /**
189
+ * Read the marker, THROWING when the storage cannot answer (instead of the
190
+ * fail-open `null` of {@link readIdentityMarker}). For a caller that must not
191
+ * mistake "unreadable" for "never written": the iOS keychain migration, which
192
+ * adopts a pre-split secret only when this marker vouches for it, and records
193
+ * once that there is nothing to adopt.
194
+ */
195
+ export async function readIdentityMarkerStrict(): Promise<IdentityMarker | null> {
196
+ const storage = await getStorage();
197
+ if (!storage) {
198
+ throw new Error('The identity marker storage is unavailable.');
199
+ }
200
+ return deserialize(await storage.getItem(IDENTITY_MARKER_STORAGE_KEY));
201
+ }
202
+
188
203
  /**
189
204
  * Write (create/replace) the marker. Returns `true` when it durably landed,
190
205
  * `false` when storage was unavailable or the write threw. Callers treat a
@@ -51,6 +51,8 @@ export {
51
51
  KeychainAccessUnavailableError,
52
52
  keychainGroupFor,
53
53
  mayHoldKeychainItem,
54
+ mayWriteKeychainItem,
55
+ DEV_KEYCHAIN_GROUP_SUFFIX,
54
56
  resolveKeychainAccess,
55
57
  keychainAccessGroupValue,
56
58
  keychainReadPlan,
@@ -71,7 +73,10 @@ export type {
71
73
  KeychainStoreLike,
72
74
  OwnKeychainItemNames,
73
75
  } from '../utils/keychainAccessGroups';
74
- export { ensureKeychainGroupsMigrated } from './keychainGroupMigration';
76
+ export {
77
+ ensureKeychainGroupsMigrated,
78
+ getPreSplitExposure,
79
+ } from './keychainGroupMigration';
75
80
  export type {
76
81
  KeychainGroupMigrationReport,
77
82
  KeychainItemMigrationOutcome,
@@ -85,6 +90,12 @@ export {
85
90
  forgetRetiredIdentityKeys,
86
91
  syncRetiredKeys,
87
92
  retiredKeyFromPhrase,
93
+ retiredKeyFromPrivateKey,
94
+ exportRetiredIdentityKeys,
95
+ claimRetiredIdentityKeys,
96
+ countQuarantinedRetiredKeys,
97
+ unmirrorPersonalRetiredKeys,
98
+ RetiredKeyRingUnreadableError,
88
99
  } from './retiredKeys';
89
100
  export type { RetiredIdentityKey, RetiredKeyPair, RetiredKeySelector } from './retiredKeys';
90
101
  export { SignatureService } from './signatureService';