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 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", {})