@oxyhq/core 12.6.0 → 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.
@@ -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
+ });
@@ -30,6 +30,7 @@ import { OxyServicesCivicMixin } from './OxyServices.civic';
30
30
  import { OxyServicesNodesMixin } from './OxyServices.nodes';
31
31
  import { OxyServicesLinksMixin } from './OxyServices.links';
32
32
  import { OxyServicesDeviceBootMixin } from './OxyServices.deviceBoot';
33
+ import { OxyServicesDeviceTransferMixin } from './OxyServices.deviceTransfer';
33
34
 
34
35
  /**
35
36
  * Instance shape of every mixin in the pipeline, intersected. The runtime
@@ -64,6 +65,7 @@ type AllMixinInstances =
64
65
  & InstanceType<ReturnType<typeof OxyServicesNodesMixin<typeof OxyServicesBase>>>
65
66
  & InstanceType<ReturnType<typeof OxyServicesLinksMixin<typeof OxyServicesBase>>>
66
67
  & InstanceType<ReturnType<typeof OxyServicesDeviceBootMixin<typeof OxyServicesBase>>>
68
+ & InstanceType<ReturnType<typeof OxyServicesDeviceTransferMixin<typeof OxyServicesBase>>>
67
69
  & InstanceType<ReturnType<typeof OxyServicesUtilityMixin<typeof OxyServicesBase>>>;
68
70
 
69
71
  /**
@@ -138,6 +140,10 @@ const MIXIN_PIPELINE: MixinFunction[] = [
138
140
  // (`mintFromDeviceSecret` → `POST /session/device/token`).
139
141
  OxyServicesDeviceBootMixin,
140
142
 
143
+ // Device-to-device identity transfer ("add a device"): E2E-encrypted key
144
+ // clone over a short-lived relay (b3 Feature 2).
145
+ OxyServicesDeviceTransferMixin,
146
+
141
147
  // Utility (last, can use all above)
142
148
  OxyServicesUtilityMixin,
143
149
  ];