From 48c1217180147a8c7151c435122c491d73c02cad Mon Sep 17 00:00:00 2001 From: Colin Constable Date: Thu, 2 Jul 2026 12:01:13 -0700 Subject: [PATCH] fix: notify() generates iv_nonce and a fresh session_id per call notify() crashed when the AtKey had no iv_nonce ('nonce must be bytes-like'), forcing callers to set metadata.iv_nonce manually. It also used a signature default session_id=str(uuid.uuid4()), which is evaluated once at import, so every notify() without an explicit id reused the same one and the server deduped the duplicates. Generate the AES nonce inside notify() when unset (and store it on the key's metadata so it travels with the notification), and default session_id to None, generating a fresh UUID per call. Adds network-free unit tests for both behaviours. --- at_client/atclient.py | 14 ++++++++++++- test/notify_test.py | 46 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 test/notify_test.py 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()