iobroker.roborock
Version:
272 lines (234 loc) • 11.1 kB
text/typescript
import * as crypto from "node:crypto";
import { cryptoEngine } from "../../cryptoEngine";
import * as MapHelper from "../MapHelper";
import { isQ10YxMapPayload } from "../q10/Q10YxMapParser";
export class MapDecryptor {
/**
* @doc:Encryption.md
* ### B01 Map Decryption
*
* B01 map decryption. Follows the spec derived from the test fixture and docs; the original app
* (see .cursorrules) handles 301 in o00O00OO.OooO0O0 → o00OO000.OooO00o.OooOooo / OooOooO, but the
* exact layer implementation is not visible in the decompiled APK.
*
* Data flow: MQTT frame → messageParser.decodeMsg() reads
* payloadLen (uint16 at offset 16), takes payload = frame.subarray(19, 19+payloadLen), then
* decryptB01(payload, localKey, random) → data.payload. For 301 that buffer is passed to
* getB01MapBuffer → decryptB01Payload → this decrypt(). So buf here is the inner payload after
* outer B01 CBC only; no 301-specific header is stripped (unlike PhotoManager P301 dataSkip).
* If buf starts with "B01", unwrapLayerCBC slices inner payload using payloadLen at buf[17..18].
*
* @see docs/map/B01_Map_Protocol.md
* @see test/unit/b01_map_specification.test.ts
*/
static decrypt(buf: Buffer, serial: string, model: string, _duid: string, _adapter?: any, localKey?: string): Buffer | null {
if (MapDecryptor.isSupportedB01MapPayload(buf)) return MapDecryptor.normalizeSupportedPayload(buf);
let current = buf;
// 1. Layer 1: Protocol Wrapper (B01 AES-CBC); payload size from header (offset 17)
current = MapDecryptor.unwrapLayerCBC(current, localKey);
current = MapDecryptor.normalizeSupportedPayload(current);
if (MapDecryptor.isSupportedB01MapPayload(current)) return current;
// 2. Layer 2: Transport Decoding (Base64)
current = MapDecryptor.unwrapBase64(current);
current = MapDecryptor.normalizeSupportedPayload(current);
if (MapDecryptor.isSupportedB01MapPayload(current)) return current;
// 3. Layer 3: Map Data Encryption (AES-ECB)
current = MapDecryptor.unwrapLayerECB(current, serial, model);
current = MapDecryptor.normalizeSupportedPayload(current);
if (MapDecryptor.isSupportedB01MapPayload(current)) return current;
// 4. Layer 4: Post-Cipher Transport Decoding (Hex-ASCII)
current = MapDecryptor.unwrapHex(current);
current = MapDecryptor.normalizeSupportedPayload(current);
if (MapDecryptor.isSupportedB01MapPayload(current)) return current;
// 5. Layer 5: Decompression (ZLIB/GZIP)
current = MapDecryptor.unwrapDecompression(current);
current = MapDecryptor.normalizeSupportedPayload(current);
const validB01Map = current && MapDecryptor.isSupportedB01MapPayload(current);
return validB01Map ? current : null;
}
private static unwrapBase64(current: Buffer): Buffer {
const checkStr = current.subarray(0, Math.min(current.length, 100)).toString("utf8");
if (/^[A-Za-z0-9+/= \r\n]+$/.test(checkStr)) {
try {
const decoded = Buffer.from(current.toString("utf8"), "base64");
if (decoded.length > 0 && decoded.length !== current.length) return decoded;
} catch {}
}
return current;
}
private static unwrapHex(current: Buffer): Buffer {
const checkStr = current.subarray(0, Math.min(current.length, 100)).toString("utf8");
if (/^[0-9a-fA-F]+$/.test(checkStr.substring(0, 10)) && (checkStr.startsWith("78") || checkStr.startsWith("1f"))) {
try {
const decoded = Buffer.from(current.toString("utf8"), "hex");
if (decoded.length > 0 && decoded.length !== current.length) return decoded;
} catch {}
}
return current;
}
private static unwrapLayerCBC(current: Buffer, localKey: string | undefined): Buffer {
if (current.length > 19 && current.toString("ascii", 0, 3) === "B01") {
try {
const ivSeed = current.readUInt32BE(7);
const payloadLen = current.readUInt16BE(17);
const payload = current.subarray(19, 19 + payloadLen);
if (localKey) {
const derivedIV = cryptoEngine.deriveB01IV(ivSeed);
const decrypted = MapDecryptor.decryptCBC(payload, localKey, derivedIV);
if (decrypted) return decrypted;
}
} catch (e: any) {
MapDecryptor.logDebug(undefined, `Layer 1: CBC Decryption failed: ${e.message}`, "warn");
}
}
return current;
}
private static unwrapLayerECB(current: Buffer, serial: string, model: string): Buffer {
if (!serial || !model || current.length % 16 !== 0) return current;
try {
const mapKey = MapDecryptor.deriveMapKey(serial, model);
const decrypted = MapDecryptor.decryptECB(current, mapKey);
// After ECB: zlib (0x78) or gzip (0x1f), or hex-ASCII "78"/"1f" (0x37 0x38 / 0x31 0x66)
if (decrypted && decrypted.length > 0 && (decrypted[0] === 0x78 || decrypted[0] === 0x1f || (decrypted[0] === 0x37 && decrypted[1] === 0x38) || (decrypted[0] === 0x31 && decrypted[1] === 0x66))) return decrypted;
} catch (e: any) {
MapDecryptor.logDebug(undefined, `Layer 3: ECB failed: ${e.message}`, "warn");
}
return current;
}
private static unwrapDecompression(current: Buffer): Buffer {
return MapHelper.decompress(current);
}
private static deriveMapKey(serial: string, model: string): Buffer {
const modelSuffix = model.includes(".") ? (model.split(".").pop() as string) : model;
// Standard key derivation
let p = modelSuffix;
while (p.length < 16) p += "0";
const key = Buffer.from(p.substring(0, 16), "utf8");
const inputStr = `${serial}+${modelSuffix}+${serial}`;
const inputBuf = Buffer.from(inputStr, "utf8");
// Apply PKCS7 padding
const z = 16 - (inputBuf.length % 16);
const paddedInput = Buffer.concat([inputBuf, Buffer.alloc(z, z)]);
const cipher = crypto.createCipheriv("aes-128-ecb", key, null);
cipher.setAutoPadding(false);
let encrypted = cipher.update(paddedInput);
encrypted = Buffer.concat([encrypted, cipher.final()]);
const hash = crypto.createHash("md5").update(encrypted.toString("base64")).digest("hex");
return Buffer.from(hash.substring(8, 24).toLowerCase(), "utf8");
}
private static decryptECB(encrypted: Buffer, key: Buffer): Buffer {
const decipher = crypto.createDecipheriv("aes-128-ecb", key, null);
decipher.setAutoPadding(true);
return Buffer.concat([decipher.update(encrypted), decipher.final()]);
}
private static decryptCBC(encrypted: Buffer, key: string | Buffer, iv: Buffer): Buffer | null {
try {
const keyBuf = typeof key === "string" ? Buffer.from(key, "utf8") : key;
const decipher = crypto.createDecipheriv("aes-128-cbc", keyBuf, iv);
decipher.setAutoPadding(true);
return Buffer.concat([decipher.update(encrypted), decipher.final()]);
} catch {
return null;
}
}
public static isSignatureMatch(buf: Buffer): boolean {
return MapHelper.isSignatureMatch(buf);
}
public static isLikelyProtobuf(buf: Buffer): boolean {
return MapHelper.isLikelyProtobuf(buf);
}
/** True if decrypted payload is a B01 history/cleaning map (08 15 12...), not live (08 00 12...). */
public static isHistoryMap(buf: Buffer): boolean {
return buf != null && buf.length >= 3 && buf[0] === 0x08 && buf[1] === 0x15 && buf[2] === 0x12;
}
/** True only if buf is a B01 map protobuf: 08 00 12 (live) or 08 15 12 (history). Rejects other protobufs (e.g. 0a ...). */
public static isB01MapProtobuf(buf: Buffer): boolean {
return buf != null && buf.length >= 3 && buf[0] === 0x08 && buf[2] === 0x12 && (buf[1] === 0x00 || buf[1] === 0x15);
}
public static isLikelyQ10MapPayload(buf: Buffer): boolean {
return isQ10YxMapPayload(buf) || (buf.length > 1 && buf[0] === 1 && isQ10YxMapPayload(buf.subarray(1)));
}
public static getQ10BlobType(buf: Buffer): 1 | 2 | 3 | 4 | null {
if (!buf || buf.length < 2) return null;
const blobType = buf[0];
return blobType === 1 || blobType === 2 || blobType === 3 || blobType === 4 ? blobType : null;
}
public static isLikelyQ10BlobPayload(buf: Buffer): boolean {
const blobType = MapDecryptor.getQ10BlobType(buf);
if (blobType === null) return false;
if (blobType === 1) {
return MapDecryptor.isLikelyQ10MapPayload(buf);
}
if (blobType === 2) {
return buf.length >= 14;
}
if (blobType === 3 || blobType === 4) {
return buf.length >= 28;
}
return false;
}
public static isSupportedB01MapPayload(buf: Buffer): boolean {
const normalized = MapDecryptor.normalizeSupportedPayload(buf);
return MapDecryptor.isB01MapProtobuf(normalized) || MapDecryptor.isLikelyQ10MapPayload(normalized) || MapDecryptor.isLikelyQ10BlobPayload(normalized);
}
/**
* Runs Layers 2–5 (Base64 → ECB → Hex → Decompress) on a buffer that is already
* the concatenated output of Layer 1 (e.g. from multiple B01 chunks decrypted separately).
* Use for chunked B01 streams where each chunk has its own B01 header and IV.
*/
static decryptFromLayer2(
layer1Concatenated: Buffer,
serial: string,
model: string,
_duid: string,
_adapter?: any,
_localKey?: string
): Buffer | null {
void _duid;
void _adapter;
void _localKey;
if (MapDecryptor.isSupportedB01MapPayload(layer1Concatenated)) return MapDecryptor.normalizeSupportedPayload(layer1Concatenated);
let current = layer1Concatenated;
current = MapDecryptor.unwrapBase64(current);
current = MapDecryptor.normalizeSupportedPayload(current);
if (MapDecryptor.isSupportedB01MapPayload(current)) return current;
current = MapDecryptor.unwrapLayerECB(current, serial, model);
current = MapDecryptor.normalizeSupportedPayload(current);
if (MapDecryptor.isSupportedB01MapPayload(current)) return current;
current = MapDecryptor.unwrapHex(current);
current = MapDecryptor.normalizeSupportedPayload(current);
if (MapDecryptor.isSupportedB01MapPayload(current)) return current;
current = MapDecryptor.unwrapDecompression(current);
current = MapDecryptor.normalizeSupportedPayload(current);
return current && MapDecryptor.isSupportedB01MapPayload(current) ? current : null;
}
/** Decrypts only Layer 1 (B01 AES-CBC wrapper). Returns inner payload (e.g. base64 or binary). */
static decryptLayer1Only(buf: Buffer, localKey: string | undefined): Buffer | null {
if (buf.length <= 19 || buf.toString("ascii", 0, 3) !== "B01") return null;
try {
const payloadLen = buf.readUInt16BE(17);
if (19 + payloadLen > buf.length) return null;
const payload = buf.subarray(19, 19 + payloadLen);
if (!localKey) return null;
const ivSeed = buf.readUInt32BE(7);
const derivedIV = cryptoEngine.deriveB01IV(ivSeed);
const decrypted = MapDecryptor.decryptCBC(payload, localKey, derivedIV);
return decrypted;
} catch {
return null;
}
}
private static normalizeSupportedPayload(buf: Buffer): Buffer {
return buf;
}
private static logDebug(adapter: any, msg: string, level: "debug" | "warn" | "error" = "debug"): void {
if (adapter && (level === "warn" || level === "error")) {
if (typeof adapter.rLog === "function") {
adapter.rLog("System", null, level === "warn" ? "Warn" : "Error", "B01Decrypt", undefined, msg, level);
} else if (adapter.log) {
if (level === "warn") adapter.log.warn(`B01Decrypt: ${msg}`);
else adapter.log.error(`B01Decrypt: ${msg}`);
}
}
}
}