Skip to content
Draft
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
61 changes: 0 additions & 61 deletions src/codex32/_cli_input.py
Original file line number Diff line number Diff line change
Expand Up @@ -214,67 +214,6 @@ def _confirm_correction(
).strip().lower() in ("y", "yes")


def _entered_groups(observed: str, expected: str) -> tuple[list[str], set[int]]:
# Keep entered tokens whole unless they exactly span complete groups.
# Unspaced input retains edit/group minimization and edit-order ties.
positions = [i for i, char in enumerate(observed) if not char.isspace()]
compact = "".join(observed.split())
expected = "".join(expected.split()).lower()
tokens = observed.split()
grouped = len(tokens) > 1 and compact.lower() != expected
boundaries = {0}
splits: set[tuple[int, int]] = set()
position = 0
for token in tokens:
for offset in range(0, len(expected), 4):
if expected[offset : offset + len(token)] == token.lower() and (
len(token) % 4 == 0 or offset + len(token) == len(expected)
):
splits.update((position + i, offset + i) for i in range(4, len(token), 4))
position += len(token)
boundaries.add(position)
scores = {0: (0, 0, b"", ())} # type: dict[int, tuple[int, int, bytes, tuple[int, ...]]]
for offset in range(0, len(expected), 4):
canonical = expected[offset : offset + 4]
following: dict[int, tuple[int, int, bytes, tuple[int, ...]]] = {}
for start, (edits, disturbed, trace, ends) in scores.items():
row = [(j, b"\x03" * j) for j in range(len(canonical) + 1)]
for end in range(start, len(compact) + 1):
if end > start:
current = [(end - start, b"\x02" * (end - start))]
for j, char in enumerate(canonical, 1):
edit = compact[end - 1].lower() != char
current.append(
min(
(row[j - 1][0] + edit, row[j - 1][1] + bytes([edit])),
(row[j][0] + 1, row[j][1] + b"\x02"),
(current[j - 1][0] + 1, current[j - 1][1] + b"\x03"),
)
)
row = current
if grouped and end not in boundaries and (end, offset + len(canonical)) not in splits:
continue
score = (
edits + row[-1][0],
disturbed + (not grouped and compact[start:end].lower() != canonical),
trace + row[-1][1],
(*ends, end),
)
if end not in following or score < following[end]:
following[end] = score
scores = following
groups = []
start = 0
for end in scores[len(compact)][3]:
stop = positions[end] if end < len(positions) else len(observed)
groups.append(observed[start:stop])
start = stop
changed = {
i for i, value in enumerate(groups) if "".join(value.split()).lower() != expected[i * 4 : i * 4 + 4]
}
return groups, changed


def _render_groups(
groups: list[str],
changed: set[int],
Expand Down
42 changes: 20 additions & 22 deletions src/codex32/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import json
import sys
from collections.abc import Callable, Sequence
from functools import partial
from typing import Literal, NamedTuple, cast

from codex32._bitcoin_core import (
Expand All @@ -23,7 +24,6 @@
_card_text,
_confirm_correction,
_corrections,
_entered_groups,
_fingerprint_matcher,
_render_groups,
_require_correction_confirmation,
Expand Down Expand Up @@ -55,6 +55,7 @@
from codex32.generation import (
ConfirmationResult,
CreationCeremony,
_confirmation,
generate_master_seed,
)
from codex32.wallet import master_xprv
Expand Down Expand Up @@ -274,10 +275,13 @@ def _confirm_card(artifact: Artifact, confirm: Callable[[str], ConfirmationResul
preserve_groups=True,
)
expected = artifact.text
groups, changed = _entered_groups(entered, expected)
while changed:
start = min(changed)
end = start + 1
confirm = confirm or partial(_confirmation, expected=expected)
result = confirm(entered)
groups, changed = list(result.entered_groups), {group - 1 for group in result.mismatched_groups}
while not result.accepted:
if not changed:
raise RuntimeError("Confirmation rejected.")
start = end = min(changed)
while end in changed:
end += 1
_print(_render_groups(groups, changed, len(expected), range(start, end)), err=True)
Expand All @@ -293,26 +297,20 @@ def _confirm_card(artifact: Artifact, confirm: Callable[[str], ConfirmationResul
)
compact = "".join(replacement.split())
if compact.lower() == expected.lower():
groups = [replacement]
break
if len(compact) > len(expected[start * 4 : end * 4]) and compact.lower().startswith(
groups, changed = [replacement], set()
elif len(compact) > len(expected[start * 4 : end * 4]) and compact.lower().startswith(
(expected.split("1", 1)[0] + "1").lower()
):
_print(
"Please re-enter only the highlighted region that remains incorrect.",
err=True,
)
continue
if not compact:
_print("Please re-enter only the highlighted region that remains incorrect.", err=True)
continue
groups[start:end], remaining = _entered_groups(replacement, expected[start * 4 : end * 4])
changed.difference_update(range(start, end))
changed.update(start + i for i in remaining)
entered = "".join(groups)
if "".join(entered.split()).lower() != expected.lower():
raise RuntimeError("Confirmation mismatch.")
if confirm is not None and not confirm(entered).accepted:
raise RuntimeError("Confirmation rejected.")
elif compact:
# Align a retry only within its region so confirmed neighbors never shift.
region = _confirmation(replacement, expected[start * 4 : end * 4])
groups[start:end] = region.entered_groups
changed.difference_update(range(start, end))
changed.update(start + group - 1 for group in region.mismatched_groups)
if not changed:
result = confirm("".join(groups))


def _show_fingerprint(core: BitcoinCore, secret: MasterSeed, action: str) -> None:
Expand Down
86 changes: 76 additions & 10 deletions src/codex32/generation.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,78 @@ class ConfirmationResult:

accepted: bool
mismatched_groups: tuple[int, ...] = ()
entered_groups: tuple[str, ...] = ()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Exclude entered recovery text from the generated representation

When a caller prints or logs the result of CreationCeremony.confirm(), the dataclass-generated repr() and str() now expose every entered group, including the complete valid share on successful confirmation. Previously this public result contained only acceptance status and group indices. This bypasses the project's redacted artifact representations and violates the mandatory security contract keeping secrets out of logs and ordinary output. Mark entered_groups with repr=False or provide a redacted representation while retaining explicit access for presentation.

AGENTS.md reference: AGENTS.md:L70-L72

Useful? React with 👍 / 👎.



def _confirmation(text: str, expected: str) -> ConfirmationResult:
"""Compare re-entered text with a card, group by group, allowing shifts."""
count = -(-len("".join(expected.split())) // 4)
if len("".join(text.split())) > 4 * count + 16:
# Alignment is quadratic in the entry, so refuse text far longer than the card.
return ConfirmationResult(False, tuple(range(1, count + 1)), (text,) + ("",) * (count - 1))
groups, changed = _entered_groups(text, expected)
return ConfirmationResult(not changed, tuple(index + 1 for index in sorted(changed)), tuple(groups))


def _entered_groups(observed: str, expected: str) -> tuple[list[str], set[int]]:
# Keep entered tokens whole unless they exactly span complete groups.
# Unspaced input retains edit/group minimization and edit-order ties.
positions = [i for i, char in enumerate(observed) if not char.isspace()]
compact = "".join(observed.split())
expected = "".join(expected.split()).lower()
tokens = observed.split()
grouped = len(tokens) > 1 and compact.lower() != expected
boundaries = {0}
splits: set[tuple[int, int]] = set()
position = 0
for token in tokens:
for offset in range(0, len(expected), 4):
if expected[offset : offset + len(token)] == token.lower() and (
len(token) % 4 == 0 or offset + len(token) == len(expected)
):
splits.update((position + i, offset + i) for i in range(4, len(token), 4))
position += len(token)
boundaries.add(position)
scores = {0: (0, 0, b"", ())} # type: dict[int, tuple[int, int, bytes, tuple[int, ...]]]
for offset in range(0, len(expected), 4):
canonical = expected[offset : offset + 4]
following: dict[int, tuple[int, int, bytes, tuple[int, ...]]] = {}
for start, (edits, disturbed, trace, ends) in scores.items():
row = [(j, b"\x03" * j) for j in range(len(canonical) + 1)]
for end in range(start, len(compact) + 1):
if end > start:
current = [(end - start, b"\x02" * (end - start))]
for j, char in enumerate(canonical, 1):
edit = compact[end - 1].lower() != char
current.append(
min(
(row[j - 1][0] + edit, row[j - 1][1] + bytes([edit])),
(row[j][0] + 1, row[j][1] + b"\x02"),
(current[j - 1][0] + 1, current[j - 1][1] + b"\x03"),
)
)
row = current
if grouped and end not in boundaries and (end, offset + len(canonical)) not in splits:
continue
score = (
edits + row[-1][0],
disturbed + (not grouped and compact[start:end].lower() != canonical),
trace + row[-1][1],
(*ends, end),
)
if end not in following or score < following[end]:
following[end] = score
scores = following
groups = []
start = 0
for end in scores[len(compact)][3]:
stop = positions[end] if end < len(positions) else len(observed)
groups.append(observed[start:stop])
start = stop
changed = {
i for i, value in enumerate(groups) if "".join(value.split()).lower() != expected[i * 4 : i * 4 + 4]
}
return groups, changed


def _threshold(value: object, *, allow_zero: bool = True) -> int:
Expand Down Expand Up @@ -305,20 +377,14 @@ def confirm(self, text: str) -> ConfirmationResult:
raise CeremonyStateError("request a card before confirming it")
if not isinstance(text, str):
raise TypeError("confirmation text must be str")
observed = "".join(text.split()).lower()
expected = self._pending.text.lower()
mismatched = tuple(
group + 1
for group in range((max(len(observed), len(expected)) + 3) // 4)
if observed[group * 4 : group * 4 + 4] != expected[group * 4 : group * 4 + 4]
)
if mismatched:
return ConfirmationResult(False, mismatched)
result = _confirmation(text, self._pending.text)
if not result.accepted:
return result
if self._position < self._direct_count:
self._basis.append(self._pending)
self._position += 1
self._pending = None
return ConfirmationResult(True)
return result

def finish(self) -> MasterSeed:
"""Return the secret after every requested card is confirmed."""
Expand Down
28 changes: 18 additions & 10 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
from codex32.bip93 import SEED_BYTE_LENGTHS
from codex32.checksums import _CODEX32, _CODEX32_LONG
from codex32.cli import main, ms_main
from codex32.generation import _fingerprint_identifier
from codex32.generation import _confirmation, _fingerprint_identifier
from tools._wallet_test_vectors import stub_fingerprint


Expand Down Expand Up @@ -1457,7 +1457,7 @@ def test_confirmation_alignment_shows_only_entered_text(
expected = "abcdefghWXYZijklmnop" if observed == "abcdefghijklmnop" else "abcdefghijklmnop"
if observed == "aaaaaaa":
expected = "aaaaaaaa"
groups, changed = input_module._entered_groups(observed, expected)
groups, changed = importlib.import_module("codex32.generation")._entered_groups(observed, expected)
actual = input_module._render_groups(groups, changed, len(expected))
plain = re.sub(r"\x1b\[[0-9;]*m", "", actual)

Expand All @@ -1467,6 +1467,16 @@ def test_confirmation_alignment_shows_only_entered_text(
assert "".join(plain.replace("_", "").split()).lower() == observed.lower()


def test_confirmation_refuses_text_far_longer_than_the_card() -> None:
expected = VECTOR_1["secret_s"]

result = _confirmation(expected * 50, expected)

assert not result.accepted and result.mismatched_groups == tuple(range(1, 13))
assert "".join(result.entered_groups) == expected * 50
assert _confirmation(expected + "q" * 16, expected).mismatched_groups == (12,)


def test_interrupted_creation_marks_partial_cards_void() -> None:
stdout, stderr = _TTYOutput(), io.StringIO()
with (
Expand Down Expand Up @@ -2098,7 +2108,7 @@ def test_production_size_budgets_are_enforced() -> None:
def test_confirmation_alignment_assigns_complete_canonical_groups(
observed: str, expected: str, groups: list[str], changed: set[int]
) -> None:
from codex32._cli_input import _entered_groups
from codex32.generation import _entered_groups

assert _entered_groups(observed, expected) == (groups, changed)
assert "".join(groups) == observed
Expand Down Expand Up @@ -2139,7 +2149,7 @@ def answer(prompt: str) -> str:

def confirm(text: str) -> ConfirmationResult:
confirmed.append(text)
return ConfirmationResult("".join(text.split()).lower() == artifact.text.lower())
return _confirmation(text, artifact.text)

with contextlib.redirect_stderr(output):
cli_module._confirm_card(artifact, confirm)
Expand All @@ -2152,8 +2162,7 @@ def confirm(text: str) -> ConfirmationResult:
assert artifact.text[16:20] in shown
assert len(re.findall(r"\x1b\[(?:1|22);7;31m", shown)) == 1
assert snapshots[1] == snapshots[2] == snapshots[3]
assert len(confirmed) == 1
assert "".join(confirmed[0].split()).lower() == artifact.text.lower()
assert "".join(confirmed[-1].split()).lower() == artifact.text.lower()
if readline:
assert editor.inserted == prefills

Expand Down Expand Up @@ -2255,7 +2264,7 @@ def test_confirmation_placeholders_only_replace_wholly_omitted_groups(
def test_confirmation_grouped_alignment_preserves_token_ownership(
observed: str, expected: str, groups: list[str], changed: set[int]
) -> None:
from codex32._cli_input import _entered_groups
from codex32.generation import _entered_groups

assert _entered_groups(observed, expected) == (groups, changed)
assert "".join(groups) == observed
Expand Down Expand Up @@ -2294,7 +2303,7 @@ def answer(prompt: str) -> str:

def confirm(value: str) -> ConfirmationResult:
confirmed.append(value)
return ConfirmationResult("".join(value.split()).upper() == Card.text)
return _confirmation(value, Card.text)

monkeypatch.setattr(cli_module, "_text", read)
monkeypatch.setattr(input_module.sys, "stdin", _TTYInput())
Expand All @@ -2308,9 +2317,8 @@ def confirm(value: str) -> ConfirmationResult:
assert "\x1b[1mEFGH\x1b[0m" in snapshots[4]
assert "\x1b[1;7;31mABC\x1b[0m" in snapshots[4]
assert "Please re-enter only the highlighted region that remains incorrect." in output.getvalue()
assert len(confirmed) == 1
if full_retry:
assert confirmed == [final]
assert confirmed[-1] == final
if readline:
assert editor.inserted == prefills

Expand Down
4 changes: 4 additions & 0 deletions tests/test_generation.py
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,10 @@ def recorded(length: int) -> bytes:
damaged = first.text[:4] + ("q" if first.text[4] != "q" else "p") + first.text[5:]
result = ceremony.confirm(damaged)
assert not result.accepted and result.mismatched_groups == (2,)
dropped = ceremony.confirm(first.text[:9] + first.text[10:])
# A dropped character marks only its own group, not every later group.
assert not dropped.accepted and dropped.mismatched_groups == (3,)
assert "".join(dropped.entered_groups) == first.text[:9] + first.text[10:]
assert calls == [26]
assert ceremony.confirm(" \n".join(first.text.upper())).accepted
second = ceremony.next_share()
Expand Down
Loading