python-aidot-cameras 0.10.0__tar.gz → 0.10.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. {python_aidot_cameras-0.10.0/src/python_aidot_cameras.egg-info → python_aidot_cameras-0.10.1}/PKG-INFO +1 -1
  2. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/pyproject.toml +1 -1
  3. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/src/aidot/camera/client.py +28 -8
  4. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/src/aidot/camera/lan_control.py +11 -0
  5. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/src/aidot/camera/models.py +46 -31
  6. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/src/aidot/camera/protocol.py +10 -1
  7. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/src/aidot/camera/webrtc_open.py +85 -29
  8. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/src/aidot/client.py +15 -3
  9. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/src/aidot/credentials.py +14 -3
  10. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/src/aidot/device_client.py +7 -1
  11. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1/src/python_aidot_cameras.egg-info}/PKG-INFO +1 -1
  12. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/src/python_aidot_cameras.egg-info/SOURCES.txt +2 -0
  13. python_aidot_cameras-0.10.1/tests/test_dtls_pinning.py +63 -0
  14. python_aidot_cameras-0.10.1/tests/test_security_hardening.py +67 -0
  15. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/LICENSE +0 -0
  16. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/MANIFEST.in +0 -0
  17. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/README.md +0 -0
  18. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/setup.cfg +0 -0
  19. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/src/aidot/__init__.py +0 -0
  20. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/src/aidot/__main__.py +0 -0
  21. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/src/aidot/aes_utils.py +0 -0
  22. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/src/aidot/camera/__init__.py +0 -0
  23. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/src/aidot/camera/constants.py +0 -0
  24. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/src/aidot/camera/controls.py +0 -0
  25. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/src/aidot/camera/go2rtc.py +0 -0
  26. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/src/aidot/camera/playback.py +0 -0
  27. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/src/aidot/camera/sdes.py +0 -0
  28. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/src/aidot/camera/sdes_open.py +0 -0
  29. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/src/aidot/camera/tutk.py +0 -0
  30. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/src/aidot/camera/webrtc.py +0 -0
  31. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/src/aidot/const.py +0 -0
  32. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/src/aidot/discover.py +0 -0
  33. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/src/aidot/exceptions.py +0 -0
  34. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/src/aidot/g711.py +0 -0
  35. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/src/aidot/login_const.py +0 -0
  36. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/src/aidot/models/__init__.py +0 -0
  37. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/src/aidot/models/device_client_model.py +0 -0
  38. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/src/aidot/models/device_model.py +0 -0
  39. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/src/aidot/models/discover_model.py +0 -0
  40. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/src/aidot/py.typed +0 -0
  41. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/src/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
  42. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/src/python_aidot_cameras.egg-info/entry_points.txt +0 -0
  43. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/src/python_aidot_cameras.egg-info/requires.txt +0 -0
  44. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/src/python_aidot_cameras.egg-info/top_level.txt +0 -0
  45. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_aioice_compat.py +0 -0
  46. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_alarm_event.py +0 -0
  47. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_backoff.py +0 -0
  48. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_device_login_guard.py +0 -0
  49. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_device_user_info_cache.py +0 -0
  50. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_dtls_not_ready_burst.py +0 -0
  51. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_dtls_skip_signaling_wait.py +0 -0
  52. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_egress_guard.py +0 -0
  53. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_go2rtc.py +0 -0
  54. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_go2rtc_cli.py +0 -0
  55. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_highport_nomination.py +0 -0
  56. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_ice_config_cache.py +0 -0
  57. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_keyframe_prompter.py +0 -0
  58. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_lan_control.py +0 -0
  59. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_live_stream_param.py +0 -0
  60. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_motion_poll.py +0 -0
  61. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_narrow_pc_ice.py +0 -0
  62. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_no_undefined_names.py +0 -0
  63. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_open_gate_delay.py +0 -0
  64. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_persistent_mqtt.py +0 -0
  65. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_persistent_mqtt_robustness.py +0 -0
  66. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_playback_tls.py +0 -0
  67. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_post_merge_hardening.py +0 -0
  68. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_retry_policy.py +0 -0
  69. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_sdes_adaptive.py +0 -0
  70. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_sdes_echo_wait_timeout.py +0 -0
  71. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_sdes_fast_liveplay.py +0 -0
  72. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_sdes_idle_release.py +0 -0
  73. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_sdes_serve_audio.py +0 -0
  74. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_sdes_serve_cmd.py +0 -0
  75. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_sdes_sprop.py +0 -0
  76. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_sdes_talk.py +0 -0
  77. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_sdes_watchdog.py +0 -0
  78. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_serve_relay.py +0 -0
  79. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_session_stats.py +0 -0
  80. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_speak.py +0 -0
  81. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_stream_cap.py +0 -0
  82. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_stream_idle.py +0 -0
  83. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_stream_teardown.py +0 -0
  84. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_talk.py +0 -0
  85. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_terminal_ack.py +0 -0
  86. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_token_refresh.py +0 -0
  87. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_wait_or_event.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-aidot-cameras
3
- Version: 0.10.0
3
+ Version: 0.10.1
4
4
  Summary: Control AiDot/Leedarson WiFi lights and cameras (WebRTC streaming, two-way audio, PTZ, controls)
5
5
  Author-email: cbrightly <chris.brightly@gmail.com>
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-aidot-cameras"
7
- version = "0.10.0"
7
+ version = "0.10.1"
8
8
  description = "Control AiDot/Leedarson WiFi lights and cameras (WebRTC streaming, two-way audio, PTZ, controls)"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -213,6 +213,7 @@ def _save_frame_as_jpeg(image_data: Any, output_path: str) -> bool:
213
213
  ],
214
214
  input=image_data.tobytes(),
215
215
  capture_output=True,
216
+ timeout=15, # bound the hang; a wedged ffmpeg must not block forever
216
217
  )
217
218
  return r.returncode == 0
218
219
  except Exception as exc:
@@ -1929,7 +1930,6 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
1929
1930
  # ── SDES path: stream briefly to a temp TS file, extract one JPEG ──── #
1930
1931
  if self.is_sdes_camera:
1931
1932
  import os as _os
1932
- import subprocess as _sp
1933
1933
  import tempfile as _tf
1934
1934
 
1935
1935
  with _tf.NamedTemporaryFile(suffix=".ts", delete=False) as _tmpf:
@@ -1970,17 +1970,33 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
1970
1970
  if _ffmpeg_path() is None:
1971
1971
  _LOGGER.error("async_snapshot SDES: %s", _FFMPEG_MISSING_MSG)
1972
1972
  return False
1973
- _ffmpeg_snap = _sp.run(
1974
- ["ffmpeg", "-y", "-i", _tmp_ts,
1975
- "-frames:v", "1", "-f", "image2", output_path],
1976
- capture_output=True, timeout=15,
1973
+ # Async subprocess (not subprocess.run): a synchronous ffmpeg
1974
+ # here blocks the whole event loop - every camera, keepalive and
1975
+ # MQTT drain - for up to the timeout.
1976
+ _snap_proc = await _asyncio.create_subprocess_exec(
1977
+ "ffmpeg", "-y", "-i", _tmp_ts,
1978
+ "-frames:v", "1", "-f", "image2", output_path,
1979
+ stdout=_asyncio.subprocess.DEVNULL,
1980
+ stderr=_asyncio.subprocess.PIPE,
1977
1981
  )
1978
- if _ffmpeg_snap.returncode == 0 and _os.path.exists(output_path):
1982
+ try:
1983
+ _, _snap_err = await _asyncio.wait_for(
1984
+ _snap_proc.communicate(), timeout=15
1985
+ )
1986
+ except TimeoutError:
1987
+ _snap_proc.kill()
1988
+ await _snap_proc.communicate()
1989
+ _LOGGER.warning(
1990
+ "async_snapshot SDES: ffmpeg frame extract timed out for %s",
1991
+ self.device_id,
1992
+ )
1993
+ return False
1994
+ if _snap_proc.returncode == 0 and _os.path.exists(output_path):
1979
1995
  return True
1980
1996
  _LOGGER.warning(
1981
1997
  "async_snapshot SDES: ffmpeg frame extract failed for %s: %s",
1982
1998
  self.device_id,
1983
- _ffmpeg_snap.stderr.decode(errors="replace")[-200:],
1999
+ (_snap_err or b"").decode(errors="replace")[-200:],
1984
2000
  )
1985
2001
  return False
1986
2002
  except Exception as _snap_exc:
@@ -2032,7 +2048,11 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
2032
2048
 
2033
2049
  if captured[0] is None:
2034
2050
  return False
2035
- return _save_frame_as_jpeg(captured[0], output_path)
2051
+ # _save_frame_as_jpeg does a blocking PIL encode (or a blocking ffmpeg
2052
+ # fallback); run it off the event loop so it can't stall other cameras.
2053
+ return await _asyncio.get_running_loop().run_in_executor(
2054
+ None, _save_frame_as_jpeg, captured[0], output_path
2055
+ )
2036
2056
 
2037
2057
  async def async_start_streaming(self) -> None:
2038
2058
  """Start a persistent background WebRTC stream that updates latest_jpeg.
@@ -59,6 +59,11 @@ ATTR_KEYS = {
59
59
  }
60
60
 
61
61
 
62
+ # Control-channel bodies are small JSON; reject anything wildly larger than that
63
+ # so a hostile/broken LAN peer can't drive memory exhaustion via the size field.
64
+ _MAX_FRAME_BODY = 1024 * 1024 # 1 MiB
65
+
66
+
62
67
  def _pack(msgtype: int, body: bytes) -> bytes:
63
68
  return struct.pack(">Hhi", _MAGIC, msgtype, len(body)) + body
64
69
 
@@ -69,6 +74,12 @@ async def _read_frame(reader: asyncio.StreamReader, timeout: float) -> bytes:
69
74
  The caller decrypts and parses the JSON (the AES key lives on the client)."""
70
75
  header = await asyncio.wait_for(reader.readexactly(8), timeout)
71
76
  _magic, _mtype, bodysize = struct.unpack(">HHI", header)
77
+ # Cap the server-supplied body size: `bodysize` is an unsigned 32-bit field
78
+ # (up to ~4 GiB) from a LAN host, and control-channel bodies are small JSON.
79
+ # Reject an implausibly large frame instead of allocating on a malicious or
80
+ # malfunctioning peer (mirrors the 4 MiB cap in playback._read_frame).
81
+ if bodysize > _MAX_FRAME_BODY:
82
+ raise ValueError(f"LAN control frame body too large: {bodysize} bytes")
72
83
  body = await asyncio.wait_for(reader.readexactly(bodysize), timeout)
73
84
  return body # caller decrypts (key is on the client)
74
85
 
@@ -30,6 +30,24 @@ from .constants import (
30
30
  _LOGGER = logging.getLogger(__name__)
31
31
 
32
32
 
33
+ def _as_int(v: Any) -> Optional[int]:
34
+ """Coerce a cloud-supplied attr value to int, or None if not numeric.
35
+
36
+ Cloud/device attributes are untrusted: a non-numeric value must be skipped,
37
+ not raise ValueError/TypeError out of the status-refresh path.
38
+ """
39
+ try:
40
+ return int(v)
41
+ except (ValueError, TypeError):
42
+ return None
43
+
44
+
45
+ def _as_bool(v: Any) -> Optional[bool]:
46
+ """Coerce a 0/1 (int or numeric string) attr to bool, or None if not numeric."""
47
+ i = _as_int(v)
48
+ return None if i is None else bool(i)
49
+
50
+
33
51
  class CameraStatusData(DeviceStatusData):
34
52
  """Core status plus camera fields; accepts both typed-model and dict updates."""
35
53
 
@@ -74,44 +92,41 @@ class CameraStatusData(DeviceStatusData):
74
92
  # feeders either strip them (update_from_camera_attributes) or have
75
93
  # already applied them via the model (receive_data raw-dict pass).
76
94
  # Camera attributes
77
- if (v := attr.get("MotionDetection_Enable")) is not None:
78
- self.motion_detection = bool(int(v))
79
- if (v := attr.get("MotionDetection_Sen")) is not None:
80
- self.motion_sensitivity = int(v)
81
- if (v := attr.get("LedOnOff")) is not None:
82
- self.status_led = bool(int(v))
83
- if (v := attr.get("micEnable")) is not None:
84
- self.microphone = bool(int(v))
95
+ # Every conversion goes through _as_int/_as_bool so a malformed value
96
+ # from the cloud (e.g. "on" where 0/1 is expected) is skipped rather than
97
+ # raising out of update() and aborting the whole status refresh.
98
+ if (b := _as_bool(attr.get("MotionDetection_Enable"))) is not None:
99
+ self.motion_detection = b
100
+ if (i := _as_int(attr.get("MotionDetection_Sen"))) is not None:
101
+ self.motion_sensitivity = i
102
+ if (b := _as_bool(attr.get("LedOnOff"))) is not None:
103
+ self.status_led = b
104
+ if (b := _as_bool(attr.get("micEnable"))) is not None:
105
+ self.microphone = b
85
106
  if (v := attr.get("nightVisionMode")) is not None:
86
- try:
87
- nv = int(v)
107
+ nv = _as_int(v)
108
+ if nv is not None:
88
109
  self.night_vision_mode = {0: "auto", 1: "on", 2: "off"}.get(nv, str(nv))
89
- except (ValueError, TypeError):
110
+ else:
90
111
  # Camera may send string "on"/"off"/"auto" instead of 0/1/2
91
112
  self.night_vision_mode = str(v)
92
- if (v := attr.get("nightVisionIRLight")) is not None:
93
- self.ir_light = bool(int(v))
94
- if (v := attr.get("LightOnOff")) is not None:
95
- self.floodlight = bool(int(v))
96
- if (v := attr.get("trackingMode")) is not None:
97
- self.ptz_tracking = bool(int(v))
98
- if (v := attr.get("SoundLevel")) is not None:
99
- self.speaker_volume = int(v)
113
+ if (b := _as_bool(attr.get("nightVisionIRLight"))) is not None:
114
+ self.ir_light = b
115
+ if (b := _as_bool(attr.get("LightOnOff"))) is not None:
116
+ self.floodlight = b
117
+ if (b := _as_bool(attr.get("trackingMode"))) is not None:
118
+ self.ptz_tracking = b
119
+ if (i := _as_int(attr.get("SoundLevel"))) is not None:
120
+ self.speaker_volume = i
100
121
  # Diagnostic / read-only
101
- if (v := attr.get("Battery_remaining")) is not None:
102
- try:
103
- self.battery_remaining = int(v)
104
- except (ValueError, TypeError):
105
- _LOGGER.debug("swallowed exception in %s", 'update', exc_info=True)
106
- if (v := attr.get("Occupancy")) is not None:
107
- self.occupancy = bool(int(v))
122
+ if (i := _as_int(attr.get("Battery_remaining"))) is not None:
123
+ self.battery_remaining = i
124
+ if (b := _as_bool(attr.get("Occupancy"))) is not None:
125
+ self.occupancy = b
108
126
  if (v := attr.get("SDcardStatus")) is not None:
109
127
  self.sd_card_status = str(v)
110
- if (v := attr.get("networkRssi")) is not None:
111
- try:
112
- self.wifi_rssi = int(v)
113
- except (ValueError, TypeError):
114
- _LOGGER.debug("swallowed exception in %s", 'update', exc_info=True)
128
+ if (i := _as_int(attr.get("networkRssi"))) is not None:
129
+ self.wifi_rssi = i
115
130
 
116
131
  # Cloud "properties" keys that belong to lights, not cameras. A camera's
117
132
  # image "Dimming" must not be read as a light brightness (and could TypeError
@@ -8,11 +8,13 @@ imports client.py -- the import edge is one-way (client -> protocol).
8
8
  """
9
9
 
10
10
  import asyncio
11
+ import hashlib
11
12
  import ipaddress
12
13
  import json
13
14
  import logging
14
15
  import os
15
16
  import random
17
+ import re
16
18
  import select
17
19
  import socket
18
20
  import struct
@@ -552,7 +554,14 @@ def _build_sprop(sps: bytes, pps: bytes) -> str:
552
554
 
553
555
 
554
556
  def _sprop_cache_path(devid: str) -> str:
555
- return os.path.join(_SPROP_DIR, f"{devid}.sprop")
557
+ # `devid` originates from the cloud/device and is interpolated into a
558
+ # filesystem path, so a value containing "/", "\\", ".." or an absolute path
559
+ # could escape _SPROP_DIR (arbitrary read/write). Reduce it to a safe,
560
+ # collision-resistant filename component.
561
+ safe = re.sub(r"[^A-Za-z0-9_-]", "_", devid or "")
562
+ if not safe or len(devid or "") > 128:
563
+ safe = hashlib.sha256((devid or "").encode("utf-8", "replace")).hexdigest()
564
+ return os.path.join(_SPROP_DIR, f"{safe}.sprop")
556
565
 
557
566
 
558
567
  def _load_sprop(devid: str) -> "Optional[str]":
@@ -39,6 +39,47 @@ from .protocol import (
39
39
  _LOGGER = logging.getLogger(__name__)
40
40
 
41
41
 
42
+ def _normalize_fingerprint(value: str) -> str:
43
+ """Normalize a sha-256 fingerprint for comparison.
44
+
45
+ Accepts either the colon-separated uppercase hex aiortc emits or an operator-
46
+ supplied pin that may differ in case, contain/omit colons, or carry stray
47
+ whitespace. Reduces both to lowercase hex with no separators.
48
+ """
49
+ return value.replace(":", "").replace(" ", "").strip().lower()
50
+
51
+
52
+ def _dtls_pin_matches(real_fp: str, pinned_fp: str) -> bool:
53
+ """True if a cert's fingerprint matches the pin (format-insensitive)."""
54
+ return _normalize_fingerprint(real_fp) == _normalize_fingerprint(pinned_fp)
55
+
56
+
57
+ def _verified_dtls_fingerprint(cam_cert, pinned_fp: str, cert_fp_fn) -> Optional[str]:
58
+ """Return the peer cert's sha-256 fingerprint, enforcing the pin if set.
59
+
60
+ Fails CLOSED under pinning: if a pin is configured, a missing peer cert or a
61
+ fingerprint that does not match the pin raises ``ValueError`` (which the
62
+ caller propagates to fail the DTLS handshake). Any error computing the
63
+ fingerprint is the caller's responsibility to treat as a failure when pinned.
64
+
65
+ Returns the fingerprint string, or ``None`` when no cert is present and no
66
+ pin is configured (accept-any default — the caller may skip verification).
67
+ """
68
+ if cam_cert is None:
69
+ if pinned_fp:
70
+ raise ValueError(
71
+ "DTLS peer presented no certificate; cannot verify against pin"
72
+ )
73
+ return None
74
+ real_fp = cert_fp_fn(cam_cert, "sha-256")
75
+ if pinned_fp and not _dtls_pin_matches(real_fp, pinned_fp):
76
+ raise ValueError(
77
+ f"DTLS fingerprint mismatch: camera presented {real_fp}, "
78
+ f"pinned {pinned_fp}"
79
+ )
80
+ return real_fp
81
+
82
+
42
83
  async def _keyframe_prompter(send_pli, first_frame, interval: float = 0.7,
43
84
  max_tries: int = 8) -> int:
44
85
  """Send an RTCP PLI (keyframe request) immediately and then every `interval`
@@ -1221,7 +1262,15 @@ class _WebRTCOpenMixin:
1221
1262
  await asyncio.wait_for(mqtt_fut, timeout=5.0)
1222
1263
  except Exception:
1223
1264
  pass # MQTT thread exit errors don't affect the retry
1224
- return await self.async_open_webrtc_stream(
1265
+ # Call the UNGATED impl, not the public async_open_webrtc_stream:
1266
+ # we are already holding the global open-gate permit (acquired in
1267
+ # async_open_webrtc_stream), and the gate's semaphore is not
1268
+ # reentrant. Re-entering the public method here would try to
1269
+ # acquire a second permit and deadlock whenever the gate is
1270
+ # saturated (two SDES->DTLS fallbacks at once, or
1271
+ # AIDOT_MAX_CONCURRENT_OPENS=1). The impl runs the same handshake
1272
+ # under the permit we already hold.
1273
+ return await self._async_open_webrtc_stream_impl(
1225
1274
  on_frame,
1226
1275
  stream_id=stream_id,
1227
1276
  timeout=timeout,
@@ -2352,27 +2401,33 @@ class _WebRTCOpenMixin:
2352
2401
  )
2353
2402
 
2354
2403
  def _accept_camera_cert(self, remote_params):
2355
- """Verify (if pinned) or accept the camera's actual DTLS cert."""
2404
+ """Verify (if pinned) or accept the camera's actual DTLS cert.
2405
+
2406
+ Fails CLOSED when AIDOT_DTLS_PINNED_FP is set: a missing cert
2407
+ or any error computing/comparing the fingerprint fails the
2408
+ handshake rather than silently accepting an unverified peer.
2409
+ """
2356
2410
  try:
2357
2411
  _cam_cert = self._ssl.get_peer_certificate(
2358
2412
  as_cryptography=True
2359
2413
  )
2360
- if _cam_cert is not None:
2361
- _real_fp = _rr_cert_fp(_cam_cert, "sha-256")
2362
- if _pinned_fp:
2363
- if _real_fp.upper() != _pinned_fp.upper():
2364
- raise ValueError(
2365
- "DTLS fingerprint mismatch: camera "
2366
- f"presented {_real_fp}, pinned "
2367
- f"{_pinned_fp}"
2368
- )
2414
+ _real_fp = _verified_dtls_fingerprint(
2415
+ _cam_cert, _pinned_fp, _rr_cert_fp
2416
+ )
2417
+ if _real_fp is not None:
2369
2418
  remote_params.fingerprints[:] = [
2370
2419
  _RRFp(algorithm="sha-256", value=_real_fp)
2371
2420
  ]
2372
2421
  except ValueError:
2373
- raise # pinned-fingerprint mismatch: fail the handshake
2422
+ raise # pin mismatch / no cert under pin: fail the handshake
2374
2423
  except Exception:
2375
- pass # cert retrieval failed; skip verification
2424
+ if _pinned_fp:
2425
+ raise # fail closed: never accept an unverifiable cert
2426
+ _LOGGER.debug(
2427
+ "camera %s: DTLS cert retrieval failed; accept-any "
2428
+ "(unpinned)", getattr(self, "device_id", "?"),
2429
+ exc_info=True,
2430
+ )
2376
2431
 
2377
2432
  for _rr_tc in pc.getTransceivers():
2378
2433
  _rr_tc.receiver.transport._validate_peer_identity = (
@@ -2837,7 +2892,16 @@ class _WebRTCOpenMixin:
2837
2892
  # Apply DTLS cert bypass - always, whenever we patched
2838
2893
  # fingerprints (camera's empty-fingerprint quirk applies on
2839
2894
  # every webrtcResp from this firmware family).
2840
- if _fp_subs:
2895
+ # Install our peer-cert validator when EITHER the camera's empty-
2896
+ # fingerprint quirk needs the workaround (_fp_subs) OR a pin is
2897
+ # configured. Pinning must be enforced even when the camera sent a
2898
+ # REAL fingerprint: that fingerprint rides attacker-influenceable
2899
+ # signaling, so a MITM can present a well-formed value matching its
2900
+ # own cert and aiortc's native check would pass. Gating the pin on
2901
+ # _fp_subs (empty-fingerprint only) left it silently inert for cameras
2902
+ # that present a real fingerprint.
2903
+ _np_pinned_fp = (os.environ.get("AIDOT_DTLS_PINNED_FP") or "").strip()
2904
+ if _fp_subs or _np_pinned_fp:
2841
2905
  import types as _np_types
2842
2906
  try:
2843
2907
  from aiortc.rtcdtlstransport import (
@@ -2845,11 +2909,6 @@ class _WebRTCOpenMixin:
2845
2909
  certificate_digest as _np_cert_fp,
2846
2910
  )
2847
2911
 
2848
- # See AIDOT_DTLS_PINNED_FP note above: optional pinning,
2849
- # otherwise accept-any with a one-time warning.
2850
- _np_pinned_fp = (
2851
- os.environ.get("AIDOT_DTLS_PINNED_FP") or ""
2852
- ).strip()
2853
2912
  if not _np_pinned_fp:
2854
2913
  _LOGGER.warning(
2855
2914
  "camera %s: DTLS peer certificate is NOT authenticated "
@@ -2865,21 +2924,18 @@ class _WebRTCOpenMixin:
2865
2924
  _cam_cert = self._ssl.get_peer_certificate(
2866
2925
  as_cryptography=True
2867
2926
  )
2868
- if _cam_cert is not None:
2869
- _real_fp = _np_cert_fp(_cam_cert, "sha-256")
2870
- if _np_pinned_fp:
2871
- if _real_fp.upper() != _np_pinned_fp.upper():
2872
- raise ValueError(
2873
- "DTLS fingerprint mismatch: camera "
2874
- f"presented {_real_fp}, pinned "
2875
- f"{_np_pinned_fp}"
2876
- )
2927
+ _real_fp = _verified_dtls_fingerprint(
2928
+ _cam_cert, _np_pinned_fp, _np_cert_fp
2929
+ )
2930
+ if _real_fp is not None:
2877
2931
  remote_params.fingerprints[:] = [
2878
2932
  _NPFp(algorithm="sha-256", value=_real_fp)
2879
2933
  ]
2880
2934
  except ValueError:
2881
- raise # pinned-fingerprint mismatch: fail handshake
2935
+ raise # pin mismatch / no cert under pin: fail handshake
2882
2936
  except Exception:
2937
+ if _np_pinned_fp:
2938
+ raise # fail closed: never accept an unverifiable cert
2883
2939
  _LOGGER.debug("camera %s: swallowed exception in %s", getattr(self, "device_id", "?"), '_np_accept_cam_cert', exc_info=True)
2884
2940
 
2885
2941
  # Diag: log PC/ICE state at patch-application time so we
@@ -245,7 +245,12 @@ class AidotClient:
245
245
  except AidotUserOrPassIncorrect:
246
246
  raise
247
247
  except aiohttp.ClientError as e:
248
- _LOGGER.error("async_post_login ClientError %s response=%s", e, response_data)
248
+ # Do NOT log response_data: the login body carries accessToken /
249
+ # refreshToken. Log only the status code the server returned.
250
+ _LOGGER.error(
251
+ "async_post_login ClientError %s (code=%s)",
252
+ e, (response_data or {}).get(CONF_CODE),
253
+ )
249
254
  raise
250
255
 
251
256
  async def _async_fetch_user_config(self) -> None:
@@ -298,9 +303,12 @@ class AidotClient:
298
303
  self.login_info["mqttPassword"] = pwd
299
304
  _LOGGER.info("_async_fetch_user_config: mqttPassword stored (len=%d)", len(pwd))
300
305
  else:
306
+ # Do NOT log `body`: the userConfig response often still carries
307
+ # the live mqttPassword under an unexpected key, which is exactly
308
+ # when this branch fires. Log the key names only.
301
309
  _LOGGER.warning(
302
310
  "_async_fetch_user_config: mqttPassword not found in response. "
303
- "keys=%s body=%s", list(data.keys()), body
311
+ "keys=%s", list(data.keys())
304
312
  )
305
313
  # Also extract MQTT clientId if provided.
306
314
  client_id = data.get("mqttClientId") or mqtt_block.get("clientId") or ""
@@ -408,7 +416,11 @@ class AidotClient:
408
416
  response.raise_for_status()
409
417
  return response_data
410
418
  except aiohttp.ClientError as e:
411
- _LOGGER.error("async_get ClientError %s %s", e, response_data)
419
+ # Do NOT log response_data: /devices carries per-device aesKey and
420
+ # password. Log the response code only.
421
+ _LOGGER.error(
422
+ "async_get ClientError %s (code=%s)", e, response_data.get(CONF_CODE)
423
+ )
412
424
  code = response_data.get(CONF_CODE)
413
425
  if code == ServerErrorCode.TOKEN_EXPIRED:
414
426
  if not _retry:
@@ -173,9 +173,20 @@ def _load_plain(path: str) -> dict:
173
173
 
174
174
 
175
175
  def _write_secret(path: str, data: bytes) -> None:
176
- """Write binary data to path with 0600 permissions."""
177
- os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
178
- with open(path, "wb") as f:
176
+ """Write binary data to path, created atomically with 0600 permissions."""
177
+ d = os.path.dirname(os.path.abspath(path))
178
+ os.makedirs(d, exist_ok=True)
179
+ try:
180
+ os.chmod(d, 0o700) # restrict the config dir too (best-effort)
181
+ except OSError:
182
+ pass
183
+ # Create with 0600 from the start so the secret is never briefly
184
+ # world/group-readable under the process umask (a chmod-after-open leaves a
185
+ # window where another local user could read the key/ciphertext).
186
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
187
+ with os.fdopen(fd, "wb") as f:
179
188
  f.write(data)
180
189
  f.write(b"\n")
190
+ # If the file pre-existed with looser perms, O_CREAT's mode was ignored;
191
+ # tighten it explicitly.
181
192
  os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
@@ -200,7 +200,13 @@ class DeviceClient(CameraMixin):
200
200
  self.ping_data = None
201
201
  self.heart_time = 10
202
202
 
203
- _LOGGER.debug(f"{self._TAG}:{device}")
203
+ # Log a redacted subset only: the raw device record carries aesKey and
204
+ # password, which must not reach logs even at DEBUG.
205
+ if isinstance(device, dict):
206
+ _LOGGER.debug(
207
+ "%s: device id=%s modelId=%s",
208
+ self._TAG, device.get("id"), device.get("modelId"),
209
+ )
204
210
  self._init_camera_state(device, user_info)
205
211
 
206
212
  async def connect(self, ip_address) -> None:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-aidot-cameras
3
- Version: 0.10.0
3
+ Version: 0.10.1
4
4
  Summary: Control AiDot/Leedarson WiFi lights and cameras (WebRTC streaming, two-way audio, PTZ, controls)
5
5
  Author-email: cbrightly <chris.brightly@gmail.com>
6
6
  License-Expression: MIT
@@ -44,6 +44,7 @@ tests/test_backoff.py
44
44
  tests/test_device_login_guard.py
45
45
  tests/test_device_user_info_cache.py
46
46
  tests/test_dtls_not_ready_burst.py
47
+ tests/test_dtls_pinning.py
47
48
  tests/test_dtls_skip_signaling_wait.py
48
49
  tests/test_egress_guard.py
49
50
  tests/test_go2rtc.py
@@ -71,6 +72,7 @@ tests/test_sdes_serve_cmd.py
71
72
  tests/test_sdes_sprop.py
72
73
  tests/test_sdes_talk.py
73
74
  tests/test_sdes_watchdog.py
75
+ tests/test_security_hardening.py
74
76
  tests/test_serve_relay.py
75
77
  tests/test_session_stats.py
76
78
  tests/test_speak.py
@@ -0,0 +1,63 @@
1
+ """DTLS fingerprint-pinning verification (AIDOT_DTLS_PINNED_FP).
2
+
3
+ These guard the fail-closed contract: with a pin configured, a missing cert or a
4
+ non-matching fingerprint must fail the handshake, never silently accept. The
5
+ absence of exactly this test is how the earlier fail-open bugs survived.
6
+ """
7
+
8
+ import pytest
9
+
10
+ from aidot.camera.webrtc_open import (
11
+ _dtls_pin_matches,
12
+ _normalize_fingerprint,
13
+ _verified_dtls_fingerprint,
14
+ )
15
+
16
+ # aiortc formats sha-256 fingerprints as uppercase colon-separated hex.
17
+ REAL = ":".join(["AB", "CD", "EF", "01"] * 8) # 32 bytes
18
+
19
+
20
+ def _fp_fn(cert, alg):
21
+ # In these tests the "cert" object IS its fingerprint string.
22
+ assert alg == "sha-256"
23
+ return cert
24
+
25
+
26
+ def test_normalize_strips_colons_case_and_space():
27
+ assert _normalize_fingerprint("AB:CD:ef 12") == "abcdef12"
28
+
29
+
30
+ def test_pin_matches_is_format_insensitive():
31
+ assert _dtls_pin_matches(REAL, REAL.lower().replace(":", ""))
32
+ assert _dtls_pin_matches(REAL, f" {REAL} ")
33
+ assert not _dtls_pin_matches(REAL, "00:11")
34
+
35
+
36
+ def test_matching_pin_returns_fingerprint():
37
+ assert _verified_dtls_fingerprint(REAL, REAL, _fp_fn) == REAL
38
+
39
+
40
+ def test_pin_format_differences_still_match():
41
+ # Operator supplies the pin without colons / lowercased.
42
+ assert _verified_dtls_fingerprint(REAL, REAL.lower().replace(":", ""), _fp_fn) == REAL
43
+
44
+
45
+ def test_mismatching_pin_raises():
46
+ with pytest.raises(ValueError, match="mismatch"):
47
+ _verified_dtls_fingerprint(REAL, "00:11:22", _fp_fn)
48
+
49
+
50
+ def test_missing_cert_with_pin_fails_closed():
51
+ # No peer cert while pinning MUST fail, not accept-any.
52
+ with pytest.raises(ValueError, match="no certificate"):
53
+ _verified_dtls_fingerprint(None, REAL, _fp_fn)
54
+
55
+
56
+ def test_missing_cert_without_pin_accepts():
57
+ assert _verified_dtls_fingerprint(None, "", _fp_fn) is None
58
+
59
+
60
+ def test_present_cert_without_pin_returns_fingerprint():
61
+ # Unpinned but a cert is present: return the real fp so the caller can
62
+ # rewrite remote_params.fingerprints (the empty-fingerprint workaround).
63
+ assert _verified_dtls_fingerprint(REAL, "", _fp_fn) == REAL
@@ -0,0 +1,67 @@
1
+ """Regression tests for the 0.10.x security-review hardening fixes."""
2
+
3
+ import asyncio
4
+ import os
5
+ import struct
6
+
7
+ import pytest
8
+
9
+ from aidot.camera.lan_control import _MAGIC, _MAX_FRAME_BODY, _read_frame
10
+ from aidot.camera.models import _as_bool, _as_int
11
+ from aidot.camera.protocol import _SPROP_DIR, _sprop_cache_path
12
+
13
+
14
+ # ── models: malformed cloud attrs are skipped, not fatal ──────────────────────
15
+
16
+ def test_as_int_coerces_or_none():
17
+ assert _as_int("5") == 5
18
+ assert _as_int(5) == 5
19
+ assert _as_int("on") is None # non-numeric string must not raise
20
+ assert _as_int(None) is None
21
+ assert _as_int({}) is None
22
+
23
+
24
+ def test_as_bool_coerces_or_none():
25
+ assert _as_bool("0") is False
26
+ assert _as_bool("1") is True
27
+ assert _as_bool(1) is True
28
+ assert _as_bool("yes") is None # non-numeric must not raise
29
+ assert _as_bool(None) is None
30
+
31
+
32
+ # ── sprop cache: devid can't escape the cache dir ─────────────────────────────
33
+
34
+ @pytest.mark.parametrize("devid", ["../../etc/passwd", "/etc/passwd", "a/b/c", "..", "x\\y"])
35
+ def test_sprop_path_stays_in_cache_dir(devid):
36
+ p = os.path.abspath(_sprop_cache_path(devid))
37
+ assert os.path.dirname(p) == os.path.abspath(_SPROP_DIR)
38
+ assert p.endswith(".sprop")
39
+
40
+
41
+ def test_sprop_path_normal_devid_preserved():
42
+ p = _sprop_cache_path("LK-IPC-A000088-abc123")
43
+ assert os.path.basename(p) == "LK-IPC-A000088-abc123.sprop"
44
+
45
+
46
+ # ── LAN control: oversized frame body is rejected before allocation ───────────
47
+
48
+ def test_read_frame_rejects_oversized_body():
49
+ async def _run():
50
+ reader = asyncio.StreamReader()
51
+ reader.feed_data(struct.pack(">HHI", _MAGIC, 1, _MAX_FRAME_BODY + 1))
52
+ reader.feed_eof()
53
+ with pytest.raises(ValueError, match="too large"):
54
+ await _read_frame(reader, timeout=1.0)
55
+
56
+ asyncio.run(_run())
57
+
58
+
59
+ def test_read_frame_accepts_small_body():
60
+ async def _run():
61
+ body = b'{"ok":1}'
62
+ reader = asyncio.StreamReader()
63
+ reader.feed_data(struct.pack(">HHI", _MAGIC, 1, len(body)) + body)
64
+ reader.feed_eof()
65
+ assert await _read_frame(reader, timeout=1.0) == body
66
+
67
+ asyncio.run(_run())