From 9845944bb802377a3aaf4ca22875e45c893ecd76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D1=83=D1=80=D0=BD=D0=B0=D1=82=20=D0=90=D0=BD=D0=B4?= =?UTF-8?q?=D1=80=D0=B5=D0=B9?= Date: Sun, 1 Nov 2026 15:28:12 +0300 Subject: [PATCH] Initial commit: QSafe Android 1.1.0 and Windows 1.0.4 --- .gitignore | 12 + README.md | 104 +++ app/build.gradle | 40 + .../ru/kusoft/qsafe/BiometricDeviceTests.java | 38 + .../java/ru/kusoft/qsafe/DeviceTests.java | 106 +++ .../ru/kusoft/qsafe/MenuThemeDeviceTests.java | 121 +++ .../kusoft/qsafe/OnboardingRestoreTests.java | 34 + .../qsafe/PasswordGeneratorDeviceTests.java | 101 +++ .../java/ru/kusoft/qsafe/QSafeTestRunner.java | 7 + .../ru/kusoft/qsafe/ScreenSecurityTests.java | 38 + .../java/ru/kusoft/qsafe/UiChecks.java | 68 ++ .../ru/kusoft/qsafe/UpdateDeviceTests.java | 40 + .../ru/kusoft/qsafe/UpdateInstallerTests.java | 29 + .../java/ru/kusoft/qsafe/UpgradeTests.java | 12 + app/src/main/AndroidManifest.xml | 34 + .../java/ru/kusoft/qsafe/AppAppearance.java | 38 + .../main/java/ru/kusoft/qsafe/AppUpdates.java | 72 ++ .../ru/kusoft/qsafe/AutofillActivity.java | 65 ++ .../main/java/ru/kusoft/qsafe/Biometrics.java | 89 ++ .../java/ru/kusoft/qsafe/CardTemplates.java | 56 ++ .../main/java/ru/kusoft/qsafe/CipherSync.java | 19 + .../java/ru/kusoft/qsafe/GlyphDrawable.java | 46 + .../java/ru/kusoft/qsafe/HomeWifiJob.java | 35 + .../ru/kusoft/qsafe/HomeWifiReceiver.java | 6 + .../java/ru/kusoft/qsafe/HomeWifiSync.java | 96 +++ .../main/java/ru/kusoft/qsafe/LanPolicy.java | 35 + .../java/ru/kusoft/qsafe/MainActivity.java | 814 ++++++++++++++++++ .../ru/kusoft/qsafe/PasswordGenerator.java | 56 ++ .../ru/kusoft/qsafe/QSafeApplication.java | 17 + .../ru/kusoft/qsafe/QSafeAutofillService.java | 63 ++ .../main/java/ru/kusoft/qsafe/Repository.java | 283 ++++++ .../ru/kusoft/qsafe/SafeInCloudImport.java | 81 ++ .../java/ru/kusoft/qsafe/SecureWindows.java | 25 + .../java/ru/kusoft/qsafe/SyncRuntime.java | 7 + .../java/ru/kusoft/qsafe/UpdateManifest.java | 27 + .../java/ru/kusoft/qsafe/UpdateProvider.java | 32 + app/src/main/java/ru/kusoft/qsafe/Vault.java | 110 +++ .../java/ru/kusoft/qsafe/VaultCrypto.java | 80 ++ app/src/main/res/drawable/ic_launcher.xml | 4 + app/src/main/res/values/ids.xml | 50 ++ app/src/main/res/values/styles.xml | 25 + app/src/main/res/xml/autofill_service.xml | 1 + .../main/res/xml/data_extraction_rules.xml | 4 + .../java/ru/kusoft/qsafe/CipherSyncTests.java | 23 + .../java/ru/kusoft/qsafe/LanPolicyTests.java | 29 + .../kusoft/qsafe/PasswordGeneratorTests.java | 61 ++ .../kusoft/qsafe/UpdateAndTemplateTests.java | 54 ++ .../test/java/ru/kusoft/qsafe/VaultTests.java | 71 ++ build.gradle | 1 + deploy/install.sh | 76 ++ deploy/lan-only.py | 95 ++ deploy/publish_argus.py | 143 +++ deploy/qa-start.sh | 26 + deploy/qa-stop.sh | 16 + deploy/test_publish_argus.py | 44 + gradle.properties | 4 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43504 bytes gradle/wrapper/gradle-wrapper.properties | 7 + gradlew | 252 ++++++ gradlew.bat | 94 ++ server/backup.py | 36 + server/server.py | 158 ++++ server/test_server.py | 89 ++ settings.gradle | 4 + shared/password-words.txt | 32 + windows/Directory.Build.props | 11 + windows/QSafe.Core/LanPolicy.cs | 49 ++ windows/QSafe.Core/PasswordGenerator.cs | 52 ++ windows/QSafe.Core/QSafe.Core.csproj | 6 + windows/QSafe.Core/SyncClient.cs | 154 ++++ windows/QSafe.Core/Vault.cs | 155 ++++ windows/QSafe.Core/VaultCrypto.cs | 87 ++ windows/QSafe.Core/XmlImport.cs | 51 ++ windows/QSafe.Desktop/App.xaml | 83 ++ windows/QSafe.Desktop/App.xaml.cs | 68 ++ windows/QSafe.Desktop/Assets/QSafe.ico | Bin 0 -> 41086 bytes windows/QSafe.Desktop/Dialogs.cs | 225 +++++ windows/QSafe.Desktop/HomeWifiNetwork.cs | 49 ++ windows/QSafe.Desktop/LocalStore.cs | 116 +++ windows/QSafe.Desktop/MainWindow.xaml | 61 ++ windows/QSafe.Desktop/MainWindow.xaml.cs | 400 +++++++++ .../QSafe.Desktop/PasswordGeneratorDialog.cs | 78 ++ windows/QSafe.Desktop/QSafe.Desktop.csproj | 11 + windows/QSafe.Desktop/Ui.cs | 39 + windows/QSafe.Desktop/app.manifest | 6 + windows/QSafe.Tests/InteropFixture.java | 23 + windows/QSafe.Tests/Program.cs | 309 +++++++ windows/QSafe.Tests/QSafe.Tests.csproj | 4 + windows/QUICKSTART.txt | 38 + windows/README.md | 44 + windows/build.ps1 | 42 + 91 files changed, 6496 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app/build.gradle create mode 100644 app/src/androidTest/java/ru/kusoft/qsafe/BiometricDeviceTests.java create mode 100644 app/src/androidTest/java/ru/kusoft/qsafe/DeviceTests.java create mode 100644 app/src/androidTest/java/ru/kusoft/qsafe/MenuThemeDeviceTests.java create mode 100644 app/src/androidTest/java/ru/kusoft/qsafe/OnboardingRestoreTests.java create mode 100644 app/src/androidTest/java/ru/kusoft/qsafe/PasswordGeneratorDeviceTests.java create mode 100644 app/src/androidTest/java/ru/kusoft/qsafe/QSafeTestRunner.java create mode 100644 app/src/androidTest/java/ru/kusoft/qsafe/ScreenSecurityTests.java create mode 100644 app/src/androidTest/java/ru/kusoft/qsafe/UiChecks.java create mode 100644 app/src/androidTest/java/ru/kusoft/qsafe/UpdateDeviceTests.java create mode 100644 app/src/androidTest/java/ru/kusoft/qsafe/UpdateInstallerTests.java create mode 100644 app/src/androidTest/java/ru/kusoft/qsafe/UpgradeTests.java create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/java/ru/kusoft/qsafe/AppAppearance.java create mode 100644 app/src/main/java/ru/kusoft/qsafe/AppUpdates.java create mode 100644 app/src/main/java/ru/kusoft/qsafe/AutofillActivity.java create mode 100644 app/src/main/java/ru/kusoft/qsafe/Biometrics.java create mode 100644 app/src/main/java/ru/kusoft/qsafe/CardTemplates.java create mode 100644 app/src/main/java/ru/kusoft/qsafe/CipherSync.java create mode 100644 app/src/main/java/ru/kusoft/qsafe/GlyphDrawable.java create mode 100644 app/src/main/java/ru/kusoft/qsafe/HomeWifiJob.java create mode 100644 app/src/main/java/ru/kusoft/qsafe/HomeWifiReceiver.java create mode 100644 app/src/main/java/ru/kusoft/qsafe/HomeWifiSync.java create mode 100644 app/src/main/java/ru/kusoft/qsafe/LanPolicy.java create mode 100644 app/src/main/java/ru/kusoft/qsafe/MainActivity.java create mode 100644 app/src/main/java/ru/kusoft/qsafe/PasswordGenerator.java create mode 100644 app/src/main/java/ru/kusoft/qsafe/QSafeApplication.java create mode 100644 app/src/main/java/ru/kusoft/qsafe/QSafeAutofillService.java create mode 100644 app/src/main/java/ru/kusoft/qsafe/Repository.java create mode 100644 app/src/main/java/ru/kusoft/qsafe/SafeInCloudImport.java create mode 100644 app/src/main/java/ru/kusoft/qsafe/SecureWindows.java create mode 100644 app/src/main/java/ru/kusoft/qsafe/SyncRuntime.java create mode 100644 app/src/main/java/ru/kusoft/qsafe/UpdateManifest.java create mode 100644 app/src/main/java/ru/kusoft/qsafe/UpdateProvider.java create mode 100644 app/src/main/java/ru/kusoft/qsafe/Vault.java create mode 100644 app/src/main/java/ru/kusoft/qsafe/VaultCrypto.java create mode 100644 app/src/main/res/drawable/ic_launcher.xml create mode 100644 app/src/main/res/values/ids.xml create mode 100644 app/src/main/res/values/styles.xml create mode 100644 app/src/main/res/xml/autofill_service.xml create mode 100644 app/src/main/res/xml/data_extraction_rules.xml create mode 100644 app/src/test/java/ru/kusoft/qsafe/CipherSyncTests.java create mode 100644 app/src/test/java/ru/kusoft/qsafe/LanPolicyTests.java create mode 100644 app/src/test/java/ru/kusoft/qsafe/PasswordGeneratorTests.java create mode 100644 app/src/test/java/ru/kusoft/qsafe/UpdateAndTemplateTests.java create mode 100644 app/src/test/java/ru/kusoft/qsafe/VaultTests.java create mode 100644 build.gradle create mode 100755 deploy/install.sh create mode 100644 deploy/lan-only.py create mode 100644 deploy/publish_argus.py create mode 100755 deploy/qa-start.sh create mode 100755 deploy/qa-stop.sh create mode 100644 deploy/test_publish_argus.py create mode 100644 gradle.properties create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 server/backup.py create mode 100644 server/server.py create mode 100644 server/test_server.py create mode 100644 settings.gradle create mode 100644 shared/password-words.txt create mode 100644 windows/Directory.Build.props create mode 100644 windows/QSafe.Core/LanPolicy.cs create mode 100644 windows/QSafe.Core/PasswordGenerator.cs create mode 100644 windows/QSafe.Core/QSafe.Core.csproj create mode 100644 windows/QSafe.Core/SyncClient.cs create mode 100644 windows/QSafe.Core/Vault.cs create mode 100644 windows/QSafe.Core/VaultCrypto.cs create mode 100644 windows/QSafe.Core/XmlImport.cs create mode 100644 windows/QSafe.Desktop/App.xaml create mode 100644 windows/QSafe.Desktop/App.xaml.cs create mode 100644 windows/QSafe.Desktop/Assets/QSafe.ico create mode 100644 windows/QSafe.Desktop/Dialogs.cs create mode 100644 windows/QSafe.Desktop/HomeWifiNetwork.cs create mode 100644 windows/QSafe.Desktop/LocalStore.cs create mode 100644 windows/QSafe.Desktop/MainWindow.xaml create mode 100644 windows/QSafe.Desktop/MainWindow.xaml.cs create mode 100644 windows/QSafe.Desktop/PasswordGeneratorDialog.cs create mode 100644 windows/QSafe.Desktop/QSafe.Desktop.csproj create mode 100644 windows/QSafe.Desktop/Ui.cs create mode 100644 windows/QSafe.Desktop/app.manifest create mode 100644 windows/QSafe.Tests/InteropFixture.java create mode 100644 windows/QSafe.Tests/Program.cs create mode 100644 windows/QSafe.Tests/QSafe.Tests.csproj create mode 100644 windows/QUICKSTART.txt create mode 100644 windows/README.md create mode 100644 windows/build.ps1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4ce8eec --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +.gradle/ +**/build/ +local.properties +.private/ +artifacts/ +*.iml +__pycache__/ +*.pyc +*.db +*.db-* +**/bin/ +**/obj/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..83a95f6 --- /dev/null +++ b/README.md @@ -0,0 +1,104 @@ +# QSafe + +Приложение для Android и Windows для хранения паролей с зашифрованной синхронизацией на собственный Raspberry Pi. Android 1.1.0, Windows 1.0.4, самостоятельная реализация. SafeInCloud используется только как источник XML-импорта; его закрытый формат `.db` не поддерживается. + +## Windows + +Готовое приложение: `artifacts/QSafe-Windows-1.0.4/QSafe.exe`, переносимый архив: `artifacts/QSafe-Windows-1.0.4.zip`. Сборка x64 включает .NET и работает без установки. Есть тёмный интерфейс, поиск, категории, избранное, редактор карточек, корзина, генератор, XML-импорт, резервные копии и синхронизация с тем же Pi. + +Для существующей Android-базы выбрать **Подключить Pi и синхронизировать**, импортировать личный `connection.qsafe`, сохранить настройки и ввести существующий мастер-пароль. Для знакомства доступна отдельная демонстрационная база с вымышленными данными. Настоящая база находится в `%LOCALAPPDATA%\Kusoft\QSafe`; последние 10 зашифрованных сохранений остаются в `backups`. + +Автосинхронизация Windows работает при разблокированном хранилище раз в минуту только внутри домашнего Wi-Fi. Это же обязательное ограничение действует для ручной синхронизации и восстановления базы. Вне дома приложение работает автономно. Ключ подключения защищён Windows DPAPI, мастер-пароль не сохраняется. Вход по Windows Hello и автозаполнение браузеров пока отсутствуют. EXE не подписан. Подробности, сборка и проверки: [windows/README.md](windows/README.md). + +## Начало работы + +1. Установить `artifacts/QSafe-1.1.0.apk` на Android 9 или новее либо скачать версию 1.1.0 из [QSafe в Аргусе](https://argus.kusoft.xyz/?app=qsafe). Публичная загрузка проверяется по размеру, SHA-256 и подписи APK. Устанавливать поверх прежней версии, без удаления приложения и очистки данных. +2. На новом устройстве нажать **Подключить Pi и синхронизировать**, выбрать обновлённый личный файл `connection-lan.qsafe` и проверить локальный IP Pi, SSID и отпечаток сертификата. На уже настроенном Android импортировать его через настройки подключения. Старые файлы с внешним адресом базы больше не принимаются. Это ключ доступа к синхронизации. Не публиковать файл и не отправлять его в чат. +3. Ввести мастер-пароль существующей базы и нажать **Разблокировать**: зашифрованная база скачивается с Pi и проверяется на устройстве. Пока настройка не завершена, её можно повторить после перезапуска. Для пустого Pi в первом подключении выбрать **Создать новое хранилище** и задать пароль от 12 символов. +4. После создания или восстановления базы на стартовом экране остаются поле мастер-пароля и кнопка **Разблокировать**. При включённом входе по отпечатку системный запрос появляется автоматически; повторить его можно кнопкой разблокировки с пустым полем пароля. +5. Вход по отпечатку предлагается после входа мастер-паролем на устройстве с зарегистрированным отпечатком. Его также можно включить или выключить переключателем **☰ → Защита → Вход по отпечатку**. Изменение зарегистрированных отпечатков аннулирует биометрический ключ; мастер-пароль остаётся способом входа. + +## Карточки и обновления + +Поле поиска и меню **☰** закреплены над списком. Карточки имеют локальные пиктограммы категорий; обращения к внешним сервисам иконок и передача доменов не выполняются. Постоянная кнопка **+** открывает выбор шаблона: сайт или приложение, банковская карта, электронная почта, Wi-Fi, сервер, документ, секретная заметка или своя карточка. + +На Android **☰** открывает полноценную страницу меню с разделами **Внешний вид**, **Синхронизация**, **Защита**, **Данные** и **Приложение**. Автосинхронизация, фоновая синхронизация, вход по отпечатку и автозаполнение имеют отдельные переключатели. Состояние автозаполнения берётся из Android; включение подтверждается в системных настройках, выключение отключает только службу QSafe. При выключении фоновой синхронизации отменяются её задачи и сетевые подписки, а новые события не ставят отключённую функцию в очередь. Подключение Pi, домашняя сеть, импорт и резервные копии открываются отдельными страницами. В настройках Pi пустое поле ключа сохраняет уже установленный ключ. + +**☰ → Внешний вид → Тема приложения** предлагает **Как в системе**, **Светлая** и **Тёмная**. Выбор применяется сразу и сохраняется на устройстве. Оформление охватывает список, карточки, формы, генератор и диалоги. В системном режиме смена темы Android не сбрасывает открытый редактор и его несохранённые данные. + +Просмотр и редактирование карточки открываются полноценными страницами. Нажатие на поле копирует его значение; секретные поля скрыты до нажатия на значок глаза. Кнопка назад возвращает к списку, а редактор предупреждает о несохранённых изменениях. Если синхронизация получила новую версию во время редактирования, сохраняются обе версии. Ручная синхронизация и блокировка находятся в меню **☰**; автоматическая синхронизация сохранена. + +**Сгенерировать пароль** в редакторе Android и Windows открывает отдельное защищённое окно с видимым результатом. Режимы: **Сложный** (16–64 символа, по умолчанию 24; специальные символы можно отключить), **Только цифры** (4–64 цифры, по умолчанию 12) и **Запоминающийся** (2–12 случайных английских слов, по умолчанию 8; цифры и специальные знаки включены по умолчанию и отключаются отдельно). Цифры и знаки размещаются случайно перед первым словом, между словами или после последнего слова; порядок цифр и знаков также случаен. Слова остаются целыми и начинаются с заглавной буквы. Примеры: `57+LemonRiver`, `Lemon7+River5`, `LemonRiver+57`. В последнем режиме длина выбирается числом слов; рядом показано итоговое число символов. Длина запоминается отдельно для каждого режима до закрытия окна. Изменение настроек и **Другой вариант** обновляют предпросмотр; только **Использовать пароль** заменяет поле карточки. Отмена сохраняет прежнее значение. Результат не копируется в буфер автоматически. Слова и цифры выбираются криптографическим генератором случайных чисел; общий встроенный список содержит 256 слов и не требует сети. + +**☰ → Проверить обновления** проверяет stable-релиз в Аргусе по HTTPS. Перед установкой проверяются размер и SHA-256 загрузки, имя пакета, числовой код версии и совпадение сертификата подписи с установленным приложением. APK хранится во внутреннем кэше; установщику передаётся доступ только к этому файлу. При необходимости Android предлагает разрешить установку обновлений из QSafe, затем показывает обычное подтверждение установки. Мастер-пароль и база в каталог обновлений не передаются. + +## Домашний Wi-Fi + +Открыть **☰ → Синхронизация → Домашний Wi-Fi**, указать точное имя домашней сети (SSID) и сохранить. Фоновая работа включается отдельным переключателем **Фоновая синхронизация** в разделе синхронизации. При нехватке параметров или разрешений открывается страница настройки сети. Для владельца этого Pi домашняя сеть — `gen`; личный файл подключения может передать эту настройку автоматически. В публичном APK имя сети и ключ подключения не зашиты. + +Чтобы Android разрешал читать SSID при закрытом приложении, нужны точный доступ к местоположению, системная геолокация и на Android 10+ разрешение местоположения **«Всегда»**. Приложение показывает недостающие разрешения и кнопки перехода к настройкам. Координаты не запрашиваются, история перемещений не ведётся. При неизвестном SSID, недостающих разрешениях или подключении к другой сети фоновая синхронизация не выполняется. + +Подключение к Wi-Fi отслеживается системным `ConnectivityManager`: живой callback обрабатывает повторные подключения, а `PendingIntent` позволяет принять событие после завершения процесса. После потери сети ожидание события регистрируется заново. Передача запускается отдельной задачей `JobScheduler` и привязывается к Wi-Fi-соединению, поэтому не переходит на мобильный интернет при обрыве Wi-Fi. Резервная периодическая задача раз в 15 минут восстанавливает работу при пропущенных событиях; экономия энергии и ограничения производителя могут задержать запуск. После принудительной остановки приложения в системных настройках его нужно открыть заново. + +Заблокированное приложение не читает мастер-ключ и открытые пароли. Оно отправляет только готовую зашифрованную базу с условием совпадения известной серверной версии. Изменённая копия Pi загружается в отдельный файл `pending.qsafe`, не заменяя доверенную локальную базу. При следующей разблокировке проверяются GCM-тег и принадлежность хранилищу, затем выполняется объединение. Повреждённая копия не заменяет локальные данные. Конфликтующие изменения сохраняются обеими версиями; окончательное объединение требует входа в хранилище. Все подключения к базе — автоматические, ручные и восстановление — требуют домашний Wi-Fi, локальный IPv4 Pi в той же подсети и закреплённый сертификат. Отключение фоновых задач не отменяет это ограничение. Интернет в домашней сети для базы не требуется. Обновления Android через Аргус остаются отдельным разрешённым внешним каналом. + +## Перенос из SafeInCloud + +В SafeInCloud для Windows открыть **Файл → Экспорт → XML**. Если установленная мобильная версия предлагает XML-экспорт, можно использовать его. Скопировать XML на телефон, открыть в QSafe **Настройки → Импорт из SafeInCloud (XML)** и проверить число найденных карточек перед подтверждением. + +Переносятся все поля с типами и значениями, заметки, метки и избранное. Удалённые карточки и шаблоны пропускаются с явным счётчиком. Повторный импорт неизменённых карточек не создаёт дублей. Изменённая после предыдущего импорта карточка будет добавлена отдельной копией. Исходный XML каждой карточки, включая вложения и дополнительные данные, сохраняется внутри зашифрованной базы. Просмотр вложений и истории в интерфейсе первой версии не реализован. + +XML-экспорт содержит **незашифрованные пароли**. QSafe читает его локально и шифрует перед сохранением; сервер получает только шифротекст. После сверки количества карточек и нескольких записей удалить открытый экспорт с устройств. Сохранить исходную базу SafeInCloud до завершения проверки. Фактический перенос пользовательских паролей требует файла экспорта и подтверждения импорта в приложении; автоматического доступа к SafeInCloud нет. + +## Защита и синхронизация + +- AES-256-GCM, случайный 96-битный nonce для каждого сохранения; заголовок файла включён в аутентифицируемые данные. +- PBKDF2-HMAC-SHA256, 600 000 итераций, случайная 128-битная соль. Используются стандартные криптографические API Java/Android. Ограничения размера и параметров проверяются до выработки ключа. +- Мастер-пароль и ключ базы не записываются на диск и не передаются серверу. В оперативной памяти расшифрованные данные доступны во время работы; гарантировать стирание всех копий строк в управляемой памяти Android нельзя. +- Ключ подключения к Pi зашифрован ключом Android Keystore. Вход по биометрии отдельно оборачивает ключ базы аппаратным/системным ключом с обязательной сильной биометрической авторизацией. Мастер-пароль остаётся способом восстановления. +- Блокировка при выходе из приложения и через 2 минуты бездействия. Все страницы и диалоги QSafe защищены `FLAG_SECURE` до показа окна; снимки в недавних приложениях также отключены на Android 13+. Содержимое форм не сохраняется в состоянии Activity. Буфер обмена помечается как чувствительный и очищается через 30 секунд, если приложение ещё работает и содержимое не заменено другим приложением. +- Системные резервные копии/перенос данных Android отключены. Доступен ручной экспорт зашифрованного `.qsafe` и восстановление с проверкой мастер-пароля. +- Для базы HTTPS проверяет IP, срок действия и закреплённый SHA-256 сертификата Pi; DNS, прокси, редиректы и сетевые загрузки сертификатов не используются. Незащищённый HTTP запрещён. Обновления APK через Аргус используют обычную системную проверку HTTPS и проверку подписи пакета. +- Pi хранит только аутентифицированный зашифрованный файл, его SHA-256 и предыдущие версии. Сервер технически не может проверить GCM-тег без клиентского ключа; целостность проверяет клиент. +- `ETag` и обязательные `If-Match`/`If-None-Match` предотвращают молчаливое затирание конкурентных записей. Клиент сравнивает общую исходную версию; независимые изменения объединяются, конкурентные правки сохраняются отдельными карточками. Конфликт удаления с редактированием сохраняет отредактированную карточку с пометкой. +- На телефоне остаются 10 предыдущих зашифрованных копий. На Pi — до 100 предыдущих версий с ограничением 256 МиБ. Ежедневная проверенная копия SQLite хранится отдельно на SD-карте: до 30 копий и 512 МиБ. Обе копии находятся на одном Pi; это не защита от потери всего устройства. + +Приложение не проходило независимый аудит безопасности. Оно не заявляет полного функционального соответствия SafeInCloud. Смена мастер-пароля, passkeys и генерация TOTP пока не реализованы. + +## Автозаполнение + +Включается пользователем в **☰ → Защита → Автозаполнение** с подтверждением Android. Тем же переключателем служба QSafe выключается. Для каждого запроса требуется разблокировка и подтверждение выбранной карточки с показом получателя. Поддерживаются приложения с подсказками Android `username`/`emailAddress`/`password`. Для веб-сайтов поддерживается только выбранный по умолчанию браузер, HTTPS и точное совпадение домена с полем сайта в карточке. Неоднозначные домены и другие браузеры отклоняются. Автоматическое сохранение новых паролей из сторонних приложений пока не реализовано. Совместимость зависит от конкретного приложения/браузера. + +## Pi + +Сервер базы: `https://192.168.0.185:18443/qsafe/`, health: `/health`, база: `/v1/vault`. Caddy слушает только `192.168.0.185:18443` и принимает клиентов из `192.168.0.0/24`; бэкенд остаётся на `127.0.0.1:18792`. Публичный путь `/qsafe/*` на Аргусе возвращает 404. Каталог и загрузка обновлений Android через Аргус сохранены. Доступ к базе — по случайному 256-битному Bearer-ключу, сервер хранит только его SHA-256. APK ключей подключения, домашнего SSID и отпечатка пользовательского сертификата не содержит. + +Локальный TLS и закрытие публичного маршрута настраивает `deploy/lan-only.py --ip 192.168.0.185`; откат конфигурации сохраняется в `/var/backups/qsafe-lan/`. Личный `.private/connection-lan.qsafe` содержит локальный URL, прежний ключ доступа, `homeWifiSsid` и `certificateSha256` и не входит в APK/ZIP. Файлы базы и истории при переносе маршрута не изменяются. + +| Назначение | Путь / служба | +|---|---| +| Приложение сервера | `/opt/qsafe/server.py` | +| Зашифрованная база и история | `/mnt/data/qsafe/data/vault.sqlite3` | +| Хеш ключа подключения | `/etc/qsafe/token.sha256` | +| Служба | `qsafe.service` | +| Резервные копии на SD-карте | `/var/backups/qsafe` | +| Таймер | `qsafe-backup.timer`, ежедневно около 04:25 по времени Pi | +| Откат Caddy | `/var/backups/qsafe-install` | + +Сервер запускается от отдельного пользователя `qsafe`, запись разрешена только в каталог базы; требуется смонтированный внешний диск. Для остановки: `sudo systemctl disable --now qsafe.service qsafe-backup.timer`. Данные при этом сохраняются. Для восстановления старой версии файла из SQLite следует сначала остановить клиентов и службу, сохранить текущую базу, затем выгрузить выбранное поле `history.body` в `.qsafe` и восстановить его через приложение. Никогда не редактировать зашифрованное содержимое вручную. + +## Сборка и проверка + +JDK 17, Android SDK 35, Gradle 8.14. В Windows при пути с кириллицей использовать временный диск, например `subst R: "путь-к-проекту"`, затем выполнять Gradle из `R:\`. Настроить `local.properties` с путём к Android SDK. + +```powershell +.\gradlew.bat :app:testDebugUnitTest :app:assembleDebug :app:assembleDebugAndroidTest :app:lintDebug :app:assembleRelease --no-daemon +``` + +Подпись релиза использует `.private/signing.properties` и личный `.p12`; ключи, пароли, соединения и локальные настройки исключены из Git. Ключ подписи необходимо сохранить для дальнейших обновлений. + +Тесты JVM проверяют шифрование, неверный пароль, повреждение, генератор, импорт и конфликты. Серверные тесты: `python3 -m unittest -v test_server` из `server/`. Android-тесты используют отдельный временный HTTPS-сервер и синтетические пароли; рабочая база пользователя в них не участвует. Файлы `deploy/qa-start.sh` и `deploy/qa-stop.sh` включают и выключают только временный маршрут проверки. + +Источники решений: [Android Cryptography](https://developer.android.com/privacy-and-security/cryptography), [Android Autofill](https://developer.android.com/identity/autofill/autofill-services), [OWASP PBKDF2](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html), [описание XML SafeInCloud от автора парсера](https://github.com/eduncan911/safeincloud). + +Фоновая сеть: [ConnectivityManager и PendingIntent](https://developer.android.com/reference/android/net/ConnectivityManager), [доступ к SSID в WifiInfo](https://developer.android.com/reference/android/net/wifi/WifiInfo), [JobInfo](https://developer.android.com/reference/android/app/job/JobInfo.Builder). diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 0000000..ec739c8 --- /dev/null +++ b/app/build.gradle @@ -0,0 +1,40 @@ +plugins { id 'com.android.application' } + +def signing = new Properties() +def signingFile = rootProject.file('.private/signing.properties') +if (signingFile.exists()) signingFile.withInputStream { signing.load(it) } + +android { + namespace 'ru.kusoft.qsafe' + compileSdk 35 + defaultConfig { + applicationId 'ru.kusoft.qsafe' + minSdk 28 + targetSdk 35 + versionCode = (project.findProperty('qsafeVersionCode') ?: '8').toInteger() + versionName = (project.findProperty('qsafeVersionName') ?: '1.1.0') + testInstrumentationRunner 'ru.kusoft.qsafe.QSafeTestRunner' + } + signingConfigs { + release { + if (signingFile.exists()) { + storeFile rootProject.file(signing.getProperty('storeFile')) + storePassword signing.getProperty('storePassword') + keyAlias signing.getProperty('keyAlias') + keyPassword signing.getProperty('keyPassword') + } + } + } + buildTypes { release { signingConfig signingConfigs.release; minifyEnabled false } } + compileOptions { sourceCompatibility JavaVersion.VERSION_17; targetCompatibility JavaVersion.VERSION_17 } + buildFeatures { buildConfig true } + sourceSets { main.resources.srcDir rootProject.file('shared') } + testOptions { unitTests.returnDefaultValues = true } + lint { abortOnError true } +} + +dependencies { + androidTestImplementation 'androidx.test:runner:1.6.2' + testImplementation 'junit:junit:4.13.2' + testImplementation 'org.json:json:20240303' +} diff --git a/app/src/androidTest/java/ru/kusoft/qsafe/BiometricDeviceTests.java b/app/src/androidTest/java/ru/kusoft/qsafe/BiometricDeviceTests.java new file mode 100644 index 0000000..49cc5ea --- /dev/null +++ b/app/src/androidTest/java/ru/kusoft/qsafe/BiometricDeviceTests.java @@ -0,0 +1,38 @@ +package ru.kusoft.qsafe; + +import android.app.*; +import android.content.*; +import android.view.WindowManager; +import android.widget.*; +import junit.framework.TestCase; +import org.json.*; +import java.lang.reflect.Field; +import java.nio.file.Files; + +public class BiometricDeviceTests extends TestCase { + private final Instrumentation instrument=androidx.test.platform.app.InstrumentationRegistry.getInstrumentation(); + private MainActivity activity; + private void main(Runnable work){instrument.runOnMainSync(work);instrument.waitForIdleSync();} + private Object field(String name)throws Exception{Field f=MainActivity.class.getDeclaredField(name);f.setAccessible(true);return f.get(activity);} + private interface Ready{boolean get()throws Exception;} + private void await(Ready ready)throws Exception{long end=System.currentTimeMillis()+90000;while(System.currentTimeMillis(){try{bio.disable();}catch(Exception e){throw new RuntimeException(e);}});assertTrue("Enroll emulator fingerprint first",bio.available()); + main(()->{((EditText)activity.findViewById(R.id.master_password)).setText(new String(pass));activity.findViewById(R.id.open_vault).performClick();});await(()->activity.findViewById(R.id.add_entry)!=null); + main(()->activity.findViewById(R.id.settings).performClick());assertNull(field("dialog"));main(()->activity.findViewById(R.id.fingerprint).performClick());android.util.Log.i("QSafeQA","BIO_ENABLE_READY"); + await(bio::enabled);assertFalse(new String(Files.readAllBytes(new java.io.File(c.getFilesDir(),"biometric.enc").toPath()),java.nio.charset.StandardCharsets.UTF_8).contains("SYNTHETIC")); + main(activity::finish);activity=(MainActivity)instrument.startActivitySync(launch);await(()->(Boolean)field("fingerprintRunning"));android.util.Log.i("QSafeQA","BIO_UNLOCK_READY"); + await(()->activity.findViewById(R.id.add_entry)!=null);assertTrue((activity.getWindow().getAttributes().flags&WindowManager.LayoutParams.FLAG_SECURE)!=0); + main(activity::finish);activity=(MainActivity)instrument.startActivitySync(launch);await(()->(Boolean)field("fingerprintRunning"));android.util.Log.i("QSafeQA","BIO_FALLBACK_READY"); + await(()->!(Boolean)field("fingerprintRunning"));assertNotNull(activity.findViewById(R.id.master_password));assertNull(activity.findViewById(R.id.connect_pi)); + main(()->{((EditText)activity.findViewById(R.id.master_password)).setText("incorrect synthetic password");activity.findViewById(R.id.open_vault).performClick();});await(()->!(Boolean)field("busy"));assertNull(activity.findViewById(R.id.add_entry)); + main(()->{((EditText)activity.findViewById(R.id.master_password)).setText(new String(pass));activity.findViewById(R.id.open_vault).performClick();});await(()->activity.findViewById(R.id.add_entry)!=null); + try(VaultCrypto.Key key=VaultCrypto.unlock(r.encrypted(),pass)){assertEquals("Biometric synthetic card",Vault.visible(r.read(key),"",false).get(0).getString("title"));} + java.util.Arrays.fill(pass,'\0');main(activity::finish);android.util.Log.i("QSafeQA","BIO_ALL_PASSED"); + } +} diff --git a/app/src/androidTest/java/ru/kusoft/qsafe/DeviceTests.java b/app/src/androidTest/java/ru/kusoft/qsafe/DeviceTests.java new file mode 100644 index 0000000..24f7ad8 --- /dev/null +++ b/app/src/androidTest/java/ru/kusoft/qsafe/DeviceTests.java @@ -0,0 +1,106 @@ +package ru.kusoft.qsafe; + +import android.app.*; +import android.content.*; +import android.graphics.Bitmap; +import junit.framework.TestCase; +import android.view.*; +import android.widget.*; +import org.json.*; +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.util.*; + +@SuppressWarnings("deprecation") +@android.annotation.SuppressLint("ResourceType") +public class DeviceTests extends TestCase { + private android.app.Instrumentation getInstrumentation() {return androidx.test.platform.app.InstrumentationRegistry.getInstrumentation();} + private Context isolated(String name) { + Context target=getInstrumentation().getTargetContext();File dir=new File(target.getCacheDir(),"qa-"+name+"-"+UUID.randomUUID());assertTrue(dir.mkdirs()); + return new ContextWrapper(target) {@Override public File getFilesDir(){return dir;}}; + } + public void testAEncryptedPersistenceAndImportOnAndroid() throws Exception { + Context c=isolated("persistence");Repository r=new Repository(c); + char[] pass="synthetic password 123!".toCharArray(); + try(VaultCrypto.Key key=VaultCrypto.create(pass)) { + JSONObject data=Vault.empty();byte[] xml=("").getBytes(StandardCharsets.UTF_8); + SafeInCloudImport.Preview preview=SafeInCloudImport.preview(xml,data);assertEquals(1,preview.added);r.save(preview.vault,key); + assertFalse(new String(r.encrypted(),StandardCharsets.UTF_8).contains("TEST_SECRET_NEVER_REAL")); + Repository reopened=new Repository(c);assertEquals("Тестовая почта",Vault.visible(reopened.read(key),"",false).get(0).getString("title")); + byte[] exported=r.encrypted();reopened.restoreFile(exported,pass); + assertEquals(1,Vault.visible(reopened.read(key),"",false).size()); + } + Arrays.fill(pass,'\0'); + } + public void testBRealHttpsSyncRestoreMergeAndConflict() throws Exception { + String path=QSafeTestRunner.arguments.getString("connection");assertNotNull("Pass a QA connection file path",path); + JSONObject connection;try(InputStream in=new FileInputStream(path)) {connection=new JSONObject(new String(Repository.bounded(in,16384),StandardCharsets.UTF_8));} + Context ca=isolated("deviceA"),cb=isolated("deviceB");Repository a=new Repository(ca),b=new Repository(cb); + a.configure(connection.getString("url"),connection.getString("token"));b.configure(connection.getString("url"),connection.getString("token")); + char[] pass="synthetic sync password 123!".toCharArray(); + try(VaultCrypto.Key ka=VaultCrypto.create(pass)) { + JSONObject first=Vault.empty(),entry=Vault.entry("Mail synthetic");entry.getJSONArray("fields").put(Vault.field("Password","password","SYNTHETIC_SYNC_SECRET"));Vault.update(first,entry);a.save(first,ka); + assertEquals(0,a.sync(ka).conflicts); + try(VaultCrypto.Key kb=b.restorePi(pass)) { + assertEquals("Mail synthetic",Vault.visible(b.read(kb),"",false).get(0).getString("title")); + JSONObject localA=a.read(ka),localB=b.read(kb); + JSONObject ea=Vault.copy(localA.getJSONArray("entries").getJSONObject(0)).put("notes","A changed"); + JSONObject eb=Vault.copy(localB.getJSONArray("entries").getJSONObject(0)).put("notes","B changed"); + Vault.update(localA,ea);Vault.update(localB,eb);a.save(localA,ka);b.save(localB,kb); + a.sync(ka);Repository.SyncResult merged=b.sync(kb);assertEquals(1,merged.conflicts);assertEquals(2,Vault.visible(merged.data,"",false).size()); + Repository.SyncResult pulled=a.sync(ka);assertEquals(2,Vault.visible(pulled.data,"",false).size()); + JSONObject deleted=a.read(ka);JSONObject remove=Vault.copy(deleted.getJSONArray("entries").getJSONObject(0)).put("deleted",true);Vault.update(deleted,remove);a.save(deleted,ka);a.sync(ka); + assertEquals(1,Vault.visible(b.sync(kb).data,"",false).size()); + Repository reopened=new Repository(ca);assertTrue(reopened.connected());assertEquals(a.url(),reopened.url()); + // Send while locked, then stage on the other locked client without changing its trusted ciphertext. + JSONObject changed=b.read(kb);JSONObject remaining=Vault.copy(Vault.visible(changed,"",false).get(0)).put("notes","Background encrypted transfer"); + Vault.update(changed,remaining);b.save(changed,kb);kb.close(); + assertTrue(b.backgroundSync(null,()->false).contains("Pi")); + byte[] before=a.encrypted();ka.close(); + assertTrue(a.backgroundSync(null,()->false).contains("загружена"));assertTrue(Arrays.equals(before,a.encrypted())); + java.io.File staged=new java.io.File(ca.getFilesDir(),"vault/pending.qsafe");byte[] corrupt=java.nio.file.Files.readAllBytes(staged.toPath());corrupt[corrupt.length-1]^=1; + Repository.write(new android.util.AtomicFile(staged),corrupt); + try(VaultCrypto.Key renewed=VaultCrypto.unlock(a.encrypted(),pass)) { + try{a.applyPending(renewed);fail("Tampered remote must not replace the local vault");}catch(java.security.GeneralSecurityException expected){} + assertTrue(Arrays.equals(before,a.encrypted())); + a.backgroundSync(null,()->false); + assertEquals("Background encrypted transfer",Vault.visible(a.applyPending(renewed).data,"",false).get(0).getString("notes")); + } + } + }finally{Arrays.fill(pass,'\0');} + } + public void testDHomeWifiArrivalRunsWhileVaultIsLocked() throws Exception { + Context context=getInstrumentation().getTargetContext(); + String path=QSafeTestRunner.arguments.getString("connection");assertNotNull(path); + JSONObject connection;try(InputStream in=new FileInputStream(path)){connection=new JSONObject(new String(Repository.bounded(in,16384),StandardCharsets.UTF_8));} + assertTrue(HomeWifiSync.permissionProblem(context,true),HomeWifiSync.permissionProblem(context,true).isEmpty()); + shell("svc wifi enable");await(()->HomeWifiSync.homeNetwork(context,"AndroidWifi",null,true)!=null,60000); + Repository r=Repository.get(context); + SyncRuntime.IO.submit(()->{ + r.configure(connection.getString("url"),connection.getString("token")); + try(VaultCrypto.Key ignored=r.restorePi("synthetic sync password 123!".toCharArray())){} + r.setHomeWifi(true,"definitely-not-home");HomeWifiSync.install(context,r);return null; + }).get(); + await(()->r.backgroundStatus().contains("Ожидается"),120000); + byte[] before=r.encrypted(); + try { + shell("svc wifi disable");await(()->HomeWifiSync.currentSsid(context).isEmpty(),30000); + SyncRuntime.IO.submit(()->{r.setHomeWifi(true,"AndroidWifi");r.backgroundStatus("QA ждёт домашний Wi-Fi");HomeWifiSync.install(context,r);return null;}).get(); + Thread.sleep(1000);assertTrue(Arrays.equals(before,r.encrypted())); + assertFalse(r.backgroundStatus().contains("Телефон и Pi синхронизированы")); + shell("svc wifi enable"); + await(()->r.backgroundStatus().contains("Телефон и Pi синхронизированы"),180000); + assertTrue(r.exists()); + }finally{shell("svc wifi enable");} + } + private void shell(String command) throws Exception { + try(android.os.ParcelFileDescriptor fd=getInstrumentation().getUiAutomation().executeShellCommand(command); + InputStream in=new android.os.ParcelFileDescriptor.AutoCloseInputStream(fd)){Repository.bounded(in,65536);} + } + public void testCUserInterfaceCreateSearchEditAndLock() throws Exception { + new UiChecks(getInstrumentation()).run(); + } + private VaultCrypto.Key testKey(Repository r) throws Exception {return VaultCrypto.unlock(r.encrypted(),"test master password 123!".toCharArray());} + private interface Condition {boolean ready() throws Exception;} + private void await(Condition c,long timeout) throws Exception {long end=System.currentTimeMillis()+timeout;while(System.currentTimeMillis(){try{value[0]=ready.get();}catch(Exception e){failure[0]=e;}});if(failure[0]!=null)throw failure[0];if(value[0])return;Thread.sleep(100);} + fail("Menu/theme transition timed out: "+screen()); + } + private void tap(int id){main(()->{View v=activity.findViewById(id);assertNotNull("Missing view "+id,v);v.performClick();});} + private View text(View root,String value){if(root instanceof TextView&&((TextView)root).getText().toString().equals(value))return root;if(root instanceof ViewGroup){ViewGroup g=(ViewGroup)root;for(int i=0;i{View v=text(activity.getWindow().getDecorView(),value);assertNotNull(value,v);v.performClick();});} + private void set(int id,String value){main(()->((EditText)activity.findViewById(id)).setText(value));} + private String shell(String command)throws Exception {try(ParcelFileDescriptor fd=instrument.getUiAutomation().executeShellCommand(command);InputStream in=new ParcelFileDescriptor.AutoCloseInputStream(fd)){return new String(Repository.bounded(in,65536),StandardCharsets.UTF_8).trim();}} + private void secure(){assertTrue((activity.getWindow().getAttributes().flags&WindowManager.LayoutParams.FLAG_SECURE)!=0);} + private int background()throws Exception{return ((ColorDrawable)((View)field("frame")).getBackground()).getColor();} + private void shot(String name)throws Exception { + secure();AlertDialog d=dialog();if(d!=null)assertTrue((d.getWindow().getAttributes().flags&WindowManager.LayoutParams.FLAG_SECURE)!=0); + main(()->{activity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_SECURE);if(d!=null)d.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_SECURE);}); + try{Thread.sleep(250);Bitmap bitmap=instrument.getUiAutomation().takeScreenshot();assertNotNull(bitmap);try(FileOutputStream out=new FileOutputStream(new File(context.getExternalFilesDir(null),name+".png"))){bitmap.compress(Bitmap.CompressFormat.PNG,100,out);}bitmap.recycle();} + finally{main(()->{SecureWindows.protect(activity);if(d!=null)SecureWindows.protect(d.getWindow());});} + } + private void launch(String master)throws Exception { + activity=(MainActivity)instrument.startActivitySync(new Intent(context,MainActivity.class).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK));secure(); + set(R.id.master_password,master);tap(R.id.open_vault);await(()->activity.findViewById(R.id.add_entry)!=null); + } + private void editor()throws Exception { + tap(R.id.add_entry);AlertDialog templates=dialog();main(()->((GridLayout)templates.findViewById(R.id.template_grid)).getChildAt(0).performClick());assertEquals("edit",screen()); + set(R.id.entry_title,"Unsaved theme draft");set(3101,"SYNTHETIC_DRAFT_SECRET"); + } + public void testMenuPagesActualTogglesThemesAndDraftSurvival()throws Exception { + assertTrue(android.os.Build.MODEL.contains("sdk_gphone"));context=instrument.getTargetContext();Repository r=Repository.get(context);assertFalse("Fresh synthetic installation required",r.exists()); + String originalAutofill=shell("settings get secure autofill_service"); + assertTrue(originalAutofill.isEmpty()||originalAutofill.matches("[A-Za-z0-9_./]+")); + String originalNight=shell("cmd uimode night"); + AppAppearance.save(context,AppAppearance.SYSTEM);shell("cmd uimode night no"); + r.fingerprintOffered(true);r.setAutoSync(false);r.setHomeWifi(false,""); + String master="Synthetic menu theme master 2026!"; + try(VaultCrypto.Key key=VaultCrypto.create(master.toCharArray())){ + JSONObject data=Vault.empty(),entry=CardTemplates.create("login").put("title","Menu QA synthetic");entry.getJSONArray("fields").getJSONObject(1).put("value","SYNTHETIC_VAULT_SECRET");Vault.update(data,entry);r.save(data,key); + } + String initialVaultHash=VaultCrypto.hash(r.encrypted()); + try { + launch(master);tap(R.id.settings);assertEquals("menu",screen());assertNull(dialog());secure(); + for(String section:new String[]{"Внешний вид","Синхронизация","Защита","Данные","Приложение"})assertNotNull(section,text(activity.getWindow().getDecorView(),section)); + tap(R.id.sync);assertEquals("connection",screen());assertNull(dialog());tap(R.id.navigate_back); + assertFalse(((Switch)activity.findViewById(R.id.auto_sync)).isChecked());tap(R.id.auto_sync);await(()->r.autoSync()&&!(Boolean)field("busy"));assertTrue(((Switch)activity.findViewById(R.id.auto_sync)).isChecked()); + tap(R.id.auto_sync);await(()->!r.autoSync()&&!(Boolean)field("busy")); + tap(R.id.background_sync);assertEquals("connection",screen());assertNull(dialog());assertFalse(r.homeWifiEnabled()); + assertNotNull(activity.findViewById(R.id.connection_url));tap(R.id.navigate_back);assertEquals("menu",screen());assertFalse(((Switch)activity.findViewById(R.id.background_sync)).isChecked()); + tap(R.id.connection_settings);set(R.id.connection_url,"https://example.test/");set(R.id.connection_token,new String(new char[43]).replace('\0','q'));set(R.id.home_ssid,"QSafe QA network");set(R.id.connection_pin,new String(new char[64]).replace('\0','f'));tapText("Сохранить"); + await(()->!(Boolean)field("busy"));assertFalse(r.connected());assertEquals("connection",screen());assertFalse(((TextView)activity.findViewById(R.id.status)).getText().toString().isEmpty()); + set(R.id.connection_url,"https://192.168.254.250:18443/qsafe/");tapText("Сохранить");await(()->screen().equals("menu")&&r.connected()); + tap(R.id.background_sync);assertEquals("wifi",screen());assertNull(dialog());set(R.id.home_ssid,"");tapText("Сохранить");assertEquals("wifi",screen());assertFalse(r.homeWifiEnabled());set(R.id.home_ssid,"QSafe QA updated network");tapText("Сохранить");await(()->screen().equals("menu")&&r.homeWifiEnabled()); + assertTrue(((Switch)activity.findViewById(R.id.background_sync)).isChecked());tap(R.id.background_sync);await(()->!r.homeWifiEnabled()&&!(Boolean)field("busy")); + JobScheduler jobs=context.getSystemService(JobScheduler.class);assertNull(jobs.getPendingJob(HomeWifiSync.ARRIVAL_JOB));assertNull(jobs.getPendingJob(HomeWifiSync.RECOVERY_JOB)); + HomeWifiSync.enqueue(context);assertNull("Disabled background sync must not be queued again",jobs.getPendingJob(HomeWifiSync.ARRIVAL_JOB)); + tap(R.id.connection_settings);assertEquals("",((EditText)activity.findViewById(R.id.connection_token)).getText().toString());set(R.id.home_ssid,"QSafe QA final network");tapText("Сохранить");await(()->screen().equals("menu"));assertTrue(r.connected());assertEquals("QSafe QA final network",r.homeSsid()); + // Reflect the actual Android service state, then disable our service from the switch. + shell("settings put secure autofill_service ru.kusoft.qsafe/.QSafeAutofillService"); + android.view.autofill.AutofillManager manager=context.getSystemService(android.view.autofill.AutofillManager.class);await(manager::hasEnabledAutofillServices); + tap(R.id.navigate_back);tap(R.id.settings);assertTrue(((Switch)activity.findViewById(R.id.autofill_enabled)).isChecked());tap(R.id.autofill_enabled);await(()->!manager.hasEnabledAutofillServices()&&!(Boolean)field("busy"));assertFalse(((Switch)activity.findViewById(R.id.autofill_enabled)).isChecked()); + assertFalse(((Switch)activity.findViewById(R.id.fingerprint)).isChecked()); + if(!((Biometrics)field("biometrics")).available()){tap(R.id.fingerprint);assertEquals("fingerprint_setup",screen());assertNull(dialog());tap(R.id.navigate_back);assertFalse(((Switch)activity.findViewById(R.id.fingerprint)).isChecked());} + Thread.sleep(4000);shot("menu-light-top"); + tap(R.id.theme_settings);assertEquals("theme",screen());assertNull(dialog());tap(R.id.theme_dark);assertEquals(AppAppearance.DARK,AppAppearance.mode(context));assertEquals(Color.parseColor("#101817"),background());shot("theme-dark"); + tap(R.id.navigate_back);shot("menu-dark-top");ScrollView scroll=(ScrollView)field("pageScroll"); + int[] headerBefore=new int[2],headerAfter=new int[2];main(()->activity.findViewById(R.id.page_title).getLocationOnScreen(headerBefore)); + main(()->scroll.scrollTo(0,scroll.getChildAt(0).getHeight()));main(()->activity.findViewById(R.id.page_title).getLocationOnScreen(headerAfter));assertEquals(headerBefore[1],headerAfter[1]);shot("menu-dark-bottom"); + tap(R.id.connection_settings);assertEquals(Color.parseColor("#101817"),background());shot("connection-dark");tap(R.id.navigate_back); + tap(R.id.backup_settings);assertEquals("backups",screen());assertNull(dialog());assertNotNull(activity.findViewById(R.id.export_backup));tap(R.id.navigate_back); + tap(R.id.import_settings);assertEquals("import",screen());assertNull(dialog());tap(R.id.navigate_back);tap(R.id.navigate_back); + editor();tapText("Сгенерировать пароль");shot("generator-dark");AlertDialog generator=dialog();main(()->generator.getButton(AlertDialog.BUTTON_NEGATIVE).performClick()); + tap(R.id.navigate_back);AlertDialog discard=dialog();assertNotNull(discard);main(()->discard.getButton(AlertDialog.BUTTON_POSITIVE).performClick()); + main(activity::finish);launch(master);assertEquals(AppAppearance.DARK,AppAppearance.mode(context));assertEquals(Color.parseColor("#101817"),background()); + tap(R.id.settings);tap(R.id.theme_settings);tap(R.id.theme_light);assertEquals(Color.parseColor("#F5F8F7"),background());shot("theme-light"); + shell("cmd uimode night yes");instrument.waitForIdleSync();assertEquals(Color.parseColor("#F5F8F7"),background());shot("theme-light-system-dark"); + Bitmap lightCapture=android.graphics.BitmapFactory.decodeFile(new File(context.getExternalFilesDir(null),"theme-light-system-dark.png").getAbsolutePath()); + assertEquals("Explicit light theme must stay light in the rendered screen",Color.parseColor("#F5F8F7"),lightCapture.getPixel(10,lightCapture.getHeight()/2));lightCapture.recycle(); + tap(R.id.theme_system);await(()->background()==Color.parseColor("#101817"));assertFalse(activity.isDestroyed());assertNotNull(field("session")); + shell("cmd uimode night no");await(()->background()==Color.parseColor("#F5F8F7"));tap(R.id.navigate_back);tap(R.id.navigate_back);editor(); + shell("cmd uimode night yes");await(()->background()==Color.parseColor("#101817"));assertEquals("edit",screen());assertEquals("Unsaved theme draft",((EditText)activity.findViewById(R.id.entry_title)).getText().toString());assertEquals("SYNTHETIC_DRAFT_SECRET",((EditText)activity.findViewById(3101)).getText().toString());secure();shot("editor-system-dark"); + tap(R.id.navigate_back);AlertDialog finalDiscard=dialog();assertNotNull("Unsaved-change warning must survive a theme change",finalDiscard);main(()->finalDiscard.getButton(AlertDialog.BUTTON_POSITIVE).performClick()); + assertEquals(initialVaultHash,VaultCrypto.hash(r.encrypted())); + Repository reopened=new Repository(context);assertFalse(reopened.autoSync());assertFalse(reopened.homeWifiEnabled());assertEquals("QSafe QA final network",reopened.homeSsid()); + File copy=new File(context.getCacheDir(),"menu-theme-restore.qsafe");try(FileOutputStream out=new FileOutputStream(copy)){out.write(r.encrypted());} + main(()->activity.onActivityResult(44,Activity.RESULT_OK,new Intent().setData(android.net.Uri.fromFile(copy)))); + assertEquals("restore",screen());assertNull(dialog());set(R.id.restore_password,"incorrect synthetic master");tapText("Восстановить");await(()->!(Boolean)field("busy"));assertEquals("restore",screen());assertEquals(initialVaultHash,VaultCrypto.hash(r.encrypted())); + set(R.id.restore_password,master);tapText("Восстановить");await(()->screen().equals("locked"));assertNull(field("session"));assertNotNull(activity.findViewById(R.id.master_password));assertEquals(initialVaultHash,VaultCrypto.hash(r.encrypted())); + }finally { + if(activity!=null)main(activity::finish); + r.setHomeWifi(false,r.homeSsid());HomeWifiSync.install(context,r); + if(originalAutofill.equals("null")||originalAutofill.isEmpty())shell("settings delete secure autofill_service");else shell("settings put secure autofill_service "+originalAutofill); + shell("cmd uimode night "+(originalNight.contains("yes")?"yes":originalNight.contains("auto")?"auto":"no")); + } + } +} diff --git a/app/src/androidTest/java/ru/kusoft/qsafe/OnboardingRestoreTests.java b/app/src/androidTest/java/ru/kusoft/qsafe/OnboardingRestoreTests.java new file mode 100644 index 0000000..7084be3 --- /dev/null +++ b/app/src/androidTest/java/ru/kusoft/qsafe/OnboardingRestoreTests.java @@ -0,0 +1,34 @@ +package ru.kusoft.qsafe; + +import android.app.*; +import android.content.*; +import android.view.View; +import android.widget.EditText; +import junit.framework.TestCase; +import org.json.JSONObject; +import java.io.*; +import java.lang.reflect.Field; +import java.nio.charset.StandardCharsets; + +public class OnboardingRestoreTests extends TestCase { + private final Instrumentation instrument=androidx.test.platform.app.InstrumentationRegistry.getInstrumentation(); + private void main(Runnable work){instrument.runOnMainSync(work);instrument.waitForIdleSync();} + private void await(java.util.concurrent.Callable condition)throws Exception{long end=System.currentTimeMillis()+25000;while(System.currentTimeMillis(){((EditText)first.findViewById(R.id.master_password)).setText("wrong synthetic master");first.findViewById(R.id.open_vault).performClick();}); + Field busy=MainActivity.class.getDeclaredField("busy");busy.setAccessible(true);await(()->!(Boolean)busy.get(first));assertFalse(isolated.exists());assertNotNull(first.findViewById(R.id.connect_pi)); + main(()->{((EditText)first.findViewById(R.id.master_password)).setText("synthetic sync password 123!");first.findViewById(R.id.connect_pi).performClick();});await(()->first.findViewById(R.id.add_entry)!=null);assertTrue(isolated.exists()); + try(VaultCrypto.Key key=VaultCrypto.unlock(isolated.encrypted(),"synthetic sync password 123!".toCharArray())){assertTrue(Vault.visible(isolated.read(key),"",false).size()>0);} + main(first::finish);a=(MainActivity)instrument.startActivitySync(launch);assertNull(a.findViewById(R.id.connect_pi));assertNotNull(a.findViewById(R.id.open_vault)); + }finally{if(a!=null){MainActivity last=a;main(last::finish);}singleton.set(null,previous);} + } +} diff --git a/app/src/androidTest/java/ru/kusoft/qsafe/PasswordGeneratorDeviceTests.java b/app/src/androidTest/java/ru/kusoft/qsafe/PasswordGeneratorDeviceTests.java new file mode 100644 index 0000000..33a4b5e --- /dev/null +++ b/app/src/androidTest/java/ru/kusoft/qsafe/PasswordGeneratorDeviceTests.java @@ -0,0 +1,101 @@ +package ru.kusoft.qsafe; + +import android.app.*; +import android.content.*; +import android.graphics.Bitmap; +import android.view.*; +import android.widget.*; +import junit.framework.TestCase; +import java.io.*; +import java.lang.reflect.Field; +import org.json.*; + +/** Dedicated fresh emulator; the user's database and connection are never opened. */ +@SuppressWarnings("deprecation") +public class PasswordGeneratorDeviceTests extends TestCase { + private final Instrumentation instrument=androidx.test.platform.app.InstrumentationRegistry.getInstrumentation(); + private MainActivity activity; + private void main(Runnable work){instrument.runOnMainSync(work);instrument.waitForIdleSync();} + private AlertDialog dialog()throws Exception {Field f=MainActivity.class.getDeclaredField("dialog");f.setAccessible(true);return (AlertDialog)f.get(activity);} + private View text(View root,String value) { + if(root instanceof TextView&&((TextView)root).getText().toString().equals(value))return root; + if(root instanceof ViewGroup){ViewGroup group=(ViewGroup)root;for(int i=0;i{activity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_SECURE);d.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_SECURE);}); + try{Thread.sleep(200);Bitmap bitmap=instrument.getUiAutomation().takeScreenshot();assertNotNull(bitmap); + try(FileOutputStream out=new FileOutputStream(new File(instrument.getTargetContext().getExternalFilesDir(null),name+".png"))){bitmap.compress(Bitmap.CompressFormat.PNG,100,out);}bitmap.recycle(); + }finally{main(()->{SecureWindows.protect(activity);SecureWindows.protect(d.getWindow());});} + } + public void testVisiblePreviewModesCancelApplyAndPersistence()throws Exception { + assertTrue("Only a dedicated emulator may run this test",android.os.Build.MODEL.contains("sdk_gphone")); + Context context=instrument.getTargetContext();Repository repository=Repository.get(context); + assertFalse("Fresh synthetic installation required",repository.exists());repository.fingerprintOffered(true); + String master="Generator synthetic master 2026!"; + try(VaultCrypto.Key key=VaultCrypto.create(master.toCharArray())){repository.save(Vault.empty(),key);} + activity=(MainActivity)instrument.startActivitySync(new Intent(context,MainActivity.class).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)); + try { + main(()->{((EditText)activity.findViewById(R.id.master_password)).setText(master);activity.findViewById(R.id.open_vault).performClick();}); + long deadline=System.currentTimeMillis()+20000; + while(activity.findViewById(R.id.add_entry)==null&&System.currentTimeMillis()activity.findViewById(R.id.add_entry).performClick()); + AlertDialog templates=dialog();main(()->((GridLayout)templates.findViewById(R.id.template_grid)).getChildAt(0).performClick()); + EditText target=activity.findViewById(3101);assertNotNull(target); + main(()->{((EditText)activity.findViewById(R.id.entry_title)).setText("Generator QA");target.setText("ORIGINAL_SYNTHETIC_SECRET");}); + tap("Сгенерировать пароль");AlertDialog canceled=dialog();secure(canceled); + TextView oldPreview=canceled.findViewById(R.id.generator_preview); + assertEquals(24,preview(canceled).length());assertNull(oldPreview.getTransformationMethod()); + assertEquals("ORIGINAL_SYNTHETIC_SECRET",target.getText().toString()); + main(()->canceled.getButton(AlertDialog.BUTTON_NEGATIVE).performClick()); + assertEquals("ORIGINAL_SYNTHETIC_SECRET",target.getText().toString());assertEquals("",oldPreview.getText().toString()); + tap("Сгенерировать пароль");AlertDialog generator=dialog();secure(generator); + SeekBar length=generator.findViewById(R.id.generator_length); + main(()->length.setProgress(32));assertEquals(32,preview(generator).length()); + main(()->((CheckBox)generator.findViewById(R.id.generator_symbols)).setChecked(false));assertTrue(preview(generator).matches("[A-Za-z2-9]{32}")); + main(()->((CheckBox)generator.findViewById(R.id.generator_symbols)).setChecked(true));shot("generator-complex"); + main(()->generator.findViewById(R.id.generator_digits).performClick());main(()->length.setProgress(4)); + assertTrue(preview(generator).matches("[0-9]{4}"));shot("generator-digits"); + main(()->generator.findViewById(R.id.generator_memorable).performClick());main(()->length.setProgress(12)); + assertEquals(12,countWords(preview(generator)));shot("generator-memorable"); + assertEquals(2,length.getMin());main(()->length.setProgress(2)); + CheckBox symbols=generator.findViewById(R.id.generator_symbols),digits=generator.findViewById(R.id.generator_add_digits); + assertEquals(View.VISIBLE,symbols.getVisibility());assertEquals(View.VISIBLE,digits.getVisibility()); + assertWholeWords(preview(generator),2);assertEquals(2,preview(generator).replaceAll("[^0-9]","").length());shot("generator-two-words"); + main(()->digits.setChecked(false));assertFalse(preview(generator).matches(".*[0-9].*")); + main(()->symbols.setChecked(false));assertTrue(preview(generator).matches("[A-Z][a-z]+[A-Z][a-z]+")); + main(()->{digits.setChecked(true);symbols.setChecked(true);}); + main(()->generator.findViewById(R.id.generator_complex).performClick());assertEquals(16,length.getMin());assertEquals(32,length.getProgress());assertEquals(32,preview(generator).length()); + main(()->generator.findViewById(R.id.generator_digits).performClick());assertEquals(4,length.getProgress()); + main(()->generator.findViewById(R.id.generator_memorable).performClick());assertEquals(2,length.getProgress()); + main(()->generator.findViewById(R.id.generator_regenerate).performClick()); + String selected=preview(generator);assertWholeWords(selected,2);assertEquals(2,selected.replaceAll("[^0-9]","").length()); + assertEquals("ORIGINAL_SYNTHETIC_SECRET",target.getText().toString()); + main(()->generator.getButton(AlertDialog.BUTTON_POSITIVE).performClick()); + assertEquals(selected,target.getText().toString());assertNotNull(target.getTransformationMethod());assertEquals("",preview(generator)); + tap("Сохранить");deadline=System.currentTimeMillis()+20000; + while(text(activity.getWindow().getDecorView(),"Изменить")==null&&System.currentTimeMillis()activity.moveTaskToBack(true));deadline=System.currentTimeMillis()+10000; + while(locked.isShowing()&&System.currentTimeMillis(){((EditText)activity.findViewById(R.id.master_password)).setText("synthetic screenshot password!");activity.findViewById(R.id.open_vault).performClick();}); + long end=System.currentTimeMillis()+20000;while(System.currentTimeMillis()((View)card.getParent().getParent()).performClick());secure(activity.getWindow()); + View remove=text(activity.getWindow().getDecorView(),"Удалить карточку");assertNotNull(remove);main(remove::performClick);secure(dialog().getWindow());main(activity::finish); + Intent autofill=new Intent(c,AutofillActivity.class).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK).putExtra("domain","example.test").putExtra("targetPackage","example.browser");activity=(MainActivity)instrument.startActivitySync(autofill);secure(activity.getWindow());open(); + View choice=text(activity.getWindow().getDecorView(),"Security synthetic\nsecurity@example.test");assertNotNull(choice);main(choice::performClick);secure(dialog().getWindow()); + main(()->activity.moveTaskToBack(true));long end=System.currentTimeMillis()+10000; + while(dialog()!=null&&System.currentTimeMillis(){activity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_SECURE);if(d!=null&&d.isShowing())d.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_SECURE);}); + try{Thread.sleep(150);Bitmap bitmap=instrument.getUiAutomation().takeScreenshot();assertNotNull(bitmap);try(FileOutputStream out=new FileOutputStream(new File(context.getExternalFilesDir(null),name+".png"))){bitmap.compress(Bitmap.CompressFormat.PNG,100,out);}bitmap.recycle();} + finally{main(()->{SecureWindows.protect(activity);if(d!=null&&d.isShowing())SecureWindows.protect(d.getWindow());});} + } + void run()throws Exception { + assertTrue("Synthetic acceptance requires an emulator",android.os.Build.MODEL.contains("sdk_gphone")); + Repository r=Repository.get(context);assertFalse("Fresh test installation required",r.exists());r.fingerprintOffered(true); + Intent launch=new Intent(context,MainActivity.class).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + activity=(MainActivity)instrument.startActivitySync(launch);secure(activity.getWindow());assertNotNull(activity.findViewById(R.id.connect_pi));shot("welcome-1.0.0"); + main(()->activity.findViewById(R.id.connect_pi).performClick());secure(dialog().getWindow());tapDialog("Создать новое хранилище"); + AlertDialog create=dialog();secure(create.getWindow());main(()->{((EditText)create.findViewById(R.id.master_password)).setText("test master password 123!");((EditText)create.findViewById(R.id.confirm_password)).setText("test master password 123!");create.getButton(AlertDialog.BUTTON_POSITIVE).performClick();}); + await(()->activity.findViewById(R.id.add_entry)!=null);assertNull(activity.findViewById(R.id.sync)); + main(()->activity.findViewById(R.id.add_entry).performClick());AlertDialog types=dialog();secure(types.getWindow());assertEquals(8,((GridLayout)types.findViewById(R.id.template_grid)).getChildCount());shot("templates-1.0.0"); + main(()->((GridLayout)types.findViewById(R.id.template_grid)).getChildAt(0).performClick());assertNull(dialog());assertEquals("edit",field("screen"));secure(activity.getWindow()); + main(()->{((EditText)activity.findViewById(R.id.entry_title)).setText("Почта · пример");((EditText)activity.findViewById(3100)).setText("demo@example.test");((EditText)activity.findViewById(3101)).setText("SYNTHETIC_UI_SECRET");((EditText)activity.findViewById(3102)).setText("https://example.test");((EditText)activity.findViewById(R.id.entry_tags)).setText("Работа, Личное");}); + shot("editor-1.0.0");tapText("Сохранить");await(()->field("screen").equals("detail"));assertNull(dialog());secure(activity.getWindow()); + assertNull(text(activity.getWindow().getDecorView(),"Копировать"));assertNull(text(activity.getWindow().getDecorView(),"SYNTHETIC_UI_SECRET")); + View password=description(activity.getWindow().getDecorView(),"Скопировать Пароль");assertNotNull(password);main(password::performClick); + main(()->{ClipboardManager clipboard=context.getSystemService(ClipboardManager.class);assertEquals("SYNTHETIC_UI_SECRET",clipboard.getPrimaryClip().getItemAt(0).getText().toString());assertTrue(clipboard.getPrimaryClipDescription().getExtras().getBoolean("android.content.extra.IS_SENSITIVE"));clipboard.clearPrimaryClip();}); + shot("detail-1.0.0");View eye=description(activity.getWindow().getDecorView(),"Показать Пароль");main(eye::performClick);assertNotNull(text(activity.getWindow().getDecorView(),"SYNTHETIC_UI_SECRET"));main(eye::performClick); + tapText("Изменить");main(()->((EditText)activity.findViewById(R.id.entry_title)).setText("Unsaved synthetic edit"));main(activity::onBackPressed);secure(dialog().getWindow());assertNotNull(text(dialog().getWindow().getDecorView(),"Выйти без сохранения?"));main(()->{try{dialog().getButton(AlertDialog.BUTTON_POSITIVE).performClick();}catch(Exception e){throw new RuntimeException(e);}});assertEquals("detail",field("screen")); + main(activity::onBackPressed);assertEquals("home",field("screen")); + try(VaultCrypto.Key key=VaultCrypto.unlock(r.encrypted(),"test master password 123!".toCharArray())) { + JSONObject data=r.read(key);for(int n=0;n<30;n++){JSONObject card=CardTemplates.create(CardTemplates.KINDS[n%8]).put("title",new String[]{"Почта","Банк","Домашняя сеть","Облачный сервер","Личные заметки"}[n%5]+" · "+(n+1));Vault.update(data,card);}r.save(data,key); + } + main(activity::finish);activity=(MainActivity)instrument.startActivitySync(launch);assertNull(activity.findViewById(R.id.connect_pi));assertNull(activity.findViewById(R.id.confirm_password)); + assertEquals(1,countButtons(activity.getWindow().getDecorView()));shot("locked-1.0.0"); + main(()->{((EditText)activity.findViewById(R.id.master_password)).setText("test master password 123!");activity.findViewById(R.id.open_vault).performClick();});await(()->activity.findViewById(R.id.add_entry)!=null); + View plus=activity.findViewById(R.id.add_entry);int[] before=new int[2],after=new int[2];main(()->plus.getLocationOnScreen(before));shot("cards-1.0.0"); + main(()->((ScrollView)activity.findViewById(R.id.card_scroller)).fullScroll(View.FOCUS_DOWN));main(()->plus.getLocationOnScreen(after));assertEquals(before[0],after[0]);assertEquals(before[1],after[1]);assertTrue(plus.isShown());shot("cards-scrolled-1.0.0"); + main(()->((EditText)activity.findViewById(R.id.search)).setText("нет совпадений"));assertEquals("Карточек: 0",((TextView)activity.findViewById(R.id.card_counter)).getText().toString()); + main(()->((EditText)activity.findViewById(R.id.search)).setText(""));assertEquals("Карточек: 31",((TextView)activity.findViewById(R.id.card_counter)).getText().toString()); + main(()->activity.findViewById(R.id.settings).performClick());assertNull(dialog());assertEquals("menu",field("screen"));secure(activity.getWindow());assertNotNull(activity.findViewById(R.id.fingerprint));assertNotNull(activity.findViewById(R.id.check_updates)); + main(activity::finish);activity=(MainActivity)instrument.startActivitySync(launch);assertNull(activity.findViewById(R.id.connect_pi));secure(activity.getWindow());main(activity::finish); + } + private int countButtons(View view){int count=view instanceof Button?1:0;if(view instanceof ViewGroup){ViewGroup g=(ViewGroup)view;for(int n=0;n=2);assertTrue(release.size>0);assertTrue(release.downloadPath.startsWith("/api/apps/qsafe/")); + } +} diff --git a/app/src/androidTest/java/ru/kusoft/qsafe/UpdateInstallerTests.java b/app/src/androidTest/java/ru/kusoft/qsafe/UpdateInstallerTests.java new file mode 100644 index 0000000..6a4b359 --- /dev/null +++ b/app/src/androidTest/java/ru/kusoft/qsafe/UpdateInstallerTests.java @@ -0,0 +1,29 @@ +package ru.kusoft.qsafe; + +import android.app.*; +import android.content.*; +import junit.framework.TestCase; +import org.json.*; +import java.io.File; +import java.lang.reflect.*; +import java.nio.file.Files; + +/** The harness then confirms Android's installer and checks the installed package version. */ +public class UpdateInstallerTests extends TestCase { + public void testPrepareVerifiedUpdateAndProtectedPermissionDialog()throws Exception { + assertTrue(android.os.Build.MODEL.contains("sdk_gphone"));Instrumentation i=androidx.test.platform.app.InstrumentationRegistry.getInstrumentation();Context c=i.getTargetContext(); + File source=new File(QSafeTestRunner.arguments.getString("updateApk")); + UpdateManifest release=UpdateManifest.parse(new JSONObject().put("slug","qsafe").put("release",new JSONObject().put("version","1.0.1").put("androidVersionCode",4).put("platform","android").put("channel","stable").put("packageKind","apk").put("packageSizeBytes",source.length()).put("sha256",VaultCrypto.hash(Files.readAllBytes(source.toPath()))).put("downloadPath","/api/apps/qsafe/releases/3d149d9b-16fb-4935-8537-b447fce96aeb/download"))); + AppUpdates.verify(c,source,release);File target=AppUpdates.apk(c);target.getParentFile().mkdirs();Files.copy(source.toPath(),target.toPath(),java.nio.file.StandardCopyOption.REPLACE_EXISTING); + MainActivity activity=(MainActivity)i.startActivitySync(new Intent(c,MainActivity.class).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)); + Field f=MainActivity.class.getDeclaredField("downloadedUpdate");f.setAccessible(true);f.set(activity,release);Method method=MainActivity.class.getDeclaredMethod("requestUpdateInstall");method.setAccessible(true); + i.runOnMainSync(()->{try{ + Field automatic=MainActivity.class.getDeclaredField("automaticFingerprintRequested");automatic.setAccessible(true);automatic.set(activity,true); + Field biometric=MainActivity.class.getDeclaredField("biometrics");biometric.setAccessible(true);((Biometrics)biometric.get(activity)).cancel(); + method.invoke(activity); + }catch(Exception e){throw new RuntimeException(e);}}); + assertFalse("Harness must exercise the unknown-sources permission flow",c.getPackageManager().canRequestPackageInstalls()); + Field dialog=MainActivity.class.getDeclaredField("dialog");dialog.setAccessible(true);android.app.Dialog permission=(android.app.Dialog)dialog.get(activity);assertNotNull(permission);assertTrue(permission.isShowing()); + assertTrue((permission.getWindow().getAttributes().flags&android.view.WindowManager.LayoutParams.FLAG_SECURE)!=0);android.util.Log.i("QSafeQA","INSTALLER_PERMISSION_READY"); + } +} diff --git a/app/src/androidTest/java/ru/kusoft/qsafe/UpgradeTests.java b/app/src/androidTest/java/ru/kusoft/qsafe/UpgradeTests.java new file mode 100644 index 0000000..a748d37 --- /dev/null +++ b/app/src/androidTest/java/ru/kusoft/qsafe/UpgradeTests.java @@ -0,0 +1,12 @@ +package ru.kusoft.qsafe; +import junit.framework.TestCase; +public final class UpgradeTests extends TestCase { + public void testLegacyVaultRemainsReadableAfterUpdate() throws Exception { + android.content.Context c=androidx.test.platform.app.InstrumentationRegistry.getInstrumentation().getTargetContext(); + Repository r=Repository.get(c);assertTrue(r.exists());byte[] before=r.encrypted(); + try(VaultCrypto.Key k=VaultCrypto.unlock(before,"test master password 123!".toCharArray())) { + assertEquals("Почта · пример",Vault.visible(r.read(k),"",false).get(0).getString("title")); + } + assertTrue(java.util.Arrays.equals(before,r.encrypted())); + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..42bc34d --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/ru/kusoft/qsafe/AppAppearance.java b/app/src/main/java/ru/kusoft/qsafe/AppAppearance.java new file mode 100644 index 0000000..3075cec --- /dev/null +++ b/app/src/main/java/ru/kusoft/qsafe/AppAppearance.java @@ -0,0 +1,38 @@ +package ru.kusoft.qsafe; + +import android.app.Activity; +import android.content.Context; +import android.content.res.Configuration; +import android.graphics.Color; +import android.view.View; +import android.view.Window; + +/** Device-local appearance preference; contains no vault data. */ +public final class AppAppearance { + public static final String SYSTEM="system",LIGHT="light",DARK="dark"; + private AppAppearance() { } + public static String mode(Context context) { + String value=context.getSharedPreferences("appearance",Context.MODE_PRIVATE).getString("theme",SYSTEM); + return LIGHT.equals(value)||DARK.equals(value)?value:SYSTEM; + } + public static void save(Context context,String value) { + if(!SYSTEM.equals(value)&&!LIGHT.equals(value)&&!DARK.equals(value))throw new IllegalArgumentException("Unknown theme"); + context.getSharedPreferences("appearance",Context.MODE_PRIVATE).edit().putString("theme",value).apply(); + } + public static boolean dark(Context context) { + String mode=mode(context); + return DARK.equals(mode)||(SYSTEM.equals(mode)&&(context.getResources().getConfiguration().uiMode&Configuration.UI_MODE_NIGHT_MASK)==Configuration.UI_MODE_NIGHT_YES); + } + public static void applyStyle(Activity activity) {activity.setTheme(dark(activity)?R.style.AppTheme_Dark:R.style.AppTheme);} + public static void disableForceDark(Window window) { + if(android.os.Build.VERSION.SDK_INT>=29&&window!=null)window.getDecorView().setForceDarkAllowed(false); + } + @SuppressWarnings("deprecation") public static void applyWindow(Activity activity) { + boolean dark=dark(activity);int background=Color.parseColor(dark?"#101817":"#F5F8F7"); + activity.getWindow().setStatusBarColor(background);activity.getWindow().setNavigationBarColor(background); + View decor=activity.getWindow().getDecorView();int flags=decor.getSystemUiVisibility(); + disableForceDark(activity.getWindow()); + int lightBars=View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR|View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR; + decor.setSystemUiVisibility(dark?flags&~lightBars:flags|lightBars); + } +} diff --git a/app/src/main/java/ru/kusoft/qsafe/AppUpdates.java b/app/src/main/java/ru/kusoft/qsafe/AppUpdates.java new file mode 100644 index 0000000..518c335 --- /dev/null +++ b/app/src/main/java/ru/kusoft/qsafe/AppUpdates.java @@ -0,0 +1,72 @@ +package ru.kusoft.qsafe; + +import android.content.*; +import android.content.pm.*; +import android.net.Uri; +import org.json.*; +import java.io.*; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.*; +import javax.net.ssl.HttpsURLConnection; + +public final class AppUpdates { + private AppUpdates() { } + public static File apk(Context context){return new File(context.getCacheDir(),"updates/update.apk");} + private static HttpsURLConnection connection(String path)throws IOException { + HttpsURLConnection c=(HttpsURLConnection)new URL(UpdateManifest.ORIGIN+path).openConnection(); + c.setInstanceFollowRedirects(false);c.setConnectTimeout(15000);c.setReadTimeout(30000);c.setRequestProperty("Accept","application/json, application/vnd.android.package-archive"); + return c; + } + public static UpdateManifest check()throws Exception { + HttpsURLConnection c=connection("/api/apps/qsafe/manifest?platform=android&channel=stable"); + try { + if(c.getResponseCode()!=200)throw new IOException("Каталог обновлений недоступен. Повторите позже."); + byte[] bytes=Repository.bounded(c.getInputStream(),128*1024); + return UpdateManifest.parse(new JSONObject(new String(bytes,StandardCharsets.UTF_8))); + }finally{c.disconnect();} + } + public static File download(Context context,UpdateManifest release)throws Exception { + if(!release.newerThan(context.getPackageManager().getPackageInfo(context.getPackageName(),0).getLongVersionCode()))throw new IOException("Эта версия уже установлена"); + File target=apk(context),directory=target.getParentFile(); + if(!directory.isDirectory()&&!directory.mkdirs())throw new IOException("Не удалось подготовить загрузку"); + File part=new File(directory,"update.part.apk");HttpsURLConnection c=connection(release.downloadPath); + try { + if(c.getResponseCode()!=200)throw new IOException("Не удалось скачать обновление"); + long announced=c.getContentLengthLong();if(announced!=-1&&announced!=release.size)throw new IOException("Размер обновления не совпадает с каталогом"); + try(InputStream in=c.getInputStream();FileOutputStream out=new FileOutputStream(part)) { + byte[] buffer=new byte[32768];long total=0;int read; + while((read=in.read(buffer))!=-1) {total+=read;if(total>release.size)throw new IOException("Превышен размер обновления");out.write(buffer,0,read);} + if(total!=release.size)throw new IOException("Обновление загружено не полностью");out.getFD().sync(); + } + verify(context,part,release); + java.nio.file.Files.move(part.toPath(),target.toPath(),java.nio.file.StandardCopyOption.REPLACE_EXISTING); + return target; + }finally{c.disconnect();java.nio.file.Files.deleteIfExists(part.toPath());} + } + public static void verify(Context context,File file,UpdateManifest release)throws Exception { + if(file.length()!=release.size)throw new IOException("Изменился размер APK"); + MessageDigest sha=MessageDigest.getInstance("SHA-256"); + try(InputStream in=new FileInputStream(file)){byte[] buffer=new byte[32768];int n;while((n=in.read(buffer))!=-1)sha.update(buffer,0,n);} + StringBuilder hash=new StringBuilder();for(byte b:sha.digest())hash.append(String.format(Locale.ROOT,"%02x",b&255)); + if(!hash.toString().equals(release.sha256))throw new IOException("Контрольная сумма APK не совпадает. Установка отменена."); + PackageManager pm=context.getPackageManager();PackageInfo installed=pm.getPackageInfo(context.getPackageName(),PackageManager.GET_SIGNING_CERTIFICATES); + PackageInfo candidate=pm.getPackageArchiveInfo(file.getAbsolutePath(),PackageManager.GET_SIGNING_CERTIFICATES); + verifyIdentity(installed,candidate,release); + } + static void verifyIdentity(PackageInfo installed,PackageInfo candidate,UpdateManifest release)throws IOException { + if(candidate==null||!installed.packageName.equals(candidate.packageName)||candidate.getLongVersionCode()!=release.versionCode|| + !release.newerThan(installed.getLongVersionCode())||!release.version.equals(candidate.versionName))throw new IOException("APK не является новой версией QSafe"); + if(installed.signingInfo==null||candidate.signingInfo==null||!signers(installed).equals(signers(candidate)))throw new IOException("Подпись APK отличается от установленного QSafe. Установка отменена."); + } + private static Set signers(PackageInfo info)throws IOException { + Signature[] signatures=info.signingInfo.getApkContentsSigners();if(signatures==null||signatures.length==0)throw new IOException("APK не подписан"); + Set result=new HashSet<>();for(Signature s:signatures)result.add(Base64.getEncoder().encodeToString(s.toByteArray()));return result; + } + public static Intent installer(Context context) { + Uri uri=Uri.parse("content://"+context.getPackageName()+".updates/update.apk"); + Intent intent=new Intent(Intent.ACTION_VIEW).setDataAndType(uri,"application/vnd.android.package-archive").addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + intent.setClipData(ClipData.newRawUri("QSafe update",uri));return intent; + } +} diff --git a/app/src/main/java/ru/kusoft/qsafe/AutofillActivity.java b/app/src/main/java/ru/kusoft/qsafe/AutofillActivity.java new file mode 100644 index 0000000..e6847cc --- /dev/null +++ b/app/src/main/java/ru/kusoft/qsafe/AutofillActivity.java @@ -0,0 +1,65 @@ +package ru.kusoft.qsafe; + +import android.app.AlertDialog; +import android.content.Intent; +import android.graphics.Color; +import android.os.Bundle; +import android.service.autofill.Dataset; +import android.service.autofill.FillResponse; +import android.view.*; +import android.view.autofill.*; +import android.widget.*; +import org.json.*; +import java.net.URI; +import java.util.*; + +public final class AutofillActivity extends MainActivity { + @Override protected boolean isAutofill() {return true;} + @Override public void onCreate(Bundle saved) {setResult(RESULT_CANCELED);super.onCreate(saved);} + @Override protected void showAutofill() { + JSONObject data=unlockedData();if(data==null)return; + LinearLayout layout=new LinearLayout(this);layout.setOrientation(LinearLayout.VERTICAL);layout.setPadding(32,72,32,32);layout.setBackgroundColor(BG); + layout.setOnApplyWindowInsetsListener((v,i)->{v.setPadding(32,i.getSystemWindowInsetTop()+32,32,i.getSystemWindowInsetBottom()+16);return i;}); + TextView title=new TextView(this);title.setText("QSafe · выберите карточку");title.setTextSize(24);title.setTextColor(INK);layout.addView(title); + String domain=getIntent().getStringExtra("domain"),target=getIntent().getStringExtra("targetPackage"); + if(domain==null)domain="";if(target==null)target="Неизвестное приложение"; + TextView origin=new TextView(this);String destination=domain.isEmpty()?"Приложение: "+target:"Сайт: "+domain; + destination+="\nПриложение: "+target; + origin.setText(destination);origin.setTextSize(16);origin.setTextColor(MUTED);origin.setPadding(0,16,0,24);layout.addView(origin); + ScrollView scroll=new ScrollView(this);LinearLayout entries=new LinearLayout(this);entries.setOrientation(LinearLayout.VERTICAL);scroll.addView(entries);layout.addView(scroll,new LinearLayout.LayoutParams(-1,0,1)); + try { + int count=0; + for(JSONObject entry:Vault.visible(data,"",false)) { + String login="",password="";boolean matches=domain.isEmpty();JSONArray fields=entry.getJSONArray("fields"); + for(int i=0;ishowProtectedDialog(new AlertDialog.Builder(this).setTitle("Заполнить пароль?") + .setMessage(confirmedDestination+"\nКарточка: "+entry.optString("title")) + .setNegativeButton("Отмена",null).setPositiveButton("Заполнить",(d,w)->fill(entry.optString("title"),selectedLogin,selectedPassword)).create())); + } + if(count==0) {TextView empty=new TextView(this);empty.setTextColor(INK);empty.setText("Нет подходящих карточек. Для сайта адрес в карточке должен совпадать с доменом.");entries.addView(empty);} + }catch(JSONException e){finish();return;} + Button cancel=new Button(this);cancel.setText("Отмена");cancel.setOnClickListener(v->finish());layout.addView(cancel);setContentView(layout);layout.requestApplyInsets(); + } + @SuppressWarnings("deprecation") private void fill(String name,String login,String password) { + ArrayList logins=getIntent().getParcelableArrayListExtra("logins"),passwords=getIntent().getParcelableArrayListExtra("passwords"); + if(passwords==null||passwords.isEmpty()) {finish();return;} + RemoteViews view=new RemoteViews(getPackageName(),android.R.layout.simple_list_item_1);view.setTextViewText(android.R.id.text1,name); + Dataset.Builder dataset=new Dataset.Builder(view); + if(logins!=null)for(AutofillId id:logins)dataset.setValue(id,AutofillValue.forText(login)); + for(AutofillId id:passwords)dataset.setValue(id,AutofillValue.forText(password)); + setResult(RESULT_OK,new Intent().putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT,new FillResponse.Builder().addDataset(dataset.build()).build()));finish(); + } +} diff --git a/app/src/main/java/ru/kusoft/qsafe/Biometrics.java b/app/src/main/java/ru/kusoft/qsafe/Biometrics.java new file mode 100644 index 0000000..ea6c6dc --- /dev/null +++ b/app/src/main/java/ru/kusoft/qsafe/Biometrics.java @@ -0,0 +1,89 @@ +package ru.kusoft.qsafe; + +import android.app.Activity; +import android.hardware.biometrics.BiometricPrompt; +import android.os.CancellationSignal; +import android.security.keystore.*; +import android.util.AtomicFile; +import java.io.File; +import java.nio.ByteBuffer; +import java.security.*; +import java.util.Arrays; +import javax.crypto.*; +import javax.crypto.spec.GCMParameterSpec; + +public final class Biometrics { + private static final String ALIAS="qsafe.biometric.v1"; + private final Activity activity; + private final AtomicFile wrapped; + private CancellationSignal pending; + public interface Result {void done(VaultCrypto.Key key);void error(String message);} + public Biometrics(Activity a) {activity=a;wrapped=new AtomicFile(new File(a.getFilesDir(),"biometric.enc"));} + public boolean enabled() {return wrapped.getBaseFile().exists();} + @SuppressWarnings("deprecation") public boolean available() { + if(!activity.getPackageManager().hasSystemFeature(android.content.pm.PackageManager.FEATURE_FINGERPRINT))return false; + if(android.os.Build.VERSION.SDK_INT>=30)return activity.getSystemService(android.hardware.biometrics.BiometricManager.class).canAuthenticate(android.hardware.biometrics.BiometricManager.Authenticators.BIOMETRIC_STRONG)==android.hardware.biometrics.BiometricManager.BIOMETRIC_SUCCESS; + if(android.os.Build.VERSION.SDK_INT>=29)return activity.getSystemService(android.hardware.biometrics.BiometricManager.class).canAuthenticate()==android.hardware.biometrics.BiometricManager.BIOMETRIC_SUCCESS; + android.hardware.fingerprint.FingerprintManager manager=activity.getSystemService(android.hardware.fingerprint.FingerprintManager.class); + return manager!=null&&manager.hasEnrolledFingerprints(); + } + public void cancel() {if(pending!=null) {pending.cancel();pending=null;}} + public void disable() throws Exception {cancel();wrapped.delete();KeyStore s=KeyStore.getInstance("AndroidKeyStore");s.load(null);s.deleteEntry(ALIAS);} + private SecretKey key(boolean create) throws Exception { + KeyStore s=KeyStore.getInstance("AndroidKeyStore");s.load(null); + if(create) { + s.deleteEntry(ALIAS); + KeyGenerator g=KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES,"AndroidKeyStore"); + KeyGenParameterSpec.Builder b=new KeyGenParameterSpec.Builder(ALIAS,KeyProperties.PURPOSE_ENCRYPT|KeyProperties.PURPOSE_DECRYPT) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM).setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(256).setUserAuthenticationRequired(true).setInvalidatedByBiometricEnrollment(true); + if(android.os.Build.VERSION.SDK_INT>=30) b.setUserAuthenticationParameters(0,KeyProperties.AUTH_BIOMETRIC_STRONG); + else b.setUserAuthenticationValidityDurationSeconds(-1); + g.init(b.build());return g.generateKey(); + } + return (SecretKey)s.getKey(ALIAS,null); + } + public void enable(VaultCrypto.Key vaultKey,Result callback) { + try { + Cipher c=Cipher.getInstance("AES/GCM/NoPadding");c.init(Cipher.ENCRYPT_MODE,key(true)); + byte[] payload=ByteBuffer.allocate(52).putInt(vaultKey.iterations).put(vaultKey.salt).put(vaultKey.bytes).array(); + prompt(c,new Result() { + public void done(VaultCrypto.Key ignored) { + try {byte[] result=c.doFinal(payload);Repository.write(wrapped,ByteBuffer.allocate(12+result.length).put(c.getIV()).put(result).array());callback.done(null);} + catch(Exception e) {wrapped.delete();callback.error("Не удалось включить биометрию. Проверьте отпечатки в настройках телефона.");} + finally {Arrays.fill(payload,(byte)0);} + } + public void error(String m) {Arrays.fill(payload,(byte)0);callback.error(m);} + }); + } catch(Exception e) {wrapped.delete();callback.error("Сначала настройте надёжную биометрию в Android.");} + } + public void unlock(Result callback) { + try { + byte[] data=wrapped.readFully();if(data.length<28) throw new GeneralSecurityException(); + Cipher c=Cipher.getInstance("AES/GCM/NoPadding");c.init(Cipher.DECRYPT_MODE,key(false),new GCMParameterSpec(128,Arrays.copyOf(data,12))); + prompt(c,new Result() { + public void done(VaultCrypto.Key ignored) { + byte[] plain=null; + try { + plain=c.doFinal(data,12,data.length-12);if(plain.length!=52) throw new GeneralSecurityException(); + ByteBuffer b=ByteBuffer.wrap(plain);int count=b.getInt();byte[] salt=new byte[16],key=new byte[32];b.get(salt);b.get(key); + callback.done(new VaultCrypto.Key(key,salt,count)); + } catch(Exception e) {callback.error("Войдите мастер-паролем и настройте биометрию заново.");} + finally {if(plain!=null) Arrays.fill(plain,(byte)0);} + } + public void error(String m) {callback.error(m);} + }); + } catch(Exception e) {callback.error("Биометрический ключ недоступен. Введите мастер-пароль.");} + } + private void prompt(Cipher cipher,Result callback) { + cancel();CancellationSignal signal=new CancellationSignal();pending=signal; + java.util.concurrent.atomic.AtomicBoolean completed=new java.util.concurrent.atomic.AtomicBoolean(); + BiometricPrompt.Builder b=new BiometricPrompt.Builder(activity).setTitle("Вход в QSafe").setSubtitle("Подтвердите отпечаток пальца") + .setNegativeButton("Мастер-пароль",activity.getMainExecutor(),(d,w)->{if(completed.compareAndSet(false,true))callback.error("Введите мастер-пароль");}); + if(android.os.Build.VERSION.SDK_INT>=30) b.setAllowedAuthenticators(android.hardware.biometrics.BiometricManager.Authenticators.BIOMETRIC_STRONG); + b.build().authenticate(new BiometricPrompt.CryptoObject(cipher),signal,activity.getMainExecutor(),new BiometricPrompt.AuthenticationCallback() { + public void onAuthenticationSucceeded(BiometricPrompt.AuthenticationResult result) {if(pending==signal)pending=null;if(completed.compareAndSet(false,true))callback.done(null);} + public void onAuthenticationError(int code,CharSequence message) {if(pending==signal)pending=null;if(completed.compareAndSet(false,true))callback.error("Отпечаток: "+message);} + }); + } +} diff --git a/app/src/main/java/ru/kusoft/qsafe/CardTemplates.java b/app/src/main/java/ru/kusoft/qsafe/CardTemplates.java new file mode 100644 index 0000000..880a310 --- /dev/null +++ b/app/src/main/java/ru/kusoft/qsafe/CardTemplates.java @@ -0,0 +1,56 @@ +package ru.kusoft.qsafe; + +import org.json.*; +import java.net.URI; +import java.util.*; + +public final class CardTemplates { + public static final String[] KINDS={"login","bank","email","wifi","server","document","note","custom"}; + public static final String[] TITLES={"Сайт или приложение","Банковская карта","Электронная почта","Сеть Wi-Fi","Сервер","Документ","Секретная заметка","Своя карточка"}; + private CardTemplates() { } + public static String title(String kind) {for(int i=0;i0)return tags.optString(0); + return title(kind(entry)); + } +} diff --git a/app/src/main/java/ru/kusoft/qsafe/CipherSync.java b/app/src/main/java/ru/kusoft/qsafe/CipherSync.java new file mode 100644 index 0000000..0c1450f --- /dev/null +++ b/app/src/main/java/ru/kusoft/qsafe/CipherSync.java @@ -0,0 +1,19 @@ +package ru.kusoft.qsafe; + +/** A locked vault may upload only against a known common version; changed remote data is staged. */ +public final class CipherSync { + private CipherSync() {} + public enum Plan { CREATE, MISSING_REMOTE, UNCHANGED, UPLOAD, STAGE } + public static Plan plan(String local,String common,String remote) { + if(remote==null) return common.isEmpty()?Plan.CREATE:Plan.MISSING_REMOTE; + if(remote.equals(local))return Plan.UNCHANGED; + if(!common.isEmpty()&&common.equals(remote))return Plan.UPLOAD; + return Plan.STAGE; + } + public static String ssid(String raw) { + if(raw==null||raw.equals(""))return ""; + if(raw.length()>=2&&raw.startsWith("\"")&&raw.endsWith("\""))return raw.substring(1,raw.length()-1); + return raw; + } + public static boolean matches(String expected,String observed) {return !expected.isEmpty()&&expected.equals(ssid(observed));} +} diff --git a/app/src/main/java/ru/kusoft/qsafe/GlyphDrawable.java b/app/src/main/java/ru/kusoft/qsafe/GlyphDrawable.java new file mode 100644 index 0000000..880763e --- /dev/null +++ b/app/src/main/java/ru/kusoft/qsafe/GlyphDrawable.java @@ -0,0 +1,46 @@ +package ru.kusoft.qsafe; + +import android.graphics.*; +import android.graphics.drawable.Drawable; + +/** Small local vector pictograms. Card domains are never sent to an icon service. */ +public final class GlyphDrawable extends Drawable { + private final String kind; + private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final int size; + public GlyphDrawable(String kind, int color, int pixels) { + this.kind=kind;size=pixels;paint.setColor(color);paint.setStrokeWidth(1.7f); + paint.setStrokeCap(Paint.Cap.ROUND);paint.setStrokeJoin(Paint.Join.ROUND);paint.setStyle(Paint.Style.STROKE); + setBounds(0,0,pixels,pixels); + } + private void line(Canvas c,float x,float y,float x2,float y2) {c.drawLine(x,y,x2,y2,paint);} + private void path(Canvas c,float... points) { + Path p=new Path();p.moveTo(points[0],points[1]);for(int i=2;i running=new ConcurrentHashMap<>(); + @Override public boolean onStartJob(JobParameters parameters) { + AtomicBoolean cancelled=new AtomicBoolean();running.put(parameters.getJobId(),cancelled); + SyncRuntime.IO.execute(()->{ + boolean retry=false; + try { + Repository r=Repository.get(this); + if(!r.homeWifiEnabled()||!r.connected())return; + String problem=HomeWifiSync.permissionProblem(this,true); + if(!problem.isEmpty()){r.backgroundStatus(problem);return;} + Network network=HomeWifiSync.homeNetwork(this,r.homeSsid(),parameters.getNetwork(),true); + if(network==null){r.backgroundStatus("Ожидается домашний Wi-Fi «"+r.homeSsid()+"»");return;} + String result=r.backgroundSync(network,cancelled::get); + r.backgroundStatus(new java.text.SimpleDateFormat("dd.MM HH:mm",java.util.Locale.getDefault()).format(new java.util.Date())+" · "+result); + }catch(Exception e) { + retry=true; + try{Repository.get(this).backgroundStatus("Не удалось связаться с Pi. Android повторит попытку; локальная база сохранена.");}catch(Exception ignored){} + }finally { + boolean needsRetry=retry; + new Handler(Looper.getMainLooper()).post(()->{running.remove(parameters.getJobId(),cancelled);if(!cancelled.get())jobFinished(parameters,needsRetry);}); + } + }); + return true; + } + @Override public boolean onStopJob(JobParameters parameters) {AtomicBoolean stopped=running.remove(parameters.getJobId());if(stopped!=null)stopped.set(true);return true;} +} diff --git a/app/src/main/java/ru/kusoft/qsafe/HomeWifiReceiver.java b/app/src/main/java/ru/kusoft/qsafe/HomeWifiReceiver.java new file mode 100644 index 0000000..43d03a3 --- /dev/null +++ b/app/src/main/java/ru/kusoft/qsafe/HomeWifiReceiver.java @@ -0,0 +1,6 @@ +package ru.kusoft.qsafe; +import android.content.*; +/** Explicit PendingIntent target plus system boot/package-replaced broadcasts; no credentials in intents. */ +public final class HomeWifiReceiver extends BroadcastReceiver { + @Override public void onReceive(Context context,Intent intent) {HomeWifiSync.enqueue(context);} +} diff --git a/app/src/main/java/ru/kusoft/qsafe/HomeWifiSync.java b/app/src/main/java/ru/kusoft/qsafe/HomeWifiSync.java new file mode 100644 index 0000000..5991bff --- /dev/null +++ b/app/src/main/java/ru/kusoft/qsafe/HomeWifiSync.java @@ -0,0 +1,96 @@ +package ru.kusoft.qsafe; + +import android.Manifest; +import android.app.*; +import android.app.job.*; +import android.content.*; +import android.content.pm.PackageManager; +import android.location.LocationManager; +import android.net.*; +import android.net.wifi.*; +import android.os.Build; + +/** Persistent network PendingIntent survives process death; periodic work recovers OEM missed callbacks. */ +public final class HomeWifiSync { + public static final int ARRIVAL_JOB=7201,RECOVERY_JOB=7202; + private static ConnectivityManager.NetworkCallback liveCallback; + private HomeWifiSync() {} + private static NetworkRequest wifi() { + return new NetworkRequest.Builder().addTransportType(NetworkCapabilities.TRANSPORT_WIFI).build(); + } + private static PendingIntent operation(Context c) { + Intent i=new Intent(c,HomeWifiReceiver.class).setAction("ru.kusoft.qsafe.HOME_WIFI_AVAILABLE"); + return PendingIntent.getBroadcast(c,7200,i,PendingIntent.FLAG_UPDATE_CURRENT|PendingIntent.FLAG_IMMUTABLE); + } + public static synchronized void install(Context context,Repository repository) { + Context c=context.getApplicationContext();ConnectivityManager cm=c.getSystemService(ConnectivityManager.class); + JobScheduler jobs=c.getSystemService(JobScheduler.class); + if(!repository.homeWifiEnabled()||!repository.connected()) { + try{cm.unregisterNetworkCallback(operation(c));}catch(RuntimeException ignored){} + if(liveCallback!=null){try{cm.unregisterNetworkCallback(liveCallback);}catch(RuntimeException ignored){}liveCallback=null;} + jobs.cancel(ARRIVAL_JOB);jobs.cancel(RECOVERY_JOB);return; + } + // Re-registering the same PendingIntent replaces its previous request instead of leaking registrations. + cm.registerNetworkCallback(wifi(),operation(c)); + // Keep reconnects responsive while the process lives; the PendingIntent and periodic + // job remain responsible for recovery after process death and OEM scheduling delays. + if(liveCallback==null) { + ConnectivityManager.NetworkCallback callback=new ConnectivityManager.NetworkCallback(){ + @Override public void onAvailable(Network network){enqueue(c,repository);} + @Override public void onLost(Network network){ + synchronized(HomeWifiSync.class){if(repository.homeWifiEnabled()&&repository.connected())cm.registerNetworkCallback(wifi(),operation(c));} + } + }; + cm.registerNetworkCallback(wifi(),callback);liveCallback=callback; + } + JobInfo periodic=new JobInfo.Builder(RECOVERY_JOB,new ComponentName(c,HomeWifiJob.class)) + .setRequiredNetwork(wifi()).setPeriodic(15*60_000L).setPersisted(true).build(); + if(jobs.getPendingJob(RECOVERY_JOB)==null)jobs.schedule(periodic); + enqueue(c,repository); + } + public static synchronized void enqueue(Context c) { + try{enqueue(c,Repository.get(c));}catch(Exception ignored){ } + } + private static synchronized void enqueue(Context c,Repository repository) { + if(!repository.homeWifiEnabled()||!repository.connected())return; + JobScheduler jobs=c.getSystemService(JobScheduler.class); + JobInfo job=new JobInfo.Builder(ARRIVAL_JOB,new ComponentName(c,HomeWifiJob.class)) + .setRequiredNetwork(wifi()).setBackoffCriteria(30_000,JobInfo.BACKOFF_POLICY_EXPONENTIAL).setPersisted(true).build(); + if(jobs.getPendingJob(ARRIVAL_JOB)==null)jobs.schedule(job); + } + public static boolean locationAllowed(Context c) {return c.checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION)==PackageManager.PERMISSION_GRANTED;} + public static boolean backgroundAllowed(Context c) {return Build.VERSION.SDK_INT<29||c.checkSelfPermission(Manifest.permission.ACCESS_BACKGROUND_LOCATION)==PackageManager.PERMISSION_GRANTED;} + public static String permissionProblem(Context c,boolean background) { + if(!locationAllowed(c))return "Для имени Wi-Fi разрешите точный доступ к местоположению"; + LocationManager lm=c.getSystemService(LocationManager.class); + if(lm==null||!lm.isLocationEnabled())return "Включите геолокацию Android, чтобы система разрешила читать имя Wi-Fi"; + if(background&&!backgroundAllowed(c))return "Для закрытого приложения разрешите местоположение «Всегда»"; + return ""; + } + @SuppressWarnings("deprecation") public static String currentSsid(Context c) { + if(!permissionProblem(c,false).isEmpty())return ""; + try { + WifiManager wm=(WifiManager)c.getApplicationContext().getSystemService(Context.WIFI_SERVICE); + WifiInfo info=wm.getConnectionInfo();return info==null?"":CipherSync.ssid(info.getSSID()); + }catch(SecurityException e){return "";} + } + @SuppressWarnings("deprecation") public static Network homeNetwork(Context c,String expected,Network preferred,boolean background) { + if(!permissionProblem(c,background).isEmpty()||!CipherSync.matches(expected,currentSsid(c)))return null; + ConnectivityManager cm=c.getSystemService(ConnectivityManager.class); + WifiManager wm=(WifiManager)c.getApplicationContext().getSystemService(Context.WIFI_SERVICE); + WifiInfo current=wm==null?null:wm.getConnectionInfo();int wifiAddress=current==null?0:current.getIpAddress(); + if(wifiAddress==0)return null; + Network[] networks=preferred==null?cm.getAllNetworks():new Network[]{preferred}; + for(Network n:networks) { + NetworkCapabilities caps=cm.getNetworkCapabilities(n); + if(caps==null||!caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)||caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN))continue; + LinkProperties links=cm.getLinkProperties(n);if(links==null)continue; + // Match the actual current Wi-Fi IPv4, even when Android redacts WifiInfo in capabilities. + for(LinkAddress link:links.getLinkAddresses()) { + byte[] ip=link.getAddress().getAddress(); + if(ip.length==4&&(ip[0]&255)==(wifiAddress&255)&&(ip[1]&255)==(wifiAddress>>>8&255)&&(ip[2]&255)==(wifiAddress>>>16&255)&&(ip[3]&255)==(wifiAddress>>>24&255))return n; + } + } + return null; + } +} diff --git a/app/src/main/java/ru/kusoft/qsafe/LanPolicy.java b/app/src/main/java/ru/kusoft/qsafe/LanPolicy.java new file mode 100644 index 0000000..60fc71c --- /dev/null +++ b/app/src/main/java/ru/kusoft/qsafe/LanPolicy.java @@ -0,0 +1,35 @@ +package ru.kusoft.qsafe; + +import java.io.IOException; +import java.net.*; + +/** Pure validation: never resolves DNS and never opens a socket. */ +public final class LanPolicy { + private LanPolicy() {} + public static byte[] ipv4(String text) throws IOException { + if(text==null||!text.matches("[0-9]{1,3}(\\.[0-9]{1,3}){3}"))throw new IOException("Нужен локальный IPv4 Pi, без доменного имени"); + String[] parts=text.split("\\.");byte[] out=new byte[4]; + for(int i=0;i<4;i++){int value=Integer.parseInt(parts[i]);if(value>255||(parts[i].length()>1&&parts[i].charAt(0)=='0'))throw new IOException("Неверный IPv4");out[i]=(byte)value;} + int a=out[0]&255,b=out[1]&255; + if(!(a==10||a==172&&b>=16&&b<=31||a==192&&b==168))throw new IOException("Внешние адреса базы запрещены. Нужен локальный IPv4 Pi"); + return out; + } + public static String url(String address) throws IOException { + try { + URI uri=new URI(address==null?"":address.trim()); + if(!"https".equalsIgnoreCase(uri.getScheme())||uri.getHost()==null||uri.getRawUserInfo()!=null||uri.getRawQuery()!=null||uri.getRawFragment()!=null)throw new IOException("Нужен локальный HTTPS-адрес Pi без логина и параметров"); + ipv4(uri.getHost());String result=uri.toASCIIString();return result.endsWith("/")?result:result+"/"; + }catch(URISyntaxException e){throw new IOException("Неверный адрес Pi",e);} + } + public static void identity(String ssid,String pin)throws IOException { + if(ssid==null||ssid.isEmpty()||ssid.getBytes(java.nio.charset.StandardCharsets.UTF_8).length>32)throw new IOException("Укажите точное имя домашнего Wi-Fi"); + if(pin==null||!pin.matches("[0-9a-fA-F]{64}"))throw new IOException("Нужен SHA-256 локального сертификата Pi из файла подключения"); + } + public static boolean sameSubnet(byte[] target,byte[] local,int prefix) { + if(target.length!=4||local.length!=4||prefix<16||prefix>30||java.util.Arrays.equals(target,local))return false; + long value=0;for(byte b:target)value=value<<8|(b&255);long hostMask=(1L<<(32-prefix))-1; + if((value&hostMask)==0||(value&hostMask)==hostMask)return false; + for(int i=0;i<4;i++){int bits=Math.max(0,Math.min(8,prefix-i*8));int mask=255<<(8-bits)&255;if((target[i]&mask)!=(local[i]&mask))return false;} + return true; + } +} diff --git a/app/src/main/java/ru/kusoft/qsafe/MainActivity.java b/app/src/main/java/ru/kusoft/qsafe/MainActivity.java new file mode 100644 index 0000000..37177ab --- /dev/null +++ b/app/src/main/java/ru/kusoft/qsafe/MainActivity.java @@ -0,0 +1,814 @@ +package ru.kusoft.qsafe; + +import android.app.*; +import android.content.*; +import android.graphics.Color; +import android.graphics.Typeface; +import android.graphics.drawable.GradientDrawable; +import android.graphics.drawable.RippleDrawable; +import android.content.res.ColorStateList; +import android.content.res.Configuration; +import android.net.Uri; +import android.os.*; +import android.text.*; +import android.text.method.PasswordTransformationMethod; +import android.view.*; +import android.view.inputmethod.InputMethodManager; +import android.widget.*; +import org.json.*; +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.security.*; +import java.util.*; +import java.util.concurrent.*; +import java.util.function.Consumer; +import java.util.function.BiConsumer; + +public class MainActivity extends Activity { + protected int INK,MUTED,TEAL,BG,SURFACE,SOFT,LINE,ON_ACCENT,DANGER; + private boolean darkTheme; + private Runnable appearanceRefresh; + private boolean enableWifiOnSave=false; + private String wifiDraft; + private static final int IMPORT=41,CONNECTION=42,EXPORT=43,RESTORE=44; + private final Handler handler=new Handler(Looper.getMainLooper()); + private final ExecutorService io=SyncRuntime.IO; + private Repository repository; + private Biometrics biometrics; + private Session session; + private LinearLayout root,content,list; + private FrameLayout frame; + private TextView status,counter; + private EditText search; + private ScrollView pageScroll; + private boolean busy=false,stars=false; + private int generation=0; + private Dialog dialog; + private Uri pendingImport; + private byte[] pendingExport; + private String notice=""; + private boolean returnHomeSettings=false; + private boolean refreshMenuOnResume=false; + private boolean resumed=false,automaticFingerprintRequested=false,fingerprintRunning=false,resumeUpdateInstall=false; + private UpdateManifest downloadedUpdate; + private String screen="locked"; + private JSONObject currentDetail; + private Runnable navigateBack; + private final Runnable idleLock=()->lock("Хранилище заблокировано"); + private final Runnable scheduledSync=()->sync(false); + private static final class Session implements AutoCloseable { + final VaultCrypto.Key key;JSONObject data;String openingNotice=""; + Session(VaultCrypto.Key k,JSONObject d) {key=k;data=d;} + public void close() {key.close();data=null;} + } + private interface Work {T run() throws Exception;} + + @Override public void onCreate(Bundle saved) { + AppAppearance.applyStyle(this); + super.onCreate(saved); + applyAppearance(); + SecureWindows.protect(this); + getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE); + biometrics=new Biometrics(this); + try {repository=Repository.get(this);showLocked();} + catch(Exception e) {page();heading("QSafe",28);paragraph("Не удалось открыть настройки. Данные сохранены. Не очищайте данные приложения; восстановите доступ из резервной копии.");} + } + @Override protected void onSaveInstanceState(Bundle out) {super.onSaveInstanceState(out);out.clear();} + @Override protected void onStop() {resumed=false;super.onStop();if(repository!=null) lock("");} + @Override protected void onResume() { + super.onResume(); + resumed=true;automaticFingerprintRequested=false; + if(refreshMenuOnResume){ + refreshMenuOnResume=false;if(session!=null&&!busy)settings(); + int resumedGeneration=generation; + for(long delay:new long[]{100,500,1500})handler.postDelayed(()->{ + if(generation!=resumedGeneration||session==null||busy||!screen.equals("menu"))return; + Switch control=findViewById(R.id.autofill_enabled);android.view.autofill.AutofillManager manager=getSystemService(android.view.autofill.AutofillManager.class); + if(control!=null&&manager!=null&&control.isChecked()!=manager.hasEnabledAutofillServices())settings(); + },delay); + } + if(returnHomeSettings&&repository!=null){returnHomeSettings=false;handler.post(this::homeWifiSettings);SyncRuntime.IO.execute(()->{try{HomeWifiSync.install(this,repository);}catch(Exception ignored){}});} + else if(resumeUpdateInstall){resumeUpdateInstall=false;if(getPackageManager().canRequestPackageInstalls())handler.post(this::installDownloadedUpdate);else error("Установка обновлений не разрешена. Разрешение можно включить при следующей попытке.");} + else scheduleFingerprint(); + } + @Override protected void onDestroy() {if(biometrics!=null) biometrics.cancel();super.onDestroy();} + @Override public void onConfigurationChanged(Configuration configuration) { + super.onConfigurationChanged(configuration); + refreshAppearanceIfNeeded(); + } + private void refreshAppearanceIfNeeded() { + if(busy)return; + if(darkTheme!=AppAppearance.dark(this)) { + Runnable redraw=appearanceRefresh;applyAppearance();dismiss(); + if(isAutofill()&&session!=null)showAutofill();else if(redraw!=null)redraw.run();else if(session==null)showLocked();else showHome(); + } + } + private void applyAppearance() { + AppAppearance.applyStyle(this);darkTheme=AppAppearance.dark(this); + INK=Color.parseColor(darkTheme?"#E5ECEA":"#19302D");MUTED=Color.parseColor(darkTheme?"#A0B3AD":"#647874"); + TEAL=Color.parseColor(darkTheme?"#78D5BF":"#087F70");BG=Color.parseColor(darkTheme?"#101817":"#F5F8F7"); + SURFACE=Color.parseColor(darkTheme?"#1B2724":"#FFFFFF");SOFT=Color.parseColor(darkTheme?"#263D35":"#E1EFEA"); + LINE=Color.parseColor(darkTheme?"#34443F":"#E6EDE9");ON_ACCENT=Color.parseColor(darkTheme?"#10291F":"#FFFFFF"); + DANGER=Color.parseColor(darkTheme?"#FFADAD":"#B14040");AppAppearance.applyWindow(this); + } + @Override public void onUserInteraction() {super.onUserInteraction();resetTimer();} + private void resetTimer() {handler.removeCallbacks(idleLock);if(session!=null) handler.postDelayed(idleLock,120_000);} + private void lock(String message) { + generation++;busy=false;handler.removeCallbacks(idleLock);handler.removeCallbacks(scheduledSync); + fingerprintRunning=false; + if(biometrics!=null) biometrics.cancel();dismiss(); + Session old=session;session=null; + list=null;search=null;counter=null; + navigateBack=null;appearanceRefresh=null;currentDetail=null;screen="locked"; + if(old!=null&&!io.isShutdown()) io.execute(old::close); + if(repository!=null&&!isFinishing()&&!isDestroyed()) {notice=message;showLocked();} + } + private void dismiss() {if(dialog!=null) {dialog.dismiss();dialog=null;}} + private int dp(float n) {return Math.round(n*getResources().getDisplayMetrics().density);} + private GradientDrawable shape(int color,int radius) {GradientDrawable d=new GradientDrawable();d.setColor(color);d.setCornerRadius(dp(radius));return d;} + private LinearLayout column() {LinearLayout l=new LinearLayout(this);l.setOrientation(LinearLayout.VERTICAL);return l;} + private LinearLayout row() {LinearLayout l=new LinearLayout(this);l.setOrientation(LinearLayout.HORIZONTAL);l.setGravity(Gravity.CENTER_VERTICAL);return l;} + private LinearLayout.LayoutParams wrap() {return new LinearLayout.LayoutParams(-1,-2);} + private void gap(LinearLayout target,int height) {View v=new View(this);target.addView(v,new LinearLayout.LayoutParams(1,dp(height)));} + private TextView label(String text,int size,int color) {TextView t=new TextView(this);t.setText(text);t.setTextSize(size);t.setTextColor(color);t.setPadding(0,dp(4),0,dp(4));return t;} + private void shell() { + applyAppearance(); + frame=new FrameLayout(this);frame.setBackgroundColor(BG);frame.setSaveFromParentEnabled(false); + root=column();root.setPadding(dp(20),0,dp(20),0);root.setSaveFromParentEnabled(false); + root.setOnApplyWindowInsetsListener((v,insets)->{ + v.setPadding(dp(20),insets.getSystemWindowInsetTop()+dp(10),dp(20),Math.max(dp(12),insets.getSystemWindowInsetBottom()));return insets; + }); + frame.addView(root,new FrameLayout.LayoutParams(-1,-1));setContentView(frame);root.requestApplyInsets(); + } + private void page() { + shell(); + ScrollView scroll=new ScrollView(this);scroll.setFillViewport(true);content=column();content.setPadding(0,dp(8),0,dp(20)); + scroll.addView(content);root.addView(scroll,new LinearLayout.LayoutParams(-1,-1)); + } + private void heading(String text,int size) {TextView t=label(text,size,INK);t.setTypeface(null,Typeface.BOLD);content.addView(t);} + private void paragraph(String text) {content.addView(label(text,15,MUTED));} + private EditText input(LinearLayout parent,String hint,String value,boolean secret,int id) { + EditText e=new EditText(this);e.setHint(hint);e.setText(value);e.setTextSize(16);e.setTextColor(INK);e.setHintTextColor(MUTED); + e.setSingleLine(true);e.setSaveEnabled(false);e.setImportantForAutofill(View.IMPORTANT_FOR_AUTOFILL_NO_EXCLUDE_DESCENDANTS); + e.setInputType(secret?android.text.InputType.TYPE_CLASS_TEXT|android.text.InputType.TYPE_TEXT_VARIATION_PASSWORD: + android.text.InputType.TYPE_CLASS_TEXT|android.text.InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS); + if(secret) e.setTransformationMethod(PasswordTransformationMethod.getInstance()); + e.setPadding(dp(14),dp(13),dp(14),dp(13));e.setBackground(shape(SURFACE,12));if(id!=0)e.setId(id); + LinearLayout.LayoutParams p=wrap();p.topMargin=dp(8);p.bottomMargin=dp(8);parent.addView(e,p);return e; + } + private Button button(LinearLayout parent,String title,boolean primary,Runnable action) { + Button b=new Button(this);b.setText(title);b.setAllCaps(false);b.setTextSize(15);b.setMinHeight(dp(48)); + b.setTextColor(primary?ON_ACCENT:TEAL);b.setBackground(shape(primary?TEAL:SOFT,12)); + b.setPadding(dp(12),dp(7),dp(12),dp(7));b.setOnClickListener(v->{resetTimer();action.run();}); + LinearLayout.LayoutParams p=wrap();p.topMargin=dp(7);p.bottomMargin=dp(3);parent.addView(b,p);return b; + } + private void showStatus(String message) {if(status!=null){status.setText(message);status.setVisibility(message.isEmpty()?View.GONE:View.VISIBLE);}} + private void error(String message) {notice=message;showStatus(message);Toast.makeText(this,message,Toast.LENGTH_LONG).show();} + private String message(Exception e) { + if(e instanceof javax.crypto.AEADBadTagException) return "Неверный мастер-пароль или повреждённая база"; + if(e instanceof GeneralSecurityException) return "Не удалось расшифровать базу. Проверьте мастер-пароль."; + if(e instanceof java.net.SocketTimeoutException || e instanceof java.net.UnknownHostException || e instanceof java.net.ConnectException) + return "Pi недоступен. Локальная база сохранена; синхронизацию можно повторить позже."; + String m=e.getMessage();return m==null?"Не удалось выполнить действие":m; + } + private void task(String title,Work work,Consumer success) { + task(title,work,success,null); + } + private void task(String title,Work work,Consumer success,Runnable failure) { + if(busy||io.isShutdown()) return;busy=true;int started=generation;showStatus(title); + io.execute(()->{ + try { + T result=work.run();handler.post(()->{ + if(started!=generation||isFinishing()||isDestroyed()) {if(result instanceof AutoCloseable) try {((AutoCloseable)result).close();}catch(Exception ignored){}return;} + busy=false;success.accept(result);refreshAppearanceIfNeeded(); + }); + } catch(Exception e) {handler.post(()->{if(started==generation&&!isFinishing()&&!isDestroyed()) {busy=false;if(failure!=null)failure.run();error(message(e));refreshAppearanceIfNeeded();}});} + }); + } + private void showLocked() { + appearanceRefresh=this::showLocked; + page();gap(content,64); + ImageView mark=new ImageView(this);mark.setImageDrawable(new GlyphDrawable("shield",TEAL,dp(48)));mark.setPadding(dp(18),dp(18),dp(18),dp(18));mark.setBackground(shape(SOFT,25)); + LinearLayout.LayoutParams logo=new LinearLayout.LayoutParams(dp(84),dp(84));logo.bottomMargin=dp(18);content.addView(mark,logo); + heading("QSafe",36);paragraph("Личное пространство для ваших паролей");gap(content,32); + boolean exists=repository.exists();heading(exists?"С возвращением":"Ваше хранилище",24); + paragraph(exists?(biometrics.enabled()?"Коснитесь датчика отпечатка или введите мастер-пароль.":"Введите мастер-пароль, чтобы открыть карточки."):"Подключите Pi и введите мастер-пароль вашей базы."); + EditText password=input(content,"Мастер-пароль","",true,R.id.master_password); + password.setImeOptions(android.view.inputmethod.EditorInfo.IME_ACTION_DONE); + password.setOnEditorActionListener((v,action,event)->{if(action==android.view.inputmethod.EditorInfo.IME_ACTION_DONE){unlockPassword(password);return true;}return false;}); + button(content,"Разблокировать",true,()->unlockPassword(password)).setId(R.id.open_vault); + if(!exists&&!isAutofill())button(content,repository.connected()?"Синхронизировать карточки с Pi":"Подключить Pi и синхронизировать",false,()->{if(repository.connected())unlockPassword(password);else firstSetup();}).setId(R.id.connect_pi); + gap(content,16);status=label(notice,13,MUTED);status.setId(R.id.status);content.addView(status); + paragraph("Мастер-пароль остаётся только у вас."); + } + private void unlockPassword(EditText password) { + if(busy||fingerprintRunning)return; + boolean exists=repository.exists(); + if(exists&&password.length()==0&&biometrics.enabled()){unlockFingerprint();return;} + if(!exists&&!repository.connected()){firstSetup();return;} + if(password.length()==0){password.setError("Введите мастер-пароль");return;} + char[] pass=password.getText().toString().toCharArray();password.setText("");hideKeyboard(password); + task(exists?"Открываю хранилище…":"Восстанавливаю карточки с Pi…",()->{ + VaultCrypto.Key key=null; + try {key=exists?VaultCrypto.unlock(repository.encrypted(),pass):repository.restorePi(pass);return loadSession(key,repository.read(key));} + catch(Exception e){if(key!=null)key.close();throw e;}finally{Arrays.fill(pass,'\0');} + },this::opened); + } + private void firstSetup() { + if(busy)return;LinearLayout body=column(); + body.addView(label("Выберите личный файл connection.qsafe. Затем введите мастер-пароль существующей базы — карточки будут восстановлены с Pi.",16,INK)); + button(body,"Выбрать файл подключения Pi",true,()->pick(CONNECTION,"*/*")); + gap(body,16);body.addView(label("Если базы ещё нет",13,MUTED)); + button(body,"Создать новое хранилище",false,this::newVaultDialog); + button(body,"Восстановить из файла .qsafe",false,()->pick(RESTORE,"*/*")); + showDialog("Первое подключение",body,null,null); + } + private void newVaultDialog() { + LinearLayout body=column();body.addView(label("Придумайте мастер-пароль от 12 символов. Он понадобится для восстановления базы на другом устройстве.",15,INK)); + EditText password=input(body,"Мастер-пароль","",true,R.id.master_password),confirm=input(body,"Повторите мастер-пароль","",true,R.id.confirm_password); + showDialog("Новое хранилище",body,"Создать",()->{ + if(busy)return;char[] pass=password.getText().toString().toCharArray(); + if(pass.length<12||!Arrays.equals(pass,confirm.getText().toString().toCharArray())){Arrays.fill(pass,'\0');password.setError("От 12 символов; оба пароля должны совпадать");return;} + password.setText("");confirm.setText(""); + task("Создаю хранилище…",()->{ + VaultCrypto.Key key=null;try{key=VaultCrypto.create(pass);JSONObject data=Vault.empty();repository.save(data,key);return new Session(key,data);} + catch(Exception e){if(key!=null)key.close();throw e;}finally{Arrays.fill(pass,'\0');} + },result->{dismiss();opened(result);}); + }); + } + private void scheduleFingerprint() { + int started=generation;handler.postDelayed(()->{ + if(resumed&&started==generation&&session==null&&repository!=null&&repository.exists()&&biometrics.enabled()&&!automaticFingerprintRequested&&dialog==null&&!busy){automaticFingerprintRequested=true;unlockFingerprint();} + },300); + } + private void unlockFingerprint() { + if(busy||fingerprintRunning||!resumed)return;int started=generation;fingerprintRunning=true; + biometrics.unlock(new Biometrics.Result() { + public void done(VaultCrypto.Key key) { + fingerprintRunning=false;if(started!=generation||!resumed){key.close();return;} + task("Открываю хранилище…",()->{try{return loadSession(key,repository.read(key));}catch(Exception e){key.close();throw e;}},MainActivity.this::opened); + } + public void error(String m){fingerprintRunning=false;if(started==generation&&resumed)MainActivity.this.error(m);} + }); + } + private void hideKeyboard(View v) {((InputMethodManager)getSystemService(INPUT_METHOD_SERVICE)).hideSoftInputFromWindow(v.getWindowToken(),0);} + private void opened(Session result) { + session=result;notice=result.openingNotice;showHome();resetTimer(); + if(pendingImport!=null) {Uri uri=pendingImport;pendingImport=null;importXml(uri);} + else if(!isAutofill()) { + if(repository.connected()&&repository.autoSync())handler.postDelayed(scheduledSync,400); + if(!biometrics.enabled()&&!repository.fingerprintOffered()&&biometrics.available())handler.postDelayed(this::offerFingerprint,700); + } + } + private Session loadSession(VaultCrypto.Key key,JSONObject fallback) throws Exception { + Session result=new Session(key,fallback); + try { + Repository.SyncResult applied=repository.applyPending(key);result.data=applied.data; + if(applied.conflicts>0)result.openingNotice="Сохранены конфликтующие версии с Pi: "+applied.conflicts; + }catch(Exception e){result.data=repository.read(key);result.openingNotice="Копия с Pi не применена: "+message(e);} + return result; + } + private void showHome() { + appearanceRefresh=this::showHome; + if(session==null) {showLocked();return;} + if(isAutofill()) {showAutofill();return;} + screen="home";navigateBack=null;currentDetail=null; + String query=search==null?"":search.getText().toString(); + shell();content=root;LinearLayout header=row();TextView name=label("QSafe",27,INK);name.setTypeface(null,Typeface.BOLD); + header.addView(name,new LinearLayout.LayoutParams(0,-2,1)); + ImageButton menu=new ImageButton(this);menu.setImageDrawable(new GlyphDrawable("menu",INK,dp(24)));menu.setBackground(shape(SURFACE,14));menu.setContentDescription("Настройки");menu.setId(R.id.settings); + menu.setOnClickListener(v->{resetTimer();settings();});menu.setContentDescription("Меню");header.addView(menu,new LinearLayout.LayoutParams(dp(48),dp(48)));root.addView(header); + gap(root,12);search=input(root,"Найти карточку",query,false,R.id.search); + search.setCompoundDrawablesWithIntrinsicBounds(new GlyphDrawable("search",MUTED,dp(20)),null,null,null);search.setCompoundDrawablePadding(dp(12)); + LinearLayout filters=row();TextView all=chip(stars?"Все":"Все карточки",!stars),favorite=chip("Избранное",stars); + all.setOnClickListener(v->{stars=false;showHome();});favorite.setOnClickListener(v->{stars=true;showHome();}); + filters.addView(all);LinearLayout.LayoutParams fp=new LinearLayout.LayoutParams(-2,dp(36));fp.leftMargin=dp(8);filters.addView(favorite,fp); + counter=label("",12,MUTED);counter.setId(R.id.card_counter);counter.setGravity(Gravity.END);filters.addView(counter,new LinearLayout.LayoutParams(0,-2,1));root.addView(filters); + status=label(notice.isEmpty()?syncCaption():notice,11,MUTED);status.setId(R.id.status);status.setSingleLine(true);status.setEllipsize(TextUtils.TruncateAt.END);root.addView(status); + ScrollView scroll=new ScrollView(this);scroll.setId(R.id.card_scroller);scroll.setClipToPadding(false);scroll.setVerticalScrollBarEnabled(false); + list=column();list.setId(R.id.entry_list);list.setPadding(0,dp(10),0,dp(96));scroll.addView(list);root.addView(scroll,new LinearLayout.LayoutParams(-1,0,1)); + ImageButton add=new ImageButton(this);add.setId(R.id.add_entry);add.setContentDescription("Добавить карточку");add.setImageDrawable(new GlyphDrawable("plus",ON_ACCENT,dp(28)));add.setBackground(new RippleDrawable(ColorStateList.valueOf(0x33FFFFFF),shape(TEAL,22),null));add.setElevation(dp(8)); + add.setOnClickListener(v->{resetTimer();newCardMenu();});FrameLayout.LayoutParams floating=new FrameLayout.LayoutParams(dp(62),dp(62),Gravity.BOTTOM|Gravity.END);floating.rightMargin=dp(22);floating.bottomMargin=dp(28);frame.addView(add,floating); + add.setOnApplyWindowInsetsListener((v,insets)->{FrameLayout.LayoutParams p=(FrameLayout.LayoutParams)v.getLayoutParams();p.bottomMargin=insets.getSystemWindowInsetBottom()+dp(24);v.setLayoutParams(p);return insets;});add.requestApplyInsets(); + search.addTextChangedListener(new TextWatcher() {public void beforeTextChanged(CharSequence s,int a,int c,int f){}public void onTextChanged(CharSequence s,int a,int b,int c){renderList();}public void afterTextChanged(Editable e){}}); + renderList(); + } + private TextView chip(String title,boolean selected) { + TextView view=label(title,13,selected?TEAL:MUTED);view.setTypeface(null,selected?Typeface.BOLD:Typeface.NORMAL);view.setGravity(Gravity.CENTER);view.setPadding(dp(14),0,dp(14),0);view.setBackground(shape(selected?SOFT:SURFACE,12));view.setMinHeight(dp(36));view.setClickable(true);return view; + } + private String syncCaption() { + if(!repository.connected())return "● На телефоне · подключите Pi в настройках"; + return repository.lastSync().isEmpty()?"● Pi подключён · ещё не синхронизировано":"● Pi · последняя синхронизация "+repository.lastSync(); + } + private void renderList() { + if(session==null||list==null)return;list.removeAllViews(); + try { + List entries=Vault.visible(session.data,search.getText().toString(),stars);counter.setText("Карточек: "+entries.size()); + if(entries.isEmpty()) { + LinearLayout empty=column();empty.setPadding(dp(18),dp(22),dp(18),dp(22));empty.setBackground(shape(SURFACE,16)); + empty.addView(label(search.length()>0?"Ничего не найдено":"Здесь будут ваши пароли",19,INK)); + empty.addView(label("Добавьте карточку или перенесите данные из SafeInCloud.",15,MUTED)); + if(search.length()==0) button(empty,"Импорт из SafeInCloud",false,this::importHelp);list.addView(empty);return; + } + for(JSONObject entry:entries) { + LinearLayout card=row();GradientDrawable surface=shape(SURFACE,18);surface.setStroke(dp(1),LINE);card.setBackground(new RippleDrawable(ColorStateList.valueOf(0x12087F70),surface,null));card.setPadding(dp(13),dp(14),dp(13),dp(14)); + String kind=CardTemplates.kind(entry);int[] colors=darkTheme?new int[]{0xFF78D5BF,0xFFABC2FF,0xFFE7B28E,0xFFD2B8FF,0xFF88D3EA}:new int[]{0xFF087F70,0xFF4762B0,0xFF965C30,0xFF7653A1,0xFF277C9C};int tint=colors[Math.floorMod(entry.optString("id").hashCode(),colors.length)]; + ImageView picture=new ImageView(this);picture.setImageDrawable(new GlyphDrawable(kind,tint,dp(25)));picture.setPadding(dp(12),dp(12),dp(12),dp(12));picture.setBackground(shape((tint&0x00ffffff)|0x15000000,14));card.addView(picture,new LinearLayout.LayoutParams(dp(48),dp(48))); + LinearLayout text=column();LinearLayout.LayoutParams tp=new LinearLayout.LayoutParams(0,-2,1);tp.leftMargin=dp(14);tp.rightMargin=dp(6);card.addView(text,tp); + TextView title=label(entry.optString("title"),16,INK);title.setTypeface(null,Typeface.BOLD);title.setSingleLine(true);title.setEllipsize(TextUtils.TruncateAt.END);text.addView(title); + TextView subtitle=label(CardTemplates.subtitle(entry),13,MUTED);subtitle.setSingleLine(true);subtitle.setEllipsize(TextUtils.TruncateAt.END);text.addView(subtitle); + if(entry.optBoolean("star")){TextView star=label("★",17,Color.rgb(184,133,37));star.setPadding(0,0,dp(10),0);card.addView(star);} + ImageView chevron=new ImageView(this);chevron.setImageDrawable(new GlyphDrawable("chevron",MUTED,dp(16)));card.addView(chevron,new LinearLayout.LayoutParams(dp(16),dp(20)));card.setOnClickListener(v->{resetTimer();detail(entry);}); + card.setContentDescription(entry.optString("title")+", открыть карточку"); + LinearLayout.LayoutParams p=wrap();p.bottomMargin=dp(9);list.addView(card,p); + } + } catch(Exception e) {error("Не удалось прочитать список карточек");} + } + private void newCardMenu() { + if(session==null||busy)return;LinearLayout body=column();body.addView(label("Что хотите сохранить?",15,MUTED)); + GridLayout grid=new GridLayout(this);grid.setColumnCount(2);grid.setId(R.id.template_grid);body.addView(grid); + for(int i=0;i{resetTimer();try{editDraft(CardTemplates.create(kind),true);}catch(Exception e){error(message(e));}}); + GridLayout.LayoutParams p=new GridLayout.LayoutParams(GridLayout.spec(i/2),GridLayout.spec(i%2,1f));p.width=0;p.height=dp(106);p.setMargins(dp(4),dp(5),dp(4),dp(5));grid.addView(option,p); + } + showDialog("Новая карточка",body,null,null); + Window window=dialog.getWindow();window.setGravity(Gravity.BOTTOM);window.setLayout(-1,-2);window.setBackgroundDrawable(shape(SURFACE,28)); + } + private void showDialog(String title,LinearLayout body,String positive,Runnable action) { + dismiss();ScrollView scroll=new ScrollView(this);scroll.setPadding(dp(20),dp(4),dp(20),dp(8));scroll.addView(body); + AlertDialog.Builder builder=new AlertDialog.Builder(this).setTitle(title).setView(scroll).setNegativeButton("Закрыть",null); + if(positive!=null)builder.setPositiveButton(positive,null); + AlertDialog d=builder.create();dialog=d;SecureWindows.show(d); + if(positive!=null)d.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener(v->action.run()); + } + protected void showProtectedDialog(AlertDialog confirmation) {dismiss();dialog=confirmation;SecureWindows.show(confirmation);} + @Override public void onBackPressed() { + if(navigateBack!=null){resetTimer();navigateBack.run();}else super.onBackPressed(); + } + private void documentPage(String title,Runnable back,String action,Runnable run) { + dismiss();shell();appearanceRefresh=null;navigateBack=back;LinearLayout bar=row(); + ImageButton arrow=new ImageButton(this);arrow.setId(R.id.navigate_back);arrow.setImageDrawable(new GlyphDrawable("back",INK,dp(24)));arrow.setBackground(shape(SURFACE,14));arrow.setContentDescription("Назад");arrow.setOnClickListener(v->{resetTimer();back.run();});bar.addView(arrow,new LinearLayout.LayoutParams(dp(46),dp(46))); + TextView caption=label(title,17,INK);caption.setId(R.id.page_title);caption.setTypeface(null,Typeface.BOLD);caption.setPadding(dp(14),0,0,0);bar.addView(caption,new LinearLayout.LayoutParams(0,-2,1)); + if(action!=null){Button button=button(bar,action,false,run);button.setLayoutParams(new LinearLayout.LayoutParams(-2,dp(46)));} + root.addView(bar);status=label("",12,MUTED);status.setId(R.id.status);status.setVisibility(View.GONE);root.addView(status);pageScroll=new ScrollView(this);pageScroll.setVerticalScrollBarEnabled(false);content=column();content.setPadding(0,dp(18),0,dp(30));content.setFocusableInTouchMode(true);pageScroll.addView(content);root.addView(pageScroll,new LinearLayout.LayoutParams(-1,0,1)); + } + private void detail(JSONObject entry) { + if(session==null||busy)return;resetTimer();screen="detail";currentDetail=entry; + try { + documentPage("Карточка",this::showHome,"Изменить",()->edit(entry)); + appearanceRefresh=()->detail(entry); + LinearLayout hero=row();ImageView icon=new ImageView(this);icon.setImageDrawable(new GlyphDrawable(CardTemplates.kind(entry),TEAL,dp(32)));icon.setPadding(dp(16),dp(16),dp(16),dp(16));icon.setBackground(shape(SOFT,20));hero.addView(icon,new LinearLayout.LayoutParams(dp(64),dp(64))); + LinearLayout summary=column();LinearLayout.LayoutParams sp=new LinearLayout.LayoutParams(0,-2,1);sp.leftMargin=dp(16);hero.addView(summary,sp); + TextView title=label(entry.optString("title"),24,INK);title.setTypeface(null,Typeface.BOLD);summary.addView(title);summary.addView(label(CardTemplates.title(CardTemplates.kind(entry)),13,MUTED));content.addView(hero); + gap(content,16);paragraph("Нажмите на поле, чтобы скопировать");gap(content,8); + JSONArray fields=entry.getJSONArray("fields"); + for(int i=0;i{resetTimer();visible[0]=!visible[0];text.setText(visible[0]?value:"•••• •••• ••••");reveal.setContentDescription((visible[0]?"Скрыть ":"Показать ")+name);});block.addView(reveal,new LinearLayout.LayoutParams(dp(46),dp(46))); + } + block.setContentDescription("Скопировать "+name);block.setOnClickListener(v->{resetTimer();if(!value.isEmpty()){v.performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK);copy(value);}}); + LinearLayout.LayoutParams bp=wrap();bp.bottomMargin=dp(10);content.addView(block,bp); + } + if(!entry.optString("notes").isEmpty()) { + gap(content,10);LinearLayout notes=column();notes.setPadding(dp(17),dp(13),dp(17),dp(13));notes.setBackground(shape(SURFACE,16));notes.addView(label("Заметки",12,MUTED));notes.addView(label(entry.optString("notes"),16,INK));content.addView(notes); + } + JSONArray tags=entry.optJSONArray("tags");if(tags!=null&&tags.length()>0){gap(content,12);content.addView(label(join(tags),13,TEAL));} + gap(content,20);button(content,entry.optBoolean("star")?"★ В избранном":"☆ Добавить в избранное",false,()->{ + try{mutate(Vault.copy(entry).put("star",!entry.optBoolean("star")));}catch(Exception e){error(message(e));} + }); + TextView remove=label("Удалить карточку",14,DANGER);remove.setGravity(Gravity.CENTER);remove.setPadding(dp(12),dp(18),dp(12),dp(18));remove.setOnClickListener(v->{resetTimer(); + dialog=new AlertDialog.Builder(this).setTitle("Удалить карточку?").setMessage("Удаление будет синхронизировано с Pi. Предыдущие зашифрованные версии сохраняются в резервных копиях.") + .setNegativeButton("Отмена",null).setPositiveButton("Удалить",(d,w)->{try{mutate(Vault.copy(entry).put("deleted",true));}catch(Exception e){error(message(e));}}).create();SecureWindows.show((AlertDialog)dialog); + });content.addView(remove); + }catch(Exception e){error(message(e));} + } + private void edit(JSONObject original) { + try{editDraft(original==null?CardTemplates.create("login"):Vault.copy(original),original==null);}catch(Exception e){error(message(e));} + } + private void editDraft(JSONObject draft,boolean isNew) { + editDraft(draft,isNew,null); + } + private void editDraft(JSONObject draft,boolean isNew,JSONObject baseline) { + if(session==null||busy)return; + try { + JSONObject original=baseline==null?Vault.copy(draft):baseline;screen="edit"; + Runnable[] save={null},back={null};documentPage(isNew?"Новая карточка":"Редактирование",()->back[0].run(),"Сохранить",()->save[0].run()); + JSONArray fields=draft.getJSONArray("fields"); + LinearLayout body=content;body.addView(label(CardTemplates.title(CardTemplates.kind(draft)),13,TEAL)); + body.addView(label("Название",13,MUTED));EditText title=input(body,"Например, личная почта",draft.optString("title"),false,R.id.entry_title);title.setTextSize(22);title.setTypeface(null,Typeface.BOLD); + gap(body,12); + List names=new ArrayList<>(),values=new ArrayList<>();List types=new ArrayList<>(); + LinearLayout fieldArea=column();body.addView(fieldArea); + for(int i=0;i{ + try {addField(fieldArea,Vault.field("","password",""),names,values,types);}catch(Exception e){error(message(e));} + }); + gap(body,16);body.addView(label("Метки",13,MUTED));EditText tags=input(body,"Например, личное, работа",join(draft.optJSONArray("tags")),false,R.id.entry_tags); + body.addView(label("Заметки",13,MUTED));EditText notes=input(body,"Заметки",draft.optString("notes"),false,R.id.entry_notes); + notes.setSingleLine(false);notes.setMinLines(3);notes.setInputType(android.text.InputType.TYPE_CLASS_TEXT|android.text.InputType.TYPE_TEXT_FLAG_MULTI_LINE|android.text.InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS); + Work capture=()->{ + JSONArray saved=new JSONArray();for(int i=0;i{try{capture.run();editDraft(draft,isNew,original);}catch(Exception e){error(message(e));}}; + save[0]=()->{ + if(busy)return; + if(title.getText().toString().trim().isEmpty()) {title.setError("Укажите название");return;} + try { + capture.run();draft.put("title",title.getText().toString().trim()); + mutate(draft); + }catch(Exception e){error(message(e));} + }; + back[0]=()->{ + JSONArray originalFields=original.optJSONArray("fields"); + if(busy)return;boolean dirty=!title.getText().toString().equals(original.optString("title"))||!notes.getText().toString().equals(original.optString("notes"))||!tags.getText().toString().equals(join(original.optJSONArray("tags")))||names.size()!=originalFields.length(); + for(int i=0;i{hideKeyboard(title);if(isNew)showHome();else detail(original);}; + if(!dirty){leave.run();return;} + LinearLayout question=column();question.addView(label("Несохранённые изменения карточки будут потеряны.",16,INK));showDialog("Выйти без сохранения?",question,"Не сохранять",leave); + }; + body.requestFocus(); + }catch(Exception e){error(message(e));} + } + private void addField(LinearLayout area,JSONObject f,List names,List values,List types) { + int index=names.size();LinearLayout block=column();block.setBackground(shape(SURFACE,16));block.setPadding(dp(14),dp(5),dp(14),dp(7)); + EditText name=input(block,"Название поля",f.optString("name"),false,3200+index);name.setTextSize(12);name.setTextColor(MUTED);name.setPadding(0,dp(6),0,0);name.setMinHeight(dp(32));name.setBackgroundColor(Color.TRANSPARENT); + EditText value=input(block,"Значение",f.optString("value"),Vault.secret(f),3100+index);value.setPadding(0,dp(4),0,dp(7));value.setTextSize(17);value.setBackgroundColor(Color.TRANSPARENT); + names.add(name);values.add(value);types.add(f.optString("type","password")); + if(f.optString("type").equals("password")) { + TextView generate=label("Сгенерировать пароль",13,TEAL);generate.setMinHeight(dp(48));generate.setGravity(Gravity.CENTER_VERTICAL);generate.setPadding(0,dp(5),0,dp(10));generate.setOnClickListener(v->{resetTimer();showPasswordGenerator(value);});block.addView(generate); + } + LinearLayout.LayoutParams bp=wrap();bp.bottomMargin=dp(12);area.addView(block,bp); + } + private void showPasswordGenerator(EditText target) { + if(session==null)return; + // Dismiss the editor keyboard so the preview and settings have the full dialog height. + ((InputMethodManager)getSystemService(INPUT_METHOD_SERVICE)).hideSoftInputFromWindow(target.getWindowToken(),0); + LinearLayout body=column();body.setSaveFromParentEnabled(false); + body.addView(label("Сгенерированный пароль",12,MUTED)); + TextView output=label("",20,INK);output.setId(R.id.generator_preview);output.setTypeface(Typeface.MONOSPACE); + if(Build.VERSION.SDK_INT>=29)output.setBreakStrategy(android.graphics.text.LineBreaker.BREAK_STRATEGY_HIGH_QUALITY); + output.setHyphenationFrequency(Layout.HYPHENATION_FREQUENCY_NONE); + output.setPadding(dp(14),dp(14),dp(14),dp(14));output.setBackground(shape(BG,12));output.setSaveEnabled(false); + output.setImportantForAutofill(View.IMPORTANT_FOR_AUTOFILL_NO_EXCLUDE_DESCENDANTS);output.setContentDescription("Сгенерированный пароль");body.addView(output); + gap(body,12);body.addView(label("Тип пароля",12,MUTED)); + RadioGroup modes=new RadioGroup(this);modes.setOrientation(RadioGroup.VERTICAL); + String[] titles={"Сложный","Только цифры","Запоминающийся"}; + int[] ids={R.id.generator_complex,R.id.generator_digits,R.id.generator_memorable}; + for(int i=0;i{ + int mode=selected[0],size=sizes[mode]; + output.setText(PasswordGenerator.generate(PasswordGenerator.Mode.values()[mode],size,symbols.isChecked(),digits.isChecked())); + String description=mode==2?"Слов: "+size+" · символов: "+output.length():"Длина: "+size+(mode==1?" цифр":" символов"); + lengthLabel.setText(description);length.setContentDescription(description); + }; + Runnable configure=()->{ + updating[0]=true;int mode=selected[0]; + length.setMin(0);length.setMax(128); + length.setMin(mode==0?16:mode==1?4:2);length.setMax(mode==2?12:64);length.setProgress(sizes[mode]); + symbols.setVisibility(mode!=1?View.VISIBLE:View.GONE);digits.setVisibility(mode==2?View.VISIBLE:View.GONE); + hint.setText(mode==0?"Заглавные и строчные латинские буквы, цифры. Похожие символы исключены.":mode==1? + "Цифры 0–9, в том числе в начале. Короткий код легче подобрать.": + "От 2 случайных английских слов. Цифры и знаки размещаются случайно перед словами, между ними или в конце. Слова остаются целыми."); + updating[0]=false;generate.run(); + }; + modes.setOnCheckedChangeListener((group,id)->{resetTimer();selected[0]=id==ids[0]?0:id==ids[1]?1:2;configure.run();}); + length.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener(){ + public void onProgressChanged(SeekBar bar,int value,boolean fromUser){if(!updating[0]){resetTimer();sizes[selected[0]]=value;generate.run();}} + public void onStartTrackingTouch(SeekBar bar){resetTimer();} + public void onStopTrackingTouch(SeekBar bar){ } + }); + symbols.setOnCheckedChangeListener((view,checked)->{resetTimer();generate.run();}); + digits.setOnCheckedChangeListener((view,checked)->{resetTimer();generate.run();}); + button(body,"Другой вариант",false,generate).setId(R.id.generator_regenerate);configure.run(); + showDialog("Генератор паролей",body,"Использовать пароль",()->{ + resetTimer();if(session!=null&&target.isAttachedToWindow()){target.setText(output.getText());target.setSelection(target.length());}dismiss(); + }); + AlertDialog generator=(AlertDialog)dialog;generator.getButton(AlertDialog.BUTTON_NEGATIVE).setText("Отмена"); + generator.setOnDismissListener(ignored->{output.setText("");if(dialog==generator)dialog=null;}); + } + private String join(JSONArray a) {StringBuilder s=new StringBuilder();if(a!=null)for(int i=0;i0)s.append(", ");s.append(a.optString(i));}return s.toString();} + private void mutate(JSONObject entry) { + if(session==null||busy)return;Session current=session; + task("Сохраняю…",()->{JSONObject next=Vault.copy(current.data),saved=Vault.applyEdit(next,entry);repository.save(next,current.key);return new JSONObject[]{next,saved};},result->{ + current.data=result[0];dismiss();notice="Сохранено на телефоне";if(result[1].optBoolean("deleted"))showHome();else detail(result[1]);autoSync(); + }); + } + private void autoSync() {handler.removeCallbacks(scheduledSync);if(repository.connected()&&repository.autoSync())handler.postDelayed(scheduledSync,1200);} + private void sync(boolean manual) { + if(session==null||busy)return; + if(!repository.connected()) {if(manual)settings();return;} + if(HomeWifiSync.homeNetwork(this,repository.homeSsid(),null,false)==null) { + String problem=HomeWifiSync.permissionProblem(this,false); + showStatus(problem.isEmpty()?"Ожидается домашний Wi-Fi «"+repository.homeSsid()+"»":problem);return; + } + handler.removeCallbacks(scheduledSync);Session current=session; + task("Синхронизация с Pi…",()->repository.sync(current.key),result->{ + current.data=result.data;notice=result.conflicts>0?"Синхронизировано. Сохранены конфликтующие версии: "+result.conflicts:"Синхронизировано с Pi · "+repository.lastSync(); + if(screen.equals("home"))showHome();else if(screen.equals("detail")&¤tDetail!=null){JSONObject updated=findEntry(currentDetail.optString("id"));if(updated==null||updated.optBoolean("deleted"))showHome();else detail(updated);} + }); + } + private JSONObject findEntry(String id) { + if(session==null)return null;JSONArray entries=session.data.optJSONArray("entries");if(entries!=null)for(int i=0;i{try{ClipDescription d=clipboard.getPrimaryClipDescription();if(d!=null&&token.contentEquals(d.getLabel()))clipboard.clearPrimaryClip();}catch(RuntimeException ignored){}},30_000); + } + private void settings() { + if(session==null||busy)return; + int position=screen.equals("menu")&&pageScroll!=null?pageScroll.getScrollY():0; + documentPage("Меню",this::showHome,null,null);screen="menu";appearanceRefresh=this::settings; + content.setPadding(0,0,0,dp(30)); + LinearLayout appearance=settingsSection("Внешний вид"); + settingsItem(appearance,"Тема приложения",themeTitle(),R.id.theme_settings,this::themeSettings); + LinearLayout sync=settingsSection("Синхронизация"); + settingsItem(sync,"Подключение Raspberry Pi",repository.connected()?repository.url():"Укажите сервер или выберите файл подключения",R.id.connection_settings,this::manualConnection); + settingsToggle(sync,"Автосинхронизация","После входа и сохранения карточек",repository.autoSync(),R.id.auto_sync,(control,value)->saveMenuSetting(()->{repository.setAutoSync(value);return true;},this::autoSync)); + String backgroundProblem=repository.homeWifiEnabled()?HomeWifiSync.permissionProblem(this,true):""; + settingsToggle(sync,"Фоновая синхронизация",backgroundProblem.isEmpty()?"В домашнем Wi-Fi, даже когда QSafe закрыт":backgroundProblem,repository.homeWifiEnabled(),R.id.background_sync,(control,value)->{ + if(value&&!repository.connected()){manualConnection(true);return;} + if(value&&(repository.homeSsid().isEmpty()||!HomeWifiSync.permissionProblem(this,true).isEmpty())){homeWifiSettings(true,null);return;} + saveMenuSetting(()->{repository.setHomeWifi(value,repository.homeSsid());HomeWifiSync.install(this,repository);return true;},null); + }); + settingsItem(sync,"Домашний Wi-Fi",repository.homeSsid().isEmpty()?"Имя сети и разрешения Android":repository.homeSsid(),R.id.home_wifi_settings,()->homeWifiSettings(false,null)); + settingsItem(sync,"Синхронизировать сейчас",syncCaption(),R.id.sync,()->{if(repository.connected())sync(true);else manualConnection();}); + LinearLayout security=settingsSection("Защита"); + settingsToggle(security,"Вход по отпечатку",biometrics.available()?"Разблокировка системной биометрией":"Для включения настройте отпечаток в Android",biometrics.enabled(),R.id.fingerprint,(control,value)->{ + if(value)enableFingerprint();else saveMenuSetting(()->{biometrics.disable();return true;},null); + }); + android.view.autofill.AutofillManager autofill=getSystemService(android.view.autofill.AutofillManager.class); + settingsToggle(security,"Автозаполнение","Пароли в приложениях и поддерживаемых браузерах",autofill!=null&&autofill.hasEnabledAutofillServices(),R.id.autofill_enabled,(control,value)->{ + if(!value){ + saveMenuSetting(()->{ + if(autofill!=null){autofill.disableAutofillServices();for(int attempt=0;attempt<30&&autofill.hasEnabledAutofillServices();attempt++)Thread.sleep(100);if(autofill.hasEnabledAutofillServices())throw new IOException("Android ещё не отключил автозаполнение. Повторите попытку.");} + return true; + },null);return; + } + try{settings();refreshMenuOnResume=true;startActivity(new Intent(android.provider.Settings.ACTION_REQUEST_SET_AUTOFILL_SERVICE,Uri.parse("package:"+getPackageName())));} + catch(ActivityNotFoundException e){refreshMenuOnResume=false;settings();error("Откройте системные настройки автозаполнения Android");} + }); + settingsItem(security,"Заблокировать хранилище","При уходе из приложения блокировка включается автоматически",R.id.lock_vault,()->lock("")); + LinearLayout data=settingsSection("Данные"); + settingsItem(data,"Импорт из SafeInCloud","Перенос карточек из XML",R.id.import_settings,this::importHelp); + settingsItem(data,"Резервные копии","Экспорт и восстановление зашифрованного .qsafe",R.id.backup_settings,this::backupSettings); + LinearLayout app=settingsSection("Приложение"); + settingsItem(app,"Проверить обновления","QSafe "+BuildConfig.VERSION_NAME+" · Аргус",R.id.check_updates,this::checkUpdates); + TextView footer=label("Блокировка через 2 минуты бездействия. Буфер обмена очищается через 30 секунд.",12,MUTED);footer.setPadding(dp(4),dp(14),dp(4),dp(4));content.addView(footer); + ScrollView menuScroll=pageScroll;menuScroll.post(()->menuScroll.scrollTo(0,position)); + } + private LinearLayout settingsSection(String title) { + TextView heading=label(title,13,MUTED);heading.setTypeface(null,Typeface.BOLD);heading.setPadding(dp(4),dp(18),0,dp(9));content.addView(heading); + LinearLayout section=column();section.setPadding(dp(8),dp(3),dp(8),dp(3));section.setBackground(shape(SURFACE,16));content.addView(section);return section; + } + private LinearLayout settingsRow(LinearLayout section,String title,String subtitle) { + if(section.getChildCount()>0){View divider=new View(this);divider.setBackgroundColor(LINE);LinearLayout.LayoutParams line=wrap();line.height=dp(1);line.leftMargin=dp(10);line.rightMargin=dp(10);section.addView(divider,line);} + LinearLayout item=row();item.setPadding(dp(10),dp(12),dp(8),dp(12));item.setMinimumHeight(dp(70)); + item.setBackground(new RippleDrawable(ColorStateList.valueOf(darkTheme?0x2478D5BF:0x18087F70),shape(SURFACE,12),null)); + LinearLayout text=column();TextView name=label(title,15,INK);name.setTypeface(null,Typeface.BOLD);text.addView(name);if(!subtitle.isEmpty())text.addView(label(subtitle,12,MUTED)); + item.addView(text,new LinearLayout.LayoutParams(0,-2,1));section.addView(item,wrap());return item; + } + private void settingsItem(LinearLayout section,String title,String subtitle,int id,Runnable action) { + LinearLayout item=settingsRow(section,title,subtitle);item.setId(id);item.setContentDescription(title); + ImageView arrow=new ImageView(this);arrow.setImageDrawable(new GlyphDrawable("chevron",MUTED,dp(18)));item.addView(arrow,new LinearLayout.LayoutParams(dp(30),dp(26))); + item.setOnClickListener(v->{resetTimer();if(!busy)action.run();}); + } + private void settingsToggle(LinearLayout section,String title,String subtitle,boolean value,int id,BiConsumer change) { + LinearLayout item=settingsRow(section,title,subtitle);Switch control=new Switch(this);control.setId(id);control.setContentDescription(title);control.setChecked(value);control.setSaveEnabled(false); + control.setMinimumHeight(dp(48));LinearLayout.LayoutParams p=new LinearLayout.LayoutParams(-2,dp(48));p.leftMargin=dp(10);item.addView(control,p); + boolean[] binding={false};control.setOnCheckedChangeListener((button,checked)->{ + if(binding[0])return;resetTimer(); + if(busy){binding[0]=true;control.setChecked(!checked);binding[0]=false;return;} + control.setEnabled(false);change.accept(control,checked); + }); + item.setOnClickListener(v->{if(control.isEnabled())control.performClick();}); + } + private void saveMenuSetting(Work save,Runnable after) { + Runnable refresh=()->{if(screen.equals("menu")&&session!=null)settings();}; + task("Сохраняю настройки…",save,ignored->{if(after!=null)after.run();refresh.run();},refresh); + } + private String themeTitle() { + String mode=AppAppearance.mode(this);return mode.equals(AppAppearance.DARK)?"Тёмная":mode.equals(AppAppearance.LIGHT)?"Светлая":"Как в системе"; + } + private void themeSettings() { + if(session==null||busy)return;documentPage("Тема приложения",this::settings,null,null);screen="theme";appearanceRefresh=this::themeSettings; + paragraph("Выберите оформление. Оно применяется сразу и сохраняется на этом устройстве."); + RadioGroup options=new RadioGroup(this);content.addView(options); + String[] titles={"Как в системе","Светлая","Тёмная"},modes={AppAppearance.SYSTEM,AppAppearance.LIGHT,AppAppearance.DARK}; + int[] ids={R.id.theme_system,R.id.theme_light,R.id.theme_dark}; + for(int i=0;i{resetTimer();String selected=id==ids[0]?modes[0]:id==ids[1]?modes[1]:modes[2];AppAppearance.save(this,selected);applyAppearance();themeSettings();}); + gap(content,16);paragraph("Режим «Как в системе» следует светлой или тёмной теме Android."); + } + private void backupSettings() { + if(session==null||busy)return;documentPage("Резервные копии",this::settings,null,null);screen="backups";appearanceRefresh=this::backupSettings; + paragraph("Копии .qsafe зашифрованы мастер-паролем. Для восстановления нужен пароль выбранной копии."); + button(content,"Сохранить копию .qsafe",true,()->task("Подготавливаю копию…",repository::encrypted,bytes->{ + pendingExport=bytes;Intent i=new Intent(Intent.ACTION_CREATE_DOCUMENT).setType("application/octet-stream").addCategory(Intent.CATEGORY_OPENABLE).putExtra(Intent.EXTRA_TITLE,"QSafe-"+new java.text.SimpleDateFormat("yyyy-MM-dd",Locale.ROOT).format(new Date())+".qsafe");startActivityForResult(i,EXPORT); + })).setId(R.id.export_backup); + button(content,"Восстановить из файла .qsafe",false,()->pick(RESTORE,"*/*")).setId(R.id.restore_backup); + gap(content,20);paragraph("На телефоне автоматически сохраняются последние 10 зашифрованных версий базы."); + } + private void offerFingerprint() { + if(session==null||isAutofill()||!screen.equals("home")||(dialog!=null&&dialog.isShowing())||biometrics.enabled()||repository.fingerprintOffered()||!biometrics.available())return; + if(busy){handler.postDelayed(this::offerFingerprint,500);return;} + SyncRuntime.IO.execute(()->{try{repository.fingerprintOffered(true);}catch(Exception ignored){}}); + LinearLayout body=column();body.addView(label("Открывайте карточки касанием датчика. Мастер-пароль сохранится как способ входа и восстановления.",16,INK)); + body.addView(label("Включить или отключить вход по отпечатку можно в меню ☰.",14,MUTED)); + showDialog("Вход по отпечатку",body,"Включить",this::enableFingerprint); + } + private void enableFingerprint() { + if(session==null||busy)return; + if(!biometrics.available()) { + documentPage("Вход по отпечатку",this::settings,null,null);screen="fingerprint_setup";appearanceRefresh=this::enableFingerprint; + paragraph("Добавьте отпечаток пальца в настройках безопасности Android, затем включите вход в меню QSafe."); + button(content,"Открыть настройки Android",true,()->{try{ + Intent intent=Build.VERSION.SDK_INT>=30?new Intent(android.provider.Settings.ACTION_BIOMETRIC_ENROLL).putExtra(android.provider.Settings.EXTRA_BIOMETRIC_AUTHENTICATORS_ALLOWED,android.hardware.biometrics.BiometricManager.Authenticators.BIOMETRIC_STRONG):new Intent(android.provider.Settings.ACTION_SECURITY_SETTINGS);startActivity(intent); + }catch(ActivityNotFoundException e){error("Откройте настройки отпечатков пальцев Android");}});return; + } + Session current=session;int started=generation;boolean fromMenu=screen.equals("menu"); + biometrics.enable(current.key,new Biometrics.Result() { + public void done(VaultCrypto.Key ignored){if(started==generation&&session==current){dismiss();notice="Вход по отпечатку включён";if(fromMenu)settings();else showHome();}} + public void error(String m){if(started==generation){if(fromMenu&&session==current)settings();MainActivity.this.error(m);}} + }); + } + private void checkUpdates() { + if(busy)return; + task("Проверяю обновления…",AppUpdates::check,release->{ + LinearLayout body=column(); + if(!release.newerThan(BuildConfig.VERSION_CODE)) { + body.addView(label("Установлена актуальная версия QSafe "+BuildConfig.VERSION_NAME,17,INK));showDialog("Обновлений нет",body,null,null);return; + } + body.addView(label("QSafe "+release.version,21,INK));body.addView(label(release.notes,15,MUTED)); + body.addView(label(String.format(Locale.ROOT,"Размер: %.1f МБ",release.size/1048576.0),13,MUTED)); + showDialog("Доступно обновление",body,"Скачать и установить",()->task("Загружаю обновление…",()->AppUpdates.download(this,release),file->{downloadedUpdate=release;dismiss();requestUpdateInstall();})); + }); + } + private void requestUpdateInstall() { + if(downloadedUpdate==null)return; + if(getPackageManager().canRequestPackageInstalls()){installDownloadedUpdate();return;} + LinearLayout body=column();body.addView(label("APK проверен. Разрешите QSafe устанавливать обновления в следующем системном окне. Установку каждой версии подтверждает Android.",16,INK)); + showDialog("Разрешение на обновления",body,"Открыть настройки",()->{ + dismiss();resumeUpdateInstall=true; + try{startActivity(new Intent(android.provider.Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,Uri.parse("package:"+getPackageName())));} + catch(ActivityNotFoundException e){resumeUpdateInstall=false;error("Не удалось открыть разрешение на установку обновлений");} + }); + } + private void installDownloadedUpdate() { + if(downloadedUpdate==null||busy)return;UpdateManifest release=downloadedUpdate; + task("Проверяю APK…",()->{AppUpdates.verify(this,AppUpdates.apk(this),release);return true;},ok->{ + try{if(getPackageManager().canRequestPackageInstalls())startActivity(AppUpdates.installer(this));else requestUpdateInstall();} + catch(ActivityNotFoundException e){error("Установщик Android недоступен");} + }); + } + private void manualConnection() { + manualConnection(false); + } + private void manualConnection(boolean enableBackground) { + manualConnection(enableBackground,repository.url(),"",repository.homeSsid(),repository.certificateSha256()); + } + private void manualConnection(boolean enableBackground,String addressDraft,String tokenDraft,String ssidDraft,String pinDraft) { + if(session==null||busy)return; + Runnable[] save={null};documentPage("Подключение Pi",this::settings,"Сохранить",()->save[0].run());screen="connection"; + paragraph(enableBackground?"Сначала настройте подключение Pi. После сохранения включится фоновая синхронизация.":"Связь с базой разрешена только внутри домашнего Wi-Fi. Мастер-пароль сюда вводить не нужно."); + button(content,"Выбрать файл подключения Pi",false,()->pick(CONNECTION,"*/*")).setId(R.id.connection_file); + content.addView(label("Локальный HTTPS-адрес Pi",13,MUTED));EditText url=input(content,"https://192.168…",addressDraft,false,R.id.connection_url); + content.addView(label(repository.connected()?"Ключ подключения · пустое поле сохраняет прежний ключ":"Ключ подключения",13,MUTED));EditText token=input(content,"Ключ доступа Pi",tokenDraft,true,R.id.connection_token); + content.addView(label("Домашний Wi-Fi (SSID)",13,MUTED));EditText ssid=input(content,"Точное имя сети",ssidDraft,false,R.id.home_ssid); + content.addView(label("SHA-256 сертификата Pi",13,MUTED));EditText pin=input(content,"64 шестнадцатеричных символа",pinDraft,false,R.id.connection_pin); + appearanceRefresh=()->manualConnection(enableBackground,url.getText().toString(),token.getText().toString(),ssid.getText().toString(),pin.getText().toString()); + save[0]=()->{ + if(busy)return; + String address=url.getText().toString(),credential=token.getText().toString(),home=ssid.getText().toString(),fingerprint=pin.getText().toString(); + task("Сохраняю подключение…",()->{repository.updateLan(address,credential,home,fingerprint);if(enableBackground)repository.setHomeWifi(true,home);HomeWifiSync.install(this,repository);return true;},v->{hideKeyboard(token);token.setText("");notice="Локальное подключение сохранено";settings();autoSync();}); + }; + } + private void homeWifiSettings() { + homeWifiSettings(enableWifiOnSave,wifiDraft); + } + private void homeWifiSettings(boolean enableOnSave,String draft) { + if(repository==null||busy)return; + enableWifiOnSave=enableOnSave;Runnable[] save={null}; + documentPage("Домашний Wi-Fi",()->{enableWifiOnSave=false;if(session==null)showLocked();else settings();},"Сохранить",()->save[0].run());screen="wifi"; + LinearLayout body=content; + body.addView(label(enableOnSave?"Укажите домашнюю сеть. После сохранения включится фоновая синхронизация.":"Связь с базой разрешена только в этой сети, включая ручную синхронизацию. Переключатель фоновой синхронизации находится в меню.",15,INK)); + String current=HomeWifiSync.currentSsid(this); + EditText ssid=input(body,"Имя домашнего Wi-Fi (SSID)",draft!=null?draft:repository.homeSsid().isEmpty()?current:repository.homeSsid(),false,R.id.home_ssid); + wifiDraft=ssid.getText().toString();ssid.addTextChangedListener(new TextWatcher(){public void beforeTextChanged(CharSequence s,int a,int c,int f){}public void onTextChanged(CharSequence s,int a,int b,int c){wifiDraft=s.toString();}public void afterTextChanged(Editable e){}}); + appearanceRefresh=()->homeWifiSettings(enableOnSave,ssid.getText().toString()); + button(body,"Использовать текущую сеть",false,()->{ + String name=HomeWifiSync.currentSsid(this); + if(name.isEmpty())error(HomeWifiSync.permissionProblem(this,false).isEmpty()?"Подключитесь к домашнему Wi-Fi":HomeWifiSync.permissionProblem(this,false)); + else ssid.setText(name); + }); + String problem=HomeWifiSync.permissionProblem(this,true); + body.addView(label(problem.isEmpty()?"Разрешения для распознавания сети доступны":problem,14,MUTED)); + body.addView(label("Android считает имя Wi-Fi данными о местоположении. QSafe не запрашивает координаты и не сохраняет историю перемещений.",14,MUTED)); + if(!HomeWifiSync.locationAllowed(this))button(body,"Разрешить доступ к имени Wi-Fi",false,()->requestPermissions(new String[]{android.Manifest.permission.ACCESS_FINE_LOCATION,android.Manifest.permission.ACCESS_COARSE_LOCATION},60)); + if(android.os.Build.VERSION.SDK_INT>=29&&HomeWifiSync.locationAllowed(this)&&!HomeWifiSync.backgroundAllowed(this)) { + button(body,"Разрешить распознавание Wi-Fi в фоне",false,()->{ + if(android.os.Build.VERSION.SDK_INT==29)requestPermissions(new String[]{android.Manifest.permission.ACCESS_BACKGROUND_LOCATION},61); + else { + dismiss();AlertDialog prompt=new AlertDialog.Builder(this).setTitle("Доступ в фоне") + .setMessage("В настройках приложения откройте Разрешения → Местоположение → Разрешить всегда. Это нужно Android для проверки имени Wi-Fi, когда QSafe закрыт.") + .setNegativeButton("Позже",null).setPositiveButton("Открыть настройки",(d,w)->{returnHomeSettings=true;startActivity(new Intent(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS,Uri.parse("package:"+getPackageName())));}).create(); + dialog=prompt;SecureWindows.show(prompt); + } + }); + } + button(body,"Настройки геолокации Android",false,()->{returnHomeSettings=true;startActivity(new Intent(android.provider.Settings.ACTION_LOCATION_SOURCE_SETTINGS));}); + body.addView(label("Новые копии с Pi проверяются мастер-ключом при следующем входе. При одновременных правках сохраняются обе версии карточки. Android может отложить фоновую задачу ради экономии заряда.",14,MUTED)); + if(!repository.backgroundStatus().isEmpty())body.addView(label(repository.backgroundStatus(),14,MUTED)); + save[0]=()->{ + if(busy)return;String name=CipherSync.ssid(ssid.getText().toString()); + if(name.isEmpty()||name.getBytes(StandardCharsets.UTF_8).length>32){ssid.setError("Укажите имя домашней сети длиной до 32 байт");return;} + boolean value=enableOnSave||repository.homeWifiEnabled(); + task("Сохраняю настройки Wi-Fi…",()->{repository.setHomeWifi(value,name);HomeWifiSync.install(this,repository);return true;},v->{hideKeyboard(ssid);enableWifiOnSave=false;wifiDraft=null;notice="Настройки домашней сети сохранены";if(session==null)showLocked();else settings();}); + }; + } + @Override public void onRequestPermissionsResult(int request,String[] permissions,int[] grants) { + super.onRequestPermissionsResult(request,permissions,grants); + if(request==60||request==61){homeWifiSettings();SyncRuntime.IO.execute(()->{try{HomeWifiSync.install(this,repository);}catch(Exception ignored){}});} + } + private void importHelp() { + if(session==null||busy)return;documentPage("Импорт из SafeInCloud",this::settings,null,null);screen="import";appearanceRefresh=this::importHelp; + LinearLayout body=content;body.addView(label("Экспортируйте базу SafeInCloud в XML, затем выберите этот файл. Карточки, поля, заметки и метки будут добавлены к текущей базе после проверки.",16,INK)); + body.addView(label("XML содержит открытые пароли. Не отправляйте его в чат или облако. После проверки переноса удалите экспорт с устройств. Исходную базу SafeInCloud пока сохраните.",14,MUTED)); + body.addView(label("Если на телефоне нет XML-экспорта, используйте SafeInCloud для Windows: Файл → Экспорт → XML.",14,MUTED)); + button(body,"Выбрать XML",true,()->pick(IMPORT,"*/*")); + } + private void pick(int request,String type) {if(busy)return;dismiss();Intent i=new Intent(Intent.ACTION_OPEN_DOCUMENT).setType(type).addCategory(Intent.CATEGORY_OPENABLE);startActivityForResult(i,request);} + @Override protected void onActivityResult(int request,int result,Intent intent) { + super.onActivityResult(request,result,intent);if(result!=RESULT_OK||intent==null||intent.getData()==null)return;Uri uri=intent.getData(); + if(request==CONNECTION) { + task("Читаю подключение Pi…",()->{ + byte[] bytes=Repository.bounded(getContentResolver().openInputStream(uri),16*1024); + JSONObject c=new JSONObject(new String(bytes,StandardCharsets.UTF_8)); + if(!c.optString("kind").equals("qsafe-connection")||c.optInt("version")!=1)throw new IOException("Это не файл подключения QSafe"); + Repository.validateUrl(c.getString("url"));LanPolicy.identity(c.optString("homeWifiSsid"),c.optString("certificateSha256"));return c; + },c->{ + LinearLayout body=column();body.addView(label("Подключить хранилище к серверу:",16,INK));body.addView(label(c.optString("url"),16,TEAL)); + showDialog("Ваш Raspberry Pi",body,"Подключить",()->task("Сохраняю подключение…",()->{ + repository.configureLan(c.getString("url"),c.getString("token"),c.getString("homeWifiSsid"),c.getString("certificateSha256")); + if(!c.optString("homeWifiSsid").isEmpty())repository.setHomeWifi(true,c.getString("homeWifiSsid")); + HomeWifiSync.install(this,repository);return true; + },v->{dismiss();notice="Pi подключён. Создайте базу или восстановите её с сервера.";showLocked();})); + }); + } else if(request==IMPORT) {pendingImport=uri;notice="Разблокируйте хранилище для импорта XML";showLocked();} + else if(request==EXPORT) { + byte[] data=pendingExport;pendingExport=null;if(data==null){error("Повторите создание резервной копии");return;} + task("Сохраняю зашифрованную копию…",()->{try(OutputStream out=getContentResolver().openOutputStream(uri,"wt")){if(out==null)throw new IOException("Не удалось открыть файл");out.write(data);}return true;},v->error("Зашифрованная копия сохранена")); + } else if(request==RESTORE) restoreDialog(uri); + } + private void importXml(Uri uri) { + if(session==null)return;Session current=session; + task("Проверяю XML…",()->SafeInCloudImport.preview(Repository.bounded(getContentResolver().openInputStream(uri),24*1024*1024),current.data),preview->{ + LinearLayout body=column();body.addView(label("Новых карточек: "+preview.added+"\nПовторных: "+preview.duplicates+"\nУдалённых и шаблонов пропущено: "+preview.skipped+"\nИсходных вложений сохранено: "+preview.attachments,17,INK)); + body.addView(label("Открытые пароли будут зашифрованы перед сохранением. Исходный файл не изменяется.",14,MUTED)); + showDialog("Проверка переноса",body,preview.added>0?"Импортировать":null,()->{ + task("Шифрую импортированные карточки…",()->{repository.save(preview.vault,current.key);return preview.vault;},data->{current.data=data;dismiss();notice="Перенесено карточек: "+preview.added;showHome();autoSync();}); + }); + }); + } + private void restoreDialog(Uri uri) { + restoreDialog(uri,""); + } + private void restoreDialog(Uri uri,String draftPassword) { + Runnable[] restore={null};documentPage("Восстановление базы",()->{if(session==null)showLocked();else backupSettings();},"Восстановить",()->restore[0].run());screen="restore"; + LinearLayout body=content;body.addView(label("Введите мастер-пароль выбранной копии. Текущая локальная база будет заменена; её зашифрованная резервная копия сохранится на телефоне.",15,INK)); + EditText password=input(body,"Мастер-пароль копии",draftPassword,true,R.id.restore_password); + appearanceRefresh=()->restoreDialog(uri,password.getText().toString()); + restore[0]=()->{ + if(busy)return;char[] pass=password.getText().toString().toCharArray();password.setText(""); + task("Проверяю и восстанавливаю копию…",()->{ + try {byte[] data=Repository.bounded(getContentResolver().openInputStream(uri),VaultCrypto.MAX_BYTES);repository.restoreFile(data,pass);biometrics.disable();return true;} + finally{Arrays.fill(pass,'\0');} + },v->lock("Копия восстановлена. Введите её мастер-пароль.")); + }; + } + protected boolean isAutofill() {return false;} + protected void showAutofill() { } + protected JSONObject unlockedData() {return session==null?null:session.data;} +} diff --git a/app/src/main/java/ru/kusoft/qsafe/PasswordGenerator.java b/app/src/main/java/ru/kusoft/qsafe/PasswordGenerator.java new file mode 100644 index 0000000..41170da --- /dev/null +++ b/app/src/main/java/ru/kusoft/qsafe/PasswordGenerator.java @@ -0,0 +1,56 @@ +package ru.kusoft.qsafe; + +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.util.*; + +/** Offline generation; each digit and word is sampled independently with SecureRandom. */ +public final class PasswordGenerator { + public enum Mode { COMPLEX, DIGITS, MEMORABLE } + private static final SecureRandom RANDOM=new SecureRandom(); + private static final String[] WORDS=loadWords(); + private PasswordGenerator() { } + + private static String[] loadWords() { + try(InputStream stream=PasswordGenerator.class.getResourceAsStream("/password-words.txt")) { + if(stream==null)throw new IOException("Missing password word list"); + ByteArrayOutputStream bytes=new ByteArrayOutputStream();byte[] buffer=new byte[4096];int n; + while((n=stream.read(buffer))!=-1)bytes.write(buffer,0,n); + String[] words=bytes.toString(StandardCharsets.UTF_8.name()).trim().split("\\s+"); + if(words.length!=256||new HashSet<>(Arrays.asList(words)).size()!=256)throw new IOException("Invalid password word list"); + return words; + }catch(IOException e){throw new IllegalStateException(e);} + } + + public static String generate(Mode mode,int length,boolean symbols) { + return generate(mode,length,symbols,true); + } + public static String generate(Mode mode,int length,boolean symbols,boolean digits) { + Objects.requireNonNull(mode); + if(mode==Mode.COMPLEX)return VaultCrypto.generate(length,symbols); + if(mode==Mode.DIGITS) { + if(length<4||length>128)throw new IllegalArgumentException("Digit count must be 4–128"); + StringBuilder value=new StringBuilder(length); + for(int i=0;i12)throw new IllegalArgumentException("Word count must be 2–12"); + String signs="!@#$%&*+-=?"; + // Only the boundaries around whole words can receive extra characters. + StringBuilder[] boundaries=new StringBuilder[length+1]; + for(int i=0;i{try{HomeWifiSync.install(this,Repository.get(this));}catch(Exception ignored){}}); + } +} diff --git a/app/src/main/java/ru/kusoft/qsafe/QSafeAutofillService.java b/app/src/main/java/ru/kusoft/qsafe/QSafeAutofillService.java new file mode 100644 index 0000000..2920f00 --- /dev/null +++ b/app/src/main/java/ru/kusoft/qsafe/QSafeAutofillService.java @@ -0,0 +1,63 @@ +package ru.kusoft.qsafe; + +import android.app.PendingIntent; +import android.app.assist.AssistStructure; +import android.content.Intent; +import android.os.CancellationSignal; +import android.service.autofill.*; +import android.view.View; +import android.view.autofill.AutofillId; +import android.widget.RemoteViews; +import java.util.*; + +/** Authentication is required for each fill request. Never reads an unlocked vault in the background. */ +public final class QSafeAutofillService extends AutofillService { + private static final class Fields { + final ArrayList logins=new ArrayList<>(),passwords=new ArrayList<>(); + String domain="";boolean ambiguous=false;int visited=0; + } + @Override public void onFillRequest(FillRequest request,CancellationSignal cancellation,FillCallback callback) { + if(cancellation.isCanceled())return; + try { + List contexts=request.getFillContexts();if(contexts.isEmpty()){callback.onSuccess(null);return;} + AssistStructure s=contexts.get(contexts.size()-1).getStructure(); + if(s.getActivityComponent()==null||getPackageName().equals(s.getActivityComponent().getPackageName())){callback.onSuccess(null);return;} + Fields f=new Fields();for(int i=0;i8||f.logins.size()>8){callback.onSuccess(null);return;} + if(!f.domain.isEmpty()) { + // A third-party app must not impersonate a website by supplying its own webDomain. + Intent browser=new Intent(Intent.ACTION_VIEW,android.net.Uri.parse("https://example.com")).addCategory(Intent.CATEGORY_BROWSABLE); + android.content.pm.ResolveInfo resolved=getPackageManager().resolveActivity(browser,android.content.pm.PackageManager.MATCH_DEFAULT_ONLY); + if(resolved==null||resolved.activityInfo==null||!resolved.activityInfo.packageName.equals(s.getActivityComponent().getPackageName())) {callback.onSuccess(null);return;} + } + Intent intent=new Intent(this,AutofillActivity.class).putParcelableArrayListExtra("logins",f.logins) + .putParcelableArrayListExtra("passwords",f.passwords).putExtra("domain",f.domain) + .putExtra("targetPackage",s.getActivityComponent().getPackageName()); + // A unique intent isolates simultaneous autofill requests and their origin/IDs. + intent.setData(android.net.Uri.parse("qsafe-autofill://request/"+UUID.randomUUID())); + PendingIntent pending=PendingIntent.getActivity(this,0,intent,PendingIntent.FLAG_CANCEL_CURRENT|PendingIntent.FLAG_IMMUTABLE); + ArrayList all=new ArrayList<>(f.logins);all.addAll(f.passwords); + RemoteViews presentation=new RemoteViews(getPackageName(),android.R.layout.simple_list_item_1); + presentation.setTextViewText(android.R.id.text1,"QSafe · разблокировать пароли"); + callback.onSuccess(new FillResponse.Builder().setAuthentication(all.toArray(new AutofillId[0]),pending.getIntentSender(),presentation).build()); + }catch(RuntimeException e){callback.onSuccess(null);} + } + private void walk(AssistStructure.ViewNode node,Fields f,int depth) { + if(depth>50||++f.visited>2000)return; + String domain=node.getWebDomain(); + if(domain!=null&&!domain.isEmpty()) { + if(!"https".equalsIgnoreCase(node.getWebScheme()))f.ambiguous=true; + domain=domain.toLowerCase(Locale.ROOT); + if(!f.domain.isEmpty()&&!f.domain.equals(domain))f.ambiguous=true;else f.domain=domain; + } + if(node.getAutofillId()!=null&&node.getVisibility()==View.VISIBLE) { + String[] hints=node.getAutofillHints(); + if(hints!=null) for(String hint:hints) { + if(hint.equalsIgnoreCase("password")) {if(!f.passwords.contains(node.getAutofillId()))f.passwords.add(node.getAutofillId());} + else if(hint.equalsIgnoreCase("username")||hint.equalsIgnoreCase("emailAddress")) {if(!f.logins.contains(node.getAutofillId()))f.logins.add(node.getAutofillId());} + } + } + for(int i=0;i32))throw new IOException("Укажите имя Wi-Fi длиной до 32 байт"); + replaceConfig(new JSONObject(config.toString()).put("homeWifiEnabled",enabled).put("homeSsid",name)); + } + public synchronized void backgroundStatus(String status) throws Exception {config.put("backgroundStatus",status);saveConfig();} + public synchronized void setAutoSync(boolean value) throws Exception {replaceConfig(new JSONObject(config.toString()).put("autoSync",value));} + public static String validateUrl(String address) throws Exception { + return LanPolicy.url(address); + } + public synchronized void configure(String address,String token) throws Exception { + configureLan(address,token,homeSsid(),certificateSha256()); + } + public synchronized void updateLan(String address,String token,String ssid,String pin) throws Exception { + configureLan(address,token.isEmpty()?config.optString("token"):token,ssid,pin); + } + public synchronized void configureLan(String address,String token,String ssid,String pin) throws Exception { + String normalized=validateUrl(address); + LanPolicy.identity(ssid,pin); + if(!token.matches("[A-Za-z0-9_-]{32,128}")) throw new IOException("Неверный ключ подключения"); + if(!normalized.equals(url()) || !token.equals(config.optString("token"))) { + config.remove("etag");config.remove("lastSync");base.delete();pending.delete(); + } + config.put("url",normalized).put("token",token).put("homeSsid",ssid).put("certificateSha256",pin.toLowerCase(Locale.ROOT));saveConfig(); + } + public boolean exists() {return vault.getBaseFile().exists();} + public byte[] encrypted() throws IOException {return vault.readFully();} + public JSONObject read(VaultCrypto.Key key) throws Exception { + byte[] plain=VaultCrypto.decrypt(encrypted(),key); + try {return Vault.parse(plain);} finally {Arrays.fill(plain,(byte)0);} + } + public void save(JSONObject data,VaultCrypto.Key key) throws Exception { + byte[] plain=Vault.bytes(data); + try {saveEncrypted(VaultCrypto.encrypt(plain,key));} finally {Arrays.fill(plain,(byte)0);} + } + private void snapshot() throws IOException { + if(!exists()) return; + File dir=new File(directory,"backups");if(!dir.exists()&&!dir.mkdirs()) throw new IOException("Не удалось сохранить резервную копию"); + File next=new File(dir,System.currentTimeMillis()+"-"+UUID.randomUUID().toString().substring(0,8)+".qsafe"); + write(new AtomicFile(next),encrypted());File[] files=dir.listFiles((d,n)->n.endsWith(".qsafe")); + if(files!=null) {Arrays.sort(files,Comparator.comparing(File::getName).reversed());for(int i=10;ilimit) throw new IOException("Файл слишком большой");out.write(buffer,0,count);} + return out.toByteArray(); + } + } + private static final class Response { + int code;byte[] body;String etag; + } + private Response request(String method,byte[] body,String etag) throws Exception { + return request(method,body,etag,null); + } + private Response request(String method,byte[] body,String etag,Network network) throws Exception { + String address,token,ssid,pin;synchronized(this) {address=url();token=config.optString("token");ssid=homeSsid();pin=certificateSha256();} + if(token.isEmpty()) throw new IOException("Сначала подключите Pi в настройках"); + address=validateUrl(address);LanPolicy.identity(ssid,pin); + Network home=HomeWifiSync.homeNetwork(context,ssid,network,false); + if(home==null)throw new IOException("Автономный режим: синхронизация разрешена только в домашнем Wi-Fi «"+ssid+"»"); + URL url=new URL(address+"v1/vault");byte[] target=LanPolicy.ipv4(url.getHost()); + LinkProperties links=context.getSystemService(ConnectivityManager.class).getLinkProperties(home); + boolean onLink=false,directRoute=false; + if(links!=null) { + for(LinkAddress link:links.getLinkAddresses())if(LanPolicy.sameSubnet(target,link.getAddress().getAddress(),link.getPrefixLength()))onLink=true; + InetAddress ip=InetAddress.getByAddress(target); + for(RouteInfo route:links.getRoutes())if(!route.isDefaultRoute()&&(route.getGateway()==null||route.getGateway().isAnyLocalAddress())&&route.getDestination().contains(ip))directRoute=true; + } + if(!onLink||!directRoute)throw new IOException("Pi должен находиться непосредственно в подсети домашнего Wi-Fi. Интернет и VPN для базы запрещены"); + HttpsURLConnection c=(HttpsURLConnection)home.openConnection(url,Proxy.NO_PROXY); + final String expectedPin=pin; + SSLContext tls=SSLContext.getInstance("TLS"); + tls.init(null,new TrustManager[]{new X509TrustManager(){ + public X509Certificate[] getAcceptedIssuers(){return new X509Certificate[0];} + public void checkClientTrusted(X509Certificate[] chain,String auth)throws CertificateException{throw new CertificateException("Client certificates unsupported");} + public void checkServerTrusted(X509Certificate[] chain,String auth)throws CertificateException{ + if(chain==null||chain.length==0)throw new CertificateException("Missing Pi certificate"); + chain[0].checkValidity(); + try{if(!VaultCrypto.hash(chain[0].getEncoded()).equalsIgnoreCase(expectedPin))throw new CertificateException("Не совпадает локальный сертификат Pi");} + catch(java.security.cert.CertificateEncodingException e){throw new CertificateException(e);} + } + }},null); + c.setSSLSocketFactory(tls.getSocketFactory());c.setUseCaches(false);c.setRequestProperty("Connection","close"); + c.setInstanceFollowRedirects(false);c.setConnectTimeout(10_000);c.setReadTimeout(30_000);c.setRequestMethod(method); + c.setRequestProperty("Authorization","Bearer "+token);c.setRequestProperty("Accept","application/octet-stream"); + try { + if(body!=null) { + c.setDoOutput(true);c.setFixedLengthStreamingMode(body.length);c.setRequestProperty("Content-Type","application/octet-stream"); + c.setRequestProperty(etag==null?"If-None-Match":"If-Match",etag==null?"*":'"'+etag+'"'); + try(OutputStream out=c.getOutputStream()) {out.write(body);} + } + Response r=new Response();r.code=c.getResponseCode(); + if(r.code==401||r.code==403) throw new IOException("Pi отклонил ключ подключения"); + if(r.code==412) throw new IOException("База на Pi обновилась во время синхронизации. Повторите ещё раз."); + if(r.code!=200 && !(r.code==404 && method.equals("GET"))) throw new IOException("Сервер вернул HTTP "+r.code); + r.body=r.code==200?bounded(c.getInputStream(),VaultCrypto.MAX_BYTES):new byte[0]; + r.etag=c.getHeaderField("ETag"); + if(r.etag!=null && r.etag.startsWith("\"")&&r.etag.endsWith("\"")) r.etag=r.etag.substring(1,r.etag.length()-1); + if(method.equals("GET")&&r.code==200) { + VaultCrypto.validate(r.body);String actual=VaultCrypto.hash(r.body); + if(!actual.equals(r.etag)) throw new IOException("Не совпадает контрольная сумма ответа Pi"); + } + return r; + } finally {c.disconnect();} + } + private synchronized void markSynced(byte[] blob) throws Exception { + write(base,blob);config.put("etag",VaultCrypto.hash(blob)) + .put("lastSync",new java.text.SimpleDateFormat("dd.MM HH:mm",Locale.getDefault()).format(new Date()));saveConfig(); + } + public VaultCrypto.Key restorePi(char[] password) throws Exception { + Response remote=request("GET",null,null);if(remote.code==404) throw new IOException("На Pi пока нет базы QSafe"); + VaultCrypto.Key key=VaultCrypto.unlock(remote.body,password); + try { + byte[] plain=VaultCrypto.decrypt(remote.body,key);try {Vault.parse(plain);} finally {Arrays.fill(plain,(byte)0);} + saveEncrypted(remote.body);markSynced(remote.body);pending.delete();return key; + } catch(Exception e) {key.close();throw e;} + } + public static final class SyncResult { + public final JSONObject data; public final int conflicts; + SyncResult(JSONObject data,int conflicts) {this.data=data;this.conflicts=conflicts;} + } + /** No master password, derived key, or plaintext is required or loaded by background work. */ + public String backgroundSync(Network network,java.util.function.BooleanSupplier cancelled) throws Exception { + if(!exists()||!connected())return "Создайте или восстановите базу и подключите Pi"; + if(cancelled.getAsBoolean())throw new IOException("Фоновая задача остановлена Android"); + byte[] local=encrypted();String localHash=VaultCrypto.hash(local),common; + synchronized(this){common=config.optString("etag");} + Response remote=request("GET",null,null,network); + if(cancelled.getAsBoolean())throw new IOException("Фоновая задача остановлена Android"); + CipherSync.Plan plan=CipherSync.plan(localHash,common,remote.code==404?null:remote.etag); + switch(plan) { + case MISSING_REMOTE:throw new IOException("База исчезла с Pi. Проверьте сервер и резервные копии."); + case CREATE:request("PUT",local,null,network);markSynced(local);pending.delete();return "Зашифрованная база отправлена на Pi"; + case UPLOAD:request("PUT",local,remote.etag,network);markSynced(local);pending.delete();return "Изменения отправлены на Pi"; + case UNCHANGED:markSynced(local);pending.delete();return "Телефон и Pi синхронизированы"; + case STAGE: + write(pending,remote.body); + return "Копия с Pi загружена. Проверка и объединение — при разблокировке."; + default:throw new IOException("Неизвестное состояние синхронизации"); + } + } + /** Authenticate staged remote data only after the user unlocks; never replace trusted data before verification. */ + public SyncResult applyPending(VaultCrypto.Key key) throws Exception { + JSONObject localVault=read(key); + if(!pending.getBaseFile().exists())return new SyncResult(localVault,0); + byte[] remote=pending.readFully(),local=encrypted(); + if(VaultCrypto.hash(remote).equals(VaultCrypto.hash(local))) {markSynced(local);pending.delete();return new SyncResult(localVault,0);} + byte[] plain=VaultCrypto.decrypt(remote,key);JSONObject remoteVault; + try{remoteVault=Vault.parse(plain);}finally{Arrays.fill(plain,(byte)0);} + if(!remoteVault.getString("id").equals(localVault.getString("id")))throw new IOException("На Pi другое хранилище. Локальная база сохранена."); + byte[] ancestor=base.getBaseFile().exists()?base.readFully():null; + if(ancestor!=null&&VaultCrypto.hash(ancestor).equals(VaultCrypto.hash(local))) { + saveEncrypted(remote);markSynced(remote);pending.delete();return new SyncResult(remoteVault,0); + } + JSONObject baseVault=null; + if(ancestor!=null) {plain=VaultCrypto.decrypt(ancestor,key);try{baseVault=Vault.parse(plain);}finally{Arrays.fill(plain,(byte)0);}} + Vault.Merge merged=Vault.merge(baseVault,localVault,remoteVault);save(merged.vault,key);markSynced(remote);pending.delete(); + return new SyncResult(merged.vault,merged.conflicts); + } + /** Whole encrypted blobs with optimistic concurrency and a local encrypted common ancestor. */ + public SyncResult sync(VaultCrypto.Key key) throws Exception { + byte[] local=encrypted();String localHash=VaultCrypto.hash(local); + Response remote=request("GET",null,null); + String previous;synchronized(this) {previous=config.optString("etag");} + if(remote.code==404) { + if(!previous.isEmpty()) throw new IOException("База исчезла с Pi. Сначала проверьте сервер и резервные копии."); + request("PUT",local,null);markSynced(local);pending.delete();return new SyncResult(read(key),0); + } + if(localHash.equals(remote.etag)) {markSynced(local);pending.delete();return new SyncResult(read(key),0);} + byte[] remotePlain=VaultCrypto.decrypt(remote.body,key);JSONObject remoteVault; + try {remoteVault=Vault.parse(remotePlain);} finally {Arrays.fill(remotePlain,(byte)0);} + JSONObject localVault=read(key); + if(!localVault.getString("id").equals(remoteVault.getString("id"))) throw new IOException("На Pi другое хранилище. Сохраните локальную копию и восстановите базу Pi."); + if(remote.etag.equals(previous)) { + request("PUT",local,remote.etag);markSynced(local);pending.delete();return new SyncResult(localVault,0); + } + byte[] ancestor=base.getBaseFile().exists()?base.readFully():null; + if(ancestor!=null && VaultCrypto.hash(ancestor).equals(localHash)) { + saveEncrypted(remote.body);markSynced(remote.body);pending.delete();return new SyncResult(remoteVault,0); + } + JSONObject baseVault=null; + if(ancestor!=null) { + byte[] plain=VaultCrypto.decrypt(ancestor,key);try {baseVault=Vault.parse(plain);} finally {Arrays.fill(plain,(byte)0);} + } + Vault.Merge merged=Vault.merge(baseVault,localVault,remoteVault); + byte[] plain=Vault.bytes(merged.vault),next; + try {next=VaultCrypto.encrypt(plain,key);} finally {Arrays.fill(plain,(byte)0);} + request("PUT",next,remote.etag);saveEncrypted(next);markSynced(next);pending.delete(); + return new SyncResult(merged.vault,merged.conflicts); + } +} diff --git a/app/src/main/java/ru/kusoft/qsafe/SafeInCloudImport.java b/app/src/main/java/ru/kusoft/qsafe/SafeInCloudImport.java new file mode 100644 index 0000000..e599d88 --- /dev/null +++ b/app/src/main/java/ru/kusoft/qsafe/SafeInCloudImport.java @@ -0,0 +1,81 @@ +package ru.kusoft.qsafe; + +import org.json.*; +import org.w3c.dom.*; +import org.xml.sax.InputSource; +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.util.*; +import javax.xml.parsers.*; +import javax.xml.transform.*; +import javax.xml.transform.dom.DOMSource; +import javax.xml.transform.stream.StreamResult; + +public final class SafeInCloudImport { + public static final class Preview { + public final JSONObject vault; + public final int added,duplicates,skipped,attachments; + Preview(JSONObject v,int a,int d,int s,int f) {vault=v;added=a;duplicates=d;skipped=s;attachments=f;} + } + private static Document document(byte[] data) throws Exception { + if(data.length>24*1024*1024) throw new IOException("XML превышает 24 МиБ"); + // Reject declarations before constructing the DOM, including UTF-16 exports. + String xml; + if(data.length>1 && ((data[0]==(byte)0xff&&data[1]==(byte)0xfe)||(data[0]==(byte)0xfe&&data[1]==(byte)0xff))) + xml=new String(data,StandardCharsets.UTF_16); + else xml=new String(data,StandardCharsets.UTF_8); + if(xml.startsWith("\ufeff")) xml=xml.substring(1); + String upper=xml.toUpperCase(Locale.ROOT); + if(upper.contains("{throw new org.xml.sax.SAXException("Внешние сущности запрещены");}); + return b.parse(new InputSource(new StringReader(xml))); + } + private static String raw(Node node) throws TransformerException { + Transformer t=TransformerFactory.newInstance().newTransformer();t.setOutputProperty(OutputKeys.OMIT_XML_DECLARATION,"yes"); + StringWriter w=new StringWriter();t.transform(new DOMSource(node),new StreamResult(w));return w.toString(); + } + private static boolean yes(String value) {return value.equalsIgnoreCase("true")||value.equals("1");} + public static Preview preview(byte[] data,JSONObject current) throws Exception { + Document d=document(data); + if(!d.getDocumentElement().getNodeName().equals("database")) throw new IOException("Ожидается XML-экспорт SafeInCloud с корнем database"); + JSONObject result=Vault.copy(current); JSONArray entries=result.getJSONArray("entries"); + Set fingerprints=new HashSet<>(); + for(int i=0;i labels=new HashMap<>(); + NodeList children=d.getDocumentElement().getChildNodes(); + for(int i=0;i= 33) activity.setRecentsScreenshotEnabled(false); + } + public static void protect(Window window) { + if (window != null) window.addFlags(WindowManager.LayoutParams.FLAG_SECURE); + } + public static AlertDialog show(AlertDialog dialog) { + protect(dialog.getWindow()); + dialog.show(); + AppAppearance.disableForceDark(dialog.getWindow()); + return dialog; + } +} diff --git a/app/src/main/java/ru/kusoft/qsafe/SyncRuntime.java b/app/src/main/java/ru/kusoft/qsafe/SyncRuntime.java new file mode 100644 index 0000000..95c11e5 --- /dev/null +++ b/app/src/main/java/ru/kusoft/qsafe/SyncRuntime.java @@ -0,0 +1,7 @@ +package ru.kusoft.qsafe; +import java.util.concurrent.*; +/** All app vault I/O, including jobs and autofill, is serialized within this process. */ +public final class SyncRuntime { + private SyncRuntime() {} + public static final ExecutorService IO=Executors.newSingleThreadExecutor(); +} diff --git a/app/src/main/java/ru/kusoft/qsafe/UpdateManifest.java b/app/src/main/java/ru/kusoft/qsafe/UpdateManifest.java new file mode 100644 index 0000000..e2aa616 --- /dev/null +++ b/app/src/main/java/ru/kusoft/qsafe/UpdateManifest.java @@ -0,0 +1,27 @@ +package ru.kusoft.qsafe; + +import org.json.*; +import java.io.IOException; +import java.util.Locale; + +public final class UpdateManifest { + public static final String ORIGIN="https://argus.kusoft.xyz"; + public static final int MAX_APK_BYTES=100*1024*1024; + public final String version,sha256,downloadPath,notes; + public final long versionCode,size; + private UpdateManifest(String version,long code,long size,String hash,String path,String notes) { + this.version=version;versionCode=code;this.size=size;sha256=hash;downloadPath=path;this.notes=notes; + } + public static UpdateManifest parse(JSONObject manifest) throws JSONException,IOException { + if(!manifest.optString("slug").equals("qsafe"))throw new IOException("Каталог вернул другое приложение"); + JSONObject release=manifest.getJSONObject("release"); + String hash=release.getString("sha256").toLowerCase(Locale.ROOT),path=release.getString("downloadPath"),version=release.getString("version"); + long size=release.getLong("packageSizeBytes"),code=release.getLong("androidVersionCode"); + if(!release.optString("platform").equals("android")||!release.optString("packageKind").equals("apk")||!release.optString("channel").equals("stable")|| + !hash.matches("[0-9a-f]{64}")||!version.matches("[0-9]+\\.[0-9]+\\.[0-9]+")||code<1||code>Integer.MAX_VALUE||size<56||size>MAX_APK_BYTES|| + !path.matches("/api/apps/qsafe/releases/[0-9a-fA-F-]{36}/download"))throw new IOException("Некорректные сведения об обновлении"); + String notes=release.optString("notes");if(notes.length()>8000)notes=notes.substring(0,8000); + return new UpdateManifest(version,code,size,hash,path,notes); + } + public boolean newerThan(long installedCode){return versionCode>installedCode;} +} diff --git a/app/src/main/java/ru/kusoft/qsafe/UpdateProvider.java b/app/src/main/java/ru/kusoft/qsafe/UpdateProvider.java new file mode 100644 index 0000000..b20ed5a --- /dev/null +++ b/app/src/main/java/ru/kusoft/qsafe/UpdateProvider.java @@ -0,0 +1,32 @@ +package ru.kusoft.qsafe; + +import android.content.*; +import android.database.*; +import android.net.Uri; +import android.os.ParcelFileDescriptor; +import android.provider.OpenableColumns; +import java.io.*; + +/** Only the verified cached APK is shared with Android's installer, with a URI grant. */ +public final class UpdateProvider extends ContentProvider { + @Override public boolean onCreate(){return true;} + private File file(Uri uri)throws FileNotFoundException { + if(!"content".equals(uri.getScheme())||!(getContext().getPackageName()+".updates").equals(uri.getAuthority())|| + !"/update.apk".equals(uri.getPath())||uri.getQuery()!=null||uri.getFragment()!=null)throw new FileNotFoundException("Invalid update URI"); + File file=AppUpdates.apk(getContext());if(!file.isFile())throw new FileNotFoundException("Update unavailable");return file; + } + @Override public ParcelFileDescriptor openFile(Uri uri,String mode)throws FileNotFoundException { + if(!"r".equals(mode))throw new FileNotFoundException("Read-only update");return ParcelFileDescriptor.open(file(uri),ParcelFileDescriptor.MODE_READ_ONLY); + } + @Override public String getType(Uri uri){return "application/vnd.android.package-archive";} + @Override public Cursor query(Uri uri,String[] projection,String selection,String[] args,String sort) { + try { + File f=file(uri);String[] columns=projection==null?new String[]{OpenableColumns.DISPLAY_NAME,OpenableColumns.SIZE}:projection; + MatrixCursor cursor=new MatrixCursor(columns);Object[] row=new Object[columns.length]; + for(int i=0;i ids=new HashSet<>(); + for(int i=0;i index(JSONObject root) throws JSONException { + Map map=new LinkedHashMap<>(); if(root==null) return map; + JSONArray a=root.getJSONArray("entries"); + for(int i=0;i b=index(base),l=index(local),r=index(remote); + Set ids=new LinkedHashSet<>(l.keySet()); ids.addAll(r.keySet()); + JSONArray merged=new JSONArray(); int conflicts=0; + for(String id:ids) { + JSONObject le=l.get(id),re=r.get(id),be=b.get(id),chosen; + if(re==null) chosen=le; + else if(le==null || rev(le).equals(rev(be))) chosen=re; + else if(rev(re).equals(rev(be)) || rev(le).equals(rev(re))) chosen=le; + else if(le.optBoolean("deleted") && re.optBoolean("deleted")) chosen=re; + else { + conflicts++; + if(le.optBoolean("deleted")) { + chosen=copy(re); chosen.put("title",re.optString("title")+" · конфликт удаления"); + chosen.put("rev",UUID.randomUUID().toString()); + } else if(re.optBoolean("deleted")) { + chosen=copy(le); chosen.put("title",le.optString("title")+" · конфликт удаления"); + chosen.put("rev",UUID.randomUUID().toString()); + } else { + chosen=re; + JSONObject duplicate=copy(le).put("id",UUID.randomUUID().toString()).put("rev",UUID.randomUUID().toString()) + .put("title",le.optString("title")+" · локальная версия (конфликт)"); + merged.put(duplicate); + } + } + if(chosen!=null) merged.put(copy(chosen)); + } + return new Merge(copy(local).put("entries",merged),conflicts); + } + public static List visible(JSONObject root,String query,boolean stars) throws JSONException { + List out=new ArrayList<>(); JSONArray a=root.getJSONArray("entries"); String q=query.toLowerCase(Locale.ROOT); + for(int i=0;io.optString("title").toLowerCase(Locale.ROOT))); return out; + } + public static boolean secret(JSONObject field) { + String t=field.optString("type").toLowerCase(Locale.ROOT); + return !Arrays.asList("login","website","url","email","phone","text","date").contains(t); + } +} diff --git a/app/src/main/java/ru/kusoft/qsafe/VaultCrypto.java b/app/src/main/java/ru/kusoft/qsafe/VaultCrypto.java new file mode 100644 index 0000000..a98f811 --- /dev/null +++ b/app/src/main/java/ru/kusoft/qsafe/VaultCrypto.java @@ -0,0 +1,80 @@ +package ru.kusoft.qsafe; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.security.*; +import java.util.Arrays; +import javax.crypto.*; +import javax.crypto.spec.*; + +/** Versioned authenticated envelope. No plaintext password or derived key is persisted. */ +public final class VaultCrypto { + public static final int ITERATIONS = 600_000; + public static final int MAX_BYTES = 32 * 1024 * 1024; + private static final byte[] MAGIC = "QSAFE01!".getBytes(StandardCharsets.US_ASCII); + private static final SecureRandom RANDOM = new SecureRandom(); + public static final class Key implements AutoCloseable { + public final byte[] bytes, salt; + public final int iterations; + Key(byte[] bytes, byte[] salt, int iterations) { this.bytes=bytes; this.salt=salt; this.iterations=iterations; } + public void close() { Arrays.fill(bytes,(byte)0); } + } + public static Key create(char[] password) throws GeneralSecurityException { + byte[] salt=new byte[16]; RANDOM.nextBytes(salt); + return derive(password,salt,ITERATIONS); + } + private static Key derive(char[] password, byte[] salt, int iterations) throws GeneralSecurityException { + PBEKeySpec spec=new PBEKeySpec(password,salt,iterations,256); + try { return new Key(SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256").generateSecret(spec).getEncoded(),salt.clone(),iterations); } + finally { spec.clearPassword(); } + } + public static void validate(byte[] envelope) { + if (envelope.length<56 || envelope.length>MAX_BYTES || !Arrays.equals(MAGIC,Arrays.copyOf(envelope,8))) + throw new IllegalArgumentException("Неверный или повреждённый файл QSafe"); + int count=ByteBuffer.wrap(envelope,8,4).getInt(); + if (count2_000_000) throw new IllegalArgumentException("Неподдерживаемые параметры шифрования"); + } + public static Key unlock(byte[] envelope,char[] password) throws GeneralSecurityException { + validate(envelope); + Key key=derive(password,Arrays.copyOfRange(envelope,12,28),ByteBuffer.wrap(envelope,8,4).getInt()); + try { byte[] test=decrypt(envelope,key); Arrays.fill(test,(byte)0); return key; } + catch (GeneralSecurityException | RuntimeException e) { key.close(); throw e; } + } + public static byte[] encrypt(byte[] plaintext,Key key) throws GeneralSecurityException { + if(plaintext.length>MAX_BYTES-56) throw new IllegalArgumentException("Хранилище превышает 32 МиБ"); + byte[] nonce=new byte[12]; RANDOM.nextBytes(nonce); + byte[] header=ByteBuffer.allocate(40).put(MAGIC).putInt(key.iterations).put(key.salt).put(nonce).array(); + Cipher c=Cipher.getInstance("AES/GCM/NoPadding"); + c.init(Cipher.ENCRYPT_MODE,new SecretKeySpec(key.bytes,"AES"),new GCMParameterSpec(128,nonce)); + c.updateAAD(header); + byte[] encrypted=c.doFinal(plaintext); + return ByteBuffer.allocate(header.length+encrypted.length).put(header).put(encrypted).array(); + } + public static byte[] decrypt(byte[] envelope,Key key) throws GeneralSecurityException { + validate(envelope); + if (!MessageDigest.isEqual(key.salt,Arrays.copyOfRange(envelope,12,28)) || key.iterations!=ByteBuffer.wrap(envelope,8,4).getInt()) + throw new GeneralSecurityException("У базы другой мастер-пароль или ключ"); + Cipher c=Cipher.getInstance("AES/GCM/NoPadding"); + c.init(Cipher.DECRYPT_MODE,new SecretKeySpec(key.bytes,"AES"),new GCMParameterSpec(128,Arrays.copyOfRange(envelope,28,40))); + c.updateAAD(Arrays.copyOf(envelope,40)); + return c.doFinal(envelope,40,envelope.length-40); + } + public static String hash(byte[] data) { + try { + byte[] digest=MessageDigest.getInstance("SHA-256").digest(data); StringBuilder s=new StringBuilder(); + for(byte b:digest) s.append(String.format(java.util.Locale.ROOT,"%02x",b&255)); return s.toString(); + } catch(NoSuchAlgorithmException e) { throw new IllegalStateException(e); } + } + public static String generate(int length) { + return generate(length,true); + } + public static String generate(int length,boolean symbols) { + if(length<16 || length>128) throw new IllegalArgumentException(); + String[] groups=symbols?new String[]{"ABCDEFGHJKLMNPQRSTUVWXYZ","abcdefghijkmnopqrstuvwxyz","23456789","!@#$%&*+-=?"}:new String[]{"ABCDEFGHJKLMNPQRSTUVWXYZ","abcdefghijkmnopqrstuvwxyz","23456789"}; + String alphabet=String.join("",groups); char[] result=new char[length]; + for(int i=0;i0;i--) { int j=RANDOM.nextInt(i+1); char t=result[i]; result[i]=result[j]; result[j]=t; } + return new String(result); + } +} diff --git a/app/src/main/res/drawable/ic_launcher.xml b/app/src/main/res/drawable/ic_launcher.xml new file mode 100644 index 0000000..9ad40e9 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/values/ids.xml b/app/src/main/res/values/ids.xml new file mode 100644 index 0000000..b9aa2f5 --- /dev/null +++ b/app/src/main/res/values/ids.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..f44729f --- /dev/null +++ b/app/src/main/res/values/styles.xml @@ -0,0 +1,25 @@ + + + + diff --git a/app/src/main/res/xml/autofill_service.xml b/app/src/main/res/xml/autofill_service.xml new file mode 100644 index 0000000..7e4b10b --- /dev/null +++ b/app/src/main/res/xml/autofill_service.xml @@ -0,0 +1 @@ + diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..173e172 --- /dev/null +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/test/java/ru/kusoft/qsafe/CipherSyncTests.java b/app/src/test/java/ru/kusoft/qsafe/CipherSyncTests.java new file mode 100644 index 0000000..4cf3d88 --- /dev/null +++ b/app/src/test/java/ru/kusoft/qsafe/CipherSyncTests.java @@ -0,0 +1,23 @@ +package ru.kusoft.qsafe; +import org.junit.Test; +import static org.junit.Assert.*; +public class CipherSyncTests { + @Test public void lockedVaultNeverOverwritesUnknownRemoteChanges() { + assertEquals(CipherSync.Plan.STAGE,CipherSync.plan("phone-new","base","pi-new")); + assertEquals(CipherSync.Plan.STAGE,CipherSync.plan("phone-new","","pi-new")); + assertEquals(CipherSync.Plan.STAGE,CipherSync.plan("base","base","pi-new")); + assertEquals(CipherSync.Plan.UPLOAD,CipherSync.plan("phone-new","base","base")); + } + @Test public void firstUploadAndMissingRemoteHaveDifferentOutcomes() { + assertEquals(CipherSync.Plan.CREATE,CipherSync.plan("phone-new","",null)); + assertEquals(CipherSync.Plan.MISSING_REMOTE,CipherSync.plan("phone-new","known",null)); + assertEquals(CipherSync.Plan.UNCHANGED,CipherSync.plan("same","older","same")); + } + @Test public void ssidMatchingIsExactAndRejectsUnknown() { + assertTrue(CipherSync.matches("gen","\"gen\"")); + assertFalse(CipherSync.matches("gen","Gen")); + assertFalse(CipherSync.matches("gen","")); + assertFalse(CipherSync.matches("",null)); + assertTrue(CipherSync.matches(" gen ","\" gen \"")); + } +} diff --git a/app/src/test/java/ru/kusoft/qsafe/LanPolicyTests.java b/app/src/test/java/ru/kusoft/qsafe/LanPolicyTests.java new file mode 100644 index 0000000..6cc63db --- /dev/null +++ b/app/src/test/java/ru/kusoft/qsafe/LanPolicyTests.java @@ -0,0 +1,29 @@ +package ru.kusoft.qsafe; + +import org.junit.Test; +import java.io.IOException; +import static org.junit.Assert.*; + +public class LanPolicyTests { + @Test public void vaultRejectsEveryExternalOrDnsAddress()throws Exception { + for(String url:new String[]{"https://argus.kusoft.xyz/qsafe/","https://8.8.8.8/","https://127.0.0.1/","https://169.254.1.1/","https://[::1]/","https://192.168.0.185.evil.test/","http://192.168.0.185/","https://user@192.168.0.185/","https://192.168.0.185/?x=1"}) { + try{LanPolicy.url(url);fail(url);}catch(IOException expected){} + } + assertEquals("https://192.168.0.185:18443/qsafe/",LanPolicy.url("https://192.168.0.185:18443/qsafe")); + assertEquals("https://10.1.2.3/",LanPolicy.url("https://10.1.2.3")); + assertEquals("https://172.16.1.2/",LanPolicy.url("https://172.16.1.2")); + } + @Test public void requiresHomeSsidAndPinnedCertificate()throws Exception { + LanPolicy.identity("gen","a".repeat(64)); + for(String[] pair:new String[][]{{"","a".repeat(64)},{"gen",""},{"gen","not-a-hash"}})try{LanPolicy.identity(pair[0],pair[1]);fail();}catch(IOException expected){} + } + @Test public void piMustBeOnLinkAndCannotBeLocalNetworkOrBroadcast()throws Exception { + byte[] local=LanPolicy.ipv4("192.168.0.61"); + assertTrue(LanPolicy.sameSubnet(LanPolicy.ipv4("192.168.0.185"),local,24)); + for(String target:new String[]{"192.168.1.185","192.168.0.0","192.168.0.255","192.168.0.61"})assertFalse(LanPolicy.sameSubnet(LanPolicy.ipv4(target),local,24)); + assertFalse(LanPolicy.sameSubnet(LanPolicy.ipv4("192.168.0.185"),local,8)); + } + @Test public void argusUpdatesRemainIndependentOfPrivateVaultPolicy() { + assertEquals("https://argus.kusoft.xyz",UpdateManifest.ORIGIN); + } +} diff --git a/app/src/test/java/ru/kusoft/qsafe/PasswordGeneratorTests.java b/app/src/test/java/ru/kusoft/qsafe/PasswordGeneratorTests.java new file mode 100644 index 0000000..be9aabe --- /dev/null +++ b/app/src/test/java/ru/kusoft/qsafe/PasswordGeneratorTests.java @@ -0,0 +1,61 @@ +package ru.kusoft.qsafe; + +import org.junit.Test; +import static org.junit.Assert.*; + +public class PasswordGeneratorTests { + @Test public void complexPasswordsRespectSymbolsAndLength() { + for(int length:new int[]{16,24,64,128})for(int i=0;i<50;i++) { + String value=PasswordGenerator.generate(PasswordGenerator.Mode.COMPLEX,length,true); + assertEquals(length,value.length());assertTrue(value.matches(".*[A-Z].*"));assertTrue(value.matches(".*[a-z].*")); + assertTrue(value.matches(".*[2-9].*"));assertTrue(value.matches(".*[!@#$%&*+\\-=?].*")); + assertTrue(PasswordGenerator.generate(PasswordGenerator.Mode.COMPLEX,length,false).matches("[A-Za-z2-9]{"+length+"}")); + } + } + @Test public void numericCodesKeepExactLengthAndUseOnlyDigits() { + for(int length:new int[]{4,12,64,128})for(int i=0;i<50;i++) { + String value=PasswordGenerator.generate(PasswordGenerator.Mode.DIGITS,length,true); + assertEquals(length,value.length());assertTrue(value.matches("[0-9]+")); + } + } + @Test public void memorablePasswordsHaveRequestedWordCount() { + for(int count:new int[]{2,3,8,12})for(boolean symbols:new boolean[]{true,false})for(boolean digits:new boolean[]{true,false})for(int i=0;i<50;i++) { + String value=PasswordGenerator.generate(PasswordGenerator.Mode.MEMORABLE,count,symbols,digits); + java.util.regex.Matcher words=java.util.regex.Pattern.compile("[A-Z]?[a-z]+").matcher(value); + int actual=0;while(words.find())actual++; + assertEquals(count,actual);assertEquals(digits,value.matches(".*[0-9].*")); + assertEquals(symbols,value.matches(".*[!@#$%&*+\\-=?].*")); + assertTrue(value.matches("[A-Za-z0-9!@#$%&*+\\-=?]+")); + String gap="[0-9!@#$%&*+\\-=?]*"; + assertTrue("Words must stay whole",value.matches(gap+"[A-Z][a-z]+(?:"+gap+"[A-Z][a-z]+){"+(count-1)+"}"+gap)); + if(digits)assertEquals(2,value.replaceAll("[^0-9]","").length()); + } + } + @Test public void digitsAndSignsCanOccurAtEveryWordBoundary() { + boolean[] digitPositions=new boolean[3],signPositions=new boolean[3]; + boolean signBeforeDigit=false,signAfterDigit=false; + for(int i=0;i<1000;i++) { + String value=PasswordGenerator.generate(PasswordGenerator.Mode.MEMORABLE,2,true); + java.util.regex.Matcher words=java.util.regex.Pattern.compile("[A-Z][a-z]+").matcher(value); + assertTrue(words.find());int firstStart=words.start(),firstEnd=words.end(); + assertTrue(words.find());int secondStart=words.start(),secondEnd=words.end();assertFalse(words.find()); + String[] boundaries={value.substring(0,firstStart),value.substring(firstEnd,secondStart),value.substring(secondEnd)}; + for(int position=0;position<3;position++) { + digitPositions[position]|=boundaries[position].matches(".*[0-9].*"); + signPositions[position]|=boundaries[position].matches(".*[!@#$%&*+\\-=?].*"); + } + int sign=-1;for(int j=0;j=0)sign=j; + for(int j=0;jj;} + } + for(int i=0;i<3;i++){assertTrue(digitPositions[i]);assertTrue(signPositions[i]);} + assertTrue(signBeforeDigit);assertTrue(signAfterDigit); + } + @Test public void invalidLengthsAreRejected() { + rejects(PasswordGenerator.Mode.COMPLEX,15);rejects(PasswordGenerator.Mode.COMPLEX,129); + rejects(PasswordGenerator.Mode.DIGITS,3);rejects(PasswordGenerator.Mode.DIGITS,129); + rejects(PasswordGenerator.Mode.MEMORABLE,1);rejects(PasswordGenerator.Mode.MEMORABLE,13); + } + private void rejects(PasswordGenerator.Mode mode,int length) { + try{PasswordGenerator.generate(mode,length,true);fail("Invalid length accepted");}catch(IllegalArgumentException expected){ } + } +} diff --git a/app/src/test/java/ru/kusoft/qsafe/UpdateAndTemplateTests.java b/app/src/test/java/ru/kusoft/qsafe/UpdateAndTemplateTests.java new file mode 100644 index 0000000..200c1e2 --- /dev/null +++ b/app/src/test/java/ru/kusoft/qsafe/UpdateAndTemplateTests.java @@ -0,0 +1,54 @@ +package ru.kusoft.qsafe; + +import org.json.*; +import org.junit.Test; +import java.io.IOException; +import static org.junit.Assert.*; + +public class UpdateAndTemplateTests { + private JSONObject manifest()throws Exception { + return new JSONObject().put("slug","qsafe").put("release",new JSONObject() + .put("version","1.0.1").put("androidVersionCode",4).put("platform","android").put("channel","stable").put("packageKind","apk") + .put("packageSizeBytes",200000).put("sha256","a".repeat(64)) + .put("downloadPath","/api/apps/qsafe/releases/3d149d9b-16fb-4935-8537-b447fce96aeb/download")); + } + @Test public void upgradeUsesNumericCodeAndRejectsDowngrades()throws Exception { + UpdateManifest update=UpdateManifest.parse(manifest());assertTrue(update.newerThan(3));assertFalse(update.newerThan(4));assertFalse(update.newerThan(5)); + } + @Test public void manifestCannotRedirectDownloadsOrChooseAnotherApp()throws Exception { + for(String path:new String[]{"https://other.example/update.apk","//other.example/update.apk","/api/apps/another/releases/3d149d9b-16fb-4935-8537-b447fce96aeb/download","/api/apps/qsafe/releases/../../private/download"}) { + JSONObject m=manifest();m.getJSONObject("release").put("downloadPath",path); + try{UpdateManifest.parse(m);fail("Untrusted download location accepted");}catch(IOException expected) { } + } + JSONObject wrong=manifest().put("slug","other");try{UpdateManifest.parse(wrong);fail();}catch(IOException expected) { } + } + @Test public void invalidUpdateSizeHashAndPlatformAreRejected()throws Exception { + Object[][] changes={{"packageSizeBytes",-1},{"packageSizeBytes",UpdateManifest.MAX_APK_BYTES+1L},{"sha256","invalid"},{"platform","windows"},{"channel","preview"},{"androidVersionCode",0}}; + for(Object[] change:changes){JSONObject m=manifest();m.getJSONObject("release").put((String)change[0],change[1]);try{UpdateManifest.parse(m);fail("Invalid update accepted");}catch(IOException expected) { }} + } + @Test public void templatesKeepSensitiveDataOutOfCardPreviews()throws Exception { + JSONObject bank=CardTemplates.create("bank");bank.getJSONArray("fields").getJSONObject(0).put("value","SYNTHETIC_CARD_SECRET"); + bank.getJSONArray("fields").getJSONObject(3).put("value","SYNTHETIC_CVV"); + assertTrue(Vault.secret(bank.getJSONArray("fields").getJSONObject(0)));assertTrue(Vault.secret(bank.getJSONArray("fields").getJSONObject(3))); + assertFalse(CardTemplates.subtitle(bank).contains("SYNTHETIC"));assertEquals("bank",CardTemplates.kind(bank)); + JSONObject note=CardTemplates.create("note").put("notes","SYNTHETIC_NOTE_SECRET");assertEquals(0,note.getJSONArray("fields").length());assertFalse(CardTemplates.subtitle(note).contains("SECRET")); + } + @Test public void templateEditsAndMetadataSurviveEncryptionAndMerge()throws Exception { + JSONObject base=Vault.empty(),bank=CardTemplates.create("bank").put("title","Example bank");Vault.update(base,bank); + JSONObject local=Vault.copy(base),remote=Vault.copy(base);Vault.update(local,Vault.copy(bank).put("notes","local note")); + JSONObject second=CardTemplates.create("wifi").put("title","Example Wi-Fi");Vault.update(remote,second); + Vault.Merge merged=Vault.merge(base,local,remote);assertEquals(0,merged.conflicts);assertEquals(2,Vault.visible(merged.vault,"",false).size()); + try(VaultCrypto.Key key=VaultCrypto.create("synthetic template password!".toCharArray())) { + JSONObject decoded=Vault.parse(VaultCrypto.decrypt(VaultCrypto.encrypt(Vault.bytes(merged.vault),key),key)); + assertEquals("bank",decoded.getJSONArray("entries").getJSONObject(0).getString("kind"));assertEquals("local note",decoded.getJSONArray("entries").getJSONObject(0).getString("notes")); + } + } + @Test public void savingAnOpenFormCannotOverwriteANewerSyncedRevision()throws Exception { + JSONObject data=Vault.empty(),entry=CardTemplates.create("login").put("title","Example");Vault.update(data,entry); + JSONObject draft=Vault.copy(entry).put("notes","form edit");Vault.update(data,Vault.copy(entry).put("notes","newer remote edit")); + JSONObject saved=Vault.applyEdit(data,draft);assertNotEquals(entry.getString("id"),saved.getString("id"));assertEquals(2,Vault.visible(data,"",false).size()); + assertEquals("newer remote edit",data.getJSONArray("entries").getJSONObject(0).getString("notes")); + try{Vault.applyEdit(data,Vault.copy(entry).put("deleted",true));fail();}catch(JSONException expected) { } + assertEquals(2,Vault.visible(data,"",false).size()); + } +} diff --git a/app/src/test/java/ru/kusoft/qsafe/VaultTests.java b/app/src/test/java/ru/kusoft/qsafe/VaultTests.java new file mode 100644 index 0000000..bd9de5b --- /dev/null +++ b/app/src/test/java/ru/kusoft/qsafe/VaultTests.java @@ -0,0 +1,71 @@ +package ru.kusoft.qsafe; + +import org.json.*; +import org.junit.Test; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.util.*; +import static org.junit.Assert.*; + +public class VaultTests { + @Test public void authenticatedEncryptionAndWrongPassword() throws Exception { + byte[] plain="Секретный пароль".getBytes(StandardCharsets.UTF_8); + try(VaultCrypto.Key key=VaultCrypto.create("correct horse battery staple".toCharArray())) { + byte[] a=VaultCrypto.encrypt(plain,key),b=VaultCrypto.encrypt(plain,key); + assertFalse(Arrays.equals(a,b));assertArrayEquals(plain,VaultCrypto.decrypt(a,key)); + try(VaultCrypto.Key unlocked=VaultCrypto.unlock(a,"correct horse battery staple".toCharArray())) {assertArrayEquals(plain,VaultCrypto.decrypt(a,unlocked));} + try {VaultCrypto.unlock(a,"wrong password".toCharArray());fail();}catch(GeneralSecurityException expected) { } + a[a.length-1]^=1; + try {VaultCrypto.decrypt(a,key);fail();}catch(GeneralSecurityException expected) { } + } + } + @Test public void boundsAreCheckedBeforeDerivation() throws Exception { + try {VaultCrypto.validate(new byte[8]);fail();}catch(IllegalArgumentException expected) { } + try(VaultCrypto.Key k=VaultCrypto.create("long test password".toCharArray())) { + byte[] blob=VaultCrypto.encrypt(new byte[]{1},k);Arrays.fill(blob,8,12,(byte)127); + try {VaultCrypto.unlock(blob,"test".toCharArray());fail();}catch(IllegalArgumentException expected) { } + } + } + @Test public void generatedPasswordsHaveEveryCharacterGroup() { + for(int i=0;i<100;i++) { + String p=VaultCrypto.generate(24);assertEquals(24,p.length()); + assertTrue(p.matches(".*[A-Z].*"));assertTrue(p.matches(".*[a-z].*"));assertTrue(p.matches(".*[2-9].*"));assertTrue(p.matches(".*[!@#$%&*+\\-=?].*")); + } + } + private byte[] xml(String cards) {return ("").getBytes(StandardCharsets.UTF_8);} + @Test public void migrationRetainsFieldsNotesTagsAndAttachmentsAndDeduplicates() throws Exception { + byte[] data=xml("me@example.testa&b1234Привет\nмир2YWJjvalue"); + SafeInCloudImport.Preview p=SafeInCloudImport.preview(data,Vault.empty()); + assertEquals(1,p.added);assertEquals(2,p.skipped);assertEquals(1,p.attachments); + JSONObject e=p.vault.getJSONArray("entries").getJSONObject(0); + assertEquals("a&b",e.getJSONArray("fields").getJSONObject(1).getString("value")); + assertEquals("Работа",e.getJSONArray("tags").getString(0));assertEquals("Привет\nмир",e.getString("notes")); + assertTrue(e.getString("safeInCloudXml").contains(""));assertTrue(e.getBoolean("star")); + SafeInCloudImport.Preview again=SafeInCloudImport.preview(data,p.vault);assertEquals(0,again.added);assertEquals(1,again.duplicates); + } + @Test public void migrationRejectsXxeAndForeignFormats() throws Exception { + for(String data:new String[]{"]>&x;",""}) { + try {SafeInCloudImport.preview(data.getBytes(StandardCharsets.UTF_8),Vault.empty());fail();}catch(Exception expected) { } + } + } + @Test public void concurrentChangesArePreservedAndIndependentChangesCombine() throws Exception { + JSONObject base=Vault.empty();JSONObject a=Vault.entry("Mail"),b=Vault.entry("Bank");Vault.update(base,a);Vault.update(base,b); + JSONObject local=Vault.copy(base),remote=Vault.copy(base); + Vault.update(local,Vault.copy(a).put("notes","local"));Vault.update(remote,Vault.copy(b).put("notes","remote")); + Vault.Merge independent=Vault.merge(base,local,remote);assertEquals(0,independent.conflicts);assertEquals(2,Vault.visible(independent.vault,"",false).size()); + Vault.update(remote,Vault.copy(a).put("notes","also changed"));Vault.Merge conflict=Vault.merge(base,local,remote); + assertEquals(1,conflict.conflicts);assertEquals(3,Vault.visible(conflict.vault,"",false).size()); + Set notes=new HashSet<>();for(JSONObject e:Vault.visible(conflict.vault,"",false)) notes.add(e.optString("notes")); + assertTrue(notes.contains("local"));assertTrue(notes.contains("also changed")); + } + @Test public void deleteEditConflictRetainsEditedCard() throws Exception { + JSONObject base=Vault.empty(),entry=Vault.entry("Keep");Vault.update(base,entry); + JSONObject local=Vault.copy(base),remote=Vault.copy(base); + Vault.update(local,Vault.copy(entry).put("deleted",true));Vault.update(remote,Vault.copy(entry).put("notes","must survive")); + Vault.Merge m=Vault.merge(base,local,remote);assertEquals(1,m.conflicts);assertEquals("must survive",Vault.visible(m.vault,"",false).get(0).getString("notes")); + assertEquals(1,Vault.visible(m.vault,"",false).size()); + } + @Test public void differentVaultsCannotMerge() throws Exception { + try {Vault.merge(null,Vault.empty(),Vault.empty());fail();}catch(JSONException expected) { } + } +} diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..381f56a --- /dev/null +++ b/build.gradle @@ -0,0 +1 @@ +plugins { id 'com.android.application' version '8.13.2' apply false } diff --git a/deploy/install.sh b/deploy/install.sh new file mode 100755 index 0000000..bf726a5 --- /dev/null +++ b/deploy/install.sh @@ -0,0 +1,76 @@ +#!/bin/bash +set -euo pipefail +umask 077 +stage="$(cd "$(dirname "$0")" && pwd)" +mountpoint -q /mnt/data || { echo 'External disk is not mounted'; exit 1; } +findmnt -n -o OPTIONS /mnt/data | tr ',' '\n' | grep -qx rw +test -f "$stage/token.sha256" +if ! id qsafe >/dev/null 2>&1; then useradd --system --home-dir /nonexistent --shell /usr/sbin/nologin qsafe; fi +install -d -o root -g root -m 755 /opt/qsafe +install -d -o qsafe -g qsafe -m 700 /mnt/data/qsafe/data +install -d -o root -g qsafe -m 750 /etc/qsafe +install -d -o root -g root -m 700 /var/backups/qsafe /var/backups/qsafe-install +install -m 644 "$stage/server.py" "$stage/backup.py" /opt/qsafe/ +if test -f /etc/qsafe/token.sha256; then + cmp -s "$stage/token.sha256" /etc/qsafe/token.sha256 || { echo 'Existing connection key differs; refusing to rotate it'; exit 1; } +else + install -o root -g qsafe -m 640 "$stage/token.sha256" /etc/qsafe/token.sha256 +fi +cat > /etc/systemd/system/qsafe.service <<'EOF' +[Unit] +Description=QSafe encrypted vault synchronization +After=network.target +RequiresMountsFor=/mnt/data/qsafe/data +[Service] +Type=simple +User=qsafe +Group=qsafe +UMask=0077 +ExecStart=/usr/bin/python3 /opt/qsafe/server.py --data /mnt/data/qsafe/data --token-hash-file /etc/qsafe/token.sha256 +Restart=on-failure +RestartSec=3 +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true +ReadWritePaths=/mnt/data/qsafe/data +RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX +MemoryMax=256M +TasksMax=48 +[Install] +WantedBy=multi-user.target +EOF +cat > /etc/systemd/system/qsafe-backup.service <<'EOF' +[Unit] +Description=QSafe verified encrypted backup to SD card +RequiresMountsFor=/mnt/data/qsafe/data +[Service] +Type=oneshot +UMask=0077 +ExecStart=/usr/bin/python3 /opt/qsafe/backup.py /mnt/data/qsafe/data/vault.sqlite3 /var/backups/qsafe +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +ReadWritePaths=/var/backups/qsafe /mnt/data/qsafe/data +EOF +cat > /etc/systemd/system/qsafe-backup.timer <<'EOF' +[Unit] +Description=Daily QSafe encrypted backup +[Timer] +OnCalendar=*-*-* 04:25:00 +Persistent=true +RandomizedDelaySec=300 +[Install] +WantedBy=timers.target +EOF +: "${QSAFE_LAN_IP:?Set the Pi private IPv4; public vault endpoints are forbidden}" +python3 "$stage/lan-only.py" --ip "$QSAFE_LAN_IP" +systemctl daemon-reload +systemctl enable --now qsafe.service qsafe-backup.timer +systemctl restart qsafe.service +for i in $(seq 1 20); do + if curl -fsS http://127.0.0.1:18792/health; then break; fi + sleep 1 +done +systemctl start qsafe-backup.service +systemctl is-active qsafe.service caddy.service qsafe-backup.timer diff --git a/deploy/lan-only.py b/deploy/lan-only.py new file mode 100644 index 0000000..9bad786 --- /dev/null +++ b/deploy/lan-only.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""Migrate only QSafe to a private-IP TLS listener; preserve Argus updates and vault data.""" +import argparse +import hashlib +import ipaddress +import json +import os +from pathlib import Path +import shutil +import subprocess +import time + + +def run(*args): + subprocess.run(args, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE) + + +def main(): + p = argparse.ArgumentParser() + p.add_argument('--ip', required=True) + p.add_argument('--port', type=int, default=18443) + a = p.parse_args() + ip = ipaddress.IPv4Address(a.ip) + if not any(ip in n for n in map(ipaddress.IPv4Network, ['10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16'])) or not 1024 <= a.port <= 65535: + raise SystemExit('A private LAN IPv4 and unprivileged TLS port are required') + links = json.loads(subprocess.check_output(['ip', '-j', '-4', 'address', 'show'])) + match = [(link['ifname'], address['prefixlen']) for link in links for address in link.get('addr_info', []) if address.get('local') == str(ip)] + if len(match) != 1 or not match[0][0].startswith(('wl', 'eth', 'en')): + raise SystemExit('IP must belong to a physical LAN interface') + subnet = ipaddress.IPv4Network(f'{ip}/{match[0][1]}', strict=False) + if subnet.prefixlen < 16: + raise SystemExit('LAN prefix is too broad') + config = Path('/etc/caddy/Caddyfile') + site = Path('/etc/caddy/sites/qsafe-lan.caddy') + original = config.read_text() + if original.count('# BEGIN QSafe') == 0 and original.count('# END QSafe') == 0: + needle = 'argus.kusoft.xyz {\n' + if original.count(needle) != 1: raise SystemExit('Expected Argus site; no change made') + original = original.replace(needle, needle + '\t# BEGIN QSafe\n\t# END QSafe\n', 1) + if original.count('# BEGIN QSafe\n') != 1 or original.count('# END QSafe\n') != 1: + raise SystemExit('Expected exactly one owned QSafe block; no change made') + start = original.index('\t# BEGIN QSafe') + end = original.index('\n', original.index('# END QSafe')) + replacement = '''\t# BEGIN QSafe +\t@qsafe_disabled path /qsafe /qsafe/* +\thandle @qsafe_disabled { +\t\trespond 404 +\t} +\t# END QSafe''' + backup = Path('/var/backups/qsafe-lan') / time.strftime('%Y%m%dT%H%M%SZ', time.gmtime()) + backup.mkdir(parents=True, mode=0o700) + shutil.copy2(config, backup / 'Caddyfile') + if site.exists(): shutil.copy2(site, backup / 'qsafe-lan.caddy') + tls_dir = Path('/etc/caddy/qsafe-lan') + tls_dir.mkdir(mode=0o750, exist_ok=True) + shutil.chown(tls_dir, user='root', group='caddy'); os.chmod(tls_dir, 0o750) + cert, key = tls_dir / 'lan.crt', tls_dir / 'lan.key' + if cert.exists() != key.exists(): raise SystemExit('Incomplete existing TLS identity; no change made') + if not cert.exists(): + run('openssl', 'req', '-x509', '-newkey', 'rsa:3072', '-sha256', '-nodes', '-days', '3650', '-subj', '/CN=QSafe LAN', '-addext', f'subjectAltName=IP:{ip}', '-addext', 'extendedKeyUsage=serverAuth', '-keyout', str(key), '-out', str(cert)) + run('openssl', 'x509', '-in', str(cert), '-noout', '-checkip', str(ip)) + for file in (cert, key): + shutil.chown(file, user='root', group='caddy'); os.chmod(file, 0o640) + lan = f'''# QSafe: private LAN listener, no ACME or public hostname. +https://{ip}:{a.port} {{ + bind {ip} + tls {cert} {key} + @foreign not remote_ip {subnet} + respond @foreign 403 + handle_path /qsafe/* {{ + request_body {{ + max_size 32MB + }} + reverse_proxy 127.0.0.1:18792 + }} + handle {{ + respond 404 + }} +}} +''' + try: + config.write_text(original[:start] + replacement + original[end:]) + site.write_text(lan) + run('caddy', 'validate', '--config', str(config), '--adapter', 'caddyfile') + run('systemctl', 'reload', 'caddy') + except BaseException: + shutil.copy2(backup / 'Caddyfile', config) + if (backup / 'qsafe-lan.caddy').exists(): shutil.copy2(backup / 'qsafe-lan.caddy', site) + else: site.unlink(missing_ok=True) + raise + der = subprocess.check_output(['openssl', 'x509', '-in', str(cert), '-outform', 'DER']) + print(json.dumps({'url': f'https://{ip}:{a.port}/qsafe/', 'homeSubnet': str(subnet), 'certificateSha256': hashlib.sha256(der).hexdigest(), 'publicVaultRoute': 'disabled', 'argusUpdates': 'unchanged', 'backup': str(backup)})) + + +if __name__ == '__main__': main() diff --git a/deploy/publish_argus.py b/deploy/publish_argus.py new file mode 100644 index 0000000..dae1779 --- /dev/null +++ b/deploy/publish_argus.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +"""Publish a reviewed QSafe APK into the existing Argus catalog, with rollback and identity guards.""" +import argparse +from contextlib import closing +from datetime import datetime, timezone +import hashlib +import json +import os +from pathlib import Path +import re +import shutil +import sqlite3 +import tempfile +import uuid + +ROOT = Path('/mnt/data/argus').resolve() +SLUG, PACKAGE = 'qsafe', 'ru.kusoft.qsafe' + + +def digest(path): + h = hashlib.sha256() + with path.open('rb') as f: + for chunk in iter(lambda: f.read(1024 * 1024), b''): + h.update(chunk) + return h.hexdigest() + + +def check(db): + if db.execute('PRAGMA integrity_check').fetchall() != [('ok',)]: + raise RuntimeError('SQLite integrity check failed') + if db.execute('PRAGMA foreign_key_check').fetchall(): + raise RuntimeError('SQLite foreign key check failed') + + +def publish(source, metadata): + version, code, size, sha = (metadata[k] for k in ['version', 'versionCode', 'size', 'sha256']) + if not re.fullmatch(r'\d+\.\d+\.\d+', version) or not isinstance(code, int) or code < 1 or not isinstance(size, int) or size < 1 or not re.fullmatch('[0-9a-f]{64}', sha): + raise ValueError('Invalid reviewed metadata') + if not source.is_file() or source.stat().st_size != size or digest(source) != sha: + raise ValueError('Reviewed APK does not match source') + with closing(sqlite3.connect(ROOT / 'argus.db', timeout=30)) as db: + db.execute('PRAGMA foreign_keys=ON') + check(db) + existing = db.execute('SELECT Id,AndroidPackageName FROM Apps WHERE Slug=?', (SLUG,)).fetchone() + if existing and existing[1] != PACKAGE: + raise RuntimeError('Slug is owned by another package') + if db.execute('SELECT Slug FROM Apps WHERE AndroidPackageName=? AND Slug<>?', (PACKAGE, SLUG)).fetchall(): + raise RuntimeError('Another slug owns this Android package') + if existing: + same = db.execute('SELECT Id,StoredRelativePath FROM Releases WHERE CatalogAppId=? AND Version=? AND AndroidVersionCode=? AND Sha256=?', (existing[0], version, code, sha)).fetchone() + if same: + file = (ROOT / 'Packages' / same[1]).resolve() + file.relative_to(ROOT / 'Packages') + if not file.is_file() or digest(file) != sha: + raise RuntimeError('Existing publication has an invalid artifact') + return {'alreadyPublished': True, 'slug': SLUG, 'releaseId': same[0], 'sha256': sha} + previous = db.execute('SELECT MAX(AndroidVersionCode) FROM Releases WHERE CatalogAppId=?', (existing[0],)).fetchone()[0] + if metadata.get('expectedPreviousVersionCode') != previous or (previous is not None and code <= previous): + raise RuntimeError('Catalog changed or versionCode did not increase') + elif metadata.get('expectedPreviousVersionCode') is not None: + raise RuntimeError('Expected previous release is missing') + stamp = datetime.now(timezone.utc) + now = stamp.isoformat(timespec='microseconds') + backup_dir = ROOT / 'backups' / ('qsafe-' + version + '-' + stamp.strftime('%Y%m%dT%H%M%S%fZ')) + backup_dir.mkdir(mode=0o700) + with closing(sqlite3.connect(backup_dir / 'argus.db')) as saved: + db.backup(saved) + check(saved) + if existing: + for relative, in db.execute('SELECT StoredRelativePath FROM Releases WHERE CatalogAppId=?', (existing[0],)): + old = (ROOT / 'Packages' / relative).resolve() + old.relative_to(ROOT / 'Packages') + if old.is_file(): + dest = backup_dir / 'Packages' / relative + dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(old, dest) + app_id = existing[0] if existing else str(uuid.uuid4()).upper() + release_id = str(uuid.uuid4()).upper() + package_dir = ROOT / 'Packages' / SLUG + if package_dir.is_symlink(): + raise RuntimeError('Package directory may not be a symlink') + package_dir.mkdir(exist_ok=True) + package_dir.resolve().relative_to(ROOT / 'Packages') + relative = SLUG + '/' + stamp.strftime('%Y%m%d%H%M%S') + '-' + version + '-' + release_id.replace('-', '') + '.apk' + target = ROOT / 'Packages' / relative + with tempfile.NamedTemporaryFile(dir=package_dir, prefix='.upload-', delete=False) as output: + temp = Path(output.name) + with source.open('rb') as src: + shutil.copyfileobj(src, output) + output.flush() + os.fsync(output.fileno()) + temp.chmod(0o644) + if digest(temp) != sha: + temp.unlink() + raise RuntimeError('Staging hash mismatch') + temp.replace(target) + + def unrelated(): + rows = [db.execute('SELECT * FROM Apps WHERE Id<>? ORDER BY Id', (app_id,)).fetchall(), db.execute('SELECT * FROM Releases WHERE CatalogAppId<>? ORDER BY Id', (app_id,)).fetchall()] + return hashlib.sha256(json.dumps(rows, ensure_ascii=False).encode()).hexdigest() + + committed = False + try: + db.execute('BEGIN IMMEDIATE') + if db.execute('SELECT Id,AndroidPackageName FROM Apps WHERE Slug=?', (SLUG,)).fetchone() != existing: + raise RuntimeError('Application catalog changed during preparation') + before = unrelated() + if existing: + previous = db.execute('SELECT MAX(AndroidVersionCode) FROM Releases WHERE CatalogAppId=?', (app_id,)).fetchone()[0] + if metadata.get('expectedPreviousVersionCode') != previous: + raise RuntimeError('Previous release changed during preparation') + db.execute('UPDATE Apps SET Name=?,Summary=?,Description=?,IsListed=1,UpdatedAt=? WHERE Id=?', ('QSafe', metadata['summary'], metadata['description'], now, app_id)) + else: + db.execute('INSERT INTO Apps VALUES(?,?,?,?,?,NULL,NULL,?,1,?,?)', (app_id, SLUG, 'QSafe', metadata['summary'], metadata['description'], PACKAGE, now, now)) + db.execute('INSERT INTO Releases VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)', (release_id, app_id, version, 'stable', 'android', 'apk', code, source.name, relative, 'application/vnd.android.package-archive', size, sha, metadata['notes'], now)) + if unrelated() != before: + raise RuntimeError('Unrelated catalog data changed') + if db.execute('SELECT COUNT(*) FROM Releases WHERE CatalogAppId=?', (app_id,)).fetchone()[0] != 1: + raise RuntimeError('Expected a single retained release') + if db.execute('PRAGMA foreign_key_check').fetchall(): + raise RuntimeError('Foreign key check failed before commit') + db.commit() + committed = True + finally: + if not committed: + db.rollback() + target.unlink(missing_ok=True) + check(db) + report = {'slug': SLUG, 'version': version, 'versionCode': code, 'size': size, 'sha256': sha, 'releaseId': release_id, + 'backup': str(backup_dir), 'integrity': 'ok', 'unrelatedCatalogRowsUnchanged': True, + 'catalogUrl': 'https://argus.kusoft.xyz/?app=qsafe', + 'manifestUrl': 'https://argus.kusoft.xyz/api/apps/qsafe/manifest?platform=android&channel=stable', + 'downloadUrl': f'https://argus.kusoft.xyz/api/apps/qsafe/releases/{release_id}/download'} + (backup_dir / 'publication.json').write_text(json.dumps(report, ensure_ascii=False, indent=2)) + return report + + +if __name__ == '__main__': + p = argparse.ArgumentParser() + p.add_argument('apk', type=Path) + p.add_argument('metadata', type=Path) + args = p.parse_args() + print(json.dumps(publish(args.apk, json.loads(args.metadata.read_text())), ensure_ascii=False)) diff --git a/deploy/qa-start.sh b/deploy/qa-start.sh new file mode 100755 index 0000000..1602747 --- /dev/null +++ b/deploy/qa-start.sh @@ -0,0 +1,26 @@ +#!/bin/bash +set -euo pipefail +stage="$(cd "$(dirname "$0")" && pwd)" +test ! -e /mnt/data/qsafe/qa/vault.sqlite3 || { echo 'QA vault already exists'; exit 1; } +install -d -o qsafe -g qsafe -m 700 /mnt/data/qsafe/qa +install -o root -g qsafe -m 640 "$stage/qa-token.sha256" /etc/qsafe/qa-token.sha256 +systemd-run --unit=qsafe-qa --property=User=qsafe --property=Group=qsafe --property=UMask=0077 \ + /usr/bin/python3 /opt/qsafe/server.py --port 18793 --data /mnt/data/qsafe/qa --token-hash-file /etc/qsafe/qa-token.sha256 +python3 - <<'PY' +from pathlib import Path +p = Path('/etc/caddy/sites/qsafe-lan.caddy') +t = p.read_text() +if '# BEGIN QSafe QA' in t: + raise SystemExit('QA route already present') +needle = ' handle_path /qsafe/* {\n' +block = '''\t# BEGIN QSafe QA +\thandle_path /qsafe-qa/* { +\t\treverse_proxy 127.0.0.1:18793 +\t} +\t# END QSafe QA +''' +assert t.count(needle) == 1, 'LAN-only QSafe site must be installed first' +p.write_text(t.replace(needle, block + needle, 1)) +PY +caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile >/dev/null +systemctl reload caddy diff --git a/deploy/qa-stop.sh b/deploy/qa-stop.sh new file mode 100755 index 0000000..889377b --- /dev/null +++ b/deploy/qa-stop.sh @@ -0,0 +1,16 @@ +#!/bin/bash +set -euo pipefail +python3 - <<'PY' +from pathlib import Path +p = Path('/etc/caddy/sites/qsafe-lan.caddy') +t = p.read_text() +start = t.find('\t# BEGIN QSafe QA\n') +endmarker = '\t# END QSafe QA\n' +end = t.find(endmarker, start) +if start >= 0 and end >= 0: + p.write_text(t[:start] + t[end + len(endmarker):]) +PY +caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile >/dev/null +systemctl reload caddy +systemctl stop qsafe-qa +rm -f /etc/qsafe/qa-token.sha256 diff --git a/deploy/test_publish_argus.py b/deploy/test_publish_argus.py new file mode 100644 index 0000000..2bb0d39 --- /dev/null +++ b/deploy/test_publish_argus.py @@ -0,0 +1,44 @@ +import hashlib +import sqlite3 +import tempfile +import unittest +from pathlib import Path +import publish_argus as publisher + + +class PublicationTests(unittest.TestCase): + def test_catalog_retains_latest_with_backup_and_other_apps_unchanged(self): + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + publisher.ROOT = root + (root / 'backups').mkdir() + (root / 'Packages' / 'qsafe').mkdir(parents=True) + old = root / 'Packages/qsafe/old.apk' + old.write_bytes(b'old synthetic APK') + with sqlite3.connect(root / 'argus.db') as db: + db.executescript(''' + CREATE TABLE Apps (Id TEXT PRIMARY KEY, Slug TEXT, Name TEXT, Summary TEXT, Description TEXT, Icon TEXT, Extra TEXT, AndroidPackageName TEXT, IsListed INTEGER, CreatedAt TEXT, UpdatedAt TEXT); + CREATE TABLE Releases (Id TEXT PRIMARY KEY, CatalogAppId TEXT REFERENCES Apps(Id), Version TEXT, Channel TEXT, Platform TEXT, PackageKind TEXT, AndroidVersionCode INTEGER, OriginalFileName TEXT, StoredRelativePath TEXT, ContentType TEXT, PackageSizeBytes INTEGER, Sha256 TEXT, Notes TEXT, PublishedAt TEXT); + CREATE TRIGGER Argus_RetainLatestAfterInsert AFTER INSERT ON Releases BEGIN + DELETE FROM Releases WHERE CatalogAppId=NEW.CatalogAppId AND rowid NOT IN (SELECT rowid FROM Releases WHERE CatalogAppId=NEW.CatalogAppId ORDER BY AndroidVersionCode DESC LIMIT 1); + END; + INSERT INTO Apps VALUES ('qsafe-id','qsafe','QSafe','old','old',NULL,NULL,'ru.kusoft.qsafe',1,'old','old'); + INSERT INTO Apps VALUES ('other-id','other','Other','keep','keep',NULL,NULL,'other.package',1,'old','old'); + ''') + db.execute('INSERT INTO Releases VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)', ('old-release', 'qsafe-id', '1.0.0', 'stable', 'android', 'apk', 3, 'old.apk', 'qsafe/old.apk', 'application/vnd.android.package-archive', old.stat().st_size, hashlib.sha256(old.read_bytes()).hexdigest(), 'old', 'old')) + new = root / 'QSafe-1.0.1.apk' + new.write_bytes(b'new synthetic APK') + metadata = dict(version='1.0.1', versionCode=4, size=new.stat().st_size, sha256=hashlib.sha256(new.read_bytes()).hexdigest(), expectedPreviousVersionCode=3, summary='new', description='new', notes='LAN only vault; Argus updates') + result = publisher.publish(new, metadata) + self.assertTrue(result['unrelatedCatalogRowsUnchanged']) + self.assertEqual(b'old synthetic APK', old.read_bytes()) + with sqlite3.connect(root / 'argus.db') as db: + self.assertEqual(1, db.execute("SELECT COUNT(*) FROM Releases WHERE CatalogAppId='qsafe-id'").fetchone()[0]) + self.assertEqual(('keep', 'keep'), db.execute("SELECT Summary,Description FROM Apps WHERE Id='other-id'").fetchone()) + self.assertTrue(publisher.publish(new, metadata)['alreadyPublished']) + self.assertEqual(b'old synthetic APK', (Path(result['backup']) / 'Packages/qsafe/old.apk').read_bytes()) + wrong = dict(metadata, version='1.0.2', versionCode=5) + with self.assertRaises(RuntimeError): publisher.publish(new, wrong) + + +if __name__ == '__main__': unittest.main() diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..e982b9b --- /dev/null +++ b/gradle.properties @@ -0,0 +1,4 @@ +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +android.useAndroidX=true +org.gradle.workers.max=2 +android.overridePathCheck=true diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..2c3521197d7c4586c843d1d3e9090525f1898cde GIT binary patch literal 43504 zcma&N1CXTcmMvW9vTb(Rwr$&4wr$(C?dmSu>@vG-+vuvg^_??!{yS%8zW-#zn-LkA z5&1^$^{lnmUON?}LBF8_K|(?T0Ra(xUH{($5eN!MR#ZihR#HxkUPe+_R8Cn`RRs(P z_^*#_XlXmGv7!4;*Y%p4nw?{bNp@UZHv1?Um8r6)Fei3p@ClJn0ECfg1hkeuUU@Or zDaPa;U3fE=3L}DooL;8f;P0ipPt0Z~9P0)lbStMS)ag54=uL9ia-Lm3nh|@(Y?B`; zx_#arJIpXH!U{fbCbI^17}6Ri*H<>OLR%c|^mh8+)*h~K8Z!9)DPf zR2h?lbDZQ`p9P;&DQ4F0sur@TMa!Y}S8irn(%d-gi0*WxxCSk*A?3lGh=gcYN?FGl z7D=Js!i~0=u3rox^eO3i@$0=n{K1lPNU zwmfjRVmLOCRfe=seV&P*1Iq=^i`502keY8Uy-WNPwVNNtJFx?IwAyRPZo2Wo1+S(xF37LJZ~%i)kpFQ3Fw=mXfd@>%+)RpYQLnr}B~~zoof(JVm^^&f zxKV^+3D3$A1G;qh4gPVjhrC8e(VYUHv#dy^)(RoUFM?o%W-EHxufuWf(l*@-l+7vt z=l`qmR56K~F|v<^Pd*p~1_y^P0P^aPC##d8+HqX4IR1gu+7w#~TBFphJxF)T$2WEa zxa?H&6=Qe7d(#tha?_1uQys2KtHQ{)Qco)qwGjrdNL7thd^G5i8Os)CHqc>iOidS} z%nFEDdm=GXBw=yXe1W-ShHHFb?Cc70+$W~z_+}nAoHFYI1MV1wZegw*0y^tC*s%3h zhD3tN8b=Gv&rj}!SUM6|ajSPp*58KR7MPpI{oAJCtY~JECm)*m_x>AZEu>DFgUcby z1Qaw8lU4jZpQ_$;*7RME+gq1KySGG#Wql>aL~k9tLrSO()LWn*q&YxHEuzmwd1?aAtI zBJ>P=&$=l1efe1CDU;`Fd+_;&wI07?V0aAIgc(!{a z0Jg6Y=inXc3^n!U0Atk`iCFIQooHqcWhO(qrieUOW8X(x?(RD}iYDLMjSwffH2~tB z)oDgNBLB^AJBM1M^c5HdRx6fBfka`(LD-qrlh5jqH~);#nw|iyp)()xVYak3;Ybik z0j`(+69aK*B>)e_p%=wu8XC&9e{AO4c~O1U`5X9}?0mrd*m$_EUek{R?DNSh(=br# z#Q61gBzEpmy`$pA*6!87 zSDD+=@fTY7<4A?GLqpA?Pb2z$pbCc4B4zL{BeZ?F-8`s$?>*lXXtn*NC61>|*w7J* z$?!iB{6R-0=KFmyp1nnEmLsA-H0a6l+1uaH^g%c(p{iT&YFrbQ$&PRb8Up#X3@Zsk zD^^&LK~111%cqlP%!_gFNa^dTYT?rhkGl}5=fL{a`UViaXWI$k-UcHJwmaH1s=S$4 z%4)PdWJX;hh5UoK?6aWoyLxX&NhNRqKam7tcOkLh{%j3K^4Mgx1@i|Pi&}<^5>hs5 zm8?uOS>%)NzT(%PjVPGa?X%`N2TQCKbeH2l;cTnHiHppPSJ<7y-yEIiC!P*ikl&!B z%+?>VttCOQM@ShFguHVjxX^?mHX^hSaO_;pnyh^v9EumqSZTi+#f&_Vaija0Q-e*| z7ulQj6Fs*bbmsWp{`auM04gGwsYYdNNZcg|ph0OgD>7O}Asn7^Z=eI>`$2*v78;sj-}oMoEj&@)9+ycEOo92xSyY344^ z11Hb8^kdOvbf^GNAK++bYioknrpdN>+u8R?JxG=!2Kd9r=YWCOJYXYuM0cOq^FhEd zBg2puKy__7VT3-r*dG4c62Wgxi52EMCQ`bKgf*#*ou(D4-ZN$+mg&7$u!! z-^+Z%;-3IDwqZ|K=ah85OLwkO zKxNBh+4QHh)u9D?MFtpbl)us}9+V!D%w9jfAMYEb>%$A;u)rrI zuBudh;5PN}_6J_}l55P3l_)&RMlH{m!)ai-i$g)&*M`eN$XQMw{v^r@-125^RRCF0 z^2>|DxhQw(mtNEI2Kj(;KblC7x=JlK$@78`O~>V!`|1Lm-^JR$-5pUANAnb(5}B}JGjBsliK4& zk6y(;$e&h)lh2)L=bvZKbvh@>vLlreBdH8No2>$#%_Wp1U0N7Ank!6$dFSi#xzh|( zRi{Uw%-4W!{IXZ)fWx@XX6;&(m_F%c6~X8hx=BN1&q}*( zoaNjWabE{oUPb!Bt$eyd#$5j9rItB-h*5JiNi(v^e|XKAj*8(k<5-2$&ZBR5fF|JA z9&m4fbzNQnAU}r8ab>fFV%J0z5awe#UZ|bz?Ur)U9bCIKWEzi2%A+5CLqh?}K4JHi z4vtM;+uPsVz{Lfr;78W78gC;z*yTch~4YkLr&m-7%-xc ztw6Mh2d>_iO*$Rd8(-Cr1_V8EO1f*^@wRoSozS) zy1UoC@pruAaC8Z_7~_w4Q6n*&B0AjOmMWa;sIav&gu z|J5&|{=a@vR!~k-OjKEgPFCzcJ>#A1uL&7xTDn;{XBdeM}V=l3B8fE1--DHjSaxoSjNKEM9|U9#m2<3>n{Iuo`r3UZp;>GkT2YBNAh|b z^jTq-hJp(ebZh#Lk8hVBP%qXwv-@vbvoREX$TqRGTgEi$%_F9tZES@z8Bx}$#5eeG zk^UsLBH{bc2VBW)*EdS({yw=?qmevwi?BL6*=12k9zM5gJv1>y#ML4!)iiPzVaH9% zgSImetD@dam~e>{LvVh!phhzpW+iFvWpGT#CVE5TQ40n%F|p(sP5mXxna+Ev7PDwA zamaV4m*^~*xV+&p;W749xhb_X=$|LD;FHuB&JL5?*Y2-oIT(wYY2;73<^#46S~Gx| z^cez%V7x$81}UWqS13Gz80379Rj;6~WdiXWOSsdmzY39L;Hg3MH43o*y8ibNBBH`(av4|u;YPq%{R;IuYow<+GEsf@R?=@tT@!}?#>zIIn0CoyV!hq3mw zHj>OOjfJM3F{RG#6ujzo?y32m^tgSXf@v=J$ELdJ+=5j|=F-~hP$G&}tDZsZE?5rX ztGj`!S>)CFmdkccxM9eGIcGnS2AfK#gXwj%esuIBNJQP1WV~b~+D7PJTmWGTSDrR` zEAu4B8l>NPuhsk5a`rReSya2nfV1EK01+G!x8aBdTs3Io$u5!6n6KX%uv@DxAp3F@{4UYg4SWJtQ-W~0MDb|j-$lwVn znAm*Pl!?Ps&3wO=R115RWKb*JKoexo*)uhhHBncEDMSVa_PyA>k{Zm2(wMQ(5NM3# z)jkza|GoWEQo4^s*wE(gHz?Xsg4`}HUAcs42cM1-qq_=+=!Gk^y710j=66(cSWqUe zklbm8+zB_syQv5A2rj!Vbw8;|$@C!vfNmNV!yJIWDQ>{+2x zKjuFX`~~HKG~^6h5FntRpnnHt=D&rq0>IJ9#F0eM)Y-)GpRjiN7gkA8wvnG#K=q{q z9dBn8_~wm4J<3J_vl|9H{7q6u2A!cW{bp#r*-f{gOV^e=8S{nc1DxMHFwuM$;aVI^ zz6A*}m8N-&x8;aunp1w7_vtB*pa+OYBw=TMc6QK=mbA-|Cf* zvyh8D4LRJImooUaSb7t*fVfih<97Gf@VE0|z>NcBwBQze);Rh!k3K_sfunToZY;f2 z^HmC4KjHRVg+eKYj;PRN^|E0>Gj_zagfRbrki68I^#~6-HaHg3BUW%+clM1xQEdPYt_g<2K+z!$>*$9nQ>; zf9Bei{?zY^-e{q_*|W#2rJG`2fy@{%6u0i_VEWTq$*(ZN37|8lFFFt)nCG({r!q#9 z5VK_kkSJ3?zOH)OezMT{!YkCuSSn!K#-Rhl$uUM(bq*jY? zi1xbMVthJ`E>d>(f3)~fozjg^@eheMF6<)I`oeJYx4*+M&%c9VArn(OM-wp%M<-`x z7sLP1&3^%Nld9Dhm@$3f2}87!quhI@nwd@3~fZl_3LYW-B?Ia>ui`ELg z&Qfe!7m6ze=mZ`Ia9$z|ARSw|IdMpooY4YiPN8K z4B(ts3p%2i(Td=tgEHX z0UQ_>URBtG+-?0E;E7Ld^dyZ;jjw0}XZ(}-QzC6+NN=40oDb2^v!L1g9xRvE#@IBR zO!b-2N7wVfLV;mhEaXQ9XAU+>=XVA6f&T4Z-@AX!leJ8obP^P^wP0aICND?~w&NykJ#54x3_@r7IDMdRNy4Hh;h*!u(Ol(#0bJdwEo$5437-UBjQ+j=Ic>Q2z` zJNDf0yO6@mr6y1#n3)s(W|$iE_i8r@Gd@!DWDqZ7J&~gAm1#~maIGJ1sls^gxL9LLG_NhU!pTGty!TbhzQnu)I*S^54U6Yu%ZeCg`R>Q zhBv$n5j0v%O_j{QYWG!R9W?5_b&67KB$t}&e2LdMvd(PxN6Ir!H4>PNlerpBL>Zvyy!yw z-SOo8caEpDt(}|gKPBd$qND5#a5nju^O>V&;f890?yEOfkSG^HQVmEbM3Ugzu+UtH zC(INPDdraBN?P%kE;*Ae%Wto&sgw(crfZ#Qy(<4nk;S|hD3j{IQRI6Yq|f^basLY; z-HB&Je%Gg}Jt@={_C{L$!RM;$$|iD6vu#3w?v?*;&()uB|I-XqEKqZPS!reW9JkLewLb!70T7n`i!gNtb1%vN- zySZj{8-1>6E%H&=V}LM#xmt`J3XQoaD|@XygXjdZ1+P77-=;=eYpoEQ01B@L*a(uW zrZeZz?HJsw_4g0vhUgkg@VF8<-X$B8pOqCuWAl28uB|@r`19DTUQQsb^pfqB6QtiT z*`_UZ`fT}vtUY#%sq2{rchyfu*pCg;uec2$-$N_xgjZcoumE5vSI{+s@iLWoz^Mf; zuI8kDP{!XY6OP~q5}%1&L}CtfH^N<3o4L@J@zg1-mt{9L`s^z$Vgb|mr{@WiwAqKg zp#t-lhrU>F8o0s1q_9y`gQNf~Vb!F%70f}$>i7o4ho$`uciNf=xgJ>&!gSt0g;M>*x4-`U)ysFW&Vs^Vk6m%?iuWU+o&m(2Jm26Y(3%TL; zA7T)BP{WS!&xmxNw%J=$MPfn(9*^*TV;$JwRy8Zl*yUZi8jWYF>==j~&S|Xinsb%c z2?B+kpet*muEW7@AzjBA^wAJBY8i|#C{WtO_or&Nj2{=6JTTX05}|H>N2B|Wf!*3_ z7hW*j6p3TvpghEc6-wufFiY!%-GvOx*bZrhZu+7?iSrZL5q9}igiF^*R3%DE4aCHZ zqu>xS8LkW+Auv%z-<1Xs92u23R$nk@Pk}MU5!gT|c7vGlEA%G^2th&Q*zfg%-D^=f z&J_}jskj|Q;73NP4<4k*Y%pXPU2Thoqr+5uH1yEYM|VtBPW6lXaetokD0u z9qVek6Q&wk)tFbQ8(^HGf3Wp16gKmr>G;#G(HRBx?F`9AIRboK+;OfHaLJ(P>IP0w zyTbTkx_THEOs%Q&aPrxbZrJlio+hCC_HK<4%f3ZoSAyG7Dn`=X=&h@m*|UYO-4Hq0 z-Bq&+Ie!S##4A6OGoC~>ZW`Y5J)*ouaFl_e9GA*VSL!O_@xGiBw!AF}1{tB)z(w%c zS1Hmrb9OC8>0a_$BzeiN?rkPLc9%&;1CZW*4}CDDNr2gcl_3z+WC15&H1Zc2{o~i) z)LLW=WQ{?ricmC`G1GfJ0Yp4Dy~Ba;j6ZV4r{8xRs`13{dD!xXmr^Aga|C=iSmor% z8hi|pTXH)5Yf&v~exp3o+sY4B^^b*eYkkCYl*T{*=-0HniSA_1F53eCb{x~1k3*`W zr~};p1A`k{1DV9=UPnLDgz{aJH=-LQo<5%+Em!DNN252xwIf*wF_zS^!(XSm(9eoj z=*dXG&n0>)_)N5oc6v!>-bd(2ragD8O=M|wGW z!xJQS<)u70m&6OmrF0WSsr@I%T*c#Qo#Ha4d3COcX+9}hM5!7JIGF>7<~C(Ear^Sn zm^ZFkV6~Ula6+8S?oOROOA6$C&q&dp`>oR-2Ym3(HT@O7Sd5c~+kjrmM)YmgPH*tL zX+znN>`tv;5eOfX?h{AuX^LK~V#gPCu=)Tigtq9&?7Xh$qN|%A$?V*v=&-2F$zTUv z`C#WyIrChS5|Kgm_GeudCFf;)!WH7FI60j^0o#65o6`w*S7R@)88n$1nrgU(oU0M9 zx+EuMkC>(4j1;m6NoGqEkpJYJ?vc|B zOlwT3t&UgL!pX_P*6g36`ZXQ; z9~Cv}ANFnJGp(;ZhS(@FT;3e)0)Kp;h^x;$*xZn*k0U6-&FwI=uOGaODdrsp-!K$Ac32^c{+FhI-HkYd5v=`PGsg%6I`4d9Jy)uW0y%) zm&j^9WBAp*P8#kGJUhB!L?a%h$hJgQrx!6KCB_TRo%9{t0J7KW8!o1B!NC)VGLM5! zpZy5Jc{`r{1e(jd%jsG7k%I+m#CGS*BPA65ZVW~fLYw0dA-H_}O zrkGFL&P1PG9p2(%QiEWm6x;U-U&I#;Em$nx-_I^wtgw3xUPVVu zqSuKnx&dIT-XT+T10p;yjo1Y)z(x1fb8Dzfn8e yu?e%!_ptzGB|8GrCfu%p?(_ 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 TZiJi*F`$tkWLn) literal 0 HcmV?d00001 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..6514f91 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..f5feea6 --- /dev/null +++ b/gradlew @@ -0,0 +1,252 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..9d21a21 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/server/backup.py b/server/backup.py new file mode 100644 index 0000000..13a86e4 --- /dev/null +++ b/server/backup.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +"""Consistent encrypted SQLite backup, verified and atomically published on a separate filesystem.""" +import datetime +import os +import pathlib +import sqlite3 +import sys +from contextlib import closing + +os.umask(0o077) +source, target = map(pathlib.Path, sys.argv[1:3]) +target.mkdir(parents=True, exist_ok=True) +stamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%dT%H%M%S%fZ") +temp = target / (stamp + ".tmp") +final = target / (stamp + ".sqlite3") +try: + with closing(sqlite3.connect(f"file:{source}?mode=ro", uri=True)) as src, closing(sqlite3.connect(temp)) as dst: + src.backup(dst) + dst.execute("PRAGMA journal_mode=DELETE") + if dst.execute("PRAGMA integrity_check").fetchone()[0] != "ok": + raise RuntimeError("Backup failed integrity check") + with temp.open("rb") as f: + os.fsync(f.fileno()) + temp.replace(final) + descriptor = os.open(target, os.O_RDONLY | os.O_DIRECTORY) + try: + os.fsync(descriptor) + finally: + os.close(descriptor) + total = 0 + for index, old in enumerate(sorted(target.glob("*.sqlite3"), reverse=True)): + total += old.stat().st_size + if index > 0 and (index >= 30 or total > 512 * 1024 * 1024): + old.unlink() +finally: + temp.unlink(missing_ok=True) diff --git a/server/server.py b/server/server.py new file mode 100644 index 0000000..d6fabe2 --- /dev/null +++ b/server/server.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +"""QSafe blind single-vault sync server. Standard library only; TLS is provided by Caddy.""" +import argparse +import hashlib +import hmac +import json +import os +import pathlib +import secrets +import sqlite3 +import struct +import time +from contextlib import contextmanager +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +MAX_BYTES = 32 * 1024 * 1024 + + +@contextmanager +def database(path): + db = sqlite3.connect(path, timeout=20) + db.execute("PRAGMA journal_mode=WAL") + db.execute("PRAGMA synchronous=FULL") + db.execute("CREATE TABLE IF NOT EXISTS vault (id INTEGER PRIMARY KEY CHECK(id=1), etag TEXT NOT NULL, body BLOB NOT NULL, updated INTEGER NOT NULL)") + db.execute("CREATE TABLE IF NOT EXISTS history (id INTEGER PRIMARY KEY, etag TEXT NOT NULL, body BLOB NOT NULL, updated INTEGER NOT NULL)") + try: + with db: + yield db + finally: + db.close() + + +class Server(ThreadingHTTPServer): + daemon_threads = True + def __init__(self, address, db_path, token_hash): + super().__init__(address, Handler) + self.db_path, self.token_hash = db_path, token_hash + with database(db_path) as db: + if db.execute("PRAGMA quick_check").fetchone()[0] != "ok": + raise RuntimeError("Database integrity check failed") + + +class Handler(BaseHTTPRequestHandler): + server_version = "QSafe" + sys_version = "" + protocol_version = "HTTP/1.1" + + def setup(self): + super().setup() + self.connection.settimeout(30) + + def log_message(self, *_args): + pass # Do not log credentials, payloads, URLs, or card data. + + def reply(self, code, body=b"", content_type="application/json", etag=None): + self.send_response(code) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store") + self.send_header("X-Content-Type-Options", "nosniff") + self.send_header("Connection", "close") + self.close_connection = True + if etag: + self.send_header("ETag", '"' + etag + '"') + self.end_headers() + if self.command != "HEAD": + self.wfile.write(body) + + def authenticated(self): + value = self.headers.get("Authorization", "") + if not value.startswith("Bearer ") or len(value) > 256: + self.reply(401, b'{"error":"unauthorized"}') + return False + candidate = hashlib.sha256(value[7:].encode()).hexdigest() + if not hmac.compare_digest(candidate, self.server.token_hash): + self.reply(401, b'{"error":"unauthorized"}') + return False + return True + + def do_GET(self): + if self.path == "/health": + self.reply(200, b'{"service":"qsafe","protocol":1}') + return + if self.path != "/v1/vault": + self.reply(404) + return + if not self.authenticated(): + return + with database(self.server.db_path) as db: + row = db.execute("SELECT etag,body FROM vault WHERE id=1").fetchone() + if row is None: + self.reply(404, b'{"error":"empty"}') + else: + self.reply(200, row[1], "application/octet-stream", row[0]) + + def do_PUT(self): + if self.path != "/v1/vault": + self.reply(404) + return + if not self.authenticated(): + return + try: + length = int(self.headers.get("Content-Length", "-1")) + except ValueError: + length = -1 + if self.headers.get("Transfer-Encoding") or not 56 <= length <= MAX_BYTES: + self.reply(413, b'{"error":"size"}') + return + body = self.rfile.read(length) + if len(body) != length or body[:8] != b"QSAFE01!" or not 600_000 <= struct.unpack(">I", body[8:12])[0] <= 2_000_000: + self.reply(400, b'{"error":"envelope"}') + return + etag = hashlib.sha256(body).hexdigest() + now = int(time.time()) + with database(self.server.db_path) as db: + db.execute("BEGIN IMMEDIATE") + previous = db.execute("SELECT etag,body,updated FROM vault WHERE id=1").fetchone() + match = self.headers.get("If-Match", "") + if previous is None: + valid = self.headers.get("If-None-Match") == "*" and not match + else: + valid = match == '"' + previous[0] + '"' and not self.headers.get("If-None-Match") + if not valid: + db.rollback() + self.reply(412, b'{"error":"version_conflict"}') + return + if previous and previous[0] != etag: + db.execute("INSERT INTO history(etag,body,updated) VALUES(?,?,?)", previous) + db.execute("INSERT OR REPLACE INTO vault VALUES(1,?,?,?)", (etag, body, now)) + # Keep the latest 100 encrypted revisions, independent of client clocks. + db.execute("DELETE FROM history WHERE id NOT IN (SELECT id FROM history ORDER BY id DESC LIMIT 100)") + rows = db.execute("SELECT id,length(body) FROM history ORDER BY id DESC").fetchall() + total = 0 + for version, size in rows: + total += size + if total > 256 * 1024 * 1024: + db.execute("DELETE FROM history WHERE id=?", (version,)) + self.reply(200, b'{"ok":true}', etag=etag) + + +def main(): + p = argparse.ArgumentParser() + p.add_argument("--bind", default="127.0.0.1") + p.add_argument("--port", type=int, default=18792) + p.add_argument("--data", required=True) + p.add_argument("--token-hash-file", required=True) + args = p.parse_args() + os.umask(0o077) + data = pathlib.Path(args.data) + data.mkdir(parents=True, exist_ok=True) + token_hash = pathlib.Path(args.token_hash_file).read_text().strip() + if len(token_hash) != 64 or any(c not in "0123456789abcdef" for c in token_hash): + raise ValueError("Invalid credential hash") + Server((args.bind, args.port), str(data / "vault.sqlite3"), token_hash).serve_forever() + + +if __name__ == "__main__": + main() diff --git a/server/test_server.py b/server/test_server.py new file mode 100644 index 0000000..574a79b --- /dev/null +++ b/server/test_server.py @@ -0,0 +1,89 @@ +import concurrent.futures +import hashlib +import http.client +import pathlib +import secrets +import sqlite3 +import struct +import tempfile +import threading +import subprocess +import sys +import unittest +from server import Server, database + + +class SyncTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.path = str(pathlib.Path(self.temp.name) / "test.sqlite3") + self.token = secrets.token_urlsafe(32) + self.server = Server(("127.0.0.1", 0), self.path, hashlib.sha256(self.token.encode()).hexdigest()) + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) + self.thread.start() + + def tearDown(self): + self.server.shutdown() + self.server.server_close() + self.thread.join() + self.temp.cleanup() + + def request(self, method, body=None, token=True, **headers): + c = http.client.HTTPConnection(*self.server.server_address, timeout=5) + if token: + headers["Authorization"] = "Bearer " + self.token + c.request(method, "/v1/vault", body=body, headers=headers) + r = c.getresponse() + result = r.status, dict(r.getheaders()), r.read() + c.close() + return result + + def envelope(self): + return b"QSAFE01!" + struct.pack(">I", 600_000) + secrets.token_bytes(80) + + def test_requires_credential_and_preconditions(self): + self.assertEqual(self.request("GET", token=False)[0], 401) + self.assertEqual(self.request("GET")[0], 404) + self.assertEqual(self.request("PUT", self.envelope())[0], 412) + self.assertEqual(self.request("PUT", b"plaintext", **{"If-None-Match": "*"})[0], 413) + + def test_roundtrip_and_versions(self): + a, b = self.envelope(), self.envelope() + status, headers, _ = self.request("PUT", a, **{"If-None-Match": "*"}) + self.assertEqual(status, 200) + self.assertEqual(self.request("GET")[2], a) + self.assertEqual(self.request("PUT", b, **{"If-Match": '"stale"'})[0], 412) + self.assertEqual(self.request("PUT", b, **{"If-Match": headers["ETag"]})[0], 200) + self.assertEqual(self.request("GET")[2], b) + with database(self.path) as db: + self.assertEqual(db.execute("SELECT body FROM history").fetchone()[0], a) + self.assertEqual(db.execute("PRAGMA integrity_check").fetchone()[0], "ok") + + def test_competing_writes_only_one_wins(self): + _, headers, _ = self.request("PUT", self.envelope(), **{"If-None-Match": "*"}) + etag = headers["ETag"] + with concurrent.futures.ThreadPoolExecutor(2) as pool: + jobs = [pool.submit(self.request, "PUT", self.envelope(), **{"If-Match": etag}) for _ in range(2)] + self.assertEqual(sorted(j.result()[0] for j in jobs), [200, 412]) + + def test_rejects_invalid_envelope(self): + body = b"not-a-qsafe-file" + secrets.token_bytes(80) + self.assertEqual(self.request("PUT", body, **{"If-None-Match": "*"})[0], 400) + + def test_backup_is_standalone_and_contains_latest_revision(self): + a, b = self.envelope(), self.envelope() + _, headers, _ = self.request("PUT", a, **{"If-None-Match": "*"}) + self.assertEqual(self.request("PUT", b, **{"If-Match": headers["ETag"]})[0], 200) + target = pathlib.Path(self.temp.name) / "backups" + subprocess.run([sys.executable, str(pathlib.Path(__file__).with_name("backup.py")), self.path, str(target)], check=True) + backups = list(target.glob("*.sqlite3")) + self.assertEqual(len(backups), 1) + self.assertEqual(list(target.glob("*.tmp*")), []) + with sqlite3.connect(backups[0]) as db: + self.assertEqual(db.execute("SELECT body FROM vault").fetchone()[0], b) + self.assertEqual(db.execute("SELECT body FROM history").fetchone()[0], a) + self.assertEqual(db.execute("PRAGMA integrity_check").fetchone()[0], "ok") + + +if __name__ == "__main__": + unittest.main() diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..8020ab3 --- /dev/null +++ b/settings.gradle @@ -0,0 +1,4 @@ +pluginManagement { repositories { google(); mavenCentral(); gradlePluginPortal() } } +dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS); repositories { google(); mavenCentral() } } +rootProject.name = 'QSafe' +include ':app' diff --git a/shared/password-words.txt b/shared/password-words.txt new file mode 100644 index 0000000..ecf29be --- /dev/null +++ b/shared/password-words.txt @@ -0,0 +1,32 @@ +acorn amber apple apron arrow atlas autumn avocado +badger bamboo banana basket beach beacon berry birch +blanket blossom boat bottle branch breeze bridge brook +canyon captain carpet carrot castle cedar celery cherry +compass cookie coral cotton cottage crab crane cricket +crown crystal daisy dancer dawn deer desert diamond +dolphin domino dragon dream drum duck eagle earth +echo elm emerald evening falcon feather fern field +fig finch firefly flame flute forest fox frost +galaxy garden garlic gate gazelle ginger glacier globe +goose grape grass gravel grove guitar harbor harvest +hawk hazel hedgehog heron hill honey horizon horse +island ivy jacket jasmine jelly jewel journal juniper +kettle kitten kiwi ladder lagoon lake lantern lark +laurel lavender leaf lemon leopard lily lime linen +lion lizard lotus magnet mango maple marble meadow +melon meteor mint mirror moon moss mountain mouse +mushroom nectar nest nettle night noodle north nutmeg +oasis ocean olive onion opal orange orchid otter +owl oyster panda paper parrot peach peanut pear +pebble pelican pencil pepper petal piano picnic pigeon +pine planet plum pocket pond poppy potato pumpkin +quartz rabbit raccoon radish rain rainbow raven reef +reed ribbon river robin rocket rose rowan ruby +saddle saffron sage sail salmon sand scarf seashell +seed shadow shell silver sky snow soap sparrow +spice spider spoon spring spruce square squirrel star +stone storm strawberry stream sugar summer summit sun +sunrise sunset swan thyme tiger timber tomato torch +tower trail tree tulip turtle valley velvet violet +walnut waterfall wave wheat willow window winter wolf +wood wren yacht yellow yogurt zebra zephyr zipper diff --git a/windows/Directory.Build.props b/windows/Directory.Build.props new file mode 100644 index 0000000..a6a4cce --- /dev/null +++ b/windows/Directory.Build.props @@ -0,0 +1,11 @@ + + + enable + enable + true + 1.0.4 + Kusoft + Kusoft + Copyright © 2026 Kusoft + + diff --git a/windows/QSafe.Core/LanPolicy.cs b/windows/QSafe.Core/LanPolicy.cs new file mode 100644 index 0000000..210de69 --- /dev/null +++ b/windows/QSafe.Core/LanPolicy.cs @@ -0,0 +1,49 @@ +using System.Net; +using System.Net.Security; +using System.Net.Sockets; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; + +namespace QSafe.Core; + +public sealed record LanBinding(string Ssid, bool IsPhysicalWifi, IPAddress Address, int PrefixLength, int InterfaceIndex); +public interface ILanNetwork { LanBinding Current(Connection connection); } + +public static class LanPolicy +{ + public static bool PrivateIpv4(IPAddress address) + { + if (address.AddressFamily != AddressFamily.InterNetwork) return false; + var b = address.GetAddressBytes(); + return b[0] == 10 || b[0] == 172 && b[1] is >= 16 and <= 31 || b[0] == 192 && b[1] == 168; + } + public static IPAddress Target(string url) + { + if (!Uri.TryCreate(url?.Trim(), UriKind.Absolute, out var uri) || uri.Scheme != "https" || uri.UserInfo.Length > 0 || uri.Query.Length > 0 || uri.Fragment.Length > 0 || !IPAddress.TryParse(uri.Host, out var ip) || !PrivateIpv4(ip)) + throw new InvalidDataException("Для базы разрешён только HTTPS с локальным IPv4 Pi (10.x, 172.16–31.x или 192.168.x). Доменные и внешние адреса запрещены."); + return ip; + } + public static void Check(Connection config, LanBinding binding) + { + var target = Target(config.Url); + if (!binding.IsPhysicalWifi || binding.Ssid != config.HomeWifiSsid || !PrivateIpv4(binding.Address) || binding.InterfaceIndex <= 0 || binding.PrefixLength is < 16 or > 30) + throw new IOException("Автономный режим: подключитесь к домашнему Wi-Fi «" + config.HomeWifiSsid + "». Подключение к базе не выполнялось."); + var a = target.GetAddressBytes(); var b = binding.Address.GetAddressBytes(); + for (int i = 0; i < 4; i++) + { + int bits = Math.Clamp(binding.PrefixLength - i * 8, 0, 8); int mask = 255 << (8 - bits) & 255; + if ((a[i] & mask) != (b[i] & mask)) throw new IOException("Pi должен находиться непосредственно в подсети домашнего Wi-Fi. Маршрутизация через интернет или VPN запрещена."); + } + uint hostMask = uint.MaxValue >> binding.PrefixLength; + uint value = System.Buffers.Binary.BinaryPrimitives.ReadUInt32BigEndian(a); + if ((value & hostMask) == 0 || (value & hostMask) == hostMask || target.Equals(binding.Address)) + throw new IOException("Укажите отдельный адрес Pi в домашней подсети."); + } + public static bool CertificateMatches(X509Certificate? certificate, SslPolicyErrors errors, string pin) + { + if (certificate == null || (errors & (SslPolicyErrors.RemoteCertificateNameMismatch | SslPolicyErrors.RemoteCertificateNotAvailable)) != 0) return false; + using var cert = new X509Certificate2(certificate); + return DateTime.UtcNow >= cert.NotBefore.ToUniversalTime() && DateTime.UtcNow <= cert.NotAfter.ToUniversalTime() + && CryptographicOperations.FixedTimeEquals(SHA256.HashData(cert.RawData), Convert.FromHexString(pin)); + } +} diff --git a/windows/QSafe.Core/PasswordGenerator.cs b/windows/QSafe.Core/PasswordGenerator.cs new file mode 100644 index 0000000..9852cd2 --- /dev/null +++ b/windows/QSafe.Core/PasswordGenerator.cs @@ -0,0 +1,52 @@ +using System.Security.Cryptography; +using System.Text; + +namespace QSafe.Core; + +public enum PasswordMode { Complex, Digits, Memorable } + +public static class PasswordGenerator +{ + private static readonly string[] Words = LoadWords(); + + private static string[] LoadWords() + { + using var stream = typeof(PasswordGenerator).Assembly.GetManifestResourceStream("QSafe.PasswordWords") + ?? throw new InvalidOperationException("Missing password word list"); + using var reader = new StreamReader(stream); + var words = reader.ReadToEnd().Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); + if (words.Length != 256 || words.Distinct().Count() != 256) throw new InvalidOperationException("Invalid password word list"); + return words; + } + + public static string Generate(PasswordMode mode, int length, bool symbols = true, bool digits = true) + { + switch (mode) + { + case PasswordMode.Complex: return VaultCrypto.Generate(length, symbols); + case PasswordMode.Digits: + if (length is < 4 or > 128) throw new ArgumentOutOfRangeException(nameof(length)); + return new string(Enumerable.Range(0, length).Select(_ => (char)('0' + RandomNumberGenerator.GetInt32(10))).ToArray()); + case PasswordMode.Memorable: + if (length is < 2 or > 12) throw new ArgumentOutOfRangeException(nameof(length)); + const string signs = "!@#$%&*+-=?"; + // Treat each word as a whole: extras only go before, between or after words. + var boundaries = Enumerable.Range(0, length + 1).Select(_ => new StringBuilder()).ToArray(); + void InsertAtBoundary(char character) + { + var boundary = boundaries[RandomNumberGenerator.GetInt32(boundaries.Length)]; + boundary.Insert(RandomNumberGenerator.GetInt32(boundary.Length + 1), character); + } + if (digits) for (int i = 0; i < 2; i++) InsertAtBoundary((char)('0' + RandomNumberGenerator.GetInt32(10))); + if (symbols) for (int i = 1; i < length; i++) InsertAtBoundary(signs[RandomNumberGenerator.GetInt32(signs.Length)]); + var value = new StringBuilder(); + for (int i = 0; i < length; i++) + { + string word = Words[RandomNumberGenerator.GetInt32(Words.Length)]; + value.Append(boundaries[i]).Append(char.ToUpperInvariant(word[0])).Append(word.AsSpan(1)); + } + return value.Append(boundaries[length]).ToString(); + default: throw new ArgumentOutOfRangeException(nameof(mode)); + } + } +} diff --git a/windows/QSafe.Core/QSafe.Core.csproj b/windows/QSafe.Core/QSafe.Core.csproj new file mode 100644 index 0000000..8d8eb4f --- /dev/null +++ b/windows/QSafe.Core/QSafe.Core.csproj @@ -0,0 +1,6 @@ + + net10.0 + + + + diff --git a/windows/QSafe.Core/SyncClient.cs b/windows/QSafe.Core/SyncClient.cs new file mode 100644 index 0000000..ee6f063 --- /dev/null +++ b/windows/QSafe.Core/SyncClient.cs @@ -0,0 +1,154 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Net.Sockets; +using System.Security.Cryptography.X509Certificates; + +namespace QSafe.Core; + +public sealed class Connection +{ + public string Url { get; set; } = ""; + public string HomeWifiSsid { get; set; } = ""; + public string CertificateSha256 { get; set; } = ""; + public string Token { get; set; } = ""; + public string Etag { get; set; } = ""; + public DateTimeOffset? LastSync { get; set; } + public bool AutoSync { get; set; } = true; + public void Validate() + { + LanPolicy.Target(Url); + Url = new Uri(Url.Trim()).AbsoluteUri.TrimEnd('/') + "/"; + if (string.IsNullOrWhiteSpace(HomeWifiSsid) || System.Text.Encoding.UTF8.GetByteCount(HomeWifiSsid) > 32) + throw new InvalidDataException("Укажите точное имя домашнего Wi-Fi (SSID), до 32 байт."); + if (CertificateSha256 == null || !Regex.IsMatch(CertificateSha256, "\\A[0-9a-fA-F]{64}\\z")) + throw new InvalidDataException("Нужен SHA-256 локального сертификата Pi из личного файла подключения."); + if (Token == null || !Regex.IsMatch(Token, "\\A[A-Za-z0-9_-]{32,128}\\z")) throw new InvalidDataException("Неверный ключ подключения."); + } + public static Connection Parse(byte[] bytes) + { + if (bytes.Length > 65536) throw new InvalidDataException("Слишком большой файл подключения."); + var c = JsonSerializer.Deserialize(bytes, Vault.Json) ?? throw new InvalidDataException("Пустой файл подключения."); + c.Validate(); c.Etag = ""; c.LastSync = null; return c; + } +} + +public sealed record SyncResult(VaultData Data, byte[] Envelope, int Conflicts); + +public sealed class SyncClient : IDisposable +{ + private readonly HttpClient http; + private readonly Connection connection; + private readonly ILanNetwork network; + public SyncClient(Connection config, ILanNetwork network, HttpMessageHandler? handler = null) + { + config.Validate(); connection = config; this.network = network; + http = new HttpClient(handler ?? CreateHandler()) { Timeout = TimeSpan.FromSeconds(40) }; + } + + private LanBinding RequireHome() + { + connection.Validate(); var binding = network.Current(connection); LanPolicy.Check(connection, binding); return binding; + } + private SocketsHttpHandler CreateHandler() + { + var handler = new SocketsHttpHandler { AllowAutoRedirect = false, UseProxy = false, PooledConnectionLifetime = TimeSpan.Zero, ConnectTimeout = TimeSpan.FromSeconds(10) }; + handler.SslOptions.CertificateRevocationCheckMode = X509RevocationMode.NoCheck; + handler.SslOptions.CertificateChainPolicy = new X509ChainPolicy { DisableCertificateDownloads = true, RevocationMode = X509RevocationMode.NoCheck }; + handler.SslOptions.RemoteCertificateValidationCallback = (_, certificate, _, errors) => LanPolicy.CertificateMatches(certificate, errors, connection.CertificateSha256); + handler.ConnectCallback = async (context, ct) => + { + var binding = RequireHome(); var ip = LanPolicy.Target(connection.Url); + var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + try + { + const int IpUnicastIf = 31; // Windows ws2ipdef.h IP_UNICAST_IF; index is in network byte order. + socket.SetSocketOption(SocketOptionLevel.IP, (SocketOptionName)IpUnicastIf, IPAddress.HostToNetworkOrder(binding.InterfaceIndex)); + socket.Bind(new IPEndPoint(binding.Address, 0)); + await socket.ConnectAsync(new IPEndPoint(ip, context.DnsEndPoint.Port), ct); + return new NetworkStream(socket, ownsSocket: true); + } + catch { socket.Dispose(); throw; } + }; + return handler; + } + + public static async Task Bounded(Stream stream, int limit, CancellationToken ct = default) + { + using var output = new MemoryStream(); var buffer = new byte[8192]; int n; + while ((n = await stream.ReadAsync(buffer, ct)) > 0) + { + if (output.Length + n > limit) throw new InvalidDataException("Файл превышает допустимый размер."); + await output.WriteAsync(buffer.AsMemory(0, n), ct); + } + return output.ToArray(); + } + + private async Task<(byte[]? Blob, string Etag)> Request(HttpMethod method, byte[]? blob, string? etag, CancellationToken ct) + { + RequireHome(); // Applies to every manual, automatic, restore, GET and PUT request before any transport access. + using var request = new HttpRequestMessage(method, connection.Url + "v1/vault"); + request.Headers.ConnectionClose = true; + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", connection.Token); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/octet-stream")); + if (blob != null) + { + request.Content = new ByteArrayContent(blob); request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream"); + request.Headers.TryAddWithoutValidation(etag == null ? "If-None-Match" : "If-Match", etag == null ? "*" : '"' + etag + '"'); + } + using var response = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct); + if (response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden) throw new IOException("Сервер отклонил ключ подключения."); + if (response.StatusCode == HttpStatusCode.PreconditionFailed) throw new SyncConflictException(); + if (method == HttpMethod.Get && response.StatusCode == HttpStatusCode.NotFound) return (null, ""); + if (response.StatusCode != HttpStatusCode.OK) throw new IOException($"Сервер вернул HTTP {(int)response.StatusCode}."); + var hash = response.Headers.ETag?.Tag.Trim('"') ?? ""; + if (method != HttpMethod.Get) + { + if (hash != VaultCrypto.Hash(blob!)) throw new IOException("Сервер не подтвердил контрольную сумму записи."); + return (null, hash); + } + if (response.Content.Headers.ContentLength > VaultCrypto.MaxBytes) throw new InvalidDataException("Ответ сервера слишком большой."); + await using var stream = await response.Content.ReadAsStreamAsync(ct); + var bytes = await Bounded(stream, VaultCrypto.MaxBytes, ct); VaultCrypto.Validate(bytes); + if (hash != VaultCrypto.Hash(bytes)) throw new InvalidDataException("Не совпадает контрольная сумма ответа сервера."); + return (bytes, hash); + } + + public async Task Download(CancellationToken ct = default) => (await Request(HttpMethod.Get, null, null, ct)).Blob ?? throw new IOException("На сервере пока нет хранилища."); + + public async Task Sync(byte[] local, byte[]? ancestor, VaultKey key, CancellationToken ct = default) + { + for (int attempt = 0; ; attempt++) + { + try { return await SyncOnce(local, ancestor, key, ct); } + catch (SyncConflictException) when (attempt < 2) { } + } + } + + private async Task SyncOnce(byte[] local, byte[]? ancestor, VaultKey key, CancellationToken ct) + { + var remote = await Request(HttpMethod.Get, null, null, ct); + var localData = Vault.Read(local, key); var localHash = VaultCrypto.Hash(local); + if (remote.Blob == null) + { + if (connection.Etag.Length > 0 || ancestor != null) throw new IOException("Хранилище исчезло с сервера. Проверьте сервер и резервные копии."); + await Request(HttpMethod.Put, local, null, ct); return new(localData, local, 0); + } + if (remote.Etag == localHash) return new(localData, local, 0); + var remoteData = Vault.Read(remote.Blob, key); + if (localData.Id != remoteData.Id) throw new InvalidDataException("На сервере другое хранилище. Сначала экспортируйте локальную копию."); + if (remote.Etag == connection.Etag) + { + await Request(HttpMethod.Put, local, remote.Etag, ct); return new(localData, local, 0); + } + if (ancestor != null && VaultCrypto.Hash(ancestor) == localHash) return new(remoteData, remote.Blob, 0); + var merged = Vault.Merge(ancestor == null ? null : Vault.Read(ancestor, key), localData, remoteData); + var next = Vault.Encrypt(merged.Data, key); + await Request(HttpMethod.Put, next, remote.Etag, ct); + return new(merged.Data, next, merged.Conflicts); + } + public void Dispose() => http.Dispose(); +} + +public sealed class SyncConflictException() : IOException("База на сервере меняется. Повторите синхронизацию."); diff --git a/windows/QSafe.Core/Vault.cs b/windows/QSafe.Core/Vault.cs new file mode 100644 index 0000000..a1d65d3 --- /dev/null +++ b/windows/QSafe.Core/Vault.cs @@ -0,0 +1,155 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Security.Cryptography; + +namespace QSafe.Core; + +public sealed class VaultData +{ + public int Format { get; set; } = 1; + public string Id { get; set; } = Guid.NewGuid().ToString(); + public List Entries { get; set; } = []; + [JsonExtensionData] public Dictionary? Extra { get; set; } +} + +public sealed class Entry +{ + public string Id { get; set; } = Guid.NewGuid().ToString(); + public string Rev { get; set; } = Guid.NewGuid().ToString(); + public string Title { get; set; } = ""; + public string Notes { get; set; } = ""; + public string Kind { get; set; } = ""; + public List Fields { get; set; } = []; + public List Tags { get; set; } = []; + public bool Star { get; set; } + public bool Deleted { get; set; } + public long Modified { get; set; } = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + [JsonExtensionData] public Dictionary? Extra { get; set; } + [JsonIgnore] public string Category => Templates.Kind(this); + [JsonIgnore] public string Subtitle => Templates.Subtitle(this); +} + +public sealed class Field +{ + public string Name { get; set; } = ""; + public string Type { get; set; } = "text"; + public string Value { get; set; } = ""; + [JsonExtensionData] public Dictionary? Extra { get; set; } + [JsonIgnore] public bool Secret => !new[] { "login", "website", "url", "email", "phone", "text", "date" }.Contains(Type.ToLowerInvariant()); +} + +public static class Vault +{ + public static readonly JsonSerializerOptions Json = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, MaxDepth = 64 }; + public static byte[] Bytes(VaultData data) => JsonSerializer.SerializeToUtf8Bytes(data, Json); + public static T Copy(T data) => JsonSerializer.Deserialize(JsonSerializer.SerializeToUtf8Bytes(data, Json), Json)!; + public static VaultData Parse(byte[] bytes) + { + if (bytes.Length > VaultCrypto.MaxBytes) throw new InvalidDataException("Хранилище слишком большое."); + var d = JsonSerializer.Deserialize(bytes, Json) ?? throw new InvalidDataException("Пустое хранилище."); + // Required properties must be present; generated defaults are only for new objects. + using var raw = JsonDocument.Parse(bytes); + if (!raw.RootElement.TryGetProperty("format", out _) || !raw.RootElement.TryGetProperty("id", out _) || !raw.RootElement.TryGetProperty("entries", out _) || d.Format != 1 || string.IsNullOrWhiteSpace(d.Id) || d.Entries == null) + throw new InvalidDataException("Неверный формат хранилища."); + var ids = new HashSet(); + foreach (var node in raw.RootElement.GetProperty("entries").EnumerateArray()) + if (node.ValueKind != JsonValueKind.Object || !node.TryGetProperty("id", out _) || !node.TryGetProperty("rev", out _) || !node.TryGetProperty("fields", out _)) + throw new InvalidDataException("Повреждённая карточка."); + foreach (var e in d.Entries) + if (e == null || string.IsNullOrEmpty(e.Id) || string.IsNullOrEmpty(e.Rev) || !ids.Add(e.Id) || e.Fields == null || e.Tags == null || e.Title == null || e.Notes == null || e.Fields.Any(f => f == null || f.Name == null || f.Type == null || f.Value == null) || e.Tags.Any(t => t == null)) + throw new InvalidDataException("Повреждённая карточка."); + return d; + } + + public static VaultData Read(byte[] envelope, VaultKey key) + { + var plain = VaultCrypto.Decrypt(envelope, key); + try { return Parse(plain); } finally { CryptographicOperations.ZeroMemory(plain); } + } + + public static byte[] Encrypt(VaultData data, VaultKey key) + { + var plain = Bytes(data); + try { return VaultCrypto.Encrypt(plain, key); } finally { CryptographicOperations.ZeroMemory(plain); } + } + + public static void Update(VaultData data, Entry entry) + { + entry.Rev = Guid.NewGuid().ToString(); entry.Modified = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + int i = data.Entries.FindIndex(e => e.Id == entry.Id); + if (i < 0) data.Entries.Add(entry); else data.Entries[i] = entry; + } + + public static (VaultData Data, int Conflicts) Merge(VaultData? ancestor, VaultData local, VaultData remote) + { + if (local.Id != remote.Id || ancestor != null && ancestor.Id != local.Id) + throw new InvalidDataException("На сервере другое хранилище. Локальная база сохранена."); + var b = ancestor?.Entries.ToDictionary(e => e.Id) ?? []; + var l = local.Entries.ToDictionary(e => e.Id); var r = remote.Entries.ToDictionary(e => e.Id); + var result = Copy(local); result.Entries.Clear(); int conflicts = 0; + foreach (string id in l.Keys.Union(r.Keys)) + { + l.TryGetValue(id, out var le); r.TryGetValue(id, out var re); b.TryGetValue(id, out var be); + Entry? chosen; + if (re == null) chosen = le; + else if (le == null || le.Rev == be?.Rev) chosen = re; + else if (re.Rev == be?.Rev || le.Rev == re.Rev) chosen = le; + else if (le.Deleted && re.Deleted) chosen = re; + else + { + conflicts++; + if (le.Deleted || re.Deleted) + { + chosen = Copy(le.Deleted ? re : le); chosen.Title += " · конфликт удаления"; chosen.Rev = Guid.NewGuid().ToString(); + } + else + { + chosen = re; + var duplicate = Copy(le); duplicate.Id = Guid.NewGuid().ToString(); duplicate.Rev = Guid.NewGuid().ToString(); duplicate.Title += " · локальная версия (конфликт)"; + result.Entries.Add(duplicate); + } + } + if (chosen != null) result.Entries.Add(Copy(chosen)); + } + return (result, conflicts); + } +} + +public static class Templates +{ + public static readonly string[] Kinds = ["login", "bank", "email", "wifi", "server", "document", "note", "custom"]; + public static readonly string[] Titles = ["Сайты и приложения", "Банковские карты", "Электронная почта", "Сети Wi-Fi", "Серверы", "Документы", "Заметки", "Другие карточки"]; + public static readonly string[] Glyphs = ["\uE774", "\uE8C7", "\uE715", "\uE701", "\uE968", "\uE8A5", "\uE70B", "\uE713"]; + public static readonly string[] Colors = ["#85AEFF", "#C4A2FF", "#FFBE83", "#7ED9C2", "#9EAFFF", "#EDAADE", "#F3D684", "#A9BBCD"]; + public static int Index(string kind) => Math.Max(0, Array.IndexOf(Kinds, kind)); + public static string Kind(Entry e) + { + if (Kinds.Contains(e.Kind)) return e.Kind; + if (e.Fields.Any(f => f.Type.Contains("card", StringComparison.OrdinalIgnoreCase))) return "bank"; + if (e.Fields.Any(f => f.Name.Contains("ssid", StringComparison.OrdinalIgnoreCase) || f.Name.Contains("wi-fi", StringComparison.OrdinalIgnoreCase))) return "wifi"; + return e.Fields.Any(f => f.Type.Equals("email", StringComparison.OrdinalIgnoreCase)) ? "email" : "login"; + } + public static string Subtitle(Entry e) + { + foreach (var f in e.Fields.Where(f => f.Type is "website" or "url")) + if (Uri.TryCreate(f.Value.Contains("://") ? f.Value : "https://" + f.Value, UriKind.Absolute, out var uri) && !string.IsNullOrEmpty(uri.Host)) + return uri.Host.StartsWith("www.", StringComparison.OrdinalIgnoreCase) ? uri.Host[4..] : uri.Host; + return e.Fields.FirstOrDefault(f => f.Type is "login" or "email" && !string.IsNullOrWhiteSpace(f.Value))?.Value ?? e.Tags.FirstOrDefault() ?? Titles[Index(Kind(e))]; + } + public static Entry Create(string kind) + { + var e = new Entry { Kind = kind }; + (string, string)[] fields = kind switch + { + "bank" => [("Номер карты", "card"), ("Владелец", "text"), ("Срок действия", "date"), ("CVV / CVC", "pin"), ("PIN", "pin")], + "email" => [("Адрес почты", "email"), ("Пароль", "password"), ("Сайт", "website")], + "wifi" => [("Имя сети (SSID)", "text"), ("Пароль сети", "password")], + "server" => [("Сервер / адрес", "text"), ("Логин", "login"), ("Пароль", "password"), ("Порт", "text")], + "document" => [("Тип документа", "text"), ("Номер документа", "secret"), ("Владелец", "text"), ("Срок действия", "date")], + "note" => [], "custom" => [("Секретное поле", "secret")], + _ => [("Логин", "login"), ("Пароль", "password"), ("Сайт", "website")] + }; + e.Fields = fields.Select(f => new Field { Name = f.Item1, Type = f.Item2 }).ToList(); + return e; + } +} diff --git a/windows/QSafe.Core/VaultCrypto.cs b/windows/QSafe.Core/VaultCrypto.cs new file mode 100644 index 0000000..059c211 --- /dev/null +++ b/windows/QSafe.Core/VaultCrypto.cs @@ -0,0 +1,87 @@ +using System.Buffers.Binary; +using System.Security.Cryptography; + +namespace QSafe.Core; + +public sealed class VaultKey(byte[] bytes, byte[] salt, int iterations) : IDisposable +{ + public byte[] Bytes { get; } = bytes; + public byte[] Salt { get; } = salt; + public int Iterations { get; } = iterations; + public void Dispose() => CryptographicOperations.ZeroMemory(Bytes); +} + +public static class VaultCrypto +{ + public const int Iterations = 600_000; + public const int MaxBytes = 32 * 1024 * 1024; + private static ReadOnlySpan Magic => "QSAFE01!"u8; + + public static void Validate(ReadOnlySpan envelope) + { + if (envelope.Length < 56 || envelope.Length > MaxBytes || !envelope[..8].SequenceEqual(Magic)) + throw new InvalidDataException("Неверный или повреждённый файл QSafe."); + if (BinaryPrimitives.ReadInt32BigEndian(envelope[8..]) is < Iterations or > 2_000_000) + throw new InvalidDataException("Неподдерживаемые параметры шифрования."); + } + + private static VaultKey Derive(ReadOnlySpan password, byte[] salt, int iterations) => + new(Rfc2898DeriveBytes.Pbkdf2(password, salt, iterations, HashAlgorithmName.SHA256, 32), salt, iterations); + + public static VaultKey Create(ReadOnlySpan password) => Derive(password, RandomNumberGenerator.GetBytes(16), Iterations); + + public static VaultKey Unlock(byte[] envelope, ReadOnlySpan password) + { + Validate(envelope); + var key = Derive(password, envelope[12..28], BinaryPrimitives.ReadInt32BigEndian(envelope.AsSpan(8))); + try + { + var plain = Decrypt(envelope, key); + CryptographicOperations.ZeroMemory(plain); + return key; + } + catch { key.Dispose(); throw; } + } + + public static byte[] Encrypt(ReadOnlySpan plain, VaultKey key) + { + if (plain.Length > MaxBytes - 56) throw new InvalidDataException("Хранилище превышает 32 МиБ."); + var envelope = new byte[plain.Length + 56]; + Magic.CopyTo(envelope); + BinaryPrimitives.WriteInt32BigEndian(envelope.AsSpan(8), key.Iterations); + key.Salt.CopyTo(envelope, 12); + RandomNumberGenerator.Fill(envelope.AsSpan(28, 12)); + using var aes = new AesGcm(key.Bytes, 16); + aes.Encrypt(envelope.AsSpan(28, 12), plain, envelope.AsSpan(40, plain.Length), envelope.AsSpan(40 + plain.Length, 16), envelope.AsSpan(0, 40)); + return envelope; + } + + public static byte[] Decrypt(byte[] envelope, VaultKey key) + { + Validate(envelope); + if (!CryptographicOperations.FixedTimeEquals(key.Salt, envelope.AsSpan(12, 16)) || key.Iterations != BinaryPrimitives.ReadInt32BigEndian(envelope.AsSpan(8))) + throw new CryptographicException("У хранилища другой ключ или мастер-пароль."); + var plain = new byte[envelope.Length - 56]; + using var aes = new AesGcm(key.Bytes, 16); + try + { + aes.Decrypt(envelope.AsSpan(28, 12), envelope.AsSpan(40, plain.Length), envelope.AsSpan(envelope.Length - 16), plain, envelope.AsSpan(0, 40)); + return plain; + } + catch { CryptographicOperations.ZeroMemory(plain); throw; } + } + + public static string Hash(byte[] data) => Convert.ToHexStringLower(SHA256.HashData(data)); + + public static string Generate(int length = 24, bool symbols = true) + { + if (length is < 16 or > 128) throw new ArgumentOutOfRangeException(nameof(length)); + string[] groups = symbols ? ["ABCDEFGHJKLMNPQRSTUVWXYZ", "abcdefghijkmnopqrstuvwxyz", "23456789", "!@#$%&*+-=?"] : ["ABCDEFGHJKLMNPQRSTUVWXYZ", "abcdefghijkmnopqrstuvwxyz", "23456789"]; + var alphabet = string.Concat(groups); + var result = new char[length]; + for (int i = 0; i < length; i++) result[i] = alphabet[RandomNumberGenerator.GetInt32(alphabet.Length)]; + for (int i = 0; i < groups.Length; i++) result[i] = groups[i][RandomNumberGenerator.GetInt32(groups[i].Length)]; + for (int i = length - 1; i > 0; i--) { int j = RandomNumberGenerator.GetInt32(i + 1); (result[i], result[j]) = (result[j], result[i]); } + return new string(result); + } +} diff --git a/windows/QSafe.Core/XmlImport.cs b/windows/QSafe.Core/XmlImport.cs new file mode 100644 index 0000000..5fb08fa --- /dev/null +++ b/windows/QSafe.Core/XmlImport.cs @@ -0,0 +1,51 @@ +using System.Text.Json; +using System.Xml; +using System.Xml.Linq; + +namespace QSafe.Core; + +public sealed record ImportPreview(VaultData Data, int Added, int Duplicates, int Skipped, int Attachments); + +public static class XmlImport +{ + public static ImportPreview Preview(byte[] bytes, VaultData current) + { + if (bytes.Length > 24 * 1024 * 1024) throw new InvalidDataException("XML превышает 24 МиБ."); + using var stream = new MemoryStream(bytes); + using var reader = XmlReader.Create(stream, new XmlReaderSettings { DtdProcessing = DtdProcessing.Prohibit, XmlResolver = null, MaxCharactersInDocument = 32 * 1024 * 1024 }); + var doc = XDocument.Load(reader); + if (doc.Root?.Name.LocalName != "database") throw new InvalidDataException("Это не XML-экспорт SafeInCloud."); + var data = Vault.Copy(current); + var labels = doc.Root.Elements("label").ToDictionary(e => (string?)e.Attribute("id") ?? "", e => (string?)e.Attribute("name") ?? ""); + var fingerprints = new HashSet(data.Entries.Select(e => e.Extra != null && e.Extra.TryGetValue("safeInCloudFingerprint", out var fp) && fp.ValueKind == JsonValueKind.String ? fp.GetString() ?? "" : "")); + int added = 0, duplicates = 0, skipped = 0, attachments = 0; + foreach (var card in doc.Root.Elements("card")) + { + bool Flag(string name) => ((string?)card.Attribute(name)) is "true" or "1"; + if (Flag("deleted") || Flag("template")) { skipped++; continue; } + var xml = card.ToString(SaveOptions.DisableFormatting); + // Preserve Android's original XML; XML serializers may use different formatting across platforms. + var fingerprint = VaultCrypto.Hash(System.Text.Encoding.UTF8.GetBytes(xml)); + bool sameSource = data.Entries.Any(e => e.Extra != null && e.Extra.TryGetValue("safeInCloudXml", out var source) && Equivalent(source.GetString(), card)); + if (fingerprints.Contains(fingerprint) || sameSource) { duplicates++; continue; } + var entry = new Entry { Title = (string?)card.Attribute("title") ?? "Без названия", Star = Flag("star"), Notes = (string?)card.Element("notes") ?? "" }; + entry.Fields = card.Elements("field").Select(f => new Field { Name = (string?)f.Attribute("name") ?? "", Type = (string?)f.Attribute("type") ?? "text", Value = f.Value }).ToList(); + entry.Tags = card.Elements("label_id").Select(e => labels.GetValueOrDefault(e.Value.Trim(), "Метка " + e.Value.Trim())).ToList(); + var files = card.Elements().Where(e => e.Name.LocalName is "file" or "image").Select(e => new { kind = e.Name.LocalName, name = (string?)e.Attribute("name") ?? "", data = e.Value }).ToArray(); + entry.Extra = new() { ["safeInCloudXml"] = JsonSerializer.SerializeToElement(xml), ["safeInCloudFingerprint"] = JsonSerializer.SerializeToElement(fingerprint), ["attachments"] = JsonSerializer.SerializeToElement(files) }; + attachments += files.Length; data.Entries.Add(entry); fingerprints.Add(fingerprint); added++; + } + return new(data, added, duplicates, skipped, attachments); + } + private static bool Equivalent(string? source, XElement card) + { + if (source == null) return false; + try + { + using var text = new StringReader(source); + using var reader = XmlReader.Create(text, new XmlReaderSettings { DtdProcessing = DtdProcessing.Prohibit, XmlResolver = null, MaxCharactersInDocument = 32 * 1024 * 1024 }); + return XNode.DeepEquals(XElement.Load(reader), card); + } + catch (XmlException) { return false; } + } +} diff --git a/windows/QSafe.Desktop/App.xaml b/windows/QSafe.Desktop/App.xaml new file mode 100644 index 0000000..b4a4c49 --- /dev/null +++ b/windows/QSafe.Desktop/App.xaml @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/windows/QSafe.Desktop/App.xaml.cs b/windows/QSafe.Desktop/App.xaml.cs new file mode 100644 index 0000000..d6c4c7e --- /dev/null +++ b/windows/QSafe.Desktop/App.xaml.cs @@ -0,0 +1,68 @@ +using System.IO; +using System.Security.Cryptography; +using System.Text; +using System.Windows; +using System.Runtime.InteropServices; +using System.Windows.Interop; +using QSafe.Core; + +namespace QSafe.Desktop; + +public partial class App : Application +{ + private Mutex? single; + private bool ownsMutex; + protected override void OnStartup(StartupEventArgs e) + { + base.OnStartup(e); + bool smoke = e.Args.Contains("--smoke-test"); + bool demo = smoke || e.Args.Contains("--demo"); + var path = demo ? Path.Combine(Path.GetTempPath(), "QSafe-Demo", Guid.NewGuid().ToString("N")) : Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Kusoft", "QSafe"); + single = new Mutex(false, "Local\\QSafe-" + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(path)))[..20]); + try { ownsMutex = single.WaitOne(0); } catch (AbandonedMutexException) { ownsMutex = true; } + if (!ownsMutex) { MessageBox.Show("QSafe уже запущен. Откройте существующее окно.", "QSafe"); Shutdown(); return; } + try + { + var store = new LocalStore(path); + int connectionIndex = Array.IndexOf(e.Args, "--connection"); + if (!demo && connectionIndex >= 0) + { + if (connectionIndex + 1 >= e.Args.Length) throw new InvalidDataException("Укажите личный файл локального подключения."); + store.Configure(Connection.Parse(LocalStore.ReadBounded(e.Args[connectionIndex + 1], 65536))); + } + if (!demo && e.Args.Contains("--configure-only")) { Shutdown(); return; } + MainWindow = new MainWindow(store, demo); MainWindow.Show(); + if (smoke) + { + var timer = new System.Windows.Threading.DispatcherTimer { Interval = TimeSpan.FromSeconds(2) }; + timer.Tick += (_, _) => { timer.Stop(); MainWindow.Close(); }; timer.Start(); + } + } + catch { MessageBox.Show("Не удалось открыть локальное хранилище или защищённые настройки. Данные не изменены. Проверьте доступ к папке %LOCALAPPDATA%\\Kusoft\\QSafe.", "QSafe", MessageBoxButton.OK, MessageBoxImage.Error); Shutdown(1); } + } + protected override void OnExit(ExitEventArgs e) + { + if (ownsMutex) single?.ReleaseMutex(); single?.Dispose(); base.OnExit(e); + } +} + +public static class NativeWindow +{ + [DllImport("dwmapi.dll")] private static extern int DwmSetWindowAttribute(IntPtr hwnd, int attribute, ref int value, int size); + [DllImport("user32.dll")] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool SetWindowDisplayAffinity(IntPtr hwnd, uint affinity); + public static void Style(Window window) + { + window.SourceInitialized += (_, _) => + { + var handle = new WindowInteropHelper(window).Handle; int dark = 1; + DwmSetWindowAttribute(handle, 20, ref dark, sizeof(int)); + // Demo contains only synthetic credentials and can be captured for interface review. + SetProtection(window, !(Application.Current.MainWindow is MainWindow main && main.IsDemo)); + }; + } + public static void SetProtection(Window window, bool enabled) + { + var handle = new WindowInteropHelper(window).Handle; + if (handle != IntPtr.Zero) SetWindowDisplayAffinity(handle, enabled ? 0x11u : 0u); + } +} diff --git a/windows/QSafe.Desktop/Assets/QSafe.ico b/windows/QSafe.Desktop/Assets/QSafe.ico new file mode 100644 index 0000000000000000000000000000000000000000..3384d07ffaa061b94194bb75670a26849e7aac07 GIT binary patch literal 41086 zcmeI5&vF|#493|^C+4xIo_*^0v3yE=?DP=#xYI+e$H=4j&D^CV?*bq|f`71bQpm9c zk}Ll3eFQ0u?1@A8W&H}_xAi-Or|%*BzCJ#rM(X~Q9)3-&5Wb)9nY$%~^fzQvAZr^$oef|41 z@AmR%V+waO=}E^+QvzS-Nq?WI#e%!{C(V3(xz}1?g4ZvB5nh|X=b5JCxl~0syYgWQ zifw4-e#%2(fTjG?X{qgj2<xS;0K=niFQtM3algtik_+Gn`%4R8C>l&H?tlZTKcyiSKma{dTDs{Et^gc;h#{0U^sgtN16f(|QxVFTQnAE86W zF}DH#Py9#5fG1(934;Ot(1UVqd9-+yq2*v3Zt zk1lDHRM_wr%9rr$XJF&?NAaS38@^t?4PP(cgiqzm?%TZor1Hh$TL%p4zeT8Qyc<1d7kH*4~T z_;q|${t!QxOM9yFNB9($x9jo;_)E^CJ$3p0d<9-1zn@RFGHr$YZvG_s-F!wa?b68a zMs0`?P|H;YC`!oi0@pbZ526XXrseF&ZfFF^c+kX+7_n)e_ z9{riw8-$R3Iv=jpB`*E^3znAbESyumDy>1Q|GJrVSvueS|M}~W ze~*}IKi%y0JOAP|fA9Xo6a5mGUVq@#k+*d1h3EgR>rc)yrEkCfbNSF&d1l>JJ^FPs z2XK?K9OcWE1@OW7$!F=-TK{nUZ{a`sa77Y@l8n+-=l)jxh%Zfz_(gp4`SYRpQUSp2 zCtY*3rP+Utw;zcw)yNe6{N%_Y{*o)b{q+yOwDXgj^3ew1i>qPz+xH*lvj*7mtL~x; zc!##-@6cbJ9?547+VR8ue14o?J`Uh(*nIwwkN0zbk^IWLj|u!tAm(GWd8~3t-4s zEdY=yhMoE~2597Woxe$p!T?b-9O%_wHy}cc7z^8pR^9gefA&=e(Kc)zEP%R) z#~py&_Fw=P){K1F+j~&BR>y6~LE&2uBqlzx=8X4yRZI7ahh2 z6@aw-M-{*VHKX{L0+5zJ9P|>1B;&@E@~TWC?Y@-BL*=SdxE$ z0cH921}w?t!p-;$tjV`FpuGCY7gGc3@@>z6$HK~QLw0*`*%+n3?5_}L+)s%am4Fs+ zlz<*@l0XZ7U4!QtFLhv!ze*LyjonSm;N_VkRQvw;GUE?`ef#oQCHV#JfIHw0xC8Eh zJKzqu1MYx3;10M0?tnYs4!8sEfIHw0xC8EhJKzqu1MYx3;10M0?tnYs4!8sEfIHw0 RxC8EhJKzqu13!ub{{d&$rAz<- literal 0 HcmV?d00001 diff --git a/windows/QSafe.Desktop/Dialogs.cs b/windows/QSafe.Desktop/Dialogs.cs new file mode 100644 index 0000000..c212879 --- /dev/null +++ b/windows/QSafe.Desktop/Dialogs.cs @@ -0,0 +1,225 @@ +using System.IO; +using System.Text.Json; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Media; +using Microsoft.Win32; +using QSafe.Core; + +namespace QSafe.Desktop; + +public class AppDialog : Window +{ + public StackPanel Body { get; } = new(); + public StackPanel Actions { get; } = new() { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Right }; + public bool Forced { get; private set; } + public bool Accepted { get; private set; } + public AppDialog(Window owner, string title, string subtitle = "", double width = 600) + { + Owner = owner; Style = (Style)Application.Current.FindResource(typeof(Window)); Title = title + " — QSafe"; Width = width; SizeToContent = SizeToContent.Height; + MaxHeight = Math.Max(600, owner.ActualHeight - 40); MinHeight = 240; WindowStartupLocation = WindowStartupLocation.CenterOwner; ResizeMode = ResizeMode.NoResize; ShowInTaskbar = false; + NativeWindow.Style(this); + var root = new Grid { Margin = new Thickness(28) }; + root.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); root.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) }); root.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); + var heading = new StackPanel(); heading.Children.Add(Ui.Text(title, 25, "#F0F2F6", FontWeights.SemiBold)); + if (subtitle.Length > 0) { var hint = Ui.Text(subtitle, 13, "#969EAD"); hint.Margin = new Thickness(0, 10, 0, 0); heading.Children.Add(hint); } + heading.Margin = new Thickness(0, 0, 0, 12); root.Children.Add(heading); + var scroll = new ScrollViewer { Content = Body, MaxHeight = Math.Max(310, owner.ActualHeight - 260), Padding = new Thickness(0, 0, 12, 3) }; Grid.SetRow(scroll, 1); root.Children.Add(scroll); + Actions.Margin = new Thickness(0, 23, 0, 0); Grid.SetRow(Actions, 2); root.Children.Add(Actions); Content = root; + PreviewKeyDown += (_, e) => { if (e.Key == Key.Escape) { Close(); e.Handled = true; } }; + } + public void Accept() { Accepted = true; DialogResult = true; } + public void ForceClose() { Forced = true; Close(); } + public void AddCancel(string title = "Отмена") { var button = Ui.Button(title, Close, "Quiet"); button.Margin = new Thickness(0, 0, 10, 0); Actions.Children.Add(button); } +} + +public sealed class SecretInput : Grid +{ + private readonly PasswordBox password; + private readonly TextBox text; + private bool revealed; + public string Value => revealed ? text.Text : password.Password; + public SecretInput(string value, string label) + { + ColumnDefinitions.Add(new ColumnDefinition()); ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + password = new PasswordBox { Password = value }; text = new TextBox { Visibility = Visibility.Collapsed }; + System.Windows.Automation.AutomationProperties.SetName(password, label); System.Windows.Automation.AutomationProperties.SetName(text, label + " (показано)"); + Children.Add(password); Children.Add(text); + var reveal = Ui.IconButton("\uE890", "Показать / скрыть «" + label + "»", () => + { + if (revealed) { password.Password = text.Text; text.Clear(); } else { text.Text = password.Password; password.Clear(); } + revealed = !revealed; text.Visibility = revealed ? Visibility.Visible : Visibility.Collapsed; password.Visibility = revealed ? Visibility.Collapsed : Visibility.Visible; + }); Grid.SetColumn(reveal, 1); Children.Add(reveal); + } + public void Set(string value) { if (revealed) text.Text = value; else password.Password = value; } +} + +public partial class MainWindow +{ + private bool Confirm(string title, string explanation) + { + var dialog = new AppDialog(this, title, explanation, 530); dialog.AddCancel(); dialog.Actions.Children.Add(Ui.Button("Продолжить", dialog.Accept, "Primary")); + return dialog.ShowDialog() == true; + } + private void AddEntry() + { + if (key == null || busy) return; + var dialog = new AppDialog(this, "Новая карточка", "Выберите, что хотите сохранить.", 630); + var grid = new Grid(); grid.ColumnDefinitions.Add(new ColumnDefinition()); grid.ColumnDefinitions.Add(new ColumnDefinition()); string? kind = null; + for (int i = 0; i < Templates.Kinds.Length; i++) + { + int index = i; if (i % 2 == 0) grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); + var button = Ui.Button(Templates.Titles[i], () => { kind = Templates.Kinds[index]; dialog.Accept(); }); button.Margin = new Thickness(0, 7, 10, 3); button.Padding = new Thickness(13, 16, 13, 16); button.HorizontalContentAlignment = HorizontalAlignment.Left; + var label = Ui.Text(Templates.Titles[i], 13); label.Margin = new Thickness(12, 0, 0, 0); label.VerticalAlignment = VerticalAlignment.Center; + button.Content = Ui.Row(Ui.Tile(Templates.Glyphs[i], Templates.Colors[i], 34), label); Grid.SetColumn(button, i % 2); Grid.SetRow(button, i / 2); grid.Children.Add(button); + } + dialog.Body.Children.Add(grid); dialog.AddCancel(); + if (dialog.ShowDialog() == true && kind != null && key != null) EditEntry(Templates.Create(kind), true); + } + private void EditEntry(Entry original, bool isNew = false) + { + if (key == null || busy) return; + var draft = Vault.Copy(original); + var dialog = new AppDialog(this, isNew ? "Добавить карточку" : "Изменить карточку", Templates.Titles[Templates.Index(draft.Category)], 650); + var title = Ui.Input(dialog.Body, "Название", draft.Title); title.MaxLength = 500; + var star = new CheckBox { Content = "В избранном", IsChecked = draft.Star, Margin = new Thickness(0, 15, 0, 0) }; dialog.Body.Children.Add(star); + var fieldPanel = new StackPanel(); dialog.Body.Children.Add(fieldPanel); + var readers = new List>(); + void RenderFields() + { + fieldPanel.Children.Clear(); readers.Clear(); + for (int i = 0; i < draft.Fields.Count; i++) + { + int index = i; var field = draft.Fields[i]; var group = new StackPanel(); + var line = new DockPanel(); var delete = Ui.IconButton("\uE74D", "Удалить поле «" + field.Name + "»", () => { CaptureFields(); draft.Fields.RemoveAt(index); RenderFields(); }); DockPanel.SetDock(delete, Dock.Right); line.Children.Add(delete); + var name = new TextBox { Text = field.Name, FontSize = 12, Padding = new Thickness(9, 7, 9, 7), Margin = new Thickness(0, 0, 8, 0) }; System.Windows.Automation.AutomationProperties.SetName(name, "Название поля " + (i + 1)); line.Children.Add(name); group.Children.Add(line); + FrameworkElement editor; Func read; + if (field.Secret) + { + var secret = new SecretInput(field.Value, field.Name); editor = secret; read = () => secret.Value; + if (field.Type == "password") + { + var generate = Ui.Button("Сгенерировать пароль", () => ShowGenerator(secret.Set), "Quiet"); generate.FontSize = 11; generate.HorizontalAlignment = HorizontalAlignment.Left; generate.Padding = new Thickness(0, 8, 10, 0); generate.Margin = new Thickness(0, 0, 0, 4); + editor.Margin = new Thickness(0, 7, 0, 0); group.Children.Add(editor); group.Children.Add(generate); + } + else { editor.Margin = new Thickness(0, 7, 0, 0); group.Children.Add(editor); } + } + else + { + var input = new TextBox { Text = field.Value, Margin = new Thickness(0, 7, 0, 0) }; System.Windows.Automation.AutomationProperties.SetName(input, field.Name); editor = input; read = () => input.Text; group.Children.Add(input); + } + readers.Add(() => { var f = Vault.Copy(field); f.Name = name.Text.Trim(); f.Value = read(); return f; }); + var card = Ui.Box(group, "#181D23", 9, 12); card.Margin = new Thickness(0, 15, 0, 0); fieldPanel.Children.Add(card); + } + } + void CaptureFields() => draft.Fields = readers.Select(read => read()).ToList(); + RenderFields(); + var extras = Ui.Row(Ui.Button("+ Текстовое поле", () => { CaptureFields(); draft.Fields.Add(new Field { Name = "Новое поле" }); RenderFields(); }, "Quiet"), Ui.Button("+ Секретное поле", () => { CaptureFields(); draft.Fields.Add(new Field { Name = "Секретное поле", Type = "secret" }); RenderFields(); }, "Quiet")); extras.Margin = new Thickness(0, 9, 0, 0); dialog.Body.Children.Add(extras); + var tags = Ui.Input(dialog.Body, "Метки через запятую", string.Join(", ", draft.Tags)); + var notes = Ui.Input(dialog.Body, "Заметка", draft.Notes, true); + var error = Ui.Text("", 12, "#FFADAD"); error.Margin = new Thickness(0, 10, 0, 0); dialog.Body.Children.Add(error); + void Capture() + { + CaptureFields(); draft.Title = title.Text.Trim(); draft.Star = star.IsChecked == true; draft.Notes = notes.Text; + draft.Tags = tags.Text.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).Distinct().ToList(); + } + void Save() + { + if (key == null || dialog.Forced) return; + Capture(); + if (draft.Title.Length == 0) { error.Text = "Укажите название карточки."; title.Focus(); return; } + if (draft.Fields.Any(f => f.Name.Length == 0)) { error.Text = "Укажите название каждого поля."; return; } + try + { + var next = Vault.Copy(data!); var current = next.Entries.Find(e => e.Id == draft.Id); + if (current != null && current.Rev != draft.Rev) { draft.Id = Guid.NewGuid().ToString(); draft.Title += " · моя версия (конфликт)"; } + Vault.Update(next, draft); store.Save(next, key); data = next; selected = draft; dialog.Accept(); filter = "all"; Search.Clear(); BuildNavigation(); RefreshCards(); SetStatus("Карточка сохранена и зашифрована."); + } + catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException or System.Security.Cryptography.CryptographicException) { error.Text = "Не удалось сохранить: " + ex.Message; } + } + dialog.Closing += (_, e) => + { + if (dialog.Accepted || dialog.Forced || key == null) return; + Capture(); + if (JsonSerializer.Serialize(draft, Vault.Json) != JsonSerializer.Serialize(original, Vault.Json) && !Confirm("Закрыть без сохранения?", "В карточке есть несохранённые изменения.")) e.Cancel = true; + }; + dialog.AddCancel(); dialog.Actions.Children.Add(Ui.Button("Сохранить карточку", Save, "Primary")); + dialog.PreviewKeyDown += (_, e) => { if (e.Key == Key.S && Keyboard.Modifiers == ModifierKeys.Control) { Save(); e.Handled = true; } }; + dialog.Loaded += (_, _) => title.Focus(); dialog.ShowDialog(); + } + + private void ShowGenerator(Action? use = null) + { + var owner = OwnedWindows.Cast().LastOrDefault(w => w.IsVisible) ?? this; + new PasswordGeneratorDialog(owner, use ?? CopySecret, use != null).ShowDialog(); + } + + private void ShowSettings() + { + if (busy) return; + var dialog = new AppDialog(this, "Настройки", "Подключение, резервные копии и защита хранилища.", 640); + dialog.Body.Children.Add(Ui.Text("СИНХРОНИЗАЦИЯ С PI", 10, "#A5E4BC", FontWeights.SemiBold)); + dialog.Body.Children.Add(Ui.Text("База доступна только по локальному IP Pi и только в указанном домашнем Wi-Fi. Вне дома приложение работает автономно.", 12, "#969EAD")); + var url = Ui.Input(dialog.Body, "Локальный HTTPS-адрес Pi", store.Config.Url); + var homeSsid = Ui.Input(dialog.Body, "Домашний Wi-Fi (точное имя SSID)", store.Config.HomeWifiSsid); + var certificate = Ui.Input(dialog.Body, "SHA-256 локального сертификата Pi", store.Config.CertificateSha256); + Ui.Label(dialog.Body, "Ключ подключения"); var token = new SecretInput(store.Config.Token, "Ключ подключения"); dialog.Body.Children.Add(token); + var auto = new CheckBox { Content = "Синхронизировать открытое хранилище раз в минуту", IsChecked = store.Config.AutoSync, Margin = new Thickness(0, 15, 0, 10) }; dialog.Body.Children.Add(auto); + var error = Ui.Text("", 12, "#FFADAD"); dialog.Body.Children.Add(error); + var connection = Ui.Button("Выбрать файл connection.qsafe", () => + { + var file = new OpenFileDialog { Title = "Личный файл подключения к Pi", Filter = "Файл подключения QSafe (*.qsafe)|*.qsafe" }; + if (file.ShowDialog(dialog) != true) return; + try { var config = Connection.Parse(LocalStore.ReadBounded(file.FileName, 65536)); url.Text = config.Url; homeSsid.Text = config.HomeWifiSsid; certificate.Text = config.CertificateSha256; token.Set(config.Token); error.Text = "Локальное подключение прочитано. Нажмите «Сохранить»."; } + catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException or JsonException or ArgumentException) { error.Text = ex.Message; } + }); connection.HorizontalAlignment = HorizontalAlignment.Left; connection.Margin = new Thickness(0, 12, 0, 12); dialog.Body.Children.Add(connection); + string syncText = store.Config.LastSync is { } last ? "Последняя синхронизация: " + last.ToLocalTime().ToString("dd.MM.yyyy HH:mm") : "Синхронизация ещё не выполнялась"; + dialog.Body.Children.Add(Ui.Text(syncText, 12, "#969EAD")); + Action? after = null; + if (key != null) + { + var sync = Ui.Button("Синхронизировать сейчас", () => { if (!SaveSettings()) return; after = () => _ = Synchronize(); dialog.Accept(); }); sync.IsEnabled = !IsDemo; sync.HorizontalAlignment = HorizontalAlignment.Left; sync.Margin = new Thickness(0, 14, 0, 0); dialog.Body.Children.Add(sync); + dialog.Body.Children.Add(Ui.Rule()); dialog.Body.Children.Add(Ui.Text("ДАННЫЕ", 10, "#A5E4BC", FontWeights.SemiBold)); + foreach (var (label, action) in new (string, Action)[] { ("Экспорт зашифрованной базы .qsafe", ExportVault), ("Восстановить из файла .qsafe", ImportVault), ("Импортировать SafeInCloud XML", ImportXml) }) + { + var button = Ui.Button(label, () => { after = action; dialog.Accept(); }, "Quiet"); button.HorizontalAlignment = HorizontalAlignment.Left; button.Margin = new Thickness(0, 6, 0, 0); dialog.Body.Children.Add(button); + } + } + dialog.Body.Children.Add(Ui.Rule()); dialog.Body.Children.Add(Ui.Text("ЗАЩИТА", 10, "#A5E4BC", FontWeights.SemiBold)); + var protections = Ui.Text("Автоблокировка через 2 минуты бездействия, при сворачивании и блокировке Windows. Буфер обмена очищается через 30 секунд. Ключ подключения защищён учётной записью Windows. Мастер-пароль не сохраняется.", 12, "#969EAD"); protections.Margin = new Thickness(0, 12, 0, 0); protections.LineHeight = 20; dialog.Body.Children.Add(protections); + dialog.Body.Children.Add(Ui.Rule()); dialog.Body.Children.Add(Ui.Text("QSafe для Windows · 1.0.4\nЛокальные резервные копии: последние 10 сохранений.\nWindows Hello, TOTP и автозаполнение браузеров пока недоступны.", 12, "#788493")); + bool SaveSettings() + { + if (IsDemo) { error.Text = "Подключение к серверу недоступно в демо."; return false; } + try + { + if (token.Value.Length > 0) + { + var next = Vault.Copy(store.Config); next.Url = url.Text.Trim(); next.HomeWifiSsid = homeSsid.Text; next.CertificateSha256 = certificate.Text.Trim(); next.Token = token.Value; next.AutoSync = auto.IsChecked == true; store.Configure(next); + } + else if (store.Connected) throw new InvalidDataException("Для изменения подключения нужен ключ. Текущее подключение сохранено."); + else { store.Config.AutoSync = auto.IsChecked == true; store.SaveConfig(); } + SetStatus("Настройки сохранены."); return true; + } + catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException or System.ComponentModel.Win32Exception or ArgumentException) { error.Text = ex.Message; return false; } + } + dialog.AddCancel("Закрыть"); dialog.Actions.Children.Add(Ui.Button("Сохранить", () => { if (SaveSettings()) dialog.Accept(); }, "Primary")); + if (dialog.ShowDialog() == true && !dialog.Forced) after?.Invoke(); + } + + private async void ImportXml() + { + if (key == null || busy) return; + var file = new OpenFileDialog { Title = "XML-экспорт SafeInCloud", Filter = "XML SafeInCloud (*.xml)|*.xml" }; + if (file.ShowDialog(this) != true) return; + ImportPreview? preview = null; + await Work("Читаем XML локально…", async () => { var current = data!; preview = await Task.Run(() => XmlImport.Preview(LocalStore.ReadBounded(file.FileName, 24 * 1024 * 1024), current)); }); + if (preview == null || key == null) return; + if (!Confirm("Импорт из SafeInCloud", $"Добавится карточек: {preview.Added}. Дубликатов: {preview.Duplicates}. Пропущено: {preview.Skipped}. Вложений сохранится: {preview.Attachments}.\n\nXML содержит открытые пароли. После проверки переноса удалите экспорт с устройства.")) return; + await Work("Сохраняем импортированные карточки…", () => + { + store.Save(preview.Data, key); data = preview.Data; filter = "all"; Search.Clear(); BuildNavigation(); RefreshCards(); SetStatus($"Импортировано карточек: {preview.Added}. Вложения сохранены в зашифрованной базе."); return Task.CompletedTask; + }); + } +} diff --git a/windows/QSafe.Desktop/HomeWifiNetwork.cs b/windows/QSafe.Desktop/HomeWifiNetwork.cs new file mode 100644 index 0000000..7c894fb --- /dev/null +++ b/windows/QSafe.Desktop/HomeWifiNetwork.cs @@ -0,0 +1,49 @@ +using System.IO; +using System.Net.NetworkInformation; +using System.Net.Sockets; +using System.Runtime.InteropServices; +using System.Text; +using QSafe.Core; + +namespace QSafe.Desktop; + +/// Reads the actual WLAN association; adapter/profile names and VPN adapters are not SSIDs. +public sealed class HomeWifiNetwork : ILanNetwork +{ + public static readonly HomeWifiNetwork Instance = new(); + [DllImport("wlanapi.dll")] private static extern uint WlanOpenHandle(uint version, IntPtr reserved, out uint negotiated, out IntPtr handle); + [DllImport("wlanapi.dll")] private static extern uint WlanCloseHandle(IntPtr handle, IntPtr reserved); + [DllImport("wlanapi.dll")] private static extern uint WlanQueryInterface(IntPtr handle, ref Guid guid, uint opcode, IntPtr reserved, out uint size, out IntPtr data, out uint valueType); + [DllImport("wlanapi.dll")] private static extern void WlanFreeMemory(IntPtr data); + public LanBinding Current(Connection connection) + { + if (WlanOpenHandle(2, IntPtr.Zero, out _, out var handle) != 0) throw Offline(); + try + { + foreach (var adapter in NetworkInterface.GetAllNetworkInterfaces().Where(a => a.NetworkInterfaceType == NetworkInterfaceType.Wireless80211 && a.OperationalStatus == OperationalStatus.Up)) + { + if (!Guid.TryParse(adapter.Id, out var guid)) continue; + IntPtr raw = IntPtr.Zero; + try + { + // WLAN_CONNECTION_ATTRIBUTES: state, mode, WCHAR profile[256], DOT11_SSID. + if (WlanQueryInterface(handle, ref guid, 7, IntPtr.Zero, out var size, out raw, out _) != 0 || size < 556 || Marshal.ReadInt32(raw) != 1) continue; + int length = Marshal.ReadInt32(raw, 520); if (length is < 1 or > 32) continue; + var ssidBytes = new byte[length]; Marshal.Copy(IntPtr.Add(raw, 524), ssidBytes, 0, length); + string ssid = new UTF8Encoding(false, true).GetString(ssidBytes); + if (ssid != connection.HomeWifiSsid) continue; + var properties = adapter.GetIPProperties(); int index = properties.GetIPv4Properties().Index; + foreach (var address in properties.UnicastAddresses.Where(a => a.Address.AddressFamily == AddressFamily.InterNetwork)) + { + var binding = new LanBinding(ssid, true, address.Address, address.PrefixLength, index); + try { LanPolicy.Check(connection, binding); return binding; } catch (IOException) { } + } + } + finally { if (raw != IntPtr.Zero) WlanFreeMemory(raw); } + } + } + finally { WlanCloseHandle(handle, IntPtr.Zero); } + throw Offline(); + } + private static IOException Offline() => new("Автономный режим: домашний Wi-Fi не подтверждён. Синхронизация не выполняется. Если Windows скрывает SSID, проверьте разрешение доступа к имени Wi-Fi."); +} diff --git a/windows/QSafe.Desktop/LocalStore.cs b/windows/QSafe.Desktop/LocalStore.cs new file mode 100644 index 0000000..25b1bbe --- /dev/null +++ b/windows/QSafe.Desktop/LocalStore.cs @@ -0,0 +1,116 @@ +using System.IO; +using System.Runtime.InteropServices; +using System.ComponentModel; +using System.Security.Cryptography; +using System.Text.Json; +using QSafe.Core; + +namespace QSafe.Desktop; + +public sealed class LocalStore +{ + public string DirectoryPath { get; } + private string VaultPath => Path.Combine(DirectoryPath, "vault.qsafe"); + private string BasePath => Path.Combine(DirectoryPath, "synced.qsafe"); + private string ConfigPath => Path.Combine(DirectoryPath, "connection.enc"); + public bool Exists => File.Exists(VaultPath); + public Connection Config { get; private set; } = new(); + public bool Connected => Config.Token.Length > 0; + public LocalStore(string path) + { + DirectoryPath = path; Directory.CreateDirectory(path); + if (File.Exists(ConfigPath)) + { + var plain = DeviceProtection.Unprotect(File.ReadAllBytes(ConfigPath)); + try { Config = JsonSerializer.Deserialize(plain, Vault.Json) ?? new(); } + finally { CryptographicOperations.ZeroMemory(plain); } + } + } + public static byte[] ReadBounded(string path, int limit = VaultCrypto.MaxBytes) + { + using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); + if (stream.Length > limit) throw new InvalidDataException("Файл слишком большой."); + using var output = new MemoryStream(); stream.CopyTo(output); + if (output.Length > limit) throw new InvalidDataException("Файл слишком большой."); + return output.ToArray(); + } + public byte[] Read() => ReadBounded(VaultPath); + public byte[]? ReadBase() => File.Exists(BasePath) ? ReadBounded(BasePath) : null; + public static void AtomicWrite(string path, byte[] bytes) + { + var temp = path + "." + Guid.NewGuid().ToString("N") + ".tmp"; + try + { + using (var stream = new FileStream(temp, FileMode.CreateNew, FileAccess.Write, FileShare.None, 8192, FileOptions.WriteThrough)) { stream.Write(bytes); stream.Flush(true); } + File.Move(temp, path, true); + } + finally { if (File.Exists(temp)) File.Delete(temp); } + } + public void Save(VaultData data, VaultKey key) => SaveEnvelope(Vault.Encrypt(data, key)); + public void SaveEnvelope(byte[] envelope) + { + VaultCrypto.Validate(envelope); + if (Exists) + { + var backup = Path.Combine(DirectoryPath, "backups"); Directory.CreateDirectory(backup); + AtomicWrite(Path.Combine(backup, DateTime.UtcNow.ToString("yyyyMMdd-HHmmss-fffffff") + ".qsafe"), Read()); + foreach (var old in Directory.GetFiles(backup, "*.qsafe").OrderDescending().Skip(10)) File.Delete(old); + } + AtomicWrite(VaultPath, envelope); + } + public void SaveConfig() + { + var plain = JsonSerializer.SerializeToUtf8Bytes(Config, Vault.Json); + try { AtomicWrite(ConfigPath, DeviceProtection.Protect(plain)); } finally { CryptographicOperations.ZeroMemory(plain); } + } + public void Configure(Connection config) + { + config.Validate(); + if (Config.Url != config.Url || Config.Token != config.Token) + { + config.Etag = ""; config.LastSync = null; + // Clear the ancestor before storing the new connection: a crash cannot use a wrong ancestor. + if (File.Exists(BasePath)) File.Delete(BasePath); + } + Config = config; SaveConfig(); + } + public void MarkSynced(byte[] envelope) + { + AtomicWrite(BasePath, envelope); Config.Etag = VaultCrypto.Hash(envelope); Config.LastSync = DateTimeOffset.Now; SaveConfig(); + } + public void ResetSync() + { + if (File.Exists(BasePath)) File.Delete(BasePath); + Config.Etag = ""; Config.LastSync = null; SaveConfig(); + } +} + +public static class DeviceProtection +{ + [StructLayout(LayoutKind.Sequential)] private struct Blob { public int Length; public IntPtr Data; } + [DllImport("crypt32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + [return: MarshalAs(UnmanagedType.Bool)] private static extern bool CryptProtectData(ref Blob input, string description, IntPtr entropy, IntPtr reserved, IntPtr prompt, int flags, out Blob output); + [DllImport("crypt32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] private static extern bool CryptUnprotectData(ref Blob input, IntPtr description, IntPtr entropy, IntPtr reserved, IntPtr prompt, int flags, out Blob output); + [DllImport("kernel32.dll")] private static extern IntPtr LocalFree(IntPtr memory); + public static byte[] Protect(byte[] bytes) => Transform(bytes, true); + public static byte[] Unprotect(byte[] bytes) => Transform(bytes, false); + private static byte[] Transform(byte[] bytes, bool encrypt) + { + var input = new Blob { Length = bytes.Length, Data = Marshal.AllocHGlobal(bytes.Length) }; + Blob output = default; + try + { + Marshal.Copy(bytes, 0, input.Data, bytes.Length); + bool ok = encrypt ? CryptProtectData(ref input, "QSafe connection", IntPtr.Zero, IntPtr.Zero, IntPtr.Zero, 1, out output) : CryptUnprotectData(ref input, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero, 1, out output); + if (!ok) throw new Win32Exception(Marshal.GetLastWin32Error(), "Не удалось защитить настройки учётной записью Windows."); + var result = new byte[output.Length]; Marshal.Copy(output.Data, result, 0, result.Length); return result; + } + finally + { + for (int i = 0; i < input.Length; i++) Marshal.WriteByte(input.Data, i, 0); + Marshal.FreeHGlobal(input.Data); + if (output.Data != IntPtr.Zero) { for (int i = 0; i < output.Length; i++) Marshal.WriteByte(output.Data, i, 0); LocalFree(output.Data); } + } + } +} diff --git a/windows/QSafe.Desktop/MainWindow.xaml b/windows/QSafe.Desktop/MainWindow.xaml new file mode 100644 index 0000000..75dc954 --- /dev/null +++ b/windows/QSafe.Desktop/MainWindow.xaml @@ -0,0 +1,61 @@ + + + + + + + + + + + + +