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
5 changes: 5 additions & 0 deletions web/src/lang/connections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -517,4 +517,9 @@ export default {
en: 'latest announcement',
ja: '最新のお知らせ',
},
authenticationMethod: {
zh: '认证方法',
en: 'Authentication Method',
ja: '認証方法',
},
}
20 changes: 18 additions & 2 deletions web/src/utils/mqttUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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(
Expand All @@ -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
Expand Down
163 changes: 163 additions & 0 deletions web/src/utils/scramAuth.ts
Original file line number Diff line number Diff line change
@@ -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<ArrayBuffer> {
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<ArrayBuffer> {
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<ArrayBuffer> {
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<Buffer> {
this.serverFirstMessage = serverFirstData.toString()

// Parse server message: r=clientNonce+serverNonce,s=salt,i=iterationCount
const serverParams: Record<string, string> = {}
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)
}
}
60 changes: 60 additions & 0 deletions web/src/utils/scramUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { MqttClient, IClientOptions } from 'mqtt'
import { ScramAuth, ScramAlgorithm } from '@/utils/scramAuth'

export const setupScramAuth = async (
record: ConnectionModel,
options: IClientOptions,
): Promise<ScramAuth | undefined> => {
// 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'))
}
}
}
16 changes: 16 additions & 0 deletions web/src/views/connections/ConnectionForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,22 @@
</el-card>
</el-collapse-transition>

<!-- Enhanced Authentication for MQTT 5.0 -->
<el-card v-if="record.mqttVersion === '5.0' && advancedVisible" shadow="never" class="info-body item-card">
<el-row :gutter="10">
<el-col :span="22">
<el-form-item :label="$t('connections.authenticationMethod')" prop="authenticationMethod">
<el-select size="mini" v-model="record.properties.authenticationMethod" clearable>
<el-option label="SCRAM-SHA-1" value="SCRAM-SHA-1"></el-option>
<el-option label="SCRAM-SHA-256" value="SCRAM-SHA-256"></el-option>
<el-option label="SCRAM-SHA-512" value="SCRAM-SHA-512"></el-option>
</el-select>
</el-form-item>
</el-col>
<el-col :span="2"></el-col>
</el-row>
</el-card>

<el-card v-if="record.mqttVersion === '5.0' && advancedVisible" shadow="never" class="info-body item-card">
<el-row :gutter="20">
<el-col :span="22">
Expand Down
4 changes: 2 additions & 2 deletions web/src/views/connections/ConnectionsDetail.vue
Original file line number Diff line number Diff line change
Expand Up @@ -328,7 +328,7 @@ export default class ConnectionsDetail extends Vue {
}

// Connect
public connect(): boolean | void {
public async connect(): Promise<boolean | void> {
if (process.env.VUE_APP_IS_ONLINE_ENV === 'true' && this.record.protocol === 'ws') {
const desktop = `<a href="${this.mqttxWebsite}" target="_blank">MQTTX Desktop</a>`
const cli = `<a href="${this.mqttxWebsite}/cli" target="_blank">MQTTX CLI</a>`
Expand All @@ -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) {
Expand Down
Loading