Skip to content

Commit

Permalink
fix(connect): HAWNG-441 encrypt session storage
Browse files Browse the repository at this point in the history
  • Loading branch information
tadayosi committed Feb 1, 2024
1 parent d15c6db commit 1b27f8b
Show file tree
Hide file tree
Showing 10 changed files with 123 additions and 20 deletions.
5 changes: 4 additions & 1 deletion packages/hawtio/jest.config.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -27,3 +28,5 @@ export default {

coveragePathIgnorePatterns: ['node_modules/'],
}

export default config
1 change: 1 addition & 0 deletions packages/hawtio/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
13 changes: 9 additions & 4 deletions packages/hawtio/src/plugins/connect/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,11 @@ class MockConnectService implements IConnectService {
return null
}

getCurrentConnection(): Connection | null {
async getCurrentConnection(): Promise<Connection | null> {
return null
}

getCurrentCredentials(): ConnectionCredentials | null {
async getCurrentCredentials(): Promise<ConnectionCredentials | null> {
return null
}

Expand Down
44 changes: 32 additions & 12 deletions packages/hawtio/src/plugins/shared/connect-service.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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'
Expand All @@ -52,8 +55,8 @@ const LOGIN_PATH = '/connect/login'

export interface IConnectService {
getCurrentConnectionName(): string | null
getCurrentConnection(): Connection | null
getCurrentCredentials(): ConnectionCredentials | null
getCurrentConnection(): Promise<Connection | null>
getCurrentCredentials(): Promise<ConnectionCredentials | null>
loadConnections(): Connections
saveConnections(connections: Connections): void
getConnection(name: string): Connection | null
Expand Down Expand Up @@ -97,14 +100,14 @@ class ConnectService implements IConnectService {
return this.currentConnection
}

getCurrentConnection(): Connection | null {
async getCurrentConnection(): Promise<Connection | null> {
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
}
Expand All @@ -116,12 +119,30 @@ class ConnectService implements IConnectService {
}

private clearCredentialsOnLogout() {
eventService.onLogout(() => sessionStorage.removeItem(SESSION_KEY_CREDENTIALS))
eventService.onLogout(() => sessionStorage.clear())
}

async getCurrentCredentials(): Promise<ConnectionCredentials | null> {
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 {
Expand Down Expand Up @@ -208,7 +229,7 @@ class ConnectService implements IConnectService {
* Log in to the current connection.
*/
async login(username: string, password: string): Promise<boolean> {
const connection = this.getCurrentConnection()
const connection = await this.getCurrentConnection()
if (!connection) {
return false
}
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/hawtio/src/plugins/shared/jolokia-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,7 @@ class JolokiaService implements IJolokiaService {
}

private async beforeSend(): Promise<JQueryBeforeSend> {
const connection = connectService.getCurrentConnection()
const connection = await connectService.getCurrentConnection()
// Just set Authorization for now...
const header = 'Authorization'
if ((await userService.isLogin()) && userService.getToken()) {
Expand Down
7 changes: 7 additions & 0 deletions packages/hawtio/src/setupTests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -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
19 changes: 19 additions & 0 deletions packages/hawtio/src/util/crypto.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
40 changes: 40 additions & 0 deletions packages/hawtio/src/util/crypto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { getFingerprint } from '@thumbmarkjs/thumbmarkjs'

export async function generateKey(salt: ArrayBufferView): Promise<CryptoKey> {
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<string> {
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<string> {
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))
}
8 changes: 8 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down

0 comments on commit 1b27f8b

Please sign in to comment.