@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
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* The device backup of the self-custody identity: a copy that lives OUTSIDE this
|
|
4
|
+
* app's own keystore, so a keystore wipe cannot take it along with the keys.
|
|
5
|
+
*
|
|
6
|
+
* Why it exists (OxyHQ/oxy#1388): on Android every Oxy app shares the Linux UID
|
|
7
|
+
* `so.oxy.shared`, and the Android Keystore is per UID. Clearing the storage of
|
|
8
|
+
* ANY sibling app (`pm clear`, or Settings › Apps › Storage › Clear storage)
|
|
9
|
+
* wipes the Keystore of the whole UID. Every copy the SDK keeps in
|
|
10
|
+
* expo-secure-store (primary, backup slot, phrase slot) and in the androidx
|
|
11
|
+
* encrypted prefs (the cross-app shared slot) is wrapped by a key in that
|
|
12
|
+
* Keystore, so they all die together, and until now the only way back was the
|
|
13
|
+
* recovery phrase.
|
|
14
|
+
*
|
|
15
|
+
* `@oxy.so/core` never imports a native module, so the store is injected by the
|
|
16
|
+
* app that owns the identity ({@link KeyManager.setDeviceBackupStore}). Commons
|
|
17
|
+
* registers Android Block Store; every other app registers nothing, which keeps
|
|
18
|
+
* every code path below a no-op for them. The design and the threat model are in
|
|
19
|
+
* `docs/identity/device-backup.md`.
|
|
20
|
+
*/
|
|
21
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
22
|
+
exports.DEVICE_BACKUP_VERSION = void 0;
|
|
23
|
+
exports.serializeDeviceBackup = serializeDeviceBackup;
|
|
24
|
+
exports.parseDeviceBackup = parseDeviceBackup;
|
|
25
|
+
/** The record format. Bump {@link DEVICE_BACKUP_VERSION} on a breaking change. */
|
|
26
|
+
exports.DEVICE_BACKUP_VERSION = 1;
|
|
27
|
+
function serializeDeviceBackup(record) {
|
|
28
|
+
const out = {
|
|
29
|
+
version: exports.DEVICE_BACKUP_VERSION,
|
|
30
|
+
privateKey: record.privateKey,
|
|
31
|
+
publicKey: record.publicKey,
|
|
32
|
+
updatedAt: record.updatedAt,
|
|
33
|
+
};
|
|
34
|
+
if (record.mnemonic) {
|
|
35
|
+
out.mnemonic = record.mnemonic;
|
|
36
|
+
}
|
|
37
|
+
return JSON.stringify(out);
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Parse a stored record, or `null` for anything that is not a well-formed
|
|
41
|
+
* version-1 record. Checks SHAPE only; the caller still proves the key pair is
|
|
42
|
+
* healthy (well-formed, public derives from private) before trusting it.
|
|
43
|
+
*/
|
|
44
|
+
function parseDeviceBackup(raw) {
|
|
45
|
+
if (!raw) {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
let parsed;
|
|
49
|
+
try {
|
|
50
|
+
parsed = JSON.parse(raw);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
const candidate = parsed;
|
|
59
|
+
if (candidate.version !== exports.DEVICE_BACKUP_VERSION ||
|
|
60
|
+
typeof candidate.privateKey !== 'string' ||
|
|
61
|
+
typeof candidate.publicKey !== 'string' ||
|
|
62
|
+
typeof candidate.updatedAt !== 'string') {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
const record = {
|
|
66
|
+
version: exports.DEVICE_BACKUP_VERSION,
|
|
67
|
+
privateKey: candidate.privateKey,
|
|
68
|
+
publicKey: candidate.publicKey,
|
|
69
|
+
updatedAt: candidate.updatedAt,
|
|
70
|
+
};
|
|
71
|
+
if (typeof candidate.mnemonic === 'string' && candidate.mnemonic.length > 0) {
|
|
72
|
+
record.mnemonic = candidate.mnemonic;
|
|
73
|
+
}
|
|
74
|
+
return record;
|
|
75
|
+
}
|
|
@@ -5,6 +5,39 @@
|
|
|
5
5
|
* Handles secure generation, storage, and retrieval of cryptographic keys.
|
|
6
6
|
* Private keys are stored securely using expo-secure-store and never leave the device.
|
|
7
7
|
*/
|
|
8
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
9
|
+
if (k2 === undefined) k2 = k;
|
|
10
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
11
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
12
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
13
|
+
}
|
|
14
|
+
Object.defineProperty(o, k2, desc);
|
|
15
|
+
}) : (function(o, m, k, k2) {
|
|
16
|
+
if (k2 === undefined) k2 = k;
|
|
17
|
+
o[k2] = m[k];
|
|
18
|
+
}));
|
|
19
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
20
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
21
|
+
}) : function(o, v) {
|
|
22
|
+
o["default"] = v;
|
|
23
|
+
});
|
|
24
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
25
|
+
var ownKeys = function(o) {
|
|
26
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
27
|
+
var ar = [];
|
|
28
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
29
|
+
return ar;
|
|
30
|
+
};
|
|
31
|
+
return ownKeys(o);
|
|
32
|
+
};
|
|
33
|
+
return function (mod) {
|
|
34
|
+
if (mod && mod.__esModule) return mod;
|
|
35
|
+
var result = {};
|
|
36
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
37
|
+
__setModuleDefault(result, mod);
|
|
38
|
+
return result;
|
|
39
|
+
};
|
|
40
|
+
})();
|
|
8
41
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
42
|
exports.KeyManager = exports.IdentityUnavailableError = exports.IdentityPersistError = exports.IdentityAlreadyExistsError = void 0;
|
|
10
43
|
const platform_1 = require("../utils/platform");
|
|
@@ -13,6 +46,7 @@ const secp256k1_1 = require("@oxy.so/protocol/secp256k1");
|
|
|
13
46
|
const logger_1 = require("../logger");
|
|
14
47
|
const kdf_1 = require("./kdf");
|
|
15
48
|
const identityMarker_1 = require("./identityMarker");
|
|
49
|
+
const deviceBackup_1 = require("./deviceBackup");
|
|
16
50
|
/**
|
|
17
51
|
* Thrown when an identity-mutating operation (createIdentity / importKeyPair)
|
|
18
52
|
* is invoked while a valid identity already exists on the device.
|
|
@@ -242,6 +276,19 @@ async function getSecureRandomBytes(length) {
|
|
|
242
276
|
}
|
|
243
277
|
}
|
|
244
278
|
class KeyManager {
|
|
279
|
+
/**
|
|
280
|
+
* Register (or, with `null`, unregister) where the identity's device backup is
|
|
281
|
+
* kept. Call once at startup, before the first identity read, ONLY from the
|
|
282
|
+
* app that holds the self-custody identity: the backup is a full copy of the
|
|
283
|
+
* private key, and it belongs to exactly one app.
|
|
284
|
+
*
|
|
285
|
+
* From then on every create, import, rotation and restore refreshes the
|
|
286
|
+
* backup, a forced delete clears it, and {@link attemptIdentityRecovery}
|
|
287
|
+
* restores from it when the keystore copies are gone.
|
|
288
|
+
*/
|
|
289
|
+
static setDeviceBackupStore(store) {
|
|
290
|
+
KeyManager.deviceBackupStore = store;
|
|
291
|
+
}
|
|
245
292
|
/**
|
|
246
293
|
* Invalidate cached identity state
|
|
247
294
|
* Called internally when identity is created/deleted/imported
|
|
@@ -1230,6 +1277,138 @@ class KeyManager {
|
|
|
1230
1277
|
// persist (a subsequent healthy read re-backfills it). Rollback paths above
|
|
1231
1278
|
// return before reaching here, so they never touch the marker.
|
|
1232
1279
|
await KeyManager._syncMarkerAfterPersist(canonicalPublic, origin);
|
|
1280
|
+
// Mirror the durable identity into the keystore-independent device backup,
|
|
1281
|
+
// so a wipe of this UID's keystore can be undone without the phrase. Every
|
|
1282
|
+
// identity change (create, import, rotation, restore) flows through here.
|
|
1283
|
+
// Best-effort like the marker: `ensureDeviceBackup` retries on next launch.
|
|
1284
|
+
await KeyManager._writeDeviceBackup(canonicalPrivate, canonicalPublic);
|
|
1285
|
+
}
|
|
1286
|
+
/**
|
|
1287
|
+
* Write the device backup for `(privateKey, publicKey)`, keeping the phrase an
|
|
1288
|
+
* existing record already carries for the SAME identity. `mnemonic` replaces
|
|
1289
|
+
* it, and must already be verified against `publicKey` by the caller.
|
|
1290
|
+
* Best-effort: never throws, returns whether the store now holds this pair.
|
|
1291
|
+
*
|
|
1292
|
+
* @internal
|
|
1293
|
+
*/
|
|
1294
|
+
static async _writeDeviceBackup(privateKey, publicKey, mnemonic) {
|
|
1295
|
+
const store = KeyManager.deviceBackupStore;
|
|
1296
|
+
if (!store) {
|
|
1297
|
+
return false;
|
|
1298
|
+
}
|
|
1299
|
+
try {
|
|
1300
|
+
let existing = null;
|
|
1301
|
+
try {
|
|
1302
|
+
existing = (0, deviceBackup_1.parseDeviceBackup)(await store.read());
|
|
1303
|
+
}
|
|
1304
|
+
catch {
|
|
1305
|
+
existing = null;
|
|
1306
|
+
}
|
|
1307
|
+
const sameIdentity = existing?.publicKey === publicKey;
|
|
1308
|
+
const keptMnemonic = mnemonic ?? (sameIdentity ? existing?.mnemonic : undefined);
|
|
1309
|
+
if (sameIdentity && existing?.privateKey === privateKey && existing?.mnemonic === keptMnemonic) {
|
|
1310
|
+
return true; // Already current; do not churn the store.
|
|
1311
|
+
}
|
|
1312
|
+
await store.write((0, deviceBackup_1.serializeDeviceBackup)({
|
|
1313
|
+
version: deviceBackup_1.DEVICE_BACKUP_VERSION,
|
|
1314
|
+
privateKey,
|
|
1315
|
+
publicKey,
|
|
1316
|
+
mnemonic: keptMnemonic,
|
|
1317
|
+
updatedAt: new Date().toISOString(),
|
|
1318
|
+
}));
|
|
1319
|
+
return true;
|
|
1320
|
+
}
|
|
1321
|
+
catch (error) {
|
|
1322
|
+
logger_1.logger.warn(`Failed to write the identity device backup (${store.name}); retried on next launch`, { component: 'KeyManager' }, error);
|
|
1323
|
+
return false;
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
/** Remove the device backup. Best-effort: never throws. @internal */
|
|
1327
|
+
static async _clearDeviceBackup() {
|
|
1328
|
+
const store = KeyManager.deviceBackupStore;
|
|
1329
|
+
if (!store) {
|
|
1330
|
+
return;
|
|
1331
|
+
}
|
|
1332
|
+
try {
|
|
1333
|
+
await store.clear();
|
|
1334
|
+
}
|
|
1335
|
+
catch (error) {
|
|
1336
|
+
logger_1.logger.warn(`Failed to clear the identity device backup (${store.name})`, { component: 'KeyManager' }, error);
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
/** True when `phrase` is a valid BIP-39 phrase that derives `publicKey`. @internal */
|
|
1340
|
+
static async _phraseDerivesPublicKey(phrase, publicKey) {
|
|
1341
|
+
try {
|
|
1342
|
+
// Imported lazily: `./recoveryPhrase` imports this module, and the phrase
|
|
1343
|
+
// derivation is frozen there, so it is reused rather than copied.
|
|
1344
|
+
const { RecoveryPhraseService } = await Promise.resolve().then(() => __importStar(require('./recoveryPhrase')));
|
|
1345
|
+
const derived = await RecoveryPhraseService.derivePublicKeyFromPhrase(phrase);
|
|
1346
|
+
return derived.toLowerCase() === publicKey.toLowerCase();
|
|
1347
|
+
}
|
|
1348
|
+
catch {
|
|
1349
|
+
return false;
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
/** The primary pair when it reads back healthy, else `null`. Never throws. @internal */
|
|
1353
|
+
static async _readHealthyPrimary() {
|
|
1354
|
+
try {
|
|
1355
|
+
const direct = await KeyManager._readPrimaryDirect();
|
|
1356
|
+
if (KeyManager._isHealthyPair(direct.privateKey, direct.publicKey) && direct.privateKey && direct.publicKey) {
|
|
1357
|
+
return {
|
|
1358
|
+
privateKey: KeyManager.canonicalPrivateKey(direct.privateKey),
|
|
1359
|
+
publicKey: direct.publicKey.toLowerCase(),
|
|
1360
|
+
};
|
|
1361
|
+
}
|
|
1362
|
+
return null;
|
|
1363
|
+
}
|
|
1364
|
+
catch {
|
|
1365
|
+
return null;
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
/**
|
|
1369
|
+
* Make sure the device backup holds the identity on this device, writing it
|
|
1370
|
+
* when it is missing or stale. Commons calls this once per launch once the
|
|
1371
|
+
* identity reads back healthy: it backfills identities created before the
|
|
1372
|
+
* device backup existed, and repairs a backup whose last write failed (a
|
|
1373
|
+
* failed write after a key rotation would otherwise leave the OLD key there).
|
|
1374
|
+
*
|
|
1375
|
+
* Carries the stored recovery phrase along only when it provably derives the
|
|
1376
|
+
* current key. Resolves `true` when the backup is current, `false` when there
|
|
1377
|
+
* is no store, no healthy identity, or the write failed. Never throws.
|
|
1378
|
+
*/
|
|
1379
|
+
static async ensureDeviceBackup() {
|
|
1380
|
+
if (isWebPlatform() || !KeyManager.deviceBackupStore) {
|
|
1381
|
+
return false;
|
|
1382
|
+
}
|
|
1383
|
+
const primary = await KeyManager._readHealthyPrimary();
|
|
1384
|
+
if (!primary) {
|
|
1385
|
+
return false;
|
|
1386
|
+
}
|
|
1387
|
+
let existing = null;
|
|
1388
|
+
try {
|
|
1389
|
+
existing = (0, deviceBackup_1.parseDeviceBackup)(await KeyManager.deviceBackupStore.read());
|
|
1390
|
+
}
|
|
1391
|
+
catch {
|
|
1392
|
+
existing = null;
|
|
1393
|
+
}
|
|
1394
|
+
const pairCurrent = existing?.publicKey === primary.publicKey && existing?.privateKey === primary.privateKey;
|
|
1395
|
+
if (pairCurrent && existing?.mnemonic) {
|
|
1396
|
+
return true; // The common launch: one read, no write, no phrase derivation.
|
|
1397
|
+
}
|
|
1398
|
+
let mnemonic;
|
|
1399
|
+
try {
|
|
1400
|
+
const stored = await KeyManager.getRecoveryMnemonic();
|
|
1401
|
+
if (stored && (await KeyManager._phraseDerivesPublicKey(stored, primary.publicKey))) {
|
|
1402
|
+
mnemonic = stored;
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
catch {
|
|
1406
|
+
mnemonic = undefined;
|
|
1407
|
+
}
|
|
1408
|
+
if (pairCurrent && !mnemonic) {
|
|
1409
|
+
return true;
|
|
1410
|
+
}
|
|
1411
|
+
return KeyManager._writeDeviceBackup(primary.privateKey, primary.publicKey, mnemonic);
|
|
1233
1412
|
}
|
|
1234
1413
|
/**
|
|
1235
1414
|
* Write/refresh the identity marker after a successful persist. A same-identity
|
|
@@ -1548,6 +1727,15 @@ class KeyManager {
|
|
|
1548
1727
|
}
|
|
1549
1728
|
throw new IdentityUnavailableError('Failed to persist recovery mnemonic.', error);
|
|
1550
1729
|
}
|
|
1730
|
+
// The phrase slot above dies with the keystore too, so the device backup
|
|
1731
|
+
// carries the phrase as well — but only one that derives the identity on
|
|
1732
|
+
// this device. Best-effort: never fails the store.
|
|
1733
|
+
if (KeyManager.deviceBackupStore) {
|
|
1734
|
+
const primary = await KeyManager._readHealthyPrimary();
|
|
1735
|
+
if (primary && (await KeyManager._phraseDerivesPublicKey(mnemonic, primary.publicKey))) {
|
|
1736
|
+
await KeyManager._writeDeviceBackup(primary.privateKey, primary.publicKey, mnemonic.trim().toLowerCase());
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1551
1739
|
}
|
|
1552
1740
|
/**
|
|
1553
1741
|
* Read the stored recovery mnemonic for re-reveal in Settings.
|
|
@@ -1810,6 +1998,10 @@ class KeyManager {
|
|
|
1810
1998
|
await KeyManager._bestEffortDeleteBackupsAllGenerations(store);
|
|
1811
1999
|
await KeyManager._clearSharedSlot(store);
|
|
1812
2000
|
}
|
|
2001
|
+
// The device backup goes on EVERY delete, forced or not: it is restored
|
|
2002
|
+
// silently, so leaving it would bring a deleted identity back on the next
|
|
2003
|
+
// launch. Best-effort, like the other recovery sources.
|
|
2004
|
+
await KeyManager._clearDeviceBackup();
|
|
1813
2005
|
// Clear the marker AFTER key deletion succeeds — a marker must never outlive
|
|
1814
2006
|
// its identity (a leftover marker would route a truly-absent device to
|
|
1815
2007
|
// `recovery` instead of `welcome`).
|
|
@@ -2020,19 +2212,29 @@ class KeyManager {
|
|
|
2020
2212
|
}
|
|
2021
2213
|
}
|
|
2022
2214
|
/**
|
|
2023
|
-
* Recovery ladder — restore
|
|
2024
|
-
*
|
|
2215
|
+
* Recovery ladder — restore an identity whose keys are gone from an
|
|
2216
|
+
* independent source WITHOUT the user re-entering their recovery phrase.
|
|
2025
2217
|
*
|
|
2026
|
-
*
|
|
2027
|
-
*
|
|
2028
|
-
*
|
|
2029
|
-
*
|
|
2030
|
-
*
|
|
2031
|
-
* `publicKey === marker.publicKey`, so a source holding a DIFFERENT account is
|
|
2032
|
-
* SKIPPED, never restored):
|
|
2033
|
-
* 1. the v2 backup slot (independent keychain key from the primary), then
|
|
2218
|
+
* For a `lost` verdict (marker present, keys empty) the rungs are tried in
|
|
2219
|
+
* order, each fully validated (well-formed + derive-match + `publicKey ===
|
|
2220
|
+
* marker.publicKey`, so a source holding a DIFFERENT account is SKIPPED, never
|
|
2221
|
+
* restored):
|
|
2222
|
+
* 1. the v2 backup slot (independent keychain key from the primary),
|
|
2034
2223
|
* 2. the cross-app shared slot (Android bridge `getShared` / iOS keychain
|
|
2035
|
-
* group) — the copy that survives a primary+backup `key_v1` death
|
|
2224
|
+
* group) — the copy that survives a primary+backup `key_v1` death,
|
|
2225
|
+
* 3. the device backup ({@link setDeviceBackupStore}; Android Block Store in
|
|
2226
|
+
* Commons) — the only copy that survives a wipe of the whole shared-UID
|
|
2227
|
+
* Android Keystore, which takes rungs 1 and 2 with it (oxy#1388).
|
|
2228
|
+
*
|
|
2229
|
+
* For an `absent` verdict (no keys AND no marker: this app's own data was
|
|
2230
|
+
* cleared, or it was reinstalled) only rung 3 applies: a device backup there
|
|
2231
|
+
* means an identity lived on this device and was never deleted (every delete
|
|
2232
|
+
* clears the backup), so it is restored instead of letting onboarding create a
|
|
2233
|
+
* new identity over it. Without a registered store the result stays
|
|
2234
|
+
* `not-lost`, exactly as before.
|
|
2235
|
+
*
|
|
2236
|
+
* `present` → `not-lost`; `unavailable` (keychain locked) → `unavailable`.
|
|
2237
|
+
* Everything here is on-device: no network, so it works offline.
|
|
2036
2238
|
*
|
|
2037
2239
|
* On success it re-persists via {@link _persistIdentityAtomic} (origin
|
|
2038
2240
|
* `'restore'`) and invalidates the cache so routing re-reads `present`. When no
|
|
@@ -2043,12 +2245,24 @@ class KeyManager {
|
|
|
2043
2245
|
return { recovered: false, reason: 'not-lost' };
|
|
2044
2246
|
}
|
|
2045
2247
|
const status = await KeyManager.getIdentityStatus({ bypassCache: true });
|
|
2046
|
-
if (status.state === 'present'
|
|
2248
|
+
if (status.state === 'present') {
|
|
2047
2249
|
return { recovered: false, reason: 'not-lost' };
|
|
2048
2250
|
}
|
|
2049
2251
|
if (status.state === 'unavailable') {
|
|
2050
2252
|
return { recovered: false, reason: 'unavailable' };
|
|
2051
2253
|
}
|
|
2254
|
+
if (status.state === 'absent') {
|
|
2255
|
+
if (!KeyManager.deviceBackupStore) {
|
|
2256
|
+
return { recovered: false, reason: 'not-lost' };
|
|
2257
|
+
}
|
|
2258
|
+
const candidate = await KeyManager._readDeviceBackupCandidate();
|
|
2259
|
+
if (!candidate) {
|
|
2260
|
+
return { recovered: false, reason: 'not-lost' };
|
|
2261
|
+
}
|
|
2262
|
+
return (await KeyManager._commitDeviceBackupRecovery(candidate))
|
|
2263
|
+
? { recovered: true, source: 'device-backup', publicKey: candidate.publicKey }
|
|
2264
|
+
: { recovered: false, reason: 'no-sources' };
|
|
2265
|
+
}
|
|
2052
2266
|
// status.state === 'lost'
|
|
2053
2267
|
const expectedPublic = status.marker.publicKey.toLowerCase();
|
|
2054
2268
|
let sawMismatch = false;
|
|
@@ -2076,10 +2290,63 @@ class KeyManager {
|
|
|
2076
2290
|
sawMismatch = true;
|
|
2077
2291
|
}
|
|
2078
2292
|
}
|
|
2293
|
+
// Rung 3: device backup, outside this UID's keystore.
|
|
2294
|
+
const deviceCandidate = await KeyManager._readDeviceBackupCandidate();
|
|
2295
|
+
if (deviceCandidate) {
|
|
2296
|
+
if (deviceCandidate.publicKey === expectedPublic) {
|
|
2297
|
+
if (await KeyManager._commitDeviceBackupRecovery(deviceCandidate)) {
|
|
2298
|
+
return { recovered: true, source: 'device-backup', publicKey: deviceCandidate.publicKey };
|
|
2299
|
+
}
|
|
2300
|
+
}
|
|
2301
|
+
else {
|
|
2302
|
+
sawMismatch = true;
|
|
2303
|
+
}
|
|
2304
|
+
}
|
|
2079
2305
|
// A source existed but identified a DIFFERENT account — never silently
|
|
2080
2306
|
// switched. Report `mismatch` so the UI can require explicit confirmation.
|
|
2081
2307
|
return { recovered: false, reason: sawMismatch ? 'mismatch' : 'no-sources' };
|
|
2082
2308
|
}
|
|
2309
|
+
/** Read the device backup as a healthy, canonical candidate, or null. Never throws. @internal */
|
|
2310
|
+
static async _readDeviceBackupCandidate() {
|
|
2311
|
+
const store = KeyManager.deviceBackupStore;
|
|
2312
|
+
if (!store) {
|
|
2313
|
+
return null;
|
|
2314
|
+
}
|
|
2315
|
+
try {
|
|
2316
|
+
const record = (0, deviceBackup_1.parseDeviceBackup)(await store.read());
|
|
2317
|
+
if (!record || !KeyManager._isHealthyPair(record.privateKey, record.publicKey)) {
|
|
2318
|
+
return null;
|
|
2319
|
+
}
|
|
2320
|
+
return {
|
|
2321
|
+
...record,
|
|
2322
|
+
privateKey: KeyManager.canonicalPrivateKey(record.privateKey),
|
|
2323
|
+
publicKey: record.publicKey.toLowerCase(),
|
|
2324
|
+
};
|
|
2325
|
+
}
|
|
2326
|
+
catch (error) {
|
|
2327
|
+
logger_1.logger.warn(`Recovery: failed to read the device backup (${store.name})`, { component: 'KeyManager' }, error);
|
|
2328
|
+
return null;
|
|
2329
|
+
}
|
|
2330
|
+
}
|
|
2331
|
+
/**
|
|
2332
|
+
* Persist a device-backup candidate, then put its phrase back in the
|
|
2333
|
+
* re-reveal slot (which died with the keystore) when it derives the key.
|
|
2334
|
+
* @internal
|
|
2335
|
+
*/
|
|
2336
|
+
static async _commitDeviceBackupRecovery(record) {
|
|
2337
|
+
if (!(await KeyManager._commitRecovery(record.privateKey, record.publicKey))) {
|
|
2338
|
+
return false;
|
|
2339
|
+
}
|
|
2340
|
+
if (record.mnemonic && (await KeyManager._phraseDerivesPublicKey(record.mnemonic, record.publicKey))) {
|
|
2341
|
+
try {
|
|
2342
|
+
await KeyManager.storeRecoveryMnemonic(record.mnemonic);
|
|
2343
|
+
}
|
|
2344
|
+
catch (error) {
|
|
2345
|
+
logger_1.logger.warn('Recovery: restored the identity but not its phrase slot', { component: 'KeyManager' }, error);
|
|
2346
|
+
}
|
|
2347
|
+
}
|
|
2348
|
+
return true;
|
|
2349
|
+
}
|
|
2083
2350
|
/** Read the active-layout backup slot as a healthy candidate, or null. @internal */
|
|
2084
2351
|
static async _readBackupCandidate() {
|
|
2085
2352
|
try {
|
|
@@ -2272,4 +2539,10 @@ KeyManager.identityChangeListeners = new Set();
|
|
|
2272
2539
|
*/
|
|
2273
2540
|
KeyManager.slotMigrationPromise = null;
|
|
2274
2541
|
KeyManager.slotMigrationResult = null;
|
|
2542
|
+
/**
|
|
2543
|
+
* The keystore-independent device backup, registered by the app that owns the
|
|
2544
|
+
* identity (Commons). `null` in every other app, which makes every device
|
|
2545
|
+
* backup path a no-op there. See `./deviceBackup`.
|
|
2546
|
+
*/
|
|
2547
|
+
KeyManager.deviceBackupStore = null;
|
|
2275
2548
|
exports.default = KeyManager;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.OxyServicesLinkedAccountsMixin = OxyServicesLinkedAccountsMixin;
|
|
4
|
+
function OxyServicesLinkedAccountsMixin(Base) {
|
|
5
|
+
return class extends Base {
|
|
6
|
+
/**
|
|
7
|
+
* Begin linking an external account for the current user.
|
|
8
|
+
*
|
|
9
|
+
* @param network - `'activitypub'` (any Mastodon-API server) or `'atproto'` (Bluesky).
|
|
10
|
+
* @param options - `instance` for ActivityPub (`mastodon.social`, or
|
|
11
|
+
* `@user@mastodon.social`), `handle` for atproto; `clientId` + `returnTo`
|
|
12
|
+
* to be sent back to your app afterwards.
|
|
13
|
+
* @returns The URL to open, and when the attempt expires (ten minutes).
|
|
14
|
+
*/
|
|
15
|
+
async startLinkedAccount(network, options) {
|
|
16
|
+
try {
|
|
17
|
+
return await this.makeRequest('POST', `/linked-accounts/${encodeURIComponent(network)}/start`, options, { cache: false });
|
|
18
|
+
}
|
|
19
|
+
catch (error) {
|
|
20
|
+
throw this.handleError(error);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Finish a link with the `link_code` the callback appended to `returnTo`.
|
|
25
|
+
* Must be the user who started the flow: for anyone else the API answers
|
|
26
|
+
* 403 and burns the code. 409 when the account is already someone else's
|
|
27
|
+
* link. Repeating it within the code's five minutes returns the same link.
|
|
28
|
+
*/
|
|
29
|
+
async completeLinkedAccount(code) {
|
|
30
|
+
try {
|
|
31
|
+
const response = await this.makeRequest('POST', '/linked-accounts/complete', { code }, { cache: false });
|
|
32
|
+
return response.linkedAccount;
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
throw this.handleError(error);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** The current user's live linked accounts, oldest first. */
|
|
39
|
+
async listLinkedAccounts() {
|
|
40
|
+
try {
|
|
41
|
+
const response = await this.makeRequest('GET', '/linked-accounts', undefined, {
|
|
42
|
+
cache: false,
|
|
43
|
+
});
|
|
44
|
+
return response.linkedAccounts;
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
throw this.handleError(error);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Unlink one of the current user's accounts. An ActivityPub link stops being
|
|
52
|
+
* published as an `alsoKnownAs` alias, and the external account can then be
|
|
53
|
+
* linked to another Oxy account.
|
|
54
|
+
*/
|
|
55
|
+
async revokeLinkedAccount(linkedAccountId) {
|
|
56
|
+
try {
|
|
57
|
+
await this.makeRequest('DELETE', `/linked-accounts/${encodeURIComponent(linkedAccountId)}`, undefined, {
|
|
58
|
+
cache: false,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
throw this.handleError(error);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* SERVICE: a user's live linked accounts, each with the federated shadow
|
|
67
|
+
* user Oxy already holds for it (`federatedUserId`). Requires a service
|
|
68
|
+
* credential whose application holds the privileged `linked-accounts:read`.
|
|
69
|
+
*/
|
|
70
|
+
async getLinkedAccountsForUser(userId) {
|
|
71
|
+
try {
|
|
72
|
+
return await this.makeServiceRequest('GET', `/linked-accounts/by-user/${encodeURIComponent(userId)}`);
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
throw this.handleError(error);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
}
|
|
@@ -859,7 +859,11 @@ function OxyServicesUserMixin(Base) {
|
|
|
859
859
|
}
|
|
860
860
|
}
|
|
861
861
|
/**
|
|
862
|
-
* Create notification
|
|
862
|
+
* Create a notification (`POST /notifications`; the API requires a service
|
|
863
|
+
* token whose application holds `notifications:write`). `type` is one of
|
|
864
|
+
* `OXY_NOTIFICATION_TYPES` from `@oxy.so/contracts` — `system` for a message
|
|
865
|
+
* from an Oxy service about the recipient's own account, with the recipient
|
|
866
|
+
* as actor and their profile as the entity.
|
|
863
867
|
*/
|
|
864
868
|
async createNotification(data) {
|
|
865
869
|
try {
|
package/dist/cjs/mixins/index.js
CHANGED
|
@@ -38,6 +38,7 @@ const OxyServices_nodes_1 = require("./OxyServices.nodes");
|
|
|
38
38
|
const OxyServices_followGraph_1 = require("./OxyServices.followGraph");
|
|
39
39
|
const OxyServices_inference_1 = require("./OxyServices.inference");
|
|
40
40
|
const OxyServices_deviceBoot_1 = require("./OxyServices.deviceBoot");
|
|
41
|
+
const OxyServices_linkedAccounts_1 = require("./OxyServices.linkedAccounts");
|
|
41
42
|
/**
|
|
42
43
|
* Mixin pipeline - applied in order from first to last.
|
|
43
44
|
*
|
|
@@ -107,6 +108,9 @@ const MIXIN_PIPELINE = [
|
|
|
107
108
|
// Device-first token mint: the client half of the zero-cookie transport
|
|
108
109
|
// (`mintFromDeviceSecret` → `POST /session/device/token`).
|
|
109
110
|
OxyServices_deviceBoot_1.OxyServicesDeviceBootMixin,
|
|
111
|
+
// Linked external accounts (Mastodon-API, Bluesky) proven by OAuth; the
|
|
112
|
+
// service read uses the auth mixin's `makeServiceRequest`.
|
|
113
|
+
OxyServices_linkedAccounts_1.OxyServicesLinkedAccountsMixin,
|
|
110
114
|
// Device-to-device identity transfer ("add a device"): E2E-encrypted key
|
|
111
115
|
// clone over a short-lived relay (b3 Feature 2).
|
|
112
116
|
// Utility (last, can use all above)
|
|
@@ -353,6 +353,9 @@ function buildOxyPagesHeaders(options = {}) {
|
|
|
353
353
|
if (options.hsts !== false) {
|
|
354
354
|
lines.push(' Strict-Transport-Security: max-age=31536000; includeSubDomains; preload');
|
|
355
355
|
}
|
|
356
|
+
if (options.sensitive) {
|
|
357
|
+
lines.push(' Cache-Control: public, max-age=0, must-revalidate, no-transform');
|
|
358
|
+
}
|
|
356
359
|
lines.push('');
|
|
357
360
|
return lines.join('\n');
|
|
358
361
|
}
|
|
@@ -493,6 +493,46 @@ class AccountDialogController {
|
|
|
493
493
|
this.activatingContextId = null;
|
|
494
494
|
}, () => this.sessionClient.activateContext(contextId));
|
|
495
495
|
}
|
|
496
|
+
/**
|
|
497
|
+
* A device account row was chosen — the one entry point for "use this
|
|
498
|
+
* account", whether or not anybody is signed in here.
|
|
499
|
+
*
|
|
500
|
+
* Signed in, it is a switch: the active row is already the answer
|
|
501
|
+
* (`'current'`), any other row is {@link activateContext}.
|
|
502
|
+
*
|
|
503
|
+
* Signed out, it is NOT a switch. The row is on the list because the DEVICE
|
|
504
|
+
* still holds that identity (Commons' shared identity, or a sibling app's
|
|
505
|
+
* session), not because this app does, so there is no bearer to activate a
|
|
506
|
+
* context with — and the active-row short-circuit used to read that stale
|
|
507
|
+
* "active" as "already signed in" and close the sheet on a signed-out app
|
|
508
|
+
* (OxyHQ/oxy#1375 item 20). Choosing the row is "Continue as @handle": the
|
|
509
|
+
* same {@link signInWithOxy} path as the "Continue with Oxy" button, which
|
|
510
|
+
* mints silently from the shared identity and otherwise falls back to the
|
|
511
|
+
* request. When that silent mint lands on a different pair from the one
|
|
512
|
+
* chosen (a device holding more than one person, or an organization row), the
|
|
513
|
+
* chosen row is then activated under the new bearer, so the press ends where
|
|
514
|
+
* it pointed.
|
|
515
|
+
*/
|
|
516
|
+
async chooseContext(contextId) {
|
|
517
|
+
if (this.isDeviceMutationInFlight())
|
|
518
|
+
return 'busy';
|
|
519
|
+
if (this.hasSession()) {
|
|
520
|
+
if (contextId === this.snapshot.activeContext?.contextId)
|
|
521
|
+
return 'current';
|
|
522
|
+
return (await this.activateContext(contextId)) ? 'switched' : 'failed';
|
|
523
|
+
}
|
|
524
|
+
await this.signInWithOxy();
|
|
525
|
+
// Only a sign-in that finished here (the silent shared-identity mint) can be
|
|
526
|
+
// steered to the chosen row; a request still waiting on approval belongs to
|
|
527
|
+
// whoever approves it.
|
|
528
|
+
if (this.signIn.phase !== 'completed' || !this.hasSession())
|
|
529
|
+
return 'signing-in';
|
|
530
|
+
const directory = this.sessionClient.getDirectory();
|
|
531
|
+
const offered = directory?.principals.some((principal) => principal.contexts.some((context) => context.id === contextId));
|
|
532
|
+
if (!offered || (0, deviceDirectory_1.resolveActiveContext)(directory)?.contextId === contextId)
|
|
533
|
+
return 'signing-in';
|
|
534
|
+
return (await this.activateContext(contextId)) ? 'switched' : 'signing-in';
|
|
535
|
+
}
|
|
496
536
|
/**
|
|
497
537
|
* Remove ONE `principal → account` pair, and only that pair.
|
|
498
538
|
*
|
|
@@ -1337,6 +1377,7 @@ class AccountDialogController {
|
|
|
1337
1377
|
return {
|
|
1338
1378
|
view,
|
|
1339
1379
|
backView: backViewOf(view, hasSession),
|
|
1380
|
+
hasSession,
|
|
1340
1381
|
directory,
|
|
1341
1382
|
activeContext: (0, deviceDirectory_1.resolveActiveContext)(directory),
|
|
1342
1383
|
loading: this.loading,
|