diff --git a/.gitignore b/.gitignore index 02a62f233..937565dd2 100644 --- a/.gitignore +++ b/.gitignore @@ -82,7 +82,8 @@ simulator/flash !tests/files/* # ignore build-release files -krux-*/ktool* +ktool-* +*mpy.sig # IDE files .vscode diff --git a/CHANGELOG.md b/CHANGELOG.md index ceb769ada..df852f236 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,11 @@ ### New Device Support: Embed Fire This device shares similarities with the WonderMV but stands out with its larger 2.4" touchscreen. +### Krux apps (Kapps) +New tool for executing developer-signed utility apps that extend Krux functionality. Includes two initial Kapps: +- Nostr: Create or load your key using NIP-06 or NIP-19, and airgap-sign events +- Steganography: Concel data within BMP image files + ### New Device Support: WonderK PRO From the wonderful land of Korea, a new creation arrives: the WonderK PRO. Created by an entrepreneur who loves the Krux project, the WonderK follows in the footsteps of the WonderMV, but boasts a larger 2.8" display! Computer simulator for the WonderK device is also included. diff --git a/Dockerfile b/Dockerfile index b361be165..12f831454 100644 --- a/Dockerfile +++ b/Dockerfile @@ -70,6 +70,8 @@ RUN cd kendryte-gnu-toolchain && \ --recursive \ --depth 1 + + RUN cd kendryte-gnu-toolchain && \ export PATH=$PATH:/opt/kendryte-toolchain/bin && \ ./configure --prefix=/opt/kendryte-toolchain --with-cmodel=medany --with-arch=rv64imafc --with-abi=lp64f --enable-threads=posix --enable-libatomic && \ @@ -85,30 +87,43 @@ RUN python3 -m venv /kruxenv RUN /kruxenv/bin/pip install astor RUN /kruxenv/bin/pip install pyserial==3.4 +# Provide memset_s for host tools (mpy-cross) that expect it during link time +RUN set -eux; \ + cat > /tmp/memset_s.c <<'EOF' +#include +#include -############ -# build-software -# copy vendor, firmware and Krux (src) files -# install embit dependency -############ -FROM build-base AS build-software +typedef size_t rsize_t; +typedef int errno_t; + +errno_t memset_s(void *dest, rsize_t destsz, int ch, rsize_t count) { + if (!dest) return EINVAL; + if (count > destsz) return EINVAL; + volatile unsigned char *p = (volatile unsigned char *)dest; + while (count--) *p++ = (unsigned char)ch; + __asm__ __volatile__("" : : : "memory"); /* compiler barrier */ + return 0; +} +EOF +RUN set -eux; \ + gcc -O2 -c /tmp/memset_s.c -o /tmp/memset_s.o; \ + ar rcs /usr/local/lib/libmemset_s.a /tmp/memset_s.o; \ + nm -g /usr/local/lib/libmemset_s.a | grep -w memset_s + +############### +# build-vendor +# everything except COPY ./src +############### +FROM build-base AS build-vendor ARG DEVICE="maixpy_m5stickv" ENV DEVICE_BUILTIN="firmware/MaixPy/projects/${DEVICE}/builtin_py" RUN mkdir /src WORKDIR /src -# copy vendor to WORKDIR (src) COPY ./vendor vendor - -# clean vendor/urtypes RUN find vendor/urtypes -type d -name '__pycache__' -exec rm -rv {} + -depth - -# clean vendor/foundation-ur-py RUN find vendor/foundation-ur-py -type d -name '__pycache__' -exec rm -rv {} + -depth - -# install vendor/embit RUN /kruxenv/bin/pip install vendor/embit -# clean vendor/embit RUN rm -rf vendor/embit/src/embit/util/prebuilt && \ rm -rf vendor/embit/src/embit/liquid && \ rm -f vendor/embit/src/embit/psbtview.py && \ @@ -119,25 +134,41 @@ RUN rm -rf vendor/embit/src/embit/util/prebuilt && \ rm -f vendor/embit/src/embit/util/py_ripemd160.py && \ find vendor/embit -type d -name '__pycache__' -exec rm -rv {} + -depth -# copy firmware to WORKDIR (src) COPY ./firmware firmware -# clean firmware RUN find firmware -type d -name '__pycache__' -exec rm -rv {} + -depth - -# copy all vendors to DEVICE_BUILTIN RUN cp -r vendor/urtypes/src/urtypes "${DEVICE_BUILTIN}" RUN cp -r vendor/foundation-ur-py/src/ur "${DEVICE_BUILTIN}" RUN cp -r vendor/embit/src/embit "${DEVICE_BUILTIN}" -# copy Krux (src) to WORKDIR (src) +############ +# build-software +# copy vendor, firmware and Krux (src) files +# install embit dependency +############ +FROM build-vendor AS build-software +ARG DEVICE="maixpy_m5stickv" +ENV DEVICE_BUILTIN="firmware/MaixPy/projects/${DEVICE}/builtin_py" +WORKDIR /src + COPY ./src src -# rename boot.py RUN mv src/boot.py src/_boot.py -# clean it RUN find src -type d -name '__pycache__' -exec rm -rv {} + -depth -# copy it to DEVICE_BUILTIN RUN cp -r src/. "${DEVICE_BUILTIN}" +################ +# build-safeclib +################ +FROM build-vendor AS build-safeclib +RUN apt-get update -y && apt-get install --no-install-recommends -y \ + git make autoconf automake libtool pkg-config ca-certificates \ + && rm -rf /var/lib/apt/lists/* +WORKDIR /src +RUN git clone --depth 1 https://github.com/rurban/safeclib.git +WORKDIR /src/safeclib +RUN ./build-aux/autogen.sh \ + && ./configure --prefix=/opt/safeclib \ + && make -j"$(nproc)" \ + && make install ############ # build-firmware @@ -151,6 +182,17 @@ WORKDIR /src/firmware/MaixPy # overrides the DEVICE specific C files (font, sensor, ...) in componets/micropython RUN cp -rf projects/"${DEVICE}"/compile/overrides/. ./ +# ensure host mpy-cross link gets memset_s +ENV LIB="-lm /usr/local/lib/libmemset_s.a" + +# MaixPy/MicroPython firmware link: riscv64-unknown-elf toolchain doesn't ship memset_s. +# Use MicroPython's internal secure memset helper instead (already present in gc.c). +RUN set -eux; \ + gc="/src/firmware/MaixPy/components/micropython/core/py/gc.c"; \ + test -f "$gc"; \ + grep -q "mp_memset_s" "$gc"; \ + sed -i -E 's/\bmemset_s[[:space:]]*\(/mp_memset_s(/g' "$gc" + RUN cd projects/"${DEVICE}" && \ /kruxenv/bin/python project.py clean && \ /kruxenv/bin/python project.py distclean && \ @@ -158,6 +200,48 @@ RUN cd projects/"${DEVICE}" && \ mv build/maixpy.bin build/firmware.bin +########### +# build-mpy +########### +FROM build-safeclib AS build-mpy +RUN apt-get update -y && apt-get install --no-install-recommends -y \ + git make python3 ca-certificates \ + && rm -rf /var/lib/apt/lists/* +COPY --from=build-safeclib /opt/safeclib /opt/safeclib +ENV LD_LIBRARY_PATH=/opt/safeclib/lib + +WORKDIR /src +COPY firmware/MaixPy/components/micropython/core/mpy-cross/ \ + firmware/MaixPy/components/micropython/core/mpy-cross/ + +# Provide memset_s for host tool only (mpy-cross). No upstream repo edits. +RUN set -eux; \ + cat > /tmp/memset_s.c <<'EOF' +#include +#include + +typedef size_t rsize_t; +typedef int errno_t; + +errno_t memset_s(void *dest, rsize_t destsz, int ch, rsize_t count) { + if (!dest) return EINVAL; + if (count > destsz) return EINVAL; + volatile unsigned char *p = (volatile unsigned char *)dest; + while (count--) *p++ = (unsigned char)ch; + __asm__ __volatile__("" : : : "memory"); /* compiler barrier */ + return 0; +} +EOF +RUN set -eux; \ + gcc -O2 -c /tmp/memset_s.c -o /tmp/memset_s.o; \ + ar rcs /usr/local/lib/libmemset_s.a /tmp/memset_s.o; \ + nm -g /usr/local/lib/libmemset_s.a | grep -w memset_s + +WORKDIR /src/firmware/MaixPy/components/micropython/core/mpy-cross +RUN set -eux; \ + make V=1 LIB="-lm /usr/local/lib/libmemset_s.a"; \ + chmod +x ./mpy-cross + ############ # build # creation of kboot.kfpkg using firmware.bin @@ -171,3 +255,15 @@ RUN cp /src/firmware/MaixPy/projects/"${DEVICE}"/build/firmware.bin . RUN sed -i -e 's/\r$//' *.sh RUN ./CLEAN.sh && ./BUILD.sh + +############## +# build kapps +# compilation of kapps inside kapps/ folders +############# +FROM build-mpy AS build-kapp +ARG KAPP="nostr" +WORKDIR /work +COPY kapps/${KAPP}.py /work/${KAPP}.py +RUN mkdir -p /out \ + && /src/firmware/MaixPy/components/micropython/core/mpy-cross/mpy-cross \ + -X heapsize=4194304 -O2 -o "/out/${KAPP}.mpy" "/work/${KAPP}.py" diff --git a/docs/getting-started/features/tools.en.md b/docs/getting-started/features/tools.en.md index 48feb8274..4db5dd034 100644 --- a/docs/getting-started/features/tools.en.md +++ b/docs/getting-started/features/tools.en.md @@ -3,6 +3,16 @@ Here are some useful tools that are available as soon as Krux starts! These are +### Load Krux app + + + +Run developer-signed Krux applications (Kapps) that are not suited to be part of the main firmware. Copy its `.mpy` file and corresponding signature to an SD card to load it onto the device. When executed, the Kapp is stored in the user's flash memory (just like custom settings) and this process modifies the last two words of the [Tamper Detection](tamper-detection.md#tamper-check-flash-hash-tc-flash-hash-a-tamper-detection-tool) (User's Region). + +For example, the **Nostr Kapp** allows converting a mnemonic into a Nostr `nsec` key and air-gapped event signing. + +
+ ### Datum Tool @@ -111,6 +121,32 @@ This option permanently removes all stored encrypted mnemonics, settings and `TC
+### Paper Wallet + +The **Paper Wallet** Kapp loads and exports Bitcoin paper wallets compatible with WIF, CSV and Base64 private keys; currently we not support encrypted [BIP38](https://bips.xyz/38) wallets[ˆ1]. Also we do not generate any paper wallet[^2]. + + +#### Loading wallets + +Load it via **Tools > Load Krux app** after copying the signed `.mpy` file to an SD card. + +Load one or more WIF ([Wallet Import Format](https://en.bitcoin.it/wiki/Wallet_import_format)) private keys by scanning QR codes or reading encrypted wallets from the SD card. Supported input formats include raw WIF, Base64-encoded WIF, and CSV. When a single key is loaded, Krux creates a **single wallet**; loading two or more keys creates a **bulk wallet** identified by a combined checksum. + +#### Backup key + +Encrypt and store the loaded wallet using Krux's encrypted format (KEF). Wallets can be saved to the SD card or to the device's internal flash memory. For bulk wallets, each key is encrypted and stored individually. + +#### Export key + +Export the wallet in one of several plaintext or encrypted formats: + +- **Base64** (.txt) - WIF re-encoded as Base64. For bulk wallets, exported as CSV with Base64-encoded WIFs. +- **Hex** (.hex) - WIF re-encoded as hexadecimal. For bulk wallets, exported as CSV with hex-encoded WIFs. +- **CSV** (.csv) - Comma-separated format: `index, "address", "wif"`. +- **SVG** (.svg) - Styled paper wallet in the bitaddress.org style with SHARE (public address + QR) and SECRET (private key + QR) sections separated by a fold line. For bulk wallets, all wallets are combined into a single SVG file. Each format can optionally be encrypted before saving. + +
+ ### Remove Mnemonic diff --git a/docs/img/maixpy_amigo/krux-apps-300.en.png b/docs/img/maixpy_amigo/krux-apps-300.en.png new file mode 100644 index 000000000..740999f0c Binary files /dev/null and b/docs/img/maixpy_amigo/krux-apps-300.en.png differ diff --git a/docs/img/maixpy_amigo/tools-options-300.en.png b/docs/img/maixpy_amigo/tools-options-300.en.png index 2297066ef..33b511d76 100644 Binary files a/docs/img/maixpy_amigo/tools-options-300.en.png and b/docs/img/maixpy_amigo/tools-options-300.en.png differ diff --git a/docs/img/maixpy_m5stickv/krux-apps-250.en.png b/docs/img/maixpy_m5stickv/krux-apps-250.en.png new file mode 100644 index 000000000..9e287f2af Binary files /dev/null and b/docs/img/maixpy_m5stickv/krux-apps-250.en.png differ diff --git a/docs/img/maixpy_m5stickv/tools-options-250.en.png b/docs/img/maixpy_m5stickv/tools-options-250.en.png index 9c39b5dda..9cba9f00e 100644 Binary files a/docs/img/maixpy_m5stickv/tools-options-250.en.png and b/docs/img/maixpy_m5stickv/tools-options-250.en.png differ diff --git a/firmware/Kboot b/firmware/Kboot index 66d7848d1..cb53021a4 160000 --- a/firmware/Kboot +++ b/firmware/Kboot @@ -1 +1 @@ -Subproject commit 66d7848d1c31f5defc9197fadb7eca503d7f9b98 +Subproject commit cb53021a4591400377bbd56a50da821cbd513bb7 diff --git a/firmware/MaixPy b/firmware/MaixPy index faf4aa9bf..0e84adc7d 160000 --- a/firmware/MaixPy +++ b/firmware/MaixPy @@ -1 +1 @@ -Subproject commit faf4aa9bff6a3e0e874f460288babf2154e0ce01 +Subproject commit 0e84adc7d4acd6205f1248b411fbc8db36a171b0 diff --git a/i18n/i18n.py b/i18n/i18n.py index 0e0c1e15f..75fef0683 100644 --- a/i18n/i18n.py +++ b/i18n/i18n.py @@ -19,6 +19,7 @@ # 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. +# pylint: disable=invalid-name import binascii import sys diff --git a/i18n/translations/de-DE.json b/i18n/translations/de-DE.json index f2194616a..9a9f1fe7f 100644 --- a/i18n/translations/de-DE.json +++ b/i18n/translations/de-DE.json @@ -19,7 +19,10 @@ "Additional entropy from camera required for %s": "Zusätzliche Entropie von der Kamera erforderlich für %s", "Address": "Adresse", "Align camera and backup plate properly.": "Richte Kamera und Sicherungsplatte richtig aus.", + "Allow Krux apps": "Krux-Apps zulassen", + "Allow in settings first!": "Erlaube zuerst Einstellungen!", "Anti-glare mode": "Blendschutzmodus", + "App will be stored internally on flash.": "Die App wird intern auf Flash gespeichert.", "Appearance": "Aussehen", "Are you sure?": "Bist Du sicher?", "BGR Colors": "BGR-Farben", @@ -93,6 +96,7 @@ "Erasing user's data…": "Benutzerdaten werden gelöscht…", "Error:": "Fehler:", "Esc": "Esc", + "Execute %s Krux app?": "%s Krux-App ausführen?", "Explore files?": "Dateien durchsuchen?", "Export Addresses": "Adressen exportieren", "Exporting %s to SD card…": "%s wird auf SD-Karte exportiert…", @@ -151,6 +155,7 @@ "Line Delay": "Leitungsverzögerung", "Line:": "Linie:", "List Addresses": "Adressen auflisten", + "Load Krux app": "Krux-App laden", "Load Mnemonic": "Mnemonic laden", "Load Wallet": "Wallet laden", "Load a trusted wallet descriptor to view addresses?": "Einen vertrauenswürdigen Wallet-Deskriptor laden, um Adressen anzuzeigen?", @@ -285,6 +290,7 @@ "Spend (%d):": "Ausgabe (%d):", "Spend:": "Ausgaben:", "Standard mode": "Standardmodus", + "Startup Kapp": "Startup Kapp", "Static": "Statisch", "Stats for Nerds": "Statistiken für Nerds", "Store on Flash": "Auf Flash speichern", @@ -317,6 +323,7 @@ "Type Key": "Schlüssel eingeben", "Undo": "Widerrufen", "Unit": "Einheit", + "Unsigned apps found in flash will be deleted.": "Nicht signierte Apps, die in Flash gefunden wurden, werden gelöscht.", "Update KEF ID?": "KEF-ID aktualisieren?", "Update QR Label?": "QR-Etikett aktualisieren?", "Upgrade complete.": "Upgrade abgeschlossen.", diff --git a/i18n/translations/es-MX.json b/i18n/translations/es-MX.json index 2784413d1..9922310a3 100644 --- a/i18n/translations/es-MX.json +++ b/i18n/translations/es-MX.json @@ -19,7 +19,10 @@ "Additional entropy from camera required for %s": "Se requiere entropía adicional de la cámara para %s", "Address": "Dirección", "Align camera and backup plate properly.": "Alinea la cámara y la placa de respaldo correctamente.", + "Allow Krux apps": "Permitir aplicaciones Krux", + "Allow in settings first!": "¡Permitir en la configuración primero!", "Anti-glare mode": "Modo antirreflejo", + "App will be stored internally on flash.": "La aplicación se almacenará internamente en flash.", "Appearance": "Apariencia", "Are you sure?": "¿Estás seguro?", "BGR Colors": "Colores BGR", @@ -93,6 +96,7 @@ "Erasing user's data…": "Borrando los datos del usuario…", "Error:": "Error:", "Esc": "Esc", + "Execute %s Krux app?": "¿Ejecutar %s aplicación Krux?", "Explore files?": "¿Explorar archivos?", "Export Addresses": "Exportar direcciones", "Exporting %s to SD card…": "Exportando %s a la tarjeta SD…", @@ -151,6 +155,7 @@ "Line Delay": "Retraso de Línea", "Line:": "Línea:", "List Addresses": "Listar direcciones", + "Load Krux app": "Cargar aplicación Krux", "Load Mnemonic": "Importar Mnemónico", "Load Wallet": "Cargar Cartera", "Load a trusted wallet descriptor to view addresses?": "¿Cargar un descriptor de monedero de confianza para ver las direcciones?", @@ -285,6 +290,7 @@ "Spend (%d):": "Gastos (%d):", "Spend:": "Gasto:", "Standard mode": "Modo estándar", + "Startup Kapp": "Startup Kapp", "Static": "Estático", "Stats for Nerds": "Estadísticas para Entendidos", "Store on Flash": "Almacenar en Flash", @@ -317,6 +323,7 @@ "Type Key": "Introduce la clave", "Undo": "Deshacer", "Unit": "Unidad", + "Unsigned apps found in flash will be deleted.": "Se eliminarán las aplicaciones sin firmar que se encuentren en Flash.", "Update KEF ID?": "¿Actualizar ID de Kef?", "Update QR Label?": "¿Actualizar etiqueta QR?", "Upgrade complete.": "Actualización completa.", diff --git a/i18n/translations/fr-FR.json b/i18n/translations/fr-FR.json index d761a902f..64eb46395 100644 --- a/i18n/translations/fr-FR.json +++ b/i18n/translations/fr-FR.json @@ -19,7 +19,10 @@ "Additional entropy from camera required for %s": "Entropie supplémentaire de la caméra requise pour %s", "Address": "Adresse", "Align camera and backup plate properly.": "Alignez correctement la caméra et plaque de sauvegarde.", + "Allow Krux apps": "Autoriser les applications Krux", + "Allow in settings first!": "Autoriser d'abord dans les paramètres !", "Anti-glare mode": "Mode anti-reflets", + "App will be stored internally on flash.": "L'application sera stockée en interne sur flash.", "Appearance": "Apparence", "Are you sure?": "Es-tu sûr ?", "BGR Colors": "Couleurs BGR", @@ -93,6 +96,7 @@ "Erasing user's data…": "Effacement des données de l'utilisateur…", "Error:": "Erreur :", "Esc": "Esc", + "Execute %s Krux app?": "Exécuter l'application %s Krux ?", "Explore files?": "Explorer des fichiers ?", "Export Addresses": "Adresses d'exportation", "Exporting %s to SD card…": "Exportation de %s vers la carte SD…", @@ -151,6 +155,7 @@ "Line Delay": "Délai de Ligne", "Line:": "Ligne :", "List Addresses": "Listage d'Addresses", + "Load Krux app": "Charger l'application Krux", "Load Mnemonic": "Charger Mnémonique", "Load Wallet": "Charger le portefeuille", "Load a trusted wallet descriptor to view addresses?": "Charger un descripteur de portefeuille de confiance pour afficher les adresses ?", @@ -285,6 +290,7 @@ "Spend (%d):": "Dépense (%d) :", "Spend:": "Dépense :", "Standard mode": "Mode standard", + "Startup Kapp": "Startup Kapp", "Static": "Statique", "Stats for Nerds": "Statistiques pour les geeks", "Store on Flash": "Stocker sur flash", @@ -317,6 +323,7 @@ "Type Key": "Taper clé", "Undo": "Annuler", "Unit": "Unité", + "Unsigned apps found in flash will be deleted.": "Les applications non signées trouvées dans Flash seront supprimées.", "Update KEF ID?": "Mettre à jour l'ID KEF ?", "Update QR Label?": "Mettre à jour l'étiquette QR ?", "Upgrade complete.": "Mise à jour complète.", diff --git a/i18n/translations/ja-JP.json b/i18n/translations/ja-JP.json index facbd1edc..8688e6896 100644 --- a/i18n/translations/ja-JP.json +++ b/i18n/translations/ja-JP.json @@ -19,7 +19,10 @@ "Additional entropy from camera required for %s": "%sにはカメラからの追加エントロピーが必要です", "Address": "アドレス", "Align camera and backup plate properly.": "カメラとバックプレートを正しく整列させてください.", + "Allow Krux apps": "Kruxアプリを許可する", + "Allow in settings first!": "最初に設定で許可してください!", "Anti-glare mode": "アンチグレアモード", + "App will be stored internally on flash.": "アプリはフラッシュに内部保存されます。", "Appearance": "外観", "Are you sure?": "よろしいですか?", "BGR Colors": "BGRカラー", @@ -93,6 +96,7 @@ "Erasing user's data…": "ユーザーのデータを消去しています…", "Error:": "エラー:", "Esc": "エスク", + "Execute %s Krux app?": "%s Kruxアプリを実行しますか?", "Explore files?": "アーカイブ探索?", "Export Addresses": "住所をエクスポート", "Exporting %s to SD card…": "%sをSDカードにエクスポートしています…", @@ -151,6 +155,7 @@ "Line Delay": "ライン遅延", "Line:": "ライン:", "List Addresses": "アドレスリスト", + "Load Krux app": "Kruxアプリを読み込む", "Load Mnemonic": "ニーモニックをロード", "Load Wallet": "ウォレットをロード", "Load a trusted wallet descriptor to view addresses?": "信頼できるウォレット記述子をロードしてアドレスを表示しますか?", @@ -285,6 +290,7 @@ "Spend (%d):": "支出(%d):", "Spend:": "支出:", "Standard mode": "標準モード", + "Startup Kapp": "スタートアップKapp", "Static": "静止画", "Stats for Nerds": "オタクのための統計", "Store on Flash": "フラッシュに保存する", @@ -317,6 +323,7 @@ "Type Key": "キーを入力する", "Undo": "取り消し", "Unit": "ユニット", + "Unsigned apps found in flash will be deleted.": "Flashで見つかった署名されていないアプリは削除されます。", "Update KEF ID?": "KEF IDを更新しますか?", "Update QR Label?": "QRラベルを更新しますか?", "Upgrade complete.": "アップグレードが完了しました.", diff --git a/i18n/translations/ko-KR.json b/i18n/translations/ko-KR.json index 6b197e5b3..ef0d41a33 100644 --- a/i18n/translations/ko-KR.json +++ b/i18n/translations/ko-KR.json @@ -19,7 +19,10 @@ "Additional entropy from camera required for %s": "%s 에 필요한 카메라의 추가 엔트로피", "Address": "주소", "Align camera and backup plate properly.": "카메라와 보조 플레이트를 올바르게 정렬하십시오.", + "Allow Krux apps": "Krux 앱 허용", + "Allow in settings first!": "먼저 설정에서 허용하세요!", "Anti-glare mode": "눈부심 방지 모드", + "App will be stored internally on flash.": "앱은 내부적으로 플래시로 저장됩니다.", "Appearance": "디스플레이", "Are you sure?": "계속하시겠습니까?", "BGR Colors": "BGR 색상", @@ -93,6 +96,7 @@ "Erasing user's data…": "사용자 데이터 삭제 중…", "Error:": "오류:", "Esc": "Esc", + "Execute %s Krux app?": "%s KRUX 앱을 실행하시겠습니까?", "Explore files?": "파일을 탐색하시겠습니까?", "Export Addresses": "주소 내보내기", "Exporting %s to SD card…": "%s 을 (를) SD 카드로 내보내는 중…", @@ -151,6 +155,7 @@ "Line Delay": "줄 지연", "Line:": "줄:", "List Addresses": "주소 목록", + "Load Krux app": "Krux 앱 로드", "Load Mnemonic": "니모닉 불러오기", "Load Wallet": "이대로 불러오기", "Load a trusted wallet descriptor to view addresses?": "주소를 보기위해 신뢰할 수 있는 월렛 디스크립터를 불러오시겠습니까?", @@ -285,6 +290,7 @@ "Spend (%d):": "Spend (%d):", "Spend:": "지출:", "Standard mode": "표준 모드", + "Startup Kapp": "스타트업 Kapp", "Static": "Static", "Stats for Nerds": "전문가를 위한 통계", "Store on Flash": "플래시 메모리에 저장", @@ -317,9 +323,10 @@ "Type Key": "비밀번호 입력", "Undo": "실행 취소", "Unit": "단위", + "Unsigned apps found in flash will be deleted.": "플래시에서 찾은 서명되지 않은 앱은 삭제됩니다.", "Update KEF ID?": "KEF ID를 업데이트하시겠습니까?", "Update QR Label?": "QR 레이블을 업데이트하시겠습니까?", - "Upgrade complete.": "업그레이드가 완료되었습니다.", + "Upgrade complete.": "업그레이드가 완료되었습니다", "Use a black background surface.": "검은색 배경 화면을 사용하십시오.", "Use camera's entropy to create a new mnemonic": "카메라의 엔트로피를 사용하여 새로운 니모닉을 생성하십시오", "Use current value?": "현재 수치", diff --git a/i18n/translations/nl-NL.json b/i18n/translations/nl-NL.json index 7b9517beb..57093b35d 100644 --- a/i18n/translations/nl-NL.json +++ b/i18n/translations/nl-NL.json @@ -19,7 +19,10 @@ "Additional entropy from camera required for %s": "Extra entropie van camera vereist voor %s", "Address": "Adres", "Align camera and backup plate properly.": "Richt de camera en back-upplaat op de juiste manier.", + "Allow Krux apps": "Krux-apps toestaan", + "Allow in settings first!": "Sta eerst instellingen toe!", "Anti-glare mode": "Anti-verblindingsmodus", + "App will be stored internally on flash.": "De app wordt intern opgeslagen op de flitser.", "Appearance": "Uiterlijk", "Are you sure?": "Weet je het zeker?", "BGR Colors": "BGR-kleuren", @@ -93,6 +96,7 @@ "Erasing user's data…": "Gebruikersgegevens worden gewist…", "Error:": "Fout:", "Esc": "Esc", + "Execute %s Krux app?": "%s Krux-app uitvoeren?", "Explore files?": "Bestanden verkennen?", "Export Addresses": "Adressen exporteren", "Exporting %s to SD card…": "Exporteren van %s naar SD-kaart…", @@ -151,6 +155,7 @@ "Line Delay": "Lijn vertraging", "Line:": "Lijn:", "List Addresses": "Adressenlijst", + "Load Krux app": "Krux-app laden", "Load Mnemonic": "Geheugensteun laden", "Load Wallet": "Portemonnee laden", "Load a trusted wallet descriptor to view addresses?": "Een vertrouwde portemonnee descriptor laden om adressen te bekijken?", @@ -285,6 +290,7 @@ "Spend (%d):": "Uitgaven (%d):", "Spend:": "Uitgaven:", "Standard mode": "Standaardmodus", + "Startup Kapp": "Kapp opstarten", "Static": "Statisch", "Stats for Nerds": "Statistieken voor nerds", "Store on Flash": "Opslaan op apparaat", @@ -317,6 +323,7 @@ "Type Key": "Voer sleutel in", "Undo": "Ongedaan maken", "Unit": "Eenheid", + "Unsigned apps found in flash will be deleted.": "Niet-ondertekende apps die in Flash worden gevonden, worden verwijderd.", "Update KEF ID?": "KEF-ID bijwerken?", "Update QR Label?": "QR-label bijwerken?", "Upgrade complete.": "Upgrade afgerond.", diff --git a/i18n/translations/pt-BR.json b/i18n/translations/pt-BR.json index 38c4f3755..61ab7af62 100644 --- a/i18n/translations/pt-BR.json +++ b/i18n/translations/pt-BR.json @@ -19,7 +19,10 @@ "Additional entropy from camera required for %s": "Entropia adicional da câmera é necessária para %s", "Address": "Endereço", "Align camera and backup plate properly.": "Alinhe a câmera e a placa de backup corretamente.", + "Allow Krux apps": "Permitir aplicativos Krux", + "Allow in settings first!": "Permita nas configurações primeiro!", "Anti-glare mode": "Modo antirreflexo", + "App will be stored internally on flash.": "O aplicativo será armazenado internamente em flash.", "Appearance": "Aparência", "Are you sure?": "Tem certeza?", "BGR Colors": "Cores BGR", @@ -93,6 +96,7 @@ "Erasing user's data…": "Apagando dados do usuário…", "Error:": "Erro:", "Esc": "Esc", + "Execute %s Krux app?": "Executar %s aplicativo Krux?", "Explore files?": "Explorar arquivos?", "Export Addresses": "Exportar endereços", "Exporting %s to SD card…": "Exportando %s para o cartão SD…", @@ -151,6 +155,7 @@ "Line Delay": "Atraso de Linha", "Line:": "Linha:", "List Addresses": "Listar Endereços", + "Load Krux app": "Carregar Krux app", "Load Mnemonic": "Carregar Mnemônico", "Load Wallet": "Carregar Carteira", "Load a trusted wallet descriptor to view addresses?": "Carregar um descritor de carteira para visualizar endereços?", @@ -285,6 +290,7 @@ "Spend (%d):": "Gastos (%d):", "Spend:": "Gasto:", "Standard mode": "Modo padrão", + "Startup Kapp": "Kapp de inicialização", "Static": "Estático", "Stats for Nerds": "Estatísticas para nerds", "Store on Flash": "Armazenar na memória flash", @@ -317,6 +323,7 @@ "Type Key": "Digite a Chave", "Undo": "Desfazer", "Unit": "Unidade", + "Unsigned apps found in flash will be deleted.": "Aplicativos não assinados encontrados em flash serão excluídos.", "Update KEF ID?": "Atualizar KEF ID?", "Update QR Label?": "Atualizar etiqueta QR?", "Upgrade complete.": "Atualização concluída.", diff --git a/i18n/translations/ru-RU.json b/i18n/translations/ru-RU.json index cce43232f..ac912dab3 100644 --- a/i18n/translations/ru-RU.json +++ b/i18n/translations/ru-RU.json @@ -19,7 +19,10 @@ "Additional entropy from camera required for %s": "Требуется дополнительная энтропия от камеры для %s", "Address": "Адрес", "Align camera and backup plate properly.": "Правильно совместите камеру и резервную пластину.", + "Allow Krux apps": "Разрешить приложения Krux", + "Allow in settings first!": "Сначала разрешите в настройках!", "Anti-glare mode": "Антибликовый режим", + "App will be stored internally on flash.": "Приложение будет храниться во флэш-памяти.", "Appearance": "Внешний Вид", "Are you sure?": "Вы уверены?", "BGR Colors": "Цвета BGR", @@ -93,6 +96,7 @@ "Erasing user's data…": "Удаление данных пользователя…", "Error:": "Ошибка:", "Esc": "Выйти", + "Execute %s Krux app?": "Запустить приложение %s Krux?", "Explore files?": "Исследовать файлы?", "Export Addresses": "Экспорт адресов", "Exporting %s to SD card…": "Экспорт %s на SD-карту…", @@ -151,6 +155,7 @@ "Line Delay": "Задержка Линии", "Line:": "Линия:", "List Addresses": "Список адресов", + "Load Krux app": "Загрузить приложение Krux", "Load Mnemonic": "Загрузить Мнемонику", "Load Wallet": "Загрузить кошелек", "Load a trusted wallet descriptor to view addresses?": "Загрузить дескриптор доверенного кошелька для просмотра адресов?", @@ -285,6 +290,7 @@ "Spend (%d):": "Расход (%d):", "Spend:": "Расход:", "Standard mode": "Стандартный режим", + "Startup Kapp": "Запуск Kapp", "Static": "Static / Статическое оборудование", "Stats for Nerds": "Статистика для Гиков", "Store on Flash": "Сохранить на Флэш Память", @@ -317,6 +323,7 @@ "Type Key": "Ввести Ключ", "Undo": "Отменить", "Unit": "Единица Измерения", + "Unsigned apps found in flash will be deleted.": "Неподписанные приложения, найденные во флэш-памяти, будут удалены.", "Update KEF ID?": "Обновить идентификатор KEF?", "Update QR Label?": "Обновить QR-метку?", "Upgrade complete.": "Обновление завершено.", diff --git a/i18n/translations/tr-TR.json b/i18n/translations/tr-TR.json index c573f542b..077624fa1 100644 --- a/i18n/translations/tr-TR.json +++ b/i18n/translations/tr-TR.json @@ -19,7 +19,10 @@ "Additional entropy from camera required for %s": "%s için kameradan gelen ek entropi gerekli", "Address": "Adres", "Align camera and backup plate properly.": "Kamerayı ve yedek plakay'ı düzgün bir şekilde hizalayın.", + "Allow Krux apps": "Krux uygulamalarına izin ver", + "Allow in settings first!": "Önce ayarlarda izin ver!", "Anti-glare mode": "Parlama Önleyici Mod", + "App will be stored internally on flash.": "Uygulama flaşta dahili olarak depolanacaktır.", "Appearance": "Görünüm", "Are you sure?": "Emin misiniz?", "BGR Colors": "BGR Renkleri", @@ -93,6 +96,7 @@ "Erasing user's data…": "Kullanıcının verileri siliniyor…", "Error:": "Hata:", "Esc": "Çıkış", + "Execute %s Krux app?": "%s Krux uygulaması çalıştırılsın mı?", "Explore files?": "Dosyaları ara?", "Export Addresses": "Adresleri Dışa Aktar", "Exporting %s to SD card…": "%s SD karta aktarılıyor…", @@ -151,6 +155,7 @@ "Line Delay": "Satır Gecikmesi", "Line:": "Satır:", "List Addresses": "Adresleri Listele", + "Load Krux app": "Krux uygulamasını yükle", "Load Mnemonic": "Mnemonic Yükle", "Load Wallet": "Cüzdan Yükle", "Load a trusted wallet descriptor to view addresses?": "Adresleri görüntülemek için güvenilir bir cüzdan tanımlayıcısı yüklensin mi?", @@ -285,6 +290,7 @@ "Spend (%d):": "Harcama (%d):", "Spend:": "Harcama:", "Standard mode": "Standart Mod", + "Startup Kapp": "Startup Kapp", "Static": "Statik", "Stats for Nerds": "İnekler İçin İstatistikler", "Store on Flash": "Flash'ta Sakla", @@ -317,6 +323,7 @@ "Type Key": "Anahtar Yaz", "Undo": "Geri Al", "Unit": "Birim", + "Unsigned apps found in flash will be deleted.": "Flash'ta bulunan imzasız uygulamalar silinecek.", "Update KEF ID?": "Kef Kimliği Güncellensin mi?", "Update QR Label?": "QR Etiketi Güncellensin mi", "Upgrade complete.": "Güncelleme tamamlandı.", diff --git a/i18n/translations/vi-VN.json b/i18n/translations/vi-VN.json index 9648090a3..4361974f0 100644 --- a/i18n/translations/vi-VN.json +++ b/i18n/translations/vi-VN.json @@ -19,7 +19,10 @@ "Additional entropy from camera required for %s": "Entropy bổ sung từ máy ảnh cần thiết cho %s", "Address": "Địa chỉ", "Align camera and backup plate properly.": "Căn chỉnh camera và tấm dự phòng đúng cách.", + "Allow Krux apps": "Cho phép ứng dụng Krux", + "Allow in settings first!": "Cho phép cài đặt trước!", "Anti-glare mode": "Chế độ chống lóa", + "App will be stored internally on flash.": "Ứng dụng sẽ được lưu trữ nội bộ trên flash.", "Appearance": "Giao diện", "Are you sure?": "Bạn có chắc không?", "BGR Colors": "Màu BGR", @@ -93,6 +96,7 @@ "Erasing user's data…": "Đang xóa dữ liệu của người dùng…", "Error:": "Lỗi:", "Esc": "Esc", + "Execute %s Krux app?": "Thực thi %s ứng dụng Krux?", "Explore files?": "Khám phá các tập tin?", "Export Addresses": "Xuất báo cáo", "Exporting %s to SD card…": "Đang xuất %s sang thẻ SD…", @@ -151,6 +155,7 @@ "Line Delay": "Độ trễ Dòng", "Line:": "Đường kẻ:", "List Addresses": "danh sách địa chỉ", + "Load Krux app": "Tải ứng dụng Krux", "Load Mnemonic": "Tải mã mnemonic", "Load Wallet": "Nạp Ví", "Load a trusted wallet descriptor to view addresses?": "Tải mô tả ví đáng tin cậy để xem địa chỉ?", @@ -285,6 +290,7 @@ "Spend (%d):": "Chi tiêu (%d):", "Spend:": "Chi tiêu:", "Standard mode": "Chế độ Tiêu chuẩn", + "Startup Kapp": "Startup Kapp", "Static": "Tĩnh", "Stats for Nerds": "Số liệu thống kê cho Mọt sách", "Store on Flash": "Lưu trữ trên flash", @@ -317,6 +323,7 @@ "Type Key": "Nhập khóa", "Undo": "Hoàn tác", "Unit": "Đơn vị", + "Unsigned apps found in flash will be deleted.": "Các ứng dụng chưa ký được tìm thấy trong flash sẽ bị xóa.", "Update KEF ID?": "Cập nhật ID KEF?", "Update QR Label?": "Cập nhật nhãn QR?", "Upgrade complete.": "Nâng cấp hoàn tất.", diff --git a/i18n/translations/zh-CN.json b/i18n/translations/zh-CN.json index 7cad610df..046cd81a3 100644 --- a/i18n/translations/zh-CN.json +++ b/i18n/translations/zh-CN.json @@ -19,7 +19,10 @@ "Additional entropy from camera required for %s": "%s需要摄像头的额外熵", "Address": "地址", "Align camera and backup plate properly.": "正确对齐摄像头和背板.", + "Allow Krux apps": "允许Krux应用程序", + "Allow in settings first!": "首先在设置中允许!", "Anti-glare mode": "防闪模式", + "App will be stored internally on flash.": "应用程序将内部存储在闪存中。", "Appearance": "界面", "Are you sure?": "确定?", "BGR Colors": "BGR 颜色", @@ -93,6 +96,7 @@ "Erasing user's data…": "正在删除用户数据…", "Error:": "错误:", "Esc": "退出", + "Execute %s Krux app?": "是否执行%s Krux应用程序?", "Explore files?": "浏览文件?", "Export Addresses": "导出地址", "Exporting %s to SD card…": "正在将%s导出到SD卡…", @@ -151,6 +155,7 @@ "Line Delay": "行延迟", "Line:": "行:", "List Addresses": "地址", + "Load Krux app": "加载Krux应用", "Load Mnemonic": "加载助记词", "Load Wallet": "加载钱包", "Load a trusted wallet descriptor to view addresses?": "加载受信任的钱包描述符以查看地址?", @@ -285,6 +290,7 @@ "Spend (%d):": "花费 (%d):", "Spend:": "花费", "Standard mode": "标准模式", + "Startup Kapp": "启动Kapp", "Static": "Static 静态?", "Stats for Nerds": "极客统计数据", "Store on Flash": "存储到 Flash", @@ -317,6 +323,7 @@ "Type Key": "输入私钥", "Undo": "撤销", "Unit": "单位", + "Unsigned apps found in flash will be deleted.": "在Flash中找到的未签名应用将被删除.", "Update KEF ID?": "更新KEF ID ?", "Update QR Label?": "更新二维码标签?", "Upgrade complete.": "升级已完成.", diff --git a/kapps/k_qr.mpy b/kapps/k_qr.mpy new file mode 100644 index 000000000..31ad58dff Binary files /dev/null and b/kapps/k_qr.mpy differ diff --git a/kapps/k_qr.py b/kapps/k_qr.py new file mode 100644 index 000000000..9e36d3829 --- /dev/null +++ b/kapps/k_qr.py @@ -0,0 +1,57 @@ +# The MIT License (MIT) + +# Copyright (c) 2021-2024 Krux contributors + +# 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. +import os + +# avoids importing from flash VSF +os.chdir("/") + +VERSION = "1.0" +NAME = "QR Code" +ALLOW_STARTUP = True + + +def run(ctx): + """Inconspicuous QR scanner app""" + from krux.pages.qr_capture import QRCodeCapture + from binascii import hexlify + + while True: + try: + qr_capture = QRCodeCapture(ctx) + data, _ = qr_capture.qr_capture_loop() + if data is None: + continue + + if isinstance(data, bytes): + try: + data = data.decode() + except: + data = "0x" + hexlify(data).decode() + + if data == "krux": + break + + ctx.display.clear() + ctx.display.draw_centered_text(data) + ctx.input.wait_for_button() + except: + pass diff --git a/kapps/nostr.mpy b/kapps/nostr.mpy new file mode 100644 index 000000000..0c8b90a1b Binary files /dev/null and b/kapps/nostr.mpy differ diff --git a/kapps/nostr.py b/kapps/nostr.py new file mode 100644 index 000000000..362eecdb3 --- /dev/null +++ b/kapps/nostr.py @@ -0,0 +1,1068 @@ +# The MIT License (MIT) + +# Copyright (c) 2021-2024 Krux contributors + +# 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. +# pylint: skip-file +import os + +# avoids importing from flash VSF +os.chdir("/") + +VERSION = "1.0" +NAME = "Nostr" + +from krux.pages import Menu, MENU_CONTINUE, MENU_EXIT, LETTERS, DIGITS, ESC_KEY +from krux.pages.login import Login, DIGITS_HEX +from krux.pages.home_pages.home import Home +from krux.krux_settings import t, Settings +from krux.display import ( + STATUS_BAR_HEIGHT, + FONT_HEIGHT, + BOTTOM_PROMPT_LINE, + DEFAULT_PADDING, + FONT_WIDTH, +) +from krux.themes import theme +from krux.kboard import kboard +from krux.key import Key, TYPE_SINGLESIG +from krux.wallet import Wallet +from krux.settings import MAIN_TXT, ELLIPSIS +from embit import bech32, bip32, bip39 +from embit.ec import PrivateKey +from embit.networks import NETWORKS +from binascii import hexlify, unhexlify +import ujson as json +import hashlib +import time +import gc + +NSEC_SIZE = 63 +HEX_SIZE = 64 + +NSEC = "nsec" +PRIV_HEX = "priv-hex" +NPUB = "npub" +PUB_HEX = "pub-hex" +HEX = "hex" +MNEMONIC = "mnemonic" +NIP06_PATH = "m/44h/1237h/0h/0/0" + +FILE_SUFFIX = "-nostr" +FILE_EXTENSION = ".txt" + + +# ------------------- + + +# https://github.com/nostr-protocol/nips +class NostrEvent: + + # https://github.com/nostr-protocol/nips/blob/master/01.md + # { + # "id": <32-bytes lowercase hex-encoded sha256 of the serialized event data>, + # "pubkey": <32-bytes lowercase hex-encoded public key of the event creator>, + # "created_at": , + # "kind": , + # "tags": [ + # [...], + # // ... + # ], + # "content": , + # "sig": <64-bytes lowercase hex of the signature of the sha256 hash of the serialized event data, which is the same as the "id" field> + # } + + # Kind types + KIND_REGULAR = "regular" + KIND_REPLACEABLE = "replaceable" + KIND_EPHEMERAL = "ephemeral" + KIND_ADDRESSABLE = "addressable" + UNKNOWN = "unknown" + + # event mandatory attributes + PUBKEY = "pubkey" + CREATED_AT = "created_at" + KIND = "kind" + TAGS = "tags" + CONTENT = "content" + ID = "id" + + KIND_DESC = { + 0: "User Metadata", + 1: "Short Text Note", + 2: "Recommend Relay (deprecated)", + 3: "Follows", + 4: "Encrypted Direct Messages", + 5: "Event Deletion Request", + 6: "Repost", + 7: "Reaction", + 8: "Badge Award", + 9: "Chat Message", + 10: "Group Chat Threaded Reply (deprecated)", + 11: "Thread", + 12: "Group Thread Reply (deprecated)", + 13: "Seal", + 14: "Direct Message", + 15: "File Message", + 16: "Generic Repost", + 17: "Reaction to a website", + 20: "Picture", + 21: "Video Event", + 22: "Short-form Portrait Video Event", + 30: "internal reference", + 31: "external web reference", + 32: "hardcopy reference", + 33: "prompt reference", + 40: "Channel Creation", + 41: "Channel Metadata", + 42: "Channel Message", + 43: "Channel Hide Message", + 44: "Channel Mute User", + 62: "Request to Vanish", + 64: "Chess (PGN)", + 443: "KeyPackage", + 444: "Welcome Message", + 445: "Group Event", + 818: "Merge Requests", + 1018: "Poll Response", + 1021: "Bid", + 1022: "Bid confirmation", + 1040: "OpenTimestamps", + 1059: "Gift Wrap", + 1063: "File Metadata", + 1068: "Poll", + 1111: "Comment", + 1222: "Voice Message", + 1244: "Voice Message Comment", + 1311: "Live Chat Message", + 1337: "Code Snippet", + 1617: "Patches", + 1621: "Issues", + 1622: "Git Replies (deprecated)", + "1630-1633": "Status", + 1971: "Problem Tracker", + 1984: "Reporting", + 1985: "Label", + 1986: "Relay reviews", + 1987: "AI Embeddings / Vector lists", + 2003: "Torrent", + 2004: "Torrent Comment", + 2022: "Coinjoin Pool", + 4550: "Community Post Approval", + "5000-5999": "Job Request", + "6000-6999": "Job Result", + 7000: "Job Feedback", + 7374: "Reserved Cashu Wallet Tokens", + 7375: "Cashu Wallet Tokens", + 7376: "Cashu Wallet History", + 7516: "Geocache log", + 7517: "Geocache proof of find", + "9000-9030": "Group Control Events", + 9041: "Zap Goal", + 9321: "Nutzap", + 9467: "Tidal login", + 9734: "Zap Request", + 9735: "Zap", + 9802: "Highlights", + 10000: "Mute list", + 10001: "Pin list", + 10002: "Relay List Metadata", + 10003: "Bookmark list", + 10004: "Communities list", + 10005: "Public chats list", + 10006: "Blocked relays list", + 10007: "Search relays list", + 10009: "User groups", + 10012: "Favorite relays list", + 10013: "Private event relay list", + 10015: "Interests list", + 10019: "Nutzap Mint Recommendation", + 10020: "Media follows", + 10030: "User emoji list", + 10050: "Relay list to receive DMs", + 10051: "KeyPackage Relays List", + 10063: "User server list", + 10096: "File storage server list (deprecated)", + 10166: "Relay Monitor Announcement", + 10312: "Room Presence", + 10377: "Proxy Announcement", + 11111: "Transport Method Announcement", + 13194: "Wallet Info", + 17375: "Cashu Wallet Event", + 21000: "Lightning Pub RPC", + 22242: "Client Authentication", + 23194: "Wallet Request", + 23195: "Wallet Response", + 24133: "Nostr Connect", + 24242: "Blobs stored on mediaservers", + 27235: "HTTP Auth", + 30000: "Follow sets", + 30001: "Generic lists (deprecated)", + 30002: "Relay sets", + 30003: "Bookmark sets", + 30004: "Curation sets", + 30005: "Video sets", + 30007: "Kind mute sets", + 30008: "Profile Badges", + 30009: "Badge Definition", + 30015: "Interest sets", + 30017: "Create or update a stall", + 30018: "Create or update a product", + 30019: "Marketplace UI/UX", + 30020: "Product sold as an auction", + 30023: "Long-form Content", + 30024: "Draft Long-form Content", + 30030: "Emoji sets", + 30040: "Curated Publication Index", + 30041: "Curated Publication Content", + 30063: "Release artifact sets", + 30078: "Application-specific Data", + 30166: "Relay Discovery", + 30267: "App curation sets", + 30311: "Live Event", + 30312: "Interactive Room", + 30313: "Conference Event", + 30315: "User Statuses", + 30388: "Slide Set", + 30402: "Classified Listing", + 30403: "Draft Classified Listing", + 30617: "Repository announcements", + 30618: "Repository state announcements", + 30818: "Wiki article", + 30819: "Redirects", + 31234: "Draft Event", + 31388: "Link Set", + 31890: "Feed", + 31922: "Date-Based Calendar Event", + 31923: "Time-Based Calendar Event", + 31924: "Calendar", + 31925: "Calendar Event RSVP", + 31989: "Handler recommendation", + 31990: "Handler information", + 32267: "Software Application", + 34550: "Community Definition", + 37516: "Geocache listing", + 38172: "Cashu Mint Announcement", + 38173: "Fedimint Announcement", + 38383: "Peer-to-peer Order events", + "39000-9": "Group metadata events", + 39089: "Starter packs", + 39092: "Media starter packs", + 39701: "Web bookmarks", + } + + # May have different meanings depending on the kind number + TAGS_DESC = { + "a": "coordinates to an event", + "A": "root address", + "d": "identifier", + "e": "event id (hex)", + "E": "root event id", + "f": "currency code", + "g": "geohash", + "h": "group id", + "i": "external identity", + "I": "root external identity", + "k": "kind", + "K": "root scope", + "l": "label, label namespace, language name", + "L": "label namespace", + "m": "MIME type", + "p": "pubkey (hex)", + "P": "pubkey (hex)", + "q": "event id (hex)", + "r": "url / relay url", + "s": "status", + "t": "hashtag", + "u": "url", + "x": "hash", + "y": "platform", + "z": "order number", + "-": "protected", + "alt": "summary", + "amount": "millisatoshis, stringified", + "bolt11": "bolt11 invoice", + "challenge": "challenge string", + "client": "name, address", + "clone": "git clone URL", + "content-warning": "reason", + "delegation": "pubkey, conditions, delegation token", + "dep": "Required dependency", + "description": "description", + "emoji": "shortcode, image URL", + "encrypted": "--", + "extension": "File extension", + "expiration": "unix timestamp (string)", + "file": "full path (string)", + "goal": "event id (hex)", + "HEAD": "ref: refs/heads/", + "image": "image URL", + "imeta": "inline metadata", + "license": "License of the shared content", + "lnurl": "bech32 encoded lnurl", + "location": "location string", + "name": "name", + "nonce": "random", + "preimage": "hash of bolt11 invoice", + "price": "price", + "proxy": "external ID", + "published_at": "unix timestamp (string)", + "relay": "relay url", + "relays": "relay list", + "repo": "Reference to the origin repository", + "runtime": "Runtime or environment specification", + "server": "file storage server url", + "subject": "subject", + "summary": "summary", + "thumb": "badge thumbnail", + "title": "title", + "tracker": "torrent tracker URL", + "web": "webpage URL", + "zap": "pubkey (hex), relay URL", + } + + @classmethod + def get_kind_type(cls, n: int): + """The type of the kind""" + if n < 0 or n > 65535: + raise ValueError("Kind number must be greater than 0 and lower than 65535!") + if 1000 <= n < 10000 or 4 <= n < 45 or n in (1, 2): + return cls.KIND_REGULAR + if 10000 <= n < 20000 or n in (0, 3): + return cls.KIND_REPLACEABLE + if 20000 <= n < 30000: + return cls.KIND_EPHEMERAL + if 30000 <= n < 40000: + return cls.KIND_ADDRESSABLE + return cls.UNKNOWN + + @classmethod + def get_kind_desc(cls, n: int): + """The meaning of the event""" + try: + return cls.KIND_DESC[n] + except: + if 1630 >= n <= 1633: + return cls.KIND_DESC["1630-1633"] + if 5000 >= n <= 5999: + return cls.KIND_DESC["5000-5999"] + if 6000 >= n <= 6999: + return cls.KIND_DESC["6000-6999"] + if 9000 >= n <= 9030: + return cls.KIND_DESC["9000-9030"] + if 39000 >= n <= 39009: + return cls.KIND_DESC["39000-9"] + + return cls.UNKNOWN + + @classmethod + def get_tag(cls, txt: str): + """The meaning of the tag (may vary depending on the kind)""" + try: + return cls.TAGS_DESC[txt] + except: + return cls.UNKNOWN + + @classmethod + def parse_event(cls, txt: str): + """ + Parse a JSON-encoded event string and validate required attributes + Returns the parsed dict if valid or raise Error + """ + json_content = json.loads(txt) + expected_attrs = { + cls.PUBKEY, + cls.CREATED_AT, + cls.KIND, + cls.TAGS, + cls.CONTENT, + cls.ID, + } + + missing = expected_attrs - set(json_content.keys()) + if missing: + raise ValueError("Missing expected attributes: %s." % ", ".join(missing)) + + return json_content + + @classmethod + def serialize_event(cls, event_dict: dict): + """UTF-8 JSON-serialized string as defined in NIP-01""" + data = [ + 0, + event_dict[cls.PUBKEY], + event_dict[cls.CREATED_AT], + event_dict[cls.KIND], + event_dict[cls.TAGS], + event_dict[cls.CONTENT], + ] + return json.dumps(data).replace(", ", ",").replace(": ", ":") + + @classmethod + def validate_id(cls, event_dict: dict, serialized_event: str): + """Validates informed id with calculated one""" + if event_dict[cls.ID] != cls._calculate_id(serialized_event): + raise ValueError("Attribute id do not match calculated.") + return True + + @staticmethod + def _calculate_id(serialized_event: str): + """Calculates the id field of the event""" + return hexlify(hashlib.sha256(serialized_event.encode()).digest()).decode() + + @staticmethod + def sign_event(root, serialized_event: str): + """Sign a serialized_event""" + return str( + root.schnorr_sign(hashlib.sha256(serialized_event.encode()).digest()) + ) + + +# ------------------- + + +class NostrKey: + """Store and convert Nostr keys""" + + def __init__(self): + self.set() + + def set(self, key="", value=None): + """Set key type and its value""" + self.key = key + self.value = value + + def load_nsec(self, nsec: str): + """Load a key in nsec format""" + if len(nsec) != NSEC_SIZE: + raise ValueError("NSEC key must be %d chars!" % NSEC_SIZE) + _, hrp, _ = bech32.bech32_decode(nsec) + if hrp != NSEC: + raise ValueError("Not an nsec key!") + self.set(NSEC, nsec) + + def load_hex(self, hex: str): + """Load a key in hex format""" + if len(hex) != HEX_SIZE: + raise ValueError("Hex key must be %d chars!" % HEX_SIZE) + # try decoding + unhexlify(hex) + self.set(HEX, hex) + + def load_mnemonic(self, mnemonic: str): + """Load a mnemonic, will assume it is valid""" + self.set(MNEMONIC, mnemonic) + + def is_loaded(self): + """If a key was loaded""" + return self.key != "" + + def is_mnemonic(self): + """If loaded key is mnemonic""" + return self.key == MNEMONIC + + @staticmethod + def _encode_bech32(data: bytes, version: str): + """Encode bytes into a bech32 string with given version""" + converted_data = bech32.convertbits(data, 8, 5) + return bech32.bech32_encode(bech32.Encoding.BECH32, version, converted_data) + + @staticmethod + def _decode_bech32(bech: str): + """Decode a bech32 string returning bytes""" + _, _, data = bech32.bech32_decode(bech) + if not data: + raise ValueError("Invalid bech32 data") + raw = bech32.convertbits(data, 5, 8, False) + return bytes(raw) + + def _mnemonic_to_nip06_key(self): + root = bip32.HDKey.from_seed(bip39.mnemonic_to_seed(self.value)) + return root.derive(NIP06_PATH) + + def get_private_key(self): + if self.is_mnemonic(): + return self._mnemonic_to_nip06_key() + hex_key = self.value if self.key == HEX else self.get_hex() + return PrivateKey(unhexlify(hex_key)) + + def _get_pub_xonly(self): + return self.get_private_key().get_public_key().xonly() + + def get_hex(self): + """Return key in hex format""" + if self.key == HEX: + return self.value + if self.key == NSEC: + return hexlify(NostrKey._decode_bech32(self.value)).decode() + # is mnemonic + nostr_root = self._mnemonic_to_nip06_key() + return hexlify(nostr_root.secret).decode() + + def get_nsec(self): + """Return key in nsec format""" + if self.key == NSEC: + return self.value + if self.key == HEX: + return NostrKey._encode_bech32(unhexlify(self.value), NSEC) + # is mnemonic + nostr_root = self._mnemonic_to_nip06_key() + return NostrKey._encode_bech32(nostr_root.secret, NSEC) + + def get_pub_hex(self): + """Return pubkey in hex format""" + if self.key in (HEX, NSEC): + pub_bytes = self._get_pub_xonly() + return hexlify(pub_bytes).decode() + # is mnemonic + nostr_root = self._mnemonic_to_nip06_key() + return hexlify(nostr_root.xonly()).decode() + + def get_npub(self): + """Return pubkey in npub format""" + if self.key in (HEX, NSEC): + pub_bytes = self._get_pub_xonly() + return NostrKey._encode_bech32(pub_bytes, NPUB) + # is mnemonic + nostr_root = self._mnemonic_to_nip06_key() + return NostrKey._encode_bech32(nostr_root.xonly(), NPUB) + + +# ------------------- + + +class KMenu(Menu): + """Customizes the page's menu""" + + def __init__( + self, + ctx, + menu, + offset=None, + disable_statusbar=False, + back_label="Back", + back_status=lambda: MENU_EXIT, + ): + super().__init__(ctx, menu, offset, disable_statusbar, back_label, back_status) + self.disable_statusbar = False + if offset is None: + self.menu_offset = STATUS_BAR_HEIGHT + else: + # Always disable status bar if menu has non standard offset + self.disable_statusbar = True + self.menu_offset = offset if offset >= 0 else DEFAULT_PADDING + + def new_draw_wallet_indicator(self): + """Customize the top bar""" + text = NAME + if nostrKey.is_loaded(): + if nostrKey.is_mnemonic(): + text = Key.extract_fingerprint(nostrKey.value) + else: + text = nostrKey.value[:9] + ELLIPSIS + + if not kboard.is_m5stickv: + self.ctx.display.draw_hcentered_text( + text, + STATUS_BAR_HEIGHT - FONT_HEIGHT - 1, + theme.highlight_color, + theme.info_bg_color, + ) + else: + self.ctx.display.draw_string( + 24, + STATUS_BAR_HEIGHT - FONT_HEIGHT - 1, + text, + theme.highlight_color, + theme.info_bg_color, + ) + + def new_draw_network_indicator(self): + """Don't draw testnet""" + + Menu.draw_wallet_indicator = new_draw_wallet_indicator + Menu.draw_network_indicator = new_draw_network_indicator + + +# ------------------- + + +class Klogin(Login): + """Page to load a Nostr the Key""" + + def __init__(self, ctx): + super().__init__(ctx) + shtn_reboot_label = t("Shutdown") if kboard.has_battery else t("Reboot") + self.menu = KMenu( + ctx, + [ + (t("Load Mnemonic"), self.load_key), + (t("New Mnemonic"), self.new_key), + (t("Load nsec or hex"), self.load_nsec), + (t("About"), self.about), + (shtn_reboot_label, self.shutdown), + ], + back_label=None, + ) + + def _load_wallet_key(self, mnemonic): + nostrKey.load_mnemonic(mnemonic) + self.ctx.wallet = Wallet(Key(mnemonic, TYPE_SINGLESIG, NETWORKS[MAIN_TXT])) + + return MENU_EXIT + + def load_nsec(self): + """Load nsec or hex menu item""" + + submenu = Menu( + self.ctx, + [ + (t("QR Code"), self._load_nostr_priv_cam), + (t("Via Manual Input"), self._pre_load_nostr_priv_manual), + ( + t("Load from SD card"), + None if not self.has_sd_card() else self._load_nostr_priv_sd, + ), + ], + ) + index, status = submenu.run_loop() + if index == len(submenu.menu) - 1: + return MENU_CONTINUE + return status + + def _pre_load_nostr_priv_manual(self): + submenu = Menu( + self.ctx, + [ + (NSEC, lambda ver=NSEC: self._load_nostr_priv_manual(ver)), + (HEX, lambda ver=HEX: self._load_nostr_priv_manual(ver)), + ], + ) + index, status = submenu.run_loop() + if index == len(submenu.menu) - 1: + return MENU_CONTINUE + return status + + def _load_nostr_priv_cam(self): + from krux.pages.qr_capture import QRCodeCapture + + error_msg = t("Failed to load") + qr_capture = QRCodeCapture(self.ctx) + data, _ = qr_capture.qr_capture_loop() + if data is None: + self.flash_error(error_msg) + return MENU_CONTINUE + + try: + data = data.decode() if not isinstance(data, str) else data + except: + self.flash_error(error_msg) + return MENU_CONTINUE + + return self._load_nostr_priv_key(data) + + def _load_nostr_priv_manual(self, version): + title = t("Private Key") + + data = "" + if version == NSEC: + data = NSEC + + while True: + if version == NSEC: + data = self.capture_from_keypad( + title, [LETTERS, DIGITS], starting_buffer=data + ) + else: + data = self.capture_from_keypad( + title, [DIGITS_HEX], starting_buffer=data + ) + + if data == ESC_KEY: + return MENU_CONTINUE + + if self._load_nostr_priv_key(data) == MENU_EXIT: + return MENU_EXIT + + def _load_nostr_priv_sd(self): + from krux.pages.utils import Utils + + # Prompt user for file + filename, _ = Utils(self.ctx).load_file(prompt=False, only_get_filename=True) + + if not filename: + return MENU_CONTINUE + + from krux.sd_card import SDHandler + + data = "" + try: + with SDHandler() as sd: + data = sd.read(filename) + + data = data.replace("\r\n", "").replace("\n", "") + except: + self.flash_error(t("Failed to load")) + return MENU_CONTINUE + + return self._load_nostr_priv_key(data) + + def _load_nostr_priv_key(self, data: str): + data = data.lower() + + self.ctx.display.clear() + self.ctx.display.draw_hcentered_text( + t("Private Key") + ":\n\n" + data, max_lines=10, highlight_prefix=":" + ) + if not self.prompt( + t("Proceed?"), + BOTTOM_PROMPT_LINE, + ): + return MENU_CONTINUE + + if data.startswith(NSEC): + nostrKey.load_nsec(data) + else: + nostrKey.load_hex(data) + + return MENU_EXIT + + # NIP-06 and NIP-19 + # mnemonic and nsec/npub + def about(self): + """Handler for the 'about' menu item""" + + self.ctx.display.clear() + self.ctx.display.draw_centered_text( + "Kapp %s\n%s: %s\n\n" % (NAME, t("Version"), VERSION) + + t("Load or create a key to sign events. Works with NIP-06 and NIP-19.") + ) + self.ctx.input.wait_for_button() + return MENU_CONTINUE + + +# ------------------- + + +class Khome(Home): + """The page after loading the Key""" + + def __init__(self, ctx): + super().__init__(ctx) + + shtn_reboot_label = t("Shutdown") if kboard.has_battery else t("Reboot") + self.menu = KMenu( + ctx, + [ + ( + t("Backup Mnemonic"), + ( + self.backup_mnemonic + if not Settings().security.hide_mnemonic + and nostrKey.is_mnemonic() + else None + ), + ), + (t("Nostr Keys"), self.nostr_keys), + (t("Sign Event"), self.sign_event), + (shtn_reboot_label, self.shutdown), + ], + back_label=None, + ) + + def sign_event(self): + """Handler for Sign Event menu item""" + from krux.pages.home_pages.sign_message_ui import SignMessage + + sing_message = SignMessage(self.ctx) + data, qr_format, message_filename = sing_message._load_message() + + self.ctx.display.clear() + self.ctx.display.draw_centered_text(t("Processing…")) + + # memory management + del sing_message + gc.collect() + + if data is None: + self.flash_error(t("Failed to load")) + return MENU_CONTINUE + + # SD + if message_filename: + data = data.decode() + + pe = NostrEvent.parse_event(data) + se = NostrEvent.serialize_event(pe) + NostrEvent.validate_id(pe, se) + + submenu = Menu( + self.ctx, + [ + (t("Review Again"), lambda: None), + (t("Sign to QR code"), lambda: None), + ( + t("Sign to SD card"), + None if not self.has_sd_card() else lambda: None, + ), + ], + back_status=lambda: None, + ) + index = 0 + + while index == 0: # Review Again + self._show_event(pe) + index, _ = submenu.run_loop() + + if index == submenu.back_index: # Back + return MENU_CONTINUE + + self.ctx.display.clear() + self.ctx.display.draw_centered_text(t("Signing…")) + + # memory management + del pe + del submenu + gc.collect() + + signed_event = NostrEvent.sign_event(nostrKey.get_private_key(), se) + + if index == 1: # Sign to QR code + from krux.pages.utils import Utils + + utils = Utils(self.ctx) + + while True: + self.display_qr_codes(signed_event, qr_format) + utils.print_standard_qr( + signed_event, qr_format, t("Signed Event"), width=45 + ) + self.ctx.display.clear() + if self.prompt(t("Done?"), self.ctx.display.height() // 2): + return MENU_CONTINUE + + # index == 2: Sign to SD card + from krux.sd_card import SDHandler, SIGNED_FILE_SUFFIX, SIGNATURE_FILE_EXTENSION + from krux.pages.file_operations import SaveFile + + save_page = SaveFile(self.ctx) + message_filename = save_page.set_filename( + message_filename, + "QRCode", + SIGNED_FILE_SUFFIX, + SIGNATURE_FILE_EXTENSION, + ) + + if message_filename and message_filename != ESC_KEY: + try: + with SDHandler() as sd: + sd.write(message_filename, signed_event) + self.flash_text( + t("Saved to SD card:") + "\n\n%s" % message_filename, + highlight_prefix=":", + ) + return MENU_CONTINUE + except OSError: + self.flash_error(t("SD card not detected.")) + + return MENU_CONTINUE + + def _show_event(self, pe: dict): + created = time.localtime(pe[NostrEvent.CREATED_AT]) + kind = pe[NostrEvent.KIND] + unique_tags = {item[0] for item in pe[NostrEvent.TAGS]} + txt = t("Created:") + txt += " %s-%02d-%02d %02d:%02d" % created[:5] + txt += "\n\n" + + txt += t("Kind:") + txt += " %d %s {%s}" % ( + kind, + NostrEvent.get_kind_desc(kind), + NostrEvent.get_kind_type(kind), + ) + txt += "\n\n" + + txt += t("Tags:") + if unique_tags: + txt += " " + ", ".join( + "'%s' %s" % (tag, NostrEvent.get_tag(tag)) for tag in unique_tags + ) + txt += "\n\n" + txt += " | ".join(", ".join(sublist) for sublist in pe[NostrEvent.TAGS]) + txt += "\n\n" + + txt += t("Content:") + txt += " %s" % pe[NostrEvent.CONTENT] + + offset_x = ( + DEFAULT_PADDING + if not kboard.is_m5stickv + else (self.ctx.display.width() % FONT_WIDTH) // 2 + ) + + startpos = endpos = 0 + txt_size = len(txt) + prefixes = [t("Created:"), t("Kind:"), t("Tags:"), t("Content:")] + while True: + lines, endpos = self.ctx.display.to_lines_endpos(txt[startpos:]) + self.ctx.display.clear() + for i, line in enumerate(lines): + self.ctx.display.draw_string( + offset_x, + (i * (FONT_HEIGHT)), + line, + ) + if any(line.startswith(p) for p in prefixes): + prefixes.pop(0) + prefix_index = line.find(":") + if prefix_index > -1: + self.ctx.display.draw_string( + offset_x, + (i * (FONT_HEIGHT)), + line[: prefix_index + 1], + theme.highlight_color, + ) + startpos += endpos + self.ctx.input.wait_for_fastnav_button() + if startpos >= txt_size: + break + + def nostr_keys(self): + """Handler for Nostr Keys menu item""" + submenu = Menu( + self.ctx, + [ + ( + t("Private Key"), + lambda: self.show_key_formats([NSEC, PRIV_HEX]), + ), + (t("Public Key"), lambda: self.show_key_formats([NPUB, PUB_HEX])), + ], + ) + index, status = submenu.run_loop() + if index == len(submenu.menu) - 1: + return MENU_CONTINUE + return status + + def show_key_formats(self, versions): + """Create menu to select Nostr keys in text or QR""" + + def _nostr_key_text(version): + def _save_nostr_to_sd(version): + from krux.pages.file_operations import SaveFile + + save_page = SaveFile(self.ctx) + title = version + FILE_SUFFIX + save_page.save_file( + self._get_nostr_key(version), + title, + title, + title + ":", + FILE_EXTENSION, + save_as_binary=False, + ) + + nostr_text_menu_items = [ + ( + t("Save to SD card"), + ( + None + if not self.has_sd_card() + else lambda ver=version: _save_nostr_to_sd(ver) + ), + ), + ] + full_nostr_key = ( + self._get_nostr_title(version) + + ":\n\n" + + str(self._get_nostr_key(version)) + ) + menu_offset = 5 + len(self.ctx.display.to_lines(full_nostr_key)) + menu_offset *= FONT_HEIGHT + nostr_key_menu = Menu(self.ctx, nostr_text_menu_items, offset=menu_offset) + self.ctx.display.clear() + self.ctx.display.draw_hcentered_text( + full_nostr_key, + offset_y=FONT_HEIGHT, + info_box=True, + highlight_prefix=":", + ) + nostr_key_menu.run_loop() + + def _nostr_key_qr(version): + title = self._get_nostr_title(version) + nostr_key = str(self._get_nostr_key(version)) + from krux.pages.qr_view import SeedQRView + + seed_qr_view = SeedQRView(self.ctx, data=nostr_key, title=title) + seed_qr_view.display_qr(allow_export=True, transcript_tools=False) + + pub_key_menu_items = [] + for version in versions: + title = version if version not in (PRIV_HEX, PUB_HEX) else HEX + pub_key_menu_items.append( + (title + " - " + t("Text"), lambda ver=version: _nostr_key_text(ver)) + ) + pub_key_menu_items.append( + (title + " - " + t("QR Code"), lambda ver=version: _nostr_key_qr(ver)) + ) + pub_key_menu = Menu(self.ctx, pub_key_menu_items) + while True: + _, status = pub_key_menu.run_loop() + if status == MENU_EXIT: + break + + return MENU_CONTINUE + + def _get_nostr_title(self, version): + if version == NPUB: + return "Public Key npub" + if version == PUB_HEX: + return "Public Key hex" + if version == NSEC: + return "Private Key nsec" + return "Private Key hex" + + def _get_nostr_key(self, version): + if version == NPUB: + return nostrKey.get_npub() + if version == NSEC: + return nostrKey.get_nsec() + if version == PRIV_HEX: + return nostrKey.get_hex() + return nostrKey.get_pub_hex() + + +# ------------------- + + +def run(ctx): + """Runs this kapp""" + + Klogin(ctx).run() + + if nostrKey.is_loaded(): + Khome(ctx).run() + + +nostrKey = NostrKey() + +# use try / catch and threat exceptions to avoid error? +# Could not execute nostr diff --git a/kapps/paper_wallet.py b/kapps/paper_wallet.py new file mode 100644 index 000000000..3eb3a3e53 --- /dev/null +++ b/kapps/paper_wallet.py @@ -0,0 +1,2372 @@ +# The MIT License (MIT) +# Copyright (c) 2021-2026 Krux contributors + +# 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. +""" +paper_wallet.py - Bitcoin Paper Wallet Generator for Krux + +A Kapp (Krux Application) for generating, loading, and exporting Bitcoin +paper wallets compatible with bitaddress.org format. + +Overview +-------- +This module provides functionality to: +- Load Bitcoin private keys (WIF format) from QR codes or SD card +- Generate styled paper wallets as SVG files +- Export wallets in multiple formats (Base64, Hex, CSV, SVG) +- Encrypt wallets using KEF (KEF Encrypted Format) +- Generate encrypted paper wallets with scannable QR codes + +Classes +------- +Wallet Classes: + ``KBaseWallet`` : Base class for all wallet types + ``KSingleWallet`` : Represents a single WIF/address pair + ``KBulkWallet`` : Collection of multiple ``KSingleWallet`` instances + ``KPaperWallet`` : Styled paper wallet with SVG generation + +SVG Generation: + ``KSVG`` : Helper class for generating paper wallet and encrypted wallet SVGs + +UI/UX Classes: + ``KMenu`` : Customized menu for the paper wallet interface + ``KLogin`` : Login page for loading wallets + ``KHome`` : Home page for wallet operations + ``KManager`` : Main manager coordinating login and home pages + +Storage: + ``KWalletStorage``: Handler for encrypted wallet storage + +Usage +----- +As a Kapp (run from Krux device): + + from paper_wallet import run + run(ctx) # ctx is the Krux ``Context`` + +Programmatic SVG generation: + + # Create a single wallet + wallet = KSingleWallet(wallet=(0, "L4x...bkN")) + + # Convert to paper wallet and generate SVG + paper = KPaperWallet.from_single_wallet(wallet) + svg_list = paper.to_svg() # Returns list of SVG strings + + # Generate encrypted SVG + enc_svg = KSVG() + svg_content = enc_svg.to_encrypted_svg( + encrypted_data="", + wallet_id="my_wallet", + wallet_index=0 + ) + +Export Formats +-------------- +Plaintext exports: + - Base64 (.b64) : WIF encoded as Base64 + - Hex (.hex) : WIF encoded as hexadecimal + - CSV (.csv) : Format: index, "address", "wif" + - SVG (.svg) : Styled paper wallet (bitaddress.org style) + +Encrypted exports (KEF): + - KEF (default) : Stored in Krux encrypted format (JSON) + - Base64 (.b64) : Encrypted data as Base64 + - Hex (.hex) : Encrypted data as hexadecimal + - CSV (.csv) : Format: wallet_id, format, data + - SVG (.svg) : Encrypted paper wallet with QR code + +Paper Wallet Styles +------------------- +Unencrypted (``KSVG``): + - Yellow/gold gradient background + - Two sections: SHARE (public) and SECRET (private) + - Bitcoin orange (#f7931a) color scheme + - QR codes for both address and WIF + - Fold line between sections + +Encrypted (``KSVG.to_encrypted_svg()``): + - Blue gradient background + - Single QR code with encrypted KEF data + - Krux K logo and lock icon + - KEF FORMAT badge + - Warning about compatible device requirement + +Constants +--------- + VERSION : Module version ("1.0") + NAME : Display name ("Paper Wallet") + WIF : Compiled regex for WIF validation + +See Also +-------- + - bitaddress.org : Original paper wallet generator +""" + +# pylint: disable=too-many-lines +# ------------------- +import os +import re + +# avoids importing from flash VSF +os.chdir("/") + +from krux.display import ( + STATUS_BAR_HEIGHT, + DEFAULT_PADDING, + FONT_HEIGHT, + BOTTOM_PROMPT_LINE, +) + +from krux.baseconv import base_decode, base_encode +from krux.context import Context +from krux.encryption import MnemonicStorage +from krux.kboard import kboard +from krux.krux_settings import t, Settings +from krux.pages.qr_capture import QRCodeCapture +from krux.pages import Menu, MENU_CONTINUE, MENU_EXIT, MENU_SHUTDOWN +from krux.pages.encryption_ui import KEFEnvelope, OVERRIDE_LABEL +from krux.pages.login import Login +from krux.pages.home_pages.home import Home +from krux.themes import theme + +# ------------------- + +VERSION = "1.0" +NAME = "Paper Wallet" +CSV_FILE_EXTENSION = "csv" +BASE58 = r"[1-9A-HJ-NP-Za-km-z]" +WIF = re.compile( + r"^(?:" + r"5(?:%s){50}" # mainnet uncompressed + r"|[KL](?:%s){51}" # mainnet compressed + r"|9(?:%s){50}" # testnet uncompressed + r"|c(?:%s){51}" # testnet compressed + r")$" % (BASE58, BASE58, BASE58, BASE58) +) + + +# ------------------- +# Utility Functions +# ------------------- + + +def normalize_on_load(on_load): + """Normalize the on_load callback for KLogin.load_from method. + + Args: + on_load: Callback function or None. + + Returns: + The original callback if not None, otherwise a default no-op function. + """ + if on_load is not None: + return on_load + + def _default_on_load(*_): + return None + + return _default_on_load + + +def is_wif(data): + """Check if the given data is a valid Wallet Import Format (WIF) string. + + WIF is a Base58Check encoding of a private key used in Bitcoin. + Supports mainnet (5, K, L prefixes) and testnet (9, c prefixes). + + Args: + data: String to validate. + + Returns: + True if data is a valid WIF string, False otherwise. + + Raises: + ValueError: If data is not a string. + + Example: + >>> is_wif("L4xHoBuqpn654roZAUKaVrzrH6wbvXhQQt68747igWSdrtU4ZbkN") + True + >>> is_wif("invalid") + False + """ + if not isinstance(data, str): + raise ValueError("Data must be str") + + s = (data or "").strip() + if len(s) not in (51, 52): + return False + + return bool(WIF.match(s)) + + +def is_base64(data): + """Check if the given data is a valid Base64 encoded string. + + Args: + data: String to validate. + + Returns: + True if data is valid Base64, False otherwise. + + Raises: + ValueError: If data is not a string. + """ + if not isinstance(data, str): + raise ValueError("Data must be str") + + s = (data or "").strip() + if not s or len(s) % 4 != 0: + return False + + return bool(re.match(r"^[A-Za-z0-9+/]+={0,2}$", s)) + + +def is_csv(data): + """Check if the given data appears to be CSV formatted. + + Checks if the string starts with a number followed by a comma, + which is the expected format for paper wallet CSV exports. + + Args: + data: String to validate. + + Returns: + True if data appears to be CSV format, False otherwise. + + Raises: + ValueError: If data is not a string. + """ + if not isinstance(data, str): + raise ValueError("Data must be str") + + s = (data or "").strip() + if not s: + return False + + return bool(re.match(r"^\s*\d+\s*,", s)) + + +def is_svg(data): + """Check if the given data appears to be SVG content. + + Args: + data: String to validate. + + Returns: + True if data contains an SVG tag, False otherwise. + + Raises: + ValueError: If data is not a string. + """ + if not isinstance(data, str): + raise ValueError("Data must be str") + + s = (data or "").strip() + if not s: + return False + + return bool(re.search(r">> w = KSingleWallet((0, "L4x...bkN")) + >>> w.address + '16k8TH6VDexBRteFd6VQARgvxAxzXdzTmU' + """ + + def __init__(self, wallet): + super().__init__(wallet_type="single") + self._wallet = wallet + + @property + def wallet(self): + """tuple: The raw wallet tuple (index, wif).""" + return self._wallet + + @property + def index(self): + """int: The wallet index number.""" + return self.wallet[0] + + @property + def wif(self): + """str: The Wallet Import Format private key string.""" + return self.wallet[1] + + @property + def checksum(self): + """str: First 8 characters of the double SHA-256 hash of the private key. + + Used as a short identifier for the wallet. + """ + return hash256(self.private_key.serialize())[:8] + + @property + def private_key(self): + """embit.ec.PrivateKey: The private key object derived from WIF.""" + from embit import ec + + return ec.PrivateKey.from_wif(self.wif) + + @property + def public_key(self): + """embit.ec.PublicKey: The public key derived from the private key.""" + return self.private_key.get_public_key() + + @property + def script(self): + """embit.script.Script: The P2PKH script for this wallet.""" + from embit import script + + return script.p2pkh(self.public_key) + + @property + def address(self): + """str: The Bitcoin address (P2PKH format).""" + return self.script.address() + + def __str__(self): + """Return a string representation of the wallet. + + Returns: + String in format: + """ + return '' % ( + self.index, + self.wif, + self.checksum, + ) + + +class KBulkWallet(KBaseWallet): + """Represents a collection of multiple ``KSingleWallet`` instances. + + Used when generating multiple paper wallets at once. + + Args: + wallets: List of wallet tuples, each in (index, wif) format. + + Example: + >>> wallets = [(0, "L4xHo..."), (1, "K3abc...")] + >>> bulk = KBulkWallet(wallets) + >>> len(bulk.wallets) + 2 + """ + + def __init__(self, wallets): + super().__init__(wallet_type="bulk") + self._wallets = [KSingleWallet(wallet=w) for w in wallets] + + @property + def wallets(self): + """list[``KSingleWallet``]: List of single wallet instances.""" + return self._wallets + + @property + def checksum(self): + """str: First 8 characters of the double SHA-256 hash of each loaded + private key concatenated. + + Used as a short identifier for the wallet. + """ + wifs = b"".join([wallet.private_key.serialize() for wallet in self.wallets]) + return hash256(wifs)[:8] + + +# ------------------- +# SVG Generation Classes +# ------------------- +class KSVG: + """Helper class for generating paper wallet SVG files. + + Provides methods to build SVG elements for the bitaddress.org-style + paper wallet layout, including headers, QR codes, text sections, + and the two-panel SHARE/SECRET design. + + The generated SVG uses a yellow/gold color scheme with Bitcoin + orange (#f7931a) accents. + + Example: + >>> svg = KSVG() + >>> content = svg.xml_open() + svg.open(width=994, height=263) + >>> content += svg.paper_wallet_defs() + >>> # ... add more elements + >>> content += svg.close() + """ + + def xml_open(self, version=1, encoding="UTF-8"): + """Generate XML declaration header. + + Args: + version: XML version number (default: 1). + encoding: Character encoding (default: UTF-8). + + Returns: + XML declaration string. + """ + return '' % (version, encoding) + + def open(self, xmlns="http://www.w3.org/2000/svg", width=900, height=450): + """Generate SVG opening tag with dimensions. + + Args: + xmlns: XML namespace URL. + width: SVG width in pixels. + height: SVG height in pixels. + + Returns: + SVG opening tag string. + """ + return "\n".join( + [ + '' % (width, height), + ] + ) + + def box( + self, + x=20, + y=20, + width=860, + height=410, + rx=19, + fill="#fff", + stroke="#111", + strokewidth=3, + ): + """Create a rounded rectangle element. + + Args: + x: X position. + y: Y position. + width: Rectangle width. + height: Rectangle height. + rx: Corner radius. + fill: Fill color. + stroke: Stroke color. + strokewidth: Stroke width. + + Returns: + SVG rect element string. + """ + return " ".join( + [ + '' % strokewidth, + ] + ) + + def bitcoin_logo(self, x=50, y=40, size=50): + """Create a Bitcoin ```₿``` symbol with circular orange background. + + Args: + x: X position of the logo. + y: Y position of the logo. + size: Diameter of the circle. + + Returns: + SVG elements for the Bitcoin logo. + """ + cx = x + size // 2 + cy = y + size // 2 + return " ".join( + [ + '', + '', + ] + ) + + def header( + self, + title, + font="monospace", + fontsize=28, + textcolor="#111", + textx=110, + texty=75, + ): + """Create a header text element. + + Args: + title: Header text content. + font: Font family. + fontsize: Font size in pixels. + textcolor: Text color. + textx: X position. + texty: Y position. + + Returns: + SVG text element string. + """ + return " ".join( + [ + '' % textcolor, + "%s" % title, + ] + ) + + # pylint: disable=too-many-arguments + def item( + self, + key, + value, + x=50, + y=140, + fontfamily="monospace", + fontsize=13, + fill="#111", + width=540, + height=45, + rx=8, + boxfill="#f7f7f7", + boxstroke="#222", + boxstrokewidth=2, + ): + """Create a labeled text box with key-value display. + + Renders a label (key) above a rounded rectangle containing + the value text. + + Args: + key: Label text displayed above the box. + value: Content text displayed inside the box. + x: X position. + y: Y position. + fontfamily: Font family for text. + fontsize: Font size. + fill: Text color. + width: Box width. + height: Box height. + rx: Box corner radius. + boxfill: Box background color. + boxstroke: Box border color. + boxstrokewidth: Box border width. + + Returns: + SVG elements for the labeled text box. + """ + return " ".join( + [ + '' % fill, + key, + "", + '' % boxstrokewidth, + '' % fill, + value, + "", + ] + ) + + def qrcode( + self, data, x=620, y=110, size=130, module_color="#111", bg_color="#fff" + ): + """Generate a QR code as SVG rectangle elements. + + Creates a QR code by drawing individual module rectangles. + Uses Krux's qrcode module which returns packed binary data. + + Args: + data: String data to encode in the QR code. + x: X position of the QR code. + y: Y position of the QR code. + size: Width and height of the QR code in pixels. + module_color: Color of the dark modules. + bg_color: Background color. + + Returns: + SVG elements forming the complete QR code. + """ + import math + import qrcode as qr + + if isinstance(data, str): + data = data.encode("utf-8") + code = qr.encode(data) + qr_size = int(math.sqrt(len(code) * 8)) + module_size = size // qr_size + + rects = [] + # Background with border + rects.append( + '') + + # Draw QR modules from packed binary (LSB-first) + for row in range(qr_size): + for col in range(qr_size): + bit_index = row * qr_size + col + if code[bit_index >> 3] & (1 << (bit_index % 8)): + rx = x + col * module_size + ry = y + row * module_size + rects.append( + '' + % (rx, ry, module_size, module_size, module_color) + ) + + return " ".join(rects) + + def paper_wallet_defs(self): + """Generate SVG defs with yellow/gold gradient for paper wallet. + + Returns: + SVG defs element with goldGrad linear gradient. + """ + return " ".join( + [ + "", + '', + '', + '', + "", + "", + ] + ) + + def paper_wallet_background(self): + """Generate the main card background with orange top border. + + Returns: + SVG elements for card background and decorative top stripe. + """ + return " ".join( + [ + '', + '', + ] + ) + + def share_section(self, address): + """Generate the left SHARE section with public Bitcoin address. + + Creates the public-facing section of the paper wallet containing + the Bitcoin address QR code and text for receiving funds. + + Args: + address: Bitcoin address string. + + Returns: + SVG elements for the complete SHARE section. + """ + return " ".join( + [ + # Section background + '', + # Header bar + '', + '', + # Bitcoin logo + '', + '', + # SHARE label + 'SHARE', + # QR Code for address + self.qrcode(data=address, x=24, y=62, size=145), + # Address label + 'Bitcoin Address', + # Address text box + '', + # Address text (wrapped) + '%s' % address[:17], + '%s' % address[17:], + # Deposit instructions + 'DEPOSIT Bitcoin to this address.', + 'Verify on blockchain before trusting.', + ] + ) + + def fold_line(self): + """Generate the dashed fold line between SHARE and SECRET sections. + + Returns: + SVG line element with dashed stroke. + """ + return " ".join( + [ + '', + ] + ) + + def secret_section(self, wif): + """Generate the right SECRET section with private key (WIF). + + Creates the private section of the paper wallet containing + the WIF private key QR code and text. Uses darker orange + color scheme to visually distinguish from the public section. + + Args: + wif: Wallet Import Format private key string. + + Returns: + SVG elements for the complete SECRET section. + """ + return " ".join( + [ + # Section background + '', + # Header bar (darker orange for secret) + '', + '', + # Bitcoin logo + '', + '', + # SECRET label + 'SECRET', + # QR Code for WIF + self.qrcode(data=wif, x=524, y=62, size=145), + # Private Key label + 'Private Key (WIF Format)', + # WIF text box + '', + # WIF text (wrapped into 3 lines) + '%s' % wif[:18], + '%s' % wif[18:36], + '%s' % wif[36:], + # Warning + '', + "Keep this SECRET. Don't share!", + 'Anyone with this key can spend your Bitcoin.', + ] + ) + + def paper_wallet_footer(self, wallet_index): + """Generate the footer with wallet number and Krux branding. + + Args: + wallet_index: Index number of this wallet. + + Returns: + SVG text elements for the footer. + """ + return " ".join( + [ + '', + "Wallet #%d" % wallet_index, + '', + "Generated with Krux", + ] + ) + + def close(self): + """Generate the SVG closing tag. + + Returns: + SVG closing tag string. + """ + return "" + + @staticmethod + def from_svg(svg_data): + """Parse an SVG paper wallet and extract wallet data. + + Extracts the WIF private key and wallet index from a paper wallet + SVG generated by this class. + + Args: + svg_data: SVG content as string or bytes. + + Returns: + ``KPaperWallet`` instance containing the extracted wallet(s). + + Raises: + ValueError: If SVG data is invalid or WIF cannot be extracted. + + Example: + >>> svg_content = open("wallet.svg").read() + >>> wallet = KSVG.from_svg(svg_content) + >>> print(wallet.wallets[0].wif) + """ + if svg_data is None: + raise ValueError("SVG data is None") + + if isinstance(svg_data, (bytes, bytearray)): + svg_data = bytes(svg_data).decode("utf-8", errors="strict") + + if not isinstance(svg_data, str): + raise ValueError("SVG data must be str or bytes") + + if not svg_data.strip(): + raise ValueError("SVG data is empty") + + # Extract WIF parts from SECRET section (x="695" text elements) + # Pattern matches: WIF_PART + wif_pattern = r']*>([^<]+)' + wif_matches = re.findall(wif_pattern, svg_data) + + if len(wif_matches) != 3: + raise ValueError( + "Could not extract WIF from SVG (found %d parts)" % len(wif_matches) + ) + + wif = "".join(wif_matches) + + if not is_wif(wif): + raise ValueError("Extracted WIF is invalid: %s" % wif) + + # Extract wallet index from footer + # Pattern matches: Wallet #N + index_pattern = r"Wallet\s+#(\d+)" + index_match = re.search(index_pattern, svg_data) + + wallet_index = 0 + if index_match: + wallet_index = int(index_match.group(1)) + + return KPaperWallet(wallets=[(wallet_index, wif)]) + + # ------------------------- + # Encrypted Wallet Methods + # ------------------------- + + def lock_icon(self, x=0, y=0, size=24, color="#1565c0"): + """Generate a padlock icon using SVG path. + + Used in encrypted wallet headers to indicate security. + + Args: + x: X position. + y: Y position. + size: Icon size (scaled from 24px base). + color: Fill color. + + Returns: + SVG group with lock icon path. + """ + scale = size / 24 + return " ".join( + [ + '' % (x, y, scale), + '', + "", + ] + ) + + def krux_logo(self, x=0, y=0, size=24, color="#fff"): + """Generate the Krux K logo with circular background. + + Args: + x: X position. + y: Y position. + size: Logo diameter. + color: Circle background color. + + Returns: + SVG elements for the K logo. + """ + return " ".join( + [ + # Circle background + '' % color, + # K letter + 'k', + ] + ) + + def encrypted_defs(self): + """Generate SVG defs with blue gradient for encrypted wallet. + + Returns: + SVG defs element with ```encGrad``` linear gradient. + """ + return " ".join( + [ + "", + '', + '', + '', + "", + "", + ] + ) + + def encrypted_background(self): + """Generate the main card background for encrypted wallet. + + Returns: + SVG rect element with blue gradient fill. + """ + return " ".join( + [ + '', + ] + ) + + def encrypted_header(self): + """Generate the header with K logo, lock icon, title and KEF badge. + + Uses ``krux_logo()`` and ``lock_icon()`` methods. + + Returns: + SVG elements for the complete encrypted wallet header. + """ + return " ".join( + [ + # Header bar + '', + '', + # Krux K logo + self.krux_logo(x=12, y=11, size=32, color="#fff"), + # Lock icon (next to K logo) + self.lock_icon(x=50, y=13, size=28, color="#fff"), + # Header text + 'ENCRYPTED WALLET', + # KEF badge + '', + 'KEF FORMAT', + ] + ) + + def encrypted_warning(self): + """Generate the warning box about KEF encrypted format. + + Displays a prominent warning that the QR contains encrypted + data and requires a compatible device to decrypt. + + Returns: + SVG elements for the warning box. + """ + return " ".join( + [ + '', + '', + "⚠ This QR contains encrypted data (KEF)", + '', + "Scan with Krux or compatible device to decrypt", + ] + ) + + def encrypted_qr_section(self, encrypted_data, wallet_id): + """Generate the QR code section with encrypted data and wallet ID. + + Uses the ``qrcode()`` method to render the encrypted data. + + Args: + encrypted_data: Base64-encoded KEF encrypted data. + wallet_id: Wallet identifier label. + + Returns: + SVG elements for QR code container and wallet ID. + """ + return " ".join( + [ + # QR Code container + '', + # QR Code (centered, large) + self.qrcode( + data=encrypted_data, + x=145, + y=132, + size=210, + module_color="#1565c0", + ), + # Wallet ID label + 'ID: %s' + % wallet_id, + ] + ) + + def encrypted_footer(self, wallet_index, wallet_id): + """Generate the footer with wallet number and Krux branding. + + Args: + wallet_index: Index number of this wallet. + wallet_id: Wallet identifier/label for display. + + Returns: + SVG text element for the footer. + """ + return " ".join( + [ + '', + "Wallet #%d | ID: %s" % (wallet_index, wallet_id), + ] + ) + + def to_encrypted_svg(self, encrypted_data, wallet_id, wallet_index=0): + """Generate a complete encrypted paper wallet SVG. + + Assembles all encrypted wallet SVG components into a single + SVG document string. Uses blue color scheme with single QR code. + + Args: + encrypted_data: Base64-encoded KEF encrypted data. + wallet_id: Wallet identifier/label for display. + wallet_index: Index number for multiple wallets. + + Returns: + Complete SVG document as a string. + + Example: + >>> svg = KSVG() + >>> content = svg.to_encrypted_svg( + ... encrypted_data="base64_kef_data", + ... wallet_id="my_wallet", + ... wallet_index=0 + ... ) + """ + return " ".join( + [ + self.xml_open(), + self.open(width=500, height=380), + self.encrypted_defs(), + self.encrypted_background(), + self.encrypted_header(), + self.encrypted_warning(), + self.encrypted_qr_section(encrypted_data, wallet_id), + self.encrypted_footer(wallet_index, wallet_id), + self.close(), + ] + ) + + def to_combined_encrypted_svg(self, encrypted_wallets): + """Generate a combined encrypted paper wallet SVG. + + Creates one SVG document with all encrypted wallets stacked vertically. + + Args: + encrypted_wallets: List of tuples (encrypted_data, wallet_id, wallet_index). + + Returns: + Complete combined SVG document as a string. + """ + wallet_height = 380 + wallet_width = 500 + total_height = wallet_height * len(encrypted_wallets) + + parts = [ + self.xml_open(), + self.open(width=wallet_width, height=total_height), + self.encrypted_defs(), + ] + + for i, (encrypted_data, wallet_id, wallet_index) in enumerate( + encrypted_wallets + ): + y_offset = i * wallet_height + parts.append( + '' % (y_offset, wallet_id) + ) + parts.append(self.encrypted_background()) + parts.append(self.encrypted_header()) + parts.append(self.encrypted_warning()) + parts.append(self.encrypted_qr_section(encrypted_data, wallet_id)) + parts.append(self.encrypted_footer(wallet_index, wallet_id)) + parts.append("") + + parts.append(self.close()) + return " ".join(parts) + + +# ------------------- +# Paper Wallet Class +# ------------------- +class KPaperWallet(KBaseWallet): + """Styled paper wallet generator for Bitcoin wallets. + + Converts ``KSingleWallet`` or ``KBulkWallet`` instances into printable + SVG paper wallets in the bitaddress.org style, with SHARE (public) + and SECRET (private) sections. + + Args: + wallets: List of wallet tuples in (index, wif) format. + + Example: + >>> paper = KPaperWallet([(0, "L4xHo...")]) + >>> svgs = paper.to_svg() + >>> print(svgs[0]) # SVG content for wallet #0 + """ + + def __init__(self, wallets): + super().__init__(wallet_type="paper") + self._wallets = [KSingleWallet(wallet=w) for w in wallets] + self._svg = KSVG() + + @property + def wallets(self): + """list[``KSingleWallet``]: List of wallet instances.""" + return self._wallets + + def to_svg(self): + """Generate SVG paper wallets for all wallets. + + Creates one SVG document per wallet in bitaddress.org style + with SHARE (public address) and SECRET (private key) sections. + + Returns: + List of SVG document strings, one per wallet. + """ + svgs = [] + + for i, wallet in enumerate(self.wallets): + svg = " ".join( + [ + self._svg.xml_open(), + self._svg.open(width=994, height=263), + self._svg.paper_wallet_defs(), + self._svg.paper_wallet_background(), + self._svg.share_section(wallet.address), + self._svg.fold_line(), + self._svg.secret_section(wallet.wif), + self._svg.paper_wallet_footer(i), + self._svg.close(), + ] + ) + svgs.append(svg) + + return svgs + + def to_combined_svg(self): + """Generate a single combined SVG containing all wallets. + + Creates one SVG document with all wallets stacked vertically, + each wallet identified by its checksum. Used for bulk wallet export. + + Returns: + Single SVG document string containing all wallets. + """ + wallet_height = 263 + wallet_width = 994 + total_height = wallet_height * len(self.wallets) + + # Start SVG with combined dimensions + parts = [ + self._svg.xml_open(), + self._svg.open(width=wallet_width, height=total_height), + self._svg.paper_wallet_defs(), + ] + + # Add each wallet in a translated group + for i, wallet in enumerate(self.wallets): + y_offset = i * wallet_height + parts.append( + '' % (y_offset, wallet.checksum) + ) + parts.append(self._svg.paper_wallet_background()) + parts.append(self._svg.share_section(wallet.address)) + parts.append(self._svg.fold_line()) + parts.append(self._svg.secret_section(wallet.wif)) + parts.append(self._svg.paper_wallet_footer(i)) + parts.append("") + + parts.append(self._svg.close()) + return " ".join(parts) + + @staticmethod + def from_single_wallet(w): + """Create a ``KPaperWallet`` from a single wallet. + + Args: + w: ``KSingleWallet`` instance. + + Returns: + ``KPaperWallet`` with one wallet. + """ + return KPaperWallet(wallets=[w.wallet]) + + @staticmethod + def from_bulk_wallet(bulk_wallet): + """Create a ``KPaperWallet`` from a bulk wallet. + + Args: + bulk_wallet: ``KBulkWallet`` instance. + + Returns: + ``KPaperWallet`` containing all wallets from the bulk wallet. + """ + return KPaperWallet(wallets=[w.wallet for w in bulk_wallet.wallets]) + + +# ------------------- +# UI/UX Classes +# ------------------- +class KMenu(Menu): + """Custom menu for the Paper Wallet Kapp. + + Extends the base Krux ``Menu`` with Paper Wallet branding + and custom status bar display. + + Attributes: + TITLE: Menu title displayed in the status bar. + """ + + def __init__( + self, + ctx, + menu, + offset=None, + disable_statusbar=False, + back_label="Back", + back_status=lambda: MENU_EXIT, + ): + super().__init__(ctx, menu, offset, disable_statusbar, back_label, back_status) + self.disable_statusbar = False + self._title = None + if offset is None: + self.menu_offset = STATUS_BAR_HEIGHT + else: + # Always disable status bar if menu has non standard offset + self.disable_statusbar = True + self.menu_offset = offset if offset >= 0 else DEFAULT_PADDING + + @property + def title(self): + return self._title + + @title.setter + def title(self, value: str): + if not KManager.WALLET or not isinstance(KManager.WALLET, KBaseWallet): + self.title = NAME + self.title = value + + def __str__(self): + """Return string representation of the menu.""" + return " ".join( + [ + '", + ] + ) + + def new_draw_wallet_indicator(self): + """Draw the Paper Wallet title in the status bar.""" + if not kboard.is_m5stickv: + self.ctx.display.draw_hcentered_text( + self.title, + STATUS_BAR_HEIGHT - FONT_HEIGHT - 1, + theme.highlight_color, + theme.info_bg_color, + ) + else: + self.ctx.display.draw_string( + 24, + STATUS_BAR_HEIGHT - FONT_HEIGHT - 1, + self.title, + theme.highlight_color, + theme.info_bg_color, + ) + + Menu.draw_wallet_indicator = new_draw_wallet_indicator + + +class KWalletStorage(MnemonicStorage): + """Storage handler for encrypted paper wallets using KEF. + + Extends ``MnemonicStorage`` to handle encrypted paper wallet data + with Krux's KEF (Krux Encrypted Format) encryption. + + Args: + ctx: Krux ``Context`` for display and input. + """ + + def __init__(self, ctx): + super().__init__() + self.ctx = ctx + + def flash_error(self, msg): + """Display an error message and wait for user acknowledgment. + + Args: + msg: Error message to display. + """ + self.ctx.display.clear() + self.ctx.display.draw_centered_text(str(msg), theme.error_color) + self.ctx.input.wait_for_button() + + def prompt(self, text, y_offset): + """Display a yes/no confirmation prompt. + + Args: + text: Prompt text to display. + y_offset: Vertical position for the text. + + Returns: + True if user pressed Enter, False otherwise. + """ + from krux.input import BUTTON_ENTER + + self.ctx.display.draw_hcentered_text(text, y_offset) + return self.ctx.input.wait_for_button() == BUTTON_ENTER + + def decrypt(self, key, mnemonic_id, sd_card=False): + """Decrypt an encrypted wallet from storage. + + Args: + key: Decryption key. + mnemonic_id: Wallet identifier. + sd_card: If True, load from SD card; otherwise from flash. + + Returns: + Decrypted wallet data as string, or None on failure. + """ + try: + if sd_card: + stored_value = self.stored_sd.get(mnemonic_id) + else: + stored_value = self.stored.get(mnemonic_id) + except Exception as e: + self.flash_error(e) + return None + + if stored_value is None: + self.flash_error("Wallet ID '%s' not found" % mnemonic_id) + return None + + from krux import kef + + if stored_value.get("b64_kef"): + envelope = base_decode(stored_value["b64_kef"], 64) + id_, version, iterations, data = kef.unwrap(envelope) + decryptor = kef.Cipher(key, id_, iterations) + decrypted = decryptor.decrypt(data, version) + if decrypted: + if isinstance(decrypted, bytes): + return decrypted.decode("utf-8") + return decrypted + else: + iterations = stored_value.get("key_iterations") + version = stored_value.get("version") + mode = kef.VERSIONS[version]["mode"] + data = base_decode(stored_value.get("data"), 64) + decrypted = self._deprecated_decrypt( + key, mnemonic_id, iterations, mode, data + ) + if decrypted: + if isinstance(decrypted, bytes): + return decrypted.decode("utf-8") + return decrypted + return None + + +class KManager: + """Main coordinator for the Paper Wallet Kapp. + + Manages the application flow between login (``KLogin``) and home + (``KHome``) screens. + + Attributes: + WALLET: Class-level storage for the currently loaded wallet. + + Args: + ctx: Krux ``Context`` for display, input, and settings. + """ + + WALLET = None + + def __init__(self, ctx): + self._ctx = ctx + self._mode_name = Settings().encryption.version + self._storage = KWalletStorage(self.ctx) + + def start(self): + """Start the Paper Wallet application. + + Runs the login flow first to load a wallet, then proceeds + to the home screen for wallet operations. + + Returns: + Menu exit status. + """ + self.on_login() + return self.on_home() + + @property + def ctx(self): + """``Context``: The Krux context instance.""" + return self._ctx + + @ctx.setter + def ctx(self, value): + """Set the context with validation. + + Args: + value: Must be a valid Krux ``Context``. + + Raises: + ValueError: If value is None or not a ``Context``. + """ + if value is None: + raise ValueError("Context must not be None") + + if not isinstance(value, Context): + raise ValueError("Context should be krux based") + self._ctx = value + + def is_logged(self): + """Check if a valid wallet has been loaded. + + Validates that ``KManager.WALLET`` is a valid ``KBaseWallet`` instance. + + Returns: + True if a wallet is loaded and context is valid. + """ + return ( + self.ctx is not None + and isinstance(self.ctx, Context) + and KManager.WALLET is not None + and isinstance(KManager.WALLET, KBaseWallet) + and KManager.WALLET.wallet_type in ("single", "bulk", "paper") + ) + + def on_login(self): + """Display the login screen for wallet loading. + + Returns: + Menu status from the login screen. + """ + klogin = KLogin(self.ctx) + return klogin.login() + + def on_home(self): + """Display the home screen for wallet operations. + + Only shows if a wallet is loaded. + + Returns: + Menu exit status. + """ + if self.is_logged(): + khome = KHome(self.ctx) + return khome.home() + return MENU_EXIT + + +class KLogin(Login): + """Login screen for loading paper wallets. + + Extends Krux ``Login`` page with Paper Wallet-specific options + to load wallets from QR codes or SD card, view information + about the Kapp, and shutdown/reboot. + + Args: + ctx: Krux ``Context``. + """ + + def __init__(self, ctx): + super().__init__(ctx) + + def login(self): + """Display the main login menu. + + Returns: + Menu exit status. + """ + shtn_reboot_label = t("Shutdown") if kboard.has_battery else t("Reboot") + submenu = KMenu( + self.ctx, + [ + (t("Load wallet"), self.load_wallet), + (t("About"), self.about), + (shtn_reboot_label, self.shutdown), + ], + back_label=None, + ) + submenu.title = NAME + + _, status = submenu.run_loop() + if status == MENU_SHUTDOWN: + return MENU_SHUTDOWN + return MENU_EXIT + + def load_wallet(self): + """Handler for the 'Load wallet' menu item""" + index = self.load_method() + + if index == 0: + return self.from_qrcode() + if index == 1: + return self.from_sdcard() + return MENU_EXIT + + def about(self): + """Handler for the 'about' menu item""" + self.ctx.display.clear() + self.ctx.display.draw_centered_text( + "Kapp %s\n%s: %s\n\n" % (NAME, t("Version"), VERSION) + + t("Load paper wallets from https://bitaddress.org") + ) + self.ctx.input.wait_for_button() + return MENU_CONTINUE + + def from_sdcard(self): + """Handler for the 'SDCard' ``KMenu`` item. + + Iteratively adds wallets from SD card while user does not + confirm they are done, allowing bulk wallet creation. + """ + return self.load_from(on_load=self.on_load_sdcard) + + def from_qrcode(self): + """Handler for the 'From QRCode' ``KMenu`` item. It iteratively adds + addresses and keys while user does not break the loop.""" + return self.load_from(on_load=self.on_load_qrcode) + + def on_load_qrcode(self): + """Load ``QRCodeCapture`` instance and show the provided data into + a QRCode to confirm, through visualization.""" + qr = QRCodeCapture(self.ctx) + + # Capture the data + data, _ = qr.qr_capture_loop() + + # show QRCode so user can confirm + self.display_qr_codes( + data=data, title=": ".join(["WIF", data]), highlight_prefix=":" + ) + return data + + def on_load_sdcard(self): + """Uses ``KWalletStorage`` to load and decrypt a mnemonic from SD card.""" + from krux.pages.encryption_ui import EncryptionKey + + storage = KWalletStorage(self.ctx) + wallets_ids = storage.list_mnemonics(sd_card=True) + + if not wallets_ids: + self.flash_error(t("No wallets found")) + return None + + submenu = Menu( + self.ctx, + [(id, lambda: None) for id in wallets_ids], + ) + + index, _ = submenu.run_loop() + if index == len(wallets_ids): + return None + + wallet_id = wallets_ids[index] + + if not self.prompt( + t("Decrypt %s?") % wallet_id, + self.ctx.display.height() // 2, + ): + return None + + keypage = EncryptionKey(self.ctx) + key = keypage.encryption_key() + return storage.decrypt(key, wallet_id, sd_card=True) + + def extract_wif(self, data): + """Extract WIF from data in various formats (WIF, Base64, CSV). + Returns the WIF string if valid, or None on error.""" + if is_wif(data): + return data + + if is_base64(data): + decoded = base_decode(data, 64) + wif = base_encode(decoded, 58) + if is_wif(wif): + return wif + self.flash_error(t("Invalid WIF from Base64")) + return None + + if is_csv(data): + parts = data.strip().split(",") + wif = parts[-1].strip().strip('"') + if is_wif(wif): + return wif + self.flash_error(t("Invalid WIF from CSV")) + return None + + if is_svg(data): + self.flash_error(t("SVG import not supported")) + return None + + self.flash_error(t("Unknown format")) + return None + + def load_from(self, on_load=None): + """Load some wallet from ``QRCodeCapture`` or keypad.""" + on_load = normalize_on_load(on_load) + entries = [] + index = 0 + + while True: + wallet = [index] + self.ctx.display.clear() + self.ctx.display.draw_hcentered_text(t("Add one or more WIFs")) + if not self.prompt(t("Proceed?"), BOTTOM_PROMPT_LINE): + return MENU_CONTINUE + + data = on_load() + if data is None: + return MENU_CONTINUE + + if not isinstance(data, str): + self.flash_error(t("Invalid data")) + return MENU_CONTINUE + + wif = self.extract_wif(data) + if wif is None: + return MENU_CONTINUE + + wallet.append(wif) + entries.append(tuple(wallet)) + + self.ctx.display.clear() + if self.prompt(t("Done?"), self.ctx.display.height() // 2): + break + + index += 1 + + if len(entries) == 1: + KManager.WALLET = KSingleWallet(wallet=entries[0]) + else: + KManager.WALLET = KBulkWallet(wallets=entries) + + return MENU_EXIT + + +class KHome(Home): + """Home screen for wallet operations. + + Extends Krux ``Home`` page with Paper Wallet-specific options + to backup the loaded wallet in various formats (Base64, Hex, CSV, SVG), + with optional encryption support. + + The menu title displays the wallet checksum for identification. + + Args: + ctx: Krux ``Context``. + """ + + def __init__(self, ctx): + super().__init__(ctx) + + def home(self): + """Display the main home menu with wallet operations. + + Returns: + Menu exit status. + """ + shtn_reboot_label = t("Shutdown") if kboard.has_battery else t("Reboot") + submenu = KMenu( + self.ctx, + [ + (t("Backup key"), lambda: self.on_key("backup")), + (t("Export key"), lambda: self.on_key("export")), + (t("Sign transaction"), None), + (t("Sign message"), None), + (shtn_reboot_label, self.shutdown), + ], + back_label=None, + ) + submenu.title = KManager.WALLET.checksum + + _, status = submenu.run_loop() + if status == MENU_SHUTDOWN: + return MENU_SHUTDOWN + return MENU_EXIT + + def on_key(self, kind="backup"): + """Display the backup or export format selection menu. + + ``Backup key`` menu item will encrypt WIF(s) on ``seeds.json`` in CSV + format while the ``Export key`` will format the wallet with the following options: + + - Base64; + - Hex; + - CSV; + - SVG. + + Each can be saved as plaintext or encrypted. + + Returns: + Menu status. + """ + + def on_save(sd_card=False): + """Helper to backup wallets (single or bulk) to encrypted storage.""" + if isinstance(KManager.WALLET, KSingleWallet): + return self.backup_wallet(KManager.WALLET.wif, sd_card) + + # KBulkWallet: backup each wallet + for wallet in KManager.WALLET.wallets: + encrypted_data, wallet_id = self._encrypt_data(wallet.wif) + + # If user cancelled encryption, stop the loop + if encrypted_data is None: + return MENU_CONTINUE + + storage = KWalletStorage(self.ctx) + if storage.store_encrypted_kef(wallet_id, encrypted_data, sd_card): + self.ctx.display.clear() + self.ctx.display.draw_centered_text( + t("Encrypted wallet stored with ID:") + " " + wallet_id, + highlight_prefix=":", + ) + else: + self.ctx.display.clear() + self.ctx.display.draw_centered_text( + t("Failed to store wallet"), theme.error_color + ) + self.ctx.input.wait_for_button() + + return MENU_CONTINUE + + if kind == "backup": + submenu = KMenu( + self.ctx, + [ + (t("To SDCard"), lambda: on_save(sd_card=True)), + (t("To Flash"), lambda: on_save(sd_card=False)), + (t("Back <"), lambda: MENU_EXIT), + ], + back_label=None, + ) + else: + submenu = KMenu( + self.ctx, + [ + (t("to Base64"), lambda k=kind: self.to_base_64(k)), + (t("to Hex"), lambda k=kind: self.to_hex(k)), + (t("to CSV"), lambda k=kind: self.to_csv(k)), + (t("to SVG"), lambda k=kind: self.to_svg(k)), + (t("Back <"), lambda: MENU_EXIT), + ], + back_label=None, + ) + + submenu.title = KManager.WALLET.checksum + + _, status = submenu.run_loop() + if status == MENU_SHUTDOWN: + return MENU_SHUTDOWN + return MENU_CONTINUE + + def to_base_64(self, kind): + """Export the wallet as Base64 encoded WIF. + + Converts the WIF from Base58 to Base64 encoding. + For bulk wallets, exports as CSV with Base64 encoded WIFs. + + Args: + kind: Either "backup" or "export" mode. + + Returns: + Menu continue status. + """ + if KManager.WALLET.wallet_type == "single": + wif = KManager.WALLET.wif + decoded = base_decode(wif, 58) + encoded = base_encode(decoded, 64) + self.save_to( + title="Base64 private key", + plaintext=encoded, + kind=kind, + display_qr=True, + ) + else: + # Bulk wallet: CSV format with Base64 encoded WIFs + lines = [] + for wallet in KManager.WALLET.wallets: + decoded = base_decode(wallet.wif, 58) + b64_wif = base_encode(decoded, 64) + lines.append('%d, "%s", "%s"' % (wallet.index, wallet.address, b64_wif)) + encoded = "\n".join(lines) + self.save_to( + title="Base64 CSV", + plaintext=encoded, + kind=kind, + file_extension=".csv", + sd_card=True, + ) + return MENU_CONTINUE + + def to_hex(self, kind): + """Export the wallet as hexadecimal encoded WIF. + + Converts the WIF from Base58 to hexadecimal encoding. + For bulk wallets, exports as CSV with hex encoded WIFs. + + Args: + kind: Either "backup" or "export" mode. + + Returns: + Menu continue status. + """ + from binascii import hexlify + + if KManager.WALLET.wallet_type == "single": + decoded = base_decode(KManager.WALLET.wif, 58) + encoded = hexlify(decoded).decode("utf-8") + self.save_to( + title="Hex private key", + plaintext=encoded, + kind=kind, + display_qr=True, + ) + else: + # Bulk wallet: CSV format with hex encoded WIFs + lines = [] + for wallet in KManager.WALLET.wallets: + decoded = base_decode(wallet.wif, 58) + hex_wif = hexlify(decoded).decode("utf-8") + lines.append('%d, "%s", "%s"' % (wallet.index, wallet.address, hex_wif)) + encoded = "\n".join(lines) + self.save_to( + title="Hex CSV", + plaintext=encoded, + kind=kind, + file_extension=".csv", + sd_card=True, + ) + return MENU_CONTINUE + + def to_csv(self, kind): + """Export the wallet as CSV format. + + Generates CSV with format: index, "address", "wif" + Supports both single and bulk wallets. + + Args: + kind: Either "backup" or "export" mode. + + Returns: + Menu continue status. + """ + if KManager.WALLET.wallet_type == "single": + encoded = '%d, "%s", "%s"' % ( + KManager.WALLET.index, + KManager.WALLET.address, + KManager.WALLET.wif, + ) + else: + encoded = "\n".join( + '%d, "%s", "%s"' % (wallet.index, wallet.address, wallet.wif) + for wallet in KManager.WALLET.wallets + ) + + self.save_to( + title="CSV private key", + plaintext=encoded, + kind=kind, + file_extension=".csv", + sd_card=True, + ) + return MENU_CONTINUE + + def to_svg(self, kind): + """Export the wallet as SVG paper wallet(s). + + Generates bitaddress.org-style paper wallet SVG files with + SHARE and SECRET sections. + + For single wallets: Creates one SVG file. + For bulk wallets: Creates a single combined SVG with all wallets, + each identified by its checksum. + + Args: + kind: Either "backup" or "export" mode. + + Returns: + Menu continue status. + """ + if KManager.WALLET.wallet_type == "single": + wallet = KPaperWallet.from_single_wallet(KManager.WALLET) + elif KManager.WALLET.wallet_type == "bulk": + wallet = KPaperWallet.from_bulk_wallet(KManager.WALLET) + else: + wallet = KManager.WALLET + + # Single wallet: original behavior + if KManager.WALLET.wallet_type == "single": + svg_content = wallet.to_svg()[0] + encrypt = self.prompt(t("Encrypt?"), self.ctx.display.height() // 2) + plaintext = wallet.wallets[0].wif if encrypt else svg_content + self.save_to( + title="SVG paper wallet", + plaintext=plaintext, + encrypt=encrypt, + kind=kind, + file_extension=".svg", + sd_card=True, + ) + return MENU_CONTINUE + + # Bulk wallet: create combined SVG + encrypt = self.prompt(t("Encrypt all?"), self.ctx.display.height() // 2) + + if not encrypt: + # Unencrypted: combined SVG with all wallets + svg_content = wallet.to_combined_svg() + self._save_bulk_svg(svg_content, kind) + else: + # Encrypted: encrypt each wallet and create combined encrypted SVG + encrypted_wallets = [] + for i, w in enumerate(wallet.wallets): + decoded = base_decode(w.wif, 58) + b64_wif = base_encode(decoded, 64) + encrypted_data, wallet_id = self._encrypt_data(b64_wif) + + if encrypted_data is None: + return MENU_CONTINUE + + b64_data = base_encode(encrypted_data, 64) + encrypted_wallets.append((b64_data, wallet_id, i)) + + enc_svg = KSVG() + svg_content = enc_svg.to_combined_encrypted_svg(encrypted_wallets) + self._save_bulk_svg(svg_content, kind, encrypted=True) + + return MENU_CONTINUE + + def _save_bulk_svg(self, svg_content, kind, encrypted=False): + """Save a bulk wallet combined SVG file. + + Args: + svg_content: The combined SVG content. + kind: Either "backup" or "export" mode. + encrypted: Whether the SVG is encrypted. + """ + from krux.pages.file_operations import SaveFile + + sf = SaveFile(self.ctx) + checksum = KManager.WALLET.checksum + suffix = "_encrypted" if encrypted else "" + + try: + sf.save_file( + svg_content, + checksum, + filename=checksum + suffix, + file_description=t("Bulk paper wallet"), + file_extension=".svg", + save_as_binary=False, + ) + self.ctx.display.clear() + self.ctx.display.draw_centered_text( + t("Saved:") + " " + checksum + suffix + ".svg", + highlight_prefix=":", + ) + self.ctx.input.wait_for_button() + except Exception as e: + self.flash_error(e) + + # pylint: disable=too-many-arguments + def save_to( + self, + title, + plaintext, + encrypt=False, + kind="backup", + file_extension=".txt", + sd_card=True, + display_qr=False, + ): + """Save wallet data to storage. + + Dispatches to either ``backup_wallet`` or ``export_wallet`` based + on the kind parameter. + + Args: + title: Display title for the data. + plaintext: The wallet data to save. + encrypt: Whether to encrypt the data before saving. + kind: Either "backup" (to encrypted storage) or "export" (to file). + file_extension: File extension for exports (.txt, .csv, .svg). + sd_card: Whether to save to SD card (for backup). + display_qr: Whether to display QR code before saving. + + Returns: + Menu status. + """ + if display_qr: + self.display_qr_codes( + data=plaintext, + title=title, + ) + + on_save = getattr(self, "%s_wallet" % kind) + if kind == "backup": + return on_save(plaintext, sd_card=sd_card) + if kind == "export": + # pylint: disable=too-many-function-args + return on_save(plaintext, file_extension, encrypt) + return MENU_CONTINUE + + def backup_wallet(self, plaintext, sd_card=False): + """Backup wallet to encrypted KEF storage. + + Encrypts the wallet data using KEF and stores it in flash + or SD card storage. + + Args: + plaintext: The wallet data to encrypt and store. + sd_card: If True, store on SD card; otherwise store in flash. + + Returns: + Menu continue status. + """ + encrypted_data, wallet_id = self._encrypt_data(plaintext) + + if encrypted_data is None: + return MENU_CONTINUE + + storage = KWalletStorage(self.ctx) + if storage.store_encrypted_kef(wallet_id, encrypted_data, sd_card): + self.ctx.display.clear() + self.ctx.display.draw_centered_text( + t("Encrypted wallet stored with ID:") + " " + wallet_id, + highlight_prefix=":", + ) + else: + self.ctx.display.clear() + self.ctx.display.draw_centered_text( + t("Failed to store wallet"), theme.error_color + ) + self.ctx.input.wait_for_button() + return MENU_CONTINUE + + def export_wallet(self, plaintext, file_extension=".txt", encrypt=False): + """Export wallet data to a file. + + Saves wallet data to a file on SD card, optionally encrypting it first. + For SVG exports, generates an encrypted paper wallet SVG. + + Args: + plaintext: The wallet data to export. + file_extension: File extension (.txt, .csv, or .svg). + encrypt: Whether to encrypt the data before saving. + + Returns: + Menu continue status. + """ + from krux.pages.file_operations import SaveFile + + sf = SaveFile(self.ctx) + self.ctx.display.clear() + + if not encrypt: + try: + sf.save_file( + plaintext, + KManager.WALLET.checksum, + filename=KManager.WALLET.checksum, + file_description="%s WIF" % KManager.WALLET.wallet_type, + file_extension=file_extension, + save_as_binary=False, + ) + except Exception as e: + self.flash_error(e) + return MENU_CONTINUE + + # Encrypted export + try: + if file_extension in (".txt", ".csv"): + encrypted_data, wallet_id = self._encrypt_data(plaintext) + if encrypted_data is None: + return MENU_CONTINUE + content = base_encode(encrypted_data, 64) + else: + # SVG: extract wallet data and create encrypted SVG + decoded = base_decode(plaintext, 58) + b64_wif = base_encode(decoded, 64) + encrypted_data, wallet_id = self._encrypt_data(b64_wif) + if encrypted_data is None: + return MENU_CONTINUE + b64_data = base_encode(encrypted_data, 64) + + enc_svg = KSVG() + wallet_index = ( + KManager.WALLET.index if hasattr(KManager.WALLET, "index") else 0 + ) + content = enc_svg.to_encrypted_svg( + encrypted_data=b64_data, + wallet_id=wallet_id, + wallet_index=wallet_index, + ) + + sf.save_file( + content, + wallet_id, + filename=wallet_id + "_encrypted", + file_description=t("Encrypted wallet"), + file_extension=file_extension, + save_as_binary=False, + ) + self._show_save_success(wallet_id, file_extension) + except Exception as e: + self.flash_error(e) + return MENU_CONTINUE + + def _show_save_success(self, wallet_id, extension): + """Display save success message. + + Args: + wallet_id: The wallet identifier. + extension: The file extension used. + """ + self.ctx.display.clear() + self.ctx.display.draw_centered_text( + t("Encrypted wallet saved:") + "\n" + wallet_id + extension, + highlight_prefix=":", + ) + self.ctx.input.wait_for_button() + + def _encrypt_data(self, plaintext=""): + """Encrypt wallet data using KEF envelope. + + Uses the wallet checksum as the default label for the encrypted data. + + Args: + plaintext: The data to encrypt (string). + + Returns: + Tuple of (encrypted_data, wallet_id) or (None, None) on failure. + """ + kef_envelope = KEFEnvelope(self.ctx) + + # Obfuscate the wallet type by using the checksum as label + kef_envelope.label = KManager.WALLET.checksum + data = plaintext.encode() if isinstance(plaintext, str) else plaintext + encrypted_data = kef_envelope.seal_ui( + data, + overrides=[OVERRIDE_LABEL], + dflt_label_affirm=True, + ) + + if encrypted_data is None: + return None, None + + wallet_id = kef_envelope.label + return encrypted_data, wallet_id + + +def run(ctx): + """Entry point for the Paper Wallet Kapp. + + This is the main function called by Krux to start the Paper Wallet + application. It initializes the KManager and starts the application + flow (login -> home). + + Args: + ctx: Krux Context + + Returns: + Menu exit status from the application. + """ + try: + manager = KManager(ctx) + manager.start() + except Exception as e: + print(e) diff --git a/kapps/steganography.mpy b/kapps/steganography.mpy new file mode 100644 index 000000000..6c2bc9060 Binary files /dev/null and b/kapps/steganography.mpy differ diff --git a/kapps/steganography.py b/kapps/steganography.py new file mode 100644 index 000000000..b7df0036a --- /dev/null +++ b/kapps/steganography.py @@ -0,0 +1,569 @@ +# The MIT License (MIT) + +# Copyright (c) 2021-2024 Krux contributors + +# 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. +import os + +# avoids importing from flash VSF +os.chdir("/") + +VERSION = "1.0" +NAME = "Steganography" + +from krux.pages import ( + Menu, + MENU_CONTINUE, + MENU_EXIT, + ESC_KEY, + LETTERS, + UPPERCASE_LETTERS, + NUM_SPECIAL_1, + NUM_SPECIAL_2, +) +from krux.krux_settings import t +from krux.display import ( + STATUS_BAR_HEIGHT, + FONT_HEIGHT, + DEFAULT_PADDING, + BOTTOM_PROMPT_LINE, +) +from krux.themes import theme +from krux.kboard import kboard +from krux.wdt import wdt +from krux.pages.device_tests import DeviceTests +from krux.sd_card import BMP_IMAGE_EXTENSION +from krux.pages.file_operations import SaveFile +from krux.settings import SD_PATH +from krux.pages.utils import Utils +import image +import lcd +import gc + +# 16 bits - 2 bytes - 565 +# 65535 - white 1111 1111 1111 1111 +# 248 - red 0000 0000 1111 1000 +# 57351 - green 1110 0000 0000 0111 +# 7936 - blue 0001 1111 0000 0000 + +PAYLOAD_LEN_BYTES = 3 # sufficient to ~4000x4000 px (OOM already with 600x550 px) +PAYLOAD_ENDIAN = "big" + +LSB_SHIFTS = [13, 3, 8] # 13G, 3R, 8B +BITS_PER_PIXEL = len(LSB_SHIFTS) +MASKS = [1 << s for s in LSB_SHIFTS] + +# ------------------- + + +class KMenu(Menu): + """Customizes the page's menu""" + + def __init__( + self, + ctx, + menu, + offset=None, + disable_statusbar=False, + back_label="Back", + back_status=lambda: MENU_EXIT, + ): + super().__init__(ctx, menu, offset, disable_statusbar, back_label, back_status) + self.disable_statusbar = False + if offset is None: + self.menu_offset = STATUS_BAR_HEIGHT + else: + # Always disable status bar if menu has non standard offset + self.disable_statusbar = True + self.menu_offset = offset if offset >= 0 else DEFAULT_PADDING + + def new_draw_wallet_indicator(self): + """Customize the top bar""" + if not kboard.is_m5stickv: + self.ctx.display.draw_hcentered_text( + NAME, + STATUS_BAR_HEIGHT - FONT_HEIGHT - 1, + theme.highlight_color, + theme.info_bg_color, + ) + else: + self.ctx.display.draw_string( + 24, + STATUS_BAR_HEIGHT - FONT_HEIGHT - 1, + NAME, + theme.highlight_color, + theme.info_bg_color, + ) + + def new_draw_network_indicator(self): + """Don't draw testnet""" + + # Overwrite Menu top bar functions to allow code reuse + Menu.draw_wallet_indicator = new_draw_wallet_indicator + Menu.draw_network_indicator = new_draw_network_indicator + + +# ------------------- + + +class Kapp(DeviceTests): + """Represents the page of the kapp""" + + def __init__(self, ctx): + shtn_reboot_label = ( + t("Shutdown") if ctx.power_manager.has_battery() else t("Reboot") + ) + super().__init__( + ctx, + ) + self.menu = KMenu( + ctx, + [ + (t("BMP Via Camera"), self.camera), + (t("SD Card"), self.sd_menu), + (t("Hide Data in BMP"), self.hide_menu), + (t("Reveal Data"), self.reveal_menu), + (t("About"), self.about), + (shtn_reboot_label, self.shutdown), + ], + back_label=None, + ) + + def camera(self): + """Capture a BMP by camera""" + self.ctx.display.clear() + self.ctx.display.draw_centered_text(t("TOUCH or ENTER to capture")) + self.ctx.display.to_landscape() + self.ctx.camera.initialize_run() + self.ctx.display.clear() + + # Flush events ocurred while loading camera + self.ctx.input.reset_ios_state() + + leave = False + while True: + wdt.feed() + + img = self.ctx.camera.snapshot() + + if self.ctx.input.enter_event() or self.ctx.input.touch_event( + validate_position=False + ): + break + if self.ctx.input.page_event() or self.ctx.input.page_prev_event(): + leave = True + break + + self.ctx.display.render_image(img) + + self.ctx.display.to_portrait() + self.ctx.camera.stop_sensor() + + # User cancelled + if leave: + self.flash_error(t("Capture cancelled")) + return MENU_CONTINUE + + self.ctx.input.reset_ios_state() + if self.prompt( + t("Save to SD card?"), + BOTTOM_PROMPT_LINE, + ): + sf = SaveFile(self.ctx) + new_filename = sf.set_filename( + empty_filename="photo", + file_extension=BMP_IMAGE_EXTENSION, + ) + + if new_filename == ESC_KEY: + return MENU_CONTINUE + + # if user defined a filename and it is ok, save! + if new_filename: + # clear and say something to the user + self.ctx.display.clear() + self.ctx.display.draw_centered_text(t("Processing…")) + + # Now save the file + img.save("/%s/%s" % (SD_PATH, new_filename)) + + # Show the user the filename + self.flash_text( + t("Saved to SD card:") + "\n\n%s" % new_filename, + highlight_prefix=":", + ) + return MENU_CONTINUE + else: + self.flash_error(t("Capture cancelled")) + + return MENU_CONTINUE + + def sd_menu(self): + """Handler for the 'SD card' menu item""" + submenu = Menu( + self.ctx, + [ + (t("Check SD Card"), self.sd_check), + (t("View BMP"), self.view), + ], + ) + index, status = submenu.run_loop() + if index == submenu.back_index: + return MENU_CONTINUE + return status + + def view(self): + """Handler for the 'View BMP' menu item""" + if not self.has_sd_card(): + self.flash_error(t("SD card not detected.")) + return MENU_CONTINUE + + utils = Utils(self.ctx) + img_path, _ = utils.load_file( + file_ext=BMP_IMAGE_EXTENSION, + prompt=False, + only_get_filename=True, + ) + + # pressed Back, no file selected + if img_path == "": + return MENU_CONTINUE + + self.ctx.display.clear() + # idea: use img.width(), img.height() to rotate or not + self.ctx.display.to_landscape() + try: + img = image.Image("/%s/%s" % (SD_PATH, img_path)) + lcd.display(img) + except: + self.ctx.display.to_portrait() + self.flash_error(t("Image is too big!")) + return MENU_CONTINUE + + self.ctx.input.wait_for_button() + self.ctx.display.to_portrait() + return MENU_CONTINUE + + def about(self): + """Handler for the 'about' menu item""" + + self.ctx.display.clear() + self.ctx.display.draw_centered_text( + "Kapp %s\n%s: %s\n\n" % (NAME, t("Version"), VERSION) + + t("Conceal data within BMP.") + ) + self.ctx.input.wait_for_button() + return MENU_CONTINUE + + def hide_menu(self): + """Handler for the 'Hide Data in BMP' menu item""" + self.ctx.display.clear() + self.ctx.display.draw_centered_text(t("Provide the data to hide")) + if not self.prompt(t("Proceed?"), BOTTOM_PROMPT_LINE): + return MENU_CONTINUE + + submenu = KMenu( + self.ctx, + [ + (t("Via Camera"), self.scan_qr), + (t("Via Manual Input"), self.text_entry), + (t("From Storage"), self.read_file), + ], + ) + index, status = submenu.run_loop() + if index == submenu.back_index or status == MENU_CONTINUE: + return MENU_CONTINUE + + secret = status + + self.ctx.display.clear() + self.ctx.display.draw_centered_text(t("Select the BMP file")) + if not self.prompt(t("Proceed?"), BOTTOM_PROMPT_LINE): + return MENU_CONTINUE + + if not self.has_sd_card(): + self.flash_error(t("SD card not detected.")) + return MENU_CONTINUE + + utils = Utils(self.ctx) + img_path, _ = utils.load_file( + file_ext=BMP_IMAGE_EXTENSION, + prompt=False, + only_get_filename=True, + ) + + # pressed Back, no file selected + if img_path == "": + return MENU_CONTINUE + + split_img_path = img_path.split("/") + sf = SaveFile(self.ctx) + new_filename = sf.set_filename( + curr_filename=split_img_path[-1], + file_extension=BMP_IMAGE_EXTENSION, + suffix="-hid", + ) + + if new_filename == ESC_KEY: + return MENU_CONTINUE + + # if user defined a filename and it is ok, save! + if new_filename: + # clear and say something to the user + self.ctx.display.clear() + self.ctx.display.draw_centered_text(t("Processing…")) + + new_filename_path = SD_PATH + "/" + if len(split_img_path) > 1: + new_filename_path += "/".join(split_img_path[:-1]) + "/" + new_filename_path += new_filename + + self.hide_data(secret, "/%s/%s" % (SD_PATH, img_path), new_filename_path) + gc.collect() + + # Show the user the filename + self.flash_text( + t("Saved to SD card:") + "\n\n%s" % new_filename, + highlight_prefix=":", + ) + + retrieved = self.extract_data(new_filename_path) + if retrieved != secret: + self.flash_error(t("Could not retrieve secret from %s") % new_filename) + + return MENU_CONTINUE + + def hide_data(self, secret: bytes, cover_path: str, stego_path: str): + """Hide secret into cover_path file and output result to stego_path file""" + + def _bits_generator(payload): + for b in payload: + # for every bit in a byte + for i in range(7, -1, -1): + yield (b >> i) & 1 + + try: + img = image.Image(cover_path) + except: + self.flash_error(t("Image is too big!")) + return MENU_CONTINUE + w, h = img.width(), img.height() + + payload = len(secret).to_bytes(PAYLOAD_LEN_BYTES, PAYLOAD_ENDIAN) + secret + bit_gen = _bits_generator(payload) + bits_needed = len(payload) * 8 # each char in binary is 1 byte + pixels_needed = -(-bits_needed // BITS_PER_PIXEL) # ceil of division + if pixels_needed > w * h: + raise ValueError("Need %s px, image has %s" % (pixels_needed, w * h)) + + # encode all bits in the img + bit_idx = 0 + for y in range(h): + for x in range(w): + if bit_idx >= bits_needed: + break + pixel = img.get_pixel(x, y, rgbtuple=False) # raw 16-bit int + + for i in range(BITS_PER_PIXEL): + if bit_idx >= bits_needed: + break + try: + bit = next(bit_gen) + except StopIteration: + break + shift = LSB_SHIFTS[i] + mask = MASKS[i] + pixel = (pixel & ~mask) | (bit << shift) + bit_idx += 1 + img.set_pixel(x, y, pixel) + if bit_idx >= bits_needed: + break + + img.save(stego_path) + return MENU_CONTINUE + + def extract_data(self, stego_path: str): + """Return secret extracted from stego_path""" + try: + img = image.Image(stego_path) + except: + self.flash_error(t("Image is too big!")) + return MENU_CONTINUE + w, h = img.width(), img.height() + + # Read total_bits length + payload_len = 0 + bits_read = 0 + pos_x = pos_y = 0 + total_bits = PAYLOAD_LEN_BYTES * 8 + while bits_read < total_bits: + pixel = img.get_pixel(pos_x, pos_y, rgbtuple=False) + + for i in range(BITS_PER_PIXEL): + if bits_read >= total_bits: + break + shift = LSB_SHIFTS[i] + bit = (pixel >> shift) & 1 + payload_len = (payload_len << 1) | bit + bits_read += 1 + + # Advance position + pos_x += 1 + if pos_x >= w: + pos_x = 0 + pos_y += 1 + if pos_y >= h: + raise ValueError("Not enough px to retrieve data") + + # Read payload byte-by-byte + payload = bytearray() + cur = cur_bits = 0 + total_bits = 0 + bits_needed = payload_len * 8 + while total_bits < bits_needed: + pixel = img.get_pixel(pos_x, pos_y, rgbtuple=False) + + for i in range(BITS_PER_PIXEL): + if total_bits >= bits_needed: + break + shift = LSB_SHIFTS[i] + bit = (pixel >> shift) & 1 + cur = (cur << 1) | bit + cur_bits += 1 + + if cur_bits == 8: + payload.append(cur) + total_bits += 8 + cur = cur_bits = 0 + + # Advance position + pos_x += 1 + if pos_x >= w: + pos_x = 0 + pos_y += 1 + if pos_y >= h: + break + + if len(payload) < payload_len: + raise ValueError("Payload truncated") + return bytes(payload[:payload_len]) + + def reveal_menu(self): + """Handler for the 'Reveal Data' menu item""" + utils = Utils(self.ctx) + img_path, _ = utils.load_file( + file_ext=BMP_IMAGE_EXTENSION, + prompt=False, + only_get_filename=True, + ) + + # pressed Back, no file selected + if img_path == "": + return MENU_CONTINUE + + secret = self.extract_data("/%s/%s" % (SD_PATH, img_path)) + + self.ctx.display.clear() + if not secret or secret == b"\x00": + self.flash_error(t("No secret found!")) + return MENU_CONTINUE + + self.flash_text(t("Found secret:") + "\n%s bytes" % len(secret)) + + from krux.pages.datum_tool import DatumTool + + page = DatumTool(self.ctx) + page.contents = secret + page.title = img_path.rsplit("/", 1)[-1] + return page.view_contents() + + def scan_qr(self): + """Handler for the 'Scan a QR' menu item""" + + from krux.pages.qr_capture import QRCodeCapture + import urtypes + + qr_scanner = QRCodeCapture(self.ctx) + contents, fmt = qr_scanner.qr_capture_loop() + if contents is None: + self.flash_error(t("Failed to load")) + return MENU_CONTINUE + + if fmt == 2: + if contents.type == "bytes": + contents = urtypes.bytes.Bytes.from_cbor(contents.cbor).data + + if isinstance(contents, str): + contents = contents.encode("utf-8") + + return contents + + def text_entry(self): + """Handler for the 'Text Entry' menu item""" + + text = "" + while True: + # Loop until user types a valid data or press ESC + text = self.capture_from_keypad( + t("Filename"), + [LETTERS, UPPERCASE_LETTERS, NUM_SPECIAL_1, NUM_SPECIAL_2], + starting_buffer=text, + ) + + if text == ESC_KEY: + return MENU_CONTINUE + + self.ctx.display.clear() + self.ctx.display.draw_centered_text( + t("Proceed?") + " " + text, highlight_prefix="?" + ) + if self.prompt("", BOTTOM_PROMPT_LINE): + return text.encode("utf-8") + + return MENU_CONTINUE + + def read_file(self): + """Handler for the 'Read File' menu item""" + if not self.has_sd_card(): + self.flash_error(t("SD card not detected.")) + return MENU_CONTINUE + + utils = Utils(self.ctx) + try: + _, contents = utils.load_file(prompt=False) + except OSError: + pass + + if not contents: + return MENU_CONTINUE + + self.ctx.display.clear() + self.ctx.display.draw_centered_text(t("Processing…")) + + # utils.load_file() always returns binary + return contents + + +# ------------------- + + +def run(ctx): + """Runs this kapp""" + + Kapp(ctx).run() diff --git a/krux b/krux index 13dedb84b..2122e88b2 100755 --- a/krux +++ b/krux @@ -74,18 +74,30 @@ if [ "$1" == "build" ]; then [ "$allow_unsupported" ]; then declare $(grep -m 1 version pyproject.toml | sed 's/ *= */=/g' | sed 's/"//g' | sed 's/\r//g') sed -i -- 's/VERSION = ".*"/VERSION = "'"$version"'"/g' src/krux/metadata.py - + # docs: latest_krux version sed -i -- 's/ latest_krux: krux-v.*/ latest_krux: krux-v'$version'/g' mkdocs.yml mkdir -p build - docker build . -t krux-builder --build-arg DEVICE=$device + docker build --build-arg DEVICE=$device --target build -t krux-builder . # Create a container to extract the built firmware docker create --name extract-firmware krux-builder - docker cp extract-firmware:$BUILD_DIR/firmware.bin build/firmware.bin - docker cp extract-firmware:$BUILD_DIR/kboot.kfpkg build/kboot.kfpkg - docker rm extract-firmware + + docker cp "extract-firmware:$BUILD_DIR" build/firmware.bin + docker cp "extract-firmware:$BUILD_DIR" build/kboot.kfpkg + docker rm -f extract-firmware + + # Use $device variable to reuse the command to compile krux apps + elif [ "$device" == "kapp" ]; then + kapp="$3" + image="kapp-${kapp}" + container="build-kapp-${kapp}" + + docker build --build-arg KAPP=$kapp --target build-kapp -t $image . + docker create --name $container $image + docker cp $container:/out/${kapp}.mpy kapps/$kapp.mpy + docker rm -f $container else echo "Wrong or unsupported device name, use maixpy_devicename" fi @@ -246,7 +258,19 @@ elif [ "$1" == "sign" ]; then echo "Missing private_key" else sig_bin=$file.sig - openssl dgst -sign $privkey_pem -keyform PEM -sha256 -out $sig_bin -binary $file + # ECDSA signatures using openssl vary in size (70–72 bytes) due to DER encoding of r and s values. + # Krux uses secp256k1, which enforces strict canonical DER, expecting signatures in the minimal 70-byte form. + + # Loop until signature is exactly 70 bytes + while true; do + openssl dgst -sign $privkey_pem -keyform PEM -sha256 -out $sig_bin -binary $file + if [ "$(wc -c < "$sig_bin")" -eq 70 ]; then + echo "Signature is exactly 70 bytes. Done!" + break + else + echo "Signature size greater than 70 bytes. Retrying..." + fi + done fi fi elif [ "$1" == "verify" ]; then diff --git a/pyproject.toml b/pyproject.toml index ee439f45c..80982c74f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -77,7 +77,7 @@ sign = ["qrcode"] # format tasks format-src = "black src" format-tests = "black tests" -format-scripts = "black firmware/font firmware/scripts i18n/*.py" +format-scripts = "black firmware/font firmware/scripts i18n kapps" format = ["format-src", "format-tests", "format-scripts"] # aliases black.ref = "format" @@ -85,7 +85,7 @@ format-test.ref = "format-tests" # pylint tasks lint-src = "pylint src" -lint-scripts = "pylint firmware/font/*.py firmware/scripts/*.py i18n/*.py" +lint-scripts = "pylint firmware/font/*.py firmware/scripts/*.py i18n/*.py kapps/*.py" lint = ["lint-src", "lint-scripts"] # aliases pylint.ref = "lint" @@ -165,6 +165,13 @@ update-glyphs = "python ./firmware/font/bdftokff.py True" glyphs.ref = "update-glyphs" fonts.ref = "update-glyphs" +# kapps tasks +mpy = "./firmware/MaixPy/components/micropython/core/mpy-cross/mpy-cross" +mpy-all = { shell = "for f in kapps/*.py; do poetry run poe mpy \"$f\"; done" } +# aliases +mpy-cross.ref = "mpy" +mpy-cross-all.ref = "mpy-all" + # task for sign_release? # python sign_release.py @@ -172,3 +179,80 @@ fonts.ref = "update-glyphs" missing-glyphs = "python ./firmware/font/missing_glyphs.py" fonts-missing.ref = "missing-glyphs" missing.ref = "missing-glyphs" + +# build tasks +[tool.poe.tasks.firmware] +args = [ + { name = "device", help = "Device to be firmwared", options = [ + "-d", + "--device", + ], default = "amigo" }, + { name = "prvkey", help = "Path to private key PEM", options = [ + "-p", + "--prvkey", + ], default = "privkey.pem" }, + { name = "pubkey", help = "Path to public key PEM", options = [ + "-P", + "--pubkey", + ], default = "pubkey.pem" }, +] +help = "Compile firmware to device" +shell = """ +set -euo pipefail + +HEX="$(./krux pem-to-pubkey "${pubkey}")" +python scripts/set_signer_pubkey.py --pubkey "$HEX" +./krux build maixpy_$device +mkdir -p build/maixpy_$device +""" + +# kapps tasks +[tool.poe.tasks.kapp] +args = [ + { name = "name", help = "Basename of kapp/.py", options = [ + "-n", + "--name", + ], default = "nostr" }, + { name = "prvkey", help = "Path to private key PEM", options = [ + "-p", + "--prvkey", + ], default = "privkey.pem" }, + { name = "pubkey", help = "Path to public key PEM", options = [ + "-P", + "--pubkey", + ], default = "pubkey.pem" }, +] +help = "Compile a .py to .mpy kapp and sign it to .mpy.sig" +shell = """ +set -euo pipefail + +HEX="$(./krux pem-to-pubkey "${pubkey}")" +python scripts/set_signer_pubkey.py --pubkey "$HEX" +./krux build kapp "${name}" +./krux sign "kapps/${name}.mpy" "${prvkey}" +cp "kapps/${name}.mpy" "simulator/sd/${name}.mpy" +cp "kapps/${name}.mpy.sig" "simulator/sd/${name}.mpy.sig" +cp "kapps/${name}.py" "simulator/${name}.py" +""" + +# clean docker +[tool.poe.tasks.docker] +args = [ + { name = "purge", help = "Clean the docker (krux) environment", options = [ + "-p", + "--purge", + ] }, +] +help = "Clean the docker krux environment or Purge all docker environment" +shell = """ +set -euo pipefail + +if [ "${purge}" = "krux" ]; then + ./krux clean +elif [ "${purge}" = "all" ]; then + ./krux purge +else + echo "ERROR: argument ${purge} not recognized" + exit 1 +fi +""" diff --git a/scripts/docker_cleanup.sh b/scripts/docker_cleanup.sh new file mode 100755 index 000000000..c326fbb30 --- /dev/null +++ b/scripts/docker_cleanup.sh @@ -0,0 +1,23 @@ +#!/bin/bash +# Docker cleanup when dangling images exceed 50GB + +THRESHOLD_GB=50 +KRUX_DIR="/Users/qlrd/github/krux" + +total_bytes=0 +while IFS= read -r size; do + [[ -z "$size" ]] && continue + num=$(echo "$size" | sed 's/[^0-9.]//g') + unit=$(echo "$size" | sed 's/[0-9.]//g') + case "$unit" in + GB) bytes=$(echo "$num * 1073741824" | bc) ;; + MB) bytes=$(echo "$num * 1048576" | bc) ;; + *) bytes=0 ;; + esac + total_bytes=$(echo "$total_bytes + $bytes" | bc) +done < <(docker images --filter "dangling=true" --format "{{.Size}}" 2>/dev/null) + +gb=$(echo "scale=2; $total_bytes / 1073741824" | bc) +if (( $(echo "$gb >= $THRESHOLD_GB" | bc -l) )); then + cd "$KRUX_DIR" && /Users/qlrd/.local/bin/poetry run poe docker --purge krux +fi \ No newline at end of file diff --git a/scripts/set_signer_pubkey.py b/scripts/set_signer_pubkey.py new file mode 100644 index 000000000..af0779ce3 --- /dev/null +++ b/scripts/set_signer_pubkey.py @@ -0,0 +1,21 @@ +import re +from pathlib import Path +from argparse import ArgumentParser + +parser = ArgumentParser( + prog="metadata", description="Change SIGNER_PUBKEY in src/krux/metadata.py" +) + +parser.add_argument("-P", "--pubkey") +args = parser.parse_args() + +PATH = Path("src/krux/metadata.py") + +s = PATH.read_text(encoding="utf-8") +s2 = re.sub( + r'^(\s*SIGNER_PUBKEY\s*=\s*)"[^"]*"', + r'\1"' + args.pubkey + '"', + s, + flags=re.M, +) +PATH.write_text(s2, encoding="utf-8") diff --git a/simulator/generate-device-screenshots.sh b/simulator/generate-device-screenshots.sh index 812e36d16..c9a870265 100755 --- a/simulator/generate-device-screenshots.sh +++ b/simulator/generate-device-screenshots.sh @@ -77,6 +77,7 @@ poetry run poe simulator --sequence sequences/tools-print-test-qr.txt --sd --de poetry run poe simulator --sequence sequences/tools-descriptor-addresses.txt --sd --device $device poetry run poe simulator --sequence sequences/tools-flash.txt --sd --device $device poetry run poe simulator --sequence sequences/tc-flash-hash.txt --sd --device $device +poetry run poe simulator --sequence sequences/tools-krux-apps.txt --sd --device $device # Settings poetry run poe simulator --sequence sequences/all-settings.txt --sd --device $device diff --git a/simulator/k_qr.py b/simulator/k_qr.py new file mode 120000 index 000000000..ce452c7af --- /dev/null +++ b/simulator/k_qr.py @@ -0,0 +1 @@ +../kapps/k_qr.py \ No newline at end of file diff --git a/simulator/kruxsim/mocks/machine.py b/simulator/kruxsim/mocks/machine.py index ca8dc822f..f140b1524 100644 --- a/simulator/kruxsim/mocks/machine.py +++ b/simulator/kruxsim/mocks/machine.py @@ -92,11 +92,6 @@ class SDCard: def remount(): pass - -def unique_id(): - return b'\xbc\x8d{%\x8e^\xc5Q\xb3N\x07f\x9f\xde\xbbG7\xddFK^\xdc\xdb\xbc\xb4E\x14A~3\x91\x12' - - if "machine" not in sys.modules: sys.modules["machine"] = mock.MagicMock( reset=reset, UART=mock.MagicMock(wraps=UART), SDCard=SDCard, unique_id=unique_id diff --git a/simulator/kruxsim/mocks/uos.py b/simulator/kruxsim/mocks/uos.py index db625f311..79d3a9780 100644 --- a/simulator/kruxsim/mocks/uos.py +++ b/simulator/kruxsim/mocks/uos.py @@ -23,19 +23,27 @@ old_listdir = os.listdir old_remove = os.remove +old_chdir = os.chdir old_stat = os.stat def new_listdir(path, *args, **kwargs): - path = path.lstrip("/") if path.startswith("/sd") else path + if path.startswith(("/sd", "/flash")): + path = path.lstrip("/") return old_listdir(path, *args, **kwargs) def new_remove(path, *args, **kwargs): - path = path.lstrip("/") if path.startswith("/sd") else path + if path.startswith(("/sd", "/flash")): + path = path.lstrip("/") return old_remove(path, *args, **kwargs) +# Avoid Krux code to change simulator execution dir +def new_chdir(path): + return + + def new_stat(path, *args, **kwargs): path = path.lstrip("/") if path.startswith("/sd") else path return old_stat(path, *args, **kwargs) @@ -43,4 +51,5 @@ def new_stat(path, *args, **kwargs): setattr(os, "listdir", new_listdir) setattr(os, "remove", new_remove) +setattr(os, "chdir", new_chdir) setattr(os, "stat", new_stat) diff --git a/simulator/kruxsim/mocks/vfs.py b/simulator/kruxsim/mocks/vfs.py new file mode 100644 index 000000000..311d0b617 --- /dev/null +++ b/simulator/kruxsim/mocks/vfs.py @@ -0,0 +1,59 @@ +# The MIT License (MIT) + +# Copyright (c) 2021-2023 Krux contributors + +# 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# The MIT License (MIT) + +# Copyright (c) 2021-2023 Krux contributors + +# 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. +import sys +import math +from unittest import mock +import pygame as pg +import cv2 +from numpy import zeros_like +from kruxsim import events +from kruxsim.mocks.board import BOARD_CONFIG +from krux.krux_settings import Settings + +def exec_allowed(value): + print("exec_allowed", value) + return None + + +if "vfs" not in sys.modules: + sys.modules["vfs"] = mock.MagicMock( + exec_allowed=exec_allowed, + ) diff --git a/simulator/nostr.py b/simulator/nostr.py new file mode 120000 index 000000000..4236c05d5 --- /dev/null +++ b/simulator/nostr.py @@ -0,0 +1 @@ +../kapps/nostr.py \ No newline at end of file diff --git a/simulator/sequences/tools-check-sd.txt b/simulator/sequences/tools-check-sd.txt index 00a0afed0..5b20cdad4 100644 --- a/simulator/sequences/tools-check-sd.txt +++ b/simulator/sequences/tools-check-sd.txt @@ -7,7 +7,7 @@ press BUTTON_A screenshot tools-options.png # Device tests -press BUTTON_B +x2 press BUTTON_B press BUTTON_A screenshot device-tests-options.png diff --git a/simulator/sequences/tools-datum-tool.txt b/simulator/sequences/tools-datum-tool.txt index f9d0b53e3..e837c1e51 100644 --- a/simulator/sequences/tools-datum-tool.txt +++ b/simulator/sequences/tools-datum-tool.txt @@ -5,6 +5,7 @@ x3 press BUTTON_B press BUTTON_A # Datum Tool +press BUTTON_B press BUTTON_A diff --git a/simulator/sequences/tools-krux-apps.txt b/simulator/sequences/tools-krux-apps.txt new file mode 100644 index 000000000..3966c4049 --- /dev/null +++ b/simulator/sequences/tools-krux-apps.txt @@ -0,0 +1,10 @@ +include _wait-for-logo.txt + +# Navigate to Tools +x3 press BUTTON_B +press BUTTON_A + +# Load Krux app +press BUTTON_A + +screenshot krux-apps.png diff --git a/simulator/simulator.py b/simulator/simulator.py index c0c093247..0b9d7bd59 100644 --- a/simulator/simulator.py +++ b/simulator/simulator.py @@ -115,6 +115,7 @@ from kruxsim.mocks import cst816 from kruxsim.mocks import buttons from kruxsim.mocks import rotary +from kruxsim.mocks import vfs from kruxsim.sequence import SequenceExecutor from kruxsim.mocks import uhashlib_hw from kruxsim.mocks import baseconv @@ -142,6 +143,11 @@ def run_krux(): os.makedirs(SD_PATH) from kruxsim.mocks import sd_card +# fake flash memory, create the flash folder if not exists +from krux.settings import FLASH_PATH +if not os.path.exists(FLASH_PATH): + os.makedirs(FLASH_PATH) + t = threading.Thread(target=run_krux) t.daemon = True diff --git a/simulator/steganography.py b/simulator/steganography.py new file mode 120000 index 000000000..3e7946c54 --- /dev/null +++ b/simulator/steganography.py @@ -0,0 +1 @@ +../kapps/steganography.py \ No newline at end of file diff --git a/src/boot.py b/src/boot.py index 44f80ac10..9637e78bc 100644 --- a/src/boot.py +++ b/src/boot.py @@ -27,17 +27,18 @@ import os from krux.power import power_manager +from krux.display import display +from krux.context import ctx MIN_SPLASH_WAIT_TIME = 1000 -def draw_splash(): +def draw_splash(display_splash): """Display splash while loading modules""" - from krux.display import display, SPLASH + from krux.display import SPLASH - display.initialize_lcd() - display.clear() - display.draw_centered_text(SPLASH) + display_splash.clear() + display_splash.draw_centered_text(SPLASH) def check_for_updates(): @@ -134,24 +135,46 @@ def home(ctx_home): break -preimport_ticks = time.ticks_ms() -draw_splash() -check_for_updates() -gc.collect() +def startup_kapp(ctx_app): + """Check for startup kapp needs to run before firmware""" + from krux.krux_settings import Settings + + app_name = Settings().security.startup_kapp + if app_name == "none": + return False + + from krux.pages.kapps import Kapps + + kapps = Kapps(ctx_app) + kapps.execute_flash_kapp(app_name, prompt=False) + + return True + + +# ------ +# Boot initialization +# ------ + +display.initialize_lcd() + +if not startup_kapp(ctx): + preimport_ticks = time.ticks_ms() + draw_splash(display) + check_for_updates() + gc.collect() + + # If importing happened too fast, sleep the difference so the logo + # will be shown + postimport_ticks = time.ticks_ms() + if preimport_ticks + MIN_SPLASH_WAIT_TIME > postimport_ticks: + time.sleep_ms(preimport_ticks + MIN_SPLASH_WAIT_TIME - postimport_ticks) + -from krux.context import ctx from krux.auto_shutdown import auto_shutdown ctx.power_manager = power_manager auto_shutdown.add_ctx(ctx) - -# If importing happened too fast, sleep the difference so the logo -# will be shown -postimport_ticks = time.ticks_ms() -if preimport_ticks + MIN_SPLASH_WAIT_TIME > postimport_ticks: - time.sleep_ms(preimport_ticks + MIN_SPLASH_WAIT_TIME - postimport_ticks) - if not tc_code_verification(ctx): power_manager.shutdown() login(ctx) diff --git a/src/krux/firmware.py b/src/krux/firmware.py index 6caf920e0..10dd73343 100644 --- a/src/krux/firmware.py +++ b/src/krux/firmware.py @@ -184,6 +184,29 @@ def sha256(firmware_filename, firmware_size=None): return hasher.digest() +def check_signature(pubkey, sig, file_hash): + """Return if signature of the file_hash is valid for the pubkey""" + + try: + # embit (via libsecp256k1) already enforces signature is compact + sig = ec.Signature.parse(sig) + if not pubkey.verify(sig, file_hash): + return False + except: + return False + + return True + + +def get_pubkey(): + """Construct the pubkey based on Krux metadata pubkey string""" + + try: + return ec.PublicKey.from_string(SIGNER_PUBKEY) + except: + return None + + def find_all_occurrences(data, pattern): """Find all occurrences of the pattern in the data""" positions = [] @@ -311,10 +334,8 @@ def status_text(text, highlight_prefix=""): return False # Validate curr pubkey - pubkey = None - try: - pubkey = ec.PublicKey.from_string(SIGNER_PUBKEY) - except: + pubkey = get_pubkey() + if pubkey is None: display.flash_text("Invalid public key", theme.error_color) return False @@ -337,13 +358,7 @@ def status_text(text, highlight_prefix=""): # Validate signature firmware_hash = sha256(firmware_path) - try: - # Parse, serialize, and reparse to ensure signature is compact prior to verification - sig = ec.Signature.parse(ec.Signature.parse(sig).serialize()) - if not pubkey.verify(sig, firmware_hash): - display.flash_text(t("Bad signature"), theme.error_color) - return False - except: + if not check_signature(pubkey, sig, firmware_hash): display.flash_text(t("Bad signature"), theme.error_color) return False diff --git a/src/krux/krux_settings.py b/src/krux/krux_settings.py index c78150501..722f6bcac 100644 --- a/src/krux/krux_settings.py +++ b/src/krux/krux_settings.py @@ -21,6 +21,7 @@ # THE SOFTWARE. import board import binascii +import ujson as json from .settings import ( SettingsNamespace, CategorySetting, @@ -30,6 +31,7 @@ FLASH_PATH, MAIN_TXT, TEST_TXT, + STARTUP_APPS_FILE, ) from .key import ( @@ -46,7 +48,7 @@ BAUDRATES = [1200, 2400, 4800, 9600, 19200, 38400, 57600, 115200] -TC_CODE_PATH = "/flash/tcc" +TC_CODE_PATH = "/" + FLASH_PATH + "/tcc" TC_CODE_PBKDF2_ITERATIONS = 100000 DEFAULT_LOCALE = "en-US" @@ -472,10 +474,23 @@ def label(self, attr): class SecuritySettings(SettingsNamespace): """Security settings""" + # pylint: disable=no-method-argument + def _load_startup_apps(): + """Dynamically retrieve available startup apps""" + try: + with open("/%s/%s" % (FLASH_PATH, STARTUP_APPS_FILE), "r") as f: + apps = json.load(f) + except: + apps = [] + + return apps + ["none"] + namespace = "settings.security" auto_shutdown = NumberSetting(int, "auto_shutdown", 10, [0, 60]) hide_mnemonic = CategorySetting("hide_mnemonic", False, [False, True]) boot_flash_hash = CategorySetting("boot_flash_hash", False, [False, True]) + allow_kapp = CategorySetting("allow_kapp", False, [False, True]) + startup_kapp = CategorySetting("startup_kapp", "none", _load_startup_apps) def label(self, attr): """Returns a label for UI when given a setting name or namespace""" @@ -483,6 +498,8 @@ def label(self, attr): "auto_shutdown": t("Shutdown Time"), "hide_mnemonic": t("Hide Mnemonics"), "boot_flash_hash": t("TC Flash Hash at Boot"), + "allow_kapp": t("Allow Krux apps"), + "startup_kapp": t("Startup Kapp"), }[attr] diff --git a/src/krux/pages/__init__.py b/src/krux/pages/__init__.py index 5e954c093..85df914cd 100644 --- a/src/krux/pages/__init__.py +++ b/src/krux/pages/__init__.py @@ -151,6 +151,7 @@ def capture_from_keypad( starting_buffer="", esc_prompt=True, buffer_title="", + compute_possible_keys=True, ): """Displays a key pad and captures a series of keys until the user returns. Returns a string. @@ -164,7 +165,8 @@ def capture_from_keypad( self._print_keypad_header(title, show_swipe_hint, buffer, buffer_title) if progress_bar_fn: progress_bar_fn() - pad.compute_possible_keys(buffer) + if compute_possible_keys: + pad.compute_possible_keys(buffer) pad.get_valid_index() pad.draw_keys() pad.draw_keyset_index() diff --git a/src/krux/pages/file_manager.py b/src/krux/pages/file_manager.py index 74d41e1b1..c7127314e 100644 --- a/src/krux/pages/file_manager.py +++ b/src/krux/pages/file_manager.py @@ -45,7 +45,7 @@ def select_file( import os path = SD_ROOT_PATH - status = "" + status = MENU_CONTINUE while True: # if is a dir then list all files in it if SDHandler.dir_exists(path): diff --git a/src/krux/pages/kapps.py b/src/krux/pages/kapps.py new file mode 100644 index 000000000..d69a813cc --- /dev/null +++ b/src/krux/pages/kapps.py @@ -0,0 +1,305 @@ +# The MIT License (MIT) + +# Copyright (c) 2021-2024 Krux contributors + +# 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. + +from krux.pages import ( + Page, + Menu, + MENU_CONTINUE, + MENU_EXIT, +) +from krux.krux_settings import t +from krux.display import BOTTOM_PROMPT_LINE +from krux.sd_card import MPY_FILE_EXTENSION, SIGNATURE_FILE_EXTENSION, SD_PATH +from krux.settings import FLASH_PATH, STARTUP_APPS_FILE +import os + +READABLEBUFFER_SIZE = 128 +STARTUP_ATTR = "ALLOW_STARTUP" + + +class Kapps(Page): + """Krux standalone apps manager""" + + def __init__(self, ctx): + self.ctx = ctx + + items = [] + signed_apps = self.parse_all_flash_apps() + for app_name in signed_apps: + clean_name = app_name[:-4] + items += [ + (clean_name, lambda name=clean_name: self.execute_flash_kapp(name)) + ] + items += [ + ( + t("Load from SD card"), + None if not self.has_sd_card() else self.load_sd_kapp, + ) + ] + + super().__init__( + ctx, + Menu(ctx, items), + ) + + def parse_all_flash_apps(self): + """Check if any .mpy app present in flash is signed. + If not, ask for deletion to prevent importing and executing malicious code""" + + from krux.firmware import sha256 + + unsigned_apps = [] + signed_apps = [] + flash_path_prefix = "/%s/" % FLASH_PATH + for file in os.listdir(flash_path_prefix): + if file.endswith(MPY_FILE_EXTENSION): + # Check if signature file exists for the .mpy file + try: + sig_data = None + with open( + flash_path_prefix + file + SIGNATURE_FILE_EXTENSION, + "rb", + buffering=0, + ) as sigfile: + sig_data = sigfile.read() + if self.valid_signature(sig_data, sha256(flash_path_prefix + file)): + signed_apps += [file] + else: + unsigned_apps += [file] + except: + unsigned_apps += [file] + + if len(unsigned_apps) > 0: + # Prompts user about deleting as it will change flash memory and TC hash + self.ctx.display.clear() + if not self.prompt( + t("Unsigned apps found in flash will be deleted.") + + "\n\n" + + t("Proceed?"), + self.ctx.display.height() // 2, + ): + raise ValueError("Unsigned apps found in flash") + + # Delete any .mpy files from flash VFS to avoid malicious code import/execution + for app in unsigned_apps: + os.remove(flash_path_prefix + app) + + return signed_apps + + def valid_signature(self, sig, data_hash): + """Return if signature of data_hash is valid""" + + from krux.firmware import get_pubkey, check_signature + + pubkey = get_pubkey() + if pubkey is None: + raise ValueError("Invalid public key") + + if not check_signature(pubkey, sig, data_hash): + return False + + return True + + def execute_flash_kapp(self, app_name, from_sd=False, prompt=True): + """Prompt user to load and 'execute' a .mpy Krux app""" + + self.ctx.display.clear() + if prompt and not self.prompt( + t("Execute %s Krux app?") % app_name, self.ctx.display.height() // 2 + ): + return MENU_EXIT if from_sd else MENU_CONTINUE + + # Allows import of files in flash VFS + import vfs + + vfs.exec_allowed(True) + os.chdir("/" + FLASH_PATH) + + # Import and exec the kapp + i_kapp = None + try: + i_kapp = __import__(app_name) + i_kapp.run(self.ctx) + except: + # avoids importing from flash VSF + vfs.exec_allowed(False) + os.chdir("/") + + from krux.themes import theme + + self.ctx.display.to_portrait() + self.ctx.display.clear() + self.ctx.display.draw_centered_text( + t("Error:") + "\n" + "Could not execute %s" % app_name, + theme.error_color, + ) + self.ctx.input.wait_for_button() + + # avoids importing from flash VSF + vfs.exec_allowed(False) + os.chdir("/") + + # Avoid restart when forced execution (startup) + if not prompt: + return None + + # After execution restart Krux (better safe than sorry) + from ..power import power_manager + + power_manager.shutdown() + return None + + def load_sd_kapp(self): + """Loads kapp from SD to flash, then executes""" + + # Prompt user for .mpy file + from krux.pages.utils import Utils + + filename, _ = Utils(self.ctx).load_file( + MPY_FILE_EXTENSION, prompt=False, only_get_filename=True + ) + + if not filename: + return MENU_CONTINUE + + from krux.firmware import sha256 + import binascii + + sd_path_prefix = "/%s/" % SD_PATH + data_hash = sha256(sd_path_prefix + filename) + + # Confirm hash string + self.ctx.display.clear() + self.ctx.display.draw_hcentered_text( + filename + "\n\n" + "SHA256:\n\n" + binascii.hexlify(data_hash).decode(), + highlight_prefix=":", + ) + if not self.prompt(t("Proceed?"), BOTTOM_PROMPT_LINE): + return MENU_CONTINUE + + # Check signature of .mpy file in SD + sig_data = None + try: + with open( + sd_path_prefix + filename + SIGNATURE_FILE_EXTENSION, "rb", buffering=0 + ) as sigfile: + sig_data = sigfile.read() + except: + self.flash_error(t("Missing signature file")) + return MENU_CONTINUE + + if not self.valid_signature(sig_data, data_hash): + self.flash_error(t("Bad signature")) + return MENU_CONTINUE + + # Check if app is already installed in flash + found_in_flash_vfs = False + filename_flash = "" + flash_path_prefix = "/%s/" % FLASH_PATH + for file in os.listdir(flash_path_prefix): + if file.endswith(MPY_FILE_EXTENSION): + if sha256(flash_path_prefix + file) == data_hash: + found_in_flash_vfs = True + filename_flash = file + break + + # Copy kapp + sig from SD to flash VFS, if app not found + install_from_sd = False + if not found_in_flash_vfs: + install_from_sd = True + + # Warns user about changing users's flash internal memory region + self.ctx.display.clear() + if not self.prompt( + t("App will be stored internally on flash.") + "\n\n" + t("Proceed?"), + self.ctx.display.height() // 2, + ): + return MENU_CONTINUE + + # Save APP .mpy + filename_flash = filename.rsplit("/", 1)[-1] + with open( + flash_path_prefix + filename_flash, + "wb", + buffering=0, + ) as flash_file: + with open(sd_path_prefix + filename, "rb", buffering=0) as sd_file: + while True: + chunk = sd_file.read(READABLEBUFFER_SIZE) + if not chunk: + break + flash_file.write(chunk) + + # Save SIG .mpy.sig + with open( + flash_path_prefix + filename_flash + SIGNATURE_FILE_EXTENSION, + "wb", + buffering=0, + ) as kapp_sig_file: + kapp_sig_file.write(sig_data) + + # Load Kapp to check for ALLOW_STARTUP flag + import vfs + import ujson as json + + # Allows import of files in flash VFS + vfs.exec_allowed(True) + os.chdir(flash_path_prefix) + + # Import kapp + i_kapp = None + app_name = filename_flash[:-4] + try: + i_kapp = __import__(app_name) + if getattr(i_kapp, STARTUP_ATTR, False): + # Load or create startup apps file + try: + with open(flash_path_prefix + STARTUP_APPS_FILE, "r") as f: + startup_apps = set(json.load(f)) + except: + startup_apps = set() + + # update + startup_apps.add(app_name) + + # save + with open(flash_path_prefix + STARTUP_APPS_FILE, "w") as f: + json.dump(list(startup_apps), f) + + # Unimport kapp + import sys + + del sys.modules[app_name] + del i_kapp + except: + pass + + # avoids importing from flash VSF + vfs.exec_allowed(False) + os.chdir("/") + + del sig_data + import gc + + gc.collect() + + return self.execute_flash_kapp(filename_flash[:-4], install_from_sd) diff --git a/src/krux/pages/login.py b/src/krux/pages/login.py index 8bc7c7dad..6e2886a71 100644 --- a/src/krux/pages/login.py +++ b/src/krux/pages/login.py @@ -214,6 +214,9 @@ def _load_key_from_words(self, words, charset=LETTERS, new=False): if mnemonic is None: return MENU_CONTINUE + return self._load_wallet_key(mnemonic) + + def _load_wallet_key(self, mnemonic): passphrase = "" if not hasattr(Settings().wallet, "policy_type") and hasattr( Settings().wallet, "multisig" diff --git a/src/krux/pages/settings_page.py b/src/krux/pages/settings_page.py index 16045a923..2ce104f38 100644 --- a/src/krux/pages/settings_page.py +++ b/src/krux/pages/settings_page.py @@ -338,7 +338,9 @@ def _amigo_lcd_reconfigure(self): def category_setting(self, settings_namespace, setting): """Handler for viewing and editing a CategorySetting""" - categories = setting.categories + categories = ( + setting.categories() if callable(setting.categories) else setting.categories + ) starting_category = setting.__get__(settings_namespace) while True: diff --git a/src/krux/pages/tools.py b/src/krux/pages/tools.py index 635fe07c6..04a9698bf 100644 --- a/src/krux/pages/tools.py +++ b/src/krux/pages/tools.py @@ -32,6 +32,7 @@ # NUM_SPECIAL_2, ) from ..krux_settings import t +import sys # TODO: re-enable "Create a QR Code" (and keypads ^^^) once encryption is possible w/o Datum Tool @@ -40,11 +41,14 @@ class Tools(Page): """Krux generic tools""" def __init__(self, ctx): + self.ctx = ctx + super().__init__( ctx, Menu( ctx, [ + (t("Load Krux app"), self.load_krux_app), (t("Datum Tool"), self.datum_tool), (t("Device Tests"), self.device_tests), # (t("Create QR Code"), self.create_qr), @@ -54,7 +58,26 @@ def __init__(self, ctx): ], ), ) - self.ctx = ctx + + def load_krux_app(self): + """Handler for the 'Load Krux app' menu item""" + + # Check if Krux app is enabled + from krux.krux_settings import Settings + + if not Settings().security.allow_kapp: + self.flash_error(t("Allow in settings first!")) + return MENU_CONTINUE + + from krux.pages.kapps import Kapps + + Kapps(self.ctx).run() + + # Unimport kapps + sys.modules.pop("krux.pages.kapps") + del sys.modules["krux.pages"].kapps + + return MENU_CONTINUE def flash_tools(self): """Handler for the 'Flash Tools' menu item""" @@ -78,7 +101,6 @@ def rm_stored_mnemonic(self): def datum_tool(self): """Handler for the 'Datum Tool' menu item""" - import sys from .datum_tool import DatumToolMenu while True: @@ -122,7 +144,6 @@ def descriptor_addresses(self): def device_tests(self): """Handler for the 'Device Tests' menu item""" - import sys from .device_tests import DeviceTests page = DeviceTests(self.ctx) diff --git a/src/krux/sd_card.py b/src/krux/sd_card.py index e288964c6..e8ea6d131 100644 --- a/src/krux/sd_card.py +++ b/src/krux/sd_card.py @@ -34,6 +34,7 @@ BMP_IMAGE_EXTENSION = ".bmp" PBM_IMAGE_EXTENSION = ".pbm" SVG_IMAGE_EXTENSION = ".svg" +MPY_FILE_EXTENSION = ".mpy" class SDHandler: diff --git a/src/krux/settings.py b/src/krux/settings.py index 51ff5dd7e..d03062335 100644 --- a/src/krux/settings.py +++ b/src/krux/settings.py @@ -34,6 +34,7 @@ # Specific storage filenames SETTINGS_FILENAME = "settings.json" MNEMONICS_FILE = "seeds.json" +STARTUP_APPS_FILE = "startup.json" # Maximum accepted size of settings.json (bytes). Guards against malicious # oversized files on SD that could exhaust RAM during load. @@ -106,7 +107,8 @@ def __get__(self, obj, _objtype=None): return self stored_val = store.get(obj.namespace, self.attr, self.default_value) - if stored_val not in self.categories: + categories = self.categories() if callable(self.categories) else self.categories + if stored_val not in categories: return self.default_value return stored_val diff --git a/src/krux/translations/__init__.py b/src/krux/translations/__init__.py index d8f2a711f..63a8eccfb 100644 --- a/src/krux/translations/__init__.py +++ b/src/krux/translations/__init__.py @@ -53,7 +53,10 @@ 4121028614, 3270727197, 900375497, + 1329241183, + 276039542, 2693258820, + 2041172833, 3857613120, 1056821534, 1868069640, @@ -127,6 +130,7 @@ 1781892685, 889040671, 1505332462, + 502721119, 3838465623, 1883247725, 692902568, @@ -185,6 +189,7 @@ 972436696, 2176866982, 1792105747, + 2993872092, 2820726296, 2369474953, 2256441194, @@ -319,6 +324,7 @@ 2090568351, 1260825919, 1075810813, + 3089686333, 2272013587, 1232757391, 3303592908, @@ -351,6 +357,7 @@ 2061556020, 1128404172, 2089395053, + 3448560703, 1374262427, 2518890350, 2786714360, diff --git a/src/krux/translations/de.py b/src/krux/translations/de.py index af68bcfe8..4be4ac675 100644 --- a/src/krux/translations/de.py +++ b/src/krux/translations/de.py @@ -41,7 +41,10 @@ "Zusätzliche Entropie von der Kamera erforderlich für %s", "Adresse", "Richte Kamera und Sicherungsplatte richtig aus.", + "Krux-Apps zulassen", + "Erlaube zuerst Einstellungen!", "Blendschutzmodus", + "Die App wird intern auf Flash gespeichert.", "Aussehen", "Bist Du sicher?", "BGR-Farben", @@ -115,6 +118,7 @@ "Benutzerdaten werden gelöscht…", "Fehler:", "Esc", + "%s Krux-App ausführen?", "Dateien durchsuchen?", "Adressen exportieren", "%s wird auf SD-Karte exportiert…", @@ -173,6 +177,7 @@ "Leitungsverzögerung", "Linie:", "Adressen auflisten", + "Krux-App laden", "Mnemonic laden", "Wallet laden", "Einen vertrauenswürdigen Wallet-Deskriptor laden, um Adressen anzuzeigen?", @@ -307,6 +312,7 @@ "Ausgabe (%d):", "Ausgaben:", "Standardmodus", + "Startup Kapp", "Statisch", "Statistiken für Nerds", "Auf Flash speichern", @@ -339,6 +345,7 @@ "Schlüssel eingeben", "Widerrufen", "Einheit", + "Nicht signierte Apps, die in Flash gefunden wurden, werden gelöscht.", "KEF-ID aktualisieren?", "QR-Etikett aktualisieren?", "Upgrade abgeschlossen.", diff --git a/src/krux/translations/es.py b/src/krux/translations/es.py index 6e493038e..54b52d544 100644 --- a/src/krux/translations/es.py +++ b/src/krux/translations/es.py @@ -41,7 +41,10 @@ "Se requiere entropía adicional de la cámara para %s", "Dirección", "Alinea la cámara y la placa de respaldo correctamente.", + "Permitir aplicaciones Krux", + "¡Permitir en la configuración primero!", "Modo antirreflejo", + "La aplicación se almacenará internamente en flash.", "Apariencia", "¿Estás seguro?", "Colores BGR", @@ -115,6 +118,7 @@ "Borrando los datos del usuario…", "Error:", "Esc", + "¿Ejecutar %s aplicación Krux?", "¿Explorar archivos?", "Exportar direcciones", "Exportando %s a la tarjeta SD…", @@ -173,6 +177,7 @@ "Retraso de Línea", "Línea:", "Listar direcciones", + "Cargar aplicación Krux", "Importar Mnemónico", "Cargar Cartera", "¿Cargar un descriptor de monedero de confianza para ver las direcciones?", @@ -307,6 +312,7 @@ "Gastos (%d):", "Gasto:", "Modo estándar", + "Startup Kapp", "Estático", "Estadísticas para Entendidos", "Almacenar en Flash", @@ -339,6 +345,7 @@ "Introduce la clave", "Deshacer", "Unidad", + "Se eliminarán las aplicaciones sin firmar que se encuentren en Flash.", "¿Actualizar ID de Kef?", "¿Actualizar etiqueta QR?", "Actualización completa.", diff --git a/src/krux/translations/fr.py b/src/krux/translations/fr.py index 2a9057091..cf4db5df7 100644 --- a/src/krux/translations/fr.py +++ b/src/krux/translations/fr.py @@ -41,7 +41,10 @@ "Entropie supplémentaire de la caméra requise pour %s", "Adresse", "Alignez correctement la caméra et plaque de sauvegarde.", + "Autoriser les applications Krux", + "Autoriser d'abord dans les paramètres\u2009!", "Mode anti-reflets", + "L'application sera stockée en interne sur flash.", "Apparence", "Es-tu sûr\u2009?", "Couleurs BGR", @@ -115,6 +118,7 @@ "Effacement des données de l'utilisateur…", "Erreur\u2009:", "Esc", + "Exécuter l'application %s Krux\u2009?", "Explorer des fichiers\u2009?", "Adresses d'exportation", "Exportation de %s vers la carte SD…", @@ -173,6 +177,7 @@ "Délai de Ligne", "Ligne\u2009:", "Listage d'Addresses", + "Charger l'application Krux", "Charger Mnémonique", "Charger le portefeuille", "Charger un descripteur de portefeuille de confiance pour afficher les adresses\u2009?", @@ -307,6 +312,7 @@ "Dépense (%d)\u2009:", "Dépense\u2009:", "Mode standard", + "Startup Kapp", "Statique", "Statistiques pour les geeks", "Stocker sur flash", @@ -339,6 +345,7 @@ "Taper clé", "Annuler", "Unité", + "Les applications non signées trouvées dans Flash seront supprimées.", "Mettre à jour l'ID KEF\u2009?", "Mettre à jour l'étiquette QR\u2009?", "Mise à jour complète.", diff --git a/src/krux/translations/ja.py b/src/krux/translations/ja.py index c03342041..4b22df63a 100644 --- a/src/krux/translations/ja.py +++ b/src/krux/translations/ja.py @@ -41,7 +41,10 @@ "%sにはカメラからの追加エントロピーが必要です", "アドレス", "カメラとバックプレートを正しく整列させてください.", + "Kruxアプリを許可する", + "最初に設定で許可してください!", "アンチグレアモード", + "アプリはフラッシュに内部保存されます。", "外観", "よろしいですか?", "BGRカラー", @@ -115,6 +118,7 @@ "ユーザーのデータを消去しています…", "エラー:", "エスク", + "%s Kruxアプリを実行しますか?", "アーカイブ探索?", "住所をエクスポート", "%sをSDカードにエクスポートしています…", @@ -173,6 +177,7 @@ "ライン遅延", "ライン:", "アドレスリスト", + "Kruxアプリを読み込む", "ニーモニックをロード", "ウォレットをロード", "信頼できるウォレット記述子をロードしてアドレスを表示しますか?", @@ -307,6 +312,7 @@ "支出(%d):", "支出:", "標準モード", + "スタートアップKapp", "静止画", "オタクのための統計", "フラッシュに保存する", @@ -339,6 +345,7 @@ "キーを入力する", "取り消し", "ユニット", + "Flashで見つかった署名されていないアプリは削除されます。", "KEF IDを更新しますか?", "QRラベルを更新しますか?", "アップグレードが完了しました.", diff --git a/src/krux/translations/ko.py b/src/krux/translations/ko.py index d9556c94a..a1082ac89 100644 --- a/src/krux/translations/ko.py +++ b/src/krux/translations/ko.py @@ -41,7 +41,10 @@ "%s 에 필요한 카메라의 추가 엔트로피", "주소", "카메라와 보조 플레이트를 올바르게 정렬하십시오.", + "Krux 앱 허용", + "먼저 설정에서 허용하세요!", "눈부심 방지 모드", + "앱은 내부적으로 플래시로 저장됩니다.", "디스플레이", "계속하시겠습니까?", "BGR 색상", @@ -115,6 +118,7 @@ "사용자 데이터 삭제 중…", "오류:", "Esc", + "%s KRUX 앱을 실행하시겠습니까?", "파일을 탐색하시겠습니까?", "주소 내보내기", "%s 을 (를) SD 카드로 내보내는 중…", @@ -173,6 +177,7 @@ "줄 지연", "줄:", "주소 목록", + "Krux 앱 로드", "니모닉 불러오기", "이대로 불러오기", "주소를 보기위해 신뢰할 수 있는 월렛 디스크립터를 불러오시겠습니까?", @@ -307,6 +312,7 @@ "Spend (%d):", "지출:", "표준 모드", + "스타트업 Kapp", "Static", "전문가를 위한 통계", "플래시 메모리에 저장", @@ -339,9 +345,10 @@ "비밀번호 입력", "실행 취소", "단위", + "플래시에서 찾은 서명되지 않은 앱은 삭제됩니다.", "KEF ID를 업데이트하시겠습니까?", "QR 레이블을 업데이트하시겠습니까?", - "업그레이드가 완료되었습니다.", + "업그레이드가 완료되었습니다", "검은색 배경 화면을 사용하십시오.", "카메라의 엔트로피를 사용하여 새로운 니모닉을 생성하십시오", "현재 수치", diff --git a/src/krux/translations/nl.py b/src/krux/translations/nl.py index c3def0d70..3d427cc2a 100644 --- a/src/krux/translations/nl.py +++ b/src/krux/translations/nl.py @@ -41,7 +41,10 @@ "Extra entropie van camera vereist voor %s", "Adres", "Richt de camera en back-upplaat op de juiste manier.", + "Krux-apps toestaan", + "Sta eerst instellingen toe!", "Anti-verblindingsmodus", + "De app wordt intern opgeslagen op de flitser.", "Uiterlijk", "Weet je het zeker?", "BGR-kleuren", @@ -115,6 +118,7 @@ "Gebruikersgegevens worden gewist…", "Fout:", "Esc", + "%s Krux-app uitvoeren?", "Bestanden verkennen?", "Adressen exporteren", "Exporteren van %s naar SD-kaart…", @@ -173,6 +177,7 @@ "Lijn vertraging", "Lijn:", "Adressenlijst", + "Krux-app laden", "Geheugensteun laden", "Portemonnee laden", "Een vertrouwde portemonnee descriptor laden om adressen te bekijken?", @@ -307,6 +312,7 @@ "Uitgaven (%d):", "Uitgaven:", "Standaardmodus", + "Kapp opstarten", "Statisch", "Statistieken voor nerds", "Opslaan op apparaat", @@ -339,6 +345,7 @@ "Voer sleutel in", "Ongedaan maken", "Eenheid", + "Niet-ondertekende apps die in Flash worden gevonden, worden verwijderd.", "KEF-ID bijwerken?", "QR-label bijwerken?", "Upgrade afgerond.", diff --git a/src/krux/translations/pt.py b/src/krux/translations/pt.py index c494f6736..7ecff6d18 100644 --- a/src/krux/translations/pt.py +++ b/src/krux/translations/pt.py @@ -41,7 +41,10 @@ "Entropia adicional da câmera é necessária para %s", "Endereço", "Alinhe a câmera e a placa de backup corretamente.", + "Permitir aplicativos Krux", + "Permita nas configurações primeiro!", "Modo antirreflexo", + "O aplicativo será armazenado internamente em flash.", "Aparência", "Tem certeza?", "Cores BGR", @@ -115,6 +118,7 @@ "Apagando dados do usuário…", "Erro:", "Esc", + "Executar %s aplicativo Krux?", "Explorar arquivos?", "Exportar endereços", "Exportando %s para o cartão SD…", @@ -173,6 +177,7 @@ "Atraso de Linha", "Linha:", "Listar Endereços", + "Carregar Krux app", "Carregar Mnemônico", "Carregar Carteira", "Carregar um descritor de carteira para visualizar endereços?", @@ -307,6 +312,7 @@ "Gastos (%d):", "Gasto:", "Modo padrão", + "Kapp de inicialização", "Estático", "Estatísticas para nerds", "Armazenar na memória flash", @@ -339,6 +345,7 @@ "Digite a Chave", "Desfazer", "Unidade", + "Aplicativos não assinados encontrados em flash serão excluídos.", "Atualizar KEF ID?", "Atualizar etiqueta QR?", "Atualização concluída.", diff --git a/src/krux/translations/ru.py b/src/krux/translations/ru.py index 418ea16cb..73ddee8e5 100644 --- a/src/krux/translations/ru.py +++ b/src/krux/translations/ru.py @@ -41,7 +41,10 @@ "Требуется дополнительная энтропия от камеры для %s", "Адрес", "Правильно совместите камеру и резервную пластину.", + "Разрешить приложения Krux", + "Сначала разрешите в настройках!", "Антибликовый режим", + "Приложение будет храниться во флэш-памяти.", "Внешний Вид", "Вы уверены?", "Цвета BGR", @@ -115,6 +118,7 @@ "Удаление данных пользователя…", "Ошибка:", "Выйти", + "Запустить приложение %s Krux?", "Исследовать файлы?", "Экспорт адресов", "Экспорт %s на SD-карту…", @@ -173,6 +177,7 @@ "Задержка Линии", "Линия:", "Список адресов", + "Загрузить приложение Krux", "Загрузить Мнемонику", "Загрузить кошелек", "Загрузить дескриптор доверенного кошелька для просмотра адресов?", @@ -307,6 +312,7 @@ "Расход (%d):", "Расход:", "Стандартный режим", + "Запуск Kapp", "Static / Статическое оборудование", "Статистика для Гиков", "Сохранить на Флэш Память", @@ -339,6 +345,7 @@ "Ввести Ключ", "Отменить", "Единица Измерения", + "Неподписанные приложения, найденные во флэш-памяти, будут удалены.", "Обновить идентификатор KEF?", "Обновить QR-метку?", "Обновление завершено.", diff --git a/src/krux/translations/tr.py b/src/krux/translations/tr.py index f4fd01e59..f8d3d87b5 100644 --- a/src/krux/translations/tr.py +++ b/src/krux/translations/tr.py @@ -41,7 +41,10 @@ "%s için kameradan gelen ek entropi gerekli", "Adres", "Kamerayı ve yedek plakay'ı düzgün bir şekilde hizalayın.", + "Krux uygulamalarına izin ver", + "Önce ayarlarda izin ver!", "Parlama Önleyici Mod", + "Uygulama flaşta dahili olarak depolanacaktır.", "Görünüm", "Emin misiniz?", "BGR Renkleri", @@ -115,6 +118,7 @@ "Kullanıcının verileri siliniyor…", "Hata:", "Çıkış", + "%s Krux uygulaması çalıştırılsın mı?", "Dosyaları ara?", "Adresleri Dışa Aktar", "%s SD karta aktarılıyor…", @@ -173,6 +177,7 @@ "Satır Gecikmesi", "Satır:", "Adresleri Listele", + "Krux uygulamasını yükle", "Mnemonic Yükle", "Cüzdan Yükle", "Adresleri görüntülemek için güvenilir bir cüzdan tanımlayıcısı yüklensin mi?", @@ -307,6 +312,7 @@ "Harcama (%d):", "Harcama:", "Standart Mod", + "Startup Kapp", "Statik", "İnekler İçin İstatistikler", "Flash'ta Sakla", @@ -339,6 +345,7 @@ "Anahtar Yaz", "Geri Al", "Birim", + "Flash'ta bulunan imzasız uygulamalar silinecek.", "Kef Kimliği Güncellensin mi?", "QR Etiketi Güncellensin mi", "Güncelleme tamamlandı.", diff --git a/src/krux/translations/vi.py b/src/krux/translations/vi.py index 2da615282..88d0b55cf 100644 --- a/src/krux/translations/vi.py +++ b/src/krux/translations/vi.py @@ -41,7 +41,10 @@ "Entropy bổ sung từ máy ảnh cần thiết cho %s", "Địa chỉ", "Căn chỉnh camera và tấm dự phòng đúng cách.", + "Cho phép ứng dụng Krux", + "Cho phép cài đặt trước!", "Chế độ chống lóa", + "Ứng dụng sẽ được lưu trữ nội bộ trên flash.", "Giao diện", "Bạn có chắc không?", "Màu BGR", @@ -115,6 +118,7 @@ "Đang xóa dữ liệu của người dùng…", "Lỗi:", "Esc", + "Thực thi %s ứng dụng Krux?", "Khám phá các tập tin?", "Xuất báo cáo", "Đang xuất %s sang thẻ SD…", @@ -173,6 +177,7 @@ "Độ trễ Dòng", "Đường kẻ:", "danh sách địa chỉ", + "Tải ứng dụng Krux", "Tải mã mnemonic", "Nạp Ví", "Tải mô tả ví đáng tin cậy để xem địa chỉ?", @@ -307,6 +312,7 @@ "Chi tiêu (%d):", "Chi tiêu:", "Chế độ Tiêu chuẩn", + "Startup Kapp", "Tĩnh", "Số liệu thống kê cho Mọt sách", "Lưu trữ trên flash", @@ -339,6 +345,7 @@ "Nhập khóa", "Hoàn tác", "Đơn vị", + "Các ứng dụng chưa ký được tìm thấy trong flash sẽ bị xóa.", "Cập nhật ID KEF?", "Cập nhật nhãn QR?", "Nâng cấp hoàn tất.", diff --git a/src/krux/translations/zh.py b/src/krux/translations/zh.py index ae11284bf..076493510 100644 --- a/src/krux/translations/zh.py +++ b/src/krux/translations/zh.py @@ -41,7 +41,10 @@ "%s需要摄像头的额外熵", "地址", "正确对齐摄像头和背板.", + "允许Krux应用程序", + "首先在设置中允许!", "防闪模式", + "应用程序将内部存储在闪存中。", "界面", "确定?", "BGR 颜色", @@ -115,6 +118,7 @@ "正在删除用户数据…", "错误:", "退出", + "是否执行%s Krux应用程序?", "浏览文件?", "导出地址", "正在将%s导出到SD卡…", @@ -173,6 +177,7 @@ "行延迟", "行:", "地址", + "加载Krux应用", "加载助记词", "加载钱包", "加载受信任的钱包描述符以查看地址?", @@ -307,6 +312,7 @@ "花费 (%d):", "花费", "标准模式", + "启动Kapp", "Static 静态?", "极客统计数据", "存储到 Flash", @@ -339,6 +345,7 @@ "输入私钥", "撤销", "单位", + "在Flash中找到的未签名应用将被删除.", "更新KEF ID ?", "更新二维码标签?", "升级已完成.", diff --git a/tests/conftest.py b/tests/conftest.py index 0a1426dd4..885f5b13a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -105,6 +105,7 @@ def mp_modules(mocker, monkeypatch): import json monkeypatch.setitem(sys.modules, "ujson", json) + monkeypatch.setitem(sys.modules, "vfs", mocker.MagicMock()) @pytest.fixture diff --git a/tests/files/kapp.py b/tests/files/kapp.py new file mode 100644 index 000000000..d8af9618a --- /dev/null +++ b/tests/files/kapp.py @@ -0,0 +1,2 @@ +def run(self): + return True diff --git a/tests/firmware/__init__.py b/tests/firmware/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/kapps/__init__.py b/tests/kapps/__init__.py new file mode 100644 index 000000000..f4348b176 --- /dev/null +++ b/tests/kapps/__init__.py @@ -0,0 +1,28 @@ +from ..shared_mocks import mock_context + + +def create_ctx(mocker, btn_seq, wallet=None, printer=None, touch_seq=None): + """Helper to create mocked context obj""" + from krux.krux_settings import Settings, THERMAL_ADAFRUIT_TXT + + HASHED_IMAGE_BYTES = b"3\x0fr\x7fKY\x15\t\x83\xaab\x92\x0f&\x820\xb4\x14\x87\x19\xee\x95F\x9c\x8f\x0c\xbdo\xbc\x1d\xcbT" + + ctx = mock_context(mocker) + ctx.power_manager.battery_charge_remaining.return_value = 1 + ctx.input.wait_for_button = mocker.MagicMock(side_effect=btn_seq) + ctx.camera.capture_entropy = mocker.MagicMock(return_value=HASHED_IMAGE_BYTES) + ctx.is_logged_in.return_value = False + + ctx.wallet = wallet + ctx.printer = printer + if printer is not None: + mocker.patch("krux.printers.create_printer", new=mocker.MagicMock()) + Settings().hardware.printer.driver = THERMAL_ADAFRUIT_TXT + elif Settings().hardware.printer.driver != "none": + Settings().hardware.printer.driver = "none" + + if touch_seq: + ctx.input.touch = mocker.MagicMock( + current_index=mocker.MagicMock(side_effect=touch_seq) + ) + return ctx diff --git a/tests/kapps/test_nostr.py b/tests/kapps/test_nostr.py new file mode 100644 index 000000000..0e322ab09 --- /dev/null +++ b/tests/kapps/test_nostr.py @@ -0,0 +1,128 @@ +import pytest +from . import create_ctx + + +def data(): + from kapps.nostr import MNEMONIC, HEX, NSEC, NPUB, PUB_HEX + + # Test vectors from NIP-06: https://github.com/nostr-protocol/nips/blob/master/06.md + return [ + { + MNEMONIC: "leader monkey parrot ring guide accident before fence cannon height naive bean", + HEX: "7f7ff03d123792d6ac594bfa67bf6d0c0ab55b6b1fdb6249303fe861f1ccba9a", + NSEC: "nsec10allq0gjx7fddtzef0ax00mdps9t2kmtrldkyjfs8l5xruwvh2dq0lhhkp", + PUB_HEX: "17162c921dc4d2518f9a101db33695df1afb56ab82f5ff3e5da6eec3ca5cd917", + NPUB: "npub1zutzeysacnf9rru6zqwmxd54mud0k44tst6l70ja5mhv8jjumytsd2x7nu", + }, + { + MNEMONIC: "what bleak badge arrange retreat wolf trade produce cricket blur garlic valid proud rude strong choose busy staff weather area salt hollow arm fade", + HEX: "c15d739894c81a2fcfd3a2df85a0d2c0dbc47a280d092799f144d73d7ae78add", + NSEC: "nsec1c9wh8xy5eqdzln7n5t0ctgxjcrdug73gp5yj0x03gntn67h83twssdfhel", + PUB_HEX: "d41b22899549e1f3d335a31002cfd382174006e166d3e658e3a5eecdb6463573", + NPUB: "npub16sdj9zv4f8sl85e45vgq9n7nsgt5qphpvmf7vk8r5hhvmdjxx4es8rq74h", + }, + ] + + +def test_nostrkey(mocker, m5stickv): + from kapps.nostr import NostrKey, MNEMONIC, HEX, NSEC, NPUB, PUB_HEX + + for n, t in enumerate(data()): + print(n, t) + for version in (MNEMONIC, HEX, NSEC): + nkey = NostrKey() + assert not nkey.is_loaded() + if version == MNEMONIC: + nkey.load_mnemonic(t[MNEMONIC]) + elif version == HEX: + nkey.load_hex(t[HEX]) + elif version == NSEC: + nkey.load_nsec(t[NSEC]) + + assert nkey.is_loaded() + + if version in (HEX, NSEC): + assert nkey.is_mnemonic() == False + else: + assert nkey.is_mnemonic() + + assert nkey.get_hex() == t[HEX] + assert nkey.get_nsec() == t[NSEC] + assert nkey.get_pub_hex() == t[PUB_HEX] + assert nkey.get_npub() == t[NPUB] + + with pytest.raises(ValueError): + nkey.load_hex(t[HEX][:-1]) + + with pytest.raises(ValueError): + nkey.load_nsec(t[NSEC][:-1]) + + with pytest.raises(ValueError): + nkey.load_nsec(t[NSEC].replace(NSEC, NPUB)) + + +def test_nostrevent(mocker, m5stickv): + from kapps.nostr import NostrEvent, NostrKey, MNEMONIC, HEX, NSEC + import json + + event = r"""{ + "id": "a2d1bc19ed2bc8e09de9485d641fa53b89df75eecc042127dd2272d46afd6c8f", + "pubkey": "17162c921dc4d2518f9a101db33695df1afb56ab82f5ff3e5da6eec3ca5cd917", + "created_at": 1760632896, + "kind": 1, + "tags": [], + "content": "t \" \\\r\n\r\nkkk", + "sig": "17f972991755d25ec8d132b93fd1c0f59abba4c5fabd56274210e2bded9fe131ad294d73904f680bc18511a4e3a40660b2a67086179b332b0d2f670bed1c3c96" + }""" + + event_dict = json.loads(event) + + # Remove the 'id' field + event_dict.pop("id", None) + + # Dump back to JSON string + event_without_id = json.dumps(event_dict, ensure_ascii=False) + + # Error event without necessary attribute + pe = None + with pytest.raises(ValueError): + pe = NostrEvent.parse_event(event_without_id) + assert pe is None + + pe = NostrEvent.parse_event(event) + assert pe[NostrEvent.KIND] == 1 + + se = NostrEvent.serialize_event(pe) + + # Event and its serialization OK + assert NostrEvent.validate_id(pe, se) + + # signature tests + data1 = data()[0] + nkey = NostrKey() + # with mnemonic + nkey.load_mnemonic(data1[MNEMONIC]) + sig = NostrEvent.sign_event(nkey.get_private_key(), se) + assert sig == pe["sig"] + + # with hex + nkey.load_hex(data1[HEX]) + sig = NostrEvent.sign_event(nkey.get_private_key(), se) + assert sig == pe["sig"] + + # with nsec + nkey.load_nsec(data1[NSEC]) + sig = NostrEvent.sign_event(nkey.get_private_key(), se) + assert sig == pe["sig"] + + # Mess with id + pe[NostrEvent.ID] = pe[NostrEvent.ID].replace("1", "2") + + # Error comparing serialized_event with its id + with pytest.raises(ValueError): + NostrEvent.validate_id(pe, se) + + # other tests + assert NostrEvent.get_kind_type(1) == NostrEvent.KIND_REGULAR + assert NostrEvent.get_kind_desc(1) == NostrEvent.KIND_DESC[1] + assert NostrEvent.get_tag(999999) == NostrEvent.UNKNOWN diff --git a/tests/kapps/test_steganography.py b/tests/kapps/test_steganography.py new file mode 100644 index 000000000..58ab92278 --- /dev/null +++ b/tests/kapps/test_steganography.py @@ -0,0 +1,54 @@ +import pytest +from . import create_ctx + +BMP_DATA = b"\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff" + + +def test_hide_and_retrive_data_from_bmp(mocker, m5stickv): + from unittest.mock import mock_open, patch + from kapps.steganography import Kapp + + ctx = create_ctx(mocker, []) + steg = Kapp(ctx) + + def _bits_generator(payload): + for i in range(0, len(payload), 2): + chunk = payload[i : i + 2] + yield int.from_bytes(chunk, byteorder="big") + + written_data = [] # will hold the stego-BMP + bit_gen = _bits_generator(BMP_DATA) + width_holder = [10] # mutable value ;) + mocker.patch( + "image.Image", + return_value=mocker.MagicMock( + width=lambda: width_holder[0], + height=lambda: 10, + get_pixel=lambda x, y, rgbtuple: next(bit_gen), + set_pixel=lambda a, b, pixel: written_data.append( + pixel.to_bytes(2, byteorder="big") + ), + ), + ) + + secret_to_hide = b"This is a secret" + steg.hide_data(secret_to_hide, "in.bmp", "out.bmp") + + written_data = b"".join(written_data) + steg_bmp = written_data + BMP_DATA[len(written_data) :] + assert len(steg_bmp) <= len(BMP_DATA) + + bit_gen = _bits_generator(steg_bmp) + retrieved = steg.extract_data("in.bmp") + assert retrieved == secret_to_hide + + width_holder = [ + 1 + ] # change width to 1 will raise Exception "Not enough px to retrieve data" + with pytest.raises(ValueError): + steg.extract_data("in.bmp") + + width_holder = [10] + secret_to_hide = b"This is a secret" * 3 # secret too big, don't fit the image + with pytest.raises(ValueError): + steg.hide_data(secret_to_hide, "in.bmp", "out.bmp") diff --git a/tests/pages/test_kapps.py b/tests/pages/test_kapps.py new file mode 100644 index 000000000..1557060f2 --- /dev/null +++ b/tests/pages/test_kapps.py @@ -0,0 +1,489 @@ +import pytest +from unittest.mock import patch +from . import create_ctx + + +def test_parse_all_flash_apps(m5stickv, mocker): + from krux.pages.kapps import Kapps + from krux.sd_card import MPY_FILE_EXTENSION + from krux.input import BUTTON_PAGE, BUTTON_ENTER + import os + from krux.settings import FLASH_PATH + + ################################# + print("Case 1: no file with MPY_FILE_EXTENSION") + + mocker.patch( + "os.listdir", + new=mocker.MagicMock(return_value=["somefile", "otherfile"]), + ) + + ctx = create_ctx(mocker, None) + kapps = Kapps(ctx) + + signed_apps = kapps.parse_all_flash_apps() + assert len(signed_apps) == 0 + + ################################ + print("Case 2: unsigned file, prompt for deletion, user deny, ValueError") + + # one unsigned file + unsigned_file = "somefile" + MPY_FILE_EXTENSION + mocker.patch( + "os.listdir", + new=mocker.MagicMock(return_value=[unsigned_file]), + ) + + # User deny prompt + ctx.input.wait_for_button = mocker.MagicMock(side_effect=[BUTTON_PAGE]) + + # unsigned file + with pytest.raises(ValueError, match="Unsigned apps found in flash"): + signed_apps = kapps.parse_all_flash_apps() + assert len(signed_apps) == 0 + + ################################ + print( + "Case 3: unsigned file, prompt for deletion, user allow, remove unsigned file" + ) + + # User accept prompt + ctx.input.wait_for_button = mocker.MagicMock(side_effect=[BUTTON_ENTER]) + + # Mock file remove + mocker.patch("os.remove", new=mocker.MagicMock()) + + signed_apps = kapps.parse_all_flash_apps() + assert len(signed_apps) == 0 + + flash_path_prefix = "/%s/" % FLASH_PATH + os.remove.assert_called_with(flash_path_prefix + unsigned_file) + + ################################ + print("Case 4: signed file") + + # one unsigned file + signed_file = "sigfile" + MPY_FILE_EXTENSION + mocker.patch( + "os.listdir", + new=mocker.MagicMock(return_value=[signed_file]), + ) + mocker.patch("builtins.open", mocker.mock_open(read_data=b"signature data")) + mocker.patch.object(kapps, "valid_signature", new=lambda data, hash: True) + + signed_apps = kapps.parse_all_flash_apps() + assert len(signed_apps) == 1 + assert signed_file in signed_apps + + +def test_valid_signature(m5stickv, mocker): + from krux.pages.kapps import Kapps + + mocker.patch( + "os.listdir", + new=mocker.MagicMock(return_value=[]), + ) + + ctx = create_ctx(mocker, None) + kapps = Kapps(ctx) + + ######################################## + print("Case 1: invalid pubkey()") + + mocker.patch("krux.firmware.get_pubkey", new=lambda: None) + + with pytest.raises(ValueError, match="Invalid public key"): + kapps.valid_signature(None, None) + + ######################################## + print("Case 2: valid signature") + + mocker.patch("krux.firmware.get_pubkey", new=lambda: "Valid pubkey") + mocker.patch("krux.firmware.check_signature", new=lambda pubk, sig, hash: True) + + sig = kapps.valid_signature(None, None) + assert sig + + +def test_execute_flash_kapp(m5stickv, mocker): + from krux.pages.kapps import Kapps + from krux.pages import MENU_CONTINUE + from krux.input import BUTTON_PAGE, BUTTON_ENTER + import os, sys + from krux.settings import FLASH_PATH + from krux.themes import theme + + btn_seq = [ + BUTTON_PAGE, # case 1 skip prompt + BUTTON_ENTER, # case 2 accept prompt to execute + BUTTON_PAGE, # case 2 dismiss error msg + BUTTON_ENTER, # case 3 accept prompt to execute + ] + + ########################################## + print("Case 1: Exit when prompted to execute the app") + mocker.patch( + "os.listdir", + new=mocker.MagicMock(return_value=[]), + ) + + ctx = create_ctx(mocker, btn_seq) + kapps = Kapps(ctx) + + mocker.spy(kapps, "prompt") + + kapp_name = "anykappname" + result = kapps.execute_flash_kapp(kapp_name) + + assert result == MENU_CONTINUE + assert kapps.prompt.called + kapps.prompt.assert_called_with( + "Execute %s Krux app?" % kapp_name, ctx.display.height() // 2 + ) + + ######################################### + print("Case 2: Continue to execut the app, skip error msg") + + mocker.patch( + "os.chdir", + new=mocker.MagicMock(), + ) + + # avoid call sys.exit() after app execution otherwise will exit test and fail + mocker.patch( + "sys.exit", + new=mocker.MagicMock(), + ) + + mocker.spy(ctx.display, "draw_centered_text") + + kapps.execute_flash_kapp(kapp_name) + assert os.chdir.called + + # First changed to flash path and execut app, then return to / when error appear + os.chdir.assert_has_calls( + [ + mocker.call("/" + FLASH_PATH), + mocker.call("/"), + ] + ) + + assert sys.exit.called + + assert ctx.display.draw_centered_text.called + ctx.display.draw_centered_text.assert_called_with( + "Error:" + "\n" + "Could not execute %s" % kapp_name, theme.error_color + ) + + ####################################### + print("Case 3: app executed") + + mocker.spy(ctx.display, "draw_centered_text") + + dir_path = os.path.dirname(os.path.realpath(__file__)) + print(dir_path) + sys.path.append(dir_path.rsplit("/", 1)[0] + "/files") + + kapps.execute_flash_kapp("kapp") + assert os.chdir.called + + # First changed to flash path and execut app, then return to / when error appear + os.chdir.assert_has_calls( + [ + mocker.call("/" + FLASH_PATH), + mocker.call("/"), + ] + ) + + assert sys.exit.called + + assert not ctx.display.draw_centered_text.called + + +def test_load_sd_kapp_none_selected(m5stickv, mocker): + from krux.pages.kapps import Kapps + from krux.pages import MENU_CONTINUE + + mocker.patch( + "os.listdir", + new=mocker.MagicMock(return_value=[]), + ) + + ctx = create_ctx(mocker, []) + kapps = Kapps(ctx) + + assert kapps.load_sd_kapp() == MENU_CONTINUE + + +def test_load_sd_kapp_negate_load_sha_prompt(m5stickv, mocker): + from krux.pages.kapps import Kapps + from krux.pages import MENU_CONTINUE + from krux.input import BUTTON_PAGE + from krux.pages.utils import Utils + + btn_seq = [BUTTON_PAGE] # cancel the load of the file + + # Used on __init__ of a new Kapps + mocker.patch( + "os.listdir", + new=mocker.MagicMock(return_value=[]), + ) + + # Used to select a file from sd + mocker.patch.object( + Utils, + "load_file", + new=lambda self, file_ext, prompt, only_get_filename: ("f_name", "f_content"), + ) + + # Used to calculate the sha of the selected file + mocker.patch( + "krux.firmware.sha256", + new=mocker.MagicMock(return_value=b"sha256hashvalue"), + ) + + ctx = create_ctx(mocker, btn_seq) + kapps = Kapps(ctx) + + assert kapps.load_sd_kapp() == MENU_CONTINUE + + +def test_load_sd_kapp_sig_file_miss(m5stickv, mocker): + from krux.pages.kapps import Kapps + from krux.pages import MENU_CONTINUE + from krux.input import BUTTON_ENTER + from krux.pages.utils import Utils + + btn_seq = [BUTTON_ENTER] # accept the load of the file + + # Used on __init__ of a new Kapps + mocker.patch( + "os.listdir", + new=mocker.MagicMock(return_value=[]), + ) + + # Used to select a file from sd + mocker.patch.object( + Utils, + "load_file", + new=lambda self, file_ext, prompt, only_get_filename: ("f_name", "f_content"), + ) + + # Used to calculate the sha of the selected file + mocker.patch( + "krux.firmware.sha256", + new=mocker.MagicMock(return_value=b"sha256hashvalue"), + ) + + ctx = create_ctx(mocker, btn_seq) + kapps = Kapps(ctx) + + mocker.spy(kapps, "flash_error") + + assert kapps.load_sd_kapp() == MENU_CONTINUE + + kapps.flash_error.assert_called_with("Missing signature file") + + +def test_load_sd_kapp_sig_file_bad(m5stickv, mocker): + from krux.pages.kapps import Kapps + from krux.pages import MENU_CONTINUE + from krux.input import BUTTON_PAGE, BUTTON_ENTER + from krux.pages.utils import Utils + + btn_seq = [BUTTON_ENTER] # accept the load of the file + + # Used on __init__ of a new Kapps + mocker.patch( + "os.listdir", + new=mocker.MagicMock(return_value=[]), + ) + + # Used to select a file from sd + mocker.patch.object( + Utils, + "load_file", + new=lambda self, file_ext, prompt, only_get_filename: ("f_name", "f_content"), + ) + + # Used to calculate the sha of the selected file + mocker.patch( + "krux.firmware.sha256", + new=mocker.MagicMock(return_value=b"sha256hashvalue"), + ) + + mocker.patch("builtins.open", mocker.mock_open(read_data=b"sigdata")) + + ctx = create_ctx(mocker, btn_seq) + kapps = Kapps(ctx) + + mocker.spy(kapps, "flash_error") + + # Used to return a invalid signature + mocker.patch.object(kapps, "valid_signature", new=lambda sig, data_hash: False) + + assert kapps.load_sd_kapp() == MENU_CONTINUE + + kapps.flash_error.assert_called_with("Bad signature") + + +def test_load_sd_kapp_found_in_flash(m5stickv, mocker): + from krux.pages.kapps import Kapps + from krux.pages import MENU_CONTINUE + from krux.input import BUTTON_PAGE, BUTTON_ENTER + from krux.pages.utils import Utils + + btn_seq = [BUTTON_ENTER] # accept the load of the file + + # Used on __init__ of a new Kapps + mocker.patch( + "os.listdir", + new=mocker.MagicMock(return_value=["one_app.mpy"]), + ) + + # Used on __init__ of a new Kapps when .mpy found + mocker.patch("builtins.open", mocker.mock_open(read_data=b"sigdata")) + + # Used on __init__ of a new Kapps when sig found + mocker.patch.object(Kapps, "valid_signature", new=lambda self, sig, data_hash: True) + + # Used to select a file from sd + mocker.patch.object( + Utils, + "load_file", + new=lambda self, file_ext, prompt, only_get_filename: ("f_name", "f_content"), + ) + + # Used to calculate the sha of the selected file + mocker.patch( + "krux.firmware.sha256", + new=mocker.MagicMock(return_value=b"sha256hashvalue"), + ) + + mocker.patch( + "os.chdir", + new=mocker.MagicMock(), + ) + + ctx = create_ctx(mocker, btn_seq) + kapps = Kapps(ctx) + + mocker.spy(kapps, "flash_error") + + mocker.patch.object(kapps, "execute_flash_kapp", return_value="Success") + + assert kapps.load_sd_kapp() == "Success" + + kapps.flash_error.assert_not_called() + + +def test_load_sd_kapp_not_found_not_allow_store_in_flash(m5stickv, mocker): + from krux.pages.kapps import Kapps + from krux.pages import MENU_CONTINUE + from krux.input import BUTTON_PAGE, BUTTON_ENTER + from krux.pages.utils import Utils + + btn_seq = [ + BUTTON_ENTER, # accept the load of the file + BUTTON_PAGE, # don't allow to store in flash + ] + + # Used on __init__ of a new Kapps + mocker.patch( + "os.listdir", + new=mocker.MagicMock(return_value=["one_app.mpy"]), + ) + + # Used on __init__ of a new Kapps + mocker.patch("builtins.open", mocker.mock_open(read_data=b"sigdata")) + + # Used on __init__ of a new Kapps + mocker.patch.object(Kapps, "valid_signature", new=lambda self, sig, data_hash: True) + + # Used to select a file from sd + mocker.patch.object( + Utils, + "load_file", + new=lambda self, file_ext, prompt, only_get_filename: ("f_name", "f_content"), + ) + + # Used to calculate the sha of the selected file + def sha_return(firmware_size): + return values_list.pop() + + values_list = [b"sha256hash_OTHER_value", b"sha256hashvalue", b"sha256hashvalue"] + mocker.patch( + "krux.firmware.sha256", + new=sha_return, + ) + + ctx = create_ctx(mocker, btn_seq) + kapps = Kapps(ctx) + + mocker.spy(kapps, "flash_error") + mocker.spy(kapps, "execute_flash_kapp") + + assert kapps.load_sd_kapp() == MENU_CONTINUE + + kapps.flash_error.assert_not_called() + kapps.execute_flash_kapp.assert_not_called() + + +def test_load_sd_kapp_not_found_allow_store_in_flash(m5stickv, mocker): + from krux.pages.kapps import Kapps + from krux.pages import MENU_CONTINUE + from krux.input import BUTTON_PAGE, BUTTON_ENTER + from krux.pages.utils import Utils + + btn_seq = [ + BUTTON_ENTER, # accept the load of the file + BUTTON_ENTER, # allow to store in flash + ] + + # Used on __init__ of a new Kapps + mocker.patch( + "os.listdir", + new=mocker.MagicMock(return_value=["one_app.mpy"]), + ) + + # Used on __init__ of a new Kapps + mocker.patch("builtins.open", mocker.mock_open(read_data=b"sigdata")) + + # Used on __init__ of a new Kapps + mocker.patch.object(Kapps, "valid_signature", new=lambda self, sig, data_hash: True) + + # Used to select a file from sd + mocker.patch.object( + Utils, + "load_file", + new=lambda self, file_ext, prompt, only_get_filename: ("f_name", "f_content"), + ) + + # Used to calculate the sha of the selected file + def sha_return(firmware_size): + return values_list.pop() + + values_list = [b"sha256hash_OTHER_value", b"sha256hashvalue", b"sha256hashvalue"] + mocker.patch( + "krux.firmware.sha256", + new=sha_return, + ) + + mocker.patch( + "os.chdir", + new=mocker.MagicMock(), + ) + + ctx = create_ctx(mocker, btn_seq) + kapps = Kapps(ctx) + + mocker.spy(kapps, "flash_error") + + mocker.patch.object(kapps, "execute_flash_kapp", return_value="Success") + + mocker.spy(kapps, "execute_flash_kapp") + + assert kapps.load_sd_kapp() == "Success" + + kapps.flash_error.assert_not_called() + kapps.execute_flash_kapp.assert_called() diff --git a/tests/pages/test_tools.py b/tests/pages/test_tools.py index 9a62c01d4..668cab540 100644 --- a/tests/pages/test_tools.py +++ b/tests/pages/test_tools.py @@ -152,11 +152,13 @@ def test_access_to_device_tests(m5stickv, mocker): from krux.input import BUTTON_ENTER, BUTTON_PAGE, BUTTON_PAGE_PREV BTN_SEQUENCE = [ + BUTTON_PAGE, # select kapps BUTTON_PAGE, # select device tests BUTTON_ENTER, # Go device tests BUTTON_PAGE_PREV, # Go to Back BUTTON_ENTER, # Leave device tests BUTTON_PAGE_PREV, + BUTTON_PAGE_PREV, BUTTON_PAGE_PREV, # select Back BUTTON_ENTER, # leave ] @@ -165,3 +167,37 @@ def test_access_to_device_tests(m5stickv, mocker): tool = Tools(ctx) tool.run() assert ctx.input.wait_for_button.call_count == len(BTN_SEQUENCE) + + +def test_load_krux_app_without_allow(m5stickv, mocker): + from krux.pages.tools import Tools + from krux.pages import MENU_CONTINUE + + ctx = create_ctx(mocker, None) + tool = Tools(ctx) + + mocker.spy(tool, "flash_error") + + val = tool.load_krux_app() + assert val == MENU_CONTINUE + assert tool.flash_error.called + tool.flash_error.assert_called_with("Allow in settings first!") + assert (("Allow in settings first!",),) in tool.flash_error.call_args_list + + +def test_load_krux_app_with_allow(m5stickv, mocker): + from krux.pages.tools import Tools + from krux.pages import MENU_CONTINUE + from krux.krux_settings import Settings + + ctx = create_ctx(mocker, None) + tool = Tools(ctx) + Settings().security.allow_kapp = True + + mocker.spy(tool, "flash_error") + mocker.patch("krux.pages.kapps.Kapps", new=mocker.MagicMock()) + + val = tool.load_krux_app() + assert val == MENU_CONTINUE + assert not tool.flash_error.called + assert (("Allow in settings first!",),) not in tool.flash_error.call_args_list diff --git a/vendor/embit b/vendor/embit index 8d7591229..ed2aa4e1f 160000 --- a/vendor/embit +++ b/vendor/embit @@ -1 +1 @@ -Subproject commit 8d7591229bd34de09aff3986b476bc99fe2b45aa +Subproject commit ed2aa4e1faab90b781a2af8453f3aa671f5fcfd6