@oxy.so/protocol 1.7.0 → 1.8.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/index.js CHANGED
@@ -11,7 +11,7 @@
11
11
  * subpath so they never enter React Native / web bundles.
12
12
  */
13
13
  Object.defineProperty(exports, "__esModule", { value: true });
14
- exports.loadCommonsIdentityBridge = exports.getRandomBytesRN = exports.loadAsyncStorage = exports.loadSecureStore = exports.loadExpoCrypto = exports.loadNodeCrypto = exports.isNodeJS = exports.isReactNative = exports.isAuthorizedKey = exports.REGISTRATION_POW_DIFFICULTY_BITS = exports.meetsRegistrationPowDifficulty = exports.countLeadingZeroBits = exports.registrationPowMessage = exports.verifyCheckpointSignature = exports.signCheckpoint = exports.checkpointHash = exports.checkpointSigningInput = exports.verifyInclusionProof = exports.inclusionProof = exports.buildTransparencyTreeFromHeads = exports.buildTransparencyTree = exports.transparencyLeafHash = exports.EMPTY_TRANSPARENCY_ROOT = exports.verifyAndAppend = exports.DEFAULT_CLOCK_SKEW_MS = exports.verifyEnvelope = exports.checkContinuity = exports.UNCHAINED_SEQ = exports.verifyEnvelopeSignature = exports.signEnvelope = exports.verifySignature = exports.signMessage = exports.computeRecordId = exports.sha256 = exports.signedRecordSigningInput = exports.canonicalize = void 0;
14
+ exports.loadKeychainDefaultAccessGroup = exports.loadCommonsIdentityBridge = exports.getRandomBytesRN = exports.loadAsyncStorage = exports.loadSecureStore = exports.loadExpoCrypto = exports.loadNodeCrypto = exports.isNodeJS = exports.isReactNative = exports.isAuthorizedKey = exports.REGISTRATION_POW_DIFFICULTY_BITS = exports.meetsRegistrationPowDifficulty = exports.countLeadingZeroBits = exports.registrationPowMessage = exports.verifyCheckpointSignature = exports.signCheckpoint = exports.checkpointHash = exports.checkpointSigningInput = exports.verifyInclusionProof = exports.inclusionProof = exports.buildTransparencyTreeFromHeads = exports.buildTransparencyTree = exports.transparencyLeafHash = exports.EMPTY_TRANSPARENCY_ROOT = exports.verifyAndAppend = exports.DEFAULT_CLOCK_SKEW_MS = exports.verifyEnvelope = exports.checkContinuity = exports.UNCHAINED_SEQ = exports.verifyEnvelopeSignature = exports.signEnvelope = exports.verifySignature = exports.signMessage = exports.computeRecordId = exports.sha256 = exports.signedRecordSigningInput = exports.canonicalize = void 0;
15
15
  // ---------------------------------------------------------------------------
16
16
  // Envelope — canonical JSON, signing input, content address, signing/verify
17
17
  // ---------------------------------------------------------------------------
@@ -77,3 +77,4 @@ Object.defineProperty(exports, "loadSecureStore", { enumerable: true, get: funct
77
77
  Object.defineProperty(exports, "loadAsyncStorage", { enumerable: true, get: function () { return crypto_1.loadAsyncStorage; } });
78
78
  Object.defineProperty(exports, "getRandomBytesRN", { enumerable: true, get: function () { return crypto_1.getRandomBytesRN; } });
79
79
  Object.defineProperty(exports, "loadCommonsIdentityBridge", { enumerable: true, get: function () { return crypto_1.loadCommonsIdentityBridge; } });
80
+ Object.defineProperty(exports, "loadKeychainDefaultAccessGroup", { enumerable: true, get: function () { return crypto_1.loadKeychainDefaultAccessGroup; } });
@@ -112,6 +112,7 @@ exports.loadExpoCrypto = loadExpoCrypto;
112
112
  exports.loadSecureStore = loadSecureStore;
113
113
  exports.loadAsyncStorage = loadAsyncStorage;
114
114
  exports.loadCommonsIdentityBridge = loadCommonsIdentityBridge;
115
+ exports.loadKeychainDefaultAccessGroup = loadKeychainDefaultAccessGroup;
115
116
  const platform_1 = require("./platform");
116
117
  // ---------------------------------------------------------------------------
117
118
  // Node `crypto` — Node built-in
@@ -177,3 +178,9 @@ Object.defineProperty(exports, "getRandomBytesRN", { enumerable: true, get: func
177
178
  function loadCommonsIdentityBridge() {
178
179
  return Promise.resolve(null);
179
180
  }
181
+ // ---------------------------------------------------------------------------
182
+ // iOS keychain default access group — native-only; off RN there is no keychain.
183
+ // ---------------------------------------------------------------------------
184
+ function loadKeychainDefaultAccessGroup() {
185
+ return Promise.resolve({ state: 'unsupported' });
186
+ }
@@ -59,6 +59,7 @@ exports.loadExpoCrypto = loadExpoCrypto;
59
59
  exports.loadSecureStore = loadSecureStore;
60
60
  exports.loadAsyncStorage = loadAsyncStorage;
61
61
  exports.loadCommonsIdentityBridge = loadCommonsIdentityBridge;
62
+ exports.loadKeychainDefaultAccessGroup = loadKeychainDefaultAccessGroup;
62
63
  const expo_modules_core_1 = require("expo-modules-core");
63
64
  const optionalPeer_1 = require("./optionalPeer");
64
65
  const random_native_1 = require("./random.native");
@@ -237,3 +238,45 @@ function loadCommonsIdentityBridge() {
237
238
  }
238
239
  return commonsIdentityBridgePromise;
239
240
  }
241
+ // ---------------------------------------------------------------------------
242
+ // iOS keychain default access group (docs/identity/ios-keychain-access-groups.md).
243
+ //
244
+ // Resolved once the native module answers; an `unavailable` probe (a thrown
245
+ // call: before first unlock, or the keychain refused) is not memoized, so the
246
+ // next caller asks again. The answer is narrowed to a plain string — it crossed
247
+ // the native boundary.
248
+ // ---------------------------------------------------------------------------
249
+ /** `<prefix>.<group>`: an App ID prefix (no dots) then a non-empty group. */
250
+ const ACCESS_GROUP = /^[A-Za-z0-9]+\.[A-Za-z0-9.-]+$/;
251
+ let keychainDefaultAccessGroup = null;
252
+ async function loadKeychainDefaultAccessGroup() {
253
+ if (keychainDefaultAccessGroup) {
254
+ return keychainDefaultAccessGroup;
255
+ }
256
+ const native = (0, expo_modules_core_1.requireOptionalNativeModule)('OxyIdentity');
257
+ const probe = native?.keychainDefaultAccessGroup;
258
+ if (!native || typeof probe !== 'function') {
259
+ keychainDefaultAccessGroup = { state: 'unsupported' };
260
+ return keychainDefaultAccessGroup;
261
+ }
262
+ let answer;
263
+ try {
264
+ answer = await probe.call(native);
265
+ }
266
+ catch (cause) {
267
+ return { state: 'unavailable', cause };
268
+ }
269
+ if (answer === null || answer === undefined) {
270
+ // The native side runs only on iOS; anything else answers nothing.
271
+ keychainDefaultAccessGroup = { state: 'unsupported' };
272
+ return keychainDefaultAccessGroup;
273
+ }
274
+ if (typeof answer !== 'string' || !ACCESS_GROUP.test(answer)) {
275
+ return {
276
+ state: 'unavailable',
277
+ cause: new Error('keychain probe answered something that is not an access group'),
278
+ };
279
+ }
280
+ keychainDefaultAccessGroup = { state: 'resolved', accessGroup: answer };
281
+ return keychainDefaultAccessGroup;
282
+ }
package/dist/esm/index.js CHANGED
@@ -37,4 +37,4 @@ export { isAuthorizedKey } from './identity/resolver.js';
37
37
  // Platform — runtime predicates + lazy crypto/storage loaders
38
38
  // ---------------------------------------------------------------------------
39
39
  export { isReactNative, isNodeJS } from './platform/platform.js';
40
- export { loadNodeCrypto, loadExpoCrypto, loadSecureStore, loadAsyncStorage, getRandomBytesRN, loadCommonsIdentityBridge, } from './platform/crypto.js';
40
+ export { loadNodeCrypto, loadExpoCrypto, loadSecureStore, loadAsyncStorage, getRandomBytesRN, loadCommonsIdentityBridge, loadKeychainDefaultAccessGroup, } from './platform/crypto.js';
@@ -135,3 +135,9 @@ export { getRandomBytesRN } from './random.js';
135
135
  export function loadCommonsIdentityBridge() {
136
136
  return Promise.resolve(null);
137
137
  }
138
+ // ---------------------------------------------------------------------------
139
+ // iOS keychain default access group — native-only; off RN there is no keychain.
140
+ // ---------------------------------------------------------------------------
141
+ export function loadKeychainDefaultAccessGroup() {
142
+ return Promise.resolve({ state: 'unsupported' });
143
+ }
@@ -228,3 +228,45 @@ export function loadCommonsIdentityBridge() {
228
228
  }
229
229
  return commonsIdentityBridgePromise;
230
230
  }
231
+ // ---------------------------------------------------------------------------
232
+ // iOS keychain default access group (docs/identity/ios-keychain-access-groups.md).
233
+ //
234
+ // Resolved once the native module answers; an `unavailable` probe (a thrown
235
+ // call: before first unlock, or the keychain refused) is not memoized, so the
236
+ // next caller asks again. The answer is narrowed to a plain string — it crossed
237
+ // the native boundary.
238
+ // ---------------------------------------------------------------------------
239
+ /** `<prefix>.<group>`: an App ID prefix (no dots) then a non-empty group. */
240
+ const ACCESS_GROUP = /^[A-Za-z0-9]+\.[A-Za-z0-9.-]+$/;
241
+ let keychainDefaultAccessGroup = null;
242
+ export async function loadKeychainDefaultAccessGroup() {
243
+ if (keychainDefaultAccessGroup) {
244
+ return keychainDefaultAccessGroup;
245
+ }
246
+ const native = requireOptionalNativeModule('OxyIdentity');
247
+ const probe = native?.keychainDefaultAccessGroup;
248
+ if (!native || typeof probe !== 'function') {
249
+ keychainDefaultAccessGroup = { state: 'unsupported' };
250
+ return keychainDefaultAccessGroup;
251
+ }
252
+ let answer;
253
+ try {
254
+ answer = await probe.call(native);
255
+ }
256
+ catch (cause) {
257
+ return { state: 'unavailable', cause };
258
+ }
259
+ if (answer === null || answer === undefined) {
260
+ // The native side runs only on iOS; anything else answers nothing.
261
+ keychainDefaultAccessGroup = { state: 'unsupported' };
262
+ return keychainDefaultAccessGroup;
263
+ }
264
+ if (typeof answer !== 'string' || !ACCESS_GROUP.test(answer)) {
265
+ return {
266
+ state: 'unavailable',
267
+ cause: new Error('keychain probe answered something that is not an access group'),
268
+ };
269
+ }
270
+ keychainDefaultAccessGroup = { state: 'resolved', accessGroup: answer };
271
+ return keychainDefaultAccessGroup;
272
+ }
@@ -29,5 +29,5 @@ export { registrationPowMessage, countLeadingZeroBits, meetsRegistrationPowDiffi
29
29
  export { isAuthorizedKey } from './identity/resolver';
30
30
  export type { VerificationMethodResolver, ResolvedVerificationMethods, KeyAuthorization, } from './identity/resolver';
31
31
  export { isReactNative, isNodeJS } from './platform/platform';
32
- export { loadNodeCrypto, loadExpoCrypto, loadSecureStore, loadAsyncStorage, getRandomBytesRN, loadCommonsIdentityBridge, } from './platform/crypto';
33
- export type { CommonsIdentityBridge, CommonsIdentityDescription, CommonsIdentityProof, CommonsSocialReceiveSignature, ExpoCryptoLike, ExpoSecureStoreLike, } from './platform/expoTypes';
32
+ export { loadNodeCrypto, loadExpoCrypto, loadSecureStore, loadAsyncStorage, getRandomBytesRN, loadCommonsIdentityBridge, loadKeychainDefaultAccessGroup, } from './platform/crypto';
33
+ export type { CommonsIdentityBridge, CommonsIdentityDescription, CommonsIdentityProof, CommonsSocialReceiveSignature, ExpoCryptoLike, ExpoSecureStoreLike, KeychainDefaultAccessGroup, } from './platform/expoTypes';
@@ -71,8 +71,8 @@
71
71
  * see a file containing only specifiers it can understand — no tricks, no
72
72
  * runtime parsing risks.
73
73
  */
74
- import type { CommonsIdentityBridge, CommonsIdentityDescription, CommonsIdentityProof, CommonsSocialReceiveSignature, ExpoCryptoLike, ExpoSecureStoreLike } from './expoTypes';
75
- export type { CommonsIdentityBridge, CommonsIdentityDescription, CommonsIdentityProof, CommonsSocialReceiveSignature, ExpoCryptoLike, ExpoSecureStoreLike, };
74
+ import type { CommonsIdentityBridge, CommonsIdentityDescription, CommonsIdentityProof, CommonsSocialReceiveSignature, ExpoCryptoLike, ExpoSecureStoreLike, KeychainDefaultAccessGroup } from './expoTypes';
75
+ export type { CommonsIdentityBridge, CommonsIdentityDescription, CommonsIdentityProof, CommonsSocialReceiveSignature, ExpoCryptoLike, ExpoSecureStoreLike, KeychainDefaultAccessGroup, };
76
76
  export declare function loadNodeCrypto(): Promise<typeof import('crypto')>;
77
77
  export declare function loadExpoCrypto(): Promise<ExpoCryptoLike>;
78
78
  export declare function loadSecureStore(): Promise<ExpoSecureStoreLike>;
@@ -85,3 +85,4 @@ export declare function loadAsyncStorage(): Promise<{
85
85
  }>;
86
86
  export { getRandomBytesRN } from './random';
87
87
  export declare function loadCommonsIdentityBridge(): Promise<CommonsIdentityBridge | null>;
88
+ export declare function loadKeychainDefaultAccessGroup(): Promise<KeychainDefaultAccessGroup>;
@@ -51,8 +51,8 @@
51
51
  * `expo-modules-core` is a NON-optional peer (every RN app has it via `expo`),
52
52
  * so it stays a plain static import.
53
53
  */
54
- import type { CommonsIdentityBridge, CommonsIdentityDescription, CommonsIdentityProof, CommonsSocialReceiveSignature, ExpoCryptoLike, ExpoSecureStoreLike } from './expoTypes';
55
- export type { CommonsIdentityBridge, CommonsIdentityDescription, CommonsIdentityProof, CommonsSocialReceiveSignature, ExpoCryptoLike, ExpoSecureStoreLike, };
54
+ import type { CommonsIdentityBridge, CommonsIdentityDescription, CommonsIdentityProof, CommonsSocialReceiveSignature, ExpoCryptoLike, ExpoSecureStoreLike, KeychainDefaultAccessGroup } from './expoTypes';
55
+ export type { CommonsIdentityBridge, CommonsIdentityDescription, CommonsIdentityProof, CommonsSocialReceiveSignature, ExpoCryptoLike, ExpoSecureStoreLike, KeychainDefaultAccessGroup, };
56
56
  /** Persistent KV storage surface used from `@react-native-async-storage/async-storage`. */
57
57
  type AsyncStorageLike = {
58
58
  getItem: (key: string) => Promise<string | null>;
@@ -67,3 +67,4 @@ export declare function loadAsyncStorage(): Promise<{
67
67
  }>;
68
68
  export { getRandomBytesRN } from './random.native';
69
69
  export declare function loadCommonsIdentityBridge(): Promise<CommonsIdentityBridge | null>;
70
+ export declare function loadKeychainDefaultAccessGroup(): Promise<KeychainDefaultAccessGroup>;
@@ -115,8 +115,8 @@ export interface CommonsSocialReceiveSignature {
115
115
  * resolves `null` when Commons is not installed, holds no identity, or refuses
116
116
  * the caller.
117
117
  *
118
- * On iOS the module is a stub that resolves `null`: the Keychain Access Group
119
- * path in `@oxy.so/core`'s `KeyManager` owns iOS sharing.
118
+ * On iOS these methods resolve `null`: the keychain access groups in
119
+ * `@oxy.so/core`'s `KeyManager` own iOS sharing (docs/identity/ios-keychain-access-groups.md).
120
120
  */
121
121
  export interface CommonsIdentityBridge {
122
122
  /**
@@ -140,3 +140,27 @@ export interface CommonsIdentityBridge {
140
140
  */
141
141
  signSocialReceive(index: number, digest: string, accountId?: string): Promise<CommonsSocialReceiveSignature | null>;
142
142
  }
143
+ /**
144
+ * What the iOS keychain says about this app's DEFAULT access group: the first
145
+ * entry of its `keychain-access-groups` entitlement, team prefix included
146
+ * (`ABCDE12345.so.oxy.identity`). The value is read back from a probe item the
147
+ * native module writes without a group, because JavaScript cannot see the
148
+ * entitlements or the team prefix (`$(AppIdentifierPrefix)` expands at build
149
+ * time), and `kSecAttrAccessGroup` must equal an entitlement value exactly.
150
+ *
151
+ * - `resolved`: the group string, as the keychain reported it.
152
+ * - `unsupported`: not iOS, or a native build whose `OxyIdentity` module
153
+ * predates the probe. Callers keep the single-group behaviour that build was
154
+ * made for.
155
+ * - `unavailable`: the probe threw (before first unlock, keychain refused).
156
+ * Transient; never cached.
157
+ */
158
+ export type KeychainDefaultAccessGroup = {
159
+ state: 'resolved';
160
+ accessGroup: string;
161
+ } | {
162
+ state: 'unsupported';
163
+ } | {
164
+ state: 'unavailable';
165
+ cause: unknown;
166
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxy.so/protocol",
3
- "version": "1.7.0",
3
+ "version": "1.8.0",
4
4
  "description": "Oxy Protocol — the app-agnostic base substrate: signed-record envelope, canonical JSON, signature/verification, and platform crypto. Reused by any Oxy app to decentralize its own content.",
5
5
  "main": "dist/cjs/index.js",
6
6
  "module": "dist/esm/index.js",
@@ -0,0 +1,65 @@
1
+ /**
2
+ * The iOS keychain probe (docs/identity/ios-keychain-access-groups.md): its
3
+ * answer crossed the native boundary, so only a `<prefix>.<group>` string is a
4
+ * group; a thrown probe is `unavailable` and asked again, never cached.
5
+ */
6
+ function mockNative(native: unknown): void {
7
+ jest.doMock('expo-modules-core', () => ({ requireOptionalNativeModule: () => native }), {
8
+ virtual: true,
9
+ });
10
+ }
11
+
12
+ async function probe() {
13
+ const mod = await import('../platform/crypto.native');
14
+ return mod.loadKeychainDefaultAccessGroup();
15
+ }
16
+
17
+ describe('loadKeychainDefaultAccessGroup (native)', () => {
18
+ beforeEach(() => {
19
+ jest.resetModules();
20
+ });
21
+
22
+ test('a native module without the probe is unsupported', async () => {
23
+ mockNative({ describe: async () => null });
24
+ await expect(probe()).resolves.toEqual({ state: 'unsupported' });
25
+ jest.resetModules();
26
+ mockNative(null);
27
+ await expect(probe()).resolves.toEqual({ state: 'unsupported' });
28
+ });
29
+
30
+ test('passes a team-prefixed group through, and remembers it', async () => {
31
+ const keychainDefaultAccessGroup = jest.fn(async () => 'ABCDE12345.so.oxy.identity');
32
+ mockNative({ keychainDefaultAccessGroup });
33
+ const mod = await import('../platform/crypto.native');
34
+ await expect(mod.loadKeychainDefaultAccessGroup()).resolves.toEqual({
35
+ state: 'resolved',
36
+ accessGroup: 'ABCDE12345.so.oxy.identity',
37
+ });
38
+ await mod.loadKeychainDefaultAccessGroup();
39
+ expect(keychainDefaultAccessGroup).toHaveBeenCalledTimes(1);
40
+ });
41
+
42
+ test('a throw is unavailable and asked again; junk is unavailable', async () => {
43
+ let locked = true;
44
+ mockNative({
45
+ keychainDefaultAccessGroup: async () => {
46
+ if (locked) throw new Error('errSecInteractionNotAllowed');
47
+ return 'ABCDE12345.group.so.oxy.shared';
48
+ },
49
+ });
50
+ const mod = await import('../platform/crypto.native');
51
+ expect((await mod.loadKeychainDefaultAccessGroup()).state).toBe('unavailable');
52
+ locked = false;
53
+ expect((await mod.loadKeychainDefaultAccessGroup()).state).toBe('resolved');
54
+ for (const junk of [42, 'nodot', '.leading', { group: 'x' }]) {
55
+ jest.resetModules();
56
+ mockNative({ keychainDefaultAccessGroup: async () => junk });
57
+ expect((await probe()).state).toBe('unavailable');
58
+ }
59
+ });
60
+
61
+ test('the web / Node variant is unsupported', async () => {
62
+ const mod = await import('../platform/crypto');
63
+ await expect(mod.loadKeychainDefaultAccessGroup()).resolves.toEqual({ state: 'unsupported' });
64
+ });
65
+ });
package/src/index.ts CHANGED
@@ -99,6 +99,7 @@ export {
99
99
  loadAsyncStorage,
100
100
  getRandomBytesRN,
101
101
  loadCommonsIdentityBridge,
102
+ loadKeychainDefaultAccessGroup,
102
103
  } from './platform/crypto';
103
104
 
104
105
  // ---------------------------------------------------------------------------
@@ -115,4 +116,5 @@ export type {
115
116
  CommonsSocialReceiveSignature,
116
117
  ExpoCryptoLike,
117
118
  ExpoSecureStoreLike,
119
+ KeychainDefaultAccessGroup,
118
120
  } from './platform/expoTypes';
@@ -60,6 +60,7 @@ import type {
60
60
  CommonsSocialReceiveSignature,
61
61
  ExpoCryptoLike,
62
62
  ExpoSecureStoreLike,
63
+ KeychainDefaultAccessGroup,
63
64
  } from './expoTypes';
64
65
  import { missingOptionalPeerError } from './optionalPeer';
65
66
  import { requireExpoCrypto } from './random.native';
@@ -73,6 +74,7 @@ export type {
73
74
  CommonsSocialReceiveSignature,
74
75
  ExpoCryptoLike,
75
76
  ExpoSecureStoreLike,
77
+ KeychainDefaultAccessGroup,
76
78
  };
77
79
 
78
80
  // ---------------------------------------------------------------------------
@@ -217,6 +219,12 @@ interface OxyIdentityNativeModule {
217
219
  digest: string,
218
220
  accountId: string,
219
221
  ): Promise<unknown>;
222
+ /**
223
+ * iOS only, added with the keychain access-group split: the access group a
224
+ * probe item written WITHOUT a group landed in (the app's default group).
225
+ * Older native modules and Android lack it.
226
+ */
227
+ keychainDefaultAccessGroup?(): Promise<unknown>;
220
228
  }
221
229
 
222
230
  const LOWER_HEX = /^[0-9a-f]+$/;
@@ -344,3 +352,48 @@ export function loadCommonsIdentityBridge(): Promise<CommonsIdentityBridge | nul
344
352
  }
345
353
  return commonsIdentityBridgePromise;
346
354
  }
355
+
356
+ // ---------------------------------------------------------------------------
357
+ // iOS keychain default access group (docs/identity/ios-keychain-access-groups.md).
358
+ //
359
+ // Resolved once the native module answers; an `unavailable` probe (a thrown
360
+ // call: before first unlock, or the keychain refused) is not memoized, so the
361
+ // next caller asks again. The answer is narrowed to a plain string — it crossed
362
+ // the native boundary.
363
+ // ---------------------------------------------------------------------------
364
+
365
+ /** `<prefix>.<group>`: an App ID prefix (no dots) then a non-empty group. */
366
+ const ACCESS_GROUP = /^[A-Za-z0-9]+\.[A-Za-z0-9.-]+$/;
367
+
368
+ let keychainDefaultAccessGroup: KeychainDefaultAccessGroup | null = null;
369
+
370
+ export async function loadKeychainDefaultAccessGroup(): Promise<KeychainDefaultAccessGroup> {
371
+ if (keychainDefaultAccessGroup) {
372
+ return keychainDefaultAccessGroup;
373
+ }
374
+ const native = requireOptionalNativeModule<Partial<OxyIdentityNativeModule>>('OxyIdentity');
375
+ const probe = native?.keychainDefaultAccessGroup;
376
+ if (!native || typeof probe !== 'function') {
377
+ keychainDefaultAccessGroup = { state: 'unsupported' };
378
+ return keychainDefaultAccessGroup;
379
+ }
380
+ let answer: unknown;
381
+ try {
382
+ answer = await probe.call(native);
383
+ } catch (cause) {
384
+ return { state: 'unavailable', cause };
385
+ }
386
+ if (answer === null || answer === undefined) {
387
+ // The native side runs only on iOS; anything else answers nothing.
388
+ keychainDefaultAccessGroup = { state: 'unsupported' };
389
+ return keychainDefaultAccessGroup;
390
+ }
391
+ if (typeof answer !== 'string' || !ACCESS_GROUP.test(answer)) {
392
+ return {
393
+ state: 'unavailable',
394
+ cause: new Error('keychain probe answered something that is not an access group'),
395
+ };
396
+ }
397
+ keychainDefaultAccessGroup = { state: 'resolved', accessGroup: answer };
398
+ return keychainDefaultAccessGroup;
399
+ }
@@ -80,6 +80,7 @@ import type {
80
80
  CommonsSocialReceiveSignature,
81
81
  ExpoCryptoLike,
82
82
  ExpoSecureStoreLike,
83
+ KeychainDefaultAccessGroup,
83
84
  } from './expoTypes';
84
85
 
85
86
  // Re-export the interfaces so consumers can import them from the same
@@ -91,6 +92,7 @@ export type {
91
92
  CommonsSocialReceiveSignature,
92
93
  ExpoCryptoLike,
93
94
  ExpoSecureStoreLike,
95
+ KeychainDefaultAccessGroup,
94
96
  };
95
97
 
96
98
  // ---------------------------------------------------------------------------
@@ -176,3 +178,11 @@ export { getRandomBytesRN } from './random';
176
178
  export function loadCommonsIdentityBridge(): Promise<CommonsIdentityBridge | null> {
177
179
  return Promise.resolve(null);
178
180
  }
181
+
182
+ // ---------------------------------------------------------------------------
183
+ // iOS keychain default access group — native-only; off RN there is no keychain.
184
+ // ---------------------------------------------------------------------------
185
+
186
+ export function loadKeychainDefaultAccessGroup(): Promise<KeychainDefaultAccessGroup> {
187
+ return Promise.resolve({ state: 'unsupported' });
188
+ }
@@ -121,8 +121,8 @@ export interface CommonsSocialReceiveSignature {
121
121
  * resolves `null` when Commons is not installed, holds no identity, or refuses
122
122
  * the caller.
123
123
  *
124
- * On iOS the module is a stub that resolves `null`: the Keychain Access Group
125
- * path in `@oxy.so/core`'s `KeyManager` owns iOS sharing.
124
+ * On iOS these methods resolve `null`: the keychain access groups in
125
+ * `@oxy.so/core`'s `KeyManager` own iOS sharing (docs/identity/ios-keychain-access-groups.md).
126
126
  */
127
127
  export interface CommonsIdentityBridge {
128
128
  /**
@@ -150,3 +150,23 @@ export interface CommonsIdentityBridge {
150
150
  accountId?: string,
151
151
  ): Promise<CommonsSocialReceiveSignature | null>;
152
152
  }
153
+
154
+ /**
155
+ * What the iOS keychain says about this app's DEFAULT access group: the first
156
+ * entry of its `keychain-access-groups` entitlement, team prefix included
157
+ * (`ABCDE12345.so.oxy.identity`). The value is read back from a probe item the
158
+ * native module writes without a group, because JavaScript cannot see the
159
+ * entitlements or the team prefix (`$(AppIdentifierPrefix)` expands at build
160
+ * time), and `kSecAttrAccessGroup` must equal an entitlement value exactly.
161
+ *
162
+ * - `resolved`: the group string, as the keychain reported it.
163
+ * - `unsupported`: not iOS, or a native build whose `OxyIdentity` module
164
+ * predates the probe. Callers keep the single-group behaviour that build was
165
+ * made for.
166
+ * - `unavailable`: the probe threw (before first unlock, keychain refused).
167
+ * Transient; never cached.
168
+ */
169
+ export type KeychainDefaultAccessGroup =
170
+ | { state: 'resolved'; accessGroup: string }
171
+ | { state: 'unsupported' }
172
+ | { state: 'unavailable'; cause: unknown };