@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.
- package/dist/cjs/platform/crypto.native.js +55 -0
- package/dist/esm/platform/crypto.native.js +55 -0
- package/dist/types/platform/expoTypes.d.ts +28 -0
- package/package.json +2 -2
- package/src/__tests__/commonsIdentityBridge.test.ts +169 -0
- package/src/platform/crypto.native.ts +87 -0
- package/src/platform/expoTypes.ts +37 -0
|
@@ -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.
|
|
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.
|
|
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
|
/**
|