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 dda7d1075..f9576b468 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,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/docs/getting-started/features/tools.en.md b/docs/getting-started/features/tools.en.md
index 48feb8274..bc2155e2a 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
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/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 76ddc6c66..6cd06a36f 100644
--- a/i18n/translations/de-DE.json
+++ b/i18n/translations/de-DE.json
@@ -20,7 +20,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?",
@@ -284,6 +289,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",
@@ -316,6 +322,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 594155858..1ecec31a7 100644
--- a/i18n/translations/es-MX.json
+++ b/i18n/translations/es-MX.json
@@ -20,7 +20,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?",
@@ -284,6 +289,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",
@@ -316,6 +322,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 a0a36f04b..80a5911ec 100644
--- a/i18n/translations/fr-FR.json
+++ b/i18n/translations/fr-FR.json
@@ -20,7 +20,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 ?",
@@ -284,6 +289,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",
@@ -316,6 +322,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 4c2d7b420..b062aa84f 100644
--- a/i18n/translations/ja-JP.json
+++ b/i18n/translations/ja-JP.json
@@ -20,7 +20,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?": "信頼できるウォレット記述子をロードしてアドレスを表示しますか?",
@@ -284,6 +289,7 @@
"Spend (%d):": "支出(%d):",
"Spend:": "支出:",
"Standard mode": "標準モード",
+ "Startup Kapp": "スタートアップKapp",
"Static": "静止画",
"Stats for Nerds": "オタクのための統計",
"Store on Flash": "フラッシュに保存する",
@@ -316,6 +322,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 0cf4250a0..18200a1ad 100644
--- a/i18n/translations/ko-KR.json
+++ b/i18n/translations/ko-KR.json
@@ -20,7 +20,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?": "주소를 보기위해 신뢰할 수 있는 월렛 디스크립터를 불러오시겠습니까?",
@@ -284,6 +289,7 @@
"Spend (%d):": "Spend (%d):",
"Spend:": "지출:",
"Standard mode": "표준 모드",
+ "Startup Kapp": "스타트업 Kapp",
"Static": "Static",
"Stats for Nerds": "전문가를 위한 통계",
"Store on Flash": "플래시 메모리에 저장",
@@ -316,9 +322,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 0b997df1e..3a70cbaf5 100644
--- a/i18n/translations/nl-NL.json
+++ b/i18n/translations/nl-NL.json
@@ -20,7 +20,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?",
@@ -284,6 +289,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",
@@ -316,6 +322,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 09d5e3a61..885083a9c 100644
--- a/i18n/translations/pt-BR.json
+++ b/i18n/translations/pt-BR.json
@@ -20,7 +20,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?",
@@ -284,6 +289,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",
@@ -316,6 +322,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 6acb2b15c..fcac5d657 100644
--- a/i18n/translations/ru-RU.json
+++ b/i18n/translations/ru-RU.json
@@ -20,7 +20,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?": "Загрузить дескриптор доверенного кошелька для просмотра адресов?",
@@ -284,6 +289,7 @@
"Spend (%d):": "Расход (%d):",
"Spend:": "Расход:",
"Standard mode": "Стандартный режим",
+ "Startup Kapp": "Запуск Kapp",
"Static": "Static / Статическое оборудование",
"Stats for Nerds": "Статистика для Гиков",
"Store on Flash": "Сохранить на Флэш Память",
@@ -316,6 +322,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 588ec91df..89daddbf9 100644
--- a/i18n/translations/tr-TR.json
+++ b/i18n/translations/tr-TR.json
@@ -20,7 +20,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?",
@@ -284,6 +289,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",
@@ -316,6 +322,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 7a86932ce..83375f3e9 100644
--- a/i18n/translations/vi-VN.json
+++ b/i18n/translations/vi-VN.json
@@ -20,7 +20,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ỉ?",
@@ -284,6 +289,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",
@@ -316,6 +322,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 212e24e7f..f8df0828b 100644
--- a/i18n/translations/zh-CN.json
+++ b/i18n/translations/zh-CN.json
@@ -20,7 +20,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?": "加载受信任的钱包描述符以查看地址?",
@@ -284,6 +289,7 @@
"Spend (%d):": "花费 (%d):",
"Spend:": "花费",
"Standard mode": "标准模式",
+ "Startup Kapp": "启动Kapp",
"Static": "Static 静态?",
"Stats for Nerds": "极客统计数据",
"Store on Flash": "存储到 Flash",
@@ -316,6 +322,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..6be9aab9b
--- /dev/null
+++ b/kapps/nostr.py
@@ -0,0 +1,1069 @@
+# 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/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..cd3ad486b
--- /dev/null
+++ b/kapps/steganography.py
@@ -0,0 +1,570 @@
+# 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 446b5474e..f5e60c7a8 100755
--- a/krux
+++ b/krux
@@ -244,7 +244,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 ae4200283..c3e31ed06 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -76,7 +76,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"
@@ -84,7 +84,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"
@@ -164,6 +164,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
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 b10719f74..4a457429e 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"
@@ -471,10 +473,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"""
@@ -482,6 +497,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/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 82e32cfca..304bc7586 100644
--- a/src/krux/pages/login.py
+++ b/src/krux/pages/login.py
@@ -218,6 +218,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 4e4c65026..7d197d288 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
@@ -41,11 +42,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),
@@ -55,7 +59,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"""
@@ -79,7 +102,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:
@@ -123,7 +145,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 f19eb609e..d5c5bb008 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"
# Network settings
MAIN_TXT = "main"
@@ -102,7 +103,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 e0900ccb1..53a8072a0 100644
--- a/src/krux/translations/__init__.py
+++ b/src/krux/translations/__init__.py
@@ -54,7 +54,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,
@@ -318,6 +323,7 @@
2090568351,
1260825919,
1075810813,
+ 3089686333,
2272013587,
1232757391,
3303592908,
@@ -350,6 +356,7 @@
2061556020,
1128404172,
2089395053,
+ 3448560703,
1374262427,
2518890350,
2786714360,
diff --git a/src/krux/translations/de.py b/src/krux/translations/de.py
index 8802adf11..a0e3b22d0 100644
--- a/src/krux/translations/de.py
+++ b/src/krux/translations/de.py
@@ -42,7 +42,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?",
@@ -306,6 +311,7 @@
"Ausgabe (%d):",
"Ausgaben:",
"Standardmodus",
+ "Startup Kapp",
"Statisch",
"Statistiken für Nerds",
"Auf Flash speichern",
@@ -338,6 +344,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 835afe873..ddd4e69d2 100644
--- a/src/krux/translations/es.py
+++ b/src/krux/translations/es.py
@@ -42,7 +42,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?",
@@ -306,6 +311,7 @@
"Gastos (%d):",
"Gasto:",
"Modo estándar",
+ "Startup Kapp",
"Estático",
"Estadísticas para Entendidos",
"Almacenar en Flash",
@@ -338,6 +344,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 5bb187cd4..f18c00a3f 100644
--- a/src/krux/translations/fr.py
+++ b/src/krux/translations/fr.py
@@ -42,7 +42,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?",
@@ -306,6 +311,7 @@
"Dépense (%d)\u2009:",
"Dépense\u2009:",
"Mode standard",
+ "Startup Kapp",
"Statique",
"Statistiques pour les geeks",
"Stocker sur flash",
@@ -338,6 +344,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 85bd471ce..d9bba747f 100644
--- a/src/krux/translations/ja.py
+++ b/src/krux/translations/ja.py
@@ -42,7 +42,10 @@
"%sにはカメラからの追加エントロピーが必要です",
"アドレス",
"カメラとバックプレートを正しく整列させてください.",
+ "Kruxアプリを許可する",
+ "最初に設定で許可してください!",
"アンチグレアモード",
+ "アプリはフラッシュに内部保存されます。",
"外観",
"よろしいですか?",
"BGRカラー",
@@ -115,6 +118,7 @@
"ユーザーのデータを消去しています…",
"エラー:",
"エスク",
+ "%s Kruxアプリを実行しますか?",
"アーカイブ探索?",
"住所をエクスポート",
"%sをSDカードにエクスポートしています…",
@@ -173,6 +177,7 @@
"ライン遅延",
"ライン:",
"アドレスリスト",
+ "Kruxアプリを読み込む",
"ニーモニックをロード",
"ウォレットをロード",
"信頼できるウォレット記述子をロードしてアドレスを表示しますか?",
@@ -306,6 +311,7 @@
"支出(%d):",
"支出:",
"標準モード",
+ "スタートアップKapp",
"静止画",
"オタクのための統計",
"フラッシュに保存する",
@@ -338,6 +344,7 @@
"キーを入力する",
"取り消し",
"ユニット",
+ "Flashで見つかった署名されていないアプリは削除されます。",
"KEF IDを更新しますか?",
"QRラベルを更新しますか?",
"アップグレードが完了しました.",
diff --git a/src/krux/translations/ko.py b/src/krux/translations/ko.py
index 3007c6ea2..b8a5c17da 100644
--- a/src/krux/translations/ko.py
+++ b/src/krux/translations/ko.py
@@ -42,7 +42,10 @@
"%s 에 필요한 카메라의 추가 엔트로피",
"주소",
"카메라와 보조 플레이트를 올바르게 정렬하십시오.",
+ "Krux 앱 허용",
+ "먼저 설정에서 허용하세요!",
"눈부심 방지 모드",
+ "앱은 내부적으로 플래시로 저장됩니다.",
"디스플레이",
"계속하시겠습니까?",
"BGR 색상",
@@ -115,6 +118,7 @@
"사용자 데이터 삭제 중…",
"오류:",
"Esc",
+ "%s KRUX 앱을 실행하시겠습니까?",
"파일을 탐색하시겠습니까?",
"주소 내보내기",
"%s 을 (를) SD 카드로 내보내는 중…",
@@ -173,6 +177,7 @@
"줄 지연",
"줄:",
"주소 목록",
+ "Krux 앱 로드",
"니모닉 불러오기",
"이대로 불러오기",
"주소를 보기위해 신뢰할 수 있는 월렛 디스크립터를 불러오시겠습니까?",
@@ -306,6 +311,7 @@
"Spend (%d):",
"지출:",
"표준 모드",
+ "스타트업 Kapp",
"Static",
"전문가를 위한 통계",
"플래시 메모리에 저장",
@@ -338,9 +344,10 @@
"비밀번호 입력",
"실행 취소",
"단위",
+ "플래시에서 찾은 서명되지 않은 앱은 삭제됩니다.",
"KEF ID를 업데이트하시겠습니까?",
"QR 레이블을 업데이트하시겠습니까?",
- "업그레이드가 완료되었습니다.",
+ "업그레이드가 완료되었습니다",
"검은색 배경 화면을 사용하십시오.",
"카메라의 엔트로피를 사용하여 새로운 니모닉을 생성하십시오",
"현재 수치",
diff --git a/src/krux/translations/nl.py b/src/krux/translations/nl.py
index cab09792e..a1eaafd22 100644
--- a/src/krux/translations/nl.py
+++ b/src/krux/translations/nl.py
@@ -42,7 +42,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?",
@@ -306,6 +311,7 @@
"Uitgaven (%d):",
"Uitgaven:",
"Standaardmodus",
+ "Kapp opstarten",
"Statisch",
"Statistieken voor nerds",
"Opslaan op apparaat",
@@ -338,6 +344,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 04a718f74..9373faf8d 100644
--- a/src/krux/translations/pt.py
+++ b/src/krux/translations/pt.py
@@ -42,7 +42,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?",
@@ -306,6 +311,7 @@
"Gastos (%d):",
"Gasto:",
"Modo padrão",
+ "Kapp de inicialização",
"Estático",
"Estatísticas para nerds",
"Armazenar na memória flash",
@@ -338,6 +344,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 a72889823..addcd8573 100644
--- a/src/krux/translations/ru.py
+++ b/src/krux/translations/ru.py
@@ -42,7 +42,10 @@
"Требуется дополнительная энтропия от камеры для %s",
"Адрес",
"Правильно совместите камеру и резервную пластину.",
+ "Разрешить приложения Krux",
+ "Сначала разрешите в настройках!",
"Антибликовый режим",
+ "Приложение будет храниться во флэш-памяти.",
"Внешний Вид",
"Вы уверены?",
"Цвета BGR",
@@ -115,6 +118,7 @@
"Удаление данных пользователя…",
"Ошибка:",
"Выйти",
+ "Запустить приложение %s Krux?",
"Исследовать файлы?",
"Экспорт адресов",
"Экспорт %s на SD-карту…",
@@ -173,6 +177,7 @@
"Задержка Линии",
"Линия:",
"Список адресов",
+ "Загрузить приложение Krux",
"Загрузить Мнемонику",
"Загрузить кошелек",
"Загрузить дескриптор доверенного кошелька для просмотра адресов?",
@@ -306,6 +311,7 @@
"Расход (%d):",
"Расход:",
"Стандартный режим",
+ "Запуск Kapp",
"Static / Статическое оборудование",
"Статистика для Гиков",
"Сохранить на Флэш Память",
@@ -338,6 +344,7 @@
"Ввести Ключ",
"Отменить",
"Единица Измерения",
+ "Неподписанные приложения, найденные во флэш-памяти, будут удалены.",
"Обновить идентификатор KEF?",
"Обновить QR-метку?",
"Обновление завершено.",
diff --git a/src/krux/translations/tr.py b/src/krux/translations/tr.py
index 0efac3498..d05cb0204 100644
--- a/src/krux/translations/tr.py
+++ b/src/krux/translations/tr.py
@@ -42,7 +42,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?",
@@ -306,6 +311,7 @@
"Harcama (%d):",
"Harcama:",
"Standart Mod",
+ "Startup Kapp",
"Statik",
"İnekler İçin İstatistikler",
"Flash'ta Sakla",
@@ -338,6 +344,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 6423f5264..6ebe35a07 100644
--- a/src/krux/translations/vi.py
+++ b/src/krux/translations/vi.py
@@ -42,7 +42,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ỉ?",
@@ -306,6 +311,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",
@@ -338,6 +344,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 44ba6c228..15ed970f8 100644
--- a/src/krux/translations/zh.py
+++ b/src/krux/translations/zh.py
@@ -42,7 +42,10 @@
"%s需要摄像头的额外熵",
"地址",
"正确对齐摄像头和背板.",
+ "允许Krux应用程序",
+ "首先在设置中允许!",
"防闪模式",
+ "应用程序将内部存储在闪存中。",
"界面",
"确定?",
"BGR 颜色",
@@ -115,6 +118,7 @@
"正在删除用户数据…",
"错误:",
"退出",
+ "是否执行%s Krux应用程序?",
"浏览文件?",
"导出地址",
"正在将%s导出到SD卡…",
@@ -173,6 +177,7 @@
"行延迟",
"行:",
"地址",
+ "加载Krux应用",
"加载助记词",
"加载钱包",
"加载受信任的钱包描述符以查看地址?",
@@ -306,6 +311,7 @@
"花费 (%d):",
"花费",
"标准模式",
+ "启动Kapp",
"Static 静态?",
"极客统计数据",
"存储到 Flash",
@@ -338,6 +344,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..0edbe5004
--- /dev/null
+++ b/tests/kapps/test_steganography.py
@@ -0,0 +1,55 @@
+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 c7d7edf44..6561b8e44 100644
--- a/tests/pages/test_tools.py
+++ b/tests/pages/test_tools.py
@@ -153,11 +153,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
]
@@ -166,3 +168,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