@oxy.so/protocol 1.6.1 → 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");
@@ -151,6 +152,20 @@ function narrowDescription(value) {
151
152
  return null;
152
153
  return { v, publicKey };
153
154
  }
155
+ /**
156
+ * The organization answer must name the organization asked for. A Commons that
157
+ * predates organization keys ignores the `accountId` extra and answers for the
158
+ * PERSONAL identity, without an echo; that answer is refused here, because a
159
+ * personal key taken for an organization's is money sent to the wrong person.
160
+ */
161
+ function echoes(value, accountId) {
162
+ const r = record(value);
163
+ return r && r.accountId === accountId ? r : null;
164
+ }
165
+ function narrowOrganizationDescription(value, accountId) {
166
+ const description = narrowDescription(echoes(value, accountId));
167
+ return description ? { ...description, accountId } : null;
168
+ }
154
169
  function narrowProof(value) {
155
170
  const r = record(value);
156
171
  const publicKey = hexField(r?.publicKey, 130);
@@ -191,12 +206,77 @@ function loadCommonsIdentityBridge() {
191
206
  }
192
207
  const m = native;
193
208
  return {
194
- describe: () => ask(() => m.describe(), narrowDescription),
209
+ describe: (accountId) => {
210
+ if (accountId === undefined)
211
+ return ask(() => m.describe(), narrowDescription);
212
+ const describeOrganization = m.describeOrganization;
213
+ if (typeof describeOrganization !== 'function')
214
+ return Promise.resolve(null);
215
+ return ask(() => describeOrganization.call(m, accountId), (v) => narrowOrganizationDescription(v, accountId));
216
+ },
195
217
  proveIdentity: (challenge) => ask(() => m.proveIdentity(challenge), narrowProof),
196
- deriveScopedSeed: (info) => ask(() => m.deriveScopedSeed(info), (v) => hexField(v, 64)),
197
- signSocialReceive: (index, digest) => ask(() => m.signSocialReceive(index, digest), narrowSocialReceive),
218
+ deriveScopedSeed: (info, accountId) => {
219
+ if (accountId === undefined) {
220
+ return ask(() => m.deriveScopedSeed(info), (v) => hexField(v, 64));
221
+ }
222
+ const derive = m.deriveOrganizationScopedSeed;
223
+ if (typeof derive !== 'function')
224
+ return Promise.resolve(null);
225
+ return ask(() => derive.call(m, info, accountId), (v) => hexField(echoes(v, accountId)?.seed, 64));
226
+ },
227
+ signSocialReceive: (index, digest, accountId) => {
228
+ if (accountId === undefined) {
229
+ return ask(() => m.signSocialReceive(index, digest), narrowSocialReceive);
230
+ }
231
+ const sign = m.signOrganizationSocialReceive;
232
+ if (typeof sign !== 'function')
233
+ return Promise.resolve(null);
234
+ return ask(() => sign.call(m, index, digest, accountId), (v) => narrowSocialReceive(echoes(v, accountId)));
235
+ },
198
236
  };
199
237
  });
200
238
  }
201
239
  return commonsIdentityBridgePromise;
202
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
+ }
@@ -142,6 +142,20 @@ function narrowDescription(value) {
142
142
  return null;
143
143
  return { v, publicKey };
144
144
  }
145
+ /**
146
+ * The organization answer must name the organization asked for. A Commons that
147
+ * predates organization keys ignores the `accountId` extra and answers for the
148
+ * PERSONAL identity, without an echo; that answer is refused here, because a
149
+ * personal key taken for an organization's is money sent to the wrong person.
150
+ */
151
+ function echoes(value, accountId) {
152
+ const r = record(value);
153
+ return r && r.accountId === accountId ? r : null;
154
+ }
155
+ function narrowOrganizationDescription(value, accountId) {
156
+ const description = narrowDescription(echoes(value, accountId));
157
+ return description ? { ...description, accountId } : null;
158
+ }
145
159
  function narrowProof(value) {
146
160
  const r = record(value);
147
161
  const publicKey = hexField(r?.publicKey, 130);
@@ -182,12 +196,77 @@ export function loadCommonsIdentityBridge() {
182
196
  }
183
197
  const m = native;
184
198
  return {
185
- describe: () => ask(() => m.describe(), narrowDescription),
199
+ describe: (accountId) => {
200
+ if (accountId === undefined)
201
+ return ask(() => m.describe(), narrowDescription);
202
+ const describeOrganization = m.describeOrganization;
203
+ if (typeof describeOrganization !== 'function')
204
+ return Promise.resolve(null);
205
+ return ask(() => describeOrganization.call(m, accountId), (v) => narrowOrganizationDescription(v, accountId));
206
+ },
186
207
  proveIdentity: (challenge) => ask(() => m.proveIdentity(challenge), narrowProof),
187
- deriveScopedSeed: (info) => ask(() => m.deriveScopedSeed(info), (v) => hexField(v, 64)),
188
- signSocialReceive: (index, digest) => ask(() => m.signSocialReceive(index, digest), narrowSocialReceive),
208
+ deriveScopedSeed: (info, accountId) => {
209
+ if (accountId === undefined) {
210
+ return ask(() => m.deriveScopedSeed(info), (v) => hexField(v, 64));
211
+ }
212
+ const derive = m.deriveOrganizationScopedSeed;
213
+ if (typeof derive !== 'function')
214
+ return Promise.resolve(null);
215
+ return ask(() => derive.call(m, info, accountId), (v) => hexField(echoes(v, accountId)?.seed, 64));
216
+ },
217
+ signSocialReceive: (index, digest, accountId) => {
218
+ if (accountId === undefined) {
219
+ return ask(() => m.signSocialReceive(index, digest), narrowSocialReceive);
220
+ }
221
+ const sign = m.signOrganizationSocialReceive;
222
+ if (typeof sign !== 'function')
223
+ return Promise.resolve(null);
224
+ return ask(() => sign.call(m, index, digest, accountId), (v) => narrowSocialReceive(echoes(v, accountId)));
225
+ },
189
226
  };
190
227
  });
191
228
  }
192
229
  return commonsIdentityBridgePromise;
193
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>;
@@ -79,6 +79,12 @@ export interface CommonsIdentityDescription {
79
79
  v: number;
80
80
  /** The identity's public key, lowercase uncompressed SEC1 hex. */
81
81
  publicKey: string;
82
+ /**
83
+ * Set only on an answer for an ORGANIZATION key (ADR 0061 D6): the account id
84
+ * Commons echoed, always equal to the one asked for. An answer without it is
85
+ * the personal identity and is never taken as an organization's.
86
+ */
87
+ accountId?: string;
82
88
  }
83
89
  /**
84
90
  * A signed server challenge from Commons (`proveIdentity`). The fields plug
@@ -109,23 +115,52 @@ export interface CommonsSocialReceiveSignature {
109
115
  * resolves `null` when Commons is not installed, holds no identity, or refuses
110
116
  * the caller.
111
117
  *
112
- * On iOS the module is a stub that resolves `null`: the Keychain Access Group
113
- * 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).
114
120
  */
115
121
  export interface CommonsIdentityBridge {
116
- /** The public key of the identity Commons holds. */
117
- describe(): Promise<CommonsIdentityDescription | null>;
122
+ /**
123
+ * The public key of the identity Commons holds, or, with `accountId`, of that
124
+ * organization's key (ADR 0061 D6).
125
+ */
126
+ describe(accountId?: string): Promise<CommonsIdentityDescription | null>;
118
127
  /** Sign a server challenge (64 lowercase hex) for `POST /auth/verify`. */
119
128
  proveIdentity(challenge: string): Promise<CommonsIdentityProof | null>;
120
129
  /**
121
- * HKDF-SHA256 over the identity key, 32 bytes as lowercase hex, identical to
122
- * `KeyManager.deriveScopedSeed(info)` on the device that holds the key.
123
- * Commons allows each caller package only its own `info` labels.
130
+ * HKDF-SHA256 over the identity key (or, with `accountId`, the organization's
131
+ * key), 32 bytes as lowercase hex, identical to `KeyManager.deriveScopedSeed`
132
+ * on the device that holds the key. Commons allows each caller package only
133
+ * its own `info` labels.
124
134
  */
125
- deriveScopedSeed(info: string): Promise<string | null>;
135
+ deriveScopedSeed(info: string, accountId?: string): Promise<string | null>;
126
136
  /**
127
137
  * Sign a 32-byte digest (64 hex) with the social-receive child key `index`
128
- * of the identity. Allowed for the wallet app only.
138
+ * of the identity, or with `accountId`, of that organization's key. Allowed
139
+ * for the wallet app only.
129
140
  */
130
- signSocialReceive(index: number, digest: string): Promise<CommonsSocialReceiveSignature | null>;
141
+ signSocialReceive(index: number, digest: string, accountId?: string): Promise<CommonsSocialReceiveSignature | null>;
131
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.6.1",
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",
@@ -133,7 +133,7 @@
133
133
  },
134
134
  "dependencies": {
135
135
  "@noble/curves": "^2.4.0",
136
- "@oxy.so/contracts": "^6.5.0"
136
+ "@oxy.so/contracts": "^6.11.0"
137
137
  },
138
138
  "peerDependencies": {
139
139
  "@react-native-async-storage/async-storage": "<4.0.0",
@@ -87,6 +87,85 @@ describe('loadCommonsIdentityBridge (native)', () => {
87
87
  await expect(bridge?.signSocialReceive(0, '11'.repeat(32))).resolves.toBeNull();
88
88
  });
89
89
 
90
+ test('organization requests go to the organization methods and must echo the account', async () => {
91
+ const ORG = '0123456789abcdef01234567';
92
+ const calls: unknown[][] = [];
93
+ mockNative({
94
+ describe: async () => ({ v: 2, publicKey: PUBLIC_KEY }),
95
+ proveIdentity: async () => null,
96
+ deriveScopedSeed: async () => 'cd'.repeat(32),
97
+ signSocialReceive: async () => ({ signature: '3045bb', publicKey: `02${'ef'.repeat(32)}` }),
98
+ describeOrganization: async (accountId: string) => {
99
+ calls.push(['describeOrganization', accountId]);
100
+ return { v: 3, publicKey: PUBLIC_KEY, accountId };
101
+ },
102
+ deriveOrganizationScopedSeed: async (info: string, accountId: string) => {
103
+ calls.push(['deriveOrganizationScopedSeed', info, accountId]);
104
+ return { seed: 'ee'.repeat(32), accountId };
105
+ },
106
+ signOrganizationSocialReceive: async (index: number, digest: string, accountId: string) => {
107
+ calls.push(['signOrganizationSocialReceive', index, digest, accountId]);
108
+ return { signature: '3046cc', publicKey: `03${'aa'.repeat(32)}`, accountId };
109
+ },
110
+ });
111
+ const bridge = await loadBridge();
112
+ await expect(bridge?.describe(ORG)).resolves.toEqual({
113
+ v: 3,
114
+ publicKey: PUBLIC_KEY,
115
+ accountId: ORG,
116
+ });
117
+ await expect(bridge?.deriveScopedSeed('peable/faircoin/v1', ORG)).resolves.toBe(
118
+ 'ee'.repeat(32),
119
+ );
120
+ await expect(bridge?.signSocialReceive(4, '11'.repeat(32), ORG)).resolves.toEqual({
121
+ signature: '3046cc',
122
+ publicKey: `03${'aa'.repeat(32)}`,
123
+ });
124
+ // The personal path is untouched.
125
+ await expect(bridge?.describe()).resolves.toEqual({ v: 2, publicKey: PUBLIC_KEY });
126
+ expect(calls).toEqual([
127
+ ['describeOrganization', ORG],
128
+ ['deriveOrganizationScopedSeed', 'peable/faircoin/v1', ORG],
129
+ ['signOrganizationSocialReceive', 4, '11'.repeat(32), ORG],
130
+ ]);
131
+ });
132
+
133
+ test('an organization answer without the echo, or for another account, is null', async () => {
134
+ const ORG = '0123456789abcdef01234567';
135
+ mockNative({
136
+ describe: async () => ({ v: 2, publicKey: PUBLIC_KEY }),
137
+ proveIdentity: async () => null,
138
+ deriveScopedSeed: async () => 'cd'.repeat(32),
139
+ signSocialReceive: async () => null,
140
+ // An old Commons ignored the extra and answered for the personal key.
141
+ describeOrganization: async () => ({ v: 2, publicKey: PUBLIC_KEY }),
142
+ deriveOrganizationScopedSeed: async () => ({ seed: 'ee'.repeat(32), accountId: 'other' }),
143
+ signOrganizationSocialReceive: async () => ({
144
+ signature: '3046cc',
145
+ publicKey: `03${'aa'.repeat(32)}`,
146
+ }),
147
+ });
148
+ const bridge = await loadBridge();
149
+ await expect(bridge?.describe(ORG)).resolves.toBeNull();
150
+ await expect(bridge?.deriveScopedSeed('peable/faircoin/v1', ORG)).resolves.toBeNull();
151
+ await expect(bridge?.signSocialReceive(0, '11'.repeat(32), ORG)).resolves.toBeNull();
152
+ });
153
+
154
+ test('a native module without the organization methods answers no organization', async () => {
155
+ mockNative({
156
+ describe: async () => ({ v: 2, publicKey: PUBLIC_KEY }),
157
+ proveIdentity: async () => null,
158
+ deriveScopedSeed: async () => 'cd'.repeat(32),
159
+ signSocialReceive: async () => ({ signature: '3045bb', publicKey: `02${'ef'.repeat(32)}` }),
160
+ });
161
+ const bridge = await loadBridge();
162
+ await expect(bridge?.describe('0123456789abcdef01234567')).resolves.toBeNull();
163
+ await expect(bridge?.deriveScopedSeed('x', '0123456789abcdef01234567')).resolves.toBeNull();
164
+ await expect(
165
+ bridge?.signSocialReceive(0, '11'.repeat(32), '0123456789abcdef01234567'),
166
+ ).resolves.toBeNull();
167
+ });
168
+
90
169
  test('the web / Node variant never has a bridge', async () => {
91
170
  const mod = await import('../platform/crypto');
92
171
  await expect(mod.loadCommonsIdentityBridge()).resolves.toBeNull();
@@ -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
  // ---------------------------------------------------------------------------
@@ -204,6 +206,25 @@ interface OxyIdentityNativeModule {
204
206
  proveIdentity(challenge: string): Promise<unknown>;
205
207
  deriveScopedSeed(info: string): Promise<unknown>;
206
208
  signSocialReceive(index: number, digest: string): Promise<unknown>;
209
+ /**
210
+ * Organization keys (ADR 0061 D6), added in `@oxy.so/services` after the four
211
+ * above: an older native module lacks them, and every organization request
212
+ * then answers `null`, never the personal identity. Each answer echoes the
213
+ * `accountId` Commons answered for.
214
+ */
215
+ describeOrganization?(accountId: string): Promise<unknown>;
216
+ deriveOrganizationScopedSeed?(info: string, accountId: string): Promise<unknown>;
217
+ signOrganizationSocialReceive?(
218
+ index: number,
219
+ digest: string,
220
+ accountId: string,
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>;
207
228
  }
208
229
 
209
230
  const LOWER_HEX = /^[0-9a-f]+$/;
@@ -226,6 +247,25 @@ function narrowDescription(value: unknown): CommonsIdentityDescription | null {
226
247
  return { v, publicKey };
227
248
  }
228
249
 
250
+ /**
251
+ * The organization answer must name the organization asked for. A Commons that
252
+ * predates organization keys ignores the `accountId` extra and answers for the
253
+ * PERSONAL identity, without an echo; that answer is refused here, because a
254
+ * personal key taken for an organization's is money sent to the wrong person.
255
+ */
256
+ function echoes(value: unknown, accountId: string): Record<string, unknown> | null {
257
+ const r = record(value);
258
+ return r && r.accountId === accountId ? r : null;
259
+ }
260
+
261
+ function narrowOrganizationDescription(
262
+ value: unknown,
263
+ accountId: string,
264
+ ): CommonsIdentityDescription | null {
265
+ const description = narrowDescription(echoes(value, accountId));
266
+ return description ? { ...description, accountId } : null;
267
+ }
268
+
229
269
  function narrowProof(value: unknown): CommonsIdentityProof | null {
230
270
  const r = record(value);
231
271
  const publicKey = hexField(r?.publicKey, 130);
@@ -272,17 +312,88 @@ export function loadCommonsIdentityBridge(): Promise<CommonsIdentityBridge | nul
272
312
  }
273
313
  const m = native as OxyIdentityNativeModule;
274
314
  return {
275
- describe: () => ask(() => m.describe(), narrowDescription),
315
+ describe: (accountId?: string) => {
316
+ if (accountId === undefined) return ask(() => m.describe(), narrowDescription);
317
+ const describeOrganization = m.describeOrganization;
318
+ if (typeof describeOrganization !== 'function') return Promise.resolve(null);
319
+ return ask(
320
+ () => describeOrganization.call(m, accountId),
321
+ (v) => narrowOrganizationDescription(v, accountId),
322
+ );
323
+ },
276
324
  proveIdentity: (challenge: string) => ask(() => m.proveIdentity(challenge), narrowProof),
277
- deriveScopedSeed: (info: string) =>
278
- ask(
279
- () => m.deriveScopedSeed(info),
280
- (v) => hexField(v, 64),
281
- ),
282
- signSocialReceive: (index: number, digest: string) =>
283
- ask(() => m.signSocialReceive(index, digest), narrowSocialReceive),
325
+ deriveScopedSeed: (info: string, accountId?: string) => {
326
+ if (accountId === undefined) {
327
+ return ask(
328
+ () => m.deriveScopedSeed(info),
329
+ (v) => hexField(v, 64),
330
+ );
331
+ }
332
+ const derive = m.deriveOrganizationScopedSeed;
333
+ if (typeof derive !== 'function') return Promise.resolve(null);
334
+ return ask(
335
+ () => derive.call(m, info, accountId),
336
+ (v) => hexField(echoes(v, accountId)?.seed, 64),
337
+ );
338
+ },
339
+ signSocialReceive: (index: number, digest: string, accountId?: string) => {
340
+ if (accountId === undefined) {
341
+ return ask(() => m.signSocialReceive(index, digest), narrowSocialReceive);
342
+ }
343
+ const sign = m.signOrganizationSocialReceive;
344
+ if (typeof sign !== 'function') return Promise.resolve(null);
345
+ return ask(
346
+ () => sign.call(m, index, digest, accountId),
347
+ (v) => narrowSocialReceive(echoes(v, accountId)),
348
+ );
349
+ },
284
350
  } satisfies CommonsIdentityBridge;
285
351
  });
286
352
  }
287
353
  return commonsIdentityBridgePromise;
288
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
+ }
@@ -82,6 +82,12 @@ export interface CommonsIdentityDescription {
82
82
  v: number;
83
83
  /** The identity's public key, lowercase uncompressed SEC1 hex. */
84
84
  publicKey: string;
85
+ /**
86
+ * Set only on an answer for an ORGANIZATION key (ADR 0061 D6): the account id
87
+ * Commons echoed, always equal to the one asked for. An answer without it is
88
+ * the personal identity and is never taken as an organization's.
89
+ */
90
+ accountId?: string;
85
91
  }
86
92
 
87
93
  /**
@@ -115,23 +121,52 @@ export interface CommonsSocialReceiveSignature {
115
121
  * resolves `null` when Commons is not installed, holds no identity, or refuses
116
122
  * the caller.
117
123
  *
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.
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).
120
126
  */
121
127
  export interface CommonsIdentityBridge {
122
- /** The public key of the identity Commons holds. */
123
- describe(): Promise<CommonsIdentityDescription | null>;
128
+ /**
129
+ * The public key of the identity Commons holds, or, with `accountId`, of that
130
+ * organization's key (ADR 0061 D6).
131
+ */
132
+ describe(accountId?: string): Promise<CommonsIdentityDescription | null>;
124
133
  /** Sign a server challenge (64 lowercase hex) for `POST /auth/verify`. */
125
134
  proveIdentity(challenge: string): Promise<CommonsIdentityProof | null>;
126
135
  /**
127
- * HKDF-SHA256 over the identity key, 32 bytes as lowercase hex, identical to
128
- * `KeyManager.deriveScopedSeed(info)` on the device that holds the key.
129
- * Commons allows each caller package only its own `info` labels.
136
+ * HKDF-SHA256 over the identity key (or, with `accountId`, the organization's
137
+ * key), 32 bytes as lowercase hex, identical to `KeyManager.deriveScopedSeed`
138
+ * on the device that holds the key. Commons allows each caller package only
139
+ * its own `info` labels.
130
140
  */
131
- deriveScopedSeed(info: string): Promise<string | null>;
141
+ deriveScopedSeed(info: string, accountId?: string): Promise<string | null>;
132
142
  /**
133
143
  * Sign a 32-byte digest (64 hex) with the social-receive child key `index`
134
- * of the identity. Allowed for the wallet app only.
144
+ * of the identity, or with `accountId`, of that organization's key. Allowed
145
+ * for the wallet app only.
135
146
  */
136
- signSocialReceive(index: number, digest: string): Promise<CommonsSocialReceiveSignature | null>;
147
+ signSocialReceive(
148
+ index: number,
149
+ digest: string,
150
+ accountId?: string,
151
+ ): Promise<CommonsSocialReceiveSignature | null>;
137
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 };