@oxy.so/protocol 1.8.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.
@@ -134,6 +134,8 @@ async function loadAsyncStorage() {
134
134
  var random_native_2 = require("./random.native");
135
135
  Object.defineProperty(exports, "getRandomBytesRN", { enumerable: true, get: function () { return random_native_2.getRandomBytesRN; } });
136
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}$/;
137
139
  function record(value) {
138
140
  return typeof value === 'object' && value !== null ? value : null;
139
141
  }
@@ -166,6 +168,39 @@ function narrowOrganizationDescription(value, accountId) {
166
168
  const description = narrowDescription(echoes(value, accountId));
167
169
  return description ? { ...description, accountId } : null;
168
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
+ }
169
204
  function narrowProof(value) {
170
205
  const r = record(value);
171
206
  const publicKey = hexField(r?.publicKey, 130);
@@ -233,6 +268,26 @@ function loadCommonsIdentityBridge() {
233
268
  return Promise.resolve(null);
234
269
  return ask(() => sign.call(m, index, digest, accountId), (v) => narrowSocialReceive(echoes(v, accountId)));
235
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
+ },
236
291
  };
237
292
  });
238
293
  }
@@ -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,6 +258,26 @@ 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
  }
@@ -117,6 +117,14 @@ export interface CommonsSocialReceiveSignature {
117
117
  *
118
118
  * On iOS these methods resolve `null`: the keychain access groups in
119
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,6 +147,26 @@ 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
  }
143
171
  /**
144
172
  * What the iOS keychain says about this app's DEFAULT access group: the first
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxy.so/protocol",
3
- "version": "1.8.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();
@@ -225,9 +225,29 @@ interface OxyIdentityNativeModule {
225
225
  * Older native modules and Android lack it.
226
226
  */
227
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>;
228
246
  }
229
247
 
230
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}$/;
231
251
 
232
252
  function record(value: unknown): Record<string, unknown> | null {
233
253
  return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : null;
@@ -266,6 +286,40 @@ function narrowOrganizationDescription(
266
286
  return description ? { ...description, accountId } : null;
267
287
  }
268
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
+
269
323
  function narrowProof(value: unknown): CommonsIdentityProof | null {
270
324
  const r = record(value);
271
325
  const publicKey = hexField(r?.publicKey, 130);
@@ -347,6 +401,39 @@ export function loadCommonsIdentityBridge(): Promise<CommonsIdentityBridge | nul
347
401
  (v) => narrowSocialReceive(echoes(v, accountId)),
348
402
  );
349
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
+ },
350
437
  } satisfies CommonsIdentityBridge;
351
438
  });
352
439
  }
@@ -123,6 +123,14 @@ export interface CommonsSocialReceiveSignature {
123
123
  *
124
124
  * On iOS these methods resolve `null`: the keychain access groups in
125
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,6 +157,35 @@ 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
  }
153
190
 
154
191
  /**