@oxyhq/core 12.6.0 → 12.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/crypto/keyManager.js +50 -0
- package/dist/cjs/i18n/locales/en-US.json +7 -0
- package/dist/cjs/i18n/locales/es-ES.json +7 -0
- package/dist/cjs/i18n/locales/locales/en-US.json +7 -0
- package/dist/cjs/i18n/locales/locales/es-ES.json +7 -0
- package/dist/cjs/mixins/OxyServices.deviceTransfer.js +319 -0
- package/dist/cjs/mixins/OxyServices.utility.js +11 -1
- package/dist/cjs/mixins/index.js +4 -0
- package/dist/cjs/server/auth.js +3 -0
- package/dist/cjs/server/index.js +2 -1
- package/dist/cjs/utils/oxyServiceEnvironment.js +19 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/crypto/keyManager.js +50 -0
- package/dist/esm/i18n/locales/en-US.json +7 -0
- package/dist/esm/i18n/locales/es-ES.json +7 -0
- package/dist/esm/i18n/locales/locales/en-US.json +7 -0
- package/dist/esm/i18n/locales/locales/es-ES.json +7 -0
- package/dist/esm/mixins/OxyServices.deviceTransfer.js +317 -0
- package/dist/esm/mixins/OxyServices.utility.js +11 -1
- package/dist/esm/mixins/index.js +4 -0
- package/dist/esm/server/auth.js +2 -0
- package/dist/esm/server/index.js +1 -1
- package/dist/esm/utils/oxyServiceEnvironment.js +16 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/crypto/keyManager.d.ts +20 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/mixins/OxyServices.deviceTransfer.d.ts +149 -0
- package/dist/types/mixins/OxyServices.utility.d.ts +3 -0
- package/dist/types/mixins/index.d.ts +2 -1
- package/dist/types/server/auth.d.ts +4 -0
- package/dist/types/server/index.d.ts +2 -2
- package/dist/types/utils/oxyServiceEnvironment.d.ts +17 -0
- package/package.json +1 -1
- package/src/crypto/__tests__/scopedSeed.test.ts +126 -0
- package/src/crypto/keyManager.ts +55 -0
- package/src/i18n/locales/en-US.json +7 -0
- package/src/i18n/locales/es-ES.json +7 -0
- package/src/index.ts +4 -0
- package/src/mixins/OxyServices.deviceTransfer.ts +397 -0
- package/src/mixins/OxyServices.utility.ts +19 -1
- package/src/mixins/__tests__/OxyServices.deviceTransfer.test.ts +270 -0
- package/src/mixins/__tests__/serviceAuth.test.ts +65 -0
- package/src/mixins/index.ts +6 -0
- package/src/server/auth.ts +5 -0
- package/src/server/index.ts +2 -0
- package/src/utils/__tests__/oxyServiceEnvironment.test.ts +7 -0
- package/src/utils/oxyServiceEnvironment.ts +17 -0
|
@@ -9,6 +9,7 @@ const { ec: EC } = _cjs_elliptic;
|
|
|
9
9
|
import { isWeb, isIOS, isAndroid } from '../utils/platform.js';
|
|
10
10
|
import { isReactNative, isNodeJS, loadExpoCrypto, loadNodeCrypto, loadSecureStore, loadSharedIdentityBridge } from '@oxyhq/protocol';
|
|
11
11
|
import { isDev, logger } from '../logger/index.js';
|
|
12
|
+
import { hkdfSha256 } from './kdf.js';
|
|
12
13
|
/**
|
|
13
14
|
* Thrown when an identity-mutating operation (createIdentity / importKeyPair)
|
|
14
15
|
* is invoked while a valid identity already exists on the device.
|
|
@@ -41,6 +42,25 @@ export class IdentityPersistError extends Error {
|
|
|
41
42
|
}
|
|
42
43
|
}
|
|
43
44
|
const ec = new EC('secp256k1');
|
|
45
|
+
/**
|
|
46
|
+
* HKDF salt that domain-separates every identity-scoped seed produced by
|
|
47
|
+
* {@link KeyManager.deriveScopedSeed}. Versioned so a future scheme change is a
|
|
48
|
+
* new, non-colliding tag. The per-app domain (e.g. Oxy Pay's FairCoin wallet)
|
|
49
|
+
* is carried by the caller's `info` string, not this salt.
|
|
50
|
+
*/
|
|
51
|
+
const SCOPED_SEED_KDF_SALT = 'oxy-identity-scoped-seed-v1';
|
|
52
|
+
/** UTF-8 encode an ASCII label to bytes (HKDF salt/info). */
|
|
53
|
+
function utf8ToBytes(label) {
|
|
54
|
+
return new TextEncoder().encode(label);
|
|
55
|
+
}
|
|
56
|
+
/** Decode a hex string to bytes. Inverse of {@link uint8ArrayToHex}. */
|
|
57
|
+
function hexToBytes(hex) {
|
|
58
|
+
const out = new Uint8Array(hex.length / 2);
|
|
59
|
+
for (let i = 0; i < out.length; i++) {
|
|
60
|
+
out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
44
64
|
const STORAGE_KEYS = {
|
|
45
65
|
PRIVATE_KEY: 'oxy_identity_private_key',
|
|
46
66
|
PUBLIC_KEY: 'oxy_identity_public_key',
|
|
@@ -1385,6 +1405,36 @@ export class KeyManager {
|
|
|
1385
1405
|
return false;
|
|
1386
1406
|
}
|
|
1387
1407
|
}
|
|
1408
|
+
/**
|
|
1409
|
+
* Derive a 32-byte, domain-separated seed from the on-device Oxy identity
|
|
1410
|
+
* private key via HKDF-SHA256, WITHOUT ever exposing the raw private key.
|
|
1411
|
+
*
|
|
1412
|
+
* The domain separation is carried by `info` (e.g. `"oxypay/faircoin/v1"`),
|
|
1413
|
+
* so distinct apps/purposes get independent seeds from the same identity.
|
|
1414
|
+
* The output is HKDF keying material, never the private key itself — a
|
|
1415
|
+
* consumer (e.g. Oxy Pay's FairCoin HD wallet) can feed it straight into
|
|
1416
|
+
* `HDKey.fromMasterSeed` and never touches the identity key.
|
|
1417
|
+
*
|
|
1418
|
+
* Key source (native only): prefers the shared ecosystem identity written to
|
|
1419
|
+
* `group.so.oxy.shared` (what a Relying Party like Oxy Pay reads), then falls
|
|
1420
|
+
* back to this device's primary identity (Commons/Accounts). Both reproduce
|
|
1421
|
+
* from the user's Oxy recovery phrase, so the derived seed is recoverable.
|
|
1422
|
+
*
|
|
1423
|
+
* @param info Context/domain-binding label (distinct labels → independent seeds).
|
|
1424
|
+
* @returns 32 bytes of derived keying material, or `null` on web / when no
|
|
1425
|
+
* identity key is available on this device.
|
|
1426
|
+
*/
|
|
1427
|
+
static async deriveScopedSeed(info) {
|
|
1428
|
+
if (isWebPlatform()) {
|
|
1429
|
+
return null;
|
|
1430
|
+
}
|
|
1431
|
+
const privateKey = (await KeyManager.getSharedPrivateKey()) ?? (await KeyManager.getPrivateKey());
|
|
1432
|
+
if (!privateKey) {
|
|
1433
|
+
return null;
|
|
1434
|
+
}
|
|
1435
|
+
const ikm = hexToBytes(KeyManager.canonicalPrivateKey(privateKey));
|
|
1436
|
+
return hkdfSha256(ikm, utf8ToBytes(SCOPED_SEED_KDF_SALT), utf8ToBytes(info), 32);
|
|
1437
|
+
}
|
|
1388
1438
|
/**
|
|
1389
1439
|
* Get a shortened version of the public key for display
|
|
1390
1440
|
* Format: first 8 chars...last 8 chars
|
|
@@ -1449,6 +1449,13 @@
|
|
|
1449
1449
|
"emptyTitle": "No photos yet",
|
|
1450
1450
|
"emptySubtitle": "Upload from your device to get started"
|
|
1451
1451
|
},
|
|
1452
|
+
"details": {
|
|
1453
|
+
"title": "File Details",
|
|
1454
|
+
"download": "Download",
|
|
1455
|
+
"type": "Type",
|
|
1456
|
+
"uploaded": "Uploaded",
|
|
1457
|
+
"description": "Description"
|
|
1458
|
+
},
|
|
1452
1459
|
"a11y": {
|
|
1453
1460
|
"viewAll": "Show all files",
|
|
1454
1461
|
"viewPhotos": "Show photos only",
|
|
@@ -1449,6 +1449,13 @@
|
|
|
1449
1449
|
"emptyTitle": "Aún no hay fotos",
|
|
1450
1450
|
"emptySubtitle": "Sube desde tu dispositivo para empezar"
|
|
1451
1451
|
},
|
|
1452
|
+
"details": {
|
|
1453
|
+
"title": "Detalles del archivo",
|
|
1454
|
+
"download": "Descargar",
|
|
1455
|
+
"type": "Tipo",
|
|
1456
|
+
"uploaded": "Subido",
|
|
1457
|
+
"description": "Descripción"
|
|
1458
|
+
},
|
|
1452
1459
|
"a11y": {
|
|
1453
1460
|
"viewAll": "Mostrar todos los archivos",
|
|
1454
1461
|
"viewPhotos": "Mostrar solo fotos",
|
|
@@ -1449,6 +1449,13 @@
|
|
|
1449
1449
|
"emptyTitle": "No photos yet",
|
|
1450
1450
|
"emptySubtitle": "Upload from your device to get started"
|
|
1451
1451
|
},
|
|
1452
|
+
"details": {
|
|
1453
|
+
"title": "File Details",
|
|
1454
|
+
"download": "Download",
|
|
1455
|
+
"type": "Type",
|
|
1456
|
+
"uploaded": "Uploaded",
|
|
1457
|
+
"description": "Description"
|
|
1458
|
+
},
|
|
1452
1459
|
"a11y": {
|
|
1453
1460
|
"viewAll": "Show all files",
|
|
1454
1461
|
"viewPhotos": "Show photos only",
|
|
@@ -1449,6 +1449,13 @@
|
|
|
1449
1449
|
"emptyTitle": "Aún no hay fotos",
|
|
1450
1450
|
"emptySubtitle": "Sube desde tu dispositivo para empezar"
|
|
1451
1451
|
},
|
|
1452
|
+
"details": {
|
|
1453
|
+
"title": "Detalles del archivo",
|
|
1454
|
+
"download": "Descargar",
|
|
1455
|
+
"type": "Tipo",
|
|
1456
|
+
"uploaded": "Subido",
|
|
1457
|
+
"description": "Descripción"
|
|
1458
|
+
},
|
|
1452
1459
|
"a11y": {
|
|
1453
1460
|
"viewAll": "Mostrar todos los archivos",
|
|
1454
1461
|
"viewPhotos": "Mostrar solo fotos",
|
|
@@ -0,0 +1,317 @@
|
|
|
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 _cjs_elliptic from 'elliptic';
|
|
30
|
+
const { ec: EC } = _cjs_elliptic;
|
|
31
|
+
import { bytesToHex, hexToBytes, utf8ToBytes, bytesToUtf8 } from '@noble/hashes/utils';
|
|
32
|
+
import { deriveSharedSecret } from '../crypto/ecdh.js';
|
|
33
|
+
import { hkdfSha256 } from '../crypto/kdf.js';
|
|
34
|
+
import { encryptAead, decryptAead } from '../crypto/aead.js';
|
|
35
|
+
import { KeyManager } from '../crypto/keyManager.js';
|
|
36
|
+
import { SignatureService } from '../crypto/signatureService.js';
|
|
37
|
+
import { getSocketIO } from '../session/socketLoader.js';
|
|
38
|
+
import { logger } from '../logger/index.js';
|
|
39
|
+
const ecCurve = new EC('secp256k1');
|
|
40
|
+
/**
|
|
41
|
+
* Ephemeral private keys for pairings an instance INITIATED, keyed by pairingId,
|
|
42
|
+
* held per OxyServices instance. In-memory ONLY (never persisted — single-use),
|
|
43
|
+
* cleared once the transfer settles. A module-level WeakMap (rather than a class
|
|
44
|
+
* field) keeps it off the mixin's emitted `.d.ts` (avoids TS4094 on the exported
|
|
45
|
+
* anonymous class) and lets the GC drop it with the instance.
|
|
46
|
+
*/
|
|
47
|
+
const ephemeralKeyStore = new WeakMap();
|
|
48
|
+
function getEphemeralKeys(instance) {
|
|
49
|
+
let keys = ephemeralKeyStore.get(instance);
|
|
50
|
+
if (!keys) {
|
|
51
|
+
keys = new Map();
|
|
52
|
+
ephemeralKeyStore.set(instance, keys);
|
|
53
|
+
}
|
|
54
|
+
return keys;
|
|
55
|
+
}
|
|
56
|
+
/** HKDF `info` binding — MUST match the server/other-device byte-for-byte. */
|
|
57
|
+
const DEVICE_TRANSFER_HKDF_INFO = 'oxy-device-transfer-v1';
|
|
58
|
+
/** Socket.IO namespace the API pushes device-pair approval events on. */
|
|
59
|
+
const DEVICE_PAIR_NAMESPACE = '/device-pair';
|
|
60
|
+
/** Fallback poll cadence — the socket delivers approval instantly; this covers
|
|
61
|
+
* the case where the socket can't connect. */
|
|
62
|
+
const DEVICE_TRANSFER_POLL_INTERVAL_MS = 2500;
|
|
63
|
+
/** Action string signed on approve (mirrors `link_identity`'s scheme). */
|
|
64
|
+
const DEVICE_TRANSFER_APPROVE_ACTION = 'approve_device_transfer';
|
|
65
|
+
/**
|
|
66
|
+
* Derive the per-pairing symmetric transfer key from an ECDH shared secret.
|
|
67
|
+
* Identical on both devices: `HKDF(ECDH, salt=pairingId, info=v1)`.
|
|
68
|
+
*/
|
|
69
|
+
function deriveTransferKey(sharedSecret, pairingId) {
|
|
70
|
+
return hkdfSha256(sharedSecret, utf8ToBytes(pairingId), utf8ToBytes(DEVICE_TRANSFER_HKDF_INFO), 32);
|
|
71
|
+
}
|
|
72
|
+
export function OxyServicesDeviceTransferMixin(Base) {
|
|
73
|
+
return class extends Base {
|
|
74
|
+
constructor(...args) {
|
|
75
|
+
super(...args);
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* NEW device — begin an "add a device" transfer. Generates a single-use
|
|
79
|
+
* ephemeral secp256k1 pair, registers the pairing, and returns the
|
|
80
|
+
* `pairingId` to render as a QR. The ephemeral private key is held in memory
|
|
81
|
+
* (keyed by `pairingId`) for the subsequent {@link subscribeDeviceTransfer}.
|
|
82
|
+
*
|
|
83
|
+
* @param label - Optional human-readable label for this new device.
|
|
84
|
+
*/
|
|
85
|
+
async initDeviceTransfer(label) {
|
|
86
|
+
try {
|
|
87
|
+
const ephKeyPair = ecCurve.genKeyPair();
|
|
88
|
+
const ephPrivateKey = ephKeyPair.getPrivate('hex');
|
|
89
|
+
const ephPublicKey = ephKeyPair.getPublic('hex');
|
|
90
|
+
const res = await this.makeRequest('POST', '/identity/device-transfer/init', { newEphPub: ephPublicKey, ...(label ? { newDeviceLabel: label } : {}) }, { cache: false });
|
|
91
|
+
getEphemeralKeys(this).set(res.pairingId, ephPrivateKey);
|
|
92
|
+
return {
|
|
93
|
+
pairingId: res.pairingId,
|
|
94
|
+
expiresAt: res.expiresAt,
|
|
95
|
+
newEphemeralPublicKey: ephPublicKey,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
throw this.handleError(error);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Resolve a pairing server-side (the QR carries only `pairingId`). The OLD
|
|
104
|
+
* device calls this after scanning to read the new device's ephemeral public
|
|
105
|
+
* key + label; the NEW device polls it to fetch the sealed material once
|
|
106
|
+
* approved. Public — no auth required.
|
|
107
|
+
*/
|
|
108
|
+
async getDeviceTransferInfo(pairingId) {
|
|
109
|
+
try {
|
|
110
|
+
return await this.makeRequest('GET', `/identity/device-transfer/${encodeURIComponent(pairingId)}`, undefined, { cache: false, retry: false });
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
throw this.handleError(error);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* OLD device — approve a scanned transfer. Reads the new device's ephemeral
|
|
118
|
+
* public key, derives the shared transfer key, AEAD-seals
|
|
119
|
+
* `{ privateKey, publicKey }`, and posts it PLUS a fresh signature over
|
|
120
|
+
* `{ action:'approve_device_transfer', pairingId, timestamp }` made with the
|
|
121
|
+
* CURRENT identity key (dual-proof alongside the bearer token).
|
|
122
|
+
*
|
|
123
|
+
* NATIVE-ONLY: requires a stored identity (throws otherwise). The UI must
|
|
124
|
+
* biometric-gate before calling this — a key clone leaves the device.
|
|
125
|
+
*/
|
|
126
|
+
async approveDeviceTransfer(pairingId) {
|
|
127
|
+
try {
|
|
128
|
+
const info = await this.getDeviceTransferInfo(pairingId);
|
|
129
|
+
if (info.status !== 'pending') {
|
|
130
|
+
throw new Error(`This transfer can no longer be approved (status: ${info.status}).`);
|
|
131
|
+
}
|
|
132
|
+
const privateKey = await KeyManager.getPrivateKey();
|
|
133
|
+
const publicKey = await KeyManager.getPublicKey();
|
|
134
|
+
if (!privateKey || !publicKey) {
|
|
135
|
+
throw new Error('No identity found on this device. Create or import an identity first.');
|
|
136
|
+
}
|
|
137
|
+
// Ephemeral ECDH → per-pairing transfer key.
|
|
138
|
+
const oldEphKeyPair = ecCurve.genKeyPair();
|
|
139
|
+
const oldEphPrivateKey = oldEphKeyPair.getPrivate('hex');
|
|
140
|
+
const oldEphPublicKey = oldEphKeyPair.getPublic('hex');
|
|
141
|
+
const sharedSecret = deriveSharedSecret(oldEphPrivateKey, info.newDeviceEphemeralPublicKey);
|
|
142
|
+
const transferKey = deriveTransferKey(sharedSecret, pairingId);
|
|
143
|
+
// Seal the identity key material.
|
|
144
|
+
const plaintext = utf8ToBytes(JSON.stringify({ privateKey, publicKey }));
|
|
145
|
+
const { nonce, ciphertext } = encryptAead(transferKey, plaintext);
|
|
146
|
+
// Dual-proof: prove control of the CURRENT identity key (a bearer alone
|
|
147
|
+
// must not be able to exfiltrate the private key).
|
|
148
|
+
const timestamp = Date.now();
|
|
149
|
+
const message = JSON.stringify({
|
|
150
|
+
action: DEVICE_TRANSFER_APPROVE_ACTION,
|
|
151
|
+
pairingId,
|
|
152
|
+
timestamp,
|
|
153
|
+
});
|
|
154
|
+
const signature = await SignatureService.sign(message);
|
|
155
|
+
return await this.makeRequest('POST', `/identity/device-transfer/${encodeURIComponent(pairingId)}/approve`, {
|
|
156
|
+
oldEphPub: oldEphPublicKey,
|
|
157
|
+
ciphertext: bytesToHex(ciphertext),
|
|
158
|
+
nonce: bytesToHex(nonce),
|
|
159
|
+
signature,
|
|
160
|
+
timestamp,
|
|
161
|
+
}, { cache: false });
|
|
162
|
+
}
|
|
163
|
+
catch (error) {
|
|
164
|
+
throw this.handleError(error);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* OLD device — deny (cancel) a scanned transfer so the waiting new device
|
|
169
|
+
* stops. Public — no auth required.
|
|
170
|
+
*/
|
|
171
|
+
async denyDeviceTransfer(pairingId) {
|
|
172
|
+
try {
|
|
173
|
+
return await this.makeRequest('POST', `/identity/device-transfer/${encodeURIComponent(pairingId)}/deny`, undefined, { cache: false });
|
|
174
|
+
}
|
|
175
|
+
catch (error) {
|
|
176
|
+
throw this.handleError(error);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* NEW device — await approval for a pairing started with
|
|
181
|
+
* {@link initDeviceTransfer}, then decrypt and import the transferred
|
|
182
|
+
* identity key. Primary path is an instant `device_pair_update` push over the
|
|
183
|
+
* `/device-pair` socket; a poll backstops a socket that can't connect.
|
|
184
|
+
*
|
|
185
|
+
* On `approved`: re-derives the shared transfer key from the old device's
|
|
186
|
+
* ephemeral public key, decrypts `{ privateKey, publicKey }`, imports it via
|
|
187
|
+
* `KeyManager.importKeyPair(privateKey, { overwrite: false })`, and invokes
|
|
188
|
+
* `onOutcome({ status:'approved', publicKey })`. The caller then runs the
|
|
189
|
+
* NORMAL challenge/verify sign-in — this method does not mint a session.
|
|
190
|
+
*
|
|
191
|
+
* @returns An unsubscribe function; call it to stop waiting (also called
|
|
192
|
+
* automatically once the transfer settles).
|
|
193
|
+
*/
|
|
194
|
+
subscribeDeviceTransfer(pairingId, onOutcome) {
|
|
195
|
+
const ephemeralKeys = getEphemeralKeys(this);
|
|
196
|
+
const ephPrivateKey = ephemeralKeys.get(pairingId);
|
|
197
|
+
if (!ephPrivateKey) {
|
|
198
|
+
throw new Error('No pending device transfer for this pairing id. Call initDeviceTransfer first.');
|
|
199
|
+
}
|
|
200
|
+
let settled = false;
|
|
201
|
+
let inFlight = false;
|
|
202
|
+
let socket = null;
|
|
203
|
+
let pollTimer = null;
|
|
204
|
+
const cleanup = () => {
|
|
205
|
+
if (pollTimer !== null) {
|
|
206
|
+
clearInterval(pollTimer);
|
|
207
|
+
pollTimer = null;
|
|
208
|
+
}
|
|
209
|
+
if (socket) {
|
|
210
|
+
try {
|
|
211
|
+
socket.off('device_pair_update');
|
|
212
|
+
socket.off('connect');
|
|
213
|
+
socket.disconnect();
|
|
214
|
+
}
|
|
215
|
+
catch (error) {
|
|
216
|
+
logger.debug('[DeviceTransfer] socket close failed', { component: 'DeviceTransfer' }, error);
|
|
217
|
+
}
|
|
218
|
+
socket = null;
|
|
219
|
+
}
|
|
220
|
+
ephemeralKeys.delete(pairingId);
|
|
221
|
+
};
|
|
222
|
+
const finish = (outcome) => {
|
|
223
|
+
if (settled)
|
|
224
|
+
return;
|
|
225
|
+
settled = true;
|
|
226
|
+
cleanup();
|
|
227
|
+
onOutcome(outcome);
|
|
228
|
+
};
|
|
229
|
+
// Re-check authoritative status; on `approved`, decrypt + import.
|
|
230
|
+
const check = async () => {
|
|
231
|
+
if (settled || inFlight)
|
|
232
|
+
return;
|
|
233
|
+
inFlight = true;
|
|
234
|
+
try {
|
|
235
|
+
const info = await this.getDeviceTransferInfo(pairingId);
|
|
236
|
+
if (settled)
|
|
237
|
+
return;
|
|
238
|
+
if (info.status === 'denied') {
|
|
239
|
+
finish({ status: 'denied' });
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
if (info.status === 'expired') {
|
|
243
|
+
finish({ status: 'expired' });
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
if (info.status === 'approved' &&
|
|
247
|
+
info.oldDeviceEphemeralPublicKey &&
|
|
248
|
+
info.ciphertext &&
|
|
249
|
+
info.nonce) {
|
|
250
|
+
const sharedSecret = deriveSharedSecret(ephPrivateKey, info.oldDeviceEphemeralPublicKey);
|
|
251
|
+
const transferKey = deriveTransferKey(sharedSecret, pairingId);
|
|
252
|
+
const plaintext = decryptAead(transferKey, hexToBytes(info.nonce), hexToBytes(info.ciphertext));
|
|
253
|
+
const parsed = JSON.parse(bytesToUtf8(plaintext));
|
|
254
|
+
// Import WITHOUT overwrite: a fresh device has no identity, and we
|
|
255
|
+
// must never silently clobber an existing one.
|
|
256
|
+
const importedPublicKey = await KeyManager.importKeyPair(parsed.privateKey, {
|
|
257
|
+
overwrite: false,
|
|
258
|
+
});
|
|
259
|
+
finish({ status: 'approved', publicKey: importedPublicKey });
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
catch (error) {
|
|
263
|
+
// Transient (a poll tick that raced the approve write, a decrypt on a
|
|
264
|
+
// half-written row) — keep waiting; the next tick/push retries.
|
|
265
|
+
logger.debug('[DeviceTransfer] status check failed', { component: 'DeviceTransfer' }, error);
|
|
266
|
+
}
|
|
267
|
+
finally {
|
|
268
|
+
inFlight = false;
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
// Primary: instant socket wake. Fall back to polling if unavailable.
|
|
272
|
+
void (async () => {
|
|
273
|
+
const io = await getSocketIO();
|
|
274
|
+
if (settled || !io)
|
|
275
|
+
return;
|
|
276
|
+
try {
|
|
277
|
+
const s = io(`${this.getBaseURL()}${DEVICE_PAIR_NAMESPACE}`, {
|
|
278
|
+
transports: ['websocket'],
|
|
279
|
+
autoConnect: true,
|
|
280
|
+
reconnection: true,
|
|
281
|
+
reconnectionAttempts: Number.POSITIVE_INFINITY,
|
|
282
|
+
reconnectionDelay: 1000,
|
|
283
|
+
reconnectionDelayMax: 10000,
|
|
284
|
+
});
|
|
285
|
+
const join = () => {
|
|
286
|
+
try {
|
|
287
|
+
s.emit('join', pairingId);
|
|
288
|
+
}
|
|
289
|
+
catch (error) {
|
|
290
|
+
logger.debug('[DeviceTransfer] join failed', { component: 'DeviceTransfer' }, error);
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
s.on('connect', join);
|
|
294
|
+
if (s.connected)
|
|
295
|
+
join();
|
|
296
|
+
s.on('device_pair_update', () => {
|
|
297
|
+
void check();
|
|
298
|
+
});
|
|
299
|
+
socket = s;
|
|
300
|
+
}
|
|
301
|
+
catch (error) {
|
|
302
|
+
logger.debug('[DeviceTransfer] socket create failed (poll fallback)', { component: 'DeviceTransfer' }, error);
|
|
303
|
+
}
|
|
304
|
+
})();
|
|
305
|
+
// Fallback poll (also covers the already-approved-before-subscribe case via
|
|
306
|
+
// the immediate first tick below). unref so it never holds a Node event
|
|
307
|
+
// loop / test runner open.
|
|
308
|
+
pollTimer = setInterval(() => {
|
|
309
|
+
void check();
|
|
310
|
+
}, DEVICE_TRANSFER_POLL_INTERVAL_MS);
|
|
311
|
+
pollTimer.unref?.();
|
|
312
|
+
// Immediate first check — the transfer may already be approved/denied.
|
|
313
|
+
void check();
|
|
314
|
+
return cleanup;
|
|
315
|
+
}
|
|
316
|
+
};
|
|
317
|
+
}
|
|
@@ -8,6 +8,7 @@ import { jwtDecode } from 'jwt-decode';
|
|
|
8
8
|
import { loadNodeCrypto } from '@oxyhq/protocol';
|
|
9
9
|
import { buildUrl } from '../utils/apiUtils.js';
|
|
10
10
|
import { logger } from '../logger/index.js';
|
|
11
|
+
import { OXY_SERVICE_ENVIRONMENTS } from '../utils/oxyServiceEnvironment.js';
|
|
11
12
|
/**
|
|
12
13
|
* Expected JWT audience for tokens issued by the Oxy auth service.
|
|
13
14
|
*/
|
|
@@ -39,6 +40,10 @@ class ServiceTokenClaimError extends Error {
|
|
|
39
40
|
this.name = 'ServiceTokenClaimError';
|
|
40
41
|
}
|
|
41
42
|
}
|
|
43
|
+
function isOxyServiceEnvironment(value) {
|
|
44
|
+
return (typeof value === 'string' &&
|
|
45
|
+
OXY_SERVICE_ENVIRONMENTS.includes(value));
|
|
46
|
+
}
|
|
42
47
|
export function OxyServicesUtilityMixin(Base) {
|
|
43
48
|
return class extends Base {
|
|
44
49
|
// TypeScript's mixin pattern requires `(...args: any[])` here — the
|
|
@@ -335,7 +340,11 @@ export function OxyServicesUtilityMixin(Base) {
|
|
|
335
340
|
// Validate required service token fields
|
|
336
341
|
const appId = decoded.appId;
|
|
337
342
|
const credentialId = decoded.credentialId;
|
|
338
|
-
|
|
343
|
+
const environment = decoded.environment;
|
|
344
|
+
if (!appId ||
|
|
345
|
+
typeof credentialId !== 'string' ||
|
|
346
|
+
credentialId.length === 0 ||
|
|
347
|
+
!isOxyServiceEnvironment(environment)) {
|
|
339
348
|
if (optional) {
|
|
340
349
|
req.userId = null;
|
|
341
350
|
req.user = null;
|
|
@@ -388,6 +397,7 @@ export function OxyServicesUtilityMixin(Base) {
|
|
|
388
397
|
appName: decoded.appName || 'unknown',
|
|
389
398
|
credentialId,
|
|
390
399
|
scopes: Array.isArray(decoded.scopes) ? decoded.scopes : [],
|
|
400
|
+
environment,
|
|
391
401
|
};
|
|
392
402
|
if (debug) {
|
|
393
403
|
logger.debug(`[oxy.auth] Service token OK app=${decoded.appName} delegateUser=${oxyUserId || '(none)'}`, {
|
package/dist/esm/mixins/index.js
CHANGED
|
@@ -29,6 +29,7 @@ import { OxyServicesCivicMixin } from './OxyServices.civic.js';
|
|
|
29
29
|
import { OxyServicesNodesMixin } from './OxyServices.nodes.js';
|
|
30
30
|
import { OxyServicesLinksMixin } from './OxyServices.links.js';
|
|
31
31
|
import { OxyServicesDeviceBootMixin } from './OxyServices.deviceBoot.js';
|
|
32
|
+
import { OxyServicesDeviceTransferMixin } from './OxyServices.deviceTransfer.js';
|
|
32
33
|
/**
|
|
33
34
|
* Mixin pipeline - applied in order from first to last.
|
|
34
35
|
*
|
|
@@ -82,6 +83,9 @@ const MIXIN_PIPELINE = [
|
|
|
82
83
|
// Device-first token mint: the client half of the zero-cookie transport
|
|
83
84
|
// (`mintFromDeviceSecret` → `POST /session/device/token`).
|
|
84
85
|
OxyServicesDeviceBootMixin,
|
|
86
|
+
// Device-to-device identity transfer ("add a device"): E2E-encrypted key
|
|
87
|
+
// clone over a short-lived relay (b3 Feature 2).
|
|
88
|
+
OxyServicesDeviceTransferMixin,
|
|
85
89
|
// Utility (last, can use all above)
|
|
86
90
|
OxyServicesUtilityMixin,
|
|
87
91
|
];
|
package/dist/esm/server/auth.js
CHANGED
package/dist/esm/server/index.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* app.use(createOxyRateLimit(oxy, { store: redisStore }));
|
|
15
15
|
* ```
|
|
16
16
|
*/
|
|
17
|
-
export { createOptionalOxyAuth, createOxyAuthMiddleware, getOxyUserId, getRequiredOxyUserId, isOxyAuthenticated, requireOxyAuth, } from './auth.js';
|
|
17
|
+
export { createOptionalOxyAuth, createOxyAuthMiddleware, getOxyUserId, getRequiredOxyUserId, isOxyAuthenticated, requireOxyAuth, OXY_SERVICE_ENVIRONMENTS, } from './auth.js';
|
|
18
18
|
export { createOxyRateLimit } from './rateLimit.js';
|
|
19
19
|
// SSRF-safe upstream fetch + URL validation (Node-only).
|
|
20
20
|
export { assertSafePublicUrl, isBlockedIp, safeFetch, SsrfRejection, UpstreamError, ALLOWED_PORTS, ALLOWED_PROTOCOLS, BLOCKED_HOSTNAMES, DEFAULT_USER_AGENT, MAX_REDIRECTS, MAX_URL_LENGTH, UPSTREAM_HEADERS_TIMEOUT_MS, } from './safeFetch.js';
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Environment segregation for Oxy service-token JWTs (test/live isolation).
|
|
3
|
+
* Mirrors `ApplicationCredentialEnvironment` on the API's `ApplicationCredential`
|
|
4
|
+
* model (`packages/api/src/models/ApplicationCredential.ts`) as an INDEPENDENT
|
|
5
|
+
* literal union — `@oxyhq/core` has zero dependency on `@oxyhq/api`, so this is
|
|
6
|
+
* kept in sync by hand, not by import.
|
|
7
|
+
*
|
|
8
|
+
* Defined here (not in `server/auth.ts` or `mixins/OxyServices.utility.ts`
|
|
9
|
+
* directly) because BOTH of those files need it and neither may import from
|
|
10
|
+
* the other: `server/` types import `express` (Node-only, a peer dependency
|
|
11
|
+
* `mixins/` deliberately avoids so it stays safe to bundle into RN/browser
|
|
12
|
+
* consumers — see the "Local request/response/socket typing" comment in
|
|
13
|
+
* `OxyServices.utility.ts`). This file has zero imports, so both sides can
|
|
14
|
+
* depend on it without crossing that boundary.
|
|
15
|
+
*/
|
|
16
|
+
export const OXY_SERVICE_ENVIRONMENTS = ['development', 'staging', 'production'];
|