@oxy.so/core 1.9.1 → 1.11.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/README.md +4 -4
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/HttpService.js +56 -163
- package/dist/cjs/OxyServices.base.js +4 -2
- package/dist/cjs/boot/sessionColdBoot.js +3 -0
- package/dist/cjs/crypto/deviceBackup.js +75 -0
- package/dist/cjs/crypto/keyManager.js +285 -12
- package/dist/cjs/mixins/OxyServices.linkedAccounts.js +79 -0
- package/dist/cjs/mixins/OxyServices.user.js +7 -6
- package/dist/cjs/mixins/index.js +4 -0
- package/dist/cjs/server/cors.js +0 -1
- package/dist/cjs/server/securityHeaders.js +3 -0
- package/dist/cjs/session/refresh.js +32 -2
- package/dist/cjs/utils/errorUtils.js +1 -1
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/HttpService.js +56 -163
- package/dist/esm/OxyServices.base.js +4 -2
- package/dist/esm/boot/sessionColdBoot.js +3 -0
- package/dist/esm/crypto/deviceBackup.js +70 -0
- package/dist/esm/crypto/keyManager.js +252 -12
- package/dist/esm/mixins/OxyServices.linkedAccounts.js +76 -0
- package/dist/esm/mixins/OxyServices.user.js +7 -6
- package/dist/esm/mixins/index.js +4 -0
- package/dist/esm/server/cors.js +0 -1
- package/dist/esm/server/securityHeaders.js +3 -0
- package/dist/esm/session/refresh.js +32 -2
- package/dist/esm/utils/errorUtils.js +1 -1
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/HttpService.d.ts +31 -7
- package/dist/types/OxyServices.base.d.ts +3 -1
- package/dist/types/crypto/deviceBackup.d.ts +60 -0
- package/dist/types/crypto/keyManager.d.ts +79 -12
- package/dist/types/index.d.ts +1 -0
- package/dist/types/mixins/OxyServices.civic.d.ts +11 -1
- package/dist/types/mixins/OxyServices.linkedAccounts.d.ts +107 -0
- package/dist/types/mixins/OxyServices.user.d.ts +8 -6
- package/dist/types/mixins/index.d.ts +2 -1
- package/dist/types/models/interfaces.d.ts +30 -0
- package/dist/types/server/securityHeaders.d.ts +5 -0
- package/dist/types/session/refresh.d.ts +7 -0
- package/dist/types/utils/errorUtils.d.ts +1 -1
- package/package.json +2 -2
- package/src/HttpService.ts +59 -187
- package/src/OxyServices.base.ts +4 -2
- package/src/__tests__/httpServiceFormEncoded.test.ts +2 -5
- package/src/__tests__/{httpServiceCsrf.test.ts → httpServiceWrites.test.ts} +49 -109
- package/src/__tests__/inSessionRefresh.test.ts +53 -0
- package/src/__tests__/linkedClient.test.ts +0 -7
- package/src/boot/__tests__/sessionColdBoot.identity.test.ts +1 -1
- package/src/boot/__tests__/sessionColdBoot.sharedDevice.test.ts +1 -1
- package/src/boot/__tests__/sessionColdBoot.test.ts +1 -1
- package/src/boot/sessionColdBoot.ts +3 -0
- package/src/crypto/__tests__/keyManager.deviceBackup.test.ts +310 -0
- package/src/crypto/deviceBackup.ts +108 -0
- package/src/crypto/keyManager.ts +278 -13
- package/src/index.ts +1 -0
- package/src/mixins/OxyServices.linkedAccounts.ts +129 -0
- package/src/mixins/OxyServices.user.ts +9 -7
- package/src/mixins/__tests__/backgroundCredential.httpIntegration.test.ts +5 -10
- package/src/mixins/__tests__/discoveryErrorHandling.test.ts +3 -4
- package/src/mixins/__tests__/linkedAccounts.test.ts +109 -0
- package/src/mixins/__tests__/topics.test.ts +2 -3
- package/src/mixins/index.ts +6 -0
- package/src/models/interfaces.ts +30 -1
- package/src/server/__tests__/securityHeaders.test.ts +7 -0
- package/src/server/cors.ts +0 -1
- package/src/server/securityHeaders.ts +8 -0
- package/src/session/__tests__/refresh.identityPin.test.ts +1 -1
- package/src/session/__tests__/refresh.test.ts +115 -2
- package/src/session/refresh.ts +38 -2
- package/src/utils/errorUtils.ts +1 -1
package/src/crypto/keyManager.ts
CHANGED
|
@@ -26,6 +26,13 @@ import {
|
|
|
26
26
|
updateIdentityMarker,
|
|
27
27
|
writeIdentityMarker,
|
|
28
28
|
} from './identityMarker';
|
|
29
|
+
import {
|
|
30
|
+
DEVICE_BACKUP_VERSION,
|
|
31
|
+
type DeviceBackupRecord,
|
|
32
|
+
type IdentityDeviceBackupStore,
|
|
33
|
+
parseDeviceBackup,
|
|
34
|
+
serializeDeviceBackup,
|
|
35
|
+
} from './deviceBackup';
|
|
29
36
|
|
|
30
37
|
/**
|
|
31
38
|
* Options for expo-secure-store calls made by KeyManager.
|
|
@@ -138,9 +145,13 @@ export type IdentityStatus =
|
|
|
138
145
|
* which independent, `key_v1`-surviving source restored the identity. On failure
|
|
139
146
|
* `reason` distinguishes "wasn't lost", "no surviving source", "a source held a
|
|
140
147
|
* DIFFERENT account" (never silently switched), and "storage unavailable".
|
|
148
|
+
*
|
|
149
|
+
* `device-backup` is the copy held outside this app's keystore (Android Block
|
|
150
|
+
* Store in Commons, see {@link KeyManager.setDeviceBackupStore}); it is the only
|
|
151
|
+
* source that survives a wipe of the whole shared-UID keystore.
|
|
141
152
|
*/
|
|
142
153
|
export type IdentityRecoveryResult =
|
|
143
|
-
| { recovered: true; source: 'backup' | 'shared'; publicKey: string }
|
|
154
|
+
| { recovered: true; source: 'backup' | 'shared' | 'device-backup'; publicKey: string }
|
|
144
155
|
| { recovered: false; reason: 'not-lost' | 'no-sources' | 'mismatch' | 'unavailable' };
|
|
145
156
|
|
|
146
157
|
/**
|
|
@@ -393,6 +404,27 @@ export class KeyManager {
|
|
|
393
404
|
private static slotMigrationPromise: Promise<SlotMigrationResult> | null = null;
|
|
394
405
|
private static slotMigrationResult: SlotMigrationResult | null = null;
|
|
395
406
|
|
|
407
|
+
/**
|
|
408
|
+
* The keystore-independent device backup, registered by the app that owns the
|
|
409
|
+
* identity (Commons). `null` in every other app, which makes every device
|
|
410
|
+
* backup path a no-op there. See `./deviceBackup`.
|
|
411
|
+
*/
|
|
412
|
+
private static deviceBackupStore: IdentityDeviceBackupStore | null = null;
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Register (or, with `null`, unregister) where the identity's device backup is
|
|
416
|
+
* kept. Call once at startup, before the first identity read, ONLY from the
|
|
417
|
+
* app that holds the self-custody identity: the backup is a full copy of the
|
|
418
|
+
* private key, and it belongs to exactly one app.
|
|
419
|
+
*
|
|
420
|
+
* From then on every create, import, rotation and restore refreshes the
|
|
421
|
+
* backup, a forced delete clears it, and {@link attemptIdentityRecovery}
|
|
422
|
+
* restores from it when the keystore copies are gone.
|
|
423
|
+
*/
|
|
424
|
+
static setDeviceBackupStore(store: IdentityDeviceBackupStore | null): void {
|
|
425
|
+
KeyManager.deviceBackupStore = store;
|
|
426
|
+
}
|
|
427
|
+
|
|
396
428
|
/**
|
|
397
429
|
* Invalidate cached identity state
|
|
398
430
|
* Called internally when identity is created/deleted/imported
|
|
@@ -1524,6 +1556,149 @@ export class KeyManager {
|
|
|
1524
1556
|
// persist (a subsequent healthy read re-backfills it). Rollback paths above
|
|
1525
1557
|
// return before reaching here, so they never touch the marker.
|
|
1526
1558
|
await KeyManager._syncMarkerAfterPersist(canonicalPublic, origin);
|
|
1559
|
+
|
|
1560
|
+
// Mirror the durable identity into the keystore-independent device backup,
|
|
1561
|
+
// so a wipe of this UID's keystore can be undone without the phrase. Every
|
|
1562
|
+
// identity change (create, import, rotation, restore) flows through here.
|
|
1563
|
+
// Best-effort like the marker: `ensureDeviceBackup` retries on next launch.
|
|
1564
|
+
await KeyManager._writeDeviceBackup(canonicalPrivate, canonicalPublic);
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
/**
|
|
1568
|
+
* Write the device backup for `(privateKey, publicKey)`, keeping the phrase an
|
|
1569
|
+
* existing record already carries for the SAME identity. `mnemonic` replaces
|
|
1570
|
+
* it, and must already be verified against `publicKey` by the caller.
|
|
1571
|
+
* Best-effort: never throws, returns whether the store now holds this pair.
|
|
1572
|
+
*
|
|
1573
|
+
* @internal
|
|
1574
|
+
*/
|
|
1575
|
+
private static async _writeDeviceBackup(
|
|
1576
|
+
privateKey: string,
|
|
1577
|
+
publicKey: string,
|
|
1578
|
+
mnemonic?: string,
|
|
1579
|
+
): Promise<boolean> {
|
|
1580
|
+
const store = KeyManager.deviceBackupStore;
|
|
1581
|
+
if (!store) {
|
|
1582
|
+
return false;
|
|
1583
|
+
}
|
|
1584
|
+
try {
|
|
1585
|
+
let existing: DeviceBackupRecord | null = null;
|
|
1586
|
+
try {
|
|
1587
|
+
existing = parseDeviceBackup(await store.read());
|
|
1588
|
+
} catch {
|
|
1589
|
+
existing = null;
|
|
1590
|
+
}
|
|
1591
|
+
const sameIdentity = existing?.publicKey === publicKey;
|
|
1592
|
+
const keptMnemonic = mnemonic ?? (sameIdentity ? existing?.mnemonic : undefined);
|
|
1593
|
+
if (sameIdentity && existing?.privateKey === privateKey && existing?.mnemonic === keptMnemonic) {
|
|
1594
|
+
return true; // Already current; do not churn the store.
|
|
1595
|
+
}
|
|
1596
|
+
await store.write(
|
|
1597
|
+
serializeDeviceBackup({
|
|
1598
|
+
version: DEVICE_BACKUP_VERSION,
|
|
1599
|
+
privateKey,
|
|
1600
|
+
publicKey,
|
|
1601
|
+
mnemonic: keptMnemonic,
|
|
1602
|
+
updatedAt: new Date().toISOString(),
|
|
1603
|
+
}),
|
|
1604
|
+
);
|
|
1605
|
+
return true;
|
|
1606
|
+
} catch (error) {
|
|
1607
|
+
logger.warn(
|
|
1608
|
+
`Failed to write the identity device backup (${store.name}); retried on next launch`,
|
|
1609
|
+
{ component: 'KeyManager' },
|
|
1610
|
+
error,
|
|
1611
|
+
);
|
|
1612
|
+
return false;
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
/** Remove the device backup. Best-effort: never throws. @internal */
|
|
1617
|
+
private static async _clearDeviceBackup(): Promise<void> {
|
|
1618
|
+
const store = KeyManager.deviceBackupStore;
|
|
1619
|
+
if (!store) {
|
|
1620
|
+
return;
|
|
1621
|
+
}
|
|
1622
|
+
try {
|
|
1623
|
+
await store.clear();
|
|
1624
|
+
} catch (error) {
|
|
1625
|
+
logger.warn(`Failed to clear the identity device backup (${store.name})`, { component: 'KeyManager' }, error);
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
/** True when `phrase` is a valid BIP-39 phrase that derives `publicKey`. @internal */
|
|
1630
|
+
private static async _phraseDerivesPublicKey(phrase: string, publicKey: string): Promise<boolean> {
|
|
1631
|
+
try {
|
|
1632
|
+
// Imported lazily: `./recoveryPhrase` imports this module, and the phrase
|
|
1633
|
+
// derivation is frozen there, so it is reused rather than copied.
|
|
1634
|
+
const { RecoveryPhraseService } = await import('./recoveryPhrase');
|
|
1635
|
+
const derived = await RecoveryPhraseService.derivePublicKeyFromPhrase(phrase);
|
|
1636
|
+
return derived.toLowerCase() === publicKey.toLowerCase();
|
|
1637
|
+
} catch {
|
|
1638
|
+
return false;
|
|
1639
|
+
}
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
/** The primary pair when it reads back healthy, else `null`. Never throws. @internal */
|
|
1643
|
+
private static async _readHealthyPrimary(): Promise<{ privateKey: string; publicKey: string } | null> {
|
|
1644
|
+
try {
|
|
1645
|
+
const direct = await KeyManager._readPrimaryDirect();
|
|
1646
|
+
if (KeyManager._isHealthyPair(direct.privateKey, direct.publicKey) && direct.privateKey && direct.publicKey) {
|
|
1647
|
+
return {
|
|
1648
|
+
privateKey: KeyManager.canonicalPrivateKey(direct.privateKey),
|
|
1649
|
+
publicKey: direct.publicKey.toLowerCase(),
|
|
1650
|
+
};
|
|
1651
|
+
}
|
|
1652
|
+
return null;
|
|
1653
|
+
} catch {
|
|
1654
|
+
return null;
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
/**
|
|
1659
|
+
* Make sure the device backup holds the identity on this device, writing it
|
|
1660
|
+
* when it is missing or stale. Commons calls this once per launch once the
|
|
1661
|
+
* identity reads back healthy: it backfills identities created before the
|
|
1662
|
+
* device backup existed, and repairs a backup whose last write failed (a
|
|
1663
|
+
* failed write after a key rotation would otherwise leave the OLD key there).
|
|
1664
|
+
*
|
|
1665
|
+
* Carries the stored recovery phrase along only when it provably derives the
|
|
1666
|
+
* current key. Resolves `true` when the backup is current, `false` when there
|
|
1667
|
+
* is no store, no healthy identity, or the write failed. Never throws.
|
|
1668
|
+
*/
|
|
1669
|
+
static async ensureDeviceBackup(): Promise<boolean> {
|
|
1670
|
+
if (isWebPlatform() || !KeyManager.deviceBackupStore) {
|
|
1671
|
+
return false;
|
|
1672
|
+
}
|
|
1673
|
+
const primary = await KeyManager._readHealthyPrimary();
|
|
1674
|
+
if (!primary) {
|
|
1675
|
+
return false;
|
|
1676
|
+
}
|
|
1677
|
+
let existing: DeviceBackupRecord | null = null;
|
|
1678
|
+
try {
|
|
1679
|
+
existing = parseDeviceBackup(await KeyManager.deviceBackupStore.read());
|
|
1680
|
+
} catch {
|
|
1681
|
+
existing = null;
|
|
1682
|
+
}
|
|
1683
|
+
const pairCurrent =
|
|
1684
|
+
existing?.publicKey === primary.publicKey && existing?.privateKey === primary.privateKey;
|
|
1685
|
+
if (pairCurrent && existing?.mnemonic) {
|
|
1686
|
+
return true; // The common launch: one read, no write, no phrase derivation.
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
let mnemonic: string | undefined;
|
|
1690
|
+
try {
|
|
1691
|
+
const stored = await KeyManager.getRecoveryMnemonic();
|
|
1692
|
+
if (stored && (await KeyManager._phraseDerivesPublicKey(stored, primary.publicKey))) {
|
|
1693
|
+
mnemonic = stored;
|
|
1694
|
+
}
|
|
1695
|
+
} catch {
|
|
1696
|
+
mnemonic = undefined;
|
|
1697
|
+
}
|
|
1698
|
+
if (pairCurrent && !mnemonic) {
|
|
1699
|
+
return true;
|
|
1700
|
+
}
|
|
1701
|
+
return KeyManager._writeDeviceBackup(primary.privateKey, primary.publicKey, mnemonic);
|
|
1527
1702
|
}
|
|
1528
1703
|
|
|
1529
1704
|
/**
|
|
@@ -1866,6 +2041,16 @@ export class KeyManager {
|
|
|
1866
2041
|
}
|
|
1867
2042
|
throw new IdentityUnavailableError('Failed to persist recovery mnemonic.', error);
|
|
1868
2043
|
}
|
|
2044
|
+
|
|
2045
|
+
// The phrase slot above dies with the keystore too, so the device backup
|
|
2046
|
+
// carries the phrase as well — but only one that derives the identity on
|
|
2047
|
+
// this device. Best-effort: never fails the store.
|
|
2048
|
+
if (KeyManager.deviceBackupStore) {
|
|
2049
|
+
const primary = await KeyManager._readHealthyPrimary();
|
|
2050
|
+
if (primary && (await KeyManager._phraseDerivesPublicKey(mnemonic, primary.publicKey))) {
|
|
2051
|
+
await KeyManager._writeDeviceBackup(primary.privateKey, primary.publicKey, mnemonic.trim().toLowerCase());
|
|
2052
|
+
}
|
|
2053
|
+
}
|
|
1869
2054
|
}
|
|
1870
2055
|
|
|
1871
2056
|
/**
|
|
@@ -2165,6 +2350,11 @@ export class KeyManager {
|
|
|
2165
2350
|
await KeyManager._clearSharedSlot(store);
|
|
2166
2351
|
}
|
|
2167
2352
|
|
|
2353
|
+
// The device backup goes on EVERY delete, forced or not: it is restored
|
|
2354
|
+
// silently, so leaving it would bring a deleted identity back on the next
|
|
2355
|
+
// launch. Best-effort, like the other recovery sources.
|
|
2356
|
+
await KeyManager._clearDeviceBackup();
|
|
2357
|
+
|
|
2168
2358
|
// Clear the marker AFTER key deletion succeeds — a marker must never outlive
|
|
2169
2359
|
// its identity (a leftover marker would route a truly-absent device to
|
|
2170
2360
|
// `recovery` instead of `welcome`).
|
|
@@ -2416,19 +2606,29 @@ export class KeyManager {
|
|
|
2416
2606
|
}
|
|
2417
2607
|
|
|
2418
2608
|
/**
|
|
2419
|
-
* Recovery ladder — restore
|
|
2420
|
-
*
|
|
2421
|
-
*
|
|
2422
|
-
* Gated on {@link getIdentityStatus} being `lost` (marker present, keys empty):
|
|
2423
|
-
* - `present` / `absent` → `not-lost` (nothing to recover / nothing lost)
|
|
2424
|
-
* - `unavailable` → `unavailable` (keychain locked; retry later)
|
|
2609
|
+
* Recovery ladder — restore an identity whose keys are gone from an
|
|
2610
|
+
* independent source WITHOUT the user re-entering their recovery phrase.
|
|
2425
2611
|
*
|
|
2426
|
-
*
|
|
2427
|
-
*
|
|
2428
|
-
* SKIPPED, never
|
|
2429
|
-
*
|
|
2612
|
+
* For a `lost` verdict (marker present, keys empty) the rungs are tried in
|
|
2613
|
+
* order, each fully validated (well-formed + derive-match + `publicKey ===
|
|
2614
|
+
* marker.publicKey`, so a source holding a DIFFERENT account is SKIPPED, never
|
|
2615
|
+
* restored):
|
|
2616
|
+
* 1. the v2 backup slot (independent keychain key from the primary),
|
|
2430
2617
|
* 2. the cross-app shared slot (Android bridge `getShared` / iOS keychain
|
|
2431
|
-
* group) — the copy that survives a primary+backup `key_v1` death
|
|
2618
|
+
* group) — the copy that survives a primary+backup `key_v1` death,
|
|
2619
|
+
* 3. the device backup ({@link setDeviceBackupStore}; Android Block Store in
|
|
2620
|
+
* Commons) — the only copy that survives a wipe of the whole shared-UID
|
|
2621
|
+
* Android Keystore, which takes rungs 1 and 2 with it (oxy#1388).
|
|
2622
|
+
*
|
|
2623
|
+
* For an `absent` verdict (no keys AND no marker: this app's own data was
|
|
2624
|
+
* cleared, or it was reinstalled) only rung 3 applies: a device backup there
|
|
2625
|
+
* means an identity lived on this device and was never deleted (every delete
|
|
2626
|
+
* clears the backup), so it is restored instead of letting onboarding create a
|
|
2627
|
+
* new identity over it. Without a registered store the result stays
|
|
2628
|
+
* `not-lost`, exactly as before.
|
|
2629
|
+
*
|
|
2630
|
+
* `present` → `not-lost`; `unavailable` (keychain locked) → `unavailable`.
|
|
2631
|
+
* Everything here is on-device: no network, so it works offline.
|
|
2432
2632
|
*
|
|
2433
2633
|
* On success it re-persists via {@link _persistIdentityAtomic} (origin
|
|
2434
2634
|
* `'restore'`) and invalidates the cache so routing re-reads `present`. When no
|
|
@@ -2440,12 +2640,24 @@ export class KeyManager {
|
|
|
2440
2640
|
}
|
|
2441
2641
|
|
|
2442
2642
|
const status = await KeyManager.getIdentityStatus({ bypassCache: true });
|
|
2443
|
-
if (status.state === 'present'
|
|
2643
|
+
if (status.state === 'present') {
|
|
2444
2644
|
return { recovered: false, reason: 'not-lost' };
|
|
2445
2645
|
}
|
|
2446
2646
|
if (status.state === 'unavailable') {
|
|
2447
2647
|
return { recovered: false, reason: 'unavailable' };
|
|
2448
2648
|
}
|
|
2649
|
+
if (status.state === 'absent') {
|
|
2650
|
+
if (!KeyManager.deviceBackupStore) {
|
|
2651
|
+
return { recovered: false, reason: 'not-lost' };
|
|
2652
|
+
}
|
|
2653
|
+
const candidate = await KeyManager._readDeviceBackupCandidate();
|
|
2654
|
+
if (!candidate) {
|
|
2655
|
+
return { recovered: false, reason: 'not-lost' };
|
|
2656
|
+
}
|
|
2657
|
+
return (await KeyManager._commitDeviceBackupRecovery(candidate))
|
|
2658
|
+
? { recovered: true, source: 'device-backup', publicKey: candidate.publicKey }
|
|
2659
|
+
: { recovered: false, reason: 'no-sources' };
|
|
2660
|
+
}
|
|
2449
2661
|
|
|
2450
2662
|
// status.state === 'lost'
|
|
2451
2663
|
const expectedPublic = status.marker.publicKey.toLowerCase();
|
|
@@ -2475,11 +2687,64 @@ export class KeyManager {
|
|
|
2475
2687
|
}
|
|
2476
2688
|
}
|
|
2477
2689
|
|
|
2690
|
+
// Rung 3: device backup, outside this UID's keystore.
|
|
2691
|
+
const deviceCandidate = await KeyManager._readDeviceBackupCandidate();
|
|
2692
|
+
if (deviceCandidate) {
|
|
2693
|
+
if (deviceCandidate.publicKey === expectedPublic) {
|
|
2694
|
+
if (await KeyManager._commitDeviceBackupRecovery(deviceCandidate)) {
|
|
2695
|
+
return { recovered: true, source: 'device-backup', publicKey: deviceCandidate.publicKey };
|
|
2696
|
+
}
|
|
2697
|
+
} else {
|
|
2698
|
+
sawMismatch = true;
|
|
2699
|
+
}
|
|
2700
|
+
}
|
|
2701
|
+
|
|
2478
2702
|
// A source existed but identified a DIFFERENT account — never silently
|
|
2479
2703
|
// switched. Report `mismatch` so the UI can require explicit confirmation.
|
|
2480
2704
|
return { recovered: false, reason: sawMismatch ? 'mismatch' : 'no-sources' };
|
|
2481
2705
|
}
|
|
2482
2706
|
|
|
2707
|
+
/** Read the device backup as a healthy, canonical candidate, or null. Never throws. @internal */
|
|
2708
|
+
private static async _readDeviceBackupCandidate(): Promise<DeviceBackupRecord | null> {
|
|
2709
|
+
const store = KeyManager.deviceBackupStore;
|
|
2710
|
+
if (!store) {
|
|
2711
|
+
return null;
|
|
2712
|
+
}
|
|
2713
|
+
try {
|
|
2714
|
+
const record = parseDeviceBackup(await store.read());
|
|
2715
|
+
if (!record || !KeyManager._isHealthyPair(record.privateKey, record.publicKey)) {
|
|
2716
|
+
return null;
|
|
2717
|
+
}
|
|
2718
|
+
return {
|
|
2719
|
+
...record,
|
|
2720
|
+
privateKey: KeyManager.canonicalPrivateKey(record.privateKey),
|
|
2721
|
+
publicKey: record.publicKey.toLowerCase(),
|
|
2722
|
+
};
|
|
2723
|
+
} catch (error) {
|
|
2724
|
+
logger.warn(`Recovery: failed to read the device backup (${store.name})`, { component: 'KeyManager' }, error);
|
|
2725
|
+
return null;
|
|
2726
|
+
}
|
|
2727
|
+
}
|
|
2728
|
+
|
|
2729
|
+
/**
|
|
2730
|
+
* Persist a device-backup candidate, then put its phrase back in the
|
|
2731
|
+
* re-reveal slot (which died with the keystore) when it derives the key.
|
|
2732
|
+
* @internal
|
|
2733
|
+
*/
|
|
2734
|
+
private static async _commitDeviceBackupRecovery(record: DeviceBackupRecord): Promise<boolean> {
|
|
2735
|
+
if (!(await KeyManager._commitRecovery(record.privateKey, record.publicKey))) {
|
|
2736
|
+
return false;
|
|
2737
|
+
}
|
|
2738
|
+
if (record.mnemonic && (await KeyManager._phraseDerivesPublicKey(record.mnemonic, record.publicKey))) {
|
|
2739
|
+
try {
|
|
2740
|
+
await KeyManager.storeRecoveryMnemonic(record.mnemonic);
|
|
2741
|
+
} catch (error) {
|
|
2742
|
+
logger.warn('Recovery: restored the identity but not its phrase slot', { component: 'KeyManager' }, error);
|
|
2743
|
+
}
|
|
2744
|
+
}
|
|
2745
|
+
return true;
|
|
2746
|
+
}
|
|
2747
|
+
|
|
2483
2748
|
/** Read the active-layout backup slot as a healthy candidate, or null. @internal */
|
|
2484
2749
|
private static async _readBackupCandidate(): Promise<{ privateKey: string; publicKey: string } | null> {
|
|
2485
2750
|
try {
|
package/src/index.ts
CHANGED
|
@@ -320,6 +320,7 @@ export {
|
|
|
320
320
|
updateIdentityMarker,
|
|
321
321
|
} from './crypto/identityMarker';
|
|
322
322
|
export type { IdentityMarker } from './crypto/identityMarker';
|
|
323
|
+
export type { IdentityDeviceBackupStore } from './crypto/deviceBackup';
|
|
323
324
|
export { SignatureService } from './crypto/signatureService';
|
|
324
325
|
export type { SignedMessage, AuthChallenge } from './crypto/signatureService';
|
|
325
326
|
export { RecoveryPhraseService } from './crypto/recoveryPhrase';
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Linked Accounts Methods Mixin
|
|
3
|
+
*
|
|
4
|
+
* External accounts (any Mastodon-API server, Bluesky) the current user has
|
|
5
|
+
* PROVEN they own by completing an OAuth authorization there. Oxy uses that
|
|
6
|
+
* authorization only to learn which account granted it and then discards the
|
|
7
|
+
* token — nothing here ever carries a third-party token.
|
|
8
|
+
*
|
|
9
|
+
* The flow is a top-level browser navigation: {@link startLinkedAccount} returns
|
|
10
|
+
* the other network's `authorizeUrl`; open it (a new tab, or
|
|
11
|
+
* `expo-web-browser`'s auth session on native); the network sends the browser
|
|
12
|
+
* to Oxy's callback, and Oxy sends it to `returnTo` with `?link_code=<code>` or
|
|
13
|
+
* `?link_error=<code>`. Pass the code to {@link completeLinkedAccount} with the
|
|
14
|
+
* same user signed in: that call, not the callback, creates the link, so an
|
|
15
|
+
* authorization someone else started can never link to them. `returnTo` must
|
|
16
|
+
* be a redirect URI registered on the first-party application `clientId` names.
|
|
17
|
+
*
|
|
18
|
+
* Wire types live in `@oxy.so/contracts` (`linkedAccounts.ts`).
|
|
19
|
+
*/
|
|
20
|
+
import type {
|
|
21
|
+
CompleteLinkedAccountResponse,
|
|
22
|
+
LinkedAccount,
|
|
23
|
+
LinkedAccountListResponse,
|
|
24
|
+
LinkedAccountNetwork,
|
|
25
|
+
ServiceLinkedAccountListResponse,
|
|
26
|
+
StartLinkedAccountRequest,
|
|
27
|
+
StartLinkedAccountResponse,
|
|
28
|
+
} from '@oxy.so/contracts';
|
|
29
|
+
import type { OxyServicesBase } from '../OxyServices.base';
|
|
30
|
+
|
|
31
|
+
export function OxyServicesLinkedAccountsMixin<T extends typeof OxyServicesBase>(Base: T) {
|
|
32
|
+
return class extends Base {
|
|
33
|
+
/** Inherited from the auth mixin at runtime. */
|
|
34
|
+
declare makeServiceRequest: <R = unknown>(
|
|
35
|
+
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE',
|
|
36
|
+
url: string,
|
|
37
|
+
data?: unknown,
|
|
38
|
+
userId?: string,
|
|
39
|
+
) => Promise<R>;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Begin linking an external account for the current user.
|
|
43
|
+
*
|
|
44
|
+
* @param network - `'activitypub'` (any Mastodon-API server) or `'atproto'` (Bluesky).
|
|
45
|
+
* @param options - `instance` for ActivityPub (`mastodon.social`, or
|
|
46
|
+
* `@user@mastodon.social`), `handle` for atproto; `clientId` + `returnTo`
|
|
47
|
+
* to be sent back to your app afterwards.
|
|
48
|
+
* @returns The URL to open, and when the attempt expires (ten minutes).
|
|
49
|
+
*/
|
|
50
|
+
async startLinkedAccount(
|
|
51
|
+
network: LinkedAccountNetwork,
|
|
52
|
+
options: StartLinkedAccountRequest,
|
|
53
|
+
): Promise<StartLinkedAccountResponse> {
|
|
54
|
+
try {
|
|
55
|
+
return await this.makeRequest<StartLinkedAccountResponse>(
|
|
56
|
+
'POST',
|
|
57
|
+
`/linked-accounts/${encodeURIComponent(network)}/start`,
|
|
58
|
+
options,
|
|
59
|
+
{ cache: false },
|
|
60
|
+
);
|
|
61
|
+
} catch (error) {
|
|
62
|
+
throw this.handleError(error);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Finish a link with the `link_code` the callback appended to `returnTo`.
|
|
68
|
+
* Must be the user who started the flow: for anyone else the API answers
|
|
69
|
+
* 403 and burns the code. 409 when the account is already someone else's
|
|
70
|
+
* link. Repeating it within the code's five minutes returns the same link.
|
|
71
|
+
*/
|
|
72
|
+
async completeLinkedAccount(code: string): Promise<LinkedAccount> {
|
|
73
|
+
try {
|
|
74
|
+
const response = await this.makeRequest<CompleteLinkedAccountResponse>(
|
|
75
|
+
'POST',
|
|
76
|
+
'/linked-accounts/complete',
|
|
77
|
+
{ code },
|
|
78
|
+
{ cache: false },
|
|
79
|
+
);
|
|
80
|
+
return response.linkedAccount;
|
|
81
|
+
} catch (error) {
|
|
82
|
+
throw this.handleError(error);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The current user's live linked accounts, oldest first. */
|
|
87
|
+
async listLinkedAccounts(): Promise<LinkedAccount[]> {
|
|
88
|
+
try {
|
|
89
|
+
const response = await this.makeRequest<LinkedAccountListResponse>('GET', '/linked-accounts', undefined, {
|
|
90
|
+
cache: false,
|
|
91
|
+
});
|
|
92
|
+
return response.linkedAccounts;
|
|
93
|
+
} catch (error) {
|
|
94
|
+
throw this.handleError(error);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Unlink one of the current user's accounts. An ActivityPub link stops being
|
|
100
|
+
* published as an `alsoKnownAs` alias, and the external account can then be
|
|
101
|
+
* linked to another Oxy account.
|
|
102
|
+
*/
|
|
103
|
+
async revokeLinkedAccount(linkedAccountId: string): Promise<void> {
|
|
104
|
+
try {
|
|
105
|
+
await this.makeRequest<void>('DELETE', `/linked-accounts/${encodeURIComponent(linkedAccountId)}`, undefined, {
|
|
106
|
+
cache: false,
|
|
107
|
+
});
|
|
108
|
+
} catch (error) {
|
|
109
|
+
throw this.handleError(error);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* SERVICE: a user's live linked accounts, each with the federated shadow
|
|
115
|
+
* user Oxy already holds for it (`federatedUserId`). Requires a service
|
|
116
|
+
* credential whose application holds the privileged `linked-accounts:read`.
|
|
117
|
+
*/
|
|
118
|
+
async getLinkedAccountsForUser(userId: string): Promise<ServiceLinkedAccountListResponse> {
|
|
119
|
+
try {
|
|
120
|
+
return await this.makeServiceRequest<ServiceLinkedAccountListResponse>(
|
|
121
|
+
'GET',
|
|
122
|
+
`/linked-accounts/by-user/${encodeURIComponent(userId)}`,
|
|
123
|
+
);
|
|
124
|
+
} catch (error) {
|
|
125
|
+
throw this.handleError(error);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* User Management Methods Mixin
|
|
3
3
|
*/
|
|
4
|
+
import type { CreateOxyNotificationRequest } from '@oxy.so/contracts';
|
|
4
5
|
import type {
|
|
5
6
|
User,
|
|
6
7
|
Notification,
|
|
@@ -470,10 +471,7 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
470
471
|
* `PostHydrationService`) and is unchanged.
|
|
471
472
|
* - **Plain client (browser / React Native with a user session):** when no
|
|
472
473
|
* service credentials are configured, the chunk is fetched via
|
|
473
|
-
* `makeRequest`, which attaches the configured user bearer.
|
|
474
|
-
* middleware skips bearer-authenticated writes, and `makeRequest` only
|
|
475
|
-
* fetches a CSRF token for cookie-only (no-bearer) state-changing requests,
|
|
476
|
-
* so the user-bearer POST is sent without CSRF and succeeds. Previously
|
|
474
|
+
* `makeRequest`, which attaches the configured user bearer. Previously
|
|
477
475
|
* this method always used the service path, so every client-side caller
|
|
478
476
|
* silently received `[]` because `getServiceToken()` had no credentials.
|
|
479
477
|
*
|
|
@@ -504,7 +502,7 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
504
502
|
|
|
505
503
|
// A backend that called configureServiceAuth() uses the bearer-service
|
|
506
504
|
// path; any other caller (browser / RN with a user session) uses the
|
|
507
|
-
// user-bearer path.
|
|
505
|
+
// user-bearer path.
|
|
508
506
|
const useServiceAuth = Boolean(this._serviceApiKey && this._serviceApiSecret);
|
|
509
507
|
|
|
510
508
|
// Run chunks concurrently; a single chunk failure must not sink the rest.
|
|
@@ -1102,9 +1100,13 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
1102
1100
|
}
|
|
1103
1101
|
|
|
1104
1102
|
/**
|
|
1105
|
-
* Create notification
|
|
1103
|
+
* Create a notification (`POST /notifications`; the API requires a service
|
|
1104
|
+
* token whose application holds `notifications:write`). `type` is one of
|
|
1105
|
+
* `OXY_NOTIFICATION_TYPES` from `@oxy.so/contracts` — `system` for a message
|
|
1106
|
+
* from an Oxy service about the recipient's own account, with the recipient
|
|
1107
|
+
* as actor and their profile as the entity.
|
|
1106
1108
|
*/
|
|
1107
|
-
async createNotification(data: Partial<Notification>): Promise<Notification> {
|
|
1109
|
+
async createNotification(data: CreateOxyNotificationRequest | Partial<Notification>): Promise<Notification> {
|
|
1108
1110
|
try {
|
|
1109
1111
|
return await this.makeRequest<Notification>('POST', '/notifications', data, { cache: false });
|
|
1110
1112
|
} catch (error) {
|
|
@@ -22,11 +22,9 @@ import { OxyServices } from '../../OxyServices';
|
|
|
22
22
|
* hand-built `Object.assign(new Error(), { status: 404 })` proves only that
|
|
23
23
|
* the branch reads the shape the test itself invented.
|
|
24
24
|
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* makes the CSRF fetch burn its own retries — so a naive call-count assertion
|
|
29
|
-
* measures CSRF attempts rather than the route.
|
|
25
|
+
* Assertions select calls by URL rather than position, so a preflight added
|
|
26
|
+
* to the real stack later cannot make a call-count assertion measure it
|
|
27
|
+
* instead of the route.
|
|
30
28
|
*/
|
|
31
29
|
const ROUTE = '/session/device/background-credential';
|
|
32
30
|
|
|
@@ -67,19 +65,16 @@ const ACCESS_TOKEN = (() => {
|
|
|
67
65
|
describe('provisionBackgroundCredential over a real HttpService', () => {
|
|
68
66
|
const originalFetch = global.fetch;
|
|
69
67
|
|
|
70
|
-
/** Answers
|
|
68
|
+
/** Answers every call with `body`/`status`. */
|
|
71
69
|
const stubFetch = (body: unknown, status: number) => {
|
|
72
70
|
const fetchMock = jest.fn(async (input: unknown) => {
|
|
73
|
-
if (String(input).includes('/csrf-token')) {
|
|
74
|
-
return jsonResponse({ csrfToken: 'csrf-test-token' }, 200);
|
|
75
|
-
}
|
|
76
71
|
return jsonResponse(body, status);
|
|
77
72
|
});
|
|
78
73
|
global.fetch = fetchMock as unknown as typeof fetch;
|
|
79
74
|
return fetchMock;
|
|
80
75
|
};
|
|
81
76
|
|
|
82
|
-
/** Every stubbed call whose URL is the route under test
|
|
77
|
+
/** Every stubbed call whose URL is the route under test. */
|
|
83
78
|
const routeCalls = (fetchMock: jest.Mock) =>
|
|
84
79
|
fetchMock.mock.calls.filter(([input]) => String(input).includes(ROUTE));
|
|
85
80
|
|
|
@@ -27,7 +27,7 @@ function jsonResponse(data: unknown): Response {
|
|
|
27
27
|
* Build a non-verified JWT whose payload decodes to the given claims.
|
|
28
28
|
* `jwtDecode` only base64url-decodes the middle segment (no signature check).
|
|
29
29
|
* Used to put the SDK in an authenticated state so a state-changing POST
|
|
30
|
-
* carries a bearer header
|
|
30
|
+
* carries a bearer header.
|
|
31
31
|
*/
|
|
32
32
|
function makeJwt(payload: Record<string, unknown>): string {
|
|
33
33
|
const b64url = (obj: Record<string, unknown>): string =>
|
|
@@ -142,9 +142,8 @@ describe('discovery error handling', () => {
|
|
|
142
142
|
* `POST /profiles/recommendations` with the validated request body.
|
|
143
143
|
*/
|
|
144
144
|
describe('getProfileRecommendations routing (GET vs POST)', () => {
|
|
145
|
-
// Authenticate so the state-changing POST carries a bearer header
|
|
146
|
-
//
|
|
147
|
-
// A bearer token does not affect GET routing or the request body, so the
|
|
145
|
+
// Authenticate so the state-changing POST carries a bearer header. A bearer
|
|
146
|
+
// token does not affect GET routing or the request body, so the
|
|
148
147
|
// GET-path assertions below are unaffected.
|
|
149
148
|
beforeEach(() => {
|
|
150
149
|
oxy.httpService.setTokens(makeJwt({ userId: 'me' }));
|