Skip to content

Commit 92d75a7

Browse files
authored
bip93: Redact artifact string rendering (#25)
* bip93: Redact artifact rendering Make default artifact string and repr rendering non-secret while preserving explicit `.text` export. Nested correction-candidate reprs inherit the redaction.\n\nFixes #22. * docs: Explain redacted artifact rendering
1 parent fba20e3 commit 92d75a7

4 files changed

Lines changed: 45 additions & 2 deletions

File tree

‎docs/developer/api.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,9 @@ generic parse-length failure.
6262
modules. Labels, lengths, padding, diagnostics, and S types stay with their
6363
application; unknown HRPs remain opaque and there is no runtime registration.
6464
- Headers and artifacts are immutable; shares expose symbols, not bytes.
65+
- Artifact `str()` and `repr()` render redacted placeholders. Code that
66+
intentionally serializes a validated `Share` or `Secret` must use its explicit
67+
`.text` attribute.
6568
- Sharing interpolates payload and checksum together, explicitly constructs the
6669
target header, and reparses the result.
6770
- `generation.py` is the only entropy owner and generates only `ms` and `cl`.

‎docs/security/model.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,10 @@ lengths or interpret payload semantics.
9090

9191
Only parsers and profile-specific factories construct immutable validated
9292
artifacts. A share has symbol semantics and cannot be converted to bytes.
93+
Default `str()` and `repr()` rendering of artifacts is redacted; callers must
94+
use `.text` explicitly when they intentionally need the serialized recovery
95+
string. This reduces accidental disclosure through logs and interpolation but
96+
does not make `.text` safe to expose.
9397
Registration adds semantics but is not required for generic parsing, recovery,
9498
derivation, or correction. Wallet and profile-specific generation APIs do not
9599
accept opaque artifacts or raw strings.

‎src/codex32/bip93.py‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,17 +134,32 @@ def __init__(
134134
object.__setattr__(self, "payload_symbols", payload_symbols)
135135

136136
def __str__(self) -> str:
137-
return self.text
137+
return f"<{type(self).__name__}: redacted>"
138+
139+
def __repr__(self) -> str:
140+
return f"{type(self).__name__}(<redacted>)"
138141

139142
def __len__(self) -> int:
140143
return len(self.text)
141144

142145

143146
class Share(_Artifact):
147+
"""Validated codex32 share; use ``.text`` for the explicit serialized value.
148+
149+
``str(share)`` and ``repr(share)`` are deliberately redacted so accidental
150+
logging or interpolation does not disclose recovery material.
151+
"""
152+
144153
__slots__ = ()
145154

146155

147156
class Secret(_Artifact):
157+
"""Validated codex32 secret; use ``.text`` for the explicit serialized value.
158+
159+
``str(secret)`` and ``repr(secret)`` are deliberately redacted so accidental
160+
logging or interpolation does not disclose recovery material.
161+
"""
162+
148163
__slots__ = ()
149164

150165

‎tests/test_public_api.py‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from data.bip93_vectors import VECTOR_2
1212

1313
import codex32
14-
from codex32 import Header, MasterSeed, Share, parse_codex32
14+
from codex32 import CorrectionCandidate, Header, MasterSeed, Share, parse_codex32
1515
from codex32.errors import InvalidIdentifier, InvalidShareIndex, InvalidThreshold
1616

1717

@@ -84,6 +84,27 @@ def test_artifacts_cannot_be_directly_constructed_or_mutated() -> None:
8484
share.header.identifier = "leak" # type: ignore[misc]
8585

8686

87+
def test_artifact_default_rendering_does_not_disclose_recovery_text() -> None:
88+
share = parse_codex32(VECTOR_2["share_A"])
89+
candidate = CorrectionCandidate(share, (), 1, 0, 0, None)
90+
91+
assert share.text not in str(share)
92+
assert share.text not in repr(share)
93+
assert share.text not in repr(candidate)
94+
assert str(share) == "<Share: redacted>"
95+
assert repr(share) == "Share(<redacted>)"
96+
97+
98+
def test_master_seed_default_rendering_does_not_disclose_seed_material() -> None:
99+
secret = MasterSeed.from_seed(bytes(range(16)), identifier="test")
100+
101+
assert secret.text not in str(secret)
102+
assert secret.text not in repr(secret)
103+
assert "payload_symbols" not in repr(secret)
104+
assert str(secret) == "<MasterSeed: redacted>"
105+
assert repr(secret) == "MasterSeed(<redacted>)"
106+
107+
87108
def test_master_seed_factory_can_only_construct_index_s() -> None:
88109
secret = MasterSeed.from_seed(bytes(16), identifier="test", threshold=2)
89110
assert secret.header.index == "s"

0 commit comments

Comments
 (0)