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/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"