Skip to content
Open
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
5 changes: 5 additions & 0 deletions app/ai-service/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
76 changes: 76 additions & 0 deletions app/ai-service/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
3 changes: 3 additions & 0 deletions app/ai-service/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
3 changes: 3 additions & 0 deletions app/ai-service/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)


Expand Down
3 changes: 3 additions & 0 deletions app/ai-service/middleware/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from .hmac_auth import HMACAuthMiddleware

__all__ = ["HMACAuthMiddleware"]
127 changes: 127 additions & 0 deletions app/ai-service/middleware/hmac_auth.py
Original file line number Diff line number Diff line change
@@ -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)
Loading
Loading