feat: prepare ARR for controlled public deployment
This commit is contained in:
232
arr_processing/signatures.py
Normal file
232
arr_processing/signatures.py
Normal file
@@ -0,0 +1,232 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user