@oxy.so/core 7.31.0 → 7.32.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 +146 -23
- package/dist/cjs/crypto/identityMarker.js +15 -0
- package/dist/cjs/crypto/index.js +7 -2
- package/dist/cjs/crypto/keyManager.js +7 -10
- package/dist/cjs/crypto/keychainGroupMigration.js +327 -160
- package/dist/cjs/crypto/organizationKeys.js +33 -5
- package/dist/cjs/crypto/retiredKeys.js +80 -18
- package/dist/cjs/utils/keychainAccessGroups.js +111 -25
- package/dist/esm/api/identity.js +146 -23
- package/dist/esm/crypto/identityMarker.js +14 -0
- package/dist/esm/crypto/index.js +3 -3
- package/dist/esm/crypto/keyManager.js +9 -12
- package/dist/esm/crypto/keychainGroupMigration.js +327 -162
- package/dist/esm/crypto/organizationKeys.js +34 -6
- package/dist/esm/crypto/retiredKeys.js +76 -17
- 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/keychainGroupMigration.d.ts +28 -6
- package/dist/types/crypto/organizationKeys.d.ts +4 -2
- package/dist/types/crypto/retiredKeys.d.ts +30 -4
- package/dist/types/utils/keychainAccessGroups.d.ts +41 -11
- package/package.json +1 -1
- package/src/api/identity.ts +185 -40
- package/src/crypto/identityMarker.ts +15 -0
- package/src/crypto/index.ts +8 -1
- package/src/crypto/keyManager.ts +9 -15
- package/src/crypto/keychainGroupMigration.ts +390 -185
- package/src/crypto/organizationKeys.ts +38 -6
- package/src/crypto/retiredKeys.ts +87 -17
- package/src/utils/keychainAccessGroups.ts +121 -24
package/dist/esm/api/identity.js
CHANGED
|
@@ -21,6 +21,7 @@ import { checkContract, IDENTITY_PROOF_ACTIONS, identityLinkCreateResponseCheck,
|
|
|
21
21
|
import { identityKeyHistoryCheck } from '@oxy.so/contracts/client';
|
|
22
22
|
import { isAndroid, isWeb } from '../utils/platform.js';
|
|
23
23
|
import { logger } from '../logger/index.js';
|
|
24
|
+
import { extractErrorStatus } from '../utils/errorUtils.js';
|
|
24
25
|
/** Short-TTL read cache for public identity reads. */
|
|
25
26
|
const SHORT_TTL = 60 * 1000;
|
|
26
27
|
/**
|
|
@@ -316,10 +317,31 @@ export class IdentityApi {
|
|
|
316
317
|
}
|
|
317
318
|
}
|
|
318
319
|
}
|
|
320
|
+
// 8. The rotation deleted the encrypted backup (it was sealed under the
|
|
321
|
+
// replaced phrase). Seal a new one under the new phrase, carrying the
|
|
322
|
+
// retired ring — now with the key just replaced — so the coins under it
|
|
323
|
+
// survive a lost phone as long as the NEW phrase does (R4-4). Always,
|
|
324
|
+
// not only when one existed: after a rotation it is the only copy of the
|
|
325
|
+
// replaced key off this phone. Best-effort: the rotation already succeeded.
|
|
326
|
+
let backupRefreshed;
|
|
327
|
+
if (!isWeb() && !localPersistFailed) {
|
|
328
|
+
try {
|
|
329
|
+
await this.backup.create(pending.phrase);
|
|
330
|
+
backupRefreshed = true;
|
|
331
|
+
}
|
|
332
|
+
catch (backupError) {
|
|
333
|
+
backupRefreshed = false;
|
|
334
|
+
logger.warn('Key rotated, but the encrypted backup could not be re-created under the new phrase.', { component: 'oxy.identity', method: 'rotateKey' }, backupError);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
319
337
|
this.invalidate(userId);
|
|
320
|
-
return
|
|
321
|
-
|
|
322
|
-
|
|
338
|
+
return {
|
|
339
|
+
newPublicKey,
|
|
340
|
+
newPhrase: pending.phrase,
|
|
341
|
+
words: pending.words,
|
|
342
|
+
...(localPersistFailed ? { localPersistFailed: true } : {}),
|
|
343
|
+
...(backupRefreshed === undefined ? {} : { backupRefreshed }),
|
|
344
|
+
};
|
|
323
345
|
}
|
|
324
346
|
/**
|
|
325
347
|
* The signed-in user's signed, open-format data-export bundle
|
|
@@ -508,6 +530,10 @@ const BACKUP_ALGORITHM = 'xchacha20poly1305';
|
|
|
508
530
|
* to recognise WHICH identity a backup belongs to, but only a prefix.
|
|
509
531
|
*/
|
|
510
532
|
const PUBLIC_KEY_HINT_LENGTH = 16;
|
|
533
|
+
/** Whether a request error is the restore lookup's constant-shape 404. */
|
|
534
|
+
function isNotFound(error) {
|
|
535
|
+
return extractErrorStatus(error) === 404;
|
|
536
|
+
}
|
|
511
537
|
/** Encode bytes as lowercase hex (cross-platform, no Buffer dependency). */
|
|
512
538
|
function toHex(bytes) {
|
|
513
539
|
let out = '';
|
|
@@ -567,7 +593,13 @@ export class IdentityBackupApi {
|
|
|
567
593
|
const publicKey = KeyManager.derivePublicKey(privateKey);
|
|
568
594
|
const createdAt = new Date().toISOString();
|
|
569
595
|
const publicKeyHint = publicKey.slice(0, PUBLIC_KEY_HINT_LENGTH);
|
|
570
|
-
const
|
|
596
|
+
const retiredKeys = await this.retiredKeysToCarry(publicKey);
|
|
597
|
+
const payload = {
|
|
598
|
+
privateKey,
|
|
599
|
+
publicKey,
|
|
600
|
+
createdAt,
|
|
601
|
+
...(retiredKeys.length > 0 ? { retiredKeys } : {}),
|
|
602
|
+
};
|
|
571
603
|
const plaintext = new TextEncoder().encode(JSON.stringify(payload));
|
|
572
604
|
const aad = buildBackupAad(BACKUP_ENVELOPE_VERSION, publicKeyHint);
|
|
573
605
|
const { nonce, ciphertext } = encryptAead(backupKey, plaintext, aad);
|
|
@@ -585,6 +617,107 @@ export class IdentityBackupApi {
|
|
|
585
617
|
cache: false,
|
|
586
618
|
});
|
|
587
619
|
}
|
|
620
|
+
/**
|
|
621
|
+
* The retired ring this device holds for the person, limited to keys Oxy's
|
|
622
|
+
* key history names as THIS account's retired keys (the ring's iOS group is
|
|
623
|
+
* writable by the wallet app too, and a previous person's ring can outlive a
|
|
624
|
+
* "Start over"). Empty where no ring is held (web, any app but Commons).
|
|
625
|
+
* Throws when the history cannot be read while a ring exists, so a backup
|
|
626
|
+
* never silently drops keys it should carry.
|
|
627
|
+
*/
|
|
628
|
+
async retiredKeysToCarry(currentPublicKey) {
|
|
629
|
+
if (isWeb())
|
|
630
|
+
return [];
|
|
631
|
+
const { exportRetiredIdentityKeys } = await import('../crypto/index.js');
|
|
632
|
+
let held;
|
|
633
|
+
try {
|
|
634
|
+
held = await exportRetiredIdentityKeys();
|
|
635
|
+
}
|
|
636
|
+
catch {
|
|
637
|
+
return []; // Not Commons: this app holds no ring to carry.
|
|
638
|
+
}
|
|
639
|
+
if (held.length === 0)
|
|
640
|
+
return [];
|
|
641
|
+
const history = await this.ctx.request('GET', '/identity/key-history', undefined, {
|
|
642
|
+
cache: false,
|
|
643
|
+
});
|
|
644
|
+
const parsed = checkContract(identityKeyHistoryCheck, history);
|
|
645
|
+
if (!parsed)
|
|
646
|
+
throw new Error('identity/key-history returned an unexpected response shape');
|
|
647
|
+
const retired = new Set(parsed.retired.map((entry) => entry.publicKey.toLowerCase()));
|
|
648
|
+
const current = currentPublicKey.toLowerCase();
|
|
649
|
+
return held.filter((key) => key.publicKey.toLowerCase() !== current && retired.has(key.publicKey.toLowerCase()));
|
|
650
|
+
}
|
|
651
|
+
/**
|
|
652
|
+
* Fetch and open the backup the phrase locates, checking it is the phrase's
|
|
653
|
+
* own identity. With `missingIsNull`, `null` when there is none (404);
|
|
654
|
+
* otherwise the request's own error propagates.
|
|
655
|
+
*/
|
|
656
|
+
async open(phrase, missingIsNull = false) {
|
|
657
|
+
const { KeyManager, RecoveryPhraseService, decryptAead } = await import('../crypto/index.js');
|
|
658
|
+
const { backupKey, lookupId } = await RecoveryPhraseService.deriveBackupMaterial(phrase);
|
|
659
|
+
let envelope;
|
|
660
|
+
try {
|
|
661
|
+
envelope = await this.ctx.request('GET', `/identity/backup/${encodeURIComponent(lookupId)}`, undefined, { cache: false });
|
|
662
|
+
}
|
|
663
|
+
catch (error) {
|
|
664
|
+
if (missingIsNull && isNotFound(error))
|
|
665
|
+
return null;
|
|
666
|
+
throw error;
|
|
667
|
+
}
|
|
668
|
+
if (envelope.algorithm !== BACKUP_ALGORITHM) {
|
|
669
|
+
throw new Error(`Unsupported backup algorithm: ${envelope.algorithm}`);
|
|
670
|
+
}
|
|
671
|
+
const aad = buildBackupAad(envelope.version, envelope.publicKeyHint);
|
|
672
|
+
const plaintext = decryptAead(backupKey, fromHex(envelope.nonce), fromHex(envelope.ciphertext), aad);
|
|
673
|
+
const payload = JSON.parse(new TextDecoder().decode(plaintext));
|
|
674
|
+
if (!payload.privateKey || !payload.publicKey) {
|
|
675
|
+
throw new Error('Backup payload is missing key material');
|
|
676
|
+
}
|
|
677
|
+
const phrasePk = (await RecoveryPhraseService.derivePublicKeyFromPhrase(phrase)).toLowerCase();
|
|
678
|
+
const payloadPk = payload.publicKey.toLowerCase();
|
|
679
|
+
const privatePk = KeyManager.derivePublicKey(payload.privateKey).toLowerCase();
|
|
680
|
+
if (phrasePk !== payloadPk || privatePk !== payloadPk) {
|
|
681
|
+
throw new Error('Backup payload does not match the recovery phrase');
|
|
682
|
+
}
|
|
683
|
+
return payload;
|
|
684
|
+
}
|
|
685
|
+
/**
|
|
686
|
+
* Keep every retired key a backup carries in this device's ring. Each pair
|
|
687
|
+
* must be healthy and must not be the current key; the envelope itself is
|
|
688
|
+
* authenticated by the phrase. Commons only (the ring refuses elsewhere).
|
|
689
|
+
* Resolves how many keys are now held; a key that fails is skipped.
|
|
690
|
+
*/
|
|
691
|
+
async keepCarriedRetiredKeys(payload) {
|
|
692
|
+
const { KeyManager, retireIdentityKey } = await import('../crypto/index.js');
|
|
693
|
+
let kept = 0;
|
|
694
|
+
for (const key of Array.isArray(payload.retiredKeys) ? payload.retiredKeys : []) {
|
|
695
|
+
try {
|
|
696
|
+
if (typeof key?.privateKey !== 'string' || typeof key.publicKey !== 'string')
|
|
697
|
+
continue;
|
|
698
|
+
if (key.publicKey.toLowerCase() === payload.publicKey.toLowerCase())
|
|
699
|
+
continue;
|
|
700
|
+
if (KeyManager.derivePublicKey(key.privateKey).toLowerCase() !== key.publicKey.toLowerCase()) {
|
|
701
|
+
continue;
|
|
702
|
+
}
|
|
703
|
+
await retireIdentityKey({ privateKey: key.privateKey, publicKey: key.publicKey });
|
|
704
|
+
kept += 1;
|
|
705
|
+
}
|
|
706
|
+
catch (error) {
|
|
707
|
+
logger.warn('Could not keep a retired key from the encrypted backup', { component: 'oxy.identity', method: 'backup.restoreRetiredKeys' }, error);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
return kept;
|
|
711
|
+
}
|
|
712
|
+
/**
|
|
713
|
+
* Restore ONLY the retired keys the phrase's backup carries into this
|
|
714
|
+
* device's ring (Commons, after a phrase import or a silent device-backup
|
|
715
|
+
* restore). Resolves how many were kept; 0 when there is no backup.
|
|
716
|
+
*/
|
|
717
|
+
async restoreRetiredKeys(phrase) {
|
|
718
|
+
const payload = await this.open(phrase, true);
|
|
719
|
+
return payload ? this.keepCarriedRetiredKeys(payload) : 0;
|
|
720
|
+
}
|
|
588
721
|
/**
|
|
589
722
|
* Whether the signed-in user has a stored backup, plus its non-sensitive hint
|
|
590
723
|
* and timestamp. Returns no ciphertext and no locator.
|
|
@@ -615,24 +748,14 @@ export class IdentityBackupApi {
|
|
|
615
748
|
* fails authentication (tamper), or an existing identity blocks the import.
|
|
616
749
|
*/
|
|
617
750
|
async restore(phrase, options) {
|
|
618
|
-
const { KeyManager
|
|
619
|
-
const
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
if (!payload.privateKey || !payload.publicKey) {
|
|
628
|
-
throw new Error('Backup payload is missing key material');
|
|
629
|
-
}
|
|
630
|
-
const phrasePk = (await RecoveryPhraseService.derivePublicKeyFromPhrase(phrase)).toLowerCase();
|
|
631
|
-
const payloadPk = payload.publicKey.toLowerCase();
|
|
632
|
-
const privatePk = KeyManager.derivePublicKey(payload.privateKey).toLowerCase();
|
|
633
|
-
if (phrasePk !== payloadPk || privatePk !== payloadPk) {
|
|
634
|
-
throw new Error('Backup payload does not match the recovery phrase');
|
|
635
|
-
}
|
|
636
|
-
return KeyManager.importKeyPair(payload.privateKey, { overwrite: options?.overwrite === true });
|
|
751
|
+
const { KeyManager } = await import('../crypto/index.js');
|
|
752
|
+
const payload = await this.open(phrase);
|
|
753
|
+
if (!payload)
|
|
754
|
+
throw new Error('No encrypted backup was found for this recovery phrase.');
|
|
755
|
+
const publicKey = await KeyManager.importKeyPair(payload.privateKey, {
|
|
756
|
+
overwrite: options?.overwrite === true,
|
|
757
|
+
});
|
|
758
|
+
await this.keepCarriedRetiredKeys(payload);
|
|
759
|
+
return publicKey;
|
|
637
760
|
}
|
|
638
761
|
}
|
|
@@ -145,6 +145,20 @@ export async function readIdentityMarker() {
|
|
|
145
145
|
return null;
|
|
146
146
|
}
|
|
147
147
|
}
|
|
148
|
+
/**
|
|
149
|
+
* Read the marker, THROWING when the storage cannot answer (instead of the
|
|
150
|
+
* fail-open `null` of {@link readIdentityMarker}). For a caller that must not
|
|
151
|
+
* mistake "unreadable" for "never written": the iOS keychain migration, which
|
|
152
|
+
* adopts a pre-split secret only when this marker vouches for it, and records
|
|
153
|
+
* once that there is nothing to adopt.
|
|
154
|
+
*/
|
|
155
|
+
export async function readIdentityMarkerStrict() {
|
|
156
|
+
const storage = await getStorage();
|
|
157
|
+
if (!storage) {
|
|
158
|
+
throw new Error('The identity marker storage is unavailable.');
|
|
159
|
+
}
|
|
160
|
+
return deserialize(await storage.getItem(IDENTITY_MARKER_STORAGE_KEY));
|
|
161
|
+
}
|
|
148
162
|
/**
|
|
149
163
|
* Write (create/replace) the marker. Returns `true` when it durably landed,
|
|
150
164
|
* `false` when storage was unavailable or the write threw. Callers treat a
|
package/dist/esm/crypto/index.js
CHANGED
|
@@ -18,11 +18,11 @@ 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
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';
|
|
21
|
+
export { KEYCHAIN_ACCESS_GROUPS, KeychainAccessUnavailableError, keychainGroupFor, mayHoldKeychainItem, mayWriteKeychainItem, DEV_KEYCHAIN_GROUP_SUFFIX, resolveKeychainAccess, keychainAccessGroupValue, keychainReadPlan, keychainDeletePlan, keychainWriteOption, ownKeychainItemPlan, readThroughPlan, deleteThroughPlan, groupedKeychainStore, } from '../utils/keychainAccessGroups.js';
|
|
22
|
+
export { ensureKeychainGroupsMigrated, getPreSplitExposure, } from './keychainGroupMigration.js';
|
|
23
23
|
// Retired identity keys: kept for spending after a rotation or replacement
|
|
24
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';
|
|
25
|
+
export { retireIdentityKey, listRetiredIdentityKeys, forgetRetiredIdentityKey, forgetRetiredIdentityKeys, syncRetiredKeys, retiredKeyFromPhrase, retiredKeyFromPrivateKey, exportRetiredIdentityKeys, } from './retiredKeys.js';
|
|
26
26
|
export { SignatureService } from './signatureService.js';
|
|
27
27
|
export { RecoveryPhraseService, BACKUP_KDF_ENCRYPTION_INFO } from './recoveryPhrase.js';
|
|
28
28
|
// Low-level primitives (encrypted backup, device transfer)
|
|
@@ -11,9 +11,9 @@ 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 {
|
|
14
|
+
import { getRetiredPrivateKey, retiredKeyScope } from './retiredKeys.js';
|
|
15
15
|
import { groupedKeychainStore, resolveKeychainAccess, } from '../utils/keychainAccessGroups.js';
|
|
16
|
-
import {
|
|
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';
|
|
18
18
|
import { clearIdentityMarker, readIdentityMarker, updateIdentityMarker, writeIdentityMarker, } from './identityMarker.js';
|
|
19
19
|
import { DEVICE_BACKUP_VERSION, parseDeviceBackup, serializeDeviceBackup, } from './deviceBackup.js';
|
|
@@ -172,9 +172,7 @@ async function loadRawSecureStore() {
|
|
|
172
172
|
async function keychainItemStore(item) {
|
|
173
173
|
const raw = await loadRawSecureStore();
|
|
174
174
|
const access = await resolveKeychainAccess();
|
|
175
|
-
|
|
176
|
-
await ensureKeychainGroupsMigrated();
|
|
177
|
-
}
|
|
175
|
+
await prepareKeychainAccess(access);
|
|
178
176
|
return groupedKeychainStore(raw, access, item);
|
|
179
177
|
}
|
|
180
178
|
/** The store for the identity slots (primary, backup, legacy, phrase): Commons only on iOS. */
|
|
@@ -2050,13 +2048,12 @@ export class KeyManager {
|
|
|
2050
2048
|
if (force) {
|
|
2051
2049
|
await KeyManager._bestEffortDeleteBackupsAllGenerations(store);
|
|
2052
2050
|
await KeyManager._clearSharedSlot(store);
|
|
2053
|
-
// The
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
}
|
|
2051
|
+
// The retired ring is NOT cleared (R4-4): it is money under the person's
|
|
2052
|
+
// previous keys, and "Start over" or a forced delete must not cost it.
|
|
2053
|
+
// The same person restoring their identity finds it again; a different
|
|
2054
|
+
// identity on this phone cannot use it (each entry is checked against
|
|
2055
|
+
// that account's key history before use). Only deleting the Oxy ACCOUNT
|
|
2056
|
+
// clears it, explicitly (`forgetRetiredIdentityKeys`, Commons' flow).
|
|
2060
2057
|
}
|
|
2061
2058
|
// The device backup goes on EVERY delete, forced or not: it is restored
|
|
2062
2059
|
// silently, so leaving it would bring a deleted identity back on the next
|