Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified .coverage
Binary file not shown.
46 changes: 43 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ from fmd_api import FmdClient

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

Expand Down Expand Up @@ -112,15 +112,36 @@ Tips:
- `set_bluetooth(enable: bool)` — True = on, False = off
- `set_do_not_disturb(enable: bool)` — True = on, False = off
- `set_ringer_mode("normal|vibrate|silent")`
- `get_device_stats()`

> **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)).

- Low‑level: `decrypt_data_blob(b64_blob)`

- `Device` helper (per‑device convenience)
- `await device.refresh()` → hydrate cached state
- `await device.get_location()` → parsed last location
- `await device.fetch_pictures(n)` + `await device.download_photo(item)`
- `await device.get_picture_blobs(n)` + `await device.decode_picture(blob)`
- Commands: `await device.play_sound()`, `await device.take_front_picture()`,
`await device.take_rear_picture()`, `await device.lock(message=None)`,
`await device.wipe(pin="YourSecurePIN", confirm=True)`
Note: wipe requires the FMD PIN (alphanumeric ASCII, no spaces) and must be enabled in the Android app's General settings.
Future versions may enforce a 16+ character PIN length ([fmd-android#379](https://gitlab.com/fmd-foss/fmd-android/-/merge_requests/379)).

### Example: Lock device with a message

```python
import asyncio
from fmd_api import FmdClient, Device

async def main():
client = await FmdClient.create("https://fmd.example.com", "alice", "secret")
device = Device(client, "alice")
# Optional message is sanitized (quotes/newlines removed, whitespace collapsed)
await device.lock(message="Lost phone. Please call +1-555-555-1234")
await client.close()

asyncio.run(main())
```

## Testing

Expand Down Expand Up @@ -157,6 +178,24 @@ pytest tests/unit/
- AES‑GCM IV: 12 bytes; RSA packet size: 384 bytes
- Password/key derivation with Argon2id
- Robust HTTP JSON/text fallback and 401 re‑auth
- Supports password-free resume via exported auth artifacts (hash + token + private key)

### Advanced: Password-Free Resume

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:

```python
client = await FmdClient.create(url, fmd_id, password, drop_password=True)
artifacts = await client.export_auth_artifacts()

# Persist `artifacts` securely (contains hash, token, private key)

# Later / after restart
client2 = await FmdClient.from_auth_artifacts(artifacts)
locations = await client2.get_locations(1)
```

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.

## Troubleshooting

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

MIT © 2025 Devin Slick
104 changes: 104 additions & 0 deletions docs/AUTH_ARTIFACTS_DESIGN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# Authentication Artifacts Design (Password-Free Runtime)

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.

## Goals

- Do not retain the user's raw password in memory/storage after onboarding.
- Support seamless reauthentication (401 → new token) without prompting the user again.
- Keep the local RSA private key as a long-lived client secret to avoid re-fetching/decrypting each session.
- Provide clear import/export and resume flows for integrations.

## Terms

- `fmd_id`: The FMD identity (username-like identifier).
- `password_hash`: The full Argon2id string expected by the server when calling `/api/v1/requestAccess` (includes salt and parameters).
- `access_token`: The current session token used in API requests; expires after the requested duration.
- `private_key`: The RSA private key used to decrypt location/picture blobs and sign commands. Long-lived, stored client-side.
- `session_duration`: Seconds requested when creating tokens (client default: 3600).
- `token_issued_at`: Local timestamp to optionally preempt expiry.

## Overview

Two operating modes:

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.
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.

## API Additions

### Constructor/Factory

- `@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`
- Loads the provided private key (PEM/DER) and sets runtime fields.
- If a 401 occurs and `password_hash` is provided, requests a new token with `/api/v1/requestAccess`.
- If `password_hash` is not provided, 401 bubbles as an error (caller can re-onboard or supply a callback).

- `@classmethod async def from_auth_artifacts(cls, artifacts: dict, **opts) -> FmdClient`
- Convenience around `resume()`. Expects keys: `base_url`, `fmd_id`, `access_token`, `private_key` (PEM or base64 DER), optional `password_hash`, `session_duration`.

- `async def export_auth_artifacts(self) -> dict`
- Returns a serializable dict containing: `base_url`, `fmd_id`, `access_token`, `private_key` (PEM), `password_hash` (if available), `session_duration`, `token_issued_at`.

- `async def drop_password(self) -> None`
- Immediately discards any stored raw password. Recommended once artifacts have been persisted by the caller.

- `@classmethod async def create(..., drop_password: bool = False)`
- After successful onboarding, if `drop_password=True`, clears the in-memory `_password` attribute.

### Internal Helpers

- `async def _reauth_with_hash(self) -> None`
- Calls `/api/v1/requestAccess` with stored `password_hash` and `session_duration`. Updates `access_token` on success.

- `_make_api_request` changes
- On 401: if `_password` is present, behave as today (reauth using raw password).
- Otherwise, if `password_hash` is present, call `_reauth_with_hash()` once and retry.
- Else: raise.

## Data Handling

- `private_key` must be loadable from PEM or DER. `export_auth_artifacts()` will prefer PEM for portability.
- `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).
- No raw password is stored or exported by default.

## Failure Modes

- 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.
- Server caps or rejects long `session_duration`: token would expire earlier than requested; client handles 401 via reauth.
- Private key rotation: if the server issues a new private key (unlikely in normal flow), onboarding should refresh artifacts.

## Example Flows

### Onboarding (password mode)

```python
client = await FmdClient.create(base_url, fmd_id, password, session_duration=3600)
artifacts = await client.export_auth_artifacts()
await client.drop_password() # optional hardening
# Persist artifacts in HA storage
```

### Resume (artifact mode)

```python
client = await FmdClient.from_auth_artifacts(artifacts)
# Use client normally; on 401 it will reauth using password_hash if present
```

## Backward Compatibility

- Existing behavior is preserved.
- New APIs are additive.
- Deprecation of retaining raw `_password` by default is not proposed; instead provide `drop_password=True` knob and a `drop_password()` method.

## Security Considerations

- Storing `password_hash` is strictly better than storing the raw password, but still sensitive.
- If the host supports keyrings or encrypted secret storage, prefer it for both `password_hash` and `private_key`.
- Consider file permissions and in-memory zeroization when feasible.

## Open Questions

- Should `drop_password=True` become the default in a future major version?
- Should we provide a pluggable secret provider interface for HA to implement platform-specific secure storage?
32 changes: 14 additions & 18 deletions docs/MIGRATE_FROM_V1.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,14 +67,16 @@ client = await FmdClient.create("https://fmd.example.com", "alice", "secret")
|----|----------------|-------------|-------|
| `await api.send_command('ring')` | `await client.send_command('ring')` | `await device.play_sound()` | Device method preferred |
| `await api.send_command('lock')` | `await client.send_command('lock')` | `await device.lock()` | Device method preferred |
| `await api.send_command('delete')` | `await client.send_command('delete')` | `await device.wipe(confirm=True)` | **REQUIRES confirm flag** |
| `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)) |

### Camera Commands

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

### Bluetooth & Audio Settings

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

| V1 | V2 (FmdClient) | V2 (Device) | Notes |
|----|----------------|-------------|-------|
| `await api.get_pictures(10)` | `await client.get_pictures(10)` | `await device.fetch_pictures(10)` | Both available |
| N/A | N/A | `await device.download_photo(blob)` | New helper method |

### Device Stats

| V1 | V2 | Notes |
|----|----|-------|
| `await api.get_device_stats()` | `await client.get_device_stats()` | Same method |
| `await api.get_pictures(10)` | `await client.get_pictures(10)` | `await device.get_picture_blobs(10)` | Both available (old: get_pictures/fetch_pictures deprecated) |
| N/A | N/A | `await device.decode_picture(blob)` | Helper method (old: get_picture/download_photo deprecated) |

### Export Data

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

# Device commands
await device.play_sound() # Ring device
await device.take_rear_photo() # Rear camera
await device.take_front_photo() # Front camera
await device.lock(message="Lost device") # Lock with message
await device.wipe(confirm=True) # Factory reset (DESTRUCTIVE)
await device.play_sound() # Ring device
await device.take_rear_picture() # Rear camera
await device.take_front_picture() # Front camera
await device.lock(message="Lost device") # Lock with message
await device.wipe(pin="YourSecurePIN", confirm=True) # Factory reset (DESTRUCTIVE, alphanumeric ASCII PIN + enabled setting)

# Pictures
pictures = await device.fetch_pictures(10)
photo_result = await device.download_photo(pictures[0])
pictures = await device.get_picture_blobs(10)
photo_result = await device.decode_picture(pictures[0])
```

---
Expand Down
12 changes: 6 additions & 6 deletions docs/PROPOSAL.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ Date: 2025-11-01

Top-level components:
- FmdClient: an async client that manages session, authentication tokens, request throttling, and device discovery.
- 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).
- 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).
- Exceptions: typed exceptions for common error cases (AuthenticationError, DeviceNotFoundError, FmdApiError, RateLimitError).
- Utilities: small helpers for caching, TTL-based per-device caches, retry/backoff, JSON parsing.

Expand Down Expand Up @@ -52,8 +52,8 @@ async def example():
await device.play_sound()

# Take front and rear photos
front = await device.take_front_photo()
rear = await device.take_rear_photo()
front = await device.take_front_picture()
rear = await device.take_rear_picture()

# Lock device with message
await device.lock_device(message="Lost phone — call me")
Expand Down Expand Up @@ -95,9 +95,9 @@ Core classes and signatures (proposal):
- async get_location(self, *, force: bool = False) -> Optional[Location]
- Returns last known location (calls refresh if expired or force=True)
- async play_sound(self, *, volume: Optional[int] = None) -> None
- async take_front_photo(self) -> Optional[bytes]
- async take_front_picture(self) -> Optional[bytes]
- Requests a front-facing photo; returns raw bytes of image if available.
- async take_rear_photo(self) -> Optional[bytes]
- async take_rear_picture(self) -> Optional[bytes]
- Requests a rear-facing photo; returns raw bytes of image if available.
- async lock_device(self, *, passcode: Optional[str] = None, message: Optional[str] = None) -> None
- async wipe_device(self, *, confirm: bool = False) -> None
Expand Down Expand Up @@ -130,7 +130,7 @@ Core classes and signatures (proposal):
- 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.
- 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.
- Device.refresh() mirrors current "get devices" and "refresh device" flows: fetch the device status endpoint, parse location, battery, and update fields.
- 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.
- 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.
- 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.
- 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.
- Retries: transient HTTP errors will be retried with an exponential backoff (configurable; default 3 retries).
Expand Down
2 changes: 1 addition & 1 deletion fmd_api/_version.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "2.0.3"
__version__ = "2.0.4"
Loading
Loading