@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.
- package/CHANGELOG.md +60 -0
- package/README.md +1169 -175
- package/bin/7h3.ts +22 -1
- package/docs/assets/banner-github.png +0 -0
- package/docs/assets/banner.svg +123 -0
- package/package.json +55 -13
- package/sdk/browser/package.json +1 -1
- package/sdk/go/cbor.go +551 -0
- package/sdk/go/cbor_test.go +232 -0
- package/sdk/go/encryption.go +280 -0
- package/sdk/go/encryption_test.go +318 -0
- package/sdk/go/go.mod +5 -1
- package/sdk/go/go.sum +4 -0
- package/sdk/go/replay.go +121 -0
- package/sdk/go/replay_test.go +149 -0
- package/sdk/pq/package-lock.json +1358 -0
- package/sdk/pq/package.json +42 -0
- package/sdk/pq/src/index.test.ts +143 -0
- package/sdk/pq/src/index.ts +166 -0
- package/sdk/pq/tsconfig.json +14 -0
- package/sdk/pq/vitest.config.ts +7 -0
- package/sdk/python/protocol_7h3/encryption.py +252 -0
- package/sdk/python/protocol_7h3/pq.py +244 -0
- package/sdk/python/protocol_7h3/replay.py +98 -0
- package/sdk/python/pyproject.toml +1 -1
- package/sdk/python/tests/test_encryption.py +206 -0
- package/sdk/rust/Cargo.lock +1 -1
- package/sdk/rust/Cargo.toml +1 -1
- package/sdk/threshold/index.d.ts +68 -0
- package/sdk/threshold/index.d.ts.map +1 -0
- package/sdk/threshold/index.js +254 -0
- package/sdk/threshold/package-lock.json +1361 -0
- package/sdk/threshold/package.json +39 -0
- package/sdk/threshold/src/index.d.ts +68 -0
- package/sdk/threshold/src/index.d.ts.map +1 -0
- package/sdk/threshold/src/index.js +254 -0
- package/sdk/threshold/src/index.test.ts +238 -0
- package/sdk/threshold/src/index.ts +355 -0
- package/sdk/threshold/tsconfig.json +19 -0
- package/sdk/threshold/vitest.config.ts +12 -0
- package/src/capability.test.ts +504 -0
- package/src/capability.ts +380 -0
- package/src/cborCodec.test.ts +263 -0
- package/src/cborCodec.ts +339 -0
- package/src/encryption.test.ts +206 -0
- package/src/encryption.ts +245 -0
- package/src/envelopeCbor.ts +140 -0
- package/src/gateway.ts +75 -0
- package/src/httpBinding.ts +37 -11
- package/src/index.ts +7 -0
- package/src/otel.ts +136 -0
- package/src/protocol.d.ts +67 -0
- package/src/protocol.d.ts.map +1 -0
- package/src/protocol.js +294 -0
- package/src/protocol.ts +1 -0
- package/src/replayStores.test.ts +133 -1
- package/src/replayStores.ts +136 -3
- package/src/stream.test.ts +254 -0
- package/src/stream.ts +417 -0
- package/src/telemetry.test.ts +251 -0
- package/src/telemetry.ts +299 -0
- package/src/wsBinding.ts +100 -0
- 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.
|
|
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()
|
package/sdk/rust/Cargo.lock
CHANGED
package/sdk/rust/Cargo.toml
CHANGED
|
@@ -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"}
|