homebridge-homeconnect
Version:
A Homebridge plugin that connects Home Connect appliances to Apple HomeKit
141 lines • 5.81 kB
JavaScript
// Homebridge plugin for Home Connect home appliances
// Copyright © 2025 Alexander Thoukydides
import { assertIsDefined, formatList, MS } from './utils.js';
import { checkers } from './ti/token-types.js';
// Mapping of haId values to their names
const applianceIds = new Map();
let revealApplianceIds = false;
const HAID_PATTERN = '\\w+-\\w+-[0-9A-F]{12}|\\d{18}';
const HAID_REGEX = new RegExp(`^${HAID_PATTERN}$`);
// Regular expressions for different types of sensitive data
const filters = [
[maskClientId, /\b[0-9A-F]{64}\b/g],
[maskRefreshToken, /\b[\w+/]{64,}(={1,2}|(%3D){1,2}|\b)/g],
[maskAccessToken, /\b[\w-]+\.[\w-]+\.[\w-]+\b/g],
[maskApplianceId, new RegExp(`\\b${HAID_PATTERN}\\b`, 'g')]
];
// A logger with filtering and support for an additional prefix
export class PrefixLogger {
// Create a new logger
constructor(logger, prefix) {
this.logger = logger;
this.prefix = prefix;
// Log level used for debug messages
this.debugLevel = "debug" /* LogLevel.DEBUG */;
}
// Wrappers around the standard Logger methods
info(message) { this.log("info" /* LogLevel.INFO */, message); }
success(message) { this.log("success" /* LogLevel.SUCCESS */, message); }
warn(message) { this.log("warn" /* LogLevel.WARN */, message); }
error(message) { this.log("error" /* LogLevel.ERROR */, message); }
debug(message) { this.log("debug" /* LogLevel.DEBUG */, message); }
log(level, message) {
// Allow debug messages to be logged as a different level
if (level === "debug" /* LogLevel.DEBUG */)
level = this.debugLevel;
// Mask any sensitive data within the log message
message = PrefixLogger.filterSensitive(message);
// Log each line of the message
const prefix = this.prefix?.length ? `[${this.prefix}] ` : '';
for (const line of message.split('\n'))
this.logger.log(level, prefix + line);
}
// Log all DEBUG messages as INFO to avoid being dropped by Homebridge
logDebugAsInfo() {
this.debugLevel = "info" /* LogLevel.INFO */;
}
// Do not redact haId values in log messages (global setting)
static set logApplianceIds(log) { revealApplianceIds = log; }
static get logApplianceIds() { return revealApplianceIds; }
// Attempt to filter sensitive data within the log message
static filterSensitive(message) {
// Exception for links related to authorisation
if (message.includes('https://developer.home-connect.com/'))
return message;
// Otherwise replace anything that should probably be protected
return filters.reduce((message, [filter, regex]) => message.replace(regex, filter), message);
}
// Add an haId to filter
static addApplianceId(haId, name) {
if (!applianceIds.has(haId) && !HAID_REGEX.test(haId)) {
// haId was not matched by the standard regexp, so add its own
filters.push([maskApplianceId, new RegExp(`\\b${haId}\\b`, 'g')]);
}
applianceIds.set(haId, name);
}
}
// Mask a Home Connect Client ID
function maskClientId(clientId) {
return maskToken('CLIENT_ID', clientId);
}
// Mask a Home Connect refresh token or authorisation code
function maskRefreshToken(token) {
try {
token = token.replace(/%3D/g, '=');
const decoded = Buffer.from(token, 'base64').toString();
const json = JSON.parse(decoded);
if (checkers.SimulatorToken.test(json) && isUUID(json.token)) {
return maskToken('SIMULATOR_TOKEN', token);
}
else if (checkers.RefreshToken.test(json) && isUUID(json.token)) {
return maskToken('REFRESH_TOKEN', token);
}
return token;
}
catch {
return token;
}
}
// Mask a Home Connect access token
function maskAccessToken(token) {
try {
const parts = token.split('.').map(part => decodeBase64URL(part));
assertIsDefined(parts[0]);
assertIsDefined(parts[1]);
const header = JSON.parse(parts[0]);
const payload = JSON.parse(parts[1]);
if (checkers.AccessTokenHeader.test(header)
&& checkers.AccessTokenPayload.test(payload)) {
return maskToken('ACCESS_TOKEN', token, {
issued: new Date(payload.iat * MS).toISOString(),
expires: new Date(payload.exp * MS).toISOString(),
scopes: payload.scope.join('/')
});
}
return maskToken('JSON_WEB_TOKEN', token);
}
catch {
return token;
}
}
// Mask a Home Connect appliance ID
function maskApplianceId(haId) {
if (revealApplianceIds)
return haId;
const name = applianceIds.get(haId);
if (name)
return `<HA_ID ${name}>`;
// Fallback if the haId is not already known
const match = /-([^-]+)-/.exec(haId);
return `<HA_ID ${match?.[1] ?? haId.substring(0, -12)}...>`;
}
// Mask a token, leaving just the first and final few characters
function maskToken(type, token, details = {}) {
let masked = `${token.slice(0, 4)}...${token.slice(-8)}`;
const parts = Object.entries(details).map(([key, value]) => `${key}=${value}`);
if (parts.length)
masked += ` (${formatList(parts)})`;
return `<${type}: ${masked}>`;
}
// Decode a Base64URL encoded string
function decodeBase64URL(base64url) {
const paddedLength = base64url.length + (4 - base64url.length % 4) % 4;
const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/')
.padEnd(paddedLength, '=');
return Buffer.from(base64, 'base64').toString();
}
// Test whether a string is in UUID canonical format
function isUUID(uuid) {
return /^\w{8}-\w{4}-\w{4}-\w{4}-\w{12}$/.test(uuid);
}
//# sourceMappingURL=logger.js.map