Repository: postlund/pyatv
Pull request #2412 (2024-06-22 16:01:43)

Fix Chacha20Cipher with companion
followup to #2338


Patch:
diff --git a/pyatv/auth/hap_srp.py b/pyatv/auth/hap_srp.py
index 0a7e613e..3453bfd1 100644
--- a/pyatv/auth/hap_srp.py
+++ b/pyatv/auth/hap_srp.py
@@ -91,7 +91,7 @@ class SRPAuthHandler:
             "Pair-Verify-Encrypt-Salt", "Pair-Verify-Encrypt-Info", self._shared
         )
 
-        chacha = chacha20.Chacha20Cipher(session_key, session_key)
+        chacha = chacha20.Chacha20Cipher8byteNonce(session_key, session_key)
         decrypted_tlv = read_tlv(chacha.decrypt(encrypted, nonce="PV-Msg02".encode()))
 
         identifier = decrypted_tlv[TlvValue.Identifier]
@@ -199,14 +199,14 @@ class SRPAuthHandler:
         if additional_data:
             tlv.update(additional_data)
 
-        chacha = chacha20.Chacha20Cipher(self._session_key, self._session_key)
+        chacha = chacha20.Chacha20Cipher8byteNonce(self._session_key, self._session_key)
         encrypted_data = chacha.encrypt(write_tlv(tlv), nonce="PS-Msg05".encode())
         log_binary(_LOGGER, "Data", Encrypted=encrypted_data)
         return encrypted_data
 
     def step4(self, encrypted_data):
         """Last pairing step."""
-        chacha = chacha20.Chacha20Cipher(self._session_key, self._session_key)
+        chacha = chacha20.Chacha20Cipher8byteNonce(self._session_key, self._session_key)
         decrypted_tlv_bytes = chacha.decrypt(encrypted_data, nonce="PS-Msg06".encode())
 
         if not decrypted_tlv_bytes:
diff --git a/pyatv/protocols/airplay/server_auth.py b/pyatv/protocols/airplay/server_auth.py
index e5839cee..9351273f 100644
--- a/pyatv/protocols/airplay/server_auth.py
+++ b/pyatv/protocols/airplay/server_auth.py
@@ -284,7 +284,7 @@ class BaseAirPlayServerAuth(http.HttpSimpleRouter, ABC):
             {TlvValue.Identifier: self.unique_id, TlvValue.Signature: signature}
         )
 
-        chacha = chacha20.Chacha20Cipher(session_key, session_key)
+        chacha = chacha20.Chacha20Cipher8byteNonce(session_key, session_key)
         encrypted = chacha.encrypt(tlv, nonce="PV-Msg02".encode())
 
         tlv = {
@@ -368,7 +368,7 @@ class BaseAirPlayServerAuth(http.HttpSimpleRouter, ABC):
             binascii.unhexlify(self.session.key),
         )
 
-        chacha = chacha20.Chacha20Cipher(session_key, session_key)
+        chacha = chacha20.Chacha20Cipher8byteNonce(session_key, session_key)
         decrypted_tlv_bytes = chacha.decrypt(
             pairing_data[TlvValue.EncryptedData], nonce="PS-Msg05".encode()
         )
@@ -396,7 +396,7 @@ class BaseAirPlayServerAuth(http.HttpSimpleRouter, ABC):
             }
         )
 
-        chacha = chacha20.Chacha20Cipher(session_key, session_key)
+        chacha = chacha20.Chacha20Cipher8byteNonce(session_key, session_key)
         encrypted = chacha.encrypt(tlv, nonce="PS-Msg06".encode())
 
         self.has_paired()
diff --git a/pyatv/protocols/mrp/connection.py b/pyatv/protocols/mrp/connection.py
index 0ba549ce..249c35f2 100644
--- a/pyatv/protocols/mrp/connection.py
+++ b/pyatv/protocols/mrp/connection.py
@@ -92,7 +92,7 @@ class MrpConnection(
 
     def enable_encryption(self, output_key: bytes, input_key: bytes) -> None:
         """Enable encryption with the specified keys."""
-        self._chacha = chacha20.Chacha20Cipher(output_key, input_key)
+        self._chacha = chacha20.Chacha20Cipher8byteNonce(output_key, input_key)
 
     @property
     def connected(self) -> bool:
diff --git a/pyatv/protocols/mrp/server_auth.py b/pyatv/protocols/mrp/server_auth.py
index c096b127..c43568bf 100644
--- a/pyatv/protocols/mrp/server_auth.py
+++ b/pyatv/protocols/mrp/server_auth.py
@@ -147,7 +147,7 @@ class MrpServerAuth(ABC):
             {TlvValue.Identifier: self.unique_id, TlvValue.Signature: signature}
         )
 
-        chacha = chacha20.Chacha20Cipher(session_key, session_key)
+        chacha = chacha20.Chacha20Cipher8byteNonce(session_key, session_key)
         encrypted = chacha.encrypt(tlv, nonce="PV-Msg02".encode())
 
         msg = messages.crypto_pairing(
@@ -227,7 +227,7 @@ class MrpServerAuth(ABC):
             }
         )
 
-        chacha = chacha20.Chacha20Cipher(session_key, session_key)
+        chacha = chacha20.Chacha20Cipher8byteNonce(session_key, session_key)
         encrypted = chacha.encrypt(tlv, nonce="PS-Msg06".encode())
 
         msg = messages.crypto_pairing(
diff --git a/pyatv/support/chacha20.py b/pyatv/support/chacha20.py
index 768da7a4..295faa4e 100644
--- a/pyatv/support/chacha20.py
+++ b/pyatv/support/chacha20.py
@@ -8,15 +8,11 @@ from chacha20poly1305_reuseable import ChaCha20Poly1305Reusable as ChaCha20Poly1
 
 NONCE_LENGTH = 12
 
-# The first 4 bytes are always 0, followed by 8 bytes of counter
-# for a total of 12 bytes.
-PACK_NONCE = partial(Struct("<LQ").pack, 0)
-
 
 class Chacha20Cipher:
     """CHACHA20 encryption/decryption layer."""
 
-    def __init__(self, out_key, in_key, nonce_length=8):
+    def __init__(self, out_key: bytes, in_key: bytes, nonce_length: int = 8) -> None:
         """Initialize a new Chacha20Cipher."""
         self._enc_out = ChaCha20Poly1305(out_key)
         self._enc_in = ChaCha20Poly1305(in_key)
@@ -31,7 +27,11 @@ class Chacha20Cipher:
         This is the nonce that will be used by encrypt in the _next_ call if no custom
         nonce is specified.
         """
-        return PACK_NONCE(self._out_counter)
+        nonce_length = self._nonce_length
+        nonce = self._out_counter.to_bytes(length=nonce_length, byteorder="little")
+        if nonce_length != NONCE_LENGTH:
+            return self._pad_nonce(nonce)
+        return nonce
 
     @property
     def in_nonce(self) -> bytes:
@@ -40,7 +40,15 @@ class Chacha20Cipher:
         This is the nonce that will be used by decrypt in the _next_ call if no custom
         nonce is specified.
         """
-        return PACK_NONCE(self._in_counter)
+        nonce_length = self._nonce_length
+        nonce = self._in_counter.to_bytes(length=nonce_length, byteorder="little")
+        if nonce_length != NONCE_LENGTH:
+            return self._pad_nonce(nonce)
+        return nonce
+
+    def _pad_nonce(self, nonce: bytes) -> bytes:
+        """Pad nonce to 12 bytes."""
+        return b"\x00" * (NONCE_LENGTH - len(nonce)) + nonce
 
     def encrypt(
         self, data: bytes, nonce: Optional[bytes] = None, aad: Optional[bytes] = None
@@ -50,7 +58,7 @@ class Chacha20Cipher:
             nonce = self.out_nonce
             self._out_counter += 1
         elif len(nonce) < NONCE_LENGTH:
-            nonce = b"\x00" * (NONCE_LENGTH - len(nonce)) + nonce
+            nonce = self._pad_nonce(nonce)
         return self._enc_out.encrypt(nonce, data, aad)
 
     def decrypt(
@@ -61,5 +69,38 @@ class Chacha20Cipher:
             nonce = self.in_nonce
             self._in_counter += 1
         elif len(nonce) < NONCE_LENGTH:
-            nonce = b"\x00" * (NONCE_LENGTH - len(nonce)) + nonce
+            nonce = self._pad_nonce(nonce)
         return self._enc_in.decrypt(nonce, data, aad)
+
+
+_PACK_NONCE_WITH_4_BYTE_PAD = partial(Struct("<LQ").pack, 0)
+
+
+class Chacha20Cipher8byteNonce(Chacha20Cipher):
+    """CHACHA20 encryption/decryption layer with an 8 byte counter.
+
+    The first 4 bytes are always 0, followed by 8 bytes of counter
+    for a total of 12 bytes.
+    """
+
+    def __init__(self, out_key: bytes, in_key: bytes) -> None:
+        """Initialize a new Chacha20Cipher8byteNonce."""
+        super().__init__(out_key, in_key, nonce_length=8)
+
+    @property
+    def out_nonce(self) -> bytes:
+        """Return next encrypt nonce.
+
+        This is the nonce that will be used by encrypt in the _next_ call if no custom
+        nonce is specified.
+        """
+        return _PACK_NONCE_WITH_4_BYTE_PAD(self._out_counter)
+
+    @property
+    def in_nonce(self) -> bytes:
+        """Return next decrypt nonce.
+
+        This is the nonce that will be used by decrypt in the _next_ call if no custom
+        nonce is specified.
+        """
+        return _PACK_NONCE_WITH_4_BYTE_PAD(self._in_counter)


Test patch:
diff --git a/tests/fake_device/mrp.py b/tests/fake_device/mrp.py
index d9433ae6..5458a266 100644
--- a/tests/fake_device/mrp.py
+++ b/tests/fake_device/mrp.py
@@ -392,7 +392,7 @@ class FakeMrpService(MrpServerAuth, asyncio.Protocol):
 
     def enable_encryption(self, output_key: bytes, input_key: bytes) -> None:
         """Enable encryption with specified keys."""
-        self.chacha = chacha20.Chacha20Cipher(output_key, input_key)
+        self.chacha = chacha20.Chacha20Cipher8byteNonce(output_key, input_key)
         self.state.has_authenticated = True
 
     def send_to_client(self, message: ProtobufMessage) -> None:
diff --git a/tests/support/test_chacha20.py b/tests/support/test_chacha20.py
new file mode 100644
index 00000000..81fc3421
--- /dev/null
+++ b/tests/support/test_chacha20.py
@@ -0,0 +1,23 @@
+"""Unit tests for pyatv.support.chacha20."""
+
+import logging
+
+from pyatv.support import chacha20
+
+fake_key = b"k" * 32
+
+
+def test_12_bytes_nonce():
+    cipher = chacha20.Chacha20Cipher(fake_key, fake_key, 12)
+    assert len(cipher.out_nonce) == chacha20.NONCE_LENGTH
+    assert len(cipher.in_nonce) == chacha20.NONCE_LENGTH
+    result = cipher.encrypt(b"test")
+    assert cipher.decrypt(result) == b"test"
+
+
+def test_8_bytes_nonce():
+    cipher = chacha20.Chacha20Cipher8byteNonce(fake_key, fake_key)
+    assert len(cipher.out_nonce) == chacha20.NONCE_LENGTH
+    assert len(cipher.in_nonce) == chacha20.NONCE_LENGTH
+    result = cipher.encrypt(b"test")
+    assert cipher.decrypt(result) == b"test"