diff --git a/packages/hawtio/jest.config.ts b/packages/hawtio/jest.config.ts index 61a001f9..7229820e 100644 --- a/packages/hawtio/jest.config.ts +++ b/packages/hawtio/jest.config.ts @@ -1,6 +1,7 @@ +import type { Config } from 'jest' import path from 'path' -export default { +const config: Config = { preset: 'ts-jest', testEnvironment: 'jsdom', silent: true, @@ -27,3 +28,5 @@ export default { coveragePathIgnorePatterns: ['node_modules/'], } + +export default config diff --git a/packages/hawtio/package.json b/packages/hawtio/package.json index 76e8e1e2..306e99cf 100644 --- a/packages/hawtio/package.json +++ b/packages/hawtio/package.json @@ -43,6 +43,7 @@ "@testing-library/jest-dom": "^6.3.0", "@testing-library/react": "^14.1.2", "@testing-library/user-event": "^14.5.2", + "@thumbmarkjs/thumbmarkjs": "^0.12.2", "@types/dagre": "^0.7.52", "@types/dagre-layout": "^0.8.5", "@types/jest": "^29.5.11", diff --git a/packages/hawtio/src/plugins/connect/init.ts b/packages/hawtio/src/plugins/connect/init.ts index 5e904fea..e1e8ec54 100644 --- a/packages/hawtio/src/plugins/connect/init.ts +++ b/packages/hawtio/src/plugins/connect/init.ts @@ -45,16 +45,21 @@ function isConnectLogin(): boolean { * can reflect the remote credentials. */ export function registerUserHooks() { - const credentials = connectService.getCurrentCredentials() - if (!credentials) { - return - } + const credPromise = connectService.getCurrentCredentials() userService.addFetchUserHook('connect', async resolve => { + const credentials = await credPromise + if (!credentials) { + return false + } resolve({ username: credentials.username, isLogin: true }) return true }) userService.addLogoutHook('connect', async () => { + const credentials = await credPromise + if (!credentials) { + return false + } // Logout from remote connection should close the window window.close() return true diff --git a/packages/hawtio/src/plugins/shared/__mocks__/connect-service.ts b/packages/hawtio/src/plugins/shared/__mocks__/connect-service.ts index 32eb0250..57b7d397 100644 --- a/packages/hawtio/src/plugins/shared/__mocks__/connect-service.ts +++ b/packages/hawtio/src/plugins/shared/__mocks__/connect-service.ts @@ -17,11 +17,11 @@ class MockConnectService implements IConnectService { return null } - getCurrentConnection(): Connection | null { + async getCurrentConnection(): Promise { return null } - getCurrentCredentials(): ConnectionCredentials | null { + async getCurrentCredentials(): Promise { return null } diff --git a/packages/hawtio/src/plugins/shared/connect-service.ts b/packages/hawtio/src/plugins/shared/connect-service.ts index 8e5cfef5..5528b109 100644 --- a/packages/hawtio/src/plugins/shared/connect-service.ts +++ b/packages/hawtio/src/plugins/shared/connect-service.ts @@ -1,4 +1,5 @@ import { eventService, hawtio } from '@hawtiosrc/core' +import { decrypt, encrypt, generateKey, toBase64, toByteArray } from '@hawtiosrc/util/crypto' import { toString } from '@hawtiosrc/util/strings' import { joinPaths } from '@hawtiosrc/util/urls' import Jolokia from 'jolokia.js' @@ -42,8 +43,10 @@ export type ConnectionCredentials = { } const STORAGE_KEY_CONNECTIONS = 'connect.connections' + const SESSION_KEY_CURRENT_CONNECTION = 'connect.currentConnection' -const SESSION_KEY_CREDENTIALS = 'connect.credentials' +const SESSION_KEY_SALT = 'connect.salt' +const SESSION_KEY_CREDENTIALS = 'connect.credentials' // Encrypted export const PARAM_KEY_CONNECTION = 'con' export const PARAM_KEY_REDIRECT = 'redirect' @@ -52,8 +55,8 @@ const LOGIN_PATH = '/connect/login' export interface IConnectService { getCurrentConnectionName(): string | null - getCurrentConnection(): Connection | null - getCurrentCredentials(): ConnectionCredentials | null + getCurrentConnection(): Promise + getCurrentCredentials(): Promise loadConnections(): Connections saveConnections(connections: Connections): void getConnection(name: string): Connection | null @@ -97,14 +100,14 @@ class ConnectService implements IConnectService { return this.currentConnection } - getCurrentConnection(): Connection | null { + async getCurrentConnection(): Promise { const conn = this.currentConnection ? this.getConnection(this.currentConnection) : null if (!conn) { return null } // Apply credentials if it exists - const credentials = this.getCurrentCredentials() + const credentials = await this.getCurrentCredentials() if (!credentials) { return conn } @@ -116,12 +119,30 @@ class ConnectService implements IConnectService { } private clearCredentialsOnLogout() { - eventService.onLogout(() => sessionStorage.removeItem(SESSION_KEY_CREDENTIALS)) + eventService.onLogout(() => sessionStorage.clear()) + } + + async getCurrentCredentials(): Promise { + const saltItem = sessionStorage.getItem(SESSION_KEY_SALT) + if (!saltItem) { + return null + } + const salt = toByteArray(saltItem) + + const credItem = sessionStorage.getItem(SESSION_KEY_CREDENTIALS) + if (!credItem) { + return null + } + const key = await generateKey(salt) + return JSON.parse(await decrypt(key, credItem)) } - getCurrentCredentials(): ConnectionCredentials | null { - const item = sessionStorage.getItem(SESSION_KEY_CREDENTIALS) - return item ? JSON.parse(item) : null + private async setCurrentCredentials(credentials: ConnectionCredentials) { + const salt = window.crypto.getRandomValues(new Uint8Array(16)) + sessionStorage.setItem(SESSION_KEY_SALT, toBase64(salt)) + const key = await generateKey(salt) + const encrypted = await encrypt(key, JSON.stringify(credentials)) + sessionStorage.setItem(SESSION_KEY_CREDENTIALS, encrypted) } loadConnections(): Connections { @@ -208,7 +229,7 @@ class ConnectService implements IConnectService { * Log in to the current connection. */ async login(username: string, password: string): Promise { - const connection = this.getCurrentConnection() + const connection = await this.getCurrentConnection() if (!connection) { return false } @@ -231,8 +252,7 @@ class ConnectService implements IConnectService { } // Persist credentials to session storage - const credentials: ConnectionCredentials = { username, password } - sessionStorage.setItem(SESSION_KEY_CREDENTIALS, JSON.stringify(credentials)) + await this.setCurrentCredentials({ username, password }) this.clearCredentialsOnLogout() return true diff --git a/packages/hawtio/src/plugins/shared/jolokia-service.ts b/packages/hawtio/src/plugins/shared/jolokia-service.ts index b3ab59ea..953256fa 100644 --- a/packages/hawtio/src/plugins/shared/jolokia-service.ts +++ b/packages/hawtio/src/plugins/shared/jolokia-service.ts @@ -273,7 +273,7 @@ class JolokiaService implements IJolokiaService { } private async beforeSend(): Promise { - const connection = connectService.getCurrentConnection() + const connection = await connectService.getCurrentConnection() // Just set Authorization for now... const header = 'Authorization' if ((await userService.isLogin()) && userService.getToken()) { diff --git a/packages/hawtio/src/setupTests.ts b/packages/hawtio/src/setupTests.ts index d13de628..8202b6a4 100644 --- a/packages/hawtio/src/setupTests.ts +++ b/packages/hawtio/src/setupTests.ts @@ -3,8 +3,10 @@ // expect(element).toHaveTextContent(/react/i) // learn more: https://github.com/testing-library/jest-dom import '@testing-library/jest-dom' +import crypto from 'crypto' import fetchMock from 'jest-fetch-mock' import $ from 'jquery' +import { TextDecoder, TextEncoder } from 'util' fetchMock.enableMocks() @@ -26,3 +28,8 @@ fetchMock.mockResponse(req => { // eslint-disable-next-line @typescript-eslint/no-explicit-any declare const global: any global.$ = global.jQuery = $ + +// For testing crypto +Object.defineProperty(global, 'crypto', { value: crypto.webcrypto }) +global.TextEncoder = TextEncoder +global.TextDecoder = TextDecoder diff --git a/packages/hawtio/src/util/crypto.test.ts b/packages/hawtio/src/util/crypto.test.ts new file mode 100644 index 00000000..0c788708 --- /dev/null +++ b/packages/hawtio/src/util/crypto.test.ts @@ -0,0 +1,19 @@ +import { decrypt, encrypt, generateKey } from './crypto' + +jest.mock('@thumbmarkjs/thumbmarkjs', () => ({ + getFingerprint: jest.fn(() => '123abc'), +})) + +describe('crypto', () => { + test('generateKey, encrypt, and decrypt', async () => { + const salt = window.crypto.getRandomValues(new Uint8Array(16)) + const key = await generateKey(salt) + expect(key).not.toBeNull() + expect(key.algorithm).toEqual({ name: 'AES-GCM', length: 256 }) + const text = 'test' + const encrypted = await encrypt(key, text) + expect(encrypted).not.toEqual(text) + const decrypted = await decrypt(key, encrypted) + expect(decrypted).toEqual(text) + }) +}) diff --git a/packages/hawtio/src/util/crypto.ts b/packages/hawtio/src/util/crypto.ts new file mode 100644 index 00000000..4e4d8286 --- /dev/null +++ b/packages/hawtio/src/util/crypto.ts @@ -0,0 +1,40 @@ +import { getFingerprint } from '@thumbmarkjs/thumbmarkjs' + +export async function generateKey(salt: ArrayBufferView): Promise { + const fingerprint = await getFingerprint() + const data = new TextEncoder().encode(fingerprint) + const key = await window.crypto.subtle.importKey('raw', data, { name: 'PBKDF2' }, false, ['deriveKey']) + const algorithm = { + name: 'PBKDF2', + salt, + iterations: 100000, + hash: 'SHA-256', + } + const keyType = { + name: 'AES-GCM', + length: 256, + } + return window.crypto.subtle.deriveKey(algorithm, key, keyType, true, ['encrypt', 'decrypt']) +} + +export function toBase64(data: Uint8Array): string { + return window.btoa(String.fromCharCode(...Array.from(data))) +} + +export function toByteArray(data: string): Uint8Array { + return new Uint8Array(Array.from(window.atob(data)).map(c => c.charCodeAt(0))) +} + +export async function encrypt(key: CryptoKey, data: string): Promise { + const iv = window.crypto.getRandomValues(new Uint8Array(12)) + const encodedData = new TextEncoder().encode(data) + const encrypted = await window.crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, encodedData) + return toBase64(iv) + '.' + toBase64(new Uint8Array(encrypted)) +} + +export async function decrypt(key: CryptoKey, data: string): Promise { + const iv = toByteArray(data.split('.')[0] ?? '') + const encrypted = toByteArray(data.split('.')[1] ?? '') + const decrypted = await window.crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, encrypted) + return new TextDecoder('utf-8').decode(new Uint8Array(decrypted)) +} diff --git a/yarn.lock b/yarn.lock index ac1ee7fa..f58bec7e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2291,6 +2291,7 @@ __metadata: "@testing-library/jest-dom": "npm:^6.3.0" "@testing-library/react": "npm:^14.1.2" "@testing-library/user-event": "npm:^14.5.2" + "@thumbmarkjs/thumbmarkjs": "npm:^0.12.2" "@types/dagre": "npm:^0.7.52" "@types/dagre-layout": "npm:^0.8.5" "@types/jest": "npm:^29.5.11" @@ -3763,6 +3764,13 @@ __metadata: languageName: node linkType: hard +"@thumbmarkjs/thumbmarkjs@npm:^0.12.2": + version: 0.12.2 + resolution: "@thumbmarkjs/thumbmarkjs@npm:0.12.2" + checksum: 10/bc0bd60d40c96ec1186ac854467bb09ca2813c8b3c13f5beff9e19b556e2adcc12d67641dcd3ed3b300466a89d1142ff9e1b1dd35b619e8bcb8416bfd542713d + languageName: node + linkType: hard + "@tootallnate/once@npm:1": version: 1.1.2 resolution: "@tootallnate/once@npm:1.1.2"