@oxy.so/core 7.28.0 → 7.29.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.
- package/dist/cjs/crypto/index.js +19 -1
- package/dist/cjs/crypto/keyManager.js +106 -106
- package/dist/cjs/crypto/keychainGroupMigration.js +353 -0
- package/dist/cjs/crypto/keychainItems.js +70 -0
- package/dist/cjs/crypto/organizationKeys.js +80 -72
- package/dist/cjs/crypto/secureStoreOptions.js +2 -0
- package/dist/cjs/session/index.js +11 -1
- package/dist/cjs/utils/keychainAccessGroups.js +248 -0
- package/dist/esm/crypto/index.js +3 -0
- package/dist/esm/crypto/keyManager.js +63 -63
- package/dist/esm/crypto/keychainGroupMigration.js +348 -0
- package/dist/esm/crypto/keychainItems.js +61 -0
- package/dist/esm/crypto/organizationKeys.js +54 -46
- package/dist/esm/crypto/secureStoreOptions.js +1 -0
- package/dist/esm/session/index.js +3 -0
- package/dist/esm/utils/keychainAccessGroups.js +231 -0
- package/dist/types/crypto/index.d.ts +4 -0
- package/dist/types/crypto/keyManager.d.ts +9 -1
- package/dist/types/crypto/keychainGroupMigration.d.ts +27 -0
- package/dist/types/crypto/keychainItems.d.ts +41 -0
- package/dist/types/crypto/organizationKeys.d.ts +11 -9
- package/dist/types/crypto/secureStoreOptions.d.ts +15 -0
- package/dist/types/session/index.d.ts +2 -0
- package/dist/types/utils/keychainAccessGroups.d.ts +104 -0
- package/package.json +2 -2
- package/src/crypto/index.ts +29 -0
- package/src/crypto/keyManager.ts +84 -99
- package/src/crypto/keychainGroupMigration.ts +440 -0
- package/src/crypto/keychainItems.ts +73 -0
- package/src/crypto/organizationKeys.ts +72 -57
- package/src/crypto/secureStoreOptions.ts +32 -0
- package/src/session/index.ts +11 -0
- package/src/utils/keychainAccessGroups.ts +308 -0
- package/src/types/expo-secure-store.d.ts +0 -21
|
@@ -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.
|
|
3
|
+
"version": "7.29.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",
|
|
@@ -182,7 +182,7 @@
|
|
|
182
182
|
"@noble/ciphers": "^2.4.0",
|
|
183
183
|
"@noble/hashes": "^2.4.0",
|
|
184
184
|
"@oxy.so/contracts": "^6.11.0",
|
|
185
|
-
"@oxy.so/protocol": "^1.
|
|
185
|
+
"@oxy.so/protocol": "^1.8.0",
|
|
186
186
|
"@oxy.so/telemetry": "^2.1.0",
|
|
187
187
|
"@scure/bip39": "^2.4.0",
|
|
188
188
|
"buffer": "^6.0.3",
|
package/src/crypto/index.ts
CHANGED
|
@@ -45,6 +45,35 @@ 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';
|
|
48
77
|
export { SignatureService } from './signatureService';
|
|
49
78
|
export type { SignedMessage, AuthChallenge } from './signatureService';
|
|
50
79
|
export { RecoveryPhraseService, BACKUP_KDF_ENCRYPTION_INFO } from './recoveryPhrase';
|
package/src/crypto/keyManager.ts
CHANGED
|
@@ -32,6 +32,21 @@ 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 {
|
|
36
|
+
type KeychainItemClass,
|
|
37
|
+
groupedKeychainStore,
|
|
38
|
+
resolveKeychainAccess,
|
|
39
|
+
} from '../utils/keychainAccessGroups';
|
|
40
|
+
import { ensureKeychainGroupsMigrated } from './keychainGroupMigration';
|
|
41
|
+
import {
|
|
42
|
+
RECOVERY_MNEMONIC_KEYCHAIN_SERVICE,
|
|
43
|
+
RECOVERY_MNEMONIC_STORAGE_KEY,
|
|
44
|
+
STORAGE_KEYS,
|
|
45
|
+
V2_BACKUP_KEYCHAIN_SERVICE,
|
|
46
|
+
V2_PRIMARY_KEYCHAIN_SERVICE,
|
|
47
|
+
V2_STORAGE_KEYS,
|
|
48
|
+
} from './keychainItems';
|
|
49
|
+
import type { OxySecureStoreOptions } from './secureStoreOptions';
|
|
35
50
|
import {
|
|
36
51
|
type IdentityMarker,
|
|
37
52
|
clearIdentityMarker,
|
|
@@ -53,37 +68,6 @@ import {
|
|
|
53
68
|
*/
|
|
54
69
|
const DEVICE_BACKUP_TOMBSTONE = JSON.stringify({ deleted: true });
|
|
55
70
|
|
|
56
|
-
/**
|
|
57
|
-
* Options for expo-secure-store calls made by KeyManager.
|
|
58
|
-
*
|
|
59
|
-
* Defined as a standalone interface (not extending expo-secure-store's
|
|
60
|
-
* `SecureStoreOptions`) so that server / Node.js consumers of @oxy.so/core
|
|
61
|
-
* do not transitively pull in expo-modules-core's type declarations under
|
|
62
|
-
* NodeNext module resolution (which would cause NodeJS.Timeout / number
|
|
63
|
-
* pollution across all timer APIs). The fields mirror SecureStoreOptions
|
|
64
|
-
* exactly — any object satisfying this interface is safe to pass to the
|
|
65
|
-
* real expo-secure-store methods (which accept `options?: object`).
|
|
66
|
-
*/
|
|
67
|
-
interface OxySecureStoreOptions {
|
|
68
|
-
/** Keychain service name (Android keystore / iOS keychain tag). */
|
|
69
|
-
keychainService?: string;
|
|
70
|
-
/**
|
|
71
|
-
* Keychain / Keystore item accessibility. Use the constants from the
|
|
72
|
-
* `ExpoSecureStoreLike` handle returned by `initSecureStore()`, e.g.
|
|
73
|
-
* `store.WHEN_UNLOCKED_THIS_DEVICE_ONLY`.
|
|
74
|
-
*/
|
|
75
|
-
keychainAccessible?: number;
|
|
76
|
-
/** Prompt shown to the user for biometric/auth-protected items. */
|
|
77
|
-
authenticationPrompt?: string;
|
|
78
|
-
/** Whether Face ID / Touch ID / biometric auth is required to read the item. */
|
|
79
|
-
requireAuthentication?: boolean;
|
|
80
|
-
/**
|
|
81
|
-
* iOS Keychain access group. Required for sharing identity material across
|
|
82
|
-
* apps in the Oxy ecosystem via Keychain Sharing entitlements.
|
|
83
|
-
*/
|
|
84
|
-
keychainAccessGroup?: string;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
71
|
/**
|
|
88
72
|
* Thrown when an identity-mutating operation (createIdentity / importKeyPair)
|
|
89
73
|
* is invoked while a valid identity already exists on the device.
|
|
@@ -181,17 +165,6 @@ export type IdentityRecoveryResult =
|
|
|
181
165
|
| { recovered: true; source: 'backup' | 'shared' | 'device-backup'; publicKey: string }
|
|
182
166
|
| { recovered: false; reason: 'not-lost' | 'no-sources' | 'mismatch' | 'unavailable' };
|
|
183
167
|
|
|
184
|
-
const STORAGE_KEYS = {
|
|
185
|
-
PRIVATE_KEY: 'oxy_identity_private_key',
|
|
186
|
-
PUBLIC_KEY: 'oxy_identity_public_key',
|
|
187
|
-
BACKUP_PRIVATE_KEY: 'oxy_identity_backup_private_key',
|
|
188
|
-
BACKUP_PUBLIC_KEY: 'oxy_identity_backup_public_key',
|
|
189
|
-
BACKUP_TIMESTAMP: 'oxy_identity_backup_timestamp',
|
|
190
|
-
// The cross-app identity in the iOS keychain access group (iOS only).
|
|
191
|
-
SHARED_PRIVATE_KEY: 'oxy_shared_identity_private_key',
|
|
192
|
-
SHARED_PUBLIC_KEY: 'oxy_shared_identity_public_key',
|
|
193
|
-
} as const;
|
|
194
|
-
|
|
195
168
|
/**
|
|
196
169
|
* v2 identity slot layout — blast-radius isolation.
|
|
197
170
|
*
|
|
@@ -207,16 +180,8 @@ const STORAGE_KEYS = {
|
|
|
207
180
|
* Migration from the legacy layout is lazy + verify-before-delete — see
|
|
208
181
|
* {@link KeyManager._runSlotMigration}.
|
|
209
182
|
*/
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
const V2_STORAGE_KEYS = {
|
|
214
|
-
PRIVATE_KEY: 'oxy_identity_private_key_v2',
|
|
215
|
-
PUBLIC_KEY: 'oxy_identity_public_key_v2',
|
|
216
|
-
BACKUP_PRIVATE_KEY: 'oxy_identity_backup_private_key_v2',
|
|
217
|
-
BACKUP_PUBLIC_KEY: 'oxy_identity_backup_public_key_v2',
|
|
218
|
-
BACKUP_TIMESTAMP: 'oxy_identity_backup_timestamp_v2',
|
|
219
|
-
} as const;
|
|
183
|
+
// The names (V2_PRIMARY_KEYCHAIN_SERVICE, V2_BACKUP_KEYCHAIN_SERVICE,
|
|
184
|
+
// V2_STORAGE_KEYS) live in `./keychainItems`, beside every other identity item.
|
|
220
185
|
|
|
221
186
|
/**
|
|
222
187
|
* Dedicated keychain slot for the recovery mnemonic (the 12-word phrase).
|
|
@@ -234,8 +199,7 @@ const V2_STORAGE_KEYS = {
|
|
|
234
199
|
* lives ONLY in this slot: it is never mirrored into the identity marker,
|
|
235
200
|
* {@link KeyManager.getIdentityStatus}, logs, or any exported bundle.
|
|
236
201
|
*/
|
|
237
|
-
|
|
238
|
-
const RECOVERY_MNEMONIC_STORAGE_KEY = 'oxy_identity_mnemonic_v1';
|
|
202
|
+
// RECOVERY_MNEMONIC_KEYCHAIN_SERVICE / RECOVERY_MNEMONIC_STORAGE_KEY: `./keychainItems`.
|
|
239
203
|
|
|
240
204
|
/**
|
|
241
205
|
* Advisory AsyncStorage fast-path flag: set once the v2 slots own the identity.
|
|
@@ -301,13 +265,6 @@ type SlotMigrationResult =
|
|
|
301
265
|
| { mode: 'legacy'; layout: ResolvedSlotLayout }
|
|
302
266
|
| { mode: 'deferred'; cause: unknown };
|
|
303
267
|
|
|
304
|
-
/**
|
|
305
|
-
* iOS Keychain Access Group for sharing identities across Oxy apps
|
|
306
|
-
* All Oxy apps must have this access group enabled in their entitlements
|
|
307
|
-
* Format: [Team ID].so.oxy.shared or group.so.oxy.shared
|
|
308
|
-
*/
|
|
309
|
-
const IOS_KEYCHAIN_GROUP = 'group.so.oxy.shared';
|
|
310
|
-
|
|
311
268
|
/**
|
|
312
269
|
* Initialize React Native specific modules
|
|
313
270
|
*
|
|
@@ -317,7 +274,7 @@ const IOS_KEYCHAIN_GROUP = 'group.so.oxy.shared';
|
|
|
317
274
|
* `expo-secure-store`; off RN it throws (and is never called because every
|
|
318
275
|
* caller is gated by `isWebPlatform()` / native-only paths).
|
|
319
276
|
*/
|
|
320
|
-
async function
|
|
277
|
+
async function loadRawSecureStore(): Promise<ExpoSecureStoreLike> {
|
|
321
278
|
try {
|
|
322
279
|
return await loadSecureStore();
|
|
323
280
|
} catch (error) {
|
|
@@ -328,6 +285,31 @@ async function initSecureStore(): Promise<ExpoSecureStoreLike> {
|
|
|
328
285
|
}
|
|
329
286
|
}
|
|
330
287
|
|
|
288
|
+
/**
|
|
289
|
+
* The secure store routed to the keychain group of `item`
|
|
290
|
+
* (`../utils/keychainAccessGroups`): on iOS reads walk the item's group then
|
|
291
|
+
* the pre-split shared group, writes carry the item's group and THROW in an app
|
|
292
|
+
* that may not hold it, and in Commons every access first awaits the
|
|
293
|
+
* access-group migration. Android and web are unchanged (`ungrouped`).
|
|
294
|
+
*
|
|
295
|
+
* Throws when the store cannot load or the keychain cannot say which groups
|
|
296
|
+
* this app holds; every caller already treats a throw here as "storage
|
|
297
|
+
* unavailable".
|
|
298
|
+
*/
|
|
299
|
+
async function keychainItemStore(item: KeychainItemClass): Promise<ExpoSecureStoreLike> {
|
|
300
|
+
const raw = await loadRawSecureStore();
|
|
301
|
+
const access = await resolveKeychainAccess();
|
|
302
|
+
if (access.mode === 'grouped' && access.role === 'holder') {
|
|
303
|
+
await ensureKeychainGroupsMigrated();
|
|
304
|
+
}
|
|
305
|
+
return groupedKeychainStore(raw, access, item);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** The store for the identity slots (primary, backup, legacy, phrase): Commons only on iOS. */
|
|
309
|
+
function initSecureStore(): Promise<ExpoSecureStoreLike> {
|
|
310
|
+
return keychainItemStore('identity-secret');
|
|
311
|
+
}
|
|
312
|
+
|
|
331
313
|
/**
|
|
332
314
|
* Check if we're on web platform
|
|
333
315
|
* Identity storage is only available on native platforms (iOS/Android)
|
|
@@ -875,13 +857,15 @@ export class KeyManager {
|
|
|
875
857
|
* Android signer store is cleared on every delete, in {@link deleteIdentity}.
|
|
876
858
|
*/
|
|
877
859
|
private static async _clearSharedSlot(
|
|
878
|
-
|
|
860
|
+
_identityStore: Awaited<ReturnType<typeof initSecureStore>>,
|
|
879
861
|
): Promise<void> {
|
|
880
862
|
try {
|
|
881
863
|
if (isIOS()) {
|
|
882
|
-
|
|
883
|
-
await
|
|
884
|
-
|
|
864
|
+
// The shared slot lives in two groups of its own, not the identity slots'.
|
|
865
|
+
await (await keychainItemStore('spending-key')).deleteItemAsync(
|
|
866
|
+
STORAGE_KEYS.SHARED_PRIVATE_KEY,
|
|
867
|
+
);
|
|
868
|
+
await (await keychainItemStore('shared')).deleteItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY);
|
|
885
869
|
}
|
|
886
870
|
KeyManager.invalidateSharedCache();
|
|
887
871
|
} catch (error) {
|
|
@@ -964,6 +948,25 @@ export class KeyManager {
|
|
|
964
948
|
KeyManager.cachedHasSharedIdentity = null;
|
|
965
949
|
}
|
|
966
950
|
|
|
951
|
+
/**
|
|
952
|
+
* iOS: write the personal key's cross-app slot — the private key to the
|
|
953
|
+
* wallet group (Commons and the Peable wallet), the public key to the shared
|
|
954
|
+
* group (every Oxy app). Throws in any app that may not hold the private key.
|
|
955
|
+
* @internal
|
|
956
|
+
*/
|
|
957
|
+
private static async _writeIosSharedSlot(privateKey: string, publicKey: string): Promise<void> {
|
|
958
|
+
const privateStore = await keychainItemStore('spending-key');
|
|
959
|
+
const privateOpts: OxySecureStoreOptions = {
|
|
960
|
+
keychainAccessible: privateStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
|
|
961
|
+
};
|
|
962
|
+
await privateStore.setItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY, privateKey, privateOpts);
|
|
963
|
+
await (await keychainItemStore('shared')).setItemAsync(
|
|
964
|
+
STORAGE_KEYS.SHARED_PUBLIC_KEY,
|
|
965
|
+
publicKey,
|
|
966
|
+
{},
|
|
967
|
+
);
|
|
968
|
+
}
|
|
969
|
+
|
|
967
970
|
/**
|
|
968
971
|
* Lowercase and pad to canonical 64-hex-char form.
|
|
969
972
|
*
|
|
@@ -1002,8 +1005,10 @@ export class KeyManager {
|
|
|
1002
1005
|
// ==================== SHARED IDENTITY METHODS ====================
|
|
1003
1006
|
// One Oxy identity across every Oxy app on a device.
|
|
1004
1007
|
//
|
|
1005
|
-
// iOS:
|
|
1006
|
-
//
|
|
1008
|
+
// iOS: keychain access groups (docs/identity/ios-keychain-access-groups.md).
|
|
1009
|
+
// The private key is in `<TEAM>.so.oxy.wallet` (Commons and the Peable
|
|
1010
|
+
// wallet only), the public key in `<TEAM>.group.so.oxy.shared`, which every
|
|
1011
|
+
// Oxy app reads. Every other app signs in through Commons (ADR 0029).
|
|
1007
1012
|
// Android: every app has its own UID, so nothing is shared through storage.
|
|
1008
1013
|
// Commons is the ONLY holder of the private key. It keeps the identity
|
|
1009
1014
|
// signer store (`setIdentitySignerStore`) for its identity host, and every
|
|
@@ -1031,22 +1036,11 @@ export class KeyManager {
|
|
|
1031
1036
|
const { privateKey, publicKey } = await KeyManager.generateKeyPair();
|
|
1032
1037
|
|
|
1033
1038
|
if (isIOS()) {
|
|
1034
|
-
const store = await initSecureStore();
|
|
1035
|
-
// Note: keychainAccessGroup requires Keychain Sharing capability in Xcode
|
|
1036
1039
|
try {
|
|
1037
|
-
|
|
1038
|
-
keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
|
|
1039
|
-
keychainAccessGroup: IOS_KEYCHAIN_GROUP, // Enables sharing across apps
|
|
1040
|
-
};
|
|
1041
|
-
await store.setItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY, privateKey, privateOpts);
|
|
1042
|
-
|
|
1043
|
-
const publicOpts: OxySecureStoreOptions = {
|
|
1044
|
-
keychainAccessGroup: IOS_KEYCHAIN_GROUP,
|
|
1045
|
-
};
|
|
1046
|
-
await store.setItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY, publicKey, publicOpts);
|
|
1040
|
+
await KeyManager._writeIosSharedSlot(privateKey, publicKey);
|
|
1047
1041
|
} catch (error) {
|
|
1048
1042
|
throw new Error(
|
|
1049
|
-
`Failed to create shared identity on iOS.
|
|
1043
|
+
`Failed to create shared identity on iOS. Only Commons may write it, with the keychain access groups of docs/identity/ios-keychain-access-groups.md. Error: ${error}`,
|
|
1050
1044
|
);
|
|
1051
1045
|
}
|
|
1052
1046
|
} else if (isAndroid()) {
|
|
@@ -1093,9 +1087,8 @@ export class KeyManager {
|
|
|
1093
1087
|
let publicKey: string | null = null;
|
|
1094
1088
|
|
|
1095
1089
|
if (isIOS()) {
|
|
1096
|
-
const store = await
|
|
1097
|
-
|
|
1098
|
-
publicKey = await store.getItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY, opts);
|
|
1090
|
+
const store = await keychainItemStore('shared');
|
|
1091
|
+
publicKey = await store.getItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY, {});
|
|
1099
1092
|
} else if (isAndroid()) {
|
|
1100
1093
|
const signer = KeyManager.identitySignerStore;
|
|
1101
1094
|
if (signer) {
|
|
@@ -1136,9 +1129,10 @@ export class KeyManager {
|
|
|
1136
1129
|
}
|
|
1137
1130
|
|
|
1138
1131
|
try {
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1132
|
+
// The wallet group: Commons and the Peable wallet. Every other app's plan
|
|
1133
|
+
// is empty, so this is `null` there without touching the keychain.
|
|
1134
|
+
const store = await keychainItemStore('spending-key');
|
|
1135
|
+
return await store.getItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY, {});
|
|
1142
1136
|
} catch (error) {
|
|
1143
1137
|
if (isDev()) {
|
|
1144
1138
|
logger.warn('Failed to get shared private key', { component: 'KeyManager' }, error);
|
|
@@ -1245,17 +1239,7 @@ export class KeyManager {
|
|
|
1245
1239
|
const publicKey = deriveSecp256k1PublicKey(canonicalPrivate);
|
|
1246
1240
|
|
|
1247
1241
|
if (isIOS()) {
|
|
1248
|
-
|
|
1249
|
-
const privateOpts: OxySecureStoreOptions = {
|
|
1250
|
-
keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
|
|
1251
|
-
keychainAccessGroup: IOS_KEYCHAIN_GROUP,
|
|
1252
|
-
};
|
|
1253
|
-
await store.setItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY, canonicalPrivate, privateOpts);
|
|
1254
|
-
|
|
1255
|
-
const publicOpts: OxySecureStoreOptions = {
|
|
1256
|
-
keychainAccessGroup: IOS_KEYCHAIN_GROUP,
|
|
1257
|
-
};
|
|
1258
|
-
await store.setItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY, publicKey, publicOpts);
|
|
1242
|
+
await KeyManager._writeIosSharedSlot(canonicalPrivate, publicKey);
|
|
1259
1243
|
} else if (isAndroid()) {
|
|
1260
1244
|
await KeyManager._writeIdentitySigner(canonicalPrivate, publicKey);
|
|
1261
1245
|
}
|
|
@@ -3230,7 +3214,8 @@ export class KeyManager {
|
|
|
3230
3214
|
* `HDKey.fromMasterSeed` and never touches the identity key.
|
|
3231
3215
|
*
|
|
3232
3216
|
* Key source (native only):
|
|
3233
|
-
* - iOS: the shared
|
|
3217
|
+
* - iOS: the shared slot's private key in the wallet keychain group (Commons
|
|
3218
|
+
* and the Peable wallet only; `null` in every other app), then this device's
|
|
3234
3219
|
* primary identity.
|
|
3235
3220
|
* - Android: Commons (the one app with a signer store) uses its own primary
|
|
3236
3221
|
* identity; every other app asks Commons over IPC and never reads a local
|