diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 20f2082..84fa3a3 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -13,8 +13,8 @@ body: id: app-version attributes: label: Encly version - description: About screen, e.g. 2.0.0. Add where you installed it from (GitHub Releases, F-Droid, IzzyOnDroid, Google Play, own build). - placeholder: 2.0.0 (GitHub Releases) + description: Settings → About, e.g. 2.0.0. + placeholder: 2.0.0 validations: required: true - type: input @@ -32,13 +32,16 @@ body: validations: required: true - type: dropdown - id: upgrade + id: source attributes: - label: Did this start after updating from Encly 1.x? + label: Installed from options: - - "No / fresh install" - - "Yes, after upgrading from 1.x" - - "Not sure" + - "GitHub Releases or Obtainium (fdroid APK)" + - "GitHub Releases or Obtainium (play APK)" + - "F-Droid" + - "Google Play" + - "Built from source" + - "Other / not sure" validations: required: true - type: textarea diff --git a/CHANGELOG.md b/CHANGELOG.md index 90ba5f9..9ca2b1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,105 +11,123 @@ IzzyOnDroid) and used as the GitHub Release notes. ## [Unreleased] -## [2.0.0] - 2026-09-24 +## [2.0.0] - 2026-09-25 versionCode 20000. +The first public release. Encly 2.0 is rebuilt around a new encrypted vault, with a PIN bound to +the phone's secure hardware, auto-lock, encrypted backups, a new design and nine languages. It +ships on GitHub Releases first; F-Droid and Google Play follow. + ### ⚠️ Breaking: new vault format -- Storage moves to the **v2 vault**: SQLCipher is keyed by a random 256-bit data key (DEK) that - is wrapped separately by the PIN (PBKDF2-HMAC-SHA256, 600,000 iterations + AES-256-GCM), by an - optional auth-bound biometric Keystore key, and by an optional BIP39 recovery seed. +- SQLCipher is now keyed by a random 256-bit data key (DEK). The PIN, an optional biometric + Keystore key and an optional 12-word recovery phrase each wrap that key in their own + AES-256-GCM slot. - **1.x data is not migrated.** 1.x had no public users; uninstall it before installing 2.0. - First-run setup still refuses to create a vault over an encrypted database it cannot open, - and asks for an explicit wipe instead. + First-run setup refuses to create a vault over an encrypted database it cannot open, and asks + for an explicit wipe instead. ### Added -- Encrypted backups (Settings → Backup): export and import of all notes, tasks and tags as one - AES-256-GCM file keyed by the recovery seed (HKDF-SHA256, random salt and nonce per file), - saved through the system file picker. Merge or replace on import, in one transaction. - "Restore from backup" in onboarding recreates a vault on a new phone from the file and the - 12 words. A vault without a recovery seed can add one before its first export. -- Mandatory 6-digit PIN; optional Class 3 biometric unlock bound to a `BiometricPrompt.CryptoObject`. -- The database is closed and the in-memory key zeroized when the app goes to the background. -- Privacy policy, published at (mirrored in - [PRIVACY.md](PRIVACY.md)) and linked from the About screen and both store listings. -- `fdroid` and `play` distribution flavors (same application ID). The `fdroid` flavor has no - Google Play "Rate app" link and no baseline profile, for reproducible builds. -- Signed release workflow: tag `vX.Y.Z` builds, signs (when secrets exist), and publishes APKs - with `SHA256SUMS`. -- Store listings for Google Play and F-Droid in all 9 languages under `fastlane/metadata/android`, - a Play App Bundle of the `play` flavor (`bundlePlayRelease`, signed with the same key, all - languages in the base module so the in-app language picker keeps working), and a Play upload - workflow that is disabled until explicitly enabled. Release builds no longer embed VCS info. -- The interface is available in English (now the default), Ukrainian, German, French, Spanish, - Italian, Polish, Portuguese and Dutch, with an in-app language picker (Settings → Language, - including "System default"); on Android 13+ the choice also appears in the system's per-app - language settings. A unit test and lint (`MissingTranslation` as an error) keep every locale - complete. -- Settings → Security: create a recovery phrase later (after the PIN, with the same three-word - check as onboarding), and "Erase all data" behind the PIN and an explicit confirmation. -- New brand: an "E" lettermark launcher icon with a themed (monochrome) layer, used in the app in - place of the old lock tile, and one icon set drawn in the app's stroke. -- Unlock animation: after a PIN or fingerprint unlock the logo tile grows to fill the window and - the notes list slides in under it, without the lock screen flashing. -- Recovery phrase entry as 12 numbered word cells, shared by onboarding, recovery from the lock - screen, backup import and phrase confirmation, with paste, a per-word BIP39 check and the - checksum check. -- Editor toolbar: a button that hides the keyboard and brings it back to the block you were - writing in (also on OEM keyboards that ignore one of the two Android APIs). -- Link blocks show as offline cards: the host as the title and the rest of the address under it. - Nothing is fetched to build them. -- Onboarding rebuilt as one flow with progress: welcome, PIN with optional fingerprint, why the - recovery phrase matters, write it down, check three words. "I have a backup" leads to restore; - "PIN only, no backups" skips the phrase. -- A rebuilt Support page and an updated FAQ; donations (Ko-fi) appear only in the F-Droid build. -- Tag drag-to-reorder, designed empty states, and a discard confirmation in the editor. +**Security** + +- Mandatory 6-digit PIN, bound to this phone: PBKDF2-HMAC-SHA256 (600,000 iterations) is mixed + with an HMAC from a non-exportable Android Keystore key (StrongBox where the phone has one), so + PIN guesses can only run on the device. Encly stores no PIN hash. Wrong PINs lock the app out + from the 5th miss, doubling up to 24 hours; a reboot or a clock change does not shorten it. +- Optional Class 3 biometric unlock, bound to a `BiometricPrompt.CryptoObject`. +- Auto-lock: after you leave the app, the database is closed and its key wiped from memory once + the delay chosen in Settings → Security has passed (immediately, 15 s by default, 30 s, 1 min + or 2 min). Turning the screen off or locking the phone locks Encly at once. +- A new Security page: an encryption status card, "How Encly protects your notes", biometric + unlock, the auto-lock delay, strict keyboard privacy, creating or replacing the recovery + phrase, and a danger zone with **Erase all data** (hold for 5 s, confirm, then PIN or + fingerprint). +- Optional 12-word BIP39 recovery phrase: unlocks the vault when the PIN is forgotten, and is + the key to encrypted backups. It can be created during setup or later, and replaced. +- Encrypted backups (Settings → Backup): all notes, tasks and tags in one AES-256-GCM file keyed + by the recovery phrase, saved wherever you choose through the system file picker. Import merges + or replaces in one transaction, and "Restore from backup" in setup rebuilds a vault on a new + phone from the file and the 12 words. +- Strict keyboard privacy (opt-in): text fields ask the keyboard for no suggestions and no cloud + prediction. Every field already asks it not to learn from what you type. +- Autofill is excluded, other apps' overlays are hidden (Android 12+), screen content is marked + sensitive for accessibility services (Android 14+), and anything copied is marked sensitive + and cleared from the clipboard after 60 s. + +**Design and editing** + +- A new design: one type scale, spacing and component set across every screen; five colour + themes (Paper, Forest, Ocean, Graphite, Midnight), System / Light / Dark mode, dynamic colour + on Android 12+, and three bundled font sets (Editorial, Modern, Technical). +- Page transitions on Material's shared X axis, and an unlock animation in which the logo tile + grows to fill the window on the first unlock after launch (later unlocks cross-fade). +- A new "E" launcher icon with a themed (monochrome) layer. +- Onboarding as one flow with progress: welcome, PIN with optional fingerprint, why the recovery + phrase matters, write it down, check three words. "I have a backup" leads to restore; "PIN + only, no backups" skips the phrase. +- Recovery phrase entry as 12 numbered cells with paste, a per-word BIP39 check and the checksum + check, used everywhere the phrase is typed. +- Editor: Enter splits a block at the cursor and Backspace merges, Markdown-style shortcuts + (`# `, `- `, `1. `), multi-line paste into blocks, per-word undo, a button that hides the + keyboard and brings it back to the block you were writing in, and a discard confirmation. +- Link blocks show as offline cards (host as the title, the rest of the address under it); + nothing is fetched to build them. Only `http`, `https` and `mailto` links are accepted. +- Tag drag-to-reorder and designed empty states. +- Nine languages: English (now the default), Ukrainian, German, French, Spanish, Italian, + Polish, Portuguese and Dutch, with an in-app picker (Settings → Language, including "System + default"). +- A rebuilt Support page, an updated FAQ and an open-source licenses page. Donations (Ko-fi) + appear only in the `fdroid` flavor. + +**Distribution** + +- `fdroid` and `play` flavors with the same application ID. The `fdroid` flavor has no Google + Play link and no baseline profile, for reproducible builds. +- Release workflow: a `vX.Y.Z` tag builds both flavors unsigned, signs them with `apksigner`, + checks the signing certificate against the fingerprint published in the README, and publishes + the APKs with `SHA256SUMS`. It fails rather than publish an unsigned APK. +- Store listings, screenshots and release notes for all nine languages under + `fastlane/metadata/android`. +- A privacy policy, published at and linked + from the About screen and the store listings. ### Changed - Fonts (Playfair Display, Source Sans 3, IBM Plex Sans, Poppins) are bundled in the APK instead of being downloaded through Google Play Services. -- Settings → Appearance: five colour themes (Paper, Forest, Ocean, Graphite, Midnight), a - System / Light / Dark mode and an app-wide font choice (Editorial, Modern, Technical). -- Feedback goes to the GitHub issue tracker instead of a third-party form service. - `FLAG_SECURE` is always on and applied before the first frame. -- All text fields ask the keyboard not to learn from input (`IME_FLAG_NO_PERSONALIZED_LEARNING`). -- Build: one Kotlin version through a Gradle version catalog, pinned Gradle wrapper checksum. -- Search covers every note whatever tag chip is selected (not notes under a hidden tag), lists - every match instead of the first five, and ignores checkbox markers and separator lines. - Note text is parsed once per change, off the main thread; an unreadable note no longer breaks - the list or search. -- The editor toolbar's move up, move down and delete buttons act on the current block. -- New design system: Playfair Display, Source Sans 3 and IBM Plex Sans on one type scale, shared - spacing, shapes and components; every screen restyled (lock, notes, editor, tasks, trash, - tags, settings, backup, dialogs). Tasks are always in the drawer. -- Editor: fields own their text, so fast typing no longer resets; new blocks go after the block - being edited; hardware Enter adds no stray line break; quotes end with a closing mark. +- Feedback goes to the GitHub issue tracker instead of a third-party form service. +- Search covers every note whatever tag is selected, lists every match, and ignores checkbox + markers and separator lines; an unreadable note no longer breaks the list or search. - Unlocking returns to the note that was open. +- Editor: fast typing no longer resets a field, new blocks go after the block being edited, + hardware Enter adds no stray line break, and quotes end with a closing mark. - Result messages on the backup and security screens stay visible until read. -- Store listings: texts, feature graphics and screenshots for all 9 languages. +- Build: one Kotlin version through a Gradle version catalog and a pinned Gradle wrapper + checksum. ### Removed -- Plaintext note sharing, clipboard copy of notes, seed export and calendar export. (A link - block's address can still be copied, on request and marked sensitive.) -- The `ui-text-google-fonts` dependency and its Google Play Services font provider. -- Unused libraries. -- The hidden screen-protection preference (screen protection cannot be turned off) and other - unused code; debug logging calls (release builds strip the remaining failure logs). +- Plaintext note sharing, copying whole notes to the clipboard, seed export, calendar export and + task reminders. Encly posts no notifications. (A link block's address can still be copied, on + request and marked sensitive.) +- The Google Play Services font provider (`ui-text-google-fonts`). +- `androidx.security:security-crypto`, which is deprecated upstream. +- The hidden screen-protection preference: screen protection can no longer be turned off. ### Security - Android backup and device-to-device transfer are disabled for all vault data. -- See [SECURITY.md](SECURITY.md) for the full threat model and known limitations. +- The full threat model and known limitations are in + [SECURITY.md](https://github.com/pasichDev/Encly/blob/main/SECURITY.md). -## [1.1.1] - v1 +## [1.1.1] -Last release of the v1 storage format (versionCode 30): SQLCipher key derived from a -Keystore-sealed seed hash, optional 4-digit PIN. Superseded by 2.0.0. +The last release of the old storage format (versionCode 30): the SQLCipher key was derived from a +Keystore-sealed seed hash, with an optional 4-digit PIN. It had no public users; superseded by +2.0.0. [Unreleased]: https://github.com/pasichDev/Encly/compare/v2.0.0...HEAD [2.0.0]: https://github.com/pasichDev/Encly/releases/tag/v2.0.0 -[1.1.1]: https://github.com/pasichDev/Encly/tree/main diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cf9767c..6b3c53d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -104,13 +104,14 @@ purpose. Every user-visible string lives in string resources. English is the default (`app/src/main/res/values/`); each translation is a `values-/` folder with a `strings.xml` -(UI) and a `motivation.xml` (home-screen quotes). +(most of the UI) and a `strings_security.xml` (security settings). -**Fixing a translation:** edit the string in `values-/strings.xml` and open a PR. Keep the -key; only change the text. +**Fixing a translation:** edit the string in `values-/strings.xml` (or +`strings_security.xml`) and open a PR. Keep the key; only change the text. -**Adding a string (code change):** add it to `values/strings.xml` in English and to *every* -`values-/strings.xml`, even if only with an English placeholder you flag in the PR. Use it +**Adding a string (code change):** add it in English to `values/strings.xml` (or +`strings_security.xml`) and to the same file in *every* `values-/`, even if only with an +English placeholder you flag in the PR. Use it with `stringResource(R.string.…)` in Compose. Text produced outside the UI (ViewModels, managers, validators) must not be resolved there: return a resource id or a `UiText` (`core/common/UiText.kt`) and resolve it in the UI, so it follows the in-app language. Never @@ -119,7 +120,7 @@ lower. **Adding a language:** -1. copy `values/strings.xml` and `values/motivation.xml` to `values-/` and translate them, +1. copy `values/strings.xml` and `values/strings_security.xml` to `values-/` and translate them, leaving out the entries marked `translatable="false"`; 2. add `` to `res/xml/locales_config.xml`; 3. add an entry to `AppLanguage` (`core/locale/AppLanguage.kt`) and its own-language name as a @@ -173,8 +174,10 @@ upload this key instead of letting Google generate one). 1. Bump `VERSION_MAJOR/MINOR/PATCH` in `version.properties`. `versionCode` follows automatically (`MAJOR*10000 + MINOR*100 + PATCH`) and must only grow. 2. Move `[Unreleased]` in `CHANGELOG.md` to a dated `## [X.Y.Z] - YYYY-MM-DD` section whose first - line is `versionCode N.` (F-Droid's update check reads the version from these two lines; the - unit tests fail when they disagree with `version.properties`). + line is `versionCode N.` F-Droid's update check reads the version name and code from these two + lines, because it cannot evaluate `version.properties`; no test checks them, so compare them + with `version.properties` by hand. The release workflow uses this section as the GitHub Release + notes, so use absolute links in it. 3. Write the store release notes (≤ 500 characters each) to `fastlane/metadata/android//changelogs/.txt` for **every** locale folder (`en-US`, `uk`, `de-DE`, `fr-FR`, `es-ES`, `it-IT`, `pl-PL`, `pt-PT`, `nl-NL`); @@ -184,7 +187,9 @@ upload this key instead of letting Google generate one). [release workflow](.github/workflows/release.yml) refuses a tag that does not match `version.properties`, fails when a signing secret or `ENCLY_CERT_SHA256` is missing, builds both flavors unsigned, signs them with `apksigner` in a separate step (Gradle never sees - the keystore), checks the certificate against `ENCLY_CERT_SHA256`, and publishes the APKs with `SHA256SUMS` and R8 mapping files. + the keystore), checks the certificate against `ENCLY_CERT_SHA256`, and publishes the APKs + with `SHA256SUMS` (signed as `SHA256SUMS.asc` when the GPG secrets are set) and the R8 + mapping files. 6. Google Play: either upload `app-play-release.aab` by hand (`./gradlew :app:bundlePlayRelease` with the `ENCLY_*` variables set), or, once enabled, run the [Publish to Google Play](.github/workflows/publish-play.yml) workflow with the tag; it diff --git a/LICENSE b/LICENSE index 609b331..d63fa20 100644 --- a/LICENSE +++ b/LICENSE @@ -186,7 +186,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2022 pasichDev + Copyright 2022-2026 pasichDev Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/PRIVACY.md b/PRIVACY.md index 9ca103a..1abec5c 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -1,6 +1,6 @@ # Privacy Policy -_Effective: 2026-09-22 · Applies to Encly 2.0.0 and later (Android, package `com.pasich.encly`)._ +_Effective: 2026-09-25 · Applies to Encly 2.0.0 and later (Android, package `com.pasich.encly`)._ Encly is an offline notes and tasks app. This policy explains what happens to your data. The short version: **everything stays on your device, encrypted, and nobody (including the @@ -35,13 +35,18 @@ transfer are disabled for it, so it is not copied to Google Drive or another pho | Permission | Why | |---|---| | `USE_BIOMETRIC` | Optional fingerprint / face unlock of your vault. Biometric data never leaves the Android system; Encly only receives "authenticated" or "not authenticated". | +| `USE_FINGERPRINT` | Added by Android's biometric library for fingerprint unlock on Android 8.x, which predates `USE_BIOMETRIC`. Same purpose, same limits. | | `HIDE_OVERLAY_WINDOWS` | Lets Encly stop other apps from drawing over its screens (Android 12 and later), so an overlay cannot cover or imitate the PIN pad. It gives Encly no access to any data. | +Encly requests no internet, storage, contacts, location, camera, microphone or notification +permission. + ## Links that leave the app A few items open a page outside Encly, **only when you tap them**: on the About screen, this -privacy policy, the GitHub issue tracker, an email to the developer, and, in builds distributed -through Google Play only, the Play Store listing; in builds distributed through F-Droid only, the +privacy policy, the GitHub issue tracker, an email to the developer, and, in the Google Play +build only, the Play Store listing; on the open-source licenses screen, each library's or font's +license; in the F-Droid build only (also offered as the `fdroid` APK on GitHub Releases), the developer's Ko-fi donation page (Support screen); and a link block you added to a note, which opens in your browser. These are handled by your browser, email or store app under their own privacy policies. Encly sends them nothing beyond the fact that the link was opened (for a link @@ -50,10 +55,10 @@ block, the address you wrote). ## Sharing Encly does not share, sell or transfer any data to anyone. It has no share flow for note -content, and the only thing it copies to the clipboard is a link from a note's link block, when -you choose to copy it (marked as sensitive on Android 13 and later, so it is hidden from the -clipboard preview). Apart from that, the only way data leaves the app is an encrypted backup file -that you export yourself (below). +content. It puts something on the clipboard only when you copy it yourself: text you select, or a +link from a note's link block. Every copy is marked as sensitive (on Android 13 and later it is +hidden from the clipboard preview) and cleared after about a minute. Apart from that, the only way +data leaves the app is an encrypted backup file that you export yourself (below). ## Encrypted backup file @@ -81,8 +86,9 @@ be revoked, so keep the words and the file apart. See ## Deleting your data Uninstalling Encly, or clearing its storage in Android settings, permanently deletes all of -it. There is no copy anywhere else, apart from encrypted backups you exported yourself. If you did not keep a recovery seed, a forgotten PIN also -means the data cannot be recovered. +it, and so does **Settings → Security → Erase all data**. There is no copy anywhere else, apart +from encrypted backups you exported yourself. If you did not keep a recovery seed, a forgotten +PIN also means the data cannot be recovered. ## Children diff --git a/README.md b/README.md index f476bb5..f219f27 100644 --- a/README.md +++ b/README.md @@ -12,15 +12,22 @@ a mandatory app PIN and, optionally, strong biometrics. ## Features - 🔒 **Encrypted at rest** — Room on SQLCipher with a random 256-bit database key. -- 🧩 **Block editor** — text, headings, quotes, checklists / numbered lists, links, separators. +- 🔐 **Mandatory lock** — a 6-digit PIN bound to the phone's Android Keystore, plus optional + Class 3 biometric unlock. +- ⏱️ **Auto-lock** — the database closes and its key is wiped after you leave the app + (immediately up to 2 minutes, 15 s by default) and at once when the screen turns off. +- 🔑 **Optional BIP39 recovery phrase** — 12 words that unlock the vault if you forget the PIN. +- 💾 **Encrypted backups** — one file, sealed with the recovery phrase, saved wherever you choose. +- 🧩 **Block editor** — text, headings, quotes, checklists, bulleted / numbered lists, links, + separators. - 🏷️ **Tags & tasks** — local organization: tags for notes, tasks with priorities. - 🗑️ **Trash** — soft-delete with restore. -- 🔑 **Optional BIP39 recovery seed** — a separate recovery slot for the database key. +- 🎨 **5 themes** — Paper, Forest, Ocean, Graphite and Midnight; light, dark or system mode and + three bundled font sets. - 📴 **Fully offline** — no `INTERNET` permission, cloud sync, analytics or downloadable fonts - (editor fonts are bundled). The only links out (privacy policy, issue tracker, email, a note's - link block and, in the Play build, the store page or, in the F-Droid build, the Ko-fi page) - open in another app and only when you tap them. -- 🔐 **Mandatory lock** — 6-digit PIN plus optional Class 3 biometric unlock. + (editor fonts are bundled). The only links out (privacy policy, issue tracker, email, the + open-source license pages, a note's link block and, in the `play` flavor, the store page or, in + the `fdroid` flavor, the Ko-fi page) open in another app and only when you tap them. - 🛡️ **Protected UI** — `FLAG_SECURE` is enforced from the first Activity frame. - 🌍 **9 languages** — pick one in Settings → Language, independently of the system language. @@ -95,29 +102,38 @@ if you ran it yourself, uninstall it before installing 2.0. ## Security model -Encly v2 uses envelope encryption rather than deriving the SQLCipher key directly from +Encly 2.0 uses envelope encryption rather than deriving the SQLCipher key directly from a seed or PIN: 1. Onboarding creates a random 256-bit **DEK** (data-encryption key). 2. SQLCipher is opened with that DEK. -3. The mandatory PIN is processed with **PBKDF2-HMAC-SHA256 (600k)** and a random salt. - The resulting KEK wraps the DEK using **AES-256-GCM**. Encly does not store a PIN hash. +3. The mandatory PIN is processed with **PBKDF2-HMAC-SHA256 (600k)** and a random salt, + and mixed (HKDF) with an HMAC from a non-exportable **Android Keystore** key (StrongBox + when the phone has one), so PIN guesses can only run on this device. The resulting KEK + wraps the DEK using **AES-256-GCM**. Encly does not store a PIN hash. 4. If biometrics are enabled, the same DEK gets a second AES-GCM slot protected by an auth-per-use AndroidKeyStore key. Unwrapping requires `BiometricPrompt.CryptoObject` with `BIOMETRIC_STRONG`. -5. In user-managed recovery mode, a 12-word BIP39 seed derives a recovery KEK and wraps - the same DEK in a separate AES-GCM recovery slot. -6. When Encly goes to the background, SQLCipher is closed and Encly's in-memory DEK copy - is zeroized. The next entry must unwrap the DEK again. +5. With a recovery phrase (offered during setup, or later in Settings → Security), a 12-word + BIP39 seed derives a recovery KEK and wraps the same DEK in a separate AES-GCM recovery + slot. The same words are the only key to encrypted backups. +6. After Encly leaves the screen (after the auto-lock delay in Settings → Security, 15 s by + default, or at once when the screen turns off), SQLCipher is closed and Encly's in-memory + DEK copy is zeroized. The next entry must unwrap the DEK again. -The auto-managed onboarding option deliberately has **no recovery seed**. Losing the PIN -and local unlock material in that mode makes the encrypted database unrecoverable. +Skipping the recovery phrase leaves the vault with **no recovery seed** and no backups. +Losing the PIN in that case makes the encrypted database unrecoverable. + +**Erase all data** (Settings → Security, in the danger zone: hold the button for 5 s, confirm, +then enter the PIN or use the fingerprint) deletes the database, every key slot and Encly's +Keystore keys, and starts setup again. The app also disables Android backup/device transfer for protected data and has no system notifications, plaintext note sharing, calendar export, or seed export; the only -thing it copies to the clipboard is a link block's address, on request and marked sensitive. The one way data leaves the phone is an **encrypted backup** -you export yourself (Settings → Backup): it is sealed with your 12-word recovery phrase, and -the same words restore it on a new phone ("Restore from backup" in onboarding). +thing it copies to the clipboard by itself is a link block's address, on request and marked +sensitive. The one way data leaves the phone is an **encrypted backup** you export yourself +(Settings → Backup): it is sealed with your 12-word recovery phrase, and the same words +restore it on a new phone ("Restore from backup" in onboarding). See [SECURITY.md](SECURITY.md) for the threat model and reporting process, and the [privacy policy](https://pasichdev.xyz/apps/encly/privacy-policy/) (also in @@ -129,7 +145,7 @@ Kotlin · Jetpack Compose · Material 3 · Hilt · Room · SQLCipher · Coroutin kotlinx.serialization · BIP39 (kotlin-bip39) Clean architecture: `presentation` → `domain` → `data`, with `core/security` -coordinating the v2 key vault. +coordinating the key vault. ## Build diff --git a/SECURITY.md b/SECURITY.md index 55de448..350be71 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,7 +1,7 @@ # Security -Encly is a local encrypted notes application. This document describes the current v3 -security model, its trust boundaries, and known limitations. +Encly is a local encrypted notes application. This document describes the security model of +Encly 2.0 (vault format v3), its trust boundaries, and known limitations. ## Key hierarchy @@ -63,9 +63,17 @@ Biometric unlock is optional. A successful biometric prompt by itself is therefore insufficient to open the database. The authenticated cryptographic operation must release the DEK. +- The system screen lock (device PIN, pattern or password) is never accepted in place of a + biometric: the prompt allows `BIOMETRIC_STRONG` only, and falling back means Encly's own + PIN. +- Turning biometric unlock **on** needs a biometric prompt that wraps the DEK through the + new key's CryptoObject; turning it **off** needs a Class 3 biometric confirmation, and + deletes the slot and the key. Only one such prompt can be open at a time, and the switch + is disabled on devices without enrolled strong biometrics. + ### Recovery slot -User-managed onboarding creates an optional BIP39 recovery slot. +Onboarding offers an optional BIP39 recovery slot. - Encly generates a 12-word BIP39 seed. - The seed is normalized (lower case, single spaces; the one normalization shared with the @@ -75,9 +83,10 @@ User-managed onboarding creates an optional BIP39 recovery slot. - Entering the correct recovery seed can therefore recover local database access even when the PIN is unavailable. -Auto-managed mode intentionally creates no recovery slot. One can be added later from an -unlocked session (Settings → Backup, after re-entering the PIN): the live DEK is wrapped under a -newly generated seed that the user writes down and confirms. +Choosing "PIN only, no backups" during onboarding intentionally creates no recovery slot. One +can be added later from an unlocked session (Settings → Security, or Settings → Backup before +the first export, after re-entering the PIN): the live DEK is wrapped under a newly generated +seed that the user writes down and confirms. **Replacing the phrase.** Settings → Security → "Replace recovery phrase" (unlocked session, after PIN or biometric re-authentication, and a confirmation) generates 12 new words, shows @@ -180,8 +189,14 @@ fileKey = HKDF-SHA256(ikm = backupRoot, salt = <32 random bytes per file>, in - The database is not considered committed until mandatory PIN setup succeeds. - Interrupted first-run setup restarts onboarding and discards incomplete vault slots. - When the app backgrounds, SQLCipher is closed and Encly's in-memory DEK copy is - zeroized. ViewModels holding decrypted content (notes list, tasks, editor, a decrypted - backup, new recovery words) drop it at that moment, not only when the UI resumes. + zeroized, after the user's auto-lock delay (Settings → Security: immediately, 15 s by + default, 30 s, 1 min or 2 min; measured on `elapsedRealtime`, so deep sleep counts). The + screen turning off or the device locking closes it at once, whatever the delay. ViewModels + holding decrypted content (notes list, tasks, editor, a decrypted backup, new recovery + words) drop it at that moment, not only when the UI resumes. +- Within the delay the vault stays open in the background: the DEK and decrypted content are + in memory, and the recents thumbnail is still blocked by `FLAG_SECURE`. Choose + "Immediately" to close it the moment Encly leaves the screen. - The next foreground entry must unwrap the DEK again through PIN, biometric, or recovery. - A process started with a committed, closed vault starts in the locked state, and a central @@ -196,10 +211,16 @@ fileKey = HKDF-SHA256(ikm = backupRoot, salt = <32 random bytes per file>, in second unlock call only with the same key, compared in constant time. - `FLAG_SECURE` is applied before the first Activity frame and cannot be disabled in settings. +- **Erase all data** (Settings → Security, danger zone) is held for 5 seconds, confirmed in a + dialog, then re-authenticated with the PIN or a Class 3 biometric. It closes and deletes the + database, every key slot, the lockout state, Encly's Keystore keys (PIN factor and biometric + key) and the vault flags (onboarding state, last export, strict keyboard privacy), and + returns to onboarding. There is no undo; only an exported + backup brings the data back. ## Outbound data policy -The beta security boundary intentionally removes system-visible plaintext features: +Encly intentionally leaves out system-visible plaintext features: - no notifications at all: no notification permission, channel, alarm or scheduled work; - no seed clipboard, file, Drive, or generic share export (encrypted backups never contain the @@ -260,8 +281,16 @@ The app requests no `INTERNET` permission and performs no analytics or sync. downloadable-font provider, so no font request goes to Google and the app works the same on devices without Google Play Services. - The About screen offers links (privacy policy, GitHub issues, developer email and, only in the - `play` flavor, the Play Store listing). They open in another app **only after an explicit - tap**; Encly itself sends nothing. + `play` flavor, the Play Store listing), the open-source licenses page links each library's and + font's license, and, only in the `fdroid` flavor, the Support page links the developer's Ko-fi + page. They open in another app **only after an explicit tap**; Encly itself sends nothing. +- **Links in notes are a deliberate exception** to "nothing leaves the vault". A link block + opens only from its sheet, which first shows the full address, after an explicit tap on + "Open", through the system chooser. Only `http`, `https` and `mailto` are saved or opened; + addresses with user info, backslashes, whitespace or control characters are refused, and + hosts are shown in punycode, so the card cannot name a different host than the one that + opens. The receiving app (a browser, a mail client) then sees that one address. Encly + fetches no previews: a link card is built from the stored address alone. ## Threat model @@ -319,7 +348,7 @@ uninstall to "fix" it without an exported backup, because uninstalling deletes t | Version | Storage format | Security fixes | |---|---|---| -| 2.0.x (beta) | v3 vault (random DEK in PIN / biometric / recovery slots; Keystore-bound PIN KEK; slot file) | ✅ yes | +| 2.0.x | v3 vault (random DEK in PIN / biometric / recovery slots; Keystore-bound PIN KEK; slot file) | ✅ yes | | 1.x (≤ 1.1.1, versionCode ≤ 30) | v1 (seed-derived SQLCipher key, 4-digit PIN) | ❌ no; not migrated, reinstall 2.0 | Reports should include the exact app version (About screen), where it was installed from, and diff --git a/app/src/main/java/com/pasich/encly/core/AppLogger.kt b/app/src/main/java/com/pasich/encly/core/AppLogger.kt index caebb1e..44ce94d 100644 --- a/app/src/main/java/com/pasich/encly/core/AppLogger.kt +++ b/app/src/main/java/com/pasich/encly/core/AppLogger.kt @@ -3,7 +3,7 @@ package com.pasich.encly.core /** * Central application logger, for failure events only. * - * Security-first beta policy: application logging is intentionally disabled in every build. + * Security-first policy: application logging is intentionally disabled in every build. * Notes, tasks, recovery material, database failures and exception messages can contain * protected plaintext or metadata that must not cross into logcat. There are no debug/trace * levels on purpose: call sites pass a fixed event description, never note content or diff --git a/app/src/main/java/com/pasich/encly/core/security/SeedPhraseManager.kt b/app/src/main/java/com/pasich/encly/core/security/SeedPhraseManager.kt index 466742e..ae4e4cb 100644 --- a/app/src/main/java/com/pasich/encly/core/security/SeedPhraseManager.kt +++ b/app/src/main/java/com/pasich/encly/core/security/SeedPhraseManager.kt @@ -112,8 +112,6 @@ class SeedPhraseManager @Inject constructor(private val store: VaultStore) { return !recoveryEnabled || hasRecoverySeed() } - fun isUserManuallyCreatedKeyByDecryption(): Boolean = hasRecoverySeed() - @Synchronized fun copyBootstrapKey(): ByteArray? = bootstrapDek?.copyOf() diff --git a/app/src/main/java/com/pasich/encly/presentation/designsystem/Editor.kt b/app/src/main/java/com/pasich/encly/presentation/designsystem/Editor.kt index cbe3497..82aacb5 100644 --- a/app/src/main/java/com/pasich/encly/presentation/designsystem/Editor.kt +++ b/app/src/main/java/com/pasich/encly/presentation/designsystem/Editor.kt @@ -1,7 +1,6 @@ package com.pasich.encly.presentation.designsystem import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -33,9 +32,6 @@ import com.pasich.encly.ui.theme.EnclyTheme private val ToolSize = 44.dp private val ToolShape = RoundedCornerShape(14.dp) -/** Outline of the block being edited: 2 dp `primary`, radius 12, bleeding 8 dp into the gutter. */ -private val BlockOutlineShape = RoundedCornerShape(12.dp) - /** * The editor's formatting toolbar, pinned above the keyboard: `surfaceContainerHigh`, a 1 dp * `outlineVariant` hairline on top, padding 8/10 with 16 below (plus the navigation bar). @@ -108,13 +104,10 @@ private val BlockBleed = 8.dp private val BlockInset = 4.dp /** - * The frame of one editor block: inner padding 4/8 and, while [active], a 2 dp outline in [color] - * with radius 12. The editor column sits [BlockBleed] inside the gutter, so text lines up with - * the title and the outline bleeds into the gutter (design spec §4.4). + * The inner padding of one editor block, 4/8. The editor column sits [BlockBleed] inside the + * gutter, so text lines up with the title. No outline while editing: the caret is the focus mark. */ -fun Modifier.editorBlockFrame(active: Boolean, color: Color): Modifier = - (if (active) border(2.dp, color, BlockOutlineShape) else this) - .padding(horizontal = BlockBleed, vertical = BlockInset) +fun Modifier.editorBlockFrame(): Modifier = padding(horizontal = BlockBleed, vertical = BlockInset) /** A thin vertical rule between groups of toolbar buttons. */ @Composable diff --git a/app/src/main/java/com/pasich/encly/presentation/editor/EditorBlocksHost.kt b/app/src/main/java/com/pasich/encly/presentation/editor/EditorBlocksHost.kt index 7951f06..10266ae 100644 --- a/app/src/main/java/com/pasich/encly/presentation/editor/EditorBlocksHost.kt +++ b/app/src/main/java/com/pasich/encly/presentation/editor/EditorBlocksHost.kt @@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -133,7 +132,7 @@ private fun EditorBlock( val focusRequester = remember { FocusRequester() } // A list registers its own target: it focuses its first or last item. if (block !is Block.ListBlock) RegisterFocusRequester(block.id, focusRegistry, focusRequester) - // Only for the outline of the block being edited; the focus callback below is unchanged. + // Whether the block is being edited: its placeholder shows then. var hasFocus by remember { mutableStateOf(false) } Box( @@ -150,7 +149,7 @@ private fun EditorBlock( !focusState.hasFocus -> callbacks.onFocusLost() } } - .editorBlockFrame(active = hasFocus && !isLocked, color = MaterialTheme.colorScheme.primary), + .editorBlockFrame(), ) { BlockContent( block = block, diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/LockFormState.kt b/app/src/main/java/com/pasich/encly/presentation/screen/LockFormState.kt index 786b550..5f7163a 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/LockFormState.kt +++ b/app/src/main/java/com/pasich/encly/presentation/screen/LockFormState.kt @@ -8,9 +8,8 @@ import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import com.pasich.encly.R -import com.pasich.encly.core.security.PIN_LENGTH -import com.pasich.encly.core.security.SensitiveDataCleaner import com.pasich.encly.presentation.designsystem.RecoveryPhraseState +import com.pasich.encly.presentation.screen.pincode.PinBuffer import com.pasich.encly.presentation.screen.pincode.lockoutSecondsLeft import com.pasich.encly.presentation.viewmodel.PinUnlockResult import com.pasich.encly.presentation.viewmodel.SeedUnlockResult @@ -26,15 +25,11 @@ internal class LockFormState { /** The recovery-phrase form is shown instead of the PIN pad. */ var useRecovery by mutableStateOf(false) - /** - * The digits typed so far live in a CharArray that is wiped when taken or cleared, never - * in a String: only their count is Compose state. - */ - private val pinDigits = CharArray(PIN_LENGTH) + /** The digits typed so far, wiped when taken or cleared (see [PinBuffer]). */ + private val pinDigits = PinBuffer() /** How many PIN digits are typed. */ - var pinLength by mutableIntStateOf(0) - private set + val pinLength: Int get() = pinDigits.length @get:StringRes var pinError by mutableStateOf(null) @@ -57,27 +52,18 @@ internal class LockFormState { /** A digit on the keypad; the first digit of a new attempt clears the last error. */ fun typeDigit(digit: Int) { - if (pinLength >= PIN_LENGTH || lockedOut || digit !in 0..MAX_DIGIT) return + if (pinDigits.isFull || lockedOut || digit !in 0..MAX_DIGIT) return if (pinLength == 0) pinError = null - pinDigits[pinLength] = '0' + digit - pinLength++ + pinDigits.add(digit) } - fun deleteDigit() { - if (pinLength > 0) { - pinLength-- - pinDigits[pinLength] = '\u0000' - } - } + fun deleteDigit() = pinDigits.deleteLast() /** The full PIN, taken out of the form to be checked. The caller wipes the copy. */ - fun takePin(): CharArray = pinDigits.copyOf(pinLength).also { clearPin() } + fun takePin(): CharArray = pinDigits.take() /** Forgets the typed digits. */ - fun clearPin() { - SensitiveDataCleaner.clear(pinDigits) - pinLength = 0 - } + fun clearPin() = pinDigits.clear() fun onPinResult(result: PinUnlockResult, lockoutRemainingMillis: Long) { when (result) { diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/PinCodeConfigScreen.kt b/app/src/main/java/com/pasich/encly/presentation/screen/PinCodeConfigScreen.kt index 379eb96..27cadfd 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/PinCodeConfigScreen.kt +++ b/app/src/main/java/com/pasich/encly/presentation/screen/PinCodeConfigScreen.kt @@ -22,6 +22,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf @@ -39,11 +40,12 @@ import androidx.compose.ui.semantics.semantics import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavHostController import com.pasich.encly.R -import com.pasich.encly.core.security.PIN_LENGTH +import com.pasich.encly.core.security.SensitiveDataCleaner import com.pasich.encly.presentation.designsystem.EnclyIconTile import com.pasich.encly.presentation.designsystem.EnclyIcons import com.pasich.encly.presentation.designsystem.EnclyTopBar import com.pasich.encly.presentation.navigation.NavRoutes +import com.pasich.encly.presentation.screen.pincode.PinBuffer import com.pasich.encly.presentation.screen.pincode.PinEntry import com.pasich.encly.presentation.screen.pincode.PinEntryActions import com.pasich.encly.presentation.screen.pincode.PinEntryScaffold @@ -64,12 +66,17 @@ enum class PinAnimationState { /** * The three-step PIN change: current PIN (skipped after a recovery-phrase unlock), new PIN, - * confirmation. Holds the screen state so the composable only renders it. + * confirmation. Holds the screen state so the composable only renders it. The PINs are + * CharArrays that are wiped once used, never Strings (see [PinBuffer]). */ private class PinChangeState(val isReset: Boolean) { var step by mutableIntStateOf(if (isReset) 1 else 0) - var firstPin by mutableStateOf("") - var currentInput by mutableStateOf("") + + /** The new PIN typed at step 1, until the confirmation is compared with it. */ + private var firstPin: CharArray? = null + + /** The digits of the current step. */ + val input = PinBuffer() var errorText by mutableStateOf(null) var animationState by mutableStateOf(PinAnimationState.Entering) var lockoutSeconds by mutableLongStateOf(0L) @@ -80,32 +87,55 @@ private class PinChangeState(val isReset: Boolean) { /** Digits are refused while the current PIN is locked out. */ val keysEnabled: Boolean get() = !(step == 0 && lockoutSeconds > 0L) + /** A digit on the keypad; refused during a lockout. */ + fun typeDigit(digit: Int) { + if (keysEnabled) input.add(digit) + } + + /** The PIN of the current step is complete: check it, keep it, or compare it. */ fun onPinComplete(viewModel: SecuritySettingsViewModel) { - val pin = currentInput - currentInput = "" + // The ViewModel wipes what it is given; everything else is wiped here. + val pin = input.take() when (step) { 0 -> viewModel.verifyCurrentPin(pin) { ok -> onCurrentPinChecked(ok, viewModel) } 1 -> { + firstPin?.let(SensitiveDataCleaner::clear) firstPin = pin errorText = null step = 2 } - else -> if (pin == firstPin) { - viewModel.activationPinAuth(pin) { ok -> - if (ok) { - animationState = PinAnimationState.SuccessAnimation - } else { - restartNewPin(R.string.pin_update_failed) - } + else -> confirmNewPin(pin, viewModel) + } + } + + private fun confirmNewPin(pin: CharArray, viewModel: SecuritySettingsViewModel) { + val first = firstPin + firstPin = null + val matches = first != null && pin.contentEquals(first) + first?.let(SensitiveDataCleaner::clear) + if (matches) { + viewModel.activationPinAuth(pin) { ok -> + if (ok) { + animationState = PinAnimationState.SuccessAnimation + } else { + restartNewPin(R.string.pin_update_failed) } - } else { - restartNewPin(R.string.pin_mismatch_retry) } + } else { + SensitiveDataCleaner.clear(pin) + restartNewPin(R.string.pin_mismatch_retry) } } + /** Typed or kept digits do not outlive the screen. */ + fun clear() { + input.clear() + firstPin?.let(SensitiveDataCleaner::clear) + firstPin = null + } + private fun onCurrentPinChecked(ok: Boolean, viewModel: SecuritySettingsViewModel) { val lockout = lockoutSecondsLeft(viewModel.pinLockoutRemainingMillis()) errorText = null @@ -128,7 +158,8 @@ private class PinChangeState(val isReset: Boolean) { private fun restartNewPin(@StringRes error: Int) { errorText = error shakeKey++ - firstPin = "" + firstPin?.let(SensitiveDataCleaner::clear) + firstPin = null step = 1 } } @@ -149,11 +180,12 @@ fun PinCodeConfigScreen( pinState.lockoutSeconds = it } - LaunchedEffect(pinState.currentInput) { - if (pinState.currentInput.length == PIN_LENGTH && pinState.animationState == PinAnimationState.Entering) { + LaunchedEffect(pinState.input.length) { + if (pinState.input.isFull && pinState.animationState == PinAnimationState.Entering) { pinState.onPinComplete(securityViewModel) } } + DisposableEffect(pinState) { onDispose { pinState.clear() } } LaunchedEffect(pinState.animationState) { if (pinState.animationState == PinAnimationState.SuccessAnimation) { @@ -183,18 +215,14 @@ fun PinCodeConfigScreen( ) { currentStep -> MainPinContent( text = pinStepText(currentStep, pinState), - currentInput = pinState.currentInput, + entered = pinState.input.length, entry = PinEntryState( enabled = pinState.keysEnabled, shakeKey = pinState.shakeKey, compact = !pinState.isReset, ), - onInput = { - if (pinState.currentInput.length < PIN_LENGTH && pinState.keysEnabled) { - pinState.currentInput += it - } - }, - onDelete = { pinState.currentInput = pinState.currentInput.dropLast(1) }, + onInput = pinState::typeDigit, + onDelete = pinState.input::deleteLast, ) } } else { @@ -249,9 +277,9 @@ private class PinEntryState(val enabled: Boolean, val shakeKey: Int, val compact @Composable private fun MainPinContent( text: PinStepText, - currentInput: String, + entered: Int, entry: PinEntryState, - onInput: (String) -> Unit, + onInput: (Int) -> Unit, onDelete: () -> Unit, ) { val message = text.message @@ -263,12 +291,12 @@ private fun MainPinContent( compact = entry.compact, ) { PinEntry( - entered = currentInput.length, - error = message != null && currentInput.isEmpty(), + entered = entered, + error = message != null && entered == 0, shakeKey = entry.shakeKey, enabled = entry.enabled, actions = PinEntryActions( - onDigit = { onInput(it.toString()) }, + onDigit = onInput, onBackspace = onDelete, ), ) diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/backup/BackupDialogs.kt b/app/src/main/java/com/pasich/encly/presentation/screen/backup/BackupDialogs.kt index 7e8322a..9c15233 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/backup/BackupDialogs.kt +++ b/app/src/main/java/com/pasich/encly/presentation/screen/backup/BackupDialogs.kt @@ -18,6 +18,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateMapOf @@ -32,7 +33,6 @@ import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.fragment.app.FragmentActivity import com.pasich.encly.R -import com.pasich.encly.core.security.PIN_LENGTH import com.pasich.encly.presentation.designsystem.CalloutTone import com.pasich.encly.presentation.designsystem.DialogAction import com.pasich.encly.presentation.designsystem.EnclyCallout @@ -52,6 +52,7 @@ import com.pasich.encly.presentation.screen.onboarding.FooterSpec import com.pasich.encly.presentation.screen.onboarding.OnboardingFooter import com.pasich.encly.presentation.screen.onboarding.PhraseCheckFields import com.pasich.encly.presentation.screen.onboarding.allChecksCorrect +import com.pasich.encly.presentation.screen.pincode.PinBuffer import com.pasich.encly.presentation.screen.pincode.PinEntry import com.pasich.encly.presentation.screen.pincode.PinEntryActions import com.pasich.encly.presentation.screen.pincode.PinEntryScaffold @@ -157,7 +158,9 @@ private fun MessageDialog( @Composable private fun ReauthDialog(actions: BackupDialogActions, step: BackupStep.Reauth) { val activity = LocalActivity.current as? FragmentActivity - var input by remember { mutableStateOf("") } + // Wiped once submitted or when the dialog closes, never a String (see PinBuffer). + val input = remember { PinBuffer() } + DisposableEffect(input) { onDispose { input.clear() } } var lockoutSeconds by remember { mutableLongStateOf(0L) } // Keyed on the step too: a wrong PIN (a new step) may just have started a lockout. PinLockoutTicker(step to (lockoutSeconds > 0L), actions.reauth::pinLockoutRemainingMillis) { @@ -181,18 +184,16 @@ private fun ReauthDialog(actions: BackupDialogActions, step: BackupStep.Reauth) ) { PinEntry( entered = input.length, - error = error != null && input.isEmpty(), + error = error != null && input.length == 0, shakeKey = step.failures, enabled = !lockedOut, actions = PinEntryActions( onDigit = { digit -> - if (input.length < PIN_LENGTH && !lockedOut) input += digit - if (input.length == PIN_LENGTH) { - actions.reauth.submitPin(input) - input = "" - } + if (!lockedOut) input.add(digit) + // The flow wipes the PIN it is given. + if (input.isFull) actions.reauth.submitPin(input.take()) }, - onBackspace = { if (input.isNotEmpty()) input = input.dropLast(1) }, + onBackspace = input::deleteLast, onBiometric = if (step.biometric && activity != null) { { actions.reauth.withBiometric(activity) } } else { diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/pincode/PinBuffer.kt b/app/src/main/java/com/pasich/encly/presentation/screen/pincode/PinBuffer.kt new file mode 100644 index 0000000..f578bee --- /dev/null +++ b/app/src/main/java/com/pasich/encly/presentation/screen/pincode/PinBuffer.kt @@ -0,0 +1,51 @@ +package com.pasich.encly.presentation.screen.pincode + +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.setValue +import com.pasich.encly.core.security.PIN_LENGTH +import com.pasich.encly.core.security.SensitiveDataCleaner + +/** + * The PIN digits typed on a keypad. They live in a CharArray that is wiped when taken or + * cleared, never in a String (a String cannot be wiped): only their count is Compose state. + */ +@Stable +internal class PinBuffer { + private val digits = CharArray(PIN_LENGTH) + + /** How many digits are typed. */ + var length by mutableIntStateOf(0) + private set + + val isFull: Boolean get() = length == PIN_LENGTH + + /** Adds [digit] (0-9); ignored once the PIN is complete. Returns whether it was added. */ + fun add(digit: Int): Boolean { + if (length >= PIN_LENGTH || digit !in 0..MAX_DIGIT) return false + digits[length] = '0' + digit + length++ + return true + } + + fun deleteLast() { + if (length > 0) { + length-- + digits[length] = '\u0000' + } + } + + /** The typed digits, taken out of the buffer, which is cleared. The caller wipes the copy. */ + fun take(): CharArray = digits.copyOf(length).also { clear() } + + /** Forgets the typed digits. */ + fun clear() { + SensitiveDataCleaner.clear(digits) + length = 0 + } + + private companion object { + const val MAX_DIGIT = 9 + } +} diff --git a/app/src/main/java/com/pasich/encly/presentation/viewmodel/BackupFlows.kt b/app/src/main/java/com/pasich/encly/presentation/viewmodel/BackupFlows.kt index dae6751..1c8afb4 100644 --- a/app/src/main/java/com/pasich/encly/presentation/viewmodel/BackupFlows.kt +++ b/app/src/main/java/com/pasich/encly/presentation/viewmodel/BackupFlows.kt @@ -166,17 +166,25 @@ class ReauthFlow( fun pinLockoutRemainingMillis(): Long = securityManager.pinLockoutRemainingMillis() /** - * During a PIN lockout nothing is verified (every attempt would be refused, even the right - * PIN), so no "wrong PIN" is shown either: the dialog shows the remaining lockout instead. + * Checks [pin], which is wiped. During a PIN lockout nothing is verified (every attempt + * would be refused, even the right PIN), so no "wrong PIN" is shown either: the dialog shows + * the remaining lockout instead. */ - fun submitPin(pin: String) { - val step = state.step as? BackupStep.Reauth ?: return - if (pinLockoutRemainingMillis() > 0L) { - state.go(step.copy(error = null)) + fun submitPin(pin: CharArray) { + val step = state.step as? BackupStep.Reauth + if (step == null || pinLockoutRemainingMillis() > 0L) { + SensitiveDataCleaner.clear(pin) + if (step != null) state.go(step.copy(error = null)) return } state.launchBusy { - val ok = withContext(Dispatchers.Default) { securityManager.verifyPin(pin.toCharArray()) } + val ok = withContext(Dispatchers.Default) { + try { + securityManager.verifyPin(pin) + } finally { + SensitiveDataCleaner.clear(pin) + } + } if (ok) { onAuthenticated(step.action) } else { diff --git a/app/src/main/java/com/pasich/encly/presentation/viewmodel/BiometricPromptGuard.kt b/app/src/main/java/com/pasich/encly/presentation/viewmodel/BiometricPromptGuard.kt new file mode 100644 index 0000000..56cf797 --- /dev/null +++ b/app/src/main/java/com/pasich/encly/presentation/viewmodel/BiometricPromptGuard.kt @@ -0,0 +1,47 @@ +package com.pasich.encly.presentation.viewmodel + +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner + +/** + * At most one biometric prompt at a time, for a ViewModel that outlives its activity. + * + * A second request while a prompt is open is ignored: a double tap must not stack prompts. The + * guard is released by the prompt's answer, or when the activity that showed it is destroyed. + * androidx.biometric drops the answer of a prompt whose activity is gone (BiometricPrompt resets + * its callback on ON_DESTROY), so without that a rotation during a prompt would leave the guard + * closed for good and the ViewModel, which survives the rotation, could never prompt again. + * + * Main thread only, as the prompts themselves. + */ +internal class BiometricPromptGuard { + private var current: Any? = null + + val inFlight: Boolean get() = current != null + + /** + * Runs [prompt] unless one is already open. [prompt] calls the `release` it is given with + * its answer; `release` returns false when that answer is stale (the guard was released by + * the activity's end meanwhile, and a newer prompt may be open), and the caller then drops it. + */ + fun launch(host: LifecycleOwner, prompt: (release: () -> Boolean) -> Unit) { + // A destroyed activity can show no prompt, and would never release the guard. + if (current != null || host.lifecycle.currentState == Lifecycle.State.DESTROYED) return + val token = Any() + current = token + val observer = object : DefaultLifecycleObserver { + override fun onDestroy(owner: LifecycleOwner) { + owner.lifecycle.removeObserver(this) + if (current === token) current = null + } + } + host.lifecycle.addObserver(observer) + prompt { + host.lifecycle.removeObserver(observer) + val mine = current === token + if (mine) current = null + mine + } + } +} diff --git a/app/src/main/java/com/pasich/encly/presentation/viewmodel/LockViewModel.kt b/app/src/main/java/com/pasich/encly/presentation/viewmodel/LockViewModel.kt index a1204c2..d8e3292 100644 --- a/app/src/main/java/com/pasich/encly/presentation/viewmodel/LockViewModel.kt +++ b/app/src/main/java/com/pasich/encly/presentation/viewmodel/LockViewModel.kt @@ -39,8 +39,11 @@ class LockViewModel @Inject constructor( private val _busy = MutableStateFlow(false) val busy: StateFlow = _busy.asStateFlow() - @Volatile - private var biometricInFlight = false + /** The open biometric prompt, if any (see [BiometricPromptGuard]). */ + private val biometricPrompt = BiometricPromptGuard() + + /** A biometric prompt is open; a new request is ignored meanwhile. */ + val biometricInFlight: Boolean get() = biometricPrompt.inFlight fun strategy(): AuthStrategy = securityManager.authStrategy() @@ -89,24 +92,32 @@ class LockViewModel @Inject constructor( } } + /** + * Unlocks with the biometric slot. Ignored while a prompt is open; an answer that arrives + * after the prompt's activity was destroyed (the screen it would report to is gone) is dropped + * and its key wiped. + */ fun authenticateBiometric(activity: FragmentActivity, onResult: (Boolean) -> Unit) { - if (biometricInFlight) return - biometricInFlight = true - securityManager.requestBiometricKey(activity) { dek -> - biometricInFlight = false - if (dek == null) { - onResult(false) - return@requestBiometricKey - } - launchUnlock(onResult) { - val ok = withContext(Dispatchers.IO) { - try { - securityManager.unlockWithRawKey(dek) - } finally { - SensitiveDataCleaner.clear(dek) + biometricPrompt.launch(activity) { release -> + securityManager.requestBiometricKey(activity) { dek -> + if (!release()) { + dek?.let(SensitiveDataCleaner::clear) + return@requestBiometricKey + } + if (dek == null) { + onResult(false) + return@requestBiometricKey + } + launchUnlock(onResult) { + val ok = withContext(Dispatchers.IO) { + try { + securityManager.unlockWithRawKey(dek) + } finally { + SensitiveDataCleaner.clear(dek) + } } + publish(ok, ok, false) } - publish(ok, ok, false) } } } diff --git a/app/src/main/java/com/pasich/encly/presentation/viewmodel/SecuritySettingsViewModel.kt b/app/src/main/java/com/pasich/encly/presentation/viewmodel/SecuritySettingsViewModel.kt index fc2759d..556b770 100644 --- a/app/src/main/java/com/pasich/encly/presentation/viewmodel/SecuritySettingsViewModel.kt +++ b/app/src/main/java/com/pasich/encly/presentation/viewmodel/SecuritySettingsViewModel.kt @@ -11,6 +11,7 @@ import com.pasich.encly.core.security.AutoLockDelay import com.pasich.encly.core.security.BiometricStatus import com.pasich.encly.core.security.KeyboardPrivacy import com.pasich.encly.core.security.SecurityManager +import com.pasich.encly.core.security.SensitiveDataCleaner import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -21,6 +22,7 @@ import kotlinx.coroutines.withContext import javax.inject.Inject @HiltViewModel +@Suppress("TooManyFunctions") // One small entry point per setting on the Security page. class SecuritySettingsViewModel @Inject constructor( private val securityManager: SecurityManager, private val keyboardPrivacy: KeyboardPrivacy, @@ -30,6 +32,12 @@ class SecuritySettingsViewModel @Inject constructor( private val _uiState = MutableStateFlow(SecuritySettingsUiState()) val uiState: StateFlow = _uiState.asStateFlow() + /** The open enrol or disable prompt, if any (see [BiometricPromptGuard]). */ + private val biometricPrompt = BiometricPromptGuard() + + /** An enrol or disable prompt is open; taps on the switch are ignored meanwhile. */ + val biometricInFlight: Boolean get() = biometricPrompt.inFlight + /** "Strict keyboard privacy" (see KeyboardPrivacy). */ val strictKeyboard: StateFlow = keyboardPrivacy.strict @@ -66,9 +74,16 @@ class SecuritySettingsViewModel @Inject constructor( fun pinLockoutRemainingMillis(): Long = securityManager.pinLockoutRemainingMillis() - fun verifyCurrentPin(target: String, onResult: (Boolean) -> Unit) { + /** Checks [pin] against the vault; [pin] is wiped. */ + fun verifyCurrentPin(pin: CharArray, onResult: (Boolean) -> Unit) { viewModelScope.launch { - val ok = withContext(Dispatchers.Default) { securityManager.verifyPin(target.toCharArray()) } + val ok = withContext(Dispatchers.Default) { + try { + securityManager.verifyPin(pin) + } finally { + SensitiveDataCleaner.clear(pin) + } + } if (!ok) { _uiState.value = _uiState.value.copy(error = UiText.of(R.string.pin_current_wrong)) } @@ -76,9 +91,16 @@ class SecuritySettingsViewModel @Inject constructor( } } - fun activationPinAuth(target: String, onResult: (Boolean) -> Unit = {}) { + /** Makes [pin] the vault's new PIN; [pin] is wiped. */ + fun activationPinAuth(pin: CharArray, onResult: (Boolean) -> Unit = {}) { viewModelScope.launch { - val ok = withContext(Dispatchers.Default) { securityManager.configurePin(target.toCharArray()) } + val ok = withContext(Dispatchers.Default) { + try { + securityManager.configurePin(pin) + } finally { + SensitiveDataCleaner.clear(pin) + } + } if (ok) { _uiState.value = _uiState.value.copy(authType = AuthType.PIN) } else { @@ -91,19 +113,28 @@ class SecuritySettingsViewModel @Inject constructor( /** * Enabling biometrics performs the auth-bound CryptoObject enrollment itself. * Disabling an existing biometric slot requires a fresh strong-biometric confirmation. + * Taps while either prompt is open are ignored: a second enrolment would replace the key the + * first one is wrapping. */ fun toggleBiometric(activity: FragmentActivity, enable: Boolean) { - if (enable) { - securityManager.enrollBiometric(activity) { ok -> - _uiState.value = _uiState.value.copy( - biometricEnable = ok && securityManager.isBiometricEnabled(), - error = if (ok) null else UiText.of(R.string.biometric_enroll_failed), - ) - } - return + biometricPrompt.launch(activity) { release -> + if (enable) enrollBiometric(activity, release) else disableBiometric(activity, release) } + } + + private fun enrollBiometric(activity: FragmentActivity, release: () -> Boolean) { + securityManager.enrollBiometric(activity) { ok -> + release() + _uiState.value = _uiState.value.copy( + biometricEnable = ok && securityManager.isBiometricEnabled(), + error = if (ok) null else UiText.of(R.string.biometric_enroll_failed), + ) + } + } + private fun disableBiometric(activity: FragmentActivity, release: () -> Boolean) { securityManager.confirmBiometric(activity) { confirmed -> + release() if (confirmed) { securityManager.disableBiometric() _uiState.value = _uiState.value.copy(biometricEnable = false) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index b288fb1..b0e0d43 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -108,7 +108,7 @@ Inhalt konnte nicht geladen werden - Encly speichert deine aktuellen Änderungen, schließt die verschlüsselte Datenbank und löscht den Sitzungsschlüssel. Wenn du zurückkehrst, musst du erneut entsperren. + Encly speichert deine Änderungen und schließt nach der unter Einstellungen → Sicherheit gewählten Verzögerung (standardmäßig 15 Sekunden) die verschlüsselte Datenbank und löscht den Sitzungsschlüssel. Bildschirm aus oder Telefon sperren tut das sofort. Danach entsperrst du erneut. Was passiert, wenn ich die App verlasse? Nutze den Biometrie-Schalter unter Einstellungen → Sicherheit. Das Anlegen oder Entfernen des biometrischen Slots erfordert eine starke biometrische Authentifizierung. Wie aktiviere oder deaktiviere ich die Biometrie? diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index dcf2d03..828ba95 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -108,7 +108,7 @@ No se pudo cargar el contenido - Encly guarda tus cambios actuales, cierra la base de datos cifrada y borra la clave de sesión. Al volver, tendrás que desbloquear de nuevo. + Encly guarda tus cambios y, tras el retraso elegido en Ajustes → Seguridad (15 segundos por defecto), cierra la base de datos cifrada y borra la clave de sesión. Apagar la pantalla o bloquear el teléfono lo hace al instante. Después, desbloqueas de nuevo. ¿Qué pasa cuando salgo de la aplicación? Usa el interruptor de biometría en Ajustes → Seguridad. Crear o eliminar la ranura biométrica requiere autenticación biométrica fuerte. ¿Cómo activo o desactivo la biometría? diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 2cd5c59..ccd82a6 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -108,7 +108,7 @@ Impossible de charger le contenu - Encly enregistre vos modifications en cours, ferme la base de données chiffrée et efface la clé de session. Vous devez déverrouiller à nouveau à votre retour. + Encly enregistre vos modifications puis, après le délai choisi dans Paramètres → Sécurité (15 secondes par défaut), ferme la base chiffrée et efface la clé de session. Éteindre l’écran ou verrouiller le téléphone le fait immédiatement. Vous déverrouillez ensuite à nouveau. Que se passe-t-il quand je quitte l\'application ? Utilisez l\'interrupteur de biométrie dans Paramètres → Sécurité. La création ou la suppression de l\'emplacement biométrique nécessite une authentification biométrique forte. Comment activer ou désactiver la biométrie ? diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 79d04cc..ebc0a9d 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -108,7 +108,7 @@ Impossibile caricare il contenuto - Encly salva le modifiche in corso, chiude il database crittografato e cancella la chiave di sessione. Al ritorno dovrai sbloccare di nuovo. + Encly salva le modifiche e, dopo il ritardo scelto in Impostazioni → Sicurezza (15 secondi per impostazione predefinita), chiude il database crittografato e cancella la chiave di sessione. Spegnere lo schermo o bloccare il telefono lo fa subito. Poi sblocchi di nuovo. Cosa succede quando esco dall\'app? Usa l\'interruttore della biometria in Impostazioni → Sicurezza. Creare o rimuovere lo slot biometrico richiede un\'autenticazione biometrica forte. Come attivo o disattivo la biometria? diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index fd549cf..cbeb94b 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -108,7 +108,7 @@ Kan de inhoud niet laden - Encly slaat je huidige wijzigingen op, sluit de versleutelde database en wist de sessiesleutel. Als je terugkomt, moet je opnieuw ontgrendelen. + Encly slaat je wijzigingen op en sluit na de vertraging die je in Instellingen → Beveiliging kiest (standaard 15 seconden) de versleutelde database en wist de sessiesleutel. Het scherm uitzetten of de telefoon vergrendelen doet dit meteen. Daarna ontgrendel je opnieuw. Wat gebeurt er als ik de app verlaat? Gebruik de biometrieschakelaar in Instellingen → Beveiliging. Voor het maken of verwijderen van het biometrische slot is sterke biometrische verificatie nodig. Hoe zet ik biometrie aan of uit? diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 7b565d6..3e01d4d 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -108,7 +108,7 @@ Nie udało się wczytać treści - Encly zapisuje bieżące zmiany, zamyka zaszyfrowaną bazę danych i usuwa klucz sesji. Po powrocie trzeba ponownie odblokować aplikację. + Encly zapisuje zmiany i po opóźnieniu wybranym w Ustawienia → Bezpieczeństwo (domyślnie 15 sekund) zamyka zaszyfrowaną bazę i czyści klucz sesji. Wyłączenie ekranu lub zablokowanie telefonu robi to od razu. Potem odblokowujesz ponownie. Co się dzieje, gdy wychodzę z aplikacji? Użyj przełącznika biometrii w Ustawienia → Bezpieczeństwo. Utworzenie lub usunięcie slotu biometrycznego wymaga silnego uwierzytelnienia biometrycznego. Jak włączyć lub wyłączyć biometrię? diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index e07889a..cec5a1c 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -108,7 +108,7 @@ Não foi possível carregar o conteúdo - O Encly salva suas alterações atuais, fecha o banco de dados criptografado e apaga a chave da sessão. Ao voltar, você precisa desbloquear novamente. + O Encly salva as suas alterações e, após o atraso escolhido em Configurações → Segurança (15 segundos por padrão), fecha o banco de dados criptografado e apaga a chave de sessão. Desligar a tela ou bloquear o telefone faz isso na hora. Depois, você desbloqueia de novo. O que acontece quando saio do app? Use a chave de biometria em Configurações → Segurança. Criar ou remover o slot biométrico exige autenticação biométrica forte. Como ativo ou desativo a biometria? diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 7e58fe6..2a326cd 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -108,7 +108,7 @@ Не вдалося завантажити вміст - Encly зберігає поточні зміни, закриває зашифровану базу та очищає сесійний ключ. Після повернення потрібне повторне розблокування. + Encly зберігає зміни й після затримки, вибраної в Налаштування → Безпека (за замовчуванням 15 секунд), закриває зашифровану базу та очищає сесійний ключ. Вимкнення екрана чи блокування телефона робить це одразу. Після цього потрібно розблокувати знову. Що відбувається після згортання застосунку? У Налаштування → Безпека використайте перемикач біометрії. Щоб увімкнути або вимкнути розблокування біометрією, потрібно підтвердити його надійною біометрією (наприклад, відбитком пальця). Як увімкнути або вимкнути біометрію? diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b30c10f..6b75428 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -124,7 +124,7 @@ Couldn\'t load the content - Encly saves your current changes, closes the encrypted database and wipes the session key. You need to unlock again when you come back. + Encly saves your changes and, after the delay chosen in Settings → Security (15 seconds by default), closes the encrypted database and wipes the session key. Turning off the screen or locking the phone does this at once. After that you unlock again. What happens when I leave the app? Use the biometrics switch in Settings → Security. Creating or removing the biometric slot requires strong biometric authentication. How do I turn biometrics on or off? diff --git a/app/src/test/java/com/pasich/encly/MainActivitySecureWindowTest.kt b/app/src/test/java/com/pasich/encly/MainActivitySecureWindowTest.kt new file mode 100644 index 0000000..a8209a4 --- /dev/null +++ b/app/src/test/java/com/pasich/encly/MainActivitySecureWindowTest.kt @@ -0,0 +1,100 @@ +package com.pasich.encly + +import android.Manifest +import android.app.Activity +import android.app.Application +import android.os.Bundle +import android.view.WindowManager +import androidx.test.core.app.ApplicationProvider +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotSame +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +/** + * Every window of the app is FLAG_SECURE (no screenshots, recordings, casting or recents + * thumbnails of notes), from onCreate on, before any frame, and again for the new instance + * after a recreation (rotation, language or theme change). + * + * The flags are read the moment onCreate returns (onActivityPostCreated), before the window is + * attached and Compose runs: AppTheme sets the flag again later, and must not hide a missing one + * in onCreate. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class MainActivitySecureWindowTest { + private val application: Application get() = ApplicationProvider.getApplicationContext() + + /** The window flags of each MainActivity instance right after its onCreate. */ + private val flagsAfterCreate = mutableListOf>() + + private val recorder = object : Application.ActivityLifecycleCallbacks { + override fun onActivityPostCreated(activity: Activity, savedInstanceState: Bundle?) { + if (activity is MainActivity) flagsAfterCreate += activity to activity.window.attributes.flags + } + + override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) = Unit + override fun onActivityStarted(activity: Activity) = Unit + override fun onActivityResumed(activity: Activity) = Unit + override fun onActivityPaused(activity: Activity) = Unit + override fun onActivityStopped(activity: Activity) = Unit + override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit + override fun onActivityDestroyed(activity: Activity) = Unit + } + + @Before + fun setUp() { + // Declared in the manifest and granted at install; Robolectric grants nothing by itself. + shadowOf(application).grantPermissions(Manifest.permission.HIDE_OVERLAY_WINDOWS) + application.registerActivityLifecycleCallbacks(recorder) + } + + @After + fun tearDown() { + application.unregisterActivityLifecycleCallbacks(recorder) + } + + @Test + fun theWindowIsSecureRightAfterOnCreate() { + val controller = Robolectric.buildActivity(MainActivity::class.java).create() + try { + assertEquals(1, flagsAfterCreate.size) + assertSecure(flagsAfterCreate.single().second) + assertSecure(controller.get().window.attributes.flags) + } finally { + controller.destroy() + } + } + + @Test + fun theWindowIsSecureAgainAfterARecreation() { + val controller = Robolectric.buildActivity(MainActivity::class.java).setup() + try { + val first = controller.get() + + controller.recreate() + + val second = controller.get() + assertNotSame(first, second) + assertEquals(listOf(first, second), flagsAfterCreate.map { it.first }) + flagsAfterCreate.forEach { (_, flags) -> assertSecure(flags) } + assertSecure(second.window.attributes.flags) + } finally { + controller.pause().stop().destroy() + } + } + + private fun assertSecure(flags: Int) { + assertTrue( + "FLAG_SECURE missing from the window flags ${Integer.toHexString(flags)}", + flags and WindowManager.LayoutParams.FLAG_SECURE != 0, + ) + } +} diff --git a/app/src/test/java/com/pasich/encly/presentation/viewmodel/BackupFlowsTest.kt b/app/src/test/java/com/pasich/encly/presentation/viewmodel/BackupFlowsTest.kt index 692ced3..2960f8a 100644 --- a/app/src/test/java/com/pasich/encly/presentation/viewmodel/BackupFlowsTest.kt +++ b/app/src/test/java/com/pasich/encly/presentation/viewmodel/BackupFlowsTest.kt @@ -132,7 +132,7 @@ class BackupFlowsTest { fun aWrongPinNeverSealsTheVault() { viewModel.start(BackupAction.EXPORT) - viewModel.reauthFlow.submitPin("000000") + viewModel.reauthFlow.submitPin("000000".toCharArray()) waitFor { (it as? BackupStep.Reauth)?.error != null } verify(security, never()).copyBackupRootKey() @@ -438,7 +438,7 @@ class BackupFlowsTest { private fun startAndAuthenticate(action: BackupAction) { viewModel.start(action) - viewModel.reauthFlow.submitPin(PIN) + viewModel.reauthFlow.submitPin(PIN.toCharArray()) } private fun export() { diff --git a/app/src/test/java/com/pasich/encly/presentation/viewmodel/BackupViewModelTest.kt b/app/src/test/java/com/pasich/encly/presentation/viewmodel/BackupViewModelTest.kt index 3055219..d07486a 100644 --- a/app/src/test/java/com/pasich/encly/presentation/viewmodel/BackupViewModelTest.kt +++ b/app/src/test/java/com/pasich/encly/presentation/viewmodel/BackupViewModelTest.kt @@ -16,6 +16,7 @@ import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.setMain import org.junit.After +import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Assert.assertTrue @@ -55,7 +56,7 @@ class BackupViewModelTest { fun theRightPinDuringALockoutIsNotReportedAsWrong() { `when`(security.pinLockoutRemainingMillis()).thenReturn(30_000L) - viewModel.reauthFlow.submitPin("123456") + viewModel.reauthFlow.submitPin("123456".toCharArray()) val step = viewModel.uiState.value.step as BackupStep.Reauth assertNull(step.error) @@ -63,11 +64,26 @@ class BackupViewModelTest { verify(security, never()).verifyPin(anyCharArray()) } + @Test + fun theSubmittedPinIsWipedWhetherItIsCheckedOrNot() { + `when`(security.pinLockoutRemainingMillis()).thenReturn(30_000L) + val refused = "123456".toCharArray() + viewModel.reauthFlow.submitPin(refused) + assertArrayEquals(CharArray(refused.size), refused) + + `when`(security.pinLockoutRemainingMillis()).thenReturn(0L) + `when`(security.verifyPin(anyCharArray())).thenReturn(false) + val checked = "000000".toCharArray() + viewModel.reauthFlow.submitPin(checked) + waitForStep { (it as? BackupStep.Reauth)?.error != null } + assertArrayEquals(CharArray(checked.size), checked) + } + @Test fun aWrongPinOutsideALockoutIsReported() { `when`(security.verifyPin(anyCharArray())).thenReturn(false) - viewModel.reauthFlow.submitPin("000000") + viewModel.reauthFlow.submitPin("000000".toCharArray()) waitForStep { (it as? BackupStep.Reauth)?.error != null } assertEquals(R.string.lock_wrong_pin, (viewModel.uiState.value.step as BackupStep.Reauth).error) @@ -77,7 +93,7 @@ class BackupViewModelTest { fun theRightPinOutsideALockoutProceeds() { `when`(security.verifyPin(anyCharArray())).thenReturn(true) - viewModel.reauthFlow.submitPin("123456") + viewModel.reauthFlow.submitPin("123456".toCharArray()) waitForStep { it == BackupStep.PickImportFile } } @@ -89,7 +105,7 @@ class BackupViewModelTest { `when`(security.generateMnemonicCode()).thenReturn(WORDS.toCharArray()) viewModel.start(BackupAction.CREATE_PHRASE) - viewModel.reauthFlow.submitPin("123456") + viewModel.reauthFlow.submitPin("123456".toCharArray()) waitForStep { it is BackupStep.ShowNewPhrase } assertEquals(WORDS.split(' '), (viewModel.uiState.value.step as BackupStep.ShowNewPhrase).words) @@ -99,9 +115,9 @@ class BackupViewModelTest { fun eachWrongPinCountsAFailureSoTheDotsShakeAgain() { `when`(security.verifyPin(anyCharArray())).thenReturn(false) - viewModel.reauthFlow.submitPin("000000") + viewModel.reauthFlow.submitPin("000000".toCharArray()) waitForStep { (it as? BackupStep.Reauth)?.failures == 1 } - viewModel.reauthFlow.submitPin("000000") + viewModel.reauthFlow.submitPin("000000".toCharArray()) waitForStep { (it as? BackupStep.Reauth)?.failures == 2 } } @@ -112,7 +128,7 @@ class BackupViewModelTest { `when`(security.hasRecoverySeed()).thenReturn(true) viewModel.start(BackupAction.CREATE_PHRASE) - viewModel.reauthFlow.submitPin("123456") + viewModel.reauthFlow.submitPin("123456".toCharArray()) waitForStep { it == BackupStep.Idle } assertEquals(BackupMessage.Text(R.string.backup_phrase_exists), viewModel.uiState.value.message) @@ -124,7 +140,7 @@ class BackupViewModelTest { `when`(security.hasRecoverySeed()).thenReturn(false) `when`(security.generateMnemonicCode()).thenReturn(WORDS.toCharArray()) viewModel.start(BackupAction.CREATE_PHRASE) - viewModel.reauthFlow.submitPin("123456") + viewModel.reauthFlow.submitPin("123456".toCharArray()) waitForStep { it is BackupStep.ShowNewPhrase } viewModel.phraseFlow.writtenDown() @@ -143,7 +159,7 @@ class BackupViewModelTest { viewModel.eraseAllData() // not re-authenticated yet: ignored verify(security, never()).wipeAndReset() - viewModel.reauthFlow.submitPin("123456") + viewModel.reauthFlow.submitPin("123456".toCharArray()) waitForStep { it == BackupStep.Idle && viewModel.uiState.value.erased } verify(security).wipeAndReset() diff --git a/app/src/test/java/com/pasich/encly/presentation/viewmodel/LockViewModelTest.kt b/app/src/test/java/com/pasich/encly/presentation/viewmodel/LockViewModelTest.kt index cef4136..b533d86 100644 --- a/app/src/test/java/com/pasich/encly/presentation/viewmodel/LockViewModelTest.kt +++ b/app/src/test/java/com/pasich/encly/presentation/viewmodel/LockViewModelTest.kt @@ -10,6 +10,7 @@ import com.pasich.encly.core.security.AuthStrategy import com.pasich.encly.core.security.SecurityManager import com.pasich.encly.core.security.SessionLockManager import com.pasich.encly.core.security.VaultUnlockResult +import com.pasich.encly.testutil.MockActivity import com.pasich.encly.testutil.answerCallback import com.pasich.encly.testutil.anyByteArray import com.pasich.encly.testutil.anyCallback @@ -30,6 +31,7 @@ import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.mockito.ArgumentMatchers +import org.mockito.Mockito.doAnswer import org.mockito.Mockito.mock import org.mockito.Mockito.never import org.mockito.Mockito.timeout @@ -46,7 +48,8 @@ class LockViewModelTest { private lateinit var security: SecurityManager private lateinit var sessionLock: SessionLockManager private lateinit var viewModel: LockViewModel - private val activity: FragmentActivity = mock(FragmentActivity::class.java) + private val host = MockActivity() + private val activity: FragmentActivity = host.activity @Before fun setUp() { @@ -195,6 +198,52 @@ class LockViewModelTest { viewModel.authenticateBiometric(activity) {} verify(security, times(1)).requestBiometricKey(eqValue(activity), anyCallback()) + assertTrue(viewModel.biometricInFlight) + } + + @Test + fun aRotationDuringThePromptReleasesItForTheRecreatedScreen() { + viewModel.authenticateBiometric(activity) {} + assertTrue(viewModel.biometricInFlight) + + // androidx.biometric drops the answer of a prompt whose activity is gone: without this the + // ViewModel, which outlives the rotation, would never prompt again. + host.destroy() + + assertFalse(viewModel.biometricInFlight) + val recreated = MockActivity().activity + viewModel.authenticateBiometric(recreated) {} + verify(security).requestBiometricKey(eqValue(recreated), anyCallback()) + } + + @Test + fun aLateAnswerFromTheDestroyedActivityIsDroppedAndItsKeyWiped() { + val answer = arrayOfNulls<(ByteArray?) -> Unit>(1) + doAnswer { invocation -> + @Suppress("UNCHECKED_CAST") + answer[0] = invocation.arguments.last() as (ByteArray?) -> Unit + null + }.`when`(security).requestBiometricKey(eqValue(activity), anyCallback()) + val results = mutableListOf() + viewModel.authenticateBiometric(activity) { results += it } + host.destroy() + + val key = ByteArray(KEY_LENGTH) { 7 } + answer[0]!!(key) + + assertTrue(results.isEmpty()) + assertArrayEquals(ByteArray(KEY_LENGTH), key) + verify(security, never()).unlockWithRawKey(anyByteArray(), ArgumentMatchers.anyBoolean()) + } + + @Test + fun aDestroyedActivityGetsNoPrompt() { + host.destroy() + + viewModel.authenticateBiometric(activity) {} + + verify(security, never()).requestBiometricKey(eqValue(activity), anyCallback()) + assertFalse(viewModel.biometricInFlight) } @Test diff --git a/app/src/test/java/com/pasich/encly/presentation/viewmodel/SecuritySettingsViewModelTest.kt b/app/src/test/java/com/pasich/encly/presentation/viewmodel/SecuritySettingsViewModelTest.kt index af30531..bab8051 100644 --- a/app/src/test/java/com/pasich/encly/presentation/viewmodel/SecuritySettingsViewModelTest.kt +++ b/app/src/test/java/com/pasich/encly/presentation/viewmodel/SecuritySettingsViewModelTest.kt @@ -10,8 +10,10 @@ import com.pasich.encly.core.security.BiometricStatus import com.pasich.encly.core.security.KeyboardPrivacy import com.pasich.encly.core.security.SecurityManager import com.pasich.encly.testutil.InMemorySharedPreferences +import com.pasich.encly.testutil.MockActivity import com.pasich.encly.testutil.answerCallback import com.pasich.encly.testutil.anyCallback +import com.pasich.encly.testutil.anyCharArray import com.pasich.encly.testutil.eqValue import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers @@ -23,14 +25,17 @@ import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import org.junit.After +import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test +import org.mockito.Mockito.doAnswer import org.mockito.Mockito.mock import org.mockito.Mockito.never +import org.mockito.Mockito.times import org.mockito.Mockito.verify import org.mockito.Mockito.`when` @@ -39,7 +44,8 @@ import org.mockito.Mockito.`when` class SecuritySettingsViewModelTest { private lateinit var security: SecurityManager private lateinit var viewModel: SecuritySettingsViewModel - private val activity: FragmentActivity = mock(FragmentActivity::class.java) + private val host = MockActivity() + private val activity: FragmentActivity = host.activity @Before fun setUp() { @@ -118,6 +124,21 @@ class SecuritySettingsViewModelTest { assertEquals(UiText.of(R.string.pin_update_failed), viewModel.uiState.value.error) } + @Test + fun theCheckedAndTheNewPinAreWipedAfterUse() = runTest { + viewModel.uiState.first { it.loaded } + `when`(security.verifyPin(anyCharArray())).thenReturn(true) + `when`(security.configurePin(anyCharArray())).thenReturn(false) + + val current = "111111".toCharArray() + assertTrue(checkCurrent(current)) + assertArrayEquals(CharArray(current.size), current) + + val new = "222222".toCharArray() + assertFalse(activate(new)) + assertArrayEquals(CharArray(new.size), new) + } + @Test fun afterARecoveryUnlockTheNewPinIsSetWithoutTheOldOne() { `when`(security.canResetPinWithoutCurrent()).thenReturn(true) @@ -177,13 +198,76 @@ class SecuritySettingsViewModelTest { assertEquals(UiText.of(R.string.biometric_change_not_confirmed), viewModel.uiState.value.error) } - private suspend fun checkCurrent(pin: String): Boolean { + @Test + fun tapsWhileTheEnrolPromptIsOpenAreIgnored() = runTest { + viewModel.uiState.first { it.loaded } + // The prompt never answers: it stays open. + viewModel.toggleBiometric(activity, enable = true) + viewModel.toggleBiometric(activity, enable = true) + viewModel.toggleBiometric(activity, enable = false) + + assertTrue(viewModel.biometricInFlight) + verify(security, times(1)).enrollBiometric(eqValue(activity), anyCallback()) + verify(security, never()).confirmBiometric(eqValue(activity), anyCallback()) + } + + @Test + fun tapsWhileTheDisablePromptIsOpenAreIgnored() = runTest { + viewModel.uiState.first { it.loaded } + viewModel.toggleBiometric(activity, enable = false) + viewModel.toggleBiometric(activity, enable = false) + viewModel.toggleBiometric(activity, enable = true) + + verify(security, times(1)).confirmBiometric(eqValue(activity), anyCallback()) + verify(security, never()).enrollBiometric(eqValue(activity), anyCallback()) + verify(security, never()).disableBiometric() + } + + @Test + fun onceThePromptAnswersTheSwitchWorksAgain() = runTest { + viewModel.uiState.first { it.loaded } + val answer = arrayOfNulls<(Boolean) -> Unit>(1) + doAnswer { invocation -> + @Suppress("UNCHECKED_CAST") + answer[0] = invocation.arguments.last() as (Boolean) -> Unit + null + }.`when`(security).enrollBiometric(eqValue(activity), anyCallback()) + + viewModel.toggleBiometric(activity, enable = true) + viewModel.toggleBiometric(activity, enable = true) + answer[0]!!(false) + + assertFalse(viewModel.biometricInFlight) + viewModel.toggleBiometric(activity, enable = true) + verify(security, times(2)).enrollBiometric(eqValue(activity), anyCallback()) + } + + @Test + fun aRotationDuringThePromptDoesNotLeaveTheSwitchStuck() = runTest { + viewModel.uiState.first { it.loaded } + viewModel.toggleBiometric(activity, enable = true) + assertTrue(viewModel.biometricInFlight) + + // androidx.biometric drops the answer of a prompt whose activity is gone. + host.destroy() + + assertFalse(viewModel.biometricInFlight) + val recreated = MockActivity().activity + viewModel.toggleBiometric(recreated, enable = true) + verify(security).enrollBiometric(eqValue(recreated), anyCallback()) + } + + private suspend fun checkCurrent(pin: String): Boolean = checkCurrent(pin.toCharArray()) + + private suspend fun checkCurrent(pin: CharArray): Boolean { val result = CompletableDeferred() viewModel.verifyCurrentPin(pin) { result.complete(it) } return result.await() } - private suspend fun activate(pin: String): Boolean { + private suspend fun activate(pin: String): Boolean = activate(pin.toCharArray()) + + private suspend fun activate(pin: CharArray): Boolean { val result = CompletableDeferred() viewModel.activationPinAuth(pin) { result.complete(it) } return result.await() diff --git a/app/src/test/java/com/pasich/encly/release/NoPlaintextExportTest.kt b/app/src/test/java/com/pasich/encly/release/NoPlaintextExportTest.kt new file mode 100644 index 0000000..de77ff6 --- /dev/null +++ b/app/src/test/java/com/pasich/encly/release/NoPlaintextExportTest.kt @@ -0,0 +1,99 @@ +package com.pasich.encly.release + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +/** + * Plaintext never leaves the vault by a side door (issue #15): no share sheet, no FileProvider + * handing out files, no notifications or alarms that could show or schedule note content, and no + * clipboard write except through SensitiveClip, which marks the clip sensitive and clears it. + * The one way out is an encrypted backup written through the system document picker. + * + * Scans every source and resource under app/src/main; comments are skipped, so a comment may + * still name what is banned. + */ +class NoPlaintextExportTest { + + private val mainRoot = listOf(File("src/main"), File("app/src/main")).first { it.isDirectory } + + @Test + fun nothingInTheAppExportsPlaintext() { + val offenders = mainRoot.walkTopDown() + .filter { it.isFile && it.extension in SCANNED } + .flatMap { file -> + val path = file.relativeTo(mainRoot).invariantSeparatorsPath + file.readLines().mapIndexedNotNull { index, line -> + if (isComment(line)) return@mapIndexedNotNull null + BANNED.filter { (_, rule) -> rule.matches(path, line) } + .map { (name, _) -> "$path:${index + 1} ($name)" } + .takeIf { it.isNotEmpty() } + }.flatten() + } + .toList() + assertTrue( + "Plaintext must not leave the app except as an encrypted backup (see SECURITY.md): $offenders", + offenders.isEmpty(), + ) + } + + @Test + fun patternsCatchTheSideDoorsButNotTheirNeighbours() { + val caught = listOf( + "share sheet" to "Intent(Intent.ACTION_SEND).apply {", + "share sheet" to "val i = Intent(ACTION_SEND_MULTIPLE)", + "share sheet" to """""", + "FileProvider" to "FileProvider.getUriForFile(context, authority, file)", + "notification" to "NotificationCompat.Builder(context, CHANNEL)", + "notification" to """""", + "alarm" to "context.getSystemService(AlarmManager::class.java)", + "clipboard write" to "clipboard.setPrimaryClip(ClipData.newPlainText(\"\", text))", + "clipboard write" to "clipboard.setClipEntry(entry)", + "clipboard write" to "val clipboard = LocalClipboardManager.current", + ) + caught.forEach { (name, line) -> + assertTrue("$name: $line", BANNED.getValue(name).matches(OTHER_FILE, line)) + } + + // The support e-mail link (a mailto: ACTION_SENDTO) and SensitiveClip's own writes. + listOf( + OTHER_FILE to "val intent = Intent(Intent.ACTION_SENDTO).apply {", + SENSITIVE_CLIP to "clipboard.setPrimaryClip(clip)", + SENSITIVE_CLIP to "delegate.setClipEntry(clipEntry)", + ).forEach { (path, line) -> + assertFalse("$path: $line", BANNED.values.any { it.matches(path, line) }) + } + assertTrue(isComment(" // no FileProvider: nothing is shared")) + assertTrue(isComment("")) + } + + private class Rule(private val pattern: Regex, private val allowedIn: Set = emptySet()) { + fun matches(path: String, line: String): Boolean = + pattern.containsMatchIn(line) && allowedIn.none { path.endsWith(it) } + } + + private companion object { + val SCANNED = setOf("kt", "java", "xml") + const val SENSITIVE_CLIP = "java/com/pasich/encly/presentation/components/SensitiveClip.kt" + const val OTHER_FILE = "java/com/pasich/encly/presentation/screen/SomeScreen.kt" + + val BANNED = mapOf( + // ACTION_SEND and ACTION_SEND_MULTIPLE, in code or an intent filter; not ACTION_SENDTO. + "share sheet" to Rule(Regex("""ACTION_SEND(?!TO)\w*|android\.intent\.action\.SEND(?!TO)\w*""")), + "FileProvider" to Rule(Regex("""\bFileProvider\b""")), + "notification" to Rule(Regex("""\bNotificationCompat\b|\bPOST_NOTIFICATIONS\b""")), + "alarm" to Rule(Regex("""\bAlarmManager\b""")), + "clipboard write" to Rule( + Regex("""\bsetPrimaryClip\b|\bsetClipEntry\b|\bLocalClipboardManager\b"""), + allowedIn = setOf(SENSITIVE_CLIP), + ), + ) + + fun isComment(line: String): Boolean { + val code = line.trimStart() + return code.startsWith("//") || code.startsWith("*") || code.startsWith("/*") || + code.startsWith(" Pin["PIN wraps random DEK"] Pin --> Open["Open SQLCipher"] Open --> Session["Unlocked session"] - Session --> Background["App backgrounds"] + Session --> Background["Left the app past the auto-lock delay, or screen off"] Background --> Lock["Close DB and clear DEK"] Lock --> Unlock["PIN / biometric / recovery"] Unlock --> Open @@ -55,7 +56,8 @@ visible destination, an opaque shield covers the previous screen, so the first f returning never shows plaintext. An unlock that completes after the app left the foreground is closed again at once. Open task and tag editors save on pause, like the note editor, because the re-lock discards them. The note that was open when the app re-locked is recorded (`RelockReturn`) and -opened again after the unlock, with fresh ViewModels. +opened again after the unlock, with fresh ViewModels. Pages move on Material's shared X axis; +the full unlock reveal plays only on the first unlock after launch, later unlocks cross-fade. ## Encrypted backups @@ -94,7 +96,8 @@ flowchart LR end ``` -Onboarding offers "Restore from backup" next to the two vault types: it decrypts and validates +Onboarding offers "Restore from backup" next to the two setup choices (with a recovery phrase, +or PIN only): it decrypts and validates the file with the typed words, creates the vault with those words as its recovery seed, and `AuthSetupViewModel` writes the backup (replace, into the empty vault) right after the PIN setup opens the database and before onboarding is committed (`SecurityManager.openInitialVault` → diff --git a/fastlane/metadata/android/de-DE/full_description.txt b/fastlane/metadata/android/de-DE/full_description.txt index 3a2ee0e..e63e3d3 100644 --- a/fastlane/metadata/android/de-DE/full_description.txt +++ b/fastlane/metadata/android/de-DE/full_description.txt @@ -7,7 +7,7 @@ PRIVAT VON ANFANG AN • Wirklich offline: Encly fordert die Berechtigung INTERNET gar nicht an und kann deine Notizen daher nirgendwohin senden. • Verschlüsselte Notizen: SQLCipher-Datenbank (AES-256) unter einem zufälligen 256-Bit-Schlüssel. • Notizen mit Passwortschutz: eine 6-stellige PIN bei jedem Öffnen, optional Fingerabdruck oder Gesicht (nur starke Biometrie). -• Sperrt sich selbst: Sobald Encly in den Hintergrund geht, wird die Datenbank geschlossen und der Schlüssel aus dem Speicher gelöscht. +• Sperrt sich selbst: nach dem Verlassen (standardmäßig nach 15 Sekunden oder sofort) oder sobald der Bildschirm ausgeht, wird die Datenbank geschlossen und der Schlüssel aus dem Speicher gelöscht. • Screenshots, Bildschirmaufnahmen und die Vorschau in „Letzte Apps“ sind blockiert. Die Android-Cloudsicherung ist aus. WIEDERHERSTELLUNGSPHRASE UND VERSCHLÜSSELTE BACKUPS diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt index 2f72266..340dfcd 100644 --- a/fastlane/metadata/android/en-US/full_description.txt +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -7,7 +7,7 @@ PRIVATE BY DESIGN • Truly offline: Encly does not request the INTERNET permission, so it cannot send your notes anywhere. • Encrypted notes at rest: a SQLCipher (AES-256) database under a random 256-bit key. • PIN lock every time you open it: a 6-digit PIN, plus optional fingerprint or face unlock (strong biometrics only). -• Locks itself: when Encly goes to the background, the database is closed and the key is wiped from memory. +• Locks itself: after you leave (15 seconds by default, or immediately) or the moment the screen turns off, the database is closed and the key is wiped from memory. • Screenshots, screen recording and recent-apps previews are blocked. Android cloud backup is off. RECOVERY PHRASE AND ENCRYPTED BACKUPS diff --git a/fastlane/metadata/android/es-ES/full_description.txt b/fastlane/metadata/android/es-ES/full_description.txt index 1bee0d7..41ec9e4 100644 --- a/fastlane/metadata/android/es-ES/full_description.txt +++ b/fastlane/metadata/android/es-ES/full_description.txt @@ -7,7 +7,7 @@ PRIVADA DESDE EL DISEÑO • Sin conexión de verdad: Encly no pide el permiso INTERNET, así que no puede enviar tus notas a ninguna parte. • Notas cifradas: base de datos SQLCipher (AES-256) con una clave aleatoria de 256 bits. • Notas con contraseña: un PIN de 6 dígitos cada vez que la abres y, si quieres, huella o cara (solo biometría fuerte). -• Se bloquea sola: cuando Encly pasa a segundo plano, la base de datos se cierra y la clave se borra de la memoria. +• Se bloquea sola: al salir (a los 15 segundos por defecto, o al instante) o en cuanto se apaga la pantalla, la base de datos se cierra y la clave se borra de la memoria. • Se bloquean las capturas, la grabación de pantalla y la vista previa en apps recientes. La copia en la nube de Android está desactivada. FRASE DE RECUPERACIÓN Y COPIAS CIFRADAS diff --git a/fastlane/metadata/android/fr-FR/full_description.txt b/fastlane/metadata/android/fr-FR/full_description.txt index 46cc5d5..ca3efd5 100644 --- a/fastlane/metadata/android/fr-FR/full_description.txt +++ b/fastlane/metadata/android/fr-FR/full_description.txt @@ -7,7 +7,7 @@ PRIVÉ PAR CONCEPTION • Vraiment hors ligne : Encly ne demande pas la permission INTERNET et ne peut donc envoyer vos notes nulle part. • Notes chiffrées : base SQLCipher (AES-256) sous une clé aléatoire de 256 bits. • Notes protégées par code : un code PIN à 6 chiffres à chaque ouverture, plus empreinte ou visage en option (biométrie forte uniquement). -• Verrouillage automatique : dès qu'Encly passe en arrière-plan, la base est fermée et la clé effacée de la mémoire. +• Verrouillage automatique : après la sortie (15 secondes par défaut, ou immédiatement) ou dès que l'écran s'éteint, la base est fermée et la clé effacée de la mémoire. • Captures d'écran, enregistrement d'écran et aperçu dans les applis récentes sont bloqués. La sauvegarde cloud d'Android est désactivée. PHRASE DE RÉCUPÉRATION ET SAUVEGARDES CHIFFRÉES diff --git a/fastlane/metadata/android/it-IT/full_description.txt b/fastlane/metadata/android/it-IT/full_description.txt index be13d9a..5e3c02d 100644 --- a/fastlane/metadata/android/it-IT/full_description.txt +++ b/fastlane/metadata/android/it-IT/full_description.txt @@ -7,7 +7,7 @@ PRIVATA PER PROGETTO • Davvero offline: Encly non chiede il permesso INTERNET, quindi non può inviare le tue note da nessuna parte. • Note criptate: database SQLCipher (AES-256) con una chiave casuale a 256 bit. • Note con password: un PIN a 6 cifre a ogni apertura e, se vuoi, impronta o volto (solo biometria forte). -• Si blocca da sola: quando Encly va in background, il database viene chiuso e la chiave cancellata dalla memoria. +• Si blocca da sola: dopo l'uscita (15 secondi per impostazione predefinita, o subito) o appena lo schermo si spegne, il database viene chiuso e la chiave cancellata dalla memoria. • Screenshot, registrazione dello schermo e anteprima nelle app recenti sono bloccati. Il backup cloud di Android è disattivato. FRASE DI RECUPERO E BACKUP CRIPTATI diff --git a/fastlane/metadata/android/nl-NL/full_description.txt b/fastlane/metadata/android/nl-NL/full_description.txt index a083694..00bf22a 100644 --- a/fastlane/metadata/android/nl-NL/full_description.txt +++ b/fastlane/metadata/android/nl-NL/full_description.txt @@ -7,7 +7,7 @@ PRIVÉ VANAF HET BEGIN • Echt offline: Encly vraagt de toestemming INTERNET niet aan en kan je notities dus nergens heen sturen. • Versleutelde notities: een SQLCipher-database (AES-256) met een willekeurige 256-bits sleutel. • Notities met wachtwoord: een 6-cijferige pincode bij elke keer openen, optioneel vingerafdruk of gezicht (alleen sterke biometrie). -• Vergrendelt zichzelf: zodra Encly naar de achtergrond gaat, wordt de database gesloten en de sleutel uit het geheugen gewist. +• Vergrendelt zichzelf: na het verlaten (standaard na 15 seconden, of meteen) of zodra het scherm uitgaat, wordt de database gesloten en de sleutel uit het geheugen gewist. • Screenshots, schermopnames en het voorbeeld bij recente apps zijn geblokkeerd. De cloudback-up van Android staat uit. HERSTELZIN EN VERSLEUTELDE BACK-UPS diff --git a/fastlane/metadata/android/pl-PL/full_description.txt b/fastlane/metadata/android/pl-PL/full_description.txt index 4404a32..f13a6ed 100644 --- a/fastlane/metadata/android/pl-PL/full_description.txt +++ b/fastlane/metadata/android/pl-PL/full_description.txt @@ -7,7 +7,7 @@ PRYWATNOŚĆ OD PODSTAW • Naprawdę offline: Encly nie prosi o uprawnienie INTERNET, więc nie może nigdzie wysłać twoich notatek. • Szyfrowane notatki: baza SQLCipher (AES-256) z losowym kluczem 256-bitowym. • Notatki z hasłem: 6-cyfrowy PIN przy każdym otwarciu, opcjonalnie odcisk palca lub twarz (tylko silna biometria). -• Blokuje się sama: gdy Encly przechodzi w tło, baza jest zamykana, a klucz usuwany z pamięci. +• Blokuje się sama: po wyjściu (domyślnie po 15 sekundach lub od razu) albo gdy ekran się wyłącza, baza jest zamykana, a klucz usuwany z pamięci. • Zrzuty ekranu, nagrywanie ekranu i podgląd w ostatnich aplikacjach są zablokowane. Kopia w chmurze Androida jest wyłączona. FRAZA ODZYSKIWANIA I SZYFROWANE KOPIE diff --git a/fastlane/metadata/android/pt-PT/full_description.txt b/fastlane/metadata/android/pt-PT/full_description.txt index 4930b8b..5d2e593 100644 --- a/fastlane/metadata/android/pt-PT/full_description.txt +++ b/fastlane/metadata/android/pt-PT/full_description.txt @@ -7,7 +7,7 @@ PRIVADO DESDE A ORIGEM • Mesmo offline: o Encly não pede a permissão INTERNET, por isso não consegue enviar as suas notas para lado nenhum. • Notas encriptadas: base de dados SQLCipher (AES-256) com uma chave aleatória de 256 bits. • Notas com palavra-passe: um PIN de 6 dígitos sempre que abre a app e, opcionalmente, impressão digital ou rosto (só biometria forte). -• Bloqueia-se sozinho: quando o Encly passa para segundo plano, a base de dados é fechada e a chave apagada da memória. +• Bloqueia-se sozinho: depois de sair (15 segundos por omissão, ou de imediato) ou assim que o ecrã se desliga, a base de dados é fechada e a chave apagada da memória. • Capturas de ecrã, gravação de ecrã e pré-visualização nas apps recentes estão bloqueadas. A cópia na nuvem do Android está desligada. FRASE DE RECUPERAÇÃO E CÓPIAS ENCRIPTADAS diff --git a/fastlane/metadata/android/uk/full_description.txt b/fastlane/metadata/android/uk/full_description.txt index e64ec3e..fdda02d 100644 --- a/fastlane/metadata/android/uk/full_description.txt +++ b/fastlane/metadata/android/uk/full_description.txt @@ -7,7 +7,7 @@ Encly — це захищені нотатки й завдання, зашифр • Справді офлайн: Encly не запитує дозвіл INTERNET, тож не може нікуди надіслати ваші нотатки. • Зашифровані нотатки: база SQLCipher (AES-256) під випадковим 256-бітним ключем. • Нотатки з паролем: 6-значний PIN-код при кожному відкритті, за бажанням відбиток пальця чи обличчя (лише надійна біометрія). -• Блокується сам: щойно Encly йде у фон, база закривається, а ключ стирається з пам'яті. +• Блокується сам: після виходу (за замовчуванням через 15 секунд або одразу) чи щойно вимикається екран база закривається, а ключ стирається з пам'яті. • Знімки й запис екрана та прев'ю в недавніх застосунках заблоковані. Хмарне резервне копіювання Android вимкнене. ФРАЗА ВІДНОВЛЕННЯ Й ЗАШИФРОВАНІ КОПІЇ