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,298 @@
1
+ # SPDX-FileCopyrightText: 2026 Deyan Paroushev
2
+ # SPDX-License-Identifier: MIT
3
+ """
4
+ Abstract base class for Algorand signers in actproof.
5
+
6
+ The structural invariant: every signer subclass signs ONLY Algorand
7
+ transactions, never arbitrary bytes. This module enforces that invariant
8
+ at class-definition time via ``__init_subclass__``; a subclass that
9
+ defines a method named ``sign_bytes`` (or any similar raw-byte path)
10
+ fails to construct, and the program that tried to import it fails to
11
+ start. The desired property is "security regression cannot land latent."
12
+
13
+ Why this matters
14
+ ----------------
15
+
16
+ If the Ed25519 anchoring key is held in HSM-backed KMS, the operator
17
+ (the platform, the SRE on call) cannot extract the key material. KMS
18
+ itself, however, will happily sign any byte sequence if asked. The
19
+ defense-in-depth move is that the signer adapter, the only piece of
20
+ actproof code that holds a reference to the KMS client, MUST NEVER
21
+ expose a code path that calls the underlying sign with anything other
22
+ than a properly-built Algorand transaction.
23
+
24
+ A future refactor that adds ``sign_bytes`` because "it would be useful
25
+ for one thing" would silently expose every Algorand key the signer
26
+ holds. ``__init_subclass__`` makes that refactor impossible: the
27
+ module that defines the offending subclass fails to import, the test
28
+ suite fails red, the CI fails the deploy. The error is loud and
29
+ correct, instead of quiet and dangerous.
30
+
31
+ Concrete subclasses
32
+ -------------------
33
+
34
+ * ``MnemonicSigner`` (testing only) holds a 25-word mnemonic in process
35
+ memory.
36
+ * ``GoogleKMSSigner`` (production for GCP users) holds a reference to a
37
+ KMS key version resource path; the key itself stays in the HSM.
38
+ * User-supplied subclasses for AWS CloudHSM, Azure Key Vault, HashiCorp
39
+ Vault, etc. As long as the subclass extends ``AlgorandSigner`` and
40
+ implements ``address`` plus ``sign_transaction``, it satisfies the
41
+ contract.
42
+
43
+ Validation
44
+ ----------
45
+
46
+ The ABC also provides ``validate_transaction(txn)`` which concrete
47
+ subclasses MUST call before signing. The default validation checks:
48
+
49
+ 1. ``txn`` is an ``algosdk.transaction.Transaction`` instance.
50
+ 2. ``txn.sender`` equals ``self.address``.
51
+ 3. ``txn.receiver`` equals ``txn.sender`` (0-Algo self-payment pattern).
52
+ 4. ``txn.amt`` equals zero.
53
+ 5. ``txn.note`` is bytes and starts with the actproof ARC-2 prefix
54
+ (``b"actproof:j"``).
55
+
56
+ Subclasses can override ``validate_transaction`` to add additional
57
+ checks (fee bounds, network ID matching, etc.); they should ``super()``
58
+ into the base check or duplicate its logic.
59
+
60
+ References
61
+ ----------
62
+
63
+ This module is the structural ancestor of every other signer in
64
+ actproof. The validation policy is the security ancestor of every
65
+ on-chain commitment the library produces.
66
+ """
67
+
68
+ from __future__ import annotations
69
+
70
+ from abc import ABC, abstractmethod
71
+ from typing import Any, Final, FrozenSet
72
+
73
+
74
+ __all__ = [
75
+ "AlgorandSigner",
76
+ "FORBIDDEN_METHOD_NAMES",
77
+ "SignerValidationError",
78
+ ]
79
+
80
+
81
+ # ─────────────────────────────────────────────────────────────────
82
+ # FORBIDDEN METHOD NAMES
83
+ # ─────────────────────────────────────────────────────────────────
84
+
85
+ FORBIDDEN_METHOD_NAMES: Final[FrozenSet[str]] = frozenset({
86
+ "sign_bytes",
87
+ "sign_data",
88
+ "sign_raw",
89
+ "sign_message",
90
+ "sign_digest",
91
+ "sign",
92
+ "raw_sign",
93
+ "asymmetric_sign",
94
+ })
95
+ """Method names that ``AlgorandSigner`` subclasses MUST NOT define.
96
+
97
+ Each represents a way to expose raw-byte signing on top of the typed
98
+ transaction signing path. The list is conservative: if a method name looks
99
+ like it could lure a future developer into bypassing the transaction-only
100
+ discipline, it is here.
101
+ """
102
+
103
+
104
+ # ─────────────────────────────────────────────────────────────────
105
+ # EXCEPTIONS
106
+ # ─────────────────────────────────────────────────────────────────
107
+
108
+ class SignerValidationError(ValueError):
109
+ """Raised by ``AlgorandSigner.validate_transaction`` on policy violation.
110
+
111
+ Subclass of ``ValueError`` so callers can catch ``ValueError`` if they
112
+ prefer unified handling, or ``SignerValidationError`` specifically to
113
+ distinguish signer-policy errors from other input-validation errors.
114
+ """
115
+
116
+
117
+ # ─────────────────────────────────────────────────────────────────
118
+ # CONSTANTS USED IN DEFAULT VALIDATION
119
+ # ─────────────────────────────────────────────────────────────────
120
+
121
+ _ACTPROOF_NOTE_PREFIX: Final[bytes] = b"actproof:j"
122
+
123
+
124
+ # ─────────────────────────────────────────────────────────────────
125
+ # THE ABSTRACT BASE CLASS
126
+ # ─────────────────────────────────────────────────────────────────
127
+
128
+ class AlgorandSigner(ABC):
129
+ """Abstract Algorand transaction signer for actproof.
130
+
131
+ Concrete subclasses provide the cryptographic backend (mnemonic, KMS,
132
+ HSM, etc.). This base class enforces two structural rules at the level
133
+ of the class hierarchy:
134
+
135
+ 1. The only signing-related method exposed on the public interface
136
+ is ``sign_transaction``, which accepts a fully-built
137
+ ``algosdk.transaction.Transaction`` object.
138
+ 2. Subclasses cannot add raw-byte signing methods. Defining any
139
+ method whose name appears in ``FORBIDDEN_METHOD_NAMES`` raises
140
+ ``TypeError`` at the moment the subclass is defined (via
141
+ ``__init_subclass__``). Adding such a method later in a refactor
142
+ causes the import of the module to fail, which is the desired
143
+ behaviour: the program does not start, rather than the security
144
+ regression sitting latent.
145
+
146
+ Subclasses MUST also implement:
147
+
148
+ * ``address`` (property) - the 58-character base32 Algorand address.
149
+ * ``sign_transaction(txn)`` - validate the transaction (via
150
+ ``self.validate_transaction(txn)`` or equivalent) and return a
151
+ ``SignedTransaction``.
152
+ """
153
+
154
+ def __init_subclass__(cls, **kwargs: object) -> None:
155
+ """Reject any subclass that defines a forbidden method name.
156
+
157
+ Python calls this hook at the moment a subclass is created,
158
+ BEFORE any code that imports the subclass can use it. The check
159
+ inspects ``cls.__dict__`` (methods defined directly on this
160
+ subclass), not ``dir(cls)`` (which would also include inherited
161
+ methods). A subclass cannot accidentally inherit a forbidden
162
+ method from somewhere else and pass the check; the check is
163
+ about what THIS specific subclass introduces.
164
+
165
+ Raises:
166
+ TypeError: If any forbidden method name appears in the
167
+ subclass's own ``__dict__``. The error message names
168
+ every forbidden method actually found, plus lists the
169
+ full forbidden set so the developer can immediately see
170
+ which name they used and what the alternatives are.
171
+ """
172
+ super().__init_subclass__(**kwargs)
173
+ forbidden_in_subclass = FORBIDDEN_METHOD_NAMES & set(cls.__dict__.keys())
174
+ if forbidden_in_subclass:
175
+ raise TypeError(
176
+ f"{cls.__name__} defines forbidden method(s) "
177
+ f"{sorted(forbidden_in_subclass)}. AlgorandSigner subclasses "
178
+ f"MUST NOT expose any raw-byte signing surface. The only "
179
+ f"signing method allowed on this interface is "
180
+ f"sign_transaction(txn) -> SignedTransaction. "
181
+ f"Complete list of forbidden method names: "
182
+ f"{sorted(FORBIDDEN_METHOD_NAMES)}."
183
+ )
184
+
185
+ @property
186
+ @abstractmethod
187
+ def address(self) -> str:
188
+ """The 58-character base32 Algorand address this signer signs for.
189
+
190
+ Implementations may cache lazily (the first access triggers a key
191
+ derivation or remote call; subsequent accesses return the cached
192
+ value).
193
+ """
194
+ ...
195
+
196
+ @abstractmethod
197
+ def sign_transaction(self, txn: Any) -> Any:
198
+ """Sign an Algorand transaction.
199
+
200
+ Concrete implementations MUST call ``self.validate_transaction(txn)``
201
+ (or implement equivalent checks) before invoking the underlying
202
+ signing backend.
203
+
204
+ Args:
205
+ txn: An ``algosdk.transaction.Transaction``, typically a
206
+ ``PaymentTxn`` built by ``actproof.anchor.build_transaction``.
207
+
208
+ Returns:
209
+ An ``algosdk.transaction.SignedTransaction`` ready for
210
+ submission to algod.
211
+
212
+ Raises:
213
+ SignerValidationError: If the transaction violates the signing
214
+ policy (wrong sender, non-zero amount, missing or wrong
215
+ note prefix).
216
+ RuntimeError: If the underlying signing backend returns an
217
+ error.
218
+ """
219
+ ...
220
+
221
+ # ─────────────────────────────────────────────────────────────
222
+ # DEFAULT VALIDATION (subclasses MUST call this before signing)
223
+ # ─────────────────────────────────────────────────────────────
224
+
225
+ def validate_transaction(self, txn: Any) -> None:
226
+ """Validate an Algorand transaction against the actproof policy.
227
+
228
+ Concrete subclasses call this from inside ``sign_transaction``
229
+ before invoking the underlying signing backend. The default checks:
230
+
231
+ 1. ``txn`` is an ``algosdk.transaction.Transaction`` instance.
232
+ 2. ``txn.sender`` equals ``self.address``.
233
+ 3. ``txn.receiver`` equals ``txn.sender`` (0-Algo self-payment).
234
+ 4. ``txn.amt`` equals 0.
235
+ 5. ``txn.note`` is bytes and starts with ``b"actproof:j"``.
236
+
237
+ Subclasses can extend by overriding this method (call ``super()``
238
+ first, then add extra checks).
239
+
240
+ Args:
241
+ txn: The transaction to validate.
242
+
243
+ Raises:
244
+ SignerValidationError: On the first policy violation found.
245
+ """
246
+ # We avoid importing algosdk at module top to keep this file
247
+ # importable even if py-algorand-sdk has a transitive problem.
248
+ # The isinstance check happens dynamically.
249
+ try:
250
+ from algosdk.transaction import Transaction
251
+ except Exception as exc: # noqa: BLE001
252
+ raise SignerValidationError(
253
+ f"py-algorand-sdk not importable: {exc}"
254
+ ) from exc
255
+
256
+ if not isinstance(txn, Transaction):
257
+ raise SignerValidationError(
258
+ f"Expected algosdk Transaction, got {type(txn).__name__}"
259
+ )
260
+
261
+ # The sender must match this signer's address.
262
+ if getattr(txn, "sender", None) != self.address:
263
+ raise SignerValidationError(
264
+ f"Transaction sender {getattr(txn, 'sender', None)!r} does "
265
+ f"not match signer address {self.address!r}. The signer "
266
+ f"must own the sender's private key."
267
+ )
268
+
269
+ # Self-payment pattern: receiver equals sender.
270
+ if getattr(txn, "receiver", None) != self.address:
271
+ raise SignerValidationError(
272
+ f"Transaction receiver {getattr(txn, 'receiver', None)!r} "
273
+ f"does not equal sender (signer address {self.address!r}). "
274
+ f"actproof anchors use the 0-Algo self-payment pattern."
275
+ )
276
+
277
+ # Zero-value: actproof anchors never transfer Algos.
278
+ amount = getattr(txn, "amt", None)
279
+ if amount != 0:
280
+ raise SignerValidationError(
281
+ f"Transaction amount must be 0, got {amount}. actproof "
282
+ f"anchors are 0-Algo self-payments; the note field carries "
283
+ f"the commitment, not a value transfer."
284
+ )
285
+
286
+ # Note must start with the ARC-2 prefix.
287
+ note = getattr(txn, "note", None)
288
+ if not isinstance(note, (bytes, bytearray)):
289
+ raise SignerValidationError(
290
+ f"Transaction note must be bytes, got "
291
+ f"{type(note).__name__ if note is not None else 'None'}."
292
+ )
293
+ if not note.startswith(_ACTPROOF_NOTE_PREFIX):
294
+ raise SignerValidationError(
295
+ f"Transaction note must start with the actproof ARC-2 "
296
+ f"prefix {_ACTPROOF_NOTE_PREFIX!r}. Got prefix "
297
+ f"{bytes(note)[:32]!r}."
298
+ )
@@ -0,0 +1,153 @@
1
+ # SPDX-FileCopyrightText: 2026 Deyan Paroushev
2
+ # SPDX-License-Identifier: MIT
3
+ """
4
+ MnemonicSigner: Algorand signer that holds a mnemonic in process memory.
5
+
6
+ **For testing only.**
7
+
8
+ The 25-word Algorand mnemonic encodes the full Ed25519 private key. Once
9
+ the mnemonic is in process memory, the private key is in process memory.
10
+ Anything that can read process memory (a debugger, a crash dump, a core
11
+ file, a kernel exploit, another process running as the same user) can
12
+ extract the key. Production anchoring keys belong in a hardware security
13
+ module: AWS CloudHSM, GCP KMS, Azure Key Vault, HashiCorp Vault, YubiHSM,
14
+ or similar.
15
+
16
+ This signer is appropriate for:
17
+
18
+ * Unit tests and CI smoke tests.
19
+ * Testnet experimentation during early development.
20
+ * Local proof-of-concept work.
21
+ * Documentation examples.
22
+
23
+ It is NOT appropriate for:
24
+
25
+ * Mainnet anchoring of compliance evidence.
26
+ * Any deployment where the signing key has material economic or legal
27
+ consequences attached.
28
+
29
+ On construction, the signer emits a ``UserWarning`` to make the testing-
30
+ only stance loud and discoverable. Tests that legitimately use the
31
+ mnemonic signer should suppress the warning with ``warnings.simplefilter``
32
+ or ``warnings.catch_warnings``; the warning is the signal, not the noise.
33
+
34
+ Example
35
+ -------
36
+
37
+ ::
38
+
39
+ import warnings
40
+ from actproof.signers import MnemonicSigner
41
+
42
+ with warnings.catch_warnings():
43
+ warnings.simplefilter("ignore", category=UserWarning)
44
+ signer = MnemonicSigner(
45
+ "abandon abandon abandon abandon abandon abandon abandon abandon "
46
+ "abandon abandon abandon abandon abandon abandon abandon abandon "
47
+ "abandon abandon abandon abandon abandon abandon abandon abandon "
48
+ "abandon"
49
+ )
50
+ # ... use signer for testnet anchoring
51
+ """
52
+
53
+ from __future__ import annotations
54
+
55
+ import warnings
56
+ from typing import Any
57
+
58
+ from actproof.signers.interface import AlgorandSigner, SignerValidationError
59
+
60
+
61
+ __all__ = ["MnemonicSigner"]
62
+
63
+
64
+ _MNEMONIC_WARNING_MESSAGE = (
65
+ "MnemonicSigner holds the Algorand private key in process memory and "
66
+ "is for testing only. Do NOT use for production anchoring. Production "
67
+ "keys belong in a hardware security module (AWS CloudHSM, GCP KMS, "
68
+ "Azure Key Vault, HashiCorp Vault, YubiHSM, or similar)."
69
+ )
70
+
71
+
72
+ class MnemonicSigner(AlgorandSigner):
73
+ """Algorand signer backed by an in-process mnemonic.
74
+
75
+ Holds the derived private key in process memory. **Testing only;**
76
+ see module docstring for the security caveats.
77
+
78
+ The address is derived from the mnemonic at construction time. Both
79
+ the private key and the address are cached for the lifetime of the
80
+ signer instance; there is no remote call.
81
+
82
+ Args:
83
+ mnemonic_phrase: A 25-word Algorand mnemonic, words separated by
84
+ single spaces. Extra whitespace is stripped.
85
+
86
+ Raises:
87
+ ValueError: If the mnemonic is empty, not 25 words, or fails
88
+ algosdk's checksum.
89
+ RuntimeError: If py-algorand-sdk is not importable.
90
+ """
91
+
92
+ def __init__(self, mnemonic_phrase: str) -> None:
93
+ try:
94
+ from algosdk import account, mnemonic
95
+ except Exception as exc: # noqa: BLE001
96
+ raise RuntimeError(
97
+ f"py-algorand-sdk not importable: {exc}. "
98
+ f"Install with: pip install 'py-algorand-sdk>=2.6.1'"
99
+ ) from exc
100
+
101
+ if not mnemonic_phrase or not mnemonic_phrase.strip():
102
+ raise ValueError("Mnemonic phrase is empty")
103
+
104
+ words = mnemonic_phrase.strip().split()
105
+ if len(words) != 25:
106
+ raise ValueError(
107
+ f"Algorand mnemonics are 25 words; got {len(words)}"
108
+ )
109
+
110
+ try:
111
+ self._private_key: str = mnemonic.to_private_key(" ".join(words))
112
+ except Exception as exc: # noqa: BLE001
113
+ raise ValueError(
114
+ f"Cannot derive private key from mnemonic: {exc}. "
115
+ f"Check the mnemonic words and checksum word."
116
+ ) from exc
117
+
118
+ self._address: str = account.address_from_private_key(self._private_key)
119
+
120
+ # Loud warning on every instantiation. This is the security signal.
121
+ warnings.warn(
122
+ _MNEMONIC_WARNING_MESSAGE,
123
+ category=UserWarning,
124
+ stacklevel=2,
125
+ )
126
+
127
+ @property
128
+ def address(self) -> str:
129
+ """The signer's 58-character Algorand address."""
130
+ return self._address
131
+
132
+ def sign_transaction(self, txn: Any) -> Any:
133
+ """Validate the transaction then sign it with the held private key.
134
+
135
+ Args:
136
+ txn: An ``algosdk.transaction.Transaction`` to sign.
137
+
138
+ Returns:
139
+ An ``algosdk.transaction.SignedTransaction``.
140
+
141
+ Raises:
142
+ SignerValidationError: If the transaction violates the signing
143
+ policy (see ``AlgorandSigner.validate_transaction``).
144
+ """
145
+ self.validate_transaction(txn)
146
+ try:
147
+ return txn.sign(self._private_key)
148
+ except SignerValidationError:
149
+ raise
150
+ except Exception as exc: # noqa: BLE001
151
+ raise RuntimeError(
152
+ f"algosdk transaction signing failed: {exc}"
153
+ ) from exc