Skip to content

Commit c8305c5

Browse files
committed
feat(verifier): add local Ed25519 attestation verifier
1 parent aaebea5 commit c8305c5

2 files changed

Lines changed: 333 additions & 0 deletions

File tree

‎streamline_sdk/attestation.py‎

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
"""Attestation client (M4 wiring).
2+
3+
Wraps ``POST /api/v1/attest`` and ``POST /api/v1/attest/verify`` for
4+
producing and verifying Ed25519 envelope signatures over Kafka records.
5+
6+
Example:
7+
attestor = Attestor("http://localhost:9094", key_id="broker-0")
8+
sig = await attestor.sign(
9+
topic="orders", partition=0, offset=42,
10+
value=b'{"id":1}', schema_id=7,
11+
)
12+
ok = await attestor.verify(
13+
topic="orders", partition=0, offset=42,
14+
value=b'{"id":1}', schema_id=7,
15+
timestamp_ms=sig.timestamp_ms, signature_b64=sig.signature_b64,
16+
)
17+
"""
18+
19+
from __future__ import annotations
20+
21+
import asyncio
22+
import base64
23+
import json
24+
import time
25+
from dataclasses import dataclass
26+
from typing import Any, Optional, Union
27+
28+
try:
29+
import aiohttp
30+
31+
_HAS_AIOHTTP = True
32+
except ImportError: # pragma: no cover
33+
_HAS_AIOHTTP = False
34+
35+
from .exceptions import StreamlineError
36+
37+
ATTEST_HEADER = "streamline-attest"
38+
39+
40+
class AttestationError(StreamlineError):
41+
"""Raised on attestation API failures."""
42+
43+
44+
@dataclass
45+
class SignedAttestation:
46+
"""Result of a successful sign request."""
47+
48+
key_id: str
49+
algorithm: str
50+
timestamp_ms: int
51+
payload_sha256: str
52+
signature_b64: str
53+
header_name: str
54+
header_value: str
55+
56+
@classmethod
57+
def from_json(cls, data: dict[str, Any]) -> "SignedAttestation":
58+
return cls(
59+
key_id=str(data["key_id"]),
60+
algorithm=str(data["algorithm"]),
61+
timestamp_ms=int(data["timestamp_ms"]),
62+
payload_sha256=str(data["payload_sha256"]),
63+
signature_b64=str(data["signature_b64"]),
64+
header_name=str(data["header_name"]),
65+
header_value=str(data["header_value"]),
66+
)
67+
68+
69+
def _encode_value(
70+
value: Union[bytes, bytearray, str],
71+
) -> tuple[str, str]:
72+
"""Return (json_field_name, json_field_value) for the value."""
73+
if isinstance(value, (bytes, bytearray)):
74+
return "value_b64", base64.b64encode(bytes(value)).decode("ascii")
75+
return "value", str(value)
76+
77+
78+
class Attestor:
79+
"""Async client for the broker's attestation sign/verify routes.
80+
81+
Args:
82+
http_url: HTTP base URL.
83+
key_id: Default signing key id used by :meth:`sign`.
84+
algorithm: Default signature algorithm (server currently supports
85+
``ed25519``; ``ecdsa-p256`` may also be accepted by ``verify``).
86+
timeout: Per-request timeout in seconds.
87+
"""
88+
89+
def __init__(
90+
self,
91+
http_url: str = "http://localhost:9094",
92+
key_id: str = "broker-0",
93+
algorithm: str = "ed25519",
94+
timeout: float = 10.0,
95+
) -> None:
96+
self.http_url = http_url.rstrip("/")
97+
self.key_id = key_id
98+
self.algorithm = algorithm
99+
self.timeout = timeout
100+
101+
async def sign(
102+
self,
103+
*,
104+
topic: str,
105+
partition: int,
106+
offset: int,
107+
value: Union[bytes, bytearray, str],
108+
schema_id: int = 0,
109+
timestamp_ms: Optional[int] = None,
110+
key_id: Optional[str] = None,
111+
) -> SignedAttestation:
112+
"""Sign an attestation envelope for a record."""
113+
ts = timestamp_ms if timestamp_ms is not None else int(time.time() * 1000)
114+
body: dict[str, Any] = {
115+
"topic": topic,
116+
"partition": partition,
117+
"offset": offset,
118+
"schema_id": schema_id,
119+
"timestamp_ms": ts,
120+
"key_id": key_id or self.key_id,
121+
}
122+
field, encoded = _encode_value(value)
123+
body[field] = encoded
124+
125+
status, payload = await self._post("/api/v1/attest", body)
126+
if status != 200:
127+
raise AttestationError(
128+
f"sign -> HTTP {status}: {json.dumps(payload)[:512]}"
129+
)
130+
return SignedAttestation.from_json(payload or {})
131+
132+
async def verify(
133+
self,
134+
*,
135+
topic: str,
136+
partition: int,
137+
offset: int,
138+
value: Union[bytes, bytearray, str],
139+
timestamp_ms: int,
140+
signature_b64: str,
141+
schema_id: int = 0,
142+
key_id: Optional[str] = None,
143+
algorithm: Optional[str] = None,
144+
) -> bool:
145+
"""Verify a previously-issued attestation. Returns ``True`` on success."""
146+
body: dict[str, Any] = {
147+
"topic": topic,
148+
"partition": partition,
149+
"offset": offset,
150+
"schema_id": schema_id,
151+
"timestamp_ms": timestamp_ms,
152+
"key_id": key_id or self.key_id,
153+
"signature_b64": signature_b64,
154+
"algorithm": algorithm or self.algorithm,
155+
}
156+
field, encoded = _encode_value(value)
157+
body[field] = encoded
158+
159+
status, payload = await self._post("/api/v1/attest/verify", body)
160+
if status != 200:
161+
raise AttestationError(
162+
f"verify -> HTTP {status}: {json.dumps(payload)[:512]}"
163+
)
164+
return bool((payload or {}).get("valid", False))
165+
166+
async def _post(
167+
self, path: str, body: dict[str, Any]
168+
) -> tuple[int, Any]:
169+
url = f"{self.http_url}{path}"
170+
if _HAS_AIOHTTP:
171+
timeout = aiohttp.ClientTimeout(total=self.timeout)
172+
async with aiohttp.ClientSession(timeout=timeout) as session:
173+
async with session.post(url, json=body) as resp:
174+
text = await resp.text()
175+
payload: Any = None
176+
if text:
177+
try:
178+
payload = json.loads(text)
179+
except json.JSONDecodeError:
180+
payload = {"raw": text}
181+
return resp.status, payload
182+
else:
183+
import urllib.request
184+
import urllib.error
185+
186+
data = json.dumps(body).encode("utf-8")
187+
req = urllib.request.Request(
188+
url,
189+
data=data,
190+
headers={"Content-Type": "application/json"},
191+
method="POST",
192+
)
193+
194+
def _sync() -> tuple[int, Any]:
195+
try:
196+
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
197+
text = resp.read().decode("utf-8")
198+
return resp.status, json.loads(text) if text else None
199+
except urllib.error.HTTPError as e:
200+
text = e.read().decode("utf-8", errors="replace")
201+
try:
202+
return e.code, json.loads(text)
203+
except json.JSONDecodeError:
204+
return e.code, {"raw": text}
205+
206+
return await asyncio.to_thread(_sync)
207+
208+
209+
__all__ = [
210+
"ATTEST_HEADER",
211+
"Attestor",
212+
"AttestationError",
213+
"SignedAttestation",
214+
]

‎streamline_sdk/verifier.py‎

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
"""Local Ed25519 attestation verifier for Streamline consumer records.
2+
3+
Verifies ``streamline-attest`` headers locally using an Ed25519 public key
4+
without any network calls.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
import base64
10+
import json
11+
from dataclasses import dataclass
12+
from typing import Any, Dict, Optional, Union
13+
14+
from cryptography.exceptions import InvalidSignature
15+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
16+
17+
ATTEST_HEADER = "streamline-attest"
18+
19+
20+
@dataclass
21+
class VerificationResult:
22+
"""Result of an attestation verification."""
23+
24+
verified: bool
25+
producer_id: str = ""
26+
schema_id: Optional[int] = None
27+
contract_id: Optional[str] = None
28+
timestamp_ms: int = 0
29+
30+
31+
class StreamlineVerifier:
32+
"""Verifies attestation headers on consumed records using a local Ed25519 public key.
33+
34+
Args:
35+
public_key: An Ed25519 public key used for signature verification.
36+
37+
Example::
38+
39+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
40+
from streamline_sdk.verifier import StreamlineVerifier
41+
42+
pub_key = Ed25519PublicKey.from_public_bytes(key_bytes)
43+
verifier = StreamlineVerifier(pub_key)
44+
result = verifier.verify(record)
45+
if result.verified:
46+
print(f"Verified from {result.producer_id}")
47+
"""
48+
49+
def __init__(self, public_key: Ed25519PublicKey) -> None:
50+
self._public_key = public_key
51+
52+
def verify(self, record: Any) -> VerificationResult:
53+
"""Verify the attestation on a consumer record.
54+
55+
Extracts the ``streamline-attest`` header, parses the base64-encoded
56+
JSON attestation, reconstructs the canonical bytes, and verifies the
57+
Ed25519 signature.
58+
59+
Args:
60+
record: A ConsumerRecord with a ``headers`` dict containing
61+
the ``streamline-attest`` header.
62+
63+
Returns:
64+
VerificationResult with ``verified=True`` if the signature is valid.
65+
"""
66+
headers: Dict[str, Union[bytes, str]] = getattr(record, "headers", {})
67+
raw = headers.get(ATTEST_HEADER)
68+
if raw is None:
69+
return VerificationResult(verified=False)
70+
71+
try:
72+
raw_bytes = raw if isinstance(raw, (bytes, bytearray)) else raw.encode("utf-8")
73+
attestation = json.loads(base64.b64decode(raw_bytes))
74+
except (json.JSONDecodeError, Exception):
75+
return VerificationResult(verified=False)
76+
77+
try:
78+
payload_sha256 = str(attestation["payload_sha256"])
79+
topic = str(attestation["topic"])
80+
partition = int(attestation["partition"])
81+
offset = int(attestation["offset"])
82+
schema_id = int(attestation["schema_id"])
83+
timestamp_ms = int(attestation["timestamp_ms"])
84+
key_id = str(attestation["key_id"])
85+
signature_b64 = str(attestation["signature"])
86+
except (KeyError, ValueError, TypeError):
87+
return VerificationResult(verified=False)
88+
89+
canonical = (
90+
f"{topic}|{partition}|{offset}|{payload_sha256}"
91+
f"|{schema_id}|{timestamp_ms}|{key_id}"
92+
)
93+
canonical_bytes = canonical.encode("utf-8")
94+
95+
try:
96+
signature = base64.b64decode(signature_b64)
97+
except Exception:
98+
return VerificationResult(verified=False)
99+
100+
try:
101+
self._public_key.verify(signature, canonical_bytes)
102+
verified = True
103+
except InvalidSignature:
104+
verified = False
105+
106+
return VerificationResult(
107+
verified=verified,
108+
producer_id=key_id,
109+
schema_id=schema_id if schema_id != 0 else None,
110+
contract_id=attestation.get("contract_id"),
111+
timestamp_ms=timestamp_ms,
112+
)
113+
114+
115+
__all__ = [
116+
"ATTEST_HEADER",
117+
"StreamlineVerifier",
118+
"VerificationResult",
119+
]

0 commit comments

Comments
 (0)