@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
|
@@ -32,7 +32,8 @@ import { logger } from '../logger/index.js';
|
|
|
32
32
|
import { groupedKeychainStore, resolveKeychainAccess, } from '../utils/keychainAccessGroups.js';
|
|
33
33
|
import { isAndroid, isIOS, isWeb } from '../utils/platform.js';
|
|
34
34
|
import { registeredIdentitySignerStore } from './identitySigner.js';
|
|
35
|
-
import {
|
|
35
|
+
import { prepareKeychainAccess } from './keychainGroupMigration.js';
|
|
36
|
+
import { retireIdentityKey } from './retiredKeys.js';
|
|
36
37
|
import { ORGANIZATION_ANDROID_KEYCHAIN_SERVICE as ANDROID_KEYCHAIN_SERVICE, ORGANIZATION_INDEX_KEYCHAIN_SERVICE as INDEX_KEYCHAIN_SERVICE, ORGANIZATION_INDEX_STORAGE_KEY as INDEX_STORAGE_KEY, ORGANIZATION_MNEMONIC_KEYCHAIN_SERVICE as MNEMONIC_KEYCHAIN_SERVICE, isOrganizationAccountId, organizationMnemonicName as mnemonicName, organizationPrivateKeyName as privateKeyName, organizationPublicKeyName as publicKeyName, serializeOrganizationIndex, } from './keychainItems.js';
|
|
37
38
|
export { isOrganizationAccountId };
|
|
38
39
|
const ORGANIZATION_PHRASE_WORDS = 24;
|
|
@@ -102,9 +103,7 @@ export async function organizationKeyFromPhrase(phrase) {
|
|
|
102
103
|
async function secureStore(item) {
|
|
103
104
|
const raw = await loadSecureStore();
|
|
104
105
|
const access = await resolveKeychainAccess();
|
|
105
|
-
|
|
106
|
-
await ensureKeychainGroupsMigrated();
|
|
107
|
-
}
|
|
106
|
+
await prepareKeychainAccess(access);
|
|
108
107
|
return groupedKeychainStore(raw, access, item);
|
|
109
108
|
}
|
|
110
109
|
const pairStore = () => secureStore('spending-key');
|
|
@@ -195,8 +194,10 @@ async function readLocalPair(accountId) {
|
|
|
195
194
|
* it; on iOS only Commons holds the groups it writes (any other app throws).
|
|
196
195
|
*
|
|
197
196
|
* Refuses a pair whose public key does not derive from its private key, and a
|
|
198
|
-
* phrase that does not derive the pair.
|
|
199
|
-
* the organization
|
|
197
|
+
* phrase that does not derive the pair. A DIFFERENT key this device already
|
|
198
|
+
* held for the organization is first kept in the organization's retired ring
|
|
199
|
+
* (ADR 0061 D7 as amended; R4-12): coins under it still need it. When that
|
|
200
|
+
* cannot be confirmed nothing is overwritten and this throws.
|
|
200
201
|
*/
|
|
201
202
|
export async function storeOrganizationKey(accountId, key) {
|
|
202
203
|
assertAccountId(accountId);
|
|
@@ -218,6 +219,18 @@ export async function storeOrganizationKey(accountId, key) {
|
|
|
218
219
|
throw new Error('On Android only Commons holds organization keys; this app has no identity signer store for them.');
|
|
219
220
|
}
|
|
220
221
|
const store = await pairStore();
|
|
222
|
+
const previous = await heldPrivateKey(store, accountId);
|
|
223
|
+
if (previous) {
|
|
224
|
+
const previousPublic = deriveSecp256k1PublicKey(previous);
|
|
225
|
+
if (previousPublic !== publicKey) {
|
|
226
|
+
try {
|
|
227
|
+
await retireIdentityKey({ privateKey: previous, publicKey: previousPublic }, { accountId });
|
|
228
|
+
}
|
|
229
|
+
catch (error) {
|
|
230
|
+
throw new Error(`organization key: the key this device held could not be kept as a previous key, so it was not replaced (${error instanceof Error ? error.message : 'unknown'})`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
221
234
|
await store.setItemAsync(privateKeyName(accountId), privateKey, pairOptions(store, true));
|
|
222
235
|
await store.setItemAsync(publicKeyName(accountId), publicKey, pairOptions(store, false));
|
|
223
236
|
if (signer?.writeOrganization &&
|
|
@@ -298,6 +311,21 @@ export async function listOrganizationKeys() {
|
|
|
298
311
|
return [];
|
|
299
312
|
return readIndex(await indexStore());
|
|
300
313
|
}
|
|
314
|
+
/**
|
|
315
|
+
* The private key this device holds for `accountId`, whatever its public half
|
|
316
|
+
* says (an unhealthy pair is still a key coins may sit under): the slot, then
|
|
317
|
+
* on Android the signer store's copy. `null` when there is none.
|
|
318
|
+
*/
|
|
319
|
+
async function heldPrivateKey(store, accountId) {
|
|
320
|
+
const local = await store.getItemAsync(privateKeyName(accountId), pairOptions(store, false));
|
|
321
|
+
if (local && isValidSecp256k1PrivateKey(local))
|
|
322
|
+
return normalizeSecp256k1PrivateKey(local);
|
|
323
|
+
const signer = isAndroid() ? registeredIdentitySignerStore() : null;
|
|
324
|
+
const mirrored = (await signer?.readOrganization?.(accountId)) ?? null;
|
|
325
|
+
return mirrored && isValidSecp256k1PrivateKey(mirrored.privateKey)
|
|
326
|
+
? normalizeSecp256k1PrivateKey(mirrored.privateKey)
|
|
327
|
+
: null;
|
|
328
|
+
}
|
|
301
329
|
/** Forget an organization key on this device: its pair, its phrase, its signer copy and its index entry. */
|
|
302
330
|
export async function deleteOrganizationKey(accountId) {
|
|
303
331
|
assertAccountId(accountId);
|
|
@@ -49,8 +49,15 @@ import { logger } from '../logger/index.js';
|
|
|
49
49
|
import { groupedKeychainStore, resolveKeychainAccess } from '../utils/keychainAccessGroups.js';
|
|
50
50
|
import { isAndroid, isIOS, isWeb } from '../utils/platform.js';
|
|
51
51
|
import { registeredIdentitySignerStore } from './identitySigner.js';
|
|
52
|
-
import {
|
|
53
|
-
import {
|
|
52
|
+
import { isOrganizationAccountId } from './keychainItems.js';
|
|
53
|
+
import { prepareKeychainAccess } from './keychainGroupMigration.js';
|
|
54
|
+
/** Thrown when the ring's index cannot be read as the ring: never treated as empty (R5-9). */
|
|
55
|
+
export class RetiredKeyRingUnreadableError extends Error {
|
|
56
|
+
constructor(reason) {
|
|
57
|
+
super(`retired key ring: ${reason}`);
|
|
58
|
+
this.name = 'RetiredKeyRingUnreadableError';
|
|
59
|
+
}
|
|
60
|
+
}
|
|
54
61
|
const PERSONAL_SCOPE = 'personal';
|
|
55
62
|
/** Android, Commons-local: its own Keystore-backed slot, shared with no other key. */
|
|
56
63
|
const ANDROID_KEYCHAIN_SERVICE = 'oxy_retired_identity';
|
|
@@ -61,15 +68,15 @@ const PUBLIC_KEY = /^04[0-9a-f]{128}$/;
|
|
|
61
68
|
* The secure store for the ring. On iOS every item is a `spending-key`
|
|
62
69
|
* (ADR 0062): the private keys AND the index live in the wallet group, which
|
|
63
70
|
* only Commons and the Peable wallet can read, never the shared one. In
|
|
64
|
-
* Commons every access first awaits the access-group migration
|
|
65
|
-
* the
|
|
71
|
+
* Commons every access first awaits the access-group migration (`gate: false`
|
|
72
|
+
* is the migration itself, keeping a pre-split copy). On Android the grouping
|
|
73
|
+
* is a no-op and Commons' own services below apply.
|
|
66
74
|
*/
|
|
67
|
-
async function ringStore() {
|
|
75
|
+
async function ringStore(gate = true) {
|
|
68
76
|
const raw = await loadSecureStore();
|
|
69
77
|
const access = await resolveKeychainAccess();
|
|
70
|
-
if (
|
|
71
|
-
await
|
|
72
|
-
}
|
|
78
|
+
if (gate)
|
|
79
|
+
await prepareKeychainAccess(access);
|
|
73
80
|
return groupedKeychainStore(raw, access, 'spending-key');
|
|
74
81
|
}
|
|
75
82
|
/** The ring a selector names: `personal`, or the organization's account id. */
|
|
@@ -119,6 +126,24 @@ function privateOptions(store, forWrite) {
|
|
|
119
126
|
function indexOptions() {
|
|
120
127
|
return isIOS() ? {} : { keychainService: INDEX_KEYCHAIN_SERVICE };
|
|
121
128
|
}
|
|
129
|
+
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;
|
|
130
|
+
/** A well-formed Oxy account id (24-hex or uuid), else `null`. */
|
|
131
|
+
function ownerOf(ownerAccountId) {
|
|
132
|
+
return ownerAccountId !== undefined && ACCOUNT_ID.test(ownerAccountId)
|
|
133
|
+
? ownerAccountId.toLowerCase()
|
|
134
|
+
: null;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Whether `entry` may be reached by a reader of `scope` acting for `owner`: an
|
|
138
|
+
* organization's entries by its own scope, a personal entry only by its owner.
|
|
139
|
+
*/
|
|
140
|
+
function isVisible(entry, scope, owner) {
|
|
141
|
+
if (entry.scope !== scope)
|
|
142
|
+
return false;
|
|
143
|
+
if (scope !== PERSONAL_SCOPE)
|
|
144
|
+
return true;
|
|
145
|
+
return owner !== null && entry.owner === owner;
|
|
146
|
+
}
|
|
122
147
|
function isIndexEntry(value) {
|
|
123
148
|
if (typeof value !== 'object' || value === null)
|
|
124
149
|
return false;
|
|
@@ -128,23 +153,31 @@ function isIndexEntry(value) {
|
|
|
128
153
|
typeof entry.publicKey === 'string' &&
|
|
129
154
|
PUBLIC_KEY.test(entry.publicKey) &&
|
|
130
155
|
typeof entry.retiredAt === 'number' &&
|
|
131
|
-
Number.isFinite(entry.retiredAt)
|
|
156
|
+
Number.isFinite(entry.retiredAt) &&
|
|
157
|
+
(entry.owner === undefined || (typeof entry.owner === 'string' && ACCOUNT_ID.test(entry.owner))));
|
|
132
158
|
}
|
|
159
|
+
/**
|
|
160
|
+
* The ring's index. STRICT (R5-6, R5-9): an index that does not parse, or that
|
|
161
|
+
* holds an entry this version cannot read, THROWS
|
|
162
|
+
* {@link RetiredKeyRingUnreadableError} instead of reading as empty, so no
|
|
163
|
+
* writer overwrites it (losing every entry) and no backup is sealed without
|
|
164
|
+
* the keys it names. A thrown keychain read propagates.
|
|
165
|
+
*/
|
|
133
166
|
async function readIndex(store) {
|
|
134
167
|
const raw = await store.getItemAsync(INDEX_STORAGE_KEY, indexOptions());
|
|
135
168
|
if (!raw)
|
|
136
169
|
return [];
|
|
170
|
+
let parsed;
|
|
137
171
|
try {
|
|
138
|
-
|
|
139
|
-
return Array.isArray(parsed) ? parsed.filter(isIndexEntry) : [];
|
|
172
|
+
parsed = JSON.parse(raw);
|
|
140
173
|
}
|
|
141
|
-
catch
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
return [];
|
|
174
|
+
catch {
|
|
175
|
+
throw new RetiredKeyRingUnreadableError('the index does not parse');
|
|
176
|
+
}
|
|
177
|
+
if (!Array.isArray(parsed) || !parsed.every(isIndexEntry)) {
|
|
178
|
+
throw new RetiredKeyRingUnreadableError('the index holds an entry this version cannot read');
|
|
147
179
|
}
|
|
180
|
+
return parsed.map((entry) => entry.owner === undefined ? entry : { ...entry, owner: entry.owner.toLowerCase() });
|
|
148
181
|
}
|
|
149
182
|
async function writeIndex(store, entries) {
|
|
150
183
|
const unique = new Map();
|
|
@@ -157,81 +190,178 @@ async function writeIndex(store, entries) {
|
|
|
157
190
|
await store.setItemAsync(INDEX_STORAGE_KEY, JSON.stringify(sorted), indexOptions());
|
|
158
191
|
}
|
|
159
192
|
function toListed(entry) {
|
|
193
|
+
// The owner is the reader's own id; it is not repeated.
|
|
160
194
|
return entry.scope === PERSONAL_SCOPE
|
|
161
195
|
? { publicKey: entry.publicKey, retiredAt: entry.retiredAt }
|
|
162
196
|
: { publicKey: entry.publicKey, accountId: entry.scope, retiredAt: entry.retiredAt };
|
|
163
197
|
}
|
|
164
|
-
/**
|
|
165
|
-
|
|
198
|
+
/**
|
|
199
|
+
* Whether this app writes the ring: Commons only (Android: the signer store it
|
|
200
|
+
* registers; iOS: the keychain role `holder`). The Peable wallet holds the
|
|
201
|
+
* wallet group too, but only reads, derives and signs: a key the wallet could
|
|
202
|
+
* add to the ring is a key it could choose (R4-14).
|
|
203
|
+
*/
|
|
204
|
+
async function assertMayWrite() {
|
|
166
205
|
if (isWeb())
|
|
167
206
|
throw new Error('retired key: only a native device holds one');
|
|
168
207
|
if (isAndroid() && !registeredIdentitySignerStore()?.writeRetired) {
|
|
169
208
|
throw new Error('On Android only Commons holds retired keys; this app has no identity signer store for them.');
|
|
170
209
|
}
|
|
210
|
+
if (isIOS()) {
|
|
211
|
+
const access = await resolveKeychainAccess();
|
|
212
|
+
if (access.mode === 'grouped' && access.role !== 'holder') {
|
|
213
|
+
throw new Error('On iOS only Commons writes retired keys; this app may only read them.');
|
|
214
|
+
}
|
|
215
|
+
}
|
|
171
216
|
}
|
|
172
217
|
/**
|
|
173
|
-
* Keep `key` in the ring of `selector`'s account. Commons only.
|
|
174
|
-
*
|
|
175
|
-
*
|
|
218
|
+
* Keep `key` in the ring of `selector`'s account. Commons only. A PERSONAL key
|
|
219
|
+
* needs `selector.ownerAccountId`, the person whose key it was (R5-2): the
|
|
220
|
+
* signed-in account at a rotation, the account whose key history or backup
|
|
221
|
+
* named it. Idempotent: a key already kept stays, with its first date; a
|
|
222
|
+
* quarantined entry of the same key is bound to that owner; an entry bound to
|
|
223
|
+
* a DIFFERENT owner is refused. Refuses a pair whose public key does not
|
|
224
|
+
* derive from its private key. Resolves once every copy confirmed it
|
|
176
225
|
* (Android: the signer store's read-back too), and throws otherwise, so a
|
|
177
226
|
* caller about to replace the key can stop before anything is lost.
|
|
178
227
|
*/
|
|
179
228
|
export async function retireIdentityKey(key, selector) {
|
|
229
|
+
const scope = retiredKeyScope(selector);
|
|
230
|
+
if (scope === PERSONAL_SCOPE && ownerOf(selector?.ownerAccountId) === null) {
|
|
231
|
+
throw new Error('retired key: a personal key needs ownerAccountId, the account it belongs to');
|
|
232
|
+
}
|
|
233
|
+
await keepRetired(key, selector, true);
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* {@link retireIdentityKey} from inside the iOS access-group migration, which
|
|
237
|
+
* keeps a pre-split copy before deleting it from the shared group and cannot
|
|
238
|
+
* await itself. A personal key without `ownerAccountId` is kept QUARANTINED:
|
|
239
|
+
* held (it may be money) but reached by nobody until
|
|
240
|
+
* {@link claimRetiredIdentityKeys} binds it. @internal
|
|
241
|
+
*/
|
|
242
|
+
export async function retireIdentityKeyDuringMigration(key, selector) {
|
|
243
|
+
await keepRetired(key, selector, false);
|
|
244
|
+
}
|
|
245
|
+
async function keepRetired(key, selector, gate) {
|
|
180
246
|
const scope = retiredKeyScope(selector);
|
|
181
247
|
if (!scope)
|
|
182
248
|
throw new Error('retired key: accountId must be a 24-hex or uuid account id');
|
|
183
|
-
|
|
249
|
+
const owner = scope === PERSONAL_SCOPE ? ownerOf(selector?.ownerAccountId) : null;
|
|
250
|
+
await assertMayWrite();
|
|
184
251
|
if (!isHealthyPair(key.privateKey, key.publicKey)) {
|
|
185
252
|
throw new Error('retired key: the public key does not derive from the private key');
|
|
186
253
|
}
|
|
187
254
|
const privateKey = normalizeSecp256k1PrivateKey(key.privateKey);
|
|
188
255
|
const publicKey = normalizeSecp256k1PublicKey(key.publicKey);
|
|
189
|
-
const store = await ringStore();
|
|
256
|
+
const store = await ringStore(gate);
|
|
257
|
+
// Strict: an unreadable index stops the write before the key is touched.
|
|
258
|
+
const before = await readIndex(store);
|
|
259
|
+
const existing = before.find((entry) => entry.scope === scope && entry.publicKey === publicKey);
|
|
260
|
+
if (existing?.owner !== undefined && owner !== null && existing.owner !== owner) {
|
|
261
|
+
throw new Error('retired key: this key is kept for a different account');
|
|
262
|
+
}
|
|
190
263
|
await store.setItemAsync(privateKeyName(scope, publicKey), privateKey, privateOptions(store, true));
|
|
191
264
|
const readBack = await store.getItemAsync(privateKeyName(scope, publicKey), privateOptions(store, false));
|
|
192
265
|
if (readBack !== privateKey) {
|
|
193
266
|
throw new Error('retired key: the keychain did not confirm the key');
|
|
194
267
|
}
|
|
268
|
+
const index = await readIndex(store);
|
|
269
|
+
const kept = index.find((entry) => entry.scope === scope && entry.publicKey === publicKey);
|
|
270
|
+
if (!kept) {
|
|
271
|
+
await writeIndex(store, [
|
|
272
|
+
...index,
|
|
273
|
+
owner === null
|
|
274
|
+
? { scope, publicKey, retiredAt: Date.now() }
|
|
275
|
+
: { scope, publicKey, retiredAt: Date.now(), owner },
|
|
276
|
+
]);
|
|
277
|
+
}
|
|
278
|
+
else if (kept.owner === undefined && owner !== null) {
|
|
279
|
+
await writeIndex(store, index.map((entry) => (entry === kept ? { ...entry, owner } : entry)));
|
|
280
|
+
}
|
|
281
|
+
// The identity host answers only from the signer store, which holds the
|
|
282
|
+
// organizations' rings and the CURRENT person's personal entries; a
|
|
283
|
+
// quarantined entry is never mirrored.
|
|
195
284
|
const signer = isAndroid() ? registeredIdentitySignerStore() : null;
|
|
196
|
-
if (signer?.writeRetired &&
|
|
285
|
+
if (signer?.writeRetired &&
|
|
286
|
+
(scope !== PERSONAL_SCOPE || owner !== null) &&
|
|
287
|
+
!(await signer.writeRetired(scope, privateKey, publicKey))) {
|
|
197
288
|
throw new Error(`The identity signer store (${signer.name}) did not confirm the retired key.`);
|
|
198
289
|
}
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Bind the QUARANTINED personal entries whose public key `publicKeys` names to
|
|
293
|
+
* `ownerAccountId` (R5-2, R5-3). Commons only, with the signed-in person's
|
|
294
|
+
* account id and that account's key history from Oxy
|
|
295
|
+
* (`oxy.identity.keyHistory()`: retired and current public keys): a key the
|
|
296
|
+
* account's own history names is that account's. Entries bound to anyone, and
|
|
297
|
+
* keys the history does not name, are left as they are. Resolves how many it
|
|
298
|
+
* bound; call {@link syncRetiredKeys} after it on Android.
|
|
299
|
+
*/
|
|
300
|
+
export async function claimRetiredIdentityKeys(ownerAccountId, publicKeys) {
|
|
301
|
+
const owner = ownerOf(ownerAccountId);
|
|
302
|
+
if (owner === null)
|
|
303
|
+
throw new Error('retired key: ownerAccountId must be an account id');
|
|
304
|
+
if (isWeb())
|
|
305
|
+
return 0;
|
|
306
|
+
await assertMayWrite();
|
|
307
|
+
const named = new Set(publicKeys.map((key) => canonicalPublicKey(key)).filter((key) => key !== null));
|
|
308
|
+
const store = await ringStore();
|
|
199
309
|
const index = await readIndex(store);
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
310
|
+
let bound = 0;
|
|
311
|
+
const next = index.map((entry) => {
|
|
312
|
+
if (entry.scope !== PERSONAL_SCOPE || entry.owner !== undefined)
|
|
313
|
+
return entry;
|
|
314
|
+
if (!named.has(entry.publicKey))
|
|
315
|
+
return entry;
|
|
316
|
+
bound += 1;
|
|
317
|
+
return { ...entry, owner };
|
|
318
|
+
});
|
|
319
|
+
if (bound > 0)
|
|
320
|
+
await writeIndex(store, next);
|
|
321
|
+
return bound;
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* How many personal entries are quarantined (no known owner). Commons shows
|
|
325
|
+
* them as "keys from before" the person can claim by adding the phrase, and
|
|
326
|
+
* {@link claimRetiredIdentityKeys} binds the ones the account's history names.
|
|
327
|
+
*/
|
|
328
|
+
export async function countQuarantinedRetiredKeys() {
|
|
329
|
+
if (isWeb() || (isAndroid() && !registeredIdentitySignerStore()))
|
|
330
|
+
return 0;
|
|
331
|
+
const store = await ringStore();
|
|
332
|
+
return (await readIndex(store)).filter((entry) => entry.scope === PERSONAL_SCOPE && entry.owner === undefined).length;
|
|
203
333
|
}
|
|
204
334
|
/**
|
|
205
335
|
* The retired keys of `selector`'s account this app can reach, oldest first.
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
336
|
+
* The personal ring answers only `selector.ownerAccountId`'s entries (none
|
|
337
|
+
* without it; R5-2). iOS and Commons: the ring's index. Android elsewhere:
|
|
338
|
+
* Commons' identity host (the wallet only), which holds only the personal
|
|
339
|
+
* entries of the person Commons is signed in as and leaves out the scope's
|
|
340
|
+
* current key. After an interrupted rotation the local list can include the
|
|
341
|
+
* current key: compare with the DID's `#key-1` before treating an entry as
|
|
342
|
+
* retired, and check every entry against the account's key history.
|
|
343
|
+
*
|
|
344
|
+
* STRICT (R5-6): a ring that cannot be read throws (the keychain's error, or
|
|
345
|
+
* {@link RetiredKeyRingUnreadableError}); it never reads as empty.
|
|
210
346
|
*/
|
|
211
347
|
export async function listRetiredIdentityKeys(selector) {
|
|
212
348
|
const scope = retiredKeyScope(selector);
|
|
213
349
|
if (!scope || isWeb())
|
|
214
350
|
return [];
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
const bridge = await loadCommonsIdentityBridge();
|
|
218
|
-
const keys = (await bridge?.listRetiredKeys?.(selector?.accountId)) ?? null;
|
|
219
|
-
return (keys ?? []).map((publicKey) => scope === PERSONAL_SCOPE
|
|
220
|
-
? { publicKey, retiredAt: 0 }
|
|
221
|
-
: { publicKey, accountId: scope, retiredAt: 0 });
|
|
222
|
-
}
|
|
223
|
-
if (!isIOS() && !isAndroid())
|
|
224
|
-
return [];
|
|
225
|
-
const store = await ringStore();
|
|
226
|
-
return (await readIndex(store)).filter((entry) => entry.scope === scope).map(toListed);
|
|
227
|
-
}
|
|
228
|
-
catch (error) {
|
|
229
|
-
logger.warn('Failed to list retired keys', {
|
|
230
|
-
component: 'RetiredKeys',
|
|
231
|
-
error: error instanceof Error ? error.name : 'unknown',
|
|
232
|
-
});
|
|
351
|
+
const owner = ownerOf(selector?.ownerAccountId);
|
|
352
|
+
if (scope === PERSONAL_SCOPE && owner === null)
|
|
233
353
|
return [];
|
|
354
|
+
if (isAndroid() && !registeredIdentitySignerStore()) {
|
|
355
|
+
const bridge = await loadCommonsIdentityBridge();
|
|
356
|
+
const keys = (await bridge?.listRetiredKeys?.(selector?.accountId)) ?? null;
|
|
357
|
+
return (keys ?? []).map((publicKey) => scope === PERSONAL_SCOPE
|
|
358
|
+
? { publicKey, retiredAt: 0 }
|
|
359
|
+
: { publicKey, accountId: scope, retiredAt: 0 });
|
|
234
360
|
}
|
|
361
|
+
if (!isIOS() && !isAndroid())
|
|
362
|
+
return [];
|
|
363
|
+
const store = await ringStore();
|
|
364
|
+
return (await readIndex(store)).filter((entry) => isVisible(entry, scope, owner)).map(toListed);
|
|
235
365
|
}
|
|
236
366
|
/**
|
|
237
367
|
* The private half of a retired key, where this app may hold it: Commons (both
|
|
@@ -245,12 +375,18 @@ export async function getRetiredPrivateKey(publicKey, selector) {
|
|
|
245
375
|
const canonical = canonicalPublicKey(publicKey);
|
|
246
376
|
if (!scope || !canonical || isWeb())
|
|
247
377
|
return null;
|
|
378
|
+
const owner = ownerOf(selector?.ownerAccountId);
|
|
379
|
+
if (scope === PERSONAL_SCOPE && owner === null)
|
|
380
|
+
return null;
|
|
248
381
|
try {
|
|
249
382
|
if (isAndroid()) {
|
|
250
383
|
const signer = registeredIdentitySignerStore();
|
|
251
384
|
if (!signer)
|
|
252
385
|
return null;
|
|
253
386
|
const store = await ringStore();
|
|
387
|
+
if (!(await readIndex(store)).some((entry) => isVisible(entry, scope, owner) && entry.publicKey === canonical)) {
|
|
388
|
+
return null;
|
|
389
|
+
}
|
|
254
390
|
const local = await store.getItemAsync(privateKeyName(scope, canonical), privateOptions(store, false));
|
|
255
391
|
if (local && isHealthyPair(local, canonical))
|
|
256
392
|
return normalizeSecp256k1PrivateKey(local);
|
|
@@ -263,6 +399,11 @@ export async function getRetiredPrivateKey(publicKey, selector) {
|
|
|
263
399
|
if (!isIOS())
|
|
264
400
|
return null;
|
|
265
401
|
const store = await ringStore();
|
|
402
|
+
// The index decides who may reach a key (R5-2); a key it does not list for
|
|
403
|
+
// this reader is not theirs, whatever item exists.
|
|
404
|
+
if (!(await readIndex(store)).some((entry) => isVisible(entry, scope, owner) && entry.publicKey === canonical)) {
|
|
405
|
+
return null;
|
|
406
|
+
}
|
|
266
407
|
const value = await store.getItemAsync(privateKeyName(scope, canonical), privateOptions(store, false));
|
|
267
408
|
return value && isHealthyPair(value, canonical) ? normalizeSecp256k1PrivateKey(value) : null;
|
|
268
409
|
}
|
|
@@ -284,18 +425,24 @@ export async function forgetRetiredIdentityKey(publicKey, selector) {
|
|
|
284
425
|
const canonical = canonicalPublicKey(publicKey);
|
|
285
426
|
if (!scope || !canonical)
|
|
286
427
|
throw new Error('retired key: invalid selector or public key');
|
|
287
|
-
|
|
428
|
+
const owner = ownerOf(selector?.ownerAccountId);
|
|
429
|
+
await assertMayWrite();
|
|
288
430
|
const store = await ringStore();
|
|
431
|
+
const visible = (await readIndex(store)).some((entry) => isVisible(entry, scope, owner) && entry.publicKey === canonical);
|
|
432
|
+
if (!visible)
|
|
433
|
+
throw new Error('retired key: not a key of this account');
|
|
289
434
|
await store.deleteItemAsync(privateKeyName(scope, canonical), privateOptions(store, false));
|
|
290
435
|
const signer = isAndroid() ? registeredIdentitySignerStore() : null;
|
|
291
436
|
await signer?.clearRetired?.(scope, canonical);
|
|
292
437
|
await writeIndex(store, (await readIndex(store)).filter((entry) => !(entry.scope === scope && entry.publicKey === canonical)));
|
|
293
438
|
}
|
|
294
439
|
/**
|
|
295
|
-
* Forget every retired key of one ring (the person's, or an organization's)
|
|
296
|
-
*
|
|
297
|
-
*
|
|
298
|
-
*
|
|
440
|
+
* Forget every retired key of one ring (the person's, or an organization's).
|
|
441
|
+
* Commons only, and only when the person deletes their Oxy ACCOUNT (Commons'
|
|
442
|
+
* delete-account flow), which tells them the coins go with it. Deleting the
|
|
443
|
+
* identity from this device — "Start over", a forced delete — never calls it:
|
|
444
|
+
* the ring is money, and the same person restoring their identity later must
|
|
445
|
+
* find it (R4-4). Resolves how many it removed.
|
|
299
446
|
*/
|
|
300
447
|
export async function forgetRetiredIdentityKeys(selector) {
|
|
301
448
|
const scope = retiredKeyScope(selector);
|
|
@@ -303,33 +450,47 @@ export async function forgetRetiredIdentityKeys(selector) {
|
|
|
303
450
|
return 0;
|
|
304
451
|
if (isAndroid() && !registeredIdentitySignerStore())
|
|
305
452
|
return 0;
|
|
453
|
+
const owner = ownerOf(selector?.ownerAccountId);
|
|
454
|
+
if (scope === PERSONAL_SCOPE && owner === null)
|
|
455
|
+
return 0;
|
|
306
456
|
const store = await ringStore();
|
|
307
457
|
const index = await readIndex(store);
|
|
308
458
|
const signer = isAndroid() ? registeredIdentitySignerStore() : null;
|
|
309
459
|
let removed = 0;
|
|
310
460
|
for (const entry of index) {
|
|
311
|
-
if (entry
|
|
461
|
+
if (!isVisible(entry, scope, owner))
|
|
312
462
|
continue;
|
|
313
463
|
await store.deleteItemAsync(privateKeyName(scope, entry.publicKey), privateOptions(store, false));
|
|
314
464
|
await signer?.clearRetired?.(scope, entry.publicKey);
|
|
315
465
|
removed += 1;
|
|
316
466
|
}
|
|
317
|
-
await writeIndex(store, index.filter((entry) => entry
|
|
467
|
+
await writeIndex(store, index.filter((entry) => !isVisible(entry, scope, owner)));
|
|
318
468
|
return removed;
|
|
319
469
|
}
|
|
320
470
|
/**
|
|
321
|
-
* Commons on Android:
|
|
322
|
-
*
|
|
323
|
-
*
|
|
324
|
-
*
|
|
471
|
+
* Commons on Android: make the identity signer store hold exactly what the
|
|
472
|
+
* identity host may answer — every organization ring, and the personal entries
|
|
473
|
+
* of `ownerAccountId` (the person Commons is signed in as) — so the host
|
|
474
|
+
* answers for them after that file was rebuilt (its self-heal leaves it
|
|
475
|
+
* empty), and never for a previous person's keys (R5-2): every other personal
|
|
476
|
+
* entry is removed from the signer store (its own slot keeps it). Without
|
|
477
|
+
* `ownerAccountId` no personal entry is mirrored. Call on every boot and after
|
|
478
|
+
* every sign-in, beside `syncOrganizationKeys`. Resolves how many it mirrored.
|
|
479
|
+
* Elsewhere 0.
|
|
325
480
|
*/
|
|
326
|
-
export async function syncRetiredKeys() {
|
|
481
|
+
export async function syncRetiredKeys(ownerAccountId) {
|
|
327
482
|
const signer = isAndroid() ? registeredIdentitySignerStore() : null;
|
|
328
483
|
if (!signer?.writeRetired || !signer.readRetired)
|
|
329
484
|
return 0;
|
|
485
|
+
const owner = ownerOf(ownerAccountId);
|
|
330
486
|
const store = await ringStore();
|
|
487
|
+
const index = await readIndex(store);
|
|
488
|
+
const allowed = new Set(index.filter((entry) => isVisible(entry, PERSONAL_SCOPE, owner)).map((e) => e.publicKey));
|
|
489
|
+
await clearPersonalMirror(signer, index, allowed);
|
|
331
490
|
let mirrored = 0;
|
|
332
|
-
for (const entry of
|
|
491
|
+
for (const entry of index) {
|
|
492
|
+
if (entry.scope === PERSONAL_SCOPE && !isVisible(entry, PERSONAL_SCOPE, owner))
|
|
493
|
+
continue;
|
|
333
494
|
const privateKey = await store.getItemAsync(privateKeyName(entry.scope, entry.publicKey), privateOptions(store, false));
|
|
334
495
|
if (!privateKey || !isHealthyPair(privateKey, entry.publicKey))
|
|
335
496
|
continue;
|
|
@@ -342,6 +503,88 @@ export async function syncRetiredKeys() {
|
|
|
342
503
|
}
|
|
343
504
|
return mirrored;
|
|
344
505
|
}
|
|
506
|
+
/**
|
|
507
|
+
* Every retired key of `selector`'s ring (personal: `ownerAccountId`'s entries)
|
|
508
|
+
* WITH its private half, for the encrypted identity backup
|
|
509
|
+
* (`oxy.identity.backup.create`), which carries them under the CURRENT phrase
|
|
510
|
+
* so a lost phone costs no more than the current phrase does (R4-4). Commons
|
|
511
|
+
* only. STRICT (R5-6): an unreadable ring, or a listed entry whose private
|
|
512
|
+
* half is missing or does not derive its public key, THROWS — a backup is
|
|
513
|
+
* never sealed without a key the ring says it holds. Never log or transmit the
|
|
514
|
+
* result except inside that encrypted envelope.
|
|
515
|
+
*/
|
|
516
|
+
export async function exportRetiredIdentityKeys(selector) {
|
|
517
|
+
const scope = retiredKeyScope(selector);
|
|
518
|
+
if (!scope || isWeb())
|
|
519
|
+
return [];
|
|
520
|
+
await assertMayWrite();
|
|
521
|
+
const out = [];
|
|
522
|
+
for (const entry of await listRetiredIdentityKeys(selector)) {
|
|
523
|
+
const privateKey = await readRetiredStrict(entry.publicKey, scope);
|
|
524
|
+
out.push({ privateKey, publicKey: entry.publicKey, retiredAt: entry.retiredAt });
|
|
525
|
+
}
|
|
526
|
+
return out;
|
|
527
|
+
}
|
|
528
|
+
/** A listed entry's private half, or a throw: Commons' own slot, then (Android) the signer copy. */
|
|
529
|
+
async function readRetiredStrict(publicKey, scope) {
|
|
530
|
+
const store = await ringStore();
|
|
531
|
+
const local = await store.getItemAsync(privateKeyName(scope, publicKey), privateOptions(store, false));
|
|
532
|
+
if (local && isHealthyPair(local, publicKey))
|
|
533
|
+
return normalizeSecp256k1PrivateKey(local);
|
|
534
|
+
const signer = isAndroid() ? registeredIdentitySignerStore() : null;
|
|
535
|
+
const mirrored = (await signer?.readRetired?.(scope, publicKey)) ?? null;
|
|
536
|
+
if (mirrored && isHealthyPair(mirrored.privateKey, publicKey)) {
|
|
537
|
+
return normalizeSecp256k1PrivateKey(mirrored.privateKey);
|
|
538
|
+
}
|
|
539
|
+
throw new RetiredKeyRingUnreadableError('a listed key has no readable private half');
|
|
540
|
+
}
|
|
541
|
+
/**
|
|
542
|
+
* Commons on Android, when the identity leaves this device ("Start over", a
|
|
543
|
+
* forced delete): remove every PERSONAL entry from the identity signer store,
|
|
544
|
+
* so the identity host answers no other app with them before the next person
|
|
545
|
+
* signs in (R5-2). Commons' own slot and index keep them (money, bound to
|
|
546
|
+
* their owner). Elsewhere a no-op.
|
|
547
|
+
*/
|
|
548
|
+
export async function unmirrorPersonalRetiredKeys() {
|
|
549
|
+
const signer = isAndroid() ? registeredIdentitySignerStore() : null;
|
|
550
|
+
if (!signer?.clearRetired)
|
|
551
|
+
return;
|
|
552
|
+
let index = [];
|
|
553
|
+
try {
|
|
554
|
+
index = await readIndex(await ringStore());
|
|
555
|
+
}
|
|
556
|
+
catch {
|
|
557
|
+
// The signer store's own list below still reaches every mirrored entry.
|
|
558
|
+
}
|
|
559
|
+
await clearPersonalMirror(signer, index, new Set());
|
|
560
|
+
}
|
|
561
|
+
/** Remove from the signer store every personal entry not in `allowed`: the index's and the store's own list. */
|
|
562
|
+
async function clearPersonalMirror(signer, index, allowed) {
|
|
563
|
+
if (!signer.clearRetired)
|
|
564
|
+
return;
|
|
565
|
+
const mirrored = new Set(index.filter((entry) => entry.scope === PERSONAL_SCOPE).map((entry) => entry.publicKey));
|
|
566
|
+
for (const publicKey of (await signer.listRetired?.(PERSONAL_SCOPE)) ?? []) {
|
|
567
|
+
mirrored.add(publicKey.toLowerCase());
|
|
568
|
+
}
|
|
569
|
+
for (const publicKey of mirrored) {
|
|
570
|
+
if (!allowed.has(publicKey))
|
|
571
|
+
await signer.clearRetired(PERSONAL_SCOPE, publicKey);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
/**
|
|
575
|
+
* The pair a previous key's raw PRIVATE KEY gives (64 hex, an optional `0x`),
|
|
576
|
+
* for a person whose old identity was imported as a private key and so has no
|
|
577
|
+
* phrase to re-enter. Stores nothing: the caller checks the public key against
|
|
578
|
+
* the account's key history and only then calls {@link retireIdentityKey}.
|
|
579
|
+
*/
|
|
580
|
+
export function retiredKeyFromPrivateKey(privateKeyHex) {
|
|
581
|
+
const trimmed = privateKeyHex.trim().toLowerCase().replace(/^0x/, '');
|
|
582
|
+
if (!/^[0-9a-f]{64}$/.test(trimmed) || !isValidSecp256k1PrivateKey(trimmed)) {
|
|
583
|
+
throw new Error('retired key: not a valid private key');
|
|
584
|
+
}
|
|
585
|
+
const privateKey = normalizeSecp256k1PrivateKey(trimmed);
|
|
586
|
+
return { privateKey, publicKey: publicKeyOf(privateKey) };
|
|
587
|
+
}
|
|
345
588
|
/**
|
|
346
589
|
* The pair a previous recovery phrase derives (`seed[0:32]`, the personal-root
|
|
347
590
|
* and organization-key derivation; 12–24 words). Stores nothing: the caller
|