@oxyhq/core 12.5.4 → 12.7.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/HttpService.js +4 -1
- package/dist/cjs/OxyServices.errors.js +42 -1
- package/dist/cjs/OxyServices.js +2 -1
- package/dist/cjs/crypto/keyManager.js +50 -0
- package/dist/cjs/i18n/locales/en-US.json +7 -0
- package/dist/cjs/i18n/locales/es-ES.json +7 -0
- package/dist/cjs/i18n/locales/locales/en-US.json +7 -0
- package/dist/cjs/i18n/locales/locales/es-ES.json +7 -0
- package/dist/cjs/index.js +5 -4
- package/dist/cjs/mixins/OxyServices.assets.js +175 -25
- package/dist/cjs/mixins/OxyServices.deviceTransfer.js +319 -0
- package/dist/cjs/mixins/index.js +4 -0
- package/dist/cjs/session/SessionClient.js +57 -8
- package/dist/cjs/utils/redactUrl.js +29 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/HttpService.js +4 -1
- package/dist/esm/OxyServices.errors.js +40 -0
- package/dist/esm/OxyServices.js +2 -2
- package/dist/esm/crypto/keyManager.js +50 -0
- package/dist/esm/i18n/locales/en-US.json +7 -0
- package/dist/esm/i18n/locales/es-ES.json +7 -0
- package/dist/esm/i18n/locales/locales/en-US.json +7 -0
- package/dist/esm/i18n/locales/locales/es-ES.json +7 -0
- package/dist/esm/index.js +1 -1
- package/dist/esm/mixins/OxyServices.assets.js +175 -25
- package/dist/esm/mixins/OxyServices.deviceTransfer.js +317 -0
- package/dist/esm/mixins/index.js +4 -0
- package/dist/esm/session/SessionClient.js +57 -8
- package/dist/esm/utils/redactUrl.js +26 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/OxyServices.d.ts +2 -2
- package/dist/types/OxyServices.errors.d.ts +40 -0
- package/dist/types/crypto/keyManager.d.ts +20 -0
- package/dist/types/index.d.ts +3 -2
- package/dist/types/mixins/OxyServices.assets.d.ts +103 -13
- package/dist/types/mixins/OxyServices.deviceTransfer.d.ts +149 -0
- package/dist/types/mixins/index.d.ts +2 -1
- package/dist/types/models/interfaces.d.ts +18 -0
- package/dist/types/session/SessionClient.d.ts +19 -2
- package/dist/types/utils/redactUrl.d.ts +17 -0
- package/package.json +1 -1
- package/src/HttpService.ts +4 -1
- package/src/OxyServices.errors.ts +51 -0
- package/src/OxyServices.ts +2 -2
- package/src/crypto/__tests__/scopedSeed.test.ts +126 -0
- package/src/crypto/keyManager.ts +55 -0
- package/src/i18n/locales/en-US.json +7 -0
- package/src/i18n/locales/es-ES.json +7 -0
- package/src/index.ts +7 -1
- package/src/mixins/OxyServices.assets.ts +192 -28
- package/src/mixins/OxyServices.deviceTransfer.ts +397 -0
- package/src/mixins/__tests__/OxyServices.deviceTransfer.test.ts +270 -0
- package/src/mixins/__tests__/getFileDownloadUrl.test.ts +265 -1
- package/src/mixins/index.ts +6 -0
- package/src/models/interfaces.ts +20 -0
- package/src/session/SessionClient.ts +59 -8
- package/src/session/__tests__/SessionClient.switchTokenOrder.test.ts +170 -0
- package/src/utils/__tests__/redactUrl.test.ts +33 -0
- package/src/utils/redactUrl.ts +28 -0
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Device-to-device Identity Transfer Mixin (b3 Feature 2 — "add a device")
|
|
4
|
+
*
|
|
5
|
+
* Clones an existing device's secp256k1 identity onto a fresh device over a
|
|
6
|
+
* short-lived, unauthenticated relay, WITHOUT the server ever holding a
|
|
7
|
+
* decryption key. Both devices end up holding the SAME private key.
|
|
8
|
+
*
|
|
9
|
+
* The two devices agree on a symmetric key via an ephemeral secp256k1 ECDH
|
|
10
|
+
* handshake (Phase-0 crypto): `deriveSharedSecret` → `hkdfSha256` → a per-pairing
|
|
11
|
+
* transfer key, used with `encryptAead`/`decryptAead` (XChaCha20-Poly1305) to
|
|
12
|
+
* seal `{ privateKey, publicKey }`. The relay carries only ephemeral public keys
|
|
13
|
+
* plus opaque ciphertext — a passive/at-rest-compromised backend cannot decrypt.
|
|
14
|
+
*
|
|
15
|
+
* Roles:
|
|
16
|
+
* - NEW device (no identity): {@link initDeviceTransfer} (generate ephemeral
|
|
17
|
+
* pair, register the pairing, render `pairingId` as a QR) then
|
|
18
|
+
* {@link subscribeDeviceTransfer} (await approval over the `/device-pair`
|
|
19
|
+
* socket with a poll fallback, decrypt, and import the key).
|
|
20
|
+
* - OLD device (has identity): {@link getDeviceTransferInfo} (resolve the
|
|
21
|
+
* scanned `pairingId` server-side — the QR is NOT self-contained) then
|
|
22
|
+
* {@link approveDeviceTransfer} (biometric-gate in the UI, seal the key
|
|
23
|
+
* material, and post it with a fresh signature over the CURRENT identity key).
|
|
24
|
+
*
|
|
25
|
+
* SECURITY: E2E against a passive relay only. Explicitly NOT hardened against an
|
|
26
|
+
* actively-malicious backend MITM'ing the ephemeral keys (same trust boundary as
|
|
27
|
+
* the existing QR sign-in; SAS compare deferred per owner decision). Approve
|
|
28
|
+
* requires BOTH a bearer token AND a fresh identity-key signature.
|
|
29
|
+
*/
|
|
30
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
31
|
+
exports.OxyServicesDeviceTransferMixin = OxyServicesDeviceTransferMixin;
|
|
32
|
+
const elliptic_1 = require("elliptic");
|
|
33
|
+
const utils_1 = require("@noble/hashes/utils");
|
|
34
|
+
const ecdh_1 = require("../crypto/ecdh");
|
|
35
|
+
const kdf_1 = require("../crypto/kdf");
|
|
36
|
+
const aead_1 = require("../crypto/aead");
|
|
37
|
+
const keyManager_1 = require("../crypto/keyManager");
|
|
38
|
+
const signatureService_1 = require("../crypto/signatureService");
|
|
39
|
+
const socketLoader_1 = require("../session/socketLoader");
|
|
40
|
+
const logger_1 = require("../logger");
|
|
41
|
+
const ecCurve = new elliptic_1.ec('secp256k1');
|
|
42
|
+
/**
|
|
43
|
+
* Ephemeral private keys for pairings an instance INITIATED, keyed by pairingId,
|
|
44
|
+
* held per OxyServices instance. In-memory ONLY (never persisted — single-use),
|
|
45
|
+
* cleared once the transfer settles. A module-level WeakMap (rather than a class
|
|
46
|
+
* field) keeps it off the mixin's emitted `.d.ts` (avoids TS4094 on the exported
|
|
47
|
+
* anonymous class) and lets the GC drop it with the instance.
|
|
48
|
+
*/
|
|
49
|
+
const ephemeralKeyStore = new WeakMap();
|
|
50
|
+
function getEphemeralKeys(instance) {
|
|
51
|
+
let keys = ephemeralKeyStore.get(instance);
|
|
52
|
+
if (!keys) {
|
|
53
|
+
keys = new Map();
|
|
54
|
+
ephemeralKeyStore.set(instance, keys);
|
|
55
|
+
}
|
|
56
|
+
return keys;
|
|
57
|
+
}
|
|
58
|
+
/** HKDF `info` binding — MUST match the server/other-device byte-for-byte. */
|
|
59
|
+
const DEVICE_TRANSFER_HKDF_INFO = 'oxy-device-transfer-v1';
|
|
60
|
+
/** Socket.IO namespace the API pushes device-pair approval events on. */
|
|
61
|
+
const DEVICE_PAIR_NAMESPACE = '/device-pair';
|
|
62
|
+
/** Fallback poll cadence — the socket delivers approval instantly; this covers
|
|
63
|
+
* the case where the socket can't connect. */
|
|
64
|
+
const DEVICE_TRANSFER_POLL_INTERVAL_MS = 2500;
|
|
65
|
+
/** Action string signed on approve (mirrors `link_identity`'s scheme). */
|
|
66
|
+
const DEVICE_TRANSFER_APPROVE_ACTION = 'approve_device_transfer';
|
|
67
|
+
/**
|
|
68
|
+
* Derive the per-pairing symmetric transfer key from an ECDH shared secret.
|
|
69
|
+
* Identical on both devices: `HKDF(ECDH, salt=pairingId, info=v1)`.
|
|
70
|
+
*/
|
|
71
|
+
function deriveTransferKey(sharedSecret, pairingId) {
|
|
72
|
+
return (0, kdf_1.hkdfSha256)(sharedSecret, (0, utils_1.utf8ToBytes)(pairingId), (0, utils_1.utf8ToBytes)(DEVICE_TRANSFER_HKDF_INFO), 32);
|
|
73
|
+
}
|
|
74
|
+
function OxyServicesDeviceTransferMixin(Base) {
|
|
75
|
+
return class extends Base {
|
|
76
|
+
constructor(...args) {
|
|
77
|
+
super(...args);
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* NEW device — begin an "add a device" transfer. Generates a single-use
|
|
81
|
+
* ephemeral secp256k1 pair, registers the pairing, and returns the
|
|
82
|
+
* `pairingId` to render as a QR. The ephemeral private key is held in memory
|
|
83
|
+
* (keyed by `pairingId`) for the subsequent {@link subscribeDeviceTransfer}.
|
|
84
|
+
*
|
|
85
|
+
* @param label - Optional human-readable label for this new device.
|
|
86
|
+
*/
|
|
87
|
+
async initDeviceTransfer(label) {
|
|
88
|
+
try {
|
|
89
|
+
const ephKeyPair = ecCurve.genKeyPair();
|
|
90
|
+
const ephPrivateKey = ephKeyPair.getPrivate('hex');
|
|
91
|
+
const ephPublicKey = ephKeyPair.getPublic('hex');
|
|
92
|
+
const res = await this.makeRequest('POST', '/identity/device-transfer/init', { newEphPub: ephPublicKey, ...(label ? { newDeviceLabel: label } : {}) }, { cache: false });
|
|
93
|
+
getEphemeralKeys(this).set(res.pairingId, ephPrivateKey);
|
|
94
|
+
return {
|
|
95
|
+
pairingId: res.pairingId,
|
|
96
|
+
expiresAt: res.expiresAt,
|
|
97
|
+
newEphemeralPublicKey: ephPublicKey,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
throw this.handleError(error);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Resolve a pairing server-side (the QR carries only `pairingId`). The OLD
|
|
106
|
+
* device calls this after scanning to read the new device's ephemeral public
|
|
107
|
+
* key + label; the NEW device polls it to fetch the sealed material once
|
|
108
|
+
* approved. Public — no auth required.
|
|
109
|
+
*/
|
|
110
|
+
async getDeviceTransferInfo(pairingId) {
|
|
111
|
+
try {
|
|
112
|
+
return await this.makeRequest('GET', `/identity/device-transfer/${encodeURIComponent(pairingId)}`, undefined, { cache: false, retry: false });
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
throw this.handleError(error);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* OLD device — approve a scanned transfer. Reads the new device's ephemeral
|
|
120
|
+
* public key, derives the shared transfer key, AEAD-seals
|
|
121
|
+
* `{ privateKey, publicKey }`, and posts it PLUS a fresh signature over
|
|
122
|
+
* `{ action:'approve_device_transfer', pairingId, timestamp }` made with the
|
|
123
|
+
* CURRENT identity key (dual-proof alongside the bearer token).
|
|
124
|
+
*
|
|
125
|
+
* NATIVE-ONLY: requires a stored identity (throws otherwise). The UI must
|
|
126
|
+
* biometric-gate before calling this — a key clone leaves the device.
|
|
127
|
+
*/
|
|
128
|
+
async approveDeviceTransfer(pairingId) {
|
|
129
|
+
try {
|
|
130
|
+
const info = await this.getDeviceTransferInfo(pairingId);
|
|
131
|
+
if (info.status !== 'pending') {
|
|
132
|
+
throw new Error(`This transfer can no longer be approved (status: ${info.status}).`);
|
|
133
|
+
}
|
|
134
|
+
const privateKey = await keyManager_1.KeyManager.getPrivateKey();
|
|
135
|
+
const publicKey = await keyManager_1.KeyManager.getPublicKey();
|
|
136
|
+
if (!privateKey || !publicKey) {
|
|
137
|
+
throw new Error('No identity found on this device. Create or import an identity first.');
|
|
138
|
+
}
|
|
139
|
+
// Ephemeral ECDH → per-pairing transfer key.
|
|
140
|
+
const oldEphKeyPair = ecCurve.genKeyPair();
|
|
141
|
+
const oldEphPrivateKey = oldEphKeyPair.getPrivate('hex');
|
|
142
|
+
const oldEphPublicKey = oldEphKeyPair.getPublic('hex');
|
|
143
|
+
const sharedSecret = (0, ecdh_1.deriveSharedSecret)(oldEphPrivateKey, info.newDeviceEphemeralPublicKey);
|
|
144
|
+
const transferKey = deriveTransferKey(sharedSecret, pairingId);
|
|
145
|
+
// Seal the identity key material.
|
|
146
|
+
const plaintext = (0, utils_1.utf8ToBytes)(JSON.stringify({ privateKey, publicKey }));
|
|
147
|
+
const { nonce, ciphertext } = (0, aead_1.encryptAead)(transferKey, plaintext);
|
|
148
|
+
// Dual-proof: prove control of the CURRENT identity key (a bearer alone
|
|
149
|
+
// must not be able to exfiltrate the private key).
|
|
150
|
+
const timestamp = Date.now();
|
|
151
|
+
const message = JSON.stringify({
|
|
152
|
+
action: DEVICE_TRANSFER_APPROVE_ACTION,
|
|
153
|
+
pairingId,
|
|
154
|
+
timestamp,
|
|
155
|
+
});
|
|
156
|
+
const signature = await signatureService_1.SignatureService.sign(message);
|
|
157
|
+
return await this.makeRequest('POST', `/identity/device-transfer/${encodeURIComponent(pairingId)}/approve`, {
|
|
158
|
+
oldEphPub: oldEphPublicKey,
|
|
159
|
+
ciphertext: (0, utils_1.bytesToHex)(ciphertext),
|
|
160
|
+
nonce: (0, utils_1.bytesToHex)(nonce),
|
|
161
|
+
signature,
|
|
162
|
+
timestamp,
|
|
163
|
+
}, { cache: false });
|
|
164
|
+
}
|
|
165
|
+
catch (error) {
|
|
166
|
+
throw this.handleError(error);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* OLD device — deny (cancel) a scanned transfer so the waiting new device
|
|
171
|
+
* stops. Public — no auth required.
|
|
172
|
+
*/
|
|
173
|
+
async denyDeviceTransfer(pairingId) {
|
|
174
|
+
try {
|
|
175
|
+
return await this.makeRequest('POST', `/identity/device-transfer/${encodeURIComponent(pairingId)}/deny`, undefined, { cache: false });
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
throw this.handleError(error);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* NEW device — await approval for a pairing started with
|
|
183
|
+
* {@link initDeviceTransfer}, then decrypt and import the transferred
|
|
184
|
+
* identity key. Primary path is an instant `device_pair_update` push over the
|
|
185
|
+
* `/device-pair` socket; a poll backstops a socket that can't connect.
|
|
186
|
+
*
|
|
187
|
+
* On `approved`: re-derives the shared transfer key from the old device's
|
|
188
|
+
* ephemeral public key, decrypts `{ privateKey, publicKey }`, imports it via
|
|
189
|
+
* `KeyManager.importKeyPair(privateKey, { overwrite: false })`, and invokes
|
|
190
|
+
* `onOutcome({ status:'approved', publicKey })`. The caller then runs the
|
|
191
|
+
* NORMAL challenge/verify sign-in — this method does not mint a session.
|
|
192
|
+
*
|
|
193
|
+
* @returns An unsubscribe function; call it to stop waiting (also called
|
|
194
|
+
* automatically once the transfer settles).
|
|
195
|
+
*/
|
|
196
|
+
subscribeDeviceTransfer(pairingId, onOutcome) {
|
|
197
|
+
const ephemeralKeys = getEphemeralKeys(this);
|
|
198
|
+
const ephPrivateKey = ephemeralKeys.get(pairingId);
|
|
199
|
+
if (!ephPrivateKey) {
|
|
200
|
+
throw new Error('No pending device transfer for this pairing id. Call initDeviceTransfer first.');
|
|
201
|
+
}
|
|
202
|
+
let settled = false;
|
|
203
|
+
let inFlight = false;
|
|
204
|
+
let socket = null;
|
|
205
|
+
let pollTimer = null;
|
|
206
|
+
const cleanup = () => {
|
|
207
|
+
if (pollTimer !== null) {
|
|
208
|
+
clearInterval(pollTimer);
|
|
209
|
+
pollTimer = null;
|
|
210
|
+
}
|
|
211
|
+
if (socket) {
|
|
212
|
+
try {
|
|
213
|
+
socket.off('device_pair_update');
|
|
214
|
+
socket.off('connect');
|
|
215
|
+
socket.disconnect();
|
|
216
|
+
}
|
|
217
|
+
catch (error) {
|
|
218
|
+
logger_1.logger.debug('[DeviceTransfer] socket close failed', { component: 'DeviceTransfer' }, error);
|
|
219
|
+
}
|
|
220
|
+
socket = null;
|
|
221
|
+
}
|
|
222
|
+
ephemeralKeys.delete(pairingId);
|
|
223
|
+
};
|
|
224
|
+
const finish = (outcome) => {
|
|
225
|
+
if (settled)
|
|
226
|
+
return;
|
|
227
|
+
settled = true;
|
|
228
|
+
cleanup();
|
|
229
|
+
onOutcome(outcome);
|
|
230
|
+
};
|
|
231
|
+
// Re-check authoritative status; on `approved`, decrypt + import.
|
|
232
|
+
const check = async () => {
|
|
233
|
+
if (settled || inFlight)
|
|
234
|
+
return;
|
|
235
|
+
inFlight = true;
|
|
236
|
+
try {
|
|
237
|
+
const info = await this.getDeviceTransferInfo(pairingId);
|
|
238
|
+
if (settled)
|
|
239
|
+
return;
|
|
240
|
+
if (info.status === 'denied') {
|
|
241
|
+
finish({ status: 'denied' });
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
if (info.status === 'expired') {
|
|
245
|
+
finish({ status: 'expired' });
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
if (info.status === 'approved' &&
|
|
249
|
+
info.oldDeviceEphemeralPublicKey &&
|
|
250
|
+
info.ciphertext &&
|
|
251
|
+
info.nonce) {
|
|
252
|
+
const sharedSecret = (0, ecdh_1.deriveSharedSecret)(ephPrivateKey, info.oldDeviceEphemeralPublicKey);
|
|
253
|
+
const transferKey = deriveTransferKey(sharedSecret, pairingId);
|
|
254
|
+
const plaintext = (0, aead_1.decryptAead)(transferKey, (0, utils_1.hexToBytes)(info.nonce), (0, utils_1.hexToBytes)(info.ciphertext));
|
|
255
|
+
const parsed = JSON.parse((0, utils_1.bytesToUtf8)(plaintext));
|
|
256
|
+
// Import WITHOUT overwrite: a fresh device has no identity, and we
|
|
257
|
+
// must never silently clobber an existing one.
|
|
258
|
+
const importedPublicKey = await keyManager_1.KeyManager.importKeyPair(parsed.privateKey, {
|
|
259
|
+
overwrite: false,
|
|
260
|
+
});
|
|
261
|
+
finish({ status: 'approved', publicKey: importedPublicKey });
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
catch (error) {
|
|
265
|
+
// Transient (a poll tick that raced the approve write, a decrypt on a
|
|
266
|
+
// half-written row) — keep waiting; the next tick/push retries.
|
|
267
|
+
logger_1.logger.debug('[DeviceTransfer] status check failed', { component: 'DeviceTransfer' }, error);
|
|
268
|
+
}
|
|
269
|
+
finally {
|
|
270
|
+
inFlight = false;
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
// Primary: instant socket wake. Fall back to polling if unavailable.
|
|
274
|
+
void (async () => {
|
|
275
|
+
const io = await (0, socketLoader_1.getSocketIO)();
|
|
276
|
+
if (settled || !io)
|
|
277
|
+
return;
|
|
278
|
+
try {
|
|
279
|
+
const s = io(`${this.getBaseURL()}${DEVICE_PAIR_NAMESPACE}`, {
|
|
280
|
+
transports: ['websocket'],
|
|
281
|
+
autoConnect: true,
|
|
282
|
+
reconnection: true,
|
|
283
|
+
reconnectionAttempts: Number.POSITIVE_INFINITY,
|
|
284
|
+
reconnectionDelay: 1000,
|
|
285
|
+
reconnectionDelayMax: 10000,
|
|
286
|
+
});
|
|
287
|
+
const join = () => {
|
|
288
|
+
try {
|
|
289
|
+
s.emit('join', pairingId);
|
|
290
|
+
}
|
|
291
|
+
catch (error) {
|
|
292
|
+
logger_1.logger.debug('[DeviceTransfer] join failed', { component: 'DeviceTransfer' }, error);
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
s.on('connect', join);
|
|
296
|
+
if (s.connected)
|
|
297
|
+
join();
|
|
298
|
+
s.on('device_pair_update', () => {
|
|
299
|
+
void check();
|
|
300
|
+
});
|
|
301
|
+
socket = s;
|
|
302
|
+
}
|
|
303
|
+
catch (error) {
|
|
304
|
+
logger_1.logger.debug('[DeviceTransfer] socket create failed (poll fallback)', { component: 'DeviceTransfer' }, error);
|
|
305
|
+
}
|
|
306
|
+
})();
|
|
307
|
+
// Fallback poll (also covers the already-approved-before-subscribe case via
|
|
308
|
+
// the immediate first tick below). unref so it never holds a Node event
|
|
309
|
+
// loop / test runner open.
|
|
310
|
+
pollTimer = setInterval(() => {
|
|
311
|
+
void check();
|
|
312
|
+
}, DEVICE_TRANSFER_POLL_INTERVAL_MS);
|
|
313
|
+
pollTimer.unref?.();
|
|
314
|
+
// Immediate first check — the transfer may already be approved/denied.
|
|
315
|
+
void check();
|
|
316
|
+
return cleanup;
|
|
317
|
+
}
|
|
318
|
+
};
|
|
319
|
+
}
|
package/dist/cjs/mixins/index.js
CHANGED
|
@@ -33,6 +33,7 @@ const OxyServices_civic_1 = require("./OxyServices.civic");
|
|
|
33
33
|
const OxyServices_nodes_1 = require("./OxyServices.nodes");
|
|
34
34
|
const OxyServices_links_1 = require("./OxyServices.links");
|
|
35
35
|
const OxyServices_deviceBoot_1 = require("./OxyServices.deviceBoot");
|
|
36
|
+
const OxyServices_deviceTransfer_1 = require("./OxyServices.deviceTransfer");
|
|
36
37
|
/**
|
|
37
38
|
* Mixin pipeline - applied in order from first to last.
|
|
38
39
|
*
|
|
@@ -86,6 +87,9 @@ const MIXIN_PIPELINE = [
|
|
|
86
87
|
// Device-first token mint: the client half of the zero-cookie transport
|
|
87
88
|
// (`mintFromDeviceSecret` → `POST /session/device/token`).
|
|
88
89
|
OxyServices_deviceBoot_1.OxyServicesDeviceBootMixin,
|
|
90
|
+
// Device-to-device identity transfer ("add a device"): E2E-encrypted key
|
|
91
|
+
// clone over a short-lived relay (b3 Feature 2).
|
|
92
|
+
OxyServices_deviceTransfer_1.OxyServicesDeviceTransferMixin,
|
|
89
93
|
// Utility (last, can use all above)
|
|
90
94
|
OxyServices_utility_1.OxyServicesUtilityMixin,
|
|
91
95
|
];
|
|
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.SessionClient = void 0;
|
|
4
4
|
const contracts_1 = require("@oxyhq/contracts");
|
|
5
5
|
const logger_1 = require("../logger");
|
|
6
|
+
const cacheKey_1 = require("../utils/cacheKey");
|
|
6
7
|
const socketLoader_1 = require("./socketLoader");
|
|
7
8
|
/**
|
|
8
9
|
* Same-origin `BroadcastChannel` name for instant, network-free session-state
|
|
@@ -83,8 +84,25 @@ class SessionClient {
|
|
|
83
84
|
}
|
|
84
85
|
}
|
|
85
86
|
}
|
|
86
|
-
/**
|
|
87
|
-
|
|
87
|
+
/**
|
|
88
|
+
* Validate + last-writer-wins by revision. Returns true if applied.
|
|
89
|
+
*
|
|
90
|
+
* `activeToken` (sync path only) is the server-issued access token for
|
|
91
|
+
* `raw.activeAccountId`. When present and the state is applied, it is planted
|
|
92
|
+
* BEFORE any subscriber is notified so the bearer already belongs to the new
|
|
93
|
+
* active account — the local switch/bootstrap path then needs no redundant
|
|
94
|
+
* device-secret mint. Push-origin applies carry no token and rely on the
|
|
95
|
+
* mint-before-notify gate below.
|
|
96
|
+
*
|
|
97
|
+
* ORDERING INVARIANT: a subscriber must NEVER observe a newly-active account
|
|
98
|
+
* while the planted bearer still identifies the PREVIOUS one — otherwise a
|
|
99
|
+
* `useCurrentUser`-style refetch fires under the wrong account's token (the
|
|
100
|
+
* account-switch 404 race). So when a transport is available and the planted
|
|
101
|
+
* bearer does not already belong to `next.activeAccountId`, minting is awaited
|
|
102
|
+
* BEFORE `notify()`. This covers EVERY notify source (a switch push, a
|
|
103
|
+
* cross-device push, a cold mint), not just the initial "no bearer yet" case.
|
|
104
|
+
*/
|
|
105
|
+
applyState(raw, origin = 'push', activeToken) {
|
|
88
106
|
const next = (0, contracts_1.safeParseContract)(contracts_1.deviceSessionStateSchema, raw);
|
|
89
107
|
if (!next) {
|
|
90
108
|
logger_1.logger.warn('[SessionClient] discarded invalid session state');
|
|
@@ -101,9 +119,26 @@ class SessionClient {
|
|
|
101
119
|
next.revision <= this.state.revision) {
|
|
102
120
|
return false;
|
|
103
121
|
}
|
|
122
|
+
const previousState = this.state;
|
|
104
123
|
this.state = next;
|
|
124
|
+
// Plant the sync-supplied active token (it is for `next.activeAccountId`)
|
|
125
|
+
// now — before the notify below — so the bearer matches the new active
|
|
126
|
+
// account when subscribers observe it. Guarded on difference to avoid a
|
|
127
|
+
// redundant token-change notification on an unchanged token (bootstrap
|
|
128
|
+
// restate).
|
|
129
|
+
if (activeToken && next.activeAccountId !== null && activeToken !== this.host.getAccessToken()) {
|
|
130
|
+
this.host.setTokens(activeToken);
|
|
131
|
+
}
|
|
105
132
|
const transport = this.options.transport;
|
|
106
|
-
const
|
|
133
|
+
const activeAccountId = next.activeAccountId;
|
|
134
|
+
// Mint before notifying when the bearer does not already belong to the new
|
|
135
|
+
// active account: no bearer at all, an opaque bearer, OR a bearer for a
|
|
136
|
+
// DIFFERENT account. `computeIdentityTag` yields the token's `userId`/`id`
|
|
137
|
+
// for a real JWT (comparable to the account id) and a non-account sentinel
|
|
138
|
+
// otherwise, so a mismatch always resolves to "mint".
|
|
139
|
+
const needsMintBeforeNotify = transport != null &&
|
|
140
|
+
next.accounts.length > 0 &&
|
|
141
|
+
(activeAccountId === null || (0, cacheKey_1.computeIdentityTag)(this.host.getAccessToken()) !== activeAccountId);
|
|
107
142
|
const finishApply = () => {
|
|
108
143
|
this.notify();
|
|
109
144
|
if (next.accounts.length === 0 && this.options.onUnauthenticated) {
|
|
@@ -117,8 +152,10 @@ class SessionClient {
|
|
|
117
152
|
};
|
|
118
153
|
if (needsMintBeforeNotify) {
|
|
119
154
|
void transport.ensureActiveToken(next).then(finishApply).catch((error) => {
|
|
120
|
-
logger_1.logger.warn('[SessionClient] ensureActiveToken failed', { component: 'SessionClient' }, error);
|
|
121
|
-
|
|
155
|
+
logger_1.logger.warn('[SessionClient] ensureActiveToken failed — reverting session state', { component: 'SessionClient' }, error);
|
|
156
|
+
// Do NOT notify under a mismatched bearer. Revert to the last applied
|
|
157
|
+
// state so subscribers keep observing the account whose token is planted.
|
|
158
|
+
this.state = previousState ?? null;
|
|
122
159
|
});
|
|
123
160
|
}
|
|
124
161
|
else {
|
|
@@ -156,9 +193,21 @@ class SessionClient {
|
|
|
156
193
|
}
|
|
157
194
|
// A `sync` is always the response to a direct REST call this client made
|
|
158
195
|
// (bootstrap / switch / signOut / add) → a `request`-origin, authoritative
|
|
159
|
-
// verdict.
|
|
160
|
-
|
|
161
|
-
|
|
196
|
+
// verdict. Hand the active token to `applyState`: in the applied path it is
|
|
197
|
+
// planted BEFORE notify (bearer matches the new active account when
|
|
198
|
+
// subscribers observe it, and no redundant device-secret mint is triggered).
|
|
199
|
+
const applied = this.applyState(sync.state, 'request', sync.activeToken?.accessToken);
|
|
200
|
+
// Equal-revision restate (this revision was already applied by a preceding
|
|
201
|
+
// socket push): `applyState` no-ops without planting, but the token still
|
|
202
|
+
// needs planting. Guard on the sync's active account STILL being the current
|
|
203
|
+
// active account so a stale response cannot adopt a token for an account a
|
|
204
|
+
// newer state already switched away from.
|
|
205
|
+
if (!applied &&
|
|
206
|
+
sync.activeToken &&
|
|
207
|
+
this.state &&
|
|
208
|
+
sync.state.activeAccountId !== null &&
|
|
209
|
+
sync.state.activeAccountId === this.state.activeAccountId &&
|
|
210
|
+
sync.activeToken.accessToken !== this.host.getAccessToken()) {
|
|
162
211
|
this.host.setTokens(sync.activeToken.accessToken);
|
|
163
212
|
}
|
|
164
213
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.redactUrlQuery = redactUrlQuery;
|
|
4
|
+
/**
|
|
5
|
+
* URL redaction for logging.
|
|
6
|
+
*
|
|
7
|
+
* Asset URLs the API hands back for private assets carry a scoped, short-lived
|
|
8
|
+
* media token (`mt=…`) in their query string. That token is a bearer credential
|
|
9
|
+
* for the underlying object, so it must never land in a log line, breadcrumb,
|
|
10
|
+
* or metric — a captured log would otherwise grant read access until the token
|
|
11
|
+
* expires. Query strings on API URLs can also carry other sensitive params, so
|
|
12
|
+
* we redact the whole query rather than allow-listing one key.
|
|
13
|
+
*
|
|
14
|
+
* `redactUrlQuery` returns the URL's path portion with a `?<redacted>` marker
|
|
15
|
+
* when a query string is present, and the input unchanged otherwise. It is
|
|
16
|
+
* defensive: any input that does not parse as a URL is passed through as-is,
|
|
17
|
+
* except that a bare `?query` tail is still stripped so a relative path with a
|
|
18
|
+
* query never leaks.
|
|
19
|
+
*/
|
|
20
|
+
function redactUrlQuery(url) {
|
|
21
|
+
if (typeof url !== 'string' || url.length === 0) {
|
|
22
|
+
return url;
|
|
23
|
+
}
|
|
24
|
+
const queryIndex = url.indexOf('?');
|
|
25
|
+
if (queryIndex === -1) {
|
|
26
|
+
return url;
|
|
27
|
+
}
|
|
28
|
+
return `${url.slice(0, queryIndex)}?<redacted>`;
|
|
29
|
+
}
|