@7h3/protocol 0.1.2 → 0.4.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.
- package/.dockerignore +19 -0
- package/.github/dependabot.yml +32 -0
- package/.github/workflows/ci.yml +31 -0
- package/.github/workflows/publish.yml +59 -0
- package/.github/workflows/scorecard.yml +37 -0
- package/7h3.example.yaml +125 -0
- package/CHANGELOG.md +92 -0
- package/CONTRIBUTING.md +82 -0
- package/Dockerfile +73 -0
- package/GOVERNANCE.md +62 -0
- package/README.md +323 -6
- package/SECURITY.md +70 -0
- package/bench-results/replay-cache-full-1777891033256.json +10 -0
- package/bench-results/replay-cache-full-1777896317488.json +10 -0
- package/bench-results/replay-cache-full-1777900993184.json +10 -0
- package/bench-results/replay-cache-full-1777901019285.json +10 -0
- package/bench-results/replay-cache-quick-1777870170126.json +10 -0
- package/bench-results/signature-profiles-quick-1775875160079.json +85 -0
- package/bench-results/signature-profiles-quick-1775983539716.json +85 -0
- package/bench-results/signature-profiles-quick-1776237913190.json +85 -0
- package/bench-results/wire-codecs-full-1777891019803.json +93 -0
- package/bench-results/wire-codecs-full-1777896260964.json +93 -0
- package/bench-results/wire-codecs-full-1777901004247.json +93 -0
- package/bench-results/wire-codecs-quick-1775972879056.json +93 -0
- package/bench-results/wire-codecs-quick-1775983541111.json +93 -0
- package/bench-results/wire-codecs-quick-1776237914299.json +93 -0
- package/bench-results/wire-codecs-quick-1777841285236.json +93 -0
- package/bench-results/wire-codecs-quick-1777841321772.json +93 -0
- package/bench-results/wire-codecs-quick-1777841330408.json +93 -0
- package/bench-results/wire-codecs-quick-1777852886082.json +93 -0
- package/bench-results/wire-codecs-quick-1777852988773.json +93 -0
- package/bench-results/wire-codecs-quick-1777870188095.json +93 -0
- package/bench-results/wire-codecs-quick-1777870263918.json +93 -0
- package/bench-results/wire-codecs-quick-1777870455034.json +93 -0
- package/bench-results/wire-codecs-quick-1778816163081.json +93 -0
- package/bench-results/wire-codecs-quick-1778843936130.json +93 -0
- package/bin/7h3.ts +385 -0
- package/conformance/7h3_v0_1.json +77 -0
- package/conformance/7h3_v0_1_binary.json +20 -0
- package/conformance/aip_v0_1_binary.json +20 -0
- package/docker-compose.yaml +77 -0
- package/docs/ADOPTION_PLAN.md +120 -0
- package/docs/AGENTS.md +77 -0
- package/docs/AIP_RFC_v0.1.md +97 -0
- package/docs/AI_DECISION_CARD.md +122 -0
- package/docs/AI_RUNTIME_POLICY.json +126 -0
- package/docs/AI_RUNTIME_POLICY.yaml +110 -0
- package/docs/BACKPRESSURE_TUNING.md +65 -0
- package/docs/BENCHMARK_CLAIM_MATRIX.md +42 -0
- package/docs/BENCHMARK_REPORT_TEMPLATE.md +169 -0
- package/docs/BINARY_CODEC_BENCH.md +23 -0
- package/docs/CLEAN_CLONE_RUNBOOK.md +36 -0
- package/docs/CLOCK_SKEW_POLICY.md +30 -0
- package/docs/DISTRIBUTED_REPLAY.md +142 -0
- package/docs/FUZZ_CAMPAIGN.md +121 -0
- package/docs/GATEWAY.md +195 -0
- package/docs/KEY_MANAGEMENT_POLICY.md +53 -0
- package/docs/KEY_REVOCATION.md +69 -0
- package/docs/MCP_WRAPPER.md +159 -0
- package/docs/MIGRATION_GUIDE.md +40 -0
- package/docs/OPERATORS.md +184 -0
- package/docs/PERF_REGRESSION_POLICY.md +34 -0
- package/docs/PROJECT_EXAMINATION_2026-05-31.md +219 -0
- package/docs/RELEASE_BENCHMARK_REPORT_2026-05-15.md +135 -0
- package/docs/RELEASE_GATE.md +25 -0
- package/docs/RELEASE_NOTES_v0.1.0.md +54 -0
- package/docs/SECURITY_REVIEW_2026-06-05.md +165 -0
- package/docs/TELEMETRY.md +41 -0
- package/docs/THREAT_MODEL.md +89 -0
- package/docs/VERSIONING_POLICY.md +30 -0
- package/docs/assets/banner.png +0 -0
- package/eslint.config.js +15 -0
- package/fuzz/ts/harness-decode.ts +136 -0
- package/fuzz/ts/harness-verify.ts +121 -0
- package/fuzz/ts/run.ts +35 -0
- package/mcp-server/README.md +38 -0
- package/mcp-server/package-lock.json +1187 -0
- package/mcp-server/package.json +35 -0
- package/mcp-server/src/index.ts +236 -0
- package/mcp-server/tsconfig.json +14 -0
- package/package.json +79 -13
- package/scripts/aip-framework-quickstart.ts +110 -0
- package/scripts/aip-mcp-gateway.ts +38 -0
- package/scripts/aip-mcp-wrap-demo.ts +72 -0
- package/scripts/aip-quickstart.ts +60 -0
- package/scripts/bench-diff.ts +118 -0
- package/scripts/bench-protocol-e2e.ts +937 -0
- package/scripts/bench-protocol-openloop.ts +1397 -0
- package/scripts/bench-replay-cache.ts +76 -0
- package/scripts/bench-signature-profiles.ts +180 -0
- package/scripts/bench-wire-codecs.ts +161 -0
- package/scripts/build-binary-conformance.ts +36 -0
- package/scripts/build-release-dashboard.ts +175 -0
- package/scripts/canary-rollout.ts +38 -0
- package/scripts/mcpGatewayCli.test.ts +116 -0
- package/scripts/prepare-aip-package.ts +88 -0
- package/scripts/regen-conformance-sigs.ts +18 -0
- package/scripts/release-gate.ts +19 -0
- package/scripts/validate-runtime-policy.ts +18 -0
- package/sdk/browser/index.test.ts +162 -0
- package/sdk/browser/index.ts +257 -0
- package/sdk/browser/package.json +13 -0
- package/sdk/go/go.mod +3 -0
- package/sdk/go/http.go +135 -0
- package/sdk/go/protocol.go +324 -0
- package/sdk/go/protocol_test.go +334 -0
- package/sdk/go/webhook.go +136 -0
- package/sdk/python/README.md +18 -0
- package/sdk/python/protocol_7h3/__init__.py +46 -0
- package/sdk/python/protocol_7h3/http.py +212 -0
- package/sdk/python/protocol_7h3/keys.py +149 -0
- package/sdk/python/protocol_7h3/protocol.py +525 -0
- package/sdk/python/protocol_7h3/queue.py +118 -0
- package/sdk/python/protocol_7h3/webhook.py +116 -0
- package/sdk/python/pyproject.toml +40 -0
- package/sdk/python/tests/test_conformance.py +110 -0
- package/sdk/python/tests/test_http.py +305 -0
- package/sdk/python/tests/test_keys.py +417 -0
- package/sdk/python/tests/test_queue.py +120 -0
- package/sdk/python/tests/test_webhook.py +345 -0
- package/sdk/rust/Cargo.lock +371 -0
- package/sdk/rust/Cargo.toml +25 -0
- package/sdk/rust/README.md +31 -0
- package/sdk/rust/fuzz/Cargo.toml +29 -0
- package/sdk/rust/fuzz/fuzz_targets/fuzz_canonicalize.rs +46 -0
- package/sdk/rust/fuzz/fuzz_targets/fuzz_decode.rs +11 -0
- package/sdk/rust/src/bin/aip_mcp_gateway.rs +59 -0
- package/sdk/rust/src/http.rs +145 -0
- package/sdk/rust/src/keys.rs +161 -0
- package/sdk/rust/src/lib.rs +688 -0
- package/sdk/rust/src/queue.rs +79 -0
- package/sdk/rust/src/webhook.rs +86 -0
- package/sdk/rust/tests/conformance.rs +148 -0
- package/sdk/rust/tests/gateway.rs +130 -0
- package/sdk/rust/tests/http_webhook_queue.rs +201 -0
- package/sdk/rust/tests/keys.rs +189 -0
- package/src/agentAdapter.test.ts +48 -0
- package/src/agentAdapter.ts +56 -0
- package/src/auditLog.test.ts +145 -0
- package/src/auditLog.ts +147 -0
- package/src/conformance.test.ts +136 -0
- package/src/conformanceVectors.ts +99 -0
- package/src/frameworkAdapters.test.ts +290 -0
- package/src/frameworkAdapters.ts +261 -0
- package/src/gateway.test.ts +343 -0
- package/src/gateway.ts +171 -0
- package/src/grpcBinding.test.ts +211 -0
- package/src/grpcBinding.ts +103 -0
- package/src/httpBinding.test.ts +376 -0
- package/src/httpBinding.ts +163 -0
- package/src/index.ts +32 -0
- package/src/keyInfra.test.ts +278 -0
- package/src/keyInfra.ts +228 -0
- package/src/keyRegistry.ts +59 -0
- package/src/keyRotation.test.ts +78 -0
- package/src/keyRotation.ts +72 -0
- package/src/mcpGateway.test.ts +129 -0
- package/src/mcpGateway.ts +250 -0
- package/src/mcpTransports.test.ts +92 -0
- package/src/mcpTransports.ts +169 -0
- package/src/mcpWrapper.test.ts +179 -0
- package/src/mcpWrapper.ts +206 -0
- package/src/policyEnforcer.test.ts +99 -0
- package/src/policyEnforcer.ts +169 -0
- package/src/policyTelemetryFeedback.test.ts +25 -0
- package/src/policyTelemetryFeedback.ts +38 -0
- package/src/protocol.bench.ts +37 -0
- package/src/protocol.test.ts +155 -0
- package/src/protocol.ts +413 -0
- package/src/protocolAgent.test.ts +105 -0
- package/src/protocolAgent.ts +169 -0
- package/src/protocolBinary.test.ts +165 -0
- package/src/protocolBinary.ts +312 -0
- package/src/protocolCapabilities.ts +70 -0
- package/src/protocolFuzz.advanced.test.ts +235 -0
- package/src/protocolFuzz.test.ts +111 -0
- package/src/protocolNegative.test.ts +97 -0
- package/src/protocolReplay.test.ts +71 -0
- package/src/protocolReplay.ts +194 -0
- package/src/protocolTransport.test.ts +556 -0
- package/src/protocolTransport.ts +483 -0
- package/src/queueBinding.test.ts +130 -0
- package/src/queueBinding.ts +102 -0
- package/src/rateLimiter.test.ts +96 -0
- package/src/rateLimiter.ts +46 -0
- package/src/redisClient.ts +140 -0
- package/src/redisIntegration.test.ts +134 -0
- package/src/replayStores.test.ts +141 -0
- package/src/replayStores.ts +82 -0
- package/src/revocation.test.ts +98 -0
- package/src/revocation.ts +0 -0
- package/src/routePolicy.test.ts +87 -0
- package/src/routePolicy.ts +72 -0
- package/src/runtimePolicy.test.ts +49 -0
- package/src/runtimePolicy.ts +81 -0
- package/src/runtimePolicyManager.test.ts +29 -0
- package/src/runtimePolicyManager.ts +50 -0
- package/src/runtimePolicyPresets.ts +43 -0
- package/src/signedResponse.test.ts +111 -0
- package/src/signedResponse.ts +83 -0
- package/src/webhookBinding.test.ts +144 -0
- package/src/webhookBinding.ts +115 -0
- package/src/wsBinding.test.ts +221 -0
- package/src/wsBinding.ts +100 -0
- package/tsconfig.json +15 -0
- package/tsconfig.lib.json +23 -0
- package/vite.lib.config.ts +16 -0
- package/agentAdapter.d.ts +0 -26
- package/conformanceVectors.d.ts +0 -20
- package/frameworkAdapters.d.ts +0 -72
- package/index.d.ts +0 -20
- package/index.js +0 -1702
- package/keyRotation.d.ts +0 -20
- package/mcpGateway.d.ts +0 -37
- package/mcpTransports.d.ts +0 -62
- package/mcpWrapper.d.ts +0 -83
- package/policyEnforcer.d.ts +0 -50
- package/policyTelemetryFeedback.d.ts +0 -11
- package/protocol.d.ts +0 -66
- package/protocolAgent.d.ts +0 -58
- package/protocolBinary.d.ts +0 -8
- package/protocolCapabilities.d.ts +0 -24
- package/protocolReplay.d.ts +0 -35
- package/protocolTransport.d.ts +0 -73
- package/redisClient.d.ts +0 -49
- package/replayStores.d.ts +0 -32
- package/revocation.d.ts +0 -71
- package/runtimePolicy.d.ts +0 -24
- package/runtimePolicyManager.d.ts +0 -15
- package/runtimePolicyPresets.d.ts +0 -11
|
@@ -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.4.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,305 @@
|
|
|
1
|
+
"""Tests for protocol_7h3.http — HTTP binding."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import time
|
|
6
|
+
import unittest
|
|
7
|
+
|
|
8
|
+
from protocol_7h3.protocol import sign_envelope_ed25519, sign_envelope_hmac
|
|
9
|
+
from protocol_7h3.http import (
|
|
10
|
+
DEFAULT_HEADER,
|
|
11
|
+
KeyRegistry,
|
|
12
|
+
StaticKeyRegistry,
|
|
13
|
+
build_signed_request_headers,
|
|
14
|
+
sign_http_request,
|
|
15
|
+
verify_http_envelope,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
# ED25519 test key pair from conformance vectors (SPKI public / PKCS8 private, base64url)
|
|
19
|
+
_TEST_PUBLIC_KEY = "MCowBQYDK2VwAyEA-mUFiTQtcKN4nnD19V_-Wyy4q19OivnAutRUPhOcC78"
|
|
20
|
+
_TEST_PRIVATE_KEY = "MC4CAQAwBQYDK2VwBCIEICheZbQGuDVb6hezIlcs0QnCHGxz6IhiLkC9M0qr8OOZ"
|
|
21
|
+
_SENDER = "agent.ed"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _make_valid_envelope(ttl_ms: int = 60_000) -> dict:
|
|
25
|
+
"""Return an unsigned envelope with a current timestamp."""
|
|
26
|
+
now = int(time.time() * 1000)
|
|
27
|
+
return {
|
|
28
|
+
"header": {
|
|
29
|
+
"version": "7h3/0.1",
|
|
30
|
+
"messageId": "test-msg-1",
|
|
31
|
+
"timestampMs": now,
|
|
32
|
+
"ttlMs": ttl_ms,
|
|
33
|
+
"sender": _SENDER,
|
|
34
|
+
"nonce": "test-nonce-1",
|
|
35
|
+
},
|
|
36
|
+
"body": {
|
|
37
|
+
"intent": "TASK",
|
|
38
|
+
"content": "hello http",
|
|
39
|
+
},
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _signed_envelope(ttl_ms: int = 60_000) -> dict:
|
|
44
|
+
env = _make_valid_envelope(ttl_ms=ttl_ms)
|
|
45
|
+
return sign_envelope_ed25519(env, _TEST_PRIVATE_KEY)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _registry() -> StaticKeyRegistry:
|
|
49
|
+
return StaticKeyRegistry({_SENDER: _TEST_PUBLIC_KEY})
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class TestVerifyHttpEnvelopeMissingHeader(unittest.TestCase):
|
|
53
|
+
"""1. verify_http_envelope: missing header."""
|
|
54
|
+
|
|
55
|
+
def test_missing_header_returns_false_and_reason(self):
|
|
56
|
+
ok, env, reason = verify_http_envelope({}, _registry())
|
|
57
|
+
self.assertFalse(ok)
|
|
58
|
+
self.assertIsNone(env)
|
|
59
|
+
self.assertEqual(reason, "missing-header")
|
|
60
|
+
|
|
61
|
+
def test_wrong_header_name_treated_as_missing(self):
|
|
62
|
+
headers = {"x-other-header": "{}"}
|
|
63
|
+
ok, env, reason = verify_http_envelope(headers, _registry())
|
|
64
|
+
self.assertFalse(ok)
|
|
65
|
+
self.assertEqual(reason, "missing-header")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class TestVerifyHttpEnvelopeInvalidJson(unittest.TestCase):
|
|
69
|
+
"""2. verify_http_envelope: invalid JSON."""
|
|
70
|
+
|
|
71
|
+
def test_malformed_json(self):
|
|
72
|
+
headers = {DEFAULT_HEADER: "not-json{{{"}
|
|
73
|
+
ok, env, reason = verify_http_envelope(headers, _registry())
|
|
74
|
+
self.assertFalse(ok)
|
|
75
|
+
self.assertIsNone(env)
|
|
76
|
+
self.assertEqual(reason, "malformed-envelope")
|
|
77
|
+
|
|
78
|
+
def test_json_without_required_keys(self):
|
|
79
|
+
headers = {DEFAULT_HEADER: json.dumps({"foo": "bar"})}
|
|
80
|
+
ok, env, reason = verify_http_envelope(headers, _registry())
|
|
81
|
+
self.assertFalse(ok)
|
|
82
|
+
self.assertEqual(reason, "malformed-envelope")
|
|
83
|
+
|
|
84
|
+
def test_json_missing_signature_key(self):
|
|
85
|
+
headers = {DEFAULT_HEADER: json.dumps({"header": {}, "body": {}})}
|
|
86
|
+
ok, env, reason = verify_http_envelope(headers, _registry())
|
|
87
|
+
self.assertFalse(ok)
|
|
88
|
+
self.assertEqual(reason, "malformed-envelope")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class TestVerifyHttpEnvelopeValidEd25519(unittest.TestCase):
|
|
92
|
+
"""3. verify_http_envelope: valid Ed25519 signed envelope."""
|
|
93
|
+
|
|
94
|
+
def test_valid_signed_envelope(self):
|
|
95
|
+
signed = _signed_envelope()
|
|
96
|
+
headers = {DEFAULT_HEADER: json.dumps(signed)}
|
|
97
|
+
ok, env, reason = verify_http_envelope(headers, _registry())
|
|
98
|
+
self.assertTrue(ok)
|
|
99
|
+
self.assertIsNotNone(env)
|
|
100
|
+
self.assertIsNone(reason)
|
|
101
|
+
|
|
102
|
+
def test_valid_envelope_mixed_case_header(self):
|
|
103
|
+
"""Headers are case-insensitive per HTTP spec."""
|
|
104
|
+
signed = _signed_envelope()
|
|
105
|
+
headers = {"X-7H3-Envelope": json.dumps(signed)}
|
|
106
|
+
ok, env, reason = verify_http_envelope(headers, _registry())
|
|
107
|
+
self.assertTrue(ok)
|
|
108
|
+
self.assertIsNone(reason)
|
|
109
|
+
|
|
110
|
+
def test_returns_envelope_dict_on_success(self):
|
|
111
|
+
signed = _signed_envelope()
|
|
112
|
+
headers = {DEFAULT_HEADER: json.dumps(signed)}
|
|
113
|
+
ok, env, reason = verify_http_envelope(headers, _registry())
|
|
114
|
+
self.assertTrue(ok)
|
|
115
|
+
self.assertIn("header", env)
|
|
116
|
+
self.assertIn("body", env)
|
|
117
|
+
self.assertIn("signature", env)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class TestVerifyHttpEnvelopeTampered(unittest.TestCase):
|
|
121
|
+
"""4. verify_http_envelope: tampered envelope."""
|
|
122
|
+
|
|
123
|
+
def test_tampered_content_fails_verification(self):
|
|
124
|
+
signed = _signed_envelope()
|
|
125
|
+
# Tamper with the body content after signing
|
|
126
|
+
signed["body"]["content"] = "tampered!"
|
|
127
|
+
headers = {DEFAULT_HEADER: json.dumps(signed)}
|
|
128
|
+
ok, env, reason = verify_http_envelope(headers, _registry())
|
|
129
|
+
self.assertFalse(ok)
|
|
130
|
+
self.assertEqual(reason, "invalid-signature")
|
|
131
|
+
|
|
132
|
+
def test_tampered_sender_fails_verification(self):
|
|
133
|
+
signed = _signed_envelope()
|
|
134
|
+
signed["header"]["sender"] = "evil.agent"
|
|
135
|
+
headers = {DEFAULT_HEADER: json.dumps(signed)}
|
|
136
|
+
# evil.agent not in registry → unknown-sender
|
|
137
|
+
ok, env, reason = verify_http_envelope(headers, _registry())
|
|
138
|
+
self.assertFalse(ok)
|
|
139
|
+
self.assertIn(reason, ("unknown-sender", "invalid-signature"))
|
|
140
|
+
|
|
141
|
+
def test_tampered_signature_value_fails(self):
|
|
142
|
+
signed = _signed_envelope()
|
|
143
|
+
signed["signature"]["value"] = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
|
|
144
|
+
headers = {DEFAULT_HEADER: json.dumps(signed)}
|
|
145
|
+
ok, env, reason = verify_http_envelope(headers, _registry())
|
|
146
|
+
self.assertFalse(ok)
|
|
147
|
+
self.assertEqual(reason, "invalid-signature")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class TestVerifyHttpEnvelopeUnknownSender(unittest.TestCase):
|
|
151
|
+
"""5. verify_http_envelope: unknown sender."""
|
|
152
|
+
|
|
153
|
+
def test_unknown_sender_ed25519(self):
|
|
154
|
+
signed = _signed_envelope()
|
|
155
|
+
# Empty registry — no known senders
|
|
156
|
+
empty_registry = StaticKeyRegistry({})
|
|
157
|
+
headers = {DEFAULT_HEADER: json.dumps(signed)}
|
|
158
|
+
ok, env, reason = verify_http_envelope(headers, empty_registry)
|
|
159
|
+
self.assertFalse(ok)
|
|
160
|
+
self.assertIsNone(env)
|
|
161
|
+
self.assertEqual(reason, "unknown-sender")
|
|
162
|
+
|
|
163
|
+
def test_unknown_sender_hmac(self):
|
|
164
|
+
env = _make_valid_envelope()
|
|
165
|
+
signed = sign_envelope_hmac(env, "mysecret", "key-1")
|
|
166
|
+
headers = {DEFAULT_HEADER: json.dumps(signed)}
|
|
167
|
+
|
|
168
|
+
class NoSecretRegistry(KeyRegistry):
|
|
169
|
+
def get_public_key(self, sender_id: str):
|
|
170
|
+
return None
|
|
171
|
+
def get_shared_secret(self, key_id: str):
|
|
172
|
+
return None # key_id "key-1" unknown
|
|
173
|
+
|
|
174
|
+
ok, result, reason = verify_http_envelope(headers, NoSecretRegistry())
|
|
175
|
+
self.assertFalse(ok)
|
|
176
|
+
self.assertEqual(reason, "unknown-sender")
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class TestSignHttpRequestRoundTrip(unittest.TestCase):
|
|
180
|
+
"""6. sign_http_request + verify_http_envelope round trip."""
|
|
181
|
+
|
|
182
|
+
def test_round_trip_succeeds(self):
|
|
183
|
+
unsigned = _make_valid_envelope()
|
|
184
|
+
headers = sign_http_request(unsigned, _TEST_PRIVATE_KEY)
|
|
185
|
+
ok, env, reason = verify_http_envelope(headers, _registry())
|
|
186
|
+
self.assertTrue(ok)
|
|
187
|
+
self.assertIsNone(reason)
|
|
188
|
+
self.assertEqual(env["body"]["content"], "hello http")
|
|
189
|
+
|
|
190
|
+
def test_round_trip_strict_ttl_expired(self):
|
|
191
|
+
# Create envelope that is already expired
|
|
192
|
+
past_ms = int(time.time() * 1000) - 120_000 # 2 minutes ago
|
|
193
|
+
envelope = {
|
|
194
|
+
"header": {
|
|
195
|
+
"version": "7h3/0.1",
|
|
196
|
+
"messageId": "expired-msg",
|
|
197
|
+
"timestampMs": past_ms,
|
|
198
|
+
"ttlMs": 1_000, # 1 second TTL → already expired
|
|
199
|
+
"sender": _SENDER,
|
|
200
|
+
"nonce": "nonce-x",
|
|
201
|
+
},
|
|
202
|
+
"body": {
|
|
203
|
+
"intent": "TASK",
|
|
204
|
+
"content": "stale",
|
|
205
|
+
},
|
|
206
|
+
}
|
|
207
|
+
signed = sign_envelope_ed25519(envelope, _TEST_PRIVATE_KEY)
|
|
208
|
+
headers = {DEFAULT_HEADER: json.dumps(signed)}
|
|
209
|
+
ok, env, reason = verify_http_envelope(headers, _registry(), strict_ttl=True)
|
|
210
|
+
self.assertFalse(ok)
|
|
211
|
+
self.assertEqual(reason, "ttl-expired")
|
|
212
|
+
|
|
213
|
+
def test_round_trip_strict_ttl_disabled(self):
|
|
214
|
+
# Same expired envelope but strict_ttl=False → signature still valid
|
|
215
|
+
past_ms = int(time.time() * 1000) - 120_000
|
|
216
|
+
envelope = {
|
|
217
|
+
"header": {
|
|
218
|
+
"version": "7h3/0.1",
|
|
219
|
+
"messageId": "expired-msg-2",
|
|
220
|
+
"timestampMs": past_ms,
|
|
221
|
+
"ttlMs": 1_000,
|
|
222
|
+
"sender": _SENDER,
|
|
223
|
+
"nonce": "nonce-y",
|
|
224
|
+
},
|
|
225
|
+
"body": {
|
|
226
|
+
"intent": "TASK",
|
|
227
|
+
"content": "stale but skip ttl check",
|
|
228
|
+
},
|
|
229
|
+
}
|
|
230
|
+
signed = sign_envelope_ed25519(envelope, _TEST_PRIVATE_KEY)
|
|
231
|
+
headers = {DEFAULT_HEADER: json.dumps(signed)}
|
|
232
|
+
ok, env, reason = verify_http_envelope(headers, _registry(), strict_ttl=False)
|
|
233
|
+
self.assertTrue(ok)
|
|
234
|
+
self.assertIsNone(reason)
|
|
235
|
+
|
|
236
|
+
def test_custom_header_name(self):
|
|
237
|
+
unsigned = _make_valid_envelope()
|
|
238
|
+
custom_hdr = "x-my-agent-envelope"
|
|
239
|
+
headers = sign_http_request(unsigned, _TEST_PRIVATE_KEY, header_name=custom_hdr)
|
|
240
|
+
self.assertIn(custom_hdr, headers)
|
|
241
|
+
ok, env, reason = verify_http_envelope(headers, _registry(), header_name=custom_hdr)
|
|
242
|
+
self.assertTrue(ok)
|
|
243
|
+
self.assertIsNone(reason)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
class TestBuildSignedRequestHeaders(unittest.TestCase):
|
|
247
|
+
"""7. build_signed_request_headers produces correct header key."""
|
|
248
|
+
|
|
249
|
+
def test_default_header_key_present(self):
|
|
250
|
+
headers = build_signed_request_headers(
|
|
251
|
+
sender=_SENDER,
|
|
252
|
+
private_key=_TEST_PRIVATE_KEY,
|
|
253
|
+
content="ping",
|
|
254
|
+
)
|
|
255
|
+
self.assertIn(DEFAULT_HEADER, headers)
|
|
256
|
+
|
|
257
|
+
def test_custom_header_key(self):
|
|
258
|
+
custom = "x-aip-envelope"
|
|
259
|
+
headers = build_signed_request_headers(
|
|
260
|
+
sender=_SENDER,
|
|
261
|
+
private_key=_TEST_PRIVATE_KEY,
|
|
262
|
+
content="ping",
|
|
263
|
+
header_name=custom,
|
|
264
|
+
)
|
|
265
|
+
self.assertIn(custom, headers)
|
|
266
|
+
self.assertNotIn(DEFAULT_HEADER, headers)
|
|
267
|
+
|
|
268
|
+
def test_header_value_is_valid_json_envelope(self):
|
|
269
|
+
headers = build_signed_request_headers(
|
|
270
|
+
sender=_SENDER,
|
|
271
|
+
private_key=_TEST_PRIVATE_KEY,
|
|
272
|
+
content="check me",
|
|
273
|
+
ttl_ms=30_000,
|
|
274
|
+
)
|
|
275
|
+
raw = headers[DEFAULT_HEADER]
|
|
276
|
+
envelope = json.loads(raw)
|
|
277
|
+
self.assertEqual(envelope["header"]["sender"], _SENDER)
|
|
278
|
+
self.assertEqual(envelope["body"]["content"], "check me")
|
|
279
|
+
self.assertEqual(envelope["signature"]["alg"], "ED25519")
|
|
280
|
+
|
|
281
|
+
def test_round_trip_verify(self):
|
|
282
|
+
headers = build_signed_request_headers(
|
|
283
|
+
sender=_SENDER,
|
|
284
|
+
private_key=_TEST_PRIVATE_KEY,
|
|
285
|
+
content="full round trip",
|
|
286
|
+
recipient="agent.receiver",
|
|
287
|
+
)
|
|
288
|
+
ok, env, reason = verify_http_envelope(headers, _registry())
|
|
289
|
+
self.assertTrue(ok)
|
|
290
|
+
self.assertIsNone(reason)
|
|
291
|
+
self.assertEqual(env["body"]["content"], "full round trip")
|
|
292
|
+
|
|
293
|
+
def test_recipient_is_included_when_provided(self):
|
|
294
|
+
headers = build_signed_request_headers(
|
|
295
|
+
sender=_SENDER,
|
|
296
|
+
private_key=_TEST_PRIVATE_KEY,
|
|
297
|
+
content="msg",
|
|
298
|
+
recipient="agent.b",
|
|
299
|
+
)
|
|
300
|
+
envelope = json.loads(headers[DEFAULT_HEADER])
|
|
301
|
+
self.assertEqual(envelope["header"].get("recipient"), "agent.b")
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
if __name__ == "__main__":
|
|
305
|
+
unittest.main()
|