Skip to content

Commit 4ceedee

Browse files
committed
Merge reviewability-v1 into dependency cleanup
Refresh the dependency-removal branch onto the current reviewability-v1 base. Preserve Python 3.10-3.15 support from #36 and the MIT/BSD license metadata from #15.
2 parents 8338c72 + cf1a599 commit 4ceedee

8 files changed

Lines changed: 94 additions & 6 deletions

File tree

‎MANIFEST.in‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
include LICENSE README.md SECURITY.md
2+
recursive-include LICENSES *
23
recursive-include docs *.html *.md
34
prune docs/planning
45
exclude docs/developer/provenance.md

‎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.

‎pyproject.toml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ authors = [
1111
description = "Python reference implementation for codex32 (BIP93) and codex32-encoded master seeds."
1212
readme = "README.md"
1313
requires-python = ">=3.10,<3.16"
14-
license = "MIT"
15-
license-files = ["LICENSE*"]
14+
license = "MIT AND BSD-3-Clause"
15+
license-files = ["LICENSE*", "LICENSES/*"]
1616
maintainers = [
1717
{ name = "Ben Westgate", email = "benwestgate@protonmail.com" },
1818
]

‎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

‎src/codex32/correction.py‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323

2424
from collections.abc import Callable, Sequence
2525
from dataclasses import dataclass, replace
26-
from functools import cache
26+
from functools import cache, lru_cache
2727
from math import comb
2828
from time import monotonic
2929
from typing import Literal
@@ -250,6 +250,10 @@ class _Spec:
250250
1023,
251251
)
252252

253+
# Keep every target length from one generic unknown-length search hot without
254+
# letting arbitrary HRPs create process-lifetime state.
255+
_ALIGNMENT_CACHE_SIZE = 11
256+
253257

254258
def _spec_for_checksum(checksum: _Checksum) -> _Spec:
255259
if checksum is _CODEX32:
@@ -276,7 +280,7 @@ def _pack_syndromes(values: tuple[int, ...]) -> int:
276280
return sum(value << (10 * index) for index, value in enumerate(values))
277281

278282

279-
@cache
283+
@lru_cache(maxsize=_ALIGNMENT_CACHE_SIZE)
280284
def _syndrome_alignment(spec: _Spec, hrp: str, length: int) -> tuple[int, tuple[tuple[int, ...], ...]]:
281285
base = _pack_syndromes(_syndromes(spec, _residue(spec, hrp, [0] * length), target=True))
282286
powers = _poly_powers(spec.generator, length)

‎tests/test_alignment.py‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from codex32._alignment import _IncrementalSyndromes, _View
88
from codex32.correction import (
9+
_ALIGNMENT_CACHE_SIZE,
910
_LONG_SPEC,
1011
_SHORT_SPEC,
1112
_pack_syndromes,
@@ -15,6 +16,45 @@
1516
)
1617

1718

19+
def test_syndrome_alignment_cache_is_bounded_for_untrusted_hrps():
20+
_syndrome_alignment.cache_clear()
21+
22+
for index in range(_ALIGNMENT_CACHE_SIZE + 5):
23+
_syndrome_alignment(_SHORT_SPEC, f"attacker{index}", 45)
24+
25+
info = _syndrome_alignment.cache_info()
26+
assert info.maxsize == _ALIGNMENT_CACHE_SIZE
27+
assert info.currsize == _ALIGNMENT_CACHE_SIZE
28+
29+
30+
def test_syndrome_alignment_cache_fits_one_unknown_length_search():
31+
observed = 50
32+
targets = sorted({observed + delta for delta in (*range(-4, 5), -8, 8)})
33+
_syndrome_alignment.cache_clear()
34+
35+
first = [_syndrome_alignment(_SHORT_SPEC, "generic", target) for target in targets]
36+
before = _syndrome_alignment.cache_info()
37+
second = [_syndrome_alignment(_SHORT_SPEC, "generic", target) for target in targets]
38+
after = _syndrome_alignment.cache_info()
39+
40+
assert len(targets) == _ALIGNMENT_CACHE_SIZE
41+
assert all(left is right for left, right in zip(first, second, strict=True))
42+
assert after.misses == before.misses
43+
assert after.hits == before.hits + len(targets)
44+
45+
46+
def test_syndrome_alignment_cache_still_reuses_recent_entries():
47+
_syndrome_alignment.cache_clear()
48+
first = _syndrome_alignment(_SHORT_SPEC, "ms", 45)
49+
before = _syndrome_alignment.cache_info()
50+
51+
second = _syndrome_alignment(_SHORT_SPEC, "ms", 45)
52+
53+
after = _syndrome_alignment.cache_info()
54+
assert first is second
55+
assert after.hits == before.hits + 1
56+
57+
1858
@pytest.mark.parametrize(
1959
("hrp", "length", "spec"),
2060
(

‎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)