@oxy.so/core 1.5.1 → 1.6.1

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 (130) 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/traffic.js +29 -2
  36. package/dist/cjs/server/workloadIdentity.js +170 -0
  37. package/dist/cjs/session/refresh.js +12 -0
  38. package/dist/esm/.tsbuildinfo +1 -1
  39. package/dist/esm/HttpService.js +72 -9
  40. package/dist/esm/crypto/identityMove.js +51 -33
  41. package/dist/esm/crypto/identityProof.js +24 -0
  42. package/dist/esm/crypto/webIdentityCarrier.js +137 -77
  43. package/dist/esm/i18n/locales/ar-SA.json +1 -0
  44. package/dist/esm/i18n/locales/ca-ES.json +1 -0
  45. package/dist/esm/i18n/locales/de-DE.json +1 -0
  46. package/dist/esm/i18n/locales/en-US.json +1 -0
  47. package/dist/esm/i18n/locales/es-ES.json +1 -0
  48. package/dist/esm/i18n/locales/fr-FR.json +1 -0
  49. package/dist/esm/i18n/locales/it-IT.json +1 -0
  50. package/dist/esm/i18n/locales/ja-JP.json +1 -0
  51. package/dist/esm/i18n/locales/ko-KR.json +1 -0
  52. package/dist/esm/i18n/locales/locales/ar-SA.json +1 -0
  53. package/dist/esm/i18n/locales/locales/ca-ES.json +1 -0
  54. package/dist/esm/i18n/locales/locales/de-DE.json +1 -0
  55. package/dist/esm/i18n/locales/locales/en-US.json +1 -0
  56. package/dist/esm/i18n/locales/locales/es-ES.json +1 -0
  57. package/dist/esm/i18n/locales/locales/fr-FR.json +1 -0
  58. package/dist/esm/i18n/locales/locales/it-IT.json +1 -0
  59. package/dist/esm/i18n/locales/locales/ja-JP.json +1 -0
  60. package/dist/esm/i18n/locales/locales/ko-KR.json +1 -0
  61. package/dist/esm/i18n/locales/locales/pt-PT.json +1 -0
  62. package/dist/esm/i18n/locales/locales/zh-CN.json +1 -0
  63. package/dist/esm/i18n/locales/pt-PT.json +1 -0
  64. package/dist/esm/i18n/locales/zh-CN.json +1 -0
  65. package/dist/esm/index.js +5 -4
  66. package/dist/esm/inference/OxyInferenceClient.js +63 -6
  67. package/dist/esm/mixins/OxyServices.auth.js +96 -0
  68. package/dist/esm/mixins/OxyServices.identity.js +11 -44
  69. package/dist/esm/mixins/index.js +0 -2
  70. package/dist/esm/server/index.js +7 -0
  71. package/dist/esm/server/securityHeaders.js +9 -2
  72. package/dist/esm/server/traffic.js +29 -2
  73. package/dist/esm/server/workloadIdentity.js +166 -0
  74. package/dist/esm/session/refresh.js +12 -0
  75. package/dist/types/.tsbuildinfo +1 -1
  76. package/dist/types/HttpService.d.ts +24 -0
  77. package/dist/types/crypto/identityMove.d.ts +48 -32
  78. package/dist/types/crypto/identityProof.d.ts +16 -0
  79. package/dist/types/crypto/webIdentityCarrier.d.ts +74 -47
  80. package/dist/types/index.d.ts +7 -6
  81. package/dist/types/inference/OxyInferenceClient.d.ts +21 -1
  82. package/dist/types/mixins/OxyServices.auth.d.ts +32 -0
  83. package/dist/types/mixins/OxyServices.identity.d.ts +16 -30
  84. package/dist/types/mixins/index.d.ts +1 -2
  85. package/dist/types/server/index.d.ts +8 -0
  86. package/dist/types/server/securityHeaders.d.ts +8 -0
  87. package/dist/types/server/workloadIdentity.d.ts +32 -0
  88. package/package.json +2 -2
  89. package/src/HttpService.ts +74 -9
  90. package/src/__tests__/httpServiceRateLimitedRefresh.test.ts +160 -0
  91. package/src/__tests__/serviceTokenWorkloadFallback.test.ts +85 -0
  92. package/src/crypto/__tests__/identityMove.test.ts +93 -51
  93. package/src/crypto/__tests__/identityProof.test.ts +81 -0
  94. package/src/crypto/__tests__/webIdentityCarrier.test.ts +21 -63
  95. package/src/crypto/__tests__/webIdentityRecoveryMaterial.test.ts +117 -0
  96. package/src/crypto/identityMove.ts +77 -43
  97. package/src/crypto/identityProof.ts +36 -0
  98. package/src/crypto/webIdentityCarrier.ts +185 -117
  99. package/src/i18n/locales/ar-SA.json +1 -0
  100. package/src/i18n/locales/ca-ES.json +1 -0
  101. package/src/i18n/locales/de-DE.json +1 -0
  102. package/src/i18n/locales/en-US.json +1 -0
  103. package/src/i18n/locales/es-ES.json +1 -0
  104. package/src/i18n/locales/fr-FR.json +1 -0
  105. package/src/i18n/locales/it-IT.json +1 -0
  106. package/src/i18n/locales/ja-JP.json +1 -0
  107. package/src/i18n/locales/ko-KR.json +1 -0
  108. package/src/i18n/locales/pt-PT.json +1 -0
  109. package/src/i18n/locales/zh-CN.json +1 -0
  110. package/src/index.ts +26 -15
  111. package/src/inference/OxyInferenceClient.ts +84 -7
  112. package/src/inference/__tests__/OxyInferenceClient.test.ts +48 -0
  113. package/src/mixins/OxyServices.auth.ts +100 -1
  114. package/src/mixins/OxyServices.identity.ts +17 -67
  115. package/src/mixins/__tests__/OxyServices.identity.test.ts +1 -76
  116. package/src/mixins/index.ts +0 -3
  117. package/src/server/__tests__/securityHeaders.test.ts +15 -0
  118. package/src/server/__tests__/traffic.test.ts +132 -1
  119. package/src/server/__tests__/workloadIdentity.test.ts +128 -0
  120. package/src/server/index.ts +9 -0
  121. package/src/server/securityHeaders.ts +19 -6
  122. package/src/server/traffic.ts +31 -1
  123. package/src/server/workloadIdentity.ts +207 -0
  124. package/src/session/__tests__/refresh.test.ts +53 -3
  125. package/src/session/refresh.ts +12 -0
  126. package/dist/cjs/mixins/OxyServices.deviceTransfer.js +0 -314
  127. package/dist/esm/mixins/OxyServices.deviceTransfer.js +0 -311
  128. package/dist/types/mixins/OxyServices.deviceTransfer.d.ts +0 -149
  129. package/src/mixins/OxyServices.deviceTransfer.ts +0 -393
  130. package/src/mixins/__tests__/OxyServices.deviceTransfer.test.ts +0 -271
@@ -1,149 +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 type { OxyServicesBase } from '../OxyServices.base';
30
- import type { DeviceTransferInfoResponse, DeviceTransferApproveResponse, DeviceTransferDenyResponse } from '@oxy.so/contracts';
31
- /** Result of {@link OxyServicesDeviceTransferMixin.initDeviceTransfer}. */
32
- export interface InitDeviceTransferResult {
33
- /** 128-bit single-use handle to render in the QR (also the HKDF salt). */
34
- pairingId: string;
35
- /** ISO-8601 expiry (3 minutes). */
36
- expiresAt: string;
37
- /** The new device's ephemeral public key registered with the relay. */
38
- newEphemeralPublicKey: string;
39
- }
40
- /** Terminal outcome delivered to {@link subscribeDeviceTransfer}'s callback. */
41
- export type DeviceTransferOutcome = {
42
- status: 'approved';
43
- publicKey: string;
44
- } | {
45
- status: 'denied';
46
- } | {
47
- status: 'expired';
48
- };
49
- export declare function OxyServicesDeviceTransferMixin<T extends typeof OxyServicesBase>(Base: T): {
50
- new (...args: any[]): {
51
- /**
52
- * NEW device — begin an "add a device" transfer. Generates a single-use
53
- * ephemeral secp256k1 pair, registers the pairing, and returns the
54
- * `pairingId` to render as a QR. The ephemeral private key is held in memory
55
- * (keyed by `pairingId`) for the subsequent {@link subscribeDeviceTransfer}.
56
- *
57
- * @param label - Optional human-readable label for this new device.
58
- */
59
- initDeviceTransfer(label?: string): Promise<InitDeviceTransferResult>;
60
- /**
61
- * Resolve a pairing server-side (the QR carries only `pairingId`). The OLD
62
- * device calls this after scanning to read the new device's ephemeral public
63
- * key + label; the NEW device polls it to fetch the sealed material once
64
- * approved. Public — no auth required.
65
- */
66
- getDeviceTransferInfo(pairingId: string): Promise<DeviceTransferInfoResponse>;
67
- /**
68
- * OLD device — approve a scanned transfer. Reads the new device's ephemeral
69
- * public key, derives the shared transfer key, AEAD-seals
70
- * `{ privateKey, publicKey }`, and posts it PLUS a fresh signature over
71
- * `{ action:'approve_device_transfer', pairingId, timestamp }` made with the
72
- * CURRENT identity key (dual-proof alongside the bearer token).
73
- *
74
- * NATIVE-ONLY: requires a stored identity (throws otherwise). The UI must
75
- * biometric-gate before calling this — a key clone leaves the device.
76
- */
77
- approveDeviceTransfer(pairingId: string): Promise<DeviceTransferApproveResponse>;
78
- /**
79
- * OLD device — deny (cancel) a scanned transfer so the waiting new device
80
- * stops. Public — no auth required.
81
- */
82
- denyDeviceTransfer(pairingId: string): Promise<DeviceTransferDenyResponse>;
83
- /**
84
- * NEW device — await approval for a pairing started with
85
- * {@link initDeviceTransfer}, then decrypt and import the transferred
86
- * identity key. Primary path is an instant `device_pair_update` push over the
87
- * `/device-pair` socket; a poll backstops a socket that can't connect.
88
- *
89
- * On `approved`: re-derives the shared transfer key from the old device's
90
- * ephemeral public key, decrypts `{ privateKey, publicKey }`, imports it via
91
- * `KeyManager.importKeyPair(privateKey, { overwrite: false })`, and invokes
92
- * `onOutcome({ status:'approved', publicKey })`. The caller then runs the
93
- * NORMAL challenge/verify sign-in — this method does not mint a session.
94
- *
95
- * @returns An unsubscribe function; call it to stop waiting (also called
96
- * automatically once the transfer settles).
97
- */
98
- subscribeDeviceTransfer(pairingId: string, onOutcome: (outcome: DeviceTransferOutcome) => void): () => void;
99
- httpService: import("../HttpService").HttpService;
100
- cloudURL: string;
101
- config: import("../OxyServices.base").OxyConfig;
102
- __resetTokensForTests(): void;
103
- makeRequest<T_1>(method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE", url: string, data?: any, options?: import("../HttpService").RequestOptions): Promise<T_1>;
104
- getBaseURL(): string;
105
- getClient(): import("../HttpService").HttpService;
106
- createLinkedClient(config: import("../OxyServices.base").OxyConfig): import("..").LinkedHttpClient;
107
- getMetrics(): {
108
- totalRequests: number;
109
- successfulRequests: number;
110
- failedRequests: number;
111
- cacheHits: number;
112
- cacheMisses: number;
113
- averageResponseTime: number;
114
- };
115
- clearCache(): void;
116
- clearCacheEntry(key: string): void;
117
- clearCacheByPrefix(prefix: string): number;
118
- getCacheStats(): {
119
- size: number;
120
- hits: number;
121
- misses: number;
122
- hitRate: number;
123
- };
124
- getCloudURL(): string;
125
- setTokens(accessToken: string): void;
126
- clearTokens(): void;
127
- onTokensChanged(listener: (accessToken: string | null) => void): () => void;
128
- _cachedUserId: string | null | undefined;
129
- _cachedAccessToken: string | null;
130
- getCurrentUserId(): string | null;
131
- hasValidToken(): boolean;
132
- getAccessToken(): string | null;
133
- getAccessTokenExpiry(): number | null;
134
- waitForAuth(timeoutMs?: number): Promise<boolean>;
135
- withAuthRetry<T_1>(operation: () => Promise<T_1>, operationName: string, options?: {
136
- maxRetries?: number;
137
- retryDelay?: number;
138
- authTimeoutMs?: number;
139
- }): Promise<T_1>;
140
- validate(): Promise<boolean>;
141
- handleError(error: unknown): Error;
142
- healthCheck(): Promise<{
143
- status: string;
144
- users?: number;
145
- timestamp?: string;
146
- [key: string]: any;
147
- }>;
148
- };
149
- } & T;
@@ -1,393 +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
-
30
- import { bytesToHex, hexToBytes, utf8ToBytes, bytesToUtf8 } from '@noble/hashes/utils';
31
- import { generateSecp256k1KeyPair } from '@oxy.so/protocol/secp256k1';
32
- import type { OxyServicesBase } from '../OxyServices.base';
33
- import type {
34
- DeviceTransferInfoResponse,
35
- DeviceTransferInitResponse,
36
- DeviceTransferApproveResponse,
37
- DeviceTransferDenyResponse,
38
- } from '@oxy.so/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
- /**
49
- * Ephemeral private keys for pairings an instance INITIATED, keyed by pairingId,
50
- * held per OxyServices instance. In-memory ONLY (never persisted — single-use),
51
- * cleared once the transfer settles. A module-level WeakMap (rather than a class
52
- * field) keeps it off the mixin's emitted `.d.ts` (avoids TS4094 on the exported
53
- * anonymous class) and lets the GC drop it with the instance.
54
- */
55
- const ephemeralKeyStore = new WeakMap<object, Map<string, string>>();
56
-
57
- function getEphemeralKeys(instance: object): Map<string, string> {
58
- let keys = ephemeralKeyStore.get(instance);
59
- if (!keys) {
60
- keys = new Map<string, string>();
61
- ephemeralKeyStore.set(instance, keys);
62
- }
63
- return keys;
64
- }
65
-
66
- /** HKDF `info` binding — MUST match the server/other-device byte-for-byte. */
67
- const DEVICE_TRANSFER_HKDF_INFO = 'oxy-device-transfer-v1';
68
- /** Socket.IO namespace the API pushes device-pair approval events on. */
69
- const DEVICE_PAIR_NAMESPACE = '/device-pair';
70
- /** Fallback poll cadence — the socket delivers approval instantly; this covers
71
- * the case where the socket can't connect. */
72
- const DEVICE_TRANSFER_POLL_INTERVAL_MS = 2500;
73
- /** Action string signed on approve (mirrors `link_identity`'s scheme). */
74
- const DEVICE_TRANSFER_APPROVE_ACTION = 'approve_device_transfer';
75
-
76
- /** Result of {@link OxyServicesDeviceTransferMixin.initDeviceTransfer}. */
77
- export interface InitDeviceTransferResult {
78
- /** 128-bit single-use handle to render in the QR (also the HKDF salt). */
79
- pairingId: string;
80
- /** ISO-8601 expiry (3 minutes). */
81
- expiresAt: string;
82
- /** The new device's ephemeral public key registered with the relay. */
83
- newEphemeralPublicKey: string;
84
- }
85
-
86
- /** Terminal outcome delivered to {@link subscribeDeviceTransfer}'s callback. */
87
- export type DeviceTransferOutcome =
88
- | { status: 'approved'; publicKey: string }
89
- | { status: 'denied' }
90
- | { status: 'expired' };
91
-
92
- /**
93
- * Derive the per-pairing symmetric transfer key from an ECDH shared secret.
94
- * Identical on both devices: `HKDF(ECDH, salt=pairingId, info=v1)`.
95
- */
96
- function deriveTransferKey(sharedSecret: Uint8Array, pairingId: string): Uint8Array {
97
- return hkdfSha256(
98
- sharedSecret,
99
- utf8ToBytes(pairingId),
100
- utf8ToBytes(DEVICE_TRANSFER_HKDF_INFO),
101
- 32,
102
- );
103
- }
104
-
105
- export function OxyServicesDeviceTransferMixin<T extends typeof OxyServicesBase>(Base: T) {
106
- return class extends Base {
107
- constructor(...args: any[]) {
108
- super(...(args as [any]));
109
- }
110
-
111
- /**
112
- * NEW device — begin an "add a device" transfer. Generates a single-use
113
- * ephemeral secp256k1 pair, registers the pairing, and returns the
114
- * `pairingId` to render as a QR. The ephemeral private key is held in memory
115
- * (keyed by `pairingId`) for the subsequent {@link subscribeDeviceTransfer}.
116
- *
117
- * @param label - Optional human-readable label for this new device.
118
- */
119
- async initDeviceTransfer(label?: string): Promise<InitDeviceTransferResult> {
120
- try {
121
- const { privateKey: ephPrivateKey, publicKey: ephPublicKey } =
122
- generateSecp256k1KeyPair();
123
-
124
- const res = await this.makeRequest<DeviceTransferInitResponse>(
125
- 'POST',
126
- '/identity/device-transfer/init',
127
- { newEphPub: ephPublicKey, ...(label ? { newDeviceLabel: label } : {}) },
128
- { cache: false },
129
- );
130
-
131
- getEphemeralKeys(this).set(res.pairingId, ephPrivateKey);
132
- return {
133
- pairingId: res.pairingId,
134
- expiresAt: res.expiresAt,
135
- newEphemeralPublicKey: ephPublicKey,
136
- };
137
- } catch (error) {
138
- throw this.handleError(error);
139
- }
140
- }
141
-
142
- /**
143
- * Resolve a pairing server-side (the QR carries only `pairingId`). The OLD
144
- * device calls this after scanning to read the new device's ephemeral public
145
- * key + label; the NEW device polls it to fetch the sealed material once
146
- * approved. Public — no auth required.
147
- */
148
- async getDeviceTransferInfo(pairingId: string): Promise<DeviceTransferInfoResponse> {
149
- try {
150
- return await this.makeRequest<DeviceTransferInfoResponse>(
151
- 'GET',
152
- `/identity/device-transfer/${encodeURIComponent(pairingId)}`,
153
- undefined,
154
- { cache: false, retry: false },
155
- );
156
- } catch (error) {
157
- throw this.handleError(error);
158
- }
159
- }
160
-
161
- /**
162
- * OLD device — approve a scanned transfer. Reads the new device's ephemeral
163
- * public key, derives the shared transfer key, AEAD-seals
164
- * `{ privateKey, publicKey }`, and posts it PLUS a fresh signature over
165
- * `{ action:'approve_device_transfer', pairingId, timestamp }` made with the
166
- * CURRENT identity key (dual-proof alongside the bearer token).
167
- *
168
- * NATIVE-ONLY: requires a stored identity (throws otherwise). The UI must
169
- * biometric-gate before calling this — a key clone leaves the device.
170
- */
171
- async approveDeviceTransfer(pairingId: string): Promise<DeviceTransferApproveResponse> {
172
- try {
173
- const info = await this.getDeviceTransferInfo(pairingId);
174
- if (info.status !== 'pending') {
175
- throw new Error(`This transfer can no longer be approved (status: ${info.status}).`);
176
- }
177
-
178
- const privateKey = await KeyManager.getPrivateKey();
179
- const publicKey = await KeyManager.getPublicKey();
180
- if (!privateKey || !publicKey) {
181
- throw new Error('No identity found on this device. Create or import an identity first.');
182
- }
183
-
184
- // Ephemeral ECDH → per-pairing transfer key.
185
- const { privateKey: oldEphPrivateKey, publicKey: oldEphPublicKey } =
186
- generateSecp256k1KeyPair();
187
-
188
- const sharedSecret = deriveSharedSecret(oldEphPrivateKey, info.newDeviceEphemeralPublicKey);
189
- const transferKey = deriveTransferKey(sharedSecret, pairingId);
190
-
191
- // Seal the identity key material.
192
- const plaintext = utf8ToBytes(JSON.stringify({ privateKey, publicKey }));
193
- const { nonce, ciphertext } = encryptAead(transferKey, plaintext);
194
-
195
- // Dual-proof: prove control of the CURRENT identity key (a bearer alone
196
- // must not be able to exfiltrate the private key).
197
- const timestamp = Date.now();
198
- const message = JSON.stringify({
199
- action: DEVICE_TRANSFER_APPROVE_ACTION,
200
- pairingId,
201
- timestamp,
202
- });
203
- const signature = await SignatureService.sign(message);
204
-
205
- return await this.makeRequest<DeviceTransferApproveResponse>(
206
- 'POST',
207
- `/identity/device-transfer/${encodeURIComponent(pairingId)}/approve`,
208
- {
209
- oldEphPub: oldEphPublicKey,
210
- ciphertext: bytesToHex(ciphertext),
211
- nonce: bytesToHex(nonce),
212
- signature,
213
- timestamp,
214
- },
215
- { cache: false },
216
- );
217
- } catch (error) {
218
- throw this.handleError(error);
219
- }
220
- }
221
-
222
- /**
223
- * OLD device — deny (cancel) a scanned transfer so the waiting new device
224
- * stops. Public — no auth required.
225
- */
226
- async denyDeviceTransfer(pairingId: string): Promise<DeviceTransferDenyResponse> {
227
- try {
228
- return await this.makeRequest<DeviceTransferDenyResponse>(
229
- 'POST',
230
- `/identity/device-transfer/${encodeURIComponent(pairingId)}/deny`,
231
- undefined,
232
- { cache: false },
233
- );
234
- } catch (error) {
235
- throw this.handleError(error);
236
- }
237
- }
238
-
239
- /**
240
- * NEW device — await approval for a pairing started with
241
- * {@link initDeviceTransfer}, then decrypt and import the transferred
242
- * identity key. Primary path is an instant `device_pair_update` push over the
243
- * `/device-pair` socket; a poll backstops a socket that can't connect.
244
- *
245
- * On `approved`: re-derives the shared transfer key from the old device's
246
- * ephemeral public key, decrypts `{ privateKey, publicKey }`, imports it via
247
- * `KeyManager.importKeyPair(privateKey, { overwrite: false })`, and invokes
248
- * `onOutcome({ status:'approved', publicKey })`. The caller then runs the
249
- * NORMAL challenge/verify sign-in — this method does not mint a session.
250
- *
251
- * @returns An unsubscribe function; call it to stop waiting (also called
252
- * automatically once the transfer settles).
253
- */
254
- subscribeDeviceTransfer(
255
- pairingId: string,
256
- onOutcome: (outcome: DeviceTransferOutcome) => void,
257
- ): () => void {
258
- const ephemeralKeys = getEphemeralKeys(this);
259
- const ephPrivateKey = ephemeralKeys.get(pairingId);
260
- if (!ephPrivateKey) {
261
- throw new Error(
262
- 'No pending device transfer for this pairing id. Call initDeviceTransfer first.',
263
- );
264
- }
265
-
266
- let settled = false;
267
- let inFlight = false;
268
- let socket: MinimalSocket | null = null;
269
- let pollTimer: ReturnType<typeof setInterval> | null = null;
270
-
271
- const cleanup = (): void => {
272
- if (pollTimer !== null) {
273
- clearInterval(pollTimer);
274
- pollTimer = null;
275
- }
276
- if (socket) {
277
- try {
278
- socket.off('device_pair_update');
279
- socket.off('connect');
280
- socket.disconnect();
281
- } catch (error) {
282
- logger.debug('[DeviceTransfer] socket close failed', { component: 'DeviceTransfer' }, error);
283
- }
284
- socket = null;
285
- }
286
- ephemeralKeys.delete(pairingId);
287
- };
288
-
289
- const finish = (outcome: DeviceTransferOutcome): void => {
290
- if (settled) return;
291
- settled = true;
292
- cleanup();
293
- onOutcome(outcome);
294
- };
295
-
296
- // Re-check authoritative status; on `approved`, decrypt + import.
297
- const check = async (): Promise<void> => {
298
- if (settled || inFlight) return;
299
- inFlight = true;
300
- try {
301
- const info = await this.getDeviceTransferInfo(pairingId);
302
- if (settled) return;
303
-
304
- if (info.status === 'denied') {
305
- finish({ status: 'denied' });
306
- return;
307
- }
308
- if (info.status === 'expired') {
309
- finish({ status: 'expired' });
310
- return;
311
- }
312
- if (
313
- info.status === 'approved' &&
314
- info.oldDeviceEphemeralPublicKey &&
315
- info.ciphertext &&
316
- info.nonce
317
- ) {
318
- const sharedSecret = deriveSharedSecret(
319
- ephPrivateKey,
320
- info.oldDeviceEphemeralPublicKey,
321
- );
322
- const transferKey = deriveTransferKey(sharedSecret, pairingId);
323
- const plaintext = decryptAead(
324
- transferKey,
325
- hexToBytes(info.nonce),
326
- hexToBytes(info.ciphertext),
327
- );
328
- const parsed = JSON.parse(bytesToUtf8(plaintext)) as {
329
- privateKey: string;
330
- publicKey: string;
331
- };
332
- // Import WITHOUT overwrite: a fresh device has no identity, and we
333
- // must never silently clobber an existing one.
334
- const importedPublicKey = await KeyManager.importKeyPair(parsed.privateKey, {
335
- overwrite: false,
336
- });
337
- finish({ status: 'approved', publicKey: importedPublicKey });
338
- }
339
- } catch (error) {
340
- // Transient (a poll tick that raced the approve write, a decrypt on a
341
- // half-written row) — keep waiting; the next tick/push retries.
342
- logger.debug('[DeviceTransfer] status check failed', { component: 'DeviceTransfer' }, error);
343
- } finally {
344
- inFlight = false;
345
- }
346
- };
347
-
348
- // Primary: instant socket wake. Fall back to polling if unavailable.
349
- void (async () => {
350
- const io = await getSocketIO();
351
- if (settled || !io) return;
352
- try {
353
- const s = io(`${this.getBaseURL()}${DEVICE_PAIR_NAMESPACE}`, {
354
- transports: ['websocket'],
355
- autoConnect: true,
356
- reconnection: true,
357
- reconnectionAttempts: Number.POSITIVE_INFINITY,
358
- reconnectionDelay: 1000,
359
- reconnectionDelayMax: 10000,
360
- });
361
- const join = (): void => {
362
- try {
363
- s.emit('join', pairingId);
364
- } catch (error) {
365
- logger.debug('[DeviceTransfer] join failed', { component: 'DeviceTransfer' }, error);
366
- }
367
- };
368
- s.on('connect', join);
369
- if (s.connected) join();
370
- s.on('device_pair_update', () => {
371
- void check();
372
- });
373
- socket = s;
374
- } catch (error) {
375
- logger.debug('[DeviceTransfer] socket create failed (poll fallback)', { component: 'DeviceTransfer' }, error);
376
- }
377
- })();
378
-
379
- // Fallback poll (also covers the already-approved-before-subscribe case via
380
- // the immediate first tick below). unref so it never holds a Node event
381
- // loop / test runner open.
382
- pollTimer = setInterval(() => {
383
- void check();
384
- }, DEVICE_TRANSFER_POLL_INTERVAL_MS);
385
- (pollTimer as { unref?: () => void }).unref?.();
386
-
387
- // Immediate first check — the transfer may already be approved/denied.
388
- void check();
389
-
390
- return cleanup;
391
- }
392
- };
393
- }