Skip to content
Merged
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
3 changes: 3 additions & 0 deletions docs/developer/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
4 changes: 4 additions & 0 deletions docs/security/model.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
17 changes: 16 additions & 1 deletion src/codex32/bip93.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__}(<redacted>)"
Comment thread
BenWestgate marked this conversation as resolved.

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__ = ()


Expand Down
23 changes: 22 additions & 1 deletion tests/test_public_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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) == "<Share: redacted>"
assert repr(share) == "Share(<redacted>)"


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) == "<MasterSeed: redacted>"
assert repr(secret) == "MasterSeed(<redacted>)"


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"
Expand Down
Loading