From 0a0c826ac291c074fd46730fd7fc85a12ca4a19a Mon Sep 17 00:00:00 2001 From: zHd4 <38856321+zHd4@users.noreply.github.com> Date: Tue, 21 Jul 2026 16:56:08 +0200 Subject: [PATCH 01/23] Refactor authentication flow to use AppSecurityService --- .../app/notesr/activity/MainActivity.java | 17 +- .../activity/note/list/NotesListActivity.java | 9 +- .../activity/security/AuthActivity.java | 13 +- .../security/AuthActivityExtension.java | 60 ++--- .../security/GenerateNewKeyAction.java | 6 +- .../security/KeySetupCompletionHandler.java | 6 +- .../notesr/activity/security/LockAction.java | 18 +- .../activity/security/SetupKeyActivity.java | 14 +- .../core/security/crypto/CryptoManager.java | 67 ++--- .../crypto/CryptoManagerProvider.java | 3 - .../security/crypto/CryptoManagerTest.java | 31 +-- .../service/exporter/ExportServiceTest.java | 7 +- .../security/AppSecurityException.java | 16 ++ .../service/security/AppSecurityService.java | 248 ++++++++++++++++++ .../AuthenticationFailedException.java | 17 ++ .../crypto/setup/SecretsSetupService.java | 30 --- 16 files changed, 380 insertions(+), 182 deletions(-) create mode 100644 service/src/main/java/app/notesr/service/security/AppSecurityException.java create mode 100644 service/src/main/java/app/notesr/service/security/AppSecurityService.java create mode 100644 service/src/main/java/app/notesr/service/security/AuthenticationFailedException.java delete mode 100644 service/src/main/java/app/notesr/service/security/crypto/setup/SecretsSetupService.java diff --git a/app/src/main/java/app/notesr/activity/MainActivity.java b/app/src/main/java/app/notesr/activity/MainActivity.java index 0c191b39..faa677a3 100644 --- a/app/src/main/java/app/notesr/activity/MainActivity.java +++ b/app/src/main/java/app/notesr/activity/MainActivity.java @@ -13,8 +13,6 @@ import java.util.function.Supplier; import app.notesr.R; -import app.notesr.core.security.crypto.CryptoManager; -import app.notesr.core.security.crypto.CryptoManagerProvider; import app.notesr.service.AndroidServiceBootstrapper; import app.notesr.service.AndroidServiceRegistry; import app.notesr.service.lifecycle.AppCloseAndroidService; @@ -22,6 +20,7 @@ import app.notesr.activity.note.list.NotesListActivity; import app.notesr.activity.security.AuthActivity; import app.notesr.activity.security.KeyRecoveryActivity; +import app.notesr.service.security.AppSecurityService; public final class MainActivity extends ActivityBase { @@ -37,10 +36,10 @@ protected void onCreate(Bundle savedInstanceState) { new AndroidServiceBootstrapper(serviceRegistry) .startServicesPreAuth(getApplicationContext()); - var cryptoManager = CryptoManagerProvider.getInstance(getApplicationContext()); + var appSecurityService = new AppSecurityService(getApplicationContext()); var intentSuppliers = getIntentSuppliers( getApplicationContext(), - cryptoManager, + appSecurityService, fsaResolver ); @@ -58,22 +57,22 @@ protected boolean requiresSession() { private List> getIntentSuppliers( Context context, - CryptoManager cryptoManager, + AppSecurityService appSecurityService, FsaResolver fsaResolver ) { return List.of( - () -> cryptoManager.isBlocked(getApplicationContext()) + () -> appSecurityService.isAppBlocked() ? new Intent(context, KeyRecoveryActivity.class) : null, - () -> !cryptoManager.isKeyExists(getApplicationContext()) + () -> !appSecurityService.isKeyExists() ? new Intent(context, StartActivity.class) : null, - () -> !cryptoManager.isConfigured() + () -> !appSecurityService.isAuthConfigured() ? new Intent(context, AuthActivity.class) .putExtra(AuthActivity.EXTRA_MODE, - AuthActivity.Mode.AUTHORIZATION.toString()) + AuthActivity.Mode.AUTHENTICATION.toString()) : null, () -> { diff --git a/app/src/main/java/app/notesr/activity/note/list/NotesListActivity.java b/app/src/main/java/app/notesr/activity/note/list/NotesListActivity.java index d344ba7b..09fc48ac 100644 --- a/app/src/main/java/app/notesr/activity/note/list/NotesListActivity.java +++ b/app/src/main/java/app/notesr/activity/note/list/NotesListActivity.java @@ -40,12 +40,11 @@ import app.notesr.activity.security.AuthActivity; import app.notesr.activity.security.GenerateNewKeyAction; import app.notesr.activity.security.LockAction; -import app.notesr.core.security.crypto.CryptoManager; -import app.notesr.core.security.crypto.CryptoManagerProvider; import app.notesr.data.AppDatabase; import app.notesr.data.DatabaseProvider; import app.notesr.data.model.Note; import app.notesr.service.note.NoteService; +import app.notesr.service.security.AppSecurityService; import java.util.HashMap; import java.util.List; @@ -74,10 +73,10 @@ protected void onCreate(Bundle savedInstanceState) { setContentView(R.layout.activity_note_list); applyInsets(findViewById(R.id.main)); - CryptoManager cryptoManager = CryptoManagerProvider.getInstance(getApplicationContext()); + var appSecurityService = new AppSecurityService(getApplicationContext()); - lockAction = new LockAction(this, cryptoManager); - generateNewKeyAction = new GenerateNewKeyAction(this, cryptoManager); + lockAction = new LockAction(this, appSecurityService); + generateNewKeyAction = new GenerateNewKeyAction(this, appSecurityService); noteEditorLauncher = registerForActivityResult( new ActivityResultContracts.StartActivityForResult(), getOpenNoteResultCallback()); diff --git a/app/src/main/java/app/notesr/activity/security/AuthActivity.java b/app/src/main/java/app/notesr/activity/security/AuthActivity.java index 03559932..a1217cc1 100644 --- a/app/src/main/java/app/notesr/activity/security/AuthActivity.java +++ b/app/src/main/java/app/notesr/activity/security/AuthActivity.java @@ -18,9 +18,8 @@ import app.notesr.R; import app.notesr.activity.ActivityBase; -import app.notesr.core.security.crypto.CryptoManager; -import app.notesr.core.security.crypto.CryptoManagerProvider; import app.notesr.core.util.SecureStringBuilder; +import app.notesr.service.security.AppSecurityService; import lombok.AllArgsConstructor; import lombok.Getter; @@ -32,7 +31,7 @@ public final class AuthActivity extends ActivityBase { @AllArgsConstructor @Getter public enum Mode { - AUTHORIZATION("authorization"), + AUTHENTICATION("authentication"), CREATE_PASSWORD("create_password"), CHANGE_PASSWORD("change_password"), KEY_RECOVERY("key_recovery"); @@ -57,8 +56,8 @@ protected void onCreate(Bundle savedInstanceState) { applyInsets(findViewById(R.id.main)); String mode = getIntent().getStringExtra(EXTRA_MODE); - CryptoManager cryptoManager = CryptoManagerProvider.getInstance(getApplicationContext()); - extension = new AuthActivityExtension(this, cryptoManager, passwordBuilder); + var appSecurityService = new AppSecurityService(getApplicationContext()); + extension = new AuthActivityExtension(this, appSecurityService, passwordBuilder); try { currentMode = Mode.valueOf(mode); @@ -86,7 +85,7 @@ private void configure() { Button changeLayoutButton = findViewById(R.id.changeKeyboardLayoutButton); switch (currentMode) { - case AUTHORIZATION -> { + case AUTHENTICATION -> { topLabel.setText(R.string.enter_access_code); disableBackButton(this); } @@ -123,7 +122,7 @@ private void configure() { authButton.setOnClickListener(view -> { switch (currentMode) { - case AUTHORIZATION -> extension.authorize(); + case AUTHENTICATION -> extension.authenticate(); case CREATE_PASSWORD -> extension.createPassword(); case KEY_RECOVERY -> extension.recoverKey(); case CHANGE_PASSWORD -> extension.changePassword(); diff --git a/app/src/main/java/app/notesr/activity/security/AuthActivityExtension.java b/app/src/main/java/app/notesr/activity/security/AuthActivityExtension.java index 913cc4e1..9a81a026 100644 --- a/app/src/main/java/app/notesr/activity/security/AuthActivityExtension.java +++ b/app/src/main/java/app/notesr/activity/security/AuthActivityExtension.java @@ -14,7 +14,6 @@ import android.widget.TextView; import android.widget.Toast; -import java.io.IOException; import java.nio.charset.CharacterCodingException; import java.nio.charset.StandardCharsets; import java.util.Arrays; @@ -23,7 +22,6 @@ import app.notesr.R; import app.notesr.activity.FsaResolver; import app.notesr.core.security.SecretCache; -import app.notesr.core.security.crypto.CryptoManager; import app.notesr.activity.migration.MigrationActivity; import app.notesr.core.security.dto.CryptoSecrets; import app.notesr.activity.note.list.NotesListActivity; @@ -33,6 +31,9 @@ import app.notesr.service.migration.DataVersionManager; import app.notesr.core.util.ActivityUtils; import app.notesr.core.util.KeyUtils; +import app.notesr.service.security.AppSecurityException; +import app.notesr.service.security.AppSecurityService; +import app.notesr.service.security.AuthenticationFailedException; import lombok.RequiredArgsConstructor; @RequiredArgsConstructor @@ -42,13 +43,13 @@ public final class AuthActivityExtension { private static final int ON_WRONG_PASSWORD_DELAY_MS = 1500; private final AuthActivity activity; - private final CryptoManager cryptoManager; + private final AppSecurityService appSecurityService; private final SecureStringBuilder passwordBuilder; private int attempts = MAX_ATTEMPTS; private char[] createdPassword; - public void authorize() { + public void authenticate() { var password = passwordBuilder.toCharArray(); if (password.length == 0) { @@ -57,17 +58,15 @@ public void authorize() { } try { - boolean isAuthorized = cryptoManager.configure(activity.getApplicationContext(), - password); - - if (isAuthorized) { - onAuthorizationSuccessful(); - } else { - onAuthorizationFailed(); - } - } catch (IOException e) { + appSecurityService.authenticate(password); + } catch (AuthenticationFailedException e) { + onAuthenticationFailed(); + return; + } catch (AppSecurityException e) { throw new RuntimeException(e); } + + onAuthenticationSuccessful(); } public void createPassword() { @@ -94,21 +93,20 @@ public void recoverKey() { if (password != null) { try { - char[] hexKey = bytesToChars(SecretCache.take(HEX_KEY), - StandardCharsets.UTF_8); - if (hexKey == null) { - throw new Exception("Missing hex key"); - } + byte[] hexKeyBytes = SecretCache.take(HEX_KEY); - Context context = activity.getApplicationContext(); - CryptoSecrets secrets = KeyUtils.getSecretsFromHex(hexKey, password); + if (hexKeyBytes == null) { + throw new RuntimeException("Missing hex key"); + } - cryptoManager.unblock(context); - cryptoManager.setSecrets(context, secrets); + char[] hexKey = bytesToChars(hexKeyBytes, + StandardCharsets.UTF_8); + CryptoSecrets secrets = KeyUtils.getSecretsFromHex(hexKey, password); + appSecurityService.unblockApp(secrets); secrets.destroy(); - } catch (Exception e) { + } catch (AppSecurityException | CharacterCodingException e) { throw new RuntimeException(e); } @@ -123,10 +121,10 @@ public void changePassword() { if (password != null) { try { Context context = activity.getApplicationContext(); - CryptoSecrets secrets = cryptoManager.getSecrets(); - secrets.setPassword(password); + CryptoSecrets secrets = appSecurityService.getActualSecrets(); - cryptoManager.setSecrets(context, secrets); + secrets.setPassword(password); + appSecurityService.setSecrets(secrets); secrets.destroy(); showToastMessage(R.string.updated); @@ -162,7 +160,7 @@ private char[] proceedPasswordSetting() { return null; } - private void onAuthorizationSuccessful() { + private void onAuthenticationSuccessful() { TextView censoredPasswordView = activity.findViewById(R.id.censoredPasswordTextView); censoredPasswordView.setText(""); @@ -171,20 +169,20 @@ private void onAuthorizationSuccessful() { new AndroidServiceBootstrapper(servicesRegistry).startServicesPostAuth( context, - cryptoManager.getSecrets() + appSecurityService.getActualSecrets() ); activity.startActivity(getNextIntentAfterAuth(context, servicesRegistry)); activity.finish(); } - private void onAuthorizationFailed() { + private void onAuthenticationFailed() { attempts--; if (attempts == 0) { try { - cryptoManager.block(activity.getApplicationContext()); - } catch (IOException e) { + appSecurityService.blockApp(); + } catch (AppSecurityException e) { throw new RuntimeException(e); } diff --git a/app/src/main/java/app/notesr/activity/security/GenerateNewKeyAction.java b/app/src/main/java/app/notesr/activity/security/GenerateNewKeyAction.java index 0af85e83..75886aa5 100644 --- a/app/src/main/java/app/notesr/activity/security/GenerateNewKeyAction.java +++ b/app/src/main/java/app/notesr/activity/security/GenerateNewKeyAction.java @@ -12,8 +12,8 @@ import app.notesr.activity.ActivityBase; import app.notesr.core.security.SecretCache; -import app.notesr.core.security.crypto.CryptoManager; import app.notesr.core.security.dto.CryptoSecrets; +import app.notesr.service.security.AppSecurityService; import lombok.RequiredArgsConstructor; import java.nio.charset.CharacterCodingException; @@ -24,10 +24,10 @@ public final class GenerateNewKeyAction { private final ActivityBase activity; - private final CryptoManager cryptoManager; + private final AppSecurityService appSecurityService; public void startActivity() { - CryptoSecrets secrets = cryptoManager.getSecrets(); + CryptoSecrets secrets = appSecurityService.getActualSecrets(); char[] passwordChars = Arrays.copyOf(secrets.getPassword(), secrets.getPassword().length); try { diff --git a/app/src/main/java/app/notesr/activity/security/KeySetupCompletionHandler.java b/app/src/main/java/app/notesr/activity/security/KeySetupCompletionHandler.java index 7717ab0e..ef5e5e8e 100644 --- a/app/src/main/java/app/notesr/activity/security/KeySetupCompletionHandler.java +++ b/app/src/main/java/app/notesr/activity/security/KeySetupCompletionHandler.java @@ -22,7 +22,7 @@ import app.notesr.activity.note.list.NotesListActivity; import app.notesr.core.security.SecretCache; import app.notesr.core.security.dto.CryptoSecrets; -import app.notesr.service.security.crypto.setup.SecretsSetupService; +import app.notesr.service.security.AppSecurityService; import app.notesr.service.migration.DataVersionManager; import app.notesr.service.security.crypto.update.SecretsUpdateAndroidService; import lombok.RequiredArgsConstructor; @@ -30,7 +30,7 @@ @RequiredArgsConstructor public final class KeySetupCompletionHandler { private final ActivityBase activity; - private final SecretsSetupService secretsSetupService; + private final AppSecurityService appSecurityService; private final KeySetupMode mode; private final CryptoSecrets cryptoSecrets; @@ -44,7 +44,7 @@ public void handle() { private void proceedFirstRun() { try { - secretsSetupService.applySecrets(cryptoSecrets); + appSecurityService.setSecrets(cryptoSecrets); Context context = activity.getApplicationContext(); Intent nextIntent = new Intent(context, NotesListActivity.class); diff --git a/app/src/main/java/app/notesr/activity/security/LockAction.java b/app/src/main/java/app/notesr/activity/security/LockAction.java index 0b733e67..9efa1fea 100644 --- a/app/src/main/java/app/notesr/activity/security/LockAction.java +++ b/app/src/main/java/app/notesr/activity/security/LockAction.java @@ -9,31 +9,23 @@ import android.content.Intent; import app.notesr.activity.ActivityBase; -import app.notesr.core.security.SecretCache; -import app.notesr.core.security.crypto.CryptoManager; -import app.notesr.data.DatabaseProvider; +import app.notesr.service.security.AppSecurityService; import lombok.RequiredArgsConstructor; @RequiredArgsConstructor public final class LockAction { private final ActivityBase activity; - private final CryptoManager cryptoManager; + private final AppSecurityService appSecurityService; public void lock() { + appSecurityService.logout(); + Context context = activity.getApplicationContext(); Intent authActivityIntent = new Intent(context, AuthActivity.class) - .putExtra(AuthActivity.EXTRA_MODE, AuthActivity.Mode.AUTHORIZATION.toString()); - - clearSecrets(); + .putExtra(AuthActivity.EXTRA_MODE, AuthActivity.Mode.AUTHENTICATION.toString()); activity.startActivity(authActivityIntent); activity.finish(); } - - private void clearSecrets() { - DatabaseProvider.close(); - cryptoManager.destroySecrets(); - SecretCache.clear(); - } } diff --git a/app/src/main/java/app/notesr/activity/security/SetupKeyActivity.java b/app/src/main/java/app/notesr/activity/security/SetupKeyActivity.java index 814cb690..edd1fed4 100644 --- a/app/src/main/java/app/notesr/activity/security/SetupKeyActivity.java +++ b/app/src/main/java/app/notesr/activity/security/SetupKeyActivity.java @@ -40,7 +40,7 @@ import app.notesr.core.security.crypto.CryptoManager; import app.notesr.core.security.crypto.CryptoManagerProvider; import app.notesr.core.security.dto.CryptoSecrets; -import app.notesr.service.security.crypto.setup.SecretsSetupService; +import app.notesr.service.security.AppSecurityService; import lombok.Getter; @Getter @@ -54,7 +54,7 @@ public final class SetupKeyActivity extends ActivityBase { private KeySetupMode mode; private char[] password; private ActivityResultLauncher importKeyLauncher; - private SecretsSetupService secretsSetupService; + private AppSecurityService appSecurityService; private CryptoSecrets cryptoSecrets; @Override @@ -73,8 +73,8 @@ protected void onCreate(Bundle savedInstanceState) { importKeyLauncher = registerForActivityResult( new ActivityResultContracts.StartActivityForResult(), getImportKeyCallback()); - secretsSetupService = getSecretsSetupService(); - cryptoSecrets = secretsSetupService.getSecretsWithRandomKey(password); + appSecurityService = getAppSecurityService(); + cryptoSecrets = appSecurityService.getSecretsWithRandomKey(password); char[] hexKey = getKeyHexFromSecrets(cryptoSecrets); showHexKey(hexKey); @@ -187,9 +187,9 @@ private char[] getPasswordFromCache() { } } - private SecretsSetupService getSecretsSetupService() { + private AppSecurityService getAppSecurityService() { CryptoManager cryptoManager = CryptoManagerProvider.getInstance(getApplicationContext()); - return new SecretsSetupService(getApplicationContext(), cryptoManager); + return new AppSecurityService(getApplicationContext(), cryptoManager); } private View.OnClickListener nextButtonOnClick() { @@ -197,7 +197,7 @@ private View.OnClickListener nextButtonOnClick() { } private KeySetupCompletionHandler getCompletionHandler(CryptoSecrets cryptoSecrets) { - return new KeySetupCompletionHandler(this, secretsSetupService, mode, cryptoSecrets); + return new KeySetupCompletionHandler(this, appSecurityService, mode, cryptoSecrets); } private void wipeKeyView() { diff --git a/core/src/main/java/app/notesr/core/security/crypto/CryptoManager.java b/core/src/main/java/app/notesr/core/security/crypto/CryptoManager.java index 0e8a4ab6..9204a81e 100644 --- a/core/src/main/java/app/notesr/core/security/crypto/CryptoManager.java +++ b/core/src/main/java/app/notesr/core/security/crypto/CryptoManager.java @@ -13,9 +13,10 @@ import android.content.SharedPreferences; import java.io.File; +import java.io.FileNotFoundException; import java.io.IOException; +import java.security.GeneralSecurityException; import java.security.NoSuchAlgorithmException; -import java.security.SecureRandom; import java.util.Arrays; import app.notesr.core.security.dto.CryptoSecrets; @@ -42,7 +43,6 @@ public final class CryptoManager { private final SharedPreferences prefs; private final FilesUtilsAdapter filesUtils; private final WiperAdapter wiper; - private final SecureRandom secureRandom; private final AesCryptorFactory aesCryptorFactory; private CryptoSecrets secrets; @@ -53,15 +53,15 @@ public final class CryptoManager { * * @param context The application context. * @param password The password to use for decryption. - * @return {@code true} if configuration was successful, {@code false} if decryption failed. + * @throws DecryptionFailedException if decryption fails. * @throws IOException if an I/O error occurs. */ - public boolean configure(Context context, char[] password) throws IOException { + public void configure(Context context, char[] password) + throws DecryptionFailedException, IOException { try { - this.secrets = tryGetSecretsWithFallback(context, password); - return true; + secrets = getSecrets(context, password, AesGcmCryptor.class); } catch (DecryptionFailedException e) { - return false; + secrets = getSecrets(context, password, AesCbcCryptor.class); } } @@ -95,18 +95,6 @@ public boolean isBlocked(Context context) { return isBlocked || filesUtils.getInternalFile(context, BLOCK_MARKER_FILENAME).exists(); } - /** - * Generates a new set of {@link CryptoSecrets} with a randomly generated master key. - * - * @param password The password to associate with the new secrets. - * @return A new {@link CryptoSecrets} instance. - */ - public CryptoSecrets generateSecrets(char[] password) { - byte[] key = new byte[CryptoSecrets.MASTER_KEY_SIZE]; - secureRandom.nextBytes(key); - return new CryptoSecrets(key, password); - } - /** * Retrieves a copy of the current {@link CryptoSecrets}. * @@ -154,18 +142,17 @@ public void setSecrets(Context context, CryptoSecrets cryptoSecrets) * * @param context The application context. * @param key The key to verify. - * @return {@code true} if the key is valid or no hash is stored, {@code false} otherwise. + * @return {@code true} if the key is valid, {@code false} otherwise. + * @throws FileNotFoundException if the key hash is not found. * @throws IOException if an I/O error occurs while reading the hash. * @throws NoSuchAlgorithmException if the hashing algorithm is not available. */ public boolean verifyKey(Context context, byte[] key) throws IOException, NoSuchAlgorithmException { byte[] originalHash = getKeyHash(context); - if (originalHash != null) { - byte[] providedHash = toSha256Bytes(key); - return Arrays.equals(originalHash, providedHash); - } - return true; + byte[] providedHash = toSha256Bytes(key); + + return Arrays.equals(originalHash, providedHash); } /** @@ -211,24 +198,6 @@ public void destroySecrets() { } } - /** - * Attempts to retrieve secrets using the modern AES-GCM cryptor, falling back to AES-CBC - * for compatibility with older versions. - * - * @param context The application context. - * @param password The password for decryption. - * @return The retrieved {@link CryptoSecrets}. - * @throws DecryptionFailedException if decryption fails with both cryptors. - */ - private CryptoSecrets tryGetSecretsWithFallback(Context context, char[] password) - throws DecryptionFailedException { - try { - return getSecrets(context, password, AesGcmCryptor.class); - } catch (DecryptionFailedException e) { - return getSecrets(context, password, AesCbcCryptor.class); - } - } - /** * Encrypts and saves the provided secrets to the internal storage. * Also updates the key hash in preferences. @@ -266,12 +235,13 @@ private void saveSecrets(Context context, CryptoSecrets cryptoSecrets) * @param cryptorClass The cryptor class to use (e.g., AesGcmCryptor). * @return The decrypted {@link CryptoSecrets}. * @throws DecryptionFailedException if decryption fails. + * @throws IOException if an I/O error occurs. */ private CryptoSecrets getSecrets( Context context, char[] password, Class cryptorClass - ) throws DecryptionFailedException { + ) throws DecryptionFailedException, IOException { try { File keyFile = filesUtils.getInternalFile(context, ENCRYPTED_KEY_FILENAME); @@ -280,7 +250,7 @@ private CryptoSecrets getSecrets( .createAesCryptor(password, cryptorClass) .decrypt(encryptedKeyFileBytes); return new CryptoSecrets(Arrays.copyOf(keyFileBytes, keyFileBytes.length), password); - } catch (Exception e) { + } catch (GeneralSecurityException e) { throw new DecryptionFailedException(e); } } @@ -289,8 +259,9 @@ private CryptoSecrets getSecrets( * Retrieves the stored master key hash. * * @param context The application context. - * @return The key hash as a byte array, or {@code null} if not found. - * @throws IOException if an I/O error occurs. + * @return The key hash as a byte array. + * @throws FileNotFoundException if the key hash is not found in preferences and file. + * @throws IOException if or an I/O error occurs. */ private byte[] getKeyHash(Context context) throws IOException { String keyHash = prefs.getString(KEY_HASH_PREF, null); @@ -305,7 +276,7 @@ private byte[] getKeyHash(Context context) throws IOException { return filesUtils.readFileBytes(keyHashFile); } - return null; + throw new FileNotFoundException("Key hash not found in preferences or file"); } /** diff --git a/core/src/main/java/app/notesr/core/security/crypto/CryptoManagerProvider.java b/core/src/main/java/app/notesr/core/security/crypto/CryptoManagerProvider.java index 0ddc8164..6eedd98f 100644 --- a/core/src/main/java/app/notesr/core/security/crypto/CryptoManagerProvider.java +++ b/core/src/main/java/app/notesr/core/security/crypto/CryptoManagerProvider.java @@ -8,8 +8,6 @@ import android.content.Context; import android.content.SharedPreferences; -import java.security.SecureRandom; - import app.notesr.core.util.FilesUtils; import app.notesr.core.util.Wiper; import lombok.AccessLevel; @@ -31,7 +29,6 @@ public static CryptoManager getInstance(Context context) { prefs, new FilesUtils(), new Wiper(), - new SecureRandom(), new AesCryptorFactory() ); } diff --git a/core/src/test/java/app/notesr/core/security/crypto/CryptoManagerTest.java b/core/src/test/java/app/notesr/core/security/crypto/CryptoManagerTest.java index 825da6f7..af15c93d 100644 --- a/core/src/test/java/app/notesr/core/security/crypto/CryptoManagerTest.java +++ b/core/src/test/java/app/notesr/core/security/crypto/CryptoManagerTest.java @@ -6,7 +6,7 @@ package app.notesr.core.security.crypto; import static org.junit.jupiter.api.Assertions.assertArrayEquals; -import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -38,6 +38,7 @@ import java.util.Arrays; import app.notesr.core.security.dto.CryptoSecrets; +import app.notesr.core.security.exception.DecryptionFailedException; import app.notesr.core.security.exception.SessionExpiredException; import app.notesr.core.util.FilesUtilsAdapter; import app.notesr.core.util.WiperAdapter; @@ -67,16 +68,7 @@ class CryptoManagerTest { @BeforeEach void setUp() { - cryptoManager = new CryptoManager(prefs, filesUtils, wiper, SECURE_RANDOM, aesCryptorFactory); - } - - @Test - void testGenerateSecretsCreatesKeyOfCorrectSize() { - CryptoSecrets secrets = cryptoManager.generateSecrets("pass".toCharArray()); - assertEquals(MASTER_KEY_SIZE, secrets.getKey().length, - "Generated key size should match constant"); - assertArrayEquals("pass".toCharArray(), secrets.getPassword(), - "Generated secrets should contain the provided password"); + cryptoManager = new CryptoManager(prefs, filesUtils, wiper, aesCryptorFactory); } @Test @@ -208,9 +200,9 @@ void testConfigureSuccess() throws Exception { .thenReturn(mockCryptor); when(mockCryptor.decrypt(encryptedKey)).thenReturn(decryptedKey); - boolean result = cryptoManager.configure(null, password); + assertDoesNotThrow(() -> cryptoManager.configure(null, password), + "configure should not throw exception"); - assertTrue(result, "configure should return true on success"); assertTrue(cryptoManager.isConfigured(), "CryptoManager should be configured after successful configuration"); assertArrayEquals(decryptedKey, cryptoManager.getSecrets().getKey(), @@ -236,10 +228,9 @@ void testConfigureFallbackToCbc() throws Exception { .thenThrow(new GeneralSecurityException("GCM failed")); when(mockCbc.decrypt(encryptedKey)).thenReturn(decryptedKey); - boolean result = cryptoManager.configure(null, password); + assertDoesNotThrow(() -> cryptoManager.configure(null, password), + "configure should not throw exception when fallback to CBC succeeds"); - assertTrue(result, - "configure should return true when fallback to CBC succeeds"); assertTrue(cryptoManager.isConfigured(), "CryptoManager should be configured after successful fallback"); assertArrayEquals(decryptedKey, cryptoManager.getSecrets().getKey(), @@ -268,10 +259,12 @@ void testConfigureFailure() throws Exception { when(mockCbc.decrypt(any())) .thenThrow(new GeneralSecurityException("fail")); - boolean result = cryptoManager.configure(null, password); + assertThrows( + DecryptionFailedException.class, + () -> cryptoManager.configure(null, password), + "configure should throw DecryptionFailedException" + + " when both GCM and CBC decryption fail"); - assertFalse(result, - "configure should return false when both GCM and CBC decryption fail"); assertFalse(cryptoManager.isConfigured(), "CryptoManager should not be configured" + " after a failed configuration attempt"); diff --git a/service/src/androidTest/java/app/notesr/service/exporter/ExportServiceTest.java b/service/src/androidTest/java/app/notesr/service/exporter/ExportServiceTest.java index 7447b689..0eb43b0f 100644 --- a/service/src/androidTest/java/app/notesr/service/exporter/ExportServiceTest.java +++ b/service/src/androidTest/java/app/notesr/service/exporter/ExportServiceTest.java @@ -30,8 +30,6 @@ import app.notesr.core.security.crypto.AesCryptor; import app.notesr.core.security.crypto.AesGcmCryptor; -import app.notesr.core.security.crypto.CryptoManager; -import app.notesr.core.security.crypto.CryptoManagerProvider; import app.notesr.core.security.dto.CryptoSecrets; import app.notesr.core.util.FilesUtils; import app.notesr.core.util.FilesUtilsAdapter; @@ -40,6 +38,7 @@ import app.notesr.data.model.Note; import app.notesr.service.file.FileService; import app.notesr.service.note.NoteService; +import app.notesr.service.security.AppSecurityService; public class ExportServiceTest { @@ -56,8 +55,8 @@ public class ExportServiceTest { public void setUp() { Context context = InstrumentationRegistry.getInstrumentation().getTargetContext(); - CryptoManager cryptoManager = CryptoManagerProvider.getInstance(context); - CryptoSecrets cryptoSecrets = cryptoManager.generateSecrets("password".toCharArray()); + var appSecurityService = new AppSecurityService(context); + CryptoSecrets cryptoSecrets = appSecurityService.getSecretsWithRandomKey("password".toCharArray()); AesCryptor cryptor = new AesGcmCryptor(getSecretKeyFromSecrets(cryptoSecrets)); FilesUtilsAdapter filesUtils = new FilesUtils(); diff --git a/service/src/main/java/app/notesr/service/security/AppSecurityException.java b/service/src/main/java/app/notesr/service/security/AppSecurityException.java new file mode 100644 index 00000000..15ba36e9 --- /dev/null +++ b/service/src/main/java/app/notesr/service/security/AppSecurityException.java @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2026 zHd4 + * SPDX-License-Identifier: MIT + */ + +package app.notesr.service.security; + +public class AppSecurityException extends RuntimeException { + public AppSecurityException(String message) { + super(message); + } + + public AppSecurityException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/service/src/main/java/app/notesr/service/security/AppSecurityService.java b/service/src/main/java/app/notesr/service/security/AppSecurityService.java new file mode 100644 index 00000000..95ef14f2 --- /dev/null +++ b/service/src/main/java/app/notesr/service/security/AppSecurityService.java @@ -0,0 +1,248 @@ +/* + * Copyright (c) 2026 zHd4 + * SPDX-License-Identifier: MIT + */ + +package app.notesr.service.security; + +import android.content.Context; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; + +import app.notesr.core.security.SecretCache; +import app.notesr.core.security.crypto.CryptoManager; +import app.notesr.core.security.crypto.CryptoManagerProvider; +import app.notesr.core.security.dto.CryptoSecrets; +import app.notesr.core.security.exception.DecryptionFailedException; +import app.notesr.core.security.exception.EncryptionFailedException; +import app.notesr.data.DatabaseProvider; +import lombok.RequiredArgsConstructor; + +/** + * Service for managing application security operations including authentication + * and access control. + * + *

This service provides a high-level API for security operations such as: + *

+ * + *

The service delegates operations to {@link CryptoManager} and wraps + * low-level exceptions into higher-level security exceptions. + * + * @see CryptoManager + * @see CryptoSecrets + * @see AppSecurityException + * @see AuthenticationFailedException + */ +@RequiredArgsConstructor +public final class AppSecurityService { + + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + + private final Context context; + private final CryptoManager cryptoManager; + + /** + * Constructs an AppSecurityService with the given context. + * + *

This constructor initializes the crypto manager using the singleton provider. + * + * @param context the Android context, used for file access and crypto initialization + */ + public AppSecurityService(Context context) { + this.context = context; + this.cryptoManager = CryptoManagerProvider.getInstance(context); + } + + /** + * Generates new cryptographic secrets which contains randomly generated master key + * and the provided password. + * The key is absolutely random and not derived from the password. + * + * @param password the password to derive secrets from (will not be modified) + * @return a new {@link CryptoSecrets} object containing derived key material + */ + public CryptoSecrets getSecretsWithRandomKey(char[] password) { + byte[] key = new byte[CryptoSecrets.MASTER_KEY_SIZE]; + SECURE_RANDOM.nextBytes(key); + + return new CryptoSecrets(key, password); + } + + /** + * Retrieves the currently configured cryptographic secrets. + * + * @return the current {@link CryptoSecrets} if configured, or null if not yet initialized + * @see #isAuthConfigured() + */ + public CryptoSecrets getActualSecrets() { + return cryptoManager.getSecrets(); + } + + /** + * Checks if the application is currently blocked. + * + * @return {@code true} if the app is blocked (locked), {@code false} otherwise + * @see #blockApp() + * @see #unblockApp(CryptoSecrets) + */ + public boolean isAppBlocked() { + return cryptoManager.isBlocked(context); + } + + /** + * Checks if authentication has been configured for the application. + * + * @return {@code true} if secrets have been set up, {@code false} if initial setup is required + * @see #authenticate(char[]) + */ + public boolean isAuthConfigured() { + return cryptoManager.isConfigured(); + } + + /** + * Checks if a cryptographic key exists on the device. + * + * @return {@code true} if a key is stored, {@code false} otherwise + */ + public boolean isKeyExists() { + return cryptoManager.isKeyExists(context); + } + + /** + * Verifies if the provided key matches the key hash stored on the device. + * + * @param key the key bytes to verify against the stored hash + * @return {@code true} if the key matches the stored hash, {@code false} otherwise + * @throws AppSecurityException if the key hash file is not found, an I/O error occurs, + * or the hash algorithm is not available + */ + public boolean isKeyMatchingWithStored(byte[] key) { + try { + return cryptoManager.verifyKey(context, key); + } catch (FileNotFoundException e) { + throw new AppSecurityException("Failed to verify key, key hash not found", e); + } catch (IOException e) { + throw new AppSecurityException("Failed to verify key, I/O issue", e); + } catch (NoSuchAlgorithmException e) { + throw new AppSecurityException("Failed to verify key, hash algorithm issue", e); + } + } + + /** + * Authenticates the application with the provided password. + * + *

This method verifies the password and decrypts the stored secrets. After successful + * authentication, the app becomes accessible and secrets are cached in memory. + * + * @param password the password to authenticate with (will not be modified) + * @throws AuthenticationFailedException if the password is invalid or decryption fails + * @throws AppSecurityException if an I/O error occurs during authentication + * @see #logout() + */ + public void authenticate(char[] password) { + try { + cryptoManager.configure(context, password); + } catch (DecryptionFailedException e) { + throw new AuthenticationFailedException("Failed to authenticate, " + + "invalid password or cryptographic issue"); + } catch (IOException e) { + throw new AppSecurityException("An I/O error occurred while authenticating", e); + } + } + + /** + * Logs out the current user and clears all sensitive data. + * + *

This method performs the following operations: + *

+ * + *

After logout, authentication is required to access the app again. + * + * @see #authenticate(char[]) + */ + public void logout() { + DatabaseProvider.close(); + cryptoManager.destroySecrets(); + SecretCache.clear(); + } + + /** + * Sets new cryptographic secrets and persists them securely. + * + *

After calling this method, the new secrets replace the existing ones and will be used + * for encryption/decryption operations. The provided secrets object is destroyed after use + * to prevent sensitive data leaks. + * + * @param newCryptoSecrets the new secrets to set and persist + * @throws AppSecurityException if encryption fails during persistence + */ + public void setSecrets(CryptoSecrets newCryptoSecrets) { + try { + cryptoManager.setSecrets(context, newCryptoSecrets); + } catch (EncryptionFailedException e) { + throw new AppSecurityException("Failed to apply new secrets, encryption issue", e); + } + + newCryptoSecrets.destroy(); + } + + /** + * Blocks the application, preventing further access until unblocked. + * + *

This is typically used for security purposes (e.g., after detecting suspicious activity). + * + * @throws AppSecurityException if an I/O error occurs during blocking + * @see #unblockApp(CryptoSecrets) + * @see #isAppBlocked() + */ + public void blockApp() { + try { + cryptoManager.block(context); + } catch (IOException e) { + throw new AppSecurityException("Failed to block app, I/O issue", e); + } + } + + /** + * Unblocks the application, restoring access. + * + *

This method verifies that the provided secrets are valid before unblocking. The key + * from the secrets must match the stored key hash. + * + * @param cryptoSecrets the cryptographic secrets to verify and apply before unblocking + * @throws AuthenticationFailedException if the key verification fails + * @throws AppSecurityException if an I/O error or encryption error occurs during unblocking + * @see #blockApp() + */ + public void unblockApp(CryptoSecrets cryptoSecrets) { + try { + boolean isKeyValid = cryptoManager.verifyKey(context, cryptoSecrets.getKey()); + + if (!isKeyValid) { + throw new AuthenticationFailedException("Failed to unblock app, invalid key"); + } + + cryptoManager.setSecrets(context, cryptoSecrets); + cryptoManager.unblock(context); + } catch (IOException e) { + throw new AppSecurityException("Failed to unblock app, I/O issue", e); + } catch (EncryptionFailedException e) { + throw new AppSecurityException("Failed to unblock app, encryption issue", e); + } catch (NoSuchAlgorithmException e) { + throw new AppSecurityException("Failed to unblock app, hash issue", e); + } + } +} diff --git a/service/src/main/java/app/notesr/service/security/AuthenticationFailedException.java b/service/src/main/java/app/notesr/service/security/AuthenticationFailedException.java new file mode 100644 index 00000000..0d583d44 --- /dev/null +++ b/service/src/main/java/app/notesr/service/security/AuthenticationFailedException.java @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2026 zHd4 + * SPDX-License-Identifier: MIT + */ + +package app.notesr.service.security; + +public class AuthenticationFailedException extends RuntimeException { + public AuthenticationFailedException(String message) { + super(message); + } + + public AuthenticationFailedException(String message, Throwable cause) { + super(message, cause); + } + +} diff --git a/service/src/main/java/app/notesr/service/security/crypto/setup/SecretsSetupService.java b/service/src/main/java/app/notesr/service/security/crypto/setup/SecretsSetupService.java deleted file mode 100644 index 3d4ce473..00000000 --- a/service/src/main/java/app/notesr/service/security/crypto/setup/SecretsSetupService.java +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright (c) 2026 zHd4 - * SPDX-License-Identifier: MIT - */ - -package app.notesr.service.security.crypto.setup; - -import android.content.Context; - -import app.notesr.core.security.crypto.CryptoManager; -import app.notesr.core.security.dto.CryptoSecrets; -import app.notesr.core.security.exception.EncryptionFailedException; -import lombok.Getter; -import lombok.RequiredArgsConstructor; - -@Getter -@RequiredArgsConstructor -public final class SecretsSetupService { - private final Context context; - private final CryptoManager cryptoManager; - - public CryptoSecrets getSecretsWithRandomKey(char[] password) { - return cryptoManager.generateSecrets(password); - } - - public void applySecrets(CryptoSecrets cryptoSecrets) throws EncryptionFailedException { - cryptoManager.setSecrets(context, cryptoSecrets); - cryptoSecrets.destroy(); - } -} From 7d32f6182b14cc6f151240338a37990974e1724d Mon Sep 17 00:00:00 2001 From: zHd4 <38856321+zHd4@users.noreply.github.com> Date: Wed, 22 Jul 2026 20:01:05 +0200 Subject: [PATCH 02/23] Migrate NotesIntegrationTest to AppSecurityService --- .../notesr/{ => activity}/NotesIntegrationTest.java | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) rename app/src/androidTest/java/app/notesr/{ => activity}/NotesIntegrationTest.java (95%) diff --git a/app/src/androidTest/java/app/notesr/NotesIntegrationTest.java b/app/src/androidTest/java/app/notesr/activity/NotesIntegrationTest.java similarity index 95% rename from app/src/androidTest/java/app/notesr/NotesIntegrationTest.java rename to app/src/androidTest/java/app/notesr/activity/NotesIntegrationTest.java index 03d5074e..0f9eb587 100644 --- a/app/src/androidTest/java/app/notesr/NotesIntegrationTest.java +++ b/app/src/androidTest/java/app/notesr/activity/NotesIntegrationTest.java @@ -3,7 +3,7 @@ * SPDX-License-Identifier: MIT */ -package app.notesr; +package app.notesr.activity; import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertEquals; @@ -20,8 +20,6 @@ import app.notesr.core.security.crypto.AesCryptor; import app.notesr.core.security.crypto.AesGcmCryptor; -import app.notesr.core.security.crypto.CryptoManager; -import app.notesr.core.security.crypto.CryptoManagerProvider; import app.notesr.data.AppDatabase; import app.notesr.data.DatabaseProvider; import app.notesr.core.security.dto.CryptoSecrets; @@ -31,6 +29,7 @@ import app.notesr.service.file.FileService; import app.notesr.service.note.NoteService; import app.notesr.core.util.FilesUtilsAdapter; +import app.notesr.service.security.AppSecurityService; import io.bloco.faker.Faker; import org.junit.Before; @@ -58,14 +57,14 @@ public class NotesIntegrationTest { private Note testNote; @BeforeClass - public static void beforeAll() throws Exception { + public static void beforeAll() { context = ApplicationProvider.getApplicationContext(); FilesUtilsAdapter filesUtils = new FilesUtils(); - CryptoManager cryptoManager = CryptoManagerProvider.getInstance(context); + var appSecurityService = new AppSecurityService(context); CryptoSecrets cryptoSecrets = getTestSecrets(); - cryptoManager.setSecrets(context, cryptoSecrets); + appSecurityService.setSecrets(cryptoSecrets); cryptor = new AesGcmCryptor(getSecretKeyFromSecrets(cryptoSecrets)); db = DatabaseProvider.getInstance(context); From 8d17ac3a4561892817612859397f350a65170b9a Mon Sep 17 00:00:00 2001 From: zHd4 <38856321+zHd4@users.noreply.github.com> Date: Wed, 22 Jul 2026 20:01:36 +0200 Subject: [PATCH 03/23] Migrate ActivityBase to AppSecurityService --- .../main/java/app/notesr/activity/ActivityBase.java | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/app/notesr/activity/ActivityBase.java b/app/src/main/java/app/notesr/activity/ActivityBase.java index e5825c9d..7ef59449 100644 --- a/app/src/main/java/app/notesr/activity/ActivityBase.java +++ b/app/src/main/java/app/notesr/activity/ActivityBase.java @@ -17,11 +17,12 @@ import androidx.core.view.WindowCompat; import androidx.core.view.WindowInsetsCompat; -import app.notesr.core.security.crypto.CryptoManager; -import app.notesr.core.security.crypto.CryptoManagerProvider; +import app.notesr.service.security.AppSecurityService; public class ActivityBase extends AppCompatActivity { + private AppSecurityService appSecurityService; + @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); @@ -30,6 +31,8 @@ protected void onCreate(Bundle savedInstanceState) { int windowFlag = WindowManager.LayoutParams.FLAG_SECURE; getWindow().setFlags(windowFlag, windowFlag); + appSecurityService = new AppSecurityService(getApplicationContext()); + if (requiresSession() && !isSessionActive()) { restartApp(); } @@ -58,8 +61,7 @@ protected boolean requiresSession() { } private boolean isSessionActive() { - CryptoManager cryptoManager = CryptoManagerProvider.getInstance(getApplicationContext()); - return cryptoManager.isConfigured(); + return appSecurityService.isAuthConfigured(); } private void restartApp() { From f28804df4d3793a32e7417c36092175d85f884ff Mon Sep 17 00:00:00 2001 From: zHd4 <38856321+zHd4@users.noreply.github.com> Date: Wed, 22 Jul 2026 20:02:03 +0200 Subject: [PATCH 04/23] Migrate ExportActivity to AppSecurityService --- .../java/app/notesr/activity/exporter/ExportActivity.java | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/app/notesr/activity/exporter/ExportActivity.java b/app/src/main/java/app/notesr/activity/exporter/ExportActivity.java index 9029a61d..07ae3083 100644 --- a/app/src/main/java/app/notesr/activity/exporter/ExportActivity.java +++ b/app/src/main/java/app/notesr/activity/exporter/ExportActivity.java @@ -32,8 +32,8 @@ import app.notesr.R; import app.notesr.activity.ActivityBase; +import app.notesr.core.security.crypto.AesCryptor; import app.notesr.core.security.crypto.AesCryptorFactory; -import app.notesr.core.security.crypto.CryptoManagerProvider; import app.notesr.core.util.FileExifDataResolver; import app.notesr.core.util.FilesUtils; import app.notesr.data.DatabaseProvider; @@ -44,6 +44,7 @@ import app.notesr.service.file.FileService; import app.notesr.activity.note.list.NotesListActivity; import app.notesr.service.note.NoteService; +import app.notesr.service.security.AppSecurityService; import app.notesr.util.VersionFetcherImpl; public final class ExportActivity extends ActivityBase { @@ -75,8 +76,8 @@ protected void onCreate(Bundle savedInstanceState) { var context = getApplicationContext(); var db = DatabaseProvider.getInstance(this); - var secrets = CryptoManagerProvider.getInstance(context).getSecrets(); - var cryptor = AesCryptorFactory.createAesGcmCryptor(secrets); + AesCryptor cryptor = AesCryptorFactory.createAesGcmCryptor( + new AppSecurityService(context).getActualSecrets()); noteService = new NoteService(db); fileService = new FileService(context, db, cryptor, new FilesUtils()); From d74be6fc3ffec2e49207d1a2825f4fa72fe8fb18 Mon Sep 17 00:00:00 2001 From: zHd4 <38856321+zHd4@users.noreply.github.com> Date: Wed, 22 Jul 2026 20:02:18 +0200 Subject: [PATCH 05/23] Migrate FilesListActivity to AppSecurityService --- .../java/app/notesr/activity/file/FilesListActivity.java | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/app/notesr/activity/file/FilesListActivity.java b/app/src/main/java/app/notesr/activity/file/FilesListActivity.java index a6b46b93..c0859744 100644 --- a/app/src/main/java/app/notesr/activity/file/FilesListActivity.java +++ b/app/src/main/java/app/notesr/activity/file/FilesListActivity.java @@ -32,6 +32,7 @@ import app.notesr.activity.ActivityBase; import app.notesr.activity.DialogFactory; import app.notesr.activity.file.viewer.FileViewerActivityBase; +import app.notesr.core.security.crypto.AesCryptor; import app.notesr.core.security.crypto.AesCryptorFactory; import app.notesr.core.security.exception.DecryptionFailedException; import app.notesr.data.DatabaseProvider; @@ -39,8 +40,8 @@ import app.notesr.service.note.NoteService; import app.notesr.data.model.FileInfo; import app.notesr.data.model.Note; -import app.notesr.core.security.crypto.CryptoManagerProvider; import app.notesr.core.util.FilesUtils; +import app.notesr.service.security.AppSecurityService; import java.io.IOException; import java.util.ArrayList; @@ -81,8 +82,8 @@ protected void onCreate(Bundle savedInstanceState) { var context = getApplicationContext(); var db = DatabaseProvider.getInstance(context); - var secrets = CryptoManagerProvider.getInstance(context).getSecrets(); - var cryptor = AesCryptorFactory.createAesGcmCryptor(secrets); + AesCryptor cryptor = AesCryptorFactory.createAesGcmCryptor( + new AppSecurityService(context).getActualSecrets()); fileService = new FileService(context, db, cryptor, new FilesUtils()); noteService = new NoteService(db); From 3518cdbdbba7d06e6f7c7866ba4b4aee8afdfc6a Mon Sep 17 00:00:00 2001 From: zHd4 <38856321+zHd4@users.noreply.github.com> Date: Wed, 22 Jul 2026 20:02:46 +0200 Subject: [PATCH 06/23] Migrate FileViewerActivityBase to AppSecurityService --- .../activity/file/viewer/FileViewerActivityBase.java | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/app/notesr/activity/file/viewer/FileViewerActivityBase.java b/app/src/main/java/app/notesr/activity/file/viewer/FileViewerActivityBase.java index cbb81dbb..92a30c5f 100644 --- a/app/src/main/java/app/notesr/activity/file/viewer/FileViewerActivityBase.java +++ b/app/src/main/java/app/notesr/activity/file/viewer/FileViewerActivityBase.java @@ -27,12 +27,13 @@ import app.notesr.R; import app.notesr.activity.ActivityBase; import app.notesr.activity.file.helper.FileIOHelper; +import app.notesr.core.security.crypto.AesCryptor; import app.notesr.core.security.crypto.AesCryptorFactory; -import app.notesr.core.security.crypto.CryptoManagerProvider; import app.notesr.core.util.FilesUtils; import app.notesr.data.DatabaseProvider; import app.notesr.data.model.FileInfo; import app.notesr.service.file.FileService; +import app.notesr.service.security.AppSecurityService; import java.io.File; import java.io.IOException; @@ -61,8 +62,8 @@ protected void onCreate(Bundle savedInstanceState) { var context = getApplicationContext(); - var secrets = CryptoManagerProvider.getInstance(context).getSecrets(); - var cryptor = AesCryptorFactory.createAesGcmCryptor(secrets); + AesCryptor cryptor = AesCryptorFactory.createAesGcmCryptor( + new AppSecurityService(context).getActualSecrets()); fileService = new FileService( context, From fb644cca61dc1affc94cb13b19c88596c47748c7 Mon Sep 17 00:00:00 2001 From: zHd4 <38856321+zHd4@users.noreply.github.com> Date: Wed, 22 Jul 2026 20:03:28 +0200 Subject: [PATCH 07/23] Migrate KeyRecoveryActivity to AppSecurityService --- .../security/KeyRecoveryActivity.java | 39 ++++++++++++------- 1 file changed, 24 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/app/notesr/activity/security/KeyRecoveryActivity.java b/app/src/main/java/app/notesr/activity/security/KeyRecoveryActivity.java index 7c081083..68126d65 100644 --- a/app/src/main/java/app/notesr/activity/security/KeyRecoveryActivity.java +++ b/app/src/main/java/app/notesr/activity/security/KeyRecoveryActivity.java @@ -27,8 +27,8 @@ import app.notesr.R; import app.notesr.activity.ActivityBase; import app.notesr.core.security.SecretCache; -import app.notesr.core.security.crypto.CryptoManager; -import app.notesr.core.security.crypto.CryptoManagerProvider; +import app.notesr.service.security.AppSecurityException; +import app.notesr.service.security.AppSecurityService; import java.io.IOException; import java.nio.charset.CharacterCodingException; @@ -40,12 +40,16 @@ public final class KeyRecoveryActivity extends ActivityBase { private static final String TAG = KeyRecoveryActivity.class.toString(); + private AppSecurityService appSecurityService; + @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_key_recovery); applyInsets(findViewById(R.id.main)); + appSecurityService = new AppSecurityService(getApplicationContext()); + ActionBar actionBar = getSupportActionBar(); Objects.requireNonNull(actionBar).setTitle(getString(R.string.key_recovery)); @@ -95,23 +99,28 @@ private void apply(EditText hexKeyField, char[] hexKey) byte[] keyBytes = getKeyBytesFromHex(hexKeyCopy); Context context = getApplicationContext(); - CryptoManager cryptoManager = CryptoManagerProvider.getInstance(context); - if (cryptoManager.verifyKey(context, keyBytes)) { - byte[] hexKeyBytes = charsToBytes(hexKey, StandardCharsets.UTF_8); - SecretCache.put(AuthActivity.HEX_KEY, hexKeyBytes); + try { + if (appSecurityService.isKeyMatchingWithStored(keyBytes)) { + byte[] hexKeyBytes = charsToBytes(hexKey, StandardCharsets.UTF_8); + SecretCache.put(AuthActivity.HEX_KEY, hexKeyBytes); - // The hex key has already been wiped by charsToBytes - wipeSecretData(keyBytes, hexKeyField); + // The hex key has already been wiped by charsToBytes + wipeSecretData(keyBytes, hexKeyField); - startActivity(new Intent(context, AuthActivity.class) - .putExtra(AuthActivity.EXTRA_MODE, AuthActivity.Mode.KEY_RECOVERY.toString())); + var targetMode = AuthActivity.Mode.KEY_RECOVERY; + var authActivityIntent = new Intent(context, AuthActivity.class) + .putExtra(AuthActivity.EXTRA_MODE, targetMode.toString()); - finish(); - } else { - showToastMessage(this, - getString(R.string.wrong_key), - Toast.LENGTH_SHORT); + startActivity(authActivityIntent); + finish(); + } else { + showToastMessage(this, + getString(R.string.wrong_key), + Toast.LENGTH_SHORT); + } + } catch (AppSecurityException e) { + throw new RuntimeException(e); } } From 088a253034fb74c07b2b827b191af645669aab26 Mon Sep 17 00:00:00 2001 From: zHd4 <38856321+zHd4@users.noreply.github.com> Date: Wed, 22 Jul 2026 20:03:48 +0200 Subject: [PATCH 08/23] Migrate OpenImageActivity to AppSecurityService --- .../app/notesr/activity/file/viewer/OpenImageActivity.java | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/app/notesr/activity/file/viewer/OpenImageActivity.java b/app/src/main/java/app/notesr/activity/file/viewer/OpenImageActivity.java index 24cc3d2d..effd8233 100644 --- a/app/src/main/java/app/notesr/activity/file/viewer/OpenImageActivity.java +++ b/app/src/main/java/app/notesr/activity/file/viewer/OpenImageActivity.java @@ -8,6 +8,7 @@ import static java.util.concurrent.Executors.newSingleThreadExecutor; import app.notesr.activity.DialogFactory; +import app.notesr.core.security.crypto.AesCryptor; import app.notesr.core.security.crypto.AesCryptorFactory; import app.notesr.core.util.FilesUtils; @@ -32,7 +33,7 @@ import app.notesr.data.DatabaseProvider; import app.notesr.core.security.exception.DecryptionFailedException; import app.notesr.service.file.FileService; -import app.notesr.core.security.crypto.CryptoManagerProvider; +import app.notesr.service.security.AppSecurityService; public final class OpenImageActivity extends FileViewerActivityBase { @@ -174,8 +175,8 @@ private FileService getFileService() { var context = getApplicationContext(); var db = DatabaseProvider.getInstance(this); - var secrets = CryptoManagerProvider.getInstance(context).getSecrets(); - var cryptor = AesCryptorFactory.createAesGcmCryptor(secrets); + AesCryptor cryptor = AesCryptorFactory.createAesGcmCryptor( + new AppSecurityService(context).getActualSecrets()); var filesUtils = new FilesUtils(); From 6a62f193a00d6cd83f10588880007e7391f128de Mon Sep 17 00:00:00 2001 From: zHd4 <38856321+zHd4@users.noreply.github.com> Date: Wed, 22 Jul 2026 20:04:00 +0200 Subject: [PATCH 09/23] Migrate OpenNoteActivity to AppSecurityService --- .../app/notesr/activity/note/editor/OpenNoteActivity.java | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/app/notesr/activity/note/editor/OpenNoteActivity.java b/app/src/main/java/app/notesr/activity/note/editor/OpenNoteActivity.java index 4d3af931..59ccdacd 100644 --- a/app/src/main/java/app/notesr/activity/note/editor/OpenNoteActivity.java +++ b/app/src/main/java/app/notesr/activity/note/editor/OpenNoteActivity.java @@ -31,13 +31,14 @@ import app.notesr.activity.ActivityBase; import app.notesr.activity.DialogFactory; import app.notesr.activity.file.FilesListActivity; +import app.notesr.core.security.crypto.AesCryptor; import app.notesr.core.security.crypto.AesCryptorFactory; import app.notesr.data.DatabaseProvider; import app.notesr.service.file.FileService; import app.notesr.data.model.Note; import app.notesr.service.note.NoteService; -import app.notesr.core.security.crypto.CryptoManagerProvider; import app.notesr.core.util.FilesUtils; +import app.notesr.service.security.AppSecurityService; import io.noties.markwon.Markwon; import kotlin.Unit; import kotlin.jvm.functions.Function1; @@ -95,8 +96,8 @@ protected void onCreate(Bundle savedInstanceState) { var context = getApplicationContext(); var db = DatabaseProvider.getInstance(context); - var secrets = CryptoManagerProvider.getInstance(context).getSecrets(); - var cryptor = AesCryptorFactory.createAesGcmCryptor(secrets); + AesCryptor cryptor = AesCryptorFactory.createAesGcmCryptor( + new AppSecurityService(context).getActualSecrets()); noteService = new NoteService(db); fileService = new FileService(context, db, cryptor, new FilesUtils()); From 35003030d3636c55144d7ee99262df6a66a72959 Mon Sep 17 00:00:00 2001 From: zHd4 <38856321+zHd4@users.noreply.github.com> Date: Wed, 22 Jul 2026 20:04:15 +0200 Subject: [PATCH 10/23] Migrate OpenVideoActivity to AppSecurityService --- .../app/notesr/activity/file/viewer/OpenVideoActivity.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/app/notesr/activity/file/viewer/OpenVideoActivity.java b/app/src/main/java/app/notesr/activity/file/viewer/OpenVideoActivity.java index 6f8eb060..8da01ed1 100644 --- a/app/src/main/java/app/notesr/activity/file/viewer/OpenVideoActivity.java +++ b/app/src/main/java/app/notesr/activity/file/viewer/OpenVideoActivity.java @@ -23,11 +23,11 @@ import app.notesr.core.security.crypto.AesCryptor; import app.notesr.core.security.crypto.AesCryptorFactory; import app.notesr.core.security.crypto.AesGcmCryptor; -import app.notesr.core.security.crypto.CryptoManagerProvider; import app.notesr.core.security.crypto.EncryptedMediaDataSourceFactory; import app.notesr.core.util.FilesUtils; import app.notesr.data.DatabaseProvider; import app.notesr.service.file.FileService; +import app.notesr.service.security.AppSecurityService; import java.io.File; import java.util.stream.Collectors; @@ -59,8 +59,8 @@ protected void onCreate(Bundle savedInstanceState) { var context = getApplicationContext(); var db = DatabaseProvider.getInstance(context); - var secrets = CryptoManagerProvider.getInstance(context).getSecrets(); - var cryptor = AesCryptorFactory.createAesGcmCryptor(secrets); + AesCryptor cryptor = AesCryptorFactory.createAesGcmCryptor( + new AppSecurityService(context).getActualSecrets()); fileService = new FileService(context, db, cryptor, new FilesUtils()); videoView = findViewById(R.id.video_view); From 5b0ffc73d8a1c9336ef91726a6a7a32b4bdb561f Mon Sep 17 00:00:00 2001 From: zHd4 <38856321+zHd4@users.noreply.github.com> Date: Wed, 22 Jul 2026 20:04:43 +0200 Subject: [PATCH 11/23] Simplify AppSecurityService instantiation in SetupKeyActivity --- .../java/app/notesr/activity/security/SetupKeyActivity.java | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/app/src/main/java/app/notesr/activity/security/SetupKeyActivity.java b/app/src/main/java/app/notesr/activity/security/SetupKeyActivity.java index edd1fed4..3d821d7c 100644 --- a/app/src/main/java/app/notesr/activity/security/SetupKeyActivity.java +++ b/app/src/main/java/app/notesr/activity/security/SetupKeyActivity.java @@ -37,8 +37,6 @@ import app.notesr.R; import app.notesr.activity.ActivityBase; import app.notesr.core.security.SecretCache; -import app.notesr.core.security.crypto.CryptoManager; -import app.notesr.core.security.crypto.CryptoManagerProvider; import app.notesr.core.security.dto.CryptoSecrets; import app.notesr.service.security.AppSecurityService; import lombok.Getter; @@ -188,8 +186,7 @@ private char[] getPasswordFromCache() { } private AppSecurityService getAppSecurityService() { - CryptoManager cryptoManager = CryptoManagerProvider.getInstance(getApplicationContext()); - return new AppSecurityService(getApplicationContext(), cryptoManager); + return new AppSecurityService(getApplicationContext()); } private View.OnClickListener nextButtonOnClick() { From 91812b139dc8176db84c789f9a47df2f0d53e911 Mon Sep 17 00:00:00 2001 From: zHd4 <38856321+zHd4@users.noreply.github.com> Date: Wed, 22 Jul 2026 20:05:52 +0200 Subject: [PATCH 12/23] Migrate AppCloseAndroidService to AppSecurityService --- .../lifecycle/AppCloseAndroidService.java | 28 +++----- .../lifecycle/AppCloseAndroidServiceTest.java | 69 ++++++++----------- 2 files changed, 40 insertions(+), 57 deletions(-) diff --git a/service/src/main/java/app/notesr/service/lifecycle/AppCloseAndroidService.java b/service/src/main/java/app/notesr/service/lifecycle/AppCloseAndroidService.java index 30907179..92baad09 100644 --- a/service/src/main/java/app/notesr/service/lifecycle/AppCloseAndroidService.java +++ b/service/src/main/java/app/notesr/service/lifecycle/AppCloseAndroidService.java @@ -17,12 +17,12 @@ import androidx.annotation.Nullable; import androidx.core.app.NotificationCompat; -import app.notesr.core.security.SecretCache; -import app.notesr.core.security.crypto.CryptoManagerProvider; -import app.notesr.data.DatabaseProvider; import app.notesr.service.AndroidService; import app.notesr.service.AndroidServiceEntry; import app.notesr.service.AndroidServiceRegistry; +import app.notesr.service.security.AppSecurityService; +import lombok.AllArgsConstructor; +import lombok.NoArgsConstructor; /** * A foreground {@link app.notesr.service.AndroidService} responsible for handling the application's @@ -36,6 +36,8 @@ *

The service operates as a foreground service to ensure the system grants it sufficient * time to execute cleanup logic during the task removal phase.

*/ +@NoArgsConstructor +@AllArgsConstructor public final class AppCloseAndroidService extends AndroidService { private static final int NOTIFICATION_ID = 1005; @@ -43,6 +45,8 @@ public final class AppCloseAndroidService extends AndroidService { private static final String CHANNEL_ID = "app_close_service_channel"; private static final String CHANNEL_NAME = "App Close Service Channel"; + private AppSecurityService appSecurityService; + @Nullable @Override public IBinder onBind(Intent intent) { @@ -66,6 +70,8 @@ public int onStartCommand(Intent intent, int flags, int startId) { type = ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC; } + appSecurityService = new AppSecurityService(getApplicationContext()); + startForeground(NOTIFICATION_ID, notification, type); register(null, null); @@ -81,9 +87,7 @@ protected AndroidServiceEntry getEntry(String payload, String state) { @Override public void onTaskRemoved(Intent rootIntent) { if (getOtherRunningServicesCount() == 0) { - clearSecretCache(); - closeDatabase(); - destroySecrets(); + appSecurityService.logout(); stopForegroundService(); stopService(); @@ -117,18 +121,6 @@ long getOtherRunningServicesCount() { .count(); } - void clearSecretCache() { - SecretCache.clear(); - } - - void closeDatabase() { - DatabaseProvider.close(); - } - - void destroySecrets() { - CryptoManagerProvider.getInstance(getApplicationContext()).destroySecrets(); - } - void exitProcess() { System.exit(0); } diff --git a/service/src/test/java/app/notesr/service/lifecycle/AppCloseAndroidServiceTest.java b/service/src/test/java/app/notesr/service/lifecycle/AppCloseAndroidServiceTest.java index 6ca161cc..410512cc 100644 --- a/service/src/test/java/app/notesr/service/lifecycle/AppCloseAndroidServiceTest.java +++ b/service/src/test/java/app/notesr/service/lifecycle/AppCloseAndroidServiceTest.java @@ -9,6 +9,7 @@ import static org.mockito.Mockito.description; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.spy; import static org.mockito.Mockito.verify; @@ -20,83 +21,73 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.junit.jupiter.MockitoExtension; +import app.notesr.service.security.AppSecurityService; + @ExtendWith(MockitoExtension.class) class AppCloseAndroidServiceTest { - private AppCloseAndroidService service; + private AppSecurityService appSecurityService; + private AppCloseAndroidService appCloseAndroidService; @BeforeEach void setUp() { - service = spy(new AppCloseAndroidService()); + appSecurityService = mock(AppSecurityService.class); + appCloseAndroidService = spy(new AppCloseAndroidService(appSecurityService)); } @Test void testOnTaskRemovedWhenNoOtherServicesRunningClosesEverythingAndExits() { - doReturn(0L).when(service).getOtherRunningServicesCount(); - doNothing().when(service).clearSecretCache(); - doNothing().when(service).closeDatabase(); - doNothing().when(service).destroySecrets(); - doNothing().when(service).stopForegroundService(); - doNothing().when(service).stopService(); - doNothing().when(service).callSuperOnTaskRemoved(any(Intent.class)); - doNothing().when(service).exitProcess(); + doReturn(0L).when(appCloseAndroidService).getOtherRunningServicesCount(); + doNothing().when(appSecurityService).logout(); + doNothing().when(appCloseAndroidService).stopForegroundService(); + doNothing().when(appCloseAndroidService).stopService(); + doNothing().when(appCloseAndroidService).callSuperOnTaskRemoved(any(Intent.class)); + doNothing().when(appCloseAndroidService).exitProcess(); Intent intent = new Intent(); - service.onTaskRemoved(intent); + appCloseAndroidService.onTaskRemoved(intent); - verify(service, description("Secret cache should be cleared" + - " when no other services are running")) - .clearSecretCache(); - verify(service, description("Database should be closed" + - " when no other services are running")) - .closeDatabase(); - verify(service, description("Secrets should be destroyed" + + verify(appSecurityService, description("User should be logged out" + " when no other services are running")) - .destroySecrets(); - verify(service, description("Foreground notification should be stopped" + + .logout(); + verify(appCloseAndroidService, description("Foreground notification should be stopped" + " when no other services are running")) .stopForegroundService(); - verify(service, description("Service should stop itself" + + verify(appCloseAndroidService, description("Service should stop itself" + " when no other services are running")) .stopService(); - verify(service, description("Super.onTaskRemoved should be called" + + verify(appCloseAndroidService, description("Super.onTaskRemoved should be called" + " when no other services are running")) .callSuperOnTaskRemoved(intent); - verify(service, description("Process should exit" + + verify(appCloseAndroidService, description("Process should exit" + " when no other services are running")) .exitProcess(); } @Test void testOnTaskRemovedWhenOtherServicesRunningOnlyStopsSelf() { - doReturn(1L).when(service).getOtherRunningServicesCount(); - doNothing().when(service).stopForegroundService(); - doNothing().when(service).stopService(); + doReturn(1L).when(appCloseAndroidService).getOtherRunningServicesCount(); + doNothing().when(appCloseAndroidService).stopForegroundService(); + doNothing().when(appCloseAndroidService).stopService(); Intent intent = new Intent(); - service.onTaskRemoved(intent); + appCloseAndroidService.onTaskRemoved(intent); - verify(service, never().description("Secret cache should NOT be cleared" + - " when other services are running")) - .clearSecretCache(); - verify(service, never().description("Database should NOT be closed" + - " when other services are running")) - .closeDatabase(); - verify(service, never().description("Secrets should NOT be destroyed" + + verify(appSecurityService, never().description("User should NOT be logged out" + " when other services are running")) - .destroySecrets(); - verify(service, never().description("Super.onTaskRemoved should NOT be called" + + .logout(); + verify(appCloseAndroidService, never().description("Super.onTaskRemoved should NOT be called" + " when other services are running")) .callSuperOnTaskRemoved(any(Intent.class)); - verify(service, never().description("Process should NOT exit" + + verify(appCloseAndroidService, never().description("Process should NOT exit" + " when other services are running")) .exitProcess(); - verify(service, description("Foreground notification should be stopped" + + verify(appCloseAndroidService, description("Foreground notification should be stopped" + " even if other services are running")) .stopForegroundService(); - verify(service, description("Service should stop itself" + + verify(appCloseAndroidService, description("Service should stop itself" + " even if other services are running")) .stopService(); } From 94b2c9887d9b16b5dfe9046593d0f7b62ad89dad Mon Sep 17 00:00:00 2001 From: zHd4 <38856321+zHd4@users.noreply.github.com> Date: Wed, 22 Jul 2026 20:07:11 +0200 Subject: [PATCH 13/23] Migrate CbcToGcmKeyMigration to AppSecurityService --- .../security/crypto/CbcToGcmKeyMigration.java | 17 ++++---- .../crypto/CbcToGcmKeyMigrationTest.java | 41 +++++++++---------- 2 files changed, 28 insertions(+), 30 deletions(-) diff --git a/service/src/main/java/app/notesr/service/migration/changes/security/crypto/CbcToGcmKeyMigration.java b/service/src/main/java/app/notesr/service/migration/changes/security/crypto/CbcToGcmKeyMigration.java index 6e24336c..d99afb7e 100644 --- a/service/src/main/java/app/notesr/service/migration/changes/security/crypto/CbcToGcmKeyMigration.java +++ b/service/src/main/java/app/notesr/service/migration/changes/security/crypto/CbcToGcmKeyMigration.java @@ -8,11 +8,10 @@ import android.content.Context; import app.notesr.core.security.dto.CryptoSecrets; -import app.notesr.core.security.exception.EncryptionFailedException; import app.notesr.service.migration.AppMigration; import app.notesr.service.migration.AppMigrationException; -import app.notesr.core.security.crypto.CryptoManager; -import app.notesr.core.security.crypto.CryptoManagerProvider; +import app.notesr.service.security.AppSecurityException; +import app.notesr.service.security.AppSecurityService; import lombok.Getter; import lombok.RequiredArgsConstructor; @@ -28,17 +27,17 @@ public class CbcToGcmKeyMigration implements AppMigration { @Override public void migrate(Context context) { try { - CryptoManager cryptoManager = getCryptoManager(context); - CryptoSecrets secrets = cryptoManager.getSecrets(); + AppSecurityService appSecurityService = getAppSecurityService(context); + CryptoSecrets secrets = appSecurityService.getActualSecrets(); - cryptoManager.setSecrets(context, secrets); + appSecurityService.setSecrets(secrets); secrets.destroy(); - } catch (EncryptionFailedException | IllegalArgumentException e) { + } catch (AppSecurityException | IllegalArgumentException e) { throw new AppMigrationException("Failed to migrate key", e); } } - CryptoManager getCryptoManager(Context context) { - return CryptoManagerProvider.getInstance(context); + AppSecurityService getAppSecurityService(Context context) { + return new AppSecurityService(context); } } diff --git a/service/src/test/java/app/notesr/service/migration/changes/security/crypto/CbcToGcmKeyMigrationTest.java b/service/src/test/java/app/notesr/service/migration/changes/security/crypto/CbcToGcmKeyMigrationTest.java index 9b0016f9..54965550 100644 --- a/service/src/test/java/app/notesr/service/migration/changes/security/crypto/CbcToGcmKeyMigrationTest.java +++ b/service/src/test/java/app/notesr/service/migration/changes/security/crypto/CbcToGcmKeyMigrationTest.java @@ -15,10 +15,11 @@ import static org.mockito.Mockito.when; import android.content.Context; -import app.notesr.core.security.exception.EncryptionFailedException; + import app.notesr.service.migration.AppMigrationException; -import app.notesr.core.security.crypto.CryptoManager; import app.notesr.core.security.dto.CryptoSecrets; +import app.notesr.service.security.AppSecurityException; +import app.notesr.service.security.AppSecurityService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -34,53 +35,51 @@ class CbcToGcmKeyMigrationTest { private Context context; @Mock - private CryptoManager cryptoManager; + private AppSecurityService appSecurityService; - private CbcToGcmKeyMigration migration; + private CbcToGcmKeyMigration cbcToGcmKeyMigration; @BeforeEach void setUp() { - migration = new CbcToGcmKeyMigration(1, 2) { + cbcToGcmKeyMigration = new CbcToGcmKeyMigration(1, 2) { @Override - CryptoManager getCryptoManager(Context context) { - return cryptoManager; + AppSecurityService getAppSecurityService(Context context) { + return appSecurityService; } }; } @Test - void testMigrateSuccess() throws EncryptionFailedException { + void testMigrateSuccess() { CryptoSecrets secrets = new CryptoSecrets(new byte[]{1, 2, 3}, "123".toCharArray()); - when(cryptoManager.getSecrets()).thenReturn(secrets); - doNothing().when(cryptoManager).setSecrets(context, secrets); + when(appSecurityService.getActualSecrets()).thenReturn(secrets); + doNothing().when(appSecurityService).setSecrets(secrets); - assertDoesNotThrow(() -> migration.migrate(context)); - verify(cryptoManager).getSecrets(); - verify(cryptoManager).setSecrets(context, secrets); + assertDoesNotThrow(() -> cbcToGcmKeyMigration.migrate(context)); + verify(appSecurityService).getActualSecrets(); + verify(appSecurityService).setSecrets(secrets); } @Test - void testMigrateWhenEncryptionFailsThrowsAppMigrationException() - throws EncryptionFailedException { - doThrow(new EncryptionFailedException()).when(cryptoManager).setSecrets(any(), any()); + void testMigrateWhenEncryptionFailsThrowsAppMigrationException() { + doThrow(new AppSecurityException("Stub")).when(appSecurityService).setSecrets(any()); AppMigrationException exception = assertThrows( AppMigrationException.class, - () -> migration.migrate(context) + () -> cbcToGcmKeyMigration.migrate(context) ); assertEquals("Failed to migrate key", exception.getMessage()); } @Test - void testMigrateWhenSecretsValidationFailsThrowsAppMigrationException() - throws EncryptionFailedException { - doThrow(new IllegalArgumentException()).when(cryptoManager).setSecrets(any(), any()); + void testMigrateWhenSecretsValidationFailsThrowsAppMigrationException() { + doThrow(new IllegalArgumentException()).when(appSecurityService).setSecrets(any()); AppMigrationException exception = assertThrows( AppMigrationException.class, - () -> migration.migrate(context) + () -> cbcToGcmKeyMigration.migrate(context) ); assertEquals("Failed to migrate key", exception.getMessage()); From 6e57ad7b755293a322525fc957b4f4a45f62ca18 Mon Sep 17 00:00:00 2001 From: zHd4 <38856321+zHd4@users.noreply.github.com> Date: Wed, 22 Jul 2026 20:07:41 +0200 Subject: [PATCH 14/23] Migrate RoomIntegrationMigration to AppSecurityService --- .../changes/db/RoomIntegrationMigration.java | 31 ++++++++++--------- .../db/RoomIntegrationMigrationTest.java | 2 +- 2 files changed, 17 insertions(+), 16 deletions(-) diff --git a/service/src/main/java/app/notesr/service/migration/changes/db/RoomIntegrationMigration.java b/service/src/main/java/app/notesr/service/migration/changes/db/RoomIntegrationMigration.java index 92f1eaa7..eefd9d85 100644 --- a/service/src/main/java/app/notesr/service/migration/changes/db/RoomIntegrationMigration.java +++ b/service/src/main/java/app/notesr/service/migration/changes/db/RoomIntegrationMigration.java @@ -11,7 +11,6 @@ import java.util.List; import app.notesr.core.security.crypto.AesCryptorFactory; -import app.notesr.core.security.crypto.CryptoManagerProvider; import app.notesr.core.security.crypto.ValueDecryptor; import app.notesr.core.security.dto.CryptoSecrets; import app.notesr.core.security.exception.DecryptionFailedException; @@ -28,6 +27,7 @@ import app.notesr.service.migration.AppMigration; import app.notesr.service.migration.AppMigrationException; import app.notesr.service.note.NoteService; +import app.notesr.service.security.AppSecurityService; import lombok.Getter; import lombok.RequiredArgsConstructor; @@ -58,15 +58,13 @@ public final class RoomIntegrationMigration implements AppMigration { public void migrate(Context context) { try { AppDatabase db = getAppDatabase(context); + CryptoSecrets cryptoSecrets = getCryptoSecrets(context); filesUtils = getFilesUtils(); noteService = getNoteService(db); - fileService = getFileService(context, db, filesUtils); + fileService = getFileService(context, cryptoSecrets, db, filesUtils); oldDbHelper = getOldDbHelper(context); - - CryptoSecrets cryptoSecrets = getCryptoSecrets(context); entityMapper = getMapper(cryptoSecrets); - wiper = getWiper(); db.runInTransaction(() -> { @@ -143,22 +141,29 @@ private void wipeOldDbs(Context context) { }); } + AppDatabase getAppDatabase(Context context) { + return DatabaseProvider.getInstance(context); + } + CryptoSecrets getCryptoSecrets(Context context) { - return CryptoManagerProvider.getInstance(context).getSecrets(); + return new AppSecurityService(context).getActualSecrets(); } - AppDatabase getAppDatabase(Context context) { - return DatabaseProvider.getInstance(context); + FilesUtilsAdapter getFilesUtils() { + return new FilesUtils(); } NoteService getNoteService(AppDatabase db) { return new NoteService(db); } - FileService getFileService(Context context, AppDatabase db, FilesUtilsAdapter filesUtils) { - var secrets = CryptoManagerProvider.getInstance(context).getSecrets(); - var cryptor = AesCryptorFactory.createAesGcmCryptor(secrets); + FileService getFileService( + Context context, + CryptoSecrets cryptoSecrets, + AppDatabase db, + FilesUtilsAdapter filesUtils) { + var cryptor = AesCryptorFactory.createAesGcmCryptor(cryptoSecrets); return new FileService(context, db, cryptor, filesUtils); } @@ -173,10 +178,6 @@ EntityMapper getMapper(CryptoSecrets cryptoSecrets) { return new EntityMapper(valueDecryptor); } - FilesUtilsAdapter getFilesUtils() { - return new FilesUtils(); - } - WiperAdapter getWiper() { return new Wiper(); } diff --git a/service/src/test/java/app/notesr/service/migration/changes/db/RoomIntegrationMigrationTest.java b/service/src/test/java/app/notesr/service/migration/changes/db/RoomIntegrationMigrationTest.java index 9fca2d82..a12d2e0c 100644 --- a/service/src/test/java/app/notesr/service/migration/changes/db/RoomIntegrationMigrationTest.java +++ b/service/src/test/java/app/notesr/service/migration/changes/db/RoomIntegrationMigrationTest.java @@ -52,7 +52,7 @@ void setup() { doReturn(mockDb).when(migration).getAppDatabase(any()); doReturn(mockNoteService).when(migration).getNoteService(any()); - doReturn(mockFileService).when(migration).getFileService(any(), any(), any()); + doReturn(mockFileService).when(migration).getFileService(any(), any(), any(), any()); doReturn(mockOldDbHelper).when(migration).getOldDbHelper(any()); doReturn(mockEntityMapper).when(migration).getMapper(any()); doReturn(mockFilesUtils).when(migration).getFilesUtils(); From 458db80bf695b8bbc577a5cb010f9914a81130c9 Mon Sep 17 00:00:00 2001 From: zHd4 <38856321+zHd4@users.noreply.github.com> Date: Wed, 22 Jul 2026 20:08:03 +0200 Subject: [PATCH 15/23] Migrate ImportService to AppSecurityService --- .../app/notesr/service/importer/ImportAndroidService.java | 6 ++++-- .../java/app/notesr/service/importer/ImportService.java | 8 +++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/service/src/main/java/app/notesr/service/importer/ImportAndroidService.java b/service/src/main/java/app/notesr/service/importer/ImportAndroidService.java index 1ac54149..eb43474e 100644 --- a/service/src/main/java/app/notesr/service/importer/ImportAndroidService.java +++ b/service/src/main/java/app/notesr/service/importer/ImportAndroidService.java @@ -28,8 +28,8 @@ import app.notesr.service.AndroidServiceEntry; import app.notesr.service.file.FileService; import app.notesr.service.note.NoteService; -import app.notesr.core.security.crypto.CryptoManagerProvider; import app.notesr.core.security.dto.CryptoSecrets; +import app.notesr.service.security.AppSecurityService; import java.util.Set; @@ -74,7 +74,7 @@ public int onStartCommand(Intent intent, int flags, int startId) { } sourceUri = intent.getData(); - secrets = CryptoManagerProvider.getInstance(getApplicationContext()).getSecrets(); + secrets = new AppSecurityService(getApplicationContext()).getActualSecrets(); importService = getImportService(); var thread = new Thread(this); @@ -131,6 +131,7 @@ private ImportService getImportService() { var db = DatabaseProvider.getInstance(context); var cryptor = AesCryptorFactory.createAesGcmCryptor(secrets); + var appSecurityService = new AppSecurityService(context); var noteService = new NoteService(db); var fileService = new FileService(context, db, cryptor, new FilesUtils()); @@ -140,6 +141,7 @@ private ImportService getImportService() { return new ImportService( this, db, + appSecurityService, noteService, fileService, secrets, diff --git a/service/src/main/java/app/notesr/service/importer/ImportService.java b/service/src/main/java/app/notesr/service/importer/ImportService.java index 33dc43a4..02492901 100644 --- a/service/src/main/java/app/notesr/service/importer/ImportService.java +++ b/service/src/main/java/app/notesr/service/importer/ImportService.java @@ -27,14 +27,13 @@ import app.notesr.service.importer.v3.ImportV3Strategy; import app.notesr.service.note.NoteService; import app.notesr.core.security.crypto.BackupDecryptor; -import app.notesr.core.security.crypto.CryptoManager; -import app.notesr.core.security.crypto.CryptoManagerProvider; import app.notesr.data.AppDatabase; import app.notesr.core.security.dto.CryptoSecrets; import app.notesr.core.security.exception.DecryptionFailedException; import app.notesr.service.importer.v2.ImportV2Strategy; import app.notesr.core.util.TempDataWiper; import app.notesr.core.util.ZipUtils; +import app.notesr.service.security.AppSecurityService; import lombok.RequiredArgsConstructor; @RequiredArgsConstructor @@ -48,6 +47,7 @@ public final class ImportService { private final Context context; private final AppDatabase db; + private final AppSecurityService appSecurityService; private final NoteService noteService; private final FileService fileService; private final CryptoSecrets secrets; @@ -116,9 +116,7 @@ private ImportV3Strategy getV3Strategy(File tempDecryptedFile) { private void decrypt(File outputFile) throws DecryptionFailedException { try { - CryptoManager cryptoManager = CryptoManagerProvider.getInstance(context); - CryptoSecrets cryptoSecrets = cryptoManager.getSecrets(); - + CryptoSecrets cryptoSecrets = appSecurityService.getActualSecrets(); BackupDecryptor decryptor = new BackupDecryptor( contentResolver, cryptoSecrets, From cd2fa8bab3f07147d98830b4d200598984ab12e5 Mon Sep 17 00:00:00 2001 From: zHd4 <38856321+zHd4@users.noreply.github.com> Date: Wed, 22 Jul 2026 20:08:28 +0200 Subject: [PATCH 16/23] Migrate ExportAndroidService to AppSecurityService --- .../app/notesr/service/exporter/ExportAndroidService.java | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/service/src/main/java/app/notesr/service/exporter/ExportAndroidService.java b/service/src/main/java/app/notesr/service/exporter/ExportAndroidService.java index ec49e950..55cf40a2 100644 --- a/service/src/main/java/app/notesr/service/exporter/ExportAndroidService.java +++ b/service/src/main/java/app/notesr/service/exporter/ExportAndroidService.java @@ -25,14 +25,16 @@ import com.fasterxml.jackson.databind.ObjectMapper; +import app.notesr.core.security.crypto.AesCryptor; import app.notesr.core.security.crypto.AesCryptorFactory; -import app.notesr.core.security.crypto.CryptoManagerProvider; +import app.notesr.core.security.dto.CryptoSecrets; import app.notesr.core.util.FilesUtils; import app.notesr.data.DatabaseProvider; import app.notesr.service.AndroidService; import app.notesr.service.AndroidServiceEntry; import app.notesr.service.file.FileService; import app.notesr.service.note.NoteService; +import app.notesr.service.security.AppSecurityService; import java.io.IOException; import java.io.OutputStream; @@ -162,8 +164,8 @@ private ExportService getExportService( var db = DatabaseProvider.getInstance(this); var noteService = new NoteService(db); - var secrets = CryptoManagerProvider.getInstance(context).getSecrets(); - var cryptor = AesCryptorFactory.createAesGcmCryptor(secrets); + CryptoSecrets secrets = new AppSecurityService(context).getActualSecrets(); + AesCryptor cryptor = AesCryptorFactory.createAesGcmCryptor(secrets); var fileService = new FileService(context, db, cryptor, new FilesUtils()); var statusHolder = new ExportStatusHolder(updateCallback); From a0a6ba8a53ced526059ac1944487b4bfaeb6de59 Mon Sep 17 00:00:00 2001 From: zHd4 <38856321+zHd4@users.noreply.github.com> Date: Wed, 22 Jul 2026 20:08:58 +0200 Subject: [PATCH 17/23] Fix Javadoc of setSecrets method in AppSecurityService --- .../java/app/notesr/service/security/AppSecurityService.java | 1 + 1 file changed, 1 insertion(+) diff --git a/service/src/main/java/app/notesr/service/security/AppSecurityService.java b/service/src/main/java/app/notesr/service/security/AppSecurityService.java index 95ef14f2..837c8947 100644 --- a/service/src/main/java/app/notesr/service/security/AppSecurityService.java +++ b/service/src/main/java/app/notesr/service/security/AppSecurityService.java @@ -188,6 +188,7 @@ public void logout() { * * @param newCryptoSecrets the new secrets to set and persist * @throws AppSecurityException if encryption fails during persistence + * @throws IllegalArgumentException if the provided secrets are invalid */ public void setSecrets(CryptoSecrets newCryptoSecrets) { try { From ce9b449915297304e067f9ddfd761ee22b3a181c Mon Sep 17 00:00:00 2001 From: zHd4 <38856321+zHd4@users.noreply.github.com> Date: Thu, 23 Jul 2026 17:03:54 +0200 Subject: [PATCH 18/23] Refactor CryptoManager to replace high-level exceptions with low-level exceptions --- .../core/security/crypto/CryptoManager.java | 76 ++- .../security/crypto/CryptoManagerTest.java | 76 +-- .../service/security/AppSecurityService.java | 46 +- .../security/AppSecurityServiceTest.java | 433 ++++++++++++++++++ 4 files changed, 501 insertions(+), 130 deletions(-) create mode 100644 service/src/test/java/app/notesr/service/security/AppSecurityServiceTest.java diff --git a/core/src/main/java/app/notesr/core/security/crypto/CryptoManager.java b/core/src/main/java/app/notesr/core/security/crypto/CryptoManager.java index 9204a81e..6d36a21e 100644 --- a/core/src/main/java/app/notesr/core/security/crypto/CryptoManager.java +++ b/core/src/main/java/app/notesr/core/security/crypto/CryptoManager.java @@ -20,8 +20,6 @@ import java.util.Arrays; import app.notesr.core.security.dto.CryptoSecrets; -import app.notesr.core.security.exception.DecryptionFailedException; -import app.notesr.core.security.exception.EncryptionFailedException; import app.notesr.core.security.exception.SessionExpiredException; import app.notesr.core.util.FilesUtilsAdapter; import app.notesr.core.util.WiperAdapter; @@ -53,14 +51,14 @@ public final class CryptoManager { * * @param context The application context. * @param password The password to use for decryption. - * @throws DecryptionFailedException if decryption fails. + * @throws GeneralSecurityException if decryption fails. * @throws IOException if an I/O error occurs. */ public void configure(Context context, char[] password) - throws DecryptionFailedException, IOException { + throws GeneralSecurityException, IOException { try { secrets = getSecrets(context, password, AesGcmCryptor.class); - } catch (DecryptionFailedException e) { + } catch (GeneralSecurityException e) { secrets = getSecrets(context, password, AesCbcCryptor.class); } } @@ -115,17 +113,16 @@ public CryptoSecrets getSecrets() { /** * Saves the provided secrets to disk and sets them as the current active secrets. * - * @param context The application context. - * @param cryptoSecrets The secrets to save and set. - * @throws IllegalArgumentException if the provided secrets are invalid. - * @throws EncryptionFailedException if saving the secrets fails. + * @param context The application context. + * @param cryptoSecrets The secrets to save and set. + * @throws IllegalArgumentException if the provided secrets are null. + * @throws GeneralSecurityException if encryption of secrets before saving fails. + * @throws IOException if an I/O error occurs during saving. */ public void setSecrets(Context context, CryptoSecrets cryptoSecrets) - throws EncryptionFailedException { - try { - cryptoSecrets.validate(); - } catch (IllegalStateException e) { - throw new IllegalArgumentException("Invalid CryptoSecrets provided", e); + throws GeneralSecurityException, IOException { + if (cryptoSecrets == null) { + throw new IllegalArgumentException("Secrets cannot be null"); } saveSecrets(context, cryptoSecrets); @@ -204,27 +201,24 @@ public void destroySecrets() { * * @param context The application context. * @param cryptoSecrets The secrets to save. - * @throws EncryptionFailedException if an error occurs during encryption or writing. + * @throws IOException if an I/O error occurs during saving. + * @throws GeneralSecurityException if secrets encryption fails. */ private void saveSecrets(Context context, CryptoSecrets cryptoSecrets) - throws EncryptionFailedException { - try { - byte[] encryptedKeyFileBytes = aesCryptorFactory - .createAesCryptor(cryptoSecrets.getPassword(), AesGcmCryptor.class) - .encrypt(cryptoSecrets.getKey()); - - File encryptedKeyFile = filesUtils.getInternalFile(context, ENCRYPTED_KEY_FILENAME); + throws IOException, GeneralSecurityException { + byte[] encryptedKeyFileBytes = aesCryptorFactory + .createAesCryptor(cryptoSecrets.getPassword(), AesGcmCryptor.class) + .encrypt(cryptoSecrets.getKey()); - if (encryptedKeyFile.exists()) { - wiper.wipeFile(encryptedKeyFile); - } + File encryptedKeyFile = filesUtils.getInternalFile(context, ENCRYPTED_KEY_FILENAME); - filesUtils.writeFileBytes(encryptedKeyFile, encryptedKeyFileBytes); - setKeyHash(toSha256String(cryptoSecrets.getKey())); - removeOldKeyHashFileIfExists(context); - } catch (Exception e) { - throw new EncryptionFailedException(e); + if (encryptedKeyFile.exists()) { + wiper.wipeFile(encryptedKeyFile); } + + filesUtils.writeFileBytes(encryptedKeyFile, encryptedKeyFileBytes); + setKeyHash(toSha256String(cryptoSecrets.getKey())); + removeOldKeyHashFileIfExists(context); } /** @@ -234,25 +228,21 @@ private void saveSecrets(Context context, CryptoSecrets cryptoSecrets) * @param password The password for decryption. * @param cryptorClass The cryptor class to use (e.g., AesGcmCryptor). * @return The decrypted {@link CryptoSecrets}. - * @throws DecryptionFailedException if decryption fails. + * @throws GeneralSecurityException if decryption fails. * @throws IOException if an I/O error occurs. */ private CryptoSecrets getSecrets( Context context, char[] password, Class cryptorClass - ) throws DecryptionFailedException, IOException { - - try { - File keyFile = filesUtils.getInternalFile(context, ENCRYPTED_KEY_FILENAME); - byte[] encryptedKeyFileBytes = filesUtils.readFileBytes(keyFile); - byte[] keyFileBytes = aesCryptorFactory - .createAesCryptor(password, cryptorClass) - .decrypt(encryptedKeyFileBytes); - return new CryptoSecrets(Arrays.copyOf(keyFileBytes, keyFileBytes.length), password); - } catch (GeneralSecurityException e) { - throw new DecryptionFailedException(e); - } + ) throws GeneralSecurityException, IOException { + + File keyFile = filesUtils.getInternalFile(context, ENCRYPTED_KEY_FILENAME); + byte[] encryptedKeyFileBytes = filesUtils.readFileBytes(keyFile); + byte[] keyFileBytes = aesCryptorFactory + .createAesCryptor(password, cryptorClass) + .decrypt(encryptedKeyFileBytes); + return new CryptoSecrets(Arrays.copyOf(keyFileBytes, keyFileBytes.length), password); } /** diff --git a/core/src/test/java/app/notesr/core/security/crypto/CryptoManagerTest.java b/core/src/test/java/app/notesr/core/security/crypto/CryptoManagerTest.java index af15c93d..95974fd4 100644 --- a/core/src/test/java/app/notesr/core/security/crypto/CryptoManagerTest.java +++ b/core/src/test/java/app/notesr/core/security/crypto/CryptoManagerTest.java @@ -38,7 +38,6 @@ import java.util.Arrays; import app.notesr.core.security.dto.CryptoSecrets; -import app.notesr.core.security.exception.DecryptionFailedException; import app.notesr.core.security.exception.SessionExpiredException; import app.notesr.core.util.FilesUtilsAdapter; import app.notesr.core.util.WiperAdapter; @@ -260,9 +259,9 @@ void testConfigureFailure() throws Exception { .thenThrow(new GeneralSecurityException("fail")); assertThrows( - DecryptionFailedException.class, + GeneralSecurityException.class, () -> cryptoManager.configure(null, password), - "configure should throw DecryptionFailedException" + + "configure should throw GeneralSecurityException" + " when both GCM and CBC decryption fail"); assertFalse(cryptoManager.isConfigured(), @@ -340,73 +339,10 @@ void testDestroySecrets() throws Exception { } @Test - void testSetSecretsThrowsWhenKeyIsNull() { - CryptoSecrets secrets = new CryptoSecrets(null, "password".toCharArray()); - - assertThrows(IllegalArgumentException.class, () -> cryptoManager.setSecrets(null, secrets), - "setSecrets should throw IllegalArgumentException when key is null"); - } - - @Test - void testSetSecretsThrowsWhenKeyIsEmpty() { - CryptoSecrets secrets = new CryptoSecrets(new byte[0], "password".toCharArray()); - - assertThrows(IllegalArgumentException.class, () -> cryptoManager.setSecrets(null, secrets), - "setSecrets should throw IllegalArgumentException when key is empty"); - } - - @Test - void testSetSecretsThrowsWhenKeyLengthIsInvalid() { - byte[] invalidKey = generateRandomBytes(MASTER_KEY_SIZE - 1); - CryptoSecrets secrets = new CryptoSecrets(invalidKey, "password".toCharArray()); - - assertThrows(IllegalArgumentException.class, () -> cryptoManager.setSecrets(null, secrets), - "setSecrets should throw IllegalArgumentException when key length is invalid"); - } - - @Test - void testSetSecretsThrowsWhenPasswordIsNull() { - byte[] key = generateRandomBytes(MASTER_KEY_SIZE); - CryptoSecrets secrets = new CryptoSecrets(key, null); - - assertThrows(IllegalArgumentException.class, () -> cryptoManager.setSecrets(null, secrets), - "setSecrets should throw IllegalArgumentException when password is null"); - } - - @Test - void testSetSecretsThrowsWhenPasswordIsEmpty() { - byte[] key = generateRandomBytes(MASTER_KEY_SIZE); - CryptoSecrets secrets = new CryptoSecrets(key, new char[0]); - - assertThrows(IllegalArgumentException.class, () -> cryptoManager.setSecrets(null, secrets), - "setSecrets should throw IllegalArgumentException when password is empty"); - } - - @Test - void testSetSecretsThrowsWhenPasswordIsTooShort() { - byte[] key = generateRandomBytes(MASTER_KEY_SIZE); - CryptoSecrets secrets = new CryptoSecrets(key, "123".toCharArray()); - - assertThrows(IllegalArgumentException.class, () -> cryptoManager.setSecrets(null, secrets), - "setSecrets should throw IllegalArgumentException when password is too short"); - } - - @Test - void testSetSecretsThrowsWhenKeyIsAllZeros() { - byte[] key = new byte[MASTER_KEY_SIZE]; - CryptoSecrets secrets = new CryptoSecrets(key, "password".toCharArray()); - - assertThrows(IllegalArgumentException.class, () -> cryptoManager.setSecrets(null, secrets), - "setSecrets should throw IllegalArgumentException when key is all zeros"); - } - - @Test - void testSetSecretsThrowsWhenPasswordIsAllZeros() { - byte[] key = generateRandomBytes(MASTER_KEY_SIZE); - CryptoSecrets secrets = new CryptoSecrets(key, new char[4]); - - assertThrows(IllegalArgumentException.class, () -> cryptoManager.setSecrets(null, secrets), - "setSecrets should throw IllegalArgumentException when password is all zeros"); + void testSetSecretsThrowsWhenSecretsIsNull() { + assertThrows(IllegalArgumentException.class, + () -> cryptoManager.setSecrets(null, null), + "setSecrets should throw IllegalArgumentException when secrets are null"); } @Test diff --git a/service/src/main/java/app/notesr/service/security/AppSecurityService.java b/service/src/main/java/app/notesr/service/security/AppSecurityService.java index 837c8947..9fddc0fd 100644 --- a/service/src/main/java/app/notesr/service/security/AppSecurityService.java +++ b/service/src/main/java/app/notesr/service/security/AppSecurityService.java @@ -9,6 +9,7 @@ import java.io.FileNotFoundException; import java.io.IOException; +import java.security.GeneralSecurityException; import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; @@ -16,8 +17,6 @@ import app.notesr.core.security.crypto.CryptoManager; import app.notesr.core.security.crypto.CryptoManagerProvider; import app.notesr.core.security.dto.CryptoSecrets; -import app.notesr.core.security.exception.DecryptionFailedException; -import app.notesr.core.security.exception.EncryptionFailedException; import app.notesr.data.DatabaseProvider; import lombok.RequiredArgsConstructor; @@ -122,8 +121,7 @@ public boolean isKeyExists() { * * @param key the key bytes to verify against the stored hash * @return {@code true} if the key matches the stored hash, {@code false} otherwise - * @throws AppSecurityException if the key hash file is not found, an I/O error occurs, - * or the hash algorithm is not available + * @throws AppSecurityException if the key verification fails */ public boolean isKeyMatchingWithStored(byte[] key) { try { @@ -145,13 +143,13 @@ public boolean isKeyMatchingWithStored(byte[] key) { * * @param password the password to authenticate with (will not be modified) * @throws AuthenticationFailedException if the password is invalid or decryption fails - * @throws AppSecurityException if an I/O error occurs during authentication + * @throws AppSecurityException if an unexpected error occurs during authentication * @see #logout() */ public void authenticate(char[] password) { try { cryptoManager.configure(context, password); - } catch (DecryptionFailedException e) { + } catch (GeneralSecurityException e) { throw new AuthenticationFailedException("Failed to authenticate, " + "invalid password or cryptographic issue"); } catch (IOException e) { @@ -187,17 +185,26 @@ public void logout() { * to prevent sensitive data leaks. * * @param newCryptoSecrets the new secrets to set and persist - * @throws AppSecurityException if encryption fails during persistence + * @throws AppSecurityException if setting the new secrets fails * @throws IllegalArgumentException if the provided secrets are invalid */ public void setSecrets(CryptoSecrets newCryptoSecrets) { try { - cryptoManager.setSecrets(context, newCryptoSecrets); - } catch (EncryptionFailedException e) { - throw new AppSecurityException("Failed to apply new secrets, encryption issue", e); + newCryptoSecrets.validate(); + } catch (IllegalStateException e) { + newCryptoSecrets.destroy(); + throw new IllegalArgumentException("Invalid new secrets", e); } - newCryptoSecrets.destroy(); + try { + cryptoManager.setSecrets(context, newCryptoSecrets); + } catch (GeneralSecurityException e) { + throw new AppSecurityException("Failed to set new secrets, encryption issue", e); + } catch (IOException e) { + throw new AppSecurityException("Failed to set new secrets, I/O issue", e); + } finally { + newCryptoSecrets.destroy(); + } } /** @@ -205,7 +212,7 @@ public void setSecrets(CryptoSecrets newCryptoSecrets) { * *

This is typically used for security purposes (e.g., after detecting suspicious activity). * - * @throws AppSecurityException if an I/O error occurs during blocking + * @throws AppSecurityException if blocking the app fails * @see #unblockApp(CryptoSecrets) * @see #isAppBlocked() */ @@ -224,8 +231,8 @@ public void blockApp() { * from the secrets must match the stored key hash. * * @param cryptoSecrets the cryptographic secrets to verify and apply before unblocking - * @throws AuthenticationFailedException if the key verification fails - * @throws AppSecurityException if an I/O error or encryption error occurs during unblocking + * @throws AuthenticationFailedException if the key is invalid + * @throws AppSecurityException if an unexpected error occurs during unblocking * @see #blockApp() */ public void unblockApp(CryptoSecrets cryptoSecrets) { @@ -235,15 +242,20 @@ public void unblockApp(CryptoSecrets cryptoSecrets) { if (!isKeyValid) { throw new AuthenticationFailedException("Failed to unblock app, invalid key"); } + } catch (NoSuchAlgorithmException e) { + throw new AppSecurityException("Failed to unblock app, hash algorithm issue", e); + } catch (IOException e) { + throw new AppSecurityException("Failed to unblock app," + + " an I/O issue has occurred during key verification", e); + } + try { cryptoManager.setSecrets(context, cryptoSecrets); cryptoManager.unblock(context); } catch (IOException e) { throw new AppSecurityException("Failed to unblock app, I/O issue", e); - } catch (EncryptionFailedException e) { + } catch (GeneralSecurityException e) { throw new AppSecurityException("Failed to unblock app, encryption issue", e); - } catch (NoSuchAlgorithmException e) { - throw new AppSecurityException("Failed to unblock app, hash issue", e); } } } diff --git a/service/src/test/java/app/notesr/service/security/AppSecurityServiceTest.java b/service/src/test/java/app/notesr/service/security/AppSecurityServiceTest.java new file mode 100644 index 00000000..ddfd6bd6 --- /dev/null +++ b/service/src/test/java/app/notesr/service/security/AppSecurityServiceTest.java @@ -0,0 +1,433 @@ +/* + * Copyright (c) 2026 zHd4 + * SPDX-License-Identifier: MIT + */ + +package app.notesr.service.security; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import android.content.Context; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InOrder; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.security.GeneralSecurityException; +import java.security.NoSuchAlgorithmException; + +import app.notesr.core.security.SecretCache; +import app.notesr.core.security.crypto.CryptoManager; +import app.notesr.core.security.crypto.CryptoManagerProvider; +import app.notesr.core.security.dto.CryptoSecrets; +import app.notesr.data.DatabaseProvider; + +@ExtendWith(MockitoExtension.class) +class AppSecurityServiceTest { + + @Mock + private Context mockContext; + + @Mock + private CryptoManager mockCryptoManager; + + private AppSecurityService appSecurityService; + + @BeforeEach + void setUp() { + appSecurityService = new AppSecurityService(mockContext, mockCryptoManager); + } + + @Test + void testConstructorWithContext() { + try (MockedStatic mockedProvider = mockStatic(CryptoManagerProvider.class)) { + mockedProvider.when(() -> CryptoManagerProvider.getInstance(mockContext)) + .thenReturn(mockCryptoManager); + + AppSecurityService service = new AppSecurityService(mockContext); + + assertNotNull(service); + mockedProvider.verify(() -> CryptoManagerProvider.getInstance(mockContext)); + } + } + + @Test + void testGetSecretsWithRandomKey() { + char[] password = "testPassword123".toCharArray(); + + CryptoSecrets secrets = appSecurityService.getSecretsWithRandomKey(password); + + assertNotNull(secrets); + assertEquals(CryptoSecrets.MASTER_KEY_SIZE, secrets.getKey().length); + } + + @Test + void testGetSecretsWithRandomKeyGeneratesDifferentKeys() { + char[] password = "testPassword123".toCharArray(); + + CryptoSecrets secrets1 = appSecurityService.getSecretsWithRandomKey(password); + CryptoSecrets secrets2 = appSecurityService.getSecretsWithRandomKey(password); + + assertNotNull(secrets1); + assertNotNull(secrets2); + assertNotEquals(secrets1.getKey(), secrets2.getKey()); + } + + @Test + void testGetActualSecrets() { + CryptoSecrets expectedSecrets = mock(CryptoSecrets.class); + when(mockCryptoManager.getSecrets()).thenReturn(expectedSecrets); + + CryptoSecrets actualSecrets = appSecurityService.getActualSecrets(); + + assertEquals(expectedSecrets, actualSecrets); + verify(mockCryptoManager).getSecrets(); + } + + @Test + void testGetActualSecretsReturnsNull() { + when(mockCryptoManager.getSecrets()).thenReturn(null); + + CryptoSecrets actualSecrets = appSecurityService.getActualSecrets(); + + assertNull(actualSecrets); + verify(mockCryptoManager).getSecrets(); + } + + @Test + void testIsAppBlockedReturnsTrue() { + when(mockCryptoManager.isBlocked(mockContext)).thenReturn(true); + + boolean isBlocked = appSecurityService.isAppBlocked(); + + assertTrue(isBlocked); + verify(mockCryptoManager).isBlocked(mockContext); + } + + @Test + void testIsAppBlockedReturnsFalse() { + when(mockCryptoManager.isBlocked(mockContext)).thenReturn(false); + + boolean isBlocked = appSecurityService.isAppBlocked(); + + assertFalse(isBlocked); + verify(mockCryptoManager).isBlocked(mockContext); + } + + @Test + void testIsAuthConfiguredReturnsTrue() { + when(mockCryptoManager.isConfigured()).thenReturn(true); + + boolean isConfigured = appSecurityService.isAuthConfigured(); + + assertTrue(isConfigured); + verify(mockCryptoManager).isConfigured(); + } + + @Test + void testIsAuthConfiguredReturnsFalse() { + when(mockCryptoManager.isConfigured()).thenReturn(false); + + boolean isConfigured = appSecurityService.isAuthConfigured(); + + assertFalse(isConfigured); + verify(mockCryptoManager).isConfigured(); + } + + @Test + void testIsKeyExistsReturnsTrue() { + when(mockCryptoManager.isKeyExists(mockContext)).thenReturn(true); + + boolean keyExists = appSecurityService.isKeyExists(); + + assertTrue(keyExists); + verify(mockCryptoManager).isKeyExists(mockContext); + } + + @Test + void testIsKeyExistsReturnsFalse() { + when(mockCryptoManager.isKeyExists(mockContext)).thenReturn(false); + + boolean keyExists = appSecurityService.isKeyExists(); + + assertFalse(keyExists); + verify(mockCryptoManager).isKeyExists(mockContext); + } + + @Test + void testIsKeyMatchingWithStoredReturnsTrue() throws Exception { + byte[] key = "testKey".getBytes(); + when(mockCryptoManager.verifyKey(mockContext, key)).thenReturn(true); + + boolean isMatching = appSecurityService.isKeyMatchingWithStored(key); + + assertTrue(isMatching); + verify(mockCryptoManager).verifyKey(mockContext, key); + } + + @Test + void testIsKeyMatchingWithStoredReturnsFalse() throws Exception { + byte[] key = "testKey".getBytes(); + when(mockCryptoManager.verifyKey(mockContext, key)).thenReturn(false); + + boolean isMatching = appSecurityService.isKeyMatchingWithStored(key); + + assertFalse(isMatching); + verify(mockCryptoManager).verifyKey(mockContext, key); + } + + @Test + void testIsKeyMatchingWithStoredThrowsAppSecurityExceptionOnFileNotFound() throws Exception { + byte[] key = "testKey".getBytes(); + when(mockCryptoManager.verifyKey(mockContext, key)) + .thenThrow(new FileNotFoundException("Key hash not found")); + + AppSecurityException exception = assertThrows(AppSecurityException.class, + () -> appSecurityService.isKeyMatchingWithStored(key)); + + assertEquals("Failed to verify key, key hash not found", exception.getMessage()); + verify(mockCryptoManager).verifyKey(mockContext, key); + } + + @Test + void testIsKeyMatchingWithStoredThrowsAppSecurityExceptionOnIOError() throws Exception { + byte[] key = "testKey".getBytes(); + when(mockCryptoManager.verifyKey(mockContext, key)) + .thenThrow(new IOException("I/O error occurred")); + + AppSecurityException exception = assertThrows(AppSecurityException.class, + () -> appSecurityService.isKeyMatchingWithStored(key)); + + assertEquals("Failed to verify key, I/O issue", exception.getMessage()); + verify(mockCryptoManager).verifyKey(mockContext, key); + } + + @Test + void testIsKeyMatchingWithStoredThrowsAppSecurityExceptionOnAlgorithmError() throws Exception { + byte[] key = "testKey".getBytes(); + when(mockCryptoManager.verifyKey(mockContext, key)) + .thenThrow(new NoSuchAlgorithmException("Algorithm not available")); + + AppSecurityException exception = assertThrows(AppSecurityException.class, + () -> appSecurityService.isKeyMatchingWithStored(key)); + + assertEquals("Failed to verify key, hash algorithm issue", exception.getMessage()); + verify(mockCryptoManager).verifyKey(mockContext, key); + } + + @Test + void testAuthenticateSuccess() throws Exception { + char[] password = "validPassword123".toCharArray(); + + appSecurityService.authenticate(password); + + verify(mockCryptoManager).configure(mockContext, password); + } + + @Test + void testAuthenticateThrowsAuthenticationFailedExceptionOnDecryptionFailure() throws Exception { + char[] password = "invalidPassword".toCharArray(); + doThrow(new GeneralSecurityException()) + .when(mockCryptoManager).configure(mockContext, password); + + AuthenticationFailedException exception = assertThrows(AuthenticationFailedException.class, + () -> appSecurityService.authenticate(password)); + + assertEquals("Failed to authenticate, invalid password or cryptographic issue", + exception.getMessage()); + verify(mockCryptoManager).configure(mockContext, password); + } + + @Test + void testAuthenticateThrowsAppSecurityExceptionOnIOError() throws Exception { + char[] password = "testPassword".toCharArray(); + doThrow(new IOException("I/O error during authentication")) + .when(mockCryptoManager).configure(mockContext, password); + + AppSecurityException exception = assertThrows(AppSecurityException.class, + () -> appSecurityService.authenticate(password)); + + assertEquals("An I/O error occurred while authenticating", exception.getMessage()); + verify(mockCryptoManager).configure(mockContext, password); + } + + @Test + void testLogoutClosesConnectionsAndClearsCaches() { + try (MockedStatic mockedDatabaseProvider = mockStatic(DatabaseProvider.class); + MockedStatic mockedSecretCache = mockStatic(SecretCache.class)) { + + appSecurityService.logout(); + + mockedDatabaseProvider.verify(DatabaseProvider::close); + verify(mockCryptoManager).destroySecrets(); + mockedSecretCache.verify(SecretCache::clear); + } + } + + @Test + void testSetSecretsSuccess() throws Exception { + CryptoSecrets newSecrets = mock(CryptoSecrets.class); + + appSecurityService.setSecrets(newSecrets); + + verify(mockCryptoManager).setSecrets(mockContext, newSecrets); + verify(newSecrets).destroy(); + } + + @Test + void testSetSecretsThrowsIllegalArgumentExceptionOnInvalidSecrets() { + CryptoSecrets newSecrets = mock(CryptoSecrets.class); + doThrow(new IllegalStateException("Invalid secrets")).when(newSecrets).validate(); + + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> appSecurityService.setSecrets(newSecrets)); + + assertEquals("Invalid new secrets", exception.getMessage()); + verify(newSecrets).validate(); + verify(newSecrets).destroy(); + } + + @Test + void testSetSecretsThrowsAppSecurityExceptionOnEncryptionFailure() throws Exception { + CryptoSecrets newSecrets = mock(CryptoSecrets.class); + doThrow(new GeneralSecurityException()) + .when(mockCryptoManager).setSecrets(mockContext, newSecrets); + + AppSecurityException exception = assertThrows(AppSecurityException.class, + () -> appSecurityService.setSecrets(newSecrets)); + + assertEquals("Failed to set new secrets, encryption issue", exception.getMessage()); + verify(mockCryptoManager).setSecrets(mockContext, newSecrets); + verify(newSecrets).validate(); + verify(newSecrets).destroy(); + } + + @Test + void testSetSecretsDestroysSecretsEvenOnException() throws Exception { + CryptoSecrets newSecrets = mock(CryptoSecrets.class); + doThrow(new GeneralSecurityException()) + .when(mockCryptoManager).setSecrets(mockContext, newSecrets); + + assertThrows(AppSecurityException.class, + () -> appSecurityService.setSecrets(newSecrets)); + + verify(newSecrets).validate(); + verify(newSecrets).destroy(); + } + + @Test + void testBlockAppSuccess() throws Exception { + appSecurityService.blockApp(); + + verify(mockCryptoManager).block(mockContext); + } + + @Test + void testBlockAppThrowsAppSecurityExceptionOnIOError() throws Exception { + doThrow(new IOException("I/O error during blocking")) + .when(mockCryptoManager).block(mockContext); + + AppSecurityException exception = assertThrows(AppSecurityException.class, + () -> appSecurityService.blockApp()); + + assertEquals("Failed to block app, I/O issue", exception.getMessage()); + verify(mockCryptoManager).block(mockContext); + } + + @Test + void testUnblockAppSuccess() throws Exception { + CryptoSecrets secrets = mock(CryptoSecrets.class); + byte[] key = "validKey".getBytes(); + when(secrets.getKey()).thenReturn(key); + when(mockCryptoManager.verifyKey(mockContext, key)).thenReturn(true); + + appSecurityService.unblockApp(secrets); + + verify(mockCryptoManager).verifyKey(mockContext, key); + verify(mockCryptoManager).setSecrets(mockContext, secrets); + verify(mockCryptoManager).unblock(mockContext); + } + + @Test + void testUnblockAppThrowsAuthenticationFailedExceptionOnInvalidKey() throws Exception { + CryptoSecrets secrets = mock(CryptoSecrets.class); + byte[] key = "invalidKey".getBytes(); + when(secrets.getKey()).thenReturn(key); + when(mockCryptoManager.verifyKey(mockContext, key)).thenReturn(false); + + AuthenticationFailedException exception = assertThrows(AuthenticationFailedException.class, + () -> appSecurityService.unblockApp(secrets)); + + assertEquals("Failed to unblock app, invalid key", exception.getMessage()); + verify(mockCryptoManager).verifyKey(mockContext, key); + verify(mockCryptoManager, never()).setSecrets(any(), any()); + verify(mockCryptoManager, never()).unblock(any()); + } + + @Test + void testUnblockAppThrowsAppSecurityExceptionOnIOError() throws Exception { + CryptoSecrets secrets = mock(CryptoSecrets.class); + byte[] key = "validKey".getBytes(); + when(secrets.getKey()).thenReturn(key); + when(mockCryptoManager.verifyKey(mockContext, key)).thenReturn(true); + doThrow(new IOException("I/O error during unblocking")) + .when(mockCryptoManager).setSecrets(mockContext, secrets); + + AppSecurityException exception = assertThrows(AppSecurityException.class, + () -> appSecurityService.unblockApp(secrets)); + + assertEquals("Failed to unblock app, I/O issue", exception.getMessage()); + } + + @Test + void testUnblockAppThrowsAppSecurityExceptionOnEncryptionError() throws Exception { + CryptoSecrets secrets = mock(CryptoSecrets.class); + byte[] key = "validKey".getBytes(); + when(secrets.getKey()).thenReturn(key); + when(mockCryptoManager.verifyKey(mockContext, key)).thenReturn(true); + doThrow(new GeneralSecurityException()) + .when(mockCryptoManager).setSecrets(mockContext, secrets); + + AppSecurityException exception = assertThrows(AppSecurityException.class, + () -> appSecurityService.unblockApp(secrets)); + + assertEquals("Failed to unblock app, encryption issue", exception.getMessage()); + } + + @Test + void testUnblockAppThrowsAppSecurityExceptionOnAlgorithmError() throws Exception { + CryptoSecrets secrets = mock(CryptoSecrets.class); + byte[] key = "validKey".getBytes(); + when(secrets.getKey()).thenReturn(key); + when(mockCryptoManager.verifyKey(mockContext, key)) + .thenThrow(new NoSuchAlgorithmException("Algorithm not available")); + + AppSecurityException exception = assertThrows(AppSecurityException.class, + () -> appSecurityService.unblockApp(secrets)); + + assertEquals("Failed to unblock app, hash algorithm issue", exception.getMessage()); + } + + @Test + void testUnblockAppVerifiesKeyBeforeUnblocking() throws Exception { + CryptoSecrets secrets = mock(CryptoSecrets.class); + byte[] key = "testKey".getBytes(); + when(secrets.getKey()).thenReturn(key); + when(mockCryptoManager.verifyKey(mockContext, key)).thenReturn(true); + + appSecurityService.unblockApp(secrets); + + InOrder inOrder = inOrder(mockCryptoManager); + inOrder.verify(mockCryptoManager).verifyKey(mockContext, key); + inOrder.verify(mockCryptoManager).setSecrets(mockContext, secrets); + inOrder.verify(mockCryptoManager).unblock(mockContext); + } +} From 74ee209bf069d530b1b8c33864cb41166c34a1de Mon Sep 17 00:00:00 2001 From: zHd4 <38856321+zHd4@users.noreply.github.com> Date: Thu, 23 Jul 2026 20:41:05 +0200 Subject: [PATCH 19/23] Update SECURE_RANDOM initialization in CryptoManagerTest to use default SecureRandom --- .../java/app/notesr/core/security/crypto/CryptoManagerTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/src/test/java/app/notesr/core/security/crypto/CryptoManagerTest.java b/core/src/test/java/app/notesr/core/security/crypto/CryptoManagerTest.java index 95974fd4..bd7517fa 100644 --- a/core/src/test/java/app/notesr/core/security/crypto/CryptoManagerTest.java +++ b/core/src/test/java/app/notesr/core/security/crypto/CryptoManagerTest.java @@ -46,7 +46,7 @@ class CryptoManagerTest { private static final int MASTER_KEY_SIZE = 48; - private static final SecureRandom SECURE_RANDOM = new SecureRandom(new byte[]{1, 2, 3}); + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); @Mock private SharedPreferences prefs; From 1a7d188784ca336c61aee64e535f946e17427c22 Mon Sep 17 00:00:00 2001 From: zHd4 <38856321+zHd4@users.noreply.github.com> Date: Thu, 23 Jul 2026 20:41:20 +0200 Subject: [PATCH 20/23] Refactor CryptoManagerTest to use default key size in generateRandomBytes method --- .../security/crypto/CryptoManagerTest.java | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/core/src/test/java/app/notesr/core/security/crypto/CryptoManagerTest.java b/core/src/test/java/app/notesr/core/security/crypto/CryptoManagerTest.java index bd7517fa..9e65626a 100644 --- a/core/src/test/java/app/notesr/core/security/crypto/CryptoManagerTest.java +++ b/core/src/test/java/app/notesr/core/security/crypto/CryptoManagerTest.java @@ -114,7 +114,7 @@ void testUnblockRemovesMarkerFile() throws IOException { @Test void testVerifyKeyReturnsTrueWhenHashesMatch() throws Exception { - byte[] key = generateRandomBytes(MASTER_KEY_SIZE); + byte[] key = generateRandomBytes(); String hash = toSha256String(key); when(prefs.getString("key_hash", null)).thenReturn(hash); @@ -127,8 +127,8 @@ void testVerifyKeyReturnsTrueWhenHashesMatch() throws Exception { @Test void testVerifyKeyReturnsFalseWhenHashesDoNotMatch() throws Exception { - byte[] key = generateRandomBytes(MASTER_KEY_SIZE); - byte[] otherKey = generateRandomBytes(MASTER_KEY_SIZE); + byte[] key = generateRandomBytes(); + byte[] otherKey = generateRandomBytes(); byte[] hash = toSha256Bytes(otherKey); when(prefs.getString("key_hash", null)).thenReturn(toSha256String(hash)); @@ -164,7 +164,7 @@ void testSetKeyHashDoubleHashingProblem() throws Exception { when(mockKeyHashFile.exists()).thenReturn(false); - byte[] key = generateRandomBytes(MASTER_KEY_SIZE); + byte[] key = generateRandomBytes(); CryptoSecrets secrets = new CryptoSecrets(key, "password".toCharArray()); cryptoManager.setSecrets(null, secrets); @@ -186,8 +186,8 @@ void testSetKeyHashDoubleHashingProblem() throws Exception { @Test void testConfigureSuccess() throws Exception { char[] password = "password".toCharArray(); - byte[] encryptedKey = generateRandomBytes(MASTER_KEY_SIZE); - byte[] decryptedKey = generateRandomBytes(MASTER_KEY_SIZE); + byte[] encryptedKey = generateRandomBytes(); + byte[] decryptedKey = generateRandomBytes(); File keyFile = mock(File.class); when(filesUtils.getInternalFile(null, "key.encrypted")).thenReturn(keyFile); @@ -211,8 +211,8 @@ void testConfigureSuccess() throws Exception { @Test void testConfigureFallbackToCbc() throws Exception { char[] password = "password".toCharArray(); - byte[] encryptedKey = generateRandomBytes(MASTER_KEY_SIZE); - byte[] decryptedKey = generateRandomBytes(MASTER_KEY_SIZE); + byte[] encryptedKey = generateRandomBytes(); + byte[] decryptedKey = generateRandomBytes(); File keyFile = mock(File.class); when(filesUtils.getInternalFile(null, "key.encrypted")).thenReturn(keyFile); @@ -239,7 +239,7 @@ void testConfigureFallbackToCbc() throws Exception { @Test void testConfigureFailure() throws Exception { char[] password = "wrong".toCharArray(); - byte[] encryptedKey = generateRandomBytes(MASTER_KEY_SIZE); + byte[] encryptedKey = generateRandomBytes(); File keyFile = mock(File.class); when(filesUtils.getInternalFile(null, "key.encrypted")).thenReturn(keyFile); @@ -312,8 +312,8 @@ void testVerifyKeyWithFileHash() throws Exception { @Test void testDestroySecrets() throws Exception { char[] password = "password".toCharArray(); - byte[] encryptedKey = generateRandomBytes(MASTER_KEY_SIZE); - byte[] decryptedKey = generateRandomBytes(MASTER_KEY_SIZE); + byte[] encryptedKey = generateRandomBytes(); + byte[] decryptedKey = generateRandomBytes(); File keyFile = mock(File.class); when(filesUtils.getInternalFile(null, "key.encrypted")).thenReturn(keyFile); @@ -370,7 +370,7 @@ void testSetSecretsSucceedsWithValidSecrets() throws Exception { when(mockKeyHashFile.exists()).thenReturn(false); - byte[] key = generateRandomBytes(MASTER_KEY_SIZE); + byte[] key = generateRandomBytes(); CryptoSecrets secrets = new CryptoSecrets(key, "password".toCharArray()); cryptoManager.setSecrets(null, secrets); @@ -379,8 +379,8 @@ void testSetSecretsSucceedsWithValidSecrets() throws Exception { "CryptoManager should be configured after setting valid secrets"); } - private byte[] generateRandomBytes(int size) { - byte[] key = new byte[size]; + private byte[] generateRandomBytes() { + byte[] key = new byte[MASTER_KEY_SIZE]; SECURE_RANDOM.nextBytes(key); return key; } From cb1a18ddd7d40f9c4f45f06016c417e1ed3c72ca Mon Sep 17 00:00:00 2001 From: zHd4 <38856321+zHd4@users.noreply.github.com> Date: Thu, 23 Jul 2026 20:41:53 +0200 Subject: [PATCH 21/23] Add null check for newCryptoSecrets in setSecrets method in AppSecurityService --- .../java/app/notesr/service/security/AppSecurityService.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/service/src/main/java/app/notesr/service/security/AppSecurityService.java b/service/src/main/java/app/notesr/service/security/AppSecurityService.java index 9fddc0fd..f124b9c8 100644 --- a/service/src/main/java/app/notesr/service/security/AppSecurityService.java +++ b/service/src/main/java/app/notesr/service/security/AppSecurityService.java @@ -189,6 +189,10 @@ public void logout() { * @throws IllegalArgumentException if the provided secrets are invalid */ public void setSecrets(CryptoSecrets newCryptoSecrets) { + if (newCryptoSecrets == null) { + throw new IllegalArgumentException("New secrets cannot be null"); + } + try { newCryptoSecrets.validate(); } catch (IllegalStateException e) { From 88210c457a93e067057d600f1b83463a3c540cbe Mon Sep 17 00:00:00 2001 From: zHd4 <38856321+zHd4@users.noreply.github.com> Date: Thu, 23 Jul 2026 20:42:17 +0200 Subject: [PATCH 22/23] Add null checks to CryptoSecrets destroy and copy methods --- .../core/security/dto/CryptoSecrets.java | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/core/src/main/java/app/notesr/core/security/dto/CryptoSecrets.java b/core/src/main/java/app/notesr/core/security/dto/CryptoSecrets.java index 5e1e7571..86bf1df2 100644 --- a/core/src/main/java/app/notesr/core/security/dto/CryptoSecrets.java +++ b/core/src/main/java/app/notesr/core/security/dto/CryptoSecrets.java @@ -39,8 +39,13 @@ public final class CryptoSecrets { * Securely clears the secrets by filling the underlying arrays with zeros. */ public void destroy() { - Arrays.fill(key, (byte) 0); - Arrays.fill(password, '\0'); + if (key != null) { + Arrays.fill(key, (byte) 0); + } + + if (password != null) { + Arrays.fill(password, '\0'); + } } /** @@ -92,7 +97,14 @@ public static CryptoSecrets from(CryptoSecrets secrets) { return null; } - return new CryptoSecrets(Arrays.copyOf(secrets.key, secrets.key.length), - Arrays.copyOf(secrets.password, secrets.password.length)); + byte[] key = secrets.key != null + ? Arrays.copyOf(secrets.key, secrets.key.length) + : null; + + char[] password = secrets.password != null + ? Arrays.copyOf(secrets.password, secrets.password.length) + : null; + + return new CryptoSecrets(key, password); } } From a75a22cac42ead6ca6e90a9141c67f83423abf77 Mon Sep 17 00:00:00 2001 From: zHd4 <38856321+zHd4@users.noreply.github.com> Date: Thu, 23 Jul 2026 20:42:35 +0200 Subject: [PATCH 23/23] Expand validation tests for setSecrets in AppSecurityServiceTest --- .../security/AppSecurityServiceTest.java | 110 ++++++++++++++++-- 1 file changed, 98 insertions(+), 12 deletions(-) diff --git a/service/src/test/java/app/notesr/service/security/AppSecurityServiceTest.java b/service/src/test/java/app/notesr/service/security/AppSecurityServiceTest.java index ddfd6bd6..f6fe7f88 100644 --- a/service/src/test/java/app/notesr/service/security/AppSecurityServiceTest.java +++ b/service/src/test/java/app/notesr/service/security/AppSecurityServiceTest.java @@ -5,9 +5,22 @@ package app.notesr.service.security; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.*; -import static org.mockito.Mockito.*; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; import android.content.Context; @@ -23,6 +36,7 @@ import java.io.IOException; import java.security.GeneralSecurityException; import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; import app.notesr.core.security.SecretCache; import app.notesr.core.security.crypto.CryptoManager; @@ -33,6 +47,9 @@ @ExtendWith(MockitoExtension.class) class AppSecurityServiceTest { + private static final int MASTER_KEY_SIZE = 48; + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + @Mock private Context mockContext; @@ -66,7 +83,7 @@ void testGetSecretsWithRandomKey() { CryptoSecrets secrets = appSecurityService.getSecretsWithRandomKey(password); assertNotNull(secrets); - assertEquals(CryptoSecrets.MASTER_KEY_SIZE, secrets.getKey().length); + assertEquals(MASTER_KEY_SIZE, secrets.getKey().length); } @Test @@ -283,16 +300,79 @@ void testSetSecretsSuccess() throws Exception { } @Test - void testSetSecretsThrowsIllegalArgumentExceptionOnInvalidSecrets() { - CryptoSecrets newSecrets = mock(CryptoSecrets.class); - doThrow(new IllegalStateException("Invalid secrets")).when(newSecrets).validate(); + void testSetSecretsThrowsWhenSecretsIsNull() { + assertThrows(IllegalArgumentException.class, () -> appSecurityService.setSecrets(null), + "setSecrets should throw IllegalArgumentException when secrets are null"); + } - IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, - () -> appSecurityService.setSecrets(newSecrets)); + @Test + void testSetSecretsThrowsWhenKeyIsNull() { + CryptoSecrets secrets = new CryptoSecrets(null, "password".toCharArray()); - assertEquals("Invalid new secrets", exception.getMessage()); - verify(newSecrets).validate(); - verify(newSecrets).destroy(); + assertThrows(IllegalArgumentException.class, () -> appSecurityService.setSecrets(secrets), + "setSecrets should throw IllegalArgumentException when key is null"); + } + + @Test + void testSetSecretsThrowsWhenKeyIsEmpty() { + CryptoSecrets secrets = new CryptoSecrets(new byte[0], "password".toCharArray()); + + assertThrows(IllegalArgumentException.class, () -> appSecurityService.setSecrets(secrets), + "setSecrets should throw IllegalArgumentException when key is empty"); + } + + @Test + void testSetSecretsThrowsWhenKeyLengthIsInvalid() { + byte[] invalidKey = generateRandomBytes(MASTER_KEY_SIZE - 1); + CryptoSecrets secrets = new CryptoSecrets(invalidKey, "password".toCharArray()); + + assertThrows(IllegalArgumentException.class, () -> appSecurityService.setSecrets(secrets), + "setSecrets should throw IllegalArgumentException when key length is invalid"); + } + + @Test + void testSetSecretsThrowsWhenPasswordIsNull() { + byte[] key = generateRandomBytes(MASTER_KEY_SIZE); + CryptoSecrets secrets = new CryptoSecrets(key, null); + + assertThrows(IllegalArgumentException.class, () -> appSecurityService.setSecrets(secrets), + "setSecrets should throw IllegalArgumentException when password is null"); + } + + @Test + void testSetSecretsThrowsWhenPasswordIsEmpty() { + byte[] key = generateRandomBytes(MASTER_KEY_SIZE); + CryptoSecrets secrets = new CryptoSecrets(key, new char[0]); + + assertThrows(IllegalArgumentException.class, () -> appSecurityService.setSecrets(secrets), + "setSecrets should throw IllegalArgumentException when password is empty"); + } + + @Test + void testSetSecretsThrowsWhenPasswordIsTooShort() { + byte[] key = generateRandomBytes(MASTER_KEY_SIZE); + CryptoSecrets secrets = new CryptoSecrets(key, "123".toCharArray()); + + assertThrows(IllegalArgumentException.class, () -> appSecurityService.setSecrets(secrets), + "setSecrets should throw IllegalArgumentException when password is too short"); + } + + @Test + void testSetSecretsThrowsWhenKeyIsAllZeros() { + byte[] key = new byte[MASTER_KEY_SIZE]; + CryptoSecrets secrets = new CryptoSecrets(key, "password".toCharArray()); + + assertThrows(IllegalArgumentException.class, () -> appSecurityService.setSecrets(secrets), + "setSecrets should throw IllegalArgumentException when key is all zeros"); + } + + @Test + void testSetSecretsThrowsWhenPasswordIsAllZeros() { + byte[] key = generateRandomBytes(MASTER_KEY_SIZE); + CryptoSecrets secrets = new CryptoSecrets(key, new char[4]); + + assertThrows(IllegalArgumentException.class, () -> appSecurityService.setSecrets(secrets), + "setSecrets should throw IllegalArgumentException when password is all zeros"); } @Test @@ -430,4 +510,10 @@ void testUnblockAppVerifiesKeyBeforeUnblocking() throws Exception { inOrder.verify(mockCryptoManager).setSecrets(mockContext, secrets); inOrder.verify(mockCryptoManager).unblock(mockContext); } + + private byte[] generateRandomBytes(int size) { + byte[] key = new byte[size]; + SECURE_RANDOM.nextBytes(key); + return key; + } }