@oxy.so/core 7.31.0 → 7.32.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/api/identity.js +146 -23
- package/dist/cjs/crypto/identityMarker.js +15 -0
- package/dist/cjs/crypto/index.js +7 -2
- package/dist/cjs/crypto/keyManager.js +7 -10
- package/dist/cjs/crypto/keychainGroupMigration.js +327 -160
- package/dist/cjs/crypto/organizationKeys.js +33 -5
- package/dist/cjs/crypto/retiredKeys.js +80 -18
- package/dist/cjs/utils/keychainAccessGroups.js +111 -25
- package/dist/esm/api/identity.js +146 -23
- package/dist/esm/crypto/identityMarker.js +14 -0
- package/dist/esm/crypto/index.js +3 -3
- package/dist/esm/crypto/keyManager.js +9 -12
- package/dist/esm/crypto/keychainGroupMigration.js +327 -162
- package/dist/esm/crypto/organizationKeys.js +34 -6
- package/dist/esm/crypto/retiredKeys.js +76 -17
- package/dist/esm/utils/keychainAccessGroups.js +107 -24
- package/dist/types/api/identity.d.ts +36 -0
- package/dist/types/crypto/identityMarker.d.ts +8 -0
- package/dist/types/crypto/index.d.ts +3 -3
- package/dist/types/crypto/keychainGroupMigration.d.ts +28 -6
- package/dist/types/crypto/organizationKeys.d.ts +4 -2
- package/dist/types/crypto/retiredKeys.d.ts +30 -4
- package/dist/types/utils/keychainAccessGroups.d.ts +41 -11
- package/package.json +1 -1
- package/src/api/identity.ts +185 -40
- package/src/crypto/identityMarker.ts +15 -0
- package/src/crypto/index.ts +8 -1
- package/src/crypto/keyManager.ts +9 -15
- package/src/crypto/keychainGroupMigration.ts +390 -185
- package/src/crypto/organizationKeys.ts +38 -6
- package/src/crypto/retiredKeys.ts +87 -17
- package/src/utils/keychainAccessGroups.ts +121 -24
|
@@ -46,6 +46,7 @@ const keychainAccessGroups_1 = require("../utils/keychainAccessGroups");
|
|
|
46
46
|
const platform_1 = require("../utils/platform");
|
|
47
47
|
const identitySigner_1 = require("./identitySigner");
|
|
48
48
|
const keychainGroupMigration_1 = require("./keychainGroupMigration");
|
|
49
|
+
const retiredKeys_1 = require("./retiredKeys");
|
|
49
50
|
const keychainItems_1 = require("./keychainItems");
|
|
50
51
|
Object.defineProperty(exports, "isOrganizationAccountId", { enumerable: true, get: function () { return keychainItems_1.isOrganizationAccountId; } });
|
|
51
52
|
const ORGANIZATION_PHRASE_WORDS = 24;
|
|
@@ -115,9 +116,7 @@ async function organizationKeyFromPhrase(phrase) {
|
|
|
115
116
|
async function secureStore(item) {
|
|
116
117
|
const raw = await (0, protocol_1.loadSecureStore)();
|
|
117
118
|
const access = await (0, keychainAccessGroups_1.resolveKeychainAccess)();
|
|
118
|
-
|
|
119
|
-
await (0, keychainGroupMigration_1.ensureKeychainGroupsMigrated)();
|
|
120
|
-
}
|
|
119
|
+
await (0, keychainGroupMigration_1.prepareKeychainAccess)(access);
|
|
121
120
|
return (0, keychainAccessGroups_1.groupedKeychainStore)(raw, access, item);
|
|
122
121
|
}
|
|
123
122
|
const pairStore = () => secureStore('spending-key');
|
|
@@ -208,8 +207,10 @@ async function readLocalPair(accountId) {
|
|
|
208
207
|
* it; on iOS only Commons holds the groups it writes (any other app throws).
|
|
209
208
|
*
|
|
210
209
|
* Refuses a pair whose public key does not derive from its private key, and a
|
|
211
|
-
* phrase that does not derive the pair.
|
|
212
|
-
* the organization
|
|
210
|
+
* phrase that does not derive the pair. A DIFFERENT key this device already
|
|
211
|
+
* held for the organization is first kept in the organization's retired ring
|
|
212
|
+
* (ADR 0061 D7 as amended; R4-12): coins under it still need it. When that
|
|
213
|
+
* cannot be confirmed nothing is overwritten and this throws.
|
|
213
214
|
*/
|
|
214
215
|
async function storeOrganizationKey(accountId, key) {
|
|
215
216
|
assertAccountId(accountId);
|
|
@@ -231,6 +232,18 @@ async function storeOrganizationKey(accountId, key) {
|
|
|
231
232
|
throw new Error('On Android only Commons holds organization keys; this app has no identity signer store for them.');
|
|
232
233
|
}
|
|
233
234
|
const store = await pairStore();
|
|
235
|
+
const previous = await heldPrivateKey(store, accountId);
|
|
236
|
+
if (previous) {
|
|
237
|
+
const previousPublic = (0, secp256k1_1.deriveSecp256k1PublicKey)(previous);
|
|
238
|
+
if (previousPublic !== publicKey) {
|
|
239
|
+
try {
|
|
240
|
+
await (0, retiredKeys_1.retireIdentityKey)({ privateKey: previous, publicKey: previousPublic }, { accountId });
|
|
241
|
+
}
|
|
242
|
+
catch (error) {
|
|
243
|
+
throw new Error(`organization key: the key this device held could not be kept as a previous key, so it was not replaced (${error instanceof Error ? error.message : 'unknown'})`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
234
247
|
await store.setItemAsync((0, keychainItems_1.organizationPrivateKeyName)(accountId), privateKey, pairOptions(store, true));
|
|
235
248
|
await store.setItemAsync((0, keychainItems_1.organizationPublicKeyName)(accountId), publicKey, pairOptions(store, false));
|
|
236
249
|
if (signer?.writeOrganization &&
|
|
@@ -311,6 +324,21 @@ async function listOrganizationKeys() {
|
|
|
311
324
|
return [];
|
|
312
325
|
return readIndex(await indexStore());
|
|
313
326
|
}
|
|
327
|
+
/**
|
|
328
|
+
* The private key this device holds for `accountId`, whatever its public half
|
|
329
|
+
* says (an unhealthy pair is still a key coins may sit under): the slot, then
|
|
330
|
+
* on Android the signer store's copy. `null` when there is none.
|
|
331
|
+
*/
|
|
332
|
+
async function heldPrivateKey(store, accountId) {
|
|
333
|
+
const local = await store.getItemAsync((0, keychainItems_1.organizationPrivateKeyName)(accountId), pairOptions(store, false));
|
|
334
|
+
if (local && (0, secp256k1_1.isValidSecp256k1PrivateKey)(local))
|
|
335
|
+
return (0, secp256k1_1.normalizeSecp256k1PrivateKey)(local);
|
|
336
|
+
const signer = (0, platform_1.isAndroid)() ? (0, identitySigner_1.registeredIdentitySignerStore)() : null;
|
|
337
|
+
const mirrored = (await signer?.readOrganization?.(accountId)) ?? null;
|
|
338
|
+
return mirrored && (0, secp256k1_1.isValidSecp256k1PrivateKey)(mirrored.privateKey)
|
|
339
|
+
? (0, secp256k1_1.normalizeSecp256k1PrivateKey)(mirrored.privateKey)
|
|
340
|
+
: null;
|
|
341
|
+
}
|
|
314
342
|
/** Forget an organization key on this device: its pair, its phrase, its signer copy and its index entry. */
|
|
315
343
|
async function deleteOrganizationKey(accountId) {
|
|
316
344
|
assertAccountId(accountId);
|
|
@@ -45,11 +45,14 @@
|
|
|
45
45
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
46
46
|
exports.retiredKeyScope = retiredKeyScope;
|
|
47
47
|
exports.retireIdentityKey = retireIdentityKey;
|
|
48
|
+
exports.retireIdentityKeyDuringMigration = retireIdentityKeyDuringMigration;
|
|
48
49
|
exports.listRetiredIdentityKeys = listRetiredIdentityKeys;
|
|
49
50
|
exports.getRetiredPrivateKey = getRetiredPrivateKey;
|
|
50
51
|
exports.forgetRetiredIdentityKey = forgetRetiredIdentityKey;
|
|
51
52
|
exports.forgetRetiredIdentityKeys = forgetRetiredIdentityKeys;
|
|
52
53
|
exports.syncRetiredKeys = syncRetiredKeys;
|
|
54
|
+
exports.exportRetiredIdentityKeys = exportRetiredIdentityKeys;
|
|
55
|
+
exports.retiredKeyFromPrivateKey = retiredKeyFromPrivateKey;
|
|
53
56
|
exports.retiredKeyFromPhrase = retiredKeyFromPhrase;
|
|
54
57
|
const bip39_1 = require("@scure/bip39");
|
|
55
58
|
const english_js_1 = require("@scure/bip39/wordlists/english.js");
|
|
@@ -59,8 +62,8 @@ const logger_1 = require("../logger");
|
|
|
59
62
|
const keychainAccessGroups_1 = require("../utils/keychainAccessGroups");
|
|
60
63
|
const platform_1 = require("../utils/platform");
|
|
61
64
|
const identitySigner_1 = require("./identitySigner");
|
|
65
|
+
const keychainItems_1 = require("./keychainItems");
|
|
62
66
|
const keychainGroupMigration_1 = require("./keychainGroupMigration");
|
|
63
|
-
const organizationKeys_1 = require("./organizationKeys");
|
|
64
67
|
const PERSONAL_SCOPE = 'personal';
|
|
65
68
|
/** Android, Commons-local: its own Keystore-backed slot, shared with no other key. */
|
|
66
69
|
const ANDROID_KEYCHAIN_SERVICE = 'oxy_retired_identity';
|
|
@@ -71,15 +74,15 @@ const PUBLIC_KEY = /^04[0-9a-f]{128}$/;
|
|
|
71
74
|
* The secure store for the ring. On iOS every item is a `spending-key`
|
|
72
75
|
* (ADR 0062): the private keys AND the index live in the wallet group, which
|
|
73
76
|
* only Commons and the Peable wallet can read, never the shared one. In
|
|
74
|
-
* Commons every access first awaits the access-group migration
|
|
75
|
-
* the
|
|
77
|
+
* Commons every access first awaits the access-group migration (`gate: false`
|
|
78
|
+
* is the migration itself, keeping a pre-split copy). On Android the grouping
|
|
79
|
+
* is a no-op and Commons' own services below apply.
|
|
76
80
|
*/
|
|
77
|
-
async function ringStore() {
|
|
81
|
+
async function ringStore(gate = true) {
|
|
78
82
|
const raw = await (0, protocol_1.loadSecureStore)();
|
|
79
83
|
const access = await (0, keychainAccessGroups_1.resolveKeychainAccess)();
|
|
80
|
-
if (
|
|
81
|
-
await (0, keychainGroupMigration_1.
|
|
82
|
-
}
|
|
84
|
+
if (gate)
|
|
85
|
+
await (0, keychainGroupMigration_1.prepareKeychainAccess)(access);
|
|
83
86
|
return (0, keychainAccessGroups_1.groupedKeychainStore)(raw, access, 'spending-key');
|
|
84
87
|
}
|
|
85
88
|
/** The ring a selector names: `personal`, or the organization's account id. */
|
|
@@ -87,7 +90,7 @@ function retiredKeyScope(selector) {
|
|
|
87
90
|
const accountId = selector?.accountId;
|
|
88
91
|
if (accountId === undefined)
|
|
89
92
|
return PERSONAL_SCOPE;
|
|
90
|
-
return (0,
|
|
93
|
+
return (0, keychainItems_1.isOrganizationAccountId)(accountId) ? accountId : null;
|
|
91
94
|
}
|
|
92
95
|
function canonicalPublicKey(publicKey) {
|
|
93
96
|
try {
|
|
@@ -134,7 +137,7 @@ function isIndexEntry(value) {
|
|
|
134
137
|
return false;
|
|
135
138
|
const entry = value;
|
|
136
139
|
return (typeof entry.scope === 'string' &&
|
|
137
|
-
(entry.scope === PERSONAL_SCOPE || (0,
|
|
140
|
+
(entry.scope === PERSONAL_SCOPE || (0, keychainItems_1.isOrganizationAccountId)(entry.scope)) &&
|
|
138
141
|
typeof entry.publicKey === 'string' &&
|
|
139
142
|
PUBLIC_KEY.test(entry.publicKey) &&
|
|
140
143
|
typeof entry.retiredAt === 'number' &&
|
|
@@ -171,13 +174,24 @@ function toListed(entry) {
|
|
|
171
174
|
? { publicKey: entry.publicKey, retiredAt: entry.retiredAt }
|
|
172
175
|
: { publicKey: entry.publicKey, accountId: entry.scope, retiredAt: entry.retiredAt };
|
|
173
176
|
}
|
|
174
|
-
/**
|
|
175
|
-
|
|
177
|
+
/**
|
|
178
|
+
* Whether this app writes the ring: Commons only (Android: the signer store it
|
|
179
|
+
* registers; iOS: the keychain role `holder`). The Peable wallet holds the
|
|
180
|
+
* wallet group too, but only reads, derives and signs: a key the wallet could
|
|
181
|
+
* add to the ring is a key it could choose (R4-14).
|
|
182
|
+
*/
|
|
183
|
+
async function assertMayWrite() {
|
|
176
184
|
if ((0, platform_1.isWeb)())
|
|
177
185
|
throw new Error('retired key: only a native device holds one');
|
|
178
186
|
if ((0, platform_1.isAndroid)() && !(0, identitySigner_1.registeredIdentitySignerStore)()?.writeRetired) {
|
|
179
187
|
throw new Error('On Android only Commons holds retired keys; this app has no identity signer store for them.');
|
|
180
188
|
}
|
|
189
|
+
if ((0, platform_1.isIOS)()) {
|
|
190
|
+
const access = await (0, keychainAccessGroups_1.resolveKeychainAccess)();
|
|
191
|
+
if (access.mode === 'grouped' && access.role !== 'holder') {
|
|
192
|
+
throw new Error('On iOS only Commons writes retired keys; this app may only read them.');
|
|
193
|
+
}
|
|
194
|
+
}
|
|
181
195
|
}
|
|
182
196
|
/**
|
|
183
197
|
* Keep `key` in the ring of `selector`'s account. Commons only. Idempotent: a
|
|
@@ -187,16 +201,27 @@ function assertMayWrite() {
|
|
|
187
201
|
* caller about to replace the key can stop before anything is lost.
|
|
188
202
|
*/
|
|
189
203
|
async function retireIdentityKey(key, selector) {
|
|
204
|
+
await keepRetired(key, selector, true);
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* {@link retireIdentityKey} from inside the iOS access-group migration, which
|
|
208
|
+
* keeps a replaced pre-split copy before deleting it from the shared group and
|
|
209
|
+
* cannot await itself. @internal
|
|
210
|
+
*/
|
|
211
|
+
async function retireIdentityKeyDuringMigration(key, selector) {
|
|
212
|
+
await keepRetired(key, selector, false);
|
|
213
|
+
}
|
|
214
|
+
async function keepRetired(key, selector, gate) {
|
|
190
215
|
const scope = retiredKeyScope(selector);
|
|
191
216
|
if (!scope)
|
|
192
217
|
throw new Error('retired key: accountId must be a 24-hex or uuid account id');
|
|
193
|
-
assertMayWrite();
|
|
218
|
+
await assertMayWrite();
|
|
194
219
|
if (!isHealthyPair(key.privateKey, key.publicKey)) {
|
|
195
220
|
throw new Error('retired key: the public key does not derive from the private key');
|
|
196
221
|
}
|
|
197
222
|
const privateKey = (0, secp256k1_1.normalizeSecp256k1PrivateKey)(key.privateKey);
|
|
198
223
|
const publicKey = (0, secp256k1_1.normalizeSecp256k1PublicKey)(key.publicKey);
|
|
199
|
-
const store = await ringStore();
|
|
224
|
+
const store = await ringStore(gate);
|
|
200
225
|
await store.setItemAsync(privateKeyName(scope, publicKey), privateKey, privateOptions(store, true));
|
|
201
226
|
const readBack = await store.getItemAsync(privateKeyName(scope, publicKey), privateOptions(store, false));
|
|
202
227
|
if (readBack !== privateKey) {
|
|
@@ -294,7 +319,7 @@ async function forgetRetiredIdentityKey(publicKey, selector) {
|
|
|
294
319
|
const canonical = canonicalPublicKey(publicKey);
|
|
295
320
|
if (!scope || !canonical)
|
|
296
321
|
throw new Error('retired key: invalid selector or public key');
|
|
297
|
-
assertMayWrite();
|
|
322
|
+
await assertMayWrite();
|
|
298
323
|
const store = await ringStore();
|
|
299
324
|
await store.deleteItemAsync(privateKeyName(scope, canonical), privateOptions(store, false));
|
|
300
325
|
const signer = (0, platform_1.isAndroid)() ? (0, identitySigner_1.registeredIdentitySignerStore)() : null;
|
|
@@ -302,10 +327,12 @@ async function forgetRetiredIdentityKey(publicKey, selector) {
|
|
|
302
327
|
await writeIndex(store, (await readIndex(store)).filter((entry) => !(entry.scope === scope && entry.publicKey === canonical)));
|
|
303
328
|
}
|
|
304
329
|
/**
|
|
305
|
-
* Forget every retired key of one ring (the person's, or an organization's)
|
|
306
|
-
*
|
|
307
|
-
*
|
|
308
|
-
*
|
|
330
|
+
* Forget every retired key of one ring (the person's, or an organization's).
|
|
331
|
+
* Commons only, and only when the person deletes their Oxy ACCOUNT (Commons'
|
|
332
|
+
* delete-account flow), which tells them the coins go with it. Deleting the
|
|
333
|
+
* identity from this device — "Start over", a forced delete — never calls it:
|
|
334
|
+
* the ring is money, and the same person restoring their identity later must
|
|
335
|
+
* find it (R4-4). Resolves how many it removed.
|
|
309
336
|
*/
|
|
310
337
|
async function forgetRetiredIdentityKeys(selector) {
|
|
311
338
|
const scope = retiredKeyScope(selector);
|
|
@@ -352,6 +379,41 @@ async function syncRetiredKeys() {
|
|
|
352
379
|
}
|
|
353
380
|
return mirrored;
|
|
354
381
|
}
|
|
382
|
+
/**
|
|
383
|
+
* Every retired key of `selector`'s ring WITH its private half, for the
|
|
384
|
+
* encrypted identity backup (`oxy.identity.backup.create`), which carries them
|
|
385
|
+
* under the CURRENT phrase so a lost phone costs no more than the current
|
|
386
|
+
* phrase does (R4-4). Commons only; an entry whose private half is missing or
|
|
387
|
+
* does not derive its public key is left out. Never log or transmit the result
|
|
388
|
+
* except inside that encrypted envelope.
|
|
389
|
+
*/
|
|
390
|
+
async function exportRetiredIdentityKeys(selector) {
|
|
391
|
+
const scope = retiredKeyScope(selector);
|
|
392
|
+
if (!scope || (0, platform_1.isWeb)())
|
|
393
|
+
return [];
|
|
394
|
+
await assertMayWrite();
|
|
395
|
+
const out = [];
|
|
396
|
+
for (const entry of await listRetiredIdentityKeys(selector)) {
|
|
397
|
+
const privateKey = await getRetiredPrivateKey(entry.publicKey, selector);
|
|
398
|
+
if (privateKey)
|
|
399
|
+
out.push({ privateKey, publicKey: entry.publicKey, retiredAt: entry.retiredAt });
|
|
400
|
+
}
|
|
401
|
+
return out;
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* The pair a previous key's raw PRIVATE KEY gives (64 hex, an optional `0x`),
|
|
405
|
+
* for a person whose old identity was imported as a private key and so has no
|
|
406
|
+
* phrase to re-enter. Stores nothing: the caller checks the public key against
|
|
407
|
+
* the account's key history and only then calls {@link retireIdentityKey}.
|
|
408
|
+
*/
|
|
409
|
+
function retiredKeyFromPrivateKey(privateKeyHex) {
|
|
410
|
+
const trimmed = privateKeyHex.trim().toLowerCase().replace(/^0x/, '');
|
|
411
|
+
if (!/^[0-9a-f]{64}$/.test(trimmed) || !(0, secp256k1_1.isValidSecp256k1PrivateKey)(trimmed)) {
|
|
412
|
+
throw new Error('retired key: not a valid private key');
|
|
413
|
+
}
|
|
414
|
+
const privateKey = (0, secp256k1_1.normalizeSecp256k1PrivateKey)(trimmed);
|
|
415
|
+
return { privateKey, publicKey: publicKeyOf(privateKey) };
|
|
416
|
+
}
|
|
355
417
|
/**
|
|
356
418
|
* The pair a previous recovery phrase derives (`seed[0:32]`, the personal-root
|
|
357
419
|
* and organization-key derivation; 12–24 words). Stores nothing: the caller
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.KeychainAccessUnavailableError = exports.KEYCHAIN_ACCESS_GROUPS = void 0;
|
|
3
|
+
exports.KeychainAccessUnavailableError = exports.DEV_KEYCHAIN_GROUP_SUFFIX = exports.KEYCHAIN_ACCESS_GROUPS = void 0;
|
|
4
4
|
exports.keychainGroupFor = keychainGroupFor;
|
|
5
|
+
exports.mayWriteKeychainItem = mayWriteKeychainItem;
|
|
5
6
|
exports.mayHoldKeychainItem = mayHoldKeychainItem;
|
|
6
7
|
exports.keychainAccessFromDefaultGroup = keychainAccessFromDefaultGroup;
|
|
7
8
|
exports.resolveKeychainAccess = resolveKeychainAccess;
|
|
8
9
|
exports.resetKeychainAccessForTests = resetKeychainAccessForTests;
|
|
9
10
|
exports.keychainAccessGroupValue = keychainAccessGroupValue;
|
|
11
|
+
exports.setHolderSharedSecretFallback = setHolderSharedSecretFallback;
|
|
12
|
+
exports.closeWalletSharedSecretFallback = closeWalletSharedSecretFallback;
|
|
10
13
|
exports.keychainReadPlan = keychainReadPlan;
|
|
11
14
|
exports.keychainDeletePlan = keychainDeletePlan;
|
|
12
15
|
exports.keychainWriteOption = keychainWriteOption;
|
|
@@ -65,6 +68,15 @@ exports.KEYCHAIN_ACCESS_GROUPS = {
|
|
|
65
68
|
wallet: 'so.oxy.wallet',
|
|
66
69
|
shared: 'group.so.oxy.shared',
|
|
67
70
|
};
|
|
71
|
+
/**
|
|
72
|
+
* The suffix a DEVELOPMENT build's restricted groups carry (`so.oxy.commons.dev`,
|
|
73
|
+
* `to.peable.app.dev`: `so.oxy.identity.dev`, `so.oxy.wallet.dev`). A dev build
|
|
74
|
+
* sits beside the production app on the same phone, is signed by the same team
|
|
75
|
+
* and runs JavaScript from a dev server, so it never shares a restricted group
|
|
76
|
+
* with production (`@oxy.so/app-preset` enforces the entitlements). The shared
|
|
77
|
+
* group has no variant: it holds nothing secret.
|
|
78
|
+
*/
|
|
79
|
+
exports.DEV_KEYCHAIN_GROUP_SUFFIX = '.dev';
|
|
68
80
|
/** Thrown while the probe cannot answer (before first unlock). Transient. */
|
|
69
81
|
class KeychainAccessUnavailableError extends Error {
|
|
70
82
|
constructor(cause) {
|
|
@@ -86,6 +98,16 @@ function keychainGroupFor(item) {
|
|
|
86
98
|
return 'app';
|
|
87
99
|
}
|
|
88
100
|
}
|
|
101
|
+
/**
|
|
102
|
+
* Whether `role` may WRITE `item`. Only Commons writes a secret: the Peable
|
|
103
|
+
* wallet reads, derives and signs with spending keys (and the retired ring)
|
|
104
|
+
* but never adds one — a key the wallet could add is one the wallet could
|
|
105
|
+
* choose. (The keychain itself lets every app in the wallet group write it;
|
|
106
|
+
* this is the SDK's half, and readers check what they find, ADR 0062.)
|
|
107
|
+
*/
|
|
108
|
+
function mayWriteKeychainItem(role, item) {
|
|
109
|
+
return role === 'holder' || item === 'shared' || item === 'app';
|
|
110
|
+
}
|
|
89
111
|
/** Whether `role` holds the group `item` lives in. */
|
|
90
112
|
function mayHoldKeychainItem(role, item) {
|
|
91
113
|
switch (role) {
|
|
@@ -109,14 +131,18 @@ function keychainAccessFromDefaultGroup(defaultGroup, appGroup) {
|
|
|
109
131
|
}
|
|
110
132
|
const prefix = defaultGroup.slice(0, dot + 1);
|
|
111
133
|
const group = defaultGroup.slice(dot + 1);
|
|
112
|
-
const
|
|
134
|
+
const dev = group.endsWith(exports.DEV_KEYCHAIN_GROUP_SUFFIX);
|
|
135
|
+
const base = dev ? group.slice(0, -exports.DEV_KEYCHAIN_GROUP_SUFFIX.length) : group;
|
|
136
|
+
const role = base === exports.KEYCHAIN_ACCESS_GROUPS.identity
|
|
113
137
|
? 'holder'
|
|
114
|
-
:
|
|
138
|
+
: base === exports.KEYCHAIN_ACCESS_GROUPS.wallet
|
|
115
139
|
? 'wallet'
|
|
116
140
|
: 'member';
|
|
141
|
+
// A `.dev` suffix on anything but a restricted group is not a variant.
|
|
142
|
+
const variant = dev && role !== 'member' ? { variant: exports.DEV_KEYCHAIN_GROUP_SUFFIX } : {};
|
|
117
143
|
return appGroup?.startsWith(prefix)
|
|
118
|
-
? { mode: 'grouped', prefix, defaultGroup, role, appGroup }
|
|
119
|
-
: { mode: 'grouped', prefix, defaultGroup, role };
|
|
144
|
+
? { mode: 'grouped', prefix, defaultGroup, role, appGroup, ...variant }
|
|
145
|
+
: { mode: 'grouped', prefix, defaultGroup, role, ...variant };
|
|
120
146
|
}
|
|
121
147
|
let resolvedAccess = null;
|
|
122
148
|
/**
|
|
@@ -169,9 +195,11 @@ async function resolveKeychainAccess() {
|
|
|
169
195
|
resolvedAccess = keychainAccessFromDefaultGroup(probe.accessGroup, own.state === 'resolved' ? own.accessGroup : undefined);
|
|
170
196
|
return resolvedAccess;
|
|
171
197
|
}
|
|
172
|
-
/** Forget the memoized access. Tests only. @internal */
|
|
198
|
+
/** Forget the memoized access and the fallback state. Tests only. @internal */
|
|
173
199
|
function resetKeychainAccessForTests() {
|
|
174
200
|
resolvedAccess = null;
|
|
201
|
+
holderSharedFallback = false;
|
|
202
|
+
walletSharedFallbackClosed = false;
|
|
175
203
|
}
|
|
176
204
|
/**
|
|
177
205
|
* The full `kSecAttrAccessGroup` value of `group`, or `undefined` for "no
|
|
@@ -189,17 +217,54 @@ function keychainAccessGroupValue(access, group) {
|
|
|
189
217
|
// default is the shared group: no group, the calls it always made.
|
|
190
218
|
return access.appGroup ?? (access.role === 'member' ? undefined : access.defaultGroup);
|
|
191
219
|
}
|
|
192
|
-
|
|
220
|
+
const variant = group === 'shared' ? '' : (access.variant ?? '');
|
|
221
|
+
return `${access.prefix}${exports.KEYCHAIN_ACCESS_GROUPS[group]}${variant}`;
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Whether a SECRET read may still fall back to the pre-split shared group
|
|
225
|
+
* (R4-7). Every Oxy app can write that group, and keychain items survive an
|
|
226
|
+
* uninstall, so a secret found there is one Commons did not necessarily write.
|
|
227
|
+
*
|
|
228
|
+
* - Commons (`holder`): CLOSED, except while its one-shot migration has proved
|
|
229
|
+
* the shared copies are its own (they carry the key its sandbox marker
|
|
230
|
+
* records) and could not move them all yet; the migration opens it for that
|
|
231
|
+
* launch only ({@link setHolderSharedSecretFallback}).
|
|
232
|
+
* - The Peable wallet: open until Commons' migration has finished (the
|
|
233
|
+
* completion record Commons mirrors into the wallet group), because until
|
|
234
|
+
* then an upgraded phone's keys are still in the shared group; closed for
|
|
235
|
+
* good once it has seen that record ({@link closeWalletSharedSecretFallback}).
|
|
236
|
+
*/
|
|
237
|
+
let holderSharedFallback = false;
|
|
238
|
+
let walletSharedFallbackClosed = false;
|
|
239
|
+
/** The migration's switch for Commons' fallback. @internal */
|
|
240
|
+
function setHolderSharedSecretFallback(open) {
|
|
241
|
+
holderSharedFallback = open;
|
|
242
|
+
}
|
|
243
|
+
/** The wallet saw Commons' completion record: no shared fallback again in this process. @internal */
|
|
244
|
+
function closeWalletSharedSecretFallback() {
|
|
245
|
+
walletSharedFallbackClosed = true;
|
|
246
|
+
}
|
|
247
|
+
function sharedFallbackOpen(access) {
|
|
248
|
+
switch (access.role) {
|
|
249
|
+
case 'holder':
|
|
250
|
+
return holderSharedFallback;
|
|
251
|
+
case 'wallet':
|
|
252
|
+
return !walletSharedFallbackClosed;
|
|
253
|
+
case 'member':
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
193
256
|
}
|
|
194
257
|
function option(value) {
|
|
195
258
|
return value === undefined ? {} : { accessGroup: value };
|
|
196
259
|
}
|
|
197
260
|
/**
|
|
198
|
-
* The option bags a READ of `item` tries, in order: its own group, then
|
|
199
|
-
* an identity secret or a spending key,
|
|
200
|
-
*
|
|
201
|
-
* app may not hold the item: the caller
|
|
202
|
-
*
|
|
261
|
+
* The option bags a READ of `item` tries, in order: its own group, then — for
|
|
262
|
+
* an identity secret or a spending key, and only while
|
|
263
|
+
* {@link sharedFallbackOpen} says so — the shared group, where those lived
|
|
264
|
+
* before the split. Empty when this app may not hold the item: the caller
|
|
265
|
+
* answers `null` without touching the keychain. Evaluated per read: the
|
|
266
|
+
* fallback closes during a process's life. An app's own items (`app`) take
|
|
267
|
+
* {@link ownKeychainItemPlan}.
|
|
203
268
|
*/
|
|
204
269
|
function keychainReadPlan(access, item) {
|
|
205
270
|
if (access.mode === 'ungrouped') {
|
|
@@ -210,18 +275,29 @@ function keychainReadPlan(access, item) {
|
|
|
210
275
|
}
|
|
211
276
|
const own = keychainGroupFor(item);
|
|
212
277
|
const plan = [option(keychainAccessGroupValue(access, own))];
|
|
213
|
-
if (own === 'identity' || own === 'wallet') {
|
|
214
|
-
// Where the split moved these items from.
|
|
278
|
+
if ((own === 'identity' || own === 'wallet') && sharedFallbackOpen(access)) {
|
|
215
279
|
plan.push(option(keychainAccessGroupValue(access, 'shared')));
|
|
216
280
|
}
|
|
217
281
|
return plan;
|
|
218
282
|
}
|
|
219
283
|
/**
|
|
220
|
-
* Every place a DELETE of `item` must reach:
|
|
221
|
-
*
|
|
284
|
+
* Every place a DELETE of `item` must reach: its own group AND the shared
|
|
285
|
+
* group, whatever the read fallback says, so a delete never leaves a copy
|
|
286
|
+
* behind where every Oxy app can read it.
|
|
222
287
|
*/
|
|
223
288
|
function keychainDeletePlan(access, item) {
|
|
224
|
-
|
|
289
|
+
if (access.mode === 'ungrouped') {
|
|
290
|
+
return [{}];
|
|
291
|
+
}
|
|
292
|
+
if (!mayHoldKeychainItem(access.role, item)) {
|
|
293
|
+
return [];
|
|
294
|
+
}
|
|
295
|
+
const own = keychainGroupFor(item);
|
|
296
|
+
const plan = [option(keychainAccessGroupValue(access, own))];
|
|
297
|
+
if (own === 'identity' || own === 'wallet') {
|
|
298
|
+
plan.push(option(keychainAccessGroupValue(access, 'shared')));
|
|
299
|
+
}
|
|
300
|
+
return plan;
|
|
225
301
|
}
|
|
226
302
|
/**
|
|
227
303
|
* The option a WRITE of `item` carries. THROWS when this app may not hold the
|
|
@@ -232,8 +308,8 @@ function keychainWriteOption(access, item) {
|
|
|
232
308
|
if (access.mode === 'ungrouped') {
|
|
233
309
|
return {};
|
|
234
310
|
}
|
|
235
|
-
if (!
|
|
236
|
-
throw new Error(`This app (keychain role "${access.role}") may not
|
|
311
|
+
if (!mayWriteKeychainItem(access.role, item)) {
|
|
312
|
+
throw new Error(`This app (keychain role "${access.role}") may not write a ${item} keychain item; only Commons writes the identity and the keys the wallet spends.`);
|
|
237
313
|
}
|
|
238
314
|
return option(keychainAccessGroupValue(access, keychainGroupFor(item)));
|
|
239
315
|
}
|
|
@@ -301,19 +377,29 @@ async function deleteThroughPlan(store, key, options, plan) {
|
|
|
301
377
|
}
|
|
302
378
|
/**
|
|
303
379
|
* `store` with every call routed to `item`'s groups for this app: reads walk
|
|
304
|
-
* the read plan, writes carry the write option (throwing
|
|
305
|
-
* not
|
|
306
|
-
*
|
|
380
|
+
* the read plan (evaluated per read), writes carry the write option (throwing
|
|
381
|
+
* for a role that may not write the item), deletes reach every location. A
|
|
382
|
+
* secret WRITE also deletes the item's copy in the shared group (R4-6): a value
|
|
383
|
+
* Commons writes replaces whatever any app could read there, and a copy that
|
|
384
|
+
* cannot be removed fails the write instead of staying readable. The constants
|
|
385
|
+
* of the wrapped store are kept, so `store.WHEN_UNLOCKED_THIS_DEVICE_ONLY` still
|
|
386
|
+
* reads.
|
|
307
387
|
*/
|
|
308
388
|
function groupedKeychainStore(store, access, item) {
|
|
309
|
-
const
|
|
389
|
+
const sharedCopy = access.mode === 'grouped' && (item === 'identity-secret' || item === 'spending-key')
|
|
390
|
+
? option(keychainAccessGroupValue(access, 'shared'))
|
|
391
|
+
: null;
|
|
310
392
|
const wrapped = {
|
|
311
|
-
getItemAsync: (key, options) => readThroughPlan(store, key, options ?? {},
|
|
393
|
+
getItemAsync: (key, options) => readThroughPlan(store, key, options ?? {}, keychainReadPlan(access, item)),
|
|
312
394
|
setItemAsync: async (key, value, options) => {
|
|
313
395
|
const group = keychainWriteOption(access, item);
|
|
314
396
|
await store.setItemAsync(key, value, { ...(options ?? {}), ...group });
|
|
397
|
+
if (sharedCopy) {
|
|
398
|
+
const { keychainAccessible: _accessible, ...rest } = (options ?? {});
|
|
399
|
+
await store.deleteItemAsync(key, { ...rest, ...sharedCopy });
|
|
400
|
+
}
|
|
315
401
|
},
|
|
316
|
-
deleteItemAsync: (key, options) => deleteThroughPlan(store, key, options ?? {},
|
|
402
|
+
deleteItemAsync: (key, options) => deleteThroughPlan(store, key, options ?? {}, keychainDeletePlan(access, item)),
|
|
317
403
|
};
|
|
318
404
|
return Object.assign(Object.create(store), wrapped);
|
|
319
405
|
}
|