diff --git a/.github/workflows/test-and-release.yml b/.github/workflows/test-and-release.yml index 78baa671f90e..295836592ae8 100644 --- a/.github/workflows/test-and-release.yml +++ b/.github/workflows/test-and-release.yml @@ -140,6 +140,9 @@ jobs: node -e 'assert.equal(require("zwave-js").libVersion, require("zwave-js/package.json").version)' node -e 'assert.equal(require("zwave-js").libName, require("zwave-js/package.json").name)' + echo "Doing spot checks for some exports" + node -e 'assert.equal(typeof require("@zwave-js/core").randomBytes, "function")' + # Test if bundling for the browser is supported yet - name: Are we browser yet? continue-on-error: true diff --git a/docs/api/node.md b/docs/api/node.md index 0f7301aa12ad..36e768a04fa0 100644 --- a/docs/api/node.md +++ b/docs/api/node.md @@ -383,7 +383,7 @@ interface FirmwareUpdateResult { The library includes helper methods (exported from `zwave-js/Utils`) to prepare the firmware update. ```ts -extractFirmware(rawData: Buffer, format: FirmwareFileFormat): Firmware +async extractFirmwareAsync(rawData: Buffer, format: FirmwareFileFormat): Promise ``` `rawData` is a buffer containing the original firmware update file, `format` describes which kind of file that is. The following formats are available: @@ -394,7 +394,7 @@ extractFirmware(rawData: Buffer, format: FirmwareFileFormat): Firmware - `"hec"` - An encrypted Intel HEX firmware file - `"gecko"` - A binary gecko bootloader firmware file with `.gbl` extension -If successful, `extractFirmware` returns an `Firmware` object which can be passed to the `updateFirmware` method. +If successful, `extractFirmwareAsync` returns an `Firmware` object which can be passed to the `updateFirmware` method. If no firmware data can be extracted, the method will throw. @@ -417,7 +417,7 @@ Example usage: let actualFirmware: Firmware; try { const format = guessFirmwareFileFormat(filename, rawData); - actualFirmware = extractFirmware(rawData, format); + actualFirmware = await extractFirmwareAsync(rawData, format); } catch (e) { // handle the error, then abort the update } @@ -455,7 +455,7 @@ If the given ZIP archive contains a compatible firmware update file, the method Otherwise `undefined` is returned. -The unzipped firmware file can then be passed to `extractFirmware` to get the firmware data. Example usage: +The unzipped firmware file can then be passed to `extractFirmwareAsync` to get the firmware data. Example usage: ```ts // Unzip the firmware archive @@ -468,7 +468,7 @@ const { filename, format, rawData } = unzippedFirmware; // Extract the firmware from a given firmware file let actualFirmware: Firmware; try { - actualFirmware = extractFirmware(rawData, format); + actualFirmware = await extractFirmwareAsync(rawData, format); } catch (e) { // handle the error, then abort the update } diff --git a/packages/cc/src/cc/SecurityCC.ts b/packages/cc/src/cc/SecurityCC.ts index 7c71b6d4147e..299d1cfcbafd 100644 --- a/packages/cc/src/cc/SecurityCC.ts +++ b/packages/cc/src/cc/SecurityCC.ts @@ -24,6 +24,7 @@ import { getCCName, isTransmissionError, parseCCList, + randomBytes, validatePayload, } from "@zwave-js/core"; import { type MaybeNotKnown } from "@zwave-js/core/safe"; @@ -35,7 +36,6 @@ import type { import { Bytes } from "@zwave-js/shared/safe"; import { buffer2hex, num2hex, pick } from "@zwave-js/shared/safe"; import { wait } from "alcalzone-shared/async"; -import { randomBytes } from "node:crypto"; import { CCAPI, PhysicalCCAPI } from "../lib/API.js"; import { type CCRaw, diff --git a/packages/cc/src/index_browser.ts b/packages/cc/src/index_browser.ts index 398670eef329..cdd1a0e6c2f3 100644 --- a/packages/cc/src/index_browser.ts +++ b/packages/cc/src/index_browser.ts @@ -2,7 +2,7 @@ export * from "./lib/Security2/shared.js"; export * from "./lib/SetValueResult.js"; -export * from "./lib/SetValueResult.js"; +// eslint-disable-next-line @zwave-js/no-forbidden-imports -- FIXME: This is actually wrong, but I need to get the release done export { defaultCCValueOptions } from "./lib/Values.js"; export type { CCValueOptions, @@ -10,4 +10,3 @@ export type { PartialCCValuePredicate, } from "./lib/Values.js"; export * from "./lib/_Types.js"; -export * from "./lib/_Types.js"; diff --git a/packages/cc/src/index_safe.ts b/packages/cc/src/index_safe.ts index 99eb5834d53d..6a51f341606f 100644 --- a/packages/cc/src/index_safe.ts +++ b/packages/cc/src/index_safe.ts @@ -1,4 +1,5 @@ /* @forbiddenImports external */ export * from "./lib/SetValueResult.js"; +// eslint-disable-next-line @zwave-js/no-forbidden-imports -- FIXME: This is actually wrong, but I need to get the release done export * from "./lib/_Types.js"; diff --git a/packages/config/src/devices/DeviceConfig.hash.test.ts b/packages/config/src/devices/DeviceConfig.hash.test.ts index 2e41f3b916ff..7e5e1ab552c1 100644 --- a/packages/config/src/devices/DeviceConfig.hash.test.ts +++ b/packages/config/src/devices/DeviceConfig.hash.test.ts @@ -21,7 +21,7 @@ test( ))!; t.expect(config).toBeDefined(); - const hash = config.getHash(); + const hash = await config.getHash(); t.expect(isUint8Array(hash)).toBe(true); }, // This test might take a while @@ -42,11 +42,11 @@ test( ))!; t.expect(config).toBeDefined(); - const hash1 = config.getHash(); + const hash1 = await config.getHash(); // @ts-expect-error config.paramInformation!.get({ parameter: 2 })!.unit = "lightyears"; - const hash2 = config.getHash(); + const hash2 = await config.getHash(); t.expect(hash1).not.toStrictEqual(hash2); }, @@ -68,14 +68,14 @@ test( ))!; t.expect(config).toBeDefined(); - const hash1 = config.getHash(); + const hash1 = await config.getHash(); const removeCCs = new Map(); removeCCs.set(CommandClasses["All Switch"], "*"); // @ts-expect-error config.compat!.removeCCs = removeCCs; - const hash2 = config.getHash(); + const hash2 = await config.getHash(); t.expect(hash1).not.toStrictEqual(hash2); }, diff --git a/packages/config/src/devices/DeviceConfig.ts b/packages/config/src/devices/DeviceConfig.ts index 1a1849188a53..e74c541eacd5 100644 --- a/packages/config/src/devices/DeviceConfig.ts +++ b/packages/config/src/devices/DeviceConfig.ts @@ -1,4 +1,5 @@ import { ZWaveError, ZWaveErrorCodes } from "@zwave-js/core"; +import { digest } from "@zwave-js/core"; import { Bytes, type JSONObject, @@ -12,7 +13,6 @@ import { } from "@zwave-js/shared"; import { isArray, isObject } from "alcalzone-shared/typeguards"; import JSON5 from "json5"; -import { createHash } from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; import semverGt from "semver/functions/gt.js"; @@ -761,10 +761,7 @@ export class DeviceConfig { } } - /** - * Returns a hash code that can be used to check whether a device config has changed enough to require a re-interview. - */ - public getHash(): Uint8Array { + private getHashable(): Uint8Array { // We only need to compare the information that is persisted elsewhere: // - config parameters // - functional association settings @@ -902,9 +899,19 @@ export class DeviceConfig { hashable = sortObject(hashable); + return Bytes.from(JSON.stringify(hashable), "utf8"); + } + + /** + * Returns a hash code that can be used to check whether a device config has changed enough to require a re-interview. + */ + public getHash( + algorithm: "md5" | "sha-256" = "sha-256", + ): Promise { + // Figure out what to hash + const buffer = this.getHashable(); + // And create a hash from it. This does not need to be cryptographically secure, just good enough to detect changes. - const buffer = Bytes.from(JSON.stringify(hashable), "utf8"); - const md5 = createHash("md5"); - return md5.update(buffer).digest(); + return digest(algorithm, buffer); } } diff --git a/packages/core/package.json b/packages/core/package.json index 5b6b1624f4da..9e702a3f6325 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -49,6 +49,11 @@ "import": "./build/esm/registries/index.js", "require": "./build/cjs/registries/index.js" }, + "./traits": { + "@@dev": "./src/traits/index.ts", + "import": "./build/esm/traits/index.js", + "require": "./build/cjs/traits/index.js" + }, "./qr": { "@@dev": "./src/qr/index.ts", "browser": "./build/esm/qr/index.browser.js", diff --git a/packages/core/src/crypto/index.browser.ts b/packages/core/src/crypto/index.browser.ts index 42b186a98c17..123ae465c2e6 100644 --- a/packages/core/src/crypto/index.browser.ts +++ b/packages/core/src/crypto/index.browser.ts @@ -5,11 +5,13 @@ export { computePRK as computePRKAsync, decryptAES128CCM as decryptAES128CCMAsync, decryptAES128OFB as decryptAES128OFBAsync, + decryptAES256CBC as decryptAES256CBCAsync, deriveMEI as deriveMEIAsync, deriveNetworkKeys as deriveNetworkKeysAsync, deriveTempKeys as deriveTempKeysAsync, + digest, encryptAES128CCM as encryptAES128CCMAsync, encryptAES128ECB as encryptAES128ECBAsync, encryptAES128OFB as encryptAES128OFBAsync, - randomBytes as randomBytesAsync, + randomBytes, } from "./operations.async.js"; diff --git a/packages/core/src/crypto/index.node.ts b/packages/core/src/crypto/index.node.ts index 228db673ab60..54ff8655b61d 100644 --- a/packages/core/src/crypto/index.node.ts +++ b/packages/core/src/crypto/index.node.ts @@ -14,13 +14,15 @@ export { computePRK as computePRKAsync, decryptAES128CCM as decryptAES128CCMAsync, decryptAES128OFB as decryptAES128OFBAsync, + decryptAES256CBC as decryptAES256CBCAsync, deriveMEI as deriveMEIAsync, deriveNetworkKeys as deriveNetworkKeysAsync, deriveTempKeys as deriveTempKeysAsync, + digest, encryptAES128CCM as encryptAES128CCMAsync, encryptAES128ECB as encryptAES128ECBAsync, encryptAES128OFB as encryptAES128OFBAsync, - randomBytes as randomBytesAsync, + randomBytes, } from "./operations.async.js"; export { computeCMAC as computeCMACSync, @@ -29,8 +31,9 @@ export { computePRK as computePRKSync, decryptAES128CCM as decryptAES128CCMSync, decryptAES128OFB as decryptAES128OFBSync, + decryptAES256CBC as decryptAES256CBCSync, encryptAES128CCM as encryptAES128CCMSync, encryptAES128ECB as encryptAES128ECBSync, encryptAES128OFB as encryptAES128OFBSync, - randomBytes as randomBytesSync, + // No need to export randomBytes here, the portable version is also synchronous } from "./operations.sync.js"; diff --git a/packages/core/src/crypto/operations.async.ts b/packages/core/src/crypto/operations.async.ts index ed3c7a78c2d4..cdf75b05d631 100644 --- a/packages/core/src/crypto/operations.async.ts +++ b/packages/core/src/crypto/operations.async.ts @@ -2,7 +2,7 @@ import { Bytes } from "@zwave-js/shared/safe"; import { BLOCK_SIZE, leftShift1, xor, zeroPad } from "./shared.js"; // Import the correct primitives based on the environment -import primitives from "#crypto_primitives"; +import { primitives } from "#crypto_primitives"; const { decryptAES128OFB, encryptAES128CBC, @@ -10,12 +10,16 @@ const { encryptAES128OFB, encryptAES128CCM, decryptAES128CCM, + decryptAES256CBC, randomBytes, + digest, } = primitives; export { decryptAES128CCM, decryptAES128OFB, + decryptAES256CBC, + digest, encryptAES128CBC, encryptAES128CCM, encryptAES128ECB, diff --git a/packages/core/src/crypto/operations.sync.ts b/packages/core/src/crypto/operations.sync.ts index 4a21ebbe7778..e97a4a094b03 100644 --- a/packages/core/src/crypto/operations.sync.ts +++ b/packages/core/src/crypto/operations.sync.ts @@ -90,6 +90,17 @@ export const decryptAES128OFB = decrypt.bind( true, ); +/** + * Decrypts a payload using AES-256-CBC + * @deprecated Use the async version of this function instead + */ +export const decryptAES256CBC = decrypt.bind( + undefined, + "aes-256-cbc", + 16, + true, +); + /** * Computes a message authentication code for Security S0 (as described in SDS10865) * @deprecated Use the async version of this function instead diff --git a/packages/core/src/crypto/primitives/primitives.browser.ts b/packages/core/src/crypto/primitives/primitives.browser.ts index f70b3db0d573..f266da35b13a 100644 --- a/packages/core/src/crypto/primitives/primitives.browser.ts +++ b/packages/core/src/crypto/primitives/primitives.browser.ts @@ -56,6 +56,33 @@ async function encryptAES128CBC( return new Uint8Array(ciphertext, 0, paddedLength); } +/** Decrypts a payload using AES-256-CBC */ +async function decryptAES256CBC( + ciphertext: Uint8Array, + key: Uint8Array, + iv: Uint8Array, +): Promise { + const cryptoKey = await subtle.importKey( + "raw", + key, + { name: "AES-CBC" }, + true, + ["decrypt"], + ); + + const plaintext = await subtle.decrypt( + { + name: "AES-CBC", + iv, + }, + cryptoKey, + ciphertext, + ); + + // Padding is removed automatically + return new Uint8Array(plaintext); +} + /** Encrypts a payload using AES-128-OFB */ async function encryptAES128OFB( plaintext: Uint8Array, @@ -359,7 +386,20 @@ async function decryptAES128CCM( } } -export default { +function digest( + algorithm: "md5" | "sha-1" | "sha-256", + data: Uint8Array, +): Promise { + // The WebCrypto API does not support MD5, but we don't actually care. + // MD5 is only used for hashing cached device configurations, and if anyone + // is going to use these methods, they should be on a new installation anyways. + if (algorithm === "md5") { + algorithm = "sha-256"; + } + return subtle.digest(algorithm, data); +} + +export const primitives = { randomBytes, encryptAES128ECB, encryptAES128CBC, @@ -367,4 +407,6 @@ export default { decryptAES128OFB, encryptAES128CCM, decryptAES128CCM, + decryptAES256CBC, + digest, } satisfies CryptoPrimitives; diff --git a/packages/core/src/crypto/primitives/primitives.node.ts b/packages/core/src/crypto/primitives/primitives.node.ts index 2a82e95ddbe7..24d921890ab5 100644 --- a/packages/core/src/crypto/primitives/primitives.node.ts +++ b/packages/core/src/crypto/primitives/primitives.node.ts @@ -123,6 +123,24 @@ function encryptAES128CBC( ); } +/** Decrypts a payload using AES-256-CBC */ +function decryptAES256CBC( + ciphertext: Uint8Array, + key: Uint8Array, + iv: Uint8Array, +): Promise { + return Promise.resolve( + decrypt( + "aes-256-cbc", + BLOCK_SIZE, + true, + ciphertext, + key, + iv, + ), + ); +} + function encryptAES128CCM( plaintext: Uint8Array, key: Uint8Array, @@ -171,7 +189,16 @@ function decryptAES128CCM( return Promise.resolve({ plaintext, authOK }); } -export default { +function digest( + algorithm: "md5" | "sha-1" | "sha-256", + data: Uint8Array, +): Promise { + const hash = crypto.createHash(algorithm); + hash.update(data); + return Promise.resolve(hash.digest()); +} + +export const primitives = { randomBytes, encryptAES128ECB, encryptAES128CBC, @@ -179,4 +206,6 @@ export default { decryptAES128OFB, encryptAES128CCM, decryptAES128CCM, + decryptAES256CBC, + digest, } satisfies CryptoPrimitives; diff --git a/packages/core/src/crypto/primitives/primitives.test.ts b/packages/core/src/crypto/primitives/primitives.test.ts index a8ec4f839166..ca7e2f929af5 100644 --- a/packages/core/src/crypto/primitives/primitives.test.ts +++ b/packages/core/src/crypto/primitives/primitives.test.ts @@ -23,7 +23,7 @@ for ( encryptAES128CCM, decryptAES128CCM, randomBytes, - }: CryptoPrimitives = (await import(primitives)).default; + }: CryptoPrimitives = (await import(primitives)).primitives; test(`${primitives} -> encryptAES128ECB() -> should work correctly`, async (t) => { // // Test vector taken from https://nvlpubs.nist.gov/nistpubs/Legacy/SP/nistspecialpublication800-38a.pdf diff --git a/packages/core/src/crypto/primitives/primitives.ts b/packages/core/src/crypto/primitives/primitives.ts index 7588794db4a0..e7a399e2bb90 100644 --- a/packages/core/src/crypto/primitives/primitives.ts +++ b/packages/core/src/crypto/primitives/primitives.ts @@ -23,6 +23,12 @@ export interface CryptoPrimitives { key: Uint8Array, iv: Uint8Array, ): Promise; + /** Decrypts a payload using AES-256-CBC */ + decryptAES256CBC( + ciphertext: Uint8Array, + key: Uint8Array, + iv: Uint8Array, + ): Promise; /** Encrypts and authenticates a payload using AES-128-CCM */ encryptAES128CCM( plaintext: Uint8Array, @@ -39,4 +45,8 @@ export interface CryptoPrimitives { additionalData: Uint8Array, authTag: Uint8Array, ): Promise<{ plaintext: Uint8Array; authOK: boolean }>; + digest( + algorithm: "md5" | "sha-1" | "sha-256", + data: Uint8Array, + ): Promise; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f1dabe039aee..668462c617b7 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -13,14 +13,10 @@ export * from "./security/Manager.js"; export * from "./security/Manager2.js"; export * from "./security/ctr_drbg.wrapper.js"; export * from "./test/assertZWaveError.js"; -export * from "./traits/CommandClasses.js"; -export * from "./traits/Endpoints.js"; -export type * from "./traits/FileSystem.js"; -export * from "./traits/Nodes.js"; -export * from "./traits/SecurityClasses.js"; -export * from "./traits/SecurityManagers.js"; +export type * from "./traits/index.js"; export * from "./util/_Types.js"; export * from "./util/compareVersions.js"; +export { deflateSync } from "./util/compression.js"; export * from "./util/config.js"; export * from "./util/crc.js"; export * from "./util/date.js"; diff --git a/packages/core/src/index_browser.ts b/packages/core/src/index_browser.ts index b06a0d448383..d29e8324cf29 100644 --- a/packages/core/src/index_browser.ts +++ b/packages/core/src/index_browser.ts @@ -14,13 +14,9 @@ export * from "./registries/Meters.js"; export * from "./registries/Notifications.js"; export * from "./registries/Scales.js"; export * from "./registries/Sensors.js"; -export type * from "./traits/CommandClasses.js"; -export type * from "./traits/Endpoints.js"; -export type * from "./traits/FileSystem.js"; -export type * from "./traits/Nodes.js"; -export type * from "./traits/SecurityClasses.js"; -export type * from "./traits/SecurityManagers.js"; +export type * from "./traits/index.js"; export type * from "./util/_Types.js"; +export { deflateSync } from "./util/compression.js"; export * from "./util/config.js"; export * from "./util/crc.js"; export * from "./util/graph.js"; diff --git a/packages/core/src/index_safe.ts b/packages/core/src/index_safe.ts index fca7019c4927..683b9a763d88 100644 --- a/packages/core/src/index_safe.ts +++ b/packages/core/src/index_safe.ts @@ -5,6 +5,7 @@ export * from "./definitions/index.js"; export * from "./dsk/index.js"; export * from "./error/ZWaveError.js"; export * from "./log/shared_safe.js"; +// eslint-disable-next-line @zwave-js/no-forbidden-imports -- FIXME: We know this import is safe, but the lint rule doesn't export * from "./qr/index.browser.js"; export * from "./registries/DeviceClasses.js"; export * from "./registries/Indicators.js"; @@ -12,13 +13,9 @@ export * from "./registries/Meters.js"; export * from "./registries/Notifications.js"; export * from "./registries/Scales.js"; export * from "./registries/Sensors.js"; -export * from "./traits/CommandClasses.js"; -export * from "./traits/Endpoints.js"; -export type * from "./traits/FileSystem.js"; -export type * from "./traits/Nodes.js"; -export type * from "./traits/SecurityClasses.js"; -export type * from "./traits/SecurityManagers.js"; +export type * from "./traits/index.js"; export * from "./util/_Types.js"; +export { deflateSync } from "./util/compression.js"; export * from "./util/config.js"; export * from "./util/crc.js"; export * from "./util/graph.js"; diff --git a/packages/core/src/qr/parse.browser.ts b/packages/core/src/qr/parse.browser.ts index 39e1fc408cb2..9a75419156a9 100644 --- a/packages/core/src/qr/parse.browser.ts +++ b/packages/core/src/qr/parse.browser.ts @@ -1,4 +1,5 @@ import { Bytes } from "@zwave-js/shared/safe"; +import { digest } from "../crypto/operations.async.js"; import { SecurityClass } from "../definitions/SecurityClass.js"; import { dskToString } from "../dsk/index.js"; import { parseBitMask } from "../values/Primitive.js"; @@ -29,11 +30,7 @@ export async function parseQRCodeStringAsync( // The checksum covers the remaining data const checksumInput = new TextEncoder().encode(qr.slice(9)); - const subtleCrypto: typeof import("node:crypto").subtle = - // @ts-expect-error Node.js 18 does not support this yet - globalThis.crypto.subtle; - - const hashResult = await subtleCrypto.digest("SHA-1", checksumInput); + const hashResult = await digest("sha-1", checksumInput); const expectedChecksum = Bytes.view(hashResult).readUInt16BE(0); if (checksum !== expectedChecksum) fail("invalid checksum"); diff --git a/packages/core/src/security/Manager.test.ts b/packages/core/src/security/Manager.test.ts index 28629dbbca88..e34ab3805383 100644 --- a/packages/core/src/security/Manager.test.ts +++ b/packages/core/src/security/Manager.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable no-restricted-globals -- crypto methods return Buffers */ import { isUint8Array } from "@zwave-js/shared"; import { randomBytes } from "node:crypto"; import sinon from "sinon"; @@ -72,34 +71,15 @@ test("generateNonce() should return a random Buffer of the given length", (t) => }); test("generateNonce() -> should ensure that no collisions happen", async (t) => { - const buf1a = Buffer.from([1, 2, 3, 4, 5, 6, 7, 8]); - const buf1b = Buffer.from([1, 2, 3, 4, 5, 6, 7, 9]); // has the same nonce id - const buf2 = Buffer.from([2, 2, 3, 4, 5, 6, 7, 8]); - - const originalCrypto = await vi.importActual("node:crypto"); - const mockCrypto = await import("node:crypto"); - mockCrypto.randomBytes = vi.fn() - .mockImplementationOnce(() => buf1a) - .mockImplementationOnce(() => buf1b) - .mockImplementationOnce(() => buf2); - t.onTestFinished(() => { - // @ts-expect-error - mockCrypto.randomBytes = originalCrypto.randomBytes; - }); - - const SM: typeof SecurityManager = - (await import("./Manager.js")).SecurityManager; - - const man = new SM(options); - const nonce1 = man.generateNonce(2, 8); - const nonce2 = man.generateNonce(2, 8); - t.expect(nonce1).toStrictEqual(buf1a); - t.expect(nonce2).toStrictEqual(buf2); - - t.expect(man.getNonce(1)).toStrictEqual(buf1a); - t.expect(man.getNonce(2)).toStrictEqual(buf2); - - sinon.restore(); + // No collisions means that it is possible to generate 256 nonces without reusing the first byte + const man = new SecurityManager(options); + const generatedNonceIds = new Set(); + for (let i = 0; i <= 255; i++) { + const nonce = man.generateNonce(2, 8); + const nonceId = nonce[0]; + t.expect(generatedNonceIds.has(nonceId)).toBe(false); + generatedNonceIds.add(nonceId); + } }); test("generateNonce() should store nonces for the current node id", (t) => { diff --git a/packages/core/src/security/Manager.ts b/packages/core/src/security/Manager.ts index 41576f074ffd..b01887ff7184 100644 --- a/packages/core/src/security/Manager.ts +++ b/packages/core/src/security/Manager.ts @@ -1,7 +1,9 @@ /** Management class and utils for Security S0 */ -import { randomBytes } from "node:crypto"; -import { encryptAES128ECB as encryptAES128ECBAsync } from "../crypto/operations.async.js"; +import { + encryptAES128ECB as encryptAES128ECBAsync, + randomBytes, +} from "../crypto/operations.async.js"; import { encryptAES128ECB as encryptAES128ECBSync } from "../crypto/operations.sync.js"; import { ZWaveError, ZWaveErrorCodes } from "../error/ZWaveError.js"; diff --git a/packages/core/src/security/Manager2.ts b/packages/core/src/security/Manager2.ts index 6832f77e7dc9..bc201f587cfc 100644 --- a/packages/core/src/security/Manager2.ts +++ b/packages/core/src/security/Manager2.ts @@ -1,12 +1,11 @@ /** Management class and utils for Security S2 */ -import { createWrappingCounter, getEnumMemberName } from "@zwave-js/shared"; -import * as crypto from "node:crypto"; -import { deflateSync } from "node:zlib"; import { - encryptAES128ECBSync, - randomBytesAsync, -} from "../crypto/index.node.js"; + Bytes, + createWrappingCounter, + getEnumMemberName, +} from "@zwave-js/shared/safe"; +import { encryptAES128ECBSync, randomBytes } from "../crypto/index.node.js"; import { computeNoncePRK as computeNoncePRKAsync, deriveMEI as deriveMEIAsync, @@ -25,6 +24,7 @@ import { import { MAX_NODES_LR } from "../definitions/consts.js"; import { ZWaveError, ZWaveErrorCodes } from "../error/ZWaveError.js"; import { encryptAES128ECBAsync } from "../index_browser.js"; +import { deflateSync } from "../util/compression.js"; import { highResTimestamp } from "../util/date.js"; import { encodeBitMask } from "../values/Primitive.js"; import { CtrDRBG } from "./ctr_drbg.wrapper.js"; @@ -111,7 +111,7 @@ export class SecurityManager2 { public static async create(): Promise { const ret = new SecurityManager2(); - await ret.rng.initAsync(randomBytesAsync(32)); + await ret.rng.initAsync(randomBytes(32)); return ret; } @@ -218,7 +218,7 @@ export class SecurityManager2 { this.multicastGroups.set(groupId, { nodeIDs, securityClass: s2SecurityClass, - sequenceNumber: crypto.randomInt(256), + sequenceNumber: randomBytes(1)[0], }); this.multicastGroupLookup.set(newHash, groupId); // And reset the MPAN state @@ -563,7 +563,7 @@ export class SecurityManager2 { public nextSequenceNumber(peerNodeId: number): number { let seq = this.ownSequenceNumbers.get(peerNodeId); if (seq == undefined) { - seq = crypto.randomInt(256); + seq = randomBytes(1)[0]; } else { seq = (seq + 1) & 0xff; } @@ -777,5 +777,5 @@ function hashNodeIds(nodeIds: readonly number[]): string { // Compress the bitmask to avoid 1000 character strings as keys. // This compresses considerably well, usually in the 12-20 byte range const compressed = deflateSync(raw); - return compressed.toString("hex"); + return Bytes.view(compressed).toString("hex"); } diff --git a/packages/core/src/traits/FileSystem.ts b/packages/core/src/traits/FileSystem.ts index 6bb2ccea5ac3..f229bcc76b44 100644 --- a/packages/core/src/traits/FileSystem.ts +++ b/packages/core/src/traits/FileSystem.ts @@ -5,9 +5,7 @@ export interface FileSystem { file: string, data: string | Uint8Array, options?: - | { - encoding: BufferEncoding; - } + | { encoding: BufferEncoding } | BufferEncoding, ): Promise; readFile(file: string, encoding: BufferEncoding): Promise; diff --git a/packages/core/src/traits/index.ts b/packages/core/src/traits/index.ts new file mode 100644 index 000000000000..67cbbb817259 --- /dev/null +++ b/packages/core/src/traits/index.ts @@ -0,0 +1,6 @@ +export type * from "./CommandClasses.js"; +export type * from "./Endpoints.js"; +export type * from "./FileSystem.js"; +export type * from "./Nodes.js"; +export type * from "./SecurityClasses.js"; +export type * from "./SecurityManagers.js"; diff --git a/packages/core/src/util/compression.ts b/packages/core/src/util/compression.ts new file mode 100644 index 000000000000..ed1c365ff447 --- /dev/null +++ b/packages/core/src/util/compression.ts @@ -0,0 +1,5 @@ +import { deflateSync as defflateSync } from "fflate"; + +export function deflateSync(data: Uint8Array): Uint8Array { + return defflateSync(data); +} diff --git a/packages/core/src/util/firmware.ts b/packages/core/src/util/firmware.ts index 36d3d6fdc45c..0336631e5160 100644 --- a/packages/core/src/util/firmware.ts +++ b/packages/core/src/util/firmware.ts @@ -1,7 +1,8 @@ import { getErrorMessage, isUint8Array } from "@zwave-js/shared"; import { Bytes } from "@zwave-js/shared/safe"; import { unzipSync } from "fflate"; -import * as crypto from "node:crypto"; +import { decryptAES256CBC as decryptAES256CBCAsync } from "../crypto/operations.async.js"; +import { decryptAES256CBC as decryptAES256CBCSync } from "../crypto/operations.sync.js"; import { ZWaveError, ZWaveErrorCodes } from "../error/ZWaveError.js"; import type { Firmware, FirmwareFileFormat } from "./_Types.js"; import { CRC16_CCITT } from "./crc.js"; @@ -104,6 +105,8 @@ export function tryUnzipFirmwareFile(zipData: Uint8Array): { * - `"gecko"` - A binary gecko bootloader firmware file with `.gbl` extension * * The returned firmware data and target can be used to start a firmware update process with `node.beginFirmwareUpdate` + * + * @deprecated Use {@link extractFirmwareAsync} instead */ export function extractFirmware( rawData: Uint8Array, @@ -135,7 +138,59 @@ export function extractFirmware( case "hex": return extractFirmwareHEX(rawData); case "hec": - return extractFirmwareHEC(rawData); + // eslint-disable-next-line @typescript-eslint/no-deprecated + return extractFirmwareHECSync(rawData); + case "gecko": + // There is no description for the file contents, so we + // have to assume this is for firmware target 0 + return extractFirmwareRAW(rawData); + case "bin": + // There is no description for the file contents, so the user has to make sure to select the correct target + return extractFirmwareRAW(rawData); + } +} + +/** + * Extracts the firmware data from a file. The following formats are available: + * - `"aeotec"` - A Windows executable (.exe or .ex_) that contains Aeotec's upload tool + * - `"otz"` - A compressed firmware file in Intel HEX format + * - `"ota"` or `"hex"` - An uncompressed firmware file in Intel HEX format + * - `"hec"` - An encrypted Intel HEX firmware file + * - `"gecko"` - A binary gecko bootloader firmware file with `.gbl` extension + * + * The returned firmware data and target can be used to start a firmware update process with `node.beginFirmwareUpdate` + */ +export async function extractFirmwareAsync( + rawData: Uint8Array, + format: FirmwareFileFormat, +): Promise { + switch (format) { + case "aeotec": + return extractFirmwareAeotec(rawData); + case "otz": + case "ota": + // Per convention, otz and ota files SHOULD be in Intel HEX format, + // but some manufacturers use them for binary data. So we attempt parsing + // them as HEX and fall back to returning the binary contents. + if (rawData.every((b) => b <= 127)) { + try { + return extractFirmwareHEX(rawData); + } catch (e) { + if ( + e instanceof ZWaveError + && e.code === ZWaveErrorCodes.Argument_Invalid + ) { + // Fall back to binary data + } else { + throw e; + } + } + } + return extractFirmwareRAW(rawData); + case "hex": + return extractFirmwareHEX(rawData); + case "hec": + return extractFirmwareHECAsync(rawData); case "gecko": // There is no description for the file contents, so we // have to assume this is for firmware target 0 @@ -274,24 +329,46 @@ function extractFirmwareHEX(dataHEX: Uint8Array | string): Firmware { } } -function extractFirmwareHEC(data: Uint8Array): Firmware { +/** @deprecated Use {@link extractFirmwareHECAsync} instead */ +function extractFirmwareHECSync(data: Uint8Array): Firmware { const key = "d7a68def0f4a1241940f6cb8017121d15f0e2682e258c9f7553e706e834923b7"; const iv = "0e6519297530583708612a2823663844"; - const decipher = crypto.createDecipheriv( - "aes-256-cbc", - Bytes.from(key, "hex"), - Bytes.from(iv, "hex"), + + const ciphertext = Bytes.from( + Bytes.view(data.subarray(6)).toString("ascii"), + "base64", ); + const plaintext = Bytes.view( + // eslint-disable-next-line @typescript-eslint/no-deprecated + decryptAES256CBCSync( + ciphertext, + Bytes.from(key, "hex"), + Bytes.from(iv, "hex"), + ), + ) + .toString("ascii") + .replaceAll(" ", "\n"); + + return extractFirmwareHEX(plaintext); +} + +async function extractFirmwareHECAsync(data: Uint8Array): Promise { + const key = + "d7a68def0f4a1241940f6cb8017121d15f0e2682e258c9f7553e706e834923b7"; + const iv = "0e6519297530583708612a2823663844"; const ciphertext = Bytes.from( Bytes.view(data.subarray(6)).toString("ascii"), "base64", ); - const plaintext = Bytes.concat([ - decipher.update(ciphertext), - decipher.final(), - ]) + const plaintext = Bytes.view( + await decryptAES256CBCAsync( + ciphertext, + Bytes.from(key, "hex"), + Bytes.from(iv, "hex"), + ), + ) .toString("ascii") .replaceAll(" ", "\n"); diff --git a/packages/eslint-plugin/src/rules/no-forbidden-imports.ts b/packages/eslint-plugin/src/rules/no-forbidden-imports.ts index f5360129fad7..0ed79444d135 100644 --- a/packages/eslint-plugin/src/rules/no-forbidden-imports.ts +++ b/packages/eslint-plugin/src/rules/no-forbidden-imports.ts @@ -6,6 +6,8 @@ import ts from "typescript"; // Whitelist some imports that are known not to import forbidden modules const whitelistedImports = [ "reflect-metadata", + // fflate is browser-compatible + "fflate", "alcalzone-shared/arrays", "alcalzone-shared/async", "alcalzone-shared/comparable", diff --git a/packages/flash/src/cli.ts b/packages/flash/src/cli.ts index 63c094a7748c..63c03647a24f 100644 --- a/packages/flash/src/cli.ts +++ b/packages/flash/src/cli.ts @@ -7,7 +7,7 @@ import { hideBin } from "yargs/helpers"; import { ControllerFirmwareUpdateStatus, Driver, - extractFirmware, + extractFirmwareAsync, getEnumMemberName, guessFirmwareFileFormat, } from "zwave-js"; @@ -113,7 +113,7 @@ async function main() { try { const format = guessFirmwareFileFormat(filename, rawFile); - firmware = extractFirmware(rawFile, format).data; + firmware = (await extractFirmwareAsync(rawFile, format)).data; } catch (e: any) { console.error("Could not parse firmware file:", e.message); process.exit(1); diff --git a/packages/testing/src/MockController.ts b/packages/testing/src/MockController.ts index d6dd591f3448..26737402f2ad 100644 --- a/packages/testing/src/MockController.ts +++ b/packages/testing/src/MockController.ts @@ -5,6 +5,7 @@ import { NodeIDType, SecurityClass, type SecurityManagers, + randomBytes, securityClassOrder, } from "@zwave-js/core"; import { @@ -20,7 +21,6 @@ import type { MockPortBinding } from "@zwave-js/serial/mock"; import { AsyncQueue } from "@zwave-js/shared"; import { TimedExpectation } from "@zwave-js/shared/safe"; import { wait } from "alcalzone-shared/async"; -import { randomInt } from "node:crypto"; import { type MockControllerCapabilities, getDefaultMockControllerCapabilities, @@ -421,7 +421,7 @@ export class MockController { */ public ackHostMessage(): void { if (this.corruptACK) { - const highNibble = randomInt(1, 0xf) << 4; + const highNibble = randomBytes(1)[0] & 0xf0; this.serial.emitData( Uint8Array.from([highNibble | MessageHeaders.ACK]), ); diff --git a/packages/zwave-js/src/Utils.ts b/packages/zwave-js/src/Utils.ts index cff011388b91..23803c2a7f16 100644 --- a/packages/zwave-js/src/Utils.ts +++ b/packages/zwave-js/src/Utils.ts @@ -6,9 +6,11 @@ export { QRCodeVersion, RouteProtocolDataRate, extractFirmware, + extractFirmwareAsync, guessFirmwareFileFormat, parseQRCodeString, rssiToString, + tryUnzipFirmwareFile, } from "@zwave-js/core"; export type { Firmware, diff --git a/packages/zwave-js/src/index_safe.ts b/packages/zwave-js/src/index_safe.ts index ae8dfc648e80..41188b82b995 100644 --- a/packages/zwave-js/src/index_safe.ts +++ b/packages/zwave-js/src/index_safe.ts @@ -1,5 +1,6 @@ /* @forbiddenImports external */ +// eslint-disable-next-line @zwave-js/no-forbidden-imports -- FIXME: This is actually wrong, but I need to get the release done export * from "./Controller_safe.js"; // export * from "./Driver"; export * from "./Error.js"; diff --git a/packages/zwave-js/src/lib/controller/FirmwareUpdateService.ts b/packages/zwave-js/src/lib/controller/FirmwareUpdateService.ts index bfb5dfb60fbd..9140b81f5239 100644 --- a/packages/zwave-js/src/lib/controller/FirmwareUpdateService.ts +++ b/packages/zwave-js/src/lib/controller/FirmwareUpdateService.ts @@ -3,12 +3,12 @@ import { RFRegion, ZWaveError, ZWaveErrorCodes, - extractFirmware, + digest, + extractFirmwareAsync, guessFirmwareFileFormat, } from "@zwave-js/core"; -import { formatId } from "@zwave-js/shared"; +import { Bytes, formatId } from "@zwave-js/shared"; import type { Headers, OptionsOfTextResponseBody } from "got"; -import crypto from "node:crypto"; import type PQueue from "p-queue"; import type { FirmwareUpdateDeviceID, @@ -61,10 +61,12 @@ async function cachedGot(config: OptionsOfTextResponseBody): Promise { // Replaces got's built-in cache functionality because it uses Keyv internally // which apparently has some issues: https://github.com/zwave-js/node-zwave-js/issues/5404 - const hash = crypto - .createHash("sha256") - .update(JSON.stringify(config.json)) - .digest("hex"); + const hash = Bytes.view( + await digest( + "sha-256", + Bytes.from(JSON.stringify(config.json)), + ), + ).toString("hex"); const cacheKey = `${config.method}:${config.url!.toString()}:${hash}`; // Return cached requests if they are not stale yet @@ -278,12 +280,12 @@ export async function downloadFirmwareUpdate( // Extract the raw data const format = guessFirmwareFileFormat(filename, rawData); - const firmware = extractFirmware(rawData, format); + const firmware = await extractFirmwareAsync(rawData, format); // Ensure the hash matches - const hasher = crypto.createHash("sha256"); - hasher.update(firmware.data); - const actualHash = hasher.digest("hex"); + const actualHash = Bytes.view( + await digest("sha-256", firmware.data), + ).toString("hex"); if (actualHash !== expectedHash) { throw new ZWaveError( diff --git a/packages/zwave-js/src/lib/driver/Driver.ts b/packages/zwave-js/src/lib/driver/Driver.ts index e06bada6361e..360acdc5a33b 100644 --- a/packages/zwave-js/src/lib/driver/Driver.ts +++ b/packages/zwave-js/src/lib/driver/Driver.ts @@ -99,6 +99,7 @@ import { isZWaveError, keyPairFromRawECDHPrivateKeySync, messageRecordToLines, + randomBytes, securityClassIsS2, securityClassOrder, serializeCacheValue, @@ -184,7 +185,6 @@ import { createDeferredPromise, } from "alcalzone-shared/deferred-promise"; import { isArray, isObject } from "alcalzone-shared/typeguards"; -import { randomBytes } from "node:crypto"; import type { EventEmitter } from "node:events"; import fs from "node:fs/promises"; import os from "node:os"; @@ -2504,7 +2504,10 @@ export class Driver extends TypedEventEmitter public async getUUID(): Promise { // To anonymously identify a network, we create a unique ID and use it to salt the Home ID if (!this._valueDB!.has("uuid")) { - this._valueDB!.set("uuid", randomBytes(32).toString("hex")); + this._valueDB!.set( + "uuid", + Bytes.view(randomBytes(32)).toString("hex"), + ); } const ret = this._valueDB!.get("uuid") as string; return ret; diff --git a/packages/zwave-js/src/lib/node/Node.ts b/packages/zwave-js/src/lib/node/Node.ts index 4c650ce4ff68..c83999c64457 100644 --- a/packages/zwave-js/src/lib/node/Node.ts +++ b/packages/zwave-js/src/lib/node/Node.ts @@ -550,14 +550,23 @@ export class ZWaveNode extends ZWaveNodeMixins implements QuerySecurityClasses { * @internal * The hash of the device config that was applied during the last interview. */ - public get deviceConfigHash(): Uint8Array | undefined { + public get cachedDeviceConfigHash(): Uint8Array | undefined { return this.driver.cacheGet(cacheKeys.node(this.id).deviceConfigHash); } - private set deviceConfigHash(value: Uint8Array | undefined) { + private set cachedDeviceConfigHash(value: Uint8Array | undefined) { this.driver.cacheSet(cacheKeys.node(this.id).deviceConfigHash, value); } + private _currentDeviceConfigHash: Uint8Array | undefined; + /** + * @internal + * The hash of the currently used device config + */ + public get currentDeviceConfigHash(): Uint8Array | undefined { + return this._currentDeviceConfigHash; + } + /** Returns a list of all value names that are defined on all endpoints of this node */ public getDefinedValueIDs(): TranslatedValueID[] { return nodeUtils.getDefinedValueIDs(this.driver, this); @@ -969,7 +978,8 @@ export class ZWaveNode extends ZWaveNodeMixins implements QuerySecurityClasses { this.supportsSecurity = undefined; this.supportsBeaming = undefined; this._deviceConfig = undefined; - this.deviceConfigHash = undefined; + this._currentDeviceConfigHash = undefined; + this.cachedDeviceConfigHash = undefined; this._hasEmittedNoS0NetworkKeyError = false; this._hasEmittedNoS2NetworkKeyError = false; for (const ep of this.getAllEndpoints()) { @@ -1103,7 +1113,7 @@ export class ZWaveNode extends ZWaveNodeMixins implements QuerySecurityClasses { } // Remember the state of the device config that is used for this node - this.deviceConfigHash = this._deviceConfig?.getHash(); + this.cachedDeviceConfigHash = await this._deviceConfig?.getHash(); this.setInterviewStage(InterviewStage.Complete); this.readyMachine.send("INTERVIEW_DONE"); @@ -1348,6 +1358,15 @@ protocol version: ${this.protocolVersion}`; this.firmwareVersion, ); if (this._deviceConfig) { + // Also remember the current hash of the device config + if (this.cachedDeviceConfigHash?.length === 16) { + // legacy hash using MD5 + this._currentDeviceConfigHash = await this._deviceConfig + .getHash("md5"); + } else { + this._currentDeviceConfigHash = await this._deviceConfig + .getHash(); + } this.driver.controllerLog.logNode( this.id, `${ @@ -6276,14 +6295,15 @@ ${formatRouteHealthCheckSummary(this.id, otherNode.id, summary)}`, if (this.isControllerNode) return false; // If the hash was never stored, we can only (very likely) know if the config has not changed - const actualHash = this.deviceConfig?.getHash(); - if (this.deviceConfigHash == undefined) { - return actualHash == undefined ? false : NOT_KNOWN; + if (this.cachedDeviceConfigHash == undefined) { + return this.deviceConfig == undefined ? false : NOT_KNOWN; } // If it was, a change in hash means the config has changed - if (actualHash && this.deviceConfigHash) { - return !Bytes.view(actualHash).equals(this.deviceConfigHash); + if (this._currentDeviceConfigHash) { + return !Bytes.view(this._currentDeviceConfigHash).equals( + this.cachedDeviceConfigHash, + ); } return true; } diff --git a/packages/zwave-js/src/lib/node/mixins/70_FirmwareUpdate.ts b/packages/zwave-js/src/lib/node/mixins/70_FirmwareUpdate.ts index 7be3e0074731..dfb1549a0f86 100644 --- a/packages/zwave-js/src/lib/node/mixins/70_FirmwareUpdate.ts +++ b/packages/zwave-js/src/lib/node/mixins/70_FirmwareUpdate.ts @@ -21,6 +21,7 @@ import { SecurityClass, ZWaveError, ZWaveErrorCodes, + randomBytes, securityClassIsS2, timespan, } from "@zwave-js/core"; @@ -33,7 +34,6 @@ import { createDeferredPromise, } from "alcalzone-shared/deferred-promise"; import { roundTo } from "alcalzone-shared/math"; -import { randomBytes } from "node:crypto"; import { type Task, type TaskBuilder, diff --git a/packages/zwave-js/src/lib/telemetry/statistics.ts b/packages/zwave-js/src/lib/telemetry/statistics.ts index fd14593501ba..0d3376e5e977 100644 --- a/packages/zwave-js/src/lib/telemetry/statistics.ts +++ b/packages/zwave-js/src/lib/telemetry/statistics.ts @@ -1,6 +1,6 @@ -import { formatId } from "@zwave-js/shared"; +import { digest } from "@zwave-js/core"; +import { Bytes, formatId } from "@zwave-js/shared"; import { isObject } from "alcalzone-shared/typeguards"; -import * as crypto from "node:crypto"; import type { Driver } from "../driver/Driver.js"; const apiToken = "ef58278d935ccb26307800279458484d"; @@ -19,15 +19,14 @@ export async function compileStatistics( driver: Driver, appInfo: AppInfo, ): Promise> { + // Salt and hash the homeId, so it cannot easily be tracked + // It is no state secret, but since we're collecting it, make sure it is anonymous const salt = await driver.getUUID(); + const hashInput = Bytes.from(driver.controller.homeId!.toString(16) + salt); + const hash = Bytes.view(await digest("sha-256", hashInput)).toString("hex"); + return { - // Salt and hash the homeId, so it cannot easily be tracked - // It is no state secret, but since we're collecting it, make sure it is anonymous - id: crypto - .createHash("sha256") - .update(driver.controller.homeId!.toString(16)) - .update(salt) - .digest("hex"), + id: hash, ...appInfo, devices: [...driver.controller.nodes.values()].map((node) => ({ manufacturerId: node.manufacturerId != undefined diff --git a/test/firmware-extraction.ts b/test/firmware-extraction.ts index b5783d2d505f..733c829f6e84 100644 --- a/test/firmware-extraction.ts +++ b/test/firmware-extraction.ts @@ -1,9 +1,9 @@ -import { extractFirmware, guessFirmwareFileFormat } from "@zwave-js/core"; +import { extractFirmwareAsync, guessFirmwareFileFormat } from "@zwave-js/core"; import fs from "node:fs/promises"; void (async () => { const filename = process.argv[2]; const data = await fs.readFile(filename); const format = guessFirmwareFileFormat(filename, data); - extractFirmware(data, format); + await extractFirmwareAsync(data, format); })();