@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
|
@@ -60,14 +60,40 @@ import { logger } from '../logger';
|
|
|
60
60
|
import { groupedKeychainStore, resolveKeychainAccess } from '../utils/keychainAccessGroups';
|
|
61
61
|
import { isAndroid, isIOS, isWeb } from '../utils/platform';
|
|
62
62
|
import { registeredIdentitySignerStore } from './identitySigner';
|
|
63
|
-
import {
|
|
64
|
-
import {
|
|
63
|
+
import { isOrganizationAccountId } from './keychainItems';
|
|
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. */
|
|
@@ -97,15 +123,14 @@ const PUBLIC_KEY = /^04[0-9a-f]{128}$/;
|
|
|
97
123
|
* The secure store for the ring. On iOS every item is a `spending-key`
|
|
98
124
|
* (ADR 0062): the private keys AND the index live in the wallet group, which
|
|
99
125
|
* only Commons and the Peable wallet can read, never the shared one. In
|
|
100
|
-
* Commons every access first awaits the access-group migration
|
|
101
|
-
* the
|
|
126
|
+
* Commons every access first awaits the access-group migration (`gate: false`
|
|
127
|
+
* is the migration itself, keeping a pre-split copy). On Android the grouping
|
|
128
|
+
* is a no-op and Commons' own services below apply.
|
|
102
129
|
*/
|
|
103
|
-
async function ringStore(): Promise<ExpoSecureStoreLike> {
|
|
130
|
+
async function ringStore(gate = true): Promise<ExpoSecureStoreLike> {
|
|
104
131
|
const raw = await loadSecureStore();
|
|
105
132
|
const access = await resolveKeychainAccess();
|
|
106
|
-
if (
|
|
107
|
-
await ensureKeychainGroupsMigrated();
|
|
108
|
-
}
|
|
133
|
+
if (gate) await prepareKeychainAccess(access);
|
|
109
134
|
return groupedKeychainStore(raw, access, 'spending-key');
|
|
110
135
|
}
|
|
111
136
|
|
|
@@ -165,6 +190,31 @@ interface IndexEntry {
|
|
|
165
190
|
scope: string;
|
|
166
191
|
publicKey: string;
|
|
167
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;
|
|
168
218
|
}
|
|
169
219
|
|
|
170
220
|
function isIndexEntry(value: unknown): value is IndexEntry {
|
|
@@ -176,23 +226,33 @@ function isIndexEntry(value: unknown): value is IndexEntry {
|
|
|
176
226
|
typeof entry.publicKey === 'string' &&
|
|
177
227
|
PUBLIC_KEY.test(entry.publicKey) &&
|
|
178
228
|
typeof entry.retiredAt === 'number' &&
|
|
179
|
-
Number.isFinite(entry.retiredAt)
|
|
229
|
+
Number.isFinite(entry.retiredAt) &&
|
|
230
|
+
(entry.owner === undefined || (typeof entry.owner === 'string' && ACCOUNT_ID.test(entry.owner)))
|
|
180
231
|
);
|
|
181
232
|
}
|
|
182
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
|
+
*/
|
|
183
241
|
async function readIndex(store: ExpoSecureStoreLike): Promise<IndexEntry[]> {
|
|
184
242
|
const raw = await store.getItemAsync(INDEX_STORAGE_KEY, indexOptions());
|
|
185
243
|
if (!raw) return [];
|
|
244
|
+
let parsed: unknown;
|
|
186
245
|
try {
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
});
|
|
194
|
-
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');
|
|
195
252
|
}
|
|
253
|
+
return parsed.map((entry: IndexEntry) =>
|
|
254
|
+
entry.owner === undefined ? entry : { ...entry, owner: entry.owner.toLowerCase() },
|
|
255
|
+
);
|
|
196
256
|
}
|
|
197
257
|
|
|
198
258
|
async function writeIndex(store: ExpoSecureStoreLike, entries: IndexEntry[]): Promise<void> {
|
|
@@ -208,41 +268,90 @@ async function writeIndex(store: ExpoSecureStoreLike, entries: IndexEntry[]): Pr
|
|
|
208
268
|
}
|
|
209
269
|
|
|
210
270
|
function toListed(entry: IndexEntry): RetiredIdentityKey {
|
|
271
|
+
// The owner is the reader's own id; it is not repeated.
|
|
211
272
|
return entry.scope === PERSONAL_SCOPE
|
|
212
273
|
? { publicKey: entry.publicKey, retiredAt: entry.retiredAt }
|
|
213
274
|
: { publicKey: entry.publicKey, accountId: entry.scope, retiredAt: entry.retiredAt };
|
|
214
275
|
}
|
|
215
276
|
|
|
216
|
-
/**
|
|
217
|
-
|
|
277
|
+
/**
|
|
278
|
+
* Whether this app writes the ring: Commons only (Android: the signer store it
|
|
279
|
+
* registers; iOS: the keychain role `holder`). The Peable wallet holds the
|
|
280
|
+
* wallet group too, but only reads, derives and signs: a key the wallet could
|
|
281
|
+
* add to the ring is a key it could choose (R4-14).
|
|
282
|
+
*/
|
|
283
|
+
async function assertMayWrite(): Promise<void> {
|
|
218
284
|
if (isWeb()) throw new Error('retired key: only a native device holds one');
|
|
219
285
|
if (isAndroid() && !registeredIdentitySignerStore()?.writeRetired) {
|
|
220
286
|
throw new Error(
|
|
221
287
|
'On Android only Commons holds retired keys; this app has no identity signer store for them.',
|
|
222
288
|
);
|
|
223
289
|
}
|
|
290
|
+
if (isIOS()) {
|
|
291
|
+
const access = await resolveKeychainAccess();
|
|
292
|
+
if (access.mode === 'grouped' && access.role !== 'holder') {
|
|
293
|
+
throw new Error('On iOS only Commons writes retired keys; this app may only read them.');
|
|
294
|
+
}
|
|
295
|
+
}
|
|
224
296
|
}
|
|
225
297
|
|
|
226
298
|
/**
|
|
227
|
-
* Keep `key` in the ring of `selector`'s account. Commons only.
|
|
228
|
-
*
|
|
229
|
-
*
|
|
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
|
|
230
306
|
* (Android: the signer store's read-back too), and throws otherwise, so a
|
|
231
307
|
* caller about to replace the key can stop before anything is lost.
|
|
232
308
|
*/
|
|
233
309
|
export async function retireIdentityKey(
|
|
234
310
|
key: RetiredKeyPair,
|
|
235
311
|
selector?: RetiredKeySelector,
|
|
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
|
+
}
|
|
317
|
+
await keepRetired(key, selector, true);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* {@link retireIdentityKey} from inside the iOS access-group migration, which
|
|
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
|
|
326
|
+
*/
|
|
327
|
+
export async function retireIdentityKeyDuringMigration(
|
|
328
|
+
key: RetiredKeyPair,
|
|
329
|
+
selector?: RetiredKeySelector,
|
|
330
|
+
): Promise<void> {
|
|
331
|
+
await keepRetired(key, selector, false);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async function keepRetired(
|
|
335
|
+
key: RetiredKeyPair,
|
|
336
|
+
selector: RetiredKeySelector | undefined,
|
|
337
|
+
gate: boolean,
|
|
236
338
|
): Promise<void> {
|
|
237
339
|
const scope = retiredKeyScope(selector);
|
|
238
340
|
if (!scope) throw new Error('retired key: accountId must be a 24-hex or uuid account id');
|
|
239
|
-
|
|
341
|
+
const owner = scope === PERSONAL_SCOPE ? ownerOf(selector?.ownerAccountId) : null;
|
|
342
|
+
await assertMayWrite();
|
|
240
343
|
if (!isHealthyPair(key.privateKey, key.publicKey)) {
|
|
241
344
|
throw new Error('retired key: the public key does not derive from the private key');
|
|
242
345
|
}
|
|
243
346
|
const privateKey = normalizeSecp256k1PrivateKey(key.privateKey);
|
|
244
347
|
const publicKey = normalizeSecp256k1PublicKey(key.publicKey);
|
|
245
|
-
const store = await ringStore();
|
|
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
|
+
}
|
|
246
355
|
await store.setItemAsync(
|
|
247
356
|
privateKeyName(scope, publicKey),
|
|
248
357
|
privateKey,
|
|
@@ -255,48 +364,112 @@ export async function retireIdentityKey(
|
|
|
255
364
|
if (readBack !== privateKey) {
|
|
256
365
|
throw new Error('retired key: the keychain did not confirm the key');
|
|
257
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.
|
|
258
385
|
const signer = isAndroid() ? registeredIdentitySignerStore() : null;
|
|
259
|
-
if (
|
|
386
|
+
if (
|
|
387
|
+
signer?.writeRetired &&
|
|
388
|
+
(scope !== PERSONAL_SCOPE || owner !== null) &&
|
|
389
|
+
!(await signer.writeRetired(scope, privateKey, publicKey))
|
|
390
|
+
) {
|
|
260
391
|
throw new Error(`The identity signer store (${signer.name}) did not confirm the retired key.`);
|
|
261
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();
|
|
262
416
|
const index = await readIndex(store);
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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;
|
|
266
439
|
}
|
|
267
440
|
|
|
268
441
|
/**
|
|
269
442
|
* The retired keys of `selector`'s account this app can reach, oldest first.
|
|
270
|
-
*
|
|
271
|
-
*
|
|
272
|
-
*
|
|
273
|
-
*
|
|
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.
|
|
274
453
|
*/
|
|
275
454
|
export async function listRetiredIdentityKeys(
|
|
276
455
|
selector?: RetiredKeySelector,
|
|
277
456
|
): Promise<RetiredIdentityKey[]> {
|
|
278
457
|
const scope = retiredKeyScope(selector);
|
|
279
458
|
if (!scope || isWeb()) return [];
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
if (!isIOS() && !isAndroid()) return [];
|
|
291
|
-
const store = await ringStore();
|
|
292
|
-
return (await readIndex(store)).filter((entry) => entry.scope === scope).map(toListed);
|
|
293
|
-
} catch (error) {
|
|
294
|
-
logger.warn('Failed to list retired keys', {
|
|
295
|
-
component: 'RetiredKeys',
|
|
296
|
-
error: error instanceof Error ? error.name : 'unknown',
|
|
297
|
-
});
|
|
298
|
-
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
|
+
);
|
|
299
469
|
}
|
|
470
|
+
if (!isIOS() && !isAndroid()) return [];
|
|
471
|
+
const store = await ringStore();
|
|
472
|
+
return (await readIndex(store)).filter((entry) => isVisible(entry, scope, owner)).map(toListed);
|
|
300
473
|
}
|
|
301
474
|
|
|
302
475
|
/**
|
|
@@ -313,11 +486,20 @@ export async function getRetiredPrivateKey(
|
|
|
313
486
|
const scope = retiredKeyScope(selector);
|
|
314
487
|
const canonical = canonicalPublicKey(publicKey);
|
|
315
488
|
if (!scope || !canonical || isWeb()) return null;
|
|
489
|
+
const owner = ownerOf(selector?.ownerAccountId);
|
|
490
|
+
if (scope === PERSONAL_SCOPE && owner === null) return null;
|
|
316
491
|
try {
|
|
317
492
|
if (isAndroid()) {
|
|
318
493
|
const signer = registeredIdentitySignerStore();
|
|
319
494
|
if (!signer) return null;
|
|
320
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
|
+
}
|
|
321
503
|
const local = await store.getItemAsync(
|
|
322
504
|
privateKeyName(scope, canonical),
|
|
323
505
|
privateOptions(store, false),
|
|
@@ -331,6 +513,15 @@ export async function getRetiredPrivateKey(
|
|
|
331
513
|
}
|
|
332
514
|
if (!isIOS()) return null;
|
|
333
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
|
+
}
|
|
334
525
|
const value = await store.getItemAsync(
|
|
335
526
|
privateKeyName(scope, canonical),
|
|
336
527
|
privateOptions(store, false),
|
|
@@ -357,8 +548,13 @@ export async function forgetRetiredIdentityKey(
|
|
|
357
548
|
const scope = retiredKeyScope(selector);
|
|
358
549
|
const canonical = canonicalPublicKey(publicKey);
|
|
359
550
|
if (!scope || !canonical) throw new Error('retired key: invalid selector or public key');
|
|
360
|
-
|
|
551
|
+
const owner = ownerOf(selector?.ownerAccountId);
|
|
552
|
+
await assertMayWrite();
|
|
361
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');
|
|
362
558
|
await store.deleteItemAsync(privateKeyName(scope, canonical), privateOptions(store, false));
|
|
363
559
|
const signer = isAndroid() ? registeredIdentitySignerStore() : null;
|
|
364
560
|
await signer?.clearRetired?.(scope, canonical);
|
|
@@ -371,21 +567,25 @@ export async function forgetRetiredIdentityKey(
|
|
|
371
567
|
}
|
|
372
568
|
|
|
373
569
|
/**
|
|
374
|
-
* Forget every retired key of one ring (the person's, or an organization's)
|
|
375
|
-
*
|
|
376
|
-
*
|
|
377
|
-
*
|
|
570
|
+
* Forget every retired key of one ring (the person's, or an organization's).
|
|
571
|
+
* Commons only, and only when the person deletes their Oxy ACCOUNT (Commons'
|
|
572
|
+
* delete-account flow), which tells them the coins go with it. Deleting the
|
|
573
|
+
* identity from this device — "Start over", a forced delete — never calls it:
|
|
574
|
+
* the ring is money, and the same person restoring their identity later must
|
|
575
|
+
* find it (R4-4). Resolves how many it removed.
|
|
378
576
|
*/
|
|
379
577
|
export async function forgetRetiredIdentityKeys(selector?: RetiredKeySelector): Promise<number> {
|
|
380
578
|
const scope = retiredKeyScope(selector);
|
|
381
579
|
if (!scope || isWeb()) return 0;
|
|
382
580
|
if (isAndroid() && !registeredIdentitySignerStore()) return 0;
|
|
581
|
+
const owner = ownerOf(selector?.ownerAccountId);
|
|
582
|
+
if (scope === PERSONAL_SCOPE && owner === null) return 0;
|
|
383
583
|
const store = await ringStore();
|
|
384
584
|
const index = await readIndex(store);
|
|
385
585
|
const signer = isAndroid() ? registeredIdentitySignerStore() : null;
|
|
386
586
|
let removed = 0;
|
|
387
587
|
for (const entry of index) {
|
|
388
|
-
if (entry
|
|
588
|
+
if (!isVisible(entry, scope, owner)) continue;
|
|
389
589
|
await store.deleteItemAsync(
|
|
390
590
|
privateKeyName(scope, entry.publicKey),
|
|
391
591
|
privateOptions(store, false),
|
|
@@ -395,23 +595,35 @@ export async function forgetRetiredIdentityKeys(selector?: RetiredKeySelector):
|
|
|
395
595
|
}
|
|
396
596
|
await writeIndex(
|
|
397
597
|
store,
|
|
398
|
-
index.filter((entry) => entry
|
|
598
|
+
index.filter((entry) => !isVisible(entry, scope, owner)),
|
|
399
599
|
);
|
|
400
600
|
return removed;
|
|
401
601
|
}
|
|
402
602
|
|
|
403
603
|
/**
|
|
404
|
-
* Commons on Android:
|
|
405
|
-
*
|
|
406
|
-
*
|
|
407
|
-
*
|
|
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.
|
|
408
613
|
*/
|
|
409
|
-
export async function syncRetiredKeys(): Promise<number> {
|
|
614
|
+
export async function syncRetiredKeys(ownerAccountId?: string): Promise<number> {
|
|
410
615
|
const signer = isAndroid() ? registeredIdentitySignerStore() : null;
|
|
411
616
|
if (!signer?.writeRetired || !signer.readRetired) return 0;
|
|
617
|
+
const owner = ownerOf(ownerAccountId);
|
|
412
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);
|
|
413
624
|
let mirrored = 0;
|
|
414
|
-
for (const entry of
|
|
625
|
+
for (const entry of index) {
|
|
626
|
+
if (entry.scope === PERSONAL_SCOPE && !isVisible(entry, PERSONAL_SCOPE, owner)) continue;
|
|
415
627
|
const privateKey = await store.getItemAsync(
|
|
416
628
|
privateKeyName(entry.scope, entry.publicKey),
|
|
417
629
|
privateOptions(store, false),
|
|
@@ -432,6 +644,98 @@ export async function syncRetiredKeys(): Promise<number> {
|
|
|
432
644
|
return mirrored;
|
|
433
645
|
}
|
|
434
646
|
|
|
647
|
+
/**
|
|
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.
|
|
656
|
+
*/
|
|
657
|
+
export async function exportRetiredIdentityKeys(
|
|
658
|
+
selector?: RetiredKeySelector,
|
|
659
|
+
): Promise<Array<RetiredKeyPair & { retiredAt: number }>> {
|
|
660
|
+
const scope = retiredKeyScope(selector);
|
|
661
|
+
if (!scope || isWeb()) return [];
|
|
662
|
+
await assertMayWrite();
|
|
663
|
+
const out: Array<RetiredKeyPair & { retiredAt: number }> = [];
|
|
664
|
+
for (const entry of await listRetiredIdentityKeys(selector)) {
|
|
665
|
+
const privateKey = await readRetiredStrict(entry.publicKey, scope);
|
|
666
|
+
out.push({ privateKey, publicKey: entry.publicKey, retiredAt: entry.retiredAt });
|
|
667
|
+
}
|
|
668
|
+
return out;
|
|
669
|
+
}
|
|
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
|
+
|
|
724
|
+
/**
|
|
725
|
+
* The pair a previous key's raw PRIVATE KEY gives (64 hex, an optional `0x`),
|
|
726
|
+
* for a person whose old identity was imported as a private key and so has no
|
|
727
|
+
* phrase to re-enter. Stores nothing: the caller checks the public key against
|
|
728
|
+
* the account's key history and only then calls {@link retireIdentityKey}.
|
|
729
|
+
*/
|
|
730
|
+
export function retiredKeyFromPrivateKey(privateKeyHex: string): RetiredKeyPair {
|
|
731
|
+
const trimmed = privateKeyHex.trim().toLowerCase().replace(/^0x/, '');
|
|
732
|
+
if (!/^[0-9a-f]{64}$/.test(trimmed) || !isValidSecp256k1PrivateKey(trimmed)) {
|
|
733
|
+
throw new Error('retired key: not a valid private key');
|
|
734
|
+
}
|
|
735
|
+
const privateKey = normalizeSecp256k1PrivateKey(trimmed);
|
|
736
|
+
return { privateKey, publicKey: publicKeyOf(privateKey) };
|
|
737
|
+
}
|
|
738
|
+
|
|
435
739
|
/**
|
|
436
740
|
* The pair a previous recovery phrase derives (`seed[0:32]`, the personal-root
|
|
437
741
|
* and organization-key derivation; 12–24 words). Stores nothing: the caller
|