actproof 0.2.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.
- actproof/__init__.py +234 -0
- actproof/anchor.py +586 -0
- actproof/canonical.py +369 -0
- actproof/catalogue.py +1031 -0
- actproof/cli.py +593 -0
- actproof/manifest.py +728 -0
- actproof/receipt.py +678 -0
- actproof/signers/__init__.py +89 -0
- actproof/signers/google_kms.py +392 -0
- actproof/signers/interface.py +298 -0
- actproof/signers/mnemonic.py +153 -0
- actproof/timestamp.py +527 -0
- actproof/verify.py +683 -0
- actproof-0.2.0.dist-info/METADATA +295 -0
- actproof-0.2.0.dist-info/RECORD +18 -0
- actproof-0.2.0.dist-info/WHEEL +4 -0
- actproof-0.2.0.dist-info/entry_points.txt +2 -0
- actproof-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Deyan Paroushev
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
"""
|
|
4
|
+
Signer implementations for actproof anchoring.
|
|
5
|
+
|
|
6
|
+
A signer holds an Algorand Ed25519 private key (or a reference to one in
|
|
7
|
+
external custody) and signs transactions built by ``actproof.anchor``.
|
|
8
|
+
Every concrete signer subclasses ``AlgorandSigner`` so the contract
|
|
9
|
+
("transactions only, never raw bytes") is structurally enforced.
|
|
10
|
+
|
|
11
|
+
What's in this package
|
|
12
|
+
----------------------
|
|
13
|
+
|
|
14
|
+
* ``AlgorandSigner`` (in ``interface.py``) - the abstract base class.
|
|
15
|
+
Concrete signers MUST subclass it. ``__init_subclass__`` raises
|
|
16
|
+
``TypeError`` if a subclass adds any method whose name suggests
|
|
17
|
+
raw-byte signing (the full forbidden-name list is the module constant
|
|
18
|
+
``FORBIDDEN_METHOD_NAMES``).
|
|
19
|
+
|
|
20
|
+
* ``MnemonicSigner`` (in ``mnemonic.py``) - holds an Algorand mnemonic
|
|
21
|
+
in process memory and signs locally. **Testing only.** Emits a loud
|
|
22
|
+
``UserWarning`` on construction. Production keys belong in an HSM.
|
|
23
|
+
|
|
24
|
+
* ``GoogleKMSSigner`` (in ``google_kms.py``) - production-grade signer
|
|
25
|
+
for users who hold their Ed25519 key in Google Cloud KMS. KMS supports
|
|
26
|
+
Ed25519 natively via the ``EC_SIGN_ED25519`` algorithm; the private
|
|
27
|
+
key never leaves the KMS HSM. Requires the optional ``[gcp]`` install
|
|
28
|
+
extra (``pip install 'actproof[gcp]'``).
|
|
29
|
+
|
|
30
|
+
What's NOT in this package
|
|
31
|
+
--------------------------
|
|
32
|
+
|
|
33
|
+
AWS KMS does not support Ed25519 directly (only RSA and SEC ECC curves
|
|
34
|
+
P-256/P-384/P-521 plus secp256k1). AWS users with HSM-grade requirements
|
|
35
|
+
need either AWS CloudHSM (where Ed25519 is supported) or an envelope-
|
|
36
|
+
encryption pattern (Ed25519 key encrypted by AWS KMS at rest, decrypted
|
|
37
|
+
into process memory at runtime - sacrifices the HSM-residency guarantee).
|
|
38
|
+
Either path is out-of-scope for v0.0.8; AWS users implement their own
|
|
39
|
+
``AlgorandSigner`` subclass against their preferred backend.
|
|
40
|
+
|
|
41
|
+
Azure Key Vault and HashiCorp Vault similarly: write your own subclass
|
|
42
|
+
against their SDKs. The ABC's enforcement does the security work
|
|
43
|
+
regardless of backend.
|
|
44
|
+
|
|
45
|
+
Example
|
|
46
|
+
-------
|
|
47
|
+
|
|
48
|
+
::
|
|
49
|
+
|
|
50
|
+
from actproof.signers import MnemonicSigner
|
|
51
|
+
from actproof import anchor_manifest, AnchorMode
|
|
52
|
+
|
|
53
|
+
signer = MnemonicSigner("...25 words separated by spaces...")
|
|
54
|
+
record = anchor_manifest(
|
|
55
|
+
manifest_hash,
|
|
56
|
+
signer=signer,
|
|
57
|
+
mode=AnchorMode.DEMO, # testnet
|
|
58
|
+
)
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
from __future__ import annotations
|
|
62
|
+
|
|
63
|
+
from actproof.signers.interface import (
|
|
64
|
+
FORBIDDEN_METHOD_NAMES,
|
|
65
|
+
AlgorandSigner,
|
|
66
|
+
SignerValidationError,
|
|
67
|
+
)
|
|
68
|
+
from actproof.signers.mnemonic import MnemonicSigner
|
|
69
|
+
|
|
70
|
+
# Optional: GoogleKMSSigner requires google-cloud-kms. Import is best-effort;
|
|
71
|
+
# if the GCP libraries are not installed, GoogleKMSSigner stays None and
|
|
72
|
+
# attempting to use it raises a clear error.
|
|
73
|
+
try:
|
|
74
|
+
from actproof.signers.google_kms import GoogleKMSSigner
|
|
75
|
+
_GOOGLE_KMS_AVAILABLE: bool = True
|
|
76
|
+
_GOOGLE_KMS_ERROR: str | None = None
|
|
77
|
+
except Exception as exc: # noqa: BLE001
|
|
78
|
+
_GOOGLE_KMS_AVAILABLE = False
|
|
79
|
+
_GOOGLE_KMS_ERROR = str(exc)
|
|
80
|
+
GoogleKMSSigner = None # type: ignore[assignment,misc]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
__all__ = [
|
|
84
|
+
"AlgorandSigner",
|
|
85
|
+
"MnemonicSigner",
|
|
86
|
+
"GoogleKMSSigner",
|
|
87
|
+
"FORBIDDEN_METHOD_NAMES",
|
|
88
|
+
"SignerValidationError",
|
|
89
|
+
]
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Deyan Paroushev
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
"""
|
|
4
|
+
GoogleKMSSigner: Algorand signer backed by Google Cloud KMS.
|
|
5
|
+
|
|
6
|
+
The private Ed25519 key never leaves the GCP HSM. This signer holds a
|
|
7
|
+
reference to the key version resource path and uses the KMS client to:
|
|
8
|
+
|
|
9
|
+
1. Fetch the SPKI-PEM-encoded Ed25519 public key (once, lazily, on first
|
|
10
|
+
``address`` access).
|
|
11
|
+
2. Derive the 58-character Algorand address from the raw 32-byte public key.
|
|
12
|
+
3. Invoke ``asymmetric_sign`` for each transaction signature.
|
|
13
|
+
|
|
14
|
+
The key never exits KMS; the operator cannot extract it. This is the
|
|
15
|
+
defense-in-depth posture actproof recommends for production anchoring
|
|
16
|
+
of compliance evidence.
|
|
17
|
+
|
|
18
|
+
Optional dependency
|
|
19
|
+
-------------------
|
|
20
|
+
|
|
21
|
+
This module requires the ``[gcp]`` install extra::
|
|
22
|
+
|
|
23
|
+
pip install 'actproof[gcp]'
|
|
24
|
+
|
|
25
|
+
which pulls in ``google-cloud-kms`` (the KMS client) and ``google-crc32c``
|
|
26
|
+
(integrity checking on KMS request/response). If those packages are not
|
|
27
|
+
installed, instantiating ``GoogleKMSSigner`` raises ``RuntimeError`` with
|
|
28
|
+
the installation command.
|
|
29
|
+
|
|
30
|
+
Other clouds (AWS, Azure, Vault)
|
|
31
|
+
--------------------------------
|
|
32
|
+
|
|
33
|
+
AWS KMS does NOT support Ed25519 directly (only RSA and SEC ECC curves
|
|
34
|
+
P-256/P-384/P-521 plus secp256k1). AWS users with HSM-grade requirements
|
|
35
|
+
need either AWS CloudHSM (where Ed25519 is supported) or an envelope-
|
|
36
|
+
encryption pattern. Either path is out-of-scope for actproof's bundled
|
|
37
|
+
signers; AWS users write their own ``AlgorandSigner`` subclass against
|
|
38
|
+
their preferred backend.
|
|
39
|
+
|
|
40
|
+
Azure Key Vault and HashiCorp Vault: write your own subclass against
|
|
41
|
+
their SDKs. The ``AlgorandSigner`` ABC's enforcement does the security
|
|
42
|
+
work regardless of backend.
|
|
43
|
+
|
|
44
|
+
Why ``data`` and not ``digest`` for Ed25519
|
|
45
|
+
-------------------------------------------
|
|
46
|
+
|
|
47
|
+
The KMS ``AsymmetricSignRequest`` has both a ``data`` field and a
|
|
48
|
+
``digest`` field. For RSA and ECDSA signing modes, callers typically
|
|
49
|
+
pre-hash and pass the digest. For Ed25519 in PureEdDSA mode, the
|
|
50
|
+
algorithm itself does the hashing as part of the signing operation, so
|
|
51
|
+
callers MUST pass the raw bytes via the ``data`` field. Passing
|
|
52
|
+
``digest`` for an Ed25519 key returns ``INVALID_ARGUMENT`` from KMS.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
from __future__ import annotations
|
|
56
|
+
|
|
57
|
+
import base64
|
|
58
|
+
import warnings
|
|
59
|
+
from typing import Any, Optional
|
|
60
|
+
|
|
61
|
+
from actproof.signers.interface import AlgorandSigner, SignerValidationError
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
__all__ = ["GoogleKMSSigner"]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# ─────────────────────────────────────────────────────────────────
|
|
68
|
+
# OPTIONAL IMPORTS: google-cloud-kms and google-crc32c
|
|
69
|
+
# ─────────────────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
_INSTALL_HINT = (
|
|
72
|
+
"Install with: pip install 'actproof[gcp]' "
|
|
73
|
+
"(adds google-cloud-kms and google-crc32c)."
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
from google.cloud import kms_v1 as _kms_v1
|
|
78
|
+
_KMS_AVAILABLE: bool = True
|
|
79
|
+
_KMS_ERROR: Optional[str] = None
|
|
80
|
+
except Exception as exc: # noqa: BLE001
|
|
81
|
+
_KMS_AVAILABLE = False
|
|
82
|
+
_KMS_ERROR = str(exc)
|
|
83
|
+
_kms_v1 = None # type: ignore[assignment,misc]
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
import google_crc32c as _google_crc32c
|
|
87
|
+
_CRC_AVAILABLE: bool = True
|
|
88
|
+
_CRC_ERROR: Optional[str] = None
|
|
89
|
+
except Exception as exc: # noqa: BLE001
|
|
90
|
+
_CRC_AVAILABLE = False
|
|
91
|
+
_CRC_ERROR = str(exc)
|
|
92
|
+
_google_crc32c = None # type: ignore[assignment,misc]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# ─────────────────────────────────────────────────────────────────
|
|
96
|
+
# CONSTANTS
|
|
97
|
+
# ─────────────────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
ALGORAND_SIGN_PREFIX: bytes = b"TX"
|
|
100
|
+
"""Algorand's canonical signing prefix. Every Ed25519 transaction signature
|
|
101
|
+
covers ``ALGORAND_SIGN_PREFIX + msgpack_encode(txn)``."""
|
|
102
|
+
|
|
103
|
+
ED25519_PUBKEY_LEN: int = 32
|
|
104
|
+
"""Ed25519 raw public key size in bytes."""
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# ─────────────────────────────────────────────────────────────────
|
|
108
|
+
# THE SIGNER CLASS
|
|
109
|
+
# ─────────────────────────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
class GoogleKMSSigner(AlgorandSigner):
|
|
112
|
+
"""Algorand signer backed by Google Cloud KMS Ed25519 keys.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
kms_resource_name: Full resource path to the KMS Ed25519 key
|
|
116
|
+
version. Example: ``projects/actproof-prod/locations/
|
|
117
|
+
europe-west4/keyRings/anchoring/cryptoKeys/anchor-signer-v1/
|
|
118
|
+
cryptoKeyVersions/1``.
|
|
119
|
+
kms_client: Optional ``KeyManagementServiceClient`` for test
|
|
120
|
+
injection. If ``None``, a default client is constructed using
|
|
121
|
+
Application Default Credentials (set via
|
|
122
|
+
``gcloud auth application-default login`` for local dev, or
|
|
123
|
+
via workload identity on GKE).
|
|
124
|
+
|
|
125
|
+
Raises:
|
|
126
|
+
RuntimeError: If ``google-cloud-kms`` or ``google-crc32c`` is not
|
|
127
|
+
installed (the ``[gcp]`` extra is required).
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
def __init__(
|
|
131
|
+
self,
|
|
132
|
+
kms_resource_name: str,
|
|
133
|
+
kms_client: Optional[Any] = None,
|
|
134
|
+
) -> None:
|
|
135
|
+
if not _KMS_AVAILABLE:
|
|
136
|
+
raise RuntimeError(
|
|
137
|
+
f"google-cloud-kms is required for GoogleKMSSigner but is "
|
|
138
|
+
f"not importable: {_KMS_ERROR}. {_INSTALL_HINT}"
|
|
139
|
+
)
|
|
140
|
+
if not _CRC_AVAILABLE:
|
|
141
|
+
raise RuntimeError(
|
|
142
|
+
f"google-crc32c is required for GoogleKMSSigner but is "
|
|
143
|
+
f"not importable: {_CRC_ERROR}. {_INSTALL_HINT}"
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
if not kms_resource_name or "cryptoKeyVersions/" not in kms_resource_name:
|
|
147
|
+
raise ValueError(
|
|
148
|
+
f"kms_resource_name must be a full KMS key version path "
|
|
149
|
+
f"(must contain 'cryptoKeyVersions/'), got "
|
|
150
|
+
f"{kms_resource_name!r}"
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
self._kms_resource_name: str = kms_resource_name
|
|
154
|
+
self._kms_client = kms_client or _kms_v1.KeyManagementServiceClient()
|
|
155
|
+
self._cached_raw_pubkey: Optional[bytes] = None
|
|
156
|
+
self._cached_address: Optional[str] = None
|
|
157
|
+
|
|
158
|
+
# ─────────────────────────────────────────────────────────────
|
|
159
|
+
# Public surface (the only methods the AlgorandSigner ABC allows)
|
|
160
|
+
# ─────────────────────────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
@property
|
|
163
|
+
def address(self) -> str:
|
|
164
|
+
"""The 58-character Algorand address derived from the KMS key.
|
|
165
|
+
|
|
166
|
+
First access triggers a KMS ``get_public_key`` call; subsequent
|
|
167
|
+
accesses return the cached value.
|
|
168
|
+
"""
|
|
169
|
+
if self._cached_address is None:
|
|
170
|
+
raw_pubkey = self._fetch_raw_public_key()
|
|
171
|
+
self._cached_address = _derive_algorand_address(raw_pubkey)
|
|
172
|
+
return self._cached_address
|
|
173
|
+
|
|
174
|
+
def sign_transaction(self, txn: Any) -> Any:
|
|
175
|
+
"""Sign an Algorand transaction via Google Cloud KMS.
|
|
176
|
+
|
|
177
|
+
Validates the transaction (sender, receiver, amount, note prefix),
|
|
178
|
+
computes the canonical bytes-to-sign, sends them to KMS as ``data``
|
|
179
|
+
(not ``digest`` - Ed25519 hashes internally), validates the CRC32C
|
|
180
|
+
integrity codes on both the request and the response, and assembles
|
|
181
|
+
the ``SignedTransaction``.
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
txn: An ``algosdk.transaction.Transaction`` to sign.
|
|
185
|
+
|
|
186
|
+
Returns:
|
|
187
|
+
An ``algosdk.transaction.SignedTransaction``.
|
|
188
|
+
|
|
189
|
+
Raises:
|
|
190
|
+
SignerValidationError: If the transaction violates the signing
|
|
191
|
+
policy.
|
|
192
|
+
RuntimeError: If KMS returns an error or an integrity check
|
|
193
|
+
fails.
|
|
194
|
+
"""
|
|
195
|
+
self.validate_transaction(txn)
|
|
196
|
+
bytes_to_sign = self._compute_bytes_to_sign(txn)
|
|
197
|
+
raw_signature = self._kms_sign(bytes_to_sign)
|
|
198
|
+
return _assemble_signed_transaction(txn, raw_signature)
|
|
199
|
+
|
|
200
|
+
# ─────────────────────────────────────────────────────────────
|
|
201
|
+
# Internal: bytes-to-sign computation
|
|
202
|
+
# ─────────────────────────────────────────────────────────────
|
|
203
|
+
|
|
204
|
+
@staticmethod
|
|
205
|
+
def _compute_bytes_to_sign(txn: Any) -> bytes:
|
|
206
|
+
"""Compute the canonical bytes that Ed25519 will sign.
|
|
207
|
+
|
|
208
|
+
Per Algorand's signing rule::
|
|
209
|
+
|
|
210
|
+
bytes_to_sign = b"TX" || msgpack_bytes(txn)
|
|
211
|
+
|
|
212
|
+
``algosdk.encoding.msgpack_encode`` returns a BASE64-ENCODED
|
|
213
|
+
STRING, not raw msgpack bytes. We base64-decode to get the raw
|
|
214
|
+
bytes, then prepend the TX prefix. This mirrors what algosdk's
|
|
215
|
+
own ``Transaction.sign`` does internally.
|
|
216
|
+
"""
|
|
217
|
+
try:
|
|
218
|
+
from algosdk import encoding
|
|
219
|
+
except Exception as exc: # noqa: BLE001
|
|
220
|
+
raise RuntimeError(
|
|
221
|
+
f"py-algorand-sdk not importable: {exc}"
|
|
222
|
+
) from exc
|
|
223
|
+
|
|
224
|
+
msgpack_b64 = encoding.msgpack_encode(txn)
|
|
225
|
+
msgpack_bytes = base64.b64decode(msgpack_b64)
|
|
226
|
+
return ALGORAND_SIGN_PREFIX + msgpack_bytes
|
|
227
|
+
|
|
228
|
+
# ─────────────────────────────────────────────────────────────
|
|
229
|
+
# Internal: KMS public key fetch
|
|
230
|
+
# ─────────────────────────────────────────────────────────────
|
|
231
|
+
|
|
232
|
+
def _fetch_raw_public_key(self) -> bytes:
|
|
233
|
+
"""Fetch the SPKI-PEM-encoded Ed25519 public key from KMS,
|
|
234
|
+
validate the CRC32C, and extract the raw 32-byte public key.
|
|
235
|
+
"""
|
|
236
|
+
if self._cached_raw_pubkey is not None:
|
|
237
|
+
return self._cached_raw_pubkey
|
|
238
|
+
|
|
239
|
+
try:
|
|
240
|
+
response = self._kms_client.get_public_key(
|
|
241
|
+
request={"name": self._kms_resource_name}
|
|
242
|
+
)
|
|
243
|
+
except Exception as exc: # noqa: BLE001
|
|
244
|
+
raise RuntimeError(
|
|
245
|
+
f"KMS get_public_key failed for {self._kms_resource_name}: "
|
|
246
|
+
f"{exc}"
|
|
247
|
+
) from exc
|
|
248
|
+
|
|
249
|
+
# CRC32C integrity check on the response PEM bytes.
|
|
250
|
+
pem = response.pem
|
|
251
|
+
pem_bytes = pem.encode("utf-8") if isinstance(pem, str) else pem
|
|
252
|
+
if hasattr(response, "pem_crc32c") and response.pem_crc32c:
|
|
253
|
+
computed = int(_google_crc32c.value(pem_bytes))
|
|
254
|
+
expected = int(response.pem_crc32c)
|
|
255
|
+
if computed != expected:
|
|
256
|
+
raise RuntimeError(
|
|
257
|
+
f"KMS get_public_key response CRC32C mismatch: "
|
|
258
|
+
f"computed {computed}, expected {expected}. The "
|
|
259
|
+
f"response may have been corrupted in transit."
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
raw_pubkey = _extract_raw_ed25519_from_pem(pem_bytes)
|
|
263
|
+
self._cached_raw_pubkey = raw_pubkey
|
|
264
|
+
return raw_pubkey
|
|
265
|
+
|
|
266
|
+
# ─────────────────────────────────────────────────────────────
|
|
267
|
+
# Internal: KMS asymmetric_sign call
|
|
268
|
+
# ─────────────────────────────────────────────────────────────
|
|
269
|
+
|
|
270
|
+
def _kms_sign(self, bytes_to_sign: bytes) -> bytes:
|
|
271
|
+
"""Call KMS ``asymmetric_sign`` and return the raw 64-byte
|
|
272
|
+
Ed25519 signature.
|
|
273
|
+
|
|
274
|
+
Sends ``bytes_to_sign`` as ``data``, never as ``digest``.
|
|
275
|
+
Validates the CRC32C integrity code on both the request and the
|
|
276
|
+
response.
|
|
277
|
+
"""
|
|
278
|
+
request_crc = int(_google_crc32c.value(bytes_to_sign))
|
|
279
|
+
|
|
280
|
+
try:
|
|
281
|
+
response = self._kms_client.asymmetric_sign(
|
|
282
|
+
request={
|
|
283
|
+
"name": self._kms_resource_name,
|
|
284
|
+
"data": bytes_to_sign,
|
|
285
|
+
"data_crc32c": request_crc,
|
|
286
|
+
}
|
|
287
|
+
)
|
|
288
|
+
except Exception as exc: # noqa: BLE001
|
|
289
|
+
raise RuntimeError(
|
|
290
|
+
f"KMS asymmetric_sign failed for "
|
|
291
|
+
f"{self._kms_resource_name}: {exc}"
|
|
292
|
+
) from exc
|
|
293
|
+
|
|
294
|
+
# KMS confirms it received our request CRC matching.
|
|
295
|
+
if hasattr(response, "verified_data_crc32c") and not response.verified_data_crc32c:
|
|
296
|
+
raise RuntimeError(
|
|
297
|
+
"KMS reports request data CRC32C did not match; request "
|
|
298
|
+
"may have been corrupted in transit."
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
# We verify the response signature CRC.
|
|
302
|
+
signature = response.signature
|
|
303
|
+
if hasattr(response, "signature_crc32c") and response.signature_crc32c:
|
|
304
|
+
computed = int(_google_crc32c.value(signature))
|
|
305
|
+
expected = int(response.signature_crc32c)
|
|
306
|
+
if computed != expected:
|
|
307
|
+
raise RuntimeError(
|
|
308
|
+
f"KMS asymmetric_sign response signature CRC32C "
|
|
309
|
+
f"mismatch: computed {computed}, expected {expected}."
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
return signature
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
# ─────────────────────────────────────────────────────────────────
|
|
316
|
+
# HELPER: Derive Algorand address from raw Ed25519 public key
|
|
317
|
+
# ─────────────────────────────────────────────────────────────────
|
|
318
|
+
|
|
319
|
+
def _derive_algorand_address(raw_pubkey: bytes) -> str:
|
|
320
|
+
"""Derive the 58-character Algorand address from a raw Ed25519 public key.
|
|
321
|
+
|
|
322
|
+
The Algorand address is base32(pubkey || checksum), where the checksum
|
|
323
|
+
is the last 4 bytes of SHA-512/256(pubkey). algosdk has the helper.
|
|
324
|
+
"""
|
|
325
|
+
if len(raw_pubkey) != ED25519_PUBKEY_LEN:
|
|
326
|
+
raise ValueError(
|
|
327
|
+
f"Expected {ED25519_PUBKEY_LEN}-byte Ed25519 public key, "
|
|
328
|
+
f"got {len(raw_pubkey)} bytes"
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
try:
|
|
332
|
+
from algosdk import encoding
|
|
333
|
+
except Exception as exc: # noqa: BLE001
|
|
334
|
+
raise RuntimeError(
|
|
335
|
+
f"py-algorand-sdk not importable: {exc}"
|
|
336
|
+
) from exc
|
|
337
|
+
|
|
338
|
+
return encoding.encode_address(raw_pubkey)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
# ─────────────────────────────────────────────────────────────────
|
|
342
|
+
# HELPER: Extract raw Ed25519 public key bytes from SPKI PEM
|
|
343
|
+
# ─────────────────────────────────────────────────────────────────
|
|
344
|
+
|
|
345
|
+
def _extract_raw_ed25519_from_pem(pem_bytes: bytes) -> bytes:
|
|
346
|
+
"""Parse an SPKI-PEM-encoded Ed25519 public key and return its 32-byte
|
|
347
|
+
raw form.
|
|
348
|
+
"""
|
|
349
|
+
try:
|
|
350
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
|
351
|
+
Ed25519PublicKey,
|
|
352
|
+
)
|
|
353
|
+
from cryptography.hazmat.primitives.serialization import (
|
|
354
|
+
Encoding,
|
|
355
|
+
PublicFormat,
|
|
356
|
+
load_pem_public_key,
|
|
357
|
+
)
|
|
358
|
+
except Exception as exc: # noqa: BLE001
|
|
359
|
+
raise RuntimeError(
|
|
360
|
+
f"cryptography library not importable: {exc}"
|
|
361
|
+
) from exc
|
|
362
|
+
|
|
363
|
+
pubkey = load_pem_public_key(pem_bytes)
|
|
364
|
+
if not isinstance(pubkey, Ed25519PublicKey):
|
|
365
|
+
raise RuntimeError(
|
|
366
|
+
f"KMS returned a non-Ed25519 public key "
|
|
367
|
+
f"({type(pubkey).__name__}); the key version may have the "
|
|
368
|
+
f"wrong algorithm. Algorand requires Ed25519."
|
|
369
|
+
)
|
|
370
|
+
return pubkey.public_bytes(
|
|
371
|
+
encoding=Encoding.Raw,
|
|
372
|
+
format=PublicFormat.Raw,
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
# ─────────────────────────────────────────────────────────────────
|
|
377
|
+
# HELPER: Assemble a SignedTransaction from txn + raw signature
|
|
378
|
+
# ─────────────────────────────────────────────────────────────────
|
|
379
|
+
|
|
380
|
+
def _assemble_signed_transaction(txn: Any, raw_signature: bytes) -> Any:
|
|
381
|
+
"""Build an ``algosdk.transaction.SignedTransaction`` from the
|
|
382
|
+
transaction and the 64-byte Ed25519 signature."""
|
|
383
|
+
try:
|
|
384
|
+
from algosdk.transaction import SignedTransaction
|
|
385
|
+
except Exception as exc: # noqa: BLE001
|
|
386
|
+
raise RuntimeError(
|
|
387
|
+
f"py-algorand-sdk not importable: {exc}"
|
|
388
|
+
) from exc
|
|
389
|
+
|
|
390
|
+
# algosdk's SignedTransaction expects the signature as a base64 string.
|
|
391
|
+
signature_b64 = base64.b64encode(raw_signature).decode("ascii")
|
|
392
|
+
return SignedTransaction(txn, signature_b64)
|