rttp 0.1.0__py3-none-any.whl
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.
- rttp/__init__.py +61 -0
- rttp/aid.py +35 -0
- rttp/pulse_header.py +254 -0
- rttp/rttp_uri.py +214 -0
- rttp/seal.py +129 -0
- rttp/seal_asym.py +371 -0
- rttp/selftest.py +410 -0
- rttp/vectors/rttp-conformance-v1.2.6.json +210 -0
- rttp-0.1.0.dist-info/METADATA +234 -0
- rttp-0.1.0.dist-info/RECORD +14 -0
- rttp-0.1.0.dist-info/WHEEL +5 -0
- rttp-0.1.0.dist-info/entry_points.txt +2 -0
- rttp-0.1.0.dist-info/licenses/LICENSE +201 -0
- rttp-0.1.0.dist-info/top_level.txt +1 -0
rttp/__init__.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""
|
|
2
|
+
rttp — RTTP reference implementation (RFC-002 §4.1 framing · §10 addressing · §11 mapping)
|
|
3
|
+
|
|
4
|
+
**Zero dependencies.** Everything in the core package runs on the Python standard
|
|
5
|
+
library alone. The optional Ed25519 seal backend is the single exception and is
|
|
6
|
+
opt-in: `pip install rttp[ed25519]`.
|
|
7
|
+
|
|
8
|
+
>>> from rttp import pulse_header, rttp_uri
|
|
9
|
+
>>> parsed = rttp_uri.parse("rttp://brain.epoekie.aicent/verify")
|
|
10
|
+
>>> raw = pulse_header.build_for_uri(1, 255, 1, parsed["canonical_uri"],
|
|
11
|
+
... aid_origin=bytes(32))
|
|
12
|
+
>>> pulse_header.verify(raw)["action"]
|
|
13
|
+
'verify'
|
|
14
|
+
|
|
15
|
+
Self-check (this is the point of the package — a stranger can verify an
|
|
16
|
+
independent implementation in one command):
|
|
17
|
+
|
|
18
|
+
$ python -m rttp.selftest
|
|
19
|
+
|
|
20
|
+
Modules
|
|
21
|
+
-------
|
|
22
|
+
`pulse_header` PulseHeader128 codec — the 128-byte hardware-aligned frame header.
|
|
23
|
+
`rttp_uri` `rttp` URI validation, canonicalisation and ROUTE_SHARD derivation.
|
|
24
|
+
`seal` Radiant Seal, managed profile — symmetric HMAC-SHA256 over a
|
|
25
|
+
pre-shared key table. Suitable for a closed set of mutually
|
|
26
|
+
known roles.
|
|
27
|
+
`seal_asym` Radiant Seal, sovereign profile — Ed25519, self-certifying:
|
|
28
|
+
AID = SHA-256(public key), no issuance and no registry.
|
|
29
|
+
`aid` Autonomous Identity derivation.
|
|
30
|
+
|
|
31
|
+
Authority boundary
|
|
32
|
+
------------------
|
|
33
|
+
* URI **syntax** — RFC-002 §10.2 ABNF. This package adds no syntax.
|
|
34
|
+
* Frame **layout** — RFC-002 §4.1, extended by SPEC/RTTP-FRAME-EXT-v1.2.6.md.
|
|
35
|
+
* Each module docstring names its own authority; where the spec is silent, the
|
|
36
|
+
module says so rather than inventing a rule.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from . import aid, pulse_header, rttp_uri, seal
|
|
40
|
+
from .pulse_header import PulseHeaderError
|
|
41
|
+
from .rttp_uri import RttpUriError
|
|
42
|
+
|
|
43
|
+
__version__ = "0.1.0"
|
|
44
|
+
|
|
45
|
+
#: The protocol revision this package implements. Mirrors `pulse_header.SPEC_REV`.
|
|
46
|
+
SPEC_REV = pulse_header.SPEC_REV
|
|
47
|
+
|
|
48
|
+
#: The RTTP specification release these vectors were generated against.
|
|
49
|
+
SPEC_RELEASE = "v1.2.6"
|
|
50
|
+
|
|
51
|
+
__all__ = [
|
|
52
|
+
"aid",
|
|
53
|
+
"pulse_header",
|
|
54
|
+
"rttp_uri",
|
|
55
|
+
"seal",
|
|
56
|
+
"PulseHeaderError",
|
|
57
|
+
"RttpUriError",
|
|
58
|
+
"__version__",
|
|
59
|
+
"SPEC_REV",
|
|
60
|
+
"SPEC_RELEASE",
|
|
61
|
+
]
|
rttp/aid.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AID — Autonomous Identity(对齐 demo.rs: AID::derive_from_entropy)
|
|
3
|
+
|
|
4
|
+
demo.rs 原文: let node_aid = AID::derive_from_entropy(node_seed);
|
|
5
|
+
NODE_AID_GENESIS: {:032X} → genesis_shard 为 u128(16 字节)
|
|
6
|
+
|
|
7
|
+
本模块以 SHA-256 派生 256-bit 身份 DNA(双分片: Genesis^Resonance),
|
|
8
|
+
与 RFC-002 §4.1 的 AID_ORIGIN (0x46, 256-bit) 字段直接对应。
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
|
|
13
|
+
# 交换机(Imperial Nerve)的熵种子 —— 与 demo.rs 中的种子一致
|
|
14
|
+
SWITCH_SEED = b"imperial_nerve_genesis_2026_radiant_totality"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def derive_from_entropy(seed: bytes) -> dict:
|
|
18
|
+
"""从熵种子派生 256-bit AID(双分片)。"""
|
|
19
|
+
aid = hashlib.sha256(seed).digest()
|
|
20
|
+
return {
|
|
21
|
+
"aid": aid, # 32B 完整身份 DNA
|
|
22
|
+
"genesis_shard": aid[:16], # u128 Genesis 分片
|
|
23
|
+
"resonance_shard": aid[16:], # u128 Resonance 分片
|
|
24
|
+
"genesis_shard_hex": aid[:16].hex().upper(),
|
|
25
|
+
"aid_hex": aid.hex(),
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def agent_seed(role: str) -> bytes:
|
|
30
|
+
"""各 Agent 角色的确定性熵种子。"""
|
|
31
|
+
return f"rttp_agent_{role}_genesis_2026".encode()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def switch_aid() -> dict:
|
|
35
|
+
return derive_from_entropy(SWITCH_SEED)
|
rttp/pulse_header.py
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PulseHeader128 — RFC-002 §4.1 官方 128 字节硬件对齐帧头
|
|
3
|
+
|
|
4
|
+
(v1.3.0: 每个神经脉冲封装在临床级 PulseHeader128 中,
|
|
5
|
+
128-BYTE 硬件对齐结构,与 CPU 双缓存行共振。)
|
|
6
|
+
|
|
7
|
+
字段布局(大端,与 RFC-002 §4.1 表逐一对应):
|
|
8
|
+
0x00 u32 RTTP_MAGIC 0x52545450(物理寄存器门验证)
|
|
9
|
+
0x04 u128 VERSION_ID 锁定 130(v1.3.0-Alpha)
|
|
10
|
+
0x14 u128 SEQUENCE_ID 单调脉冲序号(12ns 审计用)
|
|
11
|
+
0x24 u128 TIMESTAMP 绝对纳秒发射时刻(12ns 精度)
|
|
12
|
+
0x34 u8 TTL_PULSE 跳数上限(255 后脉冲蒸发)
|
|
13
|
+
0x35 u8 PRIORITY 128-bit 分流权重(255 = Sovereign)
|
|
14
|
+
0x36 u128 ROUTE_SHARD 12ns 抖动对齐的 Hive 导航哈希
|
|
15
|
+
0x46 32B AID_ORIGIN 256-bit 双分片身份 DNA(Genesis^Resonance)
|
|
16
|
+
|
|
17
|
+
--- v1.2.6 起:保留区分配(详见 SPEC/RTTP-FRAME-EXT-v1.2.6.md)---
|
|
18
|
+
0x66 u8 SPEC_REV 0 = v1.2.6 之前(整块全零); 1 = v1.2.6
|
|
19
|
+
0x67 u8 FLAGS bit0 = URI_ANCHORED; 其余位在 v1.2.6 MUST 为 0
|
|
20
|
+
0x68 u8 ACTION_LEN ACTION 有效字节数(0 = action 省略)
|
|
21
|
+
0x69 16B ACTION §10.2 action 动词(小写 ASCII,右补 0x00)
|
|
22
|
+
0x79 7B RESERVED v1.2.6 MUST 全零
|
|
23
|
+
|
|
24
|
+
⚠ **0x00–0x65 一个字节都没有改动**,VERSION_ID 亦保持 130。
|
|
25
|
+
扩展只填「原本就是零」的保留区,因此只读 0x00–0x65 的旧实现天然兼容。
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
import struct
|
|
29
|
+
import time
|
|
30
|
+
|
|
31
|
+
try: # pip 安装后:作为包内模块导入
|
|
32
|
+
from . import rttp_uri
|
|
33
|
+
except ImportError: # 平铺布局(DEMO/ 目录):作为同目录脚本导入
|
|
34
|
+
import rttp_uri # action 语法(§10.2)唯一权威所在,避免规则重写
|
|
35
|
+
|
|
36
|
+
# ---- 固定区 ----
|
|
37
|
+
SIZE = 128
|
|
38
|
+
MAGIC = 0x52545450 # "RTTP"
|
|
39
|
+
VERSION_ID = 130 # v1.3.0-Alpha
|
|
40
|
+
AID_ORIGIN_OFFSET = 0x46
|
|
41
|
+
|
|
42
|
+
# ---- 扩展块(v1.2.6)----
|
|
43
|
+
SPEC_REV = 1
|
|
44
|
+
|
|
45
|
+
OFF_SPEC_REV = 0x66
|
|
46
|
+
OFF_FLAGS = 0x67
|
|
47
|
+
OFF_ACTION_LEN = 0x68
|
|
48
|
+
OFF_ACTION = 0x69
|
|
49
|
+
ACTION_MAX_LEN = 16
|
|
50
|
+
OFF_RESERVED = 0x79
|
|
51
|
+
RESERVED_LEN = 7
|
|
52
|
+
|
|
53
|
+
FLAG_URI_ANCHORED = 0x01
|
|
54
|
+
|
|
55
|
+
# 读端只认这些 SPEC_REV;未知修订一律拒斥(fail closed)
|
|
56
|
+
KNOWN_SPEC_REVS = (0, 1)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class PulseHeaderError(Exception):
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# ---------------------------------------------------------------------------
|
|
64
|
+
# ACTION 编解码
|
|
65
|
+
# ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
def encode_action(action: str) -> bytes:
|
|
68
|
+
"""action 动词 → 16 字节(小写 ASCII,右补 0x00)。"""
|
|
69
|
+
if action == "":
|
|
70
|
+
return bytes(ACTION_MAX_LEN)
|
|
71
|
+
if not rttp_uri.is_valid_action(action):
|
|
72
|
+
raise PulseHeaderError(
|
|
73
|
+
f"invalid action token {action!r} (RFC-002 §10.2: 1*( a-z / 0-9 / '-' ))")
|
|
74
|
+
raw = action.encode("ascii")
|
|
75
|
+
if len(raw) > ACTION_MAX_LEN:
|
|
76
|
+
raise PulseHeaderError(
|
|
77
|
+
f"action too long: {len(raw)} > {ACTION_MAX_LEN} bytes")
|
|
78
|
+
return raw + bytes(ACTION_MAX_LEN - len(raw))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def decode_action(block16: bytes) -> str:
|
|
82
|
+
"""16 字节 ACTION 区 → 动词字符串(字节级解码,不含语法校验)。"""
|
|
83
|
+
if len(block16) != ACTION_MAX_LEN:
|
|
84
|
+
raise PulseHeaderError("action block must be 16 bytes")
|
|
85
|
+
end = block16.find(b"\x00")
|
|
86
|
+
raw = block16 if end < 0 else block16[:end]
|
|
87
|
+
try:
|
|
88
|
+
return raw.decode("ascii")
|
|
89
|
+
except UnicodeDecodeError as exc:
|
|
90
|
+
raise PulseHeaderError(f"ACTION is not ASCII: {exc}") from exc
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# ---------------------------------------------------------------------------
|
|
94
|
+
# 构造
|
|
95
|
+
# ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
def build(sequence_id: int, ttl: int, priority: int,
|
|
98
|
+
route_shard: bytes, aid_origin: bytes,
|
|
99
|
+
timestamp_ns: int = None,
|
|
100
|
+
action: str = "", uri_anchored: bool = False) -> bytes:
|
|
101
|
+
"""按官方解剖表构造 128 字节帧头。
|
|
102
|
+
|
|
103
|
+
action / uri_anchored 为 v1.2.6 新增的**可选**参数:
|
|
104
|
+
* 不传时产出与旧实现逐字节相同的帧(SPEC_REV 仍写 1,见 spec §4 R6)
|
|
105
|
+
* action="" 表示 §10.4 的默认操作(standing read / 无动作谓词)
|
|
106
|
+
"""
|
|
107
|
+
if len(route_shard) != 16:
|
|
108
|
+
raise PulseHeaderError("route_shard must be 16 bytes (u128)")
|
|
109
|
+
if len(aid_origin) != 32:
|
|
110
|
+
raise PulseHeaderError("aid_origin must be 32 bytes (256-bit)")
|
|
111
|
+
if not 0 <= sequence_id < (1 << 128):
|
|
112
|
+
raise PulseHeaderError("sequence_id out of u128 range")
|
|
113
|
+
if timestamp_ns is None:
|
|
114
|
+
timestamp_ns = time.time_ns()
|
|
115
|
+
|
|
116
|
+
action_block = encode_action(action)
|
|
117
|
+
|
|
118
|
+
buf = bytearray(SIZE)
|
|
119
|
+
struct.pack_into(">I", buf, 0x00, MAGIC)
|
|
120
|
+
buf[0x04:0x14] = VERSION_ID.to_bytes(16, "big")
|
|
121
|
+
buf[0x14:0x24] = sequence_id.to_bytes(16, "big")
|
|
122
|
+
buf[0x24:0x34] = timestamp_ns.to_bytes(16, "big")
|
|
123
|
+
buf[0x34] = ttl & 0xFF
|
|
124
|
+
buf[0x35] = priority & 0xFF
|
|
125
|
+
buf[0x36:0x46] = route_shard
|
|
126
|
+
buf[0x46:0x66] = aid_origin
|
|
127
|
+
|
|
128
|
+
# --- v1.2.6 扩展块 ---
|
|
129
|
+
buf[OFF_SPEC_REV] = SPEC_REV
|
|
130
|
+
buf[OFF_FLAGS] = FLAG_URI_ANCHORED if uri_anchored else 0x00
|
|
131
|
+
buf[OFF_ACTION_LEN] = len(action.encode("ascii")) if action else 0
|
|
132
|
+
buf[OFF_ACTION:OFF_ACTION + ACTION_MAX_LEN] = action_block
|
|
133
|
+
# 0x79..0x80 保持零(RESERVED)
|
|
134
|
+
|
|
135
|
+
return bytes(buf)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def build_for_uri(sequence_id: int, ttl: int, priority: int,
|
|
139
|
+
uri: str, aid_origin: bytes,
|
|
140
|
+
timestamp_ns: int = None) -> bytes:
|
|
141
|
+
"""从一条 `rttp` URI 直接构造帧头 —— 即 §10.4 所承诺的那次映射。
|
|
142
|
+
|
|
143
|
+
ROUTE_SHARD 由 authority 派生(而非脉冲序号),ACTION 取自 path。
|
|
144
|
+
"""
|
|
145
|
+
parsed = rttp_uri.parse(uri)
|
|
146
|
+
return build(sequence_id, ttl, priority,
|
|
147
|
+
route_shard=parsed["route_shard"],
|
|
148
|
+
aid_origin=aid_origin,
|
|
149
|
+
timestamp_ns=timestamp_ns,
|
|
150
|
+
action=parsed["action"],
|
|
151
|
+
uri_anchored=True)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# ---------------------------------------------------------------------------
|
|
155
|
+
# 解析 / 校验
|
|
156
|
+
# ---------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
def parse(raw: bytes) -> dict:
|
|
159
|
+
"""解析并校验 128 字节帧头,返回字段字典(不判合法性,只做结构解码)。"""
|
|
160
|
+
if not isinstance(raw, (bytes, bytearray)) or len(raw) != SIZE:
|
|
161
|
+
raise PulseHeaderError(f"header must be exactly {SIZE} bytes")
|
|
162
|
+
raw = bytes(raw)
|
|
163
|
+
magic = struct.unpack_from(">I", raw, 0x00)[0]
|
|
164
|
+
if magic != MAGIC:
|
|
165
|
+
raise PulseHeaderError(f"bad RTTP_MAGIC: 0x{magic:08X}")
|
|
166
|
+
|
|
167
|
+
spec_rev = raw[OFF_SPEC_REV]
|
|
168
|
+
flags = raw[OFF_FLAGS]
|
|
169
|
+
action_len = raw[OFF_ACTION_LEN]
|
|
170
|
+
action = "" if spec_rev == 0 else decode_action(
|
|
171
|
+
raw[OFF_ACTION:OFF_ACTION + ACTION_MAX_LEN])
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
"magic_ok": True,
|
|
175
|
+
"version_id": int.from_bytes(raw[0x04:0x14], "big"),
|
|
176
|
+
"sequence_id": int.from_bytes(raw[0x14:0x24], "big"),
|
|
177
|
+
"timestamp_ns": int.from_bytes(raw[0x24:0x34], "big"),
|
|
178
|
+
"ttl": raw[0x34],
|
|
179
|
+
"priority": raw[0x35],
|
|
180
|
+
"route_shard": raw[0x36:0x46],
|
|
181
|
+
"aid_origin": raw[0x46:0x66],
|
|
182
|
+
# --- v1.2.6 ---
|
|
183
|
+
"spec_rev": spec_rev,
|
|
184
|
+
"flags": flags,
|
|
185
|
+
"uri_anchored": bool(flags & FLAG_URI_ANCHORED),
|
|
186
|
+
"action": action,
|
|
187
|
+
"action_len": action_len,
|
|
188
|
+
"action_omitted": action_len == 0,
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def verify(raw: bytes, expected_aid_origin: bytes = None) -> dict:
|
|
193
|
+
"""解析 + 校验版本、来源 AID 与扩展块,返回字段字典。
|
|
194
|
+
|
|
195
|
+
扩展块校验规则(spec §4 R1–R7):
|
|
196
|
+
* SPEC_REV 不在 KNOWN_SPEC_REVS 内 → 拒斥
|
|
197
|
+
* SPEC_REV=0 但扩展块非全零 → 拒斥(形态自相矛盾)
|
|
198
|
+
* SPEC_REV=1 但 RESERVED 非零 / FLAGS 未知位 → 拒斥
|
|
199
|
+
* ACTION_LEN > 16 / 有内嵌 NUL / 非 §10.2 语法 → 拒斥
|
|
200
|
+
"""
|
|
201
|
+
fields = parse(raw)
|
|
202
|
+
if fields["version_id"] != VERSION_ID:
|
|
203
|
+
raise PulseHeaderError(
|
|
204
|
+
f"VERSION_ID mismatch: {fields['version_id']} != {VERSION_ID}")
|
|
205
|
+
|
|
206
|
+
spec_rev = fields["spec_rev"]
|
|
207
|
+
block = bytes(raw[OFF_SPEC_REV:SIZE])
|
|
208
|
+
|
|
209
|
+
if spec_rev not in KNOWN_SPEC_REVS:
|
|
210
|
+
raise PulseHeaderError(f"unknown SPEC_REV: {spec_rev} (fail closed)")
|
|
211
|
+
if spec_rev == 0:
|
|
212
|
+
if any(block):
|
|
213
|
+
raise PulseHeaderError(
|
|
214
|
+
"SPEC_REV=0 but extension block is not all-zero")
|
|
215
|
+
else:
|
|
216
|
+
if any(raw[OFF_RESERVED:SIZE]):
|
|
217
|
+
raise PulseHeaderError("RESERVED bytes must be zero in SPEC_REV=1")
|
|
218
|
+
unknown_flags = fields["flags"] & ~FLAG_URI_ANCHORED
|
|
219
|
+
if unknown_flags:
|
|
220
|
+
raise PulseHeaderError(
|
|
221
|
+
f"unknown FLAGS bits set: 0x{unknown_flags:02X}")
|
|
222
|
+
|
|
223
|
+
action_block = raw[OFF_ACTION:OFF_ACTION + ACTION_MAX_LEN]
|
|
224
|
+
action = fields["action"]
|
|
225
|
+
if fields["action_len"] > ACTION_MAX_LEN:
|
|
226
|
+
raise PulseHeaderError(
|
|
227
|
+
f"ACTION_LEN {fields['action_len']} > {ACTION_MAX_LEN}")
|
|
228
|
+
if fields["action_len"] != len(action.encode("ascii")):
|
|
229
|
+
raise PulseHeaderError("ACTION_LEN disagrees with ACTION payload")
|
|
230
|
+
# 补零区必须全零(不留夹缝)
|
|
231
|
+
if any(action_block[len(action.encode("ascii")):]):
|
|
232
|
+
raise PulseHeaderError("ACTION padding must be zero")
|
|
233
|
+
if action and not rttp_uri.is_valid_action(action):
|
|
234
|
+
raise PulseHeaderError(f"ACTION violates RFC-002 §10.2: {action!r}")
|
|
235
|
+
|
|
236
|
+
if expected_aid_origin is not None and \
|
|
237
|
+
fields["aid_origin"] != expected_aid_origin:
|
|
238
|
+
raise PulseHeaderError("AID_ORIGIN mismatch (spoofed origin?)")
|
|
239
|
+
return fields
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
# ---------------------------------------------------------------------------
|
|
243
|
+
# 十六进制编解码
|
|
244
|
+
# ---------------------------------------------------------------------------
|
|
245
|
+
|
|
246
|
+
def to_hex(header_bytes: bytes) -> str:
|
|
247
|
+
return header_bytes.hex()
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def from_hex(hex_str: str) -> bytes:
|
|
251
|
+
raw = bytes.fromhex(hex_str)
|
|
252
|
+
if len(raw) != SIZE:
|
|
253
|
+
raise PulseHeaderError("hex must decode to exactly 128 bytes")
|
|
254
|
+
return raw
|
rttp/rttp_uri.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""
|
|
2
|
+
rttp_uri — RFC-002 §10 `rttp` URI 解析 / 规范化 / ROUTE_SHARD 派生
|
|
3
|
+
|
|
4
|
+
本模块是 RFC-002 §10.4 所承诺映射的**参考实现**:
|
|
5
|
+
"Dereferencing an `rttp` URI emits one pulse ... carrying `action` as the intent verb."
|
|
6
|
+
规范此前只有语法(§10.2 ABNF),没有「URI → 帧字段」的映射;本模块与
|
|
7
|
+
`SPEC/RTTP-FRAME-EXT-v1.2.6.md` 一起补上这一段。
|
|
8
|
+
|
|
9
|
+
权威边界(重要):
|
|
10
|
+
* URI **语法**唯一权威 = RFC-002 §10.2 ABNF。本模块不新增语法。
|
|
11
|
+
* 本模块只做两件事:① 按 §10.2/§10.3 校验并**规范化**;② 派生 ROUTE_SHARD。
|
|
12
|
+
|
|
13
|
+
设计约束(逐条对应规范原文):
|
|
14
|
+
* §10.1 intent 既可为 8 位小写 hex(32-bit routing hash),也可为可读 organ token
|
|
15
|
+
* §10.2 action 是**开集**:1*( %x61-7A / DIGIT / "-" ),例如 vessel / verify / pulse
|
|
16
|
+
* §10.3 规范形式为**小写 US-ASCII**;无 userinfo / port / query / fragment
|
|
17
|
+
* §10.5 **不解析 DNS**:ROUTE_SHARD 纯计算可得,不依赖任何注册表或网络
|
|
18
|
+
|
|
19
|
+
ROUTE_SHARD 派生规则:见 spec §5。一句话:取 authority 的 SHA-256 前 16 字节。
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import hashlib
|
|
25
|
+
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
# 常量
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
SCHEME = "rttp"
|
|
31
|
+
WEB_SCHEME = "web+rttp" # 浏览器处理器形态:容忍,非规范定义(2026-09-17 起规范不再定义它)
|
|
32
|
+
|
|
33
|
+
ROUTE_SHARD_BYTES = 16 # §4.1 ROUTE_SHARD = u128
|
|
34
|
+
|
|
35
|
+
# §10.2: name-intent / pillar / root / action = 1*( %x61-7A / DIGIT / "-" )
|
|
36
|
+
_LOWER = "abcdefghijklmnopqrstuvwxyz"
|
|
37
|
+
_DIGITS = "0123456789"
|
|
38
|
+
_TOKEN_CHARS = frozenset(_LOWER + _DIGITS + "-")
|
|
39
|
+
_HEX_CHARS = frozenset("0123456789abcdef") # §10.2 lowhex = %x30-39 / %x61-66
|
|
40
|
+
|
|
41
|
+
HASH_INTENT_LEN = 8 # 8 位小写 hex = 32-bit routing hash
|
|
42
|
+
|
|
43
|
+
# §10.3: 本 scheme 不定义 userinfo / port / query / fragment
|
|
44
|
+
_FORBIDDEN_CHARS = ("@", "?", "#", "[", "]")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class RttpUriError(ValueError):
|
|
48
|
+
"""URI 不合规。**一律 fail closed**(§10.5:无 fallback,无 rttps)。"""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
# 校验助手
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
def _check_token(value: str, what: str) -> None:
|
|
56
|
+
if not value:
|
|
57
|
+
raise RttpUriError(f"{what} is empty")
|
|
58
|
+
for ch in value:
|
|
59
|
+
if ch in _TOKEN_CHARS:
|
|
60
|
+
continue
|
|
61
|
+
if ch.isupper():
|
|
62
|
+
# §10.3 规范形式为小写。大写**不是**可归一化的书写差异,而是非法输入。
|
|
63
|
+
raise RttpUriError(
|
|
64
|
+
f"{what} contains uppercase '{ch}' - lowercase US-ASCII only (RFC-002 §10.3)")
|
|
65
|
+
raise RttpUriError(
|
|
66
|
+
f"{what} contains illegal character {ch!r} "
|
|
67
|
+
f"(allowed: a-z, 0-9, '-')")
|
|
68
|
+
if value[0] == "-" or value[-1] == "-":
|
|
69
|
+
raise RttpUriError(f"{what} must not begin or end with '-'")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _check_intent(value: str) -> bool:
|
|
73
|
+
"""返回 True 表示 hash-intent(8 lowhex),False 表示 name-intent。"""
|
|
74
|
+
if len(value) == HASH_INTENT_LEN and all(c in _HEX_CHARS for c in value):
|
|
75
|
+
return True
|
|
76
|
+
_check_token(value, "intent")
|
|
77
|
+
return False
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def is_valid_action(value: str) -> bool:
|
|
81
|
+
"""§10.2 action 语法判定:1*( %x61-7A / DIGIT / "-" )。
|
|
82
|
+
|
|
83
|
+
`action` 是**开集** —— 本函数只判**形态**,不判动词是否已知语义。
|
|
84
|
+
供帧层(`pulse_header`)复用,避免把同一条规则写两遍。
|
|
85
|
+
"""
|
|
86
|
+
if not isinstance(value, str) or not value:
|
|
87
|
+
return False
|
|
88
|
+
if value[0] == "-" or value[-1] == "-":
|
|
89
|
+
return False
|
|
90
|
+
return all(c in _TOKEN_CHARS for c in value)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# ---------------------------------------------------------------------------
|
|
94
|
+
# 解析 / 规范化
|
|
95
|
+
# ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
def parse(uri: str) -> dict:
|
|
98
|
+
"""解析一条 `rttp` URI,返回规范化字段。
|
|
99
|
+
|
|
100
|
+
校验顺序刻意保持「先拒斥、后解释」:任何可疑形态立即抛错,绝不猜测意图。
|
|
101
|
+
(§10.5: user agents that do not implement this scheme fail closed.)
|
|
102
|
+
"""
|
|
103
|
+
if not isinstance(uri, str):
|
|
104
|
+
raise RttpUriError("uri must be a string")
|
|
105
|
+
if not uri:
|
|
106
|
+
raise RttpUriError("uri is empty")
|
|
107
|
+
if uri != uri.strip():
|
|
108
|
+
# 前后空白:可能是粘贴污染,也可能是绕过前缀检查的尝试 —— 拒斥。
|
|
109
|
+
raise RttpUriError("uri must not contain leading/trailing whitespace")
|
|
110
|
+
|
|
111
|
+
# --- scheme(§10.6 前缀白名单)---
|
|
112
|
+
if uri.startswith(WEB_SCHEME + "://"):
|
|
113
|
+
scheme = WEB_SCHEME
|
|
114
|
+
rest = uri[len(WEB_SCHEME) + 3:]
|
|
115
|
+
elif uri.startswith(SCHEME + "://"):
|
|
116
|
+
scheme = SCHEME
|
|
117
|
+
rest = uri[len(SCHEME) + 3:]
|
|
118
|
+
else:
|
|
119
|
+
raise RttpUriError(
|
|
120
|
+
f"not an rttp URI: must begin with '{SCHEME}://' or '{WEB_SCHEME}://'")
|
|
121
|
+
|
|
122
|
+
# --- §10.3 排除字符 ---
|
|
123
|
+
for ch in _FORBIDDEN_CHARS:
|
|
124
|
+
if ch in uri:
|
|
125
|
+
raise RttpUriError(
|
|
126
|
+
f"illegal character {ch!r}: this scheme defines no "
|
|
127
|
+
f"userinfo / query / fragment")
|
|
128
|
+
|
|
129
|
+
# --- authority 与 path ---
|
|
130
|
+
authority, sep, action = rest.partition("/")
|
|
131
|
+
if sep and "/" in action:
|
|
132
|
+
raise RttpUriError("path must be a single segment '/<action>'")
|
|
133
|
+
if sep and not action:
|
|
134
|
+
# §10.2: path = "/" action, action = 1*(...) —— 至少一个字符。
|
|
135
|
+
# "带空 action 的尾斜杠" 与 "无 path" 是两种不同形态,不可混同。
|
|
136
|
+
raise RttpUriError("trailing '/' with empty action: path must be '/<action>'")
|
|
137
|
+
|
|
138
|
+
parts = authority.split(".")
|
|
139
|
+
if len(parts) != 3:
|
|
140
|
+
raise RttpUriError(
|
|
141
|
+
f"authority must be exactly '<intent>.<pillar>.<root>' "
|
|
142
|
+
f"(got {len(parts)} segment(s))")
|
|
143
|
+
intent, pillar, root = parts
|
|
144
|
+
|
|
145
|
+
is_hash = _check_intent(intent)
|
|
146
|
+
_check_token(pillar, "pillar")
|
|
147
|
+
_check_token(root, "root")
|
|
148
|
+
if action:
|
|
149
|
+
_check_token(action, "action")
|
|
150
|
+
|
|
151
|
+
canonical_authority = f"{intent}.{pillar}.{root}"
|
|
152
|
+
canonical_uri = f"{SCHEME}://{canonical_authority}"
|
|
153
|
+
if action:
|
|
154
|
+
canonical_uri += f"/{action}"
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
"scheme": scheme,
|
|
158
|
+
"intent": intent,
|
|
159
|
+
"intent_is_hash": is_hash,
|
|
160
|
+
"intent_hash32": int(intent, 16) if is_hash else None,
|
|
161
|
+
"pillar": pillar,
|
|
162
|
+
"root": root,
|
|
163
|
+
"action": action, # "" = 省略(§10.4 默认操作)
|
|
164
|
+
"action_omitted": action == "",
|
|
165
|
+
"authority": canonical_authority,
|
|
166
|
+
"canonical_uri": canonical_uri,
|
|
167
|
+
"route_shard": derive_route_shard(canonical_authority),
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# ---------------------------------------------------------------------------
|
|
172
|
+
# ROUTE_SHARD 派生(本文件的核心;规范此前未定义)
|
|
173
|
+
# ---------------------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
def derive_route_shard(canonical_authority: str) -> bytes:
|
|
176
|
+
"""authority → ROUTE_SHARD(16 字节)。
|
|
177
|
+
|
|
178
|
+
ROUTE_SHARD = SHA-256( ASCII(canonical_authority) )[0:16]
|
|
179
|
+
|
|
180
|
+
性质:
|
|
181
|
+
* **确定性**:同一 authority 永远同一 shard。
|
|
182
|
+
* **纯计算**:无 DNS、无注册表、无网络(§10.5)。
|
|
183
|
+
* **单向**:不可是从 shard 还原 authority(SHA-256 前像抗性)。
|
|
184
|
+
* **与 action 无关**:路由到「哪里」,不路由到「做什么」——
|
|
185
|
+
这正是 `action` 必须作为独立帧字段存在的原因(见 spec §5.3)。
|
|
186
|
+
"""
|
|
187
|
+
if not canonical_authority:
|
|
188
|
+
raise RttpUriError("canonical_authority is empty")
|
|
189
|
+
if canonical_authority != canonical_authority.lower():
|
|
190
|
+
raise RttpUriError("canonical_authority must be lowercase (RFC-002 §10.3)")
|
|
191
|
+
return hashlib.sha256(canonical_authority.encode("ascii")).digest()[:ROUTE_SHARD_BYTES]
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def route_shard_hex(canonical_authority: str) -> str:
|
|
195
|
+
return derive_route_shard(canonical_authority).hex()
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def parse_and_derive(uri: str) -> dict:
|
|
199
|
+
"""便捷入口:解析 + 派生,一步到位。"""
|
|
200
|
+
return parse(uri)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
if __name__ == "__main__": # 手工冒烟
|
|
204
|
+
import sys
|
|
205
|
+
|
|
206
|
+
for arg in sys.argv[1:]:
|
|
207
|
+
try:
|
|
208
|
+
r = parse(arg)
|
|
209
|
+
print(f"OK {r['canonical_uri']}")
|
|
210
|
+
print(f" authority = {r['authority']}")
|
|
211
|
+
print(f" route_shard = {r['route_shard'].hex()}")
|
|
212
|
+
print(f" action = {r['action'] or '(omitted)'}")
|
|
213
|
+
except RttpUriError as exc:
|
|
214
|
+
print(f"REJECT {arg}\n {exc}")
|
rttp/seal.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Radiant Seal — RTTP v1.3.0 节点签名验证(HMAC-SHA256)
|
|
3
|
+
|
|
4
|
+
信任模型:
|
|
5
|
+
* 每个 Agent 持有独立 32 字节密钥(seal_keys.json 中 node-* 条目)
|
|
6
|
+
* 交换机持有 switch 密钥,发给 Agent 的数据包同样签名(双向验证)
|
|
7
|
+
* RTTP_IDENTIFY: 签名内容 = "role|ts|nonce" (防重放:ts 偏差 > 120s 拒收)
|
|
8
|
+
* RTTP_RESPONSE: 签名内容 = task_id 与 result 的 sha256 绑定
|
|
9
|
+
* 未知角色 / 密钥缺失 → 拒绝(fail-closed)
|
|
10
|
+
|
|
11
|
+
密钥文件缺失时进入 OPEN_MODE(仅本地开发用),并在每条日志中明示。
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import hashlib
|
|
15
|
+
import hmac
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import secrets
|
|
19
|
+
import time
|
|
20
|
+
|
|
21
|
+
KEYFILE = os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
|
22
|
+
"seal_keys.json")
|
|
23
|
+
MAX_CLOCK_SKEW = 120 # 秒
|
|
24
|
+
|
|
25
|
+
_keys_cache = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def load_keys(force=False):
|
|
29
|
+
"""读取密钥表;文件缺失时返回 None(OPEN_MODE)。"""
|
|
30
|
+
global _keys_cache
|
|
31
|
+
if _keys_cache is not None and not force:
|
|
32
|
+
return _keys_cache
|
|
33
|
+
if not os.path.exists(KEYFILE):
|
|
34
|
+
return None
|
|
35
|
+
with open(KEYFILE, "r", encoding="utf-8") as f:
|
|
36
|
+
_keys_cache = json.load(f)
|
|
37
|
+
return _keys_cache
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def open_mode() -> bool:
|
|
41
|
+
return load_keys() is None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _key_for(role: str) -> str:
|
|
45
|
+
keys = load_keys()
|
|
46
|
+
if keys is None:
|
|
47
|
+
return ""
|
|
48
|
+
return keys.get(role, "")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def sign(message: str, key_hex: str) -> str:
|
|
52
|
+
return hmac.new(bytes.fromhex(key_hex), message.encode("utf-8"),
|
|
53
|
+
hashlib.sha256).hexdigest()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def verify(message: str, sig_hex: str, key_hex: str) -> bool:
|
|
57
|
+
if not key_hex or not sig_hex:
|
|
58
|
+
return False
|
|
59
|
+
return hmac.compare_digest(sign(message, key_hex), sig_hex)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ---- IDENTIFY 签名 ----
|
|
63
|
+
|
|
64
|
+
def make_identify_challenge() -> dict:
|
|
65
|
+
return {"ts": int(time.time()), "nonce": secrets.token_hex(8)}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def canonical_identify(role: str, ts: int, nonce: str) -> str:
|
|
69
|
+
return f"{role}|{ts}|{nonce}"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def sign_identify(role: str, ts: int, nonce: str, key_hex: str) -> str:
|
|
73
|
+
return sign(canonical_identify(role, ts, nonce), key_hex)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def verify_identify(role: str, ts: int, nonce: str, sig_hex: str) -> tuple:
|
|
77
|
+
"""返回 (ok: bool, reason: str)。"""
|
|
78
|
+
if open_mode():
|
|
79
|
+
return True, "OPEN_MODE"
|
|
80
|
+
key = _key_for(role)
|
|
81
|
+
if not key:
|
|
82
|
+
return False, f"unknown role or missing key: {role}"
|
|
83
|
+
if abs(int(time.time()) - int(ts)) > MAX_CLOCK_SKEW:
|
|
84
|
+
return False, "timestamp skew too large (replay?)"
|
|
85
|
+
if verify(canonical_identify(role, ts, nonce), sig_hex, key):
|
|
86
|
+
return True, "sealed"
|
|
87
|
+
return False, "bad seal"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# ---- RESPONSE 签名(Agent → 交换机)----
|
|
91
|
+
|
|
92
|
+
def canonical_response(task_id: str, result: str) -> str:
|
|
93
|
+
result_digest = hashlib.sha256(result.encode("utf-8")).hexdigest()
|
|
94
|
+
return f"{task_id}|{result_digest}"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def sign_response(task_id: str, result: str, key_hex: str) -> str:
|
|
98
|
+
return sign(canonical_response(task_id, result), key_hex)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def verify_response(task_id: str, result: str, role: str, sig_hex: str) -> tuple:
|
|
102
|
+
if open_mode():
|
|
103
|
+
return True, "OPEN_MODE"
|
|
104
|
+
key = _key_for(role)
|
|
105
|
+
if not key:
|
|
106
|
+
return False, f"missing key for {role}"
|
|
107
|
+
if verify(canonical_response(task_id, result), sig_hex, key):
|
|
108
|
+
return True, "sealed"
|
|
109
|
+
return False, "bad seal"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# ---- 交换机签名(交换机 → Agent,双向信任)----
|
|
113
|
+
|
|
114
|
+
def canonical_packet(task_id: str, content: str) -> str:
|
|
115
|
+
return f"{task_id}|{content}"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def sign_packet(task_id: str, content: str) -> str:
|
|
119
|
+
key = _key_for("switch")
|
|
120
|
+
if not key:
|
|
121
|
+
return ""
|
|
122
|
+
return sign(canonical_packet(task_id, content), key)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def verify_packet(task_id: str, content: str, sig_hex: str) -> bool:
|
|
126
|
+
key = _key_for("switch")
|
|
127
|
+
if not key:
|
|
128
|
+
return True # OPEN_MODE
|
|
129
|
+
return verify(canonical_packet(task_id, content), sig_hex, key)
|