matterbridge-lg-thinq-plugin 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/CLAUDE.md +43 -0
- package/.claude/agents/compiler.md +91 -0
- package/.claude/agents/direct-executor.md +98 -0
- package/.claude/agents/documenter.md +78 -0
- package/.claude/agents/finalizer.md +233 -0
- package/.claude/agents/implementer.md +112 -0
- package/.claude/agents/investigator.md +100 -0
- package/.claude/agents/release-manager.md +203 -0
- package/.claude/agents/reviewer.md +131 -0
- package/.claude/agents/technical-architect.md +403 -0
- package/.claude/agents/test-writer.md +159 -0
- package/.claude/agents/wiki-manager.md +147 -0
- package/.claude/instructions/agent-prompts.md +90 -0
- package/.claude/instructions/shared-rules.md +11 -0
- package/.claude/instructions/team-orchestrator-policy.md +165 -0
- package/.claude/memory.md +186 -0
- package/.claude/output-styles/engineer-manager.md +38 -0
- package/.claude/settings.json +40 -0
- package/.claude/settings.local.json +30 -0
- package/.claude/skills/load-policy/SKILL.md +13 -0
- package/.claude/skills/ref-idea/SKILL.md +44 -0
- package/.claude/skills/status-of/SKILL.md +38 -0
- package/.claude/skills/workspace-console/SKILL.md +49 -0
- package/.claude/statusline-command.sh +130 -0
- package/.claude/statusline.md +53 -0
- package/.claude/templates/approval-packet.template.html +171 -0
- package/.claude/templates/reference-workspaces.md +16 -0
- package/.claude/tools/cli-runner-server.cjs +220 -0
- package/.claude/tools/discord-send-server.cjs +188 -0
- package/.claude/tools/github-release-server.cjs +181 -0
- package/.claude/tools/glob-grep-server.cjs +267 -0
- package/.claude/tools/read-log-server.cjs +175 -0
- package/.github/codeql/codeql-config.yml +14 -0
- package/.github/workflows/build.yml +54 -0
- package/.github/workflows/codeql.yml +59 -0
- package/.github/workflows/coverage.yml +70 -0
- package/.github/workflows/publish.yml +103 -0
- package/.mcp.json +34 -0
- package/CHANGELOG.md +195 -0
- package/LICENSE +21 -0
- package/dist/cli/commands/devices.js +52 -0
- package/dist/cli/commands/energy.js +122 -0
- package/dist/cli/commands/login.js +54 -0
- package/dist/cli/help.js +50 -0
- package/dist/cli/main.js +56 -0
- package/dist/cli/session.js +13 -0
- package/dist/cli/types.js +1 -0
- package/dist/cli/utils.js +29 -0
- package/dist/cli.js +2 -0
- package/dist/constants/index.js +1 -0
- package/dist/core/domain/entities/ThinqDevice.js +43 -0
- package/dist/core/domain/value-objects/AirConditionerCapabilities.js +22 -0
- package/dist/core/domain/value-objects/ThinqFilterState.js +35 -0
- package/dist/core/domain/value-objects/ThinqSnapshot.js +125 -0
- package/dist/errors/AuthenticationError.js +26 -0
- package/dist/errors/BaseError.js +23 -0
- package/dist/errors/index.js +2 -0
- package/dist/model/LgThinkqPluginPlatformConfig.js +23 -0
- package/dist/model/LgThinqPluginPlatformConfig.js +23 -0
- package/dist/module.js +212 -0
- package/dist/platform/deviceRegistry.js +26 -0
- package/dist/platform/platformConfigManager.js +89 -0
- package/dist/platform/platformState.js +9 -0
- package/dist/platform/thinq/thinqAirConditionerCommandHandlers.js +190 -0
- package/dist/platform/thinq/thinqAirConditionerEndpointFactory.js +53 -0
- package/dist/platform/thinq/thinqAirConditionerFilterResetCommandHandler.js +54 -0
- package/dist/platform/thinq/thinqAirConditionerFilterStateSync.js +19 -0
- package/dist/platform/thinq/thinqAirConditionerStateSync.js +205 -0
- package/dist/platform/thinq/thinqDeviceConfigReconciler.js +26 -0
- package/dist/platform/thinq/thinqDeviceConfigurator.js +124 -0
- package/dist/platform/thinq/thinqDeviceDiscovery.js +33 -0
- package/dist/platform/thinq/thinqWasherCommandHandlers.js +54 -0
- package/dist/platform/thinq/thinqWasherEndpointFactory.js +6 -0
- package/dist/platform/thinq/thinqWasherStateSync.js +31 -0
- package/dist/platform/thinq/thinqWasherStopCommandResolver.js +38 -0
- package/dist/services/authentication/AccountAuthStrategy.js +135 -0
- package/dist/services/authentication/AuthContext.js +1 -0
- package/dist/services/authentication/AuthenticationCoordinator.js +29 -0
- package/dist/services/authentication/IAuthStrategy.js +1 -0
- package/dist/services/authentication/TokenAuthStrategy.js +27 -0
- package/dist/services/authentication/UserDataRepository.js +32 -0
- package/dist/services/thinq/gateway.js +61 -0
- package/dist/services/thinq/mqtt/mqttCertificate.js +63 -0
- package/dist/services/thinq/mqtt/mqttConnectionEvents.js +37 -0
- package/dist/services/thinq/mqtt/mqttKeyRepository.js +33 -0
- package/dist/services/thinq/mqtt/mqttRetry.js +25 -0
- package/dist/services/thinq/mqtt/thinqMqttListener.js +97 -0
- package/dist/services/thinq/serviceContainer.js +73 -0
- package/dist/services/thinq/session.js +48 -0
- package/dist/services/thinq/thinqApiClient.js +258 -0
- package/dist/services/thinq/thinqDeviceService.js +75 -0
- package/dist/services/thinq/thinqFilterMonitoringService.js +40 -0
- package/dist/settings.js +9 -0
- package/dist/tests/helpers/testUtils.js +87 -0
- package/dist/utils/mask.js +39 -0
- package/docs/README_CLI.md +166 -0
- package/eslint.config.js +194 -0
- package/matterbridge-lg-thinq-plugin.config.json +27 -0
- package/matterbridge-lg-thinq-plugin.schema.json +520 -0
- package/package.json +107 -0
- package/prettier.config.js +49 -0
- package/scripts/parse-junit-failures.mjs +45 -0
- package/scripts/parse-tsc-failures.mjs +48 -0
- package/scripts/run-build-local-summary.mjs +29 -0
- package/scripts/run-format-summary.mjs +36 -0
- package/scripts/run-lint-fix-summary.mjs +38 -0
- package/scripts/run-precommit-summary.mjs +94 -0
- package/src/cli/commands/devices.ts +67 -0
- package/src/cli/commands/energy.ts +160 -0
- package/src/cli/commands/login.ts +74 -0
- package/src/cli/help.ts +53 -0
- package/src/cli/main.ts +66 -0
- package/src/cli/session.ts +15 -0
- package/src/cli/types.ts +10 -0
- package/src/cli/utils.ts +30 -0
- package/src/cli.ts +3 -0
- package/src/constants/index.ts +8 -0
- package/src/core/domain/entities/ThinqDevice.test.ts +224 -0
- package/src/core/domain/entities/ThinqDevice.ts +74 -0
- package/src/core/domain/value-objects/AirConditionerCapabilities.ts +50 -0
- package/src/core/domain/value-objects/ThinqFilterState.ts +50 -0
- package/src/core/domain/value-objects/ThinqSnapshot.ts +162 -0
- package/src/errors/AuthenticationError.ts +37 -0
- package/src/errors/BaseError.ts +38 -0
- package/src/errors/index.ts +24 -0
- package/src/model/LgThinqPluginPlatformConfig.ts +94 -0
- package/src/module.ts +298 -0
- package/src/platform/deviceRegistry.ts +58 -0
- package/src/platform/platformConfigManager.ts +133 -0
- package/src/platform/platformState.ts +16 -0
- package/src/platform/thinq/thinqAirConditionerCommandHandlers.ts +291 -0
- package/src/platform/thinq/thinqAirConditionerEndpointFactory.ts +145 -0
- package/src/platform/thinq/thinqAirConditionerFilterResetCommandHandler.ts +96 -0
- package/src/platform/thinq/thinqAirConditionerFilterStateSync.ts +42 -0
- package/src/platform/thinq/thinqAirConditionerStateSync.ts +343 -0
- package/src/platform/thinq/thinqDeviceConfigReconciler.ts +42 -0
- package/src/platform/thinq/thinqDeviceConfigurator.test.ts +90 -0
- package/src/platform/thinq/thinqDeviceConfigurator.ts +191 -0
- package/src/platform/thinq/thinqDeviceDiscovery.test.ts +173 -0
- package/src/platform/thinq/thinqDeviceDiscovery.ts +51 -0
- package/src/platform/thinq/thinqWasherCommandHandlers.ts +120 -0
- package/src/platform/thinq/thinqWasherEndpointFactory.ts +37 -0
- package/src/platform/thinq/thinqWasherStateSync.ts +60 -0
- package/src/platform/thinq/thinqWasherStopCommandResolver.ts +62 -0
- package/src/services/authentication/AccountAuthStrategy.test.ts +358 -0
- package/src/services/authentication/AccountAuthStrategy.ts +209 -0
- package/src/services/authentication/AuthContext.ts +19 -0
- package/src/services/authentication/AuthenticationCoordinator.test.ts +217 -0
- package/src/services/authentication/AuthenticationCoordinator.ts +59 -0
- package/src/services/authentication/IAuthStrategy.ts +10 -0
- package/src/services/authentication/TokenAuthStrategy.test.ts +167 -0
- package/src/services/authentication/TokenAuthStrategy.ts +40 -0
- package/src/services/authentication/UserDataRepository.test.ts +149 -0
- package/src/services/authentication/UserDataRepository.ts +46 -0
- package/src/services/thinq/gateway.ts +86 -0
- package/src/services/thinq/mqtt/mqttCertificate.test.ts +344 -0
- package/src/services/thinq/mqtt/mqttCertificate.ts +102 -0
- package/src/services/thinq/mqtt/mqttConnectionEvents.test.ts +276 -0
- package/src/services/thinq/mqtt/mqttConnectionEvents.ts +76 -0
- package/src/services/thinq/mqtt/mqttKeyRepository.test.ts +164 -0
- package/src/services/thinq/mqtt/mqttKeyRepository.ts +43 -0
- package/src/services/thinq/mqtt/mqttRetry.test.ts +239 -0
- package/src/services/thinq/mqtt/mqttRetry.ts +43 -0
- package/src/services/thinq/mqtt/thinqMqttListener.test.ts +349 -0
- package/src/services/thinq/mqtt/thinqMqttListener.ts +120 -0
- package/src/services/thinq/serviceContainer.test.ts +203 -0
- package/src/services/thinq/serviceContainer.ts +105 -0
- package/src/services/thinq/session.test.ts +287 -0
- package/src/services/thinq/session.ts +69 -0
- package/src/services/thinq/thinqApiClient.test.ts +702 -0
- package/src/services/thinq/thinqApiClient.ts +418 -0
- package/src/services/thinq/thinqDeviceService.test.ts +415 -0
- package/src/services/thinq/thinqDeviceService.ts +101 -0
- package/src/services/thinq/thinqFilterMonitoringService.test.ts +264 -0
- package/src/services/thinq/thinqFilterMonitoringService.ts +53 -0
- package/src/settings.ts +18 -0
- package/src/tests/cli/commands/devices.test.ts +293 -0
- package/src/tests/cli/commands/login.test.ts +240 -0
- package/src/tests/cli/help.test.ts +46 -0
- package/src/tests/cli/main.test.ts +214 -0
- package/src/tests/cli/session.test.ts +198 -0
- package/src/tests/cli/utils.test.ts +194 -0
- package/src/tests/core/domain/value-objects/AirConditionerCapabilities.test.ts +685 -0
- package/src/tests/core/domain/value-objects/ThinqFilterState.test.ts +210 -0
- package/src/tests/core/domain/value-objects/ThinqSnapshot.test.ts +1035 -0
- package/src/tests/helpers/testUtils.ts +118 -0
- package/src/tests/model/LgThinqPluginPlatformConfig.test.ts +81 -0
- package/src/tests/platform/deviceRegistry.test.ts +177 -0
- package/src/tests/platform/platformConfigManager.test.ts +790 -0
- package/src/tests/platform/platformState.test.ts +41 -0
- package/src/tests/platform/thinq/thinqAirConditionerCommandHandlers.test.ts +879 -0
- package/src/tests/platform/thinq/thinqAirConditionerEndpointFactory.test.ts +993 -0
- package/src/tests/platform/thinq/thinqAirConditionerFilterResetCommandHandler.test.ts +508 -0
- package/src/tests/platform/thinq/thinqAirConditionerFilterStateSync.test.ts +212 -0
- package/src/tests/platform/thinq/thinqAirConditionerStateSync.test.ts +1586 -0
- package/src/tests/platform/thinq/thinqDeviceConfigReconciler.test.ts +269 -0
- package/src/tests/platform/thinq/thinqDeviceConfigurator.test.ts +492 -0
- package/src/tests/platform/thinq/thinqWasherCommandHandlers.test.ts +594 -0
- package/src/tests/platform/thinq/thinqWasherEndpointFactory.test.ts +84 -0
- package/src/tests/platform/thinq/thinqWasherStateSync.test.ts +373 -0
- package/src/tests/platform/thinq/thinqWasherStopCommandResolver.test.ts +392 -0
- package/src/tests/settings.test.ts +33 -0
- package/src/utils/mask.ts +50 -0
- package/tsconfig.base.json +25 -0
- package/tsconfig.json +9 -0
- package/tsconfig.production.json +13 -0
- package/tsconfig.vitest.json +5 -0
- package/vite.config.ts +43 -0
- package/wiki/memory-archive.md +12 -0
- package/wiki/reference-workspaces.md +23 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
/** Shared LG EMP (Enterprise Membership Platform) client identifiers, reverse-engineered constants used by every EMP call. */
|
|
4
|
+
export const THINQ_CLIENT_ID = 'LGAO221A02';
|
|
5
|
+
export const THINQ_OAUTH_SECRET_KEY = 'c053c2a6ddeb7ad97cb0eed0dcb31cf8';
|
|
6
|
+
export const THINQ_OAUTH_CLIENT_KEY = 'LGAO722A02';
|
|
7
|
+
export const THINQ_APPLICATION_KEY = '6V1V8H2BN5P9ZQGOI5DAQ92YZBDO3EK9';
|
|
8
|
+
|
|
9
|
+
/** Raw gateway lookup response shape (`GET .../gateway-uri`). */
|
|
10
|
+
export interface GatewayData {
|
|
11
|
+
empTermsUri: string;
|
|
12
|
+
empSpxUri: string;
|
|
13
|
+
thinq2Uri: string;
|
|
14
|
+
thinq1Uri: string;
|
|
15
|
+
countryCode: string;
|
|
16
|
+
languageCode: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Resolves EMP/ThinQ base URLs from a gateway lookup response. */
|
|
20
|
+
export class Gateway {
|
|
21
|
+
constructor(public readonly data: GatewayData) {}
|
|
22
|
+
|
|
23
|
+
public get empBaseUrl(): string {
|
|
24
|
+
return `${this.data.empTermsUri}/`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
public get loginBaseUrl(): string {
|
|
28
|
+
return `${this.data.empSpxUri}/`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
public get thinq2Url(): string {
|
|
32
|
+
return `${this.data.thinq2Uri}/`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
public get thinq1Url(): string {
|
|
36
|
+
return `${this.data.thinq1Uri}/`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
public get countryCode(): string {
|
|
40
|
+
return this.data.countryCode;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
public get languageCode(): string {
|
|
44
|
+
return this.data.languageCode;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** HMAC-SHA1 signature used by the EMP/oauth2 endpoints (`lgemp-x-signature`/`x-lge-oauth-signature`). */
|
|
49
|
+
export function signThinqMessage(message: string, secret: string): string {
|
|
50
|
+
return crypto.createHmac('sha1', Buffer.from(secret)).update(message).digest('base64');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const RFC2822_DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
|
54
|
+
const RFC2822_MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
55
|
+
|
|
56
|
+
/** Formats a UTC timestamp in RFC 2822 form (`Mon, 08 Sep 2026 12:34:56 +0000`), as required by LG's oauth signature headers. */
|
|
57
|
+
export function formatRfc2822Utc(date: Date): string {
|
|
58
|
+
const day = RFC2822_DAYS[date.getUTCDay()];
|
|
59
|
+
const dd = String(date.getUTCDate()).padStart(2, '0');
|
|
60
|
+
const month = RFC2822_MONTHS[date.getUTCMonth()];
|
|
61
|
+
const hh = String(date.getUTCHours()).padStart(2, '0');
|
|
62
|
+
const mi = String(date.getUTCMinutes()).padStart(2, '0');
|
|
63
|
+
const ss = String(date.getUTCSeconds()).padStart(2, '0');
|
|
64
|
+
|
|
65
|
+
return `${day}, ${dd} ${month} ${date.getUTCFullYear()} ${hh}:${mi}:${ss} +0000`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Default headers required by the `preLogin`/`account/session` EMP endpoints. */
|
|
69
|
+
export function buildEmpDefaultHeaders(gateway: Gateway): Record<string, string> {
|
|
70
|
+
return {
|
|
71
|
+
Accept: 'application/json',
|
|
72
|
+
'X-Application-Key': THINQ_APPLICATION_KEY,
|
|
73
|
+
'X-Client-App-Key': THINQ_CLIENT_ID,
|
|
74
|
+
'X-Lge-Svccode': 'SVC709',
|
|
75
|
+
'X-Device-Type': 'M01',
|
|
76
|
+
'X-Device-Platform': 'ADR',
|
|
77
|
+
'X-Device-Language-Type': 'IETF',
|
|
78
|
+
'X-Device-Publish-Flag': 'Y',
|
|
79
|
+
'X-Device-Country': gateway.countryCode,
|
|
80
|
+
'X-Device-Language': gateway.languageCode,
|
|
81
|
+
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
|
|
82
|
+
'Access-Control-Allow-Origin': '*',
|
|
83
|
+
'Accept-Encoding': 'gzip, deflate, br',
|
|
84
|
+
'Accept-Language': 'en-US,en;q=0.9',
|
|
85
|
+
};
|
|
86
|
+
}
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import Path from 'node:path';
|
|
4
|
+
|
|
5
|
+
import axios from 'axios';
|
|
6
|
+
import MockAdapter from 'axios-mock-adapter';
|
|
7
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
ATS_ROOT_CA_URL,
|
|
11
|
+
certificateRequestBody,
|
|
12
|
+
createMqttCsr,
|
|
13
|
+
downloadRootCa,
|
|
14
|
+
generateMqttKeyPair,
|
|
15
|
+
LEGACY_ROOT_CA_URL,
|
|
16
|
+
LG_ROOT_CA_URL,
|
|
17
|
+
mqttCertificatePaths,
|
|
18
|
+
rootCaUrlForMqttHost,
|
|
19
|
+
writeIfChanged,
|
|
20
|
+
writeMqttCertificateFiles,
|
|
21
|
+
} from './mqttCertificate.js';
|
|
22
|
+
|
|
23
|
+
describe('mqttCertificate', () => {
|
|
24
|
+
let mockAxios: MockAdapter;
|
|
25
|
+
let tempDir: string;
|
|
26
|
+
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
vi.clearAllMocks();
|
|
29
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
30
|
+
mockAxios = new MockAdapter(axios as any);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
mockAxios.reset();
|
|
35
|
+
vi.clearAllMocks();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe('rootCaUrlForMqttHost', () => {
|
|
39
|
+
it('should return ATS_ROOT_CA_URL for AWS ATS hostname pattern', () => {
|
|
40
|
+
const url = rootCaUrlForMqttHost('something-ats.iot.us-east-1.amazonaws.com');
|
|
41
|
+
expect(url).toBe(ATS_ROOT_CA_URL);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('should return ATS_ROOT_CA_URL for any AWS ATS pattern', () => {
|
|
45
|
+
const url = rootCaUrlForMqttHost('other-ats.iot.eu-west-1.amazonaws.com');
|
|
46
|
+
expect(url).toBe(ATS_ROOT_CA_URL);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('should return LG_ROOT_CA_URL for LG ruic hostname pattern', () => {
|
|
50
|
+
const url = rootCaUrlForMqttHost('abc123.iot.ruic.lgthinq.com');
|
|
51
|
+
expect(url).toBe(LG_ROOT_CA_URL);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('should return LEGACY_ROOT_CA_URL for other hostnames', () => {
|
|
55
|
+
const url = rootCaUrlForMqttHost('random.mqtt.example.com');
|
|
56
|
+
expect(url).toBe(LEGACY_ROOT_CA_URL);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('should return LEGACY_ROOT_CA_URL for localhost', () => {
|
|
60
|
+
const url = rootCaUrlForMqttHost('localhost');
|
|
61
|
+
expect(url).toBe(LEGACY_ROOT_CA_URL);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe('certificateRequestBody', () => {
|
|
66
|
+
it('should strip PEM header and footer', () => {
|
|
67
|
+
const csr = `-----BEGIN CERTIFICATE REQUEST-----
|
|
68
|
+
MIICpDCCAYwCAQAwEzERMA8GA1UEAwwIVGVzdCBDU1I=
|
|
69
|
+
-----END CERTIFICATE REQUEST-----`;
|
|
70
|
+
const result = certificateRequestBody(csr);
|
|
71
|
+
expect(result).toBe('MIICpDCCAYwCAQAwEzERMA8GA1UEAwwIVGVzdCBDU1I=');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('should remove all newlines', () => {
|
|
75
|
+
const csr = `-----BEGIN CERTIFICATE REQUEST-----
|
|
76
|
+
Line1
|
|
77
|
+
Line2
|
|
78
|
+
-----END CERTIFICATE REQUEST-----`;
|
|
79
|
+
const result = certificateRequestBody(csr);
|
|
80
|
+
expect(result).not.toContain('\n');
|
|
81
|
+
expect(result).not.toContain('\r');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('should handle Windows line endings', () => {
|
|
85
|
+
const csr = `-----BEGIN CERTIFICATE REQUEST-----\r\nMIIBpDCCAYwCAQAwEzERMA8GA1UEAwwIVGVzdCBDU1I=\r\n-----END CERTIFICATE REQUEST-----`;
|
|
86
|
+
const result = certificateRequestBody(csr);
|
|
87
|
+
expect(result).toBe('MIIBpDCCAYwCAQAwEzERMA8GA1UEAwwIVGVzdCBDU1I=');
|
|
88
|
+
expect(result).not.toContain('\r');
|
|
89
|
+
expect(result).not.toContain('\n');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('should return empty string for PEM markers with no content', () => {
|
|
93
|
+
const csr = `-----BEGIN CERTIFICATE REQUEST-----
|
|
94
|
+
-----END CERTIFICATE REQUEST-----`;
|
|
95
|
+
const result = certificateRequestBody(csr);
|
|
96
|
+
expect(result).toBe('');
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('generateMqttKeyPair', () => {
|
|
101
|
+
it('should return a key pair with privateKey and publicKey', () => {
|
|
102
|
+
const keyPair = generateMqttKeyPair();
|
|
103
|
+
|
|
104
|
+
expect(keyPair).toHaveProperty('privateKey');
|
|
105
|
+
expect(keyPair).toHaveProperty('publicKey');
|
|
106
|
+
expect(typeof keyPair.privateKey).toBe('string');
|
|
107
|
+
expect(typeof keyPair.publicKey).toBe('string');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('should generate RSA keys that contain PEM headers', () => {
|
|
111
|
+
const keyPair = generateMqttKeyPair();
|
|
112
|
+
|
|
113
|
+
expect(keyPair.privateKey).toContain('BEGIN RSA PRIVATE KEY');
|
|
114
|
+
expect(keyPair.publicKey).toContain('BEGIN PUBLIC KEY');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('should generate unique key pairs', () => {
|
|
118
|
+
const keyPair1 = generateMqttKeyPair();
|
|
119
|
+
const keyPair2 = generateMqttKeyPair();
|
|
120
|
+
|
|
121
|
+
expect(keyPair1.privateKey).not.toBe(keyPair2.privateKey);
|
|
122
|
+
expect(keyPair1.publicKey).not.toBe(keyPair2.publicKey);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe('createMqttCsr', () => {
|
|
127
|
+
it('should return a CSR with PEM format', () => {
|
|
128
|
+
const keyPair = generateMqttKeyPair();
|
|
129
|
+
const csr = createMqttCsr(keyPair);
|
|
130
|
+
|
|
131
|
+
expect(csr).toContain('BEGIN CERTIFICATE REQUEST');
|
|
132
|
+
expect(csr).toContain('END CERTIFICATE REQUEST');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('should create a CSR for the generated key pair', () => {
|
|
136
|
+
const keyPair = generateMqttKeyPair();
|
|
137
|
+
const csr = createMqttCsr(keyPair);
|
|
138
|
+
|
|
139
|
+
expect(csr).toBeTruthy();
|
|
140
|
+
expect(typeof csr).toBe('string');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('should generate different CSRs for different key pairs', () => {
|
|
144
|
+
const keyPair1 = generateMqttKeyPair();
|
|
145
|
+
const keyPair2 = generateMqttKeyPair();
|
|
146
|
+
const csr1 = createMqttCsr(keyPair1);
|
|
147
|
+
const csr2 = createMqttCsr(keyPair2);
|
|
148
|
+
|
|
149
|
+
expect(csr1).not.toBe(csr2);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe('mqttCertificatePaths', () => {
|
|
154
|
+
it('should return paths object with caPath, keyPath, and certPath', () => {
|
|
155
|
+
const mqttDir = '/tmp/mqtt';
|
|
156
|
+
const paths = mqttCertificatePaths(mqttDir);
|
|
157
|
+
|
|
158
|
+
expect(paths).toHaveProperty('caPath');
|
|
159
|
+
expect(paths).toHaveProperty('keyPath');
|
|
160
|
+
expect(paths).toHaveProperty('certPath');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('should join paths correctly', () => {
|
|
164
|
+
const mqttDir = '/tmp/mqtt';
|
|
165
|
+
const paths = mqttCertificatePaths(mqttDir);
|
|
166
|
+
|
|
167
|
+
expect(paths.caPath).toBe(Path.join(mqttDir, 'ca.pem'));
|
|
168
|
+
expect(paths.keyPath).toBe(Path.join(mqttDir, 'key.pem'));
|
|
169
|
+
expect(paths.certPath).toBe(Path.join(mqttDir, 'cert.pem'));
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe('writeIfChanged', () => {
|
|
174
|
+
beforeEach(async () => {
|
|
175
|
+
tempDir = await fs.mkdtemp(Path.join(os.tmpdir(), 'mqtt-test-'));
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
afterEach(async () => {
|
|
179
|
+
if (tempDir) {
|
|
180
|
+
await fs.rm(tempDir, { recursive: true, force: true });
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('should write file when target does not exist', async () => {
|
|
185
|
+
const filePath = Path.join(tempDir, 'test.txt');
|
|
186
|
+
const content = 'test content';
|
|
187
|
+
|
|
188
|
+
await writeIfChanged(filePath, content);
|
|
189
|
+
|
|
190
|
+
const written = await fs.readFile(filePath, 'utf8');
|
|
191
|
+
expect(written).toBe(content);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('should write file when content differs from existing', async () => {
|
|
195
|
+
const filePath = Path.join(tempDir, 'test.txt');
|
|
196
|
+
const oldContent = 'old content';
|
|
197
|
+
const newContent = 'new content';
|
|
198
|
+
|
|
199
|
+
await fs.writeFile(filePath, oldContent, 'utf8');
|
|
200
|
+
await writeIfChanged(filePath, newContent);
|
|
201
|
+
|
|
202
|
+
const written = await fs.readFile(filePath, 'utf8');
|
|
203
|
+
expect(written).toBe(newContent);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('should not write file when content is identical to existing', async () => {
|
|
207
|
+
const filePath = Path.join(tempDir, 'test.txt');
|
|
208
|
+
const content = 'same content';
|
|
209
|
+
|
|
210
|
+
await fs.writeFile(filePath, content, 'utf8');
|
|
211
|
+
const writeFileSpy = vi.spyOn(fs, 'writeFile');
|
|
212
|
+
|
|
213
|
+
await writeIfChanged(filePath, content);
|
|
214
|
+
|
|
215
|
+
expect(writeFileSpy).not.toHaveBeenCalled();
|
|
216
|
+
writeFileSpy.mockRestore();
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('should handle file read errors gracefully', async () => {
|
|
220
|
+
const filePath = Path.join(tempDir, 'nonexistent', 'test.txt');
|
|
221
|
+
const content = 'new content';
|
|
222
|
+
|
|
223
|
+
// Create the parent directory
|
|
224
|
+
await fs.mkdir(Path.dirname(filePath), { recursive: true });
|
|
225
|
+
await writeIfChanged(filePath, content);
|
|
226
|
+
|
|
227
|
+
const written = await fs.readFile(filePath, 'utf8');
|
|
228
|
+
expect(written).toBe(content);
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
describe('downloadRootCa', () => {
|
|
233
|
+
it('should download root CA with responseType text', async () => {
|
|
234
|
+
const hostname = 'test-ats.iot.us-east-1.amazonaws.com';
|
|
235
|
+
const pemContent = '-----BEGIN CERTIFICATE-----\nTest PEM\n-----END CERTIFICATE-----';
|
|
236
|
+
|
|
237
|
+
mockAxios.onGet(ATS_ROOT_CA_URL).reply(200, pemContent);
|
|
238
|
+
|
|
239
|
+
const result = await downloadRootCa(hostname);
|
|
240
|
+
|
|
241
|
+
expect(result).toBe(pemContent);
|
|
242
|
+
const request = mockAxios.history.get[0];
|
|
243
|
+
expect(request.responseType).toBe('text');
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('should use correct URL for LG ruic hostname', async () => {
|
|
247
|
+
const hostname = 'abc.iot.ruic.lgthinq.com';
|
|
248
|
+
const pemContent = 'LG Root CA PEM';
|
|
249
|
+
|
|
250
|
+
mockAxios.onGet(LG_ROOT_CA_URL).reply(200, pemContent);
|
|
251
|
+
|
|
252
|
+
const result = await downloadRootCa(hostname);
|
|
253
|
+
|
|
254
|
+
expect(result).toBe(pemContent);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('should use legacy URL for unknown hostname', async () => {
|
|
258
|
+
const hostname = 'unknown.example.com';
|
|
259
|
+
const pemContent = 'Legacy Root CA PEM';
|
|
260
|
+
|
|
261
|
+
mockAxios.onGet(LEGACY_ROOT_CA_URL).reply(200, pemContent);
|
|
262
|
+
|
|
263
|
+
const result = await downloadRootCa(hostname);
|
|
264
|
+
|
|
265
|
+
expect(result).toBe(pemContent);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('should not attach auth headers to root CA download', async () => {
|
|
269
|
+
const hostname = 'test-ats.iot.us-east-1.amazonaws.com';
|
|
270
|
+
const pemContent = 'Root CA';
|
|
271
|
+
|
|
272
|
+
mockAxios.onGet(ATS_ROOT_CA_URL).reply(200, pemContent);
|
|
273
|
+
|
|
274
|
+
await downloadRootCa(hostname);
|
|
275
|
+
|
|
276
|
+
const request = mockAxios.history.get[0];
|
|
277
|
+
expect(request.headers).not.toHaveProperty('x-emp-token');
|
|
278
|
+
expect(request.headers).not.toHaveProperty('authorization');
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
describe('writeMqttCertificateFiles', () => {
|
|
283
|
+
beforeEach(async () => {
|
|
284
|
+
tempDir = await fs.mkdtemp(Path.join(os.tmpdir(), 'mqtt-test-'));
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
afterEach(async () => {
|
|
288
|
+
if (tempDir) {
|
|
289
|
+
await fs.rm(tempDir, { recursive: true, force: true });
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('should write all three certificate files', async () => {
|
|
294
|
+
const mqttDir = Path.join(tempDir, 'mqtt');
|
|
295
|
+
const rootCA = 'root-ca-pem';
|
|
296
|
+
const privateKey = 'private-key-pem';
|
|
297
|
+
const certificatePem = 'cert-pem';
|
|
298
|
+
|
|
299
|
+
const paths = await writeMqttCertificateFiles({
|
|
300
|
+
mqttDir,
|
|
301
|
+
rootCA,
|
|
302
|
+
privateKey,
|
|
303
|
+
certificatePem,
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
const caContent = await fs.readFile(paths.caPath, 'utf8');
|
|
307
|
+
const keyContent = await fs.readFile(paths.keyPath, 'utf8');
|
|
308
|
+
const certContent = await fs.readFile(paths.certPath, 'utf8');
|
|
309
|
+
|
|
310
|
+
expect(caContent).toBe(rootCA);
|
|
311
|
+
expect(keyContent).toBe(privateKey);
|
|
312
|
+
expect(certContent).toBe(certificatePem);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('should create mqttDir recursively if it does not exist', async () => {
|
|
316
|
+
const mqttDir = Path.join(tempDir, 'nested', 'mqtt', 'dir');
|
|
317
|
+
|
|
318
|
+
await writeMqttCertificateFiles({
|
|
319
|
+
mqttDir,
|
|
320
|
+
rootCA: 'ca',
|
|
321
|
+
privateKey: 'key',
|
|
322
|
+
certificatePem: 'cert',
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
const stat = await fs.stat(mqttDir);
|
|
326
|
+
expect(stat.isDirectory()).toBe(true);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it('should return the correct paths', async () => {
|
|
330
|
+
const mqttDir = Path.join(tempDir, 'mqtt');
|
|
331
|
+
|
|
332
|
+
const paths = await writeMqttCertificateFiles({
|
|
333
|
+
mqttDir,
|
|
334
|
+
rootCA: 'ca',
|
|
335
|
+
privateKey: 'key',
|
|
336
|
+
certificatePem: 'cert',
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
expect(paths.caPath).toBe(Path.join(mqttDir, 'ca.pem'));
|
|
340
|
+
expect(paths.keyPath).toBe(Path.join(mqttDir, 'key.pem'));
|
|
341
|
+
expect(paths.certPath).toBe(Path.join(mqttDir, 'cert.pem'));
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import FS from 'node:fs';
|
|
2
|
+
import Path from 'node:path';
|
|
3
|
+
|
|
4
|
+
import axios from 'axios';
|
|
5
|
+
import forge from 'node-forge';
|
|
6
|
+
|
|
7
|
+
export const ATS_ROOT_CA_URL = 'https://www.amazontrust.com/repository/AmazonRootCA1.pem';
|
|
8
|
+
export const LG_ROOT_CA_URL = 'https://support.sectigo.com/sfc/servlet.shepherd/version/download/0683l00000G9fLm';
|
|
9
|
+
export const LEGACY_ROOT_CA_URL =
|
|
10
|
+
'https://www.websecurity.digicert.com/content/dam/websitesecurity/digitalassets/desktop/pdfs/roots/' +
|
|
11
|
+
'VeriSign-Class%203-Public-Primary-Certification-Authority-G5.pem';
|
|
12
|
+
|
|
13
|
+
export interface MqttKeyPair {
|
|
14
|
+
privateKey: string;
|
|
15
|
+
publicKey: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface MqttCertificatePaths {
|
|
19
|
+
caPath: string;
|
|
20
|
+
keyPath: string;
|
|
21
|
+
certPath: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Generates a fresh 2048-bit RSA key pair for MQTT client certificate enrollment. */
|
|
25
|
+
export function generateMqttKeyPair(): MqttKeyPair {
|
|
26
|
+
const keys = forge.pki.rsa.generateKeyPair(2048);
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
privateKey: forge.pki.privateKeyToPem(keys.privateKey),
|
|
30
|
+
publicKey: forge.pki.publicKeyToPem(keys.publicKey),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Builds a self-signed CSR (CN=`AWS IoT Certificate`, O=`Amazon`, sha256) for the given key pair. */
|
|
35
|
+
export function createMqttCsr(keys: MqttKeyPair): string {
|
|
36
|
+
const csr = forge.pki.createCertificationRequest();
|
|
37
|
+
csr.publicKey = forge.pki.publicKeyFromPem(keys.publicKey);
|
|
38
|
+
csr.setSubject([
|
|
39
|
+
{ shortName: 'CN', value: 'AWS IoT Certificate' },
|
|
40
|
+
{ shortName: 'O', value: 'Amazon' },
|
|
41
|
+
]);
|
|
42
|
+
csr.sign(forge.pki.privateKeyFromPem(keys.privateKey), forge.md.sha256.create());
|
|
43
|
+
|
|
44
|
+
return forge.pki.certificationRequestToPem(csr);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Strips PEM header/footer/newlines from a CSR, producing the flat body the ThinQ API expects. */
|
|
48
|
+
export function certificateRequestBody(csr: string): string {
|
|
49
|
+
return csr.replace(/-----(BEGIN|END) CERTIFICATE REQUEST-----/g, '').replace(/(\r\n|\r|\n)/g, '');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Picks the correct root-CA download URL for a given MQTT broker hostname. */
|
|
53
|
+
export function rootCaUrlForMqttHost(hostname: string): string {
|
|
54
|
+
if (/^([^.]+)-ats\.iot\.([^.]+)\.amazonaws\.com$/g.exec(hostname)) {
|
|
55
|
+
return ATS_ROOT_CA_URL;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (/^([^.]+)\.iot\.ruic\.lgthinq\.com$/g.exec(hostname)) {
|
|
59
|
+
return LG_ROOT_CA_URL;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return LEGACY_ROOT_CA_URL;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Downloads the root CA PEM for the given MQTT broker hostname. Plain unauthenticated GET (public CA host). */
|
|
66
|
+
export async function downloadRootCa(hostname: string): Promise<string> {
|
|
67
|
+
const response = await axios.get<string>(rootCaUrlForMqttHost(hostname), { responseType: 'text' });
|
|
68
|
+
return response.data;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function mqttCertificatePaths(mqttDir: string): MqttCertificatePaths {
|
|
72
|
+
return {
|
|
73
|
+
caPath: Path.join(mqttDir, 'ca.pem'),
|
|
74
|
+
keyPath: Path.join(mqttDir, 'key.pem'),
|
|
75
|
+
certPath: Path.join(mqttDir, 'cert.pem'),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Writes `content` to `path` only if it differs from the existing file contents (or the file doesn't exist). */
|
|
80
|
+
export async function writeIfChanged(path: string, content: string): Promise<void> {
|
|
81
|
+
const existing = await FS.promises.readFile(path, 'utf8').catch(() => null);
|
|
82
|
+
if (existing !== content) {
|
|
83
|
+
await FS.promises.writeFile(path, content, 'utf8');
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function writeMqttCertificateFiles(options: {
|
|
88
|
+
mqttDir: string;
|
|
89
|
+
rootCA: string;
|
|
90
|
+
privateKey: string;
|
|
91
|
+
certificatePem: string;
|
|
92
|
+
}): Promise<MqttCertificatePaths> {
|
|
93
|
+
const { mqttDir, rootCA, privateKey, certificatePem } = options;
|
|
94
|
+
await FS.promises.mkdir(mqttDir, { recursive: true });
|
|
95
|
+
|
|
96
|
+
const paths = mqttCertificatePaths(mqttDir);
|
|
97
|
+
await writeIfChanged(paths.caPath, rootCA);
|
|
98
|
+
await writeIfChanged(paths.keyPath, privateKey);
|
|
99
|
+
await writeIfChanged(paths.certPath, certificatePem);
|
|
100
|
+
|
|
101
|
+
return paths;
|
|
102
|
+
}
|