Skip to content

Do not authenticate with a credentials_hash from a different transport - #1749

Open
nopoz wants to merge 3 commits into
python-kasa:masterfrom
nopoz:feature/credentials-hash-transport-mismatch
Open

nopoz wants to merge 3 commits into
python-kasa:masterfrom
nopoz:feature/credentials-hash-transport-mismatch

Conversation

@nopoz

@nopoz nopoz commented Aug 31, 2026

Copy link
Copy Markdown

A credentials_hash is specific to the transport that produced it, but nothing checked that the hash a transport was handed was its own.

Transport Format Reversible
klap v1 / v2 base64 of a raw md5 / sha256 digest no
aes base64 json, username and password2, both sha1'd no
ssltransport base64 json, username and password, password md5'd no
sslaes base64 json, un and pwd, plaintext yes

This matters because a device can change its encryption type without the credentials changing. Toggling Third-Party Compatibility in the Tapo app moves a plug or strip between TPAP and KLAP, as described in #1590. Discovery correctly rewrites the encryption type, and the transport is then handed a stored hash that a different transport wrote.

KlapTransport decodes any base64 successfully, so it built _local_auth_hash out of another transport's json and failed handshake1 with:

Device authentication error connect: Device response did not match our challenge on ip <ip>,
check that your e-mail and password (both case-sensitive) are correct.

That is indistinguishable from a wrong password, and it is acted on as one. homeassistant/components/tplink/__init__.py catches AuthenticationError, deletes CONF_CREDENTIALS_HASH from the config entry and raises ConfigEntryAuthFailed. Where there is no separate credential store, that hash is the only copy of the credentials, so the encryption change costs the user their credentials and a reauth.

AesTransport and SslTransport fail less quietly and raise UnicodeDecodeError from the constructor when handed a klap hash; SslAesTransport raises KeyError.

What this changes

First commit adds a per-transport check that the hash has the shape that transport produces, and treats a foreign hash as absent rather than as a bad password. This makes the failure honest but still ends in a reauth.

Second commit reads the credentials back out when the hash is one of the plaintext forms and lets the transport derive its own, so the change of encryption type needs no reauth. klap and aes hashes are one way, so this only works in that direction, and the first commit's behaviour is what applies otherwise.

Notes

  • aes at login_version=1 and ssltransport produce structurally identical hashes, so they will still accept each other's. Distinguishing them would need the hash to record its transport, which would not help any hash already stored.
  • The reversible format is also what the TPAP transport in TPAP Implementation #1592 uses, so a TPAP device that reverts to KLAP reconnects without a reauth.

Testing

New tests/transports/test_credentials_hash.py covers, per transport, that a foreign hash is not used to authenticate, that the transport's own hash still is, and that the plaintext forms are recovered. Full suite passes.

Also verified against hardware: an L920 strip that reverted from TPAP to KLAP authenticates over KLAP when given the TPAP-format hash, and rederives its own klap hash.

A credentials_hash is specific to the transport that made it. Klap stores
the base64 of a raw digest, aes and ssltransport store base64 json of
hashed credentials, and sslaestransport stores base64 json of the
plaintext. Nothing checked that the hash a transport was handed was its
own.

A device can change its encryption type without the credentials changing.
Toggling Third-Party Compatibility on a Tapo device moves it between tpap
and klap, and discovery then hands the transport a stored hash the other
transport wrote.

Klap decodes any base64 successfully, so it built _local_auth_hash out of
another transport's json and failed handshake1, reporting "Device response
did not match our challenge". Callers cannot tell that from a wrong
password; Home Assistant acts on it by deleting the stored hash and asking
the user to reauthenticate. aestransport and ssltransport are worse and
raise UnicodeDecodeError from the constructor, and sslaestransport raises
KeyError.

Check the hash has the shape the transport produces and treat a foreign one
as absent instead.
@codecov

codecov Bot commented Aug 31, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.18182% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 93.36%. Comparing base (a29d061) to head (4588797).
⚠️ Report is 7 commits behind head on master.

Files with missing lines Patch % Lines
kasa/transports/basetransport.py 87.50% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #1749      +/-   ##
==========================================
+ Coverage   93.29%   93.36%   +0.06%     
==========================================
  Files         157      158       +1     
  Lines        9932    10032     +100     
  Branches     1022     1030       +8     
==========================================
+ Hits         9266     9366     +100     
- Misses        471      472       +1     
+ Partials      195      194       -1     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@nopoz nopoz mentioned this pull request Aug 31, 2026
sslaestransport and the tpap transport on python-kasa#1592 store a credentials_hash
that is base64 json of the plaintext credentials, so the credentials can be
read back out of it. klap and aes hashes are one way and cannot.

When a device changes its encryption type the stored hash is the one the
old transport wrote. The previous commit stops that being mistaken for a
bad password, but the connection still has no credentials to offer and the
caller has to prompt for them again. Home Assistant stores the hash as the
only copy of the credentials for a device, so that prompt loses them.

Read the credentials back out when the hash is one of the plaintext forms
and let the transport derive its own. A tpap device that reverts to klap
because Third-Party Compatibility was turned on then reconnects without a
reauth. This only works in that direction.
@nopoz
nopoz force-pushed the feature/credentials-hash-transport-mismatch branch from b8d98f3 to 147b350 Compare August 31, 2026 23:59
@rytilahti rytilahti added the bug Something isn't working label Sep 18, 2026

@rytilahti rytilahti left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I really like the idea, thanks for the PR @nopoz!

We should take a look how this behaves in the bigger picture (and I have wanted to contain other logic, e.g., the get_credentias/defaultcreds logic properly), but this is a good starter to make the Credentials a bit more self-contained. I added a couple of suggestions inline, please take a look and let me know what you think!

Comment thread kasa/transports/sslaestransport.py Outdated
Comment thread kasa/transports/ssltransport.py Outdated
Comment thread kasa/credentials.py Outdated
password: str = field(default="", repr=False)


def _credentials_from_plaintext_hash(credentials_hash: str) -> Credentials | None:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of having a "free" top-level private function, I think this would be better contained within the Credentials class.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now Credentials._from_plaintext_hash. Kept it private: Credentials is autodoc'd with :members: in reference.md, so a public classmethod lands in the API reference, and unlike the transport check it is not an override point. Can make it public if you would rather the two match.

Give BaseTransport an is_transport_credentials_hash hook that defaults to
accepting the hash, and do the discard and plaintext recovery once in its
constructor rather than in each transport. Recovering the credentials is
now a Credentials classmethod instead of a module level function.

The ssl transport gains the recovery it did not have before, so it is
covered by a new test.
@nopoz
nopoz requested a review from rytilahti September 19, 2026 19:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants