From cb096241d7e011a3f2a6a9b1999d8523e878ee48 Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Sat, 24 Oct 2026 03:31:20 -0500 Subject: [PATCH 1/3] generation: Confirm repeated share copies Permit repeated output indices as physical copies while requiring distinct indices for the recovery threshold. Schedule original cards before copies and reuse only independently confirmed cards.\n\nrefs #103 --- src/codex32/generation.py | 24 +++++++++++++++++------- tests/test_generation.py | 14 ++++++++++++++ 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/src/codex32/generation.py b/src/codex32/generation.py index 7ad8c2e3..15c2ce61 100644 --- a/src/codex32/generation.py +++ b/src/codex32/generation.py @@ -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, ...]: @@ -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 @@ -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, ...] @@ -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 @@ -273,7 +279,7 @@ 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: @@ -281,7 +287,9 @@ def next_share(self) -> Share: 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, @@ -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) @@ -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 diff --git a/tests/test_generation.py b/tests/test_generation.py index 9d20ff4d..60c472ac 100644 --- a/tests/test_generation.py +++ b/tests/test_generation.py @@ -49,6 +49,20 @@ 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 _seed(byte_length: int) -> bytes: return bytes((position * 109 + byte_length) % 256 for position in range(byte_length)) From f88a4ded73c57e7e67643cce747d5ff4754215a2 Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Sat, 24 Oct 2026 03:32:24 -0500 Subject: [PATCH 2/3] docs: Explain repeated share copy order Document first-occurrence ordering and physical-copy confirmation for repeated output indices. Format the related generation regression test.\n\nrefs #103 --- docs/developer/api.md | 5 ++++- tests/test_generation.py | 4 +--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/developer/api.md b/docs/developer/api.md index 51c9fb5c..019c792e 100644 --- a/docs/developer/api.md +++ b/docs/developer/api.md @@ -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; diff --git a/tests/test_generation.py b/tests/test_generation.py index 60c472ac..3d136613 100644 --- a/tests/test_generation.py +++ b/tests/test_generation.py @@ -50,9 +50,7 @@ def _indices(shares: tuple[Share, ...]) -> str: def test_repeated_indices_copy_only_confirmed_cards() -> None: - secret, shares = _complete( - CreationCeremony.master_seed(threshold=2, indices="aacd", identifier="test") - ) + 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 From e0b538c2475a62a6bb50872503d6128d50129df7 Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Sat, 24 Oct 2026 03:35:20 -0500 Subject: [PATCH 3/3] test: Confirm repeated cards independently Cover failed physical-copy confirmation and repeated-card creation from an existing master seed. These tests protect the distinction between share copies and distinct recovery indices. refs #103 --- tests/test_generation.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/test_generation.py b/tests/test_generation.py index 3d136613..ad5ec690 100644 --- a/tests/test_generation.py +++ b/tests/test_generation.py @@ -61,6 +61,32 @@ def test_repeated_indices_do_not_count_toward_threshold() -> None: 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))