@oxy.so/core 7.30.0 → 7.31.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/session/authStateStore.js +13 -0
- package/dist/cjs/session/index.js +5 -2
- package/dist/cjs/utils/keychainAccessGroups.js +107 -36
- package/dist/esm/session/authStateStore.js +12 -0
- package/dist/esm/session/index.js +1 -1
- package/dist/esm/utils/keychainAccessGroups.js +108 -37
- package/dist/types/crypto/index.d.ts +1 -1
- 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 +72 -20
- package/package.json +2 -2
- package/src/crypto/index.ts +2 -0
- package/src/crypto/keychainGroupMigration.ts +6 -1
- package/src/session/authStateStore.ts +14 -0
- package/src/session/index.ts +8 -1
- package/src/utils/keychainAccessGroups.ts +165 -38
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
21
21
|
exports.AUTH_STATE_TOKEN_STORAGE_KEY = exports.AUTOMATIC_IDENTITY_SIGNIN_SUPPRESSED_KEY = exports.AUTH_STATE_STORAGE_KEY = void 0;
|
|
22
22
|
exports.matchesAuthState = matchesAuthState;
|
|
23
|
+
exports.nativeSessionItemNames = nativeSessionItemNames;
|
|
23
24
|
exports.createMemoryAuthStateStore = createMemoryAuthStateStore;
|
|
24
25
|
exports.createWebAuthStateStore = createWebAuthStateStore;
|
|
25
26
|
exports.createNativeAuthStateStore = createNativeAuthStateStore;
|
|
@@ -53,6 +54,18 @@ function matchesAuthState(actual, expected) {
|
|
|
53
54
|
*/
|
|
54
55
|
exports.AUTH_STATE_STORAGE_KEY = 'oxy.auth.v1';
|
|
55
56
|
exports.AUTOMATIC_IDENTITY_SIGNIN_SUPPRESSED_KEY = 'oxy.auth.automatic-identity-signin-suppressed.v1';
|
|
57
|
+
/**
|
|
58
|
+
* What `ownKeychainItemPlan` (ADR 0062 D6) must know about a key a native
|
|
59
|
+
* session store keeps — this module's three and the identity pin: every Oxy app
|
|
60
|
+
* writes it under the same name, so on iOS a copy in the shared group may be
|
|
61
|
+
* another app's session and is never read as this app's. The sign-out marker
|
|
62
|
+
* can only withhold, so a shared copy of it still counts.
|
|
63
|
+
*/
|
|
64
|
+
function nativeSessionItemNames(key) {
|
|
65
|
+
return key === exports.AUTOMATIC_IDENTITY_SIGNIN_SUPPRESSED_KEY
|
|
66
|
+
? 'every-oxy-app-withholding'
|
|
67
|
+
: 'every-oxy-app';
|
|
68
|
+
}
|
|
56
69
|
/**
|
|
57
70
|
* Versioned BEST-EFFORT warm-token storage key. Holds the short-lived
|
|
58
71
|
* `{ accessToken, expiresAt }` pair only. Its write is genuinely non-fatal — a
|
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
* the API with a token it already has never needs it.
|
|
9
9
|
*/
|
|
10
10
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
-
exports.
|
|
12
|
-
exports.runColdBoot = exports.runSessionColdBoot = exports.TOKEN_REFRESH_LEAD_MS = void 0;
|
|
11
|
+
exports.refreshDeviceSecretArm = exports.refreshPersistedSession = exports.establishIdentitySession = exports.resolveIdentityPin = exports.IDENTITY_PIN_STORAGE_KEY = exports.identityPinMatches = exports.createMemoryIdentityPinStore = exports.createNativeIdentityPinStore = exports.createWebIdentityPinStore = exports.resolveKeychainAccess = exports.deleteThroughPlan = exports.readThroughPlan = exports.ownKeychainItemPlan = exports.keychainAccessGroupValue = exports.KeychainAccessUnavailableError = exports.readLocalDeviceCredential = exports.publishProvenDeviceCredential = exports.normalizeSharedDeviceSessionRead = exports.decideSharedDevicePublish = exports.decideSharedDeviceJoin = exports.createSharedMirroringAuthStateStore = exports.AUTOMATIC_IDENTITY_SIGNIN_SUPPRESSED_KEY = exports.AUTH_STATE_TOKEN_STORAGE_KEY = exports.AUTH_STATE_STORAGE_KEY = exports.nativeSessionItemNames = exports.createMemoryAuthStateStore = exports.createNativeAuthStateStore = exports.createWebAuthStateStore = exports.createAccountDialogController = exports.AccountDialogController = exports.resolveAccountDelegationAccess = exports.canSwitchIntoAccount = exports.isSwitchTargetAccount = exports.deviceComparisonCode = exports.showsPrincipalHeaders = exports.buildSwitcherRows = exports.resolveDeviceContext = exports.resolveActiveContext = exports.projectDevicePrincipals = exports.directoryHandle = exports.directoryDisplayName = exports.canActivateContext = exports.accountIdsOf = exports.activeUserOf = exports.activeSessionIdOf = exports.deviceStateToClientSessions = exports.createSessionClient = exports.createSessionClientHost = exports.SessionClient = exports.DeviceManager = void 0;
|
|
12
|
+
exports.runColdBoot = exports.runSessionColdBoot = exports.TOKEN_REFRESH_LEAD_MS = exports.startTokenRefreshScheduler = exports.installAuthRefreshHandler = exports.createAuthRefreshHandler = void 0;
|
|
13
13
|
// Session code reaches the identity key (identity-bound sessions), so the
|
|
14
14
|
// crypto shim goes first — see `../crypto/polyfill`.
|
|
15
15
|
require("../crypto/polyfill");
|
|
@@ -95,7 +95,10 @@ var authStateStore_1 = require("./authStateStore");
|
|
|
95
95
|
Object.defineProperty(exports, "createWebAuthStateStore", { enumerable: true, get: function () { return authStateStore_1.createWebAuthStateStore; } });
|
|
96
96
|
Object.defineProperty(exports, "createNativeAuthStateStore", { enumerable: true, get: function () { return authStateStore_1.createNativeAuthStateStore; } });
|
|
97
97
|
Object.defineProperty(exports, "createMemoryAuthStateStore", { enumerable: true, get: function () { return authStateStore_1.createMemoryAuthStateStore; } });
|
|
98
|
+
Object.defineProperty(exports, "nativeSessionItemNames", { enumerable: true, get: function () { return authStateStore_1.nativeSessionItemNames; } });
|
|
98
99
|
Object.defineProperty(exports, "AUTH_STATE_STORAGE_KEY", { enumerable: true, get: function () { return authStateStore_1.AUTH_STATE_STORAGE_KEY; } });
|
|
100
|
+
Object.defineProperty(exports, "AUTH_STATE_TOKEN_STORAGE_KEY", { enumerable: true, get: function () { return authStateStore_1.AUTH_STATE_TOKEN_STORAGE_KEY; } });
|
|
101
|
+
Object.defineProperty(exports, "AUTOMATIC_IDENTITY_SIGNIN_SUPPRESSED_KEY", { enumerable: true, get: function () { return authStateStore_1.AUTOMATIC_IDENTITY_SIGNIN_SUPPRESSED_KEY; } });
|
|
99
102
|
// The shared NATIVE DeviceSession credential — how several official apps on one
|
|
100
103
|
// device end up on ONE `DeviceSession` and therefore one active context. It is an
|
|
101
104
|
// ordinary revocable `deviceId` + `deviceSecret`, deliberately NOT the
|
|
@@ -23,6 +23,7 @@ exports.groupedKeychainStore = groupedKeychainStore;
|
|
|
23
23
|
* | `<TEAM>.so.oxy.identity` | Commons only (listed FIRST) | `identity-secret` |
|
|
24
24
|
* | `<TEAM>.so.oxy.wallet` | Commons, the Peable wallet | `spending-key` |
|
|
25
25
|
* | `<TEAM>.group.so.oxy.shared` | every Oxy app | `shared` |
|
|
26
|
+
* | `<TEAM>.<bundle id>` | that app alone | `app` |
|
|
26
27
|
*
|
|
27
28
|
* - `identity-secret`: the personal root (primary and backup slots), the
|
|
28
29
|
* personal and organization recovery phrases, the organization index. Only
|
|
@@ -32,14 +33,21 @@ exports.groupedKeychainStore = groupedKeychainStore;
|
|
|
32
33
|
* (`deriveScopedSeed`, `signSocialReceive`). Commons writes them; the Peable
|
|
33
34
|
* wallet reads them.
|
|
34
35
|
* - `shared`: what every Oxy app may read: the shared public key and the
|
|
35
|
-
* device-session credential.
|
|
36
|
+
* device-session credential (the one cross-app session join point).
|
|
37
|
+
* - `app`: an app's OWN items, readable by no other app: its session
|
|
38
|
+
* credential (`oxy.auth.v1` and the rest of the SDK's session store), its
|
|
39
|
+
* identity pin, its settings, the Peable wallet's imported phrases. The group
|
|
40
|
+
* is the app's application identifier, which Apple makes every app a member
|
|
41
|
+
* of; no app lists it and no other app can name it. Android's counterpart is
|
|
42
|
+
* the app's own storage (ADR 0062 D6).
|
|
36
43
|
*
|
|
37
44
|
* The ROLE of this binary comes from the keychain itself: an item written
|
|
38
45
|
* without a group lands in the app's default group, the first entry of its
|
|
39
46
|
* `keychain-access-groups` entitlement, and `@oxy.so/app-preset` lets only
|
|
40
47
|
* Commons put the identity group first and only the Peable wallet the wallet
|
|
41
48
|
* group. The same probe gives the team prefix, which `kSecAttrAccessGroup`
|
|
42
|
-
* needs and JavaScript cannot otherwise see
|
|
49
|
+
* needs and JavaScript cannot otherwise see; a second probe proves the app's
|
|
50
|
+
* own group (`<prefix><bundle id>`) is writable.
|
|
43
51
|
*
|
|
44
52
|
* Android and web have no access groups: there every helper here answers the
|
|
45
53
|
* single-store behaviour (`ungrouped`), and Android's narrowing is Commons'
|
|
@@ -66,10 +74,6 @@ class KeychainAccessUnavailableError extends Error {
|
|
|
66
74
|
}
|
|
67
75
|
}
|
|
68
76
|
exports.KeychainAccessUnavailableError = KeychainAccessUnavailableError;
|
|
69
|
-
/**
|
|
70
|
-
* The single place each item class is assigned a group. A new kind of item
|
|
71
|
-
* (F1's retained keys, for one) picks its class and reuses everything below.
|
|
72
|
-
*/
|
|
73
77
|
function keychainGroupFor(item) {
|
|
74
78
|
switch (item) {
|
|
75
79
|
case 'identity-secret':
|
|
@@ -78,6 +82,8 @@ function keychainGroupFor(item) {
|
|
|
78
82
|
return 'wallet';
|
|
79
83
|
case 'shared':
|
|
80
84
|
return 'shared';
|
|
85
|
+
case 'app':
|
|
86
|
+
return 'app';
|
|
81
87
|
}
|
|
82
88
|
}
|
|
83
89
|
/** Whether `role` holds the group `item` lives in. */
|
|
@@ -88,11 +94,14 @@ function mayHoldKeychainItem(role, item) {
|
|
|
88
94
|
case 'wallet':
|
|
89
95
|
return item !== 'identity-secret';
|
|
90
96
|
case 'member':
|
|
91
|
-
return item === 'shared';
|
|
97
|
+
return item === 'shared' || item === 'app';
|
|
92
98
|
}
|
|
93
99
|
}
|
|
94
|
-
/**
|
|
95
|
-
|
|
100
|
+
/**
|
|
101
|
+
* Parse the default access group the probe reported, and the app's own group
|
|
102
|
+
* when the second probe proved it. Exported for tests.
|
|
103
|
+
*/
|
|
104
|
+
function keychainAccessFromDefaultGroup(defaultGroup, appGroup) {
|
|
96
105
|
const dot = defaultGroup.indexOf('.');
|
|
97
106
|
if (dot <= 0 || dot === defaultGroup.length - 1) {
|
|
98
107
|
// Not `<prefix>.<group>`: an unsigned simulator build. Nothing to split.
|
|
@@ -105,7 +114,9 @@ function keychainAccessFromDefaultGroup(defaultGroup) {
|
|
|
105
114
|
: group === exports.KEYCHAIN_ACCESS_GROUPS.wallet
|
|
106
115
|
? 'wallet'
|
|
107
116
|
: 'member';
|
|
108
|
-
return
|
|
117
|
+
return appGroup?.startsWith(prefix)
|
|
118
|
+
? { mode: 'grouped', prefix, defaultGroup, role, appGroup }
|
|
119
|
+
: { mode: 'grouped', prefix, defaultGroup, role };
|
|
109
120
|
}
|
|
110
121
|
let resolvedAccess = null;
|
|
111
122
|
/**
|
|
@@ -133,28 +144,62 @@ async function resolveKeychainAccess() {
|
|
|
133
144
|
if (probe.state === 'unavailable') {
|
|
134
145
|
throw new KeychainAccessUnavailableError(probe.cause);
|
|
135
146
|
}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
147
|
+
if (probe.state !== 'resolved') {
|
|
148
|
+
resolvedAccess = { mode: 'ungrouped' };
|
|
149
|
+
return resolvedAccess;
|
|
150
|
+
}
|
|
151
|
+
const access = keychainAccessFromDefaultGroup(probe.accessGroup);
|
|
152
|
+
if (access.mode === 'ungrouped') {
|
|
153
|
+
resolvedAccess = access;
|
|
154
|
+
return resolvedAccess;
|
|
155
|
+
}
|
|
156
|
+
// The app's own group. A thrown probe is NOT degraded to "no app group": this
|
|
157
|
+
// process would then write the app's items to the old place while the next
|
|
158
|
+
// one reads the new place first, and see a stale value.
|
|
159
|
+
let own;
|
|
160
|
+
try {
|
|
161
|
+
own = await (0, protocol_1.loadKeychainApplicationAccessGroup)(access.prefix);
|
|
162
|
+
}
|
|
163
|
+
catch (cause) {
|
|
164
|
+
throw new KeychainAccessUnavailableError(cause);
|
|
165
|
+
}
|
|
166
|
+
if (own.state === 'unavailable') {
|
|
167
|
+
throw new KeychainAccessUnavailableError(own.cause);
|
|
168
|
+
}
|
|
169
|
+
resolvedAccess = keychainAccessFromDefaultGroup(probe.accessGroup, own.state === 'resolved' ? own.accessGroup : undefined);
|
|
140
170
|
return resolvedAccess;
|
|
141
171
|
}
|
|
142
172
|
/** Forget the memoized access. Tests only. @internal */
|
|
143
173
|
function resetKeychainAccessForTests() {
|
|
144
174
|
resolvedAccess = null;
|
|
145
175
|
}
|
|
146
|
-
/**
|
|
176
|
+
/**
|
|
177
|
+
* The full `kSecAttrAccessGroup` value of `group`, or `undefined` for "no
|
|
178
|
+
* group" (the app's default): always when ungrouped, and for `app` on a build
|
|
179
|
+
* that cannot place items there (see {@link ownKeychainItemPlan}).
|
|
180
|
+
*/
|
|
147
181
|
function keychainAccessGroupValue(access, group) {
|
|
148
|
-
|
|
182
|
+
if (access.mode === 'ungrouped') {
|
|
183
|
+
return undefined;
|
|
184
|
+
}
|
|
185
|
+
if (group === 'app') {
|
|
186
|
+
// Commons' and the Peable wallet's default groups are theirs (no other app
|
|
187
|
+
// writes its own items there), so a build without the app-group probe
|
|
188
|
+
// keeps its own items there, as ADR 0062 D3 put them. Every other app's
|
|
189
|
+
// default is the shared group: no group, the calls it always made.
|
|
190
|
+
return access.appGroup ?? (access.role === 'member' ? undefined : access.defaultGroup);
|
|
191
|
+
}
|
|
192
|
+
return `${access.prefix}${exports.KEYCHAIN_ACCESS_GROUPS[group]}`;
|
|
149
193
|
}
|
|
150
194
|
function option(value) {
|
|
151
195
|
return value === undefined ? {} : { accessGroup: value };
|
|
152
196
|
}
|
|
153
197
|
/**
|
|
154
|
-
* The option bags a READ of `item` tries, in order: its own group, then
|
|
155
|
-
*
|
|
156
|
-
* drops that fallback). Empty when this
|
|
157
|
-
* answers `null` without touching the
|
|
198
|
+
* The option bags a READ of `item` tries, in order: its own group, then, for
|
|
199
|
+
* an identity secret or a spending key, the shared group, where those lived
|
|
200
|
+
* before the split (release 4 of the doc drops that fallback). Empty when this
|
|
201
|
+
* app may not hold the item: the caller answers `null` without touching the
|
|
202
|
+
* keychain. An app's own items (`app`) take {@link ownKeychainItemPlan}.
|
|
158
203
|
*/
|
|
159
204
|
function keychainReadPlan(access, item) {
|
|
160
205
|
if (access.mode === 'ungrouped') {
|
|
@@ -165,7 +210,8 @@ function keychainReadPlan(access, item) {
|
|
|
165
210
|
}
|
|
166
211
|
const own = keychainGroupFor(item);
|
|
167
212
|
const plan = [option(keychainAccessGroupValue(access, own))];
|
|
168
|
-
if (own
|
|
213
|
+
if (own === 'identity' || own === 'wallet') {
|
|
214
|
+
// Where the split moved these items from.
|
|
169
215
|
plan.push(option(keychainAccessGroupValue(access, 'shared')));
|
|
170
216
|
}
|
|
171
217
|
return plan;
|
|
@@ -192,25 +238,50 @@ function keychainWriteOption(access, item) {
|
|
|
192
238
|
return option(keychainAccessGroupValue(access, keychainGroupFor(item)));
|
|
193
239
|
}
|
|
194
240
|
/**
|
|
195
|
-
* The plan for an app's OWN items (
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
*
|
|
201
|
-
*
|
|
241
|
+
* The plan for an app's OWN items (class `app`, ADR 0062 D6): written to the
|
|
242
|
+
* app's own group (`<prefix><bundle id>`), which no other app can read, and
|
|
243
|
+
* read from it FIRST. Then:
|
|
244
|
+
*
|
|
245
|
+
* - from where this app kept them before: Commons' and the Peable wallet's
|
|
246
|
+
* default group (D3), which no other app writes its own items to;
|
|
247
|
+
* - for `this-app` names, from every group (`{}`), where they were before the
|
|
248
|
+
* split; deletes reach every group;
|
|
249
|
+
* - for `every-oxy-app` names, nowhere else (see {@link OwnKeychainItemNames}),
|
|
250
|
+
* and deletes reach exactly the read locations, earlier ones first, so a
|
|
251
|
+
* failed delete never leaves an older copy as the only one;
|
|
252
|
+
* - for `every-oxy-app-withholding` names, the shared group last on read and
|
|
253
|
+
* first on delete.
|
|
202
254
|
*
|
|
203
|
-
*
|
|
255
|
+
* Nothing is copied or deleted to migrate: the next write lands in the app
|
|
256
|
+
* group and the read order makes it win, so a crash or a failure at any step
|
|
257
|
+
* leaves the value the app had.
|
|
258
|
+
*
|
|
259
|
+
* A native build whose module predates the app-group probe (`appGroup`
|
|
260
|
+
* absent) keeps its items where that build put them: Commons and the wallet in
|
|
261
|
+
* their default group, every other app the calls it always made (the one
|
|
262
|
+
* shared item, until that app's next native build). Android and web: no
|
|
263
|
+
* options.
|
|
204
264
|
*/
|
|
205
|
-
function ownKeychainItemPlan(access) {
|
|
206
|
-
if (access.mode === 'ungrouped'
|
|
265
|
+
function ownKeychainItemPlan(access, names = 'this-app') {
|
|
266
|
+
if (access.mode === 'ungrouped') {
|
|
207
267
|
return { read: [{}], write: {}, delete: [{}] };
|
|
208
268
|
}
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
write:
|
|
212
|
-
|
|
213
|
-
|
|
269
|
+
const own = keychainAccessGroupValue(access, keychainGroupFor('app'));
|
|
270
|
+
if (own === undefined) {
|
|
271
|
+
return { read: [{}], write: {}, delete: [{}] };
|
|
272
|
+
}
|
|
273
|
+
const earlier = access.role !== 'member' && own !== access.defaultGroup ? [option(access.defaultGroup)] : [];
|
|
274
|
+
const mine = [option(own), ...earlier];
|
|
275
|
+
switch (names) {
|
|
276
|
+
case 'this-app':
|
|
277
|
+
return { read: [...mine, {}], write: option(own), delete: [{}] };
|
|
278
|
+
case 'every-oxy-app':
|
|
279
|
+
return { read: mine, write: option(own), delete: [...mine].reverse() };
|
|
280
|
+
case 'every-oxy-app-withholding': {
|
|
281
|
+
const read = [...mine, option(keychainAccessGroupValue(access, 'shared'))];
|
|
282
|
+
return { read, write: option(own), delete: [...read].reverse() };
|
|
283
|
+
}
|
|
284
|
+
}
|
|
214
285
|
}
|
|
215
286
|
/** Read `key` through `plan`: the first non-null answer. A thrown read propagates. */
|
|
216
287
|
async function readThroughPlan(store, key, options, plan) {
|
|
@@ -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 = {
|
|
@@ -49,10 +57,6 @@ export class KeychainAccessUnavailableError extends Error {
|
|
|
49
57
|
this.name = 'KeychainAccessUnavailableError';
|
|
50
58
|
}
|
|
51
59
|
}
|
|
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
60
|
export function keychainGroupFor(item) {
|
|
57
61
|
switch (item) {
|
|
58
62
|
case 'identity-secret':
|
|
@@ -61,6 +65,8 @@ export function keychainGroupFor(item) {
|
|
|
61
65
|
return 'wallet';
|
|
62
66
|
case 'shared':
|
|
63
67
|
return 'shared';
|
|
68
|
+
case 'app':
|
|
69
|
+
return 'app';
|
|
64
70
|
}
|
|
65
71
|
}
|
|
66
72
|
/** Whether `role` holds the group `item` lives in. */
|
|
@@ -71,11 +77,14 @@ export function mayHoldKeychainItem(role, item) {
|
|
|
71
77
|
case 'wallet':
|
|
72
78
|
return item !== 'identity-secret';
|
|
73
79
|
case 'member':
|
|
74
|
-
return item === 'shared';
|
|
80
|
+
return item === 'shared' || item === 'app';
|
|
75
81
|
}
|
|
76
82
|
}
|
|
77
|
-
/**
|
|
78
|
-
|
|
83
|
+
/**
|
|
84
|
+
* Parse the default access group the probe reported, and the app's own group
|
|
85
|
+
* when the second probe proved it. Exported for tests.
|
|
86
|
+
*/
|
|
87
|
+
export function keychainAccessFromDefaultGroup(defaultGroup, appGroup) {
|
|
79
88
|
const dot = defaultGroup.indexOf('.');
|
|
80
89
|
if (dot <= 0 || dot === defaultGroup.length - 1) {
|
|
81
90
|
// Not `<prefix>.<group>`: an unsigned simulator build. Nothing to split.
|
|
@@ -88,7 +97,9 @@ export function keychainAccessFromDefaultGroup(defaultGroup) {
|
|
|
88
97
|
: group === KEYCHAIN_ACCESS_GROUPS.wallet
|
|
89
98
|
? 'wallet'
|
|
90
99
|
: 'member';
|
|
91
|
-
return
|
|
100
|
+
return appGroup?.startsWith(prefix)
|
|
101
|
+
? { mode: 'grouped', prefix, defaultGroup, role, appGroup }
|
|
102
|
+
: { mode: 'grouped', prefix, defaultGroup, role };
|
|
92
103
|
}
|
|
93
104
|
let resolvedAccess = null;
|
|
94
105
|
/**
|
|
@@ -116,28 +127,62 @@ export async function resolveKeychainAccess() {
|
|
|
116
127
|
if (probe.state === 'unavailable') {
|
|
117
128
|
throw new KeychainAccessUnavailableError(probe.cause);
|
|
118
129
|
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
130
|
+
if (probe.state !== 'resolved') {
|
|
131
|
+
resolvedAccess = { mode: 'ungrouped' };
|
|
132
|
+
return resolvedAccess;
|
|
133
|
+
}
|
|
134
|
+
const access = keychainAccessFromDefaultGroup(probe.accessGroup);
|
|
135
|
+
if (access.mode === 'ungrouped') {
|
|
136
|
+
resolvedAccess = access;
|
|
137
|
+
return resolvedAccess;
|
|
138
|
+
}
|
|
139
|
+
// The app's own group. A thrown probe is NOT degraded to "no app group": this
|
|
140
|
+
// process would then write the app's items to the old place while the next
|
|
141
|
+
// one reads the new place first, and see a stale value.
|
|
142
|
+
let own;
|
|
143
|
+
try {
|
|
144
|
+
own = await loadKeychainApplicationAccessGroup(access.prefix);
|
|
145
|
+
}
|
|
146
|
+
catch (cause) {
|
|
147
|
+
throw new KeychainAccessUnavailableError(cause);
|
|
148
|
+
}
|
|
149
|
+
if (own.state === 'unavailable') {
|
|
150
|
+
throw new KeychainAccessUnavailableError(own.cause);
|
|
151
|
+
}
|
|
152
|
+
resolvedAccess = keychainAccessFromDefaultGroup(probe.accessGroup, own.state === 'resolved' ? own.accessGroup : undefined);
|
|
123
153
|
return resolvedAccess;
|
|
124
154
|
}
|
|
125
155
|
/** Forget the memoized access. Tests only. @internal */
|
|
126
156
|
export function resetKeychainAccessForTests() {
|
|
127
157
|
resolvedAccess = null;
|
|
128
158
|
}
|
|
129
|
-
/**
|
|
159
|
+
/**
|
|
160
|
+
* The full `kSecAttrAccessGroup` value of `group`, or `undefined` for "no
|
|
161
|
+
* group" (the app's default): always when ungrouped, and for `app` on a build
|
|
162
|
+
* that cannot place items there (see {@link ownKeychainItemPlan}).
|
|
163
|
+
*/
|
|
130
164
|
export function keychainAccessGroupValue(access, group) {
|
|
131
|
-
|
|
165
|
+
if (access.mode === 'ungrouped') {
|
|
166
|
+
return undefined;
|
|
167
|
+
}
|
|
168
|
+
if (group === 'app') {
|
|
169
|
+
// Commons' and the Peable wallet's default groups are theirs (no other app
|
|
170
|
+
// writes its own items there), so a build without the app-group probe
|
|
171
|
+
// keeps its own items there, as ADR 0062 D3 put them. Every other app's
|
|
172
|
+
// default is the shared group: no group, the calls it always made.
|
|
173
|
+
return access.appGroup ?? (access.role === 'member' ? undefined : access.defaultGroup);
|
|
174
|
+
}
|
|
175
|
+
return `${access.prefix}${KEYCHAIN_ACCESS_GROUPS[group]}`;
|
|
132
176
|
}
|
|
133
177
|
function option(value) {
|
|
134
178
|
return value === undefined ? {} : { accessGroup: value };
|
|
135
179
|
}
|
|
136
180
|
/**
|
|
137
|
-
* The option bags a READ of `item` tries, in order: its own group, then
|
|
138
|
-
*
|
|
139
|
-
* drops that fallback). Empty when this
|
|
140
|
-
* answers `null` without touching the
|
|
181
|
+
* The option bags a READ of `item` tries, in order: its own group, then, for
|
|
182
|
+
* an identity secret or a spending key, the shared group, where those lived
|
|
183
|
+
* before the split (release 4 of the doc drops that fallback). Empty when this
|
|
184
|
+
* app may not hold the item: the caller answers `null` without touching the
|
|
185
|
+
* keychain. An app's own items (`app`) take {@link ownKeychainItemPlan}.
|
|
141
186
|
*/
|
|
142
187
|
export function keychainReadPlan(access, item) {
|
|
143
188
|
if (access.mode === 'ungrouped') {
|
|
@@ -148,7 +193,8 @@ export function keychainReadPlan(access, item) {
|
|
|
148
193
|
}
|
|
149
194
|
const own = keychainGroupFor(item);
|
|
150
195
|
const plan = [option(keychainAccessGroupValue(access, own))];
|
|
151
|
-
if (own
|
|
196
|
+
if (own === 'identity' || own === 'wallet') {
|
|
197
|
+
// Where the split moved these items from.
|
|
152
198
|
plan.push(option(keychainAccessGroupValue(access, 'shared')));
|
|
153
199
|
}
|
|
154
200
|
return plan;
|
|
@@ -175,25 +221,50 @@ export function keychainWriteOption(access, item) {
|
|
|
175
221
|
return option(keychainAccessGroupValue(access, keychainGroupFor(item)));
|
|
176
222
|
}
|
|
177
223
|
/**
|
|
178
|
-
* The plan for an app's OWN items (
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
224
|
+
* The plan for an app's OWN items (class `app`, ADR 0062 D6): written to the
|
|
225
|
+
* app's own group (`<prefix><bundle id>`), which no other app can read, and
|
|
226
|
+
* read from it FIRST. Then:
|
|
227
|
+
*
|
|
228
|
+
* - from where this app kept them before: Commons' and the Peable wallet's
|
|
229
|
+
* default group (D3), which no other app writes its own items to;
|
|
230
|
+
* - for `this-app` names, from every group (`{}`), where they were before the
|
|
231
|
+
* split; deletes reach every group;
|
|
232
|
+
* - for `every-oxy-app` names, nowhere else (see {@link OwnKeychainItemNames}),
|
|
233
|
+
* and deletes reach exactly the read locations, earlier ones first, so a
|
|
234
|
+
* failed delete never leaves an older copy as the only one;
|
|
235
|
+
* - for `every-oxy-app-withholding` names, the shared group last on read and
|
|
236
|
+
* first on delete.
|
|
185
237
|
*
|
|
186
|
-
*
|
|
238
|
+
* Nothing is copied or deleted to migrate: the next write lands in the app
|
|
239
|
+
* group and the read order makes it win, so a crash or a failure at any step
|
|
240
|
+
* leaves the value the app had.
|
|
241
|
+
*
|
|
242
|
+
* A native build whose module predates the app-group probe (`appGroup`
|
|
243
|
+
* absent) keeps its items where that build put them: Commons and the wallet in
|
|
244
|
+
* their default group, every other app the calls it always made (the one
|
|
245
|
+
* shared item, until that app's next native build). Android and web: no
|
|
246
|
+
* options.
|
|
187
247
|
*/
|
|
188
|
-
export function ownKeychainItemPlan(access) {
|
|
189
|
-
if (access.mode === 'ungrouped'
|
|
248
|
+
export function ownKeychainItemPlan(access, names = 'this-app') {
|
|
249
|
+
if (access.mode === 'ungrouped') {
|
|
190
250
|
return { read: [{}], write: {}, delete: [{}] };
|
|
191
251
|
}
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
write:
|
|
195
|
-
|
|
196
|
-
|
|
252
|
+
const own = keychainAccessGroupValue(access, keychainGroupFor('app'));
|
|
253
|
+
if (own === undefined) {
|
|
254
|
+
return { read: [{}], write: {}, delete: [{}] };
|
|
255
|
+
}
|
|
256
|
+
const earlier = access.role !== 'member' && own !== access.defaultGroup ? [option(access.defaultGroup)] : [];
|
|
257
|
+
const mine = [option(own), ...earlier];
|
|
258
|
+
switch (names) {
|
|
259
|
+
case 'this-app':
|
|
260
|
+
return { read: [...mine, {}], write: option(own), delete: [{}] };
|
|
261
|
+
case 'every-oxy-app':
|
|
262
|
+
return { read: mine, write: option(own), delete: [...mine].reverse() };
|
|
263
|
+
case 'every-oxy-app-withholding': {
|
|
264
|
+
const read = [...mine, option(keychainAccessGroupValue(access, 'shared'))];
|
|
265
|
+
return { read, write: option(own), delete: [...read].reverse() };
|
|
266
|
+
}
|
|
267
|
+
}
|
|
197
268
|
}
|
|
198
269
|
/** Read `key` through `plan`: the first non-null answer. A thrown read propagates. */
|
|
199
270
|
export async function readThroughPlan(store, key, options, plan) {
|
|
@@ -20,7 +20,7 @@ export type { IdentitySignerStore } from './identitySigner';
|
|
|
20
20
|
export { generateOrganizationKey, organizationKeyFromPhrase, storeOrganizationKey, getOrganizationPublicKey, getOrganizationPrivateKey, getOrganizationRecoveryPhrase, listOrganizationKeys, deleteOrganizationKey, syncOrganizationKeys, isOrganizationAccountId, isHealthyOrganizationKeyPair, } from './organizationKeys';
|
|
21
21
|
export type { OrganizationKeyPair, GeneratedOrganizationKey, StoredOrganizationKey, } from './organizationKeys';
|
|
22
22
|
export { KEYCHAIN_ACCESS_GROUPS, KeychainAccessUnavailableError, keychainGroupFor, mayHoldKeychainItem, resolveKeychainAccess, keychainAccessGroupValue, keychainReadPlan, keychainDeletePlan, keychainWriteOption, ownKeychainItemPlan, readThroughPlan, deleteThroughPlan, groupedKeychainStore, } from '../utils/keychainAccessGroups';
|
|
23
|
-
export type { KeychainAccess, KeychainAccessGroupName, KeychainGroupOption, KeychainItemClass, KeychainRole, KeychainStoreLike, } from '../utils/keychainAccessGroups';
|
|
23
|
+
export type { KeychainAccess, KeychainAccessGroupName, KeychainGroup, KeychainGroupOption, KeychainItemClass, KeychainRole, KeychainStoreLike, OwnKeychainItemNames, } from '../utils/keychainAccessGroups';
|
|
24
24
|
export { ensureKeychainGroupsMigrated } from './keychainGroupMigration';
|
|
25
25
|
export type { KeychainGroupMigrationReport, KeychainItemMigrationOutcome, } from './keychainGroupMigration';
|
|
26
26
|
export { retireIdentityKey, listRetiredIdentityKeys, forgetRetiredIdentityKey, forgetRetiredIdentityKeys, syncRetiredKeys, retiredKeyFromPhrase, } from './retiredKeys';
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
*
|
|
17
17
|
* ESM-safe (no `require()`).
|
|
18
18
|
*/
|
|
19
|
+
import type { OwnKeychainItemNames } from '../utils/keychainAccessGroups';
|
|
19
20
|
/**
|
|
20
21
|
* The persisted session credential set for a single origin.
|
|
21
22
|
*
|
|
@@ -124,6 +125,14 @@ export interface NativeKeyValueStorage {
|
|
|
124
125
|
*/
|
|
125
126
|
export declare const AUTH_STATE_STORAGE_KEY = "oxy.auth.v1";
|
|
126
127
|
export declare const AUTOMATIC_IDENTITY_SIGNIN_SUPPRESSED_KEY = "oxy.auth.automatic-identity-signin-suppressed.v1";
|
|
128
|
+
/**
|
|
129
|
+
* What `ownKeychainItemPlan` (ADR 0062 D6) must know about a key a native
|
|
130
|
+
* session store keeps — this module's three and the identity pin: every Oxy app
|
|
131
|
+
* writes it under the same name, so on iOS a copy in the shared group may be
|
|
132
|
+
* another app's session and is never read as this app's. The sign-out marker
|
|
133
|
+
* can only withhold, so a shared copy of it still counts.
|
|
134
|
+
*/
|
|
135
|
+
export declare function nativeSessionItemNames(key: string): OwnKeychainItemNames;
|
|
127
136
|
/**
|
|
128
137
|
* Versioned BEST-EFFORT warm-token storage key. Holds the short-lived
|
|
129
138
|
* `{ accessToken, expiresAt }` pair only. Its write is genuinely non-fatal — a
|
|
@@ -25,12 +25,12 @@ export { resolveAccountDelegationAccess } from './accountDelegationAccess';
|
|
|
25
25
|
export type { AccountDelegationAccess, AccountDelegationNode, } from './accountDelegationAccess';
|
|
26
26
|
export { AccountDialogController, createAccountDialogController, } from './accountDialogController';
|
|
27
27
|
export type { AccountDialogControllerOptions, AccountDialogSnapshot, AccountDialogView, CommonsAvailability, ContextChoiceOutcome, SignInFailureReason, SignInFlowPhase, SignInFlowState, SignInProgress, } from './accountDialogController';
|
|
28
|
-
export { createWebAuthStateStore, createNativeAuthStateStore, createMemoryAuthStateStore, AUTH_STATE_STORAGE_KEY, } from './authStateStore';
|
|
28
|
+
export { createWebAuthStateStore, createNativeAuthStateStore, createMemoryAuthStateStore, nativeSessionItemNames, AUTH_STATE_STORAGE_KEY, AUTH_STATE_TOKEN_STORAGE_KEY, AUTOMATIC_IDENTITY_SIGNIN_SUPPRESSED_KEY, } from './authStateStore';
|
|
29
29
|
export type { PersistedAuthState, AuthStateStore, AuthStateWriteGuard, NativeKeyValueStorage, } from './authStateStore';
|
|
30
30
|
export { createSharedMirroringAuthStateStore, decideSharedDeviceJoin, decideSharedDevicePublish, normalizeSharedDeviceSessionRead, publishProvenDeviceCredential, readLocalDeviceCredential, } from './sharedDeviceCredential';
|
|
31
31
|
export type { SharedDeviceCredential, SharedDeviceCredentialRead, SharedDeviceCredentialStore, SharedDeviceJoinDecision, SharedDeviceJoinSkipReason, SharedDevicePublishDecision, SharedDevicePublishOutcome, SharedDevicePublishSkipReason, } from './sharedDeviceCredential';
|
|
32
32
|
export { KeychainAccessUnavailableError, keychainAccessGroupValue, ownKeychainItemPlan, readThroughPlan, deleteThroughPlan, resolveKeychainAccess, } from '../utils/keychainAccessGroups';
|
|
33
|
-
export type { KeychainAccess, KeychainGroupOption } from '../utils/keychainAccessGroups';
|
|
33
|
+
export type { KeychainAccess, KeychainGroupOption, OwnKeychainItemNames, } from '../utils/keychainAccessGroups';
|
|
34
34
|
export { createWebIdentityPinStore, createNativeIdentityPinStore, createMemoryIdentityPinStore, identityPinMatches, IDENTITY_PIN_STORAGE_KEY, } from './identityPin';
|
|
35
35
|
export type { IdentityPin, IdentityPinStore } from './identityPin';
|
|
36
36
|
export { resolveIdentityPin, establishIdentitySession, } from './identitySession';
|
|
@@ -5,13 +5,21 @@ export declare const KEYCHAIN_ACCESS_GROUPS: {
|
|
|
5
5
|
readonly shared: "group.so.oxy.shared";
|
|
6
6
|
};
|
|
7
7
|
export type KeychainAccessGroupName = keyof typeof KEYCHAIN_ACCESS_GROUPS;
|
|
8
|
+
/**
|
|
9
|
+
* Every group an item class can be assigned: the three Oxy groups, or `app`,
|
|
10
|
+
* this app's own group, whose name is the app's and so is not a constant.
|
|
11
|
+
*/
|
|
12
|
+
export type KeychainGroup = KeychainAccessGroupName | 'app';
|
|
8
13
|
/** What a keychain item IS, which decides its group. See the table above. */
|
|
9
|
-
export type KeychainItemClass = 'identity-secret' | 'spending-key' | 'shared';
|
|
14
|
+
export type KeychainItemClass = 'identity-secret' | 'spending-key' | 'shared' | 'app';
|
|
10
15
|
/** Which holder this binary is, from its default access group. */
|
|
11
16
|
export type KeychainRole = 'holder' | 'wallet' | 'member';
|
|
12
17
|
/**
|
|
13
18
|
* - `grouped`: iOS with the probe answered. `prefix` ends with its dot
|
|
14
|
-
* (`ABCDE12345.`); `defaultGroup` is the full default group string
|
|
19
|
+
* (`ABCDE12345.`); `defaultGroup` is the full default group string;
|
|
20
|
+
* `appGroup`, when the native module proved it, this app's own group
|
|
21
|
+
* (`ABCDE12345.so.oxy.mention`). Absent on a native build that predates that
|
|
22
|
+
* probe: see {@link ownKeychainItemPlan} for what such a build does.
|
|
15
23
|
* - `ungrouped`: Android, web, or an iOS native build without the probe.
|
|
16
24
|
*/
|
|
17
25
|
export type KeychainAccess = {
|
|
@@ -19,6 +27,7 @@ export type KeychainAccess = {
|
|
|
19
27
|
prefix: string;
|
|
20
28
|
defaultGroup: string;
|
|
21
29
|
role: KeychainRole;
|
|
30
|
+
appGroup?: string;
|
|
22
31
|
} | {
|
|
23
32
|
mode: 'ungrouped';
|
|
24
33
|
};
|
|
@@ -36,11 +45,15 @@ export declare class KeychainAccessUnavailableError extends Error {
|
|
|
36
45
|
* The single place each item class is assigned a group. A new kind of item
|
|
37
46
|
* (F1's retained keys, for one) picks its class and reuses everything below.
|
|
38
47
|
*/
|
|
39
|
-
export declare function keychainGroupFor(item: KeychainItemClass): KeychainAccessGroupName;
|
|
48
|
+
export declare function keychainGroupFor(item: Exclude<KeychainItemClass, 'app'>): KeychainAccessGroupName;
|
|
49
|
+
export declare function keychainGroupFor(item: KeychainItemClass): KeychainGroup;
|
|
40
50
|
/** Whether `role` holds the group `item` lives in. */
|
|
41
51
|
export declare function mayHoldKeychainItem(role: KeychainRole, item: KeychainItemClass): boolean;
|
|
42
|
-
/**
|
|
43
|
-
|
|
52
|
+
/**
|
|
53
|
+
* Parse the default access group the probe reported, and the app's own group
|
|
54
|
+
* when the second probe proved it. Exported for tests.
|
|
55
|
+
*/
|
|
56
|
+
export declare function keychainAccessFromDefaultGroup(defaultGroup: string, appGroup?: string): KeychainAccess;
|
|
44
57
|
/**
|
|
45
58
|
* This binary's keychain access, memoized once known. THROWS
|
|
46
59
|
* {@link KeychainAccessUnavailableError} while the probe cannot answer, which
|
|
@@ -49,13 +62,18 @@ export declare function keychainAccessFromDefaultGroup(defaultGroup: string): Ke
|
|
|
49
62
|
export declare function resolveKeychainAccess(): Promise<KeychainAccess>;
|
|
50
63
|
/** Forget the memoized access. Tests only. @internal */
|
|
51
64
|
export declare function resetKeychainAccessForTests(): void;
|
|
52
|
-
/** The full `kSecAttrAccessGroup` value of `group`, or `undefined` when ungrouped. */
|
|
53
|
-
export declare function keychainAccessGroupValue(access: KeychainAccess, group: KeychainAccessGroupName): string | undefined;
|
|
54
65
|
/**
|
|
55
|
-
* The
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
|
|
66
|
+
* The full `kSecAttrAccessGroup` value of `group`, or `undefined` for "no
|
|
67
|
+
* group" (the app's default): always when ungrouped, and for `app` on a build
|
|
68
|
+
* that cannot place items there (see {@link ownKeychainItemPlan}).
|
|
69
|
+
*/
|
|
70
|
+
export declare function keychainAccessGroupValue(access: KeychainAccess, group: KeychainGroup): string | undefined;
|
|
71
|
+
/**
|
|
72
|
+
* The option bags a READ of `item` tries, in order: its own group, then, for
|
|
73
|
+
* an identity secret or a spending key, the shared group, where those lived
|
|
74
|
+
* before the split (release 4 of the doc drops that fallback). Empty when this
|
|
75
|
+
* app may not hold the item: the caller answers `null` without touching the
|
|
76
|
+
* keychain. An app's own items (`app`) take {@link ownKeychainItemPlan}.
|
|
59
77
|
*/
|
|
60
78
|
export declare function keychainReadPlan(access: KeychainAccess, item: KeychainItemClass): KeychainGroupOption[];
|
|
61
79
|
/**
|
|
@@ -70,17 +88,51 @@ export declare function keychainDeletePlan(access: KeychainAccess, item: Keychai
|
|
|
70
88
|
*/
|
|
71
89
|
export declare function keychainWriteOption(access: KeychainAccess, item: KeychainItemClass): KeychainGroupOption;
|
|
72
90
|
/**
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
91
|
+
* Who else writes an own item's NAME, which decides whose an earlier copy of
|
|
92
|
+
* it can be.
|
|
93
|
+
*
|
|
94
|
+
* - `this-app`: no other app uses the name (the Peable wallet's own items). A
|
|
95
|
+
* copy anywhere this app can read is its own.
|
|
96
|
+
* - `every-oxy-app`: every Oxy app writes the same name (the SDK's session
|
|
97
|
+
* store: `oxy.auth.v1`, the warm token, the identity pin). Before the
|
|
98
|
+
* app group, every iOS app but Commons and the Peable wallet wrote it to the
|
|
99
|
+
* ONE copy in the shared group, so a copy there may be another app's
|
|
100
|
+
* session, or Commons' vault session. Only this app's own locations are
|
|
101
|
+
* read, written or deleted; the shared copy is left to the apps still
|
|
102
|
+
* running a build that uses it.
|
|
103
|
+
* - `every-oxy-app-withholding`: as `every-oxy-app`, for a value whose only
|
|
104
|
+
* effect is to WITHHOLD (the explicit sign-out marker). A shared copy still
|
|
105
|
+
* counts — honouring another app's marker can keep this app from signing in
|
|
106
|
+
* automatically, never sign it in — and its delete reaches that copy, as a
|
|
107
|
+
* release always did before.
|
|
108
|
+
*/
|
|
109
|
+
export type OwnKeychainItemNames = 'this-app' | 'every-oxy-app' | 'every-oxy-app-withholding';
|
|
110
|
+
/**
|
|
111
|
+
* The plan for an app's OWN items (class `app`, ADR 0062 D6): written to the
|
|
112
|
+
* app's own group (`<prefix><bundle id>`), which no other app can read, and
|
|
113
|
+
* read from it FIRST. Then:
|
|
114
|
+
*
|
|
115
|
+
* - from where this app kept them before: Commons' and the Peable wallet's
|
|
116
|
+
* default group (D3), which no other app writes its own items to;
|
|
117
|
+
* - for `this-app` names, from every group (`{}`), where they were before the
|
|
118
|
+
* split; deletes reach every group;
|
|
119
|
+
* - for `every-oxy-app` names, nowhere else (see {@link OwnKeychainItemNames}),
|
|
120
|
+
* and deletes reach exactly the read locations, earlier ones first, so a
|
|
121
|
+
* failed delete never leaves an older copy as the only one;
|
|
122
|
+
* - for `every-oxy-app-withholding` names, the shared group last on read and
|
|
123
|
+
* first on delete.
|
|
124
|
+
*
|
|
125
|
+
* Nothing is copied or deleted to migrate: the next write lands in the app
|
|
126
|
+
* group and the read order makes it win, so a crash or a failure at any step
|
|
127
|
+
* leaves the value the app had.
|
|
80
128
|
*
|
|
81
|
-
*
|
|
129
|
+
* A native build whose module predates the app-group probe (`appGroup`
|
|
130
|
+
* absent) keeps its items where that build put them: Commons and the wallet in
|
|
131
|
+
* their default group, every other app the calls it always made (the one
|
|
132
|
+
* shared item, until that app's next native build). Android and web: no
|
|
133
|
+
* options.
|
|
82
134
|
*/
|
|
83
|
-
export declare function ownKeychainItemPlan(access: KeychainAccess): {
|
|
135
|
+
export declare function ownKeychainItemPlan(access: KeychainAccess, names?: OwnKeychainItemNames): {
|
|
84
136
|
read: KeychainGroupOption[];
|
|
85
137
|
write: KeychainGroupOption;
|
|
86
138
|
delete: KeychainGroupOption[];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oxy.so/core",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.31.0",
|
|
4
4
|
"description": "OxyHQ SDK Foundation — API client, authentication, cryptographic identity, and shared utilities",
|
|
5
5
|
"main": "dist/cjs/index.js",
|
|
6
6
|
"module": "dist/esm/index.js",
|
|
@@ -182,7 +182,7 @@
|
|
|
182
182
|
"@noble/ciphers": "^2.4.0",
|
|
183
183
|
"@noble/hashes": "^2.4.0",
|
|
184
184
|
"@oxy.so/contracts": "^6.12.0",
|
|
185
|
-
"@oxy.so/protocol": "^1.
|
|
185
|
+
"@oxy.so/protocol": "^1.10.0",
|
|
186
186
|
"@oxy.so/telemetry": "^2.1.0",
|
|
187
187
|
"@scure/bip39": "^2.4.0",
|
|
188
188
|
"buffer": "^6.0.3",
|
package/src/crypto/index.ts
CHANGED
|
@@ -64,10 +64,12 @@ export {
|
|
|
64
64
|
export type {
|
|
65
65
|
KeychainAccess,
|
|
66
66
|
KeychainAccessGroupName,
|
|
67
|
+
KeychainGroup,
|
|
67
68
|
KeychainGroupOption,
|
|
68
69
|
KeychainItemClass,
|
|
69
70
|
KeychainRole,
|
|
70
71
|
KeychainStoreLike,
|
|
72
|
+
OwnKeychainItemNames,
|
|
71
73
|
} from '../utils/keychainAccessGroups';
|
|
72
74
|
export { ensureKeychainGroupsMigrated } from './keychainGroupMigration';
|
|
73
75
|
export type {
|
|
@@ -110,7 +110,12 @@ type Unit =
|
|
|
110
110
|
merge?: (shared: string, target: string) => string | null;
|
|
111
111
|
};
|
|
112
112
|
|
|
113
|
-
const slot = (
|
|
113
|
+
const slot = (
|
|
114
|
+
key: string,
|
|
115
|
+
item: Exclude<KeychainItemClass, 'app'>,
|
|
116
|
+
service?: string,
|
|
117
|
+
deviceOnly = true,
|
|
118
|
+
): Slot => ({
|
|
114
119
|
key,
|
|
115
120
|
service,
|
|
116
121
|
group: keychainGroupFor(item),
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import { logger } from '../logger';
|
|
21
|
+
import type { OwnKeychainItemNames } from '../utils/keychainAccessGroups';
|
|
21
22
|
|
|
22
23
|
/**
|
|
23
24
|
* The persisted session credential set for a single origin.
|
|
@@ -150,6 +151,19 @@ export const AUTH_STATE_STORAGE_KEY = 'oxy.auth.v1';
|
|
|
150
151
|
export const AUTOMATIC_IDENTITY_SIGNIN_SUPPRESSED_KEY =
|
|
151
152
|
'oxy.auth.automatic-identity-signin-suppressed.v1';
|
|
152
153
|
|
|
154
|
+
/**
|
|
155
|
+
* What `ownKeychainItemPlan` (ADR 0062 D6) must know about a key a native
|
|
156
|
+
* session store keeps — this module's three and the identity pin: every Oxy app
|
|
157
|
+
* writes it under the same name, so on iOS a copy in the shared group may be
|
|
158
|
+
* another app's session and is never read as this app's. The sign-out marker
|
|
159
|
+
* can only withhold, so a shared copy of it still counts.
|
|
160
|
+
*/
|
|
161
|
+
export function nativeSessionItemNames(key: string): OwnKeychainItemNames {
|
|
162
|
+
return key === AUTOMATIC_IDENTITY_SIGNIN_SUPPRESSED_KEY
|
|
163
|
+
? 'every-oxy-app-withholding'
|
|
164
|
+
: 'every-oxy-app';
|
|
165
|
+
}
|
|
166
|
+
|
|
153
167
|
/**
|
|
154
168
|
* Versioned BEST-EFFORT warm-token storage key. Holds the short-lived
|
|
155
169
|
* `{ accessToken, expiresAt }` pair only. Its write is genuinely non-fatal — a
|
package/src/session/index.ts
CHANGED
|
@@ -134,7 +134,10 @@ export {
|
|
|
134
134
|
createWebAuthStateStore,
|
|
135
135
|
createNativeAuthStateStore,
|
|
136
136
|
createMemoryAuthStateStore,
|
|
137
|
+
nativeSessionItemNames,
|
|
137
138
|
AUTH_STATE_STORAGE_KEY,
|
|
139
|
+
AUTH_STATE_TOKEN_STORAGE_KEY,
|
|
140
|
+
AUTOMATIC_IDENTITY_SIGNIN_SUPPRESSED_KEY,
|
|
138
141
|
} from './authStateStore';
|
|
139
142
|
export type {
|
|
140
143
|
PersistedAuthState,
|
|
@@ -176,7 +179,11 @@ export {
|
|
|
176
179
|
deleteThroughPlan,
|
|
177
180
|
resolveKeychainAccess,
|
|
178
181
|
} from '../utils/keychainAccessGroups';
|
|
179
|
-
export type {
|
|
182
|
+
export type {
|
|
183
|
+
KeychainAccess,
|
|
184
|
+
KeychainGroupOption,
|
|
185
|
+
OwnKeychainItemNames,
|
|
186
|
+
} from '../utils/keychainAccessGroups';
|
|
180
187
|
|
|
181
188
|
// Identity-bound sessions (the identity vault). The pin is the durable
|
|
182
189
|
// `{publicKey, accountId}` binding between this device's PRIMARY identity key
|
|
@@ -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,12 @@
|
|
|
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 {
|
|
44
|
+
import {
|
|
45
|
+
type KeychainApplicationAccessGroup,
|
|
46
|
+
type KeychainDefaultAccessGroup,
|
|
47
|
+
loadKeychainApplicationAccessGroup,
|
|
48
|
+
loadKeychainDefaultAccessGroup,
|
|
49
|
+
} from '@oxy.so/protocol';
|
|
37
50
|
import { isIOS } from './platform';
|
|
38
51
|
|
|
39
52
|
/** The group names after the team prefix, exactly as the entitlements list them. */
|
|
@@ -45,19 +58,34 @@ export const KEYCHAIN_ACCESS_GROUPS = {
|
|
|
45
58
|
|
|
46
59
|
export type KeychainAccessGroupName = keyof typeof KEYCHAIN_ACCESS_GROUPS;
|
|
47
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Every group an item class can be assigned: the three Oxy groups, or `app`,
|
|
63
|
+
* this app's own group, whose name is the app's and so is not a constant.
|
|
64
|
+
*/
|
|
65
|
+
export type KeychainGroup = KeychainAccessGroupName | 'app';
|
|
66
|
+
|
|
48
67
|
/** What a keychain item IS, which decides its group. See the table above. */
|
|
49
|
-
export type KeychainItemClass = 'identity-secret' | 'spending-key' | 'shared';
|
|
68
|
+
export type KeychainItemClass = 'identity-secret' | 'spending-key' | 'shared' | 'app';
|
|
50
69
|
|
|
51
70
|
/** Which holder this binary is, from its default access group. */
|
|
52
71
|
export type KeychainRole = 'holder' | 'wallet' | 'member';
|
|
53
72
|
|
|
54
73
|
/**
|
|
55
74
|
* - `grouped`: iOS with the probe answered. `prefix` ends with its dot
|
|
56
|
-
* (`ABCDE12345.`); `defaultGroup` is the full default group string
|
|
75
|
+
* (`ABCDE12345.`); `defaultGroup` is the full default group string;
|
|
76
|
+
* `appGroup`, when the native module proved it, this app's own group
|
|
77
|
+
* (`ABCDE12345.so.oxy.mention`). Absent on a native build that predates that
|
|
78
|
+
* probe: see {@link ownKeychainItemPlan} for what such a build does.
|
|
57
79
|
* - `ungrouped`: Android, web, or an iOS native build without the probe.
|
|
58
80
|
*/
|
|
59
81
|
export type KeychainAccess =
|
|
60
|
-
| {
|
|
82
|
+
| {
|
|
83
|
+
mode: 'grouped';
|
|
84
|
+
prefix: string;
|
|
85
|
+
defaultGroup: string;
|
|
86
|
+
role: KeychainRole;
|
|
87
|
+
appGroup?: string;
|
|
88
|
+
}
|
|
61
89
|
| { mode: 'ungrouped' };
|
|
62
90
|
|
|
63
91
|
/** The `expo-secure-store` option this module sets (its real name since 58). */
|
|
@@ -77,7 +105,9 @@ export class KeychainAccessUnavailableError extends Error {
|
|
|
77
105
|
* The single place each item class is assigned a group. A new kind of item
|
|
78
106
|
* (F1's retained keys, for one) picks its class and reuses everything below.
|
|
79
107
|
*/
|
|
80
|
-
export function keychainGroupFor(item: KeychainItemClass): KeychainAccessGroupName
|
|
108
|
+
export function keychainGroupFor(item: Exclude<KeychainItemClass, 'app'>): KeychainAccessGroupName;
|
|
109
|
+
export function keychainGroupFor(item: KeychainItemClass): KeychainGroup;
|
|
110
|
+
export function keychainGroupFor(item: KeychainItemClass): KeychainGroup {
|
|
81
111
|
switch (item) {
|
|
82
112
|
case 'identity-secret':
|
|
83
113
|
return 'identity';
|
|
@@ -85,6 +115,8 @@ export function keychainGroupFor(item: KeychainItemClass): KeychainAccessGroupNa
|
|
|
85
115
|
return 'wallet';
|
|
86
116
|
case 'shared':
|
|
87
117
|
return 'shared';
|
|
118
|
+
case 'app':
|
|
119
|
+
return 'app';
|
|
88
120
|
}
|
|
89
121
|
}
|
|
90
122
|
|
|
@@ -96,12 +128,18 @@ export function mayHoldKeychainItem(role: KeychainRole, item: KeychainItemClass)
|
|
|
96
128
|
case 'wallet':
|
|
97
129
|
return item !== 'identity-secret';
|
|
98
130
|
case 'member':
|
|
99
|
-
return item === 'shared';
|
|
131
|
+
return item === 'shared' || item === 'app';
|
|
100
132
|
}
|
|
101
133
|
}
|
|
102
134
|
|
|
103
|
-
/**
|
|
104
|
-
|
|
135
|
+
/**
|
|
136
|
+
* Parse the default access group the probe reported, and the app's own group
|
|
137
|
+
* when the second probe proved it. Exported for tests.
|
|
138
|
+
*/
|
|
139
|
+
export function keychainAccessFromDefaultGroup(
|
|
140
|
+
defaultGroup: string,
|
|
141
|
+
appGroup?: string,
|
|
142
|
+
): KeychainAccess {
|
|
105
143
|
const dot = defaultGroup.indexOf('.');
|
|
106
144
|
if (dot <= 0 || dot === defaultGroup.length - 1) {
|
|
107
145
|
// Not `<prefix>.<group>`: an unsigned simulator build. Nothing to split.
|
|
@@ -115,7 +153,9 @@ export function keychainAccessFromDefaultGroup(defaultGroup: string): KeychainAc
|
|
|
115
153
|
: group === KEYCHAIN_ACCESS_GROUPS.wallet
|
|
116
154
|
? 'wallet'
|
|
117
155
|
: 'member';
|
|
118
|
-
return
|
|
156
|
+
return appGroup?.startsWith(prefix)
|
|
157
|
+
? { mode: 'grouped', prefix, defaultGroup, role, appGroup }
|
|
158
|
+
: { mode: 'grouped', prefix, defaultGroup, role };
|
|
119
159
|
}
|
|
120
160
|
|
|
121
161
|
let resolvedAccess: KeychainAccess | null = null;
|
|
@@ -144,10 +184,31 @@ export async function resolveKeychainAccess(): Promise<KeychainAccess> {
|
|
|
144
184
|
if (probe.state === 'unavailable') {
|
|
145
185
|
throw new KeychainAccessUnavailableError(probe.cause);
|
|
146
186
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
187
|
+
if (probe.state !== 'resolved') {
|
|
188
|
+
resolvedAccess = { mode: 'ungrouped' };
|
|
189
|
+
return resolvedAccess;
|
|
190
|
+
}
|
|
191
|
+
const access = keychainAccessFromDefaultGroup(probe.accessGroup);
|
|
192
|
+
if (access.mode === 'ungrouped') {
|
|
193
|
+
resolvedAccess = access;
|
|
194
|
+
return resolvedAccess;
|
|
195
|
+
}
|
|
196
|
+
// The app's own group. A thrown probe is NOT degraded to "no app group": this
|
|
197
|
+
// process would then write the app's items to the old place while the next
|
|
198
|
+
// one reads the new place first, and see a stale value.
|
|
199
|
+
let own: KeychainApplicationAccessGroup;
|
|
200
|
+
try {
|
|
201
|
+
own = await loadKeychainApplicationAccessGroup(access.prefix);
|
|
202
|
+
} catch (cause) {
|
|
203
|
+
throw new KeychainAccessUnavailableError(cause);
|
|
204
|
+
}
|
|
205
|
+
if (own.state === 'unavailable') {
|
|
206
|
+
throw new KeychainAccessUnavailableError(own.cause);
|
|
207
|
+
}
|
|
208
|
+
resolvedAccess = keychainAccessFromDefaultGroup(
|
|
209
|
+
probe.accessGroup,
|
|
210
|
+
own.state === 'resolved' ? own.accessGroup : undefined,
|
|
211
|
+
);
|
|
151
212
|
return resolvedAccess;
|
|
152
213
|
}
|
|
153
214
|
|
|
@@ -156,12 +217,26 @@ export function resetKeychainAccessForTests(): void {
|
|
|
156
217
|
resolvedAccess = null;
|
|
157
218
|
}
|
|
158
219
|
|
|
159
|
-
/**
|
|
220
|
+
/**
|
|
221
|
+
* The full `kSecAttrAccessGroup` value of `group`, or `undefined` for "no
|
|
222
|
+
* group" (the app's default): always when ungrouped, and for `app` on a build
|
|
223
|
+
* that cannot place items there (see {@link ownKeychainItemPlan}).
|
|
224
|
+
*/
|
|
160
225
|
export function keychainAccessGroupValue(
|
|
161
226
|
access: KeychainAccess,
|
|
162
|
-
group:
|
|
227
|
+
group: KeychainGroup,
|
|
163
228
|
): string | undefined {
|
|
164
|
-
|
|
229
|
+
if (access.mode === 'ungrouped') {
|
|
230
|
+
return undefined;
|
|
231
|
+
}
|
|
232
|
+
if (group === 'app') {
|
|
233
|
+
// Commons' and the Peable wallet's default groups are theirs (no other app
|
|
234
|
+
// writes its own items there), so a build without the app-group probe
|
|
235
|
+
// keeps its own items there, as ADR 0062 D3 put them. Every other app's
|
|
236
|
+
// default is the shared group: no group, the calls it always made.
|
|
237
|
+
return access.appGroup ?? (access.role === 'member' ? undefined : access.defaultGroup);
|
|
238
|
+
}
|
|
239
|
+
return `${access.prefix}${KEYCHAIN_ACCESS_GROUPS[group]}`;
|
|
165
240
|
}
|
|
166
241
|
|
|
167
242
|
function option(value: string | undefined): KeychainGroupOption {
|
|
@@ -169,10 +244,11 @@ function option(value: string | undefined): KeychainGroupOption {
|
|
|
169
244
|
}
|
|
170
245
|
|
|
171
246
|
/**
|
|
172
|
-
* The option bags a READ of `item` tries, in order: its own group, then
|
|
173
|
-
*
|
|
174
|
-
* drops that fallback). Empty when this
|
|
175
|
-
* answers `null` without touching the
|
|
247
|
+
* The option bags a READ of `item` tries, in order: its own group, then, for
|
|
248
|
+
* an identity secret or a spending key, the shared group, where those lived
|
|
249
|
+
* before the split (release 4 of the doc drops that fallback). Empty when this
|
|
250
|
+
* app may not hold the item: the caller answers `null` without touching the
|
|
251
|
+
* keychain. An app's own items (`app`) take {@link ownKeychainItemPlan}.
|
|
176
252
|
*/
|
|
177
253
|
export function keychainReadPlan(
|
|
178
254
|
access: KeychainAccess,
|
|
@@ -186,7 +262,8 @@ export function keychainReadPlan(
|
|
|
186
262
|
}
|
|
187
263
|
const own = keychainGroupFor(item);
|
|
188
264
|
const plan = [option(keychainAccessGroupValue(access, own))];
|
|
189
|
-
if (own
|
|
265
|
+
if (own === 'identity' || own === 'wallet') {
|
|
266
|
+
// Where the split moved these items from.
|
|
190
267
|
plan.push(option(keychainAccessGroupValue(access, 'shared')));
|
|
191
268
|
}
|
|
192
269
|
return plan;
|
|
@@ -224,29 +301,79 @@ export function keychainWriteOption(
|
|
|
224
301
|
}
|
|
225
302
|
|
|
226
303
|
/**
|
|
227
|
-
*
|
|
228
|
-
*
|
|
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.
|
|
304
|
+
* Who else writes an own item's NAME, which decides whose an earlier copy of
|
|
305
|
+
* it can be.
|
|
234
306
|
*
|
|
235
|
-
*
|
|
307
|
+
* - `this-app`: no other app uses the name (the Peable wallet's own items). A
|
|
308
|
+
* copy anywhere this app can read is its own.
|
|
309
|
+
* - `every-oxy-app`: every Oxy app writes the same name (the SDK's session
|
|
310
|
+
* store: `oxy.auth.v1`, the warm token, the identity pin). Before the
|
|
311
|
+
* app group, every iOS app but Commons and the Peable wallet wrote it to the
|
|
312
|
+
* ONE copy in the shared group, so a copy there may be another app's
|
|
313
|
+
* session, or Commons' vault session. Only this app's own locations are
|
|
314
|
+
* read, written or deleted; the shared copy is left to the apps still
|
|
315
|
+
* running a build that uses it.
|
|
316
|
+
* - `every-oxy-app-withholding`: as `every-oxy-app`, for a value whose only
|
|
317
|
+
* effect is to WITHHOLD (the explicit sign-out marker). A shared copy still
|
|
318
|
+
* counts — honouring another app's marker can keep this app from signing in
|
|
319
|
+
* automatically, never sign it in — and its delete reaches that copy, as a
|
|
320
|
+
* release always did before.
|
|
236
321
|
*/
|
|
237
|
-
export
|
|
322
|
+
export type OwnKeychainItemNames = 'this-app' | 'every-oxy-app' | 'every-oxy-app-withholding';
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* The plan for an app's OWN items (class `app`, ADR 0062 D6): written to the
|
|
326
|
+
* app's own group (`<prefix><bundle id>`), which no other app can read, and
|
|
327
|
+
* read from it FIRST. Then:
|
|
328
|
+
*
|
|
329
|
+
* - from where this app kept them before: Commons' and the Peable wallet's
|
|
330
|
+
* default group (D3), which no other app writes its own items to;
|
|
331
|
+
* - for `this-app` names, from every group (`{}`), where they were before the
|
|
332
|
+
* split; deletes reach every group;
|
|
333
|
+
* - for `every-oxy-app` names, nowhere else (see {@link OwnKeychainItemNames}),
|
|
334
|
+
* and deletes reach exactly the read locations, earlier ones first, so a
|
|
335
|
+
* failed delete never leaves an older copy as the only one;
|
|
336
|
+
* - for `every-oxy-app-withholding` names, the shared group last on read and
|
|
337
|
+
* first on delete.
|
|
338
|
+
*
|
|
339
|
+
* Nothing is copied or deleted to migrate: the next write lands in the app
|
|
340
|
+
* group and the read order makes it win, so a crash or a failure at any step
|
|
341
|
+
* leaves the value the app had.
|
|
342
|
+
*
|
|
343
|
+
* A native build whose module predates the app-group probe (`appGroup`
|
|
344
|
+
* absent) keeps its items where that build put them: Commons and the wallet in
|
|
345
|
+
* their default group, every other app the calls it always made (the one
|
|
346
|
+
* shared item, until that app's next native build). Android and web: no
|
|
347
|
+
* options.
|
|
348
|
+
*/
|
|
349
|
+
export function ownKeychainItemPlan(
|
|
350
|
+
access: KeychainAccess,
|
|
351
|
+
names: OwnKeychainItemNames = 'this-app',
|
|
352
|
+
): {
|
|
238
353
|
read: KeychainGroupOption[];
|
|
239
354
|
write: KeychainGroupOption;
|
|
240
355
|
delete: KeychainGroupOption[];
|
|
241
356
|
} {
|
|
242
|
-
if (access.mode === 'ungrouped'
|
|
357
|
+
if (access.mode === 'ungrouped') {
|
|
243
358
|
return { read: [{}], write: {}, delete: [{}] };
|
|
244
359
|
}
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
write:
|
|
248
|
-
|
|
249
|
-
|
|
360
|
+
const own = keychainAccessGroupValue(access, keychainGroupFor('app'));
|
|
361
|
+
if (own === undefined) {
|
|
362
|
+
return { read: [{}], write: {}, delete: [{}] };
|
|
363
|
+
}
|
|
364
|
+
const earlier =
|
|
365
|
+
access.role !== 'member' && own !== access.defaultGroup ? [option(access.defaultGroup)] : [];
|
|
366
|
+
const mine = [option(own), ...earlier];
|
|
367
|
+
switch (names) {
|
|
368
|
+
case 'this-app':
|
|
369
|
+
return { read: [...mine, {}], write: option(own), delete: [{}] };
|
|
370
|
+
case 'every-oxy-app':
|
|
371
|
+
return { read: mine, write: option(own), delete: [...mine].reverse() };
|
|
372
|
+
case 'every-oxy-app-withholding': {
|
|
373
|
+
const read = [...mine, option(keychainAccessGroupValue(access, 'shared'))];
|
|
374
|
+
return { read, write: option(own), delete: [...read].reverse() };
|
|
375
|
+
}
|
|
376
|
+
}
|
|
250
377
|
}
|
|
251
378
|
|
|
252
379
|
/** The subset of `expo-secure-store` a grouped store wraps. */
|