hypermind 0.11.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.
- hmctl/__init__.py +7 -0
- hmctl/cli.py +947 -0
- hypermind/__init__.py +208 -0
- hypermind/_deprecations.py +108 -0
- hypermind/agent.py +2896 -0
- hypermind/agent_card.py +127 -0
- hypermind/api/__init__.py +32 -0
- hypermind/api/app.py +686 -0
- hypermind/capability.py +923 -0
- hypermind/consult.py +493 -0
- hypermind/consult_bias.py +112 -0
- hypermind/contract_net.py +219 -0
- hypermind/crypto/__init__.py +21 -0
- hypermind/crypto/cose_encrypt.py +126 -0
- hypermind/crypto/dkg.py +246 -0
- hypermind/crypto/domain.py +59 -0
- hypermind/crypto/frost.py +771 -0
- hypermind/crypto/hashing.py +38 -0
- hypermind/crypto/hlc.py +163 -0
- hypermind/crypto/hpke.py +277 -0
- hypermind/crypto/kms.py +196 -0
- hypermind/crypto/kms_backends/__init__.py +56 -0
- hypermind/crypto/kms_backends/aws.py +128 -0
- hypermind/crypto/kms_backends/azure.py +114 -0
- hypermind/crypto/kms_backends/gcp.py +97 -0
- hypermind/crypto/kms_backends/vault.py +120 -0
- hypermind/crypto/namespace_root.py +204 -0
- hypermind/crypto/pq.py +152 -0
- hypermind/crypto/room_epoch.py +97 -0
- hypermind/crypto/signing.py +245 -0
- hypermind/deliberation.py +355 -0
- hypermind/did_web.py +256 -0
- hypermind/dispute.py +858 -0
- hypermind/errors.py +218 -0
- hypermind/eval/__init__.py +49 -0
- hypermind/eval/calibration.py +307 -0
- hypermind/eval/comparison.py +251 -0
- hypermind/eval/replay.py +202 -0
- hypermind/eval/swarm_iq.py +718 -0
- hypermind/knowledge/__init__.py +17 -0
- hypermind/knowledge/citation_graph.py +141 -0
- hypermind/knowledge/correlated_agreement.py +167 -0
- hypermind/knowledge/embedding_registry.py +115 -0
- hypermind/knowledge/kernel_store.py +197 -0
- hypermind/knowledge/rekor.py +173 -0
- hypermind/knowledge/reputation.py +364 -0
- hypermind/knowledge/swarm_memory.py +523 -0
- hypermind/knowledge/transparency.py +409 -0
- hypermind/mcp/__init__.py +60 -0
- hypermind/mcp/bridge.py +151 -0
- hypermind/mcp/client.py +142 -0
- hypermind/mcp/provenance.py +214 -0
- hypermind/mcp/relata_tools.py +152 -0
- hypermind/mcp/server.py +248 -0
- hypermind/mcp/transport.py +206 -0
- hypermind/mind/__init__.py +150 -0
- hypermind/mind/active.py +234 -0
- hypermind/mind/belief.py +369 -0
- hypermind/mind/deliberator.py +625 -0
- hypermind/mind/diversity.py +144 -0
- hypermind/mind/evolve.py +184 -0
- hypermind/mind/federation.py +154 -0
- hypermind/mind/goals.py +117 -0
- hypermind/mind/integration.py +190 -0
- hypermind/mind/mediator.py +74 -0
- hypermind/mind/memory_store.py +129 -0
- hypermind/mind/policy.py +307 -0
- hypermind/mind/records.py +260 -0
- hypermind/mind/roles.py +190 -0
- hypermind/mind/sybil_guard.py +41 -0
- hypermind/mind/world_model.py +166 -0
- hypermind/namespace.py +515 -0
- hypermind/namespace_policy.py +254 -0
- hypermind/observability/__init__.py +25 -0
- hypermind/observability/asgi.py +214 -0
- hypermind/observability/cost.py +277 -0
- hypermind/observability/otel_export.py +200 -0
- hypermind/observability/recorder.py +344 -0
- hypermind/observability/swarm_trace.py +438 -0
- hypermind/pin_policy.py +116 -0
- hypermind/py.typed +0 -0
- hypermind/responders/__init__.py +87 -0
- hypermind/responders/anthropic.py +159 -0
- hypermind/responders/base.py +162 -0
- hypermind/responders/openai.py +199 -0
- hypermind/responders/router.py +254 -0
- hypermind/responders/structured.py +287 -0
- hypermind/responders/tools.py +444 -0
- hypermind/revocation.py +270 -0
- hypermind/rule_ids.py +135 -0
- hypermind/schemas/encrypted_statement.cddl +28 -0
- hypermind/schemas/namespace_accept.cddl +20 -0
- hypermind/schemas/namespace_create.cddl +25 -0
- hypermind/schemas/namespace_founding_attest.cddl +30 -0
- hypermind/schemas/namespace_invite.cddl +22 -0
- hypermind/schemas/wire-v0.1.cddl +73 -0
- hypermind/simlab/__init__.py +15 -0
- hypermind/simlab/_persona_registry.py +93 -0
- hypermind/simlab/_scenario_impl.py +2778 -0
- hypermind/simlab/app.py +2538 -0
- hypermind/simlab/backends/__init__.py +27 -0
- hypermind/simlab/backends/anchor.py +88 -0
- hypermind/simlab/backends/local.py +32 -0
- hypermind/simlab/backends/server.py +79 -0
- hypermind/simlab/cli_command.py +108 -0
- hypermind/simlab/config.py +106 -0
- hypermind/simlab/deployment.py +26 -0
- hypermind/simlab/knowledge.py +716 -0
- hypermind/simlab/learning.py +295 -0
- hypermind/simlab/modes/__init__.py +115 -0
- hypermind/simlab/modes/_eval_real_llm.py +373 -0
- hypermind/simlab/modes/aar.py +611 -0
- hypermind/simlab/modes/backtest.py +610 -0
- hypermind/simlab/modes/binary_forecast.py +69 -0
- hypermind/simlab/modes/conformance.py +1869 -0
- hypermind/simlab/modes/evaluation.py +2271 -0
- hypermind/simlab/modes/governance.py +648 -0
- hypermind/simlab/modes/redteam.py +534 -0
- hypermind/simlab/modes/tabletop.py +797 -0
- hypermind/simlab/modes/tournament.py +448 -0
- hypermind/simlab/modes/whatif.py +523 -0
- hypermind/simlab/namespace.py +125 -0
- hypermind/simlab/personas.py +477 -0
- hypermind/simlab/prompt_generator.py +172 -0
- hypermind/simlab/question_sets/geopolitics_q20.json +122 -0
- hypermind/simlab/question_sets/governance_q5.json +67 -0
- hypermind/simlab/question_sets/msft_outlook_q10.json +62 -0
- hypermind/simlab/question_sets/whatif_q3.json +38 -0
- hypermind/simlab/registry.py +325 -0
- hypermind/simlab/runner.py +239 -0
- hypermind/simlab/scenario.py +147 -0
- hypermind/simlab/swarms.py +180 -0
- hypermind/simlab/tool_handlers/__init__.py +4 -0
- hypermind/simlab/tool_handlers/alpha_vantage.py +39 -0
- hypermind/simlab/tool_handlers/brave.py +46 -0
- hypermind/simlab/tool_handlers/newsapi.py +50 -0
- hypermind/simlab/tool_handlers/openweather.py +46 -0
- hypermind/simlab/tool_handlers/pinecone.py +52 -0
- hypermind/simlab/tool_handlers/qdrant.py +57 -0
- hypermind/simlab/tool_handlers/query_knowledge_base.py +21 -0
- hypermind/simlab/tool_handlers/relata_recall.py +61 -0
- hypermind/simlab/tool_handlers/retrieve_document.py +21 -0
- hypermind/simlab/tool_handlers/serper.py +46 -0
- hypermind/simlab/tool_handlers/tavily.py +53 -0
- hypermind/simlab/tool_handlers/weaviate.py +65 -0
- hypermind/simlab/tool_handlers/wikipedia.py +64 -0
- hypermind/simlab/tool_handlers/wolfram.py +48 -0
- hypermind/simlab/tool_handlers/yahoo_finance.py +49 -0
- hypermind/simlab/tool_registry.py +568 -0
- hypermind/simlab/topic_adapter.py +338 -0
- hypermind/simlab/topic_questions.py +259 -0
- hypermind/storage/__init__.py +69 -0
- hypermind/storage/backend.py +71 -0
- hypermind/storage/relata.py +245 -0
- hypermind/storage/sqlite.py +258 -0
- hypermind/sync.py +191 -0
- hypermind/tal.py +175 -0
- hypermind/tasks.py +334 -0
- hypermind/testing.py +16 -0
- hypermind/tools/__init__.py +32 -0
- hypermind/tools/registry.py +61 -0
- hypermind/transport/__init__.py +22 -0
- hypermind/transport/anti_entropy.py +708 -0
- hypermind/transport/bus.py +206 -0
- hypermind/transport/knows_delta.py +204 -0
- hypermind/transport/libp2p.py +298 -0
- hypermind/transport/placement.py +58 -0
- hypermind/transport/tcp.py +797 -0
- hypermind/transport/tls_profile.py +417 -0
- hypermind/types.py +59 -0
- hypermind/uncertainty.py +222 -0
- hypermind/wire.py +897 -0
- hypermind/workflow/__init__.py +72 -0
- hypermind/workflow/engine.py +655 -0
- hypermind/workflow/plan.py +182 -0
- hypermind/workflow/repair.py +203 -0
- hypermind-0.11.0.dist-info/METADATA +524 -0
- hypermind-0.11.0.dist-info/RECORD +181 -0
- hypermind-0.11.0.dist-info/WHEEL +4 -0
- hypermind-0.11.0.dist-info/entry_points.txt +2 -0
- hypermind-0.11.0.dist-info/licenses/LICENSE +201 -0
hypermind/wire.py
ADDED
|
@@ -0,0 +1,897 @@
|
|
|
1
|
+
"""COSE/CBOR envelope — Signed Statements, Receipts, Disputes.
|
|
2
|
+
|
|
3
|
+
Implements `draft-hypermind-scitt-contested-claims-profile-00` wire shape.
|
|
4
|
+
Encoding is RFC 8949 deterministic CBOR (§4.2). Every envelope
|
|
5
|
+
discriminator is bound into the signed Sig_structure preimage
|
|
6
|
+
(A-ALG-BOUND), preventing cross-version substitution attacks.
|
|
7
|
+
|
|
8
|
+
Protected header label allocations (proposed IANA -65600..-65624):
|
|
9
|
+
-65601 record_type
|
|
10
|
+
-65602 namespace_id (16-byte UUIDv7 of namespace policy root)
|
|
11
|
+
-65603 hlc ([logical_ms, counter, node_id])
|
|
12
|
+
-65604 sig_alg_disc (mirrors COSE alg=1; bound into preimage)
|
|
13
|
+
-65605 envelope_schema_hash (BLAKE3 of CDDL)
|
|
14
|
+
-65606 wire_version (= 0)
|
|
15
|
+
-65607 pq_required (bool)
|
|
16
|
+
-65608 receipt_uncertainty (RECEIPT only)
|
|
17
|
+
-65609 confirmer_set (SEAL only, ≥3)
|
|
18
|
+
-65610 dispute_state
|
|
19
|
+
-65611 protocol_version (= 0; values ≥1 reserved for future profiles)
|
|
20
|
+
-65620 dispute_target_kid
|
|
21
|
+
-65621 dispute_subtype
|
|
22
|
+
-65622 bond_commitment
|
|
23
|
+
-65623 prior_dispute_chain
|
|
24
|
+
-65624 ramp_state
|
|
25
|
+
-65625 valid_from_hlc (reserved, Belief State Layer §21)
|
|
26
|
+
-65626 valid_until_hlc (reserved, Belief State Layer §21)
|
|
27
|
+
-65627 mcp_provenance (T1-03; MCP tool-call provenance)
|
|
28
|
+
-65628..-65629 reserved for Saga Workflow record types (T2)
|
|
29
|
+
-65630 rfp_id (Contract-Net §27, T3)
|
|
30
|
+
-65631 bid_kid (Contract-Net §27, T3)
|
|
31
|
+
-65632 winning_bid_kid (Contract-Net §27, T3)
|
|
32
|
+
-65633 award_signer_kids (Contract-Net §27, T3)
|
|
33
|
+
-65634 bidder_filter (Contract-Net §27, T3)
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import importlib.resources
|
|
39
|
+
import secrets
|
|
40
|
+
from dataclasses import dataclass, field
|
|
41
|
+
from typing import Any, Literal
|
|
42
|
+
|
|
43
|
+
import cbor2
|
|
44
|
+
|
|
45
|
+
from hypermind.crypto.hashing import blake3_32, sha256_kid
|
|
46
|
+
from hypermind.crypto.hlc import HLC
|
|
47
|
+
from hypermind.crypto.signing import (
|
|
48
|
+
ALG_ED25519,
|
|
49
|
+
ALG_MLDSA65_ED25519,
|
|
50
|
+
ED25519_PK_LEN,
|
|
51
|
+
Keypair,
|
|
52
|
+
verify_signature,
|
|
53
|
+
)
|
|
54
|
+
from hypermind.errors import ConformanceError, ProtocolVersionError
|
|
55
|
+
from hypermind.uncertainty import Uncertainty
|
|
56
|
+
|
|
57
|
+
# Standard COSE labels
|
|
58
|
+
COSE_ALG = 1
|
|
59
|
+
COSE_KID = 4
|
|
60
|
+
|
|
61
|
+
# HyperMind extension labels
|
|
62
|
+
HM_RECORD_TYPE = -65601
|
|
63
|
+
HM_NAMESPACE_ID = -65602
|
|
64
|
+
HM_HLC = -65603
|
|
65
|
+
HM_SIG_ALG_DISC = -65604
|
|
66
|
+
HM_ENVELOPE_SCHEMA_HASH = -65605
|
|
67
|
+
HM_WIRE_VERSION = -65606
|
|
68
|
+
HM_PQ_REQUIRED = -65607
|
|
69
|
+
HM_RECEIPT_UNCERTAINTY = -65608
|
|
70
|
+
HM_CONFIRMER_SET = -65609
|
|
71
|
+
HM_DISPUTE_STATE = -65610
|
|
72
|
+
HM_PROTOCOL_VERSION = -65611
|
|
73
|
+
|
|
74
|
+
HM_DISPUTE_TARGET_KID = -65620
|
|
75
|
+
HM_DISPUTE_SUBTYPE = -65621
|
|
76
|
+
HM_BOND_COMMITMENT = -65622
|
|
77
|
+
HM_PRIOR_DISPUTE_CHAIN = -65623
|
|
78
|
+
HM_RAMP_STATE = -65624
|
|
79
|
+
|
|
80
|
+
# P3-05: Reserved for Belief State Layer §21 — not yet normative
|
|
81
|
+
HM_VALID_FROM_HLC = -65625 # reserved, Belief State Layer §21
|
|
82
|
+
HM_VALID_UNTIL_HLC = -65626 # reserved, Belief State Layer §21
|
|
83
|
+
|
|
84
|
+
# T1-03 (v0.10.1) — MCP tool-call provenance binding (spec §coordination-T1).
|
|
85
|
+
# Carries the triple (calling_agent_kid, cap_token_root, request_kid) inside a
|
|
86
|
+
# signed MCP JSON-RPC request envelope. The MCP server MUST reject any
|
|
87
|
+
# `tools/call` whose request lacks this header. The label is reserved here so
|
|
88
|
+
# every wire-label allocation lives in one place, even though MCP requests
|
|
89
|
+
# travel as JSON-RPC (not COSE_Sign1) on the wire — the same numeric label
|
|
90
|
+
# identifies the same semantic field on both transports.
|
|
91
|
+
HM_MCP_PROVENANCE = -65627 # reserved, MCP tool-call provenance (T1-03)
|
|
92
|
+
|
|
93
|
+
# T2 (Saga Workflow) reserves -65628 / -65629 — placeholder allocation so
|
|
94
|
+
# T3 can pick non-colliding numeric labels even though T2 has not landed
|
|
95
|
+
# yet. T2 is expected to add `WORKFLOW_STEP` and `WORKFLOW_COMPENSATE`
|
|
96
|
+
# record types plus saga-id / step-index protected-header fields.
|
|
97
|
+
HM_WORKFLOW_SAGA_ID = -65628 # reserved, Saga Workflow §coordination-T2
|
|
98
|
+
HM_WORKFLOW_STEP_INDEX = -65629 # reserved, Saga Workflow §coordination-T2
|
|
99
|
+
|
|
100
|
+
# T3 (Contract-Net Protocol §27) — RFP/Bid/Award/Dispute coordination.
|
|
101
|
+
# These five record types ride on top of the existing SignedStatement
|
|
102
|
+
# envelope; the new protected-header labels carry the tuple identifiers
|
|
103
|
+
# so a verifier can reject a forged bid/award without parsing the payload.
|
|
104
|
+
HM_RFP_ID = -65630 # Contract-Net rfp_id (32 bytes, sha256 of canonical RFP body)
|
|
105
|
+
HM_BID_KID = -65631 # Contract-Net bid_kid (32 bytes, kid of the bid SignedStatement)
|
|
106
|
+
HM_WINNING_BID_KID = -65632 # AWARD-only: kid of the winning bid
|
|
107
|
+
HM_AWARD_SIGNER_KIDS = -65633 # AWARD-only: list of FROST signer kids (≥1)
|
|
108
|
+
HM_BIDDER_FILTER = -65634 # RFP_OPEN-only: minimum-reputation floor + topic glob
|
|
109
|
+
|
|
110
|
+
# T6-01 (v0.10.4) — Distributed swarm-trace propagation. Carries a W3C
|
|
111
|
+
# TraceContext blob (16-byte trace_id || 8-byte span_id || 1-byte flags ||
|
|
112
|
+
# optional 8-byte parent_span_id) inside the protected header so every
|
|
113
|
+
# SignedStatement that emerges from a deliberation/workflow/RFP step can
|
|
114
|
+
# be stitched into a single OTel span tree across hosts. Reserved here so
|
|
115
|
+
# consumers that don't yet understand the label still preserve it round-
|
|
116
|
+
# trip through canonical re-encoding (A-ALG-BOUND).
|
|
117
|
+
HM_OTEL_TRACE_CONTEXT = -65635 # reserved, distributed swarm-trace (T6-01)
|
|
118
|
+
|
|
119
|
+
#: Highest wire_version this build knows how to mint and parse, and the
|
|
120
|
+
#: outbound default for newly-minted envelopes. Bumped to 2 in v0.9.x
|
|
121
|
+
#: Phase 2 to register the namespace-lifecycle record types
|
|
122
|
+
#: (NAMESPACE_CREATE / NAMESPACE_FOUNDING_ATTEST / NAMESPACE_INVITE /
|
|
123
|
+
#: NAMESPACE_ACCEPT) and the `EncryptedStatement` payload schema (§23).
|
|
124
|
+
WIRE_VERSION = 2
|
|
125
|
+
|
|
126
|
+
#: Alias for callers that branch on the highest known wire version.
|
|
127
|
+
WIRE_VERSION_LATEST = WIRE_VERSION
|
|
128
|
+
|
|
129
|
+
#: Maximum size (in bytes) of a wire-format envelope. Envelopes larger than
|
|
130
|
+
#: this are rejected at parse time before any CBOR decoding work. Bounds the
|
|
131
|
+
#: cost of malformed/oversized inputs and keeps gossip-bus memory usage
|
|
132
|
+
#: predictable. 65536 is comfortably above every legitimate signed statement
|
|
133
|
+
#: shape in v0.x (largest = composite-PQ DISPUTE with full prior chain ≈ 6 KiB).
|
|
134
|
+
MAX_ENVELOPE_BYTES = 65536
|
|
135
|
+
|
|
136
|
+
# Wire-version cutover support (panel A2-2 task #3).
|
|
137
|
+
#
|
|
138
|
+
# During an overlap window between v0 and v1 the agent accepts any envelope
|
|
139
|
+
# whose wire_version is in WIRE_VERSION_SUPPORTED but **prefers the newest**
|
|
140
|
+
# for outbound traffic (= max of the set, currently == WIRE_VERSION). A new
|
|
141
|
+
# version MUST be added to the supported set BEFORE flipping the default
|
|
142
|
+
# WIRE_VERSION constant — this guarantees a graceful overlap.
|
|
143
|
+
#
|
|
144
|
+
# Why v1 is listed BEFORE the v1 CDDL ships (review issue #2 — intentional)
|
|
145
|
+
# -----------------------------------------------------------------------
|
|
146
|
+
# This is a deliberate dual-stack design, not a leak in the version gate.
|
|
147
|
+
# Adding `1` to the supported set ahead of time is what makes the cutover
|
|
148
|
+
# non-breaking: deployed v0 peers must already be willing to parse a v1
|
|
149
|
+
# envelope BEFORE any peer starts emitting v1, otherwise the network
|
|
150
|
+
# fractures the moment the first v1 sender appears. The "supported set
|
|
151
|
+
# MUST be widened before flipping `WIRE_VERSION`" rule above is the
|
|
152
|
+
# normative form of this requirement (panel A2-2 task #3).
|
|
153
|
+
#
|
|
154
|
+
# Security argument for why dual-listing is safe right now:
|
|
155
|
+
# v1 currently shares the v0 envelope CDDL — `HM_ENVELOPE_SCHEMA_HASH` is
|
|
156
|
+
# the same value for both versions. An adversary forging `wire_version=1`
|
|
157
|
+
# on a payload that otherwise matches the v0 layout therefore changes
|
|
158
|
+
# nothing observable: every other discriminator (record_type, namespace_id,
|
|
159
|
+
# hlc, signature_alg, envelope_schema_hash, pq_required) is bound under
|
|
160
|
+
# A-ALG-BOUND, the COSE_Sign1 signature covers the full protected map,
|
|
161
|
+
# and `from_bytes` re-canonicalises the envelope and rejects on mismatch.
|
|
162
|
+
# The version byte by itself does not unlock any new parser branch.
|
|
163
|
+
#
|
|
164
|
+
# When v1 introduces a NEW envelope shape (different CDDL), a separate
|
|
165
|
+
# `HM_ENVELOPE_SCHEMA_HASH_V1` constant MUST be added and `from_bytes`
|
|
166
|
+
# updated to dispatch on `(wire_version, envelope_schema_hash)` jointly.
|
|
167
|
+
# The dual-stack acceptance test
|
|
168
|
+
# `tests/test_wire.py::TestDualStackWireVersion::test_v1_envelope_accepted_at_parse_time`
|
|
169
|
+
# pins today's "v1 round-trips through the v0 schema" property.
|
|
170
|
+
WIRE_VERSION_SUPPORTED: set[int] = {0, 1, 2}
|
|
171
|
+
|
|
172
|
+
#: New record type for the witness cosignatures that close a dispute under the
|
|
173
|
+
#: k-of-n quorum rule (panel A2-2 / `HM-DISPUTE-QUORUM-001`).
|
|
174
|
+
RECORD_DISPUTE_CLOSE_WITNESS = "DISPUTE_CLOSE_WITNESS"
|
|
175
|
+
|
|
176
|
+
#: HyperMind protocol-version negotiation primitive (v0.5; ROADMAP §0.5.1–0.5.3).
|
|
177
|
+
#: Distinct from `wire_version` — `wire_version` discriminates the COSE/CBOR
|
|
178
|
+
#: envelope shape; `protocol_version` discriminates the higher-level profile
|
|
179
|
+
#: rules (panel selection, dispute FSM transitions, reputation kernel formula,
|
|
180
|
+
#: etc.). v0.x ships with `protocol_version=0`. Values ≥1 are reserved for
|
|
181
|
+
#: future profiles negotiated via `min_protocol_version` namespace policy.
|
|
182
|
+
PROTOCOL_VERSION = 0
|
|
183
|
+
|
|
184
|
+
#: Set of protocol_version values accepted at parse time. Add new values
|
|
185
|
+
#: BEFORE flipping PROTOCOL_VERSION so existing peers can already parse
|
|
186
|
+
#: newer envelopes (same dual-stack rationale as WIRE_VERSION_SUPPORTED).
|
|
187
|
+
SUPPORTED_PROTOCOL_VERSIONS: set[int] = {0, 1}
|
|
188
|
+
|
|
189
|
+
RecordType = Literal[
|
|
190
|
+
"SEAL",
|
|
191
|
+
"RECEIPT",
|
|
192
|
+
"DISPUTE",
|
|
193
|
+
"KEY_ROTATE",
|
|
194
|
+
"NAMESPACE_CREATE",
|
|
195
|
+
"NAMESPACE_FOUNDING_ATTEST",
|
|
196
|
+
"NAMESPACE_INVITE",
|
|
197
|
+
"NAMESPACE_ACCEPT",
|
|
198
|
+
"DISPUTE_CLOSE_WITNESS",
|
|
199
|
+
# P3-04: reserved record types for Belief State Layer §21 (no handler yet)
|
|
200
|
+
"cite_query",
|
|
201
|
+
"cite_resp",
|
|
202
|
+
# T2 (v0.10.2): durable workflow record types — reserved for the
|
|
203
|
+
# workflow engine to publish step + compensation records as signed
|
|
204
|
+
# statements once the network-mode wire path lands. The in-process
|
|
205
|
+
# workflow engine in src/hypermind/workflow/ already records steps
|
|
206
|
+
# via transparency.append_and_cosign() using these labels for the
|
|
207
|
+
# leaf-tag domain; reserving them on the wire keeps T3's parallel
|
|
208
|
+
# RFP record-type expansion conflict-free.
|
|
209
|
+
"WORKFLOW_STEP",
|
|
210
|
+
"WORKFLOW_COMPENSATE",
|
|
211
|
+
# T3 (v0.10.2): Contract-Net Protocol §27 (FIPA00029 lineage).
|
|
212
|
+
# RFP_OPEN advertises a job, BIDs come back, AWARD picks a winner
|
|
213
|
+
# under a FROST k-of-n quorum signature when consortium-signed,
|
|
214
|
+
# BID_REJECT records non-winning outcomes (and is also published to
|
|
215
|
+
# revoke an awarded bid after a successful dispute), BID_WITHDRAW
|
|
216
|
+
# lets a bidder pull out before AWARD. Numeric protected-header
|
|
217
|
+
# labels for these records are -65630..-65634 (see top-of-module
|
|
218
|
+
# docstring); they sit AFTER T2's -65628/-65629 reservation.
|
|
219
|
+
"RFP_OPEN",
|
|
220
|
+
"BID",
|
|
221
|
+
"AWARD",
|
|
222
|
+
"BID_REJECT",
|
|
223
|
+
"BID_WITHDRAW",
|
|
224
|
+
]
|
|
225
|
+
|
|
226
|
+
#: Record types that are gated on `wire_version >= 2`. v0/v1 envelopes
|
|
227
|
+
#: carrying these record_types are rejected at parse time.
|
|
228
|
+
V2_GATED_RECORD_TYPES: frozenset[str] = frozenset(
|
|
229
|
+
{
|
|
230
|
+
"NAMESPACE_CREATE",
|
|
231
|
+
"NAMESPACE_FOUNDING_ATTEST",
|
|
232
|
+
"NAMESPACE_INVITE",
|
|
233
|
+
"NAMESPACE_ACCEPT",
|
|
234
|
+
}
|
|
235
|
+
)
|
|
236
|
+
DisputeSubtype = Literal["OPEN", "ARGUE", "COUNTER", "FRIVOLOUS"]
|
|
237
|
+
DisputeState = Literal[
|
|
238
|
+
"OPEN", "ARGUE", "COUNTER", "FRIVOLOUS", "CLOSED", "UNRESOLVED_PARTITION", "MEDIATE"
|
|
239
|
+
]
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
# A-ALG-BOUND: every discriminator that must be inside the signed
|
|
243
|
+
# preimage. The verifier rejects pre-signature-check if any of these
|
|
244
|
+
# is absent from the protected header.
|
|
245
|
+
DISCRIMINATOR_LABELS = (
|
|
246
|
+
COSE_ALG,
|
|
247
|
+
HM_RECORD_TYPE,
|
|
248
|
+
HM_NAMESPACE_ID,
|
|
249
|
+
HM_HLC,
|
|
250
|
+
HM_SIG_ALG_DISC,
|
|
251
|
+
HM_ENVELOPE_SCHEMA_HASH,
|
|
252
|
+
HM_WIRE_VERSION,
|
|
253
|
+
HM_PQ_REQUIRED,
|
|
254
|
+
HM_PROTOCOL_VERSION,
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
# Canonical CDDL schema fingerprint — BLAKE3 of the published CDDL file
|
|
259
|
+
# shipped in `hypermind/schemas/wire-v0.1.cddl`. Loaded once at import
|
|
260
|
+
# time. The independent reference verifier ships its own copy of the
|
|
261
|
+
# same CDDL bytes and must compute the same digest (enforced by
|
|
262
|
+
# `tests/test_schema_hash_pinned.py`).
|
|
263
|
+
# Read as bytes, then normalise CRLF → LF. Without normalisation, a Windows
|
|
264
|
+
# checkout (where git may rewrite LF to CRLF without a .gitattributes pin)
|
|
265
|
+
# would compute a different ENVELOPE_SCHEMA_HASH and reject every Appendix Z
|
|
266
|
+
# conformance vector. The schema fingerprint must be platform-invariant.
|
|
267
|
+
_CDDL_BYTES = (
|
|
268
|
+
(importlib.resources.files("hypermind") / "schemas" / "wire-v0.1.cddl")
|
|
269
|
+
.read_bytes()
|
|
270
|
+
.replace(b"\r\n", b"\n")
|
|
271
|
+
)
|
|
272
|
+
ENVELOPE_SCHEMA_HASH = blake3_32(_CDDL_BYTES)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _load_cddl(name: str) -> bytes:
|
|
276
|
+
"""Load and CRLF-normalise a packaged CDDL file."""
|
|
277
|
+
return (
|
|
278
|
+
(importlib.resources.files("hypermind") / "schemas" / name)
|
|
279
|
+
.read_bytes()
|
|
280
|
+
.replace(b"\r\n", b"\n")
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
# Per-record-type CDDL fingerprints registered for wire_version >= 2.
|
|
285
|
+
# For v2 record_types whose payload has its own CDDL, the BLAKE3-256
|
|
286
|
+
# digest of that payload schema is bound into the protected header
|
|
287
|
+
# under HM_ENVELOPE_SCHEMA_HASH. v0/v1 record_types continue to carry
|
|
288
|
+
# the envelope CDDL digest (`ENVELOPE_SCHEMA_HASH`) for backwards
|
|
289
|
+
# compatibility — the v2 payload CDDL files were added by Phase 2 and
|
|
290
|
+
# only apply to the new record types.
|
|
291
|
+
_NAMESPACE_CREATE_CDDL = _load_cddl("namespace_create.cddl")
|
|
292
|
+
_NAMESPACE_FOUNDING_ATTEST_CDDL = _load_cddl("namespace_founding_attest.cddl")
|
|
293
|
+
_NAMESPACE_INVITE_CDDL = _load_cddl("namespace_invite.cddl")
|
|
294
|
+
_NAMESPACE_ACCEPT_CDDL = _load_cddl("namespace_accept.cddl")
|
|
295
|
+
_ENCRYPTED_STATEMENT_CDDL = _load_cddl("encrypted_statement.cddl")
|
|
296
|
+
|
|
297
|
+
NAMESPACE_CREATE_SCHEMA_HASH = blake3_32(_NAMESPACE_CREATE_CDDL)
|
|
298
|
+
NAMESPACE_FOUNDING_ATTEST_SCHEMA_HASH = blake3_32(_NAMESPACE_FOUNDING_ATTEST_CDDL)
|
|
299
|
+
NAMESPACE_INVITE_SCHEMA_HASH = blake3_32(_NAMESPACE_INVITE_CDDL)
|
|
300
|
+
NAMESPACE_ACCEPT_SCHEMA_HASH = blake3_32(_NAMESPACE_ACCEPT_CDDL)
|
|
301
|
+
ENCRYPTED_STATEMENT_SCHEMA_HASH = blake3_32(_ENCRYPTED_STATEMENT_CDDL)
|
|
302
|
+
|
|
303
|
+
#: Mapping record_type -> expected envelope_schema_hash for v2-gated
|
|
304
|
+
#: records. Records not present here use the default envelope CDDL
|
|
305
|
+
#: digest (`ENVELOPE_SCHEMA_HASH`).
|
|
306
|
+
RECORD_TYPE_SCHEMA_HASHES: dict[str, bytes] = {
|
|
307
|
+
"NAMESPACE_CREATE": NAMESPACE_CREATE_SCHEMA_HASH,
|
|
308
|
+
"NAMESPACE_FOUNDING_ATTEST": NAMESPACE_FOUNDING_ATTEST_SCHEMA_HASH,
|
|
309
|
+
"NAMESPACE_INVITE": NAMESPACE_INVITE_SCHEMA_HASH,
|
|
310
|
+
"NAMESPACE_ACCEPT": NAMESPACE_ACCEPT_SCHEMA_HASH,
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def expected_schema_hash(record_type: str) -> bytes:
|
|
315
|
+
"""Return the BLAKE3-256 schema hash bound for a given record_type."""
|
|
316
|
+
return RECORD_TYPE_SCHEMA_HASHES.get(record_type, ENVELOPE_SCHEMA_HASH)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
Kid = bytes # 32-byte content-addressed claim id
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _det_cbor(obj: Any) -> bytes:
|
|
323
|
+
"""RFC 8949 §4.2 deterministic CBOR encode."""
|
|
324
|
+
return cbor2.dumps(obj, canonical=True)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _count_raw_map_pairs(map_bytes: bytes) -> int:
|
|
328
|
+
"""Count the number of (key, value) pairs declared in a top-level CBOR map.
|
|
329
|
+
|
|
330
|
+
Reads only the map header — does NOT walk the values. Used to detect
|
|
331
|
+
duplicate keys in the protected header: cbor2 silently merges duplicates
|
|
332
|
+
into a Python dict (last-wins), so comparing the *declared* pair count
|
|
333
|
+
against `len(decoded_dict)` reveals the attack. The major-type byte must
|
|
334
|
+
be 5 (map). Indefinite-length maps (0xbf) are rejected as non-canonical.
|
|
335
|
+
"""
|
|
336
|
+
if not map_bytes:
|
|
337
|
+
raise ConformanceError("protected header is empty")
|
|
338
|
+
b0 = map_bytes[0]
|
|
339
|
+
major = b0 >> 5
|
|
340
|
+
if major != 5:
|
|
341
|
+
raise ConformanceError("protected header must be a CBOR map")
|
|
342
|
+
info = b0 & 0x1F
|
|
343
|
+
if info < 24:
|
|
344
|
+
return info
|
|
345
|
+
if info == 24:
|
|
346
|
+
return map_bytes[1]
|
|
347
|
+
if info == 25:
|
|
348
|
+
return int.from_bytes(map_bytes[1:3], "big")
|
|
349
|
+
if info == 26:
|
|
350
|
+
return int.from_bytes(map_bytes[1:5], "big")
|
|
351
|
+
if info == 27:
|
|
352
|
+
return int.from_bytes(map_bytes[1:9], "big")
|
|
353
|
+
# info == 31 -> indefinite length; non-canonical per RFC 8949 §4.2.
|
|
354
|
+
raise ConformanceError("indefinite-length map is not canonical CBOR")
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def canonical_sig_input(protected_map: dict[int, Any], payload: bytes) -> bytes:
|
|
358
|
+
"""Build the COSE_Sign1 Sig_structure preimage.
|
|
359
|
+
|
|
360
|
+
Per RFC 9052 §4.4. Includes context "Signature1", protected, ext_aad
|
|
361
|
+
(empty), and payload. The protected map is encoded as deterministic
|
|
362
|
+
CBOR before being included (so re-ordering keys cannot produce a
|
|
363
|
+
different signature).
|
|
364
|
+
"""
|
|
365
|
+
protected_bytes = _det_cbor(protected_map)
|
|
366
|
+
sig_structure = ["Signature1", protected_bytes, b"", payload]
|
|
367
|
+
return _det_cbor(sig_structure)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _check_a_alg_bound(protected_map: dict[int, Any]) -> None:
|
|
371
|
+
"""A-ALG-BOUND: every discriminator MUST be in the protected map.
|
|
372
|
+
|
|
373
|
+
Reject pre-verify (not signature-error) if any is missing — this is
|
|
374
|
+
the JWT-`alg=none` / SAML-XML-signature-wrapping defence.
|
|
375
|
+
"""
|
|
376
|
+
missing = [lbl for lbl in DISCRIMINATOR_LABELS if lbl not in protected_map]
|
|
377
|
+
if missing:
|
|
378
|
+
raise ConformanceError(
|
|
379
|
+
f"A-ALG-BOUND violation: missing discriminators {missing} from protected header",
|
|
380
|
+
telemetry={"event_type": "A_ALG_BOUND_VIOLATION", "missing": missing},
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
# Cross-check: COSE alg and sig_alg_disc must agree.
|
|
384
|
+
if protected_map[COSE_ALG] != protected_map[HM_SIG_ALG_DISC]:
|
|
385
|
+
raise ConformanceError(
|
|
386
|
+
f"A-ALG-BOUND violation: COSE alg={protected_map[COSE_ALG]} "
|
|
387
|
+
f"≠ sig_alg_disc={protected_map[HM_SIG_ALG_DISC]}",
|
|
388
|
+
telemetry={"event_type": "A_ALG_BOUND_VIOLATION_DISCRIMINATOR_MISMATCH"},
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def new_namespace_id() -> bytes:
|
|
393
|
+
"""Generate a fresh namespace id (16 random bytes; UUIDv7-shaped)."""
|
|
394
|
+
return secrets.token_bytes(16)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
@dataclass
|
|
398
|
+
class SignedStatement:
|
|
399
|
+
"""A HyperMind Signed Statement (COSE_Sign1 with HyperMind extensions).
|
|
400
|
+
|
|
401
|
+
Wire form is `[protected_bytes, unprotected, payload, signature]`.
|
|
402
|
+
|
|
403
|
+
`issuer_kid` is always the 32-byte stable agent address (Ed25519
|
|
404
|
+
public-key half). For composite signatures (alg=-49) the full public
|
|
405
|
+
key (Ed25519 || ML-DSA-65 = 1984 bytes) is carried in the
|
|
406
|
+
unprotected header under key "issuer_pk_full" so verifiers can
|
|
407
|
+
validate the ML-DSA half without needing a key lookup service.
|
|
408
|
+
"""
|
|
409
|
+
|
|
410
|
+
record_type: RecordType
|
|
411
|
+
namespace_id: bytes
|
|
412
|
+
hlc: HLC
|
|
413
|
+
issuer_kid: bytes
|
|
414
|
+
payload: bytes
|
|
415
|
+
signature: bytes
|
|
416
|
+
alg: int = ALG_ED25519
|
|
417
|
+
pq_required: bool = False
|
|
418
|
+
protocol_version: int = PROTOCOL_VERSION
|
|
419
|
+
issuer_pk_full: bytes = b"" # populated for composite alg
|
|
420
|
+
confirmer_set: list[bytes] = field(default_factory=list)
|
|
421
|
+
receipt_uncertainty: Uncertainty | None = None
|
|
422
|
+
dispute_state: DisputeState | None = None
|
|
423
|
+
# dispute fields (only populated for record_type == "DISPUTE"):
|
|
424
|
+
dispute_target_kid: bytes | None = None
|
|
425
|
+
dispute_subtype: DisputeSubtype | None = None
|
|
426
|
+
bond_commitment: dict[str, Any] | None = None
|
|
427
|
+
prior_dispute_chain: list[bytes] = field(default_factory=list)
|
|
428
|
+
ramp_state: dict[str, Any] | None = None
|
|
429
|
+
unprotected: dict[Any, Any] = field(default_factory=dict)
|
|
430
|
+
#: Wire-version of this envelope. Defaults to the newest version this
|
|
431
|
+
#: build prefers for outbound traffic. During an overlap window (panel
|
|
432
|
+
#: A2-2 task #3) `from_bytes` may parse any version in
|
|
433
|
+
#: `WIRE_VERSION_SUPPORTED` and round-trips preserve the original.
|
|
434
|
+
wire_version: int = WIRE_VERSION
|
|
435
|
+
|
|
436
|
+
# ---- construction ----------------------------------------------------
|
|
437
|
+
|
|
438
|
+
@classmethod
|
|
439
|
+
def sign(
|
|
440
|
+
cls,
|
|
441
|
+
*,
|
|
442
|
+
keypair: Keypair,
|
|
443
|
+
record_type: RecordType,
|
|
444
|
+
namespace_id: bytes,
|
|
445
|
+
hlc: HLC,
|
|
446
|
+
payload: bytes,
|
|
447
|
+
pq_required: bool = False,
|
|
448
|
+
protocol_version: int = PROTOCOL_VERSION,
|
|
449
|
+
confirmer_set: list[bytes] | None = None,
|
|
450
|
+
receipt_uncertainty: Uncertainty | None = None,
|
|
451
|
+
dispute_state: DisputeState | None = None,
|
|
452
|
+
dispute_target_kid: bytes | None = None,
|
|
453
|
+
dispute_subtype: DisputeSubtype | None = None,
|
|
454
|
+
bond_commitment: dict[str, Any] | None = None,
|
|
455
|
+
prior_dispute_chain: list[bytes] | None = None,
|
|
456
|
+
ramp_state: dict[str, Any] | None = None,
|
|
457
|
+
wire_version: int | None = None,
|
|
458
|
+
) -> SignedStatement:
|
|
459
|
+
if wire_version is None:
|
|
460
|
+
wire_version = WIRE_VERSION
|
|
461
|
+
protected = cls._build_protected_header(
|
|
462
|
+
alg=keypair.alg,
|
|
463
|
+
record_type=record_type,
|
|
464
|
+
namespace_id=namespace_id,
|
|
465
|
+
hlc=hlc,
|
|
466
|
+
issuer_kid=keypair.kid,
|
|
467
|
+
pq_required=pq_required,
|
|
468
|
+
protocol_version=protocol_version,
|
|
469
|
+
confirmer_set=confirmer_set,
|
|
470
|
+
receipt_uncertainty=receipt_uncertainty,
|
|
471
|
+
dispute_state=dispute_state,
|
|
472
|
+
dispute_target_kid=dispute_target_kid,
|
|
473
|
+
dispute_subtype=dispute_subtype,
|
|
474
|
+
bond_commitment=bond_commitment,
|
|
475
|
+
prior_dispute_chain=prior_dispute_chain,
|
|
476
|
+
ramp_state=ramp_state,
|
|
477
|
+
wire_version=wire_version,
|
|
478
|
+
)
|
|
479
|
+
sig_input = canonical_sig_input(protected, payload)
|
|
480
|
+
signature = keypair.sign(sig_input)
|
|
481
|
+
issuer_pk_full = keypair.public_bytes if keypair.alg == ALG_MLDSA65_ED25519 else b""
|
|
482
|
+
return cls(
|
|
483
|
+
record_type=record_type,
|
|
484
|
+
namespace_id=namespace_id,
|
|
485
|
+
hlc=hlc,
|
|
486
|
+
issuer_kid=keypair.kid,
|
|
487
|
+
payload=payload,
|
|
488
|
+
signature=signature,
|
|
489
|
+
alg=keypair.alg,
|
|
490
|
+
pq_required=pq_required,
|
|
491
|
+
protocol_version=protocol_version,
|
|
492
|
+
issuer_pk_full=issuer_pk_full,
|
|
493
|
+
confirmer_set=list(confirmer_set or []),
|
|
494
|
+
receipt_uncertainty=receipt_uncertainty,
|
|
495
|
+
dispute_state=dispute_state,
|
|
496
|
+
dispute_target_kid=dispute_target_kid,
|
|
497
|
+
dispute_subtype=dispute_subtype,
|
|
498
|
+
bond_commitment=bond_commitment,
|
|
499
|
+
prior_dispute_chain=list(prior_dispute_chain or []),
|
|
500
|
+
ramp_state=ramp_state,
|
|
501
|
+
wire_version=wire_version,
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
# ---- header construction --------------------------------------------
|
|
505
|
+
|
|
506
|
+
@staticmethod
|
|
507
|
+
def _build_protected_header(
|
|
508
|
+
*,
|
|
509
|
+
alg: int,
|
|
510
|
+
record_type: RecordType,
|
|
511
|
+
namespace_id: bytes,
|
|
512
|
+
hlc: HLC,
|
|
513
|
+
issuer_kid: bytes,
|
|
514
|
+
pq_required: bool,
|
|
515
|
+
protocol_version: int,
|
|
516
|
+
confirmer_set: list[bytes] | None,
|
|
517
|
+
receipt_uncertainty: Uncertainty | None,
|
|
518
|
+
dispute_state: DisputeState | None,
|
|
519
|
+
dispute_target_kid: bytes | None,
|
|
520
|
+
dispute_subtype: DisputeSubtype | None,
|
|
521
|
+
bond_commitment: dict[str, Any] | None,
|
|
522
|
+
prior_dispute_chain: list[bytes] | None,
|
|
523
|
+
ramp_state: dict[str, Any] | None,
|
|
524
|
+
wire_version: int = WIRE_VERSION,
|
|
525
|
+
) -> dict[int, Any]:
|
|
526
|
+
if len(namespace_id) != 16:
|
|
527
|
+
raise ValueError("namespace_id must be 16 bytes")
|
|
528
|
+
if record_type in V2_GATED_RECORD_TYPES and wire_version < 2:
|
|
529
|
+
raise ValueError(
|
|
530
|
+
f"record_type {record_type!r} requires wire_version >= 2 (got {wire_version})"
|
|
531
|
+
)
|
|
532
|
+
protected: dict[int, Any] = {
|
|
533
|
+
COSE_ALG: alg,
|
|
534
|
+
COSE_KID: issuer_kid,
|
|
535
|
+
HM_RECORD_TYPE: record_type,
|
|
536
|
+
HM_NAMESPACE_ID: namespace_id,
|
|
537
|
+
HM_HLC: hlc.to_cbor(),
|
|
538
|
+
HM_SIG_ALG_DISC: alg,
|
|
539
|
+
HM_ENVELOPE_SCHEMA_HASH: expected_schema_hash(record_type),
|
|
540
|
+
HM_WIRE_VERSION: wire_version,
|
|
541
|
+
HM_PQ_REQUIRED: pq_required,
|
|
542
|
+
HM_PROTOCOL_VERSION: protocol_version,
|
|
543
|
+
}
|
|
544
|
+
if receipt_uncertainty is not None:
|
|
545
|
+
if record_type != "RECEIPT":
|
|
546
|
+
raise ValueError("receipt_uncertainty only valid on RECEIPT")
|
|
547
|
+
protected[HM_RECEIPT_UNCERTAINTY] = receipt_uncertainty.to_cbor()
|
|
548
|
+
if confirmer_set:
|
|
549
|
+
if record_type != "SEAL":
|
|
550
|
+
raise ValueError("confirmer_set only valid on SEAL")
|
|
551
|
+
if len(confirmer_set) < 3:
|
|
552
|
+
raise ValueError("confirmer_set must have ≥3 entries")
|
|
553
|
+
protected[HM_CONFIRMER_SET] = list(confirmer_set)
|
|
554
|
+
if dispute_state is not None:
|
|
555
|
+
protected[HM_DISPUTE_STATE] = dispute_state
|
|
556
|
+
if record_type == "DISPUTE":
|
|
557
|
+
if dispute_target_kid is None or dispute_subtype is None or bond_commitment is None:
|
|
558
|
+
raise ValueError(
|
|
559
|
+
"DISPUTE record requires dispute_target_kid, "
|
|
560
|
+
"dispute_subtype, and bond_commitment"
|
|
561
|
+
)
|
|
562
|
+
if len(dispute_target_kid) != 32:
|
|
563
|
+
raise ValueError("dispute_target_kid must be 32 bytes")
|
|
564
|
+
protected[HM_DISPUTE_TARGET_KID] = dispute_target_kid
|
|
565
|
+
protected[HM_DISPUTE_SUBTYPE] = dispute_subtype
|
|
566
|
+
protected[HM_BOND_COMMITMENT] = bond_commitment
|
|
567
|
+
if prior_dispute_chain:
|
|
568
|
+
protected[HM_PRIOR_DISPUTE_CHAIN] = list(prior_dispute_chain)
|
|
569
|
+
if ramp_state:
|
|
570
|
+
protected[HM_RAMP_STATE] = ramp_state
|
|
571
|
+
return protected
|
|
572
|
+
|
|
573
|
+
# ---- accessors ------------------------------------------------------
|
|
574
|
+
|
|
575
|
+
@property
|
|
576
|
+
def _protected_bytes(self) -> bytes:
|
|
577
|
+
"""Canonical deterministic CBOR of the protected header only.
|
|
578
|
+
|
|
579
|
+
Used as the sole input to ``kid`` — the unprotected header is
|
|
580
|
+
mutable and NOT signature-bound, so including it in the kid would
|
|
581
|
+
let an attacker change the kid without invalidating the signature
|
|
582
|
+
(P1-02 / HM-KID-PROTECTED-ONLY).
|
|
583
|
+
"""
|
|
584
|
+
return _det_cbor(self.protected_map())
|
|
585
|
+
|
|
586
|
+
@property
|
|
587
|
+
def kid(self) -> Kid:
|
|
588
|
+
"""Content-addressed kid: sha256(protected_header_bytes only).
|
|
589
|
+
|
|
590
|
+
Derived from the *protected* header exclusively so that mutations
|
|
591
|
+
to the unprotected header (which are NOT signature-bound) cannot
|
|
592
|
+
alter the kid without invalidating the signature. This closes the
|
|
593
|
+
P1-02 mutable-unprotected-header substitution class.
|
|
594
|
+
"""
|
|
595
|
+
return sha256_kid(self._protected_bytes)
|
|
596
|
+
|
|
597
|
+
def protected_map(self) -> dict[int, Any]:
|
|
598
|
+
return self._build_protected_header(
|
|
599
|
+
alg=self.alg,
|
|
600
|
+
record_type=self.record_type,
|
|
601
|
+
namespace_id=self.namespace_id,
|
|
602
|
+
hlc=self.hlc,
|
|
603
|
+
issuer_kid=self.issuer_kid,
|
|
604
|
+
pq_required=self.pq_required,
|
|
605
|
+
protocol_version=self.protocol_version,
|
|
606
|
+
confirmer_set=self.confirmer_set,
|
|
607
|
+
receipt_uncertainty=self.receipt_uncertainty,
|
|
608
|
+
dispute_state=self.dispute_state,
|
|
609
|
+
dispute_target_kid=self.dispute_target_kid,
|
|
610
|
+
dispute_subtype=self.dispute_subtype,
|
|
611
|
+
bond_commitment=self.bond_commitment,
|
|
612
|
+
prior_dispute_chain=self.prior_dispute_chain,
|
|
613
|
+
ramp_state=self.ramp_state,
|
|
614
|
+
wire_version=self.wire_version,
|
|
615
|
+
)
|
|
616
|
+
|
|
617
|
+
# ---- serialization ---------------------------------------------------
|
|
618
|
+
|
|
619
|
+
def to_bytes(self) -> bytes:
|
|
620
|
+
"""Encode as COSE_Sign1: [protected_bytes, unprotected, payload, signature].
|
|
621
|
+
|
|
622
|
+
For composite (alg=-49) the full issuer public key is carried in
|
|
623
|
+
the unprotected header so verifiers can validate without a key
|
|
624
|
+
lookup. The Ed25519 half (=kid) is also in the protected header
|
|
625
|
+
(label 4), preventing identity-substitution.
|
|
626
|
+
"""
|
|
627
|
+
protected_bytes = _det_cbor(self.protected_map())
|
|
628
|
+
unprotected = dict(self.unprotected)
|
|
629
|
+
if self.issuer_pk_full and self.alg == ALG_MLDSA65_ED25519:
|
|
630
|
+
unprotected["issuer_pk_full"] = self.issuer_pk_full
|
|
631
|
+
return _det_cbor([protected_bytes, unprotected, self.payload, self.signature])
|
|
632
|
+
|
|
633
|
+
@classmethod
|
|
634
|
+
def from_bytes(cls, raw: bytes) -> SignedStatement:
|
|
635
|
+
if len(raw) > MAX_ENVELOPE_BYTES:
|
|
636
|
+
raise ConformanceError(
|
|
637
|
+
f"envelope exceeds maximum size: {len(raw)} > {MAX_ENVELOPE_BYTES} bytes"
|
|
638
|
+
)
|
|
639
|
+
try:
|
|
640
|
+
msg = cbor2.loads(raw)
|
|
641
|
+
except cbor2.CBORDecodeError as e:
|
|
642
|
+
raise ConformanceError(f"envelope is not valid CBOR: {e}") from e
|
|
643
|
+
if not isinstance(msg, list) or len(msg) != 4:
|
|
644
|
+
raise ConformanceError("envelope must be 4-element COSE_Sign1 array")
|
|
645
|
+
protected_bytes, unprotected, payload, signature = msg
|
|
646
|
+
try:
|
|
647
|
+
protected = cbor2.loads(protected_bytes)
|
|
648
|
+
except cbor2.CBORDecodeError as e:
|
|
649
|
+
raise ConformanceError(f"protected header is not valid CBOR: {e}") from e
|
|
650
|
+
if not isinstance(protected, dict):
|
|
651
|
+
raise ConformanceError("protected header must be a CBOR map")
|
|
652
|
+
|
|
653
|
+
# Duplicate-key gate: cbor2 silently merges duplicate map keys (last
|
|
654
|
+
# wins). Compare the *declared* pair count from the raw map header
|
|
655
|
+
# against the decoded dict length to surface the attack.
|
|
656
|
+
declared_pairs = _count_raw_map_pairs(protected_bytes)
|
|
657
|
+
if declared_pairs != len(protected):
|
|
658
|
+
raise ConformanceError("duplicate keys in protected header")
|
|
659
|
+
|
|
660
|
+
# Canonical-encoding gate (closes self-heal Cycle-1 Finding #7):
|
|
661
|
+
# re-encode the parsed protected map under deterministic CBOR
|
|
662
|
+
# and require byte-equality with the wire bytes. This prevents
|
|
663
|
+
# malleable encodings producing two distinct kids for the same
|
|
664
|
+
# logical statement.
|
|
665
|
+
if _det_cbor(protected) != protected_bytes:
|
|
666
|
+
raise ConformanceError("non-canonical protected header encoding (RFC 8949 §4.2)")
|
|
667
|
+
|
|
668
|
+
# Domain bound check happens here, BEFORE signature verify.
|
|
669
|
+
_check_a_alg_bound(protected)
|
|
670
|
+
|
|
671
|
+
# Wire-version + schema-hash gate: reject incompatible envelopes
|
|
672
|
+
# at parse time. A tampered wire_version (Z.7.4-class attack) is
|
|
673
|
+
# rejected here, not at signature-verify, so the failure mode is
|
|
674
|
+
# auditable (ConformanceError, not silent signature failure).
|
|
675
|
+
wv = protected[HM_WIRE_VERSION]
|
|
676
|
+
if wv not in WIRE_VERSION_SUPPORTED:
|
|
677
|
+
raise ConformanceError(
|
|
678
|
+
f"wire_version mismatch: got {wv}, expected one of {sorted(WIRE_VERSION_SUPPORTED)}"
|
|
679
|
+
)
|
|
680
|
+
record_type_for_hash = protected[HM_RECORD_TYPE]
|
|
681
|
+
if record_type_for_hash in V2_GATED_RECORD_TYPES and wv < 2:
|
|
682
|
+
raise ConformanceError(
|
|
683
|
+
f"record_type {record_type_for_hash!r} requires wire_version >= 2 (got {wv})"
|
|
684
|
+
)
|
|
685
|
+
expected_hash = expected_schema_hash(record_type_for_hash)
|
|
686
|
+
if protected[HM_ENVELOPE_SCHEMA_HASH] != expected_hash:
|
|
687
|
+
raise ConformanceError("envelope_schema_hash mismatch")
|
|
688
|
+
|
|
689
|
+
# P2-07: protocol_version dispatch — reject unsupported versions
|
|
690
|
+
# before signature verification so the refusal is auditable as a
|
|
691
|
+
# policy decision, not a crypto failure.
|
|
692
|
+
pv = int(protected[HM_PROTOCOL_VERSION])
|
|
693
|
+
if pv not in SUPPORTED_PROTOCOL_VERSIONS:
|
|
694
|
+
raise ProtocolVersionError(
|
|
695
|
+
f"Unsupported protocol version: {pv}; supported={sorted(SUPPORTED_PROTOCOL_VERSIONS)}",
|
|
696
|
+
statement_version=pv,
|
|
697
|
+
min_required=min(SUPPORTED_PROTOCOL_VERSIONS),
|
|
698
|
+
)
|
|
699
|
+
|
|
700
|
+
record_type = protected[HM_RECORD_TYPE]
|
|
701
|
+
unprotected_dict = dict(unprotected) if unprotected else {}
|
|
702
|
+
issuer_pk_full = bytes(unprotected_dict.pop("issuer_pk_full", b""))
|
|
703
|
+
return cls(
|
|
704
|
+
record_type=record_type,
|
|
705
|
+
namespace_id=bytes(protected[HM_NAMESPACE_ID]),
|
|
706
|
+
hlc=HLC.from_cbor(protected[HM_HLC]),
|
|
707
|
+
issuer_kid=bytes(protected[COSE_KID]),
|
|
708
|
+
payload=bytes(payload),
|
|
709
|
+
signature=bytes(signature),
|
|
710
|
+
alg=int(protected[COSE_ALG]),
|
|
711
|
+
pq_required=bool(protected[HM_PQ_REQUIRED]),
|
|
712
|
+
protocol_version=int(protected[HM_PROTOCOL_VERSION]),
|
|
713
|
+
issuer_pk_full=issuer_pk_full,
|
|
714
|
+
confirmer_set=[bytes(c) for c in protected.get(HM_CONFIRMER_SET, [])],
|
|
715
|
+
receipt_uncertainty=(
|
|
716
|
+
Uncertainty.from_cbor(protected[HM_RECEIPT_UNCERTAINTY])
|
|
717
|
+
if HM_RECEIPT_UNCERTAINTY in protected
|
|
718
|
+
else None
|
|
719
|
+
),
|
|
720
|
+
dispute_state=protected.get(HM_DISPUTE_STATE),
|
|
721
|
+
dispute_target_kid=(
|
|
722
|
+
bytes(protected[HM_DISPUTE_TARGET_KID])
|
|
723
|
+
if HM_DISPUTE_TARGET_KID in protected
|
|
724
|
+
else None
|
|
725
|
+
),
|
|
726
|
+
dispute_subtype=protected.get(HM_DISPUTE_SUBTYPE),
|
|
727
|
+
bond_commitment=protected.get(HM_BOND_COMMITMENT),
|
|
728
|
+
prior_dispute_chain=[bytes(k) for k in protected.get(HM_PRIOR_DISPUTE_CHAIN, [])],
|
|
729
|
+
ramp_state=protected.get(HM_RAMP_STATE),
|
|
730
|
+
unprotected=unprotected_dict,
|
|
731
|
+
wire_version=int(wv),
|
|
732
|
+
)
|
|
733
|
+
|
|
734
|
+
# ---- verification ----------------------------------------------------
|
|
735
|
+
|
|
736
|
+
def verify(self, *, revocation_store: Any = None) -> bool:
|
|
737
|
+
"""Verify signature and A-ALG-BOUND.
|
|
738
|
+
|
|
739
|
+
Returns True if (a) every discriminator is in the signed
|
|
740
|
+
preimage, (b) wire_version + envelope_schema_hash match the
|
|
741
|
+
local v0 contract, (c) the signature is valid Ed25519 over the
|
|
742
|
+
canonical sig_input, and (d) — when a `revocation_store` is
|
|
743
|
+
supplied — the issuer kid is not revoked as of this statement's
|
|
744
|
+
HLC. Statements signed *before* a revocation remain valid; this
|
|
745
|
+
is the historical-claim-immutability property of v0.7's CRL
|
|
746
|
+
primitive (rule_id ``HM-KEY-REVOKED-001``).
|
|
747
|
+
|
|
748
|
+
Raises ConformanceError on A-ALG-BOUND or schema mismatch.
|
|
749
|
+
Returns False on signature failure or revocation hit (no
|
|
750
|
+
exception — callers can branch on the return value).
|
|
751
|
+
"""
|
|
752
|
+
protected = self.protected_map()
|
|
753
|
+
_check_a_alg_bound(protected)
|
|
754
|
+
|
|
755
|
+
# Revocation gate: rejected BEFORE signature verify so a revoked
|
|
756
|
+
# kid cannot waste verifier CPU on signature math.
|
|
757
|
+
if revocation_store is not None:
|
|
758
|
+
entry = revocation_store.is_revoked(
|
|
759
|
+
bytes(self.issuer_kid),
|
|
760
|
+
(self.hlc.logical_ms, self.hlc.counter),
|
|
761
|
+
)
|
|
762
|
+
if entry is not None:
|
|
763
|
+
return False
|
|
764
|
+
|
|
765
|
+
if protected[HM_WIRE_VERSION] not in WIRE_VERSION_SUPPORTED:
|
|
766
|
+
raise ConformanceError(
|
|
767
|
+
f"wire_version mismatch: got {protected[HM_WIRE_VERSION]}, "
|
|
768
|
+
f"expected one of {sorted(WIRE_VERSION_SUPPORTED)}"
|
|
769
|
+
)
|
|
770
|
+
if protected[HM_ENVELOPE_SCHEMA_HASH] != expected_schema_hash(self.record_type):
|
|
771
|
+
raise ConformanceError("envelope_schema_hash mismatch")
|
|
772
|
+
|
|
773
|
+
if self.alg not in (ALG_ED25519, ALG_MLDSA65_ED25519):
|
|
774
|
+
raise ConformanceError(f"unsupported alg {self.alg}")
|
|
775
|
+
|
|
776
|
+
sig_input = canonical_sig_input(protected, self.payload)
|
|
777
|
+
if self.alg == ALG_ED25519:
|
|
778
|
+
return verify_signature(self.alg, self.issuer_kid, self.signature, sig_input)
|
|
779
|
+
# composite — need the full public key
|
|
780
|
+
full_pk = self.issuer_pk_full if len(self.issuer_pk_full) > 0 else self.issuer_kid
|
|
781
|
+
if len(full_pk) != ED25519_PK_LEN + 1952:
|
|
782
|
+
return False
|
|
783
|
+
# Bind the kid (Ed25519 half) to the full key — defeats identity
|
|
784
|
+
# substitution at parse time.
|
|
785
|
+
if full_pk[:ED25519_PK_LEN] != self.issuer_kid:
|
|
786
|
+
raise ConformanceError("issuer_kid does not match Ed25519 prefix of issuer_pk_full")
|
|
787
|
+
return verify_signature(self.alg, full_pk, self.signature, sig_input)
|
|
788
|
+
|
|
789
|
+
def __repr__(self) -> str:
|
|
790
|
+
return (
|
|
791
|
+
f"SignedStatement({self.record_type} kid={self.kid.hex()[:12]} "
|
|
792
|
+
f"hlc={self.hlc} ns={self.namespace_id.hex()[:8]})"
|
|
793
|
+
)
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
@dataclass
|
|
797
|
+
class EncryptedStatementRecipient:
|
|
798
|
+
"""One recipient slot in an `EncryptedStatement` (§23.5)."""
|
|
799
|
+
|
|
800
|
+
recipient_kid: bytes # 32-byte Ed25519 pubkey half
|
|
801
|
+
enc: bytes # HPKE encapsulated key
|
|
802
|
+
|
|
803
|
+
def to_cbor(self) -> dict[str, bytes]:
|
|
804
|
+
if len(self.recipient_kid) != 32:
|
|
805
|
+
raise ValueError("recipient_kid must be 32 bytes")
|
|
806
|
+
return {"recipient_kid": self.recipient_kid, "enc": self.enc}
|
|
807
|
+
|
|
808
|
+
@classmethod
|
|
809
|
+
def from_cbor(cls, obj: Any) -> EncryptedStatementRecipient:
|
|
810
|
+
if not isinstance(obj, dict):
|
|
811
|
+
raise ConformanceError("Recipient must be a CBOR map")
|
|
812
|
+
if set(obj.keys()) != {"recipient_kid", "enc"}:
|
|
813
|
+
raise ConformanceError(
|
|
814
|
+
f"Recipient must have exactly keys recipient_kid+enc; got {sorted(obj.keys())}"
|
|
815
|
+
)
|
|
816
|
+
kid = bytes(obj["recipient_kid"])
|
|
817
|
+
enc = bytes(obj["enc"])
|
|
818
|
+
if len(kid) != 32:
|
|
819
|
+
raise ConformanceError("recipient_kid must be 32 bytes")
|
|
820
|
+
return cls(recipient_kid=kid, enc=enc)
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
@dataclass
|
|
824
|
+
class EncryptedStatement:
|
|
825
|
+
"""HPKE-AEAD ciphertext payload for hybrid/private namespace records.
|
|
826
|
+
|
|
827
|
+
Wire form (RFC 8949 deterministic CBOR; see
|
|
828
|
+
`hypermind/schemas/encrypted_statement.cddl`):
|
|
829
|
+
|
|
830
|
+
[kdf, aead, [+ Recipient], aad_hash, ciphertext, ?epoch_tag]
|
|
831
|
+
|
|
832
|
+
`from_bytes` enforces canonical-CBOR (re-encode equality), array
|
|
833
|
+
arity, and 32-byte sizing of `aad_hash` / `recipient_kid` / optional
|
|
834
|
+
`epoch_tag` (16 bytes). The signature on the outer `SignedStatement`
|
|
835
|
+
covers `aad_hash` only — verification MUST NOT require decryption
|
|
836
|
+
(spec §23.3).
|
|
837
|
+
"""
|
|
838
|
+
|
|
839
|
+
kdf: int
|
|
840
|
+
aead: int
|
|
841
|
+
enc_recipients: list[EncryptedStatementRecipient]
|
|
842
|
+
aad_hash: bytes # 32 bytes
|
|
843
|
+
ciphertext: bytes
|
|
844
|
+
epoch_tag: bytes | None = None # 16 bytes if present
|
|
845
|
+
|
|
846
|
+
def to_bytes(self) -> bytes:
|
|
847
|
+
if len(self.aad_hash) != 32:
|
|
848
|
+
raise ValueError("aad_hash must be 32 bytes")
|
|
849
|
+
if not self.enc_recipients:
|
|
850
|
+
raise ValueError("enc_recipients must contain at least one Recipient")
|
|
851
|
+
if self.epoch_tag is not None and len(self.epoch_tag) != 16:
|
|
852
|
+
raise ValueError("epoch_tag must be 16 bytes when present")
|
|
853
|
+
arr: list[Any] = [
|
|
854
|
+
int(self.kdf),
|
|
855
|
+
int(self.aead),
|
|
856
|
+
[r.to_cbor() for r in self.enc_recipients],
|
|
857
|
+
self.aad_hash,
|
|
858
|
+
self.ciphertext,
|
|
859
|
+
]
|
|
860
|
+
if self.epoch_tag is not None:
|
|
861
|
+
arr.append(self.epoch_tag)
|
|
862
|
+
return _det_cbor(arr)
|
|
863
|
+
|
|
864
|
+
@classmethod
|
|
865
|
+
def from_bytes(cls, raw: bytes) -> EncryptedStatement:
|
|
866
|
+
if len(raw) > MAX_ENVELOPE_BYTES:
|
|
867
|
+
raise ConformanceError(
|
|
868
|
+
f"EncryptedStatement exceeds maximum size: {len(raw)} > {MAX_ENVELOPE_BYTES}"
|
|
869
|
+
)
|
|
870
|
+
try:
|
|
871
|
+
arr = cbor2.loads(raw)
|
|
872
|
+
except cbor2.CBORDecodeError as e:
|
|
873
|
+
raise ConformanceError(f"EncryptedStatement is not valid CBOR: {e}") from e
|
|
874
|
+
if not isinstance(arr, list) or len(arr) not in (5, 6):
|
|
875
|
+
raise ConformanceError("EncryptedStatement must be a 5- or 6-element CBOR array")
|
|
876
|
+
# Canonical-encoding gate (mirror SignedStatement.from_bytes).
|
|
877
|
+
if _det_cbor(arr) != raw:
|
|
878
|
+
raise ConformanceError("non-canonical EncryptedStatement encoding (RFC 8949 §4.2)")
|
|
879
|
+
kdf, aead, recipients_raw, aad_hash, ciphertext = arr[:5]
|
|
880
|
+
epoch_tag: bytes | None = bytes(arr[5]) if len(arr) == 6 else None
|
|
881
|
+
if not isinstance(kdf, int) or not isinstance(aead, int):
|
|
882
|
+
raise ConformanceError("kdf and aead must be integers")
|
|
883
|
+
if not isinstance(recipients_raw, list) or not recipients_raw:
|
|
884
|
+
raise ConformanceError("enc_recipients must be a non-empty array")
|
|
885
|
+
aad_hash_b = bytes(aad_hash)
|
|
886
|
+
if len(aad_hash_b) != 32:
|
|
887
|
+
raise ConformanceError("aad_hash must be 32 bytes")
|
|
888
|
+
if epoch_tag is not None and len(epoch_tag) != 16:
|
|
889
|
+
raise ConformanceError("epoch_tag must be 16 bytes when present")
|
|
890
|
+
return cls(
|
|
891
|
+
kdf=kdf,
|
|
892
|
+
aead=aead,
|
|
893
|
+
enc_recipients=[EncryptedStatementRecipient.from_cbor(r) for r in recipients_raw],
|
|
894
|
+
aad_hash=aad_hash_b,
|
|
895
|
+
ciphertext=bytes(ciphertext),
|
|
896
|
+
epoch_tag=epoch_tag,
|
|
897
|
+
)
|