Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package com.pentaircontrol.pool.savedpools

import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import org.junit.After
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith

/**
* Real Android Keystore suite for saved-pools vault cipher.
* Run on emulator/device (Frankie or CI with Android runtime).
*
* Exercises the corrected path: encrypt init without caller IV,
* Keystore-generated IV packed into WCAE envelope, decrypt with stored IV.
*/
@RunWith(AndroidJUnit4::class)
@SmallTest
class AndroidKeystoreVaultCipherInstrumentedTest {

private val alias = "westcurrent.saved_pools.aes.v1.instrumented"
private lateinit var cipher: AndroidKeystoreVaultCipher

@Before
fun setUp() {
cipher = AndroidKeystoreVaultCipher(keyAlias = alias)
cipher.deleteKey()
}

@After
fun tearDown() {
cipher.deleteKey()
}

@Test
fun create_encrypt_decrypt_roundTrip_usesKeystoreGeneratedIv() {
val plain = "round-trip-payload".toByteArray()
val env = cipher.encrypt(plain)
// Envelope must contain a 12-byte IV produced by Keystore (not caller-provided)
val iv = AesGcmVaultEnvelope.ivFromEnvelope(env)
assertEquals(AesGcmVaultEnvelope.IV_LENGTH, iv.size)
val out = cipher.decrypt(env)
assertArrayEquals(plain, out)
assertTrue(cipher.keyExists())
}

@Test
fun samePlaintext_encryptedTwice_differentIvAndCiphertext() {
val plain = "same-secret".toByteArray()
val a = cipher.encrypt(plain)
val b = cipher.encrypt(plain)
assertFalse(a.contentEquals(b))
assertFalse(
AesGcmVaultEnvelope.ivFromEnvelope(a)
.contentEquals(AesGcmVaultEnvelope.ivFromEnvelope(b))
)
assertArrayEquals(plain, cipher.decrypt(a))
assertArrayEquals(plain, cipher.decrypt(b))
}

@Test
fun delete_removesAlias() {
cipher.encrypt(byteArrayOf(1, 2, 3))
val result = cipher.deleteKey()
assertTrue(result is KeyDeleteResult.Deleted || result is KeyDeleteResult.AlreadyAbsent)
assertFalse(cipher.keyExists())
try {
cipher.decrypt(byteArrayOf(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20))
fail("expected missing key")
} catch (e: VaultException) {
assertEquals(VaultHealth.KEYSTORE_MISSING, e.health)
}
}

@Test
fun missingAlias_decrypt_isKeystoreMissing() {
assertFalse(cipher.keyExists())
try {
cipher.decrypt(ByteArray(40) { 1 })
fail("expected")
} catch (e: VaultException) {
assertTrue(
e.health == VaultHealth.KEYSTORE_MISSING ||
e.health == VaultHealth.CORRUPT ||
e.health == VaultHealth.LOCKED_OR_UNAVAILABLE
)
}
}

@Test
fun tamperedCiphertext_authTagFailure() {
val env = cipher.encrypt("secret-payload".toByteArray()).copyOf()
env[env.size - 1] = (env[env.size - 1].toInt() xor 0xFF).toByte()
try {
cipher.decrypt(env)
fail("expected auth failure")
} catch (e: VaultException) {
assertEquals(VaultHealth.AUTH_TAG_FAILURE, e.health)
}
}

@Test
fun encrypt_doesNotUseCallerIvPath() {
// Structural: after encrypt, envelope IV is present and decrypt works.
// Caller-provided IV encrypt is not used by AndroidKeystoreVaultCipher.
val env = cipher.encrypt("no-caller-iv".toByteArray())
val parsed = AesGcmVaultEnvelope.parse(env)
assertNotNull(parsed.iv)
assertEquals(12, parsed.iv.size)
assertArrayEquals("no-caller-iv".toByteArray(), cipher.decrypt(env))
}
}
2 changes: 2 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
<application
android:name=".PentairApp"
android:allowBackup="true"
android:fullBackupContent="@xml/backup_rules"
android:dataExtractionRules="@xml/data_extraction_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
Expand Down
41 changes: 37 additions & 4 deletions app/src/main/java/com/pentaircontrol/pool/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.pentaircontrol.pool.savedpools.AndroidKeystoreVaultCipher
import com.pentaircontrol.pool.savedpools.SimulatedAtomicVaultFileStore
import com.pentaircontrol.pool.ui.PoolApp
import com.pentaircontrol.pool.ui.PoolViewModel
import com.pentaircontrol.pool.ui.PoolViewModelFactory
Expand Down Expand Up @@ -56,6 +58,9 @@ class MainActivity : ComponentActivity() {
val canRetry by vm.canRetryConnection.collectAsStateWithLifecycle()
val canRememberUnit by vm.canRememberTemperatureUnit.collectAsStateWithLifecycle()
val savedUnit by vm.savedTemperatureUnit.collectAsStateWithLifecycle()
val savedPoolsState by vm.savedPoolsState.collectAsStateWithLifecycle()
val passwordPrompt by vm.passwordPrompt.collectAsStateWithLifecycle()
val localRecovery by vm.localRecovery.collectAsStateWithLifecycle()

PoolApp(
connection = connection,
Expand All @@ -67,9 +72,13 @@ class MainActivity : ComponentActivity() {
appearance = appearance,
onAppearanceSelected = vm::setAppearance,
onDiscover = vm::discover,
onConnect = vm::connect,
onManualConnect = vm::connectManual,
onRemoteConnect = { code, pass -> vm.connectRemote(code, pass) },
onConnect = { gw, pass, save -> vm.connect(gw, pass, save) },
onManualConnect = { ip, port, pass, save ->
vm.connectManual(ip, port, pass, save)
},
onRemoteConnect = { code, pass, save ->
vm.connectRemote(code, pass, save)
},
onSimulator = vm::connectSimulator,
onDisconnect = vm::disconnect,
onToggleCircuit = vm::toggleCircuit,
Expand All @@ -86,7 +95,31 @@ class MainActivity : ComponentActivity() {
canRememberTemperatureUnit = canRememberUnit,
canRetryConnection = canRetry,
onRetryConnection = vm::retryConnection,
onClearStatusMessage = vm::clearStatusMessage
onClearStatusMessage = vm::clearStatusMessage,
savedPoolsState = savedPoolsState,
passwordPrompt = passwordPrompt,
onConnectSavedPool = vm::connectSavedPool,
onSubmitSavedPassword = { id, pass, remember, epoch, reason ->
vm.submitSavedPoolPassword(id, pass, remember, epoch, reason)
},
onDismissPasswordPrompt = vm::dismissPasswordPrompt,
onRenameSavedPool = vm::renameSavedPool,
onDeleteSavedPool = vm::deleteSavedPool,
onSaveCurrentPool = { vm.saveCurrentConnectedPool(rememberPassword = false) },
onMarkPreferredSavedPool = vm::markPreferredSavedPool,
onForgetRememberedPassword = vm::forgetRememberedPassword,
onRetryVault = vm::refreshSavedPools,
onResetVault = vm::resetSavedPoolsVault,
localRecovery = localRecovery,
onStartLocalRecovery = vm::startLocalRecoveryDiscovery,
onConnectRecoveredGateway = { id, gw, pass, remember ->
vm.connectRecoveredGateway(id, gw, pass, remember)
},
onDismissLocalRecovery = vm::dismissLocalRecovery,
// Diagnostics only — no secrets, no "show secret" affordance.
vaultDiagnostics =
"schema=v1 · alias=${AndroidKeystoreVaultCipher.KEY_ALIAS} · " +
"file=${SimulatedAtomicVaultFileStore.VAULT_FILE_NAME} · noBackup"
)
}
}
Expand Down
Loading
Loading