@oxy.so/core 7.30.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/session/authStateStore.js +13 -0
- package/dist/cjs/session/index.js +5 -2
- package/dist/cjs/utils/keychainAccessGroups.js +208 -51
- 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/session/authStateStore.js +12 -0
- package/dist/esm/session/index.js +1 -1
- package/dist/esm/utils/keychainAccessGroups.js +205 -51
- 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 +4 -4
- 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/session/authStateStore.d.ts +9 -0
- package/dist/types/session/index.d.ts +2 -2
- package/dist/types/utils/keychainAccessGroups.d.ts +108 -26
- package/package.json +2 -2
- package/src/api/identity.ts +185 -40
- package/src/crypto/identityMarker.ts +15 -0
- package/src/crypto/index.ts +10 -1
- package/src/crypto/keyManager.ts +9 -15
- package/src/crypto/keychainGroupMigration.ts +396 -186
- package/src/crypto/organizationKeys.ts +38 -6
- package/src/crypto/retiredKeys.ts +87 -17
- package/src/session/authStateStore.ts +14 -0
- package/src/session/index.ts +8 -1
- package/src/utils/keychainAccessGroups.ts +276 -52
|
@@ -32,7 +32,8 @@ import { logger } from '../logger/index.js';
|
|
|
32
32
|
import { groupedKeychainStore, resolveKeychainAccess, } from '../utils/keychainAccessGroups.js';
|
|
33
33
|
import { isAndroid, isIOS, isWeb } from '../utils/platform.js';
|
|
34
34
|
import { registeredIdentitySignerStore } from './identitySigner.js';
|
|
35
|
-
import {
|
|
35
|
+
import { prepareKeychainAccess } from './keychainGroupMigration.js';
|
|
36
|
+
import { retireIdentityKey } from './retiredKeys.js';
|
|
36
37
|
import { ORGANIZATION_ANDROID_KEYCHAIN_SERVICE as ANDROID_KEYCHAIN_SERVICE, ORGANIZATION_INDEX_KEYCHAIN_SERVICE as INDEX_KEYCHAIN_SERVICE, ORGANIZATION_INDEX_STORAGE_KEY as INDEX_STORAGE_KEY, ORGANIZATION_MNEMONIC_KEYCHAIN_SERVICE as MNEMONIC_KEYCHAIN_SERVICE, isOrganizationAccountId, organizationMnemonicName as mnemonicName, organizationPrivateKeyName as privateKeyName, organizationPublicKeyName as publicKeyName, serializeOrganizationIndex, } from './keychainItems.js';
|
|
37
38
|
export { isOrganizationAccountId };
|
|
38
39
|
const ORGANIZATION_PHRASE_WORDS = 24;
|
|
@@ -102,9 +103,7 @@ export async function organizationKeyFromPhrase(phrase) {
|
|
|
102
103
|
async function secureStore(item) {
|
|
103
104
|
const raw = await loadSecureStore();
|
|
104
105
|
const access = await resolveKeychainAccess();
|
|
105
|
-
|
|
106
|
-
await ensureKeychainGroupsMigrated();
|
|
107
|
-
}
|
|
106
|
+
await prepareKeychainAccess(access);
|
|
108
107
|
return groupedKeychainStore(raw, access, item);
|
|
109
108
|
}
|
|
110
109
|
const pairStore = () => secureStore('spending-key');
|
|
@@ -195,8 +194,10 @@ async function readLocalPair(accountId) {
|
|
|
195
194
|
* it; on iOS only Commons holds the groups it writes (any other app throws).
|
|
196
195
|
*
|
|
197
196
|
* Refuses a pair whose public key does not derive from its private key, and a
|
|
198
|
-
* phrase that does not derive the pair.
|
|
199
|
-
* the organization
|
|
197
|
+
* phrase that does not derive the pair. A DIFFERENT key this device already
|
|
198
|
+
* held for the organization is first kept in the organization's retired ring
|
|
199
|
+
* (ADR 0061 D7 as amended; R4-12): coins under it still need it. When that
|
|
200
|
+
* cannot be confirmed nothing is overwritten and this throws.
|
|
200
201
|
*/
|
|
201
202
|
export async function storeOrganizationKey(accountId, key) {
|
|
202
203
|
assertAccountId(accountId);
|
|
@@ -218,6 +219,18 @@ export async function storeOrganizationKey(accountId, key) {
|
|
|
218
219
|
throw new Error('On Android only Commons holds organization keys; this app has no identity signer store for them.');
|
|
219
220
|
}
|
|
220
221
|
const store = await pairStore();
|
|
222
|
+
const previous = await heldPrivateKey(store, accountId);
|
|
223
|
+
if (previous) {
|
|
224
|
+
const previousPublic = deriveSecp256k1PublicKey(previous);
|
|
225
|
+
if (previousPublic !== publicKey) {
|
|
226
|
+
try {
|
|
227
|
+
await retireIdentityKey({ privateKey: previous, publicKey: previousPublic }, { accountId });
|
|
228
|
+
}
|
|
229
|
+
catch (error) {
|
|
230
|
+
throw new Error(`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'})`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
221
234
|
await store.setItemAsync(privateKeyName(accountId), privateKey, pairOptions(store, true));
|
|
222
235
|
await store.setItemAsync(publicKeyName(accountId), publicKey, pairOptions(store, false));
|
|
223
236
|
if (signer?.writeOrganization &&
|
|
@@ -298,6 +311,21 @@ export async function listOrganizationKeys() {
|
|
|
298
311
|
return [];
|
|
299
312
|
return readIndex(await indexStore());
|
|
300
313
|
}
|
|
314
|
+
/**
|
|
315
|
+
* The private key this device holds for `accountId`, whatever its public half
|
|
316
|
+
* says (an unhealthy pair is still a key coins may sit under): the slot, then
|
|
317
|
+
* on Android the signer store's copy. `null` when there is none.
|
|
318
|
+
*/
|
|
319
|
+
async function heldPrivateKey(store, accountId) {
|
|
320
|
+
const local = await store.getItemAsync(privateKeyName(accountId), pairOptions(store, false));
|
|
321
|
+
if (local && isValidSecp256k1PrivateKey(local))
|
|
322
|
+
return normalizeSecp256k1PrivateKey(local);
|
|
323
|
+
const signer = isAndroid() ? registeredIdentitySignerStore() : null;
|
|
324
|
+
const mirrored = (await signer?.readOrganization?.(accountId)) ?? null;
|
|
325
|
+
return mirrored && isValidSecp256k1PrivateKey(mirrored.privateKey)
|
|
326
|
+
? normalizeSecp256k1PrivateKey(mirrored.privateKey)
|
|
327
|
+
: null;
|
|
328
|
+
}
|
|
301
329
|
/** Forget an organization key on this device: its pair, its phrase, its signer copy and its index entry. */
|
|
302
330
|
export async function deleteOrganizationKey(accountId) {
|
|
303
331
|
assertAccountId(accountId);
|
|
@@ -49,8 +49,8 @@ import { logger } from '../logger/index.js';
|
|
|
49
49
|
import { groupedKeychainStore, resolveKeychainAccess } from '../utils/keychainAccessGroups.js';
|
|
50
50
|
import { isAndroid, isIOS, isWeb } from '../utils/platform.js';
|
|
51
51
|
import { registeredIdentitySignerStore } from './identitySigner.js';
|
|
52
|
-
import {
|
|
53
|
-
import {
|
|
52
|
+
import { isOrganizationAccountId } from './keychainItems.js';
|
|
53
|
+
import { prepareKeychainAccess } from './keychainGroupMigration.js';
|
|
54
54
|
const PERSONAL_SCOPE = 'personal';
|
|
55
55
|
/** Android, Commons-local: its own Keystore-backed slot, shared with no other key. */
|
|
56
56
|
const ANDROID_KEYCHAIN_SERVICE = 'oxy_retired_identity';
|
|
@@ -61,15 +61,15 @@ const PUBLIC_KEY = /^04[0-9a-f]{128}$/;
|
|
|
61
61
|
* The secure store for the ring. On iOS every item is a `spending-key`
|
|
62
62
|
* (ADR 0062): the private keys AND the index live in the wallet group, which
|
|
63
63
|
* only Commons and the Peable wallet can read, never the shared one. In
|
|
64
|
-
* Commons every access first awaits the access-group migration
|
|
65
|
-
* the
|
|
64
|
+
* Commons every access first awaits the access-group migration (`gate: false`
|
|
65
|
+
* is the migration itself, keeping a pre-split copy). On Android the grouping
|
|
66
|
+
* is a no-op and Commons' own services below apply.
|
|
66
67
|
*/
|
|
67
|
-
async function ringStore() {
|
|
68
|
+
async function ringStore(gate = true) {
|
|
68
69
|
const raw = await loadSecureStore();
|
|
69
70
|
const access = await resolveKeychainAccess();
|
|
70
|
-
if (
|
|
71
|
-
await
|
|
72
|
-
}
|
|
71
|
+
if (gate)
|
|
72
|
+
await prepareKeychainAccess(access);
|
|
73
73
|
return groupedKeychainStore(raw, access, 'spending-key');
|
|
74
74
|
}
|
|
75
75
|
/** The ring a selector names: `personal`, or the organization's account id. */
|
|
@@ -161,13 +161,24 @@ function toListed(entry) {
|
|
|
161
161
|
? { publicKey: entry.publicKey, retiredAt: entry.retiredAt }
|
|
162
162
|
: { publicKey: entry.publicKey, accountId: entry.scope, retiredAt: entry.retiredAt };
|
|
163
163
|
}
|
|
164
|
-
/**
|
|
165
|
-
|
|
164
|
+
/**
|
|
165
|
+
* Whether this app writes the ring: Commons only (Android: the signer store it
|
|
166
|
+
* registers; iOS: the keychain role `holder`). The Peable wallet holds the
|
|
167
|
+
* wallet group too, but only reads, derives and signs: a key the wallet could
|
|
168
|
+
* add to the ring is a key it could choose (R4-14).
|
|
169
|
+
*/
|
|
170
|
+
async function assertMayWrite() {
|
|
166
171
|
if (isWeb())
|
|
167
172
|
throw new Error('retired key: only a native device holds one');
|
|
168
173
|
if (isAndroid() && !registeredIdentitySignerStore()?.writeRetired) {
|
|
169
174
|
throw new Error('On Android only Commons holds retired keys; this app has no identity signer store for them.');
|
|
170
175
|
}
|
|
176
|
+
if (isIOS()) {
|
|
177
|
+
const access = await resolveKeychainAccess();
|
|
178
|
+
if (access.mode === 'grouped' && access.role !== 'holder') {
|
|
179
|
+
throw new Error('On iOS only Commons writes retired keys; this app may only read them.');
|
|
180
|
+
}
|
|
181
|
+
}
|
|
171
182
|
}
|
|
172
183
|
/**
|
|
173
184
|
* Keep `key` in the ring of `selector`'s account. Commons only. Idempotent: a
|
|
@@ -177,16 +188,27 @@ function assertMayWrite() {
|
|
|
177
188
|
* caller about to replace the key can stop before anything is lost.
|
|
178
189
|
*/
|
|
179
190
|
export async function retireIdentityKey(key, selector) {
|
|
191
|
+
await keepRetired(key, selector, true);
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* {@link retireIdentityKey} from inside the iOS access-group migration, which
|
|
195
|
+
* keeps a replaced pre-split copy before deleting it from the shared group and
|
|
196
|
+
* cannot await itself. @internal
|
|
197
|
+
*/
|
|
198
|
+
export async function retireIdentityKeyDuringMigration(key, selector) {
|
|
199
|
+
await keepRetired(key, selector, false);
|
|
200
|
+
}
|
|
201
|
+
async function keepRetired(key, selector, gate) {
|
|
180
202
|
const scope = retiredKeyScope(selector);
|
|
181
203
|
if (!scope)
|
|
182
204
|
throw new Error('retired key: accountId must be a 24-hex or uuid account id');
|
|
183
|
-
assertMayWrite();
|
|
205
|
+
await assertMayWrite();
|
|
184
206
|
if (!isHealthyPair(key.privateKey, key.publicKey)) {
|
|
185
207
|
throw new Error('retired key: the public key does not derive from the private key');
|
|
186
208
|
}
|
|
187
209
|
const privateKey = normalizeSecp256k1PrivateKey(key.privateKey);
|
|
188
210
|
const publicKey = normalizeSecp256k1PublicKey(key.publicKey);
|
|
189
|
-
const store = await ringStore();
|
|
211
|
+
const store = await ringStore(gate);
|
|
190
212
|
await store.setItemAsync(privateKeyName(scope, publicKey), privateKey, privateOptions(store, true));
|
|
191
213
|
const readBack = await store.getItemAsync(privateKeyName(scope, publicKey), privateOptions(store, false));
|
|
192
214
|
if (readBack !== privateKey) {
|
|
@@ -284,7 +306,7 @@ export async function forgetRetiredIdentityKey(publicKey, selector) {
|
|
|
284
306
|
const canonical = canonicalPublicKey(publicKey);
|
|
285
307
|
if (!scope || !canonical)
|
|
286
308
|
throw new Error('retired key: invalid selector or public key');
|
|
287
|
-
assertMayWrite();
|
|
309
|
+
await assertMayWrite();
|
|
288
310
|
const store = await ringStore();
|
|
289
311
|
await store.deleteItemAsync(privateKeyName(scope, canonical), privateOptions(store, false));
|
|
290
312
|
const signer = isAndroid() ? registeredIdentitySignerStore() : null;
|
|
@@ -292,10 +314,12 @@ export async function forgetRetiredIdentityKey(publicKey, selector) {
|
|
|
292
314
|
await writeIndex(store, (await readIndex(store)).filter((entry) => !(entry.scope === scope && entry.publicKey === canonical)));
|
|
293
315
|
}
|
|
294
316
|
/**
|
|
295
|
-
* Forget every retired key of one ring (the person's, or an organization's)
|
|
296
|
-
*
|
|
297
|
-
*
|
|
298
|
-
*
|
|
317
|
+
* Forget every retired key of one ring (the person's, or an organization's).
|
|
318
|
+
* Commons only, and only when the person deletes their Oxy ACCOUNT (Commons'
|
|
319
|
+
* delete-account flow), which tells them the coins go with it. Deleting the
|
|
320
|
+
* identity from this device — "Start over", a forced delete — never calls it:
|
|
321
|
+
* the ring is money, and the same person restoring their identity later must
|
|
322
|
+
* find it (R4-4). Resolves how many it removed.
|
|
299
323
|
*/
|
|
300
324
|
export async function forgetRetiredIdentityKeys(selector) {
|
|
301
325
|
const scope = retiredKeyScope(selector);
|
|
@@ -342,6 +366,41 @@ export async function syncRetiredKeys() {
|
|
|
342
366
|
}
|
|
343
367
|
return mirrored;
|
|
344
368
|
}
|
|
369
|
+
/**
|
|
370
|
+
* Every retired key of `selector`'s ring WITH its private half, for the
|
|
371
|
+
* encrypted identity backup (`oxy.identity.backup.create`), which carries them
|
|
372
|
+
* under the CURRENT phrase so a lost phone costs no more than the current
|
|
373
|
+
* phrase does (R4-4). Commons only; an entry whose private half is missing or
|
|
374
|
+
* does not derive its public key is left out. Never log or transmit the result
|
|
375
|
+
* except inside that encrypted envelope.
|
|
376
|
+
*/
|
|
377
|
+
export async function exportRetiredIdentityKeys(selector) {
|
|
378
|
+
const scope = retiredKeyScope(selector);
|
|
379
|
+
if (!scope || isWeb())
|
|
380
|
+
return [];
|
|
381
|
+
await assertMayWrite();
|
|
382
|
+
const out = [];
|
|
383
|
+
for (const entry of await listRetiredIdentityKeys(selector)) {
|
|
384
|
+
const privateKey = await getRetiredPrivateKey(entry.publicKey, selector);
|
|
385
|
+
if (privateKey)
|
|
386
|
+
out.push({ privateKey, publicKey: entry.publicKey, retiredAt: entry.retiredAt });
|
|
387
|
+
}
|
|
388
|
+
return out;
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* The pair a previous key's raw PRIVATE KEY gives (64 hex, an optional `0x`),
|
|
392
|
+
* for a person whose old identity was imported as a private key and so has no
|
|
393
|
+
* phrase to re-enter. Stores nothing: the caller checks the public key against
|
|
394
|
+
* the account's key history and only then calls {@link retireIdentityKey}.
|
|
395
|
+
*/
|
|
396
|
+
export function retiredKeyFromPrivateKey(privateKeyHex) {
|
|
397
|
+
const trimmed = privateKeyHex.trim().toLowerCase().replace(/^0x/, '');
|
|
398
|
+
if (!/^[0-9a-f]{64}$/.test(trimmed) || !isValidSecp256k1PrivateKey(trimmed)) {
|
|
399
|
+
throw new Error('retired key: not a valid private key');
|
|
400
|
+
}
|
|
401
|
+
const privateKey = normalizeSecp256k1PrivateKey(trimmed);
|
|
402
|
+
return { privateKey, publicKey: publicKeyOf(privateKey) };
|
|
403
|
+
}
|
|
345
404
|
/**
|
|
346
405
|
* The pair a previous recovery phrase derives (`seed[0:32]`, the personal-root
|
|
347
406
|
* and organization-key derivation; 12–24 words). Stores nothing: the caller
|
|
@@ -46,6 +46,18 @@ export function matchesAuthState(actual, expected) {
|
|
|
46
46
|
*/
|
|
47
47
|
export const AUTH_STATE_STORAGE_KEY = 'oxy.auth.v1';
|
|
48
48
|
export const AUTOMATIC_IDENTITY_SIGNIN_SUPPRESSED_KEY = 'oxy.auth.automatic-identity-signin-suppressed.v1';
|
|
49
|
+
/**
|
|
50
|
+
* What `ownKeychainItemPlan` (ADR 0062 D6) must know about a key a native
|
|
51
|
+
* session store keeps — this module's three and the identity pin: every Oxy app
|
|
52
|
+
* writes it under the same name, so on iOS a copy in the shared group may be
|
|
53
|
+
* another app's session and is never read as this app's. The sign-out marker
|
|
54
|
+
* can only withhold, so a shared copy of it still counts.
|
|
55
|
+
*/
|
|
56
|
+
export function nativeSessionItemNames(key) {
|
|
57
|
+
return key === AUTOMATIC_IDENTITY_SIGNIN_SUPPRESSED_KEY
|
|
58
|
+
? 'every-oxy-app-withholding'
|
|
59
|
+
: 'every-oxy-app';
|
|
60
|
+
}
|
|
49
61
|
/**
|
|
50
62
|
* Versioned BEST-EFFORT warm-token storage key. Holds the short-lived
|
|
51
63
|
* `{ accessToken, expiresAt }` pair only. Its write is genuinely non-fatal — a
|
|
@@ -65,7 +65,7 @@ export { AccountDialogController, createAccountDialogController, } from './accou
|
|
|
65
65
|
// device credential is `deviceId` + `deviceSecret`; the access token is re-minted
|
|
66
66
|
// via `POST /session/device/token`.
|
|
67
67
|
// ---------------------------------------------------------------------------
|
|
68
|
-
export { createWebAuthStateStore, createNativeAuthStateStore, createMemoryAuthStateStore, AUTH_STATE_STORAGE_KEY, } from './authStateStore.js';
|
|
68
|
+
export { createWebAuthStateStore, createNativeAuthStateStore, createMemoryAuthStateStore, nativeSessionItemNames, AUTH_STATE_STORAGE_KEY, AUTH_STATE_TOKEN_STORAGE_KEY, AUTOMATIC_IDENTITY_SIGNIN_SUPPRESSED_KEY, } from './authStateStore.js';
|
|
69
69
|
// The shared NATIVE DeviceSession credential — how several official apps on one
|
|
70
70
|
// device end up on ONE `DeviceSession` and therefore one active context. It is an
|
|
71
71
|
// ordinary revocable `deviceId` + `deviceSecret`, deliberately NOT the
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* | `<TEAM>.so.oxy.identity` | Commons only (listed FIRST) | `identity-secret` |
|
|
8
8
|
* | `<TEAM>.so.oxy.wallet` | Commons, the Peable wallet | `spending-key` |
|
|
9
9
|
* | `<TEAM>.group.so.oxy.shared` | every Oxy app | `shared` |
|
|
10
|
+
* | `<TEAM>.<bundle id>` | that app alone | `app` |
|
|
10
11
|
*
|
|
11
12
|
* - `identity-secret`: the personal root (primary and backup slots), the
|
|
12
13
|
* personal and organization recovery phrases, the organization index. Only
|
|
@@ -16,14 +17,21 @@
|
|
|
16
17
|
* (`deriveScopedSeed`, `signSocialReceive`). Commons writes them; the Peable
|
|
17
18
|
* wallet reads them.
|
|
18
19
|
* - `shared`: what every Oxy app may read: the shared public key and the
|
|
19
|
-
* device-session credential.
|
|
20
|
+
* device-session credential (the one cross-app session join point).
|
|
21
|
+
* - `app`: an app's OWN items, readable by no other app: its session
|
|
22
|
+
* credential (`oxy.auth.v1` and the rest of the SDK's session store), its
|
|
23
|
+
* identity pin, its settings, the Peable wallet's imported phrases. The group
|
|
24
|
+
* is the app's application identifier, which Apple makes every app a member
|
|
25
|
+
* of; no app lists it and no other app can name it. Android's counterpart is
|
|
26
|
+
* the app's own storage (ADR 0062 D6).
|
|
20
27
|
*
|
|
21
28
|
* The ROLE of this binary comes from the keychain itself: an item written
|
|
22
29
|
* without a group lands in the app's default group, the first entry of its
|
|
23
30
|
* `keychain-access-groups` entitlement, and `@oxy.so/app-preset` lets only
|
|
24
31
|
* Commons put the identity group first and only the Peable wallet the wallet
|
|
25
32
|
* group. The same probe gives the team prefix, which `kSecAttrAccessGroup`
|
|
26
|
-
* needs and JavaScript cannot otherwise see
|
|
33
|
+
* needs and JavaScript cannot otherwise see; a second probe proves the app's
|
|
34
|
+
* own group (`<prefix><bundle id>`) is writable.
|
|
27
35
|
*
|
|
28
36
|
* Android and web have no access groups: there every helper here answers the
|
|
29
37
|
* single-store behaviour (`ungrouped`), and Android's narrowing is Commons'
|
|
@@ -33,7 +41,7 @@
|
|
|
33
41
|
* This module never imports the identity stack: services' device-session store
|
|
34
42
|
* and Peable's wallet storage use it too.
|
|
35
43
|
*/
|
|
36
|
-
import { loadKeychainDefaultAccessGroup } from '@oxy.so/protocol';
|
|
44
|
+
import { loadKeychainApplicationAccessGroup, loadKeychainDefaultAccessGroup, } from '@oxy.so/protocol';
|
|
37
45
|
import { isIOS } from './platform.js';
|
|
38
46
|
/** The group names after the team prefix, exactly as the entitlements list them. */
|
|
39
47
|
export const KEYCHAIN_ACCESS_GROUPS = {
|
|
@@ -41,6 +49,15 @@ export const KEYCHAIN_ACCESS_GROUPS = {
|
|
|
41
49
|
wallet: 'so.oxy.wallet',
|
|
42
50
|
shared: 'group.so.oxy.shared',
|
|
43
51
|
};
|
|
52
|
+
/**
|
|
53
|
+
* The suffix a DEVELOPMENT build's restricted groups carry (`so.oxy.commons.dev`,
|
|
54
|
+
* `to.peable.app.dev`: `so.oxy.identity.dev`, `so.oxy.wallet.dev`). A dev build
|
|
55
|
+
* sits beside the production app on the same phone, is signed by the same team
|
|
56
|
+
* and runs JavaScript from a dev server, so it never shares a restricted group
|
|
57
|
+
* with production (`@oxy.so/app-preset` enforces the entitlements). The shared
|
|
58
|
+
* group has no variant: it holds nothing secret.
|
|
59
|
+
*/
|
|
60
|
+
export const DEV_KEYCHAIN_GROUP_SUFFIX = '.dev';
|
|
44
61
|
/** Thrown while the probe cannot answer (before first unlock). Transient. */
|
|
45
62
|
export class KeychainAccessUnavailableError extends Error {
|
|
46
63
|
constructor(cause) {
|
|
@@ -49,10 +66,6 @@ export class KeychainAccessUnavailableError extends Error {
|
|
|
49
66
|
this.name = 'KeychainAccessUnavailableError';
|
|
50
67
|
}
|
|
51
68
|
}
|
|
52
|
-
/**
|
|
53
|
-
* The single place each item class is assigned a group. A new kind of item
|
|
54
|
-
* (F1's retained keys, for one) picks its class and reuses everything below.
|
|
55
|
-
*/
|
|
56
69
|
export function keychainGroupFor(item) {
|
|
57
70
|
switch (item) {
|
|
58
71
|
case 'identity-secret':
|
|
@@ -61,8 +74,20 @@ export function keychainGroupFor(item) {
|
|
|
61
74
|
return 'wallet';
|
|
62
75
|
case 'shared':
|
|
63
76
|
return 'shared';
|
|
77
|
+
case 'app':
|
|
78
|
+
return 'app';
|
|
64
79
|
}
|
|
65
80
|
}
|
|
81
|
+
/**
|
|
82
|
+
* Whether `role` may WRITE `item`. Only Commons writes a secret: the Peable
|
|
83
|
+
* wallet reads, derives and signs with spending keys (and the retired ring)
|
|
84
|
+
* but never adds one — a key the wallet could add is one the wallet could
|
|
85
|
+
* choose. (The keychain itself lets every app in the wallet group write it;
|
|
86
|
+
* this is the SDK's half, and readers check what they find, ADR 0062.)
|
|
87
|
+
*/
|
|
88
|
+
export function mayWriteKeychainItem(role, item) {
|
|
89
|
+
return role === 'holder' || item === 'shared' || item === 'app';
|
|
90
|
+
}
|
|
66
91
|
/** Whether `role` holds the group `item` lives in. */
|
|
67
92
|
export function mayHoldKeychainItem(role, item) {
|
|
68
93
|
switch (role) {
|
|
@@ -71,11 +96,14 @@ export function mayHoldKeychainItem(role, item) {
|
|
|
71
96
|
case 'wallet':
|
|
72
97
|
return item !== 'identity-secret';
|
|
73
98
|
case 'member':
|
|
74
|
-
return item === 'shared';
|
|
99
|
+
return item === 'shared' || item === 'app';
|
|
75
100
|
}
|
|
76
101
|
}
|
|
77
|
-
/**
|
|
78
|
-
|
|
102
|
+
/**
|
|
103
|
+
* Parse the default access group the probe reported, and the app's own group
|
|
104
|
+
* when the second probe proved it. Exported for tests.
|
|
105
|
+
*/
|
|
106
|
+
export function keychainAccessFromDefaultGroup(defaultGroup, appGroup) {
|
|
79
107
|
const dot = defaultGroup.indexOf('.');
|
|
80
108
|
if (dot <= 0 || dot === defaultGroup.length - 1) {
|
|
81
109
|
// Not `<prefix>.<group>`: an unsigned simulator build. Nothing to split.
|
|
@@ -83,12 +111,18 @@ export function keychainAccessFromDefaultGroup(defaultGroup) {
|
|
|
83
111
|
}
|
|
84
112
|
const prefix = defaultGroup.slice(0, dot + 1);
|
|
85
113
|
const group = defaultGroup.slice(dot + 1);
|
|
86
|
-
const
|
|
114
|
+
const dev = group.endsWith(DEV_KEYCHAIN_GROUP_SUFFIX);
|
|
115
|
+
const base = dev ? group.slice(0, -DEV_KEYCHAIN_GROUP_SUFFIX.length) : group;
|
|
116
|
+
const role = base === KEYCHAIN_ACCESS_GROUPS.identity
|
|
87
117
|
? 'holder'
|
|
88
|
-
:
|
|
118
|
+
: base === KEYCHAIN_ACCESS_GROUPS.wallet
|
|
89
119
|
? 'wallet'
|
|
90
120
|
: 'member';
|
|
91
|
-
|
|
121
|
+
// A `.dev` suffix on anything but a restricted group is not a variant.
|
|
122
|
+
const variant = dev && role !== 'member' ? { variant: DEV_KEYCHAIN_GROUP_SUFFIX } : {};
|
|
123
|
+
return appGroup?.startsWith(prefix)
|
|
124
|
+
? { mode: 'grouped', prefix, defaultGroup, role, appGroup, ...variant }
|
|
125
|
+
: { mode: 'grouped', prefix, defaultGroup, role, ...variant };
|
|
92
126
|
}
|
|
93
127
|
let resolvedAccess = null;
|
|
94
128
|
/**
|
|
@@ -116,28 +150,101 @@ export async function resolveKeychainAccess() {
|
|
|
116
150
|
if (probe.state === 'unavailable') {
|
|
117
151
|
throw new KeychainAccessUnavailableError(probe.cause);
|
|
118
152
|
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
153
|
+
if (probe.state !== 'resolved') {
|
|
154
|
+
resolvedAccess = { mode: 'ungrouped' };
|
|
155
|
+
return resolvedAccess;
|
|
156
|
+
}
|
|
157
|
+
const access = keychainAccessFromDefaultGroup(probe.accessGroup);
|
|
158
|
+
if (access.mode === 'ungrouped') {
|
|
159
|
+
resolvedAccess = access;
|
|
160
|
+
return resolvedAccess;
|
|
161
|
+
}
|
|
162
|
+
// The app's own group. A thrown probe is NOT degraded to "no app group": this
|
|
163
|
+
// process would then write the app's items to the old place while the next
|
|
164
|
+
// one reads the new place first, and see a stale value.
|
|
165
|
+
let own;
|
|
166
|
+
try {
|
|
167
|
+
own = await loadKeychainApplicationAccessGroup(access.prefix);
|
|
168
|
+
}
|
|
169
|
+
catch (cause) {
|
|
170
|
+
throw new KeychainAccessUnavailableError(cause);
|
|
171
|
+
}
|
|
172
|
+
if (own.state === 'unavailable') {
|
|
173
|
+
throw new KeychainAccessUnavailableError(own.cause);
|
|
174
|
+
}
|
|
175
|
+
resolvedAccess = keychainAccessFromDefaultGroup(probe.accessGroup, own.state === 'resolved' ? own.accessGroup : undefined);
|
|
123
176
|
return resolvedAccess;
|
|
124
177
|
}
|
|
125
|
-
/** Forget the memoized access. Tests only. @internal */
|
|
178
|
+
/** Forget the memoized access and the fallback state. Tests only. @internal */
|
|
126
179
|
export function resetKeychainAccessForTests() {
|
|
127
180
|
resolvedAccess = null;
|
|
181
|
+
holderSharedFallback = false;
|
|
182
|
+
walletSharedFallbackClosed = false;
|
|
128
183
|
}
|
|
129
|
-
/**
|
|
184
|
+
/**
|
|
185
|
+
* The full `kSecAttrAccessGroup` value of `group`, or `undefined` for "no
|
|
186
|
+
* group" (the app's default): always when ungrouped, and for `app` on a build
|
|
187
|
+
* that cannot place items there (see {@link ownKeychainItemPlan}).
|
|
188
|
+
*/
|
|
130
189
|
export function keychainAccessGroupValue(access, group) {
|
|
131
|
-
|
|
190
|
+
if (access.mode === 'ungrouped') {
|
|
191
|
+
return undefined;
|
|
192
|
+
}
|
|
193
|
+
if (group === 'app') {
|
|
194
|
+
// Commons' and the Peable wallet's default groups are theirs (no other app
|
|
195
|
+
// writes its own items there), so a build without the app-group probe
|
|
196
|
+
// keeps its own items there, as ADR 0062 D3 put them. Every other app's
|
|
197
|
+
// default is the shared group: no group, the calls it always made.
|
|
198
|
+
return access.appGroup ?? (access.role === 'member' ? undefined : access.defaultGroup);
|
|
199
|
+
}
|
|
200
|
+
const variant = group === 'shared' ? '' : (access.variant ?? '');
|
|
201
|
+
return `${access.prefix}${KEYCHAIN_ACCESS_GROUPS[group]}${variant}`;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Whether a SECRET read may still fall back to the pre-split shared group
|
|
205
|
+
* (R4-7). Every Oxy app can write that group, and keychain items survive an
|
|
206
|
+
* uninstall, so a secret found there is one Commons did not necessarily write.
|
|
207
|
+
*
|
|
208
|
+
* - Commons (`holder`): CLOSED, except while its one-shot migration has proved
|
|
209
|
+
* the shared copies are its own (they carry the key its sandbox marker
|
|
210
|
+
* records) and could not move them all yet; the migration opens it for that
|
|
211
|
+
* launch only ({@link setHolderSharedSecretFallback}).
|
|
212
|
+
* - The Peable wallet: open until Commons' migration has finished (the
|
|
213
|
+
* completion record Commons mirrors into the wallet group), because until
|
|
214
|
+
* then an upgraded phone's keys are still in the shared group; closed for
|
|
215
|
+
* good once it has seen that record ({@link closeWalletSharedSecretFallback}).
|
|
216
|
+
*/
|
|
217
|
+
let holderSharedFallback = false;
|
|
218
|
+
let walletSharedFallbackClosed = false;
|
|
219
|
+
/** The migration's switch for Commons' fallback. @internal */
|
|
220
|
+
export function setHolderSharedSecretFallback(open) {
|
|
221
|
+
holderSharedFallback = open;
|
|
222
|
+
}
|
|
223
|
+
/** The wallet saw Commons' completion record: no shared fallback again in this process. @internal */
|
|
224
|
+
export function closeWalletSharedSecretFallback() {
|
|
225
|
+
walletSharedFallbackClosed = true;
|
|
226
|
+
}
|
|
227
|
+
function sharedFallbackOpen(access) {
|
|
228
|
+
switch (access.role) {
|
|
229
|
+
case 'holder':
|
|
230
|
+
return holderSharedFallback;
|
|
231
|
+
case 'wallet':
|
|
232
|
+
return !walletSharedFallbackClosed;
|
|
233
|
+
case 'member':
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
132
236
|
}
|
|
133
237
|
function option(value) {
|
|
134
238
|
return value === undefined ? {} : { accessGroup: value };
|
|
135
239
|
}
|
|
136
240
|
/**
|
|
137
|
-
* The option bags a READ of `item` tries, in order: its own group, then
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
241
|
+
* The option bags a READ of `item` tries, in order: its own group, then — for
|
|
242
|
+
* an identity secret or a spending key, and only while
|
|
243
|
+
* {@link sharedFallbackOpen} says so — the shared group, where those lived
|
|
244
|
+
* before the split. Empty when this app may not hold the item: the caller
|
|
245
|
+
* answers `null` without touching the keychain. Evaluated per read: the
|
|
246
|
+
* fallback closes during a process's life. An app's own items (`app`) take
|
|
247
|
+
* {@link ownKeychainItemPlan}.
|
|
141
248
|
*/
|
|
142
249
|
export function keychainReadPlan(access, item) {
|
|
143
250
|
if (access.mode === 'ungrouped') {
|
|
@@ -148,17 +255,29 @@ export function keychainReadPlan(access, item) {
|
|
|
148
255
|
}
|
|
149
256
|
const own = keychainGroupFor(item);
|
|
150
257
|
const plan = [option(keychainAccessGroupValue(access, own))];
|
|
151
|
-
if (own
|
|
258
|
+
if ((own === 'identity' || own === 'wallet') && sharedFallbackOpen(access)) {
|
|
152
259
|
plan.push(option(keychainAccessGroupValue(access, 'shared')));
|
|
153
260
|
}
|
|
154
261
|
return plan;
|
|
155
262
|
}
|
|
156
263
|
/**
|
|
157
|
-
* Every place a DELETE of `item` must reach:
|
|
158
|
-
*
|
|
264
|
+
* Every place a DELETE of `item` must reach: its own group AND the shared
|
|
265
|
+
* group, whatever the read fallback says, so a delete never leaves a copy
|
|
266
|
+
* behind where every Oxy app can read it.
|
|
159
267
|
*/
|
|
160
268
|
export function keychainDeletePlan(access, item) {
|
|
161
|
-
|
|
269
|
+
if (access.mode === 'ungrouped') {
|
|
270
|
+
return [{}];
|
|
271
|
+
}
|
|
272
|
+
if (!mayHoldKeychainItem(access.role, item)) {
|
|
273
|
+
return [];
|
|
274
|
+
}
|
|
275
|
+
const own = keychainGroupFor(item);
|
|
276
|
+
const plan = [option(keychainAccessGroupValue(access, own))];
|
|
277
|
+
if (own === 'identity' || own === 'wallet') {
|
|
278
|
+
plan.push(option(keychainAccessGroupValue(access, 'shared')));
|
|
279
|
+
}
|
|
280
|
+
return plan;
|
|
162
281
|
}
|
|
163
282
|
/**
|
|
164
283
|
* The option a WRITE of `item` carries. THROWS when this app may not hold the
|
|
@@ -169,31 +288,56 @@ export function keychainWriteOption(access, item) {
|
|
|
169
288
|
if (access.mode === 'ungrouped') {
|
|
170
289
|
return {};
|
|
171
290
|
}
|
|
172
|
-
if (!
|
|
173
|
-
throw new Error(`This app (keychain role "${access.role}") may not
|
|
291
|
+
if (!mayWriteKeychainItem(access.role, item)) {
|
|
292
|
+
throw new Error(`This app (keychain role "${access.role}") may not write a ${item} keychain item; only Commons writes the identity and the keys the wallet spends.`);
|
|
174
293
|
}
|
|
175
294
|
return option(keychainAccessGroupValue(access, keychainGroupFor(item)));
|
|
176
295
|
}
|
|
177
296
|
/**
|
|
178
|
-
* The plan for an app's OWN items (
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
297
|
+
* The plan for an app's OWN items (class `app`, ADR 0062 D6): written to the
|
|
298
|
+
* app's own group (`<prefix><bundle id>`), which no other app can read, and
|
|
299
|
+
* read from it FIRST. Then:
|
|
300
|
+
*
|
|
301
|
+
* - from where this app kept them before: Commons' and the Peable wallet's
|
|
302
|
+
* default group (D3), which no other app writes its own items to;
|
|
303
|
+
* - for `this-app` names, from every group (`{}`), where they were before the
|
|
304
|
+
* split; deletes reach every group;
|
|
305
|
+
* - for `every-oxy-app` names, nowhere else (see {@link OwnKeychainItemNames}),
|
|
306
|
+
* and deletes reach exactly the read locations, earlier ones first, so a
|
|
307
|
+
* failed delete never leaves an older copy as the only one;
|
|
308
|
+
* - for `every-oxy-app-withholding` names, the shared group last on read and
|
|
309
|
+
* first on delete.
|
|
310
|
+
*
|
|
311
|
+
* Nothing is copied or deleted to migrate: the next write lands in the app
|
|
312
|
+
* group and the read order makes it win, so a crash or a failure at any step
|
|
313
|
+
* leaves the value the app had.
|
|
185
314
|
*
|
|
186
|
-
*
|
|
315
|
+
* A native build whose module predates the app-group probe (`appGroup`
|
|
316
|
+
* absent) keeps its items where that build put them: Commons and the wallet in
|
|
317
|
+
* their default group, every other app the calls it always made (the one
|
|
318
|
+
* shared item, until that app's next native build). Android and web: no
|
|
319
|
+
* options.
|
|
187
320
|
*/
|
|
188
|
-
export function ownKeychainItemPlan(access) {
|
|
189
|
-
if (access.mode === 'ungrouped'
|
|
321
|
+
export function ownKeychainItemPlan(access, names = 'this-app') {
|
|
322
|
+
if (access.mode === 'ungrouped') {
|
|
190
323
|
return { read: [{}], write: {}, delete: [{}] };
|
|
191
324
|
}
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
write:
|
|
195
|
-
|
|
196
|
-
|
|
325
|
+
const own = keychainAccessGroupValue(access, keychainGroupFor('app'));
|
|
326
|
+
if (own === undefined) {
|
|
327
|
+
return { read: [{}], write: {}, delete: [{}] };
|
|
328
|
+
}
|
|
329
|
+
const earlier = access.role !== 'member' && own !== access.defaultGroup ? [option(access.defaultGroup)] : [];
|
|
330
|
+
const mine = [option(own), ...earlier];
|
|
331
|
+
switch (names) {
|
|
332
|
+
case 'this-app':
|
|
333
|
+
return { read: [...mine, {}], write: option(own), delete: [{}] };
|
|
334
|
+
case 'every-oxy-app':
|
|
335
|
+
return { read: mine, write: option(own), delete: [...mine].reverse() };
|
|
336
|
+
case 'every-oxy-app-withholding': {
|
|
337
|
+
const read = [...mine, option(keychainAccessGroupValue(access, 'shared'))];
|
|
338
|
+
return { read, write: option(own), delete: [...read].reverse() };
|
|
339
|
+
}
|
|
340
|
+
}
|
|
197
341
|
}
|
|
198
342
|
/** Read `key` through `plan`: the first non-null answer. A thrown read propagates. */
|
|
199
343
|
export async function readThroughPlan(store, key, options, plan) {
|
|
@@ -213,19 +357,29 @@ export async function deleteThroughPlan(store, key, options, plan) {
|
|
|
213
357
|
}
|
|
214
358
|
/**
|
|
215
359
|
* `store` with every call routed to `item`'s groups for this app: reads walk
|
|
216
|
-
* the read plan, writes carry the write option (throwing
|
|
217
|
-
* not
|
|
218
|
-
*
|
|
360
|
+
* the read plan (evaluated per read), writes carry the write option (throwing
|
|
361
|
+
* for a role that may not write the item), deletes reach every location. A
|
|
362
|
+
* secret WRITE also deletes the item's copy in the shared group (R4-6): a value
|
|
363
|
+
* Commons writes replaces whatever any app could read there, and a copy that
|
|
364
|
+
* cannot be removed fails the write instead of staying readable. The constants
|
|
365
|
+
* of the wrapped store are kept, so `store.WHEN_UNLOCKED_THIS_DEVICE_ONLY` still
|
|
366
|
+
* reads.
|
|
219
367
|
*/
|
|
220
368
|
export function groupedKeychainStore(store, access, item) {
|
|
221
|
-
const
|
|
369
|
+
const sharedCopy = access.mode === 'grouped' && (item === 'identity-secret' || item === 'spending-key')
|
|
370
|
+
? option(keychainAccessGroupValue(access, 'shared'))
|
|
371
|
+
: null;
|
|
222
372
|
const wrapped = {
|
|
223
|
-
getItemAsync: (key, options) => readThroughPlan(store, key, options ?? {},
|
|
373
|
+
getItemAsync: (key, options) => readThroughPlan(store, key, options ?? {}, keychainReadPlan(access, item)),
|
|
224
374
|
setItemAsync: async (key, value, options) => {
|
|
225
375
|
const group = keychainWriteOption(access, item);
|
|
226
376
|
await store.setItemAsync(key, value, { ...(options ?? {}), ...group });
|
|
377
|
+
if (sharedCopy) {
|
|
378
|
+
const { keychainAccessible: _accessible, ...rest } = (options ?? {});
|
|
379
|
+
await store.deleteItemAsync(key, { ...rest, ...sharedCopy });
|
|
380
|
+
}
|
|
227
381
|
},
|
|
228
|
-
deleteItemAsync: (key, options) => deleteThroughPlan(store, key, options ?? {},
|
|
382
|
+
deleteItemAsync: (key, options) => deleteThroughPlan(store, key, options ?? {}, keychainDeletePlan(access, item)),
|
|
229
383
|
};
|
|
230
384
|
return Object.assign(Object.create(store), wrapped);
|
|
231
385
|
}
|