@oxy.so/protocol 1.8.0 → 1.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/index.js +2 -1
- package/dist/cjs/platform/crypto.js +4 -0
- package/dist/cjs/platform/crypto.native.js +103 -0
- package/dist/esm/index.js +1 -1
- package/dist/esm/platform/crypto.js +3 -0
- package/dist/esm/platform/crypto.native.js +102 -0
- package/dist/types/index.d.ts +2 -2
- package/dist/types/platform/crypto.d.ts +3 -2
- package/dist/types/platform/crypto.native.d.ts +3 -2
- package/dist/types/platform/expoTypes.d.ts +56 -0
- package/package.json +2 -2
- package/src/__tests__/commonsIdentityBridge.test.ts +169 -0
- package/src/__tests__/keychainDefaultAccessGroup.test.ts +65 -0
- package/src/index.ts +2 -0
- package/src/platform/crypto.native.ts +148 -0
- package/src/platform/crypto.ts +8 -0
- package/src/platform/expoTypes.ts +61 -0
package/dist/cjs/index.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* subpath so they never enter React Native / web bundles.
|
|
12
12
|
*/
|
|
13
13
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
14
|
-
exports.loadKeychainDefaultAccessGroup = exports.loadCommonsIdentityBridge = exports.getRandomBytesRN = exports.loadAsyncStorage = exports.loadSecureStore = exports.loadExpoCrypto = exports.loadNodeCrypto = exports.isNodeJS = exports.isReactNative = exports.isAuthorizedKey = exports.REGISTRATION_POW_DIFFICULTY_BITS = exports.meetsRegistrationPowDifficulty = exports.countLeadingZeroBits = exports.registrationPowMessage = exports.verifyCheckpointSignature = exports.signCheckpoint = exports.checkpointHash = exports.checkpointSigningInput = exports.verifyInclusionProof = exports.inclusionProof = exports.buildTransparencyTreeFromHeads = exports.buildTransparencyTree = exports.transparencyLeafHash = exports.EMPTY_TRANSPARENCY_ROOT = exports.verifyAndAppend = exports.DEFAULT_CLOCK_SKEW_MS = exports.verifyEnvelope = exports.checkContinuity = exports.UNCHAINED_SEQ = exports.verifyEnvelopeSignature = exports.signEnvelope = exports.verifySignature = exports.signMessage = exports.computeRecordId = exports.sha256 = exports.signedRecordSigningInput = exports.canonicalize = void 0;
|
|
14
|
+
exports.loadKeychainApplicationAccessGroup = exports.loadKeychainDefaultAccessGroup = exports.loadCommonsIdentityBridge = exports.getRandomBytesRN = exports.loadAsyncStorage = exports.loadSecureStore = exports.loadExpoCrypto = exports.loadNodeCrypto = exports.isNodeJS = exports.isReactNative = exports.isAuthorizedKey = exports.REGISTRATION_POW_DIFFICULTY_BITS = exports.meetsRegistrationPowDifficulty = exports.countLeadingZeroBits = exports.registrationPowMessage = exports.verifyCheckpointSignature = exports.signCheckpoint = exports.checkpointHash = exports.checkpointSigningInput = exports.verifyInclusionProof = exports.inclusionProof = exports.buildTransparencyTreeFromHeads = exports.buildTransparencyTree = exports.transparencyLeafHash = exports.EMPTY_TRANSPARENCY_ROOT = exports.verifyAndAppend = exports.DEFAULT_CLOCK_SKEW_MS = exports.verifyEnvelope = exports.checkContinuity = exports.UNCHAINED_SEQ = exports.verifyEnvelopeSignature = exports.signEnvelope = exports.verifySignature = exports.signMessage = exports.computeRecordId = exports.sha256 = exports.signedRecordSigningInput = exports.canonicalize = void 0;
|
|
15
15
|
// ---------------------------------------------------------------------------
|
|
16
16
|
// Envelope — canonical JSON, signing input, content address, signing/verify
|
|
17
17
|
// ---------------------------------------------------------------------------
|
|
@@ -78,3 +78,4 @@ Object.defineProperty(exports, "loadAsyncStorage", { enumerable: true, get: func
|
|
|
78
78
|
Object.defineProperty(exports, "getRandomBytesRN", { enumerable: true, get: function () { return crypto_1.getRandomBytesRN; } });
|
|
79
79
|
Object.defineProperty(exports, "loadCommonsIdentityBridge", { enumerable: true, get: function () { return crypto_1.loadCommonsIdentityBridge; } });
|
|
80
80
|
Object.defineProperty(exports, "loadKeychainDefaultAccessGroup", { enumerable: true, get: function () { return crypto_1.loadKeychainDefaultAccessGroup; } });
|
|
81
|
+
Object.defineProperty(exports, "loadKeychainApplicationAccessGroup", { enumerable: true, get: function () { return crypto_1.loadKeychainApplicationAccessGroup; } });
|
|
@@ -113,6 +113,7 @@ exports.loadSecureStore = loadSecureStore;
|
|
|
113
113
|
exports.loadAsyncStorage = loadAsyncStorage;
|
|
114
114
|
exports.loadCommonsIdentityBridge = loadCommonsIdentityBridge;
|
|
115
115
|
exports.loadKeychainDefaultAccessGroup = loadKeychainDefaultAccessGroup;
|
|
116
|
+
exports.loadKeychainApplicationAccessGroup = loadKeychainApplicationAccessGroup;
|
|
116
117
|
const platform_1 = require("./platform");
|
|
117
118
|
// ---------------------------------------------------------------------------
|
|
118
119
|
// Node `crypto` — Node built-in
|
|
@@ -184,3 +185,6 @@ function loadCommonsIdentityBridge() {
|
|
|
184
185
|
function loadKeychainDefaultAccessGroup() {
|
|
185
186
|
return Promise.resolve({ state: 'unsupported' });
|
|
186
187
|
}
|
|
188
|
+
function loadKeychainApplicationAccessGroup(_prefix) {
|
|
189
|
+
return Promise.resolve({ state: 'unsupported' });
|
|
190
|
+
}
|
|
@@ -60,6 +60,7 @@ exports.loadSecureStore = loadSecureStore;
|
|
|
60
60
|
exports.loadAsyncStorage = loadAsyncStorage;
|
|
61
61
|
exports.loadCommonsIdentityBridge = loadCommonsIdentityBridge;
|
|
62
62
|
exports.loadKeychainDefaultAccessGroup = loadKeychainDefaultAccessGroup;
|
|
63
|
+
exports.loadKeychainApplicationAccessGroup = loadKeychainApplicationAccessGroup;
|
|
63
64
|
const expo_modules_core_1 = require("expo-modules-core");
|
|
64
65
|
const optionalPeer_1 = require("./optionalPeer");
|
|
65
66
|
const random_native_1 = require("./random.native");
|
|
@@ -134,6 +135,8 @@ async function loadAsyncStorage() {
|
|
|
134
135
|
var random_native_2 = require("./random.native");
|
|
135
136
|
Object.defineProperty(exports, "getRandomBytesRN", { enumerable: true, get: function () { return random_native_2.getRandomBytesRN; } });
|
|
136
137
|
const LOWER_HEX = /^[0-9a-f]+$/;
|
|
138
|
+
/** A lowercase uncompressed SEC1 public key, as Commons names every key it holds. */
|
|
139
|
+
const PUBLIC_KEY = /^04[0-9a-f]{128}$/;
|
|
137
140
|
function record(value) {
|
|
138
141
|
return typeof value === 'object' && value !== null ? value : null;
|
|
139
142
|
}
|
|
@@ -166,6 +169,39 @@ function narrowOrganizationDescription(value, accountId) {
|
|
|
166
169
|
const description = narrowDescription(echoes(value, accountId));
|
|
167
170
|
return description ? { ...description, accountId } : null;
|
|
168
171
|
}
|
|
172
|
+
/**
|
|
173
|
+
* A retired-key answer must name the key asked for, and the scope asked for:
|
|
174
|
+
* the organization's `accountId`, or none for the person. A Commons before
|
|
175
|
+
* protocol 4 ignores `keyPublicKey` and answers with the CURRENT key, without
|
|
176
|
+
* the echo; that answer is refused here.
|
|
177
|
+
*/
|
|
178
|
+
function echoesRetired(value, publicKey, accountId) {
|
|
179
|
+
const r = record(value);
|
|
180
|
+
if (!r || r.keyPublicKey !== publicKey)
|
|
181
|
+
return null;
|
|
182
|
+
return (r.accountId ?? undefined) === accountId ? r : null;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* The list must be every entry a well-formed public key: one malformed entry
|
|
186
|
+
* means an answer this side does not understand, and the whole answer is
|
|
187
|
+
* `null` rather than a list missing a key the wallet would then never sweep.
|
|
188
|
+
*/
|
|
189
|
+
function narrowRetiredKeys(value, accountId) {
|
|
190
|
+
const r = record(value);
|
|
191
|
+
if (!r || (r.accountId ?? undefined) !== accountId)
|
|
192
|
+
return null;
|
|
193
|
+
const keys = r.publicKeys;
|
|
194
|
+
if (!Array.isArray(keys))
|
|
195
|
+
return null;
|
|
196
|
+
const out = [];
|
|
197
|
+
for (const key of keys) {
|
|
198
|
+
if (typeof key !== 'string' || !PUBLIC_KEY.test(key))
|
|
199
|
+
return null;
|
|
200
|
+
if (!out.includes(key))
|
|
201
|
+
out.push(key);
|
|
202
|
+
}
|
|
203
|
+
return out;
|
|
204
|
+
}
|
|
169
205
|
function narrowProof(value) {
|
|
170
206
|
const r = record(value);
|
|
171
207
|
const publicKey = hexField(r?.publicKey, 130);
|
|
@@ -233,6 +269,26 @@ function loadCommonsIdentityBridge() {
|
|
|
233
269
|
return Promise.resolve(null);
|
|
234
270
|
return ask(() => sign.call(m, index, digest, accountId), (v) => narrowSocialReceive(echoes(v, accountId)));
|
|
235
271
|
},
|
|
272
|
+
listRetiredKeys: (accountId) => {
|
|
273
|
+
const list = m.listRetiredKeys;
|
|
274
|
+
if (typeof list !== 'function')
|
|
275
|
+
return Promise.resolve(null);
|
|
276
|
+
return ask(() => list.call(m, accountId ?? null), (v) => narrowRetiredKeys(v, accountId));
|
|
277
|
+
},
|
|
278
|
+
deriveRetiredScopedSeed: (info, publicKey, accountId) => {
|
|
279
|
+
const derive = m.deriveRetiredScopedSeed;
|
|
280
|
+
if (typeof derive !== 'function' || !PUBLIC_KEY.test(publicKey)) {
|
|
281
|
+
return Promise.resolve(null);
|
|
282
|
+
}
|
|
283
|
+
return ask(() => derive.call(m, info, publicKey, accountId ?? null), (v) => hexField(echoesRetired(v, publicKey, accountId)?.seed, 64));
|
|
284
|
+
},
|
|
285
|
+
signRetiredSocialReceive: (index, digest, publicKey, accountId) => {
|
|
286
|
+
const sign = m.signRetiredSocialReceive;
|
|
287
|
+
if (typeof sign !== 'function' || !PUBLIC_KEY.test(publicKey)) {
|
|
288
|
+
return Promise.resolve(null);
|
|
289
|
+
}
|
|
290
|
+
return ask(() => sign.call(m, index, digest, publicKey, accountId ?? null), (v) => narrowSocialReceive(echoesRetired(v, publicKey, accountId)));
|
|
291
|
+
},
|
|
236
292
|
};
|
|
237
293
|
});
|
|
238
294
|
}
|
|
@@ -280,3 +336,50 @@ async function loadKeychainDefaultAccessGroup() {
|
|
|
280
336
|
keychainDefaultAccessGroup = { state: 'resolved', accessGroup: answer };
|
|
281
337
|
return keychainDefaultAccessGroup;
|
|
282
338
|
}
|
|
339
|
+
// ---------------------------------------------------------------------------
|
|
340
|
+
// iOS keychain application access group (ADR 0062 D6): `<prefix><bundle id>`,
|
|
341
|
+
// the group every app is a member of and no other app can name — where an app
|
|
342
|
+
// keeps the items that are its alone. Same caching as the default group: a
|
|
343
|
+
// resolved or unsupported answer is constant for the binary; a thrown probe is
|
|
344
|
+
// asked again.
|
|
345
|
+
// ---------------------------------------------------------------------------
|
|
346
|
+
/** A team prefix as the default group carries it: no dots, then one dot. */
|
|
347
|
+
const KEYCHAIN_PREFIX = /^[A-Za-z0-9]+\.$/;
|
|
348
|
+
let keychainApplicationAccessGroup = null;
|
|
349
|
+
async function loadKeychainApplicationAccessGroup(prefix) {
|
|
350
|
+
if (keychainApplicationAccessGroup) {
|
|
351
|
+
return keychainApplicationAccessGroup;
|
|
352
|
+
}
|
|
353
|
+
if (!KEYCHAIN_PREFIX.test(prefix)) {
|
|
354
|
+
return {
|
|
355
|
+
state: 'unavailable',
|
|
356
|
+
cause: new Error('keychain application group probe needs a team prefix'),
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
const native = (0, expo_modules_core_1.requireOptionalNativeModule)('OxyIdentity');
|
|
360
|
+
const probe = native?.keychainApplicationAccessGroup;
|
|
361
|
+
if (!native || typeof probe !== 'function') {
|
|
362
|
+
keychainApplicationAccessGroup = { state: 'unsupported' };
|
|
363
|
+
return keychainApplicationAccessGroup;
|
|
364
|
+
}
|
|
365
|
+
let answer;
|
|
366
|
+
try {
|
|
367
|
+
answer = await probe.call(native, prefix);
|
|
368
|
+
}
|
|
369
|
+
catch (cause) {
|
|
370
|
+
return { state: 'unavailable', cause };
|
|
371
|
+
}
|
|
372
|
+
if (answer === null || answer === undefined) {
|
|
373
|
+
// Not entitled (or not iOS): this binary keeps the placement it was built for.
|
|
374
|
+
keychainApplicationAccessGroup = { state: 'unsupported' };
|
|
375
|
+
return keychainApplicationAccessGroup;
|
|
376
|
+
}
|
|
377
|
+
if (typeof answer !== 'string' || !answer.startsWith(prefix) || !ACCESS_GROUP.test(answer)) {
|
|
378
|
+
// A native bug, not a locked keychain: asking again would answer the same,
|
|
379
|
+
// and `unavailable` would leave the app without storage. Keep the
|
|
380
|
+
// placement this build had before the probe existed.
|
|
381
|
+
return { state: 'unsupported' };
|
|
382
|
+
}
|
|
383
|
+
keychainApplicationAccessGroup = { state: 'resolved', accessGroup: answer };
|
|
384
|
+
return keychainApplicationAccessGroup;
|
|
385
|
+
}
|
package/dist/esm/index.js
CHANGED
|
@@ -37,4 +37,4 @@ export { isAuthorizedKey } from './identity/resolver.js';
|
|
|
37
37
|
// Platform — runtime predicates + lazy crypto/storage loaders
|
|
38
38
|
// ---------------------------------------------------------------------------
|
|
39
39
|
export { isReactNative, isNodeJS } from './platform/platform.js';
|
|
40
|
-
export { loadNodeCrypto, loadExpoCrypto, loadSecureStore, loadAsyncStorage, getRandomBytesRN, loadCommonsIdentityBridge, loadKeychainDefaultAccessGroup, } from './platform/crypto.js';
|
|
40
|
+
export { loadNodeCrypto, loadExpoCrypto, loadSecureStore, loadAsyncStorage, getRandomBytesRN, loadCommonsIdentityBridge, loadKeychainDefaultAccessGroup, loadKeychainApplicationAccessGroup, } from './platform/crypto.js';
|
|
@@ -141,3 +141,6 @@ export function loadCommonsIdentityBridge() {
|
|
|
141
141
|
export function loadKeychainDefaultAccessGroup() {
|
|
142
142
|
return Promise.resolve({ state: 'unsupported' });
|
|
143
143
|
}
|
|
144
|
+
export function loadKeychainApplicationAccessGroup(_prefix) {
|
|
145
|
+
return Promise.resolve({ state: 'unsupported' });
|
|
146
|
+
}
|
|
@@ -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
|
}
|
|
@@ -270,3 +325,50 @@ export async function loadKeychainDefaultAccessGroup() {
|
|
|
270
325
|
keychainDefaultAccessGroup = { state: 'resolved', accessGroup: answer };
|
|
271
326
|
return keychainDefaultAccessGroup;
|
|
272
327
|
}
|
|
328
|
+
// ---------------------------------------------------------------------------
|
|
329
|
+
// iOS keychain application access group (ADR 0062 D6): `<prefix><bundle id>`,
|
|
330
|
+
// the group every app is a member of and no other app can name — where an app
|
|
331
|
+
// keeps the items that are its alone. Same caching as the default group: a
|
|
332
|
+
// resolved or unsupported answer is constant for the binary; a thrown probe is
|
|
333
|
+
// asked again.
|
|
334
|
+
// ---------------------------------------------------------------------------
|
|
335
|
+
/** A team prefix as the default group carries it: no dots, then one dot. */
|
|
336
|
+
const KEYCHAIN_PREFIX = /^[A-Za-z0-9]+\.$/;
|
|
337
|
+
let keychainApplicationAccessGroup = null;
|
|
338
|
+
export async function loadKeychainApplicationAccessGroup(prefix) {
|
|
339
|
+
if (keychainApplicationAccessGroup) {
|
|
340
|
+
return keychainApplicationAccessGroup;
|
|
341
|
+
}
|
|
342
|
+
if (!KEYCHAIN_PREFIX.test(prefix)) {
|
|
343
|
+
return {
|
|
344
|
+
state: 'unavailable',
|
|
345
|
+
cause: new Error('keychain application group probe needs a team prefix'),
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
const native = requireOptionalNativeModule('OxyIdentity');
|
|
349
|
+
const probe = native?.keychainApplicationAccessGroup;
|
|
350
|
+
if (!native || typeof probe !== 'function') {
|
|
351
|
+
keychainApplicationAccessGroup = { state: 'unsupported' };
|
|
352
|
+
return keychainApplicationAccessGroup;
|
|
353
|
+
}
|
|
354
|
+
let answer;
|
|
355
|
+
try {
|
|
356
|
+
answer = await probe.call(native, prefix);
|
|
357
|
+
}
|
|
358
|
+
catch (cause) {
|
|
359
|
+
return { state: 'unavailable', cause };
|
|
360
|
+
}
|
|
361
|
+
if (answer === null || answer === undefined) {
|
|
362
|
+
// Not entitled (or not iOS): this binary keeps the placement it was built for.
|
|
363
|
+
keychainApplicationAccessGroup = { state: 'unsupported' };
|
|
364
|
+
return keychainApplicationAccessGroup;
|
|
365
|
+
}
|
|
366
|
+
if (typeof answer !== 'string' || !answer.startsWith(prefix) || !ACCESS_GROUP.test(answer)) {
|
|
367
|
+
// A native bug, not a locked keychain: asking again would answer the same,
|
|
368
|
+
// and `unavailable` would leave the app without storage. Keep the
|
|
369
|
+
// placement this build had before the probe existed.
|
|
370
|
+
return { state: 'unsupported' };
|
|
371
|
+
}
|
|
372
|
+
keychainApplicationAccessGroup = { state: 'resolved', accessGroup: answer };
|
|
373
|
+
return keychainApplicationAccessGroup;
|
|
374
|
+
}
|
package/dist/types/index.d.ts
CHANGED
|
@@ -29,5 +29,5 @@ export { registrationPowMessage, countLeadingZeroBits, meetsRegistrationPowDiffi
|
|
|
29
29
|
export { isAuthorizedKey } from './identity/resolver';
|
|
30
30
|
export type { VerificationMethodResolver, ResolvedVerificationMethods, KeyAuthorization, } from './identity/resolver';
|
|
31
31
|
export { isReactNative, isNodeJS } from './platform/platform';
|
|
32
|
-
export { loadNodeCrypto, loadExpoCrypto, loadSecureStore, loadAsyncStorage, getRandomBytesRN, loadCommonsIdentityBridge, loadKeychainDefaultAccessGroup, } from './platform/crypto';
|
|
33
|
-
export type { CommonsIdentityBridge, CommonsIdentityDescription, CommonsIdentityProof, CommonsSocialReceiveSignature, ExpoCryptoLike, ExpoSecureStoreLike, KeychainDefaultAccessGroup, } from './platform/expoTypes';
|
|
32
|
+
export { loadNodeCrypto, loadExpoCrypto, loadSecureStore, loadAsyncStorage, getRandomBytesRN, loadCommonsIdentityBridge, loadKeychainDefaultAccessGroup, loadKeychainApplicationAccessGroup, } from './platform/crypto';
|
|
33
|
+
export type { CommonsIdentityBridge, CommonsIdentityDescription, CommonsIdentityProof, CommonsSocialReceiveSignature, ExpoCryptoLike, ExpoSecureStoreLike, KeychainApplicationAccessGroup, KeychainDefaultAccessGroup, } from './platform/expoTypes';
|
|
@@ -71,8 +71,8 @@
|
|
|
71
71
|
* see a file containing only specifiers it can understand — no tricks, no
|
|
72
72
|
* runtime parsing risks.
|
|
73
73
|
*/
|
|
74
|
-
import type { CommonsIdentityBridge, CommonsIdentityDescription, CommonsIdentityProof, CommonsSocialReceiveSignature, ExpoCryptoLike, ExpoSecureStoreLike, KeychainDefaultAccessGroup } from './expoTypes';
|
|
75
|
-
export type { CommonsIdentityBridge, CommonsIdentityDescription, CommonsIdentityProof, CommonsSocialReceiveSignature, ExpoCryptoLike, ExpoSecureStoreLike, KeychainDefaultAccessGroup, };
|
|
74
|
+
import type { CommonsIdentityBridge, CommonsIdentityDescription, CommonsIdentityProof, CommonsSocialReceiveSignature, ExpoCryptoLike, ExpoSecureStoreLike, KeychainApplicationAccessGroup, KeychainDefaultAccessGroup } from './expoTypes';
|
|
75
|
+
export type { CommonsIdentityBridge, CommonsIdentityDescription, CommonsIdentityProof, CommonsSocialReceiveSignature, ExpoCryptoLike, ExpoSecureStoreLike, KeychainApplicationAccessGroup, KeychainDefaultAccessGroup, };
|
|
76
76
|
export declare function loadNodeCrypto(): Promise<typeof import('crypto')>;
|
|
77
77
|
export declare function loadExpoCrypto(): Promise<ExpoCryptoLike>;
|
|
78
78
|
export declare function loadSecureStore(): Promise<ExpoSecureStoreLike>;
|
|
@@ -86,3 +86,4 @@ export declare function loadAsyncStorage(): Promise<{
|
|
|
86
86
|
export { getRandomBytesRN } from './random';
|
|
87
87
|
export declare function loadCommonsIdentityBridge(): Promise<CommonsIdentityBridge | null>;
|
|
88
88
|
export declare function loadKeychainDefaultAccessGroup(): Promise<KeychainDefaultAccessGroup>;
|
|
89
|
+
export declare function loadKeychainApplicationAccessGroup(_prefix: string): Promise<KeychainApplicationAccessGroup>;
|
|
@@ -51,8 +51,8 @@
|
|
|
51
51
|
* `expo-modules-core` is a NON-optional peer (every RN app has it via `expo`),
|
|
52
52
|
* so it stays a plain static import.
|
|
53
53
|
*/
|
|
54
|
-
import type { CommonsIdentityBridge, CommonsIdentityDescription, CommonsIdentityProof, CommonsSocialReceiveSignature, ExpoCryptoLike, ExpoSecureStoreLike, KeychainDefaultAccessGroup } from './expoTypes';
|
|
55
|
-
export type { CommonsIdentityBridge, CommonsIdentityDescription, CommonsIdentityProof, CommonsSocialReceiveSignature, ExpoCryptoLike, ExpoSecureStoreLike, KeychainDefaultAccessGroup, };
|
|
54
|
+
import type { CommonsIdentityBridge, CommonsIdentityDescription, CommonsIdentityProof, CommonsSocialReceiveSignature, ExpoCryptoLike, ExpoSecureStoreLike, KeychainApplicationAccessGroup, KeychainDefaultAccessGroup } from './expoTypes';
|
|
55
|
+
export type { CommonsIdentityBridge, CommonsIdentityDescription, CommonsIdentityProof, CommonsSocialReceiveSignature, ExpoCryptoLike, ExpoSecureStoreLike, KeychainApplicationAccessGroup, KeychainDefaultAccessGroup, };
|
|
56
56
|
/** Persistent KV storage surface used from `@react-native-async-storage/async-storage`. */
|
|
57
57
|
type AsyncStorageLike = {
|
|
58
58
|
getItem: (key: string) => Promise<string | null>;
|
|
@@ -68,3 +68,4 @@ export declare function loadAsyncStorage(): Promise<{
|
|
|
68
68
|
export { getRandomBytesRN } from './random.native';
|
|
69
69
|
export declare function loadCommonsIdentityBridge(): Promise<CommonsIdentityBridge | null>;
|
|
70
70
|
export declare function loadKeychainDefaultAccessGroup(): Promise<KeychainDefaultAccessGroup>;
|
|
71
|
+
export declare function loadKeychainApplicationAccessGroup(prefix: string): Promise<KeychainApplicationAccessGroup>;
|
|
@@ -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
|
|
@@ -164,3 +192,31 @@ export type KeychainDefaultAccessGroup = {
|
|
|
164
192
|
state: 'unavailable';
|
|
165
193
|
cause: unknown;
|
|
166
194
|
};
|
|
195
|
+
/**
|
|
196
|
+
* What the iOS keychain says about this app's OWN access group: its
|
|
197
|
+
* application identifier, `<prefix><bundle id>` (`ABCDE12345.so.oxy.mention`).
|
|
198
|
+
* Apple makes every app a member of that group whatever its
|
|
199
|
+
* `keychain-access-groups` lists, and no other app can name it, so it is where
|
|
200
|
+
* an app keeps the items that are its alone (its session credential): the
|
|
201
|
+
* keychain's counterpart of Android's per-app storage (ADR 0062 D6).
|
|
202
|
+
*
|
|
203
|
+
* The native module writes a probe item into that group EXPLICITLY and answers
|
|
204
|
+
* the group only when the keychain took it.
|
|
205
|
+
*
|
|
206
|
+
* - `resolved`: the group string.
|
|
207
|
+
* - `unsupported`: not iOS, a native build whose `OxyIdentity` module predates
|
|
208
|
+
* this probe, a keychain that refused the group as not entitled, or an answer
|
|
209
|
+
* that is not this team's group. Constant for the binary: callers keep the
|
|
210
|
+
* placement that build was made for.
|
|
211
|
+
* - `unavailable`: the probe threw (before first unlock). Transient; never
|
|
212
|
+
* cached.
|
|
213
|
+
*/
|
|
214
|
+
export type KeychainApplicationAccessGroup = {
|
|
215
|
+
state: 'resolved';
|
|
216
|
+
accessGroup: string;
|
|
217
|
+
} | {
|
|
218
|
+
state: 'unsupported';
|
|
219
|
+
} | {
|
|
220
|
+
state: 'unavailable';
|
|
221
|
+
cause: unknown;
|
|
222
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oxy.so/protocol",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.10.0",
|
|
4
4
|
"description": "Oxy Protocol — the app-agnostic base substrate: signed-record envelope, canonical JSON, signature/verification, and platform crypto. Reused by any Oxy app to decentralize its own content.",
|
|
5
5
|
"main": "dist/cjs/index.js",
|
|
6
6
|
"module": "dist/esm/index.js",
|
|
@@ -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();
|
|
@@ -63,3 +63,68 @@ describe('loadKeychainDefaultAccessGroup (native)', () => {
|
|
|
63
63
|
await expect(mod.loadKeychainDefaultAccessGroup()).resolves.toEqual({ state: 'unsupported' });
|
|
64
64
|
});
|
|
65
65
|
});
|
|
66
|
+
|
|
67
|
+
describe('loadKeychainApplicationAccessGroup (native, ADR 0062 D6)', () => {
|
|
68
|
+
beforeEach(() => {
|
|
69
|
+
jest.resetModules();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const load = async (prefix = 'ABCDE12345.') =>
|
|
73
|
+
(await import('../platform/crypto.native')).loadKeychainApplicationAccessGroup(prefix);
|
|
74
|
+
|
|
75
|
+
test('a native module without the probe, or a refused group, is unsupported', async () => {
|
|
76
|
+
mockNative({ keychainDefaultAccessGroup: async () => 'ABCDE12345.group.so.oxy.shared' });
|
|
77
|
+
await expect(load()).resolves.toEqual({ state: 'unsupported' });
|
|
78
|
+
jest.resetModules();
|
|
79
|
+
mockNative({ keychainApplicationAccessGroup: async () => null });
|
|
80
|
+
await expect(load()).resolves.toEqual({ state: 'unsupported' });
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('passes this team’s group through with the prefix, and remembers it', async () => {
|
|
84
|
+
const keychainApplicationAccessGroup = jest.fn(async () => 'ABCDE12345.so.oxy.mention');
|
|
85
|
+
mockNative({ keychainApplicationAccessGroup });
|
|
86
|
+
const mod = await import('../platform/crypto.native');
|
|
87
|
+
await expect(mod.loadKeychainApplicationAccessGroup('ABCDE12345.')).resolves.toEqual({
|
|
88
|
+
state: 'resolved',
|
|
89
|
+
accessGroup: 'ABCDE12345.so.oxy.mention',
|
|
90
|
+
});
|
|
91
|
+
await mod.loadKeychainApplicationAccessGroup('ABCDE12345.');
|
|
92
|
+
expect(keychainApplicationAccessGroup).toHaveBeenCalledTimes(1);
|
|
93
|
+
expect(keychainApplicationAccessGroup).toHaveBeenCalledWith('ABCDE12345.');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('a throw is unavailable and asked again; a bad prefix is never sent', async () => {
|
|
97
|
+
let locked = true;
|
|
98
|
+
const keychainApplicationAccessGroup = jest.fn(async () => {
|
|
99
|
+
if (locked) throw new Error('errSecInteractionNotAllowed');
|
|
100
|
+
return 'ABCDE12345.so.oxy.mention';
|
|
101
|
+
});
|
|
102
|
+
mockNative({ keychainApplicationAccessGroup });
|
|
103
|
+
const mod = await import('../platform/crypto.native');
|
|
104
|
+
expect((await mod.loadKeychainApplicationAccessGroup('ABCDE12345.')).state).toBe('unavailable');
|
|
105
|
+
locked = false;
|
|
106
|
+
expect((await mod.loadKeychainApplicationAccessGroup('ABCDE12345.')).state).toBe('resolved');
|
|
107
|
+
jest.resetModules();
|
|
108
|
+
mockNative({ keychainApplicationAccessGroup });
|
|
109
|
+
keychainApplicationAccessGroup.mockClear();
|
|
110
|
+
for (const prefix of ['', 'ABCDE12345', 'A.B.']) {
|
|
111
|
+
expect((await load(prefix)).state).toBe('unavailable');
|
|
112
|
+
}
|
|
113
|
+
expect(keychainApplicationAccessGroup).not.toHaveBeenCalled();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('another team’s group or junk is unsupported: the earlier placement, never no storage', async () => {
|
|
117
|
+
for (const junk of [42, 'XYZ.so.oxy.mention', 'ABCDE12345.', { group: 'x' }]) {
|
|
118
|
+
jest.resetModules();
|
|
119
|
+
mockNative({ keychainApplicationAccessGroup: async () => junk });
|
|
120
|
+
expect((await load()).state).toBe('unsupported');
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('the web / Node variant is unsupported', async () => {
|
|
125
|
+
const mod = await import('../platform/crypto');
|
|
126
|
+
await expect(mod.loadKeychainApplicationAccessGroup('ABCDE12345.')).resolves.toEqual({
|
|
127
|
+
state: 'unsupported',
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -100,6 +100,7 @@ export {
|
|
|
100
100
|
getRandomBytesRN,
|
|
101
101
|
loadCommonsIdentityBridge,
|
|
102
102
|
loadKeychainDefaultAccessGroup,
|
|
103
|
+
loadKeychainApplicationAccessGroup,
|
|
103
104
|
} from './platform/crypto';
|
|
104
105
|
|
|
105
106
|
// ---------------------------------------------------------------------------
|
|
@@ -116,5 +117,6 @@ export type {
|
|
|
116
117
|
CommonsSocialReceiveSignature,
|
|
117
118
|
ExpoCryptoLike,
|
|
118
119
|
ExpoSecureStoreLike,
|
|
120
|
+
KeychainApplicationAccessGroup,
|
|
119
121
|
KeychainDefaultAccessGroup,
|
|
120
122
|
} from './platform/expoTypes';
|
|
@@ -60,6 +60,7 @@ import type {
|
|
|
60
60
|
CommonsSocialReceiveSignature,
|
|
61
61
|
ExpoCryptoLike,
|
|
62
62
|
ExpoSecureStoreLike,
|
|
63
|
+
KeychainApplicationAccessGroup,
|
|
63
64
|
KeychainDefaultAccessGroup,
|
|
64
65
|
} from './expoTypes';
|
|
65
66
|
import { missingOptionalPeerError } from './optionalPeer';
|
|
@@ -74,6 +75,7 @@ export type {
|
|
|
74
75
|
CommonsSocialReceiveSignature,
|
|
75
76
|
ExpoCryptoLike,
|
|
76
77
|
ExpoSecureStoreLike,
|
|
78
|
+
KeychainApplicationAccessGroup,
|
|
77
79
|
KeychainDefaultAccessGroup,
|
|
78
80
|
};
|
|
79
81
|
|
|
@@ -225,9 +227,36 @@ interface OxyIdentityNativeModule {
|
|
|
225
227
|
* Older native modules and Android lack it.
|
|
226
228
|
*/
|
|
227
229
|
keychainDefaultAccessGroup?(): Promise<unknown>;
|
|
230
|
+
/**
|
|
231
|
+
* iOS only, added after the default-group probe (ADR 0062 D6): writes a
|
|
232
|
+
* probe item into `<prefix><bundle id>` explicitly and answers that group,
|
|
233
|
+
* or `null` when the keychain refused it as not entitled. Older native
|
|
234
|
+
* modules and Android lack it.
|
|
235
|
+
*/
|
|
236
|
+
keychainApplicationAccessGroup?(prefix: string): Promise<unknown>;
|
|
237
|
+
/**
|
|
238
|
+
* Retired keys (Commons protocol 4), added after the organization methods and
|
|
239
|
+
* just as absent on an older native module. `accountId` is `null` for the
|
|
240
|
+
* person. Each answer echoes the `keyPublicKey` (and the `accountId`) Commons
|
|
241
|
+
* answered for.
|
|
242
|
+
*/
|
|
243
|
+
listRetiredKeys?(accountId: string | null): Promise<unknown>;
|
|
244
|
+
deriveRetiredScopedSeed?(
|
|
245
|
+
info: string,
|
|
246
|
+
publicKey: string,
|
|
247
|
+
accountId: string | null,
|
|
248
|
+
): Promise<unknown>;
|
|
249
|
+
signRetiredSocialReceive?(
|
|
250
|
+
index: number,
|
|
251
|
+
digest: string,
|
|
252
|
+
publicKey: string,
|
|
253
|
+
accountId: string | null,
|
|
254
|
+
): Promise<unknown>;
|
|
228
255
|
}
|
|
229
256
|
|
|
230
257
|
const LOWER_HEX = /^[0-9a-f]+$/;
|
|
258
|
+
/** A lowercase uncompressed SEC1 public key, as Commons names every key it holds. */
|
|
259
|
+
const PUBLIC_KEY = /^04[0-9a-f]{128}$/;
|
|
231
260
|
|
|
232
261
|
function record(value: unknown): Record<string, unknown> | null {
|
|
233
262
|
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : null;
|
|
@@ -266,6 +295,40 @@ function narrowOrganizationDescription(
|
|
|
266
295
|
return description ? { ...description, accountId } : null;
|
|
267
296
|
}
|
|
268
297
|
|
|
298
|
+
/**
|
|
299
|
+
* A retired-key answer must name the key asked for, and the scope asked for:
|
|
300
|
+
* the organization's `accountId`, or none for the person. A Commons before
|
|
301
|
+
* protocol 4 ignores `keyPublicKey` and answers with the CURRENT key, without
|
|
302
|
+
* the echo; that answer is refused here.
|
|
303
|
+
*/
|
|
304
|
+
function echoesRetired(
|
|
305
|
+
value: unknown,
|
|
306
|
+
publicKey: string,
|
|
307
|
+
accountId: string | undefined,
|
|
308
|
+
): Record<string, unknown> | null {
|
|
309
|
+
const r = record(value);
|
|
310
|
+
if (!r || r.keyPublicKey !== publicKey) return null;
|
|
311
|
+
return (r.accountId ?? undefined) === accountId ? r : null;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* The list must be every entry a well-formed public key: one malformed entry
|
|
316
|
+
* means an answer this side does not understand, and the whole answer is
|
|
317
|
+
* `null` rather than a list missing a key the wallet would then never sweep.
|
|
318
|
+
*/
|
|
319
|
+
function narrowRetiredKeys(value: unknown, accountId: string | undefined): string[] | null {
|
|
320
|
+
const r = record(value);
|
|
321
|
+
if (!r || (r.accountId ?? undefined) !== accountId) return null;
|
|
322
|
+
const keys = r.publicKeys;
|
|
323
|
+
if (!Array.isArray(keys)) return null;
|
|
324
|
+
const out: string[] = [];
|
|
325
|
+
for (const key of keys) {
|
|
326
|
+
if (typeof key !== 'string' || !PUBLIC_KEY.test(key)) return null;
|
|
327
|
+
if (!out.includes(key)) out.push(key);
|
|
328
|
+
}
|
|
329
|
+
return out;
|
|
330
|
+
}
|
|
331
|
+
|
|
269
332
|
function narrowProof(value: unknown): CommonsIdentityProof | null {
|
|
270
333
|
const r = record(value);
|
|
271
334
|
const publicKey = hexField(r?.publicKey, 130);
|
|
@@ -347,6 +410,39 @@ export function loadCommonsIdentityBridge(): Promise<CommonsIdentityBridge | nul
|
|
|
347
410
|
(v) => narrowSocialReceive(echoes(v, accountId)),
|
|
348
411
|
);
|
|
349
412
|
},
|
|
413
|
+
listRetiredKeys: (accountId?: string) => {
|
|
414
|
+
const list = m.listRetiredKeys;
|
|
415
|
+
if (typeof list !== 'function') return Promise.resolve(null);
|
|
416
|
+
return ask(
|
|
417
|
+
() => list.call(m, accountId ?? null),
|
|
418
|
+
(v) => narrowRetiredKeys(v, accountId),
|
|
419
|
+
);
|
|
420
|
+
},
|
|
421
|
+
deriveRetiredScopedSeed: (info: string, publicKey: string, accountId?: string) => {
|
|
422
|
+
const derive = m.deriveRetiredScopedSeed;
|
|
423
|
+
if (typeof derive !== 'function' || !PUBLIC_KEY.test(publicKey)) {
|
|
424
|
+
return Promise.resolve(null);
|
|
425
|
+
}
|
|
426
|
+
return ask(
|
|
427
|
+
() => derive.call(m, info, publicKey, accountId ?? null),
|
|
428
|
+
(v) => hexField(echoesRetired(v, publicKey, accountId)?.seed, 64),
|
|
429
|
+
);
|
|
430
|
+
},
|
|
431
|
+
signRetiredSocialReceive: (
|
|
432
|
+
index: number,
|
|
433
|
+
digest: string,
|
|
434
|
+
publicKey: string,
|
|
435
|
+
accountId?: string,
|
|
436
|
+
) => {
|
|
437
|
+
const sign = m.signRetiredSocialReceive;
|
|
438
|
+
if (typeof sign !== 'function' || !PUBLIC_KEY.test(publicKey)) {
|
|
439
|
+
return Promise.resolve(null);
|
|
440
|
+
}
|
|
441
|
+
return ask(
|
|
442
|
+
() => sign.call(m, index, digest, publicKey, accountId ?? null),
|
|
443
|
+
(v) => narrowSocialReceive(echoesRetired(v, publicKey, accountId)),
|
|
444
|
+
);
|
|
445
|
+
},
|
|
350
446
|
} satisfies CommonsIdentityBridge;
|
|
351
447
|
});
|
|
352
448
|
}
|
|
@@ -397,3 +493,55 @@ export async function loadKeychainDefaultAccessGroup(): Promise<KeychainDefaultA
|
|
|
397
493
|
keychainDefaultAccessGroup = { state: 'resolved', accessGroup: answer };
|
|
398
494
|
return keychainDefaultAccessGroup;
|
|
399
495
|
}
|
|
496
|
+
|
|
497
|
+
// ---------------------------------------------------------------------------
|
|
498
|
+
// iOS keychain application access group (ADR 0062 D6): `<prefix><bundle id>`,
|
|
499
|
+
// the group every app is a member of and no other app can name — where an app
|
|
500
|
+
// keeps the items that are its alone. Same caching as the default group: a
|
|
501
|
+
// resolved or unsupported answer is constant for the binary; a thrown probe is
|
|
502
|
+
// asked again.
|
|
503
|
+
// ---------------------------------------------------------------------------
|
|
504
|
+
|
|
505
|
+
/** A team prefix as the default group carries it: no dots, then one dot. */
|
|
506
|
+
const KEYCHAIN_PREFIX = /^[A-Za-z0-9]+\.$/;
|
|
507
|
+
|
|
508
|
+
let keychainApplicationAccessGroup: KeychainApplicationAccessGroup | null = null;
|
|
509
|
+
|
|
510
|
+
export async function loadKeychainApplicationAccessGroup(
|
|
511
|
+
prefix: string,
|
|
512
|
+
): Promise<KeychainApplicationAccessGroup> {
|
|
513
|
+
if (keychainApplicationAccessGroup) {
|
|
514
|
+
return keychainApplicationAccessGroup;
|
|
515
|
+
}
|
|
516
|
+
if (!KEYCHAIN_PREFIX.test(prefix)) {
|
|
517
|
+
return {
|
|
518
|
+
state: 'unavailable',
|
|
519
|
+
cause: new Error('keychain application group probe needs a team prefix'),
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
const native = requireOptionalNativeModule<Partial<OxyIdentityNativeModule>>('OxyIdentity');
|
|
523
|
+
const probe = native?.keychainApplicationAccessGroup;
|
|
524
|
+
if (!native || typeof probe !== 'function') {
|
|
525
|
+
keychainApplicationAccessGroup = { state: 'unsupported' };
|
|
526
|
+
return keychainApplicationAccessGroup;
|
|
527
|
+
}
|
|
528
|
+
let answer: unknown;
|
|
529
|
+
try {
|
|
530
|
+
answer = await probe.call(native, prefix);
|
|
531
|
+
} catch (cause) {
|
|
532
|
+
return { state: 'unavailable', cause };
|
|
533
|
+
}
|
|
534
|
+
if (answer === null || answer === undefined) {
|
|
535
|
+
// Not entitled (or not iOS): this binary keeps the placement it was built for.
|
|
536
|
+
keychainApplicationAccessGroup = { state: 'unsupported' };
|
|
537
|
+
return keychainApplicationAccessGroup;
|
|
538
|
+
}
|
|
539
|
+
if (typeof answer !== 'string' || !answer.startsWith(prefix) || !ACCESS_GROUP.test(answer)) {
|
|
540
|
+
// A native bug, not a locked keychain: asking again would answer the same,
|
|
541
|
+
// and `unavailable` would leave the app without storage. Keep the
|
|
542
|
+
// placement this build had before the probe existed.
|
|
543
|
+
return { state: 'unsupported' };
|
|
544
|
+
}
|
|
545
|
+
keychainApplicationAccessGroup = { state: 'resolved', accessGroup: answer };
|
|
546
|
+
return keychainApplicationAccessGroup;
|
|
547
|
+
}
|
package/src/platform/crypto.ts
CHANGED
|
@@ -80,6 +80,7 @@ import type {
|
|
|
80
80
|
CommonsSocialReceiveSignature,
|
|
81
81
|
ExpoCryptoLike,
|
|
82
82
|
ExpoSecureStoreLike,
|
|
83
|
+
KeychainApplicationAccessGroup,
|
|
83
84
|
KeychainDefaultAccessGroup,
|
|
84
85
|
} from './expoTypes';
|
|
85
86
|
|
|
@@ -92,6 +93,7 @@ export type {
|
|
|
92
93
|
CommonsSocialReceiveSignature,
|
|
93
94
|
ExpoCryptoLike,
|
|
94
95
|
ExpoSecureStoreLike,
|
|
96
|
+
KeychainApplicationAccessGroup,
|
|
95
97
|
KeychainDefaultAccessGroup,
|
|
96
98
|
};
|
|
97
99
|
|
|
@@ -186,3 +188,9 @@ export function loadCommonsIdentityBridge(): Promise<CommonsIdentityBridge | nul
|
|
|
186
188
|
export function loadKeychainDefaultAccessGroup(): Promise<KeychainDefaultAccessGroup> {
|
|
187
189
|
return Promise.resolve({ state: 'unsupported' });
|
|
188
190
|
}
|
|
191
|
+
|
|
192
|
+
export function loadKeychainApplicationAccessGroup(
|
|
193
|
+
_prefix: string,
|
|
194
|
+
): Promise<KeychainApplicationAccessGroup> {
|
|
195
|
+
return Promise.resolve({ state: 'unsupported' });
|
|
196
|
+
}
|
|
@@ -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
|
/**
|
|
@@ -170,3 +207,27 @@ export type KeychainDefaultAccessGroup =
|
|
|
170
207
|
| { state: 'resolved'; accessGroup: string }
|
|
171
208
|
| { state: 'unsupported' }
|
|
172
209
|
| { state: 'unavailable'; cause: unknown };
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* What the iOS keychain says about this app's OWN access group: its
|
|
213
|
+
* application identifier, `<prefix><bundle id>` (`ABCDE12345.so.oxy.mention`).
|
|
214
|
+
* Apple makes every app a member of that group whatever its
|
|
215
|
+
* `keychain-access-groups` lists, and no other app can name it, so it is where
|
|
216
|
+
* an app keeps the items that are its alone (its session credential): the
|
|
217
|
+
* keychain's counterpart of Android's per-app storage (ADR 0062 D6).
|
|
218
|
+
*
|
|
219
|
+
* The native module writes a probe item into that group EXPLICITLY and answers
|
|
220
|
+
* the group only when the keychain took it.
|
|
221
|
+
*
|
|
222
|
+
* - `resolved`: the group string.
|
|
223
|
+
* - `unsupported`: not iOS, a native build whose `OxyIdentity` module predates
|
|
224
|
+
* this probe, a keychain that refused the group as not entitled, or an answer
|
|
225
|
+
* that is not this team's group. Constant for the binary: callers keep the
|
|
226
|
+
* placement that build was made for.
|
|
227
|
+
* - `unavailable`: the probe threw (before first unlock). Transient; never
|
|
228
|
+
* cached.
|
|
229
|
+
*/
|
|
230
|
+
export type KeychainApplicationAccessGroup =
|
|
231
|
+
| { state: 'resolved'; accessGroup: string }
|
|
232
|
+
| { state: 'unsupported' }
|
|
233
|
+
| { state: 'unavailable'; cause: unknown };
|