@oxy.so/protocol 1.7.0 → 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 +43 -0
- package/dist/esm/index.js +1 -1
- package/dist/esm/platform/crypto.js +6 -0
- package/dist/esm/platform/crypto.native.js +42 -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 +26 -2
- package/package.json +1 -1
- package/src/__tests__/keychainDefaultAccessGroup.test.ts +65 -0
- package/src/index.ts +2 -0
- package/src/platform/crypto.native.ts +53 -0
- package/src/platform/crypto.ts +10 -0
- package/src/platform/expoTypes.ts +22 -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");
|
|
@@ -237,3 +238,45 @@ function loadCommonsIdentityBridge() {
|
|
|
237
238
|
}
|
|
238
239
|
return commonsIdentityBridgePromise;
|
|
239
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
|
+
}
|
|
@@ -228,3 +228,45 @@ export function loadCommonsIdentityBridge() {
|
|
|
228
228
|
}
|
|
229
229
|
return commonsIdentityBridgePromise;
|
|
230
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>;
|
|
@@ -115,8 +115,8 @@ 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
120
|
*/
|
|
121
121
|
export interface CommonsIdentityBridge {
|
|
122
122
|
/**
|
|
@@ -140,3 +140,27 @@ export interface CommonsIdentityBridge {
|
|
|
140
140
|
*/
|
|
141
141
|
signSocialReceive(index: number, digest: string, accountId?: string): Promise<CommonsSocialReceiveSignature | null>;
|
|
142
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",
|
|
@@ -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,6 +219,12 @@ 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>;
|
|
220
228
|
}
|
|
221
229
|
|
|
222
230
|
const LOWER_HEX = /^[0-9a-f]+$/;
|
|
@@ -344,3 +352,48 @@ export function loadCommonsIdentityBridge(): Promise<CommonsIdentityBridge | nul
|
|
|
344
352
|
}
|
|
345
353
|
return commonsIdentityBridgePromise;
|
|
346
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
|
+
}
|
|
@@ -121,8 +121,8 @@ 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
126
|
*/
|
|
127
127
|
export interface CommonsIdentityBridge {
|
|
128
128
|
/**
|
|
@@ -150,3 +150,23 @@ export interface CommonsIdentityBridge {
|
|
|
150
150
|
accountId?: string,
|
|
151
151
|
): Promise<CommonsSocialReceiveSignature | null>;
|
|
152
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 };
|