@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,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
|
+
}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Device-to-device identity transfer mixin tests (b3 Feature 2 — "add a device").
|
|
3
|
+
*
|
|
4
|
+
* Exercises the E2E crypto path THROUGH the mixins with two independent
|
|
5
|
+
* OxyServices instances (old device + new device) wired to a shared in-memory
|
|
6
|
+
* "relay" that never sees a decryption key:
|
|
7
|
+
* - ECDH symmetry: the old device seals with `ECDH(oldEphPriv, newEphPub)` and
|
|
8
|
+
* the new device opens with `ECDH(newEphPriv, oldEphPub)` — same transfer key.
|
|
9
|
+
* - full round-trip: the private key the old device holds is recovered and
|
|
10
|
+
* imported byte-for-byte on the new device.
|
|
11
|
+
* - tamper: a flipped ciphertext byte fails authentication (never imports).
|
|
12
|
+
*
|
|
13
|
+
* The socket is forced OFF (getSocketIO → null) so the deterministic poll path
|
|
14
|
+
* drives the flow; KeyManager + SignatureService.sign are stubbed (the identity
|
|
15
|
+
* key material and the approval signature are not the unit under test here — the
|
|
16
|
+
* server-side signature verification is covered in the api service tests).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
jest.mock('../../session/socketLoader', () => ({
|
|
20
|
+
getSocketIO: jest.fn(async () => null),
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
import { ec as EC } from 'elliptic';
|
|
24
|
+
import { OxyServices } from '../../OxyServices';
|
|
25
|
+
import { KeyManager } from '../../crypto/keyManager';
|
|
26
|
+
import { SignatureService } from '../../crypto/signatureService';
|
|
27
|
+
import { deriveSharedSecret } from '../../crypto/ecdh';
|
|
28
|
+
import { hkdfSha256 } from '../../crypto/kdf';
|
|
29
|
+
import { encryptAead, decryptAead } from '../../crypto/aead';
|
|
30
|
+
import { bytesToHex, hexToBytes, utf8ToBytes, bytesToUtf8 } from '@noble/hashes/utils';
|
|
31
|
+
import type { DeviceTransferInfoResponse } from '@oxyhq/contracts';
|
|
32
|
+
|
|
33
|
+
const ec = new EC('secp256k1');
|
|
34
|
+
|
|
35
|
+
/** A shared in-memory relay: a single pairing row, mirroring the API's shape. */
|
|
36
|
+
interface RelayState {
|
|
37
|
+
pairingId: string;
|
|
38
|
+
newDeviceEphemeralPublicKey: string;
|
|
39
|
+
newDeviceLabel: string | null;
|
|
40
|
+
status: 'pending' | 'approved' | 'denied' | 'expired';
|
|
41
|
+
expiresAt: string;
|
|
42
|
+
oldDeviceEphemeralPublicKey: string | null;
|
|
43
|
+
ciphertext: string | null;
|
|
44
|
+
nonce: string | null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function makeRelay() {
|
|
48
|
+
const state: { row: RelayState | null } = { row: null };
|
|
49
|
+
const infoDto = (): DeviceTransferInfoResponse => {
|
|
50
|
+
const row = state.row!;
|
|
51
|
+
const approved = row.status === 'approved';
|
|
52
|
+
return {
|
|
53
|
+
pairingId: row.pairingId,
|
|
54
|
+
newDeviceEphemeralPublicKey: row.newDeviceEphemeralPublicKey,
|
|
55
|
+
newDeviceLabel: row.newDeviceLabel,
|
|
56
|
+
status: row.status,
|
|
57
|
+
expiresAt: row.expiresAt,
|
|
58
|
+
oldDeviceEphemeralPublicKey: approved ? row.oldDeviceEphemeralPublicKey : null,
|
|
59
|
+
ciphertext: approved ? row.ciphertext : null,
|
|
60
|
+
nonce: approved ? row.nonce : null,
|
|
61
|
+
};
|
|
62
|
+
};
|
|
63
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
64
|
+
const handle = (method: string, url: string, data?: any): Promise<unknown> => {
|
|
65
|
+
if (method === 'POST' && url === '/identity/device-transfer/init') {
|
|
66
|
+
state.row = {
|
|
67
|
+
pairingId: 'a'.repeat(32),
|
|
68
|
+
newDeviceEphemeralPublicKey: data.newEphPub,
|
|
69
|
+
newDeviceLabel: data.newDeviceLabel ?? null,
|
|
70
|
+
status: 'pending',
|
|
71
|
+
expiresAt: new Date(Date.now() + 180_000).toISOString(),
|
|
72
|
+
oldDeviceEphemeralPublicKey: null,
|
|
73
|
+
ciphertext: null,
|
|
74
|
+
nonce: null,
|
|
75
|
+
};
|
|
76
|
+
return Promise.resolve({ pairingId: state.row.pairingId, expiresAt: state.row.expiresAt });
|
|
77
|
+
}
|
|
78
|
+
if (method === 'GET' && url.startsWith('/identity/device-transfer/')) {
|
|
79
|
+
return Promise.resolve(infoDto());
|
|
80
|
+
}
|
|
81
|
+
if (method === 'POST' && url.endsWith('/approve')) {
|
|
82
|
+
state.row!.oldDeviceEphemeralPublicKey = data.oldEphPub;
|
|
83
|
+
state.row!.ciphertext = data.ciphertext;
|
|
84
|
+
state.row!.nonce = data.nonce;
|
|
85
|
+
state.row!.status = 'approved';
|
|
86
|
+
return Promise.resolve({ success: true, pairingId: state.row!.pairingId, status: 'approved' });
|
|
87
|
+
}
|
|
88
|
+
if (method === 'POST' && url.endsWith('/deny')) {
|
|
89
|
+
state.row!.status = 'denied';
|
|
90
|
+
return Promise.resolve({ success: true, pairingId: state.row!.pairingId, status: 'denied' });
|
|
91
|
+
}
|
|
92
|
+
return Promise.reject(new Error(`unexpected request ${method} ${url}`));
|
|
93
|
+
};
|
|
94
|
+
return { state, handle };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
describe('OxyServices.deviceTransfer', () => {
|
|
98
|
+
let identityPriv: string;
|
|
99
|
+
let identityPub: string;
|
|
100
|
+
let importedPrivateKey: string | null;
|
|
101
|
+
|
|
102
|
+
beforeEach(() => {
|
|
103
|
+
const idKey = ec.genKeyPair();
|
|
104
|
+
identityPriv = idKey.getPrivate('hex');
|
|
105
|
+
identityPub = idKey.getPublic('hex');
|
|
106
|
+
importedPrivateKey = null;
|
|
107
|
+
|
|
108
|
+
// Old device HOLDS the identity; new device IMPORTS it. One KeyManager is
|
|
109
|
+
// shared, but the two roles call disjoint methods (get* vs import).
|
|
110
|
+
jest.spyOn(KeyManager, 'getPrivateKey').mockResolvedValue(identityPriv);
|
|
111
|
+
jest.spyOn(KeyManager, 'getPublicKey').mockResolvedValue(identityPub);
|
|
112
|
+
jest.spyOn(KeyManager, 'importKeyPair').mockImplementation(async (priv: string) => {
|
|
113
|
+
importedPrivateKey = priv;
|
|
114
|
+
return ec.keyFromPrivate(priv, 'hex').getPublic('hex');
|
|
115
|
+
});
|
|
116
|
+
// The server (api service test) verifies the signature; here it is opaque.
|
|
117
|
+
jest.spyOn(SignatureService, 'sign').mockResolvedValue('sig-hex');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
afterEach(() => {
|
|
121
|
+
jest.restoreAllMocks();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('clones the identity end-to-end (ECDH symmetry + full round-trip via the mixins)', async () => {
|
|
125
|
+
const relay = makeRelay();
|
|
126
|
+
const newDevice = new OxyServices({ baseURL: 'http://relay.invalid' });
|
|
127
|
+
const oldDevice = new OxyServices({ baseURL: 'http://relay.invalid' });
|
|
128
|
+
jest.spyOn(newDevice, 'makeRequest').mockImplementation(relay.handle as never);
|
|
129
|
+
jest.spyOn(oldDevice, 'makeRequest').mockImplementation(relay.handle as never);
|
|
130
|
+
|
|
131
|
+
// 1. New device registers a pairing (generates + stores its ephemeral key).
|
|
132
|
+
const init = await newDevice.initDeviceTransfer('New iPhone');
|
|
133
|
+
expect(init.pairingId).toHaveLength(32);
|
|
134
|
+
expect(relay.state.row?.newDeviceEphemeralPublicKey).toBe(init.newEphemeralPublicKey);
|
|
135
|
+
|
|
136
|
+
// 2. Old device resolves the QR handle and approves (seals the identity key).
|
|
137
|
+
const approveResult = await oldDevice.approveDeviceTransfer(init.pairingId);
|
|
138
|
+
expect(approveResult).toEqual({ success: true, pairingId: init.pairingId, status: 'approved' });
|
|
139
|
+
// The relay stored ONLY ephemeral pubkeys + opaque ciphertext — never the key.
|
|
140
|
+
expect(relay.state.row?.ciphertext).toBeTruthy();
|
|
141
|
+
expect(relay.state.row?.ciphertext).not.toContain(identityPriv);
|
|
142
|
+
|
|
143
|
+
// 3. New device awaits approval, decrypts, and imports the SAME private key.
|
|
144
|
+
const outcome = await new Promise<{ status: string; publicKey?: string }>((resolve) => {
|
|
145
|
+
newDevice.subscribeDeviceTransfer(init.pairingId, resolve);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
expect(outcome).toEqual({ status: 'approved', publicKey: identityPub });
|
|
149
|
+
// The recovered private key is byte-for-byte the old device's identity key.
|
|
150
|
+
expect(importedPrivateKey).toBe(identityPriv);
|
|
151
|
+
// Imported WITHOUT overwrite — a fresh device must never clobber an identity.
|
|
152
|
+
expect(KeyManager.importKeyPair).toHaveBeenCalledWith(identityPriv, { overwrite: false });
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('reports a denied transfer to the subscriber without importing', async () => {
|
|
156
|
+
const relay = makeRelay();
|
|
157
|
+
const newDevice = new OxyServices({ baseURL: 'http://relay.invalid' });
|
|
158
|
+
jest.spyOn(newDevice, 'makeRequest').mockImplementation(relay.handle as never);
|
|
159
|
+
|
|
160
|
+
await newDevice.initDeviceTransfer();
|
|
161
|
+
relay.state.row!.status = 'denied';
|
|
162
|
+
|
|
163
|
+
const outcome = await new Promise<{ status: string }>((resolve) => {
|
|
164
|
+
newDevice.subscribeDeviceTransfer(relay.state.row!.pairingId, resolve);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
expect(outcome).toEqual({ status: 'denied' });
|
|
168
|
+
expect(KeyManager.importKeyPair).not.toHaveBeenCalled();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('throws if subscribing to a pairing this instance never initiated', () => {
|
|
172
|
+
const newDevice = new OxyServices({ baseURL: 'http://relay.invalid' });
|
|
173
|
+
expect(() => newDevice.subscribeDeviceTransfer('unknown-pair', () => {})).toThrow(
|
|
174
|
+
/initDeviceTransfer first/i,
|
|
175
|
+
);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('refuses to approve a non-pending pairing', async () => {
|
|
179
|
+
const relay = makeRelay();
|
|
180
|
+
const newDevice = new OxyServices({ baseURL: 'http://relay.invalid' });
|
|
181
|
+
const oldDevice = new OxyServices({ baseURL: 'http://relay.invalid' });
|
|
182
|
+
jest.spyOn(newDevice, 'makeRequest').mockImplementation(relay.handle as never);
|
|
183
|
+
jest.spyOn(oldDevice, 'makeRequest').mockImplementation(relay.handle as never);
|
|
184
|
+
|
|
185
|
+
const init = await newDevice.initDeviceTransfer();
|
|
186
|
+
relay.state.row!.status = 'denied';
|
|
187
|
+
|
|
188
|
+
await expect(oldDevice.approveDeviceTransfer(init.pairingId)).rejects.toThrow(
|
|
189
|
+
/can no longer be approved/i,
|
|
190
|
+
);
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The exact crypto derivation the mixin uses, pinned independently: two ephemeral
|
|
196
|
+
* pairs agree on a symmetric key, seal/open a JSON key blob, and any tampering
|
|
197
|
+
* with the ciphertext fails authentication.
|
|
198
|
+
*/
|
|
199
|
+
describe('device-transfer crypto derivation', () => {
|
|
200
|
+
const HKDF_INFO = 'oxy-device-transfer-v1';
|
|
201
|
+
const deriveTransferKey = (shared: Uint8Array, pairingId: string): Uint8Array =>
|
|
202
|
+
hkdfSha256(shared, utf8ToBytes(pairingId), utf8ToBytes(HKDF_INFO), 32);
|
|
203
|
+
|
|
204
|
+
it('is symmetric and round-trips the sealed identity key', () => {
|
|
205
|
+
const pairingId = 'b'.repeat(32);
|
|
206
|
+
const oldEph = ec.genKeyPair();
|
|
207
|
+
const newEph = ec.genKeyPair();
|
|
208
|
+
|
|
209
|
+
const sharedOld = deriveSharedSecret(oldEph.getPrivate('hex'), newEph.getPublic('hex'));
|
|
210
|
+
const sharedNew = deriveSharedSecret(newEph.getPrivate('hex'), oldEph.getPublic('hex'));
|
|
211
|
+
expect(bytesToHex(sharedOld)).toBe(bytesToHex(sharedNew));
|
|
212
|
+
|
|
213
|
+
const keyOld = deriveTransferKey(sharedOld, pairingId);
|
|
214
|
+
const keyNew = deriveTransferKey(sharedNew, pairingId);
|
|
215
|
+
expect(bytesToHex(keyOld)).toBe(bytesToHex(keyNew));
|
|
216
|
+
|
|
217
|
+
const identity = { privateKey: 'ff'.repeat(32), publicKey: '04' + 'ab'.repeat(64) };
|
|
218
|
+
const { nonce, ciphertext } = encryptAead(keyOld, utf8ToBytes(JSON.stringify(identity)));
|
|
219
|
+
|
|
220
|
+
const opened = JSON.parse(bytesToUtf8(decryptAead(keyNew, nonce, ciphertext)));
|
|
221
|
+
expect(opened).toEqual(identity);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('fails authentication when the ciphertext is tampered', () => {
|
|
225
|
+
const pairingId = 'c'.repeat(32);
|
|
226
|
+
const oldEph = ec.genKeyPair();
|
|
227
|
+
const newEph = ec.genKeyPair();
|
|
228
|
+
const key = deriveTransferKey(
|
|
229
|
+
deriveSharedSecret(oldEph.getPrivate('hex'), newEph.getPublic('hex')),
|
|
230
|
+
pairingId,
|
|
231
|
+
);
|
|
232
|
+
const { nonce, ciphertext } = encryptAead(key, utf8ToBytes('{"privateKey":"deadbeef"}'));
|
|
233
|
+
|
|
234
|
+
const tampered = Uint8Array.from(ciphertext);
|
|
235
|
+
tampered[0] ^= 0x01; // flip one bit
|
|
236
|
+
const keyNew = deriveTransferKey(
|
|
237
|
+
deriveSharedSecret(newEph.getPrivate('hex'), oldEph.getPublic('hex')),
|
|
238
|
+
pairingId,
|
|
239
|
+
);
|
|
240
|
+
expect(() => decryptAead(keyNew, nonce, tampered)).toThrow();
|
|
241
|
+
// And a wrong pairingId (wrong HKDF salt) also fails — binds to the pairing.
|
|
242
|
+
const wrongSaltKey = deriveTransferKey(
|
|
243
|
+
deriveSharedSecret(newEph.getPrivate('hex'), oldEph.getPublic('hex')),
|
|
244
|
+
'd'.repeat(32),
|
|
245
|
+
);
|
|
246
|
+
expect(() => decryptAead(wrongSaltKey, nonce, ciphertext)).toThrow();
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('re-derives from hex the way the wire transports the material', () => {
|
|
250
|
+
const pairingId = 'e'.repeat(32);
|
|
251
|
+
const oldEph = ec.genKeyPair();
|
|
252
|
+
const newEph = ec.genKeyPair();
|
|
253
|
+
const keyOld = deriveTransferKey(
|
|
254
|
+
deriveSharedSecret(oldEph.getPrivate('hex'), newEph.getPublic('hex')),
|
|
255
|
+
pairingId,
|
|
256
|
+
);
|
|
257
|
+
const { nonce, ciphertext } = encryptAead(keyOld, utf8ToBytes('{"k":1}'));
|
|
258
|
+
|
|
259
|
+
// Wire form: hex strings (exactly what the mixin sends/receives).
|
|
260
|
+
const nonceHex = bytesToHex(nonce);
|
|
261
|
+
const ciphertextHex = bytesToHex(ciphertext);
|
|
262
|
+
|
|
263
|
+
const keyNew = deriveTransferKey(
|
|
264
|
+
deriveSharedSecret(newEph.getPrivate('hex'), oldEph.getPublic('hex')),
|
|
265
|
+
pairingId,
|
|
266
|
+
);
|
|
267
|
+
const opened = bytesToUtf8(decryptAead(keyNew, hexToBytes(nonceHex), hexToBytes(ciphertextHex)));
|
|
268
|
+
expect(opened).toBe('{"k":1}');
|
|
269
|
+
});
|
|
270
|
+
});
|