From 42c675633641e2c4ccc56d3f22f166d8d711cab6 Mon Sep 17 00:00:00 2001 From: Sergey Aldoukhov Date: Sat, 13 Jun 2026 12:48:28 -0700 Subject: [PATCH 1/3] use lokker for KA socket --- keeperapi/src/auth.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/keeperapi/src/auth.ts b/keeperapi/src/auth.ts index 9ae5b7e..9c5a2a6 100644 --- a/keeperapi/src/auth.ts +++ b/keeperapi/src/auth.ts @@ -257,6 +257,17 @@ export class Auth { this.socket = await createAsyncSocket(url, this.messageSessionUid, getConnectionRequest) logger.debug('Socket connected') + // Log every incoming push event with the same logger/format as REST calls. + // Decryption only runs when debug logging is enabled. + this.onPushMessage(async (data: Uint8Array) => { + if (!isLevelEnabled('debug')) return + try { + const wssClientResponse = await this.endpoint.decryptPushMessage(data) + logger.debug(...formatProto('Push message received', wssClientResponse)) + } catch (e) { + logger.debug('Push message received (undecryptable)', e) + } + }) this.onCloseMessage((closeReason: CloseReason) => { if (this.options.onCommandFailure) { this.options.onCommandFailure({ From 3248072ee3f2a088dda1ff6e8f45d63a15dc3101 Mon Sep 17 00:00:00 2001 From: Sergey Aldoukhov Date: Sat, 13 Jun 2026 13:13:44 -0700 Subject: [PATCH 2/3] router socket --- keeperapi/package.json | 2 +- keeperapi/src/auth.ts | 63 +++++++++++++++++++++++++++++++ keeperapi/src/browser/platform.ts | 45 +++++++++++++--------- keeperapi/src/configuration.ts | 4 ++ keeperapi/src/endpoint.ts | 19 ++++++++++ keeperapi/src/node/platform.ts | 3 +- keeperapi/src/platform.ts | 2 +- keeperapi/src/socket.ts | 16 +++++--- 8 files changed, 129 insertions(+), 25 deletions(-) diff --git a/keeperapi/package.json b/keeperapi/package.json index 19834de..e52f1eb 100644 --- a/keeperapi/package.json +++ b/keeperapi/package.json @@ -1,7 +1,7 @@ { "name": "@keeper-security/keeperapi", "description": "Keeper API Javascript SDK", - "version": "17.2.7", + "version": "17.3.0", "browser": "dist/index.es.js", "main": "dist/index.cjs.js", "types": "dist/node/index.d.ts", diff --git a/keeperapi/src/auth.ts b/keeperapi/src/auth.ts index 9c5a2a6..34ab218 100644 --- a/keeperapi/src/auth.ts +++ b/keeperapi/src/auth.ts @@ -141,6 +141,8 @@ export class Auth { private messageSessionUid: Uint8Array options: ClientConfigurationInternal private socket?: SocketListener + // Optional second socket to the KRouter user endpoint (see connectToRouter). + private routerSocket?: SocketListener public clientKey?: Uint8Array private _accountSummary?: IAccountSummaryElements private _accountSummaryVersion: number = 1 @@ -285,6 +287,64 @@ export class Auth { } } + /** + * Opens a WebSocket to the KRouter user endpoint + * (`wss://connect./api/user/client`). This is separate from the + * KeeperApp push socket opened by `connect()`. Requires a session token, so + * call it after login. When `ClientConfiguration.connectToRouter` is set this + * runs automatically once the session token is available. + * + * Inbound frames are JSON text (`gw_response`, `client_error`, notifications). + * Subscribe with `onRouterMessage`; incoming events are also debug-logged. + */ + async connectToRouter() { + if (!this._sessionToken) { + throw new Error('Cannot connect to router socket without a session token') + } + if (this.routerSocket?.getIsConnected()) { + return + } + const url = await this.endpoint.getRouterConnectionUrl(this._sessionToken) + // No app-level heartbeat: the router treats every binary frame as a + // RouterControllerMessage and relies on protocol-level ping/pong instead. + const routerSocket = await createAsyncSocket(url, undefined, undefined, false) + if (!routerSocket) { + throw new Error('Failed to open router socket') + } + this.routerSocket = routerSocket + logger.debug('Router socket connected') + routerSocket.onPushMessage((data: Uint8Array) => { + if (!isLevelEnabled('debug')) return + const text = platform.bytesToString(data) + try { + logger.debug('Router message received', JSON.parse(text)) + } catch { + logger.debug('Router message received', text) + } + }) + } + + disconnectRouter() { + if (this.routerSocket) { + this.routerSocket.disconnect() + delete this.routerSocket + } + } + + onRouterMessage(callback: (data: Uint8Array) => void): void { + if (!this.routerSocket) { + throw new Error('No router socket available') + } + this.routerSocket.onPushMessage(callback) + } + + onRouterCloseMessage(callback: (data: any) => void): void { + if (!this.routerSocket) { + throw new Error('No router socket available') + } + this.routerSocket.onCloseMessage(callback) + } + /** * @param {LoginPayload} payload - Options for login. * @param {boolean} [payload.disableLinkingForAccountWithYubikey2fa] - @@ -1265,6 +1325,9 @@ export class Auth { this.socket.onOpen(() => { this.socket?.registerLogin(this._sessionToken) }) + if (this.options.connectToRouter) { + this.connectToRouter().catch((e) => logger.debug('Router socket connect failed', e)) + } } async registerDevice() { diff --git a/keeperapi/src/browser/platform.ts b/keeperapi/src/browser/platform.ts index 16c26a7..c3a00df 100644 --- a/keeperapi/src/browser/platform.ts +++ b/keeperapi/src/browser/platform.ts @@ -27,7 +27,6 @@ const rsaAlgorithmName: string = 'RSASSA-PKCS1-v1_5' const CBC_IV_LENGTH = 16 const GCM_IV_LENGTH = 12 const ECC_PUB_KEY_LENGTH = 65 -let socket: WebSocket | null = null let workerPool: CryptoWorkerPool | null = null const base64ToBytes = (data: string): Uint8Array => { @@ -1090,33 +1089,51 @@ export const browserPlatform: Platform = class { } } - static createWebsocket(url: string): SocketProxy { - socket = new WebSocket(url) + static createWebsocket(url: string, sendHeartbeat: boolean = true): SocketProxy { + // Use a per-socket local reference. A module-level variable would be + // overwritten when a second socket is opened (e.g. push + router), + // routing the first socket's send/close to the wrong connection. + const ws = new WebSocket(url) let createdSocket + // App-level keepalive for the push socket. The router socket relies on the + // protocol-level ping/pong handled by the server/browser and treats every + // binary frame as a RouterControllerMessage, so it must NOT receive this. + if (sendHeartbeat) { + const heartbeat = setInterval(() => { + if (ws.readyState !== WebSocket.OPEN) return + ws.send(OPCODE_PING) + }, 10000) + ws.addEventListener('close', () => clearInterval(heartbeat)) + } return (createdSocket = { onOpen: (callback: () => void) => { - socket!.onopen = (e: Event) => { + ws.onopen = (e: Event) => { callback() } }, close: () => { - socket!.close() + ws.close() }, onClose: (callback: (e: Event) => void) => { - socket!.addEventListener('close', callback) + ws.addEventListener('close', callback) }, onError: (callback: (e: Event) => void) => { - socket!.addEventListener('error', callback) + ws.addEventListener('error', callback) }, onMessage: (callback: (e: Uint8Array) => void) => { - socket!.onmessage = async (e: MessageEvent) => { - const pmArrBuff = await e.data.arrayBuffer() - const pmUint8Buff = new Uint8Array(pmArrBuff) + ws.onmessage = async (e: MessageEvent) => { + // The push socket delivers binary (Blob/ArrayBuffer) frames; the + // router socket delivers JSON text frames. Text frames arrive as a + // string with no `arrayBuffer()`, so encode them to bytes here. + const pmUint8Buff = + typeof e.data === 'string' + ? new TextEncoder().encode(e.data) + : new Uint8Array(await e.data.arrayBuffer()) callback(pmUint8Buff) } }, send: (message: any) => { - socketSendMessage(message, socket!, createdSocket) + socketSendMessage(message, ws, createdSocket) }, messageQueue: [], }) @@ -1151,12 +1168,6 @@ function bytesToHex(data: Uint8Array): string { const OPCODE_PING = new Uint8Array([0x9]) -const heartbeat = setInterval(() => { - if (!socket) return - if (socket.readyState !== WebSocket.OPEN) return - socket.send(OPCODE_PING) -}, 10000) - let keyBytesCache: Record = {} type CryptoKeyCache = { diff --git a/keeperapi/src/configuration.ts b/keeperapi/src/configuration.ts index a29be94..15553fa 100644 --- a/keeperapi/src/configuration.ts +++ b/keeperapi/src/configuration.ts @@ -27,6 +27,10 @@ export interface ClientConfiguration { iterations?: number salt?: Uint8Array useHpkeForTransmissionKey?: boolean + // When true, the SDK also opens a WebSocket to the KRouter user socket + // (`wss://connect./api/user/client`) once a session token is available, + // in addition to the KeeperApp push socket. See `Auth.connectToRouter`. + connectToRouter?: boolean } export interface ClientConfigurationInternal extends ClientConfiguration { deviceConfig: DeviceConfig // v15+ device config diff --git a/keeperapi/src/endpoint.ts b/keeperapi/src/endpoint.ts index a2188de..2625300 100644 --- a/keeperapi/src/endpoint.ts +++ b/keeperapi/src/endpoint.ts @@ -417,6 +417,25 @@ export class KeeperEndpoint { return WssClientResponse.decode(decryptedPushMessage) } + // Builds the KRouter user-socket URL. Browsers can't set custom WebSocket + // headers, so the same `keeper-user` credentials that `executeRouterRest` + // sends as `Authorization` / `TransmissionKey` headers are passed as URL-safe + // query parameters instead — KRouter's auth provider falls back to query + // parameters and un-url-safes them (see KeeperAuthenticationProvider.kt). + async getRouterConnectionUrl(sessionToken: string): Promise { + const transmissionKey = await this.getTransmissionKey() + const sessionTokenBytes = normal64Bytes(sessionToken) + const encryptedSessionToken = await platform.aesGcmEncrypt(sessionTokenBytes, transmissionKey.key) + const authorization = `KeeperUser ${webSafe64FromBytes(encryptedSessionToken)}` + const transmissionKeyParam = webSafe64FromBytes(transmissionKey.ecEncryptedKey) + const httpUrl = getKeeperRouterUrl(this.options.host, 'api/user/client') + const wssUrl = httpUrl.replace(/^http/, 'ws') + return ( + `${wssUrl}?Authorization=${encodeURIComponent(authorization)}` + + `&TransmissionKey=${encodeURIComponent(transmissionKeyParam)}` + ) + } + async getPushConnectionRequest(messageSessionUid: Uint8Array) { this._transmissionKey = await this.getTransmissionKey() return getPushConnectionRequest( diff --git a/keeperapi/src/node/platform.ts b/keeperapi/src/node/platform.ts index 657cfd3..54fdbc1 100644 --- a/keeperapi/src/node/platform.ts +++ b/keeperapi/src/node/platform.ts @@ -466,7 +466,8 @@ export const nodePlatform: Platform = class { }) } - static createWebsocket(url: string): SocketProxy { + static createWebsocket(url: string, _sendHeartbeat: boolean = true): SocketProxy { + // The node client has no app-level heartbeat, so `sendHeartbeat` is unused here. const socket = new WebSocket.Client(url) let createdSocket return (createdSocket = { diff --git a/keeperapi/src/platform.ts b/keeperapi/src/platform.ts index c2be9fa..f97307d 100644 --- a/keeperapi/src/platform.ts +++ b/keeperapi/src/platform.ts @@ -136,7 +136,7 @@ export interface Platform { closeCryptoWorker(): Promise - createWebsocket(url: string): SocketProxy + createWebsocket(url: string, sendHeartbeat?: boolean): SocketProxy } export interface CryptoTask { diff --git a/keeperapi/src/socket.ts b/keeperapi/src/socket.ts index 4872c05..9661a23 100644 --- a/keeperapi/src/socket.ts +++ b/keeperapi/src/socket.ts @@ -56,6 +56,9 @@ export class SocketListener { private onOpenListeners: Array<() => void> // The messageSessionUid private messageSessionUid?: Uint8Array + // Whether to send the app-level keepalive ping (push socket only; the router + // socket rejects arbitrary binary frames — see browser platform createWebsocket). + private sendHeartbeat: boolean private isConnected: boolean private reconnectTimeout?: ReturnType @@ -65,11 +68,13 @@ export class SocketListener { constructor( url: string, messageSessionUid?: Uint8Array, - getConnectionRequest?: (messageSessionUid: Uint8Array) => Promise + getConnectionRequest?: (messageSessionUid: Uint8Array) => Promise, + sendHeartbeat: boolean = true ) { logger.debug('Connecting to ' + url) this.url = url + this.sendHeartbeat = sendHeartbeat this.closeListeners = [] this.singleCloseListeners = [] this.messageListeners = [] @@ -88,9 +93,9 @@ export class SocketListener { async createWebsocket(messageSessionUid?: Uint8Array) { if (this.getConnectionRequest && messageSessionUid) { const connectionRequest = await this.getConnectionRequest(messageSessionUid) - this.socket = platform.createWebsocket(`${this.url}/${connectionRequest}`) + this.socket = platform.createWebsocket(`${this.url}/${connectionRequest}`, this.sendHeartbeat) } else { - this.socket = platform.createWebsocket(this.url) + this.socket = platform.createWebsocket(this.url, this.sendHeartbeat) } this.socket!.onOpen(() => { @@ -275,9 +280,10 @@ export class SocketListener { export async function createAsyncSocket( url: string, messageSessionUid?: Uint8Array, - getConnectionRequest?: (messageSessionUid: Uint8Array) => Promise + getConnectionRequest?: (messageSessionUid: Uint8Array) => Promise, + sendHeartbeat: boolean = true ): Promise { - const socket = new SocketListener(url, messageSessionUid, getConnectionRequest) + const socket = new SocketListener(url, messageSessionUid, getConnectionRequest, sendHeartbeat) await socket.createWebsocket(messageSessionUid) return socket } From c3490593a47a54aa46aa84b0da3c1fb5e9fa7dea Mon Sep 17 00:00:00 2001 From: Sergey Aldoukhov Date: Sat, 13 Jun 2026 14:57:40 -0700 Subject: [PATCH 3/3] listener --- keeperapi/src/auth.ts | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/keeperapi/src/auth.ts b/keeperapi/src/auth.ts index 34ab218..d6e5616 100644 --- a/keeperapi/src/auth.ts +++ b/keeperapi/src/auth.ts @@ -143,6 +143,11 @@ export class Auth { private socket?: SocketListener // Optional second socket to the KRouter user endpoint (see connectToRouter). private routerSocket?: SocketListener + // Listener registered via onRouterMessage, retained so it can be (re)attached + // each time the router socket (re)connects. Lets a caller subscribe before the + // socket finishes its async connect (it is opened fire-and-forget after login) + // without racing it. Only one consumer is ever needed. + private routerMessageListener?: (data: Uint8Array) => void public clientKey?: Uint8Array private _accountSummary?: IAccountSummaryElements private _accountSummaryVersion: number = 1 @@ -322,6 +327,11 @@ export class Auth { logger.debug('Router message received', text) } }) + // (Re)attach the subscriber registered before this socket existed (or before + // a reconnect created a fresh one). + if (this.routerMessageListener) { + routerSocket.onPushMessage(this.routerMessageListener) + } } disconnectRouter() { @@ -332,10 +342,11 @@ export class Auth { } onRouterMessage(callback: (data: Uint8Array) => void): void { - if (!this.routerSocket) { - throw new Error('No router socket available') - } - this.routerSocket.onPushMessage(callback) + // Retain the listener so it survives (re)connects, then attach it to the + // current socket if one is already open. Safe to call before connectToRouter + // has finished — it does not throw when the socket is not yet available. + this.routerMessageListener = callback + this.routerSocket?.onPushMessage(callback) } onRouterCloseMessage(callback: (data: any) => void): void {