From a55b76e317b67a7a1482d49244abf71e935e8af8 Mon Sep 17 00:00:00 2001 From: oscarozaine Date: Sat, 6 Jun 2026 09:23:50 -0700 Subject: [PATCH] security: redact private key material from SigningKey repr/str MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `__repr__` and `__str__` on signing keys previously delegated to `to_json()`, which embeds the raw CBOR hex of the private key. This caused the secret to leak in logs, exception tracebacks, debugger output, f-strings, and REPL echo. Add `_IS_SECRET = True` to `SigningKey` and `ExtendedSigningKey` (and thus all concrete payment/stake/pool subclasses). Override `__repr__` to emit a redacted placeholder containing only the class name, key type, and a non-reversible 16-hex-char fingerprint derived from the public verification key hash. `__str__` delegates to the same path. `to_json()`, `to_cbor()`, and `save()` are untouched — callers who invoke those have opted in to exporting key material. Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 6 ++++ pycardano/key.py | 33 ++++++++++++++++++++ test/pycardano/test_key.py | 64 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 103 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index dea17edf..759ebb9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [Unreleased] + +**Security:** + +- `repr()`/`str()` of signing keys no longer expose private key material; use `.to_json()` or `.save()` to export deliberately. + ## [0.8.1] - 2023-04-06 This patch contains a number of bug fixes to `v0.8.0`. diff --git a/pycardano/key.py b/pycardano/key.py index d2d728d8..9adb1585 100644 --- a/pycardano/key.py +++ b/pycardano/key.py @@ -42,6 +42,7 @@ class Key(CBORSerializable): KEY_TYPE = "" DESCRIPTION = "" + _IS_SECRET = False # overridden to True on signing keys def __init__( self, @@ -78,6 +79,12 @@ def to_json(self, **kwargs) -> str: # type: ignore The json output has three fields: "type", "description", and "cborHex". + .. warning:: + For signing keys, the output contains the **raw private key material** + (in the ``cborHex`` field). This is a deliberate export path; do not log + or otherwise expose its return value for a signing key. Implicit string + conversions (``repr``/``str``) are redacted, but ``to_json`` is not. + Returns: str: JSON representation of the key. """ @@ -135,14 +142,38 @@ def __eq__(self, other): and self.key_type == other.key_type ) + def _public_fingerprint(self) -> str: + """Return a short, non-reversible identifier for diagnostics. + + For a signing key this is derived from the *verification* key hash, which is + public on-chain anyway, so it never exposes secret material. Guaranteed not to + raise (a repr that throws breaks debuggers and logging). + """ + try: + vkh = self.to_verification_key().hash().payload # type: ignore[attr-defined] + return vkh.hex()[:16] + except Exception: + return "unknown" + def __repr__(self) -> str: + if self._IS_SECRET: + return ( + f"<{type(self).__name__} " + f"type={self.key_type!r} " + f"hash={self._public_fingerprint()} [REDACTED]>" + ) return self.to_json() + def __str__(self) -> str: + return self.__repr__() + def __hash__(self): return hash(self.payload) class SigningKey(Key): + _IS_SECRET = True + def sign(self, data: bytes) -> bytes: signed_message = NACLSigningKey(self.payload).sign(data) return signed_message.signature @@ -178,6 +209,8 @@ def from_signing_key(cls, key: SigningKey) -> VerificationKey: class ExtendedSigningKey(Key): + _IS_SECRET = True + def sign(self, data: bytes) -> bytes: private_key = BIP32ED25519PrivateKey(self.payload[:64], self.payload[96:]) return private_key.sign(data) diff --git a/test/pycardano/test_key.py b/test/pycardano/test_key.py index e4928541..fd4dca91 100644 --- a/test/pycardano/test_key.py +++ b/test/pycardano/test_key.py @@ -16,6 +16,7 @@ PaymentKeyPair, PaymentSigningKey, PaymentVerificationKey, + SigningKey, StakeExtendedSigningKey, StakePoolKeyPair, StakePoolSigningKey, @@ -275,6 +276,69 @@ def test_stake_pool_key_hash(): assert len(vk_set) == 1 +SIGNING_KEYS_FOR_REDACTION = [ + SK, + SPSK, + EXTENDED_SK, + PaymentSigningKey.generate(), + StakeSigningKey.generate(), + StakePoolSigningKey.generate(), + PaymentExtendedSigningKey.from_hdwallet( + HDWallet.from_mnemonic(Mnemonic().generate()) + ), + StakeExtendedSigningKey.from_hdwallet( + HDWallet.from_mnemonic(Mnemonic().generate()) + ), +] + + +@pytest.mark.parametrize("skey", SIGNING_KEYS_FOR_REDACTION) +def test_signing_key_repr_is_redacted(skey): + secret_hex = skey.payload.hex() + secret_cbor = skey.to_cbor_hex() + + for rendered in (repr(skey), str(skey), f"{skey}", "%r" % skey): + assert secret_hex not in rendered + assert secret_cbor not in rendered + + +@pytest.mark.parametrize("skey", SIGNING_KEYS_FOR_REDACTION) +def test_signing_key_repr_identifies_type(skey): + rendered = repr(skey) + assert type(skey).__name__ in rendered + assert "REDACTED" in rendered + + +@pytest.mark.parametrize("vkey", [VK, SPVK, EXTENDED_VK]) +def test_verification_key_repr_unchanged(vkey): + assert repr(vkey) == vkey.to_json() + assert str(vkey) == vkey.to_json() + assert vkey.to_cbor_hex() in repr(vkey) + + +def test_to_json_still_exports_secret_material(): + assert SK.to_cbor_hex() in SK.to_json() + assert PaymentSigningKey.from_json(SK.to_json()) == SK + + +def test_signing_key_repr_never_raises(monkeypatch): + def _boom(*args, **kwargs): + raise RuntimeError("no vkey for you") + + monkeypatch.setattr( + type(SK), "to_verification_key", _boom, raising=True + ) + rendered = repr(SK) + assert isinstance(rendered, str) + assert "unknown" in rendered + + +def test_fingerprint_matches_vkey_hash(): + expected = SK.to_verification_key().hash().payload.hex()[:16] + assert SK._public_fingerprint() == expected + assert expected in repr(SK) + + def test_extended_signing_key_from_hd_wallet_uses_type_and_description_from_class(): hd_wallet = HDWallet.from_mnemonic(Mnemonic().generate())