@oxy.so/core 1.5.1 → 1.6.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 (126) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/HttpService.js +72 -9
  3. package/dist/cjs/crypto/identityMove.js +53 -34
  4. package/dist/cjs/crypto/identityProof.js +28 -0
  5. package/dist/cjs/crypto/webIdentityCarrier.js +145 -82
  6. package/dist/cjs/i18n/locales/ar-SA.json +1 -0
  7. package/dist/cjs/i18n/locales/ca-ES.json +1 -0
  8. package/dist/cjs/i18n/locales/de-DE.json +1 -0
  9. package/dist/cjs/i18n/locales/en-US.json +1 -0
  10. package/dist/cjs/i18n/locales/es-ES.json +1 -0
  11. package/dist/cjs/i18n/locales/fr-FR.json +1 -0
  12. package/dist/cjs/i18n/locales/it-IT.json +1 -0
  13. package/dist/cjs/i18n/locales/ja-JP.json +1 -0
  14. package/dist/cjs/i18n/locales/ko-KR.json +1 -0
  15. package/dist/cjs/i18n/locales/locales/ar-SA.json +1 -0
  16. package/dist/cjs/i18n/locales/locales/ca-ES.json +1 -0
  17. package/dist/cjs/i18n/locales/locales/de-DE.json +1 -0
  18. package/dist/cjs/i18n/locales/locales/en-US.json +1 -0
  19. package/dist/cjs/i18n/locales/locales/es-ES.json +1 -0
  20. package/dist/cjs/i18n/locales/locales/fr-FR.json +1 -0
  21. package/dist/cjs/i18n/locales/locales/it-IT.json +1 -0
  22. package/dist/cjs/i18n/locales/locales/ja-JP.json +1 -0
  23. package/dist/cjs/i18n/locales/locales/ko-KR.json +1 -0
  24. package/dist/cjs/i18n/locales/locales/pt-PT.json +1 -0
  25. package/dist/cjs/i18n/locales/locales/zh-CN.json +1 -0
  26. package/dist/cjs/i18n/locales/pt-PT.json +1 -0
  27. package/dist/cjs/i18n/locales/zh-CN.json +1 -0
  28. package/dist/cjs/index.js +22 -14
  29. package/dist/cjs/inference/OxyInferenceClient.js +62 -5
  30. package/dist/cjs/mixins/OxyServices.auth.js +129 -0
  31. package/dist/cjs/mixins/OxyServices.identity.js +11 -44
  32. package/dist/cjs/mixins/index.js +0 -2
  33. package/dist/cjs/server/index.js +10 -1
  34. package/dist/cjs/server/securityHeaders.js +9 -2
  35. package/dist/cjs/server/workloadIdentity.js +170 -0
  36. package/dist/cjs/session/refresh.js +12 -0
  37. package/dist/esm/.tsbuildinfo +1 -1
  38. package/dist/esm/HttpService.js +72 -9
  39. package/dist/esm/crypto/identityMove.js +51 -33
  40. package/dist/esm/crypto/identityProof.js +24 -0
  41. package/dist/esm/crypto/webIdentityCarrier.js +137 -77
  42. package/dist/esm/i18n/locales/ar-SA.json +1 -0
  43. package/dist/esm/i18n/locales/ca-ES.json +1 -0
  44. package/dist/esm/i18n/locales/de-DE.json +1 -0
  45. package/dist/esm/i18n/locales/en-US.json +1 -0
  46. package/dist/esm/i18n/locales/es-ES.json +1 -0
  47. package/dist/esm/i18n/locales/fr-FR.json +1 -0
  48. package/dist/esm/i18n/locales/it-IT.json +1 -0
  49. package/dist/esm/i18n/locales/ja-JP.json +1 -0
  50. package/dist/esm/i18n/locales/ko-KR.json +1 -0
  51. package/dist/esm/i18n/locales/locales/ar-SA.json +1 -0
  52. package/dist/esm/i18n/locales/locales/ca-ES.json +1 -0
  53. package/dist/esm/i18n/locales/locales/de-DE.json +1 -0
  54. package/dist/esm/i18n/locales/locales/en-US.json +1 -0
  55. package/dist/esm/i18n/locales/locales/es-ES.json +1 -0
  56. package/dist/esm/i18n/locales/locales/fr-FR.json +1 -0
  57. package/dist/esm/i18n/locales/locales/it-IT.json +1 -0
  58. package/dist/esm/i18n/locales/locales/ja-JP.json +1 -0
  59. package/dist/esm/i18n/locales/locales/ko-KR.json +1 -0
  60. package/dist/esm/i18n/locales/locales/pt-PT.json +1 -0
  61. package/dist/esm/i18n/locales/locales/zh-CN.json +1 -0
  62. package/dist/esm/i18n/locales/pt-PT.json +1 -0
  63. package/dist/esm/i18n/locales/zh-CN.json +1 -0
  64. package/dist/esm/index.js +5 -4
  65. package/dist/esm/inference/OxyInferenceClient.js +63 -6
  66. package/dist/esm/mixins/OxyServices.auth.js +96 -0
  67. package/dist/esm/mixins/OxyServices.identity.js +11 -44
  68. package/dist/esm/mixins/index.js +0 -2
  69. package/dist/esm/server/index.js +7 -0
  70. package/dist/esm/server/securityHeaders.js +9 -2
  71. package/dist/esm/server/workloadIdentity.js +166 -0
  72. package/dist/esm/session/refresh.js +12 -0
  73. package/dist/types/.tsbuildinfo +1 -1
  74. package/dist/types/HttpService.d.ts +24 -0
  75. package/dist/types/crypto/identityMove.d.ts +48 -32
  76. package/dist/types/crypto/identityProof.d.ts +16 -0
  77. package/dist/types/crypto/webIdentityCarrier.d.ts +74 -47
  78. package/dist/types/index.d.ts +7 -6
  79. package/dist/types/inference/OxyInferenceClient.d.ts +21 -1
  80. package/dist/types/mixins/OxyServices.auth.d.ts +32 -0
  81. package/dist/types/mixins/OxyServices.identity.d.ts +16 -30
  82. package/dist/types/mixins/index.d.ts +1 -2
  83. package/dist/types/server/index.d.ts +8 -0
  84. package/dist/types/server/securityHeaders.d.ts +8 -0
  85. package/dist/types/server/workloadIdentity.d.ts +32 -0
  86. package/package.json +1 -1
  87. package/src/HttpService.ts +74 -9
  88. package/src/__tests__/httpServiceRateLimitedRefresh.test.ts +160 -0
  89. package/src/__tests__/serviceTokenWorkloadFallback.test.ts +85 -0
  90. package/src/crypto/__tests__/identityMove.test.ts +93 -51
  91. package/src/crypto/__tests__/identityProof.test.ts +81 -0
  92. package/src/crypto/__tests__/webIdentityCarrier.test.ts +21 -63
  93. package/src/crypto/__tests__/webIdentityRecoveryMaterial.test.ts +117 -0
  94. package/src/crypto/identityMove.ts +77 -43
  95. package/src/crypto/identityProof.ts +36 -0
  96. package/src/crypto/webIdentityCarrier.ts +185 -117
  97. package/src/i18n/locales/ar-SA.json +1 -0
  98. package/src/i18n/locales/ca-ES.json +1 -0
  99. package/src/i18n/locales/de-DE.json +1 -0
  100. package/src/i18n/locales/en-US.json +1 -0
  101. package/src/i18n/locales/es-ES.json +1 -0
  102. package/src/i18n/locales/fr-FR.json +1 -0
  103. package/src/i18n/locales/it-IT.json +1 -0
  104. package/src/i18n/locales/ja-JP.json +1 -0
  105. package/src/i18n/locales/ko-KR.json +1 -0
  106. package/src/i18n/locales/pt-PT.json +1 -0
  107. package/src/i18n/locales/zh-CN.json +1 -0
  108. package/src/index.ts +26 -15
  109. package/src/inference/OxyInferenceClient.ts +84 -7
  110. package/src/inference/__tests__/OxyInferenceClient.test.ts +48 -0
  111. package/src/mixins/OxyServices.auth.ts +100 -1
  112. package/src/mixins/OxyServices.identity.ts +17 -67
  113. package/src/mixins/__tests__/OxyServices.identity.test.ts +1 -76
  114. package/src/mixins/index.ts +0 -3
  115. package/src/server/__tests__/securityHeaders.test.ts +15 -0
  116. package/src/server/__tests__/workloadIdentity.test.ts +128 -0
  117. package/src/server/index.ts +9 -0
  118. package/src/server/securityHeaders.ts +19 -6
  119. package/src/server/workloadIdentity.ts +207 -0
  120. package/src/session/__tests__/refresh.test.ts +53 -3
  121. package/src/session/refresh.ts +12 -0
  122. package/dist/cjs/mixins/OxyServices.deviceTransfer.js +0 -314
  123. package/dist/esm/mixins/OxyServices.deviceTransfer.js +0 -311
  124. package/dist/types/mixins/OxyServices.deviceTransfer.d.ts +0 -149
  125. package/src/mixins/OxyServices.deviceTransfer.ts +0 -393
  126. package/src/mixins/__tests__/OxyServices.deviceTransfer.test.ts +0 -271
@@ -1,314 +0,0 @@
1
- "use strict";
2
- /**
3
- * Device-to-device Identity Transfer Mixin (b3 Feature 2 — "add a device")
4
- *
5
- * Clones an existing device's secp256k1 identity onto a fresh device over a
6
- * short-lived, unauthenticated relay, WITHOUT the server ever holding a
7
- * decryption key. Both devices end up holding the SAME private key.
8
- *
9
- * The two devices agree on a symmetric key via an ephemeral secp256k1 ECDH
10
- * handshake (Phase-0 crypto): `deriveSharedSecret` → `hkdfSha256` → a per-pairing
11
- * transfer key, used with `encryptAead`/`decryptAead` (XChaCha20-Poly1305) to
12
- * seal `{ privateKey, publicKey }`. The relay carries only ephemeral public keys
13
- * plus opaque ciphertext — a passive/at-rest-compromised backend cannot decrypt.
14
- *
15
- * Roles:
16
- * - NEW device (no identity): {@link initDeviceTransfer} (generate ephemeral
17
- * pair, register the pairing, render `pairingId` as a QR) then
18
- * {@link subscribeDeviceTransfer} (await approval over the `/device-pair`
19
- * socket with a poll fallback, decrypt, and import the key).
20
- * - OLD device (has identity): {@link getDeviceTransferInfo} (resolve the
21
- * scanned `pairingId` server-side — the QR is NOT self-contained) then
22
- * {@link approveDeviceTransfer} (biometric-gate in the UI, seal the key
23
- * material, and post it with a fresh signature over the CURRENT identity key).
24
- *
25
- * SECURITY: E2E against a passive relay only. Explicitly NOT hardened against an
26
- * actively-malicious backend MITM'ing the ephemeral keys (same trust boundary as
27
- * the existing QR sign-in; SAS compare deferred per owner decision). Approve
28
- * requires BOTH a bearer token AND a fresh identity-key signature.
29
- */
30
- Object.defineProperty(exports, "__esModule", { value: true });
31
- exports.OxyServicesDeviceTransferMixin = OxyServicesDeviceTransferMixin;
32
- const utils_1 = require("@noble/hashes/utils");
33
- const secp256k1_1 = require("@oxy.so/protocol/secp256k1");
34
- const ecdh_1 = require("../crypto/ecdh");
35
- const kdf_1 = require("../crypto/kdf");
36
- const aead_1 = require("../crypto/aead");
37
- const keyManager_1 = require("../crypto/keyManager");
38
- const signatureService_1 = require("../crypto/signatureService");
39
- const socketLoader_1 = require("../session/socketLoader");
40
- const logger_1 = require("../logger");
41
- /**
42
- * Ephemeral private keys for pairings an instance INITIATED, keyed by pairingId,
43
- * held per OxyServices instance. In-memory ONLY (never persisted — single-use),
44
- * cleared once the transfer settles. A module-level WeakMap (rather than a class
45
- * field) keeps it off the mixin's emitted `.d.ts` (avoids TS4094 on the exported
46
- * anonymous class) and lets the GC drop it with the instance.
47
- */
48
- const ephemeralKeyStore = new WeakMap();
49
- function getEphemeralKeys(instance) {
50
- let keys = ephemeralKeyStore.get(instance);
51
- if (!keys) {
52
- keys = new Map();
53
- ephemeralKeyStore.set(instance, keys);
54
- }
55
- return keys;
56
- }
57
- /** HKDF `info` binding — MUST match the server/other-device byte-for-byte. */
58
- const DEVICE_TRANSFER_HKDF_INFO = 'oxy-device-transfer-v1';
59
- /** Socket.IO namespace the API pushes device-pair approval events on. */
60
- const DEVICE_PAIR_NAMESPACE = '/device-pair';
61
- /** Fallback poll cadence — the socket delivers approval instantly; this covers
62
- * the case where the socket can't connect. */
63
- const DEVICE_TRANSFER_POLL_INTERVAL_MS = 2500;
64
- /** Action string signed on approve (mirrors `link_identity`'s scheme). */
65
- const DEVICE_TRANSFER_APPROVE_ACTION = 'approve_device_transfer';
66
- /**
67
- * Derive the per-pairing symmetric transfer key from an ECDH shared secret.
68
- * Identical on both devices: `HKDF(ECDH, salt=pairingId, info=v1)`.
69
- */
70
- function deriveTransferKey(sharedSecret, pairingId) {
71
- return (0, kdf_1.hkdfSha256)(sharedSecret, (0, utils_1.utf8ToBytes)(pairingId), (0, utils_1.utf8ToBytes)(DEVICE_TRANSFER_HKDF_INFO), 32);
72
- }
73
- function OxyServicesDeviceTransferMixin(Base) {
74
- return class extends Base {
75
- constructor(...args) {
76
- super(...args);
77
- }
78
- /**
79
- * NEW device — begin an "add a device" transfer. Generates a single-use
80
- * ephemeral secp256k1 pair, registers the pairing, and returns the
81
- * `pairingId` to render as a QR. The ephemeral private key is held in memory
82
- * (keyed by `pairingId`) for the subsequent {@link subscribeDeviceTransfer}.
83
- *
84
- * @param label - Optional human-readable label for this new device.
85
- */
86
- async initDeviceTransfer(label) {
87
- try {
88
- const { privateKey: ephPrivateKey, publicKey: ephPublicKey } = (0, secp256k1_1.generateSecp256k1KeyPair)();
89
- const res = await this.makeRequest('POST', '/identity/device-transfer/init', { newEphPub: ephPublicKey, ...(label ? { newDeviceLabel: label } : {}) }, { cache: false });
90
- getEphemeralKeys(this).set(res.pairingId, ephPrivateKey);
91
- return {
92
- pairingId: res.pairingId,
93
- expiresAt: res.expiresAt,
94
- newEphemeralPublicKey: ephPublicKey,
95
- };
96
- }
97
- catch (error) {
98
- throw this.handleError(error);
99
- }
100
- }
101
- /**
102
- * Resolve a pairing server-side (the QR carries only `pairingId`). The OLD
103
- * device calls this after scanning to read the new device's ephemeral public
104
- * key + label; the NEW device polls it to fetch the sealed material once
105
- * approved. Public — no auth required.
106
- */
107
- async getDeviceTransferInfo(pairingId) {
108
- try {
109
- return await this.makeRequest('GET', `/identity/device-transfer/${encodeURIComponent(pairingId)}`, undefined, { cache: false, retry: false });
110
- }
111
- catch (error) {
112
- throw this.handleError(error);
113
- }
114
- }
115
- /**
116
- * OLD device — approve a scanned transfer. Reads the new device's ephemeral
117
- * public key, derives the shared transfer key, AEAD-seals
118
- * `{ privateKey, publicKey }`, and posts it PLUS a fresh signature over
119
- * `{ action:'approve_device_transfer', pairingId, timestamp }` made with the
120
- * CURRENT identity key (dual-proof alongside the bearer token).
121
- *
122
- * NATIVE-ONLY: requires a stored identity (throws otherwise). The UI must
123
- * biometric-gate before calling this — a key clone leaves the device.
124
- */
125
- async approveDeviceTransfer(pairingId) {
126
- try {
127
- const info = await this.getDeviceTransferInfo(pairingId);
128
- if (info.status !== 'pending') {
129
- throw new Error(`This transfer can no longer be approved (status: ${info.status}).`);
130
- }
131
- const privateKey = await keyManager_1.KeyManager.getPrivateKey();
132
- const publicKey = await keyManager_1.KeyManager.getPublicKey();
133
- if (!privateKey || !publicKey) {
134
- throw new Error('No identity found on this device. Create or import an identity first.');
135
- }
136
- // Ephemeral ECDH → per-pairing transfer key.
137
- const { privateKey: oldEphPrivateKey, publicKey: oldEphPublicKey } = (0, secp256k1_1.generateSecp256k1KeyPair)();
138
- const sharedSecret = (0, ecdh_1.deriveSharedSecret)(oldEphPrivateKey, info.newDeviceEphemeralPublicKey);
139
- const transferKey = deriveTransferKey(sharedSecret, pairingId);
140
- // Seal the identity key material.
141
- const plaintext = (0, utils_1.utf8ToBytes)(JSON.stringify({ privateKey, publicKey }));
142
- const { nonce, ciphertext } = (0, aead_1.encryptAead)(transferKey, plaintext);
143
- // Dual-proof: prove control of the CURRENT identity key (a bearer alone
144
- // must not be able to exfiltrate the private key).
145
- const timestamp = Date.now();
146
- const message = JSON.stringify({
147
- action: DEVICE_TRANSFER_APPROVE_ACTION,
148
- pairingId,
149
- timestamp,
150
- });
151
- const signature = await signatureService_1.SignatureService.sign(message);
152
- return await this.makeRequest('POST', `/identity/device-transfer/${encodeURIComponent(pairingId)}/approve`, {
153
- oldEphPub: oldEphPublicKey,
154
- ciphertext: (0, utils_1.bytesToHex)(ciphertext),
155
- nonce: (0, utils_1.bytesToHex)(nonce),
156
- signature,
157
- timestamp,
158
- }, { cache: false });
159
- }
160
- catch (error) {
161
- throw this.handleError(error);
162
- }
163
- }
164
- /**
165
- * OLD device — deny (cancel) a scanned transfer so the waiting new device
166
- * stops. Public — no auth required.
167
- */
168
- async denyDeviceTransfer(pairingId) {
169
- try {
170
- return await this.makeRequest('POST', `/identity/device-transfer/${encodeURIComponent(pairingId)}/deny`, undefined, { cache: false });
171
- }
172
- catch (error) {
173
- throw this.handleError(error);
174
- }
175
- }
176
- /**
177
- * NEW device — await approval for a pairing started with
178
- * {@link initDeviceTransfer}, then decrypt and import the transferred
179
- * identity key. Primary path is an instant `device_pair_update` push over the
180
- * `/device-pair` socket; a poll backstops a socket that can't connect.
181
- *
182
- * On `approved`: re-derives the shared transfer key from the old device's
183
- * ephemeral public key, decrypts `{ privateKey, publicKey }`, imports it via
184
- * `KeyManager.importKeyPair(privateKey, { overwrite: false })`, and invokes
185
- * `onOutcome({ status:'approved', publicKey })`. The caller then runs the
186
- * NORMAL challenge/verify sign-in — this method does not mint a session.
187
- *
188
- * @returns An unsubscribe function; call it to stop waiting (also called
189
- * automatically once the transfer settles).
190
- */
191
- subscribeDeviceTransfer(pairingId, onOutcome) {
192
- const ephemeralKeys = getEphemeralKeys(this);
193
- const ephPrivateKey = ephemeralKeys.get(pairingId);
194
- if (!ephPrivateKey) {
195
- throw new Error('No pending device transfer for this pairing id. Call initDeviceTransfer first.');
196
- }
197
- let settled = false;
198
- let inFlight = false;
199
- let socket = null;
200
- let pollTimer = null;
201
- const cleanup = () => {
202
- if (pollTimer !== null) {
203
- clearInterval(pollTimer);
204
- pollTimer = null;
205
- }
206
- if (socket) {
207
- try {
208
- socket.off('device_pair_update');
209
- socket.off('connect');
210
- socket.disconnect();
211
- }
212
- catch (error) {
213
- logger_1.logger.debug('[DeviceTransfer] socket close failed', { component: 'DeviceTransfer' }, error);
214
- }
215
- socket = null;
216
- }
217
- ephemeralKeys.delete(pairingId);
218
- };
219
- const finish = (outcome) => {
220
- if (settled)
221
- return;
222
- settled = true;
223
- cleanup();
224
- onOutcome(outcome);
225
- };
226
- // Re-check authoritative status; on `approved`, decrypt + import.
227
- const check = async () => {
228
- if (settled || inFlight)
229
- return;
230
- inFlight = true;
231
- try {
232
- const info = await this.getDeviceTransferInfo(pairingId);
233
- if (settled)
234
- return;
235
- if (info.status === 'denied') {
236
- finish({ status: 'denied' });
237
- return;
238
- }
239
- if (info.status === 'expired') {
240
- finish({ status: 'expired' });
241
- return;
242
- }
243
- if (info.status === 'approved' &&
244
- info.oldDeviceEphemeralPublicKey &&
245
- info.ciphertext &&
246
- info.nonce) {
247
- const sharedSecret = (0, ecdh_1.deriveSharedSecret)(ephPrivateKey, info.oldDeviceEphemeralPublicKey);
248
- const transferKey = deriveTransferKey(sharedSecret, pairingId);
249
- const plaintext = (0, aead_1.decryptAead)(transferKey, (0, utils_1.hexToBytes)(info.nonce), (0, utils_1.hexToBytes)(info.ciphertext));
250
- const parsed = JSON.parse((0, utils_1.bytesToUtf8)(plaintext));
251
- // Import WITHOUT overwrite: a fresh device has no identity, and we
252
- // must never silently clobber an existing one.
253
- const importedPublicKey = await keyManager_1.KeyManager.importKeyPair(parsed.privateKey, {
254
- overwrite: false,
255
- });
256
- finish({ status: 'approved', publicKey: importedPublicKey });
257
- }
258
- }
259
- catch (error) {
260
- // Transient (a poll tick that raced the approve write, a decrypt on a
261
- // half-written row) — keep waiting; the next tick/push retries.
262
- logger_1.logger.debug('[DeviceTransfer] status check failed', { component: 'DeviceTransfer' }, error);
263
- }
264
- finally {
265
- inFlight = false;
266
- }
267
- };
268
- // Primary: instant socket wake. Fall back to polling if unavailable.
269
- void (async () => {
270
- const io = await (0, socketLoader_1.getSocketIO)();
271
- if (settled || !io)
272
- return;
273
- try {
274
- const s = io(`${this.getBaseURL()}${DEVICE_PAIR_NAMESPACE}`, {
275
- transports: ['websocket'],
276
- autoConnect: true,
277
- reconnection: true,
278
- reconnectionAttempts: Number.POSITIVE_INFINITY,
279
- reconnectionDelay: 1000,
280
- reconnectionDelayMax: 10000,
281
- });
282
- const join = () => {
283
- try {
284
- s.emit('join', pairingId);
285
- }
286
- catch (error) {
287
- logger_1.logger.debug('[DeviceTransfer] join failed', { component: 'DeviceTransfer' }, error);
288
- }
289
- };
290
- s.on('connect', join);
291
- if (s.connected)
292
- join();
293
- s.on('device_pair_update', () => {
294
- void check();
295
- });
296
- socket = s;
297
- }
298
- catch (error) {
299
- logger_1.logger.debug('[DeviceTransfer] socket create failed (poll fallback)', { component: 'DeviceTransfer' }, error);
300
- }
301
- })();
302
- // Fallback poll (also covers the already-approved-before-subscribe case via
303
- // the immediate first tick below). unref so it never holds a Node event
304
- // loop / test runner open.
305
- pollTimer = setInterval(() => {
306
- void check();
307
- }, DEVICE_TRANSFER_POLL_INTERVAL_MS);
308
- pollTimer.unref?.();
309
- // Immediate first check — the transfer may already be approved/denied.
310
- void check();
311
- return cleanup;
312
- }
313
- };
314
- }
@@ -1,311 +0,0 @@
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
- import { bytesToHex, hexToBytes, utf8ToBytes, bytesToUtf8 } from '@noble/hashes/utils';
30
- import { generateSecp256k1KeyPair } from '@oxy.so/protocol/secp256k1';
31
- import { deriveSharedSecret } from '../crypto/ecdh.js';
32
- import { hkdfSha256 } from '../crypto/kdf.js';
33
- import { encryptAead, decryptAead } from '../crypto/aead.js';
34
- import { KeyManager } from '../crypto/keyManager.js';
35
- import { SignatureService } from '../crypto/signatureService.js';
36
- import { getSocketIO } from '../session/socketLoader.js';
37
- import { logger } from '../logger/index.js';
38
- /**
39
- * Ephemeral private keys for pairings an instance INITIATED, keyed by pairingId,
40
- * held per OxyServices instance. In-memory ONLY (never persisted — single-use),
41
- * cleared once the transfer settles. A module-level WeakMap (rather than a class
42
- * field) keeps it off the mixin's emitted `.d.ts` (avoids TS4094 on the exported
43
- * anonymous class) and lets the GC drop it with the instance.
44
- */
45
- const ephemeralKeyStore = new WeakMap();
46
- function getEphemeralKeys(instance) {
47
- let keys = ephemeralKeyStore.get(instance);
48
- if (!keys) {
49
- keys = new Map();
50
- ephemeralKeyStore.set(instance, keys);
51
- }
52
- return keys;
53
- }
54
- /** HKDF `info` binding — MUST match the server/other-device byte-for-byte. */
55
- const DEVICE_TRANSFER_HKDF_INFO = 'oxy-device-transfer-v1';
56
- /** Socket.IO namespace the API pushes device-pair approval events on. */
57
- const DEVICE_PAIR_NAMESPACE = '/device-pair';
58
- /** Fallback poll cadence — the socket delivers approval instantly; this covers
59
- * the case where the socket can't connect. */
60
- const DEVICE_TRANSFER_POLL_INTERVAL_MS = 2500;
61
- /** Action string signed on approve (mirrors `link_identity`'s scheme). */
62
- const DEVICE_TRANSFER_APPROVE_ACTION = 'approve_device_transfer';
63
- /**
64
- * Derive the per-pairing symmetric transfer key from an ECDH shared secret.
65
- * Identical on both devices: `HKDF(ECDH, salt=pairingId, info=v1)`.
66
- */
67
- function deriveTransferKey(sharedSecret, pairingId) {
68
- return hkdfSha256(sharedSecret, utf8ToBytes(pairingId), utf8ToBytes(DEVICE_TRANSFER_HKDF_INFO), 32);
69
- }
70
- export function OxyServicesDeviceTransferMixin(Base) {
71
- return class extends Base {
72
- constructor(...args) {
73
- super(...args);
74
- }
75
- /**
76
- * NEW device — begin an "add a device" transfer. Generates a single-use
77
- * ephemeral secp256k1 pair, registers the pairing, and returns the
78
- * `pairingId` to render as a QR. The ephemeral private key is held in memory
79
- * (keyed by `pairingId`) for the subsequent {@link subscribeDeviceTransfer}.
80
- *
81
- * @param label - Optional human-readable label for this new device.
82
- */
83
- async initDeviceTransfer(label) {
84
- try {
85
- const { privateKey: ephPrivateKey, publicKey: ephPublicKey } = generateSecp256k1KeyPair();
86
- const res = await this.makeRequest('POST', '/identity/device-transfer/init', { newEphPub: ephPublicKey, ...(label ? { newDeviceLabel: label } : {}) }, { cache: false });
87
- getEphemeralKeys(this).set(res.pairingId, ephPrivateKey);
88
- return {
89
- pairingId: res.pairingId,
90
- expiresAt: res.expiresAt,
91
- newEphemeralPublicKey: ephPublicKey,
92
- };
93
- }
94
- catch (error) {
95
- throw this.handleError(error);
96
- }
97
- }
98
- /**
99
- * Resolve a pairing server-side (the QR carries only `pairingId`). The OLD
100
- * device calls this after scanning to read the new device's ephemeral public
101
- * key + label; the NEW device polls it to fetch the sealed material once
102
- * approved. Public — no auth required.
103
- */
104
- async getDeviceTransferInfo(pairingId) {
105
- try {
106
- return await this.makeRequest('GET', `/identity/device-transfer/${encodeURIComponent(pairingId)}`, undefined, { cache: false, retry: false });
107
- }
108
- catch (error) {
109
- throw this.handleError(error);
110
- }
111
- }
112
- /**
113
- * OLD device — approve a scanned transfer. Reads the new device's ephemeral
114
- * public key, derives the shared transfer key, AEAD-seals
115
- * `{ privateKey, publicKey }`, and posts it PLUS a fresh signature over
116
- * `{ action:'approve_device_transfer', pairingId, timestamp }` made with the
117
- * CURRENT identity key (dual-proof alongside the bearer token).
118
- *
119
- * NATIVE-ONLY: requires a stored identity (throws otherwise). The UI must
120
- * biometric-gate before calling this — a key clone leaves the device.
121
- */
122
- async approveDeviceTransfer(pairingId) {
123
- try {
124
- const info = await this.getDeviceTransferInfo(pairingId);
125
- if (info.status !== 'pending') {
126
- throw new Error(`This transfer can no longer be approved (status: ${info.status}).`);
127
- }
128
- const privateKey = await KeyManager.getPrivateKey();
129
- const publicKey = await KeyManager.getPublicKey();
130
- if (!privateKey || !publicKey) {
131
- throw new Error('No identity found on this device. Create or import an identity first.');
132
- }
133
- // Ephemeral ECDH → per-pairing transfer key.
134
- const { privateKey: oldEphPrivateKey, publicKey: oldEphPublicKey } = generateSecp256k1KeyPair();
135
- const sharedSecret = deriveSharedSecret(oldEphPrivateKey, info.newDeviceEphemeralPublicKey);
136
- const transferKey = deriveTransferKey(sharedSecret, pairingId);
137
- // Seal the identity key material.
138
- const plaintext = utf8ToBytes(JSON.stringify({ privateKey, publicKey }));
139
- const { nonce, ciphertext } = encryptAead(transferKey, plaintext);
140
- // Dual-proof: prove control of the CURRENT identity key (a bearer alone
141
- // must not be able to exfiltrate the private key).
142
- const timestamp = Date.now();
143
- const message = JSON.stringify({
144
- action: DEVICE_TRANSFER_APPROVE_ACTION,
145
- pairingId,
146
- timestamp,
147
- });
148
- const signature = await SignatureService.sign(message);
149
- return await this.makeRequest('POST', `/identity/device-transfer/${encodeURIComponent(pairingId)}/approve`, {
150
- oldEphPub: oldEphPublicKey,
151
- ciphertext: bytesToHex(ciphertext),
152
- nonce: bytesToHex(nonce),
153
- signature,
154
- timestamp,
155
- }, { cache: false });
156
- }
157
- catch (error) {
158
- throw this.handleError(error);
159
- }
160
- }
161
- /**
162
- * OLD device — deny (cancel) a scanned transfer so the waiting new device
163
- * stops. Public — no auth required.
164
- */
165
- async denyDeviceTransfer(pairingId) {
166
- try {
167
- return await this.makeRequest('POST', `/identity/device-transfer/${encodeURIComponent(pairingId)}/deny`, undefined, { cache: false });
168
- }
169
- catch (error) {
170
- throw this.handleError(error);
171
- }
172
- }
173
- /**
174
- * NEW device — await approval for a pairing started with
175
- * {@link initDeviceTransfer}, then decrypt and import the transferred
176
- * identity key. Primary path is an instant `device_pair_update` push over the
177
- * `/device-pair` socket; a poll backstops a socket that can't connect.
178
- *
179
- * On `approved`: re-derives the shared transfer key from the old device's
180
- * ephemeral public key, decrypts `{ privateKey, publicKey }`, imports it via
181
- * `KeyManager.importKeyPair(privateKey, { overwrite: false })`, and invokes
182
- * `onOutcome({ status:'approved', publicKey })`. The caller then runs the
183
- * NORMAL challenge/verify sign-in — this method does not mint a session.
184
- *
185
- * @returns An unsubscribe function; call it to stop waiting (also called
186
- * automatically once the transfer settles).
187
- */
188
- subscribeDeviceTransfer(pairingId, onOutcome) {
189
- const ephemeralKeys = getEphemeralKeys(this);
190
- const ephPrivateKey = ephemeralKeys.get(pairingId);
191
- if (!ephPrivateKey) {
192
- throw new Error('No pending device transfer for this pairing id. Call initDeviceTransfer first.');
193
- }
194
- let settled = false;
195
- let inFlight = false;
196
- let socket = null;
197
- let pollTimer = null;
198
- const cleanup = () => {
199
- if (pollTimer !== null) {
200
- clearInterval(pollTimer);
201
- pollTimer = null;
202
- }
203
- if (socket) {
204
- try {
205
- socket.off('device_pair_update');
206
- socket.off('connect');
207
- socket.disconnect();
208
- }
209
- catch (error) {
210
- logger.debug('[DeviceTransfer] socket close failed', { component: 'DeviceTransfer' }, error);
211
- }
212
- socket = null;
213
- }
214
- ephemeralKeys.delete(pairingId);
215
- };
216
- const finish = (outcome) => {
217
- if (settled)
218
- return;
219
- settled = true;
220
- cleanup();
221
- onOutcome(outcome);
222
- };
223
- // Re-check authoritative status; on `approved`, decrypt + import.
224
- const check = async () => {
225
- if (settled || inFlight)
226
- return;
227
- inFlight = true;
228
- try {
229
- const info = await this.getDeviceTransferInfo(pairingId);
230
- if (settled)
231
- return;
232
- if (info.status === 'denied') {
233
- finish({ status: 'denied' });
234
- return;
235
- }
236
- if (info.status === 'expired') {
237
- finish({ status: 'expired' });
238
- return;
239
- }
240
- if (info.status === 'approved' &&
241
- info.oldDeviceEphemeralPublicKey &&
242
- info.ciphertext &&
243
- info.nonce) {
244
- const sharedSecret = deriveSharedSecret(ephPrivateKey, info.oldDeviceEphemeralPublicKey);
245
- const transferKey = deriveTransferKey(sharedSecret, pairingId);
246
- const plaintext = decryptAead(transferKey, hexToBytes(info.nonce), hexToBytes(info.ciphertext));
247
- const parsed = JSON.parse(bytesToUtf8(plaintext));
248
- // Import WITHOUT overwrite: a fresh device has no identity, and we
249
- // must never silently clobber an existing one.
250
- const importedPublicKey = await KeyManager.importKeyPair(parsed.privateKey, {
251
- overwrite: false,
252
- });
253
- finish({ status: 'approved', publicKey: importedPublicKey });
254
- }
255
- }
256
- catch (error) {
257
- // Transient (a poll tick that raced the approve write, a decrypt on a
258
- // half-written row) — keep waiting; the next tick/push retries.
259
- logger.debug('[DeviceTransfer] status check failed', { component: 'DeviceTransfer' }, error);
260
- }
261
- finally {
262
- inFlight = false;
263
- }
264
- };
265
- // Primary: instant socket wake. Fall back to polling if unavailable.
266
- void (async () => {
267
- const io = await getSocketIO();
268
- if (settled || !io)
269
- return;
270
- try {
271
- const s = io(`${this.getBaseURL()}${DEVICE_PAIR_NAMESPACE}`, {
272
- transports: ['websocket'],
273
- autoConnect: true,
274
- reconnection: true,
275
- reconnectionAttempts: Number.POSITIVE_INFINITY,
276
- reconnectionDelay: 1000,
277
- reconnectionDelayMax: 10000,
278
- });
279
- const join = () => {
280
- try {
281
- s.emit('join', pairingId);
282
- }
283
- catch (error) {
284
- logger.debug('[DeviceTransfer] join failed', { component: 'DeviceTransfer' }, error);
285
- }
286
- };
287
- s.on('connect', join);
288
- if (s.connected)
289
- join();
290
- s.on('device_pair_update', () => {
291
- void check();
292
- });
293
- socket = s;
294
- }
295
- catch (error) {
296
- logger.debug('[DeviceTransfer] socket create failed (poll fallback)', { component: 'DeviceTransfer' }, error);
297
- }
298
- })();
299
- // Fallback poll (also covers the already-approved-before-subscribe case via
300
- // the immediate first tick below). unref so it never holds a Node event
301
- // loop / test runner open.
302
- pollTimer = setInterval(() => {
303
- void check();
304
- }, DEVICE_TRANSFER_POLL_INTERVAL_MS);
305
- pollTimer.unref?.();
306
- // Immediate first check — the transfer may already be approved/denied.
307
- void check();
308
- return cleanup;
309
- }
310
- };
311
- }