dosync 0.4.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dosync/__init__.py +17 -0
- dosync/adapters/__init__.py +258 -0
- dosync/adapters/ble.py +199 -0
- dosync/adapters/homeassistant.py +655 -0
- dosync/adapters/matter.py +320 -0
- dosync/adapters/mavlink.py +1205 -0
- dosync/adapters/mqtt.py +409 -0
- dosync/adapters/notifications.py +153 -0
- dosync/adapters/shelly.py +348 -0
- dosync/adapters/wiz.py +357 -0
- dosync/audit_backup.py +184 -0
- dosync/auth.py +194 -0
- dosync/auth_fastapi.py +87 -0
- dosync/cert_signing.py +117 -0
- dosync/certify.py +1091 -0
- dosync/cli.py +61 -0
- dosync/composite_operations.py +306 -0
- dosync/db.py +826 -0
- dosync/device_arbiter.py +270 -0
- dosync/discovery.py +208 -0
- dosync/ed25519_pure.py +204 -0
- dosync/executor.py +97 -0
- dosync/geo.py +63 -0
- dosync/hub.py +2923 -0
- dosync/hub_monitor.py +144 -0
- dosync/manage.py +913 -0
- dosync/mcp_server.py +746 -0
- dosync/metrics.py +244 -0
- dosync/models.py +562 -0
- dosync/operation_guards.py +228 -0
- dosync/operation_supervisor.py +216 -0
- dosync/operations.py +331 -0
- dosync/policies.py +1210 -0
- dosync/policy_config.py +252 -0
- dosync/py.typed +0 -0
- dosync/reconciler.py +177 -0
- dosync/route_composer.py +189 -0
- dosync/security.py +680 -0
- dosync/server.py +1911 -0
- dosync/validation.py +98 -0
- dosync-0.4.1.dist-info/METADATA +372 -0
- dosync-0.4.1.dist-info/RECORD +46 -0
- dosync-0.4.1.dist-info/WHEEL +5 -0
- dosync-0.4.1.dist-info/entry_points.txt +4 -0
- dosync-0.4.1.dist-info/licenses/LICENSE +201 -0
- dosync-0.4.1.dist-info/top_level.txt +1 -0
dosync/auth.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""
|
|
2
|
+
DoSync — Authentication
|
|
3
|
+
=======================
|
|
4
|
+
API key simple para proteger el hub.
|
|
5
|
+
|
|
6
|
+
- Las keys se generan con secrets.token_urlsafe(32)
|
|
7
|
+
- Solo almacenamos el SHA-256 del token, nunca el token en texto plano
|
|
8
|
+
- El primer arranque genera una key automáticamente y la muestra en consola
|
|
9
|
+
- Las keys adicionales se crean via API o CLI
|
|
10
|
+
|
|
11
|
+
Flujo:
|
|
12
|
+
1. Hub arranca → si no hay keys, genera una y la muestra UNA SOLA VEZ
|
|
13
|
+
2. Cliente incluye: Authorization: Bearer <token>
|
|
14
|
+
3. Hub hashea el token y busca en la DB
|
|
15
|
+
4. Si no coincide → 401 Unauthorized
|
|
16
|
+
|
|
17
|
+
Uso en FastAPI (las dependencias viven en dosync.auth_fastapi):
|
|
18
|
+
from dosync.auth_fastapi import require_auth
|
|
19
|
+
|
|
20
|
+
@app.get("/v1/devices")
|
|
21
|
+
async def list_devices(auth=Depends(require_auth)):
|
|
22
|
+
...
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
import hashlib
|
|
27
|
+
import logging
|
|
28
|
+
import os
|
|
29
|
+
import secrets
|
|
30
|
+
from typing import Optional
|
|
31
|
+
|
|
32
|
+
log = logging.getLogger("dosync.auth")
|
|
33
|
+
|
|
34
|
+
# NOTE: This module is the framework-agnostic core of DoSync auth.
|
|
35
|
+
# It deliberately does NOT import FastAPI (or any web framework). The
|
|
36
|
+
# FastAPI request dependencies (require_auth / optional_auth) live in
|
|
37
|
+
# dosync/auth_fastapi.py, which is only loaded by the server. Keeping the
|
|
38
|
+
# core free of framework imports means hash_token, AuthManager, and
|
|
39
|
+
# DeviceAuthManager can be imported and tested in isolation — and a prior
|
|
40
|
+
# bug (require_auth failing to import when FastAPI was absent) cannot recur.
|
|
41
|
+
# Do not add `from fastapi import ...` here.
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ── Hashing ───────────────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
def hash_token(token: str) -> str:
|
|
47
|
+
"""SHA-256 del token. Nunca almacenamos el token en texto plano."""
|
|
48
|
+
return hashlib.sha256(token.encode()).hexdigest()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ── Auth manager ──────────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
class AuthManager:
|
|
54
|
+
"""
|
|
55
|
+
Gestiona API keys para el hub DoSync.
|
|
56
|
+
Se integra con DoSyncDB para persistencia.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
def __init__(self, db, enabled: bool = True):
|
|
60
|
+
"""
|
|
61
|
+
Args:
|
|
62
|
+
db: instancia de DoSyncDB
|
|
63
|
+
enabled: si False, todas las requests pasan sin verificación.
|
|
64
|
+
Útil para desarrollo local.
|
|
65
|
+
"""
|
|
66
|
+
self.db = db
|
|
67
|
+
self.enabled = enabled
|
|
68
|
+
|
|
69
|
+
def generate_key(self, label: str = "default") -> str:
|
|
70
|
+
"""
|
|
71
|
+
Genera una nueva API key, la hashea y la guarda en la DB.
|
|
72
|
+
Retorna el token en texto plano — solo se muestra UNA VEZ.
|
|
73
|
+
"""
|
|
74
|
+
token = secrets.token_urlsafe(32)
|
|
75
|
+
key_hash = hash_token(token)
|
|
76
|
+
self.db.save_api_key(key_hash, label)
|
|
77
|
+
log.info("New API key generated: label='%s'", label)
|
|
78
|
+
return token
|
|
79
|
+
|
|
80
|
+
def verify(self, token: str) -> bool:
|
|
81
|
+
"""Verifica un token. Retorna True si es válido."""
|
|
82
|
+
if not self.enabled:
|
|
83
|
+
return True
|
|
84
|
+
key_hash = hash_token(token)
|
|
85
|
+
return self.db.verify_api_key(key_hash)
|
|
86
|
+
|
|
87
|
+
def ensure_default_key(self) -> Optional[str]:
|
|
88
|
+
"""
|
|
89
|
+
Si no hay ninguna key, genera una y la retorna para mostrarla.
|
|
90
|
+
Si ya hay keys, retorna None (no genera otra).
|
|
91
|
+
Llamar al iniciar el hub.
|
|
92
|
+
|
|
93
|
+
Si DOSYNC_DEMO_TOKEN está definido en el entorno, usa ese valor
|
|
94
|
+
como token inicial en lugar de generar uno aleatorio. Útil para
|
|
95
|
+
despliegues Docker donde el token debe ser conocido de antemano.
|
|
96
|
+
"""
|
|
97
|
+
if not self.enabled:
|
|
98
|
+
return None
|
|
99
|
+
if not self.db.has_any_key():
|
|
100
|
+
demo_token = os.environ.get("DOSYNC_DEMO_TOKEN")
|
|
101
|
+
if demo_token:
|
|
102
|
+
key_hash = hash_token(demo_token)
|
|
103
|
+
self.db.save_api_key(key_hash, "demo")
|
|
104
|
+
log.info("Demo token registered from DOSYNC_DEMO_TOKEN env var")
|
|
105
|
+
return demo_token
|
|
106
|
+
token = self.generate_key("default")
|
|
107
|
+
return token
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
def list_keys(self) -> list[dict]:
|
|
111
|
+
return self.db.list_api_keys()
|
|
112
|
+
|
|
113
|
+
def delete_key(self, key_hash: str) -> bool:
|
|
114
|
+
return self.db.delete_api_key(key_hash)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# ── FastAPI dependency ────────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
# Referencia global al auth manager — se setea al iniciar el servidor
|
|
120
|
+
_auth_manager: Optional[AuthManager] = None
|
|
121
|
+
|
|
122
|
+
def set_auth_manager(manager: AuthManager) -> None:
|
|
123
|
+
global _auth_manager
|
|
124
|
+
_auth_manager = manager
|
|
125
|
+
|
|
126
|
+
def get_auth_manager() -> AuthManager:
|
|
127
|
+
return _auth_manager
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# ── Device token manager ──────────────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
class DeviceAuthManager:
|
|
133
|
+
"""
|
|
134
|
+
Gestiona tokens de autenticación por dispositivo.
|
|
135
|
+
|
|
136
|
+
Flujo:
|
|
137
|
+
1. Operador pre-registra un dispositivo → obtiene device_token
|
|
138
|
+
2. Dispositivo incluye device_token al registrar su manifest
|
|
139
|
+
3. Hub valida el token → solo permite el device_id autorizado
|
|
140
|
+
|
|
141
|
+
Backward compatible: si device_token no se incluye en el manifest,
|
|
142
|
+
el registro procede sin validación (modo legacy).
|
|
143
|
+
Configurable via DOSYNC_DEVICE_AUTH=strict para requerir token siempre.
|
|
144
|
+
"""
|
|
145
|
+
|
|
146
|
+
def __init__(self, db):
|
|
147
|
+
self.db = db
|
|
148
|
+
self.strict = os.environ.get("DOSYNC_DEVICE_AUTH", "permissive") == "strict"
|
|
149
|
+
|
|
150
|
+
def provision(self, device_id: str, label: str = "") -> str:
|
|
151
|
+
"""
|
|
152
|
+
Pre-registra un device_id y genera su token de acceso.
|
|
153
|
+
Retorna el token en texto plano — mostrar UNA SOLA VEZ al operador.
|
|
154
|
+
"""
|
|
155
|
+
token = secrets.token_urlsafe(32)
|
|
156
|
+
token_hash = hash_token(token)
|
|
157
|
+
self.db.save_device_token(device_id, token_hash, label or device_id)
|
|
158
|
+
log.info("Device token provisioned: device_id='%s'", device_id)
|
|
159
|
+
return token
|
|
160
|
+
|
|
161
|
+
def verify(self, device_id: str, token: str) -> tuple[bool, str]:
|
|
162
|
+
"""
|
|
163
|
+
Verifica que el token corresponde al device_id declarado.
|
|
164
|
+
Retorna (valid: bool, reason: str).
|
|
165
|
+
"""
|
|
166
|
+
token_hash = hash_token(token)
|
|
167
|
+
if self.db.verify_device_token(device_id, token_hash):
|
|
168
|
+
return True, "ok"
|
|
169
|
+
if self.db.device_is_provisioned(device_id):
|
|
170
|
+
return False, f"Invalid token for device_id '{device_id}'"
|
|
171
|
+
if self.strict:
|
|
172
|
+
return False, f"Device '{device_id}' not provisioned — strict mode enabled"
|
|
173
|
+
return True, "unprovisioned — permissive mode"
|
|
174
|
+
|
|
175
|
+
def is_provisioned(self, device_id: str) -> bool:
|
|
176
|
+
return self.db.device_is_provisioned(device_id)
|
|
177
|
+
|
|
178
|
+
def revoke(self, device_id: str) -> bool:
|
|
179
|
+
return self.db.delete_device_token(device_id)
|
|
180
|
+
|
|
181
|
+
def list_provisioned(self) -> list[dict]:
|
|
182
|
+
return self.db.list_device_tokens()
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# Referencia global al device auth manager
|
|
186
|
+
_device_auth_manager: Optional[AuthManager] = None
|
|
187
|
+
|
|
188
|
+
def set_device_auth_manager(manager: "DeviceAuthManager") -> None:
|
|
189
|
+
global _device_auth_manager
|
|
190
|
+
_device_auth_manager = manager
|
|
191
|
+
|
|
192
|
+
def get_device_auth_manager() -> Optional["DeviceAuthManager"]:
|
|
193
|
+
return _device_auth_manager
|
|
194
|
+
|
dosync/auth_fastapi.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""
|
|
2
|
+
DoSync — FastAPI Auth Dependencies
|
|
3
|
+
==================================
|
|
4
|
+
FastAPI request dependencies that enforce the hub's API-key authentication.
|
|
5
|
+
|
|
6
|
+
This module is the web-framework glue layer. It is the ONLY auth module that
|
|
7
|
+
imports FastAPI, and it imports it unconditionally — because this module is
|
|
8
|
+
only ever loaded by the server, which requires FastAPI by definition.
|
|
9
|
+
|
|
10
|
+
The framework-agnostic core (hash_token, AuthManager, DeviceAuthManager) lives
|
|
11
|
+
in dosync/auth.py and must never import a web framework. This separation keeps
|
|
12
|
+
the core testable in isolation and prevents the class of bug where a
|
|
13
|
+
module-level FastAPI symbol fails to resolve when FastAPI is absent.
|
|
14
|
+
|
|
15
|
+
Usage:
|
|
16
|
+
from dosync.auth_fastapi import require_auth
|
|
17
|
+
|
|
18
|
+
@app.get("/v1/devices")
|
|
19
|
+
async def list_devices(auth=Depends(require_auth)):
|
|
20
|
+
...
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
from typing import Optional
|
|
25
|
+
|
|
26
|
+
from fastapi import HTTPException, Security
|
|
27
|
+
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|
28
|
+
|
|
29
|
+
from dosync.auth import get_auth_manager
|
|
30
|
+
|
|
31
|
+
# Bearer scheme — auto_error=False so we can return our own 401 payloads.
|
|
32
|
+
_bearer = HTTPBearer(auto_error=False)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
async def require_auth(
|
|
36
|
+
credentials: Optional[HTTPAuthorizationCredentials] = Security(_bearer),
|
|
37
|
+
) -> str:
|
|
38
|
+
"""
|
|
39
|
+
FastAPI dependency that verifies the API key.
|
|
40
|
+
|
|
41
|
+
Usage:
|
|
42
|
+
@app.get("/v1/devices")
|
|
43
|
+
async def list_devices(auth=Depends(require_auth)):
|
|
44
|
+
...
|
|
45
|
+
|
|
46
|
+
If auth is disabled (development), it lets everything through.
|
|
47
|
+
"""
|
|
48
|
+
manager = get_auth_manager()
|
|
49
|
+
|
|
50
|
+
# Auth disabled
|
|
51
|
+
if manager is None or not manager.enabled:
|
|
52
|
+
return "dev"
|
|
53
|
+
|
|
54
|
+
# No token
|
|
55
|
+
if not credentials:
|
|
56
|
+
raise HTTPException(
|
|
57
|
+
status_code=401,
|
|
58
|
+
detail="Missing Authorization header. Use: Authorization: Bearer <token>",
|
|
59
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# Invalid token
|
|
63
|
+
if not manager.verify(credentials.credentials):
|
|
64
|
+
raise HTTPException(
|
|
65
|
+
status_code=401,
|
|
66
|
+
detail="Invalid or expired API key.",
|
|
67
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
return credentials.credentials
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
async def optional_auth(
|
|
74
|
+
credentials: Optional[HTTPAuthorizationCredentials] = Security(_bearer),
|
|
75
|
+
) -> Optional[str]:
|
|
76
|
+
"""
|
|
77
|
+
Like require_auth but does not fail when no token is present.
|
|
78
|
+
Useful for endpoints that are public but show more info when authenticated.
|
|
79
|
+
"""
|
|
80
|
+
manager = get_auth_manager()
|
|
81
|
+
if manager is None or not manager.enabled:
|
|
82
|
+
return "dev"
|
|
83
|
+
if not credentials:
|
|
84
|
+
return None
|
|
85
|
+
if manager.verify(credentials.credentials):
|
|
86
|
+
return credentials.credentials
|
|
87
|
+
return None
|
dosync/cert_signing.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""
|
|
2
|
+
DoSync — Certification report signing & verification.
|
|
3
|
+
=====================================================
|
|
4
|
+
|
|
5
|
+
Signs a certification report with Ed25519 so a third party can confirm the report
|
|
6
|
+
was not ALTERED after it was issued. Uses the pure-Python Ed25519 (zero deps).
|
|
7
|
+
|
|
8
|
+
WHAT THE SIGNATURE PROVES — AND DOESN'T
|
|
9
|
+
---------------------------------------
|
|
10
|
+
A valid signature proves the report content is byte-for-byte what the holder of
|
|
11
|
+
the signing key produced: no third party edited "failed" into "passed" or changed
|
|
12
|
+
the host. It does NOT prove the tests reflect production, nor that the operator
|
|
13
|
+
didn't tune the hub for the test run — the operator holds the key and controls the
|
|
14
|
+
environment. The signature defends against tampering by others, not against
|
|
15
|
+
self-declaration. The report's own `attestation` block states this in plain words.
|
|
16
|
+
|
|
17
|
+
KEY MANAGEMENT
|
|
18
|
+
--------------
|
|
19
|
+
The signing key is a 32-byte seed stored at the path given by DOSYNC_CERT_KEY
|
|
20
|
+
(default: ~/.dosync/cert_signing_key). If absent, `sign_report` can generate one.
|
|
21
|
+
The PUBLIC key is embedded in every signed report so a verifier needs nothing but
|
|
22
|
+
the report itself (and this code, or any Ed25519 library).
|
|
23
|
+
|
|
24
|
+
The key identifies the ISSUER, not an authority. Anyone can generate a key and
|
|
25
|
+
sign their own reports — which is exactly right for self-certification. Trust in a
|
|
26
|
+
key is a social fact (you know whose key it is), established outside this protocol.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
import json
|
|
31
|
+
import os
|
|
32
|
+
import hashlib
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
|
|
35
|
+
from .ed25519_pure import publickey, signature, checkvalid
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _key_path() -> Path:
|
|
39
|
+
return Path(os.environ.get("DOSYNC_CERT_KEY", str(Path.home() / ".dosync" / "cert_signing_key")))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def load_or_create_key() -> bytes:
|
|
43
|
+
"""Return the 32-byte signing seed, creating one if it does not exist."""
|
|
44
|
+
path = _key_path()
|
|
45
|
+
if path.exists():
|
|
46
|
+
seed = path.read_bytes()
|
|
47
|
+
if len(seed) != 32:
|
|
48
|
+
raise ValueError(f"signing key at {path} is not 32 bytes")
|
|
49
|
+
return seed
|
|
50
|
+
seed = os.urandom(32)
|
|
51
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
path.write_bytes(seed)
|
|
53
|
+
try:
|
|
54
|
+
os.chmod(path, 0o600)
|
|
55
|
+
except OSError:
|
|
56
|
+
pass
|
|
57
|
+
return seed
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _canonical(report_dict: dict) -> bytes:
|
|
61
|
+
"""Canonical byte representation of the report for signing/verifying.
|
|
62
|
+
|
|
63
|
+
The `signature` block itself is excluded — you cannot sign a document that
|
|
64
|
+
contains its own signature. Everything else is serialized deterministically
|
|
65
|
+
(sorted keys, no whitespace) so signer and verifier hash identical bytes.
|
|
66
|
+
"""
|
|
67
|
+
payload = {k: v for k, v in report_dict.items() if k != "signature"}
|
|
68
|
+
return json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def sign_report(report_dict: dict, seed: bytes | None = None) -> dict:
|
|
72
|
+
"""Return a copy of the report with a `signature` block attached.
|
|
73
|
+
|
|
74
|
+
The block carries the algorithm, the public key (hex), and the signature
|
|
75
|
+
(hex), so the report is self-contained for verification.
|
|
76
|
+
"""
|
|
77
|
+
if seed is None:
|
|
78
|
+
seed = load_or_create_key()
|
|
79
|
+
pk = publickey(seed)
|
|
80
|
+
msg = _canonical(report_dict)
|
|
81
|
+
sig = signature(msg, seed, pk)
|
|
82
|
+
signed = dict(report_dict)
|
|
83
|
+
signed["signature"] = {
|
|
84
|
+
"algorithm": "ed25519",
|
|
85
|
+
"public_key": pk.hex(),
|
|
86
|
+
"signature": sig.hex(),
|
|
87
|
+
"signed_fields": "all fields except 'signature'",
|
|
88
|
+
"note": (
|
|
89
|
+
"Proves this report was not altered after issuance by the holder of "
|
|
90
|
+
"the public key. Does not prove production parity or independent review "
|
|
91
|
+
"— see the 'attestation' block."
|
|
92
|
+
),
|
|
93
|
+
}
|
|
94
|
+
return signed
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def verify_report(report_dict: dict) -> tuple[bool, str]:
|
|
98
|
+
"""Verify a signed report. Returns (ok, message).
|
|
99
|
+
|
|
100
|
+
Recomputes the canonical bytes over everything except the signature block and
|
|
101
|
+
checks the Ed25519 signature against the embedded public key.
|
|
102
|
+
"""
|
|
103
|
+
sig_block = report_dict.get("signature")
|
|
104
|
+
if not sig_block:
|
|
105
|
+
return False, "report is not signed (no 'signature' block)"
|
|
106
|
+
if sig_block.get("algorithm") != "ed25519":
|
|
107
|
+
return False, f"unsupported algorithm: {sig_block.get('algorithm')}"
|
|
108
|
+
try:
|
|
109
|
+
pk = bytes.fromhex(sig_block["public_key"])
|
|
110
|
+
sig = bytes.fromhex(sig_block["signature"])
|
|
111
|
+
except (KeyError, ValueError) as e:
|
|
112
|
+
return False, f"malformed signature block: {e}"
|
|
113
|
+
|
|
114
|
+
msg = _canonical(report_dict)
|
|
115
|
+
if checkvalid(sig, msg, pk):
|
|
116
|
+
return True, f"signature valid — issued by key {pk.hex()[:16]}…"
|
|
117
|
+
return False, "signature INVALID — report was altered or key mismatch"
|