@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
|
@@ -46,6 +46,7 @@ const keychainAccessGroups_1 = require("../utils/keychainAccessGroups");
|
|
|
46
46
|
const platform_1 = require("../utils/platform");
|
|
47
47
|
const identitySigner_1 = require("./identitySigner");
|
|
48
48
|
const keychainGroupMigration_1 = require("./keychainGroupMigration");
|
|
49
|
+
const retiredKeys_1 = require("./retiredKeys");
|
|
49
50
|
const keychainItems_1 = require("./keychainItems");
|
|
50
51
|
Object.defineProperty(exports, "isOrganizationAccountId", { enumerable: true, get: function () { return keychainItems_1.isOrganizationAccountId; } });
|
|
51
52
|
const ORGANIZATION_PHRASE_WORDS = 24;
|
|
@@ -115,9 +116,7 @@ async function organizationKeyFromPhrase(phrase) {
|
|
|
115
116
|
async function secureStore(item) {
|
|
116
117
|
const raw = await (0, protocol_1.loadSecureStore)();
|
|
117
118
|
const access = await (0, keychainAccessGroups_1.resolveKeychainAccess)();
|
|
118
|
-
|
|
119
|
-
await (0, keychainGroupMigration_1.ensureKeychainGroupsMigrated)();
|
|
120
|
-
}
|
|
119
|
+
await (0, keychainGroupMigration_1.prepareKeychainAccess)(access);
|
|
121
120
|
return (0, keychainAccessGroups_1.groupedKeychainStore)(raw, access, item);
|
|
122
121
|
}
|
|
123
122
|
const pairStore = () => secureStore('spending-key');
|
|
@@ -208,8 +207,10 @@ async function readLocalPair(accountId) {
|
|
|
208
207
|
* it; on iOS only Commons holds the groups it writes (any other app throws).
|
|
209
208
|
*
|
|
210
209
|
* Refuses a pair whose public key does not derive from its private key, and a
|
|
211
|
-
* phrase that does not derive the pair.
|
|
212
|
-
* the organization
|
|
210
|
+
* phrase that does not derive the pair. A DIFFERENT key this device already
|
|
211
|
+
* held for the organization is first kept in the organization's retired ring
|
|
212
|
+
* (ADR 0061 D7 as amended; R4-12): coins under it still need it. When that
|
|
213
|
+
* cannot be confirmed nothing is overwritten and this throws.
|
|
213
214
|
*/
|
|
214
215
|
async function storeOrganizationKey(accountId, key) {
|
|
215
216
|
assertAccountId(accountId);
|
|
@@ -231,6 +232,18 @@ async function storeOrganizationKey(accountId, key) {
|
|
|
231
232
|
throw new Error('On Android only Commons holds organization keys; this app has no identity signer store for them.');
|
|
232
233
|
}
|
|
233
234
|
const store = await pairStore();
|
|
235
|
+
const previous = await heldPrivateKey(store, accountId);
|
|
236
|
+
if (previous) {
|
|
237
|
+
const previousPublic = (0, secp256k1_1.deriveSecp256k1PublicKey)(previous);
|
|
238
|
+
if (previousPublic !== publicKey) {
|
|
239
|
+
try {
|
|
240
|
+
await (0, retiredKeys_1.retireIdentityKey)({ privateKey: previous, publicKey: previousPublic }, { accountId });
|
|
241
|
+
}
|
|
242
|
+
catch (error) {
|
|
243
|
+
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'})`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
234
247
|
await store.setItemAsync((0, keychainItems_1.organizationPrivateKeyName)(accountId), privateKey, pairOptions(store, true));
|
|
235
248
|
await store.setItemAsync((0, keychainItems_1.organizationPublicKeyName)(accountId), publicKey, pairOptions(store, false));
|
|
236
249
|
if (signer?.writeOrganization &&
|
|
@@ -311,6 +324,21 @@ async function listOrganizationKeys() {
|
|
|
311
324
|
return [];
|
|
312
325
|
return readIndex(await indexStore());
|
|
313
326
|
}
|
|
327
|
+
/**
|
|
328
|
+
* The private key this device holds for `accountId`, whatever its public half
|
|
329
|
+
* says (an unhealthy pair is still a key coins may sit under): the slot, then
|
|
330
|
+
* on Android the signer store's copy. `null` when there is none.
|
|
331
|
+
*/
|
|
332
|
+
async function heldPrivateKey(store, accountId) {
|
|
333
|
+
const local = await store.getItemAsync((0, keychainItems_1.organizationPrivateKeyName)(accountId), pairOptions(store, false));
|
|
334
|
+
if (local && (0, secp256k1_1.isValidSecp256k1PrivateKey)(local))
|
|
335
|
+
return (0, secp256k1_1.normalizeSecp256k1PrivateKey)(local);
|
|
336
|
+
const signer = (0, platform_1.isAndroid)() ? (0, identitySigner_1.registeredIdentitySignerStore)() : null;
|
|
337
|
+
const mirrored = (await signer?.readOrganization?.(accountId)) ?? null;
|
|
338
|
+
return mirrored && (0, secp256k1_1.isValidSecp256k1PrivateKey)(mirrored.privateKey)
|
|
339
|
+
? (0, secp256k1_1.normalizeSecp256k1PrivateKey)(mirrored.privateKey)
|
|
340
|
+
: null;
|
|
341
|
+
}
|
|
314
342
|
/** Forget an organization key on this device: its pair, its phrase, its signer copy and its index entry. */
|
|
315
343
|
async function deleteOrganizationKey(accountId) {
|
|
316
344
|
assertAccountId(accountId);
|
|
@@ -43,13 +43,20 @@
|
|
|
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;
|
|
49
|
+
exports.retireIdentityKeyDuringMigration = retireIdentityKeyDuringMigration;
|
|
50
|
+
exports.claimRetiredIdentityKeys = claimRetiredIdentityKeys;
|
|
51
|
+
exports.countQuarantinedRetiredKeys = countQuarantinedRetiredKeys;
|
|
48
52
|
exports.listRetiredIdentityKeys = listRetiredIdentityKeys;
|
|
49
53
|
exports.getRetiredPrivateKey = getRetiredPrivateKey;
|
|
50
54
|
exports.forgetRetiredIdentityKey = forgetRetiredIdentityKey;
|
|
51
55
|
exports.forgetRetiredIdentityKeys = forgetRetiredIdentityKeys;
|
|
52
56
|
exports.syncRetiredKeys = syncRetiredKeys;
|
|
57
|
+
exports.exportRetiredIdentityKeys = exportRetiredIdentityKeys;
|
|
58
|
+
exports.unmirrorPersonalRetiredKeys = unmirrorPersonalRetiredKeys;
|
|
59
|
+
exports.retiredKeyFromPrivateKey = retiredKeyFromPrivateKey;
|
|
53
60
|
exports.retiredKeyFromPhrase = retiredKeyFromPhrase;
|
|
54
61
|
const bip39_1 = require("@scure/bip39");
|
|
55
62
|
const english_js_1 = require("@scure/bip39/wordlists/english.js");
|
|
@@ -59,8 +66,16 @@ const logger_1 = require("../logger");
|
|
|
59
66
|
const keychainAccessGroups_1 = require("../utils/keychainAccessGroups");
|
|
60
67
|
const platform_1 = require("../utils/platform");
|
|
61
68
|
const identitySigner_1 = require("./identitySigner");
|
|
69
|
+
const keychainItems_1 = require("./keychainItems");
|
|
62
70
|
const keychainGroupMigration_1 = require("./keychainGroupMigration");
|
|
63
|
-
|
|
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;
|
|
64
79
|
const PERSONAL_SCOPE = 'personal';
|
|
65
80
|
/** Android, Commons-local: its own Keystore-backed slot, shared with no other key. */
|
|
66
81
|
const ANDROID_KEYCHAIN_SERVICE = 'oxy_retired_identity';
|
|
@@ -71,15 +86,15 @@ const PUBLIC_KEY = /^04[0-9a-f]{128}$/;
|
|
|
71
86
|
* The secure store for the ring. On iOS every item is a `spending-key`
|
|
72
87
|
* (ADR 0062): the private keys AND the index live in the wallet group, which
|
|
73
88
|
* only Commons and the Peable wallet can read, never the shared one. In
|
|
74
|
-
* Commons every access first awaits the access-group migration
|
|
75
|
-
* the
|
|
89
|
+
* Commons every access first awaits the access-group migration (`gate: false`
|
|
90
|
+
* is the migration itself, keeping a pre-split copy). On Android the grouping
|
|
91
|
+
* is a no-op and Commons' own services below apply.
|
|
76
92
|
*/
|
|
77
|
-
async function ringStore() {
|
|
93
|
+
async function ringStore(gate = true) {
|
|
78
94
|
const raw = await (0, protocol_1.loadSecureStore)();
|
|
79
95
|
const access = await (0, keychainAccessGroups_1.resolveKeychainAccess)();
|
|
80
|
-
if (
|
|
81
|
-
await (0, keychainGroupMigration_1.
|
|
82
|
-
}
|
|
96
|
+
if (gate)
|
|
97
|
+
await (0, keychainGroupMigration_1.prepareKeychainAccess)(access);
|
|
83
98
|
return (0, keychainAccessGroups_1.groupedKeychainStore)(raw, access, 'spending-key');
|
|
84
99
|
}
|
|
85
100
|
/** The ring a selector names: `personal`, or the organization's account id. */
|
|
@@ -87,7 +102,7 @@ function retiredKeyScope(selector) {
|
|
|
87
102
|
const accountId = selector?.accountId;
|
|
88
103
|
if (accountId === undefined)
|
|
89
104
|
return PERSONAL_SCOPE;
|
|
90
|
-
return (0,
|
|
105
|
+
return (0, keychainItems_1.isOrganizationAccountId)(accountId) ? accountId : null;
|
|
91
106
|
}
|
|
92
107
|
function canonicalPublicKey(publicKey) {
|
|
93
108
|
try {
|
|
@@ -129,32 +144,58 @@ function privateOptions(store, forWrite) {
|
|
|
129
144
|
function indexOptions() {
|
|
130
145
|
return (0, platform_1.isIOS)() ? {} : { keychainService: INDEX_KEYCHAIN_SERVICE };
|
|
131
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
|
+
}
|
|
132
165
|
function isIndexEntry(value) {
|
|
133
166
|
if (typeof value !== 'object' || value === null)
|
|
134
167
|
return false;
|
|
135
168
|
const entry = value;
|
|
136
169
|
return (typeof entry.scope === 'string' &&
|
|
137
|
-
(entry.scope === PERSONAL_SCOPE || (0,
|
|
170
|
+
(entry.scope === PERSONAL_SCOPE || (0, keychainItems_1.isOrganizationAccountId)(entry.scope)) &&
|
|
138
171
|
typeof entry.publicKey === 'string' &&
|
|
139
172
|
PUBLIC_KEY.test(entry.publicKey) &&
|
|
140
173
|
typeof entry.retiredAt === 'number' &&
|
|
141
|
-
Number.isFinite(entry.retiredAt)
|
|
174
|
+
Number.isFinite(entry.retiredAt) &&
|
|
175
|
+
(entry.owner === undefined || (typeof entry.owner === 'string' && ACCOUNT_ID.test(entry.owner))));
|
|
142
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
|
+
*/
|
|
143
184
|
async function readIndex(store) {
|
|
144
185
|
const raw = await store.getItemAsync(INDEX_STORAGE_KEY, indexOptions());
|
|
145
186
|
if (!raw)
|
|
146
187
|
return [];
|
|
188
|
+
let parsed;
|
|
147
189
|
try {
|
|
148
|
-
|
|
149
|
-
return Array.isArray(parsed) ? parsed.filter(isIndexEntry) : [];
|
|
190
|
+
parsed = JSON.parse(raw);
|
|
150
191
|
}
|
|
151
|
-
catch
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
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');
|
|
157
197
|
}
|
|
198
|
+
return parsed.map((entry) => entry.owner === undefined ? entry : { ...entry, owner: entry.owner.toLowerCase() });
|
|
158
199
|
}
|
|
159
200
|
async function writeIndex(store, entries) {
|
|
160
201
|
const unique = new Map();
|
|
@@ -167,81 +208,178 @@ async function writeIndex(store, entries) {
|
|
|
167
208
|
await store.setItemAsync(INDEX_STORAGE_KEY, JSON.stringify(sorted), indexOptions());
|
|
168
209
|
}
|
|
169
210
|
function toListed(entry) {
|
|
211
|
+
// The owner is the reader's own id; it is not repeated.
|
|
170
212
|
return entry.scope === PERSONAL_SCOPE
|
|
171
213
|
? { publicKey: entry.publicKey, retiredAt: entry.retiredAt }
|
|
172
214
|
: { publicKey: entry.publicKey, accountId: entry.scope, retiredAt: entry.retiredAt };
|
|
173
215
|
}
|
|
174
|
-
/**
|
|
175
|
-
|
|
216
|
+
/**
|
|
217
|
+
* Whether this app writes the ring: Commons only (Android: the signer store it
|
|
218
|
+
* registers; iOS: the keychain role `holder`). The Peable wallet holds the
|
|
219
|
+
* wallet group too, but only reads, derives and signs: a key the wallet could
|
|
220
|
+
* add to the ring is a key it could choose (R4-14).
|
|
221
|
+
*/
|
|
222
|
+
async function assertMayWrite() {
|
|
176
223
|
if ((0, platform_1.isWeb)())
|
|
177
224
|
throw new Error('retired key: only a native device holds one');
|
|
178
225
|
if ((0, platform_1.isAndroid)() && !(0, identitySigner_1.registeredIdentitySignerStore)()?.writeRetired) {
|
|
179
226
|
throw new Error('On Android only Commons holds retired keys; this app has no identity signer store for them.');
|
|
180
227
|
}
|
|
228
|
+
if ((0, platform_1.isIOS)()) {
|
|
229
|
+
const access = await (0, keychainAccessGroups_1.resolveKeychainAccess)();
|
|
230
|
+
if (access.mode === 'grouped' && access.role !== 'holder') {
|
|
231
|
+
throw new Error('On iOS only Commons writes retired keys; this app may only read them.');
|
|
232
|
+
}
|
|
233
|
+
}
|
|
181
234
|
}
|
|
182
235
|
/**
|
|
183
|
-
* Keep `key` in the ring of `selector`'s account. Commons only.
|
|
184
|
-
*
|
|
185
|
-
*
|
|
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
|
|
186
243
|
* (Android: the signer store's read-back too), and throws otherwise, so a
|
|
187
244
|
* caller about to replace the key can stop before anything is lost.
|
|
188
245
|
*/
|
|
189
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
|
+
}
|
|
251
|
+
await keepRetired(key, selector, true);
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* {@link retireIdentityKey} from inside the iOS access-group migration, which
|
|
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
|
|
259
|
+
*/
|
|
260
|
+
async function retireIdentityKeyDuringMigration(key, selector) {
|
|
261
|
+
await keepRetired(key, selector, false);
|
|
262
|
+
}
|
|
263
|
+
async function keepRetired(key, selector, gate) {
|
|
190
264
|
const scope = retiredKeyScope(selector);
|
|
191
265
|
if (!scope)
|
|
192
266
|
throw new Error('retired key: accountId must be a 24-hex or uuid account id');
|
|
193
|
-
|
|
267
|
+
const owner = scope === PERSONAL_SCOPE ? ownerOf(selector?.ownerAccountId) : null;
|
|
268
|
+
await assertMayWrite();
|
|
194
269
|
if (!isHealthyPair(key.privateKey, key.publicKey)) {
|
|
195
270
|
throw new Error('retired key: the public key does not derive from the private key');
|
|
196
271
|
}
|
|
197
272
|
const privateKey = (0, secp256k1_1.normalizeSecp256k1PrivateKey)(key.privateKey);
|
|
198
273
|
const publicKey = (0, secp256k1_1.normalizeSecp256k1PublicKey)(key.publicKey);
|
|
199
|
-
const store = await ringStore();
|
|
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
|
+
}
|
|
200
281
|
await store.setItemAsync(privateKeyName(scope, publicKey), privateKey, privateOptions(store, true));
|
|
201
282
|
const readBack = await store.getItemAsync(privateKeyName(scope, publicKey), privateOptions(store, false));
|
|
202
283
|
if (readBack !== privateKey) {
|
|
203
284
|
throw new Error('retired key: the keychain did not confirm the key');
|
|
204
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.
|
|
205
302
|
const signer = (0, platform_1.isAndroid)() ? (0, identitySigner_1.registeredIdentitySignerStore)() : null;
|
|
206
|
-
if (signer?.writeRetired &&
|
|
303
|
+
if (signer?.writeRetired &&
|
|
304
|
+
(scope !== PERSONAL_SCOPE || owner !== null) &&
|
|
305
|
+
!(await signer.writeRetired(scope, privateKey, publicKey))) {
|
|
207
306
|
throw new Error(`The identity signer store (${signer.name}) did not confirm the retired key.`);
|
|
208
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();
|
|
209
327
|
const index = await readIndex(store);
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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;
|
|
213
351
|
}
|
|
214
352
|
/**
|
|
215
353
|
* The retired keys of `selector`'s account this app can reach, oldest first.
|
|
216
|
-
*
|
|
217
|
-
*
|
|
218
|
-
*
|
|
219
|
-
*
|
|
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.
|
|
220
364
|
*/
|
|
221
365
|
async function listRetiredIdentityKeys(selector) {
|
|
222
366
|
const scope = retiredKeyScope(selector);
|
|
223
367
|
if (!scope || (0, platform_1.isWeb)())
|
|
224
368
|
return [];
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
const bridge = await (0, protocol_1.loadCommonsIdentityBridge)();
|
|
228
|
-
const keys = (await bridge?.listRetiredKeys?.(selector?.accountId)) ?? null;
|
|
229
|
-
return (keys ?? []).map((publicKey) => scope === PERSONAL_SCOPE
|
|
230
|
-
? { publicKey, retiredAt: 0 }
|
|
231
|
-
: { publicKey, accountId: scope, retiredAt: 0 });
|
|
232
|
-
}
|
|
233
|
-
if (!(0, platform_1.isIOS)() && !(0, platform_1.isAndroid)())
|
|
234
|
-
return [];
|
|
235
|
-
const store = await ringStore();
|
|
236
|
-
return (await readIndex(store)).filter((entry) => entry.scope === scope).map(toListed);
|
|
237
|
-
}
|
|
238
|
-
catch (error) {
|
|
239
|
-
logger_1.logger.warn('Failed to list retired keys', {
|
|
240
|
-
component: 'RetiredKeys',
|
|
241
|
-
error: error instanceof Error ? error.name : 'unknown',
|
|
242
|
-
});
|
|
369
|
+
const owner = ownerOf(selector?.ownerAccountId);
|
|
370
|
+
if (scope === PERSONAL_SCOPE && owner === null)
|
|
243
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 });
|
|
244
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);
|
|
245
383
|
}
|
|
246
384
|
/**
|
|
247
385
|
* The private half of a retired key, where this app may hold it: Commons (both
|
|
@@ -255,12 +393,18 @@ async function getRetiredPrivateKey(publicKey, selector) {
|
|
|
255
393
|
const canonical = canonicalPublicKey(publicKey);
|
|
256
394
|
if (!scope || !canonical || (0, platform_1.isWeb)())
|
|
257
395
|
return null;
|
|
396
|
+
const owner = ownerOf(selector?.ownerAccountId);
|
|
397
|
+
if (scope === PERSONAL_SCOPE && owner === null)
|
|
398
|
+
return null;
|
|
258
399
|
try {
|
|
259
400
|
if ((0, platform_1.isAndroid)()) {
|
|
260
401
|
const signer = (0, identitySigner_1.registeredIdentitySignerStore)();
|
|
261
402
|
if (!signer)
|
|
262
403
|
return null;
|
|
263
404
|
const store = await ringStore();
|
|
405
|
+
if (!(await readIndex(store)).some((entry) => isVisible(entry, scope, owner) && entry.publicKey === canonical)) {
|
|
406
|
+
return null;
|
|
407
|
+
}
|
|
264
408
|
const local = await store.getItemAsync(privateKeyName(scope, canonical), privateOptions(store, false));
|
|
265
409
|
if (local && isHealthyPair(local, canonical))
|
|
266
410
|
return (0, secp256k1_1.normalizeSecp256k1PrivateKey)(local);
|
|
@@ -273,6 +417,11 @@ async function getRetiredPrivateKey(publicKey, selector) {
|
|
|
273
417
|
if (!(0, platform_1.isIOS)())
|
|
274
418
|
return null;
|
|
275
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
|
+
}
|
|
276
425
|
const value = await store.getItemAsync(privateKeyName(scope, canonical), privateOptions(store, false));
|
|
277
426
|
return value && isHealthyPair(value, canonical) ? (0, secp256k1_1.normalizeSecp256k1PrivateKey)(value) : null;
|
|
278
427
|
}
|
|
@@ -294,18 +443,24 @@ async function forgetRetiredIdentityKey(publicKey, selector) {
|
|
|
294
443
|
const canonical = canonicalPublicKey(publicKey);
|
|
295
444
|
if (!scope || !canonical)
|
|
296
445
|
throw new Error('retired key: invalid selector or public key');
|
|
297
|
-
|
|
446
|
+
const owner = ownerOf(selector?.ownerAccountId);
|
|
447
|
+
await assertMayWrite();
|
|
298
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');
|
|
299
452
|
await store.deleteItemAsync(privateKeyName(scope, canonical), privateOptions(store, false));
|
|
300
453
|
const signer = (0, platform_1.isAndroid)() ? (0, identitySigner_1.registeredIdentitySignerStore)() : null;
|
|
301
454
|
await signer?.clearRetired?.(scope, canonical);
|
|
302
455
|
await writeIndex(store, (await readIndex(store)).filter((entry) => !(entry.scope === scope && entry.publicKey === canonical)));
|
|
303
456
|
}
|
|
304
457
|
/**
|
|
305
|
-
* Forget every retired key of one ring (the person's, or an organization's)
|
|
306
|
-
*
|
|
307
|
-
*
|
|
308
|
-
*
|
|
458
|
+
* Forget every retired key of one ring (the person's, or an organization's).
|
|
459
|
+
* Commons only, and only when the person deletes their Oxy ACCOUNT (Commons'
|
|
460
|
+
* delete-account flow), which tells them the coins go with it. Deleting the
|
|
461
|
+
* identity from this device — "Start over", a forced delete — never calls it:
|
|
462
|
+
* the ring is money, and the same person restoring their identity later must
|
|
463
|
+
* find it (R4-4). Resolves how many it removed.
|
|
309
464
|
*/
|
|
310
465
|
async function forgetRetiredIdentityKeys(selector) {
|
|
311
466
|
const scope = retiredKeyScope(selector);
|
|
@@ -313,33 +468,47 @@ async function forgetRetiredIdentityKeys(selector) {
|
|
|
313
468
|
return 0;
|
|
314
469
|
if ((0, platform_1.isAndroid)() && !(0, identitySigner_1.registeredIdentitySignerStore)())
|
|
315
470
|
return 0;
|
|
471
|
+
const owner = ownerOf(selector?.ownerAccountId);
|
|
472
|
+
if (scope === PERSONAL_SCOPE && owner === null)
|
|
473
|
+
return 0;
|
|
316
474
|
const store = await ringStore();
|
|
317
475
|
const index = await readIndex(store);
|
|
318
476
|
const signer = (0, platform_1.isAndroid)() ? (0, identitySigner_1.registeredIdentitySignerStore)() : null;
|
|
319
477
|
let removed = 0;
|
|
320
478
|
for (const entry of index) {
|
|
321
|
-
if (entry
|
|
479
|
+
if (!isVisible(entry, scope, owner))
|
|
322
480
|
continue;
|
|
323
481
|
await store.deleteItemAsync(privateKeyName(scope, entry.publicKey), privateOptions(store, false));
|
|
324
482
|
await signer?.clearRetired?.(scope, entry.publicKey);
|
|
325
483
|
removed += 1;
|
|
326
484
|
}
|
|
327
|
-
await writeIndex(store, index.filter((entry) => entry
|
|
485
|
+
await writeIndex(store, index.filter((entry) => !isVisible(entry, scope, owner)));
|
|
328
486
|
return removed;
|
|
329
487
|
}
|
|
330
488
|
/**
|
|
331
|
-
* Commons on Android:
|
|
332
|
-
*
|
|
333
|
-
*
|
|
334
|
-
*
|
|
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.
|
|
335
498
|
*/
|
|
336
|
-
async function syncRetiredKeys() {
|
|
499
|
+
async function syncRetiredKeys(ownerAccountId) {
|
|
337
500
|
const signer = (0, platform_1.isAndroid)() ? (0, identitySigner_1.registeredIdentitySignerStore)() : null;
|
|
338
501
|
if (!signer?.writeRetired || !signer.readRetired)
|
|
339
502
|
return 0;
|
|
503
|
+
const owner = ownerOf(ownerAccountId);
|
|
340
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);
|
|
341
508
|
let mirrored = 0;
|
|
342
|
-
for (const entry of
|
|
509
|
+
for (const entry of index) {
|
|
510
|
+
if (entry.scope === PERSONAL_SCOPE && !isVisible(entry, PERSONAL_SCOPE, owner))
|
|
511
|
+
continue;
|
|
343
512
|
const privateKey = await store.getItemAsync(privateKeyName(entry.scope, entry.publicKey), privateOptions(store, false));
|
|
344
513
|
if (!privateKey || !isHealthyPair(privateKey, entry.publicKey))
|
|
345
514
|
continue;
|
|
@@ -352,6 +521,88 @@ async function syncRetiredKeys() {
|
|
|
352
521
|
}
|
|
353
522
|
return mirrored;
|
|
354
523
|
}
|
|
524
|
+
/**
|
|
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.
|
|
533
|
+
*/
|
|
534
|
+
async function exportRetiredIdentityKeys(selector) {
|
|
535
|
+
const scope = retiredKeyScope(selector);
|
|
536
|
+
if (!scope || (0, platform_1.isWeb)())
|
|
537
|
+
return [];
|
|
538
|
+
await assertMayWrite();
|
|
539
|
+
const out = [];
|
|
540
|
+
for (const entry of await listRetiredIdentityKeys(selector)) {
|
|
541
|
+
const privateKey = await readRetiredStrict(entry.publicKey, scope);
|
|
542
|
+
out.push({ privateKey, publicKey: entry.publicKey, retiredAt: entry.retiredAt });
|
|
543
|
+
}
|
|
544
|
+
return out;
|
|
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
|
+
}
|
|
592
|
+
/**
|
|
593
|
+
* The pair a previous key's raw PRIVATE KEY gives (64 hex, an optional `0x`),
|
|
594
|
+
* for a person whose old identity was imported as a private key and so has no
|
|
595
|
+
* phrase to re-enter. Stores nothing: the caller checks the public key against
|
|
596
|
+
* the account's key history and only then calls {@link retireIdentityKey}.
|
|
597
|
+
*/
|
|
598
|
+
function retiredKeyFromPrivateKey(privateKeyHex) {
|
|
599
|
+
const trimmed = privateKeyHex.trim().toLowerCase().replace(/^0x/, '');
|
|
600
|
+
if (!/^[0-9a-f]{64}$/.test(trimmed) || !(0, secp256k1_1.isValidSecp256k1PrivateKey)(trimmed)) {
|
|
601
|
+
throw new Error('retired key: not a valid private key');
|
|
602
|
+
}
|
|
603
|
+
const privateKey = (0, secp256k1_1.normalizeSecp256k1PrivateKey)(trimmed);
|
|
604
|
+
return { privateKey, publicKey: publicKeyOf(privateKey) };
|
|
605
|
+
}
|
|
355
606
|
/**
|
|
356
607
|
* The pair a previous recovery phrase derives (`seed[0:32]`, the personal-root
|
|
357
608
|
* and organization-key derivation; 12–24 words). Stores nothing: the caller
|