diff --git a/README.md b/README.md index 5e9c773..87d0207 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ This is a JS library that implements TCP network control for LG TVs manufactured since 2018. It utilizes encryption rules based on a guide found on the internet. +A non-encrypted mode is provided for older models, but hasn't been tested. This is not provided by LG, and it is not a complete implementation for every TV model. @@ -85,7 +86,8 @@ const lgtv = new LGTV( '1a:2b:3c:4d:5e:6f', /** - * Encryption Keycode, as generated during "Setting Up the TV" above + * Encryption Keycode, as generated during "Setting Up the TV" above. + * If not provided, uses clear text, but is required by most models. */ 'KEY1C0DE', diff --git a/docs/LG_RS232_IP_legacy.pdf b/docs/LG_RS232_IP_legacy.pdf new file mode 100644 index 0000000..38d4d26 Binary files /dev/null and b/docs/LG_RS232_IP_legacy.pdf differ diff --git a/src/classes/LGEncryption.ts b/src/classes/LGEncryption.ts index e97130c..1a4a1da 100644 --- a/src/classes/LGEncryption.ts +++ b/src/classes/LGEncryption.ts @@ -15,107 +15,127 @@ export interface EncryptionSettings { responseTerminator: string; } -function assertSettings(settings: EncryptionSettings) { - assert( - typeof settings === 'object' && settings !== null, - 'settings must be an object', - ); - - const { - encryptionIvLength, - encryptionKeyDigest, - encryptionKeyIterations, - encryptionKeyLength, - encryptionKeySalt, - keycodeFormat, - messageBlockSize, - messageTerminator, - responseTerminator, - } = settings; - assert( - typeof encryptionIvLength === 'number' && encryptionIvLength > 0, - 'settings.encryptionIvLength must be a number greater than 0', - ); - assert( - typeof encryptionKeyDigest === 'string' && encryptionKeyDigest.length > 0, - 'settings.encryptionKeyDigest must be a string with length greater than 0', - ); - assert( - typeof encryptionKeyIterations === 'number' && encryptionKeyIterations > 0, - 'settings.encryptionKLeyIterations must be a number greater than 0', - ); - assert( - typeof encryptionKeyLength === 'number' && encryptionKeyLength > 0, - 'settings.encryptionKeyLength must be a number greater than 0', - ); - assert( - Array.isArray(encryptionKeySalt) && - encryptionKeySalt.some((data) => typeof data === 'number' && data > 0), - 'settings.encryptionKeySalt must be an array of numbers with length greater than 0', - ); - assert( - keycodeFormat instanceof RegExp, - 'settings.keycodeFormat must be an instance of RegExp', - ); - assert( - typeof messageBlockSize === 'number' && messageBlockSize > 0, - 'settings.messageBlockSize must be a number greater than 0', - ); - assert( - typeof messageTerminator === 'string' && messageTerminator.length > 0, - 'settings.messageTerminator must be a string with length greater than 0', - ); - assert( - typeof responseTerminator === 'string' && responseTerminator.length > 0, - 'settings.responseTerminator must be a string with length greater than 0', - ); -} +export class LGEncoder { + constructor(protected settings: EncryptionSettings = DefaultSettings) { + assert( + typeof settings === 'object' && settings !== null, + 'settings must be an object', + ); -function deriveKey(keycode: string, settings = DefaultSettings) { - assertSettings(settings); - assert(typeof keycode === 'string', 'keycode must be a string'); - assert(settings.keycodeFormat.test(keycode), 'keycode format is invalid'); - - return pbkdf2Sync( - keycode, - Buffer.from(settings.encryptionKeySalt), - settings.encryptionKeyIterations, - settings.encryptionKeyLength, - settings.encryptionKeyDigest, - ); -} + const { messageBlockSize, messageTerminator, responseTerminator } = + settings; + assert( + typeof messageBlockSize === 'number' && messageBlockSize > 0, + 'settings.messageBlockSize must be a number greater than 0', + ); + assert( + typeof messageTerminator === 'string' && messageTerminator.length > 0, + 'settings.messageTerminator must be a string with length greater than 0', + ); + assert( + typeof responseTerminator === 'string' && responseTerminator.length > 0, + 'settings.responseTerminator must be a string with length greater than 0', + ); + } -function generateRandomIv(length = DefaultSettings.encryptionIvLength) { - assert(typeof length === 'number', 'length must be a number'); - assert(length > 0, 'length must be greater than 0'); + protected terminateMessage(message: string): string { + const { messageTerminator } = this.settings; + assert(typeof message === 'string', 'message must be a string'); + assert(message.length > 0, 'message must have a length greater than 0'); + assert( + !message.includes(messageTerminator), + 'message must not include the message terminator character', + ); + return message + messageTerminator; + } + + protected stripEnd(message: string): string { + const { responseTerminator } = this.settings; + return message.substring(0, message.indexOf(responseTerminator)); + } + + encode(message: string): Buffer { + return Buffer.from(this.terminateMessage(message), 'utf8'); + } - const iv = Buffer.alloc(length, 0); - for (let i = 0; i < length; i++) { - iv[i] = Math.floor(Math.random() * 255); + decode(data: Buffer): string { + return this.stripEnd(data.toString()); } - return iv; } -export class LGEncryption { +export class LGEncryption extends LGEncoder { private derivedKey: Buffer; - constructor(keycode: string, private settings = DefaultSettings) { - assertSettings(settings); - this.derivedKey = deriveKey(keycode, settings); - } + constructor(keycode: string, settings: EncryptionSettings = DefaultSettings) { + super(settings); + + const { + encryptionIvLength, + encryptionKeyDigest, + encryptionKeyIterations, + encryptionKeyLength, + encryptionKeySalt, + keycodeFormat, + } = settings; + assert( + typeof encryptionIvLength === 'number' && encryptionIvLength > 0, + 'settings.encryptionIvLength must be a number greater than 0', + ); + assert( + typeof encryptionKeyDigest === 'string' && encryptionKeyDigest.length > 0, + 'settings.encryptionKeyDigest must be a string with length greater than 0', + ); + assert( + typeof encryptionKeyIterations === 'number' && + encryptionKeyIterations > 0, + 'settings.encryptionKLeyIterations must be a number greater than 0', + ); + assert( + typeof encryptionKeyLength === 'number' && encryptionKeyLength > 0, + 'settings.encryptionKeyLength must be a number greater than 0', + ); + assert( + Array.isArray(encryptionKeySalt) && + encryptionKeySalt.some((data) => typeof data === 'number' && data > 0), + 'settings.encryptionKeySalt must be an array of numbers with length greater than 0', + ); + assert( + keycodeFormat instanceof RegExp, + 'settings.keycodeFormat must be an instance of RegExp', + ); - private prepareMessage(message: string) { - assert(typeof message === 'string', 'message must be a string'); - assert(message.length > 0, 'message must have a length greater than 0'); + this.derivedKey = this.deriveKey(keycode); + } - const { messageTerminator, messageBlockSize } = this.settings; + private deriveKey(keycode: string) { + assert(typeof keycode === 'string', 'keycode must be a string'); assert( - !message.includes(messageTerminator), - 'message must not include the message terminator character', + this.settings.keycodeFormat.test(keycode), + 'keycode format is invalid', ); - let newMessage = message + messageTerminator; - if (newMessage.length % messageBlockSize === 0) { + return pbkdf2Sync( + keycode, + Buffer.from(this.settings.encryptionKeySalt), + this.settings.encryptionKeyIterations, + this.settings.encryptionKeyLength, + this.settings.encryptionKeyDigest, + ); + } + + private generateRandomIv() { + const { encryptionIvLength } = this.settings; + const iv = Buffer.alloc(encryptionIvLength, 0); + for (let i = 0; i < encryptionIvLength; i++) { + iv[i] = Math.floor(Math.random() * 255); + } + return iv; + } + + protected padMessage(message: string): string { + const { messageBlockSize } = this.settings; + let newMessage = message; + if (message.length % messageBlockSize === 0) { newMessage += ' '; } @@ -124,13 +144,12 @@ export class LGEncryption { const padding = messageBlockSize - remainder; newMessage += String.fromCharCode(padding).repeat(padding); } - return newMessage; } - encrypt(message: string) { - const iv = generateRandomIv(this.settings.encryptionKeyLength); - const preparedMessage = this.prepareMessage(message); + encode(message: string): Buffer { + const iv = this.generateRandomIv(); + const paddedMessage = this.padMessage(this.terminateMessage(message)); const ecbCypher = createCipheriv( 'aes-128-ecb', @@ -140,30 +159,24 @@ export class LGEncryption { const ivEnc = ecbCypher.update(iv); const cbcCypher = createCipheriv('aes-128-cbc', this.derivedKey, iv); - const dataEnc = cbcCypher.update(preparedMessage, 'utf8'); + const dataEnc = cbcCypher.update(paddedMessage); return Buffer.concat([ivEnc, dataEnc]); } - decrypt(cipher: Buffer) { + decode(cipher: Buffer): string { + const { encryptionKeyLength } = this.settings; const ecbDecypher = createDecipheriv( 'aes-128-ecb', this.derivedKey, Buffer.alloc(0), ); ecbDecypher.setAutoPadding(false); - const iv = ecbDecypher.update( - cipher.slice(0, this.settings.encryptionKeyLength), - ); + const iv = ecbDecypher.update(cipher.slice(0, encryptionKeyLength)); const cbcDecypher = createDecipheriv('aes-128-cbc', this.derivedKey, iv); cbcDecypher.setAutoPadding(false); - const decrypted = cbcDecypher - .update(cipher.slice(this.settings.encryptionKeyLength)) - .toString(); - return decrypted.substring( - 0, - decrypted.indexOf(this.settings.responseTerminator), - ); + const decrypted = cbcDecypher.update(cipher.slice(encryptionKeyLength)); + return this.stripEnd(decrypted.toString()); } } diff --git a/src/classes/LGTV.ts b/src/classes/LGTV.ts index db7e3a0..53d304f 100644 --- a/src/classes/LGTV.ts +++ b/src/classes/LGTV.ts @@ -9,7 +9,7 @@ import { PictureModes, ScreenMuteModes, } from '../constants/TV.js'; -import { LGEncryption } from './LGEncryption.js'; +import { LGEncoder, LGEncryption } from './LGEncryption.js'; import { TinySocket } from './TinySocket.js'; export class ResponseParseError extends Error {} @@ -20,23 +20,25 @@ function throwIfNotOK(response: string) { } export class LGTV { - encryption: LGEncryption; + encoder: LGEncoder; socket: TinySocket; constructor( host: string, macAddress: string | null, - keycode: string, + keycode: string | null, settings = DefaultSettings, ) { this.socket = new TinySocket(host, macAddress, settings); - this.encryption = new LGEncryption(keycode, settings); + this.encoder = keycode + ? new LGEncryption(keycode, settings) + : new LGEncoder(settings); } private async sendCommand(command: string) { - const encryptedData = this.encryption.encrypt(command); - const encryptedResponse = await this.socket.sendReceive(encryptedData); - return this.encryption.decrypt(encryptedResponse); + const request = this.encoder.encode(command); + const response = await this.socket.sendReceive(request); + return this.encoder.decode(response); } async connect(): Promise { diff --git a/test/LGEncryption.test.ts b/test/LGEncryption.test.ts index e0a775f..d2a380c 100644 --- a/test/LGEncryption.test.ts +++ b/test/LGEncryption.test.ts @@ -1,8 +1,33 @@ import { describe, expect, it, vi } from 'vitest'; -import { LGEncryption } from '../src/classes/LGEncryption.js'; +import { LGEncoder, LGEncryption } from '../src/classes/LGEncryption.js'; import { DefaultSettings } from '../src/constants/DefaultSettings.js'; +describe('LGencoder', () => { + it('constructs with valid parameters', () => { + const encoder = new LGEncoder(DefaultSettings); + expect(encoder).toBeTruthy(); + }); + + it('encode', () => { + const exampleCommand = 'VOLUME_MUTE on'; + + const encoder = new LGEncoder(); + const encodeedData = encoder.encode(exampleCommand).toString(); + expect(encodeedData).toEqual(`${exampleCommand}\r`); + }); + + it('decode', () => { + const expectedPlainText = 'VOLUME_MUTE on'; + + const encoder = new LGEncoder(); + const decodeedData = encoder.decode( + Buffer.from(`${expectedPlainText}\nsdf34`, 'utf8'), + ); + expect(decodeedData).toEqual(expectedPlainText); + }); +}); + describe('LGEncryption', () => { it('constructs with valid parameters', () => { const encryption = new LGEncryption('1234ABCD', DefaultSettings); @@ -20,29 +45,27 @@ describe('LGEncryption', () => { new LGEncryption('1234abcd'); }).toThrowErrorMatchingInlineSnapshot(`"keycode format is invalid"`); }); -}); -describe('encrypt', () => { - it('works with data from the LG document', () => { + it('encode', () => { vi.spyOn(Math, 'random').mockImplementation(() => 0); + // This data comes from the LG document const exampleKeyCode = '12345678'; const exampleCommand = 'VOLUME_MUTE on'; const expectedEncryptedIv = 'd2b21ca0ad6486cb2056a8b815033508'; const expectedEncryptedData = 'dfe77a7de05603a59ed5316ec552fac1'; const encryption = new LGEncryption(exampleKeyCode); - const encryptedData = encryption.encrypt(exampleCommand).toString('hex'); + const encryptedData = encryption.encode(exampleCommand).toString('hex'); expect(encryptedData).toEqual( `${expectedEncryptedIv}${expectedEncryptedData}`, ); vi.mocked(Math.random).mockRestore(); }); -}); -describe('decrypt', () => { - it('works with data from the LG document', () => { + it('decode', () => { + // This data comes from the LG document const exampleKeyCode = '12345678'; const encryptedIv = 'd2b21ca0ad6486cb2056a8b815033508'; const encryptedData = 'dfe77a7de05603a59ed5316ec552fac1'; @@ -56,7 +79,7 @@ describe('decrypt', () => { ...DefaultSettings, responseTerminator: '\r', }); - const decryptedData = encryption.decrypt(exampleCipherText); + const decryptedData = encryption.decode(exampleCipherText); expect(decryptedData).toEqual(expectedPlainText); }); diff --git a/test/LGTV.test.ts b/test/LGTV.test.ts index 9d605b5..2e232f6 100644 --- a/test/LGTV.test.ts +++ b/test/LGTV.test.ts @@ -3,7 +3,7 @@ import { AddressInfo, Server } from 'net'; import { promisify } from 'util'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { LGEncryption } from '../src/classes/LGEncryption.js'; +import { LGEncoder, LGEncryption } from '../src/classes/LGEncryption.js'; import { LGTV } from '../src/classes/LGTV.js'; import { DefaultSettings } from '../src/constants/DefaultSettings.js'; import { @@ -36,10 +36,12 @@ describe('LGTV', () => { }); describe.each([ - { ipProto: 'IPv4', address: '127.0.0.1' }, - { ipProto: 'IPv6', address: '::1' }, -])('streaming commands $ipProto', ({ address }) => { - let mockCrypt: LGEncryption; + { ipProto: 'IPv4', addr: '127.0.0.1', crypt: false, ctext: 'out' }, + { ipProto: 'IPv4', addr: '127.0.0.1', crypt: true, ctext: '' }, + { ipProto: 'IPv6', addr: '::1', crypt: false, ctext: 'out' }, + { ipProto: 'IPv6', addr: '::1', crypt: true, ctext: '' }, +])('streaming commands $ipProto, with$ctext encryption', ({ addr, crypt }) => { + let mockEncode: LGEncoder; let mockServer: Server; let testSettings: typeof DefaultSettings; let testTV: LGTV; @@ -48,8 +50,8 @@ describe.each([ const mockedResponse = new Promise((resolve) => { mockServer.on('connection', (socket) => { socket.on('data', async (data) => { - expect(mockCrypt.decrypt(data)).toBe(request); - socket.write(mockCrypt.encrypt(response), resolve); + expect(mockEncode.decode(data)).toBe(request); + socket.write(mockEncode.encode(response), resolve); }); }); }); @@ -57,15 +59,23 @@ describe.each([ } beforeEach(() => { - mockCrypt = new LGEncryption(CRYPT_KEY, { - ...DefaultSettings, - messageTerminator: '\n', - responseTerminator: '\r', - }); + if (!crypt) { + mockEncode = new LGEncoder({ + ...DefaultSettings, + messageTerminator: '\n', + responseTerminator: '\r', + }); + } else { + mockEncode = new LGEncryption(CRYPT_KEY, { + ...DefaultSettings, + messageTerminator: '\n', + responseTerminator: '\r', + }); + } mockServer = new Server().listen(); const port = (mockServer.address()).port; testSettings = { ...DefaultSettings, networkPort: port }; - testTV = new LGTV(address, MAC, CRYPT_KEY, testSettings); + testTV = new LGTV(addr, MAC, crypt ? CRYPT_KEY : null, testSettings); }); afterEach(async () => { @@ -260,11 +270,11 @@ describe.each([ describe.each([ { ipProto: 'IPv4', - address: '127.0.0.1', + addr: '127.0.0.1', socketType: 'udp4' as SocketType, }, - { ipProto: 'IPv6', address: '::1', socketType: 'udp6' as SocketType }, -])('datagram commands $ipProto', ({ address, socketType }) => { + { ipProto: 'IPv6', addr: '::1', socketType: 'udp6' as SocketType }, +])('datagram commands $ipProto', ({ addr, socketType }) => { let mockSocket: DgramSocket; let testSettings: typeof DefaultSettings; let testTV: LGTV; @@ -276,9 +286,9 @@ describe.each([ testSettings = { ...DefaultSettings, networkWolPort: port, - networkWolAddress: address, + networkWolAddress: addr, }; - testTV = new LGTV(address, MAC, CRYPT_KEY, testSettings); + testTV = new LGTV(addr, MAC, CRYPT_KEY, testSettings); }); afterEach(async () => {