@oxy.so/protocol 1.7.0 → 1.9.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");
@@ -133,6 +134,8 @@ async function loadAsyncStorage() {
133
134
  var random_native_2 = require("./random.native");
134
135
  Object.defineProperty(exports, "getRandomBytesRN", { enumerable: true, get: function () { return random_native_2.getRandomBytesRN; } });
135
136
  const LOWER_HEX = /^[0-9a-f]+$/;
137
+ /** A lowercase uncompressed SEC1 public key, as Commons names every key it holds. */
138
+ const PUBLIC_KEY = /^04[0-9a-f]{128}$/;
136
139
  function record(value) {
137
140
  return typeof value === 'object' && value !== null ? value : null;
138
141
  }
@@ -165,6 +168,39 @@ function narrowOrganizationDescription(value, accountId) {
165
168
  const description = narrowDescription(echoes(value, accountId));
166
169
  return description ? { ...description, accountId } : null;
167
170
  }
171
+ /**
172
+ * A retired-key answer must name the key asked for, and the scope asked for:
173
+ * the organization's `accountId`, or none for the person. A Commons before
174
+ * protocol 4 ignores `keyPublicKey` and answers with the CURRENT key, without
175
+ * the echo; that answer is refused here.
176
+ */
177
+ function echoesRetired(value, publicKey, accountId) {
178
+ const r = record(value);
179
+ if (!r || r.keyPublicKey !== publicKey)
180
+ return null;
181
+ return (r.accountId ?? undefined) === accountId ? r : null;
182
+ }
183
+ /**
184
+ * The list must be every entry a well-formed public key: one malformed entry
185
+ * means an answer this side does not understand, and the whole answer is
186
+ * `null` rather than a list missing a key the wallet would then never sweep.
187
+ */
188
+ function narrowRetiredKeys(value, accountId) {
189
+ const r = record(value);
190
+ if (!r || (r.accountId ?? undefined) !== accountId)
191
+ return null;
192
+ const keys = r.publicKeys;
193
+ if (!Array.isArray(keys))
194
+ return null;
195
+ const out = [];
196
+ for (const key of keys) {
197
+ if (typeof key !== 'string' || !PUBLIC_KEY.test(key))
198
+ return null;
199
+ if (!out.includes(key))
200
+ out.push(key);
201
+ }
202
+ return out;
203
+ }
168
204
  function narrowProof(value) {
169
205
  const r = record(value);
170
206
  const publicKey = hexField(r?.publicKey, 130);
@@ -232,8 +268,70 @@ function loadCommonsIdentityBridge() {
232
268
  return Promise.resolve(null);
233
269
  return ask(() => sign.call(m, index, digest, accountId), (v) => narrowSocialReceive(echoes(v, accountId)));
234
270
  },
271
+ listRetiredKeys: (accountId) => {
272
+ const list = m.listRetiredKeys;
273
+ if (typeof list !== 'function')
274
+ return Promise.resolve(null);
275
+ return ask(() => list.call(m, accountId ?? null), (v) => narrowRetiredKeys(v, accountId));
276
+ },
277
+ deriveRetiredScopedSeed: (info, publicKey, accountId) => {
278
+ const derive = m.deriveRetiredScopedSeed;
279
+ if (typeof derive !== 'function' || !PUBLIC_KEY.test(publicKey)) {
280
+ return Promise.resolve(null);
281
+ }
282
+ return ask(() => derive.call(m, info, publicKey, accountId ?? null), (v) => hexField(echoesRetired(v, publicKey, accountId)?.seed, 64));
283
+ },
284
+ signRetiredSocialReceive: (index, digest, publicKey, accountId) => {
285
+ const sign = m.signRetiredSocialReceive;
286
+ if (typeof sign !== 'function' || !PUBLIC_KEY.test(publicKey)) {
287
+ return Promise.resolve(null);
288
+ }
289
+ return ask(() => sign.call(m, index, digest, publicKey, accountId ?? null), (v) => narrowSocialReceive(echoesRetired(v, publicKey, accountId)));
290
+ },
235
291
  };
236
292
  });
237
293
  }
238
294
  return commonsIdentityBridgePromise;
239
295
  }
296
+ // ---------------------------------------------------------------------------
297
+ // iOS keychain default access group (docs/identity/ios-keychain-access-groups.md).
298
+ //
299
+ // Resolved once the native module answers; an `unavailable` probe (a thrown
300
+ // call: before first unlock, or the keychain refused) is not memoized, so the
301
+ // next caller asks again. The answer is narrowed to a plain string — it crossed
302
+ // the native boundary.
303
+ // ---------------------------------------------------------------------------
304
+ /** `<prefix>.<group>`: an App ID prefix (no dots) then a non-empty group. */
305
+ const ACCESS_GROUP = /^[A-Za-z0-9]+\.[A-Za-z0-9.-]+$/;
306
+ let keychainDefaultAccessGroup = null;
307
+ async function loadKeychainDefaultAccessGroup() {
308
+ if (keychainDefaultAccessGroup) {
309
+ return keychainDefaultAccessGroup;
310
+ }
311
+ const native = (0, expo_modules_core_1.requireOptionalNativeModule)('OxyIdentity');
312
+ const probe = native?.keychainDefaultAccessGroup;
313
+ if (!native || typeof probe !== 'function') {
314
+ keychainDefaultAccessGroup = { state: 'unsupported' };
315
+ return keychainDefaultAccessGroup;
316
+ }
317
+ let answer;
318
+ try {
319
+ answer = await probe.call(native);
320
+ }
321
+ catch (cause) {
322
+ return { state: 'unavailable', cause };
323
+ }
324
+ if (answer === null || answer === undefined) {
325
+ // The native side runs only on iOS; anything else answers nothing.
326
+ keychainDefaultAccessGroup = { state: 'unsupported' };
327
+ return keychainDefaultAccessGroup;
328
+ }
329
+ if (typeof answer !== 'string' || !ACCESS_GROUP.test(answer)) {
330
+ return {
331
+ state: 'unavailable',
332
+ cause: new Error('keychain probe answered something that is not an access group'),
333
+ };
334
+ }
335
+ keychainDefaultAccessGroup = { state: 'resolved', accessGroup: answer };
336
+ return keychainDefaultAccessGroup;
337
+ }
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
+ }
@@ -124,6 +124,8 @@ export async function loadAsyncStorage() {
124
124
  // Metro pointed at the same file.
125
125
  export { getRandomBytesRN } from './random.native.js';
126
126
  const LOWER_HEX = /^[0-9a-f]+$/;
127
+ /** A lowercase uncompressed SEC1 public key, as Commons names every key it holds. */
128
+ const PUBLIC_KEY = /^04[0-9a-f]{128}$/;
127
129
  function record(value) {
128
130
  return typeof value === 'object' && value !== null ? value : null;
129
131
  }
@@ -156,6 +158,39 @@ function narrowOrganizationDescription(value, accountId) {
156
158
  const description = narrowDescription(echoes(value, accountId));
157
159
  return description ? { ...description, accountId } : null;
158
160
  }
161
+ /**
162
+ * A retired-key answer must name the key asked for, and the scope asked for:
163
+ * the organization's `accountId`, or none for the person. A Commons before
164
+ * protocol 4 ignores `keyPublicKey` and answers with the CURRENT key, without
165
+ * the echo; that answer is refused here.
166
+ */
167
+ function echoesRetired(value, publicKey, accountId) {
168
+ const r = record(value);
169
+ if (!r || r.keyPublicKey !== publicKey)
170
+ return null;
171
+ return (r.accountId ?? undefined) === accountId ? r : null;
172
+ }
173
+ /**
174
+ * The list must be every entry a well-formed public key: one malformed entry
175
+ * means an answer this side does not understand, and the whole answer is
176
+ * `null` rather than a list missing a key the wallet would then never sweep.
177
+ */
178
+ function narrowRetiredKeys(value, accountId) {
179
+ const r = record(value);
180
+ if (!r || (r.accountId ?? undefined) !== accountId)
181
+ return null;
182
+ const keys = r.publicKeys;
183
+ if (!Array.isArray(keys))
184
+ return null;
185
+ const out = [];
186
+ for (const key of keys) {
187
+ if (typeof key !== 'string' || !PUBLIC_KEY.test(key))
188
+ return null;
189
+ if (!out.includes(key))
190
+ out.push(key);
191
+ }
192
+ return out;
193
+ }
159
194
  function narrowProof(value) {
160
195
  const r = record(value);
161
196
  const publicKey = hexField(r?.publicKey, 130);
@@ -223,8 +258,70 @@ export function loadCommonsIdentityBridge() {
223
258
  return Promise.resolve(null);
224
259
  return ask(() => sign.call(m, index, digest, accountId), (v) => narrowSocialReceive(echoes(v, accountId)));
225
260
  },
261
+ listRetiredKeys: (accountId) => {
262
+ const list = m.listRetiredKeys;
263
+ if (typeof list !== 'function')
264
+ return Promise.resolve(null);
265
+ return ask(() => list.call(m, accountId ?? null), (v) => narrowRetiredKeys(v, accountId));
266
+ },
267
+ deriveRetiredScopedSeed: (info, publicKey, accountId) => {
268
+ const derive = m.deriveRetiredScopedSeed;
269
+ if (typeof derive !== 'function' || !PUBLIC_KEY.test(publicKey)) {
270
+ return Promise.resolve(null);
271
+ }
272
+ return ask(() => derive.call(m, info, publicKey, accountId ?? null), (v) => hexField(echoesRetired(v, publicKey, accountId)?.seed, 64));
273
+ },
274
+ signRetiredSocialReceive: (index, digest, publicKey, accountId) => {
275
+ const sign = m.signRetiredSocialReceive;
276
+ if (typeof sign !== 'function' || !PUBLIC_KEY.test(publicKey)) {
277
+ return Promise.resolve(null);
278
+ }
279
+ return ask(() => sign.call(m, index, digest, publicKey, accountId ?? null), (v) => narrowSocialReceive(echoesRetired(v, publicKey, accountId)));
280
+ },
226
281
  };
227
282
  });
228
283
  }
229
284
  return commonsIdentityBridgePromise;
230
285
  }
286
+ // ---------------------------------------------------------------------------
287
+ // iOS keychain default access group (docs/identity/ios-keychain-access-groups.md).
288
+ //
289
+ // Resolved once the native module answers; an `unavailable` probe (a thrown
290
+ // call: before first unlock, or the keychain refused) is not memoized, so the
291
+ // next caller asks again. The answer is narrowed to a plain string — it crossed
292
+ // the native boundary.
293
+ // ---------------------------------------------------------------------------
294
+ /** `<prefix>.<group>`: an App ID prefix (no dots) then a non-empty group. */
295
+ const ACCESS_GROUP = /^[A-Za-z0-9]+\.[A-Za-z0-9.-]+$/;
296
+ let keychainDefaultAccessGroup = null;
297
+ export async function loadKeychainDefaultAccessGroup() {
298
+ if (keychainDefaultAccessGroup) {
299
+ return keychainDefaultAccessGroup;
300
+ }
301
+ const native = requireOptionalNativeModule('OxyIdentity');
302
+ const probe = native?.keychainDefaultAccessGroup;
303
+ if (!native || typeof probe !== 'function') {
304
+ keychainDefaultAccessGroup = { state: 'unsupported' };
305
+ return keychainDefaultAccessGroup;
306
+ }
307
+ let answer;
308
+ try {
309
+ answer = await probe.call(native);
310
+ }
311
+ catch (cause) {
312
+ return { state: 'unavailable', cause };
313
+ }
314
+ if (answer === null || answer === undefined) {
315
+ // The native side runs only on iOS; anything else answers nothing.
316
+ keychainDefaultAccessGroup = { state: 'unsupported' };
317
+ return keychainDefaultAccessGroup;
318
+ }
319
+ if (typeof answer !== 'string' || !ACCESS_GROUP.test(answer)) {
320
+ return {
321
+ state: 'unavailable',
322
+ cause: new Error('keychain probe answered something that is not an access group'),
323
+ };
324
+ }
325
+ keychainDefaultAccessGroup = { state: 'resolved', accessGroup: answer };
326
+ return keychainDefaultAccessGroup;
327
+ }
@@ -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,16 @@ 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
+ *
121
+ * The three `*Retired*` methods (Commons protocol 4) reach a key the person,
122
+ * or the organization with `accountId`, rotated away from, by its public key,
123
+ * for SPENDING the coins still at its addresses: never for signing in. They are
124
+ * always present on the bridge and resolve `null` on a native module or a
125
+ * Commons that predates them; an answer that does not echo the key (and the
126
+ * account) asked for is refused, because the current key taken for a retired
127
+ * one is a payment signed by the wrong key.
120
128
  */
121
129
  export interface CommonsIdentityBridge {
122
130
  /**
@@ -139,4 +147,48 @@ export interface CommonsIdentityBridge {
139
147
  * for the wallet app only.
140
148
  */
141
149
  signSocialReceive(index: number, digest: string, accountId?: string): Promise<CommonsSocialReceiveSignature | null>;
150
+ /**
151
+ * The public keys (lowercase uncompressed SEC1 hex, de-duplicated) of the
152
+ * retired keys Commons holds for the person, or with `accountId` for that
153
+ * organization, minus the current one. `[]` when it holds none; `null` when
154
+ * Commons cannot say (absent, refused, or older than retired keys). Allowed
155
+ * for the wallet app only.
156
+ */
157
+ listRetiredKeys(accountId?: string): Promise<string[] | null>;
158
+ /**
159
+ * {@link deriveScopedSeed} over the key of that scope whose public key is
160
+ * `publicKey` (current or retired), never another key: `null` when Commons
161
+ * does not hold it.
162
+ */
163
+ deriveRetiredScopedSeed(info: string, publicKey: string, accountId?: string): Promise<string | null>;
164
+ /**
165
+ * {@link signSocialReceive} with the social-receive child `index` of the key
166
+ * of that scope whose public key is `publicKey` (current or retired), never
167
+ * another key. Allowed for the wallet app only.
168
+ */
169
+ signRetiredSocialReceive(index: number, digest: string, publicKey: string, accountId?: string): Promise<CommonsSocialReceiveSignature | null>;
142
170
  }
171
+ /**
172
+ * What the iOS keychain says about this app's DEFAULT access group: the first
173
+ * entry of its `keychain-access-groups` entitlement, team prefix included
174
+ * (`ABCDE12345.so.oxy.identity`). The value is read back from a probe item the
175
+ * native module writes without a group, because JavaScript cannot see the
176
+ * entitlements or the team prefix (`$(AppIdentifierPrefix)` expands at build
177
+ * time), and `kSecAttrAccessGroup` must equal an entitlement value exactly.
178
+ *
179
+ * - `resolved`: the group string, as the keychain reported it.
180
+ * - `unsupported`: not iOS, or a native build whose `OxyIdentity` module
181
+ * predates the probe. Callers keep the single-group behaviour that build was
182
+ * made for.
183
+ * - `unavailable`: the probe threw (before first unlock, keychain refused).
184
+ * Transient; never cached.
185
+ */
186
+ export type KeychainDefaultAccessGroup = {
187
+ state: 'resolved';
188
+ accessGroup: string;
189
+ } | {
190
+ state: 'unsupported';
191
+ } | {
192
+ state: 'unavailable';
193
+ cause: unknown;
194
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxy.so/protocol",
3
- "version": "1.7.0",
3
+ "version": "1.9.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.11.0"
136
+ "@oxy.so/contracts": "^6.12.0"
137
137
  },
138
138
  "peerDependencies": {
139
139
  "@react-native-async-storage/async-storage": "<4.0.0",
@@ -166,6 +166,175 @@ describe('loadCommonsIdentityBridge (native)', () => {
166
166
  ).resolves.toBeNull();
167
167
  });
168
168
 
169
+ describe('retired keys', () => {
170
+ const ORG = '0123456789abcdef01234567';
171
+ const RETIRED = `04${'cd'.repeat(64)}`;
172
+ const OTHER = `04${'ef'.repeat(64)}`;
173
+ const base = {
174
+ describe: async () => ({ v: 4, publicKey: PUBLIC_KEY }),
175
+ proveIdentity: async () => null,
176
+ deriveScopedSeed: async () => 'cd'.repeat(32),
177
+ signSocialReceive: async () => ({ signature: '3045bb', publicKey: `02${'ef'.repeat(32)}` }),
178
+ };
179
+
180
+ test('requests carry the key and the scope, and well-formed echoed answers pass', async () => {
181
+ const calls: unknown[][] = [];
182
+ mockNative({
183
+ ...base,
184
+ listRetiredKeys: async (accountId: string | null) => {
185
+ calls.push(['listRetiredKeys', accountId]);
186
+ return accountId === null
187
+ ? { publicKeys: [RETIRED, OTHER, RETIRED] }
188
+ : { publicKeys: [OTHER], accountId };
189
+ },
190
+ deriveRetiredScopedSeed: async (
191
+ info: string,
192
+ publicKey: string,
193
+ accountId: string | null,
194
+ ) => {
195
+ calls.push(['deriveRetiredScopedSeed', info, publicKey, accountId]);
196
+ return accountId === null
197
+ ? { seed: 'aa'.repeat(32), keyPublicKey: publicKey }
198
+ : { seed: 'bb'.repeat(32), keyPublicKey: publicKey, accountId };
199
+ },
200
+ signRetiredSocialReceive: async (
201
+ index: number,
202
+ digest: string,
203
+ publicKey: string,
204
+ accountId: string | null,
205
+ ) => {
206
+ calls.push(['signRetiredSocialReceive', index, digest, publicKey, accountId]);
207
+ return {
208
+ signature: '3046cc',
209
+ publicKey: `03${'aa'.repeat(32)}`,
210
+ keyPublicKey: publicKey,
211
+ };
212
+ },
213
+ });
214
+ const bridge = await loadBridge();
215
+ // De-duplicated, in Commons' order.
216
+ await expect(bridge?.listRetiredKeys()).resolves.toEqual([RETIRED, OTHER]);
217
+ await expect(bridge?.listRetiredKeys(ORG)).resolves.toEqual([OTHER]);
218
+ await expect(bridge?.deriveRetiredScopedSeed('peable/faircoin/v1', RETIRED)).resolves.toBe(
219
+ 'aa'.repeat(32),
220
+ );
221
+ await expect(bridge?.deriveRetiredScopedSeed('peable/faircoin/v1', OTHER, ORG)).resolves.toBe(
222
+ 'bb'.repeat(32),
223
+ );
224
+ await expect(bridge?.signRetiredSocialReceive(7, '11'.repeat(32), RETIRED)).resolves.toEqual({
225
+ signature: '3046cc',
226
+ publicKey: `03${'aa'.repeat(32)}`,
227
+ });
228
+ expect(calls).toEqual([
229
+ ['listRetiredKeys', null],
230
+ ['listRetiredKeys', ORG],
231
+ ['deriveRetiredScopedSeed', 'peable/faircoin/v1', RETIRED, null],
232
+ ['deriveRetiredScopedSeed', 'peable/faircoin/v1', OTHER, ORG],
233
+ ['signRetiredSocialReceive', 7, '11'.repeat(32), RETIRED, null],
234
+ ]);
235
+ });
236
+
237
+ test('an answer without the key echo, or for another key or scope, is null', async () => {
238
+ mockNative({
239
+ ...base,
240
+ // An older Commons ignored keyPublicKey and answered with the CURRENT key.
241
+ deriveRetiredScopedSeed: async (
242
+ _info: string,
243
+ publicKey: string,
244
+ accountId: string | null,
245
+ ) =>
246
+ accountId === null
247
+ ? { seed: 'aa'.repeat(32) }
248
+ : { seed: 'bb'.repeat(32), keyPublicKey: publicKey },
249
+ signRetiredSocialReceive: async (
250
+ _index: number,
251
+ _digest: string,
252
+ _publicKey: string,
253
+ accountId: string | null,
254
+ ) =>
255
+ accountId === null
256
+ ? { signature: '3046cc', publicKey: `03${'aa'.repeat(32)}`, keyPublicKey: OTHER }
257
+ : {
258
+ signature: '3046cc',
259
+ publicKey: `03${'aa'.repeat(32)}`,
260
+ keyPublicKey: RETIRED,
261
+ accountId: 'fedcba9876543210fedcba98',
262
+ },
263
+ // A personal answer for an organization request, and vice versa.
264
+ listRetiredKeys: async (accountId: string | null) =>
265
+ accountId === null
266
+ ? { publicKeys: [RETIRED], accountId: ORG }
267
+ : { publicKeys: [RETIRED] },
268
+ });
269
+ const bridge = await loadBridge();
270
+ await expect(
271
+ bridge?.deriveRetiredScopedSeed('peable/faircoin/v1', RETIRED),
272
+ ).resolves.toBeNull();
273
+ // The key echo is there but the account echo is missing.
274
+ await expect(
275
+ bridge?.deriveRetiredScopedSeed('peable/faircoin/v1', RETIRED, ORG),
276
+ ).resolves.toBeNull();
277
+ await expect(
278
+ bridge?.signRetiredSocialReceive(0, '11'.repeat(32), RETIRED),
279
+ ).resolves.toBeNull();
280
+ await expect(
281
+ bridge?.signRetiredSocialReceive(0, '11'.repeat(32), RETIRED, ORG),
282
+ ).resolves.toBeNull();
283
+ await expect(bridge?.listRetiredKeys()).resolves.toBeNull();
284
+ await expect(bridge?.listRetiredKeys(ORG)).resolves.toBeNull();
285
+ });
286
+
287
+ test('a list with a malformed entry is refused whole; an empty list is an answer', async () => {
288
+ const answers: unknown[] = [
289
+ { publicKeys: [RETIRED, `04${'CD'.repeat(64)}`] },
290
+ { publicKeys: [RETIRED, `02${'aa'.repeat(32)}`] },
291
+ { publicKeys: [RETIRED, 7] },
292
+ { publicKeys: RETIRED },
293
+ { publicKeys: [] },
294
+ ];
295
+ mockNative({ ...base, listRetiredKeys: async () => answers.shift() });
296
+ const bridge = await loadBridge();
297
+ await expect(bridge?.listRetiredKeys()).resolves.toBeNull();
298
+ await expect(bridge?.listRetiredKeys()).resolves.toBeNull();
299
+ await expect(bridge?.listRetiredKeys()).resolves.toBeNull();
300
+ await expect(bridge?.listRetiredKeys()).resolves.toBeNull();
301
+ await expect(bridge?.listRetiredKeys()).resolves.toEqual([]);
302
+ });
303
+
304
+ test('a malformed public key is never sent to Commons', async () => {
305
+ const derive = jest.fn(async () => ({ seed: 'aa'.repeat(32), keyPublicKey: 'x' }));
306
+ const sign = jest.fn(async () => null);
307
+ mockNative({ ...base, deriveRetiredScopedSeed: derive, signRetiredSocialReceive: sign });
308
+ const bridge = await loadBridge();
309
+ await expect(bridge?.deriveRetiredScopedSeed('peable/faircoin/v1', 'x')).resolves.toBeNull();
310
+ await expect(
311
+ bridge?.deriveRetiredScopedSeed('peable/faircoin/v1', RETIRED.toUpperCase()),
312
+ ).resolves.toBeNull();
313
+ await expect(
314
+ bridge?.signRetiredSocialReceive(0, '11'.repeat(32), `02${'aa'.repeat(32)}`),
315
+ ).resolves.toBeNull();
316
+ expect(derive).not.toHaveBeenCalled();
317
+ expect(sign).not.toHaveBeenCalled();
318
+ });
319
+
320
+ test('a native module without the retired functions answers null, never the current key', async () => {
321
+ const derive = jest.fn(base.deriveScopedSeed);
322
+ const sign = jest.fn(base.signSocialReceive);
323
+ mockNative({ ...base, deriveScopedSeed: derive, signSocialReceive: sign });
324
+ const bridge = await loadBridge();
325
+ expect(bridge).not.toBeNull();
326
+ await expect(bridge?.listRetiredKeys()).resolves.toBeNull();
327
+ await expect(
328
+ bridge?.deriveRetiredScopedSeed('peable/faircoin/v1', RETIRED),
329
+ ).resolves.toBeNull();
330
+ await expect(
331
+ bridge?.signRetiredSocialReceive(0, '11'.repeat(32), RETIRED, ORG),
332
+ ).resolves.toBeNull();
333
+ expect(derive).not.toHaveBeenCalled();
334
+ expect(sign).not.toHaveBeenCalled();
335
+ });
336
+ });
337
+
169
338
  test('the web / Node variant never has a bridge', async () => {
170
339
  const mod = await import('../platform/crypto');
171
340
  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
  // ---------------------------------------------------------------------------
@@ -217,9 +219,35 @@ 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>;
228
+ /**
229
+ * Retired keys (Commons protocol 4), added after the organization methods and
230
+ * just as absent on an older native module. `accountId` is `null` for the
231
+ * person. Each answer echoes the `keyPublicKey` (and the `accountId`) Commons
232
+ * answered for.
233
+ */
234
+ listRetiredKeys?(accountId: string | null): Promise<unknown>;
235
+ deriveRetiredScopedSeed?(
236
+ info: string,
237
+ publicKey: string,
238
+ accountId: string | null,
239
+ ): Promise<unknown>;
240
+ signRetiredSocialReceive?(
241
+ index: number,
242
+ digest: string,
243
+ publicKey: string,
244
+ accountId: string | null,
245
+ ): Promise<unknown>;
220
246
  }
221
247
 
222
248
  const LOWER_HEX = /^[0-9a-f]+$/;
249
+ /** A lowercase uncompressed SEC1 public key, as Commons names every key it holds. */
250
+ const PUBLIC_KEY = /^04[0-9a-f]{128}$/;
223
251
 
224
252
  function record(value: unknown): Record<string, unknown> | null {
225
253
  return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : null;
@@ -258,6 +286,40 @@ function narrowOrganizationDescription(
258
286
  return description ? { ...description, accountId } : null;
259
287
  }
260
288
 
289
+ /**
290
+ * A retired-key answer must name the key asked for, and the scope asked for:
291
+ * the organization's `accountId`, or none for the person. A Commons before
292
+ * protocol 4 ignores `keyPublicKey` and answers with the CURRENT key, without
293
+ * the echo; that answer is refused here.
294
+ */
295
+ function echoesRetired(
296
+ value: unknown,
297
+ publicKey: string,
298
+ accountId: string | undefined,
299
+ ): Record<string, unknown> | null {
300
+ const r = record(value);
301
+ if (!r || r.keyPublicKey !== publicKey) return null;
302
+ return (r.accountId ?? undefined) === accountId ? r : null;
303
+ }
304
+
305
+ /**
306
+ * The list must be every entry a well-formed public key: one malformed entry
307
+ * means an answer this side does not understand, and the whole answer is
308
+ * `null` rather than a list missing a key the wallet would then never sweep.
309
+ */
310
+ function narrowRetiredKeys(value: unknown, accountId: string | undefined): string[] | null {
311
+ const r = record(value);
312
+ if (!r || (r.accountId ?? undefined) !== accountId) return null;
313
+ const keys = r.publicKeys;
314
+ if (!Array.isArray(keys)) return null;
315
+ const out: string[] = [];
316
+ for (const key of keys) {
317
+ if (typeof key !== 'string' || !PUBLIC_KEY.test(key)) return null;
318
+ if (!out.includes(key)) out.push(key);
319
+ }
320
+ return out;
321
+ }
322
+
261
323
  function narrowProof(value: unknown): CommonsIdentityProof | null {
262
324
  const r = record(value);
263
325
  const publicKey = hexField(r?.publicKey, 130);
@@ -339,8 +401,86 @@ export function loadCommonsIdentityBridge(): Promise<CommonsIdentityBridge | nul
339
401
  (v) => narrowSocialReceive(echoes(v, accountId)),
340
402
  );
341
403
  },
404
+ listRetiredKeys: (accountId?: string) => {
405
+ const list = m.listRetiredKeys;
406
+ if (typeof list !== 'function') return Promise.resolve(null);
407
+ return ask(
408
+ () => list.call(m, accountId ?? null),
409
+ (v) => narrowRetiredKeys(v, accountId),
410
+ );
411
+ },
412
+ deriveRetiredScopedSeed: (info: string, publicKey: string, accountId?: string) => {
413
+ const derive = m.deriveRetiredScopedSeed;
414
+ if (typeof derive !== 'function' || !PUBLIC_KEY.test(publicKey)) {
415
+ return Promise.resolve(null);
416
+ }
417
+ return ask(
418
+ () => derive.call(m, info, publicKey, accountId ?? null),
419
+ (v) => hexField(echoesRetired(v, publicKey, accountId)?.seed, 64),
420
+ );
421
+ },
422
+ signRetiredSocialReceive: (
423
+ index: number,
424
+ digest: string,
425
+ publicKey: string,
426
+ accountId?: string,
427
+ ) => {
428
+ const sign = m.signRetiredSocialReceive;
429
+ if (typeof sign !== 'function' || !PUBLIC_KEY.test(publicKey)) {
430
+ return Promise.resolve(null);
431
+ }
432
+ return ask(
433
+ () => sign.call(m, index, digest, publicKey, accountId ?? null),
434
+ (v) => narrowSocialReceive(echoesRetired(v, publicKey, accountId)),
435
+ );
436
+ },
342
437
  } satisfies CommonsIdentityBridge;
343
438
  });
344
439
  }
345
440
  return commonsIdentityBridgePromise;
346
441
  }
442
+
443
+ // ---------------------------------------------------------------------------
444
+ // iOS keychain default access group (docs/identity/ios-keychain-access-groups.md).
445
+ //
446
+ // Resolved once the native module answers; an `unavailable` probe (a thrown
447
+ // call: before first unlock, or the keychain refused) is not memoized, so the
448
+ // next caller asks again. The answer is narrowed to a plain string — it crossed
449
+ // the native boundary.
450
+ // ---------------------------------------------------------------------------
451
+
452
+ /** `<prefix>.<group>`: an App ID prefix (no dots) then a non-empty group. */
453
+ const ACCESS_GROUP = /^[A-Za-z0-9]+\.[A-Za-z0-9.-]+$/;
454
+
455
+ let keychainDefaultAccessGroup: KeychainDefaultAccessGroup | null = null;
456
+
457
+ export async function loadKeychainDefaultAccessGroup(): Promise<KeychainDefaultAccessGroup> {
458
+ if (keychainDefaultAccessGroup) {
459
+ return keychainDefaultAccessGroup;
460
+ }
461
+ const native = requireOptionalNativeModule<Partial<OxyIdentityNativeModule>>('OxyIdentity');
462
+ const probe = native?.keychainDefaultAccessGroup;
463
+ if (!native || typeof probe !== 'function') {
464
+ keychainDefaultAccessGroup = { state: 'unsupported' };
465
+ return keychainDefaultAccessGroup;
466
+ }
467
+ let answer: unknown;
468
+ try {
469
+ answer = await probe.call(native);
470
+ } catch (cause) {
471
+ return { state: 'unavailable', cause };
472
+ }
473
+ if (answer === null || answer === undefined) {
474
+ // The native side runs only on iOS; anything else answers nothing.
475
+ keychainDefaultAccessGroup = { state: 'unsupported' };
476
+ return keychainDefaultAccessGroup;
477
+ }
478
+ if (typeof answer !== 'string' || !ACCESS_GROUP.test(answer)) {
479
+ return {
480
+ state: 'unavailable',
481
+ cause: new Error('keychain probe answered something that is not an access group'),
482
+ };
483
+ }
484
+ keychainDefaultAccessGroup = { state: 'resolved', accessGroup: answer };
485
+ return keychainDefaultAccessGroup;
486
+ }
@@ -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,16 @@ 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
+ *
127
+ * The three `*Retired*` methods (Commons protocol 4) reach a key the person,
128
+ * or the organization with `accountId`, rotated away from, by its public key,
129
+ * for SPENDING the coins still at its addresses: never for signing in. They are
130
+ * always present on the bridge and resolve `null` on a native module or a
131
+ * Commons that predates them; an answer that does not echo the key (and the
132
+ * account) asked for is refused, because the current key taken for a retired
133
+ * one is a payment signed by the wrong key.
126
134
  */
127
135
  export interface CommonsIdentityBridge {
128
136
  /**
@@ -149,4 +157,53 @@ export interface CommonsIdentityBridge {
149
157
  digest: string,
150
158
  accountId?: string,
151
159
  ): Promise<CommonsSocialReceiveSignature | null>;
160
+ /**
161
+ * The public keys (lowercase uncompressed SEC1 hex, de-duplicated) of the
162
+ * retired keys Commons holds for the person, or with `accountId` for that
163
+ * organization, minus the current one. `[]` when it holds none; `null` when
164
+ * Commons cannot say (absent, refused, or older than retired keys). Allowed
165
+ * for the wallet app only.
166
+ */
167
+ listRetiredKeys(accountId?: string): Promise<string[] | null>;
168
+ /**
169
+ * {@link deriveScopedSeed} over the key of that scope whose public key is
170
+ * `publicKey` (current or retired), never another key: `null` when Commons
171
+ * does not hold it.
172
+ */
173
+ deriveRetiredScopedSeed(
174
+ info: string,
175
+ publicKey: string,
176
+ accountId?: string,
177
+ ): Promise<string | null>;
178
+ /**
179
+ * {@link signSocialReceive} with the social-receive child `index` of the key
180
+ * of that scope whose public key is `publicKey` (current or retired), never
181
+ * another key. Allowed for the wallet app only.
182
+ */
183
+ signRetiredSocialReceive(
184
+ index: number,
185
+ digest: string,
186
+ publicKey: string,
187
+ accountId?: string,
188
+ ): Promise<CommonsSocialReceiveSignature | null>;
152
189
  }
190
+
191
+ /**
192
+ * What the iOS keychain says about this app's DEFAULT access group: the first
193
+ * entry of its `keychain-access-groups` entitlement, team prefix included
194
+ * (`ABCDE12345.so.oxy.identity`). The value is read back from a probe item the
195
+ * native module writes without a group, because JavaScript cannot see the
196
+ * entitlements or the team prefix (`$(AppIdentifierPrefix)` expands at build
197
+ * time), and `kSecAttrAccessGroup` must equal an entitlement value exactly.
198
+ *
199
+ * - `resolved`: the group string, as the keychain reported it.
200
+ * - `unsupported`: not iOS, or a native build whose `OxyIdentity` module
201
+ * predates the probe. Callers keep the single-group behaviour that build was
202
+ * made for.
203
+ * - `unavailable`: the probe threw (before first unlock, keychain refused).
204
+ * Transient; never cached.
205
+ */
206
+ export type KeychainDefaultAccessGroup =
207
+ | { state: 'resolved'; accessGroup: string }
208
+ | { state: 'unsupported' }
209
+ | { state: 'unavailable'; cause: unknown };