@oxy.so/core 7.32.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 +11 -3
- package/dist/cjs/crypto/index.js +6 -2
- package/dist/cjs/crypto/keyManager.js +108 -10
- package/dist/cjs/crypto/retiredKeys.js +246 -57
- package/dist/esm/api/identity.js +11 -3
- package/dist/esm/crypto/index.js +1 -1
- package/dist/esm/crypto/keyManager.js +109 -11
- package/dist/esm/crypto/retiredKeys.js +241 -57
- package/dist/types/crypto/index.d.ts +1 -1
- package/dist/types/crypto/keyManager.d.ts +45 -1
- package/dist/types/crypto/retiredKeys.d.ts +86 -21
- package/package.json +1 -1
- package/src/api/identity.ts +15 -3
- package/src/crypto/index.ts +4 -0
- package/src/crypto/keyManager.ts +123 -16
- package/src/crypto/retiredKeys.ts +293 -59
|
@@ -64,10 +64,36 @@ import { isOrganizationAccountId } from './keychainItems';
|
|
|
64
64
|
import { prepareKeychainAccess } from './keychainGroupMigration';
|
|
65
65
|
import type { OxySecureStoreOptions as SecureStoreOptions } from './secureStoreOptions';
|
|
66
66
|
|
|
67
|
-
/**
|
|
67
|
+
/**
|
|
68
|
+
* Whose ring: the person's (`accountId` absent) or an organization's.
|
|
69
|
+
*
|
|
70
|
+
* The personal ring is BOUND TO ITS OWNER (R5-2): every personal entry records
|
|
71
|
+
* the Oxy account id of the person whose key it was, and a personal read
|
|
72
|
+
* reaches only the entries of `ownerAccountId`. A phone outlives the person on
|
|
73
|
+
* it ("Start over" keeps the ring: it is money, recoverable from that account's
|
|
74
|
+
* backup), so the next person on the phone must never list, derive from or
|
|
75
|
+
* sign with a previous person's keys. A personal read without `ownerAccountId`
|
|
76
|
+
* reaches nothing. An entry whose owner is not known (a key the access-group
|
|
77
|
+
* migration kept, an entry written before 7.33.0) is QUARANTINED: invisible to
|
|
78
|
+
* every reader until Commons binds it with {@link claimRetiredIdentityKeys},
|
|
79
|
+
* which requires the account's key history to name it.
|
|
80
|
+
*/
|
|
68
81
|
export interface RetiredKeySelector {
|
|
69
82
|
/** An organization's account id; absent for the personal ring. */
|
|
70
83
|
accountId?: string;
|
|
84
|
+
/**
|
|
85
|
+
* The person's Oxy account id, for the PERSONAL ring: whose entries are
|
|
86
|
+
* reached. Ignored for an organization's ring (its owner is `accountId`).
|
|
87
|
+
*/
|
|
88
|
+
ownerAccountId?: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Thrown when the ring's index cannot be read as the ring: never treated as empty (R5-9). */
|
|
92
|
+
export class RetiredKeyRingUnreadableError extends Error {
|
|
93
|
+
constructor(reason: string) {
|
|
94
|
+
super(`retired key ring: ${reason}`);
|
|
95
|
+
this.name = 'RetiredKeyRingUnreadableError';
|
|
96
|
+
}
|
|
71
97
|
}
|
|
72
98
|
|
|
73
99
|
/** One retired key as the ring lists it: public data only. */
|
|
@@ -164,6 +190,31 @@ interface IndexEntry {
|
|
|
164
190
|
scope: string;
|
|
165
191
|
publicKey: string;
|
|
166
192
|
retiredAt: number;
|
|
193
|
+
/**
|
|
194
|
+
* Personal entries: the Oxy account id of the person whose key it was.
|
|
195
|
+
* Absent: quarantined (never listed, derived from or signed with).
|
|
196
|
+
*/
|
|
197
|
+
owner?: string;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const ACCOUNT_ID =
|
|
201
|
+
/^(?:[0-9a-f]{24}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i;
|
|
202
|
+
|
|
203
|
+
/** A well-formed Oxy account id (24-hex or uuid), else `null`. */
|
|
204
|
+
function ownerOf(ownerAccountId: string | undefined): string | null {
|
|
205
|
+
return ownerAccountId !== undefined && ACCOUNT_ID.test(ownerAccountId)
|
|
206
|
+
? ownerAccountId.toLowerCase()
|
|
207
|
+
: null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Whether `entry` may be reached by a reader of `scope` acting for `owner`: an
|
|
212
|
+
* organization's entries by its own scope, a personal entry only by its owner.
|
|
213
|
+
*/
|
|
214
|
+
function isVisible(entry: IndexEntry, scope: string, owner: string | null): boolean {
|
|
215
|
+
if (entry.scope !== scope) return false;
|
|
216
|
+
if (scope !== PERSONAL_SCOPE) return true;
|
|
217
|
+
return owner !== null && entry.owner === owner;
|
|
167
218
|
}
|
|
168
219
|
|
|
169
220
|
function isIndexEntry(value: unknown): value is IndexEntry {
|
|
@@ -175,23 +226,33 @@ function isIndexEntry(value: unknown): value is IndexEntry {
|
|
|
175
226
|
typeof entry.publicKey === 'string' &&
|
|
176
227
|
PUBLIC_KEY.test(entry.publicKey) &&
|
|
177
228
|
typeof entry.retiredAt === 'number' &&
|
|
178
|
-
Number.isFinite(entry.retiredAt)
|
|
229
|
+
Number.isFinite(entry.retiredAt) &&
|
|
230
|
+
(entry.owner === undefined || (typeof entry.owner === 'string' && ACCOUNT_ID.test(entry.owner)))
|
|
179
231
|
);
|
|
180
232
|
}
|
|
181
233
|
|
|
234
|
+
/**
|
|
235
|
+
* The ring's index. STRICT (R5-6, R5-9): an index that does not parse, or that
|
|
236
|
+
* holds an entry this version cannot read, THROWS
|
|
237
|
+
* {@link RetiredKeyRingUnreadableError} instead of reading as empty, so no
|
|
238
|
+
* writer overwrites it (losing every entry) and no backup is sealed without
|
|
239
|
+
* the keys it names. A thrown keychain read propagates.
|
|
240
|
+
*/
|
|
182
241
|
async function readIndex(store: ExpoSecureStoreLike): Promise<IndexEntry[]> {
|
|
183
242
|
const raw = await store.getItemAsync(INDEX_STORAGE_KEY, indexOptions());
|
|
184
243
|
if (!raw) return [];
|
|
244
|
+
let parsed: unknown;
|
|
185
245
|
try {
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
});
|
|
193
|
-
return [];
|
|
246
|
+
parsed = JSON.parse(raw);
|
|
247
|
+
} catch {
|
|
248
|
+
throw new RetiredKeyRingUnreadableError('the index does not parse');
|
|
249
|
+
}
|
|
250
|
+
if (!Array.isArray(parsed) || !parsed.every(isIndexEntry)) {
|
|
251
|
+
throw new RetiredKeyRingUnreadableError('the index holds an entry this version cannot read');
|
|
194
252
|
}
|
|
253
|
+
return parsed.map((entry: IndexEntry) =>
|
|
254
|
+
entry.owner === undefined ? entry : { ...entry, owner: entry.owner.toLowerCase() },
|
|
255
|
+
);
|
|
195
256
|
}
|
|
196
257
|
|
|
197
258
|
async function writeIndex(store: ExpoSecureStoreLike, entries: IndexEntry[]): Promise<void> {
|
|
@@ -207,6 +268,7 @@ async function writeIndex(store: ExpoSecureStoreLike, entries: IndexEntry[]): Pr
|
|
|
207
268
|
}
|
|
208
269
|
|
|
209
270
|
function toListed(entry: IndexEntry): RetiredIdentityKey {
|
|
271
|
+
// The owner is the reader's own id; it is not repeated.
|
|
210
272
|
return entry.scope === PERSONAL_SCOPE
|
|
211
273
|
? { publicKey: entry.publicKey, retiredAt: entry.retiredAt }
|
|
212
274
|
: { publicKey: entry.publicKey, accountId: entry.scope, retiredAt: entry.retiredAt };
|
|
@@ -234,9 +296,13 @@ async function assertMayWrite(): Promise<void> {
|
|
|
234
296
|
}
|
|
235
297
|
|
|
236
298
|
/**
|
|
237
|
-
* Keep `key` in the ring of `selector`'s account. Commons only.
|
|
238
|
-
*
|
|
239
|
-
*
|
|
299
|
+
* Keep `key` in the ring of `selector`'s account. Commons only. A PERSONAL key
|
|
300
|
+
* needs `selector.ownerAccountId`, the person whose key it was (R5-2): the
|
|
301
|
+
* signed-in account at a rotation, the account whose key history or backup
|
|
302
|
+
* named it. Idempotent: a key already kept stays, with its first date; a
|
|
303
|
+
* quarantined entry of the same key is bound to that owner; an entry bound to
|
|
304
|
+
* a DIFFERENT owner is refused. Refuses a pair whose public key does not
|
|
305
|
+
* derive from its private key. Resolves once every copy confirmed it
|
|
240
306
|
* (Android: the signer store's read-back too), and throws otherwise, so a
|
|
241
307
|
* caller about to replace the key can stop before anything is lost.
|
|
242
308
|
*/
|
|
@@ -244,13 +310,19 @@ export async function retireIdentityKey(
|
|
|
244
310
|
key: RetiredKeyPair,
|
|
245
311
|
selector?: RetiredKeySelector,
|
|
246
312
|
): Promise<void> {
|
|
313
|
+
const scope = retiredKeyScope(selector);
|
|
314
|
+
if (scope === PERSONAL_SCOPE && ownerOf(selector?.ownerAccountId) === null) {
|
|
315
|
+
throw new Error('retired key: a personal key needs ownerAccountId, the account it belongs to');
|
|
316
|
+
}
|
|
247
317
|
await keepRetired(key, selector, true);
|
|
248
318
|
}
|
|
249
319
|
|
|
250
320
|
/**
|
|
251
321
|
* {@link retireIdentityKey} from inside the iOS access-group migration, which
|
|
252
|
-
* keeps a
|
|
253
|
-
*
|
|
322
|
+
* keeps a pre-split copy before deleting it from the shared group and cannot
|
|
323
|
+
* await itself. A personal key without `ownerAccountId` is kept QUARANTINED:
|
|
324
|
+
* held (it may be money) but reached by nobody until
|
|
325
|
+
* {@link claimRetiredIdentityKeys} binds it. @internal
|
|
254
326
|
*/
|
|
255
327
|
export async function retireIdentityKeyDuringMigration(
|
|
256
328
|
key: RetiredKeyPair,
|
|
@@ -266,6 +338,7 @@ async function keepRetired(
|
|
|
266
338
|
): Promise<void> {
|
|
267
339
|
const scope = retiredKeyScope(selector);
|
|
268
340
|
if (!scope) throw new Error('retired key: accountId must be a 24-hex or uuid account id');
|
|
341
|
+
const owner = scope === PERSONAL_SCOPE ? ownerOf(selector?.ownerAccountId) : null;
|
|
269
342
|
await assertMayWrite();
|
|
270
343
|
if (!isHealthyPair(key.privateKey, key.publicKey)) {
|
|
271
344
|
throw new Error('retired key: the public key does not derive from the private key');
|
|
@@ -273,6 +346,12 @@ async function keepRetired(
|
|
|
273
346
|
const privateKey = normalizeSecp256k1PrivateKey(key.privateKey);
|
|
274
347
|
const publicKey = normalizeSecp256k1PublicKey(key.publicKey);
|
|
275
348
|
const store = await ringStore(gate);
|
|
349
|
+
// Strict: an unreadable index stops the write before the key is touched.
|
|
350
|
+
const before = await readIndex(store);
|
|
351
|
+
const existing = before.find((entry) => entry.scope === scope && entry.publicKey === publicKey);
|
|
352
|
+
if (existing?.owner !== undefined && owner !== null && existing.owner !== owner) {
|
|
353
|
+
throw new Error('retired key: this key is kept for a different account');
|
|
354
|
+
}
|
|
276
355
|
await store.setItemAsync(
|
|
277
356
|
privateKeyName(scope, publicKey),
|
|
278
357
|
privateKey,
|
|
@@ -285,48 +364,112 @@ async function keepRetired(
|
|
|
285
364
|
if (readBack !== privateKey) {
|
|
286
365
|
throw new Error('retired key: the keychain did not confirm the key');
|
|
287
366
|
}
|
|
367
|
+
const index = await readIndex(store);
|
|
368
|
+
const kept = index.find((entry) => entry.scope === scope && entry.publicKey === publicKey);
|
|
369
|
+
if (!kept) {
|
|
370
|
+
await writeIndex(store, [
|
|
371
|
+
...index,
|
|
372
|
+
owner === null
|
|
373
|
+
? { scope, publicKey, retiredAt: Date.now() }
|
|
374
|
+
: { scope, publicKey, retiredAt: Date.now(), owner },
|
|
375
|
+
]);
|
|
376
|
+
} else if (kept.owner === undefined && owner !== null) {
|
|
377
|
+
await writeIndex(
|
|
378
|
+
store,
|
|
379
|
+
index.map((entry) => (entry === kept ? { ...entry, owner } : entry)),
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
// The identity host answers only from the signer store, which holds the
|
|
383
|
+
// organizations' rings and the CURRENT person's personal entries; a
|
|
384
|
+
// quarantined entry is never mirrored.
|
|
288
385
|
const signer = isAndroid() ? registeredIdentitySignerStore() : null;
|
|
289
|
-
if (
|
|
386
|
+
if (
|
|
387
|
+
signer?.writeRetired &&
|
|
388
|
+
(scope !== PERSONAL_SCOPE || owner !== null) &&
|
|
389
|
+
!(await signer.writeRetired(scope, privateKey, publicKey))
|
|
390
|
+
) {
|
|
290
391
|
throw new Error(`The identity signer store (${signer.name}) did not confirm the retired key.`);
|
|
291
392
|
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Bind the QUARANTINED personal entries whose public key `publicKeys` names to
|
|
397
|
+
* `ownerAccountId` (R5-2, R5-3). Commons only, with the signed-in person's
|
|
398
|
+
* account id and that account's key history from Oxy
|
|
399
|
+
* (`oxy.identity.keyHistory()`: retired and current public keys): a key the
|
|
400
|
+
* account's own history names is that account's. Entries bound to anyone, and
|
|
401
|
+
* keys the history does not name, are left as they are. Resolves how many it
|
|
402
|
+
* bound; call {@link syncRetiredKeys} after it on Android.
|
|
403
|
+
*/
|
|
404
|
+
export async function claimRetiredIdentityKeys(
|
|
405
|
+
ownerAccountId: string,
|
|
406
|
+
publicKeys: readonly string[],
|
|
407
|
+
): Promise<number> {
|
|
408
|
+
const owner = ownerOf(ownerAccountId);
|
|
409
|
+
if (owner === null) throw new Error('retired key: ownerAccountId must be an account id');
|
|
410
|
+
if (isWeb()) return 0;
|
|
411
|
+
await assertMayWrite();
|
|
412
|
+
const named = new Set(
|
|
413
|
+
publicKeys.map((key) => canonicalPublicKey(key)).filter((key): key is string => key !== null),
|
|
414
|
+
);
|
|
415
|
+
const store = await ringStore();
|
|
292
416
|
const index = await readIndex(store);
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
417
|
+
let bound = 0;
|
|
418
|
+
const next = index.map((entry) => {
|
|
419
|
+
if (entry.scope !== PERSONAL_SCOPE || entry.owner !== undefined) return entry;
|
|
420
|
+
if (!named.has(entry.publicKey)) return entry;
|
|
421
|
+
bound += 1;
|
|
422
|
+
return { ...entry, owner };
|
|
423
|
+
});
|
|
424
|
+
if (bound > 0) await writeIndex(store, next);
|
|
425
|
+
return bound;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* How many personal entries are quarantined (no known owner). Commons shows
|
|
430
|
+
* them as "keys from before" the person can claim by adding the phrase, and
|
|
431
|
+
* {@link claimRetiredIdentityKeys} binds the ones the account's history names.
|
|
432
|
+
*/
|
|
433
|
+
export async function countQuarantinedRetiredKeys(): Promise<number> {
|
|
434
|
+
if (isWeb() || (isAndroid() && !registeredIdentitySignerStore())) return 0;
|
|
435
|
+
const store = await ringStore();
|
|
436
|
+
return (await readIndex(store)).filter(
|
|
437
|
+
(entry) => entry.scope === PERSONAL_SCOPE && entry.owner === undefined,
|
|
438
|
+
).length;
|
|
296
439
|
}
|
|
297
440
|
|
|
298
441
|
/**
|
|
299
442
|
* The retired keys of `selector`'s account this app can reach, oldest first.
|
|
300
|
-
*
|
|
301
|
-
*
|
|
302
|
-
*
|
|
303
|
-
*
|
|
443
|
+
* The personal ring answers only `selector.ownerAccountId`'s entries (none
|
|
444
|
+
* without it; R5-2). iOS and Commons: the ring's index. Android elsewhere:
|
|
445
|
+
* Commons' identity host (the wallet only), which holds only the personal
|
|
446
|
+
* entries of the person Commons is signed in as and leaves out the scope's
|
|
447
|
+
* current key. After an interrupted rotation the local list can include the
|
|
448
|
+
* current key: compare with the DID's `#key-1` before treating an entry as
|
|
449
|
+
* retired, and check every entry against the account's key history.
|
|
450
|
+
*
|
|
451
|
+
* STRICT (R5-6): a ring that cannot be read throws (the keychain's error, or
|
|
452
|
+
* {@link RetiredKeyRingUnreadableError}); it never reads as empty.
|
|
304
453
|
*/
|
|
305
454
|
export async function listRetiredIdentityKeys(
|
|
306
455
|
selector?: RetiredKeySelector,
|
|
307
456
|
): Promise<RetiredIdentityKey[]> {
|
|
308
457
|
const scope = retiredKeyScope(selector);
|
|
309
458
|
if (!scope || isWeb()) return [];
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
if (!isIOS() && !isAndroid()) return [];
|
|
321
|
-
const store = await ringStore();
|
|
322
|
-
return (await readIndex(store)).filter((entry) => entry.scope === scope).map(toListed);
|
|
323
|
-
} catch (error) {
|
|
324
|
-
logger.warn('Failed to list retired keys', {
|
|
325
|
-
component: 'RetiredKeys',
|
|
326
|
-
error: error instanceof Error ? error.name : 'unknown',
|
|
327
|
-
});
|
|
328
|
-
return [];
|
|
459
|
+
const owner = ownerOf(selector?.ownerAccountId);
|
|
460
|
+
if (scope === PERSONAL_SCOPE && owner === null) return [];
|
|
461
|
+
if (isAndroid() && !registeredIdentitySignerStore()) {
|
|
462
|
+
const bridge = await loadCommonsIdentityBridge();
|
|
463
|
+
const keys = (await bridge?.listRetiredKeys?.(selector?.accountId)) ?? null;
|
|
464
|
+
return (keys ?? []).map((publicKey) =>
|
|
465
|
+
scope === PERSONAL_SCOPE
|
|
466
|
+
? { publicKey, retiredAt: 0 }
|
|
467
|
+
: { publicKey, accountId: scope, retiredAt: 0 },
|
|
468
|
+
);
|
|
329
469
|
}
|
|
470
|
+
if (!isIOS() && !isAndroid()) return [];
|
|
471
|
+
const store = await ringStore();
|
|
472
|
+
return (await readIndex(store)).filter((entry) => isVisible(entry, scope, owner)).map(toListed);
|
|
330
473
|
}
|
|
331
474
|
|
|
332
475
|
/**
|
|
@@ -343,11 +486,20 @@ export async function getRetiredPrivateKey(
|
|
|
343
486
|
const scope = retiredKeyScope(selector);
|
|
344
487
|
const canonical = canonicalPublicKey(publicKey);
|
|
345
488
|
if (!scope || !canonical || isWeb()) return null;
|
|
489
|
+
const owner = ownerOf(selector?.ownerAccountId);
|
|
490
|
+
if (scope === PERSONAL_SCOPE && owner === null) return null;
|
|
346
491
|
try {
|
|
347
492
|
if (isAndroid()) {
|
|
348
493
|
const signer = registeredIdentitySignerStore();
|
|
349
494
|
if (!signer) return null;
|
|
350
495
|
const store = await ringStore();
|
|
496
|
+
if (
|
|
497
|
+
!(await readIndex(store)).some(
|
|
498
|
+
(entry) => isVisible(entry, scope, owner) && entry.publicKey === canonical,
|
|
499
|
+
)
|
|
500
|
+
) {
|
|
501
|
+
return null;
|
|
502
|
+
}
|
|
351
503
|
const local = await store.getItemAsync(
|
|
352
504
|
privateKeyName(scope, canonical),
|
|
353
505
|
privateOptions(store, false),
|
|
@@ -361,6 +513,15 @@ export async function getRetiredPrivateKey(
|
|
|
361
513
|
}
|
|
362
514
|
if (!isIOS()) return null;
|
|
363
515
|
const store = await ringStore();
|
|
516
|
+
// The index decides who may reach a key (R5-2); a key it does not list for
|
|
517
|
+
// this reader is not theirs, whatever item exists.
|
|
518
|
+
if (
|
|
519
|
+
!(await readIndex(store)).some(
|
|
520
|
+
(entry) => isVisible(entry, scope, owner) && entry.publicKey === canonical,
|
|
521
|
+
)
|
|
522
|
+
) {
|
|
523
|
+
return null;
|
|
524
|
+
}
|
|
364
525
|
const value = await store.getItemAsync(
|
|
365
526
|
privateKeyName(scope, canonical),
|
|
366
527
|
privateOptions(store, false),
|
|
@@ -387,8 +548,13 @@ export async function forgetRetiredIdentityKey(
|
|
|
387
548
|
const scope = retiredKeyScope(selector);
|
|
388
549
|
const canonical = canonicalPublicKey(publicKey);
|
|
389
550
|
if (!scope || !canonical) throw new Error('retired key: invalid selector or public key');
|
|
551
|
+
const owner = ownerOf(selector?.ownerAccountId);
|
|
390
552
|
await assertMayWrite();
|
|
391
553
|
const store = await ringStore();
|
|
554
|
+
const visible = (await readIndex(store)).some(
|
|
555
|
+
(entry) => isVisible(entry, scope, owner) && entry.publicKey === canonical,
|
|
556
|
+
);
|
|
557
|
+
if (!visible) throw new Error('retired key: not a key of this account');
|
|
392
558
|
await store.deleteItemAsync(privateKeyName(scope, canonical), privateOptions(store, false));
|
|
393
559
|
const signer = isAndroid() ? registeredIdentitySignerStore() : null;
|
|
394
560
|
await signer?.clearRetired?.(scope, canonical);
|
|
@@ -412,12 +578,14 @@ export async function forgetRetiredIdentityKeys(selector?: RetiredKeySelector):
|
|
|
412
578
|
const scope = retiredKeyScope(selector);
|
|
413
579
|
if (!scope || isWeb()) return 0;
|
|
414
580
|
if (isAndroid() && !registeredIdentitySignerStore()) return 0;
|
|
581
|
+
const owner = ownerOf(selector?.ownerAccountId);
|
|
582
|
+
if (scope === PERSONAL_SCOPE && owner === null) return 0;
|
|
415
583
|
const store = await ringStore();
|
|
416
584
|
const index = await readIndex(store);
|
|
417
585
|
const signer = isAndroid() ? registeredIdentitySignerStore() : null;
|
|
418
586
|
let removed = 0;
|
|
419
587
|
for (const entry of index) {
|
|
420
|
-
if (entry
|
|
588
|
+
if (!isVisible(entry, scope, owner)) continue;
|
|
421
589
|
await store.deleteItemAsync(
|
|
422
590
|
privateKeyName(scope, entry.publicKey),
|
|
423
591
|
privateOptions(store, false),
|
|
@@ -427,23 +595,35 @@ export async function forgetRetiredIdentityKeys(selector?: RetiredKeySelector):
|
|
|
427
595
|
}
|
|
428
596
|
await writeIndex(
|
|
429
597
|
store,
|
|
430
|
-
index.filter((entry) => entry
|
|
598
|
+
index.filter((entry) => !isVisible(entry, scope, owner)),
|
|
431
599
|
);
|
|
432
600
|
return removed;
|
|
433
601
|
}
|
|
434
602
|
|
|
435
603
|
/**
|
|
436
|
-
* Commons on Android:
|
|
437
|
-
*
|
|
438
|
-
*
|
|
439
|
-
*
|
|
604
|
+
* Commons on Android: make the identity signer store hold exactly what the
|
|
605
|
+
* identity host may answer — every organization ring, and the personal entries
|
|
606
|
+
* of `ownerAccountId` (the person Commons is signed in as) — so the host
|
|
607
|
+
* answers for them after that file was rebuilt (its self-heal leaves it
|
|
608
|
+
* empty), and never for a previous person's keys (R5-2): every other personal
|
|
609
|
+
* entry is removed from the signer store (its own slot keeps it). Without
|
|
610
|
+
* `ownerAccountId` no personal entry is mirrored. Call on every boot and after
|
|
611
|
+
* every sign-in, beside `syncOrganizationKeys`. Resolves how many it mirrored.
|
|
612
|
+
* Elsewhere 0.
|
|
440
613
|
*/
|
|
441
|
-
export async function syncRetiredKeys(): Promise<number> {
|
|
614
|
+
export async function syncRetiredKeys(ownerAccountId?: string): Promise<number> {
|
|
442
615
|
const signer = isAndroid() ? registeredIdentitySignerStore() : null;
|
|
443
616
|
if (!signer?.writeRetired || !signer.readRetired) return 0;
|
|
617
|
+
const owner = ownerOf(ownerAccountId);
|
|
444
618
|
const store = await ringStore();
|
|
619
|
+
const index = await readIndex(store);
|
|
620
|
+
const allowed = new Set(
|
|
621
|
+
index.filter((entry) => isVisible(entry, PERSONAL_SCOPE, owner)).map((e) => e.publicKey),
|
|
622
|
+
);
|
|
623
|
+
await clearPersonalMirror(signer, index, allowed);
|
|
445
624
|
let mirrored = 0;
|
|
446
|
-
for (const entry of
|
|
625
|
+
for (const entry of index) {
|
|
626
|
+
if (entry.scope === PERSONAL_SCOPE && !isVisible(entry, PERSONAL_SCOPE, owner)) continue;
|
|
447
627
|
const privateKey = await store.getItemAsync(
|
|
448
628
|
privateKeyName(entry.scope, entry.publicKey),
|
|
449
629
|
privateOptions(store, false),
|
|
@@ -465,12 +645,14 @@ export async function syncRetiredKeys(): Promise<number> {
|
|
|
465
645
|
}
|
|
466
646
|
|
|
467
647
|
/**
|
|
468
|
-
* Every retired key of `selector`'s ring
|
|
469
|
-
*
|
|
470
|
-
* under the CURRENT phrase
|
|
471
|
-
* phrase does (R4-4). Commons
|
|
472
|
-
*
|
|
473
|
-
*
|
|
648
|
+
* Every retired key of `selector`'s ring (personal: `ownerAccountId`'s entries)
|
|
649
|
+
* WITH its private half, for the encrypted identity backup
|
|
650
|
+
* (`oxy.identity.backup.create`), which carries them under the CURRENT phrase
|
|
651
|
+
* so a lost phone costs no more than the current phrase does (R4-4). Commons
|
|
652
|
+
* only. STRICT (R5-6): an unreadable ring, or a listed entry whose private
|
|
653
|
+
* half is missing or does not derive its public key, THROWS — a backup is
|
|
654
|
+
* never sealed without a key the ring says it holds. Never log or transmit the
|
|
655
|
+
* result except inside that encrypted envelope.
|
|
474
656
|
*/
|
|
475
657
|
export async function exportRetiredIdentityKeys(
|
|
476
658
|
selector?: RetiredKeySelector,
|
|
@@ -480,13 +662,65 @@ export async function exportRetiredIdentityKeys(
|
|
|
480
662
|
await assertMayWrite();
|
|
481
663
|
const out: Array<RetiredKeyPair & { retiredAt: number }> = [];
|
|
482
664
|
for (const entry of await listRetiredIdentityKeys(selector)) {
|
|
483
|
-
const privateKey = await
|
|
484
|
-
|
|
485
|
-
out.push({ privateKey, publicKey: entry.publicKey, retiredAt: entry.retiredAt });
|
|
665
|
+
const privateKey = await readRetiredStrict(entry.publicKey, scope);
|
|
666
|
+
out.push({ privateKey, publicKey: entry.publicKey, retiredAt: entry.retiredAt });
|
|
486
667
|
}
|
|
487
668
|
return out;
|
|
488
669
|
}
|
|
489
670
|
|
|
671
|
+
/** A listed entry's private half, or a throw: Commons' own slot, then (Android) the signer copy. */
|
|
672
|
+
async function readRetiredStrict(publicKey: string, scope: string): Promise<string> {
|
|
673
|
+
const store = await ringStore();
|
|
674
|
+
const local = await store.getItemAsync(
|
|
675
|
+
privateKeyName(scope, publicKey),
|
|
676
|
+
privateOptions(store, false),
|
|
677
|
+
);
|
|
678
|
+
if (local && isHealthyPair(local, publicKey)) return normalizeSecp256k1PrivateKey(local);
|
|
679
|
+
const signer = isAndroid() ? registeredIdentitySignerStore() : null;
|
|
680
|
+
const mirrored = (await signer?.readRetired?.(scope, publicKey)) ?? null;
|
|
681
|
+
if (mirrored && isHealthyPair(mirrored.privateKey, publicKey)) {
|
|
682
|
+
return normalizeSecp256k1PrivateKey(mirrored.privateKey);
|
|
683
|
+
}
|
|
684
|
+
throw new RetiredKeyRingUnreadableError('a listed key has no readable private half');
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* Commons on Android, when the identity leaves this device ("Start over", a
|
|
689
|
+
* forced delete): remove every PERSONAL entry from the identity signer store,
|
|
690
|
+
* so the identity host answers no other app with them before the next person
|
|
691
|
+
* signs in (R5-2). Commons' own slot and index keep them (money, bound to
|
|
692
|
+
* their owner). Elsewhere a no-op.
|
|
693
|
+
*/
|
|
694
|
+
export async function unmirrorPersonalRetiredKeys(): Promise<void> {
|
|
695
|
+
const signer = isAndroid() ? registeredIdentitySignerStore() : null;
|
|
696
|
+
if (!signer?.clearRetired) return;
|
|
697
|
+
let index: IndexEntry[] = [];
|
|
698
|
+
try {
|
|
699
|
+
index = await readIndex(await ringStore());
|
|
700
|
+
} catch {
|
|
701
|
+
// The signer store's own list below still reaches every mirrored entry.
|
|
702
|
+
}
|
|
703
|
+
await clearPersonalMirror(signer, index, new Set());
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/** Remove from the signer store every personal entry not in `allowed`: the index's and the store's own list. */
|
|
707
|
+
async function clearPersonalMirror(
|
|
708
|
+
signer: NonNullable<ReturnType<typeof registeredIdentitySignerStore>>,
|
|
709
|
+
index: readonly IndexEntry[],
|
|
710
|
+
allowed: ReadonlySet<string>,
|
|
711
|
+
): Promise<void> {
|
|
712
|
+
if (!signer.clearRetired) return;
|
|
713
|
+
const mirrored = new Set(
|
|
714
|
+
index.filter((entry) => entry.scope === PERSONAL_SCOPE).map((entry) => entry.publicKey),
|
|
715
|
+
);
|
|
716
|
+
for (const publicKey of (await signer.listRetired?.(PERSONAL_SCOPE)) ?? []) {
|
|
717
|
+
mirrored.add(publicKey.toLowerCase());
|
|
718
|
+
}
|
|
719
|
+
for (const publicKey of mirrored) {
|
|
720
|
+
if (!allowed.has(publicKey)) await signer.clearRetired(PERSONAL_SCOPE, publicKey);
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
490
724
|
/**
|
|
491
725
|
* The pair a previous key's raw PRIVATE KEY gives (64 hex, an optional `0x`),
|
|
492
726
|
* for a person whose old identity was imported as a private key and so has no
|