Skip to content
Open
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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,8 +122,8 @@ codex32 strings.

Printable forms:

- [codex32 recovery card](docs/user/recovery-card.html)
- [wallet-verification record](docs/user/wallet-verification-record.html)
- [codex32 recovery card](src/codex32_gui/forms/recovery-card.html)
- [wallet-verification record](src/codex32_gui/forms/wallet-verification-record.html)

## For developers and reviewers

Expand Down
2 changes: 1 addition & 1 deletion docs/developer/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,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 1,800 logical review lines, separate
dependency. It carries its own budget of 2,050 logical review lines, separate
from the 5,000 above. Its own boundaries are documented in
[`gui.md`](gui.md) and enforced by `tests/test_gui_boundaries.py`.

Expand Down
10 changes: 7 additions & 3 deletions docs/developer/gui.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,13 @@ without a display and run in ordinary CI. `tools/gui_walkthrough.py` drives the
real widgets through every task under a throwaway X server and is the cheapest
way to see the screens without a desktop.

The package carries its own budget of 2,000 logical lines, separate from the
The package carries its own budget of 2,050 logical lines, separate from the
5,000 the installed library keeps, and `tests/test_gui_boundaries.py` enforces
it. The plan proposed 1,000 before the screens were written and the budget was
1,800 before the security review of 2026-09-19; that review's remediations are
about 250 lines, and the rest of the difference is user-facing wording in
`pages.py`, which is the first priority this program was built for.
`pages.py`, which is the first priority this program was built for. It was
2,000 until the **Before you start** page and handwriting key of 2026-10-03.

## Claims, and how to check each one

Expand All @@ -43,7 +44,10 @@ about 250 lines, and the rest of the difference is user-facing wording in
`hashlib`, or `hmac`. Entropy belongs to `CreationCeremony`.
2. **No network.** Nothing imports `socket`, `ssl`, `urllib`, or `http`, and no
module imports `subprocess`. The only child process is the `bitcoin-cli` the
library already starts.
library already starts. Separately, `_ready_page` may ask the desktop to open
a bundled blank form with `Gtk.FileLauncher`; it is the only caller. It
passes a path under `codex32_gui/forms/`, or under `CODEX32_FORMS_DIR` when a
launcher has copied the forms where a confined browser can read them.
3. **Nothing reaches disk.** Nothing imports `os`, `pathlib`, `io`, `tempfile`,
`shutil`, `pickle`, `sqlite3`, or `logging`, and nothing calls `open`. There
is no settings file, no recent list, no log, and no clipboard write.
Expand Down
7 changes: 6 additions & 1 deletion docs/security/model.md
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,12 @@ fingerprint, the identifier result, and the warning before the operator chooses.

The program draws no entropy, opens no socket, starts no process of its own, and
writes no file: no settings, no recent list, no log, and no clipboard write of
recovery text. Entered recovery text is cleared when its screen is left, subject
recovery text. One button pair is the exception to "no process": **Before you
start** can ask the desktop, through GTK's `FileLauncher`, to open one of the two
blank printable forms shipped in `codex32_gui/forms/`. The desktop chooses and
starts the viewer. Only those forms are passed, never recovery text, and this
happens before any seed is drawn. A launcher may set `CODEX32_FORMS_DIR` to a
copy of the forms that a confined browser can read; Bails does this on Tails. Entered recovery text is cleared when its screen is left, subject
to the zeroization limitation above.

Two disclosure channels belong to the toolkit rather than to this program, and
Expand Down
22 changes: 16 additions & 6 deletions docs/user/gui.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,15 +62,21 @@ Choose how many cards you want. Three cards where any two recover the wallet is
the recommended shape: one card can be lost, burned or stolen and your bitcoin is
still safe, and one card on its own tells a finder nothing.

Next, **Before you start** asks you to have one blank recovery card per card, a
pen, and one wallet record ready. Its buttons open the printable
[recovery card](../../src/codex32_gui/forms/recovery-card.html) and
[wallet record](../../src/codex32_gui/forms/wallet-verification-record.html)
forms in your browser. Press **I have them ready** to see the first card.

Each card is shown once. Copy it onto paper with a pen, then type it back from
the paper with the original off the screen. That catches a slip of the pen now
rather than years from now. If a group does not match, the window says which one;
correct that group and try again, as many times as you like.

When every card is confirmed, the window shows the master fingerprint. Write it
on your [wallet record](wallet-verification-record.html), then press **I wrote it
down**. This is a new wallet ceremony, so there is no pre-existing fingerprint
or descriptor to authenticate against.
on your wallet record, then press **I wrote it down**. This is a new wallet
ceremony, so there is no pre-existing fingerprint or descriptor to authenticate
against.

Next, choose the Bitcoin Core wallet that will hold the
keys. Only empty wallets are offered, so no wallet you already use can be
Expand All @@ -80,15 +86,19 @@ give it a name and a passphrase, and codex32 fills it in and locks it again.
Forgetting that passphrase does not lose your bitcoin. Your cards still recover
the seed. It protects the wallet on this computer.

Finally, copy the wallet details onto your
[wallet record](wallet-verification-record.html) and keep it apart from every
card. The window shows exactly the fields that record asks for.
Finally, copy the wallet details onto your wallet record and keep it apart from
every card. The window shows exactly the fields that record asks for.

A card never contains **B**, **I**, **O** or **1**: those four are left out of
the alphabet precisely because handwriting confuses them with 8, J, L and 0. If
you type one, the window says so and names what the card probably says, rather
than quietly swallowing it.

Some characters that are left in still look alike in handwriting: 5 and S, 6
and G, 2 and Z. While you write, the window asks you to mark them: slash every
0, cross 7 and Z, draw S with a line through it like $, and put a dot inside the
loop of 6. The recovery card form repeats this key.

## A card that is damaged

Type what you can still read, and `?` for each character you cannot make out.
Expand Down
7 changes: 5 additions & 2 deletions docs/user/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,11 @@ You will need:
- Bitcoin Core 32 or newer, with its local RPC server enabled and
`bitcoin-cli` available on `PATH`;
- codex32 installed using the [README instructions](../../README.md#install);
- one blank [codex32 recovery card](recovery-card.html) per secret or share; and
- a separately stored [wallet-verification record](wallet-verification-record.html).
- one blank
[codex32 recovery card](../../src/codex32_gui/forms/recovery-card.html)
per secret or share; and
- a separately stored
[wallet-verification record](../../src/codex32_gui/forms/wallet-verification-record.html).

Before running `create` for a real wallet, have your blank cards, a pen, and
wallet record ready, and choose separate trusted places for shared cards.
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ dev = [
where = ["src"]

[tool.setuptools.package-data]
codex32_gui = ["artwork/*.png", "artwork/LICENSE"]
codex32_gui = ["artwork/*.png", "artwork/LICENSE", "forms/*.html"]

[tool.pytest.ini_options]
testpaths = ["tests"]
Expand Down
1 change: 1 addition & 0 deletions src/codex32_gui/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@

__version__ = "1.0.0rc1"
ARTWORK = files("codex32_gui").joinpath("artwork")
FORMS = files("codex32_gui").joinpath("forms")

if find_spec("gi") is not None:
import gi
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@ <h2>Protected codex32 text — copy exactly in four-character groups</h2>
<span class="group"></span><span class="group"></span><span class="group"></span><span class="group"></span>
<span class="group"></span><span class="group"></span><span class="group"></span><span class="group"></span>
</div>
<p class="small">Leave unused boxes blank.</p>
<p class="small">Leave unused boxes blank. Mark the look-alikes: slash every 0, cross 7 and Z,
draw S with a line through it like $, and put a dot inside the loop of 6.</p>

<h2>Offline recovery</h2>
<ol>
Expand Down
60 changes: 56 additions & 4 deletions src/codex32_gui/pages.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from dataclasses import dataclass
from typing import Literal

from gi.repository import Adw, Gtk
from gi.repository import Adw, Gio, GLib, Gtk

from codex32 import (
ConfirmationResult,
Expand All @@ -28,7 +28,7 @@
)
from codex32.errors import CodexError
from codex32.generation import ORDINARY_INDICES
from codex32_gui import ARTWORK, reading, wallet_setup, work
from codex32_gui import ARTWORK, FORMS, reading, wallet_setup, work
from codex32_gui.entry import Codex32Entry
from codex32_gui.wallet_setup import BitcoinCore

Expand All @@ -46,6 +46,10 @@
(0, 1, "One card"),
)
CREATE_WALLET = "Create a new wallet"
HANDWRITING = (
"Mark the look-alikes as you write: slash every 0, cross 7 and Z, draw S with a line through it "
"like $, and put a dot inside the loop of 6. Then 5 and S, 6 and G, and 2 and Z stay apart."
)
NO_CAMERA = (
"Do not photograph this and do not type it into any website, chat or password manager. "
"Paper and pen only."
Expand Down Expand Up @@ -456,6 +460,7 @@ def _write_page(
content = _column(
_title("Write it down", where),
_note("Use pen on a card you can keep dry. Copy each shaded group exactly, left to right."),
_note(HANDWRITING),
shown,
_note(f"Label this card {letter}. The letter after {name} is the card's name."),
_note(NO_CAMERA, "warning"),
Expand Down Expand Up @@ -603,13 +608,13 @@ def begin() -> None:
chosen = list(details)[_selected(buttons)]
if chosen != "Something else":
threshold, count = next((t, c) for t, c, label in PRESETS if label == chosen)
_begin_cards(view, core, threshold, count, SEED_SIZES[0][0])
view.push(_ready_page(view, core, threshold, count, SEED_SIZES[0][0]))
return
threshold, count = int(needed.get_value()), int(total.get_value())
if count < threshold:
_failure(view, "A backup cannot need more cards than it has.")
return
_begin_cards(view, core, threshold, count, SEED_SIZES[size.get_selected()][0])
view.push(_ready_page(view, core, threshold, count, SEED_SIZES[size.get_selected()][0]))

content = _column(
_title(
Expand All @@ -625,6 +630,53 @@ def begin() -> None:
)


def _ready_page(
view: Adw.NavigationView, core: BitcoinCore, threshold: int, count: int, byte_length: int
) -> Adw.NavigationPage:
"""Ask for the cards and the wallet record before any card is shown.

The last page asks for the wallet record, so it is asked for here, while
there is still time to fetch or print one.
"""
cards = "one blank recovery card" if count == 1 else f"{count} blank recovery cards"
status = _note("Each form opens in your browser, where you can print it.")

def show(name: str) -> None:
# A confined browser (Tor Browser on Tails) may not read the package, so a
# launcher can copy the forms somewhere it can and name that folder here.
folder = GLib.getenv("CODEX32_FORMS_DIR")
path = f"{folder}/{name}" if folder else str(FORMS.joinpath(name))

def opened(launcher: Gtk.FileLauncher, result: Gio.AsyncResult) -> None:
try:
launcher.launch_finish(result)
except GLib.Error:
_say(status, f"That form did not open. It is at {path}", "warning")

if Gtk.check_version(4, 10, 0) is not None: # FileLauncher arrived in GTK 4.10.
_say(status, f"Open this form in a browser to print it: {path}", "warning")
return
Gtk.FileLauncher(file=Gio.File.new_for_path(path)).launch(view.get_root(), None, opened)
Comment thread
BenWestgate marked this conversation as resolved.

content = _column(
_title("Before you start", f"Have {cards}, a pen, and one wallet record ready."),
_note(
"The wallet record is a separate sheet for the master fingerprint and the other wallet "
"details shown at the end. It cannot spend your bitcoin, but it proves later that cards "
"you restore are this wallet. Keep it apart from every card."
),
_button("Open the recovery card form", lambda: show("recovery-card.html")),
_button("Open the wallet record form", lambda: show("wallet-verification-record.html")),
status,
)
begin = _button(
"I have them ready",
lambda: _begin_cards(view, core, threshold, count, byte_length),
style="suggested-action",
)
return _page("New wallet", content, actions=_actions(begin))


def _begin_cards(
view: Adw.NavigationView, core: BitcoinCore, threshold: int, count: int, byte_length: int
) -> None:
Expand Down
117 changes: 117 additions & 0 deletions tests/test_gui_before_you_start.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
"""The create flow asks for blank cards and a wallet record before the first card."""

from __future__ import annotations

from collections.abc import Iterator
from typing import Any

import pytest

gi = pytest.importorskip("gi")
try:
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
gi.require_version("Gdk", "4.0")
from gi.repository import Adw, Gdk, Gtk
except (ImportError, ValueError):
pytest.skip("GTK 4 and libadwaita are unavailable", allow_module_level=True)
if not Gtk.init_check() or Gdk.Display.get_default() is None:
pytest.skip("no display for GTK", allow_module_level=True)
Adw.init()

from codex32_gui import FORMS, pages


def _widgets(root: Gtk.Widget, kind: type) -> Iterator[Gtk.Widget]:
child = root.get_first_child()
while child is not None:
if isinstance(child, kind):
yield child
yield from _widgets(child, kind)
child = child.get_next_sibling()


def _button(page: Gtk.Widget, label: str) -> Gtk.Button:
return next(button for button in _widgets(page, Gtk.Button) if button.get_label() == label)


def _texts(page: Gtk.Widget) -> str:
return " ".join(label.get_label() for label in _widgets(page, Gtk.Label))


def test_a_layout_choice_leads_to_the_checklist_not_to_a_card(monkeypatch: pytest.MonkeyPatch) -> None:
began: list[tuple[Any, ...]] = []
monkeypatch.setattr(pages, "_begin_cards", lambda *arguments: began.append(arguments))
view = Adw.NavigationView()
core: Any = object()
view.push(pages._layout_page(view, core))

_button(view.get_visible_page(), "Continue").emit("clicked")
ready = view.get_visible_page()
assert began == []
assert "Have 3 blank recovery cards, a pen, and one wallet record ready." in _texts(ready)

_button(ready, "I have them ready").emit("clicked")
assert began == [(view, core, 2, 3, 16)]


def test_a_single_card_backup_asks_for_one_card() -> None:
view = Adw.NavigationView()
page = pages._ready_page(view, object(), 0, 1, 16) # type: ignore[arg-type]
assert "Have one blank recovery card, a pen, and one wallet record ready." in _texts(page)


@pytest.mark.parametrize(
("label", "name"),
[
("Open the recovery card form", "recovery-card.html"),
("Open the wallet record form", "wallet-verification-record.html"),
],
)
def test_each_button_opens_its_shipped_form(monkeypatch: pytest.MonkeyPatch, label: str, name: str) -> None:
opened: list[str] = []

class Launcher:
def __init__(self, file: Any) -> None:
self.path = file.get_path()

def launch(self, _parent: object, _cancellable: object, _callback: object) -> None:
opened.append(self.path)

monkeypatch.setattr(pages.Gtk, "FileLauncher", Launcher, raising=False)
monkeypatch.setattr(pages.Gtk, "check_version", lambda *_version: None)
view = Adw.NavigationView()
page = pages._ready_page(view, object(), 2, 3, 16) # type: ignore[arg-type]

_button(page, label).emit("clicked")
assert opened == [str(FORMS.joinpath(name))]
assert FORMS.joinpath(name).is_file()


def test_an_old_gtk_shows_where_the_form_is(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(pages.Gtk, "check_version", lambda *_version: "GTK is older than 4.10")
view = Adw.NavigationView()
page = pages._ready_page(view, object(), 2, 3, 16) # type: ignore[arg-type]

_button(page, "Open the wallet record form").emit("clicked")
assert str(FORMS.joinpath("wallet-verification-record.html")) in _texts(page)


def test_a_launcher_can_point_the_buttons_at_a_readable_copy(monkeypatch: pytest.MonkeyPatch) -> None:
opened: list[str] = []

class Launcher:
def __init__(self, file: Any) -> None:
opened.append(file.get_path())

def launch(self, _parent: object, _cancellable: object, _callback: object) -> None:
pass

monkeypatch.setattr(pages.Gtk, "FileLauncher", Launcher, raising=False)
monkeypatch.setattr(pages.Gtk, "check_version", lambda *_version: None)
monkeypatch.setenv("CODEX32_FORMS_DIR", "/home/amnesia/Tor Browser/codex32 forms")
view = Adw.NavigationView()
page = pages._ready_page(view, object(), 2, 3, 16) # type: ignore[arg-type]

_button(page, "Open the recovery card form").emit("clicked")
assert opened == ["/home/amnesia/Tor Browser/codex32 forms/recovery-card.html"]
Loading
Loading