@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
|
@@ -43,15 +43,19 @@
|
|
|
43
43
|
* This module imports nothing from `./keyManager` (which imports it).
|
|
44
44
|
*/
|
|
45
45
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
46
|
+
exports.RetiredKeyRingUnreadableError = void 0;
|
|
46
47
|
exports.retiredKeyScope = retiredKeyScope;
|
|
47
48
|
exports.retireIdentityKey = retireIdentityKey;
|
|
48
49
|
exports.retireIdentityKeyDuringMigration = retireIdentityKeyDuringMigration;
|
|
50
|
+
exports.claimRetiredIdentityKeys = claimRetiredIdentityKeys;
|
|
51
|
+
exports.countQuarantinedRetiredKeys = countQuarantinedRetiredKeys;
|
|
49
52
|
exports.listRetiredIdentityKeys = listRetiredIdentityKeys;
|
|
50
53
|
exports.getRetiredPrivateKey = getRetiredPrivateKey;
|
|
51
54
|
exports.forgetRetiredIdentityKey = forgetRetiredIdentityKey;
|
|
52
55
|
exports.forgetRetiredIdentityKeys = forgetRetiredIdentityKeys;
|
|
53
56
|
exports.syncRetiredKeys = syncRetiredKeys;
|
|
54
57
|
exports.exportRetiredIdentityKeys = exportRetiredIdentityKeys;
|
|
58
|
+
exports.unmirrorPersonalRetiredKeys = unmirrorPersonalRetiredKeys;
|
|
55
59
|
exports.retiredKeyFromPrivateKey = retiredKeyFromPrivateKey;
|
|
56
60
|
exports.retiredKeyFromPhrase = retiredKeyFromPhrase;
|
|
57
61
|
const bip39_1 = require("@scure/bip39");
|
|
@@ -64,6 +68,14 @@ const platform_1 = require("../utils/platform");
|
|
|
64
68
|
const identitySigner_1 = require("./identitySigner");
|
|
65
69
|
const keychainItems_1 = require("./keychainItems");
|
|
66
70
|
const keychainGroupMigration_1 = require("./keychainGroupMigration");
|
|
71
|
+
/** Thrown when the ring's index cannot be read as the ring: never treated as empty (R5-9). */
|
|
72
|
+
class RetiredKeyRingUnreadableError extends Error {
|
|
73
|
+
constructor(reason) {
|
|
74
|
+
super(`retired key ring: ${reason}`);
|
|
75
|
+
this.name = 'RetiredKeyRingUnreadableError';
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
exports.RetiredKeyRingUnreadableError = RetiredKeyRingUnreadableError;
|
|
67
79
|
const PERSONAL_SCOPE = 'personal';
|
|
68
80
|
/** Android, Commons-local: its own Keystore-backed slot, shared with no other key. */
|
|
69
81
|
const ANDROID_KEYCHAIN_SERVICE = 'oxy_retired_identity';
|
|
@@ -132,6 +144,24 @@ function privateOptions(store, forWrite) {
|
|
|
132
144
|
function indexOptions() {
|
|
133
145
|
return (0, platform_1.isIOS)() ? {} : { keychainService: INDEX_KEYCHAIN_SERVICE };
|
|
134
146
|
}
|
|
147
|
+
const ACCOUNT_ID = /^(?:[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;
|
|
148
|
+
/** A well-formed Oxy account id (24-hex or uuid), else `null`. */
|
|
149
|
+
function ownerOf(ownerAccountId) {
|
|
150
|
+
return ownerAccountId !== undefined && ACCOUNT_ID.test(ownerAccountId)
|
|
151
|
+
? ownerAccountId.toLowerCase()
|
|
152
|
+
: null;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Whether `entry` may be reached by a reader of `scope` acting for `owner`: an
|
|
156
|
+
* organization's entries by its own scope, a personal entry only by its owner.
|
|
157
|
+
*/
|
|
158
|
+
function isVisible(entry, scope, owner) {
|
|
159
|
+
if (entry.scope !== scope)
|
|
160
|
+
return false;
|
|
161
|
+
if (scope !== PERSONAL_SCOPE)
|
|
162
|
+
return true;
|
|
163
|
+
return owner !== null && entry.owner === owner;
|
|
164
|
+
}
|
|
135
165
|
function isIndexEntry(value) {
|
|
136
166
|
if (typeof value !== 'object' || value === null)
|
|
137
167
|
return false;
|
|
@@ -141,23 +171,31 @@ function isIndexEntry(value) {
|
|
|
141
171
|
typeof entry.publicKey === 'string' &&
|
|
142
172
|
PUBLIC_KEY.test(entry.publicKey) &&
|
|
143
173
|
typeof entry.retiredAt === 'number' &&
|
|
144
|
-
Number.isFinite(entry.retiredAt)
|
|
174
|
+
Number.isFinite(entry.retiredAt) &&
|
|
175
|
+
(entry.owner === undefined || (typeof entry.owner === 'string' && ACCOUNT_ID.test(entry.owner))));
|
|
145
176
|
}
|
|
177
|
+
/**
|
|
178
|
+
* The ring's index. STRICT (R5-6, R5-9): an index that does not parse, or that
|
|
179
|
+
* holds an entry this version cannot read, THROWS
|
|
180
|
+
* {@link RetiredKeyRingUnreadableError} instead of reading as empty, so no
|
|
181
|
+
* writer overwrites it (losing every entry) and no backup is sealed without
|
|
182
|
+
* the keys it names. A thrown keychain read propagates.
|
|
183
|
+
*/
|
|
146
184
|
async function readIndex(store) {
|
|
147
185
|
const raw = await store.getItemAsync(INDEX_STORAGE_KEY, indexOptions());
|
|
148
186
|
if (!raw)
|
|
149
187
|
return [];
|
|
188
|
+
let parsed;
|
|
150
189
|
try {
|
|
151
|
-
|
|
152
|
-
return Array.isArray(parsed) ? parsed.filter(isIndexEntry) : [];
|
|
190
|
+
parsed = JSON.parse(raw);
|
|
153
191
|
}
|
|
154
|
-
catch
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
return [];
|
|
192
|
+
catch {
|
|
193
|
+
throw new RetiredKeyRingUnreadableError('the index does not parse');
|
|
194
|
+
}
|
|
195
|
+
if (!Array.isArray(parsed) || !parsed.every(isIndexEntry)) {
|
|
196
|
+
throw new RetiredKeyRingUnreadableError('the index holds an entry this version cannot read');
|
|
160
197
|
}
|
|
198
|
+
return parsed.map((entry) => entry.owner === undefined ? entry : { ...entry, owner: entry.owner.toLowerCase() });
|
|
161
199
|
}
|
|
162
200
|
async function writeIndex(store, entries) {
|
|
163
201
|
const unique = new Map();
|
|
@@ -170,6 +208,7 @@ async function writeIndex(store, entries) {
|
|
|
170
208
|
await store.setItemAsync(INDEX_STORAGE_KEY, JSON.stringify(sorted), indexOptions());
|
|
171
209
|
}
|
|
172
210
|
function toListed(entry) {
|
|
211
|
+
// The owner is the reader's own id; it is not repeated.
|
|
173
212
|
return entry.scope === PERSONAL_SCOPE
|
|
174
213
|
? { publicKey: entry.publicKey, retiredAt: entry.retiredAt }
|
|
175
214
|
: { publicKey: entry.publicKey, accountId: entry.scope, retiredAt: entry.retiredAt };
|
|
@@ -194,19 +233,29 @@ async function assertMayWrite() {
|
|
|
194
233
|
}
|
|
195
234
|
}
|
|
196
235
|
/**
|
|
197
|
-
* Keep `key` in the ring of `selector`'s account. Commons only.
|
|
198
|
-
*
|
|
199
|
-
*
|
|
236
|
+
* Keep `key` in the ring of `selector`'s account. Commons only. A PERSONAL key
|
|
237
|
+
* needs `selector.ownerAccountId`, the person whose key it was (R5-2): the
|
|
238
|
+
* signed-in account at a rotation, the account whose key history or backup
|
|
239
|
+
* named it. Idempotent: a key already kept stays, with its first date; a
|
|
240
|
+
* quarantined entry of the same key is bound to that owner; an entry bound to
|
|
241
|
+
* a DIFFERENT owner is refused. Refuses a pair whose public key does not
|
|
242
|
+
* derive from its private key. Resolves once every copy confirmed it
|
|
200
243
|
* (Android: the signer store's read-back too), and throws otherwise, so a
|
|
201
244
|
* caller about to replace the key can stop before anything is lost.
|
|
202
245
|
*/
|
|
203
246
|
async function retireIdentityKey(key, selector) {
|
|
247
|
+
const scope = retiredKeyScope(selector);
|
|
248
|
+
if (scope === PERSONAL_SCOPE && ownerOf(selector?.ownerAccountId) === null) {
|
|
249
|
+
throw new Error('retired key: a personal key needs ownerAccountId, the account it belongs to');
|
|
250
|
+
}
|
|
204
251
|
await keepRetired(key, selector, true);
|
|
205
252
|
}
|
|
206
253
|
/**
|
|
207
254
|
* {@link retireIdentityKey} from inside the iOS access-group migration, which
|
|
208
|
-
* keeps a
|
|
209
|
-
*
|
|
255
|
+
* keeps a pre-split copy before deleting it from the shared group and cannot
|
|
256
|
+
* await itself. A personal key without `ownerAccountId` is kept QUARANTINED:
|
|
257
|
+
* held (it may be money) but reached by nobody until
|
|
258
|
+
* {@link claimRetiredIdentityKeys} binds it. @internal
|
|
210
259
|
*/
|
|
211
260
|
async function retireIdentityKeyDuringMigration(key, selector) {
|
|
212
261
|
await keepRetired(key, selector, false);
|
|
@@ -215,6 +264,7 @@ async function keepRetired(key, selector, gate) {
|
|
|
215
264
|
const scope = retiredKeyScope(selector);
|
|
216
265
|
if (!scope)
|
|
217
266
|
throw new Error('retired key: accountId must be a 24-hex or uuid account id');
|
|
267
|
+
const owner = scope === PERSONAL_SCOPE ? ownerOf(selector?.ownerAccountId) : null;
|
|
218
268
|
await assertMayWrite();
|
|
219
269
|
if (!isHealthyPair(key.privateKey, key.publicKey)) {
|
|
220
270
|
throw new Error('retired key: the public key does not derive from the private key');
|
|
@@ -222,51 +272,114 @@ async function keepRetired(key, selector, gate) {
|
|
|
222
272
|
const privateKey = (0, secp256k1_1.normalizeSecp256k1PrivateKey)(key.privateKey);
|
|
223
273
|
const publicKey = (0, secp256k1_1.normalizeSecp256k1PublicKey)(key.publicKey);
|
|
224
274
|
const store = await ringStore(gate);
|
|
275
|
+
// Strict: an unreadable index stops the write before the key is touched.
|
|
276
|
+
const before = await readIndex(store);
|
|
277
|
+
const existing = before.find((entry) => entry.scope === scope && entry.publicKey === publicKey);
|
|
278
|
+
if (existing?.owner !== undefined && owner !== null && existing.owner !== owner) {
|
|
279
|
+
throw new Error('retired key: this key is kept for a different account');
|
|
280
|
+
}
|
|
225
281
|
await store.setItemAsync(privateKeyName(scope, publicKey), privateKey, privateOptions(store, true));
|
|
226
282
|
const readBack = await store.getItemAsync(privateKeyName(scope, publicKey), privateOptions(store, false));
|
|
227
283
|
if (readBack !== privateKey) {
|
|
228
284
|
throw new Error('retired key: the keychain did not confirm the key');
|
|
229
285
|
}
|
|
286
|
+
const index = await readIndex(store);
|
|
287
|
+
const kept = index.find((entry) => entry.scope === scope && entry.publicKey === publicKey);
|
|
288
|
+
if (!kept) {
|
|
289
|
+
await writeIndex(store, [
|
|
290
|
+
...index,
|
|
291
|
+
owner === null
|
|
292
|
+
? { scope, publicKey, retiredAt: Date.now() }
|
|
293
|
+
: { scope, publicKey, retiredAt: Date.now(), owner },
|
|
294
|
+
]);
|
|
295
|
+
}
|
|
296
|
+
else if (kept.owner === undefined && owner !== null) {
|
|
297
|
+
await writeIndex(store, index.map((entry) => (entry === kept ? { ...entry, owner } : entry)));
|
|
298
|
+
}
|
|
299
|
+
// The identity host answers only from the signer store, which holds the
|
|
300
|
+
// organizations' rings and the CURRENT person's personal entries; a
|
|
301
|
+
// quarantined entry is never mirrored.
|
|
230
302
|
const signer = (0, platform_1.isAndroid)() ? (0, identitySigner_1.registeredIdentitySignerStore)() : null;
|
|
231
|
-
if (signer?.writeRetired &&
|
|
303
|
+
if (signer?.writeRetired &&
|
|
304
|
+
(scope !== PERSONAL_SCOPE || owner !== null) &&
|
|
305
|
+
!(await signer.writeRetired(scope, privateKey, publicKey))) {
|
|
232
306
|
throw new Error(`The identity signer store (${signer.name}) did not confirm the retired key.`);
|
|
233
307
|
}
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Bind the QUARANTINED personal entries whose public key `publicKeys` names to
|
|
311
|
+
* `ownerAccountId` (R5-2, R5-3). Commons only, with the signed-in person's
|
|
312
|
+
* account id and that account's key history from Oxy
|
|
313
|
+
* (`oxy.identity.keyHistory()`: retired and current public keys): a key the
|
|
314
|
+
* account's own history names is that account's. Entries bound to anyone, and
|
|
315
|
+
* keys the history does not name, are left as they are. Resolves how many it
|
|
316
|
+
* bound; call {@link syncRetiredKeys} after it on Android.
|
|
317
|
+
*/
|
|
318
|
+
async function claimRetiredIdentityKeys(ownerAccountId, publicKeys) {
|
|
319
|
+
const owner = ownerOf(ownerAccountId);
|
|
320
|
+
if (owner === null)
|
|
321
|
+
throw new Error('retired key: ownerAccountId must be an account id');
|
|
322
|
+
if ((0, platform_1.isWeb)())
|
|
323
|
+
return 0;
|
|
324
|
+
await assertMayWrite();
|
|
325
|
+
const named = new Set(publicKeys.map((key) => canonicalPublicKey(key)).filter((key) => key !== null));
|
|
326
|
+
const store = await ringStore();
|
|
234
327
|
const index = await readIndex(store);
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
328
|
+
let bound = 0;
|
|
329
|
+
const next = index.map((entry) => {
|
|
330
|
+
if (entry.scope !== PERSONAL_SCOPE || entry.owner !== undefined)
|
|
331
|
+
return entry;
|
|
332
|
+
if (!named.has(entry.publicKey))
|
|
333
|
+
return entry;
|
|
334
|
+
bound += 1;
|
|
335
|
+
return { ...entry, owner };
|
|
336
|
+
});
|
|
337
|
+
if (bound > 0)
|
|
338
|
+
await writeIndex(store, next);
|
|
339
|
+
return bound;
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* How many personal entries are quarantined (no known owner). Commons shows
|
|
343
|
+
* them as "keys from before" the person can claim by adding the phrase, and
|
|
344
|
+
* {@link claimRetiredIdentityKeys} binds the ones the account's history names.
|
|
345
|
+
*/
|
|
346
|
+
async function countQuarantinedRetiredKeys() {
|
|
347
|
+
if ((0, platform_1.isWeb)() || ((0, platform_1.isAndroid)() && !(0, identitySigner_1.registeredIdentitySignerStore)()))
|
|
348
|
+
return 0;
|
|
349
|
+
const store = await ringStore();
|
|
350
|
+
return (await readIndex(store)).filter((entry) => entry.scope === PERSONAL_SCOPE && entry.owner === undefined).length;
|
|
238
351
|
}
|
|
239
352
|
/**
|
|
240
353
|
* The retired keys of `selector`'s account this app can reach, oldest first.
|
|
241
|
-
*
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
*
|
|
354
|
+
* The personal ring answers only `selector.ownerAccountId`'s entries (none
|
|
355
|
+
* without it; R5-2). iOS and Commons: the ring's index. Android elsewhere:
|
|
356
|
+
* Commons' identity host (the wallet only), which holds only the personal
|
|
357
|
+
* entries of the person Commons is signed in as and leaves out the scope's
|
|
358
|
+
* current key. After an interrupted rotation the local list can include the
|
|
359
|
+
* current key: compare with the DID's `#key-1` before treating an entry as
|
|
360
|
+
* retired, and check every entry against the account's key history.
|
|
361
|
+
*
|
|
362
|
+
* STRICT (R5-6): a ring that cannot be read throws (the keychain's error, or
|
|
363
|
+
* {@link RetiredKeyRingUnreadableError}); it never reads as empty.
|
|
245
364
|
*/
|
|
246
365
|
async function listRetiredIdentityKeys(selector) {
|
|
247
366
|
const scope = retiredKeyScope(selector);
|
|
248
367
|
if (!scope || (0, platform_1.isWeb)())
|
|
249
368
|
return [];
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
const bridge = await (0, protocol_1.loadCommonsIdentityBridge)();
|
|
253
|
-
const keys = (await bridge?.listRetiredKeys?.(selector?.accountId)) ?? null;
|
|
254
|
-
return (keys ?? []).map((publicKey) => scope === PERSONAL_SCOPE
|
|
255
|
-
? { publicKey, retiredAt: 0 }
|
|
256
|
-
: { publicKey, accountId: scope, retiredAt: 0 });
|
|
257
|
-
}
|
|
258
|
-
if (!(0, platform_1.isIOS)() && !(0, platform_1.isAndroid)())
|
|
259
|
-
return [];
|
|
260
|
-
const store = await ringStore();
|
|
261
|
-
return (await readIndex(store)).filter((entry) => entry.scope === scope).map(toListed);
|
|
262
|
-
}
|
|
263
|
-
catch (error) {
|
|
264
|
-
logger_1.logger.warn('Failed to list retired keys', {
|
|
265
|
-
component: 'RetiredKeys',
|
|
266
|
-
error: error instanceof Error ? error.name : 'unknown',
|
|
267
|
-
});
|
|
369
|
+
const owner = ownerOf(selector?.ownerAccountId);
|
|
370
|
+
if (scope === PERSONAL_SCOPE && owner === null)
|
|
268
371
|
return [];
|
|
372
|
+
if ((0, platform_1.isAndroid)() && !(0, identitySigner_1.registeredIdentitySignerStore)()) {
|
|
373
|
+
const bridge = await (0, protocol_1.loadCommonsIdentityBridge)();
|
|
374
|
+
const keys = (await bridge?.listRetiredKeys?.(selector?.accountId)) ?? null;
|
|
375
|
+
return (keys ?? []).map((publicKey) => scope === PERSONAL_SCOPE
|
|
376
|
+
? { publicKey, retiredAt: 0 }
|
|
377
|
+
: { publicKey, accountId: scope, retiredAt: 0 });
|
|
269
378
|
}
|
|
379
|
+
if (!(0, platform_1.isIOS)() && !(0, platform_1.isAndroid)())
|
|
380
|
+
return [];
|
|
381
|
+
const store = await ringStore();
|
|
382
|
+
return (await readIndex(store)).filter((entry) => isVisible(entry, scope, owner)).map(toListed);
|
|
270
383
|
}
|
|
271
384
|
/**
|
|
272
385
|
* The private half of a retired key, where this app may hold it: Commons (both
|
|
@@ -280,12 +393,18 @@ async function getRetiredPrivateKey(publicKey, selector) {
|
|
|
280
393
|
const canonical = canonicalPublicKey(publicKey);
|
|
281
394
|
if (!scope || !canonical || (0, platform_1.isWeb)())
|
|
282
395
|
return null;
|
|
396
|
+
const owner = ownerOf(selector?.ownerAccountId);
|
|
397
|
+
if (scope === PERSONAL_SCOPE && owner === null)
|
|
398
|
+
return null;
|
|
283
399
|
try {
|
|
284
400
|
if ((0, platform_1.isAndroid)()) {
|
|
285
401
|
const signer = (0, identitySigner_1.registeredIdentitySignerStore)();
|
|
286
402
|
if (!signer)
|
|
287
403
|
return null;
|
|
288
404
|
const store = await ringStore();
|
|
405
|
+
if (!(await readIndex(store)).some((entry) => isVisible(entry, scope, owner) && entry.publicKey === canonical)) {
|
|
406
|
+
return null;
|
|
407
|
+
}
|
|
289
408
|
const local = await store.getItemAsync(privateKeyName(scope, canonical), privateOptions(store, false));
|
|
290
409
|
if (local && isHealthyPair(local, canonical))
|
|
291
410
|
return (0, secp256k1_1.normalizeSecp256k1PrivateKey)(local);
|
|
@@ -298,6 +417,11 @@ async function getRetiredPrivateKey(publicKey, selector) {
|
|
|
298
417
|
if (!(0, platform_1.isIOS)())
|
|
299
418
|
return null;
|
|
300
419
|
const store = await ringStore();
|
|
420
|
+
// The index decides who may reach a key (R5-2); a key it does not list for
|
|
421
|
+
// this reader is not theirs, whatever item exists.
|
|
422
|
+
if (!(await readIndex(store)).some((entry) => isVisible(entry, scope, owner) && entry.publicKey === canonical)) {
|
|
423
|
+
return null;
|
|
424
|
+
}
|
|
301
425
|
const value = await store.getItemAsync(privateKeyName(scope, canonical), privateOptions(store, false));
|
|
302
426
|
return value && isHealthyPair(value, canonical) ? (0, secp256k1_1.normalizeSecp256k1PrivateKey)(value) : null;
|
|
303
427
|
}
|
|
@@ -319,8 +443,12 @@ async function forgetRetiredIdentityKey(publicKey, selector) {
|
|
|
319
443
|
const canonical = canonicalPublicKey(publicKey);
|
|
320
444
|
if (!scope || !canonical)
|
|
321
445
|
throw new Error('retired key: invalid selector or public key');
|
|
446
|
+
const owner = ownerOf(selector?.ownerAccountId);
|
|
322
447
|
await assertMayWrite();
|
|
323
448
|
const store = await ringStore();
|
|
449
|
+
const visible = (await readIndex(store)).some((entry) => isVisible(entry, scope, owner) && entry.publicKey === canonical);
|
|
450
|
+
if (!visible)
|
|
451
|
+
throw new Error('retired key: not a key of this account');
|
|
324
452
|
await store.deleteItemAsync(privateKeyName(scope, canonical), privateOptions(store, false));
|
|
325
453
|
const signer = (0, platform_1.isAndroid)() ? (0, identitySigner_1.registeredIdentitySignerStore)() : null;
|
|
326
454
|
await signer?.clearRetired?.(scope, canonical);
|
|
@@ -340,33 +468,47 @@ async function forgetRetiredIdentityKeys(selector) {
|
|
|
340
468
|
return 0;
|
|
341
469
|
if ((0, platform_1.isAndroid)() && !(0, identitySigner_1.registeredIdentitySignerStore)())
|
|
342
470
|
return 0;
|
|
471
|
+
const owner = ownerOf(selector?.ownerAccountId);
|
|
472
|
+
if (scope === PERSONAL_SCOPE && owner === null)
|
|
473
|
+
return 0;
|
|
343
474
|
const store = await ringStore();
|
|
344
475
|
const index = await readIndex(store);
|
|
345
476
|
const signer = (0, platform_1.isAndroid)() ? (0, identitySigner_1.registeredIdentitySignerStore)() : null;
|
|
346
477
|
let removed = 0;
|
|
347
478
|
for (const entry of index) {
|
|
348
|
-
if (entry
|
|
479
|
+
if (!isVisible(entry, scope, owner))
|
|
349
480
|
continue;
|
|
350
481
|
await store.deleteItemAsync(privateKeyName(scope, entry.publicKey), privateOptions(store, false));
|
|
351
482
|
await signer?.clearRetired?.(scope, entry.publicKey);
|
|
352
483
|
removed += 1;
|
|
353
484
|
}
|
|
354
|
-
await writeIndex(store, index.filter((entry) => entry
|
|
485
|
+
await writeIndex(store, index.filter((entry) => !isVisible(entry, scope, owner)));
|
|
355
486
|
return removed;
|
|
356
487
|
}
|
|
357
488
|
/**
|
|
358
|
-
* Commons on Android:
|
|
359
|
-
*
|
|
360
|
-
*
|
|
361
|
-
*
|
|
489
|
+
* Commons on Android: make the identity signer store hold exactly what the
|
|
490
|
+
* identity host may answer — every organization ring, and the personal entries
|
|
491
|
+
* of `ownerAccountId` (the person Commons is signed in as) — so the host
|
|
492
|
+
* answers for them after that file was rebuilt (its self-heal leaves it
|
|
493
|
+
* empty), and never for a previous person's keys (R5-2): every other personal
|
|
494
|
+
* entry is removed from the signer store (its own slot keeps it). Without
|
|
495
|
+
* `ownerAccountId` no personal entry is mirrored. Call on every boot and after
|
|
496
|
+
* every sign-in, beside `syncOrganizationKeys`. Resolves how many it mirrored.
|
|
497
|
+
* Elsewhere 0.
|
|
362
498
|
*/
|
|
363
|
-
async function syncRetiredKeys() {
|
|
499
|
+
async function syncRetiredKeys(ownerAccountId) {
|
|
364
500
|
const signer = (0, platform_1.isAndroid)() ? (0, identitySigner_1.registeredIdentitySignerStore)() : null;
|
|
365
501
|
if (!signer?.writeRetired || !signer.readRetired)
|
|
366
502
|
return 0;
|
|
503
|
+
const owner = ownerOf(ownerAccountId);
|
|
367
504
|
const store = await ringStore();
|
|
505
|
+
const index = await readIndex(store);
|
|
506
|
+
const allowed = new Set(index.filter((entry) => isVisible(entry, PERSONAL_SCOPE, owner)).map((e) => e.publicKey));
|
|
507
|
+
await clearPersonalMirror(signer, index, allowed);
|
|
368
508
|
let mirrored = 0;
|
|
369
|
-
for (const entry of
|
|
509
|
+
for (const entry of index) {
|
|
510
|
+
if (entry.scope === PERSONAL_SCOPE && !isVisible(entry, PERSONAL_SCOPE, owner))
|
|
511
|
+
continue;
|
|
370
512
|
const privateKey = await store.getItemAsync(privateKeyName(entry.scope, entry.publicKey), privateOptions(store, false));
|
|
371
513
|
if (!privateKey || !isHealthyPair(privateKey, entry.publicKey))
|
|
372
514
|
continue;
|
|
@@ -380,12 +522,14 @@ async function syncRetiredKeys() {
|
|
|
380
522
|
return mirrored;
|
|
381
523
|
}
|
|
382
524
|
/**
|
|
383
|
-
* Every retired key of `selector`'s ring
|
|
384
|
-
*
|
|
385
|
-
* under the CURRENT phrase
|
|
386
|
-
* phrase does (R4-4). Commons
|
|
387
|
-
*
|
|
388
|
-
*
|
|
525
|
+
* Every retired key of `selector`'s ring (personal: `ownerAccountId`'s entries)
|
|
526
|
+
* WITH its private half, for the encrypted identity backup
|
|
527
|
+
* (`oxy.identity.backup.create`), which carries them under the CURRENT phrase
|
|
528
|
+
* so a lost phone costs no more than the current phrase does (R4-4). Commons
|
|
529
|
+
* only. STRICT (R5-6): an unreadable ring, or a listed entry whose private
|
|
530
|
+
* half is missing or does not derive its public key, THROWS — a backup is
|
|
531
|
+
* never sealed without a key the ring says it holds. Never log or transmit the
|
|
532
|
+
* result except inside that encrypted envelope.
|
|
389
533
|
*/
|
|
390
534
|
async function exportRetiredIdentityKeys(selector) {
|
|
391
535
|
const scope = retiredKeyScope(selector);
|
|
@@ -394,12 +538,57 @@ async function exportRetiredIdentityKeys(selector) {
|
|
|
394
538
|
await assertMayWrite();
|
|
395
539
|
const out = [];
|
|
396
540
|
for (const entry of await listRetiredIdentityKeys(selector)) {
|
|
397
|
-
const privateKey = await
|
|
398
|
-
|
|
399
|
-
out.push({ privateKey, publicKey: entry.publicKey, retiredAt: entry.retiredAt });
|
|
541
|
+
const privateKey = await readRetiredStrict(entry.publicKey, scope);
|
|
542
|
+
out.push({ privateKey, publicKey: entry.publicKey, retiredAt: entry.retiredAt });
|
|
400
543
|
}
|
|
401
544
|
return out;
|
|
402
545
|
}
|
|
546
|
+
/** A listed entry's private half, or a throw: Commons' own slot, then (Android) the signer copy. */
|
|
547
|
+
async function readRetiredStrict(publicKey, scope) {
|
|
548
|
+
const store = await ringStore();
|
|
549
|
+
const local = await store.getItemAsync(privateKeyName(scope, publicKey), privateOptions(store, false));
|
|
550
|
+
if (local && isHealthyPair(local, publicKey))
|
|
551
|
+
return (0, secp256k1_1.normalizeSecp256k1PrivateKey)(local);
|
|
552
|
+
const signer = (0, platform_1.isAndroid)() ? (0, identitySigner_1.registeredIdentitySignerStore)() : null;
|
|
553
|
+
const mirrored = (await signer?.readRetired?.(scope, publicKey)) ?? null;
|
|
554
|
+
if (mirrored && isHealthyPair(mirrored.privateKey, publicKey)) {
|
|
555
|
+
return (0, secp256k1_1.normalizeSecp256k1PrivateKey)(mirrored.privateKey);
|
|
556
|
+
}
|
|
557
|
+
throw new RetiredKeyRingUnreadableError('a listed key has no readable private half');
|
|
558
|
+
}
|
|
559
|
+
/**
|
|
560
|
+
* Commons on Android, when the identity leaves this device ("Start over", a
|
|
561
|
+
* forced delete): remove every PERSONAL entry from the identity signer store,
|
|
562
|
+
* so the identity host answers no other app with them before the next person
|
|
563
|
+
* signs in (R5-2). Commons' own slot and index keep them (money, bound to
|
|
564
|
+
* their owner). Elsewhere a no-op.
|
|
565
|
+
*/
|
|
566
|
+
async function unmirrorPersonalRetiredKeys() {
|
|
567
|
+
const signer = (0, platform_1.isAndroid)() ? (0, identitySigner_1.registeredIdentitySignerStore)() : null;
|
|
568
|
+
if (!signer?.clearRetired)
|
|
569
|
+
return;
|
|
570
|
+
let index = [];
|
|
571
|
+
try {
|
|
572
|
+
index = await readIndex(await ringStore());
|
|
573
|
+
}
|
|
574
|
+
catch {
|
|
575
|
+
// The signer store's own list below still reaches every mirrored entry.
|
|
576
|
+
}
|
|
577
|
+
await clearPersonalMirror(signer, index, new Set());
|
|
578
|
+
}
|
|
579
|
+
/** Remove from the signer store every personal entry not in `allowed`: the index's and the store's own list. */
|
|
580
|
+
async function clearPersonalMirror(signer, index, allowed) {
|
|
581
|
+
if (!signer.clearRetired)
|
|
582
|
+
return;
|
|
583
|
+
const mirrored = new Set(index.filter((entry) => entry.scope === PERSONAL_SCOPE).map((entry) => entry.publicKey));
|
|
584
|
+
for (const publicKey of (await signer.listRetired?.(PERSONAL_SCOPE)) ?? []) {
|
|
585
|
+
mirrored.add(publicKey.toLowerCase());
|
|
586
|
+
}
|
|
587
|
+
for (const publicKey of mirrored) {
|
|
588
|
+
if (!allowed.has(publicKey))
|
|
589
|
+
await signer.clearRetired(PERSONAL_SCOPE, publicKey);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
403
592
|
/**
|
|
404
593
|
* The pair a previous key's raw PRIVATE KEY gives (64 hex, an optional `0x`),
|
|
405
594
|
* for a person whose old identity was imported as a private key and so has no
|
package/dist/esm/api/identity.js
CHANGED
|
@@ -199,7 +199,7 @@ export class IdentityApi {
|
|
|
199
199
|
try {
|
|
200
200
|
if (!oldPrivateKeyForRing)
|
|
201
201
|
throw new Error('no readable private key');
|
|
202
|
-
await retireIdentityKey({ privateKey: oldPrivateKeyForRing, publicKey: oldPublicKey });
|
|
202
|
+
await retireIdentityKey({ privateKey: oldPrivateKeyForRing, publicKey: oldPublicKey }, { ownerAccountId: userId });
|
|
203
203
|
}
|
|
204
204
|
catch (retainError) {
|
|
205
205
|
logger.warn('Could not keep the current key as a retired key; not rotating.', { component: 'oxy.identity', method: 'rotateKey' }, retainError);
|
|
@@ -629,9 +629,12 @@ export class IdentityBackupApi {
|
|
|
629
629
|
if (isWeb())
|
|
630
630
|
return [];
|
|
631
631
|
const { exportRetiredIdentityKeys } = await import('../crypto/index.js');
|
|
632
|
+
const userId = this.ctx.oxy.session.userId;
|
|
633
|
+
if (!userId)
|
|
634
|
+
throw new Error('No authenticated user — sign in before backing up.');
|
|
632
635
|
let held;
|
|
633
636
|
try {
|
|
634
|
-
held = await exportRetiredIdentityKeys();
|
|
637
|
+
held = await exportRetiredIdentityKeys({ ownerAccountId: userId });
|
|
635
638
|
}
|
|
636
639
|
catch {
|
|
637
640
|
return []; // Not Commons: this app holds no ring to carry.
|
|
@@ -690,6 +693,11 @@ export class IdentityBackupApi {
|
|
|
690
693
|
*/
|
|
691
694
|
async keepCarriedRetiredKeys(payload) {
|
|
692
695
|
const { KeyManager, retireIdentityKey } = await import('../crypto/index.js');
|
|
696
|
+
// The backup is the signed-in account's (its phrase opened it): its keys
|
|
697
|
+
// are kept for that account only (R5-2).
|
|
698
|
+
const ownerAccountId = this.ctx.oxy.session.userId;
|
|
699
|
+
if (!ownerAccountId)
|
|
700
|
+
throw new Error('No authenticated user — sign in before restoring.');
|
|
693
701
|
let kept = 0;
|
|
694
702
|
for (const key of Array.isArray(payload.retiredKeys) ? payload.retiredKeys : []) {
|
|
695
703
|
try {
|
|
@@ -700,7 +708,7 @@ export class IdentityBackupApi {
|
|
|
700
708
|
if (KeyManager.derivePublicKey(key.privateKey).toLowerCase() !== key.publicKey.toLowerCase()) {
|
|
701
709
|
continue;
|
|
702
710
|
}
|
|
703
|
-
await retireIdentityKey({ privateKey: key.privateKey, publicKey: key.publicKey });
|
|
711
|
+
await retireIdentityKey({ privateKey: key.privateKey, publicKey: key.publicKey }, { ownerAccountId });
|
|
704
712
|
kept += 1;
|
|
705
713
|
}
|
|
706
714
|
catch (error) {
|
package/dist/esm/crypto/index.js
CHANGED
|
@@ -22,7 +22,7 @@ export { KEYCHAIN_ACCESS_GROUPS, KeychainAccessUnavailableError, keychainGroupFo
|
|
|
22
22
|
export { ensureKeychainGroupsMigrated, getPreSplitExposure, } from './keychainGroupMigration.js';
|
|
23
23
|
// Retired identity keys: kept for spending after a rotation or replacement
|
|
24
24
|
// (docs/adr/0061-organization-identity-key.md D7 as amended 2026-11-06)
|
|
25
|
-
export { retireIdentityKey, listRetiredIdentityKeys, forgetRetiredIdentityKey, forgetRetiredIdentityKeys, syncRetiredKeys, retiredKeyFromPhrase, retiredKeyFromPrivateKey, exportRetiredIdentityKeys, } from './retiredKeys.js';
|
|
25
|
+
export { retireIdentityKey, listRetiredIdentityKeys, forgetRetiredIdentityKey, forgetRetiredIdentityKeys, syncRetiredKeys, retiredKeyFromPhrase, retiredKeyFromPrivateKey, exportRetiredIdentityKeys, claimRetiredIdentityKeys, countQuarantinedRetiredKeys, unmirrorPersonalRetiredKeys, RetiredKeyRingUnreadableError, } from './retiredKeys.js';
|
|
26
26
|
export { SignatureService } from './signatureService.js';
|
|
27
27
|
export { RecoveryPhraseService, BACKUP_KDF_ENCRYPTION_INFO } from './recoveryPhrase.js';
|
|
28
28
|
// Low-level primitives (encrypted backup, device transfer)
|