@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.
@@ -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.startTokenRefreshScheduler = exports.installAuthRefreshHandler = exports.createAuthRefreshHandler = 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.AUTH_STATE_STORAGE_KEY = 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 = 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
- /** Parse the default access group the probe reported. Exported for tests. */
95
- function keychainAccessFromDefaultGroup(defaultGroup) {
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 { mode: 'grouped', prefix, defaultGroup, role };
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
- resolvedAccess =
137
- probe.state === 'resolved'
138
- ? keychainAccessFromDefaultGroup(probe.accessGroup)
139
- : { mode: 'ungrouped' };
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
- /** The full `kSecAttrAccessGroup` value of `group`, or `undefined` when ungrouped. */
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
- return access.mode === 'grouped' ? `${access.prefix}${exports.KEYCHAIN_ACCESS_GROUPS[group]}` : undefined;
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 the
155
- * shared group, where every item lived before the split (release 4 of the doc
156
- * drops that fallback). Empty when this app may not hold the item: the caller
157
- * answers `null` without touching the keychain.
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 !== 'shared') {
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 (its session credential, its settings, an
196
- * imported wallet's phrase) in Commons and the Peable wallet, whose default
197
- * group the split changed: written to the default group explicitly — where a
198
- * write without a group goes anyway — and read from it FIRST, then from every
199
- * group (`{}`), where all of them were written before. Without the explicit
200
- * order a read could return whichever of two copies the keychain met first.
201
- * Deletes reach every group, as they always did.
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
- * Every other app (`member`) and Android/web keep the calls they always made.
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' || access.role === 'member') {
265
+ function ownKeychainItemPlan(access, names = 'this-app') {
266
+ if (access.mode === 'ungrouped') {
207
267
  return { read: [{}], write: {}, delete: [{}] };
208
268
  }
209
- return {
210
- read: [option(access.defaultGroup), {}],
211
- write: option(access.defaultGroup),
212
- delete: [{}],
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
- /** Parse the default access group the probe reported. Exported for tests. */
78
- export function keychainAccessFromDefaultGroup(defaultGroup) {
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 { mode: 'grouped', prefix, defaultGroup, role };
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
- resolvedAccess =
120
- probe.state === 'resolved'
121
- ? keychainAccessFromDefaultGroup(probe.accessGroup)
122
- : { mode: 'ungrouped' };
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
- /** The full `kSecAttrAccessGroup` value of `group`, or `undefined` when ungrouped. */
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
- return access.mode === 'grouped' ? `${access.prefix}${KEYCHAIN_ACCESS_GROUPS[group]}` : undefined;
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 the
138
- * shared group, where every item lived before the split (release 4 of the doc
139
- * drops that fallback). Empty when this app may not hold the item: the caller
140
- * answers `null` without touching the keychain.
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 !== 'shared') {
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 (its session credential, its settings, an
179
- * imported wallet's phrase) in Commons and the Peable wallet, whose default
180
- * group the split changed: written to the default group explicitly — where a
181
- * write without a group goes anyway — and read from it FIRST, then from every
182
- * group (`{}`), where all of them were written before. Without the explicit
183
- * order a read could return whichever of two copies the keychain met first.
184
- * Deletes reach every group, as they always did.
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
- * Every other app (`member`) and Android/web keep the calls they always made.
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' || access.role === 'member') {
248
+ export function ownKeychainItemPlan(access, names = 'this-app') {
249
+ if (access.mode === 'ungrouped') {
190
250
  return { read: [{}], write: {}, delete: [{}] };
191
251
  }
192
- return {
193
- read: [option(access.defaultGroup), {}],
194
- write: option(access.defaultGroup),
195
- delete: [{}],
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
- /** Parse the default access group the probe reported. Exported for tests. */
43
- export declare function keychainAccessFromDefaultGroup(defaultGroup: string): KeychainAccess;
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 option bags a READ of `item` tries, in order: its own group, then the
56
- * shared group, where every item lived before the split (release 4 of the doc
57
- * drops that fallback). Empty when this app may not hold the item: the caller
58
- * answers `null` without touching the keychain.
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
- * The plan for an app's OWN items (its session credential, its settings, an
74
- * imported wallet's phrase) in Commons and the Peable wallet, whose default
75
- * group the split changed: written to the default group explicitly — where a
76
- * write without a group goes anyway — and read from it FIRST, then from every
77
- * group (`{}`), where all of them were written before. Without the explicit
78
- * order a read could return whichever of two copies the keychain met first.
79
- * Deletes reach every group, as they always did.
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
- * Every other app (`member`) and Android/web keep the calls they always made.
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.30.0",
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.9.0",
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",
@@ -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 = (key: string, item: KeychainItemClass, service?: string, deviceOnly = true): 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
@@ -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 { KeychainAccess, KeychainGroupOption } from '../utils/keychainAccessGroups';
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 { type KeychainDefaultAccessGroup, loadKeychainDefaultAccessGroup } from '@oxy.so/protocol';
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
- | { mode: 'grouped'; prefix: string; defaultGroup: string; role: KeychainRole }
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
- /** Parse the default access group the probe reported. Exported for tests. */
104
- export function keychainAccessFromDefaultGroup(defaultGroup: string): KeychainAccess {
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 { mode: 'grouped', prefix, defaultGroup, role };
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
- resolvedAccess =
148
- probe.state === 'resolved'
149
- ? keychainAccessFromDefaultGroup(probe.accessGroup)
150
- : { mode: 'ungrouped' };
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
- /** The full `kSecAttrAccessGroup` value of `group`, or `undefined` when ungrouped. */
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: KeychainAccessGroupName,
227
+ group: KeychainGroup,
163
228
  ): string | undefined {
164
- return access.mode === 'grouped' ? `${access.prefix}${KEYCHAIN_ACCESS_GROUPS[group]}` : undefined;
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 the
173
- * shared group, where every item lived before the split (release 4 of the doc
174
- * drops that fallback). Empty when this app may not hold the item: the caller
175
- * answers `null` without touching the keychain.
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 !== 'shared') {
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
- * The plan for an app's OWN items (its session credential, its settings, an
228
- * imported wallet's phrase) in Commons and the Peable wallet, whose default
229
- * group the split changed: written to the default group explicitly — where a
230
- * write without a group goes anyway — and read from it FIRST, then from every
231
- * group (`{}`), where all of them were written before. Without the explicit
232
- * order a read could return whichever of two copies the keychain met first.
233
- * Deletes reach every group, as they always did.
304
+ * Who else writes an own item's NAME, which decides whose an earlier copy of
305
+ * it can be.
234
306
  *
235
- * Every other app (`member`) and Android/web keep the calls they always made.
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 function ownKeychainItemPlan(access: KeychainAccess): {
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' || access.role === 'member') {
357
+ if (access.mode === 'ungrouped') {
243
358
  return { read: [{}], write: {}, delete: [{}] };
244
359
  }
245
- return {
246
- read: [option(access.defaultGroup), {}],
247
- write: option(access.defaultGroup),
248
- delete: [{}],
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. */