Skip to content

Commit 39e75a8

Browse files
committed
Bump dependency, enforce https, santize output, version to stable 2.0
1 parent f19e49d commit 39e75a8

File tree

8 files changed

+387
-32
lines changed

8 files changed

+387
-32
lines changed

README.md

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,64 @@ async def main():
4040
asyncio.run(main())
4141
```
4242

43+
### TLS and self-signed certificates
44+
45+
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:
46+
47+
- Preferred (secure): provide a custom SSLContext that trusts your CA or certificate
48+
- Last resort (not for production): disable certificate validation explicitly
49+
50+
Examples:
51+
52+
```python
53+
import ssl
54+
from fmd_api import FmdClient
55+
56+
# 1) Custom CA bundle / pinned cert (recommended)
57+
ctx = ssl.create_default_context()
58+
ctx.load_verify_locations(cafile="/path/to/your/ca.pem")
59+
60+
# Via constructor
61+
client = FmdClient("https://fmd.example.com", ssl=ctx)
62+
63+
# Or via factory
64+
# async with await FmdClient.create("https://fmd.example.com", "user", "pass", ssl=ctx) as client:
65+
66+
# 2) Disable verification (development only)
67+
insecure_client = FmdClient("https://fmd.example.com", ssl=False)
68+
```
69+
70+
Notes:
71+
- HTTP (http://) is rejected. Use only HTTPS URLs.
72+
- Prefer a custom SSLContext over disabling verification.
73+
- For higher security, consider pinning the server cert in your context.
74+
75+
> Warning
76+
>
77+
> 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.
78+
79+
#### Pinning the exact server certificate (recommended for self-signed)
80+
81+
If you're using a self-signed certificate and want to pin to that exact cert, load the server's PEM (or DER) directly into an SSLContext. This ensures only that certificate (or its CA) is trusted.
82+
83+
```python
84+
import ssl
85+
from fmd_api import FmdClient
86+
87+
# Export your server's certificate to PEM (e.g., server-cert.pem)
88+
ctx = ssl.create_default_context()
89+
ctx.verify_mode = ssl.CERT_REQUIRED
90+
ctx.check_hostname = True # keep hostname verification when possible
91+
ctx.load_verify_locations(cafile="/path/to/server-cert.pem")
92+
93+
client = FmdClient("https://fmd.example.com", ssl=ctx)
94+
# async with await FmdClient.create("https://fmd.example.com", "user", "pass", ssl=ctx) as client:
95+
```
96+
97+
Tips:
98+
- If the server cert changes, pinning will fail until you update the PEM.
99+
- For intermediate/CA signing chains, prefer pinning a private CA instead of the leaf.
100+
43101
## What’s in the box
44102

45103
- `FmdClient` (primary API)

debugging/pin_cert_example.py

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
"""
2+
Quick TLS test for FmdClient with certificate pinning or insecure mode.
3+
4+
Security note:
5+
- Avoid passing passwords on the command line (they can end up in history). This script supports env vars
6+
and secure prompt input so you can omit --password.
7+
8+
Usage (PowerShell):
9+
# 1) Export server cert to PEM using Python stdlib
10+
# Replace host as needed
11+
python - << 'PY'
12+
import ssl
13+
host = "fmd.example.com"
14+
pem = ssl.get_server_certificate((host, 443))
15+
open("server-cert.pem", "w").write(pem)
16+
print("Wrote server-cert.pem for", host)
17+
PY
18+
19+
# 2) Set env vars (recommended)
20+
$env:FMD_ID = "<FMD_ID>"
21+
$env:FMD_PASSWORD = "<PASSWORD>" # Consider Read-Host -AsSecureString for interactive use
22+
23+
# 3) Try connecting with pinned cert (preferred for self-signed)
24+
python debugging/pin_cert_example.py --base-url https://fmd.example.com --ca server-cert.pem
25+
26+
# 4) Or try insecure mode (development only)
27+
python debugging/pin_cert_example.py --base-url https://fmd.example.com --insecure
28+
"""
29+
30+
from __future__ import annotations
31+
32+
import argparse
33+
import asyncio
34+
import ssl
35+
from pathlib import Path
36+
import os
37+
import getpass
38+
39+
from fmd_api import FmdClient
40+
41+
42+
async def run(base_url: str, fmd_id: str, password: str, ca_path: str | None, insecure: bool) -> int:
43+
ssl_arg: object | None
44+
if insecure:
45+
ssl_arg = False
46+
elif ca_path:
47+
ctx = ssl.create_default_context()
48+
ctx.verify_mode = ssl.CERT_REQUIRED
49+
ctx.check_hostname = True
50+
ctx.load_verify_locations(cafile=ca_path)
51+
ssl_arg = ctx
52+
else:
53+
ssl_arg = None # system default validation
54+
55+
try:
56+
async with await FmdClient.create(
57+
base_url,
58+
fmd_id,
59+
password,
60+
ssl=ssl_arg,
61+
) as client:
62+
# Minimal call that exercises the API with auth
63+
# This will call /api/v1/locationDataSize
64+
locs = await client.get_locations(num_to_get=0)
65+
print("TLS OK; auth OK; available locations:", len(locs))
66+
return 0
67+
except Exception as e:
68+
print("Failed:", e)
69+
return 2
70+
71+
72+
def main() -> int:
73+
p = argparse.ArgumentParser(description="FmdClient TLS/pinning test")
74+
p.add_argument("--base-url", required=True, help="Base URL, must be https://...")
75+
p.add_argument("--id", help="FMD user ID (or set FMD_ID env var)")
76+
p.add_argument("--password", help="FMD password (or set FMD_PASSWORD env var, or omit to be prompted)")
77+
p.add_argument("--ca", dest="ca_path", help="Path to PEM file to trust (pinning or custom CA)")
78+
p.add_argument("--insecure", action="store_true", help="Disable certificate validation (development only)")
79+
args = p.parse_args()
80+
81+
if args.base_url.lower().startswith("http://"):
82+
p.error("--base-url must be HTTPS (http:// is not allowed)")
83+
84+
if args.ca_path and not Path(args.ca_path).exists():
85+
p.error(f"--ca file not found: {args.ca_path}")
86+
87+
if args.insecure and args.ca_path:
88+
p.error("Use either --ca or --insecure, not both.")
89+
90+
# Resolve credentials from args, env, or prompt
91+
fmd_id = args.id or os.environ.get("FMD_ID")
92+
if not fmd_id:
93+
fmd_id = input("FMD ID: ")
94+
95+
password = args.password or os.environ.get("FMD_PASSWORD")
96+
if not password:
97+
# Prompt securely without echo
98+
password = getpass.getpass("Password: ")
99+
100+
return asyncio.run(run(args.base_url, fmd_id, password, args.ca_path, args.insecure))
101+
102+
103+
if __name__ == "__main__":
104+
raise SystemExit(main())

docs/HOME_ASSISTANT_REVIEW.md

Lines changed: 40 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -68,12 +68,9 @@ async def _make_api_request(self, ..., timeout: int = 30):
6868
**HA Rationale:** Only stable, released versions accepted as integration dependencies.
6969

7070
**Status:** ✅ FIXED
71-
- Implemented configurable retry policy in `FmdClient._make_api_request()`
72-
- Handles 429 with `Retry-After` header and exponential backoff with jitter
73-
- Retries transient 5xx (500/502/503/504) and connection errors
74-
- Avoids unsafe retries for `/api/v1/command` POST requests
75-
- Configurable via constructor: `max_retries`, `backoff_base`, `backoff_max`, `jitter`
76-
- Added unit tests for 429 + Retry-After and 500 -> success flows
71+
- Bumped version to stable `2.0.0` in `pyproject.toml` and `fmd_api/_version.py`
72+
- Built sdist and wheel artifacts for release
73+
- All unit tests passing after version bump
7774

7875
---
7976

@@ -111,7 +108,13 @@ if resp.status == 429:
111108

112109
**HA Rationale:** Production integrations must handle rate limits gracefully to avoid service disruption.
113110

114-
**Status:** ❌ TODO
111+
**Status:** ✅ FIXED
112+
- Implemented 429 handling with Retry-After header support and exponential backoff with optional jitter
113+
- Retries for transient 5xx (500/502/503/504) and connection errors
114+
- Avoids unsafe retries for POST /api/v1/command, except on 401 re-auth or 429 with explicit Retry-After
115+
- Configurable via `max_retries`, `backoff_base`, `backoff_max`, `jitter`
116+
- Added unit tests: `test_rate_limit_retry_with_retry_after`, `test_server_error_retry_then_success`
117+
- All unit tests passing
115118

116119
---
117120

@@ -174,7 +177,8 @@ async with await FmdClient.create(...) as client:
174177

175178
**HA Rationale:** Misleading classifiers can cause installation issues.
176179

177-
**Status:** ❌ TODO
180+
**Status:** ✅ FIXED
181+
- Removed Python 3.7 classifier; `requires-python` is `>=3.8`
178182

179183
---
180184

@@ -194,7 +198,13 @@ async with await FmdClient.create(...) as client:
194198

195199
**HA Rationale:** Security and privacy requirement for production systems.
196200

197-
**Status:** ❌ TODO
201+
**Status:** ✅ FIXED
202+
- Removed logging of full JSON responses (line ~278); now logs only dict keys
203+
- Removed logging of response body text (line ~285); now logs only length
204+
- Added `_mask_token()` helper for safe token logging (shows first 8 chars)
205+
- Added comment to prevent signature logging in `send_command()`
206+
- Auth flow logs only workflow steps, never actual credentials
207+
- All 55 unit tests passing after sanitization
198208

199209
---
200210

@@ -203,19 +213,23 @@ async with await FmdClient.create(...) as client:
203213

204214
**Location:** `fmd_api/client.py` `_ensure_session()` method
205215

206-
**Fix:** Add `verify_ssl` parameter to constructor:
216+
**Fix:** Add SSL parameter/connector configuration to constructor:
207217
```python
208-
def __init__(self, base_url: str, ..., verify_ssl: bool = True):
209-
self.verify_ssl = verify_ssl
218+
def __init__(..., ssl: Optional[ssl.SSLContext|bool] = None, ...):
219+
self._ssl = ssl # None=default verify, False=disable, SSLContext=custom
210220

211221
async def _ensure_session(self):
212-
connector = aiohttp.TCPConnector(ssl=self.verify_ssl)
222+
connector = aiohttp.TCPConnector(ssl=self._ssl, ...)
213223
self._session = aiohttp.ClientSession(connector=connector)
214224
```
215225

216226
**HA Rationale:** Enterprise users and development environments need SSL configuration flexibility.
217227

218-
**Status:** ❌ TODO
228+
**Status:** ✅ FIXED
229+
- New constructor options: `ssl` (None | False | SSLContext)
230+
- Verified by unit test `test_connector_configuration_applied`
231+
- Works with self-signed certs when `ssl=False`, or custom trust via SSLContext
232+
- HTTPS is explicitly enforced: `http://` base URLs are rejected by the client
219233

220234
---
221235

@@ -228,7 +242,8 @@ async def _ensure_session(self):
228242

229243
**HA Rationale:** Improves reliability in production environments with occasional network issues.
230244

231-
**Status:** ❌ TODO
245+
**Status:** ✅ FIXED
246+
- Covered by issue 5 implementation; includes exponential backoff for transient errors
232247

233248
---
234249

@@ -280,7 +295,10 @@ async def _ensure_session(self):
280295

281296
**HA Rationale:** Performance tuning capability for production deployments.
282297

283-
**Status:** ❌ TODO
298+
**Status:** ✅ FIXED
299+
- New constructor options: `conn_limit`, `conn_limit_per_host`, `keepalive_timeout`
300+
- Applied via `aiohttp.TCPConnector` in `_ensure_session()`
301+
- Verified by unit test `test_connector_configuration_applied`
284302

285303
---
286304

@@ -475,15 +493,15 @@ def __init__(self, ..., ssl_context: Optional[ssl.SSLContext] = None):
475493
4. Fix version string inconsistency
476494
5. Add `py.typed` file
477495
6. Implement async context manager
478-
7. Add rate limit handling
496+
7. Add rate limit handling — DONE
479497

480498
**For Production Quality (Major):**
481-
- Fix Python 3.7 classifier
482-
- Sanitize logs (security)
483-
- Add SSL verification control
499+
- Fix Python 3.7 classifier — DONE
500+
- Sanitize logs (security) — DONE
501+
- Add SSL verification control — DONE
484502
- Improve type hints
485-
- Add retry logic
486-
- Configure connection pooling
503+
- Add retry logic — DONE
504+
- Configure connection pooling — DONE
487505
- Make decryption async
488506

489507
**For Best Practices (Minor):**

fmd_api/_version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "2.0.0.dev11"
1+
__version__ = "2.0.0"

0 commit comments

Comments
 (0)