@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
|
@@ -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
|
+
}
|
|
@@ -288,10 +288,7 @@ function OxyServicesUserMixin(Base) {
|
|
|
288
288
|
* `PostHydrationService`) and is unchanged.
|
|
289
289
|
* - **Plain client (browser / React Native with a user session):** when no
|
|
290
290
|
* service credentials are configured, the chunk is fetched via
|
|
291
|
-
* `makeRequest`, which attaches the configured user bearer.
|
|
292
|
-
* middleware skips bearer-authenticated writes, and `makeRequest` only
|
|
293
|
-
* fetches a CSRF token for cookie-only (no-bearer) state-changing requests,
|
|
294
|
-
* so the user-bearer POST is sent without CSRF and succeeds. Previously
|
|
291
|
+
* `makeRequest`, which attaches the configured user bearer. Previously
|
|
295
292
|
* this method always used the service path, so every client-side caller
|
|
296
293
|
* silently received `[]` because `getServiceToken()` had no credentials.
|
|
297
294
|
*
|
|
@@ -318,7 +315,7 @@ function OxyServicesUserMixin(Base) {
|
|
|
318
315
|
}
|
|
319
316
|
// A backend that called configureServiceAuth() uses the bearer-service
|
|
320
317
|
// path; any other caller (browser / RN with a user session) uses the
|
|
321
|
-
// user-bearer path.
|
|
318
|
+
// user-bearer path.
|
|
322
319
|
const useServiceAuth = Boolean(this._serviceApiKey && this._serviceApiSecret);
|
|
323
320
|
// Run chunks concurrently; a single chunk failure must not sink the rest.
|
|
324
321
|
const settled = await Promise.all(chunks.map(async (chunk) => {
|
|
@@ -862,7 +859,11 @@ function OxyServicesUserMixin(Base) {
|
|
|
862
859
|
}
|
|
863
860
|
}
|
|
864
861
|
/**
|
|
865
|
-
* 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.
|
|
866
867
|
*/
|
|
867
868
|
async createNotification(data) {
|
|
868
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)
|
package/dist/cjs/server/cors.js
CHANGED
|
@@ -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
|
}
|
|
@@ -79,6 +79,7 @@ async function refreshDeviceSecretArm(deps) {
|
|
|
79
79
|
const { oxy, store } = deps;
|
|
80
80
|
const pin = deps.pin ?? null;
|
|
81
81
|
return oxy.httpService.runSingleFlightDeviceSecretMint(async () => {
|
|
82
|
+
const epoch = oxy.httpService.getSessionEpoch();
|
|
82
83
|
const persisted = await store.load();
|
|
83
84
|
if (!persisted?.deviceId || !persisted?.deviceSecret) {
|
|
84
85
|
return { status: 'no-secret' };
|
|
@@ -144,12 +145,26 @@ async function refreshDeviceSecretArm(deps) {
|
|
|
144
145
|
expiresAt: mint.expiresAt,
|
|
145
146
|
...(bound ? { sessionId: bound.sessionId, userId: bound.accountId } : {}),
|
|
146
147
|
};
|
|
148
|
+
if (oxy.httpService.getSessionEpoch() !== epoch) {
|
|
149
|
+
// Signed out while minting. The server has rotated the secret, so a store
|
|
150
|
+
// that still holds the presented one must learn the rotation (the
|
|
151
|
+
// token-null lane keeps the store so a reload can restore); a store the
|
|
152
|
+
// sign-out cleared must NOT be refilled. Either way, plant nothing.
|
|
153
|
+
const current = await store.load();
|
|
154
|
+
if (current?.deviceId === persisted.deviceId && current.deviceSecret === persisted.deviceSecret) {
|
|
155
|
+
await store.save(next);
|
|
156
|
+
}
|
|
157
|
+
return { status: 'session-ended' };
|
|
158
|
+
}
|
|
147
159
|
// Persist nextDeviceSecret (read-back-verified) BEFORE planting the token.
|
|
148
160
|
// A failed durable persist must NOT plant.
|
|
149
161
|
const persistedOk = await store.save(next);
|
|
150
162
|
if (!persistedOk) {
|
|
151
163
|
return { status: 'persist-failed' };
|
|
152
164
|
}
|
|
165
|
+
if (oxy.httpService.getSessionEpoch() !== epoch) {
|
|
166
|
+
return { status: 'session-ended' };
|
|
167
|
+
}
|
|
153
168
|
oxy.setTokens(mint.accessToken);
|
|
154
169
|
return {
|
|
155
170
|
status: 'ok',
|
|
@@ -187,6 +202,7 @@ async function refreshPersistedSession(deps) {
|
|
|
187
202
|
const identity = deps.identity ?? null;
|
|
188
203
|
// The shared keychain is never an identity-bound client's recovery path.
|
|
189
204
|
const allowSharedKeyFallback = identity ? false : (deps.allowSharedKeyFallback ?? (0, platform_1.isNative)());
|
|
205
|
+
const epoch = oxy.httpService.getSessionEpoch();
|
|
190
206
|
// Resolved per call: a re-established identity session can move the pin, and a
|
|
191
207
|
// replaced/removed local key clears it (in which case arm 1 must NOT mint —
|
|
192
208
|
// an unpinned mint would adopt whatever account the device switched to).
|
|
@@ -198,6 +214,8 @@ async function refreshPersistedSession(deps) {
|
|
|
198
214
|
switch (arm1.status) {
|
|
199
215
|
case 'ok':
|
|
200
216
|
return arm1.token;
|
|
217
|
+
case 'session-ended':
|
|
218
|
+
return null;
|
|
201
219
|
case 'transient':
|
|
202
220
|
logger_1.logger.debug('Persisted deviceSecret mint failed (transient) — keeping store', { component: 'refresh', method: 'refreshPersistedSession' });
|
|
203
221
|
return null;
|
|
@@ -236,10 +254,18 @@ async function refreshPersistedSession(deps) {
|
|
|
236
254
|
if (identity) {
|
|
237
255
|
return recoverIdentitySession(oxy, store, identity);
|
|
238
256
|
}
|
|
239
|
-
|
|
257
|
+
// Never after a sign-out: the shared keychain holds an identity KEY, not a
|
|
258
|
+
// session, and using it here would sign the user straight back in.
|
|
259
|
+
if (allowSharedKeyFallback && !oxy.httpService.hasSessionEnded()) {
|
|
240
260
|
try {
|
|
241
|
-
|
|
261
|
+
// Planted here, not by the sign-in: a sign-out that lands while the
|
|
262
|
+
// challenge round-trips must win, or the shared keychain signs the user
|
|
263
|
+
// straight back in.
|
|
264
|
+
const session = await oxy.signInWithSharedIdentity({ plantTokens: false });
|
|
242
265
|
if (session?.accessToken) {
|
|
266
|
+
if (oxy.httpService.getSessionEpoch() !== epoch) {
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
243
269
|
// Repopulate the fast device-secret lane from the shared-key re-mint.
|
|
244
270
|
if (session.deviceId && session.deviceSecret) {
|
|
245
271
|
await store.save({
|
|
@@ -251,6 +277,10 @@ async function refreshPersistedSession(deps) {
|
|
|
251
277
|
expiresAt: session.expiresAt,
|
|
252
278
|
});
|
|
253
279
|
}
|
|
280
|
+
if (oxy.httpService.getSessionEpoch() !== epoch) {
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
oxy.setTokens(session.accessToken);
|
|
254
284
|
return session.accessToken;
|
|
255
285
|
}
|
|
256
286
|
}
|
|
@@ -82,7 +82,7 @@ const nonEmptyString = (value) => typeof value === 'string' && value.trim().leng
|
|
|
82
82
|
* - `{ error: '<CODE>', error_description }` — RFC 6749 §5.2 / RFC 6750 §3, the
|
|
83
83
|
* OAuth token and userinfo endpoints. `error_description` is the human text
|
|
84
84
|
* and `error` is the machine code, so both survive.
|
|
85
|
-
* - `{ message, code }` — e.g. the API's
|
|
85
|
+
* - `{ message, code }` — e.g. the API's middleware rejections.
|
|
86
86
|
* - `{ error: '<human message>' }` — legacy hand-rolled routes. With no sibling
|
|
87
87
|
* `message`/`error_description` the string is the message, not a code: a bare
|
|
88
88
|
* `error` string is not machine-readable enough to promote to `code`.
|