Skip to content

Commit d5b671a

Browse files
committed
gui: Gate restores on wallet identity
A checksum-valid recovered seed may not be the operator’s wallet. Ask for the independent record fingerprint before listing wallets, then carry it through existing and newly created destinations so a mismatch stops before unlock, create, or import. Keep the explicit no-record visual fallback, but do not allow its revealed fingerprint back into the recorded route in the same attempt. Raise the separately enforced GUI review cap to the authorized 2,250 lines for this gate. Keep the combined Python 3.10-3.15 package compatible with the GUI code and passphrase encoding check. Exercise the focused boundary and display walkthrough with synthetic data. Refs #26 and #28.
1 parent b41ea2f commit d5b671a

10 files changed

Lines changed: 282 additions & 35 deletions

File tree

‎docs/developer/api.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ documentation and enforcement update.
117117
installed as `codex32[gui]` and started by `codex32-gui`. It is a client of the
118118
surface above and of the private Core adapter; nothing in `src/codex32/` imports
119119
it, and the base install keeps its property of having no third-party runtime
120-
dependency. It carries its own budget of 2,050 logical review lines, separate
120+
dependency. It carries its own budget of 2,250 logical review lines, separate
121121
from the 5,200 above. Its own boundaries are documented in
122122
[`gui.md`](gui.md) and enforced by `tests/test_gui_boundaries.py`.
123123

‎docs/developer/gui.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ cryptography, entropy source, socket, or file storage.
1919

2020
Review `reading.py`, `wallet_setup.py`, and `work.py` first. Their behavior is
2121
covered without a display. `tools/gui_walkthrough.py` exercises the real GTK
22-
screens under Xvfb. `tests/test_gui_boundaries.py` enforces a separate 2,050
22+
screens under Xvfb. `tests/test_gui_boundaries.py` enforces a separate 2,250
2323
logical-line GUI budget.
2424

2525
## Security boundaries

‎docs/security/model.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,16 @@ initialization.
259259
`codex32_gui/wallet_setup.py`, the only module in that package that imports the
260260
Core adapter.
261261

262+
Before listing wallets on restore, the GUI asks for the master fingerprint from
263+
the separate wallet record without showing the recovered value. A mismatch
264+
stops the attempt. The explicit no-record route reveals the recovered
265+
fingerprint and backup-identifier assessment, then requires **Restore anyway**;
266+
after that disclosure, this attempt cannot return to the record-entry route.
267+
The chosen expected fingerprint is checked again before unlocking or creating
268+
a destination and at the shared library import boundary. Fresh creation instead
269+
shows its new fingerprint for the operator to record; there is no earlier
270+
wallet identity to compare.
271+
262272
| Departure | Required behavior |
263273
|---|---|
264274
| Passphrase | The operator may supply a Bitcoin Core wallet passphrase. It reaches `bitcoin-cli` through `-stdinwalletpassphrase`, never through an argument, so it is absent from `/proc` and process listings. It is not stored, not logged, and not written to disk, and a passphrase containing a line break is refused rather than truncated. A passphrase this computer's locale would encode as something other than what Bitcoin-Qt sends is refused, so no half-encoded secret reaches a screen or a traceback. The screen keeps the command line's behavior as an alternative: the operator may unlock in Bitcoin-Qt instead, and the program then only rechecks wallet state. |

‎docs/user/gui.md‎

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -66,9 +66,11 @@ Copy each card to paper, hide the on-screen original, then type the paper copy
6666
back. Read-back starts completely empty, including `MS1`. A mismatch highlights
6767
only the groups you typed differently; the expected text stays hidden.
6868

69-
After all cards are confirmed, choose an empty Bitcoin Core wallet or create a
70-
new blank one. A passphrase protects the wallet on this computer; the recovery
71-
cards still recover the seed if that passphrase is lost.
69+
After all cards are confirmed, write the displayed master fingerprint on your
70+
wallet record and acknowledge that you have recorded it. Then choose an empty
71+
Bitcoin Core wallet or create a new blank one. A passphrase protects the wallet
72+
on this computer; the recovery cards still recover the seed if that passphrase
73+
is lost.
7274

7375
Copy the final wallet details to the
7476
[wallet record](wallet-verification-record.html) and store it separately from the
@@ -103,8 +105,13 @@ exist. The GUI can say “any 2 cards recover the wallet”; it cannot infer “
103105
3”. `ms32 share` can add another card at any time.
104106

105107
A valid checksum shows that a card is internally consistent. It does not prove
106-
that the card belongs to your wallet. Restore and compare the master fingerprint
107-
with your wallet record.
108+
that the card belongs to your wallet. Before restoring into Bitcoin Core, type
109+
the master fingerprint from your separate wallet record; a mismatch stops before
110+
any wallet is opened or created. If you have no record, the GUI instead shows the
111+
recovered fingerprint and what the backup identifier says about the seed, then
112+
requires a separate **Restore anyway** choice. This fallback detects some
113+
mistakes but does not authenticate the intended wallet; check its history and
114+
addresses before sending funds.
108115

109116
## Secret handling
110117

‎src/codex32_gui/pages.py‎

Lines changed: 128 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
import time
1111
from collections.abc import Callable, Sequence
1212
from dataclasses import dataclass
13-
from typing import Literal
13+
from typing import Literal, TypeVar
1414

1515
from gi.repository import Adw, GLib, Gtk
1616

@@ -35,6 +35,7 @@
3535
Artifact = Share | Secret
3636
Accept = Callable[[Artifact], None]
3737
Timestamp = int | Literal["now"]
38+
Result = TypeVar("Result")
3839

3940
LEVELS = ("dim-label", "error", "warning", "success")
4041
DONE_ICON = "object-select-symbolic"
@@ -294,7 +295,7 @@ def _working(view: Adw.NavigationView, title: str, message: str) -> Adw.Navigati
294295
return page
295296

296297

297-
def _then[Result](
298+
def _then(
298299
view: Adw.NavigationView,
299300
page: Adw.NavigationPage,
300301
follow: Callable[[Result], Adw.NavigationPage | None],
@@ -687,7 +688,7 @@ def _unshared_page(view: Adw.NavigationView, core: BitcoinCore, secret: MasterSe
687688
position=0,
688689
count=1,
689690
confirm=lambda text: _compare(secret.text, text),
690-
after=lambda: _wallets(view, core, secret, "now"),
691+
after=lambda: _identity(view, core, secret),
691692
cancel=lambda page: _abandon(view, page),
692693
)
693694

@@ -731,7 +732,7 @@ def follow(secret: MasterSeed | CoreLightningSecret) -> None:
731732
if not isinstance(secret, MasterSeed):
732733
_failure(view, "That ceremony did not produce a Bitcoin master seed.")
733734
return
734-
_wallets(view, core, secret, "now")
735+
_identity(view, core, secret)
735736

736737
deliver = _then(view, page, follow, CARDS_SAFE)
737738
work.run(view, page, ceremony.finish, deliver)
@@ -745,6 +746,7 @@ def _wallets(
745746
core: BitcoinCore,
746747
secret: MasterSeed,
747748
timestamp: Timestamp,
749+
expected: bytes | None,
748750
*,
749751
restoring: bool = False,
750752
) -> None:
@@ -756,7 +758,7 @@ def _wallets(
756758
_then(
757759
view,
758760
page,
759-
lambda found: _wallet_page(view, core, secret, found, timestamp, restoring),
761+
lambda found: _wallet_page(view, core, secret, found, timestamp, expected, restoring),
760762
CARDS_SAFE,
761763
),
762764
)
@@ -768,6 +770,7 @@ def _wallet_page(
768770
secret: MasterSeed,
769771
found: tuple[wallet_setup.Wallet, ...],
770772
timestamp: Timestamp,
773+
expected: bytes | None,
771774
restoring: bool,
772775
) -> Adw.NavigationPage:
773776
"""Name the wallet that will hold the keys. The library confirms that name again."""
@@ -781,13 +784,13 @@ def go() -> None:
781784
if index < 0:
782785
return
783786
if index == len(current):
784-
view.push(_new_wallet_page(view, core, secret, timestamp, restoring))
787+
view.push(_new_wallet_page(view, core, secret, timestamp, expected, restoring))
785788
return
786789
chosen = current[index]
787790
if chosen.locked:
788-
view.push(_unlock_page(view, core, secret, chosen, timestamp, restoring))
791+
view.push(_unlock_page(view, core, secret, chosen, timestamp, expected, restoring))
789792
return
790-
_import(view, core, secret, chosen.name, "", timestamp, restoring)
793+
_import(view, core, secret, chosen.name, "", timestamp, expected, restoring)
791794

792795
continue_button = _button("Continue", go, style="suggested-action")
793796

@@ -876,6 +879,7 @@ def _new_wallet_page(
876879
core: BitcoinCore,
877880
secret: MasterSeed,
878881
timestamp: Timestamp,
882+
expected: bytes | None,
879883
restoring: bool = False,
880884
) -> Adw.NavigationPage:
881885
"""Ask Bitcoin Core for one blank wallet, with a passphrase the operator chooses."""
@@ -893,8 +897,10 @@ def make(passphrase: str) -> None:
893897
page = _working(view, "Bitcoin Core", "Creating the wallet and writing your keys into it…")
894898

895899
def job() -> Record:
900+
if restoring:
901+
wallet_setup.verify(core, secret, expected)
896902
wallet_setup.create(core, chosen, passphrase)
897-
return _record(core, secret, chosen, timestamp, passphrase)
903+
return _record(core, secret, chosen, timestamp, expected, passphrase)
898904

899905
work.run(
900906
view,
@@ -948,6 +954,7 @@ def _unlock_page(
948954
secret: MasterSeed,
949955
wallet: wallet_setup.Wallet,
950956
timestamp: Timestamp,
957+
expected: bytes | None,
951958
restoring: bool = False,
952959
) -> Adw.NavigationPage:
953960
"""Unlock one already encrypted wallet, or step aside and let Bitcoin Core do it."""
@@ -976,7 +983,7 @@ def check() -> None:
976983

977984
def job() -> Record:
978985
wallet_setup.require_unlocked(core, wallet.name)
979-
return _record(core, secret, wallet.name, timestamp)
986+
return _record(core, secret, wallet.name, timestamp, expected)
980987

981988
work.run(
982989
view,
@@ -986,7 +993,7 @@ def job() -> Record:
986993
)
987994

988995
def go() -> None:
989-
_import(view, core, secret, wallet.name, field.get_text(), timestamp, restoring)
996+
_import(view, core, secret, wallet.name, field.get_text(), timestamp, expected, restoring)
990997

991998
content = _column(
992999
_title(
@@ -1013,9 +1020,14 @@ def go() -> None:
10131020

10141021

10151022
def _record(
1016-
core: BitcoinCore, secret: MasterSeed, name: str, timestamp: Timestamp, passphrase: str = ""
1023+
core: BitcoinCore,
1024+
secret: MasterSeed,
1025+
name: str,
1026+
timestamp: Timestamp,
1027+
expected: bytes | None,
1028+
passphrase: str = "",
10171029
) -> Record:
1018-
final = wallet_setup.fill(core, secret, name, passphrase, timestamp=timestamp)
1030+
final = wallet_setup.fill(core, secret, name, passphrase, expected=expected, timestamp=timestamp)
10191031
return Record(
10201032
secret.header.identifier.upper(),
10211033
final,
@@ -1025,20 +1037,121 @@ def _record(
10251037
)
10261038

10271039

1040+
def _identity(
1041+
view: Adw.NavigationView, core: BitcoinCore, secret: MasterSeed, restoring: bool = False
1042+
) -> None:
1043+
"""Show a fresh identity for recording, or a no-record restore for confirmation."""
1044+
page = _working(view, "Wallet record", "Asking Bitcoin Core for the master fingerprint…")
1045+
1046+
def follow(identity: tuple[str, str]) -> Adw.NavigationPage:
1047+
fingerprint, note = identity
1048+
shown = (("Backup identifier", secret.header.identifier.upper()), ("Master fingerprint", fingerprint))
1049+
if not restoring:
1050+
return _page(
1051+
"Wallet record",
1052+
_column(
1053+
_title("Write this on your wallet record", "Keep the record apart from your cards."),
1054+
_rows("Identity", shown),
1055+
),
1056+
actions=_actions(
1057+
_button(
1058+
"I wrote it down",
1059+
lambda: _wallets(view, core, secret, "now", None),
1060+
style="suggested-action",
1061+
)
1062+
),
1063+
can_pop=False,
1064+
)
1065+
content = _column(
1066+
_title("Restore without a wallet record?", "Nothing here can prove these cards are your wallet."),
1067+
_rows("What the cards say", shown),
1068+
_note(note, "warning"),
1069+
_note(wallet_setup.NO_RECORD_WARNING, "warning"),
1070+
)
1071+
stop = _button("Stop", lambda: view.replace([home(view)]))
1072+
anyway = _button(
1073+
"Restore anyway",
1074+
lambda: _wallets(view, core, secret, 0, None, restoring=True),
1075+
style="destructive-action",
1076+
)
1077+
return _page("No wallet record", content, actions=_actions(stop, anyway), can_pop=False)
1078+
1079+
work.run(view, page, lambda: wallet_setup.identity(core, secret), _then(view, page, follow, CARDS_SAFE))
1080+
1081+
1082+
def _fingerprint_page(
1083+
view: Adw.NavigationView,
1084+
core: BitcoinCore,
1085+
secret: MasterSeed,
1086+
timestamp: Timestamp,
1087+
*,
1088+
restoring: bool = False,
1089+
problem: str = "",
1090+
) -> Adw.NavigationPage:
1091+
"""Ask for the record's fingerprint before any restore wallet is listed."""
1092+
entered = Adw.EntryRow(title="Master fingerprint from your wallet record")
1093+
group = Adw.PreferencesGroup()
1094+
group.add(entered)
1095+
status = _note(problem, "error" if problem else "")
1096+
1097+
def go() -> None:
1098+
try:
1099+
expected = wallet_setup.parse_fingerprint(entered.get_text())
1100+
except ValueError as error:
1101+
_say(status, str(error), "error")
1102+
return
1103+
page = _working(view, "Wallet record", "Checking the wallet record…")
1104+
1105+
def job() -> str:
1106+
try:
1107+
wallet_setup.verify(core, secret, expected)
1108+
except wallet_setup.FingerprintMismatch as error:
1109+
return str(error)
1110+
return ""
1111+
1112+
def follow(mismatch: str) -> Adw.NavigationPage | None:
1113+
if mismatch:
1114+
return _fingerprint_page(view, core, secret, timestamp, restoring=restoring, problem=mismatch)
1115+
_wallets(view, core, secret, timestamp, expected, restoring=restoring)
1116+
return None
1117+
1118+
work.run(view, page, job, _then(view, page, follow, CARDS_SAFE))
1119+
1120+
buttons = [_button("Stop", lambda: view.replace([home(view)]))]
1121+
if restoring:
1122+
buttons.append(
1123+
_button("I have no wallet record", lambda: _identity(view, core, secret, restoring=True))
1124+
)
1125+
buttons.append(_button("Check and continue", go, style="suggested-action"))
1126+
content = _column(
1127+
_title(
1128+
"Type the master fingerprint", "Copy it from the wallet record you keep apart from the cards."
1129+
),
1130+
group,
1131+
status,
1132+
_note(
1133+
"If it does not match, stop: these cards are not that wallet. Bitcoin Core has not been changed.",
1134+
"warning",
1135+
),
1136+
)
1137+
return _page("Wallet record", content, actions=_actions(*buttons), can_pop=False)
1138+
1139+
10281140
def _import(
10291141
view: Adw.NavigationView,
10301142
core: BitcoinCore,
10311143
secret: MasterSeed,
10321144
name: str,
10331145
passphrase: str,
10341146
timestamp: Timestamp,
1147+
expected: bytes | None,
10351148
restoring: bool = False,
10361149
) -> None:
10371150
page = _working(view, "Bitcoin Core", f"Writing your keys into {name}…")
10381151
work.run(
10391152
view,
10401153
page,
1041-
lambda: _record(core, secret, name, timestamp, passphrase),
1154+
lambda: _record(core, secret, name, timestamp, expected, passphrase),
10421155
_then(view, page, lambda record: _finished_page(view, record, restoring), CARDS_SAFE),
10431156
)
10441157

@@ -1536,7 +1649,7 @@ def _restore(view: Adw.NavigationView, core: BitcoinCore, secret: Secret) -> Non
15361649
if not isinstance(secret, MasterSeed):
15371650
_failure(view, "Only a Bitcoin master-seed backup can restore a wallet.")
15381651
return
1539-
_wallets(view, core, secret, 0, restoring=True)
1652+
_replace(view, _fingerprint_page(view, core, secret, 0, restoring=True))
15401653

15411654

15421655
def _start_restore(view: Adw.NavigationView) -> None:

0 commit comments

Comments
 (0)