From fa4646d72241db7c7617c0becfda43bd8de04c84 Mon Sep 17 00:00:00 2001 From: Felippe Costa Date: Tue, 17 Feb 2026 17:30:47 -0300 Subject: [PATCH 1/3] feat: add device public key and fingerprint for cross-layer binding --- .../a1b2c3d4e5f7_add_public_key_to_devices.py | 41 +++++++++ app/api/routes/capture.py | 2 + app/api/routes/device.py | 1 + app/db/models.py | 3 + app/repositories/device.py | 6 ++ app/schemas/device.py | 5 ++ app/services/device.py | 14 ++++ app/services/session.py | 8 ++ app/services/trust.py | 6 ++ tests/integration/test_capture_flow.py | 15 +++- tests/integration/test_device_flow.py | 35 ++++++-- tests/integration/test_jwks.py | 10 ++- tests/test_capture.py | 18 +++- tests/test_device.py | 83 +++++++++++++++---- 14 files changed, 217 insertions(+), 30 deletions(-) create mode 100644 alembic/versions/a1b2c3d4e5f7_add_public_key_to_devices.py diff --git a/alembic/versions/a1b2c3d4e5f7_add_public_key_to_devices.py b/alembic/versions/a1b2c3d4e5f7_add_public_key_to_devices.py new file mode 100644 index 0000000..6e6eab2 --- /dev/null +++ b/alembic/versions/a1b2c3d4e5f7_add_public_key_to_devices.py @@ -0,0 +1,41 @@ +"""add public_key and device_public_key_fingerprint to devices + +Revision ID: a1b2c3d4e5f7 +Revises: f6a7b8c9d0e1 +Create Date: 2026-02-16 10:00:00.000000 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "a1b2c3d4e5f7" +down_revision: str | None = "f6a7b8c9d0e1" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # Pre-launch: delete existing devices (they lack a public key and + # cannot produce valid sidecars with cross-layer binding). + # Captures reference devices via FK, so delete them first. + op.execute("DELETE FROM captures") + op.execute("DELETE FROM devices") + + op.add_column( + "devices", + sa.Column("public_key", sa.String(120), nullable=False), + ) + op.add_column( + "devices", + sa.Column("device_public_key_fingerprint", sa.String(64), nullable=False), + ) + + +def downgrade() -> None: + op.drop_column("devices", "device_public_key_fingerprint") + op.drop_column("devices", "public_key") diff --git a/app/api/routes/capture.py b/app/api/routes/capture.py index ef549de..6367bf1 100644 --- a/app/api/routes/capture.py +++ b/app/api/routes/capture.py @@ -41,6 +41,7 @@ async def create_session( device.id, attestation_method=device.attestation_method, app_id=device.attested_app_id, + device_public_key_fingerprint=device.device_public_key_fingerprint, ) return CaptureSessionResponse( @@ -85,6 +86,7 @@ async def create_trust_token( session_data.device_id, method=cast("AttestationMethod", session_data.attestation_method or "sandbox"), app_id=session_data.app_id, + device_public_key_fingerprint=session_data.device_public_key_fingerprint, ) # Mark capture as completed in background diff --git a/app/api/routes/device.py b/app/api/routes/device.py index b7d26e4..a2621d1 100644 --- a/app/api/routes/device.py +++ b/app/api/routes/device.py @@ -86,6 +86,7 @@ async def create_device( session, publisher_id, request.external_id, + request.public_key, attestation_method=attestation.method, attested_at=attestation.attested_at, attested_app_id=attestation.app_id, diff --git a/app/db/models.py b/app/db/models.py index 2ca6db1..41bea9e 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -87,6 +87,9 @@ class Device(Base): attested_app_id: Mapped[str | None] = mapped_column( String(255), nullable=True, default=None ) + # Content-signing public key (for cross-layer binding) + public_key: Mapped[str] = mapped_column(String(120)) + device_public_key_fingerprint: Mapped[str] = mapped_column(String(64)) class Capture(Base): diff --git a/app/repositories/device.py b/app/repositories/device.py index 5d2e5f0..97fd24d 100644 --- a/app/repositories/device.py +++ b/app/repositories/device.py @@ -36,6 +36,8 @@ async def create( publisher_id: uuid.UUID, external_id: str, token_hash: str, + public_key: str, + device_public_key_fingerprint: str, attestation_method: str | None = None, attested_at: datetime | None = None, attested_app_id: str | None = None, @@ -47,6 +49,8 @@ async def create( publisher_id: Publisher UUID. external_id: External device identifier. token_hash: Hashed device token. + public_key: Base64-encoded content-signing public key. + device_public_key_fingerprint: SHA-256 hex of the public key. attestation_method: Optional attestation method (e.g., "app_check"). attested_at: Optional timestamp when attestation was verified. attested_app_id: Optional app ID from attestation (e.g., bundle ID). @@ -62,6 +66,8 @@ async def create( attestation_method=attestation_method, attested_at=attested_at, attested_app_id=attested_app_id, + public_key=public_key, + device_public_key_fingerprint=device_public_key_fingerprint, ) session.add(device) try: diff --git a/app/schemas/device.py b/app/schemas/device.py index eb1e224..38d80ab 100644 --- a/app/schemas/device.py +++ b/app/schemas/device.py @@ -12,6 +12,11 @@ class DeviceCreateRequest(BaseModel): description="Unique identifier for the device (e.g., hardware ID, app installation ID)", json_schema_extra={"example": "device-abc-123"}, ) + public_key: str = Field( + min_length=1, + max_length=120, + description="Base64-encoded uncompressed EC public key (65 bytes: 0x04 + X + Y) from the device's content-signing key pair", + ) class DeviceCreateResponse(APIResponse): diff --git a/app/services/device.py b/app/services/device.py index d48d65a..a5c7659 100644 --- a/app/services/device.py +++ b/app/services/device.py @@ -1,3 +1,4 @@ +import base64 import hashlib import secrets import uuid @@ -23,11 +24,18 @@ def _hash_token(self, token: str) -> str: """Hash a token for storage.""" return hashlib.sha256(token.encode()).hexdigest() + @staticmethod + def _compute_fingerprint(public_key: str) -> str: + """Compute SHA-256 fingerprint of a Base64-encoded public key.""" + raw_bytes = base64.b64decode(public_key) + return hashlib.sha256(raw_bytes).hexdigest() + async def create( self, session: AsyncSession, publisher_id: uuid.UUID, external_id: str, + public_key: str, attestation_method: str | None = None, attested_at: datetime | None = None, attested_app_id: str | None = None, @@ -39,6 +47,8 @@ async def create( session: Database session. publisher_id: Publisher UUID. external_id: External device identifier. + public_key: Base64-encoded uncompressed EC public key (65 bytes) + for cross-layer binding. attestation_method: Optional attestation method (e.g., "app_check"). attested_at: Optional timestamp when attestation was verified. attested_app_id: Optional app ID from attestation (e.g., bundle ID). @@ -49,6 +59,8 @@ async def create( token = self._generate_token() token_hash = self._hash_token(token) + fingerprint = self._compute_fingerprint(public_key) + device = await self._repository.create( session, publisher_id, @@ -57,6 +69,8 @@ async def create( attestation_method=attestation_method, attested_at=attested_at, attested_app_id=attested_app_id, + public_key=public_key, + device_public_key_fingerprint=fingerprint, ) return device, token diff --git a/app/services/session.py b/app/services/session.py index 66cb17c..92b9858 100644 --- a/app/services/session.py +++ b/app/services/session.py @@ -20,6 +20,9 @@ class SessionData: device_id: str attestation_method: str | None # None means no attestation (sandbox) app_id: str | None # App ID from attestation (e.g., bundle ID) + device_public_key_fingerprint: ( + str | None + ) # SHA-256 of device's content-signing public key @dataclass @@ -53,6 +56,7 @@ async def create( device_id: uuid.UUID, attestation_method: str | None, app_id: str | None = None, + device_public_key_fingerprint: str | None = None, ) -> CreateSessionResult: """ Create a new capture session for a device. @@ -66,6 +70,8 @@ async def create( attestation_method: The device's attestation method (e.g., "app_check") or None if not attested. app_id: The app ID from attestation (e.g., bundle ID). + device_public_key_fingerprint: SHA-256 hex of the device's content-signing + public key, for cross-layer binding in the JWT. """ # Create capture record in database capture = await capture_repository.create(db, publisher_id, device_id) @@ -83,6 +89,7 @@ async def create( "device_id": str(device_id), "attestation_method": attestation_method, "app_id": app_id, + "device_public_key_fingerprint": device_public_key_fingerprint, } ) await self._storage.set( @@ -113,6 +120,7 @@ async def get_session_data(self, nonce: str) -> SessionData | None: device_id=data["device_id"], attestation_method=data.get("attestation_method"), app_id=data.get("app_id"), + device_public_key_fingerprint=data.get("device_public_key_fingerprint"), ) async def consume(self, nonce: str) -> SessionData | None: diff --git a/app/services/trust.py b/app/services/trust.py index ad112cb..977e1ea 100644 --- a/app/services/trust.py +++ b/app/services/trust.py @@ -32,6 +32,7 @@ def generate_token( device_id: str, method: AttestationMethod, app_id: str | None = None, + device_public_key_fingerprint: str | None = None, ) -> str: """ Generate a signed JWT trust token. @@ -42,6 +43,8 @@ def generate_token( device_id: The device ID. method: The attestation method used (sandbox, app_check, app_attest). app_id: The app ID from attestation (e.g., bundle ID), if available. + device_public_key_fingerprint: SHA-256 hex of the device's content-signing + public key, for cross-layer binding with the media integrity proof. Returns the signed JWT string with kid in header for JWKS lookup. """ @@ -63,6 +66,9 @@ def generate_token( "attestation": attestation, } + if device_public_key_fingerprint is not None: + payload["device_public_key_fingerprint"] = device_public_key_fingerprint + return jwt.encode( payload, self._private_key, diff --git a/tests/integration/test_capture_flow.py b/tests/integration/test_capture_flow.py index 0df0f15..abd9f6f 100644 --- a/tests/integration/test_capture_flow.py +++ b/tests/integration/test_capture_flow.py @@ -26,7 +26,10 @@ def registered_device( """Create a device and return its credentials.""" response = integration_client.post( "/devices", - json={"external_id": "capture-test-device"}, + json={ + "external_id": "capture-test-device", + "public_key": "dGVzdHB1YmxpY2tleQ==", + }, headers={"X-Publisher-ID": publisher_id}, ) assert response.status_code == status.HTTP_201_CREATED @@ -193,12 +196,18 @@ def test_multiple_devices_isolated( # Create two devices device1 = integration_client.post( "/devices", - json={"external_id": "multi-device-001"}, + json={ + "external_id": "multi-device-001", + "public_key": "dGVzdHB1YmxpY2tleQ==", + }, headers={"X-Publisher-ID": publisher_id}, ).json() device2 = integration_client.post( "/devices", - json={"external_id": "multi-device-002"}, + json={ + "external_id": "multi-device-002", + "public_key": "dGVzdHB1YmxpY2tleQ==", + }, headers={"X-Publisher-ID": publisher_id}, ).json() diff --git a/tests/integration/test_device_flow.py b/tests/integration/test_device_flow.py index 8c4d90d..515f0e9 100644 --- a/tests/integration/test_device_flow.py +++ b/tests/integration/test_device_flow.py @@ -26,7 +26,10 @@ def test_create_device_success( """Successfully create a new device.""" response = integration_client.post( "/devices", - json={"external_id": "test-device-001"}, + json={ + "external_id": "test-device-001", + "public_key": "dGVzdHB1YmxpY2tleQ==", + }, headers={"X-Publisher-ID": publisher_id}, ) @@ -46,7 +49,10 @@ def test_create_device_duplicate_fails( # Create first device response1 = integration_client.post( "/devices", - json={"external_id": "duplicate-device"}, + json={ + "external_id": "duplicate-device", + "public_key": "dGVzdHB1YmxpY2tleQ==", + }, headers={"X-Publisher-ID": publisher_id}, ) assert response1.status_code == status.HTTP_201_CREATED @@ -54,7 +60,10 @@ def test_create_device_duplicate_fails( # Try to create again with same external_id response2 = integration_client.post( "/devices", - json={"external_id": "duplicate-device"}, + json={ + "external_id": "duplicate-device", + "public_key": "dGVzdHB1YmxpY2tleQ==", + }, headers={"X-Publisher-ID": publisher_id}, ) assert response2.status_code == status.HTTP_409_CONFLICT @@ -67,7 +76,10 @@ def test_device_token_is_valid( # Create a device create_response = integration_client.post( "/devices", - json={"external_id": "session-test-device"}, + json={ + "external_id": "session-test-device", + "public_key": "dGVzdHB1YmxpY2tleQ==", + }, headers={"X-Publisher-ID": publisher_id}, ) assert create_response.status_code == status.HTTP_201_CREATED @@ -107,7 +119,10 @@ def test_same_external_id_different_publishers( # Create device for first publisher device1_response = integration_client.post( "/devices", - json={"external_id": shared_external_id}, + json={ + "external_id": shared_external_id, + "public_key": "dGVzdHB1YmxpY2tleQ==", + }, headers={"X-Publisher-ID": publisher_id_1}, ) assert device1_response.status_code == status.HTTP_201_CREATED @@ -115,7 +130,10 @@ def test_same_external_id_different_publishers( # Create device with SAME external_id for second publisher - should succeed device2_response = integration_client.post( "/devices", - json={"external_id": shared_external_id}, + json={ + "external_id": shared_external_id, + "public_key": "dGVzdHB1YmxpY2tleQ==", + }, headers={"X-Publisher-ID": publisher_id_2}, ) assert device2_response.status_code == status.HTTP_201_CREATED @@ -273,7 +291,10 @@ def test_device_created_at_format(self, integration_client: TestClient) -> None: # Create device device_response = integration_client.post( "/devices", - json={"external_id": "datetime-test-device"}, + json={ + "external_id": "datetime-test-device", + "public_key": "dGVzdHB1YmxpY2tleQ==", + }, headers={"X-Publisher-ID": publisher_id}, ) assert device_response.status_code == status.HTTP_201_CREATED diff --git a/tests/integration/test_jwks.py b/tests/integration/test_jwks.py index 5713fd1..93c4aba 100644 --- a/tests/integration/test_jwks.py +++ b/tests/integration/test_jwks.py @@ -36,7 +36,10 @@ def test_trust_token_has_kid_matching_jwks( dev_response = integration_client.post( "/devices", - json={"external_id": "jwks-test-device"}, + json={ + "external_id": "jwks-test-device", + "public_key": "dGVzdHB1YmxpY2tleQ==", + }, headers={"X-Publisher-ID": publisher_id}, ) device_token = dev_response.json()["device_token"] @@ -80,7 +83,10 @@ def test_trust_token_can_be_verified_with_jwks( dev_response = integration_client.post( "/devices", - json={"external_id": "jwks-verify-device"}, + json={ + "external_id": "jwks-verify-device", + "public_key": "dGVzdHB1YmxpY2tleQ==", + }, headers={"X-Publisher-ID": publisher_id}, ) device_token = dev_response.json()["device_token"] diff --git a/tests/test_capture.py b/tests/test_capture.py index ec5b398..20a0b99 100644 --- a/tests/test_capture.py +++ b/tests/test_capture.py @@ -25,6 +25,8 @@ def test_create_session_success() -> None: token_hash="hashed_token", created_at=datetime.now(UTC), attestation_method=None, # No attestation (sandbox mode) + public_key="dGVzdHB1YmxpY2tleQ==", + device_public_key_fingerprint="a" * 64, ) mock_capture = Capture( @@ -98,6 +100,7 @@ def test_create_trust_token_success() -> None: device_id=device_id, attestation_method=None, # No attestation (sandbox mode) app_id=None, + device_public_key_fingerprint=None, ) mock_session_service = MagicMock() @@ -123,7 +126,12 @@ def test_create_trust_token_success() -> None: assert "trust_token" in data assert data["trust_token"] == "mock.jwt.token" mock_trust_service.generate_token.assert_called_once_with( - capture_id, publisher_id, device_id, method="sandbox", app_id=None + capture_id, + publisher_id, + device_id, + method="sandbox", + app_id=None, + device_public_key_fingerprint=None, ) @@ -142,6 +150,7 @@ def test_create_trust_token_with_app_check() -> None: device_id=device_id, attestation_method="app_check", # Device was attested app_id="io.foo.bar", + device_public_key_fingerprint="abc123def456", ) mock_session_service = MagicMock() @@ -167,7 +176,12 @@ def test_create_trust_token_with_app_check() -> None: assert "trust_token" in data assert data["trust_token"] == "mock.jwt.token" mock_trust_service.generate_token.assert_called_once_with( - capture_id, publisher_id, device_id, method="app_check", app_id="io.foo.bar" + capture_id, + publisher_id, + device_id, + method="app_check", + app_id="io.foo.bar", + device_public_key_fingerprint="abc123def456", ) diff --git a/tests/test_device.py b/tests/test_device.py index 8d58ec5..055d27d 100644 --- a/tests/test_device.py +++ b/tests/test_device.py @@ -61,6 +61,8 @@ def test_create_device_success_sandbox_no_token() -> None: external_id="test-device-123", token_hash="hashed_token", created_at=datetime.now(UTC), + public_key="dGVzdHB1YmxpY2tleQ==", + device_public_key_fingerprint="a" * 64, ) mock_publisher = _mock_sandbox_publisher(publisher_uuid) @@ -80,7 +82,10 @@ def test_create_device_success_sandbox_no_token() -> None: response = client.post( "/devices", - json={"external_id": "test-device-123"}, + json={ + "external_id": "test-device-123", + "public_key": "dGVzdHB1YmxpY2tleQ==", + }, headers={"X-Publisher-ID": str(publisher_uuid)}, ) @@ -110,7 +115,10 @@ def test_create_device_sandbox_no_provider_rejects_token() -> None: response = client.post( "/devices", - json={"external_id": "test-device-123"}, + json={ + "external_id": "test-device-123", + "public_key": "dGVzdHB1YmxpY2tleQ==", + }, headers={ "X-Publisher-ID": str(publisher_uuid), "X-Attestation-Token": "some_token", @@ -131,6 +139,8 @@ def test_create_device_sandbox_with_provider_no_token() -> None: external_id="test-device-123", token_hash="hashed_token", created_at=datetime.now(UTC), + public_key="dGVzdHB1YmxpY2tleQ==", + device_public_key_fingerprint="a" * 64, ) mock_publisher = _mock_sandbox_publisher_with_provider(publisher_uuid) @@ -150,7 +160,10 @@ def test_create_device_sandbox_with_provider_no_token() -> None: response = client.post( "/devices", - json={"external_id": "test-device-123"}, + json={ + "external_id": "test-device-123", + "public_key": "dGVzdHB1YmxpY2tleQ==", + }, headers={"X-Publisher-ID": str(publisher_uuid)}, ) @@ -169,6 +182,8 @@ def test_create_device_sandbox_with_provider_validates_token() -> None: external_id="test-device-123", token_hash="hashed_token", created_at=datetime.now(UTC), + public_key="dGVzdHB1YmxpY2tleQ==", + device_public_key_fingerprint="a" * 64, ) mock_publisher = _mock_sandbox_publisher_with_provider(publisher_uuid) @@ -195,7 +210,10 @@ def test_create_device_sandbox_with_provider_validates_token() -> None: response = client.post( "/devices", - json={"external_id": "test-device-123"}, + json={ + "external_id": "test-device-123", + "public_key": "dGVzdHB1YmxpY2tleQ==", + }, headers={ "X-Publisher-ID": str(publisher_uuid), "X-Attestation-Token": "valid_token", @@ -225,7 +243,10 @@ def test_create_device_non_sandbox_requires_token() -> None: response = client.post( "/devices", - json={"external_id": "test-device-123"}, + json={ + "external_id": "test-device-123", + "public_key": "dGVzdHB1YmxpY2tleQ==", + }, headers={"X-Publisher-ID": str(publisher_uuid)}, ) @@ -245,6 +266,8 @@ def test_create_device_non_sandbox_with_valid_token() -> None: external_id="test-device-123", token_hash="hashed_token", created_at=datetime.now(UTC), + public_key="dGVzdHB1YmxpY2tleQ==", + device_public_key_fingerprint="a" * 64, ) mock_publisher = _mock_production_publisher(publisher_uuid) @@ -271,7 +294,10 @@ def test_create_device_non_sandbox_with_valid_token() -> None: response = client.post( "/devices", - json={"external_id": "test-device-123"}, + json={ + "external_id": "test-device-123", + "public_key": "dGVzdHB1YmxpY2tleQ==", + }, headers={ "X-Publisher-ID": str(publisher_uuid), "X-Attestation-Token": "valid_app_check_token", @@ -310,7 +336,10 @@ def test_create_device_non_sandbox_no_provider_invalid_config() -> None: # Non-sandbox without token first fails with "required for non-sandbox" response = client.post( "/devices", - json={"external_id": "test-device-123"}, + json={ + "external_id": "test-device-123", + "public_key": "dGVzdHB1YmxpY2tleQ==", + }, headers={"X-Publisher-ID": str(publisher_uuid)}, ) @@ -341,7 +370,10 @@ def test_create_device_non_sandbox_no_provider_with_token_invalid_config() -> No response = client.post( "/devices", - json={"external_id": "test-device-123"}, + json={ + "external_id": "test-device-123", + "public_key": "dGVzdHB1YmxpY2tleQ==", + }, headers={ "X-Publisher-ID": str(publisher_uuid), "X-Attestation-Token": "some_token", @@ -375,7 +407,10 @@ def test_create_device_already_exists() -> None: response = client.post( "/devices", - json={"external_id": "existing-device"}, + json={ + "external_id": "existing-device", + "public_key": "dGVzdHB1YmxpY2tleQ==", + }, headers={"X-Publisher-ID": str(publisher_uuid)}, ) @@ -387,7 +422,7 @@ def test_create_device_missing_publisher_id() -> None: """Return 422 when X-Publisher-ID header is missing.""" response = client.post( "/devices", - json={"external_id": "test-device-123"}, + json={"external_id": "test-device-123", "public_key": "dGVzdHB1YmxpY2tleQ=="}, ) assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT @@ -397,7 +432,7 @@ def test_create_device_invalid_publisher_id() -> None: """Return 400 when X-Publisher-ID is not a valid UUID.""" response = client.post( "/devices", - json={"external_id": "test-device-123"}, + json={"external_id": "test-device-123", "public_key": "dGVzdHB1YmxpY2tleQ=="}, headers={"X-Publisher-ID": "not-a-uuid"}, ) @@ -410,7 +445,7 @@ def test_create_device_empty_external_id() -> None: publisher_uuid = uuid.uuid4() response = client.post( "/devices", - json={"external_id": ""}, + json={"external_id": "", "public_key": "dGVzdHB1YmxpY2tleQ=="}, headers={"X-Publisher-ID": str(publisher_uuid)}, ) @@ -431,6 +466,8 @@ def test_create_device_same_external_id_different_publishers() -> None: external_id=shared_external_id, token_hash="hashed_token_1", created_at=datetime.now(UTC), + public_key="dGVzdHB1YmxpY2tleQ==", + device_public_key_fingerprint="a" * 64, ) mock_device_2 = Device( @@ -439,6 +476,8 @@ def test_create_device_same_external_id_different_publishers() -> None: external_id=shared_external_id, token_hash="hashed_token_2", created_at=datetime.now(UTC), + public_key="dGVzdHB1YmxpY2tleQ==", + device_public_key_fingerprint="a" * 64, ) mock_publisher_1 = _mock_sandbox_publisher(publisher_uuid_1) @@ -459,7 +498,10 @@ def test_create_device_same_external_id_different_publishers() -> None: mock_service.create = AsyncMock(return_value=(mock_device_1, "token_1")) response_1 = client.post( "/devices", - json={"external_id": shared_external_id}, + json={ + "external_id": shared_external_id, + "public_key": "dGVzdHB1YmxpY2tleQ==", + }, headers={"X-Publisher-ID": str(publisher_uuid_1)}, ) @@ -468,7 +510,10 @@ def test_create_device_same_external_id_different_publishers() -> None: mock_service.create = AsyncMock(return_value=(mock_device_2, "token_2")) response_2 = client.post( "/devices", - json={"external_id": shared_external_id}, + json={ + "external_id": shared_external_id, + "public_key": "dGVzdHB1YmxpY2tleQ==", + }, headers={"X-Publisher-ID": str(publisher_uuid_2)}, ) @@ -500,7 +545,10 @@ def test_create_device_publisher_not_found() -> None: response = client.post( "/devices", - json={"external_id": "test-device-123"}, + json={ + "external_id": "test-device-123", + "public_key": "dGVzdHB1YmxpY2tleQ==", + }, headers={"X-Publisher-ID": str(publisher_uuid)}, ) @@ -531,7 +579,10 @@ def test_create_device_firebase_not_initialized() -> None: response = client.post( "/devices", - json={"external_id": "test-device-123"}, + json={ + "external_id": "test-device-123", + "public_key": "dGVzdHB1YmxpY2tleQ==", + }, headers={ "X-Publisher-ID": str(publisher_uuid), "X-Attestation-Token": "some_token", From 5fa4b4d62e9f25c81231e96bb138c481b0f59d89 Mon Sep 17 00:00:00 2001 From: Felippe Costa Date: Tue, 17 Feb 2026 18:48:03 -0300 Subject: [PATCH 2/3] fix: validate public_key format on device registration --- app/schemas/device.py | 19 ++++++++- tests/integration/test_capture_flow.py | 6 +-- tests/integration/test_device_flow.py | 14 +++---- tests/integration/test_jwks.py | 4 +- tests/test_capture.py | 2 +- tests/test_device.py | 53 +++++++++++++++----------- 6 files changed, 62 insertions(+), 36 deletions(-) diff --git a/app/schemas/device.py b/app/schemas/device.py index 38d80ab..d773d47 100644 --- a/app/schemas/device.py +++ b/app/schemas/device.py @@ -1,4 +1,6 @@ -from pydantic import BaseModel, Field +import base64 + +from pydantic import BaseModel, Field, field_validator from app.schemas.base import APIDatetime, APIResponse @@ -18,6 +20,21 @@ class DeviceCreateRequest(BaseModel): description="Base64-encoded uncompressed EC public key (65 bytes: 0x04 + X + Y) from the device's content-signing key pair", ) + @field_validator("public_key") + @classmethod + def validate_public_key(cls, v: str) -> str: + try: + raw = base64.b64decode(v) + except Exception as e: + raise ValueError("Invalid Base64 encoding") from e + if len(raw) != 65: + raise ValueError( + f"Expected 65 bytes (uncompressed EC point), got {len(raw)}" + ) + if raw[0] != 0x04: + raise ValueError("Expected uncompressed point format (0x04 prefix)") + return v + class DeviceCreateResponse(APIResponse): """Response after successful device creation.""" diff --git a/tests/integration/test_capture_flow.py b/tests/integration/test_capture_flow.py index abd9f6f..fd1009d 100644 --- a/tests/integration/test_capture_flow.py +++ b/tests/integration/test_capture_flow.py @@ -28,7 +28,7 @@ def registered_device( "/devices", json={ "external_id": "capture-test-device", - "public_key": "dGVzdHB1YmxpY2tleQ==", + "public_key": "BAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0+P0A=", }, headers={"X-Publisher-ID": publisher_id}, ) @@ -198,7 +198,7 @@ def test_multiple_devices_isolated( "/devices", json={ "external_id": "multi-device-001", - "public_key": "dGVzdHB1YmxpY2tleQ==", + "public_key": "BAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0+P0A=", }, headers={"X-Publisher-ID": publisher_id}, ).json() @@ -206,7 +206,7 @@ def test_multiple_devices_isolated( "/devices", json={ "external_id": "multi-device-002", - "public_key": "dGVzdHB1YmxpY2tleQ==", + "public_key": "BAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0+P0A=", }, headers={"X-Publisher-ID": publisher_id}, ).json() diff --git a/tests/integration/test_device_flow.py b/tests/integration/test_device_flow.py index 515f0e9..d491946 100644 --- a/tests/integration/test_device_flow.py +++ b/tests/integration/test_device_flow.py @@ -28,7 +28,7 @@ def test_create_device_success( "/devices", json={ "external_id": "test-device-001", - "public_key": "dGVzdHB1YmxpY2tleQ==", + "public_key": "BAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0+P0A=", }, headers={"X-Publisher-ID": publisher_id}, ) @@ -51,7 +51,7 @@ def test_create_device_duplicate_fails( "/devices", json={ "external_id": "duplicate-device", - "public_key": "dGVzdHB1YmxpY2tleQ==", + "public_key": "BAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0+P0A=", }, headers={"X-Publisher-ID": publisher_id}, ) @@ -62,7 +62,7 @@ def test_create_device_duplicate_fails( "/devices", json={ "external_id": "duplicate-device", - "public_key": "dGVzdHB1YmxpY2tleQ==", + "public_key": "BAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0+P0A=", }, headers={"X-Publisher-ID": publisher_id}, ) @@ -78,7 +78,7 @@ def test_device_token_is_valid( "/devices", json={ "external_id": "session-test-device", - "public_key": "dGVzdHB1YmxpY2tleQ==", + "public_key": "BAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0+P0A=", }, headers={"X-Publisher-ID": publisher_id}, ) @@ -121,7 +121,7 @@ def test_same_external_id_different_publishers( "/devices", json={ "external_id": shared_external_id, - "public_key": "dGVzdHB1YmxpY2tleQ==", + "public_key": "BAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0+P0A=", }, headers={"X-Publisher-ID": publisher_id_1}, ) @@ -132,7 +132,7 @@ def test_same_external_id_different_publishers( "/devices", json={ "external_id": shared_external_id, - "public_key": "dGVzdHB1YmxpY2tleQ==", + "public_key": "BAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0+P0A=", }, headers={"X-Publisher-ID": publisher_id_2}, ) @@ -293,7 +293,7 @@ def test_device_created_at_format(self, integration_client: TestClient) -> None: "/devices", json={ "external_id": "datetime-test-device", - "public_key": "dGVzdHB1YmxpY2tleQ==", + "public_key": "BAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0+P0A=", }, headers={"X-Publisher-ID": publisher_id}, ) diff --git a/tests/integration/test_jwks.py b/tests/integration/test_jwks.py index 93c4aba..c783559 100644 --- a/tests/integration/test_jwks.py +++ b/tests/integration/test_jwks.py @@ -38,7 +38,7 @@ def test_trust_token_has_kid_matching_jwks( "/devices", json={ "external_id": "jwks-test-device", - "public_key": "dGVzdHB1YmxpY2tleQ==", + "public_key": "BAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0+P0A=", }, headers={"X-Publisher-ID": publisher_id}, ) @@ -85,7 +85,7 @@ def test_trust_token_can_be_verified_with_jwks( "/devices", json={ "external_id": "jwks-verify-device", - "public_key": "dGVzdHB1YmxpY2tleQ==", + "public_key": "BAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0+P0A=", }, headers={"X-Publisher-ID": publisher_id}, ) diff --git a/tests/test_capture.py b/tests/test_capture.py index 20a0b99..75cce21 100644 --- a/tests/test_capture.py +++ b/tests/test_capture.py @@ -25,7 +25,7 @@ def test_create_session_success() -> None: token_hash="hashed_token", created_at=datetime.now(UTC), attestation_method=None, # No attestation (sandbox mode) - public_key="dGVzdHB1YmxpY2tleQ==", + public_key="BAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0+P0A=", device_public_key_fingerprint="a" * 64, ) diff --git a/tests/test_device.py b/tests/test_device.py index 055d27d..1895d60 100644 --- a/tests/test_device.py +++ b/tests/test_device.py @@ -61,7 +61,7 @@ def test_create_device_success_sandbox_no_token() -> None: external_id="test-device-123", token_hash="hashed_token", created_at=datetime.now(UTC), - public_key="dGVzdHB1YmxpY2tleQ==", + public_key="BAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0+P0A=", device_public_key_fingerprint="a" * 64, ) mock_publisher = _mock_sandbox_publisher(publisher_uuid) @@ -84,7 +84,7 @@ def test_create_device_success_sandbox_no_token() -> None: "/devices", json={ "external_id": "test-device-123", - "public_key": "dGVzdHB1YmxpY2tleQ==", + "public_key": "BAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0+P0A=", }, headers={"X-Publisher-ID": str(publisher_uuid)}, ) @@ -117,7 +117,7 @@ def test_create_device_sandbox_no_provider_rejects_token() -> None: "/devices", json={ "external_id": "test-device-123", - "public_key": "dGVzdHB1YmxpY2tleQ==", + "public_key": "BAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0+P0A=", }, headers={ "X-Publisher-ID": str(publisher_uuid), @@ -139,7 +139,7 @@ def test_create_device_sandbox_with_provider_no_token() -> None: external_id="test-device-123", token_hash="hashed_token", created_at=datetime.now(UTC), - public_key="dGVzdHB1YmxpY2tleQ==", + public_key="BAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0+P0A=", device_public_key_fingerprint="a" * 64, ) mock_publisher = _mock_sandbox_publisher_with_provider(publisher_uuid) @@ -162,7 +162,7 @@ def test_create_device_sandbox_with_provider_no_token() -> None: "/devices", json={ "external_id": "test-device-123", - "public_key": "dGVzdHB1YmxpY2tleQ==", + "public_key": "BAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0+P0A=", }, headers={"X-Publisher-ID": str(publisher_uuid)}, ) @@ -182,7 +182,7 @@ def test_create_device_sandbox_with_provider_validates_token() -> None: external_id="test-device-123", token_hash="hashed_token", created_at=datetime.now(UTC), - public_key="dGVzdHB1YmxpY2tleQ==", + public_key="BAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0+P0A=", device_public_key_fingerprint="a" * 64, ) mock_publisher = _mock_sandbox_publisher_with_provider(publisher_uuid) @@ -212,7 +212,7 @@ def test_create_device_sandbox_with_provider_validates_token() -> None: "/devices", json={ "external_id": "test-device-123", - "public_key": "dGVzdHB1YmxpY2tleQ==", + "public_key": "BAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0+P0A=", }, headers={ "X-Publisher-ID": str(publisher_uuid), @@ -245,7 +245,7 @@ def test_create_device_non_sandbox_requires_token() -> None: "/devices", json={ "external_id": "test-device-123", - "public_key": "dGVzdHB1YmxpY2tleQ==", + "public_key": "BAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0+P0A=", }, headers={"X-Publisher-ID": str(publisher_uuid)}, ) @@ -266,7 +266,7 @@ def test_create_device_non_sandbox_with_valid_token() -> None: external_id="test-device-123", token_hash="hashed_token", created_at=datetime.now(UTC), - public_key="dGVzdHB1YmxpY2tleQ==", + public_key="BAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0+P0A=", device_public_key_fingerprint="a" * 64, ) mock_publisher = _mock_production_publisher(publisher_uuid) @@ -296,7 +296,7 @@ def test_create_device_non_sandbox_with_valid_token() -> None: "/devices", json={ "external_id": "test-device-123", - "public_key": "dGVzdHB1YmxpY2tleQ==", + "public_key": "BAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0+P0A=", }, headers={ "X-Publisher-ID": str(publisher_uuid), @@ -338,7 +338,7 @@ def test_create_device_non_sandbox_no_provider_invalid_config() -> None: "/devices", json={ "external_id": "test-device-123", - "public_key": "dGVzdHB1YmxpY2tleQ==", + "public_key": "BAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0+P0A=", }, headers={"X-Publisher-ID": str(publisher_uuid)}, ) @@ -372,7 +372,7 @@ def test_create_device_non_sandbox_no_provider_with_token_invalid_config() -> No "/devices", json={ "external_id": "test-device-123", - "public_key": "dGVzdHB1YmxpY2tleQ==", + "public_key": "BAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0+P0A=", }, headers={ "X-Publisher-ID": str(publisher_uuid), @@ -409,7 +409,7 @@ def test_create_device_already_exists() -> None: "/devices", json={ "external_id": "existing-device", - "public_key": "dGVzdHB1YmxpY2tleQ==", + "public_key": "BAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0+P0A=", }, headers={"X-Publisher-ID": str(publisher_uuid)}, ) @@ -422,7 +422,10 @@ def test_create_device_missing_publisher_id() -> None: """Return 422 when X-Publisher-ID header is missing.""" response = client.post( "/devices", - json={"external_id": "test-device-123", "public_key": "dGVzdHB1YmxpY2tleQ=="}, + json={ + "external_id": "test-device-123", + "public_key": "BAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0+P0A=", + }, ) assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT @@ -432,7 +435,10 @@ def test_create_device_invalid_publisher_id() -> None: """Return 400 when X-Publisher-ID is not a valid UUID.""" response = client.post( "/devices", - json={"external_id": "test-device-123", "public_key": "dGVzdHB1YmxpY2tleQ=="}, + json={ + "external_id": "test-device-123", + "public_key": "BAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0+P0A=", + }, headers={"X-Publisher-ID": "not-a-uuid"}, ) @@ -445,7 +451,10 @@ def test_create_device_empty_external_id() -> None: publisher_uuid = uuid.uuid4() response = client.post( "/devices", - json={"external_id": "", "public_key": "dGVzdHB1YmxpY2tleQ=="}, + json={ + "external_id": "", + "public_key": "BAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0+P0A=", + }, headers={"X-Publisher-ID": str(publisher_uuid)}, ) @@ -466,7 +475,7 @@ def test_create_device_same_external_id_different_publishers() -> None: external_id=shared_external_id, token_hash="hashed_token_1", created_at=datetime.now(UTC), - public_key="dGVzdHB1YmxpY2tleQ==", + public_key="BAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0+P0A=", device_public_key_fingerprint="a" * 64, ) @@ -476,7 +485,7 @@ def test_create_device_same_external_id_different_publishers() -> None: external_id=shared_external_id, token_hash="hashed_token_2", created_at=datetime.now(UTC), - public_key="dGVzdHB1YmxpY2tleQ==", + public_key="BAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0+P0A=", device_public_key_fingerprint="a" * 64, ) @@ -500,7 +509,7 @@ def test_create_device_same_external_id_different_publishers() -> None: "/devices", json={ "external_id": shared_external_id, - "public_key": "dGVzdHB1YmxpY2tleQ==", + "public_key": "BAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0+P0A=", }, headers={"X-Publisher-ID": str(publisher_uuid_1)}, ) @@ -512,7 +521,7 @@ def test_create_device_same_external_id_different_publishers() -> None: "/devices", json={ "external_id": shared_external_id, - "public_key": "dGVzdHB1YmxpY2tleQ==", + "public_key": "BAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0+P0A=", }, headers={"X-Publisher-ID": str(publisher_uuid_2)}, ) @@ -547,7 +556,7 @@ def test_create_device_publisher_not_found() -> None: "/devices", json={ "external_id": "test-device-123", - "public_key": "dGVzdHB1YmxpY2tleQ==", + "public_key": "BAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0+P0A=", }, headers={"X-Publisher-ID": str(publisher_uuid)}, ) @@ -581,7 +590,7 @@ def test_create_device_firebase_not_initialized() -> None: "/devices", json={ "external_id": "test-device-123", - "public_key": "dGVzdHB1YmxpY2tleQ==", + "public_key": "BAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0+P0A=", }, headers={ "X-Publisher-ID": str(publisher_uuid), From fb25ac5fcad8e2939539153fc8b61f17a0e77f91 Mon Sep 17 00:00:00 2001 From: Felippe Costa Date: Tue, 17 Feb 2026 19:44:26 -0300 Subject: [PATCH 3/3] chore: bump signedshot validator to 0.1.9 --- uv.lock | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/uv.lock b/uv.lock index e1a813a..ca5e8e9 100644 --- a/uv.lock +++ b/uv.lock @@ -1419,15 +1419,15 @@ wheels = [ [[package]] name = "signedshot" -version = "0.1.4" +version = "0.1.9" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d9/a8/dc649db7c6889d0fa1ccb018319b5f5f064a57b2b8a4a15561a22de557ae/signedshot-0.1.4.tar.gz", hash = "sha256:9d116bd9cd86f5d94af02fd9f399a2f580d182da293ca4e8bb648eeb39319d60", size = 27832, upload-time = "2026-02-05T15:09:16.823Z" } +sdist = { url = "https://files.pythonhosted.org/packages/77/a3/094b204221984bbb8b70f7ae55f3ab560ed43ddaf6636b4772e55b3a45fa/signedshot-0.1.9.tar.gz", hash = "sha256:6b1e601d8e49dfc2c4ab2da34f952f255f519359f05ddddd8123b39c573360ae", size = 36545, upload-time = "2026-02-17T22:41:51.837Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f6/d6/d3c316ef85b829179db78b7c47a2e01b7cb083f7e7780439dca2225389b1/signedshot-0.1.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9e65890ad013cc885c6311c05dae10afb72454366de2c514e0a54b07cceb68bd", size = 2008603, upload-time = "2026-02-05T15:09:06.77Z" }, - { url = "https://files.pythonhosted.org/packages/e3/cc/4a6634d981eb4ef5905c60133e689eba8d06d614fb05f1e24fa06103e679/signedshot-0.1.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:895412f5617b0184f4a6bf5be3278d0535611ee6bb0f2ccf96abaa1324569e94", size = 1948153, upload-time = "2026-02-05T15:09:08.789Z" }, - { url = "https://files.pythonhosted.org/packages/c3/36/40515ea67a8d4f93178cd73c1fe8b975834530743eaf19bf5268e0cb27f7/signedshot-0.1.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eede6887bdd04557a91cf628a53363143f88c8c0849f8c511ed814c65e80ca7b", size = 2161205, upload-time = "2026-02-05T15:09:10.938Z" }, - { url = "https://files.pythonhosted.org/packages/cd/7c/86bacfc112de6c57a47f925b3259d865d0ee44fff79db1b19d3eb42388c4/signedshot-0.1.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0cbf68b0fb6ddad1c7ba3f529c16e694efaa48162e1709d0931affb396beecb2", size = 2183687, upload-time = "2026-02-05T15:09:13.463Z" }, - { url = "https://files.pythonhosted.org/packages/78/ad/5debeab3aa80678e4b693729a0e2d62febcf00ee5b6092f8e3d0038e4ad8/signedshot-0.1.4-cp312-cp312-win_amd64.whl", hash = "sha256:47bfd6a6b36c5b189591eed56d4fcec6f078ce00f65237c62751331e8200bbc7", size = 1778760, upload-time = "2026-02-05T15:09:14.817Z" }, + { url = "https://files.pythonhosted.org/packages/1c/1f/0c482ab259797cc68db6e1d8f1a6283bd261750ec903e7eee89d07082537/signedshot-0.1.9-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a43200ae6540b0450698e8b1b1bf3ac7831d4cadb0081f4d65694171f0e60598", size = 2414311, upload-time = "2026-02-17T22:41:44.766Z" }, + { url = "https://files.pythonhosted.org/packages/c3/34/fff8079d1093c1022bd7b0f72fb6a024c69f128cc1a6e97d0449a63e0b51/signedshot-0.1.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f843a142d6b3f0a9d571fae32aed531bb30a65dbe7e2401fda4179062de5826b", size = 2315358, upload-time = "2026-02-17T22:41:46.164Z" }, + { url = "https://files.pythonhosted.org/packages/20/9f/dc068349c229540a46e061c3e3a9b5a27a3a6d96cf7834ae8c5725630d83/signedshot-0.1.9-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efcfcdaa353d44c119f33a1229c3960c0ca4ac1d3e11256160d7dd5b651d35b2", size = 2561030, upload-time = "2026-02-17T22:41:47.427Z" }, + { url = "https://files.pythonhosted.org/packages/09/df/26e15ffe6ff55668db082f02c66af644aa26cba535b6cacbcd2ec3014602/signedshot-0.1.9-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c2937f1a115a20d4fcbbdd258a14564581f006bdc62c8ab184424e9e727eeff", size = 2590163, upload-time = "2026-02-17T22:41:48.94Z" }, + { url = "https://files.pythonhosted.org/packages/50/56/be757ba9ced73e845f5244559d8cbd4867466e520e1eb7e2372d3ca377da/signedshot-0.1.9-cp312-cp312-win_amd64.whl", hash = "sha256:a4d14a2b7cc51d38751ab58ce0c071dc07d9b6da70cace08396881122aa18fd6", size = 2156103, upload-time = "2026-02-17T22:41:50.439Z" }, ] [[package]]