mintid-agent 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.
- mintid_agent/__init__.py +89 -0
- mintid_agent/client.py +504 -0
- mintid_agent/disclosure.py +124 -0
- mintid_agent/errors.py +94 -0
- mintid_agent/issuance.py +188 -0
- mintid_agent/store.py +95 -0
- mintid_agent/web_bot_auth.py +130 -0
- mintid_agent/x402.py +178 -0
- mintid_agent-0.1.0.dist-info/METADATA +128 -0
- mintid_agent-0.1.0.dist-info/RECORD +13 -0
- mintid_agent-0.1.0.dist-info/WHEEL +4 -0
- mintid_agent-0.1.0.dist-info/licenses/LICENSE +202 -0
- mintid_agent-0.1.0.dist-info/licenses/NOTICE +12 -0
mintid_agent/__init__.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""mintid-agent — the MintID agent client (MINT-342).
|
|
2
|
+
|
|
3
|
+
An agent runtime's side of the agent channel, over the holder library
|
|
4
|
+
(``proof_core.holder``): ``AgentClient.issue_from_offer`` (the principal's
|
|
5
|
+
mint presentation redeems an agent offer), ``refresh`` (status witness +
|
|
6
|
+
bulk scope delta replay), ``present`` (fail closed, naming the output key),
|
|
7
|
+
``pay`` (the bIP-0002/1 x402 identity loop, Web Bot Auth-signed) and
|
|
8
|
+
``renew`` (MINT-19 replacement). Keys never leave the client: the
|
|
9
|
+
credential's private bytes go only to an injected ``CredentialStore``
|
|
10
|
+
(encrypt-at-rest is the integrator's), the signing key stays behind an
|
|
11
|
+
injected ``Signer``. See ``docs/sdk/12-agent-clients.md``.
|
|
12
|
+
|
|
13
|
+
The pairwise agent pseudonym (R-A10) is mandatory in every ``bip-0002/1``
|
|
14
|
+
policy since contract 1.4.0: ``pay`` / ``present`` need nothing for it —
|
|
15
|
+
the holder proves the pseudonym for the challenge's own verifier (and
|
|
16
|
+
refuses a challenge naming another). ``unexpired`` is mandatory too since
|
|
17
|
+
1.5.0 (a declaration without it is ``unexpired_required``); an agent past
|
|
18
|
+
its lease cannot answer anywhere, and ``refresh`` reports the issuer's lease
|
|
19
|
+
sweep as ``CredentialRevoked`` from the next root on. A vendor session ends
|
|
20
|
+
with the status root it was decided under, so ``pay`` presents again after
|
|
21
|
+
each new root. Not in this version: sub-delegation
|
|
22
|
+
(``agent.delegate`` is gated)."""
|
|
23
|
+
|
|
24
|
+
from proof_core.holder import (
|
|
25
|
+
CredentialRevoked,
|
|
26
|
+
HolderError,
|
|
27
|
+
SelfRevocationReceipt,
|
|
28
|
+
SelfRevocationRefused,
|
|
29
|
+
WitnessState,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
from mintid_agent.client import AgentClient, Payer, PaymentResult
|
|
33
|
+
from mintid_agent.errors import (
|
|
34
|
+
AgentClientError,
|
|
35
|
+
ChallengeError,
|
|
36
|
+
IdentityRefused,
|
|
37
|
+
PolicyUnsatisfiable,
|
|
38
|
+
)
|
|
39
|
+
from mintid_agent.issuance import redeem_agent_offer
|
|
40
|
+
from mintid_agent.store import CredentialStore, MemoryStore
|
|
41
|
+
from mintid_agent.web_bot_auth import (
|
|
42
|
+
Ed25519Signer,
|
|
43
|
+
Signer,
|
|
44
|
+
directory_document,
|
|
45
|
+
okp_thumbprint,
|
|
46
|
+
sign_headers,
|
|
47
|
+
)
|
|
48
|
+
from mintid_agent.x402 import (
|
|
49
|
+
PRESENTATION_HEADER,
|
|
50
|
+
SESSION_HEADER,
|
|
51
|
+
Http,
|
|
52
|
+
HttpResponse,
|
|
53
|
+
IdentityDeclaration,
|
|
54
|
+
encode_presentation_header,
|
|
55
|
+
urllib_http,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
__version__ = "0.1.0"
|
|
59
|
+
|
|
60
|
+
__all__ = [
|
|
61
|
+
"PRESENTATION_HEADER",
|
|
62
|
+
"SESSION_HEADER",
|
|
63
|
+
"AgentClient",
|
|
64
|
+
"AgentClientError",
|
|
65
|
+
"ChallengeError",
|
|
66
|
+
"CredentialRevoked",
|
|
67
|
+
"CredentialStore",
|
|
68
|
+
"Ed25519Signer",
|
|
69
|
+
"HolderError",
|
|
70
|
+
"Http",
|
|
71
|
+
"HttpResponse",
|
|
72
|
+
"IdentityDeclaration",
|
|
73
|
+
"IdentityRefused",
|
|
74
|
+
"MemoryStore",
|
|
75
|
+
"Payer",
|
|
76
|
+
"PaymentResult",
|
|
77
|
+
"PolicyUnsatisfiable",
|
|
78
|
+
"SelfRevocationReceipt",
|
|
79
|
+
"SelfRevocationRefused",
|
|
80
|
+
"Signer",
|
|
81
|
+
"WitnessState",
|
|
82
|
+
"__version__",
|
|
83
|
+
"directory_document",
|
|
84
|
+
"encode_presentation_header",
|
|
85
|
+
"okp_thumbprint",
|
|
86
|
+
"redeem_agent_offer",
|
|
87
|
+
"sign_headers",
|
|
88
|
+
"urllib_http",
|
|
89
|
+
]
|
mintid_agent/client.py
ADDED
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
"""``AgentClient`` — one agent credential in its runtime's custody.
|
|
2
|
+
|
|
3
|
+
- **Issuance** (``issue_from_offer``): the principal's mint presentation
|
|
4
|
+
redeems an agent offer; the scope witnesses are adopted with their delta
|
|
5
|
+
cursor (``issuance``). ``renew`` redeems a renewal offer (MINT-19:
|
|
6
|
+
replacement — the old credential is revoked on issuance).
|
|
7
|
+
- **Freshness** (``refresh``): the status witness, then the issuer's public
|
|
8
|
+
scope delta log replayed in bulk from the cursor (``?since=<cursor>``,
|
|
9
|
+
never a per-value query — agent-presentation-v1 §6). A withdrawn
|
|
10
|
+
delegation then refuses at the holder: fail closed, no proof emitted.
|
|
11
|
+
- **Presenting** (``present``): the holder library answers a §10.5
|
|
12
|
+
challenge or refuses, naming the output key (``PolicyUnsatisfiable``).
|
|
13
|
+
- **Paying** (``pay``): the x402 loop of bIP-0002/1 — see ``pay``.
|
|
14
|
+
- **Kill switch** (``self_revoke``): R11 self-revocation of this agent
|
|
15
|
+
credential (self-revocation-v1 §5.5), submitted through a privacy relay
|
|
16
|
+
that pays for the transaction (MINT-348); the receipt is confirmed
|
|
17
|
+
against the chain on ``chain_rpc`` (MINT-372) through the issuer-scoped
|
|
18
|
+
``RecordedNullifiers`` read, which names no nullifier (MINT-377).
|
|
19
|
+
|
|
20
|
+
Custody: the holder's private bytes stay in the holder library and reach
|
|
21
|
+
only the injected ``CredentialStore`` (``store``); the Web Bot Auth key
|
|
22
|
+
stays behind the injected ``Signer``. Nothing here logs or returns either.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
import os
|
|
29
|
+
import time
|
|
30
|
+
import urllib.parse
|
|
31
|
+
from collections.abc import Callable, Mapping
|
|
32
|
+
from dataclasses import dataclass, field
|
|
33
|
+
|
|
34
|
+
from proof_core.chain import NullifierReader
|
|
35
|
+
from proof_core.holder import (
|
|
36
|
+
Holder,
|
|
37
|
+
HolderError,
|
|
38
|
+
SelfRevocationReceipt,
|
|
39
|
+
Transport,
|
|
40
|
+
WitnessState,
|
|
41
|
+
submit_self_revocation,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
from mintid_agent import issuance
|
|
45
|
+
from mintid_agent.errors import ChallengeError, IdentityRefused, PolicyUnsatisfiable
|
|
46
|
+
from mintid_agent.store import CredentialStore, CustodyRecord
|
|
47
|
+
from mintid_agent.web_bot_auth import Signer, sign_headers
|
|
48
|
+
from mintid_agent.x402 import (
|
|
49
|
+
PAYMENT_HEADER,
|
|
50
|
+
PRESENTATION_HEADER,
|
|
51
|
+
SESSION_HEADER,
|
|
52
|
+
Http,
|
|
53
|
+
HttpResponse,
|
|
54
|
+
IdentityDeclaration,
|
|
55
|
+
encode_presentation_header,
|
|
56
|
+
origin_of,
|
|
57
|
+
reason_of,
|
|
58
|
+
urllib_http,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
STALE_ROOT = "status_root_stale"
|
|
62
|
+
# Reasons a vendor's identity leg gives that call for a (new) presentation:
|
|
63
|
+
# the session states, and the one rejection a buyer agent retries —
|
|
64
|
+
# ``status_root_stale``, after a refresh. Every other rejection is terminal
|
|
65
|
+
# for the attempt (bIP-0002 §10, v1 buyer agent conformance).
|
|
66
|
+
REPRESENT = frozenset(
|
|
67
|
+
{
|
|
68
|
+
"identity_required",
|
|
69
|
+
"session_expired",
|
|
70
|
+
"session_key_mismatch",
|
|
71
|
+
STALE_ROOT,
|
|
72
|
+
}
|
|
73
|
+
)
|
|
74
|
+
MAX_PRESENTATIONS = 2 # per payment: the first, and one after a retryable answer
|
|
75
|
+
|
|
76
|
+
Payer = Callable[[object], str]
|
|
77
|
+
"""``payer(payment_required_body) -> X-PAYMENT value`` — the x402 payment
|
|
78
|
+
scheme is the integrator's (wallet, facilitator client); the client calls it
|
|
79
|
+
once per payment, and only after the identity leg can be answered."""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True, slots=True)
|
|
83
|
+
class PaymentResult:
|
|
84
|
+
status: int
|
|
85
|
+
headers: Mapping[str, str]
|
|
86
|
+
body: object
|
|
87
|
+
presented: bool # a fresh presentation was sent for this payment
|
|
88
|
+
session_reused: bool # the vendor accepted an existing session token
|
|
89
|
+
paid: bool # an X-PAYMENT was sent
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass
|
|
93
|
+
class AgentClient:
|
|
94
|
+
holder: Holder
|
|
95
|
+
credential_id: str
|
|
96
|
+
expires_at: int
|
|
97
|
+
scope_endpoint: str
|
|
98
|
+
transport: Transport
|
|
99
|
+
scope_cursor: int = 0
|
|
100
|
+
store: CredentialStore | None = None
|
|
101
|
+
http: Http | None = None
|
|
102
|
+
signer: Signer | None = None
|
|
103
|
+
signature_agent: str | None = None
|
|
104
|
+
clock: Callable[[], float] = time.time
|
|
105
|
+
sessions: dict[str, str] = field(default_factory=dict) # authority → token
|
|
106
|
+
last_refresh: float | None = None
|
|
107
|
+
presentations: int = 0
|
|
108
|
+
# The node the kill switch confirms its receipt on (MINT-372): a CometBFT
|
|
109
|
+
# RPC URL or a ``NullifierReader``; ``None`` → ``$MINTID_CHAIN_RPC``, then
|
|
110
|
+
# ``$MINTID_RPC`` (https://rpc.anchor.mintid.net on the anchor).
|
|
111
|
+
chain_rpc: str | NullifierReader | None = None
|
|
112
|
+
# The bounded-agent bundle it was minted from (MINT-403): "" for a raw
|
|
113
|
+
# scope. Informative for the runtime; the proof is what a verifier sees.
|
|
114
|
+
bundle_id: str = ""
|
|
115
|
+
|
|
116
|
+
# ── construction ────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
@classmethod
|
|
119
|
+
def issue_from_offer(
|
|
120
|
+
cls,
|
|
121
|
+
offer: dict,
|
|
122
|
+
principal: Holder,
|
|
123
|
+
*,
|
|
124
|
+
transport: Transport,
|
|
125
|
+
store: CredentialStore | None = None,
|
|
126
|
+
**options,
|
|
127
|
+
) -> AgentClient:
|
|
128
|
+
"""Redeem an agent offer with the principal's holder. ``HolderError``
|
|
129
|
+
on a terminal issuer refusal, ``ValueError`` when the offer is not an
|
|
130
|
+
agent offer or the principal cannot answer the mint challenge."""
|
|
131
|
+
issued = issuance.redeem_agent_offer(offer, principal, transport=transport)
|
|
132
|
+
client = cls(
|
|
133
|
+
holder=issued.holder,
|
|
134
|
+
credential_id=issued.credential_id,
|
|
135
|
+
expires_at=issued.expires_at,
|
|
136
|
+
scope_endpoint=issued.scope_endpoint,
|
|
137
|
+
transport=transport,
|
|
138
|
+
scope_cursor=issued.scope_cursor,
|
|
139
|
+
store=store,
|
|
140
|
+
bundle_id=issued.bundle_id,
|
|
141
|
+
**options,
|
|
142
|
+
)
|
|
143
|
+
client.persist()
|
|
144
|
+
return client
|
|
145
|
+
|
|
146
|
+
@classmethod
|
|
147
|
+
def load(
|
|
148
|
+
cls,
|
|
149
|
+
credential_id: str,
|
|
150
|
+
store: CredentialStore,
|
|
151
|
+
*,
|
|
152
|
+
transport: Transport,
|
|
153
|
+
**options,
|
|
154
|
+
) -> AgentClient:
|
|
155
|
+
"""Resume a credential from the store (``KeyError`` if absent)."""
|
|
156
|
+
raw = store.load(credential_id)
|
|
157
|
+
if raw is None:
|
|
158
|
+
raise KeyError(credential_id)
|
|
159
|
+
record = CustodyRecord.from_bytes(raw)
|
|
160
|
+
return cls(
|
|
161
|
+
holder=Holder.from_storage(record.storage, transport=transport),
|
|
162
|
+
credential_id=record.credential_id,
|
|
163
|
+
expires_at=record.expires_at,
|
|
164
|
+
scope_endpoint=record.scope_endpoint,
|
|
165
|
+
transport=transport,
|
|
166
|
+
scope_cursor=record.scope_cursor,
|
|
167
|
+
store=store,
|
|
168
|
+
bundle_id=record.bundle_id,
|
|
169
|
+
**options,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
def renew(self, offer: dict, principal: Holder) -> AgentClient:
|
|
173
|
+
"""Redeem a renewal offer (the operator's ``…/renew``): a new client
|
|
174
|
+
for the replacement credential with this one's runtime settings.
|
|
175
|
+
The issuer revokes this credential on issuance, so its record leaves
|
|
176
|
+
the store."""
|
|
177
|
+
new = AgentClient.issue_from_offer(
|
|
178
|
+
offer,
|
|
179
|
+
principal,
|
|
180
|
+
transport=self.transport,
|
|
181
|
+
store=self.store,
|
|
182
|
+
http=self.http,
|
|
183
|
+
signer=self.signer,
|
|
184
|
+
signature_agent=self.signature_agent,
|
|
185
|
+
clock=self.clock,
|
|
186
|
+
chain_rpc=self.chain_rpc,
|
|
187
|
+
)
|
|
188
|
+
if self.store is not None:
|
|
189
|
+
self.store.delete(self.credential_id)
|
|
190
|
+
return new
|
|
191
|
+
|
|
192
|
+
def persist(self) -> None:
|
|
193
|
+
if self.store is None:
|
|
194
|
+
return
|
|
195
|
+
self.store.save(
|
|
196
|
+
self.credential_id,
|
|
197
|
+
CustodyRecord(
|
|
198
|
+
credential_id=self.credential_id,
|
|
199
|
+
expires_at=self.expires_at,
|
|
200
|
+
scope_endpoint=self.scope_endpoint,
|
|
201
|
+
scope_cursor=self.scope_cursor,
|
|
202
|
+
storage=self.holder.storage_bytes(),
|
|
203
|
+
bundle_id=self.bundle_id,
|
|
204
|
+
).to_bytes(),
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
# ── freshness ───────────────────────────────────────────────────
|
|
208
|
+
|
|
209
|
+
def refresh_scope(self) -> int:
|
|
210
|
+
"""Replay the issuer's public delta log after the cursor (bulk);
|
|
211
|
+
returns the last delta sequence number the log reports."""
|
|
212
|
+
parts = urllib.parse.urlsplit(self.scope_endpoint)
|
|
213
|
+
url = urllib.parse.urlunsplit(
|
|
214
|
+
parts._replace(query=urllib.parse.urlencode({"since": self.scope_cursor}))
|
|
215
|
+
)
|
|
216
|
+
status, body = self.transport("GET", url)
|
|
217
|
+
if status != 200:
|
|
218
|
+
raise HolderError("scope_deltas", status, body)
|
|
219
|
+
last = int(body["last_seq"])
|
|
220
|
+
if last > self.scope_cursor:
|
|
221
|
+
self.holder.refresh_scope(bytes.fromhex(body["log"]))
|
|
222
|
+
self.scope_cursor = last
|
|
223
|
+
return last
|
|
224
|
+
|
|
225
|
+
def refresh(self) -> WitnessState:
|
|
226
|
+
"""The status witness (``CredentialRevoked`` once the agent is out of
|
|
227
|
+
the accumulator), then — once a finalized root anchors it — the
|
|
228
|
+
scope delta log. Persists the result."""
|
|
229
|
+
state = self.holder.refresh_witness()
|
|
230
|
+
if state.usable:
|
|
231
|
+
self.refresh_scope()
|
|
232
|
+
self.last_refresh = self.clock()
|
|
233
|
+
self.persist()
|
|
234
|
+
return state
|
|
235
|
+
|
|
236
|
+
# ── presenting ──────────────────────────────────────────────────
|
|
237
|
+
|
|
238
|
+
def present(self, challenge: dict) -> dict:
|
|
239
|
+
"""The envelope for a verifier's §10.5 challenge (``{request,
|
|
240
|
+
signature_hex}`` or the bare request). ``PolicyUnsatisfiable``
|
|
241
|
+
(a ``ValueError``) names the output key when the credential cannot
|
|
242
|
+
satisfy the policy — nothing leaves the holder then. Call
|
|
243
|
+
``refresh`` first (``pay`` does): the holder proves over the witness
|
|
244
|
+
material it holds, and a proof over superseded scope anchors is a
|
|
245
|
+
verifier's ``proof_invalid``, not a holder refusal."""
|
|
246
|
+
try:
|
|
247
|
+
envelope = self.holder.present(challenge)
|
|
248
|
+
except ValueError as exc:
|
|
249
|
+
raise PolicyUnsatisfiable.from_holder(exc) from exc
|
|
250
|
+
self.presentations += 1
|
|
251
|
+
return envelope
|
|
252
|
+
|
|
253
|
+
def relay_disclosure(
|
|
254
|
+
self,
|
|
255
|
+
issuer_url: str,
|
|
256
|
+
message: dict,
|
|
257
|
+
prove: Callable[[dict], object] | None = None,
|
|
258
|
+
) -> dict:
|
|
259
|
+
"""MINT-406: relay a relying party's ``DisclosureOffer`` or
|
|
260
|
+
``IdentificationRequest`` to this agent's issuer with the relay
|
|
261
|
+
proof over the issuer's challenge. The agent grants nothing and
|
|
262
|
+
receives no field: the answer is ``{state, reason}``
|
|
263
|
+
(``mintid_agent.disclosure``).
|
|
264
|
+
|
|
265
|
+
Without ``prove`` the holder proves (MINT-409: the relay-to-issuer
|
|
266
|
+
presentation, ``Holder.present_for_relay``) after a witness
|
|
267
|
+
``refresh`` — the proof is made against the definition the holder
|
|
268
|
+
last refreshed to, which is the one the issuer accepts."""
|
|
269
|
+
from mintid_agent.disclosure import holder_prover, relay_disclosure
|
|
270
|
+
|
|
271
|
+
if prove is None:
|
|
272
|
+
self.refresh()
|
|
273
|
+
prove = holder_prover(self.holder)
|
|
274
|
+
return relay_disclosure(issuer_url, message, prove, http=self.http)
|
|
275
|
+
|
|
276
|
+
def info(self):
|
|
277
|
+
return self.holder.info()
|
|
278
|
+
|
|
279
|
+
# ── kill switch (R11) ───────────────────────────────────────────
|
|
280
|
+
|
|
281
|
+
def self_revoke(
|
|
282
|
+
self,
|
|
283
|
+
chain_id: str,
|
|
284
|
+
anchoring: dict | None = None,
|
|
285
|
+
relay_url: str | None = None,
|
|
286
|
+
*,
|
|
287
|
+
status_root: bytes | None = None,
|
|
288
|
+
relay_transport: Transport | None = None,
|
|
289
|
+
chain: str | NullifierReader | None = None,
|
|
290
|
+
**confirm,
|
|
291
|
+
) -> dict | SelfRevocationReceipt:
|
|
292
|
+
"""The agent's own kill switch (R-A6 per-agent self-revoke, R11;
|
|
293
|
+
self-revocation-v1 §5.2, §5.5).
|
|
294
|
+
|
|
295
|
+
The holder builds and checks the SRP-1 message and from then on
|
|
296
|
+
refuses to present; the custody record is persisted *before* any
|
|
297
|
+
submission, so the refusal survives a crash. ``anchoring`` is the
|
|
298
|
+
issuer metadata's ``anchoring`` object of a root in the chain's ring
|
|
299
|
+
(its ``agent`` member is taken when present); omitted, the material
|
|
300
|
+
the last ``refresh`` cached is used (the issuer serves it with the
|
|
301
|
+
witness, MINT-347). ``status_root``, when read from the chain, must
|
|
302
|
+
be that root.
|
|
303
|
+
|
|
304
|
+
Without ``relay_url`` the message is returned (``{message,
|
|
305
|
+
nullifier, ciphertext}``) for any relay to submit — see
|
|
306
|
+
``submit_self_revocation``. With it, the message goes straight to
|
|
307
|
+
that privacy relay (MINT-348) and the receipt is returned, confirmed
|
|
308
|
+
on the chain (MINT-372; see ``submit_self_revocation``)."""
|
|
309
|
+
material = anchoring.get("agent", anchoring) if anchoring is not None else None
|
|
310
|
+
revocation = self.holder.self_revoke(chain_id, material, status_root)
|
|
311
|
+
self.persist()
|
|
312
|
+
if relay_url is None:
|
|
313
|
+
return revocation
|
|
314
|
+
return self.submit_self_revocation(
|
|
315
|
+
relay_url,
|
|
316
|
+
revocation,
|
|
317
|
+
relay_transport=relay_transport,
|
|
318
|
+
chain=chain,
|
|
319
|
+
**confirm,
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
def submit_self_revocation(
|
|
323
|
+
self,
|
|
324
|
+
relay_url: str,
|
|
325
|
+
revocation: dict,
|
|
326
|
+
*,
|
|
327
|
+
relay_transport: Transport | None = None,
|
|
328
|
+
chain: str | NullifierReader | None = None,
|
|
329
|
+
**confirm,
|
|
330
|
+
) -> SelfRevocationReceipt:
|
|
331
|
+
"""Hand a ``self_revoke`` result to a privacy relay (MINT-348, §5.3)
|
|
332
|
+
over ``relay_transport`` (default: a fresh stdlib transport, never
|
|
333
|
+
``self.transport``, which talks to the issuer), then confirm it on
|
|
334
|
+
the chain (MINT-372): the receipt is ``pending``/``folded`` only when
|
|
335
|
+
the node (``chain``, else ``chain_rpc``, else ``$MINTID_CHAIN_RPC`` /
|
|
336
|
+
``$MINTID_RPC``) shows the nullifier — whatever the relay answered,
|
|
337
|
+
``nullifier_recorded`` included. Otherwise it is ``submitted`` with
|
|
338
|
+
``unconfirmed_reason``: check again, or resubmit through another
|
|
339
|
+
relay (the message is idempotent). A refusal raises
|
|
340
|
+
``SelfRevocationRefused``; the holder keeps refusing to present
|
|
341
|
+
either way. ``confirm``: ``chain_post``, ``confirm_attempts``,
|
|
342
|
+
``confirm_interval``, ``sleep``."""
|
|
343
|
+
return submit_self_revocation(
|
|
344
|
+
relay_url,
|
|
345
|
+
revocation,
|
|
346
|
+
transport=relay_transport,
|
|
347
|
+
chain=self.node(chain),
|
|
348
|
+
**confirm,
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
def node(
|
|
352
|
+
self, chain: str | NullifierReader | None = None
|
|
353
|
+
) -> str | NullifierReader | None:
|
|
354
|
+
"""The node the kill switch reads: ``chain``, else ``chain_rpc``,
|
|
355
|
+
else ``$MINTID_CHAIN_RPC``, else ``$MINTID_RPC`` (``None``: no chain
|
|
356
|
+
endpoint — the receipt stays ``submitted``)."""
|
|
357
|
+
for candidate in (
|
|
358
|
+
chain,
|
|
359
|
+
self.chain_rpc,
|
|
360
|
+
os.environ.get("MINTID_CHAIN_RPC"),
|
|
361
|
+
os.environ.get("MINTID_RPC"),
|
|
362
|
+
):
|
|
363
|
+
if candidate:
|
|
364
|
+
return candidate
|
|
365
|
+
return None
|
|
366
|
+
|
|
367
|
+
# ── x402 ────────────────────────────────────────────────────────
|
|
368
|
+
|
|
369
|
+
def _headers(self, authority: str) -> dict[str, str]:
|
|
370
|
+
if self.signer is None:
|
|
371
|
+
return {}
|
|
372
|
+
if not self.signature_agent:
|
|
373
|
+
raise ValueError("a Web Bot Auth signer needs its signature_agent URL")
|
|
374
|
+
return sign_headers(
|
|
375
|
+
self.signer,
|
|
376
|
+
authority=authority,
|
|
377
|
+
signature_agent=self.signature_agent,
|
|
378
|
+
now=int(self.clock()),
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
def _send(self, method, url, authority, extra, body) -> HttpResponse:
|
|
382
|
+
http = self.http or urllib_http()
|
|
383
|
+
headers = {**self._headers(authority), **extra}
|
|
384
|
+
return http(method, url, headers=headers, body=body)
|
|
385
|
+
|
|
386
|
+
def _identity_header(
|
|
387
|
+
self, declaration: IdentityDeclaration, resource_origin: str
|
|
388
|
+
) -> str:
|
|
389
|
+
"""Refresh → challenge → local satisfiability (the holder) → header
|
|
390
|
+
value. The refresh comes first so the 10-second window is the
|
|
391
|
+
proof's alone (bIP-0002 §10: refresh before presenting)."""
|
|
392
|
+
self.refresh()
|
|
393
|
+
endpoint_origin, endpoint_authority = origin_of(declaration.challenge_endpoint)
|
|
394
|
+
resp = self._send(
|
|
395
|
+
"POST",
|
|
396
|
+
declaration.challenge_endpoint,
|
|
397
|
+
endpoint_authority,
|
|
398
|
+
{"Content-Type": "application/json"},
|
|
399
|
+
b"{}",
|
|
400
|
+
)
|
|
401
|
+
challenge = resp.body
|
|
402
|
+
if resp.status not in (200, 201) or not isinstance(challenge, dict):
|
|
403
|
+
raise ChallengeError("challenge_failed", f"HTTP {resp.status}")
|
|
404
|
+
request = challenge.get("request")
|
|
405
|
+
if not isinstance(request, dict):
|
|
406
|
+
raise ChallengeError("challenge_failed", "no request object")
|
|
407
|
+
# Present only to the party the payment is for: the resource's
|
|
408
|
+
# origin (a self-verifying seller) or the declared endpoint's (the
|
|
409
|
+
# verifying party that fronts it).
|
|
410
|
+
if request.get("audience") not in (resource_origin, endpoint_origin):
|
|
411
|
+
raise ChallengeError("audience_mismatch")
|
|
412
|
+
envelope = self.present(challenge)
|
|
413
|
+
return encode_presentation_header(request, envelope, declaration.profile)
|
|
414
|
+
|
|
415
|
+
def pay(
|
|
416
|
+
self,
|
|
417
|
+
url: str,
|
|
418
|
+
*,
|
|
419
|
+
payer: Payer,
|
|
420
|
+
method: str = "GET",
|
|
421
|
+
body: dict | bytes | None = None,
|
|
422
|
+
headers: Mapping[str, str] | None = None,
|
|
423
|
+
) -> PaymentResult:
|
|
424
|
+
"""One paid request under bIP-0002/1:
|
|
425
|
+
|
|
426
|
+
request → ``402`` with ``extensions.mintid_identity`` → (no session
|
|
427
|
+
for this authority) ``POST challenge_endpoint`` → the holder proves
|
|
428
|
+
the declared policy or refuses (``PolicyUnsatisfiable``: nothing is
|
|
429
|
+
presented **and nothing is paid**) → ``payer`` builds ``X-PAYMENT``
|
|
430
|
+
→ retry with ``X-PAYMENT`` + ``X-MINTID-PRESENTATION`` → on success
|
|
431
|
+
keep ``X-MINTID-SESSION`` for the authority, and send it (instead of
|
|
432
|
+
a presentation) on the next payment. The witnesses are refreshed
|
|
433
|
+
before every presentation. A session answer (``session_expired``,
|
|
434
|
+
``identity_required``, ``session_key_mismatch``) or
|
|
435
|
+
``status_root_stale`` (the root moved under the proof) gets one fresh
|
|
436
|
+
presentation; any other identity reason raises ``IdentityRefused``
|
|
437
|
+
(terminal for the attempt, bIP-0002 §10). Every request is Web Bot
|
|
438
|
+
Auth-signed when a signer is configured."""
|
|
439
|
+
origin, authority = origin_of(url)
|
|
440
|
+
raw = json.dumps(body).encode() if isinstance(body, dict) else body
|
|
441
|
+
base = dict(headers or {})
|
|
442
|
+
if isinstance(body, dict):
|
|
443
|
+
base.setdefault("Content-Type", "application/json")
|
|
444
|
+
|
|
445
|
+
resp = self._send(method, url, authority, base, raw)
|
|
446
|
+
if resp.status != 402:
|
|
447
|
+
return PaymentResult(
|
|
448
|
+
resp.status, resp.headers, resp.body, False, False, False
|
|
449
|
+
)
|
|
450
|
+
requirements = resp.body
|
|
451
|
+
declaration = IdentityDeclaration.from_payment_required(requirements)
|
|
452
|
+
|
|
453
|
+
payment: str | None = None
|
|
454
|
+
presented = False
|
|
455
|
+
use_session = declaration is not None and authority in self.sessions
|
|
456
|
+
fresh_presentations = 0
|
|
457
|
+
while True:
|
|
458
|
+
extra = dict(base)
|
|
459
|
+
reused = False
|
|
460
|
+
if declaration is not None:
|
|
461
|
+
if use_session:
|
|
462
|
+
extra[SESSION_HEADER] = self.sessions[authority]
|
|
463
|
+
reused = True
|
|
464
|
+
else:
|
|
465
|
+
extra[PRESENTATION_HEADER] = self._identity_header(
|
|
466
|
+
declaration, origin
|
|
467
|
+
)
|
|
468
|
+
presented = True
|
|
469
|
+
fresh_presentations += 1
|
|
470
|
+
if payment is None:
|
|
471
|
+
payment = payer(requirements)
|
|
472
|
+
extra[PAYMENT_HEADER] = payment
|
|
473
|
+
resp = self._send(method, url, authority, extra, raw)
|
|
474
|
+
if resp.status != 402 or declaration is None:
|
|
475
|
+
break
|
|
476
|
+
reason = reason_of(resp.body)
|
|
477
|
+
if reason is None:
|
|
478
|
+
break # the payment leg refused: the caller reads the 402
|
|
479
|
+
if reason not in REPRESENT or fresh_presentations >= MAX_PRESENTATIONS:
|
|
480
|
+
raise IdentityRefused(reason, resp.status, resp.body)
|
|
481
|
+
self.sessions.pop(authority, None)
|
|
482
|
+
use_session = False
|
|
483
|
+
if 200 <= resp.status < 300 and declaration is not None:
|
|
484
|
+
token = resp.header(SESSION_HEADER)
|
|
485
|
+
if token:
|
|
486
|
+
self.sessions[authority] = token
|
|
487
|
+
return PaymentResult(
|
|
488
|
+
resp.status,
|
|
489
|
+
resp.headers,
|
|
490
|
+
resp.body,
|
|
491
|
+
presented,
|
|
492
|
+
reused and 200 <= resp.status < 300,
|
|
493
|
+
True,
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
__all__ = [
|
|
498
|
+
"MAX_PRESENTATIONS",
|
|
499
|
+
"REPRESENT",
|
|
500
|
+
"STALE_ROOT",
|
|
501
|
+
"AgentClient",
|
|
502
|
+
"Payer",
|
|
503
|
+
"PaymentResult",
|
|
504
|
+
]
|