diff --git a/docs/developer/api.md b/docs/developer/api.md index 614a5a1..e16b152 100644 --- a/docs/developer/api.md +++ b/docs/developer/api.md @@ -237,6 +237,10 @@ 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. +Supplied Bitcoin seed bytes and existing Bitcoin secrets must form a valid +BIP32 root before a creation ceremony selects entropy or emits recovery cards. +Parsing a codex32 string remains a format check; Core Lightning has no BIP32 +root requirement. 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 diff --git a/docs/security/model.md b/docs/security/model.md index 8a07c3d..dd2da50 100644 --- a/docs/security/model.md +++ b/docs/security/model.md @@ -156,7 +156,12 @@ multiplication. The original ceremony result, not re-entered text, remains the source for automatic wallet setup. Sharing an existing secret generates and confirms *k−1* random initial shares -before deriving the remaining shares. Recovery requires exactly the declared +before deriving the remaining shares. Supplied Bitcoin seed bytes and existing +Bitcoin secrets must form a valid BIP32 root. `CreationCeremony.from_secret` +checks this before selecting an identifier or drawing entropy; a parsed +`MasterSeed` alone establishes format validity, not this creation precondition. +Core Lightning secrets have no BIP32-root requirement. +Recovery requires exactly the declared threshold of compatible shares with distinct indices. Derivation requires a new share index not used by its inputs. Every output is reparsed before release. diff --git a/src/codex32/generation.py b/src/codex32/generation.py index a99d0ac..818918f 100644 --- a/src/codex32/generation.py +++ b/src/codex32/generation.py @@ -295,13 +295,12 @@ def from_secret( """Start a ceremony that shares an existing validated secret.""" if not isinstance(secret, (MasterSeed, CoreLightningSecret)): raise TypeError("from_secret accepts only MasterSeed or CoreLightningSecret") + if isinstance(secret, MasterSeed) and not _valid_root(secret.seed_bytes): + raise CodexError("master seed does not form a valid BIP32 root") threshold = _threshold(threshold, allow_zero=False) random_identifier = identifier is None identifier = _random_identifier() if random_identifier else _identifier(identifier) - while (threshold, identifier) == ( - secret.header.threshold, - secret.header.identifier, - ): + while (threshold, identifier) == (secret.header.threshold, secret.header.identifier): if not random_identifier: raise HeaderCollision("new share set must use a different set header") identifier = _random_identifier() diff --git a/tests/test_generation.py b/tests/test_generation.py index 2ce8ab3..64870af 100644 --- a/tests/test_generation.py +++ b/tests/test_generation.py @@ -169,6 +169,23 @@ def test_supplied_seed_must_form_a_valid_bip32_root(monkeypatch: pytest.MonkeyPa generate_master_seed(bytes(16), identifier="test") +def test_existing_bitcoin_secret_requires_a_valid_root_before_any_entropy( + monkeypatch: pytest.MonkeyPatch, +) -> None: + source = MasterSeed.from_seed(bytes(range(16)), identifier="test") + lightning = generate_core_lightning_secret(bytes(range(32)), identifier="test") + monkeypatch.setattr(generation_module, "_valid_root", lambda _seed: False) + + def no_entropy(_length: int) -> bytes: + pytest.fail("invalid Bitcoin root reached entropy selection") + + monkeypatch.setattr(generation_module.secrets, "token_bytes", no_entropy) + with pytest.raises(CodexError, match="master seed does not form a valid BIP32 root"): + CreationCeremony.from_secret(source, threshold=2, indices="ac") + # BIP32 root validity does not apply to Core Lightning's HSM secret. + CreationCeremony.from_secret(lightning, threshold=2, indices="ac", identifier="name") + + def test_explicit_and_random_output_order_contracts() -> None: source = generate_master_seed(bytes(range(16)), identifier="test") _secret, shares = _complete(