diff --git a/web/src/lang/connections.ts b/web/src/lang/connections.ts index e94883e0f..f869d4820 100644 --- a/web/src/lang/connections.ts +++ b/web/src/lang/connections.ts @@ -517,4 +517,9 @@ export default { en: 'latest announcement', ja: '最新のお知らせ', }, + authenticationMethod: { + zh: '认证方法', + en: 'Authentication Method', + ja: '認証方法', + }, } diff --git a/web/src/utils/mqttUtils.ts b/web/src/utils/mqttUtils.ts index 0f20e3821..6eaee6e24 100644 --- a/web/src/utils/mqttUtils.ts +++ b/web/src/utils/mqttUtils.ts @@ -3,6 +3,8 @@ import Store from '@/store' import { getClientId } from '@/utils/idGenerator' import time from '@/utils/time' import { getSSLFile } from '@/utils/getFiles' +import { ScramAuth } from '@/utils/scramAuth' +import { setupScramAuth, setupAuthHandler } from '@/utils/scramUtils' import _ from 'lodash' const setMQTT5Properties = (option: ClientPropertiesModel) => { @@ -125,9 +127,18 @@ const getUrl = (record: ConnectionModel): string => { return url } -export const createClient = (record: ConnectionModel): { curConnectClient: MqttClient; connectUrl: string } => { +export const createClient = async ( + record: ConnectionModel, +): Promise<{ curConnectClient: MqttClient; connectUrl: string; scramAuth?: ScramAuth }> => { const options: IClientOptions = getClientOptions(record) const url = getUrl(record) + + // Setup SCRAM authentication if applicable + let scramAuth: ScramAuth | undefined + if (record.mqttVersion === '5.0' && record.properties?.authenticationMethod) { + scramAuth = await setupScramAuth(record, options) + } + // Map options.properties.topicAliasMaximum to options.topicAliasMaximum, as that is where MQTT.js looks for it. // TODO: remove after bug fixed in MQTT.js v5. const optionsTempWorkAround = Object.assign( @@ -136,7 +147,12 @@ export const createClient = (record: ConnectionModel): { curConnectClient: MqttC ) const curConnectClient: MqttClient = mqtt.connect(url, optionsTempWorkAround) - return { curConnectClient, connectUrl: url } + // Setup SCRAM auth handler if we have SCRAM auth configured + if (scramAuth && record.properties?.authenticationMethod) { + setupAuthHandler(curConnectClient, scramAuth, record.properties.authenticationMethod) + } + + return { curConnectClient, connectUrl: url, scramAuth } } // Prevent old data from missing protocol field diff --git a/web/src/utils/scramAuth.ts b/web/src/utils/scramAuth.ts new file mode 100644 index 000000000..bb86f8122 --- /dev/null +++ b/web/src/utils/scramAuth.ts @@ -0,0 +1,163 @@ +export type ScramAlgorithm = 'SCRAM-SHA-1' | 'SCRAM-SHA-256' | 'SCRAM-SHA-512' + +export class ScramAuth { + private username: string + private password: string + private algorithm: ScramAlgorithm + private clientNonce: string + private clientFirstMessage: string = '' + private serverFirstMessage: string = '' + + constructor(username: string, password: string, algorithm: ScramAlgorithm = 'SCRAM-SHA-256') { + this.username = username + this.password = password + this.algorithm = algorithm + this.clientNonce = this.generateNonce() + } + + private generateNonce(): string { + // Generate a random nonce for browser environment + const array = new Uint8Array(32) + crypto.getRandomValues(array) + return btoa(String.fromCharCode(...array)) + } + + private getHashFunction(): string { + switch (this.algorithm) { + case 'SCRAM-SHA-1': + return 'SHA-1' + case 'SCRAM-SHA-256': + return 'SHA-256' + case 'SCRAM-SHA-512': + return 'SHA-512' + default: + throw new Error(`Unsupported algorithm: ${this.algorithm}`) + } + } + + private getHashLength(): number { + switch (this.algorithm) { + case 'SCRAM-SHA-1': + return 20 + case 'SCRAM-SHA-256': + return 32 + case 'SCRAM-SHA-512': + return 64 + default: + throw new Error(`Unsupported algorithm: ${this.algorithm}`) + } + } + + private saslPrep(str: string): string { + // Basic SCRAM string preparation - escape = and , characters + return str.replace(/=/g, '=3D').replace(/,/g, '=2C') + } + + private async pbkdf2( + password: string, + salt: ArrayBuffer, + iterations: number, + keyLength: number, + hashAlg: string, + ): Promise { + const encoder = new TextEncoder() + const passwordKey = await crypto.subtle.importKey('raw', encoder.encode(password), 'PBKDF2', false, ['deriveBits']) + + return crypto.subtle.deriveBits( + { + name: 'PBKDF2', + salt: salt, + iterations: iterations, + hash: hashAlg, + }, + passwordKey, + keyLength * 8, + ) + } + + private async hmac(key: ArrayBuffer, data: string, hashAlg: string): Promise { + const encoder = new TextEncoder() + const cryptoKey = await crypto.subtle.importKey('raw', key, { name: 'HMAC', hash: hashAlg }, false, ['sign']) + return crypto.subtle.sign('HMAC', cryptoKey, encoder.encode(data)) + } + + private async hash(data: ArrayBuffer, hashAlg: string): Promise { + return crypto.subtle.digest(hashAlg, data) + } + + private arrayBufferToBase64(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer) + let binary = '' + for (let i = 0; i < bytes.byteLength; i++) { + binary += String.fromCharCode(bytes[i]) + } + return btoa(binary) + } + + private base64ToArrayBuffer(base64: string): ArrayBuffer { + const binary = atob(base64) + const bytes = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i) + } + return bytes.buffer + } + + clientFirst(): Buffer { + const usernameSasl = this.saslPrep(this.username) + this.clientFirstMessage = `n,,n=${usernameSasl},r=${this.clientNonce}` + return Buffer.from(this.clientFirstMessage) + } + + async clientFinal(serverFirstData: Buffer): Promise { + this.serverFirstMessage = serverFirstData.toString() + + // Parse server message: r=clientNonce+serverNonce,s=salt,i=iterationCount + const serverParams: Record = {} + this.serverFirstMessage.split(',').forEach((param) => { + const eqIndex = param.indexOf('=') + if (eqIndex > 0) { + const key = param.substring(0, eqIndex) + const value = param.substring(eqIndex + 1) + serverParams[key] = value + } + }) + + const fullNonce = serverParams.r + const salt = this.base64ToArrayBuffer(serverParams.s) + const iterationCount = parseInt(serverParams.i) + + // Verify nonce starts with our client nonce + if (!fullNonce.startsWith(this.clientNonce)) { + throw new Error('Invalid server nonce') + } + + // Calculate saltedPassword + const passwordSasl = this.saslPrep(this.password) + const hashFunc = this.getHashFunction() + const hashLength = this.getHashLength() + const saltedPassword = await this.pbkdf2(passwordSasl, salt, iterationCount, hashLength, hashFunc) + + // Calculate keys + const clientKey = await this.hmac(saltedPassword, 'Client Key', hashFunc) + const storedKey = await this.hash(clientKey, hashFunc) + + // Build auth message + const clientFinalWithoutProof = `c=biws,r=${fullNonce}` + const authMessage = `${this.clientFirstMessage.substring(3)},${this.serverFirstMessage},${clientFinalWithoutProof}` + + // Calculate client proof + const clientSignature = await this.hmac(storedKey, authMessage, hashFunc) + const clientKeyBytes = new Uint8Array(clientKey) + const clientSignatureBytes = new Uint8Array(clientSignature) + const clientProof = new Uint8Array(clientKeyBytes.length) + + for (let i = 0; i < clientKeyBytes.length; i++) { + clientProof[i] = clientKeyBytes[i] ^ clientSignatureBytes[i] + } + + // Build final message + const clientFinalMessage = `${clientFinalWithoutProof},p=${this.arrayBufferToBase64(clientProof.buffer)}` + return Buffer.from(clientFinalMessage) + } +} diff --git a/web/src/utils/scramUtils.ts b/web/src/utils/scramUtils.ts new file mode 100644 index 000000000..6570ad927 --- /dev/null +++ b/web/src/utils/scramUtils.ts @@ -0,0 +1,60 @@ +import { MqttClient, IClientOptions } from 'mqtt' +import { ScramAuth, ScramAlgorithm } from '@/utils/scramAuth' + +export const setupScramAuth = async ( + record: ConnectionModel, + options: IClientOptions, +): Promise => { + // Only work with MQTT 5.0 + if (record.mqttVersion !== '5.0') { + return undefined + } + + const authMethod = record.properties?.authenticationMethod + + // Check if it's a supported SCRAM method + if (authMethod !== 'SCRAM-SHA-1' && authMethod !== 'SCRAM-SHA-256' && authMethod !== 'SCRAM-SHA-512') { + return undefined + } + + try { + const scramAuth = new ScramAuth(record.username, record.password, authMethod as ScramAlgorithm) + const clientFirstData = scramAuth.clientFirst() + + if (!options.properties) { + options.properties = {} + } + options.properties.authenticationMethod = authMethod + options.properties.authenticationData = clientFirstData + + return scramAuth + } catch (error) { + return undefined + } +} + +export const setupAuthHandler = (client: MqttClient, scramAuth: ScramAuth, authMethod: string): void => { + client.handleAuth = async (packet, callback) => { + try { + const serverAuthData = packet.properties?.authenticationData + if (!serverAuthData) { + callback(new Error('No authentication data from server')) + return + } + + const clientFinalData = await scramAuth.clientFinal(serverAuthData) + const authResponse = { + cmd: 'auth' as const, + reasonCode: 0x18, + properties: { + authenticationMethod: authMethod, + authenticationData: clientFinalData, + }, + } + + callback(undefined, authResponse) + } catch (error) { + callback(error instanceof Error ? error : new Error('SCRAM authentication failed')) + } + } +} diff --git a/web/src/views/connections/ConnectionForm.vue b/web/src/views/connections/ConnectionForm.vue index bc502a08b..3a6502b32 100644 --- a/web/src/views/connections/ConnectionForm.vue +++ b/web/src/views/connections/ConnectionForm.vue @@ -372,6 +372,22 @@ + + + + + + + + + + + + + + + + diff --git a/web/src/views/connections/ConnectionsDetail.vue b/web/src/views/connections/ConnectionsDetail.vue index 118bff7e3..307e2170c 100644 --- a/web/src/views/connections/ConnectionsDetail.vue +++ b/web/src/views/connections/ConnectionsDetail.vue @@ -328,7 +328,7 @@ export default class ConnectionsDetail extends Vue { } // Connect - public connect(): boolean | void { + public async connect(): Promise { if (process.env.VUE_APP_IS_ONLINE_ENV === 'true' && this.record.protocol === 'ws') { const desktop = `MQTTX Desktop` const cli = `MQTTX CLI` @@ -355,7 +355,7 @@ export default class ConnectionsDetail extends Vue { this.connectLoading = true // new client try { - const { curConnectClient } = createClient(this.record) + const { curConnectClient } = await createClient(this.record) this.client = curConnectClient const { id } = this.record if (id && this.client.on) {