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/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..ad5ec690 100644 --- a/tests/test_generation.py +++ b/tests/test_generation.py @@ -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))