@7h3/protocol 0.5.0 → 0.5.1
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/README.md +6 -1317
- package/agentAdapter.d.ts +26 -0
- package/auditLog.d.ts +54 -0
- package/capability.d.ts +67 -0
- package/cborCodec.d.ts +34 -0
- package/conformanceVectors.d.ts +20 -0
- package/encryption.d.ts +85 -0
- package/envelopeCbor.d.ts +33 -0
- package/frameworkAdapters.d.ts +72 -0
- package/gateway.d.ts +53 -0
- package/grpcBinding.d.ts +29 -0
- package/httpBinding.d.ts +50 -0
- package/index.d.ts +39 -0
- package/index.js +3662 -0
- package/keyInfra.d.ts +64 -0
- package/keyRegistry.d.ts +9 -0
- package/keyRotation.d.ts +20 -0
- package/mcpGateway.d.ts +37 -0
- package/mcpTransports.d.ts +62 -0
- package/mcpWrapper.d.ts +83 -0
- package/otel.d.ts +65 -0
- package/package.json +13 -121
- package/policyEnforcer.d.ts +50 -0
- package/policyTelemetryFeedback.d.ts +11 -0
- package/{src/protocol.d.ts → protocol.d.ts} +1 -2
- package/protocolAgent.d.ts +58 -0
- package/protocolBinary.d.ts +8 -0
- package/protocolCapabilities.d.ts +24 -0
- package/protocolReplay.d.ts +35 -0
- package/protocolTransport.d.ts +73 -0
- package/queueBinding.d.ts +43 -0
- package/rateLimiter.d.ts +18 -0
- package/{src/redisClient.ts → redisClient.d.ts} +25 -75
- package/replayStores.d.ts +98 -0
- package/revocation.d.ts +71 -0
- package/routePolicy.d.ts +30 -0
- package/runtimePolicy.d.ts +24 -0
- package/runtimePolicyManager.d.ts +15 -0
- package/runtimePolicyPresets.d.ts +11 -0
- package/signedResponse.d.ts +21 -0
- package/stream.d.ts +59 -0
- package/telemetry.d.ts +79 -0
- package/webhookBinding.d.ts +30 -0
- package/wsBinding.d.ts +52 -0
- package/.dockerignore +0 -19
- package/.github/dependabot.yml +0 -32
- package/.github/workflows/ci.yml +0 -31
- package/.github/workflows/publish.yml +0 -59
- package/.github/workflows/scorecard.yml +0 -37
- package/7h3.example.yaml +0 -125
- package/CHANGELOG.md +0 -152
- package/CONTRIBUTING.md +0 -82
- package/Dockerfile +0 -73
- package/GOVERNANCE.md +0 -62
- package/SECURITY.md +0 -70
- package/bench-results/replay-cache-full-1777891033256.json +0 -10
- package/bench-results/replay-cache-full-1777896317488.json +0 -10
- package/bench-results/replay-cache-full-1777900993184.json +0 -10
- package/bench-results/replay-cache-full-1777901019285.json +0 -10
- package/bench-results/replay-cache-quick-1777870170126.json +0 -10
- package/bench-results/signature-profiles-quick-1775875160079.json +0 -85
- package/bench-results/signature-profiles-quick-1775983539716.json +0 -85
- package/bench-results/signature-profiles-quick-1776237913190.json +0 -85
- package/bench-results/wire-codecs-full-1777891019803.json +0 -93
- package/bench-results/wire-codecs-full-1777896260964.json +0 -93
- package/bench-results/wire-codecs-full-1777901004247.json +0 -93
- package/bench-results/wire-codecs-quick-1775972879056.json +0 -93
- package/bench-results/wire-codecs-quick-1775983541111.json +0 -93
- package/bench-results/wire-codecs-quick-1776237914299.json +0 -93
- package/bench-results/wire-codecs-quick-1777841285236.json +0 -93
- package/bench-results/wire-codecs-quick-1777841321772.json +0 -93
- package/bench-results/wire-codecs-quick-1777841330408.json +0 -93
- package/bench-results/wire-codecs-quick-1777852886082.json +0 -93
- package/bench-results/wire-codecs-quick-1777852988773.json +0 -93
- package/bench-results/wire-codecs-quick-1777870188095.json +0 -93
- package/bench-results/wire-codecs-quick-1777870263918.json +0 -93
- package/bench-results/wire-codecs-quick-1777870455034.json +0 -93
- package/bench-results/wire-codecs-quick-1778816163081.json +0 -93
- package/bench-results/wire-codecs-quick-1778843936130.json +0 -93
- package/bin/7h3.ts +0 -406
- package/conformance/7h3_v0_1.json +0 -77
- package/conformance/7h3_v0_1_binary.json +0 -20
- package/conformance/aip_v0_1_binary.json +0 -20
- package/docker-compose.yaml +0 -77
- package/docs/ADOPTION_PLAN.md +0 -120
- package/docs/AGENTS.md +0 -77
- package/docs/AIP_RFC_v0.1.md +0 -97
- package/docs/AI_DECISION_CARD.md +0 -122
- package/docs/AI_RUNTIME_POLICY.json +0 -126
- package/docs/AI_RUNTIME_POLICY.yaml +0 -110
- package/docs/BACKPRESSURE_TUNING.md +0 -65
- package/docs/BENCHMARK_CLAIM_MATRIX.md +0 -42
- package/docs/BENCHMARK_REPORT_TEMPLATE.md +0 -169
- package/docs/BINARY_CODEC_BENCH.md +0 -23
- package/docs/CLEAN_CLONE_RUNBOOK.md +0 -36
- package/docs/CLOCK_SKEW_POLICY.md +0 -30
- package/docs/DISTRIBUTED_REPLAY.md +0 -142
- package/docs/FUZZ_CAMPAIGN.md +0 -121
- package/docs/GATEWAY.md +0 -195
- package/docs/KEY_MANAGEMENT_POLICY.md +0 -53
- package/docs/KEY_REVOCATION.md +0 -69
- package/docs/MCP_WRAPPER.md +0 -159
- package/docs/MIGRATION_GUIDE.md +0 -40
- package/docs/OPERATORS.md +0 -184
- package/docs/PERF_REGRESSION_POLICY.md +0 -34
- package/docs/PROJECT_EXAMINATION_2026-05-31.md +0 -219
- package/docs/RELEASE_BENCHMARK_REPORT_2026-05-15.md +0 -135
- package/docs/RELEASE_GATE.md +0 -25
- package/docs/RELEASE_NOTES_v0.1.0.md +0 -54
- package/docs/SECURITY_REVIEW_2026-06-05.md +0 -165
- package/docs/TELEMETRY.md +0 -41
- package/docs/THREAT_MODEL.md +0 -89
- package/docs/VERSIONING_POLICY.md +0 -30
- package/docs/assets/banner-github.png +0 -0
- package/docs/assets/banner.png +0 -0
- package/docs/assets/banner.svg +0 -123
- package/eslint.config.js +0 -15
- package/fuzz/ts/harness-decode.ts +0 -136
- package/fuzz/ts/harness-verify.ts +0 -121
- package/fuzz/ts/run.ts +0 -35
- package/mcp-server/README.md +0 -38
- package/mcp-server/package-lock.json +0 -1187
- package/mcp-server/package.json +0 -35
- package/mcp-server/src/index.ts +0 -236
- package/mcp-server/tsconfig.json +0 -14
- package/scripts/aip-framework-quickstart.ts +0 -110
- package/scripts/aip-mcp-gateway.ts +0 -38
- package/scripts/aip-mcp-wrap-demo.ts +0 -72
- package/scripts/aip-quickstart.ts +0 -60
- package/scripts/bench-diff.ts +0 -118
- package/scripts/bench-protocol-e2e.ts +0 -937
- package/scripts/bench-protocol-openloop.ts +0 -1397
- package/scripts/bench-replay-cache.ts +0 -76
- package/scripts/bench-signature-profiles.ts +0 -180
- package/scripts/bench-wire-codecs.ts +0 -161
- package/scripts/build-binary-conformance.ts +0 -36
- package/scripts/build-release-dashboard.ts +0 -175
- package/scripts/canary-rollout.ts +0 -38
- package/scripts/mcpGatewayCli.test.ts +0 -116
- package/scripts/prepare-aip-package.ts +0 -88
- package/scripts/regen-conformance-sigs.ts +0 -18
- package/scripts/release-gate.ts +0 -19
- package/scripts/validate-runtime-policy.ts +0 -18
- package/sdk/browser/index.test.ts +0 -162
- package/sdk/browser/index.ts +0 -257
- package/sdk/browser/package.json +0 -13
- package/sdk/go/cbor.go +0 -551
- package/sdk/go/cbor_test.go +0 -232
- package/sdk/go/encryption.go +0 -280
- package/sdk/go/encryption_test.go +0 -318
- package/sdk/go/go.mod +0 -7
- package/sdk/go/go.sum +0 -4
- package/sdk/go/http.go +0 -135
- package/sdk/go/protocol.go +0 -324
- package/sdk/go/protocol_test.go +0 -334
- package/sdk/go/replay.go +0 -121
- package/sdk/go/replay_test.go +0 -149
- package/sdk/go/webhook.go +0 -136
- package/sdk/pq/package-lock.json +0 -1358
- package/sdk/pq/package.json +0 -42
- package/sdk/pq/src/index.test.ts +0 -143
- package/sdk/pq/src/index.ts +0 -166
- package/sdk/pq/tsconfig.json +0 -14
- package/sdk/pq/vitest.config.ts +0 -7
- package/sdk/python/README.md +0 -18
- package/sdk/python/protocol_7h3/__init__.py +0 -46
- package/sdk/python/protocol_7h3/encryption.py +0 -252
- package/sdk/python/protocol_7h3/http.py +0 -212
- package/sdk/python/protocol_7h3/keys.py +0 -149
- package/sdk/python/protocol_7h3/pq.py +0 -244
- package/sdk/python/protocol_7h3/protocol.py +0 -525
- package/sdk/python/protocol_7h3/queue.py +0 -118
- package/sdk/python/protocol_7h3/replay.py +0 -98
- package/sdk/python/protocol_7h3/webhook.py +0 -116
- package/sdk/python/pyproject.toml +0 -40
- package/sdk/python/tests/test_conformance.py +0 -110
- package/sdk/python/tests/test_encryption.py +0 -206
- package/sdk/python/tests/test_http.py +0 -305
- package/sdk/python/tests/test_keys.py +0 -417
- package/sdk/python/tests/test_queue.py +0 -120
- package/sdk/python/tests/test_webhook.py +0 -345
- package/sdk/rust/Cargo.lock +0 -371
- package/sdk/rust/Cargo.toml +0 -25
- package/sdk/rust/README.md +0 -31
- package/sdk/rust/fuzz/Cargo.toml +0 -29
- package/sdk/rust/fuzz/fuzz_targets/fuzz_canonicalize.rs +0 -46
- package/sdk/rust/fuzz/fuzz_targets/fuzz_decode.rs +0 -11
- package/sdk/rust/src/bin/aip_mcp_gateway.rs +0 -59
- package/sdk/rust/src/http.rs +0 -145
- package/sdk/rust/src/keys.rs +0 -161
- package/sdk/rust/src/lib.rs +0 -688
- package/sdk/rust/src/queue.rs +0 -79
- package/sdk/rust/src/webhook.rs +0 -86
- package/sdk/rust/tests/conformance.rs +0 -148
- package/sdk/rust/tests/gateway.rs +0 -130
- package/sdk/rust/tests/http_webhook_queue.rs +0 -201
- package/sdk/rust/tests/keys.rs +0 -189
- package/sdk/threshold/index.d.ts +0 -68
- package/sdk/threshold/index.d.ts.map +0 -1
- package/sdk/threshold/index.js +0 -254
- package/sdk/threshold/package-lock.json +0 -1361
- package/sdk/threshold/package.json +0 -39
- package/sdk/threshold/src/index.d.ts +0 -68
- package/sdk/threshold/src/index.d.ts.map +0 -1
- package/sdk/threshold/src/index.js +0 -254
- package/sdk/threshold/src/index.test.ts +0 -238
- package/sdk/threshold/src/index.ts +0 -355
- package/sdk/threshold/tsconfig.json +0 -19
- package/sdk/threshold/vitest.config.ts +0 -12
- package/src/agentAdapter.test.ts +0 -48
- package/src/agentAdapter.ts +0 -56
- package/src/auditLog.test.ts +0 -145
- package/src/auditLog.ts +0 -147
- package/src/capability.test.ts +0 -504
- package/src/capability.ts +0 -380
- package/src/cborCodec.test.ts +0 -263
- package/src/cborCodec.ts +0 -339
- package/src/conformance.test.ts +0 -136
- package/src/conformanceVectors.ts +0 -99
- package/src/encryption.test.ts +0 -206
- package/src/encryption.ts +0 -245
- package/src/envelopeCbor.ts +0 -140
- package/src/frameworkAdapters.test.ts +0 -290
- package/src/frameworkAdapters.ts +0 -261
- package/src/gateway.test.ts +0 -343
- package/src/gateway.ts +0 -246
- package/src/grpcBinding.test.ts +0 -211
- package/src/grpcBinding.ts +0 -103
- package/src/httpBinding.test.ts +0 -376
- package/src/httpBinding.ts +0 -189
- package/src/index.ts +0 -39
- package/src/keyInfra.test.ts +0 -278
- package/src/keyInfra.ts +0 -228
- package/src/keyRegistry.ts +0 -59
- package/src/keyRotation.test.ts +0 -78
- package/src/keyRotation.ts +0 -72
- package/src/mcpGateway.test.ts +0 -129
- package/src/mcpGateway.ts +0 -250
- package/src/mcpTransports.test.ts +0 -92
- package/src/mcpTransports.ts +0 -169
- package/src/mcpWrapper.test.ts +0 -179
- package/src/mcpWrapper.ts +0 -206
- package/src/otel.ts +0 -136
- package/src/policyEnforcer.test.ts +0 -99
- package/src/policyEnforcer.ts +0 -169
- package/src/policyTelemetryFeedback.test.ts +0 -25
- package/src/policyTelemetryFeedback.ts +0 -38
- package/src/protocol.bench.ts +0 -37
- package/src/protocol.d.ts.map +0 -1
- package/src/protocol.js +0 -294
- package/src/protocol.test.ts +0 -155
- package/src/protocol.ts +0 -414
- package/src/protocolAgent.test.ts +0 -105
- package/src/protocolAgent.ts +0 -169
- package/src/protocolBinary.test.ts +0 -165
- package/src/protocolBinary.ts +0 -312
- package/src/protocolCapabilities.ts +0 -70
- package/src/protocolFuzz.advanced.test.ts +0 -235
- package/src/protocolFuzz.test.ts +0 -111
- package/src/protocolNegative.test.ts +0 -97
- package/src/protocolReplay.test.ts +0 -71
- package/src/protocolReplay.ts +0 -194
- package/src/protocolTransport.test.ts +0 -556
- package/src/protocolTransport.ts +0 -483
- package/src/queueBinding.test.ts +0 -130
- package/src/queueBinding.ts +0 -102
- package/src/rateLimiter.test.ts +0 -96
- package/src/rateLimiter.ts +0 -46
- package/src/redisIntegration.test.ts +0 -134
- package/src/replayStores.test.ts +0 -273
- package/src/replayStores.ts +0 -215
- package/src/revocation.test.ts +0 -98
- package/src/revocation.ts +0 -0
- package/src/routePolicy.test.ts +0 -87
- package/src/routePolicy.ts +0 -72
- package/src/runtimePolicy.test.ts +0 -49
- package/src/runtimePolicy.ts +0 -81
- package/src/runtimePolicyManager.test.ts +0 -29
- package/src/runtimePolicyManager.ts +0 -50
- package/src/runtimePolicyPresets.ts +0 -43
- package/src/signedResponse.test.ts +0 -111
- package/src/signedResponse.ts +0 -83
- package/src/stream.test.ts +0 -254
- package/src/stream.ts +0 -417
- package/src/telemetry.test.ts +0 -251
- package/src/telemetry.ts +0 -299
- package/src/webhookBinding.test.ts +0 -144
- package/src/webhookBinding.ts +0 -115
- package/src/wsBinding.test.ts +0 -221
- package/src/wsBinding.ts +0 -200
- package/tsconfig.json +0 -15
- package/tsconfig.lib.json +0 -23
- package/vite.lib.config.ts +0 -16
- package/vitest.config.ts +0 -11
|
@@ -1,98 +0,0 @@
|
|
|
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)
|
|
@@ -1,116 +0,0 @@
|
|
|
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)
|
|
@@ -1,40 +0,0 @@
|
|
|
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"]
|
|
@@ -1,110 +0,0 @@
|
|
|
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()
|
|
@@ -1,206 +0,0 @@
|
|
|
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()
|