@7h3/protocol 0.4.0 → 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 (63) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/README.md +1169 -175
  3. package/bin/7h3.ts +22 -1
  4. package/docs/assets/banner-github.png +0 -0
  5. package/docs/assets/banner.svg +123 -0
  6. package/package.json +55 -13
  7. package/sdk/browser/package.json +1 -1
  8. package/sdk/go/cbor.go +551 -0
  9. package/sdk/go/cbor_test.go +232 -0
  10. package/sdk/go/encryption.go +280 -0
  11. package/sdk/go/encryption_test.go +318 -0
  12. package/sdk/go/go.mod +5 -1
  13. package/sdk/go/go.sum +4 -0
  14. package/sdk/go/replay.go +121 -0
  15. package/sdk/go/replay_test.go +149 -0
  16. package/sdk/pq/package-lock.json +1358 -0
  17. package/sdk/pq/package.json +42 -0
  18. package/sdk/pq/src/index.test.ts +143 -0
  19. package/sdk/pq/src/index.ts +166 -0
  20. package/sdk/pq/tsconfig.json +14 -0
  21. package/sdk/pq/vitest.config.ts +7 -0
  22. package/sdk/python/protocol_7h3/encryption.py +252 -0
  23. package/sdk/python/protocol_7h3/pq.py +244 -0
  24. package/sdk/python/protocol_7h3/replay.py +98 -0
  25. package/sdk/python/pyproject.toml +1 -1
  26. package/sdk/python/tests/test_encryption.py +206 -0
  27. package/sdk/rust/Cargo.lock +1 -1
  28. package/sdk/rust/Cargo.toml +1 -1
  29. package/sdk/threshold/index.d.ts +68 -0
  30. package/sdk/threshold/index.d.ts.map +1 -0
  31. package/sdk/threshold/index.js +254 -0
  32. package/sdk/threshold/package-lock.json +1361 -0
  33. package/sdk/threshold/package.json +39 -0
  34. package/sdk/threshold/src/index.d.ts +68 -0
  35. package/sdk/threshold/src/index.d.ts.map +1 -0
  36. package/sdk/threshold/src/index.js +254 -0
  37. package/sdk/threshold/src/index.test.ts +238 -0
  38. package/sdk/threshold/src/index.ts +355 -0
  39. package/sdk/threshold/tsconfig.json +19 -0
  40. package/sdk/threshold/vitest.config.ts +12 -0
  41. package/src/capability.test.ts +504 -0
  42. package/src/capability.ts +380 -0
  43. package/src/cborCodec.test.ts +263 -0
  44. package/src/cborCodec.ts +339 -0
  45. package/src/encryption.test.ts +206 -0
  46. package/src/encryption.ts +245 -0
  47. package/src/envelopeCbor.ts +140 -0
  48. package/src/gateway.ts +75 -0
  49. package/src/httpBinding.ts +37 -11
  50. package/src/index.ts +7 -0
  51. package/src/otel.ts +136 -0
  52. package/src/protocol.d.ts +67 -0
  53. package/src/protocol.d.ts.map +1 -0
  54. package/src/protocol.js +294 -0
  55. package/src/protocol.ts +1 -0
  56. package/src/replayStores.test.ts +133 -1
  57. package/src/replayStores.ts +136 -3
  58. package/src/stream.test.ts +254 -0
  59. package/src/stream.ts +417 -0
  60. package/src/telemetry.test.ts +251 -0
  61. package/src/telemetry.ts +299 -0
  62. package/src/wsBinding.ts +100 -0
  63. package/vitest.config.ts +11 -0
@@ -0,0 +1,244 @@
1
+ """
2
+ Post-quantum signatures for 7h3 Protocol.
3
+
4
+ Provides ML-DSA-65 and ML-DSA-87 (formerly Dilithium3 / Dilithium5) via
5
+ the dilithium-py pure-Python library.
6
+
7
+ Install the dependency:
8
+ pip install dilithium-py
9
+
10
+ Mapping:
11
+ ML-DSA-44 ≈ Dilithium2 (security category 2)
12
+ ML-DSA-65 ≈ Dilithium3 (security category 3)
13
+ ML-DSA-87 ≈ Dilithium5 (security category 5)
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import base64
19
+ import json
20
+ import os
21
+ import time
22
+ from typing import Any, Dict, Optional, Tuple
23
+
24
+ # ---------------------------------------------------------------------------
25
+ # Optional dilithium-py backend
26
+ # ---------------------------------------------------------------------------
27
+
28
+ try:
29
+ from dilithium_py.dilithium import Dilithium2, Dilithium3, Dilithium5 # type: ignore[import]
30
+
31
+ _HAS_DILITHIUM = True
32
+ except ImportError:
33
+ _HAS_DILITHIUM = False
34
+
35
+
36
+ def _require_dilithium() -> None:
37
+ if not _HAS_DILITHIUM:
38
+ raise ImportError(
39
+ "dilithium-py is required for post-quantum signatures.\n"
40
+ "Install it with:\n\n"
41
+ " pip install dilithium-py\n"
42
+ )
43
+
44
+
45
+ # ---------------------------------------------------------------------------
46
+ # Base64url helpers
47
+ # ---------------------------------------------------------------------------
48
+
49
+
50
+ def _to_b64url(data: bytes) -> str:
51
+ """Encode bytes to base64url without padding."""
52
+ return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
53
+
54
+
55
+ def _from_b64url(value: str) -> bytes:
56
+ """Decode base64url (with or without padding)."""
57
+ padded = value + "=" * ((-len(value)) % 4)
58
+ return base64.urlsafe_b64decode(padded)
59
+
60
+
61
+ # ---------------------------------------------------------------------------
62
+ # Level → Dilithium implementation mapping
63
+ # ---------------------------------------------------------------------------
64
+
65
+ _LEVEL_MAP = {
66
+ 44: "Dilithium2",
67
+ 65: "Dilithium3",
68
+ 87: "Dilithium5",
69
+ }
70
+
71
+
72
+ def _get_impl(level: int) -> Any:
73
+ """Return the Dilithium implementation for the given ML-DSA level."""
74
+ _require_dilithium()
75
+ if level == 44:
76
+ return Dilithium2
77
+ if level == 65:
78
+ return Dilithium3
79
+ if level == 87:
80
+ return Dilithium5
81
+ raise ValueError(f"Unsupported ML-DSA level: {level}. Use 44, 65, or 87.")
82
+
83
+
84
+ def _alg_name(level: int) -> str:
85
+ if level == 44:
86
+ return "ML-DSA-44"
87
+ if level == 65:
88
+ return "ML-DSA-65"
89
+ if level == 87:
90
+ return "ML-DSA-87"
91
+ raise ValueError(f"Unsupported ML-DSA level: {level}")
92
+
93
+
94
+ # ---------------------------------------------------------------------------
95
+ # Canonicalization (mirrors TypeScript canonicalizeEnvelope)
96
+ # ---------------------------------------------------------------------------
97
+
98
+
99
+ def _canonicalize_envelope(envelope: Dict[str, Any]) -> str:
100
+ """Produce the canonical JSON string for signing (matches TS implementation)."""
101
+ header = envelope["header"]
102
+ body = envelope["body"]
103
+
104
+ # Build canonical header (sorted keys, only defined fields)
105
+ header_parts: list[str] = [
106
+ f'"messageId":{json.dumps(header["messageId"])}',
107
+ f'"nonce":{json.dumps(header["nonce"])}',
108
+ ]
109
+ if header.get("recipient") is not None:
110
+ header_parts.append(f'"recipient":{json.dumps(header["recipient"])}')
111
+ header_parts.append(f'"sender":{json.dumps(header["sender"])}')
112
+ header_parts.append(f'"timestampMs":{header["timestampMs"]}')
113
+ header_parts.append(f'"ttlMs":{header["ttlMs"]}')
114
+ header_parts.append(f'"version":{json.dumps(header["version"])}')
115
+ canonical_header = "{" + ",".join(header_parts) + "}"
116
+
117
+ # Build canonical body (sorted keys, only defined fields)
118
+ body_parts: list[str] = []
119
+ if body.get("capability") is not None:
120
+ body_parts.append(f'"capability":{json.dumps(body["capability"])}')
121
+ body_parts.append(f'"content":{json.dumps(body["content"])}')
122
+ if body.get("correlationId") is not None:
123
+ body_parts.append(f'"correlationId":{json.dumps(body["correlationId"])}')
124
+ body_parts.append(f'"intent":{json.dumps(body["intent"])}')
125
+ canonical_body = "{" + ",".join(body_parts) + "}"
126
+
127
+ return '{"body":' + canonical_body + ',"header":' + canonical_header + "}"
128
+
129
+
130
+ # ---------------------------------------------------------------------------
131
+ # Public API
132
+ # ---------------------------------------------------------------------------
133
+
134
+
135
+ def generate_ml_dsa_keypair(level: int = 65) -> Tuple[str, str]:
136
+ """
137
+ Generate a fresh ML-DSA keypair.
138
+
139
+ Parameters
140
+ ----------
141
+ level : int
142
+ Security level: 44 (≈ ML-DSA-44), 65 (≈ ML-DSA-65), 87 (≈ ML-DSA-87).
143
+ Default is 65.
144
+
145
+ Returns
146
+ -------
147
+ (public_key_b64url, secret_key_b64url) : tuple[str, str]
148
+ Both keys encoded as base64url without padding.
149
+ """
150
+ impl = _get_impl(level)
151
+ pk, sk = impl.keygen()
152
+ return _to_b64url(pk), _to_b64url(sk)
153
+
154
+
155
+ def sign_envelope_pq(
156
+ envelope_dict: Dict[str, Any],
157
+ secret_key_b64url: str,
158
+ level: int = 65,
159
+ ) -> Dict[str, Any]:
160
+ """
161
+ Sign a protocol envelope dict with ML-DSA.
162
+
163
+ Parameters
164
+ ----------
165
+ envelope_dict : dict
166
+ Envelope with 'header' and 'body' keys. Any existing 'signature' is ignored.
167
+ secret_key_b64url : str
168
+ Base64url-encoded secret key (from generate_ml_dsa_keypair).
169
+ level : int
170
+ ML-DSA security level (44, 65, or 87). Default 65.
171
+
172
+ Returns
173
+ -------
174
+ dict
175
+ Copy of envelope_dict with 'signature' populated.
176
+ """
177
+ impl = _get_impl(level)
178
+ alg = _alg_name(level)
179
+
180
+ # Strip existing signature for canonicalization
181
+ unsigned = {k: v for k, v in envelope_dict.items() if k != "signature"}
182
+ canonical = _canonicalize_envelope(unsigned)
183
+ message = canonical.encode("utf-8")
184
+
185
+ sk = _from_b64url(secret_key_b64url)
186
+ sig_bytes = impl.sign(sk, message)
187
+ sig_b64url = _to_b64url(sig_bytes)
188
+ key_id = secret_key_b64url[:16]
189
+
190
+ return {
191
+ **unsigned,
192
+ "signature": {
193
+ "alg": alg,
194
+ "keyId": key_id,
195
+ "value": sig_b64url,
196
+ },
197
+ }
198
+
199
+
200
+ def verify_envelope_pq(
201
+ envelope_dict: Dict[str, Any],
202
+ public_key_b64url: str,
203
+ level: Optional[int] = None,
204
+ ) -> bool:
205
+ """
206
+ Verify a ML-DSA-signed protocol envelope.
207
+
208
+ Parameters
209
+ ----------
210
+ envelope_dict : dict
211
+ Signed envelope with 'signature' key.
212
+ public_key_b64url : str
213
+ Base64url-encoded public key.
214
+ level : int or None
215
+ ML-DSA security level. If None, inferred from signature.alg field.
216
+
217
+ Returns
218
+ -------
219
+ bool
220
+ True if the signature is valid, False otherwise.
221
+ """
222
+ sig = envelope_dict.get("signature")
223
+ if not sig:
224
+ return False
225
+
226
+ alg: str = sig.get("alg", "")
227
+ if level is None:
228
+ # Infer level from alg field
229
+ _alg_to_level = {"ML-DSA-44": 44, "ML-DSA-65": 65, "ML-DSA-87": 87}
230
+ level = _alg_to_level.get(alg)
231
+ if level is None:
232
+ return False
233
+
234
+ impl = _get_impl(level)
235
+ unsigned = {k: v for k, v in envelope_dict.items() if k != "signature"}
236
+ canonical = _canonicalize_envelope(unsigned)
237
+ message = canonical.encode("utf-8")
238
+
239
+ pk = _from_b64url(public_key_b64url)
240
+ try:
241
+ sig_bytes = _from_b64url(sig["value"])
242
+ return bool(impl.verify(pk, message, sig_bytes))
243
+ except Exception:
244
+ return False
@@ -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)
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "7h3-protocol"
7
- version = "0.4.0"
7
+ version = "0.5.0"
8
8
  description = "7h3 Protocol — Python SDK. Deterministic, signed, replay-safe AI-to-AI messaging. Wire version 7h3/0.1."
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -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()
@@ -201,7 +201,7 @@ dependencies = [
201
201
 
202
202
  [[package]]
203
203
  name = "protocol-7h3"
204
- version = "0.4.0"
204
+ version = "0.5.0"
205
205
  dependencies = [
206
206
  "base64",
207
207
  "ed25519-dalek",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "protocol-7h3"
3
- version = "0.4.0"
3
+ version = "0.5.0"
4
4
  edition = "2021"
5
5
  description = "7h3 Protocol — Rust SDK. Deterministic, signed, replay-safe AI-to-AI messaging with TypeScript/Python parity. Wire version 7h3/0.1."
6
6
  license = "MIT"
@@ -0,0 +1,68 @@
1
+ export interface ProtocolHeader {
2
+ version: string;
3
+ messageId: string;
4
+ timestampMs: number;
5
+ ttlMs: number;
6
+ sender: string;
7
+ recipient?: string;
8
+ nonce: string;
9
+ }
10
+ export interface ProtocolBody {
11
+ intent: string;
12
+ content: string;
13
+ capability?: string;
14
+ correlationId?: string;
15
+ }
16
+ export interface ProtocolEnvelope {
17
+ header: ProtocolHeader;
18
+ body: ProtocolBody;
19
+ }
20
+ export interface BlsKeyPair {
21
+ publicKey: string;
22
+ privateKey: string;
23
+ }
24
+ export interface ThresholdConfig {
25
+ m: number;
26
+ n: number;
27
+ }
28
+ export interface ThresholdSignature {
29
+ alg: 'BLS-G2-2';
30
+ keyId: string;
31
+ value: string;
32
+ signerIds: string[];
33
+ threshold: ThresholdConfig;
34
+ }
35
+ export interface ThresholdEnvelope extends ProtocolEnvelope {
36
+ thresholdSignature: ThresholdSignature;
37
+ }
38
+ /**
39
+ * Canonical serialization of a protocol envelope for signing.
40
+ * Must match the canonical format in @7h3/protocol.
41
+ */
42
+ export declare function canonicalizeEnvelopeForBls(envelope: ProtocolEnvelope): string;
43
+ export declare function generateBlsKeyPair(): BlsKeyPair;
44
+ export declare function signEnvelopeBls(envelope: ProtocolEnvelope, privateKeyBase64Url: string, signerId: string): Promise<{
45
+ signerId: string;
46
+ partialSig: string;
47
+ canonicalHash: string;
48
+ }>;
49
+ export declare function aggregateSignatures(partialSigs: Array<{
50
+ signerId: string;
51
+ partialSig: string;
52
+ }>, publicKeys: Record<string, string>, // signerId → BLS public key (base64url)
53
+ envelope: ProtocolEnvelope, config: ThresholdConfig): Promise<ThresholdEnvelope>;
54
+ export declare function verifyThresholdEnvelope(envelope: ThresholdEnvelope, participantPublicKeys: Record<string, string>, config: ThresholdConfig): Promise<boolean>;
55
+ /**
56
+ * Split a BLS private key into N shares using Shamir's Secret Sharing.
57
+ * Any M shares can reconstruct the original key.
58
+ * Returns N shares as base64url strings.
59
+ * Share format: 1 byte index (1-based) || 32 bytes value
60
+ */
61
+ export declare function splitPrivateKey(privateKeyBase64Url: string, m: number, n: number): string[];
62
+ /**
63
+ * Reconstruct a BLS private key from M or more shares using Lagrange interpolation.
64
+ * @param shares - array of share strings (base64url, at least m of them)
65
+ * @param m - minimum number of shares required (used for validation only)
66
+ */
67
+ export declare function reconstructPrivateKey(shares: string[], m: number): string;
68
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["src/index.ts"],"names":[],"mappings":"AAIA,MAAM,WAAW,cAAc;IAC7B,OAAO,EAAE,MAAM,CAAA;IACf,SAAS,EAAE,MAAM,CAAA;IACjB,WAAW,EAAE,MAAM,CAAA;IACnB,KAAK,EAAE,MAAM,CAAA;IACb,MAAM,EAAE,MAAM,CAAA;IACd,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,KAAK,EAAE,MAAM,CAAA;CACd;AAED,MAAM,WAAW,YAAY;IAC3B,MAAM,EAAE,MAAM,CAAA;IACd,OAAO,EAAE,MAAM,CAAA;IACf,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,aAAa,CAAC,EAAE,MAAM,CAAA;CACvB;AAED,MAAM,WAAW,gBAAgB;IAC/B,MAAM,EAAE,cAAc,CAAA;IACtB,IAAI,EAAE,YAAY,CAAA;CACnB;AAID,MAAM,WAAW,UAAU;IACzB,SAAS,EAAE,MAAM,CAAA;IACjB,UAAU,EAAE,MAAM,CAAA;CACnB;AAED,MAAM,WAAW,eAAe;IAC9B,CAAC,EAAE,MAAM,CAAA;IACT,CAAC,EAAE,MAAM,CAAA;CACV;AAED,MAAM,WAAW,kBAAkB;IACjC,GAAG,EAAE,UAAU,CAAA;IACf,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,MAAM,CAAA;IACb,SAAS,EAAE,MAAM,EAAE,CAAA;IACnB,SAAS,EAAE,eAAe,CAAA;CAC3B;AAED,MAAM,WAAW,iBAAkB,SAAQ,gBAAgB;IACzD,kBAAkB,EAAE,kBAAkB,CAAA;CACvC;AAuBD;;;GAGG;AACH,wBAAgB,0BAA0B,CAAC,QAAQ,EAAE,gBAAgB,GAAG,MAAM,CAyB7E;AAID,wBAAgB,kBAAkB,IAAI,UAAU,CAO/C;AAID,wBAAsB,eAAe,CACnC,QAAQ,EAAE,gBAAgB,EAC1B,mBAAmB,EAAE,MAAM,EAC3B,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,MAAM,CAAC;IAAC,aAAa,EAAE,MAAM,CAAA;CAAE,CAAC,CAW1E;AAID,wBAAsB,mBAAmB,CACvC,WAAW,EAAE,KAAK,CAAC;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,MAAM,CAAA;CAAE,CAAC,EAC5D,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAAG,wCAAwC;AAC7E,QAAQ,EAAE,gBAAgB,EAC1B,MAAM,EAAE,eAAe,GACtB,OAAO,CAAC,iBAAiB,CAAC,CAkC5B;AAID,wBAAsB,uBAAuB,CAC3C,QAAQ,EAAE,iBAAiB,EAC3B,qBAAqB,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAC7C,MAAM,EAAE,eAAe,GACtB,OAAO,CAAC,OAAO,CAAC,CA2BlB;AA+DD;;;;;GAKG;AACH,wBAAgB,eAAe,CAC7B,mBAAmB,EAAE,MAAM,EAC3B,CAAC,EAAE,MAAM,EACT,CAAC,EAAE,MAAM,GACR,MAAM,EAAE,CA6BV;AAED;;;;GAIG;AACH,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,MAAM,EAAE,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,CAqCzE"}