Skip to content

Commit

Permalink
Improved masking of secrets in logs
Browse files Browse the repository at this point in the history
  • Loading branch information
thoukydides committed Dec 4, 2024
1 parent bea1e92 commit db8e596
Show file tree
Hide file tree
Showing 2 changed files with 66 additions and 21 deletions.
66 changes: 45 additions & 21 deletions src/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@
// Copyright © 2022-2023 Alexander Thoukydides

import { Logger, LogLevel } from 'homebridge';
import { assertIsDefined } from './utils.js';

import { assertIsDefined, formatList, MS } from './utils.js';
import { checkers } from './ti/token-types.js';

// A logger with filtering and support for an additional prefix
export class PrefixLogger {
Expand All @@ -17,11 +19,11 @@ export class PrefixLogger {
) {}

// Wrappers around the standard Logger methods
info(message: string): void { this.log(LogLevel.INFO, message); }
info (message: string): void { this.log(LogLevel.INFO, message); }
success(message: string): void { this.log(LogLevel.SUCCESS, message); }
warn(message: string): void { this.log(LogLevel.WARN, message); }
error(message: string): void { this.log(LogLevel.ERROR, message); }
debug(message: string): void { this.log(LogLevel.DEBUG, message); }
warn (message: string): void { this.log(LogLevel.WARN, message); }
error (message: string): void { this.log(LogLevel.ERROR, message); }
debug (message: string): void { this.log(LogLevel.DEBUG, message); }
log(level: LogLevel, message: string): void {
// Allow debug messages to be logged as a different level
if (level === LogLevel.DEBUG) level = this.debugLevel;
Expand All @@ -41,33 +43,55 @@ export class PrefixLogger {

// Attempt to filter sensitive data within the log message
static filterSensitive(message: string): string {
const replaceJWT = (match: string): string => isJWT(match) ? '<JSON_WEB_TOKEN>' : match;
return message
// User ID and access tokens
.replace(/\b[\w-]+\.[\w-]+\.[\w-]+\b/g, replaceJWT)
// Username, password, and refresh tokens (within JSON encoded strings)
.replace(/(?<="username":\s*")[^"]+(?=")/gi, '<USERNAME>')
.replace(/(?<="password":\s*")[^"]+(?=")/gi, '<PASSWORD>')
.replace(/(?<="refreshToken":\s*")[^"]+(?=")/gi, '<REFRESH_TOKEN>');
.replace(/\w_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/, maskAPIKey)
.replace(/\b[\w-]+\.[\w-]+\.[\w-]+\b/g, maskAccessToken)
.replace(/\b[a-zA-Z0-9]{128}\b/g, maskRefreshToken)
// Refresh tokens (within JSON encoded strings)
.replace(/(?<="refreshToken":\s*")[^"]+(?=")/gi, maskRefreshToken);
}
}

// Test whether a string is a JSON Web Token (JWT)
function isJWT(jwt: string): boolean {
// Mask an Electrolux Group API Key
function maskAPIKey(apiKey: string): string {
return maskToken('API_KEY', apiKey);
}

// Mask an Electrolux Group API refresh token
function maskRefreshToken(apiKey: string): string {
return maskToken('REFRESH_TOKEN', apiKey);
}

// Mask a Home Connect access token
function maskAccessToken(token: string): string {
try {
const encodedParts = jwt.split('.');
if (encodedParts.length !== 3) return false;
const parts = encodedParts.map(part => decodeBase64URL(part));
const parts = token.split('.').map(part => decodeBase64URL(part));
assertIsDefined(parts[0]);
assertIsDefined(parts[1]);
JSON.parse(parts[0]);
JSON.parse(parts[1]);
return true;
const header: unknown = JSON.parse(parts[0]);
const payload: unknown = 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(),
scope: payload.scope
});
}
return maskToken('JSON_WEB_TOKEN', token);
} catch {
return false;
return token;
}
}

// Mask a token, leaving just the first and final few characters
function maskToken(type: string, token: string, details: Record<string, string> = {}): string {
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: string): string {
const paddedLength = base64url.length + (4 - base64url.length % 4) % 4;
Expand Down
21 changes: 21 additions & 0 deletions src/token-types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// Homebridge plugin for AEG RX 9 / Electrolux Pure i9 robot vacuum
// Copyright © 2024 Alexander Thoukydides

// Access token header (Base64 URL decoded)
export interface AccessTokenHeader {
kid: string; // Key ID (64 hexadecimal digits)
alg: string; // Signing algorithm, e.g. 'RS256'
typ: string; // Token type, e.g. 'JWT'
}

// Access token payload (Base64 URL decoded)
export interface AccessTokenPayload {
iat: number; // Issued At (seconds since epoch)
iss: string; // Issuer, e.g. 'https://api.ocp.electrolux.one/one-account-authorization'
aud: string; // Audience, e.g. 'https://api.ocp.electrolux.one'
exp: number; // Expiration Time (seconds since epoch)
sub: string; // Subject (32 hexadecimal digits)
azp: string; // Authorised Party, e.g. 'HeiOpenApi'
scope: string; // Authorised scopes, e.g. 'email offline_access'
occ: string; // Country code, e.g. 'GB'
}

0 comments on commit db8e596

Please sign in to comment.