diff --git a/README.md b/README.md
index ad5d83b..e015a65 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/docs/developer/api.md b/docs/developer/api.md
index 6c7c801..1b829ef 100644
--- a/docs/developer/api.md
+++ b/docs/developer/api.md
@@ -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`.
diff --git a/docs/developer/gui.md b/docs/developer/gui.md
index c922ce7..b3f7ce8 100644
--- a/docs/developer/gui.md
+++ b/docs/developer/gui.md
@@ -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
@@ -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.
diff --git a/docs/security/model.md b/docs/security/model.md
index c14f9fd..4b11d95 100644
--- a/docs/security/model.md
+++ b/docs/security/model.md
@@ -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
diff --git a/docs/user/gui.md b/docs/user/gui.md
index 019ebb4..d493275 100644
--- a/docs/user/gui.md
+++ b/docs/user/gui.md
@@ -62,15 +62,23 @@ 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. Print them blank and fill them in by hand, ideally in
+archival ink. Never print a filled-in card: printers and print queues can keep
+a copy of what they printed. 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
@@ -80,15 +88,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.
diff --git a/docs/user/guide.md b/docs/user/guide.md
index 0ccdc21..fee2ba0 100644
--- a/docs/user/guide.md
+++ b/docs/user/guide.md
@@ -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.
diff --git a/pyproject.toml b/pyproject.toml
index 7aa1426..6ccc768 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -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"]
diff --git a/src/codex32_gui/__init__.py b/src/codex32_gui/__init__.py
index 676e5e3..9e14f11 100644
--- a/src/codex32_gui/__init__.py
+++ b/src/codex32_gui/__init__.py
@@ -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
diff --git a/docs/user/recovery-card.html b/src/codex32_gui/forms/recovery-card.html
similarity index 95%
rename from docs/user/recovery-card.html
rename to src/codex32_gui/forms/recovery-card.html
index 361e153..7194140 100644
--- a/docs/user/recovery-card.html
+++ b/src/codex32_gui/forms/recovery-card.html
@@ -47,7 +47,8 @@
Protected codex32 text — copy exactly in four-character groups
- Leave unused boxes blank.
+ 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.
Offline recovery
diff --git a/docs/user/wallet-verification-record.html b/src/codex32_gui/forms/wallet-verification-record.html
similarity index 100%
rename from docs/user/wallet-verification-record.html
rename to src/codex32_gui/forms/wallet-verification-record.html
diff --git a/src/codex32_gui/pages.py b/src/codex32_gui/pages.py
index a73de64..f41d3f2 100644
--- a/src/codex32_gui/pages.py
+++ b/src/codex32_gui/pages.py
@@ -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,
@@ -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
@@ -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."
@@ -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"),
@@ -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(
@@ -625,6 +630,56 @@ 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. Print it blank, then fill it in by hand in archival ink. "
+ "Never print a filled-in card: a printer can keep a copy."
+ )
+
+ 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)
+
+ 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:
diff --git a/tests/test_gui_before_you_start.py b/tests/test_gui_before_you_start.py
new file mode 100644
index 0000000..36ce9c5
--- /dev/null
+++ b/tests/test_gui_before_you_start.py
@@ -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"]
diff --git a/tests/test_gui_boundaries.py b/tests/test_gui_boundaries.py
index 2687a49..b71fa67 100644
--- a/tests/test_gui_boundaries.py
+++ b/tests/test_gui_boundaries.py
@@ -33,7 +33,7 @@
}
)
CORE_ADAPTER = "codex32._bitcoin_core"
-BUDGET = 2000
+BUDGET = 2050
def _package() -> Path:
@@ -153,3 +153,17 @@ def test_restore_verifies_identity_before_creating_a_destination_wallet() -> Non
create = job.body[1]
assert isinstance(create, ast.Expr) and isinstance(create.value, ast.Call)
assert isinstance(create.value.func, ast.Attribute) and create.value.func.attr == "create"
+
+
+def test_only_the_checklist_hands_a_file_to_the_desktop() -> None:
+ """The one external launch opens a bundled blank form, before any seed exists."""
+ tree = ast.parse((_package() / "pages.py").read_text())
+ launching = {
+ function.name
+ for function in tree.body
+ if isinstance(function, ast.FunctionDef)
+ and any(
+ isinstance(node, ast.Attribute) and node.attr == "FileLauncher" for node in ast.walk(function)
+ )
+ }
+ assert launching == {"_ready_page"}