Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions at_client/atclient.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from queue import Empty, Queue
import time
import traceback
import uuid

from at_client.connections.notification.atevents import AtEvent, AtEventType

Expand Down Expand Up @@ -420,8 +421,18 @@ 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())):
iv = at_key.metadata.iv_nonce
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())
# Always use a FRESH nonce for each notification, and set it on the key so it
# travels in the notification metadata for the receiver. AES-CTR requires a
# nonce, and reusing one under the same shared key is insecure — so we never
# reuse whatever may already be on a (possibly reused) AtKey instance.
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()
Expand Down
63 changes: 63 additions & 0 deletions test/notify_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
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 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 _mock_client(self):
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
return client

def test_notify_generates_iv_nonce_when_unset(self):
"""notify() must generate an AES nonce when the key has none (else it crashes)."""
client = self._mock_client()
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")

def test_notify_uses_fresh_nonce_per_call(self):
"""A reused AtKey must get a fresh nonce each call (no AES-CTR nonce reuse)."""
client = self._mock_client()
key = SharedKey("demo", AtSign("@alice"), AtSign("@bob"))
key.set_namespace("test")

client.notify(key, "hello")
first = key.metadata.iv_nonce
client.notify(key, "hello again") # same key instance, already has an ivNonce
second = key.metadata.iv_nonce

self.assertIsNotNone(first)
self.assertNotEqual(first, second) # fresh nonce, not the previous one


if __name__ == "__main__":
unittest.main()