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/capability.py
ADDED
|
@@ -0,0 +1,923 @@
|
|
|
1
|
+
"""Capability tokens with caveat-chain attenuation.
|
|
2
|
+
|
|
3
|
+
Biscuit-shape semantics (monotonic narrowing) bound by COSE signature.
|
|
4
|
+
Closes Cycle-1 fix #5 and the MCP confused-deputy gap (§4.12.2).
|
|
5
|
+
|
|
6
|
+
Chain-of-trust binding (closes self-heal Cycle-1 Finding #1):
|
|
7
|
+
- Each token records `root_issuer` (the first issuer in the chain).
|
|
8
|
+
- Each attenuation records `parent_sig` (a hash of the parent's
|
|
9
|
+
canonical body+sig). The attenuated token's signed body therefore
|
|
10
|
+
proves "this attenuation was performed on a token whose body+sig
|
|
11
|
+
hash to `parent_sig`" — verifiers walk the chain back to a
|
|
12
|
+
recognised root issuer.
|
|
13
|
+
- `verify()` requires `expected_root_issuer` to be supplied so the
|
|
14
|
+
caller pins which root they trust; verifying without one only
|
|
15
|
+
checks self-consistency, NOT trust.
|
|
16
|
+
|
|
17
|
+
Each caveat narrows scope; first failing caveat raises AuthError with
|
|
18
|
+
`failed_caveat=<kind>` so operators can see exactly which restriction
|
|
19
|
+
fired. The caveat list and signing alg are inside the signed preimage
|
|
20
|
+
(A-ALG-BOUND), so JWT-`alg=none`-class substitution is impossible.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import hashlib
|
|
26
|
+
import hmac
|
|
27
|
+
import time
|
|
28
|
+
from dataclasses import dataclass, field
|
|
29
|
+
from datetime import UTC
|
|
30
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
31
|
+
|
|
32
|
+
import cbor2
|
|
33
|
+
|
|
34
|
+
from hypermind.crypto.signing import Keypair, verify_ed25519
|
|
35
|
+
from hypermind.errors import AuthError, ConformanceError
|
|
36
|
+
from hypermind.revocation import RevocationStore
|
|
37
|
+
|
|
38
|
+
if TYPE_CHECKING:
|
|
39
|
+
from hypermind.revocation import RevocationList
|
|
40
|
+
|
|
41
|
+
CaveatKind = Literal[
|
|
42
|
+
"topic",
|
|
43
|
+
"claim_type",
|
|
44
|
+
"rate",
|
|
45
|
+
"expiry",
|
|
46
|
+
"parent_kid",
|
|
47
|
+
"tool",
|
|
48
|
+
"ns",
|
|
49
|
+
"room",
|
|
50
|
+
"to",
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
#: Closed set of valid caveat kinds (validated in `Caveat.__post_init__`).
|
|
54
|
+
#: `ns`, `room`, `to` were added at wire-version 2 per spec §25.1.
|
|
55
|
+
_VALID_CAVEAT_KINDS: frozenset[str] = frozenset(
|
|
56
|
+
{
|
|
57
|
+
"topic",
|
|
58
|
+
"claim_type",
|
|
59
|
+
"rate",
|
|
60
|
+
"expiry",
|
|
61
|
+
"parent_kid",
|
|
62
|
+
"tool",
|
|
63
|
+
"ns",
|
|
64
|
+
"room",
|
|
65
|
+
"to",
|
|
66
|
+
}
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# -----------------------------------------------------------------------
|
|
71
|
+
# TokenBucket — runtime rate enforcement for "rate" caveats (P1-03)
|
|
72
|
+
# -----------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class TokenBucket:
|
|
76
|
+
"""Leaky-bucket / token-bucket for rate-caveat enforcement.
|
|
77
|
+
|
|
78
|
+
Accepts rate strings in the forms used by ``Caveat.rate()``:
|
|
79
|
+
``"5/min"``, ``"100/hour"``, ``"10/s"`` (also ``"sec"``, ``"second"``,
|
|
80
|
+
``"minute"``, ``"hr"``).
|
|
81
|
+
|
|
82
|
+
Thread-safety: not thread-safe by itself; the caller (``_require_cap``)
|
|
83
|
+
holds a per-agent dict, which is already single-threaded inside the
|
|
84
|
+
async event loop.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
def __init__(self, rate_str: str) -> None:
|
|
88
|
+
try:
|
|
89
|
+
per_min = _rate_per_min(rate_str)
|
|
90
|
+
except (ValueError, TypeError) as exc:
|
|
91
|
+
raise AuthError(
|
|
92
|
+
f"unparsable rate caveat {rate_str!r}: {exc}",
|
|
93
|
+
failed_caveat="rate",
|
|
94
|
+
) from exc
|
|
95
|
+
# Capacity = max(1, per-second rate) burst tokens pre-filled at start.
|
|
96
|
+
self._refill_per_second: float = per_min / 60.0
|
|
97
|
+
self._capacity: float = max(1.0, per_min / 60.0)
|
|
98
|
+
self._tokens: float = self._capacity
|
|
99
|
+
self._last_refill: float = time.monotonic()
|
|
100
|
+
|
|
101
|
+
def consume(self) -> bool:
|
|
102
|
+
"""Try to consume one token. Returns True if allowed, False if limited."""
|
|
103
|
+
now = time.monotonic()
|
|
104
|
+
elapsed = now - self._last_refill
|
|
105
|
+
self._last_refill = now
|
|
106
|
+
self._tokens = min(self._capacity, self._tokens + elapsed * self._refill_per_second)
|
|
107
|
+
if self._tokens >= 1.0:
|
|
108
|
+
self._tokens -= 1.0
|
|
109
|
+
return True
|
|
110
|
+
return False
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass(frozen=True)
|
|
114
|
+
class Caveat:
|
|
115
|
+
"""A single restriction in a caveat chain. Caveats are ordered;
|
|
116
|
+
each subsequent caveat may only narrow the scope of its predecessors.
|
|
117
|
+
|
|
118
|
+
Valid `kind` values are validated at construction (closes the silent
|
|
119
|
+
typo bypass — `Caveat("topik", ...)` previously parsed but never
|
|
120
|
+
enforced anything). Use the named constructors (`Caveat.topic(...)`,
|
|
121
|
+
`Caveat.rate(...)`, etc.) to avoid hand-typing the kind string.
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
kind: CaveatKind
|
|
125
|
+
value: Any
|
|
126
|
+
|
|
127
|
+
def __post_init__(self) -> None:
|
|
128
|
+
if self.kind not in _VALID_CAVEAT_KINDS:
|
|
129
|
+
raise ValueError(
|
|
130
|
+
f"unknown caveat kind {self.kind!r}.\n"
|
|
131
|
+
f" Valid kinds: {sorted(_VALID_CAVEAT_KINDS)}\n"
|
|
132
|
+
f" Hint: use Caveat.topic(...), Caveat.rate(...), "
|
|
133
|
+
f"Caveat.namespace(...), Caveat.room(...), Caveat.recipient(...) "
|
|
134
|
+
f"to avoid typos."
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
# ---- named constructors (typo-proof) -------------------------------
|
|
138
|
+
|
|
139
|
+
@classmethod
|
|
140
|
+
def topic(cls, glob: str) -> Caveat:
|
|
141
|
+
"""Restrict to a topic glob, e.g. 'ai-safety/*' or 'finance/eval'."""
|
|
142
|
+
return cls(kind="topic", value=glob)
|
|
143
|
+
|
|
144
|
+
@classmethod
|
|
145
|
+
def claim_type(cls, name: str) -> Caveat:
|
|
146
|
+
"""Restrict to a claim_type, e.g. 'eval_result' or 'threat_indicator'."""
|
|
147
|
+
return cls(kind="claim_type", value=name)
|
|
148
|
+
|
|
149
|
+
@classmethod
|
|
150
|
+
def rate(cls, spec: str) -> Caveat:
|
|
151
|
+
"""Restrict invocation rate, e.g. '10/min', '60/sec', '1000/hr'."""
|
|
152
|
+
return cls(kind="rate", value=spec)
|
|
153
|
+
|
|
154
|
+
@classmethod
|
|
155
|
+
def expiry(cls, iso_timestamp: str) -> Caveat:
|
|
156
|
+
"""Expire after the ISO-8601 timestamp (UTC, e.g. '2030-01-01T00:00Z')."""
|
|
157
|
+
return cls(kind="expiry", value=iso_timestamp)
|
|
158
|
+
|
|
159
|
+
@classmethod
|
|
160
|
+
def parent_kid(cls, kid: bytes | str) -> Caveat:
|
|
161
|
+
"""Pin to a specific parent claim kid (32-byte hex or bytes)."""
|
|
162
|
+
return cls(kind="parent_kid", value=kid.hex() if isinstance(kid, bytes) else kid)
|
|
163
|
+
|
|
164
|
+
@classmethod
|
|
165
|
+
def tool(cls, name: str) -> Caveat:
|
|
166
|
+
"""Restrict to a specific MCP tool, e.g. 'web_search' or 'shell'."""
|
|
167
|
+
return cls(kind="tool", value=name)
|
|
168
|
+
|
|
169
|
+
@classmethod
|
|
170
|
+
def namespace(cls, ns_id_hex: str) -> Caveat:
|
|
171
|
+
"""Restrict to a HyperMind namespace (spec §25.1).
|
|
172
|
+
|
|
173
|
+
``ns_id_hex`` is the lowercase hex of the namespace_id. At
|
|
174
|
+
wire-version 2 every CapToken root MUST carry one of these.
|
|
175
|
+
"""
|
|
176
|
+
if not isinstance(ns_id_hex, str):
|
|
177
|
+
raise ValueError("namespace caveat value must be a hex string")
|
|
178
|
+
return cls(kind="ns", value=ns_id_hex.lower())
|
|
179
|
+
|
|
180
|
+
@classmethod
|
|
181
|
+
def room(cls, room_id: str) -> Caveat:
|
|
182
|
+
"""Restrict to a TAL Room id (spec §25.1).
|
|
183
|
+
|
|
184
|
+
``room_id`` is the hex of the Room id (a Room lives inside a
|
|
185
|
+
namespace; the `ns:` caveat carries the namespace anchor).
|
|
186
|
+
"""
|
|
187
|
+
if not isinstance(room_id, str):
|
|
188
|
+
raise ValueError("room caveat value must be a hex string")
|
|
189
|
+
return cls(kind="room", value=room_id.lower())
|
|
190
|
+
|
|
191
|
+
@classmethod
|
|
192
|
+
def recipient(cls, kid: bytes | str) -> Caveat:
|
|
193
|
+
"""Restrict to a specific recipient kid (spec §25.1).
|
|
194
|
+
|
|
195
|
+
Used for consult routing or encrypted-payload addressing —
|
|
196
|
+
only the named recipient kid may be the operation target.
|
|
197
|
+
"""
|
|
198
|
+
if isinstance(kid, (bytes, bytearray)):
|
|
199
|
+
value = bytes(kid).hex()
|
|
200
|
+
elif isinstance(kid, str):
|
|
201
|
+
value = kid.lower()
|
|
202
|
+
else:
|
|
203
|
+
raise ValueError("recipient caveat value must be bytes or hex string")
|
|
204
|
+
return cls(kind="to", value=value)
|
|
205
|
+
|
|
206
|
+
def to_cbor(self) -> dict[str, Any]:
|
|
207
|
+
return {"kind": self.kind, "value": self.value}
|
|
208
|
+
|
|
209
|
+
@classmethod
|
|
210
|
+
def from_cbor(cls, m: dict[str, Any]) -> Caveat:
|
|
211
|
+
return cls(kind=m["kind"], value=m["value"])
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
@dataclass
|
|
215
|
+
class CapToken:
|
|
216
|
+
"""Capability token with Biscuit-shape caveat-chain attenuation (spec §4.12).
|
|
217
|
+
|
|
218
|
+
A CapToken is minted by an issuer, optionally attenuated (narrowed) by
|
|
219
|
+
delegates, and presented at the point of use. The caveat list and signing
|
|
220
|
+
algorithm are bound inside the COSE signature (A-ALG-BOUND), making
|
|
221
|
+
JWT-``alg=none``-class substitution impossible. Only the current
|
|
222
|
+
``subject`` may attenuate — any other signer is rejected.
|
|
223
|
+
|
|
224
|
+
Examples:
|
|
225
|
+
>>> from hypermind.capability import CapToken, Caveat
|
|
226
|
+
>>> token = CapToken.mint(
|
|
227
|
+
... issuer_keypair=alice.keypair,
|
|
228
|
+
... subject=bob.keypair.kid,
|
|
229
|
+
... caveats=[Caveat.topic("ai-safety/*"), Caveat.rate("10/min")],
|
|
230
|
+
... )
|
|
231
|
+
>>> delegated = token.attenuate(
|
|
232
|
+
... new_subject=carol.keypair.kid,
|
|
233
|
+
... extra_caveats=[Caveat.expiry("2030-01-01T00:00Z")],
|
|
234
|
+
... signing_keypair=bob.keypair,
|
|
235
|
+
... )
|
|
236
|
+
>>> delegated.verify(action="publish_claim", topic="ai-safety/eval")
|
|
237
|
+
"""
|
|
238
|
+
|
|
239
|
+
issuer: bytes
|
|
240
|
+
subject: bytes
|
|
241
|
+
caveats: list[Caveat]
|
|
242
|
+
sig: bytes = b""
|
|
243
|
+
issued_at_ms: int = field(default_factory=lambda: int(time.time() * 1000))
|
|
244
|
+
root_issuer: bytes = b""
|
|
245
|
+
parent_sig: bytes = b"" # hash of parent's _canonical_body()+sig; b"" for root
|
|
246
|
+
|
|
247
|
+
# Private mint-namespace marker for v0/v1 grandfathering (spec §25.3).
|
|
248
|
+
# Set at mint() time; never serialised. Verifiers receiving a v1 token
|
|
249
|
+
# in a different namespace MUST reject it (BlockReason CASCADE-L5-CROSS-NS-V1).
|
|
250
|
+
# Prefixed with `_` so dataclass equality / serialisation are unaffected.
|
|
251
|
+
_mint_namespace: bytes | None = field(default=None, repr=False, compare=False)
|
|
252
|
+
|
|
253
|
+
def __post_init__(self) -> None:
|
|
254
|
+
if not self.root_issuer:
|
|
255
|
+
# A root token's own issuer IS the root.
|
|
256
|
+
self.root_issuer = self.issuer
|
|
257
|
+
|
|
258
|
+
@classmethod
|
|
259
|
+
def mint(
|
|
260
|
+
cls,
|
|
261
|
+
*,
|
|
262
|
+
issuer_keypair: Keypair,
|
|
263
|
+
subject: bytes,
|
|
264
|
+
caveats: list[Caveat] | None = None,
|
|
265
|
+
wire_version: int = 1,
|
|
266
|
+
namespace_id: bytes | None = None,
|
|
267
|
+
) -> CapToken:
|
|
268
|
+
"""Mint a new ROOT capability token. Use `attenuate()` to delegate.
|
|
269
|
+
|
|
270
|
+
Spec §25.2: at ``wire_version >= 2``, every CapToken root MUST carry
|
|
271
|
+
an explicit ``ns:<namespace_id_hex>`` caveat. ``mint`` will raise
|
|
272
|
+
``ValueError`` if neither the caveat nor a ``namespace_id`` argument
|
|
273
|
+
is supplied (the argument is auto-converted to an ``ns`` caveat
|
|
274
|
+
when present).
|
|
275
|
+
|
|
276
|
+
For ``wire_version <= 1`` (legacy / grandfathered), the optional
|
|
277
|
+
``namespace_id`` is recorded as the token's mint namespace
|
|
278
|
+
(``_mint_namespace``) so verifiers can enforce §25.3
|
|
279
|
+
cross-namespace rejection without requiring the caveat itself.
|
|
280
|
+
"""
|
|
281
|
+
cavs = list(caveats or [])
|
|
282
|
+
existing_ns = next((c for c in cavs if c.kind == "ns"), None)
|
|
283
|
+
|
|
284
|
+
if wire_version >= 2:
|
|
285
|
+
if existing_ns is None:
|
|
286
|
+
if namespace_id is None:
|
|
287
|
+
raise ValueError(
|
|
288
|
+
"wire_version>=2 CapTokens MUST carry an `ns` caveat "
|
|
289
|
+
"(spec §25.2). Pass namespace_id=... or include "
|
|
290
|
+
"Caveat.namespace(...) in caveats."
|
|
291
|
+
)
|
|
292
|
+
cavs.insert(0, Caveat.namespace(namespace_id.hex()))
|
|
293
|
+
existing_ns = cavs[0]
|
|
294
|
+
elif namespace_id is not None and existing_ns.value != namespace_id.hex():
|
|
295
|
+
raise ValueError(
|
|
296
|
+
f"namespace_id={namespace_id.hex()!r} disagrees with "
|
|
297
|
+
f"explicit ns caveat {existing_ns.value!r}"
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
token = cls(
|
|
301
|
+
issuer=issuer_keypair.kid,
|
|
302
|
+
subject=subject,
|
|
303
|
+
caveats=cavs,
|
|
304
|
+
root_issuer=issuer_keypair.kid,
|
|
305
|
+
parent_sig=b"",
|
|
306
|
+
)
|
|
307
|
+
# Record mint namespace for grandfathering (§25.3).
|
|
308
|
+
if existing_ns is not None:
|
|
309
|
+
try:
|
|
310
|
+
token._mint_namespace = bytes.fromhex(str(existing_ns.value))
|
|
311
|
+
except ValueError:
|
|
312
|
+
token._mint_namespace = None
|
|
313
|
+
elif namespace_id is not None:
|
|
314
|
+
token._mint_namespace = bytes(namespace_id)
|
|
315
|
+
token.sig = issuer_keypair.sign(token._canonical_body())
|
|
316
|
+
return token
|
|
317
|
+
|
|
318
|
+
def attenuate(self, *new_caveats: Caveat, signing_keypair: Keypair) -> CapToken:
|
|
319
|
+
"""Re-sign with additional caveats binding back to this parent.
|
|
320
|
+
|
|
321
|
+
The signing principal MUST be the current `subject` (the holder
|
|
322
|
+
delegating onward). Every new caveat — whether the same kind or
|
|
323
|
+
a brand-new kind — must be a strict narrowing of the existing
|
|
324
|
+
chain; new caveats CANNOT widen scope by omitting an existing
|
|
325
|
+
kind, and a new kind not present in the parent is unrestricted
|
|
326
|
+
UNLESS combined with an explicit narrowing of the same kind.
|
|
327
|
+
|
|
328
|
+
The new token carries `parent_sig` = hash(parent body || parent
|
|
329
|
+
sig) so chain-walk verification can confirm the parent was
|
|
330
|
+
intact at delegation time.
|
|
331
|
+
"""
|
|
332
|
+
if signing_keypair.kid != self.subject:
|
|
333
|
+
raise AuthError(
|
|
334
|
+
"only the current subject may attenuate a capability",
|
|
335
|
+
failed_caveat="subject",
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
for c in new_caveats:
|
|
339
|
+
self._assert_narrows(c)
|
|
340
|
+
|
|
341
|
+
merged_caveats = list(self.caveats) + list(new_caveats)
|
|
342
|
+
parent_fp = _fingerprint(self._canonical_body() + self.sig)
|
|
343
|
+
token = CapToken(
|
|
344
|
+
issuer=signing_keypair.kid,
|
|
345
|
+
subject=self.subject,
|
|
346
|
+
caveats=merged_caveats,
|
|
347
|
+
root_issuer=self.root_issuer,
|
|
348
|
+
parent_sig=parent_fp,
|
|
349
|
+
)
|
|
350
|
+
# Inherit mint-namespace marker from parent (grandfathering §25.3).
|
|
351
|
+
token._mint_namespace = self._mint_namespace
|
|
352
|
+
token.sig = signing_keypair.sign(token._canonical_body())
|
|
353
|
+
return token
|
|
354
|
+
|
|
355
|
+
def _assert_narrows(self, new: Caveat) -> None:
|
|
356
|
+
"""A caveat must be at least as restrictive as every same-kind
|
|
357
|
+
caveat already in the chain. A new caveat of a previously-unseen
|
|
358
|
+
kind is allowed (it adds a fresh restriction, never widens)."""
|
|
359
|
+
for existing in self.caveats:
|
|
360
|
+
if existing.kind != new.kind:
|
|
361
|
+
continue
|
|
362
|
+
if existing.kind == "rate":
|
|
363
|
+
if _rate_per_min(new.value) > _rate_per_min(existing.value):
|
|
364
|
+
raise AuthError(
|
|
365
|
+
f"rate caveat must narrow: {new.value} > {existing.value}",
|
|
366
|
+
failed_caveat="rate",
|
|
367
|
+
)
|
|
368
|
+
elif existing.kind == "expiry":
|
|
369
|
+
if _parse_iso_to_ms(str(new.value)) > _parse_iso_to_ms(str(existing.value)):
|
|
370
|
+
raise AuthError(
|
|
371
|
+
f"expiry caveat must narrow: {new.value} > {existing.value}",
|
|
372
|
+
failed_caveat="expiry",
|
|
373
|
+
)
|
|
374
|
+
elif existing.kind in ("topic", "claim_type", "tool"):
|
|
375
|
+
if not _is_topic_subset(new.value, existing.value):
|
|
376
|
+
raise AuthError(
|
|
377
|
+
f"{existing.kind} caveat must narrow",
|
|
378
|
+
failed_caveat=existing.kind,
|
|
379
|
+
)
|
|
380
|
+
elif existing.kind == "parent_kid":
|
|
381
|
+
if str(new.value) != str(existing.value):
|
|
382
|
+
raise AuthError(
|
|
383
|
+
"parent_kid caveat is exact-match; cannot change",
|
|
384
|
+
failed_caveat="parent_kid",
|
|
385
|
+
)
|
|
386
|
+
elif existing.kind in ("ns", "room", "to"):
|
|
387
|
+
if str(new.value) != str(existing.value):
|
|
388
|
+
raise AuthError(
|
|
389
|
+
f"{existing.kind} caveat is exact-match; cannot change",
|
|
390
|
+
failed_caveat=existing.kind,
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
def _ns_caveat_value(self) -> str | None:
|
|
394
|
+
"""Return the `ns:` caveat value (lowercase hex) or None."""
|
|
395
|
+
for c in self.caveats:
|
|
396
|
+
if c.kind == "ns":
|
|
397
|
+
return str(c.value).lower()
|
|
398
|
+
return None
|
|
399
|
+
|
|
400
|
+
def _check_namespace_gate(self, wire_version: int, current_namespace_id: bytes | None) -> bool:
|
|
401
|
+
"""Spec §25.2 / §25.3 — namespace gating for verify_chain.
|
|
402
|
+
|
|
403
|
+
- wire_version >= 2: token MUST carry an `ns` caveat. If
|
|
404
|
+
``current_namespace_id`` is supplied, the caveat value MUST
|
|
405
|
+
match it (lowercase hex). Mismatch ⇒ False.
|
|
406
|
+
- wire_version <= 1: legacy / grandfathered. If
|
|
407
|
+
``current_namespace_id`` is supplied AND the token has a
|
|
408
|
+
recorded mint namespace (or explicit `ns` caveat), they must
|
|
409
|
+
match — otherwise reject (spec §25.3, BlockReason
|
|
410
|
+
``CASCADE-L5-CROSS-NS-V1``).
|
|
411
|
+
"""
|
|
412
|
+
ns_caveat = self._ns_caveat_value()
|
|
413
|
+
if wire_version >= 2:
|
|
414
|
+
if ns_caveat is None:
|
|
415
|
+
return False
|
|
416
|
+
if current_namespace_id is not None:
|
|
417
|
+
if ns_caveat != current_namespace_id.hex().lower():
|
|
418
|
+
return False
|
|
419
|
+
return True
|
|
420
|
+
# wire_version <= 1
|
|
421
|
+
if current_namespace_id is None:
|
|
422
|
+
return True
|
|
423
|
+
if ns_caveat is not None:
|
|
424
|
+
return ns_caveat == current_namespace_id.hex().lower()
|
|
425
|
+
if self._mint_namespace is not None:
|
|
426
|
+
return bytes(self._mint_namespace) == bytes(current_namespace_id)
|
|
427
|
+
# No mint-namespace recorded and no caveat: cannot prove origin.
|
|
428
|
+
# Caller has pinned a namespace, so refuse conservatively.
|
|
429
|
+
return False
|
|
430
|
+
|
|
431
|
+
def _canonical_body(self) -> bytes:
|
|
432
|
+
"""The canonical signed preimage. Includes root_issuer +
|
|
433
|
+
parent_sig so substitution attacks cannot forge a fake chain."""
|
|
434
|
+
return cbor2.dumps(
|
|
435
|
+
{
|
|
436
|
+
"issuer": self.issuer,
|
|
437
|
+
"subject": self.subject,
|
|
438
|
+
"caveats": [c.to_cbor() for c in self.caveats],
|
|
439
|
+
"issued_at_ms": self.issued_at_ms,
|
|
440
|
+
"root_issuer": self.root_issuer,
|
|
441
|
+
"parent_sig": self.parent_sig,
|
|
442
|
+
},
|
|
443
|
+
canonical=True,
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
def to_bytes(self) -> bytes:
|
|
447
|
+
"""Serialize as deterministic CBOR (RFC 8949 §4.2)."""
|
|
448
|
+
return cbor2.dumps(
|
|
449
|
+
{
|
|
450
|
+
"issuer": self.issuer,
|
|
451
|
+
"subject": self.subject,
|
|
452
|
+
"caveats": [c.to_cbor() for c in self.caveats],
|
|
453
|
+
"issued_at_ms": self.issued_at_ms,
|
|
454
|
+
"root_issuer": self.root_issuer,
|
|
455
|
+
"parent_sig": self.parent_sig,
|
|
456
|
+
"sig": self.sig,
|
|
457
|
+
},
|
|
458
|
+
canonical=True,
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
@classmethod
|
|
462
|
+
def from_bytes(cls, raw: bytes) -> CapToken:
|
|
463
|
+
"""Parse a CapToken from canonical CBOR.
|
|
464
|
+
|
|
465
|
+
Enforces the same A-ALG-BOUND-style canonical-encoding gate as
|
|
466
|
+
`SignedStatement.from_bytes()`: parse, re-encode under
|
|
467
|
+
deterministic CBOR, and require byte-equality with the wire
|
|
468
|
+
bytes. Rejects non-canonical inputs (reordered map keys,
|
|
469
|
+
indefinite-length strings, alternative integer encodings) at
|
|
470
|
+
parse time so a single logical token cannot have two distinct
|
|
471
|
+
wire encodings (and thus two distinct fingerprints).
|
|
472
|
+
"""
|
|
473
|
+
try:
|
|
474
|
+
m = cbor2.loads(raw)
|
|
475
|
+
except cbor2.CBORDecodeError as e:
|
|
476
|
+
raise ConformanceError(f"capability token is not valid CBOR: {e}") from e
|
|
477
|
+
if not isinstance(m, dict):
|
|
478
|
+
raise ConformanceError("capability token must be a CBOR map")
|
|
479
|
+
if cbor2.dumps(m, canonical=True) != raw:
|
|
480
|
+
raise ConformanceError("capability token is not canonical CBOR (RFC 8949 §4.2)")
|
|
481
|
+
try:
|
|
482
|
+
token = cls(
|
|
483
|
+
issuer=bytes(m["issuer"]),
|
|
484
|
+
subject=bytes(m["subject"]),
|
|
485
|
+
caveats=[Caveat.from_cbor(c) for c in m["caveats"]],
|
|
486
|
+
sig=bytes(m["sig"]),
|
|
487
|
+
issued_at_ms=int(m["issued_at_ms"]),
|
|
488
|
+
root_issuer=bytes(m["root_issuer"]),
|
|
489
|
+
parent_sig=bytes(m["parent_sig"]),
|
|
490
|
+
)
|
|
491
|
+
except (KeyError, TypeError) as e:
|
|
492
|
+
raise ConformanceError(f"capability token missing/invalid field: {e}") from e
|
|
493
|
+
return token
|
|
494
|
+
|
|
495
|
+
def verify(self, *, expected_root_issuer: bytes | None = None) -> bool:
|
|
496
|
+
"""Verify the issuer's signature.
|
|
497
|
+
|
|
498
|
+
If `expected_root_issuer` is supplied, also verify that
|
|
499
|
+
`self.root_issuer == expected_root_issuer`. Without that pin,
|
|
500
|
+
verification only proves self-consistency, NOT that the chain
|
|
501
|
+
traces back to a trusted authority — callers MUST supply the
|
|
502
|
+
expected root in any security-relevant context.
|
|
503
|
+
"""
|
|
504
|
+
if expected_root_issuer is not None and self.root_issuer != expected_root_issuer:
|
|
505
|
+
return False
|
|
506
|
+
return verify_ed25519(self.issuer, self.sig, self._canonical_body())
|
|
507
|
+
|
|
508
|
+
def verify_chain(
|
|
509
|
+
self,
|
|
510
|
+
parent: CapToken | None,
|
|
511
|
+
*,
|
|
512
|
+
revocation_list: RevocationList | None = None,
|
|
513
|
+
wire_version: int = 1,
|
|
514
|
+
current_namespace_id: bytes | None = None,
|
|
515
|
+
) -> bool:
|
|
516
|
+
"""Verify the leaf's own signature AND the chain link to `parent`.
|
|
517
|
+
|
|
518
|
+
Closes Cycle-2 Finding #3: earlier versions skipped the leaf's
|
|
519
|
+
own signature, allowing an unsigned token to pass `verify_chain`
|
|
520
|
+
if its `parent_sig` field happened to match. Now the leaf's
|
|
521
|
+
signature is checked first; only then is the parent link verified.
|
|
522
|
+
|
|
523
|
+
- Root token (`parent=None`): requires `parent_sig == b""` and
|
|
524
|
+
`issuer == root_issuer`.
|
|
525
|
+
- Delegated token: requires the parent's signature is valid,
|
|
526
|
+
`parent.subject == self.issuer`, root_issuer matches,
|
|
527
|
+
`self.parent_sig` == hash(parent body || parent sig), and
|
|
528
|
+
every same-kind caveat in self is a strict narrowing of the
|
|
529
|
+
parent's same-kind caveat (defence-in-depth: even if a
|
|
530
|
+
forged token bypassed `attenuate()`'s narrowing checks, a
|
|
531
|
+
chain walk catches scope widening).
|
|
532
|
+
|
|
533
|
+
P2-06: if ``revocation_list`` is supplied, any token whose issuer
|
|
534
|
+
kid appears in the list (regardless of HLC — epoch check) raises
|
|
535
|
+
:class:`AuthError` with ``rule_id="HM-REV-001"``. The check is
|
|
536
|
+
applied to both ``self`` and ``parent`` so a revoked delegator
|
|
537
|
+
cannot ride a still-valid leaf.
|
|
538
|
+
"""
|
|
539
|
+
if not self.verify():
|
|
540
|
+
return False
|
|
541
|
+
# Spec §25.2 / §25.3: namespace gating.
|
|
542
|
+
if not self._check_namespace_gate(wire_version, current_namespace_id):
|
|
543
|
+
return False
|
|
544
|
+
if parent is None:
|
|
545
|
+
if revocation_list is not None:
|
|
546
|
+
_check_revocation_list(self.issuer, revocation_list)
|
|
547
|
+
return hmac.compare_digest(self.parent_sig, b"") and hmac.compare_digest(
|
|
548
|
+
self.issuer, self.root_issuer
|
|
549
|
+
)
|
|
550
|
+
if not parent.verify():
|
|
551
|
+
return False
|
|
552
|
+
if revocation_list is not None:
|
|
553
|
+
_check_revocation_list(self.issuer, revocation_list)
|
|
554
|
+
_check_revocation_list(parent.issuer, revocation_list)
|
|
555
|
+
if not hmac.compare_digest(parent.subject, self.issuer):
|
|
556
|
+
return False
|
|
557
|
+
if not hmac.compare_digest(parent.root_issuer, self.root_issuer):
|
|
558
|
+
return False
|
|
559
|
+
if not hmac.compare_digest(
|
|
560
|
+
self.parent_sig, _fingerprint(parent._canonical_body() + parent.sig)
|
|
561
|
+
):
|
|
562
|
+
return False
|
|
563
|
+
return _caveats_narrow(parent.caveats, self.caveats)
|
|
564
|
+
|
|
565
|
+
def verify_chain_full(
|
|
566
|
+
self,
|
|
567
|
+
chain: list[CapToken] | None = None,
|
|
568
|
+
*,
|
|
569
|
+
expected_root_issuer: bytes | None = None,
|
|
570
|
+
revocation_store: RevocationStore | None = None,
|
|
571
|
+
hlc: tuple[int, int] | None = None,
|
|
572
|
+
wire_version: int = 1,
|
|
573
|
+
current_namespace_id: bytes | None = None,
|
|
574
|
+
) -> bool:
|
|
575
|
+
"""Walk the full chain root → … → self.
|
|
576
|
+
|
|
577
|
+
`chain` is the ordered list of ancestor tokens, root first, NOT
|
|
578
|
+
including `self`. For a root token, pass `[]` or `None`. Returns
|
|
579
|
+
True only if every link verifies (signature, parent_sig binding,
|
|
580
|
+
root_issuer consistency, and monotonic narrowing of caveats).
|
|
581
|
+
|
|
582
|
+
If `expected_root_issuer` is supplied, also checks that the
|
|
583
|
+
root's issuer kid matches — pinning the chain to a trusted root.
|
|
584
|
+
|
|
585
|
+
If `revocation_store` is supplied, every token's `issuer` kid is
|
|
586
|
+
checked against it at the supplied `hlc` (defaults to (0, 0) =
|
|
587
|
+
"now"; a more accurate value pins historical-claim immutability
|
|
588
|
+
— statements signed strictly before the revocation point remain
|
|
589
|
+
valid). Any revoked kid raises :class:`AuthError` with
|
|
590
|
+
``failed_caveat='revoked'`` (rule_id ``HM-KEY-REVOKED-001``).
|
|
591
|
+
"""
|
|
592
|
+
ancestors = list(chain or [])
|
|
593
|
+
|
|
594
|
+
def _check_revoked(token: CapToken) -> None:
|
|
595
|
+
if revocation_store is None:
|
|
596
|
+
return
|
|
597
|
+
entry = revocation_store.is_revoked(token.issuer, hlc or (0, 0))
|
|
598
|
+
if entry is not None:
|
|
599
|
+
raise AuthError(
|
|
600
|
+
f"capability token issuer kid {token.issuer.hex()[:8]}… "
|
|
601
|
+
f"has been revoked (reason={entry.reason}, "
|
|
602
|
+
f"rule_id=HM-KEY-REVOKED-001)",
|
|
603
|
+
failed_caveat="revoked",
|
|
604
|
+
)
|
|
605
|
+
|
|
606
|
+
if not ancestors:
|
|
607
|
+
if not self.verify_chain(
|
|
608
|
+
None,
|
|
609
|
+
wire_version=wire_version,
|
|
610
|
+
current_namespace_id=current_namespace_id,
|
|
611
|
+
):
|
|
612
|
+
return False
|
|
613
|
+
if expected_root_issuer is not None and not hmac.compare_digest(
|
|
614
|
+
self.root_issuer, expected_root_issuer
|
|
615
|
+
):
|
|
616
|
+
return False
|
|
617
|
+
_check_revoked(self)
|
|
618
|
+
return True
|
|
619
|
+
|
|
620
|
+
root = ancestors[0]
|
|
621
|
+
if not root.verify_chain(
|
|
622
|
+
None,
|
|
623
|
+
wire_version=wire_version,
|
|
624
|
+
current_namespace_id=current_namespace_id,
|
|
625
|
+
):
|
|
626
|
+
return False
|
|
627
|
+
if expected_root_issuer is not None and not hmac.compare_digest(
|
|
628
|
+
root.root_issuer, expected_root_issuer
|
|
629
|
+
):
|
|
630
|
+
return False
|
|
631
|
+
_check_revoked(root)
|
|
632
|
+
|
|
633
|
+
prev = root
|
|
634
|
+
for token in ancestors[1:]:
|
|
635
|
+
if not token.verify_chain(
|
|
636
|
+
prev,
|
|
637
|
+
wire_version=wire_version,
|
|
638
|
+
current_namespace_id=current_namespace_id,
|
|
639
|
+
):
|
|
640
|
+
return False
|
|
641
|
+
_check_revoked(token)
|
|
642
|
+
prev = token
|
|
643
|
+
if not self.verify_chain(
|
|
644
|
+
prev,
|
|
645
|
+
wire_version=wire_version,
|
|
646
|
+
current_namespace_id=current_namespace_id,
|
|
647
|
+
):
|
|
648
|
+
return False
|
|
649
|
+
_check_revoked(self)
|
|
650
|
+
return True
|
|
651
|
+
|
|
652
|
+
def authorize(
|
|
653
|
+
self,
|
|
654
|
+
*,
|
|
655
|
+
action: str,
|
|
656
|
+
topic: str | None = None,
|
|
657
|
+
claim_type: str | None = None,
|
|
658
|
+
tool: str | None = None,
|
|
659
|
+
parent_kid: bytes | None = None,
|
|
660
|
+
expected_root_issuer: bytes | None = None,
|
|
661
|
+
revocation_store: RevocationStore | None = None,
|
|
662
|
+
hlc: tuple[int, int] | None = None,
|
|
663
|
+
require_chain: bool = True,
|
|
664
|
+
room: str | None = None,
|
|
665
|
+
recipient: bytes | str | None = None,
|
|
666
|
+
namespace_id: bytes | None = None,
|
|
667
|
+
wire_version: int = 1,
|
|
668
|
+
rate_store: Any | None = None,
|
|
669
|
+
) -> None:
|
|
670
|
+
"""Check that this token authorizes the requested action.
|
|
671
|
+
|
|
672
|
+
Raises AuthError(failed_caveat=...) on first failing caveat.
|
|
673
|
+
Caveats are evaluated in order; the first failure is reported,
|
|
674
|
+
so operators can pinpoint which restriction fired without
|
|
675
|
+
guessing the chain semantics.
|
|
676
|
+
|
|
677
|
+
For chain-of-trust enforcement, supply `expected_root_issuer`
|
|
678
|
+
— the kid of the principal you trust at the root of the chain.
|
|
679
|
+
|
|
680
|
+
Root tokens (parent_sig == b"") are verified via verify_chain(None)
|
|
681
|
+
so the issuer == root_issuer invariant is always checked.
|
|
682
|
+
|
|
683
|
+
Delegated tokens (parent_sig != b""):
|
|
684
|
+
- When ``require_chain=True`` (the default): raises AuthError if the
|
|
685
|
+
caller has not first called ``verify_chain_full()`` on the ancestor
|
|
686
|
+
chain. Pass ``require_chain=True`` only after that check passes.
|
|
687
|
+
This enforces that the full chain (root → … → self) has been verified.
|
|
688
|
+
- When ``require_chain=False`` (opt-out, legacy callers only): falls
|
|
689
|
+
back to checking the leaf signature only. Does NOT verify the parent
|
|
690
|
+
chain — a forged delegated token with a valid leaf sig will pass.
|
|
691
|
+
Callers using this path should migrate to ``verify_chain_full()``.
|
|
692
|
+
"""
|
|
693
|
+
if topic is not None and _normalize_topic(topic) is None:
|
|
694
|
+
raise AuthError("topic contains path-traversal components", failed_caveat="topic")
|
|
695
|
+
|
|
696
|
+
if self.parent_sig == b"":
|
|
697
|
+
if not self.verify_chain(
|
|
698
|
+
None,
|
|
699
|
+
wire_version=wire_version,
|
|
700
|
+
current_namespace_id=namespace_id,
|
|
701
|
+
):
|
|
702
|
+
raise AuthError("capability chain invalid", failed_caveat="signature")
|
|
703
|
+
if expected_root_issuer is not None and self.root_issuer != expected_root_issuer:
|
|
704
|
+
raise AuthError("root_issuer mismatch", failed_caveat="signature")
|
|
705
|
+
else:
|
|
706
|
+
if require_chain:
|
|
707
|
+
raise AuthError(
|
|
708
|
+
"delegated CapToken passed to authorize() without prior chain verification; "
|
|
709
|
+
"call verify_chain_full() on the full ancestor chain first, then pass "
|
|
710
|
+
"require_chain=True only after that check passes",
|
|
711
|
+
failed_caveat="signature",
|
|
712
|
+
)
|
|
713
|
+
# DEPRECATED: leaf-only verification for delegated tokens.
|
|
714
|
+
# Does not verify the parent chain — use verify_chain_full() instead.
|
|
715
|
+
if not self.verify(expected_root_issuer=expected_root_issuer):
|
|
716
|
+
raise AuthError("capability signature invalid", failed_caveat="signature")
|
|
717
|
+
|
|
718
|
+
# Revocation check (HM-KEY-REVOKED-001). The leaf token's issuer
|
|
719
|
+
# is the immediate signer; if its kid is revoked at `hlc`, refuse
|
|
720
|
+
# the action. Callers wanting full-chain revocation should call
|
|
721
|
+
# ``verify_chain_full(..., revocation_store=, hlc=)`` explicitly.
|
|
722
|
+
if revocation_store is not None:
|
|
723
|
+
entry = revocation_store.is_revoked(self.issuer, hlc or (0, 0))
|
|
724
|
+
if entry is not None:
|
|
725
|
+
raise AuthError(
|
|
726
|
+
f"capability token issuer kid {self.issuer.hex()[:8]}… "
|
|
727
|
+
f"has been revoked (reason={entry.reason}, "
|
|
728
|
+
f"rule_id=HM-KEY-REVOKED-001)",
|
|
729
|
+
failed_caveat="revoked",
|
|
730
|
+
)
|
|
731
|
+
# Also check the root, so a revoked-at-root delegate cannot
|
|
732
|
+
# ride on a still-active leaf signature.
|
|
733
|
+
if self.root_issuer != self.issuer:
|
|
734
|
+
root_entry = revocation_store.is_revoked(self.root_issuer, hlc or (0, 0))
|
|
735
|
+
if root_entry is not None:
|
|
736
|
+
raise AuthError(
|
|
737
|
+
f"capability root_issuer kid {self.root_issuer.hex()[:8]}… "
|
|
738
|
+
f"has been revoked (reason={root_entry.reason}, "
|
|
739
|
+
f"rule_id=HM-KEY-REVOKED-001)",
|
|
740
|
+
failed_caveat="revoked",
|
|
741
|
+
)
|
|
742
|
+
|
|
743
|
+
now_ms = int(time.time() * 1000)
|
|
744
|
+
for cav in self.caveats:
|
|
745
|
+
if cav.kind == "expiry":
|
|
746
|
+
expiry_ms = _parse_iso_to_ms(str(cav.value))
|
|
747
|
+
if now_ms > expiry_ms:
|
|
748
|
+
raise AuthError(f"capability expired at {cav.value}", failed_caveat="expiry")
|
|
749
|
+
elif cav.kind == "topic" and topic is not None:
|
|
750
|
+
if not _is_topic_subset(topic, str(cav.value)):
|
|
751
|
+
raise AuthError(
|
|
752
|
+
f"topic {topic!r} not in caveat {cav.value!r}",
|
|
753
|
+
failed_caveat="topic",
|
|
754
|
+
)
|
|
755
|
+
elif cav.kind == "claim_type" and claim_type is not None:
|
|
756
|
+
if not _is_topic_subset(claim_type, str(cav.value)):
|
|
757
|
+
raise AuthError(
|
|
758
|
+
f"claim_type {claim_type!r} not in caveat {cav.value!r}",
|
|
759
|
+
failed_caveat="claim_type",
|
|
760
|
+
)
|
|
761
|
+
elif cav.kind == "tool" and tool is not None:
|
|
762
|
+
if not _is_topic_subset(tool, str(cav.value)):
|
|
763
|
+
raise AuthError(
|
|
764
|
+
f"tool {tool!r} not in caveat {cav.value!r}",
|
|
765
|
+
failed_caveat="tool",
|
|
766
|
+
)
|
|
767
|
+
elif cav.kind == "parent_kid" and parent_kid is not None:
|
|
768
|
+
expected = bytes.fromhex(str(cav.value))
|
|
769
|
+
if parent_kid != expected:
|
|
770
|
+
raise AuthError(
|
|
771
|
+
"parent_kid does not match caveat",
|
|
772
|
+
failed_caveat="parent_kid",
|
|
773
|
+
)
|
|
774
|
+
elif cav.kind == "rate":
|
|
775
|
+
_rate_per_min(cav.value) # validate format
|
|
776
|
+
if rate_store is not None:
|
|
777
|
+
# Delegate enforcement to the caller-supplied store.
|
|
778
|
+
limit = _rate_per_min(cav.value)
|
|
779
|
+
token_key = self.sig[:16].hex() + ":" + action
|
|
780
|
+
if hasattr(rate_store, "check") and not rate_store.check(token_key, limit):
|
|
781
|
+
raise AuthError(
|
|
782
|
+
f"rate limit {cav.value} exceeded for action {action!r}",
|
|
783
|
+
failed_caveat="rate",
|
|
784
|
+
)
|
|
785
|
+
# When rate_store is None, format validation passes but enforcement
|
|
786
|
+
# is the caller's responsibility (e.g. HyperMindAgent uses TokenBucket
|
|
787
|
+
# after authorize()). This is intentional: authorize() is stateless.
|
|
788
|
+
elif cav.kind == "ns" and namespace_id is not None:
|
|
789
|
+
if str(cav.value).lower() != namespace_id.hex().lower():
|
|
790
|
+
raise AuthError(
|
|
791
|
+
f"namespace {namespace_id.hex()!r} not in caveat {cav.value!r}",
|
|
792
|
+
failed_caveat="ns",
|
|
793
|
+
)
|
|
794
|
+
elif cav.kind == "room" and room is not None:
|
|
795
|
+
if str(cav.value).lower() != str(room).lower():
|
|
796
|
+
raise AuthError(
|
|
797
|
+
f"room {room!r} not in caveat {cav.value!r}",
|
|
798
|
+
failed_caveat="room",
|
|
799
|
+
)
|
|
800
|
+
elif cav.kind == "to" and recipient is not None:
|
|
801
|
+
expected_to = (
|
|
802
|
+
bytes(recipient).hex()
|
|
803
|
+
if isinstance(recipient, (bytes, bytearray))
|
|
804
|
+
else str(recipient).lower()
|
|
805
|
+
)
|
|
806
|
+
if str(cav.value).lower() != expected_to:
|
|
807
|
+
raise AuthError(
|
|
808
|
+
f"recipient {expected_to!r} not in caveat {cav.value!r}",
|
|
809
|
+
failed_caveat="to",
|
|
810
|
+
)
|
|
811
|
+
|
|
812
|
+
_ = action # rate-limit hookpoint; runtime tracks usage
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
def _check_revocation_list(kid: bytes, revocation_list: RevocationList) -> None:
|
|
816
|
+
"""P2-06: Raise AuthError if ``kid`` appears in the revocation list.
|
|
817
|
+
|
|
818
|
+
This is an epoch check: the presence of the kid in the list at all
|
|
819
|
+
is sufficient for rejection regardless of HLC (conservative policy —
|
|
820
|
+
once revoked, always revoked for capability chain verification).
|
|
821
|
+
Rule id: ``HM-REV-001``.
|
|
822
|
+
"""
|
|
823
|
+
for entry in revocation_list.entries:
|
|
824
|
+
if bytes(entry.kid) == bytes(kid):
|
|
825
|
+
raise AuthError(
|
|
826
|
+
f"capability token issuer kid {kid.hex()[:8]}… "
|
|
827
|
+
f"has been revoked (reason={entry.reason}, rule_id=HM-REV-001)",
|
|
828
|
+
failed_caveat="revoked",
|
|
829
|
+
)
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
def _fingerprint(b: bytes) -> bytes:
|
|
833
|
+
"""SHA-256 over canonical bytes — used to bind parent → child."""
|
|
834
|
+
return hashlib.sha256(b).digest()
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
def _caveats_narrow(parent_caveats: list[Caveat], child_caveats: list[Caveat]) -> bool:
|
|
838
|
+
"""Child's caveat list must extend parent's prefix and only narrow.
|
|
839
|
+
|
|
840
|
+
`attenuate()` produces `child.caveats = parent.caveats + new_caveats`,
|
|
841
|
+
so a well-formed child has the parent's caveats as an exact prefix.
|
|
842
|
+
Each appended caveat must be a strict narrowing of any same-kind
|
|
843
|
+
caveat earlier in the chain (or introduce a fresh restriction kind).
|
|
844
|
+
"""
|
|
845
|
+
if len(child_caveats) < len(parent_caveats):
|
|
846
|
+
return False
|
|
847
|
+
for i, p in enumerate(parent_caveats):
|
|
848
|
+
c = child_caveats[i]
|
|
849
|
+
if c.kind != p.kind or c.value != p.value:
|
|
850
|
+
return False
|
|
851
|
+
for added in child_caveats[len(parent_caveats) :]:
|
|
852
|
+
for existing in parent_caveats:
|
|
853
|
+
if existing.kind != added.kind:
|
|
854
|
+
continue
|
|
855
|
+
try:
|
|
856
|
+
if existing.kind == "rate":
|
|
857
|
+
if _rate_per_min(added.value) > _rate_per_min(existing.value):
|
|
858
|
+
return False
|
|
859
|
+
elif existing.kind == "expiry":
|
|
860
|
+
if _parse_iso_to_ms(str(added.value)) > _parse_iso_to_ms(str(existing.value)):
|
|
861
|
+
return False
|
|
862
|
+
elif existing.kind in ("topic", "claim_type", "tool"):
|
|
863
|
+
if not _is_topic_subset(str(added.value), str(existing.value)):
|
|
864
|
+
return False
|
|
865
|
+
elif existing.kind == "parent_kid":
|
|
866
|
+
if str(added.value) != str(existing.value):
|
|
867
|
+
return False
|
|
868
|
+
elif existing.kind in ("ns", "room", "to"):
|
|
869
|
+
if str(added.value) != str(existing.value):
|
|
870
|
+
return False
|
|
871
|
+
except (AuthError, ValueError):
|
|
872
|
+
return False
|
|
873
|
+
return True
|
|
874
|
+
|
|
875
|
+
|
|
876
|
+
def _rate_per_min(spec: Any) -> float:
|
|
877
|
+
"""Parse 'N/min' or 'N/sec' into per-minute float."""
|
|
878
|
+
s = str(spec)
|
|
879
|
+
n_str, _, unit = s.partition("/")
|
|
880
|
+
n = float(n_str)
|
|
881
|
+
unit = unit.strip()
|
|
882
|
+
if unit in ("min", "minute", ""):
|
|
883
|
+
return n
|
|
884
|
+
if unit in ("sec", "second"):
|
|
885
|
+
return n * 60
|
|
886
|
+
if unit in ("hr", "hour"):
|
|
887
|
+
return n / 60
|
|
888
|
+
raise AuthError(f"unparsable rate {spec!r}", failed_caveat="rate")
|
|
889
|
+
|
|
890
|
+
|
|
891
|
+
def _normalize_topic(s: str) -> str | None:
|
|
892
|
+
"""Return None if the topic contains path-traversal components."""
|
|
893
|
+
parts = s.split("/")
|
|
894
|
+
if any(p in (".", "..") for p in parts):
|
|
895
|
+
return None
|
|
896
|
+
return s
|
|
897
|
+
|
|
898
|
+
|
|
899
|
+
def _is_topic_subset(child: str, parent: str) -> bool:
|
|
900
|
+
"""Glob-style subset: parent='ai-safety/*' covers child='ai-safety/eval'."""
|
|
901
|
+
if _normalize_topic(child) is None:
|
|
902
|
+
return False
|
|
903
|
+
parent_check = parent.rstrip("*").rstrip("/") if parent.endswith("/*") else parent
|
|
904
|
+
if _normalize_topic(parent_check) is None:
|
|
905
|
+
return False
|
|
906
|
+
if parent == child:
|
|
907
|
+
return True
|
|
908
|
+
if parent.endswith("/*"):
|
|
909
|
+
return child.startswith(parent[:-1])
|
|
910
|
+
if parent == "*":
|
|
911
|
+
return True
|
|
912
|
+
return False
|
|
913
|
+
|
|
914
|
+
|
|
915
|
+
def _parse_iso_to_ms(s: str) -> int:
|
|
916
|
+
from datetime import datetime
|
|
917
|
+
|
|
918
|
+
if s.endswith("Z"):
|
|
919
|
+
s = s[:-1] + "+00:00"
|
|
920
|
+
dt = datetime.fromisoformat(s)
|
|
921
|
+
if dt.tzinfo is None:
|
|
922
|
+
dt = dt.replace(tzinfo=UTC)
|
|
923
|
+
return int(dt.timestamp() * 1000)
|