From 979aca0c136a84c2fea9da05a75312a087ba9d94 Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Sat, 24 Oct 2026 03:56:06 -0500 Subject: [PATCH] generation: Confirm repeated share copies Repeated output indices request physical copies of a confirmed share, not new independent shares. Emit first occurrences before copies and still require the threshold number of distinct indices. Each physical card must be confirmed separately, including after an incorrect confirmation and during re-sharing. The code, documentation, and focused tests match the previously validated local three-commit worktree exactly. refs #103 --- docs/developer/api.md | 124 ++++++++++++++++++-------------------- src/codex32/generation.py | 102 ++++++++++--------------------- tests/test_generation.py | 109 ++++++++++++++------------------- 3 files changed, 136 insertions(+), 199 deletions(-) diff --git a/docs/developer/api.md b/docs/developer/api.md index 4e6cd06f..019c792e 100644 --- a/docs/developer/api.md +++ b/docs/developer/api.md @@ -7,7 +7,7 @@ The package uses one narrow dependency direction: ```text text -> format/header/checksum -> optional profile module -> immutable artifact |-> BIP93 sharing - |-> ms/cl generation + |-> ms generation |-> bounded correction `-> MasterSeed wallet adapter @@ -44,8 +44,8 @@ generic parse-length failure. | CLI competitor scheduling and pruning | `_competitors.py` | `test_competitors.py` | | incremental alignment syndromes | `_alignment.py` | `test_alignment.py` | | stdlib-only BIP32 root validation and serialization | `_bip32.py`, `profiles/ms32.py` | BIP32 and wallet vectors | -| fixed wallet derivation and descriptors | `wallet.py` | `test_wallet.py` | -| Core target selection and subprocess state | `_bitcoin_core.py` | Core adapter and regtest | +| root xprv handoff | `wallet.py` | `test_wallet.py` | +| Core target selection, descriptor requests, and subprocess state | `_bitcoin_core.py` | `test_bitcoin_core.py`, regtest | | bounded stdin, fixed-prefix TTY entry, and whole-card confirmation | `_cli_input.py` | `test_cli.py` | | command grammar, dispatch, and presentation | `_cli_parser.py`, `cli.py` | `test_cli.py` | @@ -67,13 +67,12 @@ generic parse-length failure. `.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`. +- `generation.py` is the only entropy owner; it generates and shares only `ms`. - Correction never edits the HRP or separator and reparses every candidate. - `_bip32.py` stops at HMAC-SHA512 root derivation, scalar validity, and root xprv/tprv Base58Check serialization. It performs no child derivation or - secp256k1 point arithmetic. `wallet.py` accepts only `MasterSeed`; EC-dependent - public derivation is supplied through an explicit wallet integration and the - CLI uses Bitcoin Core for that boundary. + secp256k1 point arithmetic. `wallet.py` accepts only `MasterSeed`; Bitcoin + Core performs all EC-dependent derivation. - `_cli_input.py` retains at most nine artifacts and delegates partial-set compatibility to `bip93.py`. Card confirmation clears the terminal and saved scrollback where supported, then displays only entered text after a mismatch. @@ -100,7 +99,7 @@ generic parse-length failure. hidden state. Private Python names are convention rather than access control. The supported -surface is the 25-name package `__all__`; direct use of private helpers is +surface is the 22-name package `__all__`; direct use of private helpers is unsupported but remains in the review scope. ### Size budget @@ -117,13 +116,13 @@ base artifact types rather than falling back to a registered application. | Capability | opaque HRP | `ms` | `cl` | `bip39_12w/24w` | |---|---:|---:|---:|---:| -| parse S/share | yes | yes | yes | yes | +| parse S/share | yes | yes | unshared S only | yes | | semantic S bytes | no | 16, 20, 24, 28, 32, or 64 | exactly 32 | no | -| recovery and API share derivation | yes | yes | yes | yes | -| `codex32` recovery/share/correction | yes | yes | yes | yes | -| unshared generation / shared ceremony API | no | six supported sizes | exactly 32 bytes | no | +| recovery and API share derivation | yes | yes | no | yes | +| `codex32` recovery/share/correction | yes | yes | correction only | yes | +| unshared generation / shared ceremony API | no | six supported sizes | no | no | | fresh generation CLI (`ms32`) | no | six supported sizes | no | no | -| existing-S splitting | no | yes | yes | no | +| existing-S splitting | no | yes | no | no | | wallet API | no | S only | no | no | Registered application semantics remain: @@ -136,32 +135,30 @@ Registered application semantics remain: `ms` payloads encode exactly 16, 20, 24, 28, 32, or 64 seed bytes and may have any legal parsed trailing bits. Parsing, generation, ceremonies, raw-seed -import, and CLI `--bytes` enforce the same six sizes. `cl` has 52 payload symbols; parsed -discarded bits remain application data. BIP39 profiles have exactly 27/53 -payload symbols; S requires zero outer padding and a valid embedded SHA-256 -checksum. Ordinary BIP39 shares are random masks and receive structural -validation only. +import, and CLI `--bytes` enforce the same six sizes. `cl` has 52 payload +symbols and threshold 0; parsed discarded bits remain application data. BIP39 +profiles have exactly 27/53 payload symbols; S requires zero outer padding and +a valid embedded SHA-256 checksum. Ordinary BIP39 shares are random masks and +receive structural validation only. -CL generation is explicit and uses a random identifier unless one is supplied. Current Core Lightning defaults to mnemonic recovery, but its recovery command -retains an import path for codex32 HSM secrets. Generated CL S strings use the -zero-padding convention emitted by CLN; parsed nonzero discarded bits remain -valid and are preserved when re-sharing. +retains an import path for unshared codex32 HSM secrets. It rejects shares and +any S with a nonzero threshold, so the `cl` profile rejects them too and there +is no CL recovery, share derivation, or splitting. -The generic `codex32` façade supports CL and BIP39 inspection, correction, -recovery, and share derivation. The `ms32` façade accepts only `ms` artifacts. +The generic `codex32` façade supports CL inspection and correction, and BIP39 +inspection, correction, recovery, and share derivation. The `ms32` façade +accepts only `ms` artifacts. ## Secret generation -`generation.py` is the only module that draws entropy. It generates BIP93 -master seeds and Core Lightning HSM secrets, and splits either validated S type. -Core Lightning now defaults to mnemonic recovery, but retains a codex32 HSM -secret import path for recovery on an unused node. +`generation.py` is the only module that draws entropy. It generates and +splits BIP93 master seeds only. Fresh unshared seeds default to 16 bytes and use the first 20 bits of their BIP32 fingerprint as public identifier metadata. Fresh shared sets use four -independent random u5 identifier symbols. Raw bytes, re-shared secrets, and CL -generation also use an independent random identifier unless one is supplied. +independent random u5 identifier symbols. Raw bytes and re-shared secrets also +use an independent random identifier unless one is supplied. Random re-sharing never repeats the source set header; an explicitly repeated source header is rejected. @@ -169,15 +166,18 @@ The Python API and CLI accept the six PR #2258 `ms` sizes: 16, 20, 24, 28, 32, and 64 bytes. Other byte lengths are rejected at every public construction boundary; there is no legacy decoder. -One-shot functions create only unshared secrets: +The one-shot function creates only unshared secrets: ```python -generate_master_seed(seed_bytes=None, *, byte_length=None, identifier=None) -generate_core_lightning_secret(secret_bytes=None, *, identifier=None) +generate_master_seed(seed_bytes=None, *, byte_length=None, identifier=None, fingerprint=None) ``` -Shared creation uses `CreationCeremony.master_seed(...)`, -`CreationCeremony.core_lightning(...)`, or +A fresh seed with no `identifier` needs `fingerprint`: a callable that takes +the seed bytes and returns their four-byte BIP32 master fingerprint. This +package has no secp256k1 arithmetic, so the CLI asks Bitcoin Core. Without +either argument, fresh generation raises `ValueError`. + +Shared creation uses `CreationCeremony.master_seed(...)` or `CreationCeremony.from_secret(...)`. Exactly one of `share_count` and `indices` is required. `next_share()` returns one pending share, `confirm(text)` must accept its independently re-entered string, and `finish()` returns the secret @@ -185,8 +185,7 @@ only after every requested share is confirmed. There is no public one-shot sharing or `split_secret` function. Fresh and existing Bitcoin CLI creation requires interactive input and output, preflights local Bitcoin Core before entropy or recovery input, and initializes a user-selected wallet after every -share is confirmed. CLI creation does not accept Core Lightning profiles; CL -generation and sharing remain API-only. +share is confirmed. Without `--existing`, omitting the Bitcoin header creates an unshared master seed. With `--existing` and no sharing threshold, a supplied codex32 secret is emitted and confirmed unchanged, and the original validated artifact initializes @@ -254,12 +253,15 @@ string during setup. It cannot prove that the physical backup was corrected rather than reconstructed using confirmation feedback. For a fresh set, the final direct share is rejection-sampled until the recovered -S has the private CRC padding convention (`ms`) or zero discarded bits (CL). -The already confirmed `k-1` masks remain fixed. Neither padding rule is 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 +S has the private CRC padding convention. The already confirmed `k-1` masks +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 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; @@ -316,7 +318,7 @@ applies registered S semantics when the HRP has a profile. `tests/test_sharing.py` proves that recovery and derivation still work after checksum creation is disabled. Official BIP93 vectors anchor the GF(32) -arithmetic. CL and BIP39 use compact frozen string/result fixtures rather than +arithmetic. BIP39 uses compact frozen string/result fixtures rather than a duplicate test implementation of interpolation or checksumming. ### BIP39 migration profiles @@ -580,27 +582,17 @@ Public wallet operations accept only a validated `MasterSeed`. `wallet.py` is stateless and never accepts shares, Core Lightning secrets, BIP39 migration artifacts, or raw bytes. -The public adapter has two functions: - -- `master_xprv(secret, testnet=False)` returns the BIP32 root extended private - key. -- `core_descriptors(...)` returns fixed BIP44, BIP49, BIP84, and BIP86 Bitcoin - Core `importdescriptors` records. Private records use stdlib-only root xprv - serialization; public records require an explicit wallet integration and the - Core wallet whose imported root key will perform hardened derivation. +The public adapter has one function: `master_xprv(secret, testnet=False)` +returns the BIP32 root extended private key, using stdlib-only serialization. +It grants authority over every key derived from the seed, and the CLI warns +before printing it. No installed Python dependency performs secp256k1 operations. The private Bitcoin Core adapter gives Core the root xprv over stdin and asks Core to -create the four standard account-0 descriptor types. Public descriptor -derivation remains available through the explicit integration API. - -Public descriptors contain account xpubs. Private descriptors intentionally -follow Bitcoin Core's root-key form: they contain the root xprv followed by the -complete derivation path. They therefore grant authority over the entire root, -not only the selected account. The CLI warns before printing them. +create the four standard account-0 descriptor types. Core's own wallet +commands provide public descriptors and watch-only exports. -Account, private/public mode, network serialization, and timestamp are explicit -API inputs. The `ms32 wallet` CLI takes `--account 0` and `--timestamp`; the +The `ms32 wallet` CLI takes `--account 0` and `--timestamp`; the selected Bitcoin Core chain is authoritative and there is no wallet `--testnet` flag. `ms32 xprv --testnet` remains explicit because it directly selects xprv versus tprv serialization. The timestamp defaults to `0` so @@ -647,7 +639,7 @@ the BIP32 fingerprint. The Core calls are fixed: `getnetworkinfo`, `getblockchaininfo`, `listwallets`, `getwalletinfo`, `listdescriptors`, `getdescriptorinfo`, `deriveaddresses`, -`validateaddress`, `gethdkeys`, `derivehdkey`, `addhdkey`, +`validateaddress`, `addhdkey`, `createwalletdescriptor`, `importdescriptors`, and `walletlock`. Bitcoin Core alone creates wallets, selects encryption, handles passphrases, stores keys, and provides normal wallet behavior. @@ -688,11 +680,10 @@ These choices are not presented as BIP93 requirements. | `ms` accepts only six seed sizes | follows the frozen PR #2258 profile with no legacy decoder | | random electronic output indices | reduces canonical index disclosure; explicit indices preserve requested order | | generation-only CRC padding | small recovery hint; not validity or share semantics | -| fingerprint identifier only for fresh k=0 | shared sets, raw seeds, re-sharing, and CL generation use random IDs unless explicitly overridden | +| fingerprint identifier only for fresh k=0 | shared sets, raw seeds, and re-sharing use random IDs unless explicitly overridden | | BIP39 profiles have no construction or wallet CLI | migration artifacts may still be checked, corrected, recovered, and re-shared generically | | reject existing derivation targets | enforces BIP93's fresh-index wording | | bounded structural correction is deliberately finite | exact capture safety, complete global rank layers, the 48-character ten-second target, and the package audit budget exclude a general recovery engine; longer valid strings keep the same bounded classes | -| private descriptors contain root xprv | matches Bitcoin Core behavior and carries an explicit authority warning | | no caller-supplied partial-basis completion | unauthenticated points can create incompatible same-header polynomials | Unknown-HRP application interpretation, GUI, direct sockets, a general RPC @@ -712,9 +703,8 @@ The four-character identifier is public metadata, not authentication. offline 20-bit predicate against candidate seeds. - A fresh shared set uses four independent random u5 symbols and leaks no seed-derived fingerprint bits. -- Raw seed bytes, re-sharing, and CL generation use an independent random - identifier unless the caller supplies all four symbols. A random identifier - does not make a weak supplied seed safe. +- Raw seed bytes and re-sharing use an independent random identifier unless the + caller supplies all four symbols. - Random re-sharing rejects the source set header and draws another identifier. An explicitly repeated source header remains an error. diff --git a/src/codex32/generation.py b/src/codex32/generation.py index f135d191..15c2ce61 100644 --- a/src/codex32/generation.py +++ b/src/codex32/generation.py @@ -1,4 +1,4 @@ -"""Electronic generation for ``ms`` and Core Lightning share sets.""" +"""Electronic master-seed generation and sharing.""" from __future__ import annotations @@ -29,14 +29,6 @@ InvalidThreshold, ) from codex32.profiles import Profile -from codex32.profiles.cl32 import PAYLOAD_LENGTH as CL_PAYLOAD_LENGTH -from codex32.profiles.cl32 import ( - CoreLightningSecret, - _secret_from_bytes, -) -from codex32.profiles.cl32 import ( - _has_generation_padding as _cl_padding, -) from codex32.profiles.ms32 import ( DEFAULT_SEED_BYTES, SEED_BYTE_LENGTHS, @@ -91,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, ...]: @@ -107,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 @@ -124,9 +120,9 @@ def _fingerprint_identifier(fingerprint: bytes) -> str: return _u5_to_chars(tuple(convertbits(fingerprint, 8, 5, pad=True)[:4])) -def _random_share(profile: Profile, threshold: int, identifier: str, index: str, length: int) -> Share: +def _random_share(threshold: int, identifier: str, index: str, length: int) -> Share: symbols = tuple(value & 31 for value in secrets.token_bytes(length)) - artifact = _from_parts(profile, Header(threshold, identifier, index), symbols) + artifact = _from_parts(Profile.MS, Header(threshold, identifier, index), symbols) assert isinstance(artifact, Share) return artifact @@ -175,26 +171,18 @@ def generate_master_seed( return MasterSeed.from_seed(fresh, identifier=_fingerprint_identifier(fingerprint(fresh))) -def generate_core_lightning_secret( - secret_bytes: bytes | None = None, *, identifier: str | None = None -) -> CoreLightningSecret: - """Generate or encode one unshared Core Lightning HSM secret.""" - identifier = _random_identifier() if identifier is None else _identifier(identifier) - return _secret_from_bytes(secrets.token_bytes(32) if secret_bytes is None else secret_bytes, identifier) - - 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, ...] _payload_length: int _pending: Share | None _position: int - _profile: Profile - _secret: MasterSeed | CoreLightningSecret | None + _secret: MasterSeed | None _set_identifier: str _threshold: int @@ -207,24 +195,24 @@ def __reduce_ex__(self, _protocol: SupportsIndex) -> NoReturn: @classmethod def _start( cls, - profile: Profile, payload_length: int, threshold: int, share_count: int | None, indices: Sequence[str] | str | None, identifier: str, - secret: MasterSeed | CoreLightningSecret | None, + secret: MasterSeed | None, ) -> CreationCeremony: self = object.__new__(cls) - self._profile, self._payload_length = profile, payload_length + 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 else: - reheadered = _from_parts(profile, Header(threshold, identifier, "s"), secret.payload_symbols) - assert isinstance(reheadered, (MasterSeed, CoreLightningSecret)) + reheadered = _from_parts(Profile.MS, Header(threshold, identifier, "s"), secret.payload_symbols) + assert isinstance(reheadered, MasterSeed) self._secret, self._basis, self._direct_count = ( reheadered, [reheadered], @@ -247,7 +235,6 @@ def master_seed( _supplied, byte_length = _seed_input(None, byte_length) identifier = _random_identifier() if identifier is None else _identifier(identifier) return cls._start( - Profile.MS, _payload_length(byte_length), threshold, share_count, @@ -256,41 +243,19 @@ def master_seed( None, ) - @classmethod - def core_lightning( - cls, - *, - threshold: int, - share_count: int | None = None, - indices: Sequence[str] | str | None = None, - identifier: str | None = None, - ) -> CreationCeremony: - """Start a ceremony for a fresh shared Core Lightning secret.""" - threshold = _threshold(threshold, allow_zero=False) - identifier = _random_identifier() if identifier is None else _identifier(identifier) - return cls._start( - Profile.CL, - CL_PAYLOAD_LENGTH, - threshold, - share_count, - indices, - identifier, - None, - ) - @classmethod def from_secret( cls, - secret: MasterSeed | CoreLightningSecret, + secret: MasterSeed, *, threshold: int, share_count: int | None = None, indices: Sequence[str] | str | None = None, identifier: str | None = None, ) -> CreationCeremony: - """Start a ceremony that shares an existing validated secret.""" - if not isinstance(secret, (MasterSeed, CoreLightningSecret)): - raise TypeError("from_secret accepts only MasterSeed or CoreLightningSecret") + """Start a ceremony that shares an existing validated master seed.""" + if not isinstance(secret, MasterSeed): + raise TypeError("from_secret accepts only MasterSeed") threshold = _threshold(threshold, allow_zero=False) random_identifier = identifier is None identifier = _random_identifier() if random_identifier else _identifier(identifier) @@ -301,9 +266,7 @@ def from_secret( if not random_identifier: raise HeaderCollision("new share set must use a different set header") identifier = _random_identifier() - assert secret.profile is not None return cls._start( - secret.profile, len(secret.payload_symbols), threshold, share_count, @@ -312,13 +275,11 @@ def from_secret( secret, ) - def _candidate_is_accepted(self, secret: MasterSeed | CoreLightningSecret) -> bool: - if isinstance(secret, CoreLightningSecret): - return _cl_padding(secret) + 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: @@ -326,10 +287,11 @@ 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._profile, self._threshold, self._set_identifier, index, @@ -338,7 +300,7 @@ def next_share(self) -> Share: if self._position + 1 < self._direct_count or self._secret is not None: break candidate = recover_secret((*cast(list[Share], self._basis), pending)) - assert isinstance(candidate, (MasterSeed, CoreLightningSecret)) + assert isinstance(candidate, MasterSeed) if self._candidate_is_accepted(candidate): self._secret = candidate break @@ -366,20 +328,22 @@ 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) - def finish(self) -> MasterSeed | CoreLightningSecret: + def finish(self) -> MasterSeed: """Return the secret after every requested card is confirmed.""" if self._finished: raise CeremonyStateError("this creation ceremony is finished") if self._pending is not None or self._position != len(self._indices): raise CeremonyStateError("confirm every card before finishing the ceremony") secret = self._secret - assert isinstance(secret, (MasterSeed, CoreLightningSecret)) + 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 878e795b..ad5ec690 100644 --- a/tests/test_generation.py +++ b/tests/test_generation.py @@ -1,11 +1,10 @@ -"""Security invariants for incremental ``ms`` and CL creation ceremonies.""" +"""Security invariants for incremental ``ms`` creation ceremonies.""" import copy import pickle from itertools import combinations import pytest -from _codex32_oracle import oracle_encode from data.bip93_vectors import VECTOR_2, VECTOR_4, VECTOR_6 from data.sharing_vectors import SHARING_VECTORS from hypothesis import given, settings @@ -13,21 +12,17 @@ import codex32.generation as generation_module from codex32 import ( - CoreLightningSecret, CreationCeremony, MasterSeed, Share, - generate_core_lightning_secret, generate_master_seed, parse_codex32, recover_secret, ) -from codex32.bech32 import _u5_to_chars from codex32.errors import ( CeremonyStateError, CodexError, HeaderCollision, - InvalidIdentifier, InvalidLength, InvalidShareSelection, InvalidThreshold, @@ -37,7 +32,7 @@ from tools._wallet_test_vectors import FIXTURE_SEED, core_fingerprint, stub_fingerprint -def _complete(ceremony: CreationCeremony) -> tuple[MasterSeed | CoreLightningSecret, tuple[Share, ...]]: +def _complete(ceremony: CreationCeremony) -> tuple[MasterSeed, tuple[Share, ...]]: shares = [] while True: try: @@ -54,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)) @@ -95,18 +128,12 @@ def test_fresh_generation_recovers_at_every_threshold(threshold: int) -> None: assert recover_secret(shares) == secret -@pytest.mark.parametrize(("threshold", "kind"), ((2, 16), (3, "cl"), (9, 64))) -def test_generated_subsets_recover_across_profiles_and_threshold_boundaries( - threshold: int, kind: int | str +@pytest.mark.parametrize(("threshold", "byte_length"), ((2, 16), (3, 32), (9, 64))) +def test_generated_subsets_recover_across_sizes_and_threshold_boundaries( + threshold: int, byte_length: int ) -> None: - ceremony = ( - CreationCeremony.core_lightning(threshold=threshold, share_count=threshold + 2) - if kind == "cl" - else CreationCeremony.master_seed( - byte_length=kind, - threshold=threshold, - share_count=threshold + 2, # type: ignore[arg-type] - ) + ceremony = CreationCeremony.master_seed( + byte_length=byte_length, threshold=threshold, share_count=threshold + 2 ) secret, shares = _complete(ceremony) @@ -197,7 +224,6 @@ def unexpected_normalization(_value: object) -> str: source = generate_master_seed(bytes(range(16)), identifier="test") operations = ( lambda: CreationCeremony.master_seed(threshold=2, indices="a" * 32, identifier="test"), - lambda: CreationCeremony.core_lightning(threshold=2, indices="a" * 32, identifier="test"), lambda: CreationCeremony.from_secret(source, threshold=2, indices="a" * 32, identifier="name"), ) for operation in operations: @@ -353,52 +379,9 @@ def test_resharing_preserves_padding_and_requires_a_new_header() -> None: assert randomized.next_share().header.identifier != secret.header.identifier -@pytest.mark.parametrize("threshold", range(2, 10)) -def test_core_lightning_generation_and_splitting_target_zero_padding(threshold: int) -> None: - fresh, shares = _complete( - CreationCeremony.core_lightning( - identifier="peev", threshold=threshold, indices=ORDINARY_INDICES[:threshold] - ) - ) - assert isinstance(fresh, CoreLightningSecret) - assert fresh.payload_symbols[-1] & 15 == 0 - assert recover_secret(shares) == fresh - - if threshold == 2: - raw = generate_core_lightning_secret(bytes(range(32)), identifier="name") - split, split_shares = _complete( - CreationCeremony.from_secret(raw, threshold=2, indices="ac", identifier="test") - ) - assert split.payload_symbols == raw.payload_symbols - assert recover_secret(split_shares).secret_bytes == bytes(range(32)) - - -def test_resharing_preserves_nonzero_core_lightning_padding() -> None: - source = parse_codex32(VECTOR_6["codex32_peev"]) - assert isinstance(source, CoreLightningSecret) - payload = (*source.payload_symbols[:-1], source.payload_symbols[-1] | 15) - nonzero = parse_codex32(oracle_encode("cl", "0peevs" + _u5_to_chars(payload))) - assert isinstance(nonzero, CoreLightningSecret) - secret, shares = _complete( - CreationCeremony.from_secret(nonzero, threshold=2, indices="ac", identifier="name") - ) - assert secret.payload_symbols == nonzero.payload_symbols - assert recover_secret(shares).payload_symbols == nonzero.payload_symbols - - -def test_core_lightning_generation_validates_size_and_identifier() -> None: - for value in (b"x" * 31, b"x" * 33): - with pytest.raises(InvalidLength): - generate_core_lightning_secret(value, identifier="test") - with pytest.raises(TypeError): - generate_core_lightning_secret("x" * 32, identifier="test") # type: ignore[arg-type] - assert len(generate_core_lightning_secret().header.identifier) == 4 - with pytest.raises(InvalidIdentifier): - generate_core_lightning_secret(identifier="bad") - - def test_from_secret_rejects_non_secret_artifacts() -> None: artifacts = ( + parse_codex32(VECTOR_6["codex32_peev"]), parse_codex32(SHARING_VECTORS["bip39_12w"]["S"]), parse_codex32(VECTOR_2["share_A"]), VECTOR_2["secret_S"],