johnbridges commited on
Commit
88ea3d4
·
1 Parent(s): d354c17

swtich to using hmac message signing

Browse files
Files changed (4) hide show
  1. appsettings.json +1 -0
  2. gradllm_hmac.py +60 -0
  3. oa_server.py +18 -3
  4. test_gradllm_hmac.py +41 -0
appsettings.json CHANGED
@@ -1,4 +1,5 @@
1
  {
 
2
  "Logging": {
3
  "LogLevel": {
4
  "Default": "Debug",
 
1
  {
2
+ "GradLlmMessageHmacKey": ".env",
3
  "Logging": {
4
  "LogLevel": {
5
  "Default": "Debug",
gradllm_hmac.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import base64
2
+ import hashlib
3
+ import hmac
4
+ import json
5
+ from typing import Any, Dict
6
+
7
+ from config import settings
8
+
9
+ PROTOCOL = "gradllm-hmac-sha256-v1"
10
+
11
+
12
+ def _key() -> bytes:
13
+ value = getattr(settings, "GradLlmMessageHmacKey", None)
14
+ if not isinstance(value, str) or len(value.encode("utf-8")) < 32:
15
+ raise RuntimeError("GradLlmMessageHmacKey must contain at least 32 UTF-8 bytes")
16
+ return value.encode("utf-8")
17
+
18
+
19
+ def _signature(direction: str, exchange: str, routing_key: str, envelope: Dict[str, Any]) -> str:
20
+ fields = (
21
+ PROTOCOL,
22
+ direction,
23
+ exchange,
24
+ routing_key,
25
+ envelope["reply_key"],
26
+ str(envelope["sequence"]),
27
+ "1" if envelope["final"] else "0",
28
+ envelope["payload_base64"],
29
+ )
30
+ digest = hmac.new(_key(), "\n".join(fields).encode("utf-8"), hashlib.sha256).digest()
31
+ return base64.b64encode(digest).decode("ascii")
32
+
33
+
34
+ def verify_request(envelope: Dict[str, Any], exchange: str, routing_key: str) -> Dict[str, Any]:
35
+ if not isinstance(envelope, dict) or envelope.get("protocol") != PROTOCOL:
36
+ raise ValueError("missing GradLLM HMAC envelope")
37
+ if envelope.get("sequence") != 0 or envelope.get("final") is not False:
38
+ raise ValueError("invalid GradLLM request envelope")
39
+ expected = _signature("request", exchange, routing_key, envelope)
40
+ if not hmac.compare_digest(str(envelope.get("signature", "")), expected):
41
+ raise ValueError("invalid GradLLM request HMAC")
42
+ payload = base64.b64decode(envelope["payload_base64"], validate=True)
43
+ decoded = json.loads(payload.decode("utf-8"))
44
+ if not isinstance(decoded, dict):
45
+ raise ValueError("GradLLM request payload must be an object")
46
+ decoded["reply_key"] = envelope["reply_key"]
47
+ return decoded
48
+
49
+
50
+ def create_response(exchange: str, reply_key: str, sequence: int, payload: Any, final: bool = False) -> Dict[str, Any]:
51
+ payload_bytes = json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
52
+ envelope = {
53
+ "protocol": PROTOCOL,
54
+ "reply_key": reply_key,
55
+ "sequence": sequence,
56
+ "final": final,
57
+ "payload_base64": base64.b64encode(payload_bytes).decode("ascii"),
58
+ }
59
+ envelope["signature"] = _signature("response", exchange, reply_key, envelope)
60
+ return envelope
oa_server.py CHANGED
@@ -5,6 +5,8 @@ from typing import Any, Dict, List, AsyncIterable, Optional
5
  from backends_base import ChatBackend, ImagesBackend
6
 
7
  from rabbit_repo import RabbitRepo
 
 
8
 
9
  logger = logging.getLogger(__name__)
10
 
@@ -72,20 +74,28 @@ class OpenAIServers:
72
  logger.warning("oaChatCreate: data is not a dict")
73
  return
74
 
 
 
 
 
 
 
75
  reply_key = data.get("reply_key")
76
  if not reply_key:
77
  logger.error("oaChatCreate: missing reply_key")
78
  return
79
 
80
  user_id = _extract_user_id(data)
 
81
  try:
82
  async for chunk in self._chat.stream(data):
83
  try:
84
  await self._pub.publish(
85
  "oa.chat.reply",
86
- _with_user_id(chunk, user_id),
87
  routing_key=reply_key,
88
  )
 
89
  except Exception:
90
  logger.exception("oaChatCreate: publish failed")
91
  break # stop streaming on publish failure
@@ -94,7 +104,7 @@ class OpenAIServers:
94
  try:
95
  await self._pub.publish(
96
  "oa.chat.reply",
97
- _with_user_id({"object": "stream.end"}, user_id),
98
  routing_key=reply_key,
99
  )
100
  except Exception:
@@ -110,6 +120,11 @@ class OpenAIServers:
110
  if not isinstance(data, dict):
111
  logger.warning("oaImagesGenerate: data is not a dict")
112
  return
 
 
 
 
 
113
  reply_key = data.get("reply_key")
114
  if not reply_key:
115
  logger.error("oaImagesGenerate: missing reply_key")
@@ -122,7 +137,7 @@ class OpenAIServers:
122
  try:
123
  await self._pub.publish(
124
  "oa.images.reply",
125
- _with_user_id(resp, user_id),
126
  routing_key=reply_key,
127
  )
128
  except Exception:
 
5
  from backends_base import ChatBackend, ImagesBackend
6
 
7
  from rabbit_repo import RabbitRepo
8
+ from gradllm_hmac import create_response, verify_request
9
+ from config import settings
10
 
11
  logger = logging.getLogger(__name__)
12
 
 
74
  logger.warning("oaChatCreate: data is not a dict")
75
  return
76
 
77
+ try:
78
+ data = verify_request(data, "oa.chat.create", settings.RABBIT_ROUTING_KEY)
79
+ except Exception:
80
+ logger.warning("oaChatCreate: rejected unauthenticated request", exc_info=True)
81
+ return
82
+
83
  reply_key = data.get("reply_key")
84
  if not reply_key:
85
  logger.error("oaChatCreate: missing reply_key")
86
  return
87
 
88
  user_id = _extract_user_id(data)
89
+ sequence = 0
90
  try:
91
  async for chunk in self._chat.stream(data):
92
  try:
93
  await self._pub.publish(
94
  "oa.chat.reply",
95
+ create_response("oa.chat.reply", reply_key, sequence, _with_user_id(chunk, user_id)),
96
  routing_key=reply_key,
97
  )
98
+ sequence += 1
99
  except Exception:
100
  logger.exception("oaChatCreate: publish failed")
101
  break # stop streaming on publish failure
 
104
  try:
105
  await self._pub.publish(
106
  "oa.chat.reply",
107
+ create_response("oa.chat.reply", reply_key, sequence, {}, final=True),
108
  routing_key=reply_key,
109
  )
110
  except Exception:
 
120
  if not isinstance(data, dict):
121
  logger.warning("oaImagesGenerate: data is not a dict")
122
  return
123
+ try:
124
+ data = verify_request(data, "oa.images.generate", settings.RABBIT_ROUTING_KEY)
125
+ except Exception:
126
+ logger.warning("oaImagesGenerate: rejected unauthenticated request", exc_info=True)
127
+ return
128
  reply_key = data.get("reply_key")
129
  if not reply_key:
130
  logger.error("oaImagesGenerate: missing reply_key")
 
137
  try:
138
  await self._pub.publish(
139
  "oa.images.reply",
140
+ create_response("oa.images.reply", reply_key, 0, _with_user_id(resp, user_id)),
141
  routing_key=reply_key,
142
  )
143
  except Exception:
test_gradllm_hmac.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import base64
2
+ import importlib
3
+ import json
4
+ import os
5
+ import unittest
6
+
7
+
8
+ class GradLlmHmacTests(unittest.TestCase):
9
+ @classmethod
10
+ def setUpClass(cls):
11
+ os.environ["GradLlmMessageHmacKey"] = "test-gradllm-hmac-key-that-is-at-least-32-bytes"
12
+ cls.protocol = importlib.import_module("gradllm_hmac")
13
+
14
+ def test_response_round_trip_and_tamper_rejection(self):
15
+ envelope = self.protocol.create_response("oa.chat.reply", "rk.test", 3, {"value": 42})
16
+ expected = self.protocol._signature("response", "oa.chat.reply", "rk.test", envelope)
17
+ self.assertEqual(envelope["signature"], expected)
18
+
19
+ envelope["payload_base64"] = base64.b64encode(json.dumps({"value": 43}).encode()).decode()
20
+ self.assertNotEqual(envelope["signature"], self.protocol._signature("response", "oa.chat.reply", "rk.test", envelope))
21
+
22
+ def test_request_verification_returns_original_payload_and_bound_reply_key(self):
23
+ payload = base64.b64encode(b'{"model":"timesfm"}').decode()
24
+ envelope = {
25
+ "protocol": self.protocol.PROTOCOL,
26
+ "reply_key": "rk.test",
27
+ "sequence": 0,
28
+ "final": False,
29
+ "payload_base64": payload,
30
+ }
31
+ envelope["signature"] = self.protocol._signature("request", "oa.chat.create", "execute.api", envelope)
32
+ decoded = self.protocol.verify_request(envelope, "oa.chat.create", "execute.api")
33
+ self.assertEqual(decoded, {"model": "timesfm", "reply_key": "rk.test"})
34
+
35
+ envelope["reply_key"] = "rk.attacker"
36
+ with self.assertRaises(ValueError):
37
+ self.protocol.verify_request(envelope, "oa.chat.create", "execute.api")
38
+
39
+
40
+ if __name__ == "__main__":
41
+ unittest.main()