Skip to content

Commit

Permalink
adds support for the new login procedure
Browse files Browse the repository at this point in the history
* FRITZ!OS 7.24 and later supports new response generation using PBKDF2
* technical note: https://avm.de/fileadmin/user_upload/Global/Service/Schnittstellen/AVM%20Technical%20Note%20-%20Session%20ID_EN%20-%20Nov2020.pdf
* adds test for login using new variant
* updates to pypy3.9 due to build problems with pypy3.7
  • Loading branch information
Gezzo42 committed Jan 10, 2024
1 parent 5756c51 commit 1c8d5c1
Show file tree
Hide file tree
Showing 6 changed files with 66 additions and 10 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ jobs:

strategy:
matrix:
python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12", "pypy3.7"]
python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12", "pypy3.9"]

steps:
- uses: actions/checkout@v3
Expand Down
46 changes: 40 additions & 6 deletions pyfritzhome/fritzhome.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
import time
from xml.etree import ElementTree

from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC

from requests import Session

from .errors import InvalidError, LoginError
Expand Down Expand Up @@ -44,7 +47,7 @@ def _request(self, url, params=None, timeout=10):

def _login_request(self, username=None, secret=None):
"""Send a login request with paramerters."""
url = self.get_prefixed_host() + "/login_sid.lua"
url = self.get_prefixed_host() + "/login_sid.lua?version=2"
params = {}
if username:
params["username"] = username
Expand All @@ -54,9 +57,10 @@ def _login_request(self, username=None, secret=None):
plain = self._request(url, params)
dom = ElementTree.fromstring(plain)
sid = dom.findtext("SID")
blocktime = int(dom.findtext("BlockTime"))
challenge = dom.findtext("Challenge")

return (sid, challenge)
return (sid, challenge, blocktime)

def _logout_request(self):
"""Send a logout request."""
Expand All @@ -67,7 +71,27 @@ def _logout_request(self):
self._request(url, params)

@staticmethod
def _create_login_secret(challenge, password):
def _create_login_secrete_pbkdf2(challenge, password):
challenge_parts = challenge.split("$")
# Extract all necessary values encoded into the challenge
iter1 = int(challenge_parts[1])
salt1 = bytes.fromhex(challenge_parts[2])
iter2 = int(challenge_parts[3])
salt2 = bytes.fromhex(challenge_parts[4])
# Hash twice, once with static salt...
# hash1 = hashlib.pbkdf2_hmac("sha256", password.encode(), salt1, iter1)
kdf = PBKDF2HMAC(algorithm=hashes.SHA256(), length=32, salt=salt1,
iterations=iter1)
hash1 = kdf.derive(password.encode())
# Once with dynamic salt.
# hash2 = hashlib.pbkdf2_hmac("sha256", hash1, salt2, iter2)
kdf = PBKDF2HMAC(algorithm=hashes.SHA256(), length=32, salt=salt2,
iterations=iter2)
hash2 = kdf.derive(hash1)
return f"{challenge_parts[4]}${hash2.hex()}"

@staticmethod
def _create_login_secret_md5(challenge, password):
"""Create a login secret."""
to_hash = (challenge + "-" + password).encode("UTF-16LE")
hashed = hashlib.md5(to_hash).hexdigest()
Expand All @@ -92,10 +116,20 @@ def _aha_request(self, cmd, ain=None, param=None, rf=str):

def login(self):
"""Login and get a valid session ID."""
(sid, challenge) = self._login_request()
(sid, challenge, blocktime) = self._login_request()
if sid == "0000000000000000":
secret = self._create_login_secret(challenge, self._password)
(sid2, challenge) = self._login_request(username=self._user, secret=secret)
if blocktime > 0:
time.sleep(blocktime)
# PBKDF2 (FRITZ!OS 7.24 or later)
if challenge.startswith("2$"):
secret = self._create_login_secrete_pbkdf2(challenge,
self._password)
# fallback to MD5
else:
secret = self._create_login_secret_md5(challenge, self._password)
(sid2, challenge, _) = self._login_request(
username=self._user, secret=secret
)
if sid2 == "0000000000000000":
_LOGGER.warning("login failed %s", sid2)
raise LoginError(self._user)
Expand Down
4 changes: 2 additions & 2 deletions setup.cfg
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[metadata]
name = pyfritzhome
version = 0.6.8
version = 0.6.9
description = Fritz!Box Smarthome Python Library
long_description = file: README.rst
long_description_content_type = text/x-rst
Expand All @@ -27,7 +27,7 @@ packages = find:
include_package_data = true
test_suite = tests
setup_requires = setuptools
install_requires = requests
install_requires = requests; cryptography
tests_requires = pytest

[options.entry_points]
Expand Down
1 change: 1 addition & 0 deletions tests/responses/login_rsp_with_valid_sid_pbkdf2.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<?xml version="1.0" encoding="utf-8"?><SessionInfo><SID>0000000000000001</SID><Challenge>2$10000$a64b986b521fcbc44d7a9f0adad34b14$1000$b9c232dea345233f5a893b2284931ac8</Challenge><BlockTime>0</BlockTime><Rights></Rights></SessionInfo>
1 change: 1 addition & 0 deletions tests/responses/login_rsp_without_valid_sid_pbkdf2.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<?xml version="1.0" encoding="utf-8"?><SessionInfo><SID>0000000000000000</SID><Challenge>2$10000$a64b986b521fcbc44d7a9f0adad34b14$1000$b9c232dea345233f5a893b2284931ac8</Challenge><BlockTime>0</BlockTime><Rights></Rights></SessionInfo>
22 changes: 21 additions & 1 deletion tests/test_fritzhome.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
class TestFritzhome(object):
def setup_method(self):
self.mock = MagicMock()
self.fritz = Fritzhome("10.0.0.1", "user", "pass")
self.fritz = Fritzhome("10.0.0.1", "user", "admin123")
self.fritz._request = self.mock

def test_login_fail(self):
Expand All @@ -40,6 +40,26 @@ def test_login(self):

self.fritz.login()

def test_login_pbkdf2(self):
self.mock.side_effect = [
Helper.response("login_rsp_without_valid_sid_pbkdf2"),
Helper.response("login_rsp_with_valid_sid_pbkdf2"),
]
"""
challenge was generated using
http://home.mengelke.de/login_sid.lua?version=2 (Fritzbox Anmeldesimulator)
response was generated using python code from
https://avm.de/fileadmin/user_upload/Global/Service/Schnittstellen/AVM%20Technical%20Note%20-%20Session%20ID_deutsch%20-%20Nov2020.pdf
the example challenge and response in this document is faulty and
does not work with given example code
"""
self.fritz.login()
self.fritz._request.assert_called_with(
"http://10.0.0.1/login_sid.lua?version=2",
{"username": "user", "response": "b9c232dea345233f5a893b2284931ac8$"
"2825c7fbd8cdbcbaf93ca2e8d0798c31cf38394469a9ce89365778dc9103ad82"},
)

def test_logout(self):
self.fritz.logout()
self.fritz._request.assert_called_with(
Expand Down

0 comments on commit 1c8d5c1

Please sign in to comment.