cowboy-protocol-py 0.6.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.
- cowboy_protocol/__init__.py +458 -0
- cowboy_protocol/_host.py +204 -0
- cowboy_protocol/_provenance.json +123 -0
- cowboy_protocol/_wasm/__init__.py +0 -0
- cowboy_protocol/_wasm/cowboy_protocol_wasm_ffi.wasm +0 -0
- cowboy_protocol/error_codes.py +633 -0
- cowboy_protocol/py.typed +0 -0
- cowboy_protocol_py-0.6.0.dist-info/METADATA +283 -0
- cowboy_protocol_py-0.6.0.dist-info/RECORD +12 -0
- cowboy_protocol_py-0.6.0.dist-info/WHEEL +4 -0
- cowboy_protocol_py-0.6.0.dist-info/licenses/LICENSE-APACHE +178 -0
- cowboy_protocol_py-0.6.0.dist-info/licenses/LICENSE-MIT +21 -0
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
"""``cowboy_protocol`` — the canonical Cowboy transaction codec for Python.
|
|
2
|
+
|
|
3
|
+
A thin, pure-Python wrapper (no compiler at install) that loads the WS-A
|
|
4
|
+
``cowboy-protocol-wasm-ffi`` ``.wasm`` via the ``wasmtime`` pip package and
|
|
5
|
+
exposes a Pythonic API. It is the Python analog of ``@cowboyinc/protocol-wasm``:
|
|
6
|
+
every byte and every signature comes from the SAME ``cowboy-protocol-codec``
|
|
7
|
+
compiled to wasm, so ``python == native == wasm == ts`` (byte-identical, proven
|
|
8
|
+
by the golden gate).
|
|
9
|
+
|
|
10
|
+
This package lives **in** ``cowboyinc/core`` (``protocol/bindings/python``),
|
|
11
|
+
co-located and versioned with the wasm + its flat ABI, so the ABI and its only
|
|
12
|
+
Python consumer can never drift across repos.
|
|
13
|
+
|
|
14
|
+
Example
|
|
15
|
+
-------
|
|
16
|
+
>>> import cowboy_protocol as cp
|
|
17
|
+
>>> tx = '{"chain_id": 42, "nonce": 0, ...}' # canyon-JSON tx shape
|
|
18
|
+
>>> cp.signing_hash_hex(tx)
|
|
19
|
+
'…64 hex chars…'
|
|
20
|
+
>>> priv = bytes.fromhex("01" * 32)
|
|
21
|
+
>>> cp.address_from_private_key(priv)
|
|
22
|
+
'0x…EIP-55 checksum…'
|
|
23
|
+
>>> signed_tx_json = cp.sign_transaction(tx, priv)
|
|
24
|
+
|
|
25
|
+
The ``tx_json`` argument is the canyon-JSON transaction shape — byte-for-byte
|
|
26
|
+
the same serde form node serializes, identical to the golden vectors'
|
|
27
|
+
``input.json`` (see ``crates/cowboy-protocol-codec/fixtures/commonware-codec/``).
|
|
28
|
+
|
|
29
|
+
On any wasm error (malformed JSON, bad key, etc.) the wrapper reads the wasm's
|
|
30
|
+
``cpw_last_error`` and raises :class:`CowboyProtocolError` — it never returns
|
|
31
|
+
garbage.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import importlib.resources
|
|
37
|
+
import json
|
|
38
|
+
|
|
39
|
+
from . import error_codes # noqa: F401
|
|
40
|
+
from ._host import CowboyProtocolError, host
|
|
41
|
+
|
|
42
|
+
__all__ = [
|
|
43
|
+
# COW-2792 canonical error-code registry submodule.
|
|
44
|
+
"error_codes",
|
|
45
|
+
"CowboyProtocolError",
|
|
46
|
+
"provenance",
|
|
47
|
+
"signing_preimage",
|
|
48
|
+
"signing_hash",
|
|
49
|
+
"signing_hash_hex",
|
|
50
|
+
"encode_transaction",
|
|
51
|
+
"encode_submission",
|
|
52
|
+
"sign",
|
|
53
|
+
"sign_transaction",
|
|
54
|
+
"address_from_private_key",
|
|
55
|
+
"normalize_job_request",
|
|
56
|
+
"encode_job_spec_json",
|
|
57
|
+
"decode_job_spec_to_json",
|
|
58
|
+
"validate_job_spec_bytes",
|
|
59
|
+
"validate_job_spec_json",
|
|
60
|
+
# RAS consensus-auth signing bytes (COW-2636).
|
|
61
|
+
"owner_authorization_signing_bytes",
|
|
62
|
+
"owner_delegation_signing_bytes",
|
|
63
|
+
"relay_authorization_signing_bytes",
|
|
64
|
+
# CBFS control-plane signing bytes + the delegation-cert hash (COW-2649).
|
|
65
|
+
"delegation_cert_signing_bytes",
|
|
66
|
+
"delegation_cert_hash",
|
|
67
|
+
"owner_cap_token_signing_bytes",
|
|
68
|
+
"delegated_request_signing_bytes",
|
|
69
|
+
"relay_usage_signing_bytes",
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
__version__ = "0.6.0"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def provenance() -> dict:
|
|
76
|
+
"""The build record shipped with this package (``_provenance.json``).
|
|
77
|
+
|
|
78
|
+
Says which wasm this distribution carries (``wasm.sha256`` / ``wasm.size``),
|
|
79
|
+
the JobSpec version it emits, the toolchain that built it and a digest of
|
|
80
|
+
the codec sources it was built from. ``source_revision`` is the release
|
|
81
|
+
commit, written when a release is built; it is ``None`` in a checkout. A
|
|
82
|
+
consumer can compare this record with the bundled wasm's actual hash.
|
|
83
|
+
"""
|
|
84
|
+
return json.loads(
|
|
85
|
+
importlib.resources.files(__package__)
|
|
86
|
+
.joinpath("_provenance.json")
|
|
87
|
+
.read_text(encoding="utf-8")
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _tx_bytes(tx_json: str) -> bytes:
|
|
92
|
+
"""Encode a canyon-JSON tx string as UTF-8 for the ABI boundary."""
|
|
93
|
+
if not isinstance(tx_json, str):
|
|
94
|
+
raise TypeError(f"tx_json must be str, got {type(tx_json).__name__}")
|
|
95
|
+
return tx_json.encode("utf-8")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def signing_preimage(tx_json: str) -> bytes:
|
|
99
|
+
"""Return the raw bytes of ``tx.signing_preimage()``.
|
|
100
|
+
|
|
101
|
+
The canonical encoding with all signatures zeroed and a leading
|
|
102
|
+
``CURRENT_TX_VERSION`` byte. ``tx_json`` is the canyon-JSON tx shape.
|
|
103
|
+
"""
|
|
104
|
+
return host().call("cpw_signing_preimage", _tx_bytes(tx_json))
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def signing_hash(tx_json: str) -> bytes:
|
|
108
|
+
"""Return the 32-byte ``tx.signing_hash()`` (``keccak256(signing_preimage())``)."""
|
|
109
|
+
return host().call("cpw_signing_hash", _tx_bytes(tx_json))
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def signing_hash_hex(tx_json: str) -> str:
|
|
113
|
+
"""Return ``tx.signing_hash()`` as lowercase hex (no ``0x`` prefix)."""
|
|
114
|
+
return host().call("cpw_signing_hash_hex", _tx_bytes(tx_json)).decode("ascii")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def encode_transaction(tx_json: str) -> bytes:
|
|
118
|
+
"""Return the full canonical tx encoding (signatures included, leading version)."""
|
|
119
|
+
return host().call("cpw_encode_transaction", _tx_bytes(tx_json))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def encode_submission(tx_json: str) -> bytes:
|
|
123
|
+
"""Return the ``POST /submit`` body: ``Submission::Transactions(vec![tx]).encode()``.
|
|
124
|
+
|
|
125
|
+
The single-tx on-chain submission envelope (native commonware-codec).
|
|
126
|
+
"""
|
|
127
|
+
return host().call("cpw_encode_submission", _tx_bytes(tx_json))
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _require_key(priv_key32: bytes) -> bytes:
|
|
131
|
+
"""Validate the caller-supplied 32-byte private key buffer."""
|
|
132
|
+
if not isinstance(priv_key32, (bytes, bytearray)):
|
|
133
|
+
raise TypeError(
|
|
134
|
+
f"priv_key32 must be bytes, got {type(priv_key32).__name__}"
|
|
135
|
+
)
|
|
136
|
+
if len(priv_key32) != 32:
|
|
137
|
+
raise ValueError(
|
|
138
|
+
f"priv_key32 must be exactly 32 bytes, got {len(priv_key32)}"
|
|
139
|
+
)
|
|
140
|
+
return bytes(priv_key32)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def sign(hash32: bytes, priv_key32: bytes) -> bytes:
|
|
144
|
+
"""Sign a 32-byte hash, returning a 65-byte ``r || s || v`` signature.
|
|
145
|
+
|
|
146
|
+
RFC-6979 deterministic, low-S normalized, Ethereum recovery id. ``hash32``
|
|
147
|
+
is typically the output of :func:`signing_hash`.
|
|
148
|
+
|
|
149
|
+
SECURITY: the private key is passed into wasm and lives in the host's linear
|
|
150
|
+
memory for the call (the weaker threat model accepted for COW-2363). Do not
|
|
151
|
+
feed high-value keys to this in-process signer in an untrusted host.
|
|
152
|
+
"""
|
|
153
|
+
if not isinstance(hash32, (bytes, bytearray)):
|
|
154
|
+
raise TypeError(f"hash32 must be bytes, got {type(hash32).__name__}")
|
|
155
|
+
if len(hash32) != 32:
|
|
156
|
+
raise ValueError(f"hash32 must be exactly 32 bytes, got {len(hash32)}")
|
|
157
|
+
key = _require_key(priv_key32)
|
|
158
|
+
# cpw_sign input = hash(32) || key(32)
|
|
159
|
+
return host().call("cpw_sign", bytes(hash32) + key)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def sign_transaction(tx_json: str, priv_key32: bytes) -> str:
|
|
163
|
+
"""Sign a transaction and return the signed tx as canyon-JSON.
|
|
164
|
+
|
|
165
|
+
Sets ``from`` from the derived address and the primary ``signature``
|
|
166
|
+
(single-signer path; mirrors ``-codec``'s ``Transaction::sign``). For
|
|
167
|
+
multi-signer txs, assemble client-side with :func:`sign` +
|
|
168
|
+
:func:`encode_submission`.
|
|
169
|
+
|
|
170
|
+
SECURITY: see :func:`sign` — the private key lives in wasm linear memory.
|
|
171
|
+
"""
|
|
172
|
+
key = _require_key(priv_key32)
|
|
173
|
+
# cpw_sign_transaction input = key(32) || json
|
|
174
|
+
out = host().call("cpw_sign_transaction", key + _tx_bytes(tx_json))
|
|
175
|
+
return out.decode("utf-8")
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def address_from_private_key(priv_key32: bytes) -> str:
|
|
179
|
+
"""Return the EIP-55 checksummed address (``0x…``) for a 32-byte private key.
|
|
180
|
+
|
|
181
|
+
SECURITY: see :func:`sign` — the private key lives in wasm linear memory.
|
|
182
|
+
"""
|
|
183
|
+
key = _require_key(priv_key32)
|
|
184
|
+
return host().call("cpw_address_from_private_key", key).decode("utf-8")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# ─── JobSpec bridges (COW-2440 slice-4 Part A) ──────────────────────────────
|
|
188
|
+
#
|
|
189
|
+
# These marshal the canyon-JSON `JobRequest`/`JobSpec` boundary <-> the
|
|
190
|
+
# canonical commonware-codec `JobSpec` bytes. ALL normalize/validate/encode
|
|
191
|
+
# LOGIC lives in `-codec`'s `job_spec` module; this wrapper (like the wasm
|
|
192
|
+
# bridge it calls) only (de)serializes and calls it — no protocol logic here
|
|
193
|
+
# either. See the crate-level ABI doc in
|
|
194
|
+
# ``crates/cowboy-protocol-wasm-ffi/src/lib.rs`` for the verbatim contract.
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def normalize_job_request(request_json: str) -> bytes:
|
|
198
|
+
"""Normalize a friendly ``JobRequest`` JSON document into canonical bytes.
|
|
199
|
+
|
|
200
|
+
``request_json`` is a ``JobRequest`` JSON document (UTF-8 on the wire).
|
|
201
|
+
Returns ``normalize(request).encode()`` — the canonical ``JobSpec`` bytes.
|
|
202
|
+
|
|
203
|
+
Raises :class:`CowboyProtocolError` on malformed JSON, or the (currently
|
|
204
|
+
infallible-in-practice) ``ValidationError`` ``normalize`` itself can
|
|
205
|
+
return.
|
|
206
|
+
"""
|
|
207
|
+
if not isinstance(request_json, str):
|
|
208
|
+
raise TypeError(
|
|
209
|
+
f"request_json must be str, got {type(request_json).__name__}"
|
|
210
|
+
)
|
|
211
|
+
return host().call("cpw_normalize_job_request", request_json.encode("utf-8"))
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def encode_job_spec_json(spec_json: str) -> bytes:
|
|
215
|
+
"""Encode a ``JobSpec`` JSON document into canonical bytes.
|
|
216
|
+
|
|
217
|
+
``spec_json`` is a ``JobSpec`` JSON document (UTF-8 on the wire). Returns
|
|
218
|
+
``spec.encode()`` — the canonical ``JobSpec`` bytes.
|
|
219
|
+
|
|
220
|
+
Raises :class:`CowboyProtocolError` on malformed/incomplete JSON.
|
|
221
|
+
"""
|
|
222
|
+
if not isinstance(spec_json, str):
|
|
223
|
+
raise TypeError(f"spec_json must be str, got {type(spec_json).__name__}")
|
|
224
|
+
return host().call("cpw_encode_job_spec_json", spec_json.encode("utf-8"))
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def decode_job_spec_to_json(spec_bytes: bytes) -> str:
|
|
228
|
+
"""Decode canonical ``JobSpec`` bytes and re-serialize them as JSON.
|
|
229
|
+
|
|
230
|
+
``spec_bytes`` must be ``JobSpec::decode_canonical``-valid canonical bytes
|
|
231
|
+
(e.g. the output of :func:`normalize_job_request` or
|
|
232
|
+
:func:`encode_job_spec_json`).
|
|
233
|
+
|
|
234
|
+
Raises :class:`CowboyProtocolError` if the bytes fail ``decode_canonical``
|
|
235
|
+
(bad version byte, trailing bytes, a truncated field, an over-cap length
|
|
236
|
+
prefix, ...).
|
|
237
|
+
"""
|
|
238
|
+
if not isinstance(spec_bytes, (bytes, bytearray)):
|
|
239
|
+
raise TypeError(
|
|
240
|
+
f"spec_bytes must be bytes, got {type(spec_bytes).__name__}"
|
|
241
|
+
)
|
|
242
|
+
return host().call("cpw_decode_job_spec_to_json", bytes(spec_bytes)).decode(
|
|
243
|
+
"utf-8"
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def validate_job_spec_bytes(spec_bytes: bytes) -> bytes:
|
|
248
|
+
"""Run ``-codec``'s ``validate()`` over canonical ``JobSpec`` bytes.
|
|
249
|
+
|
|
250
|
+
``spec_bytes`` must be ``JobSpec::decode_canonical``-valid canonical
|
|
251
|
+
bytes.
|
|
252
|
+
|
|
253
|
+
**Verdict convention**: the return value is itself the verdict, NOT a
|
|
254
|
+
boolean. A validation VERDICT (accept or reject) is always a
|
|
255
|
+
success-path result:
|
|
256
|
+
|
|
257
|
+
* **empty bytes (``b""``) = ACCEPT** — the spec passed ``validate()``.
|
|
258
|
+
* **non-empty bytes = REJECT** — a UTF-8 JSON object
|
|
259
|
+
``{"code", "field", "message"}`` (the ``ValidationError``'s own
|
|
260
|
+
fields) describing why ``validate()`` rejected the spec.
|
|
261
|
+
|
|
262
|
+
A :class:`CowboyProtocolError` is raised ONLY when the input does not
|
|
263
|
+
even decode into a well-formed ``JobSpec`` (a decode failure is a
|
|
264
|
+
distinct, FFI-level error from a well-formed spec that ``validate()``
|
|
265
|
+
rejects — see the crate doc's ``cpw_validate_job_spec_bytes`` entry).
|
|
266
|
+
"""
|
|
267
|
+
if not isinstance(spec_bytes, (bytes, bytearray)):
|
|
268
|
+
raise TypeError(
|
|
269
|
+
f"spec_bytes must be bytes, got {type(spec_bytes).__name__}"
|
|
270
|
+
)
|
|
271
|
+
return host().call("cpw_validate_job_spec_bytes", bytes(spec_bytes))
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def validate_job_spec_json(spec_json: str) -> bytes:
|
|
275
|
+
"""Run ``-codec``'s ``validate()`` over a ``JobSpec`` JSON document.
|
|
276
|
+
|
|
277
|
+
Same **verdict convention** as :func:`validate_job_spec_bytes`: empty
|
|
278
|
+
bytes = accept, non-empty bytes = a ``{"code", "field", "message"}``
|
|
279
|
+
reject verdict. A :class:`CowboyProtocolError` is raised only when
|
|
280
|
+
``spec_json`` fails to even deserialize into a ``JobSpec``.
|
|
281
|
+
"""
|
|
282
|
+
if not isinstance(spec_json, str):
|
|
283
|
+
raise TypeError(f"spec_json must be str, got {type(spec_json).__name__}")
|
|
284
|
+
return host().call("cpw_validate_job_spec_json", spec_json.encode("utf-8"))
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
# ─── RAS + CBFS control-plane signing bytes (COW-2636 / COW-2649 completion) ──
|
|
288
|
+
#
|
|
289
|
+
# These surface the canonical SIGNING-BYTE contract `-codec`'s `ras` +
|
|
290
|
+
# `cbfs_signing` modules own — the same big-endian, EIP-55-address,
|
|
291
|
+
# `Encoder`-domain bytes the chain (node) and the cbfs control-plane verify. A
|
|
292
|
+
# non-Rust signer (the python-sdk) MUST derive the bytes it signs from here, not
|
|
293
|
+
# re-implement them: a byte-level fork is exactly the "two implementations that
|
|
294
|
+
# must agree" split that caused the 2026-07-11 outage (COW-2608). Every byte
|
|
295
|
+
# comes from the SAME wasm the native golden gate pins (byte-identity proven by
|
|
296
|
+
# `tests/test_golden.py` against `crates/cowboy-protocol-codec/fixtures/ras/`).
|
|
297
|
+
#
|
|
298
|
+
# Each takes ONE canyon-JSON document — the serde shape of the `-codec` struct
|
|
299
|
+
# named in the docstring, byte-for-byte the fixtures' `input` (fixed-width byte
|
|
300
|
+
# fields are JSON arrays of `u8`; `Address` is the `0x…` string; `Option<T>` is
|
|
301
|
+
# the value or `null`). See the crate-level ABI doc in
|
|
302
|
+
# ``crates/cowboy-protocol-wasm-ffi/src/lib.rs`` for the verbatim contract.
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _signing_json(value: str, param: str) -> bytes:
|
|
306
|
+
"""Validate a signing-input JSON string and encode it UTF-8 for the ABI."""
|
|
307
|
+
if not isinstance(value, str):
|
|
308
|
+
raise TypeError(f"{param} must be str, got {type(value).__name__}")
|
|
309
|
+
return value.encode("utf-8")
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def owner_authorization_signing_bytes(authorization_json: str) -> bytes:
|
|
313
|
+
"""Return ``ras_owner_authorization_signing_bytes()`` for a ``RasOwnerAuthorization``.
|
|
314
|
+
|
|
315
|
+
The CBFS-key authorization over one RAS owner operation (CIP-9 §12.2.1).
|
|
316
|
+
``authorization_json`` is the ``RasOwnerAuthorization`` JSON shape:
|
|
317
|
+
``{version: u8, owner_address: "0x…", delegated_pubkey: [32 u8],
|
|
318
|
+
volume_id: [32 u8] | null, operation: str, valid_from_epoch: u64,
|
|
319
|
+
valid_until_epoch: u64, nonce: [16 u8], payload_hash: [32 u8],
|
|
320
|
+
signature: [64 u8]}``.
|
|
321
|
+
|
|
322
|
+
``operation`` must be a known RAS owner op — the codec fails closed
|
|
323
|
+
(raising :class:`CowboyProtocolError` ``"unknown RAS operation"``) on an
|
|
324
|
+
unrecognized operation, so the signing bytes can never be computed for an
|
|
325
|
+
off-registry op.
|
|
326
|
+
"""
|
|
327
|
+
return host().call(
|
|
328
|
+
"cpw_ras_owner_authorization_signing_bytes",
|
|
329
|
+
_signing_json(authorization_json, "authorization_json"),
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def owner_delegation_signing_bytes(delegation_json: str) -> bytes:
|
|
334
|
+
"""Return ``ras_owner_delegation_signing_bytes()`` for a ``RasOwnerDelegation``.
|
|
335
|
+
|
|
336
|
+
The epoch-scoped owner delegation for relayer-submitted RAS instructions.
|
|
337
|
+
``delegation_json`` is the ``RasOwnerDelegation`` JSON shape:
|
|
338
|
+
``{wallet_address: "0x…", delegated_pubkey: [32 u8], scopes: [str],
|
|
339
|
+
service_class: ServiceClass,
|
|
340
|
+
chain_id: u64, network: str, aud: str, valid_from_epoch: u64,
|
|
341
|
+
valid_until_epoch: u64, signature: [65 u8]}``.
|
|
342
|
+
|
|
343
|
+
``ServiceClass`` is ``"MOUNT_CAPABLE_V1"``, ``"NON_MOUNT_SERVICE_V1"``,
|
|
344
|
+
``{"VOLUME_BOUND_SERVICE_V1": {volume_id: [32 u8], workload_id: [32 u8]}}``,
|
|
345
|
+
``{"WORKLOAD_MOUNT_V1": {volume_id: [32 u8], workload_id: [32 u8]}}`` or
|
|
346
|
+
``{"SERVICE_RELEASE_V1": {volume_id: [32 u8]}}``.
|
|
347
|
+
"""
|
|
348
|
+
return host().call(
|
|
349
|
+
"cpw_ras_owner_delegation_signing_bytes",
|
|
350
|
+
_signing_json(delegation_json, "delegation_json"),
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def relay_authorization_signing_bytes(authorization_json: str) -> bytes:
|
|
355
|
+
"""Return ``ras_relay_authorization_signing_bytes()`` for a ``RasRelayAuthorization``.
|
|
356
|
+
|
|
357
|
+
The relay-identity (Ed25519) proof over relay registry metadata.
|
|
358
|
+
``authorization_json`` is the ``RasRelayAuthorization`` JSON shape:
|
|
359
|
+
``{version: u8, owner_address: "0x…", node_id: [32 u8], operation: str,
|
|
360
|
+
valid_from_epoch: u64, valid_until_epoch: u64, nonce: [16 u8],
|
|
361
|
+
payload_hash: [32 u8], signature: [64 u8]}``.
|
|
362
|
+
|
|
363
|
+
``operation`` must be a known RAS relay op (same fail-closed registry gate
|
|
364
|
+
as :func:`owner_authorization_signing_bytes`).
|
|
365
|
+
"""
|
|
366
|
+
return host().call(
|
|
367
|
+
"cpw_ras_relay_authorization_signing_bytes",
|
|
368
|
+
_signing_json(authorization_json, "authorization_json"),
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def delegation_cert_signing_bytes(cert_json: str) -> bytes:
|
|
373
|
+
"""Return ``delegation_cert_signing_bytes()`` for a ``DelegationCert`` (CIP-9 §7).
|
|
374
|
+
|
|
375
|
+
The canonical bytes the wallet's secp256k1 key signs to establish a
|
|
376
|
+
delegation certificate (every field EXCEPT ``signature``).
|
|
377
|
+
``cert_json`` is the ``DelegationCert`` JSON shape:
|
|
378
|
+
``{wallet_address: "0x…", cbfs_public_key: [32 u8], scope: str,
|
|
379
|
+
service_class: ServiceClass,
|
|
380
|
+
chain_id: u64, network: str, aud: str, expires_at_ms: u64,
|
|
381
|
+
signature: [u8]}``.
|
|
382
|
+
|
|
383
|
+
``ServiceClass`` is ``"MOUNT_CAPABLE_V1"``, ``"NON_MOUNT_SERVICE_V1"``,
|
|
384
|
+
``{"VOLUME_BOUND_SERVICE_V1": {volume_id: [32 u8], workload_id: [32 u8]}}``,
|
|
385
|
+
``{"WORKLOAD_MOUNT_V1": {volume_id: [32 u8], workload_id: [32 u8]}}`` or
|
|
386
|
+
``{"SERVICE_RELEASE_V1": {volume_id: [32 u8]}}``.
|
|
387
|
+
"""
|
|
388
|
+
return host().call(
|
|
389
|
+
"cpw_cbfs_delegation_cert_signing_bytes",
|
|
390
|
+
_signing_json(cert_json, "cert_json"),
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def delegation_cert_hash(cert_json: str) -> bytes:
|
|
395
|
+
"""Return ``delegation_cert_hash()`` — the 32-byte keccak256 of
|
|
396
|
+
:func:`delegation_cert_signing_bytes`.
|
|
397
|
+
|
|
398
|
+
The digest the wallet's secp256k1 key actually signs (and which owner
|
|
399
|
+
cap-token verification recovers the wallet address from). ``cert_json`` is
|
|
400
|
+
the same ``DelegationCert`` JSON shape as
|
|
401
|
+
:func:`delegation_cert_signing_bytes`; returns 32 raw bytes.
|
|
402
|
+
"""
|
|
403
|
+
return host().call(
|
|
404
|
+
"cpw_cbfs_delegation_cert_hash",
|
|
405
|
+
_signing_json(cert_json, "cert_json"),
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def owner_cap_token_signing_bytes(token_json: str) -> bytes:
|
|
410
|
+
"""Return ``owner_cap_token_signing_bytes()`` for an ``OwnerCapTokenV1``.
|
|
411
|
+
|
|
412
|
+
The canonical bytes the cbfs Ed25519 key signs for an owner-issued
|
|
413
|
+
capability token (covers every field except ``owner_signature``; the
|
|
414
|
+
embedded delegation cert is included in its WIRE form, signature and all).
|
|
415
|
+
``token_json`` is the ``OwnerCapTokenV1`` JSON shape:
|
|
416
|
+
``{version: u8, volume_id: [32 u8], volume_name: str, owner_address: "0x…",
|
|
417
|
+
access_mode: "WriteOnly" | "ReadOnly" | "ReadWrite", path_prefix: str,
|
|
418
|
+
valid_from_ms: u64, valid_until_ms: u64, mount: bool, sync_interval: u32,
|
|
419
|
+
delegation_cert: {DelegationCert}, owner_signature: [u8]}`` — where
|
|
420
|
+
``delegation_cert`` is the nested :func:`delegation_cert_signing_bytes`
|
|
421
|
+
shape.
|
|
422
|
+
"""
|
|
423
|
+
return host().call(
|
|
424
|
+
"cpw_cbfs_owner_cap_token_signing_bytes",
|
|
425
|
+
_signing_json(token_json, "token_json"),
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def delegated_request_signing_bytes(request_input_json: str) -> bytes:
|
|
430
|
+
"""Return ``delegated_request_signing_bytes()`` for a per-request signature.
|
|
431
|
+
|
|
432
|
+
The challenge-bound bytes the cbfs key signs so a captured token cannot be
|
|
433
|
+
replayed (method ‖ path ‖ ``keccak256(body)`` ‖ timestamp ‖ challenge).
|
|
434
|
+
``request_input_json`` is the ``DelegatedRequestInput`` JSON shape:
|
|
435
|
+
``{method: str, path_and_query: str, body: [u8], timestamp_ms: u64,
|
|
436
|
+
nonce_or_challenge: [16 u8]}`` (``body`` is the raw request body as a JSON
|
|
437
|
+
array of ``u8``; it is hashed, not embedded verbatim).
|
|
438
|
+
"""
|
|
439
|
+
return host().call(
|
|
440
|
+
"cpw_cbfs_delegated_request_signing_bytes",
|
|
441
|
+
_signing_json(request_input_json, "request_input_json"),
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def relay_usage_signing_bytes(usage_input_json: str) -> bytes:
|
|
446
|
+
"""Return ``relay_usage_signing_bytes()`` for a relay usage report.
|
|
447
|
+
|
|
448
|
+
The domain-separated bytes a relay's Ed25519 identity key signs for a
|
|
449
|
+
periodic registry usage report (covers every ``report`` field except its
|
|
450
|
+
own ``signature``). ``usage_input_json`` is the ``RelayUsageSigningInput``
|
|
451
|
+
JSON shape: ``{chain_id: u64, network: str, report: {ReportUsageRequest}}``
|
|
452
|
+
where ``report`` carries the full usage counters plus any
|
|
453
|
+
``drain_absorption_receipts`` (each folded in whole, signature included).
|
|
454
|
+
"""
|
|
455
|
+
return host().call(
|
|
456
|
+
"cpw_cbfs_relay_usage_signing_bytes",
|
|
457
|
+
_signing_json(usage_input_json, "usage_input_json"),
|
|
458
|
+
)
|
cowboy_protocol/_host.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""Internal wasmtime host for the ``cowboy-protocol-wasm-ffi`` module.
|
|
2
|
+
|
|
3
|
+
This module loads the bundled non-JS, flat-C-ABI ``.wasm`` (built from
|
|
4
|
+
``crates/cowboy-protocol-wasm-ffi`` via ``build-ffi.sh``) with the
|
|
5
|
+
``wasmtime`` pip package and drives its flat ABI:
|
|
6
|
+
|
|
7
|
+
* ``cpw_alloc(len) -> ptr`` (host input buffers)
|
|
8
|
+
* ``cpw_free(ptr, len)`` (free input + output)
|
|
9
|
+
* ``cpw_<fn>(in_ptr, in_len) -> u64`` (packed ``(ptr<<32)|len``)
|
|
10
|
+
* ``cpw_last_error() -> u64`` (packed error message)
|
|
11
|
+
|
|
12
|
+
Every codec call returns a packed ``u64``: ``out_ptr = ret >> 32`` and
|
|
13
|
+
``out_len = ret & 0xFFFF_FFFF``. ``out_ptr == 0`` is the error sentinel; on
|
|
14
|
+
error we read ``cpw_last_error`` and raise :class:`CowboyProtocolError`.
|
|
15
|
+
|
|
16
|
+
The module declares NO imports (no ``__wbindgen_*``, no ``getrandom/js``), so it
|
|
17
|
+
instantiates with an empty import set. See the crate-level docs in
|
|
18
|
+
``crates/cowboy-protocol-wasm-ffi/src/lib.rs`` for the verbatim ABI contract.
|
|
19
|
+
|
|
20
|
+
This host is single-threaded by design (mirrors the wasm's single-instance,
|
|
21
|
+
``thread_local`` last-error slot). A module-global lock serializes all calls so
|
|
22
|
+
the alloc/call/read/free dance and the last-error read stay coherent even if a
|
|
23
|
+
caller touches the API from multiple threads.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import importlib.resources
|
|
29
|
+
import threading
|
|
30
|
+
|
|
31
|
+
from wasmtime import Engine, Instance, Module, Store
|
|
32
|
+
|
|
33
|
+
#: Filename of the bundled wasm artifact (committed package data; kept in sync
|
|
34
|
+
#: with a fresh ``build-ffi.sh`` build by the CI anti-drift gate).
|
|
35
|
+
WASM_FILENAME = "cowboy_protocol_wasm_ffi.wasm"
|
|
36
|
+
|
|
37
|
+
#: The flat-ABI codec exports, each ``(in_ptr: u32, in_len: u32) -> u64``: the
|
|
38
|
+
#: eight transaction-codec exports, the five ``JobSpec`` bridges (COW-2440
|
|
39
|
+
#: slice-4 Part A), and the eight RAS + CBFS control-plane signing/hash bridges
|
|
40
|
+
#: (COW-2636/2649 completion WS-P1).
|
|
41
|
+
_CODEC_FNS = (
|
|
42
|
+
"cpw_signing_preimage",
|
|
43
|
+
"cpw_signing_hash",
|
|
44
|
+
"cpw_signing_hash_hex",
|
|
45
|
+
"cpw_encode_transaction",
|
|
46
|
+
"cpw_encode_submission",
|
|
47
|
+
"cpw_sign",
|
|
48
|
+
"cpw_sign_transaction",
|
|
49
|
+
"cpw_address_from_private_key",
|
|
50
|
+
"cpw_normalize_job_request",
|
|
51
|
+
"cpw_encode_job_spec_json",
|
|
52
|
+
"cpw_decode_job_spec_to_json",
|
|
53
|
+
"cpw_validate_job_spec_bytes",
|
|
54
|
+
"cpw_validate_job_spec_json",
|
|
55
|
+
# RAS consensus-auth signing bytes (COW-2636).
|
|
56
|
+
"cpw_ras_owner_authorization_signing_bytes",
|
|
57
|
+
"cpw_ras_owner_delegation_signing_bytes",
|
|
58
|
+
"cpw_ras_relay_authorization_signing_bytes",
|
|
59
|
+
# CBFS control-plane signing bytes + the delegation-cert hash (COW-2649).
|
|
60
|
+
"cpw_cbfs_delegation_cert_signing_bytes",
|
|
61
|
+
"cpw_cbfs_delegation_cert_hash",
|
|
62
|
+
"cpw_cbfs_owner_cap_token_signing_bytes",
|
|
63
|
+
"cpw_cbfs_delegated_request_signing_bytes",
|
|
64
|
+
"cpw_cbfs_relay_usage_signing_bytes",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class CowboyProtocolError(Exception):
|
|
69
|
+
"""Raised when a wasm codec call returns the error sentinel.
|
|
70
|
+
|
|
71
|
+
The message is the UTF-8 string retrieved from ``cpw_last_error`` (e.g.
|
|
72
|
+
``"invalid transaction JSON: ..."`` or ``"invalid secp256k1 private
|
|
73
|
+
key: ..."``).
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _wasm_bytes() -> bytes:
|
|
78
|
+
"""Read the bundled ``.wasm`` from package data."""
|
|
79
|
+
# Chain single-arg joinpath calls: multi-arg Traversable.joinpath(*parts) is
|
|
80
|
+
# only guaranteed on 3.11+, but this package supports 3.10+ (a zip/namespace
|
|
81
|
+
# import on 3.10 would TypeError on the multi-arg form).
|
|
82
|
+
return (
|
|
83
|
+
importlib.resources.files(__package__)
|
|
84
|
+
.joinpath("_wasm")
|
|
85
|
+
.joinpath(WASM_FILENAME)
|
|
86
|
+
.read_bytes()
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class _Host:
|
|
91
|
+
"""A loaded wasm instance plus typed handles to its flat ABI.
|
|
92
|
+
|
|
93
|
+
All public methods take/return raw ``bytes`` (or ``str`` for the error
|
|
94
|
+
path); the higher-level Pythonic API lives in :mod:`cowboy_protocol`.
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
def __init__(self) -> None:
|
|
98
|
+
engine = Engine()
|
|
99
|
+
module = Module(engine, _wasm_bytes())
|
|
100
|
+
|
|
101
|
+
# No-JS-imports invariant: a non-JS wasm host provides NO imports. The
|
|
102
|
+
# module must declare none (any __wbindgen_* / getrandom-js import would
|
|
103
|
+
# appear here and would make the empty-import instantiation below fail).
|
|
104
|
+
imports = list(module.imports)
|
|
105
|
+
if imports:
|
|
106
|
+
names = [f"{i.module}::{i.name}" for i in imports]
|
|
107
|
+
raise CowboyProtocolError(
|
|
108
|
+
"ffi wasm must have NO imports (no JS/wasm-bindgen host "
|
|
109
|
+
f"coupling); found: {names}"
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
self._store = Store(engine)
|
|
113
|
+
instance = Instance(self._store, module, [])
|
|
114
|
+
exports = instance.exports(self._store)
|
|
115
|
+
|
|
116
|
+
self._memory = exports["memory"]
|
|
117
|
+
self._alloc = exports["cpw_alloc"]
|
|
118
|
+
self._free = exports["cpw_free"]
|
|
119
|
+
self._last_error = exports["cpw_last_error"]
|
|
120
|
+
self._codec = {name: exports[name] for name in _CODEC_FNS}
|
|
121
|
+
self._lock = threading.Lock()
|
|
122
|
+
|
|
123
|
+
# ── linear-memory helpers ────────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
def _read_mem(self, ptr: int, length: int) -> bytes:
|
|
126
|
+
"""Copy ``[ptr, ptr+length)`` out of wasm linear memory."""
|
|
127
|
+
if length == 0:
|
|
128
|
+
return b""
|
|
129
|
+
data = self._memory.read(self._store, ptr, ptr + length)
|
|
130
|
+
return bytes(data)
|
|
131
|
+
|
|
132
|
+
def _write_input(self, data: bytes) -> int:
|
|
133
|
+
"""Allocate a wasm buffer and copy ``data`` into it; return its ptr.
|
|
134
|
+
|
|
135
|
+
Returns 0 for empty input (matching ``cpw_alloc(0) == 0``); the codec
|
|
136
|
+
fns treat ``(0, 0)`` as an empty slice.
|
|
137
|
+
"""
|
|
138
|
+
if not data:
|
|
139
|
+
return 0
|
|
140
|
+
ptr = self._alloc(self._store, len(data))
|
|
141
|
+
if ptr == 0:
|
|
142
|
+
raise CowboyProtocolError(
|
|
143
|
+
f"cpw_alloc returned null for a {len(data)}-byte input (OOM?)"
|
|
144
|
+
)
|
|
145
|
+
self._memory.write(self._store, data, ptr)
|
|
146
|
+
return ptr
|
|
147
|
+
|
|
148
|
+
def _take_last_error(self) -> str:
|
|
149
|
+
"""Read + free the pending ``cpw_last_error`` message (or a fallback)."""
|
|
150
|
+
packed = self._last_error(self._store)
|
|
151
|
+
ptr = (packed >> 32) & 0xFFFF_FFFF
|
|
152
|
+
length = packed & 0xFFFF_FFFF
|
|
153
|
+
if ptr == 0:
|
|
154
|
+
return "<no error message>"
|
|
155
|
+
msg = self._read_mem(ptr, length)
|
|
156
|
+
self._free(self._store, ptr, length)
|
|
157
|
+
return msg.decode("utf-8", errors="replace")
|
|
158
|
+
|
|
159
|
+
# ── the alloc / write / call / read / free dance ─────────────────────────
|
|
160
|
+
|
|
161
|
+
def call(self, func: str, data: bytes) -> bytes:
|
|
162
|
+
"""Invoke a codec export and return its raw output bytes.
|
|
163
|
+
|
|
164
|
+
Handles the full ABI dance: alloc + write the input, call, read the
|
|
165
|
+
packed return, copy the output out, and free BOTH buffers (in a
|
|
166
|
+
``finally`` so they are reclaimed even if a step raises). On the error
|
|
167
|
+
sentinel (``out_ptr == 0``) reads ``cpw_last_error`` and raises
|
|
168
|
+
:class:`CowboyProtocolError`.
|
|
169
|
+
"""
|
|
170
|
+
fn = self._codec[func]
|
|
171
|
+
with self._lock:
|
|
172
|
+
in_ptr = self._write_input(data)
|
|
173
|
+
in_len = len(data)
|
|
174
|
+
try:
|
|
175
|
+
packed = fn(self._store, in_ptr, in_len)
|
|
176
|
+
finally:
|
|
177
|
+
if in_ptr != 0:
|
|
178
|
+
self._free(self._store, in_ptr, in_len)
|
|
179
|
+
|
|
180
|
+
out_ptr = (packed >> 32) & 0xFFFF_FFFF
|
|
181
|
+
out_len = packed & 0xFFFF_FFFF
|
|
182
|
+
if out_ptr == 0:
|
|
183
|
+
raise CowboyProtocolError(self._take_last_error())
|
|
184
|
+
try:
|
|
185
|
+
return self._read_mem(out_ptr, out_len)
|
|
186
|
+
finally:
|
|
187
|
+
self._free(self._store, out_ptr, out_len)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# A single process-wide host instance, created lazily on first use. The wasm is
|
|
191
|
+
# single-threaded and the host serializes calls under a lock, so one instance is
|
|
192
|
+
# correct and avoids re-reading/compiling the module per call.
|
|
193
|
+
_HOST: _Host | None = None
|
|
194
|
+
_HOST_LOCK = threading.Lock()
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def host() -> _Host:
|
|
198
|
+
"""Return the process-wide :class:`_Host`, instantiating it on first call."""
|
|
199
|
+
global _HOST
|
|
200
|
+
if _HOST is None:
|
|
201
|
+
with _HOST_LOCK:
|
|
202
|
+
if _HOST is None:
|
|
203
|
+
_HOST = _Host()
|
|
204
|
+
return _HOST
|