@oxyhq/core 12.6.0 → 12.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/crypto/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/mixins/OxyServices.deviceTransfer.js +319 -0
- package/dist/cjs/mixins/OxyServices.utility.js +11 -1
- package/dist/cjs/mixins/index.js +4 -0
- package/dist/cjs/server/auth.js +3 -0
- package/dist/cjs/server/index.js +2 -1
- package/dist/cjs/utils/oxyServiceEnvironment.js +19 -0
- package/dist/esm/.tsbuildinfo +1 -1
- 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/mixins/OxyServices.deviceTransfer.js +317 -0
- package/dist/esm/mixins/OxyServices.utility.js +11 -1
- package/dist/esm/mixins/index.js +4 -0
- package/dist/esm/server/auth.js +2 -0
- package/dist/esm/server/index.js +1 -1
- package/dist/esm/utils/oxyServiceEnvironment.js +16 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/crypto/keyManager.d.ts +20 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/mixins/OxyServices.deviceTransfer.d.ts +149 -0
- package/dist/types/mixins/OxyServices.utility.d.ts +3 -0
- package/dist/types/mixins/index.d.ts +2 -1
- package/dist/types/server/auth.d.ts +4 -0
- package/dist/types/server/index.d.ts +2 -2
- package/dist/types/utils/oxyServiceEnvironment.d.ts +17 -0
- package/package.json +1 -1
- 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 +4 -0
- package/src/mixins/OxyServices.deviceTransfer.ts +397 -0
- package/src/mixins/OxyServices.utility.ts +19 -1
- package/src/mixins/__tests__/OxyServices.deviceTransfer.test.ts +270 -0
- package/src/mixins/__tests__/serviceAuth.test.ts +65 -0
- package/src/mixins/index.ts +6 -0
- package/src/server/auth.ts +5 -0
- package/src/server/index.ts +2 -0
- package/src/utils/__tests__/oxyServiceEnvironment.test.ts +7 -0
- package/src/utils/oxyServiceEnvironment.ts +17 -0
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Device-to-device Identity Transfer Mixin (b3 Feature 2 — "add a device")
|
|
3
|
+
*
|
|
4
|
+
* Clones an existing device's secp256k1 identity onto a fresh device over a
|
|
5
|
+
* short-lived, unauthenticated relay, WITHOUT the server ever holding a
|
|
6
|
+
* decryption key. Both devices end up holding the SAME private key.
|
|
7
|
+
*
|
|
8
|
+
* The two devices agree on a symmetric key via an ephemeral secp256k1 ECDH
|
|
9
|
+
* handshake (Phase-0 crypto): `deriveSharedSecret` → `hkdfSha256` → a per-pairing
|
|
10
|
+
* transfer key, used with `encryptAead`/`decryptAead` (XChaCha20-Poly1305) to
|
|
11
|
+
* seal `{ privateKey, publicKey }`. The relay carries only ephemeral public keys
|
|
12
|
+
* plus opaque ciphertext — a passive/at-rest-compromised backend cannot decrypt.
|
|
13
|
+
*
|
|
14
|
+
* Roles:
|
|
15
|
+
* - NEW device (no identity): {@link initDeviceTransfer} (generate ephemeral
|
|
16
|
+
* pair, register the pairing, render `pairingId` as a QR) then
|
|
17
|
+
* {@link subscribeDeviceTransfer} (await approval over the `/device-pair`
|
|
18
|
+
* socket with a poll fallback, decrypt, and import the key).
|
|
19
|
+
* - OLD device (has identity): {@link getDeviceTransferInfo} (resolve the
|
|
20
|
+
* scanned `pairingId` server-side — the QR is NOT self-contained) then
|
|
21
|
+
* {@link approveDeviceTransfer} (biometric-gate in the UI, seal the key
|
|
22
|
+
* material, and post it with a fresh signature over the CURRENT identity key).
|
|
23
|
+
*
|
|
24
|
+
* SECURITY: E2E against a passive relay only. Explicitly NOT hardened against an
|
|
25
|
+
* actively-malicious backend MITM'ing the ephemeral keys (same trust boundary as
|
|
26
|
+
* the existing QR sign-in; SAS compare deferred per owner decision). Approve
|
|
27
|
+
* requires BOTH a bearer token AND a fresh identity-key signature.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { ec as EC } from 'elliptic';
|
|
31
|
+
import { bytesToHex, hexToBytes, utf8ToBytes, bytesToUtf8 } from '@noble/hashes/utils';
|
|
32
|
+
import type { OxyServicesBase } from '../OxyServices.base';
|
|
33
|
+
import type {
|
|
34
|
+
DeviceTransferInfoResponse,
|
|
35
|
+
DeviceTransferInitResponse,
|
|
36
|
+
DeviceTransferApproveResponse,
|
|
37
|
+
DeviceTransferDenyResponse,
|
|
38
|
+
} from '@oxyhq/contracts';
|
|
39
|
+
import { deriveSharedSecret } from '../crypto/ecdh';
|
|
40
|
+
import { hkdfSha256 } from '../crypto/kdf';
|
|
41
|
+
import { encryptAead, decryptAead } from '../crypto/aead';
|
|
42
|
+
import { KeyManager } from '../crypto/keyManager';
|
|
43
|
+
import { SignatureService } from '../crypto/signatureService';
|
|
44
|
+
import { getSocketIO } from '../session/socketLoader';
|
|
45
|
+
import type { MinimalSocket } from '../session/socketLoader';
|
|
46
|
+
import { logger } from '../logger';
|
|
47
|
+
|
|
48
|
+
const ecCurve = new EC('secp256k1');
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Ephemeral private keys for pairings an instance INITIATED, keyed by pairingId,
|
|
52
|
+
* held per OxyServices instance. In-memory ONLY (never persisted — single-use),
|
|
53
|
+
* cleared once the transfer settles. A module-level WeakMap (rather than a class
|
|
54
|
+
* field) keeps it off the mixin's emitted `.d.ts` (avoids TS4094 on the exported
|
|
55
|
+
* anonymous class) and lets the GC drop it with the instance.
|
|
56
|
+
*/
|
|
57
|
+
const ephemeralKeyStore = new WeakMap<object, Map<string, string>>();
|
|
58
|
+
|
|
59
|
+
function getEphemeralKeys(instance: object): Map<string, string> {
|
|
60
|
+
let keys = ephemeralKeyStore.get(instance);
|
|
61
|
+
if (!keys) {
|
|
62
|
+
keys = new Map<string, string>();
|
|
63
|
+
ephemeralKeyStore.set(instance, keys);
|
|
64
|
+
}
|
|
65
|
+
return keys;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** HKDF `info` binding — MUST match the server/other-device byte-for-byte. */
|
|
69
|
+
const DEVICE_TRANSFER_HKDF_INFO = 'oxy-device-transfer-v1';
|
|
70
|
+
/** Socket.IO namespace the API pushes device-pair approval events on. */
|
|
71
|
+
const DEVICE_PAIR_NAMESPACE = '/device-pair';
|
|
72
|
+
/** Fallback poll cadence — the socket delivers approval instantly; this covers
|
|
73
|
+
* the case where the socket can't connect. */
|
|
74
|
+
const DEVICE_TRANSFER_POLL_INTERVAL_MS = 2500;
|
|
75
|
+
/** Action string signed on approve (mirrors `link_identity`'s scheme). */
|
|
76
|
+
const DEVICE_TRANSFER_APPROVE_ACTION = 'approve_device_transfer';
|
|
77
|
+
|
|
78
|
+
/** Result of {@link OxyServicesDeviceTransferMixin.initDeviceTransfer}. */
|
|
79
|
+
export interface InitDeviceTransferResult {
|
|
80
|
+
/** 128-bit single-use handle to render in the QR (also the HKDF salt). */
|
|
81
|
+
pairingId: string;
|
|
82
|
+
/** ISO-8601 expiry (3 minutes). */
|
|
83
|
+
expiresAt: string;
|
|
84
|
+
/** The new device's ephemeral public key registered with the relay. */
|
|
85
|
+
newEphemeralPublicKey: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Terminal outcome delivered to {@link subscribeDeviceTransfer}'s callback. */
|
|
89
|
+
export type DeviceTransferOutcome =
|
|
90
|
+
| { status: 'approved'; publicKey: string }
|
|
91
|
+
| { status: 'denied' }
|
|
92
|
+
| { status: 'expired' };
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Derive the per-pairing symmetric transfer key from an ECDH shared secret.
|
|
96
|
+
* Identical on both devices: `HKDF(ECDH, salt=pairingId, info=v1)`.
|
|
97
|
+
*/
|
|
98
|
+
function deriveTransferKey(sharedSecret: Uint8Array, pairingId: string): Uint8Array {
|
|
99
|
+
return hkdfSha256(
|
|
100
|
+
sharedSecret,
|
|
101
|
+
utf8ToBytes(pairingId),
|
|
102
|
+
utf8ToBytes(DEVICE_TRANSFER_HKDF_INFO),
|
|
103
|
+
32,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function OxyServicesDeviceTransferMixin<T extends typeof OxyServicesBase>(Base: T) {
|
|
108
|
+
return class extends Base {
|
|
109
|
+
constructor(...args: any[]) {
|
|
110
|
+
super(...(args as [any]));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* NEW device — begin an "add a device" transfer. Generates a single-use
|
|
115
|
+
* ephemeral secp256k1 pair, registers the pairing, and returns the
|
|
116
|
+
* `pairingId` to render as a QR. The ephemeral private key is held in memory
|
|
117
|
+
* (keyed by `pairingId`) for the subsequent {@link subscribeDeviceTransfer}.
|
|
118
|
+
*
|
|
119
|
+
* @param label - Optional human-readable label for this new device.
|
|
120
|
+
*/
|
|
121
|
+
async initDeviceTransfer(label?: string): Promise<InitDeviceTransferResult> {
|
|
122
|
+
try {
|
|
123
|
+
const ephKeyPair = ecCurve.genKeyPair();
|
|
124
|
+
const ephPrivateKey = ephKeyPair.getPrivate('hex');
|
|
125
|
+
const ephPublicKey = ephKeyPair.getPublic('hex');
|
|
126
|
+
|
|
127
|
+
const res = await this.makeRequest<DeviceTransferInitResponse>(
|
|
128
|
+
'POST',
|
|
129
|
+
'/identity/device-transfer/init',
|
|
130
|
+
{ newEphPub: ephPublicKey, ...(label ? { newDeviceLabel: label } : {}) },
|
|
131
|
+
{ cache: false },
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
getEphemeralKeys(this).set(res.pairingId, ephPrivateKey);
|
|
135
|
+
return {
|
|
136
|
+
pairingId: res.pairingId,
|
|
137
|
+
expiresAt: res.expiresAt,
|
|
138
|
+
newEphemeralPublicKey: ephPublicKey,
|
|
139
|
+
};
|
|
140
|
+
} catch (error) {
|
|
141
|
+
throw this.handleError(error);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Resolve a pairing server-side (the QR carries only `pairingId`). The OLD
|
|
147
|
+
* device calls this after scanning to read the new device's ephemeral public
|
|
148
|
+
* key + label; the NEW device polls it to fetch the sealed material once
|
|
149
|
+
* approved. Public — no auth required.
|
|
150
|
+
*/
|
|
151
|
+
async getDeviceTransferInfo(pairingId: string): Promise<DeviceTransferInfoResponse> {
|
|
152
|
+
try {
|
|
153
|
+
return await this.makeRequest<DeviceTransferInfoResponse>(
|
|
154
|
+
'GET',
|
|
155
|
+
`/identity/device-transfer/${encodeURIComponent(pairingId)}`,
|
|
156
|
+
undefined,
|
|
157
|
+
{ cache: false, retry: false },
|
|
158
|
+
);
|
|
159
|
+
} catch (error) {
|
|
160
|
+
throw this.handleError(error);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* OLD device — approve a scanned transfer. Reads the new device's ephemeral
|
|
166
|
+
* public key, derives the shared transfer key, AEAD-seals
|
|
167
|
+
* `{ privateKey, publicKey }`, and posts it PLUS a fresh signature over
|
|
168
|
+
* `{ action:'approve_device_transfer', pairingId, timestamp }` made with the
|
|
169
|
+
* CURRENT identity key (dual-proof alongside the bearer token).
|
|
170
|
+
*
|
|
171
|
+
* NATIVE-ONLY: requires a stored identity (throws otherwise). The UI must
|
|
172
|
+
* biometric-gate before calling this — a key clone leaves the device.
|
|
173
|
+
*/
|
|
174
|
+
async approveDeviceTransfer(pairingId: string): Promise<DeviceTransferApproveResponse> {
|
|
175
|
+
try {
|
|
176
|
+
const info = await this.getDeviceTransferInfo(pairingId);
|
|
177
|
+
if (info.status !== 'pending') {
|
|
178
|
+
throw new Error(`This transfer can no longer be approved (status: ${info.status}).`);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const privateKey = await KeyManager.getPrivateKey();
|
|
182
|
+
const publicKey = await KeyManager.getPublicKey();
|
|
183
|
+
if (!privateKey || !publicKey) {
|
|
184
|
+
throw new Error('No identity found on this device. Create or import an identity first.');
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Ephemeral ECDH → per-pairing transfer key.
|
|
188
|
+
const oldEphKeyPair = ecCurve.genKeyPair();
|
|
189
|
+
const oldEphPrivateKey = oldEphKeyPair.getPrivate('hex');
|
|
190
|
+
const oldEphPublicKey = oldEphKeyPair.getPublic('hex');
|
|
191
|
+
|
|
192
|
+
const sharedSecret = deriveSharedSecret(oldEphPrivateKey, info.newDeviceEphemeralPublicKey);
|
|
193
|
+
const transferKey = deriveTransferKey(sharedSecret, pairingId);
|
|
194
|
+
|
|
195
|
+
// Seal the identity key material.
|
|
196
|
+
const plaintext = utf8ToBytes(JSON.stringify({ privateKey, publicKey }));
|
|
197
|
+
const { nonce, ciphertext } = encryptAead(transferKey, plaintext);
|
|
198
|
+
|
|
199
|
+
// Dual-proof: prove control of the CURRENT identity key (a bearer alone
|
|
200
|
+
// must not be able to exfiltrate the private key).
|
|
201
|
+
const timestamp = Date.now();
|
|
202
|
+
const message = JSON.stringify({
|
|
203
|
+
action: DEVICE_TRANSFER_APPROVE_ACTION,
|
|
204
|
+
pairingId,
|
|
205
|
+
timestamp,
|
|
206
|
+
});
|
|
207
|
+
const signature = await SignatureService.sign(message);
|
|
208
|
+
|
|
209
|
+
return await this.makeRequest<DeviceTransferApproveResponse>(
|
|
210
|
+
'POST',
|
|
211
|
+
`/identity/device-transfer/${encodeURIComponent(pairingId)}/approve`,
|
|
212
|
+
{
|
|
213
|
+
oldEphPub: oldEphPublicKey,
|
|
214
|
+
ciphertext: bytesToHex(ciphertext),
|
|
215
|
+
nonce: bytesToHex(nonce),
|
|
216
|
+
signature,
|
|
217
|
+
timestamp,
|
|
218
|
+
},
|
|
219
|
+
{ cache: false },
|
|
220
|
+
);
|
|
221
|
+
} catch (error) {
|
|
222
|
+
throw this.handleError(error);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* OLD device — deny (cancel) a scanned transfer so the waiting new device
|
|
228
|
+
* stops. Public — no auth required.
|
|
229
|
+
*/
|
|
230
|
+
async denyDeviceTransfer(pairingId: string): Promise<DeviceTransferDenyResponse> {
|
|
231
|
+
try {
|
|
232
|
+
return await this.makeRequest<DeviceTransferDenyResponse>(
|
|
233
|
+
'POST',
|
|
234
|
+
`/identity/device-transfer/${encodeURIComponent(pairingId)}/deny`,
|
|
235
|
+
undefined,
|
|
236
|
+
{ cache: false },
|
|
237
|
+
);
|
|
238
|
+
} catch (error) {
|
|
239
|
+
throw this.handleError(error);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* NEW device — await approval for a pairing started with
|
|
245
|
+
* {@link initDeviceTransfer}, then decrypt and import the transferred
|
|
246
|
+
* identity key. Primary path is an instant `device_pair_update` push over the
|
|
247
|
+
* `/device-pair` socket; a poll backstops a socket that can't connect.
|
|
248
|
+
*
|
|
249
|
+
* On `approved`: re-derives the shared transfer key from the old device's
|
|
250
|
+
* ephemeral public key, decrypts `{ privateKey, publicKey }`, imports it via
|
|
251
|
+
* `KeyManager.importKeyPair(privateKey, { overwrite: false })`, and invokes
|
|
252
|
+
* `onOutcome({ status:'approved', publicKey })`. The caller then runs the
|
|
253
|
+
* NORMAL challenge/verify sign-in — this method does not mint a session.
|
|
254
|
+
*
|
|
255
|
+
* @returns An unsubscribe function; call it to stop waiting (also called
|
|
256
|
+
* automatically once the transfer settles).
|
|
257
|
+
*/
|
|
258
|
+
subscribeDeviceTransfer(
|
|
259
|
+
pairingId: string,
|
|
260
|
+
onOutcome: (outcome: DeviceTransferOutcome) => void,
|
|
261
|
+
): () => void {
|
|
262
|
+
const ephemeralKeys = getEphemeralKeys(this);
|
|
263
|
+
const ephPrivateKey = ephemeralKeys.get(pairingId);
|
|
264
|
+
if (!ephPrivateKey) {
|
|
265
|
+
throw new Error(
|
|
266
|
+
'No pending device transfer for this pairing id. Call initDeviceTransfer first.',
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
let settled = false;
|
|
271
|
+
let inFlight = false;
|
|
272
|
+
let socket: MinimalSocket | null = null;
|
|
273
|
+
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
|
274
|
+
|
|
275
|
+
const cleanup = (): void => {
|
|
276
|
+
if (pollTimer !== null) {
|
|
277
|
+
clearInterval(pollTimer);
|
|
278
|
+
pollTimer = null;
|
|
279
|
+
}
|
|
280
|
+
if (socket) {
|
|
281
|
+
try {
|
|
282
|
+
socket.off('device_pair_update');
|
|
283
|
+
socket.off('connect');
|
|
284
|
+
socket.disconnect();
|
|
285
|
+
} catch (error) {
|
|
286
|
+
logger.debug('[DeviceTransfer] socket close failed', { component: 'DeviceTransfer' }, error);
|
|
287
|
+
}
|
|
288
|
+
socket = null;
|
|
289
|
+
}
|
|
290
|
+
ephemeralKeys.delete(pairingId);
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
const finish = (outcome: DeviceTransferOutcome): void => {
|
|
294
|
+
if (settled) return;
|
|
295
|
+
settled = true;
|
|
296
|
+
cleanup();
|
|
297
|
+
onOutcome(outcome);
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
// Re-check authoritative status; on `approved`, decrypt + import.
|
|
301
|
+
const check = async (): Promise<void> => {
|
|
302
|
+
if (settled || inFlight) return;
|
|
303
|
+
inFlight = true;
|
|
304
|
+
try {
|
|
305
|
+
const info = await this.getDeviceTransferInfo(pairingId);
|
|
306
|
+
if (settled) return;
|
|
307
|
+
|
|
308
|
+
if (info.status === 'denied') {
|
|
309
|
+
finish({ status: 'denied' });
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
if (info.status === 'expired') {
|
|
313
|
+
finish({ status: 'expired' });
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
if (
|
|
317
|
+
info.status === 'approved' &&
|
|
318
|
+
info.oldDeviceEphemeralPublicKey &&
|
|
319
|
+
info.ciphertext &&
|
|
320
|
+
info.nonce
|
|
321
|
+
) {
|
|
322
|
+
const sharedSecret = deriveSharedSecret(
|
|
323
|
+
ephPrivateKey,
|
|
324
|
+
info.oldDeviceEphemeralPublicKey,
|
|
325
|
+
);
|
|
326
|
+
const transferKey = deriveTransferKey(sharedSecret, pairingId);
|
|
327
|
+
const plaintext = decryptAead(
|
|
328
|
+
transferKey,
|
|
329
|
+
hexToBytes(info.nonce),
|
|
330
|
+
hexToBytes(info.ciphertext),
|
|
331
|
+
);
|
|
332
|
+
const parsed = JSON.parse(bytesToUtf8(plaintext)) as {
|
|
333
|
+
privateKey: string;
|
|
334
|
+
publicKey: string;
|
|
335
|
+
};
|
|
336
|
+
// Import WITHOUT overwrite: a fresh device has no identity, and we
|
|
337
|
+
// must never silently clobber an existing one.
|
|
338
|
+
const importedPublicKey = await KeyManager.importKeyPair(parsed.privateKey, {
|
|
339
|
+
overwrite: false,
|
|
340
|
+
});
|
|
341
|
+
finish({ status: 'approved', publicKey: importedPublicKey });
|
|
342
|
+
}
|
|
343
|
+
} catch (error) {
|
|
344
|
+
// Transient (a poll tick that raced the approve write, a decrypt on a
|
|
345
|
+
// half-written row) — keep waiting; the next tick/push retries.
|
|
346
|
+
logger.debug('[DeviceTransfer] status check failed', { component: 'DeviceTransfer' }, error);
|
|
347
|
+
} finally {
|
|
348
|
+
inFlight = false;
|
|
349
|
+
}
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
// Primary: instant socket wake. Fall back to polling if unavailable.
|
|
353
|
+
void (async () => {
|
|
354
|
+
const io = await getSocketIO();
|
|
355
|
+
if (settled || !io) return;
|
|
356
|
+
try {
|
|
357
|
+
const s = io(`${this.getBaseURL()}${DEVICE_PAIR_NAMESPACE}`, {
|
|
358
|
+
transports: ['websocket'],
|
|
359
|
+
autoConnect: true,
|
|
360
|
+
reconnection: true,
|
|
361
|
+
reconnectionAttempts: Number.POSITIVE_INFINITY,
|
|
362
|
+
reconnectionDelay: 1000,
|
|
363
|
+
reconnectionDelayMax: 10000,
|
|
364
|
+
});
|
|
365
|
+
const join = (): void => {
|
|
366
|
+
try {
|
|
367
|
+
s.emit('join', pairingId);
|
|
368
|
+
} catch (error) {
|
|
369
|
+
logger.debug('[DeviceTransfer] join failed', { component: 'DeviceTransfer' }, error);
|
|
370
|
+
}
|
|
371
|
+
};
|
|
372
|
+
s.on('connect', join);
|
|
373
|
+
if (s.connected) join();
|
|
374
|
+
s.on('device_pair_update', () => {
|
|
375
|
+
void check();
|
|
376
|
+
});
|
|
377
|
+
socket = s;
|
|
378
|
+
} catch (error) {
|
|
379
|
+
logger.debug('[DeviceTransfer] socket create failed (poll fallback)', { component: 'DeviceTransfer' }, error);
|
|
380
|
+
}
|
|
381
|
+
})();
|
|
382
|
+
|
|
383
|
+
// Fallback poll (also covers the already-approved-before-subscribe case via
|
|
384
|
+
// the immediate first tick below). unref so it never holds a Node event
|
|
385
|
+
// loop / test runner open.
|
|
386
|
+
pollTimer = setInterval(() => {
|
|
387
|
+
void check();
|
|
388
|
+
}, DEVICE_TRANSFER_POLL_INTERVAL_MS);
|
|
389
|
+
(pollTimer as { unref?: () => void }).unref?.();
|
|
390
|
+
|
|
391
|
+
// Immediate first check — the transfer may already be approved/denied.
|
|
392
|
+
void check();
|
|
393
|
+
|
|
394
|
+
return cleanup;
|
|
395
|
+
}
|
|
396
|
+
};
|
|
397
|
+
}
|
|
@@ -12,6 +12,7 @@ import { loadNodeCrypto } from '@oxyhq/protocol';
|
|
|
12
12
|
import { buildUrl } from '../utils/apiUtils';
|
|
13
13
|
import { logger } from '../logger';
|
|
14
14
|
import { CACHE_TIMES } from './mixinHelpers';
|
|
15
|
+
import { OXY_SERVICE_ENVIRONMENTS, type OxyServiceEnvironment } from '../utils/oxyServiceEnvironment';
|
|
15
16
|
|
|
16
17
|
interface JwtPayload {
|
|
17
18
|
exp?: number;
|
|
@@ -25,6 +26,7 @@ interface JwtPayload {
|
|
|
25
26
|
scopes?: string[];
|
|
26
27
|
aud?: string | string[];
|
|
27
28
|
iss?: string;
|
|
29
|
+
environment?: string;
|
|
28
30
|
[key: string]: unknown;
|
|
29
31
|
}
|
|
30
32
|
|
|
@@ -57,6 +59,8 @@ export interface ServiceApp {
|
|
|
57
59
|
scopes: string[];
|
|
58
60
|
/** The credentialId of the specific service credential that minted this token. */
|
|
59
61
|
credentialId: string;
|
|
62
|
+
/** Test/live isolation (F2.0): which `ApplicationCredential.environment` minted this token. */
|
|
63
|
+
environment: OxyServiceEnvironment;
|
|
60
64
|
}
|
|
61
65
|
|
|
62
66
|
/**
|
|
@@ -94,6 +98,13 @@ class ServiceTokenClaimError extends Error {
|
|
|
94
98
|
}
|
|
95
99
|
}
|
|
96
100
|
|
|
101
|
+
function isOxyServiceEnvironment(value: unknown): value is OxyServiceEnvironment {
|
|
102
|
+
return (
|
|
103
|
+
typeof value === 'string' &&
|
|
104
|
+
(OXY_SERVICE_ENVIRONMENTS as readonly string[]).includes(value)
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
97
108
|
/**
|
|
98
109
|
* Options for oxyClient.auth() middleware
|
|
99
110
|
*/
|
|
@@ -459,7 +470,13 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
459
470
|
// Validate required service token fields
|
|
460
471
|
const appId = decoded.appId;
|
|
461
472
|
const credentialId = decoded.credentialId;
|
|
462
|
-
|
|
473
|
+
const environment = decoded.environment;
|
|
474
|
+
if (
|
|
475
|
+
!appId ||
|
|
476
|
+
typeof credentialId !== 'string' ||
|
|
477
|
+
credentialId.length === 0 ||
|
|
478
|
+
!isOxyServiceEnvironment(environment)
|
|
479
|
+
) {
|
|
463
480
|
if (optional) {
|
|
464
481
|
req.userId = null;
|
|
465
482
|
req.user = null;
|
|
@@ -513,6 +530,7 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
513
530
|
appName: decoded.appName || 'unknown',
|
|
514
531
|
credentialId,
|
|
515
532
|
scopes: Array.isArray(decoded.scopes) ? decoded.scopes : [],
|
|
533
|
+
environment,
|
|
516
534
|
};
|
|
517
535
|
|
|
518
536
|
if (debug) {
|