From bfafca45fc45d9627fffa0219e2e5e02da42cd59 Mon Sep 17 00:00:00 2001 From: Devin Slick Date: Sun, 1 Feb 2026 14:33:06 -0600 Subject: [PATCH 1/3] Draft improvements --- CHANGELOG.md | 113 +++++++++++++ docs/HOME_ASSISTANT_REVIEW.md | 51 +++--- fmd_api/__init__.py | 6 +- fmd_api/_version.py | 2 +- fmd_api/client.py | 194 ++++++++++++++++++++++- fmd_api/device.py | 30 ++-- fmd_api/helpers.py | 7 +- pyproject.toml | 2 +- tests/unit/test_coverage_improvements.py | 31 +++- tests/unit/test_lock_message.py | 33 ++++ 10 files changed, 417 insertions(+), 52 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d559ebf --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,113 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [2.0.8] - 2025-02-01 + +### Added +- `decrypt_data_blob_async()` method for non-blocking decryption in async contexts +- Exported `Location`, `PhotoResult`, and `RateLimitError` from package root +- `CHANGELOG.md` following Keep a Changelog format +- Comprehensive exception documentation (Raises sections) for all public API methods + +### Changed +- Improved lock message sanitization to re-collapse whitespace after removing special characters +- Simplified `get_history()` signature by removing unused `start`/`end` parameters +- Improved docstrings for helpers.py functions + +### Removed +- Dead code: `_parse_location_blob()` function that was never called +- Placeholder comment from helpers.py + +### Fixed +- Lock messages containing only special characters now correctly fall back to plain "lock" command + +## [2.0.7] - 2025-01-15 + +### Changed +- Cleanup release to trigger new PyPI deployment +- No functional changes from 2.0.6 + +## [2.0.6] - 2025-01-10 + +### Added +- 100% test coverage achieved + +### Changed +- Full type safety with strict mypy checks +- Improved method signatures with precise return types + +## [2.0.5] - 2025-01-08 + +### Added +- Strict typing enforcement (Phase 1) +- Comprehensive edge case tests for `Location` parsing +- `docs/strict_typing_enforcement_plan.md` roadmap + +### Changed +- Updated community instance URL to https://server.fmd-foss.org/ + +## [2.0.4] - 2024-11-09 + +### Added +- Password-free authentication via `export_auth_artifacts()` and `from_auth_artifacts()` +- `drop_password=True` option to discard raw password after onboarding +- `Device.get_picture_blobs()` and `Device.decode_picture()` methods +- `Device.lock(message=...)` with sanitization (quotes, backticks, semicolons removed) +- Wipe PIN validation (alphanumeric ASCII only, no spaces) +- PNG detection via magic bytes in `export_data_zip()` + +### Changed +- 401 handling now supports hash-based token refresh +- Private key loading supports both PEM and DER formats +- Test coverage increased to ~98% + +### Deprecated +- `Device.take_front_photo()` - use `take_front_picture()` +- `Device.take_rear_photo()` - use `take_rear_picture()` +- `Device.fetch_pictures()` - use `get_picture_blobs()` +- `Device.download_photo()` - use `decode_picture()` + +## [2.0.0] - 2024-10-01 + +### Added +- Async client with `FmdClient.create()` factory method +- Async context manager support (`async with`) +- HTTPS enforcement (plain HTTP rejected) +- Configurable SSL validation (`ssl=False` for dev, custom `SSLContext` for production) +- Request timeouts on all HTTP calls +- Retry logic with exponential backoff and jitter for 5xx errors +- 429 rate-limit handling with Retry-After support +- Client-side ZIP export (locations + pictures) +- `Device` helper class for convenience actions +- `py.typed` marker for PEP 561 compliance +- GitHub Actions CI (lint, type-check, tests, coverage) +- Codecov integration with badges + +### Changed +- Complete rewrite from sync to async API +- Python 3.8+ required (3.7 dropped) + +### Removed +- Legacy synchronous `FmdApi` class + +### Security +- Sanitized logging (no sensitive payloads exposed) +- Token masking in debug output + +## [1.x] - Legacy + +Previous synchronous implementation. See git history for details. + +[Unreleased]: https://github.com/devinslick/fmd_api/compare/v2.0.8...HEAD +[2.0.8]: https://github.com/devinslick/fmd_api/compare/v2.0.7...v2.0.8 +[2.0.7]: https://github.com/devinslick/fmd_api/compare/v2.0.6...v2.0.7 +[2.0.6]: https://github.com/devinslick/fmd_api/compare/v2.0.5...v2.0.6 +[2.0.5]: https://github.com/devinslick/fmd_api/compare/v2.0.4...v2.0.5 +[2.0.4]: https://github.com/devinslick/fmd_api/compare/v2.0.0...v2.0.4 +[2.0.0]: https://github.com/devinslick/fmd_api/releases/tag/v2.0.0 diff --git a/docs/HOME_ASSISTANT_REVIEW.md b/docs/HOME_ASSISTANT_REVIEW.md index 20a6fc9..9baf6fd 100644 --- a/docs/HOME_ASSISTANT_REVIEW.md +++ b/docs/HOME_ASSISTANT_REVIEW.md @@ -318,7 +318,10 @@ async def decrypt_data_blob_async(self, data_b64: str) -> bytes: **HA Rationale:** Event loop blocking causes UI freezes and integration performance issues. -**Status:** ❌ TODO +**Status:** ✅ FIXED +- Added `decrypt_data_blob_async()` method that uses `run_in_executor()` +- Added test coverage for the async method +- Documented the sync vs async usage in docstrings --- @@ -350,7 +353,9 @@ async def decrypt_data_blob_async(self, data_b64: str) -> bytes: **HA Rationale:** Good practice for library maintenance and user communication. -**Status:** ❌ TODO +**Status:** ✅ FIXED +- Added `CHANGELOG.md` following Keep a Changelog format +- Documents all releases from 2.0.0 to current --- @@ -375,7 +380,9 @@ async def get_locations(...) -> List[str]: **HA Rationale:** Users need to know how to handle errors properly. -**Status:** ❌ TODO +**Status:** ✅ FIXED +- Added Raises sections to all public API methods in client.py +- Documented ValueError, FmdApiException, aiohttp.ClientError, asyncio.TimeoutError --- @@ -391,7 +398,10 @@ async def get_locations(...) -> List[str]: **HA Rationale:** Demonstrates code quality and test thoroughness. -**Status:** ❌ TODO +**Status:** ✅ FIXED +- Coverage reporting implemented with pytest-cov +- 100% branch coverage achieved +- Codecov badge added to README --- @@ -413,7 +423,8 @@ __all__ = [ **HA Rationale:** Makes API more discoverable and IDE-friendly. -**Status:** ❌ TODO +**Status:** ✅ FIXED +- Added `Location`, `PhotoResult`, and `RateLimitError` to `__all__` exports --- @@ -502,14 +513,14 @@ def __init__(self, ..., ssl_context: Optional[ssl.SSLContext] = None): - Improve type hints - Add retry logic — DONE - Configure connection pooling — DONE -- Make decryption async +- Make decryption async — DONE **For Best Practices (Minor):** - Add CI badges — PARTIAL (Added Tests + Codecov badges; PyPI/version badges pending) -- Create CHANGELOG.md -- Document exceptions -- Add test coverage reporting -- Export all public models +- Create CHANGELOG.md — DONE +- Document exceptions — DONE +- Add test coverage reporting — DONE (100% branch coverage) +- Export all public models — DONE --- @@ -566,16 +577,16 @@ def __init__(self, ..., ssl_context: Optional[ssl.SSLContext] = None): Before submitting to Home Assistant: -- [ ] All critical issues resolved -- [ ] Major security concerns addressed -- [ ] Type hints complete and accurate -- [ ] Documentation comprehensive -- [ ] Test coverage > 80% -- [ ] CHANGELOG.md up to date -- [ ] Stable version released to PyPI -- [ ] Code passes `flake8` and `mypy` -- [ ] CI runs tests on all supported Python versions -- [ ] CI enforces linting and type checking +- [x] All critical issues resolved +- [x] Major security concerns addressed +- [x] Type hints complete and accurate +- [x] Documentation comprehensive +- [x] Test coverage > 80% (Currently at 100%) +- [x] CHANGELOG.md up to date +- [x] Stable version released to PyPI +- [x] Code passes `flake8` and `mypy` +- [x] CI runs tests on all supported Python versions +- [x] CI enforces linting and type checking --- diff --git a/fmd_api/__init__.py b/fmd_api/__init__.py index 0bc9b83..229a677 100644 --- a/fmd_api/__init__.py +++ b/fmd_api/__init__.py @@ -1,15 +1,19 @@ # fmd_api package exports from .client import FmdClient from .device import Device -from .exceptions import FmdApiException, AuthenticationError, DeviceNotFoundError, OperationError +from .models import Location, PhotoResult +from .exceptions import FmdApiException, AuthenticationError, DeviceNotFoundError, OperationError, RateLimitError from ._version import __version__ __all__ = [ "FmdClient", "Device", + "Location", + "PhotoResult", "FmdApiException", "AuthenticationError", "DeviceNotFoundError", "OperationError", + "RateLimitError", "__version__", ] diff --git a/fmd_api/_version.py b/fmd_api/_version.py index 962c851..8cb37b5 100644 --- a/fmd_api/_version.py +++ b/fmd_api/_version.py @@ -1 +1 @@ -__version__ = "2.0.7" +__version__ = "2.0.8" diff --git a/fmd_api/client.py b/fmd_api/client.py index ca73077..daa1e72 100644 --- a/fmd_api/client.py +++ b/fmd_api/client.py @@ -123,6 +123,30 @@ async def create( keepalive_timeout: Optional[float] = None, drop_password: bool = False, ) -> "FmdClient": + """ + Factory method to create and authenticate an FmdClient. + + Args: + base_url: HTTPS URL of the FMD server. + fmd_id: User/device identifier. + password: Authentication password. + session_duration: Token validity in seconds (default 3600). + cache_ttl: Cache time-to-live in seconds. + timeout: HTTP request timeout in seconds. + ssl: SSL configuration (None=default, False=disable, SSLContext=custom). + conn_limit: Maximum total connections. + conn_limit_per_host: Maximum connections per host. + keepalive_timeout: Connection keepalive timeout. + drop_password: If True, discard password after successful auth. + + Returns: + Authenticated FmdClient instance. + + Raises: + ValueError: If base_url uses HTTP instead of HTTPS. + FmdApiException: If authentication fails or server returns an error. + asyncio.TimeoutError: If the request times out. + """ inst = cls( base_url, session_duration, @@ -176,7 +200,21 @@ async def close(self) -> None: async def authenticate(self, fmd_id: str, password: str, session_duration: int) -> None: """ Performs the full authentication and private key retrieval workflow. - Mirrors the behavior in the original fmd_api.FmdApi. + + This method: + 1. Requests a salt from the server + 2. Hashes the password with Argon2id + 3. Requests an access token + 4. Retrieves and decrypts the private key + + Args: + fmd_id: User/device identifier. + password: Authentication password. + session_duration: Token validity in seconds. + + Raises: + FmdApiException: If authentication fails or server returns an error. + asyncio.TimeoutError: If the request times out. """ log.info("[1] Requesting salt...") salt = await self._get_salt(fmd_id) @@ -322,6 +360,22 @@ async def export_auth_artifacts(self) -> AuthArtifacts: @classmethod async def from_auth_artifacts(cls, artifacts: AuthArtifacts) -> "FmdClient": + """ + Restore a client from previously exported authentication artifacts. + + This allows password-free session resumption using saved credentials. + + Args: + artifacts: Dictionary containing base_url, fmd_id, access_token, + private_key, and optionally password_hash, session_duration, + token_issued_at. + + Returns: + FmdClient instance ready for API calls. + + Raises: + ValueError: If required artifact fields are missing or invalid. + """ required = ["base_url", "fmd_id", "access_token", "private_key"] missing = [k for k in required if k not in artifacts] if missing: @@ -385,7 +439,18 @@ def decrypt_data_blob(self, data_b64: str) -> bytes: """ Decrypts a location or picture data blob using the instance's private key. - Raises FmdApiException on problems (matches original behavior). + This method performs CPU-intensive RSA and AES operations synchronously. + For async contexts (like Home Assistant), use decrypt_data_blob_async() instead + to avoid blocking the event loop. + + Args: + data_b64: Base64-encoded encrypted blob from the server. + + Returns: + Decrypted plaintext bytes. + + Raises: + FmdApiException: If private key not loaded, blob too small, or decryption fails. """ blob = base64.b64decode(_pad_base64(data_b64)) @@ -410,6 +475,25 @@ def decrypt_data_blob(self, data_b64: str) -> bytes: aesgcm = AESGCM(session_key) return aesgcm.decrypt(iv, ciphertext, None) + async def decrypt_data_blob_async(self, data_b64: str) -> bytes: + """ + Async wrapper for decrypt_data_blob that runs decryption in a thread executor. + + This prevents blocking the event loop during CPU-intensive RSA/AES operations. + Recommended for use in async contexts like Home Assistant integrations. + + Args: + data_b64: Base64-encoded encrypted blob from the server. + + Returns: + Decrypted plaintext bytes. + + Raises: + FmdApiException: If private key not loaded, blob too small, or decryption fails. + """ + loop = asyncio.get_running_loop() + return await loop.run_in_executor(None, self.decrypt_data_blob, data_b64) + # ------------------------- # HTTP helper # ------------------------- @@ -578,7 +662,18 @@ async def _make_api_request( async def get_locations(self, num_to_get: int = -1, skip_empty: bool = True, max_attempts: int = 10) -> List[str]: """ Fetches all or the N most recent location blobs. - Returns list of base64-encoded blobs (strings), same as original get_all_locations. + + Args: + num_to_get: Number of locations to fetch (-1 for all). + skip_empty: If True, skip empty/invalid blobs. + max_attempts: Maximum retry attempts for each location. + + Returns: + List of base64-encoded encrypted location blobs. + + Raises: + FmdApiException: If server returns an error or unexpected response. + asyncio.TimeoutError: If the request times out. """ log.debug(f"Getting locations, num_to_get={num_to_get}, " f"skip_empty={skip_empty}") size_str = await self._make_api_request( @@ -647,7 +742,17 @@ async def get_locations(self, num_to_get: int = -1, skip_empty: bool = True, max return locations async def get_pictures(self, num_to_get: int = -1, timeout: Optional[float] = None) -> List[JSONType]: - """Fetches all or the N most recent picture metadata blobs (raw server response).""" + """ + Fetches all or the N most recent picture metadata blobs. + + Args: + num_to_get: Number of pictures to fetch (-1 for all). + timeout: Custom timeout for this request (uses client default if None). + + Returns: + List of picture blobs (may be base64 strings or metadata dicts), or an empty + list if a network or HTTP error occurs. + """ req_timeout = aiohttp.ClientTimeout(total=timeout if timeout is not None else self.timeout) try: await self._ensure_session() @@ -811,7 +916,22 @@ async def export_data_zip(self, out_path: str, include_pictures: bool = True) -> # Commands # ------------------------- async def send_command(self, command: str) -> bool: - """Sends a signed command to the server. Returns True on success.""" + """ + Sends a signed command to the device via the server. + + Commands are signed with RSA-PSS using the client's private key. + + Args: + command: The command string to send (e.g., "ring", "lock", "locate"). + + Returns: + True if the command was sent successfully. + + Raises: + FmdApiException: If private key not loaded or command fails. + aiohttp.ClientError: If a network error occurs. + asyncio.TimeoutError: If the request times out. + """ log.info(f"Sending command to device: {command}") unix_time_ms = int(time.time() * 1000) message_to_sign = f"{unix_time_ms}:{command}" @@ -839,6 +959,18 @@ async def send_command(self, command: str) -> bool: raise FmdApiException(f"Failed to send command '{command}': {e}") from e async def request_location(self, provider: str = "all") -> bool: + """ + Request a fresh location update from the device. + + Args: + provider: Location provider - "all", "gps", "cell", "network", or "last". + + Returns: + True if the request was sent successfully. + + Raises: + FmdApiException: If the command fails. + """ provider_map = { "all": "locate", "gps": "locate gps", @@ -851,18 +983,53 @@ async def request_location(self, provider: str = "all") -> bool: return await self.send_command(command) async def set_bluetooth(self, enable: bool) -> bool: - """Set Bluetooth power explicitly: True = on, False = off.""" + """ + Set Bluetooth power state. + + Args: + enable: True to enable, False to disable. + + Returns: + True if the command was sent successfully. + + Raises: + FmdApiException: If the command fails. + """ command = "bluetooth on" if enable else "bluetooth off" log.info(f"{'Enabling' if enable else 'Disabling'} Bluetooth") return await self.send_command(command) async def set_do_not_disturb(self, enable: bool) -> bool: - """Set Do Not Disturb explicitly: True = on, False = off.""" + """ + Set Do Not Disturb mode. + + Args: + enable: True to enable, False to disable. + + Returns: + True if the command was sent successfully. + + Raises: + FmdApiException: If the command fails. + """ command = "nodisturb on" if enable else "nodisturb off" log.info(f"{'Enabling' if enable else 'Disabling'} Do Not Disturb mode") return await self.send_command(command) async def set_ringer_mode(self, mode: str) -> bool: + """ + Set the device ringer mode. + + Args: + mode: One of "normal", "vibrate", or "silent". + + Returns: + True if the command was sent successfully. + + Raises: + ValueError: If mode is not valid. + FmdApiException: If the command fails. + """ mode = mode.lower() mode_map = {"normal": "ringermode normal", "vibrate": "ringermode vibrate", "silent": "ringermode silent"} if mode not in mode_map: @@ -872,6 +1039,19 @@ async def set_ringer_mode(self, mode: str) -> bool: return await self.send_command(command) async def take_picture(self, camera: str = "back") -> bool: + """ + Request a picture from the device camera. + + Args: + camera: Which camera to use - "front" or "back". + + Returns: + True if the command was sent successfully. + + Raises: + ValueError: If camera is not "front" or "back". + FmdApiException: If the command fails. + """ camera = camera.lower() if camera not in ["front", "back"]: raise ValueError(f"Invalid camera '{camera}'. Must be 'front' or 'back'") diff --git a/fmd_api/device.py b/fmd_api/device.py index 210c525..841d6a3 100644 --- a/fmd_api/device.py +++ b/fmd_api/device.py @@ -8,7 +8,7 @@ import json from datetime import datetime, timezone -from typing import Optional, AsyncIterator, List, Dict, Union, cast +from typing import Optional, AsyncIterator, List, Dict, cast from .types import JSONType, PictureMetadata from .models import Location, PhotoResult @@ -17,13 +17,6 @@ from .client import FmdClient -def _parse_location_blob(blob_b64: str) -> Location: - """Helper to decrypt and parse a location blob into Location dataclass.""" - # This function expects the caller to pass in a client to decrypt; kept here - # for signature clarity in Device methods. - raise RuntimeError("Internal: _parse_location_blob should not be called directly") - - class Device: def __init__(self, client: FmdClient, fmd_id: str, raw: Optional[Dict[str, JSONType]] = None) -> None: self.client = client @@ -52,12 +45,21 @@ async def get_location(self, *, force: bool = False) -> Optional[Location]: await self.refresh(force=force) return self.cached_location - async def get_history( - self, start: Optional[Union[int, datetime]] = None, end: Optional[Union[int, datetime]] = None, limit: int = -1 - ) -> AsyncIterator[Location]: + async def get_history(self, limit: int = -1) -> AsyncIterator[Location]: """ Iterate historical locations. Uses client.get_locations() under the hood. - Yields decrypted Location objects newest-first (matches get_all_locations when requesting N recent). + + Args: + limit: Maximum number of locations to return. -1 for all available. + + Yields: + Decrypted Location objects, newest-first. + + Raises: + OperationError: If decryption or parsing fails for a location blob. + + Note: + Time-based filtering (start/end) is not currently supported by the FMD server API. """ # For parity with original behavior, we request num_to_get=limit when limit!=-1, # otherwise request all and stream. @@ -68,7 +70,7 @@ async def get_history( for b in blobs: try: - decrypted = self.client.decrypt_data_blob(b) + decrypted = await self.client.decrypt_data_blob_async(b) yield Location.from_json(decrypted.decode("utf-8")) except Exception as e: # skip invalid blobs but log @@ -144,6 +146,8 @@ async def lock(self, message: Optional[str] = None, passcode: Optional[str] = No # Remove characters that could break command parsing (quotes/backticks/semicolons) for ch in ['"', "'", "`", ";"]: sanitized = sanitized.replace(ch, "") + # Re-collapse whitespace after removing special chars (may leave gaps) + sanitized = " ".join(sanitized.split()) # Cap length to 120 chars to avoid overly long command payloads if len(sanitized) > 120: sanitized = sanitized[:120] diff --git a/fmd_api/helpers.py b/fmd_api/helpers.py index 05e087c..68a701b 100644 --- a/fmd_api/helpers.py +++ b/fmd_api/helpers.py @@ -1,14 +1,13 @@ -"""Small helper utilities.""" +"""Small helper utilities for base64 handling.""" import base64 def _pad_base64(s: str) -> str: + """Add padding to a base64 string if needed.""" return s + "=" * (-len(s) % 4) def b64_decode_padded(s: str) -> bytes: + """Decode a base64 string, adding padding if necessary.""" return base64.b64decode(_pad_base64(s)) - - -# Placeholder for pagination helpers, parse helpers, etc. diff --git a/pyproject.toml b/pyproject.toml index ac644a6..2e1b81c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "fmd_api" -version = "2.0.7" +version = "2.0.8" authors = [{name = "devinslick"}] description = "A Python client for the FMD (Find My Device) server API" readme = "README.md" diff --git a/tests/unit/test_coverage_improvements.py b/tests/unit/test_coverage_improvements.py index ae0dc1d..67a604e 100644 --- a/tests/unit/test_coverage_improvements.py +++ b/tests/unit/test_coverage_improvements.py @@ -734,12 +734,33 @@ async def test_backoff_without_jitter(): @pytest.mark.asyncio -async def test_device_internal_parse_location_error(): - """Test that _parse_location_blob raises RuntimeError (device.py line 23).""" - from fmd_api.device import _parse_location_blob +async def test_decrypt_data_blob_async(): + """Test decrypt_data_blob_async runs decryption without blocking event loop.""" + client = FmdClient("https://fmd.example.com") + + # Set up private key for decryption + private_key = rsa.generate_private_key(public_exponent=65537, key_size=3072, backend=default_backend()) + client.private_key = private_key + + # Create a properly encrypted blob + session_key = b"\x00" * 32 + aesgcm = AESGCM(session_key) + iv = b"\x01" * 12 + plaintext = b'{"lat":10.0,"lon":20.0,"date":1600000000000}' + ciphertext = aesgcm.encrypt(iv, plaintext, None) + + public_key = private_key.public_key() + session_key_packet = public_key.encrypt( + session_key, + asym_padding.OAEP(mgf=asym_padding.MGF1(algorithm=hashes.SHA256()), algorithm=hashes.SHA256(), label=None), + ) + + blob = session_key_packet + iv + ciphertext + blob_b64 = base64.b64encode(blob).decode("utf-8") - with pytest.raises(RuntimeError, match="should not be called directly"): - _parse_location_blob("dummy_blob") + # Test the async method + result = await client.decrypt_data_blob_async(blob_b64) + assert result == plaintext @pytest.mark.asyncio diff --git a/tests/unit/test_lock_message.py b/tests/unit/test_lock_message.py index 1e118c1..dacf846 100644 --- a/tests/unit/test_lock_message.py +++ b/tests/unit/test_lock_message.py @@ -106,3 +106,36 @@ def cb(url, **kwargs): assert payload == "a" * 120 finally: await client.close() + + +@pytest.mark.asyncio +async def test_device_lock_with_only_removed_chars_sends_plain_lock(): + """Test that a message with only removed characters results in plain 'lock' command.""" + client = FmdClient("https://fmd.example.com") + client.access_token = "token" + + class DummySigner: + def sign(self, message_bytes, pad, algo): + return b"\xab" * 64 + + client.private_key = DummySigner() + + await client._ensure_session() + device = Device(client, "test-device") + + with aioresponses() as m: + captured = {} + + def cb(url, **kwargs): + captured["json"] = kwargs.get("json") + return CallbackResult(status=200, body="OK") + + m.post("https://fmd.example.com/api/v1/command", callback=cb) + try: + # Message consists only of chars that get removed (quotes, semicolons, backticks) + ok = await device.lock(" ';\"` ;' \" ` ") + assert ok is True + # Should fall back to plain "lock" since sanitized message is empty + assert captured["json"]["Data"] == "lock" + finally: + await client.close() From 52c32ecdd266125d483854c2abbef0ed3d4c24ab Mon Sep 17 00:00:00 2001 From: Devin Slick Date: Sat, 21 Feb 2026 12:52:23 -0600 Subject: [PATCH 2/3] feat: allow HTTP connections; HTTPS is recommended but no longer required --- CHANGELOG.md | 3 +++ README.md | 6 +++--- fmd_api/client.py | 10 +++++----- tests/unit/test_client.py | 14 ++++++++++---- 4 files changed, 21 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d559ebf..5edb666 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed +- HTTP URLs (`http://`) are now permitted; HTTPS is strongly recommended but no longer enforced. Use HTTP only on trusted local networks or for development. + ## [2.0.8] - 2025-02-01 ### Added diff --git a/README.md b/README.md index 3f70132..8e5f253 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ asyncio.run(main()) ### TLS and self-signed certificates -Find My Device always requires HTTPS; plain HTTP is not allowed by this client. If you need to connect to a server with a self-signed certificate, you have two options: +HTTPS is strongly recommended for all connections to FMD server. HTTP is permitted for local development or trusted private networks, but should not be used in production. If you need to connect to a server with a self-signed certificate, you have two options: - Preferred (secure): provide a custom SSLContext that trusts your CA or certificate - Last resort (not for production): disable certificate validation explicitly @@ -68,13 +68,13 @@ insecure_client = FmdClient("https://fmd.example.com", ssl=False) ``` Notes: -- HTTP (http://) is rejected. Use only HTTPS URLs. +- HTTPS is strongly recommended. Use HTTP only on trusted local networks or for development. - Prefer a custom SSLContext over disabling verification. - For higher security, consider pinning the server cert in your context. > Warning > -> Passing `ssl=False` disables TLS certificate validation and should only be used in development. For production, use a custom `ssl.SSLContext` that trusts your CA/certificate or pin the server certificate. The client enforces HTTPS and rejects `http://` URLs. +> Passing `ssl=False` disables TLS certificate validation and should only be used in development. For production, use a custom `ssl.SSLContext` that trusts your CA/certificate or pin the server certificate. Using `http://` URLs sends credentials and data in plaintext — only use HTTP on trusted local networks or for development purposes. #### Pinning the exact server certificate (recommended for self-signed) diff --git a/fmd_api/client.py b/fmd_api/client.py index daa1e72..e0b718e 100644 --- a/fmd_api/client.py +++ b/fmd_api/client.py @@ -64,9 +64,9 @@ def __init__( conn_limit_per_host: Optional[int] = None, keepalive_timeout: Optional[float] = None, ) -> None: - # Enforce HTTPS only (FindMyDevice always uses TLS) - if base_url.lower().startswith("http://"): - raise ValueError("HTTPS is required for FmdClient base_url; plain HTTP is not allowed.") + # Validate that the URL uses a supported scheme + if not base_url.lower().startswith(("http://", "https://")): + raise ValueError("base_url must use http:// or https://") self.base_url = base_url.rstrip("/") self.session_duration = session_duration self.cache_ttl = cache_ttl @@ -127,7 +127,7 @@ async def create( Factory method to create and authenticate an FmdClient. Args: - base_url: HTTPS URL of the FMD server. + base_url: URL of the FMD server (https:// strongly recommended; http:// permitted for local/dev use). fmd_id: User/device identifier. password: Authentication password. session_duration: Token validity in seconds (default 3600). @@ -143,7 +143,7 @@ async def create( Authenticated FmdClient instance. Raises: - ValueError: If base_url uses HTTP instead of HTTPS. + ValueError: If base_url does not start with http:// or https://. FmdApiException: If authentication fails or server returns an error. asyncio.TimeoutError: If the request times out. """ diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index 360557e..4745c05 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -100,10 +100,16 @@ async def test_connector_configuration_applied(): await client.close() -def test_https_required(): - """FmdClient should reject non-HTTPS base URLs.""" - with pytest.raises(ValueError, match="HTTPS is required"): - FmdClient("http://fmd.example.com") +def test_http_url_accepted(): + """FmdClient should accept plain HTTP base URLs (HTTPS is strongly recommended but not required).""" + client = FmdClient("http://fmd.example.com") + assert client.base_url == "http://fmd.example.com" + + +def test_invalid_scheme_rejected(): + """FmdClient should reject URLs with unsupported schemes.""" + with pytest.raises(ValueError): + FmdClient("ftp://fmd.example.com") @pytest.mark.asyncio From 02013f38b24468f3b1f64312d9e1beb94fe5909c Mon Sep 17 00:00:00 2001 From: Devin Slick Date: Sat, 21 Feb 2026 12:55:11 -0600 Subject: [PATCH 3/3] chore: bump version to 2.0.9 --- CHANGELOG.md | 2 ++ pyproject.toml | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5edb666..7b6d69c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [2.0.9] - 2026-02-21 + ### Changed - HTTP URLs (`http://`) are now permitted; HTTPS is strongly recommended but no longer enforced. Use HTTP only on trusted local networks or for development. diff --git a/pyproject.toml b/pyproject.toml index 2e1b81c..6cbbd74 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "fmd_api" -version = "2.0.8" +version = "2.0.9" authors = [{name = "devinslick"}] description = "A Python client for the FMD (Find My Device) server API" readme = "README.md"