diff --git a/app/ai-service/.env.example b/app/ai-service/.env.example index 6cafe3f..60e922a 100644 --- a/app/ai-service/.env.example +++ b/app/ai-service/.env.example @@ -24,3 +24,8 @@ BACKEND_WEBHOOK_URL=http://localhost:3001/ai/webhook PROOF_OF_LIFE_CONFIDENCE_THRESHOLD=0.65 # Minimum face bounding-box size in pixels for detection PROOF_OF_LIFE_MIN_FACE_SIZE=80 + +# HMAC Authentication +# Shared secret key for HMAC signature validation (must match NestJS backend) +# Generate with: openssl rand -hex 32 +HMAC_SECRET_KEY=your_hmac_secret_key_here diff --git a/app/ai-service/README.md b/app/ai-service/README.md index 68bef29..c00d07f 100644 --- a/app/ai-service/README.md +++ b/app/ai-service/README.md @@ -20,6 +20,81 @@ Or using uvicorn directly: uvicorn main:app --reload --host 0.0.0.0 --port 8000 ``` +## HMAC Authentication + +All API endpoints (except `/health`, `/`, `/docs`, `/openapi.json`, `/redoc`) require HMAC-SHA256 signature authentication to ensure only authorized services can access the AI layer. + +### Configuration + +Set the shared secret key in your environment: + +```bash +# Generate a secure key +openssl rand -hex 32 + +# Add to .env +HMAC_SECRET_KEY=your_generated_key_here +``` + +### Required Headers + +| Header | Description | +|--------|-------------| +| `X-HMAC-Signature` | HMAC-SHA256 signature (hex-encoded) | +| `X-HMAC-Timestamp` | Unix timestamp (seconds since epoch) | + +### Signature Generation + +The signature is computed as: + +``` +HMAC-SHA256(secret_key, method + path + timestamp + body) +``` + +Where: +- `method`: HTTP method (GET, POST, etc.) +- `path`: Request path (e.g., `/ai/ocr`) +- `timestamp`: Unix timestamp as string +- `body`: Request body (empty string if no body) + +### NestJS Backend Integration + +Use the provided utility in the NestJS backend: + +```typescript +import { generateHmacSignature } from './common/utils/hmac.util'; + +const body = JSON.stringify({ data: 'example' }); +const headers = generateHmacSignature({ + method: 'POST', + path: '/ai/ocr', + body, + secretKey: process.env.HMAC_SECRET_KEY, +}); + +// headers contains: +// { +// 'X-HMAC-Signature': '...', +// 'X-HMAC-Timestamp': '...' +// } + +await fetch('http://ai-service:8000/ai/ocr', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...headers, + }, + body, +}); +``` + +### Signature Validation Rules + +1. Requests without signature headers return `403 Forbidden` +2. Requests with invalid timestamp format return `403 Forbidden` +3. Requests older than 5 minutes (300 seconds) are rejected as expired +4. Requests with invalid signatures return `403 Forbidden` + ## API ### Health Check @@ -120,6 +195,7 @@ app/ai-service/ - ✅ Image preprocessing (grayscale, thresholding, denoising) - ✅ Field extraction with confidence scores - ✅ Rate limiting (10 requests/minute) +- ✅ HMAC-SHA256 signature validation for inter-service authentication ## Development diff --git a/app/ai-service/config.py b/app/ai-service/config.py index febe2c7..73988c9 100644 --- a/app/ai-service/config.py +++ b/app/ai-service/config.py @@ -40,6 +40,9 @@ class Settings(BaseSettings): # Redis and Celery settings redis_url: str = "redis://localhost:6379/0" + # HMAC authentication + hmac_secret_key: Optional[str] = None + # Backend webhook URL for notifications backend_webhook_url: Optional[str] = "http://localhost:3001/ai/webhook" diff --git a/app/ai-service/main.py b/app/ai-service/main.py index 40815f3..492a169 100644 --- a/app/ai-service/main.py +++ b/app/ai-service/main.py @@ -16,6 +16,7 @@ from api.routes import router as ocr_router from config import settings +from middleware import HMACAuthMiddleware import tasks from proof_of_life import ProofOfLifeAnalyzer, ProofOfLifeConfig @@ -98,6 +99,8 @@ class ProofOfLifeResponse(BaseModel): app.state.limiter = limiter app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) +app.add_middleware(HMACAuthMiddleware) + app.include_router(ocr_router) diff --git a/app/ai-service/middleware/__init__.py b/app/ai-service/middleware/__init__.py new file mode 100644 index 0000000..2ba15dd --- /dev/null +++ b/app/ai-service/middleware/__init__.py @@ -0,0 +1,3 @@ +from .hmac_auth import HMACAuthMiddleware + +__all__ = ["HMACAuthMiddleware"] diff --git a/app/ai-service/middleware/hmac_auth.py b/app/ai-service/middleware/hmac_auth.py new file mode 100644 index 0000000..ee8a4ed --- /dev/null +++ b/app/ai-service/middleware/hmac_auth.py @@ -0,0 +1,127 @@ +import hashlib +import hmac +import time +from typing import Callable, Optional + +from fastapi import Request, Response +from fastapi.responses import JSONResponse +from starlette.middleware.base import BaseHTTPMiddleware + +from config import settings + + +class HMACAuthMiddleware(BaseHTTPMiddleware): + """ + Middleware to verify HMAC signatures on incoming requests. + + Ensures that only authorized services (NestJS backend) can call the AI service + by validating the HMAC-SHA256 signature in the request headers. + """ + + SIGNATURE_HEADER = "X-HMAC-Signature" + TIMESTAMP_HEADER = "X-HMAC-Timestamp" + MAX_TIMESTAMP_DIFF_SECONDS = 300 + + EXCLUDED_PATHS = {"/health", "/", "/docs", "/openapi.json", "/redoc"} + + def __init__(self, app, secret_key: Optional[str] = None): + super().__init__(app) + self.secret_key = secret_key or settings.hmac_secret_key + + async def dispatch(self, request: Request, call_next: Callable) -> Response: + if request.url.path in self.EXCLUDED_PATHS: + return await call_next(request) + + if not self.secret_key: + return JSONResponse( + status_code=500, + content={ + "error": True, + "status_code": 500, + "detail": "HMAC secret key not configured", + "service": "soter-ai-service", + }, + ) + + signature = request.headers.get(self.SIGNATURE_HEADER) + timestamp = request.headers.get(self.TIMESTAMP_HEADER) + + if not signature or not timestamp: + return JSONResponse( + status_code=403, + content={ + "error": True, + "status_code": 403, + "detail": "Missing HMAC signature or timestamp", + "service": "soter-ai-service", + }, + ) + + try: + request_timestamp = int(timestamp) + except ValueError: + return JSONResponse( + status_code=403, + content={ + "error": True, + "status_code": 403, + "detail": "Invalid timestamp format", + "service": "soter-ai-service", + }, + ) + + current_timestamp = int(time.time()) + if abs(current_timestamp - request_timestamp) > self.MAX_TIMESTAMP_DIFF_SECONDS: + return JSONResponse( + status_code=403, + content={ + "error": True, + "status_code": 403, + "detail": "Request timestamp expired", + "service": "soter-ai-service", + }, + ) + + body = await request.body() + + if not self._verify_signature( + method=request.method, + path=request.url.path, + timestamp=timestamp, + body=body, + provided_signature=signature, + ): + return JSONResponse( + status_code=403, + content={ + "error": True, + "status_code": 403, + "detail": "Invalid HMAC signature", + "service": "soter-ai-service", + }, + ) + + return await call_next(request) + + def _verify_signature( + self, + method: str, + path: str, + timestamp: str, + body: bytes, + provided_signature: str, + ) -> bool: + """ + Verify the HMAC signature against the computed signature. + + The signature is computed as: + HMAC-SHA256(secret_key, method + path + timestamp + body) + """ + payload = f"{method}{path}{timestamp}".encode("utf-8") + body + expected_signature = hmac.new( + self.secret_key.encode("utf-8"), + payload, + hashlib.sha256, + ).hexdigest() + + return hmac.compare_digest(expected_signature, provided_signature) diff --git a/app/ai-service/tests/test_hmac_auth.py b/app/ai-service/tests/test_hmac_auth.py new file mode 100644 index 0000000..91cd661 --- /dev/null +++ b/app/ai-service/tests/test_hmac_auth.py @@ -0,0 +1,156 @@ +import hashlib +import hmac +import time +from unittest.mock import patch + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from middleware.hmac_auth import HMACAuthMiddleware + + +@pytest.fixture +def secret_key(): + return "test-secret-key-12345" + + +@pytest.fixture +def app(secret_key): + app = FastAPI() + app.add_middleware(HMACAuthMiddleware, secret_key=secret_key) + + @app.get("/health") + async def health(): + return {"status": "ok"} + + @app.post("/ai/test") + async def protected_endpoint(): + return {"message": "success"} + + @app.get("/") + async def root(): + return {"service": "test"} + + return app + + +@pytest.fixture +def client(app): + return TestClient(app) + + +def generate_signature(method: str, path: str, timestamp: str, body: bytes, secret_key: str) -> str: + payload = f"{method}{path}{timestamp}".encode("utf-8") + body + return hmac.new(secret_key.encode("utf-8"), payload, hashlib.sha256).hexdigest() + + +class TestHMACAuthMiddleware: + def test_excluded_paths_bypass_auth(self, client): + response = client.get("/health") + assert response.status_code == 200 + + response = client.get("/") + assert response.status_code == 200 + + def test_missing_signature_returns_403(self, client): + response = client.post("/ai/test") + assert response.status_code == 403 + assert "Missing HMAC signature" in response.json()["detail"] + + def test_missing_timestamp_returns_403(self, client, secret_key): + headers = {"X-HMAC-Signature": "some-signature"} + response = client.post("/ai/test", headers=headers) + assert response.status_code == 403 + assert "Missing HMAC signature" in response.json()["detail"] + + def test_invalid_timestamp_format_returns_403(self, client, secret_key): + headers = { + "X-HMAC-Signature": "some-signature", + "X-HMAC-Timestamp": "not-a-number", + } + response = client.post("/ai/test", headers=headers) + assert response.status_code == 403 + assert "Invalid timestamp format" in response.json()["detail"] + + def test_expired_timestamp_returns_403(self, client, secret_key): + old_timestamp = str(int(time.time()) - 400) + body = b"" + signature = generate_signature("POST", "/ai/test", old_timestamp, body, secret_key) + headers = { + "X-HMAC-Signature": signature, + "X-HMAC-Timestamp": old_timestamp, + } + response = client.post("/ai/test", headers=headers) + assert response.status_code == 403 + assert "Request timestamp expired" in response.json()["detail"] + + def test_invalid_signature_returns_403(self, client, secret_key): + timestamp = str(int(time.time())) + headers = { + "X-HMAC-Signature": "invalid-signature", + "X-HMAC-Timestamp": timestamp, + } + response = client.post("/ai/test", headers=headers) + assert response.status_code == 403 + assert "Invalid HMAC signature" in response.json()["detail"] + + def test_valid_signature_allows_request(self, client, secret_key): + timestamp = str(int(time.time())) + body = b"" + signature = generate_signature("POST", "/ai/test", timestamp, body, secret_key) + headers = { + "X-HMAC-Signature": signature, + "X-HMAC-Timestamp": timestamp, + } + response = client.post("/ai/test", headers=headers) + assert response.status_code == 200 + assert response.json()["message"] == "success" + + def test_valid_signature_with_body(self, client, secret_key): + timestamp = str(int(time.time())) + body = b'{"data": "test"}' + signature = generate_signature("POST", "/ai/test", timestamp, body, secret_key) + headers = { + "X-HMAC-Signature": signature, + "X-HMAC-Timestamp": timestamp, + "Content-Type": "application/json", + } + response = client.post("/ai/test", headers=headers, content=body) + assert response.status_code == 200 + + def test_signature_mismatch_with_different_body_returns_403(self, client, secret_key): + timestamp = str(int(time.time())) + body = b'{"data": "test"}' + different_body = b'{"data": "modified"}' + signature = generate_signature("POST", "/ai/test", timestamp, body, secret_key) + headers = { + "X-HMAC-Signature": signature, + "X-HMAC-Timestamp": timestamp, + "Content-Type": "application/json", + } + response = client.post("/ai/test", headers=headers, content=different_body) + assert response.status_code == 403 + assert "Invalid HMAC signature" in response.json()["detail"] + + +class TestHMACAuthMiddlewareNoSecret: + def test_missing_secret_key_returns_500(self): + app = FastAPI() + app.add_middleware(HMACAuthMiddleware, secret_key=None) + + @app.post("/ai/test") + async def protected(): + return {"message": "success"} + + client = TestClient(app) + + with patch("middleware.hmac_auth.settings") as mock_settings: + mock_settings.hmac_secret_key = None + headers = { + "X-HMAC-Signature": "any", + "X-HMAC-Timestamp": str(int(time.time())), + } + response = client.post("/ai/test", headers=headers) + assert response.status_code == 500 + assert "HMAC secret key not configured" in response.json()["detail"] diff --git a/app/backend/.env.example b/app/backend/.env.example index bc68ad9..8a1250d 100644 --- a/app/backend/.env.example +++ b/app/backend/.env.example @@ -195,6 +195,12 @@ API_VERSIONING_ENABLED="true" # Default: http://localhost:8000/ai/webhook (AI service) AI_WEBHOOK_URL=http://localhost:8000/ai/webhook +# HMAC Authentication +# Shared secret key for HMAC signature validation when calling AI service +# Must match the HMAC_SECRET_KEY in the AI service +# Generate with: openssl rand -hex 32 +HMAC_SECRET_KEY=your_hmac_secret_key_here + # ============================================================================ # QUICK START GUIDE # ============================================================================ diff --git a/app/backend/src/common/utils/hmac.util.ts b/app/backend/src/common/utils/hmac.util.ts new file mode 100644 index 0000000..16b06b2 --- /dev/null +++ b/app/backend/src/common/utils/hmac.util.ts @@ -0,0 +1,30 @@ +import { createHmac } from 'crypto'; + +export interface HmacSignatureOptions { + method: string; + path: string; + body?: string | Buffer; + secretKey: string; +} + +export interface HmacHeaders { + 'X-HMAC-Signature': string; + 'X-HMAC-Timestamp': string; +} + +export function generateHmacSignature(options: HmacSignatureOptions): HmacHeaders { + const { method, path, body = '', secretKey } = options; + const timestamp = Math.floor(Date.now() / 1000).toString(); + + const bodyString = typeof body === 'string' ? body : body.toString('utf-8'); + const payload = `${method}${path}${timestamp}${bodyString}`; + + const signature = createHmac('sha256', secretKey) + .update(payload, 'utf-8') + .digest('hex'); + + return { + 'X-HMAC-Signature': signature, + 'X-HMAC-Timestamp': timestamp, + }; +}