@oxyhq/core 12.9.0 → 12.10.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/.tsbuildinfo +1 -1
- package/dist/cjs/crypto/keyManager.js +89 -0
- package/dist/cjs/index.js +7 -4
- package/dist/cjs/mixins/OxyServices.auth.js +4 -1
- package/dist/cjs/mixins/OxyServices.identityBackup.js +11 -0
- package/dist/cjs/mixins/OxyServices.user.js +4 -0
- package/dist/cjs/utils/commonsApproval.js +31 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/crypto/keyManager.js +89 -0
- package/dist/esm/index.js +2 -1
- package/dist/esm/mixins/OxyServices.auth.js +1 -0
- package/dist/esm/mixins/OxyServices.identityBackup.js +11 -0
- package/dist/esm/mixins/OxyServices.user.js +4 -0
- package/dist/esm/utils/commonsApproval.js +27 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/crypto/keyManager.d.ts +33 -0
- package/dist/types/index.d.ts +2 -1
- package/dist/types/mixins/OxyServices.auth.d.ts +4 -4
- package/dist/types/utils/commonsApproval.d.ts +13 -0
- package/package.json +1 -1
- package/src/crypto/__tests__/keyManager.recoveryMnemonic.test.ts +138 -0
- package/src/crypto/keyManager.ts +99 -0
- package/src/index.ts +7 -1
- package/src/mixins/OxyServices.auth.ts +9 -5
- package/src/mixins/OxyServices.identityBackup.ts +13 -0
- package/src/mixins/OxyServices.user.ts +4 -0
- package/src/mixins/__tests__/identityBackup.test.ts +57 -0
- package/src/mixins/__tests__/privacyCacheInvalidation.test.ts +6 -0
- package/src/utils/__tests__/commonsApproval.test.ts +60 -0
- package/src/utils/commonsApproval.ts +39 -0
|
@@ -117,6 +117,24 @@ const V2_STORAGE_KEYS = {
|
|
|
117
117
|
BACKUP_PUBLIC_KEY: 'oxy_identity_backup_public_key_v2',
|
|
118
118
|
BACKUP_TIMESTAMP: 'oxy_identity_backup_timestamp_v2',
|
|
119
119
|
};
|
|
120
|
+
/**
|
|
121
|
+
* Dedicated keychain slot for the recovery mnemonic (the 12-word phrase).
|
|
122
|
+
*
|
|
123
|
+
* Stored under its OWN keychain service — distinct from the v2 primary, backup,
|
|
124
|
+
* and shared slots — so it shares an AndroidKeyStore key with none of them
|
|
125
|
+
* (blast-radius isolation, same rationale as the v2 primary/backup split).
|
|
126
|
+
* Written `WHEN_UNLOCKED_THIS_DEVICE_ONLY` and NEVER exported off-device: it
|
|
127
|
+
* exists solely so the user can RE-READ their phrase from Settings on the SAME
|
|
128
|
+
* device that generated/imported it.
|
|
129
|
+
*
|
|
130
|
+
* This is convenience persistence, NOT a recovery mechanism — a keystore death
|
|
131
|
+
* wipes it alongside the keys, exactly like the private key itself. The user's
|
|
132
|
+
* written-down phrase remains the sole out-of-band recovery path. The mnemonic
|
|
133
|
+
* lives ONLY in this slot: it is never mirrored into the identity marker,
|
|
134
|
+
* {@link KeyManager.getIdentityStatus}, logs, or any exported bundle.
|
|
135
|
+
*/
|
|
136
|
+
const RECOVERY_MNEMONIC_KEYCHAIN_SERVICE = 'oxy_identity_mnemonic';
|
|
137
|
+
const RECOVERY_MNEMONIC_STORAGE_KEY = 'oxy_identity_mnemonic_v1';
|
|
120
138
|
/**
|
|
121
139
|
* Advisory AsyncStorage fast-path flag: set once the v2 slots own the identity.
|
|
122
140
|
* Re-derivable (its loss just re-runs the cheap slot check), so it lives in
|
|
@@ -1471,6 +1489,73 @@ export class KeyManager {
|
|
|
1471
1489
|
throw new IdentityUnavailableError('Failed to read identity from secure storage.', error);
|
|
1472
1490
|
}
|
|
1473
1491
|
}
|
|
1492
|
+
/**
|
|
1493
|
+
* Persist the recovery mnemonic (the 12-word phrase) into its dedicated,
|
|
1494
|
+
* device-only keychain slot so the user can re-reveal it from Settings after
|
|
1495
|
+
* onboarding.
|
|
1496
|
+
*
|
|
1497
|
+
* Called best-effort at identity creation/import, where the phrase is already
|
|
1498
|
+
* in memory: a failure to persist it must NEVER fail the identity itself, so
|
|
1499
|
+
* callers deliberately swallow the thrown error (logging it). Storage errors
|
|
1500
|
+
* throw {@link IdentityUnavailableError} — same "cannot determine" semantics as
|
|
1501
|
+
* the other getters — so a caller MAY observe/log the failure.
|
|
1502
|
+
*
|
|
1503
|
+
* The mnemonic is stored ONLY here — never in the marker, `getIdentityStatus`,
|
|
1504
|
+
* logs, or any exported bundle.
|
|
1505
|
+
*/
|
|
1506
|
+
static async storeRecoveryMnemonic(mnemonic) {
|
|
1507
|
+
if (isWebPlatform()) {
|
|
1508
|
+
return; // Identity storage is only available on native platforms
|
|
1509
|
+
}
|
|
1510
|
+
try {
|
|
1511
|
+
const store = await initSecureStore();
|
|
1512
|
+
await store.setItemAsync(RECOVERY_MNEMONIC_STORAGE_KEY, mnemonic, KeyManager._privateWriteOpts(store, RECOVERY_MNEMONIC_KEYCHAIN_SERVICE));
|
|
1513
|
+
}
|
|
1514
|
+
catch (error) {
|
|
1515
|
+
if (isDev()) {
|
|
1516
|
+
logger.warn('Failed to persist recovery mnemonic', { component: 'KeyManager' }, error);
|
|
1517
|
+
}
|
|
1518
|
+
throw new IdentityUnavailableError('Failed to persist recovery mnemonic.', error);
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
/**
|
|
1522
|
+
* Read the stored recovery mnemonic for re-reveal in Settings.
|
|
1523
|
+
*
|
|
1524
|
+
* Returns the phrase, or `null` when a read SUCCEEDS and finds none — the
|
|
1525
|
+
* expected result for any identity created/imported before this feature
|
|
1526
|
+
* existed, since the phrase was never captured for those. THROWS
|
|
1527
|
+
* {@link IdentityUnavailableError} when storage is unreadable (keychain locked
|
|
1528
|
+
* / module load failure), matching {@link getPublicKey}'s contract — a thrown
|
|
1529
|
+
* read is never flattened to `null`, so a caller distinguishes "phrase was
|
|
1530
|
+
* never stored" from "keychain temporarily locked, retry".
|
|
1531
|
+
*/
|
|
1532
|
+
static async getRecoveryMnemonic() {
|
|
1533
|
+
if (isWebPlatform()) {
|
|
1534
|
+
return null; // Identity storage is only available on native platforms
|
|
1535
|
+
}
|
|
1536
|
+
try {
|
|
1537
|
+
const store = await initSecureStore();
|
|
1538
|
+
return await store.getItemAsync(RECOVERY_MNEMONIC_STORAGE_KEY, KeyManager._slotOpts(RECOVERY_MNEMONIC_KEYCHAIN_SERVICE));
|
|
1539
|
+
}
|
|
1540
|
+
catch (error) {
|
|
1541
|
+
if (isDev()) {
|
|
1542
|
+
logger.warn('Failed to read recovery mnemonic', { component: 'KeyManager' }, error);
|
|
1543
|
+
}
|
|
1544
|
+
throw new IdentityUnavailableError('Failed to read recovery mnemonic from secure storage.', error);
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
/**
|
|
1548
|
+
* Delete the stored recovery mnemonic. Best-effort: a delete failure is logged
|
|
1549
|
+
* and swallowed, never thrown — it runs inside the identity-deletion path where
|
|
1550
|
+
* an unreadable keychain must not abort the wider teardown.
|
|
1551
|
+
*/
|
|
1552
|
+
static async deleteRecoveryMnemonic() {
|
|
1553
|
+
if (isWebPlatform()) {
|
|
1554
|
+
return; // Identity storage is only available on native platforms
|
|
1555
|
+
}
|
|
1556
|
+
const store = await initSecureStore();
|
|
1557
|
+
await KeyManager._bestEffortDelete(store, RECOVERY_MNEMONIC_STORAGE_KEY, RECOVERY_MNEMONIC_KEYCHAIN_SERVICE);
|
|
1558
|
+
}
|
|
1474
1559
|
/**
|
|
1475
1560
|
* Check if a complete, parseable identity exists on this device.
|
|
1476
1561
|
*
|
|
@@ -1686,6 +1771,10 @@ export class KeyManager {
|
|
|
1686
1771
|
await KeyManager._bestEffortDeleteV2Primary(store);
|
|
1687
1772
|
await KeyManager._bestEffortDelete(store, STORAGE_KEYS.PRIVATE_KEY);
|
|
1688
1773
|
await KeyManager._bestEffortDelete(store, STORAGE_KEYS.PUBLIC_KEY);
|
|
1774
|
+
// Always drop the stored recovery mnemonic — it is scoped to the identity
|
|
1775
|
+
// being deleted, so a leftover would let Settings reveal a stale phrase for
|
|
1776
|
+
// an identity that no longer exists (or a DIFFERENT one after re-onboarding).
|
|
1777
|
+
await KeyManager.deleteRecoveryMnemonic();
|
|
1689
1778
|
// Also clear backups + the shared slot on force deletion, so a deleted
|
|
1690
1779
|
// identity cannot be resurrected from any recovery source.
|
|
1691
1780
|
if (force) {
|
package/dist/esm/index.js
CHANGED
|
@@ -26,7 +26,8 @@ export { OXY_CLOUD_URL, oxyClient } from './OxyServices.js';
|
|
|
26
26
|
// ---------------------------------------------------------------------------
|
|
27
27
|
// Authentication
|
|
28
28
|
// ---------------------------------------------------------------------------
|
|
29
|
-
export { ServiceCredentialMismatchError } from './mixins/OxyServices.auth.js';
|
|
29
|
+
export { ServiceCredentialMismatchError, } from './mixins/OxyServices.auth.js';
|
|
30
|
+
export { getCommonsApprovalBlockingReason, parseCommonsApprovalExpiresAt, } from './utils/commonsApproval.js';
|
|
30
31
|
export { OxyAppDataIdentifierError } from './mixins/OxyServices.appData.js';
|
|
31
32
|
// ---------------------------------------------------------------------------
|
|
32
33
|
// User identity and handles
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { loginResultSchema, safeParseContract } from '@oxyhq/contracts';
|
|
2
|
+
export { getCommonsApprovalBlockingReason, parseCommonsApprovalExpiresAt, } from '../utils/commonsApproval.js';
|
|
2
3
|
import { OxyAuthenticationError } from '../OxyServices.errors.js';
|
|
3
4
|
import { KeyManager } from '../crypto/keyManager.js';
|
|
4
5
|
import { SignatureService } from '../crypto/signatureService.js';
|
|
@@ -138,6 +138,17 @@ export function OxyServicesIdentityBackupMixin(Base) {
|
|
|
138
138
|
const aad = buildBackupAad(envelope.version, envelope.publicKeyHint);
|
|
139
139
|
const plaintext = decryptAead(backupKey, fromHex(envelope.nonce), fromHex(envelope.ciphertext), aad);
|
|
140
140
|
const payload = JSON.parse(new TextDecoder().decode(plaintext));
|
|
141
|
+
if (!payload.privateKey || !payload.publicKey) {
|
|
142
|
+
throw new Error('Backup payload is missing key material');
|
|
143
|
+
}
|
|
144
|
+
const derivedFromPhrase = await RecoveryPhraseService.derivePublicKeyFromPhrase(phrase);
|
|
145
|
+
const derivedFromPrivate = KeyManager.derivePublicKey(payload.privateKey);
|
|
146
|
+
const phrasePk = derivedFromPhrase.toLowerCase();
|
|
147
|
+
const payloadPk = payload.publicKey.toLowerCase();
|
|
148
|
+
const privatePk = derivedFromPrivate.toLowerCase();
|
|
149
|
+
if (phrasePk !== payloadPk || privatePk !== payloadPk) {
|
|
150
|
+
throw new Error('Backup payload does not match the recovery phrase');
|
|
151
|
+
}
|
|
141
152
|
// Persist the recovered key. Native-only; refuses to clobber a different
|
|
142
153
|
// identity unless overwrite — the IdentityAlreadyExistsError propagates.
|
|
143
154
|
return await KeyManager.importKeyPair(payload.privateKey, {
|
|
@@ -420,6 +420,10 @@ export function OxyServicesUserMixin(Base) {
|
|
|
420
420
|
const result = await this.makeRequest('PATCH', `/privacy/${id}/privacy`, settings, {
|
|
421
421
|
cache: false,
|
|
422
422
|
});
|
|
423
|
+
this.clearCacheByPrefix('GET:/session/user/');
|
|
424
|
+
this.clearCacheByPrefix('GET:/users/me');
|
|
425
|
+
this.clearCacheByPrefix('GET:/profiles/username/');
|
|
426
|
+
this.clearCacheEntry(`GET:/users/${id}`);
|
|
423
427
|
this.clearCacheEntry(`GET:/privacy/${id}/privacy`);
|
|
424
428
|
return result;
|
|
425
429
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Returns a user-facing blocking reason when an approval payload must not be
|
|
3
|
+
* shown as actionable, or `null` when the request is still pending and valid.
|
|
4
|
+
*/
|
|
5
|
+
export function getCommonsApprovalBlockingReason(info) {
|
|
6
|
+
if (!info.application?.id) {
|
|
7
|
+
return 'The requesting application could not be resolved.';
|
|
8
|
+
}
|
|
9
|
+
if (info.status !== 'pending') {
|
|
10
|
+
return 'This sign-in request is invalid, already used, or expired.';
|
|
11
|
+
}
|
|
12
|
+
const expiresAtMs = parseCommonsApprovalExpiresAt(info.expiresAt);
|
|
13
|
+
if (expiresAtMs !== null && expiresAtMs < Date.now()) {
|
|
14
|
+
return 'This sign-in request has expired. Ask for a new QR code.';
|
|
15
|
+
}
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
/** Normalize API `expiresAt` (number or ISO string) to epoch ms. */
|
|
19
|
+
export function parseCommonsApprovalExpiresAt(expiresAt) {
|
|
20
|
+
if (typeof expiresAt === 'number' && Number.isFinite(expiresAt))
|
|
21
|
+
return expiresAt;
|
|
22
|
+
if (typeof expiresAt === 'string') {
|
|
23
|
+
const ms = Date.parse(expiresAt);
|
|
24
|
+
return Number.isFinite(ms) ? ms : null;
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
}
|