@oxy.so/core 7.31.0 → 7.33.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 +155 -24
- package/dist/cjs/crypto/identityMarker.js +15 -0
- package/dist/cjs/crypto/index.js +11 -2
- package/dist/cjs/crypto/keyManager.js +111 -16
- package/dist/cjs/crypto/keychainGroupMigration.js +327 -160
- package/dist/cjs/crypto/organizationKeys.js +33 -5
- package/dist/cjs/crypto/retiredKeys.js +315 -64
- package/dist/cjs/utils/keychainAccessGroups.js +111 -25
- package/dist/esm/api/identity.js +155 -24
- package/dist/esm/crypto/identityMarker.js +14 -0
- package/dist/esm/crypto/index.js +3 -3
- package/dist/esm/crypto/keyManager.js +113 -18
- package/dist/esm/crypto/keychainGroupMigration.js +327 -162
- package/dist/esm/crypto/organizationKeys.js +34 -6
- package/dist/esm/crypto/retiredKeys.js +306 -63
- 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/keyManager.d.ts +45 -1
- 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 +108 -17
- package/dist/types/utils/keychainAccessGroups.d.ts +41 -11
- package/package.json +1 -1
- package/src/api/identity.ts +198 -41
- package/src/crypto/identityMarker.ts +15 -0
- package/src/crypto/index.ts +12 -1
- package/src/crypto/keyManager.ts +127 -26
- package/src/crypto/keychainGroupMigration.ts +390 -185
- package/src/crypto/organizationKeys.ts +38 -6
- package/src/crypto/retiredKeys.ts +369 -65
- package/src/utils/keychainAccessGroups.ts +121 -24
|
@@ -49,6 +49,15 @@ export const KEYCHAIN_ACCESS_GROUPS = {
|
|
|
49
49
|
wallet: 'so.oxy.wallet',
|
|
50
50
|
shared: 'group.so.oxy.shared',
|
|
51
51
|
};
|
|
52
|
+
/**
|
|
53
|
+
* The suffix a DEVELOPMENT build's restricted groups carry (`so.oxy.commons.dev`,
|
|
54
|
+
* `to.peable.app.dev`: `so.oxy.identity.dev`, `so.oxy.wallet.dev`). A dev build
|
|
55
|
+
* sits beside the production app on the same phone, is signed by the same team
|
|
56
|
+
* and runs JavaScript from a dev server, so it never shares a restricted group
|
|
57
|
+
* with production (`@oxy.so/app-preset` enforces the entitlements). The shared
|
|
58
|
+
* group has no variant: it holds nothing secret.
|
|
59
|
+
*/
|
|
60
|
+
export const DEV_KEYCHAIN_GROUP_SUFFIX = '.dev';
|
|
52
61
|
/** Thrown while the probe cannot answer (before first unlock). Transient. */
|
|
53
62
|
export class KeychainAccessUnavailableError extends Error {
|
|
54
63
|
constructor(cause) {
|
|
@@ -69,6 +78,16 @@ export function keychainGroupFor(item) {
|
|
|
69
78
|
return 'app';
|
|
70
79
|
}
|
|
71
80
|
}
|
|
81
|
+
/**
|
|
82
|
+
* Whether `role` may WRITE `item`. Only Commons writes a secret: the Peable
|
|
83
|
+
* wallet reads, derives and signs with spending keys (and the retired ring)
|
|
84
|
+
* but never adds one — a key the wallet could add is one the wallet could
|
|
85
|
+
* choose. (The keychain itself lets every app in the wallet group write it;
|
|
86
|
+
* this is the SDK's half, and readers check what they find, ADR 0062.)
|
|
87
|
+
*/
|
|
88
|
+
export function mayWriteKeychainItem(role, item) {
|
|
89
|
+
return role === 'holder' || item === 'shared' || item === 'app';
|
|
90
|
+
}
|
|
72
91
|
/** Whether `role` holds the group `item` lives in. */
|
|
73
92
|
export function mayHoldKeychainItem(role, item) {
|
|
74
93
|
switch (role) {
|
|
@@ -92,14 +111,18 @@ export function keychainAccessFromDefaultGroup(defaultGroup, appGroup) {
|
|
|
92
111
|
}
|
|
93
112
|
const prefix = defaultGroup.slice(0, dot + 1);
|
|
94
113
|
const group = defaultGroup.slice(dot + 1);
|
|
95
|
-
const
|
|
114
|
+
const dev = group.endsWith(DEV_KEYCHAIN_GROUP_SUFFIX);
|
|
115
|
+
const base = dev ? group.slice(0, -DEV_KEYCHAIN_GROUP_SUFFIX.length) : group;
|
|
116
|
+
const role = base === KEYCHAIN_ACCESS_GROUPS.identity
|
|
96
117
|
? 'holder'
|
|
97
|
-
:
|
|
118
|
+
: base === KEYCHAIN_ACCESS_GROUPS.wallet
|
|
98
119
|
? 'wallet'
|
|
99
120
|
: 'member';
|
|
121
|
+
// A `.dev` suffix on anything but a restricted group is not a variant.
|
|
122
|
+
const variant = dev && role !== 'member' ? { variant: DEV_KEYCHAIN_GROUP_SUFFIX } : {};
|
|
100
123
|
return appGroup?.startsWith(prefix)
|
|
101
|
-
? { mode: 'grouped', prefix, defaultGroup, role, appGroup }
|
|
102
|
-
: { mode: 'grouped', prefix, defaultGroup, role };
|
|
124
|
+
? { mode: 'grouped', prefix, defaultGroup, role, appGroup, ...variant }
|
|
125
|
+
: { mode: 'grouped', prefix, defaultGroup, role, ...variant };
|
|
103
126
|
}
|
|
104
127
|
let resolvedAccess = null;
|
|
105
128
|
/**
|
|
@@ -152,9 +175,11 @@ export async function resolveKeychainAccess() {
|
|
|
152
175
|
resolvedAccess = keychainAccessFromDefaultGroup(probe.accessGroup, own.state === 'resolved' ? own.accessGroup : undefined);
|
|
153
176
|
return resolvedAccess;
|
|
154
177
|
}
|
|
155
|
-
/** Forget the memoized access. Tests only. @internal */
|
|
178
|
+
/** Forget the memoized access and the fallback state. Tests only. @internal */
|
|
156
179
|
export function resetKeychainAccessForTests() {
|
|
157
180
|
resolvedAccess = null;
|
|
181
|
+
holderSharedFallback = false;
|
|
182
|
+
walletSharedFallbackClosed = false;
|
|
158
183
|
}
|
|
159
184
|
/**
|
|
160
185
|
* The full `kSecAttrAccessGroup` value of `group`, or `undefined` for "no
|
|
@@ -172,17 +197,54 @@ export function keychainAccessGroupValue(access, group) {
|
|
|
172
197
|
// default is the shared group: no group, the calls it always made.
|
|
173
198
|
return access.appGroup ?? (access.role === 'member' ? undefined : access.defaultGroup);
|
|
174
199
|
}
|
|
175
|
-
|
|
200
|
+
const variant = group === 'shared' ? '' : (access.variant ?? '');
|
|
201
|
+
return `${access.prefix}${KEYCHAIN_ACCESS_GROUPS[group]}${variant}`;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Whether a SECRET read may still fall back to the pre-split shared group
|
|
205
|
+
* (R4-7). Every Oxy app can write that group, and keychain items survive an
|
|
206
|
+
* uninstall, so a secret found there is one Commons did not necessarily write.
|
|
207
|
+
*
|
|
208
|
+
* - Commons (`holder`): CLOSED, except while its one-shot migration has proved
|
|
209
|
+
* the shared copies are its own (they carry the key its sandbox marker
|
|
210
|
+
* records) and could not move them all yet; the migration opens it for that
|
|
211
|
+
* launch only ({@link setHolderSharedSecretFallback}).
|
|
212
|
+
* - The Peable wallet: open until Commons' migration has finished (the
|
|
213
|
+
* completion record Commons mirrors into the wallet group), because until
|
|
214
|
+
* then an upgraded phone's keys are still in the shared group; closed for
|
|
215
|
+
* good once it has seen that record ({@link closeWalletSharedSecretFallback}).
|
|
216
|
+
*/
|
|
217
|
+
let holderSharedFallback = false;
|
|
218
|
+
let walletSharedFallbackClosed = false;
|
|
219
|
+
/** The migration's switch for Commons' fallback. @internal */
|
|
220
|
+
export function setHolderSharedSecretFallback(open) {
|
|
221
|
+
holderSharedFallback = open;
|
|
222
|
+
}
|
|
223
|
+
/** The wallet saw Commons' completion record: no shared fallback again in this process. @internal */
|
|
224
|
+
export function closeWalletSharedSecretFallback() {
|
|
225
|
+
walletSharedFallbackClosed = true;
|
|
226
|
+
}
|
|
227
|
+
function sharedFallbackOpen(access) {
|
|
228
|
+
switch (access.role) {
|
|
229
|
+
case 'holder':
|
|
230
|
+
return holderSharedFallback;
|
|
231
|
+
case 'wallet':
|
|
232
|
+
return !walletSharedFallbackClosed;
|
|
233
|
+
case 'member':
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
176
236
|
}
|
|
177
237
|
function option(value) {
|
|
178
238
|
return value === undefined ? {} : { accessGroup: value };
|
|
179
239
|
}
|
|
180
240
|
/**
|
|
181
|
-
* The option bags a READ of `item` tries, in order: its own group, then
|
|
182
|
-
* an identity secret or a spending key,
|
|
183
|
-
*
|
|
184
|
-
* app may not hold the item: the caller
|
|
185
|
-
*
|
|
241
|
+
* The option bags a READ of `item` tries, in order: its own group, then — for
|
|
242
|
+
* an identity secret or a spending key, and only while
|
|
243
|
+
* {@link sharedFallbackOpen} says so — the shared group, where those lived
|
|
244
|
+
* before the split. Empty when this app may not hold the item: the caller
|
|
245
|
+
* answers `null` without touching the keychain. Evaluated per read: the
|
|
246
|
+
* fallback closes during a process's life. An app's own items (`app`) take
|
|
247
|
+
* {@link ownKeychainItemPlan}.
|
|
186
248
|
*/
|
|
187
249
|
export function keychainReadPlan(access, item) {
|
|
188
250
|
if (access.mode === 'ungrouped') {
|
|
@@ -193,18 +255,29 @@ export function keychainReadPlan(access, item) {
|
|
|
193
255
|
}
|
|
194
256
|
const own = keychainGroupFor(item);
|
|
195
257
|
const plan = [option(keychainAccessGroupValue(access, own))];
|
|
196
|
-
if (own === 'identity' || own === 'wallet') {
|
|
197
|
-
// Where the split moved these items from.
|
|
258
|
+
if ((own === 'identity' || own === 'wallet') && sharedFallbackOpen(access)) {
|
|
198
259
|
plan.push(option(keychainAccessGroupValue(access, 'shared')));
|
|
199
260
|
}
|
|
200
261
|
return plan;
|
|
201
262
|
}
|
|
202
263
|
/**
|
|
203
|
-
* Every place a DELETE of `item` must reach:
|
|
204
|
-
*
|
|
264
|
+
* Every place a DELETE of `item` must reach: its own group AND the shared
|
|
265
|
+
* group, whatever the read fallback says, so a delete never leaves a copy
|
|
266
|
+
* behind where every Oxy app can read it.
|
|
205
267
|
*/
|
|
206
268
|
export function keychainDeletePlan(access, item) {
|
|
207
|
-
|
|
269
|
+
if (access.mode === 'ungrouped') {
|
|
270
|
+
return [{}];
|
|
271
|
+
}
|
|
272
|
+
if (!mayHoldKeychainItem(access.role, item)) {
|
|
273
|
+
return [];
|
|
274
|
+
}
|
|
275
|
+
const own = keychainGroupFor(item);
|
|
276
|
+
const plan = [option(keychainAccessGroupValue(access, own))];
|
|
277
|
+
if (own === 'identity' || own === 'wallet') {
|
|
278
|
+
plan.push(option(keychainAccessGroupValue(access, 'shared')));
|
|
279
|
+
}
|
|
280
|
+
return plan;
|
|
208
281
|
}
|
|
209
282
|
/**
|
|
210
283
|
* The option a WRITE of `item` carries. THROWS when this app may not hold the
|
|
@@ -215,8 +288,8 @@ export function keychainWriteOption(access, item) {
|
|
|
215
288
|
if (access.mode === 'ungrouped') {
|
|
216
289
|
return {};
|
|
217
290
|
}
|
|
218
|
-
if (!
|
|
219
|
-
throw new Error(`This app (keychain role "${access.role}") may not
|
|
291
|
+
if (!mayWriteKeychainItem(access.role, item)) {
|
|
292
|
+
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.`);
|
|
220
293
|
}
|
|
221
294
|
return option(keychainAccessGroupValue(access, keychainGroupFor(item)));
|
|
222
295
|
}
|
|
@@ -284,19 +357,29 @@ export async function deleteThroughPlan(store, key, options, plan) {
|
|
|
284
357
|
}
|
|
285
358
|
/**
|
|
286
359
|
* `store` with every call routed to `item`'s groups for this app: reads walk
|
|
287
|
-
* the read plan, writes carry the write option (throwing
|
|
288
|
-
* not
|
|
289
|
-
*
|
|
360
|
+
* the read plan (evaluated per read), writes carry the write option (throwing
|
|
361
|
+
* for a role that may not write the item), deletes reach every location. A
|
|
362
|
+
* secret WRITE also deletes the item's copy in the shared group (R4-6): a value
|
|
363
|
+
* Commons writes replaces whatever any app could read there, and a copy that
|
|
364
|
+
* cannot be removed fails the write instead of staying readable. The constants
|
|
365
|
+
* of the wrapped store are kept, so `store.WHEN_UNLOCKED_THIS_DEVICE_ONLY` still
|
|
366
|
+
* reads.
|
|
290
367
|
*/
|
|
291
368
|
export function groupedKeychainStore(store, access, item) {
|
|
292
|
-
const
|
|
369
|
+
const sharedCopy = access.mode === 'grouped' && (item === 'identity-secret' || item === 'spending-key')
|
|
370
|
+
? option(keychainAccessGroupValue(access, 'shared'))
|
|
371
|
+
: null;
|
|
293
372
|
const wrapped = {
|
|
294
|
-
getItemAsync: (key, options) => readThroughPlan(store, key, options ?? {},
|
|
373
|
+
getItemAsync: (key, options) => readThroughPlan(store, key, options ?? {}, keychainReadPlan(access, item)),
|
|
295
374
|
setItemAsync: async (key, value, options) => {
|
|
296
375
|
const group = keychainWriteOption(access, item);
|
|
297
376
|
await store.setItemAsync(key, value, { ...(options ?? {}), ...group });
|
|
377
|
+
if (sharedCopy) {
|
|
378
|
+
const { keychainAccessible: _accessible, ...rest } = (options ?? {});
|
|
379
|
+
await store.deleteItemAsync(key, { ...rest, ...sharedCopy });
|
|
380
|
+
}
|
|
298
381
|
},
|
|
299
|
-
deleteItemAsync: (key, options) => deleteThroughPlan(store, key, options ?? {},
|
|
382
|
+
deleteItemAsync: (key, options) => deleteThroughPlan(store, key, options ?? {}, keychainDeletePlan(access, item)),
|
|
300
383
|
};
|
|
301
384
|
return Object.assign(Object.create(store), wrapped);
|
|
302
385
|
}
|
|
@@ -67,6 +67,14 @@ export interface RotateKeyResult {
|
|
|
67
67
|
* should surface a recovery prompt. Omitted on full success.
|
|
68
68
|
*/
|
|
69
69
|
localPersistFailed?: true;
|
|
70
|
+
/**
|
|
71
|
+
* Whether the encrypted identity backup was re-created under the NEW phrase,
|
|
72
|
+
* carrying the retired ring with the key just replaced (R4-4). The server
|
|
73
|
+
* deletes the old backup in the rotation's transaction; `false` (native, the
|
|
74
|
+
* upload failed) means the caller should offer "Create backup" again, or the
|
|
75
|
+
* replaced key lives only on this phone. Absent on web.
|
|
76
|
+
*/
|
|
77
|
+
backupRefreshed?: boolean;
|
|
70
78
|
}
|
|
71
79
|
/**
|
|
72
80
|
* Derive a user's Oxy DID from their stable account id.
|
|
@@ -268,6 +276,34 @@ export declare class IdentityBackupApi {
|
|
|
268
276
|
* from SecureStore.
|
|
269
277
|
*/
|
|
270
278
|
create(phrase: string): Promise<BackupStatusResponse>;
|
|
279
|
+
/**
|
|
280
|
+
* The retired ring this device holds for the person, limited to keys Oxy's
|
|
281
|
+
* key history names as THIS account's retired keys (the ring's iOS group is
|
|
282
|
+
* writable by the wallet app too, and a previous person's ring can outlive a
|
|
283
|
+
* "Start over"). Empty where no ring is held (web, any app but Commons).
|
|
284
|
+
* Throws when the history cannot be read while a ring exists, so a backup
|
|
285
|
+
* never silently drops keys it should carry.
|
|
286
|
+
*/
|
|
287
|
+
private retiredKeysToCarry;
|
|
288
|
+
/**
|
|
289
|
+
* Fetch and open the backup the phrase locates, checking it is the phrase's
|
|
290
|
+
* own identity. With `missingIsNull`, `null` when there is none (404);
|
|
291
|
+
* otherwise the request's own error propagates.
|
|
292
|
+
*/
|
|
293
|
+
private open;
|
|
294
|
+
/**
|
|
295
|
+
* Keep every retired key a backup carries in this device's ring. Each pair
|
|
296
|
+
* must be healthy and must not be the current key; the envelope itself is
|
|
297
|
+
* authenticated by the phrase. Commons only (the ring refuses elsewhere).
|
|
298
|
+
* Resolves how many keys are now held; a key that fails is skipped.
|
|
299
|
+
*/
|
|
300
|
+
private keepCarriedRetiredKeys;
|
|
301
|
+
/**
|
|
302
|
+
* Restore ONLY the retired keys the phrase's backup carries into this
|
|
303
|
+
* device's ring (Commons, after a phrase import or a silent device-backup
|
|
304
|
+
* restore). Resolves how many were kept; 0 when there is no backup.
|
|
305
|
+
*/
|
|
306
|
+
restoreRetiredKeys(phrase: string): Promise<number>;
|
|
271
307
|
/**
|
|
272
308
|
* Whether the signed-in user has a stored backup, plus its non-sensitive hint
|
|
273
309
|
* and timestamp. Returns no ciphertext and no locator.
|
|
@@ -66,6 +66,14 @@ export interface WriteIdentityMarkerInput {
|
|
|
66
66
|
* rest.
|
|
67
67
|
*/
|
|
68
68
|
export declare function readIdentityMarker(): Promise<IdentityMarker | null>;
|
|
69
|
+
/**
|
|
70
|
+
* Read the marker, THROWING when the storage cannot answer (instead of the
|
|
71
|
+
* fail-open `null` of {@link readIdentityMarker}). For a caller that must not
|
|
72
|
+
* mistake "unreadable" for "never written": the iOS keychain migration, which
|
|
73
|
+
* adopts a pre-split secret only when this marker vouches for it, and records
|
|
74
|
+
* once that there is nothing to adopt.
|
|
75
|
+
*/
|
|
76
|
+
export declare function readIdentityMarkerStrict(): Promise<IdentityMarker | null>;
|
|
69
77
|
/**
|
|
70
78
|
* Write (create/replace) the marker. Returns `true` when it durably landed,
|
|
71
79
|
* `false` when storage was unavailable or the write threw. Callers treat a
|
|
@@ -19,11 +19,11 @@ export type { IdentityDeviceBackupStore } from './deviceBackup';
|
|
|
19
19
|
export type { IdentitySignerStore } from './identitySigner';
|
|
20
20
|
export { generateOrganizationKey, organizationKeyFromPhrase, storeOrganizationKey, getOrganizationPublicKey, getOrganizationPrivateKey, getOrganizationRecoveryPhrase, listOrganizationKeys, deleteOrganizationKey, syncOrganizationKeys, isOrganizationAccountId, isHealthyOrganizationKeyPair, } from './organizationKeys';
|
|
21
21
|
export type { OrganizationKeyPair, GeneratedOrganizationKey, StoredOrganizationKey, } from './organizationKeys';
|
|
22
|
-
export { KEYCHAIN_ACCESS_GROUPS, KeychainAccessUnavailableError, keychainGroupFor, mayHoldKeychainItem, resolveKeychainAccess, keychainAccessGroupValue, keychainReadPlan, keychainDeletePlan, keychainWriteOption, ownKeychainItemPlan, readThroughPlan, deleteThroughPlan, groupedKeychainStore, } from '../utils/keychainAccessGroups';
|
|
22
|
+
export { KEYCHAIN_ACCESS_GROUPS, KeychainAccessUnavailableError, keychainGroupFor, mayHoldKeychainItem, mayWriteKeychainItem, DEV_KEYCHAIN_GROUP_SUFFIX, resolveKeychainAccess, keychainAccessGroupValue, keychainReadPlan, keychainDeletePlan, keychainWriteOption, ownKeychainItemPlan, readThroughPlan, deleteThroughPlan, groupedKeychainStore, } from '../utils/keychainAccessGroups';
|
|
23
23
|
export type { KeychainAccess, KeychainAccessGroupName, KeychainGroup, KeychainGroupOption, KeychainItemClass, KeychainRole, KeychainStoreLike, OwnKeychainItemNames, } from '../utils/keychainAccessGroups';
|
|
24
|
-
export { ensureKeychainGroupsMigrated } from './keychainGroupMigration';
|
|
24
|
+
export { ensureKeychainGroupsMigrated, getPreSplitExposure, } from './keychainGroupMigration';
|
|
25
25
|
export type { KeychainGroupMigrationReport, KeychainItemMigrationOutcome, } from './keychainGroupMigration';
|
|
26
|
-
export { retireIdentityKey, listRetiredIdentityKeys, forgetRetiredIdentityKey, forgetRetiredIdentityKeys, syncRetiredKeys, retiredKeyFromPhrase, } from './retiredKeys';
|
|
26
|
+
export { retireIdentityKey, listRetiredIdentityKeys, forgetRetiredIdentityKey, forgetRetiredIdentityKeys, syncRetiredKeys, retiredKeyFromPhrase, retiredKeyFromPrivateKey, exportRetiredIdentityKeys, claimRetiredIdentityKeys, countQuarantinedRetiredKeys, unmirrorPersonalRetiredKeys, RetiredKeyRingUnreadableError, } from './retiredKeys';
|
|
27
27
|
export type { RetiredIdentityKey, RetiredKeyPair, RetiredKeySelector } from './retiredKeys';
|
|
28
28
|
export { SignatureService } from './signatureService';
|
|
29
29
|
export type { SignedMessage, AuthChallenge } from './signatureService';
|
|
@@ -290,9 +290,46 @@ export declare class KeyManager {
|
|
|
290
290
|
* `signSocialReceive`) is fresh, so a cached key here would let a caller
|
|
291
291
|
* compare a new seed against an old key.
|
|
292
292
|
*
|
|
293
|
+
* iOS, in an app that holds the private key (Commons, the Peable wallet:
|
|
294
|
+
* keychain roles `holder` and `wallet`): DERIVED from the private key in the
|
|
295
|
+
* wallet group, never read from the public-key item, which sits in the shared
|
|
296
|
+
* group every Oxy app can write (R5-1); `null` when that app holds no private
|
|
297
|
+
* key. Every other iOS app reads the public-key item: it holds nothing it
|
|
298
|
+
* could derive from, and it never spends. Money code uses
|
|
299
|
+
* {@link getIdentityPublicKeyForMoney}.
|
|
300
|
+
*
|
|
293
301
|
* @returns Shared public key or null if no shared identity exists
|
|
294
302
|
*/
|
|
295
303
|
static getSharedPublicKey(): Promise<string | null>;
|
|
304
|
+
/**
|
|
305
|
+
* Whether this iOS app holds spending keys (keychain role `holder` or
|
|
306
|
+
* `wallet`), so a public key it uses for money must be derived from them.
|
|
307
|
+
* Throws while the keychain cannot say. @internal
|
|
308
|
+
*/
|
|
309
|
+
private static _holdsSpendingKeys;
|
|
310
|
+
/**
|
|
311
|
+
* THE public key of the identity a money holder (the Peable wallet, Commons)
|
|
312
|
+
* receives and spends with: the personal key, or with `opts.accountId` that
|
|
313
|
+
* organization's (ADR 0061 D6). It is the public half of exactly the private
|
|
314
|
+
* key {@link deriveScopedSeed} and {@link signSocialReceive} use, so an
|
|
315
|
+
* address shown from it is one this app can spend (R5-1).
|
|
316
|
+
*
|
|
317
|
+
* - iOS (Commons, the Peable wallet): DERIVED from the private key in the
|
|
318
|
+
* restricted keychain groups (personal: the wallet group's slot, then
|
|
319
|
+
* Commons' primary; organization: its pair). Never the shared group's
|
|
320
|
+
* public-key item, which every Oxy app can write. `null` when no private
|
|
321
|
+
* key is held, and in every other iOS app.
|
|
322
|
+
* - Android: inside Commons derived from its own key; elsewhere Commons'
|
|
323
|
+
* identity host (`describe`), which answers from Commons' own signer
|
|
324
|
+
* store over signature-protected IPC.
|
|
325
|
+
* - Web: `null`.
|
|
326
|
+
*
|
|
327
|
+
* Compare the answer with the account's published `#key-1` before showing a
|
|
328
|
+
* receive address (Peable ADR 0002). Lowercase uncompressed (`04…`).
|
|
329
|
+
*/
|
|
330
|
+
static getIdentityPublicKeyForMoney(opts?: {
|
|
331
|
+
accountId?: string;
|
|
332
|
+
}): Promise<string | null>;
|
|
296
333
|
/**
|
|
297
334
|
* The shared private key, where this app may hold it: iOS only.
|
|
298
335
|
*
|
|
@@ -743,17 +780,23 @@ export declare class KeyManager {
|
|
|
743
780
|
* (personal, or `accountId`'s) whose public key it names: the current key
|
|
744
781
|
* when it matches, else a RETIRED key Commons kept after a rotation or
|
|
745
782
|
* replacement (`./retiredKeys`, ADR 0061 D7 as amended). Spending only: a
|
|
746
|
-
* retired key never signs in. Never falls back to another key.
|
|
783
|
+
* retired key never signs in. Never falls back to another key. A PERSONAL
|
|
784
|
+
* retired key is reached only with `opts.ownerAccountId`, the signed-in
|
|
785
|
+
* person's account id, and only when the ring keeps it for that account
|
|
786
|
+
* (R5-2); on Android outside Commons the identity host holds only the
|
|
787
|
+
* personal keys of the person Commons is signed in as.
|
|
747
788
|
*
|
|
748
789
|
* @param info Context/domain-binding label (distinct labels → independent seeds).
|
|
749
790
|
* @param opts.accountId An organization's account id, for its key.
|
|
750
791
|
* @param opts.publicKey Selects one key of that account by its public key.
|
|
792
|
+
* @param opts.ownerAccountId The person's account id, for a personal retired key.
|
|
751
793
|
* @returns 32 bytes of derived keying material, or `null` on web / when no
|
|
752
794
|
* identity key is available to this app.
|
|
753
795
|
*/
|
|
754
796
|
static deriveScopedSeed(info: string, opts?: {
|
|
755
797
|
accountId?: string;
|
|
756
798
|
publicKey?: string;
|
|
799
|
+
ownerAccountId?: string;
|
|
757
800
|
}): Promise<Uint8Array | null>;
|
|
758
801
|
/**
|
|
759
802
|
* {@link deriveScopedSeed} over an ORGANIZATION key (ADR 0061 D6): Commons'
|
|
@@ -780,6 +823,7 @@ export declare class KeyManager {
|
|
|
780
823
|
static signSocialReceive(index: number, digest: string, opts?: {
|
|
781
824
|
accountId?: string;
|
|
782
825
|
publicKey?: string;
|
|
826
|
+
ownerAccountId?: string;
|
|
783
827
|
}): Promise<CommonsSocialReceiveSignature | null>;
|
|
784
828
|
/**
|
|
785
829
|
* The private key of the account (personal, or `accountId`'s organization)
|
|
@@ -1,20 +1,42 @@
|
|
|
1
|
+
import { type KeychainAccess } from '../utils/keychainAccessGroups';
|
|
1
2
|
/** What happened to one item (or pair) in a run. */
|
|
2
|
-
export type KeychainItemMigrationOutcome = 'absent' | 'moved' | '
|
|
3
|
+
export type KeychainItemMigrationOutcome = 'absent' | 'moved' | 'retired' | 'discarded' | 'failed' | 'deferred';
|
|
3
4
|
export interface KeychainGroupMigrationReport {
|
|
4
5
|
/**
|
|
5
6
|
* - `not-applicable`: not Commons on a split-aware iOS build; nothing to move.
|
|
6
|
-
* - `complete`:
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* - `deferred`: the keychain could not be read (locked);
|
|
10
|
-
* storage access.
|
|
7
|
+
* - `complete`: the completion record exists; the shared group is never
|
|
8
|
+
* read again for a secret.
|
|
9
|
+
* - `incomplete`: a copy or delete failed; retried next launch.
|
|
10
|
+
* - `deferred`: the keychain or the marker could not be read (locked);
|
|
11
|
+
* retried on the next storage access.
|
|
11
12
|
*/
|
|
12
13
|
state: 'not-applicable' | 'complete' | 'incomplete' | 'deferred';
|
|
13
14
|
/** Outcome per item label (`primary`, `phrase`, `organization-1`, …). */
|
|
14
15
|
items: Record<string, KeychainItemMigrationOutcome>;
|
|
16
|
+
/** Whether this device's marker vouched for the shared copies (absent before a run). */
|
|
17
|
+
proven?: boolean;
|
|
15
18
|
}
|
|
16
19
|
/** Run the migration once. Exported for tests; production code awaits the gate. */
|
|
17
20
|
export declare function runKeychainGroupMigration(): Promise<KeychainGroupMigrationReport>;
|
|
21
|
+
/**
|
|
22
|
+
* What every keychain-backed identity access awaits first, by role: Commons
|
|
23
|
+
* runs (or has run) the migration; the Peable wallet checks once whether
|
|
24
|
+
* Commons' completion record is in the wallet group, and from then on never
|
|
25
|
+
* falls back to the shared group for a secret. Never throws.
|
|
26
|
+
*/
|
|
27
|
+
export declare function prepareKeychainAccess(access: KeychainAccess): Promise<void>;
|
|
28
|
+
/**
|
|
29
|
+
* Commons: whether this device's identity keys sat in the shared keychain
|
|
30
|
+
* group before the split, where every Oxy iOS app could read them. `publicKey`
|
|
31
|
+
* is the personal key that was exposed (recommend a rotation while it is still
|
|
32
|
+
* the current key), `organizations` the organization accounts whose key was.
|
|
33
|
+
* `null` when nothing was exposed, the migration has not finished, or this is
|
|
34
|
+
* not Commons on iOS.
|
|
35
|
+
*/
|
|
36
|
+
export declare function getPreSplitExposure(): Promise<{
|
|
37
|
+
publicKey: string | null;
|
|
38
|
+
organizations: string[];
|
|
39
|
+
} | null>;
|
|
18
40
|
/**
|
|
19
41
|
* The gate every Commons identity-storage access awaits. One run per process
|
|
20
42
|
* (`complete`, `incomplete` and `not-applicable` are kept; `incomplete` is
|
|
@@ -60,8 +60,10 @@ export declare function organizationKeyFromPhrase(phrase: string): Promise<Organ
|
|
|
60
60
|
* it; on iOS only Commons holds the groups it writes (any other app throws).
|
|
61
61
|
*
|
|
62
62
|
* Refuses a pair whose public key does not derive from its private key, and a
|
|
63
|
-
* phrase that does not derive the pair.
|
|
64
|
-
* the organization
|
|
63
|
+
* phrase that does not derive the pair. A DIFFERENT key this device already
|
|
64
|
+
* held for the organization is first kept in the organization's retired ring
|
|
65
|
+
* (ADR 0061 D7 as amended; R4-12): coins under it still need it. When that
|
|
66
|
+
* cannot be confirmed nothing is overwritten and this throws.
|
|
65
67
|
*/
|
|
66
68
|
export declare function storeOrganizationKey(accountId: string, key: StoredOrganizationKey): Promise<void>;
|
|
67
69
|
/**
|
|
@@ -41,10 +41,32 @@
|
|
|
41
41
|
*
|
|
42
42
|
* This module imports nothing from `./keyManager` (which imports it).
|
|
43
43
|
*/
|
|
44
|
-
/**
|
|
44
|
+
/**
|
|
45
|
+
* Whose ring: the person's (`accountId` absent) or an organization's.
|
|
46
|
+
*
|
|
47
|
+
* The personal ring is BOUND TO ITS OWNER (R5-2): every personal entry records
|
|
48
|
+
* the Oxy account id of the person whose key it was, and a personal read
|
|
49
|
+
* reaches only the entries of `ownerAccountId`. A phone outlives the person on
|
|
50
|
+
* it ("Start over" keeps the ring: it is money, recoverable from that account's
|
|
51
|
+
* backup), so the next person on the phone must never list, derive from or
|
|
52
|
+
* sign with a previous person's keys. A personal read without `ownerAccountId`
|
|
53
|
+
* reaches nothing. An entry whose owner is not known (a key the access-group
|
|
54
|
+
* migration kept, an entry written before 7.33.0) is QUARANTINED: invisible to
|
|
55
|
+
* every reader until Commons binds it with {@link claimRetiredIdentityKeys},
|
|
56
|
+
* which requires the account's key history to name it.
|
|
57
|
+
*/
|
|
45
58
|
export interface RetiredKeySelector {
|
|
46
59
|
/** An organization's account id; absent for the personal ring. */
|
|
47
60
|
accountId?: string;
|
|
61
|
+
/**
|
|
62
|
+
* The person's Oxy account id, for the PERSONAL ring: whose entries are
|
|
63
|
+
* reached. Ignored for an organization's ring (its owner is `accountId`).
|
|
64
|
+
*/
|
|
65
|
+
ownerAccountId?: string;
|
|
66
|
+
}
|
|
67
|
+
/** Thrown when the ring's index cannot be read as the ring: never treated as empty (R5-9). */
|
|
68
|
+
export declare class RetiredKeyRingUnreadableError extends Error {
|
|
69
|
+
constructor(reason: string);
|
|
48
70
|
}
|
|
49
71
|
/** One retired key as the ring lists it: public data only. */
|
|
50
72
|
export interface RetiredIdentityKey {
|
|
@@ -63,19 +85,53 @@ export interface RetiredKeyPair {
|
|
|
63
85
|
/** The ring a selector names: `personal`, or the organization's account id. */
|
|
64
86
|
export declare function retiredKeyScope(selector?: RetiredKeySelector): string | null;
|
|
65
87
|
/**
|
|
66
|
-
* Keep `key` in the ring of `selector`'s account. Commons only.
|
|
67
|
-
*
|
|
68
|
-
*
|
|
88
|
+
* Keep `key` in the ring of `selector`'s account. Commons only. A PERSONAL key
|
|
89
|
+
* needs `selector.ownerAccountId`, the person whose key it was (R5-2): the
|
|
90
|
+
* signed-in account at a rotation, the account whose key history or backup
|
|
91
|
+
* named it. Idempotent: a key already kept stays, with its first date; a
|
|
92
|
+
* quarantined entry of the same key is bound to that owner; an entry bound to
|
|
93
|
+
* a DIFFERENT owner is refused. Refuses a pair whose public key does not
|
|
94
|
+
* derive from its private key. Resolves once every copy confirmed it
|
|
69
95
|
* (Android: the signer store's read-back too), and throws otherwise, so a
|
|
70
96
|
* caller about to replace the key can stop before anything is lost.
|
|
71
97
|
*/
|
|
72
98
|
export declare function retireIdentityKey(key: RetiredKeyPair, selector?: RetiredKeySelector): Promise<void>;
|
|
99
|
+
/**
|
|
100
|
+
* {@link retireIdentityKey} from inside the iOS access-group migration, which
|
|
101
|
+
* keeps a pre-split copy before deleting it from the shared group and cannot
|
|
102
|
+
* await itself. A personal key without `ownerAccountId` is kept QUARANTINED:
|
|
103
|
+
* held (it may be money) but reached by nobody until
|
|
104
|
+
* {@link claimRetiredIdentityKeys} binds it. @internal
|
|
105
|
+
*/
|
|
106
|
+
export declare function retireIdentityKeyDuringMigration(key: RetiredKeyPair, selector?: RetiredKeySelector): Promise<void>;
|
|
107
|
+
/**
|
|
108
|
+
* Bind the QUARANTINED personal entries whose public key `publicKeys` names to
|
|
109
|
+
* `ownerAccountId` (R5-2, R5-3). Commons only, with the signed-in person's
|
|
110
|
+
* account id and that account's key history from Oxy
|
|
111
|
+
* (`oxy.identity.keyHistory()`: retired and current public keys): a key the
|
|
112
|
+
* account's own history names is that account's. Entries bound to anyone, and
|
|
113
|
+
* keys the history does not name, are left as they are. Resolves how many it
|
|
114
|
+
* bound; call {@link syncRetiredKeys} after it on Android.
|
|
115
|
+
*/
|
|
116
|
+
export declare function claimRetiredIdentityKeys(ownerAccountId: string, publicKeys: readonly string[]): Promise<number>;
|
|
117
|
+
/**
|
|
118
|
+
* How many personal entries are quarantined (no known owner). Commons shows
|
|
119
|
+
* them as "keys from before" the person can claim by adding the phrase, and
|
|
120
|
+
* {@link claimRetiredIdentityKeys} binds the ones the account's history names.
|
|
121
|
+
*/
|
|
122
|
+
export declare function countQuarantinedRetiredKeys(): Promise<number>;
|
|
73
123
|
/**
|
|
74
124
|
* The retired keys of `selector`'s account this app can reach, oldest first.
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
125
|
+
* The personal ring answers only `selector.ownerAccountId`'s entries (none
|
|
126
|
+
* without it; R5-2). iOS and Commons: the ring's index. Android elsewhere:
|
|
127
|
+
* Commons' identity host (the wallet only), which holds only the personal
|
|
128
|
+
* entries of the person Commons is signed in as and leaves out the scope's
|
|
129
|
+
* current key. After an interrupted rotation the local list can include the
|
|
130
|
+
* current key: compare with the DID's `#key-1` before treating an entry as
|
|
131
|
+
* retired, and check every entry against the account's key history.
|
|
132
|
+
*
|
|
133
|
+
* STRICT (R5-6): a ring that cannot be read throws (the keychain's error, or
|
|
134
|
+
* {@link RetiredKeyRingUnreadableError}); it never reads as empty.
|
|
79
135
|
*/
|
|
80
136
|
export declare function listRetiredIdentityKeys(selector?: RetiredKeySelector): Promise<RetiredIdentityKey[]>;
|
|
81
137
|
/**
|
|
@@ -93,19 +149,54 @@ export declare function getRetiredPrivateKey(publicKey: string, selector?: Retir
|
|
|
93
149
|
*/
|
|
94
150
|
export declare function forgetRetiredIdentityKey(publicKey: string, selector?: RetiredKeySelector): Promise<void>;
|
|
95
151
|
/**
|
|
96
|
-
* Forget every retired key of one ring (the person's, or an organization's)
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
152
|
+
* Forget every retired key of one ring (the person's, or an organization's).
|
|
153
|
+
* Commons only, and only when the person deletes their Oxy ACCOUNT (Commons'
|
|
154
|
+
* delete-account flow), which tells them the coins go with it. Deleting the
|
|
155
|
+
* identity from this device — "Start over", a forced delete — never calls it:
|
|
156
|
+
* the ring is money, and the same person restoring their identity later must
|
|
157
|
+
* find it (R4-4). Resolves how many it removed.
|
|
100
158
|
*/
|
|
101
159
|
export declare function forgetRetiredIdentityKeys(selector?: RetiredKeySelector): Promise<number>;
|
|
102
160
|
/**
|
|
103
|
-
* Commons on Android:
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
161
|
+
* Commons on Android: make the identity signer store hold exactly what the
|
|
162
|
+
* identity host may answer — every organization ring, and the personal entries
|
|
163
|
+
* of `ownerAccountId` (the person Commons is signed in as) — so the host
|
|
164
|
+
* answers for them after that file was rebuilt (its self-heal leaves it
|
|
165
|
+
* empty), and never for a previous person's keys (R5-2): every other personal
|
|
166
|
+
* entry is removed from the signer store (its own slot keeps it). Without
|
|
167
|
+
* `ownerAccountId` no personal entry is mirrored. Call on every boot and after
|
|
168
|
+
* every sign-in, beside `syncOrganizationKeys`. Resolves how many it mirrored.
|
|
169
|
+
* Elsewhere 0.
|
|
170
|
+
*/
|
|
171
|
+
export declare function syncRetiredKeys(ownerAccountId?: string): Promise<number>;
|
|
172
|
+
/**
|
|
173
|
+
* Every retired key of `selector`'s ring (personal: `ownerAccountId`'s entries)
|
|
174
|
+
* WITH its private half, for the encrypted identity backup
|
|
175
|
+
* (`oxy.identity.backup.create`), which carries them under the CURRENT phrase
|
|
176
|
+
* so a lost phone costs no more than the current phrase does (R4-4). Commons
|
|
177
|
+
* only. STRICT (R5-6): an unreadable ring, or a listed entry whose private
|
|
178
|
+
* half is missing or does not derive its public key, THROWS — a backup is
|
|
179
|
+
* never sealed without a key the ring says it holds. Never log or transmit the
|
|
180
|
+
* result except inside that encrypted envelope.
|
|
181
|
+
*/
|
|
182
|
+
export declare function exportRetiredIdentityKeys(selector?: RetiredKeySelector): Promise<Array<RetiredKeyPair & {
|
|
183
|
+
retiredAt: number;
|
|
184
|
+
}>>;
|
|
185
|
+
/**
|
|
186
|
+
* Commons on Android, when the identity leaves this device ("Start over", a
|
|
187
|
+
* forced delete): remove every PERSONAL entry from the identity signer store,
|
|
188
|
+
* so the identity host answers no other app with them before the next person
|
|
189
|
+
* signs in (R5-2). Commons' own slot and index keep them (money, bound to
|
|
190
|
+
* their owner). Elsewhere a no-op.
|
|
191
|
+
*/
|
|
192
|
+
export declare function unmirrorPersonalRetiredKeys(): Promise<void>;
|
|
193
|
+
/**
|
|
194
|
+
* The pair a previous key's raw PRIVATE KEY gives (64 hex, an optional `0x`),
|
|
195
|
+
* for a person whose old identity was imported as a private key and so has no
|
|
196
|
+
* phrase to re-enter. Stores nothing: the caller checks the public key against
|
|
197
|
+
* the account's key history and only then calls {@link retireIdentityKey}.
|
|
107
198
|
*/
|
|
108
|
-
export declare function
|
|
199
|
+
export declare function retiredKeyFromPrivateKey(privateKeyHex: string): RetiredKeyPair;
|
|
109
200
|
/**
|
|
110
201
|
* The pair a previous recovery phrase derives (`seed[0:32]`, the personal-root
|
|
111
202
|
* and organization-key derivation; 12–24 words). Stores nothing: the caller
|