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.
@@ -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)