From 7f96e33b8acda4dbf37a7ce8f04c8d4f33d8cb88 Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Mon, 28 Sep 2026 20:14:39 -0500 Subject: [PATCH 1/3] gui: Add optional graphical program Add the GTK/libadwaita frontend as an optional package and entry point. The GUI delegates codex32 operations to the existing library, keeps Bitcoin Core integration behind one adapter module, and packages its desktop identity and Codex32 Book artwork. Keep PyGObject in the gui extra so the base installation retains its existing runtime dependency boundary. Recovery and wallet secrets remain inside the documented GUI/Core boundaries. Validation: exercised by the complete pytest and optimized pytest suites, mypy, Ruff, package build/twine checks, and the Xvfb GUI walkthrough on the final stack. --- pyproject.toml | 21 + src/codex32_gui/__init__.py | 32 + src/codex32_gui/__main__.py | 8 + src/codex32_gui/app.py | 55 + src/codex32_gui/artwork/LICENSE | 28 + src/codex32_gui/artwork/bitcoin.png | Bin 0 -> 25050 bytes src/codex32_gui/artwork/codex.png | Bin 0 -> 13126 bytes src/codex32_gui/artwork/dragon.png | Bin 0 -> 15291 bytes .../artwork/io.github.benwestgate.codex32.png | Bin 0 -> 13126 bytes src/codex32_gui/artwork/lock.png | Bin 0 -> 20367 bytes src/codex32_gui/artwork/potion.png | Bin 0 -> 16137 bytes src/codex32_gui/artwork/sun.png | Bin 0 -> 19095 bytes src/codex32_gui/entry.py | 164 ++ .../io.github.benwestgate.codex32.desktop | 10 + src/codex32_gui/pages.py | 1481 +++++++++++++++++ src/codex32_gui/reading.py | 177 ++ src/codex32_gui/style.py | 45 + src/codex32_gui/wallet_setup.py | 298 ++++ src/codex32_gui/work.py | 84 + 19 files changed, 2403 insertions(+) create mode 100644 src/codex32_gui/__init__.py create mode 100644 src/codex32_gui/__main__.py create mode 100644 src/codex32_gui/app.py create mode 100644 src/codex32_gui/artwork/LICENSE create mode 100644 src/codex32_gui/artwork/bitcoin.png create mode 100644 src/codex32_gui/artwork/codex.png create mode 100644 src/codex32_gui/artwork/dragon.png create mode 100644 src/codex32_gui/artwork/io.github.benwestgate.codex32.png create mode 100644 src/codex32_gui/artwork/lock.png create mode 100644 src/codex32_gui/artwork/potion.png create mode 100644 src/codex32_gui/artwork/sun.png create mode 100644 src/codex32_gui/entry.py create mode 100644 src/codex32_gui/io.github.benwestgate.codex32.desktop create mode 100644 src/codex32_gui/pages.py create mode 100644 src/codex32_gui/reading.py create mode 100644 src/codex32_gui/style.py create mode 100644 src/codex32_gui/wallet_setup.py create mode 100644 src/codex32_gui/work.py diff --git a/pyproject.toml b/pyproject.toml index 508d90d..87d52e0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,8 +33,12 @@ dependencies = [] [project.scripts] codex32 = "codex32.cli:main" ms32 = "codex32.cli:ms_main" +codex32-gui = "codex32_gui.__main__:main" [project.optional-dependencies] +gui = [ + "pygobject>=3.50", +] dev = [ "build>=1,<2", "hypothesis>=6,<7", @@ -48,6 +52,13 @@ dev = [ [tool.setuptools.packages.find] where = ["src"] +[tool.setuptools.package-data] +codex32_gui = ["artwork/*.png", "artwork/LICENSE"] + +[tool.setuptools.data-files] +"share/applications" = ["src/codex32_gui/io.github.benwestgate.codex32.desktop"] +"share/icons/hicolor/96x96/apps" = ["src/codex32_gui/artwork/io.github.benwestgate.codex32.png"] + [tool.pytest.ini_options] testpaths = ["tests"] @@ -55,6 +66,16 @@ testpaths = ["tests"] python_version = "3.12" strict = true +[[tool.mypy.overrides]] +# PyGObject is a system package with no type information for this project. +module = ["gi", "gi.*"] +ignore_missing_imports = true + +[[tool.mypy.overrides]] +# Widget base classes therefore arrive as Any; the GUI still checks everything else. +module = ["codex32_gui.*"] +disallow_subclassing_any = false + [tool.ruff] line-length = 110 diff --git a/src/codex32_gui/__init__.py b/src/codex32_gui/__init__.py new file mode 100644 index 0000000..9a23b1e --- /dev/null +++ b/src/codex32_gui/__init__.py @@ -0,0 +1,32 @@ +"""Graphical reference implementation for codex32 Bitcoin master-seed backups. + +Every screen imports GTK through `gi.repository`. The required versions are +declared once, here, so that importing any submodule selects them before a +typelib is loaded. `reading` and `wallet_setup` hold the parts that decide +something, and neither imports a toolkit, so both are testable without one. + +This is also where the accessibility bus is turned off. GTK otherwise publishes +every label and entry in the window on the desktop's shared accessibility bus, +where any other program running as the same user can read them, which on this +window would mean the master seed, the cards and the wallet passphrase. The +setting is left alone when the operator has already chosen one, so anyone who +needs a screen reader can run `GTK_A11Y=atspi codex32-gui` and get it back. +""" + +from contextlib import suppress +from importlib.resources import files + +__all__ = ["__version__"] + +__version__ = "1.0.0rc1" +ARTWORK = files("codex32_gui").joinpath("artwork") + +with suppress(ImportError): + import gi + + gi.require_version("Adw", "1") + gi.require_version("Gtk", "4.0") + + from gi.repository import GLib + + GLib.setenv("GTK_A11Y", "none", False) diff --git a/src/codex32_gui/__main__.py b/src/codex32_gui/__main__.py new file mode 100644 index 0000000..54a94b0 --- /dev/null +++ b/src/codex32_gui/__main__.py @@ -0,0 +1,8 @@ +"""Entry point for `codex32-gui` and for `python -m codex32_gui`.""" + +from codex32_gui.app import main + +__all__ = ["main"] + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/codex32_gui/app.py b/src/codex32_gui/app.py new file mode 100644 index 0000000..07835bc --- /dev/null +++ b/src/codex32_gui/app.py @@ -0,0 +1,55 @@ +"""The window: one navigation view, one stylesheet, and no command arguments.""" + +from __future__ import annotations + +import sys +from collections.abc import Sequence + +from gi.repository import Adw, Gdk, Gio, Gtk + +from codex32_gui import __version__, pages +from codex32_gui.style import CSS + +USAGE = "usage: codex32-gui\n\nOpens the codex32 window. It takes no arguments: never put a secret in one.\n" +APP_ID = "io.github.benwestgate.codex32" + + +class Application(Adw.Application): + """One non-unique window with a stable desktop identity and no recent list or files.""" + + def __init__(self) -> None: + super().__init__(application_id=APP_ID, flags=Gio.ApplicationFlags.NON_UNIQUE) + + def do_activate(self) -> None: + display = Gdk.Display.get_default() + if display is not None: + provider = Gtk.CssProvider() + provider.load_from_string(CSS) + Gtk.StyleContext.add_provider_for_display( + display, provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION + ) + view = Adw.NavigationView() + view.push(pages.home(view)) + window = Adw.ApplicationWindow( + application=self, + title="codex32", + default_width=880, + default_height=620, + content=view, + ) + window.present() + + +def main(argv: Sequence[str] | None = None) -> int: + """Open the window. Arguments are refused so that no secret can be passed in one.""" + arguments = list(sys.argv[1:] if argv is None else argv) + if arguments == ["--version"]: + print(__version__) + return 0 + if arguments in (["-h"], ["--help"]): + print(USAGE, end="") + return 0 + if arguments: + print(USAGE, end="", file=sys.stderr) + return 2 + return int(Application().run(["codex32-gui"])) diff --git a/src/codex32_gui/artwork/LICENSE b/src/codex32_gui/artwork/LICENSE new file mode 100644 index 0000000..5c4b1a3 --- /dev/null +++ b/src/codex32_gui/artwork/LICENSE @@ -0,0 +1,28 @@ +Codex32 book artwork +==================== + +These cropped illustrations are derived from the Codex32 book cover, published +at https://secretcodex32.com/docs/2022-09-26--color.pdf. + +Copyright © 2022 Blockstream +Cover and Vovelle Illustrations by Micaela Paez + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/codex32_gui/artwork/bitcoin.png b/src/codex32_gui/artwork/bitcoin.png new file mode 100644 index 0000000000000000000000000000000000000000..88d3c014a610f05c73fd425610c28a85b18cfbfc GIT binary patch literal 25050 zcmV+2Kq9}1P)4~ocHU>7oNms!a*oIu1WAAipqNFPfObGTJ?b3XT;9CqICk8@F!+H&ev`@MI@sH#y_5{M5Q`Q zPftJHodYFsf`BB71cHxcnFK*VBpk)_e1gD75@iHYWM=LRGjk`n;lTUcY^GaY z$xZ(9pMCiwfBWV$+<*Tg(#DNhq4~F){f8eG{Qni}BLBbr1^3_oh}zZ`5gvZ{9t*hk zQg>YUr#}%zE$+Gv+jgrx#quPT>QyNb=|@&%WK}_yWh6;NQDv&N5{s)Bu8}QIW4~bJ#ed@u3TfATW)!jD0+i&^C zyFy0%?WOWe1KEy6Ldko>Mhrh_py?7zD;5{VFA1`iqo;cVk`xf&p(-k}A|Z*JC|A$n zcy%nNhN7t0wnacd5D0i4G|V!d=ioUYi3)-&B1s`CwFPQsfmEs;SylwgtYYZVQB5~S zB~e9|b$;ttzWxmap`1vxhf0-|$NuyS_xupx!3V#jv|Fk~-!I$$Z=4GGpV-=^WU?SW z{P3?jfcdw73wPf1`H%K=_AAq~7k*t0gsttJtwbU!u3K_6t>J@5I2xAX$uYTHo1k1D zCzIVxvA9C1yh2ac27&-$krvuoMp#-|q*5u6N_OIT4FpjDd}LWcmNmdf(Io1%8udmI zBOHam#db`h;WU9CpeQO0v*tQ>!wV3w?S>c#d>|gn5)Q>F6c=}X=C>ZtSZ2xjlP`bZ zYk&hE<-rHPq!bEC|L?oc|DV}_haP%JeE8u9Jixnt%fCx*-?4ev6$_DCmA`Ij$x^Og zVruqrvp=*!80)@~O2yFvAn*f30V3ffgMHh1<DDxw4eGQzx%A2i5hBAm-zV0q;#ZSAe-x`Y5^GwmP>c&AOUqX=j4&ZBLT+suP1PXqNya)5g#baS(%Kdy5|vPOk7!II zorxhyF0Nb0FjQ=}NFZ1!NM2|x`Z z->G?}>Gsa}AE==!iYgKWNVR%RR+O-?VdMskP@Go|KY}PpD2j&fdq|RqAb_TY>FV0Z ztvb1^;O;KoV?Lkp=Y}-L-e&irPA_yo~SCJ6@ zpkyviYl8fpH{I<+4?Us=1j7GUHsHYrzoZC)5Ip?w1NQg6_kj2NpMLC_Xk5L$P@aza zJ~3HRebcN5LC_3{l}es;x{J*lZeVq7mJ63&L(_C@r$H_^N8o!91Pl-C#I_sM>Sa3H zhiGf*!?GG!u7fOv(9|%FYhybWni@e>4Lsi?@I69W7+KcvJe$t8A$q$;kR%mF(MYG- zv2Bz2h06${LNeaU^2#);Ycp)xu!mGCPQ9KZz($r;d_O={HC)#UWJ&QGW;r%~^>Ey_ z8n=JulaD|1z3)BXJ^b(kwtx^k_~4h6|D`eDp@05Jd_BWhY0$2E~e3D5Irm=#)E`q{quc21vrf!0h9k#LIX;zbb{A#R&X>RH~awC66Qr#1knrO`%dP(P-3&#gg=^8;~Rw+p+NjAIq%c zI3BX3p~xDFqT#w0LEz!I7UgoDk>TA)vdYxV1tdvf!{}bFPMjd0&(Ytzk(O+hrR4=? z=Pn{32!jK=gt^6w{&H^8Ik5ZPnk34%ee$1w_pc2->dq~U|K~4%`BzW>^ZWmEXLxW2S0~S4gd()IbP|chSjkOs`s|DJ_iUnAUS@Xg zg3!@6EO&Qq^vmT{Hxf?l3hB{Zs-m}V9lPzy7r*t1>EFEjPqpp0zEeDQ?6CkI{wLi4 z0Ui#H9s9C>`0zKq&;0fiBdVtVnPJ4=va~!^vK>=^KouoP=;#>6G;54sK8#^RNyJ)^ zWrb=jkLNnbibg;{TT36NRVNgR6N_dj}Ox1S}n7E>n$`I6=IPj zZLR%WzH)+6Imd?4z37@jC={kO+k>JSG|U=>;sTy$F*34?!^fT=6pFEF^agCl68ylI z9II}Lf>5(;bNfj1VWkC)(E}5t;FL7`NASsCr={^3Z4VL?~%^5A_^kMPQ1eS z<&*e9fTAn(_YJUZ>s~}LAQFi%F@2J&Q|IskA5m<=7U^^cj%$)HE)$QY>1gZ2b6uj5 z+ zB>w3KABht**X(~}6aKp!@Si^Qnl6eGfB9$UHaIn5S}>$(+jTb7>O}-m)Po>Ek`z48 zsw^X*IDWL8M-*(3rkq!VOthLAmDp0vZ9cPXHgZMdZWVT z4cF1pF#w`XJZ7NlGP)k-#F?jQG#Yv)+l!`!HfS(yN%HhZKlarP0H6BQYx@5q7j*Bz zk43-mz2B$>L2%3B;?-}A4PDn6kGJUcMoF;kdY~wp009Cbh7rT{T&AZ_ zEnSi%j8Ke_9%Xj!5@*i6#Gc*P(cjmNqIq1II7M!4jxC$_A&C+M7KO4%XU1Yf=NywW za}+8vi;I&aVo|!gH&U*wa{1CvDHLYewexP;+xu}{lUO8y9*Qu2b&|s3kJvh-Gc~;Rnrw?|CkKVc@zpzUQN= zVI0Ro*TaO37@qIpc@B+6iO$Y`#)fyZR#*^@oqRIzyt*5YWL!zsTeGRYE$_JJ)60MM z#dn;3@WDroW5>Sj{10uwLk~S9wzT}Z_!ociF8gznta;~k&t?b)&|AgC@ZjT^Ib z=Oojr35i4-$z&U*Rbg)762;;Yu~>$-)S zk260vNyv!O-8I78+*J-A`8l!(eSJHao4<&tga|wvt8|EseLfR23;4cAsa)f+r=DbK z_BGyncPnwj<6A#@o!5^YV`}mYuf6dSm&T7%t<3WAdu6)2w(y;&^1O27brLa^YQw__ zMOewL@Z%@G#KPh!-g4K6>FgRnRwXn|Mb#u^NkEb$9M>coO;D>rPQRzK>{@E>hI5G9a6Q4bQ|NW0h=g)5x;Kks- zS_cF{AP7Pe@%wkb|H6*twaKqto;cRxJT_=-EtGN(??(J>H_2aq_9h;GVTGgTFVG%cB@-9$odP305pLKnbL!F* z5mChwEv9E&c5aBXZNv2(zqG(p&-{Q&y@+S+#c>^4TDM`yODO&kwr(6_aBw%GDC4;{ zr%u0$EX!=&dH~n4Ns}bEQsViSzlG~M+;;PaktGFDl&I87ym92G6pM@O*?Bh|?PJvH zB?8}!96R+y&2!yN!+pEHwtwfl?*7Zae&EF4TjSs#Jpqf09m=Oa{b6C_#^34=Jo8-5 ztVW)F@$1g??5U68#TXEbPCr>@g>e?D(BfE%1qiC9r>(r^#K@dzjqUY(z3OxVHODrs2MU!iEw>aFo zCqzekj8s(Sy6ti9JlMhJkrw*9!gRETxN&y}H|_4=!}krdYjYcCE|+=B_3gan#$GPZ zOp`QBc8n%Dbm9zy?aQ1wf08?Pm$?042L*cz1B3gBM8aIUc%1M4;0yS!&%V8PVmTI~ zr0}E1{*uE-A7gBI2b(rrPpw`?Rdwd(E|FWEW82o7*uLdv3dLnaQDc7L60T>fLjybQ zvEhT!hFSiV{k!h_o1TI9SD$(2RjHY({vyQ$;7@<^r&>PshT}B0?bamYY1=kq2vU$Q zEC@%AJ<8I`6g#)w$;Qze2pKU7g$0fre~f%_iR<^ihqm?+JkLZFBvef%7T2lPa(wx# zpFvXk*u8B(Azo%?{wx^pWNfI9g~e&+<}afeZ6GMbe>x!PYG|aCvfyL$CiG+kV%wYC#YLek_(2JK9G`Cp$n82uBhK zfz% zPhMeW<{0JTRl>?5VP%F?6XBs8Yx>rBm8$s{$V7wTBHPc$s^ z!qF8(At3OL%`txE z0++9xWp3^=H(h@>cir_-7IGEN9sf2r48O+hx4)0x;rAknDza=53hDS>fa^ji6eXlb z2ms%Akrfd|2@x?e)Ei}TYg4|iM+8kZbhAY>h!M-gqsx_BL$fVKmnOUl{yw;#Uv&h}s zj*(2XQjy+*E4HJEb%H=55PVb>;_(!=?Xi-Z!?Npyv=fRwo`_EzZ89S}O?A zNLq|XGeQvfRI3GSr-80TP!%0b3*q}7mQ^9}!7viEv?S>1O%jbNghDamu?(K?aO(6S zmRC#s((Pe(Z4z;Ojr}{@+1MMWJrm`vH+QrD`W`wGVN_Y5X4?2}Ge%jqhk!uDkg3!h zQZbE0R3)9zkR_3N!y^$>>FF@mT@y4+57!MC=n9jHYv`(k?f3|yfGi38{MBWSo?c_m zrX>3gwv&w-jPxehG?JyW#o)E0=h4NheCn5E47tohU&-^@i5#uj1j%@kTCGml(1|D0 zRBAPze(t-RIQ}fLSen+>K3va56yV&&SE<)4?AUUr5DLYZo;@Wj=O#C%vpt<}x#c&n z{N-2w+4MsXJtRsGKKOq5z^?a=L}RHhIIeTok>ih6R#vBU*R`8zq-LP&VMI~JaZGBB zB93dLNGh_dvA8_N_?6e14U$-j_Vz)t=`5lIt7~g~?Qj1bvoj~T=lXWu^7a8b43SG0 z^K`a`80(85iy(U*xwQr_99yN{@aSqY&@~Cu_7MdEKM0716?9c1o76Bu5|-_gNor&g z8j>X7c>%s3AP5i%%S6Kpnj#@du)Jo`uv`cPzW&4v2X|z+X-^BKyoG5xjPyqdsREma z;`DVTxc`ncgIyB;+ZQJI=?iCA$<5-q4GbenTWc4B7-C^@nWvxqI!`|RHyB2Qy}R#1 zQ4D?|VeUDU5K__fOBBWo2awU5}DUce1oH&T}t) zh09ltBM2h096{3zN~IN^dEuulEnMQ0zZB)Bok`ppxSo&W1?2K3*_2MyP;gwIu%VDj zXxIF^s)$X-gCGC`hAyK>B90s2I03RGps5nFEH+V>00comRYY`6!g0ZM1AHI6Ai%VI z=5r=1YbI~MshxOOVXbHr3(FMBHkYO=gjA8cZfd8iHO}MDf{qLW>>njq#-ktCIprUAfmCCB8D4N{exsl$kZTNxD_~kb^ zKmH2!Mv+vagO*Gmf)L<)kWMGquwjUFwu2*wAEz^N3bQuJ?5fJjYKcpyzrpSioAg_qNh716;dEwq+31A0^LlD{Q zI;Q6Po(zE?2qKymB9rN*vwaj*2{Ai=kxN%zr`{-$PWRE))=4xWlFh{F=omtj4Dzd2 zx$ioY?E@8l{PZD;#XSH1lN%Wuh$9Fhj^lB9yuj(pMTUE#7@CCV!$Pi3zG%^z4PCDXDghWDsg4Df+~x|4fR?Et?LrCy3Jg^&Qi%_v0zfJI>f^Yf*=sApNcnu z$#_JjJ)<)@S3_52hWnx{u9}=3FL7Xd7EKkIny-?Ks%#jDVdxU=EjlMI6nXvh8eU_T zcN|#ZGY@tV&)h~${~(guPT=P85ztf(*Kv96jVG92m}b-HEo|L%8?vn6dM=*lqN$oB ziXxuxrS@!p`#YsuZ~SLp34BlYd`FNZSqOpv*R}9`7gY%nk7h_FI!GkiktL0l+ydcn zl#!8slIbXBqk*Ps^!1MN+R^h|7{A2!QHg`wG-8Iv-YqFYx`=K2Bx5S6xJsq&V1#6% zhJvb!Xo^H6)I>)^VTG5DF7c%wEOYXNg}dx<>0FH$U(a!NxR@gg*>18 ztBagCUZS+%^6V=sJo3yeHOpn!#uSPyu4hvLK>&s>;rft_skCKu1O!aWC!18+G91Tt z16oo#Mo8w3vw1E|R)`o9Gm93E)!?J=&hp`Rb#eTh%isJ|=H$gZiI_$#8pC#6L{TKb zCK?Tq%?zOHnQPrR&vTJvnS5bUn3y^x2!g10b&PdNTQ}YLul&FhMNtxm%< zYL%XTgB{yjSY0gft)I_w|C6FzeiRQzwcnP%}MLwb}G{J@5iZ zL={w7qG5XIxsW8|b<&GPAxjgIghkt&S zN1r>5YgO4ewuN-Mi$<-2B#Nl2iYzNk&0OZ<_z4`>ArZ?WARx&K^?DW0bI|puF9g`K zt{Ykq`1pR)Qn=Pj07X#&0mo@zSq&6L(Foh<1 zG)gwT)hJdSwvHrOU8(Zop(Q$771Rr5Bw41> zFljU@$chXENRrO@m5as@a2z8Pp;F6>m#)5{$pE6*EQo#I zBM9Ey03T7*sn?6Fu1(1PMtLNG3CsYAREUb-ci*RI_>F zt}@ymLzYAw$EQ}WVVM@TZPRF2c#eZ6czpZui!9_L9O!GO zSFO{qT|$b?caKeTwOpnrB=W+`lYH&b8LmHY6FU+DL1_h15ST03{P4^)swQAN9-=Ie zO=v{IDy|#QustMEKoYoS^-VJfs5U%$+Cz-=MW|I=7IP+j9bstIrb-Cuqs`6A34gLH9FKE(y8Xqg7gm^%-&ar$zRm?2}@K7aA8i@bb%g?K89 z5izhW2SgE3lu$%?`Fx1mt{b7JElFi|5?_#rgk;`2*2U4~B7gbi6>4Idd+yx9^_vF~ z7mib^)G@+gwsa=gyOibC$JRI5 zQ6vG!3Glsu-gbk zbh-`ScW9VvghM)s_#lZy22BkiNh+!mLRM6Sdv5>TKvQ)@NhA_UkWO_WNeYf_g5cx( z5}8brL`r6D?F?q_3HFRkv3>Ja4xRB?T)e~w-`Ym5;E=C`ICh~#y0Y8 z<*LJ4$)>X>%EXmCzw+=I`bKZz#+{qdMUPs|M3yA*Yyv+()>JlRLv+<j!e$b40k|^K@;5a^-D&hwL$Icfa2pH*$(%lhZa<;@@d}o|Tp3~XB`#tR2 zxe2@iGqYD2A3smIl4opm2iITscB0WZt7}WRmO`Z@jPBkyLrQpC*7;tF9ze0(Rs4?t5D%DGue zxdl*E1eyg^YfNKPw}Grm1U^(7^Q^5D@O%*zg}UV+i6ZR@ouSr9vkLZoDs_)z(|LY& zagIO!@?}(yGfFOV>OKcj9UaQuF0M-g7&m79p+cwIr z+iR%e(>(Rk8yr2mf~v(B8r)2G_b{T+j5D2`y_hwJx!H>}8Wy2Yv(~BCOVsK`fgs@eK6TSU zR5a9Z7(r;d2~CkG)GX%nHGJ1Y6q*)m7$Hl%sD`S6su-Y(y!7%9IdkGClnT=%^bFlC zVcN4{Cd(C0UdU6bxwNEo`a2?Yv>B+9fG7%BwvTG42!g=H@hQIfgU5OMKonJsQ7QV! zf`lTAWWpMel!8Ej>wAbnfGh~CRZU*Hvcf{Kfi6pgRGFBjpvw}euu50bpe3rKNfM~d z6Z2-r$+iQ=7t4fog@60*ZCKy&_?z!c(VEh^=Y}p8mue{MR^QAD1=b1{$1fCFSZ$yx zBKx+aNGEjC361x@r5nOBfAx(i{_+QTN@kW{x%Zun4ewwjx6p*b?p>y1*g0WC`;678gt9XPIq=Q${<&c%yw@X{-%329+EJBKhr zaV$q;$FRa%4yMpl702=E$QZO-r?XnLSt&GdTp!2r3F``e(9FXFHy{zw>B&fZ=e0vT zd&WRlW&FU$aeZp0&y7PJ{L9<6&}Jyi7fezS9aR+g?(s!_Fge5MaFnsG7^~GfMM0oc zv00t4SYEW~4o7+C_AUbD_jveES?L1&jON{Q$S#@?L7S5(oj3 zBoX*NzUR}{+Q-c|_M&Pkx~7v~D^V_2DV0{Jnztc zT;bn;Y8x}lRU9wC@gN>iF)fS7UYz6jIjCz%jF64Dyow@)34D*awK_XD#rf!mMi}l* zP+6{_s3NxOvsScNS#|jS&#&;=XHWC~o37*I`v@*U<-~;oTQ}__ zn~?a{xBe~FT7$RT{bADS4qVqJ2m)k9Meza%;Ox0KxNzwtjfRb3M1cTVmSqqGG|gaX zvyygTw{hb3&W7!G@jYTIz~uFkp)c0qh`9yuQcdt5Amm;-pN33 zA3uEhd4BZRMGhRemq;{=EXhJbrP`*t=Tw6D1j^~$dW802u)6; zrW%BeII^q|jq2#Sj3leHwe`{4w}Eo`Mkc4PGM^u(=*}>zS5b5qU3Kt0NX42RC`}WQ z6%knyk;G;XAP7Jdnic74!E6RS0bD;ow*nN=N0CLsded!q&6QxCq=qO8$gLVV(ZA-aYVoH@S0^vqRMB|;(|=Z!a>WO-$d z!GTTe-*+3Hr?9xNf@#+1@7u`GppI+Hm~{tH5|Dl0M-oL$vrcRs`E*?yU26s<+qNiI zN=TweBobrmrma}cHs+RVJazgtj$eF=kH57>Pg{&wID+f>n6^i?;gTy@sEUZHh{)2L z@GvN{xE>d{hClfMG)x!U3lJ3zL6QjY*R{gUPdu!VY6%m`8nk$zX(FN^G~jRq^Z zWh&(cx~?D#qKF^_h@ygRo7Czn2tZqVUlU#p0yH&*D2jMqK(Vxj=lKkDra5&i&vzbw zjqZ3mAAMJhfPkrm8p~@YHPbh#-J0HT~)Tg!t?lNay;XiUKW(5MOzIoYhL6o3^!5w|rt@nYE(DTG?ZK zw$9{gnEeOt=Xc(5Bf;rssFkZont|sBSf+^*I1FzXyw8$xS_)E=mv62wdUpv z{}(oS&}?g7oUCwZy2?;@1k>`@A^YaL70Oogj0z%^y2oIDANSqW!HJ7?BqhS=*a13P zhfx$2--lExNo$MB+G>@hr9AT7+!bU+Mv^56K-VKAl2MQxY^R2-sA#G|qh6wrUt)IV z0;OCH#R;=@B+e(^KT21tP9~}0xJ{R#DI$_6Tm!cK^@hm*qy!L26r1tGbJ#oFMy_oG zH7&-BB@hS#c@yfQix&>qxhKv?2D;cB7YJW^nbPVKf~;VuDw1c@(VjtQ9p;H8i9h+# zqwE}B;bZp=V%A*zAVB&@sQho*%#N`*+eYIgA}YEjQZqd~FF@BsA`ulKEb+{f<9z&~ zqo_tVAOB|$anqd}sMYES=&Y^Pu^o$Qt&C|_DdbluS1e>PhJ5Vwm^A+Ra638 zj-PQbLLoBgb~bI=OHbzj+jid0z~FX78Z>HiJbCCf5~0gH_`n86`lC3uhwB9>vWVw5 z`?vhx(Gac&Uvb?Q)T<5{D9vVsl*KrX<0pABf@DY8Fgc^{PmvCkmSY22|R5cJK zD)kzk7Uim+;rzuFrt=fL@76kZ?C&KRk5Vq0O#;Aw)eImN*9hqn7bYuA&D9tih>(tJ zlxrU6CRVB0B9|9hY45oU#b_ZK?<1S(A{wbutv1LP8`ERg(b0v-VY#}8O8R`ER_p;nm_PmeVLtUoXIRP2;5aq}KAz_S zDuM5jUt8qL#1$OJ#jIB3CC+L%Zc#X#Q%R4c7}WU5~2e(y%>bOKASQEO7Uh4({60 z$(7|Ye|_jGzkTHt5B-acY#&Neue(@|kD>_IBzB@G5RItp-?Eew3FA2grm#67MM4{QkohsD$yF(LPUM>^W)Fu(Y4^TvbWLPDktHf1>bVQ>g zZqSzr)87(7Q$$v47G={Rq%<4#k^q}J6MW|O%{+2qihuKkbNtcoZDXK4g69Qzet;x2 z)vT0{`bJqK3I+?VABFQ9^DQ0t%eEsq7b7kTR z$#{hOZt)oDOENK6A@J+meO){KT@ey71>XzM!zzX*p$g4D>)&Uy{kNm;uB&lijsw5(b0R^kULcPYR!l5LQaEL~uK&@WJw(BUWO1;(~ zUtC2N1Zc?&plKm&(?-)Iy8Bb8x{7JJsNp!(B`7-D zAOXAptKsskXRh$|A1`4|W_f7C2-e&LuIHkwDh=DgbUpgBVa8%{j-M;CWh6mMJVdV0 z+~6(4Nn9_WVR}e%v&gg^znLEd%>doi5<%M8g{G}Bw^Tz_Bvw{SoE$$x-F$;oqJ{l? z-bNykB@~L_IA(Jgh-@H>f{3JsP*r8U{RctN#Cse#(69mwO~7`28n#bQdzh`m31VTHz-PT^`fq1H_&%}`L(;?G76|^{5BUU? zY7VuEO%#cI)j?Clf3Q&mzX;j_pvp2IePAOku@K*QR%IsCPm4NBU|M)Vuue^B>YTTC zq&YLU#^S0;XG;XHiG<_0K38U{T$--X-x*>1XdKgQCKbL92m!9=a`M6|fB2;{9Jyd} z>n-o+ZSTI9cWDZX3v<+}7OIv)l$7RF7=-oakA|$O1`V@Jp)^muUcqr2oIL$9M^7{r z?U8VTT|2I)t+gA+u@M9T$Az|*HfAO}_|M-h^OKh+>CHCy@Y}mMurJkLi|6asJE8UbDK(S#;$h2Mmu-#k^SDw5SS`~Xa= z$u&K=Jw-=W=Rf`31s;F-Mc#6E7x%vPBkYr8T-N~tT-U~NTqY(j;W!rkeVd3xQq=1f zrfFkYHROnqB5cHoMlz_X0fB^JgxR`nh(yA`^L-r0!FBERhta52i=4cA9?_lTwrzRF zy2I?wD;yYb;bN zxBldnG=nMh3i-dqIQi6 zZ&@zI!aS?1Il@K+U2h>0j-V(Sq9~v#30krdavvgIX@F{czTpu?zoQ+9T=wY`Va8}5G1(?F$93;qD7-DSaI~^ z0trKepjm-Sf{3a~2musp4!L{{SrnQsHW2vfOS2rmP-blGR&KfJ9>O6V)3OKxA6ZhU zRCCNP&f&Q}j_a_Jo5i$E8Z`^oQ80`|vmsNrDtL7V-}jIu1p$TmrAfS?$ncPMZAPak zi&Uy53Z+$c?>s=ZrH`LJ{Ule$pQbgnz^~mu%tzkQk8S%LJ)dV{uEyrU1o5as5CkZ4 zvwA&tKF`ZXbJVRSSvDHhC>2YbxKiiFw+#}}B`nKA6q|hn0cefu9KNuM`b3VAjt0e= zjiyT2PJrtP1c6AU9 zx|bv8uCjZxNPAkR(eSPj3S7%0V#q{760^34WBbqrwT4S7s<3%5LrYd?eqn}s!=kmN zgHXsoQ#I<11d<%0R9wNbYt-r`DwRByatYrT(e)^@>)42*h%9LYQh*?chzJbyZfA5u z2jPfBy-{D+3t05^jxsX*zmtfkDV8e~iZ#O0G}mu+xbt8u9W4fPOEsnznwU^WtA-+r zlqwD#tzni{N__pXX{Pd{>=^1IuKHApHWDs(?T&Eg_5p;c!<0*9jBo_g_K@(oYhwqj z^f0}aWMb9BwM`>LGo{=a$Eou5v8eO`FHFKW1$Btmts_3y4crHQE zbgPMwgqUjM)uPHu{yaN|TS&ztEUsFFG>Mw&GBH~tYRL3=h1sGgtEYJU2Tx%I9rX6^X7i@K3=eGvBJ^}_ps#DwdbS0&W3#qaAiq{ZAVB!UNB=Ge z0-?$5Rl`VQ9|Zi>53lh28w+gNxP#ulO+53;XLnt=8{EF> z5*_g(mnTiW`lAN7A82Fm<^+=1OjUii8Ioj4!1IM>UN4G-BN}npMp(XryL<&dP;ey? z&-V!|yIFjR^0jmln)QXdJd3!xur37(umb^ARSG*lxdq*4h+|)x$s;NwmhGlHWr`B)@5}>Lwp67A-gcXZ#q!a_=IAFy)5t~?Y}2G_+W2xaV9KIMLkHr37$Kd;P$(Joxa{td@)7HXhEwx1fEAEoW%2VTnD74C=b{1 zaU7eaGOI*5gl*31!Bqm*E z(?|j**vxXJn{?ZLHgDO%xrslauy6%MvdAP7CaC-|; zxyg{z!~oL^cwydR*>swXCRG7N!}J1Ht2K&towa%nl@&gEcZ~aP9Rg8hwNP&sce3yg zs`gsLC6{+`yk<5Ri^$X(9%KCpo;zM58BcL=&jErUps+T}-25bSbCc96CYlx_7E2M0 zBruE^M#NxfU=JasgGzN7%c&!;6=vz}*-kpyi7IKBPMun#h|mlXTsd`y^Ov4tcHsh{ zkj}uK1Bl7%P_vu4`}$$JJ2G6pdYXuIg*$IgF*=au)OeZJl!UI!oWEM;z>YMg?K3}D zL6s$L+L2*;cARmihVCnPa+7HwhysBKFC1E6Ei=e(Z|otwa1K|JIX+k7-#vYXy5KU_ z6=N<}MF@0$|3mL$w`vlYs|d2pshKtY=*TqjY#2?E5Jd=S5{lxX$tJy7xMNq8TlZ}w znvPRkt5d7j2!&(DR9E(k`&^?`SYyg7RVQtP!x@@5hE0eAqGj}@eW$D0|+hPBnw{iO&Z>6hijCeSVAlfK$!1FIZ#karnpBQVOCzjg7 zAAP&ZTJ946<`bRVcA$&*zqJp?_4)a$OURPIy*GDo_YLj5_2y1o*Sn^0_k2)g0X-z~ zOTYI#btOa@os23{^rY~1jkk1v$5{FJ^})|s?y&XVa@WXR2q1I?KNiu4RuA>zdcQOLyGN-1-1>=5k&#h zYxWNFl^RvsVcSTCty?=VM1fM-YO*vuu>64fnu&i6tZRaKj_*^hdca}?j-4y;!Z9Dy(Yfcq9qit9XOqPt3Ka4tr%oSXVR4#`V|&>! zwvR+S%Rv8TW~Npt9X+f4UG8M#rnD3w>%cO-=8H;JT)WGDOfypKdIi|>h)iZ#|& zYP7XS5H*RlwG|qT8aG{k4}16BMqmFHo_^-XoI3eauE?tl_h$L<+g0XQYIL-O*f)OA#_Uw7qEjS1;%lS z${zh;oroBxv+NTJ$!L0j==#`!fNA>#2-iAYZ<@HY&OZ%l5`hiR9a&JQdwn$%Fn2weZ%Xt%Bkq|`iJ(1^MJCAvFh0$P^LL=bouTS7)T*Lsd9Rgos zt=Qmizx`9DG64e#pXbizcxGymcl5S_7hroHw$lXd1VE9RK;oMKYTz{)5Ale?-YqG9 z@XQ>VDzbZ1n)wxzf$lJwq(-IY^3tn|Jn{S#&%C-sBof860?wR%o@_e9uI<;6TUz6% zPkxQNJ)aGzkx8`^31?_nl_oDz zRGNzU04%%8((*Xdvljt@fq@}X$ux?pAqXOc;wqI&iGjXN6iQ`grq7@o8J4QO96ouK z_uSD&S67UB)uA<`V}v9YS1l?v2SbyYTWK)6(xAUH+#KLh^SF3=2*^MrQRqI31_ey8gp|?#A55Z0!2g9G=jiK)eOL4Z1Hi$OA`Qe82Lgk(N&PY*ZkPGfq_CeS;~PBKxAzK%$fL=_0@q~B(m zy0T`XYZ9rLil#I{LB|!CoUQW0;T7&U*vheUO`K}a)--`6GJd9jp#&_i709;s@s78= zlfM29boXqht$mEI{Pn-(umA4f@xZ%&gSXuAeqKNFEXR+(Oto5O^QJu{W8GBB&Dd%= zRV*t+Jlc*Rhl-Y9!_W%=6ouJYyY&vJ2M215@qGI|T!x9(+md6tg$UN&zyNPaEH#MD^^dbct* za*(!`4Vae6^z?aV=O=L;2!~Uo65Xgu2+y++MHx*skkZMGpG_oUw6_ir31?W!UE$Tk z-(>viYorn#?A-P?hWq!TX<=N~qSnZ>ww5EE>SkzQ7*X zHm{soV{X|b6IanR5!>;uRjZcM)Faf*=Dcas^6>+|Iml_9VeL3Ry4IX0^wgncR7K## z#Uh1@!?kxpm~bcU%`T-Mes`r1SM)sGjLTxlnnh$9F-TQ*;}j+qrv zy^JMO<5^tGkWK$&;)V za#)th!r~PBEW1xN&zMAG^ODT~o*;4LVywNFp@M^(K1&ubjwH zt~d<$Mp<07c;e+n2D>A4w;9(+Ua_!@D2iN~Ds%E;kyu0_6;r629-1Puuwr7_K1Qgi zDHecOSY{z-VVXXc?GX)oGi$@C*hDvoQS z>rsqQjC#FUF~tI^*Zg@5Z!HI z)`~VWi#4(-o!)kXOViC!V#Tt>tEbm!N$P}jiE~%WbhLy>MpYJ88bre~m72>NXIAOR z8uWIBSz0v-{D79U4nja%T4%5?iXurII-Te77p^j~+`;5>h?!-dnki%GkZC`Ns1NbN zE6;Ob{0#kl8yFqgLa9{X)kDt^(nIugk5DY;sZ`1g4{jzDN-;Tgj`{hkh@#fa_hp4@ zZHxbn6Wt|Ew(O5H#@^&vUTsI+7%#sv!hHz%;8g8fARnN7Hl?i71lf zb8-Cj=DlC_GPm9ILGHTcBg7&ZO4U5R-@tJkBr#xkSjuy^+z z>x2M@Y`U9}9%prJ9@}ov+A<0VtmdbfoH>Hy)`^DOsnsl`8xMT=v%cR9T+PcdS;x?2 z0^etCZH|0?u_@gQMUW(!R5Hbhv(NC_8$Y4HcMI=)+b3yj>Bq7gOwV3nad8e&l8MEV zsH#e_RN`k(e38Q^&(S^nOT7EuU*_)H?xwH1jaN_3(AU3>PrQFWhmXF(pM1~Yu@{#a z?(*5TDT%n=#1CmQeH~#`MMP6X2D&0d3wOWQXgJGS zVTy_A*Rd@DUF|~JxAVQ9^#czG5QHWjLl7iv%jC+{V-$-kWV2ngWP1=rX`PVd;aD|> z2Y0e_`<+e7h-)6ehw=S@bi9{xsg5A1jE^7V$3Oi#k$6AvyZ=-4^>&d=woojs z(%QC}_rL2-)Zi+A`@_TBaNF;*n6pXf3tXMb@x)7ujP=D>%3GYgutufkAxZ1BK1o`S zLn<#GS>gQE5|gtP{_Go<**X%Tw=;$;NocZ2GO973Yw*W^cY!Z{=RDW%NFe~=E(6E zF^o8=WEMe$dfi48bt=_0E?+&2t|d^_Fs+$k1W}||Ss`q+BkkLD|7U~1yOy#ELQ@%8 ztrd_YnYPw`5{XuVz+dP33%IUBCeun=whNyC$FT|UsaA_5lPz?03;_bJ>k`pZ6p96O zHO|J-8_8t)F|8^=;1G|;==9pKe$LPUm{-?`LnN|qqjq&r#-@d_?xSI@w<~;p04r2(LC8q49m8-GE<>7 z6J;fDbN|15l|!dih^24fuABD|Qx`D(6g&6*8m?0&8jd2%8pYx&vMdwQ!@PFrIac#I zve{O`;RwZ2k-AwUnatog79f(%w&QpX6O$Jh8rVv{xWXGpo<eNi!c9_o}M8r%jA{UpJKz{#D+>$`>_ij=CZ|u5O7x*?5ePg~HA=llJL z?gM<_fsf#L0#{~M*}L~v66wvHJoy641%W&7`gLx(;a1|Y82k3!&)m{7KYa2yg^EQw zu7eon?tA_P!vllp;dYLnU*Lz2Jw~x=ao6qd;?XC*Nf1C!_aG-u9imiPgXkvYN1-Ips6}-t)0|sb%H>|va77+7cfF;Mh5qB>GI2n zqE0;8L4ZfCQN?itr0e#5@DFUq6jW8jFm!?-z;QeRJp90056t3!!Mkiy0>$+U)0%jT zBuXfX&dl-xr_VlzUO&#xPLrwSJZfx+)=Vd!XQS&OR##_v{qWOtwhv&03~Kcv*X_T9 zO0`O%n5Uz)o%W7?R#&cY>hx1gE+^Q&<1U52wG2Xc&(Ou~>|jmJt?K;oOCjbad||r0WE}L*3SyoGVf)&hzefehjN&^4c3u zGtfWAhSBZ(;KzT%#<6W|+O(5!ID%z0$fR58?C55Gewy~yP8`o=CASQMNPv$hs9c>m zPq|vevgx@ugkc-}bO;`*d_C7#+=`=?eL^`n7oQ zUzc+U&4BFrsG5u-D`=X*)ZA5GdiC3kBmddQ#^`A8 z=Ir_7oH+FcMkq{IXFrX)MZ;_`zc@*$lp`8Rp~z7TBhJ;yBdBVO*31B=Swj$HLA-qR zH6%$OnMjh(YK(1YV_+z=&KCFAg?9h=$MZcrUqA>9Ds_vWzwjfDANd-edTS5=>V0?f zS3iB3AD{GT@7zr`9;I5ZuG3uVh@#B!;0_d7$Fe+hEz8v_m$`W9B%zQ_BoarGpj>Tc zKgmP~@mQRhxk;v`ui&{3j$`9`E^oc-J%l4+#xI}6vP`Z_oF|uCBtXQpZ9Lau=hofa za^qXLG=3IYlJPvB^A}DL3Pri|ws)c^AwnU8yYF}pK>!!VPxGF4e2nSYtDHLX2A17G z(?fWki)EPrn6^VE(@78nj9)p6C`wqiNj#q6#shEXz}~wE0-ME^2|C(_Q8k%-VUl{I zgsK|os*03|b^I)y>e?I$XFF6?^aNa~RJ17;8_mm>|IrNya9o>YJcS_2JpbxXIC}Iu zTsP$Nuikqj^+t(5{MIu})qB~sm!l76FM72hK*E?=A%g4nOCN~9x`X{pz0wsztC3?d?;_>a3F z$Fa$#+E`eg#T8P2^ zQF8elk|ZGtGA-E-db)>DR2AE@sn*JLcJ|`?9)-dxx@M3_W@yQ_BT6Egt}`<`L7}k9 z#<6WY_Ve$OTU(~TcbM+(0mSu$V`y*_M~=NhZgr92;jLJfm$CGdPQnKm++c9xgt zn4g~{m24xV#R-C7{lW`_awX5@E2ru0*^cX)%*>y!w`RulbfRX&xafxUoMk0~s*zwnBYwJQ$R3ecW(P#qS_i$Z@ zwY6pXdWI;Ki!3kCV}zsE)SA^=m6l99#ZsR6#TiCNwqV;9wOW~EB26aMitXC?o<}^^ z9LqL-NyCK_+A`Y4iSx{2m&A5DWj@!Tr0xj;xgsh8gp|~_@39C zVXdi{W`k~HF2pJKE`!|p;tx_!IDHhjIRh72Z zF0M{o+{vamEmNH?h0YXm`ns;ErQUL_GvvU$^PWLd>?9Y%(?a&i0&`QjQu;1P+$ z2!~^srima3OwC+G5=AnZ4nm4>!s3i@fNz zx4+F;TzvfVne;C$Pfg5yrCMIo2K)EPp-`fErJwJu&yto2=@EYP#9t6kuJU^ye?Pl- zZlQYiB;R@Nb*6H462S!?`}rAW7tgbKLqE6NxRpEhY-L+d8`R6>R#vbDk(#J-VLr$7 zQju4Wyu{>8o$L0$k8n7NWz`8A8J3qlo_y+MIyw~;Rb_c;kwP&~m+KLa#o4*@0P$Fo z%i|YVE3DApH%u~~rdF>aiV~*TARLb1xDHE8a|{n`;Ox2MWV5Zj{Mu9Kn$Atv-_88O z46f%kV~J^DIVQT=ydu3+UgOnQpW^!K?q+OsJB!PUw6t`hYaxoo)us+uR&kvsyg@SL^!AMU?QO$i;Q1i|=D(z3kmEOh~j@ynLSTe)DObJvGhrT2s8Os~#QgE$ER1%PT9` zj)pRyK@o zqtU3*(>2JG^$J%f&(YP@l=J$5MJ=XnH(z9i|Q zAc#!PoO%0qf9VfiTKJV;QNH};dmDfMD#PFy?FxLko=*H(9s#*ho3k%mJSI*}9TnSJ z`|0Z1##&*CpS|!^KJwmMdH2Cx96NfLZ$0@Ey`AlR==K8~+%&+Y*+rJBHgT)WiC13W zQ@E~=rbG#ajOGQQqRPdK zD-_FXsA`mGB*9v~$lT%tecc22L34up(n=2f$~cM)zUPq7=NK8>-fWH`gWc<rY{U>Hg^7tJR@auXO^;M6MWt5ZrI&w-v-dU>MMu}d z)ao^6XQz=w@B@*2A&+TVM8Zi-(_|&LKu^ygMkvfc|0pL3+qZ8pGY{0q9|B) zQ;sXFGo=&p6tlB4R4QeBKxM!Q=mRQmCKhiJT&p5E<5j2OlI9KKa1 zW@wbF9>>NF1bGWFBY|UADCYCc3pG8BNNfO2jo`bD=E!8xL6SnaE;z1-C`p9$5Wep+ zF?o><8+H>8N2%4z_(4E1U!bG2mtrYLu~m9*yY?ARbiFk^+`AO=v zbuN^u5{<;LZHq>ujwDKmf=I(`P%N)u*=Ezdt>4fs%knz6RYcbfR8?zUG~{~(zUvxB zLKCGLrdj#3pFQ)1&jZ|a(@pY=FTUvgqqnI7F5E9Y_~56-zxv9)f6n$Xr2Bm6^N8h-@GiOe*W{r`IB``uFfdB$3z9&*HItYp8OEy^qK9Pul zAhl7ecyzSs?A&o9XU@KX<5;w|bWy4lntcrf+FQE_g~C|2gDmM}Go2I)YsiwsrORh9 z^f2*Ql9|~nl*)zm1#AZ1YBfixQXrFRX+kcphwr=1yOZD44?EU+i?#_w`o7n^L(Hk; zIHn(oCVdbV@r6fz_RJSP*|hwm|Gn=$;Qn{6H2NoZ=xPYhYhI<|Iu^xZgG$x8_QI~~ps8UZ zdOKD_z;#@*nRe=pDwb6zoo*qWZY89LNu{y~fA-IHcL&&9v9sRqGJ)N}Ut$4Ih`3j}N+`Lds99+isFjzulpzYKPdzH z|5sH6x)JzM=H#fkg@qI%4Zd{z?`w(g`}}!%d7?PKSQJvIANj`nkG-ELgcyjSj|mG7 zPki#-zm0F$x57L%HYBCl<%`?vQuL8y&gJTvm(k;KqN$oP@v@Ga^_+9ApD-#hb>76mBXihXE8S*)mvpr)-=f8oVn@nCig zQZ>`<^NSw8E)wlF%nE!`)x^Gt$&}S@B_gVwti;QuS2xL;^{?>ju|u(%%!)|~n7Aqw z_B6Nfo-?NDr&r&*RQEdG>2Nz%-;jSpNAD#nc|iWrJLNw^0R;sGYQNyCWn|7zub=bT zx{q!h9AcVoH)+DKgpodYGM7xdNRJJT;H@R^DOQtNw7Q*ymVdzYSsC~ZgFkJrU~NMi zzaKr2nF$f-(%{@h8_509K(3fGfT!L($o8TRKD}fd9>2t^>->HHA-TW3Ti%wHDl^0E z%982^VVV4Mre#mS>2@6Kc6(mz>gif^!$a2{)D1~a4r)OmhB(5P{M#x(=1E253j^S# zyq6+}#}B@7&E74yKKRz=zMKy;oB>gzw%6t)3+C(AmFt1e_I6{Y4smIzUXqP zi-)4ZqlVq_)Loo!sx@+kW^kz6CHzu~Seu0)lSZ@C$@R;(bM}Qv?Ap`J!Kxm*oREu=mE{yZsD(G-<`9<1ZTswU=VrIKJ0Y_4tQ z{i+sR_DIH$o4~YblbASZ1PLk8goT=sc=320IGvqzbv5JibmP-?G}TN*STynRDTIYY zV^VD(EqL9M+S(R2ZrH-oWh+^|ZUeridK|!&7sYd9?r{F};vRgvUGk|pBaJSvp$2Ix zPM?35Zpi#OA8mXOAP+)2B1iHVIMH7$|Uv?S8g(@9K7CCCzkqeEv;aWxy(Y~ua*mr#3X zKZ?+KYGM|XQ)B6Jdu6-JYlK@(DuBnY|I=(S-Far=n=NvBi+D`w2l&R)IR@2}J2 z*1o^?8L9iPaC_afI~@Azo$J-4=tSx|J1AcB0*_1_!b{ut^GZb<)22`3(#tPm;u#~b z*?XvMDy6Zhn)>=iwr(t^z0HN+D>-v^7U`LB`2B|s(dY9cgh102yk4E2ZVw&pPU>sh zsjq9JrMVM{K}=jQ!?KbYF*1{+gftRj2N7ydq`IPmMT-_wyzEoXpqpl0u&}y~(NQ7B zAI7C)(Nx9Z@$Y_g^Zs9~Xy{l~kdve46c!v4D1Pq>IBXrk)6X0==A|J6h73uFA82H! zXN#v7{6!2Mv5zsslX$JLo=rRI$c~L-$gDa1_}WWI%Lt*PyPUF$V%j^}Q56lV)k;UZ zi$hhdgofKlOp3s4(a`myQ9uaM2YU!bpehQgrl4sGh9Plwd#J5$+Rdi`{?^Ih(D8cRAPiDd(it&i7FIQxXP zPwDY;oAqzac3=N)Q56O+^|;zk-AiEP7`%Uay!pyn2$=Vm6~ zbK8iN$8;$TMF=)Eb|@ahU`BEbtIBG4y|$a5-}^_dzUEvGR4rrMjCRkw2PMI#tCB7y9ovxp2C!H;jegH5Z8 zxNH7kYHMu_io4#wzk0K_b63#^`xWz%uzKZgmM`8vB>>?>8lIgS4NsPSCYp56`iFZ%@Ln^}H=Qdn51Z+Us+Z-yrie5}34VIYLi4Jm@m zCWMrL!{Mb>ks&uAR&;?>di!#BjPgdhmMn?@jmsexE@{ zyOX4pNPhNG*EF)W%g2JZ z-{h*R&SLqxmsz;*6N7->>8K>ST`| zfY;;Woi{cT8y7-*PYFYY4B}@u-p<2se?(_}Er+~PSy$KQ9~%=kyvt+ovjc~gF=K{S zR8?i1HVEp=>wNUsM}s8B8k@xy+uGqU{`ktDl+mUd5^Po$b?6iD5>M`FRfzE1)~^0C5g-d6Uj_Fo69cy z3EMVrB-m<`Z_UcmqpTLK*5SPV%B4I0E^`W0QJAZr@*N;Sh@yP)+I4vrOJr0*cu08G zXY0Q()|Wr4%sx}&xzEZdGl#Q$*@w9NB^0fDm%=x<5g8Ri?zNMV65K9tuf_jxg%5$< z;BtA1O9*4!gmgY!xCN)fAS}j>)7irPzkP_+>$c#oEECs{&J-F3D;v9~dk3Dk+tT}M$A{#}-;D)czL1lcXz`MbF^Hng+y^D^q1OIWk@9X?p} z7Li%mtSGG!?M|oB9yTKSk*jZd;mJFm3_mg-c1m_YPEL-R``mNJu(V+pHrDQax^mwd zWB2}z%8tqdV)L#>_LeDpy7*mc+tzWgVhao3+DUS11amJQLwlPORsG>x{fQ{5t{X@x znKWe(ySG=-+T2bc29%p%~Ah^<@4Sxemhbs>sAN)^IT6h$fR@^a_e11w$oA*PTz_LZ&S<9Ca3yFJ`6e`de% z|J|4DjoR$NR$l+xN`{X}A$x2bXPt2!jSULtoqG<~TofVe>LTPdGamN03(wSF=3ler z$XduZ-T_%TIqHb#ijCZ%1MW!-wp|l!wdz*0RZR#D<@wFU+>rke<0eG2b=PO?-rmgC zO=aA2``Pq#d%ss|d8&6ppL5R2PUiiBO(Z5o)6reQd9yFUW{u^=7Z(XbG35G9YqggB zD+V8EZF`fVe2Tu!4#>+x$I3lnGY*QbBWr^}wF z$7%57BcJfgyUryxJ{+&x_k(6S-((qv#AY*5Thm72TN}CIwrM0M4B?8ozhU~>7t>I* zoM+D;YiL4Ie25#*`*7oHG7m-MpEycBDLyEOVK7 z_~E-*v!MX1)xxXKujI_x!x)enjoan@j}v|$JL>82l9mz6gemEKSg@0}&T@9{UCB!? z{2%712o$Lh6C8w95qa;;o)9JSF-{hsDJNw}4?G}kQR(AX*R@?HB~n!sWm;ks0DpP- zema~bxP9$>@mUFAaKS}m=M-WB%G$<}^-lS<)%Lg`8i4ApaEUIYY_rLok z`zzO=C<<@AzLwc@vPeyf#_jg~Thh&M;*|Yhb^zi$;?m&Q& zXtzqkkVF4xO#B5W+Xct{Wnp2V0uUJ*d9`lHK#h}_&<10XawXzq&5>tvU)gkM~ffiU2X+i#@2W-HPVSZyZGIX@e}U;qCC zdQP;a&l0%(33uu)N0y6y9T8E%r#%`MLg4f1BqT+Um>f-KS34=m>3Dn0Ec#%XsHpUauI9>! z_ugEzsIsZ1r5APNad$u-C<2Vn!rjRsQOTpMnucEp<(2g%OqqQl_7D%<-5pr1AxLSQ zBw93sfab<7_7~SuS>8fhYd6kr4;G6?a%waY5kVM+WcsY3XqwueZ}~QY=<0N_a%nNk zKi^GPr<+Hgx*An8aq>{;)WMO-q@WubWfeO(bH*ed|C1{2ACYMciLi#%JG&>Y*vFQV6v$LM@lZGBw_-3<8!9UjXS+9VzvQxO_S96Grvg7yb zfZ*cG$FXejZeDtJ8AG#@uvj#l-JWj@6+#I7KAo7@P=0y$`8@yRQd(QOP!xftslC4D zyP>5nkDsj@${9ap0A`DijN!w1aLXDof3#p^gnj(Y#LZLVMuhpj^}Hz9(j^ufBC$94Pu62VCcQC6I_pB1mpnF zSl>x^Pcy@Y4+0R`Q6WUy%(11N?MeMm|78W_<>d*0yqh0RdwgF<+?K{R_O&@gsmEa2 zq%o9L?#I>RCv!+V9*^&+o=nZ8vS#Ieq%_cVgQ(~*va*wLyL`t)$BH6wc6&K<_AoBK zVmv+0K;58EkQR%H&@dapAy$He1H0|WqR5wTTGJFlLv4hH2VoDkoYa{chQw+!6BZsg zzAebyPn8u#5E5d=9&EvG51iK)WJU-uWg7i*Rf868GU zY-oQ+azLO8t{yLC2O9gCacX)r_7F?IaF2xx@cWH^!@gf~u+*hSA&JC7nxv6#ph6vy%IxZPfa5X?Mh_%ZR1 z0Cc}WT1E_w^&NC}wKFI+n3TjsLRBg4n!q$(C#AFIIxr#s>hNdYv9Bx`$9Q2I_goIiHmM`TAYd(jCAL!Tg`*i;KS`i!9 zm2%cOS!9nHz=!W{W!t85RxK;$mOJMV5^C*DdkBJpEPVd)PF5`|Au`%dQ-gzsy0)Ws zTHiG)ia=2m-gv2qZJR0>J7FNpKi|u;#k;xivhn0xJDF~Whpihc*tNZ~{}|`b8_V$Q zWcKW+p?G%QM&Nmm&3de54PGBQ`#a*0wgrXPXE~NaFF$dk7AVB06(? zD3nro1Pwim4>^G^buFC}n;n(n<{zaRTX!^x)YO3}s+W$AE<(eCjw?^YkQn;mt#7rO zPbT|g6(J?hKe?2mWqTPtZXh?@GLx%*GKF9L?gC6Ejne(~y#BW$R8iC#A$>()h>At68yR59ePzntL9Y$2B)hLs0~u zeXuD`da2*GUkN2uRcEz8y}9}=f|(R z2oI0IH$-qK)F6AvSrg1A6NRAnUEwjgpr9a)IpZ#&;NvEuqpS=Vkc`XY!0-1H78Zo@ zrRG^FWpBi5?ho)B4%gv0{iMvnz!qd?>&6Q9?5sjj1gQg~aXLLTHgu9TGMNEs(FjGb zdq))q4>S@UVQ1^c3O@g22NS1cG9WFQ&Ne4KJzmC697tS3SU)Q=3`tOsiJjZ3SiPbc zli9?`F{!jRJFr?!0q3pjy!^~^x;kAryWPyo9nX-gWDG;HYIzBd-v2&_s@wSWA1=gZ zH`CeC^VR8>-oVf8anRE0WORBEm!BO*aamb_8Xqqi*S9HlRUTyX!5uvRd>Nm7(agx~ zOuD<fahyh1~52VT$DsR;`NP`vw4zrf5E4MhallBz1C zWyJQf5lMY*JF2R%Vrel#1Xz{Ncetsy4;qgyo+xZuSJvO+{?==2_`|Oj@aYHJ8J?ZO z`SZr|qn}R47Gy@(13USuAD=;Runog7*tGs2^Y56A-EKZEsW{$pcDoS>9NnE5eiz$H z9PHW?&lR)I!{v6Lzy}oOA_Tnu&wCjeB3O9dNbdY%D_T$x9*+Z$+uw&5$Iw7U5llEE zgT1?|Q521;ie?T~w~>+-h1cyrMiG59y)PQ+aeDjNi__`phYv+j0%?sSZ_eqVxv>i= zC3T0|F?4ytjV46EF?O`~ASHM`K4Rm;n0Li^V&Xyx4YLJc4T;<33&4)YPv+2gqGLm; zEN?_n6}D}vVD6=3zFt&r(pX!xhq05A>FMdg;pk$^fiAL>4)C1wX%wNNOa0g#kV6hq z!mmffbM5eSY>G;c6huaZ;qiL#`HtVg+}Fl8X=)~k$K2#{_MP z8aU3=|NA-ZtxnQ1V*1~&DsS%1YD$b=_7d3gg80O6kf8R)6P4vn>@Th(EIbHZH!zvB z{%aIM^undUY*zc@kL=MYjGvs2#cIOi@nJ|wkJF3p53r?>P%AG#vmCQoB_SyiLpS(v z;Z|yE+Ob+q$BamQIWwwi3Z0!jIJ$ZWw%gDwX6_xC!ZQrQtW|U*W>iy@%XXY zY=nn~kQQVi#%||~0ZGTW#$#w`yv?M1QQO9nnkF>WL`YaL4V`X4U>L>;^B^9#kCfDC z?!NzGUVVNARpm{*`RXdFt6Dkdf@~tAf>Bk4hT3)(6>Mhf#xkbO9EQziCNVjZt9~+- z1#hgO+u`DwCzf#i{24SicCqq{Vg{te@S_{2Bc;Ua^>hBb(d^n@g`=y7=B7^a?k*rM zAq19Gyml)~a-dF`O2uwavRRh`oHw>jm+;B^+i7ifkdzY1WmiokB-Dz><7Ze_A{m3? z@pyDiJ^vZjw@> z@caCv4UA#e_G+4&I*E=8CoVpmx52vfcMN6}TZb!iThlbgRjtL2b`HJYDj_K*~GGcTJ*`rf%Iz9OPI>EtK zOlA#NkN2qP+w1lbpAg2ach14-aAPuS1li4ay*gL^_zcdwXcTb?VT6X-0O;;^W3`#M z`X^Jk_=@p(-9Aiajo=U~UXP!))^1YLqIu|vEBkwY{63v?FC4`=7i6QT3aTP7i~x0( zCy;}pDs*?b&`c_!p*GAW8(khRgMuyGI4qe?ufI8vbvRs_>{md}@IndD;d6F*eK-b2 zhKEFGf~J;M!XpFoxn7U|D`Ts^yiRww2O%Ih#7cNXP=9N>ZW#D|0ZW#;aYRZ(;Og=A zR<_jMh&zyA6JMGs2q?~vlwdMzNEz7nLO@h>Fmdsr_-37 zayOkY6(RC_^V2tdT(;ze+v=M)?FiY?+$Fs|E@4ivVbTC1>pL-tZw**haF`u`{J{oy2?q6Fdyuh);)dl(W9v!WxLY-Rsg((zLy z;wZ{|Wa{K(Sy@$8Xl-#28y8AwXc$hXfxERuCJYQ#_B6M#rO~nL$e8OW7Szk8MNUqN zui778x!ux3M4UoPQwLV-VMd}T%D*%UC`7A3prpny^5~oE%gkgg+@x-ShIJ)8|}q>q=QwRl`}934}!k(cI9(>3)WhVMr`i z4R?=^k3QW>>Hd0rUY(TGC}y2If~c5)b3dFF1w{di#mvraRczl}NoQvdHk+BzXbhDVjo2(+IroxO@xe!B)eHaUs_OXP4a5oy^CUnR=We;_zCB$lH?=AI_LWGVMn$kR2@gqP_nuuW z-_|95|K|qo{Yl%dCC~IQL zC%gFhZ_Z=VluUF(;`jOa+f&Qfy0Hv^P3y`Snw8XFP&r-AS*80Lh)W11JR}mg--=Oj zK;96Npp^Oy=aRK=Y?Km)7Z%Eswk8(l<|r?|_@eKR69;}iXHYz{r(15_QbI~nCeCgr z%}tKpLi%awD(LZY&fIK%a?=dlE-zhOE?Szq2@4Nm(9i@7-5@zN63wJw7zUb2rKYNt zi!K|>)EPr?bh+qu1bFzIxg-18OlOCyH-;{C)#P5rjts(e3bXptz2qS;@@H8IQ-~LseB=JzgeEPUphQ#?sN|3scWq>qPmNf;gKRJw~gyC#mznKO5tJ%@!#O2l32+#|1`0D1|+`>Ww zVB&(?SKeN2>{ z>;a6QJP?=b^spdJQ)q8&%aI|)_niCbY2M#?}%t);1p<)7~%E+L$s{rs#GwzZrJ>kqP9*|Omv zX&EsjB}Xwh{R}>OcM-RyMT!|Iu@b?{A|In5Fd-vPm<>I06@ip_c}nWv-fX)(J#NUL z=X`6HP$Et45%52v%eheOqE z=(>*AqceHhpko5EKuz1ilaGFe&+F%R`IitLVGqPEaLO5w5CWgqPtg}8oPGW<($lg@ zh#kwXZ~K)oD%c`Q9nSJ5Ro=0*ywO)sTulD)4DLh+&&@9ufLL7L^TyALO1vGqPkHEP7fxN$~hN|M9M%aqqD=+8;=~ee552K z)W+Zb?+fY>H`INeVK*8V4-d#0h4v8l!Hkk1v=kV4W@8WdV z@=ANBc%>=)t-F`*asuj!9_DmH1@t}H{E00ks~Uvy+1b-##U~$ss8^Rc89I0r7hF1m z4;F6u+J)+G!Y=xfZ@S;;Zv~N35)l#9U!9XO@XVp$P%E!Iw}SGrCLVq28WNJi1AiN7 z$JrgIXz52}U%%~A)j)ExWKSL0V-krBO~q!8<=JPRF@ApC5HWpXc%!q^`+^!khZ9<; z@|DIY{dgVS4>q*wonGk|4?X;(96E3&*`o(AZo)tof3yRu&2-9k!I8b80fB)%*uuJ1 zrAVnGgu+>KvheE$5OBCc2%ddn2^-dwa^tOM(cIX{_RW=)?r)%EPc3i0x|))`wOFj? zQ*xbsDU(g>4-y+6hCRg0SyF<{W(w2}deg-okDsNV z?Bbn&tOel8pG@Vvi$>Ga?M5@HxLrP;dE#?Q_tjxG2VkbJ4iLD#KugOVzrBDl69#^( zR`@T&WLrx&_7IJvgiNMSx`Fei%{E5rPGwn*b4$&Z_Nipy>y^t1wXS{Di3%t*1Spla zp8aTThx3xIp0q)~``vxUmi3F317SO{SUNx|-AT5UB*~z$izC9H^e4CQk z_%IxfF3y~MDNjE2k{s)5;kPqJ>$i+`-YrH5uMA8@o^<-;D-|F>7Ubk8;m;Sg@452K z{5rq#R&8au@uxpM%^x4Qg*V@Q1e?`<#Kp;PxElgk-rm;DhSdjfI=$$+K}>82bLNg@ z{NxNGqwI$*SPitpOrAQ3?9l`II}&_89hb}7pDERKgQS#5gd&crz}$2xb#OR3 z89rn(t&M)3dE!aq$?<7wi_`boly^6-%*)GD#MgM9=-0lQJWw7{6=QH{@XKAg@zdI_ zZvEYl7O4X>6_ysgO|U%-RSnpj->ZM4hjmB^K|y9D^me={m^1}Mpw{d^PH&RQq@t*Y zhdhp~n)ThG&#NC*Q~sv7Z#HWItnihcB_X{^MZRRk%J`a~)a0gF4 z|F+z_Y6WBCA{={KJLii(7dGSsXy?}&|NPqgF&_+>mnX&-e$icP=yz<;K zva$z|GBB7KlXBR1po@>5ctrnT-Wkf@XN~%!SNE?e$jMO)zozg{7zv+H34vY~_0h`vFm73y%at{>mmrczsZo2VjqN!sqAt83&U9gF<69=N1G<3i5Z~FibDJ80=qN*y- z{bezkgA0&*Gl+jX;cJ?TqQGAt z{g{kF37mV;aE7GMBzw>-X3xIZKVo2qR&O({di0ZBKXXax$vvXhdpZ?BVPP?OpgdjD z{)JyBXmnWk^mU(qW(1{-7Skq=71G>*s+jrsy{%-8O2!@>Xn^{6d4^nI<{?n_{Ocp1 zFl2ZVb1xe~d`uP@sZ*FX>oPr9Ez=&lXVR*BAIrMzclo>89{?r4sOVo-z_*czka>AR zi%UmoqdgnK>5@B`#_xDan($;?ke`hjle;T14>bjqM$m)z^2oe4D4ItKa5g^NWg* zd3nm7bzgvU5Ua!NFxf4mzhB?B{J@%xPaCaWK+ z^;>Nwc5JRBDmsMO=MP~((pVxxhRZ9jyw$i|acO@zbL6VqMh?6DuBF>rWL}*5bZ5%P#9zX&0a1Y(X+S+l7-gmLrFexCf{SUvmNA-7g`TeT-kx$EO zZ+&EIRZku$&lP|BkF`=y)r|yF7N`sM=p{RDnfu|$@Zjtx3qDMLZ|Me+d&%{BcGg(M zWbGs(R;6TjJ)72*VYQh^Op3r_(fV7@;q)t`@3}&Xst_6$grn2N?j1E)Y#NH{Cu879 zhGxz-9)I*T^C+KVzz1hgXphUke zPSAC2wVFUcx5GnqMGM=vRMOPgK~hR6GtM5t)TvXYRU2Tu|NeTl@RhfysH)bl&CPiB ztSJMZxbDs`ss(U=pFy35b=p1OkxW58IX~^DSC{_$<=y2s08t5vDO`NTWisc|Sw>uf zsH;DepNQ|_0S zhf-XO=-&}*9t-glZa8b=o69Z<%QP-tzqvbV(4cfCOqs#7nbR0CBF%^h71Hf%r>&!2 zID0y9Iy*#{!yy_PT4lGR2g5Khn^i(WZNg?VONk*>Rl{Pj$ZYpR2~d%Doc_)K7zKPO4K6sssY!WH@;x^%9lP^D z)dh_k+QtuwjU0B!6cQv729TYdMRH04@kw!LW{8Ojkr9zLp{NE{s~MA7#n26;6qwC6 z3_tjN3Jvw`?A~>NRYe>0&6~EC*VWhT0G33-8jr=iug4o0)CSZ%kl&@?zi$QfJL=g z6h-mc?e>cLx_VJjS+%>Y^uQsH&$9#A2kcc<#Q_3c*N>#klJ7zAKe&~bhmr$oPR`N6 zVP7#szV@0`$_i`qE$0q7D?0p4U@d$v;Y7A literal 0 HcmV?d00001 diff --git a/src/codex32_gui/artwork/dragon.png b/src/codex32_gui/artwork/dragon.png new file mode 100644 index 0000000000000000000000000000000000000000..06e8b60c9ed4a5bc2643b162e34921f8d9ff8fcd GIT binary patch literal 15291 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 N002ovPDHLkV1j!a4NU+5 literal 0 HcmV?d00001 diff --git a/src/codex32_gui/artwork/io.github.benwestgate.codex32.png b/src/codex32_gui/artwork/io.github.benwestgate.codex32.png new file mode 100644 index 0000000000000000000000000000000000000000..53a25f9251bbf6fbc3f311f7624ca890bf04581a GIT binary patch literal 13126 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 literal 0 HcmV?d00001 diff --git a/src/codex32_gui/artwork/lock.png b/src/codex32_gui/artwork/lock.png new file mode 100644 index 0000000000000000000000000000000000000000..c110bd4a18ee7bed59563cfa13f5b066dbc94330 GIT binary patch literal 20367 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 a_5T8IFCPO7jZdHe0000 None: + super().__init__() + if mode == "readback" and length is None: + raise ValueError("read-back entry requires the card length") + self._accepted, self._length, self._mode = accepted, length, mode + self._expected_header, self._allow_header_erasures, self._pending = ( + expected_header, + allow_header_erasures, + 0, + ) + self._limit = (length or TEXT_LENGTHS[-1]) + SLACK + self._dropped, self._rewriting, self._unpublishing = "", False, 0 + self._last_header_fault = "" + self._stable = "" if mode == "readback" else PREFIX + self.set_hexpand(True) + self.add_css_class("card-entry") + self.set_input_hints(Gtk.InputHints.NO_SPELLCHECK | Gtk.InputHints.NO_EMOJI) + self.set_text(self._stable) + self.set_position(-1) + self.connect("changed", self._schedule) + self.connect("notify::selection-bound", self._unpublish) + self.connect("notify::cursor-position", self._unpublish) + GLib.idle_add(self.set_position, -1) + + def prefill(self, text: str) -> None: + """Offer a known header that the operator may still overtype.""" + if self._mode == "readback": + raise ValueError("read-back entry cannot be prefilled") + self._stable = grouped(normalize(text)) + self.set_text(self._stable) + self.set_position(-1) + + def reading(self) -> Reading: + """Return the current reading of this field.""" + if self._mode == "readback": + return readback(self.get_text(), cast(int, self._length)) + state = read(normalize(self.get_text()), accepted=self._accepted, length=self._length) + if fault := self._header_fault(): + state = replace(state, artifact=None, message=fault, level="error") + if self._dropped and state.artifact is None and state.level != "error": + return replace(state, message=self._dropped, level="error") + return state + + def clear(self) -> None: + """Drop the entered recovery text.""" + self._dropped, self._last_header_fault = "", "" + self._stable = "" if self._mode == "readback" else PREFIX + self.set_text(self._stable) + self.set_position(-1) + + def _header_fault(self, raw: str | None = None) -> str: + if self._mode == "readback": + return "" + value = self.get_text() if raw is None else raw + compact = "".join(value.split()).upper() + text = compact if compact.startswith(PREFIX) else normalize(value) + if self._mode == "import" and not self._allow_header_erasures and "?" in text[len(PREFIX) : 9]: + return "? cannot be used in this header. Type the character printed on the card." + return header_fault(text, self._accepted, self._expected_header) + + def _unpublish(self, *_arguments: object) -> None: + # Selecting text in a Gtk.Entry hands it to the primary selection, where a + # clipboard manager would copy the card into a history file on disk. The + # selection still works; only the copy that leaves the program is taken + # back, once GTK has finished publishing it. + if self.get_selection_bounds() and not self._unpublishing: + self._unpublishing = GLib.idle_add(self._drop_primary) + + def _drop_primary(self) -> bool: + self._unpublishing = 0 + display = self.get_display() + if display is not None: + display.get_primary_clipboard().set_content(None) + return False + + def _schedule(self, _entry: Gtk.Entry) -> None: + # One edit reaches the buffer as a deletion and then an insertion. Reformat + # once the whole edit has settled, so no intermediate state is rewritten, + # and never in answer to this field rewriting itself, which would report + # the text it has just filtered as clean. + if not self._rewriting and not self._pending: + self._pending = GLib.idle_add(self._reformat) + + def _reformat(self) -> bool: + self._pending = 0 + raw, position = self.get_text(), self.get_position() + normalizer = normalize_readback if self._mode == "readback" else normalize + compact = "".join(raw.split()).upper() + if self._mode != "readback" and not compact.startswith(PREFIX): + self.error_bell() + self._rewrite(self._stable, -1) + return False + duplicated_prefix = self._mode == "import" and compact.startswith(PREFIX + PREFIX) + source = compact[len(PREFIX) :] if duplicated_prefix else raw + canonical = normalizer(source)[: self._limit] + shown = grouped(canonical) + self._dropped = "" if self._mode == "readback" else lookalike_fault(raw) + fault = self._header_fault(canonical) if self._mode == "import" else "" + if fault and self._last_header_fault and len(canonical) > len(normalize(self._stable)): + self.error_bell() + self._rewrite(self._stable, -1) + return False + if fault and fault != self._last_header_fault: + self.error_bell() + self._last_header_fault = fault + if shown != raw: + if duplicated_prefix: + self._rewrite(shown, -1) + self._stable = shown + return False + before = "".join(raw[:position].split()) + normalized_before = len(normalizer(raw[:position])) + kept = ( + len(before) if PREFIX.startswith(before.upper()) else min(normalized_before, len(canonical)) + ) + self._rewrite(shown, kept + max(kept - 1, 0) // GROUP) + self._stable = shown + return False + + def _rewrite(self, shown: str, position: int) -> None: + self._rewriting = True + self.set_text(shown) + self._rewriting = False + self.set_position(position) diff --git a/src/codex32_gui/io.github.benwestgate.codex32.desktop b/src/codex32_gui/io.github.benwestgate.codex32.desktop new file mode 100644 index 0000000..e2291ee --- /dev/null +++ b/src/codex32_gui/io.github.benwestgate.codex32.desktop @@ -0,0 +1,10 @@ +[Desktop Entry] +Type=Application +Name=codex32 +Comment=Create, check, repair, and restore codex32 Bitcoin backups +Exec=codex32-gui +Icon=io.github.benwestgate.codex32 +Terminal=false +Categories=Utility;Finance; +Keywords=Bitcoin;Wallet;Backup;Recovery;codex32; +StartupNotify=true diff --git a/src/codex32_gui/pages.py b/src/codex32_gui/pages.py new file mode 100644 index 0000000..4621912 --- /dev/null +++ b/src/codex32_gui/pages.py @@ -0,0 +1,1481 @@ +"""One function per screen: build the widgets, wire one action, return the page. + +No screen decides anything about codex32 text. Parsing, correction, recovery, +sharing, entropy, and every Bitcoin Core operation belong to the library. +""" + +from __future__ import annotations + +import difflib +import time +from collections.abc import Callable, Sequence +from dataclasses import dataclass +from typing import Literal + +from gi.repository import Adw, Gtk + +from codex32 import ( + ConfirmationResult, + CoreLightningSecret, + CorrectionCandidate, + CreationCeremony, + MasterSeed, + Secret, + Share, + derive_share, + generate_master_seed, + recover_secret, +) +from codex32.errors import CodexError +from codex32.generation import ORDINARY_INDICES +from codex32_gui import ARTWORK, reading, wallet_setup, work +from codex32_gui.entry import Codex32Entry, EntryMode +from codex32_gui.wallet_setup import BitcoinCore + +Artifact = Share | Secret +Accept = Callable[[Artifact], None] +Timestamp = int | Literal["now"] + +LEVELS = ("dim-label", "error", "warning", "success") +DONE_ICON = "object-select-symbolic" +_ICON_STYLE = {DONE_ICON: "success", "dialog-error-symbolic": "error"} +SEED_SIZES = ((16, "128-bit seed, 48 characters"), (32, "256-bit seed, 74 characters")) +PRESETS = ( + (2, 3, "Three cards, any two recover (recommended)"), + (3, 5, "Five cards, any three recover"), + (0, 1, "One card"), +) +CREATE_WALLET = "Create a new wallet" +NO_CAMERA = ( + "Do not photograph this and do not type it into any website, chat or password manager. " + "Paper and pen only." +) +NOT_PROOF = ( + "A card that checks out is undamaged, but that does not prove it belongs to your wallet. " + "Only restoring the wallet and comparing it with your wallet record shows that." +) +GUESSWORK = ( + "This was worked out from what you could still read. It was not read off the card, and codex32 " + "cannot tell you it is right. Copy it onto a fresh card, then prove it by restoring your wallet " + "and checking the master fingerprint against your wallet record." +) +_CREATED = ( + "Your wallet is ready", + "Copy these onto your wallet record, and keep it apart from every card.", + ( + "Store each card in a different safe place. Send a small test payment and wait for it to " + "arrive before you put real savings here." + ), +) +_RESTORED = ( + "Your wallet is back", + "Check each of these against your wallet record. They should all match.", + ( + "If the master fingerprint is not the one on your record, these cards do not belong to that " + "wallet: stop, and do not send anything to it. Bitcoin Core is now scanning the chain from " + "the beginning, so your balance and history are not complete until it has finished." + ), +) +CARDS_SAFE = ( + "Your cards are unharmed and still recover this wallet. Nothing was written onto them and " + "nothing about them changed. When Bitcoin Core is ready, choose \u201cRestore my wallet\u201d " + "and enter them. Do not set up a new wallet: that would make a different backup." +) + + +@dataclass(frozen=True, slots=True) +class Record: + """Exactly the wallet-verification record's wallet-identity fields, and nothing more.""" + + identifier: str + wallet: str + version: str + fingerprint: str + account: int + + +# --- Building blocks ------------------------------------------------------- + + +def _page( + title: str, + content: Gtk.Widget, + *, + actions: Gtk.Widget | None = None, + can_pop: bool = True, +) -> Adw.NavigationPage: + scroller = Gtk.ScrolledWindow(hscrollbar_policy=Gtk.PolicyType.NEVER, vexpand=True) + scroller.set_child(Adw.Clamp(maximum_size=700, child=content, margin_start=18, margin_end=18)) + bars = Adw.ToolbarView() + bars.add_top_bar(Adw.HeaderBar()) + bars.set_content(scroller) + if actions is not None: + bars.add_bottom_bar(actions) + page = Adw.NavigationPage(title=title, child=bars) + page.set_can_pop(can_pop) + return page + + +def _column(*children: Gtk.Widget) -> Gtk.Box: + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=14, margin_top=18, margin_bottom=18) + for child in children: + box.append(child) + return box + + +def _title(text: str, subtitle: str = "", icon: str = "") -> Gtk.Widget: + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) + if icon: + image = Gtk.Image(icon_name=icon, pixel_size=48, margin_bottom=6) + image.add_css_class(_ICON_STYLE.get(icon, "dim-label")) + box.append(image) + heading = Gtk.Label(label=text, wrap=True, justify=Gtk.Justification.CENTER) + heading.add_css_class("title-2") + box.append(heading) + if subtitle: + detail = Gtk.Label(label=subtitle, wrap=True, justify=Gtk.Justification.CENTER) + detail.add_css_class("dim-label") + box.append(detail) + return box + + +def _note(text: str, level: str = "") -> Gtk.Label: + label = Gtk.Label(label=text, wrap=True, xalign=0.0) + label.add_css_class("reading") + label.add_css_class(level or "dim-label") + return label + + +def _say(label: Gtk.Label, text: str, level: str = "") -> None: + for name in LEVELS: + label.remove_css_class(name) + label.add_css_class(level or "dim-label") + label.set_label(text) + + +def _button(label: str, on_click: Callable[[], None], *, style: str = "") -> Gtk.Button: + button = Gtk.Button(label=label) + if style: + button.add_css_class(style) + button.connect("clicked", lambda _button: on_click()) + return button + + +def _book_art(name: str) -> Gtk.Image: + """Show one static illustration from the MIT-licensed Codex32 book.""" + image = Gtk.Image.new_from_file(str(ARTWORK.joinpath(f"{name}.png"))) + image.set_pixel_size(42) + return image + + +def _actions(*buttons: Gtk.Widget) -> Gtk.Widget: + box = Gtk.Box(spacing=10, margin_top=12, margin_bottom=14, margin_start=18, margin_end=18) + for position, button in enumerate(buttons): + if position == len(buttons) - 1: + button.set_hexpand(True) + button.set_halign(Gtk.Align.END) + box.append(button) + return box + + +def _card( + text: str, + highlighted: frozenset[int] = frozenset(), + *, + highlight_class: str = "guessed", +) -> Gtk.FlowBox: + """Show one card the way wallets.md asks: uppercase, in four-character windows.""" + text = text.upper() + flow = Gtk.FlowBox(selection_mode=Gtk.SelectionMode.NONE, column_spacing=8, row_spacing=8) + flow.set_homogeneous(True) + flow.set_min_children_per_line(4) + flow.set_max_children_per_line(4) + flow.set_hexpand(True) + for start in range(0, len(text), reading.GROUP): + label = Gtk.Label(label=text[start : start + reading.GROUP], halign=Gtk.Align.CENTER) + label.add_css_class("card-group") + if start // reading.GROUP in highlighted: + label.add_css_class(highlight_class) + flow.append(label) + return flow + + +def _empty(*rows: Adw.EntryRow) -> None: + """Take a passphrase out of the widget that was holding it.""" + for row in rows: + row.set_text("") + + +def _blank(flow: Gtk.FlowBox) -> None: + """Take the card off the screen and out of the widget tree.""" + while (child := flow.get_first_child()) is not None: + flow.remove(child) + + +def _rows(title: str, values: Sequence[tuple[str, str]]) -> Adw.PreferencesGroup: + group = Adw.PreferencesGroup(title=title) + for label, value in values: + group.add(Adw.ActionRow(title=label, subtitle=value, subtitle_selectable=True, use_markup=False)) + return group + + +def _forget_when_gone(view: Adw.NavigationView, page: Adw.NavigationPage, clear: Callable[[], None]) -> None: + """Clear recovery text as soon as its page leaves the navigation stack.""" + handlers: list[int] = [] + + def gone(*_arguments: object) -> None: + stack = view.get_navigation_stack() + if any(stack.get_item(index) is page for index in range(stack.get_n_items())): + return + clear() + while handlers: + view.disconnect(handlers.pop()) + + handlers.append(view.connect("popped", gone)) + handlers.append(view.connect("replaced", gone)) + + +def _replace(view: Adw.NavigationView, page: Adw.NavigationPage) -> None: + """Rewrite the stack so Back leads home rather than back into a finished step.""" + view.replace([home(view), page]) + + +def _failure(view: Adw.NavigationView, error: object, advice: str = "") -> None: + """Stop, and where cards already exist, say plainly that they are still good.""" + buttons = [_button("Start again", lambda: view.replace([home(view)]))] + if advice: + buttons.append(_button("Restore my wallet", lambda: _again(view, _start_restore))) + buttons[-1].add_css_class("suggested-action") + page = _page( + "Stopped", + _column( + _title("That did not work", str(error), "dialog-error-symbolic"), + *((_note(advice, "success"),) if advice else ()), + ), + actions=_actions(*buttons), + can_pop=False, + ) + _replace(view, page) + + +def _working(view: Adw.NavigationView, title: str, message: str) -> Adw.NavigationPage: + """Show one operation in flight. It cannot be left, so its result cannot be lost. + + Leaving would drop the result of work that has already happened: an import + that reached Bitcoin Core would go unreported, and a ceremony left half way + would strand its cards. Every operation behind this page is bounded, by the + correction deadline or by `bitcoin-cli`'s own timeout. + """ + spinner = Adw.Spinner(halign=Gtk.Align.CENTER, width_request=32, height_request=32) + settings = Gtk.Settings.get_default() + spinner.set_visible(settings is None or bool(settings.get_property("gtk-enable-animations"))) + page = _page(title, _column(spinner, _title(message)), can_pop=False) + view.push(page) + return page + + +def _then[Result]( + view: Adw.NavigationView, + page: Adw.NavigationPage, + follow: Callable[[Result], Adw.NavigationPage | None], + advice: str = "", +) -> Callable[[Result | Exception], None]: + """Turn a worker result into the next page, the failure page, or a step of its own.""" + + def deliver(outcome: Result | Exception) -> None: + if isinstance(outcome, Exception): + _failure(view, outcome, advice) + return + following = follow(outcome) + if following is not None: + _replace(view, following) + + return deliver + + +def _describe(artifact: Artifact) -> str: + header = artifact.header + name = header.identifier.upper() + if isinstance(artifact, Secret): + whole = "the whole backup" if header.threshold == 0 else "the unsplit form of backup" + return f"This is {whole} {name}. It needs no other card." + return ( + f"It is card {header.index.upper()} of a backup called {name}. " + f"Any {header.threshold} cards from that backup recover the wallet." + ) + + +def _compare(expected: str, entered: str) -> ConfirmationResult: + """Report which four-character groups do not match, by the library's own rule.""" + observed, wanted = "".join(entered.split()).lower(), expected.lower() + span = (max(len(observed), len(wanted)) + reading.GROUP - 1) // reading.GROUP + mismatched = tuple( + group + 1 + for group in range(span) + if observed[group * reading.GROUP : (group + 1) * reading.GROUP] + != wanted[group * reading.GROUP : (group + 1) * reading.GROUP] + ) + return ConfirmationResult(not mismatched, mismatched) + + +def _guessed(observed: str, corrected: str) -> frozenset[int]: + """Mark the four-character windows a correction changed, so they can be compared.""" + changed: set[int] = set() + matcher = difflib.SequenceMatcher(None, observed.lower(), corrected.lower(), autojunk=False) + for tag, _left, _right, start, end in matcher.get_opcodes(): + if tag == "equal": + continue + changed.update(range(start // reading.GROUP, (end + reading.GROUP - 1) // reading.GROUP)) + if start == end: + changed.add(min(start // reading.GROUP, (len(corrected) - 1) // reading.GROUP)) + return frozenset(changed) + + +def _radio_group(group: Adw.PreferencesGroup, rows: Sequence[tuple[str, str]]) -> list[Gtk.CheckButton]: + """Build one radio row per choice. Rows carry Bitcoin Core's text, so none of it is markup.""" + first: Gtk.CheckButton | None = None + buttons: list[Gtk.CheckButton] = [] + for label, detail in rows: + choice = Gtk.CheckButton() + if first is None: + first = choice + choice.set_active(True) + else: + choice.set_group(first) + row = Adw.ActionRow(title=label, subtitle=detail, activatable_widget=choice, use_markup=False) + row.add_prefix(choice) + group.add(row) + buttons.append(choice) + return buttons + + +def _selected(buttons: Sequence[Gtk.CheckButton]) -> int: + """Return which row is chosen by position, so no wallet name can stand in for another.""" + return next(index for index, choice in enumerate(buttons) if choice.get_active()) + + +# --- Home ------------------------------------------------------------------ + + +def home(view: Adw.NavigationView) -> Adw.NavigationPage: + """The six things this program does, in plain words.""" + tasks = ( + ("Set up a new wallet", "Make recovery cards, then a Bitcoin Core wallet", _start_create), + ("Restore my wallet", "Use my cards to load a Bitcoin Core wallet", _start_restore), + ("Check a card", "Make sure a card is still readable and undamaged", _start_check), + ("Repair a damaged card", "Work out what a smudged or torn card should say", _start_repair), + ("Replace a lost card", "Make a fresh card for a set you still have enough of", _start_share), + ("Show my master seed", "Advanced. Displays the secret itself on screen.", _start_seed), + ) + arts = ("sun", "lock", "potion", "dragon", "codex", "bitcoin") + group = Adw.PreferencesGroup() + for (label, detail, start), art in zip(tasks, arts, strict=True): + row = Adw.ActionRow(title=label, subtitle=detail, activatable=True) + row.add_prefix(_book_art(art)) + row.add_suffix(Gtk.Image(icon_name="go-next-symbolic")) + row.connect("activated", lambda _row, begin=start: begin(view)) + group.add(row) + content = _column( + _title("What would you like to do?", "A codex32 backup is your Bitcoin wallet master seed."), + group, + _note("Everything happens on this computer. codex32 never connects to the internet."), + ) + return _page("codex32", content) + + +# --- Bitcoin Core preflight ------------------------------------------------ + + +def _connect( + view: Adw.NavigationView, + chain: str | None, + then: Callable[[BitcoinCore], Adw.NavigationPage], +) -> None: + """Discover Bitcoin Core before any entropy is drawn or any card is read.""" + page = _working(view, "Bitcoin Core", "Looking for Bitcoin Core on this computer…") + + def probe() -> BitcoinCore | tuple[str, ...]: + try: + return wallet_setup.connect(chain) + except wallet_setup.Offer as offer: + return offer.options + + def follow(found: BitcoinCore | tuple[str, ...]) -> Adw.NavigationPage: + if isinstance(found, tuple): + return _network_page(view, found, then) + return then(found) + + deliver = _then(view, page, follow) + work.run(view, page, probe, deliver) + + +def _network_page( + view: Adw.NavigationView, + options: Sequence[str], + then: Callable[[BitcoinCore], Adw.NavigationPage], +) -> Adw.NavigationPage: + group = Adw.PreferencesGroup(title="Bitcoin Core is running on more than one network") + buttons = _radio_group(group, [(label, "") for label in options]) + content = _column( + _title("Which network?", "Practise on signet. Use mainnet only for coins you cannot replace."), + group, + ) + action = _button( + "Continue", + lambda: _connect(view, options[_selected(buttons)], then), + style="suggested-action", + ) + return _page("Network", content, actions=_actions(action)) + + +# --- Writing and reading back one card ------------------------------------- + + +def _counted(letter: str, position: int, count: int | None) -> str: + """Name this card. Only the create flow may say how many cards there are.""" + return f"Card {position + 1} of {count}" if count is not None else f"Card {letter}" + + +def _write_page( + view: Adw.NavigationView, + *, + card: Artifact, + position: int, + count: int | None, + confirm: Callable[[str], ConfirmationResult], + after: Callable[[], None], + cancel: Callable[[Adw.NavigationPage], None], +) -> Adw.NavigationPage: + """Show one card, then take it off the screen before it is read back.""" + letter = card.header.index.upper() + name = card.header.identifier.upper() + shown = _card(card.text) + where = f"Copy this onto card {position + 1}" if count is not None else "Copy this onto a fresh card" + 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."), + shown, + _note(f"Label this card {letter}. The letter after {name} is the card's name."), + _note(NO_CAMERA, "warning"), + ) + page = _page( + _counted(letter, position, count), + content, + actions=_actions( + _button("Cancel", lambda: cancel(page)), + _button( + "I have written it down", + lambda: view.push(_read_back_page(view, card, position, count, confirm, after)), + style="suggested-action", + ), + ), + can_pop=False, + ) + _forget_when_gone(view, page, lambda: _blank(shown)) + return page + + +def _read_back_page( + view: Adw.NavigationView, + card: Artifact, + position: int, + count: int | None, + confirm: Callable[[str], ConfirmationResult], + after: Callable[[], None], +) -> Adw.NavigationPage: + """Read the card back from the paper, with the original off the screen.""" + field = Codex32Entry(length=len(card.text), mode="readback") + status = _note("") + comparison = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + accept = _button("Confirm card", lambda: None, style="suggested-action") + + def clear_comparison() -> None: + while (child := comparison.get_first_child()) is not None: + comparison.remove(child) + + def update(*_arguments: object) -> None: + clear_comparison() + state = field.reading() + accept.set_sensitive(state.complete) + _say(status, state.message, state.level) + + def check() -> None: + try: + state = field.reading() + result = confirm(state.text) + except CodexError as error: + _failure(view, error, CARDS_SAFE) + return + if not result.accepted: + groups = frozenset(group - 1 for group in result.mismatched_groups) + clear_comparison() + comparison.append(_card(state.text, groups, highlight_class="mismatch")) + plural = "s" if len(groups) != 1 else "" + message = f"Highlighted group{plural} {'do' if plural else 'does'} not match. Re-read the card." + _say(status, message, "error") + return + field.clear() + after() + + accept.connect("clicked", lambda _button: check()) + field.connect("activate", lambda _entry: check() if accept.get_sensitive() else None) + field.connect("changed", update) + content = _column( + _title("Now type it back from the card", _counted(card.header.index.upper(), position, count)), + _note( + "The original is no longer on screen. Read from the card you just wrote, so a slip of the " + "pen is caught now rather than years from now." + ), + field, + status, + comparison, + _note( + "Spaces and capitals do not matter. After a mismatch, only what you typed is shown; the " + "original stays hidden. Re-read the highlighted groups from your card and try again." + ), + ) + page = _page( + _counted(card.header.index.upper(), position, count), + content, + actions=_actions(_button("Show the card again", view.pop), accept), + ) + + def clear_page() -> None: + field.clear() + clear_comparison() + + _forget_when_gone(view, page, clear_page) + update() + return page + + +def _abandon(view: Adw.NavigationView, page: Adw.NavigationPage) -> None: + dialog = Adw.AlertDialog( + heading="Start over?", + body="Destroy every card you have already written from this backup. It will be abandoned.", + ) + dialog.add_response("keep", "Keep going") + dialog.add_response("stop", "Start over") + dialog.set_response_appearance("stop", Adw.ResponseAppearance.DESTRUCTIVE) + dialog.set_default_response("keep") + dialog.connect( + "response", + lambda _dialog, response: view.replace([home(view)]) if response == "stop" else None, + ) + dialog.present(page) + + +# --- Making a backup ------------------------------------------------------- + + +def _start_create(view: Adw.NavigationView) -> None: + _connect(view, None, lambda core: _layout_page(view, core)) + + +def _layout_page(view: Adw.NavigationView, core: BitcoinCore) -> Adw.NavigationPage: + """Choose how many cards the backup has, and how many of them recovery needs.""" + details = { + PRESETS[0][2]: "One card can be lost, burned or stolen and you still have your bitcoin. " + "One card on its own reveals nothing.", + PRESETS[1][2]: "Two cards can be lost. More places to hide, and more places to keep safe.", + PRESETS[2][2]: "Simplest to store. Anyone who finds that card can take everything, and " + "losing it loses everything.", + "Something else": "Choose the numbers yourself, and the size of the seed.", + } + group = Adw.PreferencesGroup() + buttons = _radio_group(group, [(label, details[label]) for label in details]) + custom = Adw.PreferencesGroup(title="Your own combination", visible=False) + needed = Adw.SpinRow( + title="Cards needed to recover", + adjustment=Gtk.Adjustment(lower=2, upper=9, step_increment=1, value=2), + ) + total = Adw.SpinRow( + title="Cards in total", + adjustment=Gtk.Adjustment(lower=2, upper=31, step_increment=1, value=3), + ) + size = Adw.ComboRow( + title="Seed size", + model=Gtk.StringList.new([label for _length, label in SEED_SIZES]), + ) + # Neither number may leave the other impossible. + needed.connect( + "notify::value", + lambda row, _spec: total.set_value(max(total.get_value(), row.get_value())), + ) + total.connect( + "notify::value", + lambda row, _spec: needed.set_value(min(needed.get_value(), row.get_value())), + ) + for row in (needed, total, size): + custom.add(row) + buttons[-1].connect("toggled", lambda choice: custom.set_visible(choice.get_active())) + + 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]) + 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]) + + content = _column( + _title( + "How many cards do you want?", + "Splitting your wallet across cards means losing one is not a disaster, and finding one " + "is not a jackpot for a thief.", + ), + group, + custom, + ) + return _page( + "New wallet", content, actions=_actions(_button("Continue", begin, style="suggested-action")) + ) + + +def _begin_cards( + view: Adw.NavigationView, core: BitcoinCore, threshold: int, count: int, byte_length: int +) -> None: + if threshold == 0: + page = _working(view, "New backup", "Drawing a fresh master seed…") + work.run( + view, + page, + lambda: generate_master_seed( + byte_length=byte_length, fingerprint=wallet_setup.fingerprint_provider(core) + ), + _then(view, page, lambda secret: _unshared_page(view, core, secret)), + ) + return + try: + ceremony = CreationCeremony.master_seed( + threshold=threshold, share_count=count, byte_length=byte_length + ) + except CodexError as error: + _failure(view, error) + return + _next_card(view, core, ceremony, 0, count) + + +def _unshared_page(view: Adw.NavigationView, core: BitcoinCore, secret: MasterSeed) -> Adw.NavigationPage: + return _write_page( + view, + card=secret, + position=0, + count=1, + confirm=lambda text: _compare(secret.text, text), + after=lambda: _wallets(view, core, secret, "now"), + cancel=lambda page: _abandon(view, page), + ) + + +def _next_card( + view: Adw.NavigationView, + core: BitcoinCore, + ceremony: CreationCeremony, + position: int, + count: int, +) -> None: + page = _working(view, f"Card {position + 1} of {count}", "Drawing this card from the operating system…") + + def follow(card: Share) -> Adw.NavigationPage: + return _write_page( + view, + card=card, + position=position, + count=count, + confirm=ceremony.confirm, + after=lambda: _card_confirmed(view, core, ceremony, position, count), + cancel=lambda shown: _abandon(view, shown), + ) + + work.run(view, page, ceremony.next_share, _then(view, page, follow)) + + +def _card_confirmed( + view: Adw.NavigationView, + core: BitcoinCore, + ceremony: CreationCeremony, + position: int, + count: int, +) -> None: + if position + 1 < count: + _next_card(view, core, ceremony, position + 1, count) + return + page = _working(view, "Backup", "Finishing the backup…") + + 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") + + deliver = _then(view, page, follow, CARDS_SAFE) + work.run(view, page, ceremony.finish, deliver) + + +# --- The Bitcoin Core wallet ----------------------------------------------- + + +def _wallets( + view: Adw.NavigationView, + core: BitcoinCore, + secret: MasterSeed, + timestamp: Timestamp, + *, + restoring: bool = False, +) -> None: + page = _working(view, "Bitcoin Core", "Asking Bitcoin Core which wallets are empty…") + work.run( + view, + page, + lambda: wallet_setup.eligible(core), + _then( + view, + page, + lambda found: _wallet_page(view, core, secret, found, timestamp, restoring), + CARDS_SAFE, + ), + ) + + +def _wallet_page( + view: Adw.NavigationView, + core: BitcoinCore, + secret: MasterSeed, + found: tuple[wallet_setup.Wallet, ...], + timestamp: Timestamp, + restoring: bool, +) -> Adw.NavigationPage: + """Name the wallet that will hold the keys. The library confirms that name again.""" + group = Adw.PreferencesGroup(title="Empty wallets Bitcoin Core has ready") + rows = [(item.name, "Empty, encrypted" if item.encrypted else "Empty, not encrypted") for item in found] + rows.append((CREATE_WALLET, "codex32 asks Bitcoin Core for a blank wallet, with a passphrase you choose")) + buttons = _radio_group(group, rows) + + def go() -> None: + # By position, so that a wallet named like the create row is still reachable. + index = _selected(buttons) + if index == len(found): + view.push(_new_wallet_page(view, core, secret, timestamp, restoring)) + return + chosen = found[index] + if chosen.locked: + view.push(_unlock_page(view, core, secret, chosen, timestamp, restoring)) + return + _import(view, core, secret, chosen.name, "", timestamp, restoring) + + content = _column( + _title( + "Which wallet should hold your keys?", + f"Bitcoin Core {wallet_setup.version_text(core)} is running on {wallet_setup.network(core)}.", + ), + group, + _note( + "Only empty wallets are listed, so no wallet you already use can be overwritten. You may also " + "create one in Bitcoin Core yourself and check again." + ), + *( + ( + _note( + "This restores account 0. If your wallet record shows a different account number, " + "restore with the ms32 wallet --account command instead.", + "warning", + ), + ) + if restoring + else () + ), + ) + return _page( + "Wallet", + content, + actions=_actions( + _button("Check again", lambda: _wallets(view, core, secret, timestamp)), + _button("Continue", go, style="suggested-action"), + ), + ) + + +def _new_wallet_page( + view: Adw.NavigationView, + core: BitcoinCore, + secret: MasterSeed, + timestamp: Timestamp, + restoring: bool = False, +) -> Adw.NavigationPage: + """Ask Bitcoin Core for one blank wallet, with a passphrase the operator chooses.""" + name = Adw.EntryRow(title="Wallet name", text=secret.header.identifier.upper()) + first = Adw.PasswordEntryRow(title="Wallet passphrase") + again = Adw.PasswordEntryRow(title="Repeat the passphrase") + group = Adw.PreferencesGroup() + for row in (name, first, again): + group.add(row) + status = _note("") + + def make(passphrase: str) -> None: + # Read the field here: the worker runs off the main loop and must not touch a widget. + chosen = name.get_text().strip() + page = _working(view, "Bitcoin Core", "Creating the wallet and writing your keys into it…") + + def job() -> Record: + wallet_setup.create(core, chosen, passphrase) + return _record(core, secret, chosen, timestamp, passphrase) + + work.run( + view, + page, + job, + _then(view, page, lambda record: _finished_page(view, record, restoring), CARDS_SAFE), + ) + + def go() -> None: + passphrase = first.get_text() + if passphrase != again.get_text(): + _say(status, "The two passphrases are not the same.", "error") + return + if passphrase: + make(passphrase) + return + dialog = Adw.AlertDialog( + heading="Create it without a passphrase?", + body="Anyone who can use this computer could then spend from this wallet.", + ) + dialog.add_response("back", "Go back") + dialog.add_response("plain", "Create without one") + dialog.set_response_appearance("plain", Adw.ResponseAppearance.DESTRUCTIVE) + dialog.set_default_response("back") + dialog.connect("response", lambda _dialog, answer: make("") if answer == "plain" else None) + dialog.present(view) + + content = _column( + _title("Create a wallet for these keys", "Bitcoin Core makes it; codex32 fills it in."), + group, + status, + _note( + "Your passphrase goes straight to Bitcoin Core on standard input and nowhere else. It is " + "never saved, never written to a file, and never shown in the list of running programs." + ), + _note( + "Forgetting this passphrase does not lose your bitcoin: your cards still recover the seed. " + "It protects the wallet on this computer.", + "success", + ), + ) + page = _page("New wallet", content, actions=_actions(_button("Create", go, style="suggested-action"))) + _forget_when_gone(view, page, lambda: _empty(first, again)) + return page + + +def _unlock_page( + view: Adw.NavigationView, + core: BitcoinCore, + secret: MasterSeed, + wallet: wallet_setup.Wallet, + timestamp: Timestamp, + restoring: bool = False, +) -> Adw.NavigationPage: + """Unlock one already encrypted wallet, or step aside and let Bitcoin Core do it.""" + field = Adw.PasswordEntryRow(title="Wallet passphrase") + group = Adw.PreferencesGroup() + group.add(field) + manual = Adw.ExpanderRow( + title="I would rather unlock it in Bitcoin Core myself", + subtitle="Then codex32 never sees the passphrase, exactly as the command line works.", + ) + manual.add_row( + Adw.ActionRow( + title="In Bitcoin Core, open Window ▸ Console", + subtitle=( + f"Select the wallet {wallet.name}, then type: " + f'walletpassphrase "YOUR PASSPHRASE" {wallet_setup.UNLOCK_SECONDS}' + ), + subtitle_selectable=True, + use_markup=False, + ) + ) + group.add(manual) + + def check() -> None: + page = _working(view, "Bitcoin Core", "Checking whether the wallet is unlocked…") + + def job() -> Record: + wallet_setup.require_unlocked(core, wallet.name) + return _record(core, secret, wallet.name, timestamp) + + work.run( + view, + page, + job, + _then(view, page, lambda record: _finished_page(view, record, restoring), CARDS_SAFE), + ) + + def go() -> None: + _import(view, core, secret, wallet.name, field.get_text(), timestamp, restoring) + + content = _column( + _title( + f"The wallet \u201c{wallet.name}\u201d is locked", + "Bitcoin Core needs its passphrase before your keys can be written into it.", + ), + group, + _note( + "Your passphrase goes straight to Bitcoin Core on standard input and nowhere else. It is " + "never saved, never written to a file, and never shown in the list of running programs. " + "codex32 locks the wallet again as soon as it has finished." + ), + ) + page = _page( + "Unlock", + content, + actions=_actions( + _button("I unlocked it myself", check), + _button("Unlock and finish", go, style="suggested-action"), + ), + ) + _forget_when_gone(view, page, lambda: _empty(field)) + return page + + +def _record( + core: BitcoinCore, secret: MasterSeed, name: str, timestamp: Timestamp, passphrase: str = "" +) -> Record: + final = wallet_setup.fill(core, secret, name, passphrase, timestamp=timestamp) + return Record( + secret.header.identifier.upper(), + final, + wallet_setup.version_text(core), + wallet_setup.fingerprint(core, secret), + 0, + ) + + +def _import( + view: Adw.NavigationView, + core: BitcoinCore, + secret: MasterSeed, + name: str, + passphrase: str, + timestamp: Timestamp, + 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), + _then(view, page, lambda record: _finished_page(view, record, restoring), CARDS_SAFE), + ) + + +def _finished_page(view: Adw.NavigationView, record: Record, restoring: bool = False) -> Adw.NavigationPage: + """Show the wallet-identity fields. + + A new wallet's are copied onto the wallet record. A restored wallet's are the + only proof the cards just entered belong to that wallet, so they are checked + against the record instead, and no creation date is offered: the one this + wallet was born with is on the record already, and today's would replace it. + """ + heading, asked, closing = _RESTORED if restoring else _CREATED + dated = () if restoring else (("Approximate creation date", time.strftime("%Y-%m-%d")),) + content = _column( + _title(heading, asked, DONE_ICON), + _rows( + "Wallet identity", + ( + ("Backup identifier", record.identifier), + ("Bitcoin Core wallet name", record.wallet), + ("Bitcoin Core version", record.version), + *dated, + ("Master fingerprint", record.fingerprint), + ("Derivation standards", "BIP 44, 49, 84 and 86"), + ("Account number", str(record.account)), + ), + ), + _note(closing, "warning" if restoring else ""), + ) + return _page( + "Finished", + content, + actions=_actions(_button("Done", lambda: view.replace([home(view)]), style="suggested-action")), + can_pop=False, + ) + + +# --- Entering cards -------------------------------------------------------- + + +def _outstanding(gathered: tuple[Artifact, ...], basis: bool, wanted: int | None) -> int: + if wanted is not None: + return max(wanted - len(gathered), 0) + first = gathered[0] + if not basis and isinstance(first, Secret): + return 0 + return max(first.header.threshold - len(gathered), 0) + + +def _incompatible(artifact: Artifact | None, accepted: tuple[Artifact, ...], basis: bool) -> str: + if artifact is None: + return "" + if basis and artifact.header.threshold == 0: + return "This backup was never split into cards, so there is no card to replace." + if not accepted: + return "" + first = accepted[0] + if artifact.header.identifier != first.header.identifier: + return ( + f"This card belongs to backup {artifact.header.identifier.upper()}, " + f"not {first.header.identifier.upper()}." + ) + if artifact.header.threshold != first.header.threshold: + return "This card comes from a different split of that backup." + if len(artifact.text) != len(first.text): + return "This card is a different length from the first one." + if not basis and isinstance(artifact, Secret): + return "This is the whole backup rather than one of its cards." + return "" + + +def _collect( + view: Adw.NavigationView, + *, + title: str, + heading: str, + body: str, + accepted: tuple[Artifact, ...] = (), + basis: bool = False, + wanted: int | None = None, + reserved: tuple[str, ...] = (), + repaired: bool = False, + correcting: bool = False, + allow_header_erasures: bool = True, + then: Callable[[tuple[Artifact, ...], bool], None], +) -> Adw.NavigationPage: + """Take one card, and keep taking them until the backup has enough.""" + first = accepted[0] if accepted else None + length = len(first.text) if first is not None else None + blocked = tuple(dict.fromkeys([*(item.header.index for item in accepted), *reserved])) + # Correction accepts ordinary indices only; S is refused by the field instead. + excluded = tuple(index for index in blocked if index != "s") + expected_header = (first.header.threshold, first.header.identifier) if first is not None else None + mode: EntryMode = "correct" if correcting else "import" + field = Codex32Entry( + accepted=blocked, + length=length, + mode=mode, + expected_header=expected_header, + allow_header_erasures=allow_header_erasures, + ) + if first is not None: + field.prefill(f"{reading.PREFIX}{first.header.threshold}{first.header.identifier}") + status = _note("") + fix: Gtk.Button + go = _button("Continue", lambda: proceed(), style="suggested-action") + + def refuse(artifact: Artifact | None) -> str: + """Say why this card cannot join the ones already entered, if it cannot.""" + if artifact is not None and artifact.header.index in blocked: + letter = artifact.header.index.upper() + return f"That would be card {letter}, which cannot be used here." + return _incompatible(artifact, accepted, basis) + + def update(*_arguments: object) -> None: + state = field.reading() + problem = refuse(state.artifact) + go.set_sensitive(state.artifact is not None and not problem) + fix.set_sensitive(state.repairable and state.artifact is None) + _say(status, problem or state.message, "error" if problem else state.level) + + def accept(artifact: Artifact, guessed: bool = False) -> None: + # Checked again here: a repair arrives from its own screen, not from the field. + problem = refuse(artifact) + if problem: + if work.showing(view, page): + view.pop_to_page(page) + _say(status, problem, "error") + return + gathered = (*accepted, artifact) + field.clear() + if _outstanding(gathered, basis, wanted): + _replace( + view, + _collect( + view, + title=title, + heading=heading, + body=body, + accepted=gathered, + basis=basis, + wanted=wanted, + reserved=reserved, + repaired=repaired or guessed, + correcting=correcting, + allow_header_erasures=allow_header_erasures, + then=then, + ), + ) + else: + then(gathered, repaired or guessed) + + def proceed() -> None: + artifact = field.reading().artifact + if artifact is not None: + accept(artifact) + + def suggest() -> None: + observed = field.reading().text + spinner = _working(view, "Repair", "Working out what the card should say…") + + def deliver(result: object) -> None: + view.pop() + if not isinstance(result, CorrectionCandidate): + _say(status, str(result), "error") + return + + def following() -> Adw.NavigationPage: + return _repair_page(view, result, observed, lambda card: accept(card, True), page, correcting) + + gated = result.low_checksum_discrimination + view.push(_guess_gate_page(view, following) if gated else following()) + + work.run(view, spinner, lambda: reading.repair(observed, length, excluded), deliver) + + fix = _button("Suggest a repair", suggest) + + def activate(_entry: Gtk.Entry) -> None: + if go.get_sensitive(): + proceed() + elif fix.get_sensitive(): + suggest() + + field.connect("activate", activate) + field.connect("changed", update) + progress = [] + if first is not None: + letters = ", ".join(item.header.index.upper() for item in accepted) + progress.append( + _note( + f"Card{'s' if len(accepted) > 1 else ''} {letters} accepted. Backup " + f"{first.header.identifier.upper()} needs {first.header.threshold} cards in all.", + "success", + ) + ) + content = _column(_title(heading, body), *progress, field, status) + page = _page(title, content, actions=_actions(fix, go)) + _forget_when_gone(view, page, field.clear) + update() + return page + + +def _guess_gate_page( + view: Adw.NavigationView, following: Callable[[], Adw.NavigationPage] +) -> Adw.NavigationPage: + """Invariant 5: disclose nothing about the candidate until literal YES is typed. + + Thirteen or fifteen unreadable characters at the end of a card are the whole + checksum, depending on card length, so this screen is also what someone + filling in the last squares of a hand-made backup reaches. It has to speak to + both of them: a person recovering a damaged card, who may be shown something + simply wrong, and a person completing new data, whose earlier mistakes this + would set in stone. + """ + field = Gtk.Entry(placeholder_text="YES") + show = _button("Show the guess", lambda: view.push(following()), style="destructive-action") + show.set_sensitive(False) + field.connect("changed", lambda _entry: show.set_sensitive(field.get_text().strip() == "YES")) + content = _column( + _title("This repair would be a guess"), + _note( + "Too little checksum remains to prove a repair. When completing new hand-written data, " + "check every character first: completion locks earlier transcription errors in. When " + "recovering a damaged card, a valid-looking guess may still be wrong and you may need " + "to try likely readings. Never erase or replace a card's ending just to make it validate.", + "warning", + ), + _note("If the funds matter, stop and get help. Type YES in capitals to continue anyway."), + field, + ) + return _page("Warning", content, actions=_actions(_button("Cancel", view.pop), show)) + + +def _repair_page( + view: Adw.NavigationView, + candidate: CorrectionCandidate, + observed: str, + accept: Accept, + back: Adw.NavigationPage, + highlight_changes: bool, +) -> Adw.NavigationPage: + corrected = candidate.artifact.text + changed = _guessed(observed, corrected) if highlight_changes else frozenset() + shown = _card(corrected, changed) + comparison = "Highlighted groups show changes, not proven error locations. " if highlight_changes else "" + content = _column( + _title("One possible repair", "It might not be the only one."), + shown, + _note( + f"Hold your card next to the screen and compare the entire string, character by character. " + f"{comparison}Accept it only if the entire string exactly matches your card." + ), + ) + page = _page( + "Repair", + content, + actions=_actions( + _button("It does not match my card", lambda: view.pop_to_page(back)), + _button("It matches my card", lambda: accept(candidate.artifact), style="suggested-action"), + ), + ) + _forget_when_gone(view, page, lambda: _blank(shown)) + return page + + +# --- The remaining tasks --------------------------------------------------- + + +def _again(view: Adw.NavigationView, start: Callable[[Adw.NavigationView], None]) -> None: + view.replace([home(view)]) + start(view) + + +def _intact_page(view: Adw.NavigationView, artifact: Artifact, repaired: bool = False) -> Adw.NavigationPage: + """Report one card. A card that was guessed at is never called intact.""" + shown = _card(artifact.text) + content = _column( + _title( + "This is what the card should say" if repaired else "This card is intact", + _describe(artifact), + "" if repaired else DONE_ICON, + ), + shown, + _note(GUESSWORK, "warning") if repaired else _note(NOT_PROOF), + ) + page = _page( + "Card", + content, + actions=_actions( + _button("Check another card", lambda: _again(view, _start_check)), + _button("Done", lambda: view.replace([home(view)]), style="suggested-action"), + ), + can_pop=False, + ) + _forget_when_gone(view, page, lambda: _blank(shown)) + return page + + +def _start_check(view: Adw.NavigationView) -> None: + view.push( + _collect( + view, + title="Check a card", + heading="Type what your card says", + body="Nothing is saved and nothing leaves this computer.", + wanted=1, + allow_header_erasures=False, + then=lambda found, guessed: _replace(view, _intact_page(view, found[0], guessed)), + ) + ) + + +def _start_repair(view: Adw.NavigationView) -> None: + view.push( + _collect( + view, + title="Repair a damaged card", + heading="What you can read on the card", + body="Type ? for anything you cannot make out. Nothing is saved and nothing leaves this computer.", + wanted=1, + correcting=True, + then=lambda found, guessed: _replace(view, _intact_page(view, found[0], guessed)), + ) + ) + + +def _recover(view: Adw.NavigationView, found: tuple[Artifact, ...], then: Callable[[Secret], None]) -> None: + if len(found) == 1 and isinstance(found[0], Secret): + then(found[0]) + return + shares = [item for item in found if isinstance(item, Share)] + if len(shares) != len(found): + _failure(view, "Recovery needs ordinary cards, not the whole backup.") + return + try: + then(recover_secret(shares)) + except CodexError as error: + _failure(view, error) + + +def _seed_page(view: Adw.NavigationView, secret: Secret) -> Adw.NavigationPage: + shown = _card(secret.text) + frame = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + frame.add_css_class("card-frame") + frame.append(shown) + content = _column( + _note( + "Anyone who reads this line can take every coin in your wallet. Check that nobody is behind " + "you and no camera is pointed at the screen.", + "error", + ), + _title("Your whole backup in one line", _describe(secret)), + frame, + _note( + "Nothing was saved, copied or sent anywhere, and your cards and wallet are unchanged. If you " + "write this down, guard it the way you would guard all your cards at once." + ), + ) + page = _page( + "Master seed", + content, + actions=_actions( + _button("Hide this and go back", lambda: view.replace([home(view)]), style="suggested-action") + ), + can_pop=False, + ) + _forget_when_gone(view, page, lambda: _blank(shown)) + return page + + +def _start_seed(view: Adw.NavigationView) -> None: + view.push( + _collect( + view, + title="Show my master seed", + heading="Enter your cards", + body="This never opens a Bitcoin Core wallet and never changes your backup.", + then=lambda found, _guessed: _recover( + view, found, lambda secret: _replace(view, _seed_page(view, secret)) + ), + ) + ) + + +def _letter_page(view: Adw.NavigationView) -> Adw.NavigationPage: + flow = Gtk.FlowBox( + selection_mode=Gtk.SelectionMode.SINGLE, + column_spacing=8, + row_spacing=8, + max_children_per_line=11, + homogeneous=True, + halign=Gtk.Align.CENTER, + ) + for letter in sorted(ORDINARY_INDICES): + label = Gtk.Label(label=letter.upper()) + label.add_css_class("card-group") + flow.append(label) + flow.select_child(flow.get_child_at_index(0)) + + def go() -> None: + selected = flow.get_selected_children() + if not selected: + return + child = selected[0].get_child() + letter = child.get_label().lower() + view.push( + _collect( + view, + title="Replace a lost card", + heading="Now enter the cards you still have", + body="codex32 works out the new card from them. Nothing else changes.", + basis=True, + reserved=(letter,), + then=lambda found, _guessed: _derive(view, found, letter), + ) + ) + + content = _column( + _title( + "Give the new card a letter", + "Every card in a backup carries its own letter. Choose one that no card already uses.", + ), + flow, + _note( + "codex32 cannot tell which letters your other cards already use — only your own records can. " + "Two cards sharing a letter cannot be used together. S is reserved for an unsplit backup." + ), + ) + return _page("New card", content, actions=_actions(_button("Continue", go, style="suggested-action"))) + + +def _start_share(view: Adw.NavigationView) -> None: + view.push(_letter_page(view)) + + +def _derive(view: Adw.NavigationView, found: tuple[Artifact, ...], letter: str) -> None: + try: + share = derive_share(list(found), letter) + except CodexError as error: + _failure(view, error) + return + _replace( + view, + _write_page( + view, + card=share, + position=0, + count=None, + confirm=lambda text: _compare(share.text, text), + after=lambda: _replace(view, _share_done_page(view, share)), + cancel=lambda _page: view.replace([home(view)]), + ), + ) + + +def _share_done_page(view: Adw.NavigationView, share: Share) -> Adw.NavigationPage: + letter = share.header.index.upper() + content = _column( + _title( + f"Card {letter} is written and confirmed", + "It works with the cards you already have, exactly as the card it replaces did.", + DONE_ICON, + ), + _rows( + "The new card", + ( + ("New card letter", letter), + ("Backup identifier", share.header.identifier.upper()), + ("Cards needed to recover", str(share.header.threshold)), + ), + ), + _note( + f"Store card {letter} somewhere safe before you destroy the card it replaces. Until then, " + "treat both as live.", + "warning", + ), + _note( + "Your wallet is untouched. No Bitcoin Core wallet was opened and your master seed has not " + "changed — this only added another way to reach it." + ), + ) + return _page( + "New card", + content, + actions=_actions( + _button("Make another card", lambda: _again(view, _start_share)), + _button("Done", lambda: view.replace([home(view)]), style="suggested-action"), + ), + can_pop=False, + ) + + +def _restore(view: Adw.NavigationView, core: BitcoinCore, secret: Secret) -> None: + 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) + + +def _start_restore(view: Adw.NavigationView) -> None: + _connect( + view, + None, + lambda core: _collect( + view, + title="Restore my wallet", + heading="Enter your cards", + body="Type what each card says. Nothing is saved and nothing leaves this computer.", + then=lambda found, _guessed: _recover(view, found, lambda secret: _restore(view, core, secret)), + ), + ) diff --git a/src/codex32_gui/reading.py b/src/codex32_gui/reading.py new file mode 100644 index 0000000..6a8503c --- /dev/null +++ b/src/codex32_gui/reading.py @@ -0,0 +1,177 @@ +"""What a field of codex32 text means. No toolkit, no widgets, no state.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from codex32 import ( + CorrectionCandidate, + CorrectionContext, + Header, + Profile, + Secret, + Share, + correct, + parse_codex32, +) +from codex32.bech32 import CHARSET +from codex32.correction import _best +from codex32.errors import CodexError, InvalidShareIndex +from codex32.profiles.ms32 import TEXT_LENGTHS + +PREFIX = "MS1" +ALLOWED = frozenset(CHARSET.upper() + "?") +HEADER_LENGTH = 6 +GROUP = 4 +SLACK = 8 +CORRECTION_LENGTH_DELTAS = frozenset((0, 1, 2, 3, 4, 8)) +LOOKALIKE = {"B": "8", "I": "J or L", "O": "0, a zero", "1": "L"} +_ASCII_UPPER = str.maketrans("abcdefghijklmnopqrstuvwxyz", "ABCDEFGHIJKLMNOPQRSTUVWXYZ") + + +@dataclass(frozen=True, slots=True) +class Reading: + """What the field currently holds, and what may be done with it.""" + + text: str + artifact: Share | Secret | None + message: str + level: str + expected: int + unreadable: int + repairable: bool + + @property + def complete(self) -> bool: + return len(self.text) == self.expected + + +def normalize(raw: str) -> str: + """Return the canonical compact text for whatever was typed or pasted.""" + typed = "".join(raw.split()).upper() + keep = max(size for size in range(len(PREFIX) + 1) if typed[:size] == PREFIX[:size]) + return PREFIX + "".join(character for character in typed[keep:] if character in ALLOWED) + + +def normalize_readback(raw: str) -> str: + """Compact a transcription without supplying or deleting any character.""" + return "".join(raw.split()).translate(_ASCII_UPPER) + + +def lookalike_fault(raw: str) -> str: + """Name a character no card can contain, rather than deleting it in silence. + + Every other character outside the alphabet is punctuation or a stray key, and + dropping it quietly is right. These four are not: bech32 leaves out B, I, O + and 1 precisely because handwriting confuses them with 8, J, L and 0, so they + are exactly what someone misreads from their own card. Swallowing them would + turn the read-back, whose whole purpose is to catch a transcription error, + into the step that hides one. + """ + typed = "".join(raw.split()).upper() + keep = max(size for size in range(len(PREFIX) + 1) if typed[:size] == PREFIX[:size]) + found = sorted({character for character in typed[keep:] if character in LOOKALIKE}) + if not found: + return "" + named = found[0] if len(found) == 1 else ", ".join(found[:-1]) + " or " + found[-1] + hints = ", ".join(f"{character} is probably {LOOKALIKE[character]}" for character in found) + return f"A card never contains {named}. Look at the card again: {hints}." + + +def grouped(text: str) -> str: + """Return the text in four-character windows.""" + return " ".join(text[start : start + GROUP] for start in range(0, len(text), GROUP)) + + +def header_fault( + text: str, + accepted: tuple[str, ...] = (), + expected_header: tuple[int, str] | None = None, +) -> str: + """Report a header that cannot belong to any card, before more is typed.""" + body = text[len(PREFIX) :] + if not body or body[0] == "?": + return "" + if body[0] not in "023456789": + return "The character after MS1 is how many cards recovery needs: 0, or 2 through 9." + if expected_header is not None: + if int(body[0]) != expected_header[0]: + return "This card comes from a different split of that backup." + found, expected = body[1:5], expected_header[1].upper() + if any(character != "?" and character != expected[index] for index, character in enumerate(found)): + return f"This card does not have backup identifier {expected}." + if len(body) < HEADER_LENGTH or "?" in body[:HEADER_LENGTH]: + return "" + try: + Header(int(body[0]), body[1:5], body[5]) + except InvalidShareIndex: + return ( + "A backup that was never split is card S. Change the last letter to S, or change the " + "first character to how many cards recovery should need." + ) + except CodexError as error: + return str(error) + if body[5].lower() in accepted: + return f"Card {body[5].upper()} has already been entered. This must be a different card." + return "" + + +def expected_length(count: int, length: int | None) -> int: + """Return the backup length being typed towards.""" + if length is not None: + return length + return next((valid for valid in TEXT_LENGTHS if valid >= count), TEXT_LENGTHS[-1]) + + +def read(text: str, *, accepted: tuple[str, ...] = (), length: int | None = None) -> Reading: + """Describe one field value without ever accepting it on the operator's behalf.""" + unreadable = text.count("?") + expected = expected_length(len(text), length) + targets = (length,) if length is not None else TEXT_LENGTHS + repairable = any(abs(target - len(text)) in CORRECTION_LENGTH_DELTAS for target in targets) + if fault := header_fault(text, accepted): + return Reading(text, None, fault, "error", expected, unreadable, repairable) + if len(text) == len(PREFIX): + return Reading(text, None, "Start typing what the card says.", "", expected, unreadable, repairable) + if len(text) != expected: + counted = f"{len(text)} of {expected} characters" + message = f"{counted}, {unreadable} unreadable" if unreadable else counted + return Reading(text, None, message, "", expected, unreadable, repairable) + if unreadable: + plural = "s" if unreadable > 1 else "" + message = f"{unreadable} character{plural} unreadable." + return Reading(text, None, message, "warning", expected, unreadable, repairable) + try: + artifact = parse_codex32(text) + except CodexError: + message = "Every character is there, but they do not check out together." + return Reading(text, None, message, "error", expected, unreadable, repairable) + return Reading(artifact.text, artifact, "Every character checks out.", "success", expected, 0, False) + + +def readback(raw: str, length: int) -> Reading: + """Describe an independently re-entered card without interpreting or repairing it.""" + text = normalize_readback(raw) + unreadable = text.count("?") + fault = lookalike_fault(text) + level = "error" if fault else "" + message = fault or f"{len(text)} of {length} characters" + return Reading(text, None, message, level, length, unreadable, False) + + +def repair(text: str, length: int | None, excluded: tuple[str, ...] = ()) -> CorrectionCandidate | str: + """Ask the library for one repair, and offer nothing at all when it is unsure. + + `_best` is the command line's own tie-breaker, minus its Bitcoin Core + fingerprint hint, which would make repairing a card need a running node. + Anything still tied afterwards is reported as ambiguous rather than shown. + """ + try: + found = _best(correct(CorrectionContext(Profile.MS, length, PREFIX, excluded), text)) + except CodexError as error: + return str(error) + if not found: + return "No repair fits this card. Compare what you typed with the paper again." + if len(found) > 1: + return "More than one repair is possible, so none is shown. Check the card again." + return found[0] diff --git a/src/codex32_gui/style.py b/src/codex32_gui/style.py new file mode 100644 index 0000000..5ea3d7c --- /dev/null +++ b/src/codex32_gui/style.py @@ -0,0 +1,45 @@ +"""The whole stylesheet. + +Only libadwaita's named colours are used, so dark mode and the operator's accent +colour work without a second stylesheet, and only the system monospace font is +named, so no font file is shipped or downloaded. +""" + +CSS = """ +.card-entry { + font-family: monospace; + font-size: 1.1rem; + letter-spacing: 0.06em; +} +.card-group { + font-family: monospace; + font-size: 1.35rem; + font-weight: bold; + padding: 4px 9px; + border-radius: 6px; + background-color: alpha(currentColor, 0.09); +} +.card-group.guessed { + background-color: alpha(@warning_color, 0.35); +} +.card-group.mismatch { + background-color: alpha(@error_color, 0.35); +} +.card-frame { + border: 2px solid @error_color; + border-radius: 12px; + padding: 14px; +} +.reading { + font-size: 0.9rem; +} +.reading.error { + color: @error_color; +} +.reading.warning { + color: @warning_color; +} +.reading.success { + color: @success_color; +} +""" diff --git a/src/codex32_gui/wallet_setup.py b/src/codex32_gui/wallet_setup.py new file mode 100644 index 0000000..ccdef0c --- /dev/null +++ b/src/codex32_gui/wallet_setup.py @@ -0,0 +1,298 @@ +"""Every Bitcoin Core interaction this program performs, including the passphrase. + +`docs/security/invariants.md` invariant 9 states that codex32 has no passphrase +channel, and the library still has none: it tells the operator to unlock the +wallet in Bitcoin-Qt. The graphical program declares one deliberate exception, +confined to this file, because a person who has just written three cards by hand +should not have to open a second application and type a console command. + +The passphrase reaches `bitcoin-cli` through `-stdinwalletpassphrase` and +`-stdin`, never through a command argument, so it is absent from `/proc` and +`ps`. It is never stored, never logged, and never written to disk. Import and +verification remain the library's `BitcoinCore.initialize`, and `fill` holds a +`finally`-protected obligation to lock again any wallet this file unlocked. +""" + +from __future__ import annotations + +import codecs +import json +import locale +import re +from collections.abc import Callable +from dataclasses import dataclass +from typing import Literal + +from codex32 import MasterSeed +from codex32._bitcoin_core import _CHAINS, BitcoinCore, BitcoinCoreError + +__all__ = [ + "UNLOCK_SECONDS", + "BitcoinCore", + "BitcoinCoreError", + "Offer", + "Wallet", + "connect", + "create", + "eligible", + "fill", + "fingerprint", + "fingerprint_provider", + "initialize", + "network", + "relock", + "require_unlocked", + "unlock", + "version_text", +] + +UNLOCK_SECONDS = 180 +_WAITING = "press Ctrl-C to stop." +_QUOTED = re.compile(r'"(?:[^"\\]|\\.)*"') +_REFUSED = "Bitcoin Core did not accept that passphrase." +_STILL_OPEN = "Confirm in Bitcoin Core that the wallet is locked before you leave this computer." +_UNSENDABLE = "This computer could not hand that text to Bitcoin Core. Use unaccented characters." + + +class Offer(BitcoinCoreError): + """The library asked the operator to choose, and no choice has been made yet.""" + + def __init__(self, options: tuple[str, ...]) -> None: + super().__init__("Bitcoin Core no longer offers that choice. Look at the list again.") + self.options = options + + +def _name(text: str) -> str | None: + try: + decoded = json.loads(text) + except json.JSONDecodeError: + return None + return decoded if isinstance(decoded, str) else None + + +class _Answer: + """Answer one library selection prompt with the choice the operator already made. + + The library prompts in free text. Nothing here guesses. A wallet is accepted + only when the prompt names it exactly, and any prompt this cannot answer from + the operator's choice raises `Offer` so that the choice is made on screen. + """ + + def __init__(self, chosen: str | None, *, quoted: bool) -> None: + self._chosen, self._quoted = chosen, quoted + self._listed: list[str] = [] + self._numbered = False + + def tell(self, message: str) -> None: + if message.rstrip().endswith(_WAITING): + raise BitcoinCoreError( + "Bitcoin Core is waiting for something to be done in its own window. " + "Check that the wallet is still empty and unlocked, then try again." + ) + number, _, rest = message.strip().partition(". ") + if not number.isdecimal() or not rest: + return + decoded = _name(rest) if _QUOTED.fullmatch(rest) else None + if self._quoted == (decoded is not None): + self._listed.append(rest if decoded is None else decoded) + + def ask(self, prompt: str) -> str: + quoted = _QUOTED.search(prompt) + if quoted is not None: + return "y" if _name(quoted.group()) == self._chosen else "n" + if not self._numbered and self._chosen is not None and self._chosen in self._listed: + self._numbered = True + return str(self._listed.index(self._chosen) + 1) + raise Offer(tuple(dict.fromkeys(self._listed))) + + +@dataclass(frozen=True, slots=True) +class Wallet: + """One Bitcoin Core wallet that codex32 is willing to fill.""" + + name: str + encrypted: bool + locked: bool + + +def connect(chain: str | None = None) -> BitcoinCore: + """Discover local Bitcoin Core before any entropy or recovery input is taken. + + The library only asks which chain to use while more than one answers, so a + chain that stops answering between the two probes would be replaced silently + by whichever one is left. The chain that was chosen is therefore confirmed + here, and a different one is offered back rather than used. + """ + answer = _Answer(chain, quoted=False) + core = BitcoinCore.connect(answer.ask, answer.tell) + if chain is not None and network(core) != chain: + raise Offer((network(core),)) + return core + + +def network(core: BitcoinCore) -> str: + """Return the chain this connection selected, named the way the operator chose it.""" + return dict(_CHAINS).get(core.chain, core.chain) + + +def fingerprint(core: BitcoinCore, secret: MasterSeed) -> str: + """Return the BIP32 master fingerprint, derived by Bitcoin Core out of process.""" + return core.fingerprint(secret).hex() + + +def fingerprint_provider(core: BitcoinCore) -> Callable[[bytes], bytes]: + """Hand the library the same out-of-process derivation for a raw seed.""" + return core.fingerprint_seed + + +def version_text(core: BitcoinCore) -> str: + """Return the running Bitcoin Core version the way its own about box does.""" + return f"{core.version // 10000}.{core.version // 100 % 100}.{core.version % 100}" + + +def eligible(core: BitcoinCore) -> tuple[Wallet, ...]: + """List the empty descriptor wallets Bitcoin Core would accept an import into.""" + found = [] + for name in sorted(core._names()): + state = core._target(name) + if state is not None: + found.append(Wallet(name, state[0], state[1])) + return tuple(found) + + +def _transferable(text: str, subject: str) -> None: + """Refuse text this computer would hand Bitcoin Core as something else. + + `bitcoin-cli` is given standard input in the locale's encoding, while + Bitcoin-Qt sends UTF-8. Where those differ, a passphrase set or checked here + would not be the one Bitcoin Core's own window sets or checks. + """ + if not text.isascii() and codecs.lookup(locale.getencoding()).name != "utf-8": + raise BitcoinCoreError( + f"This computer's text is not stored as UTF-8, so Bitcoin Core would receive a different " + f"{subject} than the one you typed. Use unaccented letters, digits and punctuation." + ) + + +def _wallet_name(name: str) -> str: + """Accept only names Bitcoin Core can carry on one standard-input line.""" + if not name or name != name.strip() or not name.isprintable(): + raise BitcoinCoreError("A wallet name must be printable text without leading or trailing spaces.") + if name in (".", "..") or "/" in name or "\\" in name: + raise BitcoinCoreError("A wallet name cannot contain a slash, and cannot be . or .. on its own.") + _transferable(name, "wallet name") + return name + + +def _passphrase(passphrase: str) -> str: + """Accept only passphrases that survive the one-argument-per-line channel.""" + if not passphrase or "\n" in passphrase or "\r" in passphrase: + raise BitcoinCoreError("A wallet passphrase cannot be empty or contain a line break.") + _transferable(passphrase, "passphrase") + return passphrase + + +def create(core: BitcoinCore, name: str, passphrase: str) -> None: + """Create one blank descriptor wallet with private keys enabled, and nothing else.""" + arguments = [f"wallet_name={_wallet_name(name)}", "disable_private_keys=false", "blank=true"] + if passphrase: + arguments.append(f"passphrase={_passphrase(passphrase)}") + try: + core._rpc("-named", "createwallet", stdin="\n".join(arguments) + "\n") + except UnicodeEncodeError: + raise BitcoinCoreError(_UNSENDABLE) from None + except BitcoinCoreError as error: + raise BitcoinCoreError( + "Bitcoin Core would not create a wallet with that name. A wallet of that name may exist already." + ) from error + if core._target(name) is None: + raise BitcoinCoreError("Bitcoin Core did not create an empty wallet that codex32 can fill.") + + +def relock(core: BitcoinCore, name: str) -> None: + """Lock a wallet this program unlocked, and make Bitcoin Core confirm it is locked.""" + try: + core._rpc("walletlock", wallet=name) + state = core._rpc("getwalletinfo", wallet=name) + except BitcoinCoreError as error: + raise BitcoinCoreError(_STILL_OPEN) from error + if not isinstance(state, dict) or state.get("unlocked_until") != 0: + raise BitcoinCoreError(_STILL_OPEN) + + +def unlock(core: BitcoinCore, name: str, passphrase: str) -> None: + """Unlock one wallet with a passphrase that only ever travels on standard input. + + Once Bitcoin Core has accepted the passphrase the wallet is open, so every + way out of the check that follows locks it again first. + """ + line = _passphrase(passphrase) + "\n" + try: + core._rpc( + "-stdinwalletpassphrase", + "walletpassphrase", + str(UNLOCK_SECONDS), + wallet=name, + stdin=line, + ) + except UnicodeEncodeError: + raise BitcoinCoreError(_UNSENDABLE) from None + except BitcoinCoreError as error: + raise BitcoinCoreError(_REFUSED) from error + open_now = False + try: + state = core._target(name) + open_now = state is not None and not state[1] + finally: + if not open_now: + relock(core, name) + if not open_now: + raise BitcoinCoreError( + "Bitcoin Core took that passphrase, but the wallet is no longer an empty one codex32 can " + "fill. It has been locked again. Look at the list of empty wallets afresh." + ) + + +def require_unlocked(core: BitcoinCore, name: str) -> None: + """Confirm the operator unlocked the wallet in Bitcoin Core themselves.""" + state = [item for item in eligible(core) if item.name == name] + if not state or state[0].locked: + raise BitcoinCoreError("That wallet is still locked in Bitcoin Core.") + + +def initialize( + core: BitcoinCore, + secret: MasterSeed, + name: str, + *, + account: int = 0, + timestamp: int | Literal["now"] = "now", +) -> str: + """Hand the library the wallet the operator named, and let it do the import.""" + answer = _Answer(name, quoted=True) + return core.initialize(secret, answer.ask, answer.tell, account=account, timestamp=timestamp) + + +def fill( + core: BitcoinCore, + secret: MasterSeed, + name: str, + passphrase: str, + *, + account: int = 0, + timestamp: int | Literal["now"] = "now", +) -> str: + """Unlock, import, and lock again, whatever happens in between. + + `BitcoinCore.initialize` relocks from its own `finally`, but it arms that + obligation only after it has chosen the wallet, so a refusal raised before + then would leave a wallet this program unlocked open. The obligation here + covers the whole sequence; locking an already locked wallet is harmless. + """ + if not passphrase: + return initialize(core, secret, name, account=account, timestamp=timestamp) + unlock(core, name, passphrase) + try: + return initialize(core, secret, name, account=account, timestamp=timestamp) + finally: + relock(core, name) diff --git a/src/codex32_gui/work.py b/src/codex32_gui/work.py new file mode 100644 index 0000000..3bcfa94 --- /dev/null +++ b/src/codex32_gui/work.py @@ -0,0 +1,84 @@ +"""One background operation at a time, and results that never reach a page that is gone.""" + +from __future__ import annotations + +import threading +from collections.abc import Callable + +from gi.repository import Adw, GLib + +from codex32.errors import CodexError +from codex32_gui.wallet_setup import BitcoinCoreError + +_UNFINISHED = ( + "That operation stopped in a way this program did not expect. If it was writing to Bitcoin " + "Core, check there what state the wallet is in before trying again." +) +_BUSY = "Another operation is still finishing. Try again in a moment." +_running = False + + +def showing(view: Adw.NavigationView, page: Adw.NavigationPage) -> bool: + """Report whether a page is still on the navigation stack. + + Being rooted in the window is not the same test: a page keeps its root for + the length of the navigation animation, so a result arriving during it would + still be handed to a page the operator has already left. + """ + stack = view.get_navigation_stack() + return any(stack.get_item(position) is page for position in range(stack.get_n_items())) + + +def run[Result]( + view: Adw.NavigationView, + page: Adw.NavigationPage, + work: Callable[[], Result], + done: Callable[[Result | Exception], None], +) -> None: + """Run one blocking library call off the main loop and deliver its result back. + + `correct` runs for up to ten seconds and every Bitcoin Core call waits on a + subprocess, so neither may run on the main loop. The page that starts an + operation shows a spinner and cannot be left, so only one is ever in flight, + and a result for a page that is gone anyway is dropped. The result is + delivered from `finally`, so a failure no screen anticipated still releases + the program instead of leaving it spinning. + + The thread is deliberately not a daemon. `BitcoinCore.initialize` locks an + unlocked wallet again from a `finally`, and Python does not run `finally` + blocks in daemon threads while the interpreter is shutting down, so closing + the window during an import would otherwise leave that wallet open. + """ + global _running + if _running: + GLib.idle_add(_busy, view, page, done) + return + _running = True + + def worker() -> None: + outcome: Result | Exception = RuntimeError(_UNFINISHED) + try: + outcome = work() + except (CodexError, BitcoinCoreError, OSError, TypeError, ValueError) as error: + outcome = error + finally: + GLib.idle_add(deliver, outcome) + + def deliver(outcome: Result | Exception) -> bool: + global _running + _running = False + if showing(view, page): + done(outcome) + return False + + threading.Thread(target=worker).start() + + +def _busy[Result]( + view: Adw.NavigationView, + page: Adw.NavigationPage, + done: Callable[[Result | Exception], None], +) -> bool: + if showing(view, page): + done(RuntimeError(_BUSY)) + return False From f5f3c66b96b0aa3c774da0a683bcb5eaf6f01160 Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Mon, 28 Sep 2026 20:14:50 -0500 Subject: [PATCH 2/3] gui: Test graphical program boundaries Add toolkit-free tests for entry interpretation and Bitcoin Core orchestration, static checks for the GUI security boundary, and an Xvfb walkthrough that exercises every user task. The boundary tests keep GUI entropy, persistence, networking, and Core access constrained to the documented surfaces and enforce the GUI review-line budget. Validation: 107 focused GUI tests and the full Xvfb walkthrough pass; the complete and optimized suites each pass 974 tests, and GUI mypy and Ruff checks pass. --- tests/test_gui_boundaries.py | 116 ++++++ tests/test_gui_reading.py | 182 +++++++++ tests/test_gui_wallet_setup.py | 294 +++++++++++++++ tools/gui_walkthrough.py | 653 +++++++++++++++++++++++++++++++++ 4 files changed, 1245 insertions(+) create mode 100644 tests/test_gui_boundaries.py create mode 100644 tests/test_gui_reading.py create mode 100644 tests/test_gui_wallet_setup.py create mode 100644 tools/gui_walkthrough.py diff --git a/tests/test_gui_boundaries.py b/tests/test_gui_boundaries.py new file mode 100644 index 0000000..cc7c5eb --- /dev/null +++ b/tests/test_gui_boundaries.py @@ -0,0 +1,116 @@ +"""The claims a reviewer of the graphical program should be able to check cheaply.""" + +from __future__ import annotations + +import ast +import importlib +from pathlib import Path + +import pytest + +FORBIDDEN = frozenset( + { + "hashlib", + "hmac", + "http", + "io", + "logging", + "os", + "pathlib", + "pickle", + "random", + "requests", + "secrets", + "shelve", + "shutil", + "socket", + "sqlite3", + "ssl", + "subprocess", + "tempfile", + "urllib", + "webbrowser", + } +) +CORE_ADAPTER = "codex32._bitcoin_core" +BUDGET = 2000 + + +def _package() -> Path: + module = importlib.import_module("codex32_gui") + assert module.__file__ is not None + return Path(module.__file__).parent + + +def _modules() -> list[Path]: + return sorted(_package().rglob("*.py")) + + +def _imports(tree: ast.AST) -> set[str]: + found: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + found.update(alias.name for alias in node.names) + elif isinstance(node, ast.ImportFrom) and node.module is not None and not node.level: + found.add(node.module) + return found + + +@pytest.mark.parametrize("path", _modules(), ids=lambda path: path.name) +def test_the_gui_draws_no_entropy_opens_no_socket_and_touches_no_file(path: Path) -> None: + imported = _imports(ast.parse(path.read_text())) + assert not {name.split(".")[0] for name in imported} & FORBIDDEN, sorted(imported) + + +@pytest.mark.parametrize("path", _modules(), ids=lambda path: path.name) +def test_nothing_reads_or_writes_a_file(path: Path) -> None: + called = { + node.func.id + for node in ast.walk(ast.parse(path.read_text())) + if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) + } + assert "open" not in called and "eval" not in called and "exec" not in called + + +@pytest.mark.parametrize("path", _modules(), ids=lambda path: path.name) +def test_only_one_module_speaks_to_bitcoin_core(path: Path) -> None: + imported = _imports(ast.parse(path.read_text())) + assert (CORE_ADAPTER in imported) == (path.name == "wallet_setup.py"), sorted(imported) + + +def test_the_accessibility_bus_is_turned_off_before_gtk_starts() -> None: + """GTK otherwise publishes every label and entry, seed and passphrase included.""" + source = (_package() / "__init__.py").read_text() + assert 'GLib.setenv("GTK_A11Y", "none", False)' in source + tree = ast.parse(source) + settings = [node for node in ast.walk(tree) if isinstance(node, ast.Import | ast.ImportFrom)] + assert settings, "the setting has to be made before any typelib is loaded" + + +@pytest.mark.parametrize("path", _modules(), ids=lambda path: path.name) +def test_nothing_but_the_version_pin_may_import_a_module_by_name(path: Path) -> None: + """`importlib` would reach any of the forbidden modules without naming one.""" + imported = {name.split(".")[0] for name in _imports(ast.parse(path.read_text()))} + assert "importlib" not in imported or path.name == "__init__.py", sorted(imported) + + +def test_the_parts_that_decide_something_need_no_toolkit() -> None: + for name in ("reading.py", "wallet_setup.py", "style.py"): + imported = _imports(ast.parse((_package() / name).read_text())) + assert not any(item == "gi" or item.startswith("gi.") for item in imported), name + + +def test_the_library_does_not_depend_on_the_gui() -> None: + library = Path(importlib.import_module("codex32").__file__ or "").parent + for path in library.rglob("*.py"): + assert "codex32_gui" not in path.read_text(), path + + +def test_the_gui_keeps_its_own_size_budget() -> None: + counts = { + path.name: sum( + bool(line.strip()) and not line.lstrip().startswith("#") for line in path.read_text().splitlines() + ) + for path in _modules() + } + assert sum(counts.values()) < BUDGET, counts diff --git a/tests/test_gui_reading.py b/tests/test_gui_reading.py new file mode 100644 index 0000000..b91d37c --- /dev/null +++ b/tests/test_gui_reading.py @@ -0,0 +1,182 @@ +"""What the graphical entry field makes of typed text, without a display.""" + +import pytest +from data.bip93_vectors import VECTOR_2, VECTOR_3, VECTOR_5 + +from codex32_gui.reading import ( + PREFIX, + expected_length, + grouped, + header_fault, + lookalike_fault, + normalize, + normalize_readback, + read, + repair, +) + +SHARE_A = VECTOR_2["share_A"] +SHARE_C = VECTOR_2["share_C"] +SECRET_S = VECTOR_2["secret_S"] + + +@pytest.mark.parametrize( + ("typed", "canonical"), + [ + ("", PREFIX), + ("M", PREFIX), + ("MS", PREFIX), + ("ms1", PREFIX), + ("ms12nameacd", "MS12NAMEACD"), + ("MS12 NAME ACD", "MS12NAMEACD"), + (" ms1 2name\tacd ", "MS12NAMEACD"), + ("2NAMEACD", "MS12NAMEACD"), + (VECTOR_3["share_a"], VECTOR_3["share_a"].upper()), + ], +) +def test_the_field_only_ever_holds_one_canonical_form(typed: str, canonical: str) -> None: + assert normalize(typed) == canonical + + +def test_characters_outside_the_charset_cannot_be_entered() -> None: + assert normalize("MS12nameb io1l+ acd") == "MS12NAMELACD" + assert normalize("MS12NAME?") == "MS12NAME?" + + +def test_text_is_shown_in_four_character_windows() -> None: + assert grouped(SHARE_C) == "MS12 NAME CACD EFGH JKLM NPQR STUV WXYZ 023F TR2G DZMP Y6PN" + + +def test_readback_supplies_and_drops_nothing() -> None: + assert normalize_readback(" ms1 2name cb! ") == "MS12NAMECB!" + assert normalize_readback("S12NAMEC") == "S12NAMEC" + + +def test_a_header_that_cannot_belong_to_any_card_is_reported() -> None: + assert "0, or 2 through 9" in header_fault("MS1X") + assert "0, or 2 through 9" in header_fault("MS1XNAMEA") + assert "never split is card S" in header_fault("MS10NAMEA") + assert header_fault("MS12NAMEA") == "" + assert header_fault("MS10NAMES") == "" + + +def test_an_unreadable_header_is_not_judged() -> None: + assert header_fault("MS1?NAMEA") == "" + assert header_fault("MS12NAM?A") == "" + + +def test_a_card_already_entered_is_refused_by_name() -> None: + assert "Card A has already been entered" in header_fault("MS12NAMEA", ("a",)) + assert header_fault("MS12NAMEC", ("a",)) == "" + + +def test_a_known_set_header_is_checked_before_the_checksum() -> None: + assert "different split" in header_fault("MS13", expected_header=(2, "name")) + assert "backup identifier NAME" in header_fault("MS12C", expected_header=(2, "name")) + assert header_fault("MS12N?", expected_header=(2, "name")) == "" + + +def test_a_complete_card_reports_its_artifact() -> None: + result = read(SHARE_C) + assert result.artifact is not None and result.artifact.text == SHARE_C + assert result.complete and not result.repairable + assert result.level == "success" + + +def test_a_complete_secret_reports_its_artifact() -> None: + assert read(SECRET_S).artifact is not None + + +def test_a_broken_checksum_offers_a_repair_rather_than_an_artifact() -> None: + result = read(SHARE_C[:-1] + "M") + assert result.artifact is None + assert result.repairable + assert result.level == "error" + + +def test_unreadable_characters_are_counted_and_never_parsed() -> None: + result = read(SHARE_C[:-3] + "?" + SHARE_C[-2:]) + assert result.artifact is None and result.repairable + assert result.unreadable == 1 + assert result.message == "1 character unreadable." + + +def test_partial_input_counts_towards_the_length_being_typed() -> None: + result = read(SHARE_C[:20]) + assert result.message == "20 of 48 characters" + assert not result.complete and not result.repairable + + +def test_short_or_long_input_is_repairable_when_the_cli_search_can_reach_a_valid_length() -> None: + assert read(SHARE_C[:-1]).repairable + assert read(SHARE_C + "Q").repairable + assert not read(SHARE_C[:39]).repairable + assert not read(SHARE_C + "Q" * 5, length=48).repairable + + +def test_the_length_of_the_first_card_fixes_the_rest_of_the_set() -> None: + assert read(SHARE_C[:20], length=74).message == "20 of 74 characters" + assert expected_length(20, None) == 48 + assert expected_length(60, None) == 61 + assert expected_length(200, None) == 127 + + +def test_an_empty_field_invites_the_first_character() -> None: + assert read(PREFIX).message.startswith("Start typing") + assert read(PREFIX).artifact is None + + +def test_a_card_from_another_set_is_still_read_as_itself() -> None: + assert read(SHARE_A).artifact is not None + + +def test_one_repair_is_offered_for_one_damaged_character() -> None: + from codex32 import CorrectionCandidate + + found = repair(SHARE_C[:-1] + "M", None) + assert isinstance(found, CorrectionCandidate) + assert found.artifact.text == SHARE_C + assert not found.low_checksum_discrimination + + +def test_a_card_that_fits_no_repair_says_so_instead_of_guessing() -> None: + assert isinstance(repair(PREFIX + "Q" * 45, None), str) + + +def test_a_repair_that_repeats_an_accepted_card_is_not_offered() -> None: + assert isinstance(repair(SHARE_A[:-1] + "Q", 48, ("a",)), str) + + +@pytest.mark.parametrize(("card", "checksum_length"), ((SHARE_C, 13), (VECTOR_5["secret_s"], 15))) +def test_a_card_with_too_little_checksum_left_demands_the_warning(card: str, checksum_length: int) -> None: + from codex32 import CorrectionCandidate + + found = repair(card[:-checksum_length] + "?" * checksum_length, None) + assert isinstance(found, CorrectionCandidate) + assert found.low_checksum_discrimination + + +@pytest.mark.parametrize( + ("typed", "expected"), + [("B", "8"), ("I", "J or L"), ("O", "0, a zero"), ("1", "L")], +) +def test_a_character_no_card_can_carry_is_named_with_what_it_is_probably(typed: str, expected: str) -> None: + fault = lookalike_fault(f"MS12NAMEC{typed}") + assert typed in fault + assert expected in fault + + +def test_the_prefix_keeps_its_own_one() -> None: + assert lookalike_fault("MS1") == "" + assert lookalike_fault("ms12namec") == "" + + +def test_an_ordinary_stray_key_is_still_dropped_in_silence() -> None: + assert lookalike_fault("MS12NAMEC!,. ") == "" + + +def test_several_confusable_characters_are_all_named() -> None: + fault = lookalike_fault("MS12NAMEBO") + assert "B or O" in fault + assert "B is probably 8" in fault + assert "O is probably 0" in fault diff --git a/tests/test_gui_wallet_setup.py b/tests/test_gui_wallet_setup.py new file mode 100644 index 0000000..86ef8c2 --- /dev/null +++ b/tests/test_gui_wallet_setup.py @@ -0,0 +1,294 @@ +"""The graphical program's Bitcoin Core boundary: naming, passphrases, and refusals.""" + +from __future__ import annotations + +import json +import subprocess +from dataclasses import dataclass, field +from typing import Any + +import pytest + +from codex32 import MasterSeed, parse_codex32 +from codex32._bitcoin_core import BitcoinCore, BitcoinCoreError +from codex32_gui import wallet_setup + + +def _master_seed() -> MasterSeed: + seed = parse_codex32("MS12NAMES6XQGUZTTXKEQNJSJZV4JV3NZ5K3KWGSPHUH6EVW") + assert isinstance(seed, MasterSeed) + return seed + + +_SEED = _master_seed() + +PASSPHRASE = 'a pass phrase with = and "quotes"' + + +def _lines(supplied: str) -> list[str]: + """Split standard input the way bitcoin-cli's own `std::getline` does: on newlines only.""" + parts = supplied.split("\n") + return parts[:-1] if parts and parts[-1] == "" else parts + + +@dataclass +class _Wallet: + encrypted: bool = False + locked: bool = False + filled: bool = False + + +@dataclass +class _Core: + """A stand-in for bitcoin-cli that answers only what wallet selection needs.""" + + wallets: dict[str, _Wallet] + runs: list[tuple[tuple[str, ...], str | None]] = field(default_factory=list) + + def run(self, command: list[str], **keywords: Any) -> subprocess.CompletedProcess[str]: + supplied = keywords.get("input") + self.runs.append((tuple(command), supplied)) + options = [item for item in command[1:] if item.startswith("-")] + arguments = [item for item in command[1:] if not item.startswith("-")] + lines = _lines(supplied or "") + if supplied is not None and "-stdinwalletpassphrase" in options: + # bitcoin-cli takes the first line as the passphrase argument. + arguments, lines = [arguments[0], lines[0], *arguments[1:]], lines[1:] + if supplied is not None and "-stdin" in options: + arguments += lines + name = next((item[11:] for item in options if item.startswith("-rpcwallet=")), None) + return subprocess.CompletedProcess(command, 0, self._reply(arguments[0], arguments[1:], name), "") + + def _reply(self, method: str, arguments: list[str], name: str | None) -> str: + if method == "listwallets": + return json.dumps(sorted(self.wallets)) + if method == "listdescriptors": + return json.dumps({"descriptors": []}) + if method == "getwalletinfo": + assert name is not None + wallet = self.wallets[name] + info: dict[str, object] = { + "descriptors": True, + "private_keys_enabled": True, + "external_signer": False, + "txcount": 0, + "keypoolsize": 0, + "keypoolsize_hd_internal": 0, + "scanning": False, + } + if wallet.encrypted: + info["unlocked_until"] = 0 if wallet.locked else 1 + return json.dumps(info) + if method == "createwallet": + fields = dict(item.split("=", 1) for item in arguments) + self.wallets[fields["wallet_name"]] = _Wallet("passphrase" in fields, "passphrase" in fields) + return json.dumps({"name": fields["wallet_name"]}) + if method == "walletpassphrase": + assert name is not None + self.wallets[name].locked = False + return "" + if method == "walletlock": + assert name is not None + self.wallets[name].locked = True + return "" + raise AssertionError(f"the graphical program should not call {method}") + + def called(self, method: str) -> bool: + return any(method in command for command, _supplied in self.runs) + + +def _client(monkeypatch: pytest.MonkeyPatch, wallets: dict[str, _Wallet]) -> tuple[BitcoinCore, _Core]: + fake = _Core(wallets) + monkeypatch.setattr(subprocess, "run", lambda command, **keywords: fake.run(command, **keywords)) + return BitcoinCore("bitcoin-cli", "regtest", 320000), fake + + +def test_only_empty_wallets_are_offered(monkeypatch: pytest.MonkeyPatch) -> None: + core, _fake = _client(monkeypatch, {"empty": _Wallet(), "locked": _Wallet(True, True)}) + assert wallet_setup.eligible(core) == ( + wallet_setup.Wallet("empty", False, False), + wallet_setup.Wallet("locked", True, True), + ) + + +def test_the_wallet_is_chosen_by_its_exact_name(monkeypatch: pytest.MonkeyPatch) -> None: + core, _fake = _client(monkeypatch, {"one": _Wallet(), "two": _Wallet()}) + for wanted in ("one", "two"): + answer = wallet_setup._Answer(wanted, quoted=True) + assert core._select(answer.ask, answer.tell) == wanted + + +def test_a_single_eligible_wallet_still_needs_its_name(monkeypatch: pytest.MonkeyPatch) -> None: + core, _fake = _client(monkeypatch, {"only": _Wallet()}) + answer = wallet_setup._Answer("only", quoted=True) + assert core._select(answer.ask, answer.tell) == "only" + + +@pytest.mark.parametrize("chosen", [None, "a wallet that is not there"]) +def test_an_unmatched_name_offers_the_list_instead_of_guessing( + monkeypatch: pytest.MonkeyPatch, chosen: str | None +) -> None: + core, _fake = _client(monkeypatch, {"one": _Wallet(), "two": _Wallet()}) + answer = wallet_setup._Answer(chosen, quoted=True) + with pytest.raises(wallet_setup.Offer) as raised: + core._select(answer.ask, answer.tell) + assert raised.value.options == ("one", "two") + + +def test_no_eligible_wallet_offers_an_empty_list_rather_than_waiting( + monkeypatch: pytest.MonkeyPatch, +) -> None: + core, _fake = _client(monkeypatch, {}) + answer = wallet_setup._Answer("anything", quoted=True) + with pytest.raises(BitcoinCoreError, match="waiting"): + core._select(answer.ask, answer.tell) + + +def test_a_wallet_name_with_quotes_is_matched_exactly(monkeypatch: pytest.MonkeyPatch) -> None: + tricky = 'a "quoted". name' + core, _fake = _client(monkeypatch, {tricky: _Wallet(), "plain": _Wallet()}) + answer = wallet_setup._Answer(tricky, quoted=True) + assert core._select(answer.ask, answer.tell) == tricky + + +def test_the_passphrase_never_reaches_a_command_argument(monkeypatch: pytest.MonkeyPatch) -> None: + core, fake = _client(monkeypatch, {}) + wallet_setup.create(core, "fresh", PASSPHRASE) + wallet_setup.unlock(core, "fresh", PASSPHRASE) + assert fake.runs + for command, supplied in fake.runs: + assert not any(PASSPHRASE in item for item in command), command + if PASSPHRASE in (supplied or ""): + assert "-stdin" in command or "-stdinwalletpassphrase" in command + + +def test_the_passphrase_travels_on_the_dedicated_channel(monkeypatch: pytest.MonkeyPatch) -> None: + core, fake = _client(monkeypatch, {"fresh": _Wallet(True, True)}) + wallet_setup.unlock(core, "fresh", PASSPHRASE) + command, supplied = next(run for run in fake.runs if "walletpassphrase" in run[0]) + assert "-stdinwalletpassphrase" in command + assert supplied == PASSPHRASE + "\n" + assert str(wallet_setup.UNLOCK_SECONDS) in command + + +def test_wallet_creation_uses_one_fixed_set_of_flags(monkeypatch: pytest.MonkeyPatch) -> None: + core, fake = _client(monkeypatch, {}) + wallet_setup.create(core, "fresh", PASSPHRASE) + command, supplied = fake.runs[0] + assert "-named" in command and "createwallet" in command + assert supplied is not None + assert _lines(supplied)[:3] == ["wallet_name=fresh", "disable_private_keys=false", "blank=true"] + assert _lines(supplied)[3] == f"passphrase={PASSPHRASE}" + assert len(_lines(supplied)) == 4 + + +def test_a_wallet_created_without_a_passphrase_carries_no_passphrase_line( + monkeypatch: pytest.MonkeyPatch, +) -> None: + core, fake = _client(monkeypatch, {}) + wallet_setup.create(core, "fresh", "") + assert fake.runs[0][1] is not None + assert len(_lines(fake.runs[0][1])) == 3 + + +@pytest.mark.parametrize("name", ["", " leading", "trailing ", "two\nlines", "bell\x07"]) +def test_unusable_wallet_names_are_refused_before_bitcoin_core_sees_them( + monkeypatch: pytest.MonkeyPatch, name: str +) -> None: + core, fake = _client(monkeypatch, {}) + with pytest.raises(BitcoinCoreError, match="printable text"): + wallet_setup.create(core, name, "") + assert fake.runs == [] + + +@pytest.mark.parametrize("name", ["..", ".", "over/there", "back\\slash", "../../elsewhere"]) +def test_a_wallet_name_cannot_describe_a_path(monkeypatch: pytest.MonkeyPatch, name: str) -> None: + core, fake = _client(monkeypatch, {}) + with pytest.raises(BitcoinCoreError, match="slash"): + wallet_setup.create(core, name, "") + assert fake.runs == [] + + +@pytest.mark.parametrize("passphrase", ["", "two\nlines", "carriage\rreturn"]) +def test_a_passphrase_that_cannot_survive_the_channel_is_refused( + monkeypatch: pytest.MonkeyPatch, passphrase: str +) -> None: + core, fake = _client(monkeypatch, {"fresh": _Wallet(True, True)}) + with pytest.raises(BitcoinCoreError, match="line break"): + wallet_setup.unlock(core, "fresh", passphrase) + assert fake.runs == [] + + +def test_an_unlock_that_leaves_the_wallet_locked_is_reported(monkeypatch: pytest.MonkeyPatch) -> None: + core, fake = _client(monkeypatch, {"fresh": _Wallet(True, True)}) + original = _Core._reply + + def stubborn(self: _Core, method: str, arguments: list[str], name: str | None) -> str: + """Accept the passphrase call, but leave the wallet locked anyway.""" + return "" if method == "walletpassphrase" else original(self, method, arguments, name) + + monkeypatch.setattr(_Core, "_reply", stubborn) + with pytest.raises(BitcoinCoreError, match="no longer an empty one"): + wallet_setup.unlock(core, "fresh", PASSPHRASE) + assert fake.called("walletlock") + + +def test_a_wallet_that_stops_being_eligible_is_locked_again(monkeypatch: pytest.MonkeyPatch) -> None: + """Bitcoin Core has taken the passphrase by then, so the wallet is open until it is shut.""" + core, fake = _client(monkeypatch, {"fresh": _Wallet(True, True)}) + original = _Core._reply + + def filled(self: _Core, method: str, arguments: list[str], name: str | None) -> str: + if method == "listdescriptors" and self.wallets["fresh"].locked is False: + return json.dumps({"descriptors": [{"desc": "wpkh(xpub)", "active": True}]}) + return original(self, method, arguments, name) + + monkeypatch.setattr(_Core, "_reply", filled) + with pytest.raises(BitcoinCoreError, match="locked it again|no longer an empty one"): + wallet_setup.unlock(core, "fresh", PASSPHRASE) + assert fake.called("walletlock") + assert fake.wallets["fresh"].locked + + +def test_an_import_that_fails_before_the_library_arms_its_own_relock_still_locks( + monkeypatch: pytest.MonkeyPatch, +) -> None: + core, fake = _client(monkeypatch, {"fresh": _Wallet(True, True)}) + + def refuse(*_arguments: object, **_keywords: object) -> str: + raise BitcoinCoreError("Bitcoin Core is waiting for something to be done in its own window.") + + monkeypatch.setattr(wallet_setup, "initialize", refuse) + with pytest.raises(BitcoinCoreError, match="waiting"): + wallet_setup.fill(core, _SEED, "fresh", PASSPHRASE) + assert fake.called("walletlock") + assert fake.wallets["fresh"].locked + + +def test_an_unlock_is_not_attempted_when_no_passphrase_was_given( + monkeypatch: pytest.MonkeyPatch, +) -> None: + core, fake = _client(monkeypatch, {"fresh": _Wallet()}) + monkeypatch.setattr(wallet_setup, "initialize", lambda *_a, **_k: "fresh") + assert wallet_setup.fill(core, _SEED, "fresh", "") == "fresh" + assert not fake.called("walletpassphrase") + assert not fake.called("walletlock") + + +def test_the_chain_the_operator_chose_is_the_one_that_is_used( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """One chain answering after another stopped must not quietly stand in for it.""" + monkeypatch.setattr( + wallet_setup.BitcoinCore, + "connect", + classmethod(lambda _cls, _ask, _tell: BitcoinCore("bitcoin-cli", "main", 320000)), + ) + assert wallet_setup.connect("mainnet").chain == "main" + with pytest.raises(wallet_setup.Offer) as raised: + wallet_setup.connect("signet") + assert raised.value.options == ("mainnet",) + + +def test_the_version_is_reported_the_way_bitcoin_core_reports_it() -> None: + assert wallet_setup.version_text(BitcoinCore("bitcoin-cli", "main", 320100)) == "32.1.0" diff --git a/tools/gui_walkthrough.py b/tools/gui_walkthrough.py new file mode 100644 index 0000000..a9bc3b6 --- /dev/null +++ b/tools/gui_walkthrough.py @@ -0,0 +1,653 @@ +"""Walk the graphical program's screens and report what they do. + +The checks here need a display, so they are not part of the pytest suite. Run +them against a throwaway X server: + + Xvfb :90 -screen 0 900x700x24 & + DISPLAY=:90 GDK_BACKEND=x11 PYTHONPATH=src python3 tools/gui_walkthrough.py + +Nothing here touches Bitcoin Core: the preflight is answered by a stand-in, so +no wallet is opened, created, or changed. +""" + +from __future__ import annotations + +from collections.abc import Callable, Iterator +from typing import Any + +import gi + +gi.require_version("Adw", "1") +gi.require_version("Gdk", "4.0") +gi.require_version("Gtk", "4.0") + +from gi.repository import Adw, Gdk, GLib, Gtk + +from codex32_gui import app, pages, reading, wallet_setup + +SHARE_A = "MS12NAMEA320ZYXWVUTSRQPNMLKJHGFEDCAXRPP870HKKQRM" +SHARE_C = "MS12NAMECACDEFGHJKLMNPQRSTUVWXYZ023FTR2GDZMPY6PN" +DERIVED_D = "MS12NAMEDLL4F8JLH4E5VDVULDLFXU2JHDNLSM97XVENRXEG" +SECRET_S = "MS12NAMES6XQGUZTTXKEQNJSJZV4JV3NZ5K3KWGSPHUH6EVW" +OTHER_BACKUP = "ms13cashcacdefghjklmnpqrstuvwxyz023949xq35my48dr" +NETWORKS = ("mainnet", "signet", "regtest") + +failures: list[str] = [] +asked: list[str | None] = [] + + +class _Stub: + """Stands in for a connected Bitcoin Core, and answers nothing else.""" + + version = 320000 + chain = "signet" + + +def _connect(chain: str | None = None) -> Any: + asked.append(chain) + if chain is None: + raise wallet_setup.Offer(NETWORKS) + return _Stub() + + +def check(name: str, condition: bool, detail: object = "") -> None: + print(f"{'PASS' if condition else 'FAIL'} {name} {detail}", flush=True) + if not condition: + failures.append(name) + + +def walk(widget: Any) -> Iterator[Any]: + child = widget.get_first_child() + while child is not None: + yield child + yield from walk(child) + child = child.get_next_sibling() + + +def card(page: Any) -> str: + return "".join(item.get_label() for item in walk(page) if "card-group" in item.get_css_classes()) + + +def button(page: Any, label: str) -> Any: + found = [item for item in walk(page) if isinstance(item, Gtk.Button) and item.get_label() == label] + return found[0] if found else None + + +def field_of(page: Any) -> Any: + return next(item for item in walk(page) if type(item).__name__ == "Codex32Entry") + + +def press(page: Any, label: str) -> None: + next(item for item in walk(page) if isinstance(item, Gtk.Button) and item.get_label() == label).emit( + "clicked" + ) + + +def labels(page: Any) -> list[str]: + return [item.get_label() for item in walk(page) if isinstance(item, Gtk.Label) and item.get_label()] + + +def rows(page: Any) -> list[Any]: + return [item for item in walk(page) if type(item).__name__ == "ActionRow"] + + +def _primary() -> str: + """Read back whatever the display's primary selection holds, without blocking.""" + display = Gdk.Display.get_default() + if display is None: + return "" + holder: list[str] = [] + display.get_primary_clipboard().read_text_async(None, lambda clip, done: holder.append(_text(clip, done))) + for _attempt in range(200): + settle() + if holder: + return holder[0] + return "" + + +def _text(clipboard: Any, done: Any) -> str: + try: + return clipboard.read_text_finish(done) or "" + except GLib.Error: + return "" + + +def _primary_is_empty() -> bool: + return _primary() == "" + + +def settle() -> None: + context = GLib.MainContext.default() + for _attempt in range(500): + if not context.pending(): + return + context.iteration(False) + + +class Walkthrough(app.Application): + """Drive the window through one step per main-loop turn.""" + + def do_activate(self) -> None: + super().do_activate() + self.view = self.get_active_window().get_content() + self.steps: list[Callable[[], bool]] = [ + self.home, + self.typing, + self.intact, + self.short_repair_entry, + self.short_repair_candidate, + self.damaged, + self.candidate, + self.back_to_entry, + self.accepted_repair, + self.completion_entry, + self.completion_gate, + self.preflight, + self.network, + self.letters, + self.basis, + self.second_card, + self.derived, + self.read_back, + self.new_card_done, + self.seed_entry, + self.seed_shown, + ] + GLib.timeout_add(300, self.pump) + + def page(self) -> Any: + return self.view.get_visible_page() + + def pump(self) -> bool: + if not self.steps: + self.quit() + return False + if self.steps[0](): + self.steps.pop(0) + GLib.timeout_add(200, self.pump) + return False + + def home(self) -> bool: + listed = rows(self.page()) + check("home offers six tasks", len(listed) == 6, [row.get_title() for row in listed]) + artwork = [ + item.get_property("file") + for item in walk(self.page()) + if isinstance(item, Gtk.Image) and item.get_property("file") + ] + check( + "all six tasks use distinct book illustrations", len(artwork) == 6 == len(set(artwork)), artwork + ) + listed[2].emit("activated") + return True + + def typing(self) -> bool: + page = self.page() + check("checking a card opens one entry page", page.get_title() == "Check a card", page.get_title()) + field = field_of(page) + check("the field starts with the frozen prefix", field.get_text() == reading.PREFIX) + check("typing starts after the frozen prefix", field.get_position() == len(reading.PREFIX)) + field.insert_text(SHARE_C.lower(), field.get_position()) + settle() + check( + "pasting a full card after the frozen prefix does not duplicate it", + "".join(field.get_text().split()) == SHARE_C, + field.get_text(), + ) + field.clear() + settle() + field.delete_text(0, 1) + settle() + check("the frozen prefix cannot be deleted", field.get_text() == reading.PREFIX, field.get_text()) + go = next( + item for item in walk(page) if isinstance(item, Gtk.Button) and item.get_label() == "Continue" + ) + check("nothing may be submitted yet", not go.get_sensitive()) + field.insert_text("X", len(reading.PREFIX)) + settle() + check("a bad threshold is kept so it can be corrected", field.get_text() == "MS1X", field.get_text()) + field.insert_text("N", len(field.get_text())) + settle() + check( + "forward typing stays frozen after the bad threshold", + field.get_text() == "MS1X", + field.get_text(), + ) + field.set_text("MS1?") + settle() + check( + "check rejects an unreadable threshold marker", + any("? cannot be used" in text for text in labels(page)), + labels(page), + ) + field.insert_text("N", len(field.get_text())) + settle() + check( + "check freezes after a question mark in the header", field.get_text() == "MS1?", field.get_text() + ) + field.delete_text(3, 4) + field.insert_text("2", 3) + settle() + field.set_text("ms12nameacd") + settle() + check("text is uppercased and grouped", field.get_text() == "MS12 NAME ACD", field.get_text()) + field.set_text("MS12NAMECB") + settle() + check( + "a character no card can carry is named, not silently dropped", + any("never contains B" in text for text in labels(page)), + [text for text in labels(page) if "contains" in text], + ) + check("and it is gone from the field", "B" not in field.get_text(), field.get_text()) + field.set_text("MS10NAMEA") + settle() + check("an impossible header stops the rest", field.get_text() == "MS10 NAME A", field.get_text()) + check( + "and says why", + any("never split is card S" in text for text in labels(page)), + [text for text in labels(page) if "S" in text], + ) + field.set_text(SHARE_C) + settle() + check("a valid card may be submitted", go.get_sensitive()) + field.emit("activate") + return True + + def intact(self) -> bool: + page = self.page() + text = labels(page) + check("an intact card is reported as intact", any("This card is intact" in item for item in text)) + check( + "the wording never states how many cards exist", + any("Any 2 cards from that backup" in item for item in text) + and not any(" of 3" in item for item in text), + [item for item in text if "cards" in item], + ) + press(page, "Done") + return True + + def short_repair_entry(self) -> bool: + page = self.page() + if page.get_title() != "codex32": + return False + rows(page)[2].emit("activated") + page = self.page() + field = field_of(page) + field.set_text(SHARE_C[:-1]) + settle() + check( + "a 47-character card is eligible for correction", button(page, "Suggest a repair").get_sensitive() + ) + field.emit("activate") + return True + + def short_repair_candidate(self) -> bool: + page = self.page() + if page.get_title() != "Repair" or button(page, "It does not match my card") is None: + return False + groups = [item for item in walk(page) if "card-group" in item.get_css_classes()] + flow = next(item for item in walk(page) if isinstance(item, Gtk.FlowBox)) + + def row_positions() -> list[float]: + children = [flow.get_child_at_index(index) for index in range(len(groups))] + return [child.compute_bounds(flow)[1].origin.y for child in children] + + def four_columns(rows_y: list[float]) -> bool: + return len(set(rows_y)) == (len(groups) + 3) // 4 and all( + rows_y[index] == rows_y[(index // 4) * 4] for index in range(len(rows_y)) + ) + + rows_y = row_positions() + check("Enter invokes repair for short input", "".join(item.get_label() for item in groups) == SHARE_C) + check( + "ordinary repair does not claim where the error was", + not any("guessed" in item.get_css_classes() for item in groups), + ) + check( + "card display uses four aligned groups per row", + flow.get_min_children_per_line() == 4 + and flow.get_max_children_per_line() == 4 + and four_columns(rows_y), + rows_y, + ) + window = self.get_active_window() + window.set_default_size(640, 620) + settle() + narrow = row_positions() + window.set_default_size(1100, 620) + settle() + wide = row_positions() + check( + "card columns stay aligned when the window is resized", + four_columns(narrow) and four_columns(wide), + ) + press(page, "It does not match my card") + settle() + self.view.replace([pages.home(self.view)]) + return True + + def damaged(self) -> bool: + page = self.page() + check("done returns home", page.get_title() == "codex32", page.get_title()) + rows(page)[3].emit("activated") + page = self.page() + field = field_of(page) + field.insert_text("X", len(reading.PREFIX)) + field.insert_text("N", len(reading.PREFIX) + 1) + settle() + check( + "explicit correction does not freeze a damaged header", + "X" in field.get_text() and "N" in field.get_text(), + ) + field.set_text(SHARE_C[:-1] + "?") + settle() + check("explicit correction preserves a literal erasure", "?" in field.get_text(), field.get_text()) + fix = next( + item + for item in walk(page) + if isinstance(item, Gtk.Button) and item.get_label() == "Suggest a repair" + ) + check("a repair is offered for an unreadable character", fix.get_sensitive()) + fix.emit("clicked") + working = self.page() + spinners = [item for item in walk(working) if isinstance(item, Adw.Spinner)] + settings = Gtk.Settings.get_default() + animations = settings is None or bool(settings.get_property("gtk-enable-animations")) + check( + "an actual repair search shows the Adwaita spinner when animations are enabled", + len(spinners) == 1 and spinners[0].get_visible() == animations, + [spinner.get_visible() for spinner in spinners], + ) + return True + + def candidate(self) -> bool: + page = self.page() + # The spinner shares this title, so wait for the candidate itself to arrive. + if page.get_title() != "Repair" or button(page, "It does not match my card") is None: + return False + groups = [item for item in walk(page) if "card-group" in item.get_css_classes()] + check( + "the repair is the published vector", + "".join(item.get_label() for item in groups) == SHARE_C, + "".join(item.get_label() for item in groups), + ) + guessed = [item.get_label() for item in groups if "guessed" in item.get_css_classes()] + check("explicit correction may highlight changed windows", guessed == ["Y6PN"], guessed) + press(page, "It does not match my card") + return True + + def back_to_entry(self) -> bool: + page = self.page() + if page.get_title() != "Repair a damaged card": + return False + typed = "".join(field_of(page).get_text().split()) + check("refusing a repair keeps what was typed", typed == SHARE_C[:-1] + "?", typed) + press(page, "Suggest a repair") + return True + + def accepted_repair(self) -> bool: + page = self.page() + if page.get_title() != "Repair" or button(page, "It matches my card") is None: + return False + press(page, "It matches my card") + settle() + page, shown = self.page(), labels(self.page()) + check( + "an accepted repair is never called intact", + not any("intact" in text for text in shown), + [text for text in shown if "intact" in text], + ) + check( + "and it says the card was guessed at, not read", + any("cannot tell you it is right" in text for text in shown), + shown[:3], + ) + check("and the repair is still the published vector", card(page) == SHARE_C, card(page)) + press(page, "Done") + return True + + def completion_entry(self) -> bool: + """Thirteen unreadable characters at the end are a whole checksum.""" + page = self.page() + if page.get_title() != "codex32": + return False + rows(page)[3].emit("activated") + page = self.page() + field = field_of(page) + field.set_text(SHARE_C[:-13] + "?" * 13) + settle() + fix = button(page, "Suggest a repair") + check("completing a checksum is offered as a repair", fix.get_sensitive()) + fix.emit("clicked") + return True + + def completion_gate(self) -> bool: + page = self.page() + if page.get_title() != "Warning" or button(page, "Show the guess") is None: + return False + shown = labels(page) + check("nothing about the guess is disclosed yet", card(page) == "", card(page)) + check( + "the gate warns that completion locks earlier errors in", + any( + "hand-written data" in text and "locks earlier transcription errors in" in text + for text in shown + ), + [text for text in shown if "hand-written" in text], + ) + check( + "and someone recovering a damaged card", + any( + "damaged card" in text and "valid-looking guess may still be wrong" in text for text in shown + ), + [text for text in shown if "damaged card" in text], + ) + check( + "and forbids replacing a checksum outright", + any("Never erase or replace" in text for text in shown), + [text for text in shown if "Never erase" in text], + ) + show = button(page, "Show the guess") + check("the guess stays hidden until YES is typed", not show.get_sensitive()) + entry = next(item for item in walk(page) if isinstance(item, Gtk.Entry)) + for typed in ("yes", "Yes", "YES please", "Y"): + entry.set_text(typed) + settle() + check(f"{typed!r} does not open the gate", not show.get_sensitive()) + entry.set_text("YES") + settle() + check("literal YES opens it", show.get_sensitive()) + press(page, "Cancel") + settle() + self.view.replace([pages.home(self.view)]) + return True + + def preflight(self) -> bool: + page = self.page() + if page.get_title() != "codex32": + return False + rows(page)[0].emit("activated") + return True + + def network(self) -> bool: + page = self.page() + if page.get_title() == "Bitcoin Core": + return False + check("more than one network asks which", page.get_title() == "Network", page.get_title()) + listed = [row.get_title() for row in rows(page)] + check("every running network is listed", tuple(listed) == NETWORKS, listed) + for row in rows(page): + if row.get_title() == "signet": + row.get_activatable_widget().set_active(True) + press(page, "Continue") + settle() + check("the chosen network is the one used", asked == [None, "signet"], asked) + self.view.replace([pages.home(self.view)]) + return True + + def letters(self) -> bool: + page = self.page() + if page.get_title() != "codex32": + return False + rows(page)[4].emit("activated") + page = self.page() + check("replacing a card starts with a letter", page.get_title() == "New card", page.get_title()) + offered = [item.get_label() for item in walk(page) if "card-group" in item.get_css_classes()] + check("thirty-one ordinary letters, and no S", len(offered) == 31 and "S" not in offered, offered) + flow = next(item for item in walk(page) if isinstance(item, Gtk.FlowBox)) + for position in range(31): + child = flow.get_child_at_index(position) + if child.get_child().get_label() == "D": + flow.select_child(child) + press(page, "Continue") + return True + + def basis(self) -> bool: + page = self.page() + field = field_of(page) + field.set_text(DERIVED_D) + settle() + check("the letter being made cannot also be entered", not button(page, "Continue").get_sensitive()) + field.set_text(SHARE_A) + settle() + check("an existing card is accepted", button(page, "Continue").get_sensitive()) + press(page, "Continue") + return True + + def second_card(self) -> bool: + page = self.page() + field = field_of(page) + check("the known header is pre-filled", field.get_text() == "MS12 NAME", field.get_text()) + field.set_text(SHARE_A) + settle() + check("the same card cannot be entered twice", not button(page, "Continue").get_sensitive()) + field.set_text("MS12C") + settle() + check( + "the first incompatible backup-identifier character pauses entry", + any("backup identifier NAME" in text for text in labels(page)), + [text for text in labels(page) if "backup" in text], + ) + field.insert_text("A", len(field.get_text())) + settle() + check( + "forward typing stays frozen after the incompatible identifier", + "".join(field.get_text().split()) == "MS12C", + field.get_text(), + ) + field.set_text("MS12?") + settle() + field.insert_text("A", len(field.get_text())) + settle() + check( + "an erasure in an incompatible header lets multi-card entry continue", + "".join(field.get_text().split()) == "MS12?A", + field.get_text(), + ) + field.set_text("MS12NAME") + settle() + field.set_text(OTHER_BACKUP) + settle() + check( + "a card from a different split is refused at its threshold", + any("different split" in text for text in labels(page)), + [text for text in labels(page) if "backup" in text], + ) + field.set_text(SHARE_C) + settle() + press(page, "Continue") + return True + + def derived(self) -> bool: + page = self.page() + if button(page, "I have written it down") is None: + return False + check("a replacement card is named, never counted", page.get_title() == "Card D", page.get_title()) + check( + "and nothing on it states a total", + not any(" of 1" in text for text in labels(page)), + [text for text in labels(page) if " of " in text], + ) + self.made = card(page) + check("the derived card is the published vector", self.made == DERIVED_D, self.made) + press(page, "I have written it down") + return True + + def read_back(self) -> bool: + page = self.page() + field = field_of(page) + check("the read-back is named the same way", page.get_title() == "Card D", page.get_title()) + check("read-back starts completely empty", field.get_text() == "", field.get_text()) + field.set_text(self.made) + settle() + field.select_region(0, -1) + settle() + check("selecting a card does not publish it", _primary_is_empty(), _primary()) + field.set_text(self.made[:-4] + "QQQQ") + settle() + press(page, "Confirm card") + mismatches = [ + item.get_label() + for item in walk(page) + if "card-group" in item.get_css_classes() and "mismatch" in item.get_css_classes() + ] + check( + "a mistyped group is highlighted without revealing its correction", + mismatches == ["QQQQ"], + mismatches, + ) + check( + "the mismatch display contains only what was typed", + card(page) == self.made[:-4] + "QQQQ", + card(page), + ) + field.set_text(self.made.lower()) + settle() + field.emit("activate") + return True + + def new_card_done(self) -> bool: + page = self.page() + if button(page, "Done") is None: + return False + text = labels(page) + check("the new card is reported confirmed", any("is written and confirmed" in item for item in text)) + check("and the wallet is said to be untouched", any("wallet is untouched" in item for item in text)) + check("and no card count is stated", not any(" of 3" in item for item in text), text) + press(page, "Done") + return True + + def seed_entry(self) -> bool: + page = self.page() + if page.get_title() != "codex32": + return False + rows(page)[5].emit("activated") + for text in (SHARE_A, SHARE_C): + page = self.page() + field_of(page).set_text(text) + settle() + press(page, "Continue") + return True + + def seed_shown(self) -> bool: + page = self.page() + if page.get_title() != "Master seed": + return False + check("two cards recover the published secret", card(page) == SECRET_S, card(page)) + check( + "and the screen warns before anything else", + any("take every coin" in text for text in labels(page)), + [text for text in labels(page) if "coin" in text], + ) + return True + + +def main() -> int: + wallet_setup.connect = _connect # type: ignore[assignment] + Walkthrough().run(["gui_walkthrough"]) + print(f"\n{'FAILED: ' + ', '.join(failures) if failures else 'every check passed'}") + return 1 if failures else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From d6dfa816f644df90133b34441db427d34114e2c1 Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Mon, 28 Sep 2026 20:14:58 -0500 Subject: [PATCH 3/3] docs: Document graphical program Document installation and use of the optional GUI, its review boundaries, Bitcoin Core passphrase and wallet-creation departures, and the corresponding security model and API map. Keep the GUI review budget synchronized with its boundary test and the current v1 library budget. Validation: reviewed the rebased diff against reviewability-v1 and verified the built wheel contains the GUI package data, desktop file, and launcher icon. --- README.md | 41 ++++++++++-- docs/developer/api.md | 10 +++ docs/developer/gui.md | 125 ++++++++++++++++++++++++++++++++++ docs/security/invariants.md | 15 ++++- docs/security/model.md | 57 ++++++++++++++-- docs/user/gui.md | 130 ++++++++++++++++++++++++++++++++++++ docs/user/guide.md | 4 ++ 7 files changed, 371 insertions(+), 11 deletions(-) create mode 100644 docs/developer/gui.md create mode 100644 docs/user/gui.md diff --git a/README.md b/README.md index e652578..107b266 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,8 @@ checksummed, secret-sharing-aware Base32 format for Bitcoin master seeds. A master seed is the private recovery secret from which a Bitcoin wallet derives its keys. -This project provides a command-line tool and Python library that can: +This project provides a command-line tool, an optional graphical user interface, and a Python +library that can: - create an unshared master-seed backup or an M-of-N shared backup; - check backup text and suggest possible repairs after damage; - recover a master seed from the required shares; @@ -16,10 +17,9 @@ This project provides a command-line tool and Python library that can: With an M-of-N backup, any M of the N paper shares can recover the master seed. A set with fewer than M shares cannot recover it. -This is not a Bitcoin wallet. It has no graphical interface, cannot show -balances or send bitcoin, and does not produce BIP39 mnemonic words. codex32 -makes no network connection; it communicates with a local Bitcoin Core -instance through `bitcoin-cli`. +This is not a Bitcoin wallet. It cannot show balances or send bitcoin, and does +not produce BIP39 mnemonic words. codex32 makes no network connection; it +communicates with a local Bitcoin Core instance through `bitcoin-cli`. This is security-critical reference software. Use it on a trusted computer and obtain an independent review before relying on it with funds. See @@ -54,6 +54,37 @@ BIP39 worksheet profiles are supported for existing-backup recovery but are Powerful correction searches, including recovery of genuinely unreadable characters, require interactive confirmation. +### The optional GUI + +`codex32-gui` does the same master-seed jobs in a graphical interface, for someone +who has never used codex32 before. It adds no Python dependency: GTK 4 and libadwaita +arrive as system packages. If your system does not already provide them, install +them first: + +```bash +sudo apt install python3-gi gir1.2-gtk-4.0 gir1.2-adw-1 +``` + +Then create the environment with `--system-site-packages` so that it can see +those packages: + +```bash +python -m venv --system-site-packages .venv +source .venv/bin/activate + +python -m pip install --require-hashes \ + -r requirements/cli-build-dependencies.txt +python -m pip install --no-build-isolation --no-deps '.[gui]' +python -m pip check +codex32-gui +``` + +Because this environment can see system Python packages, `pip check` may also +report pre-existing problems in unrelated applications. Those packages are not +GUI dependencies. Tails 7 and Debian 13 already carry the three +required system packages, so an amnesic session needs no download. See the +[GUI guide](docs/user/gui.md). + ## Start here Start Bitcoin Core 32 or newer with local RPC enabled. codex32 detects and diff --git a/docs/developer/api.md b/docs/developer/api.md index fef8079..e4c20bf 100644 --- a/docs/developer/api.md +++ b/docs/developer/api.md @@ -110,6 +110,16 @@ blank and comment-only lines while counting subpackages recursively. Changing the budget requires explicit review and authorization together with the matching documentation and enforcement update. +### The optional graphical package + +`src/codex32_gui/` is a second distribution package in this repository, +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,000 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`. + ## Profile and opaque-HRP capabilities There is no runtime registration. An unknown HRP uses generic codex32 rules and diff --git a/docs/developer/gui.md b/docs/developer/gui.md new file mode 100644 index 0000000..b0b0692 --- /dev/null +++ b/docs/developer/gui.md @@ -0,0 +1,125 @@ +# Reviewing the graphical program + +`src/codex32_gui/` is an optional package installed with `codex32[gui]` and run +as `codex32-gui`. It adds presentation and Bitcoin Core orchestration; it adds no +cryptography, entropy source, socket, or file storage. + +## Review order + +| File | Purpose | +|---|---| +| `reading.py` | Entry interpretation and repair policy; no GTK. | +| `wallet_setup.py` | All Bitcoin Core calls, passphrase handling, and relocking; no GTK. | +| `work.py` | One bounded background operation at a time. | +| `entry.py` | Ordinary, correction, and read-back entry modes. | +| `pages.py` | Screen construction and wording. | +| `app.py` | Application and window setup. | +| `style.py` | CSS string. | +| `__init__.py`, `__main__.py` | GTK setup and entry point. | + +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,000 +logical-line GUI budget. + +## Security boundaries + +`tests/test_gui_boundaries.py` checks the first four mechanically. + +1. **No entropy or cryptography.** Seed creation stays in `CreationCeremony`. +2. **No network stack.** The GUI imports no socket, TLS, HTTP, or subprocess + modules. Bitcoin Core access remains in the library's `bitcoin-cli` wrapper. +3. **No secret persistence.** The GUI imports no filesystem, database, logging, + or clipboard storage APIs and never calls `open`. +4. **One Core boundary.** Only `wallet_setup.py` imports + `codex32._bitcoin_core`. +5. **Page secrets are cleared.** `_forget_when_gone` clears card or entry text + when an `AdwNavigationView` page leaves the stack. +6. **One worker at a time.** `work.run` serializes background jobs, returns on the + GTK thread, and drops callbacks for pages that are gone. +7. **Accessibility is opt-in.** `__init__.py` defaults `GTK_A11Y` to `none` + before GTK loads; an operator can override it. +8. **Labels never select wallets.** Choice rows use position and disable markup; + exact wallet-name confirmation remains in the Core adapter. + +## Entry and correction + +The entry widget has three internal modes: + +- **Ordinary:** supplies and protects `MS1` and blocks forward typing after an + invalid header. Multi-card jobs may use `?` for an unreadable header character; + Check a card does not accept `?`. +- **Correction:** preserves damaged headers and literal `?`, matching the CLI's + correction input. +- **Read-back:** starts empty, normalizes only spacing and ASCII case, and never + reveals expected text after a mismatch. + +Enter activates the enabled primary action. Card displays use four +four-character groups per row, matching `docs/user/recovery-card.html`. +Ordinary repair suggestions are unmarked; explicit correction may mark changed +groups, which are described as changes rather than known error locations. + +Low-discrimination correction keeps the CLI's literal-`YES` gate. Its single +warning covers both hazards: checksum completion can lock errors into new data, +and a damaged-card guess can be wrong. It also forbids replacing a checksum just +to make invalid data validate. + +## Bitcoin Core integration + +These GUI-specific behaviors are confined to `wallet_setup.py` and documented in +`docs/security/invariants.md` and `docs/security/model.md`. + +**Passphrase input.** The GUI may ask for a wallet passphrase. It reaches +`bitcoin-cli` through `-stdinwalletpassphrase`, never `argv`, and is not stored. +The operator can instead unlock in Bitcoin Core. + +**Relocking.** `wallet_setup.fill` owns unlock, import, and relock in one +`finally`, including failures before `BitcoinCore.initialize` has armed its own +relock path. Core's 180-second timeout remains a backstop. + +**Blank-wallet creation.** The GUI sends a fixed `createwallet` shape: +`wallet_name`, `disable_private_keys=false`, `blank=true`, plus `passphrase` when +provided. A blank encrypted wallet starts locked, then the GUI unlocks it for +the import. + +### Driving `BitcoinCore.initialize` + +`wallet_setup._Answer` only answers choices already made by the operator: + +- `[y/N]` becomes `y` only for the exact chosen quoted wallet name; +- a numbered prompt gets the number assigned to that exact name by the library; +- anything else raises `Offer` and returns the library's choices to the GUI. + +Unexpected or stale state therefore fails closed rather than selecting another +wallet. `_Answer.tell` also rejects terminal-only “press Ctrl-C” waits. + +## Differences from `ms32` + +- GUI repair does not require Bitcoin Core. If `_best` leaves a tie, the GUI + reports ambiguity instead of using the CLI's fingerprint tie-breaker. +- The GUI can create a blank Bitcoin Core wallet; `ms32 create` does not. +- The GUI uses account 0. Other accounts require `ms32 wallet --account N`. +- The GUI has no checksum-completer action. The low-discrimination route is + still reachable through correction and uses the same warning gate. +- Restore asks the operator to compare wallet identity with the existing record + and does not show a new creation date. +- A corrected card is never reported as intact. +- `xprv`, Core Lightning, BIP39 worksheet profiles, and the generic `codex32` + façade remain command-line only. + +## Known limits + +- Long cards scroll horizontally because entry uses one `Gtk.Entry`. +- Empty-wallet lists refresh on request, not continuously. +- Widget construction is covered by `tools/gui_walkthrough.py`, not pytest. +- On X11, selecting entry text briefly exposes it through the primary selection. +- Enabling GTK accessibility exposes GUI text to other processes on that bus. +- A running spinner page cannot be left; correction and `bitcoin-cli` operations + are bounded by their configured deadlines. + +## Desktop identity and artwork + +The six home actions use six crops from the MIT-licensed Codex32 book cover. +`artwork/LICENSE` carries the attribution and source. The Codex32 orb is also +installed as the `io.github.benwestgate.codex32` launcher icon beside the matching +desktop file. Packaging tests verify both assets are present. diff --git a/docs/security/invariants.md b/docs/security/invariants.md index 5ad144a..83c3bfe 100644 --- a/docs/security/invariants.md +++ b/docs/security/invariants.md @@ -30,8 +30,19 @@ and evidence. operator confirms an eligible descriptor wallet by exact name. 8. Wallet state is revalidated before import. Every import must succeed and the exact accepted public descriptor set must match. -9. codex32 has no passphrase channel. An unlocked encrypted signer is relocked - and verified on every exit path. +9. The installed library and both command-line programs have no passphrase + channel. The graphical program declares one exception, confined to + `codex32_gui/wallet_setup.py`, which is also the only module there that + speaks to Bitcoin Core: it may send an operator-supplied passphrase to + `bitcoin-cli` on standard input to unlock a wallet, may create one blank + descriptor wallet with a fixed set of arguments and no options, and may + request `walletlock`. It stores no passphrase and writes nothing to disk. + An unlocked encrypted signer is relocked and verified on every exit path: by + the library, unchanged, and additionally by a `finally`-protected obligation + covering every wallet the graphical program itself unlocked. No window may + close out of that obligation. The graphical program also disables the + toolkit's accessibility bus before the toolkit starts, unless the operator + has set it themselves. 10. External text, Core output, public wallet data, and PSBTs are untrusted. 11. Only Bitcoin Core descriptor wallets sign with codex32-derived keys. Sensitive operations use only codex32 or Core on malware-free computers diff --git a/docs/security/model.md b/docs/security/model.md index 70cbbd8..533bbd1 100644 --- a/docs/security/model.md +++ b/docs/security/model.md @@ -72,9 +72,12 @@ The operator must: default identifier; fingerprints are metadata, not secrets. - Private Bitcoin Core descriptors contain the root xprv and temporarily exist in Python objects, serialized JSON, and the child process's standard input. -- Wallet encryption belongs to Bitcoin Core. codex32 accepts an eligible - unencrypted or unlocked encrypted wallet and never evaluates or handles a - passphrase. +- Wallet encryption belongs to Bitcoin Core. The library and both command-line + programs accept an eligible unencrypted or unlocked encrypted wallet and never + evaluate or handle a passphrase. The graphical program does handle one; see + the graphical program below. Neither can evaluate passphrase strength, and + neither can prevent a passphrase from remaining in Python objects or in the + child process's standard input buffer. - A malicious or failing `bitcoin-cli`, Core instance, configuration, or host can violate the destination boundary. Process termination, power loss, or a Core failure can prevent application cleanup; codex32 reports when locking @@ -231,7 +234,7 @@ signing setup belong to Bitcoin Core's maintained v32 workflow. | Process boundary | codex32 invokes the reviewed `bitcoin-cli` from `PATH` as a child without a shell, direct RPC socket, wallet database, or wallet-creation operation. Every call uses loopback and the selected chain. | | Destination | Only an empty descriptor wallet with private keys enabled, no external signer, transactions, descriptors, keypool entries, or active scan is eligible. One eligible wallet is offered directly; multiple wallets are selected by number. New wallets are detected by polling, and rejection returns to every eligible wallet. The escaped name is confirmed exactly. | | Seed source | The original ceremony result or validated recovered master seed supplies root-xprv private descriptors for Core's reported chain. After import, Core v32's wallet HD-key RPCs derive the requested BIP44, BIP49, BIP84, and BIP86 account xpubs. | -| Secret channel | Private descriptor JSON is sent only through the child's standard input. It is absent from arguments, ordinary output, and diagnostics. codex32 has no passphrase channel and suppresses raw Core errors. | +| Secret channel | Private descriptor JSON is sent only through the child's standard input. It is absent from arguments, ordinary output, and diagnostics. The library and the command-line programs have no passphrase channel, and raw Core errors are suppressed. | | Revalidation | Every destination property is checked again immediately before import. Every private import must succeed before public verification begins. `gethdkeys` must expose one private wallet root; `derivehdkey` must return the requested hardened account paths with one consistent fingerprint and the correct network xpub/tpub version. `getdescriptorinfo` then validates and expands the fixed public templates, and the exact eight active descriptors must match Core's accepted set. | | Relocking | Once Core reports an encrypted private-key wallet unlocked, a `finally`-protected obligation requests `walletlock` and verifies the locked state after success, failure, state change, or interruption. | @@ -245,10 +248,56 @@ and escaped for presentation; they never become shell syntax. A failure after share-string confirmation leaves valid shares but an incomplete wallet initialization. +### The graphical program + +`codex32-gui` shares every control above, because import and revalidation remain +`BitcoinCore.initialize`. It declares three departures, all confined to +`codex32_gui/wallet_setup.py`, the only module in that package that imports the +Core adapter. + +| 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. | +| Wallet creation | `createwallet` may be issued once, with `wallet_name`, `disable_private_keys=false`, `blank=true`, and a `passphrase` only when one was given. No other option is sent, and the resulting wallet must pass the same eligibility test as any other destination before it is used. Names are restricted to printable text without leading or trailing spaces, and may not contain a slash or be `.` or `..`, so a name can neither span the one-argument-per-line channel nor describe a path. | +| Relocking | Every wallet this program unlocks carries a `finally`-protected obligation of its own, in `wallet_setup.fill`, that requests `walletlock` and verifies `unlocked_until` is zero. The library's obligation is armed only after it has chosen a wallet, so a refusal raised before that point would otherwise leave an unlocked wallet open until Bitcoin Core's own timeout. Worker threads are not daemons, so closing the window during an import runs both obligations rather than skipping them. | + +Destination selection is unchanged and is not delegated to prompt wording. The +program answers the library's selection prompts only for a name the operator +already chose on screen, confirms that exact name when the library asks again, +and otherwise raises rather than answering, so a stale or unexpected listing can +produce a refusal but never a different wallet. The library's terminal-only +waiting loops are refused for the same reason. The chain the operator chose is +confirmed against the one connected, because the library asks which chain to use +only while more than one answers. On screen a wallet is chosen by the position of +its row, never by the text of its label, and Core's text is rendered without +Pango markup, so a wallet name cannot hide or impersonate another. + +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 +to the zeroization limitation above. + +Two disclosure channels belong to the toolkit rather than to this program, and +are named here because a static import check cannot see either. + +- **The accessibility bus.** GTK publishes every label and entry on the desktop's + shared accessibility bus, where any program running as the same user can read + them and can invoke a password entry's own reveal action. The window therefore + sets `GTK_A11Y=none` before GTK starts, in `codex32_gui/__init__.py`, and + leaves the setting alone when the operator has already chosen one, so + `GTK_A11Y=atspi codex32-gui` restores screen-reader support for anyone who + needs it and accepts that exposure. +- **The primary selection.** Selecting text inside the entry field hands it to + the primary selection, which a clipboard manager may copy to disk. The field + takes the selection back on the next main-loop turn, which closes the window + to one turn but does not remove it; an operator who runs a clipboard manager + should not select the text of a card. + ## Verification map | Boundary | Focused evidence | |---|---| +| Graphical program | [`test_gui_boundaries.py`](../../tests/test_gui_boundaries.py), [`test_gui_reading.py`](../../tests/test_gui_reading.py), and [`test_gui_wallet_setup.py`](../../tests/test_gui_wallet_setup.py) | | Parsing and profiles | [`test_bech32.py`](../../tests/test_bech32.py), [`test_bip93.py`](../../tests/test_bip93.py), and [`test_profiles.py`](../../tests/test_profiles.py) | | Creation, sharing, and recovery | [`test_generation.py`](../../tests/test_generation.py), [`test_sharing.py`](../../tests/test_sharing.py), and the BIP93 vectors under `tests/data/` | | Correction | [`test_correction_bch.py`](../../tests/test_correction_bch.py), [`test_correction_indel.py`](../../tests/test_correction_indel.py), [`correction_capture.py`](../../tools/correction_capture.py), and [`differential_correction.py --verify`](../../tools/differential_correction.py) | diff --git a/docs/user/gui.md b/docs/user/gui.md new file mode 100644 index 0000000..3fe3ce4 --- /dev/null +++ b/docs/user/gui.md @@ -0,0 +1,130 @@ +# codex32 graphical user interface + +`codex32-gui` provides the main `ms32` jobs in a graphical user interface. +Recovery text stays on screen only while it is needed. + +| Task | Command | +|---|---| +| Set up a new wallet | `ms32 create` | +| Restore my wallet | `ms32 wallet` | +| Check a card | `ms32 check` | +| Repair a damaged card | `ms32 correct` | +| Replace a lost card | `ms32 share` | +| Show my master seed | `ms32 secret` | + +`ms32 xprv`, Core Lightning secrets, and BIP39 worksheet profiles remain +command-line tasks. + +## Install + +GTK 4 and libadwaita come from the operating system: + +```bash +sudo apt install python3-gi gir1.2-gtk-4.0 gir1.2-adw-1 +``` + +If those packages are already installed, skip that step. Then create the virtual +environment and install codex32: + +```bash +python3 -m venv --system-site-packages .venv +.venv/bin/pip install 'codex32[gui]' +.venv/bin/codex32-gui +``` + +`--system-site-packages` lets the virtual environment import the system PyGObject +package. The base `codex32` install still has no third-party runtime dependency. + +`codex32-gui` takes no arguments. Never put a secret in one. + +The GUI disables GTK's accessibility bus by default because it can expose seed +text and passphrases to other desktop processes. Screen-reader users can opt in +with `GTK_A11Y=atspi codex32-gui`. + +## Before you start + +Start Bitcoin Core first. Wallet setup and restore discover it before any card is +created or entered. Practise on signet with `bitcoin-qt -signet -server`. + +Checking, repairing, replacing a card, and showing the master seed do not open a +Bitcoin Core wallet. + +## Entering cards + +A card never contains **B**, **I**, **O**, or **1**. If one is entered, the GUI +reports the likely look-alike instead of silently deleting it. + +When recovering from several cards, use `?` for a header or card-index character +you genuinely cannot read. **Check a card** requires the printed character. + +## Making a backup + +The recommended layout is three cards where any two recover the wallet. One card +can be lost, and one card alone reveals nothing. + +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. + +Copy the final wallet details to the +[wallet record](wallet-verification-record.html) and store it separately from the +cards. + +## Repairing a damaged card + +Type what you can read and `?` for each unknown character. The repair field +preserves damaged headers and explicit `?` characters so the correction engine +sees what is actually on the card. **Suggest a repair** appears whenever the +input is within the supported correction bounds, including missing or extra +characters. + +A repair candidate may highlight groups that changed. Those are changes, not +proven error locations. Hold your card next to the screen and compare the entire +string, character by character. + +When too little checksum remains, the GUI requires literal `YES` before showing a +candidate. Completing new hand-written data can lock earlier transcription +errors in; recovery from a damaged card can produce a valid-looking but wrong +guess. Never erase or replace a card's ending just to make it validate. If the +funds matter, stop and get help. + +If more than one repair fits, none is shown. A repaired card is always presented +as a guess; copy it to a fresh card and verify the restored wallet's master +fingerprint. + +## What the graphical user interface never claims + +A share records its recovery threshold and its own index, not how many shares +exist. The GUI can say “any 2 cards recover the wallet”; it cannot infer “2 of +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. + +## Secret handling + +The GUI writes no settings, recent-file list, log, or clipboard data containing +recovery text. Card text is cleared when its page is left. Bitcoin Core secrets +go to `bitcoin-cli` on standard input, not command arguments. + +On X11, selecting entry text briefly owns the primary selection, which some +clipboard managers persist. Avoid selecting recovery text. + +Ordinary entry supplies and protects `MS1` and blocks forward typing after an +invalid header unless you explicitly choose to keep damaged text. **Repair a +damaged card** accepts damaged headers. Read-back supplies nothing and performs +no correction. + +## If something goes wrong + +GUI failure does not alter a paper card. Fix Bitcoin Core, then use **Restore my +wallet** with the existing cards; do not create a new backup. + +Restore asks you to compare wallet identity, especially the master fingerprint, +with your wallet record. The GUI always uses account 0. For another account, use +`ms32 wallet --account N`. diff --git a/docs/user/guide.md b/docs/user/guide.md index eedd5a7..132f9b3 100644 --- a/docs/user/guide.md +++ b/docs/user/guide.md @@ -49,6 +49,10 @@ one part of a split master seed. A **wallet record** describes the expected wallet without containing recovery secrets; store it separately from every recovery card. +The same Bitcoin master-seed jobs are available in a graphical user interface. +If you would rather not use a terminal, see [the codex32 GUI](gui.md); it covers +creation, restoration, checking, repair, and replacing a card. + ## Recommended: dedicated Bitcoin Core spending wallet ### 1. Prepare