233 lines
8.5 KiB
Python
233 lines
8.5 KiB
Python
"""HMAC-authenticated result envelopes for untrusted remote callbacks/final content."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import hmac
|
|
import json
|
|
import secrets
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timezone
|
|
from typing import Any, Callable, Dict, Mapping, Optional, Tuple
|
|
|
|
from arr_ingestion.contracts import OPAQUE_ID_RE, SHA256_RE
|
|
from arr_processing.contracts import ProcessingResult, canonical_json_bytes
|
|
from arr_processing.errors import ProcessingError
|
|
|
|
|
|
SIGNED_ENVELOPE_VERSION = "arr-processing-signed-1"
|
|
SIGNATURE_FIELDS = {
|
|
"algorithm",
|
|
"key_id",
|
|
"issued_at",
|
|
"nonce",
|
|
"value",
|
|
}
|
|
SIGNED_FIELDS = {"signed_envelope_version", "payload", "signature"}
|
|
MAX_SIGNED_RESULT_BYTES = 512 * 1024
|
|
|
|
|
|
def _duplicate_rejecting_object(pairs: list[Tuple[str, Any]]) -> Dict[str, Any]:
|
|
value: Dict[str, Any] = {}
|
|
for key, item in pairs:
|
|
if key in value:
|
|
raise ValueError("duplicate JSON key")
|
|
value[key] = item
|
|
return value
|
|
|
|
|
|
def _reject_constant(_value: str) -> None:
|
|
raise ValueError("non-finite JSON number")
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class VerifiedSignedResult:
|
|
result: ProcessingResult
|
|
signed_sha256: str
|
|
key_id: str
|
|
issued_at: datetime
|
|
nonce: str
|
|
|
|
|
|
class SignedResultCodec:
|
|
"""Sign and verify canonical result payloads without exposing the shared secret."""
|
|
|
|
def __init__(
|
|
self,
|
|
keys: Mapping[str, bytes],
|
|
*,
|
|
signing_key_id: Optional[str] = None,
|
|
max_age_seconds: int = 600,
|
|
future_skew_seconds: int = 30,
|
|
clock: Optional[Callable[[], datetime]] = None,
|
|
) -> None:
|
|
normalized: Dict[str, bytes] = {}
|
|
for key_id, secret in keys.items():
|
|
if (
|
|
not isinstance(key_id, str)
|
|
or not OPAQUE_ID_RE.fullmatch(key_id)
|
|
or not isinstance(secret, bytes)
|
|
or len(secret) < 32
|
|
):
|
|
raise ValueError("result signing key configuration is invalid")
|
|
normalized[key_id] = bytes(secret)
|
|
if not normalized:
|
|
raise ValueError("at least one result signing key is required")
|
|
if signing_key_id is not None and signing_key_id not in normalized:
|
|
raise ValueError("signing key identifier is unavailable")
|
|
if max_age_seconds < 1 or future_skew_seconds < 0:
|
|
raise ValueError("signature age policy is invalid")
|
|
self._keys = normalized
|
|
self._signing_key_id = signing_key_id
|
|
self._max_age_seconds = max_age_seconds
|
|
self._future_skew_seconds = future_skew_seconds
|
|
self._clock = clock or (lambda: datetime.now(timezone.utc))
|
|
|
|
def sign(
|
|
self,
|
|
result: ProcessingResult,
|
|
*,
|
|
issued_at: Optional[datetime] = None,
|
|
nonce: Optional[str] = None,
|
|
) -> bytes:
|
|
if self._signing_key_id is None:
|
|
raise ValueError("codec is verify-only")
|
|
validated_result = ProcessingResult.from_dict(result.to_dict())
|
|
if validated_result != result:
|
|
raise ValueError("processing result is not canonical")
|
|
timestamp = self._normalize_time(issued_at or self._clock())
|
|
nonce_value = nonce or ("nonce_" + secrets.token_urlsafe(18))
|
|
if not OPAQUE_ID_RE.fullmatch(nonce_value):
|
|
raise ValueError("signature nonce is invalid")
|
|
issued_text = timestamp.isoformat(timespec="seconds").replace("+00:00", "Z")
|
|
payload = validated_result.to_dict()
|
|
value = self._signature_value(
|
|
self._keys[self._signing_key_id],
|
|
payload,
|
|
issued_text,
|
|
nonce_value,
|
|
)
|
|
return canonical_json_bytes(
|
|
{
|
|
"signed_envelope_version": SIGNED_ENVELOPE_VERSION,
|
|
"payload": payload,
|
|
"signature": {
|
|
"algorithm": "hmac-sha256",
|
|
"key_id": self._signing_key_id,
|
|
"issued_at": issued_text,
|
|
"nonce": nonce_value,
|
|
"value": value,
|
|
},
|
|
}
|
|
)
|
|
|
|
def verify(self, raw: bytes) -> VerifiedSignedResult:
|
|
if not isinstance(raw, bytes) or not raw or len(raw) > MAX_SIGNED_RESULT_BYTES:
|
|
raise ProcessingError(
|
|
"PROCESSING_SIGNATURE_INVALID", "signed processing result is invalid"
|
|
)
|
|
try:
|
|
text = raw.decode("utf-8", errors="strict")
|
|
parsed = json.loads(
|
|
text,
|
|
object_pairs_hook=_duplicate_rejecting_object,
|
|
parse_constant=_reject_constant,
|
|
)
|
|
except (UnicodeError, ValueError, json.JSONDecodeError):
|
|
raise ProcessingError(
|
|
"PROCESSING_SIGNATURE_INVALID", "signed processing result is invalid"
|
|
) from None
|
|
if not isinstance(parsed, Mapping) or set(parsed) != SIGNED_FIELDS:
|
|
raise ProcessingError(
|
|
"PROCESSING_SIGNATURE_INVALID", "signed processing result is invalid"
|
|
)
|
|
if parsed.get("signed_envelope_version") != SIGNED_ENVELOPE_VERSION:
|
|
raise ProcessingError(
|
|
"PROCESSING_SIGNATURE_INVALID", "signed envelope version is unsupported"
|
|
)
|
|
signature = parsed.get("signature")
|
|
if not isinstance(signature, Mapping) or set(signature) != SIGNATURE_FIELDS:
|
|
raise ProcessingError(
|
|
"PROCESSING_SIGNATURE_INVALID", "processing result signature is invalid"
|
|
)
|
|
algorithm = signature.get("algorithm")
|
|
key_id = signature.get("key_id")
|
|
issued_text = signature.get("issued_at")
|
|
nonce = signature.get("nonce")
|
|
value = signature.get("value")
|
|
if (
|
|
algorithm != "hmac-sha256"
|
|
or not isinstance(key_id, str)
|
|
or key_id not in self._keys
|
|
or not isinstance(issued_text, str)
|
|
or not isinstance(nonce, str)
|
|
or not OPAQUE_ID_RE.fullmatch(nonce)
|
|
or not isinstance(value, str)
|
|
or not SHA256_RE.fullmatch(value)
|
|
):
|
|
raise ProcessingError(
|
|
"PROCESSING_SIGNATURE_INVALID", "processing result signature is invalid"
|
|
)
|
|
issued_at = self._parse_time(issued_text)
|
|
now = self._normalize_time(self._clock())
|
|
age = (now - issued_at).total_seconds()
|
|
if age > self._max_age_seconds or age < -self._future_skew_seconds:
|
|
raise ProcessingError(
|
|
"PROCESSING_SIGNATURE_EXPIRED", "processing result signature is outside its window"
|
|
)
|
|
result = ProcessingResult.from_dict(parsed.get("payload"))
|
|
expected = self._signature_value(
|
|
self._keys[key_id],
|
|
result.to_dict(),
|
|
issued_text,
|
|
nonce,
|
|
)
|
|
if not hmac.compare_digest(value, expected):
|
|
raise ProcessingError(
|
|
"PROCESSING_SIGNATURE_INVALID", "processing result signature is invalid"
|
|
)
|
|
return VerifiedSignedResult(
|
|
result=result,
|
|
signed_sha256=hashlib.sha256(raw).hexdigest(),
|
|
key_id=key_id,
|
|
issued_at=issued_at,
|
|
nonce=nonce,
|
|
)
|
|
|
|
@staticmethod
|
|
def _signature_value(
|
|
secret: bytes,
|
|
payload: Mapping[str, Any],
|
|
issued_at: str,
|
|
nonce: str,
|
|
) -> str:
|
|
signed = b"\n".join(
|
|
(
|
|
SIGNED_ENVELOPE_VERSION.encode("ascii"),
|
|
issued_at.encode("ascii"),
|
|
nonce.encode("ascii"),
|
|
canonical_json_bytes(payload),
|
|
)
|
|
)
|
|
return hmac.new(secret, signed, hashlib.sha256).hexdigest()
|
|
|
|
@staticmethod
|
|
def _parse_time(value: str) -> datetime:
|
|
try:
|
|
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
except ValueError:
|
|
raise ProcessingError(
|
|
"PROCESSING_SIGNATURE_INVALID", "processing result signature time is invalid"
|
|
) from None
|
|
if parsed.tzinfo is None or parsed.utcoffset() is None:
|
|
raise ProcessingError(
|
|
"PROCESSING_SIGNATURE_INVALID", "processing result signature time is invalid"
|
|
)
|
|
return parsed.astimezone(timezone.utc)
|
|
|
|
@staticmethod
|
|
def _normalize_time(value: datetime) -> datetime:
|
|
if value.tzinfo is None or value.utcoffset() is None:
|
|
raise ValueError("signature clock must return a timezone-aware datetime")
|
|
return value.astimezone(timezone.utc)
|