@oxy.so/protocol 1.9.0 → 1.10.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.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;
14
+ exports.loadKeychainApplicationAccessGroup = 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
  // ---------------------------------------------------------------------------
@@ -78,3 +78,4 @@ Object.defineProperty(exports, "loadAsyncStorage", { enumerable: true, get: func
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
80
  Object.defineProperty(exports, "loadKeychainDefaultAccessGroup", { enumerable: true, get: function () { return crypto_1.loadKeychainDefaultAccessGroup; } });
81
+ Object.defineProperty(exports, "loadKeychainApplicationAccessGroup", { enumerable: true, get: function () { return crypto_1.loadKeychainApplicationAccessGroup; } });
@@ -113,6 +113,7 @@ exports.loadSecureStore = loadSecureStore;
113
113
  exports.loadAsyncStorage = loadAsyncStorage;
114
114
  exports.loadCommonsIdentityBridge = loadCommonsIdentityBridge;
115
115
  exports.loadKeychainDefaultAccessGroup = loadKeychainDefaultAccessGroup;
116
+ exports.loadKeychainApplicationAccessGroup = loadKeychainApplicationAccessGroup;
116
117
  const platform_1 = require("./platform");
117
118
  // ---------------------------------------------------------------------------
118
119
  // Node `crypto` — Node built-in
@@ -184,3 +185,6 @@ function loadCommonsIdentityBridge() {
184
185
  function loadKeychainDefaultAccessGroup() {
185
186
  return Promise.resolve({ state: 'unsupported' });
186
187
  }
188
+ function loadKeychainApplicationAccessGroup(_prefix) {
189
+ return Promise.resolve({ state: 'unsupported' });
190
+ }
@@ -60,6 +60,7 @@ exports.loadSecureStore = loadSecureStore;
60
60
  exports.loadAsyncStorage = loadAsyncStorage;
61
61
  exports.loadCommonsIdentityBridge = loadCommonsIdentityBridge;
62
62
  exports.loadKeychainDefaultAccessGroup = loadKeychainDefaultAccessGroup;
63
+ exports.loadKeychainApplicationAccessGroup = loadKeychainApplicationAccessGroup;
63
64
  const expo_modules_core_1 = require("expo-modules-core");
64
65
  const optionalPeer_1 = require("./optionalPeer");
65
66
  const random_native_1 = require("./random.native");
@@ -335,3 +336,50 @@ async function loadKeychainDefaultAccessGroup() {
335
336
  keychainDefaultAccessGroup = { state: 'resolved', accessGroup: answer };
336
337
  return keychainDefaultAccessGroup;
337
338
  }
339
+ // ---------------------------------------------------------------------------
340
+ // iOS keychain application access group (ADR 0062 D6): `<prefix><bundle id>`,
341
+ // the group every app is a member of and no other app can name — where an app
342
+ // keeps the items that are its alone. Same caching as the default group: a
343
+ // resolved or unsupported answer is constant for the binary; a thrown probe is
344
+ // asked again.
345
+ // ---------------------------------------------------------------------------
346
+ /** A team prefix as the default group carries it: no dots, then one dot. */
347
+ const KEYCHAIN_PREFIX = /^[A-Za-z0-9]+\.$/;
348
+ let keychainApplicationAccessGroup = null;
349
+ async function loadKeychainApplicationAccessGroup(prefix) {
350
+ if (keychainApplicationAccessGroup) {
351
+ return keychainApplicationAccessGroup;
352
+ }
353
+ if (!KEYCHAIN_PREFIX.test(prefix)) {
354
+ return {
355
+ state: 'unavailable',
356
+ cause: new Error('keychain application group probe needs a team prefix'),
357
+ };
358
+ }
359
+ const native = (0, expo_modules_core_1.requireOptionalNativeModule)('OxyIdentity');
360
+ const probe = native?.keychainApplicationAccessGroup;
361
+ if (!native || typeof probe !== 'function') {
362
+ keychainApplicationAccessGroup = { state: 'unsupported' };
363
+ return keychainApplicationAccessGroup;
364
+ }
365
+ let answer;
366
+ try {
367
+ answer = await probe.call(native, prefix);
368
+ }
369
+ catch (cause) {
370
+ return { state: 'unavailable', cause };
371
+ }
372
+ if (answer === null || answer === undefined) {
373
+ // Not entitled (or not iOS): this binary keeps the placement it was built for.
374
+ keychainApplicationAccessGroup = { state: 'unsupported' };
375
+ return keychainApplicationAccessGroup;
376
+ }
377
+ if (typeof answer !== 'string' || !answer.startsWith(prefix) || !ACCESS_GROUP.test(answer)) {
378
+ // A native bug, not a locked keychain: asking again would answer the same,
379
+ // and `unavailable` would leave the app without storage. Keep the
380
+ // placement this build had before the probe existed.
381
+ return { state: 'unsupported' };
382
+ }
383
+ keychainApplicationAccessGroup = { state: 'resolved', accessGroup: answer };
384
+ return keychainApplicationAccessGroup;
385
+ }
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, loadKeychainDefaultAccessGroup, } from './platform/crypto.js';
40
+ export { loadNodeCrypto, loadExpoCrypto, loadSecureStore, loadAsyncStorage, getRandomBytesRN, loadCommonsIdentityBridge, loadKeychainDefaultAccessGroup, loadKeychainApplicationAccessGroup, } from './platform/crypto.js';
@@ -141,3 +141,6 @@ export function loadCommonsIdentityBridge() {
141
141
  export function loadKeychainDefaultAccessGroup() {
142
142
  return Promise.resolve({ state: 'unsupported' });
143
143
  }
144
+ export function loadKeychainApplicationAccessGroup(_prefix) {
145
+ return Promise.resolve({ state: 'unsupported' });
146
+ }
@@ -325,3 +325,50 @@ export async function loadKeychainDefaultAccessGroup() {
325
325
  keychainDefaultAccessGroup = { state: 'resolved', accessGroup: answer };
326
326
  return keychainDefaultAccessGroup;
327
327
  }
328
+ // ---------------------------------------------------------------------------
329
+ // iOS keychain application access group (ADR 0062 D6): `<prefix><bundle id>`,
330
+ // the group every app is a member of and no other app can name — where an app
331
+ // keeps the items that are its alone. Same caching as the default group: a
332
+ // resolved or unsupported answer is constant for the binary; a thrown probe is
333
+ // asked again.
334
+ // ---------------------------------------------------------------------------
335
+ /** A team prefix as the default group carries it: no dots, then one dot. */
336
+ const KEYCHAIN_PREFIX = /^[A-Za-z0-9]+\.$/;
337
+ let keychainApplicationAccessGroup = null;
338
+ export async function loadKeychainApplicationAccessGroup(prefix) {
339
+ if (keychainApplicationAccessGroup) {
340
+ return keychainApplicationAccessGroup;
341
+ }
342
+ if (!KEYCHAIN_PREFIX.test(prefix)) {
343
+ return {
344
+ state: 'unavailable',
345
+ cause: new Error('keychain application group probe needs a team prefix'),
346
+ };
347
+ }
348
+ const native = requireOptionalNativeModule('OxyIdentity');
349
+ const probe = native?.keychainApplicationAccessGroup;
350
+ if (!native || typeof probe !== 'function') {
351
+ keychainApplicationAccessGroup = { state: 'unsupported' };
352
+ return keychainApplicationAccessGroup;
353
+ }
354
+ let answer;
355
+ try {
356
+ answer = await probe.call(native, prefix);
357
+ }
358
+ catch (cause) {
359
+ return { state: 'unavailable', cause };
360
+ }
361
+ if (answer === null || answer === undefined) {
362
+ // Not entitled (or not iOS): this binary keeps the placement it was built for.
363
+ keychainApplicationAccessGroup = { state: 'unsupported' };
364
+ return keychainApplicationAccessGroup;
365
+ }
366
+ if (typeof answer !== 'string' || !answer.startsWith(prefix) || !ACCESS_GROUP.test(answer)) {
367
+ // A native bug, not a locked keychain: asking again would answer the same,
368
+ // and `unavailable` would leave the app without storage. Keep the
369
+ // placement this build had before the probe existed.
370
+ return { state: 'unsupported' };
371
+ }
372
+ keychainApplicationAccessGroup = { state: 'resolved', accessGroup: answer };
373
+ return keychainApplicationAccessGroup;
374
+ }
@@ -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, loadKeychainDefaultAccessGroup, } from './platform/crypto';
33
- export type { CommonsIdentityBridge, CommonsIdentityDescription, CommonsIdentityProof, CommonsSocialReceiveSignature, ExpoCryptoLike, ExpoSecureStoreLike, KeychainDefaultAccessGroup, } from './platform/expoTypes';
32
+ export { loadNodeCrypto, loadExpoCrypto, loadSecureStore, loadAsyncStorage, getRandomBytesRN, loadCommonsIdentityBridge, loadKeychainDefaultAccessGroup, loadKeychainApplicationAccessGroup, } from './platform/crypto';
33
+ export type { CommonsIdentityBridge, CommonsIdentityDescription, CommonsIdentityProof, CommonsSocialReceiveSignature, ExpoCryptoLike, ExpoSecureStoreLike, KeychainApplicationAccessGroup, 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, KeychainDefaultAccessGroup } from './expoTypes';
75
- export type { CommonsIdentityBridge, CommonsIdentityDescription, CommonsIdentityProof, CommonsSocialReceiveSignature, ExpoCryptoLike, ExpoSecureStoreLike, KeychainDefaultAccessGroup, };
74
+ import type { CommonsIdentityBridge, CommonsIdentityDescription, CommonsIdentityProof, CommonsSocialReceiveSignature, ExpoCryptoLike, ExpoSecureStoreLike, KeychainApplicationAccessGroup, KeychainDefaultAccessGroup } from './expoTypes';
75
+ export type { CommonsIdentityBridge, CommonsIdentityDescription, CommonsIdentityProof, CommonsSocialReceiveSignature, ExpoCryptoLike, ExpoSecureStoreLike, KeychainApplicationAccessGroup, 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>;
@@ -86,3 +86,4 @@ export declare function loadAsyncStorage(): Promise<{
86
86
  export { getRandomBytesRN } from './random';
87
87
  export declare function loadCommonsIdentityBridge(): Promise<CommonsIdentityBridge | null>;
88
88
  export declare function loadKeychainDefaultAccessGroup(): Promise<KeychainDefaultAccessGroup>;
89
+ export declare function loadKeychainApplicationAccessGroup(_prefix: string): Promise<KeychainApplicationAccessGroup>;
@@ -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, KeychainDefaultAccessGroup } from './expoTypes';
55
- export type { CommonsIdentityBridge, CommonsIdentityDescription, CommonsIdentityProof, CommonsSocialReceiveSignature, ExpoCryptoLike, ExpoSecureStoreLike, KeychainDefaultAccessGroup, };
54
+ import type { CommonsIdentityBridge, CommonsIdentityDescription, CommonsIdentityProof, CommonsSocialReceiveSignature, ExpoCryptoLike, ExpoSecureStoreLike, KeychainApplicationAccessGroup, KeychainDefaultAccessGroup } from './expoTypes';
55
+ export type { CommonsIdentityBridge, CommonsIdentityDescription, CommonsIdentityProof, CommonsSocialReceiveSignature, ExpoCryptoLike, ExpoSecureStoreLike, KeychainApplicationAccessGroup, 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>;
@@ -68,3 +68,4 @@ export declare function loadAsyncStorage(): Promise<{
68
68
  export { getRandomBytesRN } from './random.native';
69
69
  export declare function loadCommonsIdentityBridge(): Promise<CommonsIdentityBridge | null>;
70
70
  export declare function loadKeychainDefaultAccessGroup(): Promise<KeychainDefaultAccessGroup>;
71
+ export declare function loadKeychainApplicationAccessGroup(prefix: string): Promise<KeychainApplicationAccessGroup>;
@@ -192,3 +192,31 @@ export type KeychainDefaultAccessGroup = {
192
192
  state: 'unavailable';
193
193
  cause: unknown;
194
194
  };
195
+ /**
196
+ * What the iOS keychain says about this app's OWN access group: its
197
+ * application identifier, `<prefix><bundle id>` (`ABCDE12345.so.oxy.mention`).
198
+ * Apple makes every app a member of that group whatever its
199
+ * `keychain-access-groups` lists, and no other app can name it, so it is where
200
+ * an app keeps the items that are its alone (its session credential): the
201
+ * keychain's counterpart of Android's per-app storage (ADR 0062 D6).
202
+ *
203
+ * The native module writes a probe item into that group EXPLICITLY and answers
204
+ * the group only when the keychain took it.
205
+ *
206
+ * - `resolved`: the group string.
207
+ * - `unsupported`: not iOS, a native build whose `OxyIdentity` module predates
208
+ * this probe, a keychain that refused the group as not entitled, or an answer
209
+ * that is not this team's group. Constant for the binary: callers keep the
210
+ * placement that build was made for.
211
+ * - `unavailable`: the probe threw (before first unlock). Transient; never
212
+ * cached.
213
+ */
214
+ export type KeychainApplicationAccessGroup = {
215
+ state: 'resolved';
216
+ accessGroup: string;
217
+ } | {
218
+ state: 'unsupported';
219
+ } | {
220
+ state: 'unavailable';
221
+ cause: unknown;
222
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxy.so/protocol",
3
- "version": "1.9.0",
3
+ "version": "1.10.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",
@@ -63,3 +63,68 @@ describe('loadKeychainDefaultAccessGroup (native)', () => {
63
63
  await expect(mod.loadKeychainDefaultAccessGroup()).resolves.toEqual({ state: 'unsupported' });
64
64
  });
65
65
  });
66
+
67
+ describe('loadKeychainApplicationAccessGroup (native, ADR 0062 D6)', () => {
68
+ beforeEach(() => {
69
+ jest.resetModules();
70
+ });
71
+
72
+ const load = async (prefix = 'ABCDE12345.') =>
73
+ (await import('../platform/crypto.native')).loadKeychainApplicationAccessGroup(prefix);
74
+
75
+ test('a native module without the probe, or a refused group, is unsupported', async () => {
76
+ mockNative({ keychainDefaultAccessGroup: async () => 'ABCDE12345.group.so.oxy.shared' });
77
+ await expect(load()).resolves.toEqual({ state: 'unsupported' });
78
+ jest.resetModules();
79
+ mockNative({ keychainApplicationAccessGroup: async () => null });
80
+ await expect(load()).resolves.toEqual({ state: 'unsupported' });
81
+ });
82
+
83
+ test('passes this team’s group through with the prefix, and remembers it', async () => {
84
+ const keychainApplicationAccessGroup = jest.fn(async () => 'ABCDE12345.so.oxy.mention');
85
+ mockNative({ keychainApplicationAccessGroup });
86
+ const mod = await import('../platform/crypto.native');
87
+ await expect(mod.loadKeychainApplicationAccessGroup('ABCDE12345.')).resolves.toEqual({
88
+ state: 'resolved',
89
+ accessGroup: 'ABCDE12345.so.oxy.mention',
90
+ });
91
+ await mod.loadKeychainApplicationAccessGroup('ABCDE12345.');
92
+ expect(keychainApplicationAccessGroup).toHaveBeenCalledTimes(1);
93
+ expect(keychainApplicationAccessGroup).toHaveBeenCalledWith('ABCDE12345.');
94
+ });
95
+
96
+ test('a throw is unavailable and asked again; a bad prefix is never sent', async () => {
97
+ let locked = true;
98
+ const keychainApplicationAccessGroup = jest.fn(async () => {
99
+ if (locked) throw new Error('errSecInteractionNotAllowed');
100
+ return 'ABCDE12345.so.oxy.mention';
101
+ });
102
+ mockNative({ keychainApplicationAccessGroup });
103
+ const mod = await import('../platform/crypto.native');
104
+ expect((await mod.loadKeychainApplicationAccessGroup('ABCDE12345.')).state).toBe('unavailable');
105
+ locked = false;
106
+ expect((await mod.loadKeychainApplicationAccessGroup('ABCDE12345.')).state).toBe('resolved');
107
+ jest.resetModules();
108
+ mockNative({ keychainApplicationAccessGroup });
109
+ keychainApplicationAccessGroup.mockClear();
110
+ for (const prefix of ['', 'ABCDE12345', 'A.B.']) {
111
+ expect((await load(prefix)).state).toBe('unavailable');
112
+ }
113
+ expect(keychainApplicationAccessGroup).not.toHaveBeenCalled();
114
+ });
115
+
116
+ test('another team’s group or junk is unsupported: the earlier placement, never no storage', async () => {
117
+ for (const junk of [42, 'XYZ.so.oxy.mention', 'ABCDE12345.', { group: 'x' }]) {
118
+ jest.resetModules();
119
+ mockNative({ keychainApplicationAccessGroup: async () => junk });
120
+ expect((await load()).state).toBe('unsupported');
121
+ }
122
+ });
123
+
124
+ test('the web / Node variant is unsupported', async () => {
125
+ const mod = await import('../platform/crypto');
126
+ await expect(mod.loadKeychainApplicationAccessGroup('ABCDE12345.')).resolves.toEqual({
127
+ state: 'unsupported',
128
+ });
129
+ });
130
+ });
package/src/index.ts CHANGED
@@ -100,6 +100,7 @@ export {
100
100
  getRandomBytesRN,
101
101
  loadCommonsIdentityBridge,
102
102
  loadKeychainDefaultAccessGroup,
103
+ loadKeychainApplicationAccessGroup,
103
104
  } from './platform/crypto';
104
105
 
105
106
  // ---------------------------------------------------------------------------
@@ -116,5 +117,6 @@ export type {
116
117
  CommonsSocialReceiveSignature,
117
118
  ExpoCryptoLike,
118
119
  ExpoSecureStoreLike,
120
+ KeychainApplicationAccessGroup,
119
121
  KeychainDefaultAccessGroup,
120
122
  } from './platform/expoTypes';
@@ -60,6 +60,7 @@ import type {
60
60
  CommonsSocialReceiveSignature,
61
61
  ExpoCryptoLike,
62
62
  ExpoSecureStoreLike,
63
+ KeychainApplicationAccessGroup,
63
64
  KeychainDefaultAccessGroup,
64
65
  } from './expoTypes';
65
66
  import { missingOptionalPeerError } from './optionalPeer';
@@ -74,6 +75,7 @@ export type {
74
75
  CommonsSocialReceiveSignature,
75
76
  ExpoCryptoLike,
76
77
  ExpoSecureStoreLike,
78
+ KeychainApplicationAccessGroup,
77
79
  KeychainDefaultAccessGroup,
78
80
  };
79
81
 
@@ -225,6 +227,13 @@ interface OxyIdentityNativeModule {
225
227
  * Older native modules and Android lack it.
226
228
  */
227
229
  keychainDefaultAccessGroup?(): Promise<unknown>;
230
+ /**
231
+ * iOS only, added after the default-group probe (ADR 0062 D6): writes a
232
+ * probe item into `<prefix><bundle id>` explicitly and answers that group,
233
+ * or `null` when the keychain refused it as not entitled. Older native
234
+ * modules and Android lack it.
235
+ */
236
+ keychainApplicationAccessGroup?(prefix: string): Promise<unknown>;
228
237
  /**
229
238
  * Retired keys (Commons protocol 4), added after the organization methods and
230
239
  * just as absent on an older native module. `accountId` is `null` for the
@@ -484,3 +493,55 @@ export async function loadKeychainDefaultAccessGroup(): Promise<KeychainDefaultA
484
493
  keychainDefaultAccessGroup = { state: 'resolved', accessGroup: answer };
485
494
  return keychainDefaultAccessGroup;
486
495
  }
496
+
497
+ // ---------------------------------------------------------------------------
498
+ // iOS keychain application access group (ADR 0062 D6): `<prefix><bundle id>`,
499
+ // the group every app is a member of and no other app can name — where an app
500
+ // keeps the items that are its alone. Same caching as the default group: a
501
+ // resolved or unsupported answer is constant for the binary; a thrown probe is
502
+ // asked again.
503
+ // ---------------------------------------------------------------------------
504
+
505
+ /** A team prefix as the default group carries it: no dots, then one dot. */
506
+ const KEYCHAIN_PREFIX = /^[A-Za-z0-9]+\.$/;
507
+
508
+ let keychainApplicationAccessGroup: KeychainApplicationAccessGroup | null = null;
509
+
510
+ export async function loadKeychainApplicationAccessGroup(
511
+ prefix: string,
512
+ ): Promise<KeychainApplicationAccessGroup> {
513
+ if (keychainApplicationAccessGroup) {
514
+ return keychainApplicationAccessGroup;
515
+ }
516
+ if (!KEYCHAIN_PREFIX.test(prefix)) {
517
+ return {
518
+ state: 'unavailable',
519
+ cause: new Error('keychain application group probe needs a team prefix'),
520
+ };
521
+ }
522
+ const native = requireOptionalNativeModule<Partial<OxyIdentityNativeModule>>('OxyIdentity');
523
+ const probe = native?.keychainApplicationAccessGroup;
524
+ if (!native || typeof probe !== 'function') {
525
+ keychainApplicationAccessGroup = { state: 'unsupported' };
526
+ return keychainApplicationAccessGroup;
527
+ }
528
+ let answer: unknown;
529
+ try {
530
+ answer = await probe.call(native, prefix);
531
+ } catch (cause) {
532
+ return { state: 'unavailable', cause };
533
+ }
534
+ if (answer === null || answer === undefined) {
535
+ // Not entitled (or not iOS): this binary keeps the placement it was built for.
536
+ keychainApplicationAccessGroup = { state: 'unsupported' };
537
+ return keychainApplicationAccessGroup;
538
+ }
539
+ if (typeof answer !== 'string' || !answer.startsWith(prefix) || !ACCESS_GROUP.test(answer)) {
540
+ // A native bug, not a locked keychain: asking again would answer the same,
541
+ // and `unavailable` would leave the app without storage. Keep the
542
+ // placement this build had before the probe existed.
543
+ return { state: 'unsupported' };
544
+ }
545
+ keychainApplicationAccessGroup = { state: 'resolved', accessGroup: answer };
546
+ return keychainApplicationAccessGroup;
547
+ }
@@ -80,6 +80,7 @@ import type {
80
80
  CommonsSocialReceiveSignature,
81
81
  ExpoCryptoLike,
82
82
  ExpoSecureStoreLike,
83
+ KeychainApplicationAccessGroup,
83
84
  KeychainDefaultAccessGroup,
84
85
  } from './expoTypes';
85
86
 
@@ -92,6 +93,7 @@ export type {
92
93
  CommonsSocialReceiveSignature,
93
94
  ExpoCryptoLike,
94
95
  ExpoSecureStoreLike,
96
+ KeychainApplicationAccessGroup,
95
97
  KeychainDefaultAccessGroup,
96
98
  };
97
99
 
@@ -186,3 +188,9 @@ export function loadCommonsIdentityBridge(): Promise<CommonsIdentityBridge | nul
186
188
  export function loadKeychainDefaultAccessGroup(): Promise<KeychainDefaultAccessGroup> {
187
189
  return Promise.resolve({ state: 'unsupported' });
188
190
  }
191
+
192
+ export function loadKeychainApplicationAccessGroup(
193
+ _prefix: string,
194
+ ): Promise<KeychainApplicationAccessGroup> {
195
+ return Promise.resolve({ state: 'unsupported' });
196
+ }
@@ -207,3 +207,27 @@ export type KeychainDefaultAccessGroup =
207
207
  | { state: 'resolved'; accessGroup: string }
208
208
  | { state: 'unsupported' }
209
209
  | { state: 'unavailable'; cause: unknown };
210
+
211
+ /**
212
+ * What the iOS keychain says about this app's OWN access group: its
213
+ * application identifier, `<prefix><bundle id>` (`ABCDE12345.so.oxy.mention`).
214
+ * Apple makes every app a member of that group whatever its
215
+ * `keychain-access-groups` lists, and no other app can name it, so it is where
216
+ * an app keeps the items that are its alone (its session credential): the
217
+ * keychain's counterpart of Android's per-app storage (ADR 0062 D6).
218
+ *
219
+ * The native module writes a probe item into that group EXPLICITLY and answers
220
+ * the group only when the keychain took it.
221
+ *
222
+ * - `resolved`: the group string.
223
+ * - `unsupported`: not iOS, a native build whose `OxyIdentity` module predates
224
+ * this probe, a keychain that refused the group as not entitled, or an answer
225
+ * that is not this team's group. Constant for the binary: callers keep the
226
+ * placement that build was made for.
227
+ * - `unavailable`: the probe threw (before first unlock). Transient; never
228
+ * cached.
229
+ */
230
+ export type KeychainApplicationAccessGroup =
231
+ | { state: 'resolved'; accessGroup: string }
232
+ | { state: 'unsupported' }
233
+ | { state: 'unavailable'; cause: unknown };