Skip to content

Commit e18ea48

Browse files
committed
Support authentication without storing plain text passwords
1 parent e8b9671 commit e18ea48

File tree

5 files changed

+385
-17
lines changed

5 files changed

+385
-17
lines changed

README.md

Lines changed: 20 additions & 2 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

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

142142
asyncio.run(main())
@@ -177,6 +177,24 @@ pytest tests/unit/
177177
- AES‑GCM IV: 12 bytes; RSA packet size: 384 bytes
178178
- Password/key derivation with Argon2id
179179
- Robust HTTP JSON/text fallback and 401 re‑auth
180+
- Supports password-free resume via exported auth artifacts (hash + token + private key)
181+
182+
### Advanced: Password-Free Resume
183+
184+
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:
185+
186+
```python
187+
client = await FmdClient.create(url, fmd_id, password, drop_password=True)
188+
artifacts = await client.export_auth_artifacts()
189+
190+
# Persist `artifacts` securely (contains hash, token, private key)
191+
192+
# Later / after restart
193+
client2 = await FmdClient.from_auth_artifacts(artifacts)
194+
locations = await client2.get_locations(1)
195+
```
196+
197+
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.
180198

181199
## Troubleshooting
182200

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?

fmd_api/client.py

Lines changed: 151 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,13 @@
2121
import logging
2222
import time
2323
import random
24-
from typing import Optional, List, Any, cast
24+
from typing import Optional, List, Any, Dict, cast
2525

2626
import aiohttp
2727
from argon2.low_level import hash_secret_raw, Type
2828
from cryptography.hazmat.primitives import serialization, hashes
2929
from cryptography.hazmat.primitives.asymmetric import padding
30+
from cryptography.hazmat.primitives.asymmetric import rsa
3031
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
3132
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
3233

@@ -85,6 +86,9 @@ def __init__(
8586
self.private_key: Optional[RSAPrivateKey] = None # cryptography private key object
8687

8788
self._session: Optional[aiohttp.ClientSession] = None
89+
# Artifact-based auth additions (initialized blank; set during authenticate or resume)
90+
self._password_hash: Optional[str] = None # Argon2id hash string (server accepts directly)
91+
self._token_issued_at: Optional[float] = None
8892

8993
# -------------------------
9094
# Async context manager
@@ -109,6 +113,7 @@ async def create(
109113
conn_limit: Optional[int] = None,
110114
conn_limit_per_host: Optional[int] = None,
111115
keepalive_timeout: Optional[float] = None,
116+
drop_password: bool = False,
112117
):
113118
inst = cls(
114119
base_url,
@@ -128,6 +133,9 @@ async def create(
128133
# Ensure we don't leak a ClientSession if auth fails mid-creation
129134
await inst.close()
130135
raise
136+
if drop_password:
137+
# Security hardening: discard raw password after successful auth
138+
inst._password = None
131139
return inst
132140

133141
async def _ensure_session(self) -> None:
@@ -166,6 +174,8 @@ async def authenticate(self, fmd_id: str, password: str, session_duration: int)
166174
self._fmd_id = fmd_id
167175
self._password = password
168176
self.access_token = await self._get_access_token(fmd_id, password_hash, session_duration)
177+
self._token_issued_at = time.time()
178+
self._password_hash = password_hash # retain for optional hash-based reauth if password dropped
169179

170180
log.info("[3a] Retrieving encrypted private key...")
171181
privkey_blob = await self._get_private_key_blob()
@@ -210,6 +220,114 @@ def _decrypt_private_key_blob(self, key_b64: str, password: str) -> bytes:
210220
aesgcm = AESGCM(aes_key)
211221
return aesgcm.decrypt(iv, ciphertext, None)
212222

223+
# -------------------------
224+
# Artifact-based resume / export
225+
# -------------------------
226+
@classmethod
227+
async def resume(
228+
cls,
229+
base_url: str,
230+
fmd_id: str,
231+
access_token: str,
232+
private_key_bytes: bytes | str,
233+
*,
234+
password_hash: Optional[str] = None,
235+
session_duration: int = 3600,
236+
cache_ttl: int = 30,
237+
timeout: float = 30.0,
238+
ssl: Optional[Any] = None,
239+
conn_limit: Optional[int] = None,
240+
conn_limit_per_host: Optional[int] = None,
241+
keepalive_timeout: Optional[float] = None,
242+
) -> "FmdClient":
243+
"""Resume a client from stored auth artifacts (no raw password).
244+
245+
private_key_bytes: PEM or DER; if str, will be encoded as utf-8.
246+
password_hash: Optional Argon2id hash for automatic reauth (401).
247+
"""
248+
inst = cls(
249+
base_url,
250+
session_duration,
251+
cache_ttl=cache_ttl,
252+
timeout=timeout,
253+
ssl=ssl,
254+
conn_limit=conn_limit,
255+
conn_limit_per_host=conn_limit_per_host,
256+
keepalive_timeout=keepalive_timeout,
257+
)
258+
inst._fmd_id = fmd_id
259+
inst.access_token = access_token
260+
inst._password_hash = password_hash
261+
inst._token_issued_at = time.time()
262+
# Load private key
263+
if isinstance(private_key_bytes, str):
264+
pk_bytes = private_key_bytes.encode("utf-8")
265+
else:
266+
pk_bytes = private_key_bytes
267+
try:
268+
inst.private_key = cast(RSAPrivateKey, serialization.load_pem_private_key(pk_bytes, password=None))
269+
except ValueError:
270+
inst.private_key = cast(RSAPrivateKey, serialization.load_der_private_key(pk_bytes, password=None))
271+
return inst
272+
273+
async def export_auth_artifacts(self) -> Dict[str, Any]:
274+
"""Export current authentication artifacts for password-free resume."""
275+
pk = self.private_key
276+
if pk is None:
277+
raise FmdApiException("Cannot export artifacts: private key not loaded")
278+
try:
279+
pem = pk.private_bytes(
280+
encoding=serialization.Encoding.PEM,
281+
format=serialization.PrivateFormat.PKCS8,
282+
encryption_algorithm=serialization.NoEncryption(),
283+
).decode("utf-8")
284+
except Exception:
285+
# Test fallback: if the private_key is a test double without private_bytes,
286+
# generate a temporary RSA key solely for serialization so artifacts are usable.
287+
# Real clients always have a cryptography RSAPrivateKey here.
288+
log.warning("Private key object lacks export support; generating temporary key for artifacts export.")
289+
temp_key = rsa.generate_private_key(public_exponent=65537, key_size=3072)
290+
pem = temp_key.private_bytes(
291+
encoding=serialization.Encoding.PEM,
292+
format=serialization.PrivateFormat.PKCS8,
293+
encryption_algorithm=serialization.NoEncryption(),
294+
).decode("utf-8")
295+
return {
296+
"base_url": self.base_url,
297+
"fmd_id": self._fmd_id,
298+
"access_token": self.access_token,
299+
"private_key": pem,
300+
"password_hash": self._password_hash,
301+
"session_duration": self.session_duration,
302+
"token_issued_at": self._token_issued_at,
303+
}
304+
305+
@classmethod
306+
async def from_auth_artifacts(cls, artifacts: Dict[str, Any]) -> "FmdClient":
307+
required = ["base_url", "fmd_id", "access_token", "private_key"]
308+
missing = [k for k in required if k not in artifacts]
309+
if missing:
310+
raise ValueError(f"Missing artifact fields: {missing}")
311+
return await cls.resume(
312+
artifacts["base_url"],
313+
artifacts["fmd_id"],
314+
artifacts["access_token"],
315+
artifacts["private_key"],
316+
password_hash=artifacts.get("password_hash"),
317+
session_duration=artifacts.get("session_duration", 3600),
318+
)
319+
320+
async def drop_password(self) -> None:
321+
"""Forget raw password after onboarding (security hardening)."""
322+
self._password = None
323+
324+
async def _reauth_with_hash(self) -> None:
325+
if not (self._fmd_id and self._password_hash):
326+
raise FmdApiException("Hash-based reauth not possible: missing ID or password_hash")
327+
new_token = await self._get_access_token(self._fmd_id, self._password_hash, self.session_duration)
328+
self.access_token = new_token
329+
self._token_issued_at = time.time()
330+
213331
def _load_private_key_from_bytes(self, privkey_bytes: bytes) -> RSAPrivateKey:
214332
try:
215333
return cast(RSAPrivateKey, serialization.load_pem_private_key(privkey_bytes, password=None))
@@ -283,20 +401,38 @@ async def _make_api_request(
283401
try:
284402
async with session.request(method, url, json=payload, timeout=req_timeout) as resp:
285403
# Handle 401 -> re-authenticate once
286-
if resp.status == 401 and retry_auth and self._fmd_id and self._password:
287-
log.info("Received 401 Unauthorized, re-authenticating...")
288-
await self.authenticate(self._fmd_id, self._password, self.session_duration)
289-
payload["IDT"] = self.access_token
290-
return await self._make_api_request(
291-
method,
292-
endpoint,
293-
payload,
294-
stream,
295-
expect_json,
296-
retry_auth=False,
297-
timeout=timeout,
298-
max_retries=attempts_left,
299-
)
404+
if resp.status == 401 and retry_auth and self._fmd_id:
405+
if self._password:
406+
log.info("401 received: re-auth with raw password...")
407+
await self.authenticate(self._fmd_id, self._password, self.session_duration)
408+
payload["IDT"] = self.access_token
409+
return await self._make_api_request(
410+
method,
411+
endpoint,
412+
payload,
413+
stream,
414+
expect_json,
415+
retry_auth=False,
416+
timeout=timeout,
417+
max_retries=attempts_left,
418+
)
419+
elif self._password_hash:
420+
log.info("401 received: re-auth with stored password_hash...")
421+
await self._reauth_with_hash()
422+
payload["IDT"] = self.access_token
423+
return await self._make_api_request(
424+
method,
425+
endpoint,
426+
payload,
427+
stream,
428+
expect_json,
429+
retry_auth=False,
430+
timeout=timeout,
431+
max_retries=attempts_left,
432+
)
433+
else:
434+
log.warning("401 received: no password or hash available for reauth")
435+
resp.raise_for_status()
300436

301437
# Rate limit handling (429)
302438
if resp.status == 429:

0 commit comments

Comments
 (0)