diff --git a/at_client/atclient.py b/at_client/atclient.py index 810f354..224a3d0 100644 --- a/at_client/atclient.py +++ b/at_client/atclient.py @@ -3,6 +3,7 @@ from queue import Empty, Queue import time import traceback +import uuid from at_client.connections.notification.atevents import AtEvent, AtEventType @@ -420,8 +421,19 @@ def handle_event(self, queue, at_event): else: raise Exception("You must assign a Queue object to the queue paremeter of AtClient class") - def notify(self, at_key : AtKey, value, operation = OperationEnum.UPDATE, session_id = str(uuid.uuid4())): + def notify(self, at_key : AtKey, value, operation = OperationEnum.UPDATE, session_id = None): + # Generate a fresh session id per call. A default of str(uuid.uuid4()) in the + # signature is evaluated once at import, so every notify() without an explicit + # id would reuse the same one and the server would dedup/drop the duplicates. + if session_id is None: + session_id = str(uuid.uuid4()) + # Ensure an AES nonce exists. AES-CTR requires one; without it aes_encrypt + # raises "nonce must be bytes-like". Generate it here and set it on the key so + # it travels in the notification metadata for the receiver to decrypt with. iv = at_key.metadata.iv_nonce + if iv is None: + iv = EncryptionUtil.generate_iv_nonce() + at_key.metadata.iv_nonce = iv shared_key = self.get_encryption_key_shared_by_me(at_key) encrypted_value = EncryptionUtil.aes_encrypt_from_base64(value, shared_key, iv) command = NotifyVerbBuilder().with_at_key(at_key, encrypted_value, operation, session_id).build() diff --git a/test/notify_test.py b/test/notify_test.py new file mode 100644 index 0000000..6bdebfe --- /dev/null +++ b/test/notify_test.py @@ -0,0 +1,46 @@ +import inspect +import unittest +from unittest.mock import MagicMock + +from at_client import AtClient +from at_client.common import AtSign +from at_client.common.keys import SharedKey +from at_client.util.encryptionutil import EncryptionUtil + + +class NotifyTest(unittest.TestCase): + """Network-free regression tests for AtClient.notify().""" + + def test_session_id_default_is_none(self): + """The session_id default must not bake in a single UUID at import time. + + A signature default of str(uuid.uuid4()) is evaluated once, so every notify() + without an explicit session_id reuses it and the server dedups/drops repeats. + """ + default = inspect.signature(AtClient.notify).parameters["session_id"].default + self.assertIsNone(default) + + def test_notify_generates_iv_nonce_when_unset(self): + """notify() must generate an AES nonce when the key has none (else it crashes).""" + client = AtClient.__new__(AtClient) # bypass the network-connecting __init__ + client.queue = None + client.get_encryption_key_shared_by_me = MagicMock( + return_value=EncryptionUtil.generate_aes_key_base64() + ) + resp = MagicMock() + resp.get_raw_data_response.return_value = "data:ok" + client.secondary_connection = MagicMock() + client.secondary_connection.execute_command.return_value = resp + + key = SharedKey("demo", AtSign("@alice"), AtSign("@bob")) + key.set_namespace("test") + self.assertIsNone(key.metadata.iv_nonce) + + result = client.notify(key, "hello") # must not raise + + self.assertIsNotNone(key.metadata.iv_nonce) # generated and set on the key + self.assertEqual(result, "data:ok") + + +if __name__ == "__main__": + unittest.main()