@oxy.so/core 1.10.0 → 1.12.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/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 +5 -1
- package/dist/cjs/mixins/index.js +4 -0
- package/dist/cjs/server/securityHeaders.js +3 -0
- package/dist/cjs/session/accountDialogController.js +41 -0
- package/dist/esm/.tsbuildinfo +1 -1
- 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 +5 -1
- package/dist/esm/mixins/index.js +4 -0
- package/dist/esm/server/securityHeaders.js +3 -0
- package/dist/esm/session/accountDialogController.js +41 -0
- package/dist/types/.tsbuildinfo +1 -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 +2 -1
- package/dist/types/mixins/OxyServices.linkedAccounts.d.ts +107 -0
- package/dist/types/mixins/OxyServices.user.d.ts +7 -2
- 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/accountDialogController.d.ts +45 -0
- package/package.json +2 -2
- 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 +2 -0
- package/src/mixins/OxyServices.linkedAccounts.ts +129 -0
- package/src/mixins/OxyServices.user.ts +7 -2
- package/src/mixins/__tests__/linkedAccounts.test.ts +109 -0
- 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/securityHeaders.ts +8 -0
- package/src/session/__tests__/accountDialogController.test.ts +173 -0
- package/src/session/__tests__/accountDialogShape.test.ts +1 -0
- package/src/session/accountDialogController.ts +65 -0
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';
|
|
@@ -818,6 +819,7 @@ export type {
|
|
|
818
819
|
AccountDialogSnapshot,
|
|
819
820
|
AccountDialogView,
|
|
820
821
|
CommonsAvailability,
|
|
822
|
+
ContextChoiceOutcome,
|
|
821
823
|
PopupWindowHandle,
|
|
822
824
|
SignInFailureReason,
|
|
823
825
|
SignInFlowPhase,
|
|
@@ -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,
|
|
@@ -1099,9 +1100,13 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
1099
1100
|
}
|
|
1100
1101
|
|
|
1101
1102
|
/**
|
|
1102
|
-
* 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.
|
|
1103
1108
|
*/
|
|
1104
|
-
async createNotification(data: Partial<Notification>): Promise<Notification> {
|
|
1109
|
+
async createNotification(data: CreateOxyNotificationRequest | Partial<Notification>): Promise<Notification> {
|
|
1105
1110
|
try {
|
|
1106
1111
|
return await this.makeRequest<Notification>('POST', '/notifications', data, { cache: false });
|
|
1107
1112
|
} catch (error) {
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Linked-accounts SDK surface: the paths, methods and bodies it sends, and the
|
|
3
|
+
* `{ data }` unwrapping. The flow itself is exercised in the API's route tests.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { OxyServices } from '../../OxyServices';
|
|
7
|
+
|
|
8
|
+
function makeJwt(payload: Record<string, unknown>): string {
|
|
9
|
+
const b64url = (obj: Record<string, unknown>): string => Buffer.from(JSON.stringify(obj)).toString('base64url');
|
|
10
|
+
const fullPayload = { exp: Math.floor(Date.now() / 1000) + 3600, ...payload };
|
|
11
|
+
return `${b64url({ alg: 'none', typ: 'JWT' })}.${b64url(fullPayload)}.sig`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function jsonResponse(data: unknown, status = 200): Response {
|
|
15
|
+
return new Response(status === 204 ? null : JSON.stringify({ data }), {
|
|
16
|
+
status,
|
|
17
|
+
headers: { 'content-type': 'application/json' },
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const LINK = {
|
|
22
|
+
id: 'link-1',
|
|
23
|
+
network: 'activitypub' as const,
|
|
24
|
+
accountKey: 'nate@mastodon.social',
|
|
25
|
+
actorUri: 'https://mastodon.social/users/nate',
|
|
26
|
+
handle: '@nate@mastodon.social',
|
|
27
|
+
host: 'mastodon.social',
|
|
28
|
+
proofMethod: 'oauth' as const,
|
|
29
|
+
verifiedAt: '2026-09-25T00:00:00.000Z',
|
|
30
|
+
createdAt: '2026-09-25T00:00:00.000Z',
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
describe('linked accounts', () => {
|
|
34
|
+
let originalFetch: typeof globalThis.fetch;
|
|
35
|
+
let fetchMock: jest.Mock<Promise<Response>, [RequestInfo | URL, RequestInit?]>;
|
|
36
|
+
let oxy: OxyServices;
|
|
37
|
+
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
originalFetch = globalThis.fetch;
|
|
40
|
+
fetchMock = jest.fn();
|
|
41
|
+
globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
|
|
42
|
+
oxy = new OxyServices({ baseURL: 'http://test.invalid' });
|
|
43
|
+
oxy.httpService.setTokens(makeJwt({ userId: 'me' }));
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
globalThis.fetch = originalFetch;
|
|
48
|
+
jest.clearAllMocks();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('starts a link with POST /linked-accounts/:network/start and returns the authorize URL', async () => {
|
|
52
|
+
fetchMock.mockResolvedValueOnce(
|
|
53
|
+
jsonResponse({ authorizeUrl: 'https://mastodon.social/oauth/authorize?x=1', expiresAt: '2026-09-25T00:10:00.000Z' }),
|
|
54
|
+
);
|
|
55
|
+
const started = await oxy.startLinkedAccount('activitypub', {
|
|
56
|
+
instance: 'mastodon.social',
|
|
57
|
+
clientId: 'oxy_dk_move',
|
|
58
|
+
returnTo: 'https://move.oxy.so/linked',
|
|
59
|
+
});
|
|
60
|
+
expect(started.authorizeUrl).toBe('https://mastodon.social/oauth/authorize?x=1');
|
|
61
|
+
const [url, init] = fetchMock.mock.calls[0];
|
|
62
|
+
expect(String(url)).toBe('http://test.invalid/linked-accounts/activitypub/start');
|
|
63
|
+
expect(init?.method).toBe('POST');
|
|
64
|
+
expect(JSON.parse(String(init?.body))).toEqual({
|
|
65
|
+
instance: 'mastodon.social',
|
|
66
|
+
clientId: 'oxy_dk_move',
|
|
67
|
+
returnTo: 'https://move.oxy.so/linked',
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('completes a link with POST /linked-accounts/complete and returns the link', async () => {
|
|
72
|
+
fetchMock.mockImplementation(async (url) =>
|
|
73
|
+
String(url).endsWith('/linked-accounts/complete') ? jsonResponse({ linkedAccount: LINK }) : jsonResponse({ csrfToken: 't'.repeat(32) }),
|
|
74
|
+
);
|
|
75
|
+
expect(await oxy.completeLinkedAccount('one-time-code')).toEqual(LINK);
|
|
76
|
+
const completion = fetchMock.mock.calls.find(([url]) => String(url).endsWith('/linked-accounts/complete'));
|
|
77
|
+
expect(completion?.[1]?.method).toBe('POST');
|
|
78
|
+
expect(JSON.parse(String(completion?.[1]?.body))).toEqual({ code: 'one-time-code' });
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('lists the live links, unwrapping the envelope, without caching', async () => {
|
|
82
|
+
fetchMock.mockImplementation(async () => jsonResponse({ linkedAccounts: [LINK] }));
|
|
83
|
+
expect(await oxy.listLinkedAccounts()).toEqual([LINK]);
|
|
84
|
+
await oxy.listLinkedAccounts();
|
|
85
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
86
|
+
expect(String(fetchMock.mock.calls[0][0])).toBe('http://test.invalid/linked-accounts');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('revokes with DELETE /linked-accounts/:id', async () => {
|
|
90
|
+
// A state-changing request may first fetch a CSRF token; answer anything.
|
|
91
|
+
fetchMock.mockImplementation(async (_url, init) =>
|
|
92
|
+
init?.method === 'DELETE' ? new Response(null, { status: 204 }) : jsonResponse({ csrfToken: 't'.repeat(32) }),
|
|
93
|
+
);
|
|
94
|
+
await oxy.revokeLinkedAccount('link-1');
|
|
95
|
+
const deletion = fetchMock.mock.calls.find(([, init]) => init?.method === 'DELETE');
|
|
96
|
+
expect(String(deletion?.[0])).toBe('http://test.invalid/linked-accounts/link-1');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('reads a user\'s links on the service lane with a service token', async () => {
|
|
100
|
+
const service = new OxyServices({ baseURL: 'http://test.invalid' });
|
|
101
|
+
jest.spyOn(service, 'getServiceToken').mockResolvedValue('service-token');
|
|
102
|
+
fetchMock.mockResolvedValueOnce(jsonResponse({ userId: 'u1', linkedAccounts: [{ ...LINK, federatedUserId: null }] }));
|
|
103
|
+
const result = await service.getLinkedAccountsForUser('u1');
|
|
104
|
+
expect(result.linkedAccounts[0].federatedUserId).toBeNull();
|
|
105
|
+
const [url, init] = fetchMock.mock.calls[0];
|
|
106
|
+
expect(String(url)).toBe('http://test.invalid/linked-accounts/by-user/u1');
|
|
107
|
+
expect(new Headers(init?.headers).get('authorization')).toBe('Bearer service-token');
|
|
108
|
+
});
|
|
109
|
+
});
|
package/src/mixins/index.ts
CHANGED
|
@@ -35,6 +35,7 @@ import { OxyServicesNodesMixin } from './OxyServices.nodes';
|
|
|
35
35
|
import { OxyServicesFollowGraphMixin } from './OxyServices.followGraph';
|
|
36
36
|
import { OxyServicesInferenceMixin } from './OxyServices.inference';
|
|
37
37
|
import { OxyServicesDeviceBootMixin } from './OxyServices.deviceBoot';
|
|
38
|
+
import { OxyServicesLinkedAccountsMixin } from './OxyServices.linkedAccounts';
|
|
38
39
|
|
|
39
40
|
/**
|
|
40
41
|
* Instance shape of every mixin in the pipeline, intersected. The runtime
|
|
@@ -74,6 +75,7 @@ type AllMixinInstances =
|
|
|
74
75
|
& InstanceType<ReturnType<typeof OxyServicesFollowGraphMixin<typeof OxyServicesBase>>>
|
|
75
76
|
& InstanceType<ReturnType<typeof OxyServicesInferenceMixin<typeof OxyServicesBase>>>
|
|
76
77
|
& InstanceType<ReturnType<typeof OxyServicesDeviceBootMixin<typeof OxyServicesBase>>>
|
|
78
|
+
& InstanceType<ReturnType<typeof OxyServicesLinkedAccountsMixin<typeof OxyServicesBase>>>
|
|
77
79
|
& InstanceType<ReturnType<typeof OxyServicesUtilityMixin<typeof OxyServicesBase>>>;
|
|
78
80
|
|
|
79
81
|
/**
|
|
@@ -165,6 +167,10 @@ const MIXIN_PIPELINE: MixinFunction[] = [
|
|
|
165
167
|
// (`mintFromDeviceSecret` → `POST /session/device/token`).
|
|
166
168
|
OxyServicesDeviceBootMixin,
|
|
167
169
|
|
|
170
|
+
// Linked external accounts (Mastodon-API, Bluesky) proven by OAuth; the
|
|
171
|
+
// service read uses the auth mixin's `makeServiceRequest`.
|
|
172
|
+
OxyServicesLinkedAccountsMixin,
|
|
173
|
+
|
|
168
174
|
// Device-to-device identity transfer ("add a device"): E2E-encrypted key
|
|
169
175
|
// clone over a short-lived relay (b3 Feature 2).
|
|
170
176
|
|