Skip to content
Merged
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
4 changes: 4 additions & 0 deletions ably/realtime/annotations.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ async def __send_annotation(self, annotation: Annotation, params: dict | None =
f'type = {annotation.type}, action = {annotation.action}'
)

# RSAN1c3: encrypt the data payload on an encrypted channel
if self.__channel.cipher:
annotation.encrypt(self.__channel.cipher)

# Convert to wire format (array of annotations)
wire_annotation = annotation.as_dict(binary=self.__channel.ably.options.use_binary_protocol)

Expand Down
6 changes: 5 additions & 1 deletion ably/rest/annotations.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,10 @@ async def __send_annotation(self, annotation: Annotation, params: dict | None =
random_id = base64.b64encode(os.urandom(9)).decode('ascii') + ':0'
annotation = annotation._copy_with(id=random_id)

# RSAN1c3: encrypt the data payload on an encrypted channel
if self.__channel.cipher:
annotation.encrypt(self.__channel.cipher)

# Convert to wire format
request_body = annotation.as_dict(binary=self.__channel.ably.options.use_binary_protocol)

Expand Down Expand Up @@ -232,7 +236,7 @@ async def get(self, msg_or_serial, params: dict | None = None):
path = self.__base_path_for_serial(message_serial) + params_str

# Create annotation response handler
annotation_handler = make_annotation_response_handler(cipher=None)
annotation_handler = make_annotation_response_handler(cipher=self.__channel.cipher)

# Return paginated result
return await PaginatedResult.paginated_query(
Expand Down
38 changes: 33 additions & 5 deletions ably/types/annotation.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
from enum import IntEnum

from ably.types.mixins import EncodeDataMixin
from ably.types.typedbuffer import TypedBuffer
from ably.util.crypto import CipherData
from ably.util.encoding import encode_data
from ably.util.helper import to_text

Expand All @@ -21,8 +23,6 @@ class AnnotationAction(IntEnum):
class Annotation(EncodeDataMixin):
"""
Represents an annotation on a message, such as a reaction or other metadata.

Annotations are not encrypted as they need to be parsed by the server for summarization.
"""

def __init__(self,
Expand Down Expand Up @@ -140,11 +140,35 @@ def id(self):
def connection_id(self):
return self.__connection_id

def encrypt(self, channel_cipher):
"""
Encrypt the annotation's data payload in place.

Mirrors Message.encrypt(); call before as_dict() when publishing on an
encrypted channel.
"""
# Annotations commonly carry no data at all, e.g. a reaction that is fully
# described by its name, so there is nothing to encrypt.
if self.__data is None:
return

if isinstance(self.__data, CipherData):
return

if isinstance(self.__data, str):
self._encoding_array.append('utf-8')
elif isinstance(self.__data, (dict, list)):
self._encoding_array.append('json')
self._encoding_array.append('utf-8')

typed_data = TypedBuffer.from_obj(self.__data)
encrypted_data = channel_cipher.encrypt(typed_data.buffer)
self.__data = CipherData(encrypted_data, typed_data.type,
cipher_type=channel_cipher.cipher_type)

def as_dict(self, binary=False):
"""
Convert annotation to dictionary format for API communication.

Note: Annotations are not encrypted as they need to be parsed by the server.
"""
request_body = {
'action': int(self.action) if self.action is not None else None,
Expand All @@ -171,7 +195,11 @@ def from_encoded(obj, cipher=None, context=None):
"""
Create an Annotation from an encoded object received from the API.

Note: cipher parameter is accepted for consistency but annotations are not encrypted.
Args:
obj: The encoded annotation
cipher: The channel's cipher, used to decrypt the data payload if it
was published on an encrypted channel
context: Optional decoding context
"""
action = obj.get('action')
serial = obj.get('serial')
Expand Down
59 changes: 59 additions & 0 deletions test/unit/annotation_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
- RSAN1c1/RSAN2a: explicit action setting in publish/delete
- TAN3: from_encoded / from_encoded_array decoding
- TAN2i: serial-based equality
- RSAN1c3: encryption of the data payload on an encrypted channel
"""

import base64
Expand All @@ -17,6 +18,7 @@
from ably.rest.annotations import construct_validate_annotation, serial_from_msg_or_serial
from ably.types.annotation import Annotation, AnnotationAction
from ably.types.message import Message
from ably.util.crypto import generate_random_key, get_cipher
from ably.util.exceptions import AblyException

# --- RSAN1a3: type validation ---
Expand Down Expand Up @@ -317,3 +319,60 @@ def test_from_encoded_array():
assert annotations[0].action == AnnotationAction.ANNOTATION_CREATE
assert annotations[1].name == '👎'
assert annotations[1].action == AnnotationAction.ANNOTATION_DELETE


# --- encryption of the data payload ---

def _cipher():
return get_cipher({'key': generate_random_key(256)})


@pytest.mark.parametrize('binary', [False, True])
@pytest.mark.parametrize('payload', [
'secret text',
{'secret': 'value'},
['secret', 1],
bytearray(b'secret bytes'),
])
def test_encrypt_data_round_trips(payload, binary):
"""encrypt() must encrypt the data payload, and from_encoded() must decrypt it"""
cipher = _cipher()
annotation = Annotation(type='reaction:distinct.v1', name='👍', data=payload)
annotation.encrypt(cipher)

d = annotation.as_dict(binary=binary)
assert 'cipher+aes-256-cbc' in d['encoding']
assert 'secret' not in str(d['data'])

decoded = Annotation.from_encoded(d, cipher=cipher)
assert decoded.data == payload


def test_encrypt_without_data_is_a_noop():
"""Annotations commonly carry no data, which must not error"""
annotation = Annotation(type='reaction:distinct.v1', name='👍')
annotation.encrypt(_cipher())
assert annotation.data is None
assert 'data' not in annotation.as_dict()


def test_encrypt_is_idempotent():
"""Already-encrypted data must not be encrypted a second time"""
cipher = _cipher()
annotation = Annotation(type='reaction:distinct.v1', data='secret text')
annotation.encrypt(cipher)
once = annotation.as_dict()['data']
annotation.encrypt(cipher)
assert annotation.as_dict()['data'] == once


def test_from_encoded_without_cipher_leaves_residual_encoding():
"""Decoding encrypted data with no cipher must leave the transform in `encoding`"""
cipher = _cipher()
annotation = Annotation(type='reaction:distinct.v1', data='secret text')
annotation.encrypt(cipher)
d = annotation.as_dict()

decoded = Annotation.from_encoded(d)
assert 'cipher+aes-256-cbc' in decoded.encoding
assert decoded.data != 'secret text'
Loading