Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allows connections without encryption #105

Merged
merged 4 commits into from
Dec 22, 2023
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
3 changes: 1 addition & 2 deletions .github/workflows/lint-typecheck-test-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,7 @@ jobs:
node: ['18.x', '20.x']
os:
- ubuntu-latest
# FIXME: Some tests are failing on macOS because of unhandled exceptions.
# - macOS-latest
- macOS-latest

steps:
- name: Checkout repo
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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',

Expand Down
Binary file added docs/LG_RS232_IP_legacy.pdf
Binary file not shown.
221 changes: 117 additions & 104 deletions src/classes/LGEncryption.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 += ' ';
}

Expand All @@ -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',
Expand All @@ -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());
}
}
16 changes: 9 additions & 7 deletions src/classes/LGTV.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
Expand All @@ -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<void> {
Expand Down
Loading
Loading