@oxy.so/core 7.31.0 → 7.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/api/identity.js +146 -23
- package/dist/cjs/crypto/identityMarker.js +15 -0
- package/dist/cjs/crypto/index.js +7 -2
- package/dist/cjs/crypto/keyManager.js +7 -10
- package/dist/cjs/crypto/keychainGroupMigration.js +327 -160
- package/dist/cjs/crypto/organizationKeys.js +33 -5
- package/dist/cjs/crypto/retiredKeys.js +80 -18
- package/dist/cjs/utils/keychainAccessGroups.js +111 -25
- package/dist/esm/api/identity.js +146 -23
- package/dist/esm/crypto/identityMarker.js +14 -0
- package/dist/esm/crypto/index.js +3 -3
- package/dist/esm/crypto/keyManager.js +9 -12
- package/dist/esm/crypto/keychainGroupMigration.js +327 -162
- package/dist/esm/crypto/organizationKeys.js +34 -6
- package/dist/esm/crypto/retiredKeys.js +76 -17
- package/dist/esm/utils/keychainAccessGroups.js +107 -24
- package/dist/types/api/identity.d.ts +36 -0
- package/dist/types/crypto/identityMarker.d.ts +8 -0
- package/dist/types/crypto/index.d.ts +3 -3
- package/dist/types/crypto/keychainGroupMigration.d.ts +28 -6
- package/dist/types/crypto/organizationKeys.d.ts +4 -2
- package/dist/types/crypto/retiredKeys.d.ts +30 -4
- package/dist/types/utils/keychainAccessGroups.d.ts +41 -11
- package/package.json +1 -1
- package/src/api/identity.ts +185 -40
- package/src/crypto/identityMarker.ts +15 -0
- package/src/crypto/index.ts +8 -1
- package/src/crypto/keyManager.ts +9 -15
- package/src/crypto/keychainGroupMigration.ts +390 -185
- package/src/crypto/organizationKeys.ts +38 -6
- package/src/crypto/retiredKeys.ts +87 -17
- package/src/utils/keychainAccessGroups.ts +121 -24
|
@@ -47,7 +47,8 @@ import {
|
|
|
47
47
|
} from '../utils/keychainAccessGroups';
|
|
48
48
|
import { isAndroid, isIOS, isWeb } from '../utils/platform';
|
|
49
49
|
import { registeredIdentitySignerStore } from './identitySigner';
|
|
50
|
-
import {
|
|
50
|
+
import { prepareKeychainAccess } from './keychainGroupMigration';
|
|
51
|
+
import { retireIdentityKey } from './retiredKeys';
|
|
51
52
|
import {
|
|
52
53
|
ORGANIZATION_ANDROID_KEYCHAIN_SERVICE as ANDROID_KEYCHAIN_SERVICE,
|
|
53
54
|
ORGANIZATION_INDEX_KEYCHAIN_SERVICE as INDEX_KEYCHAIN_SERVICE,
|
|
@@ -158,9 +159,7 @@ export async function organizationKeyFromPhrase(phrase: string): Promise<Organiz
|
|
|
158
159
|
async function secureStore(item: KeychainItemClass): Promise<ExpoSecureStoreLike> {
|
|
159
160
|
const raw = await loadSecureStore();
|
|
160
161
|
const access = await resolveKeychainAccess();
|
|
161
|
-
|
|
162
|
-
await ensureKeychainGroupsMigrated();
|
|
163
|
-
}
|
|
162
|
+
await prepareKeychainAccess(access);
|
|
164
163
|
return groupedKeychainStore(raw, access, item);
|
|
165
164
|
}
|
|
166
165
|
|
|
@@ -252,8 +251,10 @@ async function readLocalPair(accountId: string): Promise<OrganizationKeyPair | n
|
|
|
252
251
|
* it; on iOS only Commons holds the groups it writes (any other app throws).
|
|
253
252
|
*
|
|
254
253
|
* Refuses a pair whose public key does not derive from its private key, and a
|
|
255
|
-
* phrase that does not derive the pair.
|
|
256
|
-
* the organization
|
|
254
|
+
* phrase that does not derive the pair. A DIFFERENT key this device already
|
|
255
|
+
* held for the organization is first kept in the organization's retired ring
|
|
256
|
+
* (ADR 0061 D7 as amended; R4-12): coins under it still need it. When that
|
|
257
|
+
* cannot be confirmed nothing is overwritten and this throws.
|
|
257
258
|
*/
|
|
258
259
|
export async function storeOrganizationKey(
|
|
259
260
|
accountId: string,
|
|
@@ -280,6 +281,19 @@ export async function storeOrganizationKey(
|
|
|
280
281
|
);
|
|
281
282
|
}
|
|
282
283
|
const store = await pairStore();
|
|
284
|
+
const previous = await heldPrivateKey(store, accountId);
|
|
285
|
+
if (previous) {
|
|
286
|
+
const previousPublic = deriveSecp256k1PublicKey(previous);
|
|
287
|
+
if (previousPublic !== publicKey) {
|
|
288
|
+
try {
|
|
289
|
+
await retireIdentityKey({ privateKey: previous, publicKey: previousPublic }, { accountId });
|
|
290
|
+
} catch (error) {
|
|
291
|
+
throw new Error(
|
|
292
|
+
`organization key: the key this device held could not be kept as a previous key, so it was not replaced (${error instanceof Error ? error.message : 'unknown'})`,
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
283
297
|
await store.setItemAsync(privateKeyName(accountId), privateKey, pairOptions(store, true));
|
|
284
298
|
await store.setItemAsync(publicKeyName(accountId), publicKey, pairOptions(store, false));
|
|
285
299
|
if (
|
|
@@ -362,6 +376,24 @@ export async function listOrganizationKeys(): Promise<string[]> {
|
|
|
362
376
|
return readIndex(await indexStore());
|
|
363
377
|
}
|
|
364
378
|
|
|
379
|
+
/**
|
|
380
|
+
* The private key this device holds for `accountId`, whatever its public half
|
|
381
|
+
* says (an unhealthy pair is still a key coins may sit under): the slot, then
|
|
382
|
+
* on Android the signer store's copy. `null` when there is none.
|
|
383
|
+
*/
|
|
384
|
+
async function heldPrivateKey(
|
|
385
|
+
store: ExpoSecureStoreLike,
|
|
386
|
+
accountId: string,
|
|
387
|
+
): Promise<string | null> {
|
|
388
|
+
const local = await store.getItemAsync(privateKeyName(accountId), pairOptions(store, false));
|
|
389
|
+
if (local && isValidSecp256k1PrivateKey(local)) return normalizeSecp256k1PrivateKey(local);
|
|
390
|
+
const signer = isAndroid() ? registeredIdentitySignerStore() : null;
|
|
391
|
+
const mirrored = (await signer?.readOrganization?.(accountId)) ?? null;
|
|
392
|
+
return mirrored && isValidSecp256k1PrivateKey(mirrored.privateKey)
|
|
393
|
+
? normalizeSecp256k1PrivateKey(mirrored.privateKey)
|
|
394
|
+
: null;
|
|
395
|
+
}
|
|
396
|
+
|
|
365
397
|
/** Forget an organization key on this device: its pair, its phrase, its signer copy and its index entry. */
|
|
366
398
|
export async function deleteOrganizationKey(accountId: string): Promise<void> {
|
|
367
399
|
assertAccountId(accountId);
|
|
@@ -60,8 +60,8 @@ import { logger } from '../logger';
|
|
|
60
60
|
import { groupedKeychainStore, resolveKeychainAccess } from '../utils/keychainAccessGroups';
|
|
61
61
|
import { isAndroid, isIOS, isWeb } from '../utils/platform';
|
|
62
62
|
import { registeredIdentitySignerStore } from './identitySigner';
|
|
63
|
-
import {
|
|
64
|
-
import {
|
|
63
|
+
import { isOrganizationAccountId } from './keychainItems';
|
|
64
|
+
import { prepareKeychainAccess } from './keychainGroupMigration';
|
|
65
65
|
import type { OxySecureStoreOptions as SecureStoreOptions } from './secureStoreOptions';
|
|
66
66
|
|
|
67
67
|
/** Whose ring: the person's (`accountId` absent) or an organization's. */
|
|
@@ -97,15 +97,14 @@ const PUBLIC_KEY = /^04[0-9a-f]{128}$/;
|
|
|
97
97
|
* The secure store for the ring. On iOS every item is a `spending-key`
|
|
98
98
|
* (ADR 0062): the private keys AND the index live in the wallet group, which
|
|
99
99
|
* only Commons and the Peable wallet can read, never the shared one. In
|
|
100
|
-
* Commons every access first awaits the access-group migration
|
|
101
|
-
* the
|
|
100
|
+
* Commons every access first awaits the access-group migration (`gate: false`
|
|
101
|
+
* is the migration itself, keeping a pre-split copy). On Android the grouping
|
|
102
|
+
* is a no-op and Commons' own services below apply.
|
|
102
103
|
*/
|
|
103
|
-
async function ringStore(): Promise<ExpoSecureStoreLike> {
|
|
104
|
+
async function ringStore(gate = true): Promise<ExpoSecureStoreLike> {
|
|
104
105
|
const raw = await loadSecureStore();
|
|
105
106
|
const access = await resolveKeychainAccess();
|
|
106
|
-
if (
|
|
107
|
-
await ensureKeychainGroupsMigrated();
|
|
108
|
-
}
|
|
107
|
+
if (gate) await prepareKeychainAccess(access);
|
|
109
108
|
return groupedKeychainStore(raw, access, 'spending-key');
|
|
110
109
|
}
|
|
111
110
|
|
|
@@ -213,14 +212,25 @@ function toListed(entry: IndexEntry): RetiredIdentityKey {
|
|
|
213
212
|
: { publicKey: entry.publicKey, accountId: entry.scope, retiredAt: entry.retiredAt };
|
|
214
213
|
}
|
|
215
214
|
|
|
216
|
-
/**
|
|
217
|
-
|
|
215
|
+
/**
|
|
216
|
+
* Whether this app writes the ring: Commons only (Android: the signer store it
|
|
217
|
+
* registers; iOS: the keychain role `holder`). The Peable wallet holds the
|
|
218
|
+
* wallet group too, but only reads, derives and signs: a key the wallet could
|
|
219
|
+
* add to the ring is a key it could choose (R4-14).
|
|
220
|
+
*/
|
|
221
|
+
async function assertMayWrite(): Promise<void> {
|
|
218
222
|
if (isWeb()) throw new Error('retired key: only a native device holds one');
|
|
219
223
|
if (isAndroid() && !registeredIdentitySignerStore()?.writeRetired) {
|
|
220
224
|
throw new Error(
|
|
221
225
|
'On Android only Commons holds retired keys; this app has no identity signer store for them.',
|
|
222
226
|
);
|
|
223
227
|
}
|
|
228
|
+
if (isIOS()) {
|
|
229
|
+
const access = await resolveKeychainAccess();
|
|
230
|
+
if (access.mode === 'grouped' && access.role !== 'holder') {
|
|
231
|
+
throw new Error('On iOS only Commons writes retired keys; this app may only read them.');
|
|
232
|
+
}
|
|
233
|
+
}
|
|
224
234
|
}
|
|
225
235
|
|
|
226
236
|
/**
|
|
@@ -233,16 +243,36 @@ function assertMayWrite(): void {
|
|
|
233
243
|
export async function retireIdentityKey(
|
|
234
244
|
key: RetiredKeyPair,
|
|
235
245
|
selector?: RetiredKeySelector,
|
|
246
|
+
): Promise<void> {
|
|
247
|
+
await keepRetired(key, selector, true);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* {@link retireIdentityKey} from inside the iOS access-group migration, which
|
|
252
|
+
* keeps a replaced pre-split copy before deleting it from the shared group and
|
|
253
|
+
* cannot await itself. @internal
|
|
254
|
+
*/
|
|
255
|
+
export async function retireIdentityKeyDuringMigration(
|
|
256
|
+
key: RetiredKeyPair,
|
|
257
|
+
selector?: RetiredKeySelector,
|
|
258
|
+
): Promise<void> {
|
|
259
|
+
await keepRetired(key, selector, false);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async function keepRetired(
|
|
263
|
+
key: RetiredKeyPair,
|
|
264
|
+
selector: RetiredKeySelector | undefined,
|
|
265
|
+
gate: boolean,
|
|
236
266
|
): Promise<void> {
|
|
237
267
|
const scope = retiredKeyScope(selector);
|
|
238
268
|
if (!scope) throw new Error('retired key: accountId must be a 24-hex or uuid account id');
|
|
239
|
-
assertMayWrite();
|
|
269
|
+
await assertMayWrite();
|
|
240
270
|
if (!isHealthyPair(key.privateKey, key.publicKey)) {
|
|
241
271
|
throw new Error('retired key: the public key does not derive from the private key');
|
|
242
272
|
}
|
|
243
273
|
const privateKey = normalizeSecp256k1PrivateKey(key.privateKey);
|
|
244
274
|
const publicKey = normalizeSecp256k1PublicKey(key.publicKey);
|
|
245
|
-
const store = await ringStore();
|
|
275
|
+
const store = await ringStore(gate);
|
|
246
276
|
await store.setItemAsync(
|
|
247
277
|
privateKeyName(scope, publicKey),
|
|
248
278
|
privateKey,
|
|
@@ -357,7 +387,7 @@ export async function forgetRetiredIdentityKey(
|
|
|
357
387
|
const scope = retiredKeyScope(selector);
|
|
358
388
|
const canonical = canonicalPublicKey(publicKey);
|
|
359
389
|
if (!scope || !canonical) throw new Error('retired key: invalid selector or public key');
|
|
360
|
-
assertMayWrite();
|
|
390
|
+
await assertMayWrite();
|
|
361
391
|
const store = await ringStore();
|
|
362
392
|
await store.deleteItemAsync(privateKeyName(scope, canonical), privateOptions(store, false));
|
|
363
393
|
const signer = isAndroid() ? registeredIdentitySignerStore() : null;
|
|
@@ -371,10 +401,12 @@ export async function forgetRetiredIdentityKey(
|
|
|
371
401
|
}
|
|
372
402
|
|
|
373
403
|
/**
|
|
374
|
-
* Forget every retired key of one ring (the person's, or an organization's)
|
|
375
|
-
*
|
|
376
|
-
*
|
|
377
|
-
*
|
|
404
|
+
* Forget every retired key of one ring (the person's, or an organization's).
|
|
405
|
+
* Commons only, and only when the person deletes their Oxy ACCOUNT (Commons'
|
|
406
|
+
* delete-account flow), which tells them the coins go with it. Deleting the
|
|
407
|
+
* identity from this device — "Start over", a forced delete — never calls it:
|
|
408
|
+
* the ring is money, and the same person restoring their identity later must
|
|
409
|
+
* find it (R4-4). Resolves how many it removed.
|
|
378
410
|
*/
|
|
379
411
|
export async function forgetRetiredIdentityKeys(selector?: RetiredKeySelector): Promise<number> {
|
|
380
412
|
const scope = retiredKeyScope(selector);
|
|
@@ -432,6 +464,44 @@ export async function syncRetiredKeys(): Promise<number> {
|
|
|
432
464
|
return mirrored;
|
|
433
465
|
}
|
|
434
466
|
|
|
467
|
+
/**
|
|
468
|
+
* Every retired key of `selector`'s ring WITH its private half, for the
|
|
469
|
+
* encrypted identity backup (`oxy.identity.backup.create`), which carries them
|
|
470
|
+
* under the CURRENT phrase so a lost phone costs no more than the current
|
|
471
|
+
* phrase does (R4-4). Commons only; an entry whose private half is missing or
|
|
472
|
+
* does not derive its public key is left out. Never log or transmit the result
|
|
473
|
+
* except inside that encrypted envelope.
|
|
474
|
+
*/
|
|
475
|
+
export async function exportRetiredIdentityKeys(
|
|
476
|
+
selector?: RetiredKeySelector,
|
|
477
|
+
): Promise<Array<RetiredKeyPair & { retiredAt: number }>> {
|
|
478
|
+
const scope = retiredKeyScope(selector);
|
|
479
|
+
if (!scope || isWeb()) return [];
|
|
480
|
+
await assertMayWrite();
|
|
481
|
+
const out: Array<RetiredKeyPair & { retiredAt: number }> = [];
|
|
482
|
+
for (const entry of await listRetiredIdentityKeys(selector)) {
|
|
483
|
+
const privateKey = await getRetiredPrivateKey(entry.publicKey, selector);
|
|
484
|
+
if (privateKey)
|
|
485
|
+
out.push({ privateKey, publicKey: entry.publicKey, retiredAt: entry.retiredAt });
|
|
486
|
+
}
|
|
487
|
+
return out;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* The pair a previous key's raw PRIVATE KEY gives (64 hex, an optional `0x`),
|
|
492
|
+
* for a person whose old identity was imported as a private key and so has no
|
|
493
|
+
* phrase to re-enter. Stores nothing: the caller checks the public key against
|
|
494
|
+
* the account's key history and only then calls {@link retireIdentityKey}.
|
|
495
|
+
*/
|
|
496
|
+
export function retiredKeyFromPrivateKey(privateKeyHex: string): RetiredKeyPair {
|
|
497
|
+
const trimmed = privateKeyHex.trim().toLowerCase().replace(/^0x/, '');
|
|
498
|
+
if (!/^[0-9a-f]{64}$/.test(trimmed) || !isValidSecp256k1PrivateKey(trimmed)) {
|
|
499
|
+
throw new Error('retired key: not a valid private key');
|
|
500
|
+
}
|
|
501
|
+
const privateKey = normalizeSecp256k1PrivateKey(trimmed);
|
|
502
|
+
return { privateKey, publicKey: publicKeyOf(privateKey) };
|
|
503
|
+
}
|
|
504
|
+
|
|
435
505
|
/**
|
|
436
506
|
* The pair a previous recovery phrase derives (`seed[0:32]`, the personal-root
|
|
437
507
|
* and organization-key derivation; 12–24 words). Stores nothing: the caller
|
|
@@ -56,6 +56,16 @@ export const KEYCHAIN_ACCESS_GROUPS = {
|
|
|
56
56
|
shared: 'group.so.oxy.shared',
|
|
57
57
|
} as const;
|
|
58
58
|
|
|
59
|
+
/**
|
|
60
|
+
* The suffix a DEVELOPMENT build's restricted groups carry (`so.oxy.commons.dev`,
|
|
61
|
+
* `to.peable.app.dev`: `so.oxy.identity.dev`, `so.oxy.wallet.dev`). A dev build
|
|
62
|
+
* sits beside the production app on the same phone, is signed by the same team
|
|
63
|
+
* and runs JavaScript from a dev server, so it never shares a restricted group
|
|
64
|
+
* with production (`@oxy.so/app-preset` enforces the entitlements). The shared
|
|
65
|
+
* group has no variant: it holds nothing secret.
|
|
66
|
+
*/
|
|
67
|
+
export const DEV_KEYCHAIN_GROUP_SUFFIX = '.dev' as const;
|
|
68
|
+
|
|
59
69
|
export type KeychainAccessGroupName = keyof typeof KEYCHAIN_ACCESS_GROUPS;
|
|
60
70
|
|
|
61
71
|
/**
|
|
@@ -85,6 +95,8 @@ export type KeychainAccess =
|
|
|
85
95
|
defaultGroup: string;
|
|
86
96
|
role: KeychainRole;
|
|
87
97
|
appGroup?: string;
|
|
98
|
+
/** {@link DEV_KEYCHAIN_GROUP_SUFFIX} for a dev build; absent for production. */
|
|
99
|
+
variant?: typeof DEV_KEYCHAIN_GROUP_SUFFIX;
|
|
88
100
|
}
|
|
89
101
|
| { mode: 'ungrouped' };
|
|
90
102
|
|
|
@@ -120,6 +132,17 @@ export function keychainGroupFor(item: KeychainItemClass): KeychainGroup {
|
|
|
120
132
|
}
|
|
121
133
|
}
|
|
122
134
|
|
|
135
|
+
/**
|
|
136
|
+
* Whether `role` may WRITE `item`. Only Commons writes a secret: the Peable
|
|
137
|
+
* wallet reads, derives and signs with spending keys (and the retired ring)
|
|
138
|
+
* but never adds one — a key the wallet could add is one the wallet could
|
|
139
|
+
* choose. (The keychain itself lets every app in the wallet group write it;
|
|
140
|
+
* this is the SDK's half, and readers check what they find, ADR 0062.)
|
|
141
|
+
*/
|
|
142
|
+
export function mayWriteKeychainItem(role: KeychainRole, item: KeychainItemClass): boolean {
|
|
143
|
+
return role === 'holder' || item === 'shared' || item === 'app';
|
|
144
|
+
}
|
|
145
|
+
|
|
123
146
|
/** Whether `role` holds the group `item` lives in. */
|
|
124
147
|
export function mayHoldKeychainItem(role: KeychainRole, item: KeychainItemClass): boolean {
|
|
125
148
|
switch (role) {
|
|
@@ -147,15 +170,19 @@ export function keychainAccessFromDefaultGroup(
|
|
|
147
170
|
}
|
|
148
171
|
const prefix = defaultGroup.slice(0, dot + 1);
|
|
149
172
|
const group = defaultGroup.slice(dot + 1);
|
|
173
|
+
const dev = group.endsWith(DEV_KEYCHAIN_GROUP_SUFFIX);
|
|
174
|
+
const base = dev ? group.slice(0, -DEV_KEYCHAIN_GROUP_SUFFIX.length) : group;
|
|
150
175
|
const role: KeychainRole =
|
|
151
|
-
|
|
176
|
+
base === KEYCHAIN_ACCESS_GROUPS.identity
|
|
152
177
|
? 'holder'
|
|
153
|
-
:
|
|
178
|
+
: base === KEYCHAIN_ACCESS_GROUPS.wallet
|
|
154
179
|
? 'wallet'
|
|
155
180
|
: 'member';
|
|
181
|
+
// A `.dev` suffix on anything but a restricted group is not a variant.
|
|
182
|
+
const variant = dev && role !== 'member' ? { variant: DEV_KEYCHAIN_GROUP_SUFFIX } : {};
|
|
156
183
|
return appGroup?.startsWith(prefix)
|
|
157
|
-
? { mode: 'grouped', prefix, defaultGroup, role, appGroup }
|
|
158
|
-
: { mode: 'grouped', prefix, defaultGroup, role };
|
|
184
|
+
? { mode: 'grouped', prefix, defaultGroup, role, appGroup, ...variant }
|
|
185
|
+
: { mode: 'grouped', prefix, defaultGroup, role, ...variant };
|
|
159
186
|
}
|
|
160
187
|
|
|
161
188
|
let resolvedAccess: KeychainAccess | null = null;
|
|
@@ -212,9 +239,11 @@ export async function resolveKeychainAccess(): Promise<KeychainAccess> {
|
|
|
212
239
|
return resolvedAccess;
|
|
213
240
|
}
|
|
214
241
|
|
|
215
|
-
/** Forget the memoized access. Tests only. @internal */
|
|
242
|
+
/** Forget the memoized access and the fallback state. Tests only. @internal */
|
|
216
243
|
export function resetKeychainAccessForTests(): void {
|
|
217
244
|
resolvedAccess = null;
|
|
245
|
+
holderSharedFallback = false;
|
|
246
|
+
walletSharedFallbackClosed = false;
|
|
218
247
|
}
|
|
219
248
|
|
|
220
249
|
/**
|
|
@@ -236,7 +265,46 @@ export function keychainAccessGroupValue(
|
|
|
236
265
|
// default is the shared group: no group, the calls it always made.
|
|
237
266
|
return access.appGroup ?? (access.role === 'member' ? undefined : access.defaultGroup);
|
|
238
267
|
}
|
|
239
|
-
|
|
268
|
+
const variant = group === 'shared' ? '' : (access.variant ?? '');
|
|
269
|
+
return `${access.prefix}${KEYCHAIN_ACCESS_GROUPS[group]}${variant}`;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Whether a SECRET read may still fall back to the pre-split shared group
|
|
274
|
+
* (R4-7). Every Oxy app can write that group, and keychain items survive an
|
|
275
|
+
* uninstall, so a secret found there is one Commons did not necessarily write.
|
|
276
|
+
*
|
|
277
|
+
* - Commons (`holder`): CLOSED, except while its one-shot migration has proved
|
|
278
|
+
* the shared copies are its own (they carry the key its sandbox marker
|
|
279
|
+
* records) and could not move them all yet; the migration opens it for that
|
|
280
|
+
* launch only ({@link setHolderSharedSecretFallback}).
|
|
281
|
+
* - The Peable wallet: open until Commons' migration has finished (the
|
|
282
|
+
* completion record Commons mirrors into the wallet group), because until
|
|
283
|
+
* then an upgraded phone's keys are still in the shared group; closed for
|
|
284
|
+
* good once it has seen that record ({@link closeWalletSharedSecretFallback}).
|
|
285
|
+
*/
|
|
286
|
+
let holderSharedFallback = false;
|
|
287
|
+
let walletSharedFallbackClosed = false;
|
|
288
|
+
|
|
289
|
+
/** The migration's switch for Commons' fallback. @internal */
|
|
290
|
+
export function setHolderSharedSecretFallback(open: boolean): void {
|
|
291
|
+
holderSharedFallback = open;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** The wallet saw Commons' completion record: no shared fallback again in this process. @internal */
|
|
295
|
+
export function closeWalletSharedSecretFallback(): void {
|
|
296
|
+
walletSharedFallbackClosed = true;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function sharedFallbackOpen(access: Extract<KeychainAccess, { mode: 'grouped' }>): boolean {
|
|
300
|
+
switch (access.role) {
|
|
301
|
+
case 'holder':
|
|
302
|
+
return holderSharedFallback;
|
|
303
|
+
case 'wallet':
|
|
304
|
+
return !walletSharedFallbackClosed;
|
|
305
|
+
case 'member':
|
|
306
|
+
return false;
|
|
307
|
+
}
|
|
240
308
|
}
|
|
241
309
|
|
|
242
310
|
function option(value: string | undefined): KeychainGroupOption {
|
|
@@ -244,11 +312,13 @@ function option(value: string | undefined): KeychainGroupOption {
|
|
|
244
312
|
}
|
|
245
313
|
|
|
246
314
|
/**
|
|
247
|
-
* The option bags a READ of `item` tries, in order: its own group, then
|
|
248
|
-
* an identity secret or a spending key,
|
|
249
|
-
*
|
|
250
|
-
* app may not hold the item: the caller
|
|
251
|
-
*
|
|
315
|
+
* The option bags a READ of `item` tries, in order: its own group, then — for
|
|
316
|
+
* an identity secret or a spending key, and only while
|
|
317
|
+
* {@link sharedFallbackOpen} says so — the shared group, where those lived
|
|
318
|
+
* before the split. Empty when this app may not hold the item: the caller
|
|
319
|
+
* answers `null` without touching the keychain. Evaluated per read: the
|
|
320
|
+
* fallback closes during a process's life. An app's own items (`app`) take
|
|
321
|
+
* {@link ownKeychainItemPlan}.
|
|
252
322
|
*/
|
|
253
323
|
export function keychainReadPlan(
|
|
254
324
|
access: KeychainAccess,
|
|
@@ -262,22 +332,33 @@ export function keychainReadPlan(
|
|
|
262
332
|
}
|
|
263
333
|
const own = keychainGroupFor(item);
|
|
264
334
|
const plan = [option(keychainAccessGroupValue(access, own))];
|
|
265
|
-
if (own === 'identity' || own === 'wallet') {
|
|
266
|
-
// Where the split moved these items from.
|
|
335
|
+
if ((own === 'identity' || own === 'wallet') && sharedFallbackOpen(access)) {
|
|
267
336
|
plan.push(option(keychainAccessGroupValue(access, 'shared')));
|
|
268
337
|
}
|
|
269
338
|
return plan;
|
|
270
339
|
}
|
|
271
340
|
|
|
272
341
|
/**
|
|
273
|
-
* Every place a DELETE of `item` must reach:
|
|
274
|
-
*
|
|
342
|
+
* Every place a DELETE of `item` must reach: its own group AND the shared
|
|
343
|
+
* group, whatever the read fallback says, so a delete never leaves a copy
|
|
344
|
+
* behind where every Oxy app can read it.
|
|
275
345
|
*/
|
|
276
346
|
export function keychainDeletePlan(
|
|
277
347
|
access: KeychainAccess,
|
|
278
348
|
item: KeychainItemClass,
|
|
279
349
|
): KeychainGroupOption[] {
|
|
280
|
-
|
|
350
|
+
if (access.mode === 'ungrouped') {
|
|
351
|
+
return [{}];
|
|
352
|
+
}
|
|
353
|
+
if (!mayHoldKeychainItem(access.role, item)) {
|
|
354
|
+
return [];
|
|
355
|
+
}
|
|
356
|
+
const own = keychainGroupFor(item);
|
|
357
|
+
const plan = [option(keychainAccessGroupValue(access, own))];
|
|
358
|
+
if (own === 'identity' || own === 'wallet') {
|
|
359
|
+
plan.push(option(keychainAccessGroupValue(access, 'shared')));
|
|
360
|
+
}
|
|
361
|
+
return plan;
|
|
281
362
|
}
|
|
282
363
|
|
|
283
364
|
/**
|
|
@@ -292,9 +373,9 @@ export function keychainWriteOption(
|
|
|
292
373
|
if (access.mode === 'ungrouped') {
|
|
293
374
|
return {};
|
|
294
375
|
}
|
|
295
|
-
if (!
|
|
376
|
+
if (!mayWriteKeychainItem(access.role, item)) {
|
|
296
377
|
throw new Error(
|
|
297
|
-
`This app (keychain role "${access.role}") may not
|
|
378
|
+
`This app (keychain role "${access.role}") may not write a ${item} keychain item; only Commons writes the identity and the keys the wallet spends.`,
|
|
298
379
|
);
|
|
299
380
|
}
|
|
300
381
|
return option(keychainAccessGroupValue(access, keychainGroupFor(item)));
|
|
@@ -413,23 +494,39 @@ export async function deleteThroughPlan(
|
|
|
413
494
|
|
|
414
495
|
/**
|
|
415
496
|
* `store` with every call routed to `item`'s groups for this app: reads walk
|
|
416
|
-
* the read plan, writes carry the write option (throwing
|
|
417
|
-
* not
|
|
418
|
-
*
|
|
497
|
+
* the read plan (evaluated per read), writes carry the write option (throwing
|
|
498
|
+
* for a role that may not write the item), deletes reach every location. A
|
|
499
|
+
* secret WRITE also deletes the item's copy in the shared group (R4-6): a value
|
|
500
|
+
* Commons writes replaces whatever any app could read there, and a copy that
|
|
501
|
+
* cannot be removed fails the write instead of staying readable. The constants
|
|
502
|
+
* of the wrapped store are kept, so `store.WHEN_UNLOCKED_THIS_DEVICE_ONLY` still
|
|
503
|
+
* reads.
|
|
419
504
|
*/
|
|
420
505
|
export function groupedKeychainStore<S extends KeychainStoreLike>(
|
|
421
506
|
store: S,
|
|
422
507
|
access: KeychainAccess,
|
|
423
508
|
item: KeychainItemClass,
|
|
424
509
|
): S {
|
|
425
|
-
const
|
|
510
|
+
const sharedCopy =
|
|
511
|
+
access.mode === 'grouped' && (item === 'identity-secret' || item === 'spending-key')
|
|
512
|
+
? option(keychainAccessGroupValue(access, 'shared'))
|
|
513
|
+
: null;
|
|
426
514
|
const wrapped: KeychainStoreLike = {
|
|
427
|
-
getItemAsync: (key, options) =>
|
|
515
|
+
getItemAsync: (key, options) =>
|
|
516
|
+
readThroughPlan(store, key, options ?? {}, keychainReadPlan(access, item)),
|
|
428
517
|
setItemAsync: async (key, value, options) => {
|
|
429
518
|
const group = keychainWriteOption(access, item);
|
|
430
519
|
await store.setItemAsync(key, value, { ...(options ?? {}), ...group });
|
|
520
|
+
if (sharedCopy) {
|
|
521
|
+
const { keychainAccessible: _accessible, ...rest } = (options ?? {}) as Record<
|
|
522
|
+
string,
|
|
523
|
+
unknown
|
|
524
|
+
>;
|
|
525
|
+
await store.deleteItemAsync(key, { ...rest, ...sharedCopy });
|
|
526
|
+
}
|
|
431
527
|
},
|
|
432
|
-
deleteItemAsync: (key, options) =>
|
|
528
|
+
deleteItemAsync: (key, options) =>
|
|
529
|
+
deleteThroughPlan(store, key, options ?? {}, keychainDeletePlan(access, item)),
|
|
433
530
|
};
|
|
434
531
|
return Object.assign(Object.create(store) as S, wrapped);
|
|
435
532
|
}
|