diff --git a/docs/developer/api.md b/docs/developer/api.md index 837d12e..82cbdd7 100644 --- a/docs/developer/api.md +++ b/docs/developer/api.md @@ -62,6 +62,9 @@ generic parse-length failure. modules. Labels, lengths, padding, diagnostics, and S types stay with their application; unknown HRPs remain opaque and there is no runtime registration. - Headers and artifacts are immutable; shares expose symbols, not bytes. +- Artifact `str()` and `repr()` render redacted placeholders. Code that + intentionally serializes a validated `Share` or `Secret` must use its explicit + `.text` attribute. - Sharing interpolates payload and checksum together, explicitly constructs the target header, and reparses the result. - `generation.py` is the only entropy owner and generates only `ms` and `cl`. diff --git a/docs/security/model.md b/docs/security/model.md index 0ce515c..70cbbd8 100644 --- a/docs/security/model.md +++ b/docs/security/model.md @@ -90,6 +90,10 @@ lengths or interpret payload semantics. Only parsers and profile-specific factories construct immutable validated artifacts. A share has symbol semantics and cannot be converted to bytes. +Default `str()` and `repr()` rendering of artifacts is redacted; callers must +use `.text` explicitly when they intentionally need the serialized recovery +string. This reduces accidental disclosure through logs and interpolation but +does not make `.text` safe to expose. Registration adds semantics but is not required for generic parsing, recovery, derivation, or correction. Wallet and profile-specific generation APIs do not accept opaque artifacts or raw strings. diff --git a/src/codex32/bip93.py b/src/codex32/bip93.py index 02afad7..b73a3ff 100644 --- a/src/codex32/bip93.py +++ b/src/codex32/bip93.py @@ -134,17 +134,32 @@ def __init__( object.__setattr__(self, "payload_symbols", payload_symbols) def __str__(self) -> str: - return self.text + return f"<{type(self).__name__}: redacted>" + + def __repr__(self) -> str: + return f"{type(self).__name__}()" def __len__(self) -> int: return len(self.text) class Share(_Artifact): + """Validated codex32 share; use ``.text`` for the explicit serialized value. + + ``str(share)`` and ``repr(share)`` are deliberately redacted so accidental + logging or interpolation does not disclose recovery material. + """ + __slots__ = () class Secret(_Artifact): + """Validated codex32 secret; use ``.text`` for the explicit serialized value. + + ``str(secret)`` and ``repr(secret)`` are deliberately redacted so accidental + logging or interpolation does not disclose recovery material. + """ + __slots__ = () diff --git a/tests/test_public_api.py b/tests/test_public_api.py index 21e7fca..12f906a 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -11,7 +11,7 @@ from data.bip93_vectors import VECTOR_2 import codex32 -from codex32 import Header, MasterSeed, Share, parse_codex32 +from codex32 import CorrectionCandidate, Header, MasterSeed, Share, parse_codex32 from codex32.errors import InvalidIdentifier, InvalidShareIndex, InvalidThreshold @@ -84,6 +84,27 @@ def test_artifacts_cannot_be_directly_constructed_or_mutated() -> None: share.header.identifier = "leak" # type: ignore[misc] +def test_artifact_default_rendering_does_not_disclose_recovery_text() -> None: + share = parse_codex32(VECTOR_2["share_A"]) + candidate = CorrectionCandidate(share, (), 1, 0, 0, None) + + assert share.text not in str(share) + assert share.text not in repr(share) + assert share.text not in repr(candidate) + assert str(share) == "" + assert repr(share) == "Share()" + + +def test_master_seed_default_rendering_does_not_disclose_seed_material() -> None: + secret = MasterSeed.from_seed(bytes(range(16)), identifier="test") + + assert secret.text not in str(secret) + assert secret.text not in repr(secret) + assert "payload_symbols" not in repr(secret) + assert str(secret) == "" + assert repr(secret) == "MasterSeed()" + + def test_master_seed_factory_can_only_construct_index_s() -> None: secret = MasterSeed.from_seed(bytes(16), identifier="test", threshold=2) assert secret.header.index == "s"