Skip to content

Victron Energy Decryption #295

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Apr 17, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions TheengsGateway/ble_gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -715,6 +715,12 @@ def handle_encrypted_advertisement(
decoded_json = decodeBLE(json.dumps(data_json))
if decoded_json:
decoded_json = json.loads(decoded_json) # type: ignore[arg-type]
# Check if the mic matches the first byte of the bindkey for bindkey verification
elif mic != bindkey[:1].hex():
logger.exception(
"Bindkey does not seem to be correct for `%s`",
get_address(decoded_json),
)
else:
logger.exception(
"Decrypted payload not supported: `%s`",
Expand Down
43 changes: 43 additions & 0 deletions TheengsGateway/decryption.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,10 +135,53 @@ def replace_encrypted_data(
bthome_service_data.extend(decrypted_data)
data_json["servicedata"] = bthome_service_data.hex()

class VictronDecryptor(AdvertisementDecryptor):
"""Class for decryption of Victron Energy encrypted advertisements."""

def compute_nonce(self, address: str, decoded_json: dict) -> bytes:
"""Get the nonce from a specific address and JSON input."""
# The nonce is provided in the message and needs to be padded to 8 bytes
nonce = bytes.fromhex(decoded_json["ctr"])
nonce = nonce.ljust(8, b"\x00") # Pad to 8 bytes with zeros
return nonce

def decrypt(
self,
bindkey: bytes,
address: str,
decoded_json: dict,
) -> bytes:
"""Decrypt ciphertext from JSON input with AES CTR."""
nonce = self.compute_nonce(address, decoded_json)
cipher = AES.new(bindkey, AES.MODE_CTR, nonce=nonce)
payload = bytes.fromhex(decoded_json["cipher"])
decrypted_data = cipher.decrypt(payload)
return decrypted_data

def replace_encrypted_data(
self,
decrypted_data: bytes,
data_json: dict,
decoded_json: dict,
) -> None:
"""Replace the encrypted data with decrypted payload."""
# Extract the first 10 octets of the manufacturer data
victron_manufacturer_data = bytearray(bytes.fromhex(decoded_json["manufacturerdata"][:20]))

# Replace indexes 4-5 and 14-17 with "11" and "ffff" to indicate decrypted data
victron_manufacturer_data[2:3] = binascii.unhexlify("11")
victron_manufacturer_data[7:9] = binascii.unhexlify("ffff")

# Append the decrypted payload to the manufacturer data
victron_manufacturer_data.extend(decrypted_data)

# Update the manufacturerdata field in the JSON
data_json["manufacturerdata"] = victron_manufacturer_data.hex()

_DECRYPTORS = {
1: LYWSD03MMC_PVVXDecryptor,
2: BTHomeV2Decryptor,
3: VictronDecryptor,
}


Expand Down