Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
33 changes: 33 additions & 0 deletions pycardano/key.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ class Key(CBORSerializable):

KEY_TYPE = ""
DESCRIPTION = ""
_IS_SECRET = False # overridden to True on signing keys

def __init__(
self,
Expand Down Expand Up @@ -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.
"""
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
64 changes: 64 additions & 0 deletions test/pycardano/test_key.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
PaymentKeyPair,
PaymentSigningKey,
PaymentVerificationKey,
SigningKey,
StakeExtendedSigningKey,
StakePoolKeyPair,
StakePoolSigningKey,
Expand Down Expand Up @@ -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())

Expand Down