@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
package/dist/cjs/api/identity.js
CHANGED
|
@@ -59,6 +59,7 @@ const client_1 = require("@oxy.so/contracts/client");
|
|
|
59
59
|
const client_2 = require("@oxy.so/contracts/client");
|
|
60
60
|
const platform_1 = require("../utils/platform");
|
|
61
61
|
const logger_1 = require("../logger");
|
|
62
|
+
const errorUtils_1 = require("../utils/errorUtils");
|
|
62
63
|
/** Short-TTL read cache for public identity reads. */
|
|
63
64
|
const SHORT_TTL = 60 * 1000;
|
|
64
65
|
/**
|
|
@@ -236,7 +237,7 @@ class IdentityApi {
|
|
|
236
237
|
try {
|
|
237
238
|
if (!oldPrivateKeyForRing)
|
|
238
239
|
throw new Error('no readable private key');
|
|
239
|
-
await retireIdentityKey({ privateKey: oldPrivateKeyForRing, publicKey: oldPublicKey });
|
|
240
|
+
await retireIdentityKey({ privateKey: oldPrivateKeyForRing, publicKey: oldPublicKey }, { ownerAccountId: userId });
|
|
240
241
|
}
|
|
241
242
|
catch (retainError) {
|
|
242
243
|
logger_1.logger.warn('Could not keep the current key as a retired key; not rotating.', { component: 'oxy.identity', method: 'rotateKey' }, retainError);
|
|
@@ -354,10 +355,31 @@ class IdentityApi {
|
|
|
354
355
|
}
|
|
355
356
|
}
|
|
356
357
|
}
|
|
358
|
+
// 8. The rotation deleted the encrypted backup (it was sealed under the
|
|
359
|
+
// replaced phrase). Seal a new one under the new phrase, carrying the
|
|
360
|
+
// retired ring — now with the key just replaced — so the coins under it
|
|
361
|
+
// survive a lost phone as long as the NEW phrase does (R4-4). Always,
|
|
362
|
+
// not only when one existed: after a rotation it is the only copy of the
|
|
363
|
+
// replaced key off this phone. Best-effort: the rotation already succeeded.
|
|
364
|
+
let backupRefreshed;
|
|
365
|
+
if (!(0, platform_1.isWeb)() && !localPersistFailed) {
|
|
366
|
+
try {
|
|
367
|
+
await this.backup.create(pending.phrase);
|
|
368
|
+
backupRefreshed = true;
|
|
369
|
+
}
|
|
370
|
+
catch (backupError) {
|
|
371
|
+
backupRefreshed = false;
|
|
372
|
+
logger_1.logger.warn('Key rotated, but the encrypted backup could not be re-created under the new phrase.', { component: 'oxy.identity', method: 'rotateKey' }, backupError);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
357
375
|
this.invalidate(userId);
|
|
358
|
-
return
|
|
359
|
-
|
|
360
|
-
|
|
376
|
+
return {
|
|
377
|
+
newPublicKey,
|
|
378
|
+
newPhrase: pending.phrase,
|
|
379
|
+
words: pending.words,
|
|
380
|
+
...(localPersistFailed ? { localPersistFailed: true } : {}),
|
|
381
|
+
...(backupRefreshed === undefined ? {} : { backupRefreshed }),
|
|
382
|
+
};
|
|
361
383
|
}
|
|
362
384
|
/**
|
|
363
385
|
* The signed-in user's signed, open-format data-export bundle
|
|
@@ -549,6 +571,10 @@ const BACKUP_ALGORITHM = 'xchacha20poly1305';
|
|
|
549
571
|
* to recognise WHICH identity a backup belongs to, but only a prefix.
|
|
550
572
|
*/
|
|
551
573
|
const PUBLIC_KEY_HINT_LENGTH = 16;
|
|
574
|
+
/** Whether a request error is the restore lookup's constant-shape 404. */
|
|
575
|
+
function isNotFound(error) {
|
|
576
|
+
return (0, errorUtils_1.extractErrorStatus)(error) === 404;
|
|
577
|
+
}
|
|
552
578
|
/** Encode bytes as lowercase hex (cross-platform, no Buffer dependency). */
|
|
553
579
|
function toHex(bytes) {
|
|
554
580
|
let out = '';
|
|
@@ -608,7 +634,13 @@ class IdentityBackupApi {
|
|
|
608
634
|
const publicKey = KeyManager.derivePublicKey(privateKey);
|
|
609
635
|
const createdAt = new Date().toISOString();
|
|
610
636
|
const publicKeyHint = publicKey.slice(0, PUBLIC_KEY_HINT_LENGTH);
|
|
611
|
-
const
|
|
637
|
+
const retiredKeys = await this.retiredKeysToCarry(publicKey);
|
|
638
|
+
const payload = {
|
|
639
|
+
privateKey,
|
|
640
|
+
publicKey,
|
|
641
|
+
createdAt,
|
|
642
|
+
...(retiredKeys.length > 0 ? { retiredKeys } : {}),
|
|
643
|
+
};
|
|
612
644
|
const plaintext = new TextEncoder().encode(JSON.stringify(payload));
|
|
613
645
|
const aad = buildBackupAad(BACKUP_ENVELOPE_VERSION, publicKeyHint);
|
|
614
646
|
const { nonce, ciphertext } = encryptAead(backupKey, plaintext, aad);
|
|
@@ -626,6 +658,115 @@ class IdentityBackupApi {
|
|
|
626
658
|
cache: false,
|
|
627
659
|
});
|
|
628
660
|
}
|
|
661
|
+
/**
|
|
662
|
+
* The retired ring this device holds for the person, limited to keys Oxy's
|
|
663
|
+
* key history names as THIS account's retired keys (the ring's iOS group is
|
|
664
|
+
* writable by the wallet app too, and a previous person's ring can outlive a
|
|
665
|
+
* "Start over"). Empty where no ring is held (web, any app but Commons).
|
|
666
|
+
* Throws when the history cannot be read while a ring exists, so a backup
|
|
667
|
+
* never silently drops keys it should carry.
|
|
668
|
+
*/
|
|
669
|
+
async retiredKeysToCarry(currentPublicKey) {
|
|
670
|
+
if ((0, platform_1.isWeb)())
|
|
671
|
+
return [];
|
|
672
|
+
const { exportRetiredIdentityKeys } = await Promise.resolve().then(() => __importStar(require('../crypto/index')));
|
|
673
|
+
const userId = this.ctx.oxy.session.userId;
|
|
674
|
+
if (!userId)
|
|
675
|
+
throw new Error('No authenticated user — sign in before backing up.');
|
|
676
|
+
let held;
|
|
677
|
+
try {
|
|
678
|
+
held = await exportRetiredIdentityKeys({ ownerAccountId: userId });
|
|
679
|
+
}
|
|
680
|
+
catch {
|
|
681
|
+
return []; // Not Commons: this app holds no ring to carry.
|
|
682
|
+
}
|
|
683
|
+
if (held.length === 0)
|
|
684
|
+
return [];
|
|
685
|
+
const history = await this.ctx.request('GET', '/identity/key-history', undefined, {
|
|
686
|
+
cache: false,
|
|
687
|
+
});
|
|
688
|
+
const parsed = (0, client_1.checkContract)(client_2.identityKeyHistoryCheck, history);
|
|
689
|
+
if (!parsed)
|
|
690
|
+
throw new Error('identity/key-history returned an unexpected response shape');
|
|
691
|
+
const retired = new Set(parsed.retired.map((entry) => entry.publicKey.toLowerCase()));
|
|
692
|
+
const current = currentPublicKey.toLowerCase();
|
|
693
|
+
return held.filter((key) => key.publicKey.toLowerCase() !== current && retired.has(key.publicKey.toLowerCase()));
|
|
694
|
+
}
|
|
695
|
+
/**
|
|
696
|
+
* Fetch and open the backup the phrase locates, checking it is the phrase's
|
|
697
|
+
* own identity. With `missingIsNull`, `null` when there is none (404);
|
|
698
|
+
* otherwise the request's own error propagates.
|
|
699
|
+
*/
|
|
700
|
+
async open(phrase, missingIsNull = false) {
|
|
701
|
+
const { KeyManager, RecoveryPhraseService, decryptAead } = await Promise.resolve().then(() => __importStar(require('../crypto/index')));
|
|
702
|
+
const { backupKey, lookupId } = await RecoveryPhraseService.deriveBackupMaterial(phrase);
|
|
703
|
+
let envelope;
|
|
704
|
+
try {
|
|
705
|
+
envelope = await this.ctx.request('GET', `/identity/backup/${encodeURIComponent(lookupId)}`, undefined, { cache: false });
|
|
706
|
+
}
|
|
707
|
+
catch (error) {
|
|
708
|
+
if (missingIsNull && isNotFound(error))
|
|
709
|
+
return null;
|
|
710
|
+
throw error;
|
|
711
|
+
}
|
|
712
|
+
if (envelope.algorithm !== BACKUP_ALGORITHM) {
|
|
713
|
+
throw new Error(`Unsupported backup algorithm: ${envelope.algorithm}`);
|
|
714
|
+
}
|
|
715
|
+
const aad = buildBackupAad(envelope.version, envelope.publicKeyHint);
|
|
716
|
+
const plaintext = decryptAead(backupKey, fromHex(envelope.nonce), fromHex(envelope.ciphertext), aad);
|
|
717
|
+
const payload = JSON.parse(new TextDecoder().decode(plaintext));
|
|
718
|
+
if (!payload.privateKey || !payload.publicKey) {
|
|
719
|
+
throw new Error('Backup payload is missing key material');
|
|
720
|
+
}
|
|
721
|
+
const phrasePk = (await RecoveryPhraseService.derivePublicKeyFromPhrase(phrase)).toLowerCase();
|
|
722
|
+
const payloadPk = payload.publicKey.toLowerCase();
|
|
723
|
+
const privatePk = KeyManager.derivePublicKey(payload.privateKey).toLowerCase();
|
|
724
|
+
if (phrasePk !== payloadPk || privatePk !== payloadPk) {
|
|
725
|
+
throw new Error('Backup payload does not match the recovery phrase');
|
|
726
|
+
}
|
|
727
|
+
return payload;
|
|
728
|
+
}
|
|
729
|
+
/**
|
|
730
|
+
* Keep every retired key a backup carries in this device's ring. Each pair
|
|
731
|
+
* must be healthy and must not be the current key; the envelope itself is
|
|
732
|
+
* authenticated by the phrase. Commons only (the ring refuses elsewhere).
|
|
733
|
+
* Resolves how many keys are now held; a key that fails is skipped.
|
|
734
|
+
*/
|
|
735
|
+
async keepCarriedRetiredKeys(payload) {
|
|
736
|
+
const { KeyManager, retireIdentityKey } = await Promise.resolve().then(() => __importStar(require('../crypto/index')));
|
|
737
|
+
// The backup is the signed-in account's (its phrase opened it): its keys
|
|
738
|
+
// are kept for that account only (R5-2).
|
|
739
|
+
const ownerAccountId = this.ctx.oxy.session.userId;
|
|
740
|
+
if (!ownerAccountId)
|
|
741
|
+
throw new Error('No authenticated user — sign in before restoring.');
|
|
742
|
+
let kept = 0;
|
|
743
|
+
for (const key of Array.isArray(payload.retiredKeys) ? payload.retiredKeys : []) {
|
|
744
|
+
try {
|
|
745
|
+
if (typeof key?.privateKey !== 'string' || typeof key.publicKey !== 'string')
|
|
746
|
+
continue;
|
|
747
|
+
if (key.publicKey.toLowerCase() === payload.publicKey.toLowerCase())
|
|
748
|
+
continue;
|
|
749
|
+
if (KeyManager.derivePublicKey(key.privateKey).toLowerCase() !== key.publicKey.toLowerCase()) {
|
|
750
|
+
continue;
|
|
751
|
+
}
|
|
752
|
+
await retireIdentityKey({ privateKey: key.privateKey, publicKey: key.publicKey }, { ownerAccountId });
|
|
753
|
+
kept += 1;
|
|
754
|
+
}
|
|
755
|
+
catch (error) {
|
|
756
|
+
logger_1.logger.warn('Could not keep a retired key from the encrypted backup', { component: 'oxy.identity', method: 'backup.restoreRetiredKeys' }, error);
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
return kept;
|
|
760
|
+
}
|
|
761
|
+
/**
|
|
762
|
+
* Restore ONLY the retired keys the phrase's backup carries into this
|
|
763
|
+
* device's ring (Commons, after a phrase import or a silent device-backup
|
|
764
|
+
* restore). Resolves how many were kept; 0 when there is no backup.
|
|
765
|
+
*/
|
|
766
|
+
async restoreRetiredKeys(phrase) {
|
|
767
|
+
const payload = await this.open(phrase, true);
|
|
768
|
+
return payload ? this.keepCarriedRetiredKeys(payload) : 0;
|
|
769
|
+
}
|
|
629
770
|
/**
|
|
630
771
|
* Whether the signed-in user has a stored backup, plus its non-sensitive hint
|
|
631
772
|
* and timestamp. Returns no ciphertext and no locator.
|
|
@@ -656,25 +797,15 @@ class IdentityBackupApi {
|
|
|
656
797
|
* fails authentication (tamper), or an existing identity blocks the import.
|
|
657
798
|
*/
|
|
658
799
|
async restore(phrase, options) {
|
|
659
|
-
const { KeyManager
|
|
660
|
-
const
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
if (!payload.privateKey || !payload.publicKey) {
|
|
669
|
-
throw new Error('Backup payload is missing key material');
|
|
670
|
-
}
|
|
671
|
-
const phrasePk = (await RecoveryPhraseService.derivePublicKeyFromPhrase(phrase)).toLowerCase();
|
|
672
|
-
const payloadPk = payload.publicKey.toLowerCase();
|
|
673
|
-
const privatePk = KeyManager.derivePublicKey(payload.privateKey).toLowerCase();
|
|
674
|
-
if (phrasePk !== payloadPk || privatePk !== payloadPk) {
|
|
675
|
-
throw new Error('Backup payload does not match the recovery phrase');
|
|
676
|
-
}
|
|
677
|
-
return KeyManager.importKeyPair(payload.privateKey, { overwrite: options?.overwrite === true });
|
|
800
|
+
const { KeyManager } = await Promise.resolve().then(() => __importStar(require('../crypto/index')));
|
|
801
|
+
const payload = await this.open(phrase);
|
|
802
|
+
if (!payload)
|
|
803
|
+
throw new Error('No encrypted backup was found for this recovery phrase.');
|
|
804
|
+
const publicKey = await KeyManager.importKeyPair(payload.privateKey, {
|
|
805
|
+
overwrite: options?.overwrite === true,
|
|
806
|
+
});
|
|
807
|
+
await this.keepCarriedRetiredKeys(payload);
|
|
808
|
+
return publicKey;
|
|
678
809
|
}
|
|
679
810
|
}
|
|
680
811
|
exports.IdentityBackupApi = IdentityBackupApi;
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
31
31
|
exports.IDENTITY_MARKER_STORAGE_KEY = void 0;
|
|
32
32
|
exports.readIdentityMarker = readIdentityMarker;
|
|
33
|
+
exports.readIdentityMarkerStrict = readIdentityMarkerStrict;
|
|
33
34
|
exports.writeIdentityMarker = writeIdentityMarker;
|
|
34
35
|
exports.updateIdentityMarker = updateIdentityMarker;
|
|
35
36
|
exports.clearIdentityMarker = clearIdentityMarker;
|
|
@@ -152,6 +153,20 @@ async function readIdentityMarker() {
|
|
|
152
153
|
return null;
|
|
153
154
|
}
|
|
154
155
|
}
|
|
156
|
+
/**
|
|
157
|
+
* Read the marker, THROWING when the storage cannot answer (instead of the
|
|
158
|
+
* fail-open `null` of {@link readIdentityMarker}). For a caller that must not
|
|
159
|
+
* mistake "unreadable" for "never written": the iOS keychain migration, which
|
|
160
|
+
* adopts a pre-split secret only when this marker vouches for it, and records
|
|
161
|
+
* once that there is nothing to adopt.
|
|
162
|
+
*/
|
|
163
|
+
async function readIdentityMarkerStrict() {
|
|
164
|
+
const storage = await getStorage();
|
|
165
|
+
if (!storage) {
|
|
166
|
+
throw new Error('The identity marker storage is unavailable.');
|
|
167
|
+
}
|
|
168
|
+
return deserialize(await storage.getItem(exports.IDENTITY_MARKER_STORAGE_KEY));
|
|
169
|
+
}
|
|
155
170
|
/**
|
|
156
171
|
* Write (create/replace) the marker. Returns `true` when it durably landed,
|
|
157
172
|
* `false` when storage was unavailable or the write threw. Callers treat a
|
package/dist/cjs/crypto/index.js
CHANGED
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
* common chunk, which is the cost the lazy loading exists to avoid.
|
|
13
13
|
*/
|
|
14
14
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
-
exports.
|
|
16
|
-
exports.verifySignature = exports.signMessage = exports.loadCommonsIdentityBridge = exports.canonicalize = exports.createContentHasher = exports.solveRegistrationPow = exports.deriveIdentityLinkCode = exports.signIdentityProof = exports.digestIdentityPayload = exports.authChallengeDigest = exports.signSocialReceiveDigest = void 0;
|
|
15
|
+
exports.hkdfSha256 = exports.BACKUP_KDF_ENCRYPTION_INFO = exports.RecoveryPhraseService = exports.SignatureService = exports.RetiredKeyRingUnreadableError = exports.unmirrorPersonalRetiredKeys = exports.countQuarantinedRetiredKeys = exports.claimRetiredIdentityKeys = exports.exportRetiredIdentityKeys = exports.retiredKeyFromPrivateKey = exports.retiredKeyFromPhrase = exports.syncRetiredKeys = exports.forgetRetiredIdentityKeys = exports.forgetRetiredIdentityKey = exports.listRetiredIdentityKeys = exports.retireIdentityKey = exports.getPreSplitExposure = exports.ensureKeychainGroupsMigrated = exports.groupedKeychainStore = exports.deleteThroughPlan = exports.readThroughPlan = exports.ownKeychainItemPlan = exports.keychainWriteOption = exports.keychainDeletePlan = exports.keychainReadPlan = exports.keychainAccessGroupValue = exports.resolveKeychainAccess = exports.DEV_KEYCHAIN_GROUP_SUFFIX = exports.mayWriteKeychainItem = exports.mayHoldKeychainItem = exports.keychainGroupFor = exports.KeychainAccessUnavailableError = exports.KEYCHAIN_ACCESS_GROUPS = exports.isHealthyOrganizationKeyPair = exports.isOrganizationAccountId = exports.syncOrganizationKeys = exports.deleteOrganizationKey = exports.listOrganizationKeys = exports.getOrganizationRecoveryPhrase = exports.getOrganizationPrivateKey = exports.getOrganizationPublicKey = exports.storeOrganizationKey = exports.organizationKeyFromPhrase = exports.generateOrganizationKey = exports.updateIdentityMarker = exports.readIdentityMarker = exports.IdentityUnavailableError = exports.IdentityPersistError = exports.IdentityAlreadyExistsError = exports.KeyManager = void 0;
|
|
16
|
+
exports.verifySignature = exports.signMessage = exports.loadCommonsIdentityBridge = exports.canonicalize = exports.createContentHasher = exports.solveRegistrationPow = exports.deriveIdentityLinkCode = exports.signIdentityProof = exports.digestIdentityPayload = exports.authChallengeDigest = exports.signSocialReceiveDigest = exports.deriveScopedSeedFromKey = exports.MAX_SOCIAL_RECEIVE_INDEX = exports.SOCIAL_RECEIVE_CHAIN_CODE_KEY = exports.SCOPED_SEED_KDF_SALT = exports.deriveSharedSecret = exports.AEAD_NONCE_LENGTH = exports.AEAD_KEY_LENGTH = exports.decryptAead = exports.encryptAead = void 0;
|
|
17
17
|
// MUST stay the first import: the shim has to exist before any module below
|
|
18
18
|
// evaluates or draws randomness (see `./polyfill`).
|
|
19
19
|
require("./polyfill");
|
|
@@ -44,6 +44,8 @@ Object.defineProperty(exports, "KEYCHAIN_ACCESS_GROUPS", { enumerable: true, get
|
|
|
44
44
|
Object.defineProperty(exports, "KeychainAccessUnavailableError", { enumerable: true, get: function () { return keychainAccessGroups_1.KeychainAccessUnavailableError; } });
|
|
45
45
|
Object.defineProperty(exports, "keychainGroupFor", { enumerable: true, get: function () { return keychainAccessGroups_1.keychainGroupFor; } });
|
|
46
46
|
Object.defineProperty(exports, "mayHoldKeychainItem", { enumerable: true, get: function () { return keychainAccessGroups_1.mayHoldKeychainItem; } });
|
|
47
|
+
Object.defineProperty(exports, "mayWriteKeychainItem", { enumerable: true, get: function () { return keychainAccessGroups_1.mayWriteKeychainItem; } });
|
|
48
|
+
Object.defineProperty(exports, "DEV_KEYCHAIN_GROUP_SUFFIX", { enumerable: true, get: function () { return keychainAccessGroups_1.DEV_KEYCHAIN_GROUP_SUFFIX; } });
|
|
47
49
|
Object.defineProperty(exports, "resolveKeychainAccess", { enumerable: true, get: function () { return keychainAccessGroups_1.resolveKeychainAccess; } });
|
|
48
50
|
Object.defineProperty(exports, "keychainAccessGroupValue", { enumerable: true, get: function () { return keychainAccessGroups_1.keychainAccessGroupValue; } });
|
|
49
51
|
Object.defineProperty(exports, "keychainReadPlan", { enumerable: true, get: function () { return keychainAccessGroups_1.keychainReadPlan; } });
|
|
@@ -55,6 +57,7 @@ Object.defineProperty(exports, "deleteThroughPlan", { enumerable: true, get: fun
|
|
|
55
57
|
Object.defineProperty(exports, "groupedKeychainStore", { enumerable: true, get: function () { return keychainAccessGroups_1.groupedKeychainStore; } });
|
|
56
58
|
var keychainGroupMigration_1 = require("./keychainGroupMigration");
|
|
57
59
|
Object.defineProperty(exports, "ensureKeychainGroupsMigrated", { enumerable: true, get: function () { return keychainGroupMigration_1.ensureKeychainGroupsMigrated; } });
|
|
60
|
+
Object.defineProperty(exports, "getPreSplitExposure", { enumerable: true, get: function () { return keychainGroupMigration_1.getPreSplitExposure; } });
|
|
58
61
|
// Retired identity keys: kept for spending after a rotation or replacement
|
|
59
62
|
// (docs/adr/0061-organization-identity-key.md D7 as amended 2026-11-06)
|
|
60
63
|
var retiredKeys_1 = require("./retiredKeys");
|
|
@@ -64,6 +67,12 @@ Object.defineProperty(exports, "forgetRetiredIdentityKey", { enumerable: true, g
|
|
|
64
67
|
Object.defineProperty(exports, "forgetRetiredIdentityKeys", { enumerable: true, get: function () { return retiredKeys_1.forgetRetiredIdentityKeys; } });
|
|
65
68
|
Object.defineProperty(exports, "syncRetiredKeys", { enumerable: true, get: function () { return retiredKeys_1.syncRetiredKeys; } });
|
|
66
69
|
Object.defineProperty(exports, "retiredKeyFromPhrase", { enumerable: true, get: function () { return retiredKeys_1.retiredKeyFromPhrase; } });
|
|
70
|
+
Object.defineProperty(exports, "retiredKeyFromPrivateKey", { enumerable: true, get: function () { return retiredKeys_1.retiredKeyFromPrivateKey; } });
|
|
71
|
+
Object.defineProperty(exports, "exportRetiredIdentityKeys", { enumerable: true, get: function () { return retiredKeys_1.exportRetiredIdentityKeys; } });
|
|
72
|
+
Object.defineProperty(exports, "claimRetiredIdentityKeys", { enumerable: true, get: function () { return retiredKeys_1.claimRetiredIdentityKeys; } });
|
|
73
|
+
Object.defineProperty(exports, "countQuarantinedRetiredKeys", { enumerable: true, get: function () { return retiredKeys_1.countQuarantinedRetiredKeys; } });
|
|
74
|
+
Object.defineProperty(exports, "unmirrorPersonalRetiredKeys", { enumerable: true, get: function () { return retiredKeys_1.unmirrorPersonalRetiredKeys; } });
|
|
75
|
+
Object.defineProperty(exports, "RetiredKeyRingUnreadableError", { enumerable: true, get: function () { return retiredKeys_1.RetiredKeyRingUnreadableError; } });
|
|
67
76
|
var signatureService_1 = require("./signatureService");
|
|
68
77
|
Object.defineProperty(exports, "SignatureService", { enumerable: true, get: function () { return signatureService_1.SignatureService; } });
|
|
69
78
|
var recoveryPhrase_1 = require("./recoveryPhrase");
|
|
@@ -211,9 +211,7 @@ async function loadRawSecureStore() {
|
|
|
211
211
|
async function keychainItemStore(item) {
|
|
212
212
|
const raw = await loadRawSecureStore();
|
|
213
213
|
const access = await (0, keychainAccessGroups_1.resolveKeychainAccess)();
|
|
214
|
-
|
|
215
|
-
await (0, keychainGroupMigration_1.ensureKeychainGroupsMigrated)();
|
|
216
|
-
}
|
|
214
|
+
await (0, keychainGroupMigration_1.prepareKeychainAccess)(access);
|
|
217
215
|
return (0, keychainAccessGroups_1.groupedKeychainStore)(raw, access, item);
|
|
218
216
|
}
|
|
219
217
|
/** The store for the identity slots (primary, backup, legacy, phrase): Commons only on iOS. */
|
|
@@ -790,12 +788,30 @@ class KeyManager {
|
|
|
790
788
|
* `signSocialReceive`) is fresh, so a cached key here would let a caller
|
|
791
789
|
* compare a new seed against an old key.
|
|
792
790
|
*
|
|
791
|
+
* iOS, in an app that holds the private key (Commons, the Peable wallet:
|
|
792
|
+
* keychain roles `holder` and `wallet`): DERIVED from the private key in the
|
|
793
|
+
* wallet group, never read from the public-key item, which sits in the shared
|
|
794
|
+
* group every Oxy app can write (R5-1); `null` when that app holds no private
|
|
795
|
+
* key. Every other iOS app reads the public-key item: it holds nothing it
|
|
796
|
+
* could derive from, and it never spends. Money code uses
|
|
797
|
+
* {@link getIdentityPublicKeyForMoney}.
|
|
798
|
+
*
|
|
793
799
|
* @returns Shared public key or null if no shared identity exists
|
|
794
800
|
*/
|
|
795
801
|
static async getSharedPublicKey() {
|
|
796
802
|
if (isWebPlatform()) {
|
|
797
803
|
return null;
|
|
798
804
|
}
|
|
805
|
+
if ((0, platform_1.isIOS)() && (await KeyManager._holdsSpendingKeys())) {
|
|
806
|
+
// Not cached: a rotation in Commons changes it under the wallet.
|
|
807
|
+
try {
|
|
808
|
+
const privateKey = await KeyManager.getSharedPrivateKey();
|
|
809
|
+
return privateKey ? KeyManager.derivePublicKey(privateKey) : null;
|
|
810
|
+
}
|
|
811
|
+
catch {
|
|
812
|
+
return null;
|
|
813
|
+
}
|
|
814
|
+
}
|
|
799
815
|
// Return cached value if available (iOS only; see above).
|
|
800
816
|
if (!(0, platform_1.isAndroid)() && KeyManager.cachedSharedPublicKey !== null) {
|
|
801
817
|
return KeyManager.cachedSharedPublicKey;
|
|
@@ -828,6 +844,68 @@ class KeyManager {
|
|
|
828
844
|
return null;
|
|
829
845
|
}
|
|
830
846
|
}
|
|
847
|
+
/**
|
|
848
|
+
* Whether this iOS app holds spending keys (keychain role `holder` or
|
|
849
|
+
* `wallet`), so a public key it uses for money must be derived from them.
|
|
850
|
+
* Throws while the keychain cannot say. @internal
|
|
851
|
+
*/
|
|
852
|
+
static async _holdsSpendingKeys() {
|
|
853
|
+
const access = await (0, keychainAccessGroups_1.resolveKeychainAccess)();
|
|
854
|
+
return access.mode === 'grouped' && access.role !== 'member';
|
|
855
|
+
}
|
|
856
|
+
/**
|
|
857
|
+
* THE public key of the identity a money holder (the Peable wallet, Commons)
|
|
858
|
+
* receives and spends with: the personal key, or with `opts.accountId` that
|
|
859
|
+
* organization's (ADR 0061 D6). It is the public half of exactly the private
|
|
860
|
+
* key {@link deriveScopedSeed} and {@link signSocialReceive} use, so an
|
|
861
|
+
* address shown from it is one this app can spend (R5-1).
|
|
862
|
+
*
|
|
863
|
+
* - iOS (Commons, the Peable wallet): DERIVED from the private key in the
|
|
864
|
+
* restricted keychain groups (personal: the wallet group's slot, then
|
|
865
|
+
* Commons' primary; organization: its pair). Never the shared group's
|
|
866
|
+
* public-key item, which every Oxy app can write. `null` when no private
|
|
867
|
+
* key is held, and in every other iOS app.
|
|
868
|
+
* - Android: inside Commons derived from its own key; elsewhere Commons'
|
|
869
|
+
* identity host (`describe`), which answers from Commons' own signer
|
|
870
|
+
* store over signature-protected IPC.
|
|
871
|
+
* - Web: `null`.
|
|
872
|
+
*
|
|
873
|
+
* Compare the answer with the account's published `#key-1` before showing a
|
|
874
|
+
* receive address (Peable ADR 0002). Lowercase uncompressed (`04…`).
|
|
875
|
+
*/
|
|
876
|
+
static async getIdentityPublicKeyForMoney(opts) {
|
|
877
|
+
if (isWebPlatform()) {
|
|
878
|
+
return null;
|
|
879
|
+
}
|
|
880
|
+
const accountId = opts?.accountId;
|
|
881
|
+
if (accountId !== undefined) {
|
|
882
|
+
if (!(0, organizationKeys_1.isOrganizationAccountId)(accountId))
|
|
883
|
+
return null;
|
|
884
|
+
if ((0, platform_1.isAndroid)() && !KeyManager.identitySignerStore) {
|
|
885
|
+
const bridge = await (0, protocol_1.loadCommonsIdentityBridge)();
|
|
886
|
+
const description = (await bridge?.describe(accountId)) ?? null;
|
|
887
|
+
return description?.accountId === accountId && description.publicKey
|
|
888
|
+
? KeyManager.canonicalPublicKey(description.publicKey)
|
|
889
|
+
: null;
|
|
890
|
+
}
|
|
891
|
+
const organizationKey = await (0, organizationKeys_1.getOrganizationPrivateKey)(accountId);
|
|
892
|
+
return organizationKey ? KeyManager.derivePublicKey(organizationKey) : null;
|
|
893
|
+
}
|
|
894
|
+
if ((0, platform_1.isAndroid)()) {
|
|
895
|
+
if (KeyManager.identitySignerStore) {
|
|
896
|
+
const local = await KeyManager.getPrivateKey();
|
|
897
|
+
return local ? KeyManager.derivePublicKey(local) : null;
|
|
898
|
+
}
|
|
899
|
+
const bridge = await (0, protocol_1.loadCommonsIdentityBridge)();
|
|
900
|
+
const publicKey = (await bridge?.describe())?.publicKey ?? null;
|
|
901
|
+
return publicKey ? KeyManager.canonicalPublicKey(publicKey) : null;
|
|
902
|
+
}
|
|
903
|
+
if (!(0, platform_1.isIOS)()) {
|
|
904
|
+
return null;
|
|
905
|
+
}
|
|
906
|
+
const privateKey = (await KeyManager.getSharedPrivateKey()) ?? (await KeyManager.getPrivateKey());
|
|
907
|
+
return privateKey ? KeyManager.derivePublicKey(privateKey) : null;
|
|
908
|
+
}
|
|
831
909
|
/**
|
|
832
910
|
* The shared private key, where this app may hold it: iOS only.
|
|
833
911
|
*
|
|
@@ -2089,21 +2167,28 @@ class KeyManager {
|
|
|
2089
2167
|
if (force) {
|
|
2090
2168
|
await KeyManager._bestEffortDeleteBackupsAllGenerations(store);
|
|
2091
2169
|
await KeyManager._clearSharedSlot(store);
|
|
2092
|
-
// The
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
}
|
|
2170
|
+
// The retired ring is NOT cleared (R4-4): it is money under the person's
|
|
2171
|
+
// previous keys, and "Start over" or a forced delete must not cost it.
|
|
2172
|
+
// Every personal entry is bound to its owner's account id (R5-2), so the
|
|
2173
|
+
// same person signing in again finds it, and a different person on this
|
|
2174
|
+
// phone never lists, derives from or signs with it. Only deleting the Oxy
|
|
2175
|
+
// ACCOUNT clears it, explicitly (`forgetRetiredIdentityKeys`).
|
|
2099
2176
|
}
|
|
2100
2177
|
// The device backup goes on EVERY delete, forced or not: it is restored
|
|
2101
2178
|
// silently, so leaving it would bring a deleted identity back on the next
|
|
2102
2179
|
// launch. Best-effort, like the other recovery sources.
|
|
2103
2180
|
await KeyManager._clearDeviceBackup();
|
|
2104
2181
|
// Same for the Android signer store: Commons' identity host must stop
|
|
2105
|
-
// answering for a deleted identity, forced or not
|
|
2182
|
+
// answering for a deleted identity, forced or not — its personal retired
|
|
2183
|
+
// keys included (R5-2): they stay in Commons' own ring, bound to their
|
|
2184
|
+
// owner, but the host must not hand them to the next person on the phone.
|
|
2106
2185
|
await KeyManager._clearIdentitySigner();
|
|
2186
|
+
try {
|
|
2187
|
+
await (0, retiredKeys_1.unmirrorPersonalRetiredKeys)();
|
|
2188
|
+
}
|
|
2189
|
+
catch (error) {
|
|
2190
|
+
logger_1.logger.warn('Failed to withdraw the personal retired keys from the identity host', { component: 'KeyManager' }, error);
|
|
2191
|
+
}
|
|
2107
2192
|
// Clear the marker AFTER key deletion succeeds — a marker must never outlive
|
|
2108
2193
|
// its identity (a leftover marker would route a truly-absent device to
|
|
2109
2194
|
// `recovery` instead of `welcome`).
|
|
@@ -2632,11 +2717,16 @@ class KeyManager {
|
|
|
2632
2717
|
* (personal, or `accountId`'s) whose public key it names: the current key
|
|
2633
2718
|
* when it matches, else a RETIRED key Commons kept after a rotation or
|
|
2634
2719
|
* replacement (`./retiredKeys`, ADR 0061 D7 as amended). Spending only: a
|
|
2635
|
-
* retired key never signs in. Never falls back to another key.
|
|
2720
|
+
* retired key never signs in. Never falls back to another key. A PERSONAL
|
|
2721
|
+
* retired key is reached only with `opts.ownerAccountId`, the signed-in
|
|
2722
|
+
* person's account id, and only when the ring keeps it for that account
|
|
2723
|
+
* (R5-2); on Android outside Commons the identity host holds only the
|
|
2724
|
+
* personal keys of the person Commons is signed in as.
|
|
2636
2725
|
*
|
|
2637
2726
|
* @param info Context/domain-binding label (distinct labels → independent seeds).
|
|
2638
2727
|
* @param opts.accountId An organization's account id, for its key.
|
|
2639
2728
|
* @param opts.publicKey Selects one key of that account by its public key.
|
|
2729
|
+
* @param opts.ownerAccountId The person's account id, for a personal retired key.
|
|
2640
2730
|
* @returns 32 bytes of derived keying material, or `null` on web / when no
|
|
2641
2731
|
* identity key is available to this app.
|
|
2642
2732
|
*/
|
|
@@ -2653,7 +2743,7 @@ class KeyManager {
|
|
|
2653
2743
|
const seed = (await bridge?.deriveRetiredScopedSeed?.(info, selected, opts.accountId)) ?? null;
|
|
2654
2744
|
return seed ? hexToBytes(seed) : null;
|
|
2655
2745
|
}
|
|
2656
|
-
const selected = await KeyManager._selectedPrivateKey(opts
|
|
2746
|
+
const selected = await KeyManager._selectedPrivateKey({ ...opts, publicKey: opts.publicKey });
|
|
2657
2747
|
return selected ? (0, identityDerivations_1.deriveScopedSeedFromKey)(selected, info) : null;
|
|
2658
2748
|
}
|
|
2659
2749
|
if (opts?.accountId !== undefined) {
|
|
@@ -2721,7 +2811,7 @@ class KeyManager {
|
|
|
2721
2811
|
return ((await bridge?.signRetiredSocialReceive?.(index, digest, selected, opts.accountId)) ??
|
|
2722
2812
|
null);
|
|
2723
2813
|
}
|
|
2724
|
-
const selected = await KeyManager._selectedPrivateKey(opts
|
|
2814
|
+
const selected = await KeyManager._selectedPrivateKey({ ...opts, publicKey: opts.publicKey });
|
|
2725
2815
|
return selected ? (0, identityDerivations_1.signSocialReceiveDigest)(selected, index, digest) : null;
|
|
2726
2816
|
}
|
|
2727
2817
|
const accountId = opts?.accountId;
|
|
@@ -2760,7 +2850,8 @@ class KeyManager {
|
|
|
2760
2850
|
static _selectorKey(publicKey) {
|
|
2761
2851
|
return KeyManager.isValidPublicKey(publicKey) ? KeyManager.canonicalPublicKey(publicKey) : null;
|
|
2762
2852
|
}
|
|
2763
|
-
static async _selectedPrivateKey(
|
|
2853
|
+
static async _selectedPrivateKey(opts) {
|
|
2854
|
+
const { publicKey, accountId, ownerAccountId } = opts;
|
|
2764
2855
|
if ((0, retiredKeys_1.retiredKeyScope)({ accountId }) === null || !KeyManager.isValidPublicKey(publicKey)) {
|
|
2765
2856
|
return null;
|
|
2766
2857
|
}
|
|
@@ -2773,7 +2864,11 @@ class KeyManager {
|
|
|
2773
2864
|
if (current && KeyManager.canonicalPublicKey(KeyManager.derivePublicKey(current)) === wanted) {
|
|
2774
2865
|
return current;
|
|
2775
2866
|
}
|
|
2776
|
-
return (0, retiredKeys_1.getRetiredPrivateKey)(wanted, accountId !== undefined
|
|
2867
|
+
return (0, retiredKeys_1.getRetiredPrivateKey)(wanted, accountId !== undefined
|
|
2868
|
+
? { accountId }
|
|
2869
|
+
: ownerAccountId !== undefined
|
|
2870
|
+
? { ownerAccountId }
|
|
2871
|
+
: undefined);
|
|
2777
2872
|
}
|
|
2778
2873
|
/**
|
|
2779
2874
|
* Get a shortened version of the public key for display
|