@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
|
@@ -10,6 +10,7 @@ import { deriveSecp256k1PublicKey, generateSecp256k1KeyPair, isValidSecp256k1Pri
|
|
|
10
10
|
import { isDev, logger } from '../logger/index.js';
|
|
11
11
|
import { hkdfSha256 } from './kdf.js';
|
|
12
12
|
import { clearIdentityMarker, readIdentityMarker, updateIdentityMarker, writeIdentityMarker, } from './identityMarker.js';
|
|
13
|
+
import { DEVICE_BACKUP_VERSION, parseDeviceBackup, serializeDeviceBackup, } from './deviceBackup.js';
|
|
13
14
|
/**
|
|
14
15
|
* Thrown when an identity-mutating operation (createIdentity / importKeyPair)
|
|
15
16
|
* is invoked while a valid identity already exists on the device.
|
|
@@ -236,6 +237,19 @@ async function getSecureRandomBytes(length) {
|
|
|
236
237
|
}
|
|
237
238
|
}
|
|
238
239
|
export class KeyManager {
|
|
240
|
+
/**
|
|
241
|
+
* Register (or, with `null`, unregister) where the identity's device backup is
|
|
242
|
+
* kept. Call once at startup, before the first identity read, ONLY from the
|
|
243
|
+
* app that holds the self-custody identity: the backup is a full copy of the
|
|
244
|
+
* private key, and it belongs to exactly one app.
|
|
245
|
+
*
|
|
246
|
+
* From then on every create, import, rotation and restore refreshes the
|
|
247
|
+
* backup, a forced delete clears it, and {@link attemptIdentityRecovery}
|
|
248
|
+
* restores from it when the keystore copies are gone.
|
|
249
|
+
*/
|
|
250
|
+
static setDeviceBackupStore(store) {
|
|
251
|
+
KeyManager.deviceBackupStore = store;
|
|
252
|
+
}
|
|
239
253
|
/**
|
|
240
254
|
* Invalidate cached identity state
|
|
241
255
|
* Called internally when identity is created/deleted/imported
|
|
@@ -1224,6 +1238,138 @@ export class KeyManager {
|
|
|
1224
1238
|
// persist (a subsequent healthy read re-backfills it). Rollback paths above
|
|
1225
1239
|
// return before reaching here, so they never touch the marker.
|
|
1226
1240
|
await KeyManager._syncMarkerAfterPersist(canonicalPublic, origin);
|
|
1241
|
+
// Mirror the durable identity into the keystore-independent device backup,
|
|
1242
|
+
// so a wipe of this UID's keystore can be undone without the phrase. Every
|
|
1243
|
+
// identity change (create, import, rotation, restore) flows through here.
|
|
1244
|
+
// Best-effort like the marker: `ensureDeviceBackup` retries on next launch.
|
|
1245
|
+
await KeyManager._writeDeviceBackup(canonicalPrivate, canonicalPublic);
|
|
1246
|
+
}
|
|
1247
|
+
/**
|
|
1248
|
+
* Write the device backup for `(privateKey, publicKey)`, keeping the phrase an
|
|
1249
|
+
* existing record already carries for the SAME identity. `mnemonic` replaces
|
|
1250
|
+
* it, and must already be verified against `publicKey` by the caller.
|
|
1251
|
+
* Best-effort: never throws, returns whether the store now holds this pair.
|
|
1252
|
+
*
|
|
1253
|
+
* @internal
|
|
1254
|
+
*/
|
|
1255
|
+
static async _writeDeviceBackup(privateKey, publicKey, mnemonic) {
|
|
1256
|
+
const store = KeyManager.deviceBackupStore;
|
|
1257
|
+
if (!store) {
|
|
1258
|
+
return false;
|
|
1259
|
+
}
|
|
1260
|
+
try {
|
|
1261
|
+
let existing = null;
|
|
1262
|
+
try {
|
|
1263
|
+
existing = parseDeviceBackup(await store.read());
|
|
1264
|
+
}
|
|
1265
|
+
catch {
|
|
1266
|
+
existing = null;
|
|
1267
|
+
}
|
|
1268
|
+
const sameIdentity = existing?.publicKey === publicKey;
|
|
1269
|
+
const keptMnemonic = mnemonic ?? (sameIdentity ? existing?.mnemonic : undefined);
|
|
1270
|
+
if (sameIdentity && existing?.privateKey === privateKey && existing?.mnemonic === keptMnemonic) {
|
|
1271
|
+
return true; // Already current; do not churn the store.
|
|
1272
|
+
}
|
|
1273
|
+
await store.write(serializeDeviceBackup({
|
|
1274
|
+
version: DEVICE_BACKUP_VERSION,
|
|
1275
|
+
privateKey,
|
|
1276
|
+
publicKey,
|
|
1277
|
+
mnemonic: keptMnemonic,
|
|
1278
|
+
updatedAt: new Date().toISOString(),
|
|
1279
|
+
}));
|
|
1280
|
+
return true;
|
|
1281
|
+
}
|
|
1282
|
+
catch (error) {
|
|
1283
|
+
logger.warn(`Failed to write the identity device backup (${store.name}); retried on next launch`, { component: 'KeyManager' }, error);
|
|
1284
|
+
return false;
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
/** Remove the device backup. Best-effort: never throws. @internal */
|
|
1288
|
+
static async _clearDeviceBackup() {
|
|
1289
|
+
const store = KeyManager.deviceBackupStore;
|
|
1290
|
+
if (!store) {
|
|
1291
|
+
return;
|
|
1292
|
+
}
|
|
1293
|
+
try {
|
|
1294
|
+
await store.clear();
|
|
1295
|
+
}
|
|
1296
|
+
catch (error) {
|
|
1297
|
+
logger.warn(`Failed to clear the identity device backup (${store.name})`, { component: 'KeyManager' }, error);
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
/** True when `phrase` is a valid BIP-39 phrase that derives `publicKey`. @internal */
|
|
1301
|
+
static async _phraseDerivesPublicKey(phrase, publicKey) {
|
|
1302
|
+
try {
|
|
1303
|
+
// Imported lazily: `./recoveryPhrase` imports this module, and the phrase
|
|
1304
|
+
// derivation is frozen there, so it is reused rather than copied.
|
|
1305
|
+
const { RecoveryPhraseService } = await import('./recoveryPhrase.js');
|
|
1306
|
+
const derived = await RecoveryPhraseService.derivePublicKeyFromPhrase(phrase);
|
|
1307
|
+
return derived.toLowerCase() === publicKey.toLowerCase();
|
|
1308
|
+
}
|
|
1309
|
+
catch {
|
|
1310
|
+
return false;
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
/** The primary pair when it reads back healthy, else `null`. Never throws. @internal */
|
|
1314
|
+
static async _readHealthyPrimary() {
|
|
1315
|
+
try {
|
|
1316
|
+
const direct = await KeyManager._readPrimaryDirect();
|
|
1317
|
+
if (KeyManager._isHealthyPair(direct.privateKey, direct.publicKey) && direct.privateKey && direct.publicKey) {
|
|
1318
|
+
return {
|
|
1319
|
+
privateKey: KeyManager.canonicalPrivateKey(direct.privateKey),
|
|
1320
|
+
publicKey: direct.publicKey.toLowerCase(),
|
|
1321
|
+
};
|
|
1322
|
+
}
|
|
1323
|
+
return null;
|
|
1324
|
+
}
|
|
1325
|
+
catch {
|
|
1326
|
+
return null;
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
/**
|
|
1330
|
+
* Make sure the device backup holds the identity on this device, writing it
|
|
1331
|
+
* when it is missing or stale. Commons calls this once per launch once the
|
|
1332
|
+
* identity reads back healthy: it backfills identities created before the
|
|
1333
|
+
* device backup existed, and repairs a backup whose last write failed (a
|
|
1334
|
+
* failed write after a key rotation would otherwise leave the OLD key there).
|
|
1335
|
+
*
|
|
1336
|
+
* Carries the stored recovery phrase along only when it provably derives the
|
|
1337
|
+
* current key. Resolves `true` when the backup is current, `false` when there
|
|
1338
|
+
* is no store, no healthy identity, or the write failed. Never throws.
|
|
1339
|
+
*/
|
|
1340
|
+
static async ensureDeviceBackup() {
|
|
1341
|
+
if (isWebPlatform() || !KeyManager.deviceBackupStore) {
|
|
1342
|
+
return false;
|
|
1343
|
+
}
|
|
1344
|
+
const primary = await KeyManager._readHealthyPrimary();
|
|
1345
|
+
if (!primary) {
|
|
1346
|
+
return false;
|
|
1347
|
+
}
|
|
1348
|
+
let existing = null;
|
|
1349
|
+
try {
|
|
1350
|
+
existing = parseDeviceBackup(await KeyManager.deviceBackupStore.read());
|
|
1351
|
+
}
|
|
1352
|
+
catch {
|
|
1353
|
+
existing = null;
|
|
1354
|
+
}
|
|
1355
|
+
const pairCurrent = existing?.publicKey === primary.publicKey && existing?.privateKey === primary.privateKey;
|
|
1356
|
+
if (pairCurrent && existing?.mnemonic) {
|
|
1357
|
+
return true; // The common launch: one read, no write, no phrase derivation.
|
|
1358
|
+
}
|
|
1359
|
+
let mnemonic;
|
|
1360
|
+
try {
|
|
1361
|
+
const stored = await KeyManager.getRecoveryMnemonic();
|
|
1362
|
+
if (stored && (await KeyManager._phraseDerivesPublicKey(stored, primary.publicKey))) {
|
|
1363
|
+
mnemonic = stored;
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
catch {
|
|
1367
|
+
mnemonic = undefined;
|
|
1368
|
+
}
|
|
1369
|
+
if (pairCurrent && !mnemonic) {
|
|
1370
|
+
return true;
|
|
1371
|
+
}
|
|
1372
|
+
return KeyManager._writeDeviceBackup(primary.privateKey, primary.publicKey, mnemonic);
|
|
1227
1373
|
}
|
|
1228
1374
|
/**
|
|
1229
1375
|
* Write/refresh the identity marker after a successful persist. A same-identity
|
|
@@ -1542,6 +1688,15 @@ export class KeyManager {
|
|
|
1542
1688
|
}
|
|
1543
1689
|
throw new IdentityUnavailableError('Failed to persist recovery mnemonic.', error);
|
|
1544
1690
|
}
|
|
1691
|
+
// The phrase slot above dies with the keystore too, so the device backup
|
|
1692
|
+
// carries the phrase as well — but only one that derives the identity on
|
|
1693
|
+
// this device. Best-effort: never fails the store.
|
|
1694
|
+
if (KeyManager.deviceBackupStore) {
|
|
1695
|
+
const primary = await KeyManager._readHealthyPrimary();
|
|
1696
|
+
if (primary && (await KeyManager._phraseDerivesPublicKey(mnemonic, primary.publicKey))) {
|
|
1697
|
+
await KeyManager._writeDeviceBackup(primary.privateKey, primary.publicKey, mnemonic.trim().toLowerCase());
|
|
1698
|
+
}
|
|
1699
|
+
}
|
|
1545
1700
|
}
|
|
1546
1701
|
/**
|
|
1547
1702
|
* Read the stored recovery mnemonic for re-reveal in Settings.
|
|
@@ -1804,6 +1959,10 @@ export class KeyManager {
|
|
|
1804
1959
|
await KeyManager._bestEffortDeleteBackupsAllGenerations(store);
|
|
1805
1960
|
await KeyManager._clearSharedSlot(store);
|
|
1806
1961
|
}
|
|
1962
|
+
// The device backup goes on EVERY delete, forced or not: it is restored
|
|
1963
|
+
// silently, so leaving it would bring a deleted identity back on the next
|
|
1964
|
+
// launch. Best-effort, like the other recovery sources.
|
|
1965
|
+
await KeyManager._clearDeviceBackup();
|
|
1807
1966
|
// Clear the marker AFTER key deletion succeeds — a marker must never outlive
|
|
1808
1967
|
// its identity (a leftover marker would route a truly-absent device to
|
|
1809
1968
|
// `recovery` instead of `welcome`).
|
|
@@ -2014,19 +2173,29 @@ export class KeyManager {
|
|
|
2014
2173
|
}
|
|
2015
2174
|
}
|
|
2016
2175
|
/**
|
|
2017
|
-
* Recovery ladder — restore
|
|
2018
|
-
*
|
|
2019
|
-
*
|
|
2020
|
-
* Gated on {@link getIdentityStatus} being `lost` (marker present, keys empty):
|
|
2021
|
-
* - `present` / `absent` → `not-lost` (nothing to recover / nothing lost)
|
|
2022
|
-
* - `unavailable` → `unavailable` (keychain locked; retry later)
|
|
2176
|
+
* Recovery ladder — restore an identity whose keys are gone from an
|
|
2177
|
+
* independent source WITHOUT the user re-entering their recovery phrase.
|
|
2023
2178
|
*
|
|
2024
|
-
*
|
|
2025
|
-
*
|
|
2026
|
-
* SKIPPED, never
|
|
2027
|
-
*
|
|
2179
|
+
* For a `lost` verdict (marker present, keys empty) the rungs are tried in
|
|
2180
|
+
* order, each fully validated (well-formed + derive-match + `publicKey ===
|
|
2181
|
+
* marker.publicKey`, so a source holding a DIFFERENT account is SKIPPED, never
|
|
2182
|
+
* restored):
|
|
2183
|
+
* 1. the v2 backup slot (independent keychain key from the primary),
|
|
2028
2184
|
* 2. the cross-app shared slot (Android bridge `getShared` / iOS keychain
|
|
2029
|
-
* group) — the copy that survives a primary+backup `key_v1` death
|
|
2185
|
+
* group) — the copy that survives a primary+backup `key_v1` death,
|
|
2186
|
+
* 3. the device backup ({@link setDeviceBackupStore}; Android Block Store in
|
|
2187
|
+
* Commons) — the only copy that survives a wipe of the whole shared-UID
|
|
2188
|
+
* Android Keystore, which takes rungs 1 and 2 with it (oxy#1388).
|
|
2189
|
+
*
|
|
2190
|
+
* For an `absent` verdict (no keys AND no marker: this app's own data was
|
|
2191
|
+
* cleared, or it was reinstalled) only rung 3 applies: a device backup there
|
|
2192
|
+
* means an identity lived on this device and was never deleted (every delete
|
|
2193
|
+
* clears the backup), so it is restored instead of letting onboarding create a
|
|
2194
|
+
* new identity over it. Without a registered store the result stays
|
|
2195
|
+
* `not-lost`, exactly as before.
|
|
2196
|
+
*
|
|
2197
|
+
* `present` → `not-lost`; `unavailable` (keychain locked) → `unavailable`.
|
|
2198
|
+
* Everything here is on-device: no network, so it works offline.
|
|
2030
2199
|
*
|
|
2031
2200
|
* On success it re-persists via {@link _persistIdentityAtomic} (origin
|
|
2032
2201
|
* `'restore'`) and invalidates the cache so routing re-reads `present`. When no
|
|
@@ -2037,12 +2206,24 @@ export class KeyManager {
|
|
|
2037
2206
|
return { recovered: false, reason: 'not-lost' };
|
|
2038
2207
|
}
|
|
2039
2208
|
const status = await KeyManager.getIdentityStatus({ bypassCache: true });
|
|
2040
|
-
if (status.state === 'present'
|
|
2209
|
+
if (status.state === 'present') {
|
|
2041
2210
|
return { recovered: false, reason: 'not-lost' };
|
|
2042
2211
|
}
|
|
2043
2212
|
if (status.state === 'unavailable') {
|
|
2044
2213
|
return { recovered: false, reason: 'unavailable' };
|
|
2045
2214
|
}
|
|
2215
|
+
if (status.state === 'absent') {
|
|
2216
|
+
if (!KeyManager.deviceBackupStore) {
|
|
2217
|
+
return { recovered: false, reason: 'not-lost' };
|
|
2218
|
+
}
|
|
2219
|
+
const candidate = await KeyManager._readDeviceBackupCandidate();
|
|
2220
|
+
if (!candidate) {
|
|
2221
|
+
return { recovered: false, reason: 'not-lost' };
|
|
2222
|
+
}
|
|
2223
|
+
return (await KeyManager._commitDeviceBackupRecovery(candidate))
|
|
2224
|
+
? { recovered: true, source: 'device-backup', publicKey: candidate.publicKey }
|
|
2225
|
+
: { recovered: false, reason: 'no-sources' };
|
|
2226
|
+
}
|
|
2046
2227
|
// status.state === 'lost'
|
|
2047
2228
|
const expectedPublic = status.marker.publicKey.toLowerCase();
|
|
2048
2229
|
let sawMismatch = false;
|
|
@@ -2070,10 +2251,63 @@ export class KeyManager {
|
|
|
2070
2251
|
sawMismatch = true;
|
|
2071
2252
|
}
|
|
2072
2253
|
}
|
|
2254
|
+
// Rung 3: device backup, outside this UID's keystore.
|
|
2255
|
+
const deviceCandidate = await KeyManager._readDeviceBackupCandidate();
|
|
2256
|
+
if (deviceCandidate) {
|
|
2257
|
+
if (deviceCandidate.publicKey === expectedPublic) {
|
|
2258
|
+
if (await KeyManager._commitDeviceBackupRecovery(deviceCandidate)) {
|
|
2259
|
+
return { recovered: true, source: 'device-backup', publicKey: deviceCandidate.publicKey };
|
|
2260
|
+
}
|
|
2261
|
+
}
|
|
2262
|
+
else {
|
|
2263
|
+
sawMismatch = true;
|
|
2264
|
+
}
|
|
2265
|
+
}
|
|
2073
2266
|
// A source existed but identified a DIFFERENT account — never silently
|
|
2074
2267
|
// switched. Report `mismatch` so the UI can require explicit confirmation.
|
|
2075
2268
|
return { recovered: false, reason: sawMismatch ? 'mismatch' : 'no-sources' };
|
|
2076
2269
|
}
|
|
2270
|
+
/** Read the device backup as a healthy, canonical candidate, or null. Never throws. @internal */
|
|
2271
|
+
static async _readDeviceBackupCandidate() {
|
|
2272
|
+
const store = KeyManager.deviceBackupStore;
|
|
2273
|
+
if (!store) {
|
|
2274
|
+
return null;
|
|
2275
|
+
}
|
|
2276
|
+
try {
|
|
2277
|
+
const record = parseDeviceBackup(await store.read());
|
|
2278
|
+
if (!record || !KeyManager._isHealthyPair(record.privateKey, record.publicKey)) {
|
|
2279
|
+
return null;
|
|
2280
|
+
}
|
|
2281
|
+
return {
|
|
2282
|
+
...record,
|
|
2283
|
+
privateKey: KeyManager.canonicalPrivateKey(record.privateKey),
|
|
2284
|
+
publicKey: record.publicKey.toLowerCase(),
|
|
2285
|
+
};
|
|
2286
|
+
}
|
|
2287
|
+
catch (error) {
|
|
2288
|
+
logger.warn(`Recovery: failed to read the device backup (${store.name})`, { component: 'KeyManager' }, error);
|
|
2289
|
+
return null;
|
|
2290
|
+
}
|
|
2291
|
+
}
|
|
2292
|
+
/**
|
|
2293
|
+
* Persist a device-backup candidate, then put its phrase back in the
|
|
2294
|
+
* re-reveal slot (which died with the keystore) when it derives the key.
|
|
2295
|
+
* @internal
|
|
2296
|
+
*/
|
|
2297
|
+
static async _commitDeviceBackupRecovery(record) {
|
|
2298
|
+
if (!(await KeyManager._commitRecovery(record.privateKey, record.publicKey))) {
|
|
2299
|
+
return false;
|
|
2300
|
+
}
|
|
2301
|
+
if (record.mnemonic && (await KeyManager._phraseDerivesPublicKey(record.mnemonic, record.publicKey))) {
|
|
2302
|
+
try {
|
|
2303
|
+
await KeyManager.storeRecoveryMnemonic(record.mnemonic);
|
|
2304
|
+
}
|
|
2305
|
+
catch (error) {
|
|
2306
|
+
logger.warn('Recovery: restored the identity but not its phrase slot', { component: 'KeyManager' }, error);
|
|
2307
|
+
}
|
|
2308
|
+
}
|
|
2309
|
+
return true;
|
|
2310
|
+
}
|
|
2077
2311
|
/** Read the active-layout backup slot as a healthy candidate, or null. @internal */
|
|
2078
2312
|
static async _readBackupCandidate() {
|
|
2079
2313
|
try {
|
|
@@ -2265,4 +2499,10 @@ KeyManager.identityChangeListeners = new Set();
|
|
|
2265
2499
|
*/
|
|
2266
2500
|
KeyManager.slotMigrationPromise = null;
|
|
2267
2501
|
KeyManager.slotMigrationResult = null;
|
|
2502
|
+
/**
|
|
2503
|
+
* The keystore-independent device backup, registered by the app that owns the
|
|
2504
|
+
* identity (Commons). `null` in every other app, which makes every device
|
|
2505
|
+
* backup path a no-op there. See `./deviceBackup`.
|
|
2506
|
+
*/
|
|
2507
|
+
KeyManager.deviceBackupStore = null;
|
|
2268
2508
|
export default KeyManager;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
export function OxyServicesLinkedAccountsMixin(Base) {
|
|
2
|
+
return class extends Base {
|
|
3
|
+
/**
|
|
4
|
+
* Begin linking an external account for the current user.
|
|
5
|
+
*
|
|
6
|
+
* @param network - `'activitypub'` (any Mastodon-API server) or `'atproto'` (Bluesky).
|
|
7
|
+
* @param options - `instance` for ActivityPub (`mastodon.social`, or
|
|
8
|
+
* `@user@mastodon.social`), `handle` for atproto; `clientId` + `returnTo`
|
|
9
|
+
* to be sent back to your app afterwards.
|
|
10
|
+
* @returns The URL to open, and when the attempt expires (ten minutes).
|
|
11
|
+
*/
|
|
12
|
+
async startLinkedAccount(network, options) {
|
|
13
|
+
try {
|
|
14
|
+
return await this.makeRequest('POST', `/linked-accounts/${encodeURIComponent(network)}/start`, options, { cache: false });
|
|
15
|
+
}
|
|
16
|
+
catch (error) {
|
|
17
|
+
throw this.handleError(error);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Finish a link with the `link_code` the callback appended to `returnTo`.
|
|
22
|
+
* Must be the user who started the flow: for anyone else the API answers
|
|
23
|
+
* 403 and burns the code. 409 when the account is already someone else's
|
|
24
|
+
* link. Repeating it within the code's five minutes returns the same link.
|
|
25
|
+
*/
|
|
26
|
+
async completeLinkedAccount(code) {
|
|
27
|
+
try {
|
|
28
|
+
const response = await this.makeRequest('POST', '/linked-accounts/complete', { code }, { cache: false });
|
|
29
|
+
return response.linkedAccount;
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
throw this.handleError(error);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/** The current user's live linked accounts, oldest first. */
|
|
36
|
+
async listLinkedAccounts() {
|
|
37
|
+
try {
|
|
38
|
+
const response = await this.makeRequest('GET', '/linked-accounts', undefined, {
|
|
39
|
+
cache: false,
|
|
40
|
+
});
|
|
41
|
+
return response.linkedAccounts;
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
throw this.handleError(error);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Unlink one of the current user's accounts. An ActivityPub link stops being
|
|
49
|
+
* published as an `alsoKnownAs` alias, and the external account can then be
|
|
50
|
+
* linked to another Oxy account.
|
|
51
|
+
*/
|
|
52
|
+
async revokeLinkedAccount(linkedAccountId) {
|
|
53
|
+
try {
|
|
54
|
+
await this.makeRequest('DELETE', `/linked-accounts/${encodeURIComponent(linkedAccountId)}`, undefined, {
|
|
55
|
+
cache: false,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
throw this.handleError(error);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* SERVICE: a user's live linked accounts, each with the federated shadow
|
|
64
|
+
* user Oxy already holds for it (`federatedUserId`). Requires a service
|
|
65
|
+
* credential whose application holds the privileged `linked-accounts:read`.
|
|
66
|
+
*/
|
|
67
|
+
async getLinkedAccountsForUser(userId) {
|
|
68
|
+
try {
|
|
69
|
+
return await this.makeServiceRequest('GET', `/linked-accounts/by-user/${encodeURIComponent(userId)}`);
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
throw this.handleError(error);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
}
|
|
@@ -285,10 +285,7 @@ export function OxyServicesUserMixin(Base) {
|
|
|
285
285
|
* `PostHydrationService`) and is unchanged.
|
|
286
286
|
* - **Plain client (browser / React Native with a user session):** when no
|
|
287
287
|
* service credentials are configured, the chunk is fetched via
|
|
288
|
-
* `makeRequest`, which attaches the configured user bearer.
|
|
289
|
-
* middleware skips bearer-authenticated writes, and `makeRequest` only
|
|
290
|
-
* fetches a CSRF token for cookie-only (no-bearer) state-changing requests,
|
|
291
|
-
* so the user-bearer POST is sent without CSRF and succeeds. Previously
|
|
288
|
+
* `makeRequest`, which attaches the configured user bearer. Previously
|
|
292
289
|
* this method always used the service path, so every client-side caller
|
|
293
290
|
* silently received `[]` because `getServiceToken()` had no credentials.
|
|
294
291
|
*
|
|
@@ -315,7 +312,7 @@ export function OxyServicesUserMixin(Base) {
|
|
|
315
312
|
}
|
|
316
313
|
// A backend that called configureServiceAuth() uses the bearer-service
|
|
317
314
|
// path; any other caller (browser / RN with a user session) uses the
|
|
318
|
-
// user-bearer path.
|
|
315
|
+
// user-bearer path.
|
|
319
316
|
const useServiceAuth = Boolean(this._serviceApiKey && this._serviceApiSecret);
|
|
320
317
|
// Run chunks concurrently; a single chunk failure must not sink the rest.
|
|
321
318
|
const settled = await Promise.all(chunks.map(async (chunk) => {
|
|
@@ -859,7 +856,11 @@ export function OxyServicesUserMixin(Base) {
|
|
|
859
856
|
}
|
|
860
857
|
}
|
|
861
858
|
/**
|
|
862
|
-
* Create notification
|
|
859
|
+
* Create a notification (`POST /notifications`; the API requires a service
|
|
860
|
+
* token whose application holds `notifications:write`). `type` is one of
|
|
861
|
+
* `OXY_NOTIFICATION_TYPES` from `@oxy.so/contracts` — `system` for a message
|
|
862
|
+
* from an Oxy service about the recipient's own account, with the recipient
|
|
863
|
+
* as actor and their profile as the entity.
|
|
863
864
|
*/
|
|
864
865
|
async createNotification(data) {
|
|
865
866
|
try {
|
package/dist/esm/mixins/index.js
CHANGED
|
@@ -34,6 +34,7 @@ import { OxyServicesNodesMixin } from './OxyServices.nodes.js';
|
|
|
34
34
|
import { OxyServicesFollowGraphMixin } from './OxyServices.followGraph.js';
|
|
35
35
|
import { OxyServicesInferenceMixin } from './OxyServices.inference.js';
|
|
36
36
|
import { OxyServicesDeviceBootMixin } from './OxyServices.deviceBoot.js';
|
|
37
|
+
import { OxyServicesLinkedAccountsMixin } from './OxyServices.linkedAccounts.js';
|
|
37
38
|
/**
|
|
38
39
|
* Mixin pipeline - applied in order from first to last.
|
|
39
40
|
*
|
|
@@ -103,6 +104,9 @@ const MIXIN_PIPELINE = [
|
|
|
103
104
|
// Device-first token mint: the client half of the zero-cookie transport
|
|
104
105
|
// (`mintFromDeviceSecret` → `POST /session/device/token`).
|
|
105
106
|
OxyServicesDeviceBootMixin,
|
|
107
|
+
// Linked external accounts (Mastodon-API, Bluesky) proven by OAuth; the
|
|
108
|
+
// service read uses the auth mixin's `makeServiceRequest`.
|
|
109
|
+
OxyServicesLinkedAccountsMixin,
|
|
106
110
|
// Device-to-device identity transfer ("add a device"): E2E-encrypted key
|
|
107
111
|
// clone over a short-lived relay (b3 Feature 2).
|
|
108
112
|
// Utility (last, can use all above)
|
package/dist/esm/server/cors.js
CHANGED
|
@@ -340,6 +340,9 @@ export function buildOxyPagesHeaders(options = {}) {
|
|
|
340
340
|
if (options.hsts !== false) {
|
|
341
341
|
lines.push(' Strict-Transport-Security: max-age=31536000; includeSubDomains; preload');
|
|
342
342
|
}
|
|
343
|
+
if (options.sensitive) {
|
|
344
|
+
lines.push(' Cache-Control: public, max-age=0, must-revalidate, no-transform');
|
|
345
|
+
}
|
|
343
346
|
lines.push('');
|
|
344
347
|
return lines.join('\n');
|
|
345
348
|
}
|
|
@@ -71,6 +71,7 @@ export async function refreshDeviceSecretArm(deps) {
|
|
|
71
71
|
const { oxy, store } = deps;
|
|
72
72
|
const pin = deps.pin ?? null;
|
|
73
73
|
return oxy.httpService.runSingleFlightDeviceSecretMint(async () => {
|
|
74
|
+
const epoch = oxy.httpService.getSessionEpoch();
|
|
74
75
|
const persisted = await store.load();
|
|
75
76
|
if (!persisted?.deviceId || !persisted?.deviceSecret) {
|
|
76
77
|
return { status: 'no-secret' };
|
|
@@ -136,12 +137,26 @@ export async function refreshDeviceSecretArm(deps) {
|
|
|
136
137
|
expiresAt: mint.expiresAt,
|
|
137
138
|
...(bound ? { sessionId: bound.sessionId, userId: bound.accountId } : {}),
|
|
138
139
|
};
|
|
140
|
+
if (oxy.httpService.getSessionEpoch() !== epoch) {
|
|
141
|
+
// Signed out while minting. The server has rotated the secret, so a store
|
|
142
|
+
// that still holds the presented one must learn the rotation (the
|
|
143
|
+
// token-null lane keeps the store so a reload can restore); a store the
|
|
144
|
+
// sign-out cleared must NOT be refilled. Either way, plant nothing.
|
|
145
|
+
const current = await store.load();
|
|
146
|
+
if (current?.deviceId === persisted.deviceId && current.deviceSecret === persisted.deviceSecret) {
|
|
147
|
+
await store.save(next);
|
|
148
|
+
}
|
|
149
|
+
return { status: 'session-ended' };
|
|
150
|
+
}
|
|
139
151
|
// Persist nextDeviceSecret (read-back-verified) BEFORE planting the token.
|
|
140
152
|
// A failed durable persist must NOT plant.
|
|
141
153
|
const persistedOk = await store.save(next);
|
|
142
154
|
if (!persistedOk) {
|
|
143
155
|
return { status: 'persist-failed' };
|
|
144
156
|
}
|
|
157
|
+
if (oxy.httpService.getSessionEpoch() !== epoch) {
|
|
158
|
+
return { status: 'session-ended' };
|
|
159
|
+
}
|
|
145
160
|
oxy.setTokens(mint.accessToken);
|
|
146
161
|
return {
|
|
147
162
|
status: 'ok',
|
|
@@ -179,6 +194,7 @@ export async function refreshPersistedSession(deps) {
|
|
|
179
194
|
const identity = deps.identity ?? null;
|
|
180
195
|
// The shared keychain is never an identity-bound client's recovery path.
|
|
181
196
|
const allowSharedKeyFallback = identity ? false : (deps.allowSharedKeyFallback ?? isNative());
|
|
197
|
+
const epoch = oxy.httpService.getSessionEpoch();
|
|
182
198
|
// Resolved per call: a re-established identity session can move the pin, and a
|
|
183
199
|
// replaced/removed local key clears it (in which case arm 1 must NOT mint —
|
|
184
200
|
// an unpinned mint would adopt whatever account the device switched to).
|
|
@@ -190,6 +206,8 @@ export async function refreshPersistedSession(deps) {
|
|
|
190
206
|
switch (arm1.status) {
|
|
191
207
|
case 'ok':
|
|
192
208
|
return arm1.token;
|
|
209
|
+
case 'session-ended':
|
|
210
|
+
return null;
|
|
193
211
|
case 'transient':
|
|
194
212
|
logger.debug('Persisted deviceSecret mint failed (transient) — keeping store', { component: 'refresh', method: 'refreshPersistedSession' });
|
|
195
213
|
return null;
|
|
@@ -228,10 +246,18 @@ export async function refreshPersistedSession(deps) {
|
|
|
228
246
|
if (identity) {
|
|
229
247
|
return recoverIdentitySession(oxy, store, identity);
|
|
230
248
|
}
|
|
231
|
-
|
|
249
|
+
// Never after a sign-out: the shared keychain holds an identity KEY, not a
|
|
250
|
+
// session, and using it here would sign the user straight back in.
|
|
251
|
+
if (allowSharedKeyFallback && !oxy.httpService.hasSessionEnded()) {
|
|
232
252
|
try {
|
|
233
|
-
|
|
253
|
+
// Planted here, not by the sign-in: a sign-out that lands while the
|
|
254
|
+
// challenge round-trips must win, or the shared keychain signs the user
|
|
255
|
+
// straight back in.
|
|
256
|
+
const session = await oxy.signInWithSharedIdentity({ plantTokens: false });
|
|
234
257
|
if (session?.accessToken) {
|
|
258
|
+
if (oxy.httpService.getSessionEpoch() !== epoch) {
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
235
261
|
// Repopulate the fast device-secret lane from the shared-key re-mint.
|
|
236
262
|
if (session.deviceId && session.deviceSecret) {
|
|
237
263
|
await store.save({
|
|
@@ -243,6 +269,10 @@ export async function refreshPersistedSession(deps) {
|
|
|
243
269
|
expiresAt: session.expiresAt,
|
|
244
270
|
});
|
|
245
271
|
}
|
|
272
|
+
if (oxy.httpService.getSessionEpoch() !== epoch) {
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
oxy.setTokens(session.accessToken);
|
|
246
276
|
return session.accessToken;
|
|
247
277
|
}
|
|
248
278
|
}
|
|
@@ -69,7 +69,7 @@ const nonEmptyString = (value) => typeof value === 'string' && value.trim().leng
|
|
|
69
69
|
* - `{ error: '<CODE>', error_description }` — RFC 6749 §5.2 / RFC 6750 §3, the
|
|
70
70
|
* OAuth token and userinfo endpoints. `error_description` is the human text
|
|
71
71
|
* and `error` is the machine code, so both survive.
|
|
72
|
-
* - `{ message, code }` — e.g. the API's
|
|
72
|
+
* - `{ message, code }` — e.g. the API's middleware rejections.
|
|
73
73
|
* - `{ error: '<human message>' }` — legacy hand-rolled routes. With no sibling
|
|
74
74
|
* `message`/`error_description` the string is the message, not a code: a bare
|
|
75
75
|
* `error` string is not machine-readable enough to promote to `code`.
|