|
@@ -0,0 +1,586 @@
|
|
|
|
|
+import type { BrowserWindow } from 'electron'
|
|
|
|
|
+import { connect, type Socket } from 'node:net'
|
|
|
|
|
+
|
|
|
|
|
+const DEFAULT_MASK = 0x9e3779b9
|
|
|
|
|
+const MAX_FRAME_SIZE = 16 * 1024 * 1024
|
|
|
|
|
+const UINT32_MASK = 0xffffffffn
|
|
|
|
|
+const DEFAULT_MASK_BIGINT = 0x9e3779b9n
|
|
|
|
|
+
|
|
|
|
|
+export const PIPE_PATH = '\\\\.\\pipe\\www.sv-elec.com'
|
|
|
|
|
+export const PIPE_EVENT_CHANNEL = 'pipe-event'
|
|
|
|
|
+
|
|
|
|
|
+type PipeLogLevel = 'info' | 'success' | 'warning' | 'error'
|
|
|
|
|
+type PipeLogCategory = 'connection' | 'auth' | 'message'
|
|
|
|
|
+type PipeLogDirection = 'system' | 'recv' | 'send'
|
|
|
|
|
+
|
|
|
|
|
+type PipeEvent = {
|
|
|
|
|
+ timestamp: string
|
|
|
|
|
+ level: PipeLogLevel
|
|
|
|
|
+ category: PipeLogCategory
|
|
|
|
|
+ direction: PipeLogDirection
|
|
|
|
|
+ message: string
|
|
|
|
|
+ detail?: string
|
|
|
|
|
+ msgId?: number
|
|
|
|
|
+ msgName?: string
|
|
|
|
|
+ payloadLength?: number
|
|
|
|
|
+ payloadHex?: string
|
|
|
|
|
+ payloadText?: string
|
|
|
|
|
+ connected: boolean
|
|
|
|
|
+ authenticated: boolean
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+type PipeEventPayload = Omit<PipeEvent, 'timestamp' | 'connected' | 'authenticated'>
|
|
|
|
|
+
|
|
|
|
|
+const MESSAGE_NAMES: Record<number, string> = {
|
|
|
|
|
+ 0x0001: 'AUTH_CHALLENGE',
|
|
|
|
|
+ 0x0002: 'AUTH_RESPONSE',
|
|
|
|
|
+ 0x0003: 'AUTH_SUCCESS',
|
|
|
|
|
+ 0x0004: 'AUTH_FAILED',
|
|
|
|
|
+ 0x0005: 'AUTH_TIMEOUT',
|
|
|
|
|
+ 0x0006: 'HEARTBEAT',
|
|
|
|
|
+ 0x1001: 'CONNECT',
|
|
|
|
|
+ 0x1002: 'DISCONNECT',
|
|
|
|
|
+ 0x1003: 'STATUS_REQUEST',
|
|
|
|
|
+ 0x1004: 'STATUS_RESPONSE',
|
|
|
|
|
+ 0x2001: 'TEXT_DATA',
|
|
|
|
|
+ 0x2002: 'BINARY_DATA',
|
|
|
|
|
+ 0x2003: 'FILE_DATA',
|
|
|
|
|
+ 0x3001: 'CUSTOM_1',
|
|
|
|
|
+ 0x3002: 'CUSTOM_2',
|
|
|
|
|
+ 0x3003: 'CUSTOM_3'
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export class PipeClient {
|
|
|
|
|
+ private socket?: Socket
|
|
|
|
|
+ private buffer = Buffer.alloc(0)
|
|
|
|
|
+ private authenticated = false
|
|
|
|
|
+ private connecting = false
|
|
|
|
|
+ private closingReason: string | null = null
|
|
|
|
|
+ private disposed = false
|
|
|
|
|
+
|
|
|
|
|
+ constructor(private readonly window: BrowserWindow) {}
|
|
|
|
|
+
|
|
|
|
|
+ connect(): void {
|
|
|
|
|
+ if (this.disposed) return
|
|
|
|
|
+
|
|
|
|
|
+ if (this.isConnected()) {
|
|
|
|
|
+ this.emit({
|
|
|
|
|
+ level: 'info',
|
|
|
|
|
+ category: 'connection',
|
|
|
|
|
+ direction: 'system',
|
|
|
|
|
+ message: 'Pipe is already connected'
|
|
|
|
|
+ })
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (this.connecting) {
|
|
|
|
|
+ this.emit({
|
|
|
|
|
+ level: 'info',
|
|
|
|
|
+ category: 'connection',
|
|
|
|
|
+ direction: 'system',
|
|
|
|
|
+ message: 'Pipe connection is in progress'
|
|
|
|
|
+ })
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ this.buffer = Buffer.alloc(0)
|
|
|
|
|
+ this.authenticated = false
|
|
|
|
|
+ this.connecting = true
|
|
|
|
|
+ this.closingReason = null
|
|
|
|
|
+
|
|
|
|
|
+ this.emit({
|
|
|
|
|
+ level: 'info',
|
|
|
|
|
+ category: 'connection',
|
|
|
|
|
+ direction: 'system',
|
|
|
|
|
+ message: 'Connecting to pipe server'
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ const socket = connect(PIPE_PATH)
|
|
|
|
|
+ this.socket = socket
|
|
|
|
|
+
|
|
|
|
|
+ socket.on('connect', () => {
|
|
|
|
|
+ this.connecting = false
|
|
|
|
|
+ this.emit({
|
|
|
|
|
+ level: 'success',
|
|
|
|
|
+ category: 'connection',
|
|
|
|
|
+ direction: 'system',
|
|
|
|
|
+ message: 'Pipe connected, waiting for auth handshake'
|
|
|
|
|
+ })
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ socket.on('data', (chunk) => {
|
|
|
|
|
+ this.handleChunk(chunk)
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ socket.on('error', (error) => {
|
|
|
|
|
+ this.connecting = false
|
|
|
|
|
+ this.emit({
|
|
|
|
|
+ level: 'error',
|
|
|
|
|
+ category: 'connection',
|
|
|
|
|
+ direction: 'system',
|
|
|
|
|
+ message: 'Pipe connection error',
|
|
|
|
|
+ detail: formatSafeConnectionError(error)
|
|
|
|
|
+ })
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ socket.on('close', (hadError) => {
|
|
|
|
|
+ const reason = this.closingReason || (hadError ? 'Pipe closed after error' : 'Pipe closed')
|
|
|
|
|
+ this.socket = undefined
|
|
|
|
|
+ this.buffer = Buffer.alloc(0)
|
|
|
|
|
+ this.connecting = false
|
|
|
|
|
+ this.authenticated = false
|
|
|
|
|
+ this.closingReason = null
|
|
|
|
|
+
|
|
|
|
|
+ if (!this.disposed) {
|
|
|
|
|
+ this.emit({
|
|
|
|
|
+ level: hadError ? 'warning' : 'info',
|
|
|
|
|
+ category: 'connection',
|
|
|
|
|
+ direction: 'system',
|
|
|
|
|
+ message: reason
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ disconnect(reason = 'Pipe disconnected by client'): void {
|
|
|
|
|
+ if (!this.socket) {
|
|
|
|
|
+ this.connecting = false
|
|
|
|
|
+ this.authenticated = false
|
|
|
|
|
+ this.buffer = Buffer.alloc(0)
|
|
|
|
|
+ this.emit({
|
|
|
|
|
+ level: 'info',
|
|
|
|
|
+ category: 'connection',
|
|
|
|
|
+ direction: 'system',
|
|
|
|
|
+ message: 'No active pipe connection'
|
|
|
|
|
+ })
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ this.closingReason = reason
|
|
|
|
|
+ this.socket.end()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ destroy(): void {
|
|
|
|
|
+ this.disposed = true
|
|
|
|
|
+ this.connecting = false
|
|
|
|
|
+ this.authenticated = false
|
|
|
|
|
+ this.buffer = Buffer.alloc(0)
|
|
|
|
|
+ this.closingReason = null
|
|
|
|
|
+
|
|
|
|
|
+ if (this.socket) {
|
|
|
|
|
+ this.socket.removeAllListeners()
|
|
|
|
|
+ this.socket.destroy()
|
|
|
|
|
+ this.socket = undefined
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private isConnected(): boolean {
|
|
|
|
|
+ return !!this.socket && !this.socket.destroyed
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private handleChunk(chunk: Buffer): void {
|
|
|
|
|
+ this.buffer = Buffer.concat([this.buffer, chunk])
|
|
|
|
|
+
|
|
|
|
|
+ while (this.buffer.length >= 6) {
|
|
|
|
|
+ const msgId = this.buffer.readUInt16BE(0)
|
|
|
|
|
+ const payloadLength = this.buffer.readUInt32BE(2)
|
|
|
|
|
+
|
|
|
|
|
+ if (payloadLength > MAX_FRAME_SIZE) {
|
|
|
|
|
+ this.emit({
|
|
|
|
|
+ level: 'error',
|
|
|
|
|
+ category: 'message',
|
|
|
|
|
+ direction: 'recv',
|
|
|
|
|
+ message: 'Frame length is too large, disconnecting',
|
|
|
|
|
+ detail: `msgId=0x${msgId.toString(16).toUpperCase().padStart(4, '0')}, length=${payloadLength}`
|
|
|
|
|
+ })
|
|
|
|
|
+ this.disconnect('Frame length exceeded limit')
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const frameLength = 6 + payloadLength
|
|
|
|
|
+ if (this.buffer.length < frameLength) {
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const payload = Buffer.from(this.buffer.subarray(6, frameLength))
|
|
|
|
|
+ this.buffer = this.buffer.subarray(frameLength)
|
|
|
|
|
+ this.handleFrame(msgId, payload)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private handleFrame(msgId: number, payload: Buffer): void {
|
|
|
|
|
+ if (!this.authenticated && ![0x0001, 0x0003, 0x0004, 0x0005, 0x0006].includes(msgId)) {
|
|
|
|
|
+ this.emit({
|
|
|
|
|
+ level: 'warning',
|
|
|
|
|
+ category: 'auth',
|
|
|
|
|
+ direction: 'recv',
|
|
|
|
|
+ message: `Received ${getMessageName(msgId)} before auth completed`,
|
|
|
|
|
+ msgId,
|
|
|
|
|
+ msgName: getMessageName(msgId),
|
|
|
|
|
+ payloadLength: payload.length,
|
|
|
|
|
+ payloadHex: toHexPreview(payload)
|
|
|
|
|
+ })
|
|
|
|
|
+ this.disconnect('Unexpected frame received before auth')
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ switch (msgId) {
|
|
|
|
|
+ case 0x0001:
|
|
|
|
|
+ this.handleAuthChallenge(payload)
|
|
|
|
|
+ return
|
|
|
|
|
+ case 0x0003:
|
|
|
|
|
+ this.authenticated = true
|
|
|
|
|
+ this.emit({
|
|
|
|
|
+ level: 'success',
|
|
|
|
|
+ category: 'auth',
|
|
|
|
|
+ direction: 'recv',
|
|
|
|
|
+ message: 'Auth success',
|
|
|
|
|
+ msgId,
|
|
|
|
|
+ msgName: getMessageName(msgId),
|
|
|
|
|
+ payloadLength: payload.length
|
|
|
|
|
+ })
|
|
|
|
|
+ return
|
|
|
|
|
+ case 0x0004:
|
|
|
|
|
+ this.emit({
|
|
|
|
|
+ level: 'error',
|
|
|
|
|
+ category: 'auth',
|
|
|
|
|
+ direction: 'recv',
|
|
|
|
|
+ message: 'Auth failed',
|
|
|
|
|
+ msgId,
|
|
|
|
|
+ msgName: getMessageName(msgId),
|
|
|
|
|
+ payloadLength: payload.length
|
|
|
|
|
+ })
|
|
|
|
|
+ this.disconnect('Auth failed')
|
|
|
|
|
+ return
|
|
|
|
|
+ case 0x0005:
|
|
|
|
|
+ this.emit({
|
|
|
|
|
+ level: 'warning',
|
|
|
|
|
+ category: 'auth',
|
|
|
|
|
+ direction: 'recv',
|
|
|
|
|
+ message: 'Auth timeout',
|
|
|
|
|
+ msgId,
|
|
|
|
|
+ msgName: getMessageName(msgId),
|
|
|
|
|
+ payloadLength: payload.length
|
|
|
|
|
+ })
|
|
|
|
|
+ this.disconnect('Auth timeout')
|
|
|
|
|
+ return
|
|
|
|
|
+ default:
|
|
|
|
|
+ this.emit(this.describeMessage(msgId, payload))
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private handleAuthChallenge(payload: Buffer): void {
|
|
|
|
|
+ this.emit({
|
|
|
|
|
+ level: 'info',
|
|
|
|
|
+ category: 'auth',
|
|
|
|
|
+ direction: 'recv',
|
|
|
|
|
+ message: 'Received auth challenge',
|
|
|
|
|
+ detail:
|
|
|
|
|
+ payload.length === 16 ? payload.toString('hex').toUpperCase() : 'Invalid challenge length',
|
|
|
|
|
+ msgId: 0x0001,
|
|
|
|
|
+ msgName: getMessageName(0x0001),
|
|
|
|
|
+ payloadLength: payload.length,
|
|
|
|
|
+ payloadHex: toHexPreview(payload)
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ if (payload.length !== 16) {
|
|
|
|
|
+ this.emit({
|
|
|
|
|
+ level: 'error',
|
|
|
|
|
+ category: 'auth',
|
|
|
|
|
+ direction: 'system',
|
|
|
|
|
+ message: 'Challenge length must be 16 bytes'
|
|
|
|
|
+ })
|
|
|
|
|
+ this.disconnect('Invalid auth challenge length')
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const response = simpleCryptoEncrypt(payload, payload)
|
|
|
|
|
+
|
|
|
|
|
+ this.emit({
|
|
|
|
|
+ level: 'info',
|
|
|
|
|
+ category: 'auth',
|
|
|
|
|
+ direction: 'send',
|
|
|
|
|
+ message: 'Sending auth response',
|
|
|
|
|
+ detail: response.toString('hex').toUpperCase(),
|
|
|
|
|
+ msgId: 0x0002,
|
|
|
|
|
+ msgName: getMessageName(0x0002),
|
|
|
|
|
+ payloadLength: response.length,
|
|
|
|
|
+ payloadHex: toHexPreview(response)
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ this.writeFrame(0x0002, response)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private writeFrame(msgId: number, payload = Buffer.alloc(0)): void {
|
|
|
|
|
+ if (!this.socket || this.socket.destroyed) {
|
|
|
|
|
+ this.emit({
|
|
|
|
|
+ level: 'warning',
|
|
|
|
|
+ category: 'connection',
|
|
|
|
|
+ direction: 'system',
|
|
|
|
|
+ message: 'Cannot write frame, pipe is not connected'
|
|
|
|
|
+ })
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const frame = Buffer.allocUnsafe(6 + payload.length)
|
|
|
|
|
+ frame.writeUInt16BE(msgId, 0)
|
|
|
|
|
+ frame.writeUInt32BE(payload.length, 2)
|
|
|
|
|
+ payload.copy(frame, 6)
|
|
|
|
|
+
|
|
|
|
|
+ this.emit({
|
|
|
|
|
+ level: 'info',
|
|
|
|
|
+ category: msgId === 0x0002 ? 'auth' : 'message',
|
|
|
|
|
+ direction: 'send',
|
|
|
|
|
+ message: `Queue frame ${getMessageName(msgId)}`,
|
|
|
|
|
+ detail: frame.toString('hex').toUpperCase(),
|
|
|
|
|
+ msgId,
|
|
|
|
|
+ msgName: getMessageName(msgId),
|
|
|
|
|
+ payloadLength: payload.length,
|
|
|
|
|
+ payloadHex: toHexPreview(payload)
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ this.socket.write(frame, (error) => {
|
|
|
|
|
+ if (error) {
|
|
|
|
|
+ this.emit({
|
|
|
|
|
+ level: 'error',
|
|
|
|
|
+ category: msgId === 0x0002 ? 'auth' : 'message',
|
|
|
|
|
+ direction: 'send',
|
|
|
|
|
+ message: `Write failed for ${getMessageName(msgId)}`,
|
|
|
|
|
+ detail: formatSafeConnectionError(error),
|
|
|
|
|
+ msgId,
|
|
|
|
|
+ msgName: getMessageName(msgId),
|
|
|
|
|
+ payloadLength: payload.length,
|
|
|
|
|
+ payloadHex: toHexPreview(payload)
|
|
|
|
|
+ })
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ this.emit({
|
|
|
|
|
+ level: 'success',
|
|
|
|
|
+ category: msgId === 0x0002 ? 'auth' : 'message',
|
|
|
|
|
+ direction: 'send',
|
|
|
|
|
+ message: `Frame sent ${getMessageName(msgId)}`,
|
|
|
|
|
+ msgId,
|
|
|
|
|
+ msgName: getMessageName(msgId),
|
|
|
|
|
+ payloadLength: payload.length,
|
|
|
|
|
+ payloadHex: toHexPreview(payload)
|
|
|
|
|
+ })
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private describeMessage(msgId: number, payload: Buffer): PipeEventPayload {
|
|
|
|
|
+ const msgName = getMessageName(msgId)
|
|
|
|
|
+ const hex = toHexPreview(payload)
|
|
|
|
|
+ const base: PipeEventPayload = {
|
|
|
|
|
+ level: 'info',
|
|
|
|
|
+ category: 'message',
|
|
|
|
|
+ direction: 'recv',
|
|
|
|
|
+ message: `Received ${msgName}`,
|
|
|
|
|
+ msgId,
|
|
|
|
|
+ msgName,
|
|
|
|
|
+ payloadLength: payload.length,
|
|
|
|
|
+ payloadHex: hex
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ switch (msgId) {
|
|
|
|
|
+ case 0x0006:
|
|
|
|
|
+ return {
|
|
|
|
|
+ ...base,
|
|
|
|
|
+ message: 'Received heartbeat'
|
|
|
|
|
+ }
|
|
|
|
|
+ case 0x1004: {
|
|
|
|
|
+ const text = tryDecodeUtf8(payload)
|
|
|
|
|
+ return {
|
|
|
|
|
+ ...base,
|
|
|
|
|
+ message: 'Received status response',
|
|
|
|
|
+ detail: text || hex,
|
|
|
|
|
+ payloadText: text
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ case 0x2001: {
|
|
|
|
|
+ const text = payload.toString('utf8')
|
|
|
|
|
+ return {
|
|
|
|
|
+ ...base,
|
|
|
|
|
+ message: 'Received text message',
|
|
|
|
|
+ detail: text,
|
|
|
|
|
+ payloadText: text
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ case 0x2002:
|
|
|
|
|
+ return {
|
|
|
|
|
+ ...base,
|
|
|
|
|
+ message: `Received binary message (${payload.length} bytes)`,
|
|
|
|
|
+ detail: hex
|
|
|
|
|
+ }
|
|
|
|
|
+ case 0x2003:
|
|
|
|
|
+ return describeFileMessage(base, payload)
|
|
|
|
|
+ default: {
|
|
|
|
|
+ const text = tryDecodeUtf8(payload)
|
|
|
|
|
+ return {
|
|
|
|
|
+ ...base,
|
|
|
|
|
+ message: `Received ${msgName} (0x${msgId.toString(16).toUpperCase().padStart(4, '0')})`,
|
|
|
|
|
+ detail: text || hex,
|
|
|
|
|
+ payloadText: text
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private emit(event: PipeEventPayload): void {
|
|
|
|
|
+ if (this.window.isDestroyed()) return
|
|
|
|
|
+
|
|
|
|
|
+ this.window.webContents.send(PIPE_EVENT_CHANNEL, {
|
|
|
|
|
+ ...event,
|
|
|
|
|
+ timestamp: new Date().toISOString(),
|
|
|
|
|
+ connected: this.isConnected(),
|
|
|
|
|
+ authenticated: this.authenticated
|
|
|
|
|
+ } satisfies PipeEvent)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** Avoid leaking named-pipe path / host into UI logs (Node error messages often include full path). */
|
|
|
|
|
+function redactPipeAddress(message: string): string {
|
|
|
|
|
+ return message
|
|
|
|
|
+ .replace(/\\\\\.\\pipe\\[^\s"'<>]+/gi, '[pipe]')
|
|
|
|
|
+ .replace(/www\.sv-elec\.com/gi, '[endpoint]')
|
|
|
|
|
+ .replace(/\s+/g, ' ')
|
|
|
|
|
+ .trim()
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function formatSafeConnectionError(error: Error): string | undefined {
|
|
|
|
|
+ const code = (error as NodeJS.ErrnoException).code
|
|
|
|
|
+ const msg = redactPipeAddress(error.message || '')
|
|
|
|
|
+ if (code && msg) return `${code}: ${msg}`
|
|
|
|
|
+ if (msg) return msg
|
|
|
|
|
+ if (code) return code
|
|
|
|
|
+ return undefined
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function getMessageName(msgId: number): string {
|
|
|
|
|
+ return MESSAGE_NAMES[msgId] || `UNKNOWN_0x${msgId.toString(16).toUpperCase().padStart(4, '0')}`
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function toHexPreview(payload: Buffer, limit = 64): string | undefined {
|
|
|
|
|
+ if (!payload.length) return undefined
|
|
|
|
|
+ const preview = payload.subarray(0, limit).toString('hex').toUpperCase()
|
|
|
|
|
+ return payload.length > limit ? `${preview}...` : preview
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function tryDecodeUtf8(payload: Buffer): string | undefined {
|
|
|
|
|
+ if (!payload.length) return undefined
|
|
|
|
|
+ const text = payload.toString('utf8')
|
|
|
|
|
+ return /^[\u0009\u000A\u000D\u0020-\u007E\u4E00-\u9FFF]*$/u.test(text) ? text : undefined
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function describeFileMessage(base: PipeEventPayload, payload: Buffer): PipeEventPayload {
|
|
|
|
|
+ if (payload.length < 2) {
|
|
|
|
|
+ return {
|
|
|
|
|
+ ...base,
|
|
|
|
|
+ level: 'warning',
|
|
|
|
|
+ message: 'Received malformed file message',
|
|
|
|
|
+ detail: base.payloadHex
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const fileNameLength = payload.readUInt16LE(0)
|
|
|
|
|
+ const fileNameEnd = 2 + fileNameLength
|
|
|
|
|
+
|
|
|
|
|
+ if (payload.length < fileNameEnd) {
|
|
|
|
|
+ return {
|
|
|
|
|
+ ...base,
|
|
|
|
|
+ level: 'warning',
|
|
|
|
|
+ message: 'Received invalid file header length',
|
|
|
|
|
+ detail: base.payloadHex
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const fileName = payload.subarray(2, fileNameEnd).toString('utf8')
|
|
|
|
|
+ const fileDataLength = payload.length - fileNameEnd
|
|
|
|
|
+
|
|
|
|
|
+ return {
|
|
|
|
|
+ ...base,
|
|
|
|
|
+ message: `Received file message: ${fileName}`,
|
|
|
|
|
+ detail: `file=${fileName}\nsize=${fileDataLength} bytes`,
|
|
|
|
|
+ payloadText: fileName
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function rotateLeft32(value: bigint, shift: number): bigint {
|
|
|
|
|
+ const shiftBigInt = BigInt(shift)
|
|
|
|
|
+ return ((value << shiftBigInt) | (value >> BigInt(32 - shift))) & UINT32_MASK
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function rotateRight32(value: bigint, shift: number): bigint {
|
|
|
|
|
+ const shiftBigInt = BigInt(shift)
|
|
|
|
|
+ return ((value >> shiftBigInt) | (value << BigInt(32 - shift))) & UINT32_MASK
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function seedToKey32(seed: number, mask = DEFAULT_MASK): number {
|
|
|
|
|
+ const maskValue = mask === DEFAULT_MASK ? DEFAULT_MASK_BIGINT : BigInt(mask >>> 0)
|
|
|
|
|
+ let value = BigInt(seed >>> 0)
|
|
|
|
|
+
|
|
|
|
|
+ if (value === 0n) {
|
|
|
|
|
+ return 0
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Matches Qt SimpleCrypto::seedToKey / seedToKey16 (8 rounds, bit28 => <<4^mask, else ~seed)
|
|
|
|
|
+ for (let index = 0; index < 8; index += 1) {
|
|
|
|
|
+ if ((value & 0x80000000n) !== 0n) {
|
|
|
|
|
+ value = ((value << 1n) ^ maskValue) & UINT32_MASK
|
|
|
|
|
+ } else if ((value & 0x40000000n) !== 0n) {
|
|
|
|
|
+ value = ((value << 2n) ^ maskValue) & UINT32_MASK
|
|
|
|
|
+ } else if ((value & 0x20000000n) !== 0n) {
|
|
|
|
|
+ value = ((value << 3n) ^ maskValue) & UINT32_MASK
|
|
|
|
|
+ } else if ((value & 0x10000000n) !== 0n) {
|
|
|
|
|
+ value = ((value << 4n) ^ maskValue) & UINT32_MASK
|
|
|
|
|
+ } else {
|
|
|
|
|
+ value = ~value & UINT32_MASK
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const result =
|
|
|
|
|
+ ((value << 3n) ^ (maskValue >> 2n) ^ ((value >> 2n) ^ ((maskValue << 3n) & UINT32_MASK))) &
|
|
|
|
|
+ UINT32_MASK
|
|
|
|
|
+
|
|
|
|
|
+ return Number(result)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function simpleCryptoEncrypt(
|
|
|
|
|
+ data: Buffer,
|
|
|
|
|
+ seed16: Buffer,
|
|
|
|
|
+ mask = DEFAULT_MASK
|
|
|
|
|
+): Buffer<ArrayBuffer> {
|
|
|
|
|
+ const keys = [0, 1, 2, 3].map((index) => seedToKey32(seed16.readUInt32BE(index * 4), mask))
|
|
|
|
|
+ const result = Buffer.from(data)
|
|
|
|
|
+ const fullBlocks = Math.floor(result.length / 4)
|
|
|
|
|
+
|
|
|
|
|
+ for (let index = 0; index < fullBlocks; index += 1) {
|
|
|
|
|
+ let block = BigInt(result.readUInt32BE(index * 4))
|
|
|
|
|
+ const key = BigInt(keys[index % 4] >>> 0)
|
|
|
|
|
+
|
|
|
|
|
+ block = (block ^ key) & UINT32_MASK
|
|
|
|
|
+ block = (rotateLeft32(block, 3) ^ rotateRight32(key, 5)) & UINT32_MASK
|
|
|
|
|
+ block = (rotateRight32(block, 2) ^ rotateLeft32(key, 7)) & UINT32_MASK
|
|
|
|
|
+ block = (block ^ key) & UINT32_MASK
|
|
|
|
|
+
|
|
|
|
|
+ result.writeUInt32BE(Number(block), index * 4)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const tail = result.length % 4
|
|
|
|
|
+ if (tail > 0) {
|
|
|
|
|
+ const start = fullBlocks * 4
|
|
|
|
|
+ let block = 0n
|
|
|
|
|
+
|
|
|
|
|
+ for (let index = 0; index < tail; index += 1) {
|
|
|
|
|
+ block |= BigInt(result[start + index]) << BigInt(24 - index * 8)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ block = (block ^ BigInt(keys[0] >>> 0)) & UINT32_MASK
|
|
|
|
|
+
|
|
|
|
|
+ for (let index = 0; index < tail; index += 1) {
|
|
|
|
|
+ result[start + index] = Number((block >> BigInt(24 - index * 8)) & 0xffn)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return result
|
|
|
|
|
+}
|