@7h3/protocol 0.1.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (276) hide show
  1. package/.dockerignore +19 -0
  2. package/.github/dependabot.yml +32 -0
  3. package/.github/workflows/ci.yml +31 -0
  4. package/.github/workflows/publish.yml +59 -0
  5. package/.github/workflows/scorecard.yml +37 -0
  6. package/7h3.example.yaml +125 -0
  7. package/CHANGELOG.md +152 -0
  8. package/CONTRIBUTING.md +82 -0
  9. package/Dockerfile +73 -0
  10. package/GOVERNANCE.md +62 -0
  11. package/README.md +1317 -6
  12. package/SECURITY.md +70 -0
  13. package/bench-results/replay-cache-full-1777891033256.json +10 -0
  14. package/bench-results/replay-cache-full-1777896317488.json +10 -0
  15. package/bench-results/replay-cache-full-1777900993184.json +10 -0
  16. package/bench-results/replay-cache-full-1777901019285.json +10 -0
  17. package/bench-results/replay-cache-quick-1777870170126.json +10 -0
  18. package/bench-results/signature-profiles-quick-1775875160079.json +85 -0
  19. package/bench-results/signature-profiles-quick-1775983539716.json +85 -0
  20. package/bench-results/signature-profiles-quick-1776237913190.json +85 -0
  21. package/bench-results/wire-codecs-full-1777891019803.json +93 -0
  22. package/bench-results/wire-codecs-full-1777896260964.json +93 -0
  23. package/bench-results/wire-codecs-full-1777901004247.json +93 -0
  24. package/bench-results/wire-codecs-quick-1775972879056.json +93 -0
  25. package/bench-results/wire-codecs-quick-1775983541111.json +93 -0
  26. package/bench-results/wire-codecs-quick-1776237914299.json +93 -0
  27. package/bench-results/wire-codecs-quick-1777841285236.json +93 -0
  28. package/bench-results/wire-codecs-quick-1777841321772.json +93 -0
  29. package/bench-results/wire-codecs-quick-1777841330408.json +93 -0
  30. package/bench-results/wire-codecs-quick-1777852886082.json +93 -0
  31. package/bench-results/wire-codecs-quick-1777852988773.json +93 -0
  32. package/bench-results/wire-codecs-quick-1777870188095.json +93 -0
  33. package/bench-results/wire-codecs-quick-1777870263918.json +93 -0
  34. package/bench-results/wire-codecs-quick-1777870455034.json +93 -0
  35. package/bench-results/wire-codecs-quick-1778816163081.json +93 -0
  36. package/bench-results/wire-codecs-quick-1778843936130.json +93 -0
  37. package/bin/7h3.ts +406 -0
  38. package/conformance/7h3_v0_1.json +77 -0
  39. package/conformance/7h3_v0_1_binary.json +20 -0
  40. package/conformance/aip_v0_1_binary.json +20 -0
  41. package/docker-compose.yaml +77 -0
  42. package/docs/ADOPTION_PLAN.md +120 -0
  43. package/docs/AGENTS.md +77 -0
  44. package/docs/AIP_RFC_v0.1.md +97 -0
  45. package/docs/AI_DECISION_CARD.md +122 -0
  46. package/docs/AI_RUNTIME_POLICY.json +126 -0
  47. package/docs/AI_RUNTIME_POLICY.yaml +110 -0
  48. package/docs/BACKPRESSURE_TUNING.md +65 -0
  49. package/docs/BENCHMARK_CLAIM_MATRIX.md +42 -0
  50. package/docs/BENCHMARK_REPORT_TEMPLATE.md +169 -0
  51. package/docs/BINARY_CODEC_BENCH.md +23 -0
  52. package/docs/CLEAN_CLONE_RUNBOOK.md +36 -0
  53. package/docs/CLOCK_SKEW_POLICY.md +30 -0
  54. package/docs/DISTRIBUTED_REPLAY.md +142 -0
  55. package/docs/FUZZ_CAMPAIGN.md +121 -0
  56. package/docs/GATEWAY.md +195 -0
  57. package/docs/KEY_MANAGEMENT_POLICY.md +53 -0
  58. package/docs/KEY_REVOCATION.md +69 -0
  59. package/docs/MCP_WRAPPER.md +159 -0
  60. package/docs/MIGRATION_GUIDE.md +40 -0
  61. package/docs/OPERATORS.md +184 -0
  62. package/docs/PERF_REGRESSION_POLICY.md +34 -0
  63. package/docs/PROJECT_EXAMINATION_2026-05-31.md +219 -0
  64. package/docs/RELEASE_BENCHMARK_REPORT_2026-05-15.md +135 -0
  65. package/docs/RELEASE_GATE.md +25 -0
  66. package/docs/RELEASE_NOTES_v0.1.0.md +54 -0
  67. package/docs/SECURITY_REVIEW_2026-06-05.md +165 -0
  68. package/docs/TELEMETRY.md +41 -0
  69. package/docs/THREAT_MODEL.md +89 -0
  70. package/docs/VERSIONING_POLICY.md +30 -0
  71. package/docs/assets/banner-github.png +0 -0
  72. package/docs/assets/banner.png +0 -0
  73. package/docs/assets/banner.svg +123 -0
  74. package/eslint.config.js +15 -0
  75. package/fuzz/ts/harness-decode.ts +136 -0
  76. package/fuzz/ts/harness-verify.ts +121 -0
  77. package/fuzz/ts/run.ts +35 -0
  78. package/mcp-server/README.md +38 -0
  79. package/mcp-server/package-lock.json +1187 -0
  80. package/mcp-server/package.json +35 -0
  81. package/mcp-server/src/index.ts +236 -0
  82. package/mcp-server/tsconfig.json +14 -0
  83. package/package.json +121 -13
  84. package/scripts/aip-framework-quickstart.ts +110 -0
  85. package/scripts/aip-mcp-gateway.ts +38 -0
  86. package/scripts/aip-mcp-wrap-demo.ts +72 -0
  87. package/scripts/aip-quickstart.ts +60 -0
  88. package/scripts/bench-diff.ts +118 -0
  89. package/scripts/bench-protocol-e2e.ts +937 -0
  90. package/scripts/bench-protocol-openloop.ts +1397 -0
  91. package/scripts/bench-replay-cache.ts +76 -0
  92. package/scripts/bench-signature-profiles.ts +180 -0
  93. package/scripts/bench-wire-codecs.ts +161 -0
  94. package/scripts/build-binary-conformance.ts +36 -0
  95. package/scripts/build-release-dashboard.ts +175 -0
  96. package/scripts/canary-rollout.ts +38 -0
  97. package/scripts/mcpGatewayCli.test.ts +116 -0
  98. package/scripts/prepare-aip-package.ts +88 -0
  99. package/scripts/regen-conformance-sigs.ts +18 -0
  100. package/scripts/release-gate.ts +19 -0
  101. package/scripts/validate-runtime-policy.ts +18 -0
  102. package/sdk/browser/index.test.ts +162 -0
  103. package/sdk/browser/index.ts +257 -0
  104. package/sdk/browser/package.json +13 -0
  105. package/sdk/go/cbor.go +551 -0
  106. package/sdk/go/cbor_test.go +232 -0
  107. package/sdk/go/encryption.go +280 -0
  108. package/sdk/go/encryption_test.go +318 -0
  109. package/sdk/go/go.mod +7 -0
  110. package/sdk/go/go.sum +4 -0
  111. package/sdk/go/http.go +135 -0
  112. package/sdk/go/protocol.go +324 -0
  113. package/sdk/go/protocol_test.go +334 -0
  114. package/sdk/go/replay.go +121 -0
  115. package/sdk/go/replay_test.go +149 -0
  116. package/sdk/go/webhook.go +136 -0
  117. package/sdk/pq/package-lock.json +1358 -0
  118. package/sdk/pq/package.json +42 -0
  119. package/sdk/pq/src/index.test.ts +143 -0
  120. package/sdk/pq/src/index.ts +166 -0
  121. package/sdk/pq/tsconfig.json +14 -0
  122. package/sdk/pq/vitest.config.ts +7 -0
  123. package/sdk/python/README.md +18 -0
  124. package/sdk/python/protocol_7h3/__init__.py +46 -0
  125. package/sdk/python/protocol_7h3/encryption.py +252 -0
  126. package/sdk/python/protocol_7h3/http.py +212 -0
  127. package/sdk/python/protocol_7h3/keys.py +149 -0
  128. package/sdk/python/protocol_7h3/pq.py +244 -0
  129. package/sdk/python/protocol_7h3/protocol.py +525 -0
  130. package/sdk/python/protocol_7h3/queue.py +118 -0
  131. package/sdk/python/protocol_7h3/replay.py +98 -0
  132. package/sdk/python/protocol_7h3/webhook.py +116 -0
  133. package/sdk/python/pyproject.toml +40 -0
  134. package/sdk/python/tests/test_conformance.py +110 -0
  135. package/sdk/python/tests/test_encryption.py +206 -0
  136. package/sdk/python/tests/test_http.py +305 -0
  137. package/sdk/python/tests/test_keys.py +417 -0
  138. package/sdk/python/tests/test_queue.py +120 -0
  139. package/sdk/python/tests/test_webhook.py +345 -0
  140. package/sdk/rust/Cargo.lock +371 -0
  141. package/sdk/rust/Cargo.toml +25 -0
  142. package/sdk/rust/README.md +31 -0
  143. package/sdk/rust/fuzz/Cargo.toml +29 -0
  144. package/sdk/rust/fuzz/fuzz_targets/fuzz_canonicalize.rs +46 -0
  145. package/sdk/rust/fuzz/fuzz_targets/fuzz_decode.rs +11 -0
  146. package/sdk/rust/src/bin/aip_mcp_gateway.rs +59 -0
  147. package/sdk/rust/src/http.rs +145 -0
  148. package/sdk/rust/src/keys.rs +161 -0
  149. package/sdk/rust/src/lib.rs +688 -0
  150. package/sdk/rust/src/queue.rs +79 -0
  151. package/sdk/rust/src/webhook.rs +86 -0
  152. package/sdk/rust/tests/conformance.rs +148 -0
  153. package/sdk/rust/tests/gateway.rs +130 -0
  154. package/sdk/rust/tests/http_webhook_queue.rs +201 -0
  155. package/sdk/rust/tests/keys.rs +189 -0
  156. package/sdk/threshold/index.d.ts +68 -0
  157. package/sdk/threshold/index.d.ts.map +1 -0
  158. package/sdk/threshold/index.js +254 -0
  159. package/sdk/threshold/package-lock.json +1361 -0
  160. package/sdk/threshold/package.json +39 -0
  161. package/sdk/threshold/src/index.d.ts +68 -0
  162. package/sdk/threshold/src/index.d.ts.map +1 -0
  163. package/sdk/threshold/src/index.js +254 -0
  164. package/sdk/threshold/src/index.test.ts +238 -0
  165. package/sdk/threshold/src/index.ts +355 -0
  166. package/sdk/threshold/tsconfig.json +19 -0
  167. package/sdk/threshold/vitest.config.ts +12 -0
  168. package/src/agentAdapter.test.ts +48 -0
  169. package/src/agentAdapter.ts +56 -0
  170. package/src/auditLog.test.ts +145 -0
  171. package/src/auditLog.ts +147 -0
  172. package/src/capability.test.ts +504 -0
  173. package/src/capability.ts +380 -0
  174. package/src/cborCodec.test.ts +263 -0
  175. package/src/cborCodec.ts +339 -0
  176. package/src/conformance.test.ts +136 -0
  177. package/src/conformanceVectors.ts +99 -0
  178. package/src/encryption.test.ts +206 -0
  179. package/src/encryption.ts +245 -0
  180. package/src/envelopeCbor.ts +140 -0
  181. package/src/frameworkAdapters.test.ts +290 -0
  182. package/src/frameworkAdapters.ts +261 -0
  183. package/src/gateway.test.ts +343 -0
  184. package/src/gateway.ts +246 -0
  185. package/src/grpcBinding.test.ts +211 -0
  186. package/src/grpcBinding.ts +103 -0
  187. package/src/httpBinding.test.ts +376 -0
  188. package/src/httpBinding.ts +189 -0
  189. package/src/index.ts +39 -0
  190. package/src/keyInfra.test.ts +278 -0
  191. package/src/keyInfra.ts +228 -0
  192. package/src/keyRegistry.ts +59 -0
  193. package/src/keyRotation.test.ts +78 -0
  194. package/src/keyRotation.ts +72 -0
  195. package/src/mcpGateway.test.ts +129 -0
  196. package/src/mcpGateway.ts +250 -0
  197. package/src/mcpTransports.test.ts +92 -0
  198. package/src/mcpTransports.ts +169 -0
  199. package/src/mcpWrapper.test.ts +179 -0
  200. package/src/mcpWrapper.ts +206 -0
  201. package/src/otel.ts +136 -0
  202. package/src/policyEnforcer.test.ts +99 -0
  203. package/src/policyEnforcer.ts +169 -0
  204. package/src/policyTelemetryFeedback.test.ts +25 -0
  205. package/src/policyTelemetryFeedback.ts +38 -0
  206. package/src/protocol.bench.ts +37 -0
  207. package/{protocol.d.ts → src/protocol.d.ts} +2 -1
  208. package/src/protocol.d.ts.map +1 -0
  209. package/src/protocol.js +294 -0
  210. package/src/protocol.test.ts +155 -0
  211. package/src/protocol.ts +414 -0
  212. package/src/protocolAgent.test.ts +105 -0
  213. package/src/protocolAgent.ts +169 -0
  214. package/src/protocolBinary.test.ts +165 -0
  215. package/src/protocolBinary.ts +312 -0
  216. package/src/protocolCapabilities.ts +70 -0
  217. package/src/protocolFuzz.advanced.test.ts +235 -0
  218. package/src/protocolFuzz.test.ts +111 -0
  219. package/src/protocolNegative.test.ts +97 -0
  220. package/src/protocolReplay.test.ts +71 -0
  221. package/src/protocolReplay.ts +194 -0
  222. package/src/protocolTransport.test.ts +556 -0
  223. package/src/protocolTransport.ts +483 -0
  224. package/src/queueBinding.test.ts +130 -0
  225. package/src/queueBinding.ts +102 -0
  226. package/src/rateLimiter.test.ts +96 -0
  227. package/src/rateLimiter.ts +46 -0
  228. package/src/redisClient.ts +140 -0
  229. package/src/redisIntegration.test.ts +134 -0
  230. package/src/replayStores.test.ts +273 -0
  231. package/src/replayStores.ts +215 -0
  232. package/src/revocation.test.ts +98 -0
  233. package/src/revocation.ts +0 -0
  234. package/src/routePolicy.test.ts +87 -0
  235. package/src/routePolicy.ts +72 -0
  236. package/src/runtimePolicy.test.ts +49 -0
  237. package/src/runtimePolicy.ts +81 -0
  238. package/src/runtimePolicyManager.test.ts +29 -0
  239. package/src/runtimePolicyManager.ts +50 -0
  240. package/src/runtimePolicyPresets.ts +43 -0
  241. package/src/signedResponse.test.ts +111 -0
  242. package/src/signedResponse.ts +83 -0
  243. package/src/stream.test.ts +254 -0
  244. package/src/stream.ts +417 -0
  245. package/src/telemetry.test.ts +251 -0
  246. package/src/telemetry.ts +299 -0
  247. package/src/webhookBinding.test.ts +144 -0
  248. package/src/webhookBinding.ts +115 -0
  249. package/src/wsBinding.test.ts +221 -0
  250. package/src/wsBinding.ts +200 -0
  251. package/tsconfig.json +15 -0
  252. package/tsconfig.lib.json +23 -0
  253. package/vite.lib.config.ts +16 -0
  254. package/vitest.config.ts +11 -0
  255. package/agentAdapter.d.ts +0 -26
  256. package/conformanceVectors.d.ts +0 -20
  257. package/frameworkAdapters.d.ts +0 -72
  258. package/index.d.ts +0 -20
  259. package/index.js +0 -1702
  260. package/keyRotation.d.ts +0 -20
  261. package/mcpGateway.d.ts +0 -37
  262. package/mcpTransports.d.ts +0 -62
  263. package/mcpWrapper.d.ts +0 -83
  264. package/policyEnforcer.d.ts +0 -50
  265. package/policyTelemetryFeedback.d.ts +0 -11
  266. package/protocolAgent.d.ts +0 -58
  267. package/protocolBinary.d.ts +0 -8
  268. package/protocolCapabilities.d.ts +0 -24
  269. package/protocolReplay.d.ts +0 -35
  270. package/protocolTransport.d.ts +0 -73
  271. package/redisClient.d.ts +0 -49
  272. package/replayStores.d.ts +0 -32
  273. package/revocation.d.ts +0 -71
  274. package/runtimePolicy.d.ts +0 -24
  275. package/runtimePolicyManager.d.ts +0 -15
  276. package/runtimePolicyPresets.d.ts +0 -11
@@ -0,0 +1,98 @@
1
+ """
2
+ Redis-backed replay store for the 7h3 Protocol Python SDK.
3
+
4
+ Provides atomic nonce deduplication using Redis SET NX PX semantics.
5
+ The ``redis`` package is optional — an ImportError is raised at construction
6
+ time (not at module import) when it is absent, so the rest of the SDK remains
7
+ usable without installing Redis dependencies.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Optional, Any
13
+
14
+
15
+ class RedisReplayStore:
16
+ """
17
+ Atomic replay detection backed by Redis.
18
+
19
+ Uses ``SET key value NX PX ttl_ms`` so the first caller atomically claims
20
+ the nonce and all subsequent callers are identified as replays — safe
21
+ across multiple horizontally-scaled gateway instances.
22
+
23
+ Parameters
24
+ ----------
25
+ redis_url:
26
+ Redis connection URL passed to ``redis.from_url()``. Ignored when
27
+ *client* is provided.
28
+ key_prefix:
29
+ Namespace prefix prepended to every nonce key. Default ``'7h3:nonce:'``.
30
+ client:
31
+ Pre-constructed Redis client (any object that exposes ``set`` and
32
+ ``close``). When supplied, *redis_url* is ignored.
33
+
34
+ Raises
35
+ ------
36
+ ImportError
37
+ At construction time when *redis_url* is provided but the ``redis``
38
+ package is not installed.
39
+ """
40
+
41
+ def __init__(
42
+ self,
43
+ redis_url: Optional[str] = None,
44
+ key_prefix: str = "7h3:nonce:",
45
+ client: Optional[Any] = None,
46
+ ) -> None:
47
+ self.key_prefix = key_prefix
48
+
49
+ if client is not None:
50
+ self._client = client
51
+ else:
52
+ try:
53
+ import redis # type: ignore[import]
54
+ except ImportError as exc:
55
+ raise ImportError(
56
+ "The 'redis' package is required to use RedisReplayStore. "
57
+ "Install it with: pip install redis"
58
+ ) from exc
59
+
60
+ url = redis_url or "redis://localhost:6379"
61
+ self._client = redis.from_url(url, decode_responses=True)
62
+
63
+ def check(self, key: str, ttl_ms: int) -> bool:
64
+ """
65
+ Atomically check and register a nonce.
66
+
67
+ Returns
68
+ -------
69
+ bool
70
+ ``False`` if the nonce is fresh (first time seen — the key was set
71
+ in Redis), or ``True`` if the nonce is a replay (the key already
72
+ existed in Redis and SET NX was blocked).
73
+ """
74
+ redis_key = f"{self.key_prefix}{key}"
75
+ px = max(1, ttl_ms)
76
+ result = self._client.set(redis_key, "1", nx=True, px=px)
77
+ # redis-py returns True when the key was set, None when NX blocked it
78
+ return result is None
79
+
80
+ def close(self) -> None:
81
+ """Close the underlying Redis connection."""
82
+ close = getattr(self._client, "close", None)
83
+ if close is not None:
84
+ close()
85
+
86
+
87
+ def create_redis_replay_store(redis_url: str, key_prefix: str = "7h3:nonce:") -> RedisReplayStore:
88
+ """
89
+ Convenience factory — create a :class:`RedisReplayStore` from a Redis URL.
90
+
91
+ Parameters
92
+ ----------
93
+ redis_url:
94
+ Redis connection URL, e.g. ``'redis://localhost:6379'``.
95
+ key_prefix:
96
+ Key namespace prefix. Default ``'7h3:nonce:'``.
97
+ """
98
+ return RedisReplayStore(redis_url=redis_url, key_prefix=key_prefix)
@@ -0,0 +1,116 @@
1
+ """Webhook binding for 7h3 Protocol — lightweight per-payload signing."""
2
+ from __future__ import annotations
3
+ import json
4
+ import time
5
+ from typing import Any, Dict, Optional, TypeVar, Union
6
+
7
+ WEBHOOK_SIG_HEADER = "x-7h3-sig"
8
+ WEBHOOK_TS_HEADER = "x-7h3-ts"
9
+ WEBHOOK_DEFAULT_TTL_MS = 300_000 # 5 minutes
10
+
11
+
12
+ def _webhook_signing_payload(timestamp_ms: int, body: str) -> str:
13
+ return f"{timestamp_ms}.{body}"
14
+
15
+
16
+ def sign_webhook(
17
+ payload: Union[str, bytes],
18
+ private_key: str,
19
+ *,
20
+ ttl_ms: int = WEBHOOK_DEFAULT_TTL_MS,
21
+ ) -> Dict[str, str]:
22
+ """Sign a webhook payload. Returns headers dict with x-7h3-sig and x-7h3-ts."""
23
+ from .protocol import sign_canonical_payload_ed25519
24
+
25
+ body = payload.decode("utf-8") if isinstance(payload, bytes) else payload
26
+ ts = int(time.time() * 1000)
27
+ signing_payload = _webhook_signing_payload(ts, body)
28
+ sig = sign_canonical_payload_ed25519(signing_payload, private_key)
29
+ return {WEBHOOK_SIG_HEADER: sig, WEBHOOK_TS_HEADER: str(ts)}
30
+
31
+
32
+ def sign_webhook_hmac(
33
+ payload: Union[str, bytes],
34
+ secret: str,
35
+ *,
36
+ ttl_ms: int = WEBHOOK_DEFAULT_TTL_MS,
37
+ ) -> Dict[str, str]:
38
+ """Sign a webhook payload with HMAC-SHA256 shared secret."""
39
+ from .protocol import sign_canonical_payload_hmac
40
+
41
+ body = payload.decode("utf-8") if isinstance(payload, bytes) else payload
42
+ ts = int(time.time() * 1000)
43
+ signing_payload = _webhook_signing_payload(ts, body)
44
+ sig = sign_canonical_payload_hmac(signing_payload, secret)
45
+ return {WEBHOOK_SIG_HEADER: sig, WEBHOOK_TS_HEADER: str(ts)}
46
+
47
+
48
+ def verify_webhook(
49
+ payload: Union[str, bytes],
50
+ headers: Dict[str, str],
51
+ public_key: str,
52
+ *,
53
+ max_age_ms: int = WEBHOOK_DEFAULT_TTL_MS,
54
+ ) -> bool:
55
+ """Verify an Ed25519 webhook signature. Returns True/False."""
56
+ from .protocol import verify_canonical_payload_ed25519
57
+
58
+ body = payload.decode("utf-8") if isinstance(payload, bytes) else payload
59
+ # normalise header keys
60
+ normalised = {k.lower(): v for k, v in headers.items()}
61
+ sig = normalised.get(WEBHOOK_SIG_HEADER.lower())
62
+ ts_str = normalised.get(WEBHOOK_TS_HEADER.lower())
63
+ if not sig or not ts_str:
64
+ return False
65
+ try:
66
+ ts = int(ts_str)
67
+ except ValueError:
68
+ return False
69
+ now_ms = int(time.time() * 1000)
70
+ if now_ms - ts > max_age_ms:
71
+ return False
72
+ signing_payload = _webhook_signing_payload(ts, body)
73
+ return verify_canonical_payload_ed25519(signing_payload, sig, public_key)
74
+
75
+
76
+ def verify_webhook_hmac(
77
+ payload: Union[str, bytes],
78
+ headers: Dict[str, str],
79
+ secret: str,
80
+ *,
81
+ max_age_ms: int = WEBHOOK_DEFAULT_TTL_MS,
82
+ ) -> bool:
83
+ """Verify an HMAC webhook signature."""
84
+ from .protocol import verify_canonical_payload_hmac
85
+
86
+ body = payload.decode("utf-8") if isinstance(payload, bytes) else payload
87
+ normalised = {k.lower(): v for k, v in headers.items()}
88
+ sig = normalised.get(WEBHOOK_SIG_HEADER.lower())
89
+ ts_str = normalised.get(WEBHOOK_TS_HEADER.lower())
90
+ if not sig or not ts_str:
91
+ return False
92
+ try:
93
+ ts = int(ts_str)
94
+ except ValueError:
95
+ return False
96
+ now_ms = int(time.time() * 1000)
97
+ if now_ms - ts > max_age_ms:
98
+ return False
99
+ signing_payload = _webhook_signing_payload(ts, body)
100
+ return verify_canonical_payload_hmac(signing_payload, sig, secret)
101
+
102
+
103
+ T = TypeVar("T")
104
+
105
+
106
+ def consume_webhook(
107
+ payload: str,
108
+ headers: Dict[str, str],
109
+ public_key: str,
110
+ *,
111
+ max_age_ms: int = WEBHOOK_DEFAULT_TTL_MS,
112
+ ) -> Any:
113
+ """Parse and verify a JSON webhook payload. Raises ValueError on failure."""
114
+ if not verify_webhook(payload, headers, public_key, max_age_ms=max_age_ms):
115
+ raise ValueError("7h3: webhook signature verification failed")
116
+ return json.loads(payload)
@@ -0,0 +1,40 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "7h3-protocol"
7
+ version = "0.5.0"
8
+ description = "7h3 Protocol — Python SDK. Deterministic, signed, replay-safe AI-to-AI messaging. Wire version 7h3/0.1."
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.9"
12
+ keywords = ["7h3", "agent", "protocol", "ed25519", "mcp", "signing", "replay-protection"]
13
+ classifiers = [
14
+ "Development Status :: 4 - Beta",
15
+ "Intended Audience :: Developers",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.9",
19
+ "Programming Language :: Python :: 3.10",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: 3.13",
23
+ "Topic :: Security :: Cryptography",
24
+ "Topic :: Software Development :: Libraries :: Python Modules",
25
+ "Topic :: Internet",
26
+ ]
27
+ dependencies = []
28
+
29
+ [project.optional-dependencies]
30
+ crypto = ["cryptography>=41.0"]
31
+ nacl = ["PyNaCl>=1.5"]
32
+
33
+ [project.urls]
34
+ Homepage = "https://github.com/IceMasterT/7h3-protocol"
35
+ Repository = "https://github.com/IceMasterT/7h3-protocol"
36
+ Documentation = "https://github.com/IceMasterT/7h3-protocol/tree/main/sdk/python"
37
+ Changelog = "https://github.com/IceMasterT/7h3-protocol/blob/main/CHANGELOG.md"
38
+
39
+ [tool.hatch.build.targets.wheel]
40
+ packages = ["protocol_7h3"]
@@ -0,0 +1,110 @@
1
+ import json
2
+ import pathlib
3
+ import unittest
4
+
5
+ from protocol_7h3.protocol import (
6
+ canonicalize_envelope,
7
+ decode_envelope,
8
+ encode_envelope_compact,
9
+ sign_canonical_payload_ed25519,
10
+ sign_canonical_payload_hmac,
11
+ sign_envelope_ed25519,
12
+ sign_envelope_hmac,
13
+ validate_envelope,
14
+ verify_canonical_payload_ed25519,
15
+ verify_canonical_payload_hmac,
16
+ verify_envelope_ed25519,
17
+ verify_envelope_hmac,
18
+ )
19
+
20
+
21
+ ROOT = pathlib.Path(__file__).resolve().parents[3]
22
+ VECTORS_PATH = ROOT / "conformance" / "7h3_v0_1.json"
23
+
24
+
25
+ class ConformanceTests(unittest.TestCase):
26
+ @classmethod
27
+ def setUpClass(cls) -> None:
28
+ with VECTORS_PATH.open("r", encoding="utf-8") as handle:
29
+ cls.payload = json.load(handle)
30
+
31
+ def test_vectors_match_canonical_and_signatures(self) -> None:
32
+ for vector in self.payload["vectors"]:
33
+ envelope = vector["envelope"]
34
+ canonical = canonicalize_envelope(envelope)
35
+ self.assertEqual(canonical, vector["canonical"])
36
+
37
+ signature = sign_canonical_payload_hmac(canonical, vector["secret"])
38
+ self.assertEqual(signature, vector["signature"])
39
+ self.assertTrue(
40
+ verify_canonical_payload_hmac(canonical, signature, vector["secret"])
41
+ )
42
+ self.assertFalse(
43
+ verify_canonical_payload_hmac(
44
+ canonical, signature, vector["secret"] + "-bad"
45
+ )
46
+ )
47
+
48
+ signed = sign_envelope_hmac(envelope, vector["secret"], vector["keyId"])
49
+ self.assertTrue(verify_envelope_hmac(signed, vector["secret"]))
50
+ self.assertFalse(verify_envelope_hmac(signed, vector["secret"] + "-bad"))
51
+
52
+ def test_compact_roundtrip(self) -> None:
53
+ for vector in self.payload["vectors"]:
54
+ signed = sign_envelope_hmac(
55
+ vector["envelope"], vector["secret"], vector["keyId"]
56
+ )
57
+ raw = encode_envelope_compact(signed)
58
+ decoded = decode_envelope(raw)
59
+ self.assertEqual(decoded, signed)
60
+
61
+ def test_ttl_validation(self) -> None:
62
+ vector = self.payload["vectors"][0]
63
+ envelope = vector["envelope"]
64
+
65
+ valid = validate_envelope(
66
+ envelope, now_ms=envelope["header"]["timestampMs"] + 1
67
+ )
68
+ self.assertFalse(any(d.message == "Message TTL expired" for d in valid))
69
+
70
+ expired = validate_envelope(
71
+ envelope,
72
+ now_ms=envelope["header"]["timestampMs"] + envelope["header"]["ttlMs"] + 1,
73
+ )
74
+ self.assertTrue(any(d.message == "Message TTL expired" for d in expired))
75
+
76
+ def test_ed25519_vectors_if_crypto_available(self) -> None:
77
+ vectors = self.payload.get("ed25519Vectors", [])
78
+ if not vectors:
79
+ self.skipTest("No ED25519 vectors present")
80
+
81
+ for vector in vectors:
82
+ envelope = vector["envelope"]
83
+ canonical = canonicalize_envelope(envelope)
84
+ self.assertEqual(canonical, vector["canonical"])
85
+
86
+ try:
87
+ signature = sign_canonical_payload_ed25519(
88
+ canonical, vector["privateKey"]
89
+ )
90
+ except RuntimeError as exc:
91
+ if "cryptography package is required" in str(exc):
92
+ self.skipTest("cryptography package unavailable for ED25519")
93
+ raise
94
+
95
+ self.assertEqual(signature, vector["signature"])
96
+ self.assertTrue(
97
+ verify_canonical_payload_ed25519(
98
+ canonical, signature, vector["publicKey"]
99
+ )
100
+ )
101
+
102
+ signed = sign_envelope_ed25519(
103
+ envelope, vector["privateKey"], vector["keyId"]
104
+ )
105
+ self.assertTrue(verify_envelope_ed25519(signed, vector["publicKey"]))
106
+ self.assertEqual(signed["signature"]["alg"], "ED25519")
107
+
108
+
109
+ if __name__ == "__main__":
110
+ unittest.main()
@@ -0,0 +1,206 @@
1
+ """Tests for protocol_7h3.encryption — X25519 + ChaCha20-Poly1305 E2E encryption."""
2
+ from __future__ import annotations
3
+
4
+ import base64
5
+ import json
6
+ import time
7
+ import unittest
8
+
9
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
10
+ from cryptography.hazmat.primitives import serialization
11
+
12
+ from protocol_7h3.encryption import (
13
+ generate_x25519_keypair,
14
+ seal_envelope,
15
+ open_envelope,
16
+ _encrypt_body,
17
+ _decrypt_body,
18
+ )
19
+
20
+
21
+ # ---------------------------------------------------------------------------
22
+ # Helpers
23
+ # ---------------------------------------------------------------------------
24
+
25
+
26
+ def _b64url_encode(data: bytes) -> str:
27
+ return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")
28
+
29
+
30
+ def _generate_ed25519_keypair():
31
+ """Generate Ed25519 keypair in PKCS8/SPKI DER format (base64url) — matches TS protocol.ts."""
32
+ priv = Ed25519PrivateKey.generate()
33
+ pub = priv.public_key()
34
+ priv_der = priv.private_bytes(
35
+ serialization.Encoding.DER,
36
+ serialization.PrivateFormat.PKCS8,
37
+ serialization.NoEncryption(),
38
+ )
39
+ pub_der = pub.public_bytes(
40
+ serialization.Encoding.DER,
41
+ serialization.PublicFormat.SubjectPublicKeyInfo,
42
+ )
43
+ return _b64url_encode(priv_der), _b64url_encode(pub_der)
44
+
45
+
46
+ def _make_envelope(body: dict) -> dict:
47
+ now_ms = int(time.time() * 1000)
48
+ return {
49
+ "header": {
50
+ "version": "7h3/0.1",
51
+ "messageId": f"msg-{now_ms}-test",
52
+ "timestampMs": now_ms,
53
+ "ttlMs": 60_000,
54
+ "sender": "agent-alice",
55
+ "recipient": "agent-bob",
56
+ "nonce": _b64url_encode(__import__("os").urandom(12)),
57
+ },
58
+ "body": body,
59
+ }
60
+
61
+
62
+ # ---------------------------------------------------------------------------
63
+ # Tests
64
+ # ---------------------------------------------------------------------------
65
+
66
+
67
+ class TestGenerateX25519Keypair(unittest.TestCase):
68
+ """Test 1: generate_x25519_keypair returns 32-byte base64url keys."""
69
+
70
+ def test_key_format(self):
71
+ pub, priv = generate_x25519_keypair()
72
+ # base64url of 32 bytes = 43 chars (no padding)
73
+ self.assertRegex(pub, r"^[A-Za-z0-9_-]{43}$")
74
+ self.assertRegex(priv, r"^[A-Za-z0-9_-]{43}$")
75
+ # Decoded must be exactly 32 bytes
76
+ padding = "=" * ((4 - (len(pub) % 4)) % 4)
77
+ self.assertEqual(len(base64.urlsafe_b64decode(pub + padding)), 32)
78
+ padding = "=" * ((4 - (len(priv) % 4)) % 4)
79
+ self.assertEqual(len(base64.urlsafe_b64decode(priv + padding)), 32)
80
+
81
+
82
+ class TestSealAndOpenEnvelope(unittest.TestCase):
83
+ """Test 2: sealEnvelope + openEnvelope round-trip recovers original body exactly."""
84
+
85
+ def test_round_trip(self):
86
+ recipient_pub, recipient_priv = generate_x25519_keypair()
87
+ sender_priv_ed, sender_pub_ed = _generate_ed25519_keypair()
88
+
89
+ original_body = {
90
+ "intent": "TASK",
91
+ "content": "Hello encrypted world!",
92
+ "capability": "some-cap",
93
+ "correlationId": "corr-123",
94
+ }
95
+ envelope = _make_envelope(original_body)
96
+
97
+ sealed = seal_envelope(envelope, recipient_pub, sender_priv_ed)
98
+ result = open_envelope(sealed, recipient_priv, sender_pub_ed)
99
+ body = result["body"]
100
+
101
+ self.assertEqual(body["intent"], original_body["intent"])
102
+ self.assertEqual(body["content"], original_body["content"])
103
+ self.assertEqual(body.get("capability"), original_body["capability"])
104
+ self.assertEqual(body.get("correlationId"), original_body["correlationId"])
105
+
106
+
107
+ class TestWrongRecipientKey(unittest.TestCase):
108
+ """Test 3: fails with wrong recipient private key (AEAD tag mismatch)."""
109
+
110
+ def test_wrong_key_fails(self):
111
+ recipient_pub, _ = generate_x25519_keypair()
112
+ _, wrong_priv = generate_x25519_keypair()
113
+ sender_priv_ed, sender_pub_ed = _generate_ed25519_keypair()
114
+
115
+ envelope = _make_envelope({"intent": "PING", "content": "secret"})
116
+ sealed = seal_envelope(envelope, recipient_pub, sender_priv_ed)
117
+
118
+ # Use _decrypt_body directly to bypass signature check (wrong key scenario)
119
+ with self.assertRaises(Exception):
120
+ _decrypt_body(sealed["body"]["content"], wrong_priv)
121
+
122
+
123
+ class TestTamperedSignature(unittest.TestCase):
124
+ """Test 4: fails if envelope signature is tampered."""
125
+
126
+ def test_tampered_signature(self):
127
+ recipient_pub, recipient_priv = generate_x25519_keypair()
128
+ sender_priv_ed, sender_pub_ed = _generate_ed25519_keypair()
129
+
130
+ envelope = _make_envelope({"intent": "PING", "content": "secret"})
131
+ sealed = seal_envelope(envelope, recipient_pub, sender_priv_ed)
132
+
133
+ # Tamper with signature
134
+ tampered = dict(sealed)
135
+ tampered["signature"] = dict(sealed["signature"])
136
+ tampered["signature"]["value"] = "A" * 86 # wrong Ed25519 sig
137
+
138
+ with self.assertRaises(ValueError) as ctx:
139
+ open_envelope(tampered, recipient_priv, sender_pub_ed)
140
+ self.assertIn("signature", str(ctx.exception).lower())
141
+
142
+
143
+ class TestTamperedCiphertext(unittest.TestCase):
144
+ """Test 5: fails if ciphertext tampered (AEAD auth tag fails)."""
145
+
146
+ def test_tampered_ciphertext(self):
147
+ recipient_pub, recipient_priv = generate_x25519_keypair()
148
+
149
+ body = {"intent": "PING", "content": "secret"}
150
+ encrypted_payload_dict = _encrypt_body(body, recipient_pub)
151
+
152
+ # Flip bits in ciphertext
153
+ import base64
154
+ ct_bytes = bytearray(base64.urlsafe_b64decode(
155
+ encrypted_payload_dict["ciphertext"] + "=" * ((4 - len(encrypted_payload_dict["ciphertext"]) % 4) % 4)
156
+ ))
157
+ ct_bytes[0] ^= 0xFF
158
+ encrypted_payload_dict["ciphertext"] = base64.urlsafe_b64encode(bytes(ct_bytes)).decode().rstrip("=")
159
+
160
+ # Re-encode payload
161
+ tampered_content = _b64url_encode(
162
+ json.dumps(encrypted_payload_dict, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
163
+ )
164
+
165
+ with self.assertRaises(Exception):
166
+ _decrypt_body(tampered_content, recipient_priv)
167
+
168
+
169
+ class TestEphemeralRandomness(unittest.TestCase):
170
+ """Test 6: Two seal calls on same body produce different ciphertexts."""
171
+
172
+ def test_different_ciphertexts(self):
173
+ recipient_pub, _ = generate_x25519_keypair()
174
+ sender_priv_ed, _ = _generate_ed25519_keypair()
175
+
176
+ envelope1 = _make_envelope({"intent": "PING", "content": "same content"})
177
+ envelope2 = _make_envelope({"intent": "PING", "content": "same content"})
178
+
179
+ sealed1 = seal_envelope(envelope1, recipient_pub, sender_priv_ed)
180
+ sealed2 = seal_envelope(envelope2, recipient_pub, sender_priv_ed)
181
+
182
+ self.assertNotEqual(sealed1["body"]["content"], sealed2["body"]["content"])
183
+
184
+
185
+ class TestEncryptedContentOpaque(unittest.TestCase):
186
+ """Test 7: Encrypted content is opaque (does not contain original body.content)."""
187
+
188
+ def test_content_is_opaque(self):
189
+ recipient_pub, _ = generate_x25519_keypair()
190
+ sender_priv_ed, _ = _generate_ed25519_keypair()
191
+
192
+ original_content = "super-secret-data-12345"
193
+ envelope = _make_envelope({"intent": "TASK", "content": original_content})
194
+
195
+ sealed = seal_envelope(envelope, recipient_pub, sender_priv_ed)
196
+
197
+ # The encrypted content (as JSON) should not reveal the original string
198
+ encrypted_content_decoded = base64.urlsafe_b64decode(
199
+ sealed["body"]["content"] + "=" * ((4 - len(sealed["body"]["content"]) % 4) % 4)
200
+ ).decode("utf-8")
201
+ self.assertNotIn(original_content, encrypted_content_decoded)
202
+ self.assertNotIn(original_content, sealed["body"]["content"])
203
+
204
+
205
+ if __name__ == "__main__":
206
+ unittest.main()