@oxy.so/core 7.28.0 → 7.30.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 (44) hide show
  1. package/dist/cjs/api/identity.js +40 -1
  2. package/dist/cjs/crypto/index.js +28 -1
  3. package/dist/cjs/crypto/keyManager.js +175 -107
  4. package/dist/cjs/crypto/keychainGroupMigration.js +353 -0
  5. package/dist/cjs/crypto/keychainItems.js +70 -0
  6. package/dist/cjs/crypto/organizationKeys.js +80 -72
  7. package/dist/cjs/crypto/retiredKeys.js +371 -0
  8. package/dist/cjs/crypto/secureStoreOptions.js +2 -0
  9. package/dist/cjs/session/index.js +11 -1
  10. package/dist/cjs/utils/keychainAccessGroups.js +248 -0
  11. package/dist/esm/api/identity.js +40 -1
  12. package/dist/esm/crypto/index.js +6 -0
  13. package/dist/esm/crypto/keyManager.js +132 -64
  14. package/dist/esm/crypto/keychainGroupMigration.js +348 -0
  15. package/dist/esm/crypto/keychainItems.js +61 -0
  16. package/dist/esm/crypto/organizationKeys.js +54 -46
  17. package/dist/esm/crypto/retiredKeys.js +361 -0
  18. package/dist/esm/crypto/secureStoreOptions.js +1 -0
  19. package/dist/esm/session/index.js +3 -0
  20. package/dist/esm/utils/keychainAccessGroups.js +231 -0
  21. package/dist/types/api/identity.d.ts +10 -0
  22. package/dist/types/crypto/identitySigner.d.ts +23 -0
  23. package/dist/types/crypto/index.d.ts +6 -0
  24. package/dist/types/crypto/keyManager.d.ts +32 -2
  25. package/dist/types/crypto/keychainGroupMigration.d.ts +27 -0
  26. package/dist/types/crypto/keychainItems.d.ts +41 -0
  27. package/dist/types/crypto/organizationKeys.d.ts +11 -9
  28. package/dist/types/crypto/retiredKeys.d.ts +116 -0
  29. package/dist/types/crypto/secureStoreOptions.d.ts +15 -0
  30. package/dist/types/session/index.d.ts +2 -0
  31. package/dist/types/utils/keychainAccessGroups.d.ts +104 -0
  32. package/package.json +3 -3
  33. package/src/api/identity.ts +46 -3
  34. package/src/crypto/identitySigner.ts +23 -0
  35. package/src/crypto/index.ts +40 -0
  36. package/src/crypto/keyManager.ts +165 -102
  37. package/src/crypto/keychainGroupMigration.ts +440 -0
  38. package/src/crypto/keychainItems.ts +73 -0
  39. package/src/crypto/organizationKeys.ts +72 -57
  40. package/src/crypto/retiredKeys.ts +453 -0
  41. package/src/crypto/secureStoreOptions.ts +32 -0
  42. package/src/session/index.ts +11 -0
  43. package/src/utils/keychainAccessGroups.ts +308 -0
  44. package/src/types/expo-secure-store.d.ts +0 -21
@@ -238,6 +238,13 @@ export declare class KeyManager {
238
238
  * Called internally when shared identity is created/deleted/imported
239
239
  */
240
240
  private static invalidateSharedCache;
241
+ /**
242
+ * iOS: write the personal key's cross-app slot — the private key to the
243
+ * wallet group (Commons and the Peable wallet), the public key to the shared
244
+ * group (every Oxy app). Throws in any app that may not hold the private key.
245
+ * @internal
246
+ */
247
+ private static _writeIosSharedSlot;
241
248
  /**
242
249
  * Lowercase and pad to canonical 64-hex-char form.
243
250
  *
@@ -717,7 +724,8 @@ export declare class KeyManager {
717
724
  * `HDKey.fromMasterSeed` and never touches the identity key.
718
725
  *
719
726
  * Key source (native only):
720
- * - iOS: the shared identity in `group.so.oxy.shared`, then this device's
727
+ * - iOS: the shared slot's private key in the wallet keychain group (Commons
728
+ * and the Peable wallet only; `null` in every other app), then this device's
721
729
  * primary identity.
722
730
  * - Android: Commons (the one app with a signer store) uses its own primary
723
731
  * identity; every other app asks Commons over IPC and never reads a local
@@ -731,13 +739,21 @@ export declare class KeyManager {
731
739
  * keychain group, or Commons' identity host. It never falls back to the
732
740
  * personal key.
733
741
  *
742
+ * With `opts.publicKey` the seed is derived from the key of that account
743
+ * (personal, or `accountId`'s) whose public key it names: the current key
744
+ * when it matches, else a RETIRED key Commons kept after a rotation or
745
+ * replacement (`./retiredKeys`, ADR 0061 D7 as amended). Spending only: a
746
+ * retired key never signs in. Never falls back to another key.
747
+ *
734
748
  * @param info Context/domain-binding label (distinct labels → independent seeds).
735
749
  * @param opts.accountId An organization's account id, for its key.
750
+ * @param opts.publicKey Selects one key of that account by its public key.
736
751
  * @returns 32 bytes of derived keying material, or `null` on web / when no
737
752
  * identity key is available to this app.
738
753
  */
739
754
  static deriveScopedSeed(info: string, opts?: {
740
755
  accountId?: string;
756
+ publicKey?: string;
741
757
  }): Promise<Uint8Array | null>;
742
758
  /**
743
759
  * {@link deriveScopedSeed} over an ORGANIZATION key (ADR 0061 D6): Commons'
@@ -754,7 +770,8 @@ export declare class KeyManager {
754
770
  * compressed public key for the scriptSig.
755
771
  *
756
772
  * Key source: as {@link deriveScopedSeed}, including `opts.accountId` for an
757
- * organization's key (ADR 0061 D6). On Android outside Commons the signature
773
+ * organization's key (ADR 0061 D6) and `opts.publicKey` for one key of the
774
+ * account, current or retired. On Android outside Commons the signature
758
775
  * comes from Commons, which allows it for the wallet app only.
759
776
  *
760
777
  * @returns the signature, or `null` on web / when no identity key is
@@ -762,7 +779,20 @@ export declare class KeyManager {
762
779
  */
763
780
  static signSocialReceive(index: number, digest: string, opts?: {
764
781
  accountId?: string;
782
+ publicKey?: string;
765
783
  }): Promise<CommonsSocialReceiveSignature | null>;
784
+ /**
785
+ * The private key of the account (personal, or `accountId`'s organization)
786
+ * whose public key is `publicKey`, from this app's own storage: the current
787
+ * key when it matches, else the retired ring. iOS and Commons only; `null`
788
+ * for a key this device does not hold. Never another key in its place. @internal
789
+ */
790
+ /**
791
+ * The selector in the identity host's encoding (lowercase uncompressed), so a
792
+ * compressed `#key-1` from a DID or a gateway row selects the same key. @internal
793
+ */
794
+ private static _selectorKey;
795
+ private static _selectedPrivateKey;
766
796
  /**
767
797
  * Get a shortened version of the public key for display
768
798
  * Format: first 8 chars...last 8 chars
@@ -0,0 +1,27 @@
1
+ /** What happened to one item (or pair) in a run. */
2
+ export type KeychainItemMigrationOutcome = 'absent' | 'moved' | 'conflict' | 'unhealthy' | 'failed' | 'deferred';
3
+ export interface KeychainGroupMigrationReport {
4
+ /**
5
+ * - `not-applicable`: not Commons on a split-aware iOS build; nothing to move.
6
+ * - `complete`: nothing secret is left in the shared group.
7
+ * - `incomplete`: something stayed (`conflict`, `unhealthy` or `failed`);
8
+ * retried next launch.
9
+ * - `deferred`: the keychain could not be read (locked); retried on the next
10
+ * storage access.
11
+ */
12
+ state: 'not-applicable' | 'complete' | 'incomplete' | 'deferred';
13
+ /** Outcome per item label (`primary`, `phrase`, `organization-1`, …). */
14
+ items: Record<string, KeychainItemMigrationOutcome>;
15
+ }
16
+ /** Run the migration once. Exported for tests; production code awaits the gate. */
17
+ export declare function runKeychainGroupMigration(): Promise<KeychainGroupMigrationReport>;
18
+ /**
19
+ * The gate every Commons identity-storage access awaits. One run per process
20
+ * (`complete`, `incomplete` and `not-applicable` are kept; `incomplete` is
21
+ * retried on the next launch); a `deferred` run is forgotten, so the next
22
+ * access tries again once the keychain unlocks. Concurrent callers share one
23
+ * run. Never throws.
24
+ */
25
+ export declare function ensureKeychainGroupsMigrated(): Promise<KeychainGroupMigrationReport>;
26
+ /** Forget the memoized run. Tests only. @internal */
27
+ export declare function resetKeychainGroupMigrationForTests(): void;
@@ -0,0 +1,41 @@
1
+ /**
2
+ * The names of every identity item `KeyManager` and `./organizationKeys` keep
3
+ * in `expo-secure-store`, in one place so the access-group migration
4
+ * (`./keychainGroupMigration`) moves exactly what those modules read. Values
5
+ * are stable on-device identifiers: renaming one strands what is stored.
6
+ */
7
+ /** Legacy (pre-v2) and shared-slot keys; the legacy ones have no keychain service. */
8
+ export declare const STORAGE_KEYS: {
9
+ readonly PRIVATE_KEY: "oxy_identity_private_key";
10
+ readonly PUBLIC_KEY: "oxy_identity_public_key";
11
+ readonly BACKUP_PRIVATE_KEY: "oxy_identity_backup_private_key";
12
+ readonly BACKUP_PUBLIC_KEY: "oxy_identity_backup_public_key";
13
+ readonly BACKUP_TIMESTAMP: "oxy_identity_backup_timestamp";
14
+ readonly SHARED_PRIVATE_KEY: "oxy_shared_identity_private_key";
15
+ readonly SHARED_PUBLIC_KEY: "oxy_shared_identity_public_key";
16
+ };
17
+ export declare const V2_PRIMARY_KEYCHAIN_SERVICE = "oxy_identity";
18
+ export declare const V2_BACKUP_KEYCHAIN_SERVICE = "oxy_identity_backup";
19
+ export declare const V2_STORAGE_KEYS: {
20
+ readonly PRIVATE_KEY: "oxy_identity_private_key_v2";
21
+ readonly PUBLIC_KEY: "oxy_identity_public_key_v2";
22
+ readonly BACKUP_PRIVATE_KEY: "oxy_identity_backup_private_key_v2";
23
+ readonly BACKUP_PUBLIC_KEY: "oxy_identity_backup_public_key_v2";
24
+ readonly BACKUP_TIMESTAMP: "oxy_identity_backup_timestamp_v2";
25
+ };
26
+ export declare const RECOVERY_MNEMONIC_KEYCHAIN_SERVICE = "oxy_identity_mnemonic";
27
+ export declare const RECOVERY_MNEMONIC_STORAGE_KEY = "oxy_identity_mnemonic_v1";
28
+ /** Organization keys (ADR 0061 D6). On iOS the pair has no keychain service. */
29
+ export declare const ORGANIZATION_ANDROID_KEYCHAIN_SERVICE = "oxy_org_identity";
30
+ export declare const ORGANIZATION_MNEMONIC_KEYCHAIN_SERVICE = "oxy_org_identity_mnemonic";
31
+ export declare const ORGANIZATION_INDEX_KEYCHAIN_SERVICE = "oxy_org_identity_index";
32
+ export declare const ORGANIZATION_INDEX_STORAGE_KEY = "oxy_org_identity_index_v1";
33
+ export declare const organizationPrivateKeyName: (accountId: string) => string;
34
+ export declare const organizationPublicKeyName: (accountId: string) => string;
35
+ export declare const organizationMnemonicName: (accountId: string) => string;
36
+ /** Whether `value` is an account id an organization key can be stored under. */
37
+ export declare function isOrganizationAccountId(value: string): boolean;
38
+ /** The account ids in a stored organization index; anything malformed is skipped. */
39
+ export declare function parseOrganizationIndex(raw: string | null): string[];
40
+ /** The stored form of an organization index: unique, sorted. */
41
+ export declare function serializeOrganizationIndex(ids: readonly string[]): string;
@@ -8,8 +8,10 @@
8
8
  * other Oxy apps reach it the way they reach the personal identity, by
9
9
  * derivation and never by the key:
10
10
  *
11
- * - iOS: the keychain access group `group.so.oxy.shared`, like the personal
12
- * shared slot. Only Commons writes it.
11
+ * - iOS: the pair in the wallet keychain group (`<TEAM>.so.oxy.wallet`), which
12
+ * only Commons and the Peable wallet hold; the phrase and the index in the
13
+ * identity group, Commons only (docs/identity/ios-keychain-access-groups.md).
14
+ * Only Commons writes them; every other app reads nothing.
13
15
  * - Android: Commons' own expo-secure-store slot, mirrored into the identity
14
16
  * signer store (`./identitySigner`) that Commons' identity host answers
15
17
  * `accountId` requests from. Every other app asks Commons over IPC
@@ -17,11 +19,13 @@
17
19
  *
18
20
  * The phrase is kept only in Commons, on the device that created or imported
19
21
  * it, so an owner can show it to another owner (ADR 0061 D5); it never reaches
20
- * the keychain group, an IPC answer or a log.
22
+ * the wallet or shared group, an IPC answer or a log.
21
23
  *
22
24
  * This module imports nothing from `./keyManager` (which imports it): the
23
25
  * signer store comes from `./identitySigner`'s registry.
24
26
  */
27
+ import { isOrganizationAccountId } from './keychainItems';
28
+ export { isOrganizationAccountId };
25
29
  /** A secp256k1 pair: private key 64 hex, public key lowercase uncompressed (`04…`, 130 hex). */
26
30
  export interface OrganizationKeyPair {
27
31
  privateKey: string;
@@ -35,8 +39,6 @@ export interface GeneratedOrganizationKey extends OrganizationKeyPair {
35
39
  export interface StoredOrganizationKey extends OrganizationKeyPair {
36
40
  mnemonic?: string;
37
41
  }
38
- /** Whether `value` is an account id an organization key can be stored under. */
39
- export declare function isOrganizationAccountId(value: string): boolean;
40
42
  /** True only when both halves are well-formed AND the public key derives from the private one. */
41
43
  export declare function isHealthyOrganizationKeyPair(privateKey: string | null | undefined, publicKey: string | null | undefined): boolean;
42
44
  /**
@@ -55,7 +57,7 @@ export declare function organizationKeyFromPhrase(phrase: string): Promise<Organ
55
57
  /**
56
58
  * Keep an organization key on this device. Commons only: on Android it needs
57
59
  * the identity signer store Commons registers, so the identity host can serve
58
- * it; on iOS only Commons calls it (the keychain group is shared).
60
+ * it; on iOS only Commons holds the groups it writes (any other app throws).
59
61
  *
60
62
  * Refuses a pair whose public key does not derive from its private key, and a
61
63
  * phrase that does not derive the pair. Overwrites whatever this device held for
@@ -64,15 +66,15 @@ export declare function organizationKeyFromPhrase(phrase: string): Promise<Organ
64
66
  export declare function storeOrganizationKey(accountId: string, key: StoredOrganizationKey): Promise<void>;
65
67
  /**
66
68
  * The organization's public key as THIS device can reach it, or `null`.
67
- * iOS: the keychain group. Android: Commons' own slot inside Commons, and
69
+ * iOS: the wallet keychain group (Commons, the Peable wallet). Android: Commons' own slot inside Commons, and
68
70
  * Commons' identity host (`describe` with `accountId`) everywhere else. Compare
69
71
  * it with the organization's DID `#key-1` before receiving (Peable ADR 0002).
70
72
  */
71
73
  export declare function getOrganizationPublicKey(accountId: string): Promise<string | null>;
72
74
  /**
73
75
  * The organization's private key, where this app may hold it: Commons (both
74
- * platforms) and, on iOS, the keychain group. `null` on Android outside
75
- * Commons. Use it to sign within the app only; never transmit it.
76
+ * platforms) and, on iOS, the Peable wallet through the wallet keychain group.
77
+ * `null` everywhere else. Use it to sign within the app only; never transmit it.
76
78
  */
77
79
  export declare function getOrganizationPrivateKey(accountId: string): Promise<string | null>;
78
80
  /** The organization's phrase, if this device created or imported it with one. Commons only. */
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Retired identity keys: the keys an account HAD, kept on the device for
3
+ * spending only (oxy ADR 0061 D7 as amended 2026-11-06, Peable ADR 0002).
4
+ *
5
+ * The Peable wallet derives from the account's identity key (`deriveScopedSeed`
6
+ * for its HD wallet, `signSocialReceive` for its social-receive addresses), and
7
+ * coins keep arriving under a key after it is replaced: payments already
8
+ * broadcast settle against the key they were reserved under, and late payers
9
+ * and `@username` senders hold old addresses. So a personal rotation
10
+ * (`oxy.identity.rotateKey`) and an organization replacement keep the replaced
11
+ * private key here, in a ring per account — `personal`, and one per
12
+ * organization account id — instead of deleting it.
13
+ *
14
+ * A retired key is NEVER a way to sign in, publish, receive a new reservation or
15
+ * rotate: nothing here signs a challenge, and the identity host refuses
16
+ * `proveIdentity` / `describe` for one. It is reached exactly as the current key
17
+ * is, by derivation and never by export, through the `publicKey` selector of
18
+ * `KeyManager.deriveScopedSeed` / `signSocialReceive`:
19
+ *
20
+ * - iOS: the private key in the wallet keychain group (ADR 0062's
21
+ * `spending-key`: Commons and the Peable wallet only), beside the current
22
+ * private keys; the ring's index (public keys and dates only) in the same
23
+ * group, so the wallet can list it. Only Commons writes either.
24
+ * - Android: Commons' own expo-secure-store slot, mirrored into the identity
25
+ * signer store (`./identitySigner`) that Commons' identity host answers
26
+ * `keyPublicKey` requests from (wallet only). Every other app asks over IPC
27
+ * (`loadCommonsIdentityBridge`) and never gets the key.
28
+ *
29
+ * A retired phrase is not kept: the person wrote it down, and a phrase is a
30
+ * second copy of the key that buys the wallet nothing.
31
+ *
32
+ * A key enters the ring only from Commons: the key being replaced (rotation,
33
+ * organization replacement), or a previous recovery phrase the person adds,
34
+ * which Commons accepts only when Oxy's key history names it as one of the
35
+ * account's retired keys. Never into the current slot.
36
+ *
37
+ * Security: a retired key that was compromised is also the attacker's. The
38
+ * ring makes the coins under it spendable from Peable again; it cannot make
39
+ * them safe. A rotation forced by a compromise must be followed at once by the
40
+ * wallet's "move to your current key" sweep, which races the attacker.
41
+ *
42
+ * This module imports nothing from `./keyManager` (which imports it).
43
+ */
44
+ /** Whose ring: the person's (`accountId` absent) or an organization's. */
45
+ export interface RetiredKeySelector {
46
+ /** An organization's account id; absent for the personal ring. */
47
+ accountId?: string;
48
+ }
49
+ /** One retired key as the ring lists it: public data only. */
50
+ export interface RetiredIdentityKey {
51
+ /** Lowercase uncompressed secp256k1 public key (`04…`, 130 hex). */
52
+ publicKey: string;
53
+ /** The organization it was the key of; absent for a personal root. */
54
+ accountId?: string;
55
+ /** Unix milliseconds when this device kept it. */
56
+ retiredAt: number;
57
+ }
58
+ /** A pair to keep in the ring. */
59
+ export interface RetiredKeyPair {
60
+ privateKey: string;
61
+ publicKey: string;
62
+ }
63
+ /** The ring a selector names: `personal`, or the organization's account id. */
64
+ export declare function retiredKeyScope(selector?: RetiredKeySelector): string | null;
65
+ /**
66
+ * Keep `key` in the ring of `selector`'s account. Commons only. Idempotent: a
67
+ * key already kept stays, with its first date. Refuses a pair whose public key
68
+ * does not derive from its private key. Resolves once every copy confirmed it
69
+ * (Android: the signer store's read-back too), and throws otherwise, so a
70
+ * caller about to replace the key can stop before anything is lost.
71
+ */
72
+ export declare function retireIdentityKey(key: RetiredKeyPair, selector?: RetiredKeySelector): Promise<void>;
73
+ /**
74
+ * The retired keys of `selector`'s account this app can reach, oldest first.
75
+ * iOS and Commons: the ring's index. Android elsewhere: Commons' identity host
76
+ * (the wallet only), which leaves out the scope's current key. After an
77
+ * interrupted rotation the local list can include the current key: compare
78
+ * with the DID's `#key-1` before treating an entry as retired.
79
+ */
80
+ export declare function listRetiredIdentityKeys(selector?: RetiredKeySelector): Promise<RetiredIdentityKey[]>;
81
+ /**
82
+ * The private half of a retired key, where this app may hold it: Commons (both
83
+ * platforms) and, on iOS, the keychain group. `null` on Android outside Commons
84
+ * (ask the identity host instead) and for a key not in the ring. Sign within
85
+ * the app only; never transmit it. @internal — `KeyManager` reads it for the
86
+ * `publicKey` selector.
87
+ */
88
+ export declare function getRetiredPrivateKey(publicKey: string, selector?: RetiredKeySelector): Promise<string | null>;
89
+ /**
90
+ * Forget a retired key on this device: its private half, its signer copy and
91
+ * its index entry. Commons only, and only on the person's explicit request
92
+ * (their coins under it are then spendable only with its phrase again).
93
+ */
94
+ export declare function forgetRetiredIdentityKey(publicKey: string, selector?: RetiredKeySelector): Promise<void>;
95
+ /**
96
+ * Forget every retired key of one ring (the person's, or an organization's):
97
+ * called when the identity itself is deleted from this device, so a phone
98
+ * handed on keeps none of the person's old keys. Commons only. Resolves how
99
+ * many it removed.
100
+ */
101
+ export declare function forgetRetiredIdentityKeys(selector?: RetiredKeySelector): Promise<number>;
102
+ /**
103
+ * Commons on Android: copy every retired key into the identity signer store
104
+ * again, so the identity host answers for it after that file was rebuilt (its
105
+ * self-heal leaves it empty). Call on every boot, beside `syncOrganizationKeys`.
106
+ * Resolves how many it mirrored. Elsewhere 0.
107
+ */
108
+ export declare function syncRetiredKeys(): Promise<number>;
109
+ /**
110
+ * The pair a previous recovery phrase derives (`seed[0:32]`, the personal-root
111
+ * and organization-key derivation; 12–24 words). Stores nothing: the caller
112
+ * checks the public key against the account's key history (personal:
113
+ * `oxy.identity.keyHistory()`; organization: `oxy.accounts.identityKey.get`)
114
+ * and only then calls {@link retireIdentityKey}.
115
+ */
116
+ export declare function retiredKeyFromPhrase(phrase: string): Promise<RetiredKeyPair>;
@@ -0,0 +1,15 @@
1
+ export interface OxySecureStoreOptions {
2
+ /** Keychain service name (Android keystore alias / iOS `kSecAttrService`). */
3
+ keychainService?: string;
4
+ /** Item accessibility: the `ExpoSecureStoreLike` constant, e.g. `WHEN_UNLOCKED_THIS_DEVICE_ONLY`. */
5
+ keychainAccessible?: number;
6
+ /** Prompt shown for biometric-protected items. */
7
+ authenticationPrompt?: string;
8
+ /** Whether Face ID / Touch ID is required to read the item. */
9
+ requireAuthentication?: boolean;
10
+ /**
11
+ * iOS `kSecAttrAccessGroup`: the FULL, team-prefixed entitlement value. Set
12
+ * only through `../utils/keychainAccessGroups`, never by hand.
13
+ */
14
+ accessGroup?: string;
15
+ }
@@ -29,6 +29,8 @@ export { createWebAuthStateStore, createNativeAuthStateStore, createMemoryAuthSt
29
29
  export type { PersistedAuthState, AuthStateStore, AuthStateWriteGuard, NativeKeyValueStorage, } from './authStateStore';
30
30
  export { createSharedMirroringAuthStateStore, decideSharedDeviceJoin, decideSharedDevicePublish, normalizeSharedDeviceSessionRead, publishProvenDeviceCredential, readLocalDeviceCredential, } from './sharedDeviceCredential';
31
31
  export type { SharedDeviceCredential, SharedDeviceCredentialRead, SharedDeviceCredentialStore, SharedDeviceJoinDecision, SharedDeviceJoinSkipReason, SharedDevicePublishDecision, SharedDevicePublishOutcome, SharedDevicePublishSkipReason, } from './sharedDeviceCredential';
32
+ export { KeychainAccessUnavailableError, keychainAccessGroupValue, ownKeychainItemPlan, readThroughPlan, deleteThroughPlan, resolveKeychainAccess, } from '../utils/keychainAccessGroups';
33
+ export type { KeychainAccess, KeychainGroupOption } from '../utils/keychainAccessGroups';
32
34
  export { createWebIdentityPinStore, createNativeIdentityPinStore, createMemoryIdentityPinStore, identityPinMatches, IDENTITY_PIN_STORAGE_KEY, } from './identityPin';
33
35
  export type { IdentityPin, IdentityPinStore } from './identityPin';
34
36
  export { resolveIdentityPin, establishIdentitySession, } from './identitySession';
@@ -0,0 +1,104 @@
1
+ /** The group names after the team prefix, exactly as the entitlements list them. */
2
+ export declare const KEYCHAIN_ACCESS_GROUPS: {
3
+ readonly identity: "so.oxy.identity";
4
+ readonly wallet: "so.oxy.wallet";
5
+ readonly shared: "group.so.oxy.shared";
6
+ };
7
+ export type KeychainAccessGroupName = keyof typeof KEYCHAIN_ACCESS_GROUPS;
8
+ /** What a keychain item IS, which decides its group. See the table above. */
9
+ export type KeychainItemClass = 'identity-secret' | 'spending-key' | 'shared';
10
+ /** Which holder this binary is, from its default access group. */
11
+ export type KeychainRole = 'holder' | 'wallet' | 'member';
12
+ /**
13
+ * - `grouped`: iOS with the probe answered. `prefix` ends with its dot
14
+ * (`ABCDE12345.`); `defaultGroup` is the full default group string.
15
+ * - `ungrouped`: Android, web, or an iOS native build without the probe.
16
+ */
17
+ export type KeychainAccess = {
18
+ mode: 'grouped';
19
+ prefix: string;
20
+ defaultGroup: string;
21
+ role: KeychainRole;
22
+ } | {
23
+ mode: 'ungrouped';
24
+ };
25
+ /** The `expo-secure-store` option this module sets (its real name since 58). */
26
+ export interface KeychainGroupOption {
27
+ accessGroup?: string;
28
+ }
29
+ /** Thrown while the probe cannot answer (before first unlock). Transient. */
30
+ export declare class KeychainAccessUnavailableError extends Error {
31
+ readonly cause?: unknown | undefined;
32
+ readonly name = "KeychainAccessUnavailableError";
33
+ constructor(cause?: unknown | undefined);
34
+ }
35
+ /**
36
+ * The single place each item class is assigned a group. A new kind of item
37
+ * (F1's retained keys, for one) picks its class and reuses everything below.
38
+ */
39
+ export declare function keychainGroupFor(item: KeychainItemClass): KeychainAccessGroupName;
40
+ /** Whether `role` holds the group `item` lives in. */
41
+ export declare function mayHoldKeychainItem(role: KeychainRole, item: KeychainItemClass): boolean;
42
+ /** Parse the default access group the probe reported. Exported for tests. */
43
+ export declare function keychainAccessFromDefaultGroup(defaultGroup: string): KeychainAccess;
44
+ /**
45
+ * This binary's keychain access, memoized once known. THROWS
46
+ * {@link KeychainAccessUnavailableError} while the probe cannot answer, which
47
+ * every caller already treats as "storage unavailable, retry".
48
+ */
49
+ export declare function resolveKeychainAccess(): Promise<KeychainAccess>;
50
+ /** Forget the memoized access. Tests only. @internal */
51
+ export declare function resetKeychainAccessForTests(): void;
52
+ /** The full `kSecAttrAccessGroup` value of `group`, or `undefined` when ungrouped. */
53
+ export declare function keychainAccessGroupValue(access: KeychainAccess, group: KeychainAccessGroupName): string | undefined;
54
+ /**
55
+ * The option bags a READ of `item` tries, in order: its own group, then the
56
+ * shared group, where every item lived before the split (release 4 of the doc
57
+ * drops that fallback). Empty when this app may not hold the item: the caller
58
+ * answers `null` without touching the keychain.
59
+ */
60
+ export declare function keychainReadPlan(access: KeychainAccess, item: KeychainItemClass): KeychainGroupOption[];
61
+ /**
62
+ * Every place a DELETE of `item` must reach: the read plan, so a delete never
63
+ * leaves a pre-split copy behind in the shared group.
64
+ */
65
+ export declare function keychainDeletePlan(access: KeychainAccess, item: KeychainItemClass): KeychainGroupOption[];
66
+ /**
67
+ * The option a WRITE of `item` carries. THROWS when this app may not hold the
68
+ * item: an identity secret written by any app but Commons would land in that
69
+ * app's default group, a second holder nobody knows about.
70
+ */
71
+ export declare function keychainWriteOption(access: KeychainAccess, item: KeychainItemClass): KeychainGroupOption;
72
+ /**
73
+ * The plan for an app's OWN items (its session credential, its settings, an
74
+ * imported wallet's phrase) in Commons and the Peable wallet, whose default
75
+ * group the split changed: written to the default group explicitly — where a
76
+ * write without a group goes anyway — and read from it FIRST, then from every
77
+ * group (`{}`), where all of them were written before. Without the explicit
78
+ * order a read could return whichever of two copies the keychain met first.
79
+ * Deletes reach every group, as they always did.
80
+ *
81
+ * Every other app (`member`) and Android/web keep the calls they always made.
82
+ */
83
+ export declare function ownKeychainItemPlan(access: KeychainAccess): {
84
+ read: KeychainGroupOption[];
85
+ write: KeychainGroupOption;
86
+ delete: KeychainGroupOption[];
87
+ };
88
+ /** The subset of `expo-secure-store` a grouped store wraps. */
89
+ export interface KeychainStoreLike {
90
+ getItemAsync(key: string, options?: object): Promise<string | null>;
91
+ setItemAsync(key: string, value: string, options?: object): Promise<void>;
92
+ deleteItemAsync(key: string, options?: object): Promise<void>;
93
+ }
94
+ /** Read `key` through `plan`: the first non-null answer. A thrown read propagates. */
95
+ export declare function readThroughPlan(store: KeychainStoreLike, key: string, options: object, plan: readonly KeychainGroupOption[]): Promise<string | null>;
96
+ /** Delete `key` from every location in `plan`. A thrown delete propagates. */
97
+ export declare function deleteThroughPlan(store: KeychainStoreLike, key: string, options: object, plan: readonly KeychainGroupOption[]): Promise<void>;
98
+ /**
99
+ * `store` with every call routed to `item`'s groups for this app: reads walk
100
+ * the read plan, writes carry the write option (throwing for a role that may
101
+ * not hold the item), deletes reach every location. The constants of the
102
+ * wrapped store are kept, so `store.WHEN_UNLOCKED_THIS_DEVICE_ONLY` still reads.
103
+ */
104
+ 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.28.0",
3
+ "version": "7.30.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",
@@ -181,8 +181,8 @@
181
181
  "dependencies": {
182
182
  "@noble/ciphers": "^2.4.0",
183
183
  "@noble/hashes": "^2.4.0",
184
- "@oxy.so/contracts": "^6.11.0",
185
- "@oxy.so/protocol": "^1.7.0",
184
+ "@oxy.so/contracts": "^6.12.0",
185
+ "@oxy.so/protocol": "^1.9.0",
186
186
  "@oxy.so/telemetry": "^2.1.0",
187
187
  "@scure/bip39": "^2.4.0",
188
188
  "buffer": "^6.0.3",
@@ -23,6 +23,7 @@ import {
23
23
  identityLinkCreateResponseCheck,
24
24
  identityLinkStateCheck,
25
25
  } from '@oxy.so/contracts/client';
26
+ import { identityKeyHistoryCheck, type IdentityKeyHistory } from '@oxy.so/contracts/client';
26
27
  import type {
27
28
  AuthMethodsResponse,
28
29
  BackupStatusResponse,
@@ -283,9 +284,8 @@ export class IdentityApi {
283
284
  throw new Error('No authenticated user — sign in before rotating your key.');
284
285
  }
285
286
 
286
- const { KeyManager, RecoveryPhraseService, SignatureService, signMessage } = await import(
287
- '../crypto/index'
288
- );
287
+ const { KeyManager, RecoveryPhraseService, SignatureService, signMessage, retireIdentityKey } =
288
+ await import('../crypto/index');
289
289
 
290
290
  // 1. The NEW identity (in memory only). The UI may pre-derive + pre-show
291
291
  // it and pass it back here so the phrase shown === the phrase committed.
@@ -296,6 +296,7 @@ export class IdentityApi {
296
296
  // 2. Resolve the OLD signing capability from the chosen proof mode.
297
297
  let oldPublicKey: string;
298
298
  let signWithOldKey: (message: string) => Promise<string>;
299
+ let oldPrivateKeyForRing: string | null;
299
300
  if (options.proof === 'phrase') {
300
301
  const phrase = options.phrase?.trim();
301
302
  if (!phrase) {
@@ -304,6 +305,7 @@ export class IdentityApi {
304
305
  const oldPrivateKey = await RecoveryPhraseService.derivePrivateKeyFromPhrase(phrase);
305
306
  oldPublicKey = KeyManager.derivePublicKey(oldPrivateKey);
306
307
  signWithOldKey = (message) => signMessage(message, oldPrivateKey);
308
+ oldPrivateKeyForRing = oldPrivateKey;
307
309
  } else {
308
310
  const currentPublicKey = await KeyManager.getPublicKey();
309
311
  if (!currentPublicKey) {
@@ -313,6 +315,30 @@ export class IdentityApi {
313
315
  }
314
316
  oldPublicKey = currentPublicKey;
315
317
  signWithOldKey = (message) => SignatureService.sign(message);
318
+ oldPrivateKeyForRing = isWeb() ? null : await KeyManager.getPrivateKey();
319
+ }
320
+
321
+ // 2b. Keep the key being replaced in the retired ring BEFORE anything
322
+ // changes (oxy ADR 0061 D7 as amended, Peable ADR 0002): coins keep
323
+ // arriving under it after the swap, and the Peable wallet spends them
324
+ // through the `publicKey` selector of `deriveScopedSeed` /
325
+ // `signSocialReceive`. A device that cannot keep it does not rotate:
326
+ // the phrase would become the only way to those coins. Native only —
327
+ // on web no key is stored. Idempotent, so a retried rotation is fine.
328
+ if (!isWeb()) {
329
+ try {
330
+ if (!oldPrivateKeyForRing) throw new Error('no readable private key');
331
+ await retireIdentityKey({ privateKey: oldPrivateKeyForRing, publicKey: oldPublicKey });
332
+ } catch (retainError) {
333
+ logger.warn(
334
+ 'Could not keep the current key as a retired key; not rotating.',
335
+ { component: 'oxy.identity', method: 'rotateKey' },
336
+ retainError,
337
+ );
338
+ throw new Error(
339
+ 'Your current key could not be kept on this device, so it was not rotated. Nothing changed.',
340
+ );
341
+ }
316
342
  }
317
343
 
318
344
  // 3. Request a single-use rotate_key challenge (bearer).
@@ -502,6 +528,23 @@ export class IdentityApi {
502
528
  if (userId) keys.push(`GET:/u/${encodeURIComponent(userId)}/did.json`);
503
529
  this.ctx.http.invalidateCache({ keys, prefixes: ['GET:/users/me'] });
504
530
  }
531
+
532
+ /**
533
+ * The signed-in personal account's current root (`null` without one) and
534
+ * every root a rotation replaced, oldest first, all canonical (uncompressed,
535
+ * lowercase). Commons reads it to accept a previous recovery phrase only when
536
+ * it derives a key that WAS this account's root. A retired root is never any
537
+ * account's again. First-party only; a non-personal account is refused
538
+ * (`IDENTITY_NOT_PERSONAL_ACCOUNT`).
539
+ */
540
+ async keyHistory(): Promise<IdentityKeyHistory> {
541
+ const res = await this.ctx.request<unknown>('GET', '/identity/key-history', undefined, {
542
+ cache: false,
543
+ });
544
+ const parsed = checkContract(identityKeyHistoryCheck, res);
545
+ if (!parsed) throw new Error('identity/key-history returned an unexpected response shape');
546
+ return parsed;
547
+ }
505
548
  }
506
549
 
507
550
  /** `oxy.identity.links` — joining Commons' root to an account (ADR 0029 D3). */
@@ -34,6 +34,29 @@ export interface IdentitySignerStore {
34
34
  writeOrganization?(accountId: string, privateKey: string, publicKey: string): Promise<boolean>;
35
35
  /** Drop that organization's pair. */
36
36
  clearOrganization?(accountId: string): Promise<void>;
37
+ /**
38
+ * The RETIRED key ring: keys a scope rotated away from (a personal rotation,
39
+ * or an organization key replacement, ADR 0061 D7), kept so the identity host
40
+ * can still derive and sign with them for the wallet when a request names
41
+ * their public key. Spending only: the host never signs anyone in with one.
42
+ *
43
+ * `scope` is `'personal'` or an organization's account id (24-hex or uuid);
44
+ * `publicKey` is the retired key's lowercase uncompressed SEC1 hex (130),
45
+ * which names its entry. The native side refuses anything else. Clearing the
46
+ * personal pair ({@link clear}) or an organization's never drops a ring.
47
+ * Optional so a store built before retired keys still type-checks; Commons
48
+ * offers all four or none.
49
+ */
50
+ readRetired?(
51
+ scope: string,
52
+ publicKey: string,
53
+ ): Promise<{ privateKey: string; publicKey: string } | null>;
54
+ /** Keep a retired pair in `scope`'s ring. Resolves whether a read-back confirmed it. */
55
+ writeRetired?(scope: string, privateKey: string, publicKey: string): Promise<boolean>;
56
+ /** The public keys in `scope`'s ring (healthy entries only, sorted); `[]` when none. */
57
+ listRetired?(scope: string): Promise<string[]>;
58
+ /** Drop one retired key from `scope`'s ring. */
59
+ clearRetired?(scope: string, publicKey: string): Promise<void>;
37
60
  }
38
61
 
39
62
  /**
@@ -45,6 +45,46 @@ export type {
45
45
  GeneratedOrganizationKey,
46
46
  StoredOrganizationKey,
47
47
  } from './organizationKeys';
48
+ // iOS keychain access groups (docs/identity/ios-keychain-access-groups.md, ADR 0062)
49
+ export {
50
+ KEYCHAIN_ACCESS_GROUPS,
51
+ KeychainAccessUnavailableError,
52
+ keychainGroupFor,
53
+ mayHoldKeychainItem,
54
+ resolveKeychainAccess,
55
+ keychainAccessGroupValue,
56
+ keychainReadPlan,
57
+ keychainDeletePlan,
58
+ keychainWriteOption,
59
+ ownKeychainItemPlan,
60
+ readThroughPlan,
61
+ deleteThroughPlan,
62
+ groupedKeychainStore,
63
+ } from '../utils/keychainAccessGroups';
64
+ export type {
65
+ KeychainAccess,
66
+ KeychainAccessGroupName,
67
+ KeychainGroupOption,
68
+ KeychainItemClass,
69
+ KeychainRole,
70
+ KeychainStoreLike,
71
+ } from '../utils/keychainAccessGroups';
72
+ export { ensureKeychainGroupsMigrated } from './keychainGroupMigration';
73
+ export type {
74
+ KeychainGroupMigrationReport,
75
+ KeychainItemMigrationOutcome,
76
+ } from './keychainGroupMigration';
77
+ // Retired identity keys: kept for spending after a rotation or replacement
78
+ // (docs/adr/0061-organization-identity-key.md D7 as amended 2026-11-06)
79
+ export {
80
+ retireIdentityKey,
81
+ listRetiredIdentityKeys,
82
+ forgetRetiredIdentityKey,
83
+ forgetRetiredIdentityKeys,
84
+ syncRetiredKeys,
85
+ retiredKeyFromPhrase,
86
+ } from './retiredKeys';
87
+ export type { RetiredIdentityKey, RetiredKeyPair, RetiredKeySelector } from './retiredKeys';
48
88
  export { SignatureService } from './signatureService';
49
89
  export type { SignedMessage, AuthChallenge } from './signatureService';
50
90
  export { RecoveryPhraseService, BACKUP_KDF_ENCRYPTION_INFO } from './recoveryPhrase';