@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
|
@@ -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
|
|
12
|
-
*
|
|
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,7 +19,7 @@
|
|
|
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
|
|
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.
|
|
@@ -38,8 +40,28 @@ import {
|
|
|
38
40
|
normalizeSecp256k1PublicKey,
|
|
39
41
|
} from '@oxy.so/protocol/secp256k1';
|
|
40
42
|
import { logger } from '../logger';
|
|
43
|
+
import {
|
|
44
|
+
type KeychainItemClass,
|
|
45
|
+
groupedKeychainStore,
|
|
46
|
+
resolveKeychainAccess,
|
|
47
|
+
} from '../utils/keychainAccessGroups';
|
|
41
48
|
import { isAndroid, isIOS, isWeb } from '../utils/platform';
|
|
42
49
|
import { registeredIdentitySignerStore } from './identitySigner';
|
|
50
|
+
import { ensureKeychainGroupsMigrated } from './keychainGroupMigration';
|
|
51
|
+
import {
|
|
52
|
+
ORGANIZATION_ANDROID_KEYCHAIN_SERVICE as ANDROID_KEYCHAIN_SERVICE,
|
|
53
|
+
ORGANIZATION_INDEX_KEYCHAIN_SERVICE as INDEX_KEYCHAIN_SERVICE,
|
|
54
|
+
ORGANIZATION_INDEX_STORAGE_KEY as INDEX_STORAGE_KEY,
|
|
55
|
+
ORGANIZATION_MNEMONIC_KEYCHAIN_SERVICE as MNEMONIC_KEYCHAIN_SERVICE,
|
|
56
|
+
isOrganizationAccountId,
|
|
57
|
+
organizationMnemonicName as mnemonicName,
|
|
58
|
+
organizationPrivateKeyName as privateKeyName,
|
|
59
|
+
organizationPublicKeyName as publicKeyName,
|
|
60
|
+
serializeOrganizationIndex,
|
|
61
|
+
} from './keychainItems';
|
|
62
|
+
import type { OxySecureStoreOptions as SecureStoreOptions } from './secureStoreOptions';
|
|
63
|
+
|
|
64
|
+
export { isOrganizationAccountId };
|
|
43
65
|
|
|
44
66
|
/** A secp256k1 pair: private key 64 hex, public key lowercase uncompressed (`04…`, 130 hex). */
|
|
45
67
|
export interface OrganizationKeyPair {
|
|
@@ -57,39 +79,14 @@ export interface StoredOrganizationKey extends OrganizationKeyPair {
|
|
|
57
79
|
mnemonic?: string;
|
|
58
80
|
}
|
|
59
81
|
|
|
60
|
-
/** Same set the server's account ids come from: a legacy 24-hex id or a lowercase uuid. */
|
|
61
|
-
const ACCOUNT_ID =
|
|
62
|
-
/^(?:[0-9a-f]{24}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/;
|
|
63
|
-
|
|
64
|
-
const IOS_KEYCHAIN_GROUP = 'group.so.oxy.shared';
|
|
65
|
-
/** Android, Commons-local: its own Keystore key, shared with no personal slot. */
|
|
66
|
-
const ANDROID_KEYCHAIN_SERVICE = 'oxy_org_identity';
|
|
67
|
-
const MNEMONIC_KEYCHAIN_SERVICE = 'oxy_org_identity_mnemonic';
|
|
68
|
-
const INDEX_KEYCHAIN_SERVICE = 'oxy_org_identity_index';
|
|
69
|
-
const INDEX_STORAGE_KEY = 'oxy_org_identity_index_v1';
|
|
70
82
|
const ORGANIZATION_PHRASE_WORDS = 24;
|
|
71
83
|
|
|
72
|
-
interface SecureStoreOptions {
|
|
73
|
-
keychainService?: string;
|
|
74
|
-
keychainAccessible?: number;
|
|
75
|
-
keychainAccessGroup?: string;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/** Whether `value` is an account id an organization key can be stored under. */
|
|
79
|
-
export function isOrganizationAccountId(value: string): boolean {
|
|
80
|
-
return ACCOUNT_ID.test(value);
|
|
81
|
-
}
|
|
82
|
-
|
|
83
84
|
function assertAccountId(accountId: string): void {
|
|
84
85
|
if (!isOrganizationAccountId(accountId)) {
|
|
85
86
|
throw new Error('organization key: accountId must be a 24-hex or uuid account id');
|
|
86
87
|
}
|
|
87
88
|
}
|
|
88
89
|
|
|
89
|
-
const privateKeyName = (accountId: string) => `oxy_org_identity_private_key.${accountId}`;
|
|
90
|
-
const publicKeyName = (accountId: string) => `oxy_org_identity_public_key.${accountId}`;
|
|
91
|
-
const mnemonicName = (accountId: string) => `oxy_org_identity_mnemonic.${accountId}`;
|
|
92
|
-
|
|
93
90
|
function toHex(bytes: Uint8Array): string {
|
|
94
91
|
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
|
95
92
|
}
|
|
@@ -152,10 +149,25 @@ export async function organizationKeyFromPhrase(phrase: string): Promise<Organiz
|
|
|
152
149
|
return pairFromPhrase(phrase);
|
|
153
150
|
}
|
|
154
151
|
|
|
155
|
-
|
|
156
|
-
|
|
152
|
+
/**
|
|
153
|
+
* The secure store for one class of organization item: on iOS the pair is a
|
|
154
|
+
* `spending-key` (wallet group), the phrase and the index `identity-secret`
|
|
155
|
+
* (identity group); see `../utils/keychainAccessGroups`. In Commons every
|
|
156
|
+
* access first awaits the access-group migration. Android is unchanged.
|
|
157
|
+
*/
|
|
158
|
+
async function secureStore(item: KeychainItemClass): Promise<ExpoSecureStoreLike> {
|
|
159
|
+
const raw = await loadSecureStore();
|
|
160
|
+
const access = await resolveKeychainAccess();
|
|
161
|
+
if (access.mode === 'grouped' && access.role === 'holder') {
|
|
162
|
+
await ensureKeychainGroupsMigrated();
|
|
163
|
+
}
|
|
164
|
+
return groupedKeychainStore(raw, access, item);
|
|
157
165
|
}
|
|
158
166
|
|
|
167
|
+
const pairStore = () => secureStore('spending-key');
|
|
168
|
+
const phraseStore = () => secureStore('identity-secret');
|
|
169
|
+
const indexStore = () => secureStore('identity-secret');
|
|
170
|
+
|
|
159
171
|
async function readIndex(store: ExpoSecureStoreLike): Promise<string[]> {
|
|
160
172
|
const raw = await store.getItemAsync(INDEX_STORAGE_KEY, {
|
|
161
173
|
keychainService: INDEX_KEYCHAIN_SERVICE,
|
|
@@ -176,20 +188,18 @@ async function readIndex(store: ExpoSecureStoreLike): Promise<string[]> {
|
|
|
176
188
|
}
|
|
177
189
|
|
|
178
190
|
async function writeIndex(store: ExpoSecureStoreLike, ids: string[]): Promise<void> {
|
|
179
|
-
await store.setItemAsync(INDEX_STORAGE_KEY,
|
|
191
|
+
await store.setItemAsync(INDEX_STORAGE_KEY, serializeOrganizationIndex(ids), {
|
|
180
192
|
keychainService: INDEX_KEYCHAIN_SERVICE,
|
|
181
193
|
} satisfies SecureStoreOptions);
|
|
182
194
|
}
|
|
183
195
|
|
|
184
|
-
/**
|
|
196
|
+
/**
|
|
197
|
+
* The options of the pair on this platform: on iOS no keychain service (the
|
|
198
|
+
* wallet group comes from {@link pairStore}), on Android Commons' own slot.
|
|
199
|
+
*/
|
|
185
200
|
function pairOptions(store: ExpoSecureStoreLike, forPrivateWrite: boolean): SecureStoreOptions {
|
|
186
201
|
if (isIOS()) {
|
|
187
|
-
return forPrivateWrite
|
|
188
|
-
? {
|
|
189
|
-
keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
|
|
190
|
-
keychainAccessGroup: IOS_KEYCHAIN_GROUP,
|
|
191
|
-
}
|
|
192
|
-
: { keychainAccessGroup: IOS_KEYCHAIN_GROUP };
|
|
202
|
+
return forPrivateWrite ? { keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY } : {};
|
|
193
203
|
}
|
|
194
204
|
return forPrivateWrite
|
|
195
205
|
? {
|
|
@@ -205,7 +215,7 @@ async function readLocalPair(accountId: string): Promise<OrganizationKeyPair | n
|
|
|
205
215
|
if (isAndroid()) {
|
|
206
216
|
const signer = registeredIdentitySignerStore();
|
|
207
217
|
if (!signer) return null; // Only Commons holds organization keys on Android.
|
|
208
|
-
const store = await
|
|
218
|
+
const store = await pairStore();
|
|
209
219
|
const options = pairOptions(store, false);
|
|
210
220
|
const privateKey = await store.getItemAsync(privateKeyName(accountId), options);
|
|
211
221
|
const publicKey = await store.getItemAsync(publicKeyName(accountId), options);
|
|
@@ -222,7 +232,9 @@ async function readLocalPair(accountId: string): Promise<OrganizationKeyPair | n
|
|
|
222
232
|
: null;
|
|
223
233
|
}
|
|
224
234
|
if (!isIOS()) return null;
|
|
225
|
-
|
|
235
|
+
// The wallet group: `null` without a keychain read in every app but Commons
|
|
236
|
+
// and the Peable wallet.
|
|
237
|
+
const store = await pairStore();
|
|
226
238
|
const options = pairOptions(store, false);
|
|
227
239
|
const privateKey = await store.getItemAsync(privateKeyName(accountId), options);
|
|
228
240
|
const publicKey = await store.getItemAsync(publicKeyName(accountId), options);
|
|
@@ -237,7 +249,7 @@ async function readLocalPair(accountId: string): Promise<OrganizationKeyPair | n
|
|
|
237
249
|
/**
|
|
238
250
|
* Keep an organization key on this device. Commons only: on Android it needs
|
|
239
251
|
* the identity signer store Commons registers, so the identity host can serve
|
|
240
|
-
* it; on iOS only Commons
|
|
252
|
+
* it; on iOS only Commons holds the groups it writes (any other app throws).
|
|
241
253
|
*
|
|
242
254
|
* Refuses a pair whose public key does not derive from its private key, and a
|
|
243
255
|
* phrase that does not derive the pair. Overwrites whatever this device held for
|
|
@@ -267,7 +279,7 @@ export async function storeOrganizationKey(
|
|
|
267
279
|
'On Android only Commons holds organization keys; this app has no identity signer store for them.',
|
|
268
280
|
);
|
|
269
281
|
}
|
|
270
|
-
const store = await
|
|
282
|
+
const store = await pairStore();
|
|
271
283
|
await store.setItemAsync(privateKeyName(accountId), privateKey, pairOptions(store, true));
|
|
272
284
|
await store.setItemAsync(publicKeyName(accountId), publicKey, pairOptions(store, false));
|
|
273
285
|
if (
|
|
@@ -278,22 +290,24 @@ export async function storeOrganizationKey(
|
|
|
278
290
|
`The identity signer store (${signer.name}) did not confirm the organization key.`,
|
|
279
291
|
);
|
|
280
292
|
}
|
|
293
|
+
const phrases = await phraseStore();
|
|
281
294
|
if (key.mnemonic !== undefined) {
|
|
282
|
-
await
|
|
295
|
+
await phrases.setItemAsync(mnemonicName(accountId), normalizePhrase(key.mnemonic), {
|
|
283
296
|
keychainService: MNEMONIC_KEYCHAIN_SERVICE,
|
|
284
|
-
keychainAccessible:
|
|
297
|
+
keychainAccessible: phrases.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
|
|
285
298
|
} satisfies SecureStoreOptions);
|
|
286
299
|
} else {
|
|
287
|
-
await
|
|
300
|
+
await phrases.deleteItemAsync(mnemonicName(accountId), {
|
|
288
301
|
keychainService: MNEMONIC_KEYCHAIN_SERVICE,
|
|
289
302
|
} satisfies SecureStoreOptions);
|
|
290
303
|
}
|
|
291
|
-
|
|
304
|
+
const index = await indexStore();
|
|
305
|
+
await writeIndex(index, [...(await readIndex(index)), accountId]);
|
|
292
306
|
}
|
|
293
307
|
|
|
294
308
|
/**
|
|
295
309
|
* The organization's public key as THIS device can reach it, or `null`.
|
|
296
|
-
* iOS: the keychain group. Android: Commons' own slot inside Commons, and
|
|
310
|
+
* iOS: the wallet keychain group (Commons, the Peable wallet). Android: Commons' own slot inside Commons, and
|
|
297
311
|
* Commons' identity host (`describe` with `accountId`) everywhere else. Compare
|
|
298
312
|
* it with the organization's DID `#key-1` before receiving (Peable ADR 0002).
|
|
299
313
|
*/
|
|
@@ -317,8 +331,8 @@ export async function getOrganizationPublicKey(accountId: string): Promise<strin
|
|
|
317
331
|
|
|
318
332
|
/**
|
|
319
333
|
* The organization's private key, where this app may hold it: Commons (both
|
|
320
|
-
* platforms) and, on iOS, the
|
|
321
|
-
*
|
|
334
|
+
* platforms) and, on iOS, the Peable wallet through the wallet keychain group.
|
|
335
|
+
* `null` everywhere else. Use it to sign within the app only; never transmit it.
|
|
322
336
|
*/
|
|
323
337
|
export async function getOrganizationPrivateKey(accountId: string): Promise<string | null> {
|
|
324
338
|
if (!isOrganizationAccountId(accountId)) return null;
|
|
@@ -336,7 +350,7 @@ export async function getOrganizationPrivateKey(accountId: string): Promise<stri
|
|
|
336
350
|
/** The organization's phrase, if this device created or imported it with one. Commons only. */
|
|
337
351
|
export async function getOrganizationRecoveryPhrase(accountId: string): Promise<string | null> {
|
|
338
352
|
if (!isOrganizationAccountId(accountId) || isWeb()) return null;
|
|
339
|
-
const store = await
|
|
353
|
+
const store = await phraseStore();
|
|
340
354
|
return store.getItemAsync(mnemonicName(accountId), {
|
|
341
355
|
keychainService: MNEMONIC_KEYCHAIN_SERVICE,
|
|
342
356
|
} satisfies SecureStoreOptions);
|
|
@@ -345,25 +359,26 @@ export async function getOrganizationRecoveryPhrase(accountId: string): Promise<
|
|
|
345
359
|
/** The organizations this device keeps a key for (Commons' own index). */
|
|
346
360
|
export async function listOrganizationKeys(): Promise<string[]> {
|
|
347
361
|
if (isWeb()) return [];
|
|
348
|
-
return readIndex(await
|
|
362
|
+
return readIndex(await indexStore());
|
|
349
363
|
}
|
|
350
364
|
|
|
351
365
|
/** Forget an organization key on this device: its pair, its phrase, its signer copy and its index entry. */
|
|
352
366
|
export async function deleteOrganizationKey(accountId: string): Promise<void> {
|
|
353
367
|
assertAccountId(accountId);
|
|
354
368
|
if (isWeb()) return;
|
|
355
|
-
const store = await
|
|
369
|
+
const store = await pairStore();
|
|
356
370
|
const options = pairOptions(store, false);
|
|
357
371
|
await store.deleteItemAsync(privateKeyName(accountId), options);
|
|
358
372
|
await store.deleteItemAsync(publicKeyName(accountId), options);
|
|
359
|
-
await
|
|
373
|
+
await (await phraseStore()).deleteItemAsync(mnemonicName(accountId), {
|
|
360
374
|
keychainService: MNEMONIC_KEYCHAIN_SERVICE,
|
|
361
375
|
} satisfies SecureStoreOptions);
|
|
362
376
|
const signer = isAndroid() ? registeredIdentitySignerStore() : null;
|
|
363
377
|
await signer?.clearOrganization?.(accountId);
|
|
378
|
+
const index = await indexStore();
|
|
364
379
|
await writeIndex(
|
|
365
|
-
|
|
366
|
-
(await readIndex(
|
|
380
|
+
index,
|
|
381
|
+
(await readIndex(index)).filter((id) => id !== accountId),
|
|
367
382
|
);
|
|
368
383
|
}
|
|
369
384
|
|
|
@@ -376,9 +391,9 @@ export async function deleteOrganizationKey(accountId: string): Promise<void> {
|
|
|
376
391
|
export async function syncOrganizationKeys(): Promise<number> {
|
|
377
392
|
const signer = isAndroid() ? registeredIdentitySignerStore() : null;
|
|
378
393
|
if (!signer?.writeOrganization || !signer.readOrganization) return 0;
|
|
379
|
-
const store = await
|
|
394
|
+
const store = await pairStore();
|
|
380
395
|
let mirrored = 0;
|
|
381
|
-
for (const accountId of await readIndex(
|
|
396
|
+
for (const accountId of await readIndex(await indexStore())) {
|
|
382
397
|
const options = pairOptions(store, false);
|
|
383
398
|
const privateKey = await store.getItemAsync(privateKeyName(accountId), options);
|
|
384
399
|
const publicKey = await store.getItemAsync(publicKeyName(accountId), options);
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `expo-secure-store` options `@oxy.so/core` passes.
|
|
3
|
+
*
|
|
4
|
+
* Standalone, not `SecureStoreOptions` itself, so the PUBLISHED declarations
|
|
5
|
+
* never name expo-secure-store: under NodeNext its types pull in
|
|
6
|
+
* expo-modules-core and turn every Node timer into a DOM `number` in server
|
|
7
|
+
* consumers. The check below still makes `tsc` refuse a field name the real
|
|
8
|
+
* package does not have — `keychainAccessGroup` instead of `accessGroup` sat
|
|
9
|
+
* here unnoticed for years behind a hand-written ambient shim, so no item was
|
|
10
|
+
* ever written to the group the code named (docs/identity/ios-keychain-access-groups.md).
|
|
11
|
+
*/
|
|
12
|
+
import type { SecureStoreOptions } from 'expo-secure-store';
|
|
13
|
+
|
|
14
|
+
export interface OxySecureStoreOptions {
|
|
15
|
+
/** Keychain service name (Android keystore alias / iOS `kSecAttrService`). */
|
|
16
|
+
keychainService?: string;
|
|
17
|
+
/** Item accessibility: the `ExpoSecureStoreLike` constant, e.g. `WHEN_UNLOCKED_THIS_DEVICE_ONLY`. */
|
|
18
|
+
keychainAccessible?: number;
|
|
19
|
+
/** Prompt shown for biometric-protected items. */
|
|
20
|
+
authenticationPrompt?: string;
|
|
21
|
+
/** Whether Face ID / Touch ID is required to read the item. */
|
|
22
|
+
requireAuthentication?: boolean;
|
|
23
|
+
/**
|
|
24
|
+
* iOS `kSecAttrAccessGroup`: the FULL, team-prefixed entitlement value. Set
|
|
25
|
+
* only through `../utils/keychainAccessGroups`, never by hand.
|
|
26
|
+
*/
|
|
27
|
+
accessGroup?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Every key above must be one `expo-secure-store` reads; a misspelt one fails `tsc` here. */
|
|
31
|
+
type FieldsExpoReads = Pick<SecureStoreOptions, keyof OxySecureStoreOptions>;
|
|
32
|
+
type AssertExpoReadsEveryField = FieldsExpoReads;
|
package/src/session/index.ts
CHANGED
|
@@ -166,6 +166,17 @@ export type {
|
|
|
166
166
|
SharedDevicePublishOutcome,
|
|
167
167
|
SharedDevicePublishSkipReason,
|
|
168
168
|
} from './sharedDeviceCredential';
|
|
169
|
+
// Where the shared device credential (and an app's own items) live on iOS:
|
|
170
|
+
// docs/identity/ios-keychain-access-groups.md, ADR 0062.
|
|
171
|
+
export {
|
|
172
|
+
KeychainAccessUnavailableError,
|
|
173
|
+
keychainAccessGroupValue,
|
|
174
|
+
ownKeychainItemPlan,
|
|
175
|
+
readThroughPlan,
|
|
176
|
+
deleteThroughPlan,
|
|
177
|
+
resolveKeychainAccess,
|
|
178
|
+
} from '../utils/keychainAccessGroups';
|
|
179
|
+
export type { KeychainAccess, KeychainGroupOption } from '../utils/keychainAccessGroups';
|
|
169
180
|
|
|
170
181
|
// Identity-bound sessions (the identity vault). The pin is the durable
|
|
171
182
|
// `{publicKey, accountId}` binding between this device's PRIMARY identity key
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* iOS keychain access groups: which group each Oxy keychain item lives in, and
|
|
3
|
+
* which apps may read it (docs/identity/ios-keychain-access-groups.md, ADR 0062).
|
|
4
|
+
*
|
|
5
|
+
* | Group | Held by | Item class |
|
|
6
|
+
* |-------------------------------|------------------------------|-------------------|
|
|
7
|
+
* | `<TEAM>.so.oxy.identity` | Commons only (listed FIRST) | `identity-secret` |
|
|
8
|
+
* | `<TEAM>.so.oxy.wallet` | Commons, the Peable wallet | `spending-key` |
|
|
9
|
+
* | `<TEAM>.group.so.oxy.shared` | every Oxy app | `shared` |
|
|
10
|
+
*
|
|
11
|
+
* - `identity-secret`: the personal root (primary and backup slots), the
|
|
12
|
+
* personal and organization recovery phrases, the organization index. Only
|
|
13
|
+
* Commons may read or write them.
|
|
14
|
+
* - `spending-key`: private keys an app other than Commons signs or derives
|
|
15
|
+
* with in process — the personal key's shared slot and organization pairs
|
|
16
|
+
* (`deriveScopedSeed`, `signSocialReceive`). Commons writes them; the Peable
|
|
17
|
+
* wallet reads them.
|
|
18
|
+
* - `shared`: what every Oxy app may read: the shared public key and the
|
|
19
|
+
* device-session credential.
|
|
20
|
+
*
|
|
21
|
+
* The ROLE of this binary comes from the keychain itself: an item written
|
|
22
|
+
* without a group lands in the app's default group, the first entry of its
|
|
23
|
+
* `keychain-access-groups` entitlement, and `@oxy.so/app-preset` lets only
|
|
24
|
+
* Commons put the identity group first and only the Peable wallet the wallet
|
|
25
|
+
* group. The same probe gives the team prefix, which `kSecAttrAccessGroup`
|
|
26
|
+
* needs and JavaScript cannot otherwise see.
|
|
27
|
+
*
|
|
28
|
+
* Android and web have no access groups: there every helper here answers the
|
|
29
|
+
* single-store behaviour (`ungrouped`), and Android's narrowing is Commons'
|
|
30
|
+
* identity host. An iOS build whose native module predates the probe is also
|
|
31
|
+
* `ungrouped` — exactly what that build's entitlements (one shared group) do.
|
|
32
|
+
*
|
|
33
|
+
* This module never imports the identity stack: services' device-session store
|
|
34
|
+
* and Peable's wallet storage use it too.
|
|
35
|
+
*/
|
|
36
|
+
import { type KeychainDefaultAccessGroup, loadKeychainDefaultAccessGroup } from '@oxy.so/protocol';
|
|
37
|
+
import { isIOS } from './platform';
|
|
38
|
+
|
|
39
|
+
/** The group names after the team prefix, exactly as the entitlements list them. */
|
|
40
|
+
export const KEYCHAIN_ACCESS_GROUPS = {
|
|
41
|
+
identity: 'so.oxy.identity',
|
|
42
|
+
wallet: 'so.oxy.wallet',
|
|
43
|
+
shared: 'group.so.oxy.shared',
|
|
44
|
+
} as const;
|
|
45
|
+
|
|
46
|
+
export type KeychainAccessGroupName = keyof typeof KEYCHAIN_ACCESS_GROUPS;
|
|
47
|
+
|
|
48
|
+
/** What a keychain item IS, which decides its group. See the table above. */
|
|
49
|
+
export type KeychainItemClass = 'identity-secret' | 'spending-key' | 'shared';
|
|
50
|
+
|
|
51
|
+
/** Which holder this binary is, from its default access group. */
|
|
52
|
+
export type KeychainRole = 'holder' | 'wallet' | 'member';
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* - `grouped`: iOS with the probe answered. `prefix` ends with its dot
|
|
56
|
+
* (`ABCDE12345.`); `defaultGroup` is the full default group string.
|
|
57
|
+
* - `ungrouped`: Android, web, or an iOS native build without the probe.
|
|
58
|
+
*/
|
|
59
|
+
export type KeychainAccess =
|
|
60
|
+
| { mode: 'grouped'; prefix: string; defaultGroup: string; role: KeychainRole }
|
|
61
|
+
| { mode: 'ungrouped' };
|
|
62
|
+
|
|
63
|
+
/** The `expo-secure-store` option this module sets (its real name since 58). */
|
|
64
|
+
export interface KeychainGroupOption {
|
|
65
|
+
accessGroup?: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Thrown while the probe cannot answer (before first unlock). Transient. */
|
|
69
|
+
export class KeychainAccessUnavailableError extends Error {
|
|
70
|
+
override readonly name = 'KeychainAccessUnavailableError';
|
|
71
|
+
constructor(readonly cause?: unknown) {
|
|
72
|
+
super('The keychain cannot say which access groups this app holds right now.');
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The single place each item class is assigned a group. A new kind of item
|
|
78
|
+
* (F1's retained keys, for one) picks its class and reuses everything below.
|
|
79
|
+
*/
|
|
80
|
+
export function keychainGroupFor(item: KeychainItemClass): KeychainAccessGroupName {
|
|
81
|
+
switch (item) {
|
|
82
|
+
case 'identity-secret':
|
|
83
|
+
return 'identity';
|
|
84
|
+
case 'spending-key':
|
|
85
|
+
return 'wallet';
|
|
86
|
+
case 'shared':
|
|
87
|
+
return 'shared';
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Whether `role` holds the group `item` lives in. */
|
|
92
|
+
export function mayHoldKeychainItem(role: KeychainRole, item: KeychainItemClass): boolean {
|
|
93
|
+
switch (role) {
|
|
94
|
+
case 'holder':
|
|
95
|
+
return true;
|
|
96
|
+
case 'wallet':
|
|
97
|
+
return item !== 'identity-secret';
|
|
98
|
+
case 'member':
|
|
99
|
+
return item === 'shared';
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Parse the default access group the probe reported. Exported for tests. */
|
|
104
|
+
export function keychainAccessFromDefaultGroup(defaultGroup: string): KeychainAccess {
|
|
105
|
+
const dot = defaultGroup.indexOf('.');
|
|
106
|
+
if (dot <= 0 || dot === defaultGroup.length - 1) {
|
|
107
|
+
// Not `<prefix>.<group>`: an unsigned simulator build. Nothing to split.
|
|
108
|
+
return { mode: 'ungrouped' };
|
|
109
|
+
}
|
|
110
|
+
const prefix = defaultGroup.slice(0, dot + 1);
|
|
111
|
+
const group = defaultGroup.slice(dot + 1);
|
|
112
|
+
const role: KeychainRole =
|
|
113
|
+
group === KEYCHAIN_ACCESS_GROUPS.identity
|
|
114
|
+
? 'holder'
|
|
115
|
+
: group === KEYCHAIN_ACCESS_GROUPS.wallet
|
|
116
|
+
? 'wallet'
|
|
117
|
+
: 'member';
|
|
118
|
+
return { mode: 'grouped', prefix, defaultGroup, role };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
let resolvedAccess: KeychainAccess | null = null;
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* This binary's keychain access, memoized once known. THROWS
|
|
125
|
+
* {@link KeychainAccessUnavailableError} while the probe cannot answer, which
|
|
126
|
+
* every caller already treats as "storage unavailable, retry".
|
|
127
|
+
*/
|
|
128
|
+
export async function resolveKeychainAccess(): Promise<KeychainAccess> {
|
|
129
|
+
if (resolvedAccess) {
|
|
130
|
+
return resolvedAccess;
|
|
131
|
+
}
|
|
132
|
+
if (!isIOS()) {
|
|
133
|
+
// Not memoized: until `@oxy.so/services` registers the platform, a phone
|
|
134
|
+
// reads as "not iOS", and caching that would keep the whole process on the
|
|
135
|
+
// pre-split behaviour.
|
|
136
|
+
return { mode: 'ungrouped' };
|
|
137
|
+
}
|
|
138
|
+
let probe: KeychainDefaultAccessGroup;
|
|
139
|
+
try {
|
|
140
|
+
probe = await loadKeychainDefaultAccessGroup();
|
|
141
|
+
} catch (cause) {
|
|
142
|
+
throw new KeychainAccessUnavailableError(cause);
|
|
143
|
+
}
|
|
144
|
+
if (probe.state === 'unavailable') {
|
|
145
|
+
throw new KeychainAccessUnavailableError(probe.cause);
|
|
146
|
+
}
|
|
147
|
+
resolvedAccess =
|
|
148
|
+
probe.state === 'resolved'
|
|
149
|
+
? keychainAccessFromDefaultGroup(probe.accessGroup)
|
|
150
|
+
: { mode: 'ungrouped' };
|
|
151
|
+
return resolvedAccess;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Forget the memoized access. Tests only. @internal */
|
|
155
|
+
export function resetKeychainAccessForTests(): void {
|
|
156
|
+
resolvedAccess = null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** The full `kSecAttrAccessGroup` value of `group`, or `undefined` when ungrouped. */
|
|
160
|
+
export function keychainAccessGroupValue(
|
|
161
|
+
access: KeychainAccess,
|
|
162
|
+
group: KeychainAccessGroupName,
|
|
163
|
+
): string | undefined {
|
|
164
|
+
return access.mode === 'grouped' ? `${access.prefix}${KEYCHAIN_ACCESS_GROUPS[group]}` : undefined;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function option(value: string | undefined): KeychainGroupOption {
|
|
168
|
+
return value === undefined ? {} : { accessGroup: value };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The option bags a READ of `item` tries, in order: its own group, then the
|
|
173
|
+
* shared group, where every item lived before the split (release 4 of the doc
|
|
174
|
+
* drops that fallback). Empty when this app may not hold the item: the caller
|
|
175
|
+
* answers `null` without touching the keychain.
|
|
176
|
+
*/
|
|
177
|
+
export function keychainReadPlan(
|
|
178
|
+
access: KeychainAccess,
|
|
179
|
+
item: KeychainItemClass,
|
|
180
|
+
): KeychainGroupOption[] {
|
|
181
|
+
if (access.mode === 'ungrouped') {
|
|
182
|
+
return [{}];
|
|
183
|
+
}
|
|
184
|
+
if (!mayHoldKeychainItem(access.role, item)) {
|
|
185
|
+
return [];
|
|
186
|
+
}
|
|
187
|
+
const own = keychainGroupFor(item);
|
|
188
|
+
const plan = [option(keychainAccessGroupValue(access, own))];
|
|
189
|
+
if (own !== 'shared') {
|
|
190
|
+
plan.push(option(keychainAccessGroupValue(access, 'shared')));
|
|
191
|
+
}
|
|
192
|
+
return plan;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Every place a DELETE of `item` must reach: the read plan, so a delete never
|
|
197
|
+
* leaves a pre-split copy behind in the shared group.
|
|
198
|
+
*/
|
|
199
|
+
export function keychainDeletePlan(
|
|
200
|
+
access: KeychainAccess,
|
|
201
|
+
item: KeychainItemClass,
|
|
202
|
+
): KeychainGroupOption[] {
|
|
203
|
+
return keychainReadPlan(access, item);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* The option a WRITE of `item` carries. THROWS when this app may not hold the
|
|
208
|
+
* item: an identity secret written by any app but Commons would land in that
|
|
209
|
+
* app's default group, a second holder nobody knows about.
|
|
210
|
+
*/
|
|
211
|
+
export function keychainWriteOption(
|
|
212
|
+
access: KeychainAccess,
|
|
213
|
+
item: KeychainItemClass,
|
|
214
|
+
): KeychainGroupOption {
|
|
215
|
+
if (access.mode === 'ungrouped') {
|
|
216
|
+
return {};
|
|
217
|
+
}
|
|
218
|
+
if (!mayHoldKeychainItem(access.role, item)) {
|
|
219
|
+
throw new Error(
|
|
220
|
+
`This app (keychain role "${access.role}") may not hold a ${item} keychain item; only Commons writes the identity.`,
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
return option(keychainAccessGroupValue(access, keychainGroupFor(item)));
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* The plan for an app's OWN items (its session credential, its settings, an
|
|
228
|
+
* imported wallet's phrase) in Commons and the Peable wallet, whose default
|
|
229
|
+
* group the split changed: written to the default group explicitly — where a
|
|
230
|
+
* write without a group goes anyway — and read from it FIRST, then from every
|
|
231
|
+
* group (`{}`), where all of them were written before. Without the explicit
|
|
232
|
+
* order a read could return whichever of two copies the keychain met first.
|
|
233
|
+
* Deletes reach every group, as they always did.
|
|
234
|
+
*
|
|
235
|
+
* Every other app (`member`) and Android/web keep the calls they always made.
|
|
236
|
+
*/
|
|
237
|
+
export function ownKeychainItemPlan(access: KeychainAccess): {
|
|
238
|
+
read: KeychainGroupOption[];
|
|
239
|
+
write: KeychainGroupOption;
|
|
240
|
+
delete: KeychainGroupOption[];
|
|
241
|
+
} {
|
|
242
|
+
if (access.mode === 'ungrouped' || access.role === 'member') {
|
|
243
|
+
return { read: [{}], write: {}, delete: [{}] };
|
|
244
|
+
}
|
|
245
|
+
return {
|
|
246
|
+
read: [option(access.defaultGroup), {}],
|
|
247
|
+
write: option(access.defaultGroup),
|
|
248
|
+
delete: [{}],
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** The subset of `expo-secure-store` a grouped store wraps. */
|
|
253
|
+
export interface KeychainStoreLike {
|
|
254
|
+
getItemAsync(key: string, options?: object): Promise<string | null>;
|
|
255
|
+
setItemAsync(key: string, value: string, options?: object): Promise<void>;
|
|
256
|
+
deleteItemAsync(key: string, options?: object): Promise<void>;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Read `key` through `plan`: the first non-null answer. A thrown read propagates. */
|
|
260
|
+
export async function readThroughPlan(
|
|
261
|
+
store: KeychainStoreLike,
|
|
262
|
+
key: string,
|
|
263
|
+
options: object,
|
|
264
|
+
plan: readonly KeychainGroupOption[],
|
|
265
|
+
): Promise<string | null> {
|
|
266
|
+
for (const group of plan) {
|
|
267
|
+
const value = await store.getItemAsync(key, { ...options, ...group });
|
|
268
|
+
if (value !== null && value !== undefined) {
|
|
269
|
+
return value;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Delete `key` from every location in `plan`. A thrown delete propagates. */
|
|
276
|
+
export async function deleteThroughPlan(
|
|
277
|
+
store: KeychainStoreLike,
|
|
278
|
+
key: string,
|
|
279
|
+
options: object,
|
|
280
|
+
plan: readonly KeychainGroupOption[],
|
|
281
|
+
): Promise<void> {
|
|
282
|
+
for (const group of plan) {
|
|
283
|
+
await store.deleteItemAsync(key, { ...options, ...group });
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* `store` with every call routed to `item`'s groups for this app: reads walk
|
|
289
|
+
* the read plan, writes carry the write option (throwing for a role that may
|
|
290
|
+
* not hold the item), deletes reach every location. The constants of the
|
|
291
|
+
* wrapped store are kept, so `store.WHEN_UNLOCKED_THIS_DEVICE_ONLY` still reads.
|
|
292
|
+
*/
|
|
293
|
+
export function groupedKeychainStore<S extends KeychainStoreLike>(
|
|
294
|
+
store: S,
|
|
295
|
+
access: KeychainAccess,
|
|
296
|
+
item: KeychainItemClass,
|
|
297
|
+
): S {
|
|
298
|
+
const plan = keychainReadPlan(access, item);
|
|
299
|
+
const wrapped: KeychainStoreLike = {
|
|
300
|
+
getItemAsync: (key, options) => readThroughPlan(store, key, options ?? {}, plan),
|
|
301
|
+
setItemAsync: async (key, value, options) => {
|
|
302
|
+
const group = keychainWriteOption(access, item);
|
|
303
|
+
await store.setItemAsync(key, value, { ...(options ?? {}), ...group });
|
|
304
|
+
},
|
|
305
|
+
deleteItemAsync: (key, options) => deleteThroughPlan(store, key, options ?? {}, plan),
|
|
306
|
+
};
|
|
307
|
+
return Object.assign(Object.create(store) as S, wrapped);
|
|
308
|
+
}
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
declare module 'expo-secure-store' {
|
|
2
|
-
export interface SecureStoreOptions {
|
|
3
|
-
keychainAccessible?: number;
|
|
4
|
-
keychainAccessGroup?: string;
|
|
5
|
-
keychainService?: string;
|
|
6
|
-
requireAuthentication?: boolean;
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
export const WHEN_UNLOCKED: number;
|
|
10
|
-
export const AFTER_FIRST_UNLOCK: number;
|
|
11
|
-
export const WHEN_UNLOCKED_THIS_DEVICE_ONLY: number;
|
|
12
|
-
export const AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY: number;
|
|
13
|
-
|
|
14
|
-
export function getItemAsync(key: string, options?: SecureStoreOptions): Promise<string | null>;
|
|
15
|
-
export function setItemAsync(
|
|
16
|
-
key: string,
|
|
17
|
-
value: string,
|
|
18
|
-
options?: SecureStoreOptions,
|
|
19
|
-
): Promise<void>;
|
|
20
|
-
export function deleteItemAsync(key: string, options?: SecureStoreOptions): Promise<void>;
|
|
21
|
-
}
|