Skip to content

Commit 5369314

Browse files
authored
Merge pull request #9 from devinslick/ThoreFeedback
Changes to support feedback from @thgoebel, fmd-foss maintainer #8
2 parents e7a7540 + 251aa3e commit 5369314

20 files changed

+1082
-164
lines changed

.coverage

-28 KB
Binary file not shown.

README.md

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ from fmd_api import FmdClient
2626

2727
async def main():
2828
# Recommended: async context manager auto-closes session
29-
async with await FmdClient.create("https://fmd.example.com", "alice", "secret") as client:
29+
async with await FmdClient.create("https://fmd.example.com", "alice", "secret", drop_password=True) as client:
3030
# Request a fresh GPS fix and wait a bit on your side
3131
await client.request_location("gps")
3232

@@ -112,15 +112,36 @@ Tips:
112112
- `set_bluetooth(enable: bool)` — True = on, False = off
113113
- `set_do_not_disturb(enable: bool)` — True = on, False = off
114114
- `set_ringer_mode("normal|vibrate|silent")`
115-
- `get_device_stats()`
116115

116+
> **Note:** Device statistics functionality (`get_device_stats()`) has been temporarily removed and will be restored when the FMD server supports it (see [fmd-server#74](https://gitlab.com/fmd-foss/fmd-server/-/issues/74)).
117117
118118
- Low‑level: `decrypt_data_blob(b64_blob)`
119119

120120
- `Device` helper (per‑device convenience)
121121
- `await device.refresh()` → hydrate cached state
122122
- `await device.get_location()` → parsed last location
123-
- `await device.fetch_pictures(n)` + `await device.download_photo(item)`
123+
- `await device.get_picture_blobs(n)` + `await device.decode_picture(blob)`
124+
- Commands: `await device.play_sound()`, `await device.take_front_picture()`,
125+
`await device.take_rear_picture()`, `await device.lock(message=None)`,
126+
`await device.wipe(pin="YourSecurePIN", confirm=True)`
127+
Note: wipe requires the FMD PIN (alphanumeric ASCII, no spaces) and must be enabled in the Android app's General settings.
128+
Future versions may enforce a 16+ character PIN length ([fmd-android#379](https://gitlab.com/fmd-foss/fmd-android/-/merge_requests/379)).
129+
130+
### Example: Lock device with a message
131+
132+
```python
133+
import asyncio
134+
from fmd_api import FmdClient, Device
135+
136+
async def main():
137+
client = await FmdClient.create("https://fmd.example.com", "alice", "secret")
138+
device = Device(client, "alice")
139+
# Optional message is sanitized (quotes/newlines removed, whitespace collapsed)
140+
await device.lock(message="Lost phone. Please call +1-555-555-1234")
141+
await client.close()
142+
143+
asyncio.run(main())
144+
```
124145

125146
## Testing
126147

@@ -157,6 +178,24 @@ pytest tests/unit/
157178
- AES‑GCM IV: 12 bytes; RSA packet size: 384 bytes
158179
- Password/key derivation with Argon2id
159180
- Robust HTTP JSON/text fallback and 401 re‑auth
181+
- Supports password-free resume via exported auth artifacts (hash + token + private key)
182+
183+
### Advanced: Password-Free Resume
184+
185+
You can onboard once with a raw password, optionally discard it immediately using `drop_password=True`, export authentication artifacts, and later resume without storing the raw secret:
186+
187+
```python
188+
client = await FmdClient.create(url, fmd_id, password, drop_password=True)
189+
artifacts = await client.export_auth_artifacts()
190+
191+
# Persist `artifacts` securely (contains hash, token, private key)
192+
193+
# Later / after restart
194+
client2 = await FmdClient.from_auth_artifacts(artifacts)
195+
locations = await client2.get_locations(1)
196+
```
197+
198+
On a 401, the client will transparently reauthenticate using the stored Argon2id `password_hash` if available. When `drop_password=True`, the raw password is never retained after initial onboarding.
160199

161200
## Troubleshooting
162201

@@ -170,5 +209,6 @@ This client targets the FMD ecosystem:
170209
- https://fmd-foss.org/
171210
- https://gitlab.com/fmd-foss
172211
- Public community instance: https://fmd.nulide.de/
212+
- Listed on the official FMD community page: https://fmd-foss.org/docs/fmd-server/community
173213

174214
MIT © 2025 Devin Slick

docs/AUTH_ARTIFACTS_DESIGN.md

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
# Authentication Artifacts Design (Password-Free Runtime)
2+
3+
This document proposes and specifies an artifact-based authentication flow for `fmd_api` that avoids storing the raw user password in long-running integrations (e.g., Home Assistant), while preserving the ability to decrypt data and reauthenticate when tokens expire.
4+
5+
## Goals
6+
7+
- Do not retain the user's raw password in memory/storage after onboarding.
8+
- Support seamless reauthentication (401 → new token) without prompting the user again.
9+
- Keep the local RSA private key as a long-lived client secret to avoid re-fetching/decrypting each session.
10+
- Provide clear import/export and resume flows for integrations.
11+
12+
## Terms
13+
14+
- `fmd_id`: The FMD identity (username-like identifier).
15+
- `password_hash`: The full Argon2id string expected by the server when calling `/api/v1/requestAccess` (includes salt and parameters).
16+
- `access_token`: The current session token used in API requests; expires after the requested duration.
17+
- `private_key`: The RSA private key used to decrypt location/picture blobs and sign commands. Long-lived, stored client-side.
18+
- `session_duration`: Seconds requested when creating tokens (client default: 3600).
19+
- `token_issued_at`: Local timestamp to optionally preempt expiry.
20+
21+
## Overview
22+
23+
Two operating modes:
24+
25+
1. Password mode (existing): Onboard with raw password; derive `password_hash`, request `access_token`, download and decrypt `private_key`. After success, the client may optionally discard the raw password.
26+
2. Artifact mode (new): Resume using stored artifacts (no raw password). On 401 Unauthorized, the client uses `password_hash` to request a fresh `access_token`. The `private_key` is already local.
27+
28+
## API Additions
29+
30+
### Constructor/Factory
31+
32+
- `@classmethod async def resume(cls, base_url: str, fmd_id: str, access_token: str, private_key_bytes: bytes | str, *, password_hash: str | None = None, session_duration: int = 3600, **opts) -> FmdClient`
33+
- Loads the provided private key (PEM/DER) and sets runtime fields.
34+
- If a 401 occurs and `password_hash` is provided, requests a new token with `/api/v1/requestAccess`.
35+
- If `password_hash` is not provided, 401 bubbles as an error (caller can re-onboard or supply a callback).
36+
37+
- `@classmethod async def from_auth_artifacts(cls, artifacts: dict, **opts) -> FmdClient`
38+
- Convenience around `resume()`. Expects keys: `base_url`, `fmd_id`, `access_token`, `private_key` (PEM or base64 DER), optional `password_hash`, `session_duration`.
39+
40+
- `async def export_auth_artifacts(self) -> dict`
41+
- Returns a serializable dict containing: `base_url`, `fmd_id`, `access_token`, `private_key` (PEM), `password_hash` (if available), `session_duration`, `token_issued_at`.
42+
43+
- `async def drop_password(self) -> None`
44+
- Immediately discards any stored raw password. Recommended once artifacts have been persisted by the caller.
45+
46+
- `@classmethod async def create(..., drop_password: bool = False)`
47+
- After successful onboarding, if `drop_password=True`, clears the in-memory `_password` attribute.
48+
49+
### Internal Helpers
50+
51+
- `async def _reauth_with_hash(self) -> None`
52+
- Calls `/api/v1/requestAccess` with stored `password_hash` and `session_duration`. Updates `access_token` on success.
53+
54+
- `_make_api_request` changes
55+
- On 401: if `_password` is present, behave as today (reauth using raw password).
56+
- Otherwise, if `password_hash` is present, call `_reauth_with_hash()` once and retry.
57+
- Else: raise.
58+
59+
## Data Handling
60+
61+
- `private_key` must be loadable from PEM or DER. `export_auth_artifacts()` will prefer PEM for portability.
62+
- `password_hash` is an online-equivalent secret for token requests. It is preferable to raw password, but should still be stored carefully (consider HA secrets storage if available).
63+
- No raw password is stored or exported by default.
64+
65+
## Failure Modes
66+
67+
- User changes password or server salt/params: stored `password_hash` becomes invalid. Reauth fails; caller should prompt the user once, produce a new `password_hash`, and update artifacts.
68+
- Server caps or rejects long `session_duration`: token would expire earlier than requested; client handles 401 via reauth.
69+
- Private key rotation: if the server issues a new private key (unlikely in normal flow), onboarding should refresh artifacts.
70+
71+
## Example Flows
72+
73+
### Onboarding (password mode)
74+
75+
```python
76+
client = await FmdClient.create(base_url, fmd_id, password, session_duration=3600)
77+
artifacts = await client.export_auth_artifacts()
78+
await client.drop_password() # optional hardening
79+
# Persist artifacts in HA storage
80+
```
81+
82+
### Resume (artifact mode)
83+
84+
```python
85+
client = await FmdClient.from_auth_artifacts(artifacts)
86+
# Use client normally; on 401 it will reauth using password_hash if present
87+
```
88+
89+
## Backward Compatibility
90+
91+
- Existing behavior is preserved.
92+
- New APIs are additive.
93+
- Deprecation of retaining raw `_password` by default is not proposed; instead provide `drop_password=True` knob and a `drop_password()` method.
94+
95+
## Security Considerations
96+
97+
- Storing `password_hash` is strictly better than storing the raw password, but still sensitive.
98+
- If the host supports keyrings or encrypted secret storage, prefer it for both `password_hash` and `private_key`.
99+
- Consider file permissions and in-memory zeroization when feasible.
100+
101+
## Open Questions
102+
103+
- Should `drop_password=True` become the default in a future major version?
104+
- Should we provide a pluggable secret provider interface for HA to implement platform-specific secure storage?

docs/MIGRATE_FROM_V1.md

Lines changed: 14 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -67,14 +67,16 @@ client = await FmdClient.create("https://fmd.example.com", "alice", "secret")
6767
|----|----------------|-------------|-------|
6868
| `await api.send_command('ring')` | `await client.send_command('ring')` | `await device.play_sound()` | Device method preferred |
6969
| `await api.send_command('lock')` | `await client.send_command('lock')` | `await device.lock()` | Device method preferred |
70-
| `await api.send_command('delete')` | `await client.send_command('delete')` | `await device.wipe(confirm=True)` | **REQUIRES confirm flag** |
70+
| `await api.send_command('delete')` | `await client.send_command('fmd delete <PIN>')` | `await device.wipe(pin="YourSecurePIN", confirm=True)` | **Requires confirm + PIN (alphanumeric ASCII, no spaces)**. Future: 16+ char ([fmd-android#379](https://gitlab.com/fmd-foss/fmd-android/-/merge_requests/379)) |
7171

7272
### Camera Commands
7373

7474
| V1 | V2 (FmdClient) | V2 (Device) | Notes |
7575
|----|----------------|-------------|-------|
76-
| `await api.take_picture('back')` | `await client.take_picture('back')` | `await device.take_rear_photo()` | Device method preferred |
77-
| `await api.take_picture('front')` | `await client.take_picture('front')` | `await device.take_front_photo()` | Device method preferred |
76+
| `await api.take_picture('back')` | `await client.take_picture('back')` | `await device.take_rear_picture()` | Device method preferred (old: take_rear_photo deprecated) |
77+
| `await api.take_picture('front')` | `await client.take_picture('front')` | `await device.take_front_picture()` | Device method preferred (old: take_front_photo deprecated) |
78+
> Note: `Device.lock(message=None)` now supports passing an optional message string. The server may ignore the
79+
> message if UI or server versions don't yet consume it, but the base lock command will still be executed.
7880
7981
### Bluetooth & Audio Settings
8082

@@ -90,14 +92,8 @@ client = await FmdClient.create("https://fmd.example.com", "alice", "secret")
9092

9193
| V1 | V2 (FmdClient) | V2 (Device) | Notes |
9294
|----|----------------|-------------|-------|
93-
| `await api.get_pictures(10)` | `await client.get_pictures(10)` | `await device.fetch_pictures(10)` | Both available |
94-
| N/A | N/A | `await device.download_photo(blob)` | New helper method |
95-
96-
### Device Stats
97-
98-
| V1 | V2 | Notes |
99-
|----|----|-------|
100-
| `await api.get_device_stats()` | `await client.get_device_stats()` | Same method |
95+
| `await api.get_pictures(10)` | `await client.get_pictures(10)` | `await device.get_picture_blobs(10)` | Both available (old: get_pictures/fetch_pictures deprecated) |
96+
| N/A | N/A | `await device.decode_picture(blob)` | Helper method (old: get_picture/download_photo deprecated) |
10197

10298
### Export Data
10399

@@ -150,15 +146,15 @@ async for location in device.get_history(limit=10):
150146
print(f"Location at {location.date}: {location.lat}, {location.lon}")
151147

152148
# Device commands
153-
await device.play_sound() # Ring device
154-
await device.take_rear_photo() # Rear camera
155-
await device.take_front_photo() # Front camera
156-
await device.lock(message="Lost device") # Lock with message
157-
await device.wipe(confirm=True) # Factory reset (DESTRUCTIVE)
149+
await device.play_sound() # Ring device
150+
await device.take_rear_picture() # Rear camera
151+
await device.take_front_picture() # Front camera
152+
await device.lock(message="Lost device") # Lock with message
153+
await device.wipe(pin="YourSecurePIN", confirm=True) # Factory reset (DESTRUCTIVE, alphanumeric ASCII PIN + enabled setting)
158154

159155
# Pictures
160-
pictures = await device.fetch_pictures(10)
161-
photo_result = await device.download_photo(pictures[0])
156+
pictures = await device.get_picture_blobs(10)
157+
photo_result = await device.decode_picture(pictures[0])
162158
```
163159

164160
---

docs/PROPOSAL.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ Date: 2025-11-01
1818

1919
Top-level components:
2020
- FmdClient: an async client that manages session, authentication tokens, request throttling, and device discovery.
21-
- Device: represents a single device and exposes async methods to interact with it (async refresh(), async play_sound(), async get_location(), async take_front_photo(), async take_rear_photo(), async lock_device(), async wipe_device(), etc).
21+
- Device: represents a single device and exposes async methods to interact with it (async refresh(), async play_sound(), async get_location(), async take_front_picture(), async take_rear_picture(), async lock_device(), async wipe_device(), etc).
2222
- Exceptions: typed exceptions for common error cases (AuthenticationError, DeviceNotFoundError, FmdApiError, RateLimitError).
2323
- Utilities: small helpers for caching, TTL-based per-device caches, retry/backoff, JSON parsing.
2424

@@ -52,8 +52,8 @@ async def example():
5252
await device.play_sound()
5353

5454
# Take front and rear photos
55-
front = await device.take_front_photo()
56-
rear = await device.take_rear_photo()
55+
front = await device.take_front_picture()
56+
rear = await device.take_rear_picture()
5757

5858
# Lock device with message
5959
await device.lock_device(message="Lost phone — call me")
@@ -95,9 +95,9 @@ Core classes and signatures (proposal):
9595
- async get_location(self, *, force: bool = False) -> Optional[Location]
9696
- Returns last known location (calls refresh if expired or force=True)
9797
- async play_sound(self, *, volume: Optional[int] = None) -> None
98-
- async take_front_photo(self) -> Optional[bytes]
98+
- async take_front_picture(self) -> Optional[bytes]
9999
- Requests a front-facing photo; returns raw bytes of image if available.
100-
- async take_rear_photo(self) -> Optional[bytes]
100+
- async take_rear_picture(self) -> Optional[bytes]
101101
- Requests a rear-facing photo; returns raw bytes of image if available.
102102
- async lock_device(self, *, passcode: Optional[str] = None, message: Optional[str] = None) -> None
103103
- async wipe_device(self, *, confirm: bool = False) -> None
@@ -130,7 +130,7 @@ Core classes and signatures (proposal):
130130
- All request payloads, parsing, and business rules will reuse the logic currently implemented in the repository (parsing of responses, mapping fields to device properties, handling of play sound semantics, etc.). No functional changes to endpoints or command behavior are intended.
131131
- Where current code uses synchronous HTTP (requests), the new client will use asyncio/aiohttp to make non-blocking calls. Helpers will be introduced to convert existing request/response handling functions to async easily.
132132
- Device.refresh() mirrors current "get devices" and "refresh device" flows: fetch the device status endpoint, parse location, battery, and update fields.
133-
- Photo functions: take_front_photo() and take_rear_photo() call the corresponding FMD endpoints (if supported). They should return either a PhotoResult object (preferred) or None if not supported by the device/account. Implementations should include sensible timeouts and handle partial results gracefully.
133+
- Photo functions: take_front_picture() and take_rear_picture() call the corresponding FMD endpoints (if supported). They should return either a PhotoResult object (preferred) or None if not supported by the device/account. Implementations should include sensible timeouts and handle partial results gracefully.
134134
- Caching: to avoid hitting rate limits and reduce backend load, a per-device TTL cache will be implemented (configurable; default 30 seconds). get_location() uses cached data unless force=True or stale.
135135
- Rate limiting: a shared RateLimiter object will enforce a maximum requests-per-second or requests-per-minute per client instance. Simple token-bucket or asyncio.Semaphore + sleep-backoff will be sufficient.
136136
- Retries: transient HTTP errors will be retried with an exponential backoff (configurable; default 3 retries).

fmd_api/_version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "2.0.3"
1+
__version__ = "2.0.4"

0 commit comments

Comments
 (0)