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
2 changes: 1 addition & 1 deletion docs/developer/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ documentation and enforcement update.
installed as `codex32[gui]` and started by `codex32-gui`. It is a client of the
surface above and of the private Core adapter; nothing in `src/codex32/` imports
it, and the base install keeps its property of having no third-party runtime
dependency. It carries its own budget of 2,050 logical review lines, separate
dependency. It carries its own budget of 2,250 logical review lines, separate
from the 5,200 above. Its own boundaries are documented in
[`gui.md`](gui.md) and enforced by `tests/test_gui_boundaries.py`.

Expand Down
2 changes: 1 addition & 1 deletion docs/developer/gui.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ cryptography, entropy source, socket, or file storage.

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

## Security boundaries
Expand Down
10 changes: 10 additions & 0 deletions docs/security/model.md
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,16 @@ initialization.
`codex32_gui/wallet_setup.py`, the only module in that package that imports the
Core adapter.

Before listing wallets on restore, the GUI asks for the master fingerprint from
the separate wallet record without showing the recovered value. A mismatch
stops the attempt. The explicit no-record route reveals the recovered
fingerprint and backup-identifier assessment, then requires **Restore anyway**;
after that disclosure, this attempt cannot return to the record-entry route.
The chosen expected fingerprint is checked again before unlocking or creating
a destination and at the shared library import boundary. Fresh creation instead
shows its new fingerprint for the operator to record; there is no earlier
wallet identity to compare.

| Departure | Required behavior |
|---|---|
| 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. |
Expand Down
17 changes: 12 additions & 5 deletions docs/user/gui.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,11 @@ Copy each card to paper, hide the on-screen original, then type the paper copy
back. Read-back starts completely empty, including `MS1`. A mismatch highlights
only the groups you typed differently; the expected text stays hidden.

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

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

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

## Secret handling

Expand Down
143 changes: 128 additions & 15 deletions src/codex32_gui/pages.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
import time
from collections.abc import Callable, Sequence
from dataclasses import dataclass
from typing import Literal
from typing import Literal, TypeVar

from gi.repository import Adw, GLib, Gtk

Expand All @@ -35,6 +35,7 @@
Artifact = Share | Secret
Accept = Callable[[Artifact], None]
Timestamp = int | Literal["now"]
Result = TypeVar("Result")

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


def _then[Result](
def _then(
view: Adw.NavigationView,
page: Adw.NavigationPage,
follow: Callable[[Result], Adw.NavigationPage | None],
Expand Down Expand Up @@ -687,7 +688,7 @@ def _unshared_page(view: Adw.NavigationView, core: BitcoinCore, secret: MasterSe
position=0,
count=1,
confirm=lambda text: _compare(secret.text, text),
after=lambda: _wallets(view, core, secret, "now"),
after=lambda: _identity(view, core, secret),
cancel=lambda page: _abandon(view, page),
)

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

deliver = _then(view, page, follow, CARDS_SAFE)
work.run(view, page, ceremony.finish, deliver)
Expand All @@ -745,6 +746,7 @@ def _wallets(
core: BitcoinCore,
secret: MasterSeed,
timestamp: Timestamp,
expected: bytes | None,
*,
restoring: bool = False,
) -> None:
Expand All @@ -756,7 +758,7 @@ def _wallets(
_then(
view,
page,
lambda found: _wallet_page(view, core, secret, found, timestamp, restoring),
lambda found: _wallet_page(view, core, secret, found, timestamp, expected, restoring),
CARDS_SAFE,
),
)
Expand All @@ -768,6 +770,7 @@ def _wallet_page(
secret: MasterSeed,
found: tuple[wallet_setup.Wallet, ...],
timestamp: Timestamp,
expected: bytes | None,
restoring: bool,
) -> Adw.NavigationPage:
"""Name the wallet that will hold the keys. The library confirms that name again."""
Expand All @@ -781,13 +784,13 @@ def go() -> None:
if index < 0:
return
if index == len(current):
view.push(_new_wallet_page(view, core, secret, timestamp, restoring))
view.push(_new_wallet_page(view, core, secret, timestamp, expected, restoring))
return
chosen = current[index]
if chosen.locked:
view.push(_unlock_page(view, core, secret, chosen, timestamp, restoring))
view.push(_unlock_page(view, core, secret, chosen, timestamp, expected, restoring))
return
_import(view, core, secret, chosen.name, "", timestamp, restoring)
_import(view, core, secret, chosen.name, "", timestamp, expected, restoring)

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

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

def job() -> Record:
if restoring:
wallet_setup.verify(core, secret, expected)
wallet_setup.create(core, chosen, passphrase)
return _record(core, secret, chosen, timestamp, passphrase)
return _record(core, secret, chosen, timestamp, expected, passphrase)

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

def job() -> Record:
wallet_setup.require_unlocked(core, wallet.name)
return _record(core, secret, wallet.name, timestamp)
return _record(core, secret, wallet.name, timestamp, expected)

work.run(
view,
Expand All @@ -986,7 +993,7 @@ def job() -> Record:
)

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

content = _column(
_title(
Expand All @@ -1013,9 +1020,14 @@ def go() -> None:


def _record(
core: BitcoinCore, secret: MasterSeed, name: str, timestamp: Timestamp, passphrase: str = ""
core: BitcoinCore,
secret: MasterSeed,
name: str,
timestamp: Timestamp,
expected: bytes | None,
passphrase: str = "",
) -> Record:
final = wallet_setup.fill(core, secret, name, passphrase, timestamp=timestamp)
final = wallet_setup.fill(core, secret, name, passphrase, expected=expected, timestamp=timestamp)
return Record(
secret.header.identifier.upper(),
final,
Expand All @@ -1025,20 +1037,121 @@ def _record(
)


def _identity(
view: Adw.NavigationView, core: BitcoinCore, secret: MasterSeed, restoring: bool = False
) -> None:
"""Show a fresh identity for recording, or a no-record restore for confirmation."""
page = _working(view, "Wallet record", "Asking Bitcoin Core for the master fingerprint…")

def follow(identity: tuple[str, str]) -> Adw.NavigationPage:
fingerprint, note = identity
shown = (("Backup identifier", secret.header.identifier.upper()), ("Master fingerprint", fingerprint))
if not restoring:
return _page(
"Wallet record",
_column(
_title("Write this on your wallet record", "Keep the record apart from your cards."),
_rows("Identity", shown),
),
actions=_actions(
_button(
"I wrote it down",
lambda: _wallets(view, core, secret, "now", None),
style="suggested-action",
)
),
can_pop=False,
)
content = _column(
_title("Restore without a wallet record?", "Nothing here can prove these cards are your wallet."),
_rows("What the cards say", shown),
_note(note, "warning"),
_note(wallet_setup.NO_RECORD_WARNING, "warning"),
)
stop = _button("Stop", lambda: view.replace([home(view)]))
anyway = _button(
"Restore anyway",
lambda: _wallets(view, core, secret, 0, None, restoring=True),
style="destructive-action",
)
return _page("No wallet record", content, actions=_actions(stop, anyway), can_pop=False)

work.run(view, page, lambda: wallet_setup.identity(core, secret), _then(view, page, follow, CARDS_SAFE))


def _fingerprint_page(
view: Adw.NavigationView,
core: BitcoinCore,
secret: MasterSeed,
timestamp: Timestamp,
*,
restoring: bool = False,
problem: str = "",
) -> Adw.NavigationPage:
"""Ask for the record's fingerprint before any restore wallet is listed."""
entered = Adw.EntryRow(title="Master fingerprint from your wallet record")
group = Adw.PreferencesGroup()
group.add(entered)
status = _note(problem, "error" if problem else "")

def go() -> None:
try:
expected = wallet_setup.parse_fingerprint(entered.get_text())
except ValueError as error:
_say(status, str(error), "error")
return
page = _working(view, "Wallet record", "Checking the wallet record…")

def job() -> str:
try:
wallet_setup.verify(core, secret, expected)
except wallet_setup.FingerprintMismatch as error:
return str(error)
return ""

def follow(mismatch: str) -> Adw.NavigationPage | None:
if mismatch:
return _fingerprint_page(view, core, secret, timestamp, restoring=restoring, problem=mismatch)
_wallets(view, core, secret, timestamp, expected, restoring=restoring)
return None

work.run(view, page, job, _then(view, page, follow, CARDS_SAFE))

buttons = [_button("Stop", lambda: view.replace([home(view)]))]
if restoring:
buttons.append(
_button("I have no wallet record", lambda: _identity(view, core, secret, restoring=True))
)
buttons.append(_button("Check and continue", go, style="suggested-action"))
content = _column(
_title(
"Type the master fingerprint", "Copy it from the wallet record you keep apart from the cards."
),
group,
status,
_note(
"If it does not match, stop: these cards are not that wallet. Bitcoin Core has not been changed.",
"warning",
),
)
return _page("Wallet record", content, actions=_actions(*buttons), can_pop=False)


def _import(
view: Adw.NavigationView,
core: BitcoinCore,
secret: MasterSeed,
name: str,
passphrase: str,
timestamp: Timestamp,
expected: bytes | None,
restoring: bool = False,
) -> None:
page = _working(view, "Bitcoin Core", f"Writing your keys into {name}…")
work.run(
view,
page,
lambda: _record(core, secret, name, timestamp, passphrase),
lambda: _record(core, secret, name, timestamp, expected, passphrase),
_then(view, page, lambda record: _finished_page(view, record, restoring), CARDS_SAFE),
)

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


def _start_restore(view: Adw.NavigationView) -> None:
Expand Down
Loading
Loading