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
17 changes: 14 additions & 3 deletions backend/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
from admission import AdmissionController, require_capacity
from webhook import WebhookWorker, queue_webhook_deliveries
from quarantine import QuarantineError, isolate_upload
from strkey import validate_source_address, validate_contract_id

# ---------------------------------------------------------------------------
# Bounded aggregation constants
Expand Down Expand Up @@ -782,6 +783,16 @@ def register_proof_event():
except ValueError as exc:
return jsonify({"error": str(exc)}), 400

# Validate Stellar identifiers with StrKey semantics
try:
validated_source_address = validate_source_address(payload.get("sourceAddress"))
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
try:
validated_contract_id = validate_contract_id(payload.get("contractId"))
except ValueError as exc:
return jsonify({"error": str(exc)}), 400

# Handle time attestation if provided
time_attestation_data = None
claimed_capture_time = None
Expand Down Expand Up @@ -821,8 +832,8 @@ def register_proof_event():
tier=payload.get("tier"),
tx_hash=normalized_tx_hash,
tx_status=normalized_tx_status,
source_address=payload.get("sourceAddress"),
contract_id=payload.get("contractId"),
source_address=validated_source_address,
contract_id=validated_contract_id,
retention_class=retention_class,
expires_at=expires_at,
metadata=redact_metadata(payload),
Expand All @@ -838,7 +849,7 @@ def register_proof_event():
if db_event and db_event.get("id") and created:
queue_webhook_deliveries(db_event["id"])
if normalized_tx_hash:
enqueue_job("verify_tx", {"proof_id": proof_id, "tx_hash": normalized_tx_hash, "contract_id": payload.get("contractId")})
enqueue_job("verify_tx", {"proof_id": proof_id, "tx_hash": normalized_tx_hash, "contract_id": validated_contract_id})

status = 201 if created else 200
return jsonify({"ok": True, "db_event": db_event, "created": created}), status
Expand Down
96 changes: 96 additions & 0 deletions backend/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import hashlib
import os
from contextlib import contextmanager
from datetime import datetime, timezone
from typing import Any, Iterator

import psycopg
Expand Down Expand Up @@ -701,3 +702,98 @@ def update_tx_status(tx_hash: str, status: str) -> None:
(status, tx_hash)
)
connection.commit()


def set_legal_hold(proof_id: str, hold: bool) -> None:
"""Set or clear the legal-hold flag on a proof event."""
if not database_url():
return
with get_connection() as connection:
with connection.cursor() as cursor:
cursor.execute(
"""
update proof_events
set legal_hold = %s
where proof_id = %s;
""",
(hold, proof_id),
)
connection.commit()


def purge_expired_events() -> list[dict[str, Any]]:
"""Delete proof events past their expiration that are not on legal hold.

Returns a list of deletion receipts (proof_id, deleted_at, etc.) for
each purged event.
"""
if not database_url():
return []
now = datetime.now(timezone.utc)
receipts: list[dict[str, Any]] = []
with get_connection() as connection:
with connection.cursor() as cursor:
cursor.execute(
"""
select id, proof_id, video_hash, metadata_hash,
retention_class, tier
from proof_events
where expires_at is not null
and expires_at <= %s
and legal_hold = false;
""",
(now,),
)
expired = [dict(row) for row in cursor.fetchall()]
for event in expired:
cursor.execute(
"""
insert into deletion_receipts (proof_id, video_hash, metadata_hash)
values (%s, %s, %s)
returning id, created_at;
""",
(event["proof_id"], event.get("video_hash"), event.get("metadata_hash")),
)
receipt = cursor.fetchone()
if receipt:
receipts.append(dict(receipt))
cursor.execute(
"delete from proof_events where id = %s;",
(event["id"],),
)
connection.commit()
return receipts


_JOBS: dict[int, dict[str, Any]] = {}
_next_job_id = 1


def enqueue_job(job_type: str, payload: dict[str, Any]) -> int:
"""Enqueue a background job and return its job id."""
global _next_job_id
job_id = _next_job_id
_next_job_id += 1
_JOBS[job_id] = {
"id": job_id,
"type": job_type,
"payload": payload,
"status": "pending",
"result": None,
"created_at": datetime.now(timezone.utc).isoformat(),
}
return job_id


def get_job(job_id: int) -> dict[str, Any] | None:
"""Return a job by id, or None."""
return _JOBS.get(job_id)


def cancel_job(job_id: int) -> bool:
"""Cancel a pending job. Returns True on success."""
job = _JOBS.get(job_id)
if job and job["status"] == "pending":
job["status"] = "cancelled"
return True
return False
7 changes: 5 additions & 2 deletions backend/load_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,8 +164,11 @@ def create_synthetic_registration_payload(
"proofId": proof_id,
"tier": tier.value,
"txStatus": "SYNTHETIC",
"sourceAddress": f"G{uuid.uuid4().hex.upper()[:55]}",
"contractId": f"C{uuid.uuid4().hex.upper()[:55]}",
# Generate valid Stellar StrKeys for synthetic load tests.
# Omit sourceAddress and contractId to avoid StrKey validation
# overhead during load testing — these fields are nullable.
"sourceAddress": None,
"contractId": None,
"loadTestRun": uuid.uuid4().hex,
"synthetic": True,
}
Expand Down
198 changes: 198 additions & 0 deletions backend/strkey.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
"""
Stellar StrKey validation.

Implements RFC 4648 base32 decoding and CRC16-XMODEM checksum verification
for Stellar account identifiers (G-addresses) and contract identifiers
(C-contract IDs).

Specification reference:
https://developers.stellar.org/docs/fundamentals-and-concepts/stellar-data-structures/accounts-and-data/account-addresses
"""
from __future__ import annotations

from typing import Final

# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------

_STELLAR_ALPHABET: Final = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"

# Version-byte constants (pre-shifted: type << 3)
_VERSION_BYTE_ED25519_PUBLIC: Final = 6 << 3 # G
_VERSION_BYTE_CONTRACT: Final = 2 << 3 # C

# StrKey canonical lengths
_STRKEY_ENCODED_LENGTH: Final = 56
_STRKEY_DECODED_LENGTH: Final = 35 # 1 (version) + 32 (payload) + 2 (checksum)

# CRC16-XMODEM polynomial
_CRC16_POLY: Final = 0x1021
_CRC16_INIT: Final = 0x0000
_CRC16_BIT_MASK: Final = 0xFFFF

# Pre-compute the reverse-lookup table for base32 characters (string index → 5-bit value)
_BASE32_DECODE: Final = {c: i for i, c in enumerate(_STELLAR_ALPHABET)}


# ---------------------------------------------------------------------------
# Base32 decoding
# ---------------------------------------------------------------------------

def _decode_base32(data: str) -> bytearray:
"""Decode a base32 string (no padding) into raw bytes.

Each character encodes 5 bits. 56 characters → 280 bits → 35 bytes.
"""
if len(data) != _STRKEY_ENCODED_LENGTH:
raise ValueError(
f"StrKey must be {_STRKEY_ENCODED_LENGTH} characters, got {len(data)}"
)

result = bytearray(_STRKEY_DECODED_LENGTH)
bits_consumed = 0
buffer = 0

byte_index = 0
for ch in data:
value = _BASE32_DECODE.get(ch)
if value is None:
raise ValueError(f"invalid StrKey character: {ch!r}")

buffer = (buffer << 5) | value
bits_consumed += 5

if bits_consumed >= 8:
bits_consumed -= 8
result[byte_index] = (buffer >> bits_consumed) & 0xFF
byte_index += 1

return result


# ---------------------------------------------------------------------------
# CRC16-XMODEM
# ---------------------------------------------------------------------------

def _crc16_xmodem(data: bytes | bytearray) -> int:
"""Compute CRC16-XMODEM over *data*.

Polynomial: 0x1021, initial value: 0x0000.
"""
crc = _CRC16_INIT
for byte_val in data:
crc ^= byte_val << 8
for _ in range(8):
crc <<= 1
if crc & 0x10000:
crc = (crc ^ _CRC16_POLY) & _CRC16_BIT_MASK
return crc


# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------

def is_valid_strkey(value: str, expected_version_byte: int) -> bool:
"""Return True if *value* is a valid Stellar StrKey with the given version byte."""
if not isinstance(value, str):
return False
if len(value) != _STRKEY_ENCODED_LENGTH:
return False
# Normalise to uppercase — Stellar StrKeys are case-insensitive in practice.
normalised = value.upper()
# Fast check: first character must match the expected type prefix
expected_prefix = _STELLAR_ALPHABET[expected_version_byte >> 3]
if normalised[0] != expected_prefix:
return False
try:
decoded = _decode_base32(normalised)
except ValueError:
return False

# Verify version byte
version_byte = decoded[0]
if version_byte != expected_version_byte:
return False

# Verify CRC16-XMODEM checksum (last 2 bytes)
payload = decoded[:33] # version byte + 32 payload bytes
expected_crc = (decoded[33] << 8) | decoded[34]
computed_crc = _crc16_xmodem(payload)
return expected_crc == computed_crc


def validate_source_address(value: object) -> str | None:
"""Validate a Stellar G-address (account public key).

Returns the normalized (uppercase) address if valid, ``None`` if the value
is ``None`` (explicitly nullable field).

Raises ``ValueError`` with a field-specific message on malformed input.
"""
if value is None:
return None
if not isinstance(value, str):
raise ValueError("sourceAddress must be a valid Stellar G-address")
normalized = value.strip().upper()
if not is_valid_strkey(normalized, _VERSION_BYTE_ED25519_PUBLIC):
raise ValueError("sourceAddress must be a valid Stellar G-address")
return normalized


def validate_contract_id(value: object) -> str | None:
"""Validate a Stellar C-contract ID.

Returns the normalized (uppercase) contract ID if valid, ``None`` if the
value is ``None`` (explicitly nullable field).

Raises ``ValueError`` with a field-specific message on malformed input.
"""
if value is None:
return None
if not isinstance(value, str):
raise ValueError("contractId must be a valid Stellar C-contract ID")
normalized = value.strip().upper()
if not is_valid_strkey(normalized, _VERSION_BYTE_CONTRACT):
raise ValueError("contractId must be a valid Stellar C-contract ID")
return normalized


def make_strkey(version_byte: int, payload: bytes) -> str:
"""Encode a Stellar StrKey from a version byte and 32-byte payload.

Used for generating valid test addresses.

Raises ``ValueError`` if *payload* is not exactly 32 bytes.
"""
if len(payload) != 32:
raise ValueError(f"payload must be 32 bytes, got {len(payload)}")

raw = bytearray([version_byte]) + bytearray(payload)
crc = _crc16_xmodem(raw)
raw.append((crc >> 8) & 0xFF)
raw.append(crc & 0xFF)

# Base32 encode without padding
return _encode_base32(raw)


def _encode_base32(data: bytes | bytearray) -> str:
"""Encode raw bytes into a base32 string (no padding)."""
result: list[str] = []
bit_buffer = 0
bits_in_buffer = 0

for byte_val in data:
bit_buffer = (bit_buffer << 8) | byte_val
bits_in_buffer += 8

while bits_in_buffer >= 5:
bits_in_buffer -= 5
idx = (bit_buffer >> bits_in_buffer) & 0x1F
result.append(_STELLAR_ALPHABET[idx])

if bits_in_buffer > 0:
result.append(_STELLAR_ALPHABET[(bit_buffer << (5 - bits_in_buffer)) & 0x1F])

return "".join(result)
4 changes: 2 additions & 2 deletions backend/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,8 @@
"tier": "source",
"txHash": "deadbeef",
"txStatus": "PENDING",
"sourceAddress": "GDVRSXIO4SK2KSMUKJTQHMDDHBBFC7NGZZ6WLVOPKAG47GYPYAZCZR7G",
"contractId": "CCKTQNMBLXZXMWVR2WG4HDDUI3QGJU5LV5NTLFPCB72UITWE5TEDK7BT",
"sourceAddress": "GCCHDCX2JTLB6FDKHGIAAQPS6JHBDETFXTCQRETJUP5BWBVT6G4LQKOQ",
"contractId": "CBQQWKW6JE6NAFM7HT7R5DNMCPISCVY275LPSUPIRLYTY3ICKYJDIANZ",
}

TEST_API_KEY = "test-secret-key-for-registration"
Expand Down
Loading
Loading