Skip to content
Open
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
72 changes: 65 additions & 7 deletions kasa/smartcam/smartcamdevice.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@
from __future__ import annotations

import base64
import hashlib
import logging
from typing import Any, cast

from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey

from ..credentials import DEFAULT_CREDENTIALS, get_default_credentials
from ..device import DeviceInfo, WifiNetwork
from ..device_type import DeviceType
from ..deviceconfig import DeviceConfig
Expand Down Expand Up @@ -357,13 +359,7 @@ async def wifi_join(
if net is None:
raise DeviceError(f"Network with SSID '{ssid}' not found.")

public_key_b64 = self._public_key or self.STATIC_PUBLIC_KEY_B64
key_bytes = base64.b64decode(public_key_b64)
public_key = serialization.load_der_public_key(key_bytes)
if not isinstance(public_key, RSAPublicKey):
raise TypeError("Loaded public key is not an RSA public key")
encrypted = public_key.encrypt(password.encode(), padding.PKCS1v15())
encrypted_password = base64.b64encode(encrypted).decode()
encrypted_password = self._encrypt_password(password)

payload = {
"onboarding": {
Expand All @@ -390,3 +386,65 @@ async def wifi_join(
"Received a kasa exception for wifi join, but this is expected"
)
return {}

def _encrypt_password(self, password: str) -> str:
public_key_b64 = self._public_key or self.STATIC_PUBLIC_KEY_B64
key_bytes = base64.b64decode(public_key_b64)
public_key = serialization.load_der_public_key(key_bytes)
if not isinstance(public_key, RSAPublicKey):
raise TypeError("Loaded public key is not an RSA public key")
encrypted = public_key.encrypt(password.encode(), padding.PKCS1v15())
return base64.b64encode(encrypted).decode()

async def update_credentials(self, username: str, password: str) -> dict:
"""Update smart camera credentials."""
login_version = self.config.connection_type.login_version
default_old_password = get_default_credentials(
DEFAULT_CREDENTIALS["TAPOCAMERA_LV3"]
if login_version == 3
else DEFAULT_CREDENTIALS["TAPOCAMERA"]
).password

old_password_candidates: list[str] = [default_old_password]
if self.credentials and self.credentials.password:
current_password = self.credentials.password
if current_password not in old_password_candidates:
old_password_candidates.append(current_password)

new_password_hash = self._hash_password(password, login_version)

last_error: DeviceError | None = None
for old_password_candidate in old_password_candidates:
old_password_hash = self._hash_password(
old_password_candidate, login_version
)

change_admin_password_payload: dict[str, str] = {
"secname": "root",
"username": "admin",
"old_passwd": old_password_hash,
"passwd": new_password_hash,
"ciphertext": self._encrypt_password(new_password_hash),
}
if login_version == 3:
change_admin_password_payload["encrypt_type"] = "3"

payload = {
"user_management": {
"change_admin_password": change_admin_password_payload
}
}
try:
return await self.protocol.query({"changeAdminPassword": payload})
except DeviceError as ex:
last_error = ex

if last_error is not None:
raise last_error
raise KasaException("Unable to determine current admin password.")

@staticmethod
def _hash_password(password: str, login_version: int | None) -> str:
if login_version == 3:
return hashlib.sha256(password.encode()).hexdigest().upper() # noqa: S324
return hashlib.md5(password.encode()).hexdigest().upper() # noqa: S324
238 changes: 237 additions & 1 deletion tests/smartcam/test_smartcamdevice.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@
from __future__ import annotations

import base64
import hashlib
from datetime import UTC, datetime
from unittest.mock import AsyncMock, PropertyMock, patch

import pytest
from freezegun.api import FrozenDateTimeFactory

from kasa import Device, DeviceType, Module
from kasa import Credentials, Device, DeviceType, Module
from kasa.credentials import DEFAULT_CREDENTIALS, get_default_credentials
from kasa.exceptions import AuthenticationError, DeviceError, KasaException
from kasa.smartcam import SmartCamDevice

Expand Down Expand Up @@ -195,3 +197,237 @@
),
):
await dev.wifi_join("TestSSID", "password123")


@device_smartcam
async def test_update_credentials_non_lv3_request(dev: SmartCamDevice):
dev.config.connection_type.login_version = 2
default_old_password = get_default_credentials(
DEFAULT_CREDENTIALS["TAPOCAMERA"]
).password
expected_old_hash = hashlib.md5(default_old_password.encode()).hexdigest().upper() # noqa: S324
expected_new_hash = hashlib.md5(b"new-password").hexdigest().upper() # noqa: S324

query_mock = AsyncMock(return_value={})
with (
patch.object(type(dev), "credentials", new_callable=PropertyMock) as cred_mock,
patch.object(dev.protocol, "query", query_mock),
patch.object(
dev, "_encrypt_password", return_value="encrypted-ciphertext"
) as encrypt_mock,
):
cred_mock.return_value = Credentials(username="admin", password="old-password") # noqa: S106
result = await dev.update_credentials("new-user", "new-password")

assert result == {}
encrypt_mock.assert_called_once_with(expected_new_hash)
query_mock.assert_awaited_once_with(
{
"changeAdminPassword": {
"user_management": {
"change_admin_password": {
"secname": "root",
"username": "admin",
"old_passwd": expected_old_hash,
"passwd": expected_new_hash,
"ciphertext": "encrypted-ciphertext",
}
}
}
}
)


@device_smartcam
async def test_update_credentials_lv3_request(dev: SmartCamDevice):
dev.config.connection_type.login_version = 3
default_old_password = get_default_credentials(
DEFAULT_CREDENTIALS["TAPOCAMERA_LV3"]
).password
expected_old_hash = (
hashlib.sha256(default_old_password.encode()).hexdigest().upper()
) # noqa: S324
expected_new_hash = hashlib.sha256(b"new-password").hexdigest().upper() # noqa: S324

query_mock = AsyncMock(return_value={})
with (
patch.object(type(dev), "credentials", new_callable=PropertyMock) as cred_mock,
patch.object(dev.protocol, "query", query_mock),
patch.object(
dev, "_encrypt_password", return_value="encrypted-ciphertext"
) as encrypt_mock,
):
cred_mock.return_value = Credentials(username="admin", password="old-password") # noqa: S106
result = await dev.update_credentials("new-user", "new-password")

assert result == {}
encrypt_mock.assert_called_once_with(expected_new_hash)
query_mock.assert_awaited_once_with(
{
"changeAdminPassword": {
"user_management": {
"change_admin_password": {
"secname": "root",
"username": "admin",
"old_passwd": expected_old_hash,
"passwd": expected_new_hash,
"ciphertext": "encrypted-ciphertext",
"encrypt_type": "3",
}
}
}
}
)


@device_smartcam
async def test_update_credentials_falls_back_to_current_password_when_default_fails(
dev: SmartCamDevice,
):
dev.config.connection_type.login_version = 2
default_old_password = get_default_credentials(
DEFAULT_CREDENTIALS["TAPOCAMERA"]
).password
expected_default_old_hash = (
hashlib.md5(default_old_password.encode()).hexdigest().upper() # noqa: S324
)
expected_current_old_hash = hashlib.md5(b"old-password").hexdigest().upper() # noqa: S324
expected_new_hash = hashlib.md5(b"new-password").hexdigest().upper() # noqa: S324

query_mock = AsyncMock(side_effect=[DeviceError("bad old"), {}])
with (
patch.object(type(dev), "credentials", new_callable=PropertyMock) as cred_mock,
patch.object(dev.protocol, "query", query_mock),
patch.object(dev, "_encrypt_password", return_value="encrypted-ciphertext"),
):
cred_mock.return_value = Credentials(username="admin", password="old-password") # noqa: S106
result = await dev.update_credentials("new-user@example.com", "new-password")

assert result == {}
assert query_mock.await_count == 2
first_payload = query_mock.await_args_list[0].args[0]
second_payload = query_mock.await_args_list[1].args[0]

assert (
first_payload["changeAdminPassword"]["user_management"][
"change_admin_password"
]["old_passwd"]
== expected_default_old_hash
)
assert (
second_payload["changeAdminPassword"]["user_management"][
"change_admin_password"
]["old_passwd"]
== expected_current_old_hash
)
assert (
second_payload["changeAdminPassword"]["user_management"][
"change_admin_password"
]["passwd"]
== expected_new_hash
)


@device_smartcam
async def test_update_credentials_returns_last_error_after_candidates_fail(
dev: SmartCamDevice,
):
dev.config.connection_type.login_version = 2
default_old_password = get_default_credentials(
DEFAULT_CREDENTIALS["TAPOCAMERA"]
).password
expected_default_old_hash = (
hashlib.md5(default_old_password.encode()).hexdigest().upper() # noqa: S324
)
expected_current_old_hash = hashlib.md5(b"old-password").hexdigest().upper() # noqa: S324

query_mock = AsyncMock(
side_effect=[DeviceError("bad default"), DeviceError("bad current")]
)
with (
patch.object(type(dev), "credentials", new_callable=PropertyMock) as cred_mock,
patch.object(dev.protocol, "query", query_mock),
patch.object(dev, "_encrypt_password", return_value="encrypted-ciphertext"),
):
cred_mock.return_value = Credentials(username="admin", password="old-password") # noqa: S106
with pytest.raises(DeviceError, match="bad current"):
await dev.update_credentials("new-user@example.com", "new-password")

assert query_mock.await_count == 2
first_payload = query_mock.await_args_list[0].args[0]
second_payload = query_mock.await_args_list[1].args[0]

assert (
first_payload["changeAdminPassword"]["user_management"][
"change_admin_password"
]["old_passwd"]
== expected_default_old_hash
)
assert (
second_payload["changeAdminPassword"]["user_management"][
"change_admin_password"
]["old_passwd"]
== expected_current_old_hash
)


@device_smartcam
async def test_update_credentials_all_candidates_fail(dev: SmartCamDevice):
dev.config.connection_type.login_version = 2
error = DeviceError("always fails")
query_mock = AsyncMock(side_effect=error)
with (
patch.object(type(dev), "credentials", new_callable=PropertyMock) as cred_mock,
patch.object(dev.protocol, "query", query_mock),
patch.object(dev, "_encrypt_password", return_value="encrypted-ciphertext"),
):
cred_mock.return_value = Credentials(username="admin", password="old-password") # noqa: S106
with pytest.raises(DeviceError):
await dev.update_credentials("new-user@example.com", "new-password")


@device_smartcam
async def test_update_credentials_with_no_credentials(dev: SmartCamDevice):
dev.config.connection_type.login_version = 2
query_mock = AsyncMock(return_value={})
with (
patch.object(type(dev), "credentials", new_callable=PropertyMock) as cred_mock,
patch.object(dev.protocol, "query", query_mock),
patch.object(dev, "_encrypt_password", return_value="encrypted-ciphertext"),
):
cred_mock.return_value = None
result = await dev.update_credentials("new-user@example.com", "new-password")

assert result == {}
# Only the default candidate is used and it succeeds on the first query (1 attempt)
assert query_mock.await_count == 1
payload = query_mock.await_args_list[0].args[0]
assert (
"old_passwd"
in payload["changeAdminPassword"]["user_management"]["change_admin_password"]
)


@device_smartcam
async def test_update_credentials_current_password_equals_default(
dev: SmartCamDevice,
):
dev.config.connection_type.login_version = 2
default_old_password = get_default_credentials(
DEFAULT_CREDENTIALS["TAPOCAMERA"]
).password
query_mock = AsyncMock(return_value={})
with (
patch.object(type(dev), "credentials", new_callable=PropertyMock) as cred_mock,
patch.object(dev.protocol, "query", query_mock),
patch.object(dev, "_encrypt_password", return_value="encrypted-ciphertext"),
):
# current password is same as default — should not be added a second time
cred_mock.return_value = Credentials(
username="admin", password=default_old_password
)
result = await dev.update_credentials("new-user@example.com", "new-password")

assert result == {}
# Only default + None (current == default so not duplicated); default succeeds
assert query_mock.await_count == 1