@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
|
@@ -11,7 +11,7 @@ import { isDev, logger } from '../logger/index.js';
|
|
|
11
11
|
import { deriveScopedSeedFromKey, signSocialReceiveDigest } from './identityDerivations.js';
|
|
12
12
|
import { registerIdentitySignerStore } from './identitySigner.js';
|
|
13
13
|
import { getOrganizationPrivateKey, isOrganizationAccountId } from './organizationKeys.js';
|
|
14
|
-
import { getRetiredPrivateKey, retiredKeyScope } from './retiredKeys.js';
|
|
14
|
+
import { getRetiredPrivateKey, retiredKeyScope, unmirrorPersonalRetiredKeys } from './retiredKeys.js';
|
|
15
15
|
import { groupedKeychainStore, resolveKeychainAccess, } from '../utils/keychainAccessGroups.js';
|
|
16
16
|
import { prepareKeychainAccess } from './keychainGroupMigration.js';
|
|
17
17
|
import { RECOVERY_MNEMONIC_KEYCHAIN_SERVICE, RECOVERY_MNEMONIC_STORAGE_KEY, STORAGE_KEYS, V2_BACKUP_KEYCHAIN_SERVICE, V2_PRIMARY_KEYCHAIN_SERVICE, V2_STORAGE_KEYS, } from './keychainItems.js';
|
|
@@ -749,12 +749,30 @@ export class KeyManager {
|
|
|
749
749
|
* `signSocialReceive`) is fresh, so a cached key here would let a caller
|
|
750
750
|
* compare a new seed against an old key.
|
|
751
751
|
*
|
|
752
|
+
* iOS, in an app that holds the private key (Commons, the Peable wallet:
|
|
753
|
+
* keychain roles `holder` and `wallet`): DERIVED from the private key in the
|
|
754
|
+
* wallet group, never read from the public-key item, which sits in the shared
|
|
755
|
+
* group every Oxy app can write (R5-1); `null` when that app holds no private
|
|
756
|
+
* key. Every other iOS app reads the public-key item: it holds nothing it
|
|
757
|
+
* could derive from, and it never spends. Money code uses
|
|
758
|
+
* {@link getIdentityPublicKeyForMoney}.
|
|
759
|
+
*
|
|
752
760
|
* @returns Shared public key or null if no shared identity exists
|
|
753
761
|
*/
|
|
754
762
|
static async getSharedPublicKey() {
|
|
755
763
|
if (isWebPlatform()) {
|
|
756
764
|
return null;
|
|
757
765
|
}
|
|
766
|
+
if (isIOS() && (await KeyManager._holdsSpendingKeys())) {
|
|
767
|
+
// Not cached: a rotation in Commons changes it under the wallet.
|
|
768
|
+
try {
|
|
769
|
+
const privateKey = await KeyManager.getSharedPrivateKey();
|
|
770
|
+
return privateKey ? KeyManager.derivePublicKey(privateKey) : null;
|
|
771
|
+
}
|
|
772
|
+
catch {
|
|
773
|
+
return null;
|
|
774
|
+
}
|
|
775
|
+
}
|
|
758
776
|
// Return cached value if available (iOS only; see above).
|
|
759
777
|
if (!isAndroid() && KeyManager.cachedSharedPublicKey !== null) {
|
|
760
778
|
return KeyManager.cachedSharedPublicKey;
|
|
@@ -787,6 +805,68 @@ export class KeyManager {
|
|
|
787
805
|
return null;
|
|
788
806
|
}
|
|
789
807
|
}
|
|
808
|
+
/**
|
|
809
|
+
* Whether this iOS app holds spending keys (keychain role `holder` or
|
|
810
|
+
* `wallet`), so a public key it uses for money must be derived from them.
|
|
811
|
+
* Throws while the keychain cannot say. @internal
|
|
812
|
+
*/
|
|
813
|
+
static async _holdsSpendingKeys() {
|
|
814
|
+
const access = await resolveKeychainAccess();
|
|
815
|
+
return access.mode === 'grouped' && access.role !== 'member';
|
|
816
|
+
}
|
|
817
|
+
/**
|
|
818
|
+
* THE public key of the identity a money holder (the Peable wallet, Commons)
|
|
819
|
+
* receives and spends with: the personal key, or with `opts.accountId` that
|
|
820
|
+
* organization's (ADR 0061 D6). It is the public half of exactly the private
|
|
821
|
+
* key {@link deriveScopedSeed} and {@link signSocialReceive} use, so an
|
|
822
|
+
* address shown from it is one this app can spend (R5-1).
|
|
823
|
+
*
|
|
824
|
+
* - iOS (Commons, the Peable wallet): DERIVED from the private key in the
|
|
825
|
+
* restricted keychain groups (personal: the wallet group's slot, then
|
|
826
|
+
* Commons' primary; organization: its pair). Never the shared group's
|
|
827
|
+
* public-key item, which every Oxy app can write. `null` when no private
|
|
828
|
+
* key is held, and in every other iOS app.
|
|
829
|
+
* - Android: inside Commons derived from its own key; elsewhere Commons'
|
|
830
|
+
* identity host (`describe`), which answers from Commons' own signer
|
|
831
|
+
* store over signature-protected IPC.
|
|
832
|
+
* - Web: `null`.
|
|
833
|
+
*
|
|
834
|
+
* Compare the answer with the account's published `#key-1` before showing a
|
|
835
|
+
* receive address (Peable ADR 0002). Lowercase uncompressed (`04…`).
|
|
836
|
+
*/
|
|
837
|
+
static async getIdentityPublicKeyForMoney(opts) {
|
|
838
|
+
if (isWebPlatform()) {
|
|
839
|
+
return null;
|
|
840
|
+
}
|
|
841
|
+
const accountId = opts?.accountId;
|
|
842
|
+
if (accountId !== undefined) {
|
|
843
|
+
if (!isOrganizationAccountId(accountId))
|
|
844
|
+
return null;
|
|
845
|
+
if (isAndroid() && !KeyManager.identitySignerStore) {
|
|
846
|
+
const bridge = await loadCommonsIdentityBridge();
|
|
847
|
+
const description = (await bridge?.describe(accountId)) ?? null;
|
|
848
|
+
return description?.accountId === accountId && description.publicKey
|
|
849
|
+
? KeyManager.canonicalPublicKey(description.publicKey)
|
|
850
|
+
: null;
|
|
851
|
+
}
|
|
852
|
+
const organizationKey = await getOrganizationPrivateKey(accountId);
|
|
853
|
+
return organizationKey ? KeyManager.derivePublicKey(organizationKey) : null;
|
|
854
|
+
}
|
|
855
|
+
if (isAndroid()) {
|
|
856
|
+
if (KeyManager.identitySignerStore) {
|
|
857
|
+
const local = await KeyManager.getPrivateKey();
|
|
858
|
+
return local ? KeyManager.derivePublicKey(local) : null;
|
|
859
|
+
}
|
|
860
|
+
const bridge = await loadCommonsIdentityBridge();
|
|
861
|
+
const publicKey = (await bridge?.describe())?.publicKey ?? null;
|
|
862
|
+
return publicKey ? KeyManager.canonicalPublicKey(publicKey) : null;
|
|
863
|
+
}
|
|
864
|
+
if (!isIOS()) {
|
|
865
|
+
return null;
|
|
866
|
+
}
|
|
867
|
+
const privateKey = (await KeyManager.getSharedPrivateKey()) ?? (await KeyManager.getPrivateKey());
|
|
868
|
+
return privateKey ? KeyManager.derivePublicKey(privateKey) : null;
|
|
869
|
+
}
|
|
790
870
|
/**
|
|
791
871
|
* The shared private key, where this app may hold it: iOS only.
|
|
792
872
|
*
|
|
@@ -2050,18 +2130,26 @@ export class KeyManager {
|
|
|
2050
2130
|
await KeyManager._clearSharedSlot(store);
|
|
2051
2131
|
// The retired ring is NOT cleared (R4-4): it is money under the person's
|
|
2052
2132
|
// previous keys, and "Start over" or a forced delete must not cost it.
|
|
2053
|
-
//
|
|
2054
|
-
//
|
|
2055
|
-
//
|
|
2056
|
-
// clears it, explicitly (`forgetRetiredIdentityKeys
|
|
2133
|
+
// Every personal entry is bound to its owner's account id (R5-2), so the
|
|
2134
|
+
// same person signing in again finds it, and a different person on this
|
|
2135
|
+
// phone never lists, derives from or signs with it. Only deleting the Oxy
|
|
2136
|
+
// ACCOUNT clears it, explicitly (`forgetRetiredIdentityKeys`).
|
|
2057
2137
|
}
|
|
2058
2138
|
// The device backup goes on EVERY delete, forced or not: it is restored
|
|
2059
2139
|
// silently, so leaving it would bring a deleted identity back on the next
|
|
2060
2140
|
// launch. Best-effort, like the other recovery sources.
|
|
2061
2141
|
await KeyManager._clearDeviceBackup();
|
|
2062
2142
|
// Same for the Android signer store: Commons' identity host must stop
|
|
2063
|
-
// answering for a deleted identity, forced or not
|
|
2143
|
+
// answering for a deleted identity, forced or not — its personal retired
|
|
2144
|
+
// keys included (R5-2): they stay in Commons' own ring, bound to their
|
|
2145
|
+
// owner, but the host must not hand them to the next person on the phone.
|
|
2064
2146
|
await KeyManager._clearIdentitySigner();
|
|
2147
|
+
try {
|
|
2148
|
+
await unmirrorPersonalRetiredKeys();
|
|
2149
|
+
}
|
|
2150
|
+
catch (error) {
|
|
2151
|
+
logger.warn('Failed to withdraw the personal retired keys from the identity host', { component: 'KeyManager' }, error);
|
|
2152
|
+
}
|
|
2065
2153
|
// Clear the marker AFTER key deletion succeeds — a marker must never outlive
|
|
2066
2154
|
// its identity (a leftover marker would route a truly-absent device to
|
|
2067
2155
|
// `recovery` instead of `welcome`).
|
|
@@ -2590,11 +2678,16 @@ export class KeyManager {
|
|
|
2590
2678
|
* (personal, or `accountId`'s) whose public key it names: the current key
|
|
2591
2679
|
* when it matches, else a RETIRED key Commons kept after a rotation or
|
|
2592
2680
|
* replacement (`./retiredKeys`, ADR 0061 D7 as amended). Spending only: a
|
|
2593
|
-
* retired key never signs in. Never falls back to another key.
|
|
2681
|
+
* retired key never signs in. Never falls back to another key. A PERSONAL
|
|
2682
|
+
* retired key is reached only with `opts.ownerAccountId`, the signed-in
|
|
2683
|
+
* person's account id, and only when the ring keeps it for that account
|
|
2684
|
+
* (R5-2); on Android outside Commons the identity host holds only the
|
|
2685
|
+
* personal keys of the person Commons is signed in as.
|
|
2594
2686
|
*
|
|
2595
2687
|
* @param info Context/domain-binding label (distinct labels → independent seeds).
|
|
2596
2688
|
* @param opts.accountId An organization's account id, for its key.
|
|
2597
2689
|
* @param opts.publicKey Selects one key of that account by its public key.
|
|
2690
|
+
* @param opts.ownerAccountId The person's account id, for a personal retired key.
|
|
2598
2691
|
* @returns 32 bytes of derived keying material, or `null` on web / when no
|
|
2599
2692
|
* identity key is available to this app.
|
|
2600
2693
|
*/
|
|
@@ -2611,7 +2704,7 @@ export class KeyManager {
|
|
|
2611
2704
|
const seed = (await bridge?.deriveRetiredScopedSeed?.(info, selected, opts.accountId)) ?? null;
|
|
2612
2705
|
return seed ? hexToBytes(seed) : null;
|
|
2613
2706
|
}
|
|
2614
|
-
const selected = await KeyManager._selectedPrivateKey(opts
|
|
2707
|
+
const selected = await KeyManager._selectedPrivateKey({ ...opts, publicKey: opts.publicKey });
|
|
2615
2708
|
return selected ? deriveScopedSeedFromKey(selected, info) : null;
|
|
2616
2709
|
}
|
|
2617
2710
|
if (opts?.accountId !== undefined) {
|
|
@@ -2679,7 +2772,7 @@ export class KeyManager {
|
|
|
2679
2772
|
return ((await bridge?.signRetiredSocialReceive?.(index, digest, selected, opts.accountId)) ??
|
|
2680
2773
|
null);
|
|
2681
2774
|
}
|
|
2682
|
-
const selected = await KeyManager._selectedPrivateKey(opts
|
|
2775
|
+
const selected = await KeyManager._selectedPrivateKey({ ...opts, publicKey: opts.publicKey });
|
|
2683
2776
|
return selected ? signSocialReceiveDigest(selected, index, digest) : null;
|
|
2684
2777
|
}
|
|
2685
2778
|
const accountId = opts?.accountId;
|
|
@@ -2718,7 +2811,8 @@ export class KeyManager {
|
|
|
2718
2811
|
static _selectorKey(publicKey) {
|
|
2719
2812
|
return KeyManager.isValidPublicKey(publicKey) ? KeyManager.canonicalPublicKey(publicKey) : null;
|
|
2720
2813
|
}
|
|
2721
|
-
static async _selectedPrivateKey(
|
|
2814
|
+
static async _selectedPrivateKey(opts) {
|
|
2815
|
+
const { publicKey, accountId, ownerAccountId } = opts;
|
|
2722
2816
|
if (retiredKeyScope({ accountId }) === null || !KeyManager.isValidPublicKey(publicKey)) {
|
|
2723
2817
|
return null;
|
|
2724
2818
|
}
|
|
@@ -2731,7 +2825,11 @@ export class KeyManager {
|
|
|
2731
2825
|
if (current && KeyManager.canonicalPublicKey(KeyManager.derivePublicKey(current)) === wanted) {
|
|
2732
2826
|
return current;
|
|
2733
2827
|
}
|
|
2734
|
-
return getRetiredPrivateKey(wanted, accountId !== undefined
|
|
2828
|
+
return getRetiredPrivateKey(wanted, accountId !== undefined
|
|
2829
|
+
? { accountId }
|
|
2830
|
+
: ownerAccountId !== undefined
|
|
2831
|
+
? { ownerAccountId }
|
|
2832
|
+
: undefined);
|
|
2735
2833
|
}
|
|
2736
2834
|
/**
|
|
2737
2835
|
* Get a shortened version of the public key for display
|
|
@@ -51,6 +51,13 @@ import { isAndroid, isIOS, isWeb } from '../utils/platform.js';
|
|
|
51
51
|
import { registeredIdentitySignerStore } from './identitySigner.js';
|
|
52
52
|
import { isOrganizationAccountId } from './keychainItems.js';
|
|
53
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';
|
|
@@ -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,6 +190,7 @@ 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 };
|
|
@@ -181,19 +215,29 @@ async function assertMayWrite() {
|
|
|
181
215
|
}
|
|
182
216
|
}
|
|
183
217
|
/**
|
|
184
|
-
* Keep `key` in the ring of `selector`'s account. Commons only.
|
|
185
|
-
*
|
|
186
|
-
*
|
|
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
|
|
187
225
|
* (Android: the signer store's read-back too), and throws otherwise, so a
|
|
188
226
|
* caller about to replace the key can stop before anything is lost.
|
|
189
227
|
*/
|
|
190
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
|
+
}
|
|
191
233
|
await keepRetired(key, selector, true);
|
|
192
234
|
}
|
|
193
235
|
/**
|
|
194
236
|
* {@link retireIdentityKey} from inside the iOS access-group migration, which
|
|
195
|
-
* keeps a
|
|
196
|
-
*
|
|
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
|
|
197
241
|
*/
|
|
198
242
|
export async function retireIdentityKeyDuringMigration(key, selector) {
|
|
199
243
|
await keepRetired(key, selector, false);
|
|
@@ -202,6 +246,7 @@ async function keepRetired(key, selector, gate) {
|
|
|
202
246
|
const scope = retiredKeyScope(selector);
|
|
203
247
|
if (!scope)
|
|
204
248
|
throw new Error('retired key: accountId must be a 24-hex or uuid account id');
|
|
249
|
+
const owner = scope === PERSONAL_SCOPE ? ownerOf(selector?.ownerAccountId) : null;
|
|
205
250
|
await assertMayWrite();
|
|
206
251
|
if (!isHealthyPair(key.privateKey, key.publicKey)) {
|
|
207
252
|
throw new Error('retired key: the public key does not derive from the private key');
|
|
@@ -209,51 +254,114 @@ async function keepRetired(key, selector, gate) {
|
|
|
209
254
|
const privateKey = normalizeSecp256k1PrivateKey(key.privateKey);
|
|
210
255
|
const publicKey = normalizeSecp256k1PublicKey(key.publicKey);
|
|
211
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
|
+
}
|
|
212
263
|
await store.setItemAsync(privateKeyName(scope, publicKey), privateKey, privateOptions(store, true));
|
|
213
264
|
const readBack = await store.getItemAsync(privateKeyName(scope, publicKey), privateOptions(store, false));
|
|
214
265
|
if (readBack !== privateKey) {
|
|
215
266
|
throw new Error('retired key: the keychain did not confirm the key');
|
|
216
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.
|
|
217
284
|
const signer = isAndroid() ? registeredIdentitySignerStore() : null;
|
|
218
|
-
if (signer?.writeRetired &&
|
|
285
|
+
if (signer?.writeRetired &&
|
|
286
|
+
(scope !== PERSONAL_SCOPE || owner !== null) &&
|
|
287
|
+
!(await signer.writeRetired(scope, privateKey, publicKey))) {
|
|
219
288
|
throw new Error(`The identity signer store (${signer.name}) did not confirm the retired key.`);
|
|
220
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();
|
|
221
309
|
const index = await readIndex(store);
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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;
|
|
225
333
|
}
|
|
226
334
|
/**
|
|
227
335
|
* The retired keys of `selector`'s account this app can reach, oldest first.
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
*
|
|
231
|
-
*
|
|
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.
|
|
232
346
|
*/
|
|
233
347
|
export async function listRetiredIdentityKeys(selector) {
|
|
234
348
|
const scope = retiredKeyScope(selector);
|
|
235
349
|
if (!scope || isWeb())
|
|
236
350
|
return [];
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
const bridge = await loadCommonsIdentityBridge();
|
|
240
|
-
const keys = (await bridge?.listRetiredKeys?.(selector?.accountId)) ?? null;
|
|
241
|
-
return (keys ?? []).map((publicKey) => scope === PERSONAL_SCOPE
|
|
242
|
-
? { publicKey, retiredAt: 0 }
|
|
243
|
-
: { publicKey, accountId: scope, retiredAt: 0 });
|
|
244
|
-
}
|
|
245
|
-
if (!isIOS() && !isAndroid())
|
|
246
|
-
return [];
|
|
247
|
-
const store = await ringStore();
|
|
248
|
-
return (await readIndex(store)).filter((entry) => entry.scope === scope).map(toListed);
|
|
249
|
-
}
|
|
250
|
-
catch (error) {
|
|
251
|
-
logger.warn('Failed to list retired keys', {
|
|
252
|
-
component: 'RetiredKeys',
|
|
253
|
-
error: error instanceof Error ? error.name : 'unknown',
|
|
254
|
-
});
|
|
351
|
+
const owner = ownerOf(selector?.ownerAccountId);
|
|
352
|
+
if (scope === PERSONAL_SCOPE && owner === null)
|
|
255
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 });
|
|
256
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);
|
|
257
365
|
}
|
|
258
366
|
/**
|
|
259
367
|
* The private half of a retired key, where this app may hold it: Commons (both
|
|
@@ -267,12 +375,18 @@ export async function getRetiredPrivateKey(publicKey, selector) {
|
|
|
267
375
|
const canonical = canonicalPublicKey(publicKey);
|
|
268
376
|
if (!scope || !canonical || isWeb())
|
|
269
377
|
return null;
|
|
378
|
+
const owner = ownerOf(selector?.ownerAccountId);
|
|
379
|
+
if (scope === PERSONAL_SCOPE && owner === null)
|
|
380
|
+
return null;
|
|
270
381
|
try {
|
|
271
382
|
if (isAndroid()) {
|
|
272
383
|
const signer = registeredIdentitySignerStore();
|
|
273
384
|
if (!signer)
|
|
274
385
|
return null;
|
|
275
386
|
const store = await ringStore();
|
|
387
|
+
if (!(await readIndex(store)).some((entry) => isVisible(entry, scope, owner) && entry.publicKey === canonical)) {
|
|
388
|
+
return null;
|
|
389
|
+
}
|
|
276
390
|
const local = await store.getItemAsync(privateKeyName(scope, canonical), privateOptions(store, false));
|
|
277
391
|
if (local && isHealthyPair(local, canonical))
|
|
278
392
|
return normalizeSecp256k1PrivateKey(local);
|
|
@@ -285,6 +399,11 @@ export async function getRetiredPrivateKey(publicKey, selector) {
|
|
|
285
399
|
if (!isIOS())
|
|
286
400
|
return null;
|
|
287
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
|
+
}
|
|
288
407
|
const value = await store.getItemAsync(privateKeyName(scope, canonical), privateOptions(store, false));
|
|
289
408
|
return value && isHealthyPair(value, canonical) ? normalizeSecp256k1PrivateKey(value) : null;
|
|
290
409
|
}
|
|
@@ -306,8 +425,12 @@ export async function forgetRetiredIdentityKey(publicKey, selector) {
|
|
|
306
425
|
const canonical = canonicalPublicKey(publicKey);
|
|
307
426
|
if (!scope || !canonical)
|
|
308
427
|
throw new Error('retired key: invalid selector or public key');
|
|
428
|
+
const owner = ownerOf(selector?.ownerAccountId);
|
|
309
429
|
await assertMayWrite();
|
|
310
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');
|
|
311
434
|
await store.deleteItemAsync(privateKeyName(scope, canonical), privateOptions(store, false));
|
|
312
435
|
const signer = isAndroid() ? registeredIdentitySignerStore() : null;
|
|
313
436
|
await signer?.clearRetired?.(scope, canonical);
|
|
@@ -327,33 +450,47 @@ export async function forgetRetiredIdentityKeys(selector) {
|
|
|
327
450
|
return 0;
|
|
328
451
|
if (isAndroid() && !registeredIdentitySignerStore())
|
|
329
452
|
return 0;
|
|
453
|
+
const owner = ownerOf(selector?.ownerAccountId);
|
|
454
|
+
if (scope === PERSONAL_SCOPE && owner === null)
|
|
455
|
+
return 0;
|
|
330
456
|
const store = await ringStore();
|
|
331
457
|
const index = await readIndex(store);
|
|
332
458
|
const signer = isAndroid() ? registeredIdentitySignerStore() : null;
|
|
333
459
|
let removed = 0;
|
|
334
460
|
for (const entry of index) {
|
|
335
|
-
if (entry
|
|
461
|
+
if (!isVisible(entry, scope, owner))
|
|
336
462
|
continue;
|
|
337
463
|
await store.deleteItemAsync(privateKeyName(scope, entry.publicKey), privateOptions(store, false));
|
|
338
464
|
await signer?.clearRetired?.(scope, entry.publicKey);
|
|
339
465
|
removed += 1;
|
|
340
466
|
}
|
|
341
|
-
await writeIndex(store, index.filter((entry) => entry
|
|
467
|
+
await writeIndex(store, index.filter((entry) => !isVisible(entry, scope, owner)));
|
|
342
468
|
return removed;
|
|
343
469
|
}
|
|
344
470
|
/**
|
|
345
|
-
* Commons on Android:
|
|
346
|
-
*
|
|
347
|
-
*
|
|
348
|
-
*
|
|
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.
|
|
349
480
|
*/
|
|
350
|
-
export async function syncRetiredKeys() {
|
|
481
|
+
export async function syncRetiredKeys(ownerAccountId) {
|
|
351
482
|
const signer = isAndroid() ? registeredIdentitySignerStore() : null;
|
|
352
483
|
if (!signer?.writeRetired || !signer.readRetired)
|
|
353
484
|
return 0;
|
|
485
|
+
const owner = ownerOf(ownerAccountId);
|
|
354
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);
|
|
355
490
|
let mirrored = 0;
|
|
356
|
-
for (const entry of
|
|
491
|
+
for (const entry of index) {
|
|
492
|
+
if (entry.scope === PERSONAL_SCOPE && !isVisible(entry, PERSONAL_SCOPE, owner))
|
|
493
|
+
continue;
|
|
357
494
|
const privateKey = await store.getItemAsync(privateKeyName(entry.scope, entry.publicKey), privateOptions(store, false));
|
|
358
495
|
if (!privateKey || !isHealthyPair(privateKey, entry.publicKey))
|
|
359
496
|
continue;
|
|
@@ -367,12 +504,14 @@ export async function syncRetiredKeys() {
|
|
|
367
504
|
return mirrored;
|
|
368
505
|
}
|
|
369
506
|
/**
|
|
370
|
-
* Every retired key of `selector`'s ring
|
|
371
|
-
*
|
|
372
|
-
* under the CURRENT phrase
|
|
373
|
-
* phrase does (R4-4). Commons
|
|
374
|
-
*
|
|
375
|
-
*
|
|
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.
|
|
376
515
|
*/
|
|
377
516
|
export async function exportRetiredIdentityKeys(selector) {
|
|
378
517
|
const scope = retiredKeyScope(selector);
|
|
@@ -381,12 +520,57 @@ export async function exportRetiredIdentityKeys(selector) {
|
|
|
381
520
|
await assertMayWrite();
|
|
382
521
|
const out = [];
|
|
383
522
|
for (const entry of await listRetiredIdentityKeys(selector)) {
|
|
384
|
-
const privateKey = await
|
|
385
|
-
|
|
386
|
-
out.push({ privateKey, publicKey: entry.publicKey, retiredAt: entry.retiredAt });
|
|
523
|
+
const privateKey = await readRetiredStrict(entry.publicKey, scope);
|
|
524
|
+
out.push({ privateKey, publicKey: entry.publicKey, retiredAt: entry.retiredAt });
|
|
387
525
|
}
|
|
388
526
|
return out;
|
|
389
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
|
+
}
|
|
390
574
|
/**
|
|
391
575
|
* The pair a previous key's raw PRIVATE KEY gives (64 hex, an optional `0x`),
|
|
392
576
|
* for a person whose old identity was imported as a private key and so has no
|
|
@@ -23,7 +23,7 @@ export { KEYCHAIN_ACCESS_GROUPS, KeychainAccessUnavailableError, keychainGroupFo
|
|
|
23
23
|
export type { KeychainAccess, KeychainAccessGroupName, KeychainGroup, KeychainGroupOption, KeychainItemClass, KeychainRole, KeychainStoreLike, OwnKeychainItemNames, } from '../utils/keychainAccessGroups';
|
|
24
24
|
export { ensureKeychainGroupsMigrated, getPreSplitExposure, } from './keychainGroupMigration';
|
|
25
25
|
export type { KeychainGroupMigrationReport, KeychainItemMigrationOutcome, } from './keychainGroupMigration';
|
|
26
|
-
export { retireIdentityKey, listRetiredIdentityKeys, forgetRetiredIdentityKey, forgetRetiredIdentityKeys, syncRetiredKeys, retiredKeyFromPhrase, retiredKeyFromPrivateKey, exportRetiredIdentityKeys, } from './retiredKeys';
|
|
26
|
+
export { retireIdentityKey, listRetiredIdentityKeys, forgetRetiredIdentityKey, forgetRetiredIdentityKeys, syncRetiredKeys, retiredKeyFromPhrase, retiredKeyFromPrivateKey, exportRetiredIdentityKeys, claimRetiredIdentityKeys, countQuarantinedRetiredKeys, unmirrorPersonalRetiredKeys, RetiredKeyRingUnreadableError, } from './retiredKeys';
|
|
27
27
|
export type { RetiredIdentityKey, RetiredKeyPair, RetiredKeySelector } from './retiredKeys';
|
|
28
28
|
export { SignatureService } from './signatureService';
|
|
29
29
|
export type { SignedMessage, AuthChallenge } from './signatureService';
|