@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.
Files changed (48) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/crypto/keyManager.js +50 -0
  3. package/dist/cjs/i18n/locales/en-US.json +7 -0
  4. package/dist/cjs/i18n/locales/es-ES.json +7 -0
  5. package/dist/cjs/i18n/locales/locales/en-US.json +7 -0
  6. package/dist/cjs/i18n/locales/locales/es-ES.json +7 -0
  7. package/dist/cjs/mixins/OxyServices.deviceTransfer.js +319 -0
  8. package/dist/cjs/mixins/OxyServices.utility.js +11 -1
  9. package/dist/cjs/mixins/index.js +4 -0
  10. package/dist/cjs/server/auth.js +3 -0
  11. package/dist/cjs/server/index.js +2 -1
  12. package/dist/cjs/utils/oxyServiceEnvironment.js +19 -0
  13. package/dist/esm/.tsbuildinfo +1 -1
  14. package/dist/esm/crypto/keyManager.js +50 -0
  15. package/dist/esm/i18n/locales/en-US.json +7 -0
  16. package/dist/esm/i18n/locales/es-ES.json +7 -0
  17. package/dist/esm/i18n/locales/locales/en-US.json +7 -0
  18. package/dist/esm/i18n/locales/locales/es-ES.json +7 -0
  19. package/dist/esm/mixins/OxyServices.deviceTransfer.js +317 -0
  20. package/dist/esm/mixins/OxyServices.utility.js +11 -1
  21. package/dist/esm/mixins/index.js +4 -0
  22. package/dist/esm/server/auth.js +2 -0
  23. package/dist/esm/server/index.js +1 -1
  24. package/dist/esm/utils/oxyServiceEnvironment.js +16 -0
  25. package/dist/types/.tsbuildinfo +1 -1
  26. package/dist/types/crypto/keyManager.d.ts +20 -0
  27. package/dist/types/index.d.ts +1 -0
  28. package/dist/types/mixins/OxyServices.deviceTransfer.d.ts +149 -0
  29. package/dist/types/mixins/OxyServices.utility.d.ts +3 -0
  30. package/dist/types/mixins/index.d.ts +2 -1
  31. package/dist/types/server/auth.d.ts +4 -0
  32. package/dist/types/server/index.d.ts +2 -2
  33. package/dist/types/utils/oxyServiceEnvironment.d.ts +17 -0
  34. package/package.json +1 -1
  35. package/src/crypto/__tests__/scopedSeed.test.ts +126 -0
  36. package/src/crypto/keyManager.ts +55 -0
  37. package/src/i18n/locales/en-US.json +7 -0
  38. package/src/i18n/locales/es-ES.json +7 -0
  39. package/src/index.ts +4 -0
  40. package/src/mixins/OxyServices.deviceTransfer.ts +397 -0
  41. package/src/mixins/OxyServices.utility.ts +19 -1
  42. package/src/mixins/__tests__/OxyServices.deviceTransfer.test.ts +270 -0
  43. package/src/mixins/__tests__/serviceAuth.test.ts +65 -0
  44. package/src/mixins/index.ts +6 -0
  45. package/src/server/auth.ts +5 -0
  46. package/src/server/index.ts +2 -0
  47. package/src/utils/__tests__/oxyServiceEnvironment.test.ts +7 -0
  48. 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
- if (!appId || typeof credentialId !== 'string' || credentialId.length === 0) {
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) {