@oxy.so/protocol 1.7.0 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/index.js +2 -1
- package/dist/cjs/platform/crypto.js +7 -0
- package/dist/cjs/platform/crypto.native.js +98 -0
- package/dist/esm/index.js +1 -1
- package/dist/esm/platform/crypto.js +6 -0
- package/dist/esm/platform/crypto.native.js +97 -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 +54 -2
- 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 +140 -0
- package/src/platform/crypto.ts +10 -0
- package/src/platform/expoTypes.ts +59 -2
package/dist/cjs/index.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* subpath so they never enter React Native / web bundles.
|
|
12
12
|
*/
|
|
13
13
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
14
|
-
exports.loadCommonsIdentityBridge = exports.getRandomBytesRN = exports.loadAsyncStorage = exports.loadSecureStore = exports.loadExpoCrypto = exports.loadNodeCrypto = exports.isNodeJS = exports.isReactNative = exports.isAuthorizedKey = exports.REGISTRATION_POW_DIFFICULTY_BITS = exports.meetsRegistrationPowDifficulty = exports.countLeadingZeroBits = exports.registrationPowMessage = exports.verifyCheckpointSignature = exports.signCheckpoint = exports.checkpointHash = exports.checkpointSigningInput = exports.verifyInclusionProof = exports.inclusionProof = exports.buildTransparencyTreeFromHeads = exports.buildTransparencyTree = exports.transparencyLeafHash = exports.EMPTY_TRANSPARENCY_ROOT = exports.verifyAndAppend = exports.DEFAULT_CLOCK_SKEW_MS = exports.verifyEnvelope = exports.checkContinuity = exports.UNCHAINED_SEQ = exports.verifyEnvelopeSignature = exports.signEnvelope = exports.verifySignature = exports.signMessage = exports.computeRecordId = exports.sha256 = exports.signedRecordSigningInput = exports.canonicalize = void 0;
|
|
14
|
+
exports.loadKeychainDefaultAccessGroup = exports.loadCommonsIdentityBridge = exports.getRandomBytesRN = exports.loadAsyncStorage = exports.loadSecureStore = exports.loadExpoCrypto = exports.loadNodeCrypto = exports.isNodeJS = exports.isReactNative = exports.isAuthorizedKey = exports.REGISTRATION_POW_DIFFICULTY_BITS = exports.meetsRegistrationPowDifficulty = exports.countLeadingZeroBits = exports.registrationPowMessage = exports.verifyCheckpointSignature = exports.signCheckpoint = exports.checkpointHash = exports.checkpointSigningInput = exports.verifyInclusionProof = exports.inclusionProof = exports.buildTransparencyTreeFromHeads = exports.buildTransparencyTree = exports.transparencyLeafHash = exports.EMPTY_TRANSPARENCY_ROOT = exports.verifyAndAppend = exports.DEFAULT_CLOCK_SKEW_MS = exports.verifyEnvelope = exports.checkContinuity = exports.UNCHAINED_SEQ = exports.verifyEnvelopeSignature = exports.signEnvelope = exports.verifySignature = exports.signMessage = exports.computeRecordId = exports.sha256 = exports.signedRecordSigningInput = exports.canonicalize = void 0;
|
|
15
15
|
// ---------------------------------------------------------------------------
|
|
16
16
|
// Envelope — canonical JSON, signing input, content address, signing/verify
|
|
17
17
|
// ---------------------------------------------------------------------------
|
|
@@ -77,3 +77,4 @@ Object.defineProperty(exports, "loadSecureStore", { enumerable: true, get: funct
|
|
|
77
77
|
Object.defineProperty(exports, "loadAsyncStorage", { enumerable: true, get: function () { return crypto_1.loadAsyncStorage; } });
|
|
78
78
|
Object.defineProperty(exports, "getRandomBytesRN", { enumerable: true, get: function () { return crypto_1.getRandomBytesRN; } });
|
|
79
79
|
Object.defineProperty(exports, "loadCommonsIdentityBridge", { enumerable: true, get: function () { return crypto_1.loadCommonsIdentityBridge; } });
|
|
80
|
+
Object.defineProperty(exports, "loadKeychainDefaultAccessGroup", { enumerable: true, get: function () { return crypto_1.loadKeychainDefaultAccessGroup; } });
|
|
@@ -112,6 +112,7 @@ exports.loadExpoCrypto = loadExpoCrypto;
|
|
|
112
112
|
exports.loadSecureStore = loadSecureStore;
|
|
113
113
|
exports.loadAsyncStorage = loadAsyncStorage;
|
|
114
114
|
exports.loadCommonsIdentityBridge = loadCommonsIdentityBridge;
|
|
115
|
+
exports.loadKeychainDefaultAccessGroup = loadKeychainDefaultAccessGroup;
|
|
115
116
|
const platform_1 = require("./platform");
|
|
116
117
|
// ---------------------------------------------------------------------------
|
|
117
118
|
// Node `crypto` — Node built-in
|
|
@@ -177,3 +178,9 @@ Object.defineProperty(exports, "getRandomBytesRN", { enumerable: true, get: func
|
|
|
177
178
|
function loadCommonsIdentityBridge() {
|
|
178
179
|
return Promise.resolve(null);
|
|
179
180
|
}
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
// iOS keychain default access group — native-only; off RN there is no keychain.
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
function loadKeychainDefaultAccessGroup() {
|
|
185
|
+
return Promise.resolve({ state: 'unsupported' });
|
|
186
|
+
}
|
|
@@ -59,6 +59,7 @@ exports.loadExpoCrypto = loadExpoCrypto;
|
|
|
59
59
|
exports.loadSecureStore = loadSecureStore;
|
|
60
60
|
exports.loadAsyncStorage = loadAsyncStorage;
|
|
61
61
|
exports.loadCommonsIdentityBridge = loadCommonsIdentityBridge;
|
|
62
|
+
exports.loadKeychainDefaultAccessGroup = loadKeychainDefaultAccessGroup;
|
|
62
63
|
const expo_modules_core_1 = require("expo-modules-core");
|
|
63
64
|
const optionalPeer_1 = require("./optionalPeer");
|
|
64
65
|
const random_native_1 = require("./random.native");
|
|
@@ -133,6 +134,8 @@ async function loadAsyncStorage() {
|
|
|
133
134
|
var random_native_2 = require("./random.native");
|
|
134
135
|
Object.defineProperty(exports, "getRandomBytesRN", { enumerable: true, get: function () { return random_native_2.getRandomBytesRN; } });
|
|
135
136
|
const LOWER_HEX = /^[0-9a-f]+$/;
|
|
137
|
+
/** A lowercase uncompressed SEC1 public key, as Commons names every key it holds. */
|
|
138
|
+
const PUBLIC_KEY = /^04[0-9a-f]{128}$/;
|
|
136
139
|
function record(value) {
|
|
137
140
|
return typeof value === 'object' && value !== null ? value : null;
|
|
138
141
|
}
|
|
@@ -165,6 +168,39 @@ function narrowOrganizationDescription(value, accountId) {
|
|
|
165
168
|
const description = narrowDescription(echoes(value, accountId));
|
|
166
169
|
return description ? { ...description, accountId } : null;
|
|
167
170
|
}
|
|
171
|
+
/**
|
|
172
|
+
* A retired-key answer must name the key asked for, and the scope asked for:
|
|
173
|
+
* the organization's `accountId`, or none for the person. A Commons before
|
|
174
|
+
* protocol 4 ignores `keyPublicKey` and answers with the CURRENT key, without
|
|
175
|
+
* the echo; that answer is refused here.
|
|
176
|
+
*/
|
|
177
|
+
function echoesRetired(value, publicKey, accountId) {
|
|
178
|
+
const r = record(value);
|
|
179
|
+
if (!r || r.keyPublicKey !== publicKey)
|
|
180
|
+
return null;
|
|
181
|
+
return (r.accountId ?? undefined) === accountId ? r : null;
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* The list must be every entry a well-formed public key: one malformed entry
|
|
185
|
+
* means an answer this side does not understand, and the whole answer is
|
|
186
|
+
* `null` rather than a list missing a key the wallet would then never sweep.
|
|
187
|
+
*/
|
|
188
|
+
function narrowRetiredKeys(value, accountId) {
|
|
189
|
+
const r = record(value);
|
|
190
|
+
if (!r || (r.accountId ?? undefined) !== accountId)
|
|
191
|
+
return null;
|
|
192
|
+
const keys = r.publicKeys;
|
|
193
|
+
if (!Array.isArray(keys))
|
|
194
|
+
return null;
|
|
195
|
+
const out = [];
|
|
196
|
+
for (const key of keys) {
|
|
197
|
+
if (typeof key !== 'string' || !PUBLIC_KEY.test(key))
|
|
198
|
+
return null;
|
|
199
|
+
if (!out.includes(key))
|
|
200
|
+
out.push(key);
|
|
201
|
+
}
|
|
202
|
+
return out;
|
|
203
|
+
}
|
|
168
204
|
function narrowProof(value) {
|
|
169
205
|
const r = record(value);
|
|
170
206
|
const publicKey = hexField(r?.publicKey, 130);
|
|
@@ -232,8 +268,70 @@ function loadCommonsIdentityBridge() {
|
|
|
232
268
|
return Promise.resolve(null);
|
|
233
269
|
return ask(() => sign.call(m, index, digest, accountId), (v) => narrowSocialReceive(echoes(v, accountId)));
|
|
234
270
|
},
|
|
271
|
+
listRetiredKeys: (accountId) => {
|
|
272
|
+
const list = m.listRetiredKeys;
|
|
273
|
+
if (typeof list !== 'function')
|
|
274
|
+
return Promise.resolve(null);
|
|
275
|
+
return ask(() => list.call(m, accountId ?? null), (v) => narrowRetiredKeys(v, accountId));
|
|
276
|
+
},
|
|
277
|
+
deriveRetiredScopedSeed: (info, publicKey, accountId) => {
|
|
278
|
+
const derive = m.deriveRetiredScopedSeed;
|
|
279
|
+
if (typeof derive !== 'function' || !PUBLIC_KEY.test(publicKey)) {
|
|
280
|
+
return Promise.resolve(null);
|
|
281
|
+
}
|
|
282
|
+
return ask(() => derive.call(m, info, publicKey, accountId ?? null), (v) => hexField(echoesRetired(v, publicKey, accountId)?.seed, 64));
|
|
283
|
+
},
|
|
284
|
+
signRetiredSocialReceive: (index, digest, publicKey, accountId) => {
|
|
285
|
+
const sign = m.signRetiredSocialReceive;
|
|
286
|
+
if (typeof sign !== 'function' || !PUBLIC_KEY.test(publicKey)) {
|
|
287
|
+
return Promise.resolve(null);
|
|
288
|
+
}
|
|
289
|
+
return ask(() => sign.call(m, index, digest, publicKey, accountId ?? null), (v) => narrowSocialReceive(echoesRetired(v, publicKey, accountId)));
|
|
290
|
+
},
|
|
235
291
|
};
|
|
236
292
|
});
|
|
237
293
|
}
|
|
238
294
|
return commonsIdentityBridgePromise;
|
|
239
295
|
}
|
|
296
|
+
// ---------------------------------------------------------------------------
|
|
297
|
+
// iOS keychain default access group (docs/identity/ios-keychain-access-groups.md).
|
|
298
|
+
//
|
|
299
|
+
// Resolved once the native module answers; an `unavailable` probe (a thrown
|
|
300
|
+
// call: before first unlock, or the keychain refused) is not memoized, so the
|
|
301
|
+
// next caller asks again. The answer is narrowed to a plain string — it crossed
|
|
302
|
+
// the native boundary.
|
|
303
|
+
// ---------------------------------------------------------------------------
|
|
304
|
+
/** `<prefix>.<group>`: an App ID prefix (no dots) then a non-empty group. */
|
|
305
|
+
const ACCESS_GROUP = /^[A-Za-z0-9]+\.[A-Za-z0-9.-]+$/;
|
|
306
|
+
let keychainDefaultAccessGroup = null;
|
|
307
|
+
async function loadKeychainDefaultAccessGroup() {
|
|
308
|
+
if (keychainDefaultAccessGroup) {
|
|
309
|
+
return keychainDefaultAccessGroup;
|
|
310
|
+
}
|
|
311
|
+
const native = (0, expo_modules_core_1.requireOptionalNativeModule)('OxyIdentity');
|
|
312
|
+
const probe = native?.keychainDefaultAccessGroup;
|
|
313
|
+
if (!native || typeof probe !== 'function') {
|
|
314
|
+
keychainDefaultAccessGroup = { state: 'unsupported' };
|
|
315
|
+
return keychainDefaultAccessGroup;
|
|
316
|
+
}
|
|
317
|
+
let answer;
|
|
318
|
+
try {
|
|
319
|
+
answer = await probe.call(native);
|
|
320
|
+
}
|
|
321
|
+
catch (cause) {
|
|
322
|
+
return { state: 'unavailable', cause };
|
|
323
|
+
}
|
|
324
|
+
if (answer === null || answer === undefined) {
|
|
325
|
+
// The native side runs only on iOS; anything else answers nothing.
|
|
326
|
+
keychainDefaultAccessGroup = { state: 'unsupported' };
|
|
327
|
+
return keychainDefaultAccessGroup;
|
|
328
|
+
}
|
|
329
|
+
if (typeof answer !== 'string' || !ACCESS_GROUP.test(answer)) {
|
|
330
|
+
return {
|
|
331
|
+
state: 'unavailable',
|
|
332
|
+
cause: new Error('keychain probe answered something that is not an access group'),
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
keychainDefaultAccessGroup = { state: 'resolved', accessGroup: answer };
|
|
336
|
+
return keychainDefaultAccessGroup;
|
|
337
|
+
}
|
package/dist/esm/index.js
CHANGED
|
@@ -37,4 +37,4 @@ export { isAuthorizedKey } from './identity/resolver.js';
|
|
|
37
37
|
// Platform — runtime predicates + lazy crypto/storage loaders
|
|
38
38
|
// ---------------------------------------------------------------------------
|
|
39
39
|
export { isReactNative, isNodeJS } from './platform/platform.js';
|
|
40
|
-
export { loadNodeCrypto, loadExpoCrypto, loadSecureStore, loadAsyncStorage, getRandomBytesRN, loadCommonsIdentityBridge, } from './platform/crypto.js';
|
|
40
|
+
export { loadNodeCrypto, loadExpoCrypto, loadSecureStore, loadAsyncStorage, getRandomBytesRN, loadCommonsIdentityBridge, loadKeychainDefaultAccessGroup, } from './platform/crypto.js';
|
|
@@ -135,3 +135,9 @@ export { getRandomBytesRN } from './random.js';
|
|
|
135
135
|
export function loadCommonsIdentityBridge() {
|
|
136
136
|
return Promise.resolve(null);
|
|
137
137
|
}
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
// iOS keychain default access group — native-only; off RN there is no keychain.
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
export function loadKeychainDefaultAccessGroup() {
|
|
142
|
+
return Promise.resolve({ state: 'unsupported' });
|
|
143
|
+
}
|
|
@@ -124,6 +124,8 @@ export async function loadAsyncStorage() {
|
|
|
124
124
|
// Metro pointed at the same file.
|
|
125
125
|
export { getRandomBytesRN } from './random.native.js';
|
|
126
126
|
const LOWER_HEX = /^[0-9a-f]+$/;
|
|
127
|
+
/** A lowercase uncompressed SEC1 public key, as Commons names every key it holds. */
|
|
128
|
+
const PUBLIC_KEY = /^04[0-9a-f]{128}$/;
|
|
127
129
|
function record(value) {
|
|
128
130
|
return typeof value === 'object' && value !== null ? value : null;
|
|
129
131
|
}
|
|
@@ -156,6 +158,39 @@ function narrowOrganizationDescription(value, accountId) {
|
|
|
156
158
|
const description = narrowDescription(echoes(value, accountId));
|
|
157
159
|
return description ? { ...description, accountId } : null;
|
|
158
160
|
}
|
|
161
|
+
/**
|
|
162
|
+
* A retired-key answer must name the key asked for, and the scope asked for:
|
|
163
|
+
* the organization's `accountId`, or none for the person. A Commons before
|
|
164
|
+
* protocol 4 ignores `keyPublicKey` and answers with the CURRENT key, without
|
|
165
|
+
* the echo; that answer is refused here.
|
|
166
|
+
*/
|
|
167
|
+
function echoesRetired(value, publicKey, accountId) {
|
|
168
|
+
const r = record(value);
|
|
169
|
+
if (!r || r.keyPublicKey !== publicKey)
|
|
170
|
+
return null;
|
|
171
|
+
return (r.accountId ?? undefined) === accountId ? r : null;
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* The list must be every entry a well-formed public key: one malformed entry
|
|
175
|
+
* means an answer this side does not understand, and the whole answer is
|
|
176
|
+
* `null` rather than a list missing a key the wallet would then never sweep.
|
|
177
|
+
*/
|
|
178
|
+
function narrowRetiredKeys(value, accountId) {
|
|
179
|
+
const r = record(value);
|
|
180
|
+
if (!r || (r.accountId ?? undefined) !== accountId)
|
|
181
|
+
return null;
|
|
182
|
+
const keys = r.publicKeys;
|
|
183
|
+
if (!Array.isArray(keys))
|
|
184
|
+
return null;
|
|
185
|
+
const out = [];
|
|
186
|
+
for (const key of keys) {
|
|
187
|
+
if (typeof key !== 'string' || !PUBLIC_KEY.test(key))
|
|
188
|
+
return null;
|
|
189
|
+
if (!out.includes(key))
|
|
190
|
+
out.push(key);
|
|
191
|
+
}
|
|
192
|
+
return out;
|
|
193
|
+
}
|
|
159
194
|
function narrowProof(value) {
|
|
160
195
|
const r = record(value);
|
|
161
196
|
const publicKey = hexField(r?.publicKey, 130);
|
|
@@ -223,8 +258,70 @@ export function loadCommonsIdentityBridge() {
|
|
|
223
258
|
return Promise.resolve(null);
|
|
224
259
|
return ask(() => sign.call(m, index, digest, accountId), (v) => narrowSocialReceive(echoes(v, accountId)));
|
|
225
260
|
},
|
|
261
|
+
listRetiredKeys: (accountId) => {
|
|
262
|
+
const list = m.listRetiredKeys;
|
|
263
|
+
if (typeof list !== 'function')
|
|
264
|
+
return Promise.resolve(null);
|
|
265
|
+
return ask(() => list.call(m, accountId ?? null), (v) => narrowRetiredKeys(v, accountId));
|
|
266
|
+
},
|
|
267
|
+
deriveRetiredScopedSeed: (info, publicKey, accountId) => {
|
|
268
|
+
const derive = m.deriveRetiredScopedSeed;
|
|
269
|
+
if (typeof derive !== 'function' || !PUBLIC_KEY.test(publicKey)) {
|
|
270
|
+
return Promise.resolve(null);
|
|
271
|
+
}
|
|
272
|
+
return ask(() => derive.call(m, info, publicKey, accountId ?? null), (v) => hexField(echoesRetired(v, publicKey, accountId)?.seed, 64));
|
|
273
|
+
},
|
|
274
|
+
signRetiredSocialReceive: (index, digest, publicKey, accountId) => {
|
|
275
|
+
const sign = m.signRetiredSocialReceive;
|
|
276
|
+
if (typeof sign !== 'function' || !PUBLIC_KEY.test(publicKey)) {
|
|
277
|
+
return Promise.resolve(null);
|
|
278
|
+
}
|
|
279
|
+
return ask(() => sign.call(m, index, digest, publicKey, accountId ?? null), (v) => narrowSocialReceive(echoesRetired(v, publicKey, accountId)));
|
|
280
|
+
},
|
|
226
281
|
};
|
|
227
282
|
});
|
|
228
283
|
}
|
|
229
284
|
return commonsIdentityBridgePromise;
|
|
230
285
|
}
|
|
286
|
+
// ---------------------------------------------------------------------------
|
|
287
|
+
// iOS keychain default access group (docs/identity/ios-keychain-access-groups.md).
|
|
288
|
+
//
|
|
289
|
+
// Resolved once the native module answers; an `unavailable` probe (a thrown
|
|
290
|
+
// call: before first unlock, or the keychain refused) is not memoized, so the
|
|
291
|
+
// next caller asks again. The answer is narrowed to a plain string — it crossed
|
|
292
|
+
// the native boundary.
|
|
293
|
+
// ---------------------------------------------------------------------------
|
|
294
|
+
/** `<prefix>.<group>`: an App ID prefix (no dots) then a non-empty group. */
|
|
295
|
+
const ACCESS_GROUP = /^[A-Za-z0-9]+\.[A-Za-z0-9.-]+$/;
|
|
296
|
+
let keychainDefaultAccessGroup = null;
|
|
297
|
+
export async function loadKeychainDefaultAccessGroup() {
|
|
298
|
+
if (keychainDefaultAccessGroup) {
|
|
299
|
+
return keychainDefaultAccessGroup;
|
|
300
|
+
}
|
|
301
|
+
const native = requireOptionalNativeModule('OxyIdentity');
|
|
302
|
+
const probe = native?.keychainDefaultAccessGroup;
|
|
303
|
+
if (!native || typeof probe !== 'function') {
|
|
304
|
+
keychainDefaultAccessGroup = { state: 'unsupported' };
|
|
305
|
+
return keychainDefaultAccessGroup;
|
|
306
|
+
}
|
|
307
|
+
let answer;
|
|
308
|
+
try {
|
|
309
|
+
answer = await probe.call(native);
|
|
310
|
+
}
|
|
311
|
+
catch (cause) {
|
|
312
|
+
return { state: 'unavailable', cause };
|
|
313
|
+
}
|
|
314
|
+
if (answer === null || answer === undefined) {
|
|
315
|
+
// The native side runs only on iOS; anything else answers nothing.
|
|
316
|
+
keychainDefaultAccessGroup = { state: 'unsupported' };
|
|
317
|
+
return keychainDefaultAccessGroup;
|
|
318
|
+
}
|
|
319
|
+
if (typeof answer !== 'string' || !ACCESS_GROUP.test(answer)) {
|
|
320
|
+
return {
|
|
321
|
+
state: 'unavailable',
|
|
322
|
+
cause: new Error('keychain probe answered something that is not an access group'),
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
keychainDefaultAccessGroup = { state: 'resolved', accessGroup: answer };
|
|
326
|
+
return keychainDefaultAccessGroup;
|
|
327
|
+
}
|
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, } from './platform/crypto';
|
|
33
|
-
export type { CommonsIdentityBridge, CommonsIdentityDescription, CommonsIdentityProof, CommonsSocialReceiveSignature, ExpoCryptoLike, ExpoSecureStoreLike, } from './platform/expoTypes';
|
|
32
|
+
export { loadNodeCrypto, loadExpoCrypto, loadSecureStore, loadAsyncStorage, getRandomBytesRN, loadCommonsIdentityBridge, loadKeychainDefaultAccessGroup, } from './platform/crypto';
|
|
33
|
+
export type { CommonsIdentityBridge, CommonsIdentityDescription, CommonsIdentityProof, CommonsSocialReceiveSignature, ExpoCryptoLike, ExpoSecureStoreLike, KeychainDefaultAccessGroup, } from './platform/expoTypes';
|
|
@@ -71,8 +71,8 @@
|
|
|
71
71
|
* see a file containing only specifiers it can understand — no tricks, no
|
|
72
72
|
* runtime parsing risks.
|
|
73
73
|
*/
|
|
74
|
-
import type { CommonsIdentityBridge, CommonsIdentityDescription, CommonsIdentityProof, CommonsSocialReceiveSignature, ExpoCryptoLike, ExpoSecureStoreLike } from './expoTypes';
|
|
75
|
-
export type { CommonsIdentityBridge, CommonsIdentityDescription, CommonsIdentityProof, CommonsSocialReceiveSignature, ExpoCryptoLike, ExpoSecureStoreLike, };
|
|
74
|
+
import type { CommonsIdentityBridge, CommonsIdentityDescription, CommonsIdentityProof, CommonsSocialReceiveSignature, ExpoCryptoLike, ExpoSecureStoreLike, KeychainDefaultAccessGroup } from './expoTypes';
|
|
75
|
+
export type { CommonsIdentityBridge, CommonsIdentityDescription, CommonsIdentityProof, CommonsSocialReceiveSignature, ExpoCryptoLike, ExpoSecureStoreLike, KeychainDefaultAccessGroup, };
|
|
76
76
|
export declare function loadNodeCrypto(): Promise<typeof import('crypto')>;
|
|
77
77
|
export declare function loadExpoCrypto(): Promise<ExpoCryptoLike>;
|
|
78
78
|
export declare function loadSecureStore(): Promise<ExpoSecureStoreLike>;
|
|
@@ -85,3 +85,4 @@ export declare function loadAsyncStorage(): Promise<{
|
|
|
85
85
|
}>;
|
|
86
86
|
export { getRandomBytesRN } from './random';
|
|
87
87
|
export declare function loadCommonsIdentityBridge(): Promise<CommonsIdentityBridge | null>;
|
|
88
|
+
export declare function loadKeychainDefaultAccessGroup(): Promise<KeychainDefaultAccessGroup>;
|
|
@@ -51,8 +51,8 @@
|
|
|
51
51
|
* `expo-modules-core` is a NON-optional peer (every RN app has it via `expo`),
|
|
52
52
|
* so it stays a plain static import.
|
|
53
53
|
*/
|
|
54
|
-
import type { CommonsIdentityBridge, CommonsIdentityDescription, CommonsIdentityProof, CommonsSocialReceiveSignature, ExpoCryptoLike, ExpoSecureStoreLike } from './expoTypes';
|
|
55
|
-
export type { CommonsIdentityBridge, CommonsIdentityDescription, CommonsIdentityProof, CommonsSocialReceiveSignature, ExpoCryptoLike, ExpoSecureStoreLike, };
|
|
54
|
+
import type { CommonsIdentityBridge, CommonsIdentityDescription, CommonsIdentityProof, CommonsSocialReceiveSignature, ExpoCryptoLike, ExpoSecureStoreLike, KeychainDefaultAccessGroup } from './expoTypes';
|
|
55
|
+
export type { CommonsIdentityBridge, CommonsIdentityDescription, CommonsIdentityProof, CommonsSocialReceiveSignature, ExpoCryptoLike, ExpoSecureStoreLike, KeychainDefaultAccessGroup, };
|
|
56
56
|
/** Persistent KV storage surface used from `@react-native-async-storage/async-storage`. */
|
|
57
57
|
type AsyncStorageLike = {
|
|
58
58
|
getItem: (key: string) => Promise<string | null>;
|
|
@@ -67,3 +67,4 @@ export declare function loadAsyncStorage(): Promise<{
|
|
|
67
67
|
}>;
|
|
68
68
|
export { getRandomBytesRN } from './random.native';
|
|
69
69
|
export declare function loadCommonsIdentityBridge(): Promise<CommonsIdentityBridge | null>;
|
|
70
|
+
export declare function loadKeychainDefaultAccessGroup(): Promise<KeychainDefaultAccessGroup>;
|
|
@@ -115,8 +115,16 @@ export interface CommonsSocialReceiveSignature {
|
|
|
115
115
|
* resolves `null` when Commons is not installed, holds no identity, or refuses
|
|
116
116
|
* the caller.
|
|
117
117
|
*
|
|
118
|
-
* On iOS
|
|
119
|
-
*
|
|
118
|
+
* On iOS these methods resolve `null`: the keychain access groups in
|
|
119
|
+
* `@oxy.so/core`'s `KeyManager` own iOS sharing (docs/identity/ios-keychain-access-groups.md).
|
|
120
|
+
*
|
|
121
|
+
* The three `*Retired*` methods (Commons protocol 4) reach a key the person,
|
|
122
|
+
* or the organization with `accountId`, rotated away from, by its public key,
|
|
123
|
+
* for SPENDING the coins still at its addresses: never for signing in. They are
|
|
124
|
+
* always present on the bridge and resolve `null` on a native module or a
|
|
125
|
+
* Commons that predates them; an answer that does not echo the key (and the
|
|
126
|
+
* account) asked for is refused, because the current key taken for a retired
|
|
127
|
+
* one is a payment signed by the wrong key.
|
|
120
128
|
*/
|
|
121
129
|
export interface CommonsIdentityBridge {
|
|
122
130
|
/**
|
|
@@ -139,4 +147,48 @@ export interface CommonsIdentityBridge {
|
|
|
139
147
|
* for the wallet app only.
|
|
140
148
|
*/
|
|
141
149
|
signSocialReceive(index: number, digest: string, accountId?: string): Promise<CommonsSocialReceiveSignature | null>;
|
|
150
|
+
/**
|
|
151
|
+
* The public keys (lowercase uncompressed SEC1 hex, de-duplicated) of the
|
|
152
|
+
* retired keys Commons holds for the person, or with `accountId` for that
|
|
153
|
+
* organization, minus the current one. `[]` when it holds none; `null` when
|
|
154
|
+
* Commons cannot say (absent, refused, or older than retired keys). Allowed
|
|
155
|
+
* for the wallet app only.
|
|
156
|
+
*/
|
|
157
|
+
listRetiredKeys(accountId?: string): Promise<string[] | null>;
|
|
158
|
+
/**
|
|
159
|
+
* {@link deriveScopedSeed} over the key of that scope whose public key is
|
|
160
|
+
* `publicKey` (current or retired), never another key: `null` when Commons
|
|
161
|
+
* does not hold it.
|
|
162
|
+
*/
|
|
163
|
+
deriveRetiredScopedSeed(info: string, publicKey: string, accountId?: string): Promise<string | null>;
|
|
164
|
+
/**
|
|
165
|
+
* {@link signSocialReceive} with the social-receive child `index` of the key
|
|
166
|
+
* of that scope whose public key is `publicKey` (current or retired), never
|
|
167
|
+
* another key. Allowed for the wallet app only.
|
|
168
|
+
*/
|
|
169
|
+
signRetiredSocialReceive(index: number, digest: string, publicKey: string, accountId?: string): Promise<CommonsSocialReceiveSignature | null>;
|
|
142
170
|
}
|
|
171
|
+
/**
|
|
172
|
+
* What the iOS keychain says about this app's DEFAULT access group: the first
|
|
173
|
+
* entry of its `keychain-access-groups` entitlement, team prefix included
|
|
174
|
+
* (`ABCDE12345.so.oxy.identity`). The value is read back from a probe item the
|
|
175
|
+
* native module writes without a group, because JavaScript cannot see the
|
|
176
|
+
* entitlements or the team prefix (`$(AppIdentifierPrefix)` expands at build
|
|
177
|
+
* time), and `kSecAttrAccessGroup` must equal an entitlement value exactly.
|
|
178
|
+
*
|
|
179
|
+
* - `resolved`: the group string, as the keychain reported it.
|
|
180
|
+
* - `unsupported`: not iOS, or a native build whose `OxyIdentity` module
|
|
181
|
+
* predates the probe. Callers keep the single-group behaviour that build was
|
|
182
|
+
* made for.
|
|
183
|
+
* - `unavailable`: the probe threw (before first unlock, keychain refused).
|
|
184
|
+
* Transient; never cached.
|
|
185
|
+
*/
|
|
186
|
+
export type KeychainDefaultAccessGroup = {
|
|
187
|
+
state: 'resolved';
|
|
188
|
+
accessGroup: string;
|
|
189
|
+
} | {
|
|
190
|
+
state: 'unsupported';
|
|
191
|
+
} | {
|
|
192
|
+
state: 'unavailable';
|
|
193
|
+
cause: unknown;
|
|
194
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oxy.so/protocol",
|
|
3
|
-
"version": "1.
|
|
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();
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The iOS keychain probe (docs/identity/ios-keychain-access-groups.md): its
|
|
3
|
+
* answer crossed the native boundary, so only a `<prefix>.<group>` string is a
|
|
4
|
+
* group; a thrown probe is `unavailable` and asked again, never cached.
|
|
5
|
+
*/
|
|
6
|
+
function mockNative(native: unknown): void {
|
|
7
|
+
jest.doMock('expo-modules-core', () => ({ requireOptionalNativeModule: () => native }), {
|
|
8
|
+
virtual: true,
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async function probe() {
|
|
13
|
+
const mod = await import('../platform/crypto.native');
|
|
14
|
+
return mod.loadKeychainDefaultAccessGroup();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('loadKeychainDefaultAccessGroup (native)', () => {
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
jest.resetModules();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('a native module without the probe is unsupported', async () => {
|
|
23
|
+
mockNative({ describe: async () => null });
|
|
24
|
+
await expect(probe()).resolves.toEqual({ state: 'unsupported' });
|
|
25
|
+
jest.resetModules();
|
|
26
|
+
mockNative(null);
|
|
27
|
+
await expect(probe()).resolves.toEqual({ state: 'unsupported' });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('passes a team-prefixed group through, and remembers it', async () => {
|
|
31
|
+
const keychainDefaultAccessGroup = jest.fn(async () => 'ABCDE12345.so.oxy.identity');
|
|
32
|
+
mockNative({ keychainDefaultAccessGroup });
|
|
33
|
+
const mod = await import('../platform/crypto.native');
|
|
34
|
+
await expect(mod.loadKeychainDefaultAccessGroup()).resolves.toEqual({
|
|
35
|
+
state: 'resolved',
|
|
36
|
+
accessGroup: 'ABCDE12345.so.oxy.identity',
|
|
37
|
+
});
|
|
38
|
+
await mod.loadKeychainDefaultAccessGroup();
|
|
39
|
+
expect(keychainDefaultAccessGroup).toHaveBeenCalledTimes(1);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('a throw is unavailable and asked again; junk is unavailable', async () => {
|
|
43
|
+
let locked = true;
|
|
44
|
+
mockNative({
|
|
45
|
+
keychainDefaultAccessGroup: async () => {
|
|
46
|
+
if (locked) throw new Error('errSecInteractionNotAllowed');
|
|
47
|
+
return 'ABCDE12345.group.so.oxy.shared';
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
const mod = await import('../platform/crypto.native');
|
|
51
|
+
expect((await mod.loadKeychainDefaultAccessGroup()).state).toBe('unavailable');
|
|
52
|
+
locked = false;
|
|
53
|
+
expect((await mod.loadKeychainDefaultAccessGroup()).state).toBe('resolved');
|
|
54
|
+
for (const junk of [42, 'nodot', '.leading', { group: 'x' }]) {
|
|
55
|
+
jest.resetModules();
|
|
56
|
+
mockNative({ keychainDefaultAccessGroup: async () => junk });
|
|
57
|
+
expect((await probe()).state).toBe('unavailable');
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('the web / Node variant is unsupported', async () => {
|
|
62
|
+
const mod = await import('../platform/crypto');
|
|
63
|
+
await expect(mod.loadKeychainDefaultAccessGroup()).resolves.toEqual({ state: 'unsupported' });
|
|
64
|
+
});
|
|
65
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -99,6 +99,7 @@ export {
|
|
|
99
99
|
loadAsyncStorage,
|
|
100
100
|
getRandomBytesRN,
|
|
101
101
|
loadCommonsIdentityBridge,
|
|
102
|
+
loadKeychainDefaultAccessGroup,
|
|
102
103
|
} from './platform/crypto';
|
|
103
104
|
|
|
104
105
|
// ---------------------------------------------------------------------------
|
|
@@ -115,4 +116,5 @@ export type {
|
|
|
115
116
|
CommonsSocialReceiveSignature,
|
|
116
117
|
ExpoCryptoLike,
|
|
117
118
|
ExpoSecureStoreLike,
|
|
119
|
+
KeychainDefaultAccessGroup,
|
|
118
120
|
} from './platform/expoTypes';
|
|
@@ -60,6 +60,7 @@ import type {
|
|
|
60
60
|
CommonsSocialReceiveSignature,
|
|
61
61
|
ExpoCryptoLike,
|
|
62
62
|
ExpoSecureStoreLike,
|
|
63
|
+
KeychainDefaultAccessGroup,
|
|
63
64
|
} from './expoTypes';
|
|
64
65
|
import { missingOptionalPeerError } from './optionalPeer';
|
|
65
66
|
import { requireExpoCrypto } from './random.native';
|
|
@@ -73,6 +74,7 @@ export type {
|
|
|
73
74
|
CommonsSocialReceiveSignature,
|
|
74
75
|
ExpoCryptoLike,
|
|
75
76
|
ExpoSecureStoreLike,
|
|
77
|
+
KeychainDefaultAccessGroup,
|
|
76
78
|
};
|
|
77
79
|
|
|
78
80
|
// ---------------------------------------------------------------------------
|
|
@@ -217,9 +219,35 @@ interface OxyIdentityNativeModule {
|
|
|
217
219
|
digest: string,
|
|
218
220
|
accountId: string,
|
|
219
221
|
): Promise<unknown>;
|
|
222
|
+
/**
|
|
223
|
+
* iOS only, added with the keychain access-group split: the access group a
|
|
224
|
+
* probe item written WITHOUT a group landed in (the app's default group).
|
|
225
|
+
* Older native modules and Android lack it.
|
|
226
|
+
*/
|
|
227
|
+
keychainDefaultAccessGroup?(): Promise<unknown>;
|
|
228
|
+
/**
|
|
229
|
+
* Retired keys (Commons protocol 4), added after the organization methods and
|
|
230
|
+
* just as absent on an older native module. `accountId` is `null` for the
|
|
231
|
+
* person. Each answer echoes the `keyPublicKey` (and the `accountId`) Commons
|
|
232
|
+
* answered for.
|
|
233
|
+
*/
|
|
234
|
+
listRetiredKeys?(accountId: string | null): Promise<unknown>;
|
|
235
|
+
deriveRetiredScopedSeed?(
|
|
236
|
+
info: string,
|
|
237
|
+
publicKey: string,
|
|
238
|
+
accountId: string | null,
|
|
239
|
+
): Promise<unknown>;
|
|
240
|
+
signRetiredSocialReceive?(
|
|
241
|
+
index: number,
|
|
242
|
+
digest: string,
|
|
243
|
+
publicKey: string,
|
|
244
|
+
accountId: string | null,
|
|
245
|
+
): Promise<unknown>;
|
|
220
246
|
}
|
|
221
247
|
|
|
222
248
|
const LOWER_HEX = /^[0-9a-f]+$/;
|
|
249
|
+
/** A lowercase uncompressed SEC1 public key, as Commons names every key it holds. */
|
|
250
|
+
const PUBLIC_KEY = /^04[0-9a-f]{128}$/;
|
|
223
251
|
|
|
224
252
|
function record(value: unknown): Record<string, unknown> | null {
|
|
225
253
|
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : null;
|
|
@@ -258,6 +286,40 @@ function narrowOrganizationDescription(
|
|
|
258
286
|
return description ? { ...description, accountId } : null;
|
|
259
287
|
}
|
|
260
288
|
|
|
289
|
+
/**
|
|
290
|
+
* A retired-key answer must name the key asked for, and the scope asked for:
|
|
291
|
+
* the organization's `accountId`, or none for the person. A Commons before
|
|
292
|
+
* protocol 4 ignores `keyPublicKey` and answers with the CURRENT key, without
|
|
293
|
+
* the echo; that answer is refused here.
|
|
294
|
+
*/
|
|
295
|
+
function echoesRetired(
|
|
296
|
+
value: unknown,
|
|
297
|
+
publicKey: string,
|
|
298
|
+
accountId: string | undefined,
|
|
299
|
+
): Record<string, unknown> | null {
|
|
300
|
+
const r = record(value);
|
|
301
|
+
if (!r || r.keyPublicKey !== publicKey) return null;
|
|
302
|
+
return (r.accountId ?? undefined) === accountId ? r : null;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* The list must be every entry a well-formed public key: one malformed entry
|
|
307
|
+
* means an answer this side does not understand, and the whole answer is
|
|
308
|
+
* `null` rather than a list missing a key the wallet would then never sweep.
|
|
309
|
+
*/
|
|
310
|
+
function narrowRetiredKeys(value: unknown, accountId: string | undefined): string[] | null {
|
|
311
|
+
const r = record(value);
|
|
312
|
+
if (!r || (r.accountId ?? undefined) !== accountId) return null;
|
|
313
|
+
const keys = r.publicKeys;
|
|
314
|
+
if (!Array.isArray(keys)) return null;
|
|
315
|
+
const out: string[] = [];
|
|
316
|
+
for (const key of keys) {
|
|
317
|
+
if (typeof key !== 'string' || !PUBLIC_KEY.test(key)) return null;
|
|
318
|
+
if (!out.includes(key)) out.push(key);
|
|
319
|
+
}
|
|
320
|
+
return out;
|
|
321
|
+
}
|
|
322
|
+
|
|
261
323
|
function narrowProof(value: unknown): CommonsIdentityProof | null {
|
|
262
324
|
const r = record(value);
|
|
263
325
|
const publicKey = hexField(r?.publicKey, 130);
|
|
@@ -339,8 +401,86 @@ export function loadCommonsIdentityBridge(): Promise<CommonsIdentityBridge | nul
|
|
|
339
401
|
(v) => narrowSocialReceive(echoes(v, accountId)),
|
|
340
402
|
);
|
|
341
403
|
},
|
|
404
|
+
listRetiredKeys: (accountId?: string) => {
|
|
405
|
+
const list = m.listRetiredKeys;
|
|
406
|
+
if (typeof list !== 'function') return Promise.resolve(null);
|
|
407
|
+
return ask(
|
|
408
|
+
() => list.call(m, accountId ?? null),
|
|
409
|
+
(v) => narrowRetiredKeys(v, accountId),
|
|
410
|
+
);
|
|
411
|
+
},
|
|
412
|
+
deriveRetiredScopedSeed: (info: string, publicKey: string, accountId?: string) => {
|
|
413
|
+
const derive = m.deriveRetiredScopedSeed;
|
|
414
|
+
if (typeof derive !== 'function' || !PUBLIC_KEY.test(publicKey)) {
|
|
415
|
+
return Promise.resolve(null);
|
|
416
|
+
}
|
|
417
|
+
return ask(
|
|
418
|
+
() => derive.call(m, info, publicKey, accountId ?? null),
|
|
419
|
+
(v) => hexField(echoesRetired(v, publicKey, accountId)?.seed, 64),
|
|
420
|
+
);
|
|
421
|
+
},
|
|
422
|
+
signRetiredSocialReceive: (
|
|
423
|
+
index: number,
|
|
424
|
+
digest: string,
|
|
425
|
+
publicKey: string,
|
|
426
|
+
accountId?: string,
|
|
427
|
+
) => {
|
|
428
|
+
const sign = m.signRetiredSocialReceive;
|
|
429
|
+
if (typeof sign !== 'function' || !PUBLIC_KEY.test(publicKey)) {
|
|
430
|
+
return Promise.resolve(null);
|
|
431
|
+
}
|
|
432
|
+
return ask(
|
|
433
|
+
() => sign.call(m, index, digest, publicKey, accountId ?? null),
|
|
434
|
+
(v) => narrowSocialReceive(echoesRetired(v, publicKey, accountId)),
|
|
435
|
+
);
|
|
436
|
+
},
|
|
342
437
|
} satisfies CommonsIdentityBridge;
|
|
343
438
|
});
|
|
344
439
|
}
|
|
345
440
|
return commonsIdentityBridgePromise;
|
|
346
441
|
}
|
|
442
|
+
|
|
443
|
+
// ---------------------------------------------------------------------------
|
|
444
|
+
// iOS keychain default access group (docs/identity/ios-keychain-access-groups.md).
|
|
445
|
+
//
|
|
446
|
+
// Resolved once the native module answers; an `unavailable` probe (a thrown
|
|
447
|
+
// call: before first unlock, or the keychain refused) is not memoized, so the
|
|
448
|
+
// next caller asks again. The answer is narrowed to a plain string — it crossed
|
|
449
|
+
// the native boundary.
|
|
450
|
+
// ---------------------------------------------------------------------------
|
|
451
|
+
|
|
452
|
+
/** `<prefix>.<group>`: an App ID prefix (no dots) then a non-empty group. */
|
|
453
|
+
const ACCESS_GROUP = /^[A-Za-z0-9]+\.[A-Za-z0-9.-]+$/;
|
|
454
|
+
|
|
455
|
+
let keychainDefaultAccessGroup: KeychainDefaultAccessGroup | null = null;
|
|
456
|
+
|
|
457
|
+
export async function loadKeychainDefaultAccessGroup(): Promise<KeychainDefaultAccessGroup> {
|
|
458
|
+
if (keychainDefaultAccessGroup) {
|
|
459
|
+
return keychainDefaultAccessGroup;
|
|
460
|
+
}
|
|
461
|
+
const native = requireOptionalNativeModule<Partial<OxyIdentityNativeModule>>('OxyIdentity');
|
|
462
|
+
const probe = native?.keychainDefaultAccessGroup;
|
|
463
|
+
if (!native || typeof probe !== 'function') {
|
|
464
|
+
keychainDefaultAccessGroup = { state: 'unsupported' };
|
|
465
|
+
return keychainDefaultAccessGroup;
|
|
466
|
+
}
|
|
467
|
+
let answer: unknown;
|
|
468
|
+
try {
|
|
469
|
+
answer = await probe.call(native);
|
|
470
|
+
} catch (cause) {
|
|
471
|
+
return { state: 'unavailable', cause };
|
|
472
|
+
}
|
|
473
|
+
if (answer === null || answer === undefined) {
|
|
474
|
+
// The native side runs only on iOS; anything else answers nothing.
|
|
475
|
+
keychainDefaultAccessGroup = { state: 'unsupported' };
|
|
476
|
+
return keychainDefaultAccessGroup;
|
|
477
|
+
}
|
|
478
|
+
if (typeof answer !== 'string' || !ACCESS_GROUP.test(answer)) {
|
|
479
|
+
return {
|
|
480
|
+
state: 'unavailable',
|
|
481
|
+
cause: new Error('keychain probe answered something that is not an access group'),
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
keychainDefaultAccessGroup = { state: 'resolved', accessGroup: answer };
|
|
485
|
+
return keychainDefaultAccessGroup;
|
|
486
|
+
}
|
package/src/platform/crypto.ts
CHANGED
|
@@ -80,6 +80,7 @@ import type {
|
|
|
80
80
|
CommonsSocialReceiveSignature,
|
|
81
81
|
ExpoCryptoLike,
|
|
82
82
|
ExpoSecureStoreLike,
|
|
83
|
+
KeychainDefaultAccessGroup,
|
|
83
84
|
} from './expoTypes';
|
|
84
85
|
|
|
85
86
|
// Re-export the interfaces so consumers can import them from the same
|
|
@@ -91,6 +92,7 @@ export type {
|
|
|
91
92
|
CommonsSocialReceiveSignature,
|
|
92
93
|
ExpoCryptoLike,
|
|
93
94
|
ExpoSecureStoreLike,
|
|
95
|
+
KeychainDefaultAccessGroup,
|
|
94
96
|
};
|
|
95
97
|
|
|
96
98
|
// ---------------------------------------------------------------------------
|
|
@@ -176,3 +178,11 @@ export { getRandomBytesRN } from './random';
|
|
|
176
178
|
export function loadCommonsIdentityBridge(): Promise<CommonsIdentityBridge | null> {
|
|
177
179
|
return Promise.resolve(null);
|
|
178
180
|
}
|
|
181
|
+
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
// iOS keychain default access group — native-only; off RN there is no keychain.
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
export function loadKeychainDefaultAccessGroup(): Promise<KeychainDefaultAccessGroup> {
|
|
187
|
+
return Promise.resolve({ state: 'unsupported' });
|
|
188
|
+
}
|
|
@@ -121,8 +121,16 @@ export interface CommonsSocialReceiveSignature {
|
|
|
121
121
|
* resolves `null` when Commons is not installed, holds no identity, or refuses
|
|
122
122
|
* the caller.
|
|
123
123
|
*
|
|
124
|
-
* On iOS
|
|
125
|
-
*
|
|
124
|
+
* On iOS these methods resolve `null`: the keychain access groups in
|
|
125
|
+
* `@oxy.so/core`'s `KeyManager` own iOS sharing (docs/identity/ios-keychain-access-groups.md).
|
|
126
|
+
*
|
|
127
|
+
* The three `*Retired*` methods (Commons protocol 4) reach a key the person,
|
|
128
|
+
* or the organization with `accountId`, rotated away from, by its public key,
|
|
129
|
+
* for SPENDING the coins still at its addresses: never for signing in. They are
|
|
130
|
+
* always present on the bridge and resolve `null` on a native module or a
|
|
131
|
+
* Commons that predates them; an answer that does not echo the key (and the
|
|
132
|
+
* account) asked for is refused, because the current key taken for a retired
|
|
133
|
+
* one is a payment signed by the wrong key.
|
|
126
134
|
*/
|
|
127
135
|
export interface CommonsIdentityBridge {
|
|
128
136
|
/**
|
|
@@ -149,4 +157,53 @@ export interface CommonsIdentityBridge {
|
|
|
149
157
|
digest: string,
|
|
150
158
|
accountId?: string,
|
|
151
159
|
): Promise<CommonsSocialReceiveSignature | null>;
|
|
160
|
+
/**
|
|
161
|
+
* The public keys (lowercase uncompressed SEC1 hex, de-duplicated) of the
|
|
162
|
+
* retired keys Commons holds for the person, or with `accountId` for that
|
|
163
|
+
* organization, minus the current one. `[]` when it holds none; `null` when
|
|
164
|
+
* Commons cannot say (absent, refused, or older than retired keys). Allowed
|
|
165
|
+
* for the wallet app only.
|
|
166
|
+
*/
|
|
167
|
+
listRetiredKeys(accountId?: string): Promise<string[] | null>;
|
|
168
|
+
/**
|
|
169
|
+
* {@link deriveScopedSeed} over the key of that scope whose public key is
|
|
170
|
+
* `publicKey` (current or retired), never another key: `null` when Commons
|
|
171
|
+
* does not hold it.
|
|
172
|
+
*/
|
|
173
|
+
deriveRetiredScopedSeed(
|
|
174
|
+
info: string,
|
|
175
|
+
publicKey: string,
|
|
176
|
+
accountId?: string,
|
|
177
|
+
): Promise<string | null>;
|
|
178
|
+
/**
|
|
179
|
+
* {@link signSocialReceive} with the social-receive child `index` of the key
|
|
180
|
+
* of that scope whose public key is `publicKey` (current or retired), never
|
|
181
|
+
* another key. Allowed for the wallet app only.
|
|
182
|
+
*/
|
|
183
|
+
signRetiredSocialReceive(
|
|
184
|
+
index: number,
|
|
185
|
+
digest: string,
|
|
186
|
+
publicKey: string,
|
|
187
|
+
accountId?: string,
|
|
188
|
+
): Promise<CommonsSocialReceiveSignature | null>;
|
|
152
189
|
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* What the iOS keychain says about this app's DEFAULT access group: the first
|
|
193
|
+
* entry of its `keychain-access-groups` entitlement, team prefix included
|
|
194
|
+
* (`ABCDE12345.so.oxy.identity`). The value is read back from a probe item the
|
|
195
|
+
* native module writes without a group, because JavaScript cannot see the
|
|
196
|
+
* entitlements or the team prefix (`$(AppIdentifierPrefix)` expands at build
|
|
197
|
+
* time), and `kSecAttrAccessGroup` must equal an entitlement value exactly.
|
|
198
|
+
*
|
|
199
|
+
* - `resolved`: the group string, as the keychain reported it.
|
|
200
|
+
* - `unsupported`: not iOS, or a native build whose `OxyIdentity` module
|
|
201
|
+
* predates the probe. Callers keep the single-group behaviour that build was
|
|
202
|
+
* made for.
|
|
203
|
+
* - `unavailable`: the probe threw (before first unlock, keychain refused).
|
|
204
|
+
* Transient; never cached.
|
|
205
|
+
*/
|
|
206
|
+
export type KeychainDefaultAccessGroup =
|
|
207
|
+
| { state: 'resolved'; accessGroup: string }
|
|
208
|
+
| { state: 'unsupported' }
|
|
209
|
+
| { state: 'unavailable'; cause: unknown };
|