Skip to content
Merged
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
2 changes: 2 additions & 0 deletions app/api/routes/capture.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"/session",
response_model=CaptureSessionResponse,
status_code=status.HTTP_201_CREATED,
summary="Create capture session",
responses={
401: {"description": "Invalid or missing device token"},
},
Expand Down Expand Up @@ -53,6 +54,7 @@ async def create_session(
"/trust",
response_model=TrustResponse,
status_code=status.HTTP_200_OK,
summary="Generate trust token",
responses={
400: {"description": "Invalid or expired nonce"},
},
Expand Down
1 change: 1 addition & 0 deletions app/api/routes/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"",
response_model=DeviceCreateResponse,
status_code=status.HTTP_201_CREATED,
summary="Register a device",
responses={
401: {"description": "Attestation verification failed"},
404: {"description": "Publisher not found"},
Expand Down
25 changes: 21 additions & 4 deletions app/api/routes/health.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,28 @@
from fastapi import APIRouter
from pydantic import BaseModel, Field

from app.settings import settings

router = APIRouter(tags=["health"])


@router.get("/health")
async def health() -> dict[str, str]:
"""Health check endpoint."""
return {"status": "ok", "version": settings.app_version}
class HealthResponse(BaseModel):
"""Health check response."""

status: str = Field(
description="Service status. 'ok' when healthy.", examples=["ok"]
)
version: str = Field(
description="Deployed application version.", examples=["v0.1.0"]
)


@router.get("/health", response_model=HealthResponse, summary="Check API health")
async def health() -> HealthResponse:
"""
Health check endpoint.

Returns the service status and deployed version. Use this endpoint to verify
the API is running and to check which version is deployed.
"""
return HealthResponse(status="ok", version=settings.app_version)
6 changes: 4 additions & 2 deletions app/api/routes/jwks.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@
from app.schemas.jwk import JWKSResponse
from app.services.jwk import JWKService, get_jwk_service_singleton

router = APIRouter(tags=["jwks"])
router = APIRouter(tags=["security"])


@router.get("/.well-known/jwks.json", response_model=JWKSResponse)
@router.get(
"/.well-known/jwks.json", response_model=JWKSResponse, summary="Get public keys"
)
def get_jwks(
jwk_service: JWKService = Depends(get_jwk_service_singleton),
) -> JWKSResponse:
Expand Down
3 changes: 3 additions & 0 deletions app/api/routes/publisher.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ def _publisher_to_response(publisher: Publisher) -> PublisherCreateResponse:
"",
response_model=PublisherCreateResponse,
status_code=status.HTTP_201_CREATED,
summary="Create a publisher",
responses={
403: {"description": "Publisher API is disabled"},
409: {"description": "Publisher already exists"},
Expand Down Expand Up @@ -77,6 +78,7 @@ async def create_publisher(
@router.patch(
"/{publisher_id}",
response_model=PublisherCreateResponse,
summary="Update a publisher",
responses={
403: {"description": "Publisher API is disabled"},
404: {"description": "Publisher not found"},
Expand Down Expand Up @@ -125,6 +127,7 @@ async def update_publisher(
@router.get(
"/{publisher_id}",
response_model=PublisherCreateResponse,
summary="Get a publisher",
responses={
403: {"description": "Publisher API is disabled"},
404: {"description": "Publisher not found"},
Expand Down
1 change: 1 addition & 0 deletions app/api/routes/validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"",
response_model=ValidationResponse,
status_code=status.HTTP_200_OK,
summary="Validate signed media",
responses={
400: {"description": "Invalid sidecar format or validation error"},
},
Expand Down
35 changes: 32 additions & 3 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,19 +57,48 @@ def render(self, content: Any) -> bytes:
1. **Register a device** - Get a device token for authentication
2. **Create a capture session** - Get a one-time nonce before capturing media
3. **Exchange nonce for trust token** - Get a signed JWT proving capture authenticity
4. **Validate media** - Verify authenticity of signed media with its sidecar

## Authentication

- Device endpoints require an `Authorization: Bearer <device_token>` header
- Publisher ID is passed via `X-Publisher-ID` header during device registration
- Attestation token (Firebase App Check) is passed via `X-Attestation-Token` header
""",
openapi_tags=[
{
"name": "publishers",
"description": "Publisher registration and management. Publishers are apps that use SignedShot.",
},
{
"name": "devices",
"description": "Device registration and management",
"description": "Device registration and management. Devices are user phones running a publisher's app.",
},
{
"name": "capture",
"description": "Capture sessions and trust token generation",
"description": "Capture sessions and trust token generation.",
},
{
"name": "validate",
"description": "Media validation and verification.",
},
{
"name": "security",
"description": "Security endpoints for JWT verification.",
},
{
"name": "health",
"description": "Health check endpoints",
"description": "Health check endpoints.",
},
],
servers=[
{
"url": "https://api.signedshot.io",
"description": "Production API",
},
{
"url": "https://dev-api.signedshot.io",
"description": "Development API (open for testing)",
},
],
)
Expand Down
40 changes: 30 additions & 10 deletions app/schemas/jwk.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,39 @@
from pydantic import BaseModel
from pydantic import BaseModel, Field


class JWK(BaseModel):
"""JSON Web Key for EC key."""

kty: str # Key type, always "EC" for elliptic curve
crv: str # Curve, "P-256" for ES256
x: str # Base64url-encoded X coordinate
y: str # Base64url-encoded Y coordinate
use: str # Key usage, "sig" for signature
alg: str # Algorithm, "ES256"
kid: str # Key ID
kty: str = Field(
description="Key type. Always 'EC' for elliptic curve keys.",
examples=["EC"],
)
crv: str = Field(
description="Cryptographic curve. 'P-256' for ES256 algorithm.",
examples=["P-256"],
)
x: str = Field(
description="Base64url-encoded X coordinate of the elliptic curve point.",
)
y: str = Field(
description="Base64url-encoded Y coordinate of the elliptic curve point.",
)
use: str = Field(
description="Key usage. 'sig' indicates this key is used for signatures.",
examples=["sig"],
)
alg: str = Field(
description="Algorithm. 'ES256' for ECDSA using P-256 and SHA-256.",
examples=["ES256"],
)
kid: str = Field(
description="Key ID. Unique identifier for this key, used to match JWT headers.",
)


class JWKSResponse(BaseModel):
"""JSON Web Key Set response."""
"""JSON Web Key Set response for JWT verification."""

keys: list[JWK]
keys: list[JWK] = Field(
description="List of public keys available for JWT verification.",
)