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
5 changes: 4 additions & 1 deletion docs/developer/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,10 @@ remain fixed. That padding rule is not BIP93 validity: parsed S strings may use
any application-valid discarded bits, which re-sharing preserves exactly. CRC
never applies to shares.

Explicit output indices preserve caller order. A share count uses
Explicit output indices preserve first-occurrence order. Repeated indices
request identical copies, scheduled after the first occurrence of every index:
`aacd` produces `acda`. The first `threshold` indices must be distinct,
and every copy must be independently confirmed. A share count uses
`SystemRandom.sample` over the 31 ordinary indices and preserves sample order.
There is no entropy injection, sorting, caller-supplied partial-basis
completion, or BIP39 generation. Ceremonies reject copying and serialization;
Expand Down
24 changes: 17 additions & 7 deletions src/codex32/generation.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,13 @@ def _indices(values: Sequence[str] | str) -> tuple[str, ...]:
raise InvalidShareSelection("at most 31 shares may be requested")
copied: tuple[object, ...] = tuple(values[position] for position in range(len(values)))
normalized = tuple(_index(value) for value in copied)
if len(set(normalized)) != len(normalized):
raise InvalidShareSelection("output indices must be distinct")
return normalized
counts = {value: normalized.count(value) for value in normalized}
return tuple(
value
for round_number in range(max(counts.values(), default=0))
for value in dict.fromkeys(normalized)
if counts[value] > round_number
)


def _selection(threshold: int, share_count: object, indices: Sequence[str] | str | None) -> tuple[str, ...]:
Expand All @@ -99,8 +103,8 @@ def _selection(threshold: int, share_count: object, indices: Sequence[str] | str
return tuple(secrets.SystemRandom().sample(ORDINARY_INDICES, share_count))
assert indices is not None
selected = _indices(indices)
if len(selected) < threshold:
raise InvalidShareSelection(f"at least {threshold} indices are required")
if len(set(selected)) < threshold:
raise InvalidShareSelection(f"at least {threshold} distinct indices are required")
return selected


Expand Down Expand Up @@ -171,6 +175,7 @@ class CreationCeremony:
"""Generate and confirm one shared-backup card at a time."""

_basis: list[Secret | Share]
_confirmed: dict[str, Share]
_direct_count: int
_finished: bool
_indices: tuple[str, ...]
Expand Down Expand Up @@ -201,6 +206,7 @@ def _start(
self._payload_length = payload_length
self._threshold, self._set_identifier = threshold, identifier
self._indices = _selection(threshold, share_count, indices)
self._confirmed = {}
self._position, self._pending, self._finished = 0, None, False
if secret is None:
self._secret, self._basis, self._direct_count = None, [], threshold
Expand Down Expand Up @@ -273,15 +279,17 @@ def _candidate_is_accepted(self, secret: MasterSeed) -> bool:
return _valid_root(secret.seed_bytes) and _ms_padding(secret)

def next_share(self) -> Share:
"""Generate or derive the next card after the previous card is confirmed."""
"""Generate or copy the next card after the previous card is confirmed."""
if self._finished:
raise CeremonyStateError("this creation ceremony is finished")
if self._pending is not None:
raise CeremonyStateError("confirm the pending card before requesting another")
if self._position == len(self._indices):
raise CeremonyStateError("all cards are confirmed; finish the ceremony")
index = self._indices[self._position]
if self._position < self._direct_count:
if index in self._confirmed:
pending = self._confirmed[index]
elif self._position < self._direct_count:
while True:
pending = _random_share(
self._threshold,
Expand Down Expand Up @@ -320,6 +328,7 @@ def confirm(self, text: str) -> ConfirmationResult:
return ConfirmationResult(False, mismatched)
if self._position < self._direct_count:
self._basis.append(self._pending)
self._confirmed[self._pending.header.index] = self._pending
self._position += 1
self._pending = None
return ConfirmationResult(True)
Expand All @@ -334,6 +343,7 @@ def finish(self) -> MasterSeed:
assert isinstance(secret, MasterSeed)
self._finished = True
self._basis.clear()
self._confirmed.clear()
self._indices = ()
self._secret = None
return secret
38 changes: 38 additions & 0 deletions tests/test_generation.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,44 @@ def _indices(shares: tuple[Share, ...]) -> str:
return "".join(share.header.index for share in shares)


def test_repeated_indices_copy_only_confirmed_cards() -> None:
secret, shares = _complete(CreationCeremony.master_seed(threshold=2, indices="aacd", identifier="test"))
assert isinstance(secret, MasterSeed)
assert _indices(shares) == "acda"
assert shares[0].text == shares[3].text


def test_repeated_indices_do_not_count_toward_threshold() -> None:
with pytest.raises(InvalidShareSelection, match="distinct"):
CreationCeremony.master_seed(threshold=3, indices="aaac", identifier="test")


def test_repeated_cards_require_independent_confirmation() -> None:
ceremony = CreationCeremony.master_seed(threshold=2, indices="aac", identifier="test")
first = ceremony.next_share()
assert ceremony.confirm(first.text).accepted
second = ceremony.next_share()
assert ceremony.confirm(second.text).accepted
repeated = ceremony.next_share()
assert repeated.text == first.text
assert not ceremony.confirm("incorrect").accepted
with pytest.raises(CeremonyStateError, match="confirm the pending"):
ceremony.next_share()
assert ceremony.confirm(repeated.text).accepted
secret = ceremony.finish()
assert recover_secret((first, second)) == secret


def test_repeated_indices_reuse_existing_secret_shares() -> None:
source = generate_master_seed(bytes(range(16)), identifier="test")
secret, shares = _complete(
CreationCeremony.from_secret(source, threshold=2, indices="aac", identifier="name")
)
assert _indices(shares) == "aca"
assert shares[0].text == shares[2].text
assert secret.seed_bytes == source.seed_bytes


def _seed(byte_length: int) -> bytes:
return bytes((position * 109 + byte_length) % 256 for position in range(byte_length))

Expand Down
Loading