Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion keeperapi/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@keeper-security/keeperapi",
"description": "Keeper API Javascript SDK",
"version": "17.2.8",
"version": "17.3.0",
"browser": "dist/index.es.js",
"main": "dist/index.cjs.js",
"types": "dist/node/index.d.ts",
Expand Down
85 changes: 85 additions & 0 deletions keeperapi/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,13 @@ export class Auth {
private messageSessionUid: Uint8Array
options: ClientConfigurationInternal
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
Expand Down Expand Up @@ -257,6 +264,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({
Expand All @@ -274,6 +292,70 @@ export class Auth {
}
}

/**
* Opens a WebSocket to the KRouter user endpoint
* (`wss://connect.<host>/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)
}
})
// (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() {
if (this.routerSocket) {
this.routerSocket.disconnect()
delete this.routerSocket
}
}

onRouterMessage(callback: (data: Uint8Array) => void): void {
// 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 {
if (!this.routerSocket) {
throw new Error('No router socket available')
}
this.routerSocket.onCloseMessage(callback)
}

/**
* @param {LoginPayload} payload - Options for login.
* @param {boolean} [payload.disableLinkingForAccountWithYubikey2fa] -
Expand Down Expand Up @@ -1254,6 +1336,9 @@ export class Auth {
this.socket.onOpen(() => {
this.socket?.registerLogin(this._sessionToken)
})
if (this.options.connectToRouter) {
Comment thread
tylerccarson marked this conversation as resolved.
this.connectToRouter().catch((e) => logger.debug('Router socket connect failed', e))
}
}

async registerDevice() {
Expand Down
45 changes: 28 additions & 17 deletions keeperapi/src/browser/platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down Expand Up @@ -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: [],
})
Expand Down Expand Up @@ -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<string, Uint8Array> = {}

type CryptoKeyCache = {
Expand Down
4 changes: 4 additions & 0 deletions keeperapi/src/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.<host>/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
Expand Down
19 changes: 19 additions & 0 deletions keeperapi/src/endpoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
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(
Expand Down
3 changes: 2 additions & 1 deletion keeperapi/src/node/platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
2 changes: 1 addition & 1 deletion keeperapi/src/platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ export interface Platform {

closeCryptoWorker(): Promise<void>

createWebsocket(url: string): SocketProxy
createWebsocket(url: string, sendHeartbeat?: boolean): SocketProxy
}

export interface CryptoTask {
Expand Down
16 changes: 11 additions & 5 deletions keeperapi/src/socket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof setTimeout>
Expand All @@ -65,11 +68,13 @@ export class SocketListener {
constructor(
url: string,
messageSessionUid?: Uint8Array,
getConnectionRequest?: (messageSessionUid: Uint8Array) => Promise<string>
getConnectionRequest?: (messageSessionUid: Uint8Array) => Promise<string>,
sendHeartbeat: boolean = true
) {
logger.debug('Connecting to ' + url)

this.url = url
this.sendHeartbeat = sendHeartbeat
this.closeListeners = []
this.singleCloseListeners = []
this.messageListeners = []
Expand All @@ -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(() => {
Expand Down Expand Up @@ -275,9 +280,10 @@ export class SocketListener {
export async function createAsyncSocket(
url: string,
messageSessionUid?: Uint8Array,
getConnectionRequest?: (messageSessionUid: Uint8Array) => Promise<string>
getConnectionRequest?: (messageSessionUid: Uint8Array) => Promise<string>,
sendHeartbeat: boolean = true
): Promise<SocketListener | undefined> {
const socket = new SocketListener(url, messageSessionUid, getConnectionRequest)
const socket = new SocketListener(url, messageSessionUid, getConnectionRequest, sendHeartbeat)
await socket.createWebsocket(messageSessionUid)
return socket
}
Expand Down
Loading