atrib 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- atrib/__init__.py +237 -0
- atrib/anchors.py +200 -0
- atrib/attribution.py +229 -0
- atrib/canon.py +68 -0
- atrib/chain.py +73 -0
- atrib/client.py +556 -0
- atrib/content_id.py +95 -0
- atrib/encoding.py +37 -0
- atrib/evidence.py +591 -0
- atrib/hashes.py +41 -0
- atrib/keys.py +69 -0
- atrib/mirror.py +171 -0
- atrib/py.typed +0 -0
- atrib/records.py +93 -0
- atrib/signing.py +187 -0
- atrib/submission.py +163 -0
- atrib/token.py +45 -0
- atrib/types.py +139 -0
- atrib/validation.py +103 -0
- atrib-0.1.0.dist-info/METADATA +570 -0
- atrib-0.1.0.dist-info/RECORD +23 -0
- atrib-0.1.0.dist-info/WHEEL +4 -0
- atrib-0.1.0.dist-info/licenses/LICENSE +216 -0
atrib/__init__.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""atrib — verifiable agent actions, Python client SDK.
|
|
3
|
+
|
|
4
|
+
The first non-TypeScript implementation of the atrib record layer
|
|
5
|
+
(spec §1) and SDK contract (spec §5). Byte-identical to the TypeScript
|
|
6
|
+
implementation: identical inputs produce identical JCS canonical forms,
|
|
7
|
+
signatures, record hashes, and propagation tokens, verified against the
|
|
8
|
+
shared conformance corpora under ``spec/conformance/``.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from .canon import (
|
|
12
|
+
canonical_cross_attestation_input,
|
|
13
|
+
canonical_record,
|
|
14
|
+
canonical_signing_input,
|
|
15
|
+
jcs,
|
|
16
|
+
)
|
|
17
|
+
from .anchors import (
|
|
18
|
+
ANCHOR_CLAIM_PREFIX,
|
|
19
|
+
ANCHOR_TYPES,
|
|
20
|
+
BUILT_IN_DEFAULT_ANCHOR_SET,
|
|
21
|
+
AnchorDescriptor,
|
|
22
|
+
AnchorPostureResolution,
|
|
23
|
+
AnchorSetConfig,
|
|
24
|
+
anchor_claim_artifact,
|
|
25
|
+
resolve_anchor_posture,
|
|
26
|
+
resolve_effective_anchors,
|
|
27
|
+
)
|
|
28
|
+
from .attribution import (
|
|
29
|
+
ATTRIBUTION_EXTENSION_KEY,
|
|
30
|
+
ATTRIBUTION_LOG_SUBMISSION_STATUSES,
|
|
31
|
+
AttributionReceiptBlock,
|
|
32
|
+
AttributionReceiptConsistency,
|
|
33
|
+
AttributionReceiptVerification,
|
|
34
|
+
check_attribution_receipt_consistency,
|
|
35
|
+
parse_attribution_receipt_block,
|
|
36
|
+
verify_attribution_receipt,
|
|
37
|
+
)
|
|
38
|
+
from .chain import chain_root, genesis_chain_root, resolve_chain_root
|
|
39
|
+
from .client import AnchorSpec, AtribClient, AttestRef, AttestResult, RecallOutcome
|
|
40
|
+
from .evidence import (
|
|
41
|
+
ATRIB_PROFILE_BASE,
|
|
42
|
+
ATRIB_PROFILE_REGISTRY,
|
|
43
|
+
EVIDENCE_CONSTRAINT_STATUSES,
|
|
44
|
+
EVIDENCE_REF_KINDS,
|
|
45
|
+
EVIDENCE_TIERS,
|
|
46
|
+
FROZEN_LEGACY_PROTOCOLS,
|
|
47
|
+
LEGACY_PROTOCOL_TO_PROFILE,
|
|
48
|
+
EnvelopeReproducibility,
|
|
49
|
+
EnvelopeValidation,
|
|
50
|
+
EvidenceConstraint,
|
|
51
|
+
EvidenceEnvelope,
|
|
52
|
+
EvidencePayload,
|
|
53
|
+
EvidencePayloadRef,
|
|
54
|
+
EvidenceResult,
|
|
55
|
+
EvidenceVerifier,
|
|
56
|
+
ProfileClassification,
|
|
57
|
+
assess_reproducibility,
|
|
58
|
+
build_evidence_envelope,
|
|
59
|
+
classify_profile,
|
|
60
|
+
evidence_envelope_key,
|
|
61
|
+
evidence_tier_rank,
|
|
62
|
+
from_legacy_evidence_block,
|
|
63
|
+
is_relay_identity_swap,
|
|
64
|
+
is_valid_envelope,
|
|
65
|
+
jcs_sha256,
|
|
66
|
+
map_legacy_evidence_block,
|
|
67
|
+
order_envelope_instances,
|
|
68
|
+
raw_sha256,
|
|
69
|
+
render_envelope_opaque,
|
|
70
|
+
validate_envelope,
|
|
71
|
+
)
|
|
72
|
+
from .content_id import compute_content_id, normalize_server_url
|
|
73
|
+
from .encoding import base64url_decode, base64url_encode, hex_decode, hex_encode
|
|
74
|
+
from .hashes import (
|
|
75
|
+
derive_provenance_token,
|
|
76
|
+
record_hash_bytes,
|
|
77
|
+
record_hash_hex,
|
|
78
|
+
record_hash_ref,
|
|
79
|
+
sha256,
|
|
80
|
+
)
|
|
81
|
+
from .keys import ResolvedKey, resolve_key
|
|
82
|
+
from .mirror import (
|
|
83
|
+
MirrorLine,
|
|
84
|
+
append_mirror_line,
|
|
85
|
+
default_mirror_read_path,
|
|
86
|
+
default_mirror_write_path,
|
|
87
|
+
mirror_tail_hash_hex,
|
|
88
|
+
parse_mirror_line,
|
|
89
|
+
read_mirror,
|
|
90
|
+
read_mirror_tail,
|
|
91
|
+
)
|
|
92
|
+
from .records import (
|
|
93
|
+
SYNTHETIC_SERVER_URL,
|
|
94
|
+
build_and_sign_emit_record,
|
|
95
|
+
content_hash,
|
|
96
|
+
leaf_of_event_type_uri,
|
|
97
|
+
)
|
|
98
|
+
from .signing import (
|
|
99
|
+
get_public_key,
|
|
100
|
+
sign_record,
|
|
101
|
+
sign_transaction_attestation,
|
|
102
|
+
sign_transaction_record,
|
|
103
|
+
verify_record,
|
|
104
|
+
)
|
|
105
|
+
from .submission import DEFAULT_LOG_ENDPOINT, SubmissionQueue, normalize_log_endpoint
|
|
106
|
+
from .token import DecodedToken, decode_token, encode_token
|
|
107
|
+
from .types import (
|
|
108
|
+
EVENT_TYPE_ANNOTATION_URI,
|
|
109
|
+
EVENT_TYPE_DIRECTORY_ANCHOR_URI,
|
|
110
|
+
EVENT_TYPE_OBSERVATION_URI,
|
|
111
|
+
EVENT_TYPE_REVISION_URI,
|
|
112
|
+
EVENT_TYPE_TOOL_CALL_URI,
|
|
113
|
+
EVENT_TYPE_TRANSACTION_URI,
|
|
114
|
+
SPEC_VERSION,
|
|
115
|
+
AtribRecord,
|
|
116
|
+
SignerEntry,
|
|
117
|
+
event_type_uri_to_byte,
|
|
118
|
+
is_normative_event_type_uri,
|
|
119
|
+
is_valid_event_type_uri,
|
|
120
|
+
normalize_event_type,
|
|
121
|
+
)
|
|
122
|
+
from .validation import ValidationResult, validate_submission
|
|
123
|
+
|
|
124
|
+
__version__ = "0.1.0"
|
|
125
|
+
|
|
126
|
+
__all__ = [
|
|
127
|
+
"ANCHOR_CLAIM_PREFIX",
|
|
128
|
+
"ANCHOR_TYPES",
|
|
129
|
+
"ATRIB_PROFILE_BASE",
|
|
130
|
+
"ATRIB_PROFILE_REGISTRY",
|
|
131
|
+
"ATTRIBUTION_EXTENSION_KEY",
|
|
132
|
+
"ATTRIBUTION_LOG_SUBMISSION_STATUSES",
|
|
133
|
+
"AnchorDescriptor",
|
|
134
|
+
"AnchorPostureResolution",
|
|
135
|
+
"AnchorSetConfig",
|
|
136
|
+
"AnchorSpec",
|
|
137
|
+
"AttributionReceiptBlock",
|
|
138
|
+
"AttributionReceiptConsistency",
|
|
139
|
+
"AttributionReceiptVerification",
|
|
140
|
+
"BUILT_IN_DEFAULT_ANCHOR_SET",
|
|
141
|
+
"anchor_claim_artifact",
|
|
142
|
+
"check_attribution_receipt_consistency",
|
|
143
|
+
"parse_attribution_receipt_block",
|
|
144
|
+
"resolve_anchor_posture",
|
|
145
|
+
"resolve_effective_anchors",
|
|
146
|
+
"verify_attribution_receipt",
|
|
147
|
+
"AtribClient",
|
|
148
|
+
"AtribRecord",
|
|
149
|
+
"AttestRef",
|
|
150
|
+
"AttestResult",
|
|
151
|
+
"DecodedToken",
|
|
152
|
+
"DEFAULT_LOG_ENDPOINT",
|
|
153
|
+
"EVIDENCE_CONSTRAINT_STATUSES",
|
|
154
|
+
"EVIDENCE_REF_KINDS",
|
|
155
|
+
"EVIDENCE_TIERS",
|
|
156
|
+
"FROZEN_LEGACY_PROTOCOLS",
|
|
157
|
+
"LEGACY_PROTOCOL_TO_PROFILE",
|
|
158
|
+
"EnvelopeReproducibility",
|
|
159
|
+
"EnvelopeValidation",
|
|
160
|
+
"EvidenceConstraint",
|
|
161
|
+
"EvidenceEnvelope",
|
|
162
|
+
"EvidencePayload",
|
|
163
|
+
"EvidencePayloadRef",
|
|
164
|
+
"EvidenceResult",
|
|
165
|
+
"EvidenceVerifier",
|
|
166
|
+
"ProfileClassification",
|
|
167
|
+
"assess_reproducibility",
|
|
168
|
+
"build_evidence_envelope",
|
|
169
|
+
"classify_profile",
|
|
170
|
+
"evidence_envelope_key",
|
|
171
|
+
"evidence_tier_rank",
|
|
172
|
+
"from_legacy_evidence_block",
|
|
173
|
+
"is_relay_identity_swap",
|
|
174
|
+
"is_valid_envelope",
|
|
175
|
+
"jcs_sha256",
|
|
176
|
+
"map_legacy_evidence_block",
|
|
177
|
+
"order_envelope_instances",
|
|
178
|
+
"raw_sha256",
|
|
179
|
+
"render_envelope_opaque",
|
|
180
|
+
"validate_envelope",
|
|
181
|
+
"EVENT_TYPE_ANNOTATION_URI",
|
|
182
|
+
"EVENT_TYPE_DIRECTORY_ANCHOR_URI",
|
|
183
|
+
"EVENT_TYPE_OBSERVATION_URI",
|
|
184
|
+
"EVENT_TYPE_REVISION_URI",
|
|
185
|
+
"EVENT_TYPE_TOOL_CALL_URI",
|
|
186
|
+
"EVENT_TYPE_TRANSACTION_URI",
|
|
187
|
+
"MirrorLine",
|
|
188
|
+
"RecallOutcome",
|
|
189
|
+
"ResolvedKey",
|
|
190
|
+
"SPEC_VERSION",
|
|
191
|
+
"SignerEntry",
|
|
192
|
+
"SubmissionQueue",
|
|
193
|
+
"SYNTHETIC_SERVER_URL",
|
|
194
|
+
"ValidationResult",
|
|
195
|
+
"append_mirror_line",
|
|
196
|
+
"base64url_decode",
|
|
197
|
+
"base64url_encode",
|
|
198
|
+
"build_and_sign_emit_record",
|
|
199
|
+
"canonical_cross_attestation_input",
|
|
200
|
+
"canonical_record",
|
|
201
|
+
"canonical_signing_input",
|
|
202
|
+
"chain_root",
|
|
203
|
+
"compute_content_id",
|
|
204
|
+
"content_hash",
|
|
205
|
+
"decode_token",
|
|
206
|
+
"default_mirror_read_path",
|
|
207
|
+
"default_mirror_write_path",
|
|
208
|
+
"derive_provenance_token",
|
|
209
|
+
"encode_token",
|
|
210
|
+
"event_type_uri_to_byte",
|
|
211
|
+
"genesis_chain_root",
|
|
212
|
+
"get_public_key",
|
|
213
|
+
"hex_decode",
|
|
214
|
+
"hex_encode",
|
|
215
|
+
"is_normative_event_type_uri",
|
|
216
|
+
"is_valid_event_type_uri",
|
|
217
|
+
"jcs",
|
|
218
|
+
"leaf_of_event_type_uri",
|
|
219
|
+
"mirror_tail_hash_hex",
|
|
220
|
+
"normalize_event_type",
|
|
221
|
+
"normalize_log_endpoint",
|
|
222
|
+
"normalize_server_url",
|
|
223
|
+
"parse_mirror_line",
|
|
224
|
+
"read_mirror",
|
|
225
|
+
"read_mirror_tail",
|
|
226
|
+
"record_hash_bytes",
|
|
227
|
+
"record_hash_hex",
|
|
228
|
+
"record_hash_ref",
|
|
229
|
+
"resolve_chain_root",
|
|
230
|
+
"resolve_key",
|
|
231
|
+
"sha256",
|
|
232
|
+
"sign_record",
|
|
233
|
+
"sign_transaction_attestation",
|
|
234
|
+
"sign_transaction_record",
|
|
235
|
+
"validate_submission",
|
|
236
|
+
"verify_record",
|
|
237
|
+
]
|
atrib/anchors.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Producer-side anchor plurality (D138, spec §2.11.7-§2.11.13).
|
|
3
|
+
|
|
4
|
+
Python port of ``packages/mcp/src/anchors.ts``: the §2.11.8 anchor-type
|
|
5
|
+
registry, the typed anchor-set configuration, the §2.11.12 posture
|
|
6
|
+
resolution (``resolve_anchor_posture`` / ``resolve_effective_anchors``)
|
|
7
|
+
including the ``allow_single_anchor`` opt-in gate, and the §2.11.10
|
|
8
|
+
anchoring-claim artifact bytes (``anchor_claim_artifact``).
|
|
9
|
+
|
|
10
|
+
Submission fan-out lives in :mod:`atrib.client`: ``AtribClient`` builds one
|
|
11
|
+
:class:`atrib.submission.SubmissionQueue` per effective ``atrib-log``
|
|
12
|
+
anchor endpoint, so every leg keeps the §5.3.5 non-blocking contract.
|
|
13
|
+
Non-``atrib-log`` anchor types (``sigstore-rekor``, ``rfc3161-tsa``,
|
|
14
|
+
``opentimestamps``) have no Python transport yet — the TypeScript
|
|
15
|
+
reference stubs them too — so the client skips those legs with an
|
|
16
|
+
``atrib:`` warning naming the type.
|
|
17
|
+
|
|
18
|
+
Nothing here changes any signed byte: proof bundles are post-signing
|
|
19
|
+
artifacts (§2.8) and anchoring is permissionless and post-hoc (§2.11.7).
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import re
|
|
25
|
+
from collections.abc import Mapping
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from typing import TypedDict
|
|
28
|
+
|
|
29
|
+
from typing_extensions import NotRequired
|
|
30
|
+
|
|
31
|
+
# ── Anchor type registry (§2.11.8, v1) ───────────────────────────────────
|
|
32
|
+
|
|
33
|
+
ANCHOR_TYPES = ("atrib-log", "sigstore-rekor", "rfc3161-tsa", "opentimestamps")
|
|
34
|
+
|
|
35
|
+
# §2.11.10 domain-separation prefix for the anchoring-claim artifact.
|
|
36
|
+
# JCS-canonical records begin with '{'; the prefix makes the separation
|
|
37
|
+
# between anchoring signatures and record signatures explicit.
|
|
38
|
+
ANCHOR_CLAIM_PREFIX = "atrib-anchor/v1:"
|
|
39
|
+
|
|
40
|
+
_RECORD_HASH_RE = re.compile(r"^sha256:[0-9a-f]{64}\Z")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ── Anchor-set configuration (§2.11.12) ──────────────────────────────────
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class AnchorDescriptor(TypedDict, total=False):
|
|
47
|
+
"""One anchor in a producer's anchor set. ``anchor_type`` absent (or
|
|
48
|
+
``None``, mirroring the TS ``??`` coalescing) means ``atrib-log`` — the
|
|
49
|
+
same absence-defaulting rule the ``log_proofs`` discriminator uses
|
|
50
|
+
(§2.11.9 rule (a)). ``url`` wins over ``endpoint`` when both are set."""
|
|
51
|
+
|
|
52
|
+
anchor_type: NotRequired[str]
|
|
53
|
+
anchor_id: NotRequired[str]
|
|
54
|
+
url: NotRequired[str]
|
|
55
|
+
endpoint: NotRequired[str] # alias for `url`; `url` wins when both set
|
|
56
|
+
calendars: NotRequired[list[str]] # opentimestamps only
|
|
57
|
+
public_key_b64: NotRequired[str] # verifier trust material passthrough
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class AnchorSetConfig(TypedDict, total=False):
|
|
61
|
+
"""Producer anchor configuration per §2.11.12."""
|
|
62
|
+
|
|
63
|
+
anchors: NotRequired[list[AnchorDescriptor]]
|
|
64
|
+
# Opt-in acknowledgment that a sub-plurality anchor set is deliberate
|
|
65
|
+
# (§2.11.12 rule 3) — the single-anchor analog of a deliberate dangling
|
|
66
|
+
# informed_by claim per D113. Defaults to False.
|
|
67
|
+
allow_single_anchor: NotRequired[bool]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
#: The SDK's built-in default anchor set (§2.11.12 rule 1): two independent
|
|
71
|
+
#: anchors so zero-config producers get plurality without opting in. Values
|
|
72
|
+
#: match ``BUILT_IN_DEFAULT_ANCHOR_SET`` in ``packages/mcp/src/anchors.ts``
|
|
73
|
+
#: exactly. The OpenTimestamps member has no transport in this SDK yet, so
|
|
74
|
+
#: zero-config fan-out submits to the atrib log exactly as today and the
|
|
75
|
+
#: client reports the OTS leg skipped.
|
|
76
|
+
BUILT_IN_DEFAULT_ANCHOR_SET: tuple[AnchorDescriptor, ...] = (
|
|
77
|
+
{
|
|
78
|
+
"anchor_type": "atrib-log",
|
|
79
|
+
"anchor_id": "log.atrib.dev",
|
|
80
|
+
"url": "https://log.atrib.dev/v1",
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
"anchor_type": "opentimestamps",
|
|
84
|
+
"anchor_id": "opentimestamps-calendars",
|
|
85
|
+
"calendars": ["https://a.pool.opentimestamps.org"],
|
|
86
|
+
},
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(frozen=True)
|
|
91
|
+
class AnchorPostureResolution:
|
|
92
|
+
"""Result of resolving a producer anchor config per the §2.11.12
|
|
93
|
+
precedence rules. Field names match the conformance corpus
|
|
94
|
+
(``spec/conformance/2.11/anchors/cases/allow-single-anchor-config.json``)
|
|
95
|
+
and the TS ``AnchorPostureResolution`` exactly."""
|
|
96
|
+
|
|
97
|
+
effective_anchor_count: int
|
|
98
|
+
used_default_set: bool
|
|
99
|
+
warn: bool
|
|
100
|
+
# §5.9.3 sidecar degradation marker written when a sub-plurality config
|
|
101
|
+
# lacks allow_single_anchor (§2.11.12 rule 4), else None:
|
|
102
|
+
# {"configured": <n>, "allow_single_anchor": False}
|
|
103
|
+
sidecar_anchor_config: Mapping[str, object] | None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def resolve_anchor_posture(
|
|
107
|
+
config: Mapping[str, object] | None = None,
|
|
108
|
+
) -> AnchorPostureResolution:
|
|
109
|
+
"""Resolve a producer anchor config per §2.11.12, exact precedence:
|
|
110
|
+
|
|
111
|
+
1. No anchor config at all ⇒ the built-in default set (two anchors).
|
|
112
|
+
2. Explicit config with ≥ 2 entries ⇒ used as given.
|
|
113
|
+
3. Explicit config with < 2 entries and ``allow_single_anchor: True``
|
|
114
|
+
⇒ used as given, no warning.
|
|
115
|
+
4. Explicit config with < 2 entries and no flag ⇒ ``warn=True`` plus
|
|
116
|
+
the sidecar degradation marker. The operation continues; this
|
|
117
|
+
function is PURE (no output, no raise) — the client emits the
|
|
118
|
+
``atrib:``-prefixed warning so pure-function callers stay silent.
|
|
119
|
+
|
|
120
|
+
Never raises (§5.8). A malformed config resolves as if empty.
|
|
121
|
+
"""
|
|
122
|
+
mapping: Mapping[str, object] = config if isinstance(config, Mapping) else {}
|
|
123
|
+
anchors = mapping.get("anchors")
|
|
124
|
+
if not isinstance(anchors, list):
|
|
125
|
+
return AnchorPostureResolution(
|
|
126
|
+
effective_anchor_count=len(BUILT_IN_DEFAULT_ANCHOR_SET),
|
|
127
|
+
used_default_set=True,
|
|
128
|
+
warn=False,
|
|
129
|
+
sidecar_anchor_config=None,
|
|
130
|
+
)
|
|
131
|
+
configured = len(anchors)
|
|
132
|
+
if configured >= 2 or mapping.get("allow_single_anchor") is True:
|
|
133
|
+
return AnchorPostureResolution(
|
|
134
|
+
effective_anchor_count=configured,
|
|
135
|
+
used_default_set=False,
|
|
136
|
+
warn=False,
|
|
137
|
+
sidecar_anchor_config=None,
|
|
138
|
+
)
|
|
139
|
+
return AnchorPostureResolution(
|
|
140
|
+
effective_anchor_count=configured,
|
|
141
|
+
used_default_set=False,
|
|
142
|
+
warn=True,
|
|
143
|
+
sidecar_anchor_config={"configured": configured, "allow_single_anchor": False},
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def resolve_effective_anchors(
|
|
148
|
+
config: Mapping[str, object] | None = None,
|
|
149
|
+
) -> list[AnchorDescriptor]:
|
|
150
|
+
"""The effective anchor set for a config: the built-in default set when
|
|
151
|
+
no config was given (§2.11.12 rule 1), the caller's entries otherwise —
|
|
152
|
+
including deliberate or warned sub-plurality sets, which are used as
|
|
153
|
+
given (rules 3-4: warn, never block). Never raises."""
|
|
154
|
+
mapping: Mapping[str, object] = config if isinstance(config, Mapping) else {}
|
|
155
|
+
anchors = mapping.get("anchors")
|
|
156
|
+
if isinstance(anchors, list):
|
|
157
|
+
# Entries are returned as given (hostile members included) so the
|
|
158
|
+
# transport-build layer can skip them with a warning per §5.8.
|
|
159
|
+
return list(anchors)
|
|
160
|
+
return list(BUILT_IN_DEFAULT_ANCHOR_SET)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# ── §2.11.10 anchoring-claim artifact ────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def anchor_claim_artifact(record_hash: str) -> bytes:
|
|
167
|
+
"""Build the §2.11.10 anchor-claim artifact bytes for a record hash:
|
|
168
|
+
the UTF-8 bytes of ``"atrib-anchor/v1:" + record_hash`` with
|
|
169
|
+
``record_hash`` in canonical ``"sha256:" + 64-lowercase-hex`` form.
|
|
170
|
+
Deterministically reconstructible from ``record_hash`` alone; reveals
|
|
171
|
+
nothing beyond the commitment itself (§8.3 posture preserved).
|
|
172
|
+
|
|
173
|
+
Byte-identical to the TS ``anchorClaimArtifact``. Raises ``ValueError``
|
|
174
|
+
on a malformed record hash — this is a pure builder for programmer
|
|
175
|
+
input; fan-out paths catch everything per §5.8.
|
|
176
|
+
"""
|
|
177
|
+
if not isinstance(record_hash, str) or not _RECORD_HASH_RE.match(record_hash):
|
|
178
|
+
raise ValueError(
|
|
179
|
+
"atrib: anchor claim requires a canonical "
|
|
180
|
+
f'"sha256:<64 lowercase hex>" record hash, got {record_hash!r:.90}'
|
|
181
|
+
)
|
|
182
|
+
return (ANCHOR_CLAIM_PREFIX + record_hash).encode("utf-8")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# ── Descriptor helpers (shared with the client fan-out) ──────────────────
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def anchor_descriptor_type(descriptor: Mapping[str, object]) -> object:
|
|
189
|
+
"""§2.11.9 rule (a) analog: absent (or ``None``, matching the TS ``??``)
|
|
190
|
+
``anchor_type`` means ``atrib-log``. Non-string values pass through as
|
|
191
|
+
given so callers can name them in skip warnings."""
|
|
192
|
+
anchor_type = descriptor.get("anchor_type")
|
|
193
|
+
return "atrib-log" if anchor_type is None else anchor_type
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def anchor_descriptor_endpoint(descriptor: Mapping[str, object]) -> object:
|
|
197
|
+
"""Submission endpoint: ``url`` wins over ``endpoint`` (both spellings
|
|
198
|
+
accepted per §2.11.12; the spec sample uses ``url``)."""
|
|
199
|
+
url = descriptor.get("url")
|
|
200
|
+
return url if url is not None else descriptor.get("endpoint")
|
atrib/attribution.py
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""``dev.atrib/attribution`` extension receipts (accepted as D141).
|
|
3
|
+
|
|
4
|
+
Python parity for the TypeScript SDK's receipt surface: lenient parsing of
|
|
5
|
+
the extension block from a tool result's ``_meta``, and a consistency check
|
|
6
|
+
of a receipt's claims against the signed record it names. Receipts are
|
|
7
|
+
advisory — a mismatch never invalidates the tool result; it means the
|
|
8
|
+
receipt must not be trusted or cited. Conformance:
|
|
9
|
+
``spec/conformance/mcp-extension/cases/receipt--*.json``.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from collections.abc import Mapping
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from typing import cast
|
|
17
|
+
|
|
18
|
+
from .encoding import base64url_encode, hex_encode
|
|
19
|
+
from .hashes import record_hash_hex, record_hash_ref
|
|
20
|
+
from .token import decode_token, encode_token
|
|
21
|
+
from .types import AtribRecord, normalize_event_type
|
|
22
|
+
|
|
23
|
+
ATTRIBUTION_EXTENSION_KEY = "dev.atrib/attribution"
|
|
24
|
+
|
|
25
|
+
_RECEIPT_STRING_FIELDS = (
|
|
26
|
+
"record_hash",
|
|
27
|
+
"creator_key",
|
|
28
|
+
"context_id",
|
|
29
|
+
"event_type",
|
|
30
|
+
"chain_root",
|
|
31
|
+
"log_submission",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
ATTRIBUTION_LOG_SUBMISSION_STATUSES = ("queued", "submitted", "disabled", "failed")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class AttributionReceiptBlock:
|
|
39
|
+
token: str | None = None
|
|
40
|
+
receipt: Mapping[str, str] | None = None
|
|
41
|
+
record: AtribRecord | None = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class AttributionReceiptConsistency:
|
|
46
|
+
receipt_valid: bool
|
|
47
|
+
mismatched_fields: list[str] = field(default_factory=list)
|
|
48
|
+
attached_record_hash: str | None = None
|
|
49
|
+
claimed_record_hash: str | None = None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def parse_attribution_receipt_block(meta: object) -> AttributionReceiptBlock | None:
|
|
53
|
+
"""Extract the extension block from a tool result's ``_meta``. Lenient
|
|
54
|
+
parse: anything malformed yields None, never a raise; wrong-typed
|
|
55
|
+
fields drop; an all-wrong-typed receipt reads as absent."""
|
|
56
|
+
if not isinstance(meta, Mapping):
|
|
57
|
+
return None
|
|
58
|
+
raw = meta.get(ATTRIBUTION_EXTENSION_KEY)
|
|
59
|
+
if not isinstance(raw, Mapping):
|
|
60
|
+
return None
|
|
61
|
+
token = raw.get("token")
|
|
62
|
+
parsed_token = token if isinstance(token, str) else None
|
|
63
|
+
parsed_receipt: dict[str, str] | None = None
|
|
64
|
+
raw_receipt = raw.get("receipt")
|
|
65
|
+
if isinstance(raw_receipt, Mapping):
|
|
66
|
+
kept = {
|
|
67
|
+
fld: value
|
|
68
|
+
for fld in _RECEIPT_STRING_FIELDS
|
|
69
|
+
if isinstance(value := raw_receipt.get(fld), str)
|
|
70
|
+
}
|
|
71
|
+
if kept:
|
|
72
|
+
parsed_receipt = kept
|
|
73
|
+
raw_record = raw.get("record")
|
|
74
|
+
parsed_record = (
|
|
75
|
+
cast(AtribRecord, dict(raw_record)) if isinstance(raw_record, Mapping) else None
|
|
76
|
+
)
|
|
77
|
+
if parsed_token is None and parsed_receipt is None and parsed_record is None:
|
|
78
|
+
return None
|
|
79
|
+
return AttributionReceiptBlock(
|
|
80
|
+
token=parsed_token, receipt=parsed_receipt, record=parsed_record
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def check_attribution_receipt_consistency(
|
|
85
|
+
block: AttributionReceiptBlock,
|
|
86
|
+
record: AtribRecord | None = None,
|
|
87
|
+
) -> AttributionReceiptConsistency:
|
|
88
|
+
"""Check a receipt block's claims against the signed record they name
|
|
89
|
+
(the attached ``block.record`` or a caller-retrieved record). Absent
|
|
90
|
+
receipt fields are not mismatches. Never raises."""
|
|
91
|
+
attached = record if record is not None else block.record
|
|
92
|
+
receipt = block.receipt or {}
|
|
93
|
+
claimed = receipt.get("record_hash")
|
|
94
|
+
if attached is None:
|
|
95
|
+
return AttributionReceiptConsistency(
|
|
96
|
+
receipt_valid=False,
|
|
97
|
+
mismatched_fields=["record"],
|
|
98
|
+
claimed_record_hash=claimed,
|
|
99
|
+
)
|
|
100
|
+
try:
|
|
101
|
+
attached_hash = record_hash_ref(attached)
|
|
102
|
+
token = encode_token(attached)
|
|
103
|
+
except Exception: # noqa: BLE001 — unhashable record cannot back a receipt
|
|
104
|
+
return AttributionReceiptConsistency(
|
|
105
|
+
receipt_valid=False,
|
|
106
|
+
mismatched_fields=["record"],
|
|
107
|
+
claimed_record_hash=claimed,
|
|
108
|
+
)
|
|
109
|
+
mismatched: list[str] = []
|
|
110
|
+
if claimed is not None and claimed != attached_hash:
|
|
111
|
+
mismatched.append("record_hash")
|
|
112
|
+
if block.token is not None and block.token != token:
|
|
113
|
+
mismatched.append("token")
|
|
114
|
+
for fld in ("creator_key", "context_id", "chain_root"):
|
|
115
|
+
value = receipt.get(fld)
|
|
116
|
+
if value is not None and value != attached.get(fld):
|
|
117
|
+
mismatched.append(fld)
|
|
118
|
+
claimed_event = receipt.get("event_type")
|
|
119
|
+
attached_event = attached.get("event_type")
|
|
120
|
+
if claimed_event is not None and normalize_event_type(claimed_event) != (
|
|
121
|
+
normalize_event_type(attached_event) if isinstance(attached_event, str) else None
|
|
122
|
+
):
|
|
123
|
+
mismatched.append("event_type")
|
|
124
|
+
return AttributionReceiptConsistency(
|
|
125
|
+
receipt_valid=not mismatched,
|
|
126
|
+
mismatched_fields=mismatched,
|
|
127
|
+
attached_record_hash=attached_hash,
|
|
128
|
+
claimed_record_hash=claimed,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# ── Receipt verification (extension spec §6.2, consumer side) ────────────
|
|
133
|
+
|
|
134
|
+
_RECEIPT_REQUIRED_STRING_FIELDS = _RECEIPT_STRING_FIELDS
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@dataclass(frozen=True)
|
|
138
|
+
class AttributionReceiptVerification:
|
|
139
|
+
"""Outcome of :func:`verify_attribution_receipt`. ``mismatched`` uses
|
|
140
|
+
the TS vocabulary exactly: ``'token'``, ``'record_hash'``,
|
|
141
|
+
``'creator_key'``, ``'context_id'``, ``'chain_root'``,
|
|
142
|
+
``'log_submission'``, or ``['malformed']`` when the block is not
|
|
143
|
+
structurally a v0.1 result block."""
|
|
144
|
+
|
|
145
|
+
valid: bool
|
|
146
|
+
mismatched: list[str] = field(default_factory=list)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _is_structurally_receipt_block(block: object) -> bool:
|
|
150
|
+
if not isinstance(block, Mapping):
|
|
151
|
+
return False
|
|
152
|
+
if not isinstance(block.get("token"), str):
|
|
153
|
+
return False
|
|
154
|
+
receipt = block.get("receipt")
|
|
155
|
+
if not isinstance(receipt, Mapping):
|
|
156
|
+
return False
|
|
157
|
+
for fld in _RECEIPT_REQUIRED_STRING_FIELDS:
|
|
158
|
+
if not isinstance(receipt.get(fld), str):
|
|
159
|
+
return False
|
|
160
|
+
# TS parity: `record !== undefined && !isPlainObject(record)` — a
|
|
161
|
+
# present-but-null (None) record is malformed; an absent one is fine.
|
|
162
|
+
if "record" in block and not isinstance(block["record"], Mapping):
|
|
163
|
+
return False
|
|
164
|
+
return True
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def verify_attribution_receipt(block: object) -> AttributionReceiptVerification:
|
|
168
|
+
"""Consumer-side receipt integrity check (extension spec §6.2), the
|
|
169
|
+
exact port of the TS ``verifyAttributionReceipt``: the token,
|
|
170
|
+
``record_hash``, and ``creator_key`` must agree with each other and —
|
|
171
|
+
when the full record body is attached — recompute from the record's
|
|
172
|
+
canonical bytes. A consumer that detects an internal inconsistency MUST
|
|
173
|
+
treat the receipt as invalid and discard it; receipt invalidity never
|
|
174
|
+
invalidates the tool result itself.
|
|
175
|
+
|
|
176
|
+
This checks internal consistency only; it is not a proof. Callers
|
|
177
|
+
wanting Tier-3 assurance additionally verify the attached record's
|
|
178
|
+
Ed25519 signature (:func:`atrib.verify_record`) and, where required,
|
|
179
|
+
log inclusion via an independently fetched proof bundle.
|
|
180
|
+
|
|
181
|
+
Never raises: hostile input yields ``valid=False`` with
|
|
182
|
+
``mismatched=['malformed']``.
|
|
183
|
+
"""
|
|
184
|
+
if not _is_structurally_receipt_block(block):
|
|
185
|
+
return AttributionReceiptVerification(valid=False, mismatched=["malformed"])
|
|
186
|
+
mblock = cast("Mapping[str, object]", block) # narrowed by the structural check
|
|
187
|
+
receipt = cast("Mapping[str, object]", mblock["receipt"])
|
|
188
|
+
token = cast(str, mblock["token"])
|
|
189
|
+
mismatched: list[str] = []
|
|
190
|
+
decoded = decode_token(token)
|
|
191
|
+
if decoded is None:
|
|
192
|
+
return AttributionReceiptVerification(valid=False, mismatched=["token"])
|
|
193
|
+
token_hash_hex = hex_encode(decoded.record_hash)
|
|
194
|
+
token_key = base64url_encode(decoded.creator_key)
|
|
195
|
+
|
|
196
|
+
if receipt["record_hash"] != f"sha256:{token_hash_hex}":
|
|
197
|
+
mismatched.append("record_hash")
|
|
198
|
+
if receipt["creator_key"] != token_key:
|
|
199
|
+
mismatched.append("creator_key")
|
|
200
|
+
if receipt["log_submission"] not in ATTRIBUTION_LOG_SUBMISSION_STATUSES:
|
|
201
|
+
mismatched.append("log_submission")
|
|
202
|
+
|
|
203
|
+
record = mblock.get("record")
|
|
204
|
+
if isinstance(record, Mapping):
|
|
205
|
+
try:
|
|
206
|
+
record_hex = record_hash_hex(record)
|
|
207
|
+
record_token = encode_token(record)
|
|
208
|
+
except Exception: # noqa: BLE001 — unhashable attached record
|
|
209
|
+
# The TS reference would propagate here; Python keeps the
|
|
210
|
+
# never-raise contract and reports the block as malformed.
|
|
211
|
+
return AttributionReceiptVerification(valid=False, mismatched=["malformed"])
|
|
212
|
+
if token != record_token and "token" not in mismatched:
|
|
213
|
+
mismatched.append("token")
|
|
214
|
+
if (
|
|
215
|
+
receipt["record_hash"] != f"sha256:{record_hex}"
|
|
216
|
+
and "record_hash" not in mismatched
|
|
217
|
+
):
|
|
218
|
+
mismatched.append("record_hash")
|
|
219
|
+
if (
|
|
220
|
+
receipt["creator_key"] != record.get("creator_key")
|
|
221
|
+
and "creator_key" not in mismatched
|
|
222
|
+
):
|
|
223
|
+
mismatched.append("creator_key")
|
|
224
|
+
if receipt["context_id"] != record.get("context_id"):
|
|
225
|
+
mismatched.append("context_id")
|
|
226
|
+
if receipt["chain_root"] != record.get("chain_root"):
|
|
227
|
+
mismatched.append("chain_root")
|
|
228
|
+
|
|
229
|
+
return AttributionReceiptVerification(valid=not mismatched, mismatched=mismatched)
|