a2cn 0.3.3__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.
- a2cn/__init__.py +40 -0
- a2cn/acts.py +130 -0
- a2cn/client.py +423 -0
- a2cn/crypto.py +556 -0
- a2cn/did.py +422 -0
- a2cn/errors.py +56 -0
- a2cn/evidence.py +2359 -0
- a2cn/fulfillment.py +261 -0
- a2cn/invitation.py +294 -0
- a2cn/line_items.py +306 -0
- a2cn/messages.py +1087 -0
- a2cn/record.py +891 -0
- a2cn/server.py +1433 -0
- a2cn/session.py +1800 -0
- a2cn/session_store.py +217 -0
- a2cn/verify.py +994 -0
- a2cn/versions.py +30 -0
- a2cn-0.3.3.dist-info/METADATA +129 -0
- a2cn-0.3.3.dist-info/RECORD +23 -0
- a2cn-0.3.3.dist-info/WHEEL +5 -0
- a2cn-0.3.3.dist-info/entry_points.txt +2 -0
- a2cn-0.3.3.dist-info/licenses/LICENSE +201 -0
- a2cn-0.3.3.dist-info/top_level.txt +1 -0
a2cn/__init__.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""A2CN reference implementation."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from importlib.metadata import PackageNotFoundError, distribution
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from urllib.parse import urlparse
|
|
7
|
+
from urllib.request import url2pathname
|
|
8
|
+
|
|
9
|
+
_SOURCE_VERSION = "0.0.0+source"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _installed_version() -> str:
|
|
13
|
+
"""The version of the installed distribution that owns this source tree.
|
|
14
|
+
|
|
15
|
+
Another distribution named ``a2cn`` may be installed while Python imports a
|
|
16
|
+
different tree, so its version is reported only when that distribution's
|
|
17
|
+
files, or its editable install root, are the tree imported here.
|
|
18
|
+
"""
|
|
19
|
+
try:
|
|
20
|
+
dist = distribution("a2cn")
|
|
21
|
+
except PackageNotFoundError:
|
|
22
|
+
return _SOURCE_VERSION
|
|
23
|
+
here = Path(__file__).resolve()
|
|
24
|
+
if Path(dist.locate_file("a2cn/__init__.py")).resolve() == here:
|
|
25
|
+
return dist.version
|
|
26
|
+
direct_url = dist.read_text("direct_url.json")
|
|
27
|
+
if direct_url:
|
|
28
|
+
info = json.loads(direct_url)
|
|
29
|
+
url = urlparse(info.get("url", ""))
|
|
30
|
+
if info.get("dir_info", {}).get("editable") and url.scheme == "file":
|
|
31
|
+
if Path(url2pathname(url.path)).resolve() == here.parent.parent:
|
|
32
|
+
return dist.version
|
|
33
|
+
return _SOURCE_VERSION
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
__version__ = _installed_version()
|
|
37
|
+
|
|
38
|
+
from a2cn.versions import PROTOCOL_VERSIONS # noqa: E402
|
|
39
|
+
|
|
40
|
+
__all__ = ["PROTOCOL_VERSIONS", "__version__"]
|
a2cn/acts.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Verify one signed act on its own (Sections 7.3 to 7.6).
|
|
2
|
+
|
|
3
|
+
A record verifier checks the acts inside a record; this checks a single act an
|
|
4
|
+
auditor holds by itself: an offer, counteroffer, acceptance, rejection or
|
|
5
|
+
withdrawal. It rebuilds the object the act's signature covers with the same
|
|
6
|
+
primitive the records use (``rebuild_signed_act``, Section 7.3.1), requires the
|
|
7
|
+
signing method to be under the sender's DID, and verifies the signature against
|
|
8
|
+
the key that DID document lists for it. For an offer or counteroffer the act's
|
|
9
|
+
stated ``protocol_act_hash`` must also equal the rebuilt hash (Section 7.3.2).
|
|
10
|
+
|
|
11
|
+
A verified act proves who signed which terms. It says nothing about the session
|
|
12
|
+
around it: whether the act was in turn, in time, or ever accepted.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from collections.abc import Callable, Mapping
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from jwt.exceptions import InvalidSignatureError
|
|
21
|
+
|
|
22
|
+
from a2cn.crypto import hash_object, verify_jws
|
|
23
|
+
from a2cn.did import (
|
|
24
|
+
VerificationMethodNotFoundError,
|
|
25
|
+
get_public_key,
|
|
26
|
+
get_verification_method,
|
|
27
|
+
require_did_document_for,
|
|
28
|
+
)
|
|
29
|
+
from a2cn.messages import SIGNED_ACT_SIGNATURE_FIELDS, SUPPORTED_WIRE_VERSIONS, rebuild_signed_act
|
|
30
|
+
|
|
31
|
+
DidResolver = Mapping[str, dict] | Callable[[str], dict]
|
|
32
|
+
|
|
33
|
+
#: The act types this verifies: every act that carries its own signature.
|
|
34
|
+
SIGNED_ACT_TYPES = tuple(SIGNED_ACT_SIGNATURE_FIELDS)
|
|
35
|
+
|
|
36
|
+
# Why an act does not verify. verify_signed_act_reason returns one of these, or
|
|
37
|
+
# None when the act verifies.
|
|
38
|
+
ACT_REASON_UNSUPPORTED_TYPE = "UNSUPPORTED_ACT_TYPE"
|
|
39
|
+
ACT_REASON_VERSION_UNSTATED = "WIRE_VERSION_UNSTATED"
|
|
40
|
+
ACT_REASON_NOT_RECOMPUTABLE = "ACT_NOT_RECOMPUTABLE"
|
|
41
|
+
ACT_REASON_HASH_MISMATCH = "PROTOCOL_ACT_HASH_MISMATCH"
|
|
42
|
+
ACT_REASON_SIGNATURE_MISSING = "SIGNATURE_MISSING"
|
|
43
|
+
ACT_REASON_METHOD_NOT_CONTROLLED = "VERIFICATION_METHOD_NOT_CONTROLLED"
|
|
44
|
+
ACT_REASON_KEY_NOT_FOUND = "KEY_NOT_FOUND"
|
|
45
|
+
ACT_REASON_KEY_UNUSABLE = "VERIFICATION_METHOD_UNUSABLE"
|
|
46
|
+
ACT_REASON_SIGNATURE_INVALID = "SIGNATURE_INVALID"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def verify_signed_act_reason(
|
|
50
|
+
act: Any,
|
|
51
|
+
did_resolver: DidResolver,
|
|
52
|
+
*,
|
|
53
|
+
wire_version: str | None = None,
|
|
54
|
+
) -> str | None:
|
|
55
|
+
"""Why ``act`` does not verify, or None when its signature does.
|
|
56
|
+
|
|
57
|
+
``wire_version`` stands in for a ``protocol_version`` the act does not state.
|
|
58
|
+
An act received inside a session states none and is rebuilt under the
|
|
59
|
+
session's negotiated version (Section 7.3.1); held on its own, nothing says
|
|
60
|
+
which version that was, so the caller must say. An act that states its
|
|
61
|
+
version is always rebuilt under it, and ``wire_version`` is then ignored.
|
|
62
|
+
"""
|
|
63
|
+
if not isinstance(act, Mapping) or act.get("message_type") not in SIGNED_ACT_SIGNATURE_FIELDS:
|
|
64
|
+
return ACT_REASON_UNSUPPORTED_TYPE
|
|
65
|
+
if "protocol_version" not in act and wire_version not in SUPPORTED_WIRE_VERSIONS:
|
|
66
|
+
return ACT_REASON_VERSION_UNSTATED
|
|
67
|
+
|
|
68
|
+
rebuilt = rebuild_signed_act(act, version_when_absent=wire_version or "")
|
|
69
|
+
if rebuilt is None:
|
|
70
|
+
return ACT_REASON_NOT_RECOMPUTABLE
|
|
71
|
+
try:
|
|
72
|
+
expected_hash = hash_object(rebuilt)
|
|
73
|
+
except ValueError:
|
|
74
|
+
# A string with no UTF-8 encoding (a lone surrogate) cannot be
|
|
75
|
+
# canonicalized (RFC 8785): the act cannot be recomputed.
|
|
76
|
+
return ACT_REASON_NOT_RECOMPUTABLE
|
|
77
|
+
if act["message_type"] in ("offer", "counteroffer") and act.get("protocol_act_hash") != expected_hash:
|
|
78
|
+
return ACT_REASON_HASH_MISMATCH
|
|
79
|
+
|
|
80
|
+
signature = act.get(SIGNED_ACT_SIGNATURE_FIELDS[act["message_type"]])
|
|
81
|
+
verification_method = act.get("sender_verification_method")
|
|
82
|
+
sender_did = act.get("sender_did")
|
|
83
|
+
if not (isinstance(signature, str) and signature) or not (
|
|
84
|
+
isinstance(verification_method, str) and verification_method
|
|
85
|
+
):
|
|
86
|
+
return ACT_REASON_SIGNATURE_MISSING
|
|
87
|
+
if not (
|
|
88
|
+
isinstance(sender_did, str)
|
|
89
|
+
and sender_did
|
|
90
|
+
and (verification_method == sender_did or verification_method.startswith(f"{sender_did}#"))
|
|
91
|
+
):
|
|
92
|
+
return ACT_REASON_METHOD_NOT_CONTROLLED
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
did_document = require_did_document_for(
|
|
96
|
+
did_resolver(sender_did) if callable(did_resolver) else did_resolver[sender_did],
|
|
97
|
+
sender_did,
|
|
98
|
+
)
|
|
99
|
+
except Exception:
|
|
100
|
+
# A DID document the resolver cannot supply is a key that is not
|
|
101
|
+
# available, exactly like one the document does not list; so is a
|
|
102
|
+
# document whose id is not sender_did (Section 4.2).
|
|
103
|
+
return ACT_REASON_KEY_NOT_FOUND
|
|
104
|
+
try:
|
|
105
|
+
method = get_verification_method(did_document, verification_method)
|
|
106
|
+
except VerificationMethodNotFoundError:
|
|
107
|
+
# The document does not list this key: rotated, or never published. The
|
|
108
|
+
# signature cannot be checked either way, which is not a refusal.
|
|
109
|
+
return ACT_REASON_KEY_NOT_FOUND
|
|
110
|
+
except Exception:
|
|
111
|
+
return ACT_REASON_KEY_UNUSABLE
|
|
112
|
+
try:
|
|
113
|
+
public_key = get_public_key(method)
|
|
114
|
+
except Exception:
|
|
115
|
+
# Listed but unreadable (a coordinate that is not unpadded base64url,
|
|
116
|
+
# an unsupported type): the key is not available, as when unlisted.
|
|
117
|
+
return ACT_REASON_KEY_NOT_FOUND
|
|
118
|
+
try:
|
|
119
|
+
signed_payload = verify_jws(signature, public_key)
|
|
120
|
+
except (InvalidSignatureError, ValueError):
|
|
121
|
+
# A signature that does not verify, or bytes that are not a JWS at all.
|
|
122
|
+
return ACT_REASON_SIGNATURE_INVALID
|
|
123
|
+
if signed_payload != expected_hash:
|
|
124
|
+
return ACT_REASON_SIGNATURE_INVALID
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def verify_signed_act(act: Any, did_resolver: DidResolver, *, wire_version: str | None = None) -> bool:
|
|
129
|
+
"""Whether ``act``'s signature verifies; ``verify_signed_act_reason`` says why not."""
|
|
130
|
+
return verify_signed_act_reason(act, did_resolver, wire_version=wire_version) is None
|
a2cn/client.py
ADDED
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
"""
|
|
2
|
+
A2CN Initiator Client (Section 3.1, 6, 7)
|
|
3
|
+
|
|
4
|
+
Implements the buyer-side agent:
|
|
5
|
+
- fetch_discovery
|
|
6
|
+
- initiate_session
|
|
7
|
+
- send_offer
|
|
8
|
+
- send_acceptance
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import uuid
|
|
14
|
+
from datetime import datetime, timezone, timedelta
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
|
|
19
|
+
from a2cn.crypto import (
|
|
20
|
+
EllipticCurvePrivateKey,
|
|
21
|
+
hash_object,
|
|
22
|
+
sign_jws,
|
|
23
|
+
create_jwt,
|
|
24
|
+
)
|
|
25
|
+
from a2cn.messages import (
|
|
26
|
+
PROTOCOL_ACT_VERSION,
|
|
27
|
+
negotiated_protocol_version,
|
|
28
|
+
protocol_act_object,
|
|
29
|
+
signed_act_hash,
|
|
30
|
+
)
|
|
31
|
+
from a2cn.record import generate_transaction_record, A2CN_NAMESPACE
|
|
32
|
+
from a2cn.session import (
|
|
33
|
+
A2CNError,
|
|
34
|
+
Session,
|
|
35
|
+
SessionState,
|
|
36
|
+
check_fixed_money_params,
|
|
37
|
+
check_session_versions,
|
|
38
|
+
check_offer_money_params,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
A2CN_CONTENT_TYPE = "application/a2cn+json"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _now() -> str:
|
|
45
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _expires_at(seconds: int) -> str:
|
|
49
|
+
t = datetime.now(timezone.utc) + timedelta(seconds=seconds)
|
|
50
|
+
return t.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class A2CNClient:
|
|
54
|
+
"""
|
|
55
|
+
Buyer-side A2CN client. Holds identity info and a live httpx.AsyncClient.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def __init__(
|
|
59
|
+
self,
|
|
60
|
+
agent_info: dict,
|
|
61
|
+
private_key: EllipticCurvePrivateKey,
|
|
62
|
+
mandate: dict,
|
|
63
|
+
http_client: httpx.AsyncClient | None = None,
|
|
64
|
+
auth_token: str | None = None, # TODO v0.3: generate DID-based JWT locally
|
|
65
|
+
) -> None:
|
|
66
|
+
self.agent_info = agent_info
|
|
67
|
+
self.private_key = private_key
|
|
68
|
+
self.mandate = mandate
|
|
69
|
+
self._http = http_client or httpx.AsyncClient()
|
|
70
|
+
self.auth_token = auth_token
|
|
71
|
+
|
|
72
|
+
# Session state maintained by the client
|
|
73
|
+
self._sessions: dict[str, dict] = {}
|
|
74
|
+
|
|
75
|
+
def _auth_headers(self) -> dict:
|
|
76
|
+
if self.auth_token:
|
|
77
|
+
return {"Authorization": f"Bearer {self.auth_token}"}
|
|
78
|
+
return {}
|
|
79
|
+
|
|
80
|
+
async def fetch_discovery(self, base_url: str) -> dict:
|
|
81
|
+
"""
|
|
82
|
+
Fetch the discovery document at {base_url}/.well-known/a2cn-agent.
|
|
83
|
+
Section 4.3.
|
|
84
|
+
"""
|
|
85
|
+
url = f"{base_url}/.well-known/a2cn-agent"
|
|
86
|
+
response = await self._http.get(url)
|
|
87
|
+
response.raise_for_status()
|
|
88
|
+
return response.json()
|
|
89
|
+
|
|
90
|
+
async def initiate_session(
|
|
91
|
+
self,
|
|
92
|
+
endpoint: str,
|
|
93
|
+
responder_did: str,
|
|
94
|
+
session_params: dict,
|
|
95
|
+
) -> dict:
|
|
96
|
+
"""
|
|
97
|
+
Send a SessionInit and return the SessionAck dict.
|
|
98
|
+
Section 6.3.
|
|
99
|
+
"""
|
|
100
|
+
message_id = str(uuid.uuid4())
|
|
101
|
+
|
|
102
|
+
session_init = {
|
|
103
|
+
"message_type": "session_init",
|
|
104
|
+
"message_id": message_id,
|
|
105
|
+
"protocol_version": PROTOCOL_ACT_VERSION,
|
|
106
|
+
"session_params": session_params,
|
|
107
|
+
"initiator": self.agent_info,
|
|
108
|
+
"initiator_mandate": self.mandate,
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
headers = {
|
|
112
|
+
"Content-Type": A2CN_CONTENT_TYPE,
|
|
113
|
+
"Idempotency-Key": message_id,
|
|
114
|
+
**self._auth_headers(),
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
resp = await self._http.post(
|
|
118
|
+
f"{endpoint}/sessions",
|
|
119
|
+
json=session_init,
|
|
120
|
+
headers=headers,
|
|
121
|
+
)
|
|
122
|
+
resp.raise_for_status()
|
|
123
|
+
ack = resp.json()
|
|
124
|
+
if not isinstance(ack, dict):
|
|
125
|
+
raise A2CNError("INVALID_REQUEST", "SessionAck must be a JSON object", 400)
|
|
126
|
+
|
|
127
|
+
# The responder must state the version this client proposed (Section 12.1.7),
|
|
128
|
+
# which is the current one: a live session is never established at a
|
|
129
|
+
# superseded version, so no legacy replay here (Section 11.2.1). It must
|
|
130
|
+
# also echo currency, and any basis it carries, unchanged (Section 6.4.1)
|
|
131
|
+
check_session_versions(session_init, ack)
|
|
132
|
+
check_fixed_money_params(session_params, ack.get("session_params_accepted"))
|
|
133
|
+
|
|
134
|
+
# Cache session state
|
|
135
|
+
session_id = ack["session_id"]
|
|
136
|
+
self._sessions[session_id] = {
|
|
137
|
+
"session_init": session_init,
|
|
138
|
+
"session_ack": ack,
|
|
139
|
+
"sequence_number": 0,
|
|
140
|
+
"round_number": 0,
|
|
141
|
+
"current_turn": "initiator",
|
|
142
|
+
"offer_chain": [],
|
|
143
|
+
"message_log": [],
|
|
144
|
+
"latest_offer": None,
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return ack
|
|
148
|
+
|
|
149
|
+
async def send_offer(
|
|
150
|
+
self,
|
|
151
|
+
endpoint: str,
|
|
152
|
+
responder_did: str,
|
|
153
|
+
session_id: str,
|
|
154
|
+
terms: dict,
|
|
155
|
+
in_reply_to: str | None = None,
|
|
156
|
+
) -> dict:
|
|
157
|
+
"""
|
|
158
|
+
Construct, sign, and send an Offer or Counteroffer.
|
|
159
|
+
Section 7.1 + 7.3.
|
|
160
|
+
"""
|
|
161
|
+
state = self._sessions[session_id]
|
|
162
|
+
# Check the terms before the counters move, so a refused offer leaves
|
|
163
|
+
# nothing to undo and the corrected one goes out in the same round (Section 7.2)
|
|
164
|
+
check_offer_money_params(
|
|
165
|
+
state["session_ack"]["session_params_accepted"], terms, session_id=session_id
|
|
166
|
+
)
|
|
167
|
+
state["sequence_number"] += 1
|
|
168
|
+
state["round_number"] += 1
|
|
169
|
+
|
|
170
|
+
round_number = state["round_number"]
|
|
171
|
+
sequence_number = state["sequence_number"]
|
|
172
|
+
message_type = "offer" if round_number == 1 else "counteroffer"
|
|
173
|
+
message_id = str(uuid.uuid4())
|
|
174
|
+
timestamp = _now()
|
|
175
|
+
expires_at = _expires_at(state["session_ack"]["session_params_accepted"]["round_timeout_seconds"])
|
|
176
|
+
|
|
177
|
+
# Build protocol act object (Section 7.3.1)
|
|
178
|
+
protocol_act = protocol_act_object(
|
|
179
|
+
protocol_version=negotiated_protocol_version(
|
|
180
|
+
state["session_init"], state["session_ack"]
|
|
181
|
+
),
|
|
182
|
+
session_id=session_id,
|
|
183
|
+
round_number=round_number,
|
|
184
|
+
sequence_number=sequence_number,
|
|
185
|
+
message_type=message_type,
|
|
186
|
+
sender_did=self.agent_info["did"],
|
|
187
|
+
timestamp=timestamp,
|
|
188
|
+
expires_at=expires_at,
|
|
189
|
+
terms=terms,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
protocol_act_hash = hash_object(protocol_act)
|
|
193
|
+
protocol_act_signature = sign_jws(
|
|
194
|
+
protocol_act_hash,
|
|
195
|
+
self.private_key,
|
|
196
|
+
kid=self.agent_info["verification_method"],
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
offer = {
|
|
200
|
+
"message_type": message_type,
|
|
201
|
+
"message_id": message_id,
|
|
202
|
+
"session_id": session_id,
|
|
203
|
+
"round_number": round_number,
|
|
204
|
+
"sequence_number": sequence_number,
|
|
205
|
+
"sender_did": self.agent_info["did"],
|
|
206
|
+
"sender_agent_id": self.agent_info["agent_id"],
|
|
207
|
+
"sender_verification_method": self.agent_info["verification_method"],
|
|
208
|
+
"timestamp": timestamp,
|
|
209
|
+
"expires_at": expires_at,
|
|
210
|
+
"terms": terms,
|
|
211
|
+
"protocol_act_hash": protocol_act_hash,
|
|
212
|
+
"protocol_act_signature": protocol_act_signature,
|
|
213
|
+
}
|
|
214
|
+
if in_reply_to:
|
|
215
|
+
offer["in_reply_to"] = in_reply_to
|
|
216
|
+
|
|
217
|
+
headers = {
|
|
218
|
+
"Content-Type": A2CN_CONTENT_TYPE,
|
|
219
|
+
"Idempotency-Key": message_id,
|
|
220
|
+
**self._auth_headers(),
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
resp = await self._http.post(
|
|
224
|
+
f"{endpoint}/sessions/{session_id}/messages",
|
|
225
|
+
json=offer,
|
|
226
|
+
headers=headers,
|
|
227
|
+
)
|
|
228
|
+
resp.raise_for_status()
|
|
229
|
+
|
|
230
|
+
state["offer_chain"].append(protocol_act_hash)
|
|
231
|
+
state["message_log"].append(offer)
|
|
232
|
+
state["latest_offer"] = offer
|
|
233
|
+
|
|
234
|
+
return resp.json()
|
|
235
|
+
|
|
236
|
+
async def send_acceptance(
|
|
237
|
+
self,
|
|
238
|
+
endpoint: str,
|
|
239
|
+
responder_did: str,
|
|
240
|
+
session_id: str,
|
|
241
|
+
offer: dict,
|
|
242
|
+
) -> dict:
|
|
243
|
+
"""
|
|
244
|
+
Sign and send an Acceptance for the given offer dict.
|
|
245
|
+
Section 7.4.
|
|
246
|
+
"""
|
|
247
|
+
state = self._sessions[session_id]
|
|
248
|
+
# The offer may never have passed the receive check: a caller can hand over
|
|
249
|
+
# any dict, and a message whose message_type is not exactly offer or
|
|
250
|
+
# counteroffer is recorded unchecked. Hold its terms to the session before
|
|
251
|
+
# the counters move, so a refused acceptance leaves nothing to undo (Section 7.2)
|
|
252
|
+
check_offer_money_params(
|
|
253
|
+
state["session_ack"]["session_params_accepted"],
|
|
254
|
+
offer.get("terms"),
|
|
255
|
+
session_id=session_id,
|
|
256
|
+
message_id=offer.get("message_id"),
|
|
257
|
+
)
|
|
258
|
+
state["sequence_number"] += 1
|
|
259
|
+
sequence_number = state["sequence_number"]
|
|
260
|
+
round_number = state["round_number"]
|
|
261
|
+
message_id = str(uuid.uuid4())
|
|
262
|
+
timestamp = _now()
|
|
263
|
+
|
|
264
|
+
accepted_offer_id = offer["message_id"]
|
|
265
|
+
accepted_hash = offer["protocol_act_hash"]
|
|
266
|
+
|
|
267
|
+
acceptance = {
|
|
268
|
+
"message_type": "acceptance",
|
|
269
|
+
"message_id": message_id,
|
|
270
|
+
"session_id": session_id,
|
|
271
|
+
"in_reply_to": accepted_offer_id,
|
|
272
|
+
"round_number": round_number,
|
|
273
|
+
"sequence_number": sequence_number,
|
|
274
|
+
"accepted_offer_id": accepted_offer_id,
|
|
275
|
+
"accepted_protocol_act_hash": accepted_hash,
|
|
276
|
+
"sender_did": self.agent_info["did"],
|
|
277
|
+
"sender_agent_id": self.agent_info["agent_id"],
|
|
278
|
+
"sender_verification_method": self.agent_info["verification_method"],
|
|
279
|
+
"timestamp": timestamp,
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
# Signed over the act's own envelope (Section 7.3.1): the common header
|
|
283
|
+
# plus an acceptance's payload, accepted_offer_id and
|
|
284
|
+
# accepted_protocol_act_hash. The act is built first so that what is
|
|
285
|
+
# signed is rebuilt from the very message that goes on the wire.
|
|
286
|
+
acceptance["acceptance_signature"] = sign_jws(
|
|
287
|
+
signed_act_hash(
|
|
288
|
+
acceptance,
|
|
289
|
+
version_when_absent=negotiated_protocol_version(
|
|
290
|
+
state["session_init"], state["session_ack"]
|
|
291
|
+
),
|
|
292
|
+
),
|
|
293
|
+
self.private_key,
|
|
294
|
+
kid=self.agent_info["verification_method"],
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
headers = {
|
|
298
|
+
"Content-Type": A2CN_CONTENT_TYPE,
|
|
299
|
+
"Idempotency-Key": message_id,
|
|
300
|
+
**self._auth_headers(),
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
resp = await self._http.post(
|
|
304
|
+
f"{endpoint}/sessions/{session_id}/messages",
|
|
305
|
+
json=acceptance,
|
|
306
|
+
headers=headers,
|
|
307
|
+
)
|
|
308
|
+
resp.raise_for_status()
|
|
309
|
+
state["message_log"].append(acceptance)
|
|
310
|
+
return resp.json()
|
|
311
|
+
|
|
312
|
+
async def get_session_state(self, endpoint: str, session_id: str) -> dict:
|
|
313
|
+
resp = await self._http.get(f"{endpoint}/sessions/{session_id}")
|
|
314
|
+
resp.raise_for_status()
|
|
315
|
+
return resp.json()
|
|
316
|
+
|
|
317
|
+
async def get_transaction_record(self, endpoint: str, session_id: str) -> dict:
|
|
318
|
+
resp = await self._http.get(f"{endpoint}/sessions/{session_id}/record")
|
|
319
|
+
resp.raise_for_status()
|
|
320
|
+
return resp.json()
|
|
321
|
+
|
|
322
|
+
def build_client_side_record(self, session_id: str) -> dict:
|
|
323
|
+
"""
|
|
324
|
+
Build the transaction record from client-side state.
|
|
325
|
+
Used to assert identical record_hash with the server's record.
|
|
326
|
+
"""
|
|
327
|
+
state = self._sessions[session_id]
|
|
328
|
+
|
|
329
|
+
# Build a minimal Session-like object for generate_transaction_record
|
|
330
|
+
mock_session = _MockSession(
|
|
331
|
+
session_id=session_id,
|
|
332
|
+
session_init=state["session_init"],
|
|
333
|
+
session_ack=state["session_ack"],
|
|
334
|
+
message_log=state["message_log"],
|
|
335
|
+
offer_chain=state["offer_chain"],
|
|
336
|
+
)
|
|
337
|
+
return generate_transaction_record(mock_session)
|
|
338
|
+
|
|
339
|
+
def process_incoming(self, session_id: str, message: dict) -> None:
|
|
340
|
+
"""
|
|
341
|
+
Record an incoming message from the counterparty into client-side session state.
|
|
342
|
+
Must be called for every received offer/counteroffer/acceptance so that
|
|
343
|
+
build_client_side_record() produces the correct offer_chain_hash.
|
|
344
|
+
Fix 5.8: replaces manual state patching in examples.
|
|
345
|
+
"""
|
|
346
|
+
if message.get("session_id") and message["session_id"] != session_id:
|
|
347
|
+
raise ValueError(
|
|
348
|
+
f"Message session_id mismatch: expected {session_id!r}, "
|
|
349
|
+
f"got {message['session_id']!r}"
|
|
350
|
+
)
|
|
351
|
+
state = self._sessions[session_id]
|
|
352
|
+
msg_type = message.get("message_type", "")
|
|
353
|
+
|
|
354
|
+
if msg_type in ("offer", "counteroffer"):
|
|
355
|
+
# The initiator is a receiver too: before recording an offer, refuse it
|
|
356
|
+
# if its terms break the session's currency or basis (Section 7.2)
|
|
357
|
+
check_offer_money_params(
|
|
358
|
+
state["session_ack"]["session_params_accepted"],
|
|
359
|
+
message.get("terms"),
|
|
360
|
+
session_id=session_id,
|
|
361
|
+
message_id=message.get("message_id"),
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
state["message_log"].append(message)
|
|
365
|
+
|
|
366
|
+
if msg_type in ("offer", "counteroffer"):
|
|
367
|
+
pah = message.get("protocol_act_hash")
|
|
368
|
+
if pah:
|
|
369
|
+
state["offer_chain"].append(pah)
|
|
370
|
+
state["sequence_number"] = message.get("sequence_number", state["sequence_number"])
|
|
371
|
+
state["round_number"] = message.get("round_number", state["round_number"])
|
|
372
|
+
state["latest_offer"] = message
|
|
373
|
+
# Flip turn back to initiator after a responder counteroffer
|
|
374
|
+
state["current_turn"] = "initiator"
|
|
375
|
+
elif msg_type == "acceptance":
|
|
376
|
+
state["sequence_number"] = message.get("sequence_number", state["sequence_number"])
|
|
377
|
+
state["current_turn"] = "none"
|
|
378
|
+
|
|
379
|
+
async def close(self) -> None:
|
|
380
|
+
await self._http.aclose()
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
class _MockSession:
|
|
384
|
+
"""Minimal Session-like object for client-side record generation."""
|
|
385
|
+
|
|
386
|
+
def __init__(
|
|
387
|
+
self,
|
|
388
|
+
session_id: str,
|
|
389
|
+
session_init: dict,
|
|
390
|
+
session_ack: dict,
|
|
391
|
+
message_log: list[dict],
|
|
392
|
+
offer_chain: list[str],
|
|
393
|
+
) -> None:
|
|
394
|
+
self.session_id = session_id
|
|
395
|
+
self._session_init = session_init
|
|
396
|
+
self._session_ack = session_ack
|
|
397
|
+
self._message_log = message_log
|
|
398
|
+
self._offer_chain = offer_chain
|
|
399
|
+
self.state = SessionState.COMPLETED
|
|
400
|
+
self.session_created_at = session_ack.get("session_created_at", "")
|
|
401
|
+
|
|
402
|
+
# Find final offer and acceptance from message log
|
|
403
|
+
acceptance = next(
|
|
404
|
+
(m for m in reversed(message_log) if m.get("message_type") == "acceptance"),
|
|
405
|
+
None,
|
|
406
|
+
)
|
|
407
|
+
accepted_offer_id = acceptance.get("accepted_offer_id") if acceptance else None
|
|
408
|
+
final_offer = next(
|
|
409
|
+
(m for m in message_log if m.get("message_id") == accepted_offer_id),
|
|
410
|
+
None,
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
self._final_offer = final_offer
|
|
414
|
+
self._final_acceptance = acceptance
|
|
415
|
+
self.round_number = max(
|
|
416
|
+
(m.get("round_number", 0) for m in message_log if "round_number" in m),
|
|
417
|
+
default=0,
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
# Extract party info
|
|
421
|
+
self.initiator_mandate = session_init.get("initiator_mandate", {})
|
|
422
|
+
self.responder_mandate = session_ack.get("responder_mandate", {})
|
|
423
|
+
self.session_params = session_ack.get("session_params_accepted", {})
|