@oxy.so/protocol 1.6.1 → 1.8.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 +83 -3
- package/dist/esm/index.js +1 -1
- package/dist/esm/platform/crypto.js +6 -0
- package/dist/esm/platform/crypto.native.js +82 -3
- 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 +45 -10
- package/package.json +2 -2
- package/src/__tests__/commonsIdentityBridge.test.ts +79 -0
- package/src/__tests__/keychainDefaultAccessGroup.test.ts +65 -0
- package/src/index.ts +2 -0
- package/src/platform/crypto.native.ts +119 -8
- package/src/platform/crypto.ts +10 -0
- package/src/platform/expoTypes.ts +45 -10
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");
|
|
@@ -151,6 +152,20 @@ function narrowDescription(value) {
|
|
|
151
152
|
return null;
|
|
152
153
|
return { v, publicKey };
|
|
153
154
|
}
|
|
155
|
+
/**
|
|
156
|
+
* The organization answer must name the organization asked for. A Commons that
|
|
157
|
+
* predates organization keys ignores the `accountId` extra and answers for the
|
|
158
|
+
* PERSONAL identity, without an echo; that answer is refused here, because a
|
|
159
|
+
* personal key taken for an organization's is money sent to the wrong person.
|
|
160
|
+
*/
|
|
161
|
+
function echoes(value, accountId) {
|
|
162
|
+
const r = record(value);
|
|
163
|
+
return r && r.accountId === accountId ? r : null;
|
|
164
|
+
}
|
|
165
|
+
function narrowOrganizationDescription(value, accountId) {
|
|
166
|
+
const description = narrowDescription(echoes(value, accountId));
|
|
167
|
+
return description ? { ...description, accountId } : null;
|
|
168
|
+
}
|
|
154
169
|
function narrowProof(value) {
|
|
155
170
|
const r = record(value);
|
|
156
171
|
const publicKey = hexField(r?.publicKey, 130);
|
|
@@ -191,12 +206,77 @@ function loadCommonsIdentityBridge() {
|
|
|
191
206
|
}
|
|
192
207
|
const m = native;
|
|
193
208
|
return {
|
|
194
|
-
describe: () =>
|
|
209
|
+
describe: (accountId) => {
|
|
210
|
+
if (accountId === undefined)
|
|
211
|
+
return ask(() => m.describe(), narrowDescription);
|
|
212
|
+
const describeOrganization = m.describeOrganization;
|
|
213
|
+
if (typeof describeOrganization !== 'function')
|
|
214
|
+
return Promise.resolve(null);
|
|
215
|
+
return ask(() => describeOrganization.call(m, accountId), (v) => narrowOrganizationDescription(v, accountId));
|
|
216
|
+
},
|
|
195
217
|
proveIdentity: (challenge) => ask(() => m.proveIdentity(challenge), narrowProof),
|
|
196
|
-
deriveScopedSeed: (info
|
|
197
|
-
|
|
218
|
+
deriveScopedSeed: (info, accountId) => {
|
|
219
|
+
if (accountId === undefined) {
|
|
220
|
+
return ask(() => m.deriveScopedSeed(info), (v) => hexField(v, 64));
|
|
221
|
+
}
|
|
222
|
+
const derive = m.deriveOrganizationScopedSeed;
|
|
223
|
+
if (typeof derive !== 'function')
|
|
224
|
+
return Promise.resolve(null);
|
|
225
|
+
return ask(() => derive.call(m, info, accountId), (v) => hexField(echoes(v, accountId)?.seed, 64));
|
|
226
|
+
},
|
|
227
|
+
signSocialReceive: (index, digest, accountId) => {
|
|
228
|
+
if (accountId === undefined) {
|
|
229
|
+
return ask(() => m.signSocialReceive(index, digest), narrowSocialReceive);
|
|
230
|
+
}
|
|
231
|
+
const sign = m.signOrganizationSocialReceive;
|
|
232
|
+
if (typeof sign !== 'function')
|
|
233
|
+
return Promise.resolve(null);
|
|
234
|
+
return ask(() => sign.call(m, index, digest, accountId), (v) => narrowSocialReceive(echoes(v, accountId)));
|
|
235
|
+
},
|
|
198
236
|
};
|
|
199
237
|
});
|
|
200
238
|
}
|
|
201
239
|
return commonsIdentityBridgePromise;
|
|
202
240
|
}
|
|
241
|
+
// ---------------------------------------------------------------------------
|
|
242
|
+
// iOS keychain default access group (docs/identity/ios-keychain-access-groups.md).
|
|
243
|
+
//
|
|
244
|
+
// Resolved once the native module answers; an `unavailable` probe (a thrown
|
|
245
|
+
// call: before first unlock, or the keychain refused) is not memoized, so the
|
|
246
|
+
// next caller asks again. The answer is narrowed to a plain string — it crossed
|
|
247
|
+
// the native boundary.
|
|
248
|
+
// ---------------------------------------------------------------------------
|
|
249
|
+
/** `<prefix>.<group>`: an App ID prefix (no dots) then a non-empty group. */
|
|
250
|
+
const ACCESS_GROUP = /^[A-Za-z0-9]+\.[A-Za-z0-9.-]+$/;
|
|
251
|
+
let keychainDefaultAccessGroup = null;
|
|
252
|
+
async function loadKeychainDefaultAccessGroup() {
|
|
253
|
+
if (keychainDefaultAccessGroup) {
|
|
254
|
+
return keychainDefaultAccessGroup;
|
|
255
|
+
}
|
|
256
|
+
const native = (0, expo_modules_core_1.requireOptionalNativeModule)('OxyIdentity');
|
|
257
|
+
const probe = native?.keychainDefaultAccessGroup;
|
|
258
|
+
if (!native || typeof probe !== 'function') {
|
|
259
|
+
keychainDefaultAccessGroup = { state: 'unsupported' };
|
|
260
|
+
return keychainDefaultAccessGroup;
|
|
261
|
+
}
|
|
262
|
+
let answer;
|
|
263
|
+
try {
|
|
264
|
+
answer = await probe.call(native);
|
|
265
|
+
}
|
|
266
|
+
catch (cause) {
|
|
267
|
+
return { state: 'unavailable', cause };
|
|
268
|
+
}
|
|
269
|
+
if (answer === null || answer === undefined) {
|
|
270
|
+
// The native side runs only on iOS; anything else answers nothing.
|
|
271
|
+
keychainDefaultAccessGroup = { state: 'unsupported' };
|
|
272
|
+
return keychainDefaultAccessGroup;
|
|
273
|
+
}
|
|
274
|
+
if (typeof answer !== 'string' || !ACCESS_GROUP.test(answer)) {
|
|
275
|
+
return {
|
|
276
|
+
state: 'unavailable',
|
|
277
|
+
cause: new Error('keychain probe answered something that is not an access group'),
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
keychainDefaultAccessGroup = { state: 'resolved', accessGroup: answer };
|
|
281
|
+
return keychainDefaultAccessGroup;
|
|
282
|
+
}
|
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
|
+
}
|
|
@@ -142,6 +142,20 @@ function narrowDescription(value) {
|
|
|
142
142
|
return null;
|
|
143
143
|
return { v, publicKey };
|
|
144
144
|
}
|
|
145
|
+
/**
|
|
146
|
+
* The organization answer must name the organization asked for. A Commons that
|
|
147
|
+
* predates organization keys ignores the `accountId` extra and answers for the
|
|
148
|
+
* PERSONAL identity, without an echo; that answer is refused here, because a
|
|
149
|
+
* personal key taken for an organization's is money sent to the wrong person.
|
|
150
|
+
*/
|
|
151
|
+
function echoes(value, accountId) {
|
|
152
|
+
const r = record(value);
|
|
153
|
+
return r && r.accountId === accountId ? r : null;
|
|
154
|
+
}
|
|
155
|
+
function narrowOrganizationDescription(value, accountId) {
|
|
156
|
+
const description = narrowDescription(echoes(value, accountId));
|
|
157
|
+
return description ? { ...description, accountId } : null;
|
|
158
|
+
}
|
|
145
159
|
function narrowProof(value) {
|
|
146
160
|
const r = record(value);
|
|
147
161
|
const publicKey = hexField(r?.publicKey, 130);
|
|
@@ -182,12 +196,77 @@ export function loadCommonsIdentityBridge() {
|
|
|
182
196
|
}
|
|
183
197
|
const m = native;
|
|
184
198
|
return {
|
|
185
|
-
describe: () =>
|
|
199
|
+
describe: (accountId) => {
|
|
200
|
+
if (accountId === undefined)
|
|
201
|
+
return ask(() => m.describe(), narrowDescription);
|
|
202
|
+
const describeOrganization = m.describeOrganization;
|
|
203
|
+
if (typeof describeOrganization !== 'function')
|
|
204
|
+
return Promise.resolve(null);
|
|
205
|
+
return ask(() => describeOrganization.call(m, accountId), (v) => narrowOrganizationDescription(v, accountId));
|
|
206
|
+
},
|
|
186
207
|
proveIdentity: (challenge) => ask(() => m.proveIdentity(challenge), narrowProof),
|
|
187
|
-
deriveScopedSeed: (info
|
|
188
|
-
|
|
208
|
+
deriveScopedSeed: (info, accountId) => {
|
|
209
|
+
if (accountId === undefined) {
|
|
210
|
+
return ask(() => m.deriveScopedSeed(info), (v) => hexField(v, 64));
|
|
211
|
+
}
|
|
212
|
+
const derive = m.deriveOrganizationScopedSeed;
|
|
213
|
+
if (typeof derive !== 'function')
|
|
214
|
+
return Promise.resolve(null);
|
|
215
|
+
return ask(() => derive.call(m, info, accountId), (v) => hexField(echoes(v, accountId)?.seed, 64));
|
|
216
|
+
},
|
|
217
|
+
signSocialReceive: (index, digest, accountId) => {
|
|
218
|
+
if (accountId === undefined) {
|
|
219
|
+
return ask(() => m.signSocialReceive(index, digest), narrowSocialReceive);
|
|
220
|
+
}
|
|
221
|
+
const sign = m.signOrganizationSocialReceive;
|
|
222
|
+
if (typeof sign !== 'function')
|
|
223
|
+
return Promise.resolve(null);
|
|
224
|
+
return ask(() => sign.call(m, index, digest, accountId), (v) => narrowSocialReceive(echoes(v, accountId)));
|
|
225
|
+
},
|
|
189
226
|
};
|
|
190
227
|
});
|
|
191
228
|
}
|
|
192
229
|
return commonsIdentityBridgePromise;
|
|
193
230
|
}
|
|
231
|
+
// ---------------------------------------------------------------------------
|
|
232
|
+
// iOS keychain default access group (docs/identity/ios-keychain-access-groups.md).
|
|
233
|
+
//
|
|
234
|
+
// Resolved once the native module answers; an `unavailable` probe (a thrown
|
|
235
|
+
// call: before first unlock, or the keychain refused) is not memoized, so the
|
|
236
|
+
// next caller asks again. The answer is narrowed to a plain string — it crossed
|
|
237
|
+
// the native boundary.
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
/** `<prefix>.<group>`: an App ID prefix (no dots) then a non-empty group. */
|
|
240
|
+
const ACCESS_GROUP = /^[A-Za-z0-9]+\.[A-Za-z0-9.-]+$/;
|
|
241
|
+
let keychainDefaultAccessGroup = null;
|
|
242
|
+
export async function loadKeychainDefaultAccessGroup() {
|
|
243
|
+
if (keychainDefaultAccessGroup) {
|
|
244
|
+
return keychainDefaultAccessGroup;
|
|
245
|
+
}
|
|
246
|
+
const native = requireOptionalNativeModule('OxyIdentity');
|
|
247
|
+
const probe = native?.keychainDefaultAccessGroup;
|
|
248
|
+
if (!native || typeof probe !== 'function') {
|
|
249
|
+
keychainDefaultAccessGroup = { state: 'unsupported' };
|
|
250
|
+
return keychainDefaultAccessGroup;
|
|
251
|
+
}
|
|
252
|
+
let answer;
|
|
253
|
+
try {
|
|
254
|
+
answer = await probe.call(native);
|
|
255
|
+
}
|
|
256
|
+
catch (cause) {
|
|
257
|
+
return { state: 'unavailable', cause };
|
|
258
|
+
}
|
|
259
|
+
if (answer === null || answer === undefined) {
|
|
260
|
+
// The native side runs only on iOS; anything else answers nothing.
|
|
261
|
+
keychainDefaultAccessGroup = { state: 'unsupported' };
|
|
262
|
+
return keychainDefaultAccessGroup;
|
|
263
|
+
}
|
|
264
|
+
if (typeof answer !== 'string' || !ACCESS_GROUP.test(answer)) {
|
|
265
|
+
return {
|
|
266
|
+
state: 'unavailable',
|
|
267
|
+
cause: new Error('keychain probe answered something that is not an access group'),
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
keychainDefaultAccessGroup = { state: 'resolved', accessGroup: answer };
|
|
271
|
+
return keychainDefaultAccessGroup;
|
|
272
|
+
}
|
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>;
|
|
@@ -79,6 +79,12 @@ export interface CommonsIdentityDescription {
|
|
|
79
79
|
v: number;
|
|
80
80
|
/** The identity's public key, lowercase uncompressed SEC1 hex. */
|
|
81
81
|
publicKey: string;
|
|
82
|
+
/**
|
|
83
|
+
* Set only on an answer for an ORGANIZATION key (ADR 0061 D6): the account id
|
|
84
|
+
* Commons echoed, always equal to the one asked for. An answer without it is
|
|
85
|
+
* the personal identity and is never taken as an organization's.
|
|
86
|
+
*/
|
|
87
|
+
accountId?: string;
|
|
82
88
|
}
|
|
83
89
|
/**
|
|
84
90
|
* A signed server challenge from Commons (`proveIdentity`). The fields plug
|
|
@@ -109,23 +115,52 @@ export interface CommonsSocialReceiveSignature {
|
|
|
109
115
|
* resolves `null` when Commons is not installed, holds no identity, or refuses
|
|
110
116
|
* the caller.
|
|
111
117
|
*
|
|
112
|
-
* On iOS
|
|
113
|
-
*
|
|
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).
|
|
114
120
|
*/
|
|
115
121
|
export interface CommonsIdentityBridge {
|
|
116
|
-
/**
|
|
117
|
-
|
|
122
|
+
/**
|
|
123
|
+
* The public key of the identity Commons holds, or, with `accountId`, of that
|
|
124
|
+
* organization's key (ADR 0061 D6).
|
|
125
|
+
*/
|
|
126
|
+
describe(accountId?: string): Promise<CommonsIdentityDescription | null>;
|
|
118
127
|
/** Sign a server challenge (64 lowercase hex) for `POST /auth/verify`. */
|
|
119
128
|
proveIdentity(challenge: string): Promise<CommonsIdentityProof | null>;
|
|
120
129
|
/**
|
|
121
|
-
* HKDF-SHA256 over the identity key,
|
|
122
|
-
*
|
|
123
|
-
* Commons allows each caller package only
|
|
130
|
+
* HKDF-SHA256 over the identity key (or, with `accountId`, the organization's
|
|
131
|
+
* key), 32 bytes as lowercase hex, identical to `KeyManager.deriveScopedSeed`
|
|
132
|
+
* on the device that holds the key. Commons allows each caller package only
|
|
133
|
+
* its own `info` labels.
|
|
124
134
|
*/
|
|
125
|
-
deriveScopedSeed(info: string): Promise<string | null>;
|
|
135
|
+
deriveScopedSeed(info: string, accountId?: string): Promise<string | null>;
|
|
126
136
|
/**
|
|
127
137
|
* Sign a 32-byte digest (64 hex) with the social-receive child key `index`
|
|
128
|
-
* of the identity
|
|
138
|
+
* of the identity, or with `accountId`, of that organization's key. Allowed
|
|
139
|
+
* for the wallet app only.
|
|
129
140
|
*/
|
|
130
|
-
signSocialReceive(index: number, digest: string): Promise<CommonsSocialReceiveSignature | null>;
|
|
141
|
+
signSocialReceive(index: number, digest: string, accountId?: string): Promise<CommonsSocialReceiveSignature | null>;
|
|
131
142
|
}
|
|
143
|
+
/**
|
|
144
|
+
* What the iOS keychain says about this app's DEFAULT access group: the first
|
|
145
|
+
* entry of its `keychain-access-groups` entitlement, team prefix included
|
|
146
|
+
* (`ABCDE12345.so.oxy.identity`). The value is read back from a probe item the
|
|
147
|
+
* native module writes without a group, because JavaScript cannot see the
|
|
148
|
+
* entitlements or the team prefix (`$(AppIdentifierPrefix)` expands at build
|
|
149
|
+
* time), and `kSecAttrAccessGroup` must equal an entitlement value exactly.
|
|
150
|
+
*
|
|
151
|
+
* - `resolved`: the group string, as the keychain reported it.
|
|
152
|
+
* - `unsupported`: not iOS, or a native build whose `OxyIdentity` module
|
|
153
|
+
* predates the probe. Callers keep the single-group behaviour that build was
|
|
154
|
+
* made for.
|
|
155
|
+
* - `unavailable`: the probe threw (before first unlock, keychain refused).
|
|
156
|
+
* Transient; never cached.
|
|
157
|
+
*/
|
|
158
|
+
export type KeychainDefaultAccessGroup = {
|
|
159
|
+
state: 'resolved';
|
|
160
|
+
accessGroup: string;
|
|
161
|
+
} | {
|
|
162
|
+
state: 'unsupported';
|
|
163
|
+
} | {
|
|
164
|
+
state: 'unavailable';
|
|
165
|
+
cause: unknown;
|
|
166
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oxy.so/protocol",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.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.11.0"
|
|
137
137
|
},
|
|
138
138
|
"peerDependencies": {
|
|
139
139
|
"@react-native-async-storage/async-storage": "<4.0.0",
|
|
@@ -87,6 +87,85 @@ describe('loadCommonsIdentityBridge (native)', () => {
|
|
|
87
87
|
await expect(bridge?.signSocialReceive(0, '11'.repeat(32))).resolves.toBeNull();
|
|
88
88
|
});
|
|
89
89
|
|
|
90
|
+
test('organization requests go to the organization methods and must echo the account', async () => {
|
|
91
|
+
const ORG = '0123456789abcdef01234567';
|
|
92
|
+
const calls: unknown[][] = [];
|
|
93
|
+
mockNative({
|
|
94
|
+
describe: async () => ({ v: 2, publicKey: PUBLIC_KEY }),
|
|
95
|
+
proveIdentity: async () => null,
|
|
96
|
+
deriveScopedSeed: async () => 'cd'.repeat(32),
|
|
97
|
+
signSocialReceive: async () => ({ signature: '3045bb', publicKey: `02${'ef'.repeat(32)}` }),
|
|
98
|
+
describeOrganization: async (accountId: string) => {
|
|
99
|
+
calls.push(['describeOrganization', accountId]);
|
|
100
|
+
return { v: 3, publicKey: PUBLIC_KEY, accountId };
|
|
101
|
+
},
|
|
102
|
+
deriveOrganizationScopedSeed: async (info: string, accountId: string) => {
|
|
103
|
+
calls.push(['deriveOrganizationScopedSeed', info, accountId]);
|
|
104
|
+
return { seed: 'ee'.repeat(32), accountId };
|
|
105
|
+
},
|
|
106
|
+
signOrganizationSocialReceive: async (index: number, digest: string, accountId: string) => {
|
|
107
|
+
calls.push(['signOrganizationSocialReceive', index, digest, accountId]);
|
|
108
|
+
return { signature: '3046cc', publicKey: `03${'aa'.repeat(32)}`, accountId };
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
const bridge = await loadBridge();
|
|
112
|
+
await expect(bridge?.describe(ORG)).resolves.toEqual({
|
|
113
|
+
v: 3,
|
|
114
|
+
publicKey: PUBLIC_KEY,
|
|
115
|
+
accountId: ORG,
|
|
116
|
+
});
|
|
117
|
+
await expect(bridge?.deriveScopedSeed('peable/faircoin/v1', ORG)).resolves.toBe(
|
|
118
|
+
'ee'.repeat(32),
|
|
119
|
+
);
|
|
120
|
+
await expect(bridge?.signSocialReceive(4, '11'.repeat(32), ORG)).resolves.toEqual({
|
|
121
|
+
signature: '3046cc',
|
|
122
|
+
publicKey: `03${'aa'.repeat(32)}`,
|
|
123
|
+
});
|
|
124
|
+
// The personal path is untouched.
|
|
125
|
+
await expect(bridge?.describe()).resolves.toEqual({ v: 2, publicKey: PUBLIC_KEY });
|
|
126
|
+
expect(calls).toEqual([
|
|
127
|
+
['describeOrganization', ORG],
|
|
128
|
+
['deriveOrganizationScopedSeed', 'peable/faircoin/v1', ORG],
|
|
129
|
+
['signOrganizationSocialReceive', 4, '11'.repeat(32), ORG],
|
|
130
|
+
]);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('an organization answer without the echo, or for another account, is null', async () => {
|
|
134
|
+
const ORG = '0123456789abcdef01234567';
|
|
135
|
+
mockNative({
|
|
136
|
+
describe: async () => ({ v: 2, publicKey: PUBLIC_KEY }),
|
|
137
|
+
proveIdentity: async () => null,
|
|
138
|
+
deriveScopedSeed: async () => 'cd'.repeat(32),
|
|
139
|
+
signSocialReceive: async () => null,
|
|
140
|
+
// An old Commons ignored the extra and answered for the personal key.
|
|
141
|
+
describeOrganization: async () => ({ v: 2, publicKey: PUBLIC_KEY }),
|
|
142
|
+
deriveOrganizationScopedSeed: async () => ({ seed: 'ee'.repeat(32), accountId: 'other' }),
|
|
143
|
+
signOrganizationSocialReceive: async () => ({
|
|
144
|
+
signature: '3046cc',
|
|
145
|
+
publicKey: `03${'aa'.repeat(32)}`,
|
|
146
|
+
}),
|
|
147
|
+
});
|
|
148
|
+
const bridge = await loadBridge();
|
|
149
|
+
await expect(bridge?.describe(ORG)).resolves.toBeNull();
|
|
150
|
+
await expect(bridge?.deriveScopedSeed('peable/faircoin/v1', ORG)).resolves.toBeNull();
|
|
151
|
+
await expect(bridge?.signSocialReceive(0, '11'.repeat(32), ORG)).resolves.toBeNull();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('a native module without the organization methods answers no organization', async () => {
|
|
155
|
+
mockNative({
|
|
156
|
+
describe: async () => ({ v: 2, publicKey: PUBLIC_KEY }),
|
|
157
|
+
proveIdentity: async () => null,
|
|
158
|
+
deriveScopedSeed: async () => 'cd'.repeat(32),
|
|
159
|
+
signSocialReceive: async () => ({ signature: '3045bb', publicKey: `02${'ef'.repeat(32)}` }),
|
|
160
|
+
});
|
|
161
|
+
const bridge = await loadBridge();
|
|
162
|
+
await expect(bridge?.describe('0123456789abcdef01234567')).resolves.toBeNull();
|
|
163
|
+
await expect(bridge?.deriveScopedSeed('x', '0123456789abcdef01234567')).resolves.toBeNull();
|
|
164
|
+
await expect(
|
|
165
|
+
bridge?.signSocialReceive(0, '11'.repeat(32), '0123456789abcdef01234567'),
|
|
166
|
+
).resolves.toBeNull();
|
|
167
|
+
});
|
|
168
|
+
|
|
90
169
|
test('the web / Node variant never has a bridge', async () => {
|
|
91
170
|
const mod = await import('../platform/crypto');
|
|
92
171
|
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
|
// ---------------------------------------------------------------------------
|
|
@@ -204,6 +206,25 @@ interface OxyIdentityNativeModule {
|
|
|
204
206
|
proveIdentity(challenge: string): Promise<unknown>;
|
|
205
207
|
deriveScopedSeed(info: string): Promise<unknown>;
|
|
206
208
|
signSocialReceive(index: number, digest: string): Promise<unknown>;
|
|
209
|
+
/**
|
|
210
|
+
* Organization keys (ADR 0061 D6), added in `@oxy.so/services` after the four
|
|
211
|
+
* above: an older native module lacks them, and every organization request
|
|
212
|
+
* then answers `null`, never the personal identity. Each answer echoes the
|
|
213
|
+
* `accountId` Commons answered for.
|
|
214
|
+
*/
|
|
215
|
+
describeOrganization?(accountId: string): Promise<unknown>;
|
|
216
|
+
deriveOrganizationScopedSeed?(info: string, accountId: string): Promise<unknown>;
|
|
217
|
+
signOrganizationSocialReceive?(
|
|
218
|
+
index: number,
|
|
219
|
+
digest: string,
|
|
220
|
+
accountId: string,
|
|
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>;
|
|
207
228
|
}
|
|
208
229
|
|
|
209
230
|
const LOWER_HEX = /^[0-9a-f]+$/;
|
|
@@ -226,6 +247,25 @@ function narrowDescription(value: unknown): CommonsIdentityDescription | null {
|
|
|
226
247
|
return { v, publicKey };
|
|
227
248
|
}
|
|
228
249
|
|
|
250
|
+
/**
|
|
251
|
+
* The organization answer must name the organization asked for. A Commons that
|
|
252
|
+
* predates organization keys ignores the `accountId` extra and answers for the
|
|
253
|
+
* PERSONAL identity, without an echo; that answer is refused here, because a
|
|
254
|
+
* personal key taken for an organization's is money sent to the wrong person.
|
|
255
|
+
*/
|
|
256
|
+
function echoes(value: unknown, accountId: string): Record<string, unknown> | null {
|
|
257
|
+
const r = record(value);
|
|
258
|
+
return r && r.accountId === accountId ? r : null;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function narrowOrganizationDescription(
|
|
262
|
+
value: unknown,
|
|
263
|
+
accountId: string,
|
|
264
|
+
): CommonsIdentityDescription | null {
|
|
265
|
+
const description = narrowDescription(echoes(value, accountId));
|
|
266
|
+
return description ? { ...description, accountId } : null;
|
|
267
|
+
}
|
|
268
|
+
|
|
229
269
|
function narrowProof(value: unknown): CommonsIdentityProof | null {
|
|
230
270
|
const r = record(value);
|
|
231
271
|
const publicKey = hexField(r?.publicKey, 130);
|
|
@@ -272,17 +312,88 @@ export function loadCommonsIdentityBridge(): Promise<CommonsIdentityBridge | nul
|
|
|
272
312
|
}
|
|
273
313
|
const m = native as OxyIdentityNativeModule;
|
|
274
314
|
return {
|
|
275
|
-
describe: (
|
|
315
|
+
describe: (accountId?: string) => {
|
|
316
|
+
if (accountId === undefined) return ask(() => m.describe(), narrowDescription);
|
|
317
|
+
const describeOrganization = m.describeOrganization;
|
|
318
|
+
if (typeof describeOrganization !== 'function') return Promise.resolve(null);
|
|
319
|
+
return ask(
|
|
320
|
+
() => describeOrganization.call(m, accountId),
|
|
321
|
+
(v) => narrowOrganizationDescription(v, accountId),
|
|
322
|
+
);
|
|
323
|
+
},
|
|
276
324
|
proveIdentity: (challenge: string) => ask(() => m.proveIdentity(challenge), narrowProof),
|
|
277
|
-
deriveScopedSeed: (info: string) =>
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
325
|
+
deriveScopedSeed: (info: string, accountId?: string) => {
|
|
326
|
+
if (accountId === undefined) {
|
|
327
|
+
return ask(
|
|
328
|
+
() => m.deriveScopedSeed(info),
|
|
329
|
+
(v) => hexField(v, 64),
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
const derive = m.deriveOrganizationScopedSeed;
|
|
333
|
+
if (typeof derive !== 'function') return Promise.resolve(null);
|
|
334
|
+
return ask(
|
|
335
|
+
() => derive.call(m, info, accountId),
|
|
336
|
+
(v) => hexField(echoes(v, accountId)?.seed, 64),
|
|
337
|
+
);
|
|
338
|
+
},
|
|
339
|
+
signSocialReceive: (index: number, digest: string, accountId?: string) => {
|
|
340
|
+
if (accountId === undefined) {
|
|
341
|
+
return ask(() => m.signSocialReceive(index, digest), narrowSocialReceive);
|
|
342
|
+
}
|
|
343
|
+
const sign = m.signOrganizationSocialReceive;
|
|
344
|
+
if (typeof sign !== 'function') return Promise.resolve(null);
|
|
345
|
+
return ask(
|
|
346
|
+
() => sign.call(m, index, digest, accountId),
|
|
347
|
+
(v) => narrowSocialReceive(echoes(v, accountId)),
|
|
348
|
+
);
|
|
349
|
+
},
|
|
284
350
|
} satisfies CommonsIdentityBridge;
|
|
285
351
|
});
|
|
286
352
|
}
|
|
287
353
|
return commonsIdentityBridgePromise;
|
|
288
354
|
}
|
|
355
|
+
|
|
356
|
+
// ---------------------------------------------------------------------------
|
|
357
|
+
// iOS keychain default access group (docs/identity/ios-keychain-access-groups.md).
|
|
358
|
+
//
|
|
359
|
+
// Resolved once the native module answers; an `unavailable` probe (a thrown
|
|
360
|
+
// call: before first unlock, or the keychain refused) is not memoized, so the
|
|
361
|
+
// next caller asks again. The answer is narrowed to a plain string — it crossed
|
|
362
|
+
// the native boundary.
|
|
363
|
+
// ---------------------------------------------------------------------------
|
|
364
|
+
|
|
365
|
+
/** `<prefix>.<group>`: an App ID prefix (no dots) then a non-empty group. */
|
|
366
|
+
const ACCESS_GROUP = /^[A-Za-z0-9]+\.[A-Za-z0-9.-]+$/;
|
|
367
|
+
|
|
368
|
+
let keychainDefaultAccessGroup: KeychainDefaultAccessGroup | null = null;
|
|
369
|
+
|
|
370
|
+
export async function loadKeychainDefaultAccessGroup(): Promise<KeychainDefaultAccessGroup> {
|
|
371
|
+
if (keychainDefaultAccessGroup) {
|
|
372
|
+
return keychainDefaultAccessGroup;
|
|
373
|
+
}
|
|
374
|
+
const native = requireOptionalNativeModule<Partial<OxyIdentityNativeModule>>('OxyIdentity');
|
|
375
|
+
const probe = native?.keychainDefaultAccessGroup;
|
|
376
|
+
if (!native || typeof probe !== 'function') {
|
|
377
|
+
keychainDefaultAccessGroup = { state: 'unsupported' };
|
|
378
|
+
return keychainDefaultAccessGroup;
|
|
379
|
+
}
|
|
380
|
+
let answer: unknown;
|
|
381
|
+
try {
|
|
382
|
+
answer = await probe.call(native);
|
|
383
|
+
} catch (cause) {
|
|
384
|
+
return { state: 'unavailable', cause };
|
|
385
|
+
}
|
|
386
|
+
if (answer === null || answer === undefined) {
|
|
387
|
+
// The native side runs only on iOS; anything else answers nothing.
|
|
388
|
+
keychainDefaultAccessGroup = { state: 'unsupported' };
|
|
389
|
+
return keychainDefaultAccessGroup;
|
|
390
|
+
}
|
|
391
|
+
if (typeof answer !== 'string' || !ACCESS_GROUP.test(answer)) {
|
|
392
|
+
return {
|
|
393
|
+
state: 'unavailable',
|
|
394
|
+
cause: new Error('keychain probe answered something that is not an access group'),
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
keychainDefaultAccessGroup = { state: 'resolved', accessGroup: answer };
|
|
398
|
+
return keychainDefaultAccessGroup;
|
|
399
|
+
}
|
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
|
+
}
|
|
@@ -82,6 +82,12 @@ export interface CommonsIdentityDescription {
|
|
|
82
82
|
v: number;
|
|
83
83
|
/** The identity's public key, lowercase uncompressed SEC1 hex. */
|
|
84
84
|
publicKey: string;
|
|
85
|
+
/**
|
|
86
|
+
* Set only on an answer for an ORGANIZATION key (ADR 0061 D6): the account id
|
|
87
|
+
* Commons echoed, always equal to the one asked for. An answer without it is
|
|
88
|
+
* the personal identity and is never taken as an organization's.
|
|
89
|
+
*/
|
|
90
|
+
accountId?: string;
|
|
85
91
|
}
|
|
86
92
|
|
|
87
93
|
/**
|
|
@@ -115,23 +121,52 @@ export interface CommonsSocialReceiveSignature {
|
|
|
115
121
|
* resolves `null` when Commons is not installed, holds no identity, or refuses
|
|
116
122
|
* the caller.
|
|
117
123
|
*
|
|
118
|
-
* On iOS
|
|
119
|
-
*
|
|
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).
|
|
120
126
|
*/
|
|
121
127
|
export interface CommonsIdentityBridge {
|
|
122
|
-
/**
|
|
123
|
-
|
|
128
|
+
/**
|
|
129
|
+
* The public key of the identity Commons holds, or, with `accountId`, of that
|
|
130
|
+
* organization's key (ADR 0061 D6).
|
|
131
|
+
*/
|
|
132
|
+
describe(accountId?: string): Promise<CommonsIdentityDescription | null>;
|
|
124
133
|
/** Sign a server challenge (64 lowercase hex) for `POST /auth/verify`. */
|
|
125
134
|
proveIdentity(challenge: string): Promise<CommonsIdentityProof | null>;
|
|
126
135
|
/**
|
|
127
|
-
* HKDF-SHA256 over the identity key,
|
|
128
|
-
*
|
|
129
|
-
* Commons allows each caller package only
|
|
136
|
+
* HKDF-SHA256 over the identity key (or, with `accountId`, the organization's
|
|
137
|
+
* key), 32 bytes as lowercase hex, identical to `KeyManager.deriveScopedSeed`
|
|
138
|
+
* on the device that holds the key. Commons allows each caller package only
|
|
139
|
+
* its own `info` labels.
|
|
130
140
|
*/
|
|
131
|
-
deriveScopedSeed(info: string): Promise<string | null>;
|
|
141
|
+
deriveScopedSeed(info: string, accountId?: string): Promise<string | null>;
|
|
132
142
|
/**
|
|
133
143
|
* Sign a 32-byte digest (64 hex) with the social-receive child key `index`
|
|
134
|
-
* of the identity
|
|
144
|
+
* of the identity, or with `accountId`, of that organization's key. Allowed
|
|
145
|
+
* for the wallet app only.
|
|
135
146
|
*/
|
|
136
|
-
signSocialReceive(
|
|
147
|
+
signSocialReceive(
|
|
148
|
+
index: number,
|
|
149
|
+
digest: string,
|
|
150
|
+
accountId?: string,
|
|
151
|
+
): Promise<CommonsSocialReceiveSignature | null>;
|
|
137
152
|
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* What the iOS keychain says about this app's DEFAULT access group: the first
|
|
156
|
+
* entry of its `keychain-access-groups` entitlement, team prefix included
|
|
157
|
+
* (`ABCDE12345.so.oxy.identity`). The value is read back from a probe item the
|
|
158
|
+
* native module writes without a group, because JavaScript cannot see the
|
|
159
|
+
* entitlements or the team prefix (`$(AppIdentifierPrefix)` expands at build
|
|
160
|
+
* time), and `kSecAttrAccessGroup` must equal an entitlement value exactly.
|
|
161
|
+
*
|
|
162
|
+
* - `resolved`: the group string, as the keychain reported it.
|
|
163
|
+
* - `unsupported`: not iOS, or a native build whose `OxyIdentity` module
|
|
164
|
+
* predates the probe. Callers keep the single-group behaviour that build was
|
|
165
|
+
* made for.
|
|
166
|
+
* - `unavailable`: the probe threw (before first unlock, keychain refused).
|
|
167
|
+
* Transient; never cached.
|
|
168
|
+
*/
|
|
169
|
+
export type KeychainDefaultAccessGroup =
|
|
170
|
+
| { state: 'resolved'; accessGroup: string }
|
|
171
|
+
| { state: 'unsupported' }
|
|
172
|
+
| { state: 'unavailable'; cause: unknown };
|