@oxy.so/core 7.28.0 → 7.30.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 +40 -1
- package/dist/cjs/crypto/index.js +28 -1
- package/dist/cjs/crypto/keyManager.js +175 -107
- package/dist/cjs/crypto/keychainGroupMigration.js +353 -0
- package/dist/cjs/crypto/keychainItems.js +70 -0
- package/dist/cjs/crypto/organizationKeys.js +80 -72
- package/dist/cjs/crypto/retiredKeys.js +371 -0
- package/dist/cjs/crypto/secureStoreOptions.js +2 -0
- package/dist/cjs/session/index.js +11 -1
- package/dist/cjs/utils/keychainAccessGroups.js +248 -0
- package/dist/esm/api/identity.js +40 -1
- package/dist/esm/crypto/index.js +6 -0
- package/dist/esm/crypto/keyManager.js +132 -64
- package/dist/esm/crypto/keychainGroupMigration.js +348 -0
- package/dist/esm/crypto/keychainItems.js +61 -0
- package/dist/esm/crypto/organizationKeys.js +54 -46
- package/dist/esm/crypto/retiredKeys.js +361 -0
- package/dist/esm/crypto/secureStoreOptions.js +1 -0
- package/dist/esm/session/index.js +3 -0
- package/dist/esm/utils/keychainAccessGroups.js +231 -0
- package/dist/types/api/identity.d.ts +10 -0
- package/dist/types/crypto/identitySigner.d.ts +23 -0
- package/dist/types/crypto/index.d.ts +6 -0
- package/dist/types/crypto/keyManager.d.ts +32 -2
- package/dist/types/crypto/keychainGroupMigration.d.ts +27 -0
- package/dist/types/crypto/keychainItems.d.ts +41 -0
- package/dist/types/crypto/organizationKeys.d.ts +11 -9
- package/dist/types/crypto/retiredKeys.d.ts +116 -0
- package/dist/types/crypto/secureStoreOptions.d.ts +15 -0
- package/dist/types/session/index.d.ts +2 -0
- package/dist/types/utils/keychainAccessGroups.d.ts +104 -0
- package/package.json +3 -3
- package/src/api/identity.ts +46 -3
- package/src/crypto/identitySigner.ts +23 -0
- package/src/crypto/index.ts +40 -0
- package/src/crypto/keyManager.ts +165 -102
- package/src/crypto/keychainGroupMigration.ts +440 -0
- package/src/crypto/keychainItems.ts +73 -0
- package/src/crypto/organizationKeys.ts +72 -57
- package/src/crypto/retiredKeys.ts +453 -0
- package/src/crypto/secureStoreOptions.ts +32 -0
- package/src/session/index.ts +11 -0
- package/src/utils/keychainAccessGroups.ts +308 -0
- package/src/types/expo-secure-store.d.ts +0 -21
package/dist/esm/api/identity.js
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
* AEAD, signatures) load on first use, never with the client itself.
|
|
19
19
|
*/
|
|
20
20
|
import { checkContract, IDENTITY_PROOF_ACTIONS, identityLinkCreateResponseCheck, identityLinkStateCheck, } from '@oxy.so/contracts/client';
|
|
21
|
+
import { identityKeyHistoryCheck } from '@oxy.so/contracts/client';
|
|
21
22
|
import { isAndroid, isWeb } from '../utils/platform.js';
|
|
22
23
|
import { logger } from '../logger/index.js';
|
|
23
24
|
/** Short-TTL read cache for public identity reads. */
|
|
@@ -158,7 +159,7 @@ export class IdentityApi {
|
|
|
158
159
|
if (!userId) {
|
|
159
160
|
throw new Error('No authenticated user — sign in before rotating your key.');
|
|
160
161
|
}
|
|
161
|
-
const { KeyManager, RecoveryPhraseService, SignatureService, signMessage } = await import('../crypto/index.js');
|
|
162
|
+
const { KeyManager, RecoveryPhraseService, SignatureService, signMessage, retireIdentityKey } = await import('../crypto/index.js');
|
|
162
163
|
// 1. The NEW identity (in memory only). The UI may pre-derive + pre-show
|
|
163
164
|
// it and pass it back here so the phrase shown === the phrase committed.
|
|
164
165
|
const pending = options.pendingIdentity ?? (await RecoveryPhraseService.derivePendingIdentity());
|
|
@@ -166,6 +167,7 @@ export class IdentityApi {
|
|
|
166
167
|
// 2. Resolve the OLD signing capability from the chosen proof mode.
|
|
167
168
|
let oldPublicKey;
|
|
168
169
|
let signWithOldKey;
|
|
170
|
+
let oldPrivateKeyForRing;
|
|
169
171
|
if (options.proof === 'phrase') {
|
|
170
172
|
const phrase = options.phrase?.trim();
|
|
171
173
|
if (!phrase) {
|
|
@@ -174,6 +176,7 @@ export class IdentityApi {
|
|
|
174
176
|
const oldPrivateKey = await RecoveryPhraseService.derivePrivateKeyFromPhrase(phrase);
|
|
175
177
|
oldPublicKey = KeyManager.derivePublicKey(oldPrivateKey);
|
|
176
178
|
signWithOldKey = (message) => signMessage(message, oldPrivateKey);
|
|
179
|
+
oldPrivateKeyForRing = oldPrivateKey;
|
|
177
180
|
}
|
|
178
181
|
else {
|
|
179
182
|
const currentPublicKey = await KeyManager.getPublicKey();
|
|
@@ -182,6 +185,25 @@ export class IdentityApi {
|
|
|
182
185
|
}
|
|
183
186
|
oldPublicKey = currentPublicKey;
|
|
184
187
|
signWithOldKey = (message) => SignatureService.sign(message);
|
|
188
|
+
oldPrivateKeyForRing = isWeb() ? null : await KeyManager.getPrivateKey();
|
|
189
|
+
}
|
|
190
|
+
// 2b. Keep the key being replaced in the retired ring BEFORE anything
|
|
191
|
+
// changes (oxy ADR 0061 D7 as amended, Peable ADR 0002): coins keep
|
|
192
|
+
// arriving under it after the swap, and the Peable wallet spends them
|
|
193
|
+
// through the `publicKey` selector of `deriveScopedSeed` /
|
|
194
|
+
// `signSocialReceive`. A device that cannot keep it does not rotate:
|
|
195
|
+
// the phrase would become the only way to those coins. Native only —
|
|
196
|
+
// on web no key is stored. Idempotent, so a retried rotation is fine.
|
|
197
|
+
if (!isWeb()) {
|
|
198
|
+
try {
|
|
199
|
+
if (!oldPrivateKeyForRing)
|
|
200
|
+
throw new Error('no readable private key');
|
|
201
|
+
await retireIdentityKey({ privateKey: oldPrivateKeyForRing, publicKey: oldPublicKey });
|
|
202
|
+
}
|
|
203
|
+
catch (retainError) {
|
|
204
|
+
logger.warn('Could not keep the current key as a retired key; not rotating.', { component: 'oxy.identity', method: 'rotateKey' }, retainError);
|
|
205
|
+
throw new Error('Your current key could not be kept on this device, so it was not rotated. Nothing changed.');
|
|
206
|
+
}
|
|
185
207
|
}
|
|
186
208
|
// 3. Request a single-use rotate_key challenge (bearer).
|
|
187
209
|
const { challenge } = await this.ctx.request('POST', '/auth/rotate/challenge', undefined, {
|
|
@@ -338,6 +360,23 @@ export class IdentityApi {
|
|
|
338
360
|
keys.push(`GET:/u/${encodeURIComponent(userId)}/did.json`);
|
|
339
361
|
this.ctx.http.invalidateCache({ keys, prefixes: ['GET:/users/me'] });
|
|
340
362
|
}
|
|
363
|
+
/**
|
|
364
|
+
* The signed-in personal account's current root (`null` without one) and
|
|
365
|
+
* every root a rotation replaced, oldest first, all canonical (uncompressed,
|
|
366
|
+
* lowercase). Commons reads it to accept a previous recovery phrase only when
|
|
367
|
+
* it derives a key that WAS this account's root. A retired root is never any
|
|
368
|
+
* account's again. First-party only; a non-personal account is refused
|
|
369
|
+
* (`IDENTITY_NOT_PERSONAL_ACCOUNT`).
|
|
370
|
+
*/
|
|
371
|
+
async keyHistory() {
|
|
372
|
+
const res = await this.ctx.request('GET', '/identity/key-history', undefined, {
|
|
373
|
+
cache: false,
|
|
374
|
+
});
|
|
375
|
+
const parsed = checkContract(identityKeyHistoryCheck, res);
|
|
376
|
+
if (!parsed)
|
|
377
|
+
throw new Error('identity/key-history returned an unexpected response shape');
|
|
378
|
+
return parsed;
|
|
379
|
+
}
|
|
341
380
|
}
|
|
342
381
|
/** `oxy.identity.links` — joining Commons' root to an account (ADR 0029 D3). */
|
|
343
382
|
export class IdentityLinksApi {
|
package/dist/esm/crypto/index.js
CHANGED
|
@@ -17,6 +17,12 @@ export { KeyManager, IdentityAlreadyExistsError, IdentityPersistError, IdentityU
|
|
|
17
17
|
export { readIdentityMarker, updateIdentityMarker } from './identityMarker.js';
|
|
18
18
|
// Organization keys on the device (docs/adr/0061-organization-identity-key.md D6)
|
|
19
19
|
export { generateOrganizationKey, organizationKeyFromPhrase, storeOrganizationKey, getOrganizationPublicKey, getOrganizationPrivateKey, getOrganizationRecoveryPhrase, listOrganizationKeys, deleteOrganizationKey, syncOrganizationKeys, isOrganizationAccountId, isHealthyOrganizationKeyPair, } from './organizationKeys.js';
|
|
20
|
+
// iOS keychain access groups (docs/identity/ios-keychain-access-groups.md, ADR 0062)
|
|
21
|
+
export { KEYCHAIN_ACCESS_GROUPS, KeychainAccessUnavailableError, keychainGroupFor, mayHoldKeychainItem, resolveKeychainAccess, keychainAccessGroupValue, keychainReadPlan, keychainDeletePlan, keychainWriteOption, ownKeychainItemPlan, readThroughPlan, deleteThroughPlan, groupedKeychainStore, } from '../utils/keychainAccessGroups.js';
|
|
22
|
+
export { ensureKeychainGroupsMigrated } from './keychainGroupMigration.js';
|
|
23
|
+
// Retired identity keys: kept for spending after a rotation or replacement
|
|
24
|
+
// (docs/adr/0061-organization-identity-key.md D7 as amended 2026-11-06)
|
|
25
|
+
export { retireIdentityKey, listRetiredIdentityKeys, forgetRetiredIdentityKey, forgetRetiredIdentityKeys, syncRetiredKeys, retiredKeyFromPhrase, } from './retiredKeys.js';
|
|
20
26
|
export { SignatureService } from './signatureService.js';
|
|
21
27
|
export { RecoveryPhraseService, BACKUP_KDF_ENCRYPTION_INFO } from './recoveryPhrase.js';
|
|
22
28
|
// Low-level primitives (encrypted backup, device transfer)
|
|
@@ -11,6 +11,10 @@ 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 { forgetRetiredIdentityKeys, getRetiredPrivateKey, retiredKeyScope } from './retiredKeys.js';
|
|
15
|
+
import { groupedKeychainStore, resolveKeychainAccess, } from '../utils/keychainAccessGroups.js';
|
|
16
|
+
import { ensureKeychainGroupsMigrated } from './keychainGroupMigration.js';
|
|
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';
|
|
14
18
|
import { clearIdentityMarker, readIdentityMarker, updateIdentityMarker, writeIdentityMarker, } from './identityMarker.js';
|
|
15
19
|
import { DEVICE_BACKUP_VERSION, parseDeviceBackup, serializeDeviceBackup, } from './deviceBackup.js';
|
|
16
20
|
/**
|
|
@@ -68,16 +72,6 @@ export class IdentityUnavailableError extends Error {
|
|
|
68
72
|
this.name = 'IdentityUnavailableError';
|
|
69
73
|
}
|
|
70
74
|
}
|
|
71
|
-
const STORAGE_KEYS = {
|
|
72
|
-
PRIVATE_KEY: 'oxy_identity_private_key',
|
|
73
|
-
PUBLIC_KEY: 'oxy_identity_public_key',
|
|
74
|
-
BACKUP_PRIVATE_KEY: 'oxy_identity_backup_private_key',
|
|
75
|
-
BACKUP_PUBLIC_KEY: 'oxy_identity_backup_public_key',
|
|
76
|
-
BACKUP_TIMESTAMP: 'oxy_identity_backup_timestamp',
|
|
77
|
-
// The cross-app identity in the iOS keychain access group (iOS only).
|
|
78
|
-
SHARED_PRIVATE_KEY: 'oxy_shared_identity_private_key',
|
|
79
|
-
SHARED_PUBLIC_KEY: 'oxy_shared_identity_public_key',
|
|
80
|
-
};
|
|
81
75
|
/**
|
|
82
76
|
* v2 identity slot layout — blast-radius isolation.
|
|
83
77
|
*
|
|
@@ -93,15 +87,8 @@ const STORAGE_KEYS = {
|
|
|
93
87
|
* Migration from the legacy layout is lazy + verify-before-delete — see
|
|
94
88
|
* {@link KeyManager._runSlotMigration}.
|
|
95
89
|
*/
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
const V2_STORAGE_KEYS = {
|
|
99
|
-
PRIVATE_KEY: 'oxy_identity_private_key_v2',
|
|
100
|
-
PUBLIC_KEY: 'oxy_identity_public_key_v2',
|
|
101
|
-
BACKUP_PRIVATE_KEY: 'oxy_identity_backup_private_key_v2',
|
|
102
|
-
BACKUP_PUBLIC_KEY: 'oxy_identity_backup_public_key_v2',
|
|
103
|
-
BACKUP_TIMESTAMP: 'oxy_identity_backup_timestamp_v2',
|
|
104
|
-
};
|
|
90
|
+
// The names (V2_PRIMARY_KEYCHAIN_SERVICE, V2_BACKUP_KEYCHAIN_SERVICE,
|
|
91
|
+
// V2_STORAGE_KEYS) live in `./keychainItems`, beside every other identity item.
|
|
105
92
|
/**
|
|
106
93
|
* Dedicated keychain slot for the recovery mnemonic (the 12-word phrase).
|
|
107
94
|
*
|
|
@@ -118,8 +105,7 @@ const V2_STORAGE_KEYS = {
|
|
|
118
105
|
* lives ONLY in this slot: it is never mirrored into the identity marker,
|
|
119
106
|
* {@link KeyManager.getIdentityStatus}, logs, or any exported bundle.
|
|
120
107
|
*/
|
|
121
|
-
|
|
122
|
-
const RECOVERY_MNEMONIC_STORAGE_KEY = 'oxy_identity_mnemonic_v1';
|
|
108
|
+
// RECOVERY_MNEMONIC_KEYCHAIN_SERVICE / RECOVERY_MNEMONIC_STORAGE_KEY: `./keychainItems`.
|
|
123
109
|
/**
|
|
124
110
|
* Advisory AsyncStorage fast-path flag: set once the v2 slots own the identity.
|
|
125
111
|
* Re-derivable (its loss just re-runs the cheap slot check), so it lives in
|
|
@@ -154,12 +140,6 @@ const LEGACY_SLOT_LAYOUT = {
|
|
|
154
140
|
backupPublicKeyName: STORAGE_KEYS.BACKUP_PUBLIC_KEY,
|
|
155
141
|
backupTimestampName: STORAGE_KEYS.BACKUP_TIMESTAMP,
|
|
156
142
|
};
|
|
157
|
-
/**
|
|
158
|
-
* iOS Keychain Access Group for sharing identities across Oxy apps
|
|
159
|
-
* All Oxy apps must have this access group enabled in their entitlements
|
|
160
|
-
* Format: [Team ID].so.oxy.shared or group.so.oxy.shared
|
|
161
|
-
*/
|
|
162
|
-
const IOS_KEYCHAIN_GROUP = 'group.so.oxy.shared';
|
|
163
143
|
/**
|
|
164
144
|
* Initialize React Native specific modules
|
|
165
145
|
*
|
|
@@ -169,7 +149,7 @@ const IOS_KEYCHAIN_GROUP = 'group.so.oxy.shared';
|
|
|
169
149
|
* `expo-secure-store`; off RN it throws (and is never called because every
|
|
170
150
|
* caller is gated by `isWebPlatform()` / native-only paths).
|
|
171
151
|
*/
|
|
172
|
-
async function
|
|
152
|
+
async function loadRawSecureStore() {
|
|
173
153
|
try {
|
|
174
154
|
return await loadSecureStore();
|
|
175
155
|
}
|
|
@@ -178,6 +158,29 @@ async function initSecureStore() {
|
|
|
178
158
|
throw new Error(`Failed to load expo-secure-store: ${errorMessage}. Make sure expo-secure-store is installed and properly configured.`);
|
|
179
159
|
}
|
|
180
160
|
}
|
|
161
|
+
/**
|
|
162
|
+
* The secure store routed to the keychain group of `item`
|
|
163
|
+
* (`../utils/keychainAccessGroups`): on iOS reads walk the item's group then
|
|
164
|
+
* the pre-split shared group, writes carry the item's group and THROW in an app
|
|
165
|
+
* that may not hold it, and in Commons every access first awaits the
|
|
166
|
+
* access-group migration. Android and web are unchanged (`ungrouped`).
|
|
167
|
+
*
|
|
168
|
+
* Throws when the store cannot load or the keychain cannot say which groups
|
|
169
|
+
* this app holds; every caller already treats a throw here as "storage
|
|
170
|
+
* unavailable".
|
|
171
|
+
*/
|
|
172
|
+
async function keychainItemStore(item) {
|
|
173
|
+
const raw = await loadRawSecureStore();
|
|
174
|
+
const access = await resolveKeychainAccess();
|
|
175
|
+
if (access.mode === 'grouped' && access.role === 'holder') {
|
|
176
|
+
await ensureKeychainGroupsMigrated();
|
|
177
|
+
}
|
|
178
|
+
return groupedKeychainStore(raw, access, item);
|
|
179
|
+
}
|
|
180
|
+
/** The store for the identity slots (primary, backup, legacy, phrase): Commons only on iOS. */
|
|
181
|
+
function initSecureStore() {
|
|
182
|
+
return keychainItemStore('identity-secret');
|
|
183
|
+
}
|
|
181
184
|
/**
|
|
182
185
|
* Check if we're on web platform
|
|
183
186
|
* Identity storage is only available on native platforms (iOS/Android)
|
|
@@ -568,12 +571,12 @@ export class KeyManager {
|
|
|
568
571
|
* rung. Best-effort — the shared slot is a redundant convenience copy. The
|
|
569
572
|
* Android signer store is cleared on every delete, in {@link deleteIdentity}.
|
|
570
573
|
*/
|
|
571
|
-
static async _clearSharedSlot(
|
|
574
|
+
static async _clearSharedSlot(_identityStore) {
|
|
572
575
|
try {
|
|
573
576
|
if (isIOS()) {
|
|
574
|
-
|
|
575
|
-
await
|
|
576
|
-
await
|
|
577
|
+
// The shared slot lives in two groups of its own, not the identity slots'.
|
|
578
|
+
await (await keychainItemStore('spending-key')).deleteItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY);
|
|
579
|
+
await (await keychainItemStore('shared')).deleteItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY);
|
|
577
580
|
}
|
|
578
581
|
KeyManager.invalidateSharedCache();
|
|
579
582
|
}
|
|
@@ -640,6 +643,20 @@ export class KeyManager {
|
|
|
640
643
|
KeyManager.cachedSharedPublicKey = null;
|
|
641
644
|
KeyManager.cachedHasSharedIdentity = null;
|
|
642
645
|
}
|
|
646
|
+
/**
|
|
647
|
+
* iOS: write the personal key's cross-app slot — the private key to the
|
|
648
|
+
* wallet group (Commons and the Peable wallet), the public key to the shared
|
|
649
|
+
* group (every Oxy app). Throws in any app that may not hold the private key.
|
|
650
|
+
* @internal
|
|
651
|
+
*/
|
|
652
|
+
static async _writeIosSharedSlot(privateKey, publicKey) {
|
|
653
|
+
const privateStore = await keychainItemStore('spending-key');
|
|
654
|
+
const privateOpts = {
|
|
655
|
+
keychainAccessible: privateStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
|
|
656
|
+
};
|
|
657
|
+
await privateStore.setItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY, privateKey, privateOpts);
|
|
658
|
+
await (await keychainItemStore('shared')).setItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY, publicKey, {});
|
|
659
|
+
}
|
|
643
660
|
/**
|
|
644
661
|
* Lowercase and pad to canonical 64-hex-char form.
|
|
645
662
|
*
|
|
@@ -675,8 +692,10 @@ export class KeyManager {
|
|
|
675
692
|
// ==================== SHARED IDENTITY METHODS ====================
|
|
676
693
|
// One Oxy identity across every Oxy app on a device.
|
|
677
694
|
//
|
|
678
|
-
// iOS:
|
|
679
|
-
//
|
|
695
|
+
// iOS: keychain access groups (docs/identity/ios-keychain-access-groups.md).
|
|
696
|
+
// The private key is in `<TEAM>.so.oxy.wallet` (Commons and the Peable
|
|
697
|
+
// wallet only), the public key in `<TEAM>.group.so.oxy.shared`, which every
|
|
698
|
+
// Oxy app reads. Every other app signs in through Commons (ADR 0029).
|
|
680
699
|
// Android: every app has its own UID, so nothing is shared through storage.
|
|
681
700
|
// Commons is the ONLY holder of the private key. It keeps the identity
|
|
682
701
|
// signer store (`setIdentitySignerStore`) for its identity host, and every
|
|
@@ -701,21 +720,11 @@ export class KeyManager {
|
|
|
701
720
|
}
|
|
702
721
|
const { privateKey, publicKey } = await KeyManager.generateKeyPair();
|
|
703
722
|
if (isIOS()) {
|
|
704
|
-
const store = await initSecureStore();
|
|
705
|
-
// Note: keychainAccessGroup requires Keychain Sharing capability in Xcode
|
|
706
723
|
try {
|
|
707
|
-
|
|
708
|
-
keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
|
|
709
|
-
keychainAccessGroup: IOS_KEYCHAIN_GROUP, // Enables sharing across apps
|
|
710
|
-
};
|
|
711
|
-
await store.setItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY, privateKey, privateOpts);
|
|
712
|
-
const publicOpts = {
|
|
713
|
-
keychainAccessGroup: IOS_KEYCHAIN_GROUP,
|
|
714
|
-
};
|
|
715
|
-
await store.setItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY, publicKey, publicOpts);
|
|
724
|
+
await KeyManager._writeIosSharedSlot(privateKey, publicKey);
|
|
716
725
|
}
|
|
717
726
|
catch (error) {
|
|
718
|
-
throw new Error(`Failed to create shared identity on iOS.
|
|
727
|
+
throw new Error(`Failed to create shared identity on iOS. Only Commons may write it, with the keychain access groups of docs/identity/ios-keychain-access-groups.md. Error: ${error}`);
|
|
719
728
|
}
|
|
720
729
|
}
|
|
721
730
|
else if (isAndroid()) {
|
|
@@ -755,9 +764,8 @@ export class KeyManager {
|
|
|
755
764
|
try {
|
|
756
765
|
let publicKey = null;
|
|
757
766
|
if (isIOS()) {
|
|
758
|
-
const store = await
|
|
759
|
-
|
|
760
|
-
publicKey = await store.getItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY, opts);
|
|
767
|
+
const store = await keychainItemStore('shared');
|
|
768
|
+
publicKey = await store.getItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY, {});
|
|
761
769
|
}
|
|
762
770
|
else if (isAndroid()) {
|
|
763
771
|
const signer = KeyManager.identitySignerStore;
|
|
@@ -797,9 +805,10 @@ export class KeyManager {
|
|
|
797
805
|
return null;
|
|
798
806
|
}
|
|
799
807
|
try {
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
808
|
+
// The wallet group: Commons and the Peable wallet. Every other app's plan
|
|
809
|
+
// is empty, so this is `null` there without touching the keychain.
|
|
810
|
+
const store = await keychainItemStore('spending-key');
|
|
811
|
+
return await store.getItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY, {});
|
|
803
812
|
}
|
|
804
813
|
catch (error) {
|
|
805
814
|
if (isDev()) {
|
|
@@ -893,16 +902,7 @@ export class KeyManager {
|
|
|
893
902
|
const canonicalPrivate = KeyManager.canonicalPrivateKey(privateKey);
|
|
894
903
|
const publicKey = deriveSecp256k1PublicKey(canonicalPrivate);
|
|
895
904
|
if (isIOS()) {
|
|
896
|
-
|
|
897
|
-
const privateOpts = {
|
|
898
|
-
keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
|
|
899
|
-
keychainAccessGroup: IOS_KEYCHAIN_GROUP,
|
|
900
|
-
};
|
|
901
|
-
await store.setItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY, canonicalPrivate, privateOpts);
|
|
902
|
-
const publicOpts = {
|
|
903
|
-
keychainAccessGroup: IOS_KEYCHAIN_GROUP,
|
|
904
|
-
};
|
|
905
|
-
await store.setItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY, publicKey, publicOpts);
|
|
905
|
+
await KeyManager._writeIosSharedSlot(canonicalPrivate, publicKey);
|
|
906
906
|
}
|
|
907
907
|
else if (isAndroid()) {
|
|
908
908
|
await KeyManager._writeIdentitySigner(canonicalPrivate, publicKey);
|
|
@@ -2050,6 +2050,13 @@ export class KeyManager {
|
|
|
2050
2050
|
if (force) {
|
|
2051
2051
|
await KeyManager._bestEffortDeleteBackupsAllGenerations(store);
|
|
2052
2052
|
await KeyManager._clearSharedSlot(store);
|
|
2053
|
+
// The person's retired keys go with the identity they belonged to.
|
|
2054
|
+
try {
|
|
2055
|
+
await forgetRetiredIdentityKeys();
|
|
2056
|
+
}
|
|
2057
|
+
catch (error) {
|
|
2058
|
+
logger.warn('Failed to clear retired keys during delete', { component: 'KeyManager' }, error);
|
|
2059
|
+
}
|
|
2053
2060
|
}
|
|
2054
2061
|
// The device backup goes on EVERY delete, forced or not: it is restored
|
|
2055
2062
|
// silently, so leaving it would bring a deleted identity back on the next
|
|
@@ -2567,7 +2574,8 @@ export class KeyManager {
|
|
|
2567
2574
|
* `HDKey.fromMasterSeed` and never touches the identity key.
|
|
2568
2575
|
*
|
|
2569
2576
|
* Key source (native only):
|
|
2570
|
-
* - iOS: the shared
|
|
2577
|
+
* - iOS: the shared slot's private key in the wallet keychain group (Commons
|
|
2578
|
+
* and the Peable wallet only; `null` in every other app), then this device's
|
|
2571
2579
|
* primary identity.
|
|
2572
2580
|
* - Android: Commons (the one app with a signer store) uses its own primary
|
|
2573
2581
|
* identity; every other app asks Commons over IPC and never reads a local
|
|
@@ -2581,8 +2589,15 @@ export class KeyManager {
|
|
|
2581
2589
|
* keychain group, or Commons' identity host. It never falls back to the
|
|
2582
2590
|
* personal key.
|
|
2583
2591
|
*
|
|
2592
|
+
* With `opts.publicKey` the seed is derived from the key of that account
|
|
2593
|
+
* (personal, or `accountId`'s) whose public key it names: the current key
|
|
2594
|
+
* when it matches, else a RETIRED key Commons kept after a rotation or
|
|
2595
|
+
* replacement (`./retiredKeys`, ADR 0061 D7 as amended). Spending only: a
|
|
2596
|
+
* retired key never signs in. Never falls back to another key.
|
|
2597
|
+
*
|
|
2584
2598
|
* @param info Context/domain-binding label (distinct labels → independent seeds).
|
|
2585
2599
|
* @param opts.accountId An organization's account id, for its key.
|
|
2600
|
+
* @param opts.publicKey Selects one key of that account by its public key.
|
|
2586
2601
|
* @returns 32 bytes of derived keying material, or `null` on web / when no
|
|
2587
2602
|
* identity key is available to this app.
|
|
2588
2603
|
*/
|
|
@@ -2590,6 +2605,18 @@ export class KeyManager {
|
|
|
2590
2605
|
if (isWebPlatform()) {
|
|
2591
2606
|
return null;
|
|
2592
2607
|
}
|
|
2608
|
+
if (opts?.publicKey !== undefined) {
|
|
2609
|
+
if (isAndroid() && !KeyManager.identitySignerStore) {
|
|
2610
|
+
const selected = KeyManager._selectorKey(opts.publicKey);
|
|
2611
|
+
if (!selected)
|
|
2612
|
+
return null;
|
|
2613
|
+
const bridge = await loadCommonsIdentityBridge();
|
|
2614
|
+
const seed = (await bridge?.deriveRetiredScopedSeed?.(info, selected, opts.accountId)) ?? null;
|
|
2615
|
+
return seed ? hexToBytes(seed) : null;
|
|
2616
|
+
}
|
|
2617
|
+
const selected = await KeyManager._selectedPrivateKey(opts.publicKey, opts.accountId);
|
|
2618
|
+
return selected ? deriveScopedSeedFromKey(selected, info) : null;
|
|
2619
|
+
}
|
|
2593
2620
|
if (opts?.accountId !== undefined) {
|
|
2594
2621
|
return KeyManager._deriveOrganizationScopedSeed(info, opts.accountId);
|
|
2595
2622
|
}
|
|
@@ -2635,7 +2662,8 @@ export class KeyManager {
|
|
|
2635
2662
|
* compressed public key for the scriptSig.
|
|
2636
2663
|
*
|
|
2637
2664
|
* Key source: as {@link deriveScopedSeed}, including `opts.accountId` for an
|
|
2638
|
-
* organization's key (ADR 0061 D6).
|
|
2665
|
+
* organization's key (ADR 0061 D6) and `opts.publicKey` for one key of the
|
|
2666
|
+
* account, current or retired. On Android outside Commons the signature
|
|
2639
2667
|
* comes from Commons, which allows it for the wallet app only.
|
|
2640
2668
|
*
|
|
2641
2669
|
* @returns the signature, or `null` on web / when no identity key is
|
|
@@ -2645,6 +2673,18 @@ export class KeyManager {
|
|
|
2645
2673
|
if (isWebPlatform()) {
|
|
2646
2674
|
return null;
|
|
2647
2675
|
}
|
|
2676
|
+
if (opts?.publicKey !== undefined) {
|
|
2677
|
+
if (isAndroid() && !KeyManager.identitySignerStore) {
|
|
2678
|
+
const selected = KeyManager._selectorKey(opts.publicKey);
|
|
2679
|
+
if (!selected)
|
|
2680
|
+
return null;
|
|
2681
|
+
const bridge = await loadCommonsIdentityBridge();
|
|
2682
|
+
return ((await bridge?.signRetiredSocialReceive?.(index, digest, selected, opts.accountId)) ??
|
|
2683
|
+
null);
|
|
2684
|
+
}
|
|
2685
|
+
const selected = await KeyManager._selectedPrivateKey(opts.publicKey, opts.accountId);
|
|
2686
|
+
return selected ? signSocialReceiveDigest(selected, index, digest) : null;
|
|
2687
|
+
}
|
|
2648
2688
|
const accountId = opts?.accountId;
|
|
2649
2689
|
if (accountId !== undefined) {
|
|
2650
2690
|
// An organization key (ADR 0061 D6); never the personal one in its place.
|
|
@@ -2668,6 +2708,34 @@ export class KeyManager {
|
|
|
2668
2708
|
: ((await KeyManager.getSharedPrivateKey()) ?? (await KeyManager.getPrivateKey()));
|
|
2669
2709
|
return local ? signSocialReceiveDigest(local, index, digest) : null;
|
|
2670
2710
|
}
|
|
2711
|
+
/**
|
|
2712
|
+
* The private key of the account (personal, or `accountId`'s organization)
|
|
2713
|
+
* whose public key is `publicKey`, from this app's own storage: the current
|
|
2714
|
+
* key when it matches, else the retired ring. iOS and Commons only; `null`
|
|
2715
|
+
* for a key this device does not hold. Never another key in its place. @internal
|
|
2716
|
+
*/
|
|
2717
|
+
/**
|
|
2718
|
+
* The selector in the identity host's encoding (lowercase uncompressed), so a
|
|
2719
|
+
* compressed `#key-1` from a DID or a gateway row selects the same key. @internal
|
|
2720
|
+
*/
|
|
2721
|
+
static _selectorKey(publicKey) {
|
|
2722
|
+
return KeyManager.isValidPublicKey(publicKey) ? KeyManager.canonicalPublicKey(publicKey) : null;
|
|
2723
|
+
}
|
|
2724
|
+
static async _selectedPrivateKey(publicKey, accountId) {
|
|
2725
|
+
if (retiredKeyScope({ accountId }) === null || !KeyManager.isValidPublicKey(publicKey)) {
|
|
2726
|
+
return null;
|
|
2727
|
+
}
|
|
2728
|
+
const wanted = KeyManager.canonicalPublicKey(publicKey);
|
|
2729
|
+
const current = accountId !== undefined
|
|
2730
|
+
? await getOrganizationPrivateKey(accountId)
|
|
2731
|
+
: isAndroid()
|
|
2732
|
+
? await KeyManager.getPrivateKey()
|
|
2733
|
+
: ((await KeyManager.getSharedPrivateKey()) ?? (await KeyManager.getPrivateKey()));
|
|
2734
|
+
if (current && KeyManager.canonicalPublicKey(KeyManager.derivePublicKey(current)) === wanted) {
|
|
2735
|
+
return current;
|
|
2736
|
+
}
|
|
2737
|
+
return getRetiredPrivateKey(wanted, accountId !== undefined ? { accountId } : undefined);
|
|
2738
|
+
}
|
|
2671
2739
|
/**
|
|
2672
2740
|
* Get a shortened version of the public key for display
|
|
2673
2741
|
* Format: first 8 chars...last 8 chars
|