Skip to content

Commit cb47302

Browse files
committed
Improved device label handling
- Backends can now indicate whether they support device labels - Device labels now have to be signed according to XEP-0384 version 0.9.0
1 parent 735d23e commit cb47302

File tree

8 files changed

+113
-36
lines changed

8 files changed

+113
-36
lines changed

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66

77
## [Unreleased]
88

9+
### Added
10+
- Added three new abstract methods related to device label handling to `Backend`.
11+
- Added device label signing and signature verification capabilities as per XEP-0384 version 0.9.0.
12+
13+
### Changed
14+
- Modified the public device list API to include a signature for device labels.
15+
916
## [1.3.0] - 24th of June, 2025
1017

1118
### Added

docs/conf.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@
8585

8686
autodoc_typehints = "description"
8787
autodoc_type_aliases = { k: k for k in {
88+
"DeviceList",
8889
"JSONType",
8990
"Ed25519Pub",
9091
"Priv",

docs/omemo/types.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,5 @@ Module: types
1010
Type Aliases
1111
============
1212

13+
.. autoclass:: omemo.types.DeviceList
1314
.. autoclass:: omemo.types.JSONType

omemo/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,9 @@
5555
from .types import (
5656
AsyncFramework as AsyncFramework,
5757
DeviceInformation as DeviceInformation,
58+
DeviceList as DeviceList,
5859
JSONType as JSONType,
5960
OMEMOException as OMEMOException,
61+
SignedLabel as SignedLabel,
6062
TrustLevel as TrustLevel
6163
)

omemo/backend.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,6 +418,40 @@ async def get_bundle(self, bare_jid: str, device_id: int) -> Bundle:
418418
Do not include pre keys hidden by :meth:`hide_pre_key` in the bundle!
419419
"""
420420

421+
@property
422+
@abstractmethod
423+
def supports_labels(self) -> bool:
424+
"""
425+
Returns:
426+
Whether this backend supports labels for devices.
427+
"""
428+
429+
@abstractmethod
430+
async def sign_own_label(self, label: str) -> bytes:
431+
"""
432+
Sign a device label using the identity key.
433+
434+
Args:
435+
label: The label to sign.
436+
437+
Returns:
438+
A signature of the label created using the identity key.
439+
"""
440+
441+
@abstractmethod
442+
async def verify_label_signature(self, label: str, signature: bytes, identity_key: bytes) -> bool:
443+
"""
444+
Verify a device label's signature against the device owner's identity key.
445+
446+
Args:
447+
label: The label or signed data.
448+
signature: The signature.
449+
identity_key: The device owner's identity public key in Ed25519 format.
450+
451+
Returns:
452+
Whether the signature is valid.
453+
"""
454+
421455
@abstractmethod
422456
async def purge(self) -> None:
423457
"""

omemo/session_manager.py

Lines changed: 51 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
from .message import EncryptedKeyMaterial, KeyExchange, Message, PlainKeyMaterial
2222
from .session import Initiation, Session
2323
from .storage import NothingException, Storage
24-
from .types import AsyncFramework, DeviceInformation, OMEMOException, TrustLevel
24+
from .types import AsyncFramework, DeviceInformation, DeviceList, OMEMOException, SignedLabel, TrustLevel
2525

2626

2727
__all__ = [
@@ -877,13 +877,13 @@ async def _delete_bundle(namespace: str, device_id: int) -> None:
877877

878878
@staticmethod
879879
@abstractmethod
880-
async def _upload_device_list(namespace: str, device_list: Dict[int, Optional[str]]) -> None:
880+
async def _upload_device_list(namespace: str, device_list: DeviceList) -> None:
881881
"""
882882
Upload the device list for this XMPP account.
883883
884884
Args:
885885
namespace: The XML namespace to execute this operation under.
886-
device_list: The device list to upload. Mapping from device id to optional label.
886+
device_list: The device list to upload. Mapping from device id to optional signed label.
887887
888888
Raises:
889889
UnknownNamespace: if the namespace is unknown.
@@ -900,7 +900,7 @@ async def _upload_device_list(namespace: str, device_list: Dict[int, Optional[st
900900

901901
@staticmethod
902902
@abstractmethod
903-
async def _download_device_list(namespace: str, bare_jid: str) -> Dict[int, Optional[str]]:
903+
async def _download_device_list(namespace: str, bare_jid: str) -> DeviceList:
904904
"""
905905
Download the device list of a specific XMPP account.
906906
@@ -909,7 +909,7 @@ async def _download_device_list(namespace: str, bare_jid: str) -> Dict[int, Opti
909909
bare_jid: The bare JID of the XMPP account.
910910
911911
Returns:
912-
The device list as a dictionary, mapping the device ids to their optional label.
912+
The device list as a dictionary, mapping the device ids to their optional signed label.
913913
914914
Raises:
915915
UnknownNamespace: if the namespace is unknown.
@@ -1010,20 +1010,15 @@ async def _send_message(message: Message, bare_jid: str) -> None:
10101010
# device list management #
10111011
##########################
10121012

1013-
async def update_device_list(
1014-
self,
1015-
namespace: str,
1016-
bare_jid: str,
1017-
device_list: Dict[int, Optional[str]]
1018-
) -> None:
1013+
async def update_device_list(self, namespace: str, bare_jid: str, device_list: DeviceList) -> None:
10191014
"""
10201015
Update the device list of a specific bare JID, e.g. after receiving an update for the XMPP account
10211016
from `PEP <https://xmpp.org/extensions/xep-0163.html>`__.
10221017
10231018
Args:
10241019
namespace: The XML namespace to execute this operation under.
10251020
bare_jid: The bare JID of the XMPP account.
1026-
device_list: The updated device list. Mapping from device id to optional label.
1021+
device_list: The updated device list. Mapping from device id to optional signed label.
10271022
10281023
Raises:
10291024
UnknownNamespace: if the backend to handle the message is not currently loaded.
@@ -1041,8 +1036,9 @@ async def update_device_list(
10411036

10421037
storage = self.__storage
10431038

1044-
# This isn't strictly necessary, but good for consistency
1045-
if namespace not in frozenset(backend.namespace for backend in self.__backends):
1039+
# Find the backend to handle this device list update
1040+
backend = next(filter(lambda backend: backend.namespace == namespace, self.__backends), None)
1041+
if backend is None:
10461042
raise UnknownNamespace(f"The backend handling the namespace {namespace} is not currently loaded.")
10471043

10481044
# Copy to make sure the original is not modified
@@ -1066,16 +1062,25 @@ async def update_device_list(
10661062
)
10671063

10681064
# Add this device to the device list and publish it
1069-
device_list[self.__own_device_id] = (await storage.load_optional(
1065+
own_label = (await storage.load_optional(
10701066
f"/devices/{self.__own_bare_jid}/{self.__own_device_id}/label",
10711067
str
10721068
)).from_just()
1069+
1070+
device_list[self.__own_device_id] = None if own_label is None else SignedLabel(
1071+
label=own_label,
1072+
signature=await backend.sign_own_label(own_label)
1073+
)
1074+
10731075
await self._upload_device_list(namespace, device_list)
10741076

10751077
# Add new device information entries for new devices
10761078
for device_id in new_devices:
10771079
await storage.store(f"/devices/{bare_jid}/{device_id}/active", { namespace: True })
1078-
await storage.store(f"/devices/{bare_jid}/{device_id}/label", device_list[device_id])
1080+
# Device label processing is deferred until after all basic information has been processed, since
1081+
# the identity keys and thus bundle data is required to verify label signatures. This extended
1082+
# information is better fetched in bulk as the final step.
1083+
await storage.store(f"/devices/{bare_jid}/{device_id}/label", None)
10791084
await storage.store(f"/devices/{bare_jid}/{device_id}/namespaces", [ namespace ])
10801085

10811086
# Update namespaces, label and status for previously known devices
@@ -1087,24 +1092,12 @@ async def update_device_list(
10871092

10881093
active = (await storage.load_dict(f"/devices/{bare_jid}/{device_id}/active", bool)).from_just()
10891094

1090-
if device_id in device_list:
1095+
if device_id in new_device_list:
10911096
# Update the status if required
10921097
if namespace not in active or active[namespace] is False:
10931098
active[namespace] = True
10941099
await storage.store(f"/devices/{bare_jid}/{device_id}/active", active)
10951100

1096-
# Update the label if required. Even though loading the value first isn't strictly required,
1097-
# it is done under the assumption that loading values is cheaper than writing.
1098-
label = (await storage.load_optional(
1099-
f"/devices/{bare_jid}/{device_id}/label",
1100-
str
1101-
)).from_just()
1102-
1103-
# Don't interpret ``None`` as "no label set" here. Instead, interpret ``None`` as "the backend
1104-
# doesn't support labels".
1105-
if device_list[device_id] is not None and device_list[device_id] != label:
1106-
await storage.store(f"/devices/{bare_jid}/{device_id}/label", device_list[device_id])
1107-
11081101
# Add the namespace if required
11091102
if namespace not in namespaces:
11101103
namespaces.add(namespace)
@@ -1116,11 +1109,34 @@ async def update_device_list(
11161109
active[namespace] = False
11171110
await storage.store(f"/devices/{bare_jid}/{device_id}/active", active)
11181111

1119-
# If there are unknown devices in the new device list, update the list of known devices. Do this as
1120-
# the last step to ensure data consistency.
1112+
# If there are unknown devices in the new device list, update the list of known devices. Do this after
1113+
# processing all data except for device labels to ensure data consistency.
11211114
if len(new_devices) > 0:
11221115
await storage.store(f"/devices/{bare_jid}/list", list(new_device_list | old_device_list))
11231116

1117+
# If the backend supports labels, process new and updated labels now that all basic information has
1118+
# been processed.
1119+
if backend.supports_labels:
1120+
for device_information in await self.get_device_information(bare_jid):
1121+
device_id = device_information.device_id
1122+
if device_id in new_device_list:
1123+
signed_label = device_list[device_id]
1124+
new_label = None if signed_label is None else signed_label.label
1125+
if new_label != device_information.label:
1126+
signature_valid = signed_label is None or await backend.verify_label_signature(
1127+
signed_label.label,
1128+
signed_label.signature,
1129+
device_information.identity_key
1130+
)
1131+
1132+
if signature_valid:
1133+
await storage.store(f"/devices/{bare_jid}/{device_id}/label", new_label)
1134+
else:
1135+
logging.getLogger(SessionManager.LOG_TAG).warning(
1136+
f"In device list update for {bare_jid} under namespace {namespace}: ignored"
1137+
f" device label for device {device_id} without valid signature: {new_label}"
1138+
)
1139+
11241140
logging.getLogger(SessionManager.LOG_TAG).debug("Device list update processed.")
11251141

11261142
async def refresh_device_list(self, namespace: str, bare_jid: str) -> None:
@@ -1363,7 +1379,10 @@ async def set_own_label(self, own_label: Optional[str]) -> None:
13631379
# However, one PEP node fetch per backend isn't super expensive and it's nice to avoid the code to
13641380
# load the cached device list.
13651381
device_list = await self._download_device_list(backend.namespace, self.__own_bare_jid)
1366-
device_list[self.__own_device_id] = own_label
1382+
device_list[self.__own_device_id] = None if own_label is None else SignedLabel(
1383+
label=own_label,
1384+
signature=await backend.sign_own_label(own_label)
1385+
)
13671386
await self._upload_device_list(backend.namespace, device_list)
13681387

13691388
async def get_device_information(self, bare_jid: str) -> FrozenSet[DeviceInformation]:

omemo/types.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
11
from __future__ import annotations
22

33
import enum
4-
from typing import FrozenSet, List, Mapping, NamedTuple, Optional, Tuple, Union
4+
from typing import Dict, FrozenSet, List, Mapping, NamedTuple, Optional, Tuple, Union
55
from typing_extensions import TypeAlias
66

77

88
__all__ = [
99
"AsyncFramework",
1010
"DeviceInformation",
11+
"DeviceList",
1112
"JSONType",
1213
"OMEMOException",
14+
"SignedLabel",
1315
"TrustLevel"
1416
]
1517

@@ -30,6 +32,16 @@ class OMEMOException(Exception):
3032
"""
3133

3234

35+
class SignedLabel(NamedTuple):
36+
# pylint: disable=invalid-name
37+
"""
38+
Structure containing a device label and the corresponding signature.
39+
"""
40+
41+
label: str
42+
signature: bytes
43+
44+
3345
class DeviceInformation(NamedTuple):
3446
# pylint: disable=invalid-name
3547
"""
@@ -56,4 +68,5 @@ class TrustLevel(enum.Enum):
5668
UNDECIDED = "UNDECIDED"
5769

5870

71+
DeviceList: TypeAlias = Dict[int, Optional[SignedLabel]]
5972
JSONType: TypeAlias = Union[Mapping[str, "JSONType"], List["JSONType"], str, int, float, bool, None]

tests/session_manager_impl.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ class DeviceListStorageKey(NamedTuple):
5050

5151

5252
BundleStorage = Dict[BundleStorageKey, omemo.Bundle]
53-
DeviceListStorage = Dict[DeviceListStorageKey, Dict[int, Optional[str]]]
53+
DeviceListStorage = Dict[DeviceListStorageKey, omemo.DeviceList]
5454
MessageQueue = List[Tuple[str, omemo.Message]]
5555

5656

@@ -106,14 +106,14 @@ async def _delete_bundle(namespace: str, device_id: int) -> None:
106106
raise omemo.BundleDeletionFailed() from e
107107

108108
@staticmethod
109-
async def _upload_device_list(namespace: str, device_list: Dict[int, Optional[str]]) -> None:
109+
async def _upload_device_list(namespace: str, device_list: omemo.DeviceList) -> None:
110110
device_list_storage[DeviceListStorageKey(
111111
namespace=namespace,
112112
bare_jid=own_bare_jid
113113
)] = device_list
114114

115115
@staticmethod
116-
async def _download_device_list(namespace: str, bare_jid: str) -> Dict[int, Optional[str]]:
116+
async def _download_device_list(namespace: str, bare_jid: str) -> omemo.DeviceList:
117117
try:
118118
return device_list_storage[DeviceListStorageKey(
119119
namespace=namespace,

0 commit comments

Comments
 (0)